From 5fe1887fa0c31ea42a4260de494bc77a5e47bdbc Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:57:51 -0400 Subject: [PATCH] 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. --- fastmcp_slim/fastmcp/client/mixins/tools.py | 27 ++++++++++----------- tests/server/test_elicit_resolution.py | 14 +++++------ 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/fastmcp_slim/fastmcp/client/mixins/tools.py b/fastmcp_slim/fastmcp/client/mixins/tools.py index bb2e077a6..7388809f5 100644 --- a/fastmcp_slim/fastmcp/client/mixins/tools.py +++ b/fastmcp_slim/fastmcp/client/mixins/tools.py @@ -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 diff --git a/tests/server/test_elicit_resolution.py b/tests/server/test_elicit_resolution.py index 85257ccc6..6dfe3e191 100644 --- a/tests/server/test_elicit_resolution.py +++ b/tests/server/test_elicit_resolution.py @@ -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.