Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/_server/authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ For composition at the Rack layer instead (e.g. sharing one authenticator across
When a browser-based client is involved, run the CORS middleware before bearer enforcement so preflight `OPTIONS` requests
are not answered with 401, and expose the `WWW-Authenticate` response header, or the browser withholds the challenge that points
the client at the metadata document.
CORS headers alone do not admit a client on another origin: its requests must also pass the transport's `Origin` check,
by listing the origin in `allowed_origins:` or, when clients on any origin may call the server, with `allowed_origins: :any`;
see [Streamable HTTP Transport](/server/transports/#streamable-http-transport).
`Mcp-Session-Id` in the snippet below is unrelated to authorization: a handshake-lifecycle client running in a browser cannot keep its session without it,
while the modern lifecycle carries no session. `ProtectedResourceMetadataMiddleware` answers preflights and sets `Access-Control-Allow-Origin: *` on its own,
since the document is meant to be fetched cross-origin:
Expand Down
20 changes: 20 additions & 0 deletions docs/_server/transports.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,26 @@ The following examples show two common integration styles in Rails.
> 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.
>
> A server that browser-based clients on any origin may call passes `allowed_origins: :any`,
> which turns `Origin` validation off while `Host` validation stays on:
>
> ```ruby
> transport = MCP::Server::Transports::StreamableHTTPTransport.new(
> server,
> allowed_hosts: ["mcp.example.com"],
> allowed_origins: :any,
> token_verifier: verifier, # every request must carry a bearer token
> )
> ```
>
> Use it only when every request must carry a credential the browser does not attach on its own,
> such as a bearer token in the `Authorization` header. A cookie, HTTP Basic authentication and a client certificate
> are attached by the browser, and a server without authentication trusts whoever reaches it on localhost or an internal network:
> with any of those, a page on any site could send requests with the user's authority.
> The transport sends no CORS response headers and answers a preflight `OPTIONS` with 405,
> so browser-based clients also need a CORS middleware such as `rack-cors` in front of it;
> [Authorization](/server/authorization/) shows a setup.

### Rails (mount)

Expand Down
25 changes: 21 additions & 4 deletions lib/mcp/server/transports/streamable_http_transport.rb
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,10 @@ class InvalidJsonError < StandardError; end
# 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_origins [Array<String>, :any, nil] extra `Origin` values accepted in addition to
# same-origin requests, for DNS rebinding protection; `:any` accepts every `Origin` while
# the `Host` check stays on, for a server whose every request carries a credential the browser
# does not attach on its own (a bearer token, not a cookie).
# @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`.
Expand Down Expand Up @@ -253,9 +255,17 @@ def initialize(
@session_request_validator = session_request_validator
@dns_rebinding_protection = dns_rebinding_protection

# A Symbol never equals an `Origin` value, so one other than `:any` would refuse every cross-origin request
# without a hint that it was mistyped.
if allowed_origins.is_a?(Symbol) && allowed_origins != :any
raise ArgumentError, "allowed_origins must be an Array of Origin values or :any (got #{allowed_origins.inspect}); " \
":any turns Origin validation off, for a server that requires a bearer token on every request"
end

# 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
@any_origin_allowed = allowed_origins == :any
@allowed_origins = (@any_origin_allowed ? [] : Array(allowed_origins)).map(&:downcase).freeze
@pending_responses = {}

# Maps a key the transport mints for each `subscriptions/listen` stream to
Expand Down Expand Up @@ -2570,7 +2580,7 @@ def authenticate_request(request)
# 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:`.
# and the `Origin` header, when present, must be same-origin or in `allowed_origins:`, unless that is `:any`.
def validate_dns_rebinding(request)
return unless @dns_rebinding_protection

Expand Down Expand Up @@ -2602,7 +2612,14 @@ def validate_host(request)

# 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:`.
#
# `allowed_origins: :any` skips this check alone. A rebound request names the attacker's host in `Host` and, when it carries one,
# in `Origin`, so it is same-origin here and `validate_host` is what refuses it; what this check adds is refusing a page on another site
# that relies on what the browser attaches by itself, which a server requiring a bearer token does not accept.
# The `Origin` a session was created with still binds its later requests (`session_request_rejection`).
def validate_origin(request)
return if @any_origin_allowed

origin = header_bytes(request, "HTTP_ORIGIN")
return if origin.nil?
return if same_origin?(origin, request)
Expand Down
74 changes: 74 additions & 0 deletions test/mcp/server/transports/streamable_http_transport_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5961,6 +5961,55 @@ def string
assert_equal 403, status
end

test "handle_request allows every Origin with allowed_origins: :any" do
transport = StreamableHTTPTransport.new(@server, allowed_origins: :any)

["https://anywhere.example.com", "null", "chrome-extension://abcdefgh"].each do |origin|
request = create_rack_request(
"POST",
"/",
{ "CONTENT_TYPE" => "application/json", "HTTP_ORIGIN" => origin },
{ jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json,
)

status, = transport.handle_request(request)
assert_equal(200, status, origin)
end
ensure
transport&.close
end

test "rejects a Symbol other than :any for allowed_origins" do
error = assert_raises(ArgumentError) { StreamableHTTPTransport.new(@server, allowed_origins: :all) }

assert_equal(
"allowed_origins must be an Array of Origin values or :any (got :all); " \
":any turns Origin validation off, for a server that requires a bearer token on every request",
error.message,
)
end

test "handle_request keeps the Host check with allowed_origins: :any" do
transport = StreamableHTTPTransport.new(@server, allowed_origins: :any)

request = create_rack_request(
"POST",
"/",
{
"CONTENT_TYPE" => "application/json",
"HTTP_HOST" => "evil.example.com",
"HTTP_ORIGIN" => "http://evil.example.com",
},
{ jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json,
)

status, _headers, body = transport.handle_request(request)
assert_equal(403, status)
assert_equal("Forbidden: Invalid Host header", JSON.parse(body.first)["error"]["message"])
ensure
transport&.close
end

test "handle_request allows a same-origin browser request" do
request = create_rack_request(
"POST",
Expand Down Expand Up @@ -6187,6 +6236,31 @@ def string
transport.close
end

test "keeps a session bound to the Origin of its initialize with allowed_origins: :any" do
transport = StreamableHTTPTransport.new(@server, enable_json_response: true, allowed_origins: :any)
init = create_rack_request(
"POST",
"/",
{ "CONTENT_TYPE" => "application/json", "HTTP_ORIGIN" => "https://app.example.com" },
{ jsonrpc: "2.0", method: "initialize", id: "init", params: initialize_params }.to_json,
)
session_id = transport.handle_request(init)[1]["mcp-session-id"]

statuses = ["https://app.example.com", "https://other.example.com"].map do |origin|
request = create_rack_request(
"POST",
"/",
{ "CONTENT_TYPE" => "application/json", "HTTP_MCP_SESSION_ID" => session_id, "HTTP_ORIGIN" => origin },
{ jsonrpc: "2.0", method: "ping", id: "1" }.to_json,
)
transport.handle_request(request)[0]
end

assert_equal([200, 403], statuses)
ensure
transport.close
end

test "allows a POST with a matching Origin and one with no Origin" do
transport = StreamableHTTPTransport.new(
@server,
Expand Down
Loading