Skip to content

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

Description

@evdevdev

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:

  1. 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.
  2. 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:

  1. 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.
  2. 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.
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions