diff --git a/README.md b/README.md index 0d7a5278..efb072c4 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index a95c651d..7cf306e3 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -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 @@ -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 @@ -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; @@ -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. diff --git a/lib/mcp/server_session.rb b/lib/mcp/server_session.rb index cf45797b..907fe3be 100644 --- a/lib/mcp/server_session.rb +++ b/lib/mcp/server_session.rb @@ -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 diff --git a/test/mcp/server_input_required_legacy_shim_test.rb b/test/mcp/server_input_required_legacy_shim_test.rb new file mode 100644 index 00000000..dbc5aeaf --- /dev/null +++ b/test/mcp/server_input_required_legacy_shim_test.rb @@ -0,0 +1,266 @@ +# 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 + + 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 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