diff --git a/conformance/src/bin/client.rs b/conformance/src/bin/client.rs index 9d134860..852ca091 100644 --- a/conformance/src/bin/client.rs +++ b/conformance/src/bin/client.rs @@ -509,8 +509,8 @@ async fn migration_token( return Ok(manager.get_access_token().await?); } - let metadata = manager.discover_metadata().await?; - manager.set_metadata(metadata); + let resolution = manager.resolve_metadata().await?; + manager.set_metadata(resolution.metadata); manager .register_client("conformance-client", REDIRECT_URI, &[]) .await?; @@ -609,9 +609,9 @@ async fn run_client_credentials_basic( .unwrap_or("conformance-test-secret"); let mut manager = AuthorizationManager::new(server_url).await?; - let metadata = manager.discover_metadata().await?; - let token_endpoint = metadata.token_endpoint.clone(); - manager.set_metadata(metadata); + let resolution = manager.resolve_metadata().await?; + let token_endpoint = resolution.metadata.token_endpoint.clone(); + manager.set_metadata(resolution.metadata); let http = reqwest::Client::new(); let resp = http diff --git a/crates/rmcp/src/transport/auth.rs b/crates/rmcp/src/transport/auth.rs index 139443a3..26e53785 100644 --- a/crates/rmcp/src/transport/auth.rs +++ b/crates/rmcp/src/transport/auth.rs @@ -578,6 +578,43 @@ pub struct AuthorizationMetadata { pub additional_fields: HashMap, } +/// How [`AuthorizationMetadata`] was obtained during discovery. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum AuthorizationMetadataSource { + /// Discovered through RFC 9728 protected resource metadata. + ProtectedResourceMetadata, + /// Discovered through RFC 8414 / OpenID Connect metadata at the server's + /// base URL. + AuthorizationServerMetadata, + /// Nothing was discovered; the endpoints were synthesized from the base + /// URL (`/authorize`, `/token`, `/register`) for compatibility with the + /// 2025-03-26 MCP spec's default-endpoint fallback. The server gave no + /// evidence that it supports OAuth. + /// + /// [Newer MCP revisions] require metadata discovery and do not define an + /// endpoint-synthesis fallback. + /// + /// [Newer MCP revisions]: https://modelcontextprotocol.io/specification/draft/basic/authorization/authorization-server-discovery#protected-resource-metadata-discovery-requirements + LegacyEndpointFallback, +} + +impl AuthorizationMetadataSource { + /// Whether the metadata was actually published by the server, as opposed + /// to synthesized by the client as a legacy compatibility fallback. + pub fn is_discovered(self) -> bool { + !matches!(self, Self::LegacyEndpointFallback) + } +} + +/// [`AuthorizationMetadata`] together with how it was resolved. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct AuthorizationMetadataResolution { + pub metadata: AuthorizationMetadata, + pub source: AuthorizationMetadataSource, +} + #[derive(Debug, Clone, Deserialize)] struct ResourceServerMetadata { resource: Option, @@ -1236,8 +1273,10 @@ impl AuthorizationManager { /// Set OAuth2 authorization metadata /// - /// This should be called after discovering metadata via `discover_metadata()` - /// and before creating an `AuthorizationSession`. + /// This should be called with + /// [`AuthorizationMetadataResolution::metadata`] after + /// [`Self::resolve_metadata`] and before creating an + /// [`AuthorizationSession`]. pub fn set_metadata(&mut self, metadata: AuthorizationMetadata) { self.metadata = Some(metadata); } @@ -1252,8 +1291,8 @@ impl AuthorizationManager { && stored.token_response.is_some() { if self.metadata.is_none() { - let metadata = self.discover_metadata().await?; - self.metadata = Some(metadata); + let resolution = self.resolve_metadata().await?; + self.metadata = Some(resolution.metadata); } if let (Some(stored_issuer), Some(current_issuer)) = @@ -1320,18 +1359,51 @@ impl AuthorizationManager { Ok(()) } - /// discover oauth2 metadata (per SEP-985: Protected Resource Metadata first, then direct OAuth) - pub async fn discover_metadata(&self) -> Result { + /// Resolve OAuth 2.0 metadata and report how it was obtained. + /// + /// Discovery follows SEP-985: protected resource metadata first, then + /// direct OAuth 2.0 Authorization Server Metadata or OpenID Connect + /// Discovery. When discovery finds nothing, the result contains legacy + /// default endpoints derived from the base URL and + /// [`AuthorizationMetadataSource::LegacyEndpointFallback`]. + /// + /// # Examples + /// + /// ```no_run + /// use rmcp::transport::auth::{AuthorizationManager, AuthorizationMetadataSource}; + /// + /// # async fn resolve() -> Result<(), Box> { + /// let mut manager = AuthorizationManager::new("https://mcp.example.com").await?; + /// let resolution = manager.resolve_metadata().await?; + /// + /// if resolution.source == AuthorizationMetadataSource::LegacyEndpointFallback { + /// println!("the server did not publish OAuth metadata"); + /// } + /// + /// manager.set_metadata(resolution.metadata); + /// # Ok(()) + /// # } + /// ``` + pub async fn resolve_metadata(&self) -> Result { if let Some(metadata) = self.discover_oauth_server_via_resource_metadata().await? { - return Ok(metadata); + return Ok(AuthorizationMetadataResolution { + metadata, + source: AuthorizationMetadataSource::ProtectedResourceMetadata, + }); } if let Some(metadata) = self.try_discover_oauth_server(&self.base_url).await? { - return Ok(metadata); + return Ok(AuthorizationMetadataResolution { + metadata, + source: AuthorizationMetadataSource::AuthorizationServerMetadata, + }); } debug!("falling back to legacy OAuth endpoints derived from the base URL"); - Ok(Self::legacy_authorization_metadata(&self.base_url)) + Ok(AuthorizationMetadataResolution { + metadata: Self::legacy_authorization_metadata(&self.base_url), + source: AuthorizationMetadataSource::LegacyEndpointFallback, + }) } fn legacy_authorization_metadata(base_url: &Url) -> AuthorizationMetadata { @@ -3413,8 +3485,8 @@ impl OAuthState { *manager.current_scopes.write().await = granted_scopes.clone(); - let metadata = manager.discover_metadata().await?; - manager.metadata = Some(metadata); + let resolution = manager.resolve_metadata().await?; + manager.metadata = Some(resolution.metadata); let stored = StoredCredentials { client_id: client_id.to_string(), @@ -3468,8 +3540,8 @@ impl OAuthState { )); }; debug!("start discovery"); - let metadata = match manager.discover_metadata().await { - Ok(metadata) => metadata, + let metadata = match manager.resolve_metadata().await { + Ok(resolution) => resolution.metadata, Err(e) => { *self = OAuthState::Unauthorized(manager); return Err(e); @@ -3661,8 +3733,8 @@ impl OAuthState { }; // Discover metadata - let metadata = manager.discover_metadata().await?; - manager.metadata = Some(metadata); + let resolution = manager.resolve_metadata().await?; + manager.metadata = Some(resolution.metadata); // Validate server supports the requested auth method manager.validate_client_credentials_metadata(&config)?; @@ -3691,10 +3763,10 @@ mod tests { use super::{ AuthError, AuthorizationCallback, AuthorizationManager, AuthorizationMetadata, - AuthorizationRequest, AuthorizationSession, CredentialStore, InMemoryCredentialStore, - InMemoryStateStore, OAuthClientConfig, OAuthHttpClient, OAuthHttpClientError, - OAuthHttpClientFuture, OAuthHttpRedirectPolicy, OAuthHttpRequest, ScopeUpgradeConfig, - StateStore, StoredAuthorizationState, is_https_url, + AuthorizationMetadataSource, AuthorizationRequest, AuthorizationSession, CredentialStore, + InMemoryCredentialStore, InMemoryStateStore, OAuthClientConfig, OAuthHttpClient, + OAuthHttpClientError, OAuthHttpClientFuture, OAuthHttpRedirectPolicy, OAuthHttpRequest, + ScopeUpgradeConfig, StateStore, StoredAuthorizationState, is_https_url, }; use crate::transport::auth::VendorExtraTokenFields; @@ -3797,7 +3869,7 @@ mod tests { .await .unwrap(); - let metadata = manager.discover_metadata().await.unwrap(); + let metadata = manager.resolve_metadata().await.unwrap().metadata; assert_eq!(metadata.token_endpoint, "https://auth.example.com/token"); assert_eq!( @@ -3860,7 +3932,7 @@ mod tests { .await .unwrap(); - let metadata = manager.discover_metadata().await.unwrap(); + let metadata = manager.resolve_metadata().await.unwrap().metadata; assert_eq!( ( @@ -3919,7 +3991,7 @@ mod tests { .await .unwrap(); - let error = manager.discover_metadata().await.unwrap_err(); + let error = manager.resolve_metadata().await.unwrap_err(); assert!( matches!( @@ -4042,7 +4114,7 @@ mod tests { .await .unwrap(); - let metadata = manager.discover_metadata().await.unwrap(); + let metadata = manager.resolve_metadata().await.unwrap().metadata; assert_eq!( ( @@ -4077,7 +4149,7 @@ mod tests { } #[tokio::test] - async fn discover_metadata_falls_back_to_legacy_default_endpoints() { + async fn resolve_metadata_reports_legacy_fallback_when_nothing_is_discovered() { let client = RecordingOAuthHttpClient::with_responses(vec![ empty_response(404), empty_response(404), @@ -4092,13 +4164,14 @@ mod tests { .await .unwrap(); - let metadata = manager.discover_metadata().await.unwrap(); + let resolution = manager.resolve_metadata().await.unwrap(); assert_eq!( ( - metadata.authorization_endpoint.as_str(), - metadata.token_endpoint.as_str(), - metadata.registration_endpoint.as_deref(), + resolution.source, + resolution.metadata.authorization_endpoint.as_str(), + resolution.metadata.token_endpoint.as_str(), + resolution.metadata.registration_endpoint.as_deref(), client .requests() .iter() @@ -4106,6 +4179,7 @@ mod tests { .collect::>(), ), ( + AuthorizationMetadataSource::LegacyEndpointFallback, "https://legacy.example.com/authorize", "https://legacy.example.com/token", Some("https://legacy.example.com/register"), @@ -4120,6 +4194,108 @@ mod tests { ); } + #[tokio::test] + async fn resolve_metadata_reports_protected_resource_metadata() { + let challenge = oauth2::http::Response::builder() + .status(401) + .header( + "www-authenticate", + r#"Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource""#, + ) + .body(Vec::new()) + .unwrap(); + let client = RecordingOAuthHttpClient::with_responses(vec![ + challenge, + http_response( + 200, + serde_json::json!({ + "resource": "https://mcp.example.com/mcp", + "authorization_servers": ["https://auth.example.com"] + }), + ), + 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" + }), + ), + ]); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/mcp", + Arc::new(client), + ) + .await + .unwrap(); + + let resolution = manager.resolve_metadata().await.unwrap(); + + assert_eq!( + ( + resolution.source, + resolution.metadata.token_endpoint.as_str(), + ), + ( + AuthorizationMetadataSource::ProtectedResourceMetadata, + "https://auth.example.com/token", + ) + ); + } + + #[tokio::test] + async fn resolve_metadata_reports_authorization_server_metadata() { + let client = RecordingOAuthHttpClient::with_responses(vec![ + empty_response(404), + empty_response(404), + empty_response(404), + http_response( + 200, + serde_json::json!({ + "issuer": "https://mcp.example.com", + "authorization_endpoint": "https://mcp.example.com/oauth/authorize", + "token_endpoint": "https://mcp.example.com/oauth/token" + }), + ), + ]); + let manager = AuthorizationManager::new_with_oauth_http_client( + "https://mcp.example.com/", + Arc::new(client), + ) + .await + .unwrap(); + + let resolution = manager.resolve_metadata().await.unwrap(); + + assert_eq!( + ( + resolution.source, + resolution.metadata.token_endpoint.as_str(), + ), + ( + AuthorizationMetadataSource::AuthorizationServerMetadata, + "https://mcp.example.com/oauth/token", + ) + ); + } + + #[rstest] + #[case::protected_resource_metadata( + AuthorizationMetadataSource::ProtectedResourceMetadata, + true + )] + #[case::authorization_server_metadata( + AuthorizationMetadataSource::AuthorizationServerMetadata, + true + )] + #[case::legacy_endpoint_fallback(AuthorizationMetadataSource::LegacyEndpointFallback, false)] + fn is_discovered_is_false_only_for_the_legacy_fallback( + #[case] source: AuthorizationMetadataSource, + #[case] expected: bool, + ) { + assert_eq!(source.is_discovered(), expected); + } + fn preregistered_as_metadata_response() -> HttpResponse { http_response( 200, @@ -4206,7 +4382,7 @@ mod tests { ) .await .unwrap(); - manager.metadata = Some(manager.discover_metadata().await.unwrap()); + manager.metadata = Some(manager.resolve_metadata().await.unwrap().metadata); let request = AuthorizationRequest::new("http://localhost:8080/callback") .with_preregistered_client("preregistered-client"); @@ -4661,7 +4837,7 @@ mod tests { .await .unwrap(); - let metadata = manager.discover_metadata().await.unwrap(); + let metadata = manager.resolve_metadata().await.unwrap().metadata; let requests = client.requests(); assert_eq!( @@ -4718,7 +4894,7 @@ mod tests { .await .unwrap(); - let metadata = manager.discover_metadata().await.unwrap(); + let metadata = manager.resolve_metadata().await.unwrap().metadata; assert_eq!( ( @@ -4767,7 +4943,7 @@ mod tests { .await .unwrap(); - let error = manager.discover_metadata().await.unwrap_err(); + let error = manager.resolve_metadata().await.unwrap_err(); assert!( matches!(error, AuthError::MetadataError(ref message) if message.contains("resource mismatch")), @@ -4802,7 +4978,7 @@ mod tests { .await .unwrap(); - let error = manager.discover_metadata().await.unwrap_err(); + let error = manager.resolve_metadata().await.unwrap_err(); assert!( matches!(error, AuthError::MetadataError(ref message) if message.contains("missing required resource")), diff --git a/docs/OAUTH_SUPPORT.md b/docs/OAUTH_SUPPORT.md index b9d8e1ca..1d09d740 100644 --- a/docs/OAUTH_SUPPORT.md +++ b/docs/OAUTH_SUPPORT.md @@ -108,6 +108,51 @@ Use this path when OAuth traffic must go through a browser fetch API, a remote execution environment, a company gateway, a test fake, or any other non-reqwest transport. +#### Inspect discovery provenance directly + +Most applications can use `OAuthState` without calling metadata discovery +directly. When using `AuthorizationManager`, `resolve_metadata()` returns both +the metadata and how it was obtained. A client that supports the 2025-03-26 +default-endpoint fallback can continue with synthesized metadata, while a +client that requires server-published metadata should reject that result: + +```rust ignore +use rmcp::transport::auth::{AuthorizationManager, AuthorizationMetadataSource}; + +async fn configure_metadata( + manager: &mut AuthorizationManager, + allow_legacy_endpoint_fallback: bool, +) -> anyhow::Result<()> { + let resolution = manager.resolve_metadata().await?; + + if resolution.source == AuthorizationMetadataSource::LegacyEndpointFallback { + if !allow_legacy_endpoint_fallback { + anyhow::bail!("the server did not publish OAuth metadata"); + } + + tracing::warn!( + "the server did not publish OAuth metadata; using the 2025-03-26 fallback endpoints" + ); + } + + manager.set_metadata(resolution.metadata); + Ok(()) +} +``` + +`ProtectedResourceMetadata` and `AuthorizationServerMetadata` indicate +server-published metadata, so clients can proceed with the returned metadata. +`LegacyEndpointFallback` indicates endpoints synthesized for compatibility +with the 2025-03-26 MCP specification. Clients should proceed only when they +intentionally support that legacy behavior; clients using discovery as an +OAuth capability check should treat it as unsupported. + +Applications using `OAuthState` do not need to handle these sources directly: +the state machine resolves metadata internally and retains the legacy fallback. +Low-level `AuthorizationManager` users can use +`AuthorizationMetadataSource::is_discovered()` when they only need to +distinguish server-published metadata from synthesized metadata. + ### 3. Start authorization with OAuthState The `OAuthState` state machine manages the full authorization lifecycle. @@ -229,7 +274,8 @@ match oauth_state.request_scope_upgrade("admin:write", MCP_REDIRECT_URI).await { ## Complete Examples -- **Client**: [`examples/clients/src/auth/oauth_client.rs`](../examples/clients/src/auth/oauth_client.rs) +- **Authorization Code client**: [`examples/clients/src/auth/oauth_client.rs`](../examples/clients/src/auth/oauth_client.rs) +- **Client Credentials client**: [`examples/clients/src/auth/client_credentials.rs`](../examples/clients/src/auth/client_credentials.rs) - **Server**: [`examples/servers/src/complex_auth_streamhttp.rs`](../examples/servers/src/complex_auth_streamhttp.rs) ### Running the Examples @@ -240,6 +286,10 @@ cargo run -p mcp-server-examples --example servers_complex_auth_streamhttp # Run the OAuth client (in another terminal) cargo run -p mcp-client-examples --example clients_oauth_client + +# Run the Client Credentials client +cargo run -p mcp-client-examples --example clients_client_credentials -- \ + ``` ## Authorization Flow Description diff --git a/examples/clients/README.md b/examples/clients/README.md index 76aa9738..36bdd590 100644 --- a/examples/clients/README.md +++ b/examples/clients/README.md @@ -57,6 +57,14 @@ A client demonstrating how to authenticate with an MCP server using OAuth. - Establishes an authorized connection to the MCP server using the acquired access token - Demonstrates how to use the authorized connection to retrieve available tools and prompts +### OAuth Client Credentials (`auth/client_credentials.rs`) + +A client demonstrating the OAuth 2.0 Client Credentials flow from SEP-1046. + +- Accepts the server URL, client ID, and client secret as command-line arguments +- Authenticates without an interactive browser or callback server +- Establishes an authorized connection and retrieves the available tools + ### Sampling Standard I/O Client (`sampling_stdio.rs`) @@ -107,6 +115,10 @@ cargo run -p mcp-client-examples --example clients_collection # Run the OAuth client example cargo run -p mcp-client-examples --example clients_oauth_client +# Run the OAuth Client Credentials example +cargo run -p mcp-client-examples --example clients_client_credentials -- \ + + # Run the sampling standard I/O client example cargo run -p mcp-client-examples --example clients_sampling_stdio