Hand back the question when the client cannot answer it

No elicitation handler and a tool that asks now yields the question on
CallToolResult.input_required instead of an 'Elicitation not supported'
error. A handler still answers inline where one is registered, and legacy
still errors, because there the server is blocked mid-call and there is no
result to hand back. No opt-in flag: not passing a handler already says it.
This commit is contained in:
Jeremiah Lowin 2026-07-28 17:09:57 -04:00
commit ecc05fa504
No known key found for this signature in database
2 changed files with 28 additions and 57 deletions

View file

@ -2,7 +2,7 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Literal, cast, overload
from typing import TYPE_CHECKING, Any, cast
import mcp_types
from mcp.client.caching import CacheMode
@ -136,7 +136,6 @@ class ClientToolsMixin:
# --- Call Tool ---
@overload
async def call_tool_mcp(
self: Client,
name: str,
@ -147,35 +146,8 @@ class ClientToolsMixin:
*,
input_responses: mcp_types.InputResponses | None = None,
request_state: str | None = None,
drive: Literal[True] = True,
) -> mcp_types.CallToolResult: ...
@overload
async def call_tool_mcp(
self: Client,
name: str,
arguments: dict[str, Any],
progress_handler: ProgressHandler | None = None,
timeout: datetime.timedelta | float | int | None = None,
meta: dict[str, Any] | None = None,
*,
input_responses: mcp_types.InputResponses | None = None,
request_state: str | None = None,
drive: Literal[False],
) -> mcp_types.CallToolResult | mcp_types.InputRequiredResult: ...
async def call_tool_mcp(
self: Client,
name: str,
arguments: dict[str, Any],
progress_handler: ProgressHandler | None = None,
timeout: datetime.timedelta | float | int | None = None,
meta: dict[str, Any] | None = None,
*,
input_responses: mcp_types.InputResponses | None = None,
request_state: str | None = None,
drive: bool = True,
) -> mcp_types.CallToolResult | mcp_types.InputRequiredResult:
_return_ask: bool = False,
) -> mcp_types.CallToolResult:
"""Send a tools/call request and return the complete MCP protocol result.
This method returns the raw CallToolResult object, which includes an isError flag
@ -185,23 +157,25 @@ class ClientToolsMixin:
(SEP-2322) rather than a final result. By default that is resolved for
you, the same way `call_tool` does it each embedded request is
dispatched to this client's handlers and the call is retried until it
completes. Pass `drive=False` to take the wheel: the ask is handed back
to you, and you answer it by calling again with `input_responses` and
the `request_state` from the previous leg.
A tool that needs input from the user answers with an
`InputRequiredResult` (SEP-2322) rather than a final result. What happens
next depends on whether this client was given an `elicitation_handler`:
with one, each question is put to it and the call is retried until it
completes; without one, the request is returned to you, and you answer it
by calling again with `input_responses` and the `request_state` it
carried.
```python
async with Client(mcp) as client:
leg = await client.call_tool_mcp("book", {}, drive=False)
ask = await client.call_tool("book")
answers = {
key: mcp_types.ElicitResult(action="accept", content={"value": "Paris"})
for key in leg.input_requests
for key in ask.input_required.input_requests
}
final = await client.call_tool_mcp(
final = await client.call_tool(
"book",
{},
input_responses=answers,
request_state=leg.request_state,
drive=False,
request_state=ask.input_required.request_state,
)
```
@ -218,8 +192,8 @@ class ClientToolsMixin:
Returns:
The complete response object from the protocol. An
`InputRequiredResult` when the tool asked for input and
`drive=False` and the tool asked for input; otherwise a
`CallToolResult`.
mcp_types.CallToolResult: The complete response object from the
protocol, containing the tool result and any additional metadata.
Raises:
RuntimeError: If called while the client is not connected.
@ -275,10 +249,11 @@ class ClientToolsMixin:
first = await self._await_with_session_monitoring(
_retry(input_responses, request_state)
)
if not drive and isinstance(first, mcp_types.InputRequiredResult):
# The caller is driving the exchange, so hand back the ask
# untouched rather than resolving it against our own handlers.
return first
if _return_ask and isinstance(first, mcp_types.InputRequiredResult):
# Internal contract with `call_tool`, which sets `_return_ask`
# when this client has no handler and narrows the result back
# out. Public callers never see anything but a CallToolResult.
return cast("mcp_types.CallToolResult", first)
driven = await self._await_with_session_monitoring(
self._drive_input_required(first, _retry)
)
@ -347,7 +322,6 @@ class ClientToolsMixin:
meta: dict[str, Any] | None = None,
input_responses: mcp_types.InputResponses | None = None,
request_state: str | None = None,
drive: bool = True,
) -> CallToolResult:
"""Call a tool on the server.
@ -394,7 +368,9 @@ class ClientToolsMixin:
meta=request_meta or None,
input_responses=input_responses,
request_state=request_state,
drive=cast("Literal[False]", drive),
# With no handler there is nothing here that can answer, so ask for
# the question itself instead of failing.
_return_ask=self._elicitation_callback is None,
)
if isinstance(result, mcp_types.InputRequiredResult):
# The caller is driving; hand the ask back rather than parsing it as

View file

@ -576,7 +576,7 @@ def carried(result) -> str | None:
class TestDrivingLegsByHand:
"""`drive=False` hands back each leg instead of resolving it,
"""`` hands back each leg instead of resolving it,
so a test can assert on the wire shape a client would actually receive."""
async def test_each_leg_is_visible(self):
@ -594,14 +594,13 @@ class TestDrivingLegsByHand:
# No elicitation_handler — nothing drives the exchange but this test.
async with Client(mcp) as client:
first = await client.call_tool("book", drive=False)
first = await client.call_tool("book")
assert questions(first) == {"destination": "Where would you like to fly?"}
second = await client.call_tool(
"book",
input_responses=accepted(destination="Paris"),
request_state=carried(first),
drive=False,
)
# Only the airport — the destination is not asked again.
assert questions(second) == {"airport": "Which airport in Paris?"}
@ -610,7 +609,6 @@ class TestDrivingLegsByHand:
"book",
input_responses=accepted(airport="CDG"),
request_state=carried(second),
drive=False,
)
assert final.input_required is None
@ -627,7 +625,7 @@ class TestDrivingLegsByHand:
return f"{destination} on {date}"
async with Client(mcp) as client:
first = await client.call_tool("book", drive=False)
first = await client.call_tool("book")
assert questions(first) == {
"destination": "Where to?",
"date": "When?",
@ -637,7 +635,6 @@ class TestDrivingLegsByHand:
"book",
input_responses=accepted(destination="Paris", date="2026-08-01"),
request_state=carried(first),
drive=False,
)
assert final.data == "Paris on 2026-08-01"
@ -660,9 +657,7 @@ class TestDrivingLegsByHand:
return f"{destination}/{airport}"
async with Client(mcp) as client:
result = await client.call_tool(
"book", {"destination": "London"}, drive=False
)
result = await client.call_tool("book", {"destination": "London"})
# Terminal on the first leg — there was never anything to ask.
assert result.input_required is None