Skip to content

Commit add48e6

Browse files
committed
refactor: narrow auth classification API
1 parent 647855e commit add48e6

3 files changed

Lines changed: 22 additions & 43 deletions

File tree

crates/rmcp/src/transport.rs

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -272,12 +272,7 @@ 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 {
275+
pub(crate) fn is_authorization_required(&self) -> bool {
281276
let mut error = Some(self.error.as_ref() as &(dyn std::error::Error + 'static));
282277
while let Some(current) = error {
283278
#[cfg(feature = "auth")]

crates/rmcp/tests/test_auth_error_classification.rs

Lines changed: 12 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,13 @@ type TestHttpError = StreamableHttpError<std::io::Error>;
1515
#[error("outer transport wrapper")]
1616
struct OuterError(#[source] TestHttpError);
1717

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 {
18+
fn initialization_error(error: impl Error + Send + Sync + 'static) -> ClientInitializeError {
2319
ClientInitializeError::TransportError {
24-
error: dynamic(error),
20+
error: DynamicTransportError::from_parts(
21+
"test transport",
22+
TypeId::of::<()>(),
23+
Box::new(error),
24+
),
2525
context: "initialize".into(),
2626
}
2727
}
@@ -30,33 +30,22 @@ fn initialization_error(error: TestHttpError) -> ClientInitializeError {
3030
fn classifies_local_authorization_required() {
3131
let error = TestHttpError::Auth(AuthError::AuthorizationRequired);
3232

33-
assert!(dynamic(error).is_authorization_required());
33+
assert!(initialization_error(error).is_authorization_required());
3434
}
3535

3636
#[test]
3737
fn classifies_http_authorization_challenge() {
3838
let error =
3939
TestHttpError::AuthRequired(AuthRequiredError::new("Bearer realm=\"mcp\"".to_owned()));
4040

41-
assert!(dynamic(error).is_authorization_required());
41+
assert!(initialization_error(error).is_authorization_required());
4242
}
4343

4444
#[test]
4545
fn classifies_authorization_required_through_multiple_sources() {
4646
let error = OuterError(TestHttpError::Auth(AuthError::AuthorizationRequired));
4747

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());
48+
assert!(initialization_error(error).is_authorization_required());
6049
}
6150

6251
#[test]
@@ -68,9 +57,9 @@ fn does_not_classify_unrelated_transport_errors() {
6857
Some("admin".to_owned()),
6958
));
7059

71-
assert!(!dynamic(closed).is_authorization_required());
72-
assert!(!dynamic(refresh).is_authorization_required());
73-
assert!(!dynamic(scope).is_authorization_required());
60+
assert!(!initialization_error(closed).is_authorization_required());
61+
assert!(!initialization_error(refresh).is_authorization_required());
62+
assert!(!initialization_error(scope).is_authorization_required());
7463
}
7564

7665
#[test]
@@ -93,5 +82,4 @@ fn http_challenge_remains_available_as_an_error_source() {
9382
.expect("source should retain the challenge type");
9483

9584
assert_eq!(challenge.www_authenticate_header, "Bearer realm=\"mcp\"");
96-
assert_eq!(source.to_string(), "authorization required");
9785
}

docs/plans/2026-07-27-issue-795-auth-error-classification.md

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,15 @@ An authorized streamable HTTP client can currently report the same caller action
1616

1717
The variants should remain distinct internally. The HTTP form carries a `WWW-Authenticate` header, while the OAuth-manager form describes local credential state and is only available with the `auth` feature. Merging them would either lose challenge data or make the streamable HTTP transport depend on OAuth even when OAuth support is disabled.
1818

19-
The proposed additive API is:
19+
The public API addition is:
2020

2121
```rust
22-
impl DynamicTransportError {
23-
pub fn is_authorization_required(&self) -> bool;
24-
}
25-
2622
impl ClientInitializeError {
2723
pub fn is_authorization_required(&self) -> bool;
2824
}
2925
```
3026

31-
`DynamicTransportError` will inspect the standard `std::error::Error::source` chain. `ClientInitializeError` will delegate for its `TransportError` variant and return `false` for all other variants.
27+
Internally, `DynamicTransportError` inspects the standard `std::error::Error::source` chain. `ClientInitializeError` delegates for its `TransportError` variant and returns `false` for all other variants. The transport-level helper remains crate-internal until another public use case requires it.
3228

3329
`AuthRequiredError` must participate in that source chain. It should implement `std::error::Error`, and `StreamableHttpError::AuthRequired` should expose it as its source. `StreamableHttpError::Auth(AuthError)` already exposes `AuthError` through `thiserror`.
3430

@@ -51,7 +47,7 @@ impl ClientInitializeError {
5147

5248
| File | Role | Status |
5349
| --- | --- | --- |
54-
| `crates/rmcp/src/transport/streamable_http_client.rs` | Make the HTTP 401 detail error visible through the standard source chain | Complete |
50+
| `crates/rmcp/src/transport/streamable_http_client.rs` | Verify the HTTP 401 detail error is visible through the standard source chain | Complete upstream |
5551
| `crates/rmcp/src/transport.rs` | Add transport-independent authorization-required classification | Complete |
5652
| `crates/rmcp/src/service/client.rs` | Add the initialization-error convenience predicate | Complete |
5753
| `crates/rmcp/tests/test_auth_error_classification.rs` | Exercise the public API and guard false positives | Complete |
@@ -76,33 +72,33 @@ impl ClientInitializeError {
7672
- [x] Non-transport `ClientInitializeError` variants classify as false.
7773
- [x] The test requires `auth`, `client`, and `transport-streamable-http-client`, but not a concrete reqwest or Unix-socket backend.
7874

79-
### 2. Expose the HTTP challenge through the error source chain
75+
### 2. Verify the HTTP challenge source chain
8076

8177
**Files:** `crates/rmcp/src/transport/streamable_http_client.rs`
8278

83-
**Description:** Derive or implement `std::error::Error` for `AuthRequiredError` with a concise display message, then mark the value inside `StreamableHttpError::AuthRequired` as its source.
79+
**Description:** Confirm that `AuthRequiredError` implements `std::error::Error` and that `StreamableHttpError::AuthRequired` exposes it as its source. This prerequisite is already present on the latest `origin/main`.
8480

8581
**Acceptance criteria:**
8682

8783
- [x] `AuthRequiredError` remains constructible through `AuthRequiredError::new`.
8884
- [x] `www_authenticate_header` remains available and unchanged.
8985
- [x] `StreamableHttpError::source()` returns the contained `AuthRequiredError`.
90-
- [x] Error display does not print credentials or the challenge header.
86+
- [x] Existing error display behavior remains unchanged.
9187
- [x] No existing enum variants or fields are removed or renamed.
9288

9389
### 3. Add transport-independent classification
9490

9591
**Files:** `crates/rmcp/src/transport.rs`
9692

97-
**Description:** Add `DynamicTransportError::is_authorization_required()`. Walk the inner error and its sources, recognizing `AuthError::AuthorizationRequired` when `auth` is enabled and `AuthRequiredError` when the streamable HTTP client is enabled.
93+
**Description:** Add a crate-internal `DynamicTransportError::is_authorization_required()`. Walk the inner error and its sources, recognizing `AuthError::AuthorizationRequired` when `auth` is enabled and `AuthRequiredError` when the streamable HTTP client is enabled. Keep the transport-level classifier internal until another public use case requires it.
9894

9995
**Acceptance criteria:**
10096

101-
- [x] Callers do not need the transport type, backend error type, or a downcast.
97+
- [x] Client callers do not need the transport type, backend error type, or a downcast.
10298
- [x] Direct and multiply wrapped matching errors are recognized.
10399
- [x] Feature-disabled builds compile; feature-specific checks are guarded locally.
104100
- [x] Unknown custom transport errors return false.
105-
- [x] The method is documented as a classification predicate, not an authorization action.
101+
- [x] The helper name describes classification rather than an authorization action.
106102

107103
### 4. Add the initialization-error convenience API
108104

0 commit comments

Comments
 (0)