Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2954,6 +2954,14 @@ SEP-2322 also makes `resultType` a required member of every result a 2026-07-28
the modern `_meta` envelope (and on `server/discover` results), while results that already carry a discriminator (`"input_required"`, the tasks extension's `"task"`) keep it.
Legacy results stay unstamped, and clients treat an absent `resultType` as `"complete"` per the spec.

#### Dual-era authoring (legacy fulfilment shim)

Handlers written in the 2026 style serve pre-2026 clients too: 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. Each `inputRequests` entry is sent as the equivalent real server-to-client request
(`elicitation/create`, `sampling/createMessage`, `roots/list`), associated with the originating request per SEP-2260; the answers are collected under the same keys,
and the handler re-runs with `server_context.input_responses` populated and the raw `requestState` echoed, the same deterministic replay contract the modern client driver follows.
The shim is on by default (matching the TypeScript SDK) and capped at 8 rounds; `MCP::Server.new(input_required_legacy_shim: false)` restores the strict rejection of `input_required` results on legacy requests.

## Conformance Testing

The `conformance/` directory contains a test server and runner that validate the SDK against the MCP specification using [`@modelcontextprotocol/conformance`](https://github.com/modelcontextprotocol/conformance).
Expand Down
101 changes: 100 additions & 1 deletion lib/mcp/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ def initialize(
ttl_ms: nil,
cache_scope: nil,
request_state_security: nil,
input_required_legacy_shim: true,
transport: nil
)
@description = description
Expand All @@ -187,6 +188,12 @@ def initialize(
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
Expand Down Expand Up @@ -645,7 +652,18 @@ def handle_request(request, method, session: nil, related_request_id: nil)
# 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;
Expand Down Expand Up @@ -754,6 +772,87 @@ def serialize_input_required_result(result, envelope:, request:, method:)
# `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],
entry[:params],
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

# 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.
Expand Down
9 changes: 9 additions & 0 deletions lib/mcp/server_session.rb
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,15 @@ def create_url_elicitation(message:, url:, elicitation_id:, related_request_id:
send_to_transport_request(Methods::ELICITATION_CREATE, params, related_request_id: related_request_id)
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
Expand Down
Loading