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:
Koichi ITO 2026-08-08 05:04:21 +09:00
commit 6e2b7a1971

View file

@ -2,6 +2,7 @@
require "rackup"
require "json"
require "securerandom"
require "set"
require "uri"
require_relative "../lib/mcp"
@ -13,6 +14,43 @@ module Conformance
# Minimal WAV file (matches TypeScript SDK and Python SDK)
BASE64_MINIMAL_WAV = "UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA="
# SEP-2322 MRTR fixture helpers. A retried request re-runs its handler from the start,
# so every round reads the client's earlier answers back through these.
module Mrtr
module_function
# The transport parses JSON with symbolized keys, but retries may also arrive through
# `Server#handle` with string keys; tolerate both like the SDK's own readers.
def read(hash, key)
return unless hash.is_a?(Hash)
value = hash[key.to_sym]
value.nil? ? hash[key.to_s] : value
end
# The `content` of an accepted `elicitation/create` response, or `nil` for a declined,
# malformed, or missing one (a `nil` return re-requests the input).
def accepted_content(response)
content = read(response, :content)
content if read(response, :action) == "accept" && content.is_a?(Hash)
end
# The `text` of a `sampling/createMessage` response, or `nil` when malformed.
def sampled_text(response)
text = read(read(response, :content), :text)
text if text.is_a?(String)
end
def parse_state(request_state)
return unless request_state.is_a?(String)
parsed = JSON.parse(request_state)
parsed.is_a?(Hash) ? parsed : nil
rescue JSON::ParserError
nil
end
end
module Tools
class TestSimpleText < MCP::Tool
tool_name "test_simple_text"
@ -292,6 +330,285 @@ module Conformance
end
end
end
class TestMissingCapability < MCP::Tool
tool_name "test_missing_capability"
description "A tool that requires the sampling client capability (SEP-2575)"
class << self
def call(server_context:, **_args)
server_context.require_client_capability!(:sampling)
MCP::Tool::Response.new([MCP::Content::Text.new("Sampling capability is declared").to_h])
end
end
end
class TestInputRequiredResultElicitation < MCP::Tool
tool_name "test_input_required_result_elicitation"
description "A tool that asks for the user's name through an input_required result (SEP-2322)"
class << self
def call(server_context:, **_args)
content = Mrtr.accepted_content(server_context.input_response("user_name"))
name = content && Mrtr.read(content, :name)
unless name.is_a?(String)
return MCP::Server::InputRequiredResult.new(
input_requests: {
user_name: {
method: "elicitation/create",
params: {
message: "What is your name?",
requestedSchema: {
type: "object",
properties: { name: { type: "string" } },
required: ["name"],
},
},
},
},
)
end
MCP::Tool::Response.new([MCP::Content::Text.new("Hello, #{name}!").to_h])
end
end
end
class TestInputRequiredResultSampling < MCP::Tool
tool_name "test_input_required_result_sampling"
description "A tool that asks for an LLM completion through an input_required result (SEP-2322)"
class << self
def call(server_context:, **_args)
text = Mrtr.sampled_text(server_context.input_response("sample_request"))
unless text
return MCP::Server::InputRequiredResult.new(
input_requests: {
sample_request: {
method: "sampling/createMessage",
params: {
messages: [{ role: "user", content: { type: "text", text: "What is the capital of France?" } }],
maxTokens: 100,
},
},
},
)
end
MCP::Tool::Response.new([MCP::Content::Text.new("Sampled: #{text}").to_h])
end
end
end
class TestInputRequiredResultListRoots < MCP::Tool
tool_name "test_input_required_result_list_roots"
description "A tool that asks for the client's roots through an input_required result (SEP-2322)"
class << self
def call(server_context:, **_args)
roots = Mrtr.read(server_context.input_response("roots_request"), :roots)
unless roots.is_a?(Array)
return MCP::Server::InputRequiredResult.new(
input_requests: { roots_request: { method: "roots/list" } },
)
end
uris = roots.filter_map { |root| Mrtr.read(root, :uri) }
MCP::Tool::Response.new([MCP::Content::Text.new("Roots: #{uris.join(", ")}").to_h])
end
end
end
class TestInputRequiredResultRequestState < MCP::Tool
tool_name "test_input_required_result_request_state"
description "A tool whose input_required result carries an opaque requestState (SEP-2322)"
class << self
def call(server_context:, **_args)
state = Mrtr.parse_state(server_context.request_state)
confirmed = Mrtr.accepted_content(server_context.input_response("confirm"))
if state && state["kind"] == "request-state" && confirmed
return MCP::Tool::Response.new([MCP::Content::Text.new("state-ok: requestState validated").to_h])
end
MCP::Server::InputRequiredResult.new(
input_requests: {
confirm: {
method: "elicitation/create",
params: {
message: "Confirm to continue",
requestedSchema: {
type: "object",
properties: { ok: { type: "boolean" } },
},
},
},
},
request_state: JSON.generate({ kind: "request-state", nonce: SecureRandom.hex(8) }),
)
end
end
end
class TestInputRequiredResultMultipleInputs < MCP::Tool
tool_name "test_input_required_result_multiple_inputs"
description "A tool that embeds elicitation, sampling, and roots/list inputs in one input_required result (SEP-2322)"
class << self
def call(server_context:, **_args)
content = Mrtr.accepted_content(server_context.input_response("user_name"))
name = content && Mrtr.read(content, :name)
greeting = Mrtr.sampled_text(server_context.input_response("greeting"))
roots = Mrtr.read(server_context.input_response("client_roots"), :roots)
if name.is_a?(String) && greeting && roots.is_a?(Array)
return MCP::Tool::Response.new(
[MCP::Content::Text.new("#{greeting} #{name} (#{roots.length} roots)").to_h],
)
end
MCP::Server::InputRequiredResult.new(
input_requests: {
user_name: {
method: "elicitation/create",
params: {
message: "What is your name?",
requestedSchema: {
type: "object",
properties: { name: { type: "string" } },
required: ["name"],
},
},
},
greeting: {
method: "sampling/createMessage",
params: {
messages: [{ role: "user", content: { type: "text", text: "Write a one-word greeting" } }],
maxTokens: 50,
},
},
client_roots: { method: "roots/list" },
},
request_state: JSON.generate({ kind: "multiple-inputs" }),
)
end
end
end
class TestInputRequiredResultMultiRound < MCP::Tool
tool_name "test_input_required_result_multi_round"
description "A tool that needs two elicitation rounds before completing (SEP-2322)"
class << self
def call(server_context:, **_args)
state = Mrtr.parse_state(server_context.request_state)
case state && state["round"]
when 1
content = Mrtr.accepted_content(server_context.input_response("user_name"))
name = content && Mrtr.read(content, :name)
return second_round(name) if name.is_a?(String)
when 2
content = Mrtr.accepted_content(server_context.input_response("favorite_color"))
color = content && Mrtr.read(content, :color)
if color.is_a?(String)
return MCP::Tool::Response.new([MCP::Content::Text.new("#{state["name"]} likes #{color}").to_h])
end
end
first_round
end
private
def first_round
MCP::Server::InputRequiredResult.new(
input_requests: {
user_name: {
method: "elicitation/create",
params: {
message: "What is your name?",
requestedSchema: {
type: "object",
properties: { name: { type: "string" } },
required: ["name"],
},
},
},
},
request_state: JSON.generate({ round: 1 }),
)
end
def second_round(name)
MCP::Server::InputRequiredResult.new(
input_requests: {
favorite_color: {
method: "elicitation/create",
params: {
message: "What is your favorite color?",
requestedSchema: {
type: "object",
properties: { color: { type: "string" } },
required: ["color"],
},
},
},
},
request_state: JSON.generate({ round: 2, name: name }),
)
end
end
end
class TestInputRequiredResultCapabilities < MCP::Tool
tool_name "test_input_required_result_capabilities"
description "A tool that only embeds input requests the client's declared capabilities can fulfill (SEP-2322)"
class << self
def call(server_context:, **_args)
capabilities = server_context.client_capabilities || {}
elicited = Mrtr.accepted_content(server_context.input_response("elicit_input"))
sampled = Mrtr.sampled_text(server_context.input_response("sample_input"))
requests = {}
if Mrtr.read(capabilities, :elicitation) && !elicited
requests[:elicit_input] = {
method: "elicitation/create",
params: {
message: "Provide a value",
requestedSchema: {
type: "object",
properties: { value: { type: "string" } },
},
},
}
end
if Mrtr.read(capabilities, :sampling) && !sampled
requests[:sample_input] = {
method: "sampling/createMessage",
params: {
messages: [{ role: "user", content: { type: "text", text: "Say hello" } }],
maxTokens: 50,
},
}
end
if requests.empty?
MCP::Tool::Response.new(
[MCP::Content::Text.new("Inputs: elicit=#{!elicited.nil?}, sample=#{!sampled.nil?}").to_h],
)
else
MCP::Server::InputRequiredResult.new(input_requests: requests)
end
end
end
end
end
module Prompts
@ -390,6 +707,45 @@ module Conformance
end
end
end
class TestInputRequiredResultPrompt < MCP::Prompt
prompt_name "test_input_required_result_prompt"
description "A prompt that asks for user context through an input_required result (SEP-2322)"
class << self
def template(_args, server_context: nil)
content = server_context && Mrtr.accepted_content(server_context.input_response("user_context"))
context = content && Mrtr.read(content, :context)
unless context.is_a?(String)
return MCP::Server::InputRequiredResult.new(
input_requests: {
user_context: {
method: "elicitation/create",
params: {
message: "Provide context for the prompt",
requestedSchema: {
type: "object",
properties: { context: { type: "string" } },
required: ["context"],
},
},
},
},
)
end
MCP::Prompt::Result.new(
messages: [
MCP::Prompt::Message.new(
role: "user",
content: MCP::Content::Text.new("Context: #{context}"),
),
],
)
end
end
end
end
class Server
@ -482,12 +838,21 @@ module Conformance
Tools::TestElicitationSep1034Defaults,
Tools::TestElicitationSep1330Enums,
Tools::TestReconnection,
Tools::TestMissingCapability,
Tools::TestInputRequiredResultElicitation,
Tools::TestInputRequiredResultSampling,
Tools::TestInputRequiredResultListRoots,
Tools::TestInputRequiredResultRequestState,
Tools::TestInputRequiredResultMultipleInputs,
Tools::TestInputRequiredResultMultiRound,
Tools::TestInputRequiredResultCapabilities,
],
prompts: [
Prompts::TestSimplePrompt,
Prompts::TestPromptWithArguments,
Prompts::TestPromptWithEmbeddedResource,
Prompts::TestPromptWithImage,
Prompts::TestInputRequiredResultPrompt,
],
resources: resources,
resource_templates: resource_templates,