Compare commits

...

1 Commits

Author SHA1 Message Date
xli-oai
aee029b58c Add MCP OAuth client ID config 2026-04-27 12:00:03 -07:00
28 changed files with 403 additions and 4 deletions

View File

@@ -5977,6 +5977,7 @@ impl CodexMessageProcessor {
env_http_headers,
&resolved_scopes.scopes,
server.oauth_resource.as_deref(),
server.oauth_client_id.as_deref(),
timeout_secs,
config.mcp_oauth_callback_port,
config.mcp_oauth_callback_url.as_deref(),

View File

@@ -53,6 +53,7 @@ impl CodexMessageProcessor {
oauth_config.env_http_headers.clone(),
&resolved_scopes.scopes,
server.oauth_resource.as_deref(),
server.oauth_client_id.as_deref(),
callback_port,
callback_url.as_deref(),
)
@@ -68,6 +69,7 @@ impl CodexMessageProcessor {
oauth_config.env_http_headers,
&[],
server.oauth_resource.as_deref(),
server.oauth_client_id.as_deref(),
callback_port,
callback_url.as_deref(),
)

View File

@@ -200,6 +200,7 @@ async fn perform_oauth_login_retry_without_scopes(
env_http_headers: Option<HashMap<String, String>>,
resolved_scopes: &ResolvedMcpOAuthScopes,
oauth_resource: Option<&str>,
oauth_client_id: Option<&str>,
callback_port: Option<u16>,
callback_url: Option<&str>,
) -> Result<()> {
@@ -211,6 +212,7 @@ async fn perform_oauth_login_retry_without_scopes(
env_http_headers.clone(),
&resolved_scopes.scopes,
oauth_resource,
oauth_client_id,
callback_port,
callback_url,
)
@@ -227,6 +229,7 @@ async fn perform_oauth_login_retry_without_scopes(
env_http_headers,
&[],
oauth_resource,
oauth_client_id,
callback_port,
callback_url,
)
@@ -310,6 +313,7 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re
disabled_tools: None,
scopes: None,
oauth_resource: None,
oauth_client_id: None,
tools: HashMap::new(),
};
@@ -339,6 +343,7 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re
oauth_config.env_http_headers,
&resolved_scopes,
/*oauth_resource*/ None,
/*oauth_client_id*/ None,
config.mcp_oauth_callback_port,
config.mcp_oauth_callback_url.as_deref(),
)
@@ -432,6 +437,7 @@ async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs)
env_http_headers,
&resolved_scopes,
server.oauth_resource.as_deref(),
server.oauth_client_id.as_deref(),
config.mcp_oauth_callback_port,
config.mcp_oauth_callback_url.as_deref(),
)

View File

@@ -834,6 +834,7 @@ fn mcp_init_error_display_prompts_for_github_pat() {
disabled_tools: None,
scopes: None,
oauth_resource: None,
oauth_client_id: None,
tools: HashMap::new(),
},
auth_status: McpAuthStatus::Unsupported,
@@ -886,6 +887,7 @@ fn mcp_init_error_display_reports_generic_errors() {
disabled_tools: None,
scopes: None,
oauth_resource: None,
oauth_client_id: None,
tools: HashMap::new(),
},
auth_status: McpAuthStatus::Unsupported,

View File

@@ -409,6 +409,7 @@ fn codex_apps_mcp_server_config(config: &McpConfig) -> McpServerConfig {
disabled_tools: None,
scopes: None,
oauth_resource: None,
oauth_client_id: None,
tools: HashMap::new(),
}
}

View File

@@ -186,6 +186,7 @@ async fn effective_mcp_servers_preserve_user_servers_and_add_codex_apps() {
disabled_tools: None,
scopes: None,
oauth_resource: None,
oauth_client_id: None,
tools: HashMap::new(),
},
);
@@ -210,6 +211,7 @@ async fn effective_mcp_servers_preserve_user_servers_and_add_codex_apps() {
disabled_tools: None,
scopes: None,
oauth_resource: None,
oauth_client_id: None,
tools: HashMap::new(),
},
);

View File

