ruby-sdk/lib/mcp/server_context.rb
Koichi ITO 6ddcc49865 Associate server-to-client requests with the originating client request per SEP-2260
## Motivation and Context

SEP-2260 (modelcontextprotocol/modelcontextprotocol#2260, merged for the 2026-07-28 spec release)
requires servers to send `roots/list`, `sampling/createMessage`, and `elicitation/create` only in
association with an originating client request (`ping` is exempt); standalone server-initiated requests
on independent streams must not be implemented. On the Streamable HTTP transport, an associated request
rides the originating POST's SSE response stream instead of the standalone GET stream.

The Ruby SDK's `ServerContext` already satisfies the requirement for handler-scoped calls:
`list_roots`, `create_sampling_message`, `create_form_elicitation`, and `create_url_elicitation` stamp
the originating request id as `related_request_id`, and because the literal keyword appears after `**kwargs`,
a caller-supplied `related_request_id:` is overridden. That behavior was previously untested and undocumented.
This change pins it down and adds a migration signal for direct `ServerSession` calls, mirroring
the TypeScript SDK's approach (typescript-sdk#2228, non-overridable `relatedRequestId` stamping on ctx-scoped requests):

- `ServerContext` documents the non-overridable stamping, and new tests lock it in for all four request helpers.
- `ServerSession#list_roots`, `#create_sampling_message`, `#create_form_elicitation`, and `#create_url_elicitation`
  emit a deprecation warning when called without `related_request_id:` (the call still goes out unchanged,
  on the GET stream for Streamable HTTP). `ServerSession#ping` is exempt per the SEP. Hard enforcement is
  deferred to a future protocol-version gate for 2026-07-28.
- A new transport test verifies that `send_request` with `related_request_id` delivers on the originating POST stream
  and never falls back to the GET stream.

Resolves #381.

## How Has This Been Tested?

- `test/mcp/server_context_test.rb`: `list_roots` stamps the originating request id; `create_sampling_message`,
  `create_form_elicitation`, and `create_url_elicitation` stamp it non-overridably (a caller-supplied `related_request_id:
  "attacker"` is replaced).
- `test/mcp/server_roots_test.rb`, `test/mcp/server_sampling_test.rb`, `test/mcp/server_elicitation_test.rb`:
  each `ServerSession` method warns (matching /SEP-2260/) without `related_request_id:` and stays silent with it.
  The warning assertions temporarily set `$VERBOSE = false` because the rake test task runs with `-W0`, following
  the existing `assert_implicit_connect_deprecation_warning` precedent.
- `test/mcp/server/transports/streamable_http_transport_test.rb`: a `roots/list` request with `related_request_id`
  is written to the registered POST request stream and not to the GET SSE stream.
- Existing direct-call test sites were updated to pass `related_request_id:` (or capture the warning) so the suite output
  stays clean; their assertions are unchanged.

## Breaking Changes

None. Behavior is unchanged; direct `ServerSession` calls without `related_request_id:` now emit a deprecation warning
but are still sent exactly as before.
2026-07-12 02:10:16 +09:00

172 lines
7.5 KiB
Ruby

# frozen_string_literal: true
module MCP
class ServerContext
attr_reader :cancellation
def initialize(context, progress:, notification_target:, related_request_id: nil, cancellation: nil)
@context = context
@progress = progress
@notification_target = notification_target
@related_request_id = related_request_id
@cancellation = cancellation
end
def cancelled?
!!@cancellation&.cancelled?
end
def raise_if_cancelled!
@cancellation&.raise_if_cancelled!
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
@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