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:
commit
a7e1de76ac
24 files changed
+3817
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+16
@@ -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"
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user