diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index d364d4a175..24801393f7 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1930,8 +1930,26 @@ ], "type": "object" }, + "McpServerOauthClientRegistration": { + "enum": [ + "auto", + "dcr" + ], + "type": "string" + }, "McpServerOauthLoginParams": { "properties": { + "clientRegistration": { + "anyOf": [ + { + "$ref": "#/definitions/McpServerOauthClientRegistration" + }, + { + "type": "null" + } + ], + "description": "Registration strategy for this login only; omission preserves automatic DCR." + }, "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 b139c3194f..603cc2785c 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 @@ -13160,6 +13160,13 @@ ], "type": "object" }, + "McpServerOauthClientRegistration": { + "enum": [ + "auto", + "dcr" + ], + "type": "string" + }, "McpServerOauthLoginCompletedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -13192,6 +13199,17 @@ "McpServerOauthLoginParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "clientRegistration": { + "anyOf": [ + { + "$ref": "#/definitions/v2/McpServerOauthClientRegistration" + }, + { + "type": "null" + } + ], + "description": "Registration strategy for this login only; omission preserves automatic DCR." + }, "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 a1dfc5fd89..5bd2fd6e2e 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 @@ -9497,6 +9497,13 @@ ], "type": "object" }, + "McpServerOauthClientRegistration": { + "enum": [ + "auto", + "dcr" + ], + "type": "string" + }, "McpServerOauthLoginCompletedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -9529,6 +9536,17 @@ "McpServerOauthLoginParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "clientRegistration": { + "anyOf": [ + { + "$ref": "#/definitions/McpServerOauthClientRegistration" + }, + { + "type": "null" + } + ], + "description": "Registration strategy for this login only; omission preserves automatic DCR." + }, "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 de66dcefd6..7e3c394bbe 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/McpServerOauthLoginParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/McpServerOauthLoginParams.json @@ -1,6 +1,26 @@ { "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "McpServerOauthClientRegistration": { + "enum": [ + "auto", + "dcr" + ], + "type": "string" + } + }, "properties": { + "clientRegistration": { + "anyOf": [ + { + "$ref": "#/definitions/McpServerOauthClientRegistration" + }, + { + "type": "null" + } + ], + "description": "Registration strategy for this login only; omission preserves automatic DCR." + }, "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 6fa530c30c..5fecb16728 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 76b4bceb10..c249a4fa7e 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 new file mode 100644 index 0000000000..28878a8fd4 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerOauthClientRegistration.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// 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"; 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 ff088b8d9f..6f164446b8 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/McpServerOauthLoginParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerOauthLoginParams.ts @@ -1,5 +1,10 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { McpServerOauthClientRegistration } from "./McpServerOauthClientRegistration"; -export type McpServerOauthLoginParams = { name: string, threadId?: string | null, scopes?: Array | null, timeoutSecs?: bigint | null, }; +export type McpServerOauthLoginParams = { name: string, threadId?: string | null, +/** + * Registration strategy for this login only; omission preserves automatic DCR. + */ +clientRegistration?: McpServerOauthClientRegistration | null, scopes?: Array | null, timeoutSecs?: bigint | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 5ba323b7d9..b56a2df9c3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -255,6 +255,7 @@ export type { McpServerElicitationAction } from "./McpServerElicitationAction"; export type { McpServerElicitationRequestParams } from "./McpServerElicitationRequestParams"; export type { McpServerElicitationRequestResponse } from "./McpServerElicitationRequestResponse"; export type { McpServerMigration } from "./McpServerMigration"; +export type { McpServerOauthClientRegistration } from "./McpServerOauthClientRegistration"; export type { McpServerOauthLoginCompletedNotification } from "./McpServerOauthLoginCompletedNotification"; export type { McpServerOauthLoginParams } from "./McpServerOauthLoginParams"; export type { McpServerOauthLoginResponse } from "./McpServerOauthLoginResponse"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 8b68b20776..ae5c737899 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2195,6 +2195,7 @@ mod tests { params: v2::McpServerOauthLoginParams { name: "server-a".to_string(), thread_id: None, + client_registration: None, scopes: None, timeout_secs: None, }, 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 8ebbe21418..a949b021b6 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs @@ -196,6 +196,9 @@ pub struct McpServerOauthLoginParams { pub name: String, #[ts(optional = nullable)] pub thread_id: Option, + /// Registration strategy for this login only; omission preserves automatic DCR. + #[ts(optional = nullable)] + pub client_registration: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional = nullable)] pub scopes: Option>, @@ -204,6 +207,15 @@ pub struct McpServerOauthLoginParams { pub timeout_secs: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub enum McpServerOauthClientRegistration { + #[default] + Auto, + Dcr, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index a55f887c5d..2e059dd6c4 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -268,7 +268,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, and receive an `authorization_url` followed by `mcpServer/oauthLogin/completed` once the browser flow finishes. +- `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. - `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.rs b/codex-rs/app-server/src/request_processors.rs index 77eee3873b..8e9e42563e 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -113,6 +113,7 @@ use codex_app_server_protocol::MarketplaceUpgradeParams; use codex_app_server_protocol::MarketplaceUpgradeResponse; use codex_app_server_protocol::McpResourceReadParams; use codex_app_server_protocol::McpResourceReadResponse; +use codex_app_server_protocol::McpServerOauthClientRegistration; use codex_app_server_protocol::McpServerOauthLoginCompletedNotification; use codex_app_server_protocol::McpServerOauthLoginParams; use codex_app_server_protocol::McpServerOauthLoginResponse; @@ -455,6 +456,7 @@ use codex_protocol::protocol::W3cTraceContext; use codex_protocol::protocol::strip_user_message_prefix; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use codex_protocol::user_input::UserInput as CoreInputItem; +use codex_rmcp_client::McpOAuthClientRegistration; use codex_rmcp_client::StreamableHttpRedirectMode; use codex_rmcp_client::perform_oauth_login_return_url; use codex_rollout::InitialHistory; 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 fbdc8e8dde..0b2dae286e 100644 --- a/codex-rs/app-server/src/request_processors/mcp_processor.rs +++ b/codex-rs/app-server/src/request_processors/mcp_processor.rs @@ -118,9 +118,14 @@ impl McpRequestProcessor { let McpServerOauthLoginParams { name, thread_id, + client_registration, scopes, timeout_secs, } = params; + let client_registration = match client_registration.unwrap_or_default() { + McpServerOauthClientRegistration::Auto => McpOAuthClientRegistration::Auto, + McpServerOauthClientRegistration::Dcr => McpOAuthClientRegistration::Dcr, + }; let auth = self.auth_manager.auth().await; let (mcp_config, runtime_context) = match thread_id.as_deref() { @@ -201,6 +206,7 @@ impl McpRequestProcessor { env_http_headers, &resolved_scopes.scopes, server.oauth_client_id(), + client_registration, server.oauth_resource.as_deref(), timeout_secs, mcp_config.mcp_oauth_callback_port, diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 6fc6e0044c..1185812a88 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -32,6 +32,7 @@ use codex_mcp::should_retry_without_scopes; use codex_plugin::PluginId; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::auth::AuthMode as DomainAuthMode; +use codex_rmcp_client::McpOAuthClientRegistration; use codex_rmcp_client::OAuthDiscoveryTimeout; use codex_rmcp_client::StreamableHttpRedirectMode; use codex_rmcp_client::perform_oauth_login_silent; @@ -1998,6 +1999,7 @@ impl PluginRequestProcessor { oauth_config.env_http_headers.clone(), &resolved_scopes.scopes, oauth_client_id, + McpOAuthClientRegistration::Auto, server.oauth_resource.as_deref(), callback_port, callback_url.as_deref(), @@ -2017,6 +2019,7 @@ impl PluginRequestProcessor { oauth_config.env_http_headers, &[], oauth_client_id, + McpOAuthClientRegistration::Auto, server.oauth_resource.as_deref(), callback_port, callback_url.as_deref(), diff --git a/codex-rs/app-server/tests/suite/v2/executor_mcp.rs b/codex-rs/app-server/tests/suite/v2/executor_mcp.rs index b452849b97..73c4eb3747 100644 --- a/codex-rs/app-server/tests/suite/v2/executor_mcp.rs +++ b/codex-rs/app-server/tests/suite/v2/executor_mcp.rs @@ -54,6 +54,7 @@ const EXECUTOR_HTTP_MCP_URL: &str = "http://executor-only.invalid/mcp"; const HTTP_MCP_SERVER_NAME: &str = "executor_http"; const MCP_SERVER_NAME: &str = "executor_demo"; const OAUTH_MCP_SERVER_NAME: &str = "executor_oauth"; +const PRE_REGISTERED_OAUTH_MCP_SERVER_NAME: &str = "executor_oauth_preregistered"; const EXECUTOR_OAUTH_MCP_URL: &str = "http://oauth-only.invalid/oauth-mcp"; const HOST_OAUTH_ACCESS_TOKEN: &str = "host-access-token"; const EXECUTOR_OAUTH_ACCESS_TOKEN: &str = "executor-access-token"; @@ -98,10 +99,12 @@ async fn selected_executor_plugin_exposes_its_mcps_only_to_that_thread() -> Resu } }, )); + let (registration_request_tx, mut registration_request_rx) = mpsc::unbounded_channel(); let (token_request_tx, mut token_request_rx) = mpsc::unbounded_channel(); let oauth_metadata = json!({ "authorization_endpoint": "https://oauth-only.invalid/authorize", "token_endpoint": "http://oauth-only.invalid/token", + "registration_endpoint": "http://oauth-only.invalid/register", "scopes_supported": ["read", "write"], "response_types_supported": ["code"], "code_challenge_methods_supported": ["S256"], @@ -114,6 +117,19 @@ async fn selected_executor_plugin_exposes_its_mcps_only_to_that_thread() -> Resu async move { Json(metadata) } }), ) + .route( + "/register", + post(move |Json(request): Json| { + let registration_request_tx = registration_request_tx.clone(); + async move { + let _ = registration_request_tx.send(request.clone()); + Json(json!({ + "client_id": "executor-dcr-client", + "redirect_uris": request["redirect_uris"], + })) + } + }), + ) .route( "/token", post(move |body: Bytes| { @@ -205,7 +221,12 @@ HTTP_PROXY = {http_proxy} (OAUTH_MCP_SERVER_NAME): { "url": EXECUTOR_OAUTH_MCP_URL, "environment_id": "local", - "oauth": {"clientId": "executor-oauth-client"}, + "startup_timeout_sec": 10, + }, + (PRE_REGISTERED_OAUTH_MCP_SERVER_NAME): { + "url": EXECUTOR_OAUTH_MCP_URL, + "environment_id": "local", + "oauth": {"clientId": "configured-client"}, "startup_timeout_sec": 10, } } @@ -251,12 +272,63 @@ startup_timeout_sec = 10 ) .await??; + let request_id = app_server + .send_raw_request( + "mcpServer/oauth/login", + Some(json!({ + "name": PRE_REGISTERED_OAUTH_MCP_SERVER_NAME, + "threadId": selected_thread.clone(), + "clientRegistration": "dcr", + "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::>(); + assert_eq!( + parameters.get("client_id").map(String::as_str), + Some("configured-client") + ); + assert!( + registration_request_rx.try_recv().is_err(), + "configured OAuth client must skip dynamic registration" + ); + let mut callback_url = reqwest::Url::parse(¶meters["redirect_uri"])?; + callback_url + .query_pairs_mut() + .append_pair("code", "configured-test-code") + .append_pair("state", ¶meters["state"]); + reqwest::Client::builder() + .no_proxy() + .build()? + .get(callback_url) + .send() + .await? + .error_for_status()?; + let token_request = timeout(DEFAULT_READ_TIMEOUT, token_request_rx.recv()) + .await? + .expect("configured client should exchange its authorization code"); + assert!(token_request.contains("client_id=configured-client")); + let completed: McpServerOauthLoginCompletedNotification = timeout( + DEFAULT_READ_TIMEOUT, + app_server.read_notification("mcpServer/oauthLogin/completed"), + ) + .await??; + assert_eq!(completed.name, PRE_REGISTERED_OAUTH_MCP_SERVER_NAME); + assert!(completed.success); + let request_id = app_server .send_raw_request( "mcpServer/oauth/login", Some(json!({ "name": OAUTH_MCP_SERVER_NAME, "threadId": selected_thread.clone(), + "clientRegistration": "dcr", "timeoutSecs": 10, })), ) @@ -268,12 +340,11 @@ startup_timeout_sec = 10 .authorization_url .starts_with("https://oauth-only.invalid/authorize?") ); - assert!( - response - .authorization_url - .contains("client_id=executor-oauth-client") - ); let authorization_url = reqwest::Url::parse(&response.authorization_url)?; + let client_id = authorization_url + .query_pairs() + .find_map(|(key, value)| (key == "client_id").then(|| value.into_owned())); + assert_eq!(client_id.as_deref(), Some("executor-dcr-client")); let state = authorization_url .query_pairs() .find_map(|(key, value)| (key == "state").then(|| value.into_owned())) @@ -282,6 +353,14 @@ startup_timeout_sec = 10 .query_pairs() .find_map(|(key, value)| (key == "redirect_uri").then(|| value.into_owned())) .expect("authorization URL should include redirect_uri"); + let registration_request = timeout(DEFAULT_READ_TIMEOUT, registration_request_rx.recv()) + .await? + .expect("executor registration endpoint should receive a request"); + assert_eq!(registration_request["client_name"], json!("Codex")); + assert_eq!( + registration_request["redirect_uris"], + json!([redirect_uri.clone()]) + ); let mut callback_url = reqwest::Url::parse(&redirect_uri)?; callback_url .query_pairs_mut() @@ -300,6 +379,7 @@ startup_timeout_sec = 10 assert!(token_request.contains("grant_type=authorization_code")); assert!(token_request.contains("code=executor-test-code")); assert!(token_request.contains("code_verifier=")); + assert!(token_request.contains("client_id=executor-dcr-client")); let completed: McpServerOauthLoginCompletedNotification = timeout( DEFAULT_READ_TIMEOUT, app_server.read_notification("mcpServer/oauthLogin/completed"), @@ -469,6 +549,10 @@ startup_timeout_sec = 10 OAUTH_MCP_SERVER_NAME.to_string(), Some("executor-demo@1".to_string()), ), + ( + PRE_REGISTERED_OAUTH_MCP_SERVER_NAME.to_string(), + Some("executor-demo@1".to_string()), + ), (REFRESH_PROBE_SERVER_NAME.to_string(), None), ]) ); @@ -481,7 +565,10 @@ startup_timeout_sec = 10 .map(|server| server.name) .collect::>(); assert!(unselected_server_names.iter().all(|name| { - name != MCP_SERVER_NAME && name != HTTP_MCP_SERVER_NAME && name != OAUTH_MCP_SERVER_NAME + name != MCP_SERVER_NAME + && name != HTTP_MCP_SERVER_NAME + && name != OAUTH_MCP_SERVER_NAME + && name != PRE_REGISTERED_OAUTH_MCP_SERVER_NAME })); http_server_handle.abort(); diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index 7f1ab720d6..d0e4c7cbf9 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -31,6 +31,7 @@ use codex_mcp::oauth_login_support; use codex_mcp::resolve_oauth_scopes; use codex_mcp::should_retry_without_scopes; use codex_protocol::protocol::McpAuthStatus; +use codex_rmcp_client::McpOAuthClientRegistration; use codex_rmcp_client::OAuthDiscoveryTimeout; use codex_rmcp_client::StreamableHttpRedirectMode; use codex_rmcp_client::delete_oauth_tokens; @@ -151,11 +152,35 @@ pub struct AddMcpStreamableHttpArgs { #[arg(long = "oauth-client-id", value_name = "CLIENT_ID", requires = "url")] pub oauth_client_id: Option, + /// OAuth client-registration strategy for the immediate login only. + #[arg( + long = "oauth-client-registration", + value_enum, + value_name = "AUTO|DCR", + requires = "url" + )] + pub oauth_client_registration: Option, + /// Optional OAuth resource parameter to include during MCP login. #[arg(long = "oauth-resource", value_name = "RESOURCE", requires = "url")] pub oauth_resource: Option, } +#[derive(Debug, Clone, Copy, clap::ValueEnum)] +pub enum McpOAuthClientRegistrationArg { + Auto, + Dcr, +} + +impl From for McpOAuthClientRegistration { + fn from(value: McpOAuthClientRegistrationArg) -> Self { + match value { + McpOAuthClientRegistrationArg::Auto => Self::Auto, + McpOAuthClientRegistrationArg::Dcr => Self::Dcr, + } + } +} + #[derive(Debug, clap::Parser)] pub struct RemoveArgs { /// Name of the MCP server configuration to remove. @@ -170,6 +195,14 @@ pub struct LoginArgs { /// Comma-separated list of OAuth scopes to request. #[arg(long, value_delimiter = ',', value_name = "SCOPE,SCOPE")] pub scopes: Vec, + + /// OAuth client-registration strategy for this login only. + #[arg( + long = "oauth-client-registration", + value_enum, + value_name = "AUTO|DCR" + )] + pub oauth_client_registration: Option, } #[derive(Debug, clap::Parser)] @@ -235,6 +268,7 @@ async fn perform_oauth_login_retry_without_scopes( env_http_headers: Option>, resolved_scopes: &ResolvedMcpOAuthScopes, oauth_client_id: Option<&str>, + client_registration: McpOAuthClientRegistration, oauth_resource: Option<&str>, callback_port: Option, callback_url: Option<&str>, @@ -249,6 +283,7 @@ async fn perform_oauth_login_retry_without_scopes( env_http_headers.clone(), &resolved_scopes.scopes, oauth_client_id, + client_registration, oauth_resource, callback_port, callback_url, @@ -268,6 +303,7 @@ async fn perform_oauth_login_retry_without_scopes( env_http_headers, &[], oauth_client_id, + client_registration, oauth_resource, callback_port, callback_url, @@ -316,7 +352,7 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re .await .with_context(|| format!("failed to load MCP servers from {}", codex_home.display()))?; - let (transport, oauth_client_id, oauth_resource) = match transport_args { + let (transport, oauth_client_id, client_registration, oauth_resource) = match transport_args { AddMcpTransportArgs { stdio: Some(stdio), .. } => { @@ -340,6 +376,7 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re cwd: None, }, None, + McpOAuthClientRegistration::Auto, None, ) } @@ -349,6 +386,7 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re url, bearer_token_env_var, oauth_client_id, + oauth_client_registration, oauth_resource, }), .. @@ -360,6 +398,9 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re env_http_headers: None, }, oauth_client_id, + oauth_client_registration + .map(McpOAuthClientRegistration::from) + .unwrap_or_default(), oauth_resource, ), AddMcpTransportArgs { .. } => bail!("exactly one of --command or --url must be provided"), @@ -427,6 +468,7 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re oauth_config.env_http_headers, &resolved_scopes, oauth_client_id.as_deref(), + client_registration, oauth_resource.as_deref(), config.mcp_oauth_callback_port, config.mcp_oauth_callback_url.as_deref(), @@ -487,7 +529,14 @@ async fn run_login(config: &Config, login_args: LoginArgs) -> Result<()> { let mcp_manager = load_mcp_manager(config).await; let mcp_servers = mcp_manager.configured_servers(config).await; - let LoginArgs { name, scopes } = login_args; + let LoginArgs { + name, + scopes, + oauth_client_registration, + } = login_args; + let client_registration = oauth_client_registration + .map(McpOAuthClientRegistration::from) + .unwrap_or_default(); let Some(server) = mcp_servers.get(&name) else { bail!("No MCP server named '{name}' found."); @@ -532,6 +581,7 @@ async fn run_login(config: &Config, login_args: LoginArgs) -> Result<()> { env_http_headers, &resolved_scopes, server.oauth_client_id(), + client_registration, server.oauth_resource.as_deref(), config.mcp_oauth_callback_port, config.mcp_oauth_callback_url.as_deref(), diff --git a/codex-rs/cli/tests/mcp_add_remove.rs b/codex-rs/cli/tests/mcp_add_remove.rs index 9ae1d5fd11..5772198aa5 100644 --- a/codex-rs/cli/tests/mcp_add_remove.rs +++ b/codex-rs/cli/tests/mcp_add_remove.rs @@ -239,7 +239,15 @@ async fn add_streamable_http_without_manual_token() -> Result<()> { let mut add_cmd = codex_command(codex_home.path())?; add_cmd - .args(["mcp", "add", "github", "--url", "https://example.com/mcp"]) + .args([ + "mcp", + "add", + "github", + "--url", + "https://example.com/mcp", + "--oauth-client-registration", + "dcr", + ]) .assert() .success(); @@ -260,9 +268,13 @@ async fn add_streamable_http_without_manual_token() -> Result<()> { other => panic!("unexpected transport: {other:?}"), } assert!(github.enabled); + assert_eq!(github.oauth, None); assert!(!codex_home.path().join(".credentials.json").exists()); assert!(!codex_home.path().join(".env").exists()); + let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; + assert!(!config.contains("client_registration")); + assert!(!config.contains("[mcp_servers.github.oauth]")); Ok(()) } diff --git a/codex-rs/core/src/mcp_skill_dependencies.rs b/codex-rs/core/src/mcp_skill_dependencies.rs index 51198f1570..ce80db17db 100644 --- a/codex-rs/core/src/mcp_skill_dependencies.rs +++ b/codex-rs/core/src/mcp_skill_dependencies.rs @@ -11,6 +11,7 @@ use codex_protocol::request_user_input::RequestUserInputArgs; use codex_protocol::request_user_input::RequestUserInputQuestion; use codex_protocol::request_user_input::RequestUserInputQuestionOption; use codex_protocol::request_user_input::RequestUserInputResponse; +use codex_rmcp_client::McpOAuthClientRegistration; use codex_rmcp_client::OAuthDiscoveryTimeout; use codex_rmcp_client::StreamableHttpRedirectMode; use codex_rmcp_client::perform_oauth_login; @@ -181,6 +182,7 @@ pub(crate) async fn maybe_install_mcp_dependencies( oauth_config.env_http_headers.clone(), &resolved_scopes.scopes, oauth_client_id, + McpOAuthClientRegistration::Auto, server_config.oauth_resource.as_deref(), config.mcp_oauth_callback_port, config.mcp_oauth_callback_url.as_deref(), @@ -199,6 +201,7 @@ pub(crate) async fn maybe_install_mcp_dependencies( oauth_config.env_http_headers, &[], oauth_client_id, + McpOAuthClientRegistration::Auto, server_config.oauth_resource.as_deref(), config.mcp_oauth_callback_port, config.mcp_oauth_callback_url.as_deref(), diff --git a/codex-rs/rmcp-client/src/lib.rs b/codex-rs/rmcp-client/src/lib.rs index 1aef70fbd7..17fb8fc1f9 100644 --- a/codex-rs/rmcp-client/src/lib.rs +++ b/codex-rs/rmcp-client/src/lib.rs @@ -8,6 +8,7 @@ mod incoming_jsonrpc; mod local_stdio_transport; mod logging_client_handler; mod oauth; +mod oauth_client_registration; mod oauth_http_client; mod perform_oauth_login; mod program_resolver; @@ -35,6 +36,7 @@ pub use oauth::delete_oauth_tokens; pub use oauth::save_oauth_tokens; pub use oauth::stored_oauth_credential_snapshot; pub use oauth::stored_oauth_credentials; +pub use oauth_client_registration::McpOAuthClientRegistration; pub use perform_oauth_login::OAuthProviderError; pub use perform_oauth_login::OauthLoginHandle; pub use perform_oauth_login::perform_oauth_login; diff --git a/codex-rs/rmcp-client/src/oauth_client_registration.rs b/codex-rs/rmcp-client/src/oauth_client_registration.rs new file mode 100644 index 0000000000..894b438232 --- /dev/null +++ b/codex-rs/rmcp-client/src/oauth_client_registration.rs @@ -0,0 +1,40 @@ +use std::sync::Arc; + +use anyhow::Result; +use rmcp::transport::AuthorizationManager; +use rmcp::transport::AuthorizationRequest; +use rmcp::transport::auth::OAuthHttpClient; +use rmcp::transport::auth::OAuthState; + +/// 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. + #[default] + Auto, + /// Require the authorization server's Dynamic Client Registration endpoint. + Dcr, +} + +pub(crate) async fn start_authorization( + server_url: &str, + http_client: Arc, + scopes: &[&str], + redirect_uri: &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) +} diff --git a/codex-rs/rmcp-client/src/perform_oauth_login.rs b/codex-rs/rmcp-client/src/perform_oauth_login.rs index 4178464187..0345af069b 100644 --- a/codex-rs/rmcp-client/src/perform_oauth_login.rs +++ b/codex-rs/rmcp-client/src/perform_oauth_login.rs @@ -11,7 +11,6 @@ use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use codex_exec_server::HttpClient; use rmcp::transport::AuthorizationManager; -use rmcp::transport::AuthorizationRequest; use rmcp::transport::AuthorizationSession; use rmcp::transport::auth::OAuthClientConfig; use rmcp::transport::auth::OAuthHttpClient; @@ -29,6 +28,8 @@ use crate::StoredOAuthTokens; use crate::WrappedOAuthTokenResponse; use crate::http_client_adapter::StreamableHttpRedirectMode; use crate::oauth::compute_expires_at_millis; +use crate::oauth_client_registration::McpOAuthClientRegistration; +use crate::oauth_client_registration::start_authorization as start_client_registration; use crate::oauth_http_client::OAuthHttpClientAdapter; use crate::save_oauth_tokens; use crate::utils::build_default_headers; @@ -92,6 +93,7 @@ pub async fn perform_oauth_login( env_http_headers: Option>, scopes: &[String], oauth_client_id: Option<&str>, + client_registration: McpOAuthClientRegistration, oauth_resource: Option<&str>, callback_port: Option, callback_url: Option<&str>, @@ -106,6 +108,7 @@ pub async fn perform_oauth_login( env_http_headers, scopes, oauth_client_id, + client_registration, oauth_resource, callback_port, callback_url, @@ -126,6 +129,7 @@ pub async fn perform_oauth_login_silent( env_http_headers: Option>, scopes: &[String], oauth_client_id: Option<&str>, + client_registration: McpOAuthClientRegistration, oauth_resource: Option<&str>, callback_port: Option, callback_url: Option<&str>, @@ -141,6 +145,7 @@ pub async fn perform_oauth_login_silent( env_http_headers, scopes, oauth_client_id, + client_registration, oauth_resource, callback_port, callback_url, @@ -161,6 +166,7 @@ async fn perform_oauth_login_with_browser_output( env_http_headers: Option>, scopes: &[String], oauth_client_id: Option<&str>, + client_registration: McpOAuthClientRegistration, oauth_resource: Option<&str>, callback_port: Option, callback_url: Option<&str>, @@ -182,6 +188,7 @@ async fn perform_oauth_login_with_browser_output( http_context, scopes, oauth_client_id, + client_registration, oauth_resource, /*launch_browser*/ true, callback_port, @@ -203,6 +210,7 @@ pub async fn perform_oauth_login_return_url( env_http_headers: Option>, scopes: &[String], oauth_client_id: Option<&str>, + client_registration: McpOAuthClientRegistration, oauth_resource: Option<&str>, timeout_secs: Option, callback_port: Option, @@ -224,6 +232,7 @@ pub async fn perform_oauth_login_return_url( http_context, scopes, oauth_client_id, + client_registration, oauth_resource, /*launch_browser*/ false, callback_port, @@ -492,6 +501,7 @@ impl OauthLoginFlow { http_context: OAuthHttpContext, scopes: &[String], oauth_client_id: Option<&str>, + client_registration: McpOAuthClientRegistration, oauth_resource: Option<&str>, launch_browser: bool, callback_port: Option, @@ -541,14 +551,27 @@ impl OauthLoginFlow { )); let scope_refs: Vec<&str> = scopes.iter().map(String::as_str).collect(); - let oauth_state = start_authorization( - server_url, - oauth_http_client, - &scope_refs, - &redirect_uri, - oauth_client_id, - ) - .await?; + let oauth_state = if let Some(oauth_client_id) = + oauth_client_id.filter(|client_id| !client_id.trim().is_empty()) + { + start_authorization( + server_url, + oauth_http_client, + &scope_refs, + &redirect_uri, + oauth_client_id, + ) + .await? + } else { + start_client_registration( + server_url, + oauth_http_client, + &scope_refs, + &redirect_uri, + client_registration, + ) + .await? + }; let auth_url = append_query_param( &oauth_state.get_authorization_url().await?, "resource", @@ -670,25 +693,12 @@ async fn start_authorization( http_client: Arc, scopes: &[&str], redirect_uri: &str, - oauth_client_id: Option<&str>, + oauth_client_id: &str, ) -> Result { let mut auth_manager = AuthorizationManager::new_with_oauth_http_client(server_url, http_client).await?; auth_manager.set_allow_missing_issuer(true); - let Some(oauth_client_id) = oauth_client_id.filter(|client_id| !client_id.trim().is_empty()) - else { - let mut oauth_state = OAuthState::Unauthorized(auth_manager); - oauth_state - .start_authorization( - AuthorizationRequest::new(redirect_uri) - .with_scopes(scopes.iter().copied()) - .with_client_name("Codex"), - ) - .await?; - return Ok(oauth_state); - }; - let metadata = auth_manager.resolve_metadata().await?.metadata; auth_manager.set_metadata(metadata); auth_manager.configure_client( @@ -721,6 +731,7 @@ fn append_query_param(url: &str, key: &str, value: Option<&str>) -> String { #[cfg(test)] mod tests { + use std::collections::HashMap; use std::sync::Arc; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; @@ -747,6 +758,7 @@ mod tests { use url::Url; use super::CallbackOutcome; + use super::McpOAuthClientRegistration; use super::OAuthHttpClientAdapter; use super::OAuthProviderError; use super::StreamableHttpRedirectMode; @@ -790,7 +802,7 @@ mod tests { } } - async fn spawn_oauth_metadata_server() -> String { + async fn spawn_oauth_metadata_server() -> (String, Arc) { let listener = TcpListener::bind("127.0.0.1:0") .await .expect("bind metadata listener"); @@ -800,8 +812,11 @@ mod tests { "issuer": format!("{base_url}/mcp"), "authorization_endpoint": format!("{base_url}/oauth/authorize"), "token_endpoint": format!("{base_url}/oauth/token"), - "scopes_supported": [""], + "registration_endpoint": format!("{base_url}/oauth/register"), + "scopes_supported": ["read", "offline_access"], }); + let registration_requests = Arc::new(AtomicUsize::new(0)); + let captured_registration_requests = Arc::clone(®istration_requests); let path_scoped_metadata = metadata.clone(); let app = Router::new() .route( @@ -817,6 +832,16 @@ mod tests { let metadata = metadata.clone(); async move { Json(metadata) } }), + ) + .route( + "/oauth/register", + post(move || { + let registration_requests = Arc::clone(&captured_registration_requests); + async move { + registration_requests.fetch_add(1, Ordering::SeqCst); + Json(json!({"client_id": "unexpected-dynamic-client"})) + } + }), ); tokio::spawn(async move { @@ -825,38 +850,50 @@ mod tests { .expect("serve oauth metadata"); }); - base_url + (base_url, registration_requests) } #[tokio::test] - async fn start_authorization_uses_configured_client_id() { - let base_url = spawn_oauth_metadata_server().await; - let oauth_state = start_authorization( - &format!("{base_url}/mcp"), - Arc::new(OAuthHttpClientAdapter::new( - Arc::new(RouteAwareHttpClient::new(HttpClientFactory::new( - OutboundProxyPolicy::ReqwestDefault, - ))), - HeaderMap::new(), - )), - &[], - "http://127.0.0.1/callback", - Some("eci-prd-pub-codex-123"), - ) - .await - .expect("start oauth authorization"); - - let authorization_url = oauth_state - .get_authorization_url() + async fn configured_client_preserves_exact_scopes_and_redirect_without_registration() { + for (scopes, expected_scope) in [(&[][..], None), (&["read"][..], Some("read"))] { + let (base_url, registration_requests) = spawn_oauth_metadata_server().await; + let redirect_uri = "http://127.0.0.1:43123/callback/configured-client"; + let oauth_state = start_authorization( + &format!("{base_url}/mcp"), + Arc::new(OAuthHttpClientAdapter::new( + Arc::new(RouteAwareHttpClient::new(HttpClientFactory::new( + OutboundProxyPolicy::ReqwestDefault, + ))), + HeaderMap::new(), + )), + scopes, + redirect_uri, + "eci-prd-pub-codex-123", + ) .await - .expect("read authorization url"); - let auth_url = Url::parse(&authorization_url).expect("authorization url should parse"); - let client_id = auth_url - .query_pairs() - .find(|(key, _)| key == "client_id") - .map(|(_, value)| value.into_owned()); + .expect("start pre-registered OAuth authorization"); - assert_eq!(client_id.as_deref(), Some("eci-prd-pub-codex-123")); + let authorization_url = oauth_state + .get_authorization_url() + .await + .expect("read authorization URL"); + let query = Url::parse(&authorization_url) + .expect("authorization URL should parse") + .query_pairs() + .into_owned() + .collect::>(); + + assert_eq!( + query.get("client_id").map(String::as_str), + Some("eci-prd-pub-codex-123") + ); + assert_eq!( + query.get("redirect_uri").map(String::as_str), + Some(redirect_uri) + ); + assert_eq!(query.get("scope").map(String::as_str), expected_scope); + assert_eq!(registration_requests.load(Ordering::SeqCst), 0); + } } #[tokio::test] @@ -925,7 +962,7 @@ mod tests { )), &[], "http://127.0.0.1/callback", - Some("test-client"), + "test-client", ) .await .expect("start issuer-aware authorization"); @@ -970,6 +1007,7 @@ mod tests { /*env_http_headers*/ None, &[], /*oauth_client_id*/ None, + McpOAuthClientRegistration::Auto, /*oauth_resource*/ None, /*callback_port*/ None, /*callback_url*/ None, @@ -993,6 +1031,7 @@ mod tests { /*env_http_headers*/ None, &[], /*oauth_client_id*/ None, + McpOAuthClientRegistration::Auto, /*oauth_resource*/ None, /*callback_port*/ None, /*callback_url*/ None,