本教程带你用 Rust 官方 MCP SDK rmcp 从零构建一个可被 Claude Code 直接调用的工具服务器。你将掌握 #[tool] 宏定义工具、JsonSchema 自动生成参数契约、ServerHandler 身份声明、stdio 传输层接入,以及单元测试与协议级联调。最终产物是一个单文件静态二进制——没有 node_modules,没有虚拟环境,丢到任意机器即可运行。适合已有任意一门语言基础、想用 Rust 给 AI 助手"装手脚"的开发者。
rustup 安装 stable 通道,cargo --version 可用(建议 1.75+)claude 命令jq,便于手工解析 JSON-RPC 响应当你在 Python/TypeScript 里写 MCP 服务器时,分发它意味着附带一份运行时:Python 要带 venv 和依赖锁,Node 要带 node_modules(动辄数百 MB)。而 MCP 服务器本质是长期常驻的子进程,它夹在 AI 助手和你的系统之间,频繁做文件 I/O、网络调用甚至敏感操作。
Rust 在这里有三重优势:
cargo build --release 产出一个几 MB 的可执行文件,零运行时依赖,跨机部署只需 scp。rmcp 是 MCP 官方维护的 Rust SDK,截至 2026 年初在 crates.io 累计下载超 470 万次,已发布到 1.x 稳定版(docs.rs 当前为 1.7.0),宏驱动 API 让定义工具像写普通方法一样自然。
MCP(Model Context Protocol)是 Anthropic 提出的开放标准,基于 JSON-RPC 2.0,规定 AI 应用(客户端,如 Claude Code)如何与外部能力提供方(服务器)通信。架构借鉴了编辑器里的语言服务器协议(LSP)。
一个 MCP 服务器可暴露三类能力:
| 原语 | 作用 | 典型场景 |
|---|---|---|
| Tools | AI 可调用的函数 | "统计这个目录的代码行数" |
| Resources | AI 可读取的数据 | 读取配置文件、数据库记录 |
| Prompts | 可复用的提示词模板 | 标准化的代码审查模板 |
本教程聚焦 Tools——它最直接有用。通信发生在传输层之上,rmcp 内置两套:
/mcp)双向通信,适合远程、多客户端、容器化部署,支持 SSE 流式。致命细节:stdio 传输下,永远不要在服务器里用
println!()。stdout 是协议通道,任何一行调试输出都会污染 JSON-RPC 流,导致客户端静默断开。所有日志必须走 stderr(eprintln!或tracing框架)。
cargo new dupfind
cd dupfind
编辑 Cargo.toml:
[package]
name = "dupfind"
version = "0.1.0"
edition = "2021"
[dependencies]
rmcp = { version = "0.16", features = ["server", "transport-io", "macros"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
schemars = "0.8"
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
各依赖的作用:
rmcp 的三个 feature:server(服务端功能)、transport-io(stdio 传输)、macros(#[tool] 等派生宏)tokio:rmcp 是全异步的,需要 async 运行时schemars:从 Rust 类型生成 JSON Schema——MCP 客户端正是靠它发现你的工具接受哪些参数tracing + tracing-subscriber:结构化日志,输出到 stderr,保持 stdout 干净cargo build
# Compiling dupfind v0.1.0
# Compiling rmcp v0.16.x ...
# Finished dev [unoptimized + debuginfo] target(s)
error: failed to select a version for rmcp:Cargo 源连不上,配置国内镜像(如 ~/.cargo/config.toml 指向字节/清华源)或检查代理。schemars/tokio:首次拉取依赖较慢,属正常;可加 --offline 复用缓存。version = "0.16" 改成 version = "1",API 完全兼容;若遇协议协商错误(rmcp 与 Claude Code 协议版本不匹配),cargo update -p rmcp 升级即可,Claude Code 当前协商的协议版本是 2025-06-18。我们的实战项目是 dupfind——重复文件检测器,给 Claude Code 两个工具:按内容找出重复文件、按扩展名统计磁盘占用。这类"扫目录+比内容"的任务正是 Rust 的强项。
编辑 src/main.rs,先放导入和服务器结构体:
use anyhow::Result;
use rmcp::handler::server::wrapper::Json;
use rmcp::model::ServerInfo;
use rmcp::{tool, ServerHandler};
use schemars::JsonSchema;
use serde::Deserialize;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use tracing::info;
#[derive(Debug, Clone)]
pub struct DupFindServer;
结构体刻意保持无状态——每次工具调用自包含。若你的服务器需要维护连接或缓存,就在这里加字段。
接着定义第一个工具的输入类型。给字段加 JsonSchema 派生,客户端就能看到参数描述:
#[derive(Debug, Deserialize, JsonSchema)]
pub struct FindDuplicatesInput {
/// 要扫描的目录绝对路径
pub path: String,
/// 只统计大于此字节数的文件,默认 0(不限制)
pub min_size: Option<u64>,
}
字段上的文档注释会变成 Claude Code 看到的参数描述。好的描述能帮 AI 判断何时、如何调用你的工具——这是工具设计里最容易被忽视、却最影响触发准确率的一环。
然后实现工具本身,用 rmcp 的宏系统:
#[tool(tool_box)]
impl DupFindServer {
#[tool(description = "扫描目录,按文件内容找出重复文件,并报告可回收的磁盘空间")]
pub async fn find_duplicates(
&self,
#[tool(aggr)] input: Json<FindDuplicatesInput>,
) -> Result<String, anyhow::Error> {
let root = PathBuf::from(&input.path);
if !root.exists() {
return Ok(format!("错误:路径 '{}' 不存在", input.path));
}
if !root.is_dir() {
return Ok(format!("错误:路径 '{}' 不是目录", input.path));
}
let min_size = input.min_size.unwrap_or(0);
// 第一遍:按文件大小分桶(大小不同必然内容不同)
let mut by_size: HashMap<u64, Vec<PathBuf>> = HashMap::new();
index_by_size(&root, min_size, &mut by_size)?;
// 第二遍:对同大小文件比较完整内容
let mut wasted: u64 = 0;
let mut report = String::from("重复文件报告:\n\n");
let mut group_no = 0;
for (_size, files) in by_size.iter().filter(|(_, v)| v.len() > 1) {
let groups = group_identical(files);
for group in groups {
if group.len() > 1 {
group_no += 1;
let keep = &group[0];
report.push_str(&format!(
"组 #{}:保留 {}\n",
group_no,
keep.display()
));
for dup in &group[1..] {
let bytes = fs::metadata(dup)?.len();
wasted += bytes;
report.push_str(&format!(" 重复 {} ({})\n", dup.display(), human(bytes)));
}
report.push('\n');
}
}
}
if group_no == 0 {
report.push_str("未发现重复文件。");
} else {
report.push_str(&format!("共 {} 组重复,可回收 {}", group_no, human(wasted)));
}
Ok(report)
}
}
几个要点:
#[tool(tool_box)] 标在 impl 块上,告诉 rmcp "这个块里都是工具定义"。#[tool(description = "...")] 标在方法上,是 Claude Code 列出工具时看到的说明。#[tool(aggr)] 标在 Json<FindDuplicatesInput> 参数上,表示"把所有参数聚合成这个结构体"——其 JSON Schema 字段就成为工具的参数。Result<String, anyhow::Error>,字符串内容就是工具回复给 Claude Code 的内容。补上辅助函数。index_by_size 递归遍历并跳过常见无意义目录:
fn index_by_size(dir: &Path, min_size: u64, out: &mut HashMap<u64, Vec<PathBuf>>) -> Result<()> {
for entry in fs::read_dir(dir)? {
let path = entry?.path();
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
if path.is_dir() {
// 跳过隐藏目录和构建产物——把领域知识固化进工具
if name.starts_with('.') || matches!(name.as_str(), "target" | "node_modules" | "vendor" | ".git") {
continue;
}
index_by_size(&path, min_size, out)?;
} else {
let bytes = fs::metadata(&path)?.len();
if bytes >= min_size && bytes > 0 {
out.entry(bytes).or_default().push(path);
}
}
}
Ok(())
}
fn group_identical(files: &[PathBuf]) -> Vec<Vec<PathBuf>> {
// 逐字节比较,把内容相同的文件归到一组
let mut groups: Vec<(Vec<u8>, PathBuf)> = Vec::new();
let mut result: Vec<Vec<PathBuf>> = Vec::new();
for f in files {
let content = fs::read(f).unwrap_or_default();
if let Some((_, idx)) = groups.iter().zip(0..).find(|((c, _), _)| *c == content) {
result[idx].push(f.clone());
} else {
groups.push((content.clone(), f.clone()));
result.push(vec![f.clone()]);
}
}
result
}
fn human(bytes: u64) -> String {
if bytes >= 1_048_576 {
format!("{:.1} MB", bytes as f64 / 1_048_576.0)
} else if bytes >= 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else {
format!("{} B", bytes)
}
}
设计哲学:注意我们跳过了
target、node_modules、.git。这是"把领域知识固化进工具"——AI 不必每次判断该跳过哪些目录,工具替它处理好。MCP 工具不是越通用越好,而是有主见、让正确的事变简单。AI 是你的用户,它最喜欢"清晰、结构化、上下文丰富"的回复。
rmcp 要求在服务器结构体上实现 ServerHandler 特征,声明身份与能力:
#[tool(tool_box)]
impl ServerHandler for DupFindServer {
fn get_info(&self) -> ServerInfo {
ServerInfo {
instructions: "重复文件检测服务器。用 find_duplicates 扫描目录找出内容重复的文件;"
.into(),
..Default::default()
}
}
}
instructions() 返回的描述帮助 AI 理解服务器用途与使用时机。这个 impl 块上的 #[tool(tool_box)] 告诉 rmcp 自动把前面定义的工具装配进来。
#[tokio::main]
async fn main() -> Result<()> {
// 关键:日志写 stderr,绝不能碰 stdout
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::from_default_env()
.add_directive("dupfind=info".parse()?),
)
.with_writer(std::io::stderr)
.init();
info!("启动 dupfind MCP 服务器");
let server = DupFindServer;
let transport = rmcp::transport::io::stdio();
let handle = server.serve(transport).await?;
handle.waiting().await?;
Ok(())
}
执行流程:先配置 tracing_subscriber 写向 stderr(对 stdio 服务器非可选,否则协议必坏);from_default_env() 让你用 RUST_LOG 环境变量控制日志级别;然后创建 stdio 传输,serve() 启动 JSON-RPC 消息循环,waiting() 阻塞直到客户端断开。
cargo build --release
# 二进制位于 target/release/dupfind
手工发一条 JSON-RPC 初始化消息验证握手:
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0.1.0"}}}' \
| ./target/release/dupfind
应返回包含服务器能力与工具列表的 JSON 响应。若什么都没有或报错,检查是否编译干净、是否在别处误写了 stdout。
编译通过却连不上,按这个顺序排查(每条都是 rmcp 新手真实踩坑):
println!/dbg! 或写 stdout 的第三方库都会破坏 stdio 传输。审计所有依赖,把日志统一走 tracing_subscriber::fmt().with_writer(std::io::stderr)。.mcp.json 路径非绝对:Claude Code 从自己的工作目录启动服务器,相对路径会静默失败。永远用 target/release/dupfind 的绝对路径。bash
RUST_LOG=dupfind=debug ./target/release/dupfind 2>/tmp/mcp.log <<'JSON'
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0.1.0"}}}
JSON
cat /tmp/mcp.logclaude mcp add 默认注册到 local 作用域(绑定当前目录)。要在项目内全局可用,用仓库根目录的 .mcp.json。.mcp.json 或重编译二进制后,重启 Claude Code——MCP 服务器列表每会话只初始化一次。rmcp = "0.16" 或更新版本,cargo update -p rmcp 升级。两种方式。CLI 方式(快速测试):
claude mcp add --transport stdio dupfind -- /absolute/path/to/dupfind/target/release/dupfind
.mcp.json 方式(团队共享,推荐提交到版本库)。在项目根创建:
{
"mcpServers": {
"dupfind": {
"command": "/absolute/path/to/dupfind/target/release/dupfind"
}
}
}
验证注册:
claude mcp list # 应看到 dupfind
在项目目录打开 Claude Code,问:"帮我找一下 ~/Downloads 里有没有重复文件。" Claude Code 会自动发现 find_duplicates 工具并调用。你会切身体会到——无需安装任何运行时,这点对用惯 Node/Python MCP 的人格外舒适。
一个工具有用,多个能协作的工具才强大。再加一个按扩展名统计磁盘占用的工具,补到同一个 #[tool(tool_box)] impl DupFindServer 块里:
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ExtensionSizesInput {
/// 要分析的目录路径
pub path: String,
/// 最多返回多少种扩展名,默认 15
pub limit: Option<u32>,
}
#[tool(description = "按文件扩展名统计目录的磁盘占用,从大到小排序")]
pub async fn extension_sizes(
&self,
#[tool(aggr)] input: Json<ExtensionSizesInput>,
) -> Result<String, anyhow::Error> {
let root = PathBuf::from(&input.path);
if !root.is_dir() {
return Ok(format!("错误:'{}' 不是有效目录", input.path));
}
let limit = input.limit.unwrap_or(15) as usize;
let mut stats: HashMap<String, (u64, u64)> = HashMap::new(); // ext -> (bytes, count)
collect_ext_sizes(&root, &mut stats)?;
let mut rows: Vec<_> = stats.into_iter().collect();
rows.sort_by(|a, b| b.1 .0.cmp(&a.1 .0));
rows.truncate(limit);
let total: u64 = rows.iter().map(|(_, v)| v.0).sum();
let mut out = format!("按扩展名的磁盘占用(前 {}):\n\n", rows.len());
for (ext, (bytes, count)) in &rows {
let label = if ext.is_empty() { "(无扩展名)" } else { ext };
out.push_str(&format!("{:<10} {:>10} {} 个文件\n", label, human(*bytes), count));
}
out.push_str(&format!("\n合计 {}", human(total)));
Ok(out)
}
补上遍历函数:
fn collect_ext_sizes(dir: &Path, stats: &mut HashMap<String, (u64, u64)>) -> Result<()> {
for entry in fs::read_dir(dir)? {
let path = entry?.path();
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
if path.is_dir() {
if name.starts_with('.') || matches!(name.as_str(), "target" | "node_modules" | ".git") {
continue;
}
collect_ext_sizes(&path, stats)?;
} else {
let bytes = fs::metadata(&path)?.len();
let ext = path.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default();
let e = stats.entry(ext).or_insert((0, 0));
e.0 += bytes;
e.1 += 1;
}
}
Ok(())
}
重编译 cargo build --release,Claude Code 下次初始化时会自动发现新工具。现在三个能力自然组合:先用 extension_sizes 看哪种扩展名最占空间,再用 find_duplicates 在其中找重复。
把上述片段拼起来,整个服务器约 220 行 Rust。完整目录结构:
dupfind/
├── Cargo.toml
└── src/
└── main.rs
端到端验证流程:
# 1. 编译
cargo build --release
# 2. 准备测试目录(造两个内容相同的文件)
mkdir -p /tmp/dupdemo/sub
echo "important data" > /tmp/dupdemo/a.txt
echo "important data" > /tmp/dupdemo/b.txt
echo "different" > /tmp/dupdemo/sub/c.txt
# 3. 注册到 Claude Code
claude mcp add --transport stdio dupfind -- $(pwd)/target/release/dupfind
# 4. 在 Claude Code 里提问
# "分析 /tmp/dupdemo,找出重复文件并按扩展名统计占用"
预期结果:find_duplicates 报告 a.txt 与 b.txt 重复、可回收约 14 B;extension_sizes 显示 .txt 合计占用。
flowchart TD
A[Claude Code 客户端] -->|spawn 子进程 + stdin/stdout| B[dupfind 二进制]
B --> C{rmcp 消息循环}
C -->|tools/list| D[返回 JsonSchema 工具契约]
A -->|看到参数描述| A2[决定调用 find_duplicates]
A2 -->|JSON-RPC tools/call| C
C --> E[index_by_size 按大小分桶]
E --> F[group_identical 逐字节比对]
F --> G[组装可读报告]
G -->|stdout JSON-RPC 响应| A
H[tracing 日志] -.->|stderr 不污染协议| I[/tmp/mcp.log]
style A fill:#e1f5ff
style B fill:#fff4e1
style G fill:#e8f5e9
MCP 工具本质是结构体上的 async 方法,无需启动服务器或连客户端就能测。
在 src/main.rs 末尾加:
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[tokio::test]
async fn test_find_duplicates_detects_identical() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("a.txt"), "hello").unwrap();
fs::write(dir.path().join("b.txt"), "hello").unwrap(); // 重复
fs::write(dir.path().join("c.txt"), "world").unwrap();
let server = DupFindServer;
let input = Json(FindDuplicatesInput {
path: dir.path().to_string_lossy().to_string(),
min_size: None,
});
let result = server.find_duplicates(input).await.unwrap();
assert!(result.contains("可回收"));
assert!(result.contains("1 组"));
}
#[tokio::test]
async fn test_invalid_path_returns_friendly_error() {
let server = DupFindServer;
let input = Json(FindDuplicatesInput {
path: "/no/such/path".into(),
min_size: None,
});
let result = server.find_duplicates(input).await.unwrap();
assert!(result.contains("不存在"));
}
}
Cargo.toml 加开发依赖:
[dev-dependencies]
tempfile = "3"
运行 cargo test。注意一个关键设计选择:工具返回 Ok(String) 里夹带错误信息,而不是用 ? 上抛 Err。这是因为——返回 Err 客户端看到的是协议级错误;返回 Ok 带错误文案,AI 能读懂并智能反应(换个路径、向你澄清、解释问题)。把 Err 留给真正不可恢复的情况。
协议级集成测试则直接喂二进制原始 JSON-RPC:
#!/bin/bash
# test_integration.sh
BIN="./target/release/dupfind"
INIT='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"0.1.0"}}}'
LIST='{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
RESP=$(echo -e "${INIT}\n${LIST}" | $BIN 2>/dev/null)
echo "$RESP" | grep -q "find_duplicates" && echo "PASS: find_duplicates" || echo "FAIL"
echo "$RESP" | grep -q "extension_sizes" && echo "PASS: extension_sizes" || echo "FAIL"
单元测试能过、连 Claude Code 却失败,问题几乎总在传输层。开详细日志盯 stderr:
RUST_LOG=rmcp=debug,dupfind=trace ./target/release/dupfind 2>/tmp/debug.log
rmcp=debug 会打印收发的每一条 JSON-RPC,dupfind=trace 是应用日志,两者叠加能精确定位通信断点。
RUST_LOG=dupfind=debug 启动即可看详细输出;问题先看 stderr,再 claude mcp list 查状态。std::fs::canonicalize 后限定在特定根目录,防止 AI 误扫敏感位置。notifications/progress)。transport-streamable-http-server feature,配合 axum 暴露 /mcp 端点;远程部署时认证走 HTTP 层(OAuth 2.1 / Bearer Token / API Key / mTLS),多实例水平扩展用无状态模式。rmcp 的 auth feature 已内置 OAuth 2.0 支持。github.com/modelcontextprotocol/rust-sdk 与 docs.rs/rmcp——含 Resources、Prompts、Sampling、Roots、订阅等完整特性。modelcontextprotocol.io——协议演进(Streamable HTTP、Resource 订阅)的权威来源。code.claude.com——环境变量、权限作用域、服务器生命周期管理。blake3 哈希(cargo add blake3),对超大文件分组可显著提速;并行遍历可用 rayon。Cargo.toml 为 Resource;③ 把 stdio 改成 Streamable HTTP 做远程版。#[tool] 宏、ServerHandler、stdio 传输、测试与排错,本文宏语法与 code-stats 示例架构的主要参考。ServerHandler/ServiceExt 等模块的权威说明。[!tip] 51学通信知识星球 星主:爱卫生(微信:gprshome201101)——公众号「51学通信」创办者,19 年通信行业运维与讲师经验,著有《GPRS网络信令实例详解》《EPC原理与实践》《5G核心网原理与实践》三本纸质书(各大电商平台有售)。 星球定位:深耕 5G 核心网、IMS、NFV/AI、云原生等 ICT 领域,主打运维岗位与信令分析。 当前规模:5200 篇主题 / 990 个回答,198 元/年。 加入福利:加入星球后请加爱卫生微信,拉您进知识星球年度陪伴与答疑群——不限量回复您的各种问题,另有群内分享。 AI 加持:51学通信知识星球已作为 skill 接入 WorkBuddy,使用 deepseek-4-flash 在海量星球帖子中精准找贴、找视频、找 FAQ 和案例,做总结、找文档和 PPT 等场景全覆盖。
