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