diff --git a/Cargo.lock b/Cargo.lock index 10e9d3f2..1366ce5c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -116,6 +116,17 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -190,6 +201,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "cast" version = "0.3.0" @@ -651,12 +668,71 @@ dependencies = [ "num-traits", ] +[[package]] +name = "futures" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + [[package]] name = "futures-core" version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + [[package]] name = "futures-task" version = "0.3.33" @@ -669,8 +745,13 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -1081,6 +1162,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1286,6 +1373,41 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "rmcp" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14db48ee17a9ba61810ab1a9c1beb7d06d8136ae39ac25a1137f10d357af01af" +dependencies = [ + "async-trait", + "base64", + "chrono", + "futures", + "pastey", + "pin-project-lite", + "rmcp-macros", + "schemars 1.2.1", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "rmcp-macros" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "783d787bf21813b285f13019adc49e11af501c658890c1e519f31f937c68b7e3" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.119", +] + [[package]] name = "roff" version = "1.1.1" @@ -1344,12 +1466,26 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ + "chrono", "dyn-clone", "ref-cast", + "schemars_derive", "serde", "serde_json", ] +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + [[package]] name = "serde" version = "1.0.229" @@ -1380,6 +1516,17 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "serde_json" version = "1.0.151" @@ -1690,6 +1837,73 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1733,11 +1947,14 @@ dependencies = [ "miette", "predicates", "regex", + "rmcp", + "schemars 1.2.1", "serde", "serde_json", "serde_with", "tera", "thiserror", + "tokio", "usage-lib", "xx", ] diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 4e270b12..b3463cce 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -34,11 +34,14 @@ kdl = "6" log = "0.4" miette = { version = "7", features = ["fancy"] } regex = "1" +rmcp = { version = "2", features = ["server", "transport-io", "macros", "schemars"] } +schemars = "1" serde = { version = "1", features = ["derive"] } serde_json = "1.0" serde_with = "3" tera = "2" thiserror = "2" +tokio = { version = "1", features = ["rt", "macros", "io-std"] } usage-lib = { workspace = true, features = ["clap", "docs", "unstable_choices_env"] } xx = "2" diff --git a/cli/assets/fig.ts b/cli/assets/fig.ts index 9668d103..883e5355 100644 --- a/cli/assets/fig.ts +++ b/cli/assets/fig.ts @@ -532,6 +532,29 @@ const completionSpec: Fig.Spec = { template: "filepaths", }, }, + { + name: ["mcp", "mcp-server"], + description: "Serve a usage spec over the Model Context Protocol", + options: [ + { + name: ["-f", "--file"], + description: 'Usage spec file (not "-": stdin is the MCP transport)', + isRepeatable: false, + args: { + name: "file", + template: "filepaths", + }, + }, + { + name: ["-s", "--spec"], + description: "Raw string spec input", + isRepeatable: false, + args: { + name: "spec", + }, + }, + ], + }, { name: "powershell", description: "Execute a shell script using PowerShell", diff --git a/cli/assets/usage.1 b/cli/assets/usage.1 index 94119015..fe006c63 100644 --- a/cli/assets/usage.1 +++ b/cli/assets/usage.1 @@ -75,6 +75,12 @@ Generate a type\-safe SDK from a usage spec \fBlint\fR Lint a usage spec file for common issues .TP +\fBmcp\fR +Serve a usage spec over the Model Context Protocol +.RS +\fIAliases: \fRmcp\-server +.RE +.TP \fBpowershell\fR Execute a shell script with the specified shell .TP @@ -359,6 +365,22 @@ Treat warnings as errors .TP \fB\fR A usage spec file to lint, use "\-" to read from stdin +.SH "USAGE MCP" +Serve a usage spec over the Model Context Protocol + +Reads JSON\-RPC over stdin and writes responses to stdout, which is how MCP +clients launch a local server. Point one at `usage mcp \-f mycli.usage.kdl`. +.PP +\fBUsage:\fR usage mcp [OPTIONS] +.PP +\fBOptions:\fR +.PP +.TP +\fB\-f, \-\-file\fR \fI\fR +Usage spec file (not "\-": stdin is the MCP transport) +.TP +\fB\-s, \-\-spec\fR \fI\fR +Raw string spec input .SH "USAGE POWERSHELL" Execute a shell script with the specified shell diff --git a/cli/src/cli/mcp.rs b/cli/src/cli/mcp.rs new file mode 100644 index 00000000..16568b11 --- /dev/null +++ b/cli/src/cli/mcp.rs @@ -0,0 +1,528 @@ +//! An MCP server over stdio, serving a usage spec to an agent. +//! +//! The point is `effect`. An agent about to run `pitchfork logs --clear` can +//! ask what that does first and be told it deletes stored logs, without +//! running it and without the spec author writing prose about it. +//! +//! This is deliberately a local server rather than a hosted one: an agent doing +//! real work is in a project, in front of a CLI that is installed, and that is +//! a local question. It also means no hosting cost, no abuse surface, and it +//! works for private and internal CLIs that a public service could never see. +//! +//! Built on `rmcp`, the same as `mise mcp` and `fnox mcp`, so the three behave +//! alike and inherit protocol details — version negotiation, pagination, +//! cancellation, schema generation — rather than each reimplementing a subset. + +use std::path::PathBuf; +use std::sync::Arc; + +use miette::{bail, IntoDiagnostic, Result}; +use rmcp::{ + handler::server::{tool::ToolRouter, wrapper::Parameters, ServerHandler}, + model::{CallToolResult, ContentBlock, Implementation, ServerCapabilities, ServerInfo}, + tool, tool_handler, tool_router, ErrorData, ServiceExt, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use usage::{Spec, SpecArg, SpecCommand, SpecFlag}; + +use crate::cli::generate; + +/// What the client is told before it sees any command. The three effect values +/// are the reason to point an agent at this, so they are spelled out here +/// rather than left to be inferred from a field name. +const INSTRUCTIONS: &str = "Describes a CLI from its usage spec. Every command, flag and \ +argument may carry an `effect`: `read` only inspects state, `write` changes it, \ +`destructive` removes something that is work to get back. The effect of an invocation is \ +the highest of the command's and those of the flags and arguments given. A missing effect \ +means unknown — treat it as needing confirmation, not as safe."; + +/// Serve a usage spec over the Model Context Protocol +/// +/// Reads JSON-RPC over stdin and writes responses to stdout, which is how MCP +/// clients launch a local server. Point one at `usage mcp -f mycli.usage.kdl`. +#[derive(Debug, clap::Args)] +#[clap(visible_alias = "mcp-server", verbatim_doc_comment)] +pub struct Mcp { + // Unlike other subcommands this cannot be "-": stdin is the transport, so + // reading the spec from it would consume the session. + /// Usage spec file (not "-": stdin is the MCP transport) + #[clap(short, long)] + file: Option, + + /// Raw string spec input + #[clap(short, long, required_unless_present = "file", overrides_with = "file")] + spec: Option, +} + +impl Mcp { + pub fn run(&self) -> Result<()> { + // `-f -` reads stdin to EOF, which is the transport this then wants to + // serve on. Saying so beats a server that starts and instantly ends. + if self.file.as_deref().is_some_and(|f| f.as_os_str() == "-") { + bail!("`--file -` cannot be used with `mcp`: stdin is the MCP transport. Pass a path, or `--spec `."); + } + let spec = generate::file_or_spec(&self.file, &self.spec)?; + + // A current-thread runtime: this server is one stdio conversation with + // no concurrent work, so a thread pool would be cost without use. + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .into_diagnostic()? + .block_on(async move { + let service = SpecServer::new(spec) + .serve(rmcp::transport::io::stdio()) + .await + .into_diagnostic()?; + service.waiting().await.into_diagnostic()?; + Ok(()) + }) + } +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +pub struct ListCommandsParams { + /// Include commands hidden from help. They are still runnable. + #[serde(default)] + pub include_hidden: bool, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +pub struct DescribeCommandParams { + /// Command path without the binary, e.g. "logs" or "daemons remove". + pub command: String, +} + +#[derive(Clone)] +struct SpecServer { + spec: Arc, + tool_router: ToolRouter, +} + +#[tool_router] +impl SpecServer { + fn new(spec: Spec) -> Self { + Self { + spec: Arc::new(spec), + tool_router: Self::tool_router(), + } + } + + #[tool(description = "Every command in the CLI, with its effect. Start here.")] + async fn list_commands( + &self, + Parameters(ListCommandsParams { include_hidden }): Parameters, + ) -> std::result::Result { + let commands = list_commands(&self.spec, include_hidden); + Ok(json_result( + json!({ "bin": self.spec.bin, "commands": commands }), + )) + } + + #[tool( + description = "Full detail for one command: help, flags, arguments, and the effect of each. Use before running an unfamiliar command." + )] + async fn describe_command( + &self, + Parameters(DescribeCommandParams { command }): Parameters, + ) -> std::result::Result { + // Hidden commands are described on request even though `list_commands` + // omits them. An agent that names one already knows it exists; refusing + // would only mean it runs the thing without knowing the effect. The + // response says `"hidden": true` so the caller can weigh that. + match find_chain(&self.spec, &command) { + Some(chain) => Ok(json_result(describe(&self.spec, &chain))), + // A command the caller invented is their mistake, not a protocol + // failure, so it comes back as a tool error they can recover from. + None => Ok(CallToolResult::error(vec![ContentBlock::text(format!( + "no such command: {command:?}. Call list_commands to see what exists." + ))])), + } + } +} + +// `router = self.tool_router` uses the router built once in `new`. The macro's +// default is `Self::tool_router()`, which rebuilds it on every request. +#[tool_handler(router = self.tool_router)] +impl ServerHandler for SpecServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + // Without this the server names itself after rmcp, since rmcp's + // default reads the `CARGO_*` vars of its own crate. + .with_server_info(Implementation::new("usage", env!("CARGO_PKG_VERSION"))) + .with_instructions(INSTRUCTIONS) + } +} + +/// Structured data, plus the same JSON as text for clients that only read +/// `content` — which the spec asks servers returning structure to do. +fn json_result(value: Value) -> CallToolResult { + let text = serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string()); + CallToolResult::success(vec![ContentBlock::text(text)]) +} + +/// Flatten the tree. Hidden commands take their subtree with them, since a +/// visible child of a hidden parent is not a documented path. +fn list_commands(spec: &Spec, include_hidden: bool) -> Vec { + fn walk(cmd: &SpecCommand, path: &mut Vec, include_hidden: bool, out: &mut Vec) { + for (name, sub) in &cmd.subcommands { + 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 out = vec![]; + walk(&spec.cmd, &mut vec![], include_hidden, &mut out); + out +} + +/// Resolve a space-separated path, following aliases as a user would. +/// +/// Returns the whole chain from the root down to the match, because the +/// ancestors are not decoration: their `global` flags are part of what the +/// command accepts. +fn find_chain<'a>(spec: &'a Spec, path: &str) -> Option> { + let mut segments = path.split_whitespace().peekable(); + + // An agent that has seen the CLI in a shell will write the whole line, and + // a leading binary name is not a subcommand. Only skipped when the root has + // no subcommand by that name, so a CLI with a `usage usage` keeps working. + if segments.peek() == Some(&spec.bin.as_str()) && spec.cmd.find_subcommand(&spec.bin).is_none() + { + segments.next(); + } + + let mut chain = vec![&spec.cmd]; + for segment in segments { + chain.push(chain.last().unwrap().find_subcommand(segment)?); + } + // Just the root means the caller named no command, or named only the bin. + (chain.len() > 1).then_some(chain) +} + +/// Every flag the command accepts, including `global` ones from its ancestors. +/// +/// Delegated to `usage::available_flags` rather than walked here. usage resolves +/// globals while parsing an invocation instead of copying them onto each +/// subcommand, so `cmd.flags` alone is short by exactly the options an agent +/// reaches for — and the merge rules are subtle enough (a non-global +/// re-declaration of a global's long name keeps the *global's* effect, not the +/// local one) that a second implementation would state the effect wrongly, +/// which is the one thing this server must not do. +fn flags_for(chain: &[&SpecCommand]) -> Vec { + usage::available_flags(chain) + .iter() + .map(|f| describe_flag(f)) + .collect() +} + +fn describe(spec: &Spec, chain: &[&SpecCommand]) -> Value { + let cmd = chain.last().expect("chain is never empty"); + json!({ + // The path without the binary, which is what `list_commands` emits and + // what this tool takes back. Prefixing the bin here made the two ends + // disagree, so a path copied out of one response was rejected by the + // other. `bin` is reported alongside, as `list_commands` does. + "command": cmd.full_cmd.join(" "), + "bin": spec.bin, + "usage": cmd.usage, + "help": cmd.help, + "long_help": cmd.help_long, + "aliases": cmd.aliases, + "hidden": cmd.hide, + "effect": cmd.effect.map(|e| e.as_str()), + "args": cmd.args.iter().map(describe_arg).collect::>(), + "flags": flags_for(chain), + "subcommands": cmd.subcommands.keys().collect::>(), + }) +} + +fn describe_arg(arg: &SpecArg) -> Value { + json!({ + "name": arg.name, + "required": arg.required, + "variadic": arg.var, + "help": arg.help, + "effect": arg.effect.map(|e| e.as_str()), + "choices": arg.choices.as_ref().map(|c| c.choices.clone()), + }) +} + +fn describe_flag(flag: &SpecFlag) -> Value { + json!({ + "name": flag.name, + "short": flag.short.iter().map(|c| format!("-{c}")).collect::>(), + "long": flag.long.iter().map(|l| format!("--{l}")).collect::>(), + "help": flag.help, + "effect": flag.effect.map(|e| e.as_str()), + "hidden": flag.hide, + // Accepted by every subcommand, so it may appear anywhere in the line. + // There is no separate "inherited" flag on the output: a subcommand + // that re-declares a global is describing that same global, not a + // local flag that shadows it. + "global": flag.global, + "arg": flag.arg.as_ref().map(describe_arg), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SPEC: &str = r#" +name "pitchfork" +bin "pitchfork" +flag "-v --verbose" global=#true help="Verbose logging" +flag "-y --yes" global=#true effect="write" help="Skip confirmation" +flag "--not-global" help="Root only" +cmd "logs" effect="read" help="Displays logs" { + alias "l" + flag "-c --clear" effect="destructive" help="Delete logs" + flag "-t --tail" help="Follow" +} +cmd "daemons" help="Manage daemons" { + flag "-y --yes" help="Shadows the global one" + cmd "remove" effect="destructive" help="Remove a daemon" +} +cmd "internal" hide=#true { + cmd "child" +} +cmd "start" help="Runs a daemon" +"#; + + fn spec() -> Spec { + SPEC.parse().unwrap() + } + + fn commands(include_hidden: bool) -> Vec { + list_commands(&spec(), include_hidden) + } + + fn paths(include_hidden: bool) -> Vec { + commands(include_hidden) + .iter() + .map(|c| c["command"].as_str().unwrap().to_string()) + .collect() + } + + #[test] + fn hidden_subtrees_are_excluded_by_default() { + // A visible child of a hidden parent is not a documented path. + assert_eq!(paths(false), ["logs", "daemons", "daemons remove", "start"]); + } + + #[test] + fn hidden_can_be_included() { + let all = paths(true); + assert!(all.contains(&"internal".to_string())); + assert!(all.contains(&"internal child".to_string())); + } + + #[test] + fn commands_carry_their_effect() { + let all = commands(false); + let by_path = |p: &str| { + all.iter() + .find(|c| c["command"] == p) + .cloned() + .unwrap_or_else(|| panic!("no {p}")) + }; + assert_eq!(by_path("logs")["effect"], "read"); + assert_eq!(by_path("daemons remove")["effect"], "destructive"); + // Unset stays null rather than defaulting to something reassuring. + assert!(by_path("start")["effect"].is_null()); + } + + fn described(spec: &Spec, path: &str) -> Value { + describe(spec, &find_chain(spec, path).unwrap()) + } + + fn flag<'a>(out: &'a Value, name: &str) -> &'a Value { + out["flags"] + .as_array() + .unwrap() + .iter() + .find(|f| f["name"] == name) + .unwrap_or_else(|| panic!("no flag {name}")) + } + + #[test] + fn describe_reports_flag_effects() { + let spec = spec(); + let out = described(&spec, "logs"); + assert_eq!(out["effect"], "read"); + assert_eq!(out["command"], "logs"); + assert_eq!(out["bin"], "pitchfork"); + assert_eq!(out["aliases"][0], "l"); + + let flags = out["flags"].as_array().unwrap(); + let clear = flags.iter().find(|f| f["name"] == "clear").unwrap(); + assert_eq!(clear["effect"], "destructive"); + assert_eq!(clear["long"][0], "--clear"); + assert_eq!(clear["short"][0], "-c"); + + // A flag with no effect must not inherit the command's. + let tail = flags.iter().find(|f| f["name"] == "tail").unwrap(); + assert!(tail["effect"].is_null()); + } + + #[test] + fn nested_paths_and_aliases_resolve() { + let spec = spec(); + assert_eq!( + described(&spec, "daemons remove")["help"], + "Remove a daemon" + ); + // `l` is an alias for `logs`, and an agent may well have seen it. + assert_eq!(described(&spec, "l")["help"], "Displays logs"); + assert!(find_chain(&spec, "nope").is_none()); + assert!(find_chain(&spec, "logs nope").is_none()); + // The root on its own is not a command anyone can be told about. + assert!(find_chain(&spec, "").is_none()); + } + + #[test] + fn inherited_global_flags_are_included() { + // usage resolves globals when parsing an invocation instead of copying + // them onto each subcommand, so reporting only `cmd.flags` would tell + // an agent that `--yes` does not exist on a command that accepts it. + let spec = spec(); + let out = described(&spec, "daemons remove"); + + assert_eq!(flag(&out, "verbose")["global"], true); + // Inherited flags carry their effect, which is the whole point. + assert_eq!(flag(&out, "yes")["effect"], "write"); + + // A root flag that is not global is not accepted down here. + assert!(!out["flags"] + .as_array() + .unwrap() + .iter() + .any(|f| f["name"] == "not-global")); + } + + #[test] + fn a_re_declared_global_keeps_the_globals_effect() { + // `daemons` re-declares `-y --yes` without `global` or an effect. That + // is the same logical flag, not a local one shadowing it: the parser + // keeps the global's declaration and unions in the extra aliases. A + // hand-rolled "nearest wins" would report `effect: null` here, telling + // an agent that `--yes` is free of consequence when it is not. + let spec = spec(); + let out = described(&spec, "daemons"); + let yes: Vec<_> = out["flags"] + .as_array() + .unwrap() + .iter() + .filter(|f| f["name"] == "yes") + .collect(); + assert_eq!(yes.len(), 1, "{yes:?}"); + assert_eq!(yes[0]["effect"], "write"); + assert_eq!(yes[0]["global"], true); + } + + #[test] + fn a_local_only_flag_is_reported_as_local() { + let spec = spec(); + let out = described(&spec, "logs"); + assert_eq!(flag(&out, "tail")["global"], false); + } + + #[test] + fn a_path_from_one_tool_is_accepted_by_the_other() { + // `list_commands` emits paths without the binary. Every one of them + // must resolve here, or an agent doing the obvious thing — read the + // list, describe an entry — gets a tool error on a command that exists. + let spec = spec(); + for row in list_commands(&spec, true) { + let path = row["command"].as_str().unwrap(); + let out = described(&spec, path); + assert_eq!(out["command"], path, "round trip failed for {path:?}"); + } + } + + #[test] + fn a_leading_binary_name_is_tolerated() { + // An agent that has seen the CLI in a shell writes the whole line. + let spec = spec(); + assert_eq!( + described(&spec, "pitchfork daemons remove")["command"], + "daemons remove" + ); + // The bin alone names no command. + assert!(find_chain(&spec, "pitchfork").is_none()); + } + + #[test] + fn a_subcommand_sharing_the_binarys_name_still_resolves() { + // The skip must not eat a real command. `usage usage` is not + // hypothetical — a CLI that describes itself is exactly this shape. + let spec: Spec = "bin \"usage\"\ncmd \"usage\" help=\"self\"" + .parse() + .unwrap(); + assert_eq!(described(&spec, "usage")["help"], "self"); + } + + #[test] + fn a_hidden_command_can_still_be_described() { + // `list_commands` omits it, but an agent that names one already knows + // it exists. Refusing would only mean running it without the effect. + let spec = spec(); + let out = described(&spec, "internal child"); + assert_eq!(out["command"], "internal child"); + } + + #[test] + fn the_instructions_explain_what_effect_means() { + // The client sees these before any command, so they carry the meaning + // of the field that makes this server worth pointing at. + for value in ["read", "write", "destructive"] { + assert!(INSTRUCTIONS.contains(value), "missing {value}"); + } + assert!(INSTRUCTIONS.contains("confirmation")); + } + + #[tokio::test] + async fn tools_are_registered_with_schemas() { + let server = SpecServer::new(spec()); + let names: Vec<_> = server + .tool_router + .list_all() + .into_iter() + .map(|t| t.name.to_string()) + .collect(); + assert!(names.contains(&"list_commands".to_string()), "{names:?}"); + assert!(names.contains(&"describe_command".to_string()), "{names:?}"); + } + + #[tokio::test] + async fn describing_a_missing_command_is_a_tool_error() { + let server = SpecServer::new(spec()); + let res = server + .describe_command(Parameters(DescribeCommandParams { + command: "nope".into(), + })) + .await + .unwrap(); + assert_eq!(res.is_error, Some(true)); + } + + #[test] + fn server_info_declares_tools_and_instructions() { + let info = SpecServer::new(spec()).get_info(); + assert!(info.capabilities.tools.is_some()); + assert_eq!(info.server_info.name, "usage"); + assert!(info.instructions.is_some()); + } +} diff --git a/cli/src/cli/mod.rs b/cli/src/cli/mod.rs index 0c84d977..f22e7ef6 100644 --- a/cli/src/cli/mod.rs +++ b/cli/src/cli/mod.rs @@ -6,6 +6,7 @@ mod complete_word; mod exec; pub(crate) mod generate; mod lint; +mod mcp; mod shell; mod sponsors; @@ -33,6 +34,7 @@ enum Command { Fish(shell::Shell), Generate(generate::Generate), Lint(lint::Lint), + Mcp(mcp::Mcp), #[clap(name = "powershell", about = "Execute a shell script using PowerShell")] PowerShell(shell::Shell), Sponsors(sponsors::Sponsors), @@ -55,6 +57,7 @@ impl Cli { Command::Exec(mut cmd) => cmd.run(), Command::CompleteWord(cmd) => cmd.run(), Command::Lint(cmd) => cmd.run(), + Command::Mcp(cmd) => cmd.run(), Command::Sponsors(cmd) => cmd.run(), } } diff --git a/cli/src/command_effects.rs b/cli/src/command_effects.rs index 5b0f4626..31665ecd 100644 --- a/cli/src/command_effects.rs +++ b/cli/src/command_effects.rs @@ -34,6 +34,10 @@ const EFFECTS: &[(&str, SpecCommandEffect)] = &[ // to stdout, so every invocation writes a directory. ("generate sdk", Write), ("lint", Read), + // Long-running, but every tool it serves only reads the spec it was given. + // Unlike `mise mcp`, which is unclassified because it serves a tool that + // runs tasks, nothing here can act on the CLI it describes. + ("mcp", Read), ("sponsors", Read), ]; diff --git a/cli/usage.usage.kdl b/cli/usage.usage.kdl index a5cffc0b..2efd7c79 100644 --- a/cli/usage.usage.kdl +++ b/cli/usage.usage.kdl @@ -203,6 +203,21 @@ cmd lint help="Lint a usage spec file for common issues" effect=read { flag "-W --warnings-as-errors" help="Treat warnings as errors" arg help="A usage spec file to lint, use \"-\" to read from stdin" } +cmd mcp help="Serve a usage spec over the Model Context Protocol" effect=read { + alias mcp-server + long_help #""" +Serve a usage spec over the Model Context Protocol + +Reads JSON-RPC over stdin and writes responses to stdout, which is how MCP +clients launch a local server. Point one at `usage mcp -f mycli.usage.kdl`. +"""# + flag "-f --file" help="Usage spec file (not \"-\": stdin is the MCP transport)" { + arg + } + flag "-s --spec" help="Raw string spec input" { + arg + } +} cmd powershell help="Execute a shell script using PowerShell" { long_help #""" Execute a shell script with the specified shell diff --git a/docs/cli/reference/commands.json b/docs/cli/reference/commands.json index 5c45f916..a1f6e1cc 100644 --- a/docs/cli/reference/commands.json +++ b/docs/cli/reference/commands.json @@ -928,6 +928,57 @@ "hidden_aliases": [], "examples": [] }, + "mcp": { + "full_cmd": ["mcp"], + "usage": "mcp [-f --file ] [-s --spec ]", + "subcommands": {}, + "args": [], + "flags": [ + { + "name": "file", + "usage": "-f --file ", + "help": "Usage spec file (not \"-\": stdin is the MCP transport)", + "help_first_line": "Usage spec file (not \"-\": stdin is the MCP transport)", + "short": ["f"], + "long": ["file"], + "hide": false, + "global": false, + "arg": { + "name": "FILE", + "usage": "", + "required": true, + "double_dash": "Optional", + "hide": false + } + }, + { + "name": "spec", + "usage": "-s --spec ", + "help": "Raw string spec input", + "help_first_line": "Raw string spec input", + "short": ["s"], + "long": ["spec"], + "hide": false, + "global": false, + "arg": { + "name": "SPEC", + "usage": "", + "required": true, + "double_dash": "Optional", + "hide": false + } + } + ], + "mounts": [], + "effect": "read", + "hide": false, + "help": "Serve a usage spec over the Model Context Protocol", + "help_long": "Serve a usage spec over the Model Context Protocol\n\nReads JSON-RPC over stdin and writes responses to stdout, which is how MCP\nclients launch a local server. Point one at `usage mcp -f mycli.usage.kdl`.", + "name": "mcp", + "aliases": ["mcp-server"], + "hidden_aliases": [], + "examples": [] + }, "powershell": { "full_cmd": ["powershell"], "usage": "powershell [-h] [--help]