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
1 changed files with 864 additions and 7 deletions
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue