Describe the bug
The documented Rails controller integration crashes with NoMethodError on any subscriptions/listen request, because handle_request can return a Proc Rack body (the SSE stream) while the documented example assumes the body is always an Array.
The Rails (controller) example ends with:
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true)
status, headers, body = transport.handle_request(request)
render(json: body.first, status: status, headers: headers)
For subscriptions/listen, handle_modern routes to handle_subscriptions_listen, which returns [200, SSE_HEADERS, listen_sse_body(...)], and listen_sse_body is a proc do |stream|. body.first then raises:
NoMethodError: undefined method 'first' for an instance of Proc
Two things make this reachable in exactly the configuration the docs recommend:
- The modern path ignores
stateless:. handle_modern's own comment states it "never consults @stateless, @sessions, or @enable_json_response", so stateless: true — which the example sets, and which the docs describe as the correct setting for the controller pattern — does not keep the transport off the streaming branch.
serves_subscriptions_listen? is hardcoded true and does not gate the route. It is consulted only by Server#discover_capabilities to strip listChanged/subscribe flags. Even a server that advertises no subscription capabilities at all still routes an incoming subscriptions/listen to the SSE branch — honored_filter just returns an empty honored set and the stream opens anyway.
So a controller-hosted server cannot decline the stream through configuration, and cannot serve it either: render buffers, so the Proc is never called with a stream.
To Reproduce
This needs no Rails app — the transport alone shows it. Against mcp 1.3.0:
require "mcp"
require "rack"
require "json"
class ExampleTool < MCP::Tool
description "An example tool"
input_schema(properties: { message: { type: "string" } }, required: ["message"])
def self.call(message:, server_context:)
MCP::Tool::Response.new([{ type: "text", text: message }])
end
end
def post(body_hash, headers)
env = Rack::MockRequest.env_for(
"http://localhost/mcp",
method: "POST",
input: JSON.generate(body_hash),
"CONTENT_TYPE" => "application/json",
)
headers.each { |k, v| env[k] = v }
Rack::Request.new(env)
end
server = MCP::Server.new(name: "my_server", version: "1.0.0", tools: [ExampleTool])
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true)
request = post(
{
jsonrpc: "2.0",
id: 1,
method: "subscriptions/listen",
params: {
notifications: { toolsListChanged: true },
_meta: {
"io.modelcontextprotocol/protocolVersion" => "2026-07-28",
"io.modelcontextprotocol/clientCapabilities" => {},
},
},
},
{
"HTTP_ACCEPT" => "application/json, text/event-stream",
"HTTP_MCP_PROTOCOL_VERSION" => "2026-07-28",
"HTTP_MCP_METHOD" => "subscriptions/listen",
},
)
status, headers, body = transport.handle_request(request)
puts "status: #{status}, content-type: #{headers["content-type"]}, body: #{body.class}"
body.first # what the documented controller example does
Output:
status: 200, content-type: text/event-stream, body: Proc
repro.rb:...: undefined method 'first' for an instance of Proc (NoMethodError)
A tools/list request on the same transport returns an Array body, which is why the example works for every other method.
Expected behavior
A stateless: true transport should not hand back a body its documented host cannot serve. Any of these would resolve it:
- Make
subscriptions/listen refusable. Let serves_subscriptions_listen? (or a constructor option) gate the route, not just the advertised capabilities, so a server that cannot stream answers -32601 instead of opening a stream. This is the option we'd prefer: it makes the capability advertisement and the actual behavior agree.
- Decline it implicitly in stateless mode. In
stateless: true the listen registry lives on a transport instance that is discarded at the end of the request, so a stream opened there can never receive a notification even in principle — arguably it should never be offered.
- At minimum, fix the documented example to handle a non-Array body, and say in the docs that the controller pattern cannot serve
subscriptions/listen.
Happy to send a PR for any of these — say which shape you'd prefer and I'll write it against that. Option 1 is small (gate the route on the existing hook plus tests), but since it, a new constructor option, and implicit refusal in stateless mode are all defensible, I'd rather build the one you want than guess.
Additional context
We hit this in production after upgrading 1.1.0 → 1.3.0 to pick up the #512 fix. The upgrade didn't change what our client sent — claude.ai had been sending subscriptions/listen all along — it changed how the server answered it. On 1.1.0 the request fell through to the legacy path and got a harmless JSON-RPC method-not-found; on 1.3.0 the same request reaches the modern path and 500s. Anyone hosting via the documented controller pattern and serving a modern-protocol (2026-07-28) client inherits the same regression on upgrade.
Worth noting how quiet the failure is: only the notification stream 500s, so tool calls, initialize, and tools/list all keep working and users see nothing. The client retries with backoff and gives up. Without error tracking pointed at the endpoint, this produces no user-visible symptom at all — which may be why it hasn't been reported yet.
Our own fix, for anyone who lands here first: reject subscriptions/listen before invoking the transport (the Mcp-Method header is required and mirrored on every modern POST, so it identifies the request without parsing the body), declare capabilities without listChanged so well-behaved clients don't ask, and guard the response handling against a non-Array body.
Environment
mcp 1.3.0
- Ruby 3.4.10
- Rails 8.1,
ActionController::API controller, StreamableHTTPTransport with stateless: true
- Client: claude.ai connector, negotiating 2026-07-28
Describe the bug
The documented Rails controller integration crashes with
NoMethodErroron anysubscriptions/listenrequest, becausehandle_requestcan return aProcRack body (the SSE stream) while the documented example assumes the body is always an Array.The Rails (controller) example ends with:
For
subscriptions/listen,handle_modernroutes tohandle_subscriptions_listen, which returns[200, SSE_HEADERS, listen_sse_body(...)], andlisten_sse_bodyis aproc do |stream|.body.firstthen raises:Two things make this reachable in exactly the configuration the docs recommend:
stateless:.handle_modern's own comment states it "never consults@stateless,@sessions, or@enable_json_response", sostateless: true— which the example sets, and which the docs describe as the correct setting for the controller pattern — does not keep the transport off the streaming branch.serves_subscriptions_listen?is hardcodedtrueand does not gate the route. It is consulted only byServer#discover_capabilitiesto striplistChanged/subscribeflags. Even a server that advertises no subscription capabilities at all still routes an incomingsubscriptions/listento the SSE branch —honored_filterjust returns an empty honored set and the stream opens anyway.So a controller-hosted server cannot decline the stream through configuration, and cannot serve it either:
renderbuffers, so theProcis never called with a stream.To Reproduce
This needs no Rails app — the transport alone shows it. Against
mcp1.3.0:Output:
A
tools/listrequest on the same transport returns an Array body, which is why the example works for every other method.Expected behavior
A
stateless: truetransport should not hand back a body its documented host cannot serve. Any of these would resolve it:subscriptions/listenrefusable. Letserves_subscriptions_listen?(or a constructor option) gate the route, not just the advertised capabilities, so a server that cannot stream answers-32601instead of opening a stream. This is the option we'd prefer: it makes the capability advertisement and the actual behavior agree.stateless: truethe listen registry lives on a transport instance that is discarded at the end of the request, so a stream opened there can never receive a notification even in principle — arguably it should never be offered.subscriptions/listen.Happy to send a PR for any of these — say which shape you'd prefer and I'll write it against that. Option 1 is small (gate the route on the existing hook plus tests), but since it, a new constructor option, and implicit refusal in stateless mode are all defensible, I'd rather build the one you want than guess.
Additional context
We hit this in production after upgrading 1.1.0 → 1.3.0 to pick up the #512 fix. The upgrade didn't change what our client sent — claude.ai had been sending
subscriptions/listenall along — it changed how the server answered it. On 1.1.0 the request fell through to the legacy path and got a harmless JSON-RPC method-not-found; on 1.3.0 the same request reaches the modern path and 500s. Anyone hosting via the documented controller pattern and serving a modern-protocol (2026-07-28) client inherits the same regression on upgrade.Worth noting how quiet the failure is: only the notification stream 500s, so tool calls,
initialize, andtools/listall keep working and users see nothing. The client retries with backoff and gives up. Without error tracking pointed at the endpoint, this produces no user-visible symptom at all — which may be why it hasn't been reported yet.Our own fix, for anyone who lands here first: reject
subscriptions/listenbefore invoking the transport (theMcp-Methodheader is required and mirrored on every modern POST, so it identifies the request without parsing the body), declare capabilities withoutlistChangedso well-behaved clients don't ask, and guard the response handling against a non-Array body.Environment
mcp1.3.0ActionController::APIcontroller,StreamableHTTPTransportwithstateless: true