## Motivation and Context
Companion to the stateless lifecycle work (SEP-2575, modelcontextprotocol/modelcontextprotocol#2575) for
the 2026-07-28 MCP spec release. The modern lifecycle removed the HTTP GET listening stream;
`subscriptions/listen` replaces it as a long-lived POST that opts in to server change notifications.
The Python SDK ships the server side of this as its SEP-2575 event-bus work (python-sdk PR /modelcontextprotocol/python-sdk#3035);
the TypeScript SDK landed it in PR modelcontextprotocol/typescript-sdk#2321.
Wire behavior, per the draft schema:
- The request carries a REQUIRED `notifications` filter (`SubscriptionFilter`): `toolsListChanged`,
`promptsListChanged`, `resourcesListChanged`, and `resourceSubscriptions` (URI list, replacing
the legacy `resources/subscribe` RPC). Every type is opt-in; the server MUST NOT send types
the client did not request.
- The first stream message is `notifications/subscriptions/acknowledged`, reporting the subset of
requested types the server agreed to honor. Honoring reads the capability FLAGS that promise delivery
(`listChanged`, `subscribe`), the same derivation `server/discover` uses for its era-aware capability stripping;
the mere presence of a primitive's capability is not enough.
- Every notification delivered on the stream carries the correlating `io.modelcontextprotocol/subscriptionId`
(the listen request id) in `_meta`.
- A graceful teardown (transport `close`) sends a `SubscriptionsListenResult` response before closing the stream,
stamped with the REQUIRED 2026-07-28 `resultType` at its construction site (it never passes through the dispatch path);
an abrupt disconnect sends nothing.
Implementation:
- `StreamableHTTPTransport` intercepts `subscriptions/listen` on the modern path (after header and envelope validation)
and serves it as a long-lived SSE stream, using the same register-and-return body proc pattern as the legacy GET stream.
Subscriptions live in an in-process registry keyed by the listen request id; fan-out hooks into `send_notification` ahead of
the legacy delivery, so a resource updated by one session's tool call also reaches modern subscribers.
The matching snapshot is taken under the transport mutex, but stream writes happen outside it,
matching the legacy delivery paths: a slow or stalled subscriber must not block the transport.
Duplicate subscription ids close the new stream instead of double-registering.
- Concurrent listen streams are capped (`max_listen_subscriptions:`, default 1000, `nil` to opt out);
a listen request past the cap is rejected with HTTP 503, like the `max_sessions` guard against session floods.
Each stream holds an open connection for its lifetime, so without a bound an unauthenticated client can retain
unbounded connections.
- Each stream is kept alive by an SSE comment frame written every `listen_keepalive_interval:` seconds
(default 15, matching the TypeScript SDK; `nil` to opt out when an upstream proxy pings the stream).
A silently dropped connection would otherwise hold its capped slot until the next fan-out write failed,
so on a quiet server the cap would ratchet down permanently; the periodic write detects the dead peer
and frees the slot. A comment frame cannot corrupt an interleaved notification's JSON, and the write
happens outside the transport mutex, reusing the legacy GET stream's keepalive mechanism.
- Every SSE response now carries `x-accel-buffering: no`, which the spec asks of SSE streams and both
reference SDKs send: a buffering reverse proxy would hold events back instead of delivering them as
they are written, and on a listen stream it would also swallow the keepalive frames a dropped peer
is detected by. The header rides the shared `SSE_HEADERS`, so the legacy streams gain it too.
- stdio does not serve the stream, matching the Python SDK's stream-pair behavior: no server handler is registered,
so the method answers `-32601`.
- `Server#discover` becomes era-aware about notification delivery: `listChanged`/`subscribe` capability flags
promise delivery over `subscriptions/listen` streams in the modern lifecycle, so they are stripped when
the transport does not serve that RPC (the new `Transport#serves_subscriptions_listen?` seam, true for `StreamableHTTPTransport`).
- The conformance fixture defines the diagnostic triggers the `server-stateless` scenario calls
(`test_trigger_tool_change` / `test_trigger_prompt_change`): each broadcasts its list-changed notification
to the listen streams and returns, mirroring the suite's TypeScript reference fixture, which mutates nothing either.
Out of scope, noted for follow-ups: an external event bus for multi-worker deployments, and the client-side listen driver
(the Python reference is python-sdk PR modelcontextprotocol/python-sdk#3047).
Refs #389.
## How Has This Been Tested?
New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` cover:
the SSE response with the acknowledgement as the first event (including the honored-subset reduction for unsupported types and
for capability entries lacking the delivery flag), the `notifications` filter and envelope requirements (400 responses),
opt-in-only delivery with the correlating `subscriptionId`, URI-scoped `resources/updated` delivery, per-subscription ids across
concurrent streams, the graceful close result carrying `resultType: "complete"`, duplicate-id rejection, the 503 past
the concurrent stream cap, the keepalive (writing the comment frame outside the mutex, freeing a dead peer's slot,
no thread when the interval is `nil`, and the positive-or-nil interval validation), and `x-accel-buffering: no`
on both the listen stream and the legacy GET stream.
`test/mcp/server/transports/stdio_transport_test.rb` asserts `-32601` over stdio,
and `test/mcp/server_test.rb` covers the era-aware capability stripping in `server/discover` for both transport kinds.
`bundle exec rake` (tests, RuboCop, and conformance baseline) passes.
Against the conformance fixture server at `--spec-version 2026-07-28`, the `server-stateless` subscription checks
all report SUCCESS: the acknowledgement, `subscriptionId` tagging, and filter-containment MUSTs, plus both
list-changed SHOULD checks driven by the new trigger tools. The `--requirements 2025-11-25` server leg
passes 78/78, unchanged.
## Breaking Changes
None. The method was previously unhandled (`-32601` everywhere); the only observable change to existing responses is that
`server/discover` no longer advertises `listChanged`/`subscribe` flags on transports that cannot deliver those notifications in
the modern lifecycle, which `server/discover` has not shipped in a gem release with anyway.
## Motivation and Context
The 2026-07-28 conformance requirements reject two admission gaps in the modern (stateless) lifecycle
that the current implementation answers with the wrong signal:
- The RPCs the modern lifecycle removes (`initialize`, `ping`, `logging/setLevel`, `resources/subscribe`, `resources/unsubscribe`)
were still dispatched, or answered incidental errors (`initialize` answered `-32022`, `resources/subscribe` a capability error);
the requirement is `-32601` Method not found with HTTP 404.
- `server/discover` was fully envelope-exempt, while the requirements validate its envelope
like every other modern request.
The per-request `_meta` validation itself (`-32602` naming the offending keys, optional `clientInfo`,
era classification on the reserved `protocolVersion` key alone) landed separately in #491;
this change builds on that shape and completes the admission rules:
- `Server#handle_request` rejects `Methods::MODERN_REMOVED_METHODS` on a modern-era session
with `-32601` before the capability check, since the method does not exist in that era at all.
The Streamable HTTP modern path maps `-32601` to 404 and `-32602` to 400 through its existing status ladder.
- The dual-era sniff treats an `initialize` carrying the modern envelope as modern traffic (a modern client naming a removed method)
instead of the legacy-distinctive handshake, and `server/discover` keeps its envelope exemption outside the modern era only.
The envelope-less rejection on a modern-locked session adopts #491's wire shape, `-32602` with the detailed message.
One deliberate divergence from the Python SDK: its dual-era stream loop answers an `initialize` on
a modern-locked connection with `-32022` carrying the supported-version list, while its HTTP path serves
the 404/-32601 shape. The conformance requirement phrases removed methods transport-independently,
so this SDK answers `-32601` on every transport, stdio included, for uniform era semantics.
The stdio era-lock tests used a modern `ping` as the era-distinctive request; they now use `tools/list`,
since `ping` no longer exists in the modern lifecycle.
## How Has This Been Tested?
`bundle exec rake test` passes with zero failures and RuboCop reports no offenses.
New and updated tests cover each admission rule on `Server#handle`, the Streamable HTTP modern path, and the stdio era lock
(all five removed methods per transport, the envelope requirement on `server/discover`, and the envelope-carrying `initialize` routing).
Against the conformance fixture server, every admission check of the `server-stateless` scenario passes at
`--spec-version 2026-07-28`: the per-request `_meta` validation, removed-method 404, discovery, capability,
and version-negotiation checks are all green, and the only remaining failures are the `subscriptions/listen`
checks that belong to separate work. The `--requirements 2025-11-25` server leg passes 78/78, unchanged.
## Breaking Changes
None for stable protocol versions: legacy requests are byte-for-byte unchanged, and a bare legacy `_meta`
(`progressToken`, trace context) keeps its legacy classification. Within the modern lifecycle,
previously incidental error shapes are replaced by the ones the 2026-07-28 conformance requirements mandate.
## Motivation and Context
SEP-2575 (modelcontextprotocol/modelcontextprotocol#2575, merged for the 2026-07-28 spec release)
makes MCP stateless, removing the `initialize`/`notifications/initialized` handshake in favor of
per-request `_meta`-carried negotiation. The full lifecycle rewrite is unmerged in both
the TypeScript SDK (the closed #2063/#2064/#2251 prototype stack, on hold since 2026-06-08) and
the Python SDK, but two pieces of its wire contract stayed stable across every prototype iteration
and are purely additive:
- The `server/discover` method, which servers MUST implement: sessionless capability discovery
returning `supportedVersions`, `capabilities`, `serverInfo`, and `instructions`.
The new `Server#discover` handler reuses the same data `init` already builds, but is state-free
and idempotent: it stores no client info, never marks a session initialized, and responds regardless
of declared capabilities or initialization state (added to the no-capability group in `Methods.ensure_capability!`).
`serverInfo` is returned unfiltered because discovery precedes version negotiation;
the draft's `ttlMs`/`cacheScope` fields on `DiscoverResult` are left to the SEP-2549 work.
On `StreamableHTTPTransport`, a `server/discover` POST is exempt from the `Mcp-Session-Id` requirement
and the `MCP-Protocol-Version` header check, like `initialize`, so clients can probe a server with
a bare POST in both stateful and stateless modes.
- The error code vocabulary: the new `MCP::ErrorCodes` module exports `MISSING_REQUIRED_CLIENT_CAPABILITY = -32003`
and `UNSUPPORTED_PROTOCOL_VERSION = -32004`. Constants only; nothing raises them yet
(that belongs to the 2026-07-28 lifecycle work), and the existing inline `-32042` of
`URLElicitationRequiredError` is intentionally left untouched.
The removal of `initialize`, `ping`, and `logging/setLevel` that SEP-2575 also specifies is
deliberately NOT implemented: the SDK targets protocol versions up to 2025-11-25,
where those methods are required.
Part of #389.
## How Has This Been Tested?
- `test/mcp/server_test.rb`: `server/discover` returns the supported versions, the server's capabilities,
server info, and instructions; it responds before `initialize` and with empty capabilities;
it does not mark the session initialized (a subsequent `initialize` still succeeds);
`instructions` is omitted when the server has none; and `define_custom_method(method_name: "server/discover")`
now raises `MethodAlreadyDefinedError`.
- `test/mcp/methods_test.rb`: `ensure_capability!` does not raise for `SERVER_DISCOVER` with empty capabilities.
- `test/mcp/server/transports/streamable_http_transport_test.rb`: a `server/discover` POST without a session ID
returns 200 with the discover result in both stateful and stateless modes, without issuing an `Mcp-Session-Id`.
- New `test/mcp/error_codes_test.rb` pins the -32003/-32004 values.
`bundle exec rake` (tests, RuboCop, and conformance baseline) passes.
## Breaking Changes
None at the wire level for existing clients. One narrow API consequence: servers that previously registered
their own `"server/discover"` handler via `define_custom_method` now get `MethodAlreadyDefinedError` because
the method is built in.
## Motivation and Context
A JSON-RPC notification carries no id and must not receive any response.
When a client erroneously sent a notification-only method such as `notifications/cancelled` with an id,
the server replied with a successful `result: null`, which is neither a valid MCP result nor
a legal reply to a notification.
Such a message is now treated as a request for a method that has no request handler and rejected with
Method not found, matching the behavior of the TypeScript and Python SDKs. A well-formed notification
that omits the id continues to receive no response.
Fixes#434.
## How Has This Been Tested?
Added regression tests covering id-bearing `notifications/cancelled`, `notifications/initialized`,
and `notifications/progress`, a batch that mixes an id-bearing notification with other messages,
custom `notifications/*` methods, and the same case over the HTTP transport.
## Breaking Changes
None for spec-conformant clients, which never put an id on a notification.
The only behavioral change is that a malformed id-bearing notification now receives a Method not found error
instead of a spurious `result: null`.
## Motivation and Context
MCP spec (2025-11-25) defines Roots as a mechanism for clients to expose filesystem boundaries
(directories and files) to servers:
https://modelcontextprotocol.io/specification/2025-11-25/client/roots
This enables servers to understand which parts of the filesystem they can operate on.
This adds support for the two roots protocol interactions:
- `roots/list` -- server-to-client request to retrieve the client's filesystem roots
- `notifications/roots/list_changed` -- client-to-server notification when roots change
`roots/list` is a one-to-one request between a server and a specific client,
so `list_roots` is exposed only at the session level
(`ServerSession#list_roots`), matching the Python SDK approach and aligning with PR #311 for sampling.
`Server#on_roots_list_changed` registers a callback for the client notification,
which is server-wide and does not require session scoping.
Also fixes `notifications/roots/list_changed` capability checking: as a client-originated notification,
it should not require server-side capability declaration.
## How Has This Been Tested?
New test file `test/mcp/server_roots_test.rb` with 8 test cases covering session-scoped `list_roots`,
capability validation and fallback, `on_roots_list_changed` callback registration, and client capability storage.
Updated `test/mcp/methods_test.rb` to reflect the corrected capability checks. All tests pass (`rake test`),
RuboCop is clean.
## Breaking Changes
None.
## Motivation and Context
The MCP specification (2025-11-25) defines elicitation as a server-to-client
request mechanism that allows servers to request user input during tool execution:
https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation
This aligns with the Python SDK, which also exposes separate methods for form and URL mode elicitation.
Three methods are added following the Python SDK's API design:
- `create_form_elicitation`: collects structured data via JSON Schema
- `create_url_elicitation`: directs users to external URLs for sensitive
interactions (OAuth, credentials, etc.)
- `notify_elicitation_complete`: notifies the client that a URL mode
interaction has completed
`URLElicitationRequiredError` (error code `-32042`) is defined as a subclass of `RequestHandlerError`
with its own `error_code` and `error_data` attributes. `JsonRpcHandler#handle_request_error` is
extended to check for these attributes so that MCP-specific error codes can be propagated without
embedding MCP knowledge in the generic JSON-RPC layer.
## How Has This Been Tested?
- Unit tests for `ServerSession` form/URL mode, capability checks, nil
capabilities, notification, and `URLElicitationRequiredError`
- `ServerContext` delegation tests for all three elicitation methods
- `JsonRpcHandler` tests for custom error code and fallback behavior
- `Methods` capability test for `NOTIFICATIONS_ELICITATION_COMPLETE`
- Conformance tests pass (40/40), including `tools-call-elicitation`,
`elicitation-sep1034-defaults`, and `elicitation-sep1330-enums`
- All tests pass (`rake test`), RuboCop is clean
- Verified form mode elicitation end-to-end with a sample MCP server
running in Claude Code
## Breaking Changes
None. This is a new feature addition.