## 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.
288 lines
14 KiB
Markdown
288 lines
14 KiB
Markdown
---
|
|
layout: default
|
|
title: Transports
|
|
nav_order: 2
|
|
---
|
|
|
|
# Transports
|
|
|
|
The server ships two transports: [stdio](#stdio-transport) for command-line and desktop integrations,
|
|
and [Streamable HTTP](#streamable-http-transport) for web deployments.
|
|
This page covers starting each transport, mounting the HTTP transport in Rack and Rails applications,
|
|
and its deployment, session, and security settings.
|
|
|
|
## Stdio Transport
|
|
|
|
If you want to build a local command-line application, you can use the stdio transport:
|
|
|
|
```ruby
|
|
require "mcp"
|
|
|
|
# Create a simple tool
|
|
class ExampleTool < MCP::Tool
|
|
description "A simple example tool that echoes back its arguments"
|
|
input_schema(
|
|
properties: {
|
|
message: { type: "string" },
|
|
},
|
|
required: ["message"]
|
|
)
|
|
|
|
class << self
|
|
def call(message:, server_context:)
|
|
MCP::Tool::Response.new([{
|
|
type: "text",
|
|
text: "Hello from example tool! Message: #{message}",
|
|
}])
|
|
end
|
|
end
|
|
end
|
|
|
|
# Set up the server
|
|
server = MCP::Server.new(
|
|
name: "example_server",
|
|
tools: [ExampleTool],
|
|
)
|
|
|
|
# Create and start the transport
|
|
transport = MCP::Server::Transports::StdioTransport.new(server)
|
|
transport.open
|
|
```
|
|
|
|
`StdioTransport.new` accepts an optional `max_line_bytes:` keyword that caps the byte length of a single newline-delimited request frame. A frame that reaches this limit without a newline is rejected and the connection is closed, preventing unbounded memory growth from a peer that never emits a newline. It defaults to `4 * 1024 * 1024` (4 MiB).
|
|
|
|
You can run this script and then type in requests to the server at the command line.
|
|
|
|
```console
|
|
$ ruby examples/stdio_server.rb
|
|
{"jsonrpc":"2.0","id":"1","method":"ping"}
|
|
{"jsonrpc":"2.0","id":"2","method":"tools/list"}
|
|
{"jsonrpc":"2.0","id":"3","method":"tools/call","params":{"name":"example_tool","arguments":{"message":"Hello"}}}
|
|
```
|
|
|
|
## Streamable HTTP Transport
|
|
|
|
`MCP::Server::Transports::StreamableHTTPTransport` is a standard Rack app, so it can be mounted in any Rack-compatible framework.
|
|
The following examples show two common integration styles in Rails.
|
|
|
|
{: .important }
|
|
> On the legacy handshake lifecycle, `MCP::Server::Transports::StreamableHTTPTransport` stores session and
|
|
> SSE stream state in memory, so it must run in a single process. Use a single-process server (e.g., Puma with
|
|
> `workers 0`). Multi-process configurations (Unicorn, or Puma with `workers > 0`) fork separate processes that
|
|
> do not share memory, which breaks session management and SSE connections.
|
|
>
|
|
> When running multiple server instances behind a load balancer, configure your load balancer to use
|
|
> sticky sessions (session affinity) so that requests with the same `Mcp-Session-Id` header are always
|
|
> routed to the same instance.
|
|
>
|
|
> Stateless mode (`stateless: true`) does not use sessions and works with any server configuration.
|
|
> Requests of the [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle) (MCP 2026-07-28) are likewise sessionless single exchanges and work
|
|
> with any configuration, with two caveats: a [`subscriptions/listen`](/server/subscriptions/) stream
|
|
> is held in memory by the process that accepted it, so only notifications emitted in that process reach it,
|
|
> and the [multi round-trip](/server/mrtr/) `requestState` crosses workers only when they
|
|
> share a `RequestStateSecurity` key.
|
|
|
|
{: .important }
|
|
> Per MCP 2025-11-25, `StreamableHTTPTransport` validates the `Host` and `Origin` headers by default to
|
|
> prevent DNS rebinding attacks against locally bound servers, rejecting unauthorized values with HTTP 403.
|
|
> `Host` is allowed for the loopback defaults (`127.0.0.1`, `::1`, `localhost`), and an `Origin` header,
|
|
> when present, must be same-origin or explicitly allow-listed. Non-browser clients that send no `Origin`
|
|
> header are unaffected.
|
|
>
|
|
> Deployments behind a reverse proxy or bound to a non-loopback interface must widen the allow lists:
|
|
>
|
|
> ```ruby
|
|
> transport = MCP::Server::Transports::StreamableHTTPTransport.new(
|
|
> server,
|
|
> allowed_hosts: ["mcp.example.com"],
|
|
> allowed_origins: ["https://app.example.com"],
|
|
> )
|
|
> ```
|
|
>
|
|
> An `allowed_hosts:` entry matches either the bare host name (any port) or the full `host:port` value,
|
|
> so both `"mcp.example.com"` and `"mcp.example.com:8443"` work. Pass `dns_rebinding_protection: false`
|
|
> to disable the check entirely (e.g., when an upstream proxy or middleware already validates `Host`/`Origin`).
|
|
> The check runs before any lifecycle dispatch, so it protects requests of
|
|
> the [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle) as well.
|
|
|
|
### Rails (mount)
|
|
|
|
`StreamableHTTPTransport` is a Rack app that can be mounted directly in Rails routes:
|
|
|
|
```ruby
|
|
# config/routes.rb
|
|
server = MCP::Server.new(
|
|
name: "my_server",
|
|
title: "Example Server Display Name",
|
|
version: "1.0.0",
|
|
instructions: "Use the tools of this server as a last resort",
|
|
tools: [SomeTool, AnotherTool],
|
|
prompts: [MyPrompt],
|
|
)
|
|
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server)
|
|
|
|
Rails.application.routes.draw do
|
|
mount transport => "/mcp"
|
|
end
|
|
```
|
|
|
|
`mount` directs all HTTP methods on `/mcp` to the transport. `StreamableHTTPTransport` internally dispatches
|
|
`POST` (client-to-server JSON-RPC messages, with responses optionally streamed via SSE),
|
|
`GET` (optional standalone SSE stream for server-to-client messages), and `DELETE` (session termination) per
|
|
the [MCP Streamable HTTP transport spec](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http),
|
|
so no additional route configuration is needed.
|
|
|
|
A complete runnable application using this approach is available in [`examples/rails`](https://github.com/modelcontextprotocol/ruby-sdk/tree/main/examples/rails).
|
|
|
|
### Rails (controller)
|
|
|
|
While the mount approach creates a single server at boot time, the controller approach creates a new server per request.
|
|
This allows you to customize tools, prompts, or configuration based on the request (e.g., different tools per route).
|
|
|
|
`StreamableHTTPTransport#handle_request` returns proper HTTP status codes (e.g., 202 Accepted for notifications):
|
|
|
|
```ruby
|
|
class McpController < ActionController::API
|
|
def create
|
|
server = MCP::Server.new(
|
|
name: "my_server",
|
|
title: "Example Server Display Name",
|
|
version: "1.0.0",
|
|
instructions: "Use the tools of this server as a last resort",
|
|
tools: [SomeTool, AnotherTool],
|
|
prompts: [MyPrompt],
|
|
server_context: { user_id: current_user.id },
|
|
)
|
|
# 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)
|
|
end
|
|
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.
|
|
This mode allows for easy multi-node deployment.
|
|
Set `stateless: true` in `MCP::Server::Transports::StreamableHTTPTransport.new` (`stateless` defaults to `false`):
|
|
|
|
```ruby
|
|
# Stateless Streamable HTTP - session-less
|
|
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true)
|
|
```
|
|
|
|
In stateless mode, each POST is fully self-contained per SEP-2567: no `Mcp-Session-Id` is issued or required,
|
|
handlers run against an ephemeral per-request session (so client identity never leaks across requests or onto the shared server),
|
|
and repeated `initialize` requests are permitted. Request-scoped notifications such as progress and log messages are skipped
|
|
(there is no stream to deliver them), while server-to-client requests (`sampling/createMessage`, `roots/list`, `elicitation/create`) raise an error.
|
|
|
|
{: .note }
|
|
> This transport option is distinct from the sessionless [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle) of MCP 2026-07-28.
|
|
> The option governs how handshake-lifecycle clients are served; modern requests carry their own `_meta` envelope
|
|
> and are served as single exchanges whether or not `stateless: true` is set.
|
|
|
|
### JSON Response Mode
|
|
|
|
You can enable JSON response mode, where the server returns `application/json` instead of `text/event-stream`.
|
|
Set `enable_json_response: true` in `MCP::Server::Transports::StreamableHTTPTransport.new`:
|
|
|
|
```ruby
|
|
# JSON response mode
|
|
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, enable_json_response: true)
|
|
```
|
|
|
|
In JSON response mode, the POST response is a single JSON object, so server-to-client messages
|
|
that need to arrive during request processing are not supported:
|
|
request-scoped notifications (`progress`, `log`) are silently dropped, and all server-to-client requests
|
|
(`sampling/createMessage`, `roots/list`, `elicitation/create`) raise an error.
|
|
Session-scoped standalone notifications (`resources/updated`, `elicitation/complete`) and
|
|
broadcast notifications (`tools/list_changed`, etc.) still flow to clients connected to the GET SSE stream.
|
|
This mode is suitable for simple tool servers that do not need server-initiated requests.
|
|
|
|
{: .note }
|
|
> Like [stateless mode](#stateless-mode), this option applies to handshake-lifecycle clients only.
|
|
> Requests of the [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle) of MCP 2026-07-28 ignore `enable_json_response:`
|
|
> and are served as SSE-framed single exchanges, so request-scoped notifications still reach the client.
|
|
|
|
### Session Limits
|
|
|
|
By default, stateful sessions are bounded so an `initialize` flood cannot retain sessions until memory is exhausted:
|
|
they expire after `session_idle_timeout` seconds of inactivity (default 1800, i.e. 30 minutes) and the concurrent
|
|
session count is capped at `max_sessions` (default 10000). A session's idle timer is reset by activity that touches it
|
|
(a GET, or a regular-request POST), and expired sessions are collected by a background reaper roughly once a minute,
|
|
so cleanup lags inactivity by up to that interval. At the cap, the transport first reclaims any already-expired slots
|
|
and then, if still full, rejects a new `initialize` with HTTP 503 (it does not evict an existing session).
|
|
|
|
```ruby
|
|
# Tune the limits
|
|
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, session_idle_timeout: 900, max_sessions: 5000)
|
|
|
|
# Opt out of expiry and/or the cap (not recommended on internet-facing deployments)
|
|
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, session_idle_timeout: nil, max_sessions: nil)
|
|
```
|
|
|
|
Stateless mode (`stateless: true`) retains no sessions, so neither limit applies to it. The same holds
|
|
for requests of the [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle), which never create a session;
|
|
their long-lived [`subscriptions/listen`](/server/subscriptions/) streams are bounded separately
|
|
by `max_listen_subscriptions:`.
|
|
|
|
### Session Ownership
|
|
|
|
`StreamableHTTPTransport` issues a random `SecureRandom.uuid` session ID and validates incoming requests by session
|
|
existence and idle timeout only. It does not bind a session to a user, because the transport never receives
|
|
an authenticated identity on its own. A caller that obtains a valid session ID could therefore act on that session,
|
|
so binding a session to a user is the deploying application's responsibility (the MCP spec frames this as a SHOULD).
|
|
|
|
The primary control is the `session_request_validator`. It is called as `->(request, session_id) { true | false }`
|
|
on every non-`initialize` POST, GET, and DELETE against an existing session (including notification and response POSTs,
|
|
so a stolen session ID cannot, for example, POST `notifications/cancelled` against a victim's request). A falsy return
|
|
rejects the request with HTTP 403. Use it to compare the request's authenticated principal against the one recorded
|
|
when the session was created:
|
|
|
|
```ruby
|
|
transport = MCP::Server::Transports::StreamableHTTPTransport.new(
|
|
server,
|
|
session_request_validator: ->(request, session_id) { owns_session?(request, session_id) },
|
|
)
|
|
```
|
|
|
|
Without a validator the transport does not enforce ownership. As a limited defense in depth (not authentication),
|
|
it also records the `Origin` header at `initialize` and rejects a later request whose `Origin` differs, but only
|
|
when both are present - a non-browser client that omits `Origin` (e.g. `curl` or a script) is not stopped by this check.
|
|
Enforcing ownership against a determined attacker requires supplying the validator with an authenticated principal.
|
|
|
|
Requests of the [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle) carry no `Mcp-Session-Id` and touch no stored session,
|
|
so there is no session to steal, and neither the validator nor the recorded-`Origin` comparison runs for them
|
|
(the per-request `Origin` validation of the DNS rebinding protection above still applies);
|
|
on that path, authorization is enforced per request by the deploying application.
|
|
|
|
### Request Size Limits
|
|
|
|
`StreamableHTTPTransport` bounds how many bytes a single POST body may allocate, so a peer cannot exhaust memory
|
|
with one oversized message. A body larger than `max_request_bytes` (default 4 MiB) is rejected with HTTP 413,
|
|
and JSON nesting depth is capped. The 4 MiB default comfortably fits a typical JSON-RPC message (a 4 MiB JSON
|
|
string decodes to roughly 3 MiB of base64 payload) and matches the TypeScript SDK's 4 MB default; raise it only
|
|
if you exchange unusually large payloads:
|
|
|
|
```ruby
|
|
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, max_request_bytes: 8 * 1024 * 1024)
|
|
```
|
|
|
|
Unlike the deployment options above, these bounds apply to both lifecycles:
|
|
requests of the [modern lifecycle](/server/discover/#the-stateless-modern-lifecycle) are read through the same byte and nesting limits.
|