feat: expose oauth discovery metadata source (#1041)

* feat: expose oauth discovery metadata source

* feat!: return source from metadata resolution
This commit is contained in:
Dale Seo 2026-07-24 14:07:06 -04:00 • committed by GitHub
commit 60a7a75703
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 277 additions and 39 deletions

View file

@ -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

View file

@ -578,6 +578,43 @@ pub struct AuthorizationMetadata {
pub additional_fields: HashMap<String, serde_json::Value>,
}
/// 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<String>,
@ -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<AuthorizationMetadata, AuthError> {
/// 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<dyn std::error::Error>> {
/// 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<AuthorizationMetadataResolution, AuthError> {
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::<Vec<_>>(),
),
(
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")),

View file

@ -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 -- \
<server_url> <client_id> <client_secret>
```
## Authorization Flow Description

View file

@ -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 -- \
<server_url> <client_id> <client_secret>
# Run the sampling standard I/O client example
cargo run -p mcp-client-examples --example clients_sampling_stdio