use clap::{Args, Subcommand};
use crate::config::Config;
use leviath_mcp::{AuthStore, MCPClient, MCPServerConfig, OAuthClient};
#[derive(Args)]
pub struct McpArgs {
#[command(subcommand)]
command: McpCommand,
}
impl McpArgs {
#[cfg(test)]
pub(crate) fn list_for_test() -> Self {
Self {
command: McpCommand::List(ListArgs { json: false }),
}
}
}
#[derive(Subcommand)]
enum McpCommand {
Add(AddArgs),
List(ListArgs),
Remove(RemoveArgs),
Login(ServerArg),
Logout(ServerArg),
Test(ServerArg),
}
#[derive(Args)]
struct AddArgs {
name: String,
#[arg(long)]
url: Option<String>,
#[arg(long)]
command: Option<String>,
#[arg(long = "arg")]
args: Vec<String>,
#[arg(long = "env")]
env: Vec<String>,
#[arg(long = "header")]
headers: Vec<String>,
#[arg(long)]
no_login: bool,
}
#[derive(Args)]
struct ListArgs {
#[arg(long)]
json: bool,
}
#[derive(Args)]
struct RemoveArgs {
name: String,
}
#[derive(Args)]
struct ServerArg {
name: String,
}
pub struct McpEnv {
pub config_path: std::path::PathBuf,
pub store_path: std::path::PathBuf,
pub opener: leviath_mcp::BrowserOpener,
pub now: u64,
pub tools_dir: Option<std::path::PathBuf>,
pub credential_store: Option<Box<dyn leviath_core::CredentialStore>>,
pub allow_env_vars: Vec<String>,
}
pub async fn execute_with(args: McpArgs, env: &McpEnv) -> anyhow::Result<()> {
match args.command {
McpCommand::Add(add) => add_server(add, env).await,
McpCommand::List(list) => list_servers(list, env),
McpCommand::Remove(remove) => remove_server(remove, env),
McpCommand::Login(server) => login(&server.name, env).await,
McpCommand::Logout(server) => logout(&server.name, env),
McpCommand::Test(server) => test(&server.name, env).await,
}
}
fn parse_kv(pairs: &[String], what: &str) -> anyhow::Result<Vec<(String, String)>> {
pairs
.iter()
.map(|pair| {
pair.split_once('=')
.map(|(k, v)| (k.to_string(), v.to_string()))
.ok_or_else(|| anyhow::anyhow!("{what} must be KEY=VALUE, got '{pair}'"))
})
.collect()
}
fn config_from_add(add: &AddArgs) -> anyhow::Result<MCPServerConfig> {
let env = parse_kv(&add.env, "--env")?.into_iter().collect();
let headers = parse_kv(&add.headers, "--header")?.into_iter().collect();
let server = MCPServerConfig {
name: add.name.clone(),
command: add.command.clone(),
url: add.url.clone(),
args: add.args.clone(),
env,
headers,
transport: None,
};
server.validate()?;
Ok(server)
}
async fn add_server(add: AddArgs, env: &McpEnv) -> anyhow::Result<()> {
let server = config_from_add(&add)?;
let is_http = matches!(
server.resolve().expect("validated in config_from_add"),
leviath_mcp::ResolvedTransport::Http { .. }
);
let mut config = Config::load_from_path_public(&env.config_path)?;
if config.mcp_servers.iter().any(|s| s.name == server.name) {
anyhow::bail!(
"an MCP server named '{}' already exists; remove it first",
server.name
);
}
config.mcp_servers.push(server.clone());
config.save_to_path_public(&env.config_path)?;
println!("Added MCP server '{}'.", server.name);
if is_http && !add.no_login {
match login(&server.name, env).await {
Ok(()) => {}
Err(e) => {
println!("Could not complete login now ({e}).");
println!("Run `lev mcp login {}` to try again.", server.name);
}
}
}
Ok(())
}
async fn login(name: &str, env: &McpEnv) -> anyhow::Result<()> {
let config = Config::load_from_path_public(&env.config_path)?;
let server = find_server(&config, name)?;
let url = match server
.resolve()
.expect("config entries are validated at load")
{
leviath_mcp::ResolvedTransport::Http { url, .. } => url.to_string(),
leviath_mcp::ResolvedTransport::Stdio { .. } => {
anyhow::bail!("server '{name}' uses stdio transport and does not require login");
}
};
let mut store = AuthStore::load_with(&env.store_path, env.credential_store.as_deref())?;
let reuse = store.get(name).map(|a| a.client_id.clone());
let auth = OAuthClient::new()
.login(
&url,
&server.headers,
env.opener.clone(),
env.now,
reuse.as_deref(),
)
.await?;
store.set(name, auth);
store.save_with(&env.store_path, env.credential_store.as_deref())?;
println!("✓ Authenticated with '{name}'.");
Ok(())
}
fn logout(name: &str, env: &McpEnv) -> anyhow::Result<()> {
let mut store = AuthStore::load_with(&env.store_path, env.credential_store.as_deref())?;
if store.remove(name) {
store.save_with(&env.store_path, env.credential_store.as_deref())?;
println!("Removed stored credentials for '{name}'.");
} else {
println!("No stored credentials for '{name}'.");
}
Ok(())
}
fn remove_server(remove: RemoveArgs, env: &McpEnv) -> anyhow::Result<()> {
let mut config = Config::load_from_path_public(&env.config_path)?;
let before = config.mcp_servers.len();
config.mcp_servers.retain(|s| s.name != remove.name);
if config.mcp_servers.len() == before {
anyhow::bail!("no MCP server named '{}'", remove.name);
}
config.save_to_path_public(&env.config_path)?;
let mut store = AuthStore::load_with(&env.store_path, env.credential_store.as_deref())?;
if store.remove(&remove.name) {
store.save_with(&env.store_path, env.credential_store.as_deref())?;
}
println!("Removed MCP server '{}'.", remove.name);
Ok(())
}
async fn test(name: &str, env: &McpEnv) -> anyhow::Result<()> {
let config = Config::load_from_path_public(&env.config_path)?;
let server = find_server(&config, name)?;
let auth_header = OAuthClient::new()
.authorization_header(name, &env.store_path, env.now)
.await?;
let mut client =
MCPClient::from_config_with_auth(server, auth_header, &env.allow_env_vars).await?;
client.connect().await?;
let tools = client.list_tools().await?;
println!("✓ '{name}' connected · {} tool(s):", tools.len());
for tool in &tools {
println!(" - {}", tool.name);
}
let _ = client.shutdown().await;
Ok(())
}
fn list_servers(list: ListArgs, env: &McpEnv) -> anyhow::Result<()> {
let config = Config::load_from_path_public(&env.config_path)?;
let store = AuthStore::load_with(&env.store_path, env.credential_store.as_deref())?;
let mut rows: Vec<ServerRow> = config
.mcp_servers
.iter()
.map(|s| ServerRow::describe(s, &store, env.now))
.collect();
rows.extend(script_tool_rows(env.tools_dir.as_deref()));
if list.json {
let json = serde_json::to_string_pretty(&rows).expect("ServerRow serializes");
println!("{json}");
} else if rows.is_empty() {
println!("No MCP servers configured. Add one with `lev mcp add`.");
} else {
for row in &rows {
println!(
"{}\t{}\t{}\t{}\t{}",
row.kind, row.name, row.transport, row.auth, row.endpoint
);
}
}
Ok(())
}
fn script_tool_rows(tools_dir: Option<&std::path::Path>) -> Vec<ServerRow> {
let dirs: Vec<std::path::PathBuf> = tools_dir
.map(std::path::Path::to_path_buf)
.into_iter()
.collect();
let (set, _skipped) = leviath_scripting::ScriptToolSet::discover(&dirs);
let endpoint = tools_dir
.map(|d| d.display().to_string())
.unwrap_or_default();
let mut metas = set.metas();
metas.sort_by(|a, b| a.name.cmp(&b.name));
metas
.into_iter()
.filter(|m| crate::daemon::spawn::current_platform_satisfies(&m.required_caps))
.map(|m| ServerRow {
kind: "script".to_string(),
name: m.name,
transport: "rhai".to_string(),
endpoint: endpoint.clone(),
auth: "n/a".to_string(),
})
.collect()
}
#[derive(serde::Serialize)]
struct ServerRow {
kind: String,
name: String,
transport: String,
endpoint: String,
auth: String,
}
impl ServerRow {
fn describe(server: &MCPServerConfig, store: &AuthStore, now: u64) -> Self {
let (transport, endpoint) = match server.resolve() {
Ok(leviath_mcp::ResolvedTransport::Stdio { command, .. }) => {
("stdio".to_string(), command.to_string())
}
Ok(leviath_mcp::ResolvedTransport::Http { url, .. }) => {
("http".to_string(), url.to_string())
}
Err(_) => ("invalid".to_string(), String::new()),
};
let auth = auth_status(server, store, now);
Self {
kind: "mcp".to_string(),
name: server.name.clone(),
transport,
endpoint,
auth,
}
}
}
fn auth_status(server: &MCPServerConfig, store: &AuthStore, now: u64) -> String {
let is_http = matches!(
server.resolve(),
Ok(leviath_mcp::ResolvedTransport::Http { .. })
);
if !is_http {
return "n/a".to_string();
}
match store.get(&server.name) {
Some(auth) if auth.is_expired_at(now) => "expired".to_string(),
Some(_) => "authenticated".to_string(),
None => "none".to_string(),
}
}
fn find_server<'a>(config: &'a Config, name: &str) -> anyhow::Result<&'a MCPServerConfig> {
config
.mcp_servers
.iter()
.find(|s| s.name == name)
.ok_or_else(|| anyhow::anyhow!("no MCP server named '{name}'"))
}
#[cfg(test)]
mod tests {
use super::*;
fn env_at(
dir: &std::path::Path,
opener: impl Fn(&str) -> bool + Send + Sync + 'static,
now: u64,
) -> McpEnv {
McpEnv {
config_path: dir.join("config.toml"),
store_path: dir.join("mcp-auth.json"),
opener: std::sync::Arc::new(opener),
now,
tools_dir: None,
credential_store: None,
allow_env_vars: Vec::new(),
}
}
fn never_opens(_: &str) -> bool {
false
}
fn add_args(name: &str, url: Option<&str>, command: Option<&str>) -> AddArgs {
AddArgs {
name: name.to_string(),
url: url.map(String::from),
command: command.map(String::from),
args: vec![],
env: vec![],
headers: vec![],
no_login: true,
}
}
#[test]
fn parse_kv_splits_pairs() {
let pairs = parse_kv(&["A=1".to_string(), "B=x=y".to_string()], "--env").unwrap();
assert_eq!(
pairs,
vec![("A".into(), "1".into()), ("B".into(), "x=y".into())]
);
}
#[test]
fn parse_kv_rejects_a_missing_equals() {
let err = parse_kv(&["bad".to_string()], "--header").expect_err("no = must fail");
assert!(
err.to_string().contains("--header must be KEY=VALUE"),
"got: {err}"
);
}
#[test]
fn config_from_add_builds_an_http_server() {
let mut add = add_args("remote", Some("https://e.com/mcp"), None);
add.headers = vec!["Authorization=Bearer x".to_string()];
let server = config_from_add(&add).unwrap();
assert_eq!(server.url.as_deref(), Some("https://e.com/mcp"));
assert_eq!(server.headers.get("Authorization").unwrap(), "Bearer x");
}
#[test]
fn config_from_add_rejects_an_ambiguous_transport() {
let add = add_args("x", Some("https://e.com"), Some("npx"));
let err = config_from_add(&add).expect_err("both url and command must fail");
assert!(err.to_string().contains("transport"), "got: {err}");
}
#[test]
fn config_from_add_propagates_a_bad_env_pair() {
let mut add = add_args("x", None, Some("npx"));
add.env = vec!["NOEQUALS".to_string()];
assert!(config_from_add(&add).is_err());
}
#[tokio::test]
async fn add_writes_a_stdio_server_and_list_shows_it() {
let dir = tempfile::tempdir().unwrap();
let env = env_at(dir.path(), never_opens, 0);
execute_with(
McpArgs {
command: McpCommand::Add(add_args("local", None, Some("npx"))),
},
&env,
)
.await
.unwrap();
let config = Config::load_from_path_public(&env.config_path).unwrap();
assert_eq!(config.mcp_servers.len(), 1);
assert_eq!(config.mcp_servers[0].command.as_deref(), Some("npx"));
list_servers(ListArgs { json: true }, &env).unwrap();
let rows: Vec<ServerRow> = vec![ServerRow::describe(
&config.mcp_servers[0],
&AuthStore::default(),
0,
)];
assert_eq!(rows[0].transport, "stdio");
assert_eq!(rows[0].auth, "n/a");
}
#[tokio::test]
async fn add_rejects_a_duplicate_name() {
let dir = tempfile::tempdir().unwrap();
let env = env_at(dir.path(), never_opens, 0);
let mk = || McpArgs {
command: McpCommand::Add(add_args("dup", None, Some("npx"))),
};
execute_with(mk(), &env).await.unwrap();
let err = execute_with(mk(), &env).await.expect_err("dup must fail");
assert!(err.to_string().contains("already exists"), "got: {err}");
}
#[tokio::test]
async fn remove_deletes_the_server_and_its_credentials() {
let dir = tempfile::tempdir().unwrap();
let env = env_at(dir.path(), never_opens, 0);
execute_with(
McpArgs {
command: McpCommand::Add(add_args("gone", Some("https://e.com/mcp"), None)),
},
&env,
)
.await
.unwrap();
let mut store = AuthStore::default();
store.set("gone", leviath_mcp::ServerAuth::default());
store.save(&env.store_path).unwrap();
execute_with(
McpArgs {
command: McpCommand::Remove(RemoveArgs {
name: "gone".to_string(),
}),
},
&env,
)
.await
.unwrap();
let config = Config::load_from_path_public(&env.config_path).unwrap();
assert!(config.mcp_servers.is_empty());
assert!(
AuthStore::load(&env.store_path)
.unwrap()
.get("gone")
.is_none()
);
}
#[tokio::test]
async fn remove_without_stored_credentials_still_removes_the_server() {
let dir = tempfile::tempdir().unwrap();
let env = env_at(dir.path(), never_opens, 0);
execute_with(
McpArgs {
command: McpCommand::Add(add_args("plain", None, Some("npx"))),
},
&env,
)
.await
.unwrap();
remove_server(
RemoveArgs {
name: "plain".to_string(),
},
&env,
)
.unwrap();
assert!(
Config::load_from_path_public(&env.config_path)
.unwrap()
.mcp_servers
.is_empty()
);
}
#[tokio::test]
async fn remove_of_an_unknown_server_errors() {
let dir = tempfile::tempdir().unwrap();
let env = env_at(dir.path(), never_opens, 0);
let err = execute_with(
McpArgs {
command: McpCommand::Remove(RemoveArgs {
name: "ghost".to_string(),
}),
},
&env,
)
.await
.expect_err("removing a missing server must fail");
assert!(
err.to_string().contains("no MCP server named"),
"got: {err}"
);
}
#[tokio::test]
async fn list_of_nothing_is_friendly() {
let dir = tempfile::tempdir().unwrap();
let env = env_at(dir.path(), never_opens, 0);
execute_with(
McpArgs {
command: McpCommand::List(ListArgs { json: false }),
},
&env,
)
.await
.unwrap();
}
#[tokio::test]
async fn list_prints_a_table_row_per_server() {
let dir = tempfile::tempdir().unwrap();
let env = env_at(dir.path(), never_opens, 0);
execute_with(
McpArgs {
command: McpCommand::Add(add_args("local", None, Some("npx"))),
},
&env,
)
.await
.unwrap();
execute_with(
McpArgs {
command: McpCommand::List(ListArgs { json: false }),
},
&env,
)
.await
.unwrap();
}
#[tokio::test]
async fn logout_removes_stored_credentials() {
let dir = tempfile::tempdir().unwrap();
let env = env_at(dir.path(), never_opens, 0);
let mut store = AuthStore::default();
store.set("srv", leviath_mcp::ServerAuth::default());
store.save(&env.store_path).unwrap();
execute_with(
McpArgs {
command: McpCommand::Logout(ServerArg {
name: "srv".to_string(),
}),
},
&env,
)
.await
.unwrap();
assert!(
AuthStore::load(&env.store_path)
.unwrap()
.get("srv")
.is_none()
);
}
#[test]
fn logout_of_an_unauthenticated_server_is_a_noop() {
let dir = tempfile::tempdir().unwrap();
let env = env_at(dir.path(), never_opens, 0);
logout("srv", &env).unwrap();
}
#[tokio::test]
async fn login_of_an_unknown_server_errors() {
let dir = tempfile::tempdir().unwrap();
let env = env_at(dir.path(), never_opens, 0);
let err = login("nope", &env)
.await
.expect_err("unknown server must fail");
assert!(
err.to_string().contains("no MCP server named"),
"got: {err}"
);
}
#[tokio::test]
async fn login_of_a_stdio_server_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let env = env_at(dir.path(), never_opens, 0);
execute_with(
McpArgs {
command: McpCommand::Add(add_args("local", None, Some("npx"))),
},
&env,
)
.await
.unwrap();
let err = execute_with(
McpArgs {
command: McpCommand::Login(ServerArg {
name: "local".to_string(),
}),
},
&env,
)
.await
.expect_err("stdio login must fail");
assert!(
err.to_string().contains("does not require login"),
"got: {err}"
);
}
#[test]
fn auth_status_reports_each_state() {
let http = MCPServerConfig::http("s", "https://e.com/mcp");
let mut store = AuthStore::default();
assert_eq!(auth_status(&http, &store, 0), "none");
store.set(
"s",
leviath_mcp::ServerAuth {
expires_at: 10_000,
..Default::default()
},
);
assert_eq!(auth_status(&http, &store, 1_000), "authenticated");
assert_eq!(auth_status(&http, &store, 20_000), "expired");
let stdio = MCPServerConfig::stdio("s", "npx", vec![]);
assert_eq!(auth_status(&stdio, &store, 0), "n/a");
}
use axum::extract::State;
use axum::http::StatusCode;
use axum::routing::{get, post};
use axum::{Json, Router};
async fn mock_server() -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let state = base.clone();
let app = Router::new()
.route(
"/mcp",
post(|State(base): State<String>| async move {
let hint = format!(
"Bearer resource_metadata=\"{base}/.well-known/oauth-protected-resource\""
);
(
StatusCode::UNAUTHORIZED,
[(reqwest::header::WWW_AUTHENTICATE, hint)],
)
}),
)
.route(
"/.well-known/oauth-protected-resource",
get(|State(base): State<String>| async move {
Json(serde_json::json!({
"resource": format!("{base}/mcp"),
"authorization_servers": [base],
}))
}),
)
.route(
"/.well-known/oauth-authorization-server",
get(|State(base): State<String>| async move {
Json(serde_json::json!({
"issuer": base,
"authorization_endpoint": format!("{base}/authorize"),
"token_endpoint": format!("{base}/token"),
"registration_endpoint": format!("{base}/register"),
"scopes_supported": ["openid"],
}))
}),
)
.route(
"/register",
post(|| async { Json(serde_json::json!({ "client_id": "cli-client" })) }),
)
.route(
"/token",
post(|| async {
Json(serde_json::json!({
"access_token": "cli-access",
"refresh_token": "cli-refresh",
"expires_in": 3600,
}))
}),
)
.with_state(state);
tokio::spawn(std::future::IntoFuture::into_future(axum::serve(
listener, app,
)));
base
}
fn auto_consent(authorize_url: &str) -> bool {
let url = reqwest::Url::parse(authorize_url).unwrap();
let params: std::collections::HashMap<_, _> = url.query_pairs().into_owned().collect();
let redirect = params["redirect_uri"].clone();
let state = params["state"].clone();
tokio::spawn(async move {
let cb = format!("{redirect}?code=cli-code&state={state}");
let _ = reqwest::Client::new().get(&cb).send().await;
});
true
}
#[tokio::test]
async fn add_http_server_auto_starts_login_and_stores_the_token() {
let base = mock_server().await;
let dir = tempfile::tempdir().unwrap();
let env = env_at(dir.path(), auto_consent, 1_000);
let add = AddArgs {
name: "navigator".to_string(),
url: Some(format!("{base}/mcp")),
command: None,
args: vec![],
env: vec![],
headers: vec![],
no_login: false,
};
execute_with(
McpArgs {
command: McpCommand::Add(add),
},
&env,
)
.await
.unwrap();
let config = Config::load_from_path_public(&env.config_path).unwrap();
assert_eq!(config.mcp_servers[0].name, "navigator");
let stored = AuthStore::load(&env.store_path).unwrap();
assert_eq!(stored.get("navigator").unwrap().access_token, "cli-access");
let config_text = std::fs::read_to_string(&env.config_path).unwrap();
assert!(
!config_text.contains("cli-access"),
"token must not be in config"
);
}
#[tokio::test]
async fn add_http_server_survives_a_failed_login() {
let dir = tempfile::tempdir().unwrap();
let env = env_at(dir.path(), never_opens, 0);
let add = AddArgs {
name: "remote".to_string(),
url: Some("http://127.0.0.1:1/mcp".to_string()),
command: None,
args: vec![],
env: vec![],
headers: vec![],
no_login: false,
};
execute_with(
McpArgs {
command: McpCommand::Add(add),
},
&env,
)
.await
.expect("add should not fail just because login did");
let config = Config::load_from_path_public(&env.config_path).unwrap();
assert_eq!(config.mcp_servers.len(), 1, "the server is still saved");
}
#[tokio::test]
async fn explicit_login_reuses_a_prior_client_id() {
let base = mock_server().await;
let dir = tempfile::tempdir().unwrap();
let env = env_at(dir.path(), auto_consent, 1_000);
execute_with(
McpArgs {
command: McpCommand::Add(add_args("navigator", Some(&format!("{base}/mcp")), None)),
},
&env,
)
.await
.unwrap();
login("navigator", &env).await.unwrap();
login("navigator", &env).await.unwrap();
let stored = AuthStore::load(&env.store_path).unwrap();
assert_eq!(stored.get("navigator").unwrap().client_id, "cli-client");
}
const STUB: &str = r#"
import sys, json
for line in sys.stdin:
line = line.strip()
if not line: continue
req = json.loads(line); m = req.get("method",""); i = req.get("id")
if m == "initialize":
print(json.dumps({"jsonrpc":"2.0","id":i,"result":{"capabilities":{},"protocolVersion":"2024-11-05"}}), flush=True)
elif m == "tools/list":
print(json.dumps({"jsonrpc":"2.0","id":i,"result":{"tools":[{"name":"ping","inputSchema":{}}]}}), flush=True)
"#;
#[tokio::test]
async fn test_command_connects_and_lists_tools() {
let dir = tempfile::tempdir().unwrap();
let env = env_at(dir.path(), never_opens, 0);
let mut add = add_args("local", None, Some("python3"));
add.args = vec!["-c".to_string(), STUB.to_string()];
execute_with(
McpArgs {
command: McpCommand::Add(add),
},
&env,
)
.await
.unwrap();
execute_with(
McpArgs {
command: McpCommand::Test(ServerArg {
name: "local".to_string(),
}),
},
&env,
)
.await
.expect("test should connect and list tools");
}
#[tokio::test]
async fn test_command_errors_for_an_unknown_server() {
let dir = tempfile::tempdir().unwrap();
let env = env_at(dir.path(), never_opens, 0);
assert!(test("ghost", &env).await.is_err());
}
#[test]
fn server_row_describes_an_http_server() {
let http = MCPServerConfig::http("remote", "https://e.com/mcp");
let row = ServerRow::describe(&http, &AuthStore::default(), 0);
assert_eq!(row.kind, "mcp");
assert_eq!(row.transport, "http");
assert_eq!(row.endpoint, "https://e.com/mcp");
assert_eq!(row.auth, "none");
}
#[test]
fn script_tool_rows_lists_compiled_tools() {
assert!(script_tool_rows(None).is_empty());
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("up.rhai"), "// @tool up\nparams.x").unwrap();
std::fs::write(dir.path().join("down.rhai"), "// @tool down\n1").unwrap();
std::fs::write(dir.path().join("bad.rhai"), "no directive\nlet").unwrap();
std::fs::write(
dir.path().join("gpu.rhai"),
"// @tool gpu\n// @requires gpu\n1",
)
.unwrap();
let rows = script_tool_rows(Some(dir.path()));
assert_eq!(rows.len(), 2, "the gpu tool is filtered out");
assert!(rows.iter().all(|r| r.name != "gpu"));
assert_eq!(rows[0].kind, "script");
assert_eq!(rows[0].name, "down", "sorted by name");
assert_eq!(rows[1].name, "up");
assert_eq!(rows[0].transport, "rhai");
assert_eq!(rows[0].auth, "n/a");
assert!(rows[0].endpoint.contains(dir.path().to_str().unwrap()));
}
#[tokio::test]
async fn list_includes_script_tools_when_tools_dir_set() {
let dir = tempfile::tempdir().unwrap();
let mut env = env_at(dir.path(), never_opens, 0);
let tools = dir.path().join("tools");
std::fs::create_dir(&tools).unwrap();
std::fs::write(tools.join("up.rhai"), "// @tool up\nparams.x").unwrap();
env.tools_dir = Some(tools);
list_servers(ListArgs { json: false }, &env).unwrap();
list_servers(ListArgs { json: true }, &env).unwrap();
}
#[test]
fn never_opens_reports_no_browser() {
assert!(!never_opens("https://x"));
}
fn env_with_unreadable_paths(dir: &std::path::Path) -> McpEnv {
let cfg = dir.join("config-dir");
let store = dir.join("store-dir");
std::fs::create_dir(&cfg).unwrap();
std::fs::create_dir(&store).unwrap();
McpEnv {
config_path: cfg,
store_path: store,
opener: std::sync::Arc::new(never_opens),
now: 0,
tools_dir: None,
credential_store: None,
allow_env_vars: Vec::new(),
}
}
fn seed_config(env: &McpEnv, server: MCPServerConfig) {
let mut config = Config::default();
config.mcp_servers.push(server);
config.save_to_path_public(&env.config_path).unwrap();
}
fn seed_readonly_store(env: &McpEnv, name: &str) {
let mut store = AuthStore::default();
store.set(name, leviath_mcp::ServerAuth::default());
store.save(&env.store_path).unwrap();
let mut perms = std::fs::metadata(&env.store_path).unwrap().permissions();
perms.set_readonly(true);
std::fs::set_permissions(&env.store_path, perms).unwrap();
}
#[tokio::test]
async fn commands_surface_an_unreadable_config() {
let dir = tempfile::tempdir().unwrap();
let env = env_with_unreadable_paths(dir.path());
assert!(
execute_with(
McpArgs {
command: McpCommand::Add(add_args("x", None, Some("npx")))
},
&env
)
.await
.is_err()
);
assert!(list_servers(ListArgs { json: false }, &env).is_err());
assert!(
remove_server(
RemoveArgs {
name: "x".to_string()
},
&env
)
.is_err()
);
assert!(login("x", &env).await.is_err());
assert!(test("x", &env).await.is_err());
assert!(logout("x", &env).is_err());
}
#[tokio::test]
async fn add_surfaces_a_bad_header_and_an_unwritable_config() {
let dir = tempfile::tempdir().unwrap();
let env = env_at(dir.path(), never_opens, 0);
let mut bad = add_args("x", None, Some("npx"));
bad.headers = vec!["NOEQUALS".to_string()];
assert!(
execute_with(
McpArgs {
command: McpCommand::Add(bad)
},
&env
)
.await
.is_err()
);
let file = dir.path().join("a-file");
std::fs::write(&file, b"x").unwrap();
let ro_env = McpEnv {
config_path: file.join("config.toml"),
store_path: dir.path().join("s.json"),
opener: std::sync::Arc::new(never_opens),
now: 0,
tools_dir: None,
credential_store: None,
allow_env_vars: Vec::new(),
};
assert!(
execute_with(
McpArgs {
command: McpCommand::Add(add_args("x", None, Some("npx")))
},
&ro_env
)
.await
.is_err()
);
}
#[tokio::test]
async fn login_surfaces_an_unreadable_store() {
let dir = tempfile::tempdir().unwrap();
let env = env_at(dir.path(), never_opens, 0);
seed_config(
&env,
MCPServerConfig::http("remote", "http://127.0.0.1:1/mcp"),
);
std::fs::create_dir(&env.store_path).unwrap();
assert!(login("remote", &env).await.is_err());
}
#[tokio::test]
async fn remove_surfaces_an_unwritable_config() {
let dir = tempfile::tempdir().unwrap();
let env = env_at(dir.path(), never_opens, 0);
seed_config(&env, MCPServerConfig::stdio("x", "npx", vec![]));
let mut perms = std::fs::metadata(&env.config_path).unwrap().permissions();
perms.set_readonly(true);
std::fs::set_permissions(&env.config_path, perms).unwrap();
assert!(
remove_server(
RemoveArgs {
name: "x".to_string()
},
&env
)
.is_err()
);
}
#[tokio::test]
async fn login_surfaces_an_unwritable_store() {
let base = mock_server().await;
let dir = tempfile::tempdir().unwrap();
let env = env_at(dir.path(), auto_consent, 1_000);
execute_with(
McpArgs {
command: McpCommand::Add(add_args("navigator", Some(&format!("{base}/mcp")), None)),
},
&env,
)
.await
.unwrap();
seed_readonly_store(&env, "other");
assert!(login("navigator", &env).await.is_err());
}
#[tokio::test]
async fn logout_surfaces_an_unwritable_store() {
let dir = tempfile::tempdir().unwrap();
let env = env_at(dir.path(), never_opens, 0);
seed_readonly_store(&env, "srv");
assert!(logout("srv", &env).is_err());
}
#[tokio::test]
async fn remove_surfaces_an_unreadable_store() {
let dir = tempfile::tempdir().unwrap();
let env = env_at(dir.path(), never_opens, 0);
seed_config(&env, MCPServerConfig::stdio("x", "npx", vec![]));
std::fs::create_dir(&env.store_path).unwrap();
assert!(
remove_server(
RemoveArgs {
name: "x".to_string()
},
&env
)
.is_err()
);
}
#[tokio::test]
async fn remove_surfaces_an_unwritable_store() {
let dir = tempfile::tempdir().unwrap();
let env = env_at(dir.path(), never_opens, 0);
seed_config(&env, MCPServerConfig::stdio("x", "npx", vec![]));
seed_readonly_store(&env, "x");
assert!(
remove_server(
RemoveArgs {
name: "x".to_string()
},
&env
)
.is_err()
);
}
#[tokio::test]
async fn list_surfaces_an_unreadable_store() {
let dir = tempfile::tempdir().unwrap();
let env = env_at(dir.path(), never_opens, 0);
seed_config(&env, MCPServerConfig::http("remote", "https://e.com/mcp"));
std::fs::create_dir(&env.store_path).unwrap();
assert!(list_servers(ListArgs { json: false }, &env).is_err());
}
#[tokio::test]
async fn test_surfaces_an_unrefreshable_token() {
let dir = tempfile::tempdir().unwrap();
let env = env_at(dir.path(), never_opens, 1_000);
seed_config(
&env,
MCPServerConfig::http("remote", "http://127.0.0.1:1/mcp"),
);
let mut store = AuthStore::default();
store.set(
"remote",
leviath_mcp::ServerAuth {
token_endpoint: "http://127.0.0.1:1/token".to_string(),
refresh_token: Some("good".to_string()),
expires_at: 1,
..Default::default()
},
);
store.save(&env.store_path).unwrap();
assert!(test("remote", &env).await.is_err());
}
#[tokio::test]
async fn test_surfaces_a_spawn_failure() {
let dir = tempfile::tempdir().unwrap();
let env = env_at(dir.path(), never_opens, 0);
seed_config(
&env,
MCPServerConfig::stdio("x", "definitely-not-a-real-binary-xyz", vec![]),
);
assert!(test("x", &env).await.is_err());
}
#[tokio::test]
async fn test_surfaces_a_connect_failure() {
let dir = tempfile::tempdir().unwrap();
let env = env_at(dir.path(), never_opens, 0);
seed_config(
&env,
MCPServerConfig::http("remote", "http://127.0.0.1:1/mcp"),
);
assert!(test("remote", &env).await.is_err());
}
#[tokio::test]
async fn test_surfaces_a_list_tools_failure() {
let dir = tempfile::tempdir().unwrap();
let env = env_at(dir.path(), never_opens, 0);
let stub = r#"
import sys, json
for line in sys.stdin:
line = line.strip()
if not line: continue
req = json.loads(line); m = req.get("method",""); i = req.get("id")
if m == "initialize":
print(json.dumps({"jsonrpc":"2.0","id":i,"result":{"capabilities":{},"protocolVersion":"2024-11-05"}}), flush=True)
elif m == "tools/list":
print(json.dumps({"jsonrpc":"2.0","id":i,"error":{"code":-32603,"message":"boom"}}), flush=True)
"#;
seed_config(
&env,
MCPServerConfig::stdio("x", "python3", vec!["-c".to_string(), stub.to_string()]),
);
assert!(test("x", &env).await.is_err());
}
#[test]
fn server_row_marks_an_invalid_entry() {
let bad = MCPServerConfig {
name: "broken".to_string(),
..Default::default()
};
let row = ServerRow::describe(&bad, &AuthStore::default(), 0);
assert_eq!(row.transport, "invalid");
assert_eq!(row.auth, "n/a");
}
}