Add oauth_resource handling for MCP login flows (#12866)

Addresses bug https://github.com/openai/codex/issues/12589

Builds on community PR #12763.

This adds `oauth_resource` support for MCP `streamable_http` servers and
wires it through the relevant config and login paths. It fixes the bug
where the configured OAuth resource was not reliably included in the
authorization request, causing MCP login to omit the expected
`resource` parameter.
This commit is contained in:
Eric Traut 2026-02-26 20:10:12 -08:00 committed by GitHub
parent 6fe3dc2e22
commit cee009d117
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 201 additions and 1 deletions

View file

@ -4152,6 +4152,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(),

View file

@ -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(),
)

View file

@ -1146,6 +1146,10 @@
},
"type": "object"
},
"oauth_resource": {
"default": null,
"type": "string"
},
"required": {
"default": null,
"type": "boolean"

View file

@ -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
}
@ -1465,6 +1470,7 @@ gpt-5 = "gpt-5.1"
enabled_tools: Some(vec!["one".to_string(), "two".to_string()]),
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
);
@ -1489,6 +1495,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()),
},
);
@ -1507,6 +1514,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\"
@ -1556,6 +1564,7 @@ foo = { command = "cmd" }
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
);
@ -1602,6 +1611,7 @@ foo = { command = "cmd" } # keep me
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
);
@ -1647,6 +1657,7 @@ foo = { command = "cmd", args = ["--flag"] } # keep me
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
);
@ -1693,6 +1704,7 @@ foo = { command = "cmd" }
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
);

View file

@ -2433,6 +2433,7 @@ mod tests {
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
}
}
@ -2452,6 +2453,7 @@ mod tests {
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
}
}
@ -3489,6 +3491,7 @@ profile = "project"
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
);
@ -3646,6 +3649,7 @@ bearer_token = "secret"
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
)]);
@ -3717,6 +3721,7 @@ ZIG_VAR = "3"
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
)]);
@ -3768,6 +3773,7 @@ ZIG_VAR = "3"
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
)]);
@ -3817,6 +3823,7 @@ ZIG_VAR = "3"
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
)]);
@ -3882,6 +3889,7 @@ startup_timeout_sec = 2.0
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
)]);
apply_blocking(
@ -3959,6 +3967,7 @@ X-Auth = "DOCS_AUTH"
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
)]);
@ -3989,6 +3998,7 @@ X-Auth = "DOCS_AUTH"
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
);
apply_blocking(
@ -4057,6 +4067,7 @@ url = "https://example.com/mcp"
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
),
(
@ -4077,6 +4088,7 @@ url = "https://example.com/mcp"
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
),
]);
@ -4160,6 +4172,7 @@ url = "https://example.com/mcp"
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
)]);
@ -4205,6 +4218,7 @@ url = "https://example.com/mcp"
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
)]);
@ -4250,6 +4264,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,
},
)]);
@ -4278,6 +4293,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()?;

View file

@ -99,6 +99,10 @@ pub struct McpServerConfig {
/// Optional OAuth scopes to request during MCP login.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scopes: Option<Vec<String>>,
/// Optional OAuth resource parameter to include during MCP login (RFC 8707).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oauth_resource: Option<String>,
}
// Raw MCP config shape used for deserialization and JSON Schema generation.
@ -143,6 +147,8 @@ pub(crate) struct RawMcpServerConfig {
pub disabled_tools: Option<Vec<String>>,
#[serde(default)]
pub scopes: Option<Vec<String>>,
#[serde(default)]
pub oauth_resource: Option<String>,
}
impl<'de> Deserialize<'de> for McpServerConfig {
@ -166,6 +172,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<E, T>(transport: &str, field: &str, value: Option<&T>) -> Result<(), E>
where
@ -189,6 +196,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(),
@ -222,6 +230,7 @@ impl<'de> Deserialize<'de> for McpServerConfig {
enabled_tools,
disabled_tools,
scopes,
oauth_resource,
})
}
}
@ -1093,6 +1102,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(
@ -1147,6 +1172,20 @@ mod tests {
"#,
)
.expect_err("should reject env_http_headers for stdio transport");
let err = toml::from_str::<McpServerConfig>(
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]

View file

@ -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,
}
}

View file

@ -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,
},
)]);

View file

@ -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,
};

View file

@ -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
@ -463,6 +465,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
@ -581,6 +584,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> {
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
);
config
@ -740,6 +744,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> {
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
);
config
@ -959,6 +964,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> {
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
);
config

View file

@ -133,6 +133,7 @@ fn rmcp_server_config(command: String) -> McpServerConfig {
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
}
}

View file

@ -370,6 +370,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
@ -464,6 +465,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
@ -729,6 +731,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

View file

@ -47,6 +47,7 @@ pub async fn perform_oauth_login(
http_headers: Option<HashMap<String, String>>,
env_http_headers: Option<HashMap<String, String>>,
scopes: &[String],
oauth_resource: Option<&str>,
callback_port: Option<u16>,
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<HashMap<String, String>>,
env_http_headers: Option<HashMap<String, String>>,
scopes: &[String],
oauth_resource: Option<&str>,
timeout_secs: Option<i64>,
callback_port: Option<u16>,
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<u16>,
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");
}
}

View file

@ -2633,6 +2633,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);
@ -2656,6 +2657,7 @@ mod tests {
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
};
servers.insert("http".to_string(), http_config);
config