diff --git a/app/plugins/builtins/mcp_server/__init__.py b/app/plugins/builtins/mcp_server/__init__.py new file mode 100644 index 0000000..9941711 --- /dev/null +++ b/app/plugins/builtins/mcp_server/__init__.py @@ -0,0 +1,5 @@ +"""MCP Server builtin plugin.""" + +from app.plugins.builtins.mcp_server.plugin import McpServerPlugin + +__all__ = ["McpServerPlugin"] diff --git a/app/plugins/builtins/mcp_server/migrations/0001_initial.sql b/app/plugins/builtins/mcp_server/migrations/0001_initial.sql new file mode 100644 index 0000000..cad0f62 --- /dev/null +++ b/app/plugins/builtins/mcp_server/migrations/0001_initial.sql @@ -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; diff --git a/app/plugins/builtins/mcp_server/plugin.py b/app/plugins/builtins/mcp_server/plugin.py new file mode 100644 index 0000000..8f000fc --- /dev/null +++ b/app/plugins/builtins/mcp_server/plugin.py @@ -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", + ], + ) diff --git a/app/plugins/builtins/mcp_server/routes.py b/app/plugins/builtins/mcp_server/routes.py new file mode 100644 index 0000000..bb9084f --- /dev/null +++ b/app/plugins/builtins/mcp_server/routes.py @@ -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(), + ) diff --git a/app/plugins/builtins/mcp_server/schemas.py b/app/plugins/builtins/mcp_server/schemas.py new file mode 100644 index 0000000..af4d266 --- /dev/null +++ b/app/plugins/builtins/mcp_server/schemas.py @@ -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 diff --git a/app/plugins/builtins/mcp_server/tool_definitions.py b/app/plugins/builtins/mcp_server/tool_definitions.py new file mode 100644 index 0000000..bb92760 --- /dev/null +++ b/app/plugins/builtins/mcp_server/tool_definitions.py @@ -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, + } diff --git a/frontend/src/api/mcp.ts b/frontend/src/api/mcp.ts new file mode 100644 index 0000000..c02582e --- /dev/null +++ b/frontend/src/api/mcp.ts @@ -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; +} + +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 { + return apiGet('/mcp/tools'); +} + +export function executeMcpTool( + toolName: string, + args: Record +): Promise { + return apiPost(`/mcp/tools/${toolName}/execute`, { + arguments: args, + }); +} + +export function fetchMcpConfig(): Promise { + return apiGet('/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; + }) => executeMcpTool(toolName, args), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['mcp'] }); + }, + }); +} diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 0618489..1a96e96 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -16,7 +16,8 @@ "users": "Benutzer", "auditLog": "Audit-Log", "settings": "Einstellungen", - "aiAssistant": "KI Assistent" + "aiAssistant": "KI Assistent", + "mcpSettings": "MCP Einstellungen" }, "auth": { "login": "Anmelden", @@ -284,7 +285,8 @@ "livePreview": "Live-Vorschau", "livePreviewDescription": "So sieht die Anwendung mit dem aktuellen Theme aus", "resetTheme": "Zurücksetzen", - "saveTheme": "Theme speichern" + "saveTheme": "Theme speichern", + "mcp": "MCP" }, "auditLog": { "title": "Audit-Log", @@ -918,5 +920,52 @@ "tab": "Tab wechseln", "settings": "Einstellungen ändern" } + }, + "mcp": { + "title": "MCP Server & Client", + "server": { + "title": "MCP Server", + "description": "LeoCRM als MCP-Server für externe KI-Clients", + "tools": "Verfügbare Tools", + "toolCount": "Anzahl Tools", + "toolName": "Tool", + "toolDescription": "Beschreibung", + "toolCategory": "Kategorie", + "toolPermission": "Berechtigung", + "execute": "Ausführen", + "executeTool": "Tool ausführen", + "arguments": "Argumente (JSON)", + "result": "Ergebnis", + "error": "Fehler", + "success": "Erfolgreich", + "config": "Server-Konfiguration", + "serverName": "Server-Name", + "serverVersion": "Version", + "protocolVersion": "Protokoll-Version", + "authMethod": "Authentifizierung", + "availableTools": "Verfügbare Tools", + "noTools": "Keine Tools verfügbar" + }, + "client": { + "title": "MCP Client", + "description": "Beschreibung", + "servers": "Server-Konfigurationen", + "addServer": "Server hinzufügen", + "editServer": "Server bearbeiten", + "deleteServer": "Server löschen", + "serverName": "Name", + "serverUrl": "URL", + "apiToken": "API-Token", + "enabled": "Aktiviert", + "lastConnected": "Zuletzt verbunden", + "noServers": "Keine Server konfiguriert", + "deleteConfirm": "Möchten Sie diesen Server wirklich löschen?", + "serverCreated": "Server erfolgreich erstellt.", + "serverUpdated": "Server erfolgreich aktualisiert.", + "serverDeleted": "Server erfolgreich gelöscht.", + "viewTools": "Tools anzeigen", + "tools": "Tools", + "noTools": "Keine Tools verfügbar" + } } -} \ No newline at end of file +} diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 22073f4..bbe8348 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -16,7 +16,8 @@ "users": "Users", "auditLog": "Audit Log", "settings": "Settings", - "aiAssistant": "AI Assistant" + "aiAssistant": "AI Assistant", + "mcpSettings": "MCP Settings" }, "auth": { "login": "Sign In", @@ -284,7 +285,8 @@ "livePreview": "Live Preview", "livePreviewDescription": "This is how the app looks with the current theme", "resetTheme": "Reset", - "saveTheme": "Save theme" + "saveTheme": "Save theme", + "mcp": "MCP" }, "auditLog": { "title": "Audit Log", @@ -918,5 +920,52 @@ "tab": "Switch tab", "settings": "Change settings" } + }, + "mcp": { + "title": "MCP Server & Client", + "server": { + "title": "MCP Server", + "description": "LeoCRM as MCP Server for external AI clients", + "tools": "Available Tools", + "toolCount": "Tool Count", + "toolName": "Tool", + "toolDescription": "Description", + "toolCategory": "Category", + "toolPermission": "Permission", + "execute": "Execute", + "executeTool": "Execute Tool", + "arguments": "Arguments (JSON)", + "result": "Result", + "error": "Error", + "success": "Success", + "config": "Server Configuration", + "serverName": "Server Name", + "serverVersion": "Version", + "protocolVersion": "Protocol Version", + "authMethod": "Authentication", + "availableTools": "Available Tools", + "noTools": "No tools available" + }, + "client": { + "title": "MCP Client", + "description": "Description", + "servers": "Server Configurations", + "addServer": "Add Server", + "editServer": "Edit Server", + "deleteServer": "Delete Server", + "serverName": "Name", + "serverUrl": "URL", + "apiToken": "API Token", + "enabled": "Enabled", + "lastConnected": "Last Connected", + "noServers": "No servers configured", + "deleteConfirm": "Are you sure you want to delete this server?", + "serverCreated": "Server created successfully.", + "serverUpdated": "Server updated successfully.", + "serverDeleted": "Server deleted successfully.", + "viewTools": "View Tools", + "tools": "Tools", + "noTools": "No tools available" + } } -} \ No newline at end of file +} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 921ce36..af19e2a 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -22,6 +22,7 @@ export function SettingsPage() { { to: '/settings/ai-proactive', label: 'Proaktive KI', icon: '\ud83e\udd16' }, { to: '/settings/notifications', label: t('settings.notifications'), icon: '\ud83d\udd14' }, { to: '/settings/theme', label: t('settings.theme', 'Theme'), icon: '\ud83c\udfa8' }, + { to: '/settings/mcp', label: t('settings.mcp', 'MCP'), icon: '\ud83d\udd27' }, ]; const existingPaths = new Set(hardcodedNavItems.map(item => item.to)); diff --git a/frontend/src/pages/SettingsMcp.tsx b/frontend/src/pages/SettingsMcp.tsx new file mode 100644 index 0000000..1f07de2 --- /dev/null +++ b/frontend/src/pages/SettingsMcp.tsx @@ -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(null); + const [selectedServerForTools, setSelectedServerForTools] = useState(null); + const [formData, setFormData] = useState({ name: '', url: '', api_token: '', enabled: true, description: '' }); + const [executeToolName, setExecuteToolName] = useState(''); + const [executeArgs, setExecuteArgs] = useState('{}'); + const [executeResult, setExecuteResult] = useState(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 ( +
+
+

