Rejected streamable-HTTP requests leave live sessions behind: the session is registered before the request is validated

在默认的有状态 streamable-HTTP 配置下,服务端在请求校验之前就先注册了会话,因此被拒绝的请求(400/405/406/421 等)会留下未终止的会话;优先排查 `stateless_http`、`session_idle_timeout` 的取值,以及是否有反向代理限流兜底。

快速结论:在默认的有状态 streamable-HTTP 配置下,服务端在请求校验之前就先注册了会话,因此被拒绝的请求(400/405/406/421 等)会留下未终止的会话;优先排查 `stateless_http`、`session_idle_timeout` 的取值,以及是否有反向代理限流兜底。

适用环境:MCP Python SDK 2.x(`main`,复现于 `a4f4ccd0`);评论中另在 PyPI `mcp` 1.29.0 上、lowlevel `Server` 直连 `StreamableHTTPSessionManager`、默认参数、无 FastMCP 的情况下复现。未提供操作系统、Python、CUDA 或显卡信息。

最快修复方案:暂无确认的一步修复方案(Issue 已关闭,但讨论中给出的“先校验后注册”属于修复方向,未在 Issue 中标记为已验证落地)。

注意事项:关闭被拒请求路径并不能消除会话无界增长——同一无鉴权服务器上,合法的 `initialize` 请求同样会创建会话。真正相关的是缺少会话上限,以及默认关闭且无法从 `streamable_http_app()` 配置的空闲回收(#2455)。反向代理限流只是把泄漏速率压到可接受范围,不是根因修复。

问题场景

用户在按文档方式构建有状态 streamable-HTTP 服务时触发:`MCPServer(…).streamable_http_app()`、`Server(…).streamable_http_app()` 或 `mcp.run(“streamable-http”)`,即 `stateless_http=False`(默认)、`session_idle_timeout=None`(默认)。当客户端发来服务端自身会拒绝的请求(方法不支持、`Accept` 不满足、DNS 重绑定保护触发等),或扫描器、健康检查、对 `/mcp` 的杂散探测打到该端点时,`StreamableHTTPSessionManager` 会先创建并注册会话,随后才在校验中拒绝请求,被拒请求的会话不会被回收。

报错原文

Rejected streamable-HTTP requests leave live sessions behind: the session is registered before the request is validated

原因分析

可能原因位于 `src/mcp/server/streamable_http_manager.py` 的 `_handle_stateful_request`:该分支仅根据 `request_mcp_session_id is None` 判断,先 `uuid4().hex` 生成会话 ID、构造 `StreamableHTTPServerTransport`、写入 `self._server_instances`,再 `await self._task_group.start(run_server)`,最后才调用 `http_transport.handle_request(…)`;而 Host / DNS 重绑定、`Accept`、`Content-Type`、JSON 解析、JSON-RPC 校验以及非 `initialize` POST 的“Missing session ID”检查都在 transport 下游。错误路径既不调用 `terminate()` 也不从字典中移除条目,因此会话一直存活。

评论补充了两种现象:其一,被拒的 `406` 仍会返回可用的 `Mcp-Session-Id`,后续带该 ID 的请求会被泄漏会话返回 `200`;其二,`HEAD`/`OPTIONS` 返回的 `405` 同样会留下会话,说明该行为与具体方法无关。另有评论指出,成功的 `DELETE` 也不会注销会话:`run_server` 的 `finally` 中判断了 `not http_transport.is_terminated`,而 `terminate()` 在会话任务退出前已置 `_terminated = True`,导致干净关闭的会话反而跳过清理,注册表条目永久残留。评论认为这一点与本 Issue 的主路径不同,修复“先校验后注册”并不能覆盖它。

不受影响的情况:`stateless_http=True`(传输按请求终止);位于 `RequireAuthMiddleware` 之后的服务器,`401` 在 manager 运行前返回(评论已用 300 次未鉴权请求验证为零会话、无 session-id 头);携带 `MCP-Protocol-Version: 2026-07-28` 的请求会路由到无会话的现代处理器。但该头由客户端控制,省略即回到有会话路径,不能作为运营方依赖的防护。

环境排查

  • 确认 SDK 版本与来源:2.x `main` 或 PyPI `mcp` 1.29.0 等发布线,行为可能不同。
  • 确认是否使用默认有状态配置:`stateless_http` 是否为 `False`、`session_idle_timeout` 是否为 `None`。
  • 确认服务是否位于 `RequireAuthMiddleware` 之后;若在之后,未鉴权请求在进入 manager 前已被拦截。
  • 确认是否存在反向代理限流等外部兜底措施(评论建议对 `initialize` 方法按 IP 限流,如 50 POST/min,属经验性缓解,非 Issue 验证过的修复)。
  • 确认复现时是否观察 `_server_instances` 条数变化,以及被拒响应是否携带 `Mcp-Session-Id` 头。

解决步骤

  1. 先按上述“环境排查”确认自己命中的是哪条路径:默认有状态 + 无鉴权 + 无空闲回收,是被拒请求泄漏会话的典型组合。
  2. 如可接受无状态语义,可优先尝试设置 `stateless_http=True`,使传输按请求终止;评论指出该配置不受影响,但会改变会话语义,需评估客户端兼容性。
  3. 如需保留有状态服务,可在反向代理层对 MCP 端点限流,尤其针对 `initialize` 方法按 IP 限制速率,把泄漏会话的积累速度压到进程重启前不会耗尽内存的范围(评论区经验做法)。
  4. 关注 #2455 的进展:把 `session_idle_timeout` 接入 `streamable_http_app()` 的公开 API,并引入会话上限,是评论中认为同时解决合法会话增长与被拒请求泄漏的长期方向。
  5. 若参与修复,评论建议采用“延迟注册”而非“在 manager 中重复校验”:提前生成会话 ID 供响应头使用,等 `handle_request()` 返回非错误状态后再写入 `_server_instances`,校验失败则直接丢弃 ID,无需清理。

验证方法

对端点发送会被拒绝的请求(如 `GET`、`HEAD`、`OPTIONS` 或 `Accept` 不满足的请求),观察返回状态码,并统计 `_server_instances` 条目数是否随请求增长;同时检查被拒响应是否带 `Mcp-Session-Id`,以及用该 ID 发起的后续请求是否被返回 `200`。修复后,被拒请求应不再产生注册条目,且被拒响应不应授予可用会话句柄。另外可对成功 `initialize` 后的会话执行 `DELETE`,确认注册表条目随之下线。

参考来源

modelcontextprotocol/python-sdk #3228

GamsGo AI

AI 工具推荐

想把多个 AI 模型放在一个入口?

GamsGo AI 集成 ChatGPT、DeepSeek、Gemini、Claude、Midjourney、Veo 等常用模型,适合写作、绘图、视频和日常 AI 工作流。

了解 GamsGo AI

推广链接:通过此链接购买,我可能获得佣金,不影响你的价格。

这个方案解决了吗?

celebrityanime
celebrityanime
文章: 32531

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注