@@ -506,6 +506,7 @@ async fn make_rmcp_client(
let McpServerConfig {
transport,
experimental_environment,
oauth_client_id,
..
} = config;
let remote_environment = match experimental_environment.as_deref() {
@@ -578,6 +579,7 @@ async fn make_rmcp_client(
server_name,
&url,
resolved_bearer_token,
oauth_client_id,
http_headers,
env_http_headers,
store_mode,

View File

@@ -217,6 +217,11 @@ fn serialize_mcp_server(config: &McpServerConfig) -> TomlItem {
{
entry["oauth_resource"] = value(resource.clone());
}
if let Some(client_id) = &config.oauth_client_id
&& !client_id.is_empty()
{
entry["oauth_client_id"] = value(client_id.clone());
}
if !config.tools.is_empty() {
let mut tools = TomlTable::new();
tools.set_implicit(false);

View File

@@ -34,6 +34,7 @@ async fn replace_mcp_servers_serializes_per_tool_approval_overrides() -> anyhow:
disabled_tools: None,
scopes: None,
oauth_resource: None,
oauth_client_id: None,
tools: HashMap::from([
(
"search".to_string(),
@@ -82,3 +83,53 @@ 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-client-id-test-{}-{unique_suffix}",
std::process::id()
));
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,
},
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_resource: None,
oauth_client_id: Some("registered-client-id".to_string()),
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!(serialized.contains("oauth_client_id = \"registered-client-id\""));
let loaded = load_global_mcp_servers(&codex_home).await?;
assert_eq!(loaded, servers);
std::fs::remove_dir_all(&codex_home)?;
Ok(())
}

View File

@@ -171,6 +171,10 @@ pub struct McpServerConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oauth_resource: Option<String>,
/// Optional OAuth client ID to use for MCP login instead of dynamic client registration.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oauth_client_id: Option<String>,
/// Per-tool approval settings keyed by tool name.
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub tools: HashMap<String, McpServerToolConfig>,
@@ -234,6 +238,8 @@ pub struct RawMcpServerConfig {
pub scopes: Option<Vec<String>>,
#[serde(default)]
pub oauth_resource: Option<String>,
#[serde(default)]
pub oauth_client_id: Option<String>,
/// Legacy display-name field accepted for backward compatibility.
#[serde(default, rename = "name")]
pub _name: Option<String>,
@@ -268,6 +274,7 @@ impl TryFrom<RawMcpServerConfig> for McpServerConfig {
disabled_tools,
scopes,
oauth_resource,
oauth_client_id,
_name: _,
tools,
} = raw;
@@ -279,6 +286,13 @@ impl TryFrom<RawMcpServerConfig> for McpServerConfig {
(None, Some(ms)) => Some(Duration::from_millis(ms)),
(None, None) => None,
};
let oauth_client_id = match oauth_client_id {
Some(id) if id.trim().is_empty() => {
return Err("oauth_client_id cannot be empty".to_string());
}
Some(id) => Some(id),
None => None,
};
fn throw_if_set<T>(transport: &str, field: &str, value: Option<&T>) -> Result<(), String> {
if value.is_none() {
@@ -298,6 +312,7 @@ impl TryFrom<RawMcpServerConfig> for McpServerConfig {
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_resource", oauth_resource.as_ref())?;
throw_if_set("stdio", "oauth_client_id", oauth_client_id.as_ref())?;
let env_vars = env_vars.unwrap_or_default();
for env_var in &env_vars {
env_var.validate_source()?;
@@ -339,6 +354,7 @@ impl TryFrom<RawMcpServerConfig> for McpServerConfig {
disabled_tools,
scopes,
oauth_resource,
oauth_client_id,
tools: tools.unwrap_or_default(),
})
}

View File

@@ -283,6 +283,22 @@ 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 = "registered-client-id"
"#,
)
.expect("should deserialize http config with oauth_client_id");
assert_eq!(
cfg.oauth_client_id,
Some("registered-client-id".to_string())
);
}
#[test]
fn deserialize_server_config_with_tool_filters() {
let cfg: McpServerConfig = toml::from_str(
@@ -394,6 +410,7 @@ fn deserialize_ignores_unknown_server_fields() {
disabled_tools: None,
scopes: None,
oauth_resource: None,
oauth_client_id: None,
tools: HashMap::new(),
}
);
@@ -452,6 +469,20 @@ fn deserialize_rejects_headers_for_stdio() {
.contains("oauth_resource is not supported for stdio"),
"unexpected error: {err}"
);
let err = toml::from_str::<McpServerConfig>(
r#"
command = "echo"
oauth_client_id = "registered-client-id"
"#,
)
.expect_err("should reject oauth_client_id for stdio transport");
assert!(
err.to_string()
.contains("oauth_client_id is not supported for stdio"),
"unexpected error: {err}"
);
}
#[test]

View File

@@ -1795,6 +1795,10 @@
"description": "Legacy display-name field accepted for backward compatibility.",
"type": "string"
},
"oauth_client_id": {
"default": null,
"type": "string"
},
"oauth_resource": {
"default": null,
"type": "string"

View File

@@ -105,6 +105,7 @@ fn stdio_mcp(command: &str) -> McpServerConfig {
disabled_tools: None,
scopes: None,
oauth_resource: None,
oauth_client_id: None,
tools: HashMap::new(),
}
}
@@ -129,6 +130,7 @@ fn http_mcp(url: &str) -> McpServerConfig {
disabled_tools: None,
scopes: None,
oauth_resource: None,
oauth_client_id: None,
tools: HashMap::new(),
}
}
@@ -2599,6 +2601,7 @@ async fn replace_mcp_servers_round_trips_entries() -> anyhow::Result<()> {
disabled_tools: None,
scopes: None,
oauth_resource: None,
oauth_client_id: None,
tools: HashMap::new(),
},
);
@@ -2857,6 +2860,7 @@ async fn replace_mcp_servers_serializes_env_sorted() -> anyhow::Result<()> {
disabled_tools: None,
scopes: None,
oauth_resource: None,
oauth_client_id: None,
tools: HashMap::new(),
},
)]);
@@ -2933,6 +2937,7 @@ async fn replace_mcp_servers_serializes_env_vars() -> anyhow::Result<()> {
disabled_tools: None,
scopes: None,
oauth_resource: None,
oauth_client_id: None,
tools: HashMap::new(),
},
)]);
@@ -2994,6 +2999,7 @@ async fn replace_mcp_servers_serializes_sourced_env_vars() -> anyhow::Result<()>
disabled_tools: None,
scopes: None,
oauth_resource: None,
oauth_client_id: None,
tools: HashMap::new(),
},
)]);
@@ -3045,6 +3051,7 @@ async fn replace_mcp_servers_serializes_cwd() -> anyhow::Result<()> {
disabled_tools: None,
scopes: None,
oauth_resource: None,
oauth_client_id: None,
tools: HashMap::new(),
},
)]);
@@ -3099,6 +3106,7 @@ async fn replace_mcp_servers_streamable_http_serializes_bearer_token() -> anyhow
disabled_tools: None,
scopes: None,
oauth_resource: None,
oauth_client_id: None,
tools: HashMap::new(),
},
)]);
@@ -3169,6 +3177,7 @@ async fn replace_mcp_servers_streamable_http_serializes_custom_headers() -> anyh
disabled_tools: None,
scopes: None,
oauth_resource: None,
oauth_client_id: None,
tools: HashMap::new(),
},
)]);
@@ -3251,6 +3260,7 @@ async fn replace_mcp_servers_streamable_http_removes_optional_sections() -> anyh
disabled_tools: None,
scopes: None,
oauth_resource: None,
oauth_client_id: None,
tools: HashMap::new(),
},
)]);
@@ -3286,6 +3296,7 @@ async fn replace_mcp_servers_streamable_http_removes_optional_sections() -> anyh
disabled_tools: None,
scopes: None,
oauth_resource: None,
oauth_client_id: None,
tools: HashMap::new(),
},
);
@@ -3356,6 +3367,7 @@ async fn replace_mcp_servers_streamable_http_isolates_headers_between_servers()
disabled_tools: None,
scopes: None,
oauth_resource: None,
oauth_client_id: None,
tools: HashMap::new(),
},
),
@@ -3381,6 +3393,7 @@ async fn replace_mcp_servers_streamable_http_isolates_headers_between_servers()
disabled_tools: None,
scopes: None,
oauth_resource: None,
oauth_client_id: None,
tools: HashMap::new(),
},
),
@@ -3469,6 +3482,7 @@ async fn replace_mcp_servers_serializes_disabled_flag() -> anyhow::Result<()> {
disabled_tools: None,
scopes: None,
oauth_resource: None,
oauth_client_id: None,
tools: HashMap::new(),
},
)]);
@@ -3519,6 +3533,7 @@ async fn replace_mcp_servers_serializes_required_flag() -> anyhow::Result<()> {
disabled_tools: None,
scopes: None,
oauth_resource: None,
oauth_client_id: None,
tools: HashMap::new(),
},
)]);
@@ -3569,6 +3584,7 @@ async fn replace_mcp_servers_serializes_tool_filters() -> anyhow::Result<()> {
disabled_tools: Some(vec!["blocked".to_string()]),
scopes: None,
oauth_resource: None,
oauth_client_id: None,
tools: HashMap::new(),
},
)]);
@@ -3623,6 +3639,7 @@ async fn replace_mcp_servers_streamable_http_serializes_oauth_resource() -> anyh
disabled_tools: None,
scopes: None,
oauth_resource: Some("https://resource.example.com".to_string()),
oauth_client_id: None,
tools: HashMap::new(),
},
)]);

