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
This commit is contained in:
parent
38e03e419d
commit
d8050387e4
3 changed files with 42 additions and 4 deletions
|
|
@ -246,10 +246,12 @@ class MCPMiddleware:
|
||||||
self.single_bank_server = single_bank_server
|
self.single_bank_server = single_bank_server
|
||||||
else:
|
else:
|
||||||
# Create servers internally (for direct construction / tests)
|
# 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_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_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:
|
def _get_header(self, scope: dict, name: str) -> str | None:
|
||||||
"""Extract a header value from ASGI scope."""
|
"""Extract a header value from ASGI scope."""
|
||||||
|
|
@ -272,6 +274,17 @@ class MCPMiddleware:
|
||||||
await self.app(scope, receive, send)
|
await self.app(scope, receive, send)
|
||||||
return
|
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
|
# Strip prefix from path
|
||||||
path = path[len(self.prefix) :] or "/"
|
path = path[len(self.prefix) :] or "/"
|
||||||
|
|
||||||
|
|
@ -401,6 +414,22 @@ class MCPMiddleware:
|
||||||
if schema_token is not None:
|
if schema_token is not None:
|
||||||
_current_schema.reset(schema_token)
|
_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):
|
async def _send_error(self, send, status: int, message: str, extra_headers: dict[str, str] | None = None):
|
||||||
"""Send an error response."""
|
"""Send an error response."""
|
||||||
body = json.dumps({"error": message}).encode()
|
body = json.dumps({"error": message}).encode()
|
||||||
|
|
@ -431,10 +460,14 @@ def create_mcp_servers(memory: MemoryEngine):
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (multi_bank_server, single_bank_server, multi_bank_app, single_bank_app)
|
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_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_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
|
return multi_bank_server, single_bank_server, multi_bank_app, single_bank_app
|
||||||
|
|
|
||||||
|
|
@ -238,6 +238,7 @@ ENV_LOG_FORMAT = "HINDSIGHT_API_LOG_FORMAT"
|
||||||
ENV_WORKERS = "HINDSIGHT_API_WORKERS"
|
ENV_WORKERS = "HINDSIGHT_API_WORKERS"
|
||||||
ENV_MCP_ENABLED = "HINDSIGHT_API_MCP_ENABLED"
|
ENV_MCP_ENABLED = "HINDSIGHT_API_MCP_ENABLED"
|
||||||
ENV_MCP_ENABLED_TOOLS = "HINDSIGHT_API_MCP_ENABLED_TOOLS"
|
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_ENABLE_BANK_CONFIG_API = "HINDSIGHT_API_ENABLE_BANK_CONFIG_API"
|
||||||
ENV_GRAPH_RETRIEVER = "HINDSIGHT_API_GRAPH_RETRIEVER"
|
ENV_GRAPH_RETRIEVER = "HINDSIGHT_API_GRAPH_RETRIEVER"
|
||||||
ENV_MPFP_TOP_K_NEIGHBORS = "HINDSIGHT_API_MPFP_TOP_K_NEIGHBORS"
|
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_WORKERS = 1
|
||||||
DEFAULT_MCP_ENABLED = True
|
DEFAULT_MCP_ENABLED = True
|
||||||
DEFAULT_MCP_ENABLED_TOOLS: list[str] | None = None # None = all tools enabled
|
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_ENABLE_BANK_CONFIG_API = True
|
||||||
DEFAULT_GRAPH_RETRIEVER = "link_expansion" # Options: "link_expansion", "mpfp", "bfs"
|
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
|
DEFAULT_MPFP_TOP_K_NEIGHBORS = 20 # Fan-out limit per node in MPFP graph traversal
|
||||||
|
|
@ -727,6 +729,7 @@ class HindsightConfig:
|
||||||
log_format: str
|
log_format: str
|
||||||
mcp_enabled: bool
|
mcp_enabled: bool
|
||||||
mcp_enabled_tools: list[str] | None # None = all tools; explicit list = allowlist
|
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
|
enable_bank_config_api: bool
|
||||||
|
|
||||||
# Recall
|
# 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()]
|
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)
|
if os.getenv(ENV_MCP_ENABLED_TOOLS)
|
||||||
else DEFAULT_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()
|
enable_bank_config_api=os.getenv(ENV_ENABLE_BANK_CONFIG_API, str(DEFAULT_ENABLE_BANK_CONFIG_API)).lower()
|
||||||
== "true",
|
== "true",
|
||||||
# Recall
|
# Recall
|
||||||
|
|
|
||||||
|
|
@ -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` | 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_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_AUTH_TOKEN` | Bearer token for MCP authentication (optional) | - |
|
||||||
| `HINDSIGHT_API_MCP_LOCAL_BANK_ID` | Memory bank ID for local MCP | `mcp` |
|
| `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 | - |
|
| `HINDSIGHT_API_MCP_INSTRUCTIONS` | Additional instructions appended to retain/recall tool descriptions | - |
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue