Skip to main content

claude_wrapper/command/
mcp.rs

1//! MCP server management subcommand builders.
2//!
3//! Builders for the `claude mcp` surface: list, get, add, add-json,
4//! remove, add-from-desktop, login, logout, serve, and
5//! reset-project-choices. To generate a `.mcp.json` config file
6//! programmatically, see [`crate::mcp_config`].
7
8#[cfg(feature = "async")]
9use crate::Claude;
10use crate::command::ClaudeCommand;
11#[cfg(feature = "async")]
12use crate::error::Result;
13#[cfg(feature = "async")]
14use crate::exec;
15use crate::exec::CommandOutput;
16use crate::types::{Scope, Transport};
17
18/// List configured MCP servers.
19///
20/// # Example
21///
22/// ```no_run
23/// use claude_wrapper::{Claude, ClaudeCommand, McpListCommand};
24///
25/// # async fn example() -> claude_wrapper::Result<()> {
26/// let claude = Claude::builder().build()?;
27/// let output = McpListCommand::new().execute(&claude).await?;
28/// println!("{}", output.stdout);
29/// # Ok(())
30/// # }
31/// ```
32#[derive(Debug, Clone, Default)]
33pub struct McpListCommand;
34
35impl McpListCommand {
36    /// Creates a new MCP list command.
37    #[must_use]
38    pub fn new() -> Self {
39        Self
40    }
41}
42
43impl ClaudeCommand for McpListCommand {
44    type Output = CommandOutput;
45
46    fn args(&self) -> Vec<String> {
47        vec!["mcp".to_string(), "list".to_string()]
48    }
49
50    #[cfg(feature = "async")]
51    async fn execute(&self, claude: &Claude) -> Result<CommandOutput> {
52        exec::run_claude(claude, self.args()).await
53    }
54}
55
56/// Get details about a specific MCP server.
57#[derive(Debug, Clone)]
58pub struct McpGetCommand {
59    name: String,
60}
61
62impl McpGetCommand {
63    /// Creates a command to get details for a named MCP server.
64    #[must_use]
65    pub fn new(name: impl Into<String>) -> Self {
66        Self { name: name.into() }
67    }
68}
69
70impl ClaudeCommand for McpGetCommand {
71    type Output = CommandOutput;
72
73    fn args(&self) -> Vec<String> {
74        vec!["mcp".to_string(), "get".to_string(), self.name.clone()]
75    }
76
77    #[cfg(feature = "async")]
78    async fn execute(&self, claude: &Claude) -> Result<CommandOutput> {
79        exec::run_claude(claude, self.args()).await
80    }
81}
82
83/// Add an MCP server.
84///
85/// # Example
86///
87/// ```no_run
88/// use claude_wrapper::{Claude, ClaudeCommand, McpAddCommand, Scope, Transport};
89///
90/// # async fn example() -> claude_wrapper::Result<()> {
91/// let claude = Claude::builder().build()?;
92///
93/// // Add an HTTP MCP server
94/// McpAddCommand::new("sentry", "https://mcp.sentry.dev/mcp")
95///     .transport(Transport::Http)
96///     .scope(Scope::User)
97///     .execute(&claude)
98///     .await?;
99///
100/// // Add a stdio MCP server with env vars
101/// McpAddCommand::new("my-server", "npx")
102///     .server_args(["my-mcp-server"])
103///     .env("API_KEY", "xxx")
104///     .scope(Scope::Local)
105///     .execute(&claude)
106///     .await?;
107/// # Ok(())
108/// # }
109/// ```
110#[derive(Debug, Clone)]
111pub struct McpAddCommand {
112    name: String,
113    command_or_url: String,
114    server_args: Vec<String>,
115    transport: Option<Transport>,
116    scope: Option<Scope>,
117    env: Vec<(String, String)>,
118    headers: Vec<String>,
119    callback_port: Option<u16>,
120    client_id: Option<String>,
121    client_secret: bool,
122}
123
124impl McpAddCommand {
125    /// Create a new MCP add command.
126    #[must_use]
127    pub fn new(name: impl Into<String>, command_or_url: impl Into<String>) -> Self {
128        Self {
129            name: name.into(),
130            command_or_url: command_or_url.into(),
131            server_args: Vec::new(),
132            transport: None,
133            scope: None,
134            env: Vec::new(),
135            headers: Vec::new(),
136            callback_port: None,
137            client_id: None,
138            client_secret: false,
139        }
140    }
141
142    /// Set the transport type.
143    #[must_use]
144    pub fn transport(mut self, transport: Transport) -> Self {
145        self.transport = Some(transport);
146        self
147    }
148
149    /// Set the configuration scope.
150    #[must_use]
151    pub fn scope(mut self, scope: Scope) -> Self {
152        self.scope = Some(scope);
153        self
154    }
155
156    /// Add an environment variable.
157    #[must_use]
158    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
159        self.env.push((key.into(), value.into()));
160        self
161    }
162
163    /// Add a header (for HTTP/SSE transport).
164    #[must_use]
165    pub fn header(mut self, header: impl Into<String>) -> Self {
166        self.headers.push(header.into());
167        self
168    }
169
170    /// Set additional arguments for the server command.
171    #[must_use]
172    pub fn server_args(mut self, args: impl IntoIterator<Item = impl Into<String>>) -> Self {
173        self.server_args.extend(args.into_iter().map(Into::into));
174        self
175    }
176
177    /// Set a fixed port for OAuth callback.
178    #[must_use]
179    pub fn callback_port(mut self, port: u16) -> Self {
180        self.callback_port = Some(port);
181        self
182    }
183
184    /// Set the OAuth client ID for HTTP/SSE servers.
185    #[must_use]
186    pub fn client_id(mut self, id: impl Into<String>) -> Self {
187        self.client_id = Some(id.into());
188        self
189    }
190
191    /// Enable prompting for OAuth client secret.
192    #[must_use]
193    pub fn client_secret(mut self) -> Self {
194        self.client_secret = true;
195        self
196    }
197}
198
199impl ClaudeCommand for McpAddCommand {
200    type Output = CommandOutput;
201
202    fn args(&self) -> Vec<String> {
203        let mut args = vec!["mcp".to_string(), "add".to_string()];
204
205        if let Some(transport) = self.transport {
206            args.push("--transport".to_string());
207            args.push(transport.to_string());
208        }
209
210        if let Some(ref scope) = self.scope {
211            args.push("--scope".to_string());
212            args.push(scope.as_arg().to_string());
213        }
214
215        for (key, value) in &self.env {
216            args.push("-e".to_string());
217            args.push(format!("{key}={value}"));
218        }
219
220        for header in &self.headers {
221            args.push("-H".to_string());
222            args.push(header.clone());
223        }
224
225        if let Some(port) = self.callback_port {
226            args.push("--callback-port".to_string());
227            args.push(port.to_string());
228        }
229
230        if let Some(ref id) = self.client_id {
231            args.push("--client-id".to_string());
232            args.push(id.clone());
233        }
234
235        if self.client_secret {
236            args.push("--client-secret".to_string());
237        }
238
239        args.push(self.name.clone());
240        args.push(self.command_or_url.clone());
241
242        if !self.server_args.is_empty() {
243            args.push("--".to_string());
244            args.extend(self.server_args.clone());
245        }
246
247        args
248    }
249
250    #[cfg(feature = "async")]
251    async fn execute(&self, claude: &Claude) -> Result<CommandOutput> {
252        exec::run_claude(claude, self.args()).await
253    }
254}
255
256/// Add an MCP server using raw JSON configuration.
257#[derive(Debug, Clone)]
258pub struct McpAddJsonCommand {
259    name: String,
260    json: String,
261    scope: Option<Scope>,
262    client_secret: bool,
263}
264
265impl McpAddJsonCommand {
266    /// Create a new MCP add-json command.
267    #[must_use]
268    pub fn new(name: impl Into<String>, json: impl Into<String>) -> Self {
269        Self {
270            name: name.into(),
271            json: json.into(),
272            scope: None,
273            client_secret: false,
274        }
275    }
276
277    /// Set the configuration scope.
278    #[must_use]
279    pub fn scope(mut self, scope: Scope) -> Self {
280        self.scope = Some(scope);
281        self
282    }
283
284    /// Prompt for the OAuth client secret (`--client-secret`; or set
285    /// it via the `MCP_CLIENT_SECRET` env var).
286    ///
287    /// Parity with [`McpAddCommand::client_secret`].
288    #[must_use]
289    pub fn client_secret(mut self) -> Self {
290        self.client_secret = true;
291        self
292    }
293}
294
295impl ClaudeCommand for McpAddJsonCommand {
296    type Output = CommandOutput;
297
298    fn args(&self) -> Vec<String> {
299        let mut args = vec!["mcp".to_string(), "add-json".to_string()];
300
301        if let Some(ref scope) = self.scope {
302            args.push("--scope".to_string());
303            args.push(scope.as_arg().to_string());
304        }
305
306        if self.client_secret {
307            args.push("--client-secret".to_string());
308        }
309
310        args.push(self.name.clone());
311        args.push(self.json.clone());
312
313        args
314    }
315
316    #[cfg(feature = "async")]
317    async fn execute(&self, claude: &Claude) -> Result<CommandOutput> {
318        exec::run_claude(claude, self.args()).await
319    }
320}
321
322/// Remove an MCP server.
323#[derive(Debug, Clone)]
324pub struct McpRemoveCommand {
325    name: String,
326    scope: Option<Scope>,
327}
328
329impl McpRemoveCommand {
330    /// Creates a command to remove a named MCP server.
331    #[must_use]
332    pub fn new(name: impl Into<String>) -> Self {
333        Self {
334            name: name.into(),
335            scope: None,
336        }
337    }
338
339    /// Set the configuration scope.
340    #[must_use]
341    pub fn scope(mut self, scope: Scope) -> Self {
342        self.scope = Some(scope);
343        self
344    }
345}
346
347impl ClaudeCommand for McpRemoveCommand {
348    type Output = CommandOutput;
349
350    fn args(&self) -> Vec<String> {
351        let mut args = vec!["mcp".to_string(), "remove".to_string()];
352
353        if let Some(ref scope) = self.scope {
354            args.push("--scope".to_string());
355            args.push(scope.as_arg().to_string());
356        }
357
358        args.push(self.name.clone());
359
360        args
361    }
362
363    #[cfg(feature = "async")]
364    async fn execute(&self, claude: &Claude) -> Result<CommandOutput> {
365        exec::run_claude(claude, self.args()).await
366    }
367}
368
369/// Import MCP servers from Claude Desktop (Mac and WSL only).
370#[derive(Debug, Clone, Default)]
371pub struct McpAddFromDesktopCommand {
372    scope: Option<Scope>,
373}
374
375impl McpAddFromDesktopCommand {
376    /// Creates a command to import MCP servers from the Claude Desktop config.
377    #[must_use]
378    pub fn new() -> Self {
379        Self::default()
380    }
381
382    /// Set the configuration scope.
383    #[must_use]
384    pub fn scope(mut self, scope: Scope) -> Self {
385        self.scope = Some(scope);
386        self
387    }
388}
389
390impl ClaudeCommand for McpAddFromDesktopCommand {
391    type Output = CommandOutput;
392
393    fn args(&self) -> Vec<String> {
394        let mut args = vec!["mcp".to_string(), "add-from-claude-desktop".to_string()];
395        if let Some(ref scope) = self.scope {
396            args.push("--scope".to_string());
397            args.push(scope.as_arg().to_string());
398        }
399        args
400    }
401
402    #[cfg(feature = "async")]
403    async fn execute(&self, claude: &Claude) -> Result<CommandOutput> {
404        exec::run_claude(claude, self.args()).await
405    }
406}
407
408/// Start the Claude Code MCP server.
409///
410/// # Example
411///
412/// ```no_run
413/// use claude_wrapper::{Claude, ClaudeCommand, McpServeCommand};
414///
415/// # async fn example() -> claude_wrapper::Result<()> {
416/// let claude = Claude::builder().build()?;
417/// McpServeCommand::new()
418///     .verbose()
419///     .execute(&claude)
420///     .await?;
421/// # Ok(())
422/// # }
423/// ```
424#[derive(Debug, Clone, Default)]
425pub struct McpServeCommand {
426    debug: bool,
427    verbose: bool,
428}
429
430impl McpServeCommand {
431    /// Create a new MCP serve command.
432    #[must_use]
433    pub fn new() -> Self {
434        Self::default()
435    }
436
437    /// Enable debug output.
438    #[must_use]
439    pub fn debug(mut self) -> Self {
440        self.debug = true;
441        self
442    }
443
444    /// Enable verbose output.
445    #[must_use]
446    pub fn verbose(mut self) -> Self {
447        self.verbose = true;
448        self
449    }
450}
451
452impl ClaudeCommand for McpServeCommand {
453    type Output = CommandOutput;
454
455    fn args(&self) -> Vec<String> {
456        let mut args = vec!["mcp".to_string(), "serve".to_string()];
457        if self.debug {
458            args.push("--debug".to_string());
459        }
460        if self.verbose {
461            args.push("--verbose".to_string());
462        }
463        args
464    }
465
466    #[cfg(feature = "async")]
467    async fn execute(&self, claude: &Claude) -> Result<CommandOutput> {
468        exec::run_claude(claude, self.args()).await
469    }
470}
471
472/// Reset all approved and rejected project-scoped MCP servers.
473#[derive(Debug, Clone, Default)]
474pub struct McpResetProjectChoicesCommand;
475
476impl McpResetProjectChoicesCommand {
477    /// Create a new [`McpResetProjectChoicesCommand`].
478    #[must_use]
479    pub fn new() -> Self {
480        Self
481    }
482}
483
484impl ClaudeCommand for McpResetProjectChoicesCommand {
485    type Output = CommandOutput;
486
487    fn args(&self) -> Vec<String> {
488        vec!["mcp".to_string(), "reset-project-choices".to_string()]
489    }
490
491    #[cfg(feature = "async")]
492    async fn execute(&self, claude: &Claude) -> Result<CommandOutput> {
493        exec::run_claude(claude, self.args()).await
494    }
495}
496
497/// Authenticate with an MCP server (HTTP, SSE, or claude.ai connector).
498///
499/// Drives an interactive OAuth flow. By default the CLI opens a browser;
500/// use [`Self::no_browser`] for SSH/headless sessions, where the CLI
501/// prints the authorization URL and waits for the redirect URL to be
502/// pasted back.
503#[derive(Debug, Clone)]
504pub struct McpLoginCommand {
505    name: String,
506    no_browser: bool,
507}
508
509impl McpLoginCommand {
510    /// Creates a command to authenticate with a named MCP server.
511    #[must_use]
512    pub fn new(name: impl Into<String>) -> Self {
513        Self {
514            name: name.into(),
515            no_browser: false,
516        }
517    }
518
519    /// Print the authorization URL instead of opening a browser
520    /// (`--no-browser`), for SSH/headless sessions.
521    #[must_use]
522    pub fn no_browser(mut self) -> Self {
523        self.no_browser = true;
524        self
525    }
526}
527
528impl ClaudeCommand for McpLoginCommand {
529    type Output = CommandOutput;
530
531    fn args(&self) -> Vec<String> {
532        let mut args = vec!["mcp".to_string(), "login".to_string()];
533        if self.no_browser {
534            args.push("--no-browser".to_string());
535        }
536        args.push(self.name.clone());
537        args
538    }
539
540    #[cfg(feature = "async")]
541    async fn execute(&self, claude: &Claude) -> Result<CommandOutput> {
542        exec::run_claude(claude, self.args()).await
543    }
544}
545
546/// Clear stored OAuth credentials for an MCP server.
547#[derive(Debug, Clone)]
548pub struct McpLogoutCommand {
549    name: String,
550}
551
552impl McpLogoutCommand {
553    /// Creates a command to clear stored credentials for a named MCP server.
554    #[must_use]
555    pub fn new(name: impl Into<String>) -> Self {
556        Self { name: name.into() }
557    }
558}
559
560impl ClaudeCommand for McpLogoutCommand {
561    type Output = CommandOutput;
562
563    fn args(&self) -> Vec<String> {
564        vec!["mcp".to_string(), "logout".to_string(), self.name.clone()]
565    }
566
567    #[cfg(feature = "async")]
568    async fn execute(&self, claude: &Claude) -> Result<CommandOutput> {
569        exec::run_claude(claude, self.args()).await
570    }
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576
577    #[test]
578    fn test_mcp_list_args() {
579        let cmd = McpListCommand::new();
580        assert_eq!(cmd.args(), vec!["mcp", "list"]);
581    }
582
583    #[test]
584    fn test_mcp_get_args() {
585        let cmd = McpGetCommand::new("my-server");
586        assert_eq!(cmd.args(), vec!["mcp", "get", "my-server"]);
587    }
588
589    #[test]
590    fn test_mcp_add_http() {
591        let cmd = McpAddCommand::new("sentry", "https://mcp.sentry.dev/mcp")
592            .transport(Transport::Http)
593            .scope(Scope::User);
594
595        let args = cmd.args();
596        assert_eq!(
597            args,
598            vec![
599                "mcp",
600                "add",
601                "--transport",
602                "http",
603                "--scope",
604                "user",
605                "sentry",
606                "https://mcp.sentry.dev/mcp"
607            ]
608        );
609    }
610
611    #[test]
612    fn test_mcp_add_stdio_with_env() {
613        let cmd = McpAddCommand::new("my-server", "npx")
614            .env("API_KEY", "xxx")
615            .server_args(["my-mcp-server"]);
616
617        let args = cmd.args();
618        assert_eq!(
619            args,
620            vec![
621                "mcp",
622                "add",
623                "-e",
624                "API_KEY=xxx",
625                "my-server",
626                "npx",
627                "--",
628                "my-mcp-server"
629            ]
630        );
631    }
632
633    #[test]
634    fn test_mcp_add_oauth_flags() {
635        let cmd = McpAddCommand::new("my-server", "https://example.com/mcp")
636            .transport(Transport::Http)
637            .callback_port(8080)
638            .client_id("my-app-id")
639            .client_secret();
640
641        let args = cmd.args();
642        assert_eq!(
643            args,
644            vec![
645                "mcp",
646                "add",
647                "--transport",
648                "http",
649                "--callback-port",
650                "8080",
651                "--client-id",
652                "my-app-id",
653                "--client-secret",
654                "my-server",
655                "https://example.com/mcp"
656            ]
657        );
658    }
659
660    #[test]
661    fn test_mcp_add_json_basic() {
662        let cmd = McpAddJsonCommand::new("srv", r#"{"command":"npx"}"#).scope(Scope::User);
663        assert_eq!(
664            cmd.args(),
665            vec![
666                "mcp",
667                "add-json",
668                "--scope",
669                "user",
670                "srv",
671                r#"{"command":"npx"}"#
672            ]
673        );
674    }
675
676    #[test]
677    fn test_mcp_add_json_client_secret() {
678        // #601 parity: add-json accepts --client-secret like add does.
679        let cmd = McpAddJsonCommand::new("srv", "{}").client_secret();
680        assert_eq!(
681            cmd.args(),
682            vec!["mcp", "add-json", "--client-secret", "srv", "{}"]
683        );
684    }
685
686    #[test]
687    fn test_mcp_add_json_no_client_secret_by_default() {
688        let cmd = McpAddJsonCommand::new("srv", "{}");
689        assert!(!cmd.args().contains(&"--client-secret".to_string()));
690    }
691
692    #[test]
693    fn test_mcp_remove_args() {
694        let cmd = McpRemoveCommand::new("old-server").scope(Scope::Project);
695        assert_eq!(
696            cmd.args(),
697            vec!["mcp", "remove", "--scope", "project", "old-server"]
698        );
699    }
700
701    #[test]
702    fn test_mcp_add_from_desktop() {
703        let cmd = McpAddFromDesktopCommand::new().scope(Scope::User);
704        assert_eq!(
705            cmd.args(),
706            vec!["mcp", "add-from-claude-desktop", "--scope", "user"]
707        );
708    }
709
710    #[test]
711    fn test_mcp_reset_project_choices() {
712        let cmd = McpResetProjectChoicesCommand::new();
713        assert_eq!(cmd.args(), vec!["mcp", "reset-project-choices"]);
714    }
715
716    #[test]
717    fn test_mcp_serve_default() {
718        let cmd = McpServeCommand::new();
719        assert_eq!(cmd.args(), vec!["mcp", "serve"]);
720    }
721
722    #[test]
723    fn test_mcp_serve_with_flags() {
724        let cmd = McpServeCommand::new().debug().verbose();
725        assert_eq!(cmd.args(), vec!["mcp", "serve", "--debug", "--verbose"]);
726    }
727
728    #[test]
729    fn test_mcp_login_args() {
730        let cmd = McpLoginCommand::new("sentry");
731        assert_eq!(cmd.args(), vec!["mcp", "login", "sentry"]);
732    }
733
734    #[test]
735    fn test_mcp_login_no_browser() {
736        let cmd = McpLoginCommand::new("sentry").no_browser();
737        assert_eq!(cmd.args(), vec!["mcp", "login", "--no-browser", "sentry"]);
738    }
739
740    #[test]
741    fn test_mcp_logout_args() {
742        let cmd = McpLogoutCommand::new("sentry");
743        assert_eq!(cmd.args(), vec!["mcp", "logout", "sentry"]);
744    }
745}