Skip to main content

tsafe_mcp/
lib.rs

1//! tsafe-mcp — library entry point exposed for the tsafe meta-crate.
2//!
3//! Per ADR-006, this crate ships a separately published companion binary that
4//! mirrors the publish topology of `tsafe-agent`. The library entry point
5//! [`run`] is called both from the standalone `tsafe-mcp` binary
6//! (`src/main.rs`) and from the meta-crate shim
7//! (`crates/tsafe/src/bin/tsafe_mcp.rs`).
8//!
9//! The runtime exposes four subcommands per design §5.2:
10//! - `tsafe-mcp serve [...]`           — stdio JSON-RPC server (default subcommand)
11//! - `tsafe-mcp install <host> [...]`  — write per-host MCP config
12//! - `tsafe-mcp uninstall <host> [...]`— remove the entry
13//! - `tsafe-mcp status`                — print binary version + resolved scope
14//!
15//! All other doctrine: thin-MCP stance, ADR-003 agent IPC reuse, ADR-005 module
16//! layout. See `docs/architecture/ADR-006-mcp-server.md` and
17//! `docs/architecture/mcp-server-design.md`.
18
19pub mod audit;
20pub mod backend;
21pub mod errors;
22pub mod install;
23pub mod server;
24pub mod session;
25pub mod tools;
26
27use crate::errors::{McpError, McpErrorKind};
28use crate::install::{InstallOpts, Scope};
29use crate::server::MCP_PROTOCOL_VERSION;
30use crate::session::{Session, SessionArgs};
31
32/// Top-level subcommand parsed from `std::env::args()`.
33#[derive(Debug)]
34enum Subcommand {
35    /// JSON-RPC server over stdio. Default when no subcommand is given.
36    Serve(Vec<String>),
37    /// Install / uninstall / status — diagnostic shells, no JSON-RPC.
38    Install {
39        host: String,
40        rest: Vec<String>,
41    },
42    Uninstall {
43        host: String,
44        rest: Vec<String>,
45    },
46    Status,
47}
48
49/// Process entry point. Parses argv, dispatches to the matching subcommand,
50/// and exits the process with code 1 on error (same shape as
51/// `tsafe-agent::run` at `crates/tsafe-agent/src/lib.rs`).
52pub fn run() {
53    if let Err(e) = run_inner() {
54        eprintln!("tsafe-mcp: {e:#}");
55        std::process::exit(1);
56    }
57}
58
59fn run_inner() -> Result<(), McpError> {
60    let argv: Vec<String> = std::env::args().collect();
61    let sub = parse_subcommand(&argv)?;
62
63    match sub {
64        Subcommand::Serve(serve_args) => {
65            let session_args = SessionArgs::parse(&serve_args)?;
66            let session = Session::from_cli_args(&session_args)?;
67            tokio::runtime::Builder::new_multi_thread()
68                .enable_all()
69                .build()
70                .map_err(|e| {
71                    McpError::new(
72                        McpErrorKind::InternalError,
73                        format!("tokio runtime init failed: {e}"),
74                    )
75                })?
76                .block_on(async {
77                    server::serve_stdio(session).await.map_err(|e| {
78                        McpError::new(McpErrorKind::InternalError, format!("serve failed: {e}"))
79                    })
80                })
81        }
82        Subcommand::Install { host, rest } => {
83            let opts = parse_install_opts(&rest, false)?;
84            install::dispatch(&host, &opts)
85        }
86        Subcommand::Uninstall { host, rest } => {
87            let opts = parse_install_opts(&rest, true)?;
88            install::dispatch(&host, &opts)
89        }
90        Subcommand::Status => {
91            status_diagnostic();
92            Ok(())
93        }
94    }
95}
96
97fn parse_subcommand(argv: &[String]) -> Result<Subcommand, McpError> {
98    // argv[0] is the binary path. Subcommand is argv[1] when present.
99    if argv.len() < 2 {
100        // No subcommand → default to `serve` with no additional args. The
101        // serve path will still fail-closed if --profile / scope are missing.
102        return Ok(Subcommand::Serve(Vec::new()));
103    }
104
105    match argv[1].as_str() {
106        "serve" => Ok(Subcommand::Serve(argv[2..].to_vec())),
107        "install" => {
108            if argv.len() < 3 {
109                return Err(McpError::new(
110                    McpErrorKind::InvalidRequest,
111                    "install: missing host argument (claude | cursor | continue | windsurf | codex)",
112                ));
113            }
114            Ok(Subcommand::Install {
115                host: argv[2].clone(),
116                rest: argv[3..].to_vec(),
117            })
118        }
119        "uninstall" => {
120            if argv.len() < 3 {
121                return Err(McpError::new(
122                    McpErrorKind::InvalidRequest,
123                    "uninstall: missing host argument",
124                ));
125            }
126            Ok(Subcommand::Uninstall {
127                host: argv[2].clone(),
128                rest: argv[3..].to_vec(),
129            })
130        }
131        "status" => Ok(Subcommand::Status),
132        // Anything else: treat as forward-compatible `serve` invocation with
133        // flags only (matches the bare `tsafe-mcp --profile foo` shape used by
134        // most host configs).
135        other if other.starts_with("--") => Ok(Subcommand::Serve(argv[1..].to_vec())),
136        unknown => Err(McpError::new(
137            McpErrorKind::InvalidRequest,
138            format!(
139                "unknown subcommand '{unknown}'. Use: serve | install <host> | uninstall <host> | status"
140            ),
141        )),
142    }
143}
144
145fn parse_install_opts(rest: &[String], uninstall: bool) -> Result<InstallOpts, McpError> {
146    let mut profile: Option<String> = None;
147    let mut allowed_keys: Vec<String> = Vec::new();
148    let mut denied_keys: Vec<String> = Vec::new();
149    let mut contract: Option<String> = None;
150    let mut allow_reveal = false;
151    let mut name: Option<String> = None;
152    let mut global = false;
153    let mut project_dir: Option<std::path::PathBuf> = None;
154    let mut dry_run = false;
155    let mut audit_source: Option<String> = None;
156
157    let mut i = 0;
158    while i < rest.len() {
159        let arg = &rest[i];
160        match arg.as_str() {
161            "--profile" => {
162                i += 1;
163                profile = Some(rest.get(i).cloned().ok_or_else(|| {
164                    McpError::new(McpErrorKind::InvalidRequest, "--profile requires a value")
165                })?);
166            }
167            "--allowed-keys" => {
168                i += 1;
169                let raw = rest.get(i).cloned().ok_or_else(|| {
170                    McpError::new(
171                        McpErrorKind::InvalidRequest,
172                        "--allowed-keys requires a value",
173                    )
174                })?;
175                allowed_keys = split_csv(&raw);
176            }
177            "--denied-keys" => {
178                i += 1;
179                let raw = rest.get(i).cloned().ok_or_else(|| {
180                    McpError::new(
181                        McpErrorKind::InvalidRequest,
182                        "--denied-keys requires a value",
183                    )
184                })?;
185                denied_keys = split_csv(&raw);
186            }
187            "--contract" => {
188                i += 1;
189                contract = Some(rest.get(i).cloned().ok_or_else(|| {
190                    McpError::new(McpErrorKind::InvalidRequest, "--contract requires a value")
191                })?);
192            }
193            "--allow-reveal" => allow_reveal = true,
194            "--name" => {
195                i += 1;
196                name = Some(rest.get(i).cloned().ok_or_else(|| {
197                    McpError::new(McpErrorKind::InvalidRequest, "--name requires a value")
198                })?);
199            }
200            "--global" => global = true,
201            "--project" => {
202                i += 1;
203                let dir = rest.get(i).cloned().ok_or_else(|| {
204                    McpError::new(
205                        McpErrorKind::InvalidRequest,
206                        "--project requires a directory path",
207                    )
208                })?;
209                project_dir = Some(std::path::PathBuf::from(dir));
210            }
211            "--dry-run" => dry_run = true,
212            "--audit-source" => {
213                i += 1;
214                audit_source = Some(rest.get(i).cloned().ok_or_else(|| {
215                    McpError::new(
216                        McpErrorKind::InvalidRequest,
217                        "--audit-source requires a value",
218                    )
219                })?);
220            }
221            other => {
222                return Err(McpError::new(
223                    McpErrorKind::InvalidRequest,
224                    format!("unknown install flag: '{other}'"),
225                ));
226            }
227        }
228        i += 1;
229    }
230
231    let scope = match project_dir {
232        Some(dir) => Scope::Project { dir },
233        None if global => Scope::Global,
234        None => Scope::Global, // default
235    };
236
237    let profile = profile.ok_or_else(|| {
238        McpError::new(
239            McpErrorKind::InvalidRequest,
240            "install/uninstall: --profile <name> is required",
241        )
242    })?;
243
244    Ok(InstallOpts {
245        profile,
246        allowed_keys,
247        denied_keys,
248        contract,
249        allow_reveal,
250        name,
251        scope,
252        dry_run,
253        uninstall,
254        audit_source,
255    })
256}
257
258fn split_csv(raw: &str) -> Vec<String> {
259    raw.split(',')
260        .map(|s| s.trim().to_string())
261        .filter(|s| !s.is_empty())
262        .collect()
263}
264
265fn status_diagnostic() {
266    println!("tsafe-mcp {}", env!("CARGO_PKG_VERSION"));
267    println!("protocol: MCP {MCP_PROTOCOL_VERSION}");
268    println!();
269    println!("Resolve scope at runtime by invoking `tsafe-mcp serve` with one of:");
270    println!("  --profile <name>");
271    println!("  --allowed-keys <glob,glob>");
272    println!("  --contract <name>");
273    println!("  --denied-keys <glob,glob>");
274    println!("  --allow-reveal");
275    println!("  --audit-source <host-label>");
276    println!();
277    println!("See ADR-006 / docs/architecture/mcp-server-design.md for the full surface.");
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    fn argv(items: &[&str]) -> Vec<String> {
285        items.iter().map(|s| s.to_string()).collect()
286    }
287
288    /// Default subcommand is `Serve(empty)` when no subcommand is supplied.
289    /// The downstream session check still fails-closed without scope.
290    #[test]
291    fn parse_subcommand_defaults_to_serve_with_no_args() {
292        let parsed = parse_subcommand(&argv(&["tsafe-mcp"])).unwrap();
293        match parsed {
294            Subcommand::Serve(rest) => assert!(rest.is_empty()),
295            other => panic!("expected Serve, got {other:?}"),
296        }
297    }
298
299    /// `tsafe-mcp serve --profile foo` parses to Serve with the rest intact.
300    #[test]
301    fn parse_subcommand_serve_passes_through_remaining_args() {
302        let parsed = parse_subcommand(&argv(&["tsafe-mcp", "serve", "--profile", "demo"])).unwrap();
303        match parsed {
304            Subcommand::Serve(rest) => {
305                assert_eq!(rest, vec!["--profile", "demo"]);
306            }
307            other => panic!("expected Serve, got {other:?}"),
308        }
309    }
310
311    /// Bare flag invocation `tsafe-mcp --profile foo` (no `serve` keyword) is
312    /// the shape most host configs use; it must still route to Serve.
313    #[test]
314    fn parse_subcommand_bare_flag_routes_to_serve() {
315        let parsed = parse_subcommand(&argv(&["tsafe-mcp", "--profile", "demo"])).unwrap();
316        match parsed {
317            Subcommand::Serve(rest) => {
318                assert_eq!(rest, vec!["--profile", "demo"]);
319            }
320            other => panic!("expected Serve, got {other:?}"),
321        }
322    }
323
324    /// `install` without a host argument is InvalidRequest.
325    #[test]
326    fn parse_subcommand_install_without_host_returns_invalid_request() {
327        let err = parse_subcommand(&argv(&["tsafe-mcp", "install"])).unwrap_err();
328        assert_eq!(err.kind, McpErrorKind::InvalidRequest);
329        assert!(err.message.contains("install"));
330    }
331
332    /// `uninstall` mirrors `install`: host required.
333    #[test]
334    fn parse_subcommand_uninstall_without_host_returns_invalid_request() {
335        let err = parse_subcommand(&argv(&["tsafe-mcp", "uninstall"])).unwrap_err();
336        assert_eq!(err.kind, McpErrorKind::InvalidRequest);
337    }
338
339    /// `status` parses with no further arguments.
340    #[test]
341    fn parse_subcommand_status_parses_cleanly() {
342        let parsed = parse_subcommand(&argv(&["tsafe-mcp", "status"])).unwrap();
343        assert!(matches!(parsed, Subcommand::Status));
344    }
345
346    /// Unknown subcommand is InvalidRequest with a hint pointing at the
347    /// supported set.
348    #[test]
349    fn parse_subcommand_unknown_returns_invalid_request_with_hint() {
350        let err = parse_subcommand(&argv(&["tsafe-mcp", "diagnose"])).unwrap_err();
351        assert_eq!(err.kind, McpErrorKind::InvalidRequest);
352        assert!(
353            err.message.contains("serve")
354                && err.message.contains("install")
355                && err.message.contains("status"),
356            "hint should list supported subcommands: {}",
357            err.message
358        );
359    }
360
361    /// `parse_install_opts` round-trip: scope, contract, allow-reveal, name,
362    /// audit-source all land on the right fields.
363    #[test]
364    fn parse_install_opts_round_trips_all_flags() {
365        let rest = argv(&[
366            "--profile",
367            "demo",
368            "--allowed-keys",
369            "demo/*,shared/*",
370            "--denied-keys",
371            "demo/secret",
372            "--contract",
373            "deploy",
374            "--allow-reveal",
375            "--name",
376            "testsrv",
377            "--audit-source",
378            "mcp:claude:proof",
379        ]);
380        let opts = parse_install_opts(&rest, false).unwrap();
381        assert_eq!(opts.profile, "demo");
382        assert_eq!(opts.allowed_keys, vec!["demo/*", "shared/*"]);
383        assert_eq!(opts.denied_keys, vec!["demo/secret"]);
384        assert_eq!(opts.contract.as_deref(), Some("deploy"));
385        assert!(opts.allow_reveal);
386        assert_eq!(opts.name.as_deref(), Some("testsrv"));
387        assert_eq!(opts.audit_source.as_deref(), Some("mcp:claude:proof"));
388        assert!(!opts.uninstall);
389        assert!(!opts.dry_run);
390    }
391
392    /// `--project <dir>` switches scope to Project.
393    #[test]
394    fn parse_install_opts_project_scope() {
395        let rest = argv(&[
396            "--profile",
397            "demo",
398            "--allowed-keys",
399            "demo/*",
400            "--project",
401            "/tmp/myproject",
402            "--dry-run",
403        ]);
404        let opts = parse_install_opts(&rest, false).unwrap();
405        match opts.scope {
406            crate::install::Scope::Project { dir } => {
407                assert_eq!(dir, std::path::PathBuf::from("/tmp/myproject"));
408            }
409            crate::install::Scope::Global => panic!("expected Project scope, got Global"),
410        }
411        assert!(opts.dry_run);
412    }
413
414    /// Missing `--profile` value is InvalidRequest.
415    #[test]
416    fn parse_install_opts_missing_profile_value() {
417        let rest = argv(&["--profile"]);
418        let err = parse_install_opts(&rest, false).unwrap_err();
419        assert_eq!(err.kind, McpErrorKind::InvalidRequest);
420        assert!(err.message.contains("--profile"));
421    }
422
423    /// `parse_install_opts` without `--profile` at all also surfaces
424    /// InvalidRequest from the final check.
425    #[test]
426    fn parse_install_opts_missing_profile_entirely() {
427        let rest = argv(&["--allowed-keys", "demo/*"]);
428        let err = parse_install_opts(&rest, false).unwrap_err();
429        assert_eq!(err.kind, McpErrorKind::InvalidRequest);
430        assert!(err.message.contains("--profile"));
431    }
432
433    /// Unknown install flag bubbles up as InvalidRequest, never silently
434    /// swallowed.
435    #[test]
436    fn parse_install_opts_rejects_unknown_flag() {
437        let rest = argv(&["--profile", "demo", "--mystery-flag"]);
438        let err = parse_install_opts(&rest, false).unwrap_err();
439        assert_eq!(err.kind, McpErrorKind::InvalidRequest);
440        assert!(err.message.contains("unknown install flag"));
441    }
442
443    /// Uninstall flag is plumbed through correctly.
444    #[test]
445    fn parse_install_opts_uninstall_sets_flag() {
446        let rest = argv(&["--profile", "demo"]);
447        let opts = parse_install_opts(&rest, true).unwrap();
448        assert!(opts.uninstall);
449    }
450
451    /// `split_csv` strips whitespace and drops empty entries — matters for
452    /// `--allowed-keys "demo/*, , bar/*"` and similar copy-paste shapes.
453    #[test]
454    fn split_csv_handles_whitespace_and_empties() {
455        assert_eq!(split_csv("demo/*, , shared/*"), vec!["demo/*", "shared/*"]);
456        assert!(split_csv("").is_empty());
457        assert_eq!(split_csv(" only "), vec!["only"]);
458    }
459}