Skip to content

Commit ec4c0cc

Browse files
committed
feat: classify authorization-required errors
1 parent 9528801 commit ec4c0cc

5 files changed

Lines changed: 142 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: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,27 @@ impl DynamicTransportError {
272272
}
273273
}
274274

275+
pub(crate) fn is_authorization_required(&self) -> bool {
276+
let mut error = Some(self.error.as_ref() as &(dyn std::error::Error + 'static));
277+
while let Some(current) = error {
278+
#[cfg(feature = "auth")]
279+
if matches!(
280+
current.downcast_ref::<auth::AuthError>(),
281+
Some(auth::AuthError::AuthorizationRequired)
282+
) {
283+
return true;
284+
}
285+
286+
#[cfg(feature = "transport-streamable-http-client")]
287+
if current.is::<streamable_http_client::AuthRequiredError>() {
288+
return true;
289+
}
290+
291+
error = current.source();
292+
}
293+
false
294+
}
295+
275296
pub fn downcast<T: Transport<R> + 'static, R: ServiceRole>(self) -> Result<T::Error, Self> {
276297
if !self.is::<T, R>() {
277298
Err(self)
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
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 initialization_error(error: impl Error + Send + Sync + 'static) -> ClientInitializeError {
19+
ClientInitializeError::TransportError {
20+
error: DynamicTransportError::from_parts(
21+
"test transport",
22+
TypeId::of::<()>(),
23+
Box::new(error),
24+
),
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!(initialization_error(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!(initialization_error(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!(initialization_error(error).is_authorization_required());
49+
}
50+
51+
#[test]
52+
fn does_not_classify_unrelated_transport_errors() {
53+
let closed = TestHttpError::TransportChannelClosed;
54+
let refresh = TestHttpError::Auth(AuthError::TokenRefreshFailed("timeout".to_owned()));
55+
let scope = TestHttpError::InsufficientScope(InsufficientScopeError::new(
56+
"Bearer error=\"insufficient_scope\"".to_owned(),
57+
Some("admin".to_owned()),
58+
));
59+
60+
assert!(!initialization_error(closed).is_authorization_required());
61+
assert!(!initialization_error(refresh).is_authorization_required());
62+
assert!(!initialization_error(scope).is_authorization_required());
63+
}
64+
65+
#[test]
66+
fn does_not_classify_non_transport_initialization_errors() {
67+
assert!(!ClientInitializeError::Cancelled.is_authorization_required());
68+
assert!(
69+
!ClientInitializeError::ConnectionClosed("server closed the connection".to_owned())
70+
.is_authorization_required()
71+
);
72+
}
73+
74+
#[test]
75+
fn http_challenge_remains_available_as_an_error_source() {
76+
let error =
77+
TestHttpError::AuthRequired(AuthRequiredError::new("Bearer realm=\"mcp\"".to_owned()));
78+
79+
let source = error.source().expect("auth challenge should be a source");
80+
let challenge = source
81+
.downcast_ref::<AuthRequiredError>()
82+
.expect("source should retain the challenge type");
83+
84+
assert_eq!(challenge.www_authenticate_header, "Bearer realm=\"mcp\"");
85+
}

docs/OAUTH_SUPPORT.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,26 @@ let client_service = ClientInfo::default();
254254
let client = client_service.serve(transport).await?;
255255
```
256256

257+
If initialization reports that authorization is required, return to the
258+
application's authorization flow:
259+
260+
```rust ignore
261+
let client = match client_service.serve(transport).await {
262+
Ok(client) => client,
263+
Err(error) if error.is_authorization_required() => {
264+
// Prompt the user and start the application's authorization flow again.
265+
return Err(error.into());
266+
}
267+
Err(error) => return Err(error.into()),
268+
};
269+
```
270+
271+
The predicate covers both missing or expired local OAuth authorization and an
272+
HTTP 401 challenge from the MCP server. Other failures, including transient
273+
token-refresh errors and insufficient scope, return `false`. The original error
274+
is preserved for logging or more detailed handling; the SDK does not start an
275+
authorization flow automatically.
276+
257277
### 6. Handle scope upgrades
258278

259279
If a server returns 403 with `insufficient_scope`, you can request a scope

0 commit comments

Comments
 (0)