Spaces:
Running
Running
File size: 18,846 Bytes
28a08e7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 | """
backend/api/mcp.py β P19-B3: MCP (Model Context Protocol) server skeleton.
Implementa JSON-RPC 2.0 / MCP spec 2024-11-05.
Endpoint: POST /api/mcp
Compatibile con: Claude Desktop, Cursor, cline, Continue.dev, qualsiasi client MCP.
Auth: Bearer token via MCP_API_KEY env var (P20-Q1). Se non impostato β accesso libero (dev).
Metodi supportati:
initialize β handshake + capabilities negotiation
tools/list β lista dei 5 tool principali con JSON Schema input
tools/call β esegui tool per nome con arguments + timeout 30s
Design:
- Tool whitelist esplicita (_MCP_TOOLS) β zero dipendenza da struttura interna TOOL_REGISTRY.
- Fail-open: ogni errore tool β MCP error response, mai exception non gestita.
- Batch support: body puΓ² essere array di richieste (MCP spec Β§4.3).
- Timeout 30s per tool call β non blocca il worker FastAPI.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import time
from typing import Any
import uuid
from fastapi import APIRouter, Depends, Request
from .auth_guard import require_role, AuthRole
from fastapi.responses import JSONResponse, StreamingResponse
router = APIRouter(tags=["mcp"], dependencies=[Depends(require_role(AuthRole.MACHINE))]) # GAP-1-fix: INTERNAL_TOKEN sempre richiesto + MCP_API_KEY opzionale come secondo layer
_logger = logging.getLogger("agente_ai.mcp")
# P20-Q1: Auth β se MCP_API_KEY Γ¨ impostato nell'env, richiede Authorization: Bearer <key>.
# In dev locale senza env var β accesso libero (utile per test locali con Claude Desktop).
# In prod (Railway): impostare MCP_API_KEY nei Railway env vars.
_MCP_API_KEY: str = os.getenv("MCP_API_KEY", "").strip()
_MCP_PROTOCOL_VERSION = "2024-11-05"
_SERVER_INFO = {"name": "agente-ai", "version": "1.0.0"}
# ββ Tool whitelist MCP (MVP β top-5 strumenti principali) βββββββββββββββββββββββ
# Campo "inputSchema" = JSON Schema standard (MCP spec Β§5.1.1)
_MCP_TOOLS: list[dict] = [
{
"name": "web_search",
"description": (
"Cerca informazioni aggiornate sul web tramite Brave, Tavily, Wikipedia, DuckDuckGo. "
"Restituisce titoli, snippet e URL dei risultati piΓΉ rilevanti."
),
"inputSchema": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Query di ricerca in linguaggio naturale"},
"max_results": {"type": "integer", "description": "Numero massimo risultati (default 5)", "default": 5},
},
"required": ["query"],
},
},
{
"name": "trigger_webhook",
"description": (
"Invia una richiesta HTTP POST/GET/PUT a un URL esterno (n8n, Pipedream, Zapier, "
"Discord, Slack, CI/CD). Restituisce status HTTP e body risposta."
),
"inputSchema": {
"type": "object",
"properties": {
"url": {"type": "string", "description": "URL destinazione (https://...)"},
"payload": {"type": "object", "description": "Payload JSON da inviare"},
"method": {"type": "string", "enum": ["POST", "GET", "PUT"], "default": "POST"},
"headers": {"type": "object", "description": "Header HTTP aggiuntivi"},
"timeout": {"type": "number", "description": "Timeout secondi (max 10)", "default": 10},
},
"required": ["url"],
},
},
{
"name": "memory_read",
"description": (
"Legge dalla memoria episodica dell'agente: recupera ricordi semanticamente simili "
"alla query tramite embedding vector search (top-k risultati)."
),
"inputSchema": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Query per la ricerca semantica"},
"top_k": {"type": "integer", "description": "Numero max di ricordi restituiti (default 5)", "default": 5},
},
"required": ["query"],
},
},
{
"name": "code_exec",
"description": (
"Esegue codice Python in una sandbox isolata (backend-exec microservice). "
"Restituisce stdout, stderr e exit code. Timeout 25s."
),
"inputSchema": {
"type": "object",
"properties": {
"code": {"type": "string", "description": "Codice Python da eseguire"},
"timeout": {"type": "number", "description": "Timeout in secondi (max 25)", "default": 20},
},
"required": ["code"],
},
},
{
"name": "file_write",
"description": (
"Scrive o aggiorna un file nel workspace virtuale (VFS) dell'agente. "
"Percorso relativo alla root del progetto corrente."
),
"inputSchema": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Percorso file (es. 'src/index.ts')"},
"content": {"type": "string", "description": "Contenuto completo del file"},
},
"required": ["path", "content"],
},
},
]
_MCP_TOOL_MAP: dict[str, dict] = {t["name"]: t for t in _MCP_TOOLS}
# ββ Tool dispatch βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
async def _dispatch_tool(name: str, arguments: dict) -> Any:
"""
Chiama il tool reale con gli argomenti MCP. Timeout 30s.
Ogni tool ha il proprio import lazy per evitare circular dependency.
"""
if name == "web_search":
from tools.registry import _web_search as _ws
return await asyncio.wait_for(_ws(**arguments), timeout=30.0)
if name == "trigger_webhook":
from tools.trigger_webhook import trigger_webhook as _tw
return await asyncio.wait_for(_tw(**arguments), timeout=30.0)
if name == "memory_read":
# Accede al MemoryManager singleton via api.state
from api.state import _get_mem_manager_async as _gmm
mem = await _gmm()
if mem is None:
return {"error": "memory_manager non disponibile"}
query = arguments.get("query", "")
top_k = int(arguments.get("top_k", 5))
results = await asyncio.wait_for(mem.retrieve(query, top_k=top_k), timeout=10.0)
return [{"content": r.get("content", ""), "score": r.get("score", 0.0)} for r in results]
if name == "code_exec":
from tools.registry import _call_exec_engine as _cee
code = arguments.get("code", "")
timeout = float(arguments.get("timeout", 20))
result = await asyncio.wait_for(
_cee({"code": code, "timeout": timeout}),
timeout=timeout + 5,
)
return result or {"error": "exec engine non disponibile"}
if name == "file_write":
# Scrive su VFS Supabase β proxy via api.files
try:
from api.files import _write_file_internal as _wfi
path = arguments.get("path", "")
content = arguments.get("content", "")
await asyncio.wait_for(_wfi(path, content), timeout=10.0)
return {"ok": True, "path": path, "bytes": len(content)}
except (ImportError, AttributeError):
return {"error": "file_write non disponibile β implementare _write_file_internal in api.files"}
raise ValueError(f"Tool '{name}' non implementato nel dispatcher MCP")
# ββ JSON-RPC helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _ok(id_: Any, result: Any) -> dict:
return {"jsonrpc": "2.0", "id": id_, "result": result}
def _err(id_: Any, code: int, message: str, data: Any = None) -> dict:
e: dict[str, Any] = {"code": code, "message": message}
if data is not None:
e["data"] = str(data)[:500]
return {"jsonrpc": "2.0", "id": id_, "error": e}
async def _handle_one(req: Any) -> dict:
"""Gestisce una singola richiesta JSON-RPC 2.0."""
if not isinstance(req, dict):
return _err(None, -32600, "Invalid Request")
req_id = req.get("id")
method = req.get("method", "")
params = req.get("params") or {}
if req.get("jsonrpc") != "2.0":
return _err(req_id, -32600, "jsonrpc deve essere '2.0'")
# ββ initialize βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
if method == "initialize":
client_info = params.get("clientInfo", {})
_logger.info("[mcp] initialize β client: %s %s",
client_info.get("name", "unknown"),
client_info.get("version", ""))
return _ok(req_id, {
"protocolVersion": _MCP_PROTOCOL_VERSION,
"capabilities": {"tools": {"listChanged": False}},
"serverInfo": _SERVER_INFO,
})
# ββ tools/list βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
if method == "tools/list":
return _ok(req_id, {"tools": _MCP_TOOLS})
# ββ tools/call βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
if method == "tools/call":
tool_name = params.get("name")
arguments = params.get("arguments") or {}
if not tool_name:
return _err(req_id, -32602, "Params mancante: 'name' obbligatorio")
if tool_name not in _MCP_TOOL_MAP:
return _err(req_id, -32602,
f"Tool '{tool_name}' non in whitelist MCP",
data={"available": list(_MCP_TOOL_MAP.keys())})
try:
t0 = time.monotonic()
result = await _dispatch_tool(tool_name, arguments)
elapsed = int((time.monotonic() - t0) * 1000)
_logger.info("[mcp] tools/call %s OK (%dms)", tool_name, elapsed)
# Normalizza β MCP content[] format
if isinstance(result, str):
content = [{"type": "text", "text": result}]
elif isinstance(result, (dict, list)):
content = [{"type": "text",
"text": json.dumps(result, ensure_ascii=False, indent=2)}]
else:
content = [{"type": "text", "text": str(result)}]
return _ok(req_id, {
"content": content,
"isError": False,
"_meta": {"elapsed_ms": elapsed},
})
except asyncio.TimeoutError:
_logger.warning("[mcp] tools/call %s β timeout 30s", tool_name)
return _err(req_id, -32603, f"Tool '{tool_name}' timeout (30s)")
except Exception as exc:
_logger.warning("[mcp] tools/call %s β errore: %s", tool_name, exc)
return _ok(req_id, {
"content": [{"type": "text", "text": f"Errore: {exc!s:.500}"}],
"isError": True,
})
# ββ notifications (fire-and-forget β no response per spec) βββββββββββββββ
if method.startswith("notifications/"):
_logger.debug("[mcp] notification ricevuta: %s", method)
return {} # spec: notification non ha risposta
# ββ Method not found ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
return _err(req_id, -32601, f"Method not found: {method!r}")
# ββ Main endpoint βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@router.post("/api/mcp")
async def mcp_endpoint(request: Request) -> JSONResponse:
"""
POST /api/mcp β MCP JSON-RPC 2.0 endpoint.
Compatibile con Claude Desktop (mcp://), Cursor, Continue.dev.
Supporta sia request singola che batch (array).
Auth: se MCP_API_KEY Γ¨ configurato, richiede `Authorization: Bearer <key>`.
"""
# ββ P20-Q1: Auth gate ββββββββββββββββββββββββββββββββββββββββββββββββββββ
if _MCP_API_KEY:
auth_header = request.headers.get("authorization", "")
if not auth_header.lower().startswith("bearer "):
return JSONResponse(
_err(None, -32000, "Unauthorized: Authorization Bearer header mancante"),
status_code=401,
headers={"WWW-Authenticate": "Bearer realm=\"agente-ai-mcp\""},
)
provided_key = auth_header[7:].strip()
if provided_key != _MCP_API_KEY:
_logger.warning("[mcp] auth fallita β chiave errata")
return JSONResponse(
_err(None, -32000, "Unauthorized: MCP API key non valida"),
status_code=401,
headers={"WWW-Authenticate": "Bearer realm=\"agente-ai-mcp\""},
)
try:
body = await request.json()
except Exception:
return JSONResponse(
_err(None, -32700, "Parse error: body JSON non valido"),
status_code=400,
)
if isinstance(body, list):
# Batch request β processa tutte in parallelo
results = await asyncio.gather(*[_handle_one(r) for r in body])
# Filtra notification responses (dict vuoto)
out = [r for r in results if r]
return JSONResponse(out)
result = await _handle_one(body)
if not result: # notification β nessuna risposta
return JSONResponse(None, status_code=204)
return JSONResponse(result)
# ββ P20-B2: SSE transport (MCP spec 2024-11-05 Β§transport-sse) ββββββββββββββββ
# Permette ai client MCP che richiedono SSE (es. Claude Desktop, alcuni proxy)
# di aprire uno stream persistente invece del request/response classico.
#
# Flow:
# 1. Client β GET /api/mcp/sse β riceve "event: endpoint" con POST URL
# 2. Client β POST /api/mcp/messages?sessionId=<id> β invia JSON-RPC
# 3. Server β invia risposta JSON-RPC come "data:" event sul SSE stream
#
# Session store: dizionario in-memory (asyncio.Queue per sessione).
# TTL: 1h implicito β la queue viene rimossa quando il client chiude lo stream.
_SSE_SESSIONS: dict[str, asyncio.Queue] = {}
def _sse_auth_check(request: "Request") -> bool:
"""Ritorna True se autorizzato (o MCP_API_KEY non configurata)."""
if not _MCP_API_KEY:
return True
auth = request.headers.get("authorization", "")
if not auth.lower().startswith("bearer "):
return False
return auth[7:].strip() == _MCP_API_KEY
@router.get("/api/mcp/sse")
async def mcp_sse_endpoint(request: Request) -> StreamingResponse:
"""
GET /api/mcp/sse β MCP SSE transport session init (P20-B2).
Apre uno stream SSE e invia immediatamente l'endpoint per i messaggi.
Mantiene la connessione viva con keepalive ogni 30s.
Client chiude β session rimossa automaticamente.
"""
if not _sse_auth_check(request):
return JSONResponse( # type: ignore[return-value]
{"error": "Unauthorized", "hint": "Authorization: Bearer <MCP_API_KEY>"},
status_code=401,
headers={"WWW-Authenticate": "Bearer realm=\"agente-ai-mcp\""},
)
session_id: str = str(uuid.uuid4())
queue: asyncio.Queue = asyncio.Queue()
_SSE_SESSIONS[session_id] = queue
async def event_stream():
try:
# 1. Notifica endpoint per i messaggi
endpoint_url = f"/api/mcp/messages?sessionId={session_id}"
yield f"event: endpoint\ndata: {endpoint_url}\n\n"
# 2. Stream risposta + keepalive
while True:
try:
msg = await asyncio.wait_for(queue.get(), timeout=30.0)
if msg is None: # sentinel β chiudi stream
break
yield f"data: {json.dumps(msg, ensure_ascii=False)}\n\n"
except asyncio.TimeoutError:
yield ": keepalive\n\n" # comment line β nessun evento
except asyncio.CancelledError:
pass # client disconnesso
finally:
_SSE_SESSIONS.pop(session_id, None)
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no", # disabilita buffering Nginx
"Connection": "keep-alive",
},
)
@router.post("/api/mcp/messages")
async def mcp_messages_endpoint(request: Request, sessionId: str) -> JSONResponse:
"""
POST /api/mcp/messages?sessionId=<id> β MCP SSE messages endpoint (P20-B2).
Riceve JSON-RPC (singolo o batch), processa con _handle_one(),
invia il risultato alla queue SSE della sessione, risponde 202 Accepted.
Il client riceve la risposta reale come SSE event sul canale aperto.
"""
if not _sse_auth_check(request):
return JSONResponse(
{"error": "Unauthorized"},
status_code=401,
headers={"WWW-Authenticate": "Bearer realm=\"agente-ai-mcp\""},
)
queue = _SSE_SESSIONS.get(sessionId)
if queue is None:
return JSONResponse(
{"error": "session_not_found", "sessionId": sessionId,
"hint": "Apri prima GET /api/mcp/sse per ottenere il sessionId"},
status_code=404,
)
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "invalid_json"}, status_code=400)
# Processa con la stessa logica del POST /api/mcp
if isinstance(body, list):
results: Any = await asyncio.gather(*[_handle_one(r) for r in body])
result_payload = [r for r in results if r] # filtra notification
else:
result_payload = await _handle_one(body)
# Invia risposta alla SSE queue
if result_payload:
await queue.put(result_payload)
return JSONResponse({"ok": True, "sessionId": sessionId}, status_code=202)
|