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
1 changed files with 115 additions and 9 deletions
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue