fix(auth): pass WWW-Authenticate scopes to DCR registration request#705
fix(auth): pass WWW-Authenticate scopes to DCR registration request#705peschee wants to merge 4 commits intomodelcontextprotocol:mainfrom
Conversation
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
…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.
…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>
…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>
…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>
| #[serde(skip_serializing_if = "Option::is_none")] | ||
| pub scope: Option<String>, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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?
crates/rmcp/src/transport/auth.rs
Outdated
| }; | ||
|
|
||
| use async_trait::async_trait; | ||
| pub use oauth2::TokenResponse; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Removed the public re-export in 174456a. Downstream now imports oauth2::TokenResponse directly.
…[String] Avoids unnecessary Vec<String> allocation in callers that already have &[&str].
…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>
Summary
When an MCP server returns a
401withWWW-Authenticate: Bearer scope="read write", the scopes are correctly parsed and stored inAuthorizationManager.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
scopefield 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
scopefield toClientRegistrationRequestwith#[serde(skip_serializing_if = "Option::is_none")]for backward compatibility — servers that don't expectscopein DCR won't see itregister_client()to accept ascopesparameter and include it in the DCR request body and returnedOAuthClientConfigAuthorizationSession::new()into bothregister_client()call sitesoauth2::TokenResponsetrait so downstream consumers (e.g., Goose) can call.scopes()on token responses to extract granted scopesscopefield is present whenSomeand absent whenNoneTest plan
cargo test -p rmcp --features auth --lib -- client_registration_request— both new tests passWWW-Authenticateand verify the DCR request includes thescopefield (inspect viaRUST_LOG=debug)scopein DCR continue to work (field omitted when no scopes)