1use anyhow::{Context, Result, bail};
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!(
231 "SKIP x-api-key acceptance (pass --check-x-api-key only for services that implement it)"
232 );
233 }
234
235 println!("\n{} passed, {} failed", results.pass, results.fail);
236 if results.fail == 0 {
237 Ok(())
238 } else {
239 bail!("{} auth smoke check(s) failed", results.fail)
240 }
241}
242
243#[derive(Debug, Clone, PartialEq, Eq)]
244struct GenerateCliRequest {
245 curl_headers: Vec<OsString>,
246 mcporter_args: Vec<OsString>,
247}
248
249impl GenerateCliRequest {
250 fn new(token: Option<&str>) -> Self {
251 let mut curl_headers = vec![
252 "-H".into(),
253 "Accept: application/json, text/event-stream".into(),
254 ];
255 let mut mcporter_args: Vec<OsString> = [
256 "generate-cli",
257 "--command",
258 "http://localhost:40060/mcp",
259 "--name",
260 "soma-cli",
261 "--output",
262 "dist/soma-cli",
263 ]
264 .into_iter()
265 .map(OsString::from)
266 .collect();
267
268 if let Some(token) = token {
269 let auth = format!("Authorization: Bearer {token}");
270 curl_headers.extend(["-H".into(), auth.clone().into()]);
271 mcporter_args.extend(["--header".into(), auth.into()]);
272 }
273
274 Self {
275 curl_headers,
276 mcporter_args,
277 }
278 }
279}
280
281#[derive(Debug, Clone, PartialEq, Eq)]
282struct AuthSmokeOptions {
283 mcp_url: String,
284 token: Option<String>,
285 timeout: String,
286 check_x_api_key: bool,
287 help: bool,
288}
289
290impl AuthSmokeOptions {
291 fn parse(args: &[String]) -> Result<Self> {
292 let mut options = Self {
293 mcp_url: std::env::var("SOMA_MCP_URL")
294 .unwrap_or_else(|_| "http://localhost:40060/mcp".to_owned()),
295 token: std::env::var("SOMA_MCP_TOKEN").ok(),
296 timeout: std::env::var("MCP_AUTH_TIMEOUT").unwrap_or_else(|_| "10".to_owned()),
297 check_x_api_key: false,
298 help: false,
299 };
300
301 let mut index = 0usize;
302 while index < args.len() {
303 match args[index].as_str() {
304 "--url" => {
305 index += 1;
306 options.mcp_url = args
307 .get(index)
308 .context("--url requires a value")?
309 .to_owned();
310 }
311 "--token" => {
312 index += 1;
313 options.token = Some(
314 args.get(index)
315 .context("--token requires a value")?
316 .to_owned(),
317 );
318 }
319 "--check-x-api-key" => options.check_x_api_key = true,
320 "-h" | "--help" => options.help = true,
321 unknown => {
322 eprintln!("unknown argument: {unknown}");
323 print_auth_usage();
324 bail!("unknown argument: {unknown}");
325 }
326 }
327 index += 1;
328 }
329
330 Ok(options)
331 }
332}
333
334#[derive(Default)]
335pub(crate) struct AuthSmokeResults {
336 pub(crate) pass: usize,
337 pub(crate) fail: usize,
338}
339
340impl AuthSmokeResults {
341 pub(crate) fn pass(&mut self, label: &str) {
342 println!("PASS {label}");
343 self.pass += 1;
344 }
345
346 pub(crate) fn fail(&mut self, label: &str) {
347 eprintln!("FAIL {label}");
348 self.fail += 1;
349 }
350}
351
352fn print_auth_usage() {
353 println!(
354 "Usage: scripts/test-mcp-auth.sh [OPTIONS]
355
356Options:
357 --url URL MCP URL. Default: SOMA_MCP_URL or http://localhost:40060/mcp.
358 --token TOKEN Expected static bearer token. Default: SOMA_MCP_TOKEN.
359 --check-x-api-key Also require x-api-key auth to succeed. Off by default because
360 Soma's pinned lab-auth layer only supports Bearer.
361 -h, --help Show this help.
362
363Checks:
364 - /health is reachable without auth
365 - /mcp rejects missing bearer token with 401
366 - /mcp rejects a bad bearer token with 401
367 - /mcp accepts Authorization: Bearer <token>
368 - x-api-key is skipped unless --check-x-api-key is set"
369 );
370}
371
372fn expect_code<I, S>(
373 results: &mut AuthSmokeResults,
374 label: &str,
375 expected: &str,
376 body_path: &Path,
377 timeout: &str,
378 args: I,
379) where
380 I: IntoIterator<Item = S>,
381 S: Into<OsString>,
382{
383 match http_code(body_path, timeout, args) {
384 Ok(code) if code == expected => results.pass(label),
385 Ok(code) => results.fail(&format!(
386 "{label} (expected HTTP {expected}, got {code}; body: {})",
387 body_preview(body_path)
388 )),
389 Err(_) => results.fail(&format!("{label} (curl failed)")),
390 }
391}
392
393fn expect_success_jsonrpc<I, S>(
394 results: &mut AuthSmokeResults,
395 label: &str,
396 body_path: &Path,
397 timeout: &str,
398 args: I,
399) where
400 I: IntoIterator<Item = S>,
401 S: Into<OsString>,
402{
403 match http_code(body_path, timeout, args) {
404 Ok(code) if code == "200" => match response_has_tools(body_path) {
405 Ok(true) => results.pass(label),
406 Ok(false) => results.fail(&format!("{label} (missing tools)")),
407 Err(error) => results.fail(&format!("{label} (parse error: {error})")),
408 },
409 Ok(code) => results.fail(&format!(
410 "{label} (expected HTTP 200, got {code}; body: {})",
411 body_preview(body_path)
412 )),
413 Err(_) => results.fail(&format!("{label} (curl failed)")),
414 }
415}
416
417fn http_code<I, S>(body_path: &Path, timeout: &str, args: I) -> Result<String>
418where
419 I: IntoIterator<Item = S>,
420 S: Into<OsString>,
421{
422 let output = Command::new("curl")
423 .arg("-sS")
424 .arg("--max-time")
425 .arg(timeout)
426 .arg("-o")
427 .arg(body_path)
428 .arg("-w")
429 .arg("%{http_code}")
430 .args(args.into_iter().map(Into::into))
431 .stdin(Stdio::null())
432 .output()
433 .context("failed to spawn curl")?;
434 if !output.status.success() {
435 bail!("curl exited with {}", output.status);
436 }
437 String::from_utf8(output.stdout).context("curl emitted non-UTF-8 status")
438}
439
440fn response_has_tools(body_path: &Path) -> Result<bool> {
441 let body = fs::read_to_string(body_path)
442 .with_context(|| format!("failed to read {}", body_path.display()))?;
443 let value: Value = serde_json::from_str(&body)?;
444 Ok(value
445 .pointer("/result/tools")
446 .and_then(Value::as_array)
447 .is_some_and(|tools| !tools.is_empty()))
448}
449
450fn post_jsonrpc_args(
451 mcp_url: &str,
452 auth_header: Option<(&str, String)>,
453 request_body: &str,
454) -> Vec<OsString> {
455 let mut args = vec![
456 "-X".into(),
457 "POST".into(),
458 mcp_url.into(),
459 "-H".into(),
460 "Content-Type: application/json".into(),
461 "-H".into(),
462 "Accept: application/json, text/event-stream".into(),
463 "-d".into(),
464 request_body.into(),
465 ];
466 if let Some((name, value)) = auth_header {
467 args.splice(3..3, ["-H".into(), format!("{name}: {value}").into()]);
468 }
469 args
470}
471
472fn base_url_from_mcp_url(mcp_url: &str) -> String {
473 mcp_url.strip_suffix("/mcp").unwrap_or(mcp_url).to_owned()
474}
475
476fn body_preview(body_path: &Path) -> String {
477 fs::read_to_string(body_path)
478 .unwrap_or_default()
479 .chars()
480 .filter(|ch| *ch != '\n')
481 .take(200)
482 .collect()
483}
484
485fn web_watch_args(build_command: &str) -> Vec<OsString> {
486 [
487 "--project-origin",
488 ".",
489 "--watch",
490 "apps/web",
491 "--ignore",
492 "apps/web/.next",
493 "--ignore",
494 "apps/web/.next/**",
495 "--ignore",
496 "apps/web/out",
497 "--ignore",
498 "apps/web/out/**",
499 "--ignore",
500 "apps/web/node_modules",
501 "--ignore",
502 "apps/web/node_modules/**",
503 "--debounce",
504 "1000ms",
505 "--on-busy-update",
506 "queue",
507 "--wrap-process=none",
508 build_command,
509 ]
510 .into_iter()
511 .map(OsString::from)
512 .collect()
513}
514
515fn temp_schema_path() -> PathBuf {
516 let mut path = std::env::temp_dir();
517 let nanos = SystemTime::now()
518 .duration_since(UNIX_EPOCH)
519 .map(|duration| duration.as_nanos())
520 .unwrap_or_default();
521 path.push(format!("soma-schema-{}-{nanos}.json", std::process::id()));
522 path
523}
524
525fn cached_cli_is_current(cache_file: &Path, cli_path: &Path, current_hash: &str) -> Result<bool> {
526 if !cache_file.is_file() || !cli_path.is_file() {
527 return Ok(false);
528 }
529 let cached_hash = fs::read_to_string(cache_file)
530 .with_context(|| format!("failed to read {}", cache_file.display()))?;
531 Ok(cached_hash == current_hash)
532}
533
534fn sha256sum(path: &Path) -> Result<String> {
535 let output = Command::new("sha256sum")
536 .arg(path)
537 .stdin(Stdio::null())
538 .output()
539 .context("failed to spawn sha256sum")?;
540 if !output.status.success() {
541 bail!("sha256sum exited with {}", output.status);
542 }
543 let stdout = String::from_utf8(output.stdout).context("sha256sum emitted non-UTF-8 stdout")?;
544 stdout
545 .split_whitespace()
546 .next()
547 .map(str::to_owned)
548 .context("sha256sum did not print a hash")
549}
550
551fn git_check_ignore(path: &Path) -> bool {
552 Command::new("git")
553 .arg("check-ignore")
554 .arg("-q")
555 .arg(path)
556 .stdin(Stdio::null())
557 .stdout(Stdio::null())
558 .stderr(Stdio::null())
559 .status()
560 .map(|status| status.success())
561 .unwrap_or(false)
562}
563
564fn systemd_user_unit_active(unit: &str) -> bool {
565 Command::new("systemctl")
566 .args(["--user", "is-active", "--quiet", unit])
567 .stdin(Stdio::null())
568 .stdout(Stdio::null())
569 .stderr(Stdio::null())
570 .status()
571 .map(|status| status.success())
572 .unwrap_or(false)
573}
574
575fn systemd_user_unit_file_exists(unit: &str) -> bool {
576 let output = Command::new("systemctl")
577 .args(["--user", "list-unit-files", unit])
578 .stdin(Stdio::null())
579 .output();
580 let Ok(output) = output else {
581 return false;
582 };
583 if !output.status.success() {
584 return false;
585 }
586 String::from_utf8(output.stdout)
587 .map(|stdout| stdout.contains(unit))
588 .unwrap_or(false)
589}
590
591fn docker_container_running(name: &str) -> bool {
592 let filter = format!("name=^/{name}$");
593 let output = Command::new("docker")
594 .args(["ps", "--filter", &filter, "--quiet"])
595 .stdin(Stdio::null())
596 .stderr(Stdio::null())
597 .output();
598 let Ok(output) = output else {
599 return false;
600 };
601 output.status.success() && !output.stdout.iter().all(u8::is_ascii_whitespace)
602}
603
604fn command_on_path(name: &str) -> bool {
605 if name.contains('/') {
606 return Path::new(name).is_file();
607 }
608 let Some(paths) = std::env::var_os("PATH") else {
609 return false;
610 };
611 std::env::split_paths(&paths).any(|dir| dir.join(name).is_file())
612}
613
614fn run_cmd<I, S>(program: &str, args: I) -> Result<()>
615where
616 I: IntoIterator<Item = S>,
617 S: AsRef<OsStr>,
618{
619 run_command(Command::new(program).args(args))
620}
621
622fn run_cmd_os<I>(program: &str, args: I) -> Result<()>
623where
624 I: IntoIterator<Item = OsString>,
625{
626 run_command(Command::new(program).args(args))
627}
628
629fn run_cmd_in<I, S>(program: &str, args: I, cwd: &Path) -> Result<()>
630where
631 I: IntoIterator<Item = S>,
632 S: AsRef<OsStr>,
633{
634 run_command(Command::new(program).args(args).current_dir(cwd))
635}
636
637fn run_command(command: &mut Command) -> Result<()> {
638 let program = command.get_program().to_string_lossy().into_owned();
639 let args = command
640 .get_args()
641 .map(|arg| arg.to_string_lossy())
642 .collect::<Vec<_>>()
643 .join(" ");
644 let status = command
645 .stdin(Stdio::null())
646 .status()
647 .with_context(|| format!("Failed to spawn `{program}`"))?;
648 if !status.success() {
649 bail!("`{program} {args}` exited with status {status}");
650 }
651 Ok(())
652}
653
654#[cfg(unix)]
655fn set_private_executable(path: &Path) -> Result<()> {
656 use std::os::unix::fs::PermissionsExt;
657
658 let mut permissions = fs::metadata(path)
659 .with_context(|| format!("failed to stat {}", path.display()))?
660 .permissions();
661 permissions.set_mode(0o700);
662 fs::set_permissions(path, permissions)
663 .with_context(|| format!("failed to chmod 700 {}", path.display()))
664}
665
666#[cfg(not(unix))]
667fn set_private_executable(path: &Path) -> Result<()> {
668 if path.is_file() {
669 Ok(())
670 } else {
671 bail!("generated CLI missing at {}", path.display())
672 }
673}
674
675struct RemoveOnDrop(PathBuf);
676
677impl Drop for RemoveOnDrop {
678 fn drop(&mut self) {
679 let _ = fs::remove_file(&self.0);
680 }
681}
682
683#[cfg(test)]
684mod tests {
685 use super::{
686 AuthSmokeOptions, GenerateCliRequest, base_url_from_mcp_url, cached_cli_is_current,
687 post_jsonrpc_args, response_has_tools, web_watch_args,
688 };
689 use std::ffi::OsString;
690 use std::fs;
691
692 fn os_strings(values: &[&str]) -> Vec<OsString> {
693 values.iter().map(OsString::from).collect()
694 }
695
696 #[test]
697 fn web_watch_args_preserve_script_ignores_and_build_command() {
698 let args = web_watch_args("bash scripts/build-web.sh");
699 assert_eq!(args[0], "--project-origin");
700 assert!(args.contains(&OsString::from("apps/web/.next/**")));
701 assert!(args.contains(&OsString::from("apps/web/out/**")));
702 assert!(args.contains(&OsString::from("apps/web/node_modules/**")));
703 assert_eq!(
704 args.last(),
705 Some(&OsString::from("bash scripts/build-web.sh"))
706 );
707 }
708
709 #[test]
710 fn generate_cli_request_adds_auth_headers_only_when_token_is_present() {
711 let without_token = GenerateCliRequest::new(None);
712 assert_eq!(
713 without_token.curl_headers,
714 os_strings(&["-H", "Accept: application/json, text/event-stream"])
715 );
716 assert!(
717 !without_token
718 .mcporter_args
719 .contains(&OsString::from("--header"))
720 );
721
722 let with_token = GenerateCliRequest::new(Some("secret"));
723 assert!(
724 with_token
725 .curl_headers
726 .contains(&OsString::from("Authorization: Bearer secret"))
727 );
728 assert!(with_token.mcporter_args.windows(2).any(|pair| {
729 pair == [
730 OsString::from("--header"),
731 OsString::from("Authorization: Bearer secret"),
732 ]
733 }));
734 }
735
736 #[test]
737 fn auth_options_parse_flags_over_defaults() {
738 let args = vec![
739 "--url".to_owned(),
740 "http://example.test/mcp".to_owned(),
741 "--token".to_owned(),
742 "expected".to_owned(),
743 "--check-x-api-key".to_owned(),
744 ];
745 let options = AuthSmokeOptions::parse(&args).unwrap();
746 assert_eq!(options.mcp_url, "http://example.test/mcp");
747 assert_eq!(options.token.as_deref(), Some("expected"));
748 assert_eq!(options.timeout, "10");
749 assert!(options.check_x_api_key);
750 assert!(!options.help);
751 }
752
753 #[test]
754 fn auth_options_reject_unknown_args() {
755 let error = AuthSmokeOptions::parse(&["--wat".to_owned()]).unwrap_err();
756 assert!(error.to_string().contains("unknown argument"));
757 }
758
759 #[test]
760 fn base_url_strips_only_trailing_mcp_segment() {
761 assert_eq!(
762 base_url_from_mcp_url("http://localhost:40060/mcp"),
763 "http://localhost:40060"
764 );
765 assert_eq!(
766 base_url_from_mcp_url("http://localhost:40060/custom"),
767 "http://localhost:40060/custom"
768 );
769 }
770
771 #[test]
772 fn post_jsonrpc_args_insert_auth_header_after_url() {
773 let args = post_jsonrpc_args(
774 "http://localhost:40060/mcp",
775 Some(("Authorization", "Bearer token".to_owned())),
776 "{}",
777 );
778 assert_eq!(args[0], "-X");
779 assert_eq!(args[2], "http://localhost:40060/mcp");
780 assert_eq!(args[3], "-H");
781 assert_eq!(args[4], "Authorization: Bearer token");
782 assert!(args.contains(&OsString::from("Content-Type: application/json")));
783 assert!(args.contains(&OsString::from(
784 "Accept: application/json, text/event-stream"
785 )));
786 }
787
788 #[test]
789 fn response_has_tools_requires_non_empty_tools_array() {
790 let dir = tempfile::tempdir().unwrap();
791 let body = dir.path().join("body.json");
792
793 fs::write(&body, r#"{"result":{"tools":[{"name":"soma"}]}}"#).unwrap();
794 assert!(response_has_tools(&body).unwrap());
795
796 fs::write(&body, r#"{"result":{"tools":[]}}"#).unwrap();
797 assert!(!response_has_tools(&body).unwrap());
798 }
799
800 #[test]
801 fn cached_cli_requires_matching_hash_and_cli_file() {
802 let dir = tempfile::tempdir().unwrap();
803 let cache = dir.path().join("hash");
804 let cli = dir.path().join("soma-cli");
805
806 assert!(!cached_cli_is_current(&cache, &cli, "abc").unwrap());
807
808 fs::write(&cache, "abc").unwrap();
809 assert!(!cached_cli_is_current(&cache, &cli, "abc").unwrap());
810
811 fs::write(&cli, "").unwrap();
812 assert!(cached_cli_is_current(&cache, &cli, "abc").unwrap());
813 assert!(!cached_cli_is_current(&cache, &cli, "def").unwrap());
814 }
815}