mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-21 04:54:17 +02:00
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:
parent
5fe1887fa0
commit
ecc05fa504
2 changed files with 28 additions and 57 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue