chore: refactor OAuth client authorization api (#1009)

* refactor!: refactor OAuth client authorization api

`OAuthState::start_authorization` now accepts a declarative `AuthorizationRequest`
describing the client's available identity material and selects the highest-priority
mechanism the server supports: pre-registered client information → Client ID
Metadata Documents (SEP-991) → Dynamic Client Registration.

This replaces the three mechanism-specific entry points (`start_authorization`,
`start_authorization_with_metadata_url`, `start_authorization_with_preregistered_client`),
where callers previously had to know which mechanism to pick. `AuthorizationSession::new`
is consolidated the same way, and the failure-recovery pattern from #994 (returning the
manager alongside the error so `OAuthState` recovers to `Unauthorized` on transient failures)
is now applied across all registration paths. Includes new `RecordingOAuthHttpClient`-based
tests covering the priority matrix and recovery semantics, plus updated docs, examples, and
conformance client.

* fix: dcr registration uses requested application type

* fix: error for invalid combination
This commit is contained in:
Jack Amadeo 2026-07-23 14:13:11 -04:00 • committed by GitHub
commit e903341136
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 625 additions and 219 deletions

View file

@ -4,7 +4,7 @@ use rmcp::{
service::RequestContext,
transport::{
AuthClient, AuthorizationManager, StreamableHttpClientTransport,
auth::{AuthorizationCallback, InMemoryCredentialStore, OAuthState},
auth::{AuthorizationCallback, AuthorizationRequest, InMemoryCredentialStore, OAuthState},
streamable_http_client::StreamableHttpClientTransportConfig,
},
};
@ -206,11 +206,10 @@ async fn perform_oauth_flow(
// Discover + register + get auth URL
oauth
.start_authorization_with_metadata_url(
&[],
REDIRECT_URI,
Some("conformance-client"),
Some(CIMD_CLIENT_METADATA_URL),
.start_authorization(
AuthorizationRequest::new(REDIRECT_URI)
.with_client_name("conformance-client")
.with_client_metadata_url(CIMD_CLIENT_METADATA_URL),
)
.await?;
@ -255,10 +254,12 @@ async fn perform_oauth_flow_preregistered(
) -> anyhow::Result<AuthClient<reqwest::Client>> {
let mut oauth = OAuthState::new(server_url, None).await?;
let config = rmcp::transport::auth::OAuthClientConfig::new(client_id, REDIRECT_URI)
.with_client_secret(client_secret);
oauth
.start_authorization_with_preregistered_client(config)
.start_authorization(
AuthorizationRequest::new(REDIRECT_URI)
.with_preregistered_client(client_id)
.with_client_secret(client_secret),
)
.await?;
let auth_url = oauth.get_authorization_url().await?;
@ -323,11 +324,11 @@ async fn run_auth_scope_step_up_client(
) -> anyhow::Result<()> {
let mut oauth = OAuthState::new(server_url, None).await?;
oauth
.start_authorization_with_metadata_url(
SCOPE_STEP_UP_INITIAL_SCOPES,
REDIRECT_URI,
Some("conformance-client"),
Some(CIMD_CLIENT_METADATA_URL),
.start_authorization(
AuthorizationRequest::new(REDIRECT_URI)
.with_scopes(SCOPE_STEP_UP_INITIAL_SCOPES.iter().copied())
.with_client_name("conformance-client")
.with_client_metadata_url(CIMD_CLIENT_METADATA_URL),
)
.await?;
@ -375,11 +376,11 @@ async fn run_auth_scope_step_up_client(
let mut oauth2 = OAuthState::new(server_url, None).await?;
oauth2
.start_authorization_with_metadata_url(
SCOPE_STEP_UP_ESCALATED_SCOPES,
REDIRECT_URI,
Some("conformance-client"),
Some(CIMD_CLIENT_METADATA_URL),
.start_authorization(
AuthorizationRequest::new(REDIRECT_URI)
.with_scopes(SCOPE_STEP_UP_ESCALATED_SCOPES.iter().copied())
.with_client_name("conformance-client")
.with_client_metadata_url(CIMD_CLIENT_METADATA_URL),
)
.await?;
let auth_url2 = oauth2.get_authorization_url().await?;
@ -427,11 +428,10 @@ async fn run_auth_scope_retry_limit_client(
loop {
let mut oauth = OAuthState::new(server_url, None).await?;
oauth
.start_authorization_with_metadata_url(
&[],
REDIRECT_URI,
Some("conformance-client"),
Some(CIMD_CLIENT_METADATA_URL),
.start_authorization(
AuthorizationRequest::new(REDIRECT_URI)
.with_client_name("conformance-client")
.with_client_metadata_url(CIMD_CLIENT_METADATA_URL),
)
.await?;
let auth_url = oauth.get_authorization_url().await?;

View file

@ -99,11 +99,12 @@ pub mod auth;
pub use auth::JwtSigningAlgorithm;
#[cfg(feature = "auth")]
pub use auth::{
AuthClient, AuthError, AuthorizationManager, AuthorizationSession, AuthorizedHttpClient,
ClientCredentialsConfig, CredentialStore, EXTENSION_OAUTH_CLIENT_CREDENTIALS,
InMemoryCredentialStore, InMemoryStateStore, OAuthHttpClient, OAuthHttpClientError,
OAuthHttpClientFuture, OAuthHttpRedirectPolicy, OAuthHttpRequest, ScopeUpgradeConfig,
StateStore, StoredAuthorizationState, StoredCredentials, WWWAuthenticateParams,
AuthClient, AuthError, AuthorizationManager, AuthorizationRequest, AuthorizationSession,
AuthorizedHttpClient, ClientCredentialsConfig, CredentialStore,
EXTENSION_OAUTH_CLIENT_CREDENTIALS, InMemoryCredentialStore, InMemoryStateStore,
OAuthHttpClient, OAuthHttpClientError, OAuthHttpClientFuture, OAuthHttpRedirectPolicy,
OAuthHttpRequest, ScopeUpgradeConfig, StateStore, StoredAuthorizationState, StoredCredentials,
WWWAuthenticateParams,
};
// #[cfg(feature = "transport-ws")]

View file

@ -647,6 +647,129 @@ impl OAuthClientConfig {
}
}
/// Declarative description of the client identity material available for an
/// authorization flow.
///
/// The [MCP authorization specification](https://modelcontextprotocol.io/specification/draft/basic/authorization/client-registration)
/// recommends that clients obtain a client ID using the following priority
/// order. [`OAuthState::start_authorization`] and [`AuthorizationSession::new`]
/// apply it internally:
///
/// 1. Pre-registered client information
/// ([`with_preregistered_client`](Self::with_preregistered_client)), when
/// the client already holds a `client_id` issued out of band
/// 2. Client ID Metadata Documents (SEP-991,
/// [`with_client_metadata_url`](Self::with_client_metadata_url)), when the
/// authorization server advertises `client_id_metadata_document_supported`
/// 3. Dynamic Client Registration as a fallback, when the authorization server
/// advertises a `registration_endpoint`
///
/// Provide whichever identity material the client has available; the SDK
/// selects the highest-priority mechanism the server supports.
///
/// ```rust,ignore
/// let request = AuthorizationRequest::new("http://localhost:8080/callback")
/// // Omit `with_scopes` to let the SDK auto-select scopes from server metadata.
/// // Used when the server supports CIMD and no pre-registered client is set.
/// .with_client_metadata_url("https://example.com/client-metadata.json")
/// // used for dynamic client registration as a last resort
/// .with_client_name("My MCP Client");
/// oauth_state.start_authorization(request).await?;
/// ```
#[derive(Debug, Clone)]
#[non_exhaustive]
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.
pub scopes: Vec<String>,
/// Human-readable client name, used for Dynamic Client Registration.
pub client_name: Option<String>,
/// Pre-registered client ID obtained from the authorization server out of
/// band. When set, registration is skipped entirely.
pub client_id: Option<String>,
/// Client secret paired with the pre-registered [`client_id`](Self::client_id).
pub client_secret: Option<String>,
/// HTTPS URL of a Client ID Metadata Document (SEP-991). Used when the
/// authorization server advertises `client_id_metadata_document_supported`
/// and no pre-registered client is configured.
pub client_metadata_url: Option<String>,
/// OIDC Dynamic Client Registration `application_type` (SEP-837),
/// e.g. `"native"` or `"web"`.
pub application_type: Option<String>,
}
impl AuthorizationRequest {
/// Create a request for the given redirect URI. With no further identity
/// material, authorization falls back to Dynamic Client Registration.
pub fn new(redirect_uri: impl Into<String>) -> Self {
Self {
redirect_uri: redirect_uri.into(),
scopes: Vec::new(),
client_name: None,
client_id: None,
client_secret: None,
client_metadata_url: None,
application_type: None,
}
}
/// Set the scopes to request. When not set, the SDK auto-selects scopes
/// using its normal scope-selection policy.
pub fn with_scopes<I, S>(mut self, scopes: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.scopes = scopes.into_iter().map(Into::into).collect();
self
}
/// Set the client name used for Dynamic Client Registration.
pub fn with_client_name(mut self, client_name: impl Into<String>) -> Self {
self.client_name = Some(client_name.into());
self
}
/// Use a client ID that was pre-registered with the authorization server
/// out of band. This takes priority over every other mechanism.
///
/// Pair with [`with_client_secret`](Self::with_client_secret) for
/// confidential clients.
pub fn with_preregistered_client(mut self, client_id: impl Into<String>) -> Self {
self.client_id = Some(client_id.into());
self
}
/// Set the client secret paired with a pre-registered client ID.
///
/// Must be used together with
/// [`with_preregistered_client`](Self::with_preregistered_client);
/// authorization fails with [`AuthError::RegistrationFailed`] if a secret
/// is provided without a client ID.
pub fn with_client_secret(mut self, client_secret: impl Into<String>) -> Self {
self.client_secret = Some(client_secret.into());
self
}
/// Set the HTTPS URL of a Client ID Metadata Document (SEP-991). Used when
/// the authorization server advertises support and no pre-registered
/// client is configured.
pub fn with_client_metadata_url(mut self, client_metadata_url: impl Into<String>) -> Self {
self.client_metadata_url = Some(client_metadata_url.into());
self
}
/// Set the OIDC Dynamic Client Registration `application_type` (SEP-837),
/// e.g. `"native"` or `"web"`.
pub fn with_application_type(mut self, application_type: impl Into<String>) -> Self {
self.application_type = Some(application_type.into());
self
}
}
// add type aliases for oauth2 types
type OAuthErrorResponse = oauth2::StandardErrorResponse<oauth2::basic::BasicErrorResponseType>;
@ -2932,16 +3055,56 @@ pub struct AuthorizationSession {
}
impl AuthorizationSession {
/// create new authorization session
/// Create a new authorization session, selecting a client registration
/// mechanism per the [MCP authorization specification](https://modelcontextprotocol.io/specification/draft/basic/authorization/client-registration)
/// priority order:
///
/// 1. Pre-registered client information
/// ([`AuthorizationRequest::with_preregistered_client`]), when available
/// 2. Client ID Metadata Documents
/// ([`AuthorizationRequest::with_client_metadata_url`]), when the
/// authorization server advertises `client_id_metadata_document_supported`
/// 3. Dynamic Client Registration, when the authorization server
/// advertises a `registration_endpoint`
///
/// 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.
///
/// On failure, the manager is returned alongside the error so callers can
/// retry without losing the original configuration and stores.
pub async fn new(
mut auth_manager: AuthorizationManager,
scopes: &[&str],
redirect_uri: &str,
client_name: Option<&str>,
client_metadata_url: Option<&str>,
) -> Result<Self, AuthError> {
let metadata = auth_manager.metadata.as_ref();
let supports_url_based_client_id = metadata
mut request: AuthorizationRequest,
) -> Result<Self, (AuthorizationManager, AuthError)> {
if request.client_secret.is_some() && request.client_id.is_none() {
return Err((
auth_manager,
AuthError::RegistrationFailed(
"client_secret was provided without a pre-registered client_id; \
pair with_client_secret with with_preregistered_client"
.to_string(),
),
));
}
if request.scopes.is_empty() {
request.scopes = auth_manager.select_scopes(None, &[]);
} else {
auth_manager.add_offline_access_if_supported(&mut request.scopes);
}
if request.application_type.is_some() {
auth_manager.application_type = request.application_type.clone();
}
let redirect_uri = request.redirect_uri.clone();
let scopes = request.scopes.clone();
let scope_refs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect();
let supports_url_based_client_id = auth_manager
.metadata
.as_ref()
.and_then(|m| {
m.additional_fields
.get("client_id_metadata_document_supported")
@ -2949,80 +3112,74 @@ impl AuthorizationSession {
.and_then(|v| v.as_bool())
.unwrap_or(false);
let config = if supports_url_based_client_id {
if let Some(client_metadata_url) = client_metadata_url {
if !is_https_url(client_metadata_url) {
return Err(AuthError::RegistrationFailed(format!(
// 1. pre-registered client information takes priority over everything else
let config = if let Some(client_id) = &request.client_id {
OAuthClientConfig {
client_id: client_id.clone(),
client_secret: request.client_secret.clone(),
scopes: scopes.clone(),
redirect_uri: redirect_uri.clone(),
application_type: request.application_type.clone(),
}
// 2. CIMD (SEP-991), when the server advertises support and the client hosts a metadata document
} else if let Some(client_metadata_url) = request.client_metadata_url.as_deref()
&& supports_url_based_client_id
{
if !is_https_url(client_metadata_url) {
return Err((
auth_manager,
AuthError::RegistrationFailed(format!(
"client_metadata_url must be a valid HTTPS URL with a non-root pathname, got: {}",
client_metadata_url
)));
}
// SEP-991: URL-based Client IDs - use URL as client_id directly.
// SEP-837: match the hosted client-metadata.json application_type ("native")
OAuthClientConfig {
client_id: client_metadata_url.to_string(),
client_secret: None,
scopes: scopes.iter().map(|s| s.to_string()).collect(),
redirect_uri: redirect_uri.to_string(),
application_type: Some(DEFAULT_APPLICATION_TYPE.to_string()),
}
} else {
// Fallback to dynamic registration
auth_manager
.register_client(client_name.unwrap_or("MCP Client"), redirect_uri, scopes)
.await
.map_err(|e| {
AuthError::RegistrationFailed(format!("Dynamic registration failed: {}", e))
})?
)),
));
}
// SEP-991: URL-based Client IDs - use URL as client_id directly.
// SEP-837: match the hosted client-metadata.json application_type ("native")
OAuthClientConfig {
client_id: client_metadata_url.to_string(),
client_secret: None,
scopes: scopes.clone(),
redirect_uri: redirect_uri.clone(),
application_type: Some(
request
.application_type
.clone()
.unwrap_or_else(|| DEFAULT_APPLICATION_TYPE.to_string()),
),
}
// 3. fall back to dynamic client registration
} else {
// Fallback to dynamic registration
match auth_manager
.register_client(client_name.unwrap_or("MCP Client"), redirect_uri, scopes)
.register_client(
request.client_name.as_deref().unwrap_or("MCP Client"),
&redirect_uri,
&scope_refs,
)
.await
{
Ok(config) => config,
Err(e) => {
return Err(AuthError::RegistrationFailed(format!(
"Dynamic registration failed: {}",
e
)));
return Err((
auth_manager,
AuthError::RegistrationFailed(format!(
"Dynamic registration failed: {}",
e
)),
));
}
}
};
// reset client config
auth_manager.configure_client(config)?;
let auth_url = auth_manager.get_authorization_url(scopes).await?;
Ok(Self {
auth_manager,
auth_url,
redirect_uri: redirect_uri.to_string(),
})
}
/// create a session using pre-registered client credentials, skipping
/// dynamic client registration and URL-based client IDs.
///
/// The manager must already have discovered authorization server metadata.
///
/// On failure, the manager is returned alongside the error so callers can
/// retry without losing the original configuration and stores.
pub async fn with_preregistered_client(
mut auth_manager: AuthorizationManager,
config: OAuthClientConfig,
) -> Result<Self, (AuthorizationManager, AuthError)> {
let redirect_uri = config.redirect_uri.clone();
let scopes = config.scopes.clone();
if let Err(e) = auth_manager.configure_client(config) {
return Err((auth_manager, e));
}
let scope_refs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect();
let auth_url = match auth_manager.get_authorization_url(&scope_refs).await {
Ok(url) => url,
Err(e) => return Err((auth_manager, e)),
};
Ok(Self {
auth_manager,
auth_url,
@ -3251,66 +3408,28 @@ impl OAuthState {
}
}
/// start authorization
/// Start authorization.
///
/// Selects a client registration mechanism from the identity material in
/// `request`, following the [MCP authorization specification](https://modelcontextprotocol.io/specification/draft/basic/authorization/client-registration)
/// priority order:
///
/// 1. Pre-registered client information
/// ([`AuthorizationRequest::with_preregistered_client`]), when available
/// 2. Client ID Metadata Documents
/// ([`AuthorizationRequest::with_client_metadata_url`]), when the
/// authorization server advertises `client_id_metadata_document_supported`
/// 3. Dynamic Client Registration, when the authorization server
/// advertises a `registration_endpoint`
///
/// If `request.scopes` is empty, scopes are selected using the SDK's
/// normal scope-selection policy.
///
/// On failure, the state returns to `Unauthorized` so callers can retry
/// without losing the original configuration and stores.
pub async fn start_authorization(
&mut self,
scopes: &[&str],
redirect_uri: &str,
client_name: Option<&str>,
) -> Result<(), AuthError> {
self.start_authorization_with_metadata_url(scopes, redirect_uri, client_name, None)
.await
}
/// start authorization with optional client metadata URL (SEP-991)
pub async fn start_authorization_with_metadata_url(
&mut self,
scopes: &[&str],
redirect_uri: &str,
client_name: Option<&str>,
client_metadata_url: Option<&str>,
) -> Result<(), AuthError> {
let placeholder = self.placeholder().await?;
if let OAuthState::Unauthorized(mut manager) = std::mem::replace(self, placeholder) {
debug!("start discovery");
let metadata = manager.discover_metadata().await?;
manager.metadata = Some(metadata);
let selected_scopes: Vec<String> = if scopes.is_empty() {
manager.select_scopes(None, &[])
} else {
let mut s: Vec<String> = scopes.iter().map(|s| s.to_string()).collect();
manager.add_offline_access_if_supported(&mut s);
s
};
let scope_refs: Vec<&str> = selected_scopes.iter().map(|s| s.as_str()).collect();
debug!("start session");
let session = AuthorizationSession::new(
manager,
&scope_refs,
redirect_uri,
client_name,
client_metadata_url,
)
.await?;
*self = OAuthState::Session(session);
Ok(())
} else {
Err(AuthError::InternalError(
"Already in session state".to_string(),
))
}
}
/// start authorization using pre-registered client credentials,
/// skipping dynamic client registration.
///
/// Use this when the client was registered with the authorization server
/// out of band and already holds a `client_id` (and optionally a
/// `client_secret`). If `config.scopes` is empty, scopes are selected
/// using the SDK's normal scope-selection policy.
pub async fn start_authorization_with_preregistered_client(
&mut self,
mut config: OAuthClientConfig,
request: AuthorizationRequest,
) -> Result<(), AuthError> {
let placeholder = self.placeholder().await?;
let old = std::mem::replace(self, placeholder);
@ -3320,6 +3439,7 @@ impl OAuthState {
"Already in session state".to_string(),
));
};
debug!("start discovery");
let metadata = match manager.discover_metadata().await {
Ok(metadata) => metadata,
Err(e) => {
@ -3328,12 +3448,8 @@ impl OAuthState {
}
};
manager.metadata = Some(metadata);
if config.scopes.is_empty() {
config.scopes = manager.select_scopes(None, &[]);
} else {
manager.add_offline_access_if_supported(&mut config.scopes);
}
match AuthorizationSession::with_preregistered_client(manager, config).await {
debug!("start session");
match AuthorizationSession::new(manager, request).await {
Ok(session) => {
*self = OAuthState::Session(session);
Ok(())
@ -3547,9 +3663,10 @@ mod tests {
use super::{
AuthError, AuthorizationCallback, AuthorizationManager, AuthorizationMetadata,
CredentialStore, InMemoryCredentialStore, InMemoryStateStore, OAuthClientConfig,
OAuthHttpClient, OAuthHttpClientError, OAuthHttpClientFuture, OAuthHttpRedirectPolicy,
OAuthHttpRequest, ScopeUpgradeConfig, StateStore, StoredAuthorizationState, is_https_url,
AuthorizationRequest, AuthorizationSession, CredentialStore, InMemoryCredentialStore,
InMemoryStateStore, OAuthClientConfig, OAuthHttpClient, OAuthHttpClientError,
OAuthHttpClientFuture, OAuthHttpRedirectPolicy, OAuthHttpRequest, ScopeUpgradeConfig,
StateStore, StoredAuthorizationState, is_https_url,
};
use crate::transport::auth::VendorExtraTokenFields;
@ -4031,17 +4148,11 @@ mod tests {
.await
.unwrap();
let config = OAuthClientConfig {
client_id: "preregistered-client".to_string(),
client_secret: Some("secret".to_string()),
scopes: vec!["read".to_string()],
redirect_uri: "http://localhost:8080/callback".to_string(),
application_type: None,
};
state
.start_authorization_with_preregistered_client(config)
.await
.unwrap();
let request = AuthorizationRequest::new("http://localhost:8080/callback")
.with_preregistered_client("preregistered-client")
.with_client_secret("secret")
.with_scopes(["read"]);
state.start_authorization(request).await.unwrap();
// the registration endpoint was advertised but must not be called
let requests = client.requests();
@ -4059,30 +4170,25 @@ mod tests {
}
#[tokio::test]
async fn preregistered_client_selects_default_scopes_when_none_provided() {
async fn authorization_session_selects_default_scopes_when_none_provided() {
let client = RecordingOAuthHttpClient::with_responses(preregistered_discovery_responses());
let mut state = super::OAuthState::new_with_oauth_http_client(
let mut manager = AuthorizationManager::new_with_oauth_http_client(
"https://mcp.example.com/mcp",
Arc::new(client.clone()),
Arc::new(client),
)
.await
.unwrap();
manager.metadata = Some(manager.discover_metadata().await.unwrap());
let config = OAuthClientConfig {
client_id: "preregistered-client".to_string(),
client_secret: None,
scopes: Vec::new(),
redirect_uri: "http://localhost:8080/callback".to_string(),
application_type: None,
let request = AuthorizationRequest::new("http://localhost:8080/callback")
.with_preregistered_client("preregistered-client");
let session = match AuthorizationSession::new(manager, request).await {
Ok(session) => session,
Err((_, error)) => panic!("authorization session creation failed: {error}"),
};
state
.start_authorization_with_preregistered_client(config)
.await
.unwrap();
// empty config scopes fall back to the discovered scopes_supported
let auth_url = state.get_authorization_url().await.unwrap();
let query = auth_url_query(&auth_url);
// Empty request scopes fall back to the discovered scopes_supported.
let query = auth_url_query(&session.auth_url);
assert_eq!(query.get("scope").unwrap(), "read write offline_access");
}
@ -4096,17 +4202,10 @@ mod tests {
.await
.unwrap();
let config = OAuthClientConfig {
client_id: "preregistered-client".to_string(),
client_secret: None,
scopes: vec!["read".to_string()],
redirect_uri: "http://localhost:8080/callback".to_string(),
application_type: None,
};
state
.start_authorization_with_preregistered_client(config)
.await
.unwrap();
let request = AuthorizationRequest::new("http://localhost:8080/callback")
.with_preregistered_client("preregistered-client")
.with_scopes(["read"]);
state.start_authorization(request).await.unwrap();
// explicit scopes are preserved; offline_access is appended per SEP-2207
let auth_url = state.get_authorization_url().await.unwrap();
@ -4143,15 +4242,11 @@ mod tests {
.await
.unwrap();
let config = OAuthClientConfig {
client_id: "preregistered-client".to_string(),
client_secret: None,
scopes: vec!["read".to_string()],
redirect_uri: "http://localhost:8080/callback".to_string(),
application_type: None,
};
let request = AuthorizationRequest::new("http://localhost:8080/callback")
.with_preregistered_client("preregistered-client")
.with_scopes(["read"]);
let err = state
.start_authorization_with_preregistered_client(config.clone())
.start_authorization(request.clone())
.await
.unwrap_err();
assert!(!matches!(err, AuthError::InternalError(_)), "{err:?}");
@ -4166,10 +4261,273 @@ mod tests {
.lock()
.unwrap()
.extend(preregistered_discovery_responses());
state
.start_authorization_with_preregistered_client(config)
.await
state.start_authorization(request).await.unwrap();
assert!(matches!(state, super::OAuthState::Session(_)));
}
fn cimd_as_metadata_response() -> HttpResponse {
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",
"registration_endpoint": "https://auth.example.com/register",
"scopes_supported": ["read", "write", "offline_access"],
"client_id_metadata_document_supported": true
}),
)
}
/// discovery responses like [`preregistered_discovery_responses`] but the
/// authorization server advertises CIMD support.
fn cimd_discovery_responses() -> Vec<HttpResponse> {
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();
vec![
challenge,
http_response(
200,
serde_json::json!({
"resource": "https://mcp.example.com/mcp",
"authorization_servers": ["https://auth.example.com"]
}),
),
cimd_as_metadata_response(),
]
}
#[tokio::test]
async fn preregistered_client_takes_priority_over_cimd() {
// server supports CIMD and the request carries both pre-registered
// credentials and a client metadata URL: pre-registration wins
let client = RecordingOAuthHttpClient::with_responses(cimd_discovery_responses());
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_client_metadata_url("https://client.example.com/client-metadata.json")
.with_scopes(["read"]);
state.start_authorization(request).await.unwrap();
let requests = client.requests();
assert!(
requests
.iter()
.all(|request| !request.uri.contains("/register")),
"registration endpoint should not be called: {requests:?}"
);
let auth_url = state.get_authorization_url().await.unwrap();
let query = auth_url_query(&auth_url);
assert_eq!(query.get("client_id").unwrap(), "preregistered-client");
}
#[tokio::test]
async fn cimd_used_when_server_advertises_support() {
let client = RecordingOAuthHttpClient::with_responses(cimd_discovery_responses());
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_client_metadata_url("https://client.example.com/client-metadata.json")
.with_scopes(["read"]);
state.start_authorization(request).await.unwrap();
// CIMD takes priority over the advertised registration endpoint
let requests = client.requests();
assert!(
requests
.iter()
.all(|request| !request.uri.contains("/register")),
"registration endpoint should not be called: {requests:?}"
);
let auth_url = state.get_authorization_url().await.unwrap();
let query = auth_url_query(&auth_url);
assert_eq!(
query.get("client_id").unwrap(),
"https://client.example.com/client-metadata.json"
);
}
#[tokio::test]
async fn cimd_falls_back_to_dcr_when_server_lacks_support() {
// server does not advertise client_id_metadata_document_supported, so
// the client metadata URL is ignored and DCR is used instead
let mut responses = preregistered_discovery_responses();
responses.push(http_response(
201,
serde_json::json!({
"client_id": "dcr-client",
"redirect_uris": ["http://localhost:8080/callback"]
}),
));
let client = RecordingOAuthHttpClient::with_responses(responses);
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_client_metadata_url("https://client.example.com/client-metadata.json")
.with_scopes(["read"]);
state.start_authorization(request).await.unwrap();
let requests = client.requests();
assert!(
requests
.iter()
.any(|request| request.uri.contains("/register")),
"registration endpoint should be called: {requests:?}"
);
let auth_url = state.get_authorization_url().await.unwrap();
let query = auth_url_query(&auth_url);
assert_eq!(query.get("client_id").unwrap(), "dcr-client");
}
#[tokio::test]
async fn dcr_used_when_no_identity_material_is_provided() {
let mut responses = preregistered_discovery_responses();
responses.push(http_response(
201,
serde_json::json!({
"client_id": "dcr-client",
"redirect_uris": ["http://localhost:8080/callback"]
}),
));
let client = RecordingOAuthHttpClient::with_responses(responses);
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_client_name("test-client")
.with_scopes(["read"]);
state.start_authorization(request).await.unwrap();
let auth_url = state.get_authorization_url().await.unwrap();
let query = auth_url_query(&auth_url);
assert_eq!(query.get("client_id").unwrap(), "dcr-client");
assert!(matches!(state, super::OAuthState::Session(_)));
}
#[tokio::test]
async fn dcr_registration_uses_requested_application_type() {
let mut responses = preregistered_discovery_responses();
responses.push(http_response(
201,
serde_json::json!({
"client_id": "dcr-client",
"redirect_uris": ["http://localhost:8080/callback"]
}),
));
let client = RecordingOAuthHttpClient::with_responses(responses);
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_client_name("test-client")
.with_application_type("web")
.with_scopes(["read"]);
state.start_authorization(request).await.unwrap();
// SEP-837: the requested application_type must be sent in the DCR request
let requests = client.requests();
let registration = requests
.iter()
.find(|request| request.uri.contains("/register"))
.expect("registration endpoint should be called");
let body: serde_json::Value = serde_json::from_slice(&registration.body).unwrap();
assert_eq!(body.get("application_type").unwrap(), "web");
}
#[tokio::test]
async fn cimd_rejects_non_https_client_metadata_url_and_recovers_state() {
let client = RecordingOAuthHttpClient::with_responses(cimd_discovery_responses());
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_client_metadata_url("http://client.example.com/client-metadata.json")
.with_scopes(["read"]);
let err = state.start_authorization(request).await.unwrap_err();
assert!(matches!(err, AuthError::RegistrationFailed(_)), "{err:?}");
assert!(
matches!(state, super::OAuthState::Unauthorized(_)),
"state should return to Unauthorized after a registration failure"
);
}
#[tokio::test]
async fn dcr_recovers_unauthorized_state_after_registration_failure() {
// discovery succeeds, but the registration endpoint rejects the request
let mut responses = preregistered_discovery_responses();
responses.push(http_response(
400,
serde_json::json!({"error": "invalid_client_metadata"}),
));
let client = RecordingOAuthHttpClient::with_responses(responses);
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_client_name("test-client")
.with_scopes(["read"]);
let err = state
.start_authorization(request.clone())
.await
.unwrap_err();
assert!(matches!(err, AuthError::RegistrationFailed(_)), "{err:?}");
assert!(
matches!(state, super::OAuthState::Unauthorized(_)),
"state should return to Unauthorized after a registration failure"
);
// retrying with the same state succeeds once the server accepts
// registration (discovery runs again on retry)
{
let mut responses = client.responses.lock().unwrap();
responses.extend(preregistered_discovery_responses());
responses.push_back(http_response(
201,
serde_json::json!({
"client_id": "dcr-client",
"redirect_uris": ["http://localhost:8080/callback"]
}),
));
}
state.start_authorization(request).await.unwrap();
assert!(matches!(state, super::OAuthState::Session(_)));
}

View file

@ -110,14 +110,29 @@ transport.
### 3. Start authorization with OAuthState
The `OAuthState` state machine manages the full authorization lifecycle. When no
scopes are provided, the SDK automatically selects scopes from the server's
WWW-Authenticate header, Protected Resource Metadata, or AS metadata.
The `OAuthState` state machine manages the full authorization lifecycle.
`start_authorization` accepts an `AuthorizationRequest` describing the client
identity material you have available, and selects a client registration
mechanism following the [spec's priority order](https://modelcontextprotocol.io/specification/draft/basic/authorization/client-registration):
1. **Pre-registered client information** (`with_preregistered_client`), when
the client already holds a `client_id` issued out of band
2. **Client ID Metadata Documents** (SEP-991, `with_client_metadata_url`), when
the authorization server advertises `client_id_metadata_document_supported`
3. **Dynamic Client Registration**, as a fallback when the authorization server
advertises a `registration_endpoint`
When no scopes are provided, the SDK automatically selects scopes from the
server's WWW-Authenticate header, Protected Resource Metadata, or AS metadata.
```rust ignore
// start authorization - pass empty scopes to let the SDK auto-select
use rmcp::transport::auth::AuthorizationRequest;
// start authorization - pass no scopes to let the SDK auto-select
oauth_state
.start_authorization(&[], MCP_REDIRECT_URI, Some("My MCP Client"))
.start_authorization(
AuthorizationRequest::new(MCP_REDIRECT_URI).with_client_name("My MCP Client"),
)
.await
.context("Failed to start authorization")?;
```
@ -126,7 +141,40 @@ If you know the scopes you need, you can still pass them explicitly:
```rust ignore
oauth_state
.start_authorization(&["mcp", "profile"], MCP_REDIRECT_URI, Some("My MCP Client"))
.start_authorization(
AuthorizationRequest::new(MCP_REDIRECT_URI)
.with_scopes(["mcp", "profile"])
.with_client_name("My MCP Client"),
)
.await
.context("Failed to start authorization")?;
```
If the client hosts a Client ID Metadata Document (SEP-991), pass its URL; the
SDK uses it when the server supports CIMD and falls back to dynamic
registration otherwise:
```rust ignore
oauth_state
.start_authorization(
AuthorizationRequest::new(MCP_REDIRECT_URI)
.with_client_name("My MCP Client")
.with_client_metadata_url("https://example.com/client-metadata.json"),
)
.await
.context("Failed to start authorization")?;
```
If the client was registered with the authorization server out of band, provide
the pre-registered credentials; they take priority over every other mechanism:
```rust ignore
oauth_state
.start_authorization(
AuthorizationRequest::new(MCP_REDIRECT_URI)
.with_preregistered_client("my-client-id")
.with_client_secret("my-client-secret"),
)
.await
.context("Failed to start authorization")?;
```

View file

@ -12,7 +12,7 @@ use rmcp::{
model::ClientInfo,
transport::{
StreamableHttpClientTransport,
auth::{AuthClient, OAuthState},
auth::{AuthClient, AuthorizationRequest, OAuthState},
streamable_http_client::StreamableHttpClientTransportConfig,
},
};
@ -128,14 +128,13 @@ async fn main() -> Result<()> {
.await
.context("Failed to initialize oauth state machine")?;
// use CIMD (SEP-991) with client metadata URL.
// passing empty scopes lets the SDK auto-select from the server's
// passing no scopes lets the SDK auto-select from the server's
// WWW-Authenticate header, Protected Resource Metadata, or AS metadata.
oauth_state
.start_authorization_with_metadata_url(
&[],
MCP_REDIRECT_URI,
Some("Test MCP Client"),
Some(&client_metadata_url),
.start_authorization(
AuthorizationRequest::new(MCP_REDIRECT_URI)
.with_client_name("Test MCP Client")
.with_client_metadata_url(&client_metadata_url),
)
.await
.context("Failed to start authorization")?;