diff --git a/crates/rmcp/Cargo.toml b/crates/rmcp/Cargo.toml index 93f98e428..ef763e2ea 100644 --- a/crates/rmcp/Cargo.toml +++ b/crates/rmcp/Cargo.toml @@ -385,6 +385,11 @@ name = "test_client_credentials" required-features = ["auth"] path = "tests/test_client_credentials.rs" +[[test]] +name = "test_auth_error_classification" +required-features = ["auth", "client", "transport-streamable-http-client"] +path = "tests/test_auth_error_classification.rs" + [[test]] name = "test_unix_socket_transport" required-features = [ diff --git a/crates/rmcp/src/service/client.rs b/crates/rmcp/src/service/client.rs index b23a1496d..6e750f5ac 100644 --- a/crates/rmcp/src/service/client.rs +++ b/crates/rmcp/src/service/client.rs @@ -111,6 +111,17 @@ impl ClientInitializeError { } None } + + /// Returns whether client initialization failed because authorization is required. + /// + /// This covers both missing or expired local OAuth authorization and an HTTP + /// authorization challenge from the MCP server. + pub fn is_authorization_required(&self) -> bool { + matches!( + self, + Self::TransportError { error, .. } if error.is_authorization_required() + ) + } } /// Helper function to get the next message from the stream diff --git a/crates/rmcp/src/transport.rs b/crates/rmcp/src/transport.rs index 74a13945b..06fde8e51 100644 --- a/crates/rmcp/src/transport.rs +++ b/crates/rmcp/src/transport.rs @@ -272,6 +272,27 @@ impl DynamicTransportError { } } + pub(crate) fn is_authorization_required(&self) -> bool { + let mut error = Some(self.error.as_ref() as &(dyn std::error::Error + 'static)); + while let Some(current) = error { + #[cfg(feature = "auth")] + if matches!( + current.downcast_ref::(), + Some(auth::AuthError::AuthorizationRequired) + ) { + return true; + } + + #[cfg(feature = "transport-streamable-http-client")] + if current.is::() { + return true; + } + + error = current.source(); + } + false + } + pub fn downcast + 'static, R: ServiceRole>(self) -> Result { if !self.is::() { Err(self) diff --git a/crates/rmcp/tests/test_auth_error_classification.rs b/crates/rmcp/tests/test_auth_error_classification.rs new file mode 100644 index 000000000..99b050810 --- /dev/null +++ b/crates/rmcp/tests/test_auth_error_classification.rs @@ -0,0 +1,85 @@ +use std::{any::TypeId, error::Error}; + +use rmcp::{ + service::ClientInitializeError, + transport::{ + AuthError, DynamicTransportError, + streamable_http_client::{AuthRequiredError, InsufficientScopeError, StreamableHttpError}, + }, +}; +use thiserror::Error; + +type TestHttpError = StreamableHttpError; + +#[derive(Debug, Error)] +#[error("outer transport wrapper")] +struct OuterError(#[source] TestHttpError); + +fn initialization_error(error: impl Error + Send + Sync + 'static) -> ClientInitializeError { + ClientInitializeError::TransportError { + error: DynamicTransportError::from_parts( + "test transport", + TypeId::of::<()>(), + Box::new(error), + ), + context: "initialize".into(), + } +} + +#[test] +fn classifies_local_authorization_required() { + let error = TestHttpError::Auth(AuthError::AuthorizationRequired); + + assert!(initialization_error(error).is_authorization_required()); +} + +#[test] +fn classifies_http_authorization_challenge() { + let error = + TestHttpError::AuthRequired(AuthRequiredError::new("Bearer realm=\"mcp\"".to_owned())); + + assert!(initialization_error(error).is_authorization_required()); +} + +#[test] +fn classifies_authorization_required_through_multiple_sources() { + let error = OuterError(TestHttpError::Auth(AuthError::AuthorizationRequired)); + + assert!(initialization_error(error).is_authorization_required()); +} + +#[test] +fn does_not_classify_unrelated_transport_errors() { + let closed = TestHttpError::TransportChannelClosed; + let refresh = TestHttpError::Auth(AuthError::TokenRefreshFailed("timeout".to_owned())); + let scope = TestHttpError::InsufficientScope(InsufficientScopeError::new( + "Bearer error=\"insufficient_scope\"".to_owned(), + Some("admin".to_owned()), + )); + + assert!(!initialization_error(closed).is_authorization_required()); + assert!(!initialization_error(refresh).is_authorization_required()); + assert!(!initialization_error(scope).is_authorization_required()); +} + +#[test] +fn does_not_classify_non_transport_initialization_errors() { + assert!(!ClientInitializeError::Cancelled.is_authorization_required()); + assert!( + !ClientInitializeError::ConnectionClosed("server closed the connection".to_owned()) + .is_authorization_required() + ); +} + +#[test] +fn http_challenge_remains_available_as_an_error_source() { + let error = + TestHttpError::AuthRequired(AuthRequiredError::new("Bearer realm=\"mcp\"".to_owned())); + + let source = error.source().expect("auth challenge should be a source"); + let challenge = source + .downcast_ref::() + .expect("source should retain the challenge type"); + + assert_eq!(challenge.www_authenticate_header, "Bearer realm=\"mcp\""); +} diff --git a/docs/OAUTH_SUPPORT.md b/docs/OAUTH_SUPPORT.md index 1d09d7402..247275f2f 100644 --- a/docs/OAUTH_SUPPORT.md +++ b/docs/OAUTH_SUPPORT.md @@ -254,6 +254,26 @@ let client_service = ClientInfo::default(); let client = client_service.serve(transport).await?; ``` +If initialization reports that authorization is required, return to the +application's authorization flow: + +```rust ignore +let client = match client_service.serve(transport).await { + Ok(client) => client, + Err(error) if error.is_authorization_required() => { + // Prompt the user and start the application's authorization flow again. + return Err(error.into()); + } + Err(error) => return Err(error.into()), +}; +``` + +The predicate covers both missing or expired local OAuth authorization and an +HTTP 401 challenge from the MCP server. Other failures, including transient +token-refresh errors and insufficient scope, return `false`. The original error +is preserved for logging or more detailed handling; the SDK does not start an +authorization flow automatically. + ### 6. Handle scope upgrades If a server returns 403 with `insufficient_scope`, you can request a scope