Client auto-resolves InputRequiredResult via existing callbacks (SEP-2322) (#2998)

This commit is contained in:
Max 2026-06-26 17:35:23 +02:00 • committed by GitHub
commit 08b62308d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1258 additions and 202 deletions

View file

@ -34,6 +34,9 @@ multi_connection = true
# progress + log notifications dropped on the modern streamable-HTTP path pending SSE wiring
xfail = ["http-asgi:modern"]
[story.mrtr]
era = "modern"
[story.legacy_elicitation]
era = "legacy"
status = "legacy"
@ -140,7 +143,6 @@ fixed_port = 8000 # issuer/PRM metadata bake in :8
[deferred]
caching = "client honouring + per-result override unlanded"
mrtr = "#2898 — InputRequiredResult runtime"
subscriptions = "#2901 — Client.listen / ServerEventBus"
tasks = "extensions capability map + tasks runtime"
apps = "#2896 — extensions capability map"

View file

@ -1,30 +1,54 @@
# mrtr
Multi-round tool results: a 2026-era tool call returns
`resultType: "input_required"` with a `requestState` HMAC instead of pushing an
`elicitation/create` request. The client fulfils the input and resubmits, and
the server resumes from the carried state. The story will show both the
auto-fulfil helper and a manual resubmit loop.
Multi-round tool result: on the 2026-07-28 protocol a tool that needs user
input mid-call **returns** `resultType: "input_required"` with embedded
`inputRequests` and an opaque `requestState`, instead of pushing a
server→client request. The client fulfils the embedded requests and retries the
original `tools/call` carrying `inputResponses` and the echoed `requestState`.
The story shows both the `Client` auto-loop (one `await call_tool`, callbacks
fired transparently) and a manual `client.session` loop (the persistable form).
**Status: not yet implemented** ([#2898](https://github.com/modelcontextprotocol/python-sdk/issues/2898)).
The lowlevel registration surface is in this base —
[#2967](https://github.com/modelcontextprotocol/python-sdk/pull/2967)
(`ae13ede`) widened the tool/prompt/resource handler return types to include
`InputRequiredResult`. The runnable story is deliberately a follow-up PR to
keep this one reviewable.
## Run it
```bash
# HTTP — the client self-hosts the server on a free port, runs, then tears it
# down (the InputRequiredResult round-trip is 2026-era only)
uv run python -m stories.mrtr.client --http
# same, against the lowlevel-API server variant
uv run python -m stories.mrtr.client --http --server server_lowlevel
```
## What to look at
- `client.py` `main` — the auto-loop is invisible at the call site:
`Client(target, mode=mode, elicitation_callback=on_elicit)` then
`await client.call_tool("deploy", ...)`. The same `on_elicit` callback the
legacy push path uses is dispatched for each embedded `inputRequests` entry.
- `client.py` manual block — `client.session.call_tool(...,
allow_input_required=True)` returns the raw `InputRequiredResult` so
`request_state` can be persisted between rounds; the retry is just another
`tools/call` with `input_responses=` / `request_state=`.
- `server.py` `deploy` — `ctx.input_responses` / `ctx.request_state` read the
retry payload; the first round returns
`InputRequiredResult(input_requests={...}, request_state=...)`, the second
returns the final string.
- `server_lowlevel.py` — same wire contract via `params.input_responses` /
`params.request_state` and a hand-built `InputRequiredResult`.
## Caveats
- **Loop bound.** The auto-loop gives up after `input_required_max_rounds`
(default 10) with `InputRequiredRoundsExceededError`; raise it on the
`Client` ctor or drop to the manual loop.
- **`requestState` integrity is the server's job.** The client echoes it
byte-exact and never inspects it; the server MUST treat it as
attacker-controlled. The SDK ships no signing helper yet.
## Spec
[Multi-round tool results — server features](https://modelcontextprotocol.io/specification/draft/server/tools#multi-round-results)
## Working example elsewhere
The TypeScript SDK ships a runnable `mrtr` story:
[typescript-sdk/examples/mrtr](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/mrtr).
[Multi-round results — server features](https://modelcontextprotocol.io/specification/draft/server/tools#multi-round-results)
## See also
`legacy_elicitation/` and `sampling/` — the handshake-era push equivalents that
this mechanism replaces on the 2026 protocol. The TypeScript SDK ships a single
dual-era `elicitation/` story covering both eras in one place; we re-merge
`legacy_elicitation/` back into `elicitation/` once MRTR lands.
`legacy_elicitation/` and `sampling/` — the handshake-era push equivalents this
mechanism replaces on the 2026 protocol.

View file

View file

@ -0,0 +1,46 @@
"""Drive the deploy tool both ways: the Client auto-loop, and a manual session-level loop."""
import mcp_types as types
from mcp.client import Client, ClientRequestContext
from stories._harness import Target, run_client
async def on_elicit(context: ClientRequestContext, params: types.ElicitRequestParams) -> types.ElicitResult:
# The same callback serves legacy push-style elicitation/create requests AND embedded
# InputRequiredResult.input_requests entries — the driver dispatches both here.
assert isinstance(params, types.ElicitRequestFormParams)
assert "confirm" in params.requested_schema["properties"]
return types.ElicitResult(action="accept", content={"confirm": True})
async def main(target: Target, *, mode: str = "auto") -> None:
async with Client(target, mode=mode, elicitation_callback=on_elicit) as client:
# ── auto-loop: Client.call_tool dispatches input_requests to on_elicit and retries
# internally; the caller just sees the final CallToolResult.
deployed = await client.call_tool("deploy", {"env": "production"})
assert isinstance(deployed.content[0], types.TextContent)
assert deployed.content[0].text == "deployed to production", deployed
# ── manual loop: drop to client.session for the raw InputRequiredResult so the
# request_state can be persisted between rounds (e.g. across a process restart).
first = await client.session.call_tool("deploy", {"env": "staging"}, allow_input_required=True)
assert isinstance(first, types.InputRequiredResult)
assert first.input_requests is not None and "confirm" in first.input_requests
assert first.request_state == "awaiting-confirm"
# Decline this time so the path diverges from the auto-loop run above.
responses: types.InputResponses = {"confirm": types.ElicitResult(action="decline")}
second = await client.session.call_tool(
"deploy",
{"env": "staging"},
input_responses=responses,
request_state=first.request_state,
allow_input_required=True,
)
assert isinstance(second, types.CallToolResult)
assert isinstance(second.content[0], types.TextContent)
assert second.content[0].text == "deployment to staging cancelled", second
if __name__ == "__main__":
run_client(main)

View file

@ -0,0 +1,39 @@
"""Multi-round tool result (2026 era): a tool returns input_required and resumes from echoed state."""
from mcp_types import ElicitRequest, ElicitRequestedSchema, ElicitRequestFormParams, ElicitResult, InputRequiredResult
from mcp.server.mcpserver import Context, MCPServer
from stories._hosting import run_server_from_args
CONFIRM_SCHEMA: ElicitRequestedSchema = {
"type": "object",
"properties": {"confirm": {"type": "boolean", "description": "Proceed with the deployment?"}},
"required": ["confirm"],
}
def build_server() -> MCPServer:
mcp = MCPServer("mrtr-example")
@mcp.tool(description="Deploy to an environment, asking the user to confirm first.")
async def deploy(env: str, ctx: Context) -> str | InputRequiredResult:
responses = ctx.input_responses
if responses is None or "confirm" not in responses:
# First round: ask the client to elicit confirmation. request_state is opaque
# to the client; here it carries the step name so the retry can verify the echo.
ask = ElicitRequest(
params=ElicitRequestFormParams(message=f"Deploy to {env}?", requested_schema=CONFIRM_SCHEMA)
)
return InputRequiredResult(input_requests={"confirm": ask}, request_state="awaiting-confirm")
# Retry round: the client echoed request_state byte-exact and supplied the answer.
assert ctx.request_state == "awaiting-confirm", ctx.request_state
answer = responses["confirm"]
if isinstance(answer, ElicitResult) and answer.action == "accept" and (answer.content or {}).get("confirm"):
return f"deployed to {env}"
return f"deployment to {env} cancelled"
return mcp
if __name__ == "__main__":
run_server_from_args(build_server)

View file

@ -0,0 +1,62 @@
"""Multi-round tool result (2026 era) against the low-level Server."""
from typing import Any
import mcp_types as types
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
from stories._hosting import run_server_from_args
CONFIRM_SCHEMA: types.ElicitRequestedSchema = {
"type": "object",
"properties": {"confirm": {"type": "boolean", "description": "Proceed with the deployment?"}},
"required": ["confirm"],
}
DEPLOY_INPUT_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {"env": {"type": "string"}},
"required": ["env"],
}
def build_server() -> Server[Any]:
async def list_tools(
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(
tools=[
types.Tool(
name="deploy",
description="Deploy to an environment, asking the user to confirm first.",
input_schema=DEPLOY_INPUT_SCHEMA,
)
]
)
async def call_tool(
ctx: ServerRequestContext[Any], params: types.CallToolRequestParams
) -> types.CallToolResult | types.InputRequiredResult:
assert params.name == "deploy" and params.arguments is not None
env = params.arguments["env"]
responses = params.input_responses
if responses is None or "confirm" not in responses:
ask = types.ElicitRequest(
params=types.ElicitRequestFormParams(message=f"Deploy to {env}?", requested_schema=CONFIRM_SCHEMA)
)
return types.InputRequiredResult(input_requests={"confirm": ask}, request_state="awaiting-confirm")
assert params.request_state == "awaiting-confirm", params.request_state
answer = responses["confirm"]
if (
isinstance(answer, types.ElicitResult)
and answer.action == "accept"
and (answer.content or {}).get("confirm")
):
return types.CallToolResult(content=[types.TextContent(text=f"deployed to {env}")])
return types.CallToolResult(content=[types.TextContent(text=f"deployment to {env} cancelled")])
return Server("mrtr-example", on_list_tools=list_tools, on_call_tool=call_tool)
if __name__ == "__main__":
run_server_from_args(build_server)