A minimal MCP 2026-07-28 server/client pair, implemented twice — once in Rust
with turul-mcp-framework,
once in Python with FastMCP — used to verify cross-language
interop and compare framework overhead on identical add/del tools.
MCP 2026-07-28 is stateless: no initialize handshake, no Mcp-Session-Id. Every
request carries MCP-Protocol-Version / Mcp-Method headers and a params._meta
block; named calls also carry Mcp-Name.
hello-mcp/
Cargo.toml # Rust workspace root
server/ # turul-mcp-framework server (add/del tools)
client/ # turul-mcp-client functional test client
client-perf/ # turul-mcp-client latency/throughput probe
python/
server/ # FastMCP server (same add/del tools)
client/ # FastMCP functional test client
client-perf/ # FastMCP latency/throughput probe
scripts/
build.sh # cargo build --release + uv sync x3
test-functional.sh # full 2x2 client x server correctness matrix
test-perf.sh # full 2x2 client x server perf matrix
test-all.sh # build + functional + perf, in order
- Rust (stable, edition 2024 toolchain)
uv— manages Python 3.14 automatically, no separate install neededjq/curlare only used for manual wire-level probing, not by the scripts
All scripts set UV_MANAGED_PYTHON=1, forcing uv's own downloaded Python
3.14 build rather than a distro-packaged one that happens to satisfy the
version constraint (e.g. apt's python3.14 on newer Ubuntu). We hit a real
case of this: Ubuntu's system Python 3.14.0 segfaults inside CPython's own
_asyncio C extension during asyncio.run() shutdown, under this
project's client code specifically — uv's python-build-standalone 3.14.0
does not. If you run uv/Python commands manually outside the scripts, set
UV_MANAGED_PYTHON=1 yourself (or rm -rf .venv && UV_MANAGED_PYTHON=1 uv sync
to fix an existing venv built against the wrong interpreter).
git clone https://github.com/aussierobots/hello-mcp.git
cd hello-mcp
./scripts/build.sh # cargo build --release; uv sync x3 (Python 3.14 pinned)
./scripts/test-functional.sh # correctness: both clients x both servers
./scripts/test-perf.sh # perf: both clients x both servers, 1000 calls x 3 runsOr all three in one go:
./scripts/test-all.sh [ITERATIONS] [RUNS] # defaults: 1000 3Servers bind to 127.0.0.1:8000 (Rust) and 127.0.0.1:8100 (Python); the
scripts start and stop both automatically and clean up on exit.
Binaries land in ./target/release/ unless CARGO_TARGET_DIR (or a
.cargo/config.toml build.target-dir) points elsewhere — scripts/*.sh
resolve the real path via cargo metadata automatically, so they work
either way; the manual commands below assume the default location.
# Rust
./target/release/hello-mcp # http://127.0.0.1:8000/mcp
# Python (uvloop + httptools + JIT enabled)
cd python/server && PYTHON_JIT=1 uv run python server.py # http://127.0.0.1:8100/mcp./target/release/hello-mcp-client <url> # Rust
(cd python/client && uv run python client.py <url>) # Python
# perf probes: <url> [iterations=1000] [runs=3]
./target/release/hello-mcp-client-perf <url> 1000 3
(cd python/client-perf && uv run python perf.py <url> 1000 3)Rust binaries are built with LTO + codegen-units=1 + strip (see
[profile.release] in the workspace Cargo.toml). The Python server enables
uvloop + httptools (in place of the default asyncio loop / h11 parser),
disables uvicorn's per-request access log, and runs with Python 3.14's
experimental JIT (PYTHON_JIT=1).
Latest benchmark, ./scripts/test-perf.sh 1000 3 — 24,000 total calls, 0
errors, localhost, single persistent connection per pairing:
| Client → Server | Mean latency | p95 | Throughput |
|---|---|---|---|
| Rust → Rust (turul) | 0.032ms | 0.04ms | ~31,570 calls/sec |
| Rust → Python (FastMCP) | 0.64ms | 0.67ms | ~1,560 calls/sec |
| Python → Rust (turul) | 0.97ms | 0.99ms | ~1,030 calls/sec |
| Python → Python (FastMCP) | 1.20ms | 1.41ms | ~833 calls/sec |
(p99 over only 3×1000-sample runs is noisy — single-request outliers show
up in some runs — so p95 is the more representative tail figure here; see
the raw test-perf.sh output for full percentiles per run.)
Framework overhead, isolated: holding the client fixed (Rust, release build) and varying only the server cancels out client-language cost — turul is ~20x faster than FastMCP server-side (0.032ms vs 0.64ms mean). With a Python client instead, the end-to-end gap shrinks to ~1.2x, since asyncio/Starlette client-side overhead dominates and masks most of the server-side difference. The 20x number is the real framework delta; the ~1.2x number is what you'd feel from a Python-based agent talking to either server.
Un-optimized (debug Rust, no uvloop/httptools/JIT) the gap was only ~3.3x — release build + LTO is what unlocks turul's full advantage over FastMCP; the uvloop/httptools/access-log/JIT changes on the Python side each closed a further ~10-15% of FastMCP's own latency, without changing the underlying ratio much (the gap is dominated by Starlette routing + Pydantic validation
- JSON-RPC dispatch layers, not the event loop or HTTP parser).
turul-mcp-server(Rust):McpServerBuilderdidn't expose.description()/.website_url()until0.4.2, even though the underlyingImplementationprotocol type always supported both — fixed upstream,Cargo.tomlhere just pins"0.4".fastmcp(Python,4.0.0b3):FastMCP.__init__acceptswebsite_urlandinstructionsbut has nodescriptionparameter at all, despite the wire-levelImplementationtype (and the underlyingmcpSDK's low-levelServer) both supporting it — a one-line gap in FastMCP's own constructor forwarding, unresolved as of this beta.