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::{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"), &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!("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}