Initial Checks
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:
exceptException:
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=""),
)
awaitself._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.ifgetattr(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=""),
)
awaitself._write_stream.send(SessionMessage(message=JSONRPCMessage(error_response)))
# Surface to the awaiter of any in-flight outgoing request on this session.forin_flight_id, streaminlist(self._response_streams.items()):
self._propagate_errors[in_flight_id] =eawaitstream.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.exceptanyio.EndOfStream:
propagate=self._propagate_errors.pop(request_id, None)
ifpropagateisnotNone:
raisepropagatefromNoneraiseI'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 ---frommcp.server.fastmcpimportContext, FastMCPfrompydanticimportBaseModelclassAnswer(BaseModel):
value: strserver=FastMCP(name="repro", port=8765)
@server.tool()asyncdefask(ctx: Context) ->str:
result=awaitctx.elicit(message="hello?", schema=Answer)
returnf"got: {result}"if__name__=="__main__":
server.run(transport="streamable-http")
# --- repro_client.py ---importasynciofrommcpimportClientSessionfrommcp.client.streamable_httpimportstreamablehttp_clientclassCallbackBoom(Exception):
"""Stand-in for any framework's flow-control exception (e.g. GraphInterrupt)."""asyncdefon_elicit(ctx, params):
raiseCallbackBoom("callback wants to abort back to the awaiter")
asyncdefmain():
asyncwithstreamablehttp_client("http://127.0.0.1:8765/mcp") as (r, w, _):
asyncwithClientSession(r, w, elicitation_callback=on_elicit) assession:
awaitsession.initialize()
try:
result=awaitsession.call_tool("ask", {})
exceptCallbackBoom:
print("OK: CallbackBoom propagated to the awaiter")
returnprint("BUG: awaiter did not raise; result.isError =", result.isError)
print(" content =", result.content)
asyncio.run(main())Python & MCP Python SDK
Initial Checks
Description
I hit this while wiring an MCP tool that uses
ctx.elicit(...)into aLangGraph agent — the natural pattern of calling
interrupt()from theclient elicitation callback does not work because the callback's exception
is swallowed by
_receive_loopand replied to the server asInvalid request parameters. The same limitation blocks any framework thatrelies 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
ClientSessioncallback(
elicitation_callback,sampling_callback,list_roots_callback) iscaught by the blanket
except ExceptioninBaseSession._receive_loop(
src/mcp/shared/session.py) and turned into a JSON-RPCInvalid request parametersreply to the server:Consequences:
client. The original callback traceback is lost from the awaiter of
session.call_tool(...).exception-based flow control on the caller's task (LangGraph's
interrupt()for human-in-the-loop pause/resume is the motivatingcase) 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 ofbecoming 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):
I've already implemented and tested this locally against
v1.x(and thesame shape on
mainfor the V2 rework): ~33 lines added tosrc/mcp/shared/session.pyplus regression tests intests/shared/test_session.py. The patch is strictly additive — without the__mcp_propagate__marker, behaviour is byte-identical to today. End-to-endverified by surfacing a LangGraph
interrupt()from an elicitation callbackas a normal
__interrupt__on the agent's first invoke.ruff/pyrightclean, existing session tests still pass. Happy to open a PR once the issue
is accepted and you've confirmed the direction.
Example Code
Python & MCP Python SDK