Files
leocrm/app/plugins/builtins/mcp_server/tool_definitions.py
T
Agent Zero 421726700b
Check Cross-Plugin Imports / check (push) Waiting to run
fix(security): F09 (Astra P1) — CRM-/MCP-Tools delegieren mit HMAC-Token statt toter Header
Vorher: Zwei generische CRM-API-Tools (ai_assistant/crm_api_tool,
mcp_server/tool_definitions) sendeten X-Internal-Call/X-Tenant-Id/
X-User-Id-Header — die geschuetzte API akzeptiert diese nicht als
Authentisierung (Astra-Repro: "Not authenticated"). Der vorhandene
Delegationstoken-Code (app/core/delegation_token.py, HMAC-SHA256,
max 60s) war komplett unverbunden (0 Aufrufer). Im Worker zeigte der
lokale Default-Host zudem auf den Worker selbst.

Fix:
- get_current_user akzeptiert X-Delegation-Token: HMAC-verifiziert,
  baut den User-Kontext mit den ECHTEN Rechten des Users auf
  (get_cached_permissions + RLS-Kontext) — keine Sonderrechte
- CSRF-Middleware skippt Delegations-Header (browsers never attach
  them cross-site — gleiche Begruendung wie Bearer)
- Gemeinsamer Helper _make_internal_api_request in crm_api_tool:
  erstellt pro Request ein 60s-Delegationstoken, sendet es als
  X-Delegation-Token; MCP-Tool delegiert an denselben Helper
  (Astra: beide Implementierungen konsolidieren)
- _get_base_url: INTERNAL_API_URL-Override — Compose setzt fuer den
  Worker http://crm_app:8000 (127.0.0.1 zeigte im Worker auf sich
  selbst)

Abnahme (Astra): Dieselbe Fachaktion ist fuer denselben Benutzer ueber
UI und Agent gleichermaassen erlaubt oder gesperrt — die Tools laufen
jetzt mit den echten User-Rechten durch denselben Auth-Pfad. Das
Audit-Naming (delegated_by) folgt mit dem transparency-Update.

Verifikation: test_api_tokens (inkl. 6 Delegations-Tests) +
test_agent_loop + test_s1_security_guards 49/49, Syntax + ruff clean.
2026-09-18 12:12:18 +02:00

195 lines
7.5 KiB
Python

"""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,
}