mirror of
https://github.com/openai/codex.git
synced 2026-11-06 00:30:29 +00:00
- [x] Fix apps enablement condition to check both the feature flag and that the user is not an API key user.
813 lines
28 KiB
Rust
813 lines
28 KiB
Rust
pub mod auth;
|
|
mod skill_dependencies;
|
|
pub(crate) use skill_dependencies::maybe_prompt_and_install_mcp_dependencies;
|
|
|
|
use std::collections::HashMap;
|
|
use std::env;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use async_channel::unbounded;
|
|
use codex_protocol::mcp::Resource;
|
|
use codex_protocol::mcp::ResourceTemplate;
|
|
use codex_protocol::mcp::Tool;
|
|
use codex_protocol::protocol::McpListToolsResponseEvent;
|
|
use codex_protocol::protocol::SandboxPolicy;
|
|
use serde_json::Value;
|
|
|
|
use crate::AuthManager;
|
|
use crate::CodexAuth;
|
|
use crate::config::Config;
|
|
use crate::config::types::McpServerConfig;
|
|
use crate::config::types::McpServerTransportConfig;
|
|
use crate::features::Feature;
|
|
use crate::mcp::auth::compute_auth_statuses;
|
|
use crate::mcp_connection_manager::McpConnectionManager;
|
|
use crate::mcp_connection_manager::SandboxState;
|
|
use crate::mcp_connection_manager::codex_apps_tools_cache_key;
|
|
use crate::plugins::PluginCapabilitySummary;
|
|
use crate::plugins::PluginsManager;
|
|
|
|
const MCP_TOOL_NAME_PREFIX: &str = "mcp";
|
|
const MCP_TOOL_NAME_DELIMITER: &str = "__";
|
|
pub(crate) const CODEX_APPS_MCP_SERVER_NAME: &str = "codex_apps";
|
|
const CODEX_CONNECTORS_TOKEN_ENV_VAR: &str = "CODEX_CONNECTORS_TOKEN";
|
|
const OPENAI_CONNECTORS_MCP_BASE_URL: &str = "https://api.openai.com";
|
|
const OPENAI_CONNECTORS_MCP_PATH: &str = "/v1/connectors/gateways/flat/mcp";
|
|
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct ToolPluginProvenance {
|
|
plugin_display_names_by_connector_id: HashMap<String, Vec<String>>,
|
|
plugin_display_names_by_mcp_server_name: HashMap<String, Vec<String>>,
|
|
}
|
|
|
|
impl ToolPluginProvenance {
|
|
pub fn plugin_display_names_for_connector_id(&self, connector_id: &str) -> &[String] {
|
|
self.plugin_display_names_by_connector_id
|
|
.get(connector_id)
|
|
.map(Vec::as_slice)
|
|
.unwrap_or(&[])
|
|
}
|
|
|
|
pub fn plugin_display_names_for_mcp_server_name(&self, server_name: &str) -> &[String] {
|
|
self.plugin_display_names_by_mcp_server_name
|
|
.get(server_name)
|
|
.map(Vec::as_slice)
|
|
.unwrap_or(&[])
|
|
}
|
|
|
|
fn from_capability_summaries(capability_summaries: &[PluginCapabilitySummary]) -> Self {
|
|
let mut tool_plugin_provenance = Self::default();
|
|
for plugin in capability_summaries {
|
|
for connector_id in &plugin.app_connector_ids {
|
|
tool_plugin_provenance
|
|
.plugin_display_names_by_connector_id
|
|
.entry(connector_id.0.clone())
|
|
.or_default()
|
|
.push(plugin.display_name.clone());
|
|
}
|
|
|
|
for server_name in &plugin.mcp_server_names {
|
|
tool_plugin_provenance
|
|
.plugin_display_names_by_mcp_server_name
|
|
.entry(server_name.clone())
|
|
.or_default()
|
|
.push(plugin.display_name.clone());
|
|
}
|
|
}
|
|
|
|
for plugin_names in tool_plugin_provenance
|
|
.plugin_display_names_by_connector_id
|
|
.values_mut()
|
|
.chain(
|
|
tool_plugin_provenance
|
|
.plugin_display_names_by_mcp_server_name
|
|
.values_mut(),
|
|
)
|
|
{
|
|
plugin_names.sort_unstable();
|
|
plugin_names.dedup();
|
|
}
|
|
|
|
tool_plugin_provenance
|
|
}
|
|
}
|
|
|
|
// Legacy vs new MCP gateway
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum CodexAppsMcpGateway {
|
|
LegacyMCPGateway,
|
|
MCPGateway,
|
|
}
|
|
|
|
fn codex_apps_mcp_bearer_token_env_var() -> Option<String> {
|
|
match env::var(CODEX_CONNECTORS_TOKEN_ENV_VAR) {
|
|
Ok(value) if !value.trim().is_empty() => Some(CODEX_CONNECTORS_TOKEN_ENV_VAR.to_string()),
|
|
Ok(_) => None,
|
|
Err(env::VarError::NotPresent) => None,
|
|
Err(env::VarError::NotUnicode(_)) => Some(CODEX_CONNECTORS_TOKEN_ENV_VAR.to_string()),
|
|
}
|
|
}
|
|
|
|
fn codex_apps_mcp_bearer_token(auth: Option<&CodexAuth>) -> Option<String> {
|
|
let token = auth.and_then(|auth| auth.get_token().ok())?;
|
|
let token = token.trim();
|
|
if token.is_empty() {
|
|
None
|
|
} else {
|
|
Some(token.to_string())
|
|
}
|
|
}
|
|
|
|
fn codex_apps_mcp_http_headers(auth: Option<&CodexAuth>) -> Option<HashMap<String, String>> {
|
|
let mut headers = HashMap::new();
|
|
if let Some(token) = codex_apps_mcp_bearer_token(auth) {
|
|
headers.insert("Authorization".to_string(), format!("Bearer {token}"));
|
|
}
|
|
if let Some(account_id) = auth.and_then(CodexAuth::get_account_id) {
|
|
headers.insert("ChatGPT-Account-ID".to_string(), account_id);
|
|
}
|
|
if headers.is_empty() {
|
|
None
|
|
} else {
|
|
Some(headers)
|
|
}
|
|
}
|
|
|
|
fn selected_config_codex_apps_mcp_gateway(config: &Config) -> CodexAppsMcpGateway {
|
|
if config.features.enabled(Feature::AppsMcpGateway) {
|
|
CodexAppsMcpGateway::MCPGateway
|
|
} else {
|
|
CodexAppsMcpGateway::LegacyMCPGateway
|
|
}
|
|
}
|
|
|
|
fn normalize_codex_apps_base_url(base_url: &str) -> String {
|
|
let mut base_url = base_url.trim_end_matches('/').to_string();
|
|
if (base_url.starts_with("https://chatgpt.com")
|
|
|| base_url.starts_with("https://chat.openai.com"))
|
|
&& !base_url.contains("/backend-api")
|
|
{
|
|
base_url = format!("{base_url}/backend-api");
|
|
}
|
|
base_url
|
|
}
|
|
|
|
fn codex_apps_mcp_url_for_gateway(base_url: &str, gateway: CodexAppsMcpGateway) -> String {
|
|
if gateway == CodexAppsMcpGateway::MCPGateway {
|
|
return format!("{OPENAI_CONNECTORS_MCP_BASE_URL}{OPENAI_CONNECTORS_MCP_PATH}");
|
|
}
|
|
|
|
let base_url = normalize_codex_apps_base_url(base_url);
|
|
if base_url.contains("/backend-api") {
|
|
format!("{base_url}/wham/apps")
|
|
} else if base_url.contains("/api/codex") {
|
|
format!("{base_url}/apps")
|
|
} else {
|
|
format!("{base_url}/api/codex/apps")
|
|
}
|
|
}
|
|
|
|
pub(crate) fn codex_apps_mcp_url(config: &Config) -> String {
|
|
codex_apps_mcp_url_for_gateway(
|
|
&config.chatgpt_base_url,
|
|
selected_config_codex_apps_mcp_gateway(config),
|
|
)
|
|
}
|
|
|
|
fn codex_apps_mcp_server_config(config: &Config, auth: Option<&CodexAuth>) -> McpServerConfig {
|
|
let bearer_token_env_var = codex_apps_mcp_bearer_token_env_var();
|
|
let http_headers = if bearer_token_env_var.is_some() {
|
|
None
|
|
} else {
|
|
codex_apps_mcp_http_headers(auth)
|
|
};
|
|
let url = codex_apps_mcp_url(config);
|
|
|
|
McpServerConfig {
|
|
transport: McpServerTransportConfig::StreamableHttp {
|
|
url,
|
|
bearer_token_env_var,
|
|
http_headers,
|
|
env_http_headers: None,
|
|
},
|
|
enabled: true,
|
|
required: false,
|
|
disabled_reason: None,
|
|
startup_timeout_sec: Some(Duration::from_secs(30)),
|
|
tool_timeout_sec: None,
|
|
enabled_tools: None,
|
|
disabled_tools: None,
|
|
scopes: None,
|
|
oauth_resource: None,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn with_codex_apps_mcp(
|
|
mut servers: HashMap<String, McpServerConfig>,
|
|
connectors_enabled: bool,
|
|
auth: Option<&CodexAuth>,
|
|
config: &Config,
|
|
) -> HashMap<String, McpServerConfig> {
|
|
if connectors_enabled {
|
|
servers.insert(
|
|
CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
|
codex_apps_mcp_server_config(config, auth),
|
|
);
|
|
} else {
|
|
servers.remove(CODEX_APPS_MCP_SERVER_NAME);
|
|
}
|
|
servers
|
|
}
|
|
|
|
pub struct McpManager {
|
|
plugins_manager: Arc<PluginsManager>,
|
|
}
|
|
|
|
impl McpManager {
|
|
pub fn new(plugins_manager: Arc<PluginsManager>) -> Self {
|
|
Self { plugins_manager }
|
|
}
|
|
|
|
pub fn configured_servers(&self, config: &Config) -> HashMap<String, McpServerConfig> {
|
|
configured_mcp_servers(config, self.plugins_manager.as_ref())
|
|
}
|
|
|
|
pub fn effective_servers(
|
|
&self,
|
|
config: &Config,
|
|
auth: Option<&CodexAuth>,
|
|
) -> HashMap<String, McpServerConfig> {
|
|
effective_mcp_servers(config, auth, self.plugins_manager.as_ref())
|
|
}
|
|
|
|
pub fn tool_plugin_provenance(&self, config: &Config) -> ToolPluginProvenance {
|
|
let loaded_plugins = self.plugins_manager.plugins_for_config(config);
|
|
ToolPluginProvenance::from_capability_summaries(loaded_plugins.capability_summaries())
|
|
}
|
|
}
|
|
|
|
fn configured_mcp_servers(
|
|
config: &Config,
|
|
plugins_manager: &PluginsManager,
|
|
) -> HashMap<String, McpServerConfig> {
|
|
let loaded_plugins = plugins_manager.plugins_for_config(config);
|
|
let mut servers = config.mcp_servers.get().clone();
|
|
for (name, plugin_server) in loaded_plugins.effective_mcp_servers() {
|
|
servers.entry(name).or_insert(plugin_server);
|
|
}
|
|
servers
|
|
}
|
|
|
|
fn effective_mcp_servers(
|
|
config: &Config,
|
|
auth: Option<&CodexAuth>,
|
|
plugins_manager: &PluginsManager,
|
|
) -> HashMap<String, McpServerConfig> {
|
|
let servers = configured_mcp_servers(config, plugins_manager);
|
|
with_codex_apps_mcp(
|
|
servers,
|
|
config.features.apps_enabled_for_auth(auth),
|
|
auth,
|
|
config,
|
|
)
|
|
}
|
|
|
|
pub async fn collect_mcp_snapshot(config: &Config) -> McpListToolsResponseEvent {
|
|
let auth_manager = AuthManager::shared(
|
|
config.codex_home.clone(),
|
|
false,
|
|
config.cli_auth_credentials_store_mode,
|
|
);
|
|
let auth = auth_manager.auth().await;
|
|
let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(config.codex_home.clone())));
|
|
let mcp_servers = mcp_manager.effective_servers(config, auth.as_ref());
|
|
let tool_plugin_provenance = mcp_manager.tool_plugin_provenance(config);
|
|
if mcp_servers.is_empty() {
|
|
return McpListToolsResponseEvent {
|
|
tools: HashMap::new(),
|
|
resources: HashMap::new(),
|
|
resource_templates: HashMap::new(),
|
|
auth_statuses: HashMap::new(),
|
|
};
|
|
}
|
|
|
|
let auth_status_entries =
|
|
compute_auth_statuses(mcp_servers.iter(), config.mcp_oauth_credentials_store_mode).await;
|
|
|
|
let (tx_event, rx_event) = unbounded();
|
|
drop(rx_event);
|
|
|
|
// Use ReadOnly sandbox policy for MCP snapshot collection (safest default)
|
|
let sandbox_state = SandboxState {
|
|
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
|
codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(),
|
|
sandbox_cwd: env::current_dir().unwrap_or_else(|_| PathBuf::from("/")),
|
|
use_linux_sandbox_bwrap: config.features.enabled(Feature::UseLinuxSandboxBwrap),
|
|
};
|
|
|
|
let (mcp_connection_manager, cancel_token) = McpConnectionManager::new(
|
|
&mcp_servers,
|
|
config.mcp_oauth_credentials_store_mode,
|
|
auth_status_entries.clone(),
|
|
&config.permissions.approval_policy,
|
|
tx_event,
|
|
sandbox_state,
|
|
config.codex_home.clone(),
|
|
codex_apps_tools_cache_key(auth.as_ref()),
|
|
tool_plugin_provenance,
|
|
)
|
|
.await;
|
|
|
|
let snapshot =
|
|
collect_mcp_snapshot_from_manager(&mcp_connection_manager, auth_status_entries).await;
|
|
|
|
cancel_token.cancel();
|
|
|
|
snapshot
|
|
}
|
|
|
|
pub fn split_qualified_tool_name(qualified_name: &str) -> Option<(String, String)> {
|
|
let mut parts = qualified_name.split(MCP_TOOL_NAME_DELIMITER);
|
|
let prefix = parts.next()?;
|
|
if prefix != MCP_TOOL_NAME_PREFIX {
|
|
return None;
|
|
}
|
|
let server_name = parts.next()?;
|
|
let tool_name: String = parts.collect::<Vec<_>>().join(MCP_TOOL_NAME_DELIMITER);
|
|
if tool_name.is_empty() {
|
|
return None;
|
|
}
|
|
Some((server_name.to_string(), tool_name))
|
|
}
|
|
|
|
pub fn group_tools_by_server(
|
|
tools: &HashMap<String, Tool>,
|
|
) -> HashMap<String, HashMap<String, Tool>> {
|
|
let mut grouped = HashMap::new();
|
|
for (qualified_name, tool) in tools {
|
|
if let Some((server_name, tool_name)) = split_qualified_tool_name(qualified_name) {
|
|
grouped
|
|
.entry(server_name)
|
|
.or_insert_with(HashMap::new)
|
|
.insert(tool_name, tool.clone());
|
|
}
|
|
}
|
|
grouped
|
|
}
|
|
|
|
pub(crate) async fn collect_mcp_snapshot_from_manager(
|
|
mcp_connection_manager: &McpConnectionManager,
|
|
auth_status_entries: HashMap<String, crate::mcp::auth::McpAuthStatusEntry>,
|
|
) -> McpListToolsResponseEvent {
|
|
let (tools, resources, resource_templates) = tokio::join!(
|
|
mcp_connection_manager.list_all_tools(),
|
|
mcp_connection_manager.list_all_resources(),
|
|
mcp_connection_manager.list_all_resource_templates(),
|
|
);
|
|
|
|
let auth_statuses = auth_status_entries
|
|
.iter()
|
|
.map(|(name, entry)| (name.clone(), entry.auth_status))
|
|
.collect();
|
|
|
|
let tools = tools
|
|
.into_iter()
|
|
.filter_map(|(name, tool)| match serde_json::to_value(tool.tool) {
|
|
Ok(value) => match Tool::from_mcp_value(value) {
|
|
Ok(tool) => Some((name, tool)),
|
|
Err(err) => {
|
|
tracing::warn!("Failed to convert MCP tool '{name}': {err}");
|
|
None
|
|
}
|
|
},
|
|
Err(err) => {
|
|
tracing::warn!("Failed to serialize MCP tool '{name}': {err}");
|
|
None
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
let resources = resources
|
|
.into_iter()
|
|
.map(|(name, resources)| {
|
|
let resources = resources
|
|
.into_iter()
|
|
.filter_map(|resource| match serde_json::to_value(resource) {
|
|
Ok(value) => match Resource::from_mcp_value(value.clone()) {
|
|
Ok(resource) => Some(resource),
|
|
Err(err) => {
|
|
let (uri, resource_name) = match value {
|
|
Value::Object(obj) => (
|
|
obj.get("uri")
|
|
.and_then(|v| v.as_str().map(ToString::to_string)),
|
|
obj.get("name")
|
|
.and_then(|v| v.as_str().map(ToString::to_string)),
|
|
),
|
|
_ => (None, None),
|
|
};
|
|
|
|
tracing::warn!(
|
|
"Failed to convert MCP resource (uri={uri:?}, name={resource_name:?}): {err}"
|
|
);
|
|
None
|
|
}
|
|
},
|
|
Err(err) => {
|
|
tracing::warn!("Failed to serialize MCP resource: {err}");
|
|
None
|
|
}
|
|
})
|
|
.collect::<Vec<_>>();
|
|
(name, resources)
|
|
})
|
|
.collect();
|
|
|
|
let resource_templates = resource_templates
|
|
.into_iter()
|
|
.map(|(name, templates)| {
|
|
let templates = templates
|
|
.into_iter()
|
|
.filter_map(|template| match serde_json::to_value(template) {
|
|
Ok(value) => match ResourceTemplate::from_mcp_value(value.clone()) {
|
|
Ok(template) => Some(template),
|
|
Err(err) => {
|
|
let (uri_template, template_name) = match value {
|
|
Value::Object(obj) => (
|
|
obj.get("uriTemplate")
|
|
.or_else(|| obj.get("uri_template"))
|
|
.and_then(|v| v.as_str().map(ToString::to_string)),
|
|
obj.get("name")
|
|
.and_then(|v| v.as_str().map(ToString::to_string)),
|
|
),
|
|
_ => (None, None),
|
|
};
|
|
|
|
tracing::warn!(
|
|
"Failed to convert MCP resource template (uri_template={uri_template:?}, name={template_name:?}): {err}"
|
|
);
|
|
None
|
|
}
|
|
},
|
|
Err(err) => {
|
|
tracing::warn!("Failed to serialize MCP resource template: {err}");
|
|
None
|
|
}
|
|
})
|
|
.collect::<Vec<_>>();
|
|
(name, templates)
|
|
})
|
|
.collect();
|
|
|
|
McpListToolsResponseEvent {
|
|
tools,
|
|
resources,
|
|
resource_templates,
|
|
auth_statuses,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::config::CONFIG_TOML_FILE;
|
|
use crate::config::ConfigBuilder;
|
|
use crate::plugins::AppConnectorId;
|
|
use crate::plugins::PluginCapabilitySummary;
|
|
use pretty_assertions::assert_eq;
|
|
use std::fs;
|
|
use std::path::Path;
|
|
use toml::Value;
|
|
|
|
fn write_file(path: &Path, contents: &str) {
|
|
fs::create_dir_all(path.parent().expect("file should have a parent")).unwrap();
|
|
fs::write(path, contents).unwrap();
|
|
}
|
|
|
|
fn plugin_config_toml() -> String {
|
|
let mut root = toml::map::Map::new();
|
|
|
|
let mut features = toml::map::Map::new();
|
|
features.insert("plugins".to_string(), Value::Boolean(true));
|
|
root.insert("features".to_string(), Value::Table(features));
|
|
|
|
let mut plugin = toml::map::Map::new();
|
|
plugin.insert("enabled".to_string(), Value::Boolean(true));
|
|
|
|
let mut plugins = toml::map::Map::new();
|
|
plugins.insert("sample@test".to_string(), Value::Table(plugin));
|
|
root.insert("plugins".to_string(), Value::Table(plugins));
|
|
|
|
toml::to_string(&Value::Table(root)).expect("plugin test config should serialize")
|
|
}
|
|
|
|
fn make_tool(name: &str) -> Tool {
|
|
Tool {
|
|
name: name.to_string(),
|
|
title: None,
|
|
description: None,
|
|
input_schema: serde_json::json!({"type": "object", "properties": {}}),
|
|
output_schema: None,
|
|
annotations: None,
|
|
icons: None,
|
|
meta: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn split_qualified_tool_name_returns_server_and_tool() {
|
|
assert_eq!(
|
|
split_qualified_tool_name("mcp__alpha__do_thing"),
|
|
Some(("alpha".to_string(), "do_thing".to_string()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn split_qualified_tool_name_rejects_invalid_names() {
|
|
assert_eq!(split_qualified_tool_name("other__alpha__do_thing"), None);
|
|
assert_eq!(split_qualified_tool_name("mcp__alpha__"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn group_tools_by_server_strips_prefix_and_groups() {
|
|
let mut tools = HashMap::new();
|
|
tools.insert("mcp__alpha__do_thing".to_string(), make_tool("do_thing"));
|
|
tools.insert(
|
|
"mcp__alpha__nested__op".to_string(),
|
|
make_tool("nested__op"),
|
|
);
|
|
tools.insert("mcp__beta__do_other".to_string(), make_tool("do_other"));
|
|
|
|
let mut expected_alpha = HashMap::new();
|
|
expected_alpha.insert("do_thing".to_string(), make_tool("do_thing"));
|
|
expected_alpha.insert("nested__op".to_string(), make_tool("nested__op"));
|
|
|
|
let mut expected_beta = HashMap::new();
|
|
expected_beta.insert("do_other".to_string(), make_tool("do_other"));
|
|
|
|
let mut expected = HashMap::new();
|
|
expected.insert("alpha".to_string(), expected_alpha);
|
|
expected.insert("beta".to_string(), expected_beta);
|
|
|
|
assert_eq!(group_tools_by_server(&tools), expected);
|
|
}
|
|
|
|
#[test]
|
|
fn tool_plugin_provenance_collects_app_and_mcp_sources() {
|
|
let provenance = ToolPluginProvenance::from_capability_summaries(&[
|
|
PluginCapabilitySummary {
|
|
display_name: "alpha-plugin".to_string(),
|
|
app_connector_ids: vec![AppConnectorId("connector_example".to_string())],
|
|
mcp_server_names: vec!["alpha".to_string()],
|
|
..PluginCapabilitySummary::default()
|
|
},
|
|
PluginCapabilitySummary {
|
|
display_name: "beta-plugin".to_string(),
|
|
app_connector_ids: vec![
|
|
AppConnectorId("connector_example".to_string()),
|
|
AppConnectorId("connector_gmail".to_string()),
|
|
],
|
|
mcp_server_names: vec!["beta".to_string()],
|
|
..PluginCapabilitySummary::default()
|
|
},
|
|
]);
|
|
|
|
assert_eq!(
|
|
provenance,
|
|
ToolPluginProvenance {
|
|
plugin_display_names_by_connector_id: HashMap::from([
|
|
(
|
|
"connector_example".to_string(),
|
|
vec!["alpha-plugin".to_string(), "beta-plugin".to_string()],
|
|
),
|
|
(
|
|
"connector_gmail".to_string(),
|
|
vec!["beta-plugin".to_string()],
|
|
),
|
|
]),
|
|
plugin_display_names_by_mcp_server_name: HashMap::from([
|
|
("alpha".to_string(), vec!["alpha-plugin".to_string()]),
|
|
("beta".to_string(), vec!["beta-plugin".to_string()]),
|
|
]),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn codex_apps_mcp_url_for_default_gateway_keeps_existing_paths() {
|
|
assert_eq!(
|
|
codex_apps_mcp_url_for_gateway(
|
|
"https://chatgpt.com/backend-api",
|
|
CodexAppsMcpGateway::LegacyMCPGateway
|
|
),
|
|
"https://chatgpt.com/backend-api/wham/apps"
|
|
);
|
|
assert_eq!(
|
|
codex_apps_mcp_url_for_gateway(
|
|
"https://chat.openai.com",
|
|
CodexAppsMcpGateway::LegacyMCPGateway
|
|
),
|
|
"https://chat.openai.com/backend-api/wham/apps"
|
|
);
|
|
assert_eq!(
|
|
codex_apps_mcp_url_for_gateway(
|
|
"http://localhost:8080/api/codex",
|
|
CodexAppsMcpGateway::LegacyMCPGateway
|
|
),
|
|
"http://localhost:8080/api/codex/apps"
|
|
);
|
|
assert_eq!(
|
|
codex_apps_mcp_url_for_gateway(
|
|
"http://localhost:8080",
|
|
CodexAppsMcpGateway::LegacyMCPGateway
|
|
),
|
|
"http://localhost:8080/api/codex/apps"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn codex_apps_mcp_url_for_gateway_uses_openai_connectors_gateway() {
|
|
let expected_url = format!("{OPENAI_CONNECTORS_MCP_BASE_URL}{OPENAI_CONNECTORS_MCP_PATH}");
|
|
|
|
assert_eq!(
|
|
codex_apps_mcp_url_for_gateway(
|
|
"https://chatgpt.com/backend-api",
|
|
CodexAppsMcpGateway::MCPGateway
|
|
),
|
|
expected_url.as_str()
|
|
);
|
|
assert_eq!(
|
|
codex_apps_mcp_url_for_gateway(
|
|
"https://chat.openai.com",
|
|
CodexAppsMcpGateway::MCPGateway
|
|
),
|
|
expected_url.as_str()
|
|
);
|
|
assert_eq!(
|
|
codex_apps_mcp_url_for_gateway(
|
|
"http://localhost:8080/api/codex",
|
|
CodexAppsMcpGateway::MCPGateway
|
|
),
|
|
expected_url.as_str()
|
|
);
|
|
assert_eq!(
|
|
codex_apps_mcp_url_for_gateway(
|
|
"http://localhost:8080",
|
|
CodexAppsMcpGateway::MCPGateway
|
|
),
|
|
expected_url.as_str()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn codex_apps_mcp_url_uses_default_gateway_when_feature_is_disabled() {
|
|
let mut config = crate::config::test_config();
|
|
config.chatgpt_base_url = "https://chatgpt.com".to_string();
|
|
|
|
assert_eq!(
|
|
codex_apps_mcp_url(&config),
|
|
"https://chatgpt.com/backend-api/wham/apps"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn codex_apps_mcp_url_uses_openai_connectors_gateway_when_feature_is_enabled() {
|
|
let mut config = crate::config::test_config();
|
|
config.chatgpt_base_url = "https://chatgpt.com".to_string();
|
|
config
|
|
.features
|
|
.enable(Feature::AppsMcpGateway)
|
|
.expect("test config should allow apps gateway");
|
|
|
|
assert_eq!(
|
|
codex_apps_mcp_url(&config),
|
|
format!("{OPENAI_CONNECTORS_MCP_BASE_URL}{OPENAI_CONNECTORS_MCP_PATH}")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn codex_apps_server_config_switches_gateway_with_flags() {
|
|
let mut config = crate::config::test_config();
|
|
config.chatgpt_base_url = "https://chatgpt.com".to_string();
|
|
|
|
let mut servers = with_codex_apps_mcp(HashMap::new(), false, None, &config);
|
|
assert!(!servers.contains_key(CODEX_APPS_MCP_SERVER_NAME));
|
|
|
|
config
|
|
.features
|
|
.enable(Feature::Apps)
|
|
.expect("test config should allow apps");
|
|
|
|
servers = with_codex_apps_mcp(servers, true, None, &config);
|
|
let server = servers
|
|
.get(CODEX_APPS_MCP_SERVER_NAME)
|
|
.expect("codex apps should be present when apps is enabled");
|
|
let url = match &server.transport {
|
|
McpServerTransportConfig::StreamableHttp { url, .. } => url,
|
|
_ => panic!("expected streamable http transport for codex apps"),
|
|
};
|
|
|
|
assert_eq!(url, "https://chatgpt.com/backend-api/wham/apps");
|
|
|
|
config
|
|
.features
|
|
.enable(Feature::AppsMcpGateway)
|
|
.expect("test config should allow apps gateway");
|
|
servers = with_codex_apps_mcp(servers, true, None, &config);
|
|
let server = servers
|
|
.get(CODEX_APPS_MCP_SERVER_NAME)
|
|
.expect("codex apps should remain present when apps stays enabled");
|
|
let url = match &server.transport {
|
|
McpServerTransportConfig::StreamableHttp { url, .. } => url,
|
|
_ => panic!("expected streamable http transport for codex apps"),
|
|
};
|
|
|
|
let expected_url = format!("{OPENAI_CONNECTORS_MCP_BASE_URL}{OPENAI_CONNECTORS_MCP_PATH}");
|
|
assert_eq!(url, &expected_url);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn effective_mcp_servers_include_plugins_without_overriding_user_config() {
|
|
let codex_home = tempfile::tempdir().expect("tempdir");
|
|
let plugin_root = codex_home
|
|
.path()
|
|
.join("plugins/cache")
|
|
.join("test/sample/local");
|
|
write_file(
|
|
&plugin_root.join(".codex-plugin/plugin.json"),
|
|
r#"{"name":"sample"}"#,
|
|
);
|
|
write_file(
|
|
&plugin_root.join(".mcp.json"),
|
|
r#"{
|
|
"mcpServers": {
|
|
"sample": {
|
|
"type": "http",
|
|
"url": "https://plugin.example/mcp"
|
|
},
|
|
"docs": {
|
|
"type": "http",
|
|
"url": "https://docs.example/mcp"
|
|
}
|
|
}
|
|
}"#,
|
|
);
|
|
write_file(
|
|
&codex_home.path().join(CONFIG_TOML_FILE),
|
|
&plugin_config_toml(),
|
|
);
|
|
|
|
let mut config = ConfigBuilder::default()
|
|
.codex_home(codex_home.path().to_path_buf())
|
|
.build()
|
|
.await
|
|
.expect("config should load");
|
|
|
|
let mut configured_servers = config.mcp_servers.get().clone();
|
|
configured_servers.insert(
|
|
"sample".to_string(),
|
|
McpServerConfig {
|
|
transport: McpServerTransportConfig::StreamableHttp {
|
|
url: "https://user.example/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: None,
|
|
},
|
|
);
|
|
config
|
|
.mcp_servers
|
|
.set(configured_servers)
|
|
.expect("test config should accept MCP servers");
|
|
|
|
let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(config.codex_home.clone())));
|
|
let effective = mcp_manager.effective_servers(&config, None);
|
|
|
|
let sample = effective.get("sample").expect("user server should exist");
|
|
let docs = effective.get("docs").expect("plugin server should exist");
|
|
|
|
match &sample.transport {
|
|
McpServerTransportConfig::StreamableHttp { url, .. } => {
|
|
assert_eq!(url, "https://user.example/mcp");
|
|
}
|
|
other => panic!("expected streamable http transport, got {other:?}"),
|
|
}
|
|
match &docs.transport {
|
|
McpServerTransportConfig::StreamableHttp { url, .. } => {
|
|
assert_eq!(url, "https://docs.example/mcp");
|
|
}
|
|
other => panic!("expected streamable http transport, got {other:?}"),
|
|
}
|
|
}
|
|
}
|