## Motivation and Context Two deviations from the finalized 2026-07-28 specification in the SEP-2575 envelope validation, both introduced against the frozen SEP text and overtaken by post-final spec changes: 1. `clientInfo` was treated as required. Spec PR modelcontextprotocol/modelcontextprotocol#3002 made it optional (clients SHOULD include it unless configured not to), and the TypeScript and Python SDKs validate only the `protocolVersion` + `clientCapabilities` pair. A conformant client configured not to identify itself was rejected. 2. A missing or mistyped envelope answered `-32600` Invalid Request. The spec maps missing required envelope fields to `-32602` Invalid params, and both reference SDKs answer with `-32602` naming the offending keys. Fixing these also corrects the era classification to match the reference SDKs: a request claims the modern lifecycle when `_meta` carries `io.modelcontextprotocol/protocolVersion` (the TypeScript envelope claim and the Python `_has_modern_envelope` are both single-key checks), and a claimed-but-incomplete envelope is now validated and rejected with `-32602` naming the missing keys instead of silently flowing through the legacy path. Legacy `_meta` usage without the claim key (`progressToken`, trace context) is classified exactly as before. Unchanged on purpose: the era-lock violations keep their codes (`-32600` for a modern envelope on a legacy-locked session, `-32022` for `initialize` on a modern-locked one), matching the Python SDK. ## How Has This Been Tested? `test/mcp/request_envelope_test.rb` now covers: classification by the claim key alone, a claimed-but-incomplete envelope staying modern, parsing without the optional `clientInfo` (reader returns `nil`), `-32602` with the offending key names for a missing `clientCapabilities` and for mistyped required and optional fields. `test/mcp/server_test.rb` covers the dispatch-level behavior: a claimed but incomplete envelope answers `-32602` naming the missing key, an envelope without `clientInfo` is served with `server_context.client_info` reading `nil`, and a claim-less request on a modern-locked session answers `-32602` naming the required keys. The stdio and Streamable HTTP transport tests assert the new code on their envelope-requirement paths. ## Breaking Changes None for conforming clients. Requests that were already rejected change error code (`-32600` to `-32602`, HTTP status 400 unchanged), and requests carrying the `protocolVersion` claim key with an incomplete envelope are now rejected as the spec mandates instead of being served as legacy requests. Envelopes without `clientInfo`, previously rejected, now succeed.
1334 lines
55 KiB
Ruby
1334 lines
55 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require_relative "../json_rpc_handler"
|
|
require_relative "cancellation"
|
|
require_relative "cancelled_error"
|
|
require_relative "instrumentation"
|
|
require_relative "methods"
|
|
require_relative "logging_message_notification"
|
|
require_relative "progress"
|
|
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"
|
|
|
|
module MCP
|
|
class ToolNotUnique < StandardError
|
|
def initialize(duplicated_tool_names)
|
|
super(<<~MESSAGE)
|
|
Tool names should be unique. Use `tool_name` to assign unique names to:
|
|
#{duplicated_tool_names.join(", ")}
|
|
MESSAGE
|
|
end
|
|
end
|
|
|
|
class Server
|
|
DEFAULT_VERSION = "0.1.0"
|
|
|
|
UNSUPPORTED_PROPERTIES_UNTIL_2025_06_18 = [:description, :icons].freeze
|
|
UNSUPPORTED_PROPERTIES_UNTIL_2025_03_26 = [:title, :websiteUrl].freeze
|
|
|
|
DEFAULT_COMPLETION_RESULT = { completion: { values: [], hasMore: false } }.freeze
|
|
|
|
# Servers return an array of completion values ranked by relevance, with maximum 100 items per response.
|
|
# https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/completion#completion-results
|
|
MAX_COMPLETION_VALUES = 100
|
|
|
|
class RequestHandlerError < StandardError
|
|
attr_reader :error_type, :original_error, :error_code, :error_data
|
|
|
|
def initialize(message, request, error_type: :internal_error, original_error: nil, error_code: nil, error_data: nil)
|
|
super(message)
|
|
@request = request
|
|
@error_type = error_type
|
|
@original_error = original_error
|
|
@error_code = error_code
|
|
@error_data = error_data
|
|
end
|
|
end
|
|
|
|
class URLElicitationRequiredError < RequestHandlerError
|
|
def initialize(elicitations)
|
|
super(
|
|
"URL elicitation required",
|
|
nil,
|
|
error_type: :url_elicitation_required,
|
|
error_code: -32042,
|
|
error_data: { elicitations: elicitations },
|
|
)
|
|
end
|
|
end
|
|
|
|
# Raised when a request carries a protocol version the server does not support under the stateless lifecycle of
|
|
# MCP 2026-07-28 (SEP-2575). Maps to JSON-RPC error `-32022` with `data: { supported: [...], requested: "..." }`
|
|
# so the client can select a mutually supported version and retry.
|
|
#
|
|
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575
|
|
class UnsupportedProtocolVersionError < RequestHandlerError
|
|
# No keyword parameters here: with one present, Ruby 2.7 would split a trailing symbol-keyed `request` Hash
|
|
# into keywords and fail with "unknown keywords".
|
|
def initialize(requested, request = nil)
|
|
super(
|
|
"Unsupported protocol version",
|
|
request,
|
|
error_type: :unsupported_protocol_version,
|
|
error_code: ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION,
|
|
error_data: { supported: Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, requested: requested || "unknown" },
|
|
)
|
|
end
|
|
end
|
|
|
|
# Raised when processing a request requires a client capability the request did not declare in `_meta`
|
|
# (`io.modelcontextprotocol/clientCapabilities`). Per SEP-2575, servers MUST NOT rely on capabilities
|
|
# the client has not declared. Maps to JSON-RPC error `-32021` with `data: { requiredCapabilities: {...} }`
|
|
# listing the missing capabilities.
|
|
#
|
|
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575
|
|
class MissingRequiredClientCapabilityError < RequestHandlerError
|
|
def initialize(required_capabilities, request = nil)
|
|
super(
|
|
"Missing required client capability",
|
|
request,
|
|
error_type: :missing_required_client_capability,
|
|
error_code: ErrorCodes::MISSING_REQUIRED_CLIENT_CAPABILITY,
|
|
error_data: { requiredCapabilities: required_capabilities },
|
|
)
|
|
end
|
|
end
|
|
|
|
# Raised when a requested resource URI does not exist. Per SEP-2164,
|
|
# resource-not-found errors use the standard JSON-RPC Invalid Params code (-32602)
|
|
# with the requested URI in the error `data` member. Raise this from
|
|
# a `resources_read_handler` block for unknown URIs:
|
|
#
|
|
# server.resources_read_handler do |params|
|
|
# raise MCP::Server::ResourceNotFoundError.new(params[:uri], params) unless known?(params[:uri])
|
|
# do_something(params[:uri])
|
|
# end
|
|
#
|
|
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2164
|
|
class ResourceNotFoundError < RequestHandlerError
|
|
def initialize(uri, request = nil)
|
|
# The explicit `error_code` keeps the descriptive message in the JSON-RPC
|
|
# error response; `error_type: :invalid_params` alone would replace it
|
|
# with the generic "Invalid params" string.
|
|
super(
|
|
"Resource not found: #{uri}",
|
|
request,
|
|
error_type: :invalid_params,
|
|
error_code: JsonRpcHandler::ErrorCode::INVALID_PARAMS,
|
|
error_data: { uri: uri },
|
|
)
|
|
end
|
|
end
|
|
|
|
class MethodAlreadyDefinedError < StandardError
|
|
attr_reader :method_name
|
|
|
|
def initialize(method_name)
|
|
super("Method #{method_name} already defined")
|
|
@method_name = method_name
|
|
end
|
|
end
|
|
|
|
# Raised when a client response fails server-side validation, e.g., a success response
|
|
# whose `result` field is missing or has the wrong type. This is distinct from a
|
|
# client-returned JSON-RPC error.
|
|
class ValidationError < StandardError; end
|
|
|
|
include Instrumentation
|
|
include Pagination
|
|
|
|
# Allowed values for the SEP-2549 `cacheScope` cache hint.
|
|
CACHE_SCOPES = ["public", "private"].freeze
|
|
|
|
attr_accessor :description, :icons, :name, :title, :version, :website_url, :instructions, :tools, :prompts, :resource_templates, :server_context, :configuration, :capabilities, :transport, :logging_message_notification
|
|
attr_reader :resources, :page_size, :client_capabilities, :ttl_ms, :cache_scope
|
|
|
|
def initialize(
|
|
description: nil,
|
|
icons: [],
|
|
name: "model_context_protocol",
|
|
title: nil,
|
|
version: DEFAULT_VERSION,
|
|
website_url: nil,
|
|
instructions: nil,
|
|
tools: [],
|
|
prompts: [],
|
|
resources: [],
|
|
resource_templates: [],
|
|
server_context: nil,
|
|
configuration: nil,
|
|
capabilities: nil,
|
|
page_size: nil,
|
|
ttl_ms: nil,
|
|
cache_scope: nil,
|
|
transport: nil
|
|
)
|
|
@description = description
|
|
@icons = icons
|
|
@name = name
|
|
@title = title
|
|
@version = version
|
|
@website_url = website_url
|
|
@instructions = instructions
|
|
@tool_names = tools.map(&:name_value)
|
|
@tools = tools.to_h { |t| [t.name_value, t] }
|
|
@prompts = prompts.to_h { |p| [p.name_value, p] }
|
|
@resources = resources
|
|
@resource_templates = resource_templates
|
|
@resource_index = index_resources_by_uri(resources)
|
|
@server_context = server_context
|
|
self.page_size = page_size
|
|
self.ttl_ms = ttl_ms
|
|
self.cache_scope = cache_scope
|
|
@configuration = MCP.configuration.merge(configuration)
|
|
@client = nil
|
|
@client_protocol_version = nil
|
|
|
|
validate!
|
|
|
|
# Accept either a plain Hash or an `MCP::Server::Capabilities` builder.
|
|
@capabilities = if capabilities.is_a?(Capabilities)
|
|
capabilities.to_h
|
|
else
|
|
capabilities || default_capabilities
|
|
end
|
|
@client_capabilities = nil
|
|
@logging_message_notification = nil
|
|
|
|
@handlers = {
|
|
Methods::RESOURCES_LIST => method(:list_resources),
|
|
Methods::RESOURCES_READ => method(:read_resource),
|
|
Methods::RESOURCES_TEMPLATES_LIST => method(:list_resource_templates),
|
|
Methods::RESOURCES_SUBSCRIBE => ->(_) { {} },
|
|
Methods::RESOURCES_UNSUBSCRIBE => ->(_) { {} },
|
|
Methods::TOOLS_LIST => method(:list_tools),
|
|
Methods::TOOLS_CALL => method(:call_tool),
|
|
Methods::PROMPTS_LIST => method(:list_prompts),
|
|
Methods::PROMPTS_GET => method(:get_prompt),
|
|
Methods::INITIALIZE => method(:init),
|
|
Methods::SERVER_DISCOVER => method(:discover),
|
|
Methods::PING => ->(_) { {} },
|
|
Methods::NOTIFICATIONS_INITIALIZED => ->(_) {},
|
|
Methods::NOTIFICATIONS_PROGRESS => ->(_) {},
|
|
Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED => ->(_) {},
|
|
Methods::COMPLETION_COMPLETE => ->(_) { DEFAULT_COMPLETION_RESULT },
|
|
Methods::LOGGING_SET_LEVEL => method(:configure_logging_level),
|
|
}
|
|
@transport = transport
|
|
end
|
|
|
|
# Processes a parsed JSON-RPC request and returns the response as a Hash.
|
|
#
|
|
# @param request [Hash] A parsed JSON-RPC request.
|
|
# @param session [ServerSession, nil] Per-connection session. Passed by
|
|
# `ServerSession#handle` for session-scoped notification delivery.
|
|
# When `nil`, progress and logging notifications from tool handlers are silently skipped.
|
|
# @return [Hash, nil] The JSON-RPC response, or `nil` for notifications.
|
|
def handle(request, session: nil)
|
|
JsonRpcHandler.handle(request) do |method, request_id|
|
|
handle_request(request, method, session: session, related_request_id: request_id)
|
|
end
|
|
end
|
|
|
|
# Processes a JSON-RPC request string and returns the response as a JSON string.
|
|
#
|
|
# @param request [String] A JSON-RPC request as a JSON string.
|
|
# @param session [ServerSession, nil] Per-connection session. Passed by
|
|
# `ServerSession#handle_json` for session-scoped notification delivery.
|
|
# When `nil`, progress and logging notifications from tool handlers are silently skipped.
|
|
# @return [String, nil] The JSON-RPC response as JSON, or `nil` for notifications.
|
|
def handle_json(request, session: nil)
|
|
JsonRpcHandler.handle_json(request) do |method, request_id|
|
|
handle_request(request, method, session: session, related_request_id: request_id)
|
|
end
|
|
end
|
|
|
|
def define_tool(name: nil, title: nil, description: nil, input_schema: nil, output_schema: nil, annotations: nil, meta: nil, &block)
|
|
tool = Tool.define(name: name, title: title, description: description, input_schema: input_schema, output_schema: output_schema, annotations: annotations, meta: meta, &block)
|
|
tool_name = tool.name_value
|
|
|
|
@tool_names << tool_name
|
|
@tools[tool_name] = tool
|
|
|
|
validate!
|
|
end
|
|
|
|
def define_prompt(name: nil, title: nil, description: nil, arguments: [], &block)
|
|
prompt = Prompt.define(name: name, title: title, description: description, arguments: arguments, &block)
|
|
@prompts[prompt.name_value] = prompt
|
|
|
|
validate!
|
|
end
|
|
|
|
def define_resource(uri: nil, name: nil, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, size: nil, meta: nil, &block)
|
|
resource = Resource.define(
|
|
uri: uri, name: name, title: title, description: description, icons: icons,
|
|
mime_type: mime_type, annotations: annotations, size: size, meta: meta,
|
|
&block
|
|
)
|
|
@resources << resource
|
|
@resource_index[resource.uri] = resource
|
|
|
|
validate!
|
|
end
|
|
|
|
def define_resource_template(uri_template: nil, name: nil, title: nil, description: nil, icons: [], mime_type: nil, annotations: nil, meta: nil, &block)
|
|
resource_template = ResourceTemplate.define(
|
|
uri_template: uri_template, name: name, title: title, description: description, icons: icons,
|
|
mime_type: mime_type, annotations: annotations, meta: meta,
|
|
&block
|
|
)
|
|
@resource_templates << resource_template
|
|
|
|
validate!
|
|
end
|
|
|
|
def resources=(resources)
|
|
@resources = resources
|
|
@resource_index = index_resources_by_uri(resources)
|
|
end
|
|
|
|
def define_custom_method(method_name:, &block)
|
|
if @handlers.key?(method_name)
|
|
raise MethodAlreadyDefinedError, method_name
|
|
end
|
|
|
|
@handlers[method_name] = block
|
|
end
|
|
|
|
def page_size=(page_size)
|
|
unless page_size.nil? || (page_size.is_a?(Integer) && page_size > 0)
|
|
raise ArgumentError, "page_size must be nil or a positive integer"
|
|
end
|
|
|
|
@page_size = page_size
|
|
end
|
|
|
|
# SEP-2549 cache hint: freshness lifetime in milliseconds for list and read results
|
|
# (max-age semantics; 0 means do not cache). Emission is opt-in: when both `ttl_ms`
|
|
# and `cache_scope` are nil, results are serialized exactly as before.
|
|
def ttl_ms=(ttl_ms)
|
|
unless ttl_ms.nil? || (ttl_ms.is_a?(Integer) && ttl_ms >= 0)
|
|
raise ArgumentError, "ttl_ms must be nil or a non-negative integer"
|
|
end
|
|
|
|
@ttl_ms = ttl_ms
|
|
end
|
|
|
|
# SEP-2549 cache hint: whether shared intermediaries may cache the result ("public")
|
|
# or only the requesting client ("private").
|
|
def cache_scope=(cache_scope)
|
|
unless cache_scope.nil? || CACHE_SCOPES.include?(cache_scope)
|
|
raise ArgumentError, "cache_scope must be nil, \"public\", or \"private\""
|
|
end
|
|
|
|
@cache_scope = cache_scope
|
|
end
|
|
|
|
def notify_tools_list_changed
|
|
return unless @transport
|
|
|
|
@transport.send_notification(Methods::NOTIFICATIONS_TOOLS_LIST_CHANGED)
|
|
rescue => e
|
|
report_exception(e, { notification: "tools_list_changed" })
|
|
end
|
|
|
|
def notify_prompts_list_changed
|
|
return unless @transport
|
|
|
|
@transport.send_notification(Methods::NOTIFICATIONS_PROMPTS_LIST_CHANGED)
|
|
rescue => e
|
|
report_exception(e, { notification: "prompts_list_changed" })
|
|
end
|
|
|
|
def notify_resources_list_changed
|
|
return unless @transport
|
|
|
|
@transport.send_notification(Methods::NOTIFICATIONS_RESOURCES_LIST_CHANGED)
|
|
rescue => e
|
|
report_exception(e, { notification: "resources_list_changed" })
|
|
end
|
|
|
|
# @deprecated MCP Logging (`logging/setLevel` and `notifications/message`)
|
|
# is deprecated as of MCP protocol version 2026-07-28 (SEP-2577).
|
|
# Use stderr or OpenTelemetry instead.
|
|
def notify_log_message(data:, level:, logger: nil)
|
|
warn_if_deprecated_protocol_feature(:logging, uplevel: 1)
|
|
|
|
return unless @transport
|
|
return unless logging_message_notification&.should_notify?(level)
|
|
|
|
params = { "data" => data, "level" => level }
|
|
params["logger"] = logger if logger
|
|
|
|
@transport.send_notification(Methods::NOTIFICATIONS_MESSAGE, params)
|
|
rescue => e
|
|
report_exception(e, { notification: "log_message" })
|
|
end
|
|
|
|
# Sets a handler for `notifications/roots/list_changed` notifications.
|
|
# Called when a client notifies the server that its filesystem roots have changed.
|
|
#
|
|
# @yield [params] The notification params (typically `nil`).
|
|
# @deprecated MCP Roots (`roots/list` and
|
|
# `notifications/roots/list_changed`) is deprecated as of MCP protocol
|
|
# version 2026-07-28 (SEP-2577). Use tool parameters, resource URIs,
|
|
# server configuration, or environment variables instead.
|
|
def roots_list_changed_handler(&block)
|
|
warn_if_deprecated_protocol_feature(:roots, uplevel: 1)
|
|
|
|
@handlers[Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED] = block
|
|
end
|
|
|
|
# Sets a custom handler for `resources/read` requests.
|
|
# The block receives the parsed request params and should return resource
|
|
# contents. The return value is set as the `contents` field of the response.
|
|
#
|
|
# @yield [params] The request params containing `:uri`.
|
|
# @yieldreturn [Array<Hash>, Hash] Resource contents.
|
|
def resources_read_handler(&block)
|
|
@handlers[Methods::RESOURCES_READ] = block
|
|
end
|
|
|
|
# Sets a custom handler for `completion/complete` requests.
|
|
# The block receives the parsed request params and should return completion values.
|
|
#
|
|
# @yield [params] The request params containing `:ref`, `:argument`, and optionally `:context`.
|
|
# @yieldreturn [Hash] A hash with `:completion` key containing `:values`, optional `:total`, and `:hasMore`.
|
|
def completion_handler(&block)
|
|
@handlers[Methods::COMPLETION_COMPLETE] = block
|
|
end
|
|
|
|
# Sets a custom handler for `resources/subscribe` requests.
|
|
# The block receives the parsed request params. The return value is
|
|
# ignored; the response is always an empty result `{}` per the MCP specification.
|
|
#
|
|
# @yield [params] The request params containing `:uri`.
|
|
def resources_subscribe_handler(&block)
|
|
@handlers[Methods::RESOURCES_SUBSCRIBE] = block
|
|
end
|
|
|
|
# Sets a custom handler for `resources/unsubscribe` requests.
|
|
# The block receives the parsed request params. The return value is
|
|
# ignored; the response is always an empty result `{}` per the MCP specification.
|
|
#
|
|
# @yield [params] The request params containing `:uri`.
|
|
def resources_unsubscribe_handler(&block)
|
|
@handlers[Methods::RESOURCES_UNSUBSCRIBE] = block
|
|
end
|
|
|
|
def build_sampling_params(
|
|
capabilities,
|
|
messages:,
|
|
max_tokens:,
|
|
system_prompt: nil,
|
|
model_preferences: nil,
|
|
include_context: nil,
|
|
temperature: nil,
|
|
stop_sequences: nil,
|
|
metadata: nil,
|
|
tools: nil,
|
|
tool_choice: nil
|
|
)
|
|
unless capabilities&.dig(:sampling)
|
|
raise "Client does not support sampling."
|
|
end
|
|
|
|
if tools && !capabilities.dig(:sampling, :tools)
|
|
raise "Client does not support sampling with tools."
|
|
end
|
|
|
|
if tool_choice && !capabilities.dig(:sampling, :tools)
|
|
raise "Client does not support sampling with tool_choice."
|
|
end
|
|
|
|
{
|
|
messages: messages,
|
|
maxTokens: max_tokens,
|
|
systemPrompt: system_prompt,
|
|
modelPreferences: model_preferences,
|
|
includeContext: include_context,
|
|
temperature: temperature,
|
|
stopSequences: stop_sequences,
|
|
metadata: metadata,
|
|
tools: tools,
|
|
toolChoice: tool_choice,
|
|
}.compact
|
|
end
|
|
|
|
private
|
|
|
|
def validate!
|
|
validate_tool_name!
|
|
|
|
# NOTE: The draft protocol version is the next version after 2025-11-25.
|
|
if @configuration.protocol_version <= "2025-06-18"
|
|
if server_info.key?(:description)
|
|
message = "Error occurred in server_info. `description` is not supported in protocol version 2025-06-18 or earlier"
|
|
raise ArgumentError, message
|
|
end
|
|
|
|
tools_with_ref = @tools.each_with_object([]) do |(tool_name, tool), names|
|
|
names << tool_name if schema_contains_ref?(tool.input_schema_value.to_h)
|
|
end
|
|
unless tools_with_ref.empty?
|
|
message = "Error occurred in #{tools_with_ref.join(", ")}. `$ref` in input schemas is supported by protocol version 2025-11-25 or higher"
|
|
raise ArgumentError, message
|
|
end
|
|
end
|
|
|
|
if @configuration.protocol_version <= "2025-03-26"
|
|
if server_info.key?(:title) || server_info.key?(:websiteUrl)
|
|
message = "Error occurred in server_info. `title` or `website_url` are not supported in protocol version 2025-03-26 or earlier"
|
|
raise ArgumentError, message
|
|
end
|
|
|
|
primitive_titles = [@tools.values, @prompts.values, @resources, @resource_templates].flatten.map(&:title)
|
|
|
|
if primitive_titles.any?
|
|
message = "Error occurred in #{primitive_titles.join(", ")}. `title` is not supported in protocol version 2025-03-26 or earlier"
|
|
raise ArgumentError, message
|
|
end
|
|
end
|
|
|
|
if @configuration.protocol_version == "2024-11-05"
|
|
if @instructions
|
|
message = "`instructions` supported by protocol version 2025-03-26 or higher"
|
|
raise ArgumentError, message
|
|
end
|
|
|
|
error_tool_names = @tools.each_with_object([]) do |(tool_name, tool), error_tool_names|
|
|
if tool.annotations
|
|
error_tool_names << tool_name
|
|
end
|
|
end
|
|
unless error_tool_names.empty?
|
|
message = "Error occurred in #{error_tool_names.join(", ")}. `annotations` are supported by protocol version 2025-03-26 or higher"
|
|
raise ArgumentError, message
|
|
end
|
|
end
|
|
end
|
|
|
|
def validate_tool_name!
|
|
duplicated_tool_names = @tool_names.tally.filter_map { |name, count| name if count >= 2 }
|
|
|
|
raise ToolNotUnique, duplicated_tool_names unless duplicated_tool_names.empty?
|
|
end
|
|
|
|
def schema_contains_ref?(schema)
|
|
case schema
|
|
when Hash
|
|
schema.any? { |key, value| key.to_s == "$ref" || schema_contains_ref?(value) }
|
|
when Array
|
|
schema.any? { |element| schema_contains_ref?(element) }
|
|
else
|
|
false
|
|
end
|
|
end
|
|
|
|
def handle_request(request, method, session: nil, related_request_id: nil)
|
|
# A well-formed notification carries no JSON-RPC id and receives no response.
|
|
# If a client erroneously sends a notification-only method with an id, the message
|
|
# is framed as a request; since notification methods have no request handler,
|
|
# returning `nil` here makes `JsonRpcHandler` report "Method not found", matching
|
|
# the TypeScript and Python SDKs rather than emitting a spurious `result: null`.
|
|
return if Methods.notification?(method) && !related_request_id.nil?
|
|
|
|
# `notifications/cancelled` is dispatched directly: it is a notification (no JSON-RPC id)
|
|
# and intentionally bypasses the `@handlers` lookup, capability check, in-flight registry,
|
|
# and rescue blocks below.
|
|
if method == Methods::NOTIFICATIONS_CANCELLED
|
|
return ->(params) { handle_cancelled_notification(params, session: session) }
|
|
end
|
|
|
|
case method
|
|
when Methods::NOTIFICATIONS_ROOTS_LIST_CHANGED
|
|
warn_if_deprecated_protocol_feature(:roots, session: session, uplevel: 2)
|
|
when Methods::LOGGING_SET_LEVEL
|
|
warn_if_deprecated_protocol_feature(:logging, session: session, uplevel: 2)
|
|
end
|
|
|
|
handler = @handlers[method]
|
|
unless handler
|
|
instrument_call("unsupported_method", server_context: { request: request }) do
|
|
client = session&.client || @client
|
|
add_instrumentation_data(client: client) if client
|
|
end
|
|
return
|
|
end
|
|
|
|
begin
|
|
Methods.ensure_capability!(method, capabilities)
|
|
rescue Methods::MissingRequiredCapabilityError => e
|
|
# Re-raise through RequestHandlerError, the one channel whose message is
|
|
# deliberately surfaced to clients: `JsonRpcHandler`'s blind `rescue StandardError`
|
|
# no longer echoes exception messages into the error `data` member (CWE-209).
|
|
raise RequestHandlerError.new(e.message, request, error_type: :internal_error, original_error: e)
|
|
end
|
|
|
|
# `initialize` MUST NOT be cancelled (MCP spec 2025-11-25, cancellation item 2),
|
|
# so do not track it in the in-flight registry.
|
|
cancellation = if related_request_id && method != Methods::INITIALIZE
|
|
session&.register_in_flight(related_request_id)
|
|
end
|
|
|
|
->(params) {
|
|
reported_exception = nil
|
|
instrument_call(
|
|
method,
|
|
server_context: { request: request },
|
|
exception_already_reported: ->(e) { reported_exception.equal?(e) },
|
|
) do
|
|
envelope = lift_request_envelope(params, method: method, session: session)
|
|
|
|
# The envelope's `logLevel` member replaces `logging/setLevel` in the modern lifecycle:
|
|
# it authorizes `notifications/message` for this request only (SEP-2575). Without it,
|
|
# `ServerSession#notify_log_message` stays silent on modern-era sessions. An unrecognized level
|
|
# reads as absent, so delivery stays off (the safe direction), matching the Python SDK.
|
|
if envelope&.log_level && session.respond_to?(:configure_logging)
|
|
request_logging = LoggingMessageNotification.new(level: envelope.log_level)
|
|
session.configure_logging(request_logging) if request_logging.valid_level?
|
|
end
|
|
|
|
result = case method
|
|
when Methods::INITIALIZE
|
|
init(params, session: session)
|
|
when Methods::RESOURCES_READ
|
|
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)
|
|
{}
|
|
when Methods::TOOLS_CALL
|
|
call_tool(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
|
|
when Methods::PROMPTS_GET
|
|
get_prompt(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
|
|
when Methods::COMPLETION_COMPLETE
|
|
complete(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
|
|
when Methods::LOGGING_SET_LEVEL
|
|
configure_logging_level(params, session: session)
|
|
else
|
|
dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
|
|
end
|
|
client = session&.client || @client
|
|
add_instrumentation_data(client: client) if client
|
|
|
|
if cancellation&.cancelled?
|
|
add_instrumentation_data(cancelled: true, cancellation_reason: cancellation.reason)
|
|
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
|
|
|
|
# SEP-2322 makes `resultType` REQUIRED on every result a 2026-07-28 server returns;
|
|
# a missing value is only tolerated FROM earlier protocol versions. Results that already carry
|
|
# a discriminator keep it; everything else on the modern wire is the standard shape,
|
|
# `"complete"`. Legacy results stay unstamped: pre-2026 clients do not know the field.
|
|
if envelope && result.is_a?(Hash) && !result.key?(:resultType)
|
|
result = result.merge(resultType: ResultType::COMPLETE)
|
|
end
|
|
|
|
result
|
|
rescue CancelledError => e
|
|
add_instrumentation_data(cancelled: true, cancellation_reason: e.reason)
|
|
next JsonRpcHandler::NO_RESPONSE
|
|
rescue RequestHandlerError => e
|
|
report_exception(e.original_error || e, { request: request })
|
|
add_instrumentation_data(error: e.error_type)
|
|
reported_exception = e
|
|
raise e
|
|
rescue => e
|
|
report_exception(e, { request: request })
|
|
add_instrumentation_data(error: :internal_error)
|
|
wrapped = RequestHandlerError.new("Internal error handling #{method} request", request, original_error: e)
|
|
reported_exception = wrapped
|
|
raise wrapped
|
|
ensure
|
|
session&.unregister_in_flight(related_request_id) if related_request_id
|
|
end
|
|
}
|
|
end
|
|
|
|
# Lifts the SEP-2575 per-request `_meta` envelope for modern requests. Only a request whose `_meta` carries
|
|
# the full required triple is classified as modern; a partial triple keeps flowing through the legacy path untouched.
|
|
# Notifications carry no envelope (their `_meta` is a `NotificationMetaObject`), and `server/discover` is
|
|
# pre-version discovery, so both are exempt. Era-locked sessions additionally enforce the dual-era rules:
|
|
# on a modern session, `initialize` is rejected with `-32022` (the modern lifecycle has no handshake)
|
|
# and the triple becomes required for every other request; on a legacy session, a modern envelope is rejected as
|
|
# an invalid request because a connection can never change eras.
|
|
def lift_request_envelope(params, method:, session:)
|
|
return if Methods.notification?(method)
|
|
return if method == Methods::SERVER_DISCOVER
|
|
|
|
era = session.respond_to?(:era) ? session.era : nil
|
|
|
|
if era == :modern && method == Methods::INITIALIZE
|
|
requested = params.is_a?(Hash) ? params[:protocolVersion] || params["protocolVersion"] : nil
|
|
raise UnsupportedProtocolVersionError.new(requested, params)
|
|
end
|
|
|
|
if RequestEnvelope.modern?(params)
|
|
if era == :legacy
|
|
raise RequestHandlerError.new(
|
|
"Invalid Request: the session already negotiated the legacy lifecycle via `initialize`",
|
|
params,
|
|
error_type: :invalid_request,
|
|
)
|
|
end
|
|
|
|
RequestEnvelope.parse!(params, request: params)
|
|
elsif era == :modern
|
|
# A claim-less request on a modern session is a malformed envelope, not a malformed request:
|
|
# the spec maps missing required envelope fields to Invalid params (`-32602`),
|
|
# and the reference SDKs answer it naming the missing keys.
|
|
raise RequestHandlerError.new(
|
|
"Invalid params: missing or invalid `#{RequestEnvelope::REQUIRED_META_KEYS.join("`, `")}` in `_meta`",
|
|
params,
|
|
error_type: :invalid_params,
|
|
error_code: JsonRpcHandler::ErrorCode::INVALID_PARAMS,
|
|
)
|
|
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)
|
|
|
|
request_id = params[:requestId] || params["requestId"]
|
|
return if request_id.nil?
|
|
|
|
reason = params[:reason] || params["reason"]
|
|
session.cancel_incoming(request_id: request_id, reason: reason)
|
|
end
|
|
|
|
def default_capabilities
|
|
{
|
|
tools: { listChanged: true },
|
|
prompts: { listChanged: true },
|
|
resources: { listChanged: true },
|
|
logging: {},
|
|
}
|
|
end
|
|
|
|
def server_info
|
|
@server_info ||= {
|
|
description: description,
|
|
icons: icons&.then { |icons| icons.empty? ? nil : icons.map(&:to_h) },
|
|
name: name,
|
|
title: title,
|
|
version: version,
|
|
websiteUrl: website_url,
|
|
}.compact
|
|
end
|
|
|
|
def init(params, session: nil)
|
|
validate_initialize_params!(params)
|
|
|
|
# MCP spec: the initialization phase MUST be the first interaction between client and server.
|
|
# Reject duplicate `initialize` on an already-initialized session so the negotiated
|
|
# client identity and capabilities cannot be silently overwritten.
|
|
if session&.initialized?
|
|
raise RequestHandlerError.new("Invalid Request: Server already initialized", params, error_type: :invalid_request)
|
|
end
|
|
|
|
if session
|
|
session.store_client_info(client: params[:clientInfo], capabilities: params[:capabilities])
|
|
else
|
|
@client = params[:clientInfo]
|
|
@client_capabilities = params[:capabilities]
|
|
end
|
|
protocol_version = params[:protocolVersion]
|
|
|
|
negotiated_version = if Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(protocol_version)
|
|
protocol_version
|
|
else
|
|
configuration.protocol_version
|
|
end
|
|
|
|
info = server_info.reject do |property|
|
|
negotiated_version <= "2025-06-18" && UNSUPPORTED_PROPERTIES_UNTIL_2025_06_18.include?(property) ||
|
|
negotiated_version <= "2025-03-26" && UNSUPPORTED_PROPERTIES_UNTIL_2025_03_26.include?(property)
|
|
end
|
|
|
|
response_instructions = instructions
|
|
|
|
if negotiated_version == "2024-11-05"
|
|
response_instructions = nil
|
|
end
|
|
|
|
if session
|
|
session.mark_initialized!(protocol_version: negotiated_version)
|
|
else
|
|
@client_protocol_version = negotiated_version
|
|
end
|
|
|
|
{
|
|
protocolVersion: negotiated_version,
|
|
capabilities: capabilities,
|
|
serverInfo: info,
|
|
instructions: response_instructions,
|
|
}.compact
|
|
end
|
|
|
|
def validate_resource_subscription_params!(params)
|
|
unless params.is_a?(Hash) && params[:uri].is_a?(String)
|
|
raise RequestHandlerError.new("Missing or invalid uri", params, error_type: :invalid_params)
|
|
end
|
|
end
|
|
|
|
def validate_initialize_params!(params)
|
|
unless params.is_a?(Hash)
|
|
raise RequestHandlerError.new("Invalid params", params, error_type: :invalid_params)
|
|
end
|
|
|
|
unless params[:protocolVersion].is_a?(String)
|
|
raise RequestHandlerError.new("Missing or invalid protocolVersion", params, error_type: :invalid_params)
|
|
end
|
|
|
|
unless params[:capabilities].is_a?(Hash)
|
|
raise RequestHandlerError.new("Missing or invalid capabilities", params, error_type: :invalid_params)
|
|
end
|
|
|
|
client_info = params[:clientInfo]
|
|
if !client_info.is_a?(Hash) || client_info[:name].nil? || client_info[:version].nil?
|
|
raise RequestHandlerError.new("Missing or invalid clientInfo", params, error_type: :invalid_params)
|
|
end
|
|
end
|
|
|
|
# Handles `server/discover` (MCP 2026-07-28, SEP-2575): sessionless capability discovery.
|
|
# Unlike `init`, this is state-free and idempotent: it stores no client info, does not mark
|
|
# the session initialized, and responds regardless of capability declarations or initialization state,
|
|
# so clients can probe a server before (or instead of) `initialize`.
|
|
#
|
|
# `supportedVersions` advertises modern versions only, matching the TypeScript and Python SDKs:
|
|
# legacy versions are negotiated via `initialize`, not selected from discovery. The `ttlMs`/`cacheScope`
|
|
# cache hints are REQUIRED on `DiscoverResult` (unlike the opt-in SEP-2549 hints on list/read results),
|
|
# so the spec defaults (`0` = immediately stale, `"private"` = per-authorization-context caching only)
|
|
# fill in when the server was not configured with `ttl_ms`/`cache_scope`. The server identity rides
|
|
# in the result `_meta` as the optional `io.modelcontextprotocol/serverInfo` stamp per the finalized
|
|
# spec (PR #3002), unfiltered because discovery happens before version negotiation.
|
|
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575
|
|
def discover(_request)
|
|
{
|
|
supportedVersions: Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS,
|
|
capabilities: capabilities,
|
|
instructions: instructions,
|
|
_meta: { RequestEnvelope::SERVER_INFO_META_KEY => server_info },
|
|
}.compact.merge(
|
|
ttlMs: ttl_ms || 0,
|
|
cacheScope: cache_scope || "private",
|
|
# `server/discover` is exempt from the `_meta` envelope, so the central modern-result stamping does not see it;
|
|
# `DiscoverResult` is still a 2026-07-28 result and carries the REQUIRED `resultType` directly.
|
|
resultType: ResultType::COMPLETE,
|
|
)
|
|
end
|
|
|
|
def configure_logging_level(request, session: nil)
|
|
if capabilities[:logging].nil?
|
|
raise RequestHandlerError.new("Server does not support logging", request, error_type: :internal_error)
|
|
end
|
|
|
|
logging_message_notification = LoggingMessageNotification.new(level: request[:level])
|
|
unless logging_message_notification.valid_level?
|
|
raise RequestHandlerError.new("Invalid log level #{request[:level]}", request, error_type: :invalid_params)
|
|
end
|
|
|
|
session&.configure_logging(logging_message_notification)
|
|
@logging_message_notification = logging_message_notification
|
|
|
|
{}
|
|
end
|
|
|
|
def warn_if_deprecated_protocol_feature(feature, session: nil, uplevel: 1)
|
|
protocol_version = effective_deprecation_protocol_version(session)
|
|
MCP::ProtocolDeprecations.warn_for(feature, protocol_version: protocol_version, uplevel: uplevel)
|
|
end
|
|
|
|
def effective_deprecation_protocol_version(session)
|
|
session&.protocol_version || @client_protocol_version || explicit_protocol_version
|
|
end
|
|
|
|
def explicit_protocol_version
|
|
configuration.protocol_version if configuration.protocol_version?
|
|
end
|
|
|
|
def list_tools(request)
|
|
page = paginate(@tools.values, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
|
|
|
|
apply_cache_metadata({ tools: page[:items], nextCursor: page[:next_cursor] }.compact)
|
|
end
|
|
|
|
def call_tool(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
|
|
tool_name = request[:name]
|
|
|
|
tool = tools[tool_name]
|
|
unless tool
|
|
add_instrumentation_data(tool_name: tool_name, error: :tool_not_found)
|
|
|
|
raise RequestHandlerError.new("Tool not found: #{tool_name}", request, error_type: :invalid_params)
|
|
end
|
|
|
|
arguments = request[:arguments] || {}
|
|
add_instrumentation_data(tool_name: tool_name, tool_arguments: arguments)
|
|
|
|
if tool.input_schema&.missing_required_arguments?(arguments)
|
|
add_instrumentation_data(error: :missing_required_arguments)
|
|
|
|
missing = tool.input_schema.missing_required_arguments(arguments).join(", ")
|
|
return error_tool_response("Missing required arguments: #{missing}")
|
|
end
|
|
|
|
if configuration.validate_tool_call_arguments && tool.input_schema
|
|
begin
|
|
tool.input_schema.validate_arguments(arguments)
|
|
rescue Tool::InputSchema::ValidationError => e
|
|
add_instrumentation_data(error: :invalid_schema)
|
|
|
|
return error_tool_response(e.message)
|
|
end
|
|
end
|
|
|
|
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,
|
|
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(
|
|
result,
|
|
content_provided: response.respond_to?(:content_provided?) && response.content_provided?,
|
|
)
|
|
rescue RequestHandlerError, CancelledError
|
|
# CancelledError is intentionally not wrapped so `handle_request` can turn it into
|
|
# `JsonRpcHandler::NO_RESPONSE` per the MCP cancellation spec.
|
|
raise
|
|
rescue => e
|
|
# `e.message` is deliberately not included: it can carry internals (class, method
|
|
# and host names) that must not reach untrusted clients (CWE-209). The original
|
|
# exception still reaches `configuration.exception_reporter` via `original_error`.
|
|
raise RequestHandlerError.new(
|
|
"Internal error calling tool #{tool_name}",
|
|
request,
|
|
error_type: :internal_error,
|
|
original_error: e,
|
|
)
|
|
end
|
|
|
|
def list_prompts(request)
|
|
page = paginate(@prompts.values, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
|
|
|
|
apply_cache_metadata({ prompts: page[:items], nextCursor: page[:next_cursor] }.compact)
|
|
end
|
|
|
|
def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
|
|
prompt_name = request[:name]
|
|
prompt = @prompts[prompt_name]
|
|
unless prompt
|
|
add_instrumentation_data(error: :prompt_not_found)
|
|
raise RequestHandlerError.new("Prompt not found #{prompt_name}", request, error_type: :prompt_not_found)
|
|
end
|
|
|
|
add_instrumentation_data(prompt_name: prompt_name)
|
|
|
|
prompt_args = request[:arguments]
|
|
prompt.validate_arguments!(prompt_args)
|
|
|
|
server_context = build_server_context(
|
|
request: request,
|
|
session: session,
|
|
related_request_id: related_request_id,
|
|
cancellation: cancellation,
|
|
envelope: envelope,
|
|
)
|
|
|
|
call_prompt_template_with_args(prompt, prompt_args, server_context)
|
|
end
|
|
|
|
def list_resources(request)
|
|
page = paginate(@resources, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
|
|
|
|
apply_cache_metadata({ resources: page[:items], nextCursor: page[:next_cursor] }.compact)
|
|
end
|
|
|
|
# Default `resources/read` handler: routes to class-based resources and resource templates.
|
|
# Fully replaced when `resources_read_handler` is set. When no class-based resource or template is registered,
|
|
# unknown URIs keep the historical no-op `[]` response instead of raising.
|
|
def read_resource(request, server_context: nil)
|
|
uri = request[:uri]
|
|
add_instrumentation_data(resource_uri: uri)
|
|
|
|
resource = @resource_index[uri]
|
|
if resource.is_a?(Class)
|
|
return normalize_resource_contents(call_resource_contents(resource, server_context))
|
|
end
|
|
|
|
@resource_templates.each do |template|
|
|
next unless template.is_a?(Class)
|
|
|
|
params = template.match_uri(uri)
|
|
next unless params
|
|
|
|
add_instrumentation_data(resource_template: template.uri_template)
|
|
return normalize_resource_contents(call_template_contents(template, params, server_context))
|
|
end
|
|
|
|
return [] unless class_based_resources_registered?
|
|
|
|
raise ResourceNotFoundError.new(uri, request)
|
|
end
|
|
|
|
def call_resource_contents(resource, server_context)
|
|
if accepts_server_context?(resource.method(:contents))
|
|
resource.contents(server_context: server_context)
|
|
else
|
|
resource.contents
|
|
end
|
|
end
|
|
|
|
def call_template_contents(template, params, server_context)
|
|
if accepts_server_context?(template.method(:contents))
|
|
template.contents(**params, server_context: server_context)
|
|
else
|
|
template.contents(**params)
|
|
end
|
|
end
|
|
|
|
def normalize_resource_contents(result)
|
|
contents = result.is_a?(Array) ? result : [result]
|
|
contents.map(&:to_h)
|
|
end
|
|
|
|
def class_based_resources_registered?
|
|
@resources.any? { |resource| resource.is_a?(Class) } ||
|
|
@resource_templates.any? { |template| template.is_a?(Class) }
|
|
end
|
|
|
|
def list_resource_templates(request)
|
|
page = paginate(@resource_templates, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
|
|
|
|
apply_cache_metadata({ resourceTemplates: page[:items], nextCursor: page[:next_cursor] }.compact)
|
|
end
|
|
|
|
# Builds the `resources/read` result. The documented handler contract is "return value becomes `contents`";
|
|
# a Hash with a `:contents` key is also accepted as a full result so a handler can override the server-level
|
|
# `ttlMs`/`cacheScope` cache hints per result (SEP-2549).
|
|
def build_read_resource_result(handler_result)
|
|
result = if handler_result.is_a?(Hash) && handler_result.key?(:contents)
|
|
handler_result
|
|
else
|
|
{ contents: handler_result }
|
|
end
|
|
|
|
apply_cache_metadata(result)
|
|
end
|
|
|
|
# Adds the SEP-2549 cache hints (`ttlMs`, `cacheScope`) to a result. Emission is opt-in: nothing is added
|
|
# unless the server was configured with `ttl_ms`/`cache_scope` or the result already carries one of the fields, in
|
|
# which case the missing one is filled with the spec defaults (`ttlMs: 0` = do not cache, `cacheScope: "public"`).
|
|
# Values already in the result win, enabling per-result overrides.
|
|
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549
|
|
def apply_cache_metadata(result)
|
|
explicit = result.key?(:ttlMs) || result.key?(:cacheScope)
|
|
return result if @ttl_ms.nil? && @cache_scope.nil? && !explicit
|
|
|
|
{ ttlMs: @ttl_ms || 0, cacheScope: @cache_scope || "public" }.merge(result)
|
|
end
|
|
|
|
def complete(params, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
|
|
validate_completion_params!(params)
|
|
|
|
result = dispatch_optional_context_handler(
|
|
@handlers[Methods::COMPLETION_COMPLETE],
|
|
params,
|
|
session: session,
|
|
related_request_id: related_request_id,
|
|
cancellation: cancellation,
|
|
envelope: envelope,
|
|
)
|
|
|
|
normalize_completion_result(result)
|
|
end
|
|
|
|
# Invokes `resources/read` via the registered handler. If the handler block opts in to `server_context:`,
|
|
# pass an `MCP::ServerContext` so the handler can observe cancellation via `server_context.cancelled?` or
|
|
# `server_context.raise_if_cancelled!`.
|
|
def read_resource_contents(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
|
|
dispatch_optional_context_handler(
|
|
@handlers[Methods::RESOURCES_READ],
|
|
request,
|
|
session: session,
|
|
related_request_id: related_request_id,
|
|
cancellation: cancellation,
|
|
envelope: envelope,
|
|
)
|
|
end
|
|
|
|
# Opt-in `server_context:` dispatch for block-based handlers registered via `resources_read_handler`,
|
|
# `completion_handler`, `resources_subscribe_handler`, `resources_unsubscribe_handler`, or `define_custom_method`.
|
|
# Existing handlers that only accept `params` are called unchanged; handlers that declare a `server_context:`
|
|
# keyword receive an `MCP::ServerContext` wrapping the raw server context with cancellation plumbing.
|
|
def dispatch_optional_context_handler(handler, params, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
|
|
return handler.call(params) unless handler_declares_server_context?(handler)
|
|
|
|
server_context = build_server_context(
|
|
request: params,
|
|
session: session,
|
|
related_request_id: related_request_id,
|
|
cancellation: cancellation,
|
|
envelope: envelope,
|
|
)
|
|
handler.call(params, server_context: server_context)
|
|
end
|
|
|
|
# Stricter than `accepts_server_context?`: requires `server_context` to appear as a named keyword parameter
|
|
# (`:key` optional, `:keyreq` required). Positional parameters named `server_context` (`:req` / `:opt`) are NOT
|
|
# treated as opt-in - otherwise `handler.call(params, server_context: ctx)` would pass the `{server_context: ctx}`
|
|
# Hash as the handler's second positional argument, which is never what the user meant.
|
|
#
|
|
# `**kwargs`-only signatures (`:keyrest` without a named `server_context`) are also not opt-in here,
|
|
# because the dispatch site passes a positional `params`, and a `**kwargs`-only block cannot accept
|
|
# that positional argument (lambdas/methods raise `ArgumentError`; non-lambda procs silently drop `params`).
|
|
# Tool handlers intentionally allow `**kwargs` opt-in via `accepts_server_context?` because they are invoked
|
|
# via `tool.call(**args, server_context: …)` without a positional argument.
|
|
def handler_declares_server_context?(handler)
|
|
return false unless handler.respond_to?(:parameters)
|
|
|
|
handler.parameters.any? do |type, name|
|
|
name == :server_context && (type == :key || type == :keyreq)
|
|
end
|
|
end
|
|
|
|
# Builds an `MCP::ServerContext` used to give a handler access to session-scoped helpers
|
|
# (progress, cancellation, nested server-to-client requests).
|
|
def build_server_context(request:, session:, related_request_id:, cancellation:, envelope: nil)
|
|
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
|
|
|
|
def report_exception(exception, server_context = {})
|
|
configuration.exception_reporter.call(exception, server_context)
|
|
end
|
|
|
|
def index_resources_by_uri(resources)
|
|
resources.to_h { |resource| [resource.uri, resource] }
|
|
end
|
|
|
|
def error_tool_response(text)
|
|
Tool::Response.new(
|
|
[{
|
|
type: "text",
|
|
text: text,
|
|
}],
|
|
error: true,
|
|
).to_h
|
|
end
|
|
|
|
def validate_tool_call_result!(tool, result)
|
|
return unless configuration.validate_tool_call_results
|
|
return unless tool.output_schema
|
|
return if result[:isError]
|
|
|
|
tool.output_schema.validate_result(result[:structuredContent])
|
|
end
|
|
|
|
# Per SEP-2106, `structuredContent` may be any JSON value, not only an object.
|
|
# Clients on older protocol versions may only read `content`,
|
|
# so when a tool returns non-object structured content without explicitly
|
|
# providing `content`, mirror the value into serialized JSON text.
|
|
def serialize_structured_content_fallback(result, content_provided: false)
|
|
structured = result[:structuredContent]
|
|
return result if structured.nil? || structured.is_a?(Hash)
|
|
return result if content_provided
|
|
return result unless result[:content].nil? || result[:content].empty?
|
|
|
|
serialized = JSON.generate(structured)
|
|
return result if JSON.parse(serialized).is_a?(Hash)
|
|
|
|
result.merge(content: [{ type: "text", text: serialized }])
|
|
end
|
|
|
|
# Whether a tool/prompt handler opts in to receiving an `MCP::ServerContext`.
|
|
# Recognizes `:keyrest` (`**kwargs`) because tools are invoked without a positional argument
|
|
# (`tool.call(**args, server_context:)`), soa `**kwargs`-only signature safely captures `server_context:`.
|
|
# Named keyword `server_context` must be `:key` or `:keyreq` - positional parameters (`:req` / `:opt`) that
|
|
# happen to be named `server_context` are excluded because the call site passes `server_context:` as a keyword,
|
|
# and a positional slot would receive the `{server_context: ctx}` Hash instead.
|
|
def accepts_server_context?(method_object)
|
|
parameters = method_object.parameters
|
|
|
|
parameters.any? do |type, name|
|
|
type == :keyrest || (name == :server_context && (type == :key || type == :keyreq))
|
|
end
|
|
end
|
|
|
|
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.
|
|
# See docs/building-servers.md ("Tool argument keys").
|
|
args = arguments&.transform_keys(&:to_sym) || {}
|
|
|
|
if accepts_server_context?(tool.method(:call))
|
|
progress = Progress.new(notification_target: session, progress_token: progress_token, related_request_id: related_request_id)
|
|
server_context = ServerContext.new(
|
|
context,
|
|
progress: progress,
|
|
notification_target: 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
|
|
tool.call(**args)
|
|
end
|
|
end
|
|
|
|
def call_prompt_template_with_args(prompt, args, server_context)
|
|
raw_result = if accepts_server_context?(prompt.method(:template))
|
|
prompt.template(args, server_context: server_context)
|
|
else
|
|
prompt.template(args)
|
|
end
|
|
|
|
raw_result.is_a?(InputRequiredResult) ? raw_result : raw_result.to_h
|
|
end
|
|
|
|
def server_context_with_meta(request)
|
|
meta = request[:_meta]
|
|
if meta && server_context.is_a?(Hash)
|
|
context = server_context.dup
|
|
context[:_meta] = meta
|
|
context
|
|
elsif meta && server_context.nil?
|
|
{ _meta: meta }
|
|
else
|
|
server_context
|
|
end
|
|
end
|
|
|
|
def validate_completion_params!(params)
|
|
unless params.is_a?(Hash)
|
|
raise RequestHandlerError.new("Invalid params", params, error_type: :invalid_params)
|
|
end
|
|
|
|
ref = params[:ref]
|
|
if ref.nil? || ref[:type].nil?
|
|
raise RequestHandlerError.new("Missing or invalid ref", params, error_type: :invalid_params)
|
|
end
|
|
|
|
argument = params[:argument]
|
|
if argument.nil? || argument[:name].nil? || !argument.key?(:value)
|
|
raise RequestHandlerError.new("Missing argument name or value", params, error_type: :invalid_params)
|
|
end
|
|
|
|
case ref[:type]
|
|
when "ref/prompt"
|
|
unless @prompts[ref[:name]]
|
|
raise RequestHandlerError.new("Prompt not found: #{ref[:name]}", params, error_type: :invalid_params)
|
|
end
|
|
when "ref/resource"
|
|
uri = ref[:uri]
|
|
found = @resource_index.key?(uri) || @resource_templates.any? { |t| t.uri_template == uri }
|
|
unless found
|
|
raise ResourceNotFoundError.new(uri, params)
|
|
end
|
|
else
|
|
raise RequestHandlerError.new("Invalid ref type: #{ref[:type]}", params, error_type: :invalid_params)
|
|
end
|
|
end
|
|
|
|
def normalize_completion_result(result)
|
|
return DEFAULT_COMPLETION_RESULT unless result.is_a?(Hash)
|
|
|
|
completion = result[:completion] || result["completion"]
|
|
return DEFAULT_COMPLETION_RESULT unless completion.is_a?(Hash)
|
|
|
|
values = completion[:values] || completion["values"] || []
|
|
total = completion[:total] || completion["total"]
|
|
has_more = completion[:hasMore] || completion["hasMore"] || false
|
|
|
|
count = values.length
|
|
if count > MAX_COMPLETION_VALUES
|
|
has_more = true
|
|
total ||= count
|
|
values = values.first(MAX_COMPLETION_VALUES)
|
|
end
|
|
|
|
{ completion: { values: values, total: total, hasMore: has_more }.compact }
|
|
end
|
|
end
|
|
end
|