Import mcp-to-cli from the rhema workspace
ci / checks (push) Failing after 14s

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.
This commit is contained in:
vhaudiquet committed 2026-11-12 12:20:25 +01:00
commit a7e1de76ac
24 files changed
+3817

No files matched your search

+25
View File
@@ -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
+38
View File
@@ -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 }}
+1
View File
@@ -0,0 +1 @@
/target
Generated
+165
View File
@@ -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"
+20
View File
@@ -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"
+21
View File
@@ -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.
+68
View File
@@ -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
`--<property>_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.
+98
View File
@@ -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<ToolInfo> {
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<String> = 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);
}
}
}
+93
View File
@@ -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 <server-name> <dir-of-tool-json> [argv]");
std::process::exit(2);
});
let dir = argv.next().unwrap_or_else(|| {
eprintln!("usage: catalog <server-name> <dir-of-tool-json> [argv]");
std::process::exit(2);
});
let argv: Vec<String> = 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<ToolInfo> = 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);
}
}
}
+16
View File
@@ -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 <target-dir>}
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"
+44
View File
@@ -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<String> = 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);
}
}
}
+1051
View File
File diff suppressed because it is too large. Load diff
+46
View File
@@ -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<clap::ArgMatches> {
let mut full: Vec<String> = 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<mcp_to_cli::Payload, mcp_to_cli::MappingError> {
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:?}"),
}
}
+40
View File
@@ -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: <https://github.com/github/github-mcp-server>, 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: [<T>, 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.
+20
View File
@@ -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"]
}
+41
View File
@@ -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"
}
+51
View File
@@ -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"
}
+43
View File
@@ -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"]
}
+73
View File
@@ -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"
}
}
}
+46
View File
@@ -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"
}
+57
View File
@@ -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"
}
+1047
View File
File diff suppressed because it is too large. Load diff
+424
View File
@@ -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<mcp_to_cli::ToolInfo> = 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 <OWNER> --pullNumber <NUMBER> \
--repo <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));
}
+289
View File
@@ -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<serde_json::Value> = 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"})
);
}