Skip to content

Commit be87ed4

Browse files
committed
feat: classify authorization-required client errors
Let clients detect re-authorization requirements without depending on a specific HTTP backend error type or discarding the original challenge details.
1 parent 125dbfd commit be87ed4

4 files changed

Lines changed: 139 additions & 0 deletions

File tree

crates/rmcp/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,11 @@ name = "test_client_credentials"
385385
required-features = ["auth"]
386386
path = "tests/test_client_credentials.rs"
387387

388+
[[test]]
389+
name = "test_auth_error_classification"
390+
required-features = ["auth", "client", "transport-streamable-http-client"]
391+
path = "tests/test_auth_error_classification.rs"
392+
388393
[[test]]
389394
name = "test_unix_socket_transport"
390395
required-features = [

crates/rmcp/src/service/client.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,17 @@ impl ClientInitializeError {
111111
}
112112
None
113113
}
114+
115+
/// Returns whether client initialization failed because authorization is required.
116+
///
117+
/// This covers both missing or expired local OAuth authorization and an HTTP
118+
/// authorization challenge from the MCP server.
119+
pub fn is_authorization_required(&self) -> bool {
120+
matches!(
121+
self,
122+
Self::TransportError { error, .. } if error.is_authorization_required()
123+
)
124+
}
114125
}
115126

116127
/// Helper function to get the next message from the stream

crates/rmcp/src/transport.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,32 @@ impl DynamicTransportError {
272272
}
273273
}
274274

275+
/// Returns whether this transport error means the client must authorize again.
276+
///
277+
/// This recognizes both an exhausted local OAuth authorization and an HTTP
278+
/// authorization challenge without requiring the caller to know or downcast
279+
/// the concrete transport error type.
280+
pub fn is_authorization_required(&self) -> bool {
281+
let mut error = Some(self.error.as_ref() as &(dyn std::error::Error + 'static));
282+
while let Some(current) = error {
283+
#[cfg(feature = "auth")]
284+
if matches!(
285+
current.downcast_ref::<auth::AuthError>(),
286+
Some(auth::AuthError::AuthorizationRequired)
287+
) {
288+
return true;
289+
}
290+
291+
#[cfg(feature = "transport-streamable-http-client")]
292+
if current.is::<streamable_http_client::AuthRequiredError>() {
293+
return true;
294+
}
295+
296+
error = current.source();
297+
}
298+
false
299+
}
300+
275301
pub fn downcast<T: Transport<R> + 'static, R: ServiceRole>(self) -> Result<T::Error, Self> {
276302
if !self.is::<T, R>() {
277303
Err(self)
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
use std::{any::TypeId, error::Error};
2+
3+
use rmcp::{
4+
service::ClientInitializeError,
5+
transport::{
6+
AuthError, DynamicTransportError,
7+
streamable_http_client::{AuthRequiredError, InsufficientScopeError, StreamableHttpError},
8+
},
9+
};
10+
use thiserror::Error;
11+
12+
type TestHttpError = StreamableHttpError<std::io::Error>;
13+
14+
#[derive(Debug, Error)]
15+
#[error("outer transport wrapper")]
16+
struct OuterError(#[source] TestHttpError);
17+
18+
fn dynamic(error: impl Error + Send + Sync + 'static) -> DynamicTransportError {
19+
DynamicTransportError::from_parts("test transport", TypeId::of::<()>(), Box::new(error))
20+
}
21+
22+
fn initialization_error(error: TestHttpError) -> ClientInitializeError {
23+
ClientInitializeError::TransportError {
24+
error: dynamic(error),
25+
context: "initialize".into(),
26+
}
27+
}
28+
29+
#[test]
30+
fn classifies_local_authorization_required() {
31+
let error = TestHttpError::Auth(AuthError::AuthorizationRequired);
32+
33+
assert!(dynamic(error).is_authorization_required());
34+
}
35+
36+
#[test]
37+
fn classifies_http_authorization_challenge() {
38+
let error =
39+
TestHttpError::AuthRequired(AuthRequiredError::new("Bearer realm=\"mcp\"".to_owned()));
40+
41+
assert!(dynamic(error).is_authorization_required());
42+
}
43+
44+
#[test]
45+
fn classifies_authorization_required_through_multiple_sources() {
46+
let error = OuterError(TestHttpError::Auth(AuthError::AuthorizationRequired));
47+
48+
assert!(dynamic(error).is_authorization_required());
49+
}
50+
51+
#[test]
52+
fn client_initialization_error_delegates_to_transport_classification() {
53+
let local = initialization_error(TestHttpError::Auth(AuthError::AuthorizationRequired));
54+
let challenge = initialization_error(TestHttpError::AuthRequired(AuthRequiredError::new(
55+
"Bearer realm=\"mcp\"".to_owned(),
56+
)));
57+
58+
assert!(local.is_authorization_required());
59+
assert!(challenge.is_authorization_required());
60+
}
61+
62+
#[test]
63+
fn does_not_classify_unrelated_transport_errors() {
64+
let closed = TestHttpError::TransportChannelClosed;
65+
let refresh = TestHttpError::Auth(AuthError::TokenRefreshFailed("timeout".to_owned()));
66+
let scope = TestHttpError::InsufficientScope(InsufficientScopeError::new(
67+
"Bearer error=\"insufficient_scope\"".to_owned(),
68+
Some("admin".to_owned()),
69+
));
70+
71+
assert!(!dynamic(closed).is_authorization_required());
72+
assert!(!dynamic(refresh).is_authorization_required());
73+
assert!(!dynamic(scope).is_authorization_required());
74+
}
75+
76+
#[test]
77+
fn does_not_classify_non_transport_initialization_errors() {
78+
assert!(!ClientInitializeError::Cancelled.is_authorization_required());
79+
assert!(
80+
!ClientInitializeError::ConnectionClosed("server closed the connection".to_owned())
81+
.is_authorization_required()
82+
);
83+
}
84+
85+
#[test]
86+
fn http_challenge_remains_available_as_an_error_source() {
87+
let error =
88+
TestHttpError::AuthRequired(AuthRequiredError::new("Bearer realm=\"mcp\"".to_owned()));
89+
90+
let source = error.source().expect("auth challenge should be a source");
91+
let challenge = source
92+
.downcast_ref::<AuthRequiredError>()
93+
.expect("source should retain the challenge type");
94+
95+
assert_eq!(challenge.www_authenticate_header, "Bearer realm=\"mcp\"");
96+
assert_eq!(source.to_string(), "authorization required");
97+
}

0 commit comments

Comments
 (0)