Skip to content

Commit dfdd4ef

Browse files
committed
Merge upstream modelcontextprotocol/ruby-sdk main
Resolves conflicts in lib/mcp/server/transports/streamable_http_transport.rb between the DNS rebinding protection merged upstream (allowed_origins, allowed_hosts, dns_rebinding_protection keywords) and this branch's session retention hardening (session_idle_timeout default and max_sessions keyword) by combining both sets of keyword arguments and constructor logic.
2 parents afb968c + cfccb7c commit dfdd4ef

9 files changed

Lines changed: 715 additions & 7 deletions

File tree

‎README.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ transport = MCP::Server::Transports::StdioTransport.new(server)
102102
transport.open
103103
```
104104

105+
`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).
106+
105107
You can run this script and then type in requests to the server at the command line.
106108

107109
```console
@@ -128,6 +130,27 @@ The following examples show two common integration styles in Rails.
128130
>
129131
> Stateless mode (`stateless: true`) does not use sessions and works with any server configuration.
130132
133+
> [!IMPORTANT]
134+
> Per MCP 2025-11-25, `StreamableHTTPTransport` validates the `Host` and `Origin` headers by default to
135+
> prevent DNS rebinding attacks against locally bound servers, rejecting unauthorized values with HTTP 403.
136+
> `Host` is allowed for the loopback defaults (`127.0.0.1`, `::1`, `localhost`), and an `Origin` header,
137+
> when present, must be same-origin or explicitly allow-listed. Non-browser clients that send no `Origin`
138+
> header are unaffected.
139+
>
140+
> Deployments behind a reverse proxy or bound to a non-loopback interface must widen the allow lists:
141+
>
142+
> ```ruby
143+
> transport = MCP::Server::Transports::StreamableHTTPTransport.new(
144+
> server,
145+
> allowed_hosts: ["mcp.example.com"],
146+
> allowed_origins: ["https://app.example.com"],
147+
> )
148+
> ```
149+
>
150+
> An `allowed_hosts:` entry matches either the bare host name (any port) or the full `host:port` value,
151+
> so both `"mcp.example.com"` and `"mcp.example.com:8443"` work. Pass `dns_rebinding_protection: false`
152+
> to disable the check entirely (e.g., when an upstream proxy or middleware already validates `Host`/`Origin`).
153+
131154
##### Rails (mount)
132155
133156
`StreamableHTTPTransport` is a Rack app that can be mounted directly in Rails routes:
@@ -1967,6 +1990,7 @@ Use the `MCP::Client::Stdio` transport to interact with MCP servers running as s
19671990
| `args:` | No | An array of arguments passed to the command. Defaults to `[]`. |
19681991
| `env:` | No | A hash of environment variables to set for the server process. Defaults to `nil`. |
19691992
| `read_timeout:` | No | Timeout in seconds for waiting for a server response. Defaults to `nil` (no timeout). |
1993+
| `max_line_bytes:` | No | Maximum byte length of a single newline-delimited response frame. A frame that reaches this limit without a newline is rejected as a transport error, preventing unbounded memory growth from a server that never emits a newline. Defaults to `4 * 1024 * 1024` (4 MiB). |
19701994

19711995
Example usage:
19721996

‎docs/building-clients.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ stdio_transport.close
6565
| `args:` | No | An array of arguments passed to the command. Defaults to `[]`. |
6666
| `env:` | No | A hash of environment variables for the server process. Defaults to `nil`. |
6767
| `read_timeout:` | No | Timeout in seconds for waiting for a server response. Defaults to `nil`. |
68+
| `max_line_bytes:` | No | Maximum byte length of a single newline-delimited response frame. A frame that reaches this limit without a newline is rejected as a transport error, preventing unbounded memory growth from a server that never emits a newline. Defaults to `4 * 1024 * 1024` (4 MiB). |
6869

6970
## HTTP Transport
7071

‎docs/building-servers.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ transport = MCP::Server::Transports::StdioTransport.new(server)
5959
transport.open
6060
```
6161

62+
`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).
63+
6264
## Streamable HTTP Transport
6365

6466
`MCP::Server::Transports::StreamableHTTPTransport` is a standard Rack app, so it can be mounted in any Rack-compatible framework.

‎lib/mcp/client/stdio.rb‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,28 @@ class Stdio
1919
CLOSE_TIMEOUT = 2
2020
STDERR_READ_SIZE = 4096
2121

22+
# Default upper bound on a single newline-delimited frame read from the
23+
# server's stdout. CRuby's `IO#gets` without a limit accumulates bytes until a
24+
# newline arrives, so a spawned server that never emits one can grow a single
25+
# String until the host process is OOM-killed. 4 MiB is large enough for any
26+
# realistic JSON-RPC frame, including base64-embedded images.
27+
MAX_LINE_BYTES = 4 * 1024 * 1024
28+
2229
attr_reader :command, :args, :env, :server_info
2330

