Fulfill input_required results on the legacy wire per SEP-2322

## Motivation and Context

SEP-2322 handlers return `InputRequiredResult` instead of issuing in-flight server-to-client requests,
but that result type exists only on the 2026-07-28 wire: pre-2026 clients treat an unknown `resultType`
as a final result, so the server rejected `input_required` on legacy requests with an internal error.
That forced authors to keep two handler styles, one per era.

This adds the dual-era authoring shim both reference implementations converged on:
the TypeScript SDK's default-on legacy fulfilment shim (typescript-sdk#2381)
and the Python SDK's version-negotiating resolver (python-sdk#2986).
When a `tools/call`, `prompts/get`, or `resources/read` handler returns an `InputRequiredResult` on
the legacy wire, the server fulfills it in place of the client's driver:

- Every `inputRequests` entry is sent verbatim as the equivalent real server-to-client request through
  a new `ServerSession#fulfill_input_request` (per the spec, clients treat each embedded entry exactly like
  the standalone request), stamped with the originating request id so the association requirement of
  SEP-2260 holds and Streamable HTTP delivery rides the originating POST stream. One exception to
  the verbatim forwarding: a URL-mode elicitation leg gains a synthesized `elicitationId` when
  the handler supplied none, since the 2025-11-25 wire requires the field and the 2026-07-28 in-band
  shape dropped it (correlation rides `requestState` there) - matching the TypeScript shim.
- The answers are collected under the same keys and the handler re-runs with `inputResponses`
  and the raw `requestState` merged into the original params - the same deterministic replay contract
  the modern client driver follows, read through the existing `server_context.input_responses`/`request_state` accessors.
  Only the current round's responses are sent, matching the client driver; handlers carry earlier answers through `requestState`.
- `requestState` round-trips in-process as the value the handler wrote. `RequestStateSecurity` sealing is
  wire hardening against a tampering client and does not apply to the in-process replay.
- The embedded-capability gate still runs each round against the session's declared client capabilities;
  a violation fails before any client contact. The `-32021` code is 2026-only, so the legacy wire gets
  an internal error naming the missing capabilities.
- Rounds are capped at 8 (`LEGACY_INPUT_REQUIRED_MAX_ROUNDS`), matching the TypeScript shim's `maxRounds` default;
  exhaustion fails with an explanatory internal error, which also bounds the `requestState`-only load-shedding form
  that cannot make progress in-process.

The shim is on by default, matching the TypeScript SDK; `Server.new(input_required_legacy_shim: false)` restores
the strict rejection. Requests without a session (no notification path back to a client) and all modern requests
are untouched: the modern wire keeps returning the `input_required` result for the client driver.

Resolves #382.

## How Has This Been Tested?

`bundle exec rake` passes: 1691 test runs / 4381 assertions with zero failures and RuboCop clean.
The conformance baseline check passes on both legs (the frozen 2025-11-25 wire is where the shim lives),
and the modern `input-required-result-*` scenarios stay green, confirming the modern path is untouched.

New `test/mcp/server_input_required_legacy_shim_test.rb` covering: completion through
a real elicitation with the SEP-2260 association asserted on the transport, replay exposing
the collected responses and the raw `requestState`, sealing bypass under `RequestStateSecurity`,
`prompts/get` and `resources/read` shimming, multi-round collection, the round cap, the capability gate failing
before client contact, the URL-mode leg gaining a synthesized `elicitationId` (and a handler-supplied
one riding unchanged), a client answering a fulfilment leg with an error failing the original request,
the opt-out, session-less rejection, and modern requests staying untouched.

The pre-existing legacy rejection test (session-less) and all modern MRTR serialization tests pass unchanged.

## Breaking Changes

Only for handlers that returned `InputRequiredResult` to legacy clients THROUGH A SESSION and relied
on the internal-error rejection: those now drive a real fulfilment round trip by default.
No such handler could have worked end to end before (the request always failed), so this is the intended upgrade;
`input_required_legacy_shim: false` restores the previous behavior. Everything else is unchanged.
This commit is contained in:
Koichi ITO 2026-07-05 02:31:20 +09:00
commit b159314baa
2 changed files with 468 additions and 1 deletions

View file

