## Motivation and Context The MCP specification defines cursor-based pagination for list operations that may return large result sets: https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/pagination Pagination allows servers to yield results in smaller chunks rather than all at once, which is especially important when connecting to external services over the internet. The Ruby SDK previously returned complete arrays for all list endpoints (`tools/list`, `resources/list`, `prompts/list`, `resources/templates/list`) without pagination support. This adds cursor-based pagination. ### Server-side A new `MCP::Server::Pagination` module is introduced (mixed into `MCP::Server`) that provides the `paginate` helper for slicing a collection by cursor and a `cursor_from` helper that extracts the cursor from the request params while rejecting non-Hash inputs with `-32602 Invalid params` per the specification. `MCP::Server.new` accepts a new `page_size:` keyword argument. When `nil` (the default), all items are returned in a single response, preserving the existing behavior. When set to a positive integer, list responses include a `nextCursor` field if more pages are available. `Server#page_size=` is a validating setter that rejects anything other than `nil` or a positive `Integer`, raising `ArgumentError` on invalid inputs. The constructor routes through the setter, so both `Server.new(page_size: 0)` and `server.page_size = -1` raise. Cursors are string tokens carrying a zero-based offset, treated as opaque by clients. Cursor inputs are validated to be strings per the MCP spec; invalid cursors (non-string, non-numeric, negative, or out-of-range) raise `RequestHandlerError` with error code `-32602 (Invalid params)` per the spec. ### Client-side: two complementary APIs The SDK exposes two API shapes for listing, each serving a different use case: 1. **Single-page with cursor** (`client.list_tools(cursor:)`, `list_resources`, `list_resource_templates`, `list_prompts`): each call issues one JSON-RPC request and returns a result object (`MCP::Client::ListToolsResult` etc.) carrying the page items and an optional `next_cursor`. These methods match the single-page-with-cursor convention used by the Python SDK (`session.list_tools(params=PaginatedRequestParams(cursor=...))`) and the TypeScript SDK (`client.listTools({ cursor })`). The method name and the `cursor` parameter name are identical across the three SDKs, so pagination code translates directly between languages. 2. **Whole-collection** (`client.tools`, `client.resources`, `client.resource_templates`, `client.prompts`): auto-iterates through all pages and returns a plain array of items, guaranteeing the full collection regardless of the server's `page_size` setting. The auto-pagination loop tracks previously seen cursors in a set and exits if the server revisits any of them, guarding against both immediate repeats and multi-cursor cycles (e.g. `A -> B -> A`). This mirrors Rust SDK's `list_all_*` convenience methods, though the Ruby names are preserved from the pre-pagination 0.13.0 API for backward compatibility. Future rename to `list_all_*` is left as a TODO in the source. Result classes are `Struct`s named to mirror Python SDK's `ListToolsResult` / `ListPromptsResult` / etc. to align naming with other SDKs. Each struct exposes its page items (e.g. `result.tools`), an optional `next_cursor` for continuation, and an optional `meta` field mirroring the MCP `Result` type's `_meta` response field so servers that decorate list responses do not lose metadata through the client. ### Ruby-specific ergonomics `Server.new(page_size:)` and the `Pagination` module are Ruby-specific. The Python and TypeScript SDKs do not expose a built-in `page_size` helper; developers there re-implement cursor slicing inside each handler. In Ruby, setting `page_size:` on the server is enough for the SDK to handle the slicing across the four built-in list endpoints. ### README A Pagination section documents both the server-side `page_size:` option and the client-side iteration patterns, including an explicit note that each `list_*` call is a single JSON-RPC round trip whose response size depends on the server's `page_size`, and that `client.tools` / `.resources` / `.resource_templates` / `.prompts` return the complete collection when needed. ## How Has This Been Tested? - Added tests for the `Pagination` module (`test/mcp/server/pagination_test.rb`), server-side pagination (`test/mcp/server_test.rb`), and client-side pagination (`test/mcp/client_test.rb`). - Added and existing tests pass. `rake rubocop` is clean and `rake conformance` passes. ## Breaking Changes None. This is a new feature addition. Existing code continues to work unchanged: `page_size` defaults to `nil` on the server (returns all items, no `nextCursor`), and the client's existing `tools` / `resources` / `resource_templates` / `prompts` methods continue to return complete arrays by transparently iterating through pages.
702 lines
24 KiB
Ruby
702 lines
24 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require_relative "../json_rpc_handler"
|
|
require_relative "instrumentation"
|
|
require_relative "methods"
|
|
require_relative "logging_message_notification"
|
|
require_relative "progress"
|
|
require_relative "server_context"
|
|
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
|
|
|
|
class MethodAlreadyDefinedError < StandardError
|
|
attr_reader :method_name
|
|
|
|
def initialize(method_name)
|
|
super("Method #{method_name} already defined")
|
|
@method_name = method_name
|
|
end
|
|
end
|
|
|
|
include Instrumentation
|
|
include Pagination
|
|
|
|
attr_accessor :description, :icons, :name, :title, :version, :website_url, :instructions, :tools, :prompts, :resources, :server_context, :configuration, :capabilities, :transport, :logging_message_notification
|
|
attr_reader :page_size, :client_capabilities
|
|
|
|
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,
|
|
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
|
|
@configuration = MCP.configuration.merge(configuration)
|
|
@client = nil
|
|
|
|
validate!
|
|
|
|
@capabilities = capabilities || default_capabilities
|
|
@client_capabilities = nil
|
|
@logging_message_notification = nil
|
|
|
|
@handlers = {
|
|
Methods::RESOURCES_LIST => method(:list_resources),
|
|
Methods::RESOURCES_READ => method(:read_resource_no_content),
|
|
Methods::RESOURCES_TEMPLATES_LIST => method(:list_resource_templates),
|
|
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::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),
|
|
|
|
# No op handlers for currently unsupported methods
|
|
Methods::RESOURCES_SUBSCRIBE => ->(_) { {} },
|
|
Methods::RESOURCES_UNSUBSCRIBE => ->(_) { {} },
|
|
}
|
|
@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, annotations: nil, meta: nil, &block)
|
|
tool = Tool.define(name: name, title: title, description: description, input_schema: input_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_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
|
|
|
|
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
|
|
|
|
def notify_log_message(data:, level:, logger: nil)
|
|
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`).
|
|
def roots_list_changed_handler(&block)
|
|
@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
|
|
|
|
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)
|
|
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
|
|
|
|
Methods.ensure_capability!(method, capabilities)
|
|
|
|
->(params) {
|
|
reported_exception = nil
|
|
instrument_call(
|
|
method,
|
|
server_context: { request: request },
|
|
exception_already_reported: ->(e) { reported_exception.equal?(e) },
|
|
) do
|
|
result = case method
|
|
when Methods::INITIALIZE
|
|
init(params, session: session)
|
|
when Methods::RESOURCES_READ
|
|
{ contents: @handlers[Methods::RESOURCES_READ].call(params) }
|
|
when Methods::TOOLS_CALL
|
|
call_tool(params, session: session, related_request_id: related_request_id)
|
|
when Methods::COMPLETION_COMPLETE
|
|
complete(params)
|
|
when Methods::LOGGING_SET_LEVEL
|
|
configure_logging_level(params, session: session)
|
|
else
|
|
@handlers[method].call(params)
|
|
end
|
|
client = session&.client || @client
|
|
add_instrumentation_data(client: client) if client
|
|
|
|
result
|
|
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
|
|
end
|
|
}
|
|
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)
|
|
if params
|
|
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]
|
|
end
|
|
|
|
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
|
|
|
|
{
|
|
protocolVersion: negotiated_version,
|
|
capabilities: capabilities,
|
|
serverInfo: info,
|
|
instructions: response_instructions,
|
|
}.compact
|
|
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 list_tools(request)
|
|
page = paginate(@tools.values, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
|
|
|
|
{ tools: page[:items], nextCursor: page[:next_cursor] }.compact
|
|
end
|
|
|
|
def call_tool(request, session: nil, related_request_id: 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(", ")
|
|
raise RequestHandlerError.new("Missing required arguments: #{missing}", request, error_type: :invalid_params)
|
|
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)
|
|
|
|
raise RequestHandlerError.new(e.message, request, error_type: :invalid_params)
|
|
end
|
|
end
|
|
|
|
progress_token = request.dig(:_meta, :progressToken)
|
|
|
|
call_tool_with_args(tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session, related_request_id: related_request_id)
|
|
rescue RequestHandlerError
|
|
raise
|
|
rescue => e
|
|
raise RequestHandlerError.new(
|
|
"Internal error calling tool #{tool_name}: #{e.message}",
|
|
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)
|
|
|
|
{ prompts: page[:items], nextCursor: page[:next_cursor] }.compact
|
|
end
|
|
|
|
def get_prompt(request)
|
|
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)
|
|
|
|
call_prompt_template_with_args(prompt, prompt_args, server_context_with_meta(request))
|
|
end
|
|
|
|
def list_resources(request)
|
|
page = paginate(@resources, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
|
|
|
|
{ resources: page[:items], nextCursor: page[:next_cursor] }.compact
|
|
end
|
|
|
|
# Server implementation should set `resources_read_handler` to override no-op default
|
|
def read_resource_no_content(request)
|
|
add_instrumentation_data(resource_uri: request[:uri])
|
|
[]
|
|
end
|
|
|
|
def list_resource_templates(request)
|
|
page = paginate(@resource_templates, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
|
|
|
|
{ resourceTemplates: page[:items], nextCursor: page[:next_cursor] }.compact
|
|
end
|
|
|
|
def complete(params)
|
|
validate_completion_params!(params)
|
|
|
|
result = @handlers[Methods::COMPLETION_COMPLETE].call(params)
|
|
|
|
normalize_completion_result(result)
|
|
end
|
|
|
|
def report_exception(exception, server_context = {})
|
|
configuration.exception_reporter.call(exception, server_context)
|
|
end
|
|
|
|
def index_resources_by_uri(resources)
|
|
resources.each_with_object({}) do |resource, hash|
|
|
hash[resource.uri] = resource
|
|
end
|
|
end
|
|
|
|
def error_tool_response(text)
|
|
Tool::Response.new(
|
|
[{
|
|
type: "text",
|
|
text: text,
|
|
}],
|
|
error: true,
|
|
).to_h
|
|
end
|
|
|
|
def accepts_server_context?(method_object)
|
|
parameters = method_object.parameters
|
|
|
|
parameters.any? { |type, name| type == :keyrest || name == :server_context }
|
|
end
|
|
|
|
def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil)
|
|
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)
|
|
tool.call(**args, server_context: server_context).to_h
|
|
else
|
|
tool.call(**args).to_h
|
|
end
|
|
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
|
|
else
|
|
prompt.template(args).to_h
|
|
end
|
|
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 RequestHandlerError.new("Resource not found: #{uri}", params, error_type: :invalid_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
|