Skip to content

Mcp-Param-* schema listing is not cancelled when a 2026-07-28 SSE client disconnects #3849

Open

Description

@santangelx

Initial Checks

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

  1. added
    bugSomething isn't working
    spec-2026-07-28Concerns the SDK's implementation of the 2026-07-28 MCP spec revision
    v2Affects the v2 line (2.x on main)
    on Nov 11, 2026
  2. media-ctrl commented on Nov 11, 2026

    @media-ctrl

    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.py the lookup is awaited at line 438 and the task group that starts watch_disconnect is only created at line 483. So between those two lines nothing is watching http.disconnect, and a client that leaves during the listing is not observed at all on this path. Since _MCP_PARAM_LIST_PAGE_CAP is 100 at line 250 and each page goes through serve_one with a fresh Connection, 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_schema is except Exception at line 302, and asyncio.CancelledError is a BaseException subclass, 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 to send at 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:

    1. Create the disconnect watcher before the lookup and share its scope with the handler. Smallest diff, but it changes where 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.
    2. Have _tool_input_schema take 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 set get_tool_input_schema skip 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 nextCursor terminate well before it? If real catalogs page two or three times, option 1 is enough, and the cap is only a hang guard.

  3. santangelx commented on Nov 11, 2026

    @santangelx
    Author

    Thanks, that matches what I see at HEAD.

    On the page cap: in our case it isn't reachable. FastMCP doesn't paginate tools/list unless list_page_size is set. Our server returns its whole catalog (32 tools, about 70 KB) in one page with no nextCursor, 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 in handle_modern_request), and the lookup itself never calls receive(). Starting watch_disconnect first 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, after watch_disconnect starts and before run_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.)

  4. santangelx commented on Nov 11, 2026

    @santangelx
    Author

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingspec-2026-07-28Concerns the SDK's implementation of the 2026-07-28 MCP spec revisionv2Affects the v2 line (2.x on main)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions