## Motivation and Context `MCP::Client::OAuth::Flow` takes an `http_client_factory:`, but `MCP::Client::HTTP` builds the flow itself and passes none, so an application using `oauth:` could not add middleware to the requests the flow makes: Protected Resource Metadata and authorization server metadata discovery, dynamic client registration, and every token request. The block that customizes the transport's Faraday connection does not help, because that connection is bound to the MCP server URL and carries the `headers:` meant for that server, and the authorization server is a different origin. The TypeScript SDK hands the transport's `fetch` to `auth()` and the Python SDK sends the provider's requests through the transport's httpx client, so both let an application instrument these requests; this SDK did not. (#558) Providers now take `http_client_customizer:`, a callable invoked with the Faraday connection the flow builds for its own requests, next to the other callables a provider already holds. It runs after the SDK's defaults (`Accept: application/json`, no redirect following) and between two middlewares of the SDK's own: `Flow::RequestedURLStamp`, registered before the customizer is invoked, records the URL the flow asked for on the request environment, and `Flow::RequestedOriginGuard`, registered last, sees what the customizer's middleware did to the request after that record. A request that would leave that origin, by following a redirect or rewriting the URL, or that arrives without the record, is refused with `Flow::DestinationMismatchError` before it reaches the adapter. The record lives on the environment rather than in `env.request.context`, which stays the application's to fill or replace. The error is an `ArgumentError` rather than an `AuthorizationError` so neither discovery nor the transport's refresh fallback can absorb a configuration mistake. The boundary resembles the one the Python SDK keeps for its own requests, which follows a redirect only within the origin; this flow follows none itself. `Flow.build_http_client` assembles that connection and `Flow.new(http_client_factory:)` keeps replacing it whole. Following done inside an adapter cannot be seen by any guard, so the documentation keeps that, `Accept-Encoding`, and `raise_error` off the list of things a customizer may add, and notes that a recording middleware sees client credentials and tokens. Fixes #558. ## How Has This Been Tested? New tests in `test/mcp/client/oauth/flow_test.rb`, `test/mcp/client/oauth/http_oauth_test.rb`, and the three provider test files. Against the library before this change, every one of them fails, on the unknown keyword or on the missing reader. ## Breaking Changes None. The customizer is opt-in, and the guard cannot fire on the SDK's own middleware-free connection.
1884 lines
84 KiB
Ruby
1884 lines
84 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_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
|