From 91930d35b3e0673d805eba1fcf3b128f73a54083 Mon Sep 17 00:00:00 2001 From: Alex Zamoshchin Date: Tue, 13 Oct 2026 21:42:47 +0000 Subject: [PATCH 1/6] feat(auth): add initial authorization scope selection --- crates/rmcp/src/transport/auth.rs | 422 +++++++++++++++++++++++++++++- 1 file changed, 420 insertions(+), 2 deletions(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index ab11142ae..046d50947 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -1140,6 +1140,10 @@ pub struct AuthorizationManager { scope_upgrade_config: ScopeUpgradeConfig, /// scopes from the initial 401 WWW-Authenticate header, used by select_scopes() www_auth_scopes: RwLock>, + /// Initial resource challenge only; None means the resource has not been observed yet. + initial_challenge_scopes: RwLock>>, + /// Protected resource scopes from the current discovery, including an empty result. + initial_resource_scopes: RwLock>, /// scopes_supported from protected resource metadata (RFC 9728) resource_scopes: RwLock>, /// resource indicator from protected resource metadata, used for RFC 8707 `resource` @@ -1386,6 +1390,8 @@ impl AuthorizationManager { scope_upgrade_attempts: RwLock::new(0), scope_upgrade_config: ScopeUpgradeConfig::default(), www_auth_scopes: RwLock::new(Vec::new()), + initial_challenge_scopes: RwLock::new(None), + initial_resource_scopes: RwLock::new(Vec::new()), resource_scopes: RwLock::new(Vec::new()), discovered_resource: RwLock::new(None), application_type: Some(DEFAULT_APPLICATION_TYPE.to_string()), @@ -1541,6 +1547,12 @@ impl AuthorizationManager { /// # } /// ``` pub async fn resolve_metadata(&self) -> Result { + *self.initial_challenge_scopes.write().await = None; + self.initial_resource_scopes.write().await.clear(); + self.resolve_metadata_inner().await + } + + async fn resolve_metadata_inner(&self) -> Result { if let Some(metadata) = self.discover_oauth_server_via_resource_metadata().await? { return Ok(AuthorizationMetadataResolution { metadata, @@ -1579,6 +1591,9 @@ impl AuthorizationManager { }; let params = WWWAuthenticateParams::parse(www_authenticate, &self.base_url); + *self.initial_challenge_scopes.write().await = None; + self.initial_resource_scopes.write().await.clear(); + self.record_initial_challenge_scope(Some(¶ms)).await; self.record_challenge_scope(¶ms).await; if let Some(resource_metadata_url) = ¶ms.resource_metadata_url @@ -1592,7 +1607,19 @@ impl AuthorizationManager { }); } - self.resolve_metadata().await + self.resolve_metadata_inner().await + } + + async fn record_initial_challenge_scope(&self, params: Option<&WWWAuthenticateParams>) { + let mut initial_scopes = self.initial_challenge_scopes.write().await; + if initial_scopes.is_none() { + *initial_scopes = Some( + params + .and_then(|params| params.scope.as_deref()) + .map(|scope| scope.split_whitespace().map(str::to_owned).collect()) + .unwrap_or_default(), + ); + } } /// Store a challenge's `scope` hint for later scope selection. @@ -1948,7 +1975,58 @@ impl AuthorizationManager { attempts < self.scope_upgrade_config.max_upgrade_attempts } - /// select scopes to request from authorization server + /// Select scopes for initial authorization after resolving and setting metadata. + /// + /// Prefer the initial resource challenge, then protected resource metadata. If + /// neither supplies scopes, retain the authorization-server metadata and caller + /// defaults as compatibility fallbacks. Append `offline_access` when advertised + /// by the authorization server, as with [`Self::select_scopes`]. + /// + /// Unlike [`Self::select_scopes`], this does not accumulate previously requested + /// scopes or challenges from metadata endpoints. Callers with explicit scopes + /// should continue to pass those scopes directly to authorization instead. + /// + /// ```no_run + /// # use rmcp::transport::AuthorizationManager; + /// # async fn initial_scopes() -> Result<(), Box> { + /// let mut manager = AuthorizationManager::new("https://mcp.example.com/mcp").await?; + /// let resolution = manager.resolve_metadata().await?; + /// manager.set_metadata(resolution.metadata); + /// let scopes = manager.select_initial_scopes(&[]).await; + /// # Ok(()) + /// # } + /// ``` + pub async fn select_initial_scopes(&self, default_scopes: &[&str]) -> Vec { + let mut scopes = self + .initial_challenge_scopes + .read() + .await + .clone() + .unwrap_or_default(); + if scopes.is_empty() { + scopes = self.initial_resource_scopes.read().await.clone(); + } + if scopes.is_empty() { + scopes = self + .metadata + .as_ref() + .and_then(|metadata| metadata.scopes_supported.clone()) + .filter(|scopes| !scopes.is_empty()) + .unwrap_or_else(|| { + default_scopes + .iter() + .map(|scope| (*scope).to_owned()) + .collect() + }); + } + let mut scopes = Self::dedup_scopes(scopes); + self.add_offline_access_if_supported(&mut scopes); + scopes + } + + /// Select cumulative scopes to request from the authorization server. + /// + /// For initial-login precedence instead of accumulation, use [`Self::select_initial_scopes`]. pub fn select_scopes( &self, www_authenticate_scope: Option<&str>, @@ -2615,6 +2693,10 @@ impl AuthorizationManager { .await .replace(resource_metadata.resource.clone().unwrap_or_default()); + *self.initial_resource_scopes.write().await = resource_metadata + .scopes_supported + .clone() + .unwrap_or_default(); // store scopes_supported from protected resource metadata for select_scopes() if let Some(scopes) = resource_metadata.scopes_supported && !scopes.is_empty() @@ -2847,6 +2929,26 @@ impl AuthorizationManager { .await .map_err(|error| Self::discovery_failed(&self.base_url, error))?; + let initial_challenge = if response.status() == StatusCode::UNAUTHORIZED { + let challenges = || { + response + .headers() + .get_all(WWW_AUTHENTICATE) + .iter() + .filter_map(|value| value.to_str().ok()) + .map(|value| WWWAuthenticateParams::parse(value, &self.base_url)) + }; + // Keep scope selection tied to the header used for discovery. A scope-only + // challenge still applies when metadata is discovered at a well-known URL. + challenges() + .find(|params| params.resource_metadata_url.is_some()) + .or_else(|| challenges().find(|params| params.scope.is_some())) + } else { + None + }; + self.record_initial_challenge_scope(initial_challenge.as_ref()) + .await; + if response.status() == StatusCode::UNAUTHORIZED { return Ok(self .extract_resource_metadata_url_from_www_authenticate(&response) @@ -7820,6 +7922,322 @@ mod tests { assert_eq!(result.len(), 2); } + fn initial_scope_metadata_responses() -> Vec { + vec![ + http_response( + 200, + serde_json::json!({ + "resource": "https://mcp.example.com/mcp", + "authorization_servers": ["https://auth.example.com"], + "scopes_supported": ["read", "write"] + }), + ), + http_response( + 200, + serde_json::json!({ + "issuer": "https://auth.example.com", + "authorization_endpoint": "https://auth.example.com/authorize", + "token_endpoint": "https://auth.example.com/token", + "scopes_supported": ["admin"] + }), + ), + ] + } + + #[rstest] + #[case::advertised_metadata(true, false)] + #[case::redirected_resource(true, true)] + #[case::scope_only(false, false)] + #[tokio::test] + async fn select_initial_scopes_prefers_resource_challenge( + #[case] advertised_metadata: bool, + #[case] redirected: bool, + ) { + let mut responses = Vec::new(); + if redirected { + responses.push(redirect_response("/redirected-mcp")); + } + let challenge = if advertised_metadata { + r#"Bearer resource_metadata="/metadata", scope="read read""# + } else { + r#"Bearer scope="read read""# + }; + responses.push( + oauth2::http::Response::builder() + .status(401) + .header("www-authenticate", challenge) + .body(Vec::new()) + .unwrap(), + ); + responses.extend(initial_scope_metadata_responses()); + let mut manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(RecordingOAuthHttpClient::with_responses(responses)), + ) + .await + .unwrap(); + + let resolution = manager.resolve_metadata().await.unwrap(); + manager.set_metadata(resolution.metadata); + + let scopes = manager.select_initial_scopes(&["default"]).await; + assert_eq!(scopes, ["read"]); + let session = AuthorizationSession::new( + manager, + AuthorizationRequest::new("http://localhost:8080/callback") + .with_preregistered_client("test-client") + .with_scopes(scopes), + ) + .await + .map_err(|(_, error)| error) + .unwrap(); + let authorization_url = Url::parse(session.get_authorization_url()).unwrap(); + assert_eq!( + authorization_url + .query_pairs() + .find(|(key, _)| key == "scope"), + Some(("scope".into(), "read".into())) + ); + } + + #[rstest] + #[case::initial_scope(Some("read"), vec!["read"])] + #[case::no_initial_scope(None, vec!["read", "write"])] + #[tokio::test] + async fn select_initial_scopes_ignores_later_metadata_challenges( + #[case] initial_scope: Option<&str>, + #[case] expected: Vec<&str>, + ) { + let initial_response = match initial_scope { + Some(scope) => oauth2::http::Response::builder() + .status(401) + .header("www-authenticate", format!(r#"Bearer scope="{scope}""#)) + .body(Vec::new()) + .unwrap(), + None => empty_response(404), + }; + let mut responses = vec![ + initial_response, + oauth2::http::Response::builder() + .status(401) + .header( + "www-authenticate", + r#"Bearer resource_metadata="/metadata", scope="metadata:read""#, + ) + .body(Vec::new()) + .unwrap(), + ]; + responses.extend(initial_scope_metadata_responses()); + let mut manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(RecordingOAuthHttpClient::with_responses(responses)), + ) + .await + .unwrap(); + + let resolution = manager.resolve_metadata().await.unwrap(); + manager.set_metadata(resolution.metadata); + + assert_eq!(manager.select_initial_scopes(&[]).await, expected); + assert!( + manager + .select_scopes(None, &[]) + .contains(&"metadata:read".to_string()) + ); + } + + #[tokio::test] + async fn select_initial_scopes_preserves_reactive_scope_during_fallback() { + let mut responses = vec![ + oauth2::http::Response::builder() + .status(401) + .header( + "www-authenticate", + r#"Bearer resource_metadata="/metadata", scope="probe:read""#, + ) + .body(Vec::new()) + .unwrap(), + ]; + responses.extend(initial_scope_metadata_responses()); + let mut manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(RecordingOAuthHttpClient::with_responses(responses)), + ) + .await + .unwrap(); + + let resolution = manager + .resolve_metadata_from_challenge(Some(r#"Bearer scope="read""#)) + .await + .unwrap(); + manager.set_metadata(resolution.metadata); + + assert_eq!(manager.select_initial_scopes(&[]).await, ["read"]); + } + + #[tokio::test] + async fn select_initial_scopes_resets_challenge_on_repeated_discovery() { + let mut responses = Vec::new(); + for challenge in [ + r#"Bearer resource_metadata="/metadata", scope="read""#, + r#"Bearer resource_metadata="/metadata""#, + ] { + responses.push( + oauth2::http::Response::builder() + .status(401) + .header("www-authenticate", challenge) + .body(Vec::new()) + .unwrap(), + ); + responses.extend(initial_scope_metadata_responses()); + } + let mut manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(RecordingOAuthHttpClient::with_responses(responses)), + ) + .await + .unwrap(); + + let resolution = manager.resolve_metadata().await.unwrap(); + manager.set_metadata(resolution.metadata); + assert_eq!(manager.select_initial_scopes(&[]).await, ["read"]); + + let resolution = manager.resolve_metadata().await.unwrap(); + manager.set_metadata(resolution.metadata); + assert_eq!(manager.select_initial_scopes(&[]).await, ["read", "write"]); + } + + #[rstest] + #[case::challenge(&["read", "read"], &["write"], &["admin"], &["default"], &["read"])] + #[case::resource(&[], &["read", "read"], &["admin"], &["default"], &["read"])] + #[case::authorization_server(&[], &[], &["admin", "admin"], &["default"], &["admin"])] + #[case::defaults(&[], &[], &[], &["default", "default"], &["default"])] + #[case::empty(&[], &[], &[], &[], &[])] + #[case::offline_access(&["read"], &["write"], &["offline_access"], &[], &["read", "offline_access"])] + #[case::offline_access_deduplicated(&["read", "offline_access"], &[], &["offline_access"], &[], &["read", "offline_access"])] + #[tokio::test] + async fn select_initial_scopes_uses_fallbacks_and_preserves_refresh_support( + #[case] challenge: &[&str], + #[case] resource: &[&str], + #[case] authorization_server: &[&str], + #[case] defaults: &[&str], + #[case] expected: &[&str], + ) { + let strings = |scopes: &[&str]| scopes.iter().map(|scope| (*scope).to_string()).collect(); + let manager = manager_with_metadata(Some(AuthorizationMetadata { + scopes_supported: Some(strings(authorization_server)), + ..Default::default() + })) + .await; + *manager.initial_challenge_scopes.write().await = Some(strings(challenge)); + *manager.initial_resource_scopes.write().await = strings(resource); + *manager.resource_scopes.write().await = strings(resource); + *manager.current_scopes.write().await = vec!["previous".to_string()]; + + assert_eq!(manager.select_initial_scopes(defaults).await, expected); + assert!( + manager + .select_scopes(Some("upgrade"), defaults) + .contains(&"previous".to_string()) + ); + assert!( + manager + .select_scopes(Some("upgrade"), defaults) + .contains(&"upgrade".to_string()) + ); + } + + #[rstest] + #[case::missing(false)] + #[case::empty(true)] + #[tokio::test] + async fn select_initial_scopes_forgets_old_resource_scopes( + #[case] explicit_empty_scopes: bool, + ) { + let mut responses = Vec::new(); + for second_discovery in [false, true] { + responses.push( + oauth2::http::Response::builder() + .status(401) + .header( + "www-authenticate", + r#"Bearer resource_metadata="/metadata""#, + ) + .body(Vec::new()) + .unwrap(), + ); + let mut metadata_responses = initial_scope_metadata_responses(); + if second_discovery { + let mut metadata = serde_json::json!({ + "resource": "https://mcp.example.com/mcp", + "authorization_servers": ["https://auth.example.com"] + }); + if explicit_empty_scopes { + metadata["scopes_supported"] = serde_json::json!([]); + } + metadata_responses[0] = http_response(200, metadata); + } + responses.extend(metadata_responses); + } + let mut manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(RecordingOAuthHttpClient::with_responses(responses)), + ) + .await + .unwrap(); + + let resolution = manager.resolve_metadata().await.unwrap(); + manager.set_metadata(resolution.metadata); + assert_eq!(manager.select_initial_scopes(&[]).await, ["read", "write"]); + + let resolution = manager.resolve_metadata().await.unwrap(); + manager.set_metadata(resolution.metadata); + assert_eq!(manager.select_initial_scopes(&[]).await, ["admin"]); + assert_eq!(manager.select_scopes(None, &[]), ["read", "write"]); + } + + #[rstest] + #[case::with_scope( + r#"Bearer resource_metadata="/metadata", scope="read""#, + vec!["read"] + )] + #[case::without_scope( + r#"Bearer resource_metadata="/metadata""#, + vec!["read", "write"] + )] + #[tokio::test] + async fn select_initial_scopes_matches_discovery_challenge_header( + #[case] selected_challenge: &str, + #[case] expected: Vec<&str>, + ) { + let mut responses = vec![ + oauth2::http::Response::builder() + .status(401) + .header("www-authenticate", r#"Bearer scope="unrelated""#) + .header("www-authenticate", selected_challenge) + .header( + "www-authenticate", + r#"Bearer resource_metadata="/other-metadata", scope="admin""#, + ) + .body(Vec::new()) + .unwrap(), + ]; + responses.extend(initial_scope_metadata_responses()); + let client = RecordingOAuthHttpClient::with_responses(responses); + let mut manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let resolution = manager.resolve_metadata().await.unwrap(); + manager.set_metadata(resolution.metadata); + + assert_eq!(manager.select_initial_scopes(&[]).await, expected); + assert_eq!(client.requests()[1].uri, "https://mcp.example.com/metadata"); + } + // -- SEP-2207: offline_access -- #[tokio::test] From 425417142688c94804472153aba9e892b1011a86 Mon Sep 17 00:00:00 2001 From: Alex Zamoshchin Date: Tue, 13 Oct 2026 21:55:22 +0000 Subject: [PATCH 2/6] refactor(auth): share initial scope discovery helpers --- crates/rmcp/src/transport/auth.rs | 238 ++++++++++-------------------- 1 file changed, 74 insertions(+), 164 deletions(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 046d50947..3c8cbe690 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -1616,7 +1616,7 @@ impl AuthorizationManager { *initial_scopes = Some( params .and_then(|params| params.scope.as_deref()) - .map(|scope| scope.split_whitespace().map(str::to_owned).collect()) + .map(|scope| scope.split_whitespace().map(str::to_string).collect()) .unwrap_or_default(), ); } @@ -1986,6 +1986,8 @@ impl AuthorizationManager { /// scopes or challenges from metadata endpoints. Callers with explicit scopes /// should continue to pass those scopes directly to authorization instead. /// + /// # Examples + /// /// ```no_run /// # use rmcp::transport::AuthorizationManager; /// # async fn initial_scopes() -> Result<(), Box> { @@ -2007,17 +2009,7 @@ impl AuthorizationManager { scopes = self.initial_resource_scopes.read().await.clone(); } if scopes.is_empty() { - scopes = self - .metadata - .as_ref() - .and_then(|metadata| metadata.scopes_supported.clone()) - .filter(|scopes| !scopes.is_empty()) - .unwrap_or_else(|| { - default_scopes - .iter() - .map(|scope| (*scope).to_owned()) - .collect() - }); + scopes = self.select_fallback_scopes(default_scopes); } let mut scopes = Self::dedup_scopes(scopes); self.add_offline_access_if_supported(&mut scopes); @@ -2072,7 +2064,10 @@ impl AuthorizationManager { return Self::dedup_scopes(accumulated); } - // nothing requested or challenged yet: seed from AS metadata, then caller defaults + self.select_fallback_scopes(default_scopes) + } + + fn select_fallback_scopes(&self, default_scopes: &[&str]) -> Vec { if let Some(metadata) = &self.metadata && let Some(scopes_supported) = &metadata.scopes_supported && !scopes_supported.is_empty() @@ -2888,8 +2883,9 @@ impl AuthorizationManager { } StatusCode::UNAUTHORIZED => { let Some(pointer_url) = self - .extract_resource_metadata_url_from_www_authenticate(&response) + .extract_www_authenticate_challenge(&response) .await + .and_then(|params| params.resource_metadata_url) else { continue; }; @@ -2929,32 +2925,14 @@ impl AuthorizationManager { .await .map_err(|error| Self::discovery_failed(&self.base_url, error))?; - let initial_challenge = if response.status() == StatusCode::UNAUTHORIZED { - let challenges = || { - response - .headers() - .get_all(WWW_AUTHENTICATE) - .iter() - .filter_map(|value| value.to_str().ok()) - .map(|value| WWWAuthenticateParams::parse(value, &self.base_url)) - }; - // Keep scope selection tied to the header used for discovery. A scope-only - // challenge still applies when metadata is discovered at a well-known URL. - challenges() - .find(|params| params.resource_metadata_url.is_some()) - .or_else(|| challenges().find(|params| params.scope.is_some())) - } else { - None - }; - self.record_initial_challenge_scope(initial_challenge.as_ref()) - .await; - if response.status() == StatusCode::UNAUTHORIZED { - return Ok(self - .extract_resource_metadata_url_from_www_authenticate(&response) - .await); + let challenge = self.extract_www_authenticate_challenge(&response).await; + self.record_initial_challenge_scope(challenge.as_ref()) + .await; + return Ok(challenge.and_then(|params| params.resource_metadata_url)); } + self.record_initial_challenge_scope(None).await; debug!( "resource endpoint probe returned {}, no WWW-Authenticate pointer to follow", response.status() @@ -2962,11 +2940,11 @@ impl AuthorizationManager { Ok(None) } - async fn extract_resource_metadata_url_from_www_authenticate( + async fn extract_www_authenticate_challenge( &self, response: &HttpResponse, - ) -> Option { - let mut parsed_url = None; + ) -> Option { + let mut scope_only_challenge = None; for value in response.headers().get_all(WWW_AUTHENTICATE).iter() { let Ok(value_str) = value.to_str() else { continue; @@ -2974,12 +2952,16 @@ impl AuthorizationManager { let params = Self::extract_www_authenticate_params(value_str, &self.base_url); if params.resource_metadata_url.is_some() { self.record_challenge_scope(¶ms).await; - parsed_url = params.resource_metadata_url; - break; + return Some(params); + } + // Scope-only challenges guide initial selection, but do not seed the + // cumulative selector or override the header used for discovery. + if scope_only_challenge.is_none() && params.scope.is_some() { + scope_only_challenge = Some(params); } } - parsed_url + scope_only_challenge } async fn fetch_resource_metadata_from_url( @@ -4333,6 +4315,14 @@ mod tests { .unwrap() } + fn challenge_response(challenge: &str) -> HttpResponse { + oauth2::http::Response::builder() + .status(401) + .header("www-authenticate", challenge) + .body(Vec::new()) + .unwrap() + } + fn redirect_response(location: &str) -> HttpResponse { oauth2::http::Response::builder() .status(302) @@ -7922,16 +7912,16 @@ mod tests { assert_eq!(result.len(), 2); } - fn initial_scope_metadata_responses() -> Vec { + fn initial_scope_metadata_responses(resource_scopes: Option<&[&str]>) -> Vec { + let mut resource_metadata = serde_json::json!({ + "resource": "https://mcp.example.com/mcp", + "authorization_servers": ["https://auth.example.com"] + }); + if let Some(scopes) = resource_scopes { + resource_metadata["scopes_supported"] = serde_json::json!(scopes); + } vec![ - http_response( - 200, - serde_json::json!({ - "resource": "https://mcp.example.com/mcp", - "authorization_servers": ["https://auth.example.com"], - "scopes_supported": ["read", "write"] - }), - ), + http_response(200, resource_metadata), http_response( 200, serde_json::json!({ @@ -7962,14 +7952,8 @@ mod tests { } else { r#"Bearer scope="read read""# }; - responses.push( - oauth2::http::Response::builder() - .status(401) - .header("www-authenticate", challenge) - .body(Vec::new()) - .unwrap(), - ); - responses.extend(initial_scope_metadata_responses()); + responses.push(challenge_response(challenge)); + responses.extend(initial_scope_metadata_responses(Some(&["read", "write"]))); let mut manager = AuthorizationManager::new_with_oauth_http_client( "https://mcp.example.com/mcp", Arc::new(RecordingOAuthHttpClient::with_responses(responses)), @@ -8009,25 +7993,14 @@ mod tests { #[case] expected: Vec<&str>, ) { let initial_response = match initial_scope { - Some(scope) => oauth2::http::Response::builder() - .status(401) - .header("www-authenticate", format!(r#"Bearer scope="{scope}""#)) - .body(Vec::new()) - .unwrap(), + Some(scope) => challenge_response(&format!(r#"Bearer scope="{scope}""#)), None => empty_response(404), }; let mut responses = vec![ initial_response, - oauth2::http::Response::builder() - .status(401) - .header( - "www-authenticate", - r#"Bearer resource_metadata="/metadata", scope="metadata:read""#, - ) - .body(Vec::new()) - .unwrap(), + challenge_response(r#"Bearer resource_metadata="/metadata", scope="metadata:read""#), ]; - responses.extend(initial_scope_metadata_responses()); + responses.extend(initial_scope_metadata_responses(Some(&["read", "write"]))); let mut manager = AuthorizationManager::new_with_oauth_http_client( "https://mcp.example.com/mcp", Arc::new(RecordingOAuthHttpClient::with_responses(responses)), @@ -8048,17 +8021,10 @@ mod tests { #[tokio::test] async fn select_initial_scopes_preserves_reactive_scope_during_fallback() { - let mut responses = vec![ - oauth2::http::Response::builder() - .status(401) - .header( - "www-authenticate", - r#"Bearer resource_metadata="/metadata", scope="probe:read""#, - ) - .body(Vec::new()) - .unwrap(), - ]; - responses.extend(initial_scope_metadata_responses()); + let mut responses = vec![challenge_response( + r#"Bearer resource_metadata="/metadata", scope="probe:read""#, + )]; + responses.extend(initial_scope_metadata_responses(Some(&["read", "write"]))); let mut manager = AuthorizationManager::new_with_oauth_http_client( "https://mcp.example.com/mcp", Arc::new(RecordingOAuthHttpClient::with_responses(responses)), @@ -8075,38 +8041,6 @@ mod tests { assert_eq!(manager.select_initial_scopes(&[]).await, ["read"]); } - #[tokio::test] - async fn select_initial_scopes_resets_challenge_on_repeated_discovery() { - let mut responses = Vec::new(); - for challenge in [ - r#"Bearer resource_metadata="/metadata", scope="read""#, - r#"Bearer resource_metadata="/metadata""#, - ] { - responses.push( - oauth2::http::Response::builder() - .status(401) - .header("www-authenticate", challenge) - .body(Vec::new()) - .unwrap(), - ); - responses.extend(initial_scope_metadata_responses()); - } - let mut manager = AuthorizationManager::new_with_oauth_http_client( - "https://mcp.example.com/mcp", - Arc::new(RecordingOAuthHttpClient::with_responses(responses)), - ) - .await - .unwrap(); - - let resolution = manager.resolve_metadata().await.unwrap(); - manager.set_metadata(resolution.metadata); - assert_eq!(manager.select_initial_scopes(&[]).await, ["read"]); - - let resolution = manager.resolve_metadata().await.unwrap(); - manager.set_metadata(resolution.metadata); - assert_eq!(manager.select_initial_scopes(&[]).await, ["read", "write"]); - } - #[rstest] #[case::challenge(&["read", "read"], &["write"], &["admin"], &["default"], &["read"])] #[case::resource(&[], &["read", "read"], &["admin"], &["default"], &["read"])] @@ -8131,54 +8065,32 @@ mod tests { .await; *manager.initial_challenge_scopes.write().await = Some(strings(challenge)); *manager.initial_resource_scopes.write().await = strings(resource); - *manager.resource_scopes.write().await = strings(resource); *manager.current_scopes.write().await = vec!["previous".to_string()]; assert_eq!(manager.select_initial_scopes(defaults).await, expected); - assert!( - manager - .select_scopes(Some("upgrade"), defaults) - .contains(&"previous".to_string()) - ); - assert!( - manager - .select_scopes(Some("upgrade"), defaults) - .contains(&"upgrade".to_string()) - ); } #[rstest] - #[case::missing(false)] - #[case::empty(true)] + #[case::challenge(Some("read"), Some(&["read", "write"][..]), &["read"], &["read", "write"])] + #[case::missing_resource_scopes(None, None, &["read", "write"], &["admin"])] + #[case::empty_resource_scopes(None, Some(&[][..]), &["read", "write"], &["admin"])] #[tokio::test] - async fn select_initial_scopes_forgets_old_resource_scopes( - #[case] explicit_empty_scopes: bool, + async fn select_initial_scopes_resets_on_repeated_discovery( + #[case] initial_scope: Option<&str>, + #[case] resource_scopes: Option<&[&str]>, + #[case] first_expected: &[&str], + #[case] second_expected: &[&str], ) { - let mut responses = Vec::new(); - for second_discovery in [false, true] { - responses.push( - oauth2::http::Response::builder() - .status(401) - .header( - "www-authenticate", - r#"Bearer resource_metadata="/metadata""#, - ) - .body(Vec::new()) - .unwrap(), - ); - let mut metadata_responses = initial_scope_metadata_responses(); - if second_discovery { - let mut metadata = serde_json::json!({ - "resource": "https://mcp.example.com/mcp", - "authorization_servers": ["https://auth.example.com"] - }); - if explicit_empty_scopes { - metadata["scopes_supported"] = serde_json::json!([]); - } - metadata_responses[0] = http_response(200, metadata); - } - responses.extend(metadata_responses); + let mut challenge = r#"Bearer resource_metadata="/metadata""#.to_string(); + if let Some(scope) = initial_scope { + challenge.push_str(&format!(r#", scope="{scope}""#)); } + let mut responses = vec![challenge_response(&challenge)]; + responses.extend(initial_scope_metadata_responses(Some(&["read", "write"]))); + responses.push(challenge_response( + r#"Bearer resource_metadata="/metadata""#, + )); + responses.extend(initial_scope_metadata_responses(resource_scopes)); let mut manager = AuthorizationManager::new_with_oauth_http_client( "https://mcp.example.com/mcp", Arc::new(RecordingOAuthHttpClient::with_responses(responses)), @@ -8186,13 +8098,11 @@ mod tests { .await .unwrap(); - let resolution = manager.resolve_metadata().await.unwrap(); - manager.set_metadata(resolution.metadata); - assert_eq!(manager.select_initial_scopes(&[]).await, ["read", "write"]); - - let resolution = manager.resolve_metadata().await.unwrap(); - manager.set_metadata(resolution.metadata); - assert_eq!(manager.select_initial_scopes(&[]).await, ["admin"]); + for expected in [first_expected, second_expected] { + let resolution = manager.resolve_metadata().await.unwrap(); + manager.set_metadata(resolution.metadata); + assert_eq!(manager.select_initial_scopes(&[]).await, expected); + } assert_eq!(manager.select_scopes(None, &[]), ["read", "write"]); } @@ -8222,7 +8132,7 @@ mod tests { .body(Vec::new()) .unwrap(), ]; - responses.extend(initial_scope_metadata_responses()); + responses.extend(initial_scope_metadata_responses(Some(&["read", "write"]))); let client = RecordingOAuthHttpClient::with_responses(responses); let mut manager = AuthorizationManager::new_with_oauth_http_client( "https://mcp.example.com/mcp", From b9b9fea2ab5aacf33851ffa280c6466f094d2493 Mon Sep 17 00:00:00 2001 From: Alex Zamoshchin Date: Tue, 13 Oct 2026 22:05:44 +0000 Subject: [PATCH 3/6] test(auth): trim redundant initial scope assertions --- crates/rmcp/src/transport/auth.rs | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 3c8cbe690..8125af15a 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -7966,22 +7966,6 @@ mod tests { let scopes = manager.select_initial_scopes(&["default"]).await; assert_eq!(scopes, ["read"]); - let session = AuthorizationSession::new( - manager, - AuthorizationRequest::new("http://localhost:8080/callback") - .with_preregistered_client("test-client") - .with_scopes(scopes), - ) - .await - .map_err(|(_, error)| error) - .unwrap(); - let authorization_url = Url::parse(session.get_authorization_url()).unwrap(); - assert_eq!( - authorization_url - .query_pairs() - .find(|(key, _)| key == "scope"), - Some(("scope".into(), "read".into())) - ); } #[rstest] @@ -8048,7 +8032,6 @@ mod tests { #[case::defaults(&[], &[], &[], &["default", "default"], &["default"])] #[case::empty(&[], &[], &[], &[], &[])] #[case::offline_access(&["read"], &["write"], &["offline_access"], &[], &["read", "offline_access"])] - #[case::offline_access_deduplicated(&["read", "offline_access"], &[], &["offline_access"], &[], &["read", "offline_access"])] #[tokio::test] async fn select_initial_scopes_uses_fallbacks_and_preserves_refresh_support( #[case] challenge: &[&str], From dde1d0adea04799e94727fd110678692b59c2de1 Mon Sep 17 00:00:00 2001 From: Alex Zamoshchin Date: Thu, 15 Oct 2026 21:16:33 +0000 Subject: [PATCH 4/6] fix(auth): address initial scope selection review --- crates/rmcp/src/transport/auth.rs | 199 ++++++++++++++++++++++-------- 1 file changed, 147 insertions(+), 52 deletions(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 8125af15a..15fc3247b 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -813,10 +813,10 @@ impl OAuthClientConfig { pub struct AuthorizationRequest { /// Redirect URI for the authorization-code flow. pub redirect_uri: String, - /// Scopes to request. When empty, the SDK selects scopes from the - /// server's `WWW-Authenticate` challenge, Protected Resource Metadata, - /// or authorization server metadata. + /// Scopes to request. When empty, the SDK uses the configured scope-selection policy. pub scopes: Vec, + /// Use initial-login scope selection when scopes are empty. Defaults to false. + pub use_initial_scopes: bool, /// Human-readable client name, used for Dynamic Client Registration. pub client_name: Option, /// Pre-registered client ID obtained from the authorization server out of @@ -845,6 +845,7 @@ impl AuthorizationRequest { Self { redirect_uri: redirect_uri.into(), scopes: Vec::new(), + use_initial_scopes: false, client_name: None, client_id: None, client_secret: None, @@ -854,8 +855,14 @@ impl AuthorizationRequest { } } + /// Opt into [`AuthorizationManager::select_initial_scopes`]. Nonempty explicit scopes take precedence. + pub fn with_initial_scope_selection(mut self) -> Self { + self.use_initial_scopes = true; + self + } + /// Set the scopes to request. When not set, the SDK auto-selects scopes - /// using its normal scope-selection policy. + /// using its configured scope-selection policy. pub fn with_scopes(mut self, scopes: I) -> Self where I: IntoIterator, @@ -1125,6 +1132,14 @@ impl Default for ScopeUpgradeConfig { } } +#[derive(Default)] +struct InitialScopeState { + /// Initial resource challenge only; None means the resource has not been observed yet. + challenge_scopes: Option>, + /// Protected resource scopes from the current discovery, including an empty result. + resource_scopes: Vec, +} + /// oauth2 auth manager pub struct AuthorizationManager { http_client: Arc, @@ -1140,10 +1155,7 @@ pub struct AuthorizationManager { scope_upgrade_config: ScopeUpgradeConfig, /// scopes from the initial 401 WWW-Authenticate header, used by select_scopes() www_auth_scopes: RwLock>, - /// Initial resource challenge only; None means the resource has not been observed yet. - initial_challenge_scopes: RwLock>>, - /// Protected resource scopes from the current discovery, including an empty result. - initial_resource_scopes: RwLock>, + initial_scopes: RwLock, /// scopes_supported from protected resource metadata (RFC 9728) resource_scopes: RwLock>, /// resource indicator from protected resource metadata, used for RFC 8707 `resource` @@ -1390,8 +1402,7 @@ impl AuthorizationManager { scope_upgrade_attempts: RwLock::new(0), scope_upgrade_config: ScopeUpgradeConfig::default(), www_auth_scopes: RwLock::new(Vec::new()), - initial_challenge_scopes: RwLock::new(None), - initial_resource_scopes: RwLock::new(Vec::new()), + initial_scopes: RwLock::new(InitialScopeState::default()), resource_scopes: RwLock::new(Vec::new()), discovered_resource: RwLock::new(None), application_type: Some(DEFAULT_APPLICATION_TYPE.to_string()), @@ -1547,8 +1558,7 @@ impl AuthorizationManager { /// # } /// ``` pub async fn resolve_metadata(&self) -> Result { - *self.initial_challenge_scopes.write().await = None; - self.initial_resource_scopes.write().await.clear(); + self.reset_initial_scopes().await; self.resolve_metadata_inner().await } @@ -1591,8 +1601,7 @@ impl AuthorizationManager { }; let params = WWWAuthenticateParams::parse(www_authenticate, &self.base_url); - *self.initial_challenge_scopes.write().await = None; - self.initial_resource_scopes.write().await.clear(); + self.reset_initial_scopes().await; self.record_initial_challenge_scope(Some(¶ms)).await; self.record_challenge_scope(¶ms).await; @@ -1610,10 +1619,14 @@ impl AuthorizationManager { self.resolve_metadata_inner().await } + async fn reset_initial_scopes(&self) { + *self.initial_scopes.write().await = InitialScopeState::default(); + } + async fn record_initial_challenge_scope(&self, params: Option<&WWWAuthenticateParams>) { - let mut initial_scopes = self.initial_challenge_scopes.write().await; - if initial_scopes.is_none() { - *initial_scopes = Some( + let mut initial_scopes = self.initial_scopes.write().await; + if initial_scopes.challenge_scopes.is_none() { + initial_scopes.challenge_scopes = Some( params .and_then(|params| params.scope.as_deref()) .map(|scope| scope.split_whitespace().map(str::to_string).collect()) @@ -1978,13 +1991,15 @@ impl AuthorizationManager { /// Select scopes for initial authorization after resolving and setting metadata. /// /// Prefer the initial resource challenge, then protected resource metadata. If - /// neither supplies scopes, retain the authorization-server metadata and caller - /// defaults as compatibility fallbacks. Append `offline_access` when advertised - /// by the authorization server, as with [`Self::select_scopes`]. + /// neither supplies scopes, use the caller-provided `default_scopes`. Pass an + /// empty slice to omit scopes when discovery supplies none. Authorization-server + /// scopes are not used as a fallback. When the selected scopes are nonempty, + /// append `offline_access` if advertised by the authorization server, as with + /// [`Self::select_scopes`]. /// /// Unlike [`Self::select_scopes`], this does not accumulate previously requested - /// scopes or challenges from metadata endpoints. Callers with explicit scopes - /// should continue to pass those scopes directly to authorization instead. + /// scopes or challenges from metadata endpoints. Callers that need to override + /// discovered scopes should pass those scopes directly to authorization instead. /// /// # Examples /// @@ -1999,17 +2014,20 @@ impl AuthorizationManager { /// # } /// ``` pub async fn select_initial_scopes(&self, default_scopes: &[&str]) -> Vec { - let mut scopes = self - .initial_challenge_scopes - .read() - .await - .clone() - .unwrap_or_default(); - if scopes.is_empty() { - scopes = self.initial_resource_scopes.read().await.clone(); - } + let mut scopes = { + let initial_scopes = self.initial_scopes.read().await; + initial_scopes + .challenge_scopes + .as_ref() + .filter(|scopes| !scopes.is_empty()) + .unwrap_or(&initial_scopes.resource_scopes) + .clone() + }; if scopes.is_empty() { - scopes = self.select_fallback_scopes(default_scopes); + scopes = default_scopes + .iter() + .map(|scope| scope.to_string()) + .collect(); } let mut scopes = Self::dedup_scopes(scopes); self.add_offline_access_if_supported(&mut scopes); @@ -2688,7 +2706,7 @@ impl AuthorizationManager { .await .replace(resource_metadata.resource.clone().unwrap_or_default()); - *self.initial_resource_scopes.write().await = resource_metadata + self.initial_scopes.write().await.resource_scopes = resource_metadata .scopes_supported .clone() .unwrap_or_default(); @@ -3661,7 +3679,7 @@ impl AuthorizationSession { /// /// The manager must already have discovered authorization server metadata. /// If `request.scopes` is empty, scopes are selected using the SDK's - /// normal scope-selection policy. + /// configured scope-selection policy. /// /// On failure, the manager is returned alongside the error so callers can /// retry without losing the original configuration and stores. @@ -3681,7 +3699,11 @@ impl AuthorizationSession { } if request.scopes.is_empty() { - request.scopes = auth_manager.select_scopes(None, &[]); + request.scopes = if request.use_initial_scopes { + auth_manager.select_initial_scopes(&[]).await + } else { + auth_manager.select_scopes(None, &[]) + }; } else { auth_manager.add_offline_access_if_supported(&mut request.scopes); } @@ -4015,7 +4037,7 @@ impl OAuthState { /// advertises a `registration_endpoint` /// /// If `request.scopes` is empty, scopes are selected using the SDK's - /// normal scope-selection policy. + /// configured scope-selection policy. /// /// On failure, the state returns to `Unauthorized` so callers can retry /// without losing the original configuration and stores. @@ -4262,9 +4284,9 @@ mod tests { AuthError, AuthorizationCallback, AuthorizationManager, AuthorizationMetadata, AuthorizationMetadataSource, AuthorizationRequest, AuthorizationSession, CredentialRefreshGuard, CredentialStore, InMemoryCredentialStore, InMemoryStateStore, - OAuthClientConfig, OAuthHttpClient, OAuthHttpClientError, OAuthHttpClientFuture, - OAuthHttpRedirectPolicy, OAuthHttpRequest, ScopeUpgradeConfig, StateStore, - StoredAuthorizationState, is_https_url, + InitialScopeState, OAuthClientConfig, OAuthHttpClient, OAuthHttpClientError, + OAuthHttpClientFuture, OAuthHttpRedirectPolicy, OAuthHttpRequest, ScopeUpgradeConfig, + StateStore, StoredAuthorizationState, is_https_url, }; use crate::transport::auth::VendorExtraTokenFields; @@ -5964,6 +5986,55 @@ mod tests { assert_eq!(query.get("scope").unwrap(), "read write offline_access"); } + #[rstest] + #[case::legacy(false, Some("read"), Some(&["read", "write"][..]), &[], Some("read write offline_access"))] + #[case::initial(true, Some("read"), Some(&["read", "write"][..]), &[], Some("read offline_access"))] + #[case::resource(true, None, Some(&["read", "write"][..]), &[], Some("read write offline_access"))] + #[case::missing(true, None, None, &[], None)] + #[case::explicit(true, Some("read"), Some(&["read", "write"][..]), &["custom"], Some("custom offline_access"))] + #[tokio::test] + async fn oauth_state_uses_requested_scope_selection( + #[case] opt_in: bool, + #[case] challenge_scope: Option<&str>, + #[case] resource_scopes: Option<&[&str]>, + #[case] explicit_scopes: &[&str], + #[case] expected_scope: Option<&str>, + ) { + let mut challenge = r#"Bearer resource_metadata="/metadata""#.to_string(); + if let Some(scope) = challenge_scope { + challenge.push_str(&format!(r#", scope="{scope}""#)); + } + let mut resource_metadata = serde_json::json!({ + "resource": "https://mcp.example.com/mcp", + "authorization_servers": ["https://auth.example.com"] + }); + if let Some(scopes) = resource_scopes { + resource_metadata["scopes_supported"] = serde_json::json!(scopes); + } + let client = RecordingOAuthHttpClient::with_responses(vec![ + challenge_response(&challenge), + http_response(200, resource_metadata), + preregistered_as_metadata_response(), + ]); + let mut state = super::OAuthState::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client), + ) + .await + .unwrap(); + + let mut request = AuthorizationRequest::new("http://localhost:8080/callback") + .with_preregistered_client("preregistered-client") + .with_scopes(explicit_scopes.iter().copied()); + if opt_in { + request = request.with_initial_scope_selection(); + } + state.start_authorization(request).await.unwrap(); + + let query = auth_url_query(&state.get_authorization_url().await.unwrap()); + assert_eq!(query.get("scope").map(String::as_str), expected_scope); + } + #[tokio::test] async fn preregistered_client_uses_explicit_scopes_and_adds_offline_access() { let client = RecordingOAuthHttpClient::with_responses(preregistered_discovery_responses()); @@ -7968,14 +8039,9 @@ mod tests { assert_eq!(scopes, ["read"]); } - #[rstest] - #[case::initial_scope(Some("read"), vec!["read"])] - #[case::no_initial_scope(None, vec!["read", "write"])] - #[tokio::test] - async fn select_initial_scopes_ignores_later_metadata_challenges( - #[case] initial_scope: Option<&str>, - #[case] expected: Vec<&str>, - ) { + async fn manager_with_later_metadata_challenge( + initial_scope: Option<&str>, + ) -> AuthorizationManager { let initial_response = match initial_scope { Some(scope) => challenge_response(&format!(r#"Bearer scope="{scope}""#)), None => empty_response(404), @@ -7995,7 +8061,31 @@ mod tests { let resolution = manager.resolve_metadata().await.unwrap(); manager.set_metadata(resolution.metadata); + manager + } + + #[rstest] + #[case::initial_scope(Some("read"), vec!["read"])] + #[case::no_initial_scope(None, vec!["read", "write"])] + #[tokio::test] + async fn select_initial_scopes_ignores_later_metadata_challenges( + #[case] initial_scope: Option<&str>, + #[case] expected: Vec<&str>, + ) { + let manager = manager_with_later_metadata_challenge(initial_scope).await; + assert_eq!(manager.select_initial_scopes(&[]).await, expected); + } + + #[rstest] + #[case::initial_scope(Some("read"))] + #[case::no_initial_scope(None)] + #[tokio::test] + async fn select_scopes_preserves_later_metadata_challenges( + #[case] initial_scope: Option<&str>, + ) { + let manager = manager_with_later_metadata_challenge(initial_scope).await; + assert!( manager .select_scopes(None, &[]) @@ -8028,10 +8118,13 @@ mod tests { #[rstest] #[case::challenge(&["read", "read"], &["write"], &["admin"], &["default"], &["read"])] #[case::resource(&[], &["read", "read"], &["admin"], &["default"], &["read"])] - #[case::authorization_server(&[], &[], &["admin", "admin"], &["default"], &["admin"])] + #[case::defaults_ignore_authorization_server(&[], &[], &["admin", "admin"], &["default"], &["default"])] + #[case::authorization_server_ignored(&[], &[], &["admin"], &[], &[])] #[case::defaults(&[], &[], &[], &["default", "default"], &["default"])] #[case::empty(&[], &[], &[], &[], &[])] #[case::offline_access(&["read"], &["write"], &["offline_access"], &[], &["read", "offline_access"])] + #[case::offline_access_without_selected_scopes(&[], &[], &["admin", "offline_access"], &[], &[])] + #[case::defaults_with_offline_access(&[], &[], &["admin", "offline_access"], &["default", "default"], &["default", "offline_access"])] #[tokio::test] async fn select_initial_scopes_uses_fallbacks_and_preserves_refresh_support( #[case] challenge: &[&str], @@ -8046,8 +8139,10 @@ mod tests { ..Default::default() })) .await; - *manager.initial_challenge_scopes.write().await = Some(strings(challenge)); - *manager.initial_resource_scopes.write().await = strings(resource); + *manager.initial_scopes.write().await = InitialScopeState { + challenge_scopes: Some(strings(challenge)), + resource_scopes: strings(resource), + }; *manager.current_scopes.write().await = vec!["previous".to_string()]; assert_eq!(manager.select_initial_scopes(defaults).await, expected); @@ -8055,8 +8150,8 @@ mod tests { #[rstest] #[case::challenge(Some("read"), Some(&["read", "write"][..]), &["read"], &["read", "write"])] - #[case::missing_resource_scopes(None, None, &["read", "write"], &["admin"])] - #[case::empty_resource_scopes(None, Some(&[][..]), &["read", "write"], &["admin"])] + #[case::missing_resource_scopes(None, None, &["read", "write"], &[])] + #[case::empty_resource_scopes(None, Some(&[][..]), &["read", "write"], &[])] #[tokio::test] async fn select_initial_scopes_resets_on_repeated_discovery( #[case] initial_scope: Option<&str>, From b15afa08b3babc9c9a0c470b897aa5ff80745e32 Mon Sep 17 00:00:00 2001 From: Alex Zamoshchin Date: Thu, 15 Oct 2026 21:27:18 +0000 Subject: [PATCH 5/6] test(auth): cover reactive initial scope selection --- crates/rmcp/src/transport/auth.rs | 35 +++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 15fc3247b..e16640c10 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -2082,10 +2082,6 @@ impl AuthorizationManager { return Self::dedup_scopes(accumulated); } - self.select_fallback_scopes(default_scopes) - } - - fn select_fallback_scopes(&self, default_scopes: &[&str]) -> Vec { if let Some(metadata) = &self.metadata && let Some(scopes_supported) = &metadata.scopes_supported && !scopes_supported.is_empty() @@ -6035,6 +6031,37 @@ mod tests { assert_eq!(query.get("scope").map(String::as_str), expected_scope); } + #[tokio::test] + async fn oauth_state_uses_initial_scopes_from_supplied_challenge() { + let client = RecordingOAuthHttpClient::with_responses(vec![ + http_response( + 200, + serde_json::json!({ + "resource": "https://mcp.example.com/mcp", + "authorization_servers": ["https://auth.example.com"], + "scopes_supported": ["read", "write"] + }), + ), + preregistered_as_metadata_response(), + ]); + let mut state = super::OAuthState::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client.clone()), + ) + .await + .unwrap(); + + let request = AuthorizationRequest::new("http://localhost:8080/callback") + .with_preregistered_client("preregistered-client") + .with_challenge(r#"Bearer resource_metadata="/metadata", scope="read""#) + .with_initial_scope_selection(); + state.start_authorization(request).await.unwrap(); + + let query = auth_url_query(&state.get_authorization_url().await.unwrap()); + assert_eq!(query.get("scope").unwrap(), "read offline_access"); + assert_eq!(client.requests()[0].uri, "https://mcp.example.com/metadata"); + } + #[tokio::test] async fn preregistered_client_uses_explicit_scopes_and_adds_offline_access() { let client = RecordingOAuthHttpClient::with_responses(preregistered_discovery_responses()); From 689c2cf30074e56b013755aafe46db5bb3ae9203 Mon Sep 17 00:00:00 2001 From: Alex Zamoshchin Date: Fri, 16 Oct 2026 01:38:32 +0000 Subject: [PATCH 6/6] refactor(auth): use one metadata discovery entry point --- crates/rmcp/src/transport/auth.rs | 73 +++++++++++++------------------ 1 file changed, 31 insertions(+), 42 deletions(-) diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index e16640c10..a1425500f 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -1558,11 +1558,39 @@ impl AuthorizationManager { /// # } /// ``` pub async fn resolve_metadata(&self) -> Result { - self.reset_initial_scopes().await; - self.resolve_metadata_inner().await + self.resolve_metadata_from_challenge(None).await } - async fn resolve_metadata_inner(&self) -> Result { + /// Resolve authorization server metadata starting from the + /// `WWW-Authenticate` challenge of a real request's 401 response — the + /// reactive discovery path, matching the TypeScript and Python SDKs. + /// + /// Seeds scope selection with the challenge's `scope` hint and prefers + /// the challenge's `resource_metadata` URL; falls back to ordinary metadata + /// discovery when the challenge is `None` or carries no usable metadata pointer. + pub async fn resolve_metadata_from_challenge( + &self, + www_authenticate: Option<&str>, + ) -> Result { + *self.initial_scopes.write().await = InitialScopeState::default(); + + if let Some(www_authenticate) = www_authenticate { + let params = WWWAuthenticateParams::parse(www_authenticate, &self.base_url); + self.record_initial_challenge_scope(Some(¶ms)).await; + self.record_challenge_scope(¶ms).await; + + if let Some(resource_metadata_url) = ¶ms.resource_metadata_url + && let Some(metadata) = self + .discover_oauth_server_from_resource_metadata_url(resource_metadata_url) + .await? + { + return Ok(AuthorizationMetadataResolution { + metadata, + source: AuthorizationMetadataSource::ProtectedResourceMetadata, + }); + } + } + if let Some(metadata) = self.discover_oauth_server_via_resource_metadata().await? { return Ok(AuthorizationMetadataResolution { metadata, @@ -1584,45 +1612,6 @@ impl AuthorizationManager { }) } - /// Resolve authorization server metadata starting from the - /// `WWW-Authenticate` challenge of a real request's 401 response — the - /// reactive discovery path, matching the TypeScript and Python SDKs. - /// - /// Seeds scope selection with the challenge's `scope` hint and prefers - /// the challenge's `resource_metadata` URL; falls back to - /// [`resolve_metadata`](Self::resolve_metadata) when the challenge is - /// `None` or carries no usable metadata pointer. - pub async fn resolve_metadata_from_challenge( - &self, - www_authenticate: Option<&str>, - ) -> Result { - let Some(www_authenticate) = www_authenticate else { - return self.resolve_metadata().await; - }; - let params = WWWAuthenticateParams::parse(www_authenticate, &self.base_url); - - self.reset_initial_scopes().await; - self.record_initial_challenge_scope(Some(¶ms)).await; - self.record_challenge_scope(¶ms).await; - - if let Some(resource_metadata_url) = ¶ms.resource_metadata_url - && let Some(metadata) = self - .discover_oauth_server_from_resource_metadata_url(resource_metadata_url) - .await? - { - return Ok(AuthorizationMetadataResolution { - metadata, - source: AuthorizationMetadataSource::ProtectedResourceMetadata, - }); - } - - self.resolve_metadata_inner().await - } - - async fn reset_initial_scopes(&self) { - *self.initial_scopes.write().await = InitialScopeState::default(); - } - async fn record_initial_challenge_scope(&self, params: Option<&WWWAuthenticateParams>) { let mut initial_scopes = self.initial_scopes.write().await; if initial_scopes.challenge_scopes.is_none() {