Skip to main content

claude_wrapper/command/
mcp.rs

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