1use 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
16pub 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#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum McpServerDisabledReason {
37 Unknown,
39 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#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
56#[schemars(deny_unknown_fields)]
57pub struct McpServerToolConfig {
58 #[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#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
123#[schemars(deny_unknown_fields)]
124pub struct McpServerOAuthConfig {
125 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub client_id: Option<String>,
128}
129
130#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
136#[serde(rename_all = "snake_case")]
137pub enum McpServerAuth {
138 #[default]
141 #[serde(rename = "oauth")]
142 OAuth,
143 #[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 #[serde(default, skip_serializing_if = "McpServerAuth::is_default")]
163 pub auth: McpServerAuth,
164
165 pub environment_id: String,
167
168 #[serde(default = "default_enabled")]
170 pub enabled: bool,
171
172 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
174 pub required: bool,
175
176 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
178 pub supports_parallel_tool_calls: bool,
179
180 #[serde(skip)]
182 pub disabled_reason: Option<McpServerDisabledReason>,
183
184 #[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 #[serde(default, with = "option_duration_secs")]
194 pub tool_timeout_sec: Option<Duration>,
195
196 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub default_tools_approval_mode: Option<AppToolApproval>,
199
200 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub enabled_tools: Option<Vec<String>>,
203
204 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub disabled_tools: Option<Vec<String>>,
207
208 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub scopes: Option<Vec<String>>,
211
212 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub oauth: Option<McpServerOAuthConfig>,
215
216 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub oauth_resource: Option<String>,
219
220 #[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#[derive(Deserialize, Clone, JsonSchema)]
247#[schemars(deny_unknown_fields)]
248pub struct RawMcpServerConfig {
249 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 pub url: Option<String>,
265 #[schemars(skip)]
266 pub bearer_token: Option<String>,
267 pub bearer_token_env_var: Option<String>,
268
269 #[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 #[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 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 StreamableHttp {
450 url: String,
451 #[serde(default, skip_serializing_if = "Option::is_none")]
455 bearer_token_env_var: Option<String>,
456 #[serde(default, skip_serializing_if = "Option::is_none")]
458 http_headers: Option<HashMap<String, String>>,
459 #[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;