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.
This commit is contained in:
parent
75fffab8c8
commit
5b4729294c
1 changed files with 394 additions and 18 deletions
|
|
@ -7,12 +7,22 @@ module MCP
|
|||
# Holds per-connection state for a single client session.
|
||||
# Created by the transport layer; delegates request handling to the shared `Server`.
|
||||
class ServerSession
|
||||
ERAS = [:legacy, :modern].freeze
|
||||
|
||||
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
|
||||
@transport = transport
|
||||
@session_id = session_id
|
||||
@era = era
|
||||
@client = nil
|
||||
@client_capabilities = nil
|
||||
@logging_message_notification = nil
|
||||
|
|
@ -31,6 +41,19 @@ module MCP
|
|||
# (the initialization phase MUST be the first interaction).
|
||||
def mark_initialized!
|
||||
@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
|
||||
|
||||
# Registers a `Cancellation` token for an in-flight request.
|
||||
|
|
@ -236,6 +259,10 @@ module MCP
|
|||
|
||||
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
|
||||
# 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:`
|
||||
|
|
|
|||
Loading…
Reference in a new issue