mirror of
https://github.com/openai/codex.git
synced 2026-05-10 22:32:36 +00:00
Compare commits
1 Commits
ruslan/exe
...
xli-codex/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aee029b58c |
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
)]);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -288,6 +288,7 @@ fn insert_mcp_server(
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
oauth_client_id: None,
|
||||
tools: HashMap::new(),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user