通过16个MCP实例的对比数据,说明Rust在内存占用(192MB vs 1.33GB)和启动时间(40ms vs 7.4s)上的实际优势,附完整rmcp实现教程。
本教程通过 rmcp(官方 Model Context Protocol Rust SDK)来构建一个 MCP server。
示例是一个真实的 devops agent:管理 AWS EC2 G5g 实例——配备 NVIDIA T4G GPU 的 Graviton2 机器——在 vLLM 上运行 Gemma 4。它负责启动实例、通过 SSM 控制实例,以及对模型做健康检查。已有 Python 版本可供对照,结尾会将两者并列比较。
跟着做下来,你会得到一个可注册的 MCP server。🦀
这个问题值得认真回答,因为那些站不住脚的理由很容易提出、也很容易驳倒——而真正站得住脚的理由其实更好。
先说它不是什么:这些工具都是 I/O 绑定的。每个调用都是一次 AWS API——describe_instances、send_command、对 SSM 轮询——每次网络延迟 100–500 ms。调用方使用什么语言对此毫无影响。任何向你吹嘘 Rust 重写能带来原始速度提升的人,在这种工作负载下卖的是别的东西。
三个不成立的论点,这样就不必在评论区再重复了:
对于这个代码库,真正站得住脚的理由是:
每个 Python 进程占用 1 GB resident 内存来暴露 16 个工具列表,这是一笔真实的成本。
没有共享解释器。这些 rig 是系统级安装的——按策略不走 virtualenv——所以 16 台共享同一个 Python。16 个 server 在同一个解释器里各自的 boto3 和 mcp pin 版本漂移,是一个持续的冲突风险。静态二进制没有这种耦合;每台 rig 在自己的 Cargo.lock 里 pin 任何它需要的版本。
Schema 不会和代码脱节。这在第 3 步会详细展开,但这是最能经受时间考验的一点:schemars 从 handler 析构的同一个 struct 生成工具 schema。
所以:理由是分发和正确性,不是速度。✅ 如果你只有一个 MCP server 而且跑得好好的,这不是重写的理由。
两部分。agent 和 MCP server 运行在你的机器上;GPU 机器是远程的,且没有入站 SSH——一切均通过 AWS API 进出。
YOUR MACHINE AWS us-east-1
┌──────────────────────────────┐ ┌───────────────────────────────────────┐
│ │ │ │
│ Claude Code / IDE │ │ ┌─ EC2 g5g.4xlarge ───────────────┐ │
│ | │ │ │ Graviton2 (aarch64) │ │
│ | MCP · JSON-RPC 2.0 │ │ │ + NVIDIA T4G (SM 7.5) │ │
│ | over stdio │ │ │ │ │
│ v │ EC2 │ │ [PY] vLLM + [RUST] vllm-rs │ │
│ ┌────────────────────────┐ │ API │ │ listening on :8000 │ │
│ │ [RUST] │──┼──────>│ │ │ │
│ │ gpu-vllm-g5g-2b │ │ │ │ Gemma 4 E2B │ │
│ │ │ │ SSM │ └─────────────────────────────────┘ │
│ │ rmcp 3.1.2 │──┼──────>│ ^ │
│ │ tokio · schemars │ │ Run │ | no inbound SSH, │
│ │ aws-sdk-ec2 / -ssm │ │ Cmd │ | no key pair, │
│ │ 1 binary · 2.5 ms │ │ │ | no port 22 rule │
│ └────────────────────────┘ │ │ │
│ 9 tools │ │ IAM instance profile carries │
│ list / start / stop / │ │ AmazonSSMManagedInstanceCore │
│ terminate / endpoint / │ │ │
│ run_remote / health ... │ │ │
└──────────────────────────────┘ └───────────────────────────────────────┘
agent 从不直接和 GPU 机器通信。它调用一个工具;该工具调用 EC2 管理实例生命周期,或通过 SSM Run Command 在实例上执行操作。这正是该机器可以完全不需要入站规则的原因——也是这件事值得做成 server 而不是一堆 shell 脚本的主要原因。
右边那个 [RUST] 是 vLLM 自己的 Rust 前端——本系列的另一篇文章。这一篇是左边的 [RUST]:驱动那台机器的 Rust。
Model Context Protocol 是 AI agent 发现和调用你工具的方式。你的 server 公告一个带有 JSON Schema 的工具列表;客户端(Claude Code、某个 IDE 等)通过 JSON-RPC 2.0 调用它们。传输层通常是 stdio——客户端启动你的二进制程序并通过 stdin/stdout 通信。
最后这个细节是 Rust 优势所在:如果客户端在每个 session 都启动你的进程,进程启动时间就是用户能感知到的成本。
cargo new --bin rust-mcp --name gpu-vllm-g5g-2b-mcp
cd rust-mcp
现在安装依赖。Feature flag 是这里最需要搞对的东西——单独 cargo add rmcp 编译没问题但几乎什么都得不到:
cargo add rmcp --features server,macros,transport-io
该 crate 还提供 client、auth、elicitation、transport-streamable-http-server 等,默认都不开启。需要时再加。
cargo add tokio --features rt-multi-thread,macros,process,time
cargo add serde serde_json anyhow schemars
cargo add aws-config aws-sdk-ec2 aws-sdk-ssm aws-sdk-secretsmanager
最终 Cargo.toml:
[package]
name = "gpu-vllm-g5g-2b-mcp"
version = "0.1.0"
edition = "2024"
[dependencies]
rmcp = { version = "3.1.2", features = ["server", "macros", "transport-io"] }
tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "process", "time"] }
aws-config = "1.10.1"
aws-sdk-ec2 = "1.246.0"
aws-sdk-ssm = "1.118.0"
serde = "1.0.229"
serde_json = "1.0.151"
schemars = "1.2.2"
anyhow = "1.0.104"
rmcp 迭代很快,文档总是滞后的。你磁盘上附带的测试用例是用你实际解析的精确版本编译的:
ls ~/.cargo/registry/src/*/rmcp-3.1.2/tests/
tests/test_tool_macros.rs 是一个约 60 行的完整可运行 server 示例。当 API 问题时,这个文件比任何其他资料回答得更快、更可靠。⚡
rmcp server 是一个拥有 ToolRouter 的 struct:
use rmcp::{
ErrorData, ServerHandler, ServiceExt,
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
model::{CallToolResult, ContentBlock, Implementation, ServerCapabilities, ServerInfo},
tool, tool_handler, tool_router,
transport::stdio,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Clone)]
struct G5gServer {
tool_router: ToolRouter<Self>,
}
这是让我觉得整个方案值得一试的部分。你的工具输入是一个普通 struct,schemars 将其转换为 agent 看到的 JSON Schema——包括文档注释:
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct InstanceId {
/// EC2 instance id, e.g. `i-0123456789abcdef0`.
instance_id: String,
}
该文档注释会成为工具 schema 中该字段的描述。字段改名,schema 跟着变。编译器检查 handler 析构的类型。没有第二个需要保持同步的产物。✅
在 impl 块上加 #[tool_router],每个方法上加 #[tool]:
#[tool_router(router = tool_router)]
impl G5gServer {
fn new() -> Self {
Self { tool_router: Self::tool_router() }
}
#[tool(description = "List EC2 instances tagged ManagedBy=gpu-vllm-g5g-2b.")]
async fn list_g5g_instances(&self) -> Result<CallToolResult, ErrorData> {
let conf = aws_config::defaults(aws_config::BehaviorVersion::latest())
.region(aws_config::Region::new("us-east-1"))
.load()
.await;
let ec2 = aws_sdk_ec2::Client::new(&conf);
let resp = match ec2.describe_instances()
.filters(Filter::builder()
.name("tag:ManagedBy").values("gpu-vllm-g5g-2b").build())
.send().await
{
Ok(r) => r,
Err(e) => return ok(format!("❌ describe_instances failed: {e}")),
};
let mut rows = Vec::new();
for res in resp.reservations() {
for inst in res.instances() {
rows.push(format!("| `{}` | {} | {} |",
inst.instance_id().unwrap_or("?"),
inst.instance_type().map(|t| t.as_str()).unwrap_or("?"),
inst.state().and_then(|s| s.name())
.map(|n| n.as_str()).unwrap_or("unknown"),
));
}
}
ok(format!("📡 Instances\n\n| id | type | state |\n|---|---|---|\n{}",
rows.join("\n")))
}
}
带参数的工具将参数包装在 Parameters<T> 中:
#[tool(description = "Terminate a G5g instance. Permanent — destroys the root volume.")]
async fn terminate_g5g_instance(
&self,
Parameters(args): Parameters<InstanceId>,
) -> Result<CallToolResult, ErrorData> {
// …
}
还有一个小辅助函数,因为每个工具返回的形状都相同:
fn ok(text: String) -> Result<CallToolResult, ErrorData> {
Ok(CallToolResult::success(vec![ContentBlock::text(text)]))
}
#[tool_handler] 将 router 接入,所以你不必写 dispatch match:
#[tool_handler(router = self.tool_router)]
impl ServerHandler for G5gServer {
fn get_info(&self) -> ServerInfo {
let mut info = ServerInfo::new(
ServerCapabilities::builder().enable_tools().build()
);
info.server_info = Implementation::new(
"gpu-vllm-g5g-2b", env!("CARGO_PKG_VERSION")
);
info.instructions = Some(
"Devops agent for AWS EC2 G5g (Graviton2 + NVIDIA T4G) serving Gemma 4 \
under vLLM. Remote administration goes through SSM; there is no inbound SSH."
.to_string(),
);
info
}
}
💡 这些 model struct 都是 #[non_exhaustive],所以用构造函数(ServerInfo::new、Implementation::new)然后赋值字段——struct 字面量不会编译通过,即使加了 ..Default::default() 也不行。
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let service = G5gServer::new().serve(stdio()).await?;
service.waiting().await?;
Ok(())
}
cargo build --release
MCP server 是协议实现,所以用协议 transcript 测试。三行 JSON-RPC 到 stdin——不需要客户端:
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-07-28","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}}' \
'{"jsonrpc":"2.0","method":"notifications/initialized"}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \
'{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_help","arguments":{}}}' \
| ./target/release/gpu-vllm-g5g-2b-mcp
initialize OK: gpu-vllm-g5g-2b 0.1.0 proto 2026-07-28
tools/list OK: 9 tools -> get_deployment_config, get_endpoint, get_help,
list_g5g_instances, run_remote, start_g5g_instance, stop_g5g_instance,
terminate_g5g_instance, verify_model_health
tools/call get_help OK
🟢 rmcp 3.1.2 默认协商 2026-07-28 spec 版本。
把这个片段存好。这是区分"我的 server 坏了"和"我的客户端配置坏了"最快的方式。
将你的 MCP 客户端指向该二进制文件。对于 Claude Code,.mcp.json:
{
"mcpServers": {
"gpu-vllm-g5g-2b": {
"command": "/abs/path/to/rust-mcp/target/release/gpu-vllm-g5g-2b-mcp",
"env": { "AWS_REGION": "us-east-1" }
}
}
}
server 名称是每个工具的前缀——mcp__gpu-vllm-g5g-2b__list_g5g_instances——所以用所管理的事物来命名,尤其当你运行多个 server 时。
凭证来自标准 AWS provider chain,所以 aws sts get-caller-identity 解析出什么,server 就得到什么。通过 AWS_PROFILE 来选择。
有意思的断言不在代码层面,而在机器层面。Turing 没有 bf16 数据通路也没有 fp8,所以 serving flags 必须和所有 L4 类机器不同——这正是那种当有人从邻居那里复制一套 flag 时会悄然回滚的事情:
#[test]
fn serve_flags_are_turing_shaped() {
let f = serve_flags("google/gemma-4-E2B-it", "g5g.2xlarge");
assert!(f.contains("--dtype float16"), "Turing has no bf16 datapath");
assert!(f.contains("--kv-cache-dtype auto"), "Turing has no fp8 datapath");
assert!(!f.contains("attention-backend")); // not a real vLLM v0.27 variable
}
#[test]
fn unknown_types_are_rejected_and_never_need_swap() {
assert!(validate_instance_type("t4g.2xlarge").is_err()); // burstable CPU box, no GPU
assert!(!needs_swap("t4g.2xlarge")); // 0 GiB must not read as "tiny"
}
第二个测试很值:host_memory_gb 对未知实例类型返回 0,而 naive 的 ram < 16 会判定一台不可识别的机器需要 swapfile。
running 5 tests
test result: ok. 5 passed; 0 failed; finished in 0.00s
冷启动按客户端体验的方式测量:启动进程,发送 initialize + initialized + tools/list,时钟截止到工具列表返回。七次运行,取中位数。
185 倍的冷启动优势——但如前文所述,别孤立地引用这个数字。一个 session 启动一次 server,460 ms,没人在意。只有乘以 16 台 rig 时它才变成一个有意义的数字,而且即使那样,也是 12 MB vs 83 MB 那行在做更重的功。
也诚实读剩下的部分。241 个已解析包对 34 个,意味着静态二进制不是更小的供应链,只是在 Cargo.lock 里审计了同样的供应链。port 覆盖了 9 个工具而 Python 是 15 个——供应路径(cloud-init 渲染、AMI 解析、spot 选项)是最麻烦的那一半,没有 port。干净构建耗时 5 分 28 秒,而解释器启动是即时的,这在迭代时是一笔真实成本。📊
对于一个已经跑起来的单一 MCP server:不要重写。
对于 16 个共享同一个系统 Python、分发到根本不需要 Python 环境的机器上的 server:是值得的——注意这句话的两半都不是关于速度的。这是一个打包方案。
明年仍然成立的部分是 schemars。agent 看到的工具 schema 是从 handler 析构的同一个 struct 生成的,由编译器检查,文档来自字段上的文档注释。Python 版本里 schema、运行时类型和文档是三个靠约定保持一致的产物——当它们不再一致时就静默失效了。
462 ms 是附赠的好处。schema 不可能对代码撒谎才是原因。✅
# scaffold
cargo new --bin my-mcp && cd my-mcp
cargo add rmcp --features server,macros,transport-io
cargo add tokio --features rt-multi-thread,macros
cargo add serde serde_json schemars anyhow
# canonical examples for YOUR resolved version
ls ~/.cargo/registry/src/*/rmcp-*/tests/test_tool_macros.rs
# build + smoke test
cargo build --release
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-07-28","capabilities":{},"clientInfo":{"name":"p","version":"0"}}}' \
'{"jsonrpc":"2.0","method":"notifications/initialized"}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \
| ./target/release/my-mcp
四个要记住的宏:impl 上加 #[tool_router],每个方法上加 #[tool],ServerHandler impl 上加 #[tool_handler],输入 struct 用 Parameters<T> 包装。
Rust 1.97.1,rmcp 3.1.2,aws-sdk-ec2 1.246.0,edition 2024。冷启动在开发主机上测量——这是两个 MCP server 的对比,不是硬件结果。单机,每侧七次运行,报告中位数。