Skip to content

Progress callbacks run concurrently, out of order, and after call_tool() returns #3747

Open

Description

@dhruvindave007

Initial Checks

Release line

2.x (current stable)

Description

Over any transport that goes through JSONRPCDispatcher (stdio, SSE, streamable HTTP, in-memory streams), each notifications/progress spawns its own task for the request's progress_callback, and nothing waits for it. As soon as the callback awaits anything:

  • callbacks for the same request run concurrently and can finish in any order
  • call_tool() / send_request() can return before earlier progress callbacks have run

With the example below, a real transport prints:

['call_tool returned', 'progress 3.0', 'progress 2.0', 'progress 1.0']

The same code with Client(server) (in-process DirectDispatcher, which awaits the callback inline) prints what I'd expect:

['progress 1', 'progress 2', 'progress 3', 'call_tool returned']

v1 also delivered progress in order, before the result.

Cause: in src/mcp/shared/jsonrpc_dispatcher.py, _dispatch_notification, the notifications/progress branch calls self._spawn(_shielded_progress(pending.on_progress), ...) once per notification. The response then resolves the pending request independently of those tasks. tests/interaction/lowlevel/test_progress.py doesn't catch it because its callback never awaits.

Impact: progress bars go backwards, a final "100%" lands after the result was handled, and callbacks can touch state the caller already cleaned up. The server side has the same problem (send_request(..., progress_callback=...)).

Possible fix: deliver progress for each pending request through a single ordered queue with one consumer, and drain it before send_raw_request hands back the result. Awaiting the callback inline in the read loop would also restore ordering, but a slow callback would then block every other message on the connection.

I'm happy to send a PR with a regression test if this approach sounds right.

I used an AI assistant to help trace the cause; I reproduced and verified it myself.

Example Code

import anyio
from mcp.client import Client
from mcp.client._memory import InMemoryTransport
from mcp.server.mcpserver import Context, MCPServer

server = MCPServer("progress")


@server.tool()
async def work(ctx: Context) -> str:
    for p in (1, 2, 3):
        await ctx.report_progress(p, 3)
    return "done"


async def main() -> None:
    events: list[str] = []

    async def on_progress(progress: float, total: float | None, message: str | None) -> None:
        await anyio.sleep(0.1 * (4 - progress))  # e.g. updating a UI or writing a log
        events.append(f"progress {progress}")

    # Swap in Client(server) to see the correct order.
    async with Client(InMemoryTransport(server)) as client:
        await client.call_tool("work", {}, progress_callback=on_progress)
        events.append("call_tool returned")
        await anyio.sleep(1)
    print(events)


anyio.run(main)

Python & MCP Python SDK

Python 3.12.14
mcp 2.3.0 (also reproduced on main @ 2af9e30)
anyio 4.15.1
Windows 11

Activity

  1. added
    bugSomething isn't working
    v2Affects the v2 line (2.x on main)
    on Oct 23, 2026
  2. Jihaochang1 commented on Oct 24, 2026

    @Jihaochang1

    Confirming the reproduction on main @ 2af9e30, independently of the report: with the example above the events list comes out ['call_tool returned', 'progress 3.0', 'progress 2.0', 'progress 1.0'] over the stream transports (in-memory legacy, SSE, streamable HTTP stateful and stateless), and only the modern in-process path (DirectDispatcher) prints the expected ['progress 1.0', 'progress 2.0', 'progress 3.0', 'call_tool returned']. The existing test_progress_during_tool_call_reaches_callback_in_order cannot catch this because its callback never awaits.

    I put together a fix along the lines the report suggests, for what it's worth: each notification's task awaits its predecessor's done-event before invoking the callback, so the callbacks for one request run one at a time in arrival order, and send_raw_request drains the tail of the chain before the request completes. The read loop still never blocks on a callback (inline awaiting would stall every other message on the connection), the callback stays shielded and exception-contained, progress arriving after the response is still dropped, and because the same dispatcher serves call_tool(progress_callback=...) and send_request(progress_callback=...) the server direction is covered too. The regression test fails on 5 of the 6 transport-matrix cells before the change and passes on all 6 after; the parked PR is #3753.

    One design question the fix surfaces: the drain currently also runs on the error-response path (a JSON-RPC error response still waits for in-flight callbacks) but not on the caller-cancelled path, where the request propagates immediately. Happy to hear if maintainers would rather have it consistent the other way.

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 workingv2Affects 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