Support explicit MCP OAuth client IDs (#22575)

## Why
Some MCP OAuth providers require a pre-registered public client ID and
cannot rely on dynamic client registration. Codex already supports MCP
OAuth, but it had no way to supply that client ID from config into the
PKCE flow.

## What changed
- add `oauth.client_id` under `[mcp_servers.<server>]` config, including
config editing and schema generation
- thread the configured client ID through CLI, app-server, plugin login,
and MCP skill dependency OAuth entrypoints
- configure RMCP authorization with the explicit client when present,
while preserving the existing dynamic-registration path when it is
absent
- add focused coverage for config parsing/serialization and OAuth URL
generation

## Verification
- `cargo test -p codex-config -p codex-rmcp-client -p codex-mcp -p
codex-core-plugins`
- `cargo test -p codex-core blocking_replace_mcp_servers_round_trips
--lib`
- `cargo test -p codex-core
replace_mcp_servers_streamable_http_serializes_oauth_resource --lib`
- `cargo test -p codex-core config_schema_matches_fixture --lib`

## Notes
Broader local package runs still hit unrelated pre-existing stack
overflows in:
- `codex-app-server::in_process_start_clamps_zero_channel_capacity`
-
`codex-core::resume_agent_from_rollout_uses_edge_data_when_descendant_metadata_source_is_stale`
This commit is contained in:
Matthew Zeng
2026-05-14 11:52:43 -07:00
committed by GitHub
parent 4a1f1df8ce
commit d8ddeb6869
26 changed files with 374 additions and 11 deletions

View File

@@ -1200,6 +1200,17 @@
}
]
},
"McpServerOAuthConfig": {
"additionalProperties": false,
"description": "OAuth client settings used when Codex launches an MCP OAuth flow.",
"properties": {
"client_id": {
"description": "Explicit OAuth client identifier to present during authorization and token exchange.",
"type": "string"
}
},
"type": "object"
},
"McpServerToolConfig": {
"additionalProperties": false,
"description": "Per-tool approval settings for a single MCP server tool.",
@@ -2124,6 +2135,14 @@
"description": "Legacy display-name field accepted for backward compatibility.",
"type": "string"
},
"oauth": {
"allOf": [
{
"$ref": "#/definitions/McpServerOAuthConfig"
}
],
"default": null
},
"oauth_resource": {
"default": null,
"type": "string"

View File

@@ -36,6 +36,7 @@ use codex_config::types::BundledSkillsConfig;
use codex_config::types::FeedbackConfigToml;
use codex_config::types::HistoryPersistence;
use codex_config::types::McpServerEnvVar;
use codex_config::types::McpServerOAuthConfig;
use codex_config::types::McpServerToolConfig;
use codex_config::types::McpServerTransportConfig;
use codex_config::types::MemoriesConfig;
@@ -124,6 +125,7 @@ fn stdio_mcp(command: &str) -> McpServerConfig {
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
}
@@ -148,6 +150,7 @@ fn http_mcp(url: &str) -> McpServerConfig {
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
}
@@ -4428,6 +4431,7 @@ async fn replace_mcp_servers_round_trips_entries() -> anyhow::Result<()> {
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
@@ -4721,6 +4725,7 @@ async fn replace_mcp_servers_serializes_env_sorted() -> anyhow::Result<()> {
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
@@ -4797,6 +4802,7 @@ async fn replace_mcp_servers_serializes_env_vars() -> anyhow::Result<()> {
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
@@ -4858,6 +4864,7 @@ async fn replace_mcp_servers_serializes_sourced_env_vars() -> anyhow::Result<()>
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
@@ -4909,6 +4916,7 @@ async fn replace_mcp_servers_serializes_cwd() -> anyhow::Result<()> {
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
@@ -4963,6 +4971,7 @@ async fn replace_mcp_servers_streamable_http_serializes_bearer_token() -> anyhow
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
@@ -5033,6 +5042,7 @@ async fn replace_mcp_servers_streamable_http_serializes_custom_headers() -> anyh
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
@@ -5115,6 +5125,7 @@ async fn replace_mcp_servers_streamable_http_removes_optional_sections() -> anyh
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
@@ -5150,6 +5161,7 @@ async fn replace_mcp_servers_streamable_http_removes_optional_sections() -> anyh
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
@@ -5220,6 +5232,7 @@ async fn replace_mcp_servers_streamable_http_isolates_headers_between_servers()
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
@@ -5245,6 +5258,7 @@ async fn replace_mcp_servers_streamable_http_isolates_headers_between_servers()
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
@@ -5333,6 +5347,7 @@ async fn replace_mcp_servers_serializes_disabled_flag() -> anyhow::Result<()> {
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
@@ -5383,6 +5398,7 @@ async fn replace_mcp_servers_serializes_required_flag() -> anyhow::Result<()> {
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
@@ -5433,6 +5449,7 @@ async fn replace_mcp_servers_serializes_tool_filters() -> anyhow::Result<()> {
enabled_tools: Some(vec!["allowed".to_string()]),
disabled_tools: Some(vec!["blocked".to_string()]),
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
@@ -5487,6 +5504,9 @@ async fn replace_mcp_servers_streamable_http_serializes_oauth_resource() -> anyh
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: Some(McpServerOAuthConfig {
client_id: Some("eci-prd-pub-codex-123".to_string()),
}),
oauth_resource: Some("https://resource.example.com".to_string()),
tools: HashMap::new(),
},
@@ -5500,6 +5520,8 @@ async fn replace_mcp_servers_streamable_http_serializes_oauth_resource() -> anyh
let config_path = codex_home.path().join(CONFIG_TOML_FILE);
let serialized = std::fs::read_to_string(&config_path)?;
assert!(serialized.contains("[mcp_servers.docs.oauth]"));
assert!(serialized.contains(r#"client_id = "eci-prd-pub-codex-123""#));
assert!(serialized.contains(r#"oauth_resource = "https://resource.example.com""#));
let loaded = load_global_mcp_servers(codex_home.path()).await?;
@@ -5508,6 +5530,7 @@ async fn replace_mcp_servers_streamable_http_serializes_oauth_resource() -> anyh
docs.oauth_resource.as_deref(),
Some("https://resource.example.com")
);
assert_eq!(docs.oauth_client_id(), Some("eci-prd-pub-codex-123"));
Ok(())
}

View File

@@ -341,6 +341,15 @@ mod document_helpers {
{
entry["scopes"] = array_from_iter(scopes.iter().cloned());
}
if let Some(oauth) = &config.oauth
&& let Some(client_id) = &oauth.client_id
&& !client_id.is_empty()
{
let mut oauth_table = TomlTable::new();
oauth_table.set_implicit(false);
oauth_table["client_id"] = value(client_id.clone());
entry["oauth"] = TomlItem::Table(oauth_table);
}
if let Some(resource) = &config.oauth_resource
&& !resource.is_empty()
{

View File

@@ -1,5 +1,6 @@
use super::*;
use codex_config::types::AppToolApproval;
use codex_config::types::McpServerOAuthConfig;
use codex_config::types::McpServerToolConfig;
use codex_config::types::McpServerTransportConfig;
use codex_config::types::SessionPickerViewMode;
@@ -940,6 +941,7 @@ fn blocking_replace_mcp_servers_round_trips() {
enabled_tools: Some(vec!["one".to_string(), "two".to_string()]),
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
@@ -969,6 +971,9 @@ fn blocking_replace_mcp_servers_round_trips() {
enabled_tools: None,
disabled_tools: Some(vec!["forbidden".to_string()]),
scopes: None,
oauth: Some(McpServerOAuthConfig {
client_id: Some("eci-prd-pub-codex-123".to_string()),
}),
oauth_resource: Some("https://resource.example.com".to_string()),
tools: HashMap::new(),
},
@@ -994,6 +999,9 @@ oauth_resource = \"https://resource.example.com\"
[mcp_servers.http.http_headers]
Z-Header = \"z\"
[mcp_servers.http.oauth]
client_id = \"eci-prd-pub-codex-123\"
[mcp_servers.stdio]
command = \"cmd\"
args = [\"--flag\"]
@@ -1035,6 +1043,7 @@ fn blocking_replace_mcp_servers_serializes_tool_approval_overrides() {
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::from([(
"search".to_string(),
@@ -1099,6 +1108,7 @@ foo = { command = "cmd" }
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
@@ -1153,6 +1163,7 @@ foo = { command = "cmd" } # keep me
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
@@ -1206,6 +1217,7 @@ foo = { command = "cmd", args = ["--flag"] } # keep me
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
@@ -1260,6 +1272,7 @@ foo = { command = "cmd" }
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},

View File

@@ -151,6 +151,7 @@ pub(crate) async fn maybe_install_mcp_dependencies(
server_config.scopes.clone(),
oauth_config.discovered_scopes.clone(),
);
let oauth_client_id = server_config.oauth_client_id();
let first_attempt = perform_oauth_login(
&name,
&oauth_config.url,
@@ -158,6 +159,7 @@ pub(crate) async fn maybe_install_mcp_dependencies(
oauth_config.http_headers.clone(),
oauth_config.env_http_headers.clone(),
&resolved_scopes.scopes,
oauth_client_id,
server_config.oauth_resource.as_deref(),
config.mcp_oauth_callback_port,
config.mcp_oauth_callback_url.as_deref(),
@@ -173,6 +175,7 @@ pub(crate) async fn maybe_install_mcp_dependencies(
oauth_config.http_headers,
oauth_config.env_http_headers,
&[],
oauth_client_id,
server_config.oauth_resource.as_deref(),
config.mcp_oauth_callback_port,
config.mcp_oauth_callback_url.as_deref(),
@@ -369,6 +372,7 @@ fn mcp_dependency_to_server_config(
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
});
@@ -398,6 +402,7 @@ fn mcp_dependency_to_server_config(
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
});

View File

@@ -244,6 +244,7 @@ async fn run_code_mode_turn_with_rmcp_config(
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},

View File

@@ -193,6 +193,7 @@ fn insert_rmcp_test_server(config: &mut Config, command: String, approval_mode:
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},

View File

@@ -332,6 +332,7 @@ fn insert_mcp_server(
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},

View File

@@ -999,6 +999,7 @@ async fn tool_search_indexes_only_enabled_non_app_mcp_tools() -> Result<()> {
enabled_tools: Some(vec!["echo".to_string(), "image".to_string()]),
disabled_tools: Some(vec!["image".to_string()]),
scopes: None,
oauth: None,
oauth_resource: None,
supports_parallel_tool_calls: false,
tools: HashMap::new(),
@@ -1127,6 +1128,7 @@ async fn tool_search_surfaced_mcp_tool_errors_are_returned_to_model() -> Result<
enabled_tools: Some(vec!["echo".to_string()]),
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
supports_parallel_tool_calls: false,
tools: HashMap::new(),
@@ -1272,6 +1274,7 @@ async fn tool_search_uses_non_app_mcp_server_instructions_as_namespace_descripti
enabled_tools: Some(vec!["echo".to_string()]),
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
supports_parallel_tool_calls: false,
tools: HashMap::new(),

View File

@@ -386,6 +386,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result<
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},

View File

@@ -398,6 +398,7 @@ async fn mcp_tool_call_output_exceeds_limit_truncated_for_model() -> Result<()>
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
@@ -497,6 +498,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> {
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
@@ -779,6 +781,7 @@ async fn mcp_tool_call_output_not_truncated_with_custom_limit() -> Result<()> {
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},