Skip to main content

codex_config/
mcp_types.rs

1//! MCP server configuration types.
2
3use std::collections::HashMap;
4use std::fmt;
5use std::time::Duration;
6
7use codex_utils_path_uri::LegacyAppPathString;
8use schemars::JsonSchema;
9use serde::Deserialize;
10use serde::Deserializer;
11use serde::Serialize;
12use serde::de::Error as SerdeError;
13
14use crate::RequirementSource;
15
16/// Effective MCP environment id when config omits `environment_id`.
17pub const DEFAULT_MCP_SERVER_ENVIRONMENT_ID: &str = "local";
18
19#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, JsonSchema)]
20#[serde(rename_all = "snake_case")]
21pub enum AppToolApproval {
22    #[default]
23    Auto,
24    Prompt,
25    Writes,
26    Approve,
27}
28
29/// Human-readable reason a configured MCP server was disabled after requirements
30/// were applied.
31///
32/// `Display` is intentionally implemented for CLI/TUI status output; avoid
33/// relying on `Debug` because enum variant syntax is not part of the user-facing
34/// message contract.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum McpServerDisabledReason {
37    /// The server is disabled, but there is no more specific user-facing reason.
38    Unknown,
39    /// The server was disabled by config requirements from the given source.
40    Requirements { source: RequirementSource },
41}
42
43impl fmt::Display for McpServerDisabledReason {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        match self {
46            McpServerDisabledReason::Unknown => write!(f, "unknown"),
47            McpServerDisabledReason::Requirements { source } => {
48                write!(f, "requirements ({source})")
49            }
50        }
51    }
52}
53
54/// Per-tool approval settings for a single MCP server tool.
55#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
56#[schemars(deny_unknown_fields)]
57pub struct McpServerToolConfig {
58    /// Approval mode for this tool.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub approval_mode: Option<AppToolApproval>,
61}
62
63#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
64#[serde(untagged, deny_unknown_fields)]
65pub enum McpServerEnvVar {
66    Name(String),
67    Config {
68        name: String,
69        #[serde(default, skip_serializing_if = "Option::is_none")]
70        source: Option<String>,
71    },
72}
73
74impl McpServerEnvVar {
75    pub fn name(&self) -> &str {
76        match self {
77            McpServerEnvVar::Name(name) => name,
78            McpServerEnvVar::Config { name, .. } => name,
79        }
80    }
81
82    pub fn source(&self) -> Option<&str> {
83        match self {
84            McpServerEnvVar::Name(_) => None,
85            McpServerEnvVar::Config { source, .. } => source.as_deref(),
86        }
87    }
88
89    pub fn is_remote_source(&self) -> bool {
90        self.source() == Some("remote")
91    }
92
93    pub fn validate_source(&self) -> Result<(), String> {
94        match self.source() {
95            None | Some("local") | Some("remote") => Ok(()),
96            Some(source) => Err(format!(
97                "unsupported env_vars source `{source}`; expected `local` or `remote`"
98            )),
99        }
100    }
101}
102
103impl From<String> for McpServerEnvVar {
104    fn from(value: String) -> Self {
105        Self::Name(value)
106    }
107}
108
109impl From<&str> for McpServerEnvVar {
110    fn from(value: &str) -> Self {
111        Self::Name(value.to_string())
112    }
113}
114
115impl AsRef<str> for McpServerEnvVar {
116    fn as_ref(&self) -> &str {
117        self.name()
118    }
119}
120
121/// OAuth client settings used when Codex launches an MCP OAuth flow.
122#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
123#[schemars(deny_unknown_fields)]
124pub struct McpServerOAuthConfig {
125    /// Explicit OAuth client identifier to present during authorization and token exchange.
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub client_id: Option<String>,
128}
129
130/// Authentication flow Codex attempts after resolving an HTTP MCP server's
131/// configured bearer token and authorization headers, which always take
132/// precedence. ChatGPT authentication falls back to stored OAuth credentials
133/// when its session provider is unavailable; both modes ultimately fall back
134/// to an unauthenticated connection.
135#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
136#[serde(rename_all = "snake_case")]
137pub enum McpServerAuth {
138    /// Use stored MCP OAuth credentials when available. Starting an OAuth login
139    /// is a separate operation.
140    #[default]
141    #[serde(rename = "oauth")]
142    OAuth,
143    /// Use the current ChatGPT session for servers on the trusted first-party
144    /// ChatGPT origin. If no ChatGPT session provider is available, startup can
145    /// still fall back to stored OAuth credentials.
146    #[serde(rename = "chatgpt")]
147    ChatGpt,
148}
149
150impl McpServerAuth {
151    fn is_default(&self) -> bool {
152        self == &Self::default()
153    }
154}
155
156#[derive(Serialize, Debug, Clone, PartialEq)]
157pub struct McpServerConfig {
158    #[serde(flatten)]
159    pub transport: McpServerTransportConfig,
160
161    /// Authentication flow to use when no configured authorization resolves.
162    #[serde(default, skip_serializing_if = "McpServerAuth::is_default")]
163    pub auth: McpServerAuth,
164
165    /// Effective environment id for where Codex should start this MCP server.
166    pub environment_id: String,
167
168    /// When `false`, Codex skips initializing this MCP server.
169    #[serde(default = "default_enabled")]
170    pub enabled: bool,
171
172    /// When `true`, `codex exec` exits with an error if this MCP server fails to initialize.
173    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
174    pub required: bool,
175
176    /// When `true`, every tool from this server is advertised as safe for parallel tool calls.
177    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
178    pub supports_parallel_tool_calls: bool,
179
180    /// Reason this server was disabled after applying requirements.
181    #[serde(skip)]
182    pub disabled_reason: Option<McpServerDisabledReason>,
183
184    /// Startup timeout in seconds for initializing MCP server & initially listing tools.
185    #[serde(
186        default,
187        with = "option_duration_secs",
188        skip_serializing_if = "Option::is_none"
189    )]
190    pub startup_timeout_sec: Option<Duration>,
191
192    /// Default timeout for MCP tool calls initiated via this server.
193    #[serde(default, with = "option_duration_secs")]
194    pub tool_timeout_sec: Option<Duration>,
195
196    /// Approval mode for tools in this server unless a tool override exists.
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub default_tools_approval_mode: Option<AppToolApproval>,
199
200    /// Explicit allow-list of tools exposed from this server. When set, only these tools will be registered.
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub enabled_tools: Option<Vec<String>>,
203
204    /// Explicit deny-list of tools. These tools will be removed after applying `enabled_tools`.
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub disabled_tools: Option<Vec<String>>,
207
208    /// Optional OAuth scopes to request during MCP login.
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub scopes: Option<Vec<String>>,
211
212    /// Optional OAuth client settings for MCP login.
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub oauth: Option<McpServerOAuthConfig>,
215
216    /// Optional OAuth resource parameter to include during MCP login (RFC 8707).
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub oauth_resource: Option<String>,
219
220    /// Per-tool approval settings keyed by tool name.
221    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
222    pub tools: HashMap<String, McpServerToolConfig>,
223}
224
225impl McpServerConfig {
226    pub fn is_local_environment(&self) -> bool {
227        self.environment_id == DEFAULT_MCP_SERVER_ENVIRONMENT_ID
228    }
229
230    pub fn oauth_client_id(&self) -> Option<&str> {
231        self.oauth
232            .as_ref()
233            .and_then(|oauth| oauth.client_id.as_deref())
234    }
235}
236
237/// Raw MCP config shape used for deserialization and supported-field JSON
238/// Schema generation.
239///
240/// Fields that are accepted only to produce targeted validation errors should
241/// be skipped in the generated schema.
242///
243/// Keep `TryFrom<RawMcpServerConfig> for McpServerConfig` exhaustively
244/// destructuring this struct so new TOML fields cannot be added here without
245/// updating the validation/mapping logic that produces [`McpServerConfig`].
246#[derive(Deserialize, Clone, JsonSchema)]
247#[schemars(deny_unknown_fields)]
248pub struct RawMcpServerConfig {
249    // stdio
250    pub command: Option<String>,
251    #[serde(default)]
252    pub args: Option<Vec<String>>,
253    #[serde(default)]
254    pub env: Option<HashMap<String, String>>,
255    #[serde(default)]
256    pub env_vars: Option<Vec<McpServerEnvVar>>,
257    #[serde(default)]
258    pub cwd: Option<LegacyAppPathString>,
259    pub http_headers: Option<HashMap<String, String>>,
260    #[serde(default)]
261    pub env_http_headers: Option<HashMap<String, String>>,
262
263    // streamable_http
264    pub url: Option<String>,
265    #[schemars(skip)]
266    pub bearer_token: Option<String>,
267    pub bearer_token_env_var: Option<String>,
268
269    // shared
270    #[serde(default)]
271    pub environment_id: Option<String>,
272    #[serde(default)]
273    pub auth: Option<McpServerAuth>,
274    #[serde(default)]
275    pub startup_timeout_sec: Option<f64>,
276    #[serde(default)]
277    pub startup_timeout_ms: Option<u64>,
278    #[serde(default, with = "option_duration_secs")]
279    #[schemars(with = "Option<f64>")]
280    pub tool_timeout_sec: Option<Duration>,
281    #[serde(default)]
282    pub enabled: Option<bool>,
283    #[serde(default)]
284    pub required: Option<bool>,
285    #[serde(default)]
286    pub supports_parallel_tool_calls: Option<bool>,
287    #[serde(default)]
288    pub default_tools_approval_mode: Option<AppToolApproval>,
289    #[serde(default)]
290    pub enabled_tools: Option<Vec<String>>,
291    #[serde(default)]
292    pub disabled_tools: Option<Vec<String>>,
293    #[serde(default)]
294    pub scopes: Option<Vec<String>>,
295    #[serde(default)]
296    pub oauth: Option<McpServerOAuthConfig>,
297    #[serde(default)]
298    pub oauth_resource: Option<String>,
299    /// Legacy display-name field accepted for backward compatibility.
300    #[serde(default, rename = "name")]
301    pub _name: Option<String>,
302    #[serde(default)]
303    pub tools: Option<HashMap<String, McpServerToolConfig>>,
304}
305
306impl TryFrom<RawMcpServerConfig> for McpServerConfig {
307    type Error = String;
308
309    fn try_from(raw: RawMcpServerConfig) -> Result<Self, Self::Error> {
310        let RawMcpServerConfig {
311            command,
312            args,
313            env,
314            env_vars,
315            cwd,
316            http_headers,
317            env_http_headers,
318            url,
319            bearer_token,
320            bearer_token_env_var,
321            environment_id,
322            auth,
323            startup_timeout_sec,
324            startup_timeout_ms,
325            tool_timeout_sec,
326            enabled,
327            required,
328            supports_parallel_tool_calls,
329            default_tools_approval_mode,
330            enabled_tools,
331            disabled_tools,
332            scopes,
333            oauth,
334            oauth_resource,
335            _name: _,
336            tools,
337        } = raw;
338
339        let startup_timeout_sec = match (startup_timeout_sec, startup_timeout_ms) {
340            (Some(sec), _) => {
341                Some(Duration::try_from_secs_f64(sec).map_err(|err| err.to_string())?)
342            }
343            (None, Some(ms)) => Some(Duration::from_millis(ms)),
344            (None, None) => None,
345        };
346
347        fn throw_if_set<T>(transport: &str, field: &str, value: Option<&T>) -> Result<(), String> {
348            if value.is_none() {
349                return Ok(());
350            }
351            Err(format!("{field} is not supported for {transport}"))
352        }
353
354        let transport = if let Some(command) = command {
355            throw_if_set("stdio", "url", url.as_ref())?;
356            throw_if_set(
357                "stdio",
358                "bearer_token_env_var",
359                bearer_token_env_var.as_ref(),
360            )?;
361            throw_if_set("stdio", "bearer_token", bearer_token.as_ref())?;
362            throw_if_set("stdio", "http_headers", http_headers.as_ref())?;
363            throw_if_set("stdio", "env_http_headers", env_http_headers.as_ref())?;
364            throw_if_set("stdio", "oauth", oauth.as_ref())?;
365            throw_if_set("stdio", "oauth_resource", oauth_resource.as_ref())?;
366            throw_if_set("stdio", "auth", auth.as_ref())?;
367            let env_vars = env_vars.unwrap_or_default();
368            for env_var in &env_vars {
369                env_var.validate_source()?;
370            }
371            McpServerTransportConfig::Stdio {
372                command,
373                args: args.unwrap_or_default(),
374                env,
375                env_vars,
376                cwd,
377            }
378        } else if let Some(url) = url {
379            throw_if_set("streamable_http", "args", args.as_ref())?;
380            throw_if_set("streamable_http", "env", env.as_ref())?;
381            throw_if_set("streamable_http", "env_vars", env_vars.as_ref())?;
382            throw_if_set("streamable_http", "cwd", cwd.as_ref())?;
383            throw_if_set("streamable_http", "bearer_token", bearer_token.as_ref())?;
384            McpServerTransportConfig::StreamableHttp {
385                url,
386                bearer_token_env_var,
387                http_headers,
388                env_http_headers,
389            }
390        } else {
391            return Err("invalid transport".to_string());
392        };
393
394        let environment_id =
395            environment_id.unwrap_or_else(|| DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string());
396
397        Ok(Self {
398            transport,
399            auth: auth.unwrap_or_default(),
400            environment_id,
401            startup_timeout_sec,
402            tool_timeout_sec,
403            enabled: enabled.unwrap_or_else(default_enabled),
404            required: required.unwrap_or_default(),
405            supports_parallel_tool_calls: supports_parallel_tool_calls.unwrap_or_default(),
406            disabled_reason: None,
407            default_tools_approval_mode,
408            enabled_tools,
409            disabled_tools,
410            scopes,
411            oauth,
412            oauth_resource,
413            tools: tools.unwrap_or_default(),
414        })
415    }
416}
417
418impl<'de> Deserialize<'de> for McpServerConfig {
419    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
420    where
421        D: Deserializer<'de>,
422    {
423        RawMcpServerConfig::deserialize(deserializer)?
424            .try_into()
425            .map_err(SerdeError::custom)
426    }
427}
428
429const fn default_enabled() -> bool {
430    true
431}
432
433#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)]
434#[serde(untagged, deny_unknown_fields, rename_all = "snake_case")]
435pub enum McpServerTransportConfig {
436    /// https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio
437    Stdio {
438        command: String,
439        #[serde(default)]
440        args: Vec<String>,
441        #[serde(default, skip_serializing_if = "Option::is_none")]
442        env: Option<HashMap<String, String>>,
443        #[serde(default, skip_serializing_if = "Vec::is_empty")]
444        env_vars: Vec<McpServerEnvVar>,
445        #[serde(default, skip_serializing_if = "Option::is_none")]
446        cwd: Option<LegacyAppPathString>,
447    },
448    /// https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http
449    StreamableHttp {
450        url: String,
451        /// Name of the environment variable to read for an HTTP bearer token.
452        /// When set, requests will include the token via `Authorization: Bearer <token>`.
453        /// The actual secret value must be provided via the environment.
454        #[serde(default, skip_serializing_if = "Option::is_none")]
455        bearer_token_env_var: Option<String>,
456        /// Additional HTTP headers to include in requests to this server.
457        #[serde(default, skip_serializing_if = "Option::is_none")]
458        http_headers: Option<HashMap<String, String>>,
459        /// HTTP headers where the value is sourced from an environment variable.
460        #[serde(default, skip_serializing_if = "Option::is_none")]
461        env_http_headers: Option<HashMap<String, String>>,
462    },
463}
464
465mod option_duration_secs {
466    use serde::Deserialize;
467    use serde::Deserializer;
468    use serde::Serializer;
469    use std::time::Duration;
470
471    pub fn serialize<S>(value: &Option<Duration>, serializer: S) -> Result<S::Ok, S::Error>
472    where
473        S: Serializer,
474    {
475        match value {
476            Some(duration) => serializer.serialize_some(&duration.as_secs_f64()),
477            None => serializer.serialize_none(),
478        }
479    }
480
481    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>
482    where
483        D: Deserializer<'de>,
484    {
485        let secs = Option::<f64>::deserialize(deserializer)?;
486        secs.map(|secs| Duration::try_from_secs_f64(secs).map_err(serde::de::Error::custom))
487            .transpose()
488    }
489}
490
491#[cfg(test)]
492#[path = "mcp_types_tests.rs"]
493mod tests;