Skip to content

Commit 5b47292

Browse files
committed
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 modelcontextprotocol#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.
1 parent 75fffab commit 5b47292

5 files changed

Lines changed: 394 additions & 18 deletions

File tree

lib/mcp/server.rb

Lines changed: 53 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -65,13 +65,15 @@ def initialize(elicitations)
6565
#
6666
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575
6767
class UnsupportedProtocolVersionError < RequestHandlerError
68-
def initialize(requested, request = nil, supported: Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS)
68+
# No keyword parameters here: with one present, Ruby 2.7 would split a trailing symbol-keyed `request` Hash
69+
# into keywords and fail with "unknown keywords".
70+
def initialize(requested, request = nil)
6971
super(
7072
"Unsupported protocol version",
7173
request,
7274
error_type: :unsupported_protocol_version,
7375
error_code: ErrorCodes::UNSUPPORTED_PROTOCOL_VERSION,
74-
error_data: { supported: supported, requested: requested || "unknown" },
76+
error_data: { supported: Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS, requested: requested || "unknown" },
7577
)
7678
end
7779
end
@@ -560,25 +562,26 @@ def handle_request(request, method, session: nil, related_request_id: nil)
560562
server_context: { request: request },
561563
exception_already_reported: ->(e) { reported_exception.equal?(e) },
562564
) do
565+
envelope = lift_request_envelope(params, method: method, session: session)
563566
result = case method
564567
when Methods::INITIALIZE
565568
init(params, session: session)
566569
when Methods::RESOURCES_READ
567-
build_read_resource_result(read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation))
570+
build_read_resource_result(read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope))
568571
when Methods::RESOURCES_SUBSCRIBE, Methods::RESOURCES_UNSUBSCRIBE
569572
validate_resource_subscription_params!(params)
570-
dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation)
573+
dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
571574
{}
572575
when Methods::TOOLS_CALL
573-
call_tool(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
576+
call_tool(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
574577
when Methods::PROMPTS_GET
575-
get_prompt(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
578+
get_prompt(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
576579
when Methods::COMPLETION_COMPLETE
577-
complete(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
580+
complete(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
578581
when Methods::LOGGING_SET_LEVEL
579582
configure_logging_level(params, session: session)
580583
else
581-
dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation)
584+
dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
582585
end
583586
client = session&.client || @client
584587
add_instrumentation_data(client: client) if client
@@ -609,6 +612,34 @@ def handle_request(request, method, session: nil, related_request_id: nil)
609612
}
610613
end
611614

615+
# Lifts the SEP-2575 per-request `_meta` envelope for modern requests. Only a request whose `_meta` carries
616+
# the full required triple is classified as modern; a partial triple keeps flowing through the legacy path untouched.
617+
# Notifications carry no envelope (their `_meta` is a `NotificationMetaObject`), and `server/discover` is
618+
# pre-version discovery, so both are exempt. On a session already era-locked to modern, `initialize` is
619+
# rejected with `-32022` (the modern lifecycle has no handshake) and the triple becomes required for
620+
# every other request.
621+
def lift_request_envelope(params, method:, session:)
622+
return if Methods.notification?(method)
623+
return if method == Methods::SERVER_DISCOVER
624+
625+
modern_session = session.respond_to?(:era) && session.era == :modern
626+
627+
if modern_session && method == Methods::INITIALIZE
628+
requested = params.is_a?(Hash) ? params[:protocolVersion] || params["protocolVersion"] : nil
629+
raise UnsupportedProtocolVersionError.new(requested, params)
630+
end
631+
632+
if RequestEnvelope.modern?(params)
633+
RequestEnvelope.parse!(params, request: params)
634+
elsif modern_session
635+
raise RequestHandlerError.new(
636+
"Invalid Request: modern sessions require the SEP-2575 `_meta` envelope",
637+
params,
638+
error_type: :invalid_request,
639+
)
640+
end
641+
end
642+
612643
def handle_cancelled_notification(params, session: nil)
613644
return unless session
614645
return unless params.is_a?(Hash)
@@ -748,7 +779,7 @@ def list_tools(request)
748779
apply_cache_metadata({ tools: page[:items], nextCursor: page[:next_cursor] }.compact)
749780
end
750781

751-
def call_tool(request, session: nil, related_request_id: nil, cancellation: nil)
782+
def call_tool(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
752783
tool_name = request[:name]
753784

754785
tool = tools[tool_name]
@@ -781,7 +812,7 @@ def call_tool(request, session: nil, related_request_id: nil, cancellation: nil)
781812
progress_token = request.dig(:_meta, :progressToken)
782813

783814
response = call_tool_with_args(
784-
tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session, related_request_id: related_request_id, cancellation: cancellation
815+
tool, arguments, server_context_with_meta(request), progress_token: progress_token, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope
785816
)
786817
result = response.to_h
787818
validate_tool_call_result!(tool, result)
@@ -808,7 +839,7 @@ def list_prompts(request)
808839
apply_cache_metadata({ prompts: page[:items], nextCursor: page[:next_cursor] }.compact)
809840
end
810841

811-
def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil)
842+
def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
812843
prompt_name = request[:name]
813844
prompt = @prompts[prompt_name]
814845
unless prompt
@@ -826,6 +857,7 @@ def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil
826857
session: session,
827858
related_request_id: related_request_id,
828859
cancellation: cancellation,
860+
envelope: envelope,
829861
)
830862

831863
call_prompt_template_with_args(prompt, prompt_args, server_context)
@@ -921,7 +953,7 @@ def apply_cache_metadata(result)
921953
{ ttlMs: @ttl_ms || 0, cacheScope: @cache_scope || "public" }.merge(result)
922954
end
923955

924-
def complete(params, session: nil, related_request_id: nil, cancellation: nil)
956+
def complete(params, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
925957
validate_completion_params!(params)
926958

927959
result = dispatch_optional_context_handler(
@@ -930,6 +962,7 @@ def complete(params, session: nil, related_request_id: nil, cancellation: nil)
930962
session: session,
931963
related_request_id: related_request_id,
932964
cancellation: cancellation,
965+
envelope: envelope,
933966
)
934967

935968
normalize_completion_result(result)
@@ -938,28 +971,30 @@ def complete(params, session: nil, related_request_id: nil, cancellation: nil)
938971
# Invokes `resources/read` via the registered handler. If the handler block opts in to `server_context:`,
939972
# pass an `MCP::ServerContext` so the handler can observe cancellation via `server_context.cancelled?` or
940973
# `server_context.raise_if_cancelled!`.
941-
def read_resource_contents(request, session: nil, related_request_id: nil, cancellation: nil)
974+
def read_resource_contents(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
942975
dispatch_optional_context_handler(
943976
@handlers[Methods::RESOURCES_READ],
944977
request,
945978
session: session,
946979
related_request_id: related_request_id,
947980
cancellation: cancellation,
981+
envelope: envelope,
948982
)
949983
end
950984

951985
# Opt-in `server_context:` dispatch for block-based handlers registered via `resources_read_handler`,
952986
# `completion_handler`, `resources_subscribe_handler`, `resources_unsubscribe_handler`, or `define_custom_method`.
953987
# Existing handlers that only accept `params` are called unchanged; handlers that declare a `server_context:`
954988
# keyword receive an `MCP::ServerContext` wrapping the raw server context with cancellation plumbing.
955-
def dispatch_optional_context_handler(handler, params, session: nil, related_request_id: nil, cancellation: nil)
989+
def dispatch_optional_context_handler(handler, params, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
956990
return handler.call(params) unless handler_declares_server_context?(handler)
957991

958992
server_context = build_server_context(
959993
request: params,
960994
session: session,
961995
related_request_id: related_request_id,
962996
cancellation: cancellation,
997+
envelope: envelope,
963998
)
964999
handler.call(params, server_context: server_context)
9651000
end
@@ -984,7 +1019,7 @@ def handler_declares_server_context?(handler)
9841019

9851020
# Builds an `MCP::ServerContext` used to give a handler access to session-scoped helpers
9861021
# (progress, cancellation, nested server-to-client requests).
987-
def build_server_context(request:, session:, related_request_id:, cancellation:)
1022+
def build_server_context(request:, session:, related_request_id:, cancellation:, envelope: nil)
9881023
meta_source = request.is_a?(Hash) ? request : {}
9891024
progress_token = meta_source.dig(:_meta, :progressToken)
9901025
progress = Progress.new(notification_target: session, progress_token: progress_token, related_request_id: related_request_id)
@@ -994,6 +1029,7 @@ def build_server_context(request:, session:, related_request_id:, cancellation:)
9941029
notification_target: session,
9951030
related_request_id: related_request_id,
9961031
cancellation: cancellation,
1032+
envelope: envelope,
9971033
)
9981034
end
9991035

@@ -1053,7 +1089,7 @@ def accepts_server_context?(method_object)
10531089
end
10541090
end
10551091

1056-
def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil, cancellation: nil)
1092+
def call_tool_with_args(tool, arguments, context, progress_token: nil, session: nil, related_request_id: nil, cancellation: nil, envelope: nil)
10571093
# Transports parse incoming JSON with `symbolize_names: true`, so `arguments` already arrives symbolized
10581094
# at every nesting level. This top-level transform only guards callers that hand in string-keyed top-level arguments;
10591095
# it does not recurse, and nested object keys remain symbols. Tools therefore receive symbol keys all the way down.
@@ -1068,6 +1104,7 @@ def call_tool_with_args(tool, arguments, context, progress_token: nil, session:
10681104
notification_target: session,
10691105
related_request_id: related_request_id,
10701106
cancellation: cancellation,
1107+
envelope: envelope,
10711108
)
10721109
tool.call(**args, server_context: server_context)
10731110
else

lib/mcp/server_context.rb

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,17 @@ module MCP
44
class ServerContext
55
attr_reader :cancellation
66

7-
def initialize(context, progress:, notification_target:, related_request_id: nil, cancellation: nil)
7+
# The SEP-2575 per-request envelope (`MCP::RequestEnvelope`) when the request was classified as modern;
8+
# `nil` on legacy requests.
9+
attr_reader :envelope
10+
11+
def initialize(context, progress:, notification_target:, related_request_id: nil, cancellation: nil, envelope: nil)
812
@context = context
913
@progress = progress
1014
@notification_target = notification_target
1115
@related_request_id = related_request_id
1216
@cancellation = cancellation
17+
@envelope = envelope
1318
end
1419

1520
def cancelled?
@@ -20,6 +25,52 @@ def raise_if_cancelled!
2025
@cancellation&.raise_if_cancelled!
2126
end
2227

28+
# Whether the current request follows the stateless modern lifecycle (SEP-2575).
29+
def modern?
30+
!@envelope.nil?
31+
end
32+
33+
# Client identity for the current request. Modern requests carry it in the `_meta` envelope;
34+
# legacy sessions fall back to the state stored by `initialize`. The envelope always wins
35+
# because servers MUST NOT infer identity from prior requests.
36+
def client_info
37+
return @envelope.client_info if @envelope
38+
39+
@notification_target.client if @notification_target.respond_to?(:client)
40+
end
41+
42+
# Client capabilities for the current request, with the same envelope-first resolution as {#client_info}.
43+
def client_capabilities
44+
return @envelope.client_capabilities if @envelope
45+
46+
@notification_target.client_capabilities if @notification_target.respond_to?(:client_capabilities)
47+
end
48+
49+
# The protocol version the current request was made with. `nil` on legacy requests,
50+
# where the version is a session-level negotiation result rather than per-request data.
51+
def protocol_version
52+
@envelope&.protocol_version
53+
end
54+
55+
# Guards the current request on a declared client capability (SEP-2575). `path` names nested capability keys,
56+
# e.g. `require_client_capability!(:elicitation, :form)`. Raises `Server::MissingRequiredClientCapabilityError`
57+
# (JSON-RPC error `-32021` with `data: { requiredCapabilities: ... }`) when the capability was not declared.
58+
def require_client_capability!(*path)
59+
raise ArgumentError, "at least one capability key is required" if path.empty?
60+
61+
declared = client_capabilities
62+
value = path.reduce(declared) do |acc, key|
63+
break unless acc.is_a?(Hash)
64+
65+
symbol_value = acc[key.to_sym]
66+
symbol_value.nil? ? acc[key.to_s] : symbol_value
67+
end
68+
return unless value.nil?
69+
70+
required = path.reverse.inject({}) { |acc, key| { key.to_sym => acc } }
71+
raise Server::MissingRequiredClientCapabilityError, required
72+
end
73+
2374
# Reports progress for the current tool operation.
2475
# The notification is automatically scoped to the originating session.
2576
#
@@ -41,6 +92,14 @@ def report_progress(progress, total: nil, message: nil)
4192
def notify_log_message(data:, level:, logger: nil)
4293
return unless @notification_target
4394

95+
# Modern requests opt in to logging per request (SEP-2575): without `io.modelcontextprotocol/logLevel` in `_meta`,
96+
# the server MUST NOT send any `notifications/message` for the request, and an insufficient level drops
97+
# the message the same way. Session- or server-level gating still applies downstream on delegation.
98+
if @envelope
99+
threshold = @envelope.log_level && LoggingMessageNotification.new(level: @envelope.log_level)
100+
return unless threshold&.valid_level? && threshold.should_notify?(level)
101+
end
102+
44103
@notification_target.notify_log_message(data: data, level: level, logger: logger, related_request_id: @related_request_id)
45104
end
46105

lib/mcp/server_session.rb

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,22 @@ module MCP
77
# Holds per-connection state for a single client session.
88
# Created by the transport layer; delegates request handling to the shared `Server`.
99
class ServerSession
10+
ERAS = [:legacy, :modern].freeze
11+
1012
attr_reader :session_id, :client, :logging_message_notification
1113

12-
def initialize(server:, transport:, session_id: nil)
14+
# Connection-era lock of the dual-era serving model (SEP-2575): `nil` until the first era-distinctive message succeeds,
15+
# then `:legacy` or `:modern` for the connection's lifetime. Modern-era transports construct their per-request sessions
16+
# with `era: :modern` up front.
17+
attr_reader :era
18+
19+
def initialize(server:, transport:, session_id: nil, era: nil)
20+
validate_era!(era) if era
21+
1322
@server = server
1423
@transport = transport
1524
@session_id = session_id
25+
@era = era
1626
@client = nil
1727
@client_capabilities = nil
1828
@logging_message_notification = nil
@@ -31,6 +41,19 @@ def initialized?
3141
# (the initialization phase MUST be the first interaction).
3242
def mark_initialized!
3343
@initialized = true
44+
# A successful `initialize` is the legacy-distinctive message of the dual-era serving model (SEP-2575),
45+
# so it also locks the connection era.
46+
@era ||= :legacy
47+
end
48+
49+
# One-shot era lock. Locking the already-locked era is a no-op; flipping an established era raises,
50+
# because a connection can never change eras.
51+
def lock_era!(era)
52+
validate_era!(era)
53+
return if @era == era
54+
raise "Session era already locked to #{@era}" if @era
55+
56+
@era = era
3457
end
3558

3659
# Registers a `Cancellation` token for an in-flight request.
@@ -236,6 +259,10 @@ def notify_log_message(data:, level:, logger: nil, related_request_id: nil)
236259

237260
private
238261

262+
def validate_era!(era)
263+
raise ArgumentError, "era must be one of #{ERAS.inspect}" unless ERAS.include?(era)
264+
end
265+
239266
# Forwards `send_notification` to the transport with only the kwargs the transport's method signature
240267
# actually accepts. Custom transports that implement the abstract `send_notification(method, params = nil)`
241268
# contract continue to work unchanged; bundled transports that declare `session_id:` / `related_request_id:`

0 commit comments

Comments
 (0)