Make subscriptions/listen refusable for hosts that cannot stream

## 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.
This commit is contained in:
Koichi ITO 2026-08-25 01:24:36 +09:00
commit 3da0c10bed
4 changed files with 115 additions and 9 deletions

View file

@ -52,6 +52,9 @@ The first SSE event on the stream is the acknowledgement:
## Transport Support
The stream is served on the Streamable HTTP modern path; stdio answers `-32601`.
A host that cannot hold an SSE response open (such as the [Rails controller pattern](/server/transports/#rails-controller),
which buffers the body) declines the method with `serve_subscriptions_listen: false`, answering `-32601` and dropping
the `listChanged`/`subscribe` flags from discovery.
## Limits and Keepalives

View file

@ -153,8 +153,13 @@ class McpController < ActionController::API
prompts: [MyPrompt],
server_context: { user_id: current_user.id },
)
# Since the `MCP-Session-Id` is not shared across requests, `stateless: true` is set.
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true)
# Since the `MCP-Session-Id` is not shared across requests, `stateless: true` is set,
# and since `render` buffers the whole body, `subscriptions/listen` streams are declined.
transport = MCP::Server::Transports::StreamableHTTPTransport.new(
server,
stateless: true,
serve_subscriptions_listen: false,
)
status, headers, body = transport.handle_request(request)
render(json: body.first, status: status, headers: headers)
@ -162,6 +167,15 @@ class McpController < ActionController::API
end
```
{: .important }
> The controller pattern builds a fresh transport per request and `render` buffers the whole body,
> so it cannot serve the open SSE stream that [`subscriptions/listen`](/server/subscriptions/) needs:
> without `serve_subscriptions_listen: false`, that request returns a streaming `Proc` body
> that `body.first` cannot render, and a modern client's notification stream fails with a 500.
> With the flag, the method answers 404 with JSON-RPC `-32601`, and `server/discover` stops
> advertising the `listChanged`/`subscribe` capability flags, so well-behaved clients do not ask.
> To actually serve listen streams, use the [mount approach](#rails-mount).
### Stateless Mode
You can use Stateless Streamable HTTP, where notifications are not supported and all calls are request/response interactions.

View file

@ -129,6 +129,12 @@ module MCP
# on a `subscriptions/listen` stream; the periodic write frees the stream's slot when the peer
# has gone away. Defaults to `DEFAULT_LISTEN_KEEPALIVE_INTERVAL` (15); pass `nil` to disable
# when an upstream proxy already keeps the stream alive.
# @param serve_subscriptions_listen [Boolean] whether `subscriptions/listen` opens a stream.
# A host that buffers responses and cannot serve an open SSE stream (e.g. the Rails controller pattern,
# which builds a fresh transport per request and renders the body) passes `false`:
# the method then answers 404 with JSON-RPC `-32601` like any unimplemented method,
# and `Server#discover` stops advertising the `listChanged`/`subscribe` capability flags,
# keeping the advertisement and the actual behavior in agreement. Defaults to `true`.
# @param server_to_client_request_timeout [Numeric] seconds a server-to-client request waits for its
# response before the transport stops waiting and raises `MCP::Server::RequestTimeoutError`.
# Defaults to `DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT` (600); individual calls override it with `timeout:`.
@ -145,6 +151,7 @@ module MCP
max_request_bytes: DEFAULT_MAX_REQUEST_BYTES,
max_listen_subscriptions: DEFAULT_MAX_LISTEN_SUBSCRIPTIONS,
listen_keepalive_interval: DEFAULT_LISTEN_KEEPALIVE_INTERVAL,
serve_subscriptions_listen: true,
server_to_client_request_timeout: DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT
)
super(server)
@ -211,6 +218,7 @@ module MCP
end
@listen_keepalive_interval = listen_keepalive_interval
@serve_subscriptions_listen = serve_subscriptions_listen
unless server_to_client_request_timeout.is_a?(Numeric) && server_to_client_request_timeout.positive?
raise ArgumentError, "server_to_client_request_timeout must be a positive number"
@ -260,10 +268,12 @@ module MCP
handle_request(Rack::Request.new(env))
end
# The `subscriptions/listen` notification stream (SEP-2575) is served on the modern path,
# so `Server#discover` may advertise `listChanged`/`subscribe` capability flags.
# Whether this transport serves the `subscriptions/listen` notification stream (SEP-2575).
# Gates both the route (a refusing transport answers the method as unimplemented) and
# the `listChanged`/`subscribe` capability flags `Server#discover` advertises,
# so the two always agree. Set via the `serve_subscriptions_listen:` constructor keyword.
def serves_subscriptions_listen?
true
@serve_subscriptions_listen
end
def handle_request(request)
@ -723,8 +733,13 @@ module MCP
return mismatch_error if mismatch_error
# `subscriptions/listen` is a long-lived notification stream served at the transport layer;
# it never dispatches through `Server#handle`.
return handle_subscriptions_listen(body) if body[:method] == Methods::SUBSCRIPTIONS_LISTEN
# it never dispatches through `Server#handle`. A transport constructed with
# `serve_subscriptions_listen: false` skips the interception, so the method falls through
# to the dispatcher as unimplemented (404 with `-32601`) - the refusal a host that cannot
# serve an open SSE stream needs, instead of a `Proc` body it can never call.
if body[:method] == Methods::SUBSCRIPTIONS_LISTEN && serves_subscriptions_listen?
return handle_subscriptions_listen(body)
end
session = modern_session
notifications = @mutex.synchronize { @modern_request_sinks[session.session_id] = [] }
@ -881,10 +896,34 @@ module MCP
)
end
# The proc registers the stream and returns, leaving the response open like
# The Rack streaming body of a `subscriptions/listen` response. It responds to `call`
# and deliberately not to `each`, so Rack keeps classifying it as a streaming body;
# `first` exists only to turn the buffered-host mistake (e.g. `render(json: body.first)`
# in the Rails controller pattern) from a bare `NoMethodError` into guidance naming the fix.
class ListenStreamBody
def initialize(&block)
@block = block
end
def call(stream)
@block.call(stream)
end
def first
raise <<~MESSAGE
subscriptions/listen returned a streaming SSE body, which cannot be buffered into a JSON response. \
A host that cannot hold an SSE response open should construct the transport with `serve_subscriptions_listen: false`, \
so the method is answered as unimplemented instead.
See the Rails (controller) section at https://ruby.sdk.modelcontextprotocol.io/server/transports/ for the hosting patterns.
MESSAGE
end
end
private_constant :ListenStreamBody
# The body registers the stream and returns, leaving the response open like
# the legacy GET stream (`create_sse_body`).
def listen_sse_body(request_id, honored)
proc do |stream|
ListenStreamBody.new do |stream|
rejected = false
@mutex.synchronize do
if @listen_subscriptions.key?(request_id) ||

