Compare commits
3 Commits
f4beb78f91
...
3c8e41b3f8
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c8e41b3f8 | |||
| 317d5c81f8 | |||
| 9d4f701a25 |
+93
@@ -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
|
- `app/plugins/builtins/system_notif/plugin.py` — backup.completed/failed events + handler methods
|
||||||
|
|
||||||
**Phase 5 Batch 3 Gesamt: ✅ Complete**
|
**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"]
|
||||||
@@ -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);
|
||||||
@@ -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)
|
||||||
@@ -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",
|
||||||
|
],
|
||||||
|
)
|
||||||
@@ -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;
|
||||||
@@ -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",
|
||||||
|
],
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -16,7 +16,8 @@
|
|||||||
"users": "Benutzer",
|
"users": "Benutzer",
|
||||||
"auditLog": "Audit-Log",
|
"auditLog": "Audit-Log",
|
||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen",
|
||||||
"aiAssistant": "KI Assistent"
|
"aiAssistant": "KI Assistent",
|
||||||
|
"mcpSettings": "MCP Einstellungen"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"login": "Anmelden",
|
"login": "Anmelden",
|
||||||
@@ -284,7 +285,8 @@
|
|||||||
"livePreview": "Live-Vorschau",
|
"livePreview": "Live-Vorschau",
|
||||||
"livePreviewDescription": "So sieht die Anwendung mit dem aktuellen Theme aus",
|
"livePreviewDescription": "So sieht die Anwendung mit dem aktuellen Theme aus",
|
||||||
"resetTheme": "Zurücksetzen",
|
"resetTheme": "Zurücksetzen",
|
||||||
"saveTheme": "Theme speichern"
|
"saveTheme": "Theme speichern",
|
||||||
|
"mcp": "MCP"
|
||||||
},
|
},
|
||||||
"auditLog": {
|
"auditLog": {
|
||||||
"title": "Audit-Log",
|
"title": "Audit-Log",
|
||||||
@@ -918,5 +920,52 @@
|
|||||||
"tab": "Tab wechseln",
|
"tab": "Tab wechseln",
|
||||||
"settings": "Einstellungen ändern"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,8 @@
|
|||||||
"users": "Users",
|
"users": "Users",
|
||||||
"auditLog": "Audit Log",
|
"auditLog": "Audit Log",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"aiAssistant": "AI Assistant"
|
"aiAssistant": "AI Assistant",
|
||||||
|
"mcpSettings": "MCP Settings"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"login": "Sign In",
|
"login": "Sign In",
|
||||||
@@ -284,7 +285,8 @@
|
|||||||
"livePreview": "Live Preview",
|
"livePreview": "Live Preview",
|
||||||
"livePreviewDescription": "This is how the app looks with the current theme",
|
"livePreviewDescription": "This is how the app looks with the current theme",
|
||||||
"resetTheme": "Reset",
|
"resetTheme": "Reset",
|
||||||
"saveTheme": "Save theme"
|
"saveTheme": "Save theme",
|
||||||
|
"mcp": "MCP"
|
||||||
},
|
},
|
||||||
"auditLog": {
|
"auditLog": {
|
||||||
"title": "Audit Log",
|
"title": "Audit Log",
|
||||||
@@ -918,5 +920,52 @@
|
|||||||
"tab": "Switch tab",
|
"tab": "Switch tab",
|
||||||
"settings": "Change settings"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export function SettingsPage() {
|
|||||||
{ to: '/settings/ai-proactive', label: 'Proaktive KI', icon: '\ud83e\udd16' },
|
{ to: '/settings/ai-proactive', label: 'Proaktive KI', icon: '\ud83e\udd16' },
|
||||||
{ to: '/settings/notifications', label: t('settings.notifications'), icon: '\ud83d\udd14' },
|
{ to: '/settings/notifications', label: t('settings.notifications'), icon: '\ud83d\udd14' },
|
||||||
{ to: '/settings/theme', label: t('settings.theme', 'Theme'), icon: '\ud83c\udfa8' },
|
{ 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));
|
const existingPaths = new Set(hardcodedNavItems.map(item => item.to));
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 AISettingsPage = React.lazy(() => import('@/pages/AISettings').then(m => ({ default: m.AISettingsPage })));
|
||||||
const ProactiveAISettings = React.lazy(() => import('@/pages/ProactiveAISettings').then(m => ({ default: m.ProactiveAISettings })));
|
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 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 AutomationDashboardPage = React.lazy(() => import('@/pages/AutomationDashboard').then(m => ({ default: m.AutomationDashboardPage })));
|
||||||
const AgentDashboardPage = React.lazy(() => import('@/pages/AgentDashboard').then(m => ({ default: m.AgentDashboardPage })));
|
const AgentDashboardPage = React.lazy(() => import('@/pages/AgentDashboard').then(m => ({ default: m.AgentDashboardPage })));
|
||||||
const AutomationSettingsPage = React.lazy(() => import('@/pages/AutomationSettings').then(m => ({ default: m.AutomationSettingsPage })));
|
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: 'ai-proactive', element: withSuspense(<ProactiveAISettings />) },
|
||||||
{ path: 'theme', element: withSuspense(<SettingsThemePage />) },
|
{ path: 'theme', element: withSuspense(<SettingsThemePage />) },
|
||||||
{ path: 'automation', element: withSuspense(<AutomationSettingsPage />) },
|
{ path: 'automation', element: withSuspense(<AutomationSettingsPage />) },
|
||||||
|
{ path: 'mcp', element: withSuspense(<SettingsMcpPage />) },
|
||||||
{ path: '*', element: <PluginRouteRenderer /> },
|
{ path: '*', element: <PluginRouteRenderer /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ from app.models.user import User, UserTenant
|
|||||||
from app.models.user_preference import UserPreference # noqa: F401
|
from app.models.user_preference import UserPreference # noqa: F401
|
||||||
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory # 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.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
|
from app.plugins.builtins.calendar.models import ( # noqa: F401
|
||||||
Calendar,
|
Calendar,
|
||||||
CalendarEntry,
|
CalendarEntry,
|
||||||
@@ -434,3 +437,61 @@ async def calendar_authed_client(
|
|||||||
assert resp.status_code == 200, f"Calendar activate failed: {resp.text}"
|
assert resp.status_code == 200, f"Calendar activate failed: {resp.text}"
|
||||||
|
|
||||||
return calendar_client, seed
|
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,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"
|
||||||
@@ -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