View File

@@ -275,6 +275,11 @@ mod document_helpers {
{
entry["oauth_resource"] = value(resource.clone());
}
if let Some(client_id) = &config.oauth_client_id
&& !client_id.is_empty()
{
entry["oauth_client_id"] = value(client_id.clone());
}
if !config.tools.is_empty() {
let mut tools = new_implicit_table();
let mut tool_entries: Vec<_> = config.tools.iter().collect();

View File

@@ -711,6 +711,7 @@ fn blocking_replace_mcp_servers_round_trips() {
disabled_tools: None,
scopes: None,
oauth_resource: None,
oauth_client_id: None,
tools: HashMap::new(),
},
);
@@ -740,6 +741,7 @@ fn blocking_replace_mcp_servers_round_trips() {
disabled_tools: Some(vec!["forbidden".to_string()]),
scopes: None,
oauth_resource: Some("https://resource.example.com".to_string()),
oauth_client_id: None,
tools: HashMap::new(),
},
);
@@ -806,6 +808,7 @@ fn blocking_replace_mcp_servers_serializes_tool_approval_overrides() {
disabled_tools: None,
scopes: None,
oauth_resource: None,
oauth_client_id: None,
tools: HashMap::from([(
"search".to_string(),
McpServerToolConfig {
@@ -870,6 +873,7 @@ foo = { command = "cmd" }
disabled_tools: None,
scopes: None,
oauth_resource: None,
oauth_client_id: None,
tools: HashMap::new(),
},
);
@@ -924,6 +928,7 @@ foo = { command = "cmd" } # keep me
disabled_tools: None,
scopes: None,
oauth_resource: None,
oauth_client_id: None,
tools: HashMap::new(),
},
);
@@ -977,6 +982,7 @@ foo = { command = "cmd", args = ["--flag"] } # keep me
disabled_tools: None,
scopes: None,
oauth_resource: None,
oauth_client_id: None,
tools: HashMap::new(),
},
);
@@ -1031,6 +1037,7 @@ foo = { command = "cmd" }
disabled_tools: None,
scopes: None,
oauth_resource: None,
oauth_client_id: None,
tools: HashMap::new(),
},
);