{t('mcp.title')}

+
+ + {/* MCP Server Section */} +
+

{t('mcp.server.title')}

+

{t('mcp.server.description')}

+ + {config && ( +
+
{t('mcp.server.serverName')}: {config.server_name}
+
{t('mcp.server.serverVersion')}: {config.server_version}
+
{t('mcp.server.protocolVersion')}: {config.protocol_version}
+
{t('mcp.server.authMethod')}: {config.auth_method}
+
{t('mcp.server.availableTools')}: {config.available_tools.join(', ')}
+
+ )} + + {toolsLoading ? ( +
Loading...
+ ) : toolsData && toolsData.tools.length > 0 ? ( +
+ + + + + + + + + + + {toolsData.tools.map((tool) => ( + + + + + + + ))} + +
{t('mcp.server.toolName')}{t('mcp.server.toolDescription')}{t('mcp.server.toolCategory')}{t('mcp.server.toolPermission')}
{tool.name}{tool.description}{tool.category}{tool.required_permission || '-'}
+
+ ) : ( +
{t('mcp.server.noTools')}
+ )} + + {/* Tool Execution */} +
+

{t('mcp.server.executeTool')}

+
+ + 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" + /> + +
+ {executeResult && ( +
{executeResult}
+ )} +
+
+ + {/* MCP Client Section */} +
+
+

