Where Does Rust Land in an MCP Benchmark? I Tried to Find Out.
Let me start with an honest confession: I don’t know much Rust. I can read Rust code without completly panicking, but writing it from scratch is a different story.
Here is my story: I spent three days struggling with the borrow checker on something a competent Rust developer could write in 20 minutes. So, when I stumbled upon this MCP server benchmark by tmdevlab, comparing Java, Go, Node.js, and Python, and wondering to myself, “Where does Rust actually land?” so I prompted what I need to Claude, and let Claude write the server.
And, well, one day after work, I had something running. I ran the benchmark. And, well… everything broke. And then… two days of figuring out what was wrong.
Why I Even Cared
The tmdevlab benchmark is well done. Same container constraints for all servers, same k6 harness for tests, full MCP protocol sessions per request. Not only are the endpoints called, but the entire handshake is executed with every call. Their results: Java and Go are tied at 1,624 RPS with 10ms p95. Node.js and Python are in a completely different league.
I work with MCP servers. A discussion on what language to write one in is inevitable. I wanted some real results on Rust, but nobody had run it with the same tests. So I went ahead and made my own fork of the repo with an extra server. :)
What the Benchmark Tests
Each server implements four identical tools:
calculate_fibonacci(n) // for recursive CPU computation
fetch_external_data(endpoint) // for an HTTP GET to a mock API
process_json_data(data) // that uppercases all string values in a JSON object
simulate_database_query(query, delay_ms) // which is an async sleep to simulate I/O waitEvery k6 iteration runs a full MCP session over Streamable HTTP transport: POST initialize then POST notifications/initialized then POST tools/call then DELETE for session cleanup. The Mcp-Session-Id header carries through all requests after the handshake. It is the actual protocol, not a simplified version.
Each container gets 1 CPU and 1GB RAM.
Building the Rust Server with Claude
For building the Rust server, I used the official rmcp SDK, version 0.15.0, and Axum as the web layer. Claude helped me set up these tools, which I would have found difficult to do on my own. For instance, using the #[tool_router]macro, how Parameters wraps input types, and using Axum’s nest_service feature.
The tool definitions look like this:
#[tool_router]
impl BenchmarkServer {
#[tool(description = "Calculate a Fibonacci number recursively (CPU-bound)")]
fn calculate_fibonacci(
&self,
Parameters(params): Parameters<FibonacciParams>,
) -> Result<CallToolResult, McpError> {
let n = params.n as u32;
let result = fibonacci(n);
let response = serde_json::json!({
"input": params.n,
"result": result,
"server_type": "rust"
});
Ok(CallToolResult::success(vec![Content::text(response.to_string())]))
}
}The #[tool_router] macro generates the dispatch routing automatically. The Parameters wrapper handles JSON deserialization. The FibonacciParams struct derives JsonSchema via schemars, which means the schema exposed to the LLM is always consistent with what the code actually accepts. That part I found genuinely interesting.
The server startup connects the StreamableHttpService with a LocalSessionManager and mounts it in Axum:
let service = StreamableHttpService::new(
move || Ok(BenchmarkServer::new(client.clone())),
LocalSessionManager::default().into(),
Default::default(),
);
let app = Router::new()
.route("/health", get(health))
.nest_service("/mcp", service);The Dockerfile uses a two-stage build: a Rust builder image that compiles a release binary, then a debian:bookworm-slim runtime image. The comment in the Dockerfile explains the choice:
# Runtime image (glibc - faster allocator + SIMD string ops vs musl)
FROM debian:bookworm-slimAnd the Cargo.toml release profile includes real optimization settings:
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
strip = true
panic = "abort"So this was not a naive default build. Claude made deliberate choices based on my prompting.
First Run: Everything Broke
I ran the benchmark with all five servers. And the results came back like this:
Go: 11 RPS | p95=2,657ms | error_rate=25%
Java: 11 RPS | p95=15,000ms | error_rate=26%
Node.js: 9 RPS | p95=15,001ms | error_rate=25%
Rust: 13 RPS | p95=797ms | error_rate=25%
Python: 18 RPS | p95=10,009ms | error_rate=0% (this was lying)My immediate thought was, “I broke Rust somehow and it’s affecting all the others.”
My second thought, after about 30 seconds, was, “Wait a minute, 25% error rate on all five servers simultaneously? That’s not a problem with Rust. That’s an infrastructure problem.”
What happened was that the MockServer container, which handles fetch_external_data, had no config file mounted. So every time one of those tools called it, it waited 15 seconds and then failed. One out of every four attempts failed, so that’s 25%. And it was doing it to all of them, so it looked like every single one was failing, when really it was just one out of four, every time it was called.
I also noticed that the Python one was reporting 0% error rate, which was great, until I saw it was reporting a p95 of 10 seconds. That’s not great. That’s just not counting timeouts differently, is all.
While investigating this, I found two other bugs. First, the health check was calling /api to see if MockServer was running yet, which returns a 404. It should be PUT /mockserver/status. So MockServer was running, but it was completely unconfigured the entire time.
Also, the banner was reporting “50 VUs” when I was only running 10. So the config file was stale and the consolidation script was reporting wrong information. When the information on the screen is wrong, it makes it hard to trust anything.
Three bugs. None of which were subtle. All of which were mine.
First Clean Run: Rust was slower than expectation
After fixing the infrastructure:
Python: 269 RPS | p95=79ms
Node.js: 439 RPS | p95=54ms
Rust: 1,048 RPS | p95=40ms
Java: 1,174 RPS | p95=11ms
Go: 1,323 RPS | p95=11msRust at 1,048 RPS with a 40ms p95. That is closer to Node.js than Go, with a p95 nearly 4x higher than the compiled languages. All of the information I had read led me to believe that Rust would be competitive with Go here. Something is wrong, but I do not know what.
Finding the Bug Claude Introduced
I looked at the per-operation breakdowns. The request to initialize Rust, just the MCP handshake and nothing more, took an average of 0.82ms. This was faster than Go’s 1.13ms.
However, all tool requests took significantly longer: approximately 42ms at p95. All of them. All of them took the same number of milliseconds. Calculating Fibonacci, uppercasing strings, sleeping, making an HTTP request: all the same.
That consistency is probably a clue. It is probably not the tool logic. It is probably something to do with tool dispatch.
And there it is, in the code:
// What Claude originally wrote:
let result = tokio::task::spawn_blocking(move || fibonacci(n)).await.unwrap();spawn_blocking is for offloading genuinely heavy CPU work so it does not block the async executor. Claude's reasoning was probably: fibonacci is CPU work, so use spawn_blocking, which is good practice. That reasoning is not wrong in general.
The problem is that fibonacci(10) takes microseconds. It is not blocking anything meaningful. But on a 1-CPU container, sending it to a separate thread pool means the pool gets backed up under concurrent load. Everything queues behind it. The 40ms was not computation time. It was waiting time.
The fix was removing one keyword:
// After fix:
let result = fibonacci(n);The p95 went from 40ms to 12.8ms. RPS increased from 1,048 to 1,199. Just that one change alone…
Claude wrote code that looked good, looked reasonable, but was simply wrong for the situation. I did not catch it on my initial review because it looked good. I only caught it because I read the latency profile and thought, “Why did all four tools spike at the same number?”
I think this is the part of the Vibe coding experience that is not talked about enough…
I also saw that the HTTP client did not have a timeout set:
// Original:
let client = reqwest::Client::new();
// Fixed:
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.unwrap();A small thing. But silent hangs are not small things in practice.
Better Numbers, But Something Still Seemed Off
After the fixes, at 10 VUs:
Server | RPS | p95 | CPU | RAM
Python | 255 | 96ms | 98% | 261MB
Node.js | 458 | 57ms | 98% | 57MB
Rust | 1,199 | 12.8ms | 16% | 2.7MB
Java | 1,391 | 10.7ms | 31% | 180MB
Go | 1,391 | 10.7ms | 32% | 8.6MBRust’s CPU is running at 16% while Go and Java are running at 30%. It looks like the server has spare capacity that spare capacity that it is not using , here is the per-tool breakdown here :
Go Java Rust
_initialize 2.46ms 3.30ms 1.57ms (Rust fastest here)
calculate_fibonacci 2.70ms 3.33ms 41.12ms
fetch_external_data 20.45ms 6.68ms 41.65ms
process_json_data 2.36ms 2.89ms 42.39ms
simulate_database_query 13.04ms 13.26ms 41.78msRust’s initialization is the fastest of all three. However, every call to the tool takes approximately 42ms regardless of the operation it is performing. Based on my best guess, it seems that there is a bottleneck in the dispatch mechanism of the rmcp SDK’s tool interface, possibly related to the LocalSessionManagersession state management, possibly related to the encoding of SSE responses for tool results vs. the initialize result. Honestly, I have no idea. The rmcp SDK is at version 0.15.0 and is still quite young. I suspect this is something that is not fixable by me.
Then I Found the Real Problem: Rosetta
Well, that’s where things get slightly embarrassing.
I had made my Docker images with the platform set to linux/amd64. I run on an Apple Silicon platform. This meant every single one of my containers had been running under the Rosetta x86 emulator. That adds around 2x latency overhead on every single system call. I had been running the benchmark with all five servers going through an emulator layer.
The figures are real figures. They are simply not representative of what these servers actually do.
I rebuilt my server images with the platform set to native ARM64 and with the original tmdevlab configuration: 50 VUs instead of 10.
The Numbers After Fixing Everything
Server | RPS | p95 | CPU | RAM
Java | 5,579 | 12.3ms | 97% | 207MB
Go | 5,407 | 13.4ms | 99% | 12MB
Rust | 4,806 | 41.8ms | 46% | 10MBJava and Go increased from about 1,390 to roughly 5,500 RPS. That’s a fourfold improvement from removing Rosetta and effectively using the CPU. They surpassed tmdevlab’s 1,624 RPS because native ARM64 on Apple Silicon is truly faster than x86 emulation. Rust rose from 1,199 to 4,806 RPS, which is also a significant increase. However, the 42ms p95 tool ceiling remains, and CPU usage is at 46%, while the others are at 97 to 99%. Here’s the per-tool story at 50 VUs:
Go Java Rust
_initialize 9.10ms 7.56ms 2.87ms (Rust still fastest)
calculate_fibonacci 6.45ms 6.12ms 41.97ms
process_json_data 7.65ms 6.59ms 43.64ms
simulate_database_query 32.60ms 22.95ms 43.59msRust’s initialize handshake at 2.87ms is almost three times faster than Go’s 9.10ms. However, once you call a tool, the times stabilize at around 42 to 44ms, no matter what the tool does.
The Memory Numbers
At 50 VUs, handling thousands of requests per second:
Rust and Go are nearly equal at this scale. Java has the JVM overhead, but you do get a lot for those 207MB: a well-established ecosystem, JIT compilation, and decades of use in production. Spring Boot with Spring AI handles a lot, and that reflects in the throughput numbers as well.
However, 10MB at 4,806 RPS with zero errors is impressive, especially if you run many instances or work in an environment where memory costs matter.
Does Any of This Actually Matter?
I would guess not for the typical use case for an AI agent. I don’t think the language is a concern for this particular use case, as the real delay is with the LLM, not the MCP server. For servers that simply make API calls or handle I/O, it’s not as big a deal, as you’re mostly waiting on the network. It gets more pertinent if there’s actual computation happening on the server or with memory costs for a large number of servers.
I’m not saying this is a language for which any of this is a concern. Go is great for this sort of task. It’s clean. Python has the tools for a data-intensive task. Java is a safe choice for an enterprise environment, where Spring is a consideration. Node.js is fast to implement with and versatile.
But, looking at other benchmarks I’ve seen, a REST with database latency comparison for the same languages, Rust seems to be a lean and fast language. This fits with that, even with the SDK issues impacting performance. I suspect the rmcp SDK will be better. Memory usage, initialization latency, those are already impressive.
What I Actually Learned
Vibe coding got me a working Rust server in one afternoon. That is real. The #[tool_router]macro setup, the rmcp SDK integration, the Axum routing, none of that was in my head, and I would not have figured it out in any reasonable time on my own.
There were, however, three infrastructure bugs that caused my results to be silently wrong, one Rust-specific bug that caused my performance to be half what it should be, and one platform assumption about Rosetta that caused all my numbers to be wrong. None of these was apparent until I looked hard enough to see that something was wrong.
“It works” is not the same as “it is correct.” The AI did write the code. My debugging work was still my own. And the infrastructure had silent assumptions that no one warned me about.
I got 80% of the work done in one afternoon thanks to the AI. The last 20% was my problem. And I think that is probably the honest truth about what “vibe coding” is.
This whole experiment would not have existed without the benchmark that tmdevlab put together. Their methodology is solid, the setup is reproducible, and the writeup is honest about what the numbers mean. I just had a question they did not answer yet and a free afternoon.
If you have not read their original article, I think it is worth your time before reading mine. They did the real work of building the four-language comparison. I just added one more server and made a lot of mistakes along the way.
My fork: https://github.com/armantik/benchmark-mcp-servers
Original benchmark by tmdevlab:
https://www.tmdevlab.com/mcp-server-performance-benchmark.html