View File

@@ -156,6 +156,7 @@ pub(crate) async fn maybe_install_mcp_dependencies(
oauth_config.env_http_headers.clone(),
&resolved_scopes.scopes,
server_config.oauth_resource.as_deref(),
server_config.oauth_client_id.as_deref(),
config.mcp_oauth_callback_port,
config.mcp_oauth_callback_url.as_deref(),
)
@@ -179,6 +180,7 @@ pub(crate) async fn maybe_install_mcp_dependencies(
oauth_config.env_http_headers,
&[],
server_config.oauth_resource.as_deref(),
server_config.oauth_client_id.as_deref(),
config.mcp_oauth_callback_port,
config.mcp_oauth_callback_url.as_deref(),
)
@@ -376,6 +378,7 @@ fn mcp_dependency_to_server_config(
disabled_tools: None,
scopes: None,
oauth_resource: None,
oauth_client_id: None,
tools: HashMap::new(),
});
}
@@ -405,6 +408,7 @@ fn mcp_dependency_to_server_config(
disabled_tools: None,
scopes: None,
oauth_resource: None,
oauth_client_id: None,
tools: HashMap::new(),
});
}

View File

@@ -215,6 +215,7 @@ async fn load_plugins_loads_default_skills_and_mcp_servers() {
disabled_tools: None,
scopes: None,
oauth_resource: None,
oauth_client_id: None,
tools: HashMap::new(),
},
)]),
@@ -553,6 +554,7 @@ async fn load_plugins_uses_manifest_configured_component_paths() {
disabled_tools: None,
scopes: None,
oauth_resource: None,
oauth_client_id: None,
tools: HashMap::new(),
},
)])
@@ -664,6 +666,7 @@ async fn load_plugins_ignores_manifest_component_paths_without_dot_slash() {
disabled_tools: None,
scopes: None,
oauth_resource: None,
oauth_client_id: None,
tools: HashMap::new(),
},
)])
@@ -823,6 +826,7 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() {
disabled_tools: None,
scopes: None,
oauth_resource: None,
oauth_client_id: None,
tools: HashMap::new(),
};
let plugin = |config_name: &str, dir_name: &str, manifest_name: &str| LoadedPlugin {

View File

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

View File

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

View File

@@ -288,6 +288,7 @@ fn insert_mcp_server(
disabled_tools: None,
scopes: None,
oauth_resource: None,
oauth_client_id: None,
tools: HashMap::new(),
},
);

