ClientSession receive loop swallows callback exceptions, replying "Invalid request parameters" to the server instead of propagating to the call_tool awaiter
Dieses Issue hat noch niemand übernommen.
Bewertung
- Schwierigkeit
- 3/5
- Geschätzter Aufwand
- 1-2 Tage
- Anfängerfreundlichkeit
- 68/100
- Issue-Typ
- Bug
- Klarheit
- Klar beschrieben
- Aktivitätsstatus
- Aktiv
- Tech-Stack
- python
- Bereich
- api, backend-api-design
Rechercherichtung
Beginne mit src/mcp/shared/session.py, insbesondere mit BaseSession._receive_loop und send_request, und überprüfe anschließend die Regressionstests in tests/shared/test_session.py. Reproduziere das Verhalten von Callback-Ausnahmen und verifiziere, dass markierte Callback-Ausnahmen den Awaiter des laufenden call_tool-Aufrufs erreichen, während nicht markierte Ausnahmen das bestehende Antwortverhalten beibehalten.
Vom Indexierungsmodell aus dem Issue-Text verfasst.
Beschreibung
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
- Vorherrschende Sprache
- Python
- Sterne
- 24.3k
- Forks
- 4k
- Ø Merge
- 1 T. 11 Std.
- Gemergte PRs (30 T.)
- 30
Beitragsleitfaden
Erste Schritte
- Lesen Sie das ganze Issue und danach den Beitragsleitfaden des Projekts.
- Schreiben Sie ins Issue, dass Sie es übernehmen — das erspart doppelte Arbeit.
- Forken Sie das Repository und arbeiten Sie in einem Branch.
- Öffnen Sie einen Pull Request, der die Issue-Nummer nennt.
Mehr aus modelcontextprotocol/python-sdk
-
Schwierigkeit 2/5 1-3 Stunden Anfängerfreundlichkeit 75/100
modelcontextprotocol/python-sdk#3566 ·
-
v1 v2
Schwierigkeit 2/5 1-3 Stunden Anfängerfreundlichkeit 85/100
modelcontextprotocol/python-sdk#3546 · 5 Kommentare ·
-
v1 v2
Schwierigkeit 2/5 1-3 Stunden Anfängerfreundlichkeit 76/100
modelcontextprotocol/python-sdk#3545 · 1 Kommentar ·
-
v1 v2
Schwierigkeit 1/5 Unter einer Stunde Anfängerfreundlichkeit 91/100
modelcontextprotocol/python-sdk#3508 · 2 Kommentare ·
-
Schwierigkeit 2/5 1-3 Stunden Anfängerfreundlichkeit 64/100
modelcontextprotocol/python-sdk#3504 ·
Alle Issues in modelcontextprotocol/python-sdk
Ähnliche Issues
-
bug confirmed issue
Schwierigkeit 2/5 1-3 Stunden Anfängerfreundlichkeit 75/100
open-webui/open-webui#30750 · 1 Kommentar ·
-
Schwierigkeit 2/5 1-3 Stunden Anfängerfreundlichkeit 75/100
-
enhancement
Schwierigkeit 2/5 1-3 Stunden Anfängerfreundlichkeit 75/100
OpenwaterHealth/openmotion-bloodflow-app#604 · 1 Kommentar ·
-
Schwierigkeit 2/5 1-3 Stunden Anfängerfreundlichkeit 70/100
-
good first issue
Schwierigkeit 1/5 Unter einer Stunde Anfängerfreundlichkeit 90/100