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.
1052 lines
38 KiB
Rust
1052 lines
38 KiB
Rust
//! 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 `--<property>_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 `--<property>_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 `<property>_json` flag against a sibling)
|
|
/// does the same. Every subcommand also carries the `--json <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<Payload, MappingError> {
|
|
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::<String>("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::<Map<String, Value>>()
|
|
})
|
|
.collect::<Vec<Map<String, Value>>>();
|
|
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: `--<property>_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<Slot> {
|
|
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::<Vec<_>>()
|
|
.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: \
|
|
<tool> --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<String>,
|
|
) -> 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<Value, serde_json::Error> {
|
|
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<Number> {
|
|
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::<Vec<_>>()
|
|
.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::<Vec<_>>()
|
|
.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::<Vec<_>>()
|
|
.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<String, Value>> {
|
|
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<Value> {
|
|
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<Vec<String>> {
|
|
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<String, Value>,
|
|
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::<bool>(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<Value> {
|
|
match kind {
|
|
Kind::Text | Kind::Enum => sub.get_one::<String>(id).map(|v| Value::String(v.clone())),
|
|
Kind::Number => sub
|
|
.get_one::<f64>(id)
|
|
.and_then(|v| number_of(*v))
|
|
.map(Value::Number),
|
|
Kind::Integer => sub.get_one::<i64>(id).map(|v| Value::Number((*v).into())),
|
|
Kind::Bool => sub.get_one::<bool>(id).map(|v| Value::Bool(*v)),
|
|
Kind::Json => sub.get_one::<Value>(id).cloned(),
|
|
}
|
|
}
|
|
|
|
/// The mapped array of one repeatable flag's occurrences.
|
|
fn append_value(sub: &ArgMatches, id: &str, kind: Kind) -> Option<Value> {
|
|
let values = match kind {
|
|
Kind::Text | Kind::Enum => sub
|
|
.get_many::<String>(id)?
|
|
.map(|v| Value::String(v.clone()))
|
|
.collect(),
|
|
Kind::Number => sub
|
|
.get_many::<f64>(id)?
|
|
.filter_map(|v| number_of(*v))
|
|
.map(Value::Number)
|
|
.collect(),
|
|
Kind::Integer => sub
|
|
.get_many::<i64>(id)?
|
|
.map(|v| Value::Number((*v).into()))
|
|
.collect(),
|
|
Kind::Bool => sub.get_many::<bool>(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"));
|
|
}
|
|
}
|