modelcontextprotocol / modelcontextprotocol/python-sdk
MCPServer serves subscriptions/listen and advertises listChanged=true unconditionally — hold-open streams pin serverless invocations to the platform timeout
还没有人认领这个 Issue。
- 主要语言
- Python
- 星标
- 24.3k
- 派生
- 4k
- 平均合并
- 1 天 1 小时
- 30 天内合并 PR
- 31
描述
Summary
MCPServer always registers the subscriptions/listen handler (on_subscriptions_listen=ListenHandler(self._subscriptions)), and Server.get_capabilities() derives tools/prompts/resources.listChanged = true and resources.subscribe = true from the mere presence of that handler on the 2026-07-28 wire. A server that never publishes any change notification therefore advertises subscriptions, 2026-era clients open subscriptions/listen, and the response stream stays open until the client closes it (as specified).
On serverless HTTP platforms where a client disconnect does not propagate to the app (AWS Lambda Function URL with response streaming behind Lambda Web Adapter, in our case), every such stream pins one invocation until the platform timeout. There is no public way to opt out.
This is the same failure mode as #3492 (legacy GET stream), now on the modern wire.
Environment
- mcp 2.1.1 (local reproduction) and 2.2.0 (production, same behaviour)
MCPServer(...)+mcp.streamable_http_app(streamable_http_path="/", stateless_http=True, transport_security=...), nojson_response- uvicorn 0.52.4 (uvloop + httptools), sse-starlette 3.4.8, Python 3.12
- AWS Lambda (container image, Function URL
RESPONSE_STREAM, aws-lambda-web-adapter 0.9.1) — client disconnects never reach the ASGI app - Clients: TypeScript SDK based (auto-open listen on connect) and a hosted connector
Reproduction (no Lambda needed)
import asyncio, json
from mcp.server.mcpserver import MCPServer
mcp = MCPServer("demo")
@mcp.tool()
async def hello() -> str:
return "hi"
mcp.streamable_http_app(streamable_http_path="/", stateless_http=True)
sm = mcp.session_manager
META = {"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {"name": "probe", "version": "1"},
"io.modelcontextprotocol/clientCapabilities": {}}
def scope(body, method):
return {"type": "http", "http_version": "1.1", "method": "POST", "scheme": "http", "path": "/",
"raw_path": b"/", "query_string": b"", "server": ("localhost", 3000), "client": ("127.0.0.1", 1),
"headers": [(b"host", b"localhost"), (b"accept", b"application/json, text/event-stream"),
(b"content-type", b"application/json"), (b"content-length", str(len(body)).encode()),
(b"mcp-protocol-version", b"2026-07-28"), (b"mcp-method", method.encode())]}
async def post(msg, timeout=3):
body = json.dumps(msg).encode(); got = False
async def receive():
nonlocal got
if not got:
got = True
return {"type": "http.request", "body": body, "more_body": False}
await asyncio.sleep(3600) # like Lambda: no http.disconnect ever arrives
events, done = [], asyncio.Event()
async def send(m):
events.append(m)
if m["type"] == "http.response.body" and not m.get("more_body", False):
done.set()
task = asyncio.create_task(sm.handle_request(scope(body, msg["method"]), receive, send))
try:
await asyncio.wait_for(done.wait(), timeout); print(msg["method"], "completed")
except asyncio.TimeoutError:
print(msg["method"], "STILL OPEN after", timeout, "s")
print(" ", b"".join(e.get("body", b"") for e in events if e["type"] == "http.response.body")[:200])
task.cancel()
async def main():
async with sm.run():
await post({"jsonrpc": "2.0", "id": "d1", "method": "server/discover", "params": {"_meta": META}})
await post({"jsonrpc": "2.0", "id": "listen:1", "method": "subscriptions/listen",
"params": {"_meta": META, "notifications": {"toolsListChanged": True}}})
asyncio.run(main())
Output:
server/discover completed
{"jsonrpc":"2.0","id":"d1","result":{"cacheScope":"private","capabilities":{"prompts":{"listChanged":true},"resources":{"listChanged":true,"subscribe":true},"tools":{"listChanged":true}}, ...
subscriptions/listen STILL OPEN after 3 s
event: message\r\ndata: {"jsonrpc":"2.0","method":"notifications/subscriptions/acknowledged","params":{"_meta":{"io.modelcontextprotocol/subscriptionId":"listen:1"},"notifications":{"toolsListChanged":true}}}
The server has nothing it could ever publish, yet it advertises listChanged: true and holds the listen stream open.
Impact observed in production
After migrating from mcp 1.29 (FastMCP) to 2.x, the only method that never completed was subscriptions/listen: in a 30-minute sample, 19 of 19 listen POSTs were still unanswered after 60 s while every other method (tools/call, tools/list, prompts/list, resources/list, server/discover, initialize) completed in milliseconds. Each open stream ran to the Lambda timeout (900 s); ~1,300–1,800 such invocations per day accounted for 99% of the function's billed GB-seconds. Clients re-sent the listen request immediately after each timeout, so the streams were permanently occupied.
Current workaround
mcp._lowlevel_server._request_handlers.pop("subscriptions/listen")
With the handler gone, get_capabilities() reports listChanged: false / subscribe: false, and a listen POST gets the SDK's standard 404 + JSON-RPC -32601, which the TypeScript client treats as a soft error ("a failed auto-open MUST NOT fail connect"). It works, but it depends on two private attributes.
Request
A public way to run a server without subscriptions/listen, for example MCPServer(subscriptions=None) meaning "not served" (today None means "use the in-memory bus"), or an explicit listen=False / serve_subscriptions=False flag — and have get_capabilities() derive listChanged=false from it, as it already does from handler presence. Alternatively, a documented guidance for stateless/serverless deployments.
Thanks for the SDK — the 2.x transport is otherwise working well for us.
贡献指南
从这里开始
- 先读完整个 Issue,再读项目的贡献指南。
- 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
- Fork 仓库,在一个分支上完成修改。
- 提交 Pull Request,并在描述里引用这个 Issue 编号。
调研方向
从 MCPServer 初始化、subscriptions/listen handler 注册和 Server.get_capabilities() 开始,然后复现 issue 中所示的 server/discover 和 subscriptions/listen 请求。比较所请求的公开 opt-out 设计,并追踪现有的 private handler 移除如何改变所公布的 capabilities。完成的标准是:受支持的配置会禁用该 handler,并报告 listChanged=false 和 subscribe=false,同时通过测试覆盖 serverless 行为。
由索引模型根据 Issue 内容生成。
评估
- 技术栈
- python
- 领域
- api, backend
- Issue 类型
- 功能
- 难度
- 4/5
- 预计耗时
- 3-5 天
- 活跃度
- 活跃
- 描述清晰度
- 基本清楚
- 新手友好度
- 57/100