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