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,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));
|
||||
}
|
||||
Reference in new issue
Block a user