Client call_tool: input_responses/request_state retry params; InputRequiredResult via allow_input_required (#2968)

This commit is contained in:
Max 2026-06-25 17:37:00 +02:00 • committed by GitHub
commit 03681ed55e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 239 additions and 13 deletions

View file

@ -5,7 +5,7 @@ from __future__ import annotations
from collections.abc import Awaitable, Callable, Mapping
from contextlib import AsyncExitStack
from dataclasses import KW_ONLY, dataclass, field
from typing import Any, Literal, TypeVar
from typing import Any, Literal, TypeVar, overload
import anyio
from typing_extensions import deprecated
@ -30,6 +30,8 @@ from mcp.types import (
EmptyResult,
GetPromptResult,
Implementation,
InputRequiredResult,
InputResponses,
ListPromptsResult,
ListResourcesResult,
ListResourceTemplatesResult,
@ -374,6 +376,7 @@ class Client:
"""Unsubscribe from resource updates."""
return await self.session.unsubscribe_resource(uri, meta=meta)
@overload
async def call_tool(
self,
name: str,
@ -381,8 +384,38 @@ class Client:
read_timeout_seconds: float | None = None,
progress_callback: ProgressFnT | None = None,
*,
input_responses: InputResponses | None = None,
request_state: str | None = None,
meta: RequestParamsMeta | None = None,
) -> CallToolResult:
allow_input_required: Literal[False] = False,
) -> CallToolResult: ...
@overload
async def call_tool(
self,
name: str,
arguments: dict[str, Any] | None = None,
read_timeout_seconds: float | None = None,
progress_callback: ProgressFnT | None = None,
*,
input_responses: InputResponses | None = None,
request_state: str | None = None,
meta: RequestParamsMeta | None = None,
allow_input_required: bool,
) -> CallToolResult | InputRequiredResult: ...
async def call_tool(
self,
name: str,
arguments: dict[str, Any] | None = None,
read_timeout_seconds: float | None = None,
progress_callback: ProgressFnT | None = None,
*,
input_responses: InputResponses | None = None,
request_state: str | None = None,
meta: RequestParamsMeta | None = None,
allow_input_required: bool = False,
) -> CallToolResult | InputRequiredResult:
"""Call a tool on the server.
Args:
@ -390,17 +423,32 @@ class Client:
arguments: Arguments to pass to the tool
read_timeout_seconds: Timeout for the tool call
progress_callback: Callback for progress updates
input_responses: Responses to a prior `InputRequiredResult.input_requests`
request_state: Opaque state echoed from a prior `InputRequiredResult`
meta: Additional metadata for the request
allow_input_required: When ``False`` (default), an `InputRequiredResult`
from the server raises `RuntimeError`; when ``True``, it is returned
so the caller can resolve the requests and retry.
Returns:
The tool result.
The tool result. When ``allow_input_required=True``, may instead be an
`InputRequiredResult` carrying the server's input requests and opaque
``request_state`` for the retry.
Raises:
RuntimeError: If the server returns an `InputRequiredResult` and
``allow_input_required`` is ``False``.
"""
# TODO(L84): stop forwarding allow_input_required; run the MRTR auto-loop driver here (S6).
return await self.session.call_tool(
name=name,
arguments=arguments,
read_timeout_seconds=read_timeout_seconds,
progress_callback=progress_callback,
input_responses=input_responses,
request_state=request_state,
meta=meta,
allow_input_required=allow_input_required,
)
async def list_prompts(

View file

@ -4,7 +4,7 @@ import logging
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from types import TracebackType
from typing import Any, Protocol, cast
from typing import Any, Literal, Protocol, cast, overload
import anyio
import anyio.abc
@ -173,6 +173,10 @@ async def _default_logging_callback(
ClientResponse: TypeAdapter[types.ClientResult | types.ErrorData] = TypeAdapter(types.ClientResult | types.ErrorData)
_CallToolResultAdapter: TypeAdapter[types.CallToolResult | types.InputRequiredResult] = TypeAdapter(
types.CallToolResult | types.InputRequiredResult
)
class ClientSession:
"""Client half of an MCP connection, running on a `Dispatcher`.
@ -269,7 +273,7 @@ class ClientSession:
async def send_request(
self,
request: types.ClientRequest,
result_type: type[ReceiveResultT],
result_type: type[ReceiveResultT] | TypeAdapter[ReceiveResultT],
request_read_timeout_seconds: float | None = None,
metadata: ClientMessageMetadata | None = None,
progress_callback: ProgressFnT | None = None,
@ -308,6 +312,8 @@ class ClientSession:
_methods.validate_server_result(method, version, raw)
except KeyError:
pass
if isinstance(result_type, TypeAdapter):
return result_type.validate_python(raw, by_name=False)
return result_type.model_validate(raw, by_name=False)
async def send_notification(self, notification: types.ClientNotification) -> None:
@ -596,6 +602,7 @@ class ClientSession:
types.EmptyResult,
)
@overload
async def call_tool(
self,
name: str,
@ -603,22 +610,75 @@ class ClientSession:
read_timeout_seconds: float | None = None,
progress_callback: ProgressFnT | None = None,
*,
input_responses: types.InputResponses | None = None,
request_state: str | None = None,
meta: RequestParamsMeta | None = None,
) -> types.CallToolResult:
"""Send a tools/call request with optional progress callback support."""
allow_input_required: Literal[False] = False,
) -> types.CallToolResult: ...
@overload
async def call_tool(
self,
name: str,
arguments: dict[str, Any] | None = None,
read_timeout_seconds: float | None = None,
progress_callback: ProgressFnT | None = None,
*,
input_responses: types.InputResponses | None = None,
request_state: str | None = None,
meta: RequestParamsMeta | None = None,
allow_input_required: bool,
) -> types.CallToolResult | types.InputRequiredResult: ...
async def call_tool(
self,
name: str,
arguments: dict[str, Any] | None = None,
read_timeout_seconds: float | None = None,
progress_callback: ProgressFnT | None = None,
*,
input_responses: types.InputResponses | None = None,
request_state: str | None = None,
meta: RequestParamsMeta | None = None,
allow_input_required: bool = False,
) -> types.CallToolResult | types.InputRequiredResult:
"""Send a tools/call request with optional progress callback support.
Args:
input_responses: Responses to a prior `InputRequiredResult.input_requests`.
request_state: Opaque state echoed from a prior `InputRequiredResult`.
allow_input_required: When ``False`` (default), an `InputRequiredResult`
from the server raises `RuntimeError`; when ``True``, it is returned
so the caller can resolve the requests and retry.
Raises:
RuntimeError: If the server returns an `InputRequiredResult` and
``allow_input_required`` is ``False``.
"""
result = await self.send_request(
types.CallToolRequest(
params=types.CallToolRequestParams(name=name, arguments=arguments, _meta=meta),
params=types.CallToolRequestParams(
name=name,
arguments=arguments,
input_responses=input_responses,
request_state=request_state,
_meta=meta,
),
),
types.CallToolResult,
_CallToolResultAdapter,
request_read_timeout_seconds=read_timeout_seconds,
progress_callback=progress_callback,
)
if not result.is_error:
if isinstance(result, types.CallToolResult) and not result.is_error:
await self._validate_tool_result(name, result)
if isinstance(result, types.InputRequiredResult) and not allow_input_required:
raise RuntimeError(
"Server returned InputRequiredResult; pass allow_input_required=True to receive it "
"and retry call_tool(..., input_responses=..., request_state=result.request_state)."
)
return result
async def _validate_tool_result(self, name: str, result: types.CallToolResult) -> None:

View file

@ -11,7 +11,7 @@ import logging
from collections.abc import Callable
from dataclasses import dataclass
from types import TracebackType
from typing import Any, TypeAlias
from typing import Any, Literal, TypeAlias, overload
import anyio
import httpx
@ -190,6 +190,7 @@ class ClientSessionGroup:
"""Returns the tools as a dictionary of names to tools."""
return self._tools
@overload
async def call_tool(
self,
name: str,
@ -197,9 +198,44 @@ class ClientSessionGroup:
read_timeout_seconds: float | None = None,
progress_callback: ProgressFnT | None = None,
*,
input_responses: types.InputResponses | None = None,
request_state: str | None = None,
meta: types.RequestParamsMeta | None = None,
) -> types.CallToolResult:
"""Executes a tool given its name and arguments."""
allow_input_required: Literal[False] = False,
) -> types.CallToolResult: ...
@overload
async def call_tool(
self,
name: str,
arguments: dict[str, Any] | None = None,
read_timeout_seconds: float | None = None,
progress_callback: ProgressFnT | None = None,
*,
input_responses: types.InputResponses | None = None,
request_state: str | None = None,
meta: types.RequestParamsMeta | None = None,
allow_input_required: bool,
) -> types.CallToolResult | types.InputRequiredResult: ...
async def call_tool(
self,
name: str,
arguments: dict[str, Any] | None = None,
read_timeout_seconds: float | None = None,
progress_callback: ProgressFnT | None = None,
*,
input_responses: types.InputResponses | None = None,
request_state: str | None = None,
meta: types.RequestParamsMeta | None = None,
allow_input_required: bool = False,
) -> types.CallToolResult | types.InputRequiredResult:
"""Executes a tool given its name and arguments.
Raises:
RuntimeError: If the server returns an `InputRequiredResult` and
``allow_input_required`` is ``False``.
"""
session = self._tool_to_session[name]
session_tool_name = self.tools[name].name
return await session.call_tool(
@ -207,7 +243,10 @@ class ClientSessionGroup:
arguments=arguments,
read_timeout_seconds=read_timeout_seconds,
progress_callback=progress_callback,
input_responses=input_responses,
request_state=request_state,
meta=meta,
allow_input_required=allow_input_required,
)
async def disconnect_from_server(self, session: mcp.ClientSession) -> None: