1pub 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#[derive(Debug)]
34enum Subcommand {
35 Serve(Vec<String>),
37 Install {
39 host: String,
40 rest: Vec<String>,
41 },
42 Uninstall {
43 host: String,
44 rest: Vec<String>,
45 },
46 Status,
47}
48
49pub 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 if argv.len() < 2 {
100 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 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, };
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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}