mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
## Summary - reduce public module visibility across Rust crates, preferring private or crate-private modules with explicit crate-root public exports - update external call sites and tests to use the intended public crate APIs instead of reaching through module trees - add the module visibility guideline to AGENTS.md ## Validation - `cargo check --workspace --all-targets --message-format=short` passed before the final fix/format pass - `just fix` completed successfully - `just fmt` completed successfully - `git diff --check` passed
537 lines
18 KiB
Rust
537 lines
18 KiB
Rust
pub(crate) mod auth;
|
|
mod skill_dependencies;
|
|
pub use auth::McpAuthStatusEntry;
|
|
pub use auth::McpOAuthLoginConfig;
|
|
pub use auth::McpOAuthLoginSupport;
|
|
pub use auth::McpOAuthScopesSource;
|
|
pub use auth::ResolvedMcpOAuthScopes;
|
|
pub use auth::compute_auth_statuses;
|
|
pub use auth::discover_supported_scopes;
|
|
pub use auth::oauth_login_support;
|
|
pub use auth::resolve_oauth_scopes;
|
|
pub use auth::should_retry_without_scopes;
|
|
pub use skill_dependencies::canonical_mcp_server_key;
|
|
pub use skill_dependencies::collect_missing_mcp_dependencies;
|
|
|
|
use std::collections::HashMap;
|
|
use std::env;
|
|
use std::path::PathBuf;
|
|
use std::time::Duration;
|
|
|
|
use async_channel::unbounded;
|
|
use codex_config::Constrained;
|
|
use codex_config::McpServerConfig;
|
|
use codex_config::McpServerTransportConfig;
|
|
use codex_config::types::OAuthCredentialsStoreMode;
|
|
use codex_login::CodexAuth;
|
|
use codex_plugin::PluginCapabilitySummary;
|
|
use codex_protocol::mcp::Resource;
|
|
use codex_protocol::mcp::ResourceTemplate;
|
|
use codex_protocol::mcp::Tool;
|
|
use codex_protocol::protocol::AskForApproval;
|
|
use codex_protocol::protocol::McpListToolsResponseEvent;
|
|
use codex_protocol::protocol::SandboxPolicy;
|
|
use serde_json::Value;
|
|
|
|
use crate::mcp_connection_manager::McpConnectionManager;
|
|
use crate::mcp_connection_manager::SandboxState;
|
|
use crate::mcp_connection_manager::codex_apps_tools_cache_key;
|
|
pub type McpManager = McpConnectionManager;
|
|
|
|
const MCP_TOOL_NAME_PREFIX: &str = "mcp";
|
|
const MCP_TOOL_NAME_DELIMITER: &str = "__";
|
|
pub const CODEX_APPS_MCP_SERVER_NAME: &str = "codex_apps";
|
|
const CODEX_CONNECTORS_TOKEN_ENV_VAR: &str = "CODEX_CONNECTORS_TOKEN";
|
|
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
|
pub enum McpSnapshotDetail {
|
|
#[default]
|
|
Full,
|
|
ToolsAndAuthOnly,
|
|
}
|
|
|
|
impl McpSnapshotDetail {
|
|
fn include_resources(self) -> bool {
|
|
matches!(self, Self::Full)
|
|
}
|
|
}
|
|
|
|
/// The Responses API requires tool names to match `^[a-zA-Z0-9_-]+$`.
|
|
/// MCP server/tool names are user-controlled, so sanitize the fully-qualified
|
|
/// name we expose to the model by replacing any disallowed character with `_`.
|
|
pub(crate) fn sanitize_responses_api_tool_name(name: &str) -> String {
|
|
let mut sanitized = String::with_capacity(name.len());
|
|
for c in name.chars() {
|
|
if c.is_ascii_alphanumeric() || c == '_' {
|
|
sanitized.push(c);
|
|
} else {
|
|
sanitized.push('_');
|
|
}
|
|
}
|
|
|
|
if sanitized.is_empty() {
|
|
"_".to_string()
|
|
} else {
|
|
sanitized
|
|
}
|
|
}
|
|
|
|
pub fn qualified_mcp_tool_name_prefix(server_name: &str) -> String {
|
|
sanitize_responses_api_tool_name(&format!(
|
|
"{MCP_TOOL_NAME_PREFIX}{MCP_TOOL_NAME_DELIMITER}{server_name}{MCP_TOOL_NAME_DELIMITER}"
|
|
))
|
|
}
|
|
|
|
/// MCP runtime settings derived from `codex_core::config::Config`.
|
|
///
|
|
/// This struct should contain only long-lived configuration values that the
|
|
/// `codex-mcp` crate needs to construct server transports, enforce MCP
|
|
/// approval/sandbox policy, locate OAuth state, and merge plugin-provided MCP
|
|
/// servers. Request-scoped or auth-scoped state should not be stored here;
|
|
/// thread those values explicitly into runtime entry points such as
|
|
/// [`with_codex_apps_mcp`] and [`collect_mcp_snapshot`] so config objects do
|
|
/// not go stale when auth changes.
|
|
#[derive(Debug, Clone)]
|
|
pub struct McpConfig {
|
|
/// Base URL for ChatGPT-hosted app MCP servers, copied from the root config.
|
|
pub chatgpt_base_url: String,
|
|
/// Codex home directory used for MCP OAuth state and app-tool cache files.
|
|
pub codex_home: PathBuf,
|
|
/// Preferred credential store for MCP OAuth tokens.
|
|
pub mcp_oauth_credentials_store_mode: OAuthCredentialsStoreMode,
|
|
/// Optional fixed localhost callback port for MCP OAuth login.
|
|
pub mcp_oauth_callback_port: Option<u16>,
|
|
/// Optional OAuth redirect URI override for MCP login.
|
|
pub mcp_oauth_callback_url: Option<String>,
|
|
/// Whether skill MCP dependency installation prompts are enabled.
|
|
pub skill_mcp_dependency_install_enabled: bool,
|
|
/// Approval policy used for MCP tool calls and MCP elicitation requests.
|
|
pub approval_policy: Constrained<AskForApproval>,
|
|
/// Optional path to `codex-linux-sandbox` for sandboxed MCP tool execution.
|
|
pub codex_linux_sandbox_exe: Option<PathBuf>,
|
|
/// Whether to use legacy Landlock behavior in the MCP sandbox state.
|
|
pub use_legacy_landlock: bool,
|
|
/// Whether the app MCP integration is enabled by config.
|
|
///
|
|
/// ChatGPT auth is checked separately at runtime before the built-in apps
|
|
/// MCP server is added.
|
|
pub apps_enabled: bool,
|
|
/// User-configured and plugin-provided MCP servers keyed by server name.
|
|
pub configured_mcp_servers: HashMap<String, McpServerConfig>,
|
|
/// Plugin metadata used to attribute MCP tools/connectors to plugin display names.
|
|
pub plugin_capability_summaries: Vec<PluginCapabilitySummary>,
|
|
}
|
|
|
|
#[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
|
|
}
|
|
}
|
|
|
|
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 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_base_url(base_url: &str) -> String {
|
|
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: &McpConfig) -> String {
|
|
codex_apps_mcp_url_for_base_url(&config.chatgpt_base_url)
|
|
}
|
|
|
|
fn codex_apps_mcp_server_config(config: &McpConfig, 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,
|
|
tools: HashMap::new(),
|
|
}
|
|
}
|
|
|
|
pub fn with_codex_apps_mcp(
|
|
mut servers: HashMap<String, McpServerConfig>,
|
|
auth: Option<&CodexAuth>,
|
|
config: &McpConfig,
|
|
) -> HashMap<String, McpServerConfig> {
|
|
if config.apps_enabled && auth.is_some_and(CodexAuth::is_chatgpt_auth) {
|
|
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 fn configured_mcp_servers(config: &McpConfig) -> HashMap<String, McpServerConfig> {
|
|
config.configured_mcp_servers.clone()
|
|
}
|
|
|
|
pub fn effective_mcp_servers(
|
|
config: &McpConfig,
|
|
auth: Option<&CodexAuth>,
|
|
) -> HashMap<String, McpServerConfig> {
|
|
let servers = configured_mcp_servers(config);
|
|
with_codex_apps_mcp(servers, auth, config)
|
|
}
|
|
|
|
pub fn tool_plugin_provenance(config: &McpConfig) -> ToolPluginProvenance {
|
|
ToolPluginProvenance::from_capability_summaries(&config.plugin_capability_summaries)
|
|
}
|
|
|
|
pub async fn collect_mcp_snapshot(
|
|
config: &McpConfig,
|
|
auth: Option<&CodexAuth>,
|
|
submit_id: String,
|
|
) -> McpListToolsResponseEvent {
|
|
collect_mcp_snapshot_with_detail(config, auth, submit_id, McpSnapshotDetail::Full).await
|
|
}
|
|
|
|
pub async fn collect_mcp_snapshot_with_detail(
|
|
config: &McpConfig,
|
|
auth: Option<&CodexAuth>,
|
|
submit_id: String,
|
|
detail: McpSnapshotDetail,
|
|
) -> McpListToolsResponseEvent {
|
|
let mcp_servers = effective_mcp_servers(config, auth);
|
|
let tool_plugin_provenance = 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_legacy_landlock: config.use_legacy_landlock,
|
|
};
|
|
|
|
let (mcp_connection_manager, cancel_token) = McpConnectionManager::new(
|
|
&mcp_servers,
|
|
config.mcp_oauth_credentials_store_mode,
|
|
auth_status_entries.clone(),
|
|
&config.approval_policy,
|
|
submit_id,
|
|
tx_event,
|
|
sandbox_state,
|
|
config.codex_home.clone(),
|
|
codex_apps_tools_cache_key(auth),
|
|
tool_plugin_provenance,
|
|
)
|
|
.await;
|
|
|
|
let snapshot = collect_mcp_snapshot_from_manager_with_detail(
|
|
&mcp_connection_manager,
|
|
auth_status_entries,
|
|
detail,
|
|
)
|
|
.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 async fn collect_mcp_snapshot_from_manager(
|
|
mcp_connection_manager: &McpConnectionManager,
|
|
auth_status_entries: HashMap<String, McpAuthStatusEntry>,
|
|
) -> McpListToolsResponseEvent {
|
|
collect_mcp_snapshot_from_manager_with_detail(
|
|
mcp_connection_manager,
|
|
auth_status_entries,
|
|
McpSnapshotDetail::Full,
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub async fn collect_mcp_snapshot_from_manager_with_detail(
|
|
mcp_connection_manager: &McpConnectionManager,
|
|
auth_status_entries: HashMap<String, McpAuthStatusEntry>,
|
|
detail: McpSnapshotDetail,
|
|
) -> McpListToolsResponseEvent {
|
|
let (tools, resources, resource_templates) = tokio::join!(
|
|
mcp_connection_manager.list_all_tools(),
|
|
async {
|
|
if detail.include_resources() {
|
|
mcp_connection_manager.list_all_resources().await
|
|
} else {
|
|
HashMap::new()
|
|
}
|
|
},
|
|
async {
|
|
if detail.include_resources() {
|
|
mcp_connection_manager.list_all_resource_templates().await
|
|
} else {
|
|
HashMap::new()
|
|
}
|
|
},
|
|
);
|
|
|
|
let auth_statuses = auth_status_entries
|
|
.iter()
|
|
.map(|(name, entry)| (name.clone(), entry.auth_status))
|
|
.collect::<HashMap<_, _>>();
|
|
|
|
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::<HashMap<_, _>>();
|
|
|
|
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::<HashMap<_, _>>();
|
|
|
|
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::<HashMap<_, _>>();
|
|
|
|
McpListToolsResponseEvent {
|
|
tools,
|
|
resources,
|
|
resource_templates,
|
|
auth_statuses,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "mod_tests.rs"]
|
|
mod tests;
|