modelcontextprotocol / modelcontextprotocol/python-sdk

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

未关闭
#2,416 14 条评论 0 个 reaction 已指派 0 人 在 GitHub 查看

还没有人认领这个 Issue。

bug fix proposed P2 ready for work
主要语言
Python
星标
24.3k
派生
4k
平均合并
1 天 1 小时
30 天内合并 PR
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. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

调研方向

从 mcp/shared/session.py 中的 RequestResponder.respond() 以及 mcp/server/lowlevel/server.py 中的 _handle_request 和 403-406 行附近的取消处理程序开始。通过在发送 notifications/cancelled 的同时完成一个长时间运行的 tools/call 处理程序来复现竞争条件,然后检查并发完成是如何处理的。完成标准是 server 不再引发 AssertionError,并且取消操作最多产生一个响应,同时不会导致正在执行的工作崩溃。

由索引模型根据 Issue 内容生成。

评估

技术栈
python
领域
api, backend
Issue 类型
缺陷
难度
4/5
预计耗时
3-5 天
活跃度
活跃
描述清晰度
描述清楚
新手友好度
68/100

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。