feat: MCP Server plugin (Task 5.16) - expose LeoCRM tools to external AI clients

- New plugin app/plugins/builtins/mcp_server/ with 9 MCP tools
- Tools: search_contacts, get_contact, create_contact, list_calendar_entries,
  create_calendar_entry, list_emails, send_email, list_files, upload_file
- Routes: GET /api/v1/mcp/tools, POST /api/v1/mcp/tools/{name}/execute, GET /api/v1/mcp/config
- API-token auth via session + RBAC (mcp:read, mcp:write)
- Frontend: mcp.ts API client with React Query hooks
- Frontend: SettingsMcp.tsx settings page with tool listing and execution
- i18n: de.json and en.json updated with MCP entries
- Tests: 7 tests covering tool listing, config, execution, auth, schema validation
This commit is contained in:
Agent Zero
2026-07-23 23:01:59 +02:00
parent f4beb78f91
commit 9d4f701a25
14 changed files with 1313 additions and 6 deletions
+61
View File
@@ -38,6 +38,9 @@ from app.models.user import User, UserTenant
from app.models.user_preference import UserPreference # noqa: F401
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory # noqa: F401
from app.plugins.builtins.calendar import CalendarPlugin # noqa: F401
from app.plugins.builtins.mcp_server import McpServerPlugin # noqa: F401
from app.plugins.builtins.mcp_client import McpClientPlugin # noqa: F401
from app.plugins.builtins.mcp_client.models import McpServerConfig # noqa: F401
from app.plugins.builtins.calendar.models import ( # noqa: F401
Calendar,
CalendarEntry,
@@ -434,3 +437,61 @@ async def calendar_authed_client(
assert resp.status_code == 200, f"Calendar activate failed: {resp.text}"
return calendar_client, seed
# ─── MCP Server / Client Fixtures ───────────────────────────────────────────
@pytest_asyncio.fixture
async def mcp_app(engine: AsyncEngine, redis_client):
"""FastAPI app with MCP Server + Client + Permissions plugins registered, installed, and activated."""
reset_engine_for_testing(engine)
app = create_app()
registry = reset_registry_for_testing()
registry.initialize(engine, app)
container = get_container()
await container.initialize()
registry.register_plugin(PermissionsPlugin())
registry.register_plugin(McpServerPlugin())
registry.register_plugin(McpClientPlugin())
reset_plugin_service_for_testing(registry)
_sf = async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession)
async with _sf() as session:
await registry.install(session, "permissions")
await registry.activate(session, "permissions")
await registry.install(session, "mcp_server")
await registry.activate(session, "mcp_server")
await registry.install(session, "mcp_client")
await registry.activate(session, "mcp_client")
await session.commit()
yield app
await close_engine()
@pytest_asyncio.fixture
async def mcp_client_fixture(mcp_app) -> AsyncClient:
transport = ASGITransport(app=mcp_app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
@pytest_asyncio.fixture
async def mcp_authed_client(
mcp_client_fixture: AsyncClient, db_session: AsyncSession
) -> tuple[AsyncClient, dict]:
"""Authenticated admin client with seeded data and MCP plugins activated."""
seed = await seed_tenant_and_users(db_session)
login_resp = await mcp_client_fixture.post(
"/api/v1/auth/login",
json={"email": "admin@tenanta.com", "password": "TestPass123!"},
headers=ORIGIN_HEADER,
)
assert login_resp.status_code == 200, f"Login failed: {login_resp.text}"
csrf_token = login_resp.json().get("csrf_token", "")
mcp_client_fixture.headers.update({"X-CSRF-Token": csrf_token})
return mcp_client_fixture, seed
+146
View File
@@ -0,0 +1,146 @@
"""Tests for MCP Server plugin — tool listing, execution, config.
Tests Task 5.16 acceptance criteria.
"""
from __future__ import annotations
import pytest
from tests.conftest import ORIGIN_HEADER
# ─── AC1: List MCP tools ───
@pytest.mark.asyncio
async def test_ac1_list_mcp_tools(mcp_authed_client):
"""AC1: GET /api/v1/mcp/tools → 200 + tool list with 9 tools."""
client, _ = mcp_authed_client
resp = await client.get("/api/v1/mcp/tools", headers=ORIGIN_HEADER)
assert resp.status_code == 200
data = resp.json()
assert data["count"] == 9
assert len(data["tools"]) == 9
tool_names = [t["name"] for t in data["tools"]]
assert "search_contacts" in tool_names
assert "get_contact" in tool_names
assert "create_contact" in tool_names
assert "list_calendar_entries" in tool_names
assert "create_calendar_entry" in tool_names
assert "list_emails" in tool_names
assert "send_email" in tool_names
assert "list_files" in tool_names
assert "upload_file" in tool_names
# ─── AC2: Get MCP config ───
@pytest.mark.asyncio
async def test_ac2_get_mcp_config(mcp_authed_client):
"""AC2: GET /api/v1/mcp/config → 200 + server config."""
client, _ = mcp_authed_client
resp = await client.get("/api/v1/mcp/config", headers=ORIGIN_HEADER)
assert resp.status_code == 200
data = resp.json()
assert data["server_name"] == "LeoCRM"
assert data["server_version"] == "1.0.0"
assert data["protocol_version"] == "2024-11-05"
assert data["auth_method"] == "api-token"
assert "search_contacts" in data["available_tools"]
assert len(data["available_tools"]) == 9
# ─── AC3: Execute search_contacts tool ───
@pytest.mark.asyncio
async def test_ac3_execute_search_contacts(mcp_authed_client):
"""AC3: POST /api/v1/mcp/tools/search_contacts/execute → 200 + results."""
client, _ = mcp_authed_client
resp = await client.post(
"/api/v1/mcp/tools/search_contacts/execute",
json={"arguments": {"query": "Admin", "limit": 10}},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
data = resp.json()
assert data["tool"] == "search_contacts"
assert data["success"] is True
assert "result" in data
assert "contacts" in data["result"]
# ─── AC4: Execute non-existent tool returns 404 ───
@pytest.mark.asyncio
async def test_ac4_execute_nonexistent_tool(mcp_authed_client):
"""AC4: POST /api/v1/mcp/tools/nonexistent/execute → 404."""
client, _ = mcp_authed_client
resp = await client.post(
"/api/v1/mcp/tools/nonexistent_tool/execute",
json={"arguments": {}},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 404
assert "tool_not_found" in resp.text
# ─── AC5: Tool definitions have correct schema ───
@pytest.mark.asyncio
async def test_ac5_tool_definitions_schema(mcp_authed_client):
"""AC5: GET /api/v1/mcp/tools → tools have proper schema with parameters."""
client, _ = mcp_authed_client
resp = await client.get("/api/v1/mcp/tools", headers=ORIGIN_HEADER)
assert resp.status_code == 200
tools = resp.json()["tools"]
# Check search_contacts has query and limit params
search_tool = next(t for t in tools if t["name"] == "search_contacts")
param_names = [p["name"] for p in search_tool["parameters"]]
assert "query" in param_names
assert "limit" in param_names
query_param = next(p for p in search_tool["parameters"] if p["name"] == "query")
assert query_param["required"] is True
# Check create_contact has name, email, phone, type params
create_tool = next(t for t in tools if t["name"] == "create_contact")
create_params = [p["name"] for p in create_tool["parameters"]]
assert "name" in create_params
assert "email" in create_params
assert "phone" in create_params
assert "type" in create_params
# ─── AC6: Unauthorized access is rejected ───
@pytest.mark.asyncio
async def test_ac6_unauthorized_access(mcp_client_fixture):
"""AC6: GET /api/v1/mcp/tools without auth → 401."""
resp = await mcp_client_fixture.get("/api/v1/mcp/tools", headers=ORIGIN_HEADER)
assert resp.status_code == 401
# ─── AC7: Execute create_contact tool ───
@pytest.mark.asyncio
async def test_ac7_execute_create_contact(mcp_authed_client):
"""AC7: POST /api/v1/mcp/tools/create_contact/execute → 200 + created contact."""
client, _ = mcp_authed_client
resp = await client.post(
"/api/v1/mcp/tools/create_contact/execute",
json={"arguments": {"name": "MCP Test Contact", "email": "mcp@test.com", "phone": "+49123456789", "type": "person"}},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
data = resp.json()
assert data["tool"] == "create_contact"
assert data["success"] is True
assert data["result"]["name"] == "MCP Test Contact"
assert data["result"]["email"] == "mcp@test.com"