diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 24801393f74c..f9907887a4a3 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1933,6 +1933,7 @@ "McpServerOauthClientRegistration": { "enum": [ "auto", + "cimd", "dcr" ], "type": "string" @@ -1948,7 +1949,7 @@ "type": "null" } ], - "description": "Registration strategy for this login only; omission preserves automatic DCR." + "description": "Registration strategy for this login only; omission selects automatic discovery." }, "name": { "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 6ee99e1a2abd..7303eba621bb 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -13170,6 +13170,7 @@ "McpServerOauthClientRegistration": { "enum": [ "auto", + "cimd", "dcr" ], "type": "string" @@ -13215,7 +13216,7 @@ "type": "null" } ], - "description": "Registration strategy for this login only; omission preserves automatic DCR." + "description": "Registration strategy for this login only; omission selects automatic discovery." }, "name": { "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 5bd2fd6e2ea8..29ccdf0dd834 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -9500,6 +9500,7 @@ "McpServerOauthClientRegistration": { "enum": [ "auto", + "cimd", "dcr" ], "type": "string" @@ -9545,7 +9546,7 @@ "type": "null" } ], - "description": "Registration strategy for this login only; omission preserves automatic DCR." + "description": "Registration strategy for this login only; omission selects automatic discovery." }, "name": { "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/McpServerOauthLoginParams.json b/codex-rs/app-server-protocol/schema/json/v2/McpServerOauthLoginParams.json index 7e3c394bbe6c..387636e7d0d9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/McpServerOauthLoginParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/McpServerOauthLoginParams.json @@ -4,6 +4,7 @@ "McpServerOauthClientRegistration": { "enum": [ "auto", + "cimd", "dcr" ], "type": "string" @@ -19,7 +20,7 @@ "type": "null" } ], - "description": "Registration strategy for this login only; omission preserves automatic DCR." + "description": "Registration strategy for this login only; omission selects automatic discovery." }, "name": { "type": "string" diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index 72c9643dda4a..12a055d2e4ce 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index 15cac3939477..dc85ded9c5a1 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpServerOauthClientRegistration.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerOauthClientRegistration.ts index 28878a8fd4b5..052150bfcbe2 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/McpServerOauthClientRegistration.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerOauthClientRegistration.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type McpServerOauthClientRegistration = "auto" | "dcr"; +export type McpServerOauthClientRegistration = "auto" | "cimd" | "dcr"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpServerOauthLoginParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerOauthLoginParams.ts index 6f164446b8e7..3d3aebd43b84 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/McpServerOauthLoginParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerOauthLoginParams.ts @@ -5,6 +5,6 @@ import type { McpServerOauthClientRegistration } from "./McpServerOauthClientReg export type McpServerOauthLoginParams = { name: string, threadId?: string | null, /** - * Registration strategy for this login only; omission preserves automatic DCR. + * Registration strategy for this login only; omission selects automatic discovery. */ clientRegistration?: McpServerOauthClientRegistration | null, scopes?: Array | null, timeoutSecs?: bigint | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs b/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs index a949b021b64b..2f806c0c28b7 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs @@ -196,7 +196,7 @@ pub struct McpServerOauthLoginParams { pub name: String, #[ts(optional = nullable)] pub thread_id: Option, - /// Registration strategy for this login only; omission preserves automatic DCR. + /// Registration strategy for this login only; omission selects automatic discovery. #[ts(optional = nullable)] pub client_registration: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -213,6 +213,7 @@ pub struct McpServerOauthLoginParams { pub enum McpServerOauthClientRegistration { #[default] Auto, + Cimd, Dcr, } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 7176b062e8b6..d3467a581c4c 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -264,7 +264,7 @@ Example with notification opt-out: - `skills/config/write` — write user-level skill config by name or absolute path. - `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, install MCPs if any, and return the effective plugin auth policy plus any apps that still need auth. For remote installs, clients may include an optional `installAttemptId`; app-server forwards it unchanged as `install_attempt_id` in the backend POST body, while omission preserves the legacy empty-body request (**under development; do not call from production clients yet**). - `plugin/uninstall` — uninstall a local plugin by `pluginId` in `@` form by removing its cached files and clearing its user-level config entry, or uninstall a remote ChatGPT plugin by backend `pluginId` by forwarding the uninstall to the ChatGPT plugin backend and removing any downloaded remote-plugin cache (**under development; do not call from production clients yet**). -- `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; pass `threadId` to resolve servers from that thread's selected plugins and executor, optionally pass `clientRegistration` (`auto` or `dcr`) to select registration for this login only, and receive an `authorization_url` followed by `mcpServer/oauthLogin/completed` once the browser flow finishes. Omitting `clientRegistration` preserves automatic DCR; the override is never persisted in server configuration. +- `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; pass `threadId` to resolve servers from that thread's selected plugins and executor, optionally pass `clientRegistration` (`auto`, `cimd`, or `dcr`) to override client registration for this login only, and receive an `authorization_url` followed by `mcpServer/oauthLogin/completed` once the browser flow finishes. Omitting `clientRegistration` automatically discovers the authorization server's supported registration methods; the override is never persisted in server configuration. - `tool/requestUserInput` — prompt the user with 1–3 short questions for a tool call and return their answers (experimental). - `config/mcpServer/reload` — reload MCP server config from disk and queue a refresh for loaded threads (applied on each thread's next active turn); returns `{}`. Use this after editing `config.toml` without restarting the server. - `mcpServerStatus/list` — enumerate configured MCP servers with their tools, auth status, server info, owning `pluginId` (`null` for servers not contributed by a plugin), plus resources/resource templates for `full` detail; supports optional `threadId` and cursor+limit pagination. If `threadId` is omitted, the server reads from the latest global config directly. If `detail` is omitted, the server defaults to `full`. An `unknown` auth status means OAuth support could not be determined; `unsupported` means OAuth is known not to be supported. diff --git a/codex-rs/app-server/src/request_processors/mcp_processor.rs b/codex-rs/app-server/src/request_processors/mcp_processor.rs index 0b2dae286e13..4deaecb90432 100644 --- a/codex-rs/app-server/src/request_processors/mcp_processor.rs +++ b/codex-rs/app-server/src/request_processors/mcp_processor.rs @@ -124,6 +124,7 @@ impl McpRequestProcessor { } = params; let client_registration = match client_registration.unwrap_or_default() { McpServerOauthClientRegistration::Auto => McpOAuthClientRegistration::Auto, + McpServerOauthClientRegistration::Cimd => McpOAuthClientRegistration::Cimd, McpServerOauthClientRegistration::Dcr => McpOAuthClientRegistration::Dcr, }; diff --git a/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs b/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs index 86d39f1cdbdd..d1f45bbc5e50 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs @@ -3,16 +3,25 @@ use std::collections::BTreeMap; use std::collections::BTreeSet; use std::path::Path; use std::sync::Arc; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; use std::time::Duration; use anyhow::Result; use app_test_support::MockResponsesConfig; use app_test_support::TestAppServer; use app_test_support::create_mock_responses_server_sequence_unchecked; +use axum::Json; use axum::Router; +use axum::body::Bytes; +use axum::http::HeaderMap; +use axum::routing::get; +use axum::routing::post; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ListMcpServerStatusParams; use codex_app_server_protocol::ListMcpServerStatusResponse; +use codex_app_server_protocol::McpServerOauthLoginCompletedNotification; +use codex_app_server_protocol::McpServerOauthLoginResponse; use codex_app_server_protocol::McpServerStatusDetail; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ThreadStartParams; @@ -39,6 +48,7 @@ use rmcp::transport::streamable_http_server::session::local::LocalSessionManager use serde_json::json; use tempfile::TempDir; use tokio::net::TcpListener; +use tokio::sync::mpsc; use tokio::task::JoinHandle; use tokio::time::sleep; use tokio::time::timeout; @@ -80,6 +90,235 @@ fn assert_dynamic_status(response: &ListMcpServerStatusResponse, process_label: ); } +#[tokio::test] +async fn oauth_login_automatically_selects_callback_specific_cimd_without_metadata_issuer() +-> Result<()> { + let responses_server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let listener = TcpListener::bind("127.0.0.1:0").await?; + let base_url = format!("http://{}", listener.local_addr()?); + let metadata = json!({ + "authorization_endpoint": format!("{base_url}/authorize"), + "token_endpoint": format!("{base_url}/token"), + "registration_endpoint": format!("{base_url}/register"), + "client_id_metadata_document_supported": true, + "token_endpoint_auth_methods_supported": ["none"], + "response_types_supported": ["code"], + "code_challenge_methods_supported": ["S256"], + }); + let registrations = Arc::new(AtomicUsize::new(0)); + let registration_count = Arc::clone(®istrations); + let token_count = Arc::new(AtomicUsize::new(0)); + let (token_request_tx, mut token_request_rx) = mpsc::unbounded_channel(); + let (mcp_authorization_tx, mut mcp_authorization_rx) = mpsc::unbounded_channel(); + let tool_name = Arc::new("cimd".to_string()); + let mcp_service = StreamableHttpService::new( + move || { + Ok(McpStatusServer { + tool_name: Arc::clone(&tool_name), + }) + }, + Arc::new(LocalSessionManager::default()), + StreamableHttpServerConfig::default(), + ); + let mcp_router = + Router::new() + .nest_service("/mcp", mcp_service) + .layer(axum::middleware::from_fn( + move |request: axum::extract::Request, next: axum::middleware::Next| { + let mcp_authorization_tx = mcp_authorization_tx.clone(); + async move { + if let Some(authorization) = request + .headers() + .get(axum::http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + { + let _ = mcp_authorization_tx.send(authorization.to_string()); + } + next.run(request).await + } + }, + )); + let oauth_server = Router::new() + .route( + "/.well-known/oauth-authorization-server/mcp", + get(move || { + let metadata = metadata.clone(); + async move { Json(metadata) } + }), + ) + .route( + "/register", + post(move || { + let registrations = Arc::clone(®istration_count); + async move { + registrations.fetch_add(1, Ordering::SeqCst); + Json(json!({"client_id": "unexpected-dcr-client"})) + } + }), + ) + .route( + "/token", + post(move |headers: HeaderMap, body: Bytes| { + let token_request_tx = token_request_tx.clone(); + let token_count = Arc::clone(&token_count); + async move { + let _ = token_request_tx.send(( + String::from_utf8_lossy(&body).into_owned(), + headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .map(str::to_string), + )); + if token_count.fetch_add(1, Ordering::SeqCst) == 0 { + Json(json!({ + "access_token": "expired-cimd-access-token", + "token_type": "Bearer", + "expires_in": 0, + "refresh_token": "test-refresh-token", + })) + } else { + Json(json!({ + "access_token": "refreshed-cimd-access-token", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "test-refresh-token", + })) + } + } + }), + ) + .merge(mcp_router); + let oauth_server_handle = tokio::spawn(async move { + let _ = axum::serve(listener, oauth_server).await; + }); + + let codex_home = TempDir::new()?; + mock_responses_config(&responses_server.uri()) + .with_extra_config(&format!( + "mcp_oauth_credentials_store = \"file\"\n[mcp_servers.cimd]\nurl = \"{base_url}/mcp\"" + )) + .write(codex_home.path())?; + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .without_auto_env() + .build_initialized() + .await?; + + let request_id = app_server + .send_raw_request( + "mcpServer/oauth/login", + Some(json!({"name": "cimd", "timeoutSecs": 10})), + ) + .await?; + let response: McpServerOauthLoginResponse = + timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(request_id)).await??; + let authorization_url = reqwest::Url::parse(&response.authorization_url)?; + let parameters = authorization_url + .query_pairs() + .into_owned() + .collect::>(); + let redirect_uri = parameters["redirect_uri"].clone(); + let mut callback_url = reqwest::Url::parse(&redirect_uri)?; + let callback_id = callback_url + .path() + .strip_prefix("/callback/") + .expect("issuerless CIMD should use a resource-specific callback"); + let client_id = format!("https://chatgpt.com/oauth/codex/{callback_id}/client.json"); + assert_eq!(parameters.get("client_id"), Some(&client_id)); + assert_eq!( + parameters.get("code_challenge_method").map(String::as_str), + Some("S256") + ); + assert_eq!(registrations.load(Ordering::SeqCst), 0); + + callback_url + .query_pairs_mut() + .append_pair("code", "cimd-authorization-code") + .append_pair("state", ¶meters["state"]); + reqwest::Client::builder() + .no_proxy() + .build()? + .get(callback_url) + .send() + .await? + .error_for_status()?; + let (token_request, token_authorization) = + timeout(DEFAULT_READ_TIMEOUT, token_request_rx.recv()) + .await? + .expect("CIMD authorization should exchange its authorization code"); + let token_parameters = url::form_urlencoded::parse(token_request.as_bytes()) + .into_owned() + .collect::>(); + assert_eq!(token_parameters.get("client_id"), Some(&client_id)); + assert!(token_parameters.contains_key("code_verifier")); + assert_eq!(token_authorization, None); + + let completed: McpServerOauthLoginCompletedNotification = timeout( + DEFAULT_READ_TIMEOUT, + app_server.read_notification("mcpServer/oauthLogin/completed"), + ) + .await??; + assert_eq!( + completed, + McpServerOauthLoginCompletedNotification { + name: "cimd".to_string(), + thread_id: None, + success: true, + error: None, + } + ); + assert_eq!(registrations.load(Ordering::SeqCst), 0); + + let request_id = app_server + .send_raw_request("config/mcpServer/reload", /*params*/ None) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let _: ListMcpServerStatusResponse = app_server + .request(|request_id| ClientRequest::McpServerStatusList { + request_id, + params: ListMcpServerStatusParams { + cursor: None, + limit: None, + detail: Some(McpServerStatusDetail::Full), + thread_id: None, + }, + }) + .await?; + let (refresh_request, refresh_authorization) = + timeout(DEFAULT_READ_TIMEOUT, token_request_rx.recv()) + .await? + .expect("expired CIMD token should be refreshed"); + let refresh_parameters = url::form_urlencoded::parse(refresh_request.as_bytes()) + .into_owned() + .collect::>(); + assert_eq!( + refresh_parameters.get("grant_type").map(String::as_str), + Some("refresh_token") + ); + assert_eq!( + refresh_parameters.get("refresh_token").map(String::as_str), + Some("test-refresh-token") + ); + assert_eq!(refresh_parameters.get("client_id"), Some(&client_id)); + assert!(!refresh_parameters.contains_key("client_secret")); + assert_eq!(refresh_authorization, None); + assert_eq!( + timeout(DEFAULT_READ_TIMEOUT, mcp_authorization_rx.recv()) + .await? + .expect("MCP startup should use the refreshed token"), + "Bearer refreshed-cimd-access-token" + ); + assert_eq!(registrations.load(Ordering::SeqCst), 0); + + oauth_server_handle.abort(); + let _ = oauth_server_handle.await; + Ok(()) +} + #[tokio::test] async fn mcp_server_status_list_returns_raw_server_and_tool_names() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index d0e4c7cbf9a4..53cb603eaaa2 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -156,7 +156,7 @@ pub struct AddMcpStreamableHttpArgs { #[arg( long = "oauth-client-registration", value_enum, - value_name = "AUTO|DCR", + value_name = "AUTO|CIMD|DCR", requires = "url" )] pub oauth_client_registration: Option, @@ -169,6 +169,7 @@ pub struct AddMcpStreamableHttpArgs { #[derive(Debug, Clone, Copy, clap::ValueEnum)] pub enum McpOAuthClientRegistrationArg { Auto, + Cimd, Dcr, } @@ -176,6 +177,7 @@ impl From for McpOAuthClientRegistration { fn from(value: McpOAuthClientRegistrationArg) -> Self { match value { McpOAuthClientRegistrationArg::Auto => Self::Auto, + McpOAuthClientRegistrationArg::Cimd => Self::Cimd, McpOAuthClientRegistrationArg::Dcr => Self::Dcr, } } @@ -200,7 +202,7 @@ pub struct LoginArgs { #[arg( long = "oauth-client-registration", value_enum, - value_name = "AUTO|DCR" + value_name = "AUTO|CIMD|DCR" )] pub oauth_client_registration: Option, } diff --git a/codex-rs/rmcp-client/src/oauth_client_registration.rs b/codex-rs/rmcp-client/src/oauth_client_registration.rs index 894b43823286..2adb96eec3a6 100644 --- a/codex-rs/rmcp-client/src/oauth_client_registration.rs +++ b/codex-rs/rmcp-client/src/oauth_client_registration.rs @@ -1,17 +1,22 @@ use std::sync::Arc; use anyhow::Result; +use anyhow::bail; use rmcp::transport::AuthorizationManager; use rmcp::transport::AuthorizationRequest; +use rmcp::transport::AuthorizationSession; use rmcp::transport::auth::OAuthHttpClient; use rmcp::transport::auth::OAuthState; +use url::Url; /// OAuth client-registration strategy for one interactive HTTP MCP login. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum McpOAuthClientRegistration { - /// Preserve the current automatic Dynamic Client Registration flow. + /// Prefer a supported native CIMD and otherwise use advertised DCR. #[default] Auto, + /// Require a ChatGPT-hosted Codex public native Client ID Metadata Document. + Cimd, /// Require the authorization server's Dynamic Client Registration endpoint. Dcr, } @@ -21,20 +26,79 @@ pub(crate) async fn start_authorization( http_client: Arc, scopes: &[&str], redirect_uri: &str, + callback_id: &str, client_registration: McpOAuthClientRegistration, ) -> Result { - let request = match client_registration { - McpOAuthClientRegistration::Auto | McpOAuthClientRegistration::Dcr => { - AuthorizationRequest::new(redirect_uri) - .with_scopes(scopes.iter().copied()) - .with_client_name("Codex") - } - }; - let mut auth_manager = AuthorizationManager::new_with_oauth_http_client(server_url, http_client).await?; auth_manager.set_allow_missing_issuer(true); - let mut oauth_state = OAuthState::Unauthorized(auth_manager); - oauth_state.start_authorization(request).await?; - Ok(oauth_state) + let metadata = auth_manager.resolve_metadata().await?.metadata; + + let cimd_advertised = metadata + .additional_fields + .get("client_id_metadata_document_supported") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let public_client_auth_supported = metadata + .additional_fields + .get("token_endpoint_auth_methods_supported") + .and_then(serde_json::Value::as_array) + .is_some_and(|methods| methods.iter().any(|method| method.as_str() == Some("none"))); + + let parsed_redirect_uri = Url::parse(redirect_uri)?; + let native_redirect_supported = parsed_redirect_uri.scheme() == "http" + && matches!( + parsed_redirect_uri.host_str(), + Some("127.0.0.1" | "localhost") + ) + && parsed_redirect_uri.port().is_some_and(|port| port > 0) + && parsed_redirect_uri.path() == format!("/callback/{callback_id}") + && parsed_redirect_uri.query().is_none() + && parsed_redirect_uri.fragment().is_none() + && parsed_redirect_uri.username().is_empty() + && parsed_redirect_uri.password().is_none(); + // MCP 2026-07-28 priority: pre-registered clients never reach this path; offer + // advertised CIMD here and otherwise let rmcp fall back to DCR. + // https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration + let offer_cimd = match client_registration { + McpOAuthClientRegistration::Auto => { + cimd_advertised && native_redirect_supported && public_client_auth_supported + } + McpOAuthClientRegistration::Cimd => { + if !cimd_advertised || !public_client_auth_supported { + bail!( + "MCP authorization server does not advertise CIMD with token endpoint auth method `none`" + ); + } + if !native_redirect_supported { + bail!( + "MCP OAuth CIMD requires an ephemeral loopback callback at `/callback/{callback_id}`" + ); + } + true + } + McpOAuthClientRegistration::Dcr => false, + }; + + auth_manager.set_metadata(metadata); + let mut request = AuthorizationRequest::new(redirect_uri) + .with_scopes(scopes.iter().copied()) + .with_client_name("Codex"); + if offer_cimd { + // CIMD is an active IETF Internet-Draft: this HTTPS client identifier resolves + // to its self-referential JSON metadata document. + // https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/ + request = request.with_client_metadata_url(format!( + "https://chatgpt.com/oauth/codex/{callback_id}/client.json" + )); + } + let session = AuthorizationSession::new(auth_manager, request) + .await + .map_err(|(_auth_manager, error)| error)?; + + Ok(OAuthState::Session(session)) } + +#[cfg(test)] +#[path = "oauth_client_registration_tests.rs"] +mod tests; diff --git a/codex-rs/rmcp-client/src/oauth_client_registration_tests.rs b/codex-rs/rmcp-client/src/oauth_client_registration_tests.rs new file mode 100644 index 000000000000..62f0330af844 --- /dev/null +++ b/codex-rs/rmcp-client/src/oauth_client_registration_tests.rs @@ -0,0 +1,256 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use anyhow::Result; +use codex_exec_server::RouteAwareHttpClient; +use codex_http_client::HttpClientFactory; +use codex_http_client::OutboundProxyPolicy; +use http::HeaderMap; +use pretty_assertions::assert_eq; +use rmcp::transport::auth::OAuthState; +use serde_json::Value; +use serde_json::json; +use url::Url; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::Request; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +use super::McpOAuthClientRegistration; +use super::start_authorization; +use crate::oauth_http_client::OAuthHttpClientAdapter; + +const CALLBACK_ID: &str = "abc123ABC_-x"; + +async fn oauth_server(overrides: Value) -> MockServer { + let server = MockServer::start().await; + let base_url = server.uri(); + let mut metadata = json!({ + "authorization_endpoint": format!("{base_url}/authorize"), + "token_endpoint": format!("{base_url}/token"), + "registration_endpoint": format!("{base_url}/register"), + "client_id_metadata_document_supported": true, + "token_endpoint_auth_methods_supported": ["none"], + "code_challenge_methods_supported": ["S256"], + "scopes_supported": ["read", "offline_access"], + }); + metadata + .as_object_mut() + .expect("metadata should be an object") + .extend( + overrides + .as_object() + .expect("overrides should be an object") + .clone(), + ); + if metadata["authorization_response_iss_parameter_supported"] == json!(true) + && metadata.get("issuer").is_none() + { + metadata["issuer"] = json!(format!("{base_url}/mcp")); + } + + Mock::given(method("GET")) + .and(path("/.well-known/oauth-authorization-server/mcp")) + .respond_with(ResponseTemplate::new(200).set_body_json(metadata)) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/register")) + .respond_with(|request: &Request| { + let registration: Value = serde_json::from_slice(&request.body) + .expect("dynamic registration should contain JSON"); + ResponseTemplate::new(200).set_body_json(json!({ + "client_id": "dcr-client", + "redirect_uris": registration["redirect_uris"], + })) + }) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": "test-access-token", + "token_type": "Bearer", + "refresh_token": "test-refresh-token", + }))) + .mount(&server) + .await; + + server +} + +async fn requests_to(server: &MockServer, request_path: &str) -> Vec { + server + .received_requests() + .await + .expect("mock server should record requests") + .into_iter() + .filter(|request| request.url.path() == request_path) + .collect() +} + +async fn authorization( + server: &MockServer, + redirect_uri: &str, + registration: McpOAuthClientRegistration, +) -> Result<(OAuthState, HashMap)> { + let state = start_authorization( + &format!("{}/mcp", server.uri()), + Arc::new(OAuthHttpClientAdapter::new( + Arc::new(RouteAwareHttpClient::new(HttpClientFactory::new( + OutboundProxyPolicy::ReqwestDefault, + ))), + HeaderMap::new(), + )), + &["read"], + redirect_uri, + CALLBACK_ID, + registration, + ) + .await?; + let query = Url::parse(&state.get_authorization_url().await?)? + .query_pairs() + .into_owned() + .collect(); + + Ok((state, query)) +} + +#[tokio::test] +async fn automatic_cimd_uses_callback_specific_identity() -> Result<()> { + for host in ["127.0.0.1", "localhost"] { + let server = oauth_server(json!({})).await; + let redirect = format!("http://{host}:43123/callback/{CALLBACK_ID}"); + let (mut state, query) = + authorization(&server, &redirect, McpOAuthClientRegistration::Auto).await?; + let expected_id = format!("https://chatgpt.com/oauth/codex/{CALLBACK_ID}/client.json"); + assert_eq!(query["client_id"], expected_id); + assert_eq!(query["redirect_uri"], redirect); + assert_eq!(query["code_challenge_method"], "S256"); + assert_eq!(query["scope"], "read offline_access"); + + state + .handle_callback_with_issuer("valid-authorization-code", &query["state"], None) + .await?; + let token_requests = requests_to(&server, "/token").await; + assert_eq!(token_requests.len(), 1); + let request = &token_requests[0]; + let body: HashMap<_, _> = url::form_urlencoded::parse(&request.body) + .into_owned() + .collect(); + assert_eq!(body["client_id"], expected_id); + assert_eq!(body["redirect_uri"], redirect); + assert_eq!(body["grant_type"], "authorization_code"); + assert!(body.contains_key("code_verifier")); + assert!(!body.contains_key("client_secret")); + assert!(!request.headers.contains_key("authorization")); + assert!(requests_to(&server, "/register").await.is_empty()); + assert_eq!( + requests_to(&server, "/.well-known/oauth-authorization-server/mcp") + .await + .len(), + 1 + ); + } + + Ok(()) +} + +#[tokio::test] +async fn registration_selection_preserves_dcr_capabilities_and_exact_redirects() -> Result<()> { + let native = "http://localhost:43123/callback/abc123ABC_-x"; + let custom = "https://callbacks.example.com/oauth/callback/abc123ABC_-x"; + for (metadata, redirect, registration, expected_redirect) in [ + ( + json!({"client_id_metadata_document_supported": false}), + native, + McpOAuthClientRegistration::Auto, + native, + ), + ( + json!({"token_endpoint_auth_methods_supported": null}), + native, + McpOAuthClientRegistration::Auto, + native, + ), + ( + json!({"token_endpoint_auth_methods_supported": ["private_key_jwt"]}), + native, + McpOAuthClientRegistration::Auto, + native, + ), + (json!({}), custom, McpOAuthClientRegistration::Auto, custom), + ( + json!({"authorization_response_iss_parameter_supported": true}), + native, + McpOAuthClientRegistration::Dcr, + native, + ), + ] { + let server = oauth_server(metadata).await; + let (_, query) = authorization(&server, redirect, registration).await?; + assert_eq!(query["client_id"], "dcr-client"); + assert_eq!(query["redirect_uri"], expected_redirect); + let registrations = requests_to(&server, "/register").await; + assert_eq!(registrations.len(), 1); + let registration: Value = serde_json::from_slice(®istrations[0].body)?; + assert_eq!(registration["redirect_uris"], json!([expected_redirect])); + } + + Ok(()) +} + +#[tokio::test] +async fn invalid_cimd_metadata_and_redirects_fail_without_dynamic_registration() { + let valid = "http://127.0.0.1:43123/callback/abc123ABC_-x"; + for (metadata, redirect, expected_error) in [ + ( + json!({"token_endpoint_auth_methods_supported": ["private_key_jwt"]}), + valid, + "token endpoint auth method `none`", + ), + ( + json!({}), + "http://127.0.0.1.evil.example:43123/callback/abc123ABC_-x", + "ephemeral loopback callback", + ), + ( + json!({}), + "http://127.0.0.1/callback/abc123ABC_-x", + "ephemeral loopback callback", + ), + ( + json!({}), + "http://127.0.0.1:43123/callback/wrong-id", + "ephemeral loopback callback", + ), + ( + json!({}), + "http://127.0.0.1:43123/callback/abc123ABC_-x?unexpected=true", + "ephemeral loopback callback", + ), + ( + json!({}), + "http://[::1]:43123/callback/abc123ABC_-x", + "ephemeral loopback callback", + ), + ] { + let server = oauth_server(metadata).await; + let error = authorization(&server, redirect, McpOAuthClientRegistration::Cimd) + .await + .err() + .expect("invalid CIMD metadata or callback should fail"); + assert!(error.to_string().contains(expected_error)); + assert!(requests_to(&server, "/register").await.is_empty()); + assert!(requests_to(&server, "/token").await.is_empty()); + } + + let server = oauth_server(json!({"registration_endpoint": null})).await; + let error = authorization(&server, valid, McpOAuthClientRegistration::Dcr) + .await + .err() + .expect("explicit DCR should require an advertised registration endpoint"); + assert!(error.to_string().contains("registration not supported")); +} diff --git a/codex-rs/rmcp-client/src/perform_oauth_login.rs b/codex-rs/rmcp-client/src/perform_oauth_login.rs index 0345af069b63..4a40f5267e0b 100644 --- a/codex-rs/rmcp-client/src/perform_oauth_login.rs +++ b/codex-rs/rmcp-client/src/perform_oauth_login.rs @@ -446,6 +446,8 @@ fn resolve_redirect_uri(server: &Server, callback_url: Option<&str>) -> Result Result { + // Native Codex callback IDs intentionally hash the complete MCP URL (minus its fragment) + // with SHA-256. Python connector callback IDs use SHAKE-256 over the origin and are distinct. let mut parsed = Url::parse(server_url).with_context(|| format!("invalid MCP server URL `{server_url}`"))?; parsed @@ -568,6 +570,7 @@ impl OauthLoginFlow { oauth_http_client, &scope_refs, &redirect_uri, + &callback_id, client_registration, ) .await? @@ -698,7 +701,6 @@ async fn start_authorization( let mut auth_manager = AuthorizationManager::new_with_oauth_http_client(server_url, http_client).await?; auth_manager.set_allow_missing_issuer(true); - let metadata = auth_manager.resolve_metadata().await?.metadata; auth_manager.set_metadata(metadata); auth_manager.configure_client( @@ -895,7 +897,6 @@ mod tests { assert_eq!(registration_requests.load(Ordering::SeqCst), 0); } } - #[tokio::test] async fn oauth_callback_validates_rfc_9207_issuer_before_token_exchange() { for (supports_issuer, callback_issuer, expected_token_requests) in [ @@ -1132,12 +1133,7 @@ mod tests { assert_ne!(callback_id, different_path); assert_ne!(callback_id, different_query); assert_ne!(callback_id, different_origin); - assert_eq!(callback_id.len(), 12); - assert!( - callback_id - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') - ); + assert_eq!(callback_id, "XuuuHAzzHOni"); } #[test] diff --git a/scripts/mcp_conformance/codex_conformance_adapter.py b/scripts/mcp_conformance/codex_conformance_adapter.py index bdf2e1dfeb6c..74df275c4aaa 100644 --- a/scripts/mcp_conformance/codex_conformance_adapter.py +++ b/scripts/mcp_conformance/codex_conformance_adapter.py @@ -43,6 +43,7 @@ class AdapterFailure(RuntimeError): CIMD_CLIENT_METADATA_URL = "https://conformance-test.local/client-metadata.json" PRE_REGISTERED_CLIENT_SECRET_ENV_VAR = "MCP_CONFORMANCE_CLIENT_SECRET" +CLIENT_REGISTRATION_OVERRIDE_ENV_VAR = "CODEX_CONFORMANCE_CLIENT_REGISTRATION" AUTH_COMPLETION_METHOD = "mcpServer/oauthLogin/completed" EXPECTED_AUTH_REJECTION_SCENARIOS = frozenset( { @@ -303,11 +304,9 @@ def _drive_headless_authorization( def _oauth_client_id( scenario: str, context: Mapping[str, object], - *, - require_production_client_identity: bool = False, ) -> str | None: if scenario == "auth/basic-cimd": - return None if require_production_client_identity else CIMD_CLIENT_METADATA_URL + return CIMD_CLIENT_METADATA_URL if scenario == "auth/pre-registration": client_id = context.get("client_id") if not isinstance(client_id, str) or not client_id: @@ -316,6 +315,23 @@ def _oauth_client_id( return None +def _client_registration_override( + scenario: str, + *, + require_automatic_auth: bool, +) -> str | None: + requested = os.environ.get(CLIENT_REGISTRATION_OVERRIDE_ENV_VAR) + if requested: + if requested not in {"auto", "cimd", "dcr"}: + raise AdapterFailure( + f"{CLIENT_REGISTRATION_OVERRIDE_ENV_VAR} must be auto, cimd, or dcr" + ) + return requested + if scenario == "auth/offline-access-scope" and not require_automatic_auth: + return "dcr" + return None + + def _write_auth_registration( config_path: Path, *, @@ -359,6 +375,7 @@ def _oauth_login( *, scopes: Sequence[str] | None, timeout_seconds: float, + client_registration: str | None = None, ) -> tuple[bool, str | None]: event_index = len(client.events) params: dict[str, object] = { @@ -367,6 +384,8 @@ def _oauth_login( } if scopes is not None: params["scopes"] = list(scopes) + if client_registration is not None: + params["clientRegistration"] = client_registration response = client.request("mcpServer/oauth/login", params) result, detail = _response_result(response) if result is None: @@ -424,11 +443,13 @@ def _login_reload_and_call( workspace: Path, timeout_seconds: float, scopes: Sequence[str] | None = None, + client_registration: str | None = None, ) -> None: success, error = _oauth_login( client, scopes=scopes, timeout_seconds=timeout_seconds, + client_registration=client_registration, ) if not success: raise AdapterFailure(f"OAuth login failed: {error or 'unknown error'}") @@ -444,6 +465,7 @@ def _exercise_auth_scenario( workspace: Path, timeout_seconds: float, require_automatic_auth: bool = False, + client_registration: str | None = None, ) -> str: if scenario in EXPECTED_AUTH_REJECTION_SCENARIOS: success, error = _oauth_login( @@ -566,6 +588,7 @@ def _exercise_auth_scenario( client, workspace=workspace, timeout_seconds=timeout_seconds, + client_registration=client_registration, ) return "completed OAuth login, authenticated discovery, and tool call" @@ -741,11 +764,7 @@ def run_adapter(server_url: str) -> dict[str, object]: if not feature_configured: raise AdapterFailure("could not configure the modern MCP feature") - oauth_client_id = _oauth_client_id( - scenario, - context, - require_production_client_identity=require_automatic_auth, - ) + oauth_client_id = _oauth_client_id(scenario, context) oauth_client_secret = context.get("client_secret") oauth_client_secret_env_var = None if ( @@ -880,12 +899,17 @@ def run_adapter(server_url: str) -> dict[str, object]: raise AdapterFailure("could not enable the modern MCP feature") if scenario.startswith("auth/"): + client_registration = _client_registration_override( + scenario, + require_automatic_auth=require_automatic_auth, + ) detail = _exercise_auth_scenario( client, scenario=scenario, workspace=workspace, timeout_seconds=timeout_seconds, require_automatic_auth=require_automatic_auth, + client_registration=client_registration, ) if scenario == "auth/pre-registration" and isinstance( oauth_client_secret, diff --git a/scripts/mcp_conformance/regression-baseline-v1.json b/scripts/mcp_conformance/regression-baseline-v1.json index 08f01ed1fd80..dc936a64689e 100644 --- a/scripts/mcp_conformance/regression-baseline-v1.json +++ b/scripts/mcp_conformance/regression-baseline-v1.json @@ -4030,6 +4030,20 @@ "source": "official", "transport": "official-http" }, + { + "check_id": "auto_cimd", + "mode": "2026-07-28", + "scenario": "auth/offline-access-scope", + "source": "supplemental", + "transport": "official-http" + }, + { + "check_id": "forced_cimd", + "mode": "2026-07-28", + "scenario": "auth/offline-access-scope", + "source": "supplemental", + "transport": "official-http" + }, { "check_id": "app_server_initialize", "mode": "2026-07-28", diff --git a/scripts/mcp_conformance/run_codex_compliance.py b/scripts/mcp_conformance/run_codex_compliance.py index bfb31cc6c5ca..f29e22ff4524 100644 --- a/scripts/mcp_conformance/run_codex_compliance.py +++ b/scripts/mcp_conformance/run_codex_compliance.py @@ -1233,12 +1233,76 @@ def _run_official_case( source="harness", ) ) + if mode == MODERN_VERSION and "auth/offline-access-scope" in scenarios: + case.checks.extend( + _cimd_registration_checks( + codex_binary, + adapter_script, + conformance_command=conformance_command, + case_home=case_home / "cimd-registration", + timeout_seconds=timeout_seconds, + enable_modern_feature=enable_modern_feature, + ) + ) if diagnostics: case.diagnostics = "\n\n".join(diagnostics)[-16_000:] case.finish(started_at) return case +def _cimd_registration_checks( + codex_binary: Path, + adapter_script: Path, + *, + conformance_command: Sequence[str], + case_home: Path, + timeout_seconds: float, + enable_modern_feature: bool, +) -> list[CheckResult]: + checks: list[CheckResult] = [] + case_home.mkdir(parents=True) + for check_name, client_registration in ( + ("auto_cimd", None), + ("forced_cimd", "cimd"), + ): + env = _isolated_environment(case_home / check_name) + env["CODEX_CONFORMANCE_TIMEOUT"] = str(timeout_seconds) + env["CODEX_CONFORMANCE_ENABLE_MODERN_FEATURE"] = ( + "1" if enable_modern_feature else "0" + ) + env["CODEX_CONFORMANCE_REQUIRE_AUTOMATIC_AUTH"] = "1" + if client_registration is not None: + env["CODEX_CONFORMANCE_CLIENT_REGISTRATION"] = client_registration + results = run_official_mode( + conformance_command=conformance_command, + adapter_script=adapter_script, + codex_binary=codex_binary, + mode=MODERN_VERSION, + scenarios=("auth/offline-access-scope",), + output_dir=case_home / check_name / "official-results", + timeout_seconds=timeout_seconds, + base_env=env, + ) + result = results[0] if len(results) == 1 else None + success = result is not None and result.success + checks.append( + CheckResult( + name=f"supplemental/auth/offline-access-scope/{check_name}", + success=success, + detail=( + result.adapter_detail or result.runner_detail + if result is not None + else "supplemental offline-access-scope scenario did not run" + ), + source="supplemental", + scenario="auth/offline-access-scope", + check_id=check_name, + category="auth", + ) + ) + return checks + + def run_compliance( codex_binary: Path, *, diff --git a/scripts/mcp_conformance/test_codex_compliance.py b/scripts/mcp_conformance/test_codex_compliance.py index e97374d515e9..91acf83e9121 100644 --- a/scripts/mcp_conformance/test_codex_compliance.py +++ b/scripts/mcp_conformance/test_codex_compliance.py @@ -529,6 +529,55 @@ def test_official_case_preserves_independent_runner_failure_with_successful_adap assert case.diagnostics == f"{scenario}:\nofficial runner failed" +def test_official_case_keeps_cimd_registration_checks_in_regression_gated_http_case( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[dict[str, object]] = [] + + def run_official_mode(**kwargs: object) -> list[OfficialScenarioResult]: + calls.append(kwargs) + scenarios = kwargs["scenarios"] + assert isinstance(scenarios, (tuple, list)) + scenario = scenarios[0] + assert isinstance(scenario, str) + return [ + OfficialScenarioResult( + scenario=scenario, + success=True, + adapter_success=True, + adapter_detail="adapter succeeded", + ) + ] + + monkeypatch.setattr( + "run_codex_compliance.run_official_mode", + run_official_mode, + ) + + case = _run_official_case( + Path("/opt/codex"), + Path("/src/codex_conformance_adapter.py"), + conformance_command=["conformance"], + mode=MODERN_VERSION, + scenarios=["auth/offline-access-scope"], + case_home=tmp_path / "case", + timeout_seconds=1.0, + enable_modern_feature=True, + ) + + assert case.transport == "official-http" + supplemental = [check for check in case.checks if check.source == "supplemental"] + assert [check.check_id for check in supplemental] == ["auto_cimd", "forced_cimd"] + assert len(calls) == 3 + auto_env = calls[1]["base_env"] + forced_env = calls[2]["base_env"] + assert isinstance(auto_env, dict) + assert isinstance(forced_env, dict) + assert auto_env.get("CODEX_CONFORMANCE_CLIENT_REGISTRATION") is None + assert forced_env["CODEX_CONFORMANCE_CLIENT_REGISTRATION"] == "cimd" + + def _regression_report() -> dict[str, object]: cases: list[dict[str, object]] = [] known_modern = { @@ -599,6 +648,18 @@ def _regression_report() -> dict[str, object]: "check_id": None, } ) + if mode == MODERN_VERSION: + official_checks.extend( + { + "name": f"supplemental/auth/offline-access-scope/{check_id}", + "success": True, + "status": "PASS", + "source": "supplemental", + "scenario": "auth/offline-access-scope", + "check_id": check_id, + } + for check_id in ("auto_cimd", "forced_cimd") + ) cases.append( { "mode": mode, @@ -690,6 +751,33 @@ def test_regression_gate_rejects_a_new_modern_failure() -> None: assert any(item["scenario"] == "tools_call" for item in gate["newFailures"]) +@pytest.mark.parametrize("check_id", ["auto_cimd", "forced_cimd"]) +def test_regression_gate_rejects_cimd_registration_failures( + check_id: str, +) -> None: + baseline = _compact_regression_baseline(_regression_report()) + candidate = _regression_report() + checks = _regression_case(candidate, MODERN_VERSION, "official-http")["checks"] + assert isinstance(checks, list) + check = next( + item + for item in checks + if isinstance(item, dict) + and item.get("source") == "supplemental" + and item.get("check_id") == check_id + ) + check["success"] = False + check["status"] = "FAIL" + + gate = _evaluate_regression_gate(candidate, baseline) + + assert gate["success"] is False + assert any( + item["source"] == "supplemental" and item["check_id"] == check_id + for item in gate["newFailures"] + ) + + def test_regression_gate_rejects_a_new_intermediate_oauth_failure() -> None: baseline = _regression_report() candidate = deepcopy(baseline) diff --git a/scripts/mcp_conformance/test_official_conformance.py b/scripts/mcp_conformance/test_official_conformance.py index be8a9650abd9..10da8ce31cf0 100644 --- a/scripts/mcp_conformance/test_official_conformance.py +++ b/scripts/mcp_conformance/test_official_conformance.py @@ -10,7 +10,9 @@ import run_codex_compliance from codex_conformance_adapter import ( CIMD_CLIENT_METADATA_URL, + CLIENT_REGISTRATION_OVERRIDE_ENV_VAR, AdapterFailure, + _client_registration_override, _exercise_auth_scenario, _oauth_client_id, _validate_oauth_secret_not_persisted, @@ -303,17 +305,6 @@ def test_strict_auth_accepts_product_owned_reauthentication( assert manual_reauthorizations == [] -def test_strict_auth_does_not_invent_a_production_client_metadata_url() -> None: - assert ( - _oauth_client_id( - "auth/basic-cimd", - {}, - require_production_client_identity=True, - ) - is None - ) - - @pytest.mark.parametrize("filename", ["config.toml", ".credentials.json"]) def test_oauth_client_secret_persistence_is_detected_without_disclosing_it( tmp_path: Path, @@ -347,14 +338,6 @@ def test_oauth_client_secret_environment_reference_is_not_a_persisted_secret( def test_auth_adapter_selects_only_scenario_provided_client_ids() -> None: assert _oauth_client_id("auth/basic-cimd", {}) == CIMD_CLIENT_METADATA_URL - assert ( - _oauth_client_id( - "auth/basic-cimd", - {}, - require_production_client_identity=True, - ) - is None - ) assert ( _oauth_client_id( "auth/pre-registration", @@ -367,6 +350,35 @@ def test_auth_adapter_selects_only_scenario_provided_client_ids() -> None: _oauth_client_id("auth/pre-registration", {}) +@pytest.mark.parametrize( + ("scenario", "require_automatic_auth", "override", "expected"), + [ + ("auth/offline-access-not-supported", False, None, None), + ("auth/offline-access-scope", False, None, "dcr"), + ("auth/offline-access-scope", True, None, None), + ("auth/offline-access-scope", False, "cimd", "cimd"), + ], +) +def test_auth_adapter_scopes_client_registration_overrides( + monkeypatch: pytest.MonkeyPatch, + scenario: str, + require_automatic_auth: bool, + override: str | None, + expected: str | None, +) -> None: + if override is None: + monkeypatch.delenv(CLIENT_REGISTRATION_OVERRIDE_ENV_VAR, raising=False) + else: + monkeypatch.setenv(CLIENT_REGISTRATION_OVERRIDE_ENV_VAR, override) + assert ( + _client_registration_override( + scenario, + require_automatic_auth=require_automatic_auth, + ) + == expected + ) + + def test_auth_registration_does_not_persist_context_secret(tmp_path: Path) -> None: config_path = tmp_path / "config.toml" config_path.write_text('mcp_oauth_credentials_store = "file"\n', encoding="utf-8")