首页 › tutorials › Rust构建MCP服务器实战

Rust 构建 MCP 服务器实战:用 rmcp 交付一个带状态的代码片段管家

概述

本教程带你用 Rust 官方 SDK rmcp 从零构建一个真正能跑、带持久化状态的 MCP(Model Context Protocol)服务器——snippet-mcp 代码片段管家。学完你将掌握:rmcp 的宏驱动开发模型、工具定义与参数 schema、有状态服务的线程安全设计、stdio 传输、MCP Inspector 调试,以及接入 Claude Code 的完整闭环。适合有 Rust 基础、想把高性能本地工具暴露给 AI 编程助手使用的开发者。

前置条件

确认环境:

cargo --version    # cargo 1.75.0 或更高
claude --version   # Claude Code 已安装

一、为什么用 Rust 写 MCP 服务器

MCP 是 Anthropic 提出的、让 AI 模型以标准方式调用外部工具、读取资源、复用提示词的协议。社区已有 Python(FastMCP)、TypeScript、Go 等多套官方 SDK,它们上手快,但在三类场景里 Rust 优势明显:

  1. 常驻进程、高并发工具调用:MCP 服务器是长驻服务,模型可能在一轮对话里并发调用十几次工具。Rust 的零成本异步 + 无 GC,让 99 分位延迟稳定且可预测,适合需要实时性(如系统监控、代码索引、文件检索)的工具。
  2. 单文件分发、零运行时依赖:cargo build --release 产出一个静态链接的二进制,团队成员拷贝即用,不必担心 Python 版本或 Node 依赖地狱——这对"团队内分发 MCP 服务器"极其友好。
  3. 类型即契约:rmcp 用 serde + schemars 把 Rust 的类型直接编译成 JSON Schema,工具的输入校验和文档由编译器保证,模型拿到的 inputSchema 永远和实现一致。

代价是:写起来比 Python 啰嗦,异步借用要小心。本教程会用宏 + 简单状态把复杂度压到最低。

二、理解 MCP 与 rmcp 的最小心智模型

一个 MCP 服务器对外暴露三类原语:Tools(工具,可执行副作用)、Resources(资源,只读数据)、Prompts(提示词模板)。本教程聚焦最高频的 Tools。rmcp 把"声明工具"这件事做成了两个声明式宏:

你只需要写"带 #[tool] 的普通 Rust 方法",剩下的协议序列化、JSON-RPC 分发全部由宏完成。

flowchart LR
    subgraph Client["Claude Code / Inspector (MCP Client)"]
        A["模型决定调用工具"]
    end
    subgraph Transport["stdio (stdin/stdout JSON-RPC)"]
        T["行分隔 JSON-RPC 2.0"]
    end
    subgraph Server["你的 Rust MCP 服务器"]
        H["ServerHandler<br/>协议入口"] --> R["ToolRouter<br/>宏生成的路由表"]
        R --> M1["#[tool] add_snippet"]
        R --> M2["#[tool] search_snippets"]
        R --> M3["#[tool] delete_snippet"]
        M1 & M2 & M3 --> S["AppState<br/>tokio::Mutex + JSON 文件"]
    end
    A -->|call_tool| T --> H
    M3 -.持久化.-> S

记住这条链路:模型 → 客户端 → JSON-RPC → ServerHandler → ToolRouter → 你的 #[tool] 方法 → 状态。后面每一步都是在这条链路的一个环节上动手。

三、第 1 步:初始化工程与依赖

操作说明:

cargo new snippet-mcp
cd snippet-mcp

把 Cargo.toml 的 [dependencies] 替换为:

