Repository navigation
Description
Initial Checks
- I confirm that I'm using the newest release of my line (the latest 2.x, or the latest 1.x if I'm still on v1)
- I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening this issue
Release line
2.x (current stable)
Description
On the 2026-07-28 Streamable HTTP path, a low-level Server without get_tool_input_schema finds the called
tool's input schema for Mcp-Param-* validation by running its own tools/list handler before dispatch
(_mcp_param_rejection → _tool_input_schema, through serve_one, up to _MCP_PARAM_LIST_PAGE_CAP pages).
In handle_modern_request that lookup runs before the task group that starts watch_disconnect. So in SSE
mode, a client that goes away during the listing does not cancel it. The listing runs to completion; then the
tools/call handler is started and immediately cancelled.
The spec says closing the SSE response stream MUST be treated as cancellation, and the server SHOULD
stop work on the cancelled request as soon as practical (streamable-http, §Cancellation).
#3630 lets a server avoid the listing with get_tool_input_schema. This affects servers that still take the
listing fallback: the default for the low-level Server, and frameworks built on it that do not set the hook
yet (FastMCP 4.1.8 and 4.2.0a4, the latest releases; PrefectHQ/fastmcp#5666 adds it on their main). When tools/list is
expensive (per-user visibility, backend reads), every abandoned call still pays a full listing. We saw this in
production on 2.2.0, with a client that fires many short calls and abandons them.
Expected: a disconnect during the schema listing cancels it, and the call is never dispatched.
Actual: the listing runs to completion, then the call is dispatched and cancelled.
Example Code
import asyncio
import threading
import time
import uvicorn
from mcp import types
from mcp.client.client import Client
from mcp.server.lowlevel.server import Server
events: list[str] = []
async def on_list_tools(ctx, params):
events.append("tools/list started")
try:
await asyncio.sleep(1.0) # a slow, per-user listing
except asyncio.CancelledError:
events.append("tools/list cancelled")
raise
events.append("tools/list ran to completion")
return types.ListToolsResult(
tools=[types.Tool(name="echo", input_schema={"type": "object", "properties": {"text": {"type": "string"}}})]
)
async def on_call_tool(ctx, params):
events.append("tools/call dispatched")
try:
await asyncio.sleep(1.0)
except asyncio.CancelledError:
events.append("tools/call cancelled")
raise
return types.CallToolResult(content=[types.TextContent(type="text", text=params.arguments["text"])])
server = Server("repro", on_list_tools=on_list_tools, on_call_tool=on_call_tool)
app = server.streamable_http_app(stateless_http=True, json_response=False)
threading.Thread(target=uvicorn.Server(uvicorn.Config(app, port=8766, log_level="warning")).run, daemon=True).start()
time.sleep(1)
async def main():
async with Client("http://127.0.0.1:8766/mcp", mode="2026-07-28") as client:
try:
await client.call_tool("echo", {"text": "hi"}, read_timeout_seconds=0.2)
except Exception as exc:
print(f"client gave up: {type(exc).__name__}")
await asyncio.sleep(2.5)
for event in events:
print(" ", event)
asyncio.run(main())On main (77001c9):
client gave up: MCPError
tools/list started
tools/list ran to completion
tools/call dispatched
tools/call cancelled
The client left after 0.2 s, but the listing ran its full second anyway. The fallback path is the same in
2.2.0 and 2.3.0.
Python & MCP Python SDK
Python 3.13, mcp 2.2.0 in production; reproduced on main @ 77001c9.
Related: #3565 / #3630 (the per-call cost of the listing), #3762 (JSON-response mode does not observe
disconnects).
I have a small fix with tests on a branch and am happy to open a PR if assigned.
Drafted with AI assistance (Claude Code), which also ran the reproduction above and wrote the fix.
Activity
media-ctrl commentedon Nov 11, 2026
Your reading of the ordering checks out at HEAD, and the gap is wider than one round trip.
In
src/mcp/server/_streamable_http_modern.pythe lookup is awaited at line 438 and the task group that startswatch_disconnectis only created at line 483. So between those two lines nothing is watchinghttp.disconnect, and a client that leaves during the listing is not observed at all on this path. Since_MCP_PARAM_LIST_PAGE_CAPis 100 at line 250 and each page goes throughserve_onewith a freshConnection, the worst case is 100 complete tool listings for a caller that is already gone. With a visibility-scoped catalog that per-user work is not trivial either.The fix is safe to make here, which is worth saying because it is not obvious. The fail-open boundary inside
_tool_input_schemaisexcept Exceptionat line 302, andasyncio.CancelledErroris aBaseExceptionsubclass, so it will not be absorbed and logged as "the tools/list listing failed". Moving the lookup inside a cancel scope propagates cancellation instead of turning it into a skip. Also nothing has been written tosendat line 438, so installing an early scope cannot corrupt a response that was already committed.Two shapes for the change, and I would take the second:
receive()is first consumed, so the same message has to be replayed into the task group or the watcher has to own the receive loop for both._tool_input_schematake the cancel scope and check it between pages, so the multi-page walk aborts promptly even if the watcher is still where it is. That bounds the 100-page case, which is the part that actually hurts, and it stacks with Look the tool schema up by name for Mcp-Param-* validation instead of running tools/list #3630 rather than duplicating it: servers that setget_tool_input_schemaskip the walk entirely, and the ones that still take the fallback get a prompt abort instead of a silent full listing.One thing that would settle whether this needs the second shape: is the 100-page cap reachable in practice for a scoped catalog, or does
nextCursorterminate well before it? If real catalogs page two or three times, option 1 is enough, and the cap is only a hang guard.santangelx commentedon Nov 11, 2026
Thanks, that matches what I see at HEAD.
On the page cap: in our case it isn't reachable. FastMCP doesn't paginate
tools/listunlesslist_page_sizeis set. Our server returns its whole catalog (32 tools, about 70 KB) in one page with nonextCursor, so the walk is a single listing. What hurt wasn't depth but rate. Our client fires bursts of short argument-bearing calls (about 27 at a time) and abandons most of them. Each abandoned call still paid one complete per-user listing: an auth check plus three backend reads. On a small instance that saturated the CPU.So I'd go with the first shape. A check between pages never fires for a one-page walk, and that's the common case. The receive-replay concern doesn't come up on the SSE path: by line 438 the body has already been fully read (
await request.body()earlier inhandle_modern_request), and the lookup itself never callsreceive(). Startingwatch_disconnectfirst leaves it as the only consumer, so there's nothing to replay. The branch does exactly that. It runs the check inside the existing task group, afterwatch_disconnectstarts and beforerun_handler, so a rejection is still a plain JSON 400. JSON-response mode is left alone for #3762.The second shape still stacks nicely for servers whose catalogs do paginate, as a cheap follow-up.
Happy to open the PR if a maintainer assigns this.
(Checked with Claude Code.)
santangelx commentedon Nov 11, 2026
Thanks! The fix and regression tests are already done on a branch: the disconnect watcher now starts before the lookup, with an SSE test that fails on
main. I'll open the PR as soon as a maintainer assigns this. Reviews on it are welcome.By the way, these messages are sent from Claude Code, but there's a very real human behind them who wants this fixed. I'm not a clanker.