@ -1,5 +1,7 @@
# frozen_string_literal: true
require "securerandom"
require_relative "../json_rpc_handler"
require_relative "cancellation"
require_relative "cancelled_error"
@ -201,6 +203,7 @@ module MCP
ttl_ms: nil,
cache_scope: nil,
request_state_security: nil,
input_required_legacy_shim: true,
transport: nil
)
@description = description
@ -221,6 +224,12 @@ module MCP
self.ttl_ms = ttl_ms
self.cache_scope = cache_scope
@request_state_security = request_state_security
# Dual-era authoring (SEP-2322): on the legacy wire, an `input_required` result is fulfilled
# through real server-to-client requests and the handler re-runs, so handlers written
# in the 2026 style serve both eras. `false` restores the strict rejection of `input_required`
# on legacy requests. Matches the TypeScript SDK's default-on legacy shim.
@input_required_legacy_shim = input_required_legacy_shim
@configuration = MCP.configuration.merge(configuration)
@client = nil
@client_protocol_version = nil
@ -679,7 +688,18 @@ module MCP
# 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, method: method)
result = if envelope.nil? && @input_required_legacy_shim && session
run_legacy_input_required_shim(
result,
method: method,
params: params,
session: session,
related_request_id: related_request_id,
cancellation: cancellation,
)
else
serialize_input_required_result(result, envelope: envelope, request: params, method: method)
end
end
# SEP-2322 makes `resultType` REQUIRED on every result a 2026-07-28 server returns;
@ -817,6 +837,99 @@ module MCP
# `inputResponses`/`requestState` (SEP-2322).
MRTR_METHODS = [Methods::TOOLS_CALL, Methods::PROMPTS_GET, Methods::RESOURCES_READ].freeze
# Fulfilment rounds the legacy shim runs before giving up, matching the TypeScript SDK's legacy shim default (`maxRounds: 8`).
LEGACY_INPUT_REQUIRED_MAX_ROUNDS = 8
# Dual-era authoring shim (SEP-2322): a handler on the legacy wire returned an `input_required` result,
# which pre-2026 clients cannot understand, so the server fulfills it in place of the client's driver.
# Every entry of `inputRequests` is sent as the equivalent real server-to-client request (associated with
# the originating request per SEP-2260), the answers are collected under the same keys, and the handler
# re-runs with `inputResponses`/`requestState` merged into the original params - the same deterministic replay
# contract the modern client driver follows. The `requestState` round-trips in-process as the raw value
# the handler wrote; `RequestStateSecurity` sealing is wire hardening and does not apply.
def run_legacy_input_required_shim(result, method:, params:, session:, related_request_id:, cancellation:)
rounds = 0
loop do
missing = result.missing_client_capabilities(session.client_capabilities)
unless missing.empty?
# The explicit `error_code` keeps the descriptive message in the JSON-RPC error response
# (the `ResourceNotFoundError` pattern). `-32021` is a 2026-07-28 code, so the legacy wire
# gets a plain internal error.
raise RequestHandlerError.new(
"input_required requires client capabilities the client did not declare: #{missing.to_json}",
params,
error_type: :internal_error,
error_code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR,
)
end
responses = (result.input_requests || {}).each_with_object({}) do |(key, entry), collected|
collected[key] = session.fulfill_input_request(
entry[:method],
legacy_leg_params(entry),
related_request_id: related_request_id,
)
end
retry_params = params.reject { |key, _| [:inputResponses, :requestState].include?(key.to_sym) }
retry_params[:inputResponses] = responses unless responses.empty?
retry_params[:requestState] = result.request_state if result.request_state
result = redispatch_mrtr_method(
method,
retry_params,
session: session,
related_request_id: related_request_id,
cancellation: cancellation,
)
return result unless result.is_a?(InputRequiredResult)
rounds += 1
next if rounds < LEGACY_INPUT_REQUIRED_MAX_ROUNDS
raise RequestHandlerError.new(
"Handler still returned `input_required` after #{LEGACY_INPUT_REQUIRED_MAX_ROUNDS} legacy shim rounds (SEP-2322)",
params,
error_type: :internal_error,
error_code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR,
)
end
end
# The params an embedded entry sends on its legacy leg. Entries are forwarded verbatim with
# one exception: the 2025-11-25 wire requires `elicitationId` on URL-mode elicitation requests,
# a field the 2026-07-28 in-band shape dropped (correlation rides `requestState` there),
# so a missing one is synthesized for the leg, matching the TypeScript SDK's shim.
def legacy_leg_params(entry)
params = entry[:params]
return params unless entry[:method] == Methods::ELICITATION_CREATE
return params if params.nil? || (params[:mode] || params["mode"]) != "url"
return params if params.key?(:elicitationId) || params.key?("elicitationId")
params.merge(elicitationId: SecureRandom.uuid)
end
# Re-runs the handler of one of the three MRTR-capable methods for
# the legacy shim. Legacy wire, so no envelope is threaded.
def redispatch_mrtr_method(method, params, session:, related_request_id:, cancellation:)
case method
when Methods::TOOLS_CALL
call_tool(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
when Methods::PROMPTS_GET
get_prompt(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
when Methods::RESOURCES_READ
contents = read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
contents.is_a?(InputRequiredResult) ? contents : build_read_resource_result(contents)
else
raise RequestHandlerError.new(
"input_required results are only supported for #{MRTR_METHODS.join(", ")}",
params,
error_type: :internal_error,
)
end
end
# Replaces a sealed client-echoed `requestState` with its verified plaintext before dispatch,
# so handlers always read the state they wrote. A tampered, expired, or cross-request token is
# rejected as invalid params, matching the Python SDK's "Invalid or expired requestState" behavior.

View file

@ -215,6 +215,15 @@ module MCP
)
end
# Sends an embedded SEP-2322 `inputRequests` entry as a real server-to-client request on the legacy wire,
# for the server's dual-era fulfilment shim.
# The entry is forwarded verbatim - per the spec, clients treat each entry exactly like the equivalent
# standalone request - and stays associated with the originating client request per SEP-2260.
# Returns the client's result.
def fulfill_input_request(method, params, related_request_id:)
send_to_transport_request(method, params, related_request_id: related_request_id)
end
# Sends `notifications/cancelled` to the peer for a nested server-to-client request
# that was started inside a now-cancelled parent request. `related_request_id`
# is the parent request id so the notification is routed to the same stream