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:
parent
33ed4a80aa
commit
b159314baa
4 changed files with 468 additions and 1 deletions
|
|
@ -2986,6 +2986,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.
|
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.
|
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
|
## 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).
|
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).
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "securerandom"
|
||||||
|
|
||||||
require_relative "../json_rpc_handler"
|
require_relative "../json_rpc_handler"
|
||||||
require_relative "cancellation"
|
require_relative "cancellation"
|
||||||
require_relative "cancelled_error"
|
require_relative "cancelled_error"
|
||||||
|
|
@ -201,6 +203,7 @@ module MCP
|
||||||
ttl_ms: nil,
|
ttl_ms: nil,
|
||||||
cache_scope: nil,
|
cache_scope: nil,
|
||||||
request_state_security: nil,
|
request_state_security: nil,
|
||||||
|
input_required_legacy_shim: true,
|
||||||
transport: nil
|
transport: nil
|
||||||
)
|
)
|
||||||
@description = description
|
@description = description
|
||||||
|
|
@ -221,6 +224,12 @@ module MCP
|
||||||
self.ttl_ms = ttl_ms
|
self.ttl_ms = ttl_ms
|
||||||
self.cache_scope = cache_scope
|
self.cache_scope = cache_scope
|
||||||
@request_state_security = request_state_security
|
@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)
|
@configuration = MCP.configuration.merge(configuration)
|
||||||
@client = nil
|
@client = nil
|
||||||
@client_protocol_version = nil
|
@client_protocol_version = nil
|
||||||
|
|
@ -679,7 +688,18 @@ module MCP
|
||||||
# Runs after the cancellation check so a cancelled request stays suppressed
|
# Runs after the cancellation check so a cancelled request stays suppressed
|
||||||
# instead of turning into a gate error response.
|
# instead of turning into a gate error response.
|
||||||
if result.is_a?(InputRequiredResult)
|
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
|
end
|
||||||
|
|
||||||
# SEP-2322 makes `resultType` REQUIRED on every result a 2026-07-28 server returns;
|
# SEP-2322 makes `resultType` REQUIRED on every result a 2026-07-28 server returns;
|
||||||
|
|
@ -817,6 +837,99 @@ module MCP
|
||||||
# `inputResponses`/`requestState` (SEP-2322).
|
# `inputResponses`/`requestState` (SEP-2322).
|
||||||
MRTR_METHODS = [Methods::TOOLS_CALL, Methods::PROMPTS_GET, Methods::RESOURCES_READ].freeze
|
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,
|
# 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
|
# 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.
|
# rejected as invalid params, matching the Python SDK's "Invalid or expired requestState" behavior.
|
||||||
|
|
|
||||||
|
|
@ -215,6 +215,15 @@ module MCP
|
||||||
)
|
)
|
||||||
end
|
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
|
# 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`
|
# 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
|
# is the parent request id so the notification is routed to the same stream
|
||||||
|
|
|
||||||
337
test/mcp/server_input_required_legacy_shim_test.rb
Normal file
337
test/mcp/server_input_required_legacy_shim_test.rb
Normal file
|
|
@ -0,0 +1,337 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
module MCP
|
||||||
|
# The SEP-2322 dual-era authoring shim: a handler returning an `InputRequiredResult`
|
||||||
|
# on the legacy wire is fulfilled through real server-to-client requests and re-run,
|
||||||
|
# so handlers written in the 2026 style serve pre-2026 clients too.
|
||||||
|
class ServerInputRequiredLegacyShimTest < ActiveSupport::TestCase
|
||||||
|
# Answers every server-to-client request like a cooperative client and records what was sent.
|
||||||
|
class AnsweringTransport
|
||||||
|
attr_reader :sent
|
||||||
|
|
||||||
|
def initialize(answers = {})
|
||||||
|
@answers = answers
|
||||||
|
@sent = []
|
||||||
|
end
|
||||||
|
|
||||||
|
def send_request(method, params = nil, related_request_id: nil)
|
||||||
|
@sent << { method: method, params: params, related_request_id: related_request_id }
|
||||||
|
@answers.fetch(method) do
|
||||||
|
case method
|
||||||
|
when Methods::ELICITATION_CREATE
|
||||||
|
{ action: "accept", content: { name: "Koichi" } }
|
||||||
|
when Methods::ROOTS_LIST
|
||||||
|
{ roots: [{ uri: "file:///workspace", name: "workspace" }] }
|
||||||
|
when Methods::SAMPLING_CREATE_MESSAGE
|
||||||
|
{ role: "assistant", content: { type: "text", text: "sampled" }, model: "test-model" }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def send_notification(method, params = nil, session_id: nil, related_request_id: nil)
|
||||||
|
true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Raises like the bundled transports when the client answers a server-to-client request
|
||||||
|
# with a JSON-RPC error.
|
||||||
|
class RejectingTransport
|
||||||
|
def send_request(method, params = nil, related_request_id: nil)
|
||||||
|
raise StandardError, "Client returned an error for #{method} request (code: -1): User rejected"
|
||||||
|
end
|
||||||
|
|
||||||
|
def send_notification(method, params = nil, session_id: nil, related_request_id: nil)
|
||||||
|
true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
setup do
|
||||||
|
@greeting_tool = Tool.define(name: "greeter") do |server_context:|
|
||||||
|
answer = server_context.input_response("who")
|
||||||
|
if answer
|
||||||
|
Tool::Response.new([{ type: "text", text: "Hello, #{answer.dig(:content, :name)}!" }])
|
||||||
|
else
|
||||||
|
Server::InputRequiredResult.new(
|
||||||
|
input_requests: { "who" => { method: Methods::ELICITATION_CREATE, params: { message: "Who?" } } },
|
||||||
|
request_state: "round-1",
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a legacy tools/call is fulfilled through a real elicitation and completes" do
|
||||||
|
transport = AnsweringTransport.new
|
||||||
|
session = legacy_session(tools: [@greeting_tool], transport: transport)
|
||||||
|
|
||||||
|
response = session.handle(tool_call_request)
|
||||||
|
|
||||||
|
assert_equal "Hello, Koichi!", response.dig(:result, :content, 0, :text)
|
||||||
|
refute response[:result].key?(:resultType)
|
||||||
|
|
||||||
|
sent = transport.sent.first
|
||||||
|
assert_equal Methods::ELICITATION_CREATE, sent[:method]
|
||||||
|
assert_equal({ message: "Who?" }, sent[:params])
|
||||||
|
|
||||||
|
# SEP-2260: the fulfilment request stays associated with the originating request.
|
||||||
|
assert_equal 1, sent[:related_request_id]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "the handler re-runs with the collected responses and the raw requestState" do
|
||||||
|
seen = []
|
||||||
|
tool = Tool.define(name: "stateful") do |server_context:|
|
||||||
|
seen << { responses: server_context.input_responses, state: server_context.request_state }
|
||||||
|
if server_context.request_state == "opaque-state"
|
||||||
|
Tool::Response.new([{ type: "text", text: "resumed" }])
|
||||||
|
else
|
||||||
|
Server::InputRequiredResult.new(
|
||||||
|
input_requests: { "k" => { method: Methods::ELICITATION_CREATE, params: { message: "?" } } },
|
||||||
|
request_state: "opaque-state",
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
session = legacy_session(tools: [tool], transport: AnsweringTransport.new)
|
||||||
|
|
||||||
|
response = session.handle(tool_call_request(name: "stateful"))
|
||||||
|
|
||||||
|
assert_equal "resumed", response.dig(:result, :content, 0, :text)
|
||||||
|
assert_equal 2, seen.size
|
||||||
|
assert_nil seen.first[:responses]
|
||||||
|
assert_equal "opaque-state", seen.last[:state]
|
||||||
|
assert_equal({ action: "accept", content: { name: "Koichi" } }, seen.last[:responses]["k"])
|
||||||
|
end
|
||||||
|
|
||||||
|
test "the shim bypasses requestState sealing" do
|
||||||
|
seen_state = nil
|
||||||
|
tool = Tool.define(name: "sealed") do |server_context:|
|
||||||
|
if server_context.request_state
|
||||||
|
seen_state = server_context.request_state
|
||||||
|
Tool::Response.new([{ type: "text", text: "done" }])
|
||||||
|
else
|
||||||
|
Server::InputRequiredResult.new(
|
||||||
|
input_requests: { "k" => { method: Methods::ELICITATION_CREATE, params: { message: "?" } } },
|
||||||
|
request_state: "plain-state",
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
security = Server::RequestStateSecurity.new(key: "k" * 32)
|
||||||
|
session = legacy_session(tools: [tool], transport: AnsweringTransport.new, request_state_security: security)
|
||||||
|
|
||||||
|
response = session.handle(tool_call_request(name: "sealed"))
|
||||||
|
|
||||||
|
assert_equal "done", response.dig(:result, :content, 0, :text)
|
||||||
|
|
||||||
|
# In-process replay round-trips the handler's own value; sealing is wire hardening.
|
||||||
|
assert_equal "plain-state", seen_state
|
||||||
|
end
|
||||||
|
|
||||||
|
test "prompts/get and resources/read are shimmed too" do
|
||||||
|
prompt = Prompt.define(name: "greeting_prompt") do |_args, server_context:|
|
||||||
|
if server_context.input_response("who")
|
||||||
|
Prompt::Result.new(messages: [Prompt::Message.new(role: "user", content: Content::Text.new("hi"))])
|
||||||
|
else
|
||||||
|
Server::InputRequiredResult.new(
|
||||||
|
input_requests: { "who" => { method: Methods::ELICITATION_CREATE, params: { message: "Who?" } } },
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
server = Server.new(name: "shim_test", prompts: [prompt], resources: [])
|
||||||
|
server.resources_read_handler do |params|
|
||||||
|
# `server_context` is unavailable in this handler shape, so park once via the params-visible retry fields instead.
|
||||||
|
if params[:inputResponses]
|
||||||
|
[{ uri: params[:uri], mimeType: "text/plain", text: "read" }]
|
||||||
|
else
|
||||||
|
Server::InputRequiredResult.new(
|
||||||
|
input_requests: { "who" => { method: Methods::ELICITATION_CREATE, params: { message: "Who?" } } },
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
transport = AnsweringTransport.new
|
||||||
|
session = ServerSession.new(server: server, transport: transport, session_id: "legacy")
|
||||||
|
session.store_client_info(client: { name: "legacy" }, capabilities: { elicitation: { form: {} } })
|
||||||
|
|
||||||
|
prompt_response = session.handle(
|
||||||
|
{ jsonrpc: "2.0", id: 10, method: Methods::PROMPTS_GET, params: { name: "greeting_prompt" } },
|
||||||
|
)
|
||||||
|
assert_equal "hi", prompt_response.dig(:result, :messages, 0, :content, :text)
|
||||||
|
|
||||||
|
read_response = session.handle(
|
||||||
|
{ jsonrpc: "2.0", id: 11, method: Methods::RESOURCES_READ, params: { uri: "file:///a.txt" } },
|
||||||
|
)
|
||||||
|
assert_equal "read", read_response.dig(:result, :contents, 0, :text)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "multiple rounds collect each round's answers" do
|
||||||
|
rounds = []
|
||||||
|
tool = Tool.define(name: "two_rounds") do |server_context:|
|
||||||
|
rounds << server_context.input_responses&.keys
|
||||||
|
if server_context.input_response("second")
|
||||||
|
Tool::Response.new([{ type: "text", text: "done" }])
|
||||||
|
elsif server_context.input_response("first")
|
||||||
|
Server::InputRequiredResult.new(
|
||||||
|
input_requests: { "second" => { method: Methods::ELICITATION_CREATE, params: { message: "2?" } } },
|
||||||
|
request_state: "after-first",
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Server::InputRequiredResult.new(
|
||||||
|
input_requests: { "first" => { method: Methods::ELICITATION_CREATE, params: { message: "1?" } } },
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
session = legacy_session(tools: [tool], transport: AnsweringTransport.new)
|
||||||
|
|
||||||
|
response = session.handle(tool_call_request(name: "two_rounds"))
|
||||||
|
|
||||||
|
assert_equal "done", response.dig(:result, :content, 0, :text)
|
||||||
|
assert_equal [nil, ["first"], ["second"]], rounds
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a url-mode elicitation leg gains the elicitationId the legacy wire requires" do
|
||||||
|
# The 2026-07-28 in-band shape has no `elicitationId` (correlation rides `requestState`),
|
||||||
|
# but the 2025-11-25 wire requires the field on URL-mode elicitation requests.
|
||||||
|
transport = AnsweringTransport.new
|
||||||
|
tool = Tool.define(name: "url_asker") do |server_context:|
|
||||||
|
if server_context.input_response("auth")
|
||||||
|
Tool::Response.new([{ type: "text", text: "authed" }])
|
||||||
|
else
|
||||||
|
Server::InputRequiredResult.new(
|
||||||
|
input_requests: {
|
||||||
|
"auth" => {
|
||||||
|
method: Methods::ELICITATION_CREATE,
|
||||||
|
params: { mode: "url", url: "https://example.com/auth", message: "Sign in" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
session = legacy_session(tools: [tool], transport: transport, capabilities: { elicitation: { url: {} } })
|
||||||
|
|
||||||
|
response = session.handle(tool_call_request(name: "url_asker"))
|
||||||
|
|
||||||
|
assert_equal "authed", response.dig(:result, :content, 0, :text)
|
||||||
|
sent = transport.sent.first
|
||||||
|
assert_equal "https://example.com/auth", sent[:params][:url]
|
||||||
|
refute_nil sent[:params][:elicitationId]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a handler-supplied elicitationId rides the leg unchanged" do
|
||||||
|
transport = AnsweringTransport.new
|
||||||
|
tool = Tool.define(name: "url_asker") do |server_context:|
|
||||||
|
if server_context.input_response("auth")
|
||||||
|
Tool::Response.new([{ type: "text", text: "authed" }])
|
||||||
|
else
|
||||||
|
Server::InputRequiredResult.new(
|
||||||
|
input_requests: {
|
||||||
|
"auth" => {
|
||||||
|
method: Methods::ELICITATION_CREATE,
|
||||||
|
params: { mode: "url", url: "https://example.com/auth", elicitationId: "mine" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
session = legacy_session(tools: [tool], transport: transport, capabilities: { elicitation: { url: {} } })
|
||||||
|
|
||||||
|
session.handle(tool_call_request(name: "url_asker"))
|
||||||
|
|
||||||
|
assert_equal "mine", transport.sent.first[:params][:elicitationId]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a client answering a fulfilment leg with an error fails the original request" do
|
||||||
|
session = legacy_session(tools: [@greeting_tool], transport: RejectingTransport.new)
|
||||||
|
|
||||||
|
response = session.handle(tool_call_request)
|
||||||
|
|
||||||
|
assert_equal(-32603, response.dig(:error, :code))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a handler that never completes fails after the round cap" do
|
||||||
|
tool = Tool.define(name: "greedy") do |server_context:|
|
||||||
|
round = (server_context.request_state || "0").to_i + 1
|
||||||
|
Server::InputRequiredResult.new(
|
||||||
|
input_requests: { "k#{round}" => { method: Methods::ELICITATION_CREATE, params: { message: "?" } } },
|
||||||
|
request_state: round.to_s,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
session = legacy_session(tools: [tool], transport: AnsweringTransport.new)
|
||||||
|
|
||||||
|
response = session.handle(tool_call_request(name: "greedy"))
|
||||||
|
|
||||||
|
assert_equal(-32603, response.dig(:error, :code))
|
||||||
|
assert_match(/#{Server::LEGACY_INPUT_REQUIRED_MAX_ROUNDS} legacy shim rounds/, response.dig(:error, :message))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "embedded requests exceeding the declared capabilities fail without contacting the client" do
|
||||||
|
transport = AnsweringTransport.new
|
||||||
|
session = legacy_session(tools: [@greeting_tool], transport: transport, capabilities: {})
|
||||||
|
|
||||||
|
response = session.handle(tool_call_request)
|
||||||
|
|
||||||
|
assert_equal(-32603, response.dig(:error, :code))
|
||||||
|
assert_match(/elicitation/, response.dig(:error, :message))
|
||||||
|
assert_empty transport.sent
|
||||||
|
end
|
||||||
|
|
||||||
|
test "opting out restores the strict legacy rejection" do
|
||||||
|
session = legacy_session(
|
||||||
|
tools: [@greeting_tool],
|
||||||
|
transport: AnsweringTransport.new,
|
||||||
|
input_required_legacy_shim: false,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = session.handle(tool_call_request)
|
||||||
|
|
||||||
|
assert_equal(-32603, response.dig(:error, :code))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a session-less legacy request is still rejected" do
|
||||||
|
server = Server.new(name: "shim_test", tools: [@greeting_tool])
|
||||||
|
|
||||||
|
response = server.handle(tool_call_request)
|
||||||
|
|
||||||
|
assert_equal(-32603, response.dig(:error, :code))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "modern requests keep the input_required result untouched" do
|
||||||
|
session = legacy_session(tools: [@greeting_tool], transport: transport = AnsweringTransport.new)
|
||||||
|
|
||||||
|
response = session.handle({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 1,
|
||||||
|
method: Methods::TOOLS_CALL,
|
||||||
|
params: {
|
||||||
|
name: "greeter",
|
||||||
|
arguments: {},
|
||||||
|
_meta: {
|
||||||
|
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
|
||||||
|
"io.modelcontextprotocol/clientInfo": { name: "modern", version: "1" },
|
||||||
|
"io.modelcontextprotocol/clientCapabilities": { elicitation: { form: {} } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert_equal "input_required", response.dig(:result, :resultType)
|
||||||
|
assert_empty transport.sent
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def legacy_session(tools:, transport:, capabilities: { elicitation: { form: {} } },
|
||||||
|
input_required_legacy_shim: true, request_state_security: nil)
|
||||||
|
server = Server.new(
|
||||||
|
name: "shim_test",
|
||||||
|
tools: tools,
|
||||||
|
input_required_legacy_shim: input_required_legacy_shim,
|
||||||
|
request_state_security: request_state_security,
|
||||||
|
)
|
||||||
|
session = ServerSession.new(server: server, transport: transport, session_id: "legacy")
|
||||||
|
session.store_client_info(client: { name: "legacy" }, capabilities: capabilities)
|
||||||
|
session
|
||||||
|
end
|
||||||
|
|
||||||
|
def tool_call_request(name: "greeter")
|
||||||
|
{ jsonrpc: "2.0", id: 1, method: Methods::TOOLS_CALL, params: { name: name, arguments: {} } }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Reference in a new issue