[dependencies]
rmcp = { version = "0.7", features = ["server", "transport-io"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

[dev-dependencies]
cargo --version

[[bin]]
name = "snippet-mcp"
path = "src/main.rs"

注意:rmcp 版本演进较快(仓库正在向 1.0 迁移)。请到 crates.io/crates/rmcp 核对最新稳定版号再填。features = ["server", "transport-io"] 分别启用服务端角色和 stdio 传输;如果只用宏写工具,server 通常已包含宏支持,若编译报宏未找到再加 macros。

预期结果:cargo check 通过,无依赖解析错误。首次会下载 tokio 全家桶与 rmcp,约 1–2 分钟。

常见问题:

四、第 2 步:定义带状态的服务与工具

操作说明:先写数据模型与状态。新建 src/main.rs(先放数据层,下一步再加服务层):

use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::PathBuf;
use tokio::sync::Mutex;

/// 一条代码片段
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Snippet {
    pub id: u64,
    pub title: String,
    pub language: String,
    pub code: String,
}

/// 可持久化的仓库:一个自增计数器 + 按 id 存储的片段表
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct Store {
    pub next_id: u64,
    pub items: BTreeMap<u64, Snippet>,
}

/// MCP 服务的共享状态:内存镜像 + 落盘路径
pub struct AppState {
    pub store: Mutex<Store>,
    pub path: PathBuf,
}

impl AppState {
    pub fn new(path: PathBuf) -> anyhow::Result<Self> {
        let store = if path.exists() {
            serde_json::from_slice(&std::fs::read(&path)?)?
        } else {
            Store::default()
        };
        Ok(Self { store: Mutex::new(store), path })
    }

    pub async fn persist(&self) -> anyhow::Result<()> {
        let guard = self.store.lock().await;
        let bytes = serde_json::to_vec_pretty(&*guard)?;
        // 原子写:先写临时文件再 rename,避免半截写入
        let tmp = self.path.with_extension("json.tmp");
        std::fs::write(&tmp, bytes)?;
        std::fs::rename(&tmp, &self.path)?;
        Ok(())
    }
}

关键点:状态用 tokio::sync::Mutex(不是 std::sync::Mutex),因为我们要在 async 工具方法里 .lock().await;落盘用"临时文件 + rename"保证原子性,进程随时被 kill 也不会写坏 JSON。

现在写服务层与工具。rmcp 让你把工具写成结构体的方法,用宏登记:

use rmcp::{
    handler::server::tool::ToolRouter,
    model::{ServerCapabilities, ServerInfo},
    schemars,
    service::ServiceExt,
    tool, tool_handler, tool_router,
    Error as McpError,
};
use serde::Deserialize;

#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct AddSnippetParams {
    /// 片段标题,例如 "二分查找"
    pub title: String,
    /// 语言,例如 "rust" / "python"
    pub language: String,
    /// 代码正文
    pub code: String,
}

#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct SearchParams {
    /// 关键字,在 title 和 code 中做子串匹配
    pub query: String,
}

pub struct SnippetService {
    pub state: std::sync::Arc<AppState>,
    pub router: ToolRouter<SnippetService>,
}

#[tool_router]
impl SnippetService {
    pub fn new(state: std::sync::Arc<AppState>) -> Self {
        Self { state, router: Self::tool_router() }
    }

    #[tool(description = "保存一段代码片段到本地知识库,返回它的 id。")]
    async fn add_snippet(&self, #[tool(param)] params: AddSnippetParams) -> Result<String, McpError> {
        let mut store = self.state.store.lock().await;
        store.next_id += 1;
        let id = store.next_id;
        store.items.insert(id, Snippet {
            id,
            title: params.title,
            language: params.language,
            code: params.code,
        });
        drop(store);
        self.state.persist().await.map_err(|e| internal(e))?;
        Ok(format!("已保存,id = {id}"))
    }

    #[tool(description = "按关键字在标题与代码中搜索,返回匹配片段列表。")]
    async fn search_snippets(&self, #[tool(param)] params: SearchParams) -> Result<String, McpError> {
        let store = self.state.store.lock().await;
        let q = params.query.to_lowercase();
        let hits: Vec<&Snippet> = store.items.values()
            .filter(|s| s.title.to_lowercase().contains(&q) || s.code.to_lowercase().contains(&q))
            .collect();
        if hits.is_empty() {
            return Ok("没有匹配的片段。".into());
        }
        let out: Vec<String> = hits.iter()
            .map(|s| format!("[{}] ({}) {}", s.id, s.language, s.title))
            .collect();
        Ok(out.join("\n"))
    }

    #[tool(description = "按 id 删除片段。")]
    async fn delete_snippet(&self, #[tool(param)] id: u64) -> Result<String, McpError> {
        let mut store = self.state.store.lock().await;
        if store.items.remove(&id).is_none() {
            return Ok(format!("id {id} 不存在,未做任何删除。"));
        }
        drop(store);
        self.state.persist().await.map_err(|e| internal(e))?;
        Ok(format!("已删除 id {id}。"))
    }
}

/// 把任意 anyhow 错误转成 MCP 协议错误(供进阶按错误类型细分)
fn internal(e: impl std::fmt::Display) -> McpError {
    McpError::internal_error(format!("snippet-mcp: {e}"), None)
}

关于错误处理:#[tool] 方法支持返回 Result<T, McpError>,T 只要实现序列化输出(String、i32、或 CallToolResult)。这里用 McpError::internal_error 把落盘失败转成协议错误。若你想返回"业务上的非致命错误"(如片段不存在)让模型能继续推理,有两种选择:一是像 delete_snippet 那样返回一个描述性 String(语义为成功调用、内容是提示),二是返回 CallToolResult { is_error: true, content: ... }。本教程对"删除不存在"采用前者,对"落盘失败"采用后者,覆盖两种范式。

