Skip to content

Make subscriptions/listen refusable for hosts that cannot stream - #533

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

Make subscriptions/listen refusable for hosts that cannot stream#533
koic wants to merge 1 commit into
modelcontextprotocol:mainfrom
koic:make_subscriptions_listen_refusable

Conversation

@koic

@koic koic commented Aug 24, 2026

Copy link
Copy Markdown
Member

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.

Types of changes

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

Checklist

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

## Motivation and Context

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 modelcontextprotocol#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.
@koic
koic force-pushed the make_subscriptions_listen_refusable branch from 392f15e to 3da0c10 Compare August 24, 2026 17:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Documented Rails controller example crashes on subscriptions/listen: handle_request returns a Proc body

2 participants