"""MCP tool definitions for LeoCRM — generic CRM API access. Single tool that gives MCP clients (Claude Desktop, etc.) full access to all CRM API endpoints via the generic call_crm_api tool. """ from __future__ import annotations import json import logging import os import uuid from typing import Any from sqlalchemy.ext.asyncio import AsyncSession from app.plugins.builtins.mcp_server.schemas import McpToolDefinition, McpToolParameter logger = logging.getLogger(__name__) 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 ────────────────────────────────────────────────── TOOL_DEFINITIONS: list[McpToolDefinition] = [ McpToolDefinition( 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", required_permission="mcp:read", parameters=[ 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), ], ), 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), ], ), ] 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 Handler ───────────────────────────────────────────────────────── 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") if not path: return {"error": "path is required"} if not path.startswith("/"): path = "/" + path try: tenant_id = context.get("tenant_id", "") user_id = context.get("user_id", "") # F09 (Astra P1): authenticated request via short-lived delegation # token - the MCP session user's real permissions apply. from app.plugins.builtins.ai_assistant.crm_api_tool import _make_internal_api_request resp = await _make_internal_api_request( method, path, tenant_id=str(tenant_id), user_id=str(user_id), body=body ) 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)} # ─── 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: 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"} 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} # ─── Handler Registry ───────────────────────────────────────────────────── TOOL_HANDLERS: dict[str, Any] = { "call_crm_api": _handler_call_crm_api, "search": _handler_search, }