Skip to main content

xtask/
scripts_lane_a.rs

1//! Lane A script migrations.
2//!
3//! These functions are intentionally not wired into `main.rs` in this lane.
4//! They preserve the behavior of the compatibility shell scripts and are ready
5//! for the parent integration pass to expose as xtask commands.
6
7use 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"), &current_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}