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 | - |