Skip to main content

leviath_cli/commands/
mcp.rs

1//! `lev mcp` - manage MCP tool servers and their authentication.
2//!
3//! Adding a server that requires OAuth starts the browser login automatically,
4//! the way Claude Code and other clients do, so setup is a single command.
5
6use clap::{Args, Subcommand};
7
8use crate::config::Config;
9use leviath_mcp::{AuthStore, LoginOutcome, MCPClient, MCPServerConfig, OAuthClient};
10
11/// Arguments for `lev mcp`.
12#[derive(Args)]
13pub struct McpArgs {
14    #[command(subcommand)]
15    command: McpCommand,
16}
17
18impl McpArgs {
19    /// A `list` invocation, for routing tests in `dispatch`.
20    #[cfg(test)]
21    pub(crate) fn list_for_test() -> Self {
22        Self {
23            command: McpCommand::List(ListArgs { json: false }),
24        }
25    }
26}
27
28#[derive(Subcommand)]
29enum McpCommand {
30    /// Add an MCP server (auto-starts login if it requires auth)
31    Add(AddArgs),
32    /// List configured MCP servers and their auth status
33    List(ListArgs),
34    /// Remove a configured MCP server
35    Remove(RemoveArgs),
36    /// Authenticate (or re-authenticate) with a configured server
37    Login(ServerArg),
38    /// Forget a server's stored credentials
39    Logout(ServerArg),
40    /// Connect to a server and list its tools
41    Test(ServerArg),
42}
43
44#[derive(Args)]
45struct AddArgs {
46    /// Server name (an identifier used in config and for auth)
47    name: String,
48    /// Endpoint URL for an HTTP transport server
49    #[arg(long)]
50    url: Option<String>,
51    /// Command to launch a stdio transport server
52    #[arg(long)]
53    command: Option<String>,
54    /// Argument to pass to the command (repeatable)
55    ///
56    /// `allow_hyphen_values` because the arguments being passed through belong
57    /// to the *server's* command line, not to `lev`. Nearly every published MCP
58    /// server is launched as `npx -y <package>`, and without this the `-y` is
59    /// read as an unknown flag of ours and the whole command is rejected.
60    #[arg(long = "arg", allow_hyphen_values = true)]
61    args: Vec<String>,
62    /// Environment variable for the command, as KEY=VALUE (repeatable)
63    #[arg(long = "env")]
64    env: Vec<String>,
65    /// HTTP header as KEY=VALUE (repeatable)
66    #[arg(long = "header")]
67    headers: Vec<String>,
68    /// Add the server without attempting a login, even if it needs auth
69    #[arg(long)]
70    no_login: bool,
71}
72
73#[derive(Args)]
74struct ListArgs {
75    /// Emit JSON instead of a table
76    #[arg(long)]
77    json: bool,
78}
79
80#[derive(Args)]
81struct RemoveArgs {
82    /// Server name
83    name: String,
84}
85
86#[derive(Args)]
87struct ServerArg {
88    /// Server name
89    name: String,
90}
91
92/// Seams the real I/O of `lev mcp` depends on, injected so the command logic is
93/// unit-testable without a browser, real config, or the real home directory.
94pub struct McpEnv {
95    /// Path to the config file to read and rewrite.
96    pub config_path: std::path::PathBuf,
97    /// Path to the OAuth token store.
98    pub store_path: std::path::PathBuf,
99    /// How to open the browser during a login.
100    pub opener: leviath_mcp::BrowserOpener,
101    /// Current Unix time, for token-expiry math.
102    pub now: u64,
103    /// The global Rhai script-tools directory (`<leviath-home>/tools/`). `lev mcp
104    /// list` also surfaces these tools (labeled `script`) so the listing covers
105    /// every external tool provider, not only MCP servers. `None`
106    /// disables the script scan (used by tests that only care about servers).
107    pub tools_dir: Option<std::path::PathBuf>,
108    /// Where OAuth grants are kept, already resolved. `lev mcp login` writes a
109    /// refresh token, so it has to write it where the user asked for it to be
110    /// kept.
111    ///
112    /// Resolved by the caller rather than here, and *before* any subcommand
113    /// runs: a keychain that was asked for but cannot be reached has to fail the
114    /// command outright, because falling back to the file would put a refresh
115    /// token on disk that the user asked to keep out of it. Doing that once at
116    /// the edge also means these code paths carry no error arm that only an
117    /// unreachable keychain could take.
118    pub credential_store: Option<Box<dyn leviath_core::CredentialStore>>,
119    /// `[security] allow_env_vars`: which credential-shaped variables an MCP
120    /// server's `${VAR}` headers may interpolate.
121    pub allow_env_vars: Vec<String>,
122}
123
124/// Run a `lev mcp` subcommand against the injected environment.
125pub async fn execute_with(args: McpArgs, env: &McpEnv) -> anyhow::Result<()> {
126    match args.command {
127        McpCommand::Add(add) => add_server(add, env).await,
128        McpCommand::List(list) => list_servers(list, env),
129        McpCommand::Remove(remove) => remove_server(remove, env),
130        McpCommand::Login(server) => login(&server.name, env).await,
131        McpCommand::Logout(server) => logout(&server.name, env),
132        McpCommand::Test(server) => test(&server.name, env).await,
133    }
134}
135
136/// Parse `KEY=VALUE` pairs, erroring on a missing `=`.
137fn parse_kv(pairs: &[String], what: &str) -> anyhow::Result<Vec<(String, String)>> {
138    pairs
139        .iter()
140        .map(|pair| {
141            pair.split_once('=')
142                .map(|(k, v)| (k.to_string(), v.to_string()))
143                .ok_or_else(|| anyhow::anyhow!("{what} must be KEY=VALUE, got '{pair}'"))
144        })
145        .collect()
146}
147
148/// Build the `MCPServerConfig` an `add` describes, validating the transport.
149fn config_from_add(add: &AddArgs) -> anyhow::Result<MCPServerConfig> {
150    let env = parse_kv(&add.env, "--env")?.into_iter().collect();
151    let headers = parse_kv(&add.headers, "--header")?.into_iter().collect();
152    let server = MCPServerConfig {
153        name: add.name.clone(),
154        command: add.command.clone(),
155        url: add.url.clone(),
156        args: add.args.clone(),
157        env,
158        headers,
159        transport: None,
160    };
161    // Reject an ambiguous or incomplete transport before writing it.
162    server.validate()?;
163    Ok(server)
164}
165
166async fn add_server(add: AddArgs, env: &McpEnv) -> anyhow::Result<()> {
167    let server = config_from_add(&add)?;
168    // `config_from_add` already validated the transport, so resolving here
169    // cannot fail.
170    let is_http = matches!(
171        server.resolve().expect("validated in config_from_add"),
172        leviath_mcp::ResolvedTransport::Http { .. }
173    );
174
175    let mut config = Config::load_from_path_public(&env.config_path)?;
176    if config.mcp_servers.iter().any(|s| s.name == server.name) {
177        anyhow::bail!(
178            "an MCP server named '{}' already exists; remove it first",
179            server.name
180        );
181    }
182    config.mcp_servers.push(server.clone());
183    config.save_to_path_public(&env.config_path)?;
184    println!("Added MCP server '{}'.", server.name);
185
186    // Auto-login for an HTTP server that isn't opted out - this is what makes
187    // `add` a one-step setup for an authenticated server.
188    if is_http && !add.no_login {
189        match login(&server.name, env).await {
190            Ok(()) => {}
191            Err(e) => {
192                // The server is saved; a failed login is recoverable with
193                // `lev mcp login`, so don't unwind the add.
194                println!("Could not complete login now ({e}).");
195                println!("Run `lev mcp login {}` to try again.", server.name);
196            }
197        }
198    }
199    Ok(())
200}
201
202async fn login(name: &str, env: &McpEnv) -> anyhow::Result<()> {
203    let config = Config::load_from_path_public(&env.config_path)?;
204    let server = find_server(&config, name)?;
205    // A loaded config's entries are validated at load, so this resolves.
206    let url = match server
207        .resolve()
208        .expect("config entries are validated at load")
209    {
210        leviath_mcp::ResolvedTransport::Http { url, .. } => url.to_string(),
211        leviath_mcp::ResolvedTransport::Stdio { .. } => {
212            anyhow::bail!("server '{name}' uses stdio transport and does not require login");
213        }
214    };
215
216    let mut store = AuthStore::load_with(&env.store_path, env.credential_store.as_deref())?;
217    // Reuse a prior registration if we have one, so re-login doesn't re-register.
218    let reuse = store.get(name).map(|a| a.client_id.clone());
219    let outcome = OAuthClient::new()
220        .login(
221            &url,
222            &server.headers,
223            &env.allow_env_vars,
224            env.opener.clone(),
225            env.now,
226            reuse.as_deref(),
227        )
228        .await?;
229    match outcome {
230        LoginOutcome::Authenticated(auth) => {
231            store.set(name, *auth);
232            store.save_with(&env.store_path, env.credential_store.as_deref())?;
233            println!("✓ Authenticated with '{name}'.");
234        }
235        LoginOutcome::NotRequired => {
236            println!("'{name}' does not need a login: it accepted the configured request.");
237        }
238    }
239    Ok(())
240}
241
242fn logout(name: &str, env: &McpEnv) -> anyhow::Result<()> {
243    let mut store = AuthStore::load_with(&env.store_path, env.credential_store.as_deref())?;
244    if store.remove(name) {
245        store.save_with(&env.store_path, env.credential_store.as_deref())?;
246        println!("Removed stored credentials for '{name}'.");
247    } else {
248        println!("No stored credentials for '{name}'.");
249    }
250    Ok(())
251}
252
253fn remove_server(remove: RemoveArgs, env: &McpEnv) -> anyhow::Result<()> {
254    let mut config = Config::load_from_path_public(&env.config_path)?;
255    let before = config.mcp_servers.len();
256    config.mcp_servers.retain(|s| s.name != remove.name);
257    if config.mcp_servers.len() == before {
258        anyhow::bail!("no MCP server named '{}'", remove.name);
259    }
260    config.save_to_path_public(&env.config_path)?;
261    // Drop any stored credentials too, so a removed server leaves nothing behind.
262    let mut store = AuthStore::load_with(&env.store_path, env.credential_store.as_deref())?;
263    if store.remove(&remove.name) {
264        store.save_with(&env.store_path, env.credential_store.as_deref())?;
265    }
266    println!("Removed MCP server '{}'.", remove.name);
267    Ok(())
268}
269
270async fn test(name: &str, env: &McpEnv) -> anyhow::Result<()> {
271    let config = Config::load_from_path_public(&env.config_path)?;
272    let server = find_server(&config, name)?;
273    let auth_header = OAuthClient::new()
274        .authorization_header(name, &env.store_path, env.now)
275        .await?;
276    let mut client =
277        MCPClient::from_config_with_auth(server, auth_header, &env.allow_env_vars).await?;
278    client.connect().await?;
279    let tools = client.list_tools().await?;
280    println!("✓ '{name}' connected · {} tool(s):", tools.len());
281    for tool in &tools {
282        println!("  - {}", tool.name);
283    }
284    // `shutdown` swallows subprocess errors by design, so it never fails.
285    let _ = client.shutdown().await;
286    Ok(())
287}
288
289fn list_servers(list: ListArgs, env: &McpEnv) -> anyhow::Result<()> {
290    let config = Config::load_from_path_public(&env.config_path)?;
291    let store = AuthStore::load_with(&env.store_path, env.credential_store.as_deref())?;
292
293    let mut rows: Vec<ServerRow> = config
294        .mcp_servers
295        .iter()
296        .map(|s| ServerRow::describe(s, &store, env.now))
297        .collect();
298    // Also surface the global Rhai script tools (labeled `script`) so the listing
299    // covers every external tool provider, not just MCP servers (issue #97).
300    rows.extend(script_tool_rows(env.tools_dir.as_deref()));
301
302    if list.json {
303        // `ServerRow` is plain data; serialization is infallible.
304        let json = serde_json::to_string_pretty(&rows).expect("ServerRow serializes");
305        println!("{json}");
306    } else if rows.is_empty() {
307        println!("No MCP servers configured. Add one with `lev mcp add`.");
308    } else {
309        for row in &rows {
310            println!(
311                "{}\t{}\t{}\t{}\t{}",
312                row.kind, row.name, row.transport, row.auth, row.endpoint
313            );
314        }
315    }
316    Ok(())
317}
318
319/// The `script`-kind rows for `lev mcp list`: one per compiled global script
320/// tool. A `None`/absent tools dir yields no rows.
321fn script_tool_rows(tools_dir: Option<&std::path::Path>) -> Vec<ServerRow> {
322    let dirs: Vec<std::path::PathBuf> = tools_dir
323        .map(std::path::Path::to_path_buf)
324        .into_iter()
325        .collect();
326    let (set, _skipped) = leviath_scripting::ScriptToolSet::discover(&dirs);
327    let endpoint = tools_dir
328        .map(|d| d.display().to_string())
329        .unwrap_or_default();
330    let mut metas = set.metas();
331    metas.sort_by(|a, b| a.name.cmp(&b.name));
332    metas
333        .into_iter()
334        // Only tools the platform can actually load (the daemon's own gate), so
335        // the listing reflects what's really usable.
336        .filter(|m| crate::daemon::spawn::current_platform_satisfies(&m.required_caps))
337        .map(|m| ServerRow {
338            kind: "script".to_string(),
339            name: m.name,
340            transport: "rhai".to_string(),
341            endpoint: endpoint.clone(),
342            auth: "n/a".to_string(),
343        })
344        .collect()
345}
346
347/// One row of `lev mcp list`, also the JSON shape. `kind` is `mcp` for a
348/// configured server or `script` for a discovered Rhai script tool.
349#[derive(serde::Serialize)]
350struct ServerRow {
351    kind: String,
352    name: String,
353    transport: String,
354    endpoint: String,
355    auth: String,
356}
357
358impl ServerRow {
359    fn describe(server: &MCPServerConfig, store: &AuthStore, now: u64) -> Self {
360        // A malformed entry still lists - with its problem shown - rather than
361        // being hidden.
362        let (transport, endpoint) = match server.resolve() {
363            Ok(leviath_mcp::ResolvedTransport::Stdio { command, .. }) => {
364                ("stdio".to_string(), command.to_string())
365            }
366            Ok(leviath_mcp::ResolvedTransport::Http { url, .. }) => {
367                ("http".to_string(), url.to_string())
368            }
369            Err(_) => ("invalid".to_string(), String::new()),
370        };
371        let auth = auth_status(server, store, now);
372        Self {
373            kind: "mcp".to_string(),
374            name: server.name.clone(),
375            transport,
376            endpoint,
377            auth,
378        }
379    }
380}
381
382/// A one-word description of a server's auth state, for display.
383fn auth_status(server: &MCPServerConfig, store: &AuthStore, now: u64) -> String {
384    let is_http = matches!(
385        server.resolve(),
386        Ok(leviath_mcp::ResolvedTransport::Http { .. })
387    );
388    if !is_http {
389        return "n/a".to_string();
390    }
391    match store.get(&server.name) {
392        Some(auth) if auth.is_expired_at(now) => "expired".to_string(),
393        Some(_) => "authenticated".to_string(),
394        // A configured `Authorization` header is a credential too. Calling it
395        // "none" reads as "log in here", which is the prompt that sends people
396        // into an OAuth flow their server never wanted.
397        None if server.has_auth_header() => "header".to_string(),
398        None => "none".to_string(),
399    }
400}
401
402/// Look up a configured server by name.
403fn find_server<'a>(config: &'a Config, name: &str) -> anyhow::Result<&'a MCPServerConfig> {
404    config
405        .mcp_servers
406        .iter()
407        .find(|s| s.name == name)
408        .ok_or_else(|| anyhow::anyhow!("no MCP server named '{name}'"))
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    fn env_at(
416        dir: &std::path::Path,
417        opener: impl Fn(&str) -> bool + Send + Sync + 'static,
418        now: u64,
419    ) -> McpEnv {
420        McpEnv {
421            config_path: dir.join("config.toml"),
422            store_path: dir.join("mcp-auth.json"),
423            opener: std::sync::Arc::new(opener),
424            now,
425            // Default: no script scan, so server-focused tests stay hermetic. The
426            // script-row path has its own dedicated test with a seeded dir.
427            tools_dir: None,
428            credential_store: None,
429            allow_env_vars: Vec::new(),
430        }
431    }
432
433    fn never_opens(_: &str) -> bool {
434        false
435    }
436
437    fn add_args(name: &str, url: Option<&str>, command: Option<&str>) -> AddArgs {
438        AddArgs {
439            name: name.to_string(),
440            url: url.map(String::from),
441            command: command.map(String::from),
442            args: vec![],
443            env: vec![],
444            headers: vec![],
445            no_login: true,
446        }
447    }
448
449    // ─── parse_kv ─────────────────────────────────────────────────────────
450
451    #[test]
452    fn parse_kv_splits_pairs() {
453        let pairs = parse_kv(&["A=1".to_string(), "B=x=y".to_string()], "--env").unwrap();
454        assert_eq!(
455            pairs,
456            vec![("A".into(), "1".into()), ("B".into(), "x=y".into())]
457        );
458    }
459
460    #[test]
461    fn parse_kv_rejects_a_missing_equals() {
462        let err = parse_kv(&["bad".to_string()], "--header").expect_err("no = must fail");
463        assert!(
464            err.to_string().contains("--header must be KEY=VALUE"),
465            "got: {err}"
466        );
467    }
468
469    // ─── config_from_add ──────────────────────────────────────────────────
470
471    #[test]
472    fn config_from_add_builds_an_http_server() {
473        let mut add = add_args("remote", Some("https://e.com/mcp"), None);
474        add.headers = vec!["Authorization=Bearer x".to_string()];
475        let server = config_from_add(&add).unwrap();
476        assert_eq!(server.url.as_deref(), Some("https://e.com/mcp"));
477        assert_eq!(server.headers.get("Authorization").unwrap(), "Bearer x");
478    }
479
480    #[test]
481    fn config_from_add_rejects_an_ambiguous_transport() {
482        let add = add_args("x", Some("https://e.com"), Some("npx"));
483        let err = config_from_add(&add).expect_err("both url and command must fail");
484        assert!(err.to_string().contains("transport"), "got: {err}");
485    }
486
487    #[test]
488    fn config_from_add_propagates_a_bad_env_pair() {
489        let mut add = add_args("x", None, Some("npx"));
490        add.env = vec!["NOEQUALS".to_string()];
491        assert!(config_from_add(&add).is_err());
492    }
493
494    // ─── add / list / remove (no network) ─────────────────────────────────
495
496    #[tokio::test]
497    async fn add_writes_a_stdio_server_and_list_shows_it() {
498        let dir = tempfile::tempdir().unwrap();
499        let env = env_at(dir.path(), never_opens, 0);
500        execute_with(
501            McpArgs {
502                command: McpCommand::Add(add_args("local", None, Some("npx"))),
503            },
504            &env,
505        )
506        .await
507        .unwrap();
508
509        let config = Config::load_from_path_public(&env.config_path).unwrap();
510        assert_eq!(config.mcp_servers.len(), 1);
511        assert_eq!(config.mcp_servers[0].command.as_deref(), Some("npx"));
512
513        // list (json) reports it as a stdio server needing no auth.
514        list_servers(ListArgs { json: true }, &env).unwrap();
515        let rows: Vec<ServerRow> = vec![ServerRow::describe(
516            &config.mcp_servers[0],
517            &AuthStore::default(),
518            0,
519        )];
520        assert_eq!(rows[0].transport, "stdio");
521        assert_eq!(rows[0].auth, "n/a");
522    }
523
524    #[tokio::test]
525    async fn add_rejects_a_duplicate_name() {
526        let dir = tempfile::tempdir().unwrap();
527        let env = env_at(dir.path(), never_opens, 0);
528        let mk = || McpArgs {
529            command: McpCommand::Add(add_args("dup", None, Some("npx"))),
530        };
531        execute_with(mk(), &env).await.unwrap();
532        let err = execute_with(mk(), &env).await.expect_err("dup must fail");
533        assert!(err.to_string().contains("already exists"), "got: {err}");
534    }
535
536    #[tokio::test]
537    async fn remove_deletes_the_server_and_its_credentials() {
538        let dir = tempfile::tempdir().unwrap();
539        let env = env_at(dir.path(), never_opens, 0);
540        execute_with(
541            McpArgs {
542                command: McpCommand::Add(add_args("gone", Some("https://e.com/mcp"), None)),
543            },
544            &env,
545        )
546        .await
547        .unwrap();
548        // Seed a credential to prove removal clears it too.
549        let mut store = AuthStore::default();
550        store.set("gone", leviath_mcp::ServerAuth::default());
551        store.save(&env.store_path).unwrap();
552
553        execute_with(
554            McpArgs {
555                command: McpCommand::Remove(RemoveArgs {
556                    name: "gone".to_string(),
557                }),
558            },
559            &env,
560        )
561        .await
562        .unwrap();
563
564        let config = Config::load_from_path_public(&env.config_path).unwrap();
565        assert!(config.mcp_servers.is_empty());
566        assert!(
567            AuthStore::load(&env.store_path)
568                .unwrap()
569                .get("gone")
570                .is_none()
571        );
572    }
573
574    #[tokio::test]
575    async fn remove_without_stored_credentials_still_removes_the_server() {
576        let dir = tempfile::tempdir().unwrap();
577        let env = env_at(dir.path(), never_opens, 0);
578        execute_with(
579            McpArgs {
580                command: McpCommand::Add(add_args("plain", None, Some("npx"))),
581            },
582            &env,
583        )
584        .await
585        .unwrap();
586        // No credentials were ever stored, so removal skips the store write.
587        remove_server(
588            RemoveArgs {
589                name: "plain".to_string(),
590            },
591            &env,
592        )
593        .unwrap();
594        assert!(
595            Config::load_from_path_public(&env.config_path)
596                .unwrap()
597                .mcp_servers
598                .is_empty()
599        );
600    }
601
602    #[tokio::test]
603    async fn remove_of_an_unknown_server_errors() {
604        let dir = tempfile::tempdir().unwrap();
605        let env = env_at(dir.path(), never_opens, 0);
606        let err = execute_with(
607            McpArgs {
608                command: McpCommand::Remove(RemoveArgs {
609                    name: "ghost".to_string(),
610                }),
611            },
612            &env,
613        )
614        .await
615        .expect_err("removing a missing server must fail");
616        assert!(
617            err.to_string().contains("no MCP server named"),
618            "got: {err}"
619        );
620    }
621
622    #[tokio::test]
623    async fn list_of_nothing_is_friendly() {
624        let dir = tempfile::tempdir().unwrap();
625        let env = env_at(dir.path(), never_opens, 0);
626        // No config file yet; list must still succeed with an empty result -
627        // routed through execute_with to cover the List dispatch arm.
628        execute_with(
629            McpArgs {
630                command: McpCommand::List(ListArgs { json: false }),
631            },
632            &env,
633        )
634        .await
635        .unwrap();
636    }
637
638    #[tokio::test]
639    async fn list_prints_a_table_row_per_server() {
640        let dir = tempfile::tempdir().unwrap();
641        let env = env_at(dir.path(), never_opens, 0);
642        execute_with(
643            McpArgs {
644                command: McpCommand::Add(add_args("local", None, Some("npx"))),
645            },
646            &env,
647        )
648        .await
649        .unwrap();
650        // Non-JSON list with a server present: the table branch.
651        execute_with(
652            McpArgs {
653                command: McpCommand::List(ListArgs { json: false }),
654            },
655            &env,
656        )
657        .await
658        .unwrap();
659    }
660
661    // ─── logout ───────────────────────────────────────────────────────────
662
663    #[tokio::test]
664    async fn logout_removes_stored_credentials() {
665        let dir = tempfile::tempdir().unwrap();
666        let env = env_at(dir.path(), never_opens, 0);
667        let mut store = AuthStore::default();
668        store.set("srv", leviath_mcp::ServerAuth::default());
669        store.save(&env.store_path).unwrap();
670
671        execute_with(
672            McpArgs {
673                command: McpCommand::Logout(ServerArg {
674                    name: "srv".to_string(),
675                }),
676            },
677            &env,
678        )
679        .await
680        .unwrap();
681        assert!(
682            AuthStore::load(&env.store_path)
683                .unwrap()
684                .get("srv")
685                .is_none()
686        );
687    }
688
689    #[test]
690    fn logout_of_an_unauthenticated_server_is_a_noop() {
691        let dir = tempfile::tempdir().unwrap();
692        let env = env_at(dir.path(), never_opens, 0);
693        logout("srv", &env).unwrap();
694    }
695
696    // ─── login guards ─────────────────────────────────────────────────────
697
698    #[tokio::test]
699    async fn login_of_an_unknown_server_errors() {
700        let dir = tempfile::tempdir().unwrap();
701        let env = env_at(dir.path(), never_opens, 0);
702        let err = login("nope", &env)
703            .await
704            .expect_err("unknown server must fail");
705        assert!(
706            err.to_string().contains("no MCP server named"),
707            "got: {err}"
708        );
709    }
710
711    #[tokio::test]
712    async fn login_of_a_stdio_server_is_rejected() {
713        let dir = tempfile::tempdir().unwrap();
714        let env = env_at(dir.path(), never_opens, 0);
715        execute_with(
716            McpArgs {
717                command: McpCommand::Add(add_args("local", None, Some("npx"))),
718            },
719            &env,
720        )
721        .await
722        .unwrap();
723        // Through execute_with to cover the Login dispatch arm.
724        let err = execute_with(
725            McpArgs {
726                command: McpCommand::Login(ServerArg {
727                    name: "local".to_string(),
728                }),
729            },
730            &env,
731        )
732        .await
733        .expect_err("stdio login must fail");
734        assert!(
735            err.to_string().contains("does not require login"),
736            "got: {err}"
737        );
738    }
739
740    // ─── auth_status / ServerRow for the HTTP + token states ──────────────
741
742    #[test]
743    fn auth_status_reports_each_state() {
744        let http = MCPServerConfig::http("s", "https://e.com/mcp");
745        let mut store = AuthStore::default();
746        assert_eq!(auth_status(&http, &store, 0), "none");
747
748        store.set(
749            "s",
750            leviath_mcp::ServerAuth {
751                expires_at: 10_000,
752                ..Default::default()
753            },
754        );
755        assert_eq!(auth_status(&http, &store, 1_000), "authenticated");
756        assert_eq!(auth_status(&http, &store, 20_000), "expired");
757
758        let stdio = MCPServerConfig::stdio("s", "npx", vec![]);
759        assert_eq!(auth_status(&stdio, &store, 0), "n/a");
760    }
761
762    // ─── auto-login on add, and `test`, against real mock servers ─────────
763
764    use axum::extract::State;
765    use axum::http::StatusCode;
766    use axum::routing::{get, post};
767    use axum::{Json, Router};
768
769    /// A standards-correct mock authorization server + MCP endpoint, enough for
770    /// the CLI's add→login→store round trip. Returns its base URL.
771    async fn mock_server() -> String {
772        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
773        let base = format!("http://{}", listener.local_addr().unwrap());
774        let state = base.clone();
775        let app = Router::new()
776            .route(
777                "/mcp",
778                post(|State(base): State<String>| async move {
779                    let hint = format!(
780                        "Bearer resource_metadata=\"{base}/.well-known/oauth-protected-resource\""
781                    );
782                    (
783                        StatusCode::UNAUTHORIZED,
784                        [(reqwest::header::WWW_AUTHENTICATE, hint)],
785                    )
786                }),
787            )
788            .route(
789                "/.well-known/oauth-protected-resource",
790                get(|State(base): State<String>| async move {
791                    Json(serde_json::json!({
792                        "resource": format!("{base}/mcp"),
793                        "authorization_servers": [base],
794                    }))
795                }),
796            )
797            .route(
798                "/.well-known/oauth-authorization-server",
799                get(|State(base): State<String>| async move {
800                    Json(serde_json::json!({
801                        "issuer": base,
802                        "authorization_endpoint": format!("{base}/authorize"),
803                        "token_endpoint": format!("{base}/token"),
804                        "registration_endpoint": format!("{base}/register"),
805                        "scopes_supported": ["openid"],
806                    }))
807                }),
808            )
809            .route(
810                "/register",
811                post(|| async { Json(serde_json::json!({ "client_id": "cli-client" })) }),
812            )
813            .route(
814                "/token",
815                post(|| async {
816                    Json(serde_json::json!({
817                        "access_token": "cli-access",
818                        "refresh_token": "cli-refresh",
819                        "expires_in": 3600,
820                    }))
821                }),
822            )
823            .with_state(state);
824        tokio::spawn(std::future::IntoFuture::into_future(axum::serve(
825            listener, app,
826        )));
827        base
828    }
829
830    /// A browser stub that consents by GETting the loopback callback itself.
831    fn auto_consent(authorize_url: &str) -> bool {
832        let url = reqwest::Url::parse(authorize_url).unwrap();
833        let params: std::collections::HashMap<_, _> = url.query_pairs().into_owned().collect();
834        let redirect = params["redirect_uri"].clone();
835        let state = params["state"].clone();
836        tokio::spawn(async move {
837            let cb = format!("{redirect}?code=cli-code&state={state}");
838            let _ = reqwest::Client::new().get(&cb).send().await;
839        });
840        true
841    }
842
843    /// An MCP endpoint that takes its own API token and publishes no OAuth
844    /// metadata whatsoever, which is what a server like GitHub's looks like once
845    /// you have configured a header for it.
846    async fn mock_token_authenticated_server() -> String {
847        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
848        let base = format!("http://{}", listener.local_addr().unwrap());
849        // Answers everything, and publishes no OAuth metadata at all, so a login
850        // that goes looking for some fails loudly. That the header is what earns
851        // the `200` is `leviath-mcp`'s test to make; this one is about what the
852        // CLI does with the answer.
853        let app = Router::new().route("/mcp", post(|| async { StatusCode::OK }));
854        tokio::spawn(std::future::IntoFuture::into_future(axum::serve(
855            listener, app,
856        )));
857        base
858    }
859
860    /// Adding a header-authenticated server must not chase an OAuth login it
861    /// cannot complete. Before this, `add` printed a discovery 404 and told the
862    /// user to run `lev mcp login`, which then printed the same 404 forever.
863    #[tokio::test]
864    async fn add_with_an_auth_header_does_not_chase_an_oauth_login() {
865        let base = mock_token_authenticated_server().await;
866        let dir = tempfile::tempdir().unwrap();
867        let env = env_at(dir.path(), auto_consent, 1_000);
868
869        let add = AddArgs {
870            name: "hub".to_string(),
871            url: Some(format!("{base}/mcp")),
872            command: None,
873            args: vec![],
874            env: vec![],
875            headers: vec!["Authorization=Bearer configured-token".to_string()],
876            no_login: false,
877        };
878        execute_with(
879            McpArgs {
880                command: McpCommand::Add(add),
881            },
882            &env,
883        )
884        .await
885        .expect("adding a header-authenticated server must succeed");
886
887        let config = Config::load_from_path_public(&env.config_path).unwrap();
888        assert_eq!(
889            config.mcp_servers[0].headers.get("Authorization").unwrap(),
890            "Bearer configured-token"
891        );
892        // Nothing to store: the header is the credential, and it stays in the
893        // config rather than being duplicated into the OAuth store.
894        let stored = AuthStore::load(&env.store_path).unwrap();
895        assert!(stored.get("hub").is_none());
896
897        // And an explicit login says so rather than failing.
898        login("hub", &env)
899            .await
900            .expect("an explicit login on such a server is a no-op, not an error");
901        assert!(
902            AuthStore::load(&env.store_path)
903                .unwrap()
904                .get("hub")
905                .is_none()
906        );
907
908        // The listing reports the header as the credential it is. "none" here
909        // is what tells a user to go and log in.
910        let store = AuthStore::load(&env.store_path).unwrap();
911        assert_eq!(auth_status(&config.mcp_servers[0], &store, 1_000), "header");
912    }
913
914    #[tokio::test]
915    async fn add_http_server_auto_starts_login_and_stores_the_token() {
916        let base = mock_server().await;
917        let dir = tempfile::tempdir().unwrap();
918        let env = env_at(dir.path(), auto_consent, 1_000);
919
920        // `add` with login enabled (no_login=false).
921        let add = AddArgs {
922            name: "navigator".to_string(),
923            url: Some(format!("{base}/mcp")),
924            command: None,
925            args: vec![],
926            env: vec![],
927            headers: vec![],
928            no_login: false,
929        };
930        execute_with(
931            McpArgs {
932                command: McpCommand::Add(add),
933            },
934            &env,
935        )
936        .await
937        .unwrap();
938
939        // The server is in config and the token landed in the store.
940        let config = Config::load_from_path_public(&env.config_path).unwrap();
941        assert_eq!(config.mcp_servers[0].name, "navigator");
942        let stored = AuthStore::load(&env.store_path).unwrap();
943        assert_eq!(stored.get("navigator").unwrap().access_token, "cli-access");
944        // And no token leaked into the config file.
945        let config_text = std::fs::read_to_string(&env.config_path).unwrap();
946        assert!(
947            !config_text.contains("cli-access"),
948            "token must not be in config"
949        );
950    }
951
952    #[tokio::test]
953    async fn add_http_server_survives_a_failed_login() {
954        // A server whose /mcp probe leads nowhere: the add still persists, and
955        // the command succeeds with a "run login later" message.
956        let dir = tempfile::tempdir().unwrap();
957        let env = env_at(dir.path(), never_opens, 0);
958        let add = AddArgs {
959            name: "remote".to_string(),
960            url: Some("http://127.0.0.1:1/mcp".to_string()),
961            command: None,
962            args: vec![],
963            env: vec![],
964            headers: vec![],
965            no_login: false,
966        };
967        execute_with(
968            McpArgs {
969                command: McpCommand::Add(add),
970            },
971            &env,
972        )
973        .await
974        .expect("add should not fail just because login did");
975        let config = Config::load_from_path_public(&env.config_path).unwrap();
976        assert_eq!(config.mcp_servers.len(), 1, "the server is still saved");
977    }
978
979    #[tokio::test]
980    async fn explicit_login_reuses_a_prior_client_id() {
981        let base = mock_server().await;
982        let dir = tempfile::tempdir().unwrap();
983        let env = env_at(dir.path(), auto_consent, 1_000);
984        execute_with(
985            McpArgs {
986                command: McpCommand::Add(add_args("navigator", Some(&format!("{base}/mcp")), None)),
987            },
988            &env,
989        )
990        .await
991        .unwrap();
992        // First login registers, second reuses the stored client_id.
993        login("navigator", &env).await.unwrap();
994        login("navigator", &env).await.unwrap();
995        let stored = AuthStore::load(&env.store_path).unwrap();
996        assert_eq!(stored.get("navigator").unwrap().client_id, "cli-client");
997    }
998
999    /// A minimal stdio MCP server for the `test` command.
1000    const STUB: &str = r#"
1001import sys, json
1002for line in sys.stdin:
1003    line = line.strip()
1004    if not line: continue
1005    req = json.loads(line); m = req.get("method",""); i = req.get("id")
1006    if m == "initialize":
1007        print(json.dumps({"jsonrpc":"2.0","id":i,"result":{"capabilities":{},"protocolVersion":"2024-11-05"}}), flush=True)
1008    elif m == "tools/list":
1009        print(json.dumps({"jsonrpc":"2.0","id":i,"result":{"tools":[{"name":"ping","inputSchema":{}}]}}), flush=True)
1010"#;
1011
1012    #[tokio::test]
1013    async fn test_command_connects_and_lists_tools() {
1014        let dir = tempfile::tempdir().unwrap();
1015        let env = env_at(dir.path(), never_opens, 0);
1016        let mut add = add_args("local", None, Some("python3"));
1017        add.args = vec!["-c".to_string(), STUB.to_string()];
1018        execute_with(
1019            McpArgs {
1020                command: McpCommand::Add(add),
1021            },
1022            &env,
1023        )
1024        .await
1025        .unwrap();
1026
1027        // Through execute_with to cover the Test dispatch arm.
1028        execute_with(
1029            McpArgs {
1030                command: McpCommand::Test(ServerArg {
1031                    name: "local".to_string(),
1032                }),
1033            },
1034            &env,
1035        )
1036        .await
1037        .expect("test should connect and list tools");
1038    }
1039
1040    #[tokio::test]
1041    async fn test_command_errors_for_an_unknown_server() {
1042        let dir = tempfile::tempdir().unwrap();
1043        let env = env_at(dir.path(), never_opens, 0);
1044        assert!(test("ghost", &env).await.is_err());
1045    }
1046
1047    #[test]
1048    fn server_row_describes_an_http_server() {
1049        let http = MCPServerConfig::http("remote", "https://e.com/mcp");
1050        let row = ServerRow::describe(&http, &AuthStore::default(), 0);
1051        assert_eq!(row.kind, "mcp");
1052        assert_eq!(row.transport, "http");
1053        assert_eq!(row.endpoint, "https://e.com/mcp");
1054        assert_eq!(row.auth, "none");
1055    }
1056
1057    #[test]
1058    fn script_tool_rows_lists_compiled_tools() {
1059        // None → no rows.
1060        assert!(script_tool_rows(None).is_empty());
1061        // A tools dir with two valid + a broken script → two `script` rows,
1062        // sorted by name (the broken one is silently omitted, like the daemon).
1063        let dir = tempfile::tempdir().unwrap();
1064        std::fs::write(dir.path().join("up.rhai"), "// @tool up\nparams.x").unwrap();
1065        std::fs::write(dir.path().join("down.rhai"), "// @tool down\n1").unwrap();
1066        std::fs::write(dir.path().join("bad.rhai"), "no directive\nlet").unwrap();
1067        // A tool requiring an unsatisfiable capability is filtered out (not usable).
1068        std::fs::write(
1069            dir.path().join("gpu.rhai"),
1070            "// @tool gpu\n// @requires gpu\n1",
1071        )
1072        .unwrap();
1073        let rows = script_tool_rows(Some(dir.path()));
1074        assert_eq!(rows.len(), 2, "the gpu tool is filtered out");
1075        assert!(rows.iter().all(|r| r.name != "gpu"));
1076        assert_eq!(rows[0].kind, "script");
1077        assert_eq!(rows[0].name, "down", "sorted by name");
1078        assert_eq!(rows[1].name, "up");
1079        assert_eq!(rows[0].transport, "rhai");
1080        assert_eq!(rows[0].auth, "n/a");
1081        assert!(rows[0].endpoint.contains(dir.path().to_str().unwrap()));
1082    }
1083
1084    #[tokio::test]
1085    async fn list_includes_script_tools_when_tools_dir_set() {
1086        let dir = tempfile::tempdir().unwrap();
1087        let mut env = env_at(dir.path(), never_opens, 0);
1088        // Seed a global tools dir with one script.
1089        let tools = dir.path().join("tools");
1090        std::fs::create_dir(&tools).unwrap();
1091        std::fs::write(tools.join("up.rhai"), "// @tool up\nparams.x").unwrap();
1092        env.tools_dir = Some(tools);
1093        // No MCP servers configured, but the script tool still lists (text + JSON).
1094        list_servers(ListArgs { json: false }, &env).unwrap();
1095        list_servers(ListArgs { json: true }, &env).unwrap();
1096    }
1097
1098    #[test]
1099    fn never_opens_reports_no_browser() {
1100        // The stub opener used where a login should not reach the browser.
1101        assert!(!never_opens("https://x"));
1102    }
1103
1104    // ─── I/O failure arms ─────────────────────────────────────────────────
1105    //
1106    // Each config/store read or write has an error-propagation `?`. A directory
1107    // where a file is expected makes a read fail; a read-only file makes a
1108    // rewrite fail. These drive each arm portably and deterministically.
1109
1110    /// An env whose config and store paths are directories, so reads of them
1111    /// fail.
1112    fn env_with_unreadable_paths(dir: &std::path::Path) -> McpEnv {
1113        let cfg = dir.join("config-dir");
1114        let store = dir.join("store-dir");
1115        std::fs::create_dir(&cfg).unwrap();
1116        std::fs::create_dir(&store).unwrap();
1117        McpEnv {
1118            config_path: cfg,
1119            store_path: store,
1120            opener: std::sync::Arc::new(never_opens),
1121            now: 0,
1122            tools_dir: None,
1123            credential_store: None,
1124            allow_env_vars: Vec::new(),
1125        }
1126    }
1127
1128    /// Seed a config file holding `server`, bypassing the network-touching add.
1129    fn seed_config(env: &McpEnv, server: MCPServerConfig) {
1130        let mut config = Config::default();
1131        config.mcp_servers.push(server);
1132        config.save_to_path_public(&env.config_path).unwrap();
1133    }
1134
1135    /// Seed a store file holding `name`, then make it read-only so a later
1136    /// rewrite fails while reads still succeed.
1137    fn seed_readonly_store(env: &McpEnv, name: &str) {
1138        let mut store = AuthStore::default();
1139        store.set(name, leviath_mcp::ServerAuth::default());
1140        store.save(&env.store_path).unwrap();
1141        let mut perms = std::fs::metadata(&env.store_path).unwrap().permissions();
1142        perms.set_readonly(true);
1143        std::fs::set_permissions(&env.store_path, perms).unwrap();
1144    }
1145
1146    #[tokio::test]
1147    async fn commands_surface_an_unreadable_config() {
1148        let dir = tempfile::tempdir().unwrap();
1149        let env = env_with_unreadable_paths(dir.path());
1150        assert!(
1151            execute_with(
1152                McpArgs {
1153                    command: McpCommand::Add(add_args("x", None, Some("npx")))
1154                },
1155                &env
1156            )
1157            .await
1158            .is_err()
1159        );
1160        assert!(list_servers(ListArgs { json: false }, &env).is_err());
1161        assert!(
1162            remove_server(
1163                RemoveArgs {
1164                    name: "x".to_string()
1165                },
1166                &env
1167            )
1168            .is_err()
1169        );
1170        assert!(login("x", &env).await.is_err());
1171        assert!(test("x", &env).await.is_err());
1172        assert!(logout("x", &env).is_err());
1173    }
1174
1175    #[tokio::test]
1176    async fn add_surfaces_a_bad_header_and_an_unwritable_config() {
1177        let dir = tempfile::tempdir().unwrap();
1178        // Bad --header: config_from_add fails inside add_server (parse_kv arm).
1179        let env = env_at(dir.path(), never_opens, 0);
1180        let mut bad = add_args("x", None, Some("npx"));
1181        bad.headers = vec!["NOEQUALS".to_string()];
1182        assert!(
1183            execute_with(
1184                McpArgs {
1185                    command: McpCommand::Add(bad)
1186                },
1187                &env
1188            )
1189            .await
1190            .is_err()
1191        );
1192
1193        // Unwritable config: parent is a file, so the save cannot create it.
1194        let file = dir.path().join("a-file");
1195        std::fs::write(&file, b"x").unwrap();
1196        let ro_env = McpEnv {
1197            config_path: file.join("config.toml"),
1198            store_path: dir.path().join("s.json"),
1199            opener: std::sync::Arc::new(never_opens),
1200            now: 0,
1201            tools_dir: None,
1202            credential_store: None,
1203            allow_env_vars: Vec::new(),
1204        };
1205        assert!(
1206            execute_with(
1207                McpArgs {
1208                    command: McpCommand::Add(add_args("x", None, Some("npx")))
1209                },
1210                &ro_env
1211            )
1212            .await
1213            .is_err()
1214        );
1215    }
1216
1217    #[tokio::test]
1218    async fn login_surfaces_an_unreadable_store() {
1219        let dir = tempfile::tempdir().unwrap();
1220        let env = env_at(dir.path(), never_opens, 0);
1221        seed_config(
1222            &env,
1223            MCPServerConfig::http("remote", "http://127.0.0.1:1/mcp"),
1224        );
1225        // Config + resolve succeed; the store is a directory, so its load fails
1226        // before any browser flow.
1227        std::fs::create_dir(&env.store_path).unwrap();
1228        assert!(login("remote", &env).await.is_err());
1229    }
1230
1231    #[tokio::test]
1232    async fn remove_surfaces_an_unwritable_config() {
1233        let dir = tempfile::tempdir().unwrap();
1234        let env = env_at(dir.path(), never_opens, 0);
1235        seed_config(&env, MCPServerConfig::stdio("x", "npx", vec![]));
1236        // Make the config file read-only: load reads it, but the rewrite fails.
1237        let mut perms = std::fs::metadata(&env.config_path).unwrap().permissions();
1238        perms.set_readonly(true);
1239        std::fs::set_permissions(&env.config_path, perms).unwrap();
1240        assert!(
1241            remove_server(
1242                RemoveArgs {
1243                    name: "x".to_string()
1244                },
1245                &env
1246            )
1247            .is_err()
1248        );
1249    }
1250
1251    #[tokio::test]
1252    async fn login_surfaces_an_unwritable_store() {
1253        let base = mock_server().await;
1254        let dir = tempfile::tempdir().unwrap();
1255        let env = env_at(dir.path(), auto_consent, 1_000);
1256        execute_with(
1257            McpArgs {
1258                command: McpCommand::Add(add_args("navigator", Some(&format!("{base}/mcp")), None)),
1259            },
1260            &env,
1261        )
1262        .await
1263        .unwrap();
1264        // Store reads fine (empty) but is read-only, so persisting the token fails.
1265        seed_readonly_store(&env, "other");
1266        assert!(login("navigator", &env).await.is_err());
1267    }
1268
1269    #[tokio::test]
1270    async fn logout_surfaces_an_unwritable_store() {
1271        let dir = tempfile::tempdir().unwrap();
1272        let env = env_at(dir.path(), never_opens, 0);
1273        seed_readonly_store(&env, "srv");
1274        // Load returns the seeded cred (read is allowed), remove is true, but
1275        // the rewrite fails.
1276        assert!(logout("srv", &env).is_err());
1277    }
1278
1279    #[tokio::test]
1280    async fn remove_surfaces_an_unreadable_store() {
1281        let dir = tempfile::tempdir().unwrap();
1282        let env = env_at(dir.path(), never_opens, 0);
1283        seed_config(&env, MCPServerConfig::stdio("x", "npx", vec![]));
1284        // Config load + save succeed; the store is a directory, so its load fails.
1285        std::fs::create_dir(&env.store_path).unwrap();
1286        assert!(
1287            remove_server(
1288                RemoveArgs {
1289                    name: "x".to_string()
1290                },
1291                &env
1292            )
1293            .is_err()
1294        );
1295    }
1296
1297    #[tokio::test]
1298    async fn remove_surfaces_an_unwritable_store() {
1299        let dir = tempfile::tempdir().unwrap();
1300        let env = env_at(dir.path(), never_opens, 0);
1301        seed_config(&env, MCPServerConfig::stdio("x", "npx", vec![]));
1302        seed_readonly_store(&env, "x");
1303        // Config rewrite ok; the store has "x" so remove is true, but the store
1304        // rewrite fails.
1305        assert!(
1306            remove_server(
1307                RemoveArgs {
1308                    name: "x".to_string()
1309                },
1310                &env
1311            )
1312            .is_err()
1313        );
1314    }
1315
1316    #[tokio::test]
1317    async fn list_surfaces_an_unreadable_store() {
1318        let dir = tempfile::tempdir().unwrap();
1319        let env = env_at(dir.path(), never_opens, 0);
1320        seed_config(&env, MCPServerConfig::http("remote", "https://e.com/mcp"));
1321        std::fs::create_dir(&env.store_path).unwrap();
1322        assert!(list_servers(ListArgs { json: false }, &env).is_err());
1323    }
1324
1325    #[tokio::test]
1326    async fn test_surfaces_an_unrefreshable_token() {
1327        let dir = tempfile::tempdir().unwrap();
1328        let env = env_at(dir.path(), never_opens, 1_000);
1329        seed_config(
1330            &env,
1331            MCPServerConfig::http("remote", "http://127.0.0.1:1/mcp"),
1332        );
1333        // An expired token with a dead refresh endpoint: authorization_header
1334        // errors before any connection is attempted.
1335        let mut store = AuthStore::default();
1336        store.set(
1337            "remote",
1338            leviath_mcp::ServerAuth {
1339                token_endpoint: "http://127.0.0.1:1/token".to_string(),
1340                refresh_token: Some("good".to_string()),
1341                expires_at: 1,
1342                ..Default::default()
1343            },
1344        );
1345        store.save(&env.store_path).unwrap();
1346        assert!(test("remote", &env).await.is_err());
1347    }
1348
1349    #[tokio::test]
1350    async fn test_surfaces_a_spawn_failure() {
1351        let dir = tempfile::tempdir().unwrap();
1352        let env = env_at(dir.path(), never_opens, 0);
1353        seed_config(
1354            &env,
1355            MCPServerConfig::stdio("x", "definitely-not-a-real-binary-xyz", vec![]),
1356        );
1357        // Auth resolves to None (stdio), then from_config's spawn fails.
1358        assert!(test("x", &env).await.is_err());
1359    }
1360
1361    #[tokio::test]
1362    async fn test_surfaces_a_connect_failure() {
1363        let dir = tempfile::tempdir().unwrap();
1364        let env = env_at(dir.path(), never_opens, 0);
1365        seed_config(
1366            &env,
1367            MCPServerConfig::http("remote", "http://127.0.0.1:1/mcp"),
1368        );
1369        // The transport builds, but connecting to a dead port fails.
1370        assert!(test("remote", &env).await.is_err());
1371    }
1372
1373    #[tokio::test]
1374    async fn test_surfaces_a_list_tools_failure() {
1375        // A stdio server that answers initialize but errors tools/list.
1376        let dir = tempfile::tempdir().unwrap();
1377        let env = env_at(dir.path(), never_opens, 0);
1378        let stub = r#"
1379import sys, json
1380for line in sys.stdin:
1381    line = line.strip()
1382    if not line: continue
1383    req = json.loads(line); m = req.get("method",""); i = req.get("id")
1384    if m == "initialize":
1385        print(json.dumps({"jsonrpc":"2.0","id":i,"result":{"capabilities":{},"protocolVersion":"2024-11-05"}}), flush=True)
1386    elif m == "tools/list":
1387        print(json.dumps({"jsonrpc":"2.0","id":i,"error":{"code":-32603,"message":"boom"}}), flush=True)
1388"#;
1389        seed_config(
1390            &env,
1391            MCPServerConfig::stdio("x", "python3", vec!["-c".to_string(), stub.to_string()]),
1392        );
1393        assert!(test("x", &env).await.is_err());
1394    }
1395
1396    #[test]
1397    fn server_row_marks_an_invalid_entry() {
1398        // Neither command nor url → invalid, but still listed.
1399        let bad = MCPServerConfig {
1400            name: "broken".to_string(),
1401            ..Default::default()
1402        };
1403        let row = ServerRow::describe(&bad, &AuthStore::default(), 0);
1404        assert_eq!(row.transport, "invalid");
1405        assert_eq!(row.auth, "n/a");
1406    }
1407}