Let the client_credentials and jwt-bearer grants run without Protected Resource Metadata

## Motivation and Context

When a server publishes no Protected Resource Metadata, discovery takes the legacy 2025-03-26 path and `prm` is `nil`.
The authorization-code branch of `run!` allowed for that by passing `prm || {}` to `resolve_scope`,
but `run_client_credentials!` and `run_jwt_bearer!` passed `prm` as is, and `resolve_scope` then
called `prm["scopes_supported"]` on `nil`. Without a non-empty challenge scope, a `ClientCredentialsProvider` or
`CrossAppAccessProvider` pointed at such a server therefore failed with `NoMethodError` after discovery and before
the token request or the ID-JAG callback, instead of running its grant against the legacy authorization base
the way `Provider` does.

`resolve_scope` now treats a missing PRM as advertising no scopes, so the challenge scope or the provider's own scope applies,
and the authorization-code branch passes `prm` unchanged like the other two.

## How Has This Been Tested?

New tests in `test/mcp/client/oauth/flow_test.rb` run the `client_credentials` grant against a PRM-less server with metadata
at the origin, checking the provider's scope, the `resource`, and the recorded issuer, and run the `jwt-bearer` grant against
a server without any metadata, checking the default token endpoint, the assertion audience, the `resource`, and the recorded
issuer. Both raise `NoMethodError` against the previous library.

## Breaking Changes

None.
This commit is contained in:
Koichi ITO 2026-09-17 19:05:51 +09:00
commit 29e8d3f2b9
2 changed files with 72 additions and 2 deletions

View file

@ -87,7 +87,7 @@ module MCP
ensure_pkce_supported!(as_metadata)
effective_scope = resolve_scope(scope: scope, prm: prm || {})
effective_scope = resolve_scope(scope: scope, prm: prm)
effective_scope = normalize_offline_access_scope(effective_scope, as_metadata: as_metadata)
# Asked before registering, not after: a refusal must not leave this client registered at an authorization server
@ -898,7 +898,8 @@ module MCP
def resolve_scope(scope:, prm:)
return scope if scope && !scope.empty?
supported = prm["scopes_supported"]
# `prm` is nil on the legacy path, where nothing advertises scopes.
supported = prm && prm["scopes_supported"]
return supported.join(" ") if supported.is_a?(Array) && !supported.empty?
return @provider.scope if @provider.scope && !@provider.scope.empty?

View file

@ -991,6 +991,75 @@ module MCP
end
end
def test_run_client_credentials_without_prm_uses_the_legacy_authorization_base
# A PRM-less server takes the legacy path for this grant too: metadata at the origin, no advertised scopes,
# so the provider's own scope is used and the tokens are bound to the origin.
stub_prm_not_found
stub_request(:get, "https://srv.example.com/.well-known/oauth-authorization-server").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(
issuer: "https://srv.example.com",
token_endpoint: "https://srv.example.com/token",
token_endpoint_auth_methods_supported: ["client_secret_basic"],
),
)
stub_request(:post, "https://srv.example.com/token").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(access_token: "legacy-token", token_type: "Bearer", expires_in: 3600),
)
provider = ClientCredentialsProvider.new(client_id: "cc-client", client_secret: "cc-secret", scope: "mcp:read")
result = Flow.new(provider: provider).run!(server_url: @server_url)
assert_equal(:authorized, result)
assert_equal("legacy-token", provider.access_token)
assert_equal("https://srv.example.com", provider.tokens["issuer"])
assert_requested(:post, "https://srv.example.com/token") do |req|
form = URI.decode_www_form(req.body).to_h
form["grant_type"] == "client_credentials" &&
form["scope"] == "mcp:read" &&
form["resource"] == "https://srv.example.com/mcp"
end
end
def test_run_jwt_bearer_without_any_metadata_uses_the_legacy_default_endpoints
stub_prm_not_found
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/token").to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: JSON.generate(access_token: "legacy-token", token_type: "Bearer", expires_in: 3600),
)
received = nil
provider = CrossAppAccessProvider.new(
client_id: "xaa-client",
client_secret: "xaa-secret",
assertion_provider: ->(audience:, resource:) {
received = { audience: audience, resource: resource }
"id-jag-assertion"
},
)
result = Flow.new(provider: provider).run!(server_url: @server_url)
assert_equal(:authorized, result)
# The assertion is minted for the legacy authorization base, which is the only identity such a server has.
assert_equal({ audience: "https://srv.example.com", resource: "https://srv.example.com/mcp" }, received)
assert_equal("https://srv.example.com", provider.tokens["issuer"])
assert_requested(:post, "https://srv.example.com/token") do |req|
form = URI.decode_www_form(req.body).to_h
form["grant_type"] == "urn:ietf:params:oauth:grant-type:jwt-bearer" &&
form["assertion"] == "id-jag-assertion" &&
form["resource"] == "https://srv.example.com/mcp" &&
!form.key?("scope")
end
end
def test_run_legacy_fallback_rejects_insecure_authorization_base
# The Communication Security requirement still applies on the legacy path: a remote plain-http origin must not
# become the authorization base URL.