Skip to content

Handle the SEP-2575 Modern Request Envelope in the Server Core - #475

Open
koic wants to merge 1 commit into
modelcontextprotocol:mainfrom
koic:server_modern_core
Open

Handle the SEP-2575 Modern Request Envelope in the Server Core#475
koic wants to merge 1 commit into
modelcontextprotocol:mainfrom
koic:server_modern_core

Conversation

@koic

@koic koic commented Jul 31, 2026

Copy link
Copy Markdown
Member

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).

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

## 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.
@koic
koic force-pushed the server_modern_core branch from 60b6751 to 5b47292 Compare July 31, 2026 01:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant