Skip to content

Commit eb3511d

Browse files
CodeWhale Botclaude
andcommitted
chore(deps): bump rmcp from 2.2.0 to 3.2.0 and port the OAuth transport
Supersedes the red Dependabot PR #5877. rmcp 3.2 reshaped `transport::auth`; this ports our client and the in-process OAuth mock to the new contract without changing any product guarantee. What changed in rmcp, and what we adapted to: 1. `AuthorizationManager::discover_metadata()` is now `resolve_metadata()`, returning `AuthorizationMetadataResolution { metadata, source }`. The discovery order (RFC 9728 protected resource metadata, then RFC 8414 / OIDC, then legacy synthesized endpoints) and the legacy fallback are unchanged, so the two call sites just take `.metadata`. 2. `OAuthState::start_authorization(scopes, redirect_uri, client_name)` is now a single `AuthorizationRequest` builder. It also introduces a client-identity priority order (pre-registered client ID, then a Client ID Metadata Document, then Dynamic Client Registration); we supply neither of the first two, so we still land on DCR exactly as before. Empty scopes now mean "let the SDK select from the WWW-Authenticate challenge / PRM / AS metadata" rather than "request none" — strictly better, and our explicit scopes still win. 3. Discovery now validates the `issuer` in the fetched authorization server metadata against the discovery URL (RFC 8414 / OIDC) and fails with `AuthError::AuthorizationServerMismatch` instead of silently accepting it. Our in-process mock answered *every* `/.well-known/oauth-authorization-server*` path with one document whose `issuer` is the origin root, including rmcp's first candidate `/.well-known/oauth-authorization-server/mcp`, whose issuer must be `<origin>/mcp`. The mock now answers only the canonical path, which is what a real authorization server at the origin root does. That is a conformance fix to a test double, not a product behavior change: every assertion in the eight tests is untouched. 4. `AuthError` split the refresh outcomes: `TokenRefreshFailed` is now the retryable case and the new `TokenRefreshRejected` carries a definitive `invalid_grant`; separately, `refresh_token()` returns `AuthError::AuthorizationRequired` where 2.2 returned `TokenRefreshFailed("No refresh token available")`. The `invalid_grant` text still reaches us intact, so the dead-grant guarantees hold unchanged, but "OAuth authorization required" — a credential with no usable grant left — was classified as a plain transport failure on every surface. `error_text_looks_auth_required` now matches rmcp's full phrase, so that case flips the server to `auth required` and offers the login tool like every other unrecoverable credential. Not adapted because nothing depended on it: `StoredCredentials` gained an `issuer` field and `initialize_from_store` now discards tokens when the authorization server changes; `CredentialStore` gained an optional `acquire_refresh_guard`; the RFC 8707 `resource` parameter on refreshes now prefers the discovered PRM resource indicator and falls back to the base URL, which is what 2.2 always sent. We keep our own credential store, so rmcp's in-memory default is untouched by any of these. Verified locally: cargo fmt clean cargo check -p codewhale-tui clean RUST_MIN_STACK=16777216 cargo test -p codewhale-tui --lib -- mcp:: 230 passed; 0 failed; 1 ignored (was 222 passed; 8 failed) RUST_MIN_STACK=16777216 cargo test -p codewhale-tui --lib 11717 passed; 0 failed; 13 ignored Two earlier full-suite runs each tripped one different pre-existing parallel-execution flake in a module this change does not touch (`model_inventory::...ollama_default_prefers_live_local_tags...`, then `remote_control::...distinct_recovery_turn_ids`); both pass alone and the run above is clean. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0188XYyJaw9Mh9uSrqQBoqhm Signed-off-by: CodeWhale Bot <bot@codewhale.net>
1 parent 41fecf8 commit eb3511d

4 files changed

Lines changed: 45 additions & 12 deletions

File tree

Cargo.lock

Lines changed: 5 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/tui/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ ratatui = { version = "=0.30.2", features = ["unstable-rendered-line-info"] }
6262
regex = "1.11"
6363
reqwest = { workspace = true, features = ["blocking", "stream", "form", "http2"] }
6464
rusqlite.workspace = true
65-
rmcp = { version = "2.2.0", default-features = false, features = ["auth", "client"] }
65+
rmcp = { version = "3.2.0", default-features = false, features = ["auth", "client"] }
6666
rustls.workspace = true
6767
qrcode = { version = "0.14", default-features = false }
6868
similar = { version = "3", features = ["unicode"] }

crates/tui/src/mcp/oauth.rs

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ use reqwest::Url;
1010
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
1111
use rmcp::transport::AuthorizationManager;
1212
use rmcp::transport::AuthorizationSession;
13-
use rmcp::transport::auth::{AuthError, OAuthClientConfig, OAuthState, OAuthTokenResponse};
13+
use rmcp::transport::auth::{
14+
AuthError, AuthorizationRequest, OAuthClientConfig, OAuthState, OAuthTokenResponse,
15+
};
1416
use serde::{Deserialize, Serialize};
1517
use sha2::{Digest, Sha256};
1618
use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -76,6 +78,14 @@ pub fn error_text_looks_auth_required(text: &str) -> bool {
7678
|| text.contains("unauthorized")
7779
|| text.contains("authentication_required")
7880
|| text.contains("invalid_grant")
81+
// rmcp 3.2 collapsed several unrecoverable refresh outcomes onto
82+
// `AuthError::AuthorizationRequired` (Display: "OAuth authorization
83+
// required") — a stored credential with no usable refresh grant, and
84+
// every refresh the server definitively rejected. In 2.2 those
85+
// arrived as `TokenRefreshFailed("No refresh token available")`, which
86+
// no surface recognised. The full phrase is matched so it stays
87+
// anchored to rmcp's own wording.
88+
|| text.contains("oauth authorization required")
7989
|| text.contains("◆ auth required")
8090
|| text.contains("requires oauth login")
8191
|| text.contains("requires oauth authentication")
@@ -611,9 +621,9 @@ async fn discover_streamable_http_oauth_with_headers(
611621
.context("building MCP OAuth discovery client")?;
612622
let mut manager = AuthorizationManager::new(url).await?;
613623
manager.with_client(client)?;
614-
match manager.discover_metadata().await {
615-
Ok(metadata) => Ok(Some(McpOAuthDiscovery {
616-
scopes_supported: normalize_scopes(metadata.scopes_supported),
624+
match manager.resolve_metadata().await {
625+
Ok(resolution) => Ok(Some(McpOAuthDiscovery {
626+
scopes_supported: normalize_scopes(resolution.metadata.scopes_supported),
617627
})),
618628
Err(AuthError::NoAuthorizationSupport) => Ok(None),
619629
Err(err) => Err(err.into()),
@@ -1344,14 +1354,18 @@ async fn start_authorization(
13441354
let Some(client_id) = oauth_client_id.filter(|client_id| !client_id.trim().is_empty()) else {
13451355
let mut oauth_state = OAuthState::new(server_url, Some(client)).await?;
13461356
oauth_state
1347-
.start_authorization(scopes, redirect_uri, Some("Codewhale"))
1357+
.start_authorization(
1358+
AuthorizationRequest::new(redirect_uri)
1359+
.with_scopes(scopes.iter().copied())
1360+
.with_client_name("Codewhale"),
1361+
)
13481362
.await?;
13491363
return Ok(oauth_state);
13501364
};
13511365

13521366
let mut manager = AuthorizationManager::new(server_url).await?;
13531367
manager.with_client(client)?;
1354-
let metadata = manager.discover_metadata().await?;
1368+
let metadata = manager.resolve_metadata().await?.metadata;
13551369
manager.set_metadata(metadata);
13561370
manager.configure_client(
13571371
OAuthClientConfig::new(client_id, redirect_uri)
@@ -1677,6 +1691,15 @@ mod tests {
16771691
assert!(!error_text_looks_auth_required(
16781692
"invalid_request: missing parameter"
16791693
));
1694+
// rmcp's own `AuthError::AuthorizationRequired` wording: a stored
1695+
// credential that can no longer be refreshed is a login, not a
1696+
// transport failure.
1697+
assert!(error_text_looks_auth_required(
1698+
"refreshing MCP OAuth token for server wiki: OAuth authorization required"
1699+
));
1700+
assert!(!error_text_looks_auth_required(
1701+
"authorization required for the requested file"
1702+
));
16801703
}
16811704

16821705
#[test]

crates/tui/src/mcp/tests.rs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5986,9 +5986,16 @@ impl OAuthMcpMock {
59865986
.to_ascii_lowercase()
59875987
.contains("authorization: bearer cw-test-access");
59885988

5989-
if method == "GET"
5990-
&& path_only.starts_with("/.well-known/oauth-authorization-server")
5991-
{
5989+
// RFC 8414: the authorization server lives at the origin
5990+
// root, so it publishes its metadata only at the canonical
5991+
// `/.well-known/oauth-authorization-server` and its
5992+
// `issuer` is the root origin. The path-insertion
5993+
// candidates rmcp probes first (`.../oauth-authorization-server/mcp`)
5994+
// belong to a *different* issuer and must 404 here: rmcp
5995+
// 3.2 validates the discovered `issuer` against the
5996+
// discovery URL and rejects metadata served at the wrong
5997+
// one.
5998+
if method == "GET" && path_only == "/.well-known/oauth-authorization-server" {
59925999
let metadata = serde_json::json!({
59936000
"issuer": format!("http://{addr}"),
59946001
"authorization_endpoint": format!("http://{addr}/authorize"),

0 commit comments

Comments
 (0)