ruby-sdk/test/mcp/server_input_required_legacy_shim_test.rb
Koichi ITO b159314baa 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.
2026-08-14 02:46:38 +09:00

337 lines
13 KiB
Ruby

# 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