Client call_tool: input_responses/request_state retry params; InputRequiredResult via allow_input_required#2968
Conversation
…equiredResult via allow_input_required opt-in - ClientSession.send_request: accept TypeAdapter[T] alongside type[T] for result_type so callers can parse union results. - ClientSession.call_tool (mechanics): add input_responses= / request_state= retry kwargs; return CallToolResult | InputRequiredResult; gate output-schema validation on isinstance(result, CallToolResult). - Client.call_tool / ClientSessionGroup.call_tool (policy): @overload on allow_input_required — Literal[False] (default) returns CallToolResult; Literal[True] returns the union. Default raises RuntimeError on InputRequiredResult with a retry steer (TODO(L80) marks where the auto-loop driver replaces this). - Examples and tests that call ClientSession.call_tool directly narrow with isinstance(result, CallToolResult); README.v2.md regenerated from snippets.
adbfd73 to
0b4d8a9
Compare
The gate now lives once on ClientSession (mechanics layer); Client and ClientSessionGroup are pure passthroughs that forward allow_input_required. Third 'bool' overload on ClientSession.call_tool lets the passthrough impls type-check. Reverts the isinstance narrowing in examples/ and tests/shared/; default return is CallToolResult everywhere, so the change is additive.
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🟡
src/mcp/client/session.py:402-418— The Raises section ofsend_discoverdocuments aProbeNotRecognizedexception, but no class with that name exists anywhere in the repository — callers cannot catch it. The bare-4xx case it describes is actually surfaced asMCPError(the streamable-HTTP transport maps it into a JSON-RPC error frame), so this entry should be removed or folded into the existingMCPErrorline.Extended reasoning...
The bug: The docstring of
ClientSession.send_discover(src/mcp/client/session.py:402-418) lists two exception types under Raises:MCPErrorfor a JSON-RPC error from the server, andProbeNotRecognizedfor "the transport bounced the request at its own layer (HTTP 4xx without a JSON-RPC error body)". The second entry references an exception class that does not exist: a repo-wide grep findsProbeNotRecognizedexactly once — in this docstring. It is not defined or exported inmcp.shared.exceptions(which hasMCPError,MCPDeprecationWarning,NoBackChannelError, …),mcp.client.streamable_http, or anywhere else, so a caller writingexcept ProbeNotRecognized:would hit anImportError/NameErrorbefore even reaching the call.Why the documented path can't happen as described: The streamable-HTTP transport never lets a bare HTTP 4xx escape as a transport-layer exception. In
_handle_post_request(src/mcp/client/streamable_http.py), a non-2xx response with no parseable JSON-RPC error body is converted into a syntheticJSONRPCErrorframe and written onto the read stream — this PR even adds the new pre-session mapping where a bare 404 becomesMETHOD_NOT_FOUND("Not Found"), and other statuses becomeINVALID_REQUEST/INTERNAL_ERROR. The dispatcher then surfaces that frame tosend_discover'ssend_raw_requestcall as anMCPError— exactly the exception type the first Raises line already documents.Step-by-step proof:
negotiate_auto(ordiscover()) callssession.send_discover("2026-07-28"), which POSTsserver/discover.- A legacy server / gateway answers with a bare
404and no JSON-RPC body. _handle_post_requestseesresponse.is_successis false, fails to parse a JSON-RPC error from the body, and (sinceself.session_id is None) buildsErrorData(code=METHOD_NOT_FOUND, message="Not Found"), wrapping it in aJSONRPCErrorsent to the read stream (this is pinned bytests/client/test_streamable_http.py::test_pre_session_bare_404_maps_to_method_not_found).JSONRPCDispatchercorrelates the error frame to the pending request and raisesMCPError(code=-32601)fromsend_raw_request.- The caller of
send_discovertherefore catchesMCPError— never anyProbeNotRecognized. Consistently, bothnegotiate_autoin_probe.pyandClientSession.discover()only catchMCPError, and_probe.py's own module docstring says HTTP-layer 4xx rejections "are mapped into MCPError codes".
Why nothing else prevents it: This is documentation only — no code references the name, so type checkers and tests are silent. It looks like a leftover from an earlier design where the transport surfaced bare 4xx as a distinct exception before the JSONRPCError-frame mapping was adopted.
Impact: A reader of the docstring may try to import and catch
ProbeNotRecognizedand get a confusingImportError, or wrongly conclude theirexcept MCPErrorblock won't cover the bare-4xx case. No functional impact on the SDK itself.Fix: Delete the
ProbeNotRecognizedentry, or fold it into theMCPErrorline, e.g. "MCPError: The server returned a JSON-RPC error, or the transport rejected the request at the HTTP layer (a bare 4xx is mapped into a JSON-RPC error frame)."
Aligns with the class branch and the existing validate_python(..., by_name=False) call sites in types/methods.py — wire data must use camelCase aliases only.
There was a problem hiding this comment.
2 issues found and verified against the latest diff
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
|
Re the "additional findings" note about |
…ayers A runtime bool variable now matches the second overload (union return); literal False and the default still match the first (CallToolResult). Drops the redundant Literal[True] arm.
There was a problem hiding this comment.
Both follow-up fixes look right — the TypeAdapter branch now passes by_name=False (78b1f9d) and the overloads are collapsed to Literal[False]/bool consistently across all three layers (68fbef9) — but this widens the public call_tool API surface for a new protocol feature, so it deserves a human maintainer's look rather than auto-approval.
Extended reasoning...
Overview
This PR widens the client-side call_tool path across three layers (ClientSession, Client, ClientSessionGroup) so a 2026-07-28 tools/call can return an InputRequiredResult, adding input_responses/request_state retry kwargs and an allow_input_required opt-in gate (default False raises RuntimeError). ClientSession.send_request is generalized to accept a TypeAdapter result type, and migration docs plus tests for all three layers are added.
Security risks
None identified. The change is client-side result parsing and parameter plumbing; no auth, crypto, or permission logic is touched. The earlier wire-strictness inconsistency (lenient snake_case acceptance on the new TypeAdapter branch) was fixed in 78b1f9d by passing by_name=False, restoring uniform alias-only validation of inbound server data.
Level of scrutiny
Moderate-to-high. Although no bugs remain after the follow-up commits, this is a deliberate public-API design change (union return type, new opt-in flag, overload shape, a TODO placeholder for a future auto-loop driver) on a core client code path used by every SDK consumer. API-surface and naming decisions like allow_input_required and the interim RuntimeError behavior are judgment calls a maintainer should sign off on, especially since the description notes follow-up work (get_prompt/read_resource widening, conformance un-waiving) builds on this shape.
Other factors
Both prior reviewer findings (mine on by_name=False, cubic's on the missing bool overload) were addressed in commits 78b1f9d and 68fbef9, and I verified the fixes are present in the current branch. New tests cover the InputRequiredResult passthrough, the params threading, the no-opt-in RuntimeError, and the session-group forwarding. Existing default-path callers keep the narrow CallToolResult return via the Literal[False] overload, so backward compatibility looks preserved.
|
This pull request is included in pre-release v2.0.0a3 |
Stacks on
s3-client-modern-path(#2950). Widens the client-sidecall_toolpath so anInputRequiredResultcan pass through and the caller can retry withinput_responses/request_state.Changes
ClientSession.send_request—result_typenow acceptsTypeAdapter[T]alongsidetype[T], so callers can parse a union result. All existing single-class callers are unchanged.ClientSession.call_tool(mechanics layer) — addsinput_responses=/request_state=retry kwargs and returnsCallToolResult | InputRequiredResult(matchingMONOLITH_RESULTS["tools/call"]). Output-schema validation is gated onisinstance(result, CallToolResult).Client.call_tool/ClientSessionGroup.call_tool(policy layer) —@overloadonallow_input_required: the default (False) still returnsCallToolResultso existing callers are unaffected;allow_input_required=Truereturns the union. With the default, anInputRequiredResultraisesRuntimeErrorwith a retry steer —# TODO(L80)marks where the auto-loop driver replaces this.Direct
ClientSession.call_toolcallers — examples and tests that bypassClientnow narrow withisinstance(result, CallToolResult).README.v2.mdis regenerated from the updated snippets.Not in this PR
get_prompt/read_resourcewidening — mechanically identical, follow separately.allow_input_required=Falsedefault will resolveInputRequiredResultautomatically once it lands).InputRequiredResultat-least-one-of validator — already onmainvia lowlevel Server: widen on_* return types for InputRequiredResult; add subscriptions/listen slot #2967; lands here on rebase.Conformance
sep-2322-client-request-statealready exists upstream at the pinned conformance build; it's currently waived inexpected-failures*.yml. Un-waiving + adding the@registerhandler in.github/actions/conformance/client.pyis the burndown step once this is onmain.Part of #2891.
AI Disclaimer