feat: classify authorization-required errors (#1056)

This commit is contained in:
Dale Seo 2026-07-31 09:00:23 -04:00 • committed by GitHub
commit 65f05e9924
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 142 additions and 0 deletions

View file

@ -390,6 +390,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 = [

View file

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

View file

@ -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::<auth::AuthError>(),
Some(auth::AuthError::AuthorizationRequired)
) {
return true;
}
#[cfg(feature = "transport-streamable-http-client")]
if current.is::<streamable_http_client::AuthRequiredError>() {
return true;
}
error = current.source();
}
false
}
pub fn downcast<T: Transport<R> + 'static, R: ServiceRole>(self) -> Result<T::Error, Self> {
if !self.is::<T, R>() {
Err(self)

View file

@ -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<std::io::Error>;
#[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::<AuthRequiredError>()
.expect("source should retain the challenge type");
assert_eq!(challenge.www_authenticate_header, "Bearer realm=\"mcp\"");
}

View file

@ -256,6 +256,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