1use anyhow::{bail, Context, Result};
8use serde_json::Value;
9use std::ffi::{OsStr, OsString};
10use std::fs;
11use std::path::{Path, PathBuf};
12use std::process::{Command, Stdio};
13use std::time::{SystemTime, UNIX_EPOCH};
14
15pub fn build_web() -> Result<()> {
16 let web_dir = Path::new("apps/web");
17 if !web_dir.is_dir() {
18 println!("No apps/web directory found - skipping web build");
19 return Ok(());
20 }
21
22 if !web_dir.join("node_modules").is_dir() {
23 println!("Installing web dependencies...");
24 run_cmd_in("pnpm", ["install", "--frozen-lockfile"], web_dir)?;
25 }
26
27 run_cmd_in("pnpm", ["build"], web_dir)?;
28 println!("Web assets built -> apps/web/out/");
29 Ok(())
30}
31
32pub fn web_watch() -> Result<()> {
33 web_watch_with_build_command("bash scripts/build-web.sh")
34}
35
36pub fn web_watch_with_build_command(build_command: &str) -> Result<()> {
37 if !command_on_path("watchexec") {
38 eprintln!("error: watchexec is required for web-watch");
39 eprintln!("install: cargo install watchexec-cli");
40 bail!("watchexec is required for web-watch");
41 }
42
43 println!("Building apps/web once...");
44 run_cmd("bash", ["scripts/build-web.sh"])?;
45
46 println!("Watching apps/web for changes...");
47 let args = web_watch_args(build_command);
48 run_command(Command::new("watchexec").args(args))
49}
50
51pub fn generate_cli() -> Result<()> {
52 if !command_on_path("mcporter") {
53 eprintln!("error: mcporter not found. Install it first.");
54 bail!("mcporter not found");
55 }
56
57 println!("Server must be running on port 40060 (run 'just dev' first)");
58 println!("Generated CLI embeds your token - do not commit or share");
59
60 fs::create_dir_all("dist/.cache").context("failed to create dist/.cache")?;
61
62 let schema_json = temp_schema_path();
63 let _guard = RemoveOnDrop(schema_json.clone());
64 let token = std::env::var("SOMA_MCP_TOKEN")
65 .ok()
66 .filter(|v| !v.is_empty());
67 let request = GenerateCliRequest::new(token.as_deref());
68
69 let mut curl_args: Vec<OsString> = vec!["10".into(), "curl".into(), "-sf".into()];
70 curl_args.extend(request.curl_headers.iter().cloned());
71 curl_args.extend([
72 "http://localhost:40060/mcp/tools/list".into(),
73 "-o".into(),
74 schema_json.as_os_str().to_owned(),
75 ]);
76 run_cmd_os("timeout", curl_args).with_context(|| {
77 "error: failed to fetch tool schema from http://localhost:40060/mcp/tools/list"
78 })?;
79
80 let current_hash = sha256sum(&schema_json)?;
81 let cache_file = Path::new("dist/.cache/soma-cli.schema_hash");
82 if cached_cli_is_current(cache_file, Path::new("dist/soma-cli"), ¤t_hash)? {
83 println!("SKIP: tool schema unchanged - use existing dist/soma-cli");
84 return Ok(());
85 }
86
87 let mut mcporter_args: Vec<OsString> = vec!["30".into(), "mcporter".into()];
88 mcporter_args.extend(request.mcporter_args);
89 run_cmd_os("timeout", mcporter_args)?;
90
91 set_private_executable(Path::new("dist/soma-cli"))?;
92 if !git_check_ignore(Path::new("dist/soma-cli")) {
93 eprintln!(
94 "warning: dist/soma-cli is not ignored; generated CLI embeds secrets and must not be committed"
95 );
96 }
97
98 fs::write(cache_file, current_hash).context("failed to write CLI schema hash cache")?;
99 println!("Generated dist/soma-cli (requires bun at runtime)");
100 Ok(())
101}
102
103pub fn repair() -> Result<()> {
104 println!("==> Stopping soma-mcp...");
105 if systemd_user_unit_active("soma-mcp.service") {
106 run_cmd("systemctl", ["--user", "stop", "soma-mcp.service"])?;
107 println!(" stopped systemd unit");
108 } else if docker_container_running("soma-mcp") {
109 let _ = run_cmd("docker", ["stop", "soma-mcp"]);
110 println!(" stopped docker container");
111 } else {
112 println!(" no running instance found");
113 }
114
115 println!("==> Rebuilding release binary...");
116 run_cmd(
117 "cargo",
118 ["build", "--release", "--bin", "soma", "--features", "full"],
119 )?;
120
121 println!("==> Restarting...");
122 if systemd_user_unit_file_exists("soma-mcp.service") {
123 let home = std::env::var("HOME").context("HOME is not set")?;
124 let bin_dir = Path::new(&home).join(".local/bin");
125 fs::create_dir_all(&bin_dir)
126 .with_context(|| format!("failed to create {}", bin_dir.display()))?;
127 run_cmd(
128 "install",
129 [
130 "-m",
131 "755",
132 "target/release/soma",
133 bin_dir
134 .join("soma")
135 .to_str()
136 .context("non-UTF-8 install path")?,
137 ],
138 )?;
139 run_cmd("systemctl", ["--user", "start", "soma-mcp.service"])?;
140 println!(" started systemd unit");
141 } else if Path::new("docker-compose.yml").is_file() {
142 run_cmd("docker", ["compose", "build"])?;
143 run_cmd("docker", ["compose", "up", "-d", "--force-recreate"])?;
144 println!(" started docker compose service");
145 } else {
146 println!(" no service manager detected; binary at target/release/soma");
147 }
148
149 println!("==> Done");
150 Ok(())
151}
152
153pub fn test_mcp_auth(args: &[String]) -> Result<()> {
154 let options = AuthSmokeOptions::parse(args)?;
155 if options.help {
156 print_auth_usage();
157 return Ok(());
158 }
159 let token = options
160 .token
161 .as_deref()
162 .filter(|value| !value.is_empty())
163 .ok_or_else(|| {
164 eprintln!("ERROR: set SOMA_MCP_TOKEN or pass --token");
165 anyhow::anyhow!("missing SOMA_MCP_TOKEN")
166 })?;
167
168 let body_path = Path::new("/tmp/soma-auth-body.txt");
169 let _guard = RemoveOnDrop(body_path.to_path_buf());
170 let request_body = r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}"#;
171 let base_url = base_url_from_mcp_url(&options.mcp_url);
172 let mut results = AuthSmokeResults::default();
173
174 expect_code(
175 &mut results,
176 "health is public",
177 "200",
178 body_path,
179 &options.timeout,
180 [format!("{base_url}/health")],
181 );
182
183 expect_code(
184 &mut results,
185 "missing bearer token is rejected",
186 "401",
187 body_path,
188 &options.timeout,
189 post_jsonrpc_args(&options.mcp_url, None, request_body),
190 );
191
192 expect_code(
193 &mut results,
194 "bad bearer token is rejected",
195 "401",
196 body_path,
197 &options.timeout,
198 post_jsonrpc_args(
199 &options.mcp_url,
200 Some(("Authorization", "Bearer intentionally-bad-token".to_owned())),
201 request_body,
202 ),
203 );
204
205 expect_success_jsonrpc(
206 &mut results,
207 "valid bearer token is accepted",
208 body_path,
209 &options.timeout,
210 post_jsonrpc_args(
211 &options.mcp_url,
212 Some(("Authorization", format!("Bearer {token}"))),
213 request_body,
214 ),
215 );
216
217 if options.check_x_api_key {
218 expect_success_jsonrpc(
219 &mut results,
220 "x-api-key token is accepted",
221 body_path,
222 &options.timeout,
223 post_jsonrpc_args(
224 &options.mcp_url,
225 Some(("x-api-key", token.to_owned())),
226 request_body,
227 ),
228 );
229 } else {
230 println!("SKIP x-api-key acceptance (pass --check-x-api-key only for services that implement it)");
231 }
232
233 println!("\n{} passed, {} failed", results.pass, results.fail);
234 if results.fail == 0 {
235 Ok(())
236 } else {
237 bail!("{} auth smoke check(s) failed", results.fail)
238 }
239}
240
241#[derive(Debug, Clone, PartialEq, Eq)]
242struct GenerateCliRequest {
243 curl_headers: Vec<OsString>,
244 mcporter_args: Vec<OsString>,
245}
246
247impl GenerateCliRequest {
248 fn new(token: Option<&str>) -> Self {
249 let mut curl_headers = vec![
250 "-H".into(),
251 "Accept: application/json, text/event-stream".into(),
252 ];
253 let mut mcporter_args: Vec<OsString> = [
254 "generate-cli",
255 "--command",
256 "http://localhost:40060/mcp",
257 "--name",
258 "soma-cli",
259 "--output",
260 "dist/soma-cli",
261 ]
262 .into_iter()
263 .map(OsString::from)
264 .collect();
265
266 if let Some(token) = token {
267 let auth = format!("Authorization: Bearer {token}");
268 curl_headers.extend(["-H".into(), auth.clone().into()]);
269 mcporter_args.extend(["--header".into(), auth.into()]);
270 }
271
272 Self {
273 curl_headers,
274 mcporter_args,
275 }
276 }
277}
278
279#[derive(Debug, Clone, PartialEq, Eq)]
280struct AuthSmokeOptions {
281 mcp_url: String,
282 token: Option<String>,
283 timeout: String,
284 check_x_api_key: bool,
285 help: bool,
286}
287
288impl AuthSmokeOptions {
289 fn parse(args: &[String]) -> Result<Self> {
290 let mut options = Self {
291 mcp_url: std::env::var("SOMA_MCP_URL")
292 .unwrap_or_else(|_| "http://localhost:40060/mcp".to_owned()),
293 token: std::env::var("SOMA_MCP_TOKEN").ok(),
294 timeout: std::env::var("MCP_AUTH_TIMEOUT").unwrap_or_else(|_| "10".to_owned()),
295 check_x_api_key: false,
296 help: false,
297 };
298
299 let mut index = 0usize;
300 while index < args.len() {
301 match args[index].as_str() {
302 "--url" => {
303 index += 1;
304 options.mcp_url = args
305 .get(index)
306 .context("--url requires a value")?
307 .to_owned();
308 }
309 "--token" => {
310 index += 1;
311 options.token = Some(
312 args.get(index)
313 .context("--token requires a value")?
314 .to_owned(),
315 );
316 }
317 "--check-x-api-key" => options.check_x_api_key = true,
318 "-h" | "--help" => options.help = true,
319 unknown => {
320 eprintln!("unknown argument: {unknown}");
321 print_auth_usage();
322 bail!("unknown argument: {unknown}");
323 }
324 }
325 index += 1;
326 }
327
328 Ok(options)
329 }
330}
331
332#[derive(Default)]
333pub(crate) struct AuthSmokeResults {
334 pub(crate) pass: usize,
335 pub(crate) fail: usize,
336}
337
338impl AuthSmokeResults {
339 pub(crate) fn pass(&mut self, label: &str) {
340 println!("PASS {label}");
341 self.pass += 1;
342 }
343
344 pub(crate) fn fail(&mut self, label: &str) {
345 eprintln!("FAIL {label}");
346 self.fail += 1;
347 }
348}
349
350fn print_auth_usage() {
351 println!(
352 "Usage: scripts/test-mcp-auth.sh [OPTIONS]
353
354Options:
355 --url URL MCP URL. Default: SOMA_MCP_URL or http://localhost:40060/mcp.
356 --token TOKEN Expected static bearer token. Default: SOMA_MCP_TOKEN.
357 --check-x-api-key Also require x-api-key auth to succeed. Off by default because
358 Soma's pinned lab-auth layer only supports Bearer.
359 -h, --help Show this help.
360
361Checks:
362 - /health is reachable without auth
363 - /mcp rejects missing bearer token with 401
364 - /mcp rejects a bad bearer token with 401
365 - /mcp accepts Authorization: Bearer <token>
366 - x-api-key is skipped unless --check-x-api-key is set"
367 );
368}
369
370fn expect_code<I, S>(
371 results: &mut AuthSmokeResults,
372 label: &str,
373 expected: &str,
374 body_path: &Path,
375 timeout: &str,
376 args: I,
377) where
378 I: IntoIterator<Item = S>,
379 S: Into<OsString>,
380{
381 match http_code(body_path, timeout, args) {
382 Ok(code) if code == expected => results.pass(label),
383 Ok(code) => results.fail(&format!(
384 "{label} (expected HTTP {expected}, got {code}; body: {})",
385 body_preview(body_path)
386 )),
387 Err(_) => results.fail(&format!("{label} (curl failed)")),
388 }
389}
390
391fn expect_success_jsonrpc<I, S>(
392 results: &mut AuthSmokeResults,
393 label: &str,
394 body_path: &Path,
395 timeout: &str,
396 args: I,
397) where
398 I: IntoIterator<Item = S>,
399 S: Into<OsString>,
400{
401 match http_code(body_path, timeout, args) {
402 Ok(code) if code == "200" => match response_has_tools(body_path) {
403 Ok(true) => results.pass(label),
404 Ok(false) => results.fail(&format!("{label} (missing tools)")),
405 Err(error) => results.fail(&format!("{label} (parse error: {error})")),
406 },
407 Ok(code) => results.fail(&format!(
408 "{label} (expected HTTP 200, got {code}; body: {})",
409 body_preview(body_path)
410 )),
411 Err(_) => results.fail(&format!("{label} (curl failed)")),
412 }
413}
414
415fn http_code<I, S>(body_path: &Path, timeout: &str, args: I) -> Result<String>
416where
417 I: IntoIterator<Item = S>,
418 S: Into<OsString>,
419{
420 let output = Command::new("curl")
421 .arg("-sS")
422 .arg("--max-time")
423 .arg(timeout)
424 .arg("-o")
425 .arg(body_path)
426 .arg("-w")
427 .arg("%{http_code}")
428 .args(args.into_iter().map(Into::into))
429 .stdin(Stdio::null())
430 .output()
431 .context("failed to spawn curl")?;
432 if !output.status.success() {
433 bail!("curl exited with {}", output.status);
434 }
435 String::from_utf8(output.stdout).context("curl emitted non-UTF-8 status")
436}
437
438fn response_has_tools(body_path: &Path) -> Result<bool> {
439 let body = fs::read_to_string(body_path)
440 .with_context(|| format!("failed to read {}", body_path.display()))?;
441 let value: Value = serde_json::from_str(&body)?;
442 Ok(value
443 .pointer("/result/tools")
444 .and_then(Value::as_array)
445 .is_some_and(|tools| !tools.is_empty()))
446}
447
448fn post_jsonrpc_args(
449 mcp_url: &str,
450 auth_header: Option<(&str, String)>,
451 request_body: &str,
452) -> Vec<OsString> {
453 let mut args = vec![
454 "-X".into(),
455 "POST".into(),
456 mcp_url.into(),
457 "-H".into(),
458 "Content-Type: application/json".into(),
459 "-H".into(),
460 "Accept: application/json, text/event-stream".into(),
461 "-d".into(),
462 request_body.into(),
463 ];
464 if let Some((name, value)) = auth_header {
465 args.splice(3..3, ["-H".into(), format!("{name}: {value}").into()]);
466 }
467 args
468}
469
470fn base_url_from_mcp_url(mcp_url: &str) -> String {
471 mcp_url.strip_suffix("/mcp").unwrap_or(mcp_url).to_owned()
472}
473
474fn body_preview(body_path: &Path) -> String {
475 fs::read_to_string(body_path)
476 .unwrap_or_default()
477 .chars()
478 .filter(|ch| *ch != '\n')
479 .take(200)
480 .collect()
481}
482
483fn web_watch_args(build_command: &str) -> Vec<OsString> {
484 [
485 "--project-origin",
486 ".",
487 "--watch",
488 "apps/web",
489 "--ignore",
490 "apps/web/.next",
491 "--ignore",
492 "apps/web/.next/**",
493 "--ignore",
494 "apps/web/out",
495 "--ignore",
496 "apps/web/out/**",
497 "--ignore",
498 "apps/web/node_modules",
499 "--ignore",
500 "apps/web/node_modules/**",
501 "--debounce",
502 "1000ms",
503 "--on-busy-update",
504 "queue",
505 "--wrap-process=none",
506 build_command,
507 ]
508 .into_iter()
509 .map(OsString::from)
510 .collect()
511}
512
513fn temp_schema_path() -> PathBuf {
514 let mut path = std::env::temp_dir();
515 let nanos = SystemTime::now()
516 .duration_since(UNIX_EPOCH)
517 .map(|duration| duration.as_nanos())
518 .unwrap_or_default();
519 path.push(format!("soma-schema-{}-{nanos}.json", std::process::id()));
520 path
521}
522
523fn cached_cli_is_current(cache_file: &Path, cli_path: &Path, current_hash: &str) -> Result<bool> {
524 if !cache_file.is_file() || !cli_path.is_file() {
525 return Ok(false);
526 }
527 let cached_hash = fs::read_to_string(cache_file)
528 .with_context(|| format!("failed to read {}", cache_file.display()))?;
529 Ok(cached_hash == current_hash)
530}
531
532fn sha256sum(path: &Path) -> Result<String> {
533 let output = Command::new("sha256sum")
534 .arg(path)
535 .stdin(Stdio::null())
536 .output()
537 .context("failed to spawn sha256sum")?;
538 if !output.status.success() {
539 bail!("sha256sum exited with {}", output.status);
540 }
541 let stdout = String::from_utf8(output.stdout).context("sha256sum emitted non-UTF-8 stdout")?;
542 stdout
543 .split_whitespace()
544 .next()
545 .map(str::to_owned)
546 .context("sha256sum did not print a hash")
547}
548
549fn git_check_ignore(path: &Path) -> bool {
550 Command::new("git")
551 .arg("check-ignore")
552 .arg("-q")
553 .arg(path)
554 .stdin(Stdio::null())
555 .stdout(Stdio::null())
556 .stderr(Stdio::null())
557 .status()
558 .map(|status| status.success())
559 .unwrap_or(false)
560}
561
562fn systemd_user_unit_active(unit: &str) -> bool {
563 Command::new("systemctl")
564 .args(["--user", "is-active", "--quiet", unit])
565 .stdin(Stdio::null())
566 .stdout(Stdio::null())
567 .stderr(Stdio::null())
568 .status()
569 .map(|status| status.success())
570 .unwrap_or(false)
571}
572
573fn systemd_user_unit_file_exists(unit: &str) -> bool {
574 let output = Command::new("systemctl")
575 .args(["--user", "list-unit-files", unit])
576 .stdin(Stdio::null())
577 .output();
578 let Ok(output) = output else {
579 return false;
580 };
581 if !output.status.success() {
582 return false;
583 }
584 String::from_utf8(output.stdout)
585 .map(|stdout| stdout.contains(unit))
586 .unwrap_or(false)
587}
588
589fn docker_container_running(name: &str) -> bool {
590 let filter = format!("name=^/{name}$");
591 let output = Command::new("docker")
592 .args(["ps", "--filter", &filter, "--quiet"])
593 .stdin(Stdio::null())
594 .stderr(Stdio::null())
595 .output();
596 let Ok(output) = output else {
597 return false;
598 };
599 output.status.success() && !output.stdout.iter().all(u8::is_ascii_whitespace)
600}
601
602fn command_on_path(name: &str) -> bool {
603 if name.contains('/') {
604 return Path::new(name).is_file();
605 }
606 let Some(paths) = std::env::var_os("PATH") else {
607 return false;
608 };
609 std::env::split_paths(&paths).any(|dir| dir.join(name).is_file())
610}
611
612fn run_cmd<I, S>(program: &str, args: I) -> Result<()>
613where
614 I: IntoIterator<Item = S>,
615 S: AsRef<OsStr>,
616{
617 run_command(Command::new(program).args(args))
618}
619
620fn run_cmd_os<I>(program: &str, args: I) -> Result<()>
621where
622 I: IntoIterator<Item = OsString>,
623{
624 run_command(Command::new(program).args(args))
625}
626
627fn run_cmd_in<I, S>(program: &str, args: I, cwd: &Path) -> Result<()>
628where
629 I: IntoIterator<Item = S>,
630 S: AsRef<OsStr>,
631{
632 run_command(Command::new(program).args(args).current_dir(cwd))
633}
634
635fn run_command(command: &mut Command) -> Result<()> {
636 let program = command.get_program().to_string_lossy().into_owned();
637 let args = command
638 .get_args()
639 .map(|arg| arg.to_string_lossy())
640 .collect::<Vec<_>>()
641 .join(" ");
642 let status = command
643 .stdin(Stdio::null())
644 .status()
645 .with_context(|| format!("Failed to spawn `{program}`"))?;
646 if !status.success() {
647 bail!("`{program} {args}` exited with status {status}");
648 }
649 Ok(())
650}
651
652#[cfg(unix)]
653fn set_private_executable(path: &Path) -> Result<()> {
654 use std::os::unix::fs::PermissionsExt;
655
656 let mut permissions = fs::metadata(path)
657 .with_context(|| format!("failed to stat {}", path.display()))?
658 .permissions();
659 permissions.set_mode(0o700);
660 fs::set_permissions(path, permissions)
661 .with_context(|| format!("failed to chmod 700 {}", path.display()))
662}
663
664#[cfg(not(unix))]
665fn set_private_executable(path: &Path) -> Result<()> {
666 if path.is_file() {
667 Ok(())
668 } else {
669 bail!("generated CLI missing at {}", path.display())
670 }
671}
672
673struct RemoveOnDrop(PathBuf);
674
675impl Drop for RemoveOnDrop {
676 fn drop(&mut self) {
677 let _ = fs::remove_file(&self.0);
678 }
679}
680
681#[cfg(test)]
682mod tests {
683 use super::{
684 base_url_from_mcp_url, cached_cli_is_current, post_jsonrpc_args, response_has_tools,
685 web_watch_args, AuthSmokeOptions, GenerateCliRequest,
686 };
687 use std::ffi::OsString;
688 use std::fs;
689
690 fn os_strings(values: &[&str]) -> Vec<OsString> {
691 values.iter().map(OsString::from).collect()
692 }
693
694 #[test]
695 fn web_watch_args_preserve_script_ignores_and_build_command() {
696 let args = web_watch_args("bash scripts/build-web.sh");
697 assert_eq!(args[0], "--project-origin");
698 assert!(args.contains(&OsString::from("apps/web/.next/**")));
699 assert!(args.contains(&OsString::from("apps/web/out/**")));
700 assert!(args.contains(&OsString::from("apps/web/node_modules/**")));
701 assert_eq!(
702 args.last(),
703 Some(&OsString::from("bash scripts/build-web.sh"))
704 );
705 }
706
707 #[test]
708 fn generate_cli_request_adds_auth_headers_only_when_token_is_present() {
709 let without_token = GenerateCliRequest::new(None);
710 assert_eq!(
711 without_token.curl_headers,
712 os_strings(&["-H", "Accept: application/json, text/event-stream"])
713 );
714 assert!(!without_token
715 .mcporter_args
716 .contains(&OsString::from("--header")));
717
718 let with_token = GenerateCliRequest::new(Some("secret"));
719 assert!(with_token
720 .curl_headers
721 .contains(&OsString::from("Authorization: Bearer secret")));
722 assert!(with_token.mcporter_args.windows(2).any(|pair| {
723 pair == [
724 OsString::from("--header"),
725 OsString::from("Authorization: Bearer secret"),
726 ]
727 }));
728 }
729
730 #[test]
731 fn auth_options_parse_flags_over_defaults() {
732 let args = vec![
733 "--url".to_owned(),
734 "http://example.test/mcp".to_owned(),
735 "--token".to_owned(),
736 "expected".to_owned(),
737 "--check-x-api-key".to_owned(),
738 ];
739 let options = AuthSmokeOptions::parse(&args).unwrap();
740 assert_eq!(options.mcp_url, "http://example.test/mcp");
741 assert_eq!(options.token.as_deref(), Some("expected"));
742 assert_eq!(options.timeout, "10");
743 assert!(options.check_x_api_key);
744 assert!(!options.help);
745 }
746
747 #[test]
748 fn auth_options_reject_unknown_args() {
749 let error = AuthSmokeOptions::parse(&["--wat".to_owned()]).unwrap_err();
750 assert!(error.to_string().contains("unknown argument"));
751 }
752
753 #[test]
754 fn base_url_strips_only_trailing_mcp_segment() {
755 assert_eq!(
756 base_url_from_mcp_url("http://localhost:40060/mcp"),
757 "http://localhost:40060"
758 );
759 assert_eq!(
760 base_url_from_mcp_url("http://localhost:40060/custom"),
761 "http://localhost:40060/custom"
762 );
763 }
764
765 #[test]
766 fn post_jsonrpc_args_insert_auth_header_after_url() {
767 let args = post_jsonrpc_args(
768 "http://localhost:40060/mcp",
769 Some(("Authorization", "Bearer token".to_owned())),
770 "{}",
771 );
772 assert_eq!(args[0], "-X");
773 assert_eq!(args[2], "http://localhost:40060/mcp");
774 assert_eq!(args[3], "-H");
775 assert_eq!(args[4], "Authorization: Bearer token");
776 assert!(args.contains(&OsString::from("Content-Type: application/json")));
777 assert!(args.contains(&OsString::from(
778 "Accept: application/json, text/event-stream"
779 )));
780 }
781
782 #[test]
783 fn response_has_tools_requires_non_empty_tools_array() {
784 let dir = tempfile::tempdir().unwrap();
785 let body = dir.path().join("body.json");
786
787 fs::write(&body, r#"{"result":{"tools":[{"name":"soma"}]}}"#).unwrap();
788 assert!(response_has_tools(&body).unwrap());
789
790 fs::write(&body, r#"{"result":{"tools":[]}}"#).unwrap();
791 assert!(!response_has_tools(&body).unwrap());
792 }
793
794 #[test]
795 fn cached_cli_requires_matching_hash_and_cli_file() {
796 let dir = tempfile::tempdir().unwrap();
797 let cache = dir.path().join("hash");
798 let cli = dir.path().join("soma-cli");
799
800 assert!(!cached_cli_is_current(&cache, &cli, "abc").unwrap());
801
802 fs::write(&cache, "abc").unwrap();
803 assert!(!cached_cli_is_current(&cache, &cli, "abc").unwrap());
804
805 fs::write(&cli, "").unwrap();
806 assert!(cached_cli_is_current(&cache, &cli, "abc").unwrap());
807 assert!(!cached_cli_is_current(&cache, &cli, "def").unwrap());
808 }
809}