The official Rust SDK for Model Context Protocol (MCP) just shipped, and it exposes something TypeScript implementations abstract away: the plumbing between type systems, JSON-RPC schema contracts, and stdio transport. The rmcp crate shows how Rust’s ownership model and compile-time guarantees change the shape of agent tool servers, especially for long-running processes or fleet deployments.
This is not about raw speed. MCP tool calls are I/O bound (AWS API calls, database queries, file operations). The language runtime contributes nothing measurable to a 200 ms network round-trip. The real difference is memory footprint, session startup cost, and dependency isolation when you run multiple MCP servers in the same environment.
Why Rust for MCP Servers
The weak argument is easy to dismiss: “Rust is faster.” For I/O-bound work, it is not meaningfully faster. A Python MCP server calling describe_instances waits the same 150 ms as a Rust one.
The strong argument is about operational shape:
Fleet deployment costs. Sixteen Python MCP servers in one environment consume roughly 1.33 GB resident memory (16 × 83 MB). Sixteen Rust binaries consume 192 MB (16 × 12 MB). Session startup drops from 7.4 seconds to 40 milliseconds.
Dependency isolation. System-wide Python installations share one interpreter. Sixteen MCP servers with different boto3 or mcp pins create standing conflict risk. Static binaries have no such coupling.
Long-running process safety. Rust’s ownership model prevents entire classes of memory leaks and data races that matter when an MCP server runs for days or weeks between restarts.
| Concern | Python MCP | Rust MCP |
|---|---|---|
| Per-server memory | 83 MB | 12 MB |
| Session startup | 462 ms | 2.5 ms |
| Dependency conflicts | Shared interpreter | None (static binary) |
| Memory safety | Runtime checks | Compile-time guarantees |
| Cold-start penalty | Interpreter + imports | Binary load only |
The rmcp SDK Architecture
The rmcp crate provides three layers:
- Protocol layer: JSON-RPC 2.0 message framing over stdio.
- Schema layer: Rust types that generate MCP tool definitions.
- Handler layer: Async functions that implement tool logic.
The key difference from TypeScript MCP SDKs is that schema validation happens at compile time. A TypeScript MCP server validates tool arguments at runtime when the agent calls the tool. A Rust MCP server validates the entire schema contract when you compile the binary.
Scaffolding a Server
The rmcp SDK uses a builder pattern to register tools:
use rmcp::{Server, Tool, ToolInput, ToolOutput};
use serde::{Deserialize, Serialize};
#[derive(Deserialize, Serialize)]
struct LaunchInstanceInput {
instance_type: String,
ami_id: String,
count: u32,
}
#[derive(Serialize)]
struct LaunchInstanceOutput {
instance_ids: Vec<String>,
}
async fn launch_instance(input: LaunchInstanceInput) -> Result<ToolOutput, String> {
// AWS SDK calls here
let instance_ids = vec!["i-1234567890abcdef0".to_string()];
Ok(ToolOutput::success(LaunchInstanceOutput { instance_ids }))
}
#[tokio::main]
async fn main() {
let server = Server::new()
.tool(Tool::new("launch_instance", launch_instance))
.build();
server.run_stdio().await.unwrap();
}
The Tool::new call generates a JSON schema from LaunchInstanceInput at compile time. If you add a field to the struct but forget to handle it in the function body, the compiler catches it. If you change the field type from String to u32, the schema updates automatically.
Stdio Transport and Message Framing
MCP servers communicate over stdio using JSON-RPC 2.0. The agent writes a request to stdin, the server writes a response to stdout. The rmcp SDK handles framing, buffering, and backpressure.
Framing: Each message is a single line of JSON terminated by \n. The rmcp SDK uses tokio::io::BufReader to read lines asynchronously without blocking.
Buffering: The SDK maintains a 64 KB read buffer by default. If the agent sends a tool call with a 1 MB argument (a large SQL query, a base64-encoded image), the buffer grows dynamically.
Backpressure: If the server cannot process requests fast enough, the stdio buffer fills. The agent blocks on write. This is correct behavior: the agent should not send more work than the server can handle.
The TypeScript MCP SDK uses Node.js streams, which abstract these details. The Rust SDK exposes them because you may need to tune buffer sizes or handle custom framing for non-standard transports.
Type-Safe Tool Registration
The rmcp SDK uses Rust’s type system to enforce schema contracts. Here is what happens when you register a tool:
- The
Tool::newfunction takes a handler with signatureasync fn(T) -> Result<ToolOutput, String>whereT: Deserialize + Serialize. - The SDK calls
schemars::schema_for::<T>()to generate a JSON schema from the input type. - The schema is stored in the server’s tool registry.
- When the agent requests the tool list, the server returns the schema.
- When the agent calls the tool, the SDK deserializes the JSON arguments into
T. If deserialization fails, the server returns an error before the handler runs.
This is different from TypeScript, where you define the schema separately (often by hand) and validate arguments at runtime. The Rust approach guarantees that the schema matches the handler signature.
Example: AWS EC2 Tool
The walkthrough builds an MCP server that manages AWS EC2 instances. The tools are:
launch_instance: Start a new EC2 instance.describe_instances: List running instances.send_ssm_command: Run a shell command via AWS Systems Manager.check_model_health: Poll an HTTP endpoint to verify a model server is responding.
Each tool is a Rust function with a typed input struct. The SDK generates the schema, handles deserialization, and routes requests.
#[derive(Deserialize, Serialize)]
struct DescribeInstancesInput {
filters: Option<Vec<String>>,
}
async fn describe_instances(input: DescribeInstancesInput) -> Result<ToolOutput, String> {
let config = aws_config::load_from_env().await;
let client = aws_sdk_ec2::Client::new(&config);
let mut req = client.describe_instances();
if let Some(filters) = input.filters {
// Apply filters
}
let resp = req.send().await.map_err(|e| e.to_string())?;
// Serialize response
Ok(ToolOutput::success(resp))
}
The AWS SDK calls are async. The rmcp SDK uses tokio for the async runtime, so all handlers run on the same executor. If a tool blocks (a synchronous database call, a CPU-bound computation), it blocks the entire server. You must use tokio::task::spawn_blocking to offload blocking work.
Testing the Protocol by Hand
Before wiring the server into Claude Code, you can test it manually. The MCP protocol is JSON-RPC over stdio, so you can send requests with echo and jq:
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | ./target/release/mcp-server | jq
Expected response:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"tools": [
{
"name": "launch_instance",
"description": "Launch a new EC2 instance",
"inputSchema": {
"type": "object",
"properties": {
"instance_type": { "type": "string" },
"ami_id": { "type": "string" },
"count": { "type": "integer" }
},
"required": ["instance_type", "ami_id", "count"]
}
}
]
}
}
To call a tool:
echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"launch_instance","arguments":{"instance_type":"g5g.xlarge","ami_id":"ami-12345","count":1}}}' | ./target/release/mcp-server | jq
This is faster than restarting Claude Code for every change. You can script test cases, check error handling, and validate schema generation before integration.
Wiring Into Claude Code
Claude Code (and other MCP clients) discover servers via a configuration file. For Claude Code on macOS, the file is ~/Library/Application Support/Claude/claude_desktop_config.json:
{
"mcpServers": {
"ec2-manager": {
"command": "/path/to/mcp-server",
"args": [],
"env": {
"AWS_REGION": "us-west-2"
}
}
}
}
Restart Claude Code. The server spawns when you start a conversation. Claude Code sends initialize, tools/list, and tools/call requests over stdio.
If the server crashes, Claude Code logs the stderr output. You can add tracing to the server with the tracing crate:
use tracing::{info, error};
use tracing_subscriber;
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
info!("Starting MCP server");
// ...
}
Logs appear in Claude Code’s developer console.
Failure Modes and Observability
MCP servers fail in predictable ways:
Schema mismatch. The agent sends arguments that do not match the tool schema. The rmcp SDK returns a JSON-RPC error before the handler runs. This is a client bug (the agent should respect the schema) or a schema bug (the schema does not match the handler).
Handler panic. The Rust handler panics (array out of bounds, unwrap on None). The rmcp SDK catches the panic and returns a JSON-RPC error. The server does not crash, but the tool call fails.
Async deadlock. Two tools wait on each other (one holds a lock, the other needs it). The server hangs. The agent times out. This is a handler bug. Use tokio::time::timeout to enforce deadlines.
Stdio buffer overflow. The agent sends a tool call with a 10 MB argument. The stdio buffer grows to 10 MB. If the server runs in a memory-constrained environment (a Lambda function, a container with a 128 MB limit), it may OOM. Solution: reject large arguments at the schema level or stream them.
For observability, add structured logging with tracing. Export spans to OpenTelemetry if you run multiple servers. Track:
- Tool call latency (time from request to response).
- Error rate (failed tool calls per minute).
- Memory usage (resident set size over time).
- Stdio buffer size (current and peak).
Technical Verdict
Use Rust for MCP servers when:
- You run multiple servers in the same environment and memory footprint matters.
- You deploy to resource-constrained environments (Lambda, edge functions, embedded systems).
- You need compile-time guarantees that the schema matches the handler signature.
- You have long-running processes where memory leaks or data races are risks.
Avoid Rust for MCP servers when:
- You have a single server with no memory constraints.
- You need rapid prototyping and the Python or TypeScript ecosystem is more familiar.
- Your tools are simple wrappers around HTTP APIs or database queries with no complex state.
- You do not have Rust expertise on the team and the operational benefits do not justify the learning curve.
The rmcp SDK is production-ready. The type-safe schema generation, async runtime integration, and stdio transport handling are solid. The main trade-off is development velocity: Rust is slower to write than Python, but the result is a smaller, safer, more predictable binary.