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
+1
View File
@@ -96,6 +96,7 @@ pub use mcp_types::AppToolApproval;
pub use mcp_types::McpServerConfig;
pub use mcp_types::McpServerDisabledReason;
pub use mcp_types::McpServerEnvVar;
pub use mcp_types::McpServerOAuthConfig;
pub use mcp_types::McpServerToolConfig;
pub use mcp_types::McpServerTransportConfig;
pub use mcp_types::RawMcpServerConfig;
+9
View File
@@ -212,6 +212,15 @@ fn serialize_mcp_server(config: &McpServerConfig) -> TomlItem {
{
entry["scopes"] = array_from_strings(scopes);
}
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()
{
+62
View File
@@ -1,4 +1,5 @@
use super::*;
use crate::McpServerOAuthConfig;
use crate::McpServerToolConfig;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
@@ -33,6 +34,7 @@ async fn replace_mcp_servers_serializes_per_tool_approval_overrides() -> anyhow:
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::from([
(
@@ -82,3 +84,63 @@ approval_mode = "approve"
Ok(())
}
#[tokio::test]
async fn replace_mcp_servers_serializes_oauth_client_id() -> anyhow::Result<()> {
let unique_suffix = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
let codex_home = std::env::temp_dir().join(format!(
"codex-config-mcp-oauth-edit-test-{}-{unique_suffix}",
std::process::id()
));
let servers = BTreeMap::from([(
"maas_outlook".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,
},
experimental_environment: None,
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: Some(McpServerOAuthConfig {
client_id: Some("eci-prd-pub-codex-123".to_string()),
}),
oauth_resource: None,
tools: HashMap::new(),
},
)]);
ConfigEditsBuilder::new(&codex_home)
.replace_mcp_servers(&servers)
.apply()
.await?;
let config_path = codex_home.join(CONFIG_TOML_FILE);
let serialized = std::fs::read_to_string(&config_path)?;
assert_eq!(
serialized,
r#"[mcp_servers.maas_outlook]
url = "https://example.com/mcp"
[mcp_servers.maas_outlook.oauth]
client_id = "eci-prd-pub-codex-123"
"#
);
let loaded = load_global_mcp_servers(&codex_home).await?;
assert_eq!(loaded, servers);
std::fs::remove_dir_all(&codex_home)?;
Ok(())
}
+26
View File
@@ -114,6 +114,15 @@ impl AsRef<str> for McpServerEnvVar {
}
}
/// OAuth client settings used when Codex launches an MCP OAuth flow.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct McpServerOAuthConfig {
/// Explicit OAuth client identifier to present during authorization and token exchange.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_id: Option<String>,
}
#[derive(Serialize, Debug, Clone, PartialEq)]
pub struct McpServerConfig {
#[serde(flatten)]
@@ -167,6 +176,10 @@ pub struct McpServerConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scopes: Option<Vec<String>>,
/// Optional OAuth client settings for MCP login.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oauth: Option<McpServerOAuthConfig>,
/// Optional OAuth resource parameter to include during MCP login (RFC 8707).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oauth_resource: Option<String>,
@@ -176,6 +189,14 @@ pub struct McpServerConfig {
pub tools: HashMap<String, McpServerToolConfig>,
}
impl McpServerConfig {
pub fn oauth_client_id(&self) -> Option<&str> {
self.oauth
.as_ref()
.and_then(|oauth| oauth.client_id.as_deref())
}
}
/// Raw MCP config shape used for deserialization and supported-field JSON
/// Schema generation.
///
@@ -233,6 +254,8 @@ pub struct RawMcpServerConfig {
#[serde(default)]
pub scopes: Option<Vec<String>>,
#[serde(default)]
pub oauth: Option<McpServerOAuthConfig>,
#[serde(default)]
pub oauth_resource: Option<String>,
/// Legacy display-name field accepted for backward compatibility.
#[serde(default, rename = "name")]
@@ -267,6 +290,7 @@ impl TryFrom<RawMcpServerConfig> for McpServerConfig {
enabled_tools,
disabled_tools,
scopes,
oauth,
oauth_resource,
_name: _,
tools,
@@ -297,6 +321,7 @@ impl TryFrom<RawMcpServerConfig> for McpServerConfig {
throw_if_set("stdio", "bearer_token", bearer_token.as_ref())?;
throw_if_set("stdio", "http_headers", http_headers.as_ref())?;
throw_if_set("stdio", "env_http_headers", env_http_headers.as_ref())?;
throw_if_set("stdio", "oauth", oauth.as_ref())?;
throw_if_set("stdio", "oauth_resource", oauth_resource.as_ref())?;
let env_vars = env_vars.unwrap_or_default();
for env_var in &env_vars {
@@ -338,6 +363,7 @@ impl TryFrom<RawMcpServerConfig> for McpServerConfig {
enabled_tools,
disabled_tools,
scopes,
oauth,
oauth_resource,
tools: tools.unwrap_or_default(),
})
+34
View File
@@ -283,6 +283,26 @@ fn deserialize_streamable_http_server_config_with_oauth_resource() {
);
}
#[test]
fn deserialize_streamable_http_server_config_with_oauth_client_id() {
let cfg: McpServerConfig = toml::from_str(
r#"
url = "https://example.com/mcp"
[oauth]
client_id = "eci-prd-pub-codex-123"
"#,
)
.expect("should deserialize http config with oauth client id");
assert_eq!(
cfg.oauth,
Some(McpServerOAuthConfig {
client_id: Some("eci-prd-pub-codex-123".to_string()),
})
);
}
#[test]
fn deserialize_server_config_with_tool_filters() {
let cfg: McpServerConfig = toml::from_str(
@@ -393,6 +413,7 @@ fn deserialize_ignores_unknown_server_fields() {
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
}
@@ -439,6 +460,19 @@ fn deserialize_rejects_headers_for_stdio() {
)
.expect_err("should reject env_http_headers for stdio transport");
let err = toml::from_str::<McpServerConfig>(
r#"
command = "echo"
oauth = { client_id = "eci-prd-pub-codex-123" }
"#,
)
.expect_err("should reject oauth for stdio transport");
assert!(
err.to_string().contains("oauth is not supported for stdio"),
"unexpected error: {err}"
);
let err = toml::from_str::<McpServerConfig>(
r#"
command = "echo"
+1
View File
@@ -7,6 +7,7 @@ pub use crate::mcp_types::AppToolApproval;
pub use crate::mcp_types::McpServerConfig;
pub use crate::mcp_types::McpServerDisabledReason;
pub use crate::mcp_types::McpServerEnvVar;
pub use crate::mcp_types::McpServerOAuthConfig;
pub use crate::mcp_types::McpServerToolConfig;
pub use crate::mcp_types::McpServerTransportConfig;
pub use crate::mcp_types::RawMcpServerConfig;