# 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