Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
76 changes: 70 additions & 6 deletions lib/mcp/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -1062,13 +1119,16 @@ 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,
notification_target: session,
related_request_id: related_request_id,
cancellation: cancellation,
envelope: envelope,
input_responses: retry_fields[:input_responses],
request_state: retry_fields[:request_state],
)
end

Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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)
Expand Down
163 changes: 163 additions & 0 deletions lib/mcp/server/input_required_result.rb
Original file line number Diff line number Diff line change
@@ -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
20 changes: 19 additions & 1 deletion lib/mcp/server_context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
Loading