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:
parent
9cd1041e7c
commit
3da0c10bed
2 changed files with 115 additions and 9 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue