From 12d375dc8e8aa560ec50542c7ec3781be3a87a19 Mon Sep 17 00:00:00 2001 From: Steven Lee Date: Wed, 13 May 2026 11:18:27 -0700 Subject: [PATCH] Support OAuth client metadata URLs for MCP login --- .../schema/json/ClientRequest.json | 6 + .../codex_app_server_protocol.schemas.json | 6 + .../codex_app_server_protocol.v2.schemas.json | 6 + .../json/v2/McpServerOauthLoginParams.json | 6 + .../v2/McpServerOauthLoginParams.ts | 2 +- .../src/protocol/common.rs | 1 + .../src/protocol/v2/mcp.rs | 3 + codex-rs/app-server/README.md | 2 +- .../src/request_processors/mcp_processor.rs | 2 + .../src/request_processors/plugins.rs | 2 + codex-rs/cli/src/mcp_cmd.rs | 2 + codex-rs/core/src/mcp_skill_dependencies.rs | 2 + .../rmcp-client/src/perform_oauth_login.rs | 318 +++++++++++++++++- 13 files changed, 353 insertions(+), 5 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index a6fe99b35e..8ec4b07438 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1560,6 +1560,12 @@ }, "McpServerOauthLoginParams": { "properties": { + "clientMetadataUrlBase": { + "type": [ + "string", + "null" + ] + }, "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 6da572813f..b6483996b9 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 @@ -10742,6 +10742,12 @@ "McpServerOauthLoginParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "clientMetadataUrlBase": { + "type": [ + "string", + "null" + ] + }, "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 e338f4151f..2535e815be 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 @@ -7291,6 +7291,12 @@ "McpServerOauthLoginParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "clientMetadataUrlBase": { + "type": [ + "string", + "null" + ] + }, "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 4370f444b9..1b79014202 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,12 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "clientMetadataUrlBase": { + "type": [ + "string", + "null" + ] + }, "name": { "type": "string" }, 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 a61c304609..4b157587d3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/McpServerOauthLoginParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerOauthLoginParams.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 McpServerOauthLoginParams = { name: string, scopes?: Array | null, timeoutSecs?: bigint | null, }; +export type McpServerOauthLoginParams = { name: string, scopes?: Array | null, clientMetadataUrlBase?: string | null, timeoutSecs?: bigint | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index b70af1a22b..2f1f39efe4 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -1726,6 +1726,7 @@ mod tests { params: v2::McpServerOauthLoginParams { name: "server-a".to_string(), scopes: None, + client_metadata_url_base: 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 9fd9384076..0e0907e42c 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs @@ -187,6 +187,9 @@ pub struct McpServerOauthLoginParams { pub scopes: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional = nullable)] + pub client_metadata_url_base: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] pub timeout_secs: Option, } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 788fc9e7c3..98bee8d42f 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -206,7 +206,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 (**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; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes. +- `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes. Clients may optionally pass `clientMetadataUrlBase`; the app server appends its deterministic callback id and `client.json`, then sends the resulting URL as the OAuth client id metadata document. - `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 and auth status, plus resources/resource templates for `full` detail; supports cursor+limit pagination. If `detail` is omitted, the server defaults to `full`. 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 812108a15c..f275876192 100644 --- a/codex-rs/app-server/src/request_processors/mcp_processor.rs +++ b/codex-rs/app-server/src/request_processors/mcp_processor.rs @@ -117,6 +117,7 @@ impl McpRequestProcessor { let McpServerOauthLoginParams { name, scopes, + client_metadata_url_base, timeout_secs, } = params; @@ -161,6 +162,7 @@ impl McpRequestProcessor { env_http_headers, &resolved_scopes.scopes, server.oauth_resource.as_deref(), + client_metadata_url_base.as_deref(), timeout_secs, config.mcp_oauth_callback_port, config.mcp_oauth_callback_url.as_deref(), diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 6980945ae2..94984d9ebd 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -1350,6 +1350,7 @@ impl PluginRequestProcessor { oauth_config.env_http_headers.clone(), &resolved_scopes.scopes, server.oauth_resource.as_deref(), + /*client_metadata_url_base*/ None, callback_port, callback_url.as_deref(), ) @@ -1365,6 +1366,7 @@ impl PluginRequestProcessor { oauth_config.env_http_headers, &[], server.oauth_resource.as_deref(), + /*client_metadata_url_base*/ None, callback_port, callback_url.as_deref(), ) diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index af75999163..1ab305d139 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -211,6 +211,7 @@ async fn perform_oauth_login_retry_without_scopes( env_http_headers.clone(), &resolved_scopes.scopes, oauth_resource, + /*client_metadata_url_base*/ None, callback_port, callback_url, ) @@ -227,6 +228,7 @@ async fn perform_oauth_login_retry_without_scopes( env_http_headers, &[], oauth_resource, + /*client_metadata_url_base*/ None, callback_port, callback_url, ) diff --git a/codex-rs/core/src/mcp_skill_dependencies.rs b/codex-rs/core/src/mcp_skill_dependencies.rs index 44764e0064..363a20d136 100644 --- a/codex-rs/core/src/mcp_skill_dependencies.rs +++ b/codex-rs/core/src/mcp_skill_dependencies.rs @@ -159,6 +159,7 @@ pub(crate) async fn maybe_install_mcp_dependencies( oauth_config.env_http_headers.clone(), &resolved_scopes.scopes, server_config.oauth_resource.as_deref(), + /*client_metadata_url_base*/ None, config.mcp_oauth_callback_port, config.mcp_oauth_callback_url.as_deref(), ) @@ -174,6 +175,7 @@ pub(crate) async fn maybe_install_mcp_dependencies( oauth_config.env_http_headers, &[], server_config.oauth_resource.as_deref(), + /*client_metadata_url_base*/ None, config.mcp_oauth_callback_port, config.mcp_oauth_callback_url.as_deref(), ) diff --git a/codex-rs/rmcp-client/src/perform_oauth_login.rs b/codex-rs/rmcp-client/src/perform_oauth_login.rs index f42786c652..f90efc71c6 100644 --- a/codex-rs/rmcp-client/src/perform_oauth_login.rs +++ b/codex-rs/rmcp-client/src/perform_oauth_login.rs @@ -82,6 +82,7 @@ pub async fn perform_oauth_login( env_http_headers: Option>, scopes: &[String], oauth_resource: Option<&str>, + client_metadata_url_base: Option<&str>, callback_port: Option, callback_url: Option<&str>, ) -> Result<()> { @@ -93,6 +94,7 @@ pub async fn perform_oauth_login( env_http_headers, scopes, oauth_resource, + client_metadata_url_base, callback_port, callback_url, /*emit_browser_url*/ true, @@ -109,6 +111,7 @@ pub async fn perform_oauth_login_silent( env_http_headers: Option>, scopes: &[String], oauth_resource: Option<&str>, + client_metadata_url_base: Option<&str>, callback_port: Option, callback_url: Option<&str>, ) -> Result<()> { @@ -120,6 +123,7 @@ pub async fn perform_oauth_login_silent( env_http_headers, scopes, oauth_resource, + client_metadata_url_base, callback_port, callback_url, /*emit_browser_url*/ false, @@ -136,6 +140,7 @@ async fn perform_oauth_login_with_browser_output( env_http_headers: Option>, scopes: &[String], oauth_resource: Option<&str>, + client_metadata_url_base: Option<&str>, callback_port: Option, callback_url: Option<&str>, emit_browser_url: bool, @@ -151,6 +156,7 @@ async fn perform_oauth_login_with_browser_output( headers, scopes, oauth_resource, + client_metadata_url_base, /*launch_browser*/ true, callback_port, callback_url, @@ -170,6 +176,7 @@ pub async fn perform_oauth_login_return_url( env_http_headers: Option>, scopes: &[String], oauth_resource: Option<&str>, + client_metadata_url_base: Option<&str>, timeout_secs: Option, callback_port: Option, callback_url: Option<&str>, @@ -185,6 +192,7 @@ pub async fn perform_oauth_login_return_url( headers, scopes, oauth_resource, + client_metadata_url_base, /*launch_browser*/ false, callback_port, callback_url, @@ -407,6 +415,35 @@ fn append_callback_id_to_redirect_uri(redirect_uri: &str, callback_id: &str) -> Ok(parsed.to_string()) } +fn build_callback_scoped_client_metadata_url( + client_metadata_url_base: &str, + callback_id: &str, + redirect_uri: &str, +) -> Result { + let mut parsed = Url::parse(client_metadata_url_base).with_context(|| { + format!("invalid MCP OAuth client metadata URL base `{client_metadata_url_base}`") + })?; + if parsed.query().is_some() { + bail!( + "invalid MCP OAuth client metadata URL base `{client_metadata_url_base}`: query is not allowed" + ); + } + if parsed.fragment().is_some() { + bail!( + "invalid MCP OAuth client metadata URL base `{client_metadata_url_base}`: fragment is not allowed" + ); + } + + let base_path = parsed.path().trim_end_matches('/'); + parsed.set_path(&format!("{base_path}/{callback_id}/client.json")); + + Ok(set_query_param( + parsed.as_str(), + "redirect_uri", + redirect_uri, + )) +} + fn callback_path_from_redirect_uri(redirect_uri: &str) -> Result { let parsed = Url::parse(redirect_uri) .with_context(|| format!("invalid redirect URI `{redirect_uri}`"))?; @@ -437,6 +474,7 @@ impl OauthLoginFlow { headers: OauthHeaders, scopes: &[String], oauth_resource: Option<&str>, + client_metadata_url_base: Option<&str>, launch_browser: bool, callback_port: Option, callback_url: Option<&str>, @@ -459,6 +497,11 @@ impl OauthLoginFlow { let redirect_uri = resolve_redirect_uri(&server, callback_url)?; let callback_id = callback_id_from_server_url(server_url)?; let redirect_uri = append_callback_id_to_redirect_uri(&redirect_uri, &callback_id)?; + let client_metadata_url = client_metadata_url_base + .map(|url_base| { + build_callback_scoped_client_metadata_url(url_base, &callback_id, &redirect_uri) + }) + .transpose()?; let callback_path = callback_path_from_redirect_uri(&redirect_uri)?; let (tx, rx) = oneshot::channel(); @@ -473,9 +516,20 @@ impl OauthLoginFlow { let mut oauth_state = OAuthState::new(server_url, Some(http_client)).await?; let scope_refs: Vec<&str> = scopes.iter().map(String::as_str).collect(); - oauth_state - .start_authorization(&scope_refs, &redirect_uri, Some("Codex")) - .await?; + if let Some(client_metadata_url) = client_metadata_url.as_deref() { + oauth_state + .start_authorization_with_metadata_url( + &scope_refs, + &redirect_uri, + Some("Codex"), + Some(client_metadata_url), + ) + .await?; + } else { + oauth_state + .start_authorization(&scope_refs, &redirect_uri, Some("Codex")) + .await?; + } let auth_url = append_query_param( &oauth_state.get_authorization_url().await?, "resource", @@ -602,17 +656,228 @@ fn append_query_param(url: &str, key: &str, value: Option<&str>) -> String { format!("{url}{separator}{key}={encoded}") } +fn set_query_param(url: &str, key: &str, value: &str) -> String { + if let Ok(mut parsed) = Url::parse(url) { + let existing_pairs: Vec<(String, String)> = parsed + .query_pairs() + .filter(|(existing_key, _)| existing_key != key) + .map(|(existing_key, existing_value)| { + (existing_key.into_owned(), existing_value.into_owned()) + }) + .collect(); + { + let mut query = parsed.query_pairs_mut(); + query.clear(); + for (existing_key, existing_value) in existing_pairs { + query.append_pair(&existing_key, &existing_value); + } + query.append_pair(key, value); + } + return parsed.to_string(); + } + + append_query_param(url, key, Some(value)) +} + #[cfg(test)] mod tests { use pretty_assertions::assert_eq; + use reqwest::Url; + use std::sync::Arc; + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; + use tiny_http::Header; + use tiny_http::Method; + use tiny_http::Response; + use tiny_http::Server; use super::CallbackOutcome; use super::OAuthProviderError; + use super::OauthHeaders; + use super::OauthLoginFlow; use super::append_callback_id_to_redirect_uri; use super::append_query_param; + use super::build_callback_scoped_client_metadata_url; use super::callback_id_from_server_url; use super::callback_path_from_redirect_uri; use super::parse_oauth_callback; + use super::set_query_param; + use codex_config::types::OAuthCredentialsStoreMode; + + struct TestOAuthServer { + server: Arc, + base_url: String, + registration_hits: Arc, + } + + impl TestOAuthServer { + fn new(supports_metadata_url: bool) -> Self { + let server = Arc::new(Server::http("127.0.0.1:0").expect("bind test OAuth server")); + let base_url = local_server_base_url(&server); + let registration_hits = Arc::new(AtomicUsize::new(0)); + let server_for_thread = Arc::clone(&server); + let hits_for_thread = Arc::clone(®istration_hits); + let base_url_for_thread = base_url.clone(); + + std::thread::spawn(move || { + while let Ok(request) = server_for_thread.recv() { + let path = request + .url() + .split_once('?') + .map(|(path, _)| path) + .unwrap_or_else(|| request.url()); + + match (request.method(), path) { + (&Method::Get, "/.well-known/oauth-authorization-server") => { + let body = serde_json::json!({ + "authorization_endpoint": format!("{base_url_for_thread}/authorize"), + "token_endpoint": format!("{base_url_for_thread}/token"), + "registration_endpoint": format!("{base_url_for_thread}/register"), + "response_types_supported": ["code"], + "client_id_metadata_document_supported": supports_metadata_url, + }); + let _ = request.respond(json_response(body)); + } + (&Method::Post, "/register") => { + hits_for_thread.fetch_add(1, Ordering::SeqCst); + let body = serde_json::json!({ + "client_id": "dynamic-client", + "client_secret": null, + "client_name": "Codex", + "redirect_uris": ["http://127.0.0.1/callback"], + }); + let _ = request.respond(json_response(body)); + } + _ => { + let _ = request + .respond(Response::from_string("not found").with_status_code(404)); + } + } + } + }); + + Self { + server, + base_url, + registration_hits, + } + } + + fn base_url(&self) -> &str { + &self.base_url + } + + fn registration_hits(&self) -> usize { + self.registration_hits.load(Ordering::SeqCst) + } + } + + impl Drop for TestOAuthServer { + fn drop(&mut self) { + self.server.unblock(); + } + } + + fn local_server_base_url(server: &Server) -> String { + match server.server_addr() { + tiny_http::ListenAddr::IP(std::net::SocketAddr::V4(addr)) => { + format!("http://{}:{}", addr.ip(), addr.port()) + } + tiny_http::ListenAddr::IP(std::net::SocketAddr::V6(addr)) => { + format!("http://[{}]:{}", addr.ip(), addr.port()) + } + #[cfg(not(target_os = "windows"))] + _ => panic!("unexpected test server address"), + } + } + + fn json_response(body: serde_json::Value) -> Response>> { + Response::from_string(body.to_string()).with_header( + Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]) + .expect("valid content-type header"), + ) + } + + async fn start_test_oauth_flow( + oauth_server: &TestOAuthServer, + client_metadata_url_base: Option<&str>, + ) -> String { + let flow = OauthLoginFlow::new( + "test-server", + oauth_server.base_url(), + OAuthCredentialsStoreMode::File, + OauthHeaders { + http_headers: None, + env_http_headers: None, + }, + &[], + /*oauth_resource*/ None, + client_metadata_url_base, + /*launch_browser*/ false, + /*callback_port*/ None, + /*callback_url*/ None, + Some(1), + ) + .await + .expect("start OAuth flow"); + flow.authorization_url() + } + + fn authorization_url_client_id(authorization_url: &str) -> String { + Url::parse(authorization_url) + .expect("valid authorization URL") + .query_pairs() + .find_map(|(key, value)| (key == "client_id").then(|| value.into_owned())) + .expect("authorization URL includes client_id") + } + + fn client_id_redirect_uri(authorization_url: &str) -> String { + let client_id = authorization_url_client_id(authorization_url); + Url::parse(&client_id) + .expect("client_id should be a URL") + .query_pairs() + .find_map(|(key, value)| (key == "redirect_uri").then(|| value.into_owned())) + .expect("client_id includes redirect_uri") + } + + #[tokio::test] + async fn authorization_uses_callback_scoped_client_metadata_url_as_client_id_when_supplied() { + let oauth_server = TestOAuthServer::new(/*supports_metadata_url*/ true); + let client_metadata_url_base = "https://chatgpt.com/codex/local-mcp/oauth"; + + let authorization_url = + start_test_oauth_flow(&oauth_server, Some(client_metadata_url_base)).await; + let client_id = authorization_url_client_id(&authorization_url); + let client_id_url = Url::parse(&client_id).expect("client ID should parse as URL"); + let callback_id = + callback_id_from_server_url(oauth_server.base_url()).expect("server URL should parse"); + + assert_eq!( + client_id_url.path(), + format!("/codex/local-mcp/oauth/{callback_id}/client.json") + ); + assert_eq!( + Url::parse(&client_id_redirect_uri(&authorization_url)) + .expect("redirect URI should parse") + .path(), + format!("/callback/{callback_id}") + ); + assert_eq!(oauth_server.registration_hits(), 0); + } + + #[tokio::test] + async fn authorization_omits_client_metadata_url_by_default() { + let oauth_server = TestOAuthServer::new(/*supports_metadata_url*/ true); + + let authorization_url = + start_test_oauth_flow(&oauth_server, /*client_metadata_url_base*/ None).await; + + assert_eq!( + authorization_url_client_id(&authorization_url), + "dynamic-client" + ); + assert_eq!(oauth_server.registration_hits(), 1); + } #[test] fn parse_oauth_callback_accepts_default_path() { @@ -717,6 +982,39 @@ mod tests { ); } + #[test] + fn client_metadata_url_base_is_scoped_to_callback_id() { + let client_metadata_url = build_callback_scoped_client_metadata_url( + "https://chatgpt.com/codex/local-mcp/oauth/", + "abc123", + "http://127.0.0.1:4321/callback/abc123", + ) + .expect("client metadata URL should build"); + + assert_eq!( + client_metadata_url, + "https://chatgpt.com/codex/local-mcp/oauth/abc123/client.json?redirect_uri=http%3A%2F%2F127.0.0.1%3A4321%2Fcallback%2Fabc123" + ); + } + + #[test] + fn client_metadata_url_base_rejects_query_or_fragment() { + for url in [ + "https://chatgpt.com/codex/local-mcp/oauth?variant=one", + "https://chatgpt.com/codex/local-mcp/oauth#fragment", + ] { + assert!( + build_callback_scoped_client_metadata_url( + url, + "abc123", + "http://127.0.0.1:4321/callback/abc123", + ) + .is_err(), + "expected `{url}` to be rejected" + ); + } + } + #[test] fn append_query_param_adds_resource_to_absolute_url() { let url = append_query_param( @@ -748,4 +1046,18 @@ mod tests { assert_eq!(url, "not a url?resource=api%2Fresource"); } + + #[test] + fn set_query_param_replaces_existing_value() { + let url = set_query_param( + "https://example.com/client.json?foo=bar&redirect_uri=old", + "redirect_uri", + "http://127.0.0.1:4321/callback/abc123", + ); + + assert_eq!( + url, + "https://example.com/client.json?foo=bar&redirect_uri=http%3A%2F%2F127.0.0.1%3A4321%2Fcallback%2Fabc123" + ); + } }