预期结果:cargo check 通过。#[tool_router] 宏会在编译期为 SnippetService 生成 tool_router() 关联函数,并把 add_snippet/search_snippets/delete_snippet 注册进路由表,对应的 inputSchema 由 schemars 从 AddSnippetParams/SearchParams 自动派生。

常见问题:

五、第 3 步:实现 ServerHandler 与 stdio 主循环

操作说明:在 main.rs 末尾加上协议入口和 main:

use rmcp::{handler::server::ServerHandler, implementation};
use tokio::io::{stdin, stdout};

#[tool_handler]
impl ServerHandler for SnippetService {
    fn get_info(&self) -> ServerInfo {
        ServerInfo {
            // implementation!() 宏帮你构造 Implementation{name, version}
            server_info: implementation!("snippet-mcp", env!("CARGO_PKG_VERSION")),
            capabilities: ServerCapabilities::builder()
                .enable_tools()
                .build(),
            instructions: Some("代码片段管家:用 add_snippet 存,用 search_snippets 查,用 delete_snippet 删。".into()),
            ..Default::default()
        }
    }
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    tracing_subscriber::fmt()
        .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
        .with_writer(std::io::stderr)   // 日志只走 stderr,绝不污染 stdout
        .init();

    // 存储路径:优先用环境变量,否则落到用户数据目录
    let path = std::env::var("SNIPPET_STORE_PATH")
        .map(PathBuf::from)
        .unwrap_or_else(|_| {
            let mut p = dirs_or_home();
            p.push("snippet-mcp-store.json");
            p
        });

    let state = std::sync::Arc::new(AppState::new(path)?);
    let service = SnippetService::new(state);

    // 关键:serve(stdin, stdout) 启动 JSON-RPC over stdio 循环
    let running = service.serve(stdin(), stdout()).await?;
    running.waiting().await?;
    Ok(())
}

fn dirs_or_home() -> PathBuf {
    std::env::var("HOME")
        .map(PathBuf::from)
        .unwrap_or_else(|_| PathBuf::from("."))
}

#[tool_handler] 宏会自动实现 list_tools 与 call_tool,把它们转发给 self.router——你完全不用手写分发。serve(stdin(), stdout())(来自 ServiceExt,需 transport-io 特性)就是 MCP 的 stdio 传输:客户端写到服务器的 stdin,服务器把响应写到 stdout。

预期结果:cargo build 成功,产出 target/debug/snippet-mcp。

常见问题:

六、第 4 步:cargo run 冒烟自测

MCP 服务器平时不打印东西,直接 cargo run 会"看起来卡住"——这是正常的,它在等 stdin。最快的自测是手发一条 initialize 报文:

printf '%s\n' \
  '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"manual","version":"0.0"}}}' \
  '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
  '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
  | cargo run -q

预期结果:stdout 依次返回三条 JSON-RPC 响应:initialize 结果(含 serverInfo 名为 snippet-mcp)、(initialized 是通知,无响应)、tools/list 列出三个工具及其 inputSchema。看到 tools/list 里 add_snippet、search_snippets、delete_snippet 就说明链路通了。

七、第 5 步:用 MCP Inspector 交互测试

手动拼 JSON 太累。官方 Inspector 给了你一个 GUI:

npx -y @modelcontextprotocol/inspector cargo run -q

