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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user