24-
def initialize(command:, args: [], env: nil, read_timeout: nil)
31+
def initialize(command:, args: [], env: nil, read_timeout: nil, max_line_bytes: MAX_LINE_BYTES)
32+
# Reject `nil` or non-positive values: `IO#gets("\n", nil)` and a negative
33+
# limit read without an upper bound, which would silently disable the
34+
# protection this option exists to provide.
35+
unless max_line_bytes.is_a?(Integer) && max_line_bytes > 0
36+
raise ArgumentError, "max_line_bytes must be a positive Integer"
37+
end
38+
2539
@command = command
2640
@args = args
2741
@env = env
2842
@read_timeout = read_timeout
43+
@max_line_bytes = max_line_bytes
2944
@stdin = nil
3045
@stdout = nil
3146
@stderr = nil
@@ -239,7 +254,7 @@ def read_response(request)
239254
loop do
240255
ensure_running!
241256
wait_for_readable!(method, params) if @read_timeout
242-
line = @stdout.gets
257+
line = read_line(method, params)
243258
raise_connection_error!(method, params) if line.nil?
244259

245260
parsed = JSON.parse(line.strip)
@@ -278,6 +293,22 @@ def wait_for_readable!(method, params)
278293
)
279294
end
280295

296+
# Reads one newline-delimited frame from the server's stdout, bounded by `@max_line_bytes`.
297+
# Returns the line (including its trailing newline) or `nil` at EOF. Raises when the limit
298+
# is reached before a newline arrives, which signals a server streaming an unbounded frame.
299+
# A short final frame without a trailing newline (EOF) is still returned, since its length
300+
# stays under the limit.
301+
def read_line(method, params)
302+
line = @stdout.gets("\n", @max_line_bytes)
303+
return line unless line && !line.end_with?("\n") && line.bytesize >= @max_line_bytes
304+
305+
raise RequestHandlerError.new(
306+
"Server response frame exceeds #{@max_line_bytes} bytes without a newline",
307+
{ method: method, params: params },
308+
error_type: :internal_error,
309+
)
310+
end
311+
281312
def raise_connection_error!(method, params)
282313
raise RequestHandlerError.new(
283314
"Server process closed stdout unexpectedly",

‎lib/mcp/server/transports/stdio_transport.rb‎

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,46 @@ module Transports
99
class StdioTransport < Transport
1010
STATUS_INTERRUPTED = Signal.list["INT"] + 128
1111

12-
def initialize(server)
12+
# Default upper bound on a single newline-delimited frame. CRuby's `IO#gets`
13+
# without a limit accumulates bytes until a newline arrives, so a peer that
14+
# never emits one can grow a single String until the process is OOM-killed.
15+
# 4 MiB is large enough for any realistic JSON-RPC frame, including
16+
# base64-embedded images.
17+
MAX_LINE_BYTES = 4 * 1024 * 1024
18+
19+
def initialize(server, max_line_bytes: MAX_LINE_BYTES)
1320
super(server)
1421
@open = false
1522
@session = nil
23+
# Reject `nil` or non-positive values: `IO#gets("\n", nil)` and a negative
24+
# limit read without an upper bound, which would silently disable the
25+
# protection this option exists to provide.
26+
unless max_line_bytes.is_a?(Integer) && max_line_bytes > 0
27+
raise ArgumentError, "max_line_bytes must be a positive Integer"
28+
end
29+
30+
@max_line_bytes = max_line_bytes
1631
$stdin.set_encoding("UTF-8")
1732
$stdout.set_encoding("UTF-8")
1833
end
1934

2035
def open
2136
@open = true
2237
@session = ServerSession.new(server: @server, transport: self)
23-
while @open && (line = $stdin.gets)
38+
while @open
39+
begin
40+
line = read_line($stdin)
41+
rescue RequestHandlerError => e
42+
# Stop accumulating and end the connection gracefully rather than
43+
# letting an unbounded read exhaust memory or escape as an uncaught
44+
# backtrace. Scoped to the read so genuine request errors raised while
45+
# handling a frame are not swallowed here.
46+
@open = false
47+
MCP.configuration.exception_reporter.call(e, { error: "stdio frame exceeds limit" })
48+
break
49+
end
50+
break if line.nil?
51+
2452
response = @session.handle_json(line.strip)
2553
send_response(response) if response
2654
end
@@ -73,7 +101,7 @@ def send_request(method, params = nil)
73101
raise
74102
end
75103

76-
while @open && (line = $stdin.gets)
104+
while @open && (line = read_line($stdin))
77105
begin
78106
parsed = JSON.parse(line.strip, symbolize_names: true)
79107
rescue JSON::ParserError => e
@@ -95,6 +123,26 @@ def send_request(method, params = nil)
95123

96124
raise "Transport closed while waiting for response to #{method} request."
97125
end
126+
127+
private
128+
129+
# Reads one newline-delimited frame, bounded by `@max_line_bytes`. Returns
130+
# the line (including its trailing newline) or `nil` at EOF. Raises when the
131+
# limit is reached before a newline arrives, which signals a peer streaming
132+
# an unbounded frame. A short final frame without a trailing newline (EOF) is
133+
# still returned, since its length stays under the limit.
134+
def read_line(io)
135+
line = io.gets("\n", @max_line_bytes)
136+
if line && !line.end_with?("\n") && line.bytesize >= @max_line_bytes
137+
raise RequestHandlerError.new(
138+
"stdio frame exceeds #{@max_line_bytes} bytes without a newline",
139+
nil,
140+
error_type: :internal_error,
141+
)
142+
end
143+
144+
line
145+
end
98146
end
99147
end
100148
end

‎lib/mcp/server/transports/streamable_http_transport.rb‎

Lines changed: 109 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,47 @@ class InvalidJsonError < StandardError; end
3939
UNSET_IDLE_TIMEOUT = Object.new.freeze
4040
private_constant :UNSET_IDLE_TIMEOUT
4141

42-
def initialize(server, stateless: false, enable_json_response: false,
43-
session_idle_timeout: UNSET_IDLE_TIMEOUT, max_sessions: DEFAULT_MAX_SESSIONS)
42+
# Creates a Streamable HTTP transport that can be mounted as a Rack app.
43+
#
44+
# @param server [MCP::Server] the server whose requests this transport dispatches.
45+
# @param stateless [Boolean] when `true`, no session is issued and each POST is self-contained.
46+
# @param enable_json_response [Boolean] when `true`, a request is answered with a single JSON
47+
# object instead of an SSE stream.
48+
# @param session_idle_timeout [Numeric, nil] seconds before an idle session is reaped; defaults
49+
# to `DEFAULT_SESSION_IDLE_TIMEOUT` (1800) in stateful mode, and an explicit `nil` disables
50+
# expiry. Not supported in stateless mode.
51+
# @param max_sessions [Integer, nil] cap on the concurrent session count in stateful mode; a new
52+
# `initialize` past the cap is rejected with HTTP 503, and `nil` disables the cap.
53+
# @param allowed_origins [Array<String>, nil] extra `Origin` values accepted in addition to
54+
# same-origin requests, for DNS rebinding protection.
55+
# @param allowed_hosts [Array<String>, nil] extra `Host` values accepted beyond the loopback
56+
# defaults (`127.0.0.1`, `::1`, `localhost`); each entry matches a bare host name (any port)
57+
# or a full `host:port`.
58+
# @param dns_rebinding_protection [Boolean] when `true` (default), validates the `Host` and
59+
# `Origin` headers to prevent DNS rebinding; pass `false` when an upstream proxy already
60+
# validates them.
61+
def initialize(
62+
server,
63+
stateless: false,
64+
enable_json_response: false,
65+
session_idle_timeout: UNSET_IDLE_TIMEOUT,
66+
max_sessions: DEFAULT_MAX_SESSIONS,
67+
allowed_origins: nil,
68+
allowed_hosts: nil,
69+
dns_rebinding_protection: true
70+
)
4471
super(server)
4572
# Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock }`.
4673
@sessions = {}
4774
@mutex = Mutex.new
4875

4976
@stateless = stateless
5077
@enable_json_response = enable_json_response
78+
@dns_rebinding_protection = dns_rebinding_protection
79+
80+
# Host names are case-insensitive, so the allow lists are compared down-cased.
81+
@allowed_hosts = (DEFAULT_LOOPBACK_HOSTS + Array(allowed_hosts)).map(&:downcase).freeze
82+
@allowed_origins = Array(allowed_origins).map(&:downcase).freeze
5183
@pending_responses = {}
5284

5385
# Resolve the idle timeout: an explicit value (including `nil` to opt out) wins; otherwise apply the secure default,
@@ -82,12 +114,19 @@ def initialize(server, stateless: false, enable_json_response: false,
82114
STREAM_WRITE_ERRORS = [IOError, Errno::EPIPE, Errno::ECONNRESET].freeze
83115
SESSION_REAP_INTERVAL = 60
84116

117+
# Loopback hosts always accepted by DNS rebinding protection. A locally bound MCP server (the canonical pattern) is
118+
# protected out of the box; non-loopback deployments widen the list via `allowed_hosts:`.
119+
DEFAULT_LOOPBACK_HOSTS = ["127.0.0.1", "::1", "localhost"].freeze
120+
85121
# Rack app interface. This transport can be mounted as a Rack app.
86122
def call(env)
87123
handle_request(Rack::Request.new(env))
88124
end
89125

90126
def handle_request(request)
127+
rebinding_error = validate_dns_rebinding(request)
128+
return rebinding_error if rebinding_error
129+
91130
case request.env["REQUEST_METHOD"]
92131
when "POST"
93132
handle_post(request)
@@ -863,6 +902,74 @@ def session_active?(session_id)
863902
active
864903
end
865904

905+
# Per MCP 2025-11-25, servers MUST validate the `Origin` header and SHOULD bind only to localhost
906+
# to prevent DNS rebinding attacks against locally bound MCP servers. Protection is on by default;
907+
# pass `dns_rebinding_protection: false` to disable it (e.g. when an upstream proxy or middleware already
908+
# performs the check). The `Host` header is validated against the loopback defaults plus `allowed_hosts:`,
909+
# and the `Origin` header, when present, must be same-origin or in `allowed_origins:`.
910+
def validate_dns_rebinding(request)
911+
return unless @dns_rebinding_protection
912+
913+
validate_host(request) || validate_origin(request)
914+
end
915+
916+
# Rejects a rebound `Host` (e.g. `evil.example.com` re-pointed at 127.0.0.1).
917+
# A request without a `Host` header (e.g. HTTP/1.0) is allowed; the rebinding vector this guards against always carries one.
918+
def validate_host(request)
919+
host = request.env["HTTP_HOST"]
920+
return if host.nil?
921+
922+
# An `allowed_hosts:` entry matches either the bare host name (any port)
923+
# or the full `host:port` value, so both `"app.example.com"` and
924+
# `"app.example.com:8443"` can be configured.
925+
normalized = host.downcase
926+
return if @allowed_hosts.include?(request_hostname(normalized)) || @allowed_hosts.include?(normalized)
927+
928+
forbidden_response("Forbidden: Invalid Host header")
929+
end
930+
931+
# A request without an `Origin` header (typical for non-browser MCP clients) is allowed. A browser cross-origin request is
932+
# rejected unless the origin is same-origin or explicitly allow-listed via `allowed_origins:`.
933+
def validate_origin(request)
934+
origin = request.env["HTTP_ORIGIN"]
935+
return if origin.nil?
936+
return if same_origin?(origin, request)
937+
return if @allowed_origins.include?(origin.downcase)
938+
939+
forbidden_response("Forbidden: Invalid Origin header")
940+
end
941+
942+
# Extracts the host name from a `Host` header value, stripping any port and IPv6 brackets
943+
# (`[::1]:8080` becomes `::1`, `127.0.0.1:8080` becomes `127.0.0.1`).
944+
def request_hostname(host)
945+
return host[/\A\[([^\]]+)\]/, 1] if host.start_with?("[")
946+
947+
host.split(":").first
948+
end
949+
950+
# Compares the `Origin` authority (host:port) against the request's own `Host`.
951+
# Scheme is not compared (the `Host` header carries none, and `request.scheme` is unreliable behind proxies),
952+
# but the `Origin`'s scheme is used to drop a redundant default port (`:80` for http, `:443` for https) from
953+
# both sides so `http://example.com` matches `Host: example.com:80`. Comparison is case-insensitive.
954+
def same_origin?(origin, request)
955+
host = request.env["HTTP_HOST"]
956+
return false if host.nil?
957+
958+
normalized = origin.downcase
959+
default_port = normalized.start_with?("https://") ? ":443" : ":80"
960+
authority = normalized.sub(%r{\Ahttps?://}, "")
961+
962+
authority.delete_suffix(default_port) == host.downcase.delete_suffix(default_port)
963+
end
964+
965+
def forbidden_response(message)
966+
json_rpc_error_response(
967+
status: 403,
968+
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
969+
message: message,
970+
)
971+
end
972+
866973
def method_not_allowed_response
867974
json_rpc_error_response(
868975
status: 405,

0 commit comments

Comments
 (0)