use std::collections::HashSet;
use std::net::ToSocketAddrs;
use crate::cli::mapping::tool_name_to_subcommand;
use crate::client_gen::generator::{
CliBridgeEntry, 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 bridges = bridge_map_literal(config);
let mut script = format!(
r#"#!/usr/bin/env sh
# generated by mcp-compressor — do not edit manually
show_help() {{
cat <<'HELP'
{help}
HELP
}}
find_bridge() {{
python3 - <<'PY'
import json, os, urllib.request
BRIDGES = {bridges}
def alive(entry):
try:
req = urllib.request.Request(entry["bridge"] + "/health", method="GET")
with urllib.request.urlopen(req, timeout=1) as resp:
return resp.status == 200
except Exception:
return False
def ancestors():
pid = os.getppid()
seen = set()
while pid and pid not in seen:
seen.add(pid)
yield str(pid)
try:
with open(f"/proc/{{pid}}/stat", "r", encoding="utf-8") as handle:
stat = handle.read()
pid = int(stat.rsplit(")", 1)[1].split()[1])
except Exception:
try:
import subprocess
output = subprocess.check_output(["ps", "-o", "ppid=", "-p", str(pid)], text=True)
pid = int(output.strip() or "0")
except Exception:
break
for pid in ancestors():
entry = BRIDGES.get(pid)
if entry and alive(entry):
print(json.dumps(entry))
raise SystemExit(0)
for entry in BRIDGES.values():
if alive(entry):
print(json.dumps(entry))
raise SystemExit(0)
raise SystemExit(1)
PY
}}
case "$1" in
""|--help|-h|help)
show_help
;;
"#,
bridges = bridges,
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
BRIDGE_ENTRY=$(find_bridge) || {{
echo "mcp-compressor proxy is not running; restart the mcp-compressor CLI-mode process and try again." >&2
exit 1
}}
python3 - "$BRIDGE_ENTRY" "{tool_name}" "$@" <<'PY'
import json, sys, urllib.error, urllib.request
TOOL_SCHEMAS = json.loads({tool_schemas:?})
bridge_entry, tool_name, *argv = sys.argv[1:]
entry = json.loads(bridge_entry)
bridge = entry["bridge"]
token = entry["token"]
tool_schema = TOOL_SCHEMAS[tool_name]
properties = tool_schema.get("inputSchema") or tool_schema.get("input_schema") or {{}}
properties = properties.get("properties", {{}}) if isinstance(properties, dict) else {{}}
def schema_type(schema):
return schema.get("type") if isinstance(schema, dict) else None
def flag_to_property(flag):
raw = flag[2:]
if raw.startswith("no-"):
raw = raw[3:]
snake = raw.replace("-", "_")
if snake in properties:
return snake
return raw.replace("_", "-")
def coerce_value(schema, raw_value, forced_bool=None):
if forced_bool is not None:
return forced_bool
typ = schema_type(schema)
if typ == "boolean":
if raw_value is None:
return True
if raw_value == "true":
return True
if raw_value == "false":
return False
raise SystemExit(f"invalid boolean value: {{raw_value}}")
if typ == "integer":
try:
return int(raw_value)
except Exception:
raise SystemExit(f"invalid integer value: {{raw_value}}")
if typ == "number":
try:
return float(raw_value)
except Exception:
raise SystemExit(f"invalid number value: {{raw_value}}")
if typ == "array":
try:
parsed = json.loads(raw_value)
if isinstance(parsed, list):
return parsed
except Exception:
pass
return coerce_value(schema.get("items", {{}}), raw_value)
try:
return json.loads(raw_value or "")
except Exception:
return raw_value or ""
def insert_value(output, key, schema, value):
if schema_type(schema) == "array":
values = value if isinstance(value, list) else [value]
output.setdefault(key, []).extend(values)
else:
output[key] = value
def parse_args(argv):
if argv and argv[0] == "--json":
if len(argv) < 2:
raise SystemExit("--json requires a value")
if len(argv) > 2:
raise SystemExit("--json cannot be combined with other arguments")
return json.loads(argv[1])
output = {{}}
index = 0
while index < len(argv):
flag = argv[index]
if not flag.startswith("--") or flag == "--":
raise SystemExit(f"unexpected positional argument: {{flag}}")
prop = flag_to_property(flag)
if prop not in properties:
raise SystemExit(f"unknown flag: {{flag}}")
schema = properties[prop]
typ = schema_type(schema)
forced_bool = False if flag.startswith("--no-") else None
if forced_bool is False:
if typ != "boolean":
raise SystemExit(f"{{flag}} can only be used with boolean properties")
raw_value = None
consumed = 1
elif typ == "boolean":
if index + 1 < len(argv) and not argv[index + 1].startswith("--"):
raw_value = argv[index + 1]
consumed = 2
else:
raw_value = None
consumed = 1
else:
if index + 1 >= len(argv) or argv[index + 1].startswith("--"):
raise SystemExit(f"{{flag}} requires a value")
raw_value = argv[index + 1]
consumed = 2
insert_value(output, prop, schema, coerce_value(schema, raw_value, forced_bool))
index += consumed
return output
tool_input = parse_args(argv)
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),
tool_schemas = tool_schema_map_literal(config),
));
}
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\nREM session {} -> {}\r\n",
config.session_pid, config.bridge_url
);
for tool in &config.tools {
script.push_str(&format!(
"REM subcommand: {}\r\n",
tool_name_to_subcommand(&tool.name)
));
}
script
}
pub fn read_live_bridge_entries(script: &str) -> Vec<CliBridgeEntry> {
let Some(line) = script
.lines()
.find(|line| line.trim_start().starts_with("BRIDGES = "))
else {
return Vec::new();
};
let Some(json) = line.split_once('=').map(|(_, value)| value.trim()) else {
return Vec::new();
};
let Ok(value) = serde_json::from_str::<serde_json::Value>(json) else {
return Vec::new();
};
value
.as_object()
.into_iter()
.flat_map(|object| object.iter())
.filter_map(|(pid, entry)| {
let session_pid = pid.parse().ok()?;
Some(CliBridgeEntry {
session_pid,
bridge_url: entry.get("bridge")?.as_str()?.to_string(),
token: entry.get("token")?.as_str()?.to_string(),
})
})
.filter(|entry| bridge_is_live(&entry.bridge_url))
.collect()
}
fn tool_schema_map_literal(config: &GeneratorConfig) -> String {
let mut map = serde_json::Map::new();
for tool in &config.tools {
map.insert(
tool.name.clone(),
serde_json::to_value(tool).unwrap_or_else(|_| serde_json::json!({})),
);
}
serde_json::to_string(&serde_json::Value::Object(map)).unwrap_or_else(|_| "{}".to_string())
}
fn bridge_map_literal(config: &GeneratorConfig) -> String {
let mut entries = config.extra_cli_bridges.clone();
entries.retain(|entry| entry.session_pid != config.session_pid);
entries.push(CliBridgeEntry {
session_pid: config.session_pid,
bridge_url: config.bridge_url.clone(),
token: config.token.clone(),
});
let mut map = serde_json::Map::new();
for entry in entries {
map.insert(
entry.session_pid.to_string(),
serde_json::json!({ "bridge": entry.bridge_url, "token": entry.token }),
);
}
serde_json::Value::Object(map).to_string()
}
fn bridge_is_live(bridge_url: &str) -> bool {
let Some(address) = bridge_url.strip_prefix("http://") else {
return false;
};
let Some(host_port) = address.split('/').next() else {
return false;
};
host_port
.to_socket_addrs()
.ok()
.and_then(|mut addresses| addresses.next())
.is_some_and(|address| {
std::net::TcpStream::connect_timeout(&address, std::time::Duration::from_millis(200))
.is_ok()
})
}
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_multi_session_bridge_map() {
let dir = tempfile::tempdir().unwrap();
let mut config = make_config(dir.path());
config.extra_cli_bridges = vec![CliBridgeEntry {
session_pid: 111,
bridge_url: "http://127.0.0.1:1".to_string(),
token: "old-token".to_string(),
}];
let artifacts = CliGenerator.render(&config).unwrap();
let content = &artifacts[0].contents;
assert!(content.contains("BRIDGES = "));
assert!(content.contains("\"111\""));
assert!(content.contains("old-token"));
assert!(content.contains(&format!("\"{}\"", config.session_pid)));
assert!(content.contains(&config.token));
assert!(content.contains("def ancestors():"));
}
#[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));
}
}