From d8050387e40a5daaaeb5fab003cae89aab334d69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Boschi?= Date: Mon, 30 Mar 2026 12:15:06 +0200 Subject: [PATCH] fix(mcp): handle Claude Code GET probe and make stateless_http configurable (#757) * fix(mcp): handle Claude Code GET probe and make stateless_http configurable (#751) Claude Code v2.1.84+ sends a GET to /mcp/ before POST initialize, which fails with 405 (stateless) or 400 (stateful). Intercept sessionless GET requests in MCPMiddleware and return 200 OK so the client proceeds to POST initialize. Also make stateless_http configurable via HINDSIGHT_API_MCP_STATELESS (default: false/stateful) instead of hardcoding true. Closes #751 * docs: add HINDSIGHT_API_MCP_STATELESS to configuration reference --- hindsight-api-slim/hindsight_api/api/mcp.py | 41 +++++++++++++++++-- hindsight-api-slim/hindsight_api/config.py | 4 ++ .../docs/developer/configuration.md | 1 + 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/hindsight-api-slim/hindsight_api/api/mcp.py b/hindsight-api-slim/hindsight_api/api/mcp.py index 7dbe41ae..6350de20 100644 --- a/hindsight-api-slim/hindsight_api/api/mcp.py +++ b/hindsight-api-slim/hindsight_api/api/mcp.py @@ -246,10 +246,12 @@ class MCPMiddleware: self.single_bank_server = single_bank_server else: # Create servers internally (for direct construction / tests) + global_config = _get_raw_config() + stateless = global_config.mcp_stateless self.multi_bank_server = create_mcp_server(memory, multi_bank=True) - self.multi_bank_app = self.multi_bank_server.http_app(path="/", stateless_http=True) + self.multi_bank_app = self.multi_bank_server.http_app(path="/", stateless_http=stateless) self.single_bank_server = create_mcp_server(memory, multi_bank=False) - self.single_bank_app = self.single_bank_server.http_app(path="/", stateless_http=True) + self.single_bank_app = self.single_bank_server.http_app(path="/", stateless_http=stateless) def _get_header(self, scope: dict, name: str) -> str | None: """Extract a header value from ASGI scope.""" @@ -272,6 +274,17 @@ class MCPMiddleware: await self.app(scope, receive, send) return + # Handle GET-before-POST gracefully (Claude Code v2.1.84+ sends GET probe before POST initialize). + # Without a valid Mcp-Session-Id, GET has no meaningful response — return 200 OK so + # the client proceeds to POST initialize instead of marking the server as failed. + method = scope.get("method", "") + if method == "GET": + session_id = self._get_header(scope, "Mcp-Session-Id") + if not session_id: + logger.debug("MCP GET without session ID (client probe) — returning 200 OK") + await self._send_ok(send) + return + # Strip prefix from path path = path[len(self.prefix) :] or "/" @@ -401,6 +414,22 @@ class MCPMiddleware: if schema_token is not None: _current_schema.reset(schema_token) + async def _send_ok(self, send): + """Send a 200 OK response with empty body (used for GET probes without session).""" + await send( + { + "type": "http.response.start", + "status": 200, + "headers": [(b"content-type", b"application/json")], + } + ) + await send( + { + "type": "http.response.body", + "body": b"{}", + } + ) + async def _send_error(self, send, status: int, message: str, extra_headers: dict[str, str] | None = None): """Send an error response.""" body = json.dumps({"error": message}).encode() @@ -431,10 +460,14 @@ def create_mcp_servers(memory: MemoryEngine): Returns: Tuple of (multi_bank_server, single_bank_server, multi_bank_app, single_bank_app) """ + global_config = _get_raw_config() + stateless = global_config.mcp_stateless + multi_bank_server = create_mcp_server(memory, multi_bank=True) - multi_bank_app = multi_bank_server.http_app(path="/", stateless_http=True) + multi_bank_app = multi_bank_server.http_app(path="/", stateless_http=stateless) single_bank_server = create_mcp_server(memory, multi_bank=False) - single_bank_app = single_bank_server.http_app(path="/", stateless_http=True) + single_bank_app = single_bank_server.http_app(path="/", stateless_http=stateless) + logger.info(f"MCP servers created (stateless_http={stateless})") return multi_bank_server, single_bank_server, multi_bank_app, single_bank_app diff --git a/hindsight-api-slim/hindsight_api/config.py b/hindsight-api-slim/hindsight_api/config.py index 3cfc868d..dec09872 100644 --- a/hindsight-api-slim/hindsight_api/config.py +++ b/hindsight-api-slim/hindsight_api/config.py @@ -238,6 +238,7 @@ ENV_LOG_FORMAT = "HINDSIGHT_API_LOG_FORMAT" ENV_WORKERS = "HINDSIGHT_API_WORKERS" ENV_MCP_ENABLED = "HINDSIGHT_API_MCP_ENABLED" ENV_MCP_ENABLED_TOOLS = "HINDSIGHT_API_MCP_ENABLED_TOOLS" +ENV_MCP_STATELESS = "HINDSIGHT_API_MCP_STATELESS" ENV_ENABLE_BANK_CONFIG_API = "HINDSIGHT_API_ENABLE_BANK_CONFIG_API" ENV_GRAPH_RETRIEVER = "HINDSIGHT_API_GRAPH_RETRIEVER" ENV_MPFP_TOP_K_NEIGHBORS = "HINDSIGHT_API_MPFP_TOP_K_NEIGHBORS" @@ -444,6 +445,7 @@ DEFAULT_LOG_FORMAT = "text" # Options: "text", "json" DEFAULT_WORKERS = 1 DEFAULT_MCP_ENABLED = True DEFAULT_MCP_ENABLED_TOOLS: list[str] | None = None # None = all tools enabled +DEFAULT_MCP_STATELESS = False # False = stateful (supports SSE/GET); True = stateless (POST-only) DEFAULT_ENABLE_BANK_CONFIG_API = True DEFAULT_GRAPH_RETRIEVER = "link_expansion" # Options: "link_expansion", "mpfp", "bfs" DEFAULT_MPFP_TOP_K_NEIGHBORS = 20 # Fan-out limit per node in MPFP graph traversal @@ -727,6 +729,7 @@ class HindsightConfig: log_format: str mcp_enabled: bool mcp_enabled_tools: list[str] | None # None = all tools; explicit list = allowlist + mcp_stateless: bool # True = stateless HTTP (POST-only); False = stateful (supports GET/SSE) enable_bank_config_api: bool # Recall @@ -1195,6 +1198,7 @@ class HindsightConfig: mcp_enabled_tools=[t.strip() for t in os.getenv(ENV_MCP_ENABLED_TOOLS).split(",") if t.strip()] if os.getenv(ENV_MCP_ENABLED_TOOLS) else DEFAULT_MCP_ENABLED_TOOLS, + mcp_stateless=os.getenv(ENV_MCP_STATELESS, str(DEFAULT_MCP_STATELESS)).lower() == "true", enable_bank_config_api=os.getenv(ENV_ENABLE_BANK_CONFIG_API, str(DEFAULT_ENABLE_BANK_CONFIG_API)).lower() == "true", # Recall diff --git a/hindsight-docs/docs/developer/configuration.md b/hindsight-docs/docs/developer/configuration.md index ca831606..92464fd5 100644 --- a/hindsight-docs/docs/developer/configuration.md +++ b/hindsight-docs/docs/developer/configuration.md @@ -947,6 +947,7 @@ Configuration for MCP server endpoints. |----------|-------------|---------| | `HINDSIGHT_API_MCP_ENABLED` | Enable MCP server at `/mcp/{bank_id}/` | `true` | | `HINDSIGHT_API_MCP_ENABLED_TOOLS` | Comma-separated allowlist of MCP tools to expose globally (empty = all tools) | - | +| `HINDSIGHT_API_MCP_STATELESS` | Use stateless HTTP transport (POST-only). When `false`, enables stateful mode with GET/SSE support for server-initiated messages | `false` | | `HINDSIGHT_API_MCP_AUTH_TOKEN` | Bearer token for MCP authentication (optional) | - | | `HINDSIGHT_API_MCP_LOCAL_BANK_ID` | Memory bank ID for local MCP | `mcp` | | `HINDSIGHT_API_MCP_INSTRUCTIONS` | Additional instructions appended to retain/recall tool descriptions | - |