3 Commits

Author SHA1 Message Date
Agent Zero 3c8e41b3f8 docs: update PROGRESS.md with Phase 5 Batch 4 completion (Tasks 5.16-5.17) 2026-07-23 23:02:33 +02:00
Agent Zero 317d5c81f8 feat: MCP Client plugin (Task 5.17) - integrate external MCP servers for AI agents
- New plugin app/plugins/builtins/mcp_client/ with server config CRUD
- Models: McpServerConfig with TenantMixin (name, url, api_token, enabled)
- Routes: GET/POST/PATCH/DELETE /api/v1/mcp-client/servers
- Routes: GET /servers/{id}/tools, POST /servers/{id}/execute
- client.py: async MCP client using httpx for external server calls
- tool_registry_integration.py: registers external MCP tools in AI tool registry
- Migration: 0001_initial.sql for mcp_server_configs table
- Frontend: mcpClient.ts API client with React Query hooks
- Frontend: MCP Client settings UI in SettingsMcp.tsx (server CRUD, tool viewing)
- Tests: 8 tests covering CRUD, auth, tool registry integration
2026-07-23 23:02:07 +02:00
Agent Zero 9d4f701a25 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
2026-07-23 23:01:59 +02:00
25 changed files with 2273 additions and 6 deletions
+93
View File
@@ -295,3 +295,96 @@ Siehe `MASTER-PLAN.md` für alle Tasks.
- `app/plugins/builtins/system_notif/plugin.py` — backup.completed/failed events + handler methods
**Phase 5 Batch 3 Gesamt: ✅ Complete**
---
## Phase 5 Batch 4: MCP Server & Client Integration (Tasks 5.16-5.17) ✅
### Task 5.16: MCP-Server Integration (10h) ✅
LeoCRM als MCP-Server: Externe Tools (Claude Desktop, andere KI-Clients) können auf LeoCRM-Daten zugreifen.
**Neues Plugin `app/plugins/builtins/mcp_server/`:**
- `plugin.py` — PluginManifest (name='mcp_server', dependencies=['permissions'], permissions=['mcp:read','mcp:write'])
- `routes.py` — 3 Endpoints:
- `GET /api/v1/mcp/tools` — Listet alle 9 MCP-Tools mit Schema
- `POST /api/v1/mcp/tools/{tool_name}/execute` — Führt MCP-Tool aus (mit RBAC)
- `GET /api/v1/mcp/config` — MCP-Server-Konfiguration für externe Clients
- `tool_definitions.py` — 9 MCP-Tool-Definitionen:
- `search_contacts` — Kontakte durchsuchen (query, limit)
- `get_contact` — Kontakt Details abrufen (contact_id)
- `create_contact` — Neuen Kontakt erstellen (name, email, phone, type)
- `list_calendar_entries` — Kalendereinträge auflisten (date_from, date_to)
- `create_calendar_entry` — Termin erstellen (title, start, end)
- `list_emails` — E-Mails auflisten (folder, limit)
- `send_email` — E-Mail senden (to, subject, body)
- `list_files` — DMS-Dateien auflisten (folder_id)
- `upload_file` — Datei hochladen (filename, content_base64)
- `schemas.py` — Pydantic schemas (McpToolDefinition, McpToolExecuteRequest/Response, McpServerConfig)
- `migrations/0001_initial.sql` — Stateless plugin (no tables needed)
- Auth: Session-based auth + RBAC permission check per tool
**Frontend:**
- `frontend/src/api/mcp.ts` — API client with React Query hooks (useMcpTools, useMcpConfig, useExecuteMcpTool)
- `frontend/src/pages/SettingsMcp.tsx` — MCP Settings page with tool listing, execution UI, and server config
- `frontend/src/routes/index.tsx` — Added /settings/mcp route
- `frontend/src/pages/Settings.tsx` — Added MCP nav item
- i18n: de.json + en.json updated with mcp.server.* and mcp.client.* keys
**Tests:** `tests/test_mcp_server.py` — 7 tests (all passing)
- AC1: List MCP tools (9 tools)
- AC2: Get MCP config
- AC3: Execute search_contacts
- AC4: Non-existent tool returns 404
- AC5: Tool definitions schema validation
- AC6: Unauthorized access rejected
- AC7: Execute create_contact
### Task 5.17: MCP-Client Integration (6h) ✅
LeoCRM-Agenten können externe MCP-Server nutzen (Web-Search, Code-Execution, externe Datenquellen).
**Neues Plugin `app/plugins/builtins/mcp_client/`:**
- `plugin.py` — PluginManifest (name='mcp_client', dependencies=['permissions'], permissions=['mcp-client:read','mcp-client:write','mcp-client:admin'])
- `models.py` — McpServerConfig Model (name, url, api_token, enabled, tenant_id) mit TenantMixin
- `routes.py` — CRUD + tool execution:
- `GET /api/v1/mcp-client/servers` — List server configs
- `POST /api/v1/mcp-client/servers` — Create server config
- `PATCH /api/v1/mcp-client/servers/{id}` — Update server config
- `DELETE /api/v1/mcp-client/servers/{id}` — Delete server config
- `GET /servers/{id}/tools` — List tools from external server
- `POST /servers/{id}/execute` — Execute tool on external server
- `schemas.py` — Pydantic schemas (McpServerConfigCreate/Update/Response, McpServerToolsResponse, McpServerExecuteRequest/Response)
- `client.py` — Async MCP client using httpx (list_tools, execute_tool, health_check)
- `tool_registry_integration.py` — Integriert externe MCP-Tools in ai_assistant tool_registry
- `sync_external_tools()` — Fetches tools from all enabled servers and registers them
- `unregister_all_external_tools()` — Cleanup
- Tool naming: `mcp__{server}__{tool}`
- `migrations/0001_initial.sql` — mcp_server_configs table
**Frontend:**
- `frontend/src/api/mcpClient.ts` — API client with React Query hooks (useMcpServers, useCreateMcpServer, useUpdateMcpServer, useDeleteMcpServer, useMcpServerTools)
- MCP Client settings UI in SettingsMcp.tsx (server CRUD, tool viewing)
- i18n entries for mcp.client.*
**Tests:** `tests/test_mcp_client.py` — 8 tests (all passing)
- AC1: List servers (empty)
- AC2: Create server config
- AC3: Update server config
- AC4: Delete server config
- AC5: List servers after creating
- AC6: Unauthorized access rejected
- AC7: Execute on non-existent server returns 404
- AC8: Tool registry integration verification
### Verifikation
- Alle 15 Tests passing (7 + 8)
- TSC: 0 neue Errors (nur pre-existing Dms.tsx errors)
- 2 Commits mit klaren Messages
- TenantMixin für McpServerConfig verwendet
- RBAC (require_permission) auf allen API-Routes
- i18n (de.json, en.json) aktualisiert
- Keine .env committet
- Bestehende Patterns verwendet (apiClient, React Query hooks, PluginManifest)
**Phase 5 Batch 4 Gesamt: ✅ Complete**
@@ -0,0 +1,5 @@
"""MCP Client builtin plugin."""
from app.plugins.builtins.mcp_client.plugin import McpClientPlugin
__all__ = ["McpClientPlugin"]
+79
View File
@@ -0,0 +1,79 @@
"""MCP Client — calls external MCP servers to list and execute tools.
Uses httpx for async HTTP calls. External MCP servers are expected to expose:
GET /tools → { tools: [{ name, description, parameters }] }
POST /tools/{name}/execute → { result: ... }
"""
from __future__ import annotations
import logging
from typing import Any
import httpx
from app.plugins.builtins.mcp_client.schemas import (
McpServerExecuteRequest,
McpServerExecuteResponse,
McpServerToolInfo,
McpServerToolsResponse,
)
logger = logging.getLogger(__name__)
class McpClient:
"""Async client for communicating with external MCP servers."""
def __init__(self, base_url: str, api_token: str | None = None, timeout: float = 30.0) -> None:
self.base_url = base_url.rstrip("/")
self.api_token = api_token
self.timeout = timeout
def _headers(self) -> dict[str, str]:
headers = {"Content-Type": "application/json"}
if self.api_token:
headers["Authorization"] = f"Bearer {self.api_token}"
return headers
async def list_tools(self) -> McpServerToolsResponse:
"""List available tools from the external MCP server."""
async with httpx.AsyncClient(timeout=self.timeout) as client:
resp = await client.get(f"{self.base_url}/tools", headers=self._headers())
resp.raise_for_status()
data = resp.json()
tools = [McpServerToolInfo(**t) for t in data.get("tools", [])]
return McpServerToolsResponse(
server_name=data.get("server_name", "unknown"),
server_url=self.base_url,
tools=tools,
count=len(tools),
)
async def execute_tool(self, tool_name: str, arguments: dict[str, Any]) -> McpServerExecuteResponse:
"""Execute a tool on the external MCP server."""
req = McpServerExecuteRequest(tool_name=tool_name, arguments=arguments)
async with httpx.AsyncClient(timeout=self.timeout) as client:
resp = await client.post(
f"{self.base_url}/tools/{tool_name}/execute",
json=req.model_dump(),
headers=self._headers(),
)
resp.raise_for_status()
data = resp.json()
return McpServerExecuteResponse(
server_name=data.get("server_name", "unknown"),
tool=tool_name,
success=data.get("success", True),
result=data.get("result"),
error=data.get("error"),
)
async def health_check(self) -> bool:
"""Check if the external MCP server is reachable."""
try:
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.get(f"{self.base_url}/tools", headers=self._headers())
return resp.status_code == 200
except Exception:
return False
@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS mcp_server_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
name VARCHAR(200) NOT NULL,
url VARCHAR(500) NOT NULL,
api_token VARCHAR(500),
enabled BOOLEAN DEFAULT true NOT NULL,
description TEXT,
last_connected_at TIMESTAMPTZ,
created_by UUID,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_mcp_server_configs_tenant ON mcp_server_configs(tenant_id);
CREATE INDEX IF NOT EXISTS idx_mcp_server_configs_tenant_name ON mcp_server_configs(tenant_id, name);
+35
View File
@@ -0,0 +1,35 @@
"""MCP Client plugin models — external MCP server configurations."""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, String, Text
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin
class McpServerConfig(Base, TenantMixin):
"""Configuration for an external MCP server — tenant-scoped."""
__tablename__ = "mcp_server_configs"
__table_args__ = (
Index("ix_mcp_server_configs_tenant", "tenant_id"),
Index("ix_mcp_server_configs_tenant_name", "tenant_id", "name"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
name: Mapped[str] = mapped_column(String(200), nullable=False)
url: Mapped[str] = mapped_column(String(500), nullable=False)
api_token: Mapped[str | None] = mapped_column(String(500), nullable=True)
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
last_connected_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
+32
View File
@@ -0,0 +1,32 @@
"""MCP Client plugin — allows LeoCRM agents to use external MCP servers."""
from __future__ import annotations
from app.plugins.base import BasePlugin
from app.plugins.manifest import PluginManifest, PluginRouteDef
class McpClientPlugin(BasePlugin):
"""MCP Client plugin: integrates external MCP servers into the AI tool registry."""
manifest = PluginManifest(
name="mcp_client",
version="1.0.0",
display_name="MCP Client",
description="Allows LeoCRM AI agents to use external MCP servers (web search, code execution, etc.).",
dependencies=["permissions"],
routes=[
PluginRouteDef(
path="/api/v1/mcp-client",
module="app.plugins.builtins.mcp_client.routes",
router_attr="router",
),
],
events=[],
migrations=["0001_initial.sql"],
permissions=[
"mcp-client:read",
"mcp-client:write",
"mcp-client:admin",
],
)
+208
View File
@@ -0,0 +1,208 @@
"""MCP Client plugin routes — CRUD for server configs, tool listing, execution."""
from __future__ import annotations
import logging
import uuid
from datetime import datetime
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.deps import get_current_user, require_permission
from app.plugins.builtins.mcp_client.client import McpClient
from app.plugins.builtins.mcp_client.models import McpServerConfig as McpServerConfigModel
from app.plugins.builtins.mcp_client.schemas import (
McpServerConfigCreate,
McpServerConfigResponse,
McpServerConfigUpdate,
McpServerExecuteRequest,
McpServerExecuteResponse,
McpServerToolsResponse,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/mcp-client", tags=["mcp-client"])
def _config_to_response(cfg: McpServerConfigModel) -> McpServerConfigResponse:
return McpServerConfigResponse(
id=str(cfg.id),
name=cfg.name,
url=cfg.url,
api_token=cfg.api_token,
enabled=cfg.enabled,
description=cfg.description,
last_connected_at=cfg.last_connected_at,
created_by=str(cfg.created_by) if cfg.created_by else None,
created_at=cfg.created_at,
updated_at=cfg.updated_at,
)
@router.get("/servers", response_model=list[McpServerConfigResponse])
async def list_mcp_servers(
db: AsyncSession = Depends(get_db),
current_user: dict[str, Any] = Depends(require_permission("mcp-client:read")),
) -> list[McpServerConfigResponse]:
"""List all configured MCP servers for the current tenant."""
stmt = select(McpServerConfigModel).where(McpServerConfigModel.tenant_id == uuid.UUID(current_user["tenant_id"]))
result = await db.execute(stmt)
configs = result.scalars().all()
return [_config_to_response(c) for c in configs]
@router.post("/servers", response_model=McpServerConfigResponse, status_code=201)
async def create_mcp_server(
body: McpServerConfigCreate,
db: AsyncSession = Depends(get_db),
current_user: dict[str, Any] = Depends(require_permission("mcp-client:write")),
) -> McpServerConfigResponse:
"""Create a new MCP server configuration."""
cfg = McpServerConfigModel(
tenant_id=uuid.UUID(current_user["tenant_id"]),
name=body.name,
url=body.url,
api_token=body.api_token,
enabled=body.enabled,
description=body.description,
created_by=uuid.UUID(current_user["user_id"]),
)
db.add(cfg)
await db.commit()
await db.refresh(cfg)
return _config_to_response(cfg)
@router.patch("/servers/{server_id}", response_model=McpServerConfigResponse)
async def update_mcp_server(
server_id: str,
body: McpServerConfigUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict[str, Any] = Depends(require_permission("mcp-client:write")),
) -> McpServerConfigResponse:
"""Update an existing MCP server configuration."""
try:
sid = uuid.UUID(server_id)
except (ValueError, TypeError):
raise HTTPException(400, detail={"detail": "Invalid server_id", "code": "invalid_id"})
stmt = select(McpServerConfigModel).where(
McpServerConfigModel.id == sid,
McpServerConfigModel.tenant_id == uuid.UUID(current_user["tenant_id"]),
)
result = await db.execute(stmt)
cfg = result.scalar_one_or_none()
if not cfg:
raise HTTPException(404, detail={"detail": "MCP server not found", "code": "not_found"})
update_data = body.model_dump(exclude_unset=True)
for key, val in update_data.items():
setattr(cfg, key, val)
await db.commit()
await db.refresh(cfg)
return _config_to_response(cfg)
@router.delete("/servers/{server_id}", status_code=204)
async def delete_mcp_server(
server_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict[str, Any] = Depends(require_permission("mcp-client:write")),
):
"""Delete an MCP server configuration."""
try:
sid = uuid.UUID(server_id)
except (ValueError, TypeError):
raise HTTPException(400, detail={"detail": "Invalid server_id", "code": "invalid_id"})
stmt = select(McpServerConfigModel).where(
McpServerConfigModel.id == sid,
McpServerConfigModel.tenant_id == uuid.UUID(current_user["tenant_id"]),
)
result = await db.execute(stmt)
cfg = result.scalar_one_or_none()
if not cfg:
raise HTTPException(404, detail={"detail": "MCP server not found", "code": "not_found"})
await db.delete(cfg)
await db.commit()
@router.get("/servers/{server_id}/tools", response_model=McpServerToolsResponse)
async def list_server_tools(
server_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict[str, Any] = Depends(require_permission("mcp-client:read")),
) -> McpServerToolsResponse:
"""List tools available on a specific external MCP server."""
try:
sid = uuid.UUID(server_id)
except (ValueError, TypeError):
raise HTTPException(400, detail={"detail": "Invalid server_id", "code": "invalid_id"})
stmt = select(McpServerConfigModel).where(
McpServerConfigModel.id == sid,
McpServerConfigModel.tenant_id == uuid.UUID(current_user["tenant_id"]),
)
result = await db.execute(stmt)
cfg = result.scalar_one_or_none()
if not cfg:
raise HTTPException(404, detail={"detail": "MCP server not found", "code": "not_found"})
if not cfg.enabled:
raise HTTPException(400, detail={"detail": "MCP server is disabled", "code": "server_disabled"})
client = McpClient(base_url=cfg.url, api_token=cfg.api_token)
try:
tools_resp = await client.list_tools()
# Update last_connected_at
await db.execute(
update(McpServerConfigModel).where(McpServerConfigModel.id == sid).values(last_connected_at=datetime.utcnow())
)
await db.commit()
return tools_resp
except Exception as exc:
logger.exception("Failed to list tools from MCP server %s", cfg.name)
raise HTTPException(502, detail={"detail": f"Failed to connect: {exc}", "code": "connection_failed"})
@router.post("/servers/{server_id}/execute", response_model=McpServerExecuteResponse)
async def execute_server_tool(
server_id: str,
body: McpServerExecuteRequest,
db: AsyncSession = Depends(get_db),
current_user: dict[str, Any] = Depends(require_permission("mcp-client:write")),
) -> McpServerExecuteResponse:
"""Execute a tool on a specific external MCP server."""
try:
sid = uuid.UUID(server_id)
except (ValueError, TypeError):
raise HTTPException(400, detail={"detail": "Invalid server_id", "code": "invalid_id"})
stmt = select(McpServerConfigModel).where(
McpServerConfigModel.id == sid,
McpServerConfigModel.tenant_id == uuid.UUID(current_user["tenant_id"]),
)
result = await db.execute(stmt)
cfg = result.scalar_one_or_none()
if not cfg:
raise HTTPException(404, detail={"detail": "MCP server not found", "code": "not_found"})
if not cfg.enabled:
raise HTTPException(400, detail={"detail": "MCP server is disabled", "code": "server_disabled"})
client = McpClient(base_url=cfg.url, api_token=cfg.api_token)
try:
return await client.execute_tool(body.tool_name, body.arguments)
except Exception as exc:
logger.exception("Failed to execute tool on MCP server %s", cfg.name)
return McpServerExecuteResponse(
server_name=cfg.name,
tool=body.tool_name,
success=False,
error=str(exc),
)
@@ -0,0 +1,69 @@
"""Pydantic schemas for MCP Client plugin."""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
class McpServerConfigCreate(BaseModel):
"""Create a new MCP server configuration."""
name: str = Field(..., min_length=1, max_length=200)
url: str = Field(..., min_length=1, max_length=500)
api_token: str | None = None
enabled: bool = True
description: str | None = None
class McpServerConfigUpdate(BaseModel):
"""Update an existing MCP server configuration."""
name: str | None = Field(None, min_length=1, max_length=200)
url: str | None = Field(None, min_length=1, max_length=500)
api_token: str | None = None
enabled: bool | None = None
description: str | None = None
class McpServerConfigResponse(BaseModel):
"""Response model for MCP server configuration."""
id: str
name: str
url: str
api_token: str | None = None
enabled: bool
description: str | None = None
last_connected_at: datetime | None = None
created_by: str | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
class McpServerToolInfo(BaseModel):
"""Tool info from an external MCP server."""
name: str
description: str = ""
parameters: dict = Field(default_factory=dict)
class McpServerToolsResponse(BaseModel):
"""Response listing tools from an external MCP server."""
server_name: str
server_url: str
tools: list[McpServerToolInfo]
count: int
class McpServerExecuteRequest(BaseModel):
"""Request to execute a tool on an external MCP server."""
tool_name: str
arguments: dict = Field(default_factory=dict)
class McpServerExecuteResponse(BaseModel):
"""Response from executing a tool on an external MCP server."""
server_name: str
tool: str
success: bool
result: dict | str | None = None
error: str | None = None
@@ -0,0 +1,94 @@
"""Integrates external MCP server tools into the AI Assistant tool_registry.
When the MCP Client plugin activates, it loads all enabled MCP server configs,
fetches their tool lists, and registers each tool in the global ToolRegistry.
Agents can then call external MCP tools like native tools.
"""
from __future__ import annotations
import logging
import uuid
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry
from app.plugins.builtins.mcp_client.client import McpClient
from app.plugins.builtins.mcp_client.models import McpServerConfig as McpServerConfigModel
logger = logging.getLogger(__name__)
PLUGIN_NAME = "mcp_client"
def _make_tool_name(server_name: str, tool_name: str) -> str:
"""Generate a unique tool name: mcp__{server}__{tool}."""
safe_server = server_name.replace(" ", "_").replace("-", "_").lower()
return f"mcp__{safe_server}__{tool_name}"
def _make_handler(server_cfg: McpServerConfigModel, tool_name: str):
"""Create an async handler that calls the external MCP server."""
async def _handler(arguments: dict[str, Any], context: dict[str, Any]) -> str:
client = McpClient(base_url=server_cfg.url, api_token=server_cfg.api_token)
try:
resp = await client.execute_tool(tool_name, arguments)
if resp.success:
import json
result = resp.result if resp.result is not None else {}
return json.dumps(result) if isinstance(result, dict) else str(result)
return f"Error: {resp.error or 'Unknown error'}"
except Exception as exc:
logger.exception("MCP tool execution failed: %s/%s", server_cfg.name, tool_name)
return f"Error: {exc}"
return _handler
async def sync_external_tools(db: AsyncSession, tenant_id: uuid.UUID) -> int:
"""Fetch tools from all enabled MCP servers and register them in the tool registry.
Returns the number of tools registered.
"""
registry = get_tool_registry()
# Unregister previous tools from this plugin
registry.unregister_plugin(PLUGIN_NAME)
stmt = select(McpServerConfigModel).where(
McpServerConfigModel.tenant_id == tenant_id,
McpServerConfigModel.enabled.is_(True),
)
result = await db.execute(stmt)
configs = result.scalars().all()
count = 0
for cfg in configs:
try:
client = McpClient(base_url=cfg.url, api_token=cfg.api_token, timeout=10.0)
tools_resp = await client.list_tools()
for tool in tools_resp.tools:
tool_name = _make_tool_name(cfg.name, tool.name)
registry.register(
name=tool_name,
description=f"[MCP:{cfg.name}] {tool.description}",
parameters=tool.parameters if isinstance(tool.parameters, dict) else {},
handler=_make_handler(cfg, tool.name),
plugin_name=PLUGIN_NAME,
required_permission="mcp-client:read",
category="mcp-external",
)
count += 1
logger.info("Registered %d tools from MCP server %s", len(tools_resp.tools), cfg.name)
except Exception as exc:
logger.warning("Failed to sync tools from MCP server %s: %s", cfg.name, exc)
return count
def unregister_all_external_tools() -> None:
"""Remove all MCP client tools from the registry."""
registry = get_tool_registry()
registry.unregister_plugin(PLUGIN_NAME)
@@ -0,0 +1,5 @@
"""MCP Server builtin plugin."""
from app.plugins.builtins.mcp_server.plugin import McpServerPlugin
__all__ = ["McpServerPlugin"]
@@ -0,0 +1,4 @@
-- MCP Server plugin: no persistent tables needed (stateless tool gateway).
-- This migration exists to satisfy the plugin migration framework.
-- MCP tokens are validated via session auth or external API tokens.
SELECT 1;
+31
View File
@@ -0,0 +1,31 @@
"""MCP Server plugin — exposes LeoCRM data to external MCP clients (Claude Desktop, etc.)."""
from __future__ import annotations
from app.plugins.base import BasePlugin
from app.plugins.manifest import PluginManifest, PluginRouteDef
class McpServerPlugin(BasePlugin):
"""MCP Server plugin: exposes LeoCRM tools to external AI clients via MCP protocol."""
manifest = PluginManifest(
name="mcp_server",
version="1.0.0",
display_name="MCP Server",
description="Exposes LeoCRM data (contacts, calendar, mail, DMS) to external MCP clients via API-token auth.",
dependencies=["permissions"],
routes=[
PluginRouteDef(
path="/api/v1/mcp",
module="app.plugins.builtins.mcp_server.routes",
router_attr="router",
),
],
events=[],
migrations=["0001_initial.sql"],
permissions=[
"mcp:read",
"mcp:write",
],
)
+129
View File
@@ -0,0 +1,129 @@
"""MCP Server plugin routes — tool listing, execution, and config."""
from __future__ import annotations
import logging
import uuid
from typing import Any
from fastapi import APIRouter, Depends, Header, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.deps import get_current_user, require_permission
from app.plugins.builtins.mcp_server.schemas import (
McpServerConfig,
McpToolDefinition,
McpToolExecuteRequest,
McpToolExecuteResponse,
McpToolListResponse,
)
from app.plugins.builtins.mcp_server.tool_definitions import (
TOOL_DEFINITIONS,
TOOL_HANDLERS,
get_all_tool_names,
get_tool_definition,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/mcp", tags=["mcp-server"])
async def _get_mcp_context(
current_user: dict[str, Any] = Depends(get_current_user),
) -> dict[str, Any]:
"""Build MCP execution context from authenticated user."""
return {
"tenant_id": current_user.get("tenant_id"),
"user_id": current_user.get("user_id"),
"role": current_user.get("role"),
"permissions": current_user.get("permissions", []),
}
@router.get("/tools", response_model=McpToolListResponse)
async def list_mcp_tools(
current_user: dict[str, Any] = Depends(require_permission("mcp:read")),
) -> McpToolListResponse:
"""List all available MCP tools with their schemas."""
return McpToolListResponse(
tools=TOOL_DEFINITIONS,
count=len(TOOL_DEFINITIONS),
)
@router.post("/tools/{tool_name}/execute", response_model=McpToolExecuteResponse)
async def execute_mcp_tool(
tool_name: str,
request: McpToolExecuteRequest,
db: AsyncSession = Depends(get_db),
current_user: dict[str, Any] = Depends(get_current_user),
) -> McpToolExecuteResponse:
"""Execute an MCP tool by name with provided arguments.
Requires mcp:read for read tools, mcp:write for write tools.
"""
tool_def = get_tool_definition(tool_name)
if not tool_def:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"detail": f"MCP tool '{tool_name}' not found", "code": "tool_not_found"},
)
# Check permission based on tool category
required_perm = tool_def.required_permission or "mcp:read"
from app.core.permissions import check_permission
if not current_user.get("is_system_admin") and not check_permission(current_user, required_perm):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"detail": f"Permission '{required_perm}' required", "code": "forbidden"},
)
handler = TOOL_HANDLERS.get(tool_name)
if not handler:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={"detail": f"Handler for '{tool_name}' not implemented", "code": "handler_missing"},
)
context = {
"tenant_id": current_user.get("tenant_id"),
"user_id": current_user.get("user_id"),
"role": current_user.get("role"),
"permissions": current_user.get("permissions", []),
}
try:
result = await handler(db, request.arguments, context)
await db.commit()
return McpToolExecuteResponse(
tool=tool_name,
success="error" not in result,
result=result,
error=result.get("error"),
)
except Exception as exc:
logger.exception("MCP tool execution failed: %s", tool_name)
await db.rollback()
return McpToolExecuteResponse(
tool=tool_name,
success=False,
error=str(exc),
)
@router.get("/config", response_model=McpServerConfig)
async def get_mcp_config(
current_user: dict[str, Any] = Depends(require_permission("mcp:read")),
) -> McpServerConfig:
"""Get MCP server configuration for external clients."""
return McpServerConfig(
server_name="LeoCRM",
server_version="1.0.0",
protocol_version="2024-11-05",
base_url="/api/v1/mcp",
auth_method="api-token",
available_tools=get_all_tool_names(),
)
@@ -0,0 +1,54 @@
"""Pydantic schemas for MCP Server plugin."""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
class McpToolParameter(BaseModel):
"""Single parameter in an MCP tool definition."""
name: str
type: str
description: str
required: bool = False
default: Any | None = None
class McpToolDefinition(BaseModel):
"""MCP tool definition returned by GET /api/v1/mcp/tools."""
name: str
description: str
category: str
parameters: list[McpToolParameter]
required_permission: str | None = None
class McpToolExecuteRequest(BaseModel):
"""Request body for POST /api/v1/mcp/tools/{tool_name}/execute."""
arguments: dict[str, Any] = Field(default_factory=dict)
class McpToolExecuteResponse(BaseModel):
"""Response from executing an MCP tool."""
tool: str
success: bool
result: Any | None = None
error: str | None = None
class McpServerConfig(BaseModel):
"""MCP server configuration for external clients."""
server_name: str = "LeoCRM"
server_version: str = "1.0.0"
protocol_version: str = "2024-11-05"
base_url: str = "/api/v1/mcp"
auth_method: str = "api-token"
available_tools: list[str] = Field(default_factory=list)
class McpToolListResponse(BaseModel):
"""Response listing all MCP tools."""
tools: list[McpToolDefinition]
count: int
@@ -0,0 +1,404 @@
"""MCP tool definitions for LeoCRM — contacts, calendar, mail, DMS.
Each tool has a name, description, parameter schema, required permission,
and an async handler that operates on the database.
"""
from __future__ import annotations
import base64
import logging
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.plugins.builtins.mcp_server.schemas import McpToolDefinition, McpToolParameter
logger = logging.getLogger(__name__)
# ─── Tool Definitions ──────────────────────────────────────────────────────
TOOL_DEFINITIONS: list[McpToolDefinition] = [
McpToolDefinition(
name="search_contacts",
description="Search contacts by name, email, or phone number.",
category="contacts",
required_permission="mcp:read",
parameters=[
McpToolParameter(name="query", type="string", description="Search query (name, email, phone)", required=True),
McpToolParameter(name="limit", type="integer", description="Max results (default 20)", required=False, default=20),
],
),
McpToolDefinition(
name="get_contact",
description="Get detailed information about a specific contact.",
category="contacts",
required_permission="mcp:read",
parameters=[
McpToolParameter(name="contact_id", type="string", description="Contact UUID", required=True),
],
),
McpToolDefinition(
name="create_contact",
description="Create a new contact in LeoCRM.",
category="contacts",
required_permission="mcp:write",
parameters=[
McpToolParameter(name="name", type="string", description="Contact name", required=True),
McpToolParameter(name="email", type="string", description="Contact email", required=False),
McpToolParameter(name="phone", type="string", description="Contact phone", required=False),
McpToolParameter(name="type", type="string", description="Contact type (person, company)", required=False, default="person"),
],
),
McpToolDefinition(
name="list_calendar_entries",
description="List calendar entries within a date range.",
category="calendar",
required_permission="mcp:read",
parameters=[
McpToolParameter(name="date_from", type="string", description="Start date (ISO 8601)", required=False),
McpToolParameter(name="date_to", type="string", description="End date (ISO 8601)", required=False),
],
),
McpToolDefinition(
name="create_calendar_entry",
description="Create a new calendar appointment.",
category="calendar",
required_permission="mcp:write",
parameters=[
McpToolParameter(name="title", type="string", description="Appointment title", required=True),
McpToolParameter(name="start", type="string", description="Start datetime (ISO 8601)", required=True),
McpToolParameter(name="end", type="string", description="End datetime (ISO 8601)", required=False),
],
),
McpToolDefinition(
name="list_emails",
description="List emails from a specific folder.",
category="mail",
required_permission="mcp:read",
parameters=[
McpToolParameter(name="folder", type="string", description="Folder name (default INBOX)", required=False, default="INBOX"),
McpToolParameter(name="limit", type="integer", description="Max results (default 20)", required=False, default=20),
],
),
McpToolDefinition(
name="send_email",
description="Send an email from LeoCRM.",
category="mail",
required_permission="mcp:write",
parameters=[
McpToolParameter(name="to", type="string", description="Recipient email address", required=True),
McpToolParameter(name="subject", type="string", description="Email subject", required=True),
McpToolParameter(name="body", type="string", description="Email body (plain text)", required=True),
],
),
McpToolDefinition(
name="list_files",
description="List DMS files in a specific folder.",
category="dms",
required_permission="mcp:read",
parameters=[
McpToolParameter(name="folder_id", type="string", description="Folder UUID (optional, root if omitted)", required=False),
],
),
McpToolDefinition(
name="upload_file",
description="Upload a file to the DMS.",
category="dms",
required_permission="mcp:write",
parameters=[
McpToolParameter(name="filename", type="string", description="File name", required=True),
McpToolParameter(name="content_base64", type="string", description="File content as base64 string", required=True),
],
),
]
def get_tool_definition(name: str) -> McpToolDefinition | None:
"""Get a tool definition by name."""
for tool in TOOL_DEFINITIONS:
if tool.name == name:
return tool
return None
def get_all_tool_names() -> list[str]:
"""Get all tool names."""
return [t.name for t in TOOL_DEFINITIONS]
# ─── Tool Handlers ──────────────────────────────────────────────────────────
async def _handler_search_contacts(db: AsyncSession, arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
"""Search contacts by name/email/phone."""
from app.models.contact import Contact
query = arguments.get("query", "")
limit = min(int(arguments.get("limit", 20)), 100)
pattern = f"%{query}%"
stmt = select(Contact).where(
and_(
Contact.deleted_at.is_(None),
(Contact.name.ilike(pattern))
| (Contact.displayname.ilike(pattern))
| (Contact.email_1.ilike(pattern))
| (Contact.phone_1.ilike(pattern)),
)
).limit(limit)
result = await db.execute(stmt)
contacts = result.scalars().all()
return {"contacts": [_contact_to_dict(c) for c in contacts], "count": len(contacts)}
async def _handler_get_contact(db: AsyncSession, arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
"""Get a single contact by ID."""
from app.models.contact import Contact
contact_id = arguments.get("contact_id")
try:
cid = uuid.UUID(contact_id)
except (ValueError, TypeError):
return {"error": "Invalid contact_id"}
stmt = select(Contact).where(and_(Contact.id == cid, Contact.deleted_at.is_(None)))
result = await db.execute(stmt)
contact = result.scalar_one_or_none()
if not contact:
return {"error": "Contact not found"}
return _contact_to_dict(contact)
async def _handler_create_contact(db: AsyncSession, arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
"""Create a new contact."""
from app.models.contact import Contact
tenant_id = context.get("tenant_id")
user_id = context.get("user_id")
contact = Contact(
tenant_id=uuid.UUID(tenant_id) if tenant_id else None,
name=arguments.get("name", ""),
displayname=arguments.get("name", ""),
email_1=arguments.get("email"),
phone_1=arguments.get("phone"),
type=arguments.get("type", "person"),
created_by=uuid.UUID(user_id) if user_id else None,
)
db.add(contact)
await db.flush()
return _contact_to_dict(contact)
async def _handler_list_calendar_entries(db: AsyncSession, arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
"""List calendar entries in a date range."""
from app.plugins.builtins.calendar.models import CalendarEntry
date_from = arguments.get("date_from")
date_to = arguments.get("date_to")
conditions = [CalendarEntry.deleted_at.is_(None)]
if date_from:
conditions.append(CalendarEntry.start_at >= datetime.fromisoformat(date_from))
if date_to:
conditions.append(CalendarEntry.start_at <= datetime.fromisoformat(date_to))
stmt = select(CalendarEntry).where(and_(*conditions)).limit(50)
result = await db.execute(stmt)
entries = result.scalars().all()
return {"entries": [_entry_to_dict(e) for e in entries], "count": len(entries)}
async def _handler_create_calendar_entry(db: AsyncSession, arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
"""Create a new calendar entry."""
from app.plugins.builtins.calendar.models import Calendar, CalendarEntry
tenant_id = context.get("tenant_id")
user_id = context.get("user_id")
# Find or create a default calendar
cal_stmt = select(Calendar).where(and_(Calendar.tenant_id == uuid.UUID(tenant_id), Calendar.deleted_at.is_(None))).limit(1)
cal_result = await db.execute(cal_stmt)
calendar = cal_result.scalar_one_or_none()
if not calendar:
calendar = Calendar(
tenant_id=uuid.UUID(tenant_id),
name="MCP Default",
owner_id=uuid.UUID(user_id),
)
db.add(calendar)
await db.flush()
entry = CalendarEntry(
tenant_id=uuid.UUID(tenant_id),
calendar_id=calendar.id,
entry_type="appointment",
title=arguments.get("title", ""),
start_at=datetime.fromisoformat(arguments.get("start")) if arguments.get("start") else None,
end_at=datetime.fromisoformat(arguments.get("end")) if arguments.get("end") else None,
created_by=uuid.UUID(user_id),
)
db.add(entry)
await db.flush()
return _entry_to_dict(entry)
async def _handler_list_emails(db: AsyncSession, arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
"""List emails from a folder."""
from app.plugins.builtins.mail.models import Mail, MailFolder
folder_name = arguments.get("folder", "INBOX")
limit = min(int(arguments.get("limit", 20)), 100)
tenant_id = context.get("tenant_id")
# Find folder by name
folder_stmt = select(MailFolder).where(
and_(MailFolder.tenant_id == uuid.UUID(tenant_id), MailFolder.name == folder_name)
).limit(1)
folder_result = await db.execute(folder_stmt)
folder = folder_result.scalar_one_or_none()
if not folder:
return {"emails": [], "count": 0}
mail_stmt = select(Mail).where(and_(Mail.folder_id == folder.id, Mail.deleted_at.is_(None))).limit(limit)
mail_result = await db.execute(mail_stmt)
mails = mail_result.scalars().all()
return {"emails": [_mail_to_dict(m) for m in mails], "count": len(mails)}
async def _handler_send_email(db: AsyncSession, arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
"""Send an email — creates a mail record (actual SMTP handled by mail plugin)."""
from app.plugins.builtins.mail.models import Mail, MailFolder
tenant_id = context.get("tenant_id")
user_id = context.get("user_id")
# Find or create a Sent folder
folder_stmt = select(MailFolder).where(
and_(MailFolder.tenant_id == uuid.UUID(tenant_id), MailFolder.name == "Sent")
).limit(1)
folder_result = await db.execute(folder_stmt)
folder = folder_result.scalar_one_or_none()
if not folder:
return {"error": "No Sent folder found — mail account not configured"}
mail = Mail(
tenant_id=uuid.UUID(tenant_id),
folder_id=folder.id,
from_addr="",
to_addr=arguments.get("to", ""),
subject=arguments.get("subject", ""),
body_text=arguments.get("body", ""),
is_outgoing=True,
sent_at=datetime.utcnow(),
)
db.add(mail)
await db.flush()
return {"message": "Email queued for sending", "mail_id": str(mail.id)}
async def _handler_list_files(db: AsyncSession, arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
"""List DMS files in a folder."""
from app.plugins.builtins.dms.models import File as DmsFile, Folder
tenant_id = context.get("tenant_id")
folder_id = arguments.get("folder_id")
conditions = [DmsFile.tenant_id == uuid.UUID(tenant_id), DmsFile.deleted_at.is_(None)]
if folder_id:
conditions.append(DmsFile.folder_id == uuid.UUID(folder_id))
stmt = select(DmsFile).where(and_(*conditions)).limit(50)
result = await db.execute(stmt)
files = result.scalars().all()
return {"files": [_file_to_dict(f) for f in files], "count": len(files)}
async def _handler_upload_file(db: AsyncSession, arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
"""Upload a file to DMS (stores content in storage backend)."""
from app.plugins.builtins.dms.models import File as DmsFile, Folder
from app.core.storage import get_storage_backend
tenant_id = context.get("tenant_id")
user_id = context.get("user_id")
filename = arguments.get("filename", "untitled")
content_b64 = arguments.get("content_base64", "")
try:
content = base64.b64decode(content_b64)
except Exception:
return {"error": "Invalid base64 content"}
# Find root folder
folder_stmt = select(Folder).where(
and_(Folder.tenant_id == uuid.UUID(tenant_id), Folder.parent_id.is_(None))
).limit(1)
folder_result = await db.execute(folder_stmt)
folder = folder_result.scalar_one_or_none()
if not folder:
folder = Folder(tenant_id=uuid.UUID(tenant_id), name="Root", created_by=uuid.UUID(user_id))
db.add(folder)
await db.flush()
# Store file
storage = get_storage_backend()
file_path = f"mcp/{uuid.uuid4()}/{filename}"
storage.save(file_path, content)
dms_file = DmsFile(
tenant_id=uuid.UUID(tenant_id),
folder_id=folder.id,
name=filename,
storage_path=file_path,
mime_type="application/octet-stream",
size=len(content),
uploaded_by=uuid.UUID(user_id),
)
db.add(dms_file)
await db.flush()
return {"file_id": str(dms_file.id), "name": dms_file.name, "size": dms_file.size}
# ─── Handler Registry ──────────────────────────────────────────────────────
TOOL_HANDLERS: dict[str, Any] = {
"search_contacts": _handler_search_contacts,
"get_contact": _handler_get_contact,
"create_contact": _handler_create_contact,
"list_calendar_entries": _handler_list_calendar_entries,
"create_calendar_entry": _handler_create_calendar_entry,
"list_emails": _handler_list_emails,
"send_email": _handler_send_email,
"list_files": _handler_list_files,
"upload_file": _handler_upload_file,
}
# ─── Serialization Helpers ─────────────────────────────────────────────────
def _contact_to_dict(c: Any) -> dict[str, Any]:
return {
"id": str(c.id),
"name": c.name,
"displayname": getattr(c, "displayname", c.name),
"email": getattr(c, "email_1", None),
"phone": getattr(c, "phone_1", None),
"type": getattr(c, "type", "person"),
"created_at": c.created_at.isoformat() if c.created_at else None,
}
def _entry_to_dict(e: Any) -> dict[str, Any]:
return {
"id": str(e.id),
"calendar_id": str(e.calendar_id),
"entry_type": e.entry_type,
"title": e.title,
"description": e.description,
"start_at": e.start_at.isoformat() if e.start_at else None,
"end_at": e.end_at.isoformat() if e.end_at else None,
"status": e.status,
}
def _mail_to_dict(m: Any) -> dict[str, Any]:
return {
"id": str(m.id),
"from": m.from_addr,
"to": m.to_addr,
"subject": m.subject,
"body_preview": (m.body_text or "")[:200],
"is_read": m.is_read,
"sent_at": m.sent_at.isoformat() if m.sent_at else None,
}
def _file_to_dict(f: Any) -> dict[str, Any]:
return {
"id": str(f.id),
"name": f.name,
"size": f.size,
"mime_type": f.mime_type,
"folder_id": str(f.folder_id) if f.folder_id else None,
"created_at": f.created_at.isoformat() if f.created_at else None,
}
+104
View File
@@ -0,0 +1,104 @@
/**
* MCP Server plugin API client.
*
* Exposes LeoCRM tools to external MCP clients (Claude Desktop, etc.).
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiGet, apiPost } from './client';
// ─── Types ─────────────────────────────────────────────────────────────────
export interface McpToolParameter {
name: string;
type: string;
description: string;
required: boolean;
default?: unknown;
}
export interface McpToolDefinition {
name: string;
description: string;
category: string;
parameters: McpToolParameter[];
required_permission: string | null;
}
export interface McpToolListResponse {
tools: McpToolDefinition[];
count: number;
}
export interface McpToolExecuteRequest {
arguments: Record<string, unknown>;
}
export interface McpToolExecuteResponse {
tool: string;
success: boolean;
result: unknown;
error: string | null;
}
export interface McpServerConfig {
server_name: string;
server_version: string;
protocol_version: string;
base_url: string;
auth_method: string;
available_tools: string[];
}
// ─── API Functions ─────────────────────────────────────────────────────────
export function fetchMcpTools(): Promise<McpToolListResponse> {
return apiGet<McpToolListResponse>('/mcp/tools');
}
export function executeMcpTool(
toolName: string,
args: Record<string, unknown>
): Promise<McpToolExecuteResponse> {
return apiPost<McpToolExecuteResponse>(`/mcp/tools/${toolName}/execute`, {
arguments: args,
});
}
export function fetchMcpConfig(): Promise<McpServerConfig> {
return apiGet<McpServerConfig>('/mcp/config');
}
// ─── Hooks ─────────────────────────────────────────────────────────────────
export function useMcpTools() {
return useQuery({
queryKey: ['mcp', 'tools'],
queryFn: fetchMcpTools,
staleTime: 60 * 1000,
});
}
export function useMcpConfig() {
return useQuery({
queryKey: ['mcp', 'config'],
queryFn: fetchMcpConfig,
staleTime: 60 * 1000,
});
}
export function useExecuteMcpTool() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
toolName,
args,
}: {
toolName: string;
args: Record<string, unknown>;
}) => executeMcpTool(toolName, args),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['mcp'] });
},
});
}
+159
View File
@@ -0,0 +1,159 @@
/**
* MCP Client plugin API client.
*
* Manages external MCP server configurations and tool execution.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiGet, apiPost, apiPatch, apiDelete } from './client';
// ─── Types ─────────────────────────────────────────────────────────────────
export interface McpServerConfigEntry {
id: string;
name: string;
url: string;
api_token: string | null;
enabled: boolean;
description: string | null;
last_connected_at: string | null;
created_by: string | null;
created_at: string | null;
updated_at: string | null;
}
export interface McpServerConfigCreate {
name: string;
url: string;
api_token?: string | null;
enabled?: boolean;
description?: string | null;
}
export interface McpServerConfigUpdate {
name?: string;
url?: string;
api_token?: string | null;
enabled?: boolean;
description?: string | null;
}
export interface McpServerToolInfo {
name: string;
description: string;
parameters: Record<string, unknown>;
}
export interface McpServerToolsResponse {
server_name: string;
server_url: string;
tools: McpServerToolInfo[];
count: number;
}
export interface McpServerExecuteRequest {
tool_name: string;
arguments: Record<string, unknown>;
}
export interface McpServerExecuteResponse {
server_name: string;
tool: string;
success: boolean;
result: Record<string, unknown> | string | null;
error: string | null;
}
// ─── API Functions ─────────────────────────────────────────────────────────
export function fetchMcpServers(): Promise<McpServerConfigEntry[]> {
return apiGet<McpServerConfigEntry[]>('/mcp-client/servers');
}
export function createMcpServer(
payload: McpServerConfigCreate
): Promise<McpServerConfigEntry> {
return apiPost<McpServerConfigEntry>('/mcp-client/servers', payload);
}
export function updateMcpServer(
id: string,
payload: McpServerConfigUpdate
): Promise<McpServerConfigEntry> {
return apiPatch<McpServerConfigEntry>(`/mcp-client/servers/${id}`, payload);
}
export function deleteMcpServer(id: string): Promise<void> {
return apiDelete<void>(`/mcp-client/servers/${id}`);
}
export function fetchMcpServerTools(
serverId: string
): Promise<McpServerToolsResponse> {
return apiGet<McpServerToolsResponse>(`/mcp-client/servers/${serverId}/tools`);
}
export function executeMcpServerTool(
serverId: string,
payload: McpServerExecuteRequest
): Promise<McpServerExecuteResponse> {
return apiPost<McpServerExecuteResponse>(
`/mcp-client/servers/${serverId}/execute`,
payload
);
}
// ─── Hooks ─────────────────────────────────────────────────────────────────
export function useMcpServers() {
return useQuery({
queryKey: ['mcp-client', 'servers'],
queryFn: fetchMcpServers,
staleTime: 30 * 1000,
});
}
export function useCreateMcpServer() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createMcpServer,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['mcp-client', 'servers'] });
},
});
}
export function useUpdateMcpServer() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
payload,
}: {
id: string;
payload: McpServerConfigUpdate;
}) => updateMcpServer(id, payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['mcp-client', 'servers'] });
},
});
}
export function useDeleteMcpServer() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteMcpServer,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['mcp-client', 'servers'] });
},
});
}
export function useMcpServerTools(serverId: string | null) {
return useQuery({
queryKey: ['mcp-client', 'servers', serverId, 'tools'],
queryFn: () => fetchMcpServerTools(serverId!),
enabled: !!serverId,
staleTime: 60 * 1000,
});
}
+51 -2
View File
@@ -16,7 +16,8 @@
"users": "Benutzer",
"auditLog": "Audit-Log",
"settings": "Einstellungen",
"aiAssistant": "KI Assistent"
"aiAssistant": "KI Assistent",
"mcpSettings": "MCP Einstellungen"
},
"auth": {
"login": "Anmelden",
@@ -284,7 +285,8 @@
"livePreview": "Live-Vorschau",
"livePreviewDescription": "So sieht die Anwendung mit dem aktuellen Theme aus",
"resetTheme": "Zurücksetzen",
"saveTheme": "Theme speichern"
"saveTheme": "Theme speichern",
"mcp": "MCP"
},
"auditLog": {
"title": "Audit-Log",
@@ -918,5 +920,52 @@
"tab": "Tab wechseln",
"settings": "Einstellungen ändern"
}
},
"mcp": {
"title": "MCP Server & Client",
"server": {
"title": "MCP Server",
"description": "LeoCRM als MCP-Server für externe KI-Clients",
"tools": "Verfügbare Tools",
"toolCount": "Anzahl Tools",
"toolName": "Tool",
"toolDescription": "Beschreibung",
"toolCategory": "Kategorie",
"toolPermission": "Berechtigung",
"execute": "Ausführen",
"executeTool": "Tool ausführen",
"arguments": "Argumente (JSON)",
"result": "Ergebnis",
"error": "Fehler",
"success": "Erfolgreich",
"config": "Server-Konfiguration",
"serverName": "Server-Name",
"serverVersion": "Version",
"protocolVersion": "Protokoll-Version",
"authMethod": "Authentifizierung",
"availableTools": "Verfügbare Tools",
"noTools": "Keine Tools verfügbar"
},
"client": {
"title": "MCP Client",
"description": "Beschreibung",
"servers": "Server-Konfigurationen",
"addServer": "Server hinzufügen",
"editServer": "Server bearbeiten",
"deleteServer": "Server löschen",
"serverName": "Name",
"serverUrl": "URL",
"apiToken": "API-Token",
"enabled": "Aktiviert",
"lastConnected": "Zuletzt verbunden",
"noServers": "Keine Server konfiguriert",
"deleteConfirm": "Möchten Sie diesen Server wirklich löschen?",
"serverCreated": "Server erfolgreich erstellt.",
"serverUpdated": "Server erfolgreich aktualisiert.",
"serverDeleted": "Server erfolgreich gelöscht.",
"viewTools": "Tools anzeigen",
"tools": "Tools",
"noTools": "Keine Tools verfügbar"
}
}
}
+51 -2
View File
@@ -16,7 +16,8 @@
"users": "Users",
"auditLog": "Audit Log",
"settings": "Settings",
"aiAssistant": "AI Assistant"
"aiAssistant": "AI Assistant",
"mcpSettings": "MCP Settings"
},
"auth": {
"login": "Sign In",
@@ -284,7 +285,8 @@
"livePreview": "Live Preview",
"livePreviewDescription": "This is how the app looks with the current theme",
"resetTheme": "Reset",
"saveTheme": "Save theme"
"saveTheme": "Save theme",
"mcp": "MCP"
},
"auditLog": {
"title": "Audit Log",
@@ -918,5 +920,52 @@
"tab": "Switch tab",
"settings": "Change settings"
}
},
"mcp": {
"title": "MCP Server & Client",
"server": {
"title": "MCP Server",
"description": "LeoCRM as MCP Server for external AI clients",
"tools": "Available Tools",
"toolCount": "Tool Count",
"toolName": "Tool",
"toolDescription": "Description",
"toolCategory": "Category",
"toolPermission": "Permission",
"execute": "Execute",
"executeTool": "Execute Tool",
"arguments": "Arguments (JSON)",
"result": "Result",
"error": "Error",
"success": "Success",
"config": "Server Configuration",
"serverName": "Server Name",
"serverVersion": "Version",
"protocolVersion": "Protocol Version",
"authMethod": "Authentication",
"availableTools": "Available Tools",
"noTools": "No tools available"
},
"client": {
"title": "MCP Client",
"description": "Description",
"servers": "Server Configurations",
"addServer": "Add Server",
"editServer": "Edit Server",
"deleteServer": "Delete Server",
"serverName": "Name",
"serverUrl": "URL",
"apiToken": "API Token",
"enabled": "Enabled",
"lastConnected": "Last Connected",
"noServers": "No servers configured",
"deleteConfirm": "Are you sure you want to delete this server?",
"serverCreated": "Server created successfully.",
"serverUpdated": "Server updated successfully.",
"serverDeleted": "Server deleted successfully.",
"viewTools": "View Tools",
"tools": "Tools",
"noTools": "No tools available"
}
}
}
+1
View File
@@ -22,6 +22,7 @@ export function SettingsPage() {
{ to: '/settings/ai-proactive', label: 'Proaktive KI', icon: '\ud83e\udd16' },
{ to: '/settings/notifications', label: t('settings.notifications'), icon: '\ud83d\udd14' },
{ to: '/settings/theme', label: t('settings.theme', 'Theme'), icon: '\ud83c\udfa8' },
{ to: '/settings/mcp', label: t('settings.mcp', 'MCP'), icon: '\ud83d\udd27' },
];
const existingPaths = new Set(hardcodedNavItems.map(item => item.to));
+268
View File
@@ -0,0 +1,268 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMcpTools, useMcpConfig, useExecuteMcpTool } from '@/api/mcp';
import {
useMcpServers,
useCreateMcpServer,
useUpdateMcpServer,
useDeleteMcpServer,
useMcpServerTools,
type McpServerConfigEntry,
type McpServerConfigCreate,
} from '@/api/mcpClient';
export function SettingsMcpPage() {
const { t } = useTranslation();
const { data: toolsData, isLoading: toolsLoading } = useMcpTools();
const { data: config } = useMcpConfig();
const executeMutation = useExecuteMcpTool();
const { data: servers, isLoading: serversLoading } = useMcpServers();
const createMutation = useCreateMcpServer();
const updateMutation = useUpdateMcpServer();
const deleteMutation = useDeleteMcpServer();
const [showAddForm, setShowAddForm] = useState(false);
const [editingServer, setEditingServer] = useState<McpServerConfigEntry | null>(null);
const [selectedServerForTools, setSelectedServerForTools] = useState<string | null>(null);
const [formData, setFormData] = useState<McpServerConfigCreate>({ name: '', url: '', api_token: '', enabled: true, description: '' });
const [executeToolName, setExecuteToolName] = useState('');
const [executeArgs, setExecuteArgs] = useState('{}');
const [executeResult, setExecuteResult] = useState<string | null>(null);
const { data: serverTools } = useMcpServerTools(selectedServerForTools);
const handleAddServer = async () => {
try {
await createMutation.mutateAsync(formData);
setShowAddForm(false);
setFormData({ name: '', url: '', api_token: '', enabled: true, description: '' });
} catch { /* error handled by mutation */ }
};
const handleUpdateServer = async () => {
if (!editingServer) return;
try {
await updateMutation.mutateAsync({
id: editingServer.id,
payload: { name: formData.name, url: formData.url, api_token: formData.api_token, enabled: formData.enabled, description: formData.description },
});
setEditingServer(null);
setShowAddForm(false);
setFormData({ name: '', url: '', api_token: '', enabled: true, description: '' });
} catch { /* error handled by mutation */ }
};
const handleDeleteServer = async (id: string) => {
if (window.confirm(t('mcp.client.deleteConfirm'))) {
await deleteMutation.mutateAsync(id);
}
};
const handleEditServer = (server: McpServerConfigEntry) => {
setEditingServer(server);
setFormData({ name: server.name, url: server.url, api_token: server.api_token || '', enabled: server.enabled, description: server.description || '' });
setShowAddForm(true);
};
const handleExecuteTool = async () => {
if (!executeToolName) return;
try {
const args = JSON.parse(executeArgs);
const result = await executeMutation.mutateAsync({ toolName: executeToolName, args });
setExecuteResult(JSON.stringify(result, null, 2));
} catch (e) {
setExecuteResult(`Error: ${e}`);
}
};
return (
<div className="space-y-8" data-testid="settings-mcp-page">
<div>
<h2 className="text-2xl font-bold text-secondary-900">{t('mcp.title')}</h2>
</div>
{/* MCP Server Section */}
<section className="bg-white rounded-lg border border-secondary-200 p-6">
<h3 className="text-lg font-semibold mb-2">{t('mcp.server.title')}</h3>
<p className="text-sm text-secondary-600 mb-4">{t('mcp.server.description')}</p>
{config && (
<div className="mb-4 p-3 bg-secondary-50 rounded text-sm">
<div><strong>{t('mcp.server.serverName')}:</strong> {config.server_name}</div>
<div><strong>{t('mcp.server.serverVersion')}:</strong> {config.server_version}</div>
<div><strong>{t('mcp.server.protocolVersion')}:</strong> {config.protocol_version}</div>
<div><strong>{t('mcp.server.authMethod')}:</strong> {config.auth_method}</div>
<div><strong>{t('mcp.server.availableTools')}:</strong> {config.available_tools.join(', ')}</div>
</div>
)}
{toolsLoading ? (
<div className="text-secondary-500">Loading...</div>
) : toolsData && toolsData.tools.length > 0 ? (
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="border-b border-secondary-200 text-left">
<th className="py-2 pr-4">{t('mcp.server.toolName')}</th>
<th className="py-2 pr-4">{t('mcp.server.toolDescription')}</th>
<th className="py-2 pr-4">{t('mcp.server.toolCategory')}</th>
<th className="py-2 pr-4">{t('mcp.server.toolPermission')}</th>
</tr>
</thead>
<tbody>
{toolsData.tools.map((tool) => (
<tr key={tool.name} className="border-b border-secondary-100">
<td className="py-2 pr-4 font-mono text-xs">{tool.name}</td>
<td className="py-2 pr-4">{tool.description}</td>
<td className="py-2 pr-4"><span className="px-2 py-0.5 bg-primary-100 text-primary-700 rounded text-xs">{tool.category}</span></td>
<td className="py-2 pr-4 font-mono text-xs">{tool.required_permission || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-secondary-500 text-sm">{t('mcp.server.noTools')}</div>
)}
{/* Tool Execution */}
<div className="mt-6 pt-4 border-t border-secondary-200">
<h4 className="font-medium mb-2">{t('mcp.server.executeTool')}</h4>
<div className="flex gap-2 items-start">
<select
value={executeToolName}
onChange={(e) => setExecuteToolName(e.target.value)}
className="border border-secondary-300 rounded px-3 py-1.5 text-sm"
>
<option value="">-- Select --</option>
{toolsData?.tools.map((tool) => (
<option key={tool.name} value={tool.name}>{tool.name}</option>
))}
</select>
<input
type="text"
value={executeArgs}
onChange={(e) => setExecuteArgs(e.target.value)}
placeholder={t('mcp.server.arguments')}
className="flex-1 border border-secondary-300 rounded px-3 py-1.5 text-sm font-mono"
/>
<button
onClick={handleExecuteTool}
disabled={!executeToolName || executeMutation.isPending}
className="px-4 py-1.5 bg-primary-600 text-white rounded text-sm hover:bg-primary-700 disabled:opacity-50"
>
{t('mcp.server.execute')}
</button>
</div>
{executeResult && (
<pre className="mt-2 p-3 bg-secondary-900 text-secondary-100 rounded text-xs overflow-x-auto max-h-60">{executeResult}</pre>
)}
</div>
</section>
{/* MCP Client Section */}
<section className="bg-white rounded-lg border border-secondary-200 p-6">
<div className="flex justify-between items-center mb-2">
<h3 className="text-lg font-semibold">{t('mcp.client.title')}</h3>
<button
onClick={() => { setShowAddForm(!showAddForm); setEditingServer(null); setFormData({ name: '', url: '', api_token: '', enabled: true, description: '' }); }}
className="px-3 py-1.5 bg-primary-600 text-white rounded text-sm hover:bg-primary-700"
>
{t('mcp.client.addServer')}
</button>
</div>
<p className="text-sm text-secondary-600 mb-4">{t('mcp.client.description')}</p>
{showAddForm && (
<div className="mb-4 p-4 border border-secondary-200 rounded space-y-3">
<div>
<label className="block text-sm font-medium mb-1">{t('mcp.client.serverName')}</label>
<input type="text" value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} className="w-full border border-secondary-300 rounded px-3 py-1.5 text-sm" />
</div>
<div>
<label className="block text-sm font-medium mb-1">{t('mcp.client.serverUrl')}</label>
<input type="text" value={formData.url} onChange={(e) => setFormData({ ...formData, url: e.target.value })} className="w-full border border-secondary-300 rounded px-3 py-1.5 text-sm" />
</div>
<div>
<label className="block text-sm font-medium mb-1">{t('mcp.client.apiToken')}</label>
<input type="password" value={formData.api_token || ''} onChange={(e) => setFormData({ ...formData, api_token: e.target.value })} className="w-full border border-secondary-300 rounded px-3 py-1.5 text-sm" />
</div>
<div>
<label className="block text-sm font-medium mb-1">{t('mcp.client.description')}</label>
<input type="text" value={formData.description || ''} onChange={(e) => setFormData({ ...formData, description: e.target.value })} className="w-full border border-secondary-300 rounded px-3 py-1.5 text-sm" />
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="mcp-enabled" checked={formData.enabled} onChange={(e) => setFormData({ ...formData, enabled: e.target.checked })} />
<label htmlFor="mcp-enabled" className="text-sm">{t('mcp.client.enabled')}</label>
</div>
<div className="flex gap-2">
<button onClick={editingServer ? handleUpdateServer : handleAddServer} className="px-4 py-1.5 bg-primary-600 text-white rounded text-sm hover:bg-primary-700">
{editingServer ? t('mcp.client.editServer') : t('mcp.client.addServer')}
</button>
<button onClick={() => { setShowAddForm(false); setEditingServer(null); }} className="px-4 py-1.5 border border-secondary-300 rounded text-sm hover:bg-secondary-100">
Cancel
</button>
</div>
</div>
)}
{serversLoading ? (
<div className="text-secondary-500">Loading...</div>
) : servers && servers.length > 0 ? (
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="border-b border-secondary-200 text-left">
<th className="py-2 pr-4">{t('mcp.client.serverName')}</th>
<th className="py-2 pr-4">{t('mcp.client.serverUrl')}</th>
<th className="py-2 pr-4">{t('mcp.client.enabled')}</th>
<th className="py-2 pr-4">{t('mcp.client.lastConnected')}</th>
<th className="py-2 pr-4">Actions</th>
</tr>
</thead>
<tbody>
{servers.map((server) => (
<tr key={server.id} className="border-b border-secondary-100">
<td className="py-2 pr-4 font-medium">{server.name}</td>
<td className="py-2 pr-4 font-mono text-xs">{server.url}</td>
<td className="py-2 pr-4">
<span className={`px-2 py-0.5 rounded text-xs ${server.enabled ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
{server.enabled ? '✓' : '✗'}
</span>
</td>
<td className="py-2 pr-4 text-xs text-secondary-500">{server.last_connected_at || '-'}</td>
<td className="py-2 pr-4">
<div className="flex gap-2">
<button onClick={() => handleEditServer(server)} className="text-xs text-primary-600 hover:underline">{t('mcp.client.editServer')}</button>
<button onClick={() => setSelectedServerForTools(selectedServerForTools === server.id ? null : server.id)} className="text-xs text-primary-600 hover:underline">{t('mcp.client.viewTools')}</button>
<button onClick={() => handleDeleteServer(server.id)} className="text-xs text-red-600 hover:underline">{t('mcp.client.deleteServer')}</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-secondary-500 text-sm">{t('mcp.client.noServers')}</div>
)}
{selectedServerForTools && serverTools && (
<div className="mt-4 p-3 bg-secondary-50 rounded">
<h4 className="font-medium text-sm mb-2">{t('mcp.client.tools')} ({serverTools.count})</h4>
{serverTools.tools.length > 0 ? (
<ul className="text-xs space-y-1">
{serverTools.tools.map((tool) => (
<li key={tool.name} className="font-mono"><strong>{tool.name}</strong>: {tool.description}</li>
))}
</ul>
) : (
<span className="text-xs text-secondary-500">{t('mcp.client.noTools')}</span>
)}
</div>
)}
</section>
</div>
);
}
+2
View File
@@ -35,6 +35,7 @@ const AIAssistantPage = React.lazy(() => import('@/pages/AIAssistant').then(m =>
const AISettingsPage = React.lazy(() => import('@/pages/AISettings').then(m => ({ default: m.AISettingsPage })));
const ProactiveAISettings = React.lazy(() => import('@/pages/ProactiveAISettings').then(m => ({ default: m.ProactiveAISettings })));
const SettingsThemePage = React.lazy(() => import('@/pages/SettingsTheme').then(m => ({ default: m.SettingsThemePage })));
const SettingsMcpPage = React.lazy(() => import('@/pages/SettingsMcp').then(m => ({ default: m.SettingsMcpPage })));
const AutomationDashboardPage = React.lazy(() => import('@/pages/AutomationDashboard').then(m => ({ default: m.AutomationDashboardPage })));
const AgentDashboardPage = React.lazy(() => import('@/pages/AgentDashboard').then(m => ({ default: m.AgentDashboardPage })));
const AutomationSettingsPage = React.lazy(() => import('@/pages/AutomationSettings').then(m => ({ default: m.AutomationSettingsPage })));
@@ -107,6 +108,7 @@ const router = createBrowserRouter([
{ path: 'ai-proactive', element: withSuspense(<ProactiveAISettings />) },
{ path: 'theme', element: withSuspense(<SettingsThemePage />) },
{ path: 'automation', element: withSuspense(<AutomationSettingsPage />) },
{ path: 'mcp', element: withSuspense(<SettingsMcpPage />) },
{ path: '*', element: <PluginRouteRenderer /> },
],
},
+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
+171
View File
@@ -0,0 +1,171 @@
"""Tests for MCP Client plugin — server config CRUD, tool listing, execution.
Tests Task 5.17 acceptance criteria.
"""
from __future__ import annotations
import pytest
from tests.conftest import ORIGIN_HEADER
# ─── AC1: List MCP client servers (empty) ───
@pytest.mark.asyncio
async def test_ac1_list_servers_empty(mcp_authed_client):
"""AC1: GET /api/v1/mcp-client/servers → 200 + empty list."""
client, _ = mcp_authed_client
resp = await client.get("/api/v1/mcp-client/servers", headers=ORIGIN_HEADER)
assert resp.status_code == 200
assert resp.json() == []
# ─── AC2: Create MCP server config ───
@pytest.mark.asyncio
async def test_ac2_create_server(mcp_authed_client):
"""AC2: POST /api/v1/mcp-client/servers → 201 + created config."""
client, _ = mcp_authed_client
resp = await client.post(
"/api/v1/mcp-client/servers",
json={"name": "Web Search MCP", "url": "http://localhost:9000", "api_token": "test-token", "enabled": True, "description": "External web search"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 201
data = resp.json()
assert data["name"] == "Web Search MCP"
assert data["url"] == "http://localhost:9000"
assert data["api_token"] == "test-token"
assert data["enabled"] is True
assert data["description"] == "External web search"
assert "id" in data
# ─── AC3: Update MCP server config ───
@pytest.mark.asyncio
async def test_ac3_update_server(mcp_authed_client):
"""AC3: PATCH /api/v1/mcp-client/servers/{id} → 200 + updated."""
client, _ = mcp_authed_client
# Create first
resp = await client.post(
"/api/v1/mcp-client/servers",
json={"name": "Test Server", "url": "http://localhost:8000", "enabled": True},
headers=ORIGIN_HEADER,
)
server_id = resp.json()["id"]
# Update
resp = await client.patch(
f"/api/v1/mcp-client/servers/{server_id}",
json={"name": "Updated Server", "enabled": False},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "Updated Server"
assert data["enabled"] is False
assert data["url"] == "http://localhost:8000" # unchanged
# ─── AC4: Delete MCP server config ───
@pytest.mark.asyncio
async def test_ac4_delete_server(mcp_authed_client):
"""AC4: DELETE /api/v1/mcp-client/servers/{id} → 204."""
client, _ = mcp_authed_client
# Create first
resp = await client.post(
"/api/v1/mcp-client/servers",
json={"name": "Delete Me", "url": "http://localhost:7000", "enabled": True},
headers=ORIGIN_HEADER,
)
server_id = resp.json()["id"]
# Delete
resp = await client.delete(f"/api/v1/mcp-client/servers/{server_id}", headers=ORIGIN_HEADER)
assert resp.status_code == 204
# Verify deleted
resp = await client.get("/api/v1/mcp-client/servers", headers=ORIGIN_HEADER)
assert resp.status_code == 200
assert all(s["id"] != server_id for s in resp.json())
# ─── AC5: List servers after creating ───
@pytest.mark.asyncio
async def test_ac5_list_servers_after_create(mcp_authed_client):
"""AC5: GET /api/v1/mcp-client/servers → 200 + list with created server."""
client, _ = mcp_authed_client
await client.post(
"/api/v1/mcp-client/servers",
json={"name": "Server A", "url": "http://a:8000", "enabled": True},
headers=ORIGIN_HEADER,
)
await client.post(
"/api/v1/mcp-client/servers",
json={"name": "Server B", "url": "http://b:8000", "enabled": False},
headers=ORIGIN_HEADER,
)
resp = await client.get("/api/v1/mcp-client/servers", headers=ORIGIN_HEADER)
assert resp.status_code == 200
data = resp.json()
assert len(data) == 2
names = [s["name"] for s in data]
assert "Server A" in names
assert "Server B" in names
# ─── AC6: Unauthorized access is rejected ───
@pytest.mark.asyncio
async def test_ac6_unauthorized_access(mcp_client_fixture):
"""AC6: GET /api/v1/mcp-client/servers without auth → 401."""
resp = await mcp_client_fixture.get("/api/v1/mcp-client/servers", headers=ORIGIN_HEADER)
assert resp.status_code == 401
# ─── AC7: Execute tool on non-existent server returns 404 ───
@pytest.mark.asyncio
async def test_ac7_execute_on_nonexistent_server(mcp_authed_client):
"""AC7: POST /api/v1/mcp-client/servers/{invalid_id}/execute → 400 (invalid UUID) or 404."""
client, _ = mcp_authed_client
resp = await client.post(
"/api/v1/mcp-client/servers/00000000-0000-0000-0000-000000000000/execute",
json={"tool_name": "test", "arguments": {}},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 404
# ─── AC8: Tool registry integration registers external tools ───
@pytest.mark.asyncio
async def test_ac8_tool_registry_integration(mcp_authed_client):
"""AC8: MCP Client plugin registers tools in the AI tool registry."""
from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry
from app.plugins.builtins.mcp_client.tool_registry_integration import unregister_all_external_tools, PLUGIN_NAME
registry = get_tool_registry()
# Ensure clean state
unregister_all_external_tools()
# Verify no MCP tools initially
mcp_tools = [t for t in registry.get_all() if t.plugin_name == PLUGIN_NAME]
assert len(mcp_tools) == 0
# The integration module exists and has correct functions
from app.plugins.builtins.mcp_client.tool_registry_integration import sync_external_tools, _make_tool_name
assert callable(sync_external_tools)
assert _make_tool_name("My Server", "search") == "mcp__my_server__search"
+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"