View File

@@ -942,6 +942,7 @@ async fn tool_search_indexes_only_enabled_non_app_mcp_tools() -> Result<()> {
disabled_tools: Some(vec!["image".to_string()]),
scopes: None,
oauth_resource: None,
oauth_client_id: None,
supports_parallel_tool_calls: false,
tools: HashMap::new(),
},

View File

@@ -385,6 +385,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result<
disabled_tools: None,
scopes: None,
oauth_resource: None,
oauth_client_id: None,
tools: HashMap::new(),
},
);

View File

@@ -259,6 +259,7 @@ async fn historical_unavailable_mcp_call_is_exposed_as_placeholder_tool() -> Res
disabled_tools: None,
scopes: None,
oauth_resource: None,
oauth_client_id: None,
tools: HashMap::new(),
},
);

View File

@@ -387,6 +387,7 @@ async fn mcp_tool_call_output_exceeds_limit_truncated_for_model() -> Result<()>
disabled_tools: None,
scopes: None,
oauth_resource: None,
oauth_client_id: None,
tools: HashMap::new(),
},
);
@@ -486,6 +487,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> {
disabled_tools: None,
scopes: None,
oauth_resource: None,
oauth_client_id: None,
tools: HashMap::new(),
},
);
@@ -766,6 +768,7 @@ async fn mcp_tool_call_output_not_truncated_with_custom_limit() -> Result<()> {
disabled_tools: None,
scopes: None,
oauth_resource: None,
oauth_client_id: None,
tools: HashMap::new(),
},
);

View File

