modelcontextprotocol / modelcontextprotocol/python-sdk

Bug: AssertionError: Request already responded to — cancellation race in v1.27.0

オープン
#2,416 コメント 14 件 リアクション 0 件 担当者 0 名 GitHub で見る

まだ誰も着手していません。

bug fix proposed P2 ready for work
主要言語
Python
スター
24.3k
フォーク
4k
平均マージ
1日 1時間
マージ済み PR(30日)
31

説明

Initial Checks
Description

Bug: AssertionError: Request already responded to — cancellation race in v1.27.0

AssertionError: Request already responded to when CancelledNotification arrives after handler completes but before respond()

Description

When a client sends a notifications/cancelled for a request whose handler has already finished executing but hasn't yet called message.respond(), the server crashes with AssertionError: Request already responded to.

PR #2334 (v1.27.0) fixed the ClosedResourceError crash path by catching CancelledError in _handle_request and guarding respond() against BrokenResourceError/ClosedResourceError. However, it left a race window between handler completion and respond() where a cancellation notification can set _completed = True first, causing the assert on line 129 of session.py to fire.

Reproduction scenario

  1. Client sends a tools/call request with a long-running handler (e.g. polling with 600s timeout)
  2. Handler completes and returns a result
  3. Between the handler's return and the await message.respond(response) call in _handle_request, the client sends notifications/cancelled for that same request ID
  4. The cancellation notification handler (session.py:403-406) calls responder.cancel(), which:
    • Calls _cancel_scope.cancel()
    • Sets _completed = True
    • Sends an error response "Request cancelled"
  5. Back in _handle_request, execution reaches await message.respond(response) at server.py:800
  6. respond() hits assert not self._completed at session.py:129crash

The cancel_scope.cancel() only raises CancelledError if the task is currently in an await. Since the handler already returned, the code path between the handler return and respond() is synchronous — no checkpoint where CancelledError can be delivered. The except anyio.get_cancelled_exc_class() guard at server.py:773 never fires.

Stack trace

ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
  +-+---------------- 1 ----------------
    | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
    +-+---------------- 1 ----------------
      | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
      +-+---------------- 1 ----------------
        | Traceback (most recent call last):
        |   File "mcp/server/lowlevel/server.py", line 703, in _handle_message
        |     await self._handle_request(message, req, session, lifespan_context, raise_exceptions)
        |   File "mcp/server/lowlevel/server.py", line 800, in _handle_request
        |     await message.respond(response)
        |   File "mcp/shared/session.py", line 129, in respond
        |     assert not self._completed, "Request already responded to"
        | AssertionError: Request already responded to
        +------------------------------------

Impact

This crashes the entire MCP server process, killing all in-flight requests. In our case, the server manages multiple long-running background agents, so a crash loses all active work. The crash is triggered by normal client behavior (user cancels an operation), making it a reliability issue rather than an edge case.

Related

  • #2328 — original ClosedResourceError report
  • #2334 — v1.x fix (covers ClosedResourceError but not this assert race)
  • #2306 — main branch transport-close cancellation
Example Code
The race window in `_handle_request` (`server.py:719`):


# Line 770: handler completes, returns response
response = await handler(req)

# ... exception handling ...

# Line 799-800: GAP — between handler return and respond(),
# a CancelledNotification can arrive on another task and call
# responder.cancel(), setting _completed = True and sending
# an error response. No await in this gap means no CancelledError
# can be delivered.
try:
    await message.respond(response)  # <-- assert fires here
except (anyio.BrokenResourceError, anyio.ClosedResourceError):
    ...


The `except anyio.get_cancelled_exc_class()` at line 773 correctly handles the case where the cancellation arrives *during* handler execution. But it cannot handle cancellation that arrives *after* the handler returns, because there's no async checkpoint between the handler return and `respond()`.

## Suggested fix

In `session.py`, change `respond()` to handle the already-completed case gracefully instead of asserting:


async def respond(self, response: SendResultT | ErrorData) -> None:
    if not self._entered:
        raise RuntimeError("RequestResponder must be used as a context manager")
    
    # If already completed (e.g. by a concurrent cancellation), skip silently.
    if self._completed:
        return

    if not self.cancelled:
        self._completed = True
        await self._session._send_response(
            request_id=self.request_id, response=response
        )


Alternatively, the guard could be added in `_handle_request` before calling `respond()`:


if not message._completed:
    try:
        await message.respond(response)
    except (anyio.BrokenResourceError, anyio.ClosedResourceError):
        logger.debug("Response for %s dropped - transport closed", message.request_id)


The first approach (in `respond()` itself) is more robust since it closes the race for all callers.
Python & MCP Python SDK
- `mcp` 1.27.0
- Python 3.14
- anyio (asyncio backend)
- FastMCP stdio transport
- Client: Claude Code 2.1.92

コントリビューションガイド

コントリビューションガイドを開く

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

調査の方向性

mcp/shared/session.py の RequestResponder.respond() と、mcp/server/lowlevel/server.py の _handle_request、および 403-406 行付近のキャンセルハンドラーから始めます。notifications/cancelled を送信しながら長時間実行される tools/call ハンドラーを完了させて競合状態を再現し、その後、同時実行された完了がどのように処理されるかを調べます。サーバーが AssertionError を発生させなくなり、キャンセルによって最大 1 つのレスポンスだけが生成され、実行中の処理がクラッシュしなければ完了です。

索引モデルが issue の本文から書いたものです。

評価

技術スタック
python
領域
api, backend
issue の種類
バグ
難易度
4/5
見積もり時間
3〜5日
活発さ
活発
明瞭さ
明確に書かれている
初心者へのやさしさ
68/100

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。