Skip to content

fix(auth): pass WWW-Authenticate scopes to DCR registration request#705

Open
peschee wants to merge 4 commits intomodelcontextprotocol:mainfrom
peschee:fix-dcr-scopes
Open

fix(auth): pass WWW-Authenticate scopes to DCR registration request#705
peschee wants to merge 4 commits intomodelcontextprotocol:mainfrom
peschee:fix-dcr-scopes

Conversation

@peschee
Copy link

@peschee peschee commented Feb 27, 2026

Summary

When an MCP server returns a 401 with WWW-Authenticate: Bearer scope="read write", the scopes are correctly parsed and stored in AuthorizationManager.www_auth_scopes, and used for the authorization URL. However, they are never included in the Dynamic Client Registration (DCR) request.

Per RFC 7591, the DCR request should include a scope field so the authorization server knows what scopes the client intends to use. Servers that enforce scope-matching between registration and authorization reject the flow without this.

Changes

  • Add optional scope field to ClientRegistrationRequest with #[serde(skip_serializing_if = "Option::is_none")] for backward compatibility — servers that don't expect scope in DCR won't see it
  • Update register_client() to accept a scopes parameter and include it in the DCR request body and returned OAuthClientConfig
  • Thread scopes from AuthorizationSession::new() into both register_client() call sites
  • Re-export oauth2::TokenResponse trait so downstream consumers (e.g., Goose) can call .scopes() on token responses to extract granted scopes
  • Add serialization tests verifying the scope field is present when Some and absent when None

Test plan

  • cargo test -p rmcp --features auth --lib -- client_registration_request — both new tests pass
  • Connect to an MCP server that returns scopes in WWW-Authenticate and verify the DCR request includes the scope field (inspect via RUST_LOG=debug)
  • Verify backward compatibility: servers that don't expect scope in DCR continue to work (field omitted when no scopes)

When an MCP server returns a 401 with `WWW-Authenticate: Bearer scope="..."`,
the scopes are parsed but never included in the Dynamic Client Registration
(DCR) request. Per RFC 7591, the DCR request should include a `scope` field
so the authorization server knows what scopes the client intends to use.
Servers that enforce scope-matching between registration and authorization
will reject the flow without this.

Changes:
- Add optional `scope` field to `ClientRegistrationRequest` with
  `skip_serializing_if` for backward compatibility
- Update `register_client()` to accept scopes parameter and include
  them in the DCR request body and returned `OAuthClientConfig`
- Thread scopes from `AuthorizationSession::new()` into both
  `register_client()` call sites
- Re-export `oauth2::TokenResponse` trait so consumers can extract
  scopes from token responses
- Add serialization tests for the new `scope` field
@peschee peschee requested a review from a team as a code owner February 27, 2026 15:46
@github-actions github-actions bot added T-core Core library changes T-transport Transport layer changes labels Feb 27, 2026
peschee added a commit to peschee/goose that referenced this pull request Feb 27, 2026
…esponse

Two fixes for OAuth scope handling in MCP server connections:

1. Patch rmcp to include scopes from WWW-Authenticate in the DCR
   registration request (see modelcontextprotocol/rust-sdk#705)

2. Extract granted_scopes from the token response instead of always
   saving an empty vec, so stored credentials accurately reflect
   what the authorization server granted.
peschee added a commit to peschee/goose that referenced this pull request Feb 27, 2026
…esponse

Two fixes for OAuth scope handling in MCP server connections:

1. Patch rmcp to include scopes from WWW-Authenticate in the DCR
   registration request (see modelcontextprotocol/rust-sdk#705)

2. Extract granted_scopes from the token response instead of always
   saving an empty vec, so stored credentials accurately reflect
   what the authorization server granted.

Signed-off-by: Peter Siska <63866+peschee@users.noreply.github.com>
peschee added a commit to peschee/goose that referenced this pull request Feb 27, 2026
…esponse

Two fixes for OAuth scope handling in MCP server connections:

1. Patch rmcp to include scopes from WWW-Authenticate in the DCR
   registration request (see modelcontextprotocol/rust-sdk#705)

2. Extract granted_scopes from the token response instead of always
   saving an empty vec, so stored credentials accurately reflect
   what the authorization server granted.

Signed-off-by: Peter Siska <63866+peschee@users.noreply.github.com>
peschee added a commit to peschee/goose that referenced this pull request Feb 27, 2026
…esponse

Two fixes for OAuth scope handling in MCP server connections:

1. Patch rmcp to include scopes from WWW-Authenticate in the DCR
   registration request (see modelcontextprotocol/rust-sdk#705)

2. Extract granted_scopes from the token response instead of always
   saving an empty vec, so stored credentials accurately reflect
   what the authorization server granted.

Signed-off-by: Peter Siska <63866+peschee@users.noreply.github.com>
Copy link
Member

@DaleSeo DaleSeo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for your contribution, @peschee! I have some suggestions.

Comment on lines +410 to +411
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ClientRegistrationRequest is a public struct that doesn't have #[non_exhaustive]. Adding the new scope field means that any downstream code constructing this struct directly will fail to compile. If this type is part of the crate's public API, consider adding #[non_exhaustive] to the struct. This would be a breaking change, but it’s a good way to future-proof it.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point! However, adding #[non_exhaustive] is itself a breaking change (it prevents downstream from constructing the struct with literal syntax), so it should probably be done as a separate PR that also considers the other public structs like ClientRegistrationResponse. Happy to open a follow-up for that if you'd like — or would you prefer to include it in this PR?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I took another look and noticed this struct isn't actually re-exported from the crate. Would it make sense to just scope it to pub(crate) here?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated to pub(crate) in 3e08474.

};

use async_trait::async_trait;
pub use oauth2::TokenResponse;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this re-export is intentional?

Re-exporting oauth2::TokenResponse ties this crate's public API to the oauth2 crate's specific version. If oauth2 is upgraded to a new major version, downstream consumers who depend on this re-export will also break.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This re-export is pre-existing and was not introduced by this PR — it's been there since the auth module was added. I agree it's worth reviewing, but I'd suggest addressing it separately to keep this PR focused on the DCR scopes fix. Would you like me to open a separate issue for it?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This re-export is pre-existing and was not introduced by this PR — it's been there since the auth module was added.

Just to clarify, it's been only imported privately and this PR is the first to make it a pub use re-export.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the public re-export in 174456a. Downstream now imports oauth2::TokenResponse directly.

peschee added a commit to peschee/goose that referenced this pull request Feb 28, 2026
…esponse

Two fixes for OAuth scope handling in MCP server connections:

1. Patch rmcp to include scopes from WWW-Authenticate in the DCR
   registration request (see modelcontextprotocol/rust-sdk#705)

2. Extract granted_scopes from the token response instead of always
   saving an empty vec, so stored credentials accurately reflect
   what the authorization server granted.

Signed-off-by: Peter Siska <63866+peschee@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

T-core Core library changes T-transport Transport layer changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants