diff --git a/crates/lpm-cli/src/cli/args/security.rs b/crates/lpm-cli/src/cli/args/security.rs index 418784219..ef483f35b 100644 --- a/crates/lpm-cli/src/cli/args/security.rs +++ b/crates/lpm-cli/src/cli/args/security.rs @@ -170,10 +170,8 @@ pub(crate) struct DoctorArgs { #[derive(Args)] pub(crate) struct McpArgs { - /// Action: setup, serve, remove, status. - pub(crate) action: String, - /// Server name (for setup/remove). - pub(crate) name: Option, + #[command(subcommand)] + pub(crate) action: commands::mcp::McpAction, } #[derive(Args)] diff --git a/crates/lpm-cli/src/cli/dispatch.rs b/crates/lpm-cli/src/cli/dispatch.rs index 91395c53e..f979763e7 100644 --- a/crates/lpm-cli/src/cli/dispatch.rs +++ b/crates/lpm-cli/src/cli/dispatch.rs @@ -360,8 +360,7 @@ async fn async_main() -> Result<()> { lpm_auth::SessionManager::new(session_registry_url.to_string(), explicit_flag_token); let session_manager = auth_storage_notice::attach(session_manager, cli.json); let session = std::sync::Arc::new(session_manager); - let unattended_mcp_serve = - matches!(&command, Commands::Mcp(args) if args.action.as_str() == "serve"); + let unattended_mcp_serve = matches!(&command, Commands::Mcp(args) if matches!(args.action, commands::mcp::McpAction::Serve)); let mut client = lpm_registry::RegistryClient::new() .with_base_url(session_registry_url.to_string()) @@ -2018,11 +2017,7 @@ async fn async_main() -> Result<()> { commands::swift_registry::run(&session, registry_url, cli.json, force).await }, Commands::Mcp(args) => { - let security_args::McpArgs { - action, - name, - } = args; - commands::mcp::run(&client, &action, name.as_deref(), cli.json).await + commands::mcp::run(&client, args.action, cli.json).await }, Commands::Use(args) => { let lifecycle_args::UseArgs { diff --git a/crates/lpm-cli/src/commands/mcp.rs b/crates/lpm-cli/src/commands/mcp.rs index fcaeb79cb..aa44c0dc3 100644 --- a/crates/lpm-cli/src/commands/mcp.rs +++ b/crates/lpm-cli/src/commands/mcp.rs @@ -9,26 +9,28 @@ const MCP_PACKAGE: &str = "@lpm-registry/mcp-server"; const MCP_PACKAGE_SPEC: &str = "@lpm-registry/mcp-server@latest"; const MCP_VERSION_POLICY: &str = "latest-security-eligible"; -/// MCP server management: setup, serve, remove, status. +#[derive(clap::Subcommand)] +pub(crate) enum McpAction { + /// Configure the Registry server in existing editor configurations. + Setup { name: Option }, + /// Run the managed stdio server for an MCP client. + Serve, + /// Remove a named server from existing editor configurations. + Remove { name: String }, + /// List configured servers for supported editors. + Status, +} + pub async fn run( client: &RegistryClient, - action: &str, - server_name: Option<&str>, + action: McpAction, json_output: bool, ) -> Result<(), LpmError> { match action { - "setup" => setup(server_name, json_output).await, - "serve" => serve(client, server_name, json_output).await, - "remove" => { - let name = server_name.ok_or_else(|| { - LpmError::Registry("specify server name: lpm mcp remove ".into()) - })?; - remove(name, json_output).await - } - "status" => status(json_output).await, - _ => Err(LpmError::Registry(format!( - "unknown mcp action: {action}. Use: setup, serve, remove, status" - ))), + McpAction::Setup { name } => setup(name.as_deref(), json_output).await, + McpAction::Serve => serve(client, json_output).await, + McpAction::Remove { name } => remove(&name, json_output).await, + McpAction::Status => status(json_output).await, } } @@ -61,8 +63,12 @@ impl EditorSetupResult { } } -fn get_editors() -> Vec { - let home = dirs::home_dir().unwrap_or_default(); +fn get_editors() -> Result, LpmError> { + let home = dirs::home_dir() + .filter(|path| path.is_absolute()) + .ok_or_else(|| { + LpmError::Registry("MCP configuration requires an absolute home directory".into()) + })?; #[allow(unused_mut)] // Mutated on macOS via cfg-gated .push() calls below let mut editors = vec![ @@ -127,14 +133,20 @@ fn get_editors() -> Vec { }); } - editors + Ok(editors) } -fn default_server_config() -> Value { - serde_json::json!({ - "command": "lpm", +fn default_server_config() -> Result { + let executable = std::env::current_exe()?; + let command = executable + .to_str() + .ok_or_else(|| LpmError::Registry("MCP launcher path must contain valid UTF-8".into()))?; + // Editors and delegated tools must not resolve a project-controlled PATH entry. + Ok(serde_json::json!({ + "command": command, "args": ["mcp", "serve"], - }) + "env": { "LPM_CLI_PATH": command }, + })) } fn require_stdio_transport(stdin_is_terminal: bool, json_output: bool) -> Result<(), LpmError> { @@ -153,16 +165,7 @@ fn require_stdio_transport(stdin_is_terminal: bool, json_output: bool) -> Result Ok(()) } -async fn serve( - client: &RegistryClient, - server_name: Option<&str>, - json_output: bool, -) -> Result<(), LpmError> { - if server_name.is_some() { - return Err(LpmError::Registry( - "`lpm mcp serve` does not accept a server name".into(), - )); - } +async fn serve(client: &RegistryClient, json_output: bool) -> Result<(), LpmError> { require_stdio_transport(std::io::stdin().is_terminal(), json_output)?; let workspace = std::env::current_dir().map_err(LpmError::Io)?; let anonymous_client = client.clone_anonymous(); @@ -186,7 +189,23 @@ fn load_config(path: &Path) -> Result { }) } +fn existing_writable_config(path: &Path) -> Result { + let metadata = match std::fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error.into()), + }; + if lpm_common::is_symlink_or_junction(&metadata) || !metadata.is_file() { + return Err(LpmError::Registry(format!( + "MCP config at {} must be a regular file, not a symlink or directory junction", + path.display() + ))); + } + Ok(true) +} + fn write_config(path: &Path, config: &Value) -> Result<(), LpmError> { + existing_writable_config(path)?; if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } @@ -267,7 +286,7 @@ fn setup_editors( for editor in editors { let path = match &editor.global_path { - Some(path) if path.exists() => path, + Some(path) if existing_writable_config(path)? => path, _ => { results.push(EditorSetupResult { name: editor.name, @@ -297,7 +316,7 @@ fn remove_from_editors( for editor in editors { let path = match &editor.global_path { - Some(path) if path.exists() => path, + Some(path) if existing_writable_config(path)? => path, _ => continue, }; @@ -339,11 +358,12 @@ fn status_for_editors(editors: &[EditorConfig]) -> Result, LpmError> async fn setup(server_name: Option<&str>, json_output: bool) -> Result<(), LpmError> { let name = server_name.unwrap_or("lpm-registry"); - let editors = get_editors(); + let editors = get_editors()?; if !json_output { install_ui::phase("Configuring MCP servers for supported editors"); } - let setup_results = setup_editors(&editors, name, &default_server_config())?; + let server_config = default_server_config()?; + let setup_results = setup_editors(&editors, name, &server_config)?; let configured: Vec<&'static str> = setup_results .iter() .filter_map(|result| result.configured_name()) @@ -366,8 +386,9 @@ async fn setup(server_name: Option<&str>, json_output: bool) -> Result<(), LpmEr "package": MCP_PACKAGE, "package_spec": MCP_PACKAGE_SPEC, "version_policy": MCP_VERSION_POLICY, - "command": "lpm", - "args": ["mcp", "serve"], + "command": server_config["command"], + "args": server_config["args"], + "env": server_config["env"], })) .unwrap() ); @@ -412,7 +433,7 @@ async fn setup(server_name: Option<&str>, json_output: bool) -> Result<(), LpmEr } async fn remove(name: &str, json_output: bool) -> Result<(), LpmError> { - let editors = get_editors(); + let editors = get_editors()?; let removed_from = remove_from_editors(&editors, name)?; if json_output { @@ -457,7 +478,7 @@ async fn remove(name: &str, json_output: bool) -> Result<(), LpmError> { } async fn status(json_output: bool) -> Result<(), LpmError> { - let editors = get_editors(); + let editors = get_editors()?; let results = status_for_editors(&editors)?; if json_output { @@ -562,7 +583,7 @@ mod tests { ]; let setup_results = - setup_editors(&editors, "lpm-registry", &default_server_config()).unwrap(); + setup_editors(&editors, "lpm-registry", &default_server_config().unwrap()).unwrap(); let configured: Vec<_> = setup_results .iter() .filter_map(|result| result.configured_name()) @@ -649,7 +670,8 @@ mod tests { let original = std::fs::read_to_string(&path).unwrap(); let editors = vec![test_editor("Claude", path.clone(), "mcpServers")]; - let error = setup_editors(&editors, "lpm-registry", &default_server_config()).unwrap_err(); + let error = + setup_editors(&editors, "lpm-registry", &default_server_config().unwrap()).unwrap_err(); assert!( error.to_string().contains("failed to parse MCP config"), @@ -671,7 +693,7 @@ mod tests { .join("config.json"); let editors = vec![test_editor("Cursor", nested_path.clone(), "mcpServers")]; - let results = setup_editors(&editors, "lpm-registry", &default_server_config()) + let results = setup_editors(&editors, "lpm-registry", &default_server_config().unwrap()) .expect("missing config must be reported, not treated as fatal"); assert_eq!( @@ -698,7 +720,7 @@ mod tests { .join("config.json"); let config = serde_json::json!({ "mcpServers": { - "lpm-registry": default_server_config() + "lpm-registry": default_server_config().unwrap() } }); @@ -722,11 +744,14 @@ mod tests { } } }); - let new_config = default_server_config(); + let new_config = default_server_config().unwrap(); add_server_to_config(&mut config, "mcpServers", "lpm-registry", &new_config).unwrap(); - assert_eq!(config["mcpServers"]["lpm-registry"]["command"], "lpm"); + assert_eq!( + config["mcpServers"]["lpm-registry"]["command"], + std::env::current_exe().unwrap().to_str().unwrap() + ); assert_eq!( config["mcpServers"]["lpm-registry"]["args"], serde_json::json!(["mcp", "serve"]) @@ -851,7 +876,8 @@ mod tests { server_key: "mcpServers", }]; - let results = setup_editors(&editors, "lpm-registry", &default_server_config()).unwrap(); + let results = + setup_editors(&editors, "lpm-registry", &default_server_config().unwrap()).unwrap(); assert_eq!( results, @@ -895,14 +921,11 @@ mod tests { #[test] fn default_server_config_uses_the_isolated_lpm_launcher() { - let cfg = default_server_config(); - assert_eq!( - cfg, - serde_json::json!({ - "command": "lpm", - "args": ["mcp", "serve"] - }) - ); + let cfg = default_server_config().unwrap(); + let executable = std::env::current_exe().unwrap(); + assert_eq!(cfg["command"], executable.to_str().unwrap()); + assert_eq!(cfg["env"]["LPM_CLI_PATH"], cfg["command"]); + assert_eq!(cfg["args"], serde_json::json!(["mcp", "serve"])); } #[test] diff --git a/tests/workflows/tests/mcp.rs b/tests/workflows/tests/mcp.rs index 78fb0dc38..213e407dc 100644 --- a/tests/workflows/tests/mcp.rs +++ b/tests/workflows/tests/mcp.rs @@ -11,6 +11,116 @@ use support::mock_registry::{MockRegistry, compute_integrity, make_tarball_from_ use support::{TempProject, lpm, lpm_with_registry}; const MCP_PACKAGE_SPEC: &str = "@lpm-registry/mcp-server@latest"; + +#[test] +fn mcp_invalid_syntax_reports_usage_errors() { + let project = TempProject::empty(r#"{"name":"mcp-usage"}"#); + for args in [ + vec!["mcp", "remove"], + vec!["mcp", "status", "unexpected"], + vec!["mcp", "invalid"], + vec!["mcp", "serve", "unexpected"], + ] { + for json in [false, true] { + let mut command = lpm(&project); + command.args(&args); + if json { + command.arg("--json"); + } + let output = command.output().unwrap(); + assert_eq!(output.status.code(), Some(2), "{args:?}: {output:?}"); + if json { + let result: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(result["error_code"], "usage"); + } + } + } +} + +#[cfg(unix)] +#[test] +fn mcp_setup_pins_the_launcher_before_project_path_lookup() { + use std::os::unix::fs::PermissionsExt; + let project = TempProject::empty(r#"{"name":"mcp-launcher"}"#); + std::fs::write(project.home().join(".claude.json"), "{}").unwrap(); + project.write_file("lpm", "#!/bin/sh\nprintf hijacked > intercepted\nexit 91\n"); + std::fs::set_permissions( + project.path().join("lpm"), + std::fs::Permissions::from_mode(0o755), + ) + .unwrap(); + let setup = lpm(&project).args(["mcp", "setup"]).output().unwrap(); + assert!(setup.status.success(), "{setup:?}"); + let config: serde_json::Value = + serde_json::from_slice(&std::fs::read(project.home().join(".claude.json")).unwrap()) + .unwrap(); + let server = &config["mcpServers"]["lpm-registry"]; + let mut paths = vec![project.path().to_path_buf()]; + paths.extend(std::env::split_paths( + &std::env::var_os("PATH").unwrap_or_default(), + )); + let result = std::process::Command::new(server["command"].as_str().unwrap()) + .args(["mcp", "serve", "--help"]) + .current_dir(project.path()) + .env("PATH", std::env::join_paths(paths).unwrap()) + .output() + .unwrap(); + assert!( + !project.path().join("intercepted").exists(), + "configured launcher resolved a project executable" + ); + assert!(result.status.success(), "{result:?}"); + assert!(std::path::Path::new(server["command"].as_str().unwrap()).is_absolute()); + assert_eq!(server["env"]["LPM_CLI_PATH"], server["command"]); +} + +#[cfg(unix)] +#[test] +fn mcp_mutations_preserve_linked_editor_configs() { + for action in ["setup", "remove"] { + let project = TempProject::empty(r#"{"name":"mcp-linked"}"#); + let original = r#"{"mcpServers":{"lpm-registry":{"command":"old"}}}"#; + let target = project.path().join("shared.json"); + std::fs::write(&target, original).unwrap(); + let config = project.home().join(".claude.json"); + std::os::unix::fs::symlink(&target, &config).unwrap(); + let output = lpm(&project) + .args(["mcp", action, "lpm-registry", "--json"]) + .output() + .unwrap(); + assert!( + !output.status.success(), + "linked config must be rejected: {output:?}" + ); + assert!( + std::fs::symlink_metadata(&config) + .unwrap() + .file_type() + .is_symlink() + ); + assert_eq!(std::fs::read_to_string(target).unwrap(), original); + } +} + +#[cfg(unix)] +#[test] +fn mcp_rejects_relative_home_without_editing_project_configs() { + let project = TempProject::empty(r#"{"name":"mcp-home"}"#); + project.write_file(".claude.json", "{}"); + let output = lpm(&project) + .env("HOME", ".") + .args(["mcp", "setup", "--json"]) + .output() + .unwrap(); + assert!( + !output.status.success(), + "relative HOME must be rejected: {output:?}" + ); + assert_eq!( + std::fs::read_to_string(project.path().join(".claude.json")).unwrap(), + "{}" + ); +} const HOSTILE_SERVER_NAME: &str = "safe\nFORGED\rrewritten\u{8}\u{1b}]52;c;AAAA\u{7}\u{0090}hidden\u{009c}end"; @@ -281,7 +391,7 @@ fn mcp_remove_without_name_fails_with_helpful_message() { let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("specify server name") || stderr.contains("name"), + stderr.contains(""), "stderr must guide the user, got:\n{stderr}", ); } @@ -451,9 +561,13 @@ fn mcp_setup_writes_the_published_package_to_both_container_shapes_idempotently( serde_json::from_slice(&std::fs::read(&claude_path).unwrap()).unwrap(); let vscode: serde_json::Value = serde_json::from_slice(&std::fs::read(&vscode_path).unwrap()).unwrap(); + let executable = assert_cmd::cargo::cargo_bin("lpm-rs") + .canonicalize() + .unwrap(); let expected = serde_json::json!({ - "command": "lpm", - "args": ["mcp", "serve"] + "command": executable, + "args": ["mcp", "serve"], + "env": { "LPM_CLI_PATH": executable } }); assert_eq!(claude["mcpServers"]["lpm-registry"], expected); @@ -484,7 +598,10 @@ fn mcp_setup_json_reports_the_same_published_package_policy_as_the_written_confi let stdout = String::from_utf8_lossy(&output.stdout); let envelope: serde_json::Value = serde_json::from_str(&stdout) .unwrap_or_else(|error| panic!("stdout must be one JSON document: {error}\n{stdout}")); - insta::assert_json_snapshot!(envelope, @r###" + insta::assert_json_snapshot!(envelope, { + ".command" => "[LPM_PATH]", + ".env.LPM_CLI_PATH" => "[LPM_PATH]", + }, @r###" { "success": true, "server": "lpm-registry", @@ -494,11 +611,14 @@ fn mcp_setup_json_reports_the_same_published_package_policy_as_the_written_confi "package": "@lpm-registry/mcp-server", "package_spec": "@lpm-registry/mcp-server@latest", "version_policy": "latest-security-eligible", - "command": "lpm", + "command": "[LPM_PATH]", "args": [ "mcp", "serve" - ] + ], + "env": { + "LPM_CLI_PATH": "[LPM_PATH]" + } } "###); @@ -788,7 +908,7 @@ async fn mcp_serve_uses_the_last_verified_runtime_during_a_registry_outage() { // ─── unknown action ─────────────────────────────────────────────────── #[test] -fn mcp_unknown_action_lists_valid_subcommands() { +fn mcp_unknown_action_reports_usage_and_points_to_help() { let project = TempProject::empty(r#"{"name":"mcp","version":"1.0.0"}"#); let output = lpm(&project) @@ -803,11 +923,8 @@ fn mcp_unknown_action_lists_valid_subcommands() { let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("setup") - && stderr.contains("remove") - && stderr.contains("status") - && stderr.contains("serve"), - "stderr must enumerate valid actions, got:\n{stderr}", + stderr.contains("usage lpm mcp") && stderr.contains("--help"), + "stderr must show usage and the help option, got:\n{stderr}", ); }