diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index ab11142ae..a1425500f 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,6 +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_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 +1402,7 @@ impl AuthorizationManager { scope_upgrade_attempts: RwLock::new(0), scope_upgrade_config: ScopeUpgradeConfig::default(), www_auth_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()), @@ -1541,6 +1558,39 @@ impl AuthorizationManager { /// # } /// ``` pub async fn resolve_metadata(&self) -> Result { + self.resolve_metadata_from_challenge(None).await + } + + /// 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, @@ -1562,37 +1612,16 @@ 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.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, - }); + 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() { + initial_scopes.challenge_scopes = Some( + params + .and_then(|params| params.scope.as_deref()) + .map(|scope| scope.split_whitespace().map(str::to_string).collect()) + .unwrap_or_default(), + ); } - - self.resolve_metadata().await } /// Store a challenge's `scope` hint for later scope selection. @@ -1948,7 +1977,55 @@ 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, 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 that need to override + /// discovered scopes should pass those scopes directly to authorization instead. + /// + /// # Examples + /// + /// ```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 = { + 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 = default_scopes + .iter() + .map(|scope| scope.to_string()) + .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>, @@ -1994,7 +2071,6 @@ impl AuthorizationManager { return Self::dedup_scopes(accumulated); } - // nothing requested or challenged yet: seed from AS metadata, then caller defaults if let Some(metadata) = &self.metadata && let Some(scopes_supported) = &metadata.scopes_supported && !scopes_supported.is_empty() @@ -2615,6 +2691,10 @@ impl AuthorizationManager { .await .replace(resource_metadata.resource.clone().unwrap_or_default()); + self.initial_scopes.write().await.resource_scopes = 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() @@ -2806,8 +2886,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; }; @@ -2848,11 +2929,13 @@ impl AuthorizationManager { .map_err(|error| Self::discovery_failed(&self.base_url, error))?; 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() @@ -2860,11 +2943,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; @@ -2872,12 +2955,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( @@ -3577,7 +3664,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. @@ -3597,7 +3684,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); } @@ -3931,7 +4022,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. @@ -4178,9 +4269,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; @@ -4231,6 +4322,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) @@ -5872,6 +5971,86 @@ 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 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()); @@ -7820,6 +7999,249 @@ mod tests { assert_eq!(result.len(), 2); } + 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, resource_metadata), + 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(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)), + ) + .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"]); + } + + 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), + }; + let mut responses = vec![ + initial_response, + challenge_response(r#"Bearer resource_metadata="/metadata", scope="metadata: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)), + ) + .await + .unwrap(); + + 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, &[]) + .contains(&"metadata:read".to_string()) + ); + } + + #[tokio::test] + async fn select_initial_scopes_preserves_reactive_scope_during_fallback() { + 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)), + ) + .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"]); + } + + #[rstest] + #[case::challenge(&["read", "read"], &["write"], &["admin"], &["default"], &["read"])] + #[case::resource(&[], &["read", "read"], &["admin"], &["default"], &["read"])] + #[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], + #[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_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); + } + + #[rstest] + #[case::challenge(Some("read"), Some(&["read", "write"][..]), &["read"], &["read", "write"])] + #[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>, + #[case] resource_scopes: Option<&[&str]>, + #[case] first_expected: &[&str], + #[case] second_expected: &[&str], + ) { + 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)), + ) + .await + .unwrap(); + + 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"]); + } + + #[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(Some(&["read", "write"]))); + 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]