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.
This commit is contained in:
commit
dfdd4efc24
9 changed files with 715 additions and 7 deletions
24
README.md
24
README.md
|
|
@ -102,6 +102,8 @@ 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
|
||||
|
|
@ -128,6 +130,27 @@ The following examples show two common integration styles in Rails.
|
|||
>
|
||||
> Stateless mode (`stateless: true`) does not use sessions and works with any server configuration.
|
||||
|
||||
> [!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`).
|
||||
|
||||
##### Rails (mount)
|
||||
|
||||
`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
|
|||
| `args:` | No | An array of arguments passed to the command. Defaults to `[]`. |
|
||||
| `env:` | No | A hash of environment variables to set for the server process. Defaults to `nil`. |
|
||||
| `read_timeout:` | No | Timeout in seconds for waiting for a server response. Defaults to `nil` (no timeout). |
|
||||
| `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). |
|
||||
|
||||
Example usage:
|
||||
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ stdio_transport.close
|
|||
| `args:` | No | An array of arguments passed to the command. Defaults to `[]`. |
|
||||
| `env:` | No | A hash of environment variables for the server process. Defaults to `nil`. |
|
||||
| `read_timeout:` | No | Timeout in seconds for waiting for a server response. Defaults to `nil`. |
|
||||
| `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). |
|
||||
|
||||
## HTTP Transport
|
||||
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ 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).
|
||||
|
||||
## Streamable HTTP Transport
|
||||
|
||||
`MCP::Server::Transports::StreamableHTTPTransport` is a standard Rack app, so it can be mounted in any Rack-compatible framework.
|
||||
|
|
|
|||
|
|
@ -19,13 +19,28 @@ module MCP
|
|||
CLOSE_TIMEOUT = 2
|
||||
STDERR_READ_SIZE = 4096
|
||||
|
||||
# Default upper bound on a single newline-delimited frame read from the
|
||||
# server's stdout. CRuby's `IO#gets` without a limit accumulates bytes until a
|
||||
# newline arrives, so a spawned server that never emits one can grow a single
|
||||
# String until the host process is OOM-killed. 4 MiB is large enough for any
|
||||
# realistic JSON-RPC frame, including base64-embedded images.
|
||||
MAX_LINE_BYTES = 4 * 1024 * 1024
|
||||
|
||||
attr_reader :command, :args, :env, :server_info
|
||||
|
||||
def initialize(command:, args: [], env: nil, read_timeout: nil)
|
||||
def initialize(command:, args: [], env: nil, read_timeout: nil, max_line_bytes: MAX_LINE_BYTES)
|
||||
# Reject `nil` or non-positive values: `IO#gets("\n", nil)` and a negative
|
||||
# limit read without an upper bound, which would silently disable the
|
||||
# protection this option exists to provide.
|
||||
unless max_line_bytes.is_a?(Integer) && max_line_bytes > 0
|
||||
raise ArgumentError, "max_line_bytes must be a positive Integer"
|
||||
end
|
||||
|
||||
@command = command
|
||||
@args = args
|
||||
@env = env
|
||||
@read_timeout = read_timeout
|
||||
@max_line_bytes = max_line_bytes
|
||||
@stdin = nil
|
||||
@stdout = nil
|
||||
@stderr = nil
|
||||
|
|
@ -239,7 +254,7 @@ module MCP
|
|||
loop do
|
||||
ensure_running!
|
||||
wait_for_readable!(method, params) if @read_timeout
|
||||
line = @stdout.gets
|
||||
line = read_line(method, params)
|
||||
raise_connection_error!(method, params) if line.nil?
|
||||
|
||||
parsed = JSON.parse(line.strip)
|
||||
|
|
@ -278,6 +293,22 @@ module MCP
|
|||
)
|
||||
end
|
||||
|
||||
# Reads one newline-delimited frame from the server's stdout, bounded by `@max_line_bytes`.
|
||||
# Returns the line (including its trailing newline) or `nil` at EOF. Raises when the limit
|
||||
# is reached before a newline arrives, which signals a server streaming an unbounded frame.
|
||||
# A short final frame without a trailing newline (EOF) is still returned, since its length
|
||||
# stays under the limit.
|
||||
def read_line(method, params)
|
||||
line = @stdout.gets("\n", @max_line_bytes)
|
||||
return line unless line && !line.end_with?("\n") && line.bytesize >= @max_line_bytes
|
||||
|
||||
raise RequestHandlerError.new(
|
||||
"Server response frame exceeds #{@max_line_bytes} bytes without a newline",
|
||||
{ method: method, params: params },
|
||||
error_type: :internal_error,
|
||||
)
|
||||
end
|
||||
|
||||
def raise_connection_error!(method, params)
|
||||
raise RequestHandlerError.new(
|
||||
"Server process closed stdout unexpectedly",
|
||||
|
|
|
|||
|
|
@ -9,10 +9,25 @@ module MCP
|
|||
class StdioTransport < Transport
|
||||
STATUS_INTERRUPTED = Signal.list["INT"] + 128
|
||||
|
||||
def initialize(server)
|
||||
# Default upper bound on a single newline-delimited frame. CRuby's `IO#gets`
|
||||
# without a limit accumulates bytes until a newline arrives, so a peer that
|
||||
# never emits one can grow a single String until the process is OOM-killed.
|
||||
# 4 MiB is large enough for any realistic JSON-RPC frame, including
|
||||
# base64-embedded images.
|
||||
MAX_LINE_BYTES = 4 * 1024 * 1024
|
||||
|
||||
def initialize(server, max_line_bytes: MAX_LINE_BYTES)
|
||||
super(server)
|
||||
@open = false
|
||||
@session = nil
|
||||
# Reject `nil` or non-positive values: `IO#gets("\n", nil)` and a negative
|
||||
# limit read without an upper bound, which would silently disable the
|
||||
# protection this option exists to provide.
|
||||
unless max_line_bytes.is_a?(Integer) && max_line_bytes > 0
|
||||
raise ArgumentError, "max_line_bytes must be a positive Integer"
|
||||
end
|
||||
|
||||
@max_line_bytes = max_line_bytes
|
||||
$stdin.set_encoding("UTF-8")
|
||||
$stdout.set_encoding("UTF-8")
|
||||
end
|
||||
|
|
@ -20,7 +35,20 @@ module MCP
|
|||
def open
|
||||
@open = true
|
||||
@session = ServerSession.new(server: @server, transport: self)
|
||||
while @open && (line = $stdin.gets)
|
||||
while @open
|
||||
begin
|
||||
line = read_line($stdin)
|
||||
rescue RequestHandlerError => e
|
||||
# Stop accumulating and end the connection gracefully rather than
|
||||
# letting an unbounded read exhaust memory or escape as an uncaught
|
||||
# backtrace. Scoped to the read so genuine request errors raised while
|
||||
# handling a frame are not swallowed here.
|
||||
@open = false
|
||||
MCP.configuration.exception_reporter.call(e, { error: "stdio frame exceeds limit" })
|
||||
break
|
||||
end
|
||||
break if line.nil?
|
||||
|
||||
response = @session.handle_json(line.strip)
|
||||
send_response(response) if response
|
||||
end
|
||||
|
|
@ -73,7 +101,7 @@ module MCP
|
|||
raise
|
||||
end
|
||||
|
||||
while @open && (line = $stdin.gets)
|
||||
while @open && (line = read_line($stdin))
|
||||
begin
|
||||
parsed = JSON.parse(line.strip, symbolize_names: true)
|
||||
rescue JSON::ParserError => e
|
||||
|
|
@ -95,6 +123,26 @@ module MCP
|
|||
|
||||
raise "Transport closed while waiting for response to #{method} request."
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Reads one newline-delimited frame, bounded by `@max_line_bytes`. Returns
|
||||
# the line (including its trailing newline) or `nil` at EOF. Raises when the
|
||||
# limit is reached before a newline arrives, which signals a peer streaming
|
||||
# an unbounded frame. A short final frame without a trailing newline (EOF) is
|
||||
# still returned, since its length stays under the limit.
|
||||
def read_line(io)
|
||||
line = io.gets("\n", @max_line_bytes)
|
||||
if line && !line.end_with?("\n") && line.bytesize >= @max_line_bytes
|
||||
raise RequestHandlerError.new(
|
||||
"stdio frame exceeds #{@max_line_bytes} bytes without a newline",
|
||||
nil,
|
||||
error_type: :internal_error,
|
||||
)
|
||||
end
|
||||
|
||||
line
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -39,8 +39,35 @@ module MCP
|
|||
UNSET_IDLE_TIMEOUT = Object.new.freeze
|
||||
private_constant :UNSET_IDLE_TIMEOUT
|
||||
|
||||
def initialize(server, stateless: false, enable_json_response: false,
|
||||
session_idle_timeout: UNSET_IDLE_TIMEOUT, max_sessions: DEFAULT_MAX_SESSIONS)
|
||||
# Creates a Streamable HTTP transport that can be mounted as a Rack app.
|
||||
#
|
||||
# @param server [MCP::Server] the server whose requests this transport dispatches.
|
||||
# @param stateless [Boolean] when `true`, no session is issued and each POST is self-contained.
|
||||
# @param enable_json_response [Boolean] when `true`, a request is answered with a single JSON
|
||||
# object instead of an SSE stream.
|
||||
# @param session_idle_timeout [Numeric, nil] seconds before an idle session is reaped; defaults
|
||||
# to `DEFAULT_SESSION_IDLE_TIMEOUT` (1800) in stateful mode, and an explicit `nil` disables
|
||||
# expiry. Not supported in stateless mode.
|
||||
# @param max_sessions [Integer, nil] cap on the concurrent session count in stateful mode; a new
|
||||
# `initialize` past the cap is rejected with HTTP 503, and `nil` disables the cap.
|
||||
# @param allowed_origins [Array<String>, nil] extra `Origin` values accepted in addition to
|
||||
# same-origin requests, for DNS rebinding protection.
|
||||
# @param allowed_hosts [Array<String>, nil] extra `Host` values accepted beyond the loopback
|
||||
# defaults (`127.0.0.1`, `::1`, `localhost`); each entry matches a bare host name (any port)
|
||||
# or a full `host:port`.
|
||||
# @param dns_rebinding_protection [Boolean] when `true` (default), validates the `Host` and
|
||||
# `Origin` headers to prevent DNS rebinding; pass `false` when an upstream proxy already
|
||||
# validates them.
|
||||
def initialize(
|
||||
server,
|
||||
stateless: false,
|
||||
enable_json_response: false,
|
||||
session_idle_timeout: UNSET_IDLE_TIMEOUT,
|
||||
max_sessions: DEFAULT_MAX_SESSIONS,
|
||||
allowed_origins: nil,
|
||||
allowed_hosts: nil,
|
||||
dns_rebinding_protection: true
|
||||
)
|
||||
super(server)
|
||||
# Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock }`.
|
||||
@sessions = {}
|
||||
|
|
@ -48,6 +75,11 @@ module MCP
|
|||
|
||||
@stateless = stateless
|
||||
@enable_json_response = enable_json_response
|
||||
@dns_rebinding_protection = dns_rebinding_protection
|
||||
|
||||
# Host names are case-insensitive, so the allow lists are compared down-cased.
|
||||
@allowed_hosts = (DEFAULT_LOOPBACK_HOSTS + Array(allowed_hosts)).map(&:downcase).freeze
|
||||
@allowed_origins = Array(allowed_origins).map(&:downcase).freeze
|
||||
@pending_responses = {}
|
||||
|
||||
# Resolve the idle timeout: an explicit value (including `nil` to opt out) wins; otherwise apply the secure default,
|
||||
|
|
@ -82,12 +114,19 @@ module MCP
|
|||
STREAM_WRITE_ERRORS = [IOError, Errno::EPIPE, Errno::ECONNRESET].freeze
|
||||
SESSION_REAP_INTERVAL = 60
|
||||
|
||||
# Loopback hosts always accepted by DNS rebinding protection. A locally bound MCP server (the canonical pattern) is
|
||||
# protected out of the box; non-loopback deployments widen the list via `allowed_hosts:`.
|
||||
DEFAULT_LOOPBACK_HOSTS = ["127.0.0.1", "::1", "localhost"].freeze
|
||||
|
||||
# Rack app interface. This transport can be mounted as a Rack app.
|
||||
def call(env)
|
||||
handle_request(Rack::Request.new(env))
|
||||
end
|
||||
|
||||
def handle_request(request)
|
||||
rebinding_error = validate_dns_rebinding(request)
|
||||
return rebinding_error if rebinding_error
|
||||
|
||||
case request.env["REQUEST_METHOD"]
|
||||
when "POST"
|
||||
handle_post(request)
|
||||
|
|
@ -863,6 +902,74 @@ module MCP
|
|||
active
|
||||
end
|
||||
|
||||
# Per MCP 2025-11-25, servers MUST validate the `Origin` header and SHOULD bind only to localhost
|
||||
# to prevent DNS rebinding attacks against locally bound MCP servers. Protection is on by default;
|
||||
# pass `dns_rebinding_protection: false` to disable it (e.g. when an upstream proxy or middleware already
|
||||
# performs the check). The `Host` header is validated against the loopback defaults plus `allowed_hosts:`,
|
||||
# and the `Origin` header, when present, must be same-origin or in `allowed_origins:`.
|
||||
def validate_dns_rebinding(request)
|
||||
return unless @dns_rebinding_protection
|
||||
|
||||
validate_host(request) || validate_origin(request)
|
||||
end
|
||||
|
||||
# Rejects a rebound `Host` (e.g. `evil.example.com` re-pointed at 127.0.0.1).
|
||||
# A request without a `Host` header (e.g. HTTP/1.0) is allowed; the rebinding vector this guards against always carries one.
|
||||
def validate_host(request)
|
||||
host = request.env["HTTP_HOST"]
|
||||
return if host.nil?
|
||||
|
||||
# An `allowed_hosts:` entry matches either the bare host name (any port)
|
||||
# or the full `host:port` value, so both `"app.example.com"` and
|
||||
# `"app.example.com:8443"` can be configured.
|
||||
normalized = host.downcase
|
||||
return if @allowed_hosts.include?(request_hostname(normalized)) || @allowed_hosts.include?(normalized)
|
||||
|
||||
forbidden_response("Forbidden: Invalid Host header")
|
||||
end
|
||||
|
||||
# A request without an `Origin` header (typical for non-browser MCP clients) is allowed. A browser cross-origin request is
|
||||
# rejected unless the origin is same-origin or explicitly allow-listed via `allowed_origins:`.
|
||||
def validate_origin(request)
|
||||
origin = request.env["HTTP_ORIGIN"]
|
||||
return if origin.nil?
|
||||
return if same_origin?(origin, request)
|
||||
return if @allowed_origins.include?(origin.downcase)
|
||||
|
||||
forbidden_response("Forbidden: Invalid Origin header")
|
||||
end
|
||||
|
||||
# Extracts the host name from a `Host` header value, stripping any port and IPv6 brackets
|
||||
# (`[::1]:8080` becomes `::1`, `127.0.0.1:8080` becomes `127.0.0.1`).
|
||||
def request_hostname(host)
|
||||
return host[/\A\[([^\]]+)\]/, 1] if host.start_with?("[")
|
||||
|
||||
host.split(":").first
|
||||
end
|
||||
|
||||
# Compares the `Origin` authority (host:port) against the request's own `Host`.
|
||||
# Scheme is not compared (the `Host` header carries none, and `request.scheme` is unreliable behind proxies),
|
||||
# but the `Origin`'s scheme is used to drop a redundant default port (`:80` for http, `:443` for https) from
|
||||
# both sides so `http://example.com` matches `Host: example.com:80`. Comparison is case-insensitive.
|
||||
def same_origin?(origin, request)
|
||||
host = request.env["HTTP_HOST"]
|
||||
return false if host.nil?
|
||||
|
||||
normalized = origin.downcase
|
||||
default_port = normalized.start_with?("https://") ? ":443" : ":80"
|
||||
authority = normalized.sub(%r{\Ahttps?://}, "")
|
||||
|
||||
authority.delete_suffix(default_port) == host.downcase.delete_suffix(default_port)
|
||||
end
|
||||
|
||||
def forbidden_response(message)
|
||||
json_rpc_error_response(
|
||||
status: 403,
|
||||
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
|
||||
message: message,
|
||||
)
|
||||
end
|
||||
|
||||
def method_not_allowed_response
|
||||
json_rpc_error_response(
|
||||
status: 405,
|
||||
|
|
|
|||
|
|
@ -1060,6 +1060,116 @@ module MCP
|
|||
end
|
||||
end
|
||||
|
||||
def test_new_raises_argument_error_when_max_line_bytes_is_not_a_positive_integer
|
||||
[nil, 0, -1, 1.5, "1024"].each do |invalid|
|
||||
error = assert_raises(ArgumentError) do
|
||||
Stdio.new(command: "ruby", args: ["server.rb"], max_line_bytes: invalid)
|
||||
end
|
||||
assert_equal("max_line_bytes must be a positive Integer", error.message)
|
||||
end
|
||||
end
|
||||
|
||||
def test_send_request_raises_when_response_frame_exceeds_max_line_bytes
|
||||
stdin_read, stdin_write = IO.pipe
|
||||
stdout_read, stdout_write = IO.pipe
|
||||
stderr_read, _ = IO.pipe
|
||||
|
||||
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
|
||||
|
||||
transport = Stdio.new(command: "ruby", args: ["server.rb"], max_line_bytes: 1024)
|
||||
|
||||
request = { jsonrpc: "2.0", id: "test-id", method: "tools/list" }
|
||||
|
||||
server_thread = Thread.new do
|
||||
init_line = stdin_read.gets
|
||||
init_request = JSON.parse(init_line)
|
||||
stdout_write.puts(JSON.generate({
|
||||
jsonrpc: "2.0",
|
||||
id: init_request["id"],
|
||||
result: {
|
||||
protocolVersion: "2025-11-25",
|
||||
capabilities: {},
|
||||
serverInfo: { name: "test-server", version: "1.0.0" },
|
||||
},
|
||||
}))
|
||||
stdout_write.flush
|
||||
|
||||
# Read initialized notification.
|
||||
stdin_read.gets
|
||||
|
||||
# Read tools/list request, then stream an unbounded frame with no newline.
|
||||
stdin_read.gets
|
||||
stdout_write.write("A" * 2000)
|
||||
stdout_write.flush
|
||||
end
|
||||
|
||||
transport.connect
|
||||
|
||||
error = assert_raises(RequestHandlerError) do
|
||||
transport.send_request(request: request)
|
||||
end
|
||||
|
||||
assert_match(/exceeds 1024 bytes without a newline/, error.message)
|
||||
assert_equal(:internal_error, error.error_type)
|
||||
ensure
|
||||
server_thread.join
|
||||
stdin_read.close
|
||||
stdin_write.close
|
||||
stdout_read.close
|
||||
stdout_write.close
|
||||
end
|
||||
|
||||
def test_send_request_accepts_response_within_custom_max_line_bytes
|
||||
stdin_read, stdin_write = IO.pipe
|
||||
stdout_read, stdout_write = IO.pipe
|
||||
stderr_read, _ = IO.pipe
|
||||
|
||||
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
|
||||
|
||||
transport = Stdio.new(command: "ruby", args: ["server.rb"], max_line_bytes: 1024)
|
||||
|
||||
request = { jsonrpc: "2.0", id: "test-id", method: "tools/list" }
|
||||
|
||||
server_thread = Thread.new do
|
||||
init_line = stdin_read.gets
|
||||
init_request = JSON.parse(init_line)
|
||||
stdout_write.puts(JSON.generate({
|
||||
jsonrpc: "2.0",
|
||||
id: init_request["id"],
|
||||
result: {
|
||||
protocolVersion: "2025-11-25",
|
||||
capabilities: {},
|
||||
serverInfo: { name: "test-server", version: "1.0.0" },
|
||||
},
|
||||
}))
|
||||
stdout_write.flush
|
||||
|
||||
stdin_read.gets
|
||||
|
||||
tools_line = stdin_read.gets
|
||||
tools_request = JSON.parse(tools_line)
|
||||
stdout_write.puts(JSON.generate({
|
||||
jsonrpc: "2.0",
|
||||
id: tools_request["id"],
|
||||
result: { tools: [] },
|
||||
}))
|
||||
stdout_write.flush
|
||||
end
|
||||
|
||||
transport.connect
|
||||
|
||||
response = transport.send_request(request: request)
|
||||
|
||||
assert_equal("test-id", response["id"])
|
||||
assert_equal({ "tools" => [] }, response["result"])
|
||||
ensure
|
||||
server_thread.join
|
||||
stdin_read.close
|
||||
stdin_write.close
|
||||
stdout_read.close
|
||||
stdout_write.close
|
||||
end
|
||||
|
||||
def test_send_notification_writes_json_line_without_waiting_for_response
|
||||
stdin_read, stdin_write = IO.pipe
|
||||
stdout_read, stdout_write = IO.pipe
|
||||
|
|
|
|||
|
|
@ -441,6 +441,91 @@ module MCP
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "raises ArgumentError when max_line_bytes is not a positive Integer" do
|
||||
[nil, 0, -1, 1.5, "1024"].each do |invalid|
|
||||
error = assert_raises(ArgumentError) do
|
||||
StdioTransport.new(@server, max_line_bytes: invalid)
|
||||
end
|
||||
assert_equal("max_line_bytes must be a positive Integer", error.message)
|
||||
end
|
||||
end
|
||||
|
||||
test "open stops and reports when a stdin frame exceeds max_line_bytes without a newline" do
|
||||
transport = StdioTransport.new(@server, max_line_bytes: 64)
|
||||
input = StringIO.new("A" * 200) # No newline: an unbounded frame.
|
||||
output = StringIO.new
|
||||
|
||||
reported = []
|
||||
original_stdin = $stdin
|
||||
original_stdout = $stdout
|
||||
original_reporter = MCP.configuration.exception_reporter
|
||||
|
||||
begin
|
||||
$stdin = input
|
||||
$stdout = output
|
||||
MCP.configuration.exception_reporter = ->(e, ctx) { reported << [e, ctx] }
|
||||
|
||||
transport.open
|
||||
|
||||
refute(transport.instance_variable_get(:@open))
|
||||
assert_equal(1, reported.size)
|
||||
error, _ctx = reported.first
|
||||
assert_instance_of(RequestHandlerError, error)
|
||||
assert_match(/exceeds 64 bytes without a newline/, error.message)
|
||||
ensure
|
||||
$stdin = original_stdin
|
||||
$stdout = original_stdout
|
||||
MCP.configuration.exception_reporter = original_reporter
|
||||
end
|
||||
end
|
||||
|
||||
test "open processes a valid final frame that ends at EOF without a trailing newline" do
|
||||
request = { jsonrpc: "2.0", method: "ping", id: "eof" }
|
||||
input = StringIO.new(JSON.generate(request)) # No trailing newline.
|
||||
output = StringIO.new
|
||||
|
||||
original_stdin = $stdin
|
||||
original_stdout = $stdout
|
||||
|
||||
begin
|
||||
$stdin = input
|
||||
$stdout = output
|
||||
|
||||
@transport.open
|
||||
|
||||
response = JSON.parse(output.string, symbolize_names: true)
|
||||
assert_equal("eof", response[:id])
|
||||
assert_equal({}, response[:result])
|
||||
ensure
|
||||
$stdin = original_stdin
|
||||
$stdout = original_stdout
|
||||
end
|
||||
end
|
||||
|
||||
test "send_request raises when a response frame exceeds max_line_bytes without a newline" do
|
||||
transport = StdioTransport.new(@server, max_line_bytes: 64)
|
||||
input = StringIO.new("A" * 200) # No newline: an unbounded frame.
|
||||
output = StringIO.new
|
||||
|
||||
original_stdin = $stdin
|
||||
original_stdout = $stdout
|
||||
|
||||
begin
|
||||
$stdin = input
|
||||
$stdout = output
|
||||
transport.instance_variable_set(:@open, true)
|
||||
|
||||
error = assert_raises(RequestHandlerError) do
|
||||
transport.send_request("sampling/createMessage", { messages: [] })
|
||||
end
|
||||
|
||||
assert_match(/exceeds 64 bytes without a newline/, error.message)
|
||||
ensure
|
||||
$stdin = original_stdin
|
||||
$stdout = original_stdout
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -4441,6 +4441,306 @@ module MCP
|
|||
refute headers.frozen?, "SSE response headers should not be frozen"
|
||||
end
|
||||
|
||||
test "handle_request returns 403 for POST when Origin is not in allowed_origins" do
|
||||
transport = StreamableHTTPTransport.new(@server, allowed_origins: ["https://app.example.com"])
|
||||
|
||||
request = create_rack_request(
|
||||
"POST",
|
||||
"/",
|
||||
{
|
||||
"CONTENT_TYPE" => "application/json",
|
||||
"HTTP_ORIGIN" => "https://evil.example.com",
|
||||
},
|
||||
{ jsonrpc: "2.0", method: "ping", id: "1" }.to_json,
|
||||
)
|
||||
|
||||
status, headers, body = transport.handle_request(request)
|
||||
assert_equal(403, status)
|
||||
assert_equal("application/json", headers["Content-Type"])
|
||||
parsed = JSON.parse(body.first)
|
||||
assert_equal(JsonRpcHandler::ErrorCode::INVALID_REQUEST, parsed["error"]["code"])
|
||||
assert_equal("Forbidden: Invalid Origin header", parsed["error"]["message"])
|
||||
ensure
|
||||
transport&.close
|
||||
end
|
||||
|
||||
test "handle_request returns 403 for GET when Origin is not in allowed_origins" do
|
||||
transport = StreamableHTTPTransport.new(@server, allowed_origins: ["https://app.example.com"])
|
||||
|
||||
request = create_rack_request(
|
||||
"GET",
|
||||
"/",
|
||||
{ "HTTP_ORIGIN" => "https://evil.example.com" },
|
||||
)
|
||||
|
||||
status, = transport.handle_request(request)
|
||||
assert_equal(403, status)
|
||||
ensure
|
||||
transport&.close
|
||||
end
|
||||
|
||||
test "handle_request returns 403 for DELETE when Origin is not in allowed_origins" do
|
||||
transport = StreamableHTTPTransport.new(@server, allowed_origins: ["https://app.example.com"])
|
||||
|
||||
request = create_rack_request(
|
||||
"DELETE",
|
||||
"/",
|
||||
{
|
||||
"HTTP_ORIGIN" => "https://evil.example.com",
|
||||
"HTTP_MCP_SESSION_ID" => "any",
|
||||
},
|
||||
)
|
||||
|
||||
status, = transport.handle_request(request)
|
||||
assert_equal(403, status)
|
||||
ensure
|
||||
transport&.close
|
||||
end
|
||||
|
||||
test "handle_request allows POST when Origin is in allowed_origins" do
|
||||
transport = StreamableHTTPTransport.new(@server, allowed_origins: ["https://app.example.com"])
|
||||
|
||||
request = create_rack_request(
|
||||
"POST",
|
||||
"/",
|
||||
{
|
||||
"CONTENT_TYPE" => "application/json",
|
||||
"HTTP_ORIGIN" => "https://app.example.com",
|
||||
},
|
||||
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
|
||||
)
|
||||
|
||||
status, = transport.handle_request(request)
|
||||
assert_equal(200, status)
|
||||
ensure
|
||||
transport&.close
|
||||
end
|
||||
|
||||
test "handle_request allows POST when Origin header is absent (e.g. non-browser client)" do
|
||||
transport = StreamableHTTPTransport.new(@server, allowed_origins: ["https://app.example.com"])
|
||||
|
||||
request = create_rack_request(
|
||||
"POST",
|
||||
"/",
|
||||
{ "CONTENT_TYPE" => "application/json" },
|
||||
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
|
||||
)
|
||||
|
||||
status, = transport.handle_request(request)
|
||||
assert_equal(200, status)
|
||||
ensure
|
||||
transport&.close
|
||||
end
|
||||
|
||||
test "handle_request rejects a cross-origin request by default (secure-by-default)" do
|
||||
# No allowed_origins configured: a browser cross-origin Origin is still rejected.
|
||||
request = create_rack_request(
|
||||
"POST",
|
||||
"/",
|
||||
{
|
||||
"CONTENT_TYPE" => "application/json",
|
||||
"HTTP_ORIGIN" => "https://anywhere.example.com",
|
||||
},
|
||||
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
|
||||
)
|
||||
|
||||
status, = @transport.handle_request(request)
|
||||
assert_equal 403, status
|
||||
end
|
||||
|
||||
test "handle_request allows a same-origin browser request" do
|
||||
request = create_rack_request(
|
||||
"POST",
|
||||
"/",
|
||||
{
|
||||
"CONTENT_TYPE" => "application/json",
|
||||
"HTTP_HOST" => "localhost:3000",
|
||||
"HTTP_ORIGIN" => "http://localhost:3000",
|
||||
},
|
||||
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
|
||||
)
|
||||
|
||||
status, = @transport.handle_request(request)
|
||||
assert_equal 200, status
|
||||
end
|
||||
|
||||
test "handle_request rejects a rebound Host by default" do
|
||||
# DNS rebinding: a foreign Host re-pointed at the loopback server is rejected.
|
||||
request = create_rack_request(
|
||||
"POST",
|
||||
"/",
|
||||
{
|
||||
"CONTENT_TYPE" => "application/json",
|
||||
"HTTP_HOST" => "evil.example.com",
|
||||
},
|
||||
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
|
||||
)
|
||||
|
||||
status, headers, body = @transport.handle_request(request)
|
||||
assert_equal(403, status)
|
||||
assert_equal("application/json", headers["Content-Type"])
|
||||
parsed = JSON.parse(body.first)
|
||||
assert_equal(JsonRpcHandler::ErrorCode::INVALID_REQUEST, parsed["error"]["code"])
|
||||
assert_equal("Forbidden: Invalid Host header", parsed["error"]["message"])
|
||||
end
|
||||
|
||||
test "handle_request allows loopback Host values by default" do
|
||||
["127.0.0.1:8080", "localhost:3000", "[::1]:8080"].each do |host|
|
||||
transport = StreamableHTTPTransport.new(@server)
|
||||
request = create_rack_request(
|
||||
"POST",
|
||||
"/",
|
||||
{
|
||||
"CONTENT_TYPE" => "application/json",
|
||||
"HTTP_HOST" => host,
|
||||
},
|
||||
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
|
||||
)
|
||||
|
||||
status, = transport.handle_request(request)
|
||||
assert_equal(200, status, "expected #{host} to be allowed")
|
||||
ensure
|
||||
transport.close
|
||||
end
|
||||
end
|
||||
|
||||
test "handle_request matches Host case-insensitively" do
|
||||
# Host names are case-insensitive, so an upper-cased loopback Host is allowed.
|
||||
request = create_rack_request(
|
||||
"POST",
|
||||
"/",
|
||||
{
|
||||
"CONTENT_TYPE" => "application/json",
|
||||
"HTTP_HOST" => "LOCALHOST:3000",
|
||||
},
|
||||
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
|
||||
)
|
||||
|
||||
status, = @transport.handle_request(request)
|
||||
assert_equal(200, status)
|
||||
end
|
||||
|
||||
test "handle_request allows a non-loopback Host listed in allowed_hosts" do
|
||||
transport = StreamableHTTPTransport.new(@server, allowed_hosts: ["app.example.com"])
|
||||
|
||||
request = create_rack_request(
|
||||
"POST",
|
||||
"/",
|
||||
{
|
||||
"CONTENT_TYPE" => "application/json",
|
||||
"HTTP_HOST" => "app.example.com",
|
||||
},
|
||||
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
|
||||
)
|
||||
|
||||
status, = transport.handle_request(request)
|
||||
assert_equal(200, status)
|
||||
ensure
|
||||
transport.close
|
||||
end
|
||||
|
||||
test "handle_request matches a host:port entry in allowed_hosts exactly" do
|
||||
transport = StreamableHTTPTransport.new(@server, allowed_hosts: ["app.example.com:8443"])
|
||||
|
||||
allowed = create_rack_request(
|
||||
"POST",
|
||||
"/",
|
||||
{ "CONTENT_TYPE" => "application/json", "HTTP_HOST" => "app.example.com:8443" },
|
||||
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
|
||||
)
|
||||
rejected = create_rack_request(
|
||||
"POST",
|
||||
"/",
|
||||
{ "CONTENT_TYPE" => "application/json", "HTTP_HOST" => "app.example.com:9000" },
|
||||
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
|
||||
)
|
||||
|
||||
assert_equal(200, transport.handle_request(allowed).first)
|
||||
assert_equal(403, transport.handle_request(rejected).first)
|
||||
ensure
|
||||
transport.close
|
||||
end
|
||||
|
||||
test "handle_request treats a default port as same-origin" do
|
||||
# `Origin: http://localhost` (no port) is same-origin with `Host: localhost:80`.
|
||||
[
|
||||
["http://localhost", "localhost:80"],
|
||||
["https://localhost", "localhost:443"],
|
||||
].each do |origin, host|
|
||||
transport = StreamableHTTPTransport.new(@server)
|
||||
request = create_rack_request(
|
||||
"POST",
|
||||
"/",
|
||||
{ "CONTENT_TYPE" => "application/json", "HTTP_HOST" => host, "HTTP_ORIGIN" => origin },
|
||||
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
|
||||
)
|
||||
|
||||
assert_equal(200, transport.handle_request(request).first, "expected #{origin} / #{host} to match")
|
||||
ensure
|
||||
transport.close
|
||||
end
|
||||
end
|
||||
|
||||
test "handle_request rejects opaque and malformed Origins" do
|
||||
["null", "file:///etc/passwd", "http://localhost:3000/path"].each do |origin|
|
||||
transport = StreamableHTTPTransport.new(@server)
|
||||
request = create_rack_request(
|
||||
"POST",
|
||||
"/",
|
||||
{ "CONTENT_TYPE" => "application/json", "HTTP_HOST" => "localhost:3000", "HTTP_ORIGIN" => origin },
|
||||
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
|
||||
)
|
||||
|
||||
assert_equal(403, transport.handle_request(request).first, "expected #{origin} to be rejected")
|
||||
ensure
|
||||
transport.close
|
||||
end
|
||||
end
|
||||
|
||||
test "handle_request enforces Host validation on GET requests" do
|
||||
request = create_rack_request(
|
||||
"GET",
|
||||
"/",
|
||||
{ "HTTP_HOST" => "evil.example.com" },
|
||||
)
|
||||
|
||||
status, = @transport.handle_request(request)
|
||||
assert_equal(403, status)
|
||||
end
|
||||
|
||||
test "handle_request allows a request with no Host header" do
|
||||
# Non-browser clients may omit Host; the rebinding vector always carries one.
|
||||
request = create_rack_request(
|
||||
"POST",
|
||||
"/",
|
||||
{ "CONTENT_TYPE" => "application/json" },
|
||||
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
|
||||
)
|
||||
|
||||
status, = @transport.handle_request(request)
|
||||
assert_equal(200, status)
|
||||
end
|
||||
|
||||
test "handle_request skips DNS rebinding checks when protection is disabled" do
|
||||
transport = StreamableHTTPTransport.new(@server, dns_rebinding_protection: false)
|
||||
|
||||
request = create_rack_request(
|
||||
"POST",
|
||||
"/",
|
||||
{
|
||||
"CONTENT_TYPE" => "application/json",
|
||||
"HTTP_HOST" => "evil.example.com",
|
||||
"HTTP_ORIGIN" => "https://evil.example.com",
|
||||
},
|
||||
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
|
||||
)
|
||||
|
||||
status, = transport.handle_request(request)
|
||||
assert_equal(200, status)
|
||||
ensure
|
||||
transport.close
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_rack_request(method, path, headers, body = nil)
|
||||
|
|
|
|||
Loading…
Reference in a new issue