Name the client-side flag for who drives the exchange

allow_input_required was the SDK session's own vocabulary. drive=True (the
default) answers each ask from the client's handlers and returns a terminal
result; drive=False hands the ask back so the caller can answer it on a later
call, which is what an app whose user replies minutes later actually needs.
This commit is contained in:
Jeremiah Lowin 2026-07-28 16:57:51 -04:00
commit 5fe1887fa0
No known key found for this signature in database
2 changed files with 20 additions and 21 deletions

View file

@ -147,7 +147,7 @@ class ClientToolsMixin:
*,
input_responses: mcp_types.InputResponses | None = None,
request_state: str | None = None,
allow_input_required: Literal[False] = False,
drive: Literal[True] = True,
) -> mcp_types.CallToolResult: ...
@overload
@ -161,7 +161,7 @@ class ClientToolsMixin:
*,
input_responses: mcp_types.InputResponses | None = None,
request_state: str | None = None,
allow_input_required: Literal[True],
drive: Literal[False],
) -> mcp_types.CallToolResult | mcp_types.InputRequiredResult: ...
async def call_tool_mcp(
@ -174,7 +174,7 @@ class ClientToolsMixin:
*,
input_responses: mcp_types.InputResponses | None = None,
request_state: str | None = None,
allow_input_required: bool = False,
drive: bool = True,
) -> mcp_types.CallToolResult | mcp_types.InputRequiredResult:
"""Send a tools/call request and return the complete MCP protocol result.
@ -185,13 +185,13 @@ 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 `allow_input_required=True` to receive the ask instead
and drive the exchange one leg at a time, feeding the answers back
through `input_responses` and `request_state`:
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.
```python
async with Client(mcp) as client:
leg = await client.call_tool_mcp("book", {}, allow_input_required=True)
leg = await client.call_tool_mcp("book", {}, drive=False)
answers = {
key: mcp_types.ElicitResult(action="accept", content={"value": "Paris"})
for key in leg.input_requests
@ -201,7 +201,7 @@ class ClientToolsMixin:
{},
input_responses=answers,
request_state=leg.request_state,
allow_input_required=True,
drive=False,
)
```
@ -218,7 +218,8 @@ class ClientToolsMixin:
Returns:
The complete response object from the protocol. An
`InputRequiredResult` when the tool asked for input and
`allow_input_required` is set; otherwise a `CallToolResult`.
`drive=False` and the tool asked for input; otherwise a
`CallToolResult`.
Raises:
RuntimeError: If called while the client is not connected.
@ -274,9 +275,7 @@ class ClientToolsMixin:
first = await self._await_with_session_monitoring(
_retry(input_responses, request_state)
)
if allow_input_required and isinstance(
first, mcp_types.InputRequiredResult
):
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
@ -348,7 +347,7 @@ class ClientToolsMixin:
meta: dict[str, Any] | None = None,
input_responses: mcp_types.InputResponses | None = None,
request_state: str | None = None,
allow_input_required: bool = False,
drive: bool = True,
) -> CallToolResult:
"""Call a tool on the server.
@ -395,7 +394,7 @@ class ClientToolsMixin:
meta=request_meta or None,
input_responses=input_responses,
request_state=request_state,
allow_input_required=cast("Literal[True]", allow_input_required),
drive=cast("Literal[False]", drive),
)
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:
"""`allow_input_required=True` hands back each leg instead of resolving it,
"""`drive=False` 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,14 @@ class TestDrivingLegsByHand:
# No elicitation_handler — nothing drives the exchange but this test.
async with Client(mcp) as client:
first = await client.call_tool("book", allow_input_required=True)
first = await client.call_tool("book", drive=False)
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),
allow_input_required=True,
drive=False,
)
# Only the airport — the destination is not asked again.
assert questions(second) == {"airport": "Which airport in Paris?"}
@ -610,7 +610,7 @@ class TestDrivingLegsByHand:
"book",
input_responses=accepted(airport="CDG"),
request_state=carried(second),
allow_input_required=True,
drive=False,
)
assert final.input_required is None
@ -627,7 +627,7 @@ class TestDrivingLegsByHand:
return f"{destination} on {date}"
async with Client(mcp) as client:
first = await client.call_tool("book", allow_input_required=True)
first = await client.call_tool("book", drive=False)
assert questions(first) == {
"destination": "Where to?",
"date": "When?",
@ -637,7 +637,7 @@ class TestDrivingLegsByHand:
"book",
input_responses=accepted(destination="Paris", date="2026-08-01"),
request_state=carried(first),
allow_input_required=True,
drive=False,
)
assert final.data == "Paris on 2026-08-01"
@ -661,7 +661,7 @@ class TestDrivingLegsByHand:
async with Client(mcp) as client:
result = await client.call_tool(
"book", {"destination": "London"}, allow_input_required=True
"book", {"destination": "London"}, drive=False
)
# Terminal on the first leg — there was never anything to ask.