Merge pull request #481 from koic/input_required_results
Let Handlers Return Multi Round-Trip `input_required` Results per SEP-2322
This commit is contained in:
commit
e5ab590c05
7 changed files with 864 additions and 7 deletions
|
|
@ -54,6 +54,14 @@ It implements the Model Context Protocol specification, handling model context r
|
||||||
and the Streamable HTTP transport serves them on a sessionless single-exchange path. On the client, `MCP::Client#connect` negotiates
|
and the Streamable HTTP transport serves them on a sessionless single-exchange path. On the client, `MCP::Client#connect` negotiates
|
||||||
the lifecycle automatically by default (probe `server/discover`, fall back to the `initialize` handshake), `connect(mode: :modern)` skips
|
the lifecycle automatically by default (probe `server/discover`, fall back to the `initialize` handshake), `connect(mode: :modern)` skips
|
||||||
the handshake entirely, `connect(mode: :legacy)` forces the classic handshake, and `MCP::Client#discover` exposes the raw discovery result
|
the handshake entirely, `connect(mode: :legacy)` forces the classic handshake, and `MCP::Client#discover` exposes the raw discovery result
|
||||||
|
- 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
|
- `ping` - Simple health check
|
||||||
- `logging/setLevel` - Configures the minimum log level for the server
|
- `logging/setLevel` - Configures the minimum log level for the server
|
||||||
- `tools/list` - Lists all registered tools and their schemas
|
- `tools/list` - Lists all registered tools and their schemas
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
require "rackup"
|
require "rackup"
|
||||||
require "json"
|
require "json"
|
||||||
|
require "securerandom"
|
||||||
require "set"
|
require "set"
|
||||||
require "uri"
|
require "uri"
|
||||||
require_relative "../lib/mcp"
|
require_relative "../lib/mcp"
|
||||||
|
|
@ -13,6 +14,43 @@ module Conformance
|
||||||
# Minimal WAV file (matches TypeScript SDK and Python SDK)
|
# Minimal WAV file (matches TypeScript SDK and Python SDK)
|
||||||
BASE64_MINIMAL_WAV = "UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA="
|
BASE64_MINIMAL_WAV = "UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA="
|
||||||
|
|
||||||
|
# SEP-2322 MRTR fixture helpers. A retried request re-runs its handler from the start,
|
||||||
|
# so every round reads the client's earlier answers back through these.
|
||||||
|
module Mrtr
|
||||||
|
module_function
|
||||||
|
|
||||||
|
# The transport parses JSON with symbolized keys, but retries may also arrive through
|
||||||
|
# `Server#handle` with string keys; tolerate both like the SDK's own readers.
|
||||||
|
def read(hash, key)
|
||||||
|
return unless hash.is_a?(Hash)
|
||||||
|
|
||||||
|
value = hash[key.to_sym]
|
||||||
|
value.nil? ? hash[key.to_s] : value
|
||||||
|
end
|
||||||
|
|
||||||
|
# The `content` of an accepted `elicitation/create` response, or `nil` for a declined,
|
||||||
|
# malformed, or missing one (a `nil` return re-requests the input).
|
||||||
|
def accepted_content(response)
|
||||||
|
content = read(response, :content)
|
||||||
|
content if read(response, :action) == "accept" && content.is_a?(Hash)
|
||||||
|
end
|
||||||
|
|
||||||
|
# The `text` of a `sampling/createMessage` response, or `nil` when malformed.
|
||||||
|
def sampled_text(response)
|
||||||
|
text = read(read(response, :content), :text)
|
||||||
|
text if text.is_a?(String)
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_state(request_state)
|
||||||
|
return unless request_state.is_a?(String)
|
||||||
|
|
||||||
|
parsed = JSON.parse(request_state)
|
||||||
|
parsed.is_a?(Hash) ? parsed : nil
|
||||||
|
rescue JSON::ParserError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
module Tools
|
module Tools
|
||||||
class TestSimpleText < MCP::Tool
|
class TestSimpleText < MCP::Tool
|
||||||
tool_name "test_simple_text"
|
tool_name "test_simple_text"
|
||||||
|
|
@ -292,6 +330,285 @@ module Conformance
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
class TestMissingCapability < MCP::Tool
|
||||||
|
tool_name "test_missing_capability"
|
||||||
|
description "A tool that requires the sampling client capability (SEP-2575)"
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def call(server_context:, **_args)
|
||||||
|
server_context.require_client_capability!(:sampling)
|
||||||
|
|
||||||
|
MCP::Tool::Response.new([MCP::Content::Text.new("Sampling capability is declared").to_h])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class TestInputRequiredResultElicitation < MCP::Tool
|
||||||
|
tool_name "test_input_required_result_elicitation"
|
||||||
|
description "A tool that asks for the user's name through an input_required result (SEP-2322)"
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def call(server_context:, **_args)
|
||||||
|
content = Mrtr.accepted_content(server_context.input_response("user_name"))
|
||||||
|
name = content && Mrtr.read(content, :name)
|
||||||
|
|
||||||
|
unless name.is_a?(String)
|
||||||
|
return MCP::Server::InputRequiredResult.new(
|
||||||
|
input_requests: {
|
||||||
|
user_name: {
|
||||||
|
method: "elicitation/create",
|
||||||
|
params: {
|
||||||
|
message: "What is your name?",
|
||||||
|
requestedSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: { name: { type: "string" } },
|
||||||
|
required: ["name"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
MCP::Tool::Response.new([MCP::Content::Text.new("Hello, #{name}!").to_h])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class TestInputRequiredResultSampling < MCP::Tool
|
||||||
|
tool_name "test_input_required_result_sampling"
|
||||||
|
description "A tool that asks for an LLM completion through an input_required result (SEP-2322)"
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def call(server_context:, **_args)
|
||||||
|
text = Mrtr.sampled_text(server_context.input_response("sample_request"))
|
||||||
|
|
||||||
|
unless text
|
||||||
|
return MCP::Server::InputRequiredResult.new(
|
||||||
|
input_requests: {
|
||||||
|
sample_request: {
|
||||||
|
method: "sampling/createMessage",
|
||||||
|
params: {
|
||||||
|
messages: [{ role: "user", content: { type: "text", text: "What is the capital of France?" } }],
|
||||||
|
maxTokens: 100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
MCP::Tool::Response.new([MCP::Content::Text.new("Sampled: #{text}").to_h])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class TestInputRequiredResultListRoots < MCP::Tool
|
||||||
|
tool_name "test_input_required_result_list_roots"
|
||||||
|
description "A tool that asks for the client's roots through an input_required result (SEP-2322)"
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def call(server_context:, **_args)
|
||||||
|
roots = Mrtr.read(server_context.input_response("roots_request"), :roots)
|
||||||
|
|
||||||
|
unless roots.is_a?(Array)
|
||||||
|
return MCP::Server::InputRequiredResult.new(
|
||||||
|
input_requests: { roots_request: { method: "roots/list" } },
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
uris = roots.filter_map { |root| Mrtr.read(root, :uri) }
|
||||||
|
MCP::Tool::Response.new([MCP::Content::Text.new("Roots: #{uris.join(", ")}").to_h])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class TestInputRequiredResultRequestState < MCP::Tool
|
||||||
|
tool_name "test_input_required_result_request_state"
|
||||||
|
description "A tool whose input_required result carries an opaque requestState (SEP-2322)"
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def call(server_context:, **_args)
|
||||||
|
state = Mrtr.parse_state(server_context.request_state)
|
||||||
|
confirmed = Mrtr.accepted_content(server_context.input_response("confirm"))
|
||||||
|
|
||||||
|
if state && state["kind"] == "request-state" && confirmed
|
||||||
|
return MCP::Tool::Response.new([MCP::Content::Text.new("state-ok: requestState validated").to_h])
|
||||||
|
end
|
||||||
|
|
||||||
|
MCP::Server::InputRequiredResult.new(
|
||||||
|
input_requests: {
|
||||||
|
confirm: {
|
||||||
|
method: "elicitation/create",
|
||||||
|
params: {
|
||||||
|
message: "Confirm to continue",
|
||||||
|
requestedSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: { ok: { type: "boolean" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
request_state: JSON.generate({ kind: "request-state", nonce: SecureRandom.hex(8) }),
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class TestInputRequiredResultMultipleInputs < MCP::Tool
|
||||||
|
tool_name "test_input_required_result_multiple_inputs"
|
||||||
|
description "A tool that embeds elicitation, sampling, and roots/list inputs in one input_required result (SEP-2322)"
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def call(server_context:, **_args)
|
||||||
|
content = Mrtr.accepted_content(server_context.input_response("user_name"))
|
||||||
|
name = content && Mrtr.read(content, :name)
|
||||||
|
greeting = Mrtr.sampled_text(server_context.input_response("greeting"))
|
||||||
|
roots = Mrtr.read(server_context.input_response("client_roots"), :roots)
|
||||||
|
|
||||||
|
if name.is_a?(String) && greeting && roots.is_a?(Array)
|
||||||
|
return MCP::Tool::Response.new(
|
||||||
|
[MCP::Content::Text.new("#{greeting} #{name} (#{roots.length} roots)").to_h],
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
MCP::Server::InputRequiredResult.new(
|
||||||
|
input_requests: {
|
||||||
|
user_name: {
|
||||||
|
method: "elicitation/create",
|
||||||
|
params: {
|
||||||
|
message: "What is your name?",
|
||||||
|
requestedSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: { name: { type: "string" } },
|
||||||
|
required: ["name"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
greeting: {
|
||||||
|
method: "sampling/createMessage",
|
||||||
|
params: {
|
||||||
|
messages: [{ role: "user", content: { type: "text", text: "Write a one-word greeting" } }],
|
||||||
|
maxTokens: 50,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
client_roots: { method: "roots/list" },
|
||||||
|
},
|
||||||
|
request_state: JSON.generate({ kind: "multiple-inputs" }),
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class TestInputRequiredResultMultiRound < MCP::Tool
|
||||||
|
tool_name "test_input_required_result_multi_round"
|
||||||
|
description "A tool that needs two elicitation rounds before completing (SEP-2322)"
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def call(server_context:, **_args)
|
||||||
|
state = Mrtr.parse_state(server_context.request_state)
|
||||||
|
|
||||||
|
case state && state["round"]
|
||||||
|
when 1
|
||||||
|
content = Mrtr.accepted_content(server_context.input_response("user_name"))
|
||||||
|
name = content && Mrtr.read(content, :name)
|
||||||
|
return second_round(name) if name.is_a?(String)
|
||||||
|
when 2
|
||||||
|
content = Mrtr.accepted_content(server_context.input_response("favorite_color"))
|
||||||
|
color = content && Mrtr.read(content, :color)
|
||||||
|
if color.is_a?(String)
|
||||||
|
return MCP::Tool::Response.new([MCP::Content::Text.new("#{state["name"]} likes #{color}").to_h])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
first_round
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def first_round
|
||||||
|
MCP::Server::InputRequiredResult.new(
|
||||||
|
input_requests: {
|
||||||
|
user_name: {
|
||||||
|
method: "elicitation/create",
|
||||||
|
params: {
|
||||||
|
message: "What is your name?",
|
||||||
|
requestedSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: { name: { type: "string" } },
|
||||||
|
required: ["name"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
request_state: JSON.generate({ round: 1 }),
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def second_round(name)
|
||||||
|
MCP::Server::InputRequiredResult.new(
|
||||||
|
input_requests: {
|
||||||
|
favorite_color: {
|
||||||
|
method: "elicitation/create",
|
||||||
|
params: {
|
||||||
|
message: "What is your favorite color?",
|
||||||
|
requestedSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: { color: { type: "string" } },
|
||||||
|
required: ["color"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
request_state: JSON.generate({ round: 2, name: name }),
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class TestInputRequiredResultCapabilities < MCP::Tool
|
||||||
|
tool_name "test_input_required_result_capabilities"
|
||||||
|
description "A tool that only embeds input requests the client's declared capabilities can fulfill (SEP-2322)"
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def call(server_context:, **_args)
|
||||||
|
capabilities = server_context.client_capabilities || {}
|
||||||
|
elicited = Mrtr.accepted_content(server_context.input_response("elicit_input"))
|
||||||
|
sampled = Mrtr.sampled_text(server_context.input_response("sample_input"))
|
||||||
|
|
||||||
|
requests = {}
|
||||||
|
if Mrtr.read(capabilities, :elicitation) && !elicited
|
||||||
|
requests[:elicit_input] = {
|
||||||
|
method: "elicitation/create",
|
||||||
|
params: {
|
||||||
|
message: "Provide a value",
|
||||||
|
requestedSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: { value: { type: "string" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
end
|
||||||
|
if Mrtr.read(capabilities, :sampling) && !sampled
|
||||||
|
requests[:sample_input] = {
|
||||||
|
method: "sampling/createMessage",
|
||||||
|
params: {
|
||||||
|
messages: [{ role: "user", content: { type: "text", text: "Say hello" } }],
|
||||||
|
maxTokens: 50,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
if requests.empty?
|
||||||
|
MCP::Tool::Response.new(
|
||||||
|
[MCP::Content::Text.new("Inputs: elicit=#{!elicited.nil?}, sample=#{!sampled.nil?}").to_h],
|
||||||
|
)
|
||||||
|
else
|
||||||
|
MCP::Server::InputRequiredResult.new(input_requests: requests)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
module Prompts
|
module Prompts
|
||||||
|
|
@ -390,6 +707,45 @@ module Conformance
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
class TestInputRequiredResultPrompt < MCP::Prompt
|
||||||
|
prompt_name "test_input_required_result_prompt"
|
||||||
|
description "A prompt that asks for user context through an input_required result (SEP-2322)"
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def template(_args, server_context: nil)
|
||||||
|
content = server_context && Mrtr.accepted_content(server_context.input_response("user_context"))
|
||||||
|
context = content && Mrtr.read(content, :context)
|
||||||
|
|
||||||
|
unless context.is_a?(String)
|
||||||
|
return MCP::Server::InputRequiredResult.new(
|
||||||
|
input_requests: {
|
||||||
|
user_context: {
|
||||||
|
method: "elicitation/create",
|
||||||
|
params: {
|
||||||
|
message: "Provide context for the prompt",
|
||||||
|
requestedSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: { context: { type: "string" } },
|
||||||
|
required: ["context"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
MCP::Prompt::Result.new(
|
||||||
|
messages: [
|
||||||
|
MCP::Prompt::Message.new(
|
||||||
|
role: "user",
|
||||||
|
content: MCP::Content::Text.new("Context: #{context}"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
class Server
|
class Server
|
||||||
|
|
@ -482,12 +838,21 @@ module Conformance
|
||||||
Tools::TestElicitationSep1034Defaults,
|
Tools::TestElicitationSep1034Defaults,
|
||||||
Tools::TestElicitationSep1330Enums,
|
Tools::TestElicitationSep1330Enums,
|
||||||
Tools::TestReconnection,
|
Tools::TestReconnection,
|
||||||
|
Tools::TestMissingCapability,
|
||||||
|
Tools::TestInputRequiredResultElicitation,
|
||||||
|
Tools::TestInputRequiredResultSampling,
|
||||||
|
Tools::TestInputRequiredResultListRoots,
|
||||||
|
Tools::TestInputRequiredResultRequestState,
|
||||||
|
Tools::TestInputRequiredResultMultipleInputs,
|
||||||
|
Tools::TestInputRequiredResultMultiRound,
|
||||||
|
Tools::TestInputRequiredResultCapabilities,
|
||||||
],
|
],
|
||||||
prompts: [
|
prompts: [
|
||||||
Prompts::TestSimplePrompt,
|
Prompts::TestSimplePrompt,
|
||||||
Prompts::TestPromptWithArguments,
|
Prompts::TestPromptWithArguments,
|
||||||
Prompts::TestPromptWithEmbeddedResource,
|
Prompts::TestPromptWithEmbeddedResource,
|
||||||
Prompts::TestPromptWithImage,
|
Prompts::TestPromptWithImage,
|
||||||
|
Prompts::TestInputRequiredResultPrompt,
|
||||||
],
|
],
|
||||||
resources: resources,
|
resources: resources,
|
||||||
resource_templates: resource_templates,
|
resource_templates: resource_templates,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ require_relative "progress"
|
||||||
require_relative "protocol_deprecations"
|
require_relative "protocol_deprecations"
|
||||||
require_relative "server_context"
|
require_relative "server_context"
|
||||||
require_relative "server/capabilities"
|
require_relative "server/capabilities"
|
||||||
|
require_relative "server/input_required_result"
|
||||||
require_relative "server/pagination"
|
require_relative "server/pagination"
|
||||||
require_relative "server/transports"
|
require_relative "server/transports"
|
||||||
|
|
||||||
|
|
@ -587,7 +588,10 @@ module MCP
|
||||||
when Methods::INITIALIZE
|
when Methods::INITIALIZE
|
||||||
init(params, session: session)
|
init(params, session: session)
|
||||||
when Methods::RESOURCES_READ
|
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
|
when Methods::RESOURCES_SUBSCRIBE, Methods::RESOURCES_UNSUBSCRIBE
|
||||||
validate_resource_subscription_params!(params)
|
validate_resource_subscription_params!(params)
|
||||||
dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
|
dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
|
||||||
|
|
@ -611,6 +615,12 @@ module MCP
|
||||||
next JsonRpcHandler::NO_RESPONSE
|
next JsonRpcHandler::NO_RESPONSE
|
||||||
end
|
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
|
result
|
||||||
rescue CancelledError => e
|
rescue CancelledError => e
|
||||||
add_instrumentation_data(cancelled: true, cancellation_reason: e.reason)
|
add_instrumentation_data(cancelled: true, cancellation_reason: e.reason)
|
||||||
|
|
@ -669,6 +679,40 @@ module MCP
|
||||||
end
|
end
|
||||||
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)
|
def handle_cancelled_notification(params, session: nil)
|
||||||
return unless session
|
return unless session
|
||||||
return unless params.is_a?(Hash)
|
return unless params.is_a?(Hash)
|
||||||
|
|
@ -864,8 +908,21 @@ module MCP
|
||||||
progress_token = request.dig(:_meta, :progressToken)
|
progress_token = request.dig(:_meta, :progressToken)
|
||||||
|
|
||||||
response = call_tool_with_args(
|
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
|
result = response.to_h
|
||||||
validate_tool_call_result!(tool, result)
|
validate_tool_call_result!(tool, result)
|
||||||
serialize_structured_content_fallback(
|
serialize_structured_content_fallback(
|
||||||
|
|
@ -1078,6 +1135,7 @@ module MCP
|
||||||
meta_source = request.is_a?(Hash) ? request : {}
|
meta_source = request.is_a?(Hash) ? request : {}
|
||||||
progress_token = meta_source.dig(:_meta, :progressToken)
|
progress_token = meta_source.dig(:_meta, :progressToken)
|
||||||
progress = Progress.new(notification_target: session, progress_token: progress_token, related_request_id: related_request_id)
|
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(
|
ServerContext.new(
|
||||||
server_context_with_meta(meta_source),
|
server_context_with_meta(meta_source),
|
||||||
progress: progress,
|
progress: progress,
|
||||||
|
|
@ -1085,6 +1143,8 @@ module MCP
|
||||||
related_request_id: related_request_id,
|
related_request_id: related_request_id,
|
||||||
cancellation: cancellation,
|
cancellation: cancellation,
|
||||||
envelope: envelope,
|
envelope: envelope,
|
||||||
|
input_responses: retry_fields[:input_responses],
|
||||||
|
request_state: retry_fields[:request_state],
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -1144,7 +1204,7 @@ module MCP
|
||||||
end
|
end
|
||||||
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
|
# 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;
|
# 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.
|
# it does not recurse, and nested object keys remain symbols. Tools therefore receive symbol keys all the way down.
|
||||||
|
|
@ -1160,6 +1220,8 @@ module MCP
|
||||||
related_request_id: related_request_id,
|
related_request_id: related_request_id,
|
||||||
cancellation: cancellation,
|
cancellation: cancellation,
|
||||||
envelope: envelope,
|
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)
|
tool.call(**args, server_context: server_context)
|
||||||
else
|
else
|
||||||
|
|
@ -1168,11 +1230,13 @@ module MCP
|
||||||
end
|
end
|
||||||
|
|
||||||
def call_prompt_template_with_args(prompt, args, server_context)
|
def call_prompt_template_with_args(prompt, args, server_context)
|
||||||
if accepts_server_context?(prompt.method(:template))
|
raw_result = if accepts_server_context?(prompt.method(:template))
|
||||||
prompt.template(args, server_context: server_context).to_h
|
prompt.template(args, server_context: server_context)
|
||||||
else
|
else
|
||||||
prompt.template(args).to_h
|
prompt.template(args)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
raw_result.is_a?(InputRequiredResult) ? raw_result : raw_result.to_h
|
||||||
end
|
end
|
||||||
|
|
||||||
def server_context_with_meta(request)
|
def server_context_with_meta(request)
|
||||||
|
|
|
||||||
163
lib/mcp/server/input_required_result.rb
Normal file
163
lib/mcp/server/input_required_result.rb
Normal file
|
|
@ -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
|
||||||
|
|
@ -8,13 +8,31 @@ module MCP
|
||||||
# `nil` on legacy requests.
|
# `nil` on legacy requests.
|
||||||
attr_reader :envelope
|
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
|
@context = context
|
||||||
@progress = progress
|
@progress = progress
|
||||||
@notification_target = notification_target
|
@notification_target = notification_target
|
||||||
@related_request_id = related_request_id
|
@related_request_id = related_request_id
|
||||||
@cancellation = cancellation
|
@cancellation = cancellation
|
||||||
@envelope = envelope
|
@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
|
end
|
||||||
|
|
||||||
def cancelled?
|
def cancelled?
|
||||||
|
|
|
||||||
109
test/mcp/server/input_required_result_test.rb
Normal file
109
test/mcp/server/input_required_result_test.rb
Normal file
|
|
@ -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
|
||||||
|
|
@ -376,6 +376,136 @@ module MCP
|
||||||
)
|
)
|
||||||
end
|
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
|
test "ServerSession locks the legacy era on mark_initialized! and refuses to flip eras" do
|
||||||
session = ServerSession.new(server: @server, transport: mock)
|
session = ServerSession.new(server: @server, transport: mock)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue