From d2e568b97f2212290c8c0b912bc9515ac3d8d661 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Thu, 2 Jul 2026 14:35:59 +0900 Subject: [PATCH] Let Handlers Return Multi Round-Trip `input_required` Results per SEP-2322 ## Motivation and Context SEP-2322 (modelcontextprotocol/modelcontextprotocol#2322, merged for the 2026-07-28 MCP spec release) replaces server-initiated JSON-RPC requests, which the stateless modern lifecycle (SEP-2575) forbids, with multi round-trip results: a `tools/call`, `prompts/get`, or `resources/read` handler returns `resultType: "input_required"` with `inputRequests` (server-assigned keys mapping to `elicitation/create`, `sampling/createMessage`, or `roots/list` request shapes) and/or an opaque `requestState`, and the client retries the ORIGINAL request with `inputResponses` under the same keys plus the echoed state. The client-side recognition landed earlier (`MCP::ResultType`, `Client::InputRequiredError`); this adds the server side, mirroring the Python SDK's low-level Server (python-sdk#2967/#2986) and the TypeScript SDK's umbrella 2026-07-28 work. - New `MCP::Server::InputRequiredResult` value object: construction validation (at least one of the two fields, embedded methods restricted to the three shapes), `to_h` wire serialization, and the capability mapping shared with the TypeScript SDK's `requiredClientCapabilitiesForInputRequest` (url-mode elicitation requires `elicitation.url`, form otherwise; sampling with `tools`/`toolChoice` requires `sampling.tools`; `roots/list` requires `roots`), including the 2025 back-compat rule that a bare `elicitation: {}` declaration implies form support. - Handlers return the object through the existing paths: both branches of `call_tool_with_args` and `call_prompt_template_with_args` pass it through instead of calling `.to_h`, `call_tool` skips output schema validation and the structured-content fallback for it (output schema validation would otherwise run against a `nil` `structuredContent`), and the `resources/read` dispatch skips the `contents` wrapping and SEP-2549 cache-hint stamping. - One central hook in the dispatch lambda gates and serializes the result, after the cancellation check so cancelled requests stay suppressed: a legacy request (no SEP-2575 envelope) gets an internal error, because pre-2026 clients treat an unknown `resultType` as a final result; embedded requests exceeding the request's declared client capabilities get `-32021` with the full merged `requiredCapabilities` set. - `MCP::ServerContext` gains `input_responses`, `request_state`, and the key-tolerant `input_response(key)` reader. The retry fields are params-top-level siblings of `name`/`arguments`/`uri` (not `_meta`), so only handlers that opt in to `server_context:` can participate, matching the envelope readers' access model. The server holds no memory between rounds: handlers re-run from the start on every retry (deterministic replay, as in the Python SDK). Sealing of the echoed `requestState` (it arrives as client-controlled input) and the client-side auto-fulfillment loop follow in the next changes. Refs #382. ## How Has This Been Tested? New `test/mcp/server/input_required_result_test.rb` covers construction validation, key normalization and freezing, the wire shape of `to_h`, the full capability mapping, missing-capability subtraction with symbol/string declarations, and the implied-form back-compat rule. New tests in `test/mcp/server_test.rb` drive `Server#handle` with the modern envelope: issuance wire shape for all three methods, the retry leg exposing `input_responses`/`request_state`/ `input_response(key)` to the handler, the legacy-request internal error, `-32021` with the merged `requiredCapabilities` data, output-schema validation bypass under `validate_tool_call_results: true`, and `resources/read` results staying unwrapped without `ttlMs`/`cacheScope` stamping even when the server configures cache hints. ## Breaking Changes None. The serialization seams only branch on a return type that previously could not occur, all new keyword arguments default to `nil`, and requests that carry no `inputResponses`/`requestState` behave exactly as before. --- README.md | 8 + lib/mcp/server.rb | 76 +++++++- lib/mcp/server/input_required_result.rb | 163 ++++++++++++++++++ lib/mcp/server_context.rb | 20 ++- test/mcp/server/input_required_result_test.rb | 109 ++++++++++++ test/mcp/server_test.rb | 130 ++++++++++++++ 6 files changed, 499 insertions(+), 7 deletions(-) create mode 100644 lib/mcp/server/input_required_result.rb create mode 100644 test/mcp/server/input_required_result_test.rb diff --git a/README.md b/README.md index 000e6e26..9f905216 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,14 @@ It implements the Model Context Protocol specification, handling model context r - `initialize` - Initializes the protocol and returns server capabilities - `server/discover` - Sessionless capability discovery (MCP 2026-07-28 draft, SEP-2575): returns `supportedVersions`, `capabilities`, `serverInfo`, and `instructions`, and responds before `initialize` and without an `Mcp-Session-Id` +- Multi round-trip `input_required` results (MCP 2026-07-28, SEP-2322): a `tools/call`, `prompts/get`, or `resources/read` handler that + opts in to `server_context:` may return `MCP::Server::InputRequiredResult.new(input_requests:, request_state:)` to ask the client for + additional input (`elicitation/create`, `sampling/createMessage`, or `roots/list` shapes) instead of performing a server-initiated request, + which the modern lifecycle forbids. On the retried request the handler re-runs from the start and reads the answers via + `server_context.input_responses` / `server_context.input_response(key)` and the echoed opaque `server_context.request_state` + (deterministic replay; the server holds no memory between rounds). The SDK rejects issuance on legacy requests and returns `-32021` + when an embedded request needs a client capability the request did not declare. Note that the echoed `requestState` arrives as + client-controlled input; treat it accordingly - `ping` - Simple health check - `logging/setLevel` - Configures the minimum log level for the server - `tools/list` - Lists all registered tools and their schemas diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index ef8cfde8..bce738b9 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -10,6 +10,7 @@ require_relative "protocol_deprecations" require_relative "server_context" require_relative "server/capabilities" +require_relative "server/input_required_result" require_relative "server/pagination" require_relative "server/transports" @@ -580,7 +581,10 @@ def handle_request(request, method, session: nil, related_request_id: nil) when Methods::INITIALIZE init(params, session: session) when Methods::RESOURCES_READ - build_read_resource_result(read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)) + contents = read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope) + + # An SEP-2322 `input_required` result must not be wrapped as `contents` or stamped with SEP-2549 cache hints. + contents.is_a?(InputRequiredResult) ? contents : build_read_resource_result(contents) when Methods::RESOURCES_SUBSCRIBE, Methods::RESOURCES_UNSUBSCRIBE validate_resource_subscription_params!(params) dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope) @@ -604,6 +608,12 @@ def handle_request(request, method, session: nil, related_request_id: nil) next JsonRpcHandler::NO_RESPONSE end + # 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) + end + result rescue CancelledError => e add_instrumentation_data(cancelled: true, cancellation_reason: e.reason) @@ -662,6 +672,40 @@ def lift_request_envelope(params, method:, session:) end end + # Central gate and serializer for SEP-2322 `input_required` results, run once in the dispatch lambda for + # whichever handler produced one. The result type exists only in the 2026-07-28 stateless lifecycle, + # so a legacy request (no envelope) must not receive it: pre-2026 clients treat an unknown `resultType` as + # a final result. The capability gate enforces the SEP-2575 rule that servers MUST NOT rely on + # (or embed requests for) capabilities the client did not declare, and reports every missing capability at + # once so the client sees the full set. + def serialize_input_required_result(result, envelope:, request:) + if envelope.nil? + raise RequestHandlerError.new( + "input_required results require the 2026-07-28 stateless lifecycle (SEP-2322)", + request, + error_type: :internal_error, + ) + end + + missing = result.missing_client_capabilities(envelope.client_capabilities) + raise MissingRequiredClientCapabilityError.new(missing, request) unless missing.empty? + + add_instrumentation_data(input_required: true) + result.to_h + end + + # Extracts the SEP-2322 retry fields a client sends when re-issuing a request: + # `inputResponses` (answers keyed like the earlier `inputRequests`) and the echoed opaque `requestState`. + # They are params-top-level siblings of `name`/`arguments`/ `uri`, not `_meta` entries. + def mrtr_retry_fields(params) + return { input_responses: nil, request_state: nil } unless params.is_a?(Hash) + + { + input_responses: params[:inputResponses] || params["inputResponses"], + request_state: params[:requestState] || params["requestState"], + } + end + def handle_cancelled_notification(params, session: nil) return unless session return unless params.is_a?(Hash) @@ -851,8 +895,21 @@ def call_tool(request, session: nil, related_request_id: nil, cancellation: nil, progress_token = request.dig(:_meta, :progressToken) response = call_tool_with_args( - tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope + tool, + arguments, + server_context_with_meta(request), + progress_token: progress_token, + session: session, + related_request_id: related_request_id, + cancellation: cancellation, + envelope: envelope, + retry_fields: mrtr_retry_fields(request), ) + # An SEP-2322 `input_required` result is not a tool result: output schema + # validation would run against a `nil` `structuredContent` and the structured + # content fallback does not apply. The dispatch lambda serializes it. + return response if response.is_a?(InputRequiredResult) + result = response.to_h validate_tool_call_result!(tool, result) serialize_structured_content_fallback( @@ -1062,6 +1119,7 @@ def build_server_context(request:, session:, related_request_id:, cancellation:, meta_source = request.is_a?(Hash) ? request : {} progress_token = meta_source.dig(:_meta, :progressToken) progress = Progress.new(notification_target: session, progress_token: progress_token, related_request_id: related_request_id) + retry_fields = mrtr_retry_fields(meta_source) ServerContext.new( server_context_with_meta(meta_source), progress: progress, @@ -1069,6 +1127,8 @@ def build_server_context(request:, session:, related_request_id:, cancellation:, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, + input_responses: retry_fields[:input_responses], + request_state: retry_fields[:request_state], ) end @@ -1128,7 +1188,7 @@ def accepts_server_context?(method_object) end end - def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil, cancellation: nil, envelope: nil) + def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil, cancellation: nil, envelope: nil, retry_fields: nil) # Transports parse incoming JSON with `symbolize_names: true`, so `arguments` already arrives symbolized # at every nesting level. This top-level transform only guards callers that hand in string-keyed top-level arguments; # it does not recurse, and nested object keys remain symbols. Tools therefore receive symbol keys all the way down. @@ -1144,6 +1204,8 @@ def call_tool_with_args(tool, arguments, context, progress_token: nil, session: related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, + input_responses: retry_fields&.fetch(:input_responses, nil), + request_state: retry_fields&.fetch(:request_state, nil), ) tool.call(**args, server_context: server_context) else @@ -1152,11 +1214,13 @@ def call_tool_with_args(tool, arguments, context, progress_token: nil, session: end def call_prompt_template_with_args(prompt, args, server_context) - if accepts_server_context?(prompt.method(:template)) - prompt.template(args, server_context: server_context).to_h + raw_result = if accepts_server_context?(prompt.method(:template)) + prompt.template(args, server_context: server_context) else - prompt.template(args).to_h + prompt.template(args) end + + raw_result.is_a?(InputRequiredResult) ? raw_result : raw_result.to_h end def server_context_with_meta(request) diff --git a/lib/mcp/server/input_required_result.rb b/lib/mcp/server/input_required_result.rb new file mode 100644 index 00000000..16242e7c --- /dev/null +++ b/lib/mcp/server/input_required_result.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +require_relative "../methods" +require_relative "../result_type" + +module MCP + class Server + # A multi round-trip `input_required` result (SEP-2322, MCP 2026-07-28). + # Handlers for `tools/call`, `prompts/get`, and `resources/read` may return one instead of + # their normal result to ask the client for additional input: `input_requests` maps server-assigned keys + # to embedded request shapes (`elicitation/create`, `sampling/createMessage`, or `roots/list`), + # and `request_state` is an opaque continuation string the client echoes back byte-exactly when it retries + # the original request with `inputResponses` under the same keys. + # + # The server holds no memory between rounds: handlers re-run from the start on every retry and read + # the answers via `server_context.input_responses` / `server_context.request_state` + # (deterministic replay, matching the Python SDK). At least one of the two fields must be present; + # a `request_state`-only result is the load-shedding form ("retry later"). + # https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322 + class InputRequiredResult + EMBEDDABLE_METHODS = [ + Methods::ELICITATION_CREATE, + Methods::SAMPLING_CREATE_MESSAGE, + Methods::ROOTS_LIST, + ].freeze + + attr_reader :input_requests, :request_state + + def initialize(input_requests: nil, request_state: nil) + if (input_requests.nil? || input_requests.empty?) && request_state.nil? + raise ArgumentError, "at least one of input_requests or request_state is required" + end + unless request_state.nil? || request_state.is_a?(String) + raise ArgumentError, "request_state must be a String" + end + + @input_requests = normalize_input_requests(input_requests) + @request_state = request_state + freeze + end + + def to_h + serialized_requests = @input_requests&.transform_values do |entry| + { method: entry[:method], params: entry[:params] }.compact + end + + { + resultType: ResultType::INPUT_REQUIRED, + inputRequests: serialized_requests, + requestState: @request_state, + }.compact + end + + # The client capabilities required to fulfill every embedded request, merged into one nested hash. + # The mapping matches the TypeScript SDK's `requiredClientCapabilitiesForInputRequest`: + # `elicitation/create` with `mode: "url"` requires `elicitation.url`, any other `elicitation/create` + # requires `elicitation.form`, `sampling/createMessage` with `tools`/`toolChoice` requires `sampling.tools` + # (plain sampling otherwise), and `roots/list` requires `roots`. + def required_client_capabilities + (@input_requests || {}).values.reduce({}) do |merged, entry| + deep_merge(merged, entry_capability(entry)) + end + end + + # The subset of {#required_client_capabilities} the request did not declare. + # Per SEP-2575, servers MUST NOT rely on capabilities the client has not declared, + # so a non-empty return means the result must not be sent (`-32021`). + def missing_client_capabilities(declared) + missing = missing_subtree(required_client_capabilities, declared) + prune_implied_form_elicitation(missing, declared) + end + + private + + def normalize_input_requests(input_requests) + return if input_requests.nil? + + raise ArgumentError, "input_requests must be a Hash" unless input_requests.is_a?(Hash) + + normalized = input_requests.each_with_object({}) do |(key, entry), result| + raise ArgumentError, "input_requests entries must be Hashes" unless entry.is_a?(Hash) + + method = entry[:method] || entry["method"] + unless EMBEDDABLE_METHODS.include?(method) + raise ArgumentError, + "input_requests entry #{key.inspect} must have a method of " \ + "#{EMBEDDABLE_METHODS.join(", ")} (got #{method.inspect})" + end + + params = entry[:params] || entry["params"] + raise ArgumentError, "input_requests entry params must be a Hash" unless params.nil? || params.is_a?(Hash) + + result[key.to_s] = { method: method, params: params }.compact.freeze + end + + normalized.freeze + end + + def entry_capability(entry) + params = entry[:params] + + case entry[:method] + when Methods::ELICITATION_CREATE + mode = params && (params[:mode] || params["mode"]) + mode == "url" ? { elicitation: { url: {} } } : { elicitation: { form: {} } } + when Methods::SAMPLING_CREATE_MESSAGE + with_tools = params && (params.key?(:tools) || params.key?("tools") || + params.key?(:toolChoice) || params.key?("toolChoice")) + with_tools ? { sampling: { tools: {} } } : { sampling: {} } + when Methods::ROOTS_LIST + { roots: {} } + end + end + + # Walks `required` and keeps only the branches absent from `declared` + # (symbol/string tolerant on the declared side). + def missing_subtree(required, declared) + required.each_with_object({}) do |(name, nested), missing| + declared_value = read_key(declared, name) + + if declared_value.nil? + missing[name] = nested + elsif nested.is_a?(Hash) && !nested.empty? + nested_missing = missing_subtree(nested, declared_value) + missing[name] = nested_missing unless nested_missing.empty? + end + end + end + + # 2025 back-compat implication shared with the TypeScript and Python SDKs: + # a bare `elicitation: {}` declaration implies form elicitation support, while + # an explicit url-only declaration does not. + def prune_implied_form_elicitation(missing, declared) + elicitation_missing = missing[:elicitation] + return missing unless elicitation_missing.is_a?(Hash) && elicitation_missing.key?(:form) + + declared_elicitation = read_key(declared, :elicitation) + return missing unless declared_elicitation.is_a?(Hash) + return missing unless read_key(declared_elicitation, :url).nil? + + pruned = elicitation_missing.reject { |key, _| key == :form } + pruned.empty? ? missing.reject { |key, _| key == :elicitation } : missing.merge(elicitation: pruned) + end + + def read_key(hash, key) + return unless hash.is_a?(Hash) + + value = hash[key.to_sym] + value.nil? ? hash[key.to_s] : value + end + + def deep_merge(left, right) + left.merge(right) do |_key, left_value, right_value| + if left_value.is_a?(Hash) && right_value.is_a?(Hash) + deep_merge(left_value, right_value) + else + right_value + end + end + end + end + end +end diff --git a/lib/mcp/server_context.rb b/lib/mcp/server_context.rb index d2abb527..0bbec5c8 100644 --- a/lib/mcp/server_context.rb +++ b/lib/mcp/server_context.rb @@ -8,13 +8,31 @@ class ServerContext # `nil` on legacy requests. attr_reader :envelope - def initialize(context, progress:, notification_target:, related_request_id: nil, cancellation: nil, envelope: nil) + # SEP-2322 multi round-trip retry fields, present when the client re-issued the request after + # an `input_required` result: `input_responses` maps the keys of the earlier `inputRequests` to + # the client's answers, and `request_state` is the opaque continuation string echoed back byte-exactly. + # Both are `nil` on a first-round request. Only handlers that opt in to `server_context:` can read them + # (the same access model as the envelope readers). + attr_reader :input_responses, :request_state + + def initialize(context, progress:, notification_target:, related_request_id: nil, cancellation: nil, envelope: nil, + input_responses: nil, request_state: nil) @context = context @progress = progress @notification_target = notification_target @related_request_id = related_request_id @cancellation = cancellation @envelope = envelope + @input_responses = input_responses + @request_state = request_state + end + + # Reads one entry of {#input_responses} by its `inputRequests` key, tolerating symbol or string keys. + def input_response(key) + return unless @input_responses.is_a?(Hash) + + value = @input_responses[key.to_sym] + value.nil? ? @input_responses[key.to_s] : value end def cancelled? diff --git a/test/mcp/server/input_required_result_test.rb b/test/mcp/server/input_required_result_test.rb new file mode 100644 index 00000000..ba8ce431 --- /dev/null +++ b/test/mcp/server/input_required_result_test.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require "test_helper" + +module MCP + class Server + class InputRequiredResultTest < ActiveSupport::TestCase + test "requires at least one of input_requests or request_state" do + assert_raises(ArgumentError) { InputRequiredResult.new } + assert_raises(ArgumentError) { InputRequiredResult.new(input_requests: {}) } + assert_raises(ArgumentError) { InputRequiredResult.new(request_state: 42) } + end + + test "validates input_requests entries" do + assert_raises(ArgumentError) { InputRequiredResult.new(input_requests: []) } + assert_raises(ArgumentError) { InputRequiredResult.new(input_requests: { region: "not-a-hash" }) } + assert_raises(ArgumentError) do + InputRequiredResult.new(input_requests: { region: { method: "tools/call" } }) + end + assert_raises(ArgumentError) do + InputRequiredResult.new(input_requests: { region: { method: "elicitation/create", params: "x" } }) + end + end + + test "normalizes keys, tolerates string entry keys, and freezes" do + result = InputRequiredResult.new(input_requests: { + region: { "method" => "elicitation/create", "params" => { message: "Which region?" } }, + }) + + assert_predicate result, :frozen? + assert_equal ["region"], result.input_requests.keys + assert_equal "elicitation/create", result.input_requests["region"][:method] + assert_equal({ message: "Which region?" }, result.input_requests["region"][:params]) + end + + test "#to_h serializes the SEP-2322 wire shape" do + result = InputRequiredResult.new( + input_requests: { region: { method: "elicitation/create", params: { message: "Which region?" } } }, + request_state: "opaque-state", + ) + + assert_equal( + { + resultType: "input_required", + inputRequests: { "region" => { method: "elicitation/create", params: { message: "Which region?" } } }, + requestState: "opaque-state", + }, + result.to_h, + ) + end + + test "#to_h omits absent fields" do + state_only = InputRequiredResult.new(request_state: "opaque-state") + + assert_equal({ resultType: "input_required", requestState: "opaque-state" }, state_only.to_h) + end + + test "#required_client_capabilities maps every embedded request kind" do + result = InputRequiredResult.new(input_requests: { + form: { method: "elicitation/create", params: { message: "?" } }, + url: { method: "elicitation/create", params: { mode: "url", url: "https://example.com" } }, + plain_sampling: { method: "sampling/createMessage", params: { messages: [] } }, + tool_sampling: { method: "sampling/createMessage", params: { messages: [], tools: [] } }, + roots: { method: "roots/list" }, + }) + + assert_equal( + { + elicitation: { form: {}, url: {} }, + sampling: { tools: {} }, + roots: {}, + }, + result.required_client_capabilities, + ) + end + + test "#missing_client_capabilities returns only undeclared branches" do + result = InputRequiredResult.new(input_requests: { + form: { method: "elicitation/create", params: { message: "?" } }, + roots: { method: "roots/list" }, + }) + + assert_equal( + { elicitation: { form: {} }, roots: {} }, + result.missing_client_capabilities({}), + ) + assert_equal( + { roots: {} }, + result.missing_client_capabilities({ elicitation: { form: {} } }), + ) + assert_empty result.missing_client_capabilities({ "elicitation" => { "form" => {} }, "roots" => {} }) + end + + test "#missing_client_capabilities treats a bare elicitation declaration as implying form" do + # 2025 back-compat rule shared with the TypeScript and Python SDKs; an explicit + # url-only declaration does not imply form support. + result = InputRequiredResult.new(input_requests: { + form: { method: "elicitation/create", params: { message: "?" } }, + }) + + assert_empty result.missing_client_capabilities({ elicitation: {} }) + assert_equal( + { elicitation: { form: {} } }, + result.missing_client_capabilities({ elicitation: { url: {} } }), + ) + end + end + end +end diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index 108e6521..c767c1ba 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -353,6 +353,136 @@ class ServerTest < ActiveSupport::TestCase ) end + test "#handle tools/call serializes an input_required result on a modern request" do + server = Server.new(name: "mrtr_test", tools: []) + server.define_tool(name: "mrtr_tool") do |server_context:| + if server_context.input_responses + Tool::Response.new([{ type: "text", text: "done" }]) + else + Server::InputRequiredResult.new( + input_requests: { region: { method: "elicitation/create", params: { message: "Which region?" } } }, + request_state: "state-1", + ) + end + end + + response = server.handle( + modern_request("tools/call", { name: "mrtr_tool" }, capabilities: { elicitation: { form: {} } }), + ) + result = response[:result] + + assert_equal "input_required", result[:resultType] + assert_equal "state-1", result[:requestState] + assert_equal( + { method: "elicitation/create", params: { message: "Which region?" } }, + result.dig(:inputRequests, "region"), + ) + end + + test "#handle tools/call retry leg exposes input responses and request state to the handler" do + server = Server.new(name: "mrtr_test", tools: []) + seen = nil + server.define_tool(name: "mrtr_tool") do |server_context:| + seen = { + input_responses: server_context.input_responses, + request_state: server_context.request_state, + region: server_context.input_response(:region), + } + Tool::Response.new([{ type: "text", text: "done" }]) + end + + request = modern_request("tools/call", { + name: "mrtr_tool", + arguments: {}, + inputResponses: { region: { action: "accept", content: { value: "us-east-1" } } }, + requestState: "state-1", + }) + response = server.handle(request) + + refute_nil response[:result] + assert_equal "state-1", seen[:request_state] + assert_equal({ action: "accept", content: { value: "us-east-1" } }, seen[:region]) + assert_equal seen[:region], seen[:input_responses][:region] + end + + test "#handle rejects an input_required result on a legacy request with an internal error" do + # The result type exists only in the 2026-07-28 lifecycle: pre-2026 clients treat + # an unknown resultType as a final result, so it must never reach them. + server = Server.new(name: "mrtr_test", tools: []) + server.define_tool(name: "mrtr_tool") do + Server::InputRequiredResult.new(request_state: "state-1") + end + + response = server.handle({ + jsonrpc: "2.0", + method: "tools/call", + id: 1, + params: { name: "mrtr_tool" }, + }) + + assert_equal JsonRpcHandler::ErrorCode::INTERNAL_ERROR, response.dig(:error, :code) + end + + test "#handle rejects an input_required result whose embedded requests exceed declared capabilities" do + server = Server.new(name: "mrtr_test", tools: []) + server.define_tool(name: "mrtr_tool") do + Server::InputRequiredResult.new(input_requests: { + region: { method: "elicitation/create", params: { message: "?" } }, + roots: { method: "roots/list" }, + }) + end + + response = server.handle(modern_request("tools/call", { name: "mrtr_tool" }, capabilities: {})) + + assert_equal ErrorCodes::MISSING_REQUIRED_CLIENT_CAPABILITY, response.dig(:error, :code) + assert_equal( + { elicitation: { form: {} }, roots: {} }, + response.dig(:error, :data, :requiredCapabilities), + ) + end + + test "#handle tools/call input_required bypasses output schema validation" do + configuration = Configuration.new(validate_tool_call_results: true) + server = Server.new(name: "mrtr_test", tools: [], configuration: configuration) + server.define_tool(name: "mrtr_tool", output_schema: { properties: { value: { type: "string" } }, required: ["value"] }) do + Server::InputRequiredResult.new(request_state: "state-1") + end + + response = server.handle(modern_request("tools/call", { name: "mrtr_tool" })) + + assert_equal "input_required", response.dig(:result, :resultType) + end + + test "#handle prompts/get serializes an input_required result on a modern request" do + server = Server.new(name: "mrtr_test") + server.define_prompt(name: "mrtr_prompt", arguments: []) do |_args, server_context:| + server_context.request_state # participates via server_context: + Server::InputRequiredResult.new(request_state: "prompt-state") + end + + response = server.handle(modern_request("prompts/get", { name: "mrtr_prompt", arguments: {} })) + + assert_equal "input_required", response.dig(:result, :resultType) + assert_equal "prompt-state", response.dig(:result, :requestState) + end + + test "#handle resources/read passes an input_required result through unwrapped and without cache hints" do + server = Server.new(name: "mrtr_test", ttl_ms: 5000, cache_scope: "public") + server.resources_read_handler do |_params, server_context:| + server_context.request_state + Server::InputRequiredResult.new(request_state: "resource-state") + end + + response = server.handle(modern_request("resources/read", { uri: "file:///pending.txt" })) + result = response[:result] + + assert_equal "input_required", result[:resultType] + assert_equal "resource-state", result[:requestState] + refute result.key?(:contents) + refute result.key?(:ttlMs) + refute result.key?(:cacheScope) + end + test "ServerSession locks the legacy era on mark_initialized! and refuses to flip eras" do session = ServerSession.new(server: @server, transport: mock)