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
3 changed files with 864 additions and 7 deletions
|
|
@ -10,6 +10,7 @@ require_relative "progress"
|
|||
require_relative "protocol_deprecations"
|
||||
require_relative "server_context"
|
||||
require_relative "server/capabilities"
|
||||
require_relative "server/input_required_result"
|
||||
require_relative "server/pagination"
|
||||
require_relative "server/transports"
|
||||
|
||||
|
|
@ -587,7 +588,10 @@ module MCP
|
|||
when Methods::INITIALIZE
|
||||
init(params, session: session)
|
||||
when Methods::RESOURCES_READ
|
||||
build_read_resource_result(read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope))
|
||||
contents = read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
|
||||
|
||||
# An SEP-2322 `input_required` result must not be wrapped as `contents` or stamped with SEP-2549 cache hints.
|
||||
contents.is_a?(InputRequiredResult) ? contents : build_read_resource_result(contents)
|
||||
when Methods::RESOURCES_SUBSCRIBE, Methods::RESOURCES_UNSUBSCRIBE
|
||||
validate_resource_subscription_params!(params)
|
||||
dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
|
||||
|
|
@ -611,6 +615,12 @@ module MCP
|
|||
next JsonRpcHandler::NO_RESPONSE
|
||||
end
|
||||
|
||||
# Runs after the cancellation check so a cancelled request stays suppressed
|
||||
# instead of turning into a gate error response.
|
||||
if result.is_a?(InputRequiredResult)
|
||||
result = serialize_input_required_result(result, envelope: envelope, request: params)
|
||||
end
|
||||
|
||||
result
|
||||
rescue CancelledError => e
|
||||
add_instrumentation_data(cancelled: true, cancellation_reason: e.reason)
|
||||
|
|
@ -669,6 +679,40 @@ module MCP
|
|||
end
|
||||
end
|
||||
|
||||
# Central gate and serializer for SEP-2322 `input_required` results, run once in the dispatch lambda for
|
||||
# whichever handler produced one. The result type exists only in the 2026-07-28 stateless lifecycle,
|
||||
# so a legacy request (no envelope) must not receive it: pre-2026 clients treat an unknown `resultType` as
|
||||
# a final result. The capability gate enforces the SEP-2575 rule that servers MUST NOT rely on
|
||||
# (or embed requests for) capabilities the client did not declare, and reports every missing capability at
|
||||
# once so the client sees the full set.
|
||||
def serialize_input_required_result(result, envelope:, request:)
|
||||
if envelope.nil?
|
||||
raise RequestHandlerError.new(
|
||||
"input_required results require the 2026-07-28 stateless lifecycle (SEP-2322)",
|
||||
request,
|
||||
error_type: :internal_error,
|
||||
)
|
||||
end
|
||||
|
||||
missing = result.missing_client_capabilities(envelope.client_capabilities)
|
||||
raise MissingRequiredClientCapabilityError.new(missing, request) unless missing.empty?
|
||||
|
||||
add_instrumentation_data(input_required: true)
|
||||
result.to_h
|
||||
end
|
||||
|
||||
# Extracts the SEP-2322 retry fields a client sends when re-issuing a request:
|
||||
# `inputResponses` (answers keyed like the earlier `inputRequests`) and the echoed opaque `requestState`.
|
||||
# They are params-top-level siblings of `name`/`arguments`/ `uri`, not `_meta` entries.
|
||||
def mrtr_retry_fields(params)
|
||||
return { input_responses: nil, request_state: nil } unless params.is_a?(Hash)
|
||||
|
||||
{
|
||||
input_responses: params[:inputResponses] || params["inputResponses"],
|
||||
request_state: params[:requestState] || params["requestState"],
|
||||
}
|
||||
end
|
||||
|
||||
def handle_cancelled_notification(params, session: nil)
|
||||
return unless session
|
||||
return unless params.is_a?(Hash)
|
||||
|
|
@ -864,8 +908,21 @@ module MCP
|
|||
progress_token = request.dig(:_meta, :progressToken)
|
||||
|
||||
response = call_tool_with_args(
|
||||
tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope
|
||||
tool,
|
||||
arguments,
|
||||
server_context_with_meta(request),
|
||||
progress_token: progress_token,
|
||||
session: session,
|
||||
related_request_id: related_request_id,
|
||||
cancellation: cancellation,
|
||||
envelope: envelope,
|
||||
retry_fields: mrtr_retry_fields(request),
|
||||
)
|
||||
# An SEP-2322 `input_required` result is not a tool result: output schema
|
||||
# validation would run against a `nil` `structuredContent` and the structured
|
||||
# content fallback does not apply. The dispatch lambda serializes it.
|
||||
return response if response.is_a?(InputRequiredResult)
|
||||
|
||||
result = response.to_h
|
||||
validate_tool_call_result!(tool, result)
|
||||
serialize_structured_content_fallback(
|
||||
|
|
@ -1078,6 +1135,7 @@ module MCP
|
|||
meta_source = request.is_a?(Hash) ? request : {}
|
||||
progress_token = meta_source.dig(:_meta, :progressToken)
|
||||
progress = Progress.new(notification_target: session, progress_token: progress_token, related_request_id: related_request_id)
|
||||
retry_fields = mrtr_retry_fields(meta_source)
|
||||
ServerContext.new(
|
||||
server_context_with_meta(meta_source),
|
||||
progress: progress,
|
||||
|
|
@ -1085,6 +1143,8 @@ module MCP
|
|||
related_request_id: related_request_id,
|
||||
cancellation: cancellation,
|
||||
envelope: envelope,
|
||||
input_responses: retry_fields[:input_responses],
|
||||
request_state: retry_fields[:request_state],
|
||||
)
|
||||
end
|
||||
|
||||
|
|
@ -1144,7 +1204,7 @@ module MCP
|
|||
end
|
||||
end
|
||||
|
||||
def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
|
||||
def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil, cancellation: nil, envelope: nil, retry_fields: nil)
|
||||
# Transports parse incoming JSON with `symbolize_names: true`, so `arguments` already arrives symbolized
|
||||
# at every nesting level. This top-level transform only guards callers that hand in string-keyed top-level arguments;
|
||||
# it does not recurse, and nested object keys remain symbols. Tools therefore receive symbol keys all the way down.
|
||||
|
|
@ -1160,6 +1220,8 @@ module MCP
|
|||
related_request_id: related_request_id,
|
||||
cancellation: cancellation,
|
||||
envelope: envelope,
|
||||
input_responses: retry_fields&.fetch(:input_responses, nil),
|
||||
request_state: retry_fields&.fetch(:request_state, nil),
|
||||
)
|
||||
tool.call(**args, server_context: server_context)
|
||||
else
|
||||
|
|
@ -1168,11 +1230,13 @@ module MCP
|
|||
end
|
||||
|
||||
def call_prompt_template_with_args(prompt, args, server_context)
|
||||
if accepts_server_context?(prompt.method(:template))
|
||||
prompt.template(args, server_context: server_context).to_h
|
||||
raw_result = if accepts_server_context?(prompt.method(:template))
|
||||
prompt.template(args, server_context: server_context)
|
||||
else
|
||||
prompt.template(args).to_h
|
||||
prompt.template(args)
|
||||
end
|
||||
|
||||
raw_result.is_a?(InputRequiredResult) ? raw_result : raw_result.to_h
|
||||
end
|
||||
|
||||
def server_context_with_meta(request)
|
||||
|
|
|
|||
163
lib/mcp/server/input_required_result.rb
Normal file
163
lib/mcp/server/input_required_result.rb
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require_relative "../methods"
|
||||
require_relative "../result_type"
|
||||
|
||||
module MCP
|
||||
class Server
|
||||
# A multi round-trip `input_required` result (SEP-2322, MCP 2026-07-28).
|
||||
# Handlers for `tools/call`, `prompts/get`, and `resources/read` may return one instead of
|
||||
# their normal result to ask the client for additional input: `input_requests` maps server-assigned keys
|
||||
# to embedded request shapes (`elicitation/create`, `sampling/createMessage`, or `roots/list`),
|
||||
# and `request_state` is an opaque continuation string the client echoes back byte-exactly when it retries
|
||||
# the original request with `inputResponses` under the same keys.
|
||||
#
|
||||
# The server holds no memory between rounds: handlers re-run from the start on every retry and read
|
||||
# the answers via `server_context.input_responses` / `server_context.request_state`
|
||||
# (deterministic replay, matching the Python SDK). At least one of the two fields must be present;
|
||||
# a `request_state`-only result is the load-shedding form ("retry later").
|
||||
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322
|
||||
class InputRequiredResult
|
||||
EMBEDDABLE_METHODS = [
|
||||
Methods::ELICITATION_CREATE,
|
||||
Methods::SAMPLING_CREATE_MESSAGE,
|
||||
Methods::ROOTS_LIST,
|
||||
].freeze
|
||||
|
||||
attr_reader :input_requests, :request_state
|
||||
|
||||
def initialize(input_requests: nil, request_state: nil)
|
||||
if (input_requests.nil? || input_requests.empty?) && request_state.nil?
|
||||
raise ArgumentError, "at least one of input_requests or request_state is required"
|
||||
end
|
||||
unless request_state.nil? || request_state.is_a?(String)
|
||||
raise ArgumentError, "request_state must be a String"
|
||||
end
|
||||
|
||||
@input_requests = normalize_input_requests(input_requests)
|
||||
@request_state = request_state
|
||||
freeze
|
||||
end
|
||||
|
||||
def to_h
|
||||
serialized_requests = @input_requests&.transform_values do |entry|
|
||||
{ method: entry[:method], params: entry[:params] }.compact
|
||||
end
|
||||
|
||||
{
|
||||
resultType: ResultType::INPUT_REQUIRED,
|
||||
inputRequests: serialized_requests,
|
||||
requestState: @request_state,
|
||||
}.compact
|
||||
end
|
||||
|
||||
# The client capabilities required to fulfill every embedded request, merged into one nested hash.
|
||||
# The mapping matches the TypeScript SDK's `requiredClientCapabilitiesForInputRequest`:
|
||||
# `elicitation/create` with `mode: "url"` requires `elicitation.url`, any other `elicitation/create`
|
||||
# requires `elicitation.form`, `sampling/createMessage` with `tools`/`toolChoice` requires `sampling.tools`
|
||||
# (plain sampling otherwise), and `roots/list` requires `roots`.
|
||||
def required_client_capabilities
|
||||
(@input_requests || {}).values.reduce({}) do |merged, entry|
|
||||
deep_merge(merged, entry_capability(entry))
|
||||
end
|
||||
end
|
||||
|
||||
# The subset of {#required_client_capabilities} the request did not declare.
|
||||
# Per SEP-2575, servers MUST NOT rely on capabilities the client has not declared,
|
||||
# so a non-empty return means the result must not be sent (`-32021`).
|
||||
def missing_client_capabilities(declared)
|
||||
missing = missing_subtree(required_client_capabilities, declared)
|
||||
prune_implied_form_elicitation(missing, declared)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def normalize_input_requests(input_requests)
|
||||
return if input_requests.nil?
|
||||
|
||||
raise ArgumentError, "input_requests must be a Hash" unless input_requests.is_a?(Hash)
|
||||
|
||||
normalized = input_requests.each_with_object({}) do |(key, entry), result|
|
||||
raise ArgumentError, "input_requests entries must be Hashes" unless entry.is_a?(Hash)
|
||||
|
||||
method = entry[:method] || entry["method"]
|
||||
unless EMBEDDABLE_METHODS.include?(method)
|
||||
raise ArgumentError,
|
||||
"input_requests entry #{key.inspect} must have a method of " \
|
||||
"#{EMBEDDABLE_METHODS.join(", ")} (got #{method.inspect})"
|
||||
end
|
||||
|
||||
params = entry[:params] || entry["params"]
|
||||
raise ArgumentError, "input_requests entry params must be a Hash" unless params.nil? || params.is_a?(Hash)
|
||||
|
||||
result[key.to_s] = { method: method, params: params }.compact.freeze
|
||||
end
|
||||
|
||||
normalized.freeze
|
||||
end
|
||||
|
||||
def entry_capability(entry)
|
||||
params = entry[:params]
|
||||
|
||||
case entry[:method]
|
||||
when Methods::ELICITATION_CREATE
|
||||
mode = params && (params[:mode] || params["mode"])
|
||||
mode == "url" ? { elicitation: { url: {} } } : { elicitation: { form: {} } }
|
||||
when Methods::SAMPLING_CREATE_MESSAGE
|
||||
with_tools = params && (params.key?(:tools) || params.key?("tools") ||
|
||||
params.key?(:toolChoice) || params.key?("toolChoice"))
|
||||
with_tools ? { sampling: { tools: {} } } : { sampling: {} }
|
||||
when Methods::ROOTS_LIST
|
||||
{ roots: {} }
|
||||
end
|
||||
end
|
||||
|
||||
# Walks `required` and keeps only the branches absent from `declared`
|
||||
# (symbol/string tolerant on the declared side).
|
||||
def missing_subtree(required, declared)
|
||||
required.each_with_object({}) do |(name, nested), missing|
|
||||
declared_value = read_key(declared, name)
|
||||
|
||||
if declared_value.nil?
|
||||
missing[name] = nested
|
||||
elsif nested.is_a?(Hash) && !nested.empty?
|
||||
nested_missing = missing_subtree(nested, declared_value)
|
||||
missing[name] = nested_missing unless nested_missing.empty?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# 2025 back-compat implication shared with the TypeScript and Python SDKs:
|
||||
# a bare `elicitation: {}` declaration implies form elicitation support, while
|
||||
# an explicit url-only declaration does not.
|
||||
def prune_implied_form_elicitation(missing, declared)
|
||||
elicitation_missing = missing[:elicitation]
|
||||
return missing unless elicitation_missing.is_a?(Hash) && elicitation_missing.key?(:form)
|
||||
|
||||
declared_elicitation = read_key(declared, :elicitation)
|
||||
return missing unless declared_elicitation.is_a?(Hash)
|
||||
return missing unless read_key(declared_elicitation, :url).nil?
|
||||
|
||||
pruned = elicitation_missing.reject { |key, _| key == :form }
|
||||
pruned.empty? ? missing.reject { |key, _| key == :elicitation } : missing.merge(elicitation: pruned)
|
||||
end
|
||||
|
||||
def read_key(hash, key)
|
||||
return unless hash.is_a?(Hash)
|
||||
|
||||
value = hash[key.to_sym]
|
||||
value.nil? ? hash[key.to_s] : value
|
||||
end
|
||||
|
||||
def deep_merge(left, right)
|
||||
left.merge(right) do |_key, left_value, right_value|
|
||||
if left_value.is_a?(Hash) && right_value.is_a?(Hash)
|
||||
deep_merge(left_value, right_value)
|
||||
else
|
||||
right_value
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -8,13 +8,31 @@ module MCP
|
|||
# `nil` on legacy requests.
|
||||
attr_reader :envelope
|
||||
|
||||
def initialize(context, progress:, notification_target:, related_request_id: nil, cancellation: nil, envelope: nil)
|
||||
# SEP-2322 multi round-trip retry fields, present when the client re-issued the request after
|
||||
# an `input_required` result: `input_responses` maps the keys of the earlier `inputRequests` to
|
||||
# the client's answers, and `request_state` is the opaque continuation string echoed back byte-exactly.
|
||||
# Both are `nil` on a first-round request. Only handlers that opt in to `server_context:` can read them
|
||||
# (the same access model as the envelope readers).
|
||||
attr_reader :input_responses, :request_state
|
||||
|
||||
def initialize(context, progress:, notification_target:, related_request_id: nil, cancellation: nil, envelope: nil,
|
||||
input_responses: nil, request_state: nil)
|
||||
@context = context
|
||||
@progress = progress
|
||||
@notification_target = notification_target
|
||||
@related_request_id = related_request_id
|
||||
@cancellation = cancellation
|
||||
@envelope = envelope
|
||||
@input_responses = input_responses
|
||||
@request_state = request_state
|
||||
end
|
||||
|
||||
# Reads one entry of {#input_responses} by its `inputRequests` key, tolerating symbol or string keys.
|
||||
def input_response(key)
|
||||
return unless @input_responses.is_a?(Hash)
|
||||
|
||||
value = @input_responses[key.to_sym]
|
||||
value.nil? ? @input_responses[key.to_s] : value
|
||||
end
|
||||
|
||||
def cancelled?
|
||||
|
|
|
|||
Loading…
Reference in a new issue