1use 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 #[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(AddArgs),
31 List(ListArgs),
33 Remove(RemoveArgs),
35 Login(ServerArg),
37 Logout(ServerArg),
39 Test(ServerArg),
41}
42
43#[derive(Args)]
44struct AddArgs {
45 name: String,
47 #[arg(long)]
49 url: Option<String>,
50 #[arg(long)]
52 command: Option<String>,
53 #[arg(long = "arg")]
55 args: Vec<String>,
56 #[arg(long = "env")]
58 env: Vec<String>,
59 #[arg(long = "header")]
61 headers: Vec<String>,
62 #[arg(long)]
64 no_login: bool,
65}
66
67#[derive(Args)]
68struct ListArgs {
69 #[arg(long)]
71 json: bool,
72}
73
74#[derive(Args)]
75struct RemoveArgs {
76 name: String,
78}
79
80#[derive(Args)]
81struct ServerArg {
82 name: String,
84}
85
86pub struct McpEnv {
89 pub config_path: std::path::PathBuf,
91 pub store_path: std::path::PathBuf,
93 pub opener: leviath_mcp::BrowserOpener,
95 pub now: u64,
97 pub tools_dir: Option<std::path::PathBuf>,
102 pub credential_store: Option<Box<dyn leviath_core::CredentialStore>>,
113 pub allow_env_vars: Vec<String>,
116}
117
118pub 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
130fn 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
142fn 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 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 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 if is_http && !add.no_login {
183 match login(&server.name, env).await {
184 Ok(()) => {}
185 Err(e) => {
186 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 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 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 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 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 rows.extend(script_tool_rows(env.tools_dir.as_deref()));
287
288 if list.json {
289 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
305fn 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 .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#[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 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
368fn 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
384fn 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 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 #[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 #[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 #[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_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 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 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 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 execute_with(
634 McpArgs {
635 command: McpCommand::List(ListArgs { json: false }),
636 },
637 &env,
638 )
639 .await
640 .unwrap();
641 }
642
643 #[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 #[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 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 #[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 use axum::extract::State;
747 use axum::http::StatusCode;
748 use axum::routing::{get, post};
749 use axum::{Json, Router};
750
751 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 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 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 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 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 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 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 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 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 assert!(script_tool_rows(None).is_empty());
972 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 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 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 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 assert!(!never_opens("https://x"));
1013 }
1014
1015 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 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 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 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 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 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 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 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 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 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 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 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 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 assert!(test("remote", &env).await.is_err());
1282 }
1283
1284 #[tokio::test]
1285 async fn test_surfaces_a_list_tools_failure() {
1286 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 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}