diff --git a/services/loogle-mcp/app/main.py b/services/loogle-mcp/app/main.py index ee6d73d..4b3c962 100644 --- a/services/loogle-mcp/app/main.py +++ b/services/loogle-mcp/app/main.py @@ -11,7 +11,7 @@ from typing import Optional import anyio from fastapi import Depends, FastAPI, Form, HTTPException, Request, Response from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, StreamingResponse from pydantic import BaseModel from starlette.concurrency import run_in_threadpool @@ -30,7 +30,7 @@ app.add_middleware( "https://chatgpt.com", "https://chat.openai.com", ], - allow_methods=["GET", "POST", "OPTIONS"], + allow_methods=["GET", "POST", "HEAD", "OPTIONS"], allow_headers=["*"], ) STATIC_DIR = os.path.join(os.path.dirname(__file__), "static") @@ -226,8 +226,10 @@ _UNAUTHENTICATED_METHODS = ("initialize", "notifications/initialized", "ping") async def mcp_post(request: Request): header_version = request.headers.get("mcp-protocol-version") if header_version and header_version not in mcp_server.SUPPORTED_PROTOCOL_VERSIONS: - return JSONResponse( - {"error": f"MCP-Protocol-Version non supportata: {header_version}"}, status_code=400 + # Non bloccare: Claude/Anthropic mandano versioni nuove; negoziamo nel body initialize + LOGGER.warning( + "MCP-Protocol-Version header=%s non in lista locale — continuo (negoziazione JSON-RPC)", + header_version, ) auth_header = request.headers.get("authorization", "") claims = await run_in_threadpool(oauth.bearer_claims_from_header, auth_header) @@ -285,15 +287,84 @@ def _timeout_error(msg: dict) -> dict: } -@app.get("/mcp") -def mcp_get(): +@app.post("/") +async def root_mcp_post(request: Request): + """Alcuni client (Claude) dopo OAuth POST-ano sulla root invece che su /mcp.""" + ctype = (request.headers.get("content-type") or "").lower() + accept = (request.headers.get("accept") or "").lower() + if "json" not in ctype and "json" not in accept and "event-stream" not in accept: + raise HTTPException(404, "Not Found") + return await mcp_post(request) + + +@app.api_route("/mcp", methods=["GET", "HEAD"]) +async def mcp_get(request: Request): + """Streamable HTTP: GET può aprire SSE; HEAD serve alla discovery di Claude.""" + if request.method == "HEAD": + return Response( + status_code=200, + headers={ + **_auth_challenge_headers(), + "Allow": "GET, HEAD, POST, OPTIONS", + "Content-Type": "application/json", + }, + ) + accept = (request.headers.get("accept") or "").lower() + if "text/event-stream" in accept: + # Nessun messaggio server-initiated: stream SSE vuoto che si chiude subito + async def _empty(): + yield ": loogle-mcp ready\n\n" + + return StreamingResponse( + _empty(), + media_type="text/event-stream", + headers={ + **_auth_challenge_headers(), + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }, + ) return JSONResponse( - {"error": "Use POST for MCP JSON-RPC"}, - status_code=405, + { + "ok": True, + "service": "loogle-mcp", + "transport": "streamable-http", + "endpoint": "/mcp", + "hint": "Usa POST JSON-RPC (initialize, tools/list, tools/call)", + }, headers=_auth_challenge_headers(), ) +@app.api_route("/mcp/", methods=["GET", "HEAD", "POST", "OPTIONS"]) +async def mcp_slash(request: Request): + """Evita redirect HTTP su /mcp/ (NPM) che rompe Claude.""" + if request.method == "POST": + return await mcp_post(request) + if request.method == "OPTIONS": + return Response( + status_code=204, + headers={ + "Allow": "GET, HEAD, POST, OPTIONS", + "Access-Control-Allow-Methods": "GET, HEAD, POST, OPTIONS", + "Access-Control-Allow-Headers": "Authorization, Content-Type, Accept, MCP-Protocol-Version, Mcp-Session-Id", + }, + ) + return await mcp_get(request) + + +@app.options("/mcp") +async def mcp_options(): + return Response( + status_code=204, + headers={ + "Allow": "GET, HEAD, POST, OPTIONS", + "Access-Control-Allow-Methods": "GET, HEAD, POST, OPTIONS", + "Access-Control-Allow-Headers": "Authorization, Content-Type, Accept, MCP-Protocol-Version, Mcp-Session-Id", + }, + ) + + def _auth_challenge_headers() -> dict: base = jwt_utils.base_url() resource_metadata = f"{base}/.well-known/oauth-protected-resource/mcp" diff --git a/services/loogle-mcp/app/mcp/server.py b/services/loogle-mcp/app/mcp/server.py index abc2c79..91e8022 100644 --- a/services/loogle-mcp/app/mcp/server.py +++ b/services/loogle-mcp/app/mcp/server.py @@ -10,12 +10,15 @@ from . import tools LOGGER = logging.getLogger("loogle_mcp.server") # Versioni supportate, dalla più recente: si risponde con quella chiesta dal client # se supportata, altrimenti con la più recente (negoziazione MCP lifecycle) -SUPPORTED_PROTOCOL_VERSIONS = ("2025-06-18", "2025-03-26", "2024-11-05") +SUPPORTED_PROTOCOL_VERSIONS = ("2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05") def negotiate_protocol_version(requested: Optional[str]) -> str: if requested in SUPPORTED_PROTOCOL_VERSIONS: return requested + # Client più nuovi: accettiamo la richiesta dichiarando la più recente che implementiamo + if requested: + LOGGER.info("protocollo sconosciuto %s — negozio %s", requested, SUPPORTED_PROTOCOL_VERSIONS[0]) return SUPPORTED_PROTOCOL_VERSIONS[0]