Transports
The server ships two transports: stdio for command-line and desktop integrations, and Streamable HTTP 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:
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.
$ 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.
On the legacy handshake lifecycle,
MCP::Server::Transports::StreamableHTTPTransportstores session and SSE stream state in memory, so it must run in a single process. Use a single-process server (e.g., Puma withworkers 0). Multi-process configurations (Unicorn, or Puma withworkers > 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-Idheader 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 (MCP 2026-07-28) are likewise sessionless single exchanges and work with any configuration, with two caveats: asubscriptions/listenstream is held in memory by the process that accepted it, so only notifications emitted in that process reach it, and the multi round-triprequestStatecrosses workers only when they share aRequestStateSecuritykey.
Per MCP 2025-11-25,
StreamableHTTPTransportvalidates theHostandOriginheaders by default to prevent DNS rebinding attacks against locally bound servers, rejecting unauthorized values with HTTP 403.Hostis allowed for the loopback defaults (127.0.0.1,::1,localhost), and anOriginheader, when present, must be same-origin or explicitly allow-listed. Non-browser clients that send noOriginheader are unaffected.Deployments behind a reverse proxy or bound to a non-loopback interface must widen the allow lists:
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 fullhost:portvalue, so both"mcp.example.com"and"mcp.example.com:8443"work. Passdns_rebinding_protection: falseto disable the check entirely (e.g., when an upstream proxy or middleware already validatesHost/Origin). The check runs before any lifecycle dispatch, so it protects requests of the modern lifecycle as well.
Rails (mount)
StreamableHTTPTransport is a Rack app that can be mounted directly in Rails routes:
# 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, so no additional route configuration is needed.
A complete runnable application using this approach is available in 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):
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.
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true)
status, headers, body = transport.handle_request(request)
render(json: body.first, status: status, headers: headers)
end
end
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):
# 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.
This transport option is distinct from the sessionless modern lifecycle of MCP 2026-07-28. The option governs how handshake-lifecycle clients are served; modern requests carry their own
_metaenvelope and are served as single exchanges whether or notstateless: trueis 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:
# 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.
Like stateless mode, this option applies to handshake-lifecycle clients only. Requests of the 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).
# 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, which never create a session; their long-lived subscriptions/listen 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:
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 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:
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 are read through the same byte and nesting limits.