use crate::Result; use crate::args::ToolArg; use crate::cmd::{RunningPidGuard, prepare_noninteractive_child}; use crate::config::config_file::{REQUIRE_EXPLICIT_TRUST_ENV, config_trust_root, is_path_trusted}; use crate::config::{self, Config, SettingsExt}; use itertools::Itertools; use rmcp::{ RoleServer, ServiceExt, handler::server::{ServerHandler, tool::ToolRouter, wrapper::Parameters}, model::{ CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ErrorCode, ErrorData, Implementation, ListResourcesResult, ListToolsResult, PaginatedRequestParams, ProtocolVersion, ReadResourceRequestParams, ReadResourceResponse, ReadResourceResult, Resource, ResourceContents, ServerCapabilities, ServerConfig, }, schemars::JsonSchema, service::RequestContext, tool, tool_router, }; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use std::borrow::Cow; use std::collections::HashMap; use std::str::FromStr; use std::sync::Arc; /// Run the Model Context Protocol server over stdin/stdout /// /// Exposes project tools, tasks, environment, and configuration to an MCP client. /// The client, such as an AI coding assistant, starts this command and exchanges /// messages with it. Start it in the project directory or pass `--cd`: the /// resources describe that project. /// /// Resources (JSON): /// - mise://tools: active tool versions (?include_inactive=true adds the rest) /// - mise://tasks: task definitions, including monorepo subproject tasks /// - mise://env: environment variables /// - mise://config: loaded config files and the project root /// /// Environment resources contain real values, including secrets. /// /// Tools: /// - list_commands: every mise command and its declared effect /// - run_task: run a task with your permissions; it can change files and call /// external services /// - install_tool: install a tool version, as `mise install` does /// /// run_task and install_tool refuse a project whose config files are not /// trusted; run `mise trust` there after reviewing them. /// /// See https://mise.jdx.dev/mcp.html for client configuration and access controls. #[derive(Debug, usage_rs::Args)] #[usage( verbatim_doc_comment, example( r###"mise --cd /path/to/project mcp"###, help = r###"Serve a project from another directory, as an MCP client config does"### ) )] pub(crate) struct Mcp {} #[derive(Clone)] struct MiseServer { tool_router: ToolRouter, /// mise's own usage spec, built once. Deriving it walks the whole clap /// command tree, which is not work to repeat per request. spec: Arc, } /// Parameters for installing a tool #[derive(Debug, Serialize, Deserialize, JsonSchema)] #[schemars(crate = "rmcp::schemars")] struct InstallToolParams { /// Tool name (e.g. "node", "python", "go") tool: String, /// Optional version to install (e.g. "20", "3.12"). Defaults to the /// configured version, or latest if the tool is not configured. #[serde(default)] version: Option, } /// Parameters for listing mise's commands #[derive(Debug, Serialize, Deserialize, JsonSchema)] #[schemars(crate = "rmcp::schemars")] struct ListCommandsParams { /// Include commands hidden from help. They are still runnable. #[serde(default)] include_hidden: bool, } /// Parameters for running a mise task #[derive(Debug, Serialize, Deserialize, JsonSchema)] #[schemars(crate = "rmcp::schemars")] struct RunTaskParams { /// Name of the task to run task: String, /// Optional arguments to pass to the task #[serde(default)] args: Vec, } /// Structured data as pretty JSON text, which is what the other tools here /// return and what clients that only read `content` can use. fn json_result(value: Value) -> std::result::Result { let text = serde_json::to_string_pretty(&value).map_err(|e| ErrorData { code: ErrorCode::INTERNAL_ERROR, message: Cow::Owned(format!("Failed to serialize response: {e}")), data: None, })?; Ok(CallToolResult::success(vec![ContentBlock::text(text)])) } /// A child `mise` with no terminal: output is captured, and the child is /// killed if the request is dropped. The child trusts no config on its own /// (see [`REQUIRE_EXPLICIT_TRUST_ENV`]), so config the user has not trusted /// fails to load rather than being trusted on a client's behalf. fn mise_child>( args: &[S], ) -> std::result::Result { let exe = std::env::current_exe().map_err(|e| ErrorData { code: ErrorCode::INTERNAL_ERROR, message: Cow::Owned(format!("Failed to get current exe: {e}")), data: None, })?; let mut command = tokio::process::Command::new(exe); command .args(args) .env("NO_COLOR", "1") .env(REQUIRE_EXPLICIT_TRUST_ENV, "1") .kill_on_drop(true) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()); prepare_noninteractive_child(command.as_std_mut()); Ok(command) } async fn run_mise(args: &[&str]) -> std::result::Result { let error = |e: std::io::Error| ErrorData { code: ErrorCode::INTERNAL_ERROR, message: Cow::Owned(format!("Failed to execute mise {}: {e}", args.join(" "))), data: None, }; let child = mise_child(args)?.spawn().map_err(error)?; // The child runs in its own process group; registering it lets Ctrl-C // stop an install's compilers and plugins, not just the child. let _running_pid = RunningPidGuard::new(child.id()); child.wait_with_output().await.map_err(error) } /// The version `mise ls` reports for the installation at `install_path`. An /// installation directory is not always named after its version. async fn installed_version(spec: &str, install_path: &str) -> Option { let tool = ToolArg::from_str(spec).ok()?.ba.short.clone(); let output = run_mise(&["ls", "--json", "--installed", &tool]) .await .ok()?; let versions: Vec = serde_json::from_slice(&output.stdout).ok()?; versions .iter() .find(|v| v["install_path"] == install_path) .and_then(|v| v["version"].as_str()) .map(str::to_string) } fn combined_output(output: &std::process::Output) -> String { let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); [stderr.trim_end(), stdout.trim_end()] .into_iter() .filter(|s| !s.is_empty()) .join("\n") } /// The refusal to act on a project whose config is not trusted, if it is not. /// /// `mise run` and `mise install` trust the project config they load: typing /// either command is the user's consent to it. A request from an MCP client is /// not, so the child cannot trust anything (see [`mise_child`]). This check of /// the files `trust_active_config` would trust gives the client an early, /// actionable answer; the child still refuses any other untrusted config it /// reaches, such as a monorepo subproject's. fn untrusted_project_refusal() -> Option { let untrusted = config::load_config_paths(&config::DEFAULT_CONFIG_FILENAMES, false) .into_iter() .filter(|path| !config::is_global_config(path) && !is_path_trusted(path)) .map(|path| config_trust_root(&path)) .unique() .collect_vec(); if untrusted.is_empty() { return None; } let commands = untrusted .iter() .map(|root| { format!( " mise trust {}", shell_words::quote(&root.to_string_lossy()) ) }) .join("\n"); Some(CallToolResult::error(vec![ContentBlock::text(format!( "Refusing to act on untrusted config. The MCP server does not trust config on a \ client's behalf. Ask the user to review it and run:\n{commands}" ))])) } /// `tool@version` for `mise install`, refusing a tool it would parse as a flag /// or as `tool[option=value]`: options such as `postinstall` are commands, which /// come from config the user trusts and not from a client. fn tool_spec(tool: &str, version: Option<&str>) -> std::result::Result { let spec = match version.filter(|v| !v.is_empty()) { Some(version) => format!("{tool}@{version}"), None => tool.to_string(), }; let message = if tool.is_empty() { "invalid tool '': a tool name is required".to_string() } else if tool.starts_with('-') { format!("invalid tool '{tool}': tool names cannot start with '-'") } else if spec.contains('[') { // The version counts too: `npm:` and `pkg[option=value]` join into a // scoped package name with options. format!("invalid tool '{spec}': tool options are not accepted") } else { return Ok(spec); }; Err(ErrorData { code: ErrorCode::INVALID_PARAMS, message: message.into(), data: None, }) } #[tool_router] impl MiseServer { fn new() -> Self { Self { tool_router: Self::tool_router(), spec: Arc::new(crate::cli::usage::spec()), } } /// Every mise command, with what running it does to the world #[tool( description = "Every mise command, with what running it does: `read` only inspects state, `write` changes it, `destructive` removes something that is work to get back. A command with no effect listed is unclassified: treat it as needing confirmation, not as safe. Call this before running an unfamiliar mise command." )] async fn list_commands( &self, Parameters(ListCommandsParams { include_hidden }): Parameters, ) -> std::result::Result { fn walk( cmd: &usage::SpecCommand, path: &mut Vec, include_hidden: bool, out: &mut Vec, ) { for (name, sub) in &cmd.subcommands { // A hidden command takes its subtree with it: clap does not // propagate `hide` to children, so a visible child of a hidden // parent is still not a documented path. if sub.hide && !include_hidden { continue; } path.push(name.clone()); out.push(json!({ "command": path.join(" "), "help": sub.help, "effect": sub.effect.map(|e| e.as_str()), "hidden": sub.hide, })); walk(sub, path, include_hidden, out); path.pop(); } } let mut commands = vec![]; walk(&self.spec.cmd, &mut vec![], include_hidden, &mut commands); json_result(json!({ "bin": "mise", "commands": commands })) } /// Install a tool with an optional version #[tool(description = "Install a tool with an optional version (e.g. node@20, python@3.12)")] async fn install_tool( &self, Parameters(InstallToolParams { tool, version }): Parameters, ) -> std::result::Result { let spec = tool_spec(&tool, version.as_deref())?; if let Some(refusal) = untrusted_project_refusal() { return Ok(refusal); } // A child `mise install` keeps backend and hook output off this // process's stdout, which carries the protocol. let output = run_mise(&["install", &spec]).await?; let log = combined_output(&output); if !output.status.success() { return Ok(CallToolResult::error(vec![ContentBlock::text(format!( "Installing {spec} failed with exit code {}:\n{log}", output.status.code().unwrap_or(1), ))])); } // `mise where` resolves the request as `mise install` did, now against // what is installed, so it finds what was just installed. let location = run_mise(&["where", &spec]).await?; if !location.status.success() { return Ok(CallToolResult::error(vec![ContentBlock::text(format!( "Installed {spec}, but could not locate it:\n{}", combined_output(&location), ))])); } let install_path = String::from_utf8_lossy(&location.stdout).trim().to_string(); json_result(json!({ "tool": spec, "version": installed_version(&spec, &install_path).await, "install_path": install_path, "output": log, })) } /// Execute a mise task with optional arguments #[tool(description = "Execute a mise task with optional arguments")] async fn run_task( &self, Parameters(RunTaskParams { task, args }): Parameters, ) -> std::result::Result { validate_task_name(&task)?; if let Some(refusal) = untrusted_project_refusal() { return Ok(refusal); } let mut cmd_args = vec!["run".to_string(), task.clone()]; if !args.is_empty() { cmd_args.push("--".to_string()); cmd_args.extend(args); } let mut command = mise_child(&cmd_args)?; // The child looks the name up as a task only, never as the path of a script: the check in // `validate_task_name` cannot see a file that appears before the child starts. command.env(crate::env::MISE_TASK_NAMES_ONLY_ENV, "1"); // Answers a task's `confirm` prompt. It cannot grant trust: the child // requires explicit trust, which ignores `yes`. command.env("MISE_YES", "1"); let child = command.spawn().map_err(|e| ErrorData { code: ErrorCode::INTERNAL_ERROR, message: Cow::Owned(format!("Failed to spawn mise run: {e}")), data: None, })?; let _running_pid = RunningPidGuard::new(child.id()); let output = match crate::config::Settings::get().task_timeout_duration() { Some(timeout) => tokio::time::timeout(timeout, child.wait_with_output()) .await .map_err(|_| ErrorData { code: ErrorCode::INTERNAL_ERROR, message: Cow::Owned(format!("Task '{task}' timed out after {timeout:?}")), data: None, })?, None => child.wait_with_output().await, } .map_err(|e| ErrorData { code: ErrorCode::INTERNAL_ERROR, message: Cow::Owned(format!("Failed to execute mise run: {e}")), data: None, })?; let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); if output.status.success() { let text = match (stderr.is_empty(), stdout.is_empty()) { (true, true) => format!("Task '{task}' completed successfully"), (true, false) => stdout.into_owned(), (false, true) => stderr.into_owned(), (false, false) => format!("{stderr}\n{stdout}"), }; Ok(CallToolResult::success(vec![ContentBlock::text(text)])) } else { let text = match (stderr.is_empty(), stdout.is_empty()) { (true, true) => format!("Task '{task}' failed with no output"), (false, true) => stderr.into_owned(), (true, false) => stdout.into_owned(), (false, false) => format!("{stderr}\n{stdout}"), }; Ok(CallToolResult::error(vec![ContentBlock::text(format!( "Task '{task}' failed with exit code {}:\n{text}", output.status.code().unwrap_or(1), ))])) } } } impl ServerHandler for MiseServer { fn get_info(&self) -> ServerConfig { let capabilities = ServerCapabilities::builder() .enable_resources() .enable_tools() .build(); ServerConfig::new(capabilities) .with_protocol_version(ProtocolVersion::V_2025_03_26) .with_server_info(Implementation::new("mise", env!("CARGO_PKG_VERSION"))) .with_instructions( "The mise MCP server provides access to tools, tasks, environment variables, and \ configuration. Call list_commands before running an unfamiliar mise command: \ every command declares its effect on the world (`read`, `write`, \ `destructive`), and a command with no effect listed is unclassified rather \ than safe.", ) } async fn list_resources( &self, _pagination: Option, _context: RequestContext, ) -> std::result::Result { let resources = vec![ Resource::new("mise://tools", "Installed Tools"), Resource::new("mise://tasks", "Available Tasks"), Resource::new("mise://env", "Environment Variables"), Resource::new("mise://config", "Configuration"), ]; Ok(ListResourcesResult::with_all_items(resources)) } async fn read_resource( &self, params: ReadResourceRequestParams, _context: RequestContext, ) -> std::result::Result { // Parse URI to extract query parameters // Example: mise://tools?include_inactive=true let url = url::Url::parse(¶ms.uri).map_err(|e| ErrorData { code: ErrorCode::INVALID_REQUEST, message: Cow::Owned(format!("Invalid URI: {e}")), data: None, })?; // Parse query parameters // include_inactive=true will show all installed tools, not just active ones let include_inactive = url .query_pairs() .any(|(key, value)| key == "include_inactive" && value == "true"); match (url.scheme(), url.host_str()) { ("mise", Some("tools")) => { // Return tool information // By default only shows active tools (those in current .mise.toml) // With ?include_inactive=true, shows all installed tools let config = Config::get().await.map_err(|e| ErrorData { code: ErrorCode::INTERNAL_ERROR, message: Cow::Owned(format!("Failed to load config: {e:#}")), data: None, })?; // Get tool request set and resolve toolset let trs = config .get_tool_request_set() .await .map_err(|e| ErrorData { code: ErrorCode::INTERNAL_ERROR, message: Cow::Owned(format!("Failed to get tool request set: {e:#}")), data: None, })? .clone(); let mut ts = crate::toolset::Toolset::from(trs); ts.resolve(&config).await.map_err(|e| ErrorData { code: ErrorCode::INTERNAL_ERROR, message: Cow::Owned(format!("Failed to resolve toolset: {e:#}")), data: None, })?; // Get current versions to determine which are active let current_versions = ts.list_current_versions(); let active_versions: std::collections::HashSet = current_versions .iter() .map(|(backend, tv)| format!("{}@{}", backend.id(), tv.version)) .collect(); // Determine which versions to include let versions = if include_inactive { // Include all versions (active + installed) ts.list_all_versions(&config).await.map_err(|e| ErrorData { code: ErrorCode::INTERNAL_ERROR, message: Cow::Owned(format!("Failed to list tool versions: {e:#}")), data: None, })? } else { // Only include active versions (current) current_versions }; // Group by tool and create JSON output // Output format: { "node": [{"version": "20.11.0", "active": true, ...}], ... } let mut tools_map: std::collections::HashMap> = std::collections::HashMap::new(); for (backend, tv) in versions { let tool_name = backend.id().to_string(); let install_path = tv.install_path(); let installed = install_path.exists(); let version_key = format!("{}@{}", backend.id(), tv.version); let version_info = json!({ "version": tv.version.clone(), "requested_version": tv.request.version(), "install_path": install_path.to_string_lossy(), "installed": installed, "active": active_versions.contains(&version_key), "source": tv.request.source().as_json(), }); tools_map.entry(tool_name).or_default().push(version_info); } let text = serde_json::to_string_pretty(&tools_map).unwrap(); let contents = vec![ResourceContents::TextResourceContents { uri: params.uri.clone(), mime_type: Some("application/json".to_string()), text, meta: None, }]; Ok(ReadResourceResult::new(contents).into()) } ("mise", Some("tasks")) => { let config = Config::get().await.map_err(|e| ErrorData { code: ErrorCode::INTERNAL_ERROR, message: Cow::Owned(format!("Failed to load config: {e:#}")), data: None, })?; // Load every monorepo subproject task (`//app:task`), not just the cwd's. let ctx = crate::task::TaskLoadContext::all(); let tasks = config .tasks_with_context(Some(&ctx)) .await .map_err(|e| ErrorData { code: ErrorCode::INTERNAL_ERROR, message: Cow::Owned(format!("Failed to load tasks: {e:#}")), data: None, })?; // Read remote task files first, as `mise tasks ls --json` does, so metadata from // their `#MISE` headers, such as `tags`, is in the listing. let names = tasks.keys().cloned().collect::>(); let mut fetched = tasks.values().cloned().collect::>(); crate::task::task_fetcher::TaskFetcher::for_inspection(false) .without_warning() // with no-cache set, each read downloads the files again .without_shared_snapshots() // one unreachable file must not hide every task from the client .skipping_failures() .fetch_tasks(&config, &mut fetched) .await .map_err(|e| ErrorData { code: ErrorCode::INTERNAL_ERROR, message: Cow::Owned(format!("Failed to fetch remote tasks: {e:#}")), data: None, })?; let task_list: Vec<_> = names.iter().zip(&fetched).map(|(name, task)| { json!({ "name": name, "description": task.description.clone(), "aliases": task.aliases, "tags": task.tags, "source": task.config_source.to_string_lossy(), "config_sources": task .config_sources() .iter() .map(|source| source.to_string_lossy()) .collect::>(), "depends": task.depends.iter().map(|d| d.task.clone()).collect::>(), "daemons": task.daemons, "secrets": task.secrets.as_ref().map(|s| s.names()).unwrap_or_default(), "depends_post": task.depends_post.iter().map(|d| d.task.clone()).collect::>(), "wait_for": task.wait_for.iter().map(|d| d.task.clone()).collect::>(), "env": task .env .0 .iter() .chain(task.overlay_env.iter().map(|(d, _)| d)) .map(|d| d.to_string()) .collect::>(), "dir": task.dir.clone(), "hide": task.hide, "raw": task.raw, "interactive": task.interactive, "sources": task.sources.clone(), "outputs": task.outputs.clone(), "shell": task.shell.clone(), "quiet": task.quiet, "silent": task.silent, "tools": task.tools.clone(), "run": task.run(), "usage": task.usage.clone(), }) }).collect(); let text = serde_json::to_string_pretty(&task_list).unwrap(); let contents = vec![ResourceContents::TextResourceContents { uri: params.uri.clone(), mime_type: Some("application/json".to_string()), text, meta: None, }]; Ok(ReadResourceResult::new(contents).into()) } ("mise", Some("env")) => { let config = Config::get().await.map_err(|e| ErrorData { code: ErrorCode::INTERNAL_ERROR, message: Cow::Owned(format!("Failed to load config: {e:#}")), data: None, })?; let env_template = config.env().await.map_err(|e| ErrorData { code: ErrorCode::INTERNAL_ERROR, message: Cow::Owned(format!("Failed to load env: {e:#}")), data: None, })?; let mut env_map = HashMap::new(); for (k, v) in env_template.iter() { env_map.insert(k.clone(), v.clone()); } let text = serde_json::to_string_pretty(&env_map).unwrap(); let contents = vec![ResourceContents::TextResourceContents { uri: params.uri.clone(), mime_type: Some("application/json".to_string()), text, meta: None, }]; Ok(ReadResourceResult::new(contents).into()) } ("mise", Some("config")) => { let config = Config::get().await.map_err(|e| ErrorData { code: ErrorCode::INTERNAL_ERROR, message: Cow::Owned(format!("Failed to load config: {e:#}")), data: None, })?; let config_info = json!({ "config_files": config .config_files .keys() .map(|p| p.to_string_lossy()) .collect::>(), "project_root": config.project_root.as_ref().map(|p| p.to_string_lossy()), }); let text = serde_json::to_string_pretty(&config_info).unwrap(); let contents = vec![ResourceContents::TextResourceContents { uri: params.uri.clone(), mime_type: Some("application/json".to_string()), text, meta: None, }]; Ok(ReadResourceResult::new(contents).into()) } _ => Err(ErrorData { code: ErrorCode::RESOURCE_NOT_FOUND, message: Cow::Owned(format!("Unknown resource URI: {}", params.uri)), data: None, }), } } async fn list_tools( &self, _pagination: Option, _context: RequestContext, ) -> std::result::Result { Ok(ListToolsResult::with_all_items(self.tool_router.list_all())) } async fn call_tool( &self, request: CallToolRequestParams, context: RequestContext, ) -> std::result::Result { let tool_call_context = rmcp::handler::server::tool::ToolCallContext::new(self, request, context); self.tool_router.call(tool_call_context).await } } impl Mcp { pub(crate) async fn run(self) -> Result<()> { eprintln!("Starting mise MCP server..."); let server = MiseServer::new(); // Create stdio transport and serve let service = server .serve(rmcp::transport::stdio()) .await .map_err(|e| eyre::eyre!("Failed to create service: {}", e))?; // Wait for the service to complete service .waiting() .await .map_err(|e| eyre::eyre!("Service error: {}", e))?; Ok(()) } } /// `task` is handed to `mise run` as an argument, so a leading dash would be /// parsed as a flag (`--raw`, `--cd=...`) instead of naming a task, and the /// path of an existing file would run that file, which no project config names /// and no trust check covers. fn validate_task_name(task: &str) -> std::result::Result<(), ErrorData> { let message = if task.is_empty() { "invalid task name '': a task name is required".to_string() } else if task.starts_with('-') { format!("invalid task name '{task}': task names cannot start with '-'") } else if is_path_like(task) { format!("invalid task name '{task}': only the project's tasks can be run, not a file path") } else { return Ok(()); }; Err(ErrorData { code: ErrorCode::INVALID_PARAMS, message: message.into(), data: None, }) } /// Whether `mise run` could take `task` for the path of a script rather than a task name. /// /// Decided by its shape, not by whether the file exists: the child looks again when it starts, /// and a file that appears in between must not turn a refused name into a script. Relative /// (`./`, `../`, `~/`, `.\`), absolute (`/x`, `\x`, UNC `\\host\share`) and drive (`C:\`, /// `C:/`) paths all count, and none of them is a task name. A leading `//` is the monorepo /// spelling (`//packages/app:build`), which names a task by its `:`; without one there is no /// task in it, only the path `mise run` makes of it by dropping a `/`. A monorepo name that is /// also the path of a file is no risk: the child `run_task` starts resolves names as tasks only. fn is_path_like(task: &str) -> bool { if task.starts_with("//") { return !task.contains(':'); } xx::regex!(r"^((\.*|~)[/\\]|\w:[/\\])").is_match(task) } #[cfg(test)] mod tests { use super::*; #[test] fn validate_task_name_rejects_flags_and_empty() { for task in ["", "-x", "--secrets-all", "--cd=/tmp", "--raw"] { let err = validate_task_name(task).unwrap_err(); assert_eq!(err.code, ErrorCode::INVALID_PARAMS, "{task:?}"); } assert_eq!( validate_task_name("--raw").unwrap_err().message, "invalid task name '--raw': task names cannot start with '-'" ); } #[test] fn validate_task_name_rejects_file_paths() { let dir = tempfile::tempdir().unwrap(); let script = dir.path().join("build.sh"); std::fs::write(&script, "#!/bin/sh\n").unwrap(); let script = script.to_string_lossy().to_string(); let mut tasks = vec![script.clone()]; // `mise run` strips one `/` from a leading `//`, so on Unix `//abs/path` runs // `/abs/path`. A Windows absolute path starts with a drive letter, so a `/` in front of // it is no path to `mise run` either. if cfg!(unix) { tasks.push(format!("/{script}")); } for task in tasks { // The validator refuses exactly what `mise run` would run as a script. assert!( crate::task::task_list::script_path_arg(&task).is_some(), "{task:?}" ); let err = validate_task_name(&task).unwrap_err(); assert_eq!(err.code, ErrorCode::INVALID_PARAMS, "{task:?}"); assert!(err.message.contains("not a file path"), "{task:?}"); } } #[test] fn validate_task_name_rejects_path_shapes_whether_or_not_they_exist() { for task in [ "./build.sh", "../clone/build.sh", "~/build.sh", "/does/not/exist/build.sh", "//does/not/exist/build.sh", r".\build.cmd", r"..\clone\build.cmd", r"\does\not\exist.cmd", r"\\server\share\build.cmd", r"C:\build.cmd", "C:/build.cmd", ] { let err = validate_task_name(task).unwrap_err(); assert_eq!(err.code, ErrorCode::INVALID_PARAMS, "{task:?}"); assert!(err.message.contains("not a file path"), "{task:?}"); } } #[test] fn validate_task_name_accepts_task_names() { for task in [ "build", "//sub:build", "//packages/app:build", "lint:fix", "a-b", "test:e2e", "test/e2e", "a/b:c", "docs:build/site", ".hidden", ] { validate_task_name(task).unwrap_or_else(|_| panic!("{task:?} should be accepted")); } } #[test] fn tool_spec_joins_the_version_and_rejects_flags() { assert_eq!(tool_spec("node", Some("20")).unwrap(), "node@20"); assert_eq!(tool_spec("node", None).unwrap(), "node"); assert_eq!(tool_spec("node", Some("")).unwrap(), "node"); for tool in ["", "-f", "--system"] { let err = tool_spec(tool, Some("1")).unwrap_err(); assert_eq!(err.code, ErrorCode::INVALID_PARAMS, "{tool:?}"); } } #[test] fn tool_spec_rejects_tool_options() { for (tool, version) in [ ("node[postinstall=touch x]", None), ("node[postinstall=touch x]", Some("20")), ("node[postinstall=touch x", Some("]")), ("npm:", Some("pkg[postinstall=touch x]")), ] { let err = tool_spec(tool, version).unwrap_err(); assert_eq!(err.code, ErrorCode::INVALID_PARAMS, "{tool:?} {version:?}"); } assert_eq!( tool_spec("node[postinstall=touch x]", Some("20")) .unwrap_err() .message, "invalid tool 'node[postinstall=touch x]@20': tool options are not accepted" ); } /// The text of every command row `list_commands` returns. async fn commands(include_hidden: bool) -> Vec { let res = MiseServer::new() .list_commands(Parameters(ListCommandsParams { include_hidden })) .await .unwrap(); let ContentBlock::Text(text) = &res.content[0] else { panic!("expected text content"); }; serde_json::from_str::(&text.text).unwrap()["commands"] .as_array() .unwrap() .clone() } fn find<'a>(commands: &'a [Value], path: &str) -> &'a Value { commands .iter() .find(|c| c["command"] == path) .unwrap_or_else(|| panic!("no command {path:?}")) } #[tokio::test] async fn list_commands_carries_the_declared_effects() { // The point of the tool. If these come back null the classification in // command_effects is not reaching the agent that needs it. let commands = commands(false).await; assert_eq!(find(&commands, "ls")["effect"], "read"); assert_eq!(find(&commands, "install")["effect"], "write"); assert_eq!(find(&commands, "prune")["effect"], "destructive"); assert_eq!(find(&commands, "dotfiles origin")["effect"], "destructive"); } #[tokio::test] async fn list_commands_reaches_nested_paths() { let commands = commands(false).await; assert_eq!(find(&commands, "tasks ls")["effect"], "read"); assert!(find(&commands, "tasks ls")["help"].is_string()); } #[tokio::test] async fn hidden_subtrees_are_excluded_by_default() { // clap does not propagate `hide`, so `bootstrap launchd` is hidden // while its children are not; the children must not leak either. let shown = commands(false).await; assert!(!shown.iter().any(|c| c["command"] == "bootstrap launchd")); assert!( !shown .iter() .any(|c| c["command"] == "bootstrap launchd apply") ); } #[tokio::test] async fn hidden_subtrees_are_included_when_requested() { let all = commands(true).await; // Hidden aliases keep the effect of the command they mount. assert_eq!( find(&all, "bootstrap dotfiles origin")["effect"], "destructive" ); assert!(all.iter().any(|c| c["command"] == "bootstrap launchd")); assert!( all.iter() .any(|c| c["command"] == "bootstrap launchd apply") ); } #[tokio::test] async fn nothing_is_unclassified_by_accident() { // command_effects tests its own table, but nothing there proves the // classification survives into what an agent is actually served. A // missing effect reads as "unknown, ask" — fine for `mise run`, whose // effect really is whatever the task does, and a silent gap for // anything else. let deliberate: std::collections::HashSet<_> = crate::cli::command_effects::UNCLASSIFIED .iter() .map(|(cmd, _)| *cmd) .collect(); let accidental: Vec<_> = commands(false) .await .iter() .filter(|c| c["effect"].is_null()) .map(|c| c["command"].as_str().unwrap().to_string()) .filter(|cmd| !deliberate.contains(cmd.as_str())) .collect(); assert!( accidental.is_empty(), "unclassified with no entry in command_effects::UNCLASSIFIED: {accidental:?}" ); } #[test] fn server_info_identifies_mise() { // rmcp defaults server_info to its own crate name/version, which would // make every MCP client see the server as "rmcp". let info = MiseServer::new().get_info(); assert_eq!(info.server_info.name, "mise"); assert_eq!(info.server_info.version, env!("CARGO_PKG_VERSION")); } }