{t('mcp.client.title')}

+ +
+

{t('mcp.client.description')}

+ + {showAddForm && ( +
+
+ + setFormData({ ...formData, name: e.target.value })} className="w-full border border-secondary-300 rounded px-3 py-1.5 text-sm" /> +
+
+ + setFormData({ ...formData, url: e.target.value })} className="w-full border border-secondary-300 rounded px-3 py-1.5 text-sm" /> +
+
+ + setFormData({ ...formData, api_token: e.target.value })} className="w-full border border-secondary-300 rounded px-3 py-1.5 text-sm" /> +
+
+ + setFormData({ ...formData, description: e.target.value })} className="w-full border border-secondary-300 rounded px-3 py-1.5 text-sm" /> +
+
+ setFormData({ ...formData, enabled: e.target.checked })} /> + +
+
+ + +
+
+ )} + + {serversLoading ? ( +
Loading...
+ ) : servers && servers.length > 0 ? ( +
+ + + + + + + + + + + + {servers.map((server) => ( + + + + + + + + ))} + +
{t('mcp.client.serverName')}{t('mcp.client.serverUrl')}{t('mcp.client.enabled')}{t('mcp.client.lastConnected')}Actions
{server.name}{server.url} + + {server.enabled ? '✓' : '✗'} + + {server.last_connected_at || '-'} +
+ + + +
+
+
+ ) : ( +
{t('mcp.client.noServers')}
+ )} + + {selectedServerForTools && serverTools && ( +
+

{t('mcp.client.tools')} ({serverTools.count})

+ {serverTools.tools.length > 0 ? ( +
    + {serverTools.tools.map((tool) => ( +
  • {tool.name}: {tool.description}
  • + ))} +
+ ) : ( + {t('mcp.client.noTools')} + )} +
+ )} +
+
+ ); +} diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index 4b2265c..f96208e 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -35,6 +35,7 @@ const AIAssistantPage = React.lazy(() => import('@/pages/AIAssistant').then(m => const AISettingsPage = React.lazy(() => import('@/pages/AISettings').then(m => ({ default: m.AISettingsPage }))); const ProactiveAISettings = React.lazy(() => import('@/pages/ProactiveAISettings').then(m => ({ default: m.ProactiveAISettings }))); const SettingsThemePage = React.lazy(() => import('@/pages/SettingsTheme').then(m => ({ default: m.SettingsThemePage }))); +const SettingsMcpPage = React.lazy(() => import('@/pages/SettingsMcp').then(m => ({ default: m.SettingsMcpPage }))); const AutomationDashboardPage = React.lazy(() => import('@/pages/AutomationDashboard').then(m => ({ default: m.AutomationDashboardPage }))); const AgentDashboardPage = React.lazy(() => import('@/pages/AgentDashboard').then(m => ({ default: m.AgentDashboardPage }))); const AutomationSettingsPage = React.lazy(() => import('@/pages/AutomationSettings').then(m => ({ default: m.AutomationSettingsPage }))); @@ -107,6 +108,7 @@ const router = createBrowserRouter([ { path: 'ai-proactive', element: withSuspense() }, { path: 'theme', element: withSuspense() }, { path: 'automation', element: withSuspense() }, + { path: 'mcp', element: withSuspense() }, { path: '*', element: }, ], }, diff --git a/tests/conftest.py b/tests/conftest.py index c88fc64..a879cf4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -38,6 +38,9 @@ from app.models.user import User, UserTenant from app.models.user_preference import UserPreference # noqa: F401 from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory # noqa: F401 from app.plugins.builtins.calendar import CalendarPlugin # noqa: F401 +from app.plugins.builtins.mcp_server import McpServerPlugin # noqa: F401 +from app.plugins.builtins.mcp_client import McpClientPlugin # noqa: F401 +from app.plugins.builtins.mcp_client.models import McpServerConfig # noqa: F401 from app.plugins.builtins.calendar.models import ( # noqa: F401 Calendar, CalendarEntry, @@ -434,3 +437,61 @@ async def calendar_authed_client( assert resp.status_code == 200, f"Calendar activate failed: {resp.text}" return calendar_client, seed + + +# ─── MCP Server / Client Fixtures ─────────────────────────────────────────── + +@pytest_asyncio.fixture +async def mcp_app(engine: AsyncEngine, redis_client): + """FastAPI app with MCP Server + Client + Permissions plugins registered, installed, and activated.""" + reset_engine_for_testing(engine) + app = create_app() + + registry = reset_registry_for_testing() + registry.initialize(engine, app) + + container = get_container() + await container.initialize() + + registry.register_plugin(PermissionsPlugin()) + registry.register_plugin(McpServerPlugin()) + registry.register_plugin(McpClientPlugin()) + reset_plugin_service_for_testing(registry) + + _sf = async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession) + async with _sf() as session: + await registry.install(session, "permissions") + await registry.activate(session, "permissions") + await registry.install(session, "mcp_server") + await registry.activate(session, "mcp_server") + await registry.install(session, "mcp_client") + await registry.activate(session, "mcp_client") + await session.commit() + + yield app + await close_engine() + + +@pytest_asyncio.fixture +async def mcp_client_fixture(mcp_app) -> AsyncClient: + transport = ASGITransport(app=mcp_app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +@pytest_asyncio.fixture +async def mcp_authed_client( + mcp_client_fixture: AsyncClient, db_session: AsyncSession +) -> tuple[AsyncClient, dict]: + """Authenticated admin client with seeded data and MCP plugins activated.""" + seed = await seed_tenant_and_users(db_session) + login_resp = await mcp_client_fixture.post( + "/api/v1/auth/login", + json={"email": "admin@tenanta.com", "password": "TestPass123!"}, + headers=ORIGIN_HEADER, + ) + assert login_resp.status_code == 200, f"Login failed: {login_resp.text}" + csrf_token = login_resp.json().get("csrf_token", "") + mcp_client_fixture.headers.update({"X-CSRF-Token": csrf_token}) + + return mcp_client_fixture, seed diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py new file mode 100644 index 0000000..5e9723c --- /dev/null +++ b/tests/test_mcp_server.py @@ -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"