use std::time::{Duration, Instant};
use tower_mcp::client::{HttpClientTransport, McpClient};
use tower_mcp::transport::http::HttpTransport;
use tower_mcp::{CallToolResult, McpRouter, ToolBuilder};
use mcp_proxy::{Proxy, ProxyConfig};
const WARMUP_PER_WORKER: usize = 20;
const REQUESTS_PER_CELL: usize = 2000;
const CONCURRENCY_LEVELS: &[usize] = &[1, 8, 32];
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct EchoInput {
v: u64,
}
fn echo_router() -> McpRouter {
let echo = ToolBuilder::new("echo")
.description("Echo a number")
.handler(|input: EchoInput| async move { Ok(CallToolResult::text(input.v.to_string())) })
.build();
McpRouter::new()
.server_info("bench-backend", "1.0.0")
.tool(echo)
}
async fn serve(router: axum::Router) -> anyhow::Result<String> {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let addr = listener.local_addr()?;
tokio::spawn(async move {
axum::serve(listener, router).await.expect("server runs");
});
Ok(format!("http://{addr}"))
}
async fn start_backend() -> anyhow::Result<String> {
serve(HttpTransport::new(echo_router()).into_router()).await
}
async fn start_proxy(backend_url: &str, loaded: bool) -> anyhow::Result<String> {
let middleware = if loaded {
r#"
[backends.timeout]
seconds = 5
[backends.retry]
max_retries = 2
initial_backoff_ms = 10
max_backoff_ms = 100
[backends.circuit_breaker]
failure_rate_threshold = 0.5
minimum_calls = 100
wait_duration_seconds = 5
[backends.cache]
tool_ttl_seconds = 60
max_entries = 10000
"#
} else {
""
};
let toml = format!(
r#"
[proxy]
name = "bench-proxy"
[proxy.listen]
host = "127.0.0.1"
port = 0
[performance]
coalesce_requests = {coalesce}
[[backends]]
name = "b"
transport = "http"
url = "{backend_url}"
{middleware}
"#,
coalesce = loaded,
);
let config = ProxyConfig::parse(&toml)?;
let proxy = Proxy::from_config(config).await?;
let (router, _session_handle) = proxy.into_router();
serve(router).await
}
async fn run_cell(
url: &str,
tool: &str,
concurrency: usize,
) -> anyhow::Result<(Duration, Vec<Duration>, usize)> {
let per_worker = REQUESTS_PER_CELL / concurrency;
let barrier = std::sync::Arc::new(tokio::sync::Barrier::new(concurrency + 1));
let mut handles = Vec::new();
for worker in 0..concurrency {
let url = url.to_string();
let tool = tool.to_string();
let barrier = barrier.clone();
handles.push(tokio::spawn(async move {
let mut reconnects = 0usize;
let client = 'connect: loop {
let client = McpClient::connect(HttpClientTransport::new(&url))
.await
.expect("client connects");
client
.initialize("bench-client", "1.0.0")
.await
.expect("initialize");
for _ in 0..20 {
if client.list_tools().await.is_ok() {
break 'connect client;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
reconnects += 1;
assert!(
reconnects <= 5,
"worker {worker}: session never settled after 5 connects"
);
};
barrier.wait().await;
let mut latencies = Vec::with_capacity(per_worker);
let mut handshake_retries = 0usize;
for i in 0..(WARMUP_PER_WORKER + per_worker) {
let arg = (worker * 1_000_000 + i) as u64;
let t = Instant::now();
let mut attempts = 0;
let result = loop {
attempts += 1;
match client
.call_tool(&tool, serde_json::json!({ "v": arg }))
.await
{
Ok(r) => break r,
Err(e)
if attempts <= 10
&& e.to_string().contains("notifications/initialized") =>
{
handshake_retries += 1;
tokio::time::sleep(Duration::from_millis(10)).await;
}
Err(e) => panic!("call failed: {e}"),
}
};
let elapsed = t.elapsed();
assert!(!result.is_error, "echo must not fail");
if i >= WARMUP_PER_WORKER {
latencies.push(elapsed);
}
}
(latencies, handshake_retries + reconnects)
}));
}
barrier.wait().await;
let started = Instant::now();
let mut all = Vec::with_capacity(REQUESTS_PER_CELL);
let mut retries = 0usize;
for h in handles {
let (latencies, worker_retries) = h.await?;
all.extend(latencies);
retries += worker_retries;
}
let elapsed = started.elapsed();
all.sort();
Ok((elapsed, all, retries))
}
fn pct(sorted: &[Duration], p: f64) -> Duration {
let idx = ((sorted.len() as f64 - 1.0) * p).round() as usize;
sorted[idx]
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let backend_url = start_backend().await?;
let minimal_url = start_proxy(&backend_url, false).await?;
let loaded_url = start_proxy(&backend_url, true).await?;
tokio::time::sleep(Duration::from_millis(100)).await;
let scenarios: &[(&str, &str, &str)] = &[
("direct", backend_url.as_str(), "echo"),
("proxied-minimal", minimal_url.as_str(), "b/echo"),
("proxied-loaded", loaded_url.as_str(), "b/echo"),
];
println!(
"{:<16} {:>4} {:>10} {:>10} {:>10} {:>10} {:>8}",
"scenario", "conc", "req/s", "p50", "p95", "p99", "resets"
);
for (name, url, tool) in scenarios {
for &concurrency in CONCURRENCY_LEVELS {
let (elapsed, latencies, retries) = run_cell(url, tool, concurrency).await?;
let throughput = latencies.len() as f64 / elapsed.as_secs_f64();
println!(
"{:<16} {:>4} {:>10.0} {:>10.2?} {:>10.2?} {:>10.2?} {:>8}",
name,
concurrency,
throughput,
pct(&latencies, 0.50),
pct(&latencies, 0.95),
pct(&latencies, 0.99),
retries,
);
}
}
Ok(())
}