@@ -9,6 +9,8 @@ use anyhow::anyhow;
use anyhow::bail;
use reqwest::ClientBuilder;
use reqwest::Url;
use rmcp::transport::auth::AuthorizationSession;
use rmcp::transport::auth::OAuthClientConfig;
use rmcp::transport::auth::OAuthState;
use tiny_http::Response;
use tiny_http::Server;
@@ -78,6 +80,7 @@ pub async fn perform_oauth_login(
env_http_headers: Option<HashMap<String, String>>,
scopes: &[String],
oauth_resource: Option<&str>,
oauth_client_id: Option<&str>,
callback_port: Option<u16>,
callback_url: Option<&str>,
) -> Result<()> {
@@ -89,6 +92,7 @@ pub async fn perform_oauth_login(
env_http_headers,
scopes,
oauth_resource,
oauth_client_id,
callback_port,
callback_url,
/*emit_browser_url*/ true,
@@ -105,6 +109,7 @@ pub async fn perform_oauth_login_silent(
env_http_headers: Option<HashMap<String, String>>,
scopes: &[String],
oauth_resource: Option<&str>,
oauth_client_id: Option<&str>,
callback_port: Option<u16>,
callback_url: Option<&str>,
) -> Result<()> {
@@ -116,6 +121,7 @@ pub async fn perform_oauth_login_silent(
env_http_headers,
scopes,
oauth_resource,
oauth_client_id,
callback_port,
callback_url,
/*emit_browser_url*/ false,
@@ -132,6 +138,7 @@ async fn perform_oauth_login_with_browser_output(
env_http_headers: Option<HashMap<String, String>>,
scopes: &[String],
oauth_resource: Option<&str>,
oauth_client_id: Option<&str>,
callback_port: Option<u16>,
callback_url: Option<&str>,
emit_browser_url: bool,
@@ -147,6 +154,7 @@ async fn perform_oauth_login_with_browser_output(
headers,
scopes,
oauth_resource,
oauth_client_id,
/*launch_browser*/ true,
callback_port,
callback_url,
@@ -166,6 +174,7 @@ pub async fn perform_oauth_login_return_url(
env_http_headers: Option<HashMap<String, String>>,
scopes: &[String],
oauth_resource: Option<&str>,
oauth_client_id: Option<&str>,
timeout_secs: Option<i64>,
callback_port: Option<u16>,
callback_url: Option<&str>,
@@ -181,6 +190,7 @@ pub async fn perform_oauth_login_return_url(
headers,
scopes,
oauth_resource,
oauth_client_id,
/*launch_browser*/ false,
callback_port,
callback_url,
@@ -408,6 +418,7 @@ impl OauthLoginFlow {
headers: OauthHeaders,
scopes: &[String],
oauth_resource: Option<&str>,
oauth_client_id: Option<&str>,
launch_browser: bool,
callback_port: Option<u16>,
callback_url: Option<&str>,
@@ -442,9 +453,15 @@ 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?;
start_authorization(
&mut oauth_state,
server_url,
&scope_refs,
&redirect_uri,
Some("Codex"),
oauth_client_id,
)
.await?;
let auth_url = append_query_param(
&oauth_state.get_authorization_url().await?,
"resource",
@@ -554,6 +571,43 @@ impl OauthLoginFlow {
}
}
async fn start_authorization(
oauth_state: &mut OAuthState,
server_url: &str,
scopes: &[&str],
redirect_uri: &str,
client_name: Option<&str>,
oauth_client_id: Option<&str>,
) -> Result<()> {
let Some(client_id) = oauth_client_id.map(str::trim).filter(|id| !id.is_empty()) else {
oauth_state
.start_authorization(scopes, redirect_uri, client_name)
.await?;
return Ok(());
};
let placeholder = OAuthState::new(server_url, None).await?;
let OAuthState::Unauthorized(mut manager) = std::mem::replace(oauth_state, placeholder) else {
return Err(anyhow!("OAuth client is already authorizing or authorized"));
};
let metadata = manager.discover_metadata().await?;
manager.set_metadata(metadata);
manager.configure_client(OAuthClientConfig {
client_id: client_id.to_string(),
client_secret: None,
scopes: scopes.iter().map(|scope| (*scope).to_string()).collect(),
redirect_uri: redirect_uri.to_string(),
})?;
let auth_url = manager.get_authorization_url(scopes).await?;
*oauth_state = OAuthState::Session(AuthorizationSession {
auth_manager: manager,
auth_url,
redirect_uri: redirect_uri.to_string(),
});
Ok(())
}
fn append_query_param(url: &str, key: &str, value: Option<&str>) -> String {
let Some(value) = value else {
return url.to_string();
@@ -573,13 +627,62 @@ fn append_query_param(url: &str, key: &str, value: Option<&str>) -> String {
#[cfg(test)]
mod tests {
use axum::Json;
use axum::Router;
use axum::routing::get;
use pretty_assertions::assert_eq;
use tokio::task::JoinHandle;
use super::CallbackOutcome;
use super::OAuthProviderError;
use super::OauthHeaders;
use super::OauthLoginFlow;
use super::append_query_param;
use super::callback_path_from_redirect_uri;
use super::parse_oauth_callback;
use codex_config::types::OAuthCredentialsStoreMode;
struct TestServer {
url: String,
handle: JoinHandle<()>,
}
impl Drop for TestServer {
fn drop(&mut self) {
self.handle.abort();
}
}
async fn spawn_oauth_metadata_server() -> TestServer {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
let address = listener.local_addr().expect("listener should have address");
let base = format!("http://{address}");
let app = Router::new().route(
"/.well-known/oauth-authorization-server/mcp",
get({
let base = base.clone();
move || {
let base = base.clone();
async move {
Json(serde_json::json!({
"authorization_endpoint": format!("{base}/oauth/authorize"),
"token_endpoint": format!("{base}/oauth/token"),
}))
}
}
}),
);
let handle = tokio::spawn(async move {
axum::serve(listener, app).await.expect("server should run");
});
TestServer {
url: format!("{base}/mcp"),
handle,
}
}
#[test]
fn parse_oauth_callback_accepts_default_path() {
@@ -622,6 +725,37 @@ mod tests {
assert_eq!(path, "/oauth/callback");
}
#[tokio::test]
async fn oauth_login_flow_uses_configured_client_id_without_registration() {
let server = spawn_oauth_metadata_server().await;
let flow = OauthLoginFlow::new(
"docs",
&server.url,
OAuthCredentialsStoreMode::File,
OauthHeaders {
http_headers: None,
env_http_headers: None,
},
&[],
/*oauth_resource*/ None,
Some("registered-client-id"),
/*launch_browser*/ false,
/*callback_port*/ None,
/*callback_url*/ None,
Some(1),
)
.await
.expect("static client id should avoid dynamic registration");
let authorization_url =
reqwest::Url::parse(&flow.authorization_url()).expect("authorization URL should parse");
let client_id = authorization_url
.query_pairs()
.find_map(|(key, value)| (key == "client_id").then_some(value.into_owned()));
assert_eq!(client_id, Some("registered-client-id".to_string()));
}
#[test]
fn append_query_param_adds_resource_to_absolute_url() {
let url = append_query_param(

View File

@@ -105,6 +105,7 @@ enum TransportRecipe {
server_name: String,
url: String,
bearer_token: Option<String>,
oauth_client_id: Option<String>,
http_headers: Option<HashMap<String, String>>,
env_http_headers: Option<HashMap<String, String>>,
store_mode: OAuthCredentialsStoreMode,
@@ -304,6 +305,7 @@ impl RmcpClient {
server_name: &str,
url: &str,
bearer_token: Option<String>,
oauth_client_id: Option<String>,
http_headers: Option<HashMap<String, String>>,
env_http_headers: Option<HashMap<String, String>>,
store_mode: OAuthCredentialsStoreMode,
@@ -314,6 +316,7 @@ impl RmcpClient {
server_name: server_name.to_string(),
url: url.to_string(),
bearer_token,
oauth_client_id,
http_headers,
env_http_headers,
store_mode,
@@ -667,6 +670,7 @@ impl RmcpClient {
server_name,
url,
bearer_token,
oauth_client_id,
http_headers,
env_http_headers,
store_mode,
@@ -681,7 +685,26 @@ impl RmcpClient {
&& !default_headers.contains_key(AUTHORIZATION)
{
match load_oauth_tokens(server_name, url, *store_mode) {
Ok(tokens) => tokens,
Ok(Some(tokens))
if oauth_tokens_match_client_id(
&tokens,
oauth_client_id.as_deref(),
) =>
{
Some(tokens)
}
Ok(Some(tokens)) => {
if let Some(configured_client_id) =
configured_oauth_client_id(oauth_client_id.as_deref())
{
warn!(
"stored OAuth tokens for MCP server `{server_name}` were issued to client `{}` but config requires `{configured_client_id}`; ignoring cached tokens",
tokens.client_id
);
}
None
}
Ok(None) => None,
Err(err) => {
warn!("failed to read tokens for server `{server_name}`: {err}");
None
@@ -991,14 +1014,31 @@ async fn create_oauth_transport_and_runtime(
Ok((transport, runtime))
}
fn configured_oauth_client_id(oauth_client_id: Option<&str>) -> Option<&str> {
oauth_client_id.map(str::trim).filter(|id| !id.is_empty())
}
fn oauth_tokens_match_client_id(tokens: &StoredOAuthTokens, oauth_client_id: Option<&str>) -> bool {
let Some(client_id) = configured_oauth_client_id(oauth_client_id) else {
return true;
};
tokens.client_id == client_id
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use oauth2::AccessToken;
use oauth2::EmptyExtraTokenFields;
use oauth2::basic::BasicTokenType;
use pretty_assertions::assert_eq;
use rmcp::transport::auth::OAuthTokenResponse;
use tokio::time;
use super::*;
use crate::WrappedOAuthTokenResponse;
#[tokio::test]
async fn active_time_timeout_pauses_while_elicitation_is_pending() {
@@ -1018,4 +1058,45 @@ mod tests {
assert_eq!(Ok("done"), result);
}
#[test]
fn cached_oauth_tokens_are_valid_without_configured_client_id() {
let tokens = stored_tokens("registered-client-id");
assert!(oauth_tokens_match_client_id(&tokens, None));
}
#[test]
fn cached_oauth_tokens_are_valid_for_matching_configured_client_id() {
let tokens = stored_tokens("registered-client-id");
assert!(oauth_tokens_match_client_id(
&tokens,
Some(" registered-client-id ")
));
}
#[test]
fn cached_oauth_tokens_are_rejected_for_different_configured_client_id() {
let tokens = stored_tokens("dynamic-client-id");
assert!(!oauth_tokens_match_client_id(
&tokens,
Some("registered-client-id")
));
}
fn stored_tokens(client_id: &str) -> StoredOAuthTokens {
StoredOAuthTokens {
server_name: "test-server".to_string(),
url: "https://mcp.example.test/mcp".to_string(),
client_id: client_id.to_string(),
token_response: WrappedOAuthTokenResponse(OAuthTokenResponse::new(
AccessToken::new("access-token".to_string()),
BasicTokenType::Bearer,
EmptyExtraTokenFields {},
)),
expires_at: None,
}
}
}

View File

@@ -94,6 +94,7 @@ pub(crate) async fn create_client(base_url: &str) -> anyhow::Result<RmcpClient>
"test-streamable-http",
&format!("{base_url}/mcp"),
Some("test-bearer".to_string()),
/*oauth_client_id*/ None,
/*http_headers*/ None,
/*env_http_headers*/ None,
OAuthCredentialsStoreMode::File,
@@ -132,6 +133,7 @@ pub(crate) async fn create_remote_client(
"test-streamable-http-remote",
&format!("{base_url}/mcp"),
Some("test-bearer".to_string()),
/*oauth_client_id*/ None,
/*http_headers*/ None,
/*env_http_headers*/ None,
OAuthCredentialsStoreMode::File,

View File

@@ -26,6 +26,19 @@ Only enable parallel calls for MCP servers whose tools are safe to run at the
same time. If tools read and write shared state, files, databases, or external
resources, review those read/write race conditions before enabling this setting.
For streamable HTTP servers that require a pre-registered OAuth client instead
of dynamic client registration, set `oauth_client_id` on that server:
```toml
[mcp_servers.docs]
url = "https://example.com/mcp"
oauth_client_id = "your-registered-client-id"
```
When this is set, `codex mcp login docs` sends that client ID in the OAuth
authorization request. When it is unset, Codex keeps using dynamic client
registration.
## MCP tool approvals
Codex stores approval defaults and per-tool overrides for custom MCP servers