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 authored and GitHub committed 2026-02-26 20:10:12 -08:00
1 parent 6fe3dc2e22
commit cee009d117
14 files changed
+201 -1

No files matched your search

+12
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,
},
);
+60
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()?;
+39
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]
+1
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,
}
}
@@ -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,
},
)]);
@@ -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,
};