Skip to main content

codex_wrapper/command/
mcp.rs

1use crate::Codex;
2use crate::command::CodexCommand;
3#[cfg(feature = "json")]
4use crate::error::Error;
5use crate::error::Result;
6use crate::exec::{self, CommandOutput};
7
8#[derive(Debug, Clone, Default)]
9pub struct McpListCommand {
10    config_overrides: Vec<String>,
11    enabled_features: Vec<String>,
12    disabled_features: Vec<String>,
13    json: bool,
14}
15
16impl McpListCommand {
17    #[must_use]
18    pub fn new() -> Self {
19        Self::default()
20    }
21
22    #[must_use]
23    pub fn json(mut self) -> Self {
24        self.json = true;
25        self
26    }
27
28    #[cfg(feature = "json")]
29    pub async fn execute_json(&self, codex: &Codex) -> Result<serde_json::Value> {
30        let mut args = self.args();
31        if !self.json {
32            args.push("--json".into());
33        }
34
35        let output = exec::run_codex(codex, args).await?;
36        serde_json::from_str(&output.stdout).map_err(|source| Error::Json {
37            message: "failed to parse MCP list output".into(),
38            source,
39        })
40    }
41}
42
43impl CodexCommand for McpListCommand {
44    type Output = CommandOutput;
45
46    fn args(&self) -> Vec<String> {
47        let mut args = base_args(
48            "list",
49            &self.config_overrides,
50            &self.enabled_features,
51            &self.disabled_features,
52        );
53        if self.json {
54            args.push("--json".into());
55        }
56        args
57    }
58
59    async fn execute(&self, codex: &Codex) -> Result<CommandOutput> {
60        exec::run_codex(codex, self.args()).await
61    }
62}
63
64#[derive(Debug, Clone, Default)]
65pub struct McpGetCommand {
66    name: String,
67    config_overrides: Vec<String>,
68    enabled_features: Vec<String>,
69    disabled_features: Vec<String>,
70    json: bool,
71}
72
73impl McpGetCommand {
74    #[must_use]
75    pub fn new(name: impl Into<String>) -> Self {
76        Self {
77            name: name.into(),
78            ..Default::default()
79        }
80    }
81
82    #[must_use]
83    pub fn json(mut self) -> Self {
84        self.json = true;
85        self
86    }
87
88    #[cfg(feature = "json")]
89    pub async fn execute_json(&self, codex: &Codex) -> Result<serde_json::Value> {
90        let mut args = self.args();
91        if !self.json {
92            args.push("--json".into());
93        }
94        let output = exec::run_codex(codex, args).await?;
95        serde_json::from_str(&output.stdout).map_err(|source| Error::Json {
96            message: "failed to parse MCP server output".into(),
97            source,
98        })
99    }
100}
101
102impl CodexCommand for McpGetCommand {
103    type Output = CommandOutput;
104
105    fn args(&self) -> Vec<String> {
106        let mut args = base_args(
107            "get",
108            &self.config_overrides,
109            &self.enabled_features,
110            &self.disabled_features,
111        );
112        if self.json {
113            args.push("--json".into());
114        }
115        args.push(self.name.clone());
116        args
117    }
118
119    async fn execute(&self, codex: &Codex) -> Result<CommandOutput> {
120        exec::run_codex(codex, self.args()).await
121    }
122}
123
124#[derive(Debug, Clone)]
125enum McpAddTransport {
126    Stdio {
127        command: String,
128        args: Vec<String>,
129        env: Vec<String>,
130    },
131    Http {
132        url: String,
133        bearer_token_env_var: Option<String>,
134        oauth_client_id: Option<String>,
135        oauth_resource: Option<String>,
136    },
137}
138
139#[derive(Debug, Clone)]
140pub struct McpAddCommand {
141    name: String,
142    config_overrides: Vec<String>,
143    enabled_features: Vec<String>,
144    disabled_features: Vec<String>,
145    transport: McpAddTransport,
146}
147
148impl McpAddCommand {
149    #[must_use]
150    pub fn stdio(name: impl Into<String>, command: impl Into<String>) -> Self {
151        Self {
152            name: name.into(),
153            config_overrides: Vec::new(),
154            enabled_features: Vec::new(),
155            disabled_features: Vec::new(),
156            transport: McpAddTransport::Stdio {
157                command: command.into(),
158                args: Vec::new(),
159                env: Vec::new(),
160            },
161        }
162    }
163
164    #[must_use]
165    pub fn http(name: impl Into<String>, url: impl Into<String>) -> Self {
166        Self {
167            name: name.into(),
168            config_overrides: Vec::new(),
169            enabled_features: Vec::new(),
170            disabled_features: Vec::new(),
171            transport: McpAddTransport::Http {
172                url: url.into(),
173                bearer_token_env_var: None,
174                oauth_client_id: None,
175                oauth_resource: None,
176            },
177        }
178    }
179
180    /// Override a config key (`-c key=value`). May be called multiple times.
181    #[must_use]
182    pub fn config(mut self, key_value: impl Into<String>) -> Self {
183        self.config_overrides.push(key_value.into());
184        self
185    }
186
187    /// Enable an optional feature flag (`--enable <feature>`).
188    #[must_use]
189    pub fn enable(mut self, feature: impl Into<String>) -> Self {
190        self.enabled_features.push(feature.into());
191        self
192    }
193
194    /// Disable an optional feature flag (`--disable <feature>`).
195    #[must_use]
196    pub fn disable(mut self, feature: impl Into<String>) -> Self {
197        self.disabled_features.push(feature.into());
198        self
199    }
200
201    #[must_use]
202    pub fn arg(mut self, value: impl Into<String>) -> Self {
203        if let McpAddTransport::Stdio { args, .. } = &mut self.transport {
204            args.push(value.into());
205        }
206        self
207    }
208
209    #[must_use]
210    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
211        if let McpAddTransport::Stdio { env, .. } = &mut self.transport {
212            env.push(format!("{}={}", key.into(), value.into()));
213        }
214        self
215    }
216
217    #[must_use]
218    pub fn bearer_token_env_var(mut self, env_var: impl Into<String>) -> Self {
219        if let McpAddTransport::Http {
220            bearer_token_env_var,
221            ..
222        } = &mut self.transport
223        {
224            *bearer_token_env_var = Some(env_var.into());
225        }
226        self
227    }
228
229    /// OAuth client id for an HTTP MCP server (`--oauth-client-id`).
230    ///
231    /// No-op on stdio transports.
232    #[must_use]
233    pub fn oauth_client_id(mut self, client_id: impl Into<String>) -> Self {
234        if let McpAddTransport::Http {
235            oauth_client_id, ..
236        } = &mut self.transport
237        {
238            *oauth_client_id = Some(client_id.into());
239        }
240        self
241    }
242
243    /// OAuth resource for an HTTP MCP server (`--oauth-resource`).
244    ///
245    /// No-op on stdio transports.
246    #[must_use]
247    pub fn oauth_resource(mut self, resource: impl Into<String>) -> Self {
248        if let McpAddTransport::Http { oauth_resource, .. } = &mut self.transport {
249            *oauth_resource = Some(resource.into());
250        }
251        self
252    }
253}
254
255impl CodexCommand for McpAddCommand {
256    type Output = CommandOutput;
257
258    fn args(&self) -> Vec<String> {
259        let mut args = base_args(
260            "add",
261            &self.config_overrides,
262            &self.enabled_features,
263            &self.disabled_features,
264        );
265        args.push(self.name.clone());
266        match &self.transport {
267            McpAddTransport::Stdio {
268                command,
269                args: command_args,
270                env,
271            } => {
272                for entry in env {
273                    args.push("--env".into());
274                    args.push(entry.clone());
275                }
276                args.push("--".into());
277                args.push(command.clone());
278                args.extend(command_args.clone());
279            }
280            McpAddTransport::Http {
281                url,
282                bearer_token_env_var,
283                oauth_client_id,
284                oauth_resource,
285            } => {
286                args.push("--url".into());
287                args.push(url.clone());
288                if let Some(env_var) = bearer_token_env_var {
289                    args.push("--bearer-token-env-var".into());
290                    args.push(env_var.clone());
291                }
292                if let Some(client_id) = oauth_client_id {
293                    args.push("--oauth-client-id".into());
294                    args.push(client_id.clone());
295                }
296                if let Some(resource) = oauth_resource {
297                    args.push("--oauth-resource".into());
298                    args.push(resource.clone());
299                }
300            }
301        }
302        args
303    }
304
305    async fn execute(&self, codex: &Codex) -> Result<CommandOutput> {
306        exec::run_codex(codex, self.args()).await
307    }
308}
309
310#[derive(Debug, Clone)]
311pub struct McpRemoveCommand {
312    name: String,
313}
314
315impl McpRemoveCommand {
316    #[must_use]
317    pub fn new(name: impl Into<String>) -> Self {
318        Self { name: name.into() }
319    }
320}
321
322impl CodexCommand for McpRemoveCommand {
323    type Output = CommandOutput;
324
325    fn args(&self) -> Vec<String> {
326        vec!["mcp".into(), "remove".into(), self.name.clone()]
327    }
328
329    async fn execute(&self, codex: &Codex) -> Result<CommandOutput> {
330        exec::run_codex(codex, self.args()).await
331    }
332}
333
334#[derive(Debug, Clone)]
335pub struct McpLoginCommand {
336    name: String,
337    scopes: Option<String>,
338}
339
340impl McpLoginCommand {
341    #[must_use]
342    pub fn new(name: impl Into<String>) -> Self {
343        Self {
344            name: name.into(),
345            scopes: None,
346        }
347    }
348
349    #[must_use]
350    pub fn scopes(mut self, scopes: impl Into<String>) -> Self {
351        self.scopes = Some(scopes.into());
352        self
353    }
354}
355
356impl CodexCommand for McpLoginCommand {
357    type Output = CommandOutput;
358
359    fn args(&self) -> Vec<String> {
360        let mut args = vec!["mcp".into(), "login".into()];
361        if let Some(scopes) = &self.scopes {
362            args.push("--scopes".into());
363            args.push(scopes.clone());
364        }
365        args.push(self.name.clone());
366        args
367    }
368
369    async fn execute(&self, codex: &Codex) -> Result<CommandOutput> {
370        exec::run_codex(codex, self.args()).await
371    }
372}
373
374#[derive(Debug, Clone)]
375pub struct McpLogoutCommand {
376    name: String,
377}
378
379impl McpLogoutCommand {
380    #[must_use]
381    pub fn new(name: impl Into<String>) -> Self {
382        Self { name: name.into() }
383    }
384}
385
386impl CodexCommand for McpLogoutCommand {
387    type Output = CommandOutput;
388
389    fn args(&self) -> Vec<String> {
390        vec!["mcp".into(), "logout".into(), self.name.clone()]
391    }
392
393    async fn execute(&self, codex: &Codex) -> Result<CommandOutput> {
394        exec::run_codex(codex, self.args()).await
395    }
396}
397
398fn base_args(
399    subcommand: &str,
400    configs: &[String],
401    enabled: &[String],
402    disabled: &[String],
403) -> Vec<String> {
404    let mut args = vec!["mcp".into(), subcommand.into()];
405    for value in configs {
406        args.push("-c".into());
407        args.push(value.clone());
408    }
409    for value in enabled {
410        args.push("--enable".into());
411        args.push(value.clone());
412    }
413    for value in disabled {
414        args.push("--disable".into());
415        args.push(value.clone());
416    }
417    args
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423
424    #[test]
425    fn mcp_list_args() {
426        assert_eq!(
427            McpListCommand::new().json().args(),
428            vec!["mcp", "list", "--json"]
429        );
430    }
431
432    #[test]
433    fn mcp_stdio_add_args() {
434        let args = McpAddCommand::stdio("server", "uvx")
435            .arg("my-server")
436            .env("API_KEY", "secret")
437            .args();
438        assert_eq!(
439            args,
440            vec![
441                "mcp",
442                "add",
443                "server",
444                "--env",
445                "API_KEY=secret",
446                "--",
447                "uvx",
448                "my-server",
449            ]
450        );
451    }
452
453    #[test]
454    fn mcp_http_add_args() {
455        let args = McpAddCommand::http("server", "https://example.com/mcp")
456            .bearer_token_env_var("TOKEN")
457            .args();
458        assert_eq!(
459            args,
460            vec![
461                "mcp",
462                "add",
463                "server",
464                "--url",
465                "https://example.com/mcp",
466                "--bearer-token-env-var",
467                "TOKEN",
468            ]
469        );
470    }
471
472    #[test]
473    fn mcp_http_add_oauth_and_config_args() {
474        let args = McpAddCommand::http("server", "https://example.com/mcp")
475            .config("foo=bar")
476            .enable("beta")
477            .oauth_client_id("client-123")
478            .oauth_resource("https://api.example.com")
479            .args();
480        assert_eq!(
481            args,
482            vec![
483                "mcp",
484                "add",
485                "-c",
486                "foo=bar",
487                "--enable",
488                "beta",
489                "server",
490                "--url",
491                "https://example.com/mcp",
492                "--oauth-client-id",
493                "client-123",
494                "--oauth-resource",
495                "https://api.example.com",
496            ]
497        );
498    }
499
500    #[test]
501    fn mcp_stdio_add_oauth_is_noop() {
502        // OAuth options only apply to HTTP transports.
503        let args = McpAddCommand::stdio("server", "uvx")
504            .oauth_client_id("ignored")
505            .args();
506        assert_eq!(args, vec!["mcp", "add", "server", "--", "uvx"]);
507    }
508}