use anyhow::{Result, anyhow};
use serde_json::Value;
use std::io::{self};
mod mcp;
mod snapshot;
mod diff;
mod config;
mod logger;
use mcp::MCPClient;
use snapshot::{Snapshot, ToolInfo, ExchangeResult};
use diff::{diff_tools, print_report, print_report_json};
use config::TripwireConfig;
use logger::{debug, error};
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt::init();
let args: Vec<String> = std::env::args().collect();
if args.iter().any(|a| a == "--verbose" || a == "-v") {
logger::set_verbose(true);
}
if args.iter().any(|a| a == "--help" || a == "-h") {
print_help();
std::process::exit(0);
}
if args.iter().any(|a| a == "--version") {
println!("trip-test 0.1.0");
std::process::exit(0);
}
if args.len() < 2 {
eprintln!("Usage: trip-test <connect|call|record|check|diff> --server \"<command>\" [options]");
eprintln!("Run 'trip-test --help' for more information");
std::process::exit(3);
}
match args[1].as_str() {
"connect" => {
if args.len() >= 4 && args[2] == "--server" {
match connect_and_list(&args[3]).await {
Ok(_) => std::process::exit(0),
Err(e) => {
eprintln!("Error: {}", e);
std::process::exit(2);
}
}
} else {
eprintln!("Usage: trip-test connect --server \"<command>\"");
std::process::exit(3);
}
}
"call" => {
if args.len() >= 8 && args[2] == "--server" && args[4] == "--tool" && args[6] == "--args" {
let server_cmd = &args[3];
let tool_name = &args[5];
let args_json = &args[7];
match call_tool(server_cmd, tool_name, args_json).await {
Ok(_) => std::process::exit(0),
Err(e) => {
eprintln!("Error: {}", e);
std::process::exit(2);
}
}
} else {
eprintln!("Usage: trip-test call --server \"<command>\" --tool <name> --args '<json>'");
std::process::exit(3);
}
}
"record" => {
if args.len() >= 4 && args[2] == "--server" {
let server_cmd = &args[3];
let out_file = if args.len() >= 6 && args[4] == "--out" {
&args[5]
} else {
"snapshot.trip-test.json"
};
match record_snapshot(server_cmd, out_file).await {
Ok(_) => std::process::exit(0),
Err(e) => {
eprintln!("Error: {}", e);
std::process::exit(2);
}
}
} else {
eprintln!("Usage: trip-test record --server \"<command>\" --out <file>");
std::process::exit(3);
}
}
"check" => {
if args.len() >= 6 && args[2] == "--snapshot" && args[4] == "--server" {
let snapshot_file = &args[3];
let server_cmd = &args[5];
match check_snapshot(snapshot_file, server_cmd).await {
Ok(_) => std::process::exit(0),
Err(e) => {
eprintln!("Error: {}", e);
std::process::exit(1);
}
}
} else {
eprintln!("Usage: trip-test check --snapshot <file> --server \"<command>\"");
std::process::exit(3);
}
}
"diff" => {
let mut config_file: Option<&str> = None;
let mut baseline_file: Option<&str> = None;
let mut server_cmd: Option<&str> = None;
let mut format = "pretty";
let mut i = 2;
while i < args.len() {
match args[i].as_str() {
"--config" => {
if i + 1 < args.len() {
config_file = Some(&args[i + 1]);
i += 2;
} else {
eprintln!("Error: --config requires a value");
std::process::exit(3);
}
}
"--baseline" => {
if i + 1 < args.len() {
baseline_file = Some(&args[i + 1]);
i += 2;
} else {
eprintln!("Error: --baseline requires a value");
std::process::exit(3);
}
}
"--server" => {
if i + 1 < args.len() {
server_cmd = Some(&args[i + 1]);
i += 2;
} else {
eprintln!("Error: --server requires a value");
std::process::exit(3);
}
}
"--format" => {
if i + 1 < args.len() {
format = &args[i + 1];
i += 2;
} else {
eprintln!("Error: --format requires a value");
std::process::exit(3);
}
}
"--verbose" | "-v" => {
i += 1;
}
_ => {
i += 1;
}
}
}
debug(&format!("Loading config from: {:?}", config_file.unwrap_or("default")));
let cfg = TripwireConfig::load_or_default(config_file);
if let Err(e) = cfg {
error(&format!("Failed to load config: {}", e));
std::process::exit(3);
}
let cfg = cfg.unwrap();
debug(&format!("Config loaded successfully"));
let baseline_str;
let server_str;
let baseline = if let Some(b) = baseline_file {
b
} else if let Some(b) = cfg.get_baseline() {
baseline_str = b;
&baseline_str
} else {
eprintln!("Error: baseline file not specified");
eprintln!("Usage: trip-test diff [--config <file>] [--baseline <file>] [--server \"<command>\"] [--format <pretty|json>]");
std::process::exit(3);
};
let server = if let Some(s) = server_cmd {
s
} else if let Some(s) = cfg.get_server_command() {
server_str = s;
&server_str
} else {
eprintln!("Error: server command not specified");
eprintln!("Usage: trip-test diff [--config <file>] [--baseline <file>] [--server \"<command>\"] [--format <pretty|json>]");
std::process::exit(3);
};
match diff_contracts(baseline, server, format).await {
Ok(_) => std::process::exit(0),
Err(e) => {
eprintln!("Error: {}", e);
std::process::exit(1);
}
}
}
_ => {
eprintln!("Unknown command: {}", args[1]);
eprintln!("Usage: trip-test <connect|call|record|check|diff> --server \"<command>\" [options]");
std::process::exit(3);
}
}
}
async fn connect_and_list(server_cmd: &str) -> Result<()> {
let mut client = MCPClient::new(server_cmd).await?;
println!("Connecting to: {}", server_cmd);
let tools = client.list_tools().await?;
println!("\nTools:");
for tool in tools {
println!(" - {} ({})", tool.name, tool.description);
if let Some(schema) = &tool.input_schema {
print!(" inputs: ");
print_schema(schema);
}
}
Ok(())
}
async fn call_tool(server_cmd: &str, tool_name: &str, args_json: &str) -> Result<()> {
let mut client = MCPClient::new(server_cmd).await?;
let args: Value = serde_json::from_str(args_json)?;
println!("Calling tool: {}", tool_name);
println!("Arguments: {}", serde_json::to_string_pretty(&args)?);
let result = client.call_tool(tool_name, args).await?;
println!("\nResult:");
println!("{}", serde_json::to_string_pretty(&result)?);
Ok(())
}
async fn record_snapshot(server_cmd: &str, out_file: &str) -> Result<()> {
let mut client = MCPClient::new(server_cmd).await?;
println!("Connecting to: {}", server_cmd);
let tools = client.list_tools().await?;
let mut snapshot = Snapshot::new(
client.get_server_name(),
client.get_server_version(),
);
println!("\nTools available:");
for (idx, tool) in tools.iter().enumerate() {
println!(" {}. {} ({})", idx + 1, tool.name, tool.description);
snapshot.add_tool(ToolInfo {
name: tool.name.clone(),
description: tool.description.clone(),
input_schema: tool.input_schema.clone(),
});
}
let stdin = io::stdin();
loop {
println!("\nEnter tool number to record (or 0 to finish):");
let mut input = String::new();
stdin.read_line(&mut input)?;
let choice: usize = input.trim().parse().unwrap_or(0);
if choice == 0 {
break;
}
if choice > 0 && choice <= tools.len() {
let tool = &tools[choice - 1];
println!("\nEnter arguments as JSON (e.g., {{\"q\":\"test\",\"limit\":5}}):");
let mut args_input = String::new();
stdin.read_line(&mut args_input)?;
match serde_json::from_str::<Value>(&args_input) {
Ok(args) => {
println!("Calling {}...", tool.name);
match client.call_tool(&tool.name, args.clone()).await {
Ok(result) => {
println!("Result: {}", serde_json::to_string_pretty(&result)?);
let is_error = result.get("result").is_none();
let content = result
.get("result")
.and_then(|r| r.get("content"))
.and_then(|c| c.as_array())
.cloned()
.unwrap_or_default();
snapshot.add_exchange(
tool.name.clone(),
args,
ExchangeResult {
is_error,
content,
},
);
println!("Exchange recorded!");
}
Err(e) => {
eprintln!("Failed to call tool: {}", e);
}
}
}
Err(e) => {
eprintln!("Invalid JSON: {}", e);
}
}
} else {
eprintln!("Invalid choice");
}
}
snapshot.save(out_file)?;
Ok(())
}
async fn check_snapshot(snapshot_file: &str, server_cmd: &str) -> Result<()> {
let mut client = MCPClient::new(server_cmd).await?;
let snapshot = Snapshot::load(snapshot_file)?;
println!("Checking snapshot: {}", snapshot_file);
println!("Against server: {}", server_cmd);
let mut passed = 0;
let mut failed = 0;
for exchange in &snapshot.exchanges {
println!("\nReplaying {}: {}", exchange.id, exchange.tool);
println!(" Input: {}", serde_json::to_string(&exchange.input)?);
match client.call_tool(&exchange.tool, exchange.input.clone()).await {
Ok(result) => {
let is_error = result.get("result").is_none();
if is_error == exchange.expected.is_error {
println!(" ✓ PASS");
passed += 1;
} else {
println!(" ✗ FAIL - error mismatch");
failed += 1;
}
}
Err(e) => {
println!(" ✗ FAIL - {}", e);
failed += 1;
}
}
}
println!("\n========================================");
println!("Results: {} passed, {} failed", passed, failed);
if failed > 0 {
return Err(anyhow!("Snapshot check failed"));
}
Ok(())
}
async fn diff_contracts(baseline_file: &str, server_cmd: &str, format: &str) -> Result<()> {
let mut client = MCPClient::new(server_cmd).await?;
let baseline_snapshot = Snapshot::load(baseline_file)?;
if format == "pretty" {
println!("Comparing baseline: {}", baseline_file);
println!("Against server: {}", server_cmd);
}
let candidate_tools = client.list_tools().await?;
let baseline_tools = baseline_snapshot.tool_catalog;
let report = diff_tools(baseline_tools, candidate_tools)?;
match format {
"json" => print_report_json(&report),
"pretty" | _ => print_report(&report),
}
if report.overall_verdict == diff::ChangeClass::Breaking {
return Err(anyhow!("Breaking changes detected"));
}
Ok(())
}
fn print_help() {
println!("trip-test 0.1.0");
println!("Contract testing & regression safety for MCP servers\n");
println!("USAGE:");
println!(" trip-test <COMMAND> [OPTIONS]\n");
println!("COMMANDS:");
println!(" connect Connect to a server and list tools");
println!(" call Invoke a tool and see the response");
println!(" record Record tool exchanges into a snapshot file");
println!(" check Replay a snapshot and verify behavior");
println!(" diff Compare contracts and detect breaking changes\n");
println!("OPTIONS:");
println!(" -h, --help Print help information");
println!(" -v, --verbose Enable verbose output");
println!(" --version Print version\n");
println!("EXAMPLES:");
println!(" trip-test connect --server \"./my-server --stdio\"");
println!(" trip-test record --server \"./my-server --stdio\" --out baseline.trip-test.json");
println!(" trip-test check --snapshot baseline.trip-test.json --server \"./my-server --stdio\"");
println!(" trip-test diff --config trip-test.toml --format json\n");
println!("For more help on a specific command, run:");
println!(" trip-test <COMMAND> --help");
}
fn print_schema(schema: &Value) {
if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) {
print!("{{ ");
for (key, val) in props {
let type_str = val.get("type").and_then(|t| t.as_str()).unwrap_or("unknown");
print!("{}: {}, ", key, type_str);
}
println!("}}");
} else {
println!("{}", schema);
}
}