diff --git a/ai-hub/app/api/routes/mcp.py b/ai-hub/app/api/routes/mcp.py index 415f794..c9e1082 100644 --- a/ai-hub/app/api/routes/mcp.py +++ b/ai-hub/app/api/routes/mcp.py @@ -1,19 +1,21 @@ """ -MCP (Model Context Protocol) Server Route — Anthropic / SSE Transport +MCP (Model Context Protocol) Server Route — Streamable HTTP + Legacy SSE Transport -Endpoints registered under /api/v1/mcp/*: - GET /mcp/sse - Establish persistent SSE stream (per MCP spec) - POST /mcp/messages - Receive JSON-RPC 2.0 messages from client +Supports: + MCP spec 2025-11-25 — Streamable HTTP (primary, recommended) + MCP spec 2024-11-05 — HTTP+SSE (legacy, backwards-compat) + +Endpoints (mounted under /api/v1/mcp/*): + POST /mcp/sse — Streamable HTTP: JSON-RPC in, JSON response out + POST /mcp/ — Same, aliased for clients using the base path + GET /mcp/sse — Legacy SSE stream (sends endpoint event) + POST /mcp/messages — Legacy SSE message handler Discovery: - GET /.well-known/mcp/manifest.json (registered on root app in app.py) - -Protocol flow: - 1. Client GETs /mcp/sse — server streams an `endpoint` event with the /messages URL - 2. Client POSTs JSON-RPC requests to /mcp/messages?session_id= - 3. Server dispatches the tool, pushes JSON-RPC response back over the SSE stream + GET /.well-known/mcp/manifest.json (mounted in app.py) """ + import asyncio import json import uuid @@ -28,6 +30,8 @@ logger = logging.getLogger(__name__) +MCP_VERSION = "2025-11-25" # Latest MCP specification version + # ─── In-process SSE session registry ───────────────────────────────────────── # Maps session_id → asyncio.Queue of JSON-serializable dicts _sse_sessions: dict[str, asyncio.Queue] = {} @@ -82,11 +86,7 @@ }, ) - # ─── Streamable HTTP Transport (MCP 2025-03-26) ─────────────────────────── - # Modern clients (Antigravity, Cursor, VS Code) POST JSON-RPC directly to - # the serverURL and expect a synchronous JSON response back. - # Both /sse and / are registered so it works regardless of which URL - # the client is pointed at. + # ─── Streamable HTTP Transport (MCP 2025-11-25) ─────────────────────────── @router.post("/sse") @router.post("/") async def mcp_streamable_http( @@ -94,29 +94,57 @@ token: Optional[str] = Query(None), ): """ - Streamable HTTP transport — MCP spec 2025-03-26. - Client sends a JSON-RPC 2.0 request via POST and receives the response - synchronously in the HTTP response body. + Streamable HTTP transport (MCP 2025-11-25 / 2025-03-26). + Client POSTs JSON-RPC and receives the response synchronously. """ + # Origin validation — MUST per MCP 2025-11-25 security spec + origin = request.headers.get("origin") + if origin: + allowed = [ + "https://ai.jerxie.com", + "http://localhost:3000", + "http://localhost:8080", + ] + # Also allow the server's own origin + server_host = request.headers.get("host", "") + allowed.append(f"https://{server_host}") + allowed.append(f"http://{server_host}") + if not any(origin.startswith(a) for a in allowed): + logger.warning(f"[MCP] Rejected request from disallowed origin: {origin}") + return JSONResponse( + {"jsonrpc": "2.0", "error": {"code": -32000, "message": "Forbidden origin"}}, + status_code=403, + ) + try: body = await request.json() except Exception: raise HTTPException(status_code=400, detail="Invalid JSON body.") - # Batch requests (array) — process each and return array + # Batch requests (JSON array) if isinstance(body, list): results = [] for item in body: results.append(await _handle_single(item, token, services)) - return JSONResponse(results, headers={"Access-Control-Allow-Origin": "*"}) + return JSONResponse( + [r for r in results if r is not None], + headers={"Access-Control-Allow-Origin": "*", "MCP-Protocol-Version": MCP_VERSION}, + ) # Single request response = await _handle_single(body, token, services) - # Notifications have no id — return 202 with empty body - if response is None: - return JSONResponse(None, status_code=202, - headers={"Access-Control-Allow-Origin": "*"}) - return JSONResponse(response, headers={"Access-Control-Allow-Origin": "*"}) + if response is None: # notification — no id + return JSONResponse( + None, status_code=202, + headers={"Access-Control-Allow-Origin": "*"}, + ) + + # If initialize, attach a session ID (MAY per spec) + headers = {"Access-Control-Allow-Origin": "*", "MCP-Protocol-Version": MCP_VERSION} + if body.get("method") == "initialize": + headers["Mcp-Session-Id"] = str(uuid.uuid4()) + + return JSONResponse(response, headers=headers) # ─── SSE Transport — Message Handler ───────────────────────────────────── @router.post("/messages") @@ -209,7 +237,7 @@ # ── MCP Handshake ───────────────────────────────────────────────────────── if method == "initialize": return { - "protocolVersion": "2024-11-05", + "protocolVersion": MCP_VERSION, "capabilities": {"tools": {}}, "serverInfo": {"name": "Cortex Hub", "version": "1.0.0"}, }