Let Handlers Return Multi Round-Trip input_required Results per SEP-2322
## Motivation and Context
SEP-2322 (modelcontextprotocol/modelcontextprotocol#2322, merged for the 2026-07-28 MCP spec release)
replaces server-initiated JSON-RPC requests, which the stateless modern lifecycle (SEP-2575) forbids,
with multi round-trip results: a `tools/call`, `prompts/get`, or `resources/read` handler returns
`resultType: "input_required"` with `inputRequests` (server-assigned keys mapping to `elicitation/create`,
`sampling/createMessage`, or `roots/list` request shapes) and/or an opaque `requestState`,
and the client retries the ORIGINAL request with `inputResponses` under the same keys plus the echoed state.
The client-side recognition landed earlier (`MCP::ResultType`, `Client::InputRequiredError`);
this adds the server side, mirroring the Python SDK's low-level Server (python-sdk#2967/#2986)
and the TypeScript SDK's umbrella 2026-07-28 work.
- New `MCP::Server::InputRequiredResult` value object: construction validation
(at least one of the two fields, embedded methods restricted to the three shapes), `to_h` wire serialization,
and the capability mapping shared with the TypeScript SDK's `requiredClientCapabilitiesForInputRequest`
(url-mode elicitation requires `elicitation.url`, form otherwise; sampling with `tools`/`toolChoice` requires
`sampling.tools`; `roots/list` requires `roots`), including the 2025 back-compat rule that a bare `elicitation: {}`
declaration implies form support.
- Handlers return the object through the existing paths: both branches of `call_tool_with_args` and
`call_prompt_template_with_args` pass it through instead of calling `.to_h`, `call_tool` skips
output schema validation and the structured-content fallback for it (output schema validation would otherwise
run against a `nil` `structuredContent`), and the `resources/read` dispatch skips the `contents` wrapping and
SEP-2549 cache-hint stamping.
- One central hook in the dispatch lambda gates and serializes the result, after the cancellation check
so cancelled requests stay suppressed: a legacy request (no SEP-2575 envelope) gets an internal error,
because pre-2026 clients treat an unknown `resultType` as a final result; embedded requests exceeding
the request's declared client capabilities get `-32021` with the full merged `requiredCapabilities` set.
- `MCP::ServerContext` gains `input_responses`, `request_state`, and the key-tolerant `input_response(key)` reader.
The retry fields are params-top-level siblings of `name`/`arguments`/`uri` (not `_meta`),
so only handlers that opt in to `server_context:` can participate, matching the envelope readers' access model.
The server holds no memory between rounds: handlers re-run from the start on every retry
(deterministic replay, as in the Python SDK).
The 2026-07-28 conformance requirements score 14 `input-required-result-*` scenarios and the capability
checks of `server-stateless`, and each drives a diagnostic fixture tool the server under test must define.
The fixture defines the tools those scenarios call, mirroring the suite's TypeScript reference fixture
(`examples/servers/typescript/everything-server.ts`) in tool names and `inputRequests` keys:
- `test_input_required_result_elicitation` / `_sampling` / `_list_roots`: one embedded request each,
answered on the retry round (also exercised by the missing-response, result-type, ignore-extra-params,
and validate-input scenarios)
- `test_input_required_result_request_state`: stamps an opaque `requestState` and completes only after
it round-trips
- `test_input_required_result_multiple_inputs`: all three embeddable methods in one result
- `test_input_required_result_multi_round`: two elicitation rounds with per-round `requestState`
- `test_input_required_result_capabilities`: builds `inputRequests` from the declared client capabilities,
so a sampling-only client is never asked to elicit
- prompt `test_input_required_result_prompt`: the non-tool MRTR flow over `prompts/get`
- `test_missing_capability`: guards on `require_client_capability!(:sampling)`, giving
the `server-stateless` capability checks a target that answers -32021 with HTTP 400
`test_input_required_result_tampered_state` is deliberately absent: rejecting a tampered `requestState` needs
the sealing machinery of the request-state security change, and its fixture tool lands there.
Sealing of the echoed `requestState` (it arrives as client-controlled input) and the client-side auto-fulfillment loop
follow in the next changes.
Refs #382.
## How Has This Been Tested?
New `test/mcp/server/input_required_result_test.rb` covers construction validation, key normalization and freezing,
the wire shape of `to_h`, the full capability mapping, missing-capability subtraction with symbol/string declarations,
and the implied-form back-compat rule.
New tests in `test/mcp/server_test.rb` drive `Server#handle` with the modern envelope: issuance wire shape for all three methods,
the retry leg exposing `input_responses`/`request_state`/ `input_response(key)` to the handler, the legacy-request internal error,
`-32021` with the merged `requiredCapabilities` data, output-schema validation bypass under `validate_tool_call_results: true`,
and `resources/read` results staying unwrapped without `ttlMs`/`cacheScope` stamping even when the server configures cache hints.
Against the fixture server at `--spec-version 2026-07-28`, all SEP-2322 checks of the 13 covered scenarios pass,
and `server-stateless` reports `sep-2575-server-rejects-undeclared-capability` and
`sep-2575-missing-capability-http-400` as SUCCESS. The `wire-schema-valid` failures on rounds that reach
a complete result come from the missing `resultType` stamp, which is the separate stamping change's job.
The `--requirements 2025-11-25` server leg still passes 78/78, and `bundle exec rake` is green.
## Breaking Changes
None. The serialization seams only branch on a return type that previously could not occur, all new keyword arguments default to `nil`,
and requests that carry no `inputResponses`/`requestState` behave exactly as before.
This commit is contained in:
parent
4ed461c72f
commit
6e2b7a1971
2 changed files with 864 additions and 7 deletions
109
test/mcp/server/input_required_result_test.rb
Normal file
109
test/mcp/server/input_required_result_test.rb
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
module MCP
|
||||
class Server
|
||||
class InputRequiredResultTest < ActiveSupport::TestCase
|
||||
test "requires at least one of input_requests or request_state" do
|
||||
assert_raises(ArgumentError) { InputRequiredResult.new }
|
||||
assert_raises(ArgumentError) { InputRequiredResult.new(input_requests: {}) }
|
||||
assert_raises(ArgumentError) { InputRequiredResult.new(request_state: 42) }
|
||||
end
|
||||
|
||||
test "validates input_requests entries" do
|
||||
assert_raises(ArgumentError) { InputRequiredResult.new(input_requests: []) }
|
||||
assert_raises(ArgumentError) { InputRequiredResult.new(input_requests: { region: "not-a-hash" }) }
|
||||
assert_raises(ArgumentError) do
|
||||
InputRequiredResult.new(input_requests: { region: { method: "tools/call" } })
|
||||
end
|
||||
assert_raises(ArgumentError) do
|
||||
InputRequiredResult.new(input_requests: { region: { method: "elicitation/create", params: "x" } })
|
||||
end
|
||||
end
|
||||
|
||||
test "normalizes keys, tolerates string entry keys, and freezes" do
|
||||
result = InputRequiredResult.new(input_requests: {
|
||||
region: { "method" => "elicitation/create", "params" => { message: "Which region?" } },
|
||||
})
|
||||
|
||||
assert_predicate result, :frozen?
|
||||
assert_equal ["region"], result.input_requests.keys
|
||||
assert_equal "elicitation/create", result.input_requests["region"][:method]
|
||||
assert_equal({ message: "Which region?" }, result.input_requests["region"][:params])
|
||||
end
|
||||
|
||||
test "#to_h serializes the SEP-2322 wire shape" do
|
||||
result = InputRequiredResult.new(
|
||||
input_requests: { region: { method: "elicitation/create", params: { message: "Which region?" } } },
|
||||
request_state: "opaque-state",
|
||||
)
|
||||
|
||||
assert_equal(
|
||||
{
|
||||
resultType: "input_required",
|
||||
inputRequests: { "region" => { method: "elicitation/create", params: { message: "Which region?" } } },
|
||||
requestState: "opaque-state",
|
||||
},
|
||||
result.to_h,
|
||||
)
|
||||
end
|
||||
|
||||
test "#to_h omits absent fields" do
|
||||
state_only = InputRequiredResult.new(request_state: "opaque-state")
|
||||
|
||||
assert_equal({ resultType: "input_required", requestState: "opaque-state" }, state_only.to_h)
|
||||
end
|
||||
|
||||
test "#required_client_capabilities maps every embedded request kind" do
|
||||
result = InputRequiredResult.new(input_requests: {
|
||||
form: { method: "elicitation/create", params: { message: "?" } },
|
||||
url: { method: "elicitation/create", params: { mode: "url", url: "https://example.com" } },
|
||||
plain_sampling: { method: "sampling/createMessage", params: { messages: [] } },
|
||||
tool_sampling: { method: "sampling/createMessage", params: { messages: [], tools: [] } },
|
||||
roots: { method: "roots/list" },
|
||||
})
|
||||
|
||||
assert_equal(
|
||||
{
|
||||
elicitation: { form: {}, url: {} },
|
||||
sampling: { tools: {} },
|
||||
roots: {},
|
||||
},
|
||||
result.required_client_capabilities,
|
||||
)
|
||||
end
|
||||
|
||||
test "#missing_client_capabilities returns only undeclared branches" do
|
||||
result = InputRequiredResult.new(input_requests: {
|
||||
form: { method: "elicitation/create", params: { message: "?" } },
|
||||
roots: { method: "roots/list" },
|
||||
})
|
||||
|
||||
assert_equal(
|
||||
{ elicitation: { form: {} }, roots: {} },
|
||||
result.missing_client_capabilities({}),
|
||||
)
|
||||
assert_equal(
|
||||
{ roots: {} },
|
||||
result.missing_client_capabilities({ elicitation: { form: {} } }),
|
||||
)
|
||||
assert_empty result.missing_client_capabilities({ "elicitation" => { "form" => {} }, "roots" => {} })
|
||||
end
|
||||
|
||||
test "#missing_client_capabilities treats a bare elicitation declaration as implying form" do
|
||||
# 2025 back-compat rule shared with the TypeScript and Python SDKs; an explicit
|
||||
# url-only declaration does not imply form support.
|
||||
result = InputRequiredResult.new(input_requests: {
|
||||
form: { method: "elicitation/create", params: { message: "?" } },
|
||||
})
|
||||
|
||||
assert_empty result.missing_client_capabilities({ elicitation: {} })
|
||||
assert_equal(
|
||||
{ elicitation: { form: {} } },
|
||||
result.missing_client_capabilities({ elicitation: { url: {} } }),
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -376,6 +376,136 @@ module MCP
|
|||
)
|
||||
end
|
||||
|
||||
test "#handle tools/call serializes an input_required result on a modern request" do
|
||||
server = Server.new(name: "mrtr_test", tools: [])
|
||||
server.define_tool(name: "mrtr_tool") do |server_context:|
|
||||
if server_context.input_responses
|
||||
Tool::Response.new([{ type: "text", text: "done" }])
|
||||
else
|
||||
Server::InputRequiredResult.new(
|
||||
input_requests: { region: { method: "elicitation/create", params: { message: "Which region?" } } },
|
||||
request_state: "state-1",
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
response = server.handle(
|
||||
modern_request("tools/call", { name: "mrtr_tool" }, capabilities: { elicitation: { form: {} } }),
|
||||
)
|
||||
result = response[:result]
|
||||
|
||||
assert_equal "input_required", result[:resultType]
|
||||
assert_equal "state-1", result[:requestState]
|
||||
assert_equal(
|
||||
{ method: "elicitation/create", params: { message: "Which region?" } },
|
||||
result.dig(:inputRequests, "region"),
|
||||
)
|
||||
end
|
||||
|
||||
test "#handle tools/call retry leg exposes input responses and request state to the handler" do
|
||||
server = Server.new(name: "mrtr_test", tools: [])
|
||||
seen = nil
|
||||
server.define_tool(name: "mrtr_tool") do |server_context:|
|
||||
seen = {
|
||||
input_responses: server_context.input_responses,
|
||||
request_state: server_context.request_state,
|
||||
region: server_context.input_response(:region),
|
||||
}
|
||||
Tool::Response.new([{ type: "text", text: "done" }])
|
||||
end
|
||||
|
||||
request = modern_request("tools/call", {
|
||||
name: "mrtr_tool",
|
||||
arguments: {},
|
||||
inputResponses: { region: { action: "accept", content: { value: "us-east-1" } } },
|
||||
requestState: "state-1",
|
||||
})
|
||||
response = server.handle(request)
|
||||
|
||||
refute_nil response[:result]
|
||||
assert_equal "state-1", seen[:request_state]
|
||||
assert_equal({ action: "accept", content: { value: "us-east-1" } }, seen[:region])
|
||||
assert_equal seen[:region], seen[:input_responses][:region]
|
||||
end
|
||||
|
||||
test "#handle rejects an input_required result on a legacy request with an internal error" do
|
||||
# The result type exists only in the 2026-07-28 lifecycle: pre-2026 clients treat
|
||||
# an unknown resultType as a final result, so it must never reach them.
|
||||
server = Server.new(name: "mrtr_test", tools: [])
|
||||
server.define_tool(name: "mrtr_tool") do
|
||||
Server::InputRequiredResult.new(request_state: "state-1")
|
||||
end
|
||||
|
||||
response = server.handle({
|
||||
jsonrpc: "2.0",
|
||||
method: "tools/call",
|
||||
id: 1,
|
||||
params: { name: "mrtr_tool" },
|
||||
})
|
||||
|
||||
assert_equal JsonRpcHandler::ErrorCode::INTERNAL_ERROR, response.dig(:error, :code)
|
||||
end
|
||||
|
||||
test "#handle rejects an input_required result whose embedded requests exceed declared capabilities" do
|
||||
server = Server.new(name: "mrtr_test", tools: [])
|
||||
server.define_tool(name: "mrtr_tool") do
|
||||
Server::InputRequiredResult.new(input_requests: {
|
||||
region: { method: "elicitation/create", params: { message: "?" } },
|
||||
roots: { method: "roots/list" },
|
||||
})
|
||||
end
|
||||
|
||||
response = server.handle(modern_request("tools/call", { name: "mrtr_tool" }, capabilities: {}))
|
||||
|
||||
assert_equal ErrorCodes::MISSING_REQUIRED_CLIENT_CAPABILITY, response.dig(:error, :code)
|
||||
assert_equal(
|
||||
{ elicitation: { form: {} }, roots: {} },
|
||||
response.dig(:error, :data, :requiredCapabilities),
|
||||
)
|
||||
end
|
||||
|
||||
test "#handle tools/call input_required bypasses output schema validation" do
|
||||
configuration = Configuration.new(validate_tool_call_results: true)
|
||||
server = Server.new(name: "mrtr_test", tools: [], configuration: configuration)
|
||||
server.define_tool(name: "mrtr_tool", output_schema: { properties: { value: { type: "string" } }, required: ["value"] }) do
|
||||
Server::InputRequiredResult.new(request_state: "state-1")
|
||||
end
|
||||
|
||||
response = server.handle(modern_request("tools/call", { name: "mrtr_tool" }))
|
||||
|
||||
assert_equal "input_required", response.dig(:result, :resultType)
|
||||
end
|
||||
|
||||
test "#handle prompts/get serializes an input_required result on a modern request" do
|
||||
server = Server.new(name: "mrtr_test")
|
||||
server.define_prompt(name: "mrtr_prompt", arguments: []) do |_args, server_context:|
|
||||
server_context.request_state # participates via server_context:
|
||||
Server::InputRequiredResult.new(request_state: "prompt-state")
|
||||
end
|
||||
|
||||
response = server.handle(modern_request("prompts/get", { name: "mrtr_prompt", arguments: {} }))
|
||||
|
||||
assert_equal "input_required", response.dig(:result, :resultType)
|
||||
assert_equal "prompt-state", response.dig(:result, :requestState)
|
||||
end
|
||||
|
||||
test "#handle resources/read passes an input_required result through unwrapped and without cache hints" do
|
||||
server = Server.new(name: "mrtr_test", ttl_ms: 5000, cache_scope: "public")
|
||||
server.resources_read_handler do |_params, server_context:|
|
||||
server_context.request_state
|
||||
Server::InputRequiredResult.new(request_state: "resource-state")
|
||||
end
|
||||
|
||||
response = server.handle(modern_request("resources/read", { uri: "file:///pending.txt" }))
|
||||
result = response[:result]
|
||||
|
||||
assert_equal "input_required", result[:resultType]
|
||||
assert_equal "resource-state", result[:requestState]
|
||||
refute result.key?(:contents)
|
||||
refute result.key?(:ttlMs)
|
||||
refute result.key?(:cacheScope)
|
||||
end
|
||||
|
||||
test "ServerSession locks the legacy era on mark_initialized! and refuses to flip eras" do
|
||||
session = ServerSession.new(server: @server, transport: mock)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue