use std::collections::HashSet;
use crate::cli::mapping::tool_name_to_subcommand;
use crate::client_gen::generator::{ClientGenerator, GeneratedArtifact, GeneratorConfig};
use crate::Error;
pub struct CliGenerator;
impl ClientGenerator for CliGenerator {
fn render(&self, config: &GeneratorConfig) -> Result<Vec<GeneratedArtifact>, Error> {
Ok(render_cli_artifacts(config))
}
}
#[cfg(not(windows))]
fn render_cli_artifacts(config: &GeneratorConfig) -> Vec<GeneratedArtifact> {
vec![GeneratedArtifact::new(&config.cli_name, render_unix_script(config)).executable()]
}
#[cfg(windows)]
fn render_cli_artifacts(config: &GeneratorConfig) -> Vec<GeneratedArtifact> {
vec![
GeneratedArtifact::new(&config.cli_name, render_unix_script(config)).executable(),
GeneratedArtifact::new(format!("{}.cmd", config.cli_name), render_windows_cmd(config)),
]
}
fn render_unix_script(config: &GeneratorConfig) -> String {
let help = render_top_level_help(config);
let mut script = format!(
r#"#!/usr/bin/env sh
# generated by mcp-compressor — do not edit manually
BRIDGE='{bridge}'
TOKEN='{token}'
SESSION_PID='{pid}'
show_help() {{
cat <<'HELP'
{help}
HELP
}}
case "$1" in
""|--help|-h|help)
show_help
;;
"#,
bridge = shell_quote(&config.bridge_url),
token = shell_quote(&config.token),
pid = config.session_pid,
help = help,
);
for tool in &config.tools {
let subcommand = tool_name_to_subcommand(&tool.name);
let tool_help = render_tool_help(&config.cli_name, tool);
script.push_str(&format!(
r#" {subcommand})
shift
if [ "$1" = "--help" ] || [ "$1" = "-h" ] || [ "$1" = "help" ]; then
cat <<'HELP'
{tool_help}
HELP
exit 0
fi
python3 - "$BRIDGE" "$TOKEN" "{tool_name}" "$@" <<'PY'
import json, sys, urllib.error, urllib.request
bridge, token, tool_name, *argv = sys.argv[1:]
tool_input = {{}}
index = 0
while index < len(argv):
flag = argv[index]
if not flag.startswith("--"):
raise SystemExit(f"unexpected positional argument: {{flag}}")
key = flag[2:].replace("-", "_")
if index + 1 >= len(argv) or argv[index + 1].startswith("--"):
tool_input[key] = True
index += 1
else:
raw_value = argv[index + 1]
try:
tool_input[key] = json.loads(raw_value)
except json.JSONDecodeError:
tool_input[key] = raw_value
index += 2
payload = json.dumps({{"tool": tool_name, "input": tool_input}}).encode()
req = urllib.request.Request(
bridge + "/exec",
data=payload,
headers={{"Content-Type": "application/json", "Authorization": "Bearer " + token}},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
sys.stdout.write(resp.read().decode())
except urllib.error.HTTPError as exc:
message = exc.read().decode(errors="replace") or exc.reason
print(f"mcp-compressor proxy returned HTTP {{exc.code}}: {{message}}", file=sys.stderr)
raise SystemExit(1)
except urllib.error.URLError as exc:
print(
"mcp-compressor proxy is not running; restart the mcp-compressor CLI-mode process and try again.",
file=sys.stderr,
)
print(f"details: {{exc.reason}}", file=sys.stderr)
raise SystemExit(1)
PY
;;
"#,
subcommand = subcommand,
tool_help = tool_help,
tool_name = shell_quote(&tool.name),
));
}
script.push_str(
r#" *)
echo "Usage: $0 <subcommand> [args...]" >&2
exit 2
;;
esac
"#,
);
script
}
#[cfg(windows)]
fn render_windows_cmd(config: &GeneratorConfig) -> String {
let mut script = format!(
"@echo off\r\nREM generated by mcp-compressor -- do not edit manually\r\nset BRIDGE={}\r\nset TOKEN={}\r\n",
config.bridge_url, config.token
);
for tool in &config.tools {
script.push_str(&format!(
"REM subcommand: {}\r\n",
tool_name_to_subcommand(&tool.name)
));
}
script
}
fn render_top_level_help(config: &GeneratorConfig) -> String {
let mut help = format!(
"{name} - the {name} toolset\n\nWhen relevant, outputs from this CLI will prefer using the TOON format for more efficient representation of data.\n\nUSAGE:\n {name} <subcommand> [options]\n\nSUBCOMMANDS:\n",
name = config.cli_name,
);
for tool in &config.tools {
help.push_str(&format!(
" {:<35} {}\n",
tool_name_to_subcommand(&tool.name),
first_line(tool.description.as_deref().unwrap_or("")),
));
}
help.push_str(&format!(
"\nRun '{} <subcommand> --help' for subcommand usage.",
config.cli_name
));
help
}
fn render_tool_help(cli_name: &str, tool: &crate::compression::engine::Tool) -> String {
let subcommand = tool_name_to_subcommand(&tool.name);
let mut help = format!(
"{cli_name} {subcommand}\n\n{}\n\nUSAGE:\n {cli_name} {subcommand}",
tool.description.as_deref().unwrap_or("Invoke this tool."),
);
for param in tool.param_names() {
help.push_str(&format!(" --{} <value>", tool_name_to_subcommand(¶m)));
}
help.push_str("\n");
let options = tool_options(tool);
if !options.is_empty() {
help.push_str("\nOPTIONS:\n");
for option in options {
help.push_str(&format_tool_option_help(&option));
}
}
help
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ToolOption {
name: String,
ty: String,
required: bool,
description: Option<String>,
default: Option<String>,
enum_values: Vec<String>,
}
fn tool_options(tool: &crate::compression::engine::Tool) -> Vec<ToolOption> {
let required = tool
.input_schema
.get("required")
.and_then(|value| value.as_array())
.map(|values| {
values
.iter()
.filter_map(|value| value.as_str().map(str::to_string))
.collect::<HashSet<_>>()
})
.unwrap_or_default();
tool.input_schema
.get("properties")
.and_then(|value| value.as_object())
.map(|properties| {
properties
.iter()
.map(|(name, schema)| ToolOption {
name: name.clone(),
ty: schema_type_label(schema),
required: required.contains(name),
description: schema
.get("description")
.and_then(|value| value.as_str())
.map(str::to_string),
default: schema.get("default").map(default_value_label),
enum_values: schema
.get("enum")
.and_then(|value| value.as_array())
.map(|values| values.iter().map(default_value_label).collect())
.unwrap_or_default(),
})
.collect()
})
.unwrap_or_default()
}
fn format_tool_option_help(option: &ToolOption) -> String {
let flag = format!("--{}", tool_name_to_subcommand(&option.name));
let requirement = if option.required {
"required"
} else {
"optional"
};
let mut line = format!(" {flag:<28} <{}> {requirement}", option.ty);
let mut details = Vec::new();
if let Some(description) = &option.description {
details.push(first_line(description).to_string());
}
if !option.enum_values.is_empty() {
details.push(format!("values: {}", option.enum_values.join(", ")));
}
if let Some(default) = &option.default {
details.push(format!("default: {default}"));
}
if !details.is_empty() {
line.push_str(" — ");
line.push_str(&details.join("; "));
}
line.push('\n');
line
}
fn schema_type_label(schema: &serde_json::Value) -> String {
if let Some(values) = schema.get("enum").and_then(|value| value.as_array()) {
let labels = values
.iter()
.filter_map(|value| value.as_str().map(str::to_string))
.collect::<Vec<_>>();
if !labels.is_empty() {
return labels.join("|");
}
}
match schema.get("type").and_then(|value| value.as_str()) {
Some("integer") => "integer".to_string(),
Some("number") => "number".to_string(),
Some("boolean") => "boolean".to_string(),
Some("array") => schema
.get("items")
.map(|items| format!("{}[]", schema_type_label(items)))
.unwrap_or_else(|| "array".to_string()),
Some("object") => "json".to_string(),
Some("string") | None => "string".to_string(),
Some(other) => other.to_string(),
}
}
fn default_value_label(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(value) => value.clone(),
other => other.to_string(),
}
}
fn first_line(value: &str) -> &str {
value.lines().next().unwrap_or("")
}
fn shell_quote(value: &str) -> String {
value.replace('\'', "'\\''")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client_gen::generator::test_helpers::{make_config, make_config_multiword_tool};
use crate::client_gen::ClientGenerator;
use std::fs;
#[test]
fn generate_returns_non_empty_paths() {
let dir = tempfile::tempdir().unwrap();
let config = make_config(dir.path());
let paths = CliGenerator.generate(&config).unwrap();
assert!(
!paths.is_empty(),
"expected at least one generated artifact"
);
}
#[test]
fn generated_paths_exist() {
let dir = tempfile::tempdir().unwrap();
let config = make_config(dir.path());
let paths = CliGenerator.generate(&config).unwrap();
for path in &paths {
assert!(path.exists(), "path does not exist: {path:?}");
}
}
#[test]
fn generated_paths_inside_output_dir() {
let dir = tempfile::tempdir().unwrap();
let config = make_config(dir.path());
let paths = CliGenerator.generate(&config).unwrap();
for path in &paths {
assert!(
path.starts_with(dir.path()),
"path {path:?} is outside output_dir {:?}",
dir.path(),
);
}
}
#[test]
#[cfg(unix)]
fn unix_script_has_shebang() {
let dir = tempfile::tempdir().unwrap();
let config = make_config(dir.path());
let paths = CliGenerator.generate(&config).unwrap();
let unix_script = paths.iter().find(|p| p.extension().is_none()).unwrap();
let content = fs::read_to_string(unix_script).unwrap();
assert!(
content.starts_with("#!"),
"script must start with shebang, got: {content:?}"
);
}
#[test]
#[cfg(unix)]
fn unix_script_named_after_cli_name() {
use std::ffi::OsStr;
let dir = tempfile::tempdir().unwrap();
let config = make_config(dir.path());
let paths = CliGenerator.generate(&config).unwrap();
let unix_script = paths.iter().find(|p| p.extension().is_none()).unwrap();
assert_eq!(unix_script.file_name(), Some(OsStr::new("my-server")));
}
#[test]
#[cfg(unix)]
fn unix_script_contains_token() {
let dir = tempfile::tempdir().unwrap();
let config = make_config(dir.path());
let paths = CliGenerator.generate(&config).unwrap();
let unix_script = paths.iter().find(|p| p.extension().is_none()).unwrap();
let content = fs::read_to_string(unix_script).unwrap();
assert!(content.contains(&config.token), "token not found in script");
}
#[test]
#[cfg(unix)]
fn unix_script_contains_bridge_url() {
let dir = tempfile::tempdir().unwrap();
let config = make_config(dir.path());
let paths = CliGenerator.generate(&config).unwrap();
let unix_script = paths.iter().find(|p| p.extension().is_none()).unwrap();
let content = fs::read_to_string(unix_script).unwrap();
assert!(
content.contains(&config.bridge_url),
"bridge URL not found in script"
);
}
#[test]
#[cfg(unix)]
fn unix_script_contains_all_tool_subcommands() {
let dir = tempfile::tempdir().unwrap();
let config = make_config(dir.path());
let paths = CliGenerator.generate(&config).unwrap();
let unix_script = paths.iter().find(|p| p.extension().is_none()).unwrap();
let content = fs::read_to_string(unix_script).unwrap();
assert!(content.contains("fetch"), "subcommand 'fetch' not found");
assert!(content.contains("search"), "subcommand 'search' not found");
}
#[test]
#[cfg(unix)]
fn unix_script_kebab_case_subcommand() {
let dir = tempfile::tempdir().unwrap();
let config = make_config_multiword_tool(dir.path());
let paths = CliGenerator.generate(&config).unwrap();
let unix_script = paths.iter().find(|p| p.extension().is_none()).unwrap();
let content = fs::read_to_string(unix_script).unwrap();
assert!(
content.contains("get-confluence-page"),
"expected kebab-case subcommand in script",
);
}
#[test]
#[cfg(unix)]
fn unix_script_contains_top_level_help() {
let dir = tempfile::tempdir().unwrap();
let config = make_config(dir.path());
let paths = CliGenerator.generate(&config).unwrap();
let unix_script = paths.iter().find(|p| p.extension().is_none()).unwrap();
let content = fs::read_to_string(unix_script).unwrap();
assert!(content.contains("my-server - the my-server toolset"));
assert!(content.contains("USAGE:"));
assert!(content.contains("SUBCOMMANDS:"));
assert!(content.contains("Run 'my-server <subcommand> --help'"));
}
#[test]
#[cfg(unix)]
fn unix_script_contains_subcommand_help() {
let dir = tempfile::tempdir().unwrap();
let config = make_config(dir.path());
let paths = CliGenerator.generate(&config).unwrap();
let unix_script = paths.iter().find(|p| p.extension().is_none()).unwrap();
let content = fs::read_to_string(unix_script).unwrap();
assert!(content.contains("my-server fetch"));
assert!(content.contains("OPTIONS:"));
assert!(content.contains("--url <value>"));
assert!(content.contains("--url"));
assert!(content.contains("<string> required — URL to fetch."));
assert!(content.contains("--timeout"));
assert!(content.contains("<integer> optional — Timeout in seconds.; default: 30"));
assert!(content.contains("--method"));
assert!(content.contains("values: GET, POST"));
}
#[test]
#[cfg(unix)]
fn unix_script_is_executable() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let config = make_config(dir.path());
let paths = CliGenerator.generate(&config).unwrap();
let unix_script = paths.iter().find(|p| p.extension().is_none()).unwrap();
let perms = fs::metadata(unix_script).unwrap().permissions();
assert_ne!(perms.mode() & 0o111, 0, "script must be executable");
}
#[test]
#[cfg(windows)]
fn windows_cmd_generated() {
use std::ffi::OsStr;
let dir = tempfile::tempdir().unwrap();
let config = make_config(dir.path());
let paths = CliGenerator.generate(&config).unwrap();
assert!(
paths
.iter()
.any(|p| p.extension() == Some(OsStr::new("cmd"))),
"expected a .cmd file on Windows",
);
}
#[test]
#[cfg(windows)]
fn windows_cmd_contains_token() {
use std::ffi::OsStr;
let dir = tempfile::tempdir().unwrap();
let config = make_config(dir.path());
let paths = CliGenerator.generate(&config).unwrap();
let cmd = paths
.iter()
.find(|p| p.extension() == Some(OsStr::new("cmd")))
.unwrap();
let content = fs::read_to_string(cmd).unwrap();
assert!(content.contains(&config.token));
}
}