1use clap::{Args, Subcommand};
7
8use crate::config::Config;
9use leviath_mcp::{AuthStore, MCPClient, MCPServerConfig, OAuthClient};
10
11#[derive(Args)]
13pub struct McpArgs {
14 #[command(subcommand)]
15 command: McpCommand,
16}
17
18impl McpArgs {
19 #[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(AddArgs),
32 List(ListArgs),
34 Remove(RemoveArgs),
36 Login(ServerArg),
38 Logout(ServerArg),
40 Test(ServerArg),
42}
43
44#[derive(Args)]
45struct AddArgs {
46 name: String,
48 #[arg(long)]
50 url: Option<String>,
51 #[arg(long)]
53 command: Option<String>,
54 #[arg(long = "arg")]
56 args: Vec<String>,
57 #[arg(long = "env")]
59 env: Vec<String>,
60 #[arg(long = "header")]
62 headers: Vec<String>,
63 #[arg(long)]
65 no_login: bool,
66}
67
68#[derive(Args)]
69struct ListArgs {
70 #[arg(long)]
72 json: bool,
73}
74
75#[derive(Args)]
76struct RemoveArgs {
77 name: String,
79}
80
81#[derive(Args)]
82struct ServerArg {
83 name: String,
85}
86
87pub struct McpEnv {
90 pub config_path: std::path::PathBuf,
92 pub store_path: std::path::PathBuf,
94 pub opener: leviath_mcp::BrowserOpener,
96 pub now: u64,
98 pub tools_dir: Option<std::path::PathBuf>,
103 pub credential_store: Option<Box<dyn leviath_core::CredentialStore>>,
114 pub allow_env_vars: Vec<String>,
117}
118
119pub 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
131fn 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
143fn 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 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 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 if is_http && !add.no_login {
184 match login(&server.name, env).await {
185 Ok(()) => {}
186 Err(e) => {
187 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 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 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 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 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 rows.extend(script_tool_rows(env.tools_dir.as_deref()));
288
289 if list.json {
290 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
306fn 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 .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#[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 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
369fn 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
385fn 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 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 #[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 #[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 #[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_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 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 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 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 execute_with(
635 McpArgs {
636 command: McpCommand::List(ListArgs { json: false }),
637 },
638 &env,
639 )
640 .await
641 .unwrap();
642 }
643
644 #[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 #[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 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 #[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 use axum::extract::State;
748 use axum::http::StatusCode;
749 use axum::routing::{get, post};
750 use axum::{Json, Router};
751
752 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 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 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 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 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 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 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 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 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 assert!(script_tool_rows(None).is_empty());
973 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 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 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 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 assert!(!never_opens("https://x"));
1014 }
1015
1016 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 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 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 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 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 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 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 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 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 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 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 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 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 assert!(test("remote", &env).await.is_err());
1283 }
1284
1285 #[tokio::test]
1286 async fn test_surfaces_a_list_tools_failure() {
1287 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 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}