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

- New plugin app/plugins/builtins/mcp_server/ with 9 MCP tools
- Tools: search_contacts, get_contact, create_contact, list_calendar_entries,
  create_calendar_entry, list_emails, send_email, list_files, upload_file
- Routes: GET /api/v1/mcp/tools, POST /api/v1/mcp/tools/{name}/execute, GET /api/v1/mcp/config
- API-token auth via session + RBAC (mcp:read, mcp:write)
- Frontend: mcp.ts API client with React Query hooks
- Frontend: SettingsMcp.tsx settings page with tool listing and execution
- i18n: de.json and en.json updated with MCP entries
- Tests: 7 tests covering tool listing, config, execution, auth, schema validation
This commit is contained in:
Agent Zero
2026-07-23 23:01:59 +02:00
parent f4beb78f91
commit 9d4f701a25
14 changed files with 1313 additions and 6 deletions
@@ -0,0 +1,5 @@
"""MCP Server builtin plugin."""
from app.plugins.builtins.mcp_server.plugin import McpServerPlugin
__all__ = ["McpServerPlugin"]
@@ -0,0 +1,4 @@
-- MCP Server plugin: no persistent tables needed (stateless tool gateway).
-- This migration exists to satisfy the plugin migration framework.
-- MCP tokens are validated via session auth or external API tokens.
SELECT 1;
+31
View File
@@ -0,0 +1,31 @@
"""MCP Server plugin — exposes LeoCRM data to external MCP clients (Claude Desktop, etc.)."""
from __future__ import annotations
from app.plugins.base import BasePlugin
from app.plugins.manifest import PluginManifest, PluginRouteDef
class McpServerPlugin(BasePlugin):
"""MCP Server plugin: exposes LeoCRM tools to external AI clients via MCP protocol."""
manifest = PluginManifest(
name="mcp_server",
version="1.0.0",
display_name="MCP Server",
description="Exposes LeoCRM data (contacts, calendar, mail, DMS) to external MCP clients via API-token auth.",
dependencies=["permissions"],
routes=[
PluginRouteDef(
path="/api/v1/mcp",
module="app.plugins.builtins.mcp_server.routes",
router_attr="router",
),
],
events=[],
migrations=["0001_initial.sql"],
permissions=[
"mcp:read",
"mcp:write",
],
)
+129
View File
@@ -0,0 +1,129 @@
"""MCP Server plugin routes — tool listing, execution, and config."""
from __future__ import annotations
import logging
import uuid
from typing import Any
from fastapi import APIRouter, Depends, Header, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.deps import get_current_user, require_permission
from app.plugins.builtins.mcp_server.schemas import (
McpServerConfig,
McpToolDefinition,
McpToolExecuteRequest,
McpToolExecuteResponse,
McpToolListResponse,
)
from app.plugins.builtins.mcp_server.tool_definitions import (
TOOL_DEFINITIONS,
TOOL_HANDLERS,
get_all_tool_names,
get_tool_definition,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/mcp", tags=["mcp-server"])
async def _get_mcp_context(
current_user: dict[str, Any] = Depends(get_current_user),
) -> dict[str, Any]:
"""Build MCP execution context from authenticated user."""
return {
"tenant_id": current_user.get("tenant_id"),
"user_id": current_user.get("user_id"),
"role": current_user.get("role"),
"permissions": current_user.get("permissions", []),
}
@router.get("/tools", response_model=McpToolListResponse)
async def list_mcp_tools(
current_user: dict[str, Any] = Depends(require_permission("mcp:read")),
) -> McpToolListResponse:
"""List all available MCP tools with their schemas."""
return McpToolListResponse(
tools=TOOL_DEFINITIONS,
count=len(TOOL_DEFINITIONS),
)
@router.post("/tools/{tool_name}/execute", response_model=McpToolExecuteResponse)
async def execute_mcp_tool(
tool_name: str,
request: McpToolExecuteRequest,
db: AsyncSession = Depends(get_db),
current_user: dict[str, Any] = Depends(get_current_user),
) -> McpToolExecuteResponse:
"""Execute an MCP tool by name with provided arguments.
Requires mcp:read for read tools, mcp:write for write tools.
"""
tool_def = get_tool_definition(tool_name)
if not tool_def:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"detail": f"MCP tool '{tool_name}' not found", "code": "tool_not_found"},
)
# Check permission based on tool category
required_perm = tool_def.required_permission or "mcp:read"
from app.core.permissions import check_permission
if not current_user.get("is_system_admin") and not check_permission(current_user, required_perm):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"detail": f"Permission '{required_perm}' required", "code": "forbidden"},
)
handler = TOOL_HANDLERS.get(tool_name)
if not handler:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={"detail": f"Handler for '{tool_name}' not implemented", "code": "handler_missing"},
)
context = {
"tenant_id": current_user.get("tenant_id"),
"user_id": current_user.get("user_id"),
"role": current_user.get("role"),
"permissions": current_user.get("permissions", []),
}
try:
result = await handler(db, request.arguments, context)
await db.commit()
return McpToolExecuteResponse(
tool=tool_name,
success="error" not in result,
result=result,
error=result.get("error"),
)
except Exception as exc:
logger.exception("MCP tool execution failed: %s", tool_name)
await db.rollback()
return McpToolExecuteResponse(
tool=tool_name,
success=False,
error=str(exc),
)
@router.get("/config", response_model=McpServerConfig)
async def get_mcp_config(
current_user: dict[str, Any] = Depends(require_permission("mcp:read")),
) -> McpServerConfig:
"""Get MCP server configuration for external clients."""
return McpServerConfig(
server_name="LeoCRM",
server_version="1.0.0",
protocol_version="2024-11-05",
base_url="/api/v1/mcp",
auth_method="api-token",
available_tools=get_all_tool_names(),
)
@@ -0,0 +1,54 @@
"""Pydantic schemas for MCP Server plugin."""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
class McpToolParameter(BaseModel):
"""Single parameter in an MCP tool definition."""
name: str
type: str
description: str
required: bool = False
default: Any | None = None
class McpToolDefinition(BaseModel):
"""MCP tool definition returned by GET /api/v1/mcp/tools."""
name: str
description: str
category: str
parameters: list[McpToolParameter]
required_permission: str | None = None
class McpToolExecuteRequest(BaseModel):
"""Request body for POST /api/v1/mcp/tools/{tool_name}/execute."""
arguments: dict[str, Any] = Field(default_factory=dict)
class McpToolExecuteResponse(BaseModel):
"""Response from executing an MCP tool."""
tool: str
success: bool
result: Any | None = None
error: str | None = None
class McpServerConfig(BaseModel):
"""MCP server configuration for external clients."""
server_name: str = "LeoCRM"
server_version: str = "1.0.0"
protocol_version: str = "2024-11-05"
base_url: str = "/api/v1/mcp"
auth_method: str = "api-token"
available_tools: list[str] = Field(default_factory=list)
class McpToolListResponse(BaseModel):
"""Response listing all MCP tools."""
tools: list[McpToolDefinition]
count: int
@@ -0,0 +1,404 @@
"""MCP tool definitions for LeoCRM — contacts, calendar, mail, DMS.
Each tool has a name, description, parameter schema, required permission,
and an async handler that operates on the database.
"""
from __future__ import annotations
import base64
import logging
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.plugins.builtins.mcp_server.schemas import McpToolDefinition, McpToolParameter
logger = logging.getLogger(__name__)
# ─── Tool Definitions ──────────────────────────────────────────────────────
TOOL_DEFINITIONS: list[McpToolDefinition] = [
McpToolDefinition(
name="search_contacts",
description="Search contacts by name, email, or phone number.",
category="contacts",
required_permission="mcp:read",
parameters=[
McpToolParameter(name="query", type="string", description="Search query (name, email, phone)", required=True),
McpToolParameter(name="limit", type="integer", description="Max results (default 20)", required=False, default=20),
],
),
McpToolDefinition(
name="get_contact",
description="Get detailed information about a specific contact.",
category="contacts",
required_permission="mcp:read",
parameters=[
McpToolParameter(name="contact_id", type="string", description="Contact UUID", required=True),
],
),
McpToolDefinition(
name="create_contact",
description="Create a new contact in LeoCRM.",
category="contacts",
required_permission="mcp:write",
parameters=[
McpToolParameter(name="name", type="string", description="Contact name", required=True),
McpToolParameter(name="email", type="string", description="Contact email", required=False),
McpToolParameter(name="phone", type="string", description="Contact phone", required=False),
McpToolParameter(name="type", type="string", description="Contact type (person, company)", required=False, default="person"),
],
),
McpToolDefinition(
name="list_calendar_entries",
description="List calendar entries within a date range.",
category="calendar",
required_permission="mcp:read",
parameters=[
McpToolParameter(name="date_from", type="string", description="Start date (ISO 8601)", required=False),
McpToolParameter(name="date_to", type="string", description="End date (ISO 8601)", required=False),
],
),
McpToolDefinition(
name="create_calendar_entry",
description="Create a new calendar appointment.",
category="calendar",
required_permission="mcp:write",
parameters=[
McpToolParameter(name="title", type="string", description="Appointment title", required=True),
McpToolParameter(name="start", type="string", description="Start datetime (ISO 8601)", required=True),
McpToolParameter(name="end", type="string", description="End datetime (ISO 8601)", required=False),
],
),
McpToolDefinition(
name="list_emails",
description="List emails from a specific folder.",
category="mail",
required_permission="mcp:read",
parameters=[
McpToolParameter(name="folder", type="string", description="Folder name (default INBOX)", required=False, default="INBOX"),
McpToolParameter(name="limit", type="integer", description="Max results (default 20)", required=False, default=20),
],
),
McpToolDefinition(
name="send_email",
description="Send an email from LeoCRM.",
category="mail",
required_permission="mcp:write",
parameters=[
McpToolParameter(name="to", type="string", description="Recipient email address", required=True),
McpToolParameter(name="subject", type="string", description="Email subject", required=True),
McpToolParameter(name="body", type="string", description="Email body (plain text)", required=True),
],
),
McpToolDefinition(
name="list_files",
description="List DMS files in a specific folder.",
category="dms",
required_permission="mcp:read",
parameters=[
McpToolParameter(name="folder_id", type="string", description="Folder UUID (optional, root if omitted)", required=False),
],
),
McpToolDefinition(
name="upload_file",
description="Upload a file to the DMS.",
category="dms",
required_permission="mcp:write",
parameters=[
McpToolParameter(name="filename", type="string", description="File name", required=True),
McpToolParameter(name="content_base64", type="string", description="File content as base64 string", required=True),
],
),
]
def get_tool_definition(name: str) -> McpToolDefinition | None:
"""Get a tool definition by name."""
for tool in TOOL_DEFINITIONS:
if tool.name == name:
return tool
return None
def get_all_tool_names() -> list[str]:
"""Get all tool names."""
return [t.name for t in TOOL_DEFINITIONS]
# ─── Tool Handlers ──────────────────────────────────────────────────────────
async def _handler_search_contacts(db: AsyncSession, arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
"""Search contacts by name/email/phone."""
from app.models.contact import Contact
query = arguments.get("query", "")
limit = min(int(arguments.get("limit", 20)), 100)
pattern = f"%{query}%"
stmt = select(Contact).where(
and_(
Contact.deleted_at.is_(None),
(Contact.name.ilike(pattern))
| (Contact.displayname.ilike(pattern))
| (Contact.email_1.ilike(pattern))
| (Contact.phone_1.ilike(pattern)),
)
).limit(limit)
result = await db.execute(stmt)
contacts = result.scalars().all()
return {"contacts": [_contact_to_dict(c) for c in contacts], "count": len(contacts)}
async def _handler_get_contact(db: AsyncSession, arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
"""Get a single contact by ID."""
from app.models.contact import Contact
contact_id = arguments.get("contact_id")
try:
cid = uuid.UUID(contact_id)
except (ValueError, TypeError):
return {"error": "Invalid contact_id"}
stmt = select(Contact).where(and_(Contact.id == cid, Contact.deleted_at.is_(None)))
result = await db.execute(stmt)
contact = result.scalar_one_or_none()
if not contact:
return {"error": "Contact not found"}
return _contact_to_dict(contact)
async def _handler_create_contact(db: AsyncSession, arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
"""Create a new contact."""
from app.models.contact import Contact
tenant_id = context.get("tenant_id")
user_id = context.get("user_id")
contact = Contact(
tenant_id=uuid.UUID(tenant_id) if tenant_id else None,
name=arguments.get("name", ""),
displayname=arguments.get("name", ""),
email_1=arguments.get("email"),
phone_1=arguments.get("phone"),
type=arguments.get("type", "person"),
created_by=uuid.UUID(user_id) if user_id else None,
)
db.add(contact)
await db.flush()
return _contact_to_dict(contact)
async def _handler_list_calendar_entries(db: AsyncSession, arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
"""List calendar entries in a date range."""
from app.plugins.builtins.calendar.models import CalendarEntry
date_from = arguments.get("date_from")
date_to = arguments.get("date_to")
conditions = [CalendarEntry.deleted_at.is_(None)]
if date_from:
conditions.append(CalendarEntry.start_at >= datetime.fromisoformat(date_from))
if date_to:
conditions.append(CalendarEntry.start_at <= datetime.fromisoformat(date_to))
stmt = select(CalendarEntry).where(and_(*conditions)).limit(50)
result = await db.execute(stmt)
entries = result.scalars().all()
return {"entries": [_entry_to_dict(e) for e in entries], "count": len(entries)}
async def _handler_create_calendar_entry(db: AsyncSession, arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
"""Create a new calendar entry."""
from app.plugins.builtins.calendar.models import Calendar, CalendarEntry
tenant_id = context.get("tenant_id")
user_id = context.get("user_id")
# Find or create a default calendar
cal_stmt = select(Calendar).where(and_(Calendar.tenant_id == uuid.UUID(tenant_id), Calendar.deleted_at.is_(None))).limit(1)
cal_result = await db.execute(cal_stmt)
calendar = cal_result.scalar_one_or_none()
if not calendar:
calendar = Calendar(
tenant_id=uuid.UUID(tenant_id),
name="MCP Default",
owner_id=uuid.UUID(user_id),
)
db.add(calendar)
await db.flush()
entry = CalendarEntry(
tenant_id=uuid.UUID(tenant_id),
calendar_id=calendar.id,
entry_type="appointment",
title=arguments.get("title", ""),
start_at=datetime.fromisoformat(arguments.get("start")) if arguments.get("start") else None,
end_at=datetime.fromisoformat(arguments.get("end")) if arguments.get("end") else None,
created_by=uuid.UUID(user_id),
)
db.add(entry)
await db.flush()
return _entry_to_dict(entry)
async def _handler_list_emails(db: AsyncSession, arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
"""List emails from a folder."""
from app.plugins.builtins.mail.models import Mail, MailFolder
folder_name = arguments.get("folder", "INBOX")
limit = min(int(arguments.get("limit", 20)), 100)
tenant_id = context.get("tenant_id")
# Find folder by name
folder_stmt = select(MailFolder).where(
and_(MailFolder.tenant_id == uuid.UUID(tenant_id), MailFolder.name == folder_name)
).limit(1)
folder_result = await db.execute(folder_stmt)
folder = folder_result.scalar_one_or_none()
if not folder:
return {"emails": [], "count": 0}
mail_stmt = select(Mail).where(and_(Mail.folder_id == folder.id, Mail.deleted_at.is_(None))).limit(limit)
mail_result = await db.execute(mail_stmt)
mails = mail_result.scalars().all()
return {"emails": [_mail_to_dict(m) for m in mails], "count": len(mails)}
async def _handler_send_email(db: AsyncSession, arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
"""Send an email — creates a mail record (actual SMTP handled by mail plugin)."""
from app.plugins.builtins.mail.models import Mail, MailFolder
tenant_id = context.get("tenant_id")
user_id = context.get("user_id")
# Find or create a Sent folder
folder_stmt = select(MailFolder).where(
and_(MailFolder.tenant_id == uuid.UUID(tenant_id), MailFolder.name == "Sent")
).limit(1)
folder_result = await db.execute(folder_stmt)
folder = folder_result.scalar_one_or_none()
if not folder:
return {"error": "No Sent folder found — mail account not configured"}
mail = Mail(
tenant_id=uuid.UUID(tenant_id),
folder_id=folder.id,
from_addr="",
to_addr=arguments.get("to", ""),
subject=arguments.get("subject", ""),
body_text=arguments.get("body", ""),
is_outgoing=True,
sent_at=datetime.utcnow(),
)
db.add(mail)
await db.flush()
return {"message": "Email queued for sending", "mail_id": str(mail.id)}
async def _handler_list_files(db: AsyncSession, arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
"""List DMS files in a folder."""
from app.plugins.builtins.dms.models import File as DmsFile, Folder
tenant_id = context.get("tenant_id")
folder_id = arguments.get("folder_id")
conditions = [DmsFile.tenant_id == uuid.UUID(tenant_id), DmsFile.deleted_at.is_(None)]
if folder_id:
conditions.append(DmsFile.folder_id == uuid.UUID(folder_id))
stmt = select(DmsFile).where(and_(*conditions)).limit(50)
result = await db.execute(stmt)
files = result.scalars().all()
return {"files": [_file_to_dict(f) for f in files], "count": len(files)}
async def _handler_upload_file(db: AsyncSession, arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
"""Upload a file to DMS (stores content in storage backend)."""
from app.plugins.builtins.dms.models import File as DmsFile, Folder
from app.core.storage import get_storage_backend
tenant_id = context.get("tenant_id")
user_id = context.get("user_id")
filename = arguments.get("filename", "untitled")
content_b64 = arguments.get("content_base64", "")
try:
content = base64.b64decode(content_b64)
except Exception:
return {"error": "Invalid base64 content"}
# Find root folder
folder_stmt = select(Folder).where(
and_(Folder.tenant_id == uuid.UUID(tenant_id), Folder.parent_id.is_(None))
).limit(1)
folder_result = await db.execute(folder_stmt)
folder = folder_result.scalar_one_or_none()
if not folder:
folder = Folder(tenant_id=uuid.UUID(tenant_id), name="Root", created_by=uuid.UUID(user_id))
db.add(folder)
await db.flush()
# Store file
storage = get_storage_backend()
file_path = f"mcp/{uuid.uuid4()}/{filename}"
storage.save(file_path, content)
dms_file = DmsFile(
tenant_id=uuid.UUID(tenant_id),
folder_id=folder.id,
name=filename,
storage_path=file_path,
mime_type="application/octet-stream",
size=len(content),
uploaded_by=uuid.UUID(user_id),
)
db.add(dms_file)
await db.flush()
return {"file_id": str(dms_file.id), "name": dms_file.name, "size": dms_file.size}
# ─── Handler Registry ──────────────────────────────────────────────────────
TOOL_HANDLERS: dict[str, Any] = {
"search_contacts": _handler_search_contacts,
"get_contact": _handler_get_contact,
"create_contact": _handler_create_contact,
"list_calendar_entries": _handler_list_calendar_entries,
"create_calendar_entry": _handler_create_calendar_entry,
"list_emails": _handler_list_emails,
"send_email": _handler_send_email,
"list_files": _handler_list_files,
"upload_file": _handler_upload_file,
}
# ─── Serialization Helpers ─────────────────────────────────────────────────
def _contact_to_dict(c: Any) -> dict[str, Any]:
return {
"id": str(c.id),
"name": c.name,
"displayname": getattr(c, "displayname", c.name),
"email": getattr(c, "email_1", None),
"phone": getattr(c, "phone_1", None),
"type": getattr(c, "type", "person"),
"created_at": c.created_at.isoformat() if c.created_at else None,
}
def _entry_to_dict(e: Any) -> dict[str, Any]:
return {
"id": str(e.id),
"calendar_id": str(e.calendar_id),
"entry_type": e.entry_type,
"title": e.title,
"description": e.description,
"start_at": e.start_at.isoformat() if e.start_at else None,
"end_at": e.end_at.isoformat() if e.end_at else None,
"status": e.status,
}
def _mail_to_dict(m: Any) -> dict[str, Any]:
return {
"id": str(m.id),
"from": m.from_addr,
"to": m.to_addr,
"subject": m.subject,
"body_preview": (m.body_text or "")[:200],
"is_read": m.is_read,
"sent_at": m.sent_at.isoformat() if m.sent_at else None,
}
def _file_to_dict(f: Any) -> dict[str, Any]:
return {
"id": str(f.id),
"name": f.name,
"size": f.size,
"mime_type": f.mime_type,
"folder_id": str(f.folder_id) if f.folder_id else None,
"created_at": f.created_at.isoformat() if f.created_at else None,
}
+104
View File
@@ -0,0 +1,104 @@
/**
* MCP Server plugin API client.
*
* Exposes LeoCRM tools to external MCP clients (Claude Desktop, etc.).
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiGet, apiPost } from './client';
// ─── Types ─────────────────────────────────────────────────────────────────
export interface McpToolParameter {
name: string;
type: string;
description: string;
required: boolean;
default?: unknown;
}
export interface McpToolDefinition {
name: string;
description: string;
category: string;
parameters: McpToolParameter[];
required_permission: string | null;
}
export interface McpToolListResponse {
tools: McpToolDefinition[];
count: number;
}
export interface McpToolExecuteRequest {
arguments: Record<string, unknown>;
}
export interface McpToolExecuteResponse {
tool: string;
success: boolean;
result: unknown;
error: string | null;
}
export interface McpServerConfig {
server_name: string;
server_version: string;
protocol_version: string;
base_url: string;
auth_method: string;
available_tools: string[];
}
// ─── API Functions ─────────────────────────────────────────────────────────
export function fetchMcpTools(): Promise<McpToolListResponse> {
return apiGet<McpToolListResponse>('/mcp/tools');
}
export function executeMcpTool(
toolName: string,
args: Record<string, unknown>
): Promise<McpToolExecuteResponse> {
return apiPost<McpToolExecuteResponse>(`/mcp/tools/${toolName}/execute`, {
arguments: args,
});
}
export function fetchMcpConfig(): Promise<McpServerConfig> {
return apiGet<McpServerConfig>('/mcp/config');
}
// ─── Hooks ─────────────────────────────────────────────────────────────────
export function useMcpTools() {
return useQuery({
queryKey: ['mcp', 'tools'],
queryFn: fetchMcpTools,
staleTime: 60 * 1000,
});
}
export function useMcpConfig() {
return useQuery({
queryKey: ['mcp', 'config'],
queryFn: fetchMcpConfig,
staleTime: 60 * 1000,
});
}
export function useExecuteMcpTool() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
toolName,
args,
}: {
toolName: string;
args: Record<string, unknown>;
}) => executeMcpTool(toolName, args),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['mcp'] });
},
});
}
+52 -3
View File
@@ -16,7 +16,8 @@
"users": "Benutzer",
"auditLog": "Audit-Log",
"settings": "Einstellungen",
"aiAssistant": "KI Assistent"
"aiAssistant": "KI Assistent",
"mcpSettings": "MCP Einstellungen"
},
"auth": {
"login": "Anmelden",
@@ -284,7 +285,8 @@
"livePreview": "Live-Vorschau",
"livePreviewDescription": "So sieht die Anwendung mit dem aktuellen Theme aus",
"resetTheme": "Zurücksetzen",
"saveTheme": "Theme speichern"
"saveTheme": "Theme speichern",
"mcp": "MCP"
},
"auditLog": {
"title": "Audit-Log",
@@ -918,5 +920,52 @@
"tab": "Tab wechseln",
"settings": "Einstellungen ändern"
}
},
"mcp": {
"title": "MCP Server & Client",
"server": {
"title": "MCP Server",
"description": "LeoCRM als MCP-Server für externe KI-Clients",
"tools": "Verfügbare Tools",
"toolCount": "Anzahl Tools",
"toolName": "Tool",
"toolDescription": "Beschreibung",
"toolCategory": "Kategorie",
"toolPermission": "Berechtigung",
"execute": "Ausführen",
"executeTool": "Tool ausführen",
"arguments": "Argumente (JSON)",
"result": "Ergebnis",
"error": "Fehler",
"success": "Erfolgreich",
"config": "Server-Konfiguration",
"serverName": "Server-Name",
"serverVersion": "Version",
"protocolVersion": "Protokoll-Version",
"authMethod": "Authentifizierung",
"availableTools": "Verfügbare Tools",
"noTools": "Keine Tools verfügbar"
},
"client": {
"title": "MCP Client",
"description": "Beschreibung",
"servers": "Server-Konfigurationen",
"addServer": "Server hinzufügen",
"editServer": "Server bearbeiten",
"deleteServer": "Server löschen",
"serverName": "Name",
"serverUrl": "URL",
"apiToken": "API-Token",
"enabled": "Aktiviert",
"lastConnected": "Zuletzt verbunden",
"noServers": "Keine Server konfiguriert",
"deleteConfirm": "Möchten Sie diesen Server wirklich löschen?",
"serverCreated": "Server erfolgreich erstellt.",
"serverUpdated": "Server erfolgreich aktualisiert.",
"serverDeleted": "Server erfolgreich gelöscht.",
"viewTools": "Tools anzeigen",
"tools": "Tools",
"noTools": "Keine Tools verfügbar"
}
}
}
}
+52 -3
View File
@@ -16,7 +16,8 @@
"users": "Users",
"auditLog": "Audit Log",
"settings": "Settings",
"aiAssistant": "AI Assistant"
"aiAssistant": "AI Assistant",
"mcpSettings": "MCP Settings"
},
"auth": {
"login": "Sign In",
@@ -284,7 +285,8 @@
"livePreview": "Live Preview",
"livePreviewDescription": "This is how the app looks with the current theme",
"resetTheme": "Reset",
"saveTheme": "Save theme"
"saveTheme": "Save theme",
"mcp": "MCP"
},
"auditLog": {
"title": "Audit Log",
@@ -918,5 +920,52 @@
"tab": "Switch tab",
"settings": "Change settings"
}
},
"mcp": {
"title": "MCP Server & Client",
"server": {
"title": "MCP Server",
"description": "LeoCRM as MCP Server for external AI clients",
"tools": "Available Tools",
"toolCount": "Tool Count",
"toolName": "Tool",
"toolDescription": "Description",
"toolCategory": "Category",
"toolPermission": "Permission",
"execute": "Execute",
"executeTool": "Execute Tool",
"arguments": "Arguments (JSON)",
"result": "Result",
"error": "Error",
"success": "Success",
"config": "Server Configuration",
"serverName": "Server Name",
"serverVersion": "Version",
"protocolVersion": "Protocol Version",
"authMethod": "Authentication",
"availableTools": "Available Tools",
"noTools": "No tools available"
},
"client": {
"title": "MCP Client",
"description": "Description",
"servers": "Server Configurations",
"addServer": "Add Server",
"editServer": "Edit Server",
"deleteServer": "Delete Server",
"serverName": "Name",
"serverUrl": "URL",
"apiToken": "API Token",
"enabled": "Enabled",
"lastConnected": "Last Connected",
"noServers": "No servers configured",
"deleteConfirm": "Are you sure you want to delete this server?",
"serverCreated": "Server created successfully.",
"serverUpdated": "Server updated successfully.",
"serverDeleted": "Server deleted successfully.",
"viewTools": "View Tools",
"tools": "Tools",
"noTools": "No tools available"
}
}
}
}
+1
View File
@@ -22,6 +22,7 @@ export function SettingsPage() {
{ to: '/settings/ai-proactive', label: 'Proaktive KI', icon: '\ud83e\udd16' },
{ to: '/settings/notifications', label: t('settings.notifications'), icon: '\ud83d\udd14' },
{ to: '/settings/theme', label: t('settings.theme', 'Theme'), icon: '\ud83c\udfa8' },
{ to: '/settings/mcp', label: t('settings.mcp', 'MCP'), icon: '\ud83d\udd27' },
];
const existingPaths = new Set(hardcodedNavItems.map(item => item.to));
+268
View File
@@ -0,0 +1,268 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMcpTools, useMcpConfig, useExecuteMcpTool } from '@/api/mcp';
import {
useMcpServers,
useCreateMcpServer,
useUpdateMcpServer,
useDeleteMcpServer,
useMcpServerTools,
type McpServerConfigEntry,
type McpServerConfigCreate,
} from '@/api/mcpClient';
export function SettingsMcpPage() {
const { t } = useTranslation();
const { data: toolsData, isLoading: toolsLoading } = useMcpTools();
const { data: config } = useMcpConfig();
const executeMutation = useExecuteMcpTool();
const { data: servers, isLoading: serversLoading } = useMcpServers();
const createMutation = useCreateMcpServer();
const updateMutation = useUpdateMcpServer();
const deleteMutation = useDeleteMcpServer();
const [showAddForm, setShowAddForm] = useState(false);
const [editingServer, setEditingServer] = useState<McpServerConfigEntry | null>(null);
const [selectedServerForTools, setSelectedServerForTools] = useState<string | null>(null);
const [formData, setFormData] = useState<McpServerConfigCreate>({ name: '', url: '', api_token: '', enabled: true, description: '' });
const [executeToolName, setExecuteToolName] = useState('');
const [executeArgs, setExecuteArgs] = useState('{}');
const [executeResult, setExecuteResult] = useState<string | null>(null);
const { data: serverTools } = useMcpServerTools(selectedServerForTools);
const handleAddServer = async () => {
try {
await createMutation.mutateAsync(formData);
setShowAddForm(false);
setFormData({ name: '', url: '', api_token: '', enabled: true, description: '' });
} catch { /* error handled by mutation */ }
};
const handleUpdateServer = async () => {
if (!editingServer) return;
try {
await updateMutation.mutateAsync({
id: editingServer.id,
payload: { name: formData.name, url: formData.url, api_token: formData.api_token, enabled: formData.enabled, description: formData.description },
});
setEditingServer(null);
setShowAddForm(false);
setFormData({ name: '', url: '', api_token: '', enabled: true, description: '' });
} catch { /* error handled by mutation */ }
};
const handleDeleteServer = async (id: string) => {
if (window.confirm(t('mcp.client.deleteConfirm'))) {
await deleteMutation.mutateAsync(id);
}
};
const handleEditServer = (server: McpServerConfigEntry) => {
setEditingServer(server);
setFormData({ name: server.name, url: server.url, api_token: server.api_token || '', enabled: server.enabled, description: server.description || '' });
setShowAddForm(true);
};
const handleExecuteTool = async () => {
if (!executeToolName) return;
try {
const args = JSON.parse(executeArgs);
const result = await executeMutation.mutateAsync({ toolName: executeToolName, args });
setExecuteResult(JSON.stringify(result, null, 2));
} catch (e) {
setExecuteResult(`Error: ${e}`);
}
};
return (
<div className="space-y-8" data-testid="settings-mcp-page">
<div>
<h2 className="text-2xl font-bold text-secondary-900">{t('mcp.title')}</h2>
</div>
{/* MCP Server Section */}
<section className="bg-white rounded-lg border border-secondary-200 p-6">
<h3 className="text-lg font-semibold mb-2">{t('mcp.server.title')}</h3>
<p className="text-sm text-secondary-600 mb-4">{t('mcp.server.description')}</p>
{config && (
<div className="mb-4 p-3 bg-secondary-50 rounded text-sm">
<div><strong>{t('mcp.server.serverName')}:</strong> {config.server_name}</div>
<div><strong>{t('mcp.server.serverVersion')}:</strong> {config.server_version}</div>
<div><strong>{t('mcp.server.protocolVersion')}:</strong> {config.protocol_version}</div>
<div><strong>{t('mcp.server.authMethod')}:</strong> {config.auth_method}</div>
<div><strong>{t('mcp.server.availableTools')}:</strong> {config.available_tools.join(', ')}</div>
</div>
)}
{toolsLoading ? (
<div className="text-secondary-500">Loading...</div>
) : toolsData && toolsData.tools.length > 0 ? (
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="border-b border-secondary-200 text-left">
<th className="py-2 pr-4">{t('mcp.server.toolName')}</th>
<th className="py-2 pr-4">{t('mcp.server.toolDescription')}</th>
<th className="py-2 pr-4">{t('mcp.server.toolCategory')}</th>
<th className="py-2 pr-4">{t('mcp.server.toolPermission')}</th>
</tr>
</thead>
<tbody>
{toolsData.tools.map((tool) => (
<tr key={tool.name} className="border-b border-secondary-100">
<td className="py-2 pr-4 font-mono text-xs">{tool.name}</td>
<td className="py-2 pr-4">{tool.description}</td>
<td className="py-2 pr-4"><span className="px-2 py-0.5 bg-primary-100 text-primary-700 rounded text-xs">{tool.category}</span></td>
<td className="py-2 pr-4 font-mono text-xs">{tool.required_permission || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-secondary-500 text-sm">{t('mcp.server.noTools')}</div>
)}
{/* Tool Execution */}
<div className="mt-6 pt-4 border-t border-secondary-200">
<h4 className="font-medium mb-2">{t('mcp.server.executeTool')}</h4>
<div className="flex gap-2 items-start">
<select
value={executeToolName}
onChange={(e) => setExecuteToolName(e.target.value)}
className="border border-secondary-300 rounded px-3 py-1.5 text-sm"
>
<option value="">-- Select --</option>
{toolsData?.tools.map((tool) => (
<option key={tool.name} value={tool.name}>{tool.name}</option>
))}
</select>
<input
type="text"
value={executeArgs}
onChange={(e) => setExecuteArgs(e.target.value)}
placeholder={t('mcp.server.arguments')}
className="flex-1 border border-secondary-300 rounded px-3 py-1.5 text-sm font-mono"
/>
<button
onClick={handleExecuteTool}
disabled={!executeToolName || executeMutation.isPending}
className="px-4 py-1.5 bg-primary-600 text-white rounded text-sm hover:bg-primary-700 disabled:opacity-50"
>
{t('mcp.server.execute')}
</button>
</div>
{executeResult && (
<pre className="mt-2 p-3 bg-secondary-900 text-secondary-100 rounded text-xs overflow-x-auto max-h-60">{executeResult}</pre>
)}
</div>
</section>
{/* MCP Client Section */}
<section className="bg-white rounded-lg border border-secondary-200 p-6">
<div className="flex justify-between items-center mb-2">
<h3 className="text-lg font-semibold">{t('mcp.client.title')}</h3>
<button
onClick={() => { setShowAddForm(!showAddForm); setEditingServer(null); setFormData({ name: '', url: '', api_token: '', enabled: true, description: '' }); }}
className="px-3 py-1.5 bg-primary-600 text-white rounded text-sm hover:bg-primary-700"
>
{t('mcp.client.addServer')}
</button>
</div>
<p className="text-sm text-secondary-600 mb-4">{t('mcp.client.description')}</p>
{showAddForm && (
<div className="mb-4 p-4 border border-secondary-200 rounded space-y-3">
<div>
<label className="block text-sm font-medium mb-1">{t('mcp.client.serverName')}</label>
<input type="text" value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} className="w-full border border-secondary-300 rounded px-3 py-1.5 text-sm" />
</div>
<div>
<label className="block text-sm font-medium mb-1">{t('mcp.client.serverUrl')}</label>
<input type="text" value={formData.url} onChange={(e) => setFormData({ ...formData, url: e.target.value })} className="w-full border border-secondary-300 rounded px-3 py-1.5 text-sm" />
</div>
<div>
<label className="block text-sm font-medium mb-1">{t('mcp.client.apiToken')}</label>
<input type="password" value={formData.api_token || ''} onChange={(e) => setFormData({ ...formData, api_token: e.target.value })} className="w-full border border-secondary-300 rounded px-3 py-1.5 text-sm" />
</div>
<div>
<label className="block text-sm font-medium mb-1">{t('mcp.client.description')}</label>
<input type="text" value={formData.description || ''} onChange={(e) => setFormData({ ...formData, description: e.target.value })} className="w-full border border-secondary-300 rounded px-3 py-1.5 text-sm" />
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="mcp-enabled" checked={formData.enabled} onChange={(e) => setFormData({ ...formData, enabled: e.target.checked })} />
<label htmlFor="mcp-enabled" className="text-sm">{t('mcp.client.enabled')}</label>
</div>
<div className="flex gap-2">
<button onClick={editingServer ? handleUpdateServer : handleAddServer} className="px-4 py-1.5 bg-primary-600 text-white rounded text-sm hover:bg-primary-700">
{editingServer ? t('mcp.client.editServer') : t('mcp.client.addServer')}
</button>
<button onClick={() => { setShowAddForm(false); setEditingServer(null); }} className="px-4 py-1.5 border border-secondary-300 rounded text-sm hover:bg-secondary-100">
Cancel
</button>
</div>
</div>
)}
{serversLoading ? (
<div className="text-secondary-500">Loading...</div>
) : servers && servers.length > 0 ? (
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="border-b border-secondary-200 text-left">
<th className="py-2 pr-4">{t('mcp.client.serverName')}</th>
<th className="py-2 pr-4">{t('mcp.client.serverUrl')}</th>
<th className="py-2 pr-4">{t('mcp.client.enabled')}</th>
<th className="py-2 pr-4">{t('mcp.client.lastConnected')}</th>
<th className="py-2 pr-4">Actions</th>
</tr>
</thead>
<tbody>
{servers.map((server) => (
<tr key={server.id} className="border-b border-secondary-100">
<td className="py-2 pr-4 font-medium">{server.name}</td>
<td className="py-2 pr-4 font-mono text-xs">{server.url}</td>
<td className="py-2 pr-4">
<span className={`px-2 py-0.5 rounded text-xs ${server.enabled ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
{server.enabled ? '✓' : '✗'}
</span>
</td>
<td className="py-2 pr-4 text-xs text-secondary-500">{server.last_connected_at || '-'}</td>
<td className="py-2 pr-4">
<div className="flex gap-2">
<button onClick={() => handleEditServer(server)} className="text-xs text-primary-600 hover:underline">{t('mcp.client.editServer')}</button>
<button onClick={() => setSelectedServerForTools(selectedServerForTools === server.id ? null : server.id)} className="text-xs text-primary-600 hover:underline">{t('mcp.client.viewTools')}</button>
<button onClick={() => handleDeleteServer(server.id)} className="text-xs text-red-600 hover:underline">{t('mcp.client.deleteServer')}</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-secondary-500 text-sm">{t('mcp.client.noServers')}</div>
)}
{selectedServerForTools && serverTools && (
<div className="mt-4 p-3 bg-secondary-50 rounded">
<h4 className="font-medium text-sm mb-2">{t('mcp.client.tools')} ({serverTools.count})</h4>
{serverTools.tools.length > 0 ? (
<ul className="text-xs space-y-1">
{serverTools.tools.map((tool) => (
<li key={tool.name} className="font-mono"><strong>{tool.name}</strong>: {tool.description}</li>
))}
</ul>
) : (
<span className="text-xs text-secondary-500">{t('mcp.client.noTools')}</span>
)}
</div>
)}
</section>
</div>
);
}
+2
View File
@@ -35,6 +35,7 @@ const AIAssistantPage = React.lazy(() => import('@/pages/AIAssistant').then(m =>
const AISettingsPage = React.lazy(() => import('@/pages/AISettings').then(m => ({ default: m.AISettingsPage })));
const ProactiveAISettings = React.lazy(() => import('@/pages/ProactiveAISettings').then(m => ({ default: m.ProactiveAISettings })));
const SettingsThemePage = React.lazy(() => import('@/pages/SettingsTheme').then(m => ({ default: m.SettingsThemePage })));
const SettingsMcpPage = React.lazy(() => import('@/pages/SettingsMcp').then(m => ({ default: m.SettingsMcpPage })));
const AutomationDashboardPage = React.lazy(() => import('@/pages/AutomationDashboard').then(m => ({ default: m.AutomationDashboardPage })));
const AgentDashboardPage = React.lazy(() => import('@/pages/AgentDashboard').then(m => ({ default: m.AgentDashboardPage })));
const AutomationSettingsPage = React.lazy(() => import('@/pages/AutomationSettings').then(m => ({ default: m.AutomationSettingsPage })));
@@ -107,6 +108,7 @@ const router = createBrowserRouter([
{ path: 'ai-proactive', element: withSuspense(<ProactiveAISettings />) },
{ path: 'theme', element: withSuspense(<SettingsThemePage />) },
{ path: 'automation', element: withSuspense(<AutomationSettingsPage />) },
{ path: 'mcp', element: withSuspense(<SettingsMcpPage />) },
{ path: '*', element: <PluginRouteRenderer /> },
],
},
+61
View File
@@ -38,6 +38,9 @@ from app.models.user import User, UserTenant
from app.models.user_preference import UserPreference # noqa: F401
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory # noqa: F401
from app.plugins.builtins.calendar import CalendarPlugin # noqa: F401
from app.plugins.builtins.mcp_server import McpServerPlugin # noqa: F401
from app.plugins.builtins.mcp_client import McpClientPlugin # noqa: F401
from app.plugins.builtins.mcp_client.models import McpServerConfig # noqa: F401
from app.plugins.builtins.calendar.models import ( # noqa: F401
Calendar,
CalendarEntry,
@@ -434,3 +437,61 @@ async def calendar_authed_client(
assert resp.status_code == 200, f"Calendar activate failed: {resp.text}"
return calendar_client, seed
# ─── MCP Server / Client Fixtures ───────────────────────────────────────────
@pytest_asyncio.fixture
async def mcp_app(engine: AsyncEngine, redis_client):
"""FastAPI app with MCP Server + Client + Permissions plugins registered, installed, and activated."""
reset_engine_for_testing(engine)
app = create_app()
registry = reset_registry_for_testing()
registry.initialize(engine, app)
container = get_container()
await container.initialize()
registry.register_plugin(PermissionsPlugin())
registry.register_plugin(McpServerPlugin())
registry.register_plugin(McpClientPlugin())
reset_plugin_service_for_testing(registry)
_sf = async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession)
async with _sf() as session:
await registry.install(session, "permissions")
await registry.activate(session, "permissions")
await registry.install(session, "mcp_server")
await registry.activate(session, "mcp_server")
await registry.install(session, "mcp_client")
await registry.activate(session, "mcp_client")
await session.commit()
yield app
await close_engine()
@pytest_asyncio.fixture
async def mcp_client_fixture(mcp_app) -> AsyncClient:
transport = ASGITransport(app=mcp_app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
@pytest_asyncio.fixture
async def mcp_authed_client(
mcp_client_fixture: AsyncClient, db_session: AsyncSession
) -> tuple[AsyncClient, dict]:
"""Authenticated admin client with seeded data and MCP plugins activated."""
seed = await seed_tenant_and_users(db_session)
login_resp = await mcp_client_fixture.post(
"/api/v1/auth/login",
json={"email": "admin@tenanta.com", "password": "TestPass123!"},
headers=ORIGIN_HEADER,
)
assert login_resp.status_code == 200, f"Login failed: {login_resp.text}"
csrf_token = login_resp.json().get("csrf_token", "")
mcp_client_fixture.headers.update({"X-CSRF-Token": csrf_token})
return mcp_client_fixture, seed
+146
View File
@@ -0,0 +1,146 @@
"""Tests for MCP Server plugin — tool listing, execution, config.
Tests Task 5.16 acceptance criteria.
"""
from __future__ import annotations
import pytest
from tests.conftest import ORIGIN_HEADER
# ─── AC1: List MCP tools ───
@pytest.mark.asyncio
async def test_ac1_list_mcp_tools(mcp_authed_client):
"""AC1: GET /api/v1/mcp/tools → 200 + tool list with 9 tools."""
client, _ = mcp_authed_client
resp = await client.get("/api/v1/mcp/tools", headers=ORIGIN_HEADER)
assert resp.status_code == 200
data = resp.json()
assert data["count"] == 9
assert len(data["tools"]) == 9
tool_names = [t["name"] for t in data["tools"]]
assert "search_contacts" in tool_names
assert "get_contact" in tool_names
assert "create_contact" in tool_names
assert "list_calendar_entries" in tool_names
assert "create_calendar_entry" in tool_names
assert "list_emails" in tool_names
assert "send_email" in tool_names
assert "list_files" in tool_names
assert "upload_file" in tool_names
# ─── AC2: Get MCP config ───
@pytest.mark.asyncio
async def test_ac2_get_mcp_config(mcp_authed_client):
"""AC2: GET /api/v1/mcp/config → 200 + server config."""
client, _ = mcp_authed_client
resp = await client.get("/api/v1/mcp/config", headers=ORIGIN_HEADER)
assert resp.status_code == 200
data = resp.json()
assert data["server_name"] == "LeoCRM"
assert data["server_version"] == "1.0.0"
assert data["protocol_version"] == "2024-11-05"
assert data["auth_method"] == "api-token"
assert "search_contacts" in data["available_tools"]
assert len(data["available_tools"]) == 9
# ─── AC3: Execute search_contacts tool ───
@pytest.mark.asyncio
async def test_ac3_execute_search_contacts(mcp_authed_client):
"""AC3: POST /api/v1/mcp/tools/search_contacts/execute → 200 + results."""
client, _ = mcp_authed_client
resp = await client.post(
"/api/v1/mcp/tools/search_contacts/execute",
json={"arguments": {"query": "Admin", "limit": 10}},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
data = resp.json()
assert data["tool"] == "search_contacts"
assert data["success"] is True
assert "result" in data
assert "contacts" in data["result"]
# ─── AC4: Execute non-existent tool returns 404 ───
@pytest.mark.asyncio
async def test_ac4_execute_nonexistent_tool(mcp_authed_client):
"""AC4: POST /api/v1/mcp/tools/nonexistent/execute → 404."""
client, _ = mcp_authed_client
resp = await client.post(
"/api/v1/mcp/tools/nonexistent_tool/execute",
json={"arguments": {}},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 404
assert "tool_not_found" in resp.text
# ─── AC5: Tool definitions have correct schema ───
@pytest.mark.asyncio
async def test_ac5_tool_definitions_schema(mcp_authed_client):
"""AC5: GET /api/v1/mcp/tools → tools have proper schema with parameters."""
client, _ = mcp_authed_client
resp = await client.get("/api/v1/mcp/tools", headers=ORIGIN_HEADER)
assert resp.status_code == 200
tools = resp.json()["tools"]
# Check search_contacts has query and limit params
search_tool = next(t for t in tools if t["name"] == "search_contacts")
param_names = [p["name"] for p in search_tool["parameters"]]
assert "query" in param_names
assert "limit" in param_names
query_param = next(p for p in search_tool["parameters"] if p["name"] == "query")
assert query_param["required"] is True
# Check create_contact has name, email, phone, type params
create_tool = next(t for t in tools if t["name"] == "create_contact")
create_params = [p["name"] for p in create_tool["parameters"]]
assert "name" in create_params
assert "email" in create_params
assert "phone" in create_params
assert "type" in create_params
# ─── AC6: Unauthorized access is rejected ───
@pytest.mark.asyncio
async def test_ac6_unauthorized_access(mcp_client_fixture):
"""AC6: GET /api/v1/mcp/tools without auth → 401."""
resp = await mcp_client_fixture.get("/api/v1/mcp/tools", headers=ORIGIN_HEADER)
assert resp.status_code == 401
# ─── AC7: Execute create_contact tool ───
@pytest.mark.asyncio
async def test_ac7_execute_create_contact(mcp_authed_client):
"""AC7: POST /api/v1/mcp/tools/create_contact/execute → 200 + created contact."""
client, _ = mcp_authed_client
resp = await client.post(
"/api/v1/mcp/tools/create_contact/execute",
json={"arguments": {"name": "MCP Test Contact", "email": "mcp@test.com", "phone": "+49123456789", "type": "person"}},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
data = resp.json()
assert data["tool"] == "create_contact"
assert data["success"] is True
assert data["result"]["name"] == "MCP Test Contact"
assert data["result"]["email"] == "mcp@test.com"