ruby-sdk/lib/mcp/server_context.rb
Koichi ITO 5b4729294c Handle the SEP-2575 Modern Request Envelope in the Server Core
## Motivation and Context

Second step of the stateless lifecycle (SEP-2575, modelcontextprotocol/modelcontextprotocol#2575)
for the 2026-07-28 MCP spec release, building on the `MCP::RequestEnvelope` foundations.

`Server#handle_request` now lifts the per-request `_meta` envelope before dispatch: a request
whose `_meta` carries the full required triple is validated (raising `-32022` with
`data: { supported:, requested: }` for unsupported versions) and its envelope is threaded into
`tools/call`, `prompts/get`, `completion/complete`, `resources/read`, subscribe/unsubscribe,
and custom method handlers via `MCP::ServerContext`. A partial triple keeps flowing through
the legacy path untouched, so existing `_meta` usage (`progressToken`, trace context) is unaffected.

`MCP::ServerContext` gains per-request readers and a capability guard:

- `modern?`, `protocol_version`, `client_info`, and `client_capabilities` read from the envelope first
  and fall back to session state stored by `initialize` on legacy requests. The envelope always wins
  because servers MUST NOT infer client state from prior requests; nothing is written to the session
  on the modern path.
- `require_client_capability!(*path)` raises `Server::MissingRequiredClientCapabilityError`
  (`-32021` with `data: { requiredCapabilities: }`) when the request did not declare the capability.
- `notify_log_message` honors the per-request `io.modelcontextprotocol/logLevel`: on modern requests without it,
  the server MUST NOT send `notifications/message`, and an insufficient level drops the message the same way.

`MCP::ServerSession` gains the connection-era lock of the dual-era serving model: `era` is `nil`
until the first era-distinctive message succeeds, a successful `initialize` locks `:legacy` as
a side effect of `mark_initialized!`, `lock_era!` refuses to flip an established era, and transports can construct
per-request sessions with `era: :modern`. On a modern-locked session, `initialize` is rejected with `-32022`
(the modern lifecycle has no handshake) and the envelope triple becomes required for every other request
except `server/discover`.

The existing plain `RuntimeError` raises in `ServerSession#list_roots` and friends are intentionally unchanged:
converting them to `RequestHandlerError` subclasses would break callers rescuing `RuntimeError`.
Typed `-32021` errors are scoped to the new envelope-based guard.

Refs #389.

## How Has This Been Tested?

New tests in `test/mcp/server_test.rb` cover the wire behavior through `Server#handle`: envelope data exposure
without session mutation, `-32022` for unsupported envelope versions, partial triples staying legacy,
the envelope requirement and `initialize` rejection on modern-locked sessions, the `server/discover` exemption,
`require_client_capability!` returning `-32021` with the `requiredCapabilities` data shape,
and the `ServerSession` era-lock transitions (including preset `era: :modern` and invalid values).

New tests in `test/mcp/server_context_test.rb` cover the per-request logLevel gate (absent, insufficient,
and sufficient levels), the envelope-first/session-fallback readers, and string-keyed capability matching
in `require_client_capability!`.

## Breaking Changes

None. All new keyword arguments default to `nil`, legacy requests take exactly the same code path as before,
and the era lock only constrains sequences that were previously impossible (modern-era traffic).

Fix a Ruby 2.7 ArgumentError When Raising UnsupportedProtocolVersionError

## Motivation and Context

On Ruby 2.7, a method that declares a keyword parameter splits a trailing
symbol-keyed Hash positional argument into keywords. `UnsupportedProtocolVersionError#initialize`
declared `supported:`, so passing the request Hash as the second positional argument raised
`ArgumentError: unknown keywords: :name, :arguments, :_meta` inside the error constructor,
and the intended `-32022` response surfaced as a `-32603` internal error on the Ruby 2.7 CI job.

No caller overrides `supported:`; drop the keyword parameter and read
`Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS` directly, matching the keyword-free
shape of `ResourceNotFoundError` and `MissingRequiredClientCapabilityError`.

## How Has This Been Tested?

- Reproduced the `-32603` responses on Ruby 2.7.8 and confirmed both now return `-32022`
- Added a regression test constructing the error with a symbol-keyed request Hash
- `bundle exec rake test` on Ruby 2.7.8: 1467 runs, 0 failures

## Breaking Changes

None. The `supported:` keyword argument was never passed by any caller.
2026-07-31 10:16:41 +09:00

231 lines
10 KiB
Ruby

# frozen_string_literal: true
module MCP
class ServerContext
attr_reader :cancellation
# 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
@progress = progress
@notification_target = notification_target
@related_request_id = related_request_id
@cancellation = cancellation
@envelope = envelope
end
def cancelled?
!!@cancellation&.cancelled?
end
def raise_if_cancelled!
@cancellation&.raise_if_cancelled!
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.
# The notification is automatically scoped to the originating session.
#
# @param progress [Numeric] Current progress value.
# @param total [Numeric, nil] Total expected value.
# @param message [String, nil] Human-readable status message.
def report_progress(progress, total: nil, message: nil)
@progress.report(progress, total: total, message: message)
end
# Sends a log message notification scoped to the originating session.
#
# @param data [Object] The log data to send.
# @param level [String] Log level (e.g., `"debug"`, `"info"`, `"error"`).
# @param logger [String, nil] Logger name.
# @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)
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)
end
# Sends a resource updated notification scoped to the originating session.
#
# @param uri [String] The URI of the updated resource.
def notify_resources_updated(uri:)
return unless @notification_target
@notification_target.notify_resources_updated(uri: uri)
end
# Delegates to the session so the request is scoped to the originating client.
# The originating request id is stamped as `related_request_id`, satisfying
# SEP-2260's requirement that server-to-client requests be associated with
# the client request being processed.
# @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 list_roots
if @notification_target.respond_to?(:list_roots)
@notification_target.list_roots(related_request_id: @related_request_id)
else
raise NoMethodError, "undefined method 'list_roots' for #{self}"
end
end
# Sends a `ping` request to the originating client to verify it is still responsive.
# Per the MCP spec, the client MUST respond promptly with an empty result.
#
# @return [Hash] An empty hash on success.
# @raise [Server::ValidationError] If the response `result` is not a Hash.
# @raise [NoMethodError] If the session does not support sending pings.
#
# @example
# def self.call(server_context:)
# server_context.ping # => {}
# # ...
# end
#
# @see https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping
def ping
if @notification_target.respond_to?(:ping)
@notification_target.ping(related_request_id: @related_request_id)
else
raise NoMethodError, "undefined method 'ping' for #{self}"
end
end
# Delegates to the session so the request is scoped to the originating client.
# Falls back to `@context` (via `method_missing`) when `@notification_target`
# does not support sampling.
#
# The originating request id is stamped as `related_request_id` and cannot be
# overridden by callers (the literal keyword after `**kwargs` wins),
# satisfying SEP-2260's requirement that server-to-client requests be
# associated with the client request being processed.
# @deprecated MCP Sampling (`sampling/createMessage`) is deprecated as of
# MCP protocol version 2026-07-28 (SEP-2577). Use direct LLM provider
# APIs instead.
def create_sampling_message(**kwargs)
if @notification_target.respond_to?(:create_sampling_message)
@notification_target.create_sampling_message(**kwargs, related_request_id: @related_request_id)
elsif @context.respond_to?(:create_sampling_message)
@context.create_sampling_message(**kwargs, related_request_id: @related_request_id)
else
raise NoMethodError, "undefined method 'create_sampling_message' for #{self}"
end
end
# Delegates to the session so the request is scoped to the originating client.
# Falls back to `@context` (via `method_missing`) when `@notification_target`
# does not support elicitation.
# The originating request id is stamped as a non-overridable
# `related_request_id`, as with `create_sampling_message` (SEP-2260).
def create_form_elicitation(**kwargs)
if @notification_target.respond_to?(:create_form_elicitation)
@notification_target.create_form_elicitation(**kwargs, related_request_id: @related_request_id)
elsif @context.respond_to?(:create_form_elicitation)
@context.create_form_elicitation(**kwargs, related_request_id: @related_request_id)
else
raise NoMethodError, "undefined method 'create_form_elicitation' for #{self}"
end
end
# Delegates to the session so the request is scoped to the originating client.
# Falls back to `@context` when `@notification_target` does not support URL mode elicitation.
# The originating request id is stamped as a non-overridable `related_request_id`,
# as with `create_sampling_message` (SEP-2260).
def create_url_elicitation(**kwargs)
if @notification_target.respond_to?(:create_url_elicitation)
@notification_target.create_url_elicitation(**kwargs, related_request_id: @related_request_id)
elsif @context.respond_to?(:create_url_elicitation)
@context.create_url_elicitation(**kwargs, related_request_id: @related_request_id)
else
raise NoMethodError, "undefined method 'create_url_elicitation' for #{self}"
end
end
# Delegates to the session so the notification is scoped to the originating client.
def notify_elicitation_complete(**kwargs)
if @notification_target.respond_to?(:notify_elicitation_complete)
@notification_target.notify_elicitation_complete(**kwargs)
elsif @context.respond_to?(:notify_elicitation_complete)
@context.notify_elicitation_complete(**kwargs)
else
raise NoMethodError, "undefined method 'notify_elicitation_complete' for #{self}"
end
end
# Forward arguments explicitly with `*args, **kwargs, &block` rather than the `...` forwarding syntax.
# The gem supports Ruby 2.7.0 (see `required_ruby_version`), but RuboCop's Parser backend only runs on Ruby 2.7.8,
# so leading-argument forwarding like `def method_missing(name, ...)` is allowed by the linter even though it
# raises a `SyntaxError` on Ruby 2.7.0 through 2.7.2 (it was added in Ruby 2.7.3). Explicit forwarding keeps
# this method loadable on Ruby 2.7.0.
def method_missing(name, *args, **kwargs, &block)
if @context.respond_to?(name)
@context.public_send(name, *args, **kwargs, &block)
else
super
end
end
def respond_to_missing?(name, include_private = false)
@context.respond_to?(name) || super
end
end
end