1use clap::{Args, Subcommand};
7
8use crate::config::Config;
9use leviath_mcp::{AuthStore, LoginOutcome, 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", allow_hyphen_values = true)]
61 args: Vec<String>,
62 #[arg(long = "env")]
64 env: Vec<String>,
65 #[arg(long = "header")]
67 headers: Vec<String>,
68 #[arg(long)]
70 no_login: bool,
71}
72
73#[derive(Args)]
74struct ListArgs {
75 #[arg(long)]
77 json: bool,
78}
79
80#[derive(Args)]
81struct RemoveArgs {
82 name: String,
84}
85
86#[derive(Args)]
87struct ServerArg {
88 name: String,
90}
91
92pub struct McpEnv {
95 pub config_path: std::path::PathBuf,
97 pub store_path: std::path::PathBuf,
99 pub opener: leviath_mcp::BrowserOpener,
101 pub now: u64,
103 pub tools_dir: Option<std::path::PathBuf>,
108 pub credential_store: Option<Box<dyn leviath_core::CredentialStore>>,
119 pub allow_env_vars: Vec<String>,
122}
123
124pub async fn execute_with(args: McpArgs, env: &McpEnv) -> anyhow::Result<()> {
126 match args.command {
127 McpCommand::Add(add) => add_server(add, env).await,
128 McpCommand::List(list) => list_servers(list, env),
129 McpCommand::Remove(remove) => remove_server(remove, env),
130 McpCommand::Login(server) => login(&server.name, env).await,
131 McpCommand::Logout(server) => logout(&server.name, env),
132 McpCommand::Test(server) => test(&server.name, env).await,
133 }
134}
135
136fn parse_kv(pairs: &[String], what: &str) -> anyhow::Result<Vec<(String, String)>> {
138 pairs
139 .iter()
140 .map(|pair| {
141 pair.split_once('=')
142 .map(|(k, v)| (k.to_string(), v.to_string()))
143 .ok_or_else(|| anyhow::anyhow!("{what} must be KEY=VALUE, got '{pair}'"))
144 })
145 .collect()
146}
147
148fn config_from_add(add: &AddArgs) -> anyhow::Result<MCPServerConfig> {
150 let env = parse_kv(&add.env, "--env")?.into_iter().collect();
151 let headers = parse_kv(&add.headers, "--header")?.into_iter().collect();
152 let server = MCPServerConfig {
153 name: add.name.clone(),
154 command: add.command.clone(),
155 url: add.url.clone(),
156 args: add.args.clone(),
157 env,
158 headers,
159 transport: None,
160 };
161 server.validate()?;
163 Ok(server)
164}
165
166async fn add_server(add: AddArgs, env: &McpEnv) -> anyhow::Result<()> {
167 let server = config_from_add(&add)?;
168 let is_http = matches!(
171 server.resolve().expect("validated in config_from_add"),
172 leviath_mcp::ResolvedTransport::Http { .. }
173 );
174
175 let mut config = Config::load_from_path_public(&env.config_path)?;
176 if config.mcp_servers.iter().any(|s| s.name == server.name) {
177 anyhow::bail!(
178 "an MCP server named '{}' already exists; remove it first",
179 server.name
180 );
181 }
182 config.mcp_servers.push(server.clone());
183 config.save_to_path_public(&env.config_path)?;
184 println!("Added MCP server '{}'.", server.name);
185
186 if is_http && !add.no_login {
189 match login(&server.name, env).await {
190 Ok(()) => {}
191 Err(e) => {
192 println!("Could not complete login now ({e}).");
195 println!("Run `lev mcp login {}` to try again.", server.name);
196 }
197 }
198 }
199 Ok(())
200}
201
202async fn login(name: &str, env: &McpEnv) -> anyhow::Result<()> {
203 let config = Config::load_from_path_public(&env.config_path)?;
204 let server = find_server(&config, name)?;
205 let url = match server
207 .resolve()
208 .expect("config entries are validated at load")
209 {
210 leviath_mcp::ResolvedTransport::Http { url, .. } => url.to_string(),
211 leviath_mcp::ResolvedTransport::Stdio { .. } => {
212 anyhow::bail!("server '{name}' uses stdio transport and does not require login");
213 }
214 };
215
216 let mut store = AuthStore::load_with(&env.store_path, env.credential_store.as_deref())?;
217 let reuse = store.get(name).map(|a| a.client_id.clone());
219 let outcome = OAuthClient::new()
220 .login(
221 &url,
222 &server.headers,
223 &env.allow_env_vars,
224 env.opener.clone(),
225 env.now,
226 reuse.as_deref(),
227 )
228 .await?;
229 match outcome {
230 LoginOutcome::Authenticated(auth) => {
231 store.set(name, *auth);
232 store.save_with(&env.store_path, env.credential_store.as_deref())?;
233 println!("✓ Authenticated with '{name}'.");
234 }
235 LoginOutcome::NotRequired => {
236 println!("'{name}' does not need a login: it accepted the configured request.");
237 }
238 }
239 Ok(())
240}
241
242fn logout(name: &str, env: &McpEnv) -> anyhow::Result<()> {
243 let mut store = AuthStore::load_with(&env.store_path, env.credential_store.as_deref())?;
244 if store.remove(name) {
245 store.save_with(&env.store_path, env.credential_store.as_deref())?;
246 println!("Removed stored credentials for '{name}'.");
247 } else {
248 println!("No stored credentials for '{name}'.");
249 }
250 Ok(())
251}
252
253fn remove_server(remove: RemoveArgs, env: &McpEnv) -> anyhow::Result<()> {
254 let mut config = Config::load_from_path_public(&env.config_path)?;
255 let before = config.mcp_servers.len();
256 config.mcp_servers.retain(|s| s.name != remove.name);
257 if config.mcp_servers.len() == before {
258 anyhow::bail!("no MCP server named '{}'", remove.name);
259 }
260 config.save_to_path_public(&env.config_path)?;
261 let mut store = AuthStore::load_with(&env.store_path, env.credential_store.as_deref())?;
263 if store.remove(&remove.name) {
264 store.save_with(&env.store_path, env.credential_store.as_deref())?;
265 }
266 println!("Removed MCP server '{}'.", remove.name);
267 Ok(())
268}
269
270async fn test(name: &str, env: &McpEnv) -> anyhow::Result<()> {
271 let config = Config::load_from_path_public(&env.config_path)?;
272 let server = find_server(&config, name)?;
273 let auth_header = OAuthClient::new()
274 .authorization_header(name, &env.store_path, env.now)
275 .await?;
276 let mut client =
277 MCPClient::from_config_with_auth(server, auth_header, &env.allow_env_vars).await?;
278 client.connect().await?;
279 let tools = client.list_tools().await?;
280 println!("✓ '{name}' connected · {} tool(s):", tools.len());
281 for tool in &tools {
282 println!(" - {}", tool.name);
283 }
284 let _ = client.shutdown().await;
286 Ok(())
287}
288
289fn list_servers(list: ListArgs, env: &McpEnv) -> anyhow::Result<()> {
290 let config = Config::load_from_path_public(&env.config_path)?;
291 let store = AuthStore::load_with(&env.store_path, env.credential_store.as_deref())?;
292
293 let mut rows: Vec<ServerRow> = config
294 .mcp_servers
295 .iter()
296 .map(|s| ServerRow::describe(s, &store, env.now))
297 .collect();
298 rows.extend(script_tool_rows(env.tools_dir.as_deref()));
301
302 if list.json {
303 let json = serde_json::to_string_pretty(&rows).expect("ServerRow serializes");
305 println!("{json}");
306 } else if rows.is_empty() {
307 println!("No MCP servers configured. Add one with `lev mcp add`.");
308 } else {
309 for row in &rows {
310 println!(
311 "{}\t{}\t{}\t{}\t{}",
312 row.kind, row.name, row.transport, row.auth, row.endpoint
313 );
314 }
315 }
316 Ok(())
317}
318
319fn script_tool_rows(tools_dir: Option<&std::path::Path>) -> Vec<ServerRow> {
322 let dirs: Vec<std::path::PathBuf> = tools_dir
323 .map(std::path::Path::to_path_buf)
324 .into_iter()
325 .collect();
326 let (set, _skipped) = leviath_scripting::ScriptToolSet::discover(&dirs);
327 let endpoint = tools_dir
328 .map(|d| d.display().to_string())
329 .unwrap_or_default();
330 let mut metas = set.metas();
331 metas.sort_by(|a, b| a.name.cmp(&b.name));
332 metas
333 .into_iter()
334 .filter(|m| crate::daemon::spawn::current_platform_satisfies(&m.required_caps))
337 .map(|m| ServerRow {
338 kind: "script".to_string(),
339 name: m.name,
340 transport: "rhai".to_string(),
341 endpoint: endpoint.clone(),
342 auth: "n/a".to_string(),
343 })
344 .collect()
345}
346
347#[derive(serde::Serialize)]
350struct ServerRow {
351 kind: String,
352 name: String,
353 transport: String,
354 endpoint: String,
355 auth: String,
356}
357
358impl ServerRow {
359 fn describe(server: &MCPServerConfig, store: &AuthStore, now: u64) -> Self {
360 let (transport, endpoint) = match server.resolve() {
363 Ok(leviath_mcp::ResolvedTransport::Stdio { command, .. }) => {
364 ("stdio".to_string(), command.to_string())
365 }
366 Ok(leviath_mcp::ResolvedTransport::Http { url, .. }) => {
367 ("http".to_string(), url.to_string())
368 }
369 Err(_) => ("invalid".to_string(), String::new()),
370 };
371 let auth = auth_status(server, store, now);
372 Self {
373 kind: "mcp".to_string(),
374 name: server.name.clone(),
375 transport,
376 endpoint,
377 auth,
378 }
379 }
380}
381
382fn auth_status(server: &MCPServerConfig, store: &AuthStore, now: u64) -> String {
384 let is_http = matches!(
385 server.resolve(),
386 Ok(leviath_mcp::ResolvedTransport::Http { .. })
387 );
388 if !is_http {
389 return "n/a".to_string();
390 }
391 match store.get(&server.name) {
392 Some(auth) if auth.is_expired_at(now) => "expired".to_string(),
393 Some(_) => "authenticated".to_string(),
394 None if server.has_auth_header() => "header".to_string(),
398 None => "none".to_string(),
399 }
400}
401
402fn find_server<'a>(config: &'a Config, name: &str) -> anyhow::Result<&'a MCPServerConfig> {
404 config
405 .mcp_servers
406 .iter()
407 .find(|s| s.name == name)
408 .ok_or_else(|| anyhow::anyhow!("no MCP server named '{name}'"))
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414
415 fn env_at(
416 dir: &std::path::Path,
417 opener: impl Fn(&str) -> bool + Send + Sync + 'static,
418 now: u64,
419 ) -> McpEnv {
420 McpEnv {
421 config_path: dir.join("config.toml"),
422 store_path: dir.join("mcp-auth.json"),
423 opener: std::sync::Arc::new(opener),
424 now,
425 tools_dir: None,
428 credential_store: None,
429 allow_env_vars: Vec::new(),
430 }
431 }
432
433 fn never_opens(_: &str) -> bool {
434 false
435 }
436
437 fn add_args(name: &str, url: Option<&str>, command: Option<&str>) -> AddArgs {
438 AddArgs {
439 name: name.to_string(),
440 url: url.map(String::from),
441 command: command.map(String::from),
442 args: vec![],
443 env: vec![],
444 headers: vec![],
445 no_login: true,
446 }
447 }
448
449 #[test]
452 fn parse_kv_splits_pairs() {
453 let pairs = parse_kv(&["A=1".to_string(), "B=x=y".to_string()], "--env").unwrap();
454 assert_eq!(
455 pairs,
456 vec![("A".into(), "1".into()), ("B".into(), "x=y".into())]
457 );
458 }
459
460 #[test]
461 fn parse_kv_rejects_a_missing_equals() {
462 let err = parse_kv(&["bad".to_string()], "--header").expect_err("no = must fail");
463 assert!(
464 err.to_string().contains("--header must be KEY=VALUE"),
465 "got: {err}"
466 );
467 }
468
469 #[test]
472 fn config_from_add_builds_an_http_server() {
473 let mut add = add_args("remote", Some("https://e.com/mcp"), None);
474 add.headers = vec!["Authorization=Bearer x".to_string()];
475 let server = config_from_add(&add).unwrap();
476 assert_eq!(server.url.as_deref(), Some("https://e.com/mcp"));
477 assert_eq!(server.headers.get("Authorization").unwrap(), "Bearer x");
478 }
479
480 #[test]
481 fn config_from_add_rejects_an_ambiguous_transport() {
482 let add = add_args("x", Some("https://e.com"), Some("npx"));
483 let err = config_from_add(&add).expect_err("both url and command must fail");
484 assert!(err.to_string().contains("transport"), "got: {err}");
485 }
486
487 #[test]
488 fn config_from_add_propagates_a_bad_env_pair() {
489 let mut add = add_args("x", None, Some("npx"));
490 add.env = vec!["NOEQUALS".to_string()];
491 assert!(config_from_add(&add).is_err());
492 }
493
494 #[tokio::test]
497 async fn add_writes_a_stdio_server_and_list_shows_it() {
498 let dir = tempfile::tempdir().unwrap();
499 let env = env_at(dir.path(), never_opens, 0);
500 execute_with(
501 McpArgs {
502 command: McpCommand::Add(add_args("local", None, Some("npx"))),
503 },
504 &env,
505 )
506 .await
507 .unwrap();
508
509 let config = Config::load_from_path_public(&env.config_path).unwrap();
510 assert_eq!(config.mcp_servers.len(), 1);
511 assert_eq!(config.mcp_servers[0].command.as_deref(), Some("npx"));
512
513 list_servers(ListArgs { json: true }, &env).unwrap();
515 let rows: Vec<ServerRow> = vec![ServerRow::describe(
516 &config.mcp_servers[0],
517 &AuthStore::default(),
518 0,
519 )];
520 assert_eq!(rows[0].transport, "stdio");
521 assert_eq!(rows[0].auth, "n/a");
522 }
523
524 #[tokio::test]
525 async fn add_rejects_a_duplicate_name() {
526 let dir = tempfile::tempdir().unwrap();
527 let env = env_at(dir.path(), never_opens, 0);
528 let mk = || McpArgs {
529 command: McpCommand::Add(add_args("dup", None, Some("npx"))),
530 };
531 execute_with(mk(), &env).await.unwrap();
532 let err = execute_with(mk(), &env).await.expect_err("dup must fail");
533 assert!(err.to_string().contains("already exists"), "got: {err}");
534 }
535
536 #[tokio::test]
537 async fn remove_deletes_the_server_and_its_credentials() {
538 let dir = tempfile::tempdir().unwrap();
539 let env = env_at(dir.path(), never_opens, 0);
540 execute_with(
541 McpArgs {
542 command: McpCommand::Add(add_args("gone", Some("https://e.com/mcp"), None)),
543 },
544 &env,
545 )
546 .await
547 .unwrap();
548 let mut store = AuthStore::default();
550 store.set("gone", leviath_mcp::ServerAuth::default());
551 store.save(&env.store_path).unwrap();
552
553 execute_with(
554 McpArgs {
555 command: McpCommand::Remove(RemoveArgs {
556 name: "gone".to_string(),
557 }),
558 },
559 &env,
560 )
561 .await
562 .unwrap();
563
564 let config = Config::load_from_path_public(&env.config_path).unwrap();
565 assert!(config.mcp_servers.is_empty());
566 assert!(
567 AuthStore::load(&env.store_path)
568 .unwrap()
569 .get("gone")
570 .is_none()
571 );
572 }
573
574 #[tokio::test]
575 async fn remove_without_stored_credentials_still_removes_the_server() {
576 let dir = tempfile::tempdir().unwrap();
577 let env = env_at(dir.path(), never_opens, 0);
578 execute_with(
579 McpArgs {
580 command: McpCommand::Add(add_args("plain", None, Some("npx"))),
581 },
582 &env,
583 )
584 .await
585 .unwrap();
586 remove_server(
588 RemoveArgs {
589 name: "plain".to_string(),
590 },
591 &env,
592 )
593 .unwrap();
594 assert!(
595 Config::load_from_path_public(&env.config_path)
596 .unwrap()
597 .mcp_servers
598 .is_empty()
599 );
600 }
601
602 #[tokio::test]
603 async fn remove_of_an_unknown_server_errors() {
604 let dir = tempfile::tempdir().unwrap();
605 let env = env_at(dir.path(), never_opens, 0);
606 let err = execute_with(
607 McpArgs {
608 command: McpCommand::Remove(RemoveArgs {
609 name: "ghost".to_string(),
610 }),
611 },
612 &env,
613 )
614 .await
615 .expect_err("removing a missing server must fail");
616 assert!(
617 err.to_string().contains("no MCP server named"),
618 "got: {err}"
619 );
620 }
621
622 #[tokio::test]
623 async fn list_of_nothing_is_friendly() {
624 let dir = tempfile::tempdir().unwrap();
625 let env = env_at(dir.path(), never_opens, 0);
626 execute_with(
629 McpArgs {
630 command: McpCommand::List(ListArgs { json: false }),
631 },
632 &env,
633 )
634 .await
635 .unwrap();
636 }
637
638 #[tokio::test]
639 async fn list_prints_a_table_row_per_server() {
640 let dir = tempfile::tempdir().unwrap();
641 let env = env_at(dir.path(), never_opens, 0);
642 execute_with(
643 McpArgs {
644 command: McpCommand::Add(add_args("local", None, Some("npx"))),
645 },
646 &env,
647 )
648 .await
649 .unwrap();
650 execute_with(
652 McpArgs {
653 command: McpCommand::List(ListArgs { json: false }),
654 },
655 &env,
656 )
657 .await
658 .unwrap();
659 }
660
661 #[tokio::test]
664 async fn logout_removes_stored_credentials() {
665 let dir = tempfile::tempdir().unwrap();
666 let env = env_at(dir.path(), never_opens, 0);
667 let mut store = AuthStore::default();
668 store.set("srv", leviath_mcp::ServerAuth::default());
669 store.save(&env.store_path).unwrap();
670
671 execute_with(
672 McpArgs {
673 command: McpCommand::Logout(ServerArg {
674 name: "srv".to_string(),
675 }),
676 },
677 &env,
678 )
679 .await
680 .unwrap();
681 assert!(
682 AuthStore::load(&env.store_path)
683 .unwrap()
684 .get("srv")
685 .is_none()
686 );
687 }
688
689 #[test]
690 fn logout_of_an_unauthenticated_server_is_a_noop() {
691 let dir = tempfile::tempdir().unwrap();
692 let env = env_at(dir.path(), never_opens, 0);
693 logout("srv", &env).unwrap();
694 }
695
696 #[tokio::test]
699 async fn login_of_an_unknown_server_errors() {
700 let dir = tempfile::tempdir().unwrap();
701 let env = env_at(dir.path(), never_opens, 0);
702 let err = login("nope", &env)
703 .await
704 .expect_err("unknown server must fail");
705 assert!(
706 err.to_string().contains("no MCP server named"),
707 "got: {err}"
708 );
709 }
710
711 #[tokio::test]
712 async fn login_of_a_stdio_server_is_rejected() {
713 let dir = tempfile::tempdir().unwrap();
714 let env = env_at(dir.path(), never_opens, 0);
715 execute_with(
716 McpArgs {
717 command: McpCommand::Add(add_args("local", None, Some("npx"))),
718 },
719 &env,
720 )
721 .await
722 .unwrap();
723 let err = execute_with(
725 McpArgs {
726 command: McpCommand::Login(ServerArg {
727 name: "local".to_string(),
728 }),
729 },
730 &env,
731 )
732 .await
733 .expect_err("stdio login must fail");
734 assert!(
735 err.to_string().contains("does not require login"),
736 "got: {err}"
737 );
738 }
739
740 #[test]
743 fn auth_status_reports_each_state() {
744 let http = MCPServerConfig::http("s", "https://e.com/mcp");
745 let mut store = AuthStore::default();
746 assert_eq!(auth_status(&http, &store, 0), "none");
747
748 store.set(
749 "s",
750 leviath_mcp::ServerAuth {
751 expires_at: 10_000,
752 ..Default::default()
753 },
754 );
755 assert_eq!(auth_status(&http, &store, 1_000), "authenticated");
756 assert_eq!(auth_status(&http, &store, 20_000), "expired");
757
758 let stdio = MCPServerConfig::stdio("s", "npx", vec![]);
759 assert_eq!(auth_status(&stdio, &store, 0), "n/a");
760 }
761
762 use axum::extract::State;
765 use axum::http::StatusCode;
766 use axum::routing::{get, post};
767 use axum::{Json, Router};
768
769 async fn mock_server() -> String {
772 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
773 let base = format!("http://{}", listener.local_addr().unwrap());
774 let state = base.clone();
775 let app = Router::new()
776 .route(
777 "/mcp",
778 post(|State(base): State<String>| async move {
779 let hint = format!(
780 "Bearer resource_metadata=\"{base}/.well-known/oauth-protected-resource\""
781 );
782 (
783 StatusCode::UNAUTHORIZED,
784 [(reqwest::header::WWW_AUTHENTICATE, hint)],
785 )
786 }),
787 )
788 .route(
789 "/.well-known/oauth-protected-resource",
790 get(|State(base): State<String>| async move {
791 Json(serde_json::json!({
792 "resource": format!("{base}/mcp"),
793 "authorization_servers": [base],
794 }))
795 }),
796 )
797 .route(
798 "/.well-known/oauth-authorization-server",
799 get(|State(base): State<String>| async move {
800 Json(serde_json::json!({
801 "issuer": base,
802 "authorization_endpoint": format!("{base}/authorize"),
803 "token_endpoint": format!("{base}/token"),
804 "registration_endpoint": format!("{base}/register"),
805 "scopes_supported": ["openid"],
806 }))
807 }),
808 )
809 .route(
810 "/register",
811 post(|| async { Json(serde_json::json!({ "client_id": "cli-client" })) }),
812 )
813 .route(
814 "/token",
815 post(|| async {
816 Json(serde_json::json!({
817 "access_token": "cli-access",
818 "refresh_token": "cli-refresh",
819 "expires_in": 3600,
820 }))
821 }),
822 )
823 .with_state(state);
824 tokio::spawn(std::future::IntoFuture::into_future(axum::serve(
825 listener, app,
826 )));
827 base
828 }
829
830 fn auto_consent(authorize_url: &str) -> bool {
832 let url = reqwest::Url::parse(authorize_url).unwrap();
833 let params: std::collections::HashMap<_, _> = url.query_pairs().into_owned().collect();
834 let redirect = params["redirect_uri"].clone();
835 let state = params["state"].clone();
836 tokio::spawn(async move {
837 let cb = format!("{redirect}?code=cli-code&state={state}");
838 let _ = reqwest::Client::new().get(&cb).send().await;
839 });
840 true
841 }
842
843 async fn mock_token_authenticated_server() -> String {
847 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
848 let base = format!("http://{}", listener.local_addr().unwrap());
849 let app = Router::new().route("/mcp", post(|| async { StatusCode::OK }));
854 tokio::spawn(std::future::IntoFuture::into_future(axum::serve(
855 listener, app,
856 )));
857 base
858 }
859
860 #[tokio::test]
864 async fn add_with_an_auth_header_does_not_chase_an_oauth_login() {
865 let base = mock_token_authenticated_server().await;
866 let dir = tempfile::tempdir().unwrap();
867 let env = env_at(dir.path(), auto_consent, 1_000);
868
869 let add = AddArgs {
870 name: "hub".to_string(),
871 url: Some(format!("{base}/mcp")),
872 command: None,
873 args: vec![],
874 env: vec![],
875 headers: vec!["Authorization=Bearer configured-token".to_string()],
876 no_login: false,
877 };
878 execute_with(
879 McpArgs {
880 command: McpCommand::Add(add),
881 },
882 &env,
883 )
884 .await
885 .expect("adding a header-authenticated server must succeed");
886
887 let config = Config::load_from_path_public(&env.config_path).unwrap();
888 assert_eq!(
889 config.mcp_servers[0].headers.get("Authorization").unwrap(),
890 "Bearer configured-token"
891 );
892 let stored = AuthStore::load(&env.store_path).unwrap();
895 assert!(stored.get("hub").is_none());
896
897 login("hub", &env)
899 .await
900 .expect("an explicit login on such a server is a no-op, not an error");
901 assert!(
902 AuthStore::load(&env.store_path)
903 .unwrap()
904 .get("hub")
905 .is_none()
906 );
907
908 let store = AuthStore::load(&env.store_path).unwrap();
911 assert_eq!(auth_status(&config.mcp_servers[0], &store, 1_000), "header");
912 }
913
914 #[tokio::test]
915 async fn add_http_server_auto_starts_login_and_stores_the_token() {
916 let base = mock_server().await;
917 let dir = tempfile::tempdir().unwrap();
918 let env = env_at(dir.path(), auto_consent, 1_000);
919
920 let add = AddArgs {
922 name: "navigator".to_string(),
923 url: Some(format!("{base}/mcp")),
924 command: None,
925 args: vec![],
926 env: vec![],
927 headers: vec![],
928 no_login: false,
929 };
930 execute_with(
931 McpArgs {
932 command: McpCommand::Add(add),
933 },
934 &env,
935 )
936 .await
937 .unwrap();
938
939 let config = Config::load_from_path_public(&env.config_path).unwrap();
941 assert_eq!(config.mcp_servers[0].name, "navigator");
942 let stored = AuthStore::load(&env.store_path).unwrap();
943 assert_eq!(stored.get("navigator").unwrap().access_token, "cli-access");
944 let config_text = std::fs::read_to_string(&env.config_path).unwrap();
946 assert!(
947 !config_text.contains("cli-access"),
948 "token must not be in config"
949 );
950 }
951
952 #[tokio::test]
953 async fn add_http_server_survives_a_failed_login() {
954 let dir = tempfile::tempdir().unwrap();
957 let env = env_at(dir.path(), never_opens, 0);
958 let add = AddArgs {
959 name: "remote".to_string(),
960 url: Some("http://127.0.0.1:1/mcp".to_string()),
961 command: None,
962 args: vec![],
963 env: vec![],
964 headers: vec![],
965 no_login: false,
966 };
967 execute_with(
968 McpArgs {
969 command: McpCommand::Add(add),
970 },
971 &env,
972 )
973 .await
974 .expect("add should not fail just because login did");
975 let config = Config::load_from_path_public(&env.config_path).unwrap();
976 assert_eq!(config.mcp_servers.len(), 1, "the server is still saved");
977 }
978
979 #[tokio::test]
980 async fn explicit_login_reuses_a_prior_client_id() {
981 let base = mock_server().await;
982 let dir = tempfile::tempdir().unwrap();
983 let env = env_at(dir.path(), auto_consent, 1_000);
984 execute_with(
985 McpArgs {
986 command: McpCommand::Add(add_args("navigator", Some(&format!("{base}/mcp")), None)),
987 },
988 &env,
989 )
990 .await
991 .unwrap();
992 login("navigator", &env).await.unwrap();
994 login("navigator", &env).await.unwrap();
995 let stored = AuthStore::load(&env.store_path).unwrap();
996 assert_eq!(stored.get("navigator").unwrap().client_id, "cli-client");
997 }
998
999 const STUB: &str = r#"
1001import sys, json
1002for line in sys.stdin:
1003 line = line.strip()
1004 if not line: continue
1005 req = json.loads(line); m = req.get("method",""); i = req.get("id")
1006 if m == "initialize":
1007 print(json.dumps({"jsonrpc":"2.0","id":i,"result":{"capabilities":{},"protocolVersion":"2024-11-05"}}), flush=True)
1008 elif m == "tools/list":
1009 print(json.dumps({"jsonrpc":"2.0","id":i,"result":{"tools":[{"name":"ping","inputSchema":{}}]}}), flush=True)
1010"#;
1011
1012 #[tokio::test]
1013 async fn test_command_connects_and_lists_tools() {
1014 let dir = tempfile::tempdir().unwrap();
1015 let env = env_at(dir.path(), never_opens, 0);
1016 let mut add = add_args("local", None, Some("python3"));
1017 add.args = vec!["-c".to_string(), STUB.to_string()];
1018 execute_with(
1019 McpArgs {
1020 command: McpCommand::Add(add),
1021 },
1022 &env,
1023 )
1024 .await
1025 .unwrap();
1026
1027 execute_with(
1029 McpArgs {
1030 command: McpCommand::Test(ServerArg {
1031 name: "local".to_string(),
1032 }),
1033 },
1034 &env,
1035 )
1036 .await
1037 .expect("test should connect and list tools");
1038 }
1039
1040 #[tokio::test]
1041 async fn test_command_errors_for_an_unknown_server() {
1042 let dir = tempfile::tempdir().unwrap();
1043 let env = env_at(dir.path(), never_opens, 0);
1044 assert!(test("ghost", &env).await.is_err());
1045 }
1046
1047 #[test]
1048 fn server_row_describes_an_http_server() {
1049 let http = MCPServerConfig::http("remote", "https://e.com/mcp");
1050 let row = ServerRow::describe(&http, &AuthStore::default(), 0);
1051 assert_eq!(row.kind, "mcp");
1052 assert_eq!(row.transport, "http");
1053 assert_eq!(row.endpoint, "https://e.com/mcp");
1054 assert_eq!(row.auth, "none");
1055 }
1056
1057 #[test]
1058 fn script_tool_rows_lists_compiled_tools() {
1059 assert!(script_tool_rows(None).is_empty());
1061 let dir = tempfile::tempdir().unwrap();
1064 std::fs::write(dir.path().join("up.rhai"), "// @tool up\nparams.x").unwrap();
1065 std::fs::write(dir.path().join("down.rhai"), "// @tool down\n1").unwrap();
1066 std::fs::write(dir.path().join("bad.rhai"), "no directive\nlet").unwrap();
1067 std::fs::write(
1069 dir.path().join("gpu.rhai"),
1070 "// @tool gpu\n// @requires gpu\n1",
1071 )
1072 .unwrap();
1073 let rows = script_tool_rows(Some(dir.path()));
1074 assert_eq!(rows.len(), 2, "the gpu tool is filtered out");
1075 assert!(rows.iter().all(|r| r.name != "gpu"));
1076 assert_eq!(rows[0].kind, "script");
1077 assert_eq!(rows[0].name, "down", "sorted by name");
1078 assert_eq!(rows[1].name, "up");
1079 assert_eq!(rows[0].transport, "rhai");
1080 assert_eq!(rows[0].auth, "n/a");
1081 assert!(rows[0].endpoint.contains(dir.path().to_str().unwrap()));
1082 }
1083
1084 #[tokio::test]
1085 async fn list_includes_script_tools_when_tools_dir_set() {
1086 let dir = tempfile::tempdir().unwrap();
1087 let mut env = env_at(dir.path(), never_opens, 0);
1088 let tools = dir.path().join("tools");
1090 std::fs::create_dir(&tools).unwrap();
1091 std::fs::write(tools.join("up.rhai"), "// @tool up\nparams.x").unwrap();
1092 env.tools_dir = Some(tools);
1093 list_servers(ListArgs { json: false }, &env).unwrap();
1095 list_servers(ListArgs { json: true }, &env).unwrap();
1096 }
1097
1098 #[test]
1099 fn never_opens_reports_no_browser() {
1100 assert!(!never_opens("https://x"));
1102 }
1103
1104 fn env_with_unreadable_paths(dir: &std::path::Path) -> McpEnv {
1113 let cfg = dir.join("config-dir");
1114 let store = dir.join("store-dir");
1115 std::fs::create_dir(&cfg).unwrap();
1116 std::fs::create_dir(&store).unwrap();
1117 McpEnv {
1118 config_path: cfg,
1119 store_path: store,
1120 opener: std::sync::Arc::new(never_opens),
1121 now: 0,
1122 tools_dir: None,
1123 credential_store: None,
1124 allow_env_vars: Vec::new(),
1125 }
1126 }
1127
1128 fn seed_config(env: &McpEnv, server: MCPServerConfig) {
1130 let mut config = Config::default();
1131 config.mcp_servers.push(server);
1132 config.save_to_path_public(&env.config_path).unwrap();
1133 }
1134
1135 fn seed_readonly_store(env: &McpEnv, name: &str) {
1138 let mut store = AuthStore::default();
1139 store.set(name, leviath_mcp::ServerAuth::default());
1140 store.save(&env.store_path).unwrap();
1141 let mut perms = std::fs::metadata(&env.store_path).unwrap().permissions();
1142 perms.set_readonly(true);
1143 std::fs::set_permissions(&env.store_path, perms).unwrap();
1144 }
1145
1146 #[tokio::test]
1147 async fn commands_surface_an_unreadable_config() {
1148 let dir = tempfile::tempdir().unwrap();
1149 let env = env_with_unreadable_paths(dir.path());
1150 assert!(
1151 execute_with(
1152 McpArgs {
1153 command: McpCommand::Add(add_args("x", None, Some("npx")))
1154 },
1155 &env
1156 )
1157 .await
1158 .is_err()
1159 );
1160 assert!(list_servers(ListArgs { json: false }, &env).is_err());
1161 assert!(
1162 remove_server(
1163 RemoveArgs {
1164 name: "x".to_string()
1165 },
1166 &env
1167 )
1168 .is_err()
1169 );
1170 assert!(login("x", &env).await.is_err());
1171 assert!(test("x", &env).await.is_err());
1172 assert!(logout("x", &env).is_err());
1173 }
1174
1175 #[tokio::test]
1176 async fn add_surfaces_a_bad_header_and_an_unwritable_config() {
1177 let dir = tempfile::tempdir().unwrap();
1178 let env = env_at(dir.path(), never_opens, 0);
1180 let mut bad = add_args("x", None, Some("npx"));
1181 bad.headers = vec!["NOEQUALS".to_string()];
1182 assert!(
1183 execute_with(
1184 McpArgs {
1185 command: McpCommand::Add(bad)
1186 },
1187 &env
1188 )
1189 .await
1190 .is_err()
1191 );
1192
1193 let file = dir.path().join("a-file");
1195 std::fs::write(&file, b"x").unwrap();
1196 let ro_env = McpEnv {
1197 config_path: file.join("config.toml"),
1198 store_path: dir.path().join("s.json"),
1199 opener: std::sync::Arc::new(never_opens),
1200 now: 0,
1201 tools_dir: None,
1202 credential_store: None,
1203 allow_env_vars: Vec::new(),
1204 };
1205 assert!(
1206 execute_with(
1207 McpArgs {
1208 command: McpCommand::Add(add_args("x", None, Some("npx")))
1209 },
1210 &ro_env
1211 )
1212 .await
1213 .is_err()
1214 );
1215 }
1216
1217 #[tokio::test]
1218 async fn login_surfaces_an_unreadable_store() {
1219 let dir = tempfile::tempdir().unwrap();
1220 let env = env_at(dir.path(), never_opens, 0);
1221 seed_config(
1222 &env,
1223 MCPServerConfig::http("remote", "http://127.0.0.1:1/mcp"),
1224 );
1225 std::fs::create_dir(&env.store_path).unwrap();
1228 assert!(login("remote", &env).await.is_err());
1229 }
1230
1231 #[tokio::test]
1232 async fn remove_surfaces_an_unwritable_config() {
1233 let dir = tempfile::tempdir().unwrap();
1234 let env = env_at(dir.path(), never_opens, 0);
1235 seed_config(&env, MCPServerConfig::stdio("x", "npx", vec![]));
1236 let mut perms = std::fs::metadata(&env.config_path).unwrap().permissions();
1238 perms.set_readonly(true);
1239 std::fs::set_permissions(&env.config_path, perms).unwrap();
1240 assert!(
1241 remove_server(
1242 RemoveArgs {
1243 name: "x".to_string()
1244 },
1245 &env
1246 )
1247 .is_err()
1248 );
1249 }
1250
1251 #[tokio::test]
1252 async fn login_surfaces_an_unwritable_store() {
1253 let base = mock_server().await;
1254 let dir = tempfile::tempdir().unwrap();
1255 let env = env_at(dir.path(), auto_consent, 1_000);
1256 execute_with(
1257 McpArgs {
1258 command: McpCommand::Add(add_args("navigator", Some(&format!("{base}/mcp")), None)),
1259 },
1260 &env,
1261 )
1262 .await
1263 .unwrap();
1264 seed_readonly_store(&env, "other");
1266 assert!(login("navigator", &env).await.is_err());
1267 }
1268
1269 #[tokio::test]
1270 async fn logout_surfaces_an_unwritable_store() {
1271 let dir = tempfile::tempdir().unwrap();
1272 let env = env_at(dir.path(), never_opens, 0);
1273 seed_readonly_store(&env, "srv");
1274 assert!(logout("srv", &env).is_err());
1277 }
1278
1279 #[tokio::test]
1280 async fn remove_surfaces_an_unreadable_store() {
1281 let dir = tempfile::tempdir().unwrap();
1282 let env = env_at(dir.path(), never_opens, 0);
1283 seed_config(&env, MCPServerConfig::stdio("x", "npx", vec![]));
1284 std::fs::create_dir(&env.store_path).unwrap();
1286 assert!(
1287 remove_server(
1288 RemoveArgs {
1289 name: "x".to_string()
1290 },
1291 &env
1292 )
1293 .is_err()
1294 );
1295 }
1296
1297 #[tokio::test]
1298 async fn remove_surfaces_an_unwritable_store() {
1299 let dir = tempfile::tempdir().unwrap();
1300 let env = env_at(dir.path(), never_opens, 0);
1301 seed_config(&env, MCPServerConfig::stdio("x", "npx", vec![]));
1302 seed_readonly_store(&env, "x");
1303 assert!(
1306 remove_server(
1307 RemoveArgs {
1308 name: "x".to_string()
1309 },
1310 &env
1311 )
1312 .is_err()
1313 );
1314 }
1315
1316 #[tokio::test]
1317 async fn list_surfaces_an_unreadable_store() {
1318 let dir = tempfile::tempdir().unwrap();
1319 let env = env_at(dir.path(), never_opens, 0);
1320 seed_config(&env, MCPServerConfig::http("remote", "https://e.com/mcp"));
1321 std::fs::create_dir(&env.store_path).unwrap();
1322 assert!(list_servers(ListArgs { json: false }, &env).is_err());
1323 }
1324
1325 #[tokio::test]
1326 async fn test_surfaces_an_unrefreshable_token() {
1327 let dir = tempfile::tempdir().unwrap();
1328 let env = env_at(dir.path(), never_opens, 1_000);
1329 seed_config(
1330 &env,
1331 MCPServerConfig::http("remote", "http://127.0.0.1:1/mcp"),
1332 );
1333 let mut store = AuthStore::default();
1336 store.set(
1337 "remote",
1338 leviath_mcp::ServerAuth {
1339 token_endpoint: "http://127.0.0.1:1/token".to_string(),
1340 refresh_token: Some("good".to_string()),
1341 expires_at: 1,
1342 ..Default::default()
1343 },
1344 );
1345 store.save(&env.store_path).unwrap();
1346 assert!(test("remote", &env).await.is_err());
1347 }
1348
1349 #[tokio::test]
1350 async fn test_surfaces_a_spawn_failure() {
1351 let dir = tempfile::tempdir().unwrap();
1352 let env = env_at(dir.path(), never_opens, 0);
1353 seed_config(
1354 &env,
1355 MCPServerConfig::stdio("x", "definitely-not-a-real-binary-xyz", vec![]),
1356 );
1357 assert!(test("x", &env).await.is_err());
1359 }
1360
1361 #[tokio::test]
1362 async fn test_surfaces_a_connect_failure() {
1363 let dir = tempfile::tempdir().unwrap();
1364 let env = env_at(dir.path(), never_opens, 0);
1365 seed_config(
1366 &env,
1367 MCPServerConfig::http("remote", "http://127.0.0.1:1/mcp"),
1368 );
1369 assert!(test("remote", &env).await.is_err());
1371 }
1372
1373 #[tokio::test]
1374 async fn test_surfaces_a_list_tools_failure() {
1375 let dir = tempfile::tempdir().unwrap();
1377 let env = env_at(dir.path(), never_opens, 0);
1378 let stub = r#"
1379import sys, json
1380for line in sys.stdin:
1381 line = line.strip()
1382 if not line: continue
1383 req = json.loads(line); m = req.get("method",""); i = req.get("id")
1384 if m == "initialize":
1385 print(json.dumps({"jsonrpc":"2.0","id":i,"result":{"capabilities":{},"protocolVersion":"2024-11-05"}}), flush=True)
1386 elif m == "tools/list":
1387 print(json.dumps({"jsonrpc":"2.0","id":i,"error":{"code":-32603,"message":"boom"}}), flush=True)
1388"#;
1389 seed_config(
1390 &env,
1391 MCPServerConfig::stdio("x", "python3", vec!["-c".to_string(), stub.to_string()]),
1392 );
1393 assert!(test("x", &env).await.is_err());
1394 }
1395
1396 #[test]
1397 fn server_row_marks_an_invalid_entry() {
1398 let bad = MCPServerConfig {
1400 name: "broken".to_string(),
1401 ..Default::default()
1402 };
1403 let row = ServerRow::describe(&bad, &AuthStore::default(), 0);
1404 assert_eq!(row.transport, "invalid");
1405 assert_eq!(row.auth, "n/a");
1406 }
1407}