ruby-sdk/test/mcp/client/oauth/http_oauth_test.rb
Koichi ITO 5f429f3fbf
Merge pull request #560 from koic/let_a_provider_customize_the_oauth_http_client
Let a provider customize the HTTP client the OAuth flow uses
2026-09-19 08:00:02 +09:00

1946 lines
86 KiB
Ruby

# frozen_string_literal: true
require "test_helper"
require "json"
require "webmock/minitest"
require "faraday"
require "mcp/client/http"
require "mcp/client/oauth"
module MCP
class Client
module OAuth
class HTTPOAuthTest < Minitest::Test
# Records `[method, url]` for every request that passes through, standing in for the tracing
# middleware an application adds through `http_client_customizer`.
class RecordingMiddleware
def initialize(app, log)
@app = app
@log = log
end
def call(env)
@log << [env.method, env.url.to_s]
@app.call(env)
end
end
# Sends the request for `from` to `to` instead, the shape redirect-following middleware leaves behind.
class URLRewritingMiddleware
def initialize(app, from:, to:)
@app = app
@from = from
@to = to
end
def call(env)
env.url = URI(@to) if env.url.to_s == @from
@app.call(env)
end
end
# Hands the next middleware an environment rebuilt from Faraday's own members only, dropping whatever
# an earlier middleware recorded on the original.
class EnvRebuildingMiddleware
def initialize(app)
@app = app
end
def call(env)
@app.call(Faraday::Env.from(env.to_h))
end
end
def setup
WebMock.enable!
@mcp_url = "https://srv.example.com/mcp"
@prm_url = "https://srv.example.com/.well-known/oauth-protected-resource/mcp"
@auth_base = "https://auth.example.com"
end
def teardown
WebMock.reset!
end
def test_send_request_runs_oauth_flow_on_401_and_retries_with_bearer_token
# First POST: 401 with WWW-Authenticate. Second POST (with Bearer): 200.
stub_request(:post, @mcp_url)
.with { |req| req.headers["Authorization"].nil? }
.to_return(
status: 401,
headers: {
"WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}"),
},
body: "",
)
stub_request(:post, @mcp_url)
.with(headers: { "Authorization" => "Bearer test-token-after-flow" })
.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
)
stub_request(:get, @prm_url).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
resource: "https://srv.example.com/mcp",
authorization_servers: [@auth_base],
),
)
stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
issuer: @auth_base,
authorization_endpoint: "#{@auth_base}/authorize",
token_endpoint: "#{@auth_base}/token",
registration_endpoint: "#{@auth_base}/register",
response_types_supported: ["code"],
grant_types_supported: ["authorization_code"],
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
),
)
stub_request(:post, "#{@auth_base}/register").to_return(
status: 201,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(client_id: "test-client"),
)
stub_request(:post, "#{@auth_base}/token").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(access_token: "test-token-after-flow", token_type: "Bearer", expires_in: 3600),
)
state_holder = {}
provider = Provider.new(
client_metadata: {
client_name: "ruby-sdk-test",
redirect_uris: ["http://localhost:0/callback"],
grant_types: ["authorization_code"],
response_types: ["code"],
token_endpoint_auth_method: "none",
},
redirect_uri: "http://localhost:0/callback",
redirect_handler: ->(url) {
state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state")
},
callback_handler: -> { ["test-auth-code", state_holder[:state]] },
)
transport = HTTP.new(url: @mcp_url, oauth: provider)
response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
assert_equal({ "ok" => true }, response["result"])
assert_equal("test-token-after-flow", provider.access_token)
end
def test_send_request_runs_the_oauth_flow_through_the_provider_customizer
stub_request(:post, @mcp_url).with { |req|
req.headers["Authorization"].nil?
}.to_return(
status: 401,
headers: {
"WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}"),
},
body: "",
)
stub_request(:post, @mcp_url).with(
headers: { "Authorization" => "Bearer test-token-after-flow" }
).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
)
stub_request(:get, @prm_url).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]),
)
stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
issuer: @auth_base,
authorization_endpoint: "#{@auth_base}/authorize",
token_endpoint: "#{@auth_base}/token",
registration_endpoint: "#{@auth_base}/register",
response_types_supported: ["code"],
grant_types_supported: ["authorization_code"],
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
),
)
stub_request(:post, "#{@auth_base}/register").to_return(
status: 201,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(client_id: "test-client"),
)
stub_request(:post, "#{@auth_base}/token").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(access_token: "test-token-after-flow", token_type: "Bearer", expires_in: 3600),
)
log = []
state_holder = {}
provider = Provider.new(
client_metadata: {
client_name: "ruby-sdk-test",
redirect_uris: ["http://localhost:0/callback"],
grant_types: ["authorization_code"],
response_types: ["code"],
token_endpoint_auth_method: "none",
},
redirect_uri: "http://localhost:0/callback",
redirect_handler: ->(url) {
state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state")
},
callback_handler: -> { ["test-auth-code", state_holder[:state]] },
http_client_customizer: ->(faraday) { faraday.use(RecordingMiddleware, log) },
)
transport = HTTP.new(url: @mcp_url, oauth: provider)
response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
assert_equal({ "ok" => true }, response["result"])
assert_equal(
[
[:get, @prm_url],
[:get, "#{@auth_base}/.well-known/oauth-authorization-server"],
[:post, "#{@auth_base}/register"],
[:post, "#{@auth_base}/token"],
],
log,
)
end
def test_send_request_does_not_follow_a_resource_metadata_challenge_off_the_server_origin
# End to end over the transport, which is where the header is actually parsed:
# a server that answers 401 must not be able to name an unrelated host in
# `resource_metadata` and have the client fetch it. The request never leaving
# is the point; the 401 surfacing to the caller is secondary.
metadata_url = "https://169.254.169.254/latest/meta-data/iam/"
stub_request(:post, @mcp_url).to_return(
status: 401,
headers: {
"WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{metadata_url}"),
},
body: "",
)
stub_request(:get, metadata_url).to_return(status: 200, body: "{}")
provider = Provider.new(
client_metadata: {
redirect_uris: ["http://localhost:0/callback"],
grant_types: ["authorization_code"],
response_types: ["code"],
token_endpoint_auth_method: "none",
},
redirect_uri: "http://localhost:0/callback",
redirect_handler: ->(_url) {},
callback_handler: -> { ["code", "state"] },
)
transport = HTTP.new(url: @mcp_url, oauth: provider)
assert_raises(StandardError) do
transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
end
assert_not_requested(:get, metadata_url)
end
def test_send_request_runs_step_up_flow_on_403_insufficient_scope
# First call with the initial token: 403 carrying an `insufficient_scope`
# Bearer challenge with the escalated scope. The transport must re-run
# a full authorization (NOT a refresh) with the escalated scope and
# retry the original request.
stub_request(:post, @mcp_url).with(
headers: { "Authorization" => "Bearer initial-token" },
).to_return(
status: 403,
headers: {
"WWW-Authenticate" => %(Bearer error="insufficient_scope", ) +
%(scope="mcp:basic mcp:write", resource_metadata="#{@prm_url}"),
},
body: "",
)
stub_request(:post, @mcp_url).with(
headers: { "Authorization" => "Bearer escalated-token" },
).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
)
stub_step_up_authorization_server
provider = build_step_up_provider
transport = HTTP.new(url: @mcp_url, oauth: provider)
response = transport.send_request(
request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: { name: "x" } },
)
assert_equal({ "ok" => true }, response["result"])
assert_equal("escalated-token", provider.access_token)
# The fresh authorization-code grant ran (refresh path was bypassed).
assert_requested(:post, "#{@auth_base}/token") do |req|
form = URI.decode_www_form(req.body).to_h
form["grant_type"] == "authorization_code" &&
form["code"] == "test-auth-code"
end
# And the authorization request the SDK handed to the redirect
# handler carried the escalated scope from the challenge.
authorization_query = URI.decode_www_form(provider.last_authorization_url.query).to_h
assert_equal("mcp:basic mcp:write", authorization_query["scope"])
end
def test_send_request_step_up_unions_existing_scope_with_challenge_scope
# MCP step-up: the re-authorization request must ask for the union
# of the previously granted scope and the newly demanded scope.
# Otherwise the new token would lack permissions the caller had
# before, triggering another step-up the next time they are used.
stub_request(:post, @mcp_url)
.with(headers: { "Authorization" => "Bearer initial-token" })
.to_return(
status: 403,
headers: {
"WWW-Authenticate" => %(Bearer error="insufficient_scope", ) +
%(scope="mcp:write", resource_metadata="#{@prm_url}"),
},
body: "",
)
stub_request(:post, @mcp_url)
.with(headers: { "Authorization" => "Bearer escalated-token" })
.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
)
stub_step_up_authorization_server
provider = build_step_up_provider
# The provider already holds a token granted for `mcp:read`.
provider.save_tokens(
"access_token" => "initial-token",
"token_type" => "Bearer",
"scope" => "mcp:read",
)
transport = HTTP.new(url: @mcp_url, oauth: provider)
transport.send_request(
request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: {} },
)
authorization_query = URI.decode_www_form(provider.last_authorization_url.query).to_h
requested = authorization_query["scope"].split
assert_includes(requested, "mcp:read", "must preserve previously granted scope")
assert_includes(requested, "mcp:write", "must add the newly demanded scope")
assert_equal(requested.uniq.length, requested.length, "must not duplicate scopes")
end
def test_send_request_does_not_retry_plain_403
# A 403 without an `insufficient_scope` Bearer challenge is a hard
# forbidden, not a step-up signal. Surface it as a `RequestHandlerError`
# rather than tripping the OAuth flow.
stub_request(:post, @mcp_url).with(
headers: { "Authorization" => "Bearer initial-token" },
).to_return(
status: 403, body: "",
)
provider = build_step_up_provider
transport = HTTP.new(url: @mcp_url, oauth: provider)
error = assert_raises(MCP::Client::RequestHandlerError) do
transport.send_request(
request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: {} },
)
end
assert_equal(:forbidden, error.error_type)
assert_not_requested(:get, "#{@auth_base}/.well-known/oauth-authorization-server")
end
def test_send_request_does_not_loop_on_repeated_insufficient_scope
# The server keeps demanding more scope after every authorization.
# The transport retries the step-up flow at most once per
# `send_request`, then surfaces the 403 to the caller.
stub_request(:post, @mcp_url).to_return(
status: 403,
headers: {
"WWW-Authenticate" => %(Bearer error="insufficient_scope", ) +
%(scope="mcp:everything", resource_metadata="#{@prm_url}"),
},
body: "",
)
stub_step_up_authorization_server
provider = build_step_up_provider
transport = HTTP.new(url: @mcp_url, oauth: provider)
error = assert_raises(MCP::Client::RequestHandlerError) do
transport.send_request(
request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: {} },
)
end
assert_equal(:forbidden, error.error_type)
end
def test_send_request_step_up_bypasses_refresh_token
# The provider has a refresh_token, but step-up MUST run a fresh
# authorization request with the escalated scope, not exchange the
# refresh_token. Refreshing would mint a new access token with the
# same scopes as the current one, which already failed.
stub_request(:post, @mcp_url).with(
headers: { "Authorization" => "Bearer initial-token" },
).to_return(
status: 403,
headers: {
"WWW-Authenticate" => %(Bearer error="insufficient_scope", ) +
%(scope="mcp:basic mcp:write", resource_metadata="#{@prm_url}"),
},
body: "",
)
stub_request(:post, @mcp_url).with(
headers: { "Authorization" => "Bearer escalated-token" },
).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
)
stub_step_up_authorization_server
provider = build_step_up_provider
provider.save_tokens(
"access_token" => "initial-token",
"refresh_token" => "saved-rt",
"token_type" => "Bearer",
)
transport = HTTP.new(url: @mcp_url, oauth: provider)
transport.send_request(
request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: {} },
)
# The token endpoint was only called for the fresh
# `authorization_code` grant, never for `refresh_token`.
assert_not_requested(:post, "#{@auth_base}/token") do |req|
URI.decode_www_form(req.body).to_h["grant_type"] == "refresh_token"
end
end
def test_send_request_step_up_appends_offline_access_when_advertised
# Integration of step-up with the SEP-2207 offline_access logic:
# the re-authorization request unions the existing and challenge scopes,
# then -- because the AS advertises `offline_access` and the client
# declared the `refresh_token` grant -- `offline_access` is appended
# so the escalated token can itself be refreshed later.
stub_request(:post, @mcp_url)
.with(headers: { "Authorization" => "Bearer initial-token" })
.to_return(
status: 403,
headers: {
"WWW-Authenticate" => %(Bearer error="insufficient_scope", ) +
%(scope="mcp:write", resource_metadata="#{@prm_url}"),
},
body: "",
)
stub_request(:post, @mcp_url)
.with(headers: { "Authorization" => "Bearer escalated-token" })
.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
)
stub_step_up_authorization_server(
extra_as_metadata: { scopes_supported: ["mcp:read", "mcp:write", "offline_access"] },
)
provider = build_step_up_provider(grant_types: ["authorization_code", "refresh_token"])
provider.save_tokens(
"access_token" => "initial-token",
"token_type" => "Bearer",
"scope" => "mcp:read",
)
transport = HTTP.new(url: @mcp_url, oauth: provider)
transport.send_request(
request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: {} },
)
requested = URI.decode_www_form(provider.last_authorization_url.query).to_h["scope"].split
assert_includes(requested, "mcp:read", "must preserve previously granted scope")
assert_includes(requested, "mcp:write", "must add the challenged scope")
assert_includes(requested, "offline_access", "must append offline_access when advertised")
end
def test_send_request_step_up_strips_offline_access_when_not_advertised
# Mirror of the previous test: if the AS does NOT advertise
# `offline_access`, the step-up request must not carry it even when
# the existing token's scope happened to include it
# (SEP-2207 MUST-NOT, enforced by `normalize_offline_access_scope`).
stub_request(:post, @mcp_url)
.with(headers: { "Authorization" => "Bearer initial-token" })
.to_return(
status: 403,
headers: {
"WWW-Authenticate" => %(Bearer error="insufficient_scope", ) +
%(scope="mcp:write", resource_metadata="#{@prm_url}"),
},
body: "",
)
stub_request(:post, @mcp_url)
.with(headers: { "Authorization" => "Bearer escalated-token" })
.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
)
# AS advertises only resource scopes, no offline_access.
stub_step_up_authorization_server(
extra_as_metadata: { scopes_supported: ["mcp:read", "mcp:write"] },
)
provider = build_step_up_provider(grant_types: ["authorization_code", "refresh_token"])
provider.save_tokens(
"access_token" => "initial-token",
"token_type" => "Bearer",
"scope" => "mcp:read offline_access",
)
transport = HTTP.new(url: @mcp_url, oauth: provider)
transport.send_request(
request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: {} },
)
requested = URI.decode_www_form(provider.last_authorization_url.query).to_h["scope"].split
assert_includes(requested, "mcp:read")
assert_includes(requested, "mcp:write")
refute_includes(requested, "offline_access", "must strip offline_access when the AS does not advertise it")
end
def test_send_request_step_up_uses_cimd_client_id
# Integration of step-up with Client ID Metadata Documents:
# when the provider is CIMD-configured and the AS advertises CIMD support,
# the step-up re-authorization uses the CIMD URL as `client_id` and skips
# Dynamic Client Registration, just like the initial flow.
cimd_url = "https://app.example.com/client-metadata.json"
stub_request(:post, @mcp_url)
.with(headers: { "Authorization" => "Bearer initial-token" })
.to_return(
status: 403,
headers: {
"WWW-Authenticate" => %(Bearer error="insufficient_scope", ) +
%(scope="mcp:basic mcp:write", resource_metadata="#{@prm_url}"),
},
body: "",
)
stub_request(:post, @mcp_url)
.with(headers: { "Authorization" => "Bearer escalated-token" })
.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
)
stub_step_up_authorization_server(
extra_as_metadata: { client_id_metadata_document_supported: true },
)
provider = build_step_up_provider(client_id_metadata_document_url: cimd_url)
transport = HTTP.new(url: @mcp_url, oauth: provider)
transport.send_request(
request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: {} },
)
# DCR was skipped and the CIMD URL was used as the client_id on
# the escalated authorization request.
assert_not_requested(:post, "#{@auth_base}/register")
authorization_query = URI.decode_www_form(provider.last_authorization_url.query).to_h
assert_equal(cimd_url, authorization_query["client_id"])
end
def test_initialize_rejects_non_secure_mcp_url_when_oauth_is_set
# A bearer token sent to `http://attacker.example.com/mcp` would leak
# over the wire. When OAuth is on, the transport URL must clear
# the same Communication Security bar as every other OAuth-related URL.
provider = Provider.new(
client_metadata: { redirect_uris: ["http://localhost:0/callback"] },
redirect_uri: "http://localhost:0/callback",
redirect_handler: ->(_url) {},
callback_handler: -> { ["code", "state"] },
)
assert_raises(MCP::Client::HTTP::InsecureURLError) do
MCP::Client::HTTP.new(url: "http://attacker.example.com/mcp", oauth: provider)
end
end
def test_initialize_allows_loopback_http_mcp_url_when_oauth_is_set
provider = Provider.new(
client_metadata: { redirect_uris: ["http://localhost:0/callback"] },
redirect_uri: "http://localhost:0/callback",
redirect_handler: ->(_url) {},
callback_handler: -> { ["code", "state"] },
)
# Loopback HTTP is permitted for local development / conformance.
MCP::Client::HTTP.new(url: "http://localhost:9292/mcp", oauth: provider)
MCP::Client::HTTP.new(url: "http://127.0.0.1:9292/mcp", oauth: provider)
end
def test_initialize_allows_non_secure_mcp_url_when_oauth_is_nil
# Without OAuth, no bearer tokens are sent, so the SDK leaves
# the scheme decision to the caller.
MCP::Client::HTTP.new(url: "http://internal.example.com/mcp")
end
def test_refresh_path_preserves_query_string_in_resource_claim
# The refresh flow MUST send the same canonical `resource` claim as
# the original authorization request -- otherwise the AS rejects
# the refresh as a different audience. Verify by giving the transport
# a query-bearing MCP URL and a saved refresh token, then asserting
# the refresh POST body carries the full URL.
tenant_mcp_url = "https://srv.example.com/mcp?tenant=1"
stub_request(:post, tenant_mcp_url)
.with { |req| req.headers["Authorization"] != "Bearer refreshed-tenant-token" }
.to_return(
status: 401,
headers: {
"WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}"),
},
body: "",
)
stub_request(:post, tenant_mcp_url)
.with(headers: { "Authorization" => "Bearer refreshed-tenant-token" })
.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
)
stub_request(:get, @prm_url).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(authorization_servers: [@auth_base]),
)
stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
issuer: @auth_base,
authorization_endpoint: "#{@auth_base}/authorize",
token_endpoint: "#{@auth_base}/token",
registration_endpoint: "#{@auth_base}/register",
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
),
)
stub_request(:post, "#{@auth_base}/token")
.with(body: hash_including("grant_type" => "refresh_token"))
.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(access_token: "refreshed-tenant-token", token_type: "Bearer", expires_in: 3600),
)
provider = build_provider
provider.save_client_information("client_id" => "test-client")
provider.save_tokens("access_token" => "stale", "refresh_token" => "valid-rt")
transport = HTTP.new(url: tenant_mcp_url, oauth: provider)
response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
assert_equal({ "ok" => true }, response["result"])
# The refresh POST body's `resource` MUST keep the query string so it
# matches the audience the original token was minted for.
assert_requested(:post, "#{@auth_base}/token") do |req|
form = URI.decode_www_form(req.body).to_h
form["grant_type"] == "refresh_token" &&
form["resource"] == "https://srv.example.com/mcp?tenant=1"
end
end
def test_send_request_refreshes_when_refresh_token_is_available
# On 401 with a saved refresh_token, the transport should swap it for
# a fresh access token rather than re-running the full interactive
# Authorization Code flow.
stub_request(:post, @mcp_url)
.with { |req| req.headers["Authorization"] != "Bearer refreshed-token" }
.to_return(
status: 401,
headers: {
"WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}"),
},
body: "",
)
stub_request(:post, @mcp_url)
.with(headers: { "Authorization" => "Bearer refreshed-token" })
.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
)
stub_request(:get, @prm_url).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]),
)
stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
issuer: @auth_base,
authorization_endpoint: "#{@auth_base}/authorize",
token_endpoint: "#{@auth_base}/token",
registration_endpoint: "#{@auth_base}/register",
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
),
)
# DCR and /authorize must not be invoked when refresh succeeds.
stub_request(:post, "#{@auth_base}/register").to_raise(StandardError.new("DCR should not be called."))
stub_request(:get, "#{@auth_base}/authorize").to_raise(StandardError.new("authorize should not be called."))
stub_request(:post, "#{@auth_base}/token")
.with(body: hash_including("grant_type" => "refresh_token"))
.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(access_token: "refreshed-token", token_type: "Bearer", expires_in: 3600),
)
provider = build_provider
provider.save_client_information("client_id" => "test-client")
provider.save_tokens("access_token" => "stale-token", "refresh_token" => "saved-rt")
transport = HTTP.new(url: @mcp_url, oauth: provider)
response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
assert_equal({ "ok" => true }, response["result"])
assert_equal("refreshed-token", provider.access_token)
end
def test_send_request_refreshes_for_a_cross_app_access_provider_holding_a_refresh_token
# Refresh serves every provider, not only the authorization-code one that has a CIMD URL reader.
stub_request(:post, @mcp_url).with { |req|
req.headers["Authorization"] != "Bearer refreshed-token"
}.to_return(
status: 401,
headers: { "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}") },
body: "",
)
stub_request(:post, @mcp_url).with(
headers: { "Authorization" => "Bearer refreshed-token" }
).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
)
stub_request(:get, @prm_url).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]),
)
stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
issuer: @auth_base,
token_endpoint: "#{@auth_base}/token",
grant_types_supported: ["urn:ietf:params:oauth:grant-type:jwt-bearer", "refresh_token"],
token_endpoint_auth_methods_supported: ["client_secret_basic"],
),
)
stub_request(:post, "#{@auth_base}/token").with(
body: hash_including("grant_type" => "refresh_token", "refresh_token" => "saved-rt")
).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(access_token: "refreshed-token", token_type: "Bearer", expires_in: 3600),
)
assertion_calls = 0
provider = CrossAppAccessProvider.new(
client_id: "xaa-client",
client_secret: "xaa-secret",
assertion_provider: ->(**) {
assertion_calls += 1
"id-jag-assertion"
},
)
provider.save_tokens("access_token" => "stale-token", "refresh_token" => "saved-rt", "issuer" => @auth_base)
transport = HTTP.new(url: @mcp_url, oauth: provider)
response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
assert_equal({ "ok" => true }, response["result"])
assert_equal("refreshed-token", provider.access_token)
assert_equal(0, assertion_calls)
end
def test_send_request_surfaces_a_bad_token_request_params_hook_instead_of_reauthorizing
# A provider whose `token_request_params` names a reserved key is misconfigured, not unauthorized:
# the refresh attempt must raise rather than fall through to the interactive flow,
# which would fail the same way after the user signed in.
stub_request(:post, @mcp_url).to_return(
status: 401,
headers: { "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}") },
body: "",
)
stub_request(:get, @prm_url).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]),
)
stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
issuer: @auth_base,
authorization_endpoint: "#{@auth_base}/authorize",
token_endpoint: "#{@auth_base}/token",
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
),
)
redirected = false
provider_class = Class.new(Provider) do
define_method(:token_request_params) { { "grant_type" => "password" } }
end
provider = provider_class.new(
client_metadata: { redirect_uris: ["http://localhost:0/callback"] },
redirect_uri: "http://localhost:0/callback",
redirect_handler: ->(_url) { redirected = true },
callback_handler: -> { ["code", "state"] },
)
provider.save_client_information("client_id" => "test-client")
provider.save_tokens("access_token" => "stale-token", "refresh_token" => "saved-rt")
transport = HTTP.new(url: @mcp_url, oauth: provider)
assert_raises(Flow::InvalidTokenRequestParamsError) do
transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
end
refute(redirected)
assert_not_requested(:post, "#{@auth_base}/token")
assert_equal("saved-rt", provider.tokens["refresh_token"])
end
def test_send_request_surfaces_a_refused_refresh_without_falling_back
# A customizer middleware that would carry the refresh request off the origin is refused before
# the request leaves, and that refusal is not one of the failures that start the interactive flow
# or discard the stored tokens.
stub_request(:post, @mcp_url).to_return(
status: 401,
headers: { "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}") },
body: "",
)
stub_request(:get, @prm_url).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]),
)
stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
issuer: @auth_base,
authorization_endpoint: "#{@auth_base}/authorize",
token_endpoint: "#{@auth_base}/token",
registration_endpoint: "#{@auth_base}/register",
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
),
)
stub_request(:post, "#{@auth_base}/register").to_raise(StandardError.new("DCR should not be called."))
stub_request(:post, "https://other.example.com/token").to_raise(StandardError.new("the rewritten request must not leave."))
provider = build_provider(
http_client_customizer: ->(faraday) {
faraday.use(URLRewritingMiddleware, from: "#{@auth_base}/token", to: "https://other.example.com/token")
},
)
provider.save_client_information("client_id" => "test-client")
provider.save_tokens("access_token" => "stale-token", "refresh_token" => "saved-rt")
transport = HTTP.new(url: @mcp_url, oauth: provider)
assert_raises(MCP::Client::OAuth::Flow::DestinationMismatchError) do
transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
end
assert_not_requested(:post, "https://other.example.com/token")
assert_not_requested(:post, "#{@auth_base}/token")
assert_not_requested(:post, "#{@auth_base}/register")
assert_equal("saved-rt", provider.tokens["refresh_token"])
end
def test_send_request_surfaces_a_refresh_whose_environment_was_rebuilt_without_falling_back
# The missing-record refusal takes the same route as the origin mismatch: out of `send_request`,
# with no interactive fallback and the stored tokens intact.
stub_request(:post, @mcp_url).to_return(
status: 401,
headers: { "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}") },
body: "",
)
stub_request(:get, @prm_url).to_raise(StandardError.new("the rebuilt request must not leave."))
stub_request(:post, "#{@auth_base}/register").to_raise(StandardError.new("DCR should not be called."))
interactive_flow_started = false
provider = Provider.new(
client_metadata: { redirect_uris: ["http://localhost:0/callback"] },
redirect_uri: "http://localhost:0/callback",
redirect_handler: ->(_url) { interactive_flow_started = true },
callback_handler: -> { ["code", "state"] },
http_client_customizer: ->(faraday) { faraday.use(EnvRebuildingMiddleware) },
)
provider.save_client_information("client_id" => "test-client")
provider.save_tokens("access_token" => "stale-token", "refresh_token" => "saved-rt")
transport = HTTP.new(url: @mcp_url, oauth: provider)
error = assert_raises(MCP::Client::OAuth::Flow::DestinationMismatchError) do
transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
end
assert_match(/carries no record of the URL the flow asked for/, error.message)
refute(interactive_flow_started, "the refusal must not fall back to the interactive flow")
assert_not_requested(:get, @prm_url)
assert_not_requested(:post, "#{@auth_base}/token")
assert_not_requested(:post, "#{@auth_base}/register")
assert_equal({ "access_token" => "stale-token", "refresh_token" => "saved-rt" }, provider.tokens)
end
def test_send_request_preserves_refresh_token_when_refresh_hits_a_transient_failure
# A 5xx (or any non-`invalid_grant`) from the token endpoint indicates
# a transient AS outage, NOT that the refresh token is dead.
# The transport must fall through to the full Authorization Code flow
# (we cannot serve the MCP request without a fresh access token), but
# the stored refresh_token must remain so subsequent attempts on
# the next outage-free request can succeed without interactive reauth.
stub_request(:post, @mcp_url)
.with { |req| req.headers["Authorization"] != "Bearer brand-new-token" }
.to_return(
status: 401,
headers: {
"WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}"),
},
body: "",
)
stub_request(:post, @mcp_url)
.with(headers: { "Authorization" => "Bearer brand-new-token" })
.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
)
stub_request(:get, @prm_url).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]),
)
stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
issuer: @auth_base,
authorization_endpoint: "#{@auth_base}/authorize",
token_endpoint: "#{@auth_base}/token",
registration_endpoint: "#{@auth_base}/register",
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
),
)
# Refresh request: transient 503. Auth code exchange: succeeds with
# a NEW refresh token (so we can prove the live RT survived rather than
# being silently overwritten by the success path).
stub_request(:post, "#{@auth_base}/token")
.with(body: hash_including("grant_type" => "refresh_token"))
.to_return(status: 503, body: "")
stub_request(:post, "#{@auth_base}/token")
.with(body: hash_including("grant_type" => "authorization_code"))
.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(access_token: "brand-new-token", token_type: "Bearer"),
)
state_holder = {}
provider = Provider.new(
client_metadata: { redirect_uris: ["http://localhost:0/callback"] },
redirect_uri: "http://localhost:0/callback",
redirect_handler: ->(url) {
state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state")
},
callback_handler: -> { ["test-auth-code", state_holder[:state]] },
)
provider.save_client_information("client_id" => "test-client")
provider.save_tokens("access_token" => "stale", "refresh_token" => "live-rt")
transport = HTTP.new(url: @mcp_url, oauth: provider)
response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
assert_equal({ "ok" => true }, response["result"])
# The transient 503 must not wipe the refresh token; the auth-code
# success path overwrites tokens, but we asserted the prior refresh
# token survived the refresh-attempt failure by NOT seeing the full
# flow trigger `clear_tokens!`. To prove this, inspect that
# the final stored refresh_token is whatever the auth-code response
# provided (omitted here), not nil from a forced clear.
stored = provider.tokens || {}
assert_equal("brand-new-token", stored["access_token"] || stored[:access_token])
end
def test_send_request_falls_back_to_full_flow_when_refresh_fails
# If the refresh request itself is rejected (refresh token revoked),
# the transport should clear stale tokens and fall through to
# the full Authorization Code flow.
stub_request(:post, @mcp_url)
.with { |req| req.headers["Authorization"] != "Bearer brand-new-token" }
.to_return(
status: 401,
headers: {
"WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}"),
},
body: "",
)
stub_request(:post, @mcp_url)
.with(headers: { "Authorization" => "Bearer brand-new-token" })
.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
)
stub_request(:get, @prm_url).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(resource: "https://srv.example.com/mcp", authorization_servers: [@auth_base]),
)
stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
issuer: @auth_base,
authorization_endpoint: "#{@auth_base}/authorize",
token_endpoint: "#{@auth_base}/token",
registration_endpoint: "#{@auth_base}/register",
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
),
)
# First /token call (refresh) fails; second call (authorization_code) succeeds.
stub_request(:post, "#{@auth_base}/token")
.with(body: hash_including("grant_type" => "refresh_token"))
.to_return(status: 401, body: "")
stub_request(:post, "#{@auth_base}/token")
.with(body: hash_including("grant_type" => "authorization_code"))
.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(access_token: "brand-new-token", token_type: "Bearer", expires_in: 3600),
)
state_holder = {}
provider = Provider.new(
client_metadata: { redirect_uris: ["http://localhost:0/callback"] },
redirect_uri: "http://localhost:0/callback",
redirect_handler: ->(url) {
state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state")
},
callback_handler: -> { ["test-auth-code", state_holder[:state]] },
)
provider.save_client_information("client_id" => "test-client")
provider.save_tokens("access_token" => "stale", "refresh_token" => "revoked-rt")
transport = HTTP.new(url: @mcp_url, oauth: provider)
response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
assert_equal({ "ok" => true }, response["result"])
assert_equal("brand-new-token", provider.access_token)
end
def test_oauth_flow_preserves_query_string_in_resource_claim
# Query parameters on the MCP URL (e.g. `?tenant=...`) must be carried
# into the RFC 8707 `resource` claim sent on the authorization and
# token requests, matching how the TS and Python SDKs build
# the canonical resource URL. The URL guard's snapshot is allowed to drop
# the query because Faraday does the same at request time, but
# the OAuth-side snapshot must retain it.
tenant_mcp_url = "https://srv.example.com/mcp?tenant=1"
stub_request(:post, tenant_mcp_url)
.with { |req| req.headers["Authorization"].nil? }
.to_return(
status: 401,
headers: {
"WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}"),
},
body: "",
)
stub_request(:post, tenant_mcp_url)
.with(headers: { "Authorization" => "Bearer tenant-token" })
.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
)
stub_request(:get, @prm_url).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
# PRM is allowed to omit `resource`; the client falls back to its
# canonical MCP URL (query and all).
authorization_servers: [@auth_base],
),
)
stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
issuer: @auth_base,
authorization_endpoint: "#{@auth_base}/authorize",
token_endpoint: "#{@auth_base}/token",
registration_endpoint: "#{@auth_base}/register",
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
),
)
stub_request(:post, "#{@auth_base}/register").to_return(
status: 201,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(client_id: "test-client"),
)
stub_request(:post, "#{@auth_base}/token").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(access_token: "tenant-token", token_type: "Bearer", expires_in: 3600),
)
captured_authorization_url = nil
provider = Provider.new(
client_metadata: {
client_name: "ruby-sdk-test",
redirect_uris: ["http://localhost:0/callback"],
grant_types: ["authorization_code"],
response_types: ["code"],
token_endpoint_auth_method: "none",
},
redirect_uri: "http://localhost:0/callback",
redirect_handler: ->(url) { captured_authorization_url = url },
callback_handler: -> {
query = URI.decode_www_form(captured_authorization_url.query).to_h
["test-auth-code", query.fetch("state")]
},
)
transport = HTTP.new(url: tenant_mcp_url, oauth: provider)
response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
assert_equal({ "ok" => true }, response["result"])
# The authorization URL's `resource` parameter MUST keep the query.
query = URI.decode_www_form(captured_authorization_url.query).to_h
assert_equal("https://srv.example.com/mcp?tenant=1", query["resource"])
# The token endpoint POST body's `resource` MUST also keep the query.
assert_requested(:post, "#{@auth_base}/token") do |req|
URI.decode_www_form(req.body).to_h["resource"] == "https://srv.example.com/mcp?tenant=1"
end
end
def test_oauth_flow_uses_validated_url_after_url_mutation
# Even if an attacker mutates `@url` after construction (e.g.
# `instance_variable_set(:@url, "https://attacker.example.com/mcp")`),
# the OAuth discovery / authorization code flow MUST continue to use
# the URL snapshotted at initialize time. Otherwise PRM, AS metadata,
# and the `resource` claim sent to the authorization server would
# all point at the attacker-controlled host.
attacker_prm = "https://attacker.example.com/.well-known/oauth-protected-resource/mcp"
attacker_root_prm = "https://attacker.example.com/.well-known/oauth-protected-resource"
stub_request(:get, attacker_prm).to_raise(StandardError.new("PRM must not target attacker host."))
stub_request(:get, attacker_root_prm).to_raise(StandardError.new("PRM must not target attacker host."))
stub_request(:post, @mcp_url)
.with { |req| req.headers["Authorization"] != "Bearer post-mutation-token" }
.to_return(
status: 401,
# No `resource_metadata` to force PRM derivation from
# the server_url -- this is exactly the path where a mutated
# `@url` could redirect discovery without the snapshot.
headers: { "WWW-Authenticate" => 'Bearer error="invalid_token"' },
body: "",
)
stub_request(:get, @prm_url).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
resource: "https://srv.example.com/mcp",
authorization_servers: [@auth_base],
),
)
stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
issuer: @auth_base,
authorization_endpoint: "#{@auth_base}/authorize",
token_endpoint: "#{@auth_base}/token",
registration_endpoint: "#{@auth_base}/register",
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
),
)
stub_request(:post, "#{@auth_base}/register").to_return(
status: 201,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(client_id: "test-client"),
)
stub_request(:post, "#{@auth_base}/token").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(access_token: "post-mutation-token", token_type: "Bearer", expires_in: 3600),
)
captured_authorization_url = nil
provider = Provider.new(
client_metadata: {
client_name: "ruby-sdk-test",
redirect_uris: ["http://localhost:0/callback"],
grant_types: ["authorization_code"],
response_types: ["code"],
token_endpoint_auth_method: "none",
},
redirect_uri: "http://localhost:0/callback",
redirect_handler: ->(url) { captured_authorization_url = url },
callback_handler: -> {
query = URI.decode_www_form(captured_authorization_url.query).to_h
["test-auth-code", query.fetch("state")]
},
)
stub_request(:post, @mcp_url)
.with(headers: { "Authorization" => "Bearer post-mutation-token" })
.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
)
transport = HTTP.new(url: @mcp_url, oauth: provider)
# Force the lazy Faraday connection to be built against the validated
# URL before the mutation. After this, `@client.url_prefix` is
# locked to `srv.example.com` regardless of what `@url` later points
# at, which is what the URL guard middleware reads.
transport.send(:client)
transport.instance_variable_set(:@url, "https://attacker.example.com/mcp")
# The first 401 must drive `run_oauth_flow!` against the validated
# URL, not the mutated `@url`. The OAuth flow succeeds, the retry
# carries the new bearer token, and Faraday (still pointed at
# the safe URL via its frozen `url_prefix`) returns the stubbed 200.
response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
assert_equal({ "ok" => true }, response["result"])
# Discovery must have aimed at the validated host. The attacker-host
# PRM stubs would have raised otherwise.
assert_requested(:get, @prm_url)
refute_requested(:get, attacker_prm)
refute_requested(:get, attacker_root_prm)
# The `resource` claim sent on the authorization URL is built from
# the validated URL, not the attacker-host mutation.
query = URI.decode_www_form(captured_authorization_url.query).to_h
assert_equal("https://srv.example.com/mcp", query["resource"])
refute_includes(query["resource"], "attacker.example.com")
end
def test_send_request_rejects_url_mutation_after_initialize
# The constructor secure-URL check alone is not enough: `@url` is
# a plain instance variable, so a caller can `instance_variable_set` it
# to a non-secure URL after the transport is built. The send-time
# re-check must catch this and refuse to send the bearer token.
provider = build_provider
transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp", oauth: provider)
transport.instance_variable_set(:@url, "http://attacker.example.com/mcp")
assert_raises(MCP::Client::HTTP::InsecureURLError) do
transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
end
end
def test_send_request_rejects_faraday_customizer_url_swap
# An `&block` Faraday customizer can rewrite `url_prefix` to point at
# a plaintext attacker host; the constructor URL check would miss this.
# The send-time effective-URL check must reject it.
provider = build_provider
transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp", oauth: provider) do |faraday|
faraday.url_prefix = "http://attacker.example.com/mcp"
end
assert_raises(MCP::Client::HTTP::InsecureURLError) do
transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
end
end
def test_send_request_rejects_https_to_different_host_via_customizer
# An `https://` URL alone is not enough - the URL must also match
# the one validated at initialize time. A customizer that swaps to
# a *different* https host (e.g. `https://attacker.example.com/mcp`)
# would otherwise pass a naive "still https?" check and leak
# the bearer token to the attacker.
provider = build_provider
transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp", oauth: provider) do |faraday|
faraday.url_prefix = "https://attacker.example.com/mcp"
end
assert_raises(MCP::Client::HTTP::InsecureURLError) do
transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
end
end
def test_send_request_rejects_middleware_env_url_query_rewrite
# A Faraday middleware that swaps only the query (`tenant=victim` ->
# `tenant=evil`) must be caught by the URL guard. Otherwise
# an attacker who can inject middleware can ship the bearer token to
# a different tenant of the same origin/path while the OAuth-side
# audience binding stays correct.
rewrite_query_middleware = Class.new do
def initialize(app)
@app = app
end
def call(env)
env.url.query = "tenant=evil"
@app.call(env)
end
end
provider = build_provider
provider.save_tokens("access_token" => "valid-token")
transport = MCP::Client::HTTP.new(
url: "https://srv.example.com/mcp?tenant=victim",
oauth: provider,
) do |faraday|
faraday.use(rewrite_query_middleware)
end
assert_raises(MCP::Client::HTTP::InsecureURLError) do
transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
end
end
def test_send_request_rejects_middleware_env_url_rewrite
# A Faraday middleware can mutate `env.url` at request time, which
# `Faraday::Connection#url_prefix` cannot detect. The identity guard
# must run as a middleware *after* the customizer so it sees
# the rewritten env.url before it reaches the adapter.
rewrite_middleware = Class.new do
def initialize(app)
@app = app
end
def call(env)
env.url = URI("https://attacker.example.com/mcp")
@app.call(env)
end
end
provider = build_provider
transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp", oauth: provider) do |faraday|
faraday.use(rewrite_middleware)
end
assert_raises(MCP::Client::HTTP::InsecureURLError) do
transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
end
end
def test_send_request_accepts_validated_url_with_userinfo
# Faraday hoists `user:pass@` out of the URL into an `Authorization:
# Basic` header, so `env.url` at request time does not carry
# the userinfo. The identity guard must drop userinfo on both sides to
# avoid a false-positive `InsecureURLError` on this valid URL.
stub_request(:post, "https://srv.example.com/mcp")
.with(headers: { "Authorization" => /Basic|Bearer/ })
.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
)
provider = build_provider
provider.save_tokens("access_token" => "valid-token")
transport = MCP::Client::HTTP.new(url: "https://user:pass@srv.example.com/mcp", oauth: provider)
response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
assert_equal({ "ok" => true }, response["result"])
end
def test_insecure_url_error_does_not_leak_userinfo
# The error message MUST surface only the canonicalized (origin + path)
# form so that `user:pass@` cannot leak into logs, stack traces, or
# exception reporters.
provider = build_provider
error = assert_raises(MCP::Client::HTTP::InsecureURLError) do
MCP::Client::HTTP.new(url: "http://hunter2:t0psecret@attacker.example.com/mcp", oauth: provider)
end
refute_includes(error.message, "hunter2")
refute_includes(error.message, "t0psecret")
end
def test_send_request_accepts_validated_url_with_query_string
# A `?tenant=1` style query on the user-supplied URL must not trip
# the identity guard. `Faraday::Connection#url_prefix.to_s` drops
# the query, so naive string-compare would raise a false-positive
# `InsecureURLError` on the very first request.
stub_request(:post, "https://srv.example.com/mcp?tenant=1").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
)
provider = build_provider
provider.save_tokens("access_token" => "valid-token")
transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp?tenant=1", oauth: provider)
response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
assert_equal({ "ok" => true }, response["result"])
end
def test_send_request_accepts_url_with_unsorted_query_parameters
# Faraday sorts query parameters by name before sending the request,
# so the snapshot and the effective URL must compare as equal even
# when the user-supplied URL listed `b` first and `a` second.
stub_request(:post, "https://srv.example.com/mcp?b=2&a=1").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
)
provider = build_provider
provider.save_tokens("access_token" => "valid-token")
transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp?b=2&a=1", oauth: provider)
response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
assert_equal({ "ok" => true }, response["result"])
end
def test_send_request_accepts_url_with_lowercase_percent_encoded_hex
# Faraday uppercases percent-encoded hex digits (`%2f` -> `%2F`),
# so the snapshot and the effective URL must compare as equal even
# when the user-supplied URL used lowercase hex.
stub_request(:post, "https://srv.example.com/mcp?x=%2f").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
)
provider = build_provider
provider.save_tokens("access_token" => "valid-token")
transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp?x=%2f", oauth: provider)
response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
assert_equal({ "ok" => true }, response["result"])
end
def test_send_request_accepts_url_with_valueless_query_key
# `?tenant` (no `=`) and `?tenant=` (empty value) are distinct in
# Faraday's encoding. The snapshot must preserve that distinction
# so the URL guard does not false-positive when the user-supplied
# URL omits the `=`.
stub_request(:post, "https://srv.example.com/mcp?tenant").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
)
provider = build_provider
provider.save_tokens("access_token" => "valid-token")
transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp?tenant", oauth: provider)
response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
assert_equal({ "ok" => true }, response["result"])
end
def test_send_request_accepts_url_with_array_form_query_parameters
# Faraday preserves array-form (`?roles[]=a&roles[]=b`) duplicates
# rather than collapsing them. The snapshot must do the same so
# the URL guard does not raise a false positive.
stub_request(:post, "https://srv.example.com/mcp?roles[]=a&roles[]=b").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
)
provider = build_provider
provider.save_tokens("access_token" => "valid-token")
transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp?roles[]=a&roles[]=b", oauth: provider)
response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
assert_equal({ "ok" => true }, response["result"])
end
def test_send_request_rejects_array_form_query_entry_drop_via_middleware
# An attacker middleware that strips one value from a multi-value
# array-form query (`?tenant[]=victim&tenant[]=victim2` ->
# `?tenant[]=victim`) must be caught by the URL guard. If
# the snapshot collapsed duplicates, both forms would compare as equal
# and the bearer token would leak to a partially-rewritten audience.
drop_first_value_middleware = Class.new do
def initialize(app)
@app = app
end
def call(env)
env.url.query = "tenant%5B%5D=victim"
@app.call(env)
end
end
provider = build_provider
provider.save_tokens("access_token" => "valid-token")
transport = MCP::Client::HTTP.new(
url: "https://srv.example.com/mcp?tenant[]=victim&tenant[]=victim2",
oauth: provider,
) do |faraday|
faraday.use(drop_first_value_middleware)
end
assert_raises(MCP::Client::HTTP::InsecureURLError) do
transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
end
end
def test_send_request_accepts_url_with_blank_query_segments
# Faraday's query encoder drops empty-name pairs and blank `&&`
# separators (`?&&a=1&&` -> `?a=1`). The snapshot must do the same
# so the URL guard does not raise a false positive.
stub_request(:post, "https://srv.example.com/mcp?a=1").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
)
provider = build_provider
provider.save_tokens("access_token" => "valid-token")
transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp?&&a=1&&", oauth: provider)
response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
assert_equal({ "ok" => true }, response["result"])
end
def test_send_request_accepts_url_with_repeated_query_parameters
# Faraday's query encoder collapses repeated same-name parameters
# to the last value (`?a=1&a=2` -> `?a=2`). The snapshot must apply
# the same collapse so the URL guard does not raise a false
# positive.
stub_request(:post, "https://srv.example.com/mcp?a=2").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
)
provider = build_provider
provider.save_tokens("access_token" => "valid-token")
transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp?a=1&a=2", oauth: provider)
response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
assert_equal({ "ok" => true }, response["result"])
end
def test_send_request_accepts_url_with_empty_query_marker
# Faraday drops a bare trailing `?` with no parameters, so
# the snapshot and the effective URL must compare as equal even when
# the user-supplied URL kept the `?` marker.
stub_request(:post, "https://srv.example.com/mcp").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
)
provider = build_provider
provider.save_tokens("access_token" => "valid-token")
transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp?", oauth: provider)
response = transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
assert_equal({ "ok" => true }, response["result"])
end
def test_send_request_rejects_https_to_different_host_via_url_mutation
# Same identity-binding requirement when the swap goes through
# `instance_variable_set(:@url, ...)`: the new URL is HTTPS, but it
# is not the URL that was validated at initialize time.
stub_request(:post, "https://attacker.example.com/mcp").to_return(status: 200, body: "")
provider = build_provider
transport = MCP::Client::HTTP.new(url: "https://srv.example.com/mcp", oauth: provider)
transport.instance_variable_set(:@url, "https://attacker.example.com/mcp")
transport.instance_variable_set(:@client, nil) # force Faraday rebuild against mutated @url
assert_raises(MCP::Client::HTTP::InsecureURLError) do
transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
end
end
def test_send_request_raises_unauthorized_when_no_oauth_provider
stub_request(:post, @mcp_url).to_return(status: 401, body: "")
transport = HTTP.new(url: @mcp_url)
error = assert_raises(MCP::Client::RequestHandlerError) do
transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
end
assert_equal(:unauthorized, error.error_type)
end
def test_send_request_does_not_loop_when_oauth_flow_fails
stub_request(:post, @mcp_url).to_return(status: 401, body: "")
stub_request(:get, @prm_url).to_return(status: 404, body: "")
stub_request(:get, "https://srv.example.com/.well-known/oauth-protected-resource").to_return(
status: 404,
body: "",
)
# With no PRM, discovery falls back to the legacy 2025-03-26 path; dead-end that too so the flow fails exactly once.
stub_request(:get, "https://srv.example.com/.well-known/oauth-authorization-server").to_return(status: 404)
stub_request(:get, "https://srv.example.com/.well-known/openid-configuration").to_return(status: 404)
stub_request(:post, "https://srv.example.com/register").to_return(status: 404)
provider = Provider.new(
client_metadata: { redirect_uris: ["http://localhost:0/callback"] },
redirect_uri: "http://localhost:0/callback",
redirect_handler: ->(_url) {},
callback_handler: -> { [nil, nil] },
)
transport = HTTP.new(url: @mcp_url, oauth: provider)
assert_raises(Flow::AuthorizationError) do
transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
end
end
def test_send_request_does_not_re_run_oauth_flow_on_repeated_401
# Server keeps returning 401 even after a successful token exchange.
# The retry guard MUST limit OAuth flow execution to one attempt per
# `send_request` so we surface the failure instead of looping.
stub_request(:post, @mcp_url).to_return(
status: 401,
headers: {
"WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}"),
},
body: "",
)
stub_request(:get, @prm_url).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
resource: "https://srv.example.com/mcp",
authorization_servers: [@auth_base],
),
)
stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
issuer: @auth_base,
authorization_endpoint: "#{@auth_base}/authorize",
token_endpoint: "#{@auth_base}/token",
registration_endpoint: "#{@auth_base}/register",
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
),
)
stub_request(:post, "#{@auth_base}/register").to_return(
status: 201,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(client_id: "test-client"),
)
stub_request(:post, "#{@auth_base}/token").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(access_token: "still-bad-token", token_type: "Bearer", expires_in: 3600),
)
state_holder = {}
provider = Provider.new(
client_metadata: {
client_name: "ruby-sdk-test",
redirect_uris: ["http://localhost:0/callback"],
grant_types: ["authorization_code"],
response_types: ["code"],
token_endpoint_auth_method: "none",
},
redirect_uri: "http://localhost:0/callback",
redirect_handler: ->(url) {
state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state")
},
callback_handler: -> { ["test-auth-code", state_holder[:state]] },
)
transport = HTTP.new(url: @mcp_url, oauth: provider)
assert_raises(MCP::Client::RequestHandlerError) do
transport.send_request(request: { jsonrpc: "2.0", id: "1", method: "tools/list" })
end
# Token endpoint should be hit at most once across the failing call,
# proving the retry guard prevents an infinite re-authorization loop.
assert_requested(:post, "#{@auth_base}/token", times: 1)
end
def test_send_request_reauthorizes_when_the_authorization_server_did_not_issue_the_stored_tokens
# The refusal in `refresh!` is only half the answer; this is the other half.
# A renamed authorization server makes the refresh fail, the transport falls through to a full authorization,
# and that is where the embedding application is asked about the new one.
stub_request(:post, @mcp_url).with { |req|
req.headers["Authorization"] != "Bearer fresh-at"
}.to_return(
status: 401,
headers: { "WWW-Authenticate" => %(Bearer error="invalid_token", resource_metadata="#{@prm_url}") },
body: "",
)
stub_request(:post, @mcp_url).with(
headers: { "Authorization" => "Bearer fresh-at" }
).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(jsonrpc: "2.0", id: "1", result: { ok: true }),
)
stub_request(:get, @prm_url).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(resource: @mcp_url, authorization_servers: [@auth_base]),
)
stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
issuer: @auth_base,
authorization_endpoint: "#{@auth_base}/authorize",
token_endpoint: "#{@auth_base}/token",
registration_endpoint: "#{@auth_base}/register",
response_types_supported: ["code"],
grant_types_supported: ["authorization_code"],
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
),
)
stub_request(:post, "#{@auth_base}/register").to_return(
status: 201,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(client_id: "reauthorized-client"),
)
stub_request(:post, "#{@auth_base}/token").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(access_token: "fresh-at", token_type: "Bearer"),
)
seen = []
state_holder = {}
provider = Provider.new(
client_metadata: {
redirect_uris: ["http://localhost:0/callback"],
grant_types: ["authorization_code"],
response_types: ["code"],
token_endpoint_auth_method: "none",
},
redirect_uri: "http://localhost:0/callback",
redirect_handler: ->(url) { state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state") },
callback_handler: -> { ["test-auth-code", state_holder[:state]] },
authorization_request_validator: ->(request) {
seen << request.authorization_server
true
},
)
# Registered and refreshable, so the refresh path is genuinely reachable and the issuer binding is
# what stops it rather than a missing client identity.
provider.save_client_information("client_id" => "test-client")
provider.save_tokens(
"access_token" => "stale-at",
"refresh_token" => "saved-rt",
"issuer" => "https://issued-by.example.com",
)
HTTP.new(url: @mcp_url, oauth: provider).send_request(
request: { jsonrpc: "2.0", id: "1", method: "tools/call", params: {} },
)
# The refresh token was never presented to the authorization server now being named.
assert_not_requested(:post, "#{@auth_base}/token") do |req|
URI.decode_www_form(req.body).to_h["grant_type"] == "refresh_token"
end
assert_equal([@auth_base], seen)
assert_equal(@auth_base, provider.tokens["issuer"])
end
private
# Stubs PRM, AS metadata, /register, and /token so a full authorization
# flow runs. The /token stub echoes `requested_scope` so callers can
# assert the escalated scope was sent.
# `extra_as_metadata` lets a test advertise additional AS capabilities
# (e.g. `scopes_supported` for the offline_access interaction, or
# `client_id_metadata_document_supported` for the CIMD interaction)
# without duplicating the whole stub.
def stub_step_up_authorization_server(extra_as_metadata: {})
stub_request(:get, @prm_url).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
resource: "https://srv.example.com/mcp",
authorization_servers: [@auth_base],
),
)
stub_request(:get, "#{@auth_base}/.well-known/oauth-authorization-server").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate({
issuer: @auth_base,
authorization_endpoint: "#{@auth_base}/authorize",
token_endpoint: "#{@auth_base}/token",
registration_endpoint: "#{@auth_base}/register",
response_types_supported: ["code"],
grant_types_supported: ["authorization_code", "refresh_token"],
code_challenge_methods_supported: ["S256"],
token_endpoint_auth_methods_supported: ["none"],
}.merge(extra_as_metadata)),
)
stub_request(:post, "#{@auth_base}/register").to_return(
status: 201,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(client_id: "test-client"),
)
stub_request(:post, "#{@auth_base}/token").to_return do |request|
form = URI.decode_www_form(request.body).to_h
{
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
access_token: "escalated-token",
token_type: "Bearer",
expires_in: 3600,
requested_scope: form["scope"],
grant_type_received: form["grant_type"],
),
}
end
end
def build_step_up_provider(grant_types: ["authorization_code"], client_id_metadata_document_url: nil)
state_holder = {}
captured_authorization_url = nil
provider = Provider.new(
client_metadata: {
client_name: "ruby-sdk-test",
redirect_uris: ["http://localhost:0/callback"],
grant_types: grant_types,
response_types: ["code"],
token_endpoint_auth_method: "none",
},
redirect_uri: "http://localhost:0/callback",
redirect_handler: ->(url) {
captured_authorization_url = url
state_holder[:state] = URI.decode_www_form(url.query).to_h.fetch("state")
},
callback_handler: -> { ["test-auth-code", state_holder[:state]] },
client_id_metadata_document_url: client_id_metadata_document_url,
)
provider.save_tokens("access_token" => "initial-token", "token_type" => "Bearer")
# `captured_authorization_url` is a closure variable that the redirect handler writes at flow time;
# expose its current value via a method on the provider so tests can assert on what was sent to /authorize.
provider.define_singleton_method(:last_authorization_url) { captured_authorization_url }
provider
end
def build_provider(http_client_customizer: nil)
Provider.new(
client_metadata: { redirect_uris: ["http://localhost:0/callback"] },
redirect_uri: "http://localhost:0/callback",
redirect_handler: ->(_url) {},
callback_handler: -> { ["code", "state"] },
http_client_customizer: http_client_customizer,
)
end
end
end
end
end