modelcontextprotocol / modelcontextprotocol/python-sdk

MCPServer serves subscriptions/listen and advertises listChanged=true unconditionally — hold-open streams pin serverless invocations to the platform timeout

Đang mở
#3,493 3 bình luận 0 reaction 0 người được giao Xem trên GitHub

Chưa có ai nhận issue này.

spec-2026-07-28 v2
Ngôn ngữ chính
Python
Star
24.3k
Fork
4k
Merge trung bình
1 ngày 1 giờ
Pull request đã merge (30 ngày)
31

Mô tả

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=...), no json_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.

Hướng dẫn đóng góp

Mở hướng dẫn đóng góp

Bắt đầu từ đâu

  1. Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
  2. Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
  3. Fork repository và làm thay đổi trên một nhánh.
  4. Mở pull request có tham chiếu số hiệu của issue.

Hướng nghiên cứu

Bắt đầu với việc khởi tạo MCPServer, đăng ký handler subscriptions/listen và Server.get_capabilities(), sau đó tái hiện các request server/discover và subscriptions/listen được nêu trong issue. So sánh các thiết kế opt-out công khai được yêu cầu và theo dõi cách việc xóa handler private hiện có làm thay đổi các capabilities được quảng bá. Được xem là hoàn tất khi một cấu hình được hỗ trợ vô hiệu hóa handler và báo cáo listChanged=false cùng subscribe=false, với hành vi serverless được bao phủ bởi các test.

Do mô hình lập chỉ mục viết ra từ nội dung của issue.

Đánh giá

Công nghệ
python
Lĩnh vực
api, backend
Loại issue
Tính năng
Độ khó
4/5
Thời gian dự kiến
3-5 ngày
Mức độ hoạt động
Sôi nổi
Độ rõ ràng
Khá rõ ràng
Mức phù hợp với người mới
57/100

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.