From e8da53ef49ac54aa7ecf996397dfb7338c758a97 Mon Sep 17 00:00:00 2001 From: Peter Siska <63866+peschee@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:45:38 +0100 Subject: [PATCH 1/4] fix(auth): pass WWW-Authenticate scopes to DCR registration request 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 --- crates/rmcp/src/transport/auth.rs | 56 ++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index b8d4f3f4..710a23ac 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -5,11 +5,12 @@ use std::{ }; use async_trait::async_trait; +pub use oauth2::TokenResponse; use oauth2::{ AsyncHttpClient, AuthType, AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, EmptyExtraTokenFields, HttpClientError, HttpRequest, HttpResponse, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, RefreshToken, RequestTokenError, Scope, StandardTokenResponse, - TokenResponse, TokenUrl, + TokenUrl, basic::{BasicClient, BasicTokenType}, }; use reqwest::{ @@ -406,6 +407,8 @@ pub struct ClientRegistrationRequest { pub grant_types: Vec, pub token_endpoint_auth_method: String, pub response_types: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub scope: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -645,6 +648,7 @@ impl AuthorizationManager { &mut self, name: &str, redirect_uri: &str, + scopes: &[String], ) -> Result { if self.metadata.is_none() { return Err(AuthError::NoAuthorizationSupport); @@ -667,6 +671,11 @@ impl AuthorizationManager { ], token_endpoint_auth_method: "none".to_string(), // public client response_types: vec!["code".to_string()], + scope: if scopes.is_empty() { + None + } else { + Some(scopes.join(" ")) + }, }; let response = match self @@ -720,7 +729,7 @@ impl AuthorizationManager { // as a password, which is not a goal of the client secret. client_secret: reg_response.client_secret.filter(|s| !s.is_empty()), redirect_uri: redirect_uri.to_string(), - scopes: vec![], + scopes: scopes.to_vec(), }; self.configure_client(config.clone())?; @@ -1463,6 +1472,7 @@ impl AuthorizationSession { client_name: Option<&str>, client_metadata_url: Option<&str>, ) -> Result { + let scopes_owned: Vec = scopes.iter().map(|s| s.to_string()).collect(); let metadata = auth_manager.metadata.as_ref(); let supports_url_based_client_id = metadata .and_then(|m| { @@ -1490,7 +1500,11 @@ impl AuthorizationSession { } else { // Fallback to dynamic registration auth_manager - .register_client(client_name.unwrap_or("MCP Client"), redirect_uri) + .register_client( + client_name.unwrap_or("MCP Client"), + redirect_uri, + &scopes_owned, + ) .await .map_err(|e| { AuthError::RegistrationFailed(format!("Dynamic registration failed: {}", e)) @@ -1499,7 +1513,11 @@ impl AuthorizationSession { } else { // Fallback to dynamic registration match auth_manager - .register_client(client_name.unwrap_or("MCP Client"), redirect_uri) + .register_client( + client_name.unwrap_or("MCP Client"), + redirect_uri, + &scopes_owned, + ) .await { Ok(config) => config, @@ -2792,4 +2810,34 @@ mod tests { "expected InternalError when OAuth client is not configured, got: {err:?}" ); } + + // -- ClientRegistrationRequest serialization -- + + #[test] + fn client_registration_request_includes_scope_when_present() { + let req = super::ClientRegistrationRequest { + client_name: "test".to_string(), + redirect_uris: vec!["http://localhost/callback".to_string()], + grant_types: vec!["authorization_code".to_string()], + token_endpoint_auth_method: "none".to_string(), + response_types: vec!["code".to_string()], + scope: Some("read write".to_string()), + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["scope"], "read write"); + } + + #[test] + fn client_registration_request_omits_scope_when_none() { + let req = super::ClientRegistrationRequest { + client_name: "test".to_string(), + redirect_uris: vec!["http://localhost/callback".to_string()], + grant_types: vec!["authorization_code".to_string()], + token_endpoint_auth_method: "none".to_string(), + response_types: vec!["code".to_string()], + scope: None, + }; + let json = serde_json::to_value(&req).unwrap(); + assert!(!json.as_object().unwrap().contains_key("scope")); + } } From 8f2252a99a7e52f0db0e165124e895ef9109fff1 Mon Sep 17 00:00:00 2001 From: Peter Siska <63866+peschee@users.noreply.github.com> Date: Fri, 27 Feb 2026 21:26:15 +0100 Subject: [PATCH 2/4] refactor(auth): change register_client to accept &[&str] instead of &[String] Avoids unnecessary Vec allocation in callers that already have &[&str]. --- crates/rmcp/src/transport/auth.rs | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 710a23ac..da778c61 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -648,7 +648,7 @@ impl AuthorizationManager { &mut self, name: &str, redirect_uri: &str, - scopes: &[String], + scopes: &[&str], ) -> Result { if self.metadata.is_none() { return Err(AuthError::NoAuthorizationSupport); @@ -729,7 +729,7 @@ impl AuthorizationManager { // as a password, which is not a goal of the client secret. client_secret: reg_response.client_secret.filter(|s| !s.is_empty()), redirect_uri: redirect_uri.to_string(), - scopes: scopes.to_vec(), + scopes: scopes.iter().map(|s| s.to_string()).collect(), }; self.configure_client(config.clone())?; @@ -1472,7 +1472,6 @@ impl AuthorizationSession { client_name: Option<&str>, client_metadata_url: Option<&str>, ) -> Result { - let scopes_owned: Vec = scopes.iter().map(|s| s.to_string()).collect(); let metadata = auth_manager.metadata.as_ref(); let supports_url_based_client_id = metadata .and_then(|m| { @@ -1500,11 +1499,7 @@ impl AuthorizationSession { } else { // Fallback to dynamic registration auth_manager - .register_client( - client_name.unwrap_or("MCP Client"), - redirect_uri, - &scopes_owned, - ) + .register_client(client_name.unwrap_or("MCP Client"), redirect_uri, scopes) .await .map_err(|e| { AuthError::RegistrationFailed(format!("Dynamic registration failed: {}", e)) @@ -1513,11 +1508,7 @@ impl AuthorizationSession { } else { // Fallback to dynamic registration match auth_manager - .register_client( - client_name.unwrap_or("MCP Client"), - redirect_uri, - &scopes_owned, - ) + .register_client(client_name.unwrap_or("MCP Client"), redirect_uri, scopes) .await { Ok(config) => config, From 3e08474913eb612d16cbb7d16da28400cd433d7a Mon Sep 17 00:00:00 2001 From: Peter Siska <63866+peschee@users.noreply.github.com> Date: Sat, 28 Feb 2026 07:32:18 +0100 Subject: [PATCH 3/4] fix(auth): make ClientRegistrationRequest crate-private --- crates/rmcp/src/transport/auth.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index da778c61..b8d85e53 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -401,7 +401,7 @@ pub struct AuthorizationManager { } #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ClientRegistrationRequest { +pub(crate) struct ClientRegistrationRequest { pub client_name: String, pub redirect_uris: Vec, pub grant_types: Vec, From 174456ac089a256398c9aae6198406dff00029c6 Mon Sep 17 00:00:00 2001 From: Peter Siska <63866+peschee@users.noreply.github.com> Date: Sat, 28 Feb 2026 07:45:16 +0100 Subject: [PATCH 4/4] refactor(auth): stop re-exporting oauth2 TokenResponse trait --- crates/rmcp/src/transport/auth.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index b8d85e53..83c6e1e1 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -5,7 +5,7 @@ use std::{ }; use async_trait::async_trait; -pub use oauth2::TokenResponse; +use oauth2::TokenResponse; use oauth2::{ AsyncHttpClient, AuthType, AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, EmptyExtraTokenFields, HttpClientError, HttpRequest, HttpResponse, PkceCodeChallenge,