Codex Handbook
config/src/mcp_types.rs 482 lines
//! MCP server configuration types.use std::collections::HashMap;use std::fmt;use std::path::PathBuf;use std::time::Duration;use schemars::JsonSchema;use serde::Deserialize;use serde::Deserializer;use serde::Serialize;use serde::de::Error as SerdeError;use crate::RequirementSource;/// Effective MCP environment id when config omits `environment_id`.pub const DEFAULT_MCP_SERVER_ENVIRONMENT_ID: &str = "local";#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, JsonSchema)]#[serde(rename_all = "snake_case")]pub enum AppToolApproval {    #[default]    Auto,    Prompt,    Approve,}/// Human-readable reason a configured MCP server was disabled after requirements/// were applied.////// `Display` is intentionally implemented for CLI/TUI status output; avoid/// relying on `Debug` because enum variant syntax is not part of the user-facing/// message contract.#[derive(Debug, Clone, PartialEq, Eq)]pub enum McpServerDisabledReason {    /// The server is disabled, but there is no more specific user-facing reason.    Unknown,    /// The server was disabled by config requirements from the given source.    Requirements { source: RequirementSource },}impl fmt::Display for McpServerDisabledReason {    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {        match self {            McpServerDisabledReason::Unknown => write!(f, "unknown"),            McpServerDisabledReason::Requirements { source } => {                write!(f, "requirements ({source})")            }        }    }}/// Per-tool approval settings for a single MCP server tool.#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]#[schemars(deny_unknown_fields)]pub struct McpServerToolConfig {    /// Approval mode for this tool.    #[serde(default, skip_serializing_if = "Option::is_none")]    pub approval_mode: Option<AppToolApproval>,}#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]#[serde(untagged, deny_unknown_fields)]pub enum McpServerEnvVar {    Name(String),    Config {        name: String,        #[serde(default, skip_serializing_if = "Option::is_none")]        source: Option<String>,    },}impl McpServerEnvVar {    pub fn name(&self) -> &str {        match self {            McpServerEnvVar::Name(name) => name,            McpServerEnvVar::Config { name, .. } => name,        }    }    pub fn source(&self) -> Option<&str> {        match self {            McpServerEnvVar::Name(_) => None,            McpServerEnvVar::Config { source, .. } => source.as_deref(),        }    }    pub fn is_remote_source(&self) -> bool {        self.source() == Some("remote")    }    pub fn validate_source(&self) -> Result<(), String> {        match self.source() {            None | Some("local") | Some("remote") => Ok(()),            Some(source) => Err(format!(                "unsupported env_vars source `{source}`; expected `local` or `remote`"            )),        }    }}impl From<String> for McpServerEnvVar {    fn from(value: String) -> Self {        Self::Name(value)    }}impl From<&str> for McpServerEnvVar {    fn from(value: &str) -> Self {        Self::Name(value.to_string())    }}impl AsRef<str> for McpServerEnvVar {    fn as_ref(&self) -> &str {        self.name()    }}/// OAuth client settings used when Codex launches an MCP OAuth flow.#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]#[schemars(deny_unknown_fields)]pub struct McpServerOAuthConfig {    /// Explicit OAuth client identifier to present during authorization and token exchange.    #[serde(default, skip_serializing_if = "Option::is_none")]    pub client_id: Option<String>,}#[derive(Serialize, Debug, Clone, PartialEq)]pub struct McpServerConfig {    #[serde(flatten)]    pub transport: McpServerTransportConfig,    /// Effective environment id for where Codex should start this MCP server.    pub environment_id: String,    /// When `false`, Codex skips initializing this MCP server.    #[serde(default = "default_enabled")]    pub enabled: bool,    /// When `true`, `codex exec` exits with an error if this MCP server fails to initialize.    #[serde(default, skip_serializing_if = "std::ops::Not::not")]    pub required: bool,    /// When `true`, every tool from this server is advertised as safe for parallel tool calls.    #[serde(default, skip_serializing_if = "std::ops::Not::not")]    pub supports_parallel_tool_calls: bool,    /// Reason this server was disabled after applying requirements.    #[serde(skip)]    pub disabled_reason: Option<McpServerDisabledReason>,    /// Startup timeout in seconds for initializing MCP server & initially listing tools.    #[serde(        default,        with = "option_duration_secs",        skip_serializing_if = "Option::is_none"    )]    pub startup_timeout_sec: Option<Duration>,    /// Default timeout for MCP tool calls initiated via this server.    #[serde(default, with = "option_duration_secs")]    pub tool_timeout_sec: Option<Duration>,    /// Approval mode for tools in this server unless a tool override exists.    #[serde(default, skip_serializing_if = "Option::is_none")]    pub default_tools_approval_mode: Option<AppToolApproval>,    /// Explicit allow-list of tools exposed from this server. When set, only these tools will be registered.    #[serde(default, skip_serializing_if = "Option::is_none")]    pub enabled_tools: Option<Vec<String>>,    /// Explicit deny-list of tools. These tools will be removed after applying `enabled_tools`.    #[serde(default, skip_serializing_if = "Option::is_none")]    pub disabled_tools: Option<Vec<String>>,    /// Optional OAuth scopes to request during MCP login.    #[serde(default, skip_serializing_if = "Option::is_none")]    pub scopes: Option<Vec<String>>,    /// Optional OAuth client settings for MCP login.    #[serde(default, skip_serializing_if = "Option::is_none")]    pub oauth: Option<McpServerOAuthConfig>,    /// Optional OAuth resource parameter to include during MCP login (RFC 8707).    #[serde(default, skip_serializing_if = "Option::is_none")]    pub oauth_resource: Option<String>,    /// Per-tool approval settings keyed by tool name.    #[serde(default, skip_serializing_if = "HashMap::is_empty")]    pub tools: HashMap<String, McpServerToolConfig>,}impl McpServerConfig {    pub fn is_local_environment(&self) -> bool {        self.environment_id == DEFAULT_MCP_SERVER_ENVIRONMENT_ID    }    pub fn oauth_client_id(&self) -> Option<&str> {        self.oauth            .as_ref()            .and_then(|oauth| oauth.client_id.as_deref())    }}/// Raw MCP config shape used for deserialization and supported-field JSON/// Schema generation.////// Fields that are accepted only to produce targeted validation errors should/// be skipped in the generated schema.////// Keep `TryFrom<RawMcpServerConfig> for McpServerConfig` exhaustively/// destructuring this struct so new TOML fields cannot be added here without/// updating the validation/mapping logic that produces [`McpServerConfig`].#[derive(Deserialize, Clone, JsonSchema)]#[schemars(deny_unknown_fields)]pub struct RawMcpServerConfig {    // stdio    pub command: Option<String>,    #[serde(default)]    pub args: Option<Vec<String>>,    #[serde(default)]    pub env: Option<HashMap<String, String>>,    #[serde(default)]    pub env_vars: Option<Vec<McpServerEnvVar>>,    #[serde(default)]    pub cwd: Option<PathBuf>,    pub http_headers: Option<HashMap<String, String>>,    #[serde(default)]    pub env_http_headers: Option<HashMap<String, String>>,    // streamable_http    pub url: Option<String>,    #[schemars(skip)]    pub bearer_token: Option<String>,    pub bearer_token_env_var: Option<String>,    // shared    #[serde(default)]    pub environment_id: Option<String>,    #[serde(default)]    pub startup_timeout_sec: Option<f64>,    #[serde(default)]    pub startup_timeout_ms: Option<u64>,    #[serde(default, with = "option_duration_secs")]    #[schemars(with = "Option<f64>")]    pub tool_timeout_sec: Option<Duration>,    #[serde(default)]    pub enabled: Option<bool>,    #[serde(default)]    pub required: Option<bool>,    #[serde(default)]    pub supports_parallel_tool_calls: Option<bool>,    #[serde(default)]    pub default_tools_approval_mode: Option<AppToolApproval>,    #[serde(default)]    pub enabled_tools: Option<Vec<String>>,    #[serde(default)]    pub disabled_tools: Option<Vec<String>>,    #[serde(default)]    pub scopes: Option<Vec<String>>,    #[serde(default)]    pub oauth: Option<McpServerOAuthConfig>,    #[serde(default)]    pub oauth_resource: Option<String>,    /// Legacy display-name field accepted for backward compatibility.    #[serde(default, rename = "name")]    pub _name: Option<String>,    #[serde(default)]    pub tools: Option<HashMap<String, McpServerToolConfig>>,}impl TryFrom<RawMcpServerConfig> for McpServerConfig {    type Error = String;    fn try_from(raw: RawMcpServerConfig) -> Result<Self, Self::Error> {        let RawMcpServerConfig {            command,            args,            env,            env_vars,            cwd,            http_headers,            env_http_headers,            url,            bearer_token,            bearer_token_env_var,            environment_id,            startup_timeout_sec,            startup_timeout_ms,            tool_timeout_sec,            enabled,            required,            supports_parallel_tool_calls,            default_tools_approval_mode,            enabled_tools,            disabled_tools,            scopes,            oauth,            oauth_resource,            _name: _,            tools,        } = raw;        let startup_timeout_sec = match (startup_timeout_sec, startup_timeout_ms) {            (Some(sec), _) => {                Some(Duration::try_from_secs_f64(sec).map_err(|err| err.to_string())?)            }            (None, Some(ms)) => Some(Duration::from_millis(ms)),            (None, None) => None,        };        fn throw_if_set<T>(transport: &str, field: &str, value: Option<&T>) -> Result<(), String> {            if value.is_none() {                return Ok(());            }            Err(format!("{field} is not supported for {transport}"))        }        let transport = if let Some(command) = command {            throw_if_set("stdio", "url", url.as_ref())?;            throw_if_set(                "stdio",                "bearer_token_env_var",                bearer_token_env_var.as_ref(),            )?;            throw_if_set("stdio", "bearer_token", bearer_token.as_ref())?;            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", oauth.as_ref())?;            throw_if_set("stdio", "oauth_resource", oauth_resource.as_ref())?;            let env_vars = env_vars.unwrap_or_default();            for env_var in &env_vars {                env_var.validate_source()?;            }            McpServerTransportConfig::Stdio {                command,                args: args.unwrap_or_default(),                env,                env_vars,                cwd,            }        } else if let Some(url) = url {            throw_if_set("streamable_http", "args", args.as_ref())?;            throw_if_set("streamable_http", "env", env.as_ref())?;            throw_if_set("streamable_http", "env_vars", env_vars.as_ref())?;            throw_if_set("streamable_http", "cwd", cwd.as_ref())?;            throw_if_set("streamable_http", "bearer_token", bearer_token.as_ref())?;            McpServerTransportConfig::StreamableHttp {                url,                bearer_token_env_var,                http_headers,                env_http_headers,            }        } else {            return Err("invalid transport".to_string());        };        let environment_id =            environment_id.unwrap_or_else(|| DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string());        validate_remote_stdio_cwd(&transport, &environment_id)?;        Ok(Self {            transport,            environment_id,            startup_timeout_sec,            tool_timeout_sec,            enabled: enabled.unwrap_or_else(default_enabled),            required: required.unwrap_or_default(),            supports_parallel_tool_calls: supports_parallel_tool_calls.unwrap_or_default(),            disabled_reason: None,            default_tools_approval_mode,            enabled_tools,            disabled_tools,            scopes,            oauth,            oauth_resource,            tools: tools.unwrap_or_default(),        })    }}impl<'de> Deserialize<'de> for McpServerConfig {    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>    where        D: Deserializer<'de>,    {        RawMcpServerConfig::deserialize(deserializer)?            .try_into()            .map_err(SerdeError::custom)    }}const fn default_enabled() -> bool {    true}fn validate_remote_stdio_cwd(    transport: &McpServerTransportConfig,    environment_id: &str,) -> Result<(), String> {    if environment_id == DEFAULT_MCP_SERVER_ENVIRONMENT_ID {        return Ok(());    }    let McpServerTransportConfig::Stdio { cwd, .. } = transport else {        return Ok(());    };    let Some(cwd) = cwd else {        return Err(format!(            "remote stdio MCP servers require an absolute cwd when environment_id is `{environment_id}`"        ));    };    if cwd.is_absolute() {        return Ok(());    }    Err(format!(        "remote stdio MCP servers require an absolute cwd when environment_id is `{environment_id}`, got `{}`",        cwd.display()    ))}#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)]#[serde(untagged, deny_unknown_fields, rename_all = "snake_case")]pub enum McpServerTransportConfig {    /// https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio    Stdio {        command: String,        #[serde(default)]        args: Vec<String>,        #[serde(default, skip_serializing_if = "Option::is_none")]        env: Option<HashMap<String, String>>,        #[serde(default, skip_serializing_if = "Vec::is_empty")]        env_vars: Vec<McpServerEnvVar>,        #[serde(default, skip_serializing_if = "Option::is_none")]        cwd: Option<PathBuf>,    },    /// https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http    StreamableHttp {        url: String,        /// Name of the environment variable to read for an HTTP bearer token.        /// When set, requests will include the token via `Authorization: Bearer <token>`.        /// The actual secret value must be provided via the environment.        #[serde(default, skip_serializing_if = "Option::is_none")]        bearer_token_env_var: Option<String>,        /// Additional HTTP headers to include in requests to this server.        #[serde(default, skip_serializing_if = "Option::is_none")]        http_headers: Option<HashMap<String, String>>,        /// HTTP headers where the value is sourced from an environment variable.        #[serde(default, skip_serializing_if = "Option::is_none")]        env_http_headers: Option<HashMap<String, String>>,    },}mod option_duration_secs {    use serde::Deserialize;    use serde::Deserializer;    use serde::Serializer;    use std::time::Duration;    pub fn serialize<S>(value: &Option<Duration>, serializer: S) -> Result<S::Ok, S::Error>    where        S: Serializer,    {        match value {            Some(duration) => serializer.serialize_some(&duration.as_secs_f64()),            None => serializer.serialize_none(),        }    }    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>    where        D: Deserializer<'de>,    {        let secs = Option::<f64>::deserialize(deserializer)?;        secs.map(|secs| Duration::try_from_secs_f64(secs).map_err(serde::de::Error::custom))            .transpose()    }}#[cfg(test)]#[path = "mcp_types_tests.rs"]mod tests;