View file

@ -6156,6 +6156,56 @@ module MCP
transport.close
end
test "a buffered read of the listen stream body raises guidance instead of NoMethodError" do
response = @transport.handle_request(modern_rack_request(
modern_listen_body(id: "listen-1", params: { notifications: { toolsListChanged: true } }),
))
error = assert_raises(RuntimeError) { response[2].first }
assert_includes error.message, "serve_subscriptions_listen: false"
# The cited page is a published-URL contract, like the kwarg name above.
assert_includes error.message, "https://ruby.sdk.modelcontextprotocol.io/server/transports/"
# Responding to `each` would make Rack classify the body as enumerable and break streaming.
refute_respond_to response[2], :each
end
test "subscriptions/listen is refused as unimplemented when the transport does not serve it" do
transport = StreamableHTTPTransport.new(@server, serve_subscriptions_listen: false)
status, headers, body = transport.handle_request(modern_rack_request(
modern_listen_body(id: "listen-1", params: { notifications: { toolsListChanged: true } }),
))
assert_equal 404, status
assert_equal "application/json", headers["content-type"]
# The documented Rails controller pattern renders `body.first`, so the refusal must be an Array body,
# never the streaming Proc.
assert_kind_of Array, body
assert_equal(-32601, JSON.parse(body.first).dig("error", "code"))
ensure
transport.close
end
test "discover stops advertising subscription capabilities when listen is not served" do
server = Server.new(
name: "listen_test",
capabilities: { tools: { listChanged: true }, resources: { listChanged: true, subscribe: true } },
)
transport = StreamableHTTPTransport.new(server, serve_subscriptions_listen: false)
response = transport.handle_request(modern_rack_request(modern_body("server/discover", {})))
capabilities = JSON.parse(response[2].first).dig("result", "capabilities")
assert_nil capabilities.dig("tools", "listChanged")
assert_nil capabilities.dig("resources", "listChanged")
assert_nil capabilities.dig("resources", "subscribe")
ensure
transport.close
end
test "subscriptions/listen past the concurrent stream cap is rejected with 503" do
transport = StreamableHTTPTransport.new(@server, max_listen_subscriptions: 1)
open_listen_stream(id: "listen-1", notifications: { toolsListChanged: true }, transport: transport)