From 63e0a9b4769d4f057cacc8353210a4ed006866d9 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 25 Feb 2026 21:12:14 -0800 Subject: [PATCH] Fix MCP OAuth resource handling --- .../app-server/src/codex_message_processor.rs | 1 + codex-rs/cli/src/mcp_cmd.rs | 3 + codex-rs/core/config.schema.json | 4 ++ codex-rs/core/src/config/edit.rs | 12 ++++ codex-rs/core/src/config/mod.rs | 60 ++++++++++++++++++ codex-rs/core/src/config/types.rs | 39 ++++++++++++ codex-rs/core/src/mcp/mod.rs | 1 + codex-rs/core/src/mcp/skill_dependencies.rs | 5 ++ codex-rs/core/src/mcp_connection_manager.rs | 2 + codex-rs/core/tests/suite/rmcp_client.rs | 6 ++ codex-rs/core/tests/suite/search_tool.rs | 1 + codex-rs/core/tests/suite/truncation.rs | 3 + .../rmcp-client/src/perform_oauth_login.rs | 63 ++++++++++++++++++- codex-rs/tui/src/history_cell.rs | 2 + 14 files changed, 201 insertions(+), 1 deletion(-) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 594720acaf2..7eefb9d7ed4 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -4149,6 +4149,7 @@ impl CodexMessageProcessor { http_headers, env_http_headers, scopes.as_deref().unwrap_or_default(), + server.oauth_resource.as_deref(), timeout_secs, config.mcp_oauth_callback_port, config.mcp_oauth_callback_url.as_deref(), diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index 396e0774114..2da868cada2 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -250,6 +250,7 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re enabled_tools: None, disabled_tools: None, scopes: None, + oauth_resource: None, }; servers.insert(name.clone(), new_entry); @@ -272,6 +273,7 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re oauth_config.http_headers, oauth_config.env_http_headers, &Vec::new(), + None, config.mcp_oauth_callback_port, config.mcp_oauth_callback_url.as_deref(), ) @@ -356,6 +358,7 @@ async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs) http_headers, env_http_headers, &scopes, + server.oauth_resource.as_deref(), config.mcp_oauth_callback_port, config.mcp_oauth_callback_url.as_deref(), ) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index e1f0fa5147d..d994d5fe217 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1141,6 +1141,10 @@ }, "type": "object" }, + "oauth_resource": { + "default": null, + "type": "string" + }, "required": { "default": null, "type": "boolean" diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index fceb9659989..35f8f250105 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -195,6 +195,11 @@ mod document_helpers { { entry["scopes"] = array_from_iter(scopes.iter().cloned()); } + if let Some(resource) = &config.oauth_resource + && !resource.is_empty() + { + entry["oauth_resource"] = value(resource.clone()); + } entry } @@ -1441,6 +1446,7 @@ gpt-5 = "gpt-5.1" enabled_tools: Some(vec!["one".to_string(), "two".to_string()]), disabled_tools: None, scopes: None, + oauth_resource: None, }, ); @@ -1465,6 +1471,7 @@ gpt-5 = "gpt-5.1" enabled_tools: None, disabled_tools: Some(vec!["forbidden".to_string()]), scopes: None, + oauth_resource: Some("https://resource.example.com".to_string()), }, ); @@ -1483,6 +1490,7 @@ bearer_token_env_var = \"TOKEN\" enabled = false startup_timeout_sec = 5.0 disabled_tools = [\"forbidden\"] +oauth_resource = \"https://resource.example.com\" [mcp_servers.http.http_headers] Z-Header = \"z\" @@ -1532,6 +1540,7 @@ foo = { command = "cmd" } enabled_tools: None, disabled_tools: None, scopes: None, + oauth_resource: None, }, ); @@ -1578,6 +1587,7 @@ foo = { command = "cmd" } # keep me enabled_tools: None, disabled_tools: None, scopes: None, + oauth_resource: None, }, ); @@ -1623,6 +1633,7 @@ foo = { command = "cmd", args = ["--flag"] } # keep me enabled_tools: None, disabled_tools: None, scopes: None, + oauth_resource: None, }, ); @@ -1669,6 +1680,7 @@ foo = { command = "cmd" } enabled_tools: None, disabled_tools: None, scopes: None, + oauth_resource: None, }, ); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 306ba49c100..4bfcc9d7ac4 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -2411,6 +2411,7 @@ mod tests { enabled_tools: None, disabled_tools: None, scopes: None, + oauth_resource: None, } } @@ -2430,6 +2431,7 @@ mod tests { enabled_tools: None, disabled_tools: None, scopes: None, + oauth_resource: None, } } @@ -3464,6 +3466,7 @@ profile = "project" enabled_tools: None, disabled_tools: None, scopes: None, + oauth_resource: None, }, ); @@ -3621,6 +3624,7 @@ bearer_token = "secret" enabled_tools: None, disabled_tools: None, scopes: None, + oauth_resource: None, }, )]); @@ -3692,6 +3696,7 @@ ZIG_VAR = "3" enabled_tools: None, disabled_tools: None, scopes: None, + oauth_resource: None, }, )]); @@ -3743,6 +3748,7 @@ ZIG_VAR = "3" enabled_tools: None, disabled_tools: None, scopes: None, + oauth_resource: None, }, )]); @@ -3792,6 +3798,7 @@ ZIG_VAR = "3" enabled_tools: None, disabled_tools: None, scopes: None, + oauth_resource: None, }, )]); @@ -3857,6 +3864,7 @@ startup_timeout_sec = 2.0 enabled_tools: None, disabled_tools: None, scopes: None, + oauth_resource: None, }, )]); apply_blocking( @@ -3934,6 +3942,7 @@ X-Auth = "DOCS_AUTH" enabled_tools: None, disabled_tools: None, scopes: None, + oauth_resource: None, }, )]); @@ -3964,6 +3973,7 @@ X-Auth = "DOCS_AUTH" enabled_tools: None, disabled_tools: None, scopes: None, + oauth_resource: None, }, ); apply_blocking( @@ -4032,6 +4042,7 @@ url = "https://example.com/mcp" enabled_tools: None, disabled_tools: None, scopes: None, + oauth_resource: None, }, ), ( @@ -4052,6 +4063,7 @@ url = "https://example.com/mcp" enabled_tools: None, disabled_tools: None, scopes: None, + oauth_resource: None, }, ), ]); @@ -4135,6 +4147,7 @@ url = "https://example.com/mcp" enabled_tools: None, disabled_tools: None, scopes: None, + oauth_resource: None, }, )]); @@ -4180,6 +4193,7 @@ url = "https://example.com/mcp" enabled_tools: None, disabled_tools: None, scopes: None, + oauth_resource: None, }, )]); @@ -4225,6 +4239,7 @@ url = "https://example.com/mcp" enabled_tools: Some(vec!["allowed".to_string()]), disabled_tools: Some(vec!["blocked".to_string()]), scopes: None, + oauth_resource: None, }, )]); @@ -4253,6 +4268,51 @@ url = "https://example.com/mcp" Ok(()) } + #[tokio::test] + async fn replace_mcp_servers_streamable_http_serializes_oauth_resource() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + + let servers = BTreeMap::from([( + "docs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "https://example.com/mcp".to_string(), + bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, + }, + enabled: true, + required: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: Some("https://resource.example.com".to_string()), + }, + )]); + + apply_blocking( + codex_home.path(), + None, + &[ConfigEdit::ReplaceMcpServers(servers.clone())], + )?; + + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + let serialized = std::fs::read_to_string(&config_path)?; + assert!(serialized.contains(r#"oauth_resource = "https://resource.example.com""#)); + + let loaded = load_global_mcp_servers(codex_home.path()).await?; + let docs = loaded.get("docs").expect("docs entry"); + assert_eq!( + docs.oauth_resource.as_deref(), + Some("https://resource.example.com") + ); + + Ok(()) + } + #[tokio::test] async fn set_model_updates_defaults() -> anyhow::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index 678766e8078..a5e15355bdf 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -98,6 +98,10 @@ pub struct McpServerConfig { /// Optional OAuth scopes to request during MCP login. #[serde(default, skip_serializing_if = "Option::is_none")] pub scopes: Option>, + + /// Optional OAuth resource parameter to include during MCP login (RFC 8707). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub oauth_resource: Option, } // Raw MCP config shape used for deserialization and JSON Schema generation. @@ -142,6 +146,8 @@ pub(crate) struct RawMcpServerConfig { pub disabled_tools: Option>, #[serde(default)] pub scopes: Option>, + #[serde(default)] + pub oauth_resource: Option, } impl<'de> Deserialize<'de> for McpServerConfig { @@ -165,6 +171,7 @@ impl<'de> Deserialize<'de> for McpServerConfig { let enabled_tools = raw.enabled_tools.clone(); let disabled_tools = raw.disabled_tools.clone(); let scopes = raw.scopes.clone(); + let oauth_resource = raw.oauth_resource.clone(); fn throw_if_set(transport: &str, field: &str, value: Option<&T>) -> Result<(), E> where @@ -188,6 +195,7 @@ impl<'de> Deserialize<'de> for McpServerConfig { throw_if_set("stdio", "bearer_token", raw.bearer_token.as_ref())?; throw_if_set("stdio", "http_headers", raw.http_headers.as_ref())?; throw_if_set("stdio", "env_http_headers", raw.env_http_headers.as_ref())?; + throw_if_set("stdio", "oauth_resource", raw.oauth_resource.as_ref())?; McpServerTransportConfig::Stdio { command, args: raw.args.clone().unwrap_or_default(), @@ -221,6 +229,7 @@ impl<'de> Deserialize<'de> for McpServerConfig { enabled_tools, disabled_tools, scopes, + oauth_resource, }) } } @@ -1084,6 +1093,22 @@ mod tests { ); } + #[test] + fn deserialize_streamable_http_server_config_with_oauth_resource() { + let cfg: McpServerConfig = toml::from_str( + r#" + url = "https://example.com/mcp" + oauth_resource = "https://api.example.com" + "#, + ) + .expect("should deserialize http config with oauth_resource"); + + assert_eq!( + cfg.oauth_resource, + Some("https://api.example.com".to_string()) + ); + } + #[test] fn deserialize_server_config_with_tool_filters() { let cfg: McpServerConfig = toml::from_str( @@ -1138,6 +1163,20 @@ mod tests { "#, ) .expect_err("should reject env_http_headers for stdio transport"); + + let err = toml::from_str::( + r#" + command = "echo" + oauth_resource = "https://api.example.com" + "#, + ) + .expect_err("should reject oauth_resource for stdio transport"); + + assert!( + err.to_string() + .contains("oauth_resource is not supported for stdio"), + "unexpected error: {err}" + ); } #[test] diff --git a/codex-rs/core/src/mcp/mod.rs b/codex-rs/core/src/mcp/mod.rs index 3abe4e9233c..d744010469b 100644 --- a/codex-rs/core/src/mcp/mod.rs +++ b/codex-rs/core/src/mcp/mod.rs @@ -139,6 +139,7 @@ fn codex_apps_mcp_server_config(config: &Config, auth: Option<&CodexAuth>) -> Mc enabled_tools: None, disabled_tools: None, scopes: None, + oauth_resource: None, } } diff --git a/codex-rs/core/src/mcp/skill_dependencies.rs b/codex-rs/core/src/mcp/skill_dependencies.rs index 1969bc6322b..8d5fc3c1a35 100644 --- a/codex-rs/core/src/mcp/skill_dependencies.rs +++ b/codex-rs/core/src/mcp/skill_dependencies.rs @@ -241,6 +241,7 @@ pub(crate) async fn maybe_install_mcp_dependencies( oauth_config.http_headers, oauth_config.env_http_headers, &[], + server_config.oauth_resource.as_deref(), config.mcp_oauth_callback_port, config.mcp_oauth_callback_url.as_deref(), ) @@ -387,6 +388,7 @@ fn mcp_dependency_to_server_config( enabled_tools: None, disabled_tools: None, scopes: None, + oauth_resource: None, }); } @@ -411,6 +413,7 @@ fn mcp_dependency_to_server_config( enabled_tools: None, disabled_tools: None, scopes: None, + oauth_resource: None, }); } @@ -468,6 +471,7 @@ mod tests { enabled_tools: None, disabled_tools: None, scopes: None, + oauth_resource: None, }, )]); @@ -516,6 +520,7 @@ mod tests { enabled_tools: None, disabled_tools: None, scopes: None, + oauth_resource: None, }, )]); diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index 12e3d325d34..2d22351d1f8 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -2115,6 +2115,7 @@ mod tests { enabled_tools: None, disabled_tools: None, scopes: None, + oauth_resource: None, }, auth_status: McpAuthStatus::Unsupported, }; @@ -2162,6 +2163,7 @@ mod tests { enabled_tools: None, disabled_tools: None, scopes: None, + oauth_resource: None, }, auth_status: McpAuthStatus::Unsupported, }; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index df389c5ef74..104a737cbc5 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -104,6 +104,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { enabled_tools: None, disabled_tools: None, scopes: None, + oauth_resource: None, }, ); config @@ -246,6 +247,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { enabled_tools: None, disabled_tools: None, scopes: None, + oauth_resource: None, }, ); config @@ -462,6 +464,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re enabled_tools: None, disabled_tools: None, scopes: None, + oauth_resource: None, }, ); config @@ -580,6 +583,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { enabled_tools: None, disabled_tools: None, scopes: None, + oauth_resource: None, }, ); config @@ -739,6 +743,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { enabled_tools: None, disabled_tools: None, scopes: None, + oauth_resource: None, }, ); config @@ -958,6 +963,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { enabled_tools: None, disabled_tools: None, scopes: None, + oauth_resource: None, }, ); config diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index 1caab45b12b..d852c9cfb30 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -133,6 +133,7 @@ fn rmcp_server_config(command: String) -> McpServerConfig { enabled_tools: None, disabled_tools: None, scopes: None, + oauth_resource: None, } } diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index 8ede6c621f6..0a03c4c7dcc 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -371,6 +371,7 @@ async fn mcp_tool_call_output_exceeds_limit_truncated_for_model() -> Result<()> enabled_tools: None, disabled_tools: None, scopes: None, + oauth_resource: None, }, ); config @@ -465,6 +466,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { enabled_tools: None, disabled_tools: None, scopes: None, + oauth_resource: None, }, ); config @@ -730,6 +732,7 @@ async fn mcp_tool_call_output_not_truncated_with_custom_limit() -> Result<()> { enabled_tools: None, disabled_tools: None, scopes: None, + oauth_resource: None, }, ); config diff --git a/codex-rs/rmcp-client/src/perform_oauth_login.rs b/codex-rs/rmcp-client/src/perform_oauth_login.rs index 62a9c3b019f..c71799c6293 100644 --- a/codex-rs/rmcp-client/src/perform_oauth_login.rs +++ b/codex-rs/rmcp-client/src/perform_oauth_login.rs @@ -47,6 +47,7 @@ pub async fn perform_oauth_login( http_headers: Option>, env_http_headers: Option>, scopes: &[String], + oauth_resource: Option<&str>, callback_port: Option, callback_url: Option<&str>, ) -> Result<()> { @@ -60,6 +61,7 @@ pub async fn perform_oauth_login( store_mode, headers, scopes, + oauth_resource, true, callback_port, callback_url, @@ -78,6 +80,7 @@ pub async fn perform_oauth_login_return_url( http_headers: Option>, env_http_headers: Option>, scopes: &[String], + oauth_resource: Option<&str>, timeout_secs: Option, callback_port: Option, callback_url: Option<&str>, @@ -92,6 +95,7 @@ pub async fn perform_oauth_login_return_url( store_mode, headers, scopes, + oauth_resource, false, callback_port, callback_url, @@ -303,6 +307,7 @@ impl OauthLoginFlow { store_mode: OAuthCredentialsStoreMode, headers: OauthHeaders, scopes: &[String], + oauth_resource: Option<&str>, launch_browser: bool, callback_port: Option, callback_url: Option<&str>, @@ -340,7 +345,11 @@ impl OauthLoginFlow { oauth_state .start_authorization(&scope_refs, &redirect_uri, Some("Codex")) .await?; - let auth_url = oauth_state.get_authorization_url().await?; + let auth_url = append_query_param( + &oauth_state.get_authorization_url().await?, + "resource", + oauth_resource, + ); let timeout_secs = timeout_secs.unwrap_or(DEFAULT_OAUTH_TIMEOUT_SECS).max(1); let timeout = Duration::from_secs(timeout_secs as u64); @@ -431,9 +440,29 @@ impl OauthLoginFlow { } } +fn append_query_param(url: &str, key: &str, value: Option<&str>) -> String { + let Some(value) = value else { + return url.to_string(); + }; + let value = value.trim(); + if value.is_empty() { + return url.to_string(); + } + if let Ok(mut parsed) = Url::parse(url) { + parsed.query_pairs_mut().append_pair(key, value); + return parsed.to_string(); + } + let encoded = urlencoding::encode(value); + let separator = if url.contains('?') { "&" } else { "?" }; + format!("{url}{separator}{key}={encoded}") +} + #[cfg(test)] mod tests { + use pretty_assertions::assert_eq; + use super::CallbackOutcome; + use super::append_query_param; use super::callback_path_from_redirect_uri; use super::parse_oauth_callback; @@ -461,4 +490,36 @@ mod tests { .expect("redirect URI should parse"); assert_eq!(path, "/oauth/callback"); } + + #[test] + fn append_query_param_adds_resource_to_absolute_url() { + let url = append_query_param( + "https://example.com/authorize?scope=read", + "resource", + Some("https://api.example.com"), + ); + + assert_eq!( + url, + "https://example.com/authorize?scope=read&resource=https%3A%2F%2Fapi.example.com" + ); + } + + #[test] + fn append_query_param_ignores_empty_values() { + let url = append_query_param( + "https://example.com/authorize?scope=read", + "resource", + Some(" "), + ); + + assert_eq!(url, "https://example.com/authorize?scope=read"); + } + + #[test] + fn append_query_param_handles_unparseable_url() { + let url = append_query_param("not a url", "resource", Some("api/resource")); + + assert_eq!(url, "not a url?resource=api%2Fresource"); + } } diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 78312db27df..59f4c015019 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -2623,6 +2623,7 @@ mod tests { enabled_tools: None, disabled_tools: None, scopes: None, + oauth_resource: None, }; let mut servers = config.mcp_servers.get().clone(); servers.insert("docs".to_string(), stdio_config); @@ -2646,6 +2647,7 @@ mod tests { enabled_tools: None, disabled_tools: None, scopes: None, + oauth_resource: None, }; servers.insert("http".to_string(), http_config); config