## Motivation and Context
On graceful teardown (`transport.close`), each open `subscriptions/listen` stream receives
its `SubscriptionsListenResult` response before closing, signaling a clean end the client can distinguish
from an abrupt disconnect. But a delivery that snapshotted the stream from the registry
before teardown cleared it performs its write outside the lock, so a change notification could land after the final result.
Each subscription entry now carries a per-stream write mutex. Teardown marks the entry closed and writes
the result under that mutex; a racing delivery either lands before the result or observes `closed` and skips,
keeping the graceful result the stream's final message. The mutex is per stream and held only around the single write call,
so deliveries to different streams stay as parallel as before, and no path nests it inside the transport mutex
(delivery snapshots release `@mutex` first, and the write-error cleanup takes `@mutex` only after the write mutex is released),
ruling out lock-order inversions. Keepalive pings stay outside the mutex: they are SSE comment frames, which clients ignore by
specification, so one landing after the result is harmless.
## How Has This Been Tested?
With a new regression test pinning the race (an entry captured before teardown and re-presented to delivery afterwards writes
nothing after the result), the full suite, RuboCop, and the conformance suite, all green.
## Breaking Changes
None. The write mutex is internal to the listen registry; the wire only gains the ordering guarantee.
## Motivation and Context
The documented Rails controller pattern builds a fresh transport per request and renders `body.first`,
but a modern client's `subscriptions/listen` made `handle_request` return the streaming `Proc` body,
so exactly the documented configuration answered every notification-stream request with a 500 - quietly,
since tool calls and listings kept working. Nothing gated the route: `serves_subscriptions_listen?` was
hardcoded `true` and consulted only by `Server#discover` for capability stripping,
and the modern path deliberately ignores `stateless:`.
`StreamableHTTPTransport.new` now accepts `serve_subscriptions_listen:` (default `true`, behavior unchanged).
When `false`, the transport skips the listen interception so the method falls through the dispatcher as
unimplemented - the spec's 404 with JSON-RPC `-32601`, in an Array body the controller pattern can render - and
`serves_subscriptions_listen?` reflects the setting, so `Server#discover` stops advertising the `listChanged`/`subscribe`
capability flags through its existing wiring and the advertisement agrees with the actual behavior.
The documented controller example passes the flag and explains why; refusing implicitly in `stateless:` mode
was rejected because a stateless transport mounted as a long-lived Rack app serves listen streams correctly
(listen delivery deliberately precedes the stateless notification guard).
On the default streaming side, the listen body is now a small streaming-body object rather than a bare `Proc`:
it still responds to `call` and deliberately not to `each` (Rack would otherwise classify it as enumerable
and break the streaming), while `first` raises an error naming `serve_subscriptions_listen: false`
and pointing at the transports documentation, so a host that buffers the body the documented way fails with
guidance instead of a bare `NoMethodError`.
Fixes#531.
## How Has This Been Tested?
With the issue's reproduction script (default: a streaming body whose `body.first` raises the guidance;
with the flag: 404, `application/json`, and an Array body whose first element is the `-32601` error),
new transport tests covering the refusal shape, the capability stripping on `server/discover`, and the guidance
raise with the body's streaming classification, the full suite, RuboCop, and the conformance suite, all green;
the docs build passes every internal link and anchor check.
## Breaking Changes
None. The keyword defaults to the current behavior; refusal is opt-in.
## Motivation and Context
Per SEP-2575, the server MUST NOT send any notification on a subscription before `notifications/subscriptions/acknowledged`.
The listen stream was registered in the delivery registry before its acknowledgement was written (the write happens outside
the lock so a stalled reader cannot block the transport), so a change notification published concurrently from another thread
could be written to the stream ahead of the acknowledgement.
The entry is now inserted inactive - still reserving the id and the cap slot atomically - and becomes eligible for delivery only
after the acknowledgement write has completed. A concurrent notification inside the window skips the inactive entry,
which is indistinguishable from the change happening just before the subscription opened.
## How Has This Been Tested?
With a new regression test pinning the window (an entry injected in the registered-but-unacknowledged state receives nothing until activation),
the full suite, RuboCop, and the conformance suite, all green.
## Breaking Changes
None. Activation happens synchronously inside the same response body proc, so an acknowledged stream behaves exactly as before.
## Motivation and Context
A request id is the only key that routes request-scoped messages back to the request that caused them:
progress and log notifications, server-to-client requests such as sampling and elicitation,
and `notifications/cancelled` all resolve through it. Nothing checked that the id was free,
so a second request arriving under an id already in flight took the routing entry over.
From that point the first request's notifications were written to the second request's SSE stream,
and whichever finished first removed the shared entry, leaving the survivor's messages nowhere to go.
The spec puts the uniqueness obligation on the sender, and 2026-07-28 draws it at exactly
the window this change enforces: "The request ID MUST NOT match the ID of any other request the sender
has issued and not yet received a response for". Earlier revisions worded it as never reusing an id within
the session. Neither says what a receiver does with a duplicate, but the Streamable HTTP rules require
that messages the server sends before the response "SHOULD relate to the originating client request",
and two live requests sharing one id make that impossible to honor for either of them.
Answering the second one is the only option that stays correct, so it is refused the same way
a duplicate `initialize` is, and for the same reason: a repeated id must not silently displace state
the first one established.
The reference SDKs currently accept the duplicate and let the newer request take the entry.
That is not a settled design: the Python SDK carries a TODO naming rejection with `INVALID_REQUEST`
as the revisit, so this moves toward where they expect to end up rather than away from them.
`ServerSession#register_in_flight` now claims the id atomically and reports a collision instead of overwriting,
which `Server` turns into an Invalid Request for every transport. `StreamableHTTPTransport` refuses
the colliding POST with 409 before it registers a stream, since the stream is registered ahead of dispatch
and would otherwise take over the routing entry for as long as the rejection takes.
Removal is now guarded by identity everywhere the registry is keyed by request id, so a request that loses
a race cannot retire a registration it does not own. `initialize` never registers, so its `ensure` no longer unregisters:
a refused duplicate `initialize` reusing an in-flight id used to evict that registration on the way out,
silently disabling cancellation for the request that owned it (Streamable HTTP refuses such a POST before dispatch;
stdio reached this path). `drop_broken_stream` had a sharper form of the same bug: it removed whichever stream held
the id while closing the one passed in, which are not necessarily the same stream.
Only ids that are in flight are refused, which is the scope the current spec draws.
Enforcing the older "never within the session" wording would instead mean remembering every id a session ever used,
and the in-flight window is the part routing depends on either way. Sequential reuse keeps working,
and neither this SDK's client (`SecureRandom.uuid`) nor a client built on the TypeScript SDK (a per-connection counter)
can produce a collision.
## How Has This Been Tested?
New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` reproduce the collision:
a tool parked mid-request, a second POST reusing its id, and assertions that the second POST is refused
and that the first request still receives the progress frame it emits afterwards. Others cover
the identity-guarded removal and confirm that an id can be reused once the earlier request has finished.
New tests in `test/mcp/server_cancellation_test.rb` cover the registry directly, including
the transport-independent Invalid Request and the survival of an in-flight registration across
a refused duplicate `initialize` under the same id. All of them except the sequential-reuse guard fail
without this change. `bundle exec rake` (tests, RuboCop, and conformance baseline) passes.
## Breaking Changes
A request whose id is already in flight on the same session is now answered with Invalid Request
(HTTP 409 on Streamable HTTP) instead of being processed. A client that reuses ids concurrently was
already violating the specification and was already having its notifications misrouted.
## Motivation and Context
Follow-up to https://github.com/modelcontextprotocol/ruby-sdk/issues/512#issuecomment-5285679761
A comment on #512 reported that results stay unstamped when a client negotiates `2026-07-28` through
the legacy `initialize` handshake: the SEP-2322 `resultType` stamp and the SEP-2549 cache hints are
both gated on the per-request `_meta` envelope, which such a connection never carries,
so strictly validating clients reject every result as missing the REQUIRED `resultType`.
The root cause sits upstream of the stamp. Per the SEP-2575 era model, an era is a property of
the protocol version itself: legacy versions establish a session via `initialize`
(`2025-11-25` and earlier), and modern versions carry their version on every request in `_meta` with
no handshake at all - the 2026-07-28 schema defines no `initialize`, and a request without the envelope
is malformed. The negotiation list of this server still contained `2026-07-28`, producing a hybrid state
the spec does not define and the reference SDKs refuse to construct: the TypeScript server negotiates
from `SUPPORTED_PROTOCOL_VERSIONS`, which stops at `2025-11-25`, and counter-offers its first entry;
the Python server negotiates from `HANDSHAKE_PROTOCOL_VERSIONS`, which stops there too,
and counter-offers `LATEST_HANDSHAKE_VERSION`.
`initialize` now negotiates from a new `SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS` list
(`2025-11-25` and earlier): a request for `2026-07-28`, or for a version this server does not know,
is counter-offered `2025-11-25`, exactly as both reference SDKs answer. A counter-offered client proceeds on
`2025-11-25`, where `resultType` does not exist and its absence is the correct shape,
resolving the reported rejection. `2026-07-28` stays reachable through `server/discover` and
the per-request envelope, where the stamps already apply. The `envelope &&` stamp gates are
intentionally unchanged: with the handshake capped, an envelope-less `2026-07-28` connection is
no longer constructible, so the gates are correct by construction.
The client side is symmetrized the same way, matching both reference SDKs:
the legacy handshake offers `LATEST_HANDSHAKE_PROTOCOL_VERSION` by default,
passing an explicitly modern `protocol_version` to the legacy connect raises `ArgumentError`
(validated before the stdio transport spawns its child process), and a server answering `initialize` with
a modern version is rejected like an unknown one. `:auto` forwards an explicitly modern version to
the discovery probe and never downgrades it into the legacy fallback: the probe's failure is
the real answer and propagates, instead of the fallback dying on the handshake guard.
The handshake rule itself lives in one place (`Configuration.reject_modern_handshake_version!`),
and the handshake list is derived from the stable and modern lists so the era partition cannot drift
when the next revision lands.
Two consequences are worth naming. The roots/sampling deprecation warnings move to the modern connect
on the client, the revision where SEP-2577 actually deprecates them, since the handshake can no longer land
on a deprecating version and the call site the warnings used to sit on had become unreachable.
On the server the answer is removal: `Configuration#protocol_version` scopes the handshake,
so it now accepts handshake versions only and rejects a modern pin with `ArgumentError` at construction.
Accepting one would configure nothing (a modern version has no handshake to pin),
and neither reference SDK carries a config value that is accepted but inert:
Python's negotiation has no knob at all, and every entry of the TypeScript server's `supportedProtocolVersions`
is consumed by its own era's mechanism. With no path left onto a deprecating revision,
the server-side deprecation warning plumbing became unreachable and is removed along with it.
Keying those warnings on a modern-era session instead was tried and rejected,
because `notify_log_message` is the SEP-2575 sanctioned delivery path on that wire and every log line
would then carry a deprecation warning for doing exactly what the revision prescribes
(a single test file produced over a thousand of them).
A session-bound request may still stamp `MCP-Protocol-Version: 2026-07-28` after being counter-offered,
and such a request continues to be served at the negotiated revision rather than rejected.
Sending the negotiated version is a SHOULD on the client, and the spec's `400 Bad Request` rule
covers a version the server does not support, which is not the case here: this server does serve `2026-07-28`,
through the other lifecycle. The session it is bound to decides, and a test now pins that reading along with
its consequence, which is that no `resultType` appears on those results.
Two details follow from the pin being handshake-scoped. Reading it back when nothing is set
now answers `LATEST_HANDSHAKE_PROTOCOL_VERSION` rather than the newest version of any era,
so the getter no longer hands out a value its own writer rejects and round-tripping
the setting keeps working; only `Server#validate!` reads that default,
through three comparisons that answer the same for either value, so nothing else moves.
And the rejection names its reason instead of listing the accepted versions,
because this is the error an upgrade from a release that accepted the value lands on,
which makes it the migration note a bare list would not be.
`MCP::ProtocolDeprecations` says in its own documentation that only the client emits these warnings now,
and that `LOGGING_MESSAGE` has no caller left. It stays public and callable: removing it would be
a breaking change for no gain.
The handshake examples in `docs/building-clients.md` and the shared `initialize_params` test helper
now use the handshake version as well, and the README section on `protocol_version` describes the pin as
handshake-scoped: handshake versions only, `ArgumentError` for a modern one, the handshake version as the default,
and `2026-07-28` reached through `server/discover` with no configuration needed.
## How Has This Been Tested?
New regression tests pin the reported scenario end to end: an `initialize` requesting `2026-07-28` is
counter-offered `2025-11-25`, locks the legacy era, and subsequent results carry no `resultType`
(the correct shape at that revision). Client-side tests cover the default `2025-11-25` offer,
the `ArgumentError` for an explicitly modern version on the legacy connect
(asserting no child process is spawned on the stdio transport), the rejection of a modern `InitializeResult`
on both transports, the `:auto` probe failure propagating for an explicitly modern version instead of
falling back, and the deprecation warning firing on modern connects declaring roots/sampling capabilities.
Counter-offer adoption gets its own test on both transports, since the server-side fix only resolves anything for
clients that honor a counter-offer, and this client is one of them: the offer is the latest handshake version,
the answer is an older one, and the connection speaks the answer afterwards
(over HTTP the next request's `MCP-Protocol-Version` header carries it). The existing tests could not tell adoption
from an echo, because every one of them had the server answer with the version the client had just offered.
Both new tests were confirmed to fail when the client is made to report its own offer instead.
Tests that pinned the previous dual-lifecycle negotiation were inverted or reworked:
the server tests asserting `2026-07-28` echoes and fallbacks now assert the counter-offer,
and the deprecation warning tests keyed on a legacy-negotiated `2026-07-28` now assert no warning fires.
The server-side deprecation tests that reached their state by calling `mark_initialized!` with a modern version,
or by pinning one in `Configuration`, were replaced by tests asserting no warning fires on those paths,
since no code path can produce the warning state any more; `Configuration` rejecting a modern pin has its own tests
on the constructor and the setter, asserting the reason in the message rather than the list of accepted values.
The default-pin test now also round-trips what it reads back into a fresh `Configuration`,
which is what the old default could not do. The default move was checked for neutrality outside
the suite as well, by building a server that uses every feature `Server#validate!` gates on that value
(`description`, `title`, `website_url`, `instructions`, a `$ref` input schema, and tool annotations)
and confirming all of them are still accepted.
## Breaking Changes
Servers no longer echo `2026-07-28` from `initialize`: clients that negotiated it through
the handshake are counter-offered `2025-11-25` and are served at that revision, matching
the TypeScript and Python servers. `Configuration.new(protocol_version: "2026-07-28")` now
raises `ArgumentError` (the pin scopes the handshake, which cannot land there);
the value was only accepted since v1.1.0, whose handshake behavior this change replaces anyway.
Reading `Configuration#protocol_version` with no pin set answers `2025-11-25` instead of `2026-07-28`;
nothing in this SDK behaves differently for either value, but code comparing that reader against
a literal will see the change. The client no longer offers modern versions on the legacy handshake
and rejects a modern `InitializeResult`. Server-side deprecation warnings for SEP-2577 features
no longer fire at all, because no negotiation or configuration path can put a connection on a deprecating revision;
the client-side warning on a modern connect replaces them.
## Motivation and Context
The modern lifecycle removes server-initiated JSON-RPC requests: a stateless server has no connection to send them on,
and SEP-2322 multi round-trip `input_required` results replace what sampling and elicitation covered before.
`StdioTransport#send_request` already says so, refusing outright when its session is modern.
`StreamableHTTPTransport#send_request` says nothing, so a modern handler that reaches for elicitation
or sampling falls through to the session lookup and is told `Session not found: <uuid>` against a session id it never chose.
Failing is right, the explanation is not: `handle_modern` mints a session per request without ever registering it in `@sessions`,
so that lookup cannot succeed for a modern request no matter what the handler does.
`send_request` now refuses a modern-era session up front with the message `StdioTransport` uses, which turns
an incidental registration detail into a stated rule and leaves the rest of the method a legacy-only path by construction.
Callers see the same `RuntimeError` class as before, only with a message naming the actual constraint.
## How Has This Been Tested?
A new test in `test/mcp/server/transports/streamable_http_transport_test.rb` sends `elicitation/create` from
a session constructed with `era: :modern` and asserts the SEP-2575 message. It sits with the existing guard tests for stateless mode,
a missing `session_id`, and an unknown session, which still pass unchanged.
`bundle exec rake` is green, and both conformance legs pass their baseline check.
## Breaking Changes
None. The call already failed; only the message changes.
## Motivation and Context
The specification asks implementations to establish timeouts for all sent requests,
"to prevent hung connections and resource exhaustion", to cancel the request once the deadline passes,
and to let SDK users configure the deadline per request. This SDK did none of the three: `send_request` blocked
on `Queue#pop` with no deadline, so a client that opens a session, triggers a handler that elicits input,
and then simply never answers holds that worker thread for good. A handful of such requests exhausts
a Rack server's thread pool. The existing escape hatches are weak: the idle reaper waits 1800 seconds
by default and refreshes on any traffic for the session, so a client that keeps sending other requests never trips it,
and only the GET stream has a keepalive to notice a vanished peer.
`StreamableHTTPTransport` now waits `server_to_client_request_timeout:` seconds
(`DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT`, 600) for a response, and `timeout:` overrides it per request through
`ServerContext` and `ServerSession` down to the transport. The option spells out its direction because
the same constructor already takes `max_request_bytes:` for the requests arriving the other way.
Ten minutes matches the TypeScript SDK, which raises its uniform 60-second request default to 600 seconds for
the legs of its legacy `input_required` shim because they are "human-paced, so the 60s protocol default is wrong".
Every request this transport can send is that kind of leg: someone answering an elicitation prompt, or the client's
own model producing a sample. The Python SDK leaves the deadline unset and bounds nothing by default.
On expiry the transport sends `notifications/cancelled` for the request it stopped waiting on, so a late answer
does not act on something the server abandoned, and raises the new `MCP::Server::RequestTimeoutError`.
Both reference SDKs send the same courtesy cancel on timeout, and it is what the revision governing this path asks
for: 2025-11-25 lets either side cancel and tells the sender to "issue a cancellation notification for that request
and stop waiting". Only that revision and earlier reach the wait, since the modern lifecycle forbids these requests
and its sessionless requests never register the session `send_request` looks up.
Uncaught in a handler, the error answers the client's originating request with `-32001` rather than a generic
internal error. The code is not spec-allocated, but it sits in the implementation-defined server range and is what
the Python SDK reports for this condition, so a peer that recognizes it there reads the same meaning here.
The wait itself moves from `Queue` to a small `MCP::Server::PendingResponse`: `Queue#pop` only accepts a `timeout:`
on Ruby 3.2 and later and this gem supports 2.7, so the wait is a `ConditionVariable` with a monotonic deadline.
It keeps the `push`/`pop` names, leaving the three resolving call sites (a client response, a cancellation, session teardown)
unchanged, and keeps first-writer-wins so a cancellation racing a real response still cannot overwrite it.
The expiry block runs after the lock is released, since it cancels the request and would otherwise re-enter
the same non-reentrant mutex.
stdio is untouched and ignores `timeout:`, which the README now states. Its `send_request` waits inside
a `$stdin.gets` loop rather than on a queue, and the process model differs enough (the server owns the client
process, whose exit surfaces as EOF) that it deserves its own change; `forward_to_transport` already filters
`timeout:` out for transports that do not declare it, so custom transports and stdio keep their existing signatures.
## How Has This Been Tested?
New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` cover a request timing out with
the transport default, `timeout:` overriding it per request, the error code it reports, expiry reaching the peer
as `notifications/cancelled`, a response arriving before the deadline still returning normally, and
the constructor rejecting a non-positive `server_to_client_request_timeout`.
`test/mcp/server_session_request_timeout_test.rb` covers the deadline reaching the transport from all five
`ServerSession` entry points, and being dropped for transports that never declared it.
`test/mcp/server_context_test.rb` covers the same through `ServerContext`, including an omitted `timeout:`
not being forwarded at all. The pre-existing `send_request` tests (the response path, cross-session rejection,
cancellation, the cancel/response race, client errors, and session teardown) pass unchanged against the new
wait primitive.
`bundle exec rake` is green, and both conformance legs pass their baseline check. `PendingResponse` was exercised
directly on Ruby 2.7.0, the minimum this gem supports and the reason the wait avoids `Queue#pop(timeout:)`:
the deadline fires on time, a pushed value is delivered, the first writer wins, and the expiry block can resolve
the same object without deadlocking.
## Breaking Changes
Server-to-client requests that previously waited forever now fail after ten minutes by default. This is
the incompatible-but-required kind of change VERSIONING.md admits into a minor release: the unbounded wait deviates from
the specification and is the resource-exhaustion path it exists to prevent. Handlers that legitimately wait longer pass
a larger `timeout:`; the release notes and CHANGELOG entry need to call this out and point at that knob.
## 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
The modern (stateless) lifecycle of MCP 2026-07-28 serves each request as a self-contained exchange,
and two delivery requirements of that model were unimplemented:
- Notifications a handler emits while serving a modern request (progress, log messages)
had nowhere to go: the modern path answered with a single JSON object,
and the ephemeral per-request session degraded every notification to non-delivery.
The conformance requirement expects them as frames on the request's own response stream
(`tools-call-with-progress` fails with "No progress notifications received" at 2026-07-28).
- The envelope's `logLevel` member, which replaces the removed `logging/setLevel` RPC,
was parsed but never applied: log delivery fell back to the server-wide level, violating the requirement
that no `notifications/message` is sent for requests that did not set `_meta` `logLevel`.
The changes wire both up:
- `StreamableHTTPTransport#handle_modern` registers a per-request notification sink for its ephemeral session;
`send_notification` routes into the sink ahead of the stateless guard.
A request whose handler emitted notifications answers as an SSE stream carrying them ahead of the final response;
a request that emitted none keeps the single JSON exchange and its HTTP status ladder.
- `Server#handle_request` applies the lifted envelope's `logLevel` to the session via the existing `configure_logging`,
and on modern-era sessions `ServerSession#notify_log_message` no longer falls back to the server-wide level:
without a per-request `logLevel` it stays silent, on every transport. An unrecognized level reads as absent,
the safe direction, matching the Python SDK's `allowed_log_levels`.
The `server-stateless` scenario of the 2026-07-28 conformance requirements probes two stream-discipline
rules through diagnostic fixture tools the Ruby fixture did not define, so both checks failed as
not testable: `sep-2575-http-server-no-independent-requests-on-stream` calls `test_streaming_elicitation`
and requires that every frame on the request's response stream is a notification or the final response,
and `sep-2575-server-no-log-without-loglevel` calls `test_logging_tool` without an envelope `logLevel`
and requires that no `notifications/message` reaches the stream.
The fixture now defines both tools. `test_streaming_elicitation` reports progress and completes;
`test_logging_tool` calls `notify_log_message` unconditionally and relies on the SDK's per-request gate,
which drops the notification for modern requests that did not authorize logging through
`io.modelcontextprotocol/logLevel`. Both lean on the notification streaming this change adds:
with a `progressToken` or an authorizing `logLevel`, the notification is delivered as an SSE frame
ahead of the final response rather than being dropped.
One known limitation relative to the TypeScript and Python SDKs, which stream notifications live while the handler runs:
delivery here is buffered, flushing the frames after the handler returns. Ordering is preserved
and the conformance checks are satisfied, but long-running handlers get no real-time interleaving.
The sink is bounded (`MAX_MODERN_REQUEST_NOTIFICATIONS`, 1000): a handler emitting notifications
in proportion to client-supplied input cannot grow one request's memory without bound,
and past the cap the notify helpers report `false`, the same non-delivery degradation
they have on every other undeliverable path. Live streaming can follow without changing the wire shape.
## How Has This Been Tested?
`bundle exec rake test` passes with zero failures and RuboCop reports no offenses. New tests cover:
a modern `tools/call` streaming `notifications/progress` frames ahead of the result, the single JSON exchange staying
in place without notifications, log suppression without the envelope `logLevel`, log delivery with it,
the modern-era session ignoring the server-wide level while honoring a per-request one,
and the notification cap bounding one request's buffered frames while the notify helpers report `false` past it.
Against the conformance fixture server, the `tools-call-with-progress` progress check passes
at `--spec-version 2026-07-28` (its remaining wire-schema check belongs to the separate `resultType` change),
the stream-discipline checks `sep-2575-http-server-no-independent-requests-on-stream` and
`sep-2575-server-no-log-without-loglevel` report SUCCESS, and manual probes confirm the positive
directions the suite leaves untested: with `io.modelcontextprotocol/logLevel` in `_meta`,
`test_logging_tool` streams its `notifications/message` frame ahead of the result, and
with a `progressToken`, `test_streaming_elicitation` streams its `notifications/progress` frame.
The `--requirements 2025-11-25` server leg passes 78/78, unchanged.
## Breaking Changes
None for stable protocol versions: legacy requests and their notification delivery are unchanged.
Within the modern lifecycle, notifications become deliverable where they were previously dropped,
and log messages without a per-request `logLevel` stop leaking through the server-wide level,
as the specification requires.
## Motivation and Context
The modern Streamable HTTP path validated the SEP-2243 mirror headers only when present:
an absent `Mcp-Method` or `Mcp-Name` was silently tolerated. The 2026-07-28 specification
requires `Mcp-Method` on every request and `Mcp-Name` on `tools/call`, `resources/read`,
and `prompts/get`, and lists a missing required standard header among the validation failures
a server MUST reject; the TypeScript SDK enforces presence with `-32020` the same way.
Tolerating absence also defeats the headers' purpose silently: they exist so intermediaries
can route and inspect requests without parsing bodies.
A modern POST without `Mcp-Method`, or a name-bearing modern POST whose body carries
a target name without `Mcp-Name`, now answers HTTP 400 with `-32020` naming the missing header,
exactly like a mismatch. `Mcp-Name` stays unrequired when the body carries no target name,
matching the TypeScript SDK. The bundled `MCP::Client::HTTP` already sends both headers on
every modern request, so SDK-to-SDK traffic is unaffected.
## How Has This Been Tested?
New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` cover
the missing `Mcp-Method` and missing `Mcp-Name` rejections, asserting the `-32020` code
and the header name in the message. The `modern_rack_request` helper now mirrors
a conforming client by deriving `Mcp-Method` from the body (with `method_header: nil` to omit it),
so the existing modern-path tests exercise the requirement on every request.
## Breaking Changes
None for conforming clients, which must already send these headers.
Hand-rolled modern clients that omitted them are now rejected with `-32020` instead of being served;
add the headers, or use the legacy lifecycle, to proceed.
## Motivation and Context
Fourth step of the stateless lifecycle (SEP-2575, modelcontextprotocol/modelcontextprotocol#2575)
for the 2026-07-28 MCP spec release. `StreamableHTTPTransport` now routes header-first,
like the Python SDK's streamable HTTP manager: an `MCP-Protocol-Version` header naming a version outside
every supported list enters a new sessionless modern path, while requests without the header (or with
a stable-only version) take the existing paths untouched. Routing unknown versions to the modern path
means an unknown future version receives the spec-mandated `-32022` with `data: { supported:, requested: }`
instead of a generic invalid-request error.
Since 2026-07-28 became the latest stable protocol version, it serves both lifecycles of the dual-era model,
and the header value alone can no longer decide the era. For a dual-era header the routing disambiguates
by request shape: an `Mcp-Session-Id` binds the request to an established legacy session
(POST requests, the GET SSE stream, and DELETE termination keep working), and a sessionless POST
whose body is `initialize` is the legacy-distinctive handshake, so a client negotiating 2026-07-28 over
the classic handshake connects unchanged. Everything else under a dual-era header is sessionless modern traffic
(`server/discover`, envelope-carrying requests, and envelope-missing requests that get the modern path's error shape).
The era sniff reads the body once, bounded by `max_request_bytes`, and hands the string to whichever path serves the request,
since Rack 3 inputs need not be rewindable.
The modern path (`handle_modern`) is a single POST/JSON exchange:
- GET (the removed listening stream, replaced by `subscriptions/listen`) and DELETE (no sessions to terminate) return 405.
- It never consults `@stateless`, `@sessions`, or `@enable_json_response`, never issues an `Mcp-Session-Id`,
and rejects requests carrying one with HTTP 400.
- Its body read is bounded by `max_request_bytes` (HTTP 413), like the legacy POST path.
- Header/body match rules surface as `-32020 HEADER_MISMATCH` (HTTP 400): the header version against
the `_meta`-carried version, and the `Mcp-Method` / `Mcp-Name` mirror headers against the body when sent.
`Mcp-Name` decoding mirrors the client transport's `=?base64?...?=` sentinel for values that are not header-safe ASCII.
- HTTP statuses follow the Python SDK's ladder: `-32020`/`-32021`/ `-32022` and the generic parse/invalid codes map to 400,
`-32601` maps to 404 (disambiguating an unknown method from a legacy HTTP+SSE 404), and everything else
including internal errors stays 200.
- Dispatch runs against an ephemeral per-request `ServerSession` locked to `era: :modern`,
whose fresh unregistered `session_id` makes notification delivery degrade gracefully instead of
broadcasting to unrelated legacy sessions through the broadcast branch of `send_notification`.
The legacy POST path gains one load-bearing guard for bodies carrying the modern `_meta` triple,
which would previously fall through the legacy path via the header default. Session-bound under
a dual-era header, such a request is a lifecycle violation, rejected as `-32600` because
the session already negotiated the legacy lifecycle (mirroring the stdio era lock).
With the header missing or naming a stable-only version, it violates the header/body match
requirement and is rejected as a header mismatch (`-32020`).
Intentional behavior changes for previously-erroneous requests, all following from header-primary routing:
an unknown header version now yields `-32022` with data instead of the legacy `-32600` message
(`initialize` included, which legacy-wise ignored the header), and GET/DELETE with an unknown header version,
or with a dual-era header and no session, yield 405 instead of 400. An empty header value stays malformed
on the legacy path. Existing tests were updated to codify these.
Refs #389.
## How Has This Been Tested?
New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` cover:
the sessionless 200 exchange without `Mcp-Session-Id`, `-32022` with the supported list
for unknown header versions, `-32020` for header/body version, `Mcp-Method`,
and base64-encoded `Mcp-Name` mismatches, the `Mcp-Session-Id` rejection,
405 for modern GET/DELETE, 404/`-32601` for unknown methods, the envelope requirement (`-32600`),
`-32021` with `requiredCapabilities` from a handler capability guard, `server/discover` without
an envelope, and the legacy-path sniff for modern-envelope bodies without the modern header.
Dual-era routing tests: a sessionless `initialize` with the 2026-07-28 header stays legacy
and negotiates 2026-07-28, session-bound POST and DELETE with that header stay on the legacy path,
a session-bound POST carrying the modern envelope is rejected as `-32600`,
and an oversized modern POST returns 413.
## Breaking Changes
None for conforming clients: requests without the header or with a stable-only version
are byte-identical to before, and legacy clients negotiating 2026-07-28 over
the classic handshake connect unchanged. Requests that were already rejected change error shape
(see intentional behavior changes above) to the forms the 2026-07-28 spec mandates.
## Motivation and Context
Third step of the stateless lifecycle (SEP-2575, modelcontextprotocol/modelcontextprotocol#2575) for
the 2026-07-28 MCP spec release. `StdioTransport` now serves the legacy handshake lifecycle
and the modern per-request-envelope lifecycle on one connection, with the same era-lock semantics as
the TypeScript and Python SDKs: the first era-distinctive message to SUCCEED locks the connection era
for its lifetime.
- Each stdin frame is parsed once (`symbolize_names: true`) so era classification can inspect its method
and `_meta`. Frames that are not JSON objects fall back to `ServerSession#handle_json`,
keeping protocol-level error responses byte-identical.
- A successful `initialize` locks `:legacy` (already a side effect of `ServerSession#mark_initialized!`).
A successful `server/discover` or a successful request carrying the full modern `_meta` triple locks `:modern`.
Failed era-distinctive messages (for example an unsupported envelope version) leave the connection unlocked,
so a client probe can still fall back to the other era.
- Era violations are rejected in-band by `Server#lift_request_envelope`, which now also covers the legacy side:
a modern envelope arriving on a legacy-locked session is an invalid request (`-32600`), mirroring
the existing modern-side rules (`initialize` after a modern lock is `-32022`; a missing envelope after
a modern lock is `-32600`).
- `StdioTransport#send_request` raises on a modern-locked session: the modern lifecycle forbids
server-initiated JSON-RPC requests, which multi round-trip `input_required` results (SEP-2322) replace.
The inline read loop inside `send_request` dispatches through the same era-aware path as the main loop.
Refs #389.
## How Has This Been Tested?
New tests in `test/mcp/server/transports/stdio_transport_test.rb` drive full stdin/stdout round trips:
legacy lock via `initialize` then rejection of a modern envelope, modern lock via `server/discover`
then `-32022` (with `data.supported`) for a late `initialize`, modern lock via an envelope-carrying request,
no lock when the probe fails with an unsupported version (a legacy `initialize` still succeeds afterwards),
the envelope requirement after a modern lock, and the `send_request` prohibition on modern sessions.
`bundle exec rake` (tests, RuboCop, and conformance baseline, including the stdio conformance scenarios) passes.
## Breaking Changes
None. Legacy clients send `initialize` first and take the same code path as before;
the era lock only constrains message sequences that mix lifecycles on one connection,
which no legacy client produces.
## Motivation and Context
The Rack 3 SPEC requires response header names to be lowercase.
https://github.com/rack/rack/blob/main/SPEC.rdoc says in its "The Headers" section:
> Header keys must not contain uppercase ASCII characters (A-Z).
https://github.com/rack/rack/blob/main/SPEC.rdoc#the-headers
However, `StreamableHTTPTransport` emitted capitalized names such as `Content-Type` and `Mcp-Session-Id`.
Any `Rack::Lint`-wrapped stack, including the default development mode of rackup, rejects every response
from the transport with a `LintError` 500. Lowercasing the names restores Rack 3 compliance.
HTTP header names are case-insensitive on the wire, so HTTP clients are unaffected;
only in-process consumers that read the raw Rack headers hash see the new keys.
Lowercase is also the only representation valid across HTTP versions: RFC 9113 makes uppercase field names
a protocol error on HTTP/2, so proxies and h2-capable servers already require it.
The TypeScript SDK sets `headers['mcp-session-id']` and the Python SDK defines `MCP_SESSION_ID_HEADER = "mcp-session-id"`,
so lowercase also matches what the reference SDKs emit; the MCP spec's `Mcp-Session-Id` casing is prose convention,
not a wire requirement.
Examples and documentation deliberately keep the spec's `Mcp-Session-Id` casing where it does not name
a Rack response hash key: curl invocations and `examples/streamable_http_client.rb` set request headers,
which the Rack response rule does not govern; the rack-cors `expose:` entry carries a header name as
an `Access-Control-Expose-Headers` value, which browsers match case-insensitively;
and README/docs prose follows the MCP specification's documentation convention.
Only code reading the raw Rack response headers hash, a case-sensitive Hash lookup, had to switch to lowercase keys.
AGENTS.md now records the lowercase rule under the code style guidelines, since the MCP spec's `Mcp-Session-Id` prose casing
invites reintroducing capitalized names in future transport work.
## How Has This Been Tested?
Added a regression test asserting that header names are lowercase in initialize, GET SSE, DELETE, and parse error responses.
Existing tests that read the raw headers hash were updated to the new keys. Verified with rake test, rake rubocop,
and rake conformance.
## Breaking Changes
Code that reads the raw Rack response headers hash returned by `handle_request`, or by middleware around the mounted transport,
with exact-case keys such as `"Mcp-Session-Id"` must switch to the lowercase keys. Clients speaking HTTP are
unaffected because header names are case-insensitive on the wire.
## Motivation and Context
`StreamableHTTPTransport#handle_post` routes to `handle_initialization` via `initialize_request?`,
which matches on the method name alone. An `initialize` message sent without an id (framed as a notification)
therefore reaches `handle_initialization`. `Server#init` runs and marks the session initialized,
but JSON-RPC emits no response for an id-less request, so `handle_json` returns nil.
Two things then went wrong:
- `handle_initialization` returned `[200, headers, [nil]]`. A `nil` body element is not a valid Rack response,
so the web server raises while serializing it (`NoMethodError` on `nil.bytesize`)
and the client gets a malformed response or a 500.
- The `if session_id && !server_session.initialized?` cleanup guard was skipped, because the session is marked initialized.
The session was retained with a `Mcp-Session-Id` header, so a stream of id-less `initialize` messages accumulates sessions
toward the `max_sessions` cap until the reaper reclaims them.
`handle_initialization` now treats a nil response as an id-less initialize: it discards the orphaned session
and acks with 202, mirroring how `handle_regular_request` already handles a nil response.
A well-formed `initialize` request (with an id) is unaffected.
## How Has This Been Tested?
A new test in `test/mcp/server/transports/streamable_http_transport_test.rb` posts an `initialize`
with no id and asserts a 202 with an empty body, no `Mcp-Session-Id` header, and an empty session map.
It fails against the previous code (a 200 with a nil body element and a retained session) and passes now.
## Breaking Changes
None. A conforming client sends `initialize` as a request with an id and is unaffected;
only the malformed id-less form changes, from a broken 200 to a 202 with no retained session.
## Motivation and Context
In `StreamableHTTPTransport#handle_post`, the response and regular-request branches reject
a message whose session no longer exists (the response branch via `session_exists?`,
the regular path via `validate_and_touch_session`), but the notification branch dispatched unconditionally.
A notification carrying a present-but-unknown (or expired-but-not-yet-reaped) `Mcp-Session-Id`
therefore passed the `missing_session_id` gate and the ownership gate (which returns true
when the session does not exist) and reached `dispatch_notification` with no session,
running against the shared `Server` instead of a live `ServerSession`. With the default no-op notification
handlers this had no visible effect, but a custom notification handler would then run without
a live session behind it.
`handle_post` now checks `session_active?` in the notification branch, mirroring the other two branches.
`session_active?` rejects an unknown or expired session (evicting the expired one) without extending its lifetime,
so a stale session ID yields `404 Session not found` and a live session is dispatched exactly as before.
## How Has This Been Tested?
New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` assert that
a notification with an unknown session ID is rejected with `404 Session not found`
and that a notification carrying a live session ID is still accepted with `202`.
The new rejection test fails against the previous code (it returned `202`) and passes now.
## Breaking Changes
None for a conforming client: a client that has completed `initialize` sends its notifications with
the live session ID and is unaffected. Only a notification bearing an unknown or expired session ID,
previously accepted with `202`, now receives `404 Session not found`.
## Motivation and Context
`StreamableHTTPTransport` guards its `@sessions` and `@pending_responses` maps with one `@mutex`.
The send paths wrote to an SSE stream while holding that lock: the targeted and
broadcast branches of `send_notification`, the server-to-client `send_request`,
and `send_keepalive_ping` all called `send_to_stream`/`send_ping_to_stream`
(a `stream.write` plus `stream.flush`) inside `@mutex.synchronize`.
A stream write is blocking I/O with no timeout: if a client reads its SSE stream slowly,
the server-side `write` waits until the socket buffer drains.
Performing that write under `@mutex` serializes every other session on the same lock (head-of-line blocking):
new `initialize` inserts, session validation, `handle_response`, the reaper, and delivery to other sessions
all wait behind one slow reader. The broadcast path holds the lock across every session,
so one slow stream also delays delivery to the sessions after it.
The cleanup paths already release the lock before touching a stream: they collect `streams_to_close`
and call `close` outside `synchronize`. This change applies the same discipline to the writes.
- Each send path resolves its target stream under `@mutex` (and, for `send_request`, registers
the pending response there), then releases the lock before writing.
- A write error still drops the broken stream. That teardown is centralized in a new `drop_broken_stream`,
which mutates `@sessions` under `@mutex` and closes the affected streams outside it.
A request-scoped stream is dropped on its own; a GET SSE failure removes the whole session,
matching the previous behavior.
- `send_notification` splits into `deliver_targeted_notification` and `deliver_broadcast_notification`;
the return values (true/false for a session, the delivered count for a broadcast) are unchanged.
## How Has This Been Tested?
New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` install a probe stream that records,
via `mutex.try_lock`, whether `@mutex` is held during each write, and assert it is not held for the targeted notification,
broadcast, `send_request`, and keepalive paths. Each new test fails against the previous code and passes now.
The existing send and cleanup tests still pass.
## Breaking Changes
None. Delivery semantics and return values are unchanged; the lock is simply no longer held while writing to a stream.
## Motivation and Context
Both transports read a peer-controlled message fully into memory with no upper bound,
so a single unauthenticated message can allocate gigabytes and OOM the worker:
- `StreamableHTTPTransport#handle_post` did `request.body.read` with no size limit
before `JSON.parse`. A single ~512 MB POST grew a worker from 44 MB to 1.66 GB RSS
in the reporter's measurement. The path runs before session validation,
so it is reachable unauthenticated in both stateful and `stateless: true` modes.
- `StdioTransport` and `MCP::Client::Stdio` read frames with `IO#gets` and no `limit:`,
so a peer that never emits a newline grows a single String until the process is OOM-killed.
This matters when a host pipes an untrusted server's stdout into an unconstrained process.
The spec does not mandate a request size limit, and the reference SDKs vary
(the TypeScript SDK caps at a 4 MB default; Python/Go delegate to the web framework).
A finite default here bounds the allocation at the transport layer regardless of the deployment,
matching the TypeScript SDK's default.
- `StreamableHTTPTransport` gains `max_request_bytes:` (default 4 MiB). `handle_post`
rejects a declared `Content-Length` over the cap before reading, and also bounds
the actual read to one byte past the cap so a missing or spoofed `Content-Length`
(chunked transfer) is still caught, returning HTTP 413. `parse_request_body` now passes
`max_nesting:` (64) so a deeply nested body cannot exhaust the stack (complements the byte cap).
The constructor validates the new option as a positive Integer, since `nil` or a non-positive
value makes the underlying read unbounded and would silently disable the protection.
- The stdio `max_line_bytes:` bound (default 4 MiB) has since been merged upstream through
a separate advisory, so this change carries the remaining piece of the reported stdio fix:
an over-limit frame leaves leftover bytes in the pipe, desyncing the stream beyond recovery,
so `MCP::Client::Stdio` now closes the transport before raising and a later `send_request`
fails cleanly instead of parsing a truncated frame.
## How Has This Been Tested?
New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` cover the 413 rejection
with and without `Content-Length`, the boundary body being accepted, the nesting-depth rejection,
and the constructor validation. The client stdio over-limit test now also asserts the transport
is closed afterward (`refute_predicate(transport, :connected?)`).
`bundle exec rake` (tests, RuboCop, and conformance baseline) passes. Verified end-to-end that
a 256 MB POST with no declared `Content-Length` is rejected with 413 without the transport reading
the whole body (a throttled `rack.input` that raises if read far past the cap is never tripped).
## Breaking Changes
None. The 4 MiB defaults comfortably fit a typical JSON-RPC message (a 4 MiB JSON string decodes to
roughly 3 MiB of base64 payload), and the new keyword arguments are additive and optional.
## Motivation and Context
`StreamableHTTPTransport` issues a random `SecureRandom.uuid` session ID and validates
an incoming request by session existence and idle timeout only, with no binding to a client identity.
An attacker who obtains a victim's session ID (network sniffing, logs, etc.) can therefore POST
a `tools/call` on that session and the server executes it, silently poisoning the victim's session.
The previously shipped 409 fix guards the GET SSE stream slot but does nothing for the POST write path,
so this is not covered by it.
Binding a session to a user is fundamentally the deploying application's responsibility:
the transport hands the server only the JSON-RPC body string and never receives the authenticated principal
(`Authorization`/`Origin` headers do not reach handlers), and the MCP spec frames session-to-user binding as
a SHOULD. The Python, Go, and C# SDKs bind automatically only when an authenticated identity is present.
This gives the Ruby SDK the seam to do the same:
- A `session_request_validator` constructor option, called as `->(request, session_id) { true | false }`
on every non-`initialize` POST, GET, and DELETE against an existing session; a falsy return rejects
with HTTP 403. The gate is applied uniformly to regular requests, notifications, and client responses,
not just tool calls - otherwise a stolen session ID could POST `notifications/cancelled` to cancel
a victim's in-flight request, or a forged client response, by taking a branch the gate did not cover.
`initialize` is exempt because it establishes the session. An application that has an authenticated principal
uses the validator to enforce that the caller owns the session; without one, ownership is not enforced.
- A limited built-in Origin-consistency check as defense in depth (not authentication): the `Origin` header
is recorded at `initialize` and a later request whose `Origin` differs is rejected, but only when
both are present. A non-browser client that omits `Origin` (curl, a script - i.e. the advisory's own PoC)
is not stopped by this check; it only raises the bar against a browser-based cross-origin attacker.
Enforcing ownership against a determined attacker requires the validator.
Stateless mode retains no sessions, so neither check applies to it.
## How Has This Been Tested?
New tests in `test/mcp/server/transports/streamable_http_transport_test.rb`: a POST whose Origin differs
from `initialize` is rejected with 403; a matching Origin and a no-Origin request both succeed;
a custom `session_request_validator` both rejects (403) and allows (200) and receives the request and session ID;
the validator also gates notification (`notifications/cancelled`) and client-response POSTs, not just regular requests;
and stateless mode never consults the validator.
## Breaking Changes
None. `session_request_validator` defaults to `nil`, and the built-in Origin check only rejects when both
the session and the request carry an `Origin` and they differ, so existing applications are unaffected.
Resolves conflicts in lib/mcp/server/transports/streamable_http_transport.rb
between the DNS rebinding protection merged upstream (allowed_origins,
allowed_hosts, dns_rebinding_protection keywords) and this branch's session
retention hardening (session_idle_timeout default and max_sessions keyword)
by combining both sets of keyword arguments and constructor logic.
## Motivation and Context
CRuby's `IO#gets` without a limit reads until the next newline with no upper bound.
A peer that never emits a newline can therefore grow a single String until the process
is killed by the OS OOM killer.
This is a denial-of-service exposure (GHSA-7683-3w9x-ch42), identified during
a cross-SDK audit of the same unbounded-buffer pattern across the MCP SDKs.
It is most relevant when a host application spawns a sandboxed or untrusted MCP server
and pipes its stdout into an unconstrained host process, where a memory-capped peer
can exhaust the host.
Each frame is now capped at a configurable `max_line_bytes` (default 4 MiB,
large enough for any realistic frame including base64-embedded images,
and the value recommended in the advisory). A frame that reaches the limit without
a newline is treated as a transport error: the client raises, and the server reports
the error and closes the connection. A short final frame that ends at EOF without
a trailing newline is still accepted, so legitimate traffic is unaffected.
Both constructors validate `max_line_bytes:` as a positive Integer,
since `nil` or a non-positive value makes `IO#gets` read without an upper bound and
would silently disable the protection.
## How Has This Been Tested?
Server tests cover the `open` read loop stopping and reporting on an over-limit frame,
the `send_request` read loop raising on an over-limit response, and a valid final frame
without a trailing newline still being processed.
Client tests cover `read_response` raising on an over-limit response frame and
a normal response still round-tripping within a custom `max_line_bytes`.
Both transports also have tests rejecting invalid `max_line_bytes:` values
(`nil`, zero, negative, non-Integer) with an `ArgumentError`.
The full test suite and RuboCop pass.
## Breaking Changes
None. The default 4 MiB limit is far larger than any realistic JSON-RPC frame,
so existing well-behaved traffic is unaffected. The new `max_line_bytes:` keyword
on both transport constructors is purely additive and optional.
## Motivation and Context
In its default configuration `StreamableHTTPTransport` never expired sessions: every successful `initialize` stored
a new `ServerSession` under a fresh UUID, and the only removal path was an explicit client DELETE. An unauthenticated attacker
could repeatedly initialize and immediately disconnect, forcing the server to retain an unbounded number of sessions until
memory is exhausted; the reporter created and held 50,000 sessions in 26.6 seconds from a single client.
The reaper existed but only started when `session_idle_timeout` was set, and there was no cap on the concurrent session count,
so the insecure behavior was the default.
Of the reference SDKs only C# hardens this by default (a finite idle timeout plus a max session count); TypeScript, Python,
and Go all leave sessions unbounded out of the box, and the spec permits (MAY) session expiry. This adopts secure defaults
in the same spirit as C#, with one deliberate difference at the cap (reject vs. evict, below):
- `session_idle_timeout` now defaults to a finite 1800 seconds (30 minutes) in stateful mode, so the existing reaper starts by default
and idle sessions are reclaimed. Passing `session_idle_timeout: nil` opts out (the previous unbounded behavior); stateless mode
retains no sessions and keeps no timeout regardless. An internal sentinel distinguishes "argument omitted, apply the default" from
an explicit `nil`.
- A new `max_sessions` keyword (default 10000) caps the concurrent session count. `handle_initialization` checks and inserts under
the existing mutex so concurrent initializes cannot race past the limit. At the cap it first reclaims slots held by already-expired sessions
the 60s reaper has not yet collected (so the cap rejects only when genuinely full, not up to a reaper interval after sessions expired),
and if still full rejects the new `initialize` with HTTP 503. Unlike the C# SDK, which evicts the oldest idle session, this rejects:
established sessions stay stable and a legitimate idle session is not evicted on an attacker's behalf, at the cost of refusing new sessions
while full; a global cap thus bounds memory but can convert the memory-exhaustion DoS into a session-slot-exhaustion DoS,
which the idle timeout drains over time. Passing `max_sessions: nil` opts out; the cap does not apply to stateless mode.
The value is validated as a positive Integer or `nil`.
## How Has This Been Tested?
New and updated tests in `test/mcp/server/transports/streamable_http_transport_test.rb`: the finite idle timeout is applied and
the reaper starts by default while normal request flow is unaffected, `session_idle_timeout: nil` and stateless mode both keep
no timeout/reaper, a new `initialize` at `max_sessions` is rejected with 503 while the store stays at the cap, an already-expired session
is reclaimed in-line so a new initialize succeeds at the cap instead of being rejected, `max_sessions: nil` opts out,
and an invalid `max_sessions` raises `ArgumentError`. The pre-existing test asserting the old never-expire default was rewritten to
the new secure default.
## Breaking Changes
Stateful deployments now expire sessions after 30 minutes of inactivity and reject new sessions past 10000 concurrent, where before sessions
lived until an explicit DELETE. This is a security-hardening behavior change, documented in the README; deployments that require
the old unbounded behavior can pass `session_idle_timeout: nil` and `max_sessions: nil`.
## 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
MCP 2025-11-25 (modelcontextprotocol/modelcontextprotocol#1439) requires servers to validate
the `Origin` header on incoming Streamable HTTP connections and recommends binding only to localhost,
to prevent DNS rebinding attacks against locally bound MCP servers.
`StreamableHTTPTransport` previously validated neither `Host` nor `Origin`,
so a malicious web page could rebind its own hostname to 127.0.0.1 and reach a local server cross-origin,
invoking tools and exfiltrating their output. This is disclosed as GHSA-rjr6-rcgv-9m7m.
The TypeScript SDK (`enableDnsRebindingProtection` defaults to `false`, and is deprecated in favor of
external middleware) and the Python SDK (`transport_security.py` disables protection by default)
both ship this off for backwards compatibility, a constraint they cannot escape after 1.0.
Because this gem is still pre-1.0, it adopts the secure default now so protection is baked into the stable baseline:
- `Host` is validated against the loopback defaults (`127.0.0.1`, `::1`, `localhost`) plus any `allowed_hosts:`,
compared case-insensitively; each entry matches the bare host name (any port) or a full `host:port`.
This check is the primary anti-rebinding control, because a rebound request carries a foreign `Host`.
A request without a `Host` header (e.g. HTTP/1.0) is allowed because the rebinding vector always sends one.
- `Origin`, when present, must be same-origin or in `allowed_origins:`. Same-origin compares authority (host:port)
case-insensitively and uses the `Origin`'s own scheme to drop a redundant default port, so `http://example.com` matches
`Host: example.com:80`. Non-browser MCP clients that send no `Origin` header are unaffected.
- `dns_rebinding_protection: false` disables the check when an upstream proxy or middleware already validates these headers.
A rejected request returns HTTP 403 with a JSON-RPC error response carrying no `id`, as the spec permits.
## How Has This Been Tested?
New tests in `test/mcp/server/transports/streamable_http_transport_test.rb`:
- a cross-origin `Origin` is rejected by default, and a same-origin browser request is allowed
- a rebound foreign `Host` is rejected with the `Invalid Host header` body, while loopback values
(`127.0.0.1`, `localhost`, `[::1]`) are allowed
- an upper-cased loopback `Host` is accepted (case-insensitive matching)
- a non-loopback `Host` listed in `allowed_hosts:` is allowed
- a request with no `Host` header, and one with no `Origin` header, are allowed
- a default-port `Origin` (`http://localhost`) matches `Host: localhost:80`
- opaque and malformed `Origin`s (`null`, `file://`, a path suffix) are rejected
- a `host:port` `allowed_hosts:` entry matches that port only
- `Host` validation also applies to GET requests
- `dns_rebinding_protection: false` lets a forged `Host`/`Origin` through
## Breaking Changes
`StreamableHTTPTransport` now rejects non-loopback `Host` and cross-origin `Origin` requests by default.
A deployment behind a reverse proxy or bound to a non-loopback interface must set `allowed_hosts:` / `allowed_origins:`,
or pass `dns_rebinding_protection: false` when validation is handled upstream. Loopback servers and non-browser clients
are unaffected.
Co-authored-by: tonghuaroot <tonghuaroot@gmail.com>
## Motivation and Context
SEP-2567 (modelcontextprotocol/modelcontextprotocol#2567, merged for the 2026-07-28 spec release)
makes MCP sessionless: every Streamable HTTP POST must be fully self-contained,
with no protocol-level session state shared between requests.
`StreamableHTTPTransport` already implements the SEP's transport surface via
`stateless: true` (no `Mcp-Session-Id` issued or required, GET returns 405,
DELETE is a no-op, server-to-client requests raise), but its dispatch had a state leak:
stateless POSTs were handled with `session: nil`, so `Server#init` wrote `@client` and
`@client_capabilities` onto the shared `Server` instance.
Concurrent stateless requests could therefore observe another client's identity,
and the data persisted across requests, which is exactly what SEP-2567 forbids.
The TypeScript SDK's stateless prototypes (the closed typescript-sdk#2058/#2131/#2251 stack)
solve this with per-request dispatch; this change applies the equivalent fix within the existing architecture:
- Stateless `handle_initialization`, `handle_regular_request`, and `dispatch_notification` now
run handlers against an ephemeral per-request `ServerSession` (with `session_id: nil`),
so client info, logging level, and initialized state live only for the duration of that POST.
Repeated `initialize` requests are naturally permitted because each POST gets a fresh,
never-initialized session.
- `send_notification` in stateless mode now returns `false` (non-delivery) instead of raising.
With ephemeral sessions in place, a tool calling `server_context.report_progress` or `notify_log_message`
would otherwise route every call into the exception reporter; non-delivery matches how
these helpers already degrade when no session exists. `send_request` (server-to-client requests) still raises,
as those are genuinely unsupported without a stream.
Resolves#388.
## How Has This Been Tested?
New tests in `test/mcp/server/transports/streamable_http_transport_test.rb`:
- a stateless `initialize` POST leaves `Server#client_capabilities` and the server's `@client` untouched
(the leak regression)
- repeated `initialize` POSTs both succeed with 200 and no `Mcp-Session-Id` header
- a tool calling `server_context.report_progress` under stateless mode returns its result normally and
the exception reporter is never invoked
- the existing "stateless mode does not support server-sent events" test is updated to assert
the new `false` return instead of the removed raise
All other existing stateless-mode tests pass unchanged.
## Breaking Changes
`StreamableHTTPTransport#send_notification` in stateless mode now returns `false` instead of raising `RuntimeError`.
The raise message was not a documented contract, broadcasting in stateless mode was always a non-deliverable operation,
and the boolean return matches the method's documented semantics in every other non-delivery case.
Default (session-oriented) mode is unchanged.
## Motivation and Context
MCP 2025-11-25 (basic/transports#protocol-version-header) specifies:
> For backwards compatibility, if the server does not receive an
> `MCP-Protocol-Version` header, and has no other way to identify the
> version - for example, by relying on the protocol version negotiated
> during initialization - the server SHOULD assume protocol version `2025-03-26`.
PR #347 satisfied only the MUST requirement (reject invalid/unsupported
values with 400). The SHOULD requirement to default a missing header to
`2025-03-26` was deferred to this follow-up.
This change brings the Ruby SDK in line with the Python SDK
(`src/mcp/server/streamable_http.py`), which falls back to
`DEFAULT_NEGOTIATED_VERSION` and then revalidates against the supported list.
## Behavior
A new constant `MCP::Configuration::DEFAULT_NEGOTIATED_PROTOCOL_VERSION`
holds `"2025-03-26"`. `validate_protocol_version_header` now substitutes
this value when the `MCP-Protocol-Version` header is absent, then runs
the same supported-version check. Because `"2025-03-26"` is currently in
`SUPPORTED_STABLE_PROTOCOL_VERSIONS`, missing headers continue to pass
through as before. Once `"2025-03-26"` is dropped from the supported
list in a future change, missing headers will start returning 400 with
a message naming the defaulted value.
## How Has This Been Tested?
Existing test `POST request without MCP-Protocol-Version header
succeeds` continues to pass (observable behavior unchanged today).
Added a focused regression test that stubs the supported-version list
to exclude `"2025-03-26"` and verifies that a header-less request is
rejected with the defaulted value reported in the error message;
this exercises the fallback branch directly.
## Breaking Changes
None today. The fallback is future-proofing: when `"2025-03-26"` is
eventually dropped, header-less requests will start returning 400.
That is the intended spec-driven behavior.
## Motivation and Context
PR #347 introduced a single JSON-RPC error envelope response for
unsupported `MCP-Protocol-Version` headers, while the rest of the
transport-level error responses (Accept, Content-Type, Invalid JSON,
session management, method not allowed, internal server error) still
returned plain JSON of the form `{ "error": "..." }`. The Python SDK
(`src/mcp/server/streamable_http.py`) and TypeScript SDK
(`packages/server/src/server/streamableHttp.ts`) consistently use
a JSON-RPC error envelope for all transport-level errors.
Unify the Ruby SDK with them.
## Behavior
All transport-level error responses now return a JSON-RPC error envelope:
```json
{
"jsonrpc": "2.0",
"id": null,
"error": {
"code": -32600,
"message": "..."
}
}
```
Affected helpers in `lib/mcp/server/transports/streamable_http_transport.rb`:
- `validate_content_type` (HTTP 415)
- `not_acceptable_response` (HTTP 406)
- `parse_request_body` error (HTTP 400, `PARSE_ERROR` `-32700`)
- `method_not_allowed_response` (HTTP 405)
- `missing_session_id_response` (HTTP 400)
- `session_not_found_response` (HTTP 404)
- `session_already_connected_response` (HTTP 409)
- Internal server error fallback in `handle_post` (HTTP 500, `INTERNAL_ERROR` `-32603`)
- `validate_protocol_version_header` (HTTP 400, dedup with shared helper)
A new private helper `json_rpc_error_response(status:, code:, message:)`
centralizes the envelope construction.
The "Invalid JSON" message wording is updated to "Parse error: Invalid JSON"
to match the JSON-RPC 2.0 `PARSE_ERROR` semantics.
## How Has This Been Tested?
Updated all affected tests in `test/mcp/server/transports/streamable_http_transport_test.rb` to
assert the new envelope shape (`body["error"]["message"]` instead of `body["error"]`).
## Breaking Changes
Clients that parsed the previous plain `{"error": "..."}` shape will need to read
`body["error"]["message"]` (or `body["error"]["code"]`).
The HTTP status codes are unchanged, only the response body structure changed.
## Motivation and Context
RFC 9110 defines media type comparison as case-insensitive,
but `parse_accept_header` compared types without normalizing case.
This caused `Application/JSON` or `TEXT/Event-Stream` to be rejected
with 406 Not Acceptable.
## How Has This Been Tested?
Added tests for mixed-case Accept headers:
- `Accept: Application/JSON, Text/Event-Stream` succeeds for POST
- `Accept: TEXT/EVENT-STREAM` succeeds for GET SSE
## Breaking Changes
None. This is a bug fix bringing behavior into conformance with RFC 9110.
## Motivation and Context
`handle_post` and `handle_get` already use this helper, but
`handle_delete` inlined `request.env["HTTP_MCP_SESSION_ID"]`.
This aligns all three handlers consistently.
## How Has This Been Tested?
It has passed existing tests.
## Breaking Changes
None.
## Motivation and Context
The Ruby SDK accepted duplicate `initialize` requests after a session was already initialized.
On stdio, a second `initialize` silently overwrote the per-session `clientInfo` and `clientCapabilities`
(including with an older `protocolVersion`). On Streamable HTTP, every `initialize` minted a fresh
`Mcp-Session-Id` and `ServerSession`, abandoning the originally negotiated session.
MCP specification (`2025-06-18` / `2025-11-25` lifecycle) states that the initialization phase
MUST be the first interaction between client and server;
re-initialization on an established session is not part of the defined lifecycle.
TypeScript SDK rejects duplicate `initialize` on a live session with HTTP 400 + JSON-RPC `-32600`
("Invalid Request: Server already initialized"), and Python SDK does not mint a new session
on duplicate `initialize`. The Ruby SDK was the outlier; this change aligns it with the TypeScript SDK.
- `ServerSession` tracks an `@initialized` flag, exposed via `initialized?` and
set by `mark_initialized!` after a successful `initialize` response.
- `Server#init` raises `RequestHandlerError(error_type: :invalid_request)` when
the session is already initialized, which the existing error mapping converts
to JSON-RPC `-32600 Invalid Request`.
- `StreamableHTTPTransport#handle_post` short-circuits at the transport layer:
duplicate `initialize` against a live session returns HTTP 400 + JSON-RPC
`-32600`; a stale or expired `Mcp-Session-Id` returns 404 (evicting the expired entry
instead of misreporting it as a duplicate).
- `handle_initialization` evicts the registered session and omits the `Mcp-Session-Id`
header when the first `initialize` fails before `mark_initialized!` is reached,
so retries do not collide with an orphaned ID.
- Non-Hash JSON-RPC POST bodies (e.g. batched arrays, which are not supported in `2025-11-25`)
are explicitly rejected with HTTP 400 + JSON-RPC `-32600` rather than falling through to
an unparseable Rack response.
## How Has This Been Tested?
- Server tests: a second `initialize` on the same `ServerSession` returns
`code: -32600` and the original `clientInfo` is preserved.
- Streamable HTTP tests: duplicate `initialize` with a live `Mcp-Session-Id`
returns HTTP 400 + `-32600` and the original session remains usable for
subsequent `ping`; stale `Mcp-Session-Id` returns 404; an idle-expired
session is evicted on duplicate `initialize` and returns 404; a failed
`initialize` (invalid `jsonrpc` envelope) does not leak `Mcp-Session-Id` and
leaves `@sessions` empty; an array body is rejected with HTTP 400 + `-32600`.
- Stdio tests: a second `initialize` on the same stdio session returns
`code: -32600` and the original `clientInfo` is preserved.
## Breaking Changes
Clients that previously sent `initialize` more than once on the same session
now receive a JSON-RPC error with `code: -32600` for the second and later requests
instead of silently overwriting session state (stdio) or being re-issued
a new `Mcp-Session-Id` (Streamable HTTP). Clients that follow the MCP specification
(single `initialize` per session) are unaffected.
Additionally, non-Hash JSON-RPC POST bodies on Streamable HTTP now return
HTTP 400 + JSON-RPC `-32600` rather than falling through to a broken Rack response.
The previous behavior produced an unparseable response, so this is unlikely to
affect any working client.
Closes#349.
## Motivation and Context
MCP 2025-11-25 does not support JSON-RPC batch (batch support was removed in 2025-06-18),
so request bodies are expected to be a single JSON-RPC message object. The previous code path:
```ruby
body = parse_request_body(body_string)
return body unless body.is_a?(Hash)
```
returned the parsed JSON value as if it were a Rack response when
the body was a JSON array (former batch shape), a string, a number,
or any other non-Hash JSON value. This produced a malformed Rack response
instead of a proper HTTP 400.
This was a pre-existing bug exposed during follow-up review of #347
(`MCP-Protocol-Version` header validation), which surfaced it but did not fix
the broken response shape for non-Hash bodies sent with a valid header.
### Behavior
- `parse_request_body` now raises a private `InvalidJsonError` on parse
failure instead of returning a Rack response tuple. The overloaded
return type and the `parse_error_tuple?` discriminator are gone.
- `handle_post` rescues `InvalidJsonError` and returns the same plain
JSON 400 response as before (`{"error":"Invalid JSON"}`), so parse
error handling is observably unchanged.
- Non-Hash parsed bodies (arrays, strings, numbers, booleans, null) now
return HTTP 400 with a plain JSON body explaining that the body must
be a single JSON-RPC message object, instead of producing a malformed
Rack response.
The error response shape stays in the existing plain JSON style for
consistency with `validate_content_type`, `not_acceptable_response`,
and the other transport-level error helpers. Unifying all of these to
a JSON-RPC error envelope (matching the Python and TypeScript SDKs) is
deferred to a separate follow-up.
### SDK Comparison
- Python SDK (`src/mcp/server/streamable_http.py`): Already rejects
non-Hash bodies (Array, primitive) with HTTP 400. Uses a JSON-RPC
error envelope with `INVALID_PARAMS`.
- TypeScript SDK (`packages/server/src/server/streamableHttp.ts`):
As of `main`, still processes JSON arrays as JSON-RPC batches and
has not yet caught up with the 2025-06-18 batch removal. Primitives
fail schema validation and return 400 with a JSON-RPC error envelope.
This change brings the Ruby SDK in line with the current MCP spec
(no batch). The Ruby SDK is now closer to the spec than the TypeScript
SDK's `main` branch on this specific point.
## How Has This Been Tested?
Existing regression test `test "handles POST request with invalid
JSON"` continues to pass unchanged (plain JSON response preserved).
Added new regression tests:
- POST with a JSON array body returns 400 with a clear error message
- POST with a non-object JSON body (e.g. `"foo"`) returns 400
`bundle exec rake test` and `bundle exec rake rubocop` both pass.
## Breaking Changes
None for compliant clients sending single JSON-RPC message objects.
Clients that were sending JSON arrays (batch requests, no longer
supported as of MCP 2025-11-25) or other non-object bodies will now
receive a proper HTTP 400 with a JSON error body instead of
the previous malformed Rack response; no client could have relied on
the broken pre-existing behavior.
## Motivation and Context
When an `initialize` POST carries an `MCP-Protocol-Version` HTTP header that disagrees with
`params.protocolVersion` in the JSON-RPC body, the Streamable HTTP server accepts the request
and negotiates using the body value. The behavior was already encoded in `handle_post`
(the header check is skipped on `initialize`) but it was not stated in the code and there was
no regression test for the specific mismatch case.
MCP 2025-06-18 / 2025-11-25 only requires `MCP-Protocol-Version` on requests subsequent to
`initialize`, and the spec does not require the header and body to agree on the initial `initialize`.
All official MCP SDKs (TypeScript, Python, Rust, Java, Go, PHP) accept the mismatch and
treat the JSON-RPC body as authoritative.
## How Has This Been Tested?
Streamable HTTP tests: two new regression tests confirm that on an `initialize` POST
with a supported but mismatched `MCP-Protocol-Version` header, the server returns
HTTP 200 and the response's `result.protocolVersion` matches the JSON-RPC body value,
in both mismatch directions (header older / body newer, and header newer / body older).
## Breaking Changes
None. The behavior is unchanged: `initialize` requests with a mismatched `MCP-Protocol-Version` header
are still accepted, and the JSON-RPC body `params.protocolVersion` continues to drive negotiation.
This commit only adds an explanatory comment and regression tests.
Closes#351.
## Motivation and Context
MCP 2025-11-25 (basic/transports#protocol-version-header) requires servers
to respond with `400 Bad Request` when receiving a request with an invalid
or unsupported `MCP-Protocol-Version` header:
> If the server receives a request with an invalid or unsupported
> `MCP-Protocol-Version`, it MUST respond with `400 Bad Request`.
The Ruby SDK was not reading the header at all, accepting any value
(including malformed strings like `not-a-version` and unsupported versions
like `1900-01-01`) and dispatching requests normally with HTTP 200.
## Behavior
`StreamableHTTPTransport` now validates the `MCP-Protocol-Version` header
on POST (non-initialize), GET, and DELETE requests. Missing headers are
accepted as before; the spec SHOULD requirement to default to `2025-03-26`
is intentionally left for a follow-up. The `initialize` POST is exempt
because the client does not know the negotiated version until the response
arrives, matching the Python (`src/mcp/server/streamable_http.py`) and
TypeScript (`packages/server/src/server/streamableHttp.ts`) SDKs.
The error response uses a JSON-RPC envelope with `code: -32600`
(`INVALID_REQUEST`) to match the Python SDK shape:
```json
{
"jsonrpc": "2.0",
"id": null,
"error": {
"code": -32600,
"message": "Bad Request: Unsupported protocol version: <value>. Supported versions: ..."
}
}
```
Validation order is session then protocol version, matching the Python and
TypeScript SDKs.
`handle_post` was reorganized so that non-Hash request bodies (e.g., JSON
arrays, which MCP 2025-11-25 no longer supports as batches) also pass
through the header check. The pre-existing broken response for Array
bodies sent with a valid header is unchanged and is tracked as a separate
follow-up.
## How Has This Been Tested?
Added regression tests covering:
- POST `initialize` ignores the header (bypass)
- POST non-initialize with unsupported / malformed / valid / missing values
- POST array body with unsupported value returns 400
- GET with unsupported / missing values
- DELETE with unsupported value in both stateful and stateless modes
- DELETE validates session before protocol version
`bundle exec rake test` and `bundle exec rake rubocop` both pass.
## Breaking Changes
Strictly additive for compliant clients. Third-party clients sending stale
or malformed `MCP-Protocol-Version` values will now receive `400` instead
of `200`, which is the intended spec behavior.
Fixes#346.
## Motivation and Context
The MCP specification defines `notifications/cancelled` so either party can stop
a previously-issued in-flight request. The Ruby SDK declared the method constant
but had no receive logic, no in-flight tracking, and no way for handlers to participate
in cancellation; cancellation notifications fell through to the unsupported-method path.
This PR implements the **server-side** half of the spec. The server stops processing
the targeted request cooperatively and suppresses its JSON-RPC response, matching
the Python SDK's anyio `CancelScope` and the TypeScript SDK's `RequestHandlerExtra.signal`.
Cancellation is observable by every user-overridable request handler:
- `Tool.call` (tools/call)
- prompt templates (prompts/get)
- blocks registered via `resources_read_handler` / `completion_handler` /
`resources_subscribe_handler` / `resources_unsubscribe_handler` / `define_custom_method`
Each handler opts in by declaring a `server_context:` keyword and polls `server_context.cancelled?`
or calls `server_context.raise_if_cancelled!` (raising `MCP::CancelledError`) inside long-running work.
Handlers that keep their existing `|params|` (or `|args|`) signature continue to work unchanged.
On `StreamableHTTPTransport`, cancelling a parent `tools/call` automatically cancels n
ested server-to-client requests (`sampling/createMessage`, `elicitation/create`);
the nested `send_request` raises `MCP::CancelledError` and a cancel notification is routed to
the peer on the parent's POST response stream. `StdioTransport` is single-threaded and blocks on `$stdin.gets`,
so it deliberately does not propagate nested cancellation.
Tools running on stdio still observe cancellation between calls via `server_context.cancelled?`.
The design is cooperative-only (no `Thread#raise`) because preemptive cancellation is unsafe
across Rack adapters and arbitrary handler code. The `initialize` request is never cancellable,
satisfying the spec rule that it MUST NOT be cancelled. Unknown / completed / duplicate cancel notifications
are silently ignored per the spec.
`MCP::Client#cancel` (an equivalent that aborts the calling thread's synchronous wait) is deferred to a follow-up PR.
Ref: https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation
## How Has This Been Tested?
`test/mcp/cancellation_test.rb` covers the `MCP::Cancellation` token contract.
`test/mcp/server_cancellation_test.rb` covers end-to-end cancellation:
- A handler spins on `cancelled?` in a background thread; the test sends `notifications/cancelled`
and asserts the handler observed cancellation and the JSON-RPC response was suppressed.
Each user-overridable handler type (tool, prompt template, resources/read, completion, custom method)
has its own regression test.
- `initialize` is not cancellable; unknown / duplicate / late-after-completion cancels are silently ignored.
- The `reason` propagates into the `cancellation_reason` instrumentation field.
- Custom transports implementing only the abstract `(method, params = nil)` contract keep working;
the new kwargs (`session_id:`, `related_request_id:`, `parent_cancellation:`, `server_session:`)
are silently dropped when the transport's signature does not declare them.
`StreamableHTTPTransport` tests cover nested cancellation, hook deregistration after normal completion
(so a late parent cancel does not emit a stray `notifications/cancelled`), and the first-writer-wins race
when a real response and a cancel arrive concurrently.
## Breaking Change
None. `MCP::ServerContext#initialize` gains an optional `cancellation:` keyword (defaults to `nil`),
and `StreamableHTTPTransport#send_request` / `#send_notification` gain optional cancellation kwargs.
The abstract `Transport` base class and `StdioTransport` keep their existing `(method, params = nil)` signatures,
and custom transports following those signatures continue to work. `StreamableHTTPTransport` now dispatches
client-originated notifications through `ServerSession#handle_json` before returning 202;
previously these were accepted without dispatch, but the existing handlers for `notifications/initialized`
and `notifications/progress` were already no-ops, so no user-visible effect is expected.
Adding `server_context:` to a handler block is strictly opt-in.
## Motivation and Context
The MCP Streamable HTTP specification allows servers to return POST responses as either
`text/event-stream` (SSE) or `application/json`:
> If the input is a JSON-RPC request, the server MUST either return `Content-Type: text/event-stream`,
> to initiate an SSE stream, or `Content-Type: application/json`, to return one JSON object.
See: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server
The TypeScript and Python SDKs support a configurable JSON response mode
via `enableJsonResponse` / `is_json_response_enabled`.
JSON response mode is suitable for simple tool servers that do not need server-initiated requests.
It avoids SSE framing overhead and returns a single JSON object for the POST response.
## Behavior
- POST responses use `Content-Type: application/json` and return a single JSON object.
- The POST `Accept` header requirement is relaxed to `application/json` only
(matching the Python SDK's lenient behavior).
- Request-scoped notifications (`progress`, `log`) cannot ride along with the single-object response
and are silently dropped.
- Session-scoped standalone notifications (`resources/updated`, `elicitation/complete`)
and broadcast notifications (`tools/list_changed`, etc.) continue to flow to clients connected
to the GET SSE stream.
- All server-to-client requests (`sampling/createMessage`, `roots/list`, `elicitation/create`)
raise an error in JSON response mode.
- Combines with `stateless: true` for simple single-response servers without session tracking.
## How Has This Been Tested?
Added tests for JSON response mode:
- POST request returns `application/json` response
- Accept header validation requires only `application/json`
- Returns 406 when Accept header is missing
- Accepts wildcard `*/*` in Accept header
- Request-scoped notifications (progress, log) during tool execution are silently dropped
- Request-scoped notifications do not leak to GET SSE even when connected
- Session-scoped standalone notifications are delivered via GET SSE
- Broadcast notifications are delivered via GET SSE
- `send_request` raises for `sampling/createMessage`, `roots/list`, `elicitation/create`
- Combined with `stateless: true`: POST returns JSON without a session ID, GET returns 405
## Breaking Changes
None. JSON response mode is opt-in via `enable_json_response: true`.
The default behavior (SSE responses) is unchanged.
## Motivation and Context
The stream object always responds to flush, so the conditional check is unnecessary.
## How Has This Been Tested?
All existing tests are passing.
## Breaking Changes
None.
## Motivation and Context
Add `call(env)` to `StreamableHTTPTransport`, making it a Rack application
that works with `mount`, `run`, and Rack middleware.
Refactor examples to use Rack middleware classes for MCP logging instead of proc wrappers,
demonstrating idiomatic Rack composition with the new `run(transport)` pattern.
Update README.md with mount and controller integration patterns.
Closes#59, #60
## How Has This Been Tested?
Added tests for `call(env)` as a Rack app. All tests pass.
## Breaking Change
No breaking changes. All existing APIs are preserved:
- `StreamableHTTPTransport.new(server)` continues to work as before.
- `handle_request(request)` is unchanged. The new `call(env)` is a purely additive public method.
## Motivation and Context
The MCP Streamable HTTP specification requires POST request bodies to
be JSON-RPC messages with `Content-Type: application/json`. The Ruby
SDK did not validate Content-Type, accepting requests with any or no
Content-Type. The Python SDK validates this and returns 415
Unsupported Media Type for non-JSON Content-Types.
## How Has This Been Tested?
Added tests for Content-Type validation:
- POST without Content-Type returns 415
- POST with `Content-Type: text/plain` returns 415
- POST with `Content-Type: application/json; charset=utf-8` succeeds
## Breaking Changes
POST requests without `Content-Type: application/json` now return
415 Unsupported Media Type. This is a spec compliance fix.