2026-07-25 02:41:55 +02:00
|
|
|
"""MCP tool definitions for LeoCRM — generic CRM API access.
|
2026-07-23 23:01:59 +02:00
|
|
|
|
2026-07-25 02:41:55 +02:00
|
|
|
Single tool that gives MCP clients (Claude Desktop, etc.) full access to all
|
|
|
|
|
CRM API endpoints via the generic call_crm_api tool.
|
2026-07-23 23:01:59 +02:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-07-25 02:41:55 +02:00
|
|
|
import json
|
2026-07-23 23:01:59 +02:00
|
|
|
import logging
|
2026-07-25 02:41:55 +02:00
|
|
|
import os
|
2026-07-23 23:01:59 +02:00
|
|
|
import uuid
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
2026-07-25 02:41:55 +02:00
|
|
|
import httpx
|
2026-07-23 23:01:59 +02:00
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
|
|
|
|
from app.plugins.builtins.mcp_server.schemas import McpToolDefinition, McpToolParameter
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
2026-07-25 02:41:55 +02:00
|
|
|
def _get_base_url() -> str:
|
|
|
|
|
"""Get the internal base URL for API calls."""
|
|
|
|
|
port = os.environ.get("PORT", "8000")
|
|
|
|
|
return f"http://127.0.0.1:{port}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── Generic CRM API Tool ──────────────────────────────────────────────────
|
2026-07-23 23:01:59 +02:00
|
|
|
|
|
|
|
|
TOOL_DEFINITIONS: list[McpToolDefinition] = [
|
|
|
|
|
McpToolDefinition(
|
2026-07-25 02:41:55 +02:00
|
|
|
name="call_crm_api",
|
|
|
|
|
description=(
|
|
|
|
|
"Rufe einen beliebigen CRM API Endpunkt auf. "
|
|
|
|
|
"GET zum Lesen, POST zum Erstellen, PATCH zum Aktualisieren, DELETE zum Löschen. "
|
|
|
|
|
"Verfügbare Endpunkte: /api/v1/contacts, /api/v1/mail, /api/v1/calendar, "
|
|
|
|
|
"/api/v1/dms, /api/v1/tasks, /api/v1/addresses, /api/v1/bank-accounts, "
|
|
|
|
|
"/api/v1/search, /api/v1/comm, /api/v1/ai, /api/v1/reports, etc. "
|
|
|
|
|
"Für POST/PATCH kann ein body (JSON) mitgegeben werden."
|
|
|
|
|
),
|
|
|
|
|
category="system",
|
2026-07-23 23:01:59 +02:00
|
|
|
required_permission="mcp:read",
|
|
|
|
|
parameters=[
|
2026-07-25 02:41:55 +02:00
|
|
|
McpToolParameter(name="method", type="string", description="HTTP Methode: GET, POST, PATCH, DELETE", required=True),
|
|
|
|
|
McpToolParameter(name="path", type="string", description="API Pfad, z.B. /api/v1/contacts", required=True),
|
|
|
|
|
McpToolParameter(name="body", type="object", description="Request body für POST/PATCH (JSON Objekt)", required=False),
|
2026-07-23 23:01:59 +02:00
|
|
|
],
|
|
|
|
|
),
|
2026-08-14 01:34:58 +02:00
|
|
|
McpToolDefinition(
|
|
|
|
|
name="search",
|
|
|
|
|
description=(
|
|
|
|
|
"Durchsuche alle CRM-Daten (Kontakte, Firmen, Mails, Dateien, Kalender, Tasks) "
|
|
|
|
|
"mit Hybrid-Suche (Volltext + semantisch). "
|
|
|
|
|
"Liefert kompakte Ergebnisse mit entity_type, title, snippet und score. "
|
|
|
|
|
"Die Suche respektiert die Berechtigungen des aufrufenden Benutzers."
|
|
|
|
|
),
|
|
|
|
|
category="search",
|
|
|
|
|
required_permission="search:read",
|
|
|
|
|
parameters=[
|
|
|
|
|
McpToolParameter(name="query", type="string", description="Suchanfrage, z.B. 'Max Mustermann' oder 'Angebot 2026'", required=True),
|
|
|
|
|
McpToolParameter(name="entity_types", type="array", description="Optional: Nur diese Entity-Typen durchsuchen (contact, company, mail, file, event, task, ...)", required=False),
|
|
|
|
|
McpToolParameter(name="limit", type="integer", description="Maximale Anzahl Ergebnisse (Standard: 10)", required=False, default=10),
|
|
|
|
|
],
|
|
|
|
|
),
|
2026-07-23 23:01:59 +02:00
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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]
|
|
|
|
|
|
|
|
|
|
|
2026-07-25 02:41:55 +02:00
|
|
|
# ─── Tool Handler ─────────────────────────────────────────────────────────
|
2026-07-23 23:01:59 +02:00
|
|
|
|
|
|
|
|
|
2026-07-25 02:41:55 +02:00
|
|
|
async def _handler_call_crm_api(db: AsyncSession, arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
"""Execute an arbitrary CRM API call on behalf of the MCP client."""
|
|
|
|
|
method = arguments.get("method", "GET").upper()
|
|
|
|
|
path = arguments.get("path", "")
|
|
|
|
|
body = arguments.get("body")
|
2026-07-23 23:01:59 +02:00
|
|
|
|
2026-07-25 02:41:55 +02:00
|
|
|
if not path:
|
|
|
|
|
return {"error": "path is required"}
|
2026-07-23 23:01:59 +02:00
|
|
|
|
2026-07-25 02:41:55 +02:00
|
|
|
if not path.startswith("/"):
|
|
|
|
|
path = "/" + path
|
2026-07-23 23:01:59 +02:00
|
|
|
|
|
|
|
|
try:
|
2026-07-25 02:41:55 +02:00
|
|
|
base_url = _get_base_url()
|
|
|
|
|
tenant_id = context.get("tenant_id", "")
|
|
|
|
|
user_id = context.get("user_id", "")
|
|
|
|
|
|
|
|
|
|
headers = {
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
"X-Internal-Call": "true",
|
|
|
|
|
"X-Tenant-Id": str(tenant_id),
|
|
|
|
|
"X-User-Id": str(user_id),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
|
|
|
if method == "GET":
|
|
|
|
|
resp = await client.get(f"{base_url}{path}", headers=headers, timeout=30.0)
|
|
|
|
|
elif method == "POST":
|
|
|
|
|
resp = await client.post(f"{base_url}{path}", headers=headers, json=body, timeout=30.0)
|
|
|
|
|
elif method == "PATCH":
|
|
|
|
|
resp = await client.patch(f"{base_url}{path}", headers=headers, json=body, timeout=30.0)
|
|
|
|
|
elif method == "PUT":
|
|
|
|
|
resp = await client.put(f"{base_url}{path}", headers=headers, json=body, timeout=30.0)
|
|
|
|
|
elif method == "DELETE":
|
|
|
|
|
resp = await client.delete(f"{base_url}{path}", headers=headers, timeout=30.0)
|
|
|
|
|
else:
|
|
|
|
|
return {"error": f"Unsupported method: {method}"}
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
resp_data = resp.json()
|
|
|
|
|
# Truncate large responses
|
|
|
|
|
resp_text = json.dumps(resp_data, default=str)
|
|
|
|
|
if len(resp_text) > 8000:
|
|
|
|
|
resp_data = json.loads(resp_text[:8000] + "...\n[truncated]")
|
|
|
|
|
return resp_data
|
|
|
|
|
except Exception:
|
|
|
|
|
return {"response": resp.text[:8000]}
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.exception("MCP call_crm_api failed")
|
|
|
|
|
return {"error": str(e)}
|
|
|
|
|
|
|
|
|
|
|
2026-08-14 01:34:58 +02:00
|
|
|
# ─── Search Tool Handler ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _handler_search(db: AsyncSession, arguments: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
"""Execute a unified search on behalf of the MCP client.
|
|
|
|
|
|
|
|
|
|
Uses the MCP session's user context (tenant_id, user_id) so that
|
|
|
|
|
visibility filtering and RBAC are respected. MCP gets no special rights.
|
|
|
|
|
"""
|
|
|
|
|
query = (arguments.get("query") or "").strip()
|
|
|
|
|
if not query:
|
|
|
|
|
return {"error": "query is required"}
|
|
|
|
|
|
|
|
|
|
entity_types = arguments.get("entity_types")
|
|
|
|
|
if isinstance(entity_types, str):
|
|
|
|
|
entity_types = [t.strip() for t in entity_types.split(",") if t.strip()]
|
|
|
|
|
limit = int(arguments.get("limit", 10) or 10)
|
|
|
|
|
limit = max(1, min(limit, 50))
|
|
|
|
|
|
|
|
|
|
tenant_id = context.get("tenant_id")
|
|
|
|
|
user_id = context.get("user_id")
|
|
|
|
|
is_system_admin = bool(context.get("is_system_admin", False))
|
|
|
|
|
|
|
|
|
|
if not tenant_id:
|
|
|
|
|
return {"error": "missing tenant context"}
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
tenant_uuid = uuid.UUID(str(tenant_id))
|
|
|
|
|
user_uuid = uuid.UUID(str(user_id)) if user_id else None
|
|
|
|
|
except (ValueError, TypeError):
|
|
|
|
|
return {"error": "invalid tenant/user context"}
|
|
|
|
|
|
|
|
|
|
try:
|
2026-08-16 01:17:18 +02:00
|
|
|
from app.plugins.builtins.contracts import get_contract
|
|
|
|
|
search_contract = get_contract("unified_search")
|
|
|
|
|
if search_contract is not None:
|
|
|
|
|
query_analysis = await search_contract.llm_analyze_query(query, db=db, tenant_id=tenant_uuid)
|
|
|
|
|
results = await search_contract.hybrid_search(
|
|
|
|
|
db=db,
|
|
|
|
|
query_analysis=query_analysis,
|
|
|
|
|
tenant_id=tenant_uuid,
|
|
|
|
|
entity_types=entity_types,
|
|
|
|
|
limit=limit,
|
|
|
|
|
user_id=user_uuid,
|
|
|
|
|
is_system_admin=is_system_admin,
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
return {"error": "search plugin not available"}
|
2026-08-14 01:34:58 +02:00
|
|
|
except Exception as e:
|
|
|
|
|
logger.exception("MCP search failed")
|
|
|
|
|
return {"error": str(e)}
|
|
|
|
|
|
|
|
|
|
compact = [
|
|
|
|
|
{
|
|
|
|
|
"entity_type": r.get("entity_type", ""),
|
|
|
|
|
"entity_id": r.get("entity_id", ""),
|
|
|
|
|
"title": r.get("title", ""),
|
|
|
|
|
"snippet": (r.get("snippet", "") or "")[:200],
|
|
|
|
|
"score": round(float(r.get("score", 0.0)), 4),
|
|
|
|
|
}
|
|
|
|
|
for r in results
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
return {"count": len(compact), "results": compact}
|
|
|
|
|
|
|
|
|
|
|
2026-07-25 02:41:55 +02:00
|
|
|
# ─── Handler Registry ─────────────────────────────────────────────────────
|
2026-07-23 23:01:59 +02:00
|
|
|
|
|
|
|
|
TOOL_HANDLERS: dict[str, Any] = {
|
2026-07-25 02:41:55 +02:00
|
|
|
"call_crm_api": _handler_call_crm_api,
|
2026-08-14 01:34:58 +02:00
|
|
|
"search": _handler_search,
|
2026-07-23 23:01:59 +02:00
|
|
|
}
|