From a7e1de76ac883eb59352ccc6962782f1545bf249 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Thu, 12 Nov 2026 12:17:14 +0100 Subject: [PATCH] Import mcp-to-cli from the rhema workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The crate maps MCP tool catalogs to clap commands and back, and has no rhema dependencies — it was in-tree only because the workspace glob reached it. Its own repository gives it its own release cadence and its own crates.io token, scoped to this crate alone. --- .gitea/workflows/ci.yml | 25 + .gitea/workflows/release.yml | 38 + .gitignore | 1 + Cargo.lock | 165 +++ Cargo.toml | 20 + LICENSE | 21 + README.md | 68 ++ examples/blender.rs | 98 ++ examples/catalog.rs | 93 ++ examples/fetch-github-snaps.sh | 16 + examples/github.rs | 44 + src/lib.rs | 1051 ++++++++++++++++++++ tests/common/mod.rs | 46 + tests/fixtures/README.md | 40 + tests/fixtures/additional_fields.json | 20 + tests/fixtures/create_issue.json | 41 + tests/fixtures/create_pull_request.json | 51 + tests/fixtures/get_scene_info.json | 43 + tests/fixtures/projects_write_excerpt.json | 73 ++ tests/fixtures/search_repositories.json | 46 + tests/fixtures/update_pull_request.json | 57 ++ tests/mapping.rs | 1047 +++++++++++++++++++ tests/real_catalog.rs | 424 ++++++++ tests/selection.rs | 289 ++++++ 24 files changed, 3817 insertions(+) create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitea/workflows/release.yml create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 examples/blender.rs create mode 100644 examples/catalog.rs create mode 100755 examples/fetch-github-snaps.sh create mode 100644 examples/github.rs create mode 100644 src/lib.rs create mode 100644 tests/common/mod.rs create mode 100644 tests/fixtures/README.md create mode 100644 tests/fixtures/additional_fields.json create mode 100644 tests/fixtures/create_issue.json create mode 100644 tests/fixtures/create_pull_request.json create mode 100644 tests/fixtures/get_scene_info.json create mode 100644 tests/fixtures/projects_write_excerpt.json create mode 100644 tests/fixtures/search_repositories.json create mode 100644 tests/fixtures/update_pull_request.json create mode 100644 tests/mapping.rs create mode 100644 tests/real_catalog.rs create mode 100644 tests/selection.rs diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..bdd1c2d --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,25 @@ +name: ci + +on: + push: + pull_request: + +env: + CARGO_TERM_COLOR: always + +jobs: + checks: + runs-on: ubuntu-latest + steps: + - uses: https://gitea.com/actions/checkout@v4 + + - uses: actions-rust-lang/setup-rust-toolchain@v1 + + - name: Format + run: cargo fmt --all -- --check + + - name: Clippy + run: cargo clippy --all-targets -- -D warnings + + - name: Test + run: cargo test diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..0025787 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,38 @@ +name: release + +on: + push: + tags: ["v*"] + +jobs: + publish: + # Prerelease tags (v0.1.0-rc1) do not reach crates.io. + if: ${{ !contains(gitea.ref_name, '-') }} + runs-on: ubuntu-latest + steps: + - uses: https://gitea.com/actions/checkout@v4 + + - uses: actions-rust-lang/setup-rust-toolchain@v1 + + - name: Check the tag matches the crate version + run: | + crate_version="$(awk -F'"' '/^version =/{print $2; exit}' Cargo.toml)" + tag_version="${GITHUB_REF_NAME#v}" + if [ "$crate_version" != "$tag_version" ]; then + echo "tag $GITHUB_REF_NAME does not match crate version $crate_version" >&2 + exit 1 + fi + + - name: Publish + # Idempotent: a re-run for a version that already landed skips + # instead of failing. + run: | + version="${GITHUB_REF_NAME#v}" + if curl -fsSL -A "mcp-to-cli (git.vhaudiquet.fr/vhaudiquet/mcp-to-cli)" \ + "https://crates.io/api/v1/crates/mcp-to-cli/$version" >/dev/null 2>&1; then + echo "mcp-to-cli v$version is already on crates.io, skipping" + exit 0 + fi + cargo publish + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..17f7345 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,165 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "itoa" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bd1574e617a9a6c1f8f8181bd7e841e1f20e4277607ef28945765188d6284d4" + +[[package]] +name = "mcp-to-cli" +version = "0.1.0" +dependencies = [ + "clap", + "serde_json", + "thiserror", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae9ccbea3a7b0b09443036490c8f34a8b1b760fd345e5c0dae0aac718faea79" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..48cfda6 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "mcp-to-cli" +description = "JSON Schema tool catalogs to clap commands for MCP servers" +version = "0.1.0" +edition = "2024" +rust-version = "1.89" +license = "MIT" +repository = "https://git.vhaudiquet.fr/vhaudiquet/mcp-to-cli" +authors = ["Valentin Haudiquet"] + +[dependencies] +# The builder API only: the derive feature stays for the crates that +# define their own commands, and `string` lets flag names and help +# text come from runtime schemas. Default features minus `color`. +clap = { version = "4.5", default-features = false, features = ["std", "string", "help", "usage", "error-context", "suggestions"] } +serde_json = "1.0" +thiserror = "2" + +[lints.rust] +missing_docs = "deny" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5d507bd --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Valentin Haudiquet + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d5bd145 --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# mcp-to-cli + +MCP servers advertise their tools as `(name, description, JSON Schema)` +triples. `mcp-to-cli` turns each CLI-safe tool into a clap subcommand +whose flags come from the schema's top-level properties, and turns +parsed argv back into the `{property: value}` object the server +expects. + +It is pure mapping: no I/O, no processes, no MCP protocol. `clap`, +`serde_json`, and `thiserror` are the whole dependency list. + +## Use + +```rust +use mcp_to_cli::{Payload, ToolInfo, args, command}; + +fn main() { + let catalog = [ToolInfo { + name: "create_pull_request".into(), + description: "Create a new pull request".into(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "title": {"type": "string"}, + "draft": {"type": "boolean", "default": false} + }, + "required": ["title"] + }), + }]; + + let cmd = command("github", &catalog); + let matches = cmd + .try_get_matches_from(["github", "create_pull_request", "--title", "Ship it"]) + .expect("argv parses"); + + match args(&cmd, &matches).expect("argv maps") { + Payload::Mapped(value) => println!("{value}"), // {"title":"Ship it"} + Payload::Overridden(raw) => println!("{raw}"), // the --json string, verbatim + Payload::Schema { tool } => println!("print the schema of {tool}"), + _ => unreachable!(), + } +} +``` + +The mapping is conservative, with an escape hatch at every level of +schema complexity: + +- enums become clap `possible_values`; +- scalar arrays become repeatable flags; +- every shape the flag form cannot express collapses to a + `--_json` flag; +- `--json` carries the whole argument object verbatim, for scripts; +- schema defaults are never pre-filled: an omitted flag omits the key, + and the server stays the validator. + +Discovery rides along: a bare invocation answers with the tool list +(`list_json`), and `--schema` asks for one tool's `input_schema` +instead of calling it (`Payload::Schema`). + +## Examples + +``` +cargo run --example github # the real GitHub create_pull_request schema +cargo run --example blender # a Blender MCP server's tools +cargo run --example catalog # every tool of a JSON catalog +``` + +`cargo test` drives the mapping matrix against vendored real schemas. diff --git a/examples/blender.rs b/examples/blender.rs new file mode 100644 index 0000000..5585394 --- /dev/null +++ b/examples/blender.rs @@ -0,0 +1,98 @@ +//! A runnable demo of the mapping: a Blender-shaped catalog, rendered. +//! +//! The schemas below mirror what FastMCP servers actually emit (nullable +//! `anyOf` optionals, schema defaults), so the output is what a real +//! `blender` command would look like. Bare invocation prints the full +//! help; `list` prints the bare-invocation JSON; anything after `--` is +//! parsed as argv and printed back as the mapped JSON arguments: +//! +//! ```sh +//! cargo run -p mcp-to-cli --example blender +//! cargo run -p mcp-to-cli --example blender -- list +//! cargo run -p mcp-to-cli --example blender -- get_scene_info --help +//! cargo run -p mcp-to-cli --example blender -- get_scene_info --query chair --fields location --limit 5 +//! ``` +use mcp_to_cli::{Payload, ToolInfo, args, command, list_json}; + +fn catalog() -> Vec { + let nullable = |t: &str| serde_json::json!({ "anyOf": [{"type": t}, {"type": "null"}] }); + vec![ + ToolInfo { + name: "get_scene_info".into(), + description: "Facts about the scene as text: what's there, where, how big, and \ + how healthy meshes and rigs are. No image; to see the scene, use look." + .into(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "user_prompt": {"type": "string", "default": ""}, + "query": nullable("string"), + "root": nullable("string"), + "fields": {"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}]}, + "limit": {"type": "integer", "default": 20}, + }, + }), + }, + ToolInfo { + name: "execute_blender_code".into(), + description: "Run Python in the user's live Blender (bpy, bmesh, mathutils). \ + Whatever it prints is returned." + .into(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "code": {"type": "string", "description": "The Python code to execute"}, + "user_prompt": {"type": "string", "default": ""}, + }, + "required": ["code"], + }), + }, + ToolInfo { + name: "look".into(), + description: "Grab a viewport screenshot and return it as an image.".into(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "max_size": {"type": "integer", "default": 1000}, + "user_prompt": {"type": "string", "default": ""}, + }, + }), + }, + ] +} + +fn main() { + let catalog = catalog(); + let argv: Vec = std::env::args().skip(1).collect(); + let mut cmd = command("blender", &catalog); + + if argv.as_slice() == ["list"] { + println!("{}", list_json(&catalog)); + return; + } + if argv.is_empty() { + cmd.print_help().expect("help renders to stdout"); + return; + } + + let mut full = vec!["blender"]; + full.extend(argv.iter().map(String::as_str)); + let matches = cmd + .clone() + .try_get_matches_from(full) + .unwrap_or_else(|e| e.exit()); + match args(&cmd, &matches) { + Ok(Payload::Mapped(v)) => { + println!( + "{}", + serde_json::to_string_pretty(&v).expect("values render") + ) + } + Ok(Payload::Overridden(raw)) => println!("--json override, verbatim: {raw}"), + Ok(_) => unreachable!("payload variants are the two above"), + Err(e) => { + eprintln!("{e}"); + std::process::exit(2); + } + } +} diff --git a/examples/catalog.rs b/examples/catalog.rs new file mode 100644 index 0000000..3068dca --- /dev/null +++ b/examples/catalog.rs @@ -0,0 +1,93 @@ +//! A stress harness for the mapping: build one command tree from a +//! directory of real MCP tool definitions (wire-format JSON objects +//! with `name`, `description`, `inputSchema` — e.g. github-mcp-server's +//! `__toolsnaps__`), then behave like the other examples. Prints a +//! render summary to stderr. Build once, drive it like a CLI: +//! +//! ```sh +//! cargo build -p mcp-to-cli --example catalog +//! target/debug/examples/catalog github /path/to/__toolsnaps__ +//! target/debug/examples/catalog github /path/to/__toolsnaps__ list +//! target/debug/examples/catalog github /path/to/__toolsnaps__ create_issue --help +//! target/debug/examples/catalog github /path/to/__toolsnaps__ create_issue --owner acme --repo api --title hi +//! ``` +use mcp_to_cli::{Payload, ToolInfo, args, command, list_json}; + +fn main() { + let mut argv = std::env::args().skip(1); + let name = argv.next().unwrap_or_else(|| { + eprintln!("usage: catalog [argv]"); + std::process::exit(2); + }); + let dir = argv.next().unwrap_or_else(|| { + eprintln!("usage: catalog [argv]"); + std::process::exit(2); + }); + let argv: Vec = argv.collect(); + + let mut paths: Vec<_> = std::fs::read_dir(&dir) + .unwrap_or_else(|e| panic!("{dir}: {e}")) + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| p.extension().is_some_and(|e| e == "json" || e == "snap")) + .collect(); + paths.sort(); + let catalog: Vec = paths + .iter() + .map(|p| { + let raw: serde_json::Value = std::fs::read_to_string(p) + .expect("fixture reads") + .parse() + .expect("tool JSON parses"); + let field = |k: &str| { + raw.get(k) + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_owned() + }; + ToolInfo { + name: field("name"), + description: field("description"), + input_schema: raw.get("inputSchema").cloned().unwrap_or_default(), + } + }) + .collect(); + let rendered = command(&name, &catalog) + .get_subcommands() + .filter(|c| c.get_name() != "help") + .count(); + eprintln!( + "{} tools loaded, {} rendered as subcommands", + catalog.len(), + rendered, + ); + + let mut cmd = command(&name, &catalog); + if argv.as_slice() == ["list"] { + println!("{}", list_json(&catalog)); + return; + } + if argv.is_empty() { + cmd.print_help().expect("help renders to stdout"); + return; + } + let mut full = vec![name.as_str()]; + full.extend(argv.iter().map(String::as_str)); + let matches = cmd + .clone() + .try_get_matches_from(full) + .unwrap_or_else(|e| e.exit()); + match args(&cmd, &matches) { + Ok(Payload::Mapped(v)) => { + println!( + "{}", + serde_json::to_string_pretty(&v).expect("values render") + ) + } + Ok(Payload::Overridden(raw)) => println!("--json override, verbatim: {raw}"), + Ok(_) => unreachable!("payload variants are the two above"), + Err(e) => { + eprintln!("{e}"); + std::process::exit(2); + } + } +} diff --git a/examples/fetch-github-snaps.sh b/examples/fetch-github-snaps.sh new file mode 100755 index 0000000..dbe734f --- /dev/null +++ b/examples/fetch-github-snaps.sh @@ -0,0 +1,16 @@ +#!/bin/sh +# Fetch github-mcp-server's real tool definitions (the `__toolsnaps__` +# the upstream test suite snapshots) into a scratch directory, for the +# catalog example to build a full command tree from. Not a test +# fixture: the full set lives out of the tree, the trimmed fixtures in +# tests/fixtures/ are the committed matrix. +# +# crates/mcp-to-cli/examples/fetch-github-snaps.sh /tmp/snaps +set -eu +dir=${1:?usage: fetch-github-snaps.sh } +mkdir -p "$dir" +base=https://raw.githubusercontent.com/github/github-mcp-server/main/pkg/github/__toolsnaps__ +curl -sfL "https://api.github.com/repos/github/github-mcp-server/contents/pkg/github/__toolsnaps__" | + jq -r '.[].name' | + xargs -P 8 -I{} curl -sfL "$base/{}" -o "$dir/{}" +echo "$(ls "$dir" | wc -l | tr -d ' ') tool definitions in $dir" diff --git a/examples/github.rs b/examples/github.rs new file mode 100644 index 0000000..ca904d9 --- /dev/null +++ b/examples/github.rs @@ -0,0 +1,44 @@ +//! A runnable demo of the required-argument story: the real GitHub +//! `create_pull_request` schema (five required of nine properties), so +//! the help output is what a real `github` command would look like. +//! Same driver as the Blender example: bare prints help, argv maps. +use mcp_to_cli::{Payload, ToolInfo, args, command}; + +fn main() { + let schema = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/create_pull_request.json" + )); + let catalog = [ToolInfo { + name: "create_pull_request".into(), + description: "Create a new pull request in a GitHub repository".into(), + input_schema: serde_json::from_str(schema).expect("fixture parses"), + }]; + let mut cmd = command("github", &catalog); + + let argv: Vec = std::env::args().skip(1).collect(); + if argv.is_empty() { + cmd.print_help().expect("help renders to stdout"); + return; + } + let mut full = vec!["github"]; + full.extend(argv.iter().map(String::as_str)); + let matches = cmd + .clone() + .try_get_matches_from(full) + .unwrap_or_else(|e| e.exit()); + match args(&cmd, &matches) { + Ok(Payload::Mapped(v)) => { + println!( + "{}", + serde_json::to_string_pretty(&v).expect("values render") + ) + } + Ok(Payload::Overridden(raw)) => println!("--json override, verbatim: {raw}"), + Ok(_) => unreachable!("payload variants are the two above"), + Err(e) => { + eprintln!("{e}"); + std::process::exit(2); + } + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..0184ca9 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,1051 @@ +//! Map MCP tool catalogs to clap commands, and argv back to JSON. +//! +//! An MCP server advertises its tools as `(name, description, JSON +//! Schema)` triples; this crate turns each CLI-safe tool into a clap +//! subcommand whose flags come from the schema's top-level properties, +//! then turns parsed argv back into the `{property: value}` object the +//! server expects. It is pure mapping: no I/O, no processes, no MCP +//! protocol, no rhema dependencies — `clap`, `serde_json`, and +//! `thiserror` are the whole list. +//! +//! The mapping is conservative, with escape hatches at every level of +//! schema complexity: enums become clap `possible_values`, scalar +//! arrays become repeatable flags, and every shape the flag form +//! cannot express collapses to a `--_json` flag or to the +//! per-tool `--json` payload override. Schema defaults are never +//! pre-filled — an omitted flag omits the key — and the server stays +//! the validator: constraint keywords are ignored and JSON-flag +//! properties are never clap-required. + +#![deny(missing_docs)] +#![forbid(unsafe_code)] + +use std::collections::BTreeSet; + +use clap::builder::PossibleValuesParser; +use clap::{Arg, ArgAction, ArgMatches, Command}; +use serde_json::{Map, Number, Value}; + +/// One advertised MCP tool: its name, description, and input schema. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ToolInfo { + /// The tool's name as the server advertises it, e.g. `create_issue`. + pub name: String, + /// The tool's description, flowed into the subcommand's help verbatim. + pub description: String, + /// The tool's JSON Schema input: the wire format's `inputSchema` + /// object, read as-is. + pub input_schema: Value, +} + +/// The arguments for one tool call, recovered from parsed argv. +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum Payload { + /// The mapped `{property: value}` object: one entry per flag the + /// caller passed, absent flags leaving absent keys. + Mapped(Value), + /// The raw `--json` string, verbatim. `-` means stdin and is the + /// caller's to read; this crate never touches I/O. + Overridden(String), + /// A discovery request: the caller should print this tool's + /// `input_schema` and exit. The value is the subcommand's tool + /// name; [`schema_of`] is the lookup that matches it. + Schema { + /// The tool whose schema the caller should print. + tool: String, + }, +} + +/// Why parsed argv could not be mapped to tool arguments. +#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] +#[non_exhaustive] +pub enum MappingError { + /// No subcommand matched: the invocation was bare, and the tool + /// list ([`list_json`]) is the answer. + #[error("no subcommand was matched; a bare invocation lists the tools")] + NoSubcommand, + /// The matches carry a subcommand the command does not define. + #[error("unknown subcommand `{name}`")] + UnknownSubcommand { + /// The subcommand name found in the matches but missing from + /// the command. + name: String, + }, + /// The `--json` payload override was combined with the tool's own + /// flags. + #[error("the --json override cannot be combined with the tool's own flags")] + JsonConflict, +} + +/// Build the root command for one server's catalog: one subcommand per +/// CLI-safe tool, flags from the tool's input schema. +/// +/// The returned [`Command`] requires no subcommand — a bare invocation +/// is the caller's cue to print [`list_json`] — and carries no +/// decoration of its own, so the caller is free to add `.about()`, +/// `.version()`, and friends. A tool becomes a subcommand when +/// [`cli_safe`] accepts its name, its name is not `help` (clap owns +/// that subcommand), and its schema's root is an object; an explicit +/// non-object `"type"` omits the tool, a missing `"type"` is read as +/// the object shape every tool call has. Tools left out are reachable +/// only through the caller's raw-call path; duplicate names keep the +/// first occurrence. +/// +/// Each tool renders per its schema, property names passing through +/// verbatim (`--per_page` — no case translation, no aliases): +/// +/// - `string` is a value flag; a string `enum` adds clap +/// `possible_values`, so validation and completion come free. An +/// enum with any non-string value falls back to the JSON flag. +/// - `number` parses as `f64`, `integer` as `i64`. +/// - A required boolean is a value flag accepting exactly `true` or +/// `false`. An optional boolean with no `default` (or `default: +/// false`) is a presence flag — passing it maps `true`, omitting it +/// omits the key. With `default: true` it stays a value flag, never +/// a guess. +/// - An array of scalars is a repeatable flag (`--tag a --tag b`, +/// one value per occurrence); an array of anything else takes the +/// JSON flag. +/// - Objects, `anyOf`/`oneOf`/`allOf`, and unknown or missing types +/// map to `--_json`, taking one raw JSON value verbatim. +/// A two-variant `anyOf`/`oneOf` of `{"type": "null"}` and one other +/// shape — the `Optional[T]` form FastMCP emits for every optional +/// parameter — unwraps to that other shape first, recursively and +/// through array `items`; so does a `type` array of `"null"` plus +/// one other type. Nullable properties stay optional: JSON `null` is +/// a value the flag form cannot say, so they are never +/// clap-required. +/// - A property in the schema's `required` array is clap-required — +/// but only when its flag can express the value, and never beneath +/// the `--json` override, which replaces the whole payload. +/// JSON-flag properties are never clap-required and stay the +/// server's to validate. +/// - Schema `default`s are never pre-filled. Constraint keywords +/// (`pattern`, `minimum`, `maximum`, `format`, ...) and unknown +/// extension keys (`x-mcp-header`) are ignored. +/// +/// Two collisions demote a tool to a subcommand with no property +/// flags, reachable only through `--json`: a property literally named +/// `json` would collide with the payload override, and any flag +/// derived from the tool that collides with another (`help` or `json` +/// as a property name, or a `_json` flag against a sibling) +/// does the same. Every subcommand also carries the `--json ` +/// payload override; see [`args`]. +#[must_use] +pub fn command(server_name: &str, catalog: &[ToolInfo]) -> Command { + let mut root = Command::new(server_name.to_owned()).subcommand_required(false); + let mut seen = BTreeSet::new(); + for tool in catalog { + if !renderable(tool) || !seen.insert(tool.name.as_str()) { + continue; + } + root = root.subcommand(tool_subcommand(server_name, tool)); + } + root +} + +/// Recover one tool call's arguments from matches parsed against +/// [`command`]'s tree. +/// +/// The matched subcommand's flags become the [`Payload::Mapped`] +/// object, each passed flag contributing its key and absent flags +/// leaving absent keys — schema defaults included, never pre-filled. +/// A lone `--json` wins as [`Payload::Overridden`], carrying the raw +/// string untouched; `-` means stdin and is never read here. `--json` +/// next to any of the tool's own flags is [`MappingError::JsonConflict`]. +/// `--schema` short-circuits everything as [`Payload::Schema`]: a +/// question about the shape, not a call. +/// +/// # Errors +/// +/// - [`MappingError::NoSubcommand`] when `matches` has no subcommand: +/// a bare invocation, answered with [`list_json`]. +/// - [`MappingError::UnknownSubcommand`] when the matched subcommand +/// is not part of `cmd`. +/// - [`MappingError::JsonConflict`] when `--json` rides alongside the +/// tool's own flags. +pub fn args(cmd: &Command, matches: &ArgMatches) -> Result { + let (name, sub) = matches.subcommand().ok_or(MappingError::NoSubcommand)?; + let sc = cmd + .find_subcommand(name) + .ok_or_else(|| MappingError::UnknownSubcommand { + name: name.to_owned(), + })?; + // Discovery first: `--schema` is a question about the tool, not a + // call, so it wins over everything else the command line carries. + if sub.get_flag("schema") { + return Ok(Payload::Schema { + tool: name.to_owned(), + }); + } + + let mut object = Map::new(); + for arg in sc.get_arguments() { + let id = arg.get_id().as_str(); + if id == "json" || id == "help" || id == "version" { + continue; + } + let Some(long) = arg.get_long() else { + continue; + }; + let Some(slot) = slot_of_id(id) else { + continue; + }; + insert_flag(&mut object, sub, id, long, slot, arg.get_action()); + } + + if let Some(raw) = sub.get_one::("json") { + if !object.is_empty() { + return Err(MappingError::JsonConflict); + } + return Ok(Payload::Overridden(raw.clone())); + } + Ok(Payload::Mapped(Value::Object(object))) +} + +/// Whether a tool name can ride argv: non-empty `[a-zA-Z0-9_-]` only. +/// +/// The charset test only — a name passing it can still be skipped by +/// [`command`] when clap itself owns it (`help`). +#[must_use] +pub fn cli_safe(name: &str) -> bool { + !name.is_empty() + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') +} + +/// One tool's `input_schema`, by the command tree's own rules: +/// renderable tools only, first name wins. The caller behind +/// [`Payload::Schema`] looks the schema up here so the printed shape +/// is exactly the one the subcommand's flags were built from. +#[must_use] +pub fn schema_of<'a>(catalog: &'a [ToolInfo], tool: &str) -> Option<&'a Value> { + catalog + .iter() + .find(|info| info.name == tool && renderable(info)) + .map(|info| &info.input_schema) +} + +/// Render the bare-invocation tool list: `[{name, description}]`. +/// +/// The list mirrors [`command`]'s tree exactly — the same tools, in +/// catalog order, duplicates collapsed first-wins — so what it +/// advertises is exactly what the subcommands can run. +#[must_use] +pub fn list_json(catalog: &[ToolInfo]) -> Value { + let mut seen = BTreeSet::new(); + let tools = catalog + .iter() + .filter(|tool| renderable(tool) && seen.insert(tool.name.as_str())) + .map(|tool| { + [ + ("name".to_owned(), Value::String(tool.name.clone())), + ( + "description".to_owned(), + Value::String(tool.description.clone()), + ), + ] + .into_iter() + .collect::>() + }) + .collect::>>(); + Value::Array(tools.into_iter().map(Value::Object).collect()) +} + +/// The clap-side value kind of one property. +/// +/// The kind is tagged into the arg's id (a private encoding: `t`, `e`, +/// `n`, `i`, `b`, `j`, or `f` for the presence flag) so [`args`] can +/// extract typed values from a rebuilt [`Command`], which carries no +/// schema. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Kind { + /// Free-form string. + Text, + /// String restricted by the schema's `enum`. + Enum, + /// JSON number, parsed as `f64`. + Number, + /// JSON integer, parsed as `i64`. + Integer, + /// Boolean value, exactly `true` or `false`. + Bool, + /// One raw JSON value, verbatim. + Json, +} + +impl Kind { + /// The id tag for one value of this kind. + const fn tag(self) -> char { + match self { + Kind::Text => 't', + Kind::Enum => 'e', + Kind::Number => 'n', + Kind::Integer => 'i', + Kind::Bool => 'b', + Kind::Json => 'j', + } + } + + /// The help placeholder for one value; text and enums show the + /// property's own name, the rest name their shape. + fn value_name(self, prop: &str) -> String { + match self { + Kind::Text | Kind::Enum => prop.to_uppercase(), + Kind::Number => "NUMBER".to_owned(), + Kind::Integer => "INTEGER".to_owned(), + Kind::Bool => "BOOL".to_owned(), + Kind::Json => "JSON".to_owned(), + } + } +} + +/// The flag shape one property maps to. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Shape { + /// One value per flag: `--field value`. + Value(Kind), + /// Repeatable, one value per occurrence: an array of scalars. + Repeat(Kind), + /// Presence flag: an optional boolean, `true` when passed. + SetTrue, + /// The JSON escape: `--_json`, one raw JSON value. + Json, +} + +/// The id slot one built arg decodes back to in [`args`]. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Slot { + /// A presence flag; only `true` is ever stored. + Presence, + /// A value or repeatable flag of the given kind. + Valued(Kind), +} + +fn slot_of_id(id: &str) -> Option { + match id.chars().next()? { + 'f' => Some(Slot::Presence), + 't' => Some(Slot::Valued(Kind::Text)), + 'e' => Some(Slot::Valued(Kind::Enum)), + 'n' => Some(Slot::Valued(Kind::Number)), + 'i' => Some(Slot::Valued(Kind::Integer)), + 'b' => Some(Slot::Valued(Kind::Bool)), + 'j' => Some(Slot::Valued(Kind::Json)), + _ => None, + } +} + +/// Whether a tool joins the clap tree. +fn renderable(tool: &ToolInfo) -> bool { + tool.name != "help" && cli_safe(&tool.name) && root_is_object(&tool.input_schema) +} + +/// Whether a schema's root is object-shaped: a JSON object carrying an +/// explicit `"object"` type, or none at all — `{}` included, since the +/// missing `"type"` is the common tool-schema shape. +fn root_is_object(schema: &Value) -> bool { + if !schema.is_object() { + return false; + } + match schema.get("type") { + None => true, + Some(t) => t.as_str() == Some("object"), + } +} + +fn tool_subcommand(server_name: &str, tool: &ToolInfo) -> Command { + let mut cmd = Command::new(tool.name.clone()).about(&tool.description); + let mut used = vec!["help".to_owned(), "json".to_owned(), "schema".to_owned()]; + let mut demoted = false; + let mut mandatory = Vec::new(); + + if let Some(props) = properties(&tool.input_schema) { + let required = required_names(&tool.input_schema); + for (index, (prop, schema)) in props.iter().enumerate() { + let Some((arg, enforced)) = property_arg( + prop, + schema, + index, + required.contains(&prop.as_str()), + &mut used, + ) else { + demoted = true; + break; + }; + if enforced { + mandatory.push(arg.clone()); + } + cmd = cmd.arg(arg); + } + } + if demoted { + // Collision between derived flags: no property flags at all, + // the tool stays reachable only through `--json`. + cmd = Command::new(tool.name.clone()).about(&tool.description); + } else if !mandatory.is_empty() { + // clap's own usage line renders [OPTIONS] alone — our required + // flags are `required_unless_present` so `--json` can carry the + // payload — so the usage is synthesized: the mandatory flags, + // in schema order, where a usage line traditionally puts them. + let prefix = mandatory + .iter() + .map(|arg| { + let long = arg.get_long().expect("property args have longs"); + let value = arg + .get_value_names() + .and_then(|names| names.first()) + .map(|v| v.to_string()) + .unwrap_or_else(|| long.to_uppercase()); + format!("--{long} <{value}>") + }) + .collect::>() + .join(" "); + cmd = cmd.override_usage(format!("{server_name} {} {prefix} [OPTIONS]", tool.name)); + } + cmd.arg(override_arg()).arg(schema_arg()) +} + +/// The per-tool `--schema` discovery flag: the caller prints the +/// tool's `input_schema` and exits. The one answer to "what shape +/// does this payload take" that needs no guessing. +fn schema_arg() -> Arg { + Arg::new("schema") + .long("schema") + .action(ArgAction::SetTrue) + .display_order(3) + .help("Print this tool's input schema as JSON and exit") +} + +/// The per-tool `--json` whole-payload override, present on every +/// subcommand. +fn override_arg() -> Arg { + Arg::new("json") + .long("json") + .value_name("JSON") + .display_order(2) + .help( + "Replace the whole payload with this JSON; '-' reads it from stdin. \ + For anything beyond a one-liner, pipe or heredoc it: \ + --json - <<'EOF' ... EOF", + ) +} + +/// The flag for one property — with whether it is clap-required — or +/// `None` when the property would make derived flags collide, which +/// demotes the whole tool. +fn property_arg( + prop: &str, + schema: &Value, + index: usize, + prop_required: bool, + used: &mut Vec, +) -> Option<(Arg, bool)> { + let mut shape = classify(schema); + if shape == Shape::Value(Kind::Bool) + && !prop_required + && !is_nullable(schema) + && !default_true(schema) + { + // Optional boolean, no default to honor: presence means true, + // absence means the key stays absent. Required booleans and + // nullable ones keep the explicit `true|false` value flag. + shape = Shape::SetTrue; + } + let long = match shape { + Shape::Json => format!("{prop}_json"), + Shape::Value(_) | Shape::Repeat(_) | Shape::SetTrue => prop.to_owned(), + }; + if !flag_name_ok(&long) || used.contains(&long) { + return None; + } + used.push(long.clone()); + + let help = schema.get("description").and_then(Value::as_str); + // clap-required only when the flags are the whole answer: JSON-flag + // properties and nullable ones stay optional. clap cannot spell + // `required_unless_present` args out in its usage line, so the + // required mark rides the help text instead. + let enforced = prop_required + && !is_nullable(schema) + && matches!(shape, Shape::Value(_) | Shape::Repeat(_)); + // Help groups mandatory flags first: clap's usage line cannot + // spell out `required_unless_present` args, so the display order + // is where requiredness reads at a glance — the `(required)` mark + // is the second signal. + let mut arg = Arg::new(arg_id(shape, index)) + .long(long) + .display_order(if enforced { 0 } else { 1 }); + match shape { + Shape::SetTrue => arg = arg.action(ArgAction::SetTrue), + Shape::Json => { + // The shape rides the help: the model reading + // `--help` learns the payload's structure here, without a + // second round trip to `--schema`. + let shaped = format!( + "{} JSON shape: {}", + flag_help(help, false, false), + shape_of(schema, 4) + ); + arg = arg + .help(shaped) + .value_name("JSON") + .value_parser(clap::builder::ValueParser::new(json_value)); + } + Shape::Value(kind) => { + arg = typed_arg(arg, kind, schema, prop); + // A JSON-flag property can never be clap-required; a + // nullable one neither, since null is a value the flag + // cannot say. + if prop_required && !is_nullable(schema) { + // Required unless the override carries the payload — + // the one case the flags are not the answer. + arg = arg.required_unless_present("json"); + } + } + Shape::Repeat(kind) => { + // The enum values, where any, live on the items schema. + let items = schema.get("items").unwrap_or(schema); + arg = typed_arg(arg.action(ArgAction::Append), kind, items, prop); + if enforced { + arg = arg.required_unless_present("json"); + } + arg = arg.help(flag_help(help, enforced, true)); + } + } + if !matches!(shape, Shape::Repeat(_)) { + arg = with_help(arg, help, enforced); + } + Some((arg, enforced)) +} + +/// One raw JSON value; clap's parse step owns the rejection of bad +/// JSON, the usage-error path. +fn json_value(raw: &str) -> Result { + serde_json::from_str(raw) +} + +/// The `number`-typed flag's value as a JSON number. Schemas say +/// `number` for fields that are semantically integers (`issue_number` +/// in github-mcp-server), and a bare `137.0` is JSON-valid but +/// rejected by strict parsers — Go's unmarshaller, for one — so a +/// whole-number float becomes a JSON integer and keeps its shortest +/// representation. Non-finite values have no JSON form: `None`. +fn number_of(v: f64) -> Option { + if v.fract() == 0.0 && v >= i64::MIN as f64 && v <= i64::MAX as f64 { + Some(Number::from(v as i64)) + } else { + Number::from_f64(v) + } +} + +/// The value name and parser for one scalar value flag. +fn typed_arg(arg: Arg, kind: Kind, schema: &Value, prop: &str) -> Arg { + let arg = arg.value_name(kind.value_name(prop)); + match kind { + Kind::Text => arg, + Kind::Enum => match string_enum_values(schema) { + Some(values) => arg.value_parser(PossibleValuesParser::new(values)), + None => arg, + }, + Kind::Number => arg.value_parser(clap::value_parser!(f64)), + Kind::Integer => arg.value_parser(clap::value_parser!(i64)), + Kind::Bool => arg.value_parser(clap::value_parser!(bool)), + Kind::Json => arg, + } +} + +/// The arg id carrying the shape tag and the property's index. +fn arg_id(shape: Shape, index: usize) -> String { + let tag = match shape { + Shape::SetTrue => 'f', + Shape::Json => Kind::Json.tag(), + Shape::Value(kind) | Shape::Repeat(kind) => kind.tag(), + }; + format!("{tag}{index}") +} + +/// clap rejects longs holding whitespace or `=`, and anything leading +/// with `-`; the empty long is unusable too. +fn flag_name_ok(long: &str) -> bool { + !long.is_empty() + && !long.starts_with('-') + && !long.chars().any(|c| c.is_whitespace() || c == '=') +} + +/// Help text for a repeatable flag, stating so. +/// The flag's help text: the schema description, honest about what the +/// flag demands (`required`, `repeatable`). clap renders neither mark +/// in its usage line — our required flags are +/// `required_unless_present`, so `--json` can carry the payload — and +/// the help text is where the model reads. +fn flag_help(help: Option<&str>, required: bool, repeatable: bool) -> String { + let mut marks = Vec::new(); + if required { + marks.push("required"); + } + if repeatable { + marks.push("repeatable"); + } + match help { + Some(h) if !h.is_empty() => format!("{h} ({})", marks.join(", ")), + _ => format!("({})", marks.join(", ")), + } +} + +fn with_help(arg: Arg, help: Option<&str>, enforced: bool) -> Arg { + if enforced { + return arg.help(flag_help(help, true, false)); + } + match help { + Some(h) if !h.is_empty() => arg.help(h.to_owned()), + _ => arg, + } +} + +/// A compact structural summary of one schema: the shape a caller +/// needs to build a matching payload, depth-limited so pathological +/// schemas cannot flood the help. `!` marks required properties. +#[must_use] +pub fn shape_of(schema: &Value, depth: usize) -> String { + if depth == 0 { + return String::from("..."); + } + for key in ["anyOf", "oneOf"] { + if let Some(alts) = schema.get(key).and_then(Value::as_array) { + let parts = alts + .iter() + .map(|alt| shape_of(alt, depth.saturating_sub(1))) + .collect::>() + .join(" | "); + return format!("{key} {parts}"); + } + } + match schema.get("type").and_then(Value::as_str) { + Some("object") => match properties(schema) { + Some(props) if !props.is_empty() => { + let required = required_names(schema); + let parts = props + .iter() + .map(|(name, sub)| { + let mark = if required.contains(&name.as_str()) { + "!" + } else { + "" + }; + format!("{name}{mark}: {}", shape_of(sub, depth - 1)) + }) + .collect::>() + .join(", "); + format!("{{{parts}}}") + } + _ => String::from("object"), + }, + Some("array") => schema + .get("items") + .map(|items| format!("array of {}", shape_of(items, depth - 1))) + .unwrap_or_else(|| String::from("array")), + Some("string") => match schema.get("enum").and_then(Value::as_array) { + Some(values) if !values.is_empty() => { + let names = values + .iter() + .filter_map(Value::as_str) + .collect::>() + .join("|"); + format!("string, one of {names}") + } + _ => String::from("string"), + }, + Some(other) => other.to_owned(), + None => String::from("any"), + } +} + +/// The schema's top-level properties, in schema order. +fn properties(schema: &Value) -> Option<&Map> { + schema.get("properties").and_then(Value::as_object) +} + +/// The names in the schema's `required` array; non-strings ignored. +fn required_names(schema: &Value) -> Vec<&str> { + schema + .get("required") + .and_then(Value::as_array) + .map(|names| names.iter().filter_map(Value::as_str).collect()) + .unwrap_or_default() +} + +/// Whether the schema pins `default: true` — such a boolean keeps the +/// explicit `true|false` value flag, never a presence guess. +fn default_true(schema: &Value) -> bool { + schema.get("default") == Some(&Value::Bool(true)) +} + +/// The flag shape one property maps to, per the schema. +fn classify(schema: &Value) -> Shape { + // FastMCP's Optional[T]: a two-variant anyOf/oneOf of null and one + // other shape, unwrapped recursively before classifying. + if let Some(inner) = nullable_variant(schema) { + return classify(inner); + } + // JSON Schema's ["null", "T"] type array, the same unwrapping. + if let Some(inner) = nullable_type(schema) { + return classify(&inner); + } + if schema.get("enum").is_some() { + // A string enum earns possible_values; any other enum falls + // back to the JSON flag. + return match string_enum_values(schema) { + Some(_) => Shape::Value(Kind::Enum), + None => Shape::Json, + }; + } + match schema.get("type").and_then(Value::as_str) { + Some("string") => Shape::Value(Kind::Text), + Some("number") => Shape::Value(Kind::Number), + Some("integer") => Shape::Value(Kind::Integer), + Some("boolean") => Shape::Value(Kind::Bool), + Some("array") => array_shape(schema), + // Objects, unions that survived the nullable unwrap, unknown + // and missing types all take the JSON escape. + _ => Shape::Json, + } +} + +/// An array of scalars repeats; anything else falls back to JSON. +fn array_shape(schema: &Value) -> Shape { + match schema.get("items") { + Some(items) => match classify(items) { + Shape::Value(kind) => Shape::Repeat(kind), + _ => Shape::Json, + }, + None => Shape::Json, + } +} + +/// The single non-null variant of an `anyOf`/`oneOf` `[T, null]` — the +/// `Optional[T]` shape FastMCP emits for every optional parameter. +fn nullable_variant(schema: &Value) -> Option<&Value> { + let unions = ["anyOf", "oneOf"] + .iter() + .filter_map(|key| schema.get(key).and_then(Value::as_array)); + for variants in unions { + if variants.len() == 2 { + if is_null_schema(&variants[0]) { + return Some(&variants[1]); + } + if is_null_schema(&variants[1]) { + return Some(&variants[0]); + } + } + } + None +} + +/// The schema with a `["null", "T"]` type array rewritten to `"T"`. +fn nullable_type(schema: &Value) -> Option { + let types = schema.get("type")?.as_array()?; + if types.len() != 2 { + return None; + } + let mut others = types.iter().filter(|t| !is_null_schema(t)); + let other = others.next()?; + if others.next().is_some() { + return None; + } + let mut rewritten = schema.clone(); + rewritten + .as_object_mut()? + .insert("type".to_owned(), other.clone()); + Some(rewritten) +} + +/// Whether the property itself is nullable, top level: such a flag +/// stays optional even when `required` lists it. +fn is_nullable(schema: &Value) -> bool { + nullable_variant(schema).is_some() || nullable_type(schema).is_some() +} + +/// Whether a value is the JSON `null` schema, in either spelling. +fn is_null_schema(value: &Value) -> bool { + value.as_str() == Some("null") || value.get("type").and_then(Value::as_str) == Some("null") +} + +/// The enum values when the schema is a plain string enum; any +/// non-string value — or a non-string `type` — disqualifies it. +fn string_enum_values(schema: &Value) -> Option> { + let string_typed = match schema.get("type") { + None => true, + Some(Value::String(t)) => t == "string", + Some(_) => false, + }; + if !string_typed { + return None; + } + let values = schema.get("enum")?.as_array()?; + if values.is_empty() { + return None; + } + values + .iter() + .map(|v| v.as_str().map(str::to_owned)) + .collect() +} + +/// One flag's contribution to the mapped object, when it was passed. +fn insert_flag( + object: &mut Map, + sub: &ArgMatches, + id: &str, + long: &str, + slot: Slot, + action: &ArgAction, +) { + let key = match slot { + Slot::Presence => long.to_owned(), + Slot::Valued(Kind::Json) => long.strip_suffix("_json").unwrap_or(long).to_owned(), + Slot::Valued(_) => long.to_owned(), + }; + match slot { + Slot::Presence => { + if matches!(action, ArgAction::SetTrue) + && let Some(&true) = sub.get_one::(id) + { + object.insert(key, Value::Bool(true)); + } + } + Slot::Valued(kind) => { + let value = match action { + ArgAction::Append => append_value(sub, id, kind), + _ => single_value(sub, id, kind), + }; + if let Some(value) = value { + object.insert(key, value); + } + } + } +} + +/// The mapped value of one passed flag, typed per its kind. +fn single_value(sub: &ArgMatches, id: &str, kind: Kind) -> Option { + match kind { + Kind::Text | Kind::Enum => sub.get_one::(id).map(|v| Value::String(v.clone())), + Kind::Number => sub + .get_one::(id) + .and_then(|v| number_of(*v)) + .map(Value::Number), + Kind::Integer => sub.get_one::(id).map(|v| Value::Number((*v).into())), + Kind::Bool => sub.get_one::(id).map(|v| Value::Bool(*v)), + Kind::Json => sub.get_one::(id).cloned(), + } +} + +/// The mapped array of one repeatable flag's occurrences. +fn append_value(sub: &ArgMatches, id: &str, kind: Kind) -> Option { + let values = match kind { + Kind::Text | Kind::Enum => sub + .get_many::(id)? + .map(|v| Value::String(v.clone())) + .collect(), + Kind::Number => sub + .get_many::(id)? + .filter_map(|v| number_of(*v)) + .map(Value::Number) + .collect(), + Kind::Integer => sub + .get_many::(id)? + .map(|v| Value::Number((*v).into())) + .collect(), + Kind::Bool => sub.get_many::(id)?.map(|v| Value::Bool(*v)).collect(), + // Arrays of JSON take the JSON flag and never repeat. + Kind::Json => return None, + }; + Some(Value::Array(values)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn classified(schema: Value) -> Shape { + classify(&schema) + } + + #[test] + fn nullable_anyof_unwraps_recursively() { + let optional_string = json!({"anyOf": [{"type": "string"}, {"type": "null"}]}); + assert_eq!(classified(optional_string), Shape::Value(Kind::Text)); + let optional_array = json!({ + "anyOf": [ + {"type": "array", "items": {"anyOf": [{"type": "integer"}, {"type": "null"}]}}, + {"type": "null"}, + ] + }); + assert_eq!(classified(optional_array), Shape::Repeat(Kind::Integer)); + } + + #[test] + fn nullable_oneof_and_type_arrays_unwrap() { + let oneof = json!({"oneOf": [{"type": "null"}, {"type": "boolean"}]}); + assert_eq!(classified(oneof), Shape::Value(Kind::Bool)); + let type_array = json!({"type": ["integer", "null"], "default": 7}); + assert_eq!(classified(type_array), Shape::Value(Kind::Integer)); + } + + #[test] + fn three_variant_unions_and_multi_type_arrays_stay_json() { + let three = json!({"anyOf": [{"type": "object"}, {"type": "string"}, {"type": "null"}]}); + assert_eq!(classified(three), Shape::Json); + let multi = json!({"type": ["string", "number"]}); + assert_eq!(classified(multi), Shape::Json); + } + + #[test] + fn non_string_enums_and_nested_arrays_stay_json() { + let numeric_enum = json!({"type": "integer", "enum": [1, 2, 3]}); + assert_eq!(classified(numeric_enum), Shape::Json); + let nested = + json!({"type": "array", "items": {"type": "array", "items": {"type": "string"}}}); + assert_eq!(classified(nested), Shape::Json); + let items_missing = json!({"type": "array"}); + assert_eq!(classified(items_missing), Shape::Json); + } + + #[test] + fn shape_summary_describes_structure() { + // The batch shape the shiba session fumbled: an array of + // objects under a required key. + let steps = json!({ + "type": "array", + "items": { + "type": "object", + "required": ["command"], + "properties": { + "command": {"type": "string"}, + "params": {"type": "object"} + } + } + }); + assert_eq!( + shape_of(&steps, 4), + "array of {command!: string, params: object}" + ); + + // Properties inserted in sorted order: with serde_json's + // default BTreeMap the map sorts anyway, and when feature + // unification brings `preserve_order` in (an insertion-ordered + // map), the summary follows the insertion — so the fixture + // must not rely on sorting the input itself. + let flat = json!({"type": "object", "properties": {"height": {"type": "integer"}, "width": {"type": "integer"}}}); + assert_eq!(shape_of(&flat, 4), "{height: integer, width: integer}"); + + let enum_ = json!({"type": "string", "enum": ["circle", "rect"]}); + assert_eq!(shape_of(&enum_, 4), "string, one of circle|rect"); + + let union = json!({"anyOf": [{"type": "string"}, {"type": "integer"}]}); + assert_eq!(shape_of(&union, 4), "anyOf string | integer"); + + // Depth-limited: deep schemas end in an ellipsis, not a flood. + let mut deep = json!({"type": "string"}); + for _ in 0..8 { + deep = json!({"type": "object", "properties": {"inner": deep}}); + } + let summary = shape_of(&deep, 4); + assert!(summary.contains("..."), "{summary}"); + assert!(summary.ends_with('}'), "{summary}"); + + assert_eq!(shape_of(&json!({}), 4), "any"); + } + + #[test] + fn the_schema_flag_short_circuits_to_discovery() { + let tool = ToolInfo { + name: "doc_execute".to_owned(), + description: "run batch steps".to_owned(), + input_schema: json!({ + "type": "object", + "properties": {"steps": {"type": "array", "items": {"type": "object"}}} + }), + }; + let tree = command("vectorcraft-cli", &[tool]); + + // `--schema` wins even beside other flags and the override: + // it is a question, not a call. + for argv in [ + vec!["vectorcraft-cli", "doc_execute", "--schema"], + vec!["vectorcraft-cli", "doc_execute", "--schema", "--json", "-"], + vec!["vectorcraft-cli", "doc_execute", "--json", "-", "--schema"], + ] { + let matches = tree.clone().try_get_matches_from(argv).unwrap(); + let payload = args(&tree, &matches).unwrap(); + assert_eq!( + payload, + Payload::Schema { + tool: "doc_execute".to_owned() + } + ); + } + + // And the lookup returns the same schema the flags were built + // from. + let catalog = [ToolInfo { + name: "doc_execute".to_owned(), + description: String::new(), + input_schema: json!({"type": "object"}), + }]; + assert_eq!( + schema_of(&catalog, "doc_execute"), + Some(&json!({"type": "object"})) + ); + assert_eq!(schema_of(&[], "doc_execute"), None); + } + + #[test] + fn a_property_named_schema_demotes_the_tool() { + let tool = ToolInfo { + name: "colliding".to_owned(), + description: String::new(), + input_schema: json!({ + "type": "object", + "properties": {"schema": {"type": "string"}} + }), + }; + let tree = command("srv", &[tool]); + let matches = tree + .clone() + .try_get_matches_from(vec!["srv", "colliding", "--json", "{}"]) + .unwrap(); + // The tool survives, reachable through the override only. + assert_eq!( + args(&tree, &matches).unwrap(), + Payload::Overridden("{}".to_owned()) + ); + let matches = tree + .clone() + .try_get_matches_from(vec!["srv", "colliding", "--schema"]) + .unwrap(); + assert_eq!( + args(&tree, &matches).unwrap(), + Payload::Schema { + tool: "colliding".to_owned() + } + ); + assert_eq!( + args(&tree, &matches).unwrap(), + Payload::Schema { + tool: "colliding".to_owned() + } + ); + } + + #[test] + fn cli_safe_is_the_charset_only() { + assert!(cli_safe("help")); + assert!(cli_safe("create_pull_request")); + assert!(!cli_safe("")); + assert!(!cli_safe("has space")); + assert!(!cli_safe("dot.name")); + } +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..4280be7 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,46 @@ +//! Shared helpers for the mapping matrix: argv driving and payload +//! unwrapping. + +use mcp_to_cli::{Payload, ToolInfo, args}; + +/// A [`ToolInfo`] from parts, for inline schemas. +pub fn tool(name: &str, description: &str, input_schema: serde_json::Value) -> ToolInfo { + ToolInfo { + name: name.to_owned(), + description: description.to_owned(), + input_schema, + } +} + +/// Parses `argv` (no program name) against `cmd`; `None` is clap +/// rejecting the argv — the usage-error path. +pub fn parse(cmd: &clap::Command, argv: &[&str]) -> Option { + let mut full: Vec = vec!["srv".to_owned()]; + full.extend(argv.iter().map(|a| (*a).to_owned())); + cmd.clone().try_get_matches_from(full).ok() +} + +/// Parses clap-valid `argv` and maps the matched tool. +pub fn run( + cmd: &clap::Command, + argv: &[&str], +) -> Result { + let matches = parse(cmd, argv).expect("argv parses"); + args(cmd, &matches) +} + +/// The mapped object of a payload that must be mapped. +pub fn mapped(payload: Payload) -> serde_json::Value { + match payload { + Payload::Mapped(value) => value, + other => panic!("expected a mapped payload, got {other:?}"), + } +} + +/// The raw string of a payload that must be the `--json` override. +pub fn overridden(payload: Payload) -> String { + match payload { + Payload::Overridden(raw) => raw, + other => panic!("expected an overridden payload, got {other:?}"), + } +} diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 0000000..b1a8702 --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1,40 @@ +# Fixtures + +Trimmed `inputSchema` objects from real MCP servers, plus two +hand-written stand-ins, so the mapping matrix runs against real shapes. +All upstream work is MIT-licensed. + +## Vendored from github/github-mcp-server + +Source: , MIT license, +`main @ 4bcdcdf8d7be`, fetched 2026-11-03. The files carry the +`inputSchema` object of the named tool, verbatim: + +- `create_pull_request.json` — flat scalars, optional booleans, an + array of strings. +- `search_repositories.json` — a boolean with `default: true` + (`minimal_output`), string `enum`s (`sort`, `order`), constraint + keywords (`minimum`, `maximum`) the mapping ignores. +- `update_pull_request.json` — the `state: ["open", "closed"]` enum. +- `create_issue.json` — flat required scalars, with `x-mcp-header` + extension keys the mapping ignores. +- `projects_write_excerpt.json` — hand-trimmed from the 26-property + `projects_write` tool: `iterations` (array of objects with per-item + `required`) and `updated_field` (a `oneOf` of object variants) kept + verbatim, every other property dropped, `required` emptied because + both listed names (`method`, `owner`) were among the dropped. + +## Hand-written stand-ins + +Documented stand-ins for the other two servers named in the spec; the +shapes are trimmed from their actual tool definitions, not invented. + +- `additional_fields.json` — the pydantic shape `sooperset/mcp-atlassian` + emits: required scalars, a `pattern` the mapping ignores, and + `additional_fields` as a three-variant `anyOf` (`object | string | + null`), which is the JSON-flag case, not the nullable case. +- `get_scene_info.json` — the FastMCP shape `ahujasid/blender-mcp` + emits for every optional parameter: `anyOf: [, null]` nullable + forms (`user_prompt` with `default: ""`, `query`, `fields` as a + nullable array of strings, `limit` integer with `default: 20`) and a + required `code` string. diff --git a/tests/fixtures/additional_fields.json b/tests/fixtures/additional_fields.json new file mode 100644 index 0000000..83c249a --- /dev/null +++ b/tests/fixtures/additional_fields.json @@ -0,0 +1,20 @@ +{ + "type": "object", + "properties": { + "project_key": { + "type": "string", + "pattern": "[A-Z]+" + }, + "summary": { + "type": "string" + }, + "additional_fields": { + "anyOf": [ + { "type": "object" }, + { "type": "string" }, + { "type": "null" } + ] + } + }, + "required": ["project_key", "summary"] +} diff --git a/tests/fixtures/create_issue.json b/tests/fixtures/create_issue.json new file mode 100644 index 0000000..ae5d57b --- /dev/null +++ b/tests/fixtures/create_issue.json @@ -0,0 +1,41 @@ +{ + "properties": { + "body": { + "description": "Issue body content (optional)", + "type": "string" + }, + "owner": { + "description": "Repository owner (username or organization)", + "type": "string", + "x-mcp-header": "owner" + }, + "parent_issue_number": { + "description": "Issue number of the parent issue. The new issue is created and attached to this parent in the same operation.", + "minimum": 1, + "type": "number" + }, + "parent_owner": { + "description": "Repository owner of the parent issue. Must be provided with parent_repo. Omit both to use owner and repo. Only used when parent_issue_number is provided.", + "type": "string" + }, + "parent_repo": { + "description": "Repository name of the parent issue. Must be provided with parent_owner. Omit both to use owner and repo. Only used when parent_issue_number is provided.", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string", + "x-mcp-header": "repo" + }, + "title": { + "description": "Issue title", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "title" + ], + "type": "object" +} diff --git a/tests/fixtures/create_pull_request.json b/tests/fixtures/create_pull_request.json new file mode 100644 index 0000000..d431645 --- /dev/null +++ b/tests/fixtures/create_pull_request.json @@ -0,0 +1,51 @@ +{ + "properties": { + "base": { + "description": "Branch to merge into", + "type": "string" + }, + "body": { + "description": "PR description", + "type": "string" + }, + "draft": { + "description": "Create as draft PR", + "type": "boolean" + }, + "head": { + "description": "Branch containing changes", + "type": "string" + }, + "maintainer_can_modify": { + "description": "Allow maintainer edits", + "type": "boolean" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + }, + "reviewers": { + "description": "GitHub usernames or ORG/team-slug team reviewers to request reviews from", + "items": { + "type": "string" + }, + "type": "array" + }, + "title": { + "description": "PR title", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "title", + "head", + "base" + ], + "type": "object" +} diff --git a/tests/fixtures/get_scene_info.json b/tests/fixtures/get_scene_info.json new file mode 100644 index 0000000..573e98f --- /dev/null +++ b/tests/fixtures/get_scene_info.json @@ -0,0 +1,43 @@ +{ + "type": "object", + "properties": { + "code": { + "description": "Execute arbitrary Blender Python code", + "type": "string" + }, + "user_prompt": { + "anyOf": [ + { "type": "string" }, + { "type": "null" } + ], + "default": "", + "description": "Optional natural-language prompt the operator can add" + }, + "query": { + "anyOf": [ + { "type": "string" }, + { "type": "null" } + ], + "description": "Optional substring to filter the reported objects" + }, + "fields": { + "anyOf": [ + { + "type": "array", + "items": { "type": "string" } + }, + { "type": "null" } + ], + "description": "Optional list of fields to report per object" + }, + "limit": { + "anyOf": [ + { "type": "integer" }, + { "type": "null" } + ], + "default": 20, + "description": "Maximum number of objects to report" + } + }, + "required": ["code"] +} diff --git a/tests/fixtures/projects_write_excerpt.json b/tests/fixtures/projects_write_excerpt.json new file mode 100644 index 0000000..3fec180 --- /dev/null +++ b/tests/fixtures/projects_write_excerpt.json @@ -0,0 +1,73 @@ +{ + "type": "object", + "required": [], + "properties": { + "iterations": { + "description": "Custom iterations for 'create_iteration_field' method. Only set this when you need iterations with varying durations, breaks between them, or specific titles. Otherwise omit it: GitHub auto-creates three iterations of 'iteration_duration' days starting on 'start_date', which is the right choice for most cases.", + "items": { + "additionalProperties": false, + "properties": { + "duration": { + "description": "Duration in days", + "type": "number" + }, + "start_date": { + "description": "Start date in YYYY-MM-DD format", + "type": "string" + }, + "title": { + "description": "Iteration title (e.g. 'Sprint 1')", + "type": "string" + } + }, + "required": [ + "title", + "start_date", + "duration" + ], + "type": "object" + }, + "type": "array" + }, + "updated_field": { + "description": "The field/value to apply, using {\"id\": 123, \"value\": ...} or {\"name\": \"Status\", \"value\": ...}; null clears the field. Required for 'update_project_item' and 'update_project_items', where one top-level field/value applies to every item in a batch. For 'update_project_item' SINGLE_SELECT fields, the name form accepts option names; the ID form expects an option ID.", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "id": { + "description": "The numeric project field ID.", + "type": "integer" + }, + "value": { + "description": "The value to apply. Any JSON value is accepted; use null to clear the field." + } + }, + "required": [ + "id", + "value" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "name": { + "description": "The project field name. Matching is case-insensitive.", + "type": "string" + }, + "value": { + "description": "The value to apply. Any JSON value is accepted; use null to clear the field." + } + }, + "required": [ + "name", + "value" + ], + "type": "object" + } + ], + "type": "object" + } + } +} diff --git a/tests/fixtures/search_repositories.json b/tests/fixtures/search_repositories.json new file mode 100644 index 0000000..ea10713 --- /dev/null +++ b/tests/fixtures/search_repositories.json @@ -0,0 +1,46 @@ +{ + "properties": { + "minimal_output": { + "default": true, + "description": "Return minimal repository information (default: true). When false, modern clients receive additional curated repository details, not the complete GitHub API object. Legacy clients retain full GitHub API repository objects.", + "type": "boolean" + }, + "order": { + "description": "Sort order", + "enum": [ + "asc", + "desc" + ], + "type": "string" + }, + "page": { + "description": "Page number for pagination (min 1)", + "minimum": 1, + "type": "number" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, + "query": { + "description": "Repository search query. Examples: 'machine learning in:name stars:>1000 language:python', 'topic:react', 'user:facebook'. Supports advanced search syntax for precise filtering.", + "type": "string" + }, + "sort": { + "description": "Sort repositories by field, defaults to best match", + "enum": [ + "stars", + "forks", + "help-wanted-issues", + "updated" + ], + "type": "string" + } + }, + "required": [ + "query" + ], + "type": "object" +} diff --git a/tests/fixtures/update_pull_request.json b/tests/fixtures/update_pull_request.json new file mode 100644 index 0000000..e777255 --- /dev/null +++ b/tests/fixtures/update_pull_request.json @@ -0,0 +1,57 @@ +{ + "properties": { + "base": { + "description": "New base branch name", + "type": "string" + }, + "body": { + "description": "New description", + "type": "string" + }, + "draft": { + "description": "Mark pull request as draft (true) or ready for review (false)", + "type": "boolean" + }, + "maintainer_can_modify": { + "description": "Allow maintainer edits", + "type": "boolean" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "pullNumber": { + "description": "Pull request number to update", + "type": "number" + }, + "repo": { + "description": "Repository name", + "type": "string" + }, + "reviewers": { + "description": "GitHub usernames or ORG/team-slug team reviewers to request reviews from", + "items": { + "type": "string" + }, + "type": "array" + }, + "state": { + "description": "New state", + "enum": [ + "open", + "closed" + ], + "type": "string" + }, + "title": { + "description": "New title", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "pullNumber" + ], + "type": "object" +} diff --git a/tests/mapping.rs b/tests/mapping.rs new file mode 100644 index 0000000..81ecca9 --- /dev/null +++ b/tests/mapping.rs @@ -0,0 +1,1047 @@ +//! The mapping table, row by row, driven through `command` and clap's +//! own argv parsing. + +mod common; + +use common::{mapped, overridden, parse, run, tool}; +use mcp_to_cli::{MappingError, command}; +use serde_json::json; + +/// An object schema from its properties and `required` list. +fn object_schema(properties: serde_json::Value, required: &[&str]) -> serde_json::Value { + json!({ + "type": "object", + "properties": properties, + "required": required, + }) +} + +// --- property names: verbatim, no case translation --- + +#[test] +fn property_names_pass_through_verbatim() { + let cmd = command( + "srv", + &[tool( + "t", + "d", + object_schema(json!({"per_page": {"type": "string"}}), &[]), + )], + ); + assert_eq!( + mapped(run(&cmd, &["t", "--per_page", "20"]).unwrap()), + json!({"per_page": "20"}) + ); + // The camel-cased guess is not a flag. + assert!(parse(&cmd, &["t", "--perPage", "20"]).is_none()); +} + +#[test] +fn numbers_map_as_the_shortest_wire_form() { + let cmd = command( + "srv", + &[tool( + "t", + "d", + object_schema(json!({"ratio": {"type": "number"}}), &[]), + )], + ); + let value = mapped(run(&cmd, &["t", "--ratio", "2.5"]).unwrap()); + assert_eq!(value["ratio"].as_f64(), Some(2.5)); + // An integral float keeps its shortest wire form: a JSON integer, + // which strict parsers (Go's unmarshaller) reject as `3.0`. + let whole = mapped(run(&cmd, &["t", "--ratio", "3"]).unwrap()); + assert!(whole["ratio"].is_i64()); + assert!(!whole["ratio"].is_f64()); + // clap owns the parse error, the usage path. + assert!(parse(&cmd, &["t", "--ratio", "two"]).is_none()); +} + +#[test] +fn integers_map_as_i64() { + let cmd = command( + "srv", + &[tool( + "t", + "d", + object_schema(json!({"limit": {"type": "integer"}}), &[]), + )], + ); + let value = mapped(run(&cmd, &["t", "--limit", "5"]).unwrap()); + assert_eq!(value["limit"].as_i64(), Some(5)); + assert!(value["limit"].is_i64()); + assert!(parse(&cmd, &["t", "--limit", "1.5"]).is_none()); + assert!(parse(&cmd, &["t", "--limit", "99999999999999999999"]).is_none()); +} + +// --- enums: possible_values, validation and completion free --- + +#[test] +fn string_enum_earns_possible_values() { + let cmd = command( + "srv", + &[tool( + "t", + "d", + object_schema( + json!({"state": {"type": "string", "enum": ["open", "closed"]}}), + &[], + ), + )], + ); + assert_eq!( + mapped(run(&cmd, &["t", "--state", "closed"]).unwrap()), + json!({"state": "closed"}) + ); + // clap rejects anything else. + assert!(parse(&cmd, &["t", "--state", "archived"]).is_none()); + + let sc = cmd.find_subcommand("t").expect("tool subcommand"); + let arg = sc + .get_arguments() + .find(|a| a.get_long() == Some("state")) + .expect("state flag"); + let values: Vec = arg + .get_value_parser() + .possible_values() + .expect("enum parser lists values") + .map(|v| v.get_name().to_owned()) + .collect(); + assert_eq!(values, ["open", "closed"]); +} + +#[test] +fn enum_without_type_is_still_a_string_enum() { + let cmd = command( + "srv", + &[tool( + "t", + "d", + object_schema(json!({"kind": {"enum": ["a", "b"]}}), &[]), + )], + ); + assert_eq!( + mapped(run(&cmd, &["t", "--kind", "a"]).unwrap()), + json!({"kind": "a"}) + ); +} + +#[test] +fn non_string_enum_falls_back_to_the_json_flag() { + let cmd = command( + "srv", + &[tool( + "t", + "d", + object_schema(json!({"num": {"type": "integer", "enum": [1, 2, 3]}}), &[]), + )], + ); + let sc = cmd.find_subcommand("t").expect("tool subcommand"); + let longs: Vec<&str> = sc.get_arguments().filter_map(|a| a.get_long()).collect(); + assert!(!longs.contains(&"num")); + assert!(longs.contains(&"num_json")); + assert_eq!( + mapped(run(&cmd, &["t", "--num_json", "2"]).unwrap()), + json!({"num": 2}) + ); + assert!(parse(&cmd, &["t", "--num", "2"]).is_none()); +} + +// --- booleans: never guessed --- + +#[test] +fn required_boolean_is_a_true_false_value_flag() { + let cmd = command( + "srv", + &[tool( + "t", + "d", + object_schema(json!({"force": {"type": "boolean"}}), &["force"]), + )], + ); + assert_eq!( + mapped(run(&cmd, &["t", "--force", "false"]).unwrap()), + json!({"force": false}) + ); + assert_eq!( + mapped(run(&cmd, &["t", "--force", "true"]).unwrap()), + json!({"force": true}) + ); + // Required: bare tool and valueless flag are both clap errors. + assert!(parse(&cmd, &["t"]).is_none()); + assert!(parse(&cmd, &["t", "--force"]).is_none()); + // Exactly true/false. + assert!(parse(&cmd, &["t", "--force", "maybe"]).is_none()); + assert!(parse(&cmd, &["t", "--force", "1"]).is_none()); +} + +#[test] +fn optional_boolean_is_a_presence_flag() { + let cmd = command( + "srv", + &[tool( + "t", + "d", + object_schema(json!({"draft": {"type": "boolean"}}), &[]), + )], + ); + // Absent means the key stays absent. + assert_eq!(mapped(run(&cmd, &["t"]).unwrap()), json!({})); + assert_eq!( + mapped(run(&cmd, &["t", "--draft"]).unwrap()), + json!({"draft": true}) + ); + // A presence flag takes no value. + assert!(parse(&cmd, &["t", "--draft", "false"]).is_none()); +} + +#[test] +fn optional_boolean_default_false_is_still_a_presence_flag() { + let cmd = command( + "srv", + &[tool( + "t", + "d", + object_schema( + json!({"verbose": {"type": "boolean", "default": false}}), + &[], + ), + )], + ); + assert_eq!(mapped(run(&cmd, &["t"]).unwrap()), json!({})); + assert_eq!( + mapped(run(&cmd, &["t", "--verbose"]).unwrap()), + json!({"verbose": true}) + ); +} + +#[test] +fn optional_boolean_default_true_stays_an_explicit_value_flag() { + let cmd = command( + "srv", + &[tool( + "t", + "d", + object_schema( + json!({"minimal_output": {"type": "boolean", "default": true}}), + &[], + ), + )], + ); + // Omitted: the key stays absent, the server applies its default. + assert_eq!(mapped(run(&cmd, &["t"]).unwrap()), json!({})); + assert_eq!( + mapped(run(&cmd, &["t", "--minimal_output", "false"]).unwrap()), + json!({"minimal_output": false}) + ); + assert_eq!( + mapped(run(&cmd, &["t", "--minimal_output", "true"]).unwrap()), + json!({"minimal_output": true}) + ); + // And it is not a presence flag: the value is mandatory. + assert!(parse(&cmd, &["t", "--minimal_output"]).is_none()); +} + +#[test] +fn nullable_boolean_keeps_the_value_flag() { + let cmd = command( + "srv", + &[tool( + "t", + "d", + object_schema( + json!({"flag": {"anyOf": [{"type": "boolean"}, {"type": "null"}]}}), + &[], + ), + )], + ); + // A presence flag could not say false; nullability only forces + // optionality. + assert_eq!( + mapped(run(&cmd, &["t", "--flag", "false"]).unwrap()), + json!({"flag": false}) + ); + assert_eq!(mapped(run(&cmd, &["t"]).unwrap()), json!({})); +} + +// --- defaults are never pre-filled --- + +#[test] +fn schema_defaults_are_never_prefilled() { + let cmd = command( + "srv", + &[tool( + "t", + "d", + object_schema( + json!({ + "limit": {"type": "integer", "default": 20}, + "name": {"type": "string", "default": "x"}, + }), + &[], + ), + )], + ); + assert_eq!(mapped(run(&cmd, &["t"]).unwrap()), json!({})); +} + +// --- arrays --- + +#[test] +fn array_of_strings_is_repeatable() { + let cmd = command( + "srv", + &[tool( + "t", + "d", + object_schema( + json!({"tags": {"type": "array", "items": {"type": "string"}}}), + &[], + ), + )], + ); + assert_eq!( + mapped(run(&cmd, &["t", "--tags", "a", "--tags", "b"]).unwrap()), + json!({"tags": ["a", "b"]}) + ); + // One occurrence is a one-element array, not a bare string. + assert_eq!( + mapped(run(&cmd, &["t", "--tags", "a"]).unwrap()), + json!({"tags": ["a"]}) + ); + assert_eq!(mapped(run(&cmd, &["t"]).unwrap()), json!({})); +} + +#[test] +fn arrays_of_numbers_integers_and_booleans_repeat() { + let cmd = command( + "srv", + &[tool( + "t", + "d", + object_schema( + json!({ + "weights": {"type": "array", "items": {"type": "number"}}, + "counts": {"type": "array", "items": {"type": "integer"}}, + "flags": {"type": "array", "items": {"type": "boolean"}}, + }), + &[], + ), + )], + ); + assert_eq!( + mapped(run(&cmd, &["t", "--weights", "1.5", "--weights", "2"]).unwrap()), + json!({"weights": [1.5, 2]}) + ); + assert_eq!( + mapped(run(&cmd, &["t", "--counts", "7", "--counts", "9"]).unwrap()), + json!({"counts": [7, 9]}) + ); + assert_eq!( + mapped(run(&cmd, &["t", "--flags", "true", "--flags", "false"]).unwrap()), + json!({"flags": [true, false]}) + ); + assert!(parse(&cmd, &["t", "--counts", "1.5"]).is_none()); +} + +#[test] +fn array_of_enum_strings_repeats_with_possible_values() { + let cmd = command( + "srv", + &[tool( + "t", + "d", + object_schema( + json!({"levels": { + "type": "array", + "items": {"type": "string", "enum": ["low", "high"]}, + }}), + &[], + ), + )], + ); + assert_eq!( + mapped(run(&cmd, &["t", "--levels", "low", "--levels", "high"]).unwrap()), + json!({"levels": ["low", "high"]}) + ); + assert!(parse(&cmd, &["t", "--levels", "mid"]).is_none()); +} + +#[test] +fn array_of_nullable_strings_is_repeatable() { + let cmd = command( + "srv", + &[tool( + "t", + "d", + object_schema( + json!({"fields": { + "type": "array", + "items": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + }}), + &[], + ), + )], + ); + assert_eq!( + mapped(run(&cmd, &["t", "--fields", "location", "--fields", "topology"]).unwrap()), + json!({"fields": ["location", "topology"]}) + ); +} + +// --- the JSON escape hatch --- + +#[test] +fn objects_take_the_json_flag_verbatim() { + let cmd = command( + "srv", + &[tool( + "t", + "d", + object_schema(json!({"cfg": {"type": "object"}}), &[]), + )], + ); + let raw = r#"{"b": 1, "a": [true, null]}"#; + assert_eq!( + mapped(run(&cmd, &["t", "--cfg_json", raw]).unwrap()), + json!({"cfg": {"b": 1, "a": [true, null]}}) + ); + assert!(parse(&cmd, &["t", "--cfg_json", "not json"]).is_none()); + assert!(parse(&cmd, &["t", "--cfg", "{}"]).is_none()); +} + +#[test] +fn anyof_of_three_variants_takes_the_json_flag() { + let cmd = command( + "srv", + &[tool( + "t", + "d", + object_schema( + json!({"additional_fields": { + "anyOf": [{"type": "object"}, {"type": "string"}, {"type": "null"}], + }}), + &[], + ), + )], + ); + assert_eq!( + mapped(run(&cmd, &["t", "--additional_fields_json", r#""notes""#]).unwrap()), + json!({"additional_fields": "notes"}) + ); + assert_eq!( + mapped( + run( + &cmd, + &[ + "t", + "--additional_fields_json", + r#"{"priority": {"name": "High"}}"# + ] + ) + .unwrap() + ), + json!({"additional_fields": {"priority": {"name": "High"}}}) + ); +} + +#[test] +fn oneof_and_allof_take_the_json_flag() { + let schema = json!({ + "updated_field": { + "oneOf": [ + {"type": "object", "required": ["id"]}, + {"type": "object", "required": ["name"]}, + ], + }, + "merged": {"allOf": [{"type": "string"}]}, + }); + let cmd = command("srv", &[tool("t", "d", object_schema(schema, &[]))]); + assert_eq!( + mapped( + run( + &cmd, + &["t", "--updated_field_json", r#"{"name": "Status"}"#] + ) + .unwrap() + ), + json!({"updated_field": {"name": "Status"}}) + ); + let longs: Vec<&str> = cmd + .find_subcommand("t") + .unwrap() + .get_arguments() + .filter_map(|a| a.get_long()) + .collect(); + assert!(longs.contains(&"merged_json")); +} + +#[test] +fn unknown_and_missing_types_take_the_json_flag() { + let cmd = command( + "srv", + &[tool( + "t", + "d", + object_schema(json!({"weird": {"type": "binary"}, "anything": {}}), &[]), + )], + ); + assert_eq!( + mapped(run(&cmd, &["t", "--weird_json", r#"[1, 2]"#]).unwrap()), + json!({"weird": [1, 2]}) + ); + assert_eq!( + mapped(run(&cmd, &["t", "--anything_json", "null"]).unwrap()), + json!({"anything": null}) + ); +} + +#[test] +fn arrays_of_objects_and_nested_arrays_take_the_json_flag() { + let cmd = command( + "srv", + &[tool( + "t", + "d", + object_schema( + json!({ + "items": {"type": "array", "items": {"type": "object"}}, + "matrix": {"type": "array", "items": {"type": "array", "items": {"type": "string"}}}, + "untyped": {"type": "array"}, + }), + &[], + ), + )], + ); + assert_eq!( + mapped(run(&cmd, &["t", "--items_json", r#"[{"a": 1}]"#]).unwrap()), + json!({"items": [{"a": 1}]}) + ); + assert_eq!( + mapped(run(&cmd, &["t", "--matrix_json", r#"[["a"]]"#]).unwrap()), + json!({"matrix": [["a"]]}) + ); + assert_eq!( + mapped(run(&cmd, &["t", "--untyped_json", "3"]).unwrap()), + json!({"untyped": 3}) + ); + // The repeatable form does not exist for these. + assert!(parse(&cmd, &["t", "--items", "x"]).is_none()); +} + +// --- nullability: the FastMCP Optional[T] shape --- + +#[test] +fn nullable_string_maps_as_a_plain_optional_string() { + let cmd = command( + "srv", + &[tool( + "t", + "d", + object_schema( + json!({"query": {"anyOf": [{"type": "string"}, {"type": "null"}]}}), + &[], + ), + )], + ); + assert_eq!( + mapped(run(&cmd, &["t", "--query", "chair"]).unwrap()), + json!({"query": "chair"}) + ); + assert_eq!(mapped(run(&cmd, &["t"]).unwrap()), json!({})); +} + +#[test] +fn nullable_type_arrays_unwrap() { + let cmd = command( + "srv", + &[tool( + "t", + "d", + object_schema(json!({"q": {"type": ["string", "null"]}}), &[]), + )], + ); + assert_eq!( + mapped(run(&cmd, &["t", "--q", "x"]).unwrap()), + json!({"q": "x"}) + ); + let longs: Vec<&str> = cmd + .find_subcommand("t") + .unwrap() + .get_arguments() + .filter_map(|a| a.get_long()) + .collect(); + assert!(longs.contains(&"q")); + assert!(!longs.contains(&"q_json")); +} + +#[test] +fn nullable_integer_with_default_omits_when_absent() { + let cmd = command( + "srv", + &[tool( + "t", + "d", + object_schema( + json!({"limit": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": 20}}), + &[], + ), + )], + ); + assert_eq!( + mapped(run(&cmd, &["t", "--limit", "5"]).unwrap()), + json!({"limit": 5}) + ); + // Not 20: the default is the server's business. + assert_eq!(mapped(run(&cmd, &["t"]).unwrap()), json!({})); +} + +#[test] +fn nullable_properties_are_never_clap_required() { + let cmd = command( + "srv", + &[tool( + "t", + "d", + object_schema( + json!({"q": {"anyOf": [{"type": "string"}, {"type": "null"}]}}), + &["q"], + ), + )], + ); + // `required` lists it, but null is a value the flag cannot say, so + // clap lets the tool run bare. + assert_eq!(mapped(run(&cmd, &["t"]).unwrap()), json!({})); +} + +// --- required --- + +#[test] +fn required_scalars_are_clap_required() { + let cmd = command( + "srv", + &[tool( + "t", + "d", + object_schema( + json!({"owner": {"type": "string"}, "n": {"type": "number"}, "i": {"type": "integer"}}), + &["owner", "n", "i"], + ), + )], + ); + assert!(parse(&cmd, &["t"]).is_none()); + assert!(parse(&cmd, &["t", "--owner", "o", "--n", "1.5"]).is_none()); + assert!(parse(&cmd, &["t", "--owner", "o", "--n", "1.5", "--i", "2"]).is_some()); +} + +#[test] +fn required_json_properties_are_never_clap_required() { + let cmd = command( + "srv", + &[tool( + "t", + "d", + object_schema(json!({"cfg": {"type": "object"}}), &["cfg"]), + )], + ); + // Server-side validation owns these. + assert_eq!(mapped(run(&cmd, &["t"]).unwrap()), json!({})); + assert_eq!( + mapped(run(&cmd, &["t", "--cfg_json", r#"{"a": 1}"#]).unwrap()), + json!({"cfg": {"a": 1}}) + ); +} + +// --- ignored keywords and extensions: the server stays the validator --- + +#[test] +fn constraint_keywords_are_ignored() { + let cmd = command( + "srv", + &[tool( + "t", + "d", + object_schema( + json!({ + "key": {"type": "string", "pattern": "[A-Z]+", "format": "uri", "minLength": 3}, + "n": {"type": "number", "minimum": 1, "maximum": 100}, + }), + &[], + ), + )], + ); + assert_eq!( + mapped(run(&cmd, &["t", "--key", "lower", "--n", "5000"]).unwrap()), + json!({"key": "lower", "n": 5000}) + ); +} + +#[test] +fn unknown_extension_keys_are_ignored() { + let cmd = command( + "srv", + &[tool( + "t", + "d", + object_schema( + json!({"owner": {"type": "string", "x-mcp-header": "owner"}}), + &["owner"], + ), + )], + ); + assert_eq!( + mapped(run(&cmd, &["t", "--owner", "acme"]).unwrap()), + json!({"owner": "acme"}) + ); +} + +// --- the --json payload override --- + +#[test] +fn json_override_wins_verbatim() { + let cmd = command( + "srv", + &[tool( + "t", + "d", + object_schema(json!({"owner": {"type": "string"}}), &[]), + )], + ); + let raw = r#"{ "owner" : "acme", "extra" : [1, 2] }"#; + assert_eq!(overridden(run(&cmd, &["t", "--json", raw]).unwrap()), raw); + // The stdin marker rides through untouched; reading it is the + // caller's job. + assert_eq!(overridden(run(&cmd, &["t", "--json", "-"]).unwrap()), "-"); +} + +#[test] +fn json_override_relaxes_required_flags() { + let cmd = command( + "srv", + &[tool( + "t", + "d", + object_schema(json!({"owner": {"type": "string"}}), &["owner"]), + )], + ); + // The override replaces the payload, so the required flag does + // not apply. + assert_eq!( + overridden(run(&cmd, &["t", "--json", r#"{"owner": "o"}"#]).unwrap()), + r#"{"owner": "o"}"# + ); + // But it still cannot ride alongside the flag it replaces. + assert_eq!( + run(&cmd, &["t", "--json", "{}", "--owner", "o"]).unwrap_err(), + MappingError::JsonConflict + ); +} + +#[test] +fn json_override_conflicts_with_tool_flags() { + let cmd = command( + "srv", + &[tool( + "t", + "d", + object_schema(json!({"owner": {"type": "string"}}), &[]), + )], + ); + assert_eq!( + run(&cmd, &["t", "--json", "{}", "--owner", "acme"]).unwrap_err(), + MappingError::JsonConflict + ); + // A presence flag counts as a tool flag too. + let bools = command( + "srv", + &[tool( + "t", + "d", + object_schema(json!({"draft": {"type": "boolean"}}), &[]), + )], + ); + assert_eq!( + run(&bools, &["t", "--json", "{}", "--draft"]).unwrap_err(), + MappingError::JsonConflict + ); +} + +#[test] +fn json_override_serves_a_demoted_tool() { + let cmd = command( + "srv", + &[tool( + "t", + "d", + object_schema( + json!({"json": {"type": "string"}, "other": {"type": "string"}}), + &[], + ), + )], + ); + let longs: Vec<&str> = cmd + .find_subcommand("t") + .unwrap() + .get_arguments() + .filter_map(|a| a.get_long()) + .collect(); + // The property named `json` collides with the override: the whole + // tool is demoted to `--json` and the discovery flag. + assert_eq!(longs, ["json", "schema"]); + assert!(parse(&cmd, &["t", "--other", "x"]).is_none()); + assert_eq!(mapped(run(&cmd, &["t"]).unwrap()), json!({})); + assert_eq!( + overridden(run(&cmd, &["t", "--json", r#"{"json": "x"}"#]).unwrap()), + r#"{"json": "x"}"# + ); +} + +// --- collisions demote the whole tool --- + +#[test] +fn derived_flag_collision_demotes_the_tool() { + // `a` (object) derives `--a_json`; a sibling literally named + // `a_json` would collide with it. + let cmd = command( + "srv", + &[tool( + "t", + "d", + object_schema( + json!({"a": {"type": "object"}, "a_json": {"type": "string"}}), + &[], + ), + )], + ); + let longs: Vec<&str> = cmd + .find_subcommand("t") + .unwrap() + .get_arguments() + .filter_map(|a| a.get_long()) + .collect(); + assert_eq!(longs, ["json", "schema"]); + assert!(parse(&cmd, &["t", "--a_json", "x"]).is_none()); + assert!(parse(&cmd, &["t", "--a", "x"]).is_none()); + assert_eq!( + overridden(run(&cmd, &["t", "--json", r#"{"a": {}}"#]).unwrap()), + r#"{"a": {}}"# + ); +} + +#[test] +fn help_named_property_demotes_the_tool() { + let cmd = command( + "srv", + &[tool( + "t", + "d", + object_schema(json!({"help": {"type": "string"}}), &[]), + )], + ); + // `--help` is clap's; the tool runs bare and takes only `--json`. + assert_eq!(mapped(run(&cmd, &["t"]).unwrap()), json!({})); + assert_eq!(overridden(run(&cmd, &["t", "--json", "{}"]).unwrap()), "{}"); +} + +#[test] +fn unusable_property_name_demotes_the_tool() { + let cmd = command( + "srv", + &[tool( + "t", + "d", + object_schema( + json!({"bad name": {"type": "string"}, "ok": {"type": "string"}}), + &[], + ), + )], + ); + // Whitespace cannot ride a long flag; the conservative cut drops + // every property flag rather than half a tool. + let longs: Vec<&str> = cmd + .find_subcommand("t") + .unwrap() + .get_arguments() + .filter_map(|a| a.get_long()) + .collect(); + assert_eq!(longs, ["json", "schema"]); + assert!(parse(&cmd, &["t", "--ok", "x"]).is_none()); +} + +// --- degenerate schemas --- + +#[test] +fn empty_schema_tool_maps_an_empty_object() { + let cmd = command("srv", &[tool("t", "d", json!({}))]); + assert!(cmd.find_subcommand("t").is_some()); + assert_eq!(mapped(run(&cmd, &["t"]).unwrap()), json!({})); + // Nothing mandatory: the usage line carries no flags. (Standalone + // subcommand renders have no bin path, so only the absence of + // flags is asserted, not the line's prefix.) + let mut rendered = command("srv", &[tool("t", "d", json!({}))]); + let text = rendered + .find_subcommand_mut("t") + .expect("subcommand") + .render_help() + .to_string(); + let usage = text + .lines() + .find(|l| l.starts_with("Usage:")) + .expect("usage line"); + assert!(!usage.contains("--"), "{text}"); +} + +#[test] +fn object_schema_without_properties_maps_an_empty_object() { + let cmd = command( + "srv", + &[tool("t", "d", json!({"type": "object", "properties": {}}))], + ); + assert_eq!(mapped(run(&cmd, &["t"]).unwrap()), json!({})); + assert_eq!( + overridden(run(&cmd, &["t", "--json", r#"{"a": 1}"#]).unwrap()), + r#"{"a": 1}"# + ); +} + +// --- requiredness is visible where the model reads --- + +#[test] +fn required_flags_mark_help_and_enforce() { + let cmd = command( + "srv", + &[tool( + "t", + "d", + json!({ + "type": "object", + "properties": { + "must": {"type": "string", "description": "The mandatory one"}, + "tags": {"type": "array", "items": {"type": "string"}}, + "free": {"type": "string", "description": "The optional one"}, + }, + "required": ["must", "tags"], + }), + )], + ); + let sc = cmd.find_subcommand("t").unwrap(); + let help_of = |long: &str| { + sc.get_arguments() + .find(|a| a.get_long() == Some(long)) + .unwrap_or_else(|| panic!("{long} flag exists")) + .get_help() + .map(|h| h.to_string()) + }; + assert_eq!( + help_of("must").as_deref(), + Some("The mandatory one (required)") + ); + assert_eq!(help_of("tags").as_deref(), Some("(required, repeatable)")); + assert_eq!(help_of("free").as_deref(), Some("The optional one")); + + // Requiredness reads at a glance: mandatory flags lead the help, + // optionals follow, the override flag sits last — and the usage + // line spells the mandatory flags out, synthesized since clap's + // own usage cannot render required_unless_present args. + let mut rendered = command( + "srv", + &[tool( + "t", + "d", + json!({ + "type": "object", + "properties": { + "must": {"type": "string", "description": "The mandatory one"}, + "tags": {"type": "array", "items": {"type": "string"}}, + "free": {"type": "string", "description": "The optional one"}, + }, + "required": ["must", "tags"], + }), + )], + ); + let text = rendered + .find_subcommand_mut("t") + .expect("subcommand") + .render_help() + .to_string(); + let must_at = text.find("--must").expect("must in help"); + let tags_at = text.find("--tags").expect("tags in help"); + let free_at = text.find("--free").expect("free in help"); + assert!(must_at < free_at && tags_at < free_at, "{text}"); + assert!(text.contains("Usage: srv t --must --tags [OPTIONS]")); + assert!( + !text[..text.find("Options:").expect("options header")].contains("--free"), + "usage line lists mandatory flags only" + ); + + // clap enforces both, loudly, listing the missing flags. + let err = parse(&cmd, &["t", "--free", "x"]); + assert!(err.is_none(), "omitted required flags must not parse"); + + // The override still relaxes them all. + assert_eq!( + overridden(run(&cmd, &["t", "--json", r#"{"must": "v"}"#]).unwrap()), + r#"{"must": "v"}"# + ); +} + +#[test] +fn nullable_required_props_stay_optional_in_help() { + // A required property whose flag cannot say null stays + // clap-optional, and the help must not claim otherwise. + let cmd = command( + "srv", + &[tool( + "t", + "d", + json!({ + "type": "object", + "properties": { + "maybe": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + }, + "required": ["maybe"], + }), + )], + ); + let sc = cmd.find_subcommand("t").unwrap(); + let arg = sc + .get_arguments() + .find(|a| a.get_long() == Some("maybe")) + .expect("maybe flag"); + assert!( + arg.get_help().is_none(), + "no required mark on a nullable prop" + ); + assert!(parse(&cmd, &["t"]).is_some(), "not clap-required"); +} + +#[test] +fn number_flags_whole_values_are_json_integers() { + let cmd = command( + "srv", + &[tool( + "t", + "d", + json!({ + "type": "object", + "properties": { + "n": {"type": "number"}, + "m": {"type": "number"}, + "many": {"type": "array", "items": {"type": "number"}}, + }, + }), + )], + ); + assert_eq!( + mapped( + run( + &cmd, + &[ + "t", "--n", "137", "--m", "4.5", "--many", "1", "--many", "2.5" + ] + ) + .unwrap() + ), + json!({"n": 137, "m": 4.5, "many": [1, 2.5]}) + ); +} diff --git a/tests/real_catalog.rs b/tests/real_catalog.rs new file mode 100644 index 0000000..fa0a644 --- /dev/null +++ b/tests/real_catalog.rs @@ -0,0 +1,424 @@ +//! The mapping matrix against vendored real schemas: github's +//! github-mcp-server, an Atlassian-shaped stand-in, and a +//! FastMCP-shaped stand-in. See tests/fixtures/README.md. + +mod common; + +use clap::Command; +use common::{mapped, overridden, parse, run, tool}; +use mcp_to_cli::{args, command}; +use serde_json::json; + +/// The vendored fixture `name`, its JSON as the wire format carried it. +fn fixture(name: &str) -> serde_json::Value { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures") + .join(name); + let raw = std::fs::read_to_string(&path).expect("fixture is readable"); + serde_json::from_str(&raw).unwrap_or_else(|e| panic!("fixture {name} parses: {e}")) +} + +fn catalog(names: &[&str]) -> Command { + let tools: Vec = names + .iter() + .map(|name| { + tool( + name, + &format!("the {name} tool"), + fixture(&format!("{name}.json")), + ) + }) + .collect(); + command("github", &tools) +} + +#[test] +fn create_pull_request_is_the_worked_example() { + let cmd = catalog(&["create_pull_request"]); + // The spec's own example, flags verbatim. + let value = mapped( + run( + &cmd, + &[ + "create_pull_request", + "--owner", + "acme", + "--repo", + "api", + "--title", + "Add /health", + "--head", + "feature/health", + "--base", + "main", + "--draft", + "--reviewers", + "alice", + "--reviewers", + "bob", + ], + ) + .unwrap(), + ); + assert_eq!( + value, + json!({ + "owner": "acme", + "repo": "api", + "title": "Add /health", + "head": "feature/health", + "base": "main", + "draft": true, + "reviewers": ["alice", "bob"], + }) + ); +} + +#[test] +fn create_pull_request_omitted_optionals_stay_absent() { + let cmd = catalog(&["create_pull_request"]); + let value = mapped( + run( + &cmd, + &[ + "create_pull_request", + "--owner", + "o", + "--repo", + "r", + "--title", + "t", + "--head", + "h", + "--base", + "b", + ], + ) + .unwrap(), + ); + // draft, maintainer_can_modify, body, reviewers: all absent. + assert_eq!( + value, + json!({"owner": "o", "repo": "r", "title": "t", "head": "h", "base": "b"}) + ); + // And the required scalars are enforced. + assert!(parse(&cmd, &["create_pull_request", "--owner", "o"]).is_none()); +} + +#[test] +fn search_repositories_bool_default_true_takes_explicit_false() { + let cmd = catalog(&["search_repositories"]); + // The spec's example, verbatim. + let value = mapped( + run( + &cmd, + &[ + "search_repositories", + "--query", + "org:acme", + "--minimal_output", + "false", + ], + ) + .unwrap(), + ); + assert_eq!(value, json!({"query": "org:acme", "minimal_output": false})); + // Omitted, the key stays absent and the server applies its true. + let omitted = mapped(run(&cmd, &["search_repositories", "--query", "q"]).unwrap()); + assert_eq!(omitted, json!({"query": "q"})); +} + +#[test] +fn search_repositories_enums_validate() { + let cmd = catalog(&["search_repositories"]); + let value = mapped( + run( + &cmd, + &[ + "search_repositories", + "--query", + "q", + "--sort", + "stars", + "--order", + "desc", + ], + ) + .unwrap(), + ); + assert_eq!(value["sort"], "stars"); + assert_eq!(value["order"], "desc"); + assert!( + parse( + &cmd, + &[ + "search_repositories", + "--query", + "q", + "--sort", + "stargazers" + ] + ) + .is_none() + ); + assert!( + parse( + &cmd, + &["search_repositories", "--query", "q", "--order", "up"] + ) + .is_none() + ); +} + +#[test] +fn search_repositories_numbers_and_ignored_constraints() { + let cmd = catalog(&["search_repositories"]); + // `perPage` rides camelCase verbatim; minimum/maximum are ignored. + let value = mapped( + run( + &cmd, + &[ + "search_repositories", + "--query", + "q", + "--perPage", + "1000", + "--page", + "2", + ], + ) + .unwrap(), + ); + assert_eq!(value["perPage"].as_f64(), Some(1000.0)); + assert_eq!(value["page"].as_f64(), Some(2.0)); +} + +#[test] +fn update_pull_request_state_enum_and_number() { + let cmd = catalog(&["update_pull_request"]); + let value = mapped( + run( + &cmd, + &[ + "update_pull_request", + "--owner", + "o", + "--repo", + "r", + "--pullNumber", + "7", + "--state", + "closed", + ], + ) + .unwrap(), + ); + assert_eq!(value["state"], "closed"); + // The usage line spells the mandatory flags out. + let mut rendered = cmd.clone(); + let text = rendered + .find_subcommand_mut("update_pull_request") + .expect("subcommand") + .render_help() + .to_string(); + assert!( + text.contains( + "Usage: github update_pull_request --owner --pullNumber \ + --repo [OPTIONS]", + ), + "{text}" + ); + // `pullNumber` is a schema number: whole values keep the integer + // wire form strict parsers accept. + assert_eq!(value["pullNumber"].as_i64(), Some(7)); + assert!(!value["pullNumber"].is_f64()); + // draft is an optional boolean: presence flag. + assert!(parse(&cmd, &["update_pull_request", "--draft", "false"]).is_none()); +} + +#[test] +fn create_issue_maps_scalars_and_ignores_extensions() { + let cmd = catalog(&["create_issue"]); + let value = mapped( + run( + &cmd, + &[ + "create_issue", + "--owner", + "o", + "--repo", + "r", + "--title", + "t", + "--body", + "b", + ], + ) + .unwrap(), + ); + assert_eq!( + value, + json!({"owner": "o", "repo": "r", "title": "t", "body": "b"}) + ); + // parent_issue_number (number, minimum ignored) is optional. + let partial = mapped( + run( + &cmd, + &[ + "create_issue", + "--owner", + "o", + "--repo", + "r", + "--title", + "t", + ], + ) + .unwrap(), + ); + assert_eq!(partial, json!({"owner": "o", "repo": "r", "title": "t"})); + assert!(parse(&cmd, &["create_issue", "--owner", "o", "--repo", "r"]).is_none()); +} + +#[test] +fn projects_write_excerpt_maps_both_json_flags() { + let cmd = catalog(&["projects_write_excerpt"]); + let value = mapped( + run( + &cmd, + &[ + "projects_write_excerpt", + "--iterations_json", + r#"[{"title": "Sprint 1", "start_date": "2026-01-01", "duration": 7}]"#, + "--updated_field_json", + r#"{"name": "Status", "value": "In Progress"}"#, + ], + ) + .unwrap(), + ); + // Verbatim: the per-item required shape passes through untouched. + assert_eq!( + value, + json!({ + "iterations": [ + {"title": "Sprint 1", "start_date": "2026-01-01", "duration": 7}, + ], + "updated_field": {"name": "Status", "value": "In Progress"}, + }) + ); +} + +#[test] +fn atlassian_additional_fields_shape() { + let cmd = command( + "atlassian", + &[tool( + "create_issue", + "create an issue", + fixture("additional_fields.json"), + )], + ); + // The pattern on project_key is ignored — the server validates. + let value = mapped( + run( + &cmd, + &[ + "create_issue", + "--project_key", + "proj", + "--summary", + "Fix login", + "--additional_fields_json", + r#"{"priority": {"name": "High"}}"#, + ], + ) + .unwrap(), + ); + assert_eq!( + value, + json!({ + "project_key": "proj", + "summary": "Fix login", + "additional_fields": {"priority": {"name": "High"}}, + }) + ); + // Required scalars are enforced. + assert!(parse(&cmd, &["create_issue", "--project_key", "PROJ"]).is_none()); + // The three-variant anyOf is a JSON flag, not a nullable string. + assert!(parse(&cmd, &["create_issue", "--additional_fields", "x"]).is_none()); +} + +#[test] +fn fastmcp_get_scene_info_shape() { + let cmd = command( + "blender", + &[tool( + "get_scene_info", + "get scene info", + fixture("get_scene_info.json"), + )], + ); + // The spec's example: nullable optionals map as plain flags. + let value = mapped( + run( + &cmd, + &[ + "get_scene_info", + "--code", + "import bpy; print(bpy.context.scene.name)", + "--query", + "chair", + "--fields", + "location", + "--fields", + "topology", + "--limit", + "5", + ], + ) + .unwrap(), + ); + assert_eq!( + value, + json!({ + "code": "import bpy; print(bpy.context.scene.name)", + "query": "chair", + "fields": ["location", "topology"], + "limit": 5, + }) + ); + // Omitted optionals — user_prompt (default ""), limit (default 20) + // — stay absent; required code is enforced. + let minimal = mapped(run(&cmd, &["get_scene_info", "--code", "bpy"]).unwrap()); + assert_eq!(minimal, json!({"code": "bpy"})); + assert!(parse(&cmd, &["get_scene_info"]).is_none()); +} + +#[test] +fn whole_catalog_round_trips_through_the_override() { + let cmd = catalog(&["create_issue", "create_pull_request"]); + assert_eq!( + overridden(run(&cmd, &["create_issue", "--json", r#"{"owner": "o"}"#]).unwrap()), + r#"{"owner": "o"}"# + ); + // Every tool of the catalog also serves the bare list path: bare + // invocation parses, args reports NoSubcommand, list_json renders. + let matches = parse(&cmd, &[]).unwrap(); + assert!(matches!( + args(&cmd, &matches), + Err(mcp_to_cli::MappingError::NoSubcommand) + )); + let listed = mcp_to_cli::list_json(&[ + tool( + "create_issue", + "the create_issue tool", + fixture("create_issue.json"), + ), + tool( + "create_pull_request", + "the create_pull_request tool", + fixture("create_pull_request.json"), + ), + ]); + assert_eq!(listed.as_array().map(Vec::len), Some(2)); +} diff --git a/tests/selection.rs b/tests/selection.rs new file mode 100644 index 0000000..d26ffdf --- /dev/null +++ b/tests/selection.rs @@ -0,0 +1,289 @@ +//! Tool selection: which tools become subcommands, the bare +//! invocation list, and the errors `args` reports. + +mod common; + +use common::{mapped, overridden, parse, run, tool}; +use mcp_to_cli::{MappingError, args, cli_safe, command, list_json}; +use serde_json::json; + +#[test] +fn cli_safe_accepts_argv_clean_names() { + for name in [ + "create_issue", + "a", + "A9", + "with-dash", + "with_underscore", + "9", + "help", + ] { + assert!(cli_safe(name), "{name} should be safe"); + } +} + +#[test] +fn cli_safe_rejects_everything_else() { + for name in [ + "", + "has space", + "dot.name", + "sla/sh", + "uni\u{e7}ode", + "star*", + "tab\tname", + "query?", + ] { + assert!(!cli_safe(name), "{name} should not be safe"); + } +} + +#[test] +fn unsafe_named_tools_are_omitted() { + let cmd = command( + "srv", + &[ + tool( + "safe", + "safe tool", + json!({"type": "object", "properties": {}}), + ), + tool( + "bad name", + "unreachable", + json!({"type": "object", "properties": {}}), + ), + ], + ); + assert!(cmd.find_subcommand("safe").is_some()); + assert!(cmd.find_subcommand("bad name").is_none()); +} + +#[test] +fn help_named_tools_are_skipped() { + let cmd = command( + "srv", + &[ + tool( + "help", + "clap owns this name", + json!({"type": "object", "properties": {}}), + ), + tool("other", "fine", json!({"type": "object", "properties": {}})), + ], + ); + // cli_safe("help") is true — the skip is the command's, not the + // charset's. + assert!(cli_safe("help")); + assert!(cmd.find_subcommand("help").is_none()); + assert!(cmd.find_subcommand("other").is_some()); +} + +#[test] +fn non_object_roots_are_omitted() { + let cmd = command( + "srv", + &[ + tool( + "arr", + "array root", + json!({"type": "array", "items": {"type": "string"}}), + ), + tool("str", "string root", json!({"type": "string"})), + tool("null_root", "null root", serde_json::Value::Null), + tool( + "object", + "object root", + json!({"type": "object", "properties": {}}), + ), + ], + ); + assert!(cmd.find_subcommand("arr").is_none()); + assert!(cmd.find_subcommand("str").is_none()); + assert!(cmd.find_subcommand("null_root").is_none()); + assert!(cmd.find_subcommand("object").is_some()); +} + +#[test] +fn duplicate_tool_names_keep_the_first() { + let cmd = command( + "srv", + &[ + tool( + "dup", + "first wins", + json!({"type": "object", "properties": {}}), + ), + tool( + "dup", + "second ignored", + json!({"type": "object", "properties": {}}), + ), + ], + ); + let sc = cmd.find_subcommand("dup").expect("one subcommand"); + assert_eq!( + sc.get_about().map(|a| a.to_string()), + Some("first wins".to_owned()) + ); +} + +#[test] +fn subcommands_follow_catalog_order() { + let empty = json!({"type": "object", "properties": {}}); + let cmd = command( + "srv", + &[ + tool("bravo", "b", empty.clone()), + tool("alpha", "a", empty.clone()), + tool("charlie", "c", empty), + ], + ); + let names: Vec<&str> = cmd.get_subcommands().map(|sc| sc.get_name()).collect(); + assert_eq!(names, ["bravo", "alpha", "charlie"]); +} + +#[test] +fn descriptions_flow_into_about_and_flag_help_verbatim() { + let about = "List things, possibly at length, always helpfully"; + let flag_help = "Repository owner (username or organization)"; + let cmd = command( + "srv", + &[tool( + "t", + about, + json!({"type": "object", "properties": {"owner": {"type": "string", "description": flag_help}}}), + )], + ); + let sc = cmd.find_subcommand("t").expect("tool subcommand"); + assert_eq!( + sc.get_about().map(|a| a.to_string()), + Some(about.to_owned()) + ); + let arg = sc + .get_arguments() + .find(|a| a.get_long() == Some("owner")) + .expect("owner flag"); + assert_eq!( + arg.get_help().map(|h| h.to_string()), + Some(flag_help.to_owned()) + ); +} + +#[test] +fn bare_invocation_matches_and_args_reports_it() { + let cmd = command( + "srv", + &[tool("t", "d", json!({"type": "object", "properties": {}}))], + ); + let matches = parse(&cmd, &[]).expect("bare invocation parses"); + assert!(matches.subcommand().is_none()); + assert_eq!( + args(&cmd, &matches).unwrap_err(), + MappingError::NoSubcommand + ); +} + +#[test] +fn unknown_tool_subcommand_is_claps_error() { + let cmd = command( + "srv", + &[tool("t", "d", json!({"type": "object", "properties": {}}))], + ); + assert!(parse(&cmd, &["nope"]).is_none()); +} + +#[test] +fn matches_outside_the_command_report_unknown_subcommand() { + let with_a = command( + "srv", + &[tool("a", "d", json!({"type": "object", "properties": {}}))], + ); + let empty = command("srv", &[]); + let matches = parse(&with_a, &["a"]).expect("parses"); + assert_eq!( + args(&empty, &matches).unwrap_err(), + MappingError::UnknownSubcommand { + name: "a".to_owned(), + } + ); +} + +#[test] +fn list_json_is_name_and_description_pairs() { + let value = list_json(&[ + tool("create_issue", "Create an issue", json!({"type": "object"})), + tool( + "get_me", + "Get the authenticated user", + json!({"type": "object"}), + ), + ]); + assert_eq!( + value, + json!([ + {"name": "create_issue", "description": "Create an issue"}, + {"name": "get_me", "description": "Get the authenticated user"}, + ]) + ); +} + +#[test] +fn list_json_of_an_empty_catalog_is_an_empty_array() { + assert_eq!(list_json(&[]), json!([])); +} + +#[test] +fn list_json_mirrors_the_rendered_tree() { + let catalog = [ + tool("safe", "kept", json!({"type": "object", "properties": {}})), + tool( + "bad name", + "dropped", + json!({"type": "object", "properties": {}}), + ), + tool( + "help", + "dropped", + json!({"type": "object", "properties": {}}), + ), + tool("str_root", "dropped", json!({"type": "string"})), + tool( + "safe", + "duplicate dropped", + json!({"type": "object", "properties": {}}), + ), + ]; + let cmd = command("srv", &catalog); + let tree: Vec = cmd + .get_subcommands() + .map(|sc| { + json!({ + "name": sc.get_name(), + "description": sc.get_about().map(|a| a.to_string()).unwrap_or_default(), + }) + }) + .collect(); + assert_eq!(list_json(&catalog), serde_json::Value::Array(tree)); + assert_eq!( + list_json(&catalog), + json!([{"name": "safe", "description": "kept"}]) + ); +} + +#[test] +fn mapped_and_overridden_cover_the_whole_surface() { + let cmd = command( + "srv", + &[tool( + "t", + "d", + json!({"type": "object", "properties": {"owner": {"type": "string"}}}), + )], + ); + assert_eq!(overridden(run(&cmd, &["t", "--json", "{}"]).unwrap()), "{}"); + assert_eq!( + mapped(run(&cmd, &["t", "--owner", "o"]).unwrap()), + json!({"owner": "o"}) + ); +}