预期结果:自动打开浏览器(默认 http://localhost:6274)。在左侧 Tools 面板能看到三个工具,点开 add_snippet、填入 title/language/code、点 Run Tool,右侧返回 已保存,id = 1。再去 search_snippets 查一下,验证状态真的持久化了(关掉 Inspector 重开,数据还在)。

常见问题:

八、第 6 步:接入 Claude Code

操作说明:编译 release 版本,再用 claude mcp add 注册:

cargo build --release
# 项目级(写入当前项目 .mcp.json,随仓库共享)
claude mcp add snippet-mcp -- /home/you/snippet-mcp/target/release/snippet-mcp
# 或用户级(所有项目可用)
claude mcp add --scope user snippet-mcp -- /home/you/snippet-mcp/target/release/snippet-mcp

用绝对路径,避免在不同工作目录下找不到二进制。要传环境变量(如自定义存储路径),加 -e SNIPPET_STORE_PATH=/path/store.json。

预期结果:进入任意项目目录启动 claude,运行 /mcp,应看到 snippet-mcp 状态为 connected,并展开列出三个工具。然后试着对话:

> 帮我存一段快排,语言 rust,标题就叫 quicksort
> 搜一下我库里有没有 quicksort
> 把 quicksort 删掉

模型会自动选中 add_snippet → search_snippets → delete_snippet,你能看到每一步工具调用与返回。

常见问题:

实战案例:完整运行演示

把前几步的代码拼起来就是一个完整可运行项目,目录结构:

snippet-mcp/
├── Cargo.toml
└── src/
    └── main.rs      # 数据层 + 服务层 + ServerHandler + main 全在一个文件

端到端验证脚本(保存为 e2e.sh):

#!/usr/bin/env bash
set -e
export SNIPPET_STORE_PATH=/tmp/snippet-test.json
BIN=./target/release/snippet-mcp
[ -x "$BIN" ] || cargo build --release

# 存两条
printf '%s\n' \
  '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}' \
  '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
  '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"add_snippet","arguments":{"title":"hello","language":"rust","code":"fn main(){println!(\"hi\")}"}}}' \
  '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"search_snippets","arguments":{"query":"hi"}}}' \
  | "$BIN"

echo "--- 持久化文件内容 ---"
cat "$SNIPPET_STORE_PATH"

跑通后你会看到:add_snippet 返回 已保存,id = 1,search_snippets 命中它,且 /tmp/snippet-test.json 里确实落盘了一条记录。这就闭环了——从模型决策到工具执行再到磁盘持久化,全程类型安全。

进阶:从 stdio 迈向 Streamable HTTP

stdio 适合本地单用户。当你想让远程的 Claude(或多台机器)共享同一个服务器时,换传输层即可,业务代码几乎不动。rmcp 把传输抽象成了 serve(transport),因此只需替换传输实现:

flowchart TB
    subgraph Local["本地单机"]
        L1["Claude Code"] -->|"stdio"| S1["snippet-mcp"]
    end
    subgraph Remote["远程/多用户"]
        R1["远程 Claude"] -->|"HTTPS + SSE"| GW["axum HTTP 服务器"]
        GW --> S2["同一个 SnippetService"]
        GW -.鉴权.-> Auth["API Key / OAuth2 中间件"]
    end
    S1 ---|"同一份 #[tool] 实现"| S2

要点:

这部分你不需要现在动手——先用 stdio 跑通价值闭环,等真有远程需求时再切传输层,因为 rmcp 的传输抽象让你为此付出的迁移成本极低。

调优与安全清单

上线前逐条过一遍:

  1. stdout 纯净:全程零 println!,日志只走 stderr。这是 MCP 服务的第一戒律。
  2. 工具粒度:一个工具做一件事。add/search/delete 分开,比一个 manage_snippet(action, ...) 大杂烩更容易被模型选准。
  3. 描述即文档:description 和 #[schemars(description)] 是模型唯一能看到的接口说明,写得越精确(含边界、单位、示例),误用越少。
  4. 大输出分页:search 命中上千条时不要全返回,加 limit/offset 参数,避免撑爆上下文窗口。
  5. 副作用最小化与可逆:delete 先做存在性检查、返回清晰提示;危险操作可在 description 里写明"不可逆"。
  6. 输入校验:虽然 schema 保证类型,但业务约束(标题非空、长度上限、语言白名单)要在方法体里再验一次,用 McpError::invalid_params 反馈。
  7. 密钥:若工具要调外部 API,密钥走环境变量/secret manager,绝不硬编码;二进制分发时附 .env.example 而非真值。
  8. 超时与取消:长任务工具要响应客户端取消(rmcp 的请求处理可感知 Cancelled 通知),别让一个慢查询拖死整个会话。

训练营适用性评估

参考来源

  1. modelcontextprotocol/rust-sdk (GitHub) — rmcp 官方仓库,含 macros、transport、server/client 全套示例,最权威的 API 与特性列表来源。
  2. rmcp 文档 (docs.rs) — 各版本 API 参考与宏用法,核对 ServerHandler、ServiceExt::serve、错误类型的实时来源。
  3. Build an MCP Server in Rust with rmcp and Claude Code (systemprompt.io) — 从 cargo new 到三个工具的端到端实战指南,本文第 1–3 步的组织方式参考了它的编排。
  4. Building MCP Servers in Rust: Model Context Protocol Guide for 2026 (rustify.rs) — 对 Rust 写 MCP 的性能与适用场景分析,支撑"为什么用 Rust"一节。
  5. Model Context Protocol 官方文档 (modelcontextprotocol.io) — 协议规范、工具/Resources/Prompts 三类原语与传输层概念,理解 JSON-RPC 报文格式的第一手来源。

[!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 等场景全覆盖。

51学通信知识星球海报-扫码加入