modelcontextprotocol / modelcontextprotocol/python-sdk
ClientSession receive loop swallows callback exceptions, replying "Invalid request parameters" to the server instead of propagating to the call_tool awaiter
還沒有人認領這個 Issue。
- 主要語言
- Python
- 星號
- 24.3k
- 分支
- 4k
- 平均合併
- 1 天 1 小時
- 30 天內合併 PR
- 31
描述
Initial Checks
- I confirm that I'm using the latest version of MCP Python SDK
- I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening this issue
Description
I hit this while wiring an MCP tool that uses ctx.elicit(...) into a
LangGraph agent — the natural pattern of calling interrupt() from the
client elicitation callback does not work because the callback's exception
is swallowed by _receive_loop and replied to the server as
Invalid request parameters. The same limitation blocks any framework that
relies on exception-based control flow on the awaiter's task (custom
async-cancellation strategies, Trio nursery patterns, retry/backoff
libraries, any human-in-the-loop integration), so a small SDK-level escape
hatch would unblock a broad class of MCP client developers, not just this
one use case.
Any exception raised inside a ClientSession callback
(elicitation_callback, sampling_callback, list_roots_callback) is
caught by the blanket except Exception in BaseSession._receive_loop
(src/mcp/shared/session.py) and turned into a JSON-RPC
Invalid request parameters reply to the server:
except Exception:
logging.warning("Failed to validate request", exc_info=True)
error_response = JSONRPCError(
jsonrpc="2.0",
id=message.message.id,
error=ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data=""),
)
await self._write_stream.send(SessionMessage(message=error_response))
Consequences:
- The server is blamed for "invalid params" when the failure was on the
client. The original callback traceback is lost from the awaiter of
session.call_tool(...). - Callbacks have no way to abort back to the awaiter. Frameworks that use
exception-based flow control on the caller's task (LangGraph's
interrupt()for human-in-the-loop pause/resume is the motivating
case) cannot be wired through MCP without per-framework workarounds.
Expected: an opt-in escape hatch so a callback can raise an exception
that propagates out of the in-flight session.call_tool(...) instead of
becoming a JSON-RPC error to the server. Defaulting opt-in to off preserves
existing behaviour.
Proposed minimal fix (single file, ~30 lines, three coordinated edits):
# src/mcp/shared/session.py
#
# (1) BaseSession.__init__: add a per-session stash for marked exceptions.
self._propagate_errors: dict[RequestId, BaseException] = {}
# (2) Inside _receive_loop's request-handler `except Exception as e:` block,
# branch on the marker before the existing INVALID_PARAMS path.
if getattr(e, "__mcp_propagate__", False):
# Notify the peer so their request doesn't hang.
error_response = JSONRPCError(
jsonrpc="2.0",
id=message.message.root.id,
error=ErrorData(code=INTERNAL_ERROR, message="Handler raised", data=""),
)
await self._write_stream.send(SessionMessage(message=JSONRPCMessage(error_response)))
# Surface to the awaiter of any in-flight outgoing request on this session.
for in_flight_id, stream in list(self._response_streams.items()):
self._propagate_errors[in_flight_id] = e
await stream.aclose()
continue
# ...existing INVALID_PARAMS path unchanged below this point.
# (3) Inside send_request, alongside the existing `except TimeoutError:`,
# consume the stash on EndOfStream and re-raise the original exception.
except anyio.EndOfStream:
propagate = self._propagate_errors.pop(request_id, None)
if propagate is not None:
raise propagate from None
raise
I've already implemented and tested this locally against v1.x (and the
same shape on main for the V2 rework): ~33 lines added to
src/mcp/shared/session.py plus regression tests in
tests/shared/test_session.py. The patch is strictly additive — without the
__mcp_propagate__ marker, behaviour is byte-identical to today. End-to-end
verified by surfacing a LangGraph interrupt() from an elicitation callback
as a normal __interrupt__ on the agent's first invoke. ruff / pyright
clean, existing session tests still pass. Happy to open a PR once the issue
is accepted and you've confirmed the direction.
Example Code
# Self-contained reproduction. No third-party agent framework needed.
#
# Terminal A: python repro_server.py
# Terminal B: python repro_client.py
#
# Observed in A: WARNING:root:Failed to validate request: CallbackBoom(...)
# Observed in B: tool result.isError = True, CallbackBoom was NEVER raised
# on the awaiter — silently converted to INVALID_PARAMS.
# --- repro_server.py ---
from mcp.server.fastmcp import Context, FastMCP
from pydantic import BaseModel
class Answer(BaseModel):
value: str
server = FastMCP(name="repro", port=8765)
@server.tool()
async def ask(ctx: Context) -> str:
result = await ctx.elicit(message="hello?", schema=Answer)
return f"got: {result}"
if __name__ == "__main__":
server.run(transport="streamable-http")
# --- repro_client.py ---
import asyncio
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
class CallbackBoom(Exception):
"""Stand-in for any framework's flow-control exception (e.g. GraphInterrupt)."""
async def on_elicit(ctx, params):
raise CallbackBoom("callback wants to abort back to the awaiter")
async def main():
async with streamablehttp_client("http://127.0.0.1:8765/mcp") as (r, w, _):
async with ClientSession(r, w, elicitation_callback=on_elicit) as session:
await session.initialize()
try:
result = await session.call_tool("ask", {})
except CallbackBoom:
print("OK: CallbackBoom propagated to the awaiter")
return
print("BUG: awaiter did not raise; result.isError =", result.isError)
print(" content =", result.content)
asyncio.run(main())
Python & MCP Python SDK
Python: 3.13
mcp: 1.27.1
貢獻指南
從這裡開始
- 先讀完整個 Issue,再讀專案的貢獻指南。
- 在 Issue 下留言說明你要接手 —— 這能避免兩個人做同樣的事。
- Fork 儲存庫,在一個分支上完成修改。
- 送出 Pull Request,並在描述裡引用這個 Issue 編號。
研究方向
從 src/mcp/shared/session.py 開始,特別查看 BaseSession._receive_loop 和 send_request,然後檢查 tests/shared/test_session.py 中的回歸測試。重現 callback 例外的行為,並驗證已標記的 callback 例外會到達正在執行的 call_tool 呼叫的 awaiter,而未標記的例外則維持現有的回應行為。
由索引模型根據 Issue 內容生成。
評估
- 技術堆疊
- python
- 領域
- api, backend-api-design
- Issue 類型
- 缺陷
- 難度
- 3/5
- 預估耗時
- 1-2 天
- 活躍度
- 活躍
- 描述清晰度
- 描述清楚
- 新手友好度
- 68/100