Merge pull request #475 from koic/server_modern_core
Handle the SEP-2575 Modern Request Envelope in the Server Core
This commit is contained in:
commit
f4d939d349
5 changed files with 394 additions and 18 deletions
|
|
@ -65,13 +65,15 @@ module MCP
|
||||||
#
|
#
|
||||||
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575
|
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575
|
||||||
class UnsupportedProtocolVersionError < RequestHandlerError
|
class UnsupportedProtocolVersionError < RequestHandlerError
|
||||||
def initialize(requested, request = nil, supported: Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS)
|
# 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(
|
super(
|
||||||
"Unsupported protocol version",
|
"Unsupported protocol version",
|
||||||
request,
|
request,
|
||||||
error_type: :unsupported_protocol_version,
|
error_type: :unsupported_protocol_version,
|
||||||
error_code: ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION,
|
error_code: ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION,
|
||||||
error_data: { supported: supported, requested: requested || "unknown" },
|
error_data: { supported: Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, requested: requested || "unknown" },
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -560,25 +562,26 @@ module MCP
|
||||||
server_context: { request: request },
|
server_context: { request: request },
|
||||||
exception_already_reported: ->(e) { reported_exception.equal?(e) },
|
exception_already_reported: ->(e) { reported_exception.equal?(e) },
|
||||||
) do
|
) do
|
||||||
|
envelope = lift_request_envelope(params, method: method, session: session)
|
||||||
result = case method
|
result = case method
|
||||||
when Methods::INITIALIZE
|
when Methods::INITIALIZE
|
||||||
init(params, session: session)
|
init(params, session: session)
|
||||||
when Methods::RESOURCES_READ
|
when Methods::RESOURCES_READ
|
||||||
build_read_resource_result(read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation))
|
build_read_resource_result(read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope))
|
||||||
when Methods::RESOURCES_SUBSCRIBE, Methods::RESOURCES_UNSUBSCRIBE
|
when Methods::RESOURCES_SUBSCRIBE, Methods::RESOURCES_UNSUBSCRIBE
|
||||||
validate_resource_subscription_params!(params)
|
validate_resource_subscription_params!(params)
|
||||||
dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation)
|
dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
|
||||||
{}
|
{}
|
||||||
when Methods::TOOLS_CALL
|
when Methods::TOOLS_CALL
|
||||||
call_tool(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
|
call_tool(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
|
||||||
when Methods::PROMPTS_GET
|
when Methods::PROMPTS_GET
|
||||||
get_prompt(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
|
get_prompt(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
|
||||||
when Methods::COMPLETION_COMPLETE
|
when Methods::COMPLETION_COMPLETE
|
||||||
complete(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
|
complete(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
|
||||||
when Methods::LOGGING_SET_LEVEL
|
when Methods::LOGGING_SET_LEVEL
|
||||||
configure_logging_level(params, session: session)
|
configure_logging_level(params, session: session)
|
||||||
else
|
else
|
||||||
dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation)
|
dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
|
||||||
end
|
end
|
||||||
client = session&.client || @client
|
client = session&.client || @client
|
||||||
add_instrumentation_data(client: client) if client
|
add_instrumentation_data(client: client) if client
|
||||||
|
|
@ -609,6 +612,34 @@ module MCP
|
||||||
}
|
}
|
||||||
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. On a session already era-locked to modern, `initialize` is
|
||||||
|
# rejected with `-32022` (the modern lifecycle has no handshake) and the triple becomes required for
|
||||||
|
# every other request.
|
||||||
|
def lift_request_envelope(params, method:, session:)
|
||||||
|
return if Methods.notification?(method)
|
||||||
|
return if method == Methods::SERVER_DISCOVER
|
||||||
|
|
||||||
|
modern_session = session.respond_to?(:era) && session.era == :modern
|
||||||
|
|
||||||
|
if modern_session && method == Methods::INITIALIZE
|
||||||
|
requested = params.is_a?(Hash) ? params[:protocolVersion] || params["protocolVersion"] : nil
|
||||||
|
raise UnsupportedProtocolVersionError.new(requested, params)
|
||||||
|
end
|
||||||
|
|
||||||
|
if RequestEnvelope.modern?(params)
|
||||||
|
RequestEnvelope.parse!(params, request: params)
|
||||||
|
elsif modern_session
|
||||||
|
raise RequestHandlerError.new(
|
||||||
|
"Invalid Request: modern sessions require the SEP-2575 `_meta` envelope",
|
||||||
|
params,
|
||||||
|
error_type: :invalid_request,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def handle_cancelled_notification(params, session: nil)
|
def handle_cancelled_notification(params, session: nil)
|
||||||
return unless session
|
return unless session
|
||||||
return unless params.is_a?(Hash)
|
return unless params.is_a?(Hash)
|
||||||
|
|
@ -748,7 +779,7 @@ module MCP
|
||||||
apply_cache_metadata({ tools: page[:items], nextCursor: page[:next_cursor] }.compact)
|
apply_cache_metadata({ tools: page[:items], nextCursor: page[:next_cursor] }.compact)
|
||||||
end
|
end
|
||||||
|
|
||||||
def call_tool(request, session: nil, related_request_id: nil, cancellation: nil)
|
def call_tool(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
|
||||||
tool_name = request[:name]
|
tool_name = request[:name]
|
||||||
|
|
||||||
tool = tools[tool_name]
|
tool = tools[tool_name]
|
||||||
|
|
@ -781,7 +812,7 @@ module MCP
|
||||||
progress_token = request.dig(:_meta, :progressToken)
|
progress_token = request.dig(:_meta, :progressToken)
|
||||||
|
|
||||||
response = call_tool_with_args(
|
response = call_tool_with_args(
|
||||||
tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session, related_request_id: related_request_id, cancellation: cancellation
|
tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope
|
||||||
)
|
)
|
||||||
result = response.to_h
|
result = response.to_h
|
||||||
validate_tool_call_result!(tool, result)
|
validate_tool_call_result!(tool, result)
|
||||||
|
|
@ -808,7 +839,7 @@ module MCP
|
||||||
apply_cache_metadata({ prompts: page[:items], nextCursor: page[:next_cursor] }.compact)
|
apply_cache_metadata({ prompts: page[:items], nextCursor: page[:next_cursor] }.compact)
|
||||||
end
|
end
|
||||||
|
|
||||||
def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil)
|
def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
|
||||||
prompt_name = request[:name]
|
prompt_name = request[:name]
|
||||||
prompt = @prompts[prompt_name]
|
prompt = @prompts[prompt_name]
|
||||||
unless prompt
|
unless prompt
|
||||||
|
|
@ -826,6 +857,7 @@ module MCP
|
||||||
session: session,
|
session: session,
|
||||||
related_request_id: related_request_id,
|
related_request_id: related_request_id,
|
||||||
cancellation: cancellation,
|
cancellation: cancellation,
|
||||||
|
envelope: envelope,
|
||||||
)
|
)
|
||||||
|
|
||||||
call_prompt_template_with_args(prompt, prompt_args, server_context)
|
call_prompt_template_with_args(prompt, prompt_args, server_context)
|
||||||
|
|
@ -921,7 +953,7 @@ module MCP
|
||||||
{ ttlMs: @ttl_ms || 0, cacheScope: @cache_scope || "public" }.merge(result)
|
{ ttlMs: @ttl_ms || 0, cacheScope: @cache_scope || "public" }.merge(result)
|
||||||
end
|
end
|
||||||
|
|
||||||
def complete(params, session: nil, related_request_id: nil, cancellation: nil)
|
def complete(params, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
|
||||||
validate_completion_params!(params)
|
validate_completion_params!(params)
|
||||||
|
|
||||||
result = dispatch_optional_context_handler(
|
result = dispatch_optional_context_handler(
|
||||||
|
|
@ -930,6 +962,7 @@ module MCP
|
||||||
session: session,
|
session: session,
|
||||||
related_request_id: related_request_id,
|
related_request_id: related_request_id,
|
||||||
cancellation: cancellation,
|
cancellation: cancellation,
|
||||||
|
envelope: envelope,
|
||||||
)
|
)
|
||||||
|
|
||||||
normalize_completion_result(result)
|
normalize_completion_result(result)
|
||||||
|
|
@ -938,13 +971,14 @@ module MCP
|
||||||
# Invokes `resources/read` via the registered handler. If the handler block opts in to `server_context:`,
|
# 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
|
# pass an `MCP::ServerContext` so the handler can observe cancellation via `server_context.cancelled?` or
|
||||||
# `server_context.raise_if_cancelled!`.
|
# `server_context.raise_if_cancelled!`.
|
||||||
def read_resource_contents(request, session: nil, related_request_id: nil, cancellation: nil)
|
def read_resource_contents(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
|
||||||
dispatch_optional_context_handler(
|
dispatch_optional_context_handler(
|
||||||
@handlers[Methods::RESOURCES_READ],
|
@handlers[Methods::RESOURCES_READ],
|
||||||
request,
|
request,
|
||||||
session: session,
|
session: session,
|
||||||
related_request_id: related_request_id,
|
related_request_id: related_request_id,
|
||||||
cancellation: cancellation,
|
cancellation: cancellation,
|
||||||
|
envelope: envelope,
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -952,7 +986,7 @@ module MCP
|
||||||
# `completion_handler`, `resources_subscribe_handler`, `resources_unsubscribe_handler`, or `define_custom_method`.
|
# `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:`
|
# 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.
|
# 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)
|
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)
|
return handler.call(params) unless handler_declares_server_context?(handler)
|
||||||
|
|
||||||
server_context = build_server_context(
|
server_context = build_server_context(
|
||||||
|
|
@ -960,6 +994,7 @@ module MCP
|
||||||
session: session,
|
session: session,
|
||||||
related_request_id: related_request_id,
|
related_request_id: related_request_id,
|
||||||
cancellation: cancellation,
|
cancellation: cancellation,
|
||||||
|
envelope: envelope,
|
||||||
)
|
)
|
||||||
handler.call(params, server_context: server_context)
|
handler.call(params, server_context: server_context)
|
||||||
end
|
end
|
||||||
|
|
@ -984,7 +1019,7 @@ module MCP
|
||||||
|
|
||||||
# Builds an `MCP::ServerContext` used to give a handler access to session-scoped helpers
|
# Builds an `MCP::ServerContext` used to give a handler access to session-scoped helpers
|
||||||
# (progress, cancellation, nested server-to-client requests).
|
# (progress, cancellation, nested server-to-client requests).
|
||||||
def build_server_context(request:, session:, related_request_id:, cancellation:)
|
def build_server_context(request:, session:, related_request_id:, cancellation:, envelope: nil)
|
||||||
meta_source = request.is_a?(Hash) ? request : {}
|
meta_source = request.is_a?(Hash) ? request : {}
|
||||||
progress_token = meta_source.dig(:_meta, :progressToken)
|
progress_token = meta_source.dig(:_meta, :progressToken)
|
||||||
progress = Progress.new(notification_target: session, progress_token: progress_token, related_request_id: related_request_id)
|
progress = Progress.new(notification_target: session, progress_token: progress_token, related_request_id: related_request_id)
|
||||||
|
|
@ -994,6 +1029,7 @@ module MCP
|
||||||
notification_target: session,
|
notification_target: session,
|
||||||
related_request_id: related_request_id,
|
related_request_id: related_request_id,
|
||||||
cancellation: cancellation,
|
cancellation: cancellation,
|
||||||
|
envelope: envelope,
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -1053,7 +1089,7 @@ module MCP
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil, cancellation: nil)
|
def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
|
||||||
# Transports parse incoming JSON with `symbolize_names: true`, so `arguments` already arrives symbolized
|
# Transports parse incoming JSON with `symbolize_names: true`, so `arguments` already arrives symbolized
|
||||||
# at every nesting level. This top-level transform only guards callers that hand in string-keyed top-level arguments;
|
# at every nesting level. This top-level transform only guards callers that hand in string-keyed top-level arguments;
|
||||||
# it does not recurse, and nested object keys remain symbols. Tools therefore receive symbol keys all the way down.
|
# it does not recurse, and nested object keys remain symbols. Tools therefore receive symbol keys all the way down.
|
||||||
|
|
@ -1068,6 +1104,7 @@ module MCP
|
||||||
notification_target: session,
|
notification_target: session,
|
||||||
related_request_id: related_request_id,
|
related_request_id: related_request_id,
|
||||||
cancellation: cancellation,
|
cancellation: cancellation,
|
||||||
|
envelope: envelope,
|
||||||
)
|
)
|
||||||
tool.call(**args, server_context: server_context)
|
tool.call(**args, server_context: server_context)
|
||||||
else
|
else
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,17 @@ module MCP
|
||||||
class ServerContext
|
class ServerContext
|
||||||
attr_reader :cancellation
|
attr_reader :cancellation
|
||||||
|
|
||||||
def initialize(context, progress:, notification_target:, related_request_id: nil, cancellation: nil)
|
# The SEP-2575 per-request envelope (`MCP::RequestEnvelope`) when the request was classified as modern;
|
||||||
|
# `nil` on legacy requests.
|
||||||
|
attr_reader :envelope
|
||||||
|
|
||||||
|
def initialize(context, progress:, notification_target:, related_request_id: nil, cancellation: nil, envelope: nil)
|
||||||
@context = context
|
@context = context
|
||||||
@progress = progress
|
@progress = progress
|
||||||
@notification_target = notification_target
|
@notification_target = notification_target
|
||||||
@related_request_id = related_request_id
|
@related_request_id = related_request_id
|
||||||
@cancellation = cancellation
|
@cancellation = cancellation
|
||||||
|
@envelope = envelope
|
||||||
end
|
end
|
||||||
|
|
||||||
def cancelled?
|
def cancelled?
|
||||||
|
|
@ -20,6 +25,52 @@ module MCP
|
||||||
@cancellation&.raise_if_cancelled!
|
@cancellation&.raise_if_cancelled!
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Whether the current request follows the stateless modern lifecycle (SEP-2575).
|
||||||
|
def modern?
|
||||||
|
!@envelope.nil?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Client identity for the current request. Modern requests carry it in the `_meta` envelope;
|
||||||
|
# legacy sessions fall back to the state stored by `initialize`. The envelope always wins
|
||||||
|
# because servers MUST NOT infer identity from prior requests.
|
||||||
|
def client_info
|
||||||
|
return @envelope.client_info if @envelope
|
||||||
|
|
||||||
|
@notification_target.client if @notification_target.respond_to?(:client)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Client capabilities for the current request, with the same envelope-first resolution as {#client_info}.
|
||||||
|
def client_capabilities
|
||||||
|
return @envelope.client_capabilities if @envelope
|
||||||
|
|
||||||
|
@notification_target.client_capabilities if @notification_target.respond_to?(:client_capabilities)
|
||||||
|
end
|
||||||
|
|
||||||
|
# The protocol version the current request was made with. `nil` on legacy requests,
|
||||||
|
# where the version is a session-level negotiation result rather than per-request data.
|
||||||
|
def protocol_version
|
||||||
|
@envelope&.protocol_version
|
||||||
|
end
|
||||||
|
|
||||||
|
# Guards the current request on a declared client capability (SEP-2575). `path` names nested capability keys,
|
||||||
|
# e.g. `require_client_capability!(:elicitation, :form)`. Raises `Server::MissingRequiredClientCapabilityError`
|
||||||
|
# (JSON-RPC error `-32021` with `data: { requiredCapabilities: ... }`) when the capability was not declared.
|
||||||
|
def require_client_capability!(*path)
|
||||||
|
raise ArgumentError, "at least one capability key is required" if path.empty?
|
||||||
|
|
||||||
|
declared = client_capabilities
|
||||||
|
value = path.reduce(declared) do |acc, key|
|
||||||
|
break unless acc.is_a?(Hash)
|
||||||
|
|
||||||
|
symbol_value = acc[key.to_sym]
|
||||||
|
symbol_value.nil? ? acc[key.to_s] : symbol_value
|
||||||
|
end
|
||||||
|
return unless value.nil?
|
||||||
|
|
||||||
|
required = path.reverse.inject({}) { |acc, key| { key.to_sym => acc } }
|
||||||
|
raise Server::MissingRequiredClientCapabilityError, required
|
||||||
|
end
|
||||||
|
|
||||||
# Reports progress for the current tool operation.
|
# Reports progress for the current tool operation.
|
||||||
# The notification is automatically scoped to the originating session.
|
# The notification is automatically scoped to the originating session.
|
||||||
#
|
#
|
||||||
|
|
@ -41,6 +92,14 @@ module MCP
|
||||||
def notify_log_message(data:, level:, logger: nil)
|
def notify_log_message(data:, level:, logger: nil)
|
||||||
return unless @notification_target
|
return unless @notification_target
|
||||||
|
|
||||||
|
# Modern requests opt in to logging per request (SEP-2575): without `io.modelcontextprotocol/logLevel` in `_meta`,
|
||||||
|
# the server MUST NOT send any `notifications/message` for the request, and an insufficient level drops
|
||||||
|
# the message the same way. Session- or server-level gating still applies downstream on delegation.
|
||||||
|
if @envelope
|
||||||
|
threshold = @envelope.log_level && LoggingMessageNotification.new(level: @envelope.log_level)
|
||||||
|
return unless threshold&.valid_level? && threshold.should_notify?(level)
|
||||||
|
end
|
||||||
|
|
||||||
@notification_target.notify_log_message(data: data, level: level, logger: logger, related_request_id: @related_request_id)
|
@notification_target.notify_log_message(data: data, level: level, logger: logger, related_request_id: @related_request_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,22 @@ module MCP
|
||||||
# Holds per-connection state for a single client session.
|
# Holds per-connection state for a single client session.
|
||||||
# Created by the transport layer; delegates request handling to the shared `Server`.
|
# Created by the transport layer; delegates request handling to the shared `Server`.
|
||||||
class ServerSession
|
class ServerSession
|
||||||
|
ERAS = [:legacy, :modern].freeze
|
||||||
|
|
||||||
attr_reader :session_id, :client, :logging_message_notification
|
attr_reader :session_id, :client, :logging_message_notification
|
||||||
|
|
||||||
def initialize(server:, transport:, session_id: nil)
|
# Connection-era lock of the dual-era serving model (SEP-2575): `nil` until the first era-distinctive message succeeds,
|
||||||
|
# then `:legacy` or `:modern` for the connection's lifetime. Modern-era transports construct their per-request sessions
|
||||||
|
# with `era: :modern` up front.
|
||||||
|
attr_reader :era
|
||||||
|
|
||||||
|
def initialize(server:, transport:, session_id: nil, era: nil)
|
||||||
|
validate_era!(era) if era
|
||||||
|
|
||||||
@server = server
|
@server = server
|
||||||
@transport = transport
|
@transport = transport
|
||||||
@session_id = session_id
|
@session_id = session_id
|
||||||
|
@era = era
|
||||||
@client = nil
|
@client = nil
|
||||||
@client_capabilities = nil
|
@client_capabilities = nil
|
||||||
@logging_message_notification = nil
|
@logging_message_notification = nil
|
||||||
|
|
@ -31,6 +41,19 @@ module MCP
|
||||||
# (the initialization phase MUST be the first interaction).
|
# (the initialization phase MUST be the first interaction).
|
||||||
def mark_initialized!
|
def mark_initialized!
|
||||||
@initialized = true
|
@initialized = true
|
||||||
|
# A successful `initialize` is the legacy-distinctive message of the dual-era serving model (SEP-2575),
|
||||||
|
# so it also locks the connection era.
|
||||||
|
@era ||= :legacy
|
||||||
|
end
|
||||||
|
|
||||||
|
# One-shot era lock. Locking the already-locked era is a no-op; flipping an established era raises,
|
||||||
|
# because a connection can never change eras.
|
||||||
|
def lock_era!(era)
|
||||||
|
validate_era!(era)
|
||||||
|
return if @era == era
|
||||||
|
raise "Session era already locked to #{@era}" if @era
|
||||||
|
|
||||||
|
@era = era
|
||||||
end
|
end
|
||||||
|
|
||||||
# Registers a `Cancellation` token for an in-flight request.
|
# Registers a `Cancellation` token for an in-flight request.
|
||||||
|
|
@ -236,6 +259,10 @@ module MCP
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def validate_era!(era)
|
||||||
|
raise ArgumentError, "era must be one of #{ERAS.inspect}" unless ERAS.include?(era)
|
||||||
|
end
|
||||||
|
|
||||||
# Forwards `send_notification` to the transport with only the kwargs the transport's method signature
|
# Forwards `send_notification` to the transport with only the kwargs the transport's method signature
|
||||||
# actually accepts. Custom transports that implement the abstract `send_notification(method, params = nil)`
|
# actually accepts. Custom transports that implement the abstract `send_notification(method, params = nil)`
|
||||||
# contract continue to work unchanged; bundled transports that declare `session_id:` / `related_request_id:`
|
# contract continue to work unchanged; bundled transports that declare `session_id:` / `related_request_id:`
|
||||||
|
|
|
||||||
|
|
@ -311,6 +311,71 @@ module MCP
|
||||||
assert_nothing_raised { server_context.notify_log_message(data: "test", level: "info") }
|
assert_nothing_raised { server_context.notify_log_message(data: "test", level: "info") }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "ServerContext#notify_log_message drops messages on modern requests without a logLevel" do
|
||||||
|
# Per SEP-2575, without `io.modelcontextprotocol/logLevel` in `_meta`, the server MUST NOT send
|
||||||
|
# any `notifications/message` for the request.
|
||||||
|
notification_target = mock
|
||||||
|
notification_target.expects(:notify_log_message).never
|
||||||
|
server_context = build_modern_server_context(notification_target, log_level: nil)
|
||||||
|
|
||||||
|
server_context.notify_log_message(data: "test", level: "error")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "ServerContext#notify_log_message drops messages below the modern request logLevel" do
|
||||||
|
notification_target = mock
|
||||||
|
notification_target.expects(:notify_log_message).never
|
||||||
|
server_context = build_modern_server_context(notification_target, log_level: "error")
|
||||||
|
|
||||||
|
server_context.notify_log_message(data: "test", level: "info")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "ServerContext#notify_log_message delegates messages at or above the modern request logLevel" do
|
||||||
|
notification_target = mock
|
||||||
|
notification_target.expects(:notify_log_message).with(
|
||||||
|
data: "test", level: "error", logger: nil, related_request_id: nil,
|
||||||
|
).once
|
||||||
|
server_context = build_modern_server_context(notification_target, log_level: "warning")
|
||||||
|
|
||||||
|
server_context.notify_log_message(data: "test", level: "error")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "ServerContext exposes envelope data and falls back to the session on legacy requests" do
|
||||||
|
notification_target = mock
|
||||||
|
notification_target.stubs(:client).returns({ name: "legacy_client", version: "1.0" })
|
||||||
|
notification_target.stubs(:client_capabilities).returns({ roots: {} })
|
||||||
|
progress = Progress.new(notification_target: notification_target, progress_token: nil)
|
||||||
|
|
||||||
|
legacy_context = ServerContext.new(nil, progress: progress, notification_target: notification_target)
|
||||||
|
modern_context = build_modern_server_context(notification_target)
|
||||||
|
|
||||||
|
refute_predicate legacy_context, :modern?
|
||||||
|
assert_equal({ name: "legacy_client", version: "1.0" }, legacy_context.client_info)
|
||||||
|
assert_equal({ roots: {} }, legacy_context.client_capabilities)
|
||||||
|
assert_nil legacy_context.protocol_version
|
||||||
|
|
||||||
|
assert_predicate modern_context, :modern?
|
||||||
|
assert_equal({ name: "modern_client", version: "2.0" }, modern_context.client_info)
|
||||||
|
assert_equal({ elicitation: { form: {} } }, modern_context.client_capabilities)
|
||||||
|
assert_equal "2026-07-28", modern_context.protocol_version
|
||||||
|
end
|
||||||
|
|
||||||
|
test "ServerContext#require_client_capability! matches string-keyed capabilities and requires a path" do
|
||||||
|
envelope = RequestEnvelope.new(
|
||||||
|
protocol_version: "2026-07-28",
|
||||||
|
client_info: { name: "c", version: "1" },
|
||||||
|
client_capabilities: { "elicitation" => { "form" => {} } },
|
||||||
|
)
|
||||||
|
progress = Progress.new(notification_target: nil, progress_token: nil)
|
||||||
|
server_context = ServerContext.new(nil, progress: progress, notification_target: nil, envelope: envelope)
|
||||||
|
|
||||||
|
assert_nothing_raised { server_context.require_client_capability!(:elicitation, :form) }
|
||||||
|
assert_raises(ArgumentError) { server_context.require_client_capability! }
|
||||||
|
error = assert_raises(Server::MissingRequiredClientCapabilityError) do
|
||||||
|
server_context.require_client_capability!(:sampling)
|
||||||
|
end
|
||||||
|
assert_equal({ requiredCapabilities: { sampling: {} } }, error.error_data)
|
||||||
|
end
|
||||||
|
|
||||||
test "ServerContext#notify_resources_updated delegates to notification_target" do
|
test "ServerContext#notify_resources_updated delegates to notification_target" do
|
||||||
notification_target = mock
|
notification_target = mock
|
||||||
notification_target.expects(:notify_resources_updated).with(uri: "test://resource-1").once
|
notification_target.expects(:notify_resources_updated).with(uri: "test://resource-1").once
|
||||||
|
|
@ -1000,5 +1065,18 @@ module MCP
|
||||||
assert_equal "User: prompt_user, Trace: trace_xyz789, Message: World",
|
assert_equal "User: prompt_user, Trace: trace_xyz789, Message: World",
|
||||||
response[:result][:messages][0][:content][:text]
|
response[:result][:messages][0][:content][:text]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def build_modern_server_context(notification_target, log_level: nil)
|
||||||
|
envelope = RequestEnvelope.new(
|
||||||
|
protocol_version: "2026-07-28",
|
||||||
|
client_info: { name: "modern_client", version: "2.0" },
|
||||||
|
client_capabilities: { elicitation: { form: {} } },
|
||||||
|
log_level: log_level,
|
||||||
|
)
|
||||||
|
progress = Progress.new(notification_target: notification_target, progress_token: nil)
|
||||||
|
ServerContext.new(nil, progress: progress, notification_target: notification_target, envelope: envelope)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -197,6 +197,17 @@ module MCP
|
||||||
assert_equal "unknown", error.error_data[:requested]
|
assert_equal "unknown", error.error_data[:requested]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "UnsupportedProtocolVersionError accepts a symbol-keyed request Hash on every Ruby version" do
|
||||||
|
# On Ruby 2.7, a keyword parameter in `initialize` would make the trailing symbol-keyed Hash split
|
||||||
|
# into keywords and raise an ArgumentError.
|
||||||
|
request = { name: "echo", arguments: {}, _meta: {} }
|
||||||
|
|
||||||
|
error = Server::UnsupportedProtocolVersionError.new("1900-01-01", request)
|
||||||
|
|
||||||
|
assert_equal "1900-01-01", error.error_data[:requested]
|
||||||
|
assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, error.error_data[:supported]
|
||||||
|
end
|
||||||
|
|
||||||
test "MissingRequiredClientCapabilityError surfaces as -32021 with the SEP-2575 data shape" do
|
test "MissingRequiredClientCapabilityError surfaces as -32021 with the SEP-2575 data shape" do
|
||||||
server = Server.new(name: "error_test", tools: [TestTool])
|
server = Server.new(name: "error_test", tools: [TestTool])
|
||||||
server.define_tool(name: "missing_capability_tool") do
|
server.define_tool(name: "missing_capability_tool") do
|
||||||
|
|
@ -215,6 +226,152 @@ module MCP
|
||||||
assert_equal({ elicitation: {} }, response.dig(:error, :data, :requiredCapabilities))
|
assert_equal({ elicitation: {} }, response.dig(:error, :data, :requiredCapabilities))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "#handle tools/call with a modern envelope exposes per-request client data without touching the session" do
|
||||||
|
server = Server.new(name: "modern_test", tools: [])
|
||||||
|
received = nil
|
||||||
|
server.define_tool(name: "modern_tool") do |server_context:|
|
||||||
|
received = {
|
||||||
|
modern: server_context.modern?,
|
||||||
|
client_info: server_context.client_info,
|
||||||
|
client_capabilities: server_context.client_capabilities,
|
||||||
|
protocol_version: server_context.protocol_version,
|
||||||
|
}
|
||||||
|
Tool::Response.new([{ type: "text", text: "ok" }])
|
||||||
|
end
|
||||||
|
session = ServerSession.new(server: server, transport: mock)
|
||||||
|
|
||||||
|
response = server.handle(
|
||||||
|
modern_request("tools/call", { name: "modern_tool", arguments: {} }, capabilities: { elicitation: {} }),
|
||||||
|
session: session,
|
||||||
|
)
|
||||||
|
|
||||||
|
refute_nil response[:result]
|
||||||
|
assert received[:modern]
|
||||||
|
assert_equal({ name: "modern_client", version: "2.0" }, received[:client_info])
|
||||||
|
assert_equal({ elicitation: {} }, received[:client_capabilities])
|
||||||
|
assert_equal "2026-07-28", received[:protocol_version]
|
||||||
|
# Per SEP-2575, servers MUST NOT infer client state from prior requests, so nothing is stored.
|
||||||
|
assert_nil session.client
|
||||||
|
refute_predicate session, :initialized?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "#handle tools/call with an unsupported envelope version returns -32022" do
|
||||||
|
server = Server.new(name: "modern_test", tools: [])
|
||||||
|
called = false
|
||||||
|
server.define_tool(name: "modern_tool") do
|
||||||
|
called = true
|
||||||
|
Tool::Response.new([{ type: "text", text: "ok" }])
|
||||||
|
end
|
||||||
|
|
||||||
|
response = server.handle(modern_request("tools/call", { name: "modern_tool" }, version: "2027-01-01"))
|
||||||
|
|
||||||
|
refute called
|
||||||
|
assert_equal ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, response.dig(:error, :code)
|
||||||
|
assert_equal Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, response.dig(:error, :data, :supported)
|
||||||
|
assert_equal "2027-01-01", response.dig(:error, :data, :requested)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "#handle treats a partial modern triple as a legacy request" do
|
||||||
|
server = Server.new(name: "modern_test", tools: [])
|
||||||
|
received_context = nil
|
||||||
|
server.define_tool(name: "modern_tool") do |server_context:|
|
||||||
|
received_context = server_context
|
||||||
|
Tool::Response.new([{ type: "text", text: "ok" }])
|
||||||
|
end
|
||||||
|
|
||||||
|
response = server.handle({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method: "tools/call",
|
||||||
|
id: 1,
|
||||||
|
params: {
|
||||||
|
name: "modern_tool",
|
||||||
|
arguments: {},
|
||||||
|
_meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
refute_nil response[:result]
|
||||||
|
refute_predicate received_context, :modern?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "#handle requires the envelope for requests on a modern-locked session" do
|
||||||
|
server = Server.new(name: "modern_test", tools: [])
|
||||||
|
server.define_tool(name: "modern_tool") { Tool::Response.new([{ type: "text", text: "ok" }]) }
|
||||||
|
session = ServerSession.new(server: server, transport: mock, era: :modern)
|
||||||
|
|
||||||
|
response = server.handle(
|
||||||
|
{ jsonrpc: "2.0", method: "tools/call", id: 1, params: { name: "modern_tool" } },
|
||||||
|
session: session,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, response.dig(:error, :code)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "#handle rejects initialize on a modern-locked session with -32022" do
|
||||||
|
session = ServerSession.new(server: @server, transport: mock, era: :modern)
|
||||||
|
|
||||||
|
response = @server.handle(
|
||||||
|
{
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method: "initialize",
|
||||||
|
id: 1,
|
||||||
|
params: { protocolVersion: "2025-11-25", clientInfo: { name: "c", version: "1" } },
|
||||||
|
},
|
||||||
|
session: session,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_equal ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION, response.dig(:error, :code)
|
||||||
|
assert_equal "2025-11-25", response.dig(:error, :data, :requested)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "#handle server/discover succeeds without an envelope on a modern-locked session" do
|
||||||
|
session = ServerSession.new(server: @server, transport: mock, era: :modern)
|
||||||
|
|
||||||
|
response = @server.handle({ jsonrpc: "2.0", method: "server/discover", id: 1 }, session: session)
|
||||||
|
|
||||||
|
refute_nil response[:result]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "#handle tools/call enforces require_client_capability! from the envelope" do
|
||||||
|
server = Server.new(name: "modern_test", tools: [])
|
||||||
|
server.define_tool(name: "guarded_tool") do |server_context:|
|
||||||
|
server_context.require_client_capability!(:elicitation, :form)
|
||||||
|
Tool::Response.new([{ type: "text", text: "ok" }])
|
||||||
|
end
|
||||||
|
|
||||||
|
declared = server.handle(
|
||||||
|
modern_request("tools/call", { name: "guarded_tool" }, capabilities: { elicitation: { form: {} } }),
|
||||||
|
)
|
||||||
|
undeclared = server.handle(modern_request("tools/call", { name: "guarded_tool" }))
|
||||||
|
|
||||||
|
refute_nil declared[:result]
|
||||||
|
assert_equal ErrorCodes::MISSING_REQUIRED_CLIENT_CAPABILITY, undeclared.dig(:error, :code)
|
||||||
|
assert_equal(
|
||||||
|
{ elicitation: { form: {} } },
|
||||||
|
undeclared.dig(:error, :data, :requiredCapabilities),
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "ServerSession locks the legacy era on mark_initialized! and refuses to flip eras" do
|
||||||
|
session = ServerSession.new(server: @server, transport: mock)
|
||||||
|
|
||||||
|
assert_nil session.era
|
||||||
|
session.mark_initialized!
|
||||||
|
assert_equal :legacy, session.era
|
||||||
|
|
||||||
|
session.lock_era!(:legacy)
|
||||||
|
assert_equal :legacy, session.era
|
||||||
|
assert_raises(RuntimeError) { session.lock_era!(:modern) }
|
||||||
|
end
|
||||||
|
|
||||||
|
test "ServerSession accepts a preset era and validates era values" do
|
||||||
|
session = ServerSession.new(server: @server, transport: mock, era: :modern)
|
||||||
|
|
||||||
|
assert_equal :modern, session.era
|
||||||
|
assert_raises(ArgumentError) { ServerSession.new(server: @server, transport: mock, era: :future) }
|
||||||
|
assert_raises(ArgumentError) { session.lock_era!(:future) }
|
||||||
|
end
|
||||||
|
|
||||||
test "#handle initialize request returns protocol info, server info, and capabilities" do
|
test "#handle initialize request returns protocol info, server info, and capabilities" do
|
||||||
request = {
|
request = {
|
||||||
jsonrpc: "2.0",
|
jsonrpc: "2.0",
|
||||||
|
|
@ -3740,5 +3897,23 @@ module MCP
|
||||||
|
|
||||||
refute Apps.client_supports?(server.client_capabilities)
|
refute Apps.client_supports?(server.client_capabilities)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# Builds a request carrying the SEP-2575 modern `_meta` envelope.
|
||||||
|
def modern_request(method, params, version: "2026-07-28", capabilities: {})
|
||||||
|
{
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method: method,
|
||||||
|
id: 1,
|
||||||
|
params: params.merge(
|
||||||
|
_meta: {
|
||||||
|
"io.modelcontextprotocol/protocolVersion": version,
|
||||||
|
"io.modelcontextprotocol/clientInfo": { name: "modern_client", version: "2.0" },
|
||||||
|
"io.modelcontextprotocol/clientCapabilities": capabilities,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue