
用 Rust 的 rmcp 写一个 MCP server:从空项目到接进编码助手
一份可以照着敲的完整教程:用 rmcp 0.11 搭起 MCP server 骨架,接上本地的 CRUD API,写两个工具(一个只读、一个带参数),用 MCP Inspector 验证,最后配进 Kiro CLI 真跑一遍。
原文来源:rup12.net — 用 rmcp 从零搭一个 MCP server 的完整教程,覆盖工具路由宏、JSON Schema 参数、MCP Inspector 验证和接进编码助手的配置。
MCP(Model Context Protocol)是一套开放协议,规定了应用怎么把上下文、工具和资源交给大模型。大多数人是在编码助手里第一次接触到它:接上各种 MCP server,助手就能查最新文档、连数据库、读写你自己的系统。Python 那边有 FastMCP,几十行就能跑起来:
from mcp.server import FastMCP
mcp = FastMCP("Calculator Server")
@mcp.tool(description="Calculator tool which performs calculations")
def calculator(x: int, y: int) -> int:
return x + y
mcp.run(transport="sse")这份教程要做的是同一件事的另一条路:用 Rust 的 rmcp crate 写。理由倒也不新鲜——编译型、启动快、类型安全。作者还补了一句个人体验:用编码助手写 Rust 时效率反而更高,因为编译器会很早把问题顶回来。
例子本身很简单:一个通过本地 HTTP 接口读写待办事项的 MCP server,用 stdio 作为传输层。这也是最常见的形态。
准备工作
需要一个在本地跑起来的 CRUD API,教程里直接给了快速启动流程(只需要 Rust、Docker 和 just 三个东西)。把 API 挂在后台跑着,然后开新项目:
cargo new crud_mcpCargo.toml 里的依赖如下,版本号直接照抄:
anyhow = "1.0.100"
reqwest = "0.12.25"
rmcp = { version = "0.11.0", features = ["reqwest", "transport-io", "uuid"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.145"
tokio = { version = "1.48.0", features = ["full"] }
tracing = "0.1.43"
tracing-subscriber = { version = "0.3.22", features = ["env-filter"] }—— 广告 ——
先让骨架跑起来
第一步是给 main.rs 加上日志。这里有个容易踩的坑:日志必须写到 stderr。
use anyhow::Result;
use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::from_default_env()
.add_directive(tracing::Level::DEBUG.into()),
)
.with_writer(std::io::stderr)
.with_ansi(false)
.init();
tracing::info!("Starting CRUD MCP Server");
Ok(())
}因为 stdio 传输层用的是 stdout,往 stdout 打任何东西都会污染协议流。
接下来新建 todos.rs,把 MCP server 的骨架搭起来。按照 MCP 规范,server 可以给客户端提供 tools、prompts、resources 三类能力,教程里只实现 tools——绝大多数 MCP server 也只提供这个。
use rmcp::{
handler::server::tool::ToolRouter,
model::{
CallToolResult, Content, Implementation, InitializeResult,
ProtocolVersion, ServerCapabilities, ServerInfo,
},
tool, tool_handler, tool_router, ServerHandler,
};
use rmcp::ErrorData as McpError;
#[derive(Clone)]
pub struct TodoMcpServer {
tool_router: ToolRouter<Self>,
}
#[tool_router]
impl TodoMcpServer {
pub fn new() -> Self {
Self { tool_router: Self::tool_router() }
}
// 工具待会儿加
}
#[tool_handler]
impl ServerHandler for TodoMcpServer {
fn get_info(&self) -> ServerInfo {
ServerInfo {
protocol_version: ProtocolVersion::V_2025_06_18,
capabilities: ServerCapabilities::builder()
.enable_tools()
.build(),
server_info: Implementation::from_build_env(),
instructions: Some(
"I manage a list of TODOs. That are stored behind an API server. \
The available actions are:".to_string(),
),
}
}
async fn initialize(
&self,
_request: rmcp::model::InitializeRequestParam,
_context: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<InitializeResult, McpError> {
Ok(self.get_info())
}
}两个宏加一个必需字段:#[tool_router] 生成工具路由,#[tool_handler] 生成处理器,tool_router 字段是必需的。get_info() 里写清楚协议版本、启用了哪些能力,以及发给客户端的 instructions——这段文字会进入模型的上下文,值得当提示词认真写。
回到 main.rs,把模块接上并启动服务:
mod todos;
use anyhow::Result;
use rmcp::{transport::stdio, ServiceExt};
use crate::todos::TodoMcpServer;
// ... tracing 初始化部分同上 ...
let service = TodoMcpServer::new()
.serve(stdio())
.await
.inspect_err(|e| tracing::error!("serving error: {:?}", e))?;
service.waiting().await?;现在 cargo build 应该能编过。恭喜,你的第一个 MCP server 已经存在了——虽然它一个工具都没有。
用 MCP Inspector 验证
验证环节用的是官方 MCP Inspector,作者把它包进 justfile 里:
USER_ID := "550e8400-e29b-41d4-a716-446655440001"
release:
cargo build --release
# Test with MCP inspector
mcp-test: release
npx @modelcontextprotocol/inspector -e USER_ID={{ USER_ID }} ./target/release/crud_mcpjust 是个命令行执行器,可以理解成现代化的 make,不是必须的,但复杂命令写进去省事。
跑 just mcp-test,会打开浏览器版的 Inspector。点 Connect 连上,再点 List Tools——你会看到什么都没有。这是对的,因为确实还没写工具。这时候可以在 Inspector 里到处点一点,找找刚才发出去的 instructions 跑哪去了。
第一个工具:只读列表
先补上纯 Rust 的部分:定义 Todo 和 Todos 两个结构体,用 reqwest 请求接口、用 serde 反序列化。
use std::sync::Arc;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Todos {
todos: Arc<Vec<Todo>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Todo {
id: String,
user_id: String,
date: String,
title: String,
body: String,
complete: bool,
printed: bool,
archived: bool,
}
impl Todos {
async fn get_todos() -> Result<Self, anyhow::Error> {
let response: Vec<Todo> = reqwest::get("http://localhost:3000/todos")
.await?
.json()
.await?;
Ok(Self { todos: Arc::new(response) })
}
}这段完全没有 MCP 的成分,就是普通 Rust。真正变身成工具只差一个属性宏:
#[tool_router]
impl TodoMcpServer {
pub fn new() -> Self {
Self { tool_router: Self::tool_router() }
}
/// 从 API 取出全部待办
#[tool(description = "Get all available TODOs in JSON format")]
async fn get_all_todos() -> Result<CallToolResult, McpError> {
let todos = Todos::get_todos().await.map_err(|e| {
McpError::internal_error(format!("Error getting TODOs: {}", e), None)
})?;
let content = Content::json(todos).map_err(|e| {
McpError::internal_error(
format!("Error converting TODOs into JSON values: {}", e),
None,
)
})?;
Ok(CallToolResult::success(vec![content]))
}
}几个约定值得记住:工具名默认取函数名;#[tool(description = ...)] 里的文字会发给客户端,模型就是靠它判断什么时候该调这个工具;返回值必须是 CallToolResult;出错不要 panic,包成 McpError::internal_error 回传,让客户端知道发生了什么。
作者自己吐槽了一下这段代码——先把 JSON 反序列化成结构体,紧接着又序列化回 JSON,确实绕。这么做只是为了演示结构体的用法。真要写,他会直接返回请求拿到的 JSON,或者更讲究一点,只挑客户端需要的字段回传。
别忘了同步更新 instructions 里的工具清单,不然模型不知道有这么一个工具:
instructions: Some(
"I manage a list of TODOs. That are stored behind an API server. \
The available actions are: - get_all_todos: Get a list of all the todos."
.to_string(),
),再跑一次 just mcp-test,Inspector 里点 List Tools,get_all_todos 出现了。点进去运行——记得 CRUD API 还在后台跑着。
第二个工具:带参数
最后补一个能创建待办的工具,这也是教程里唯一需要额外「包装」的地方:工具参数不是普通的函数参数,得先用结构体描述清楚,让模型知道该填什么。
先给 Rust 侧加一个辅助结构体和创建函数:
#[derive(Debug, Serialize, Deserialize, Clone)]
struct NewTodo {
user_id: String,
title: String,
body: String,
}
impl Todos {
async fn create_todo(todo: NewTodo) -> Result<Todo, anyhow::Error> {
let response: Todo = reqwest::Client::new()
.post("http://localhost:3000/todos")
.json(&todo)
.send()
.await?
.json()
.await?;
Ok(response)
}
}然后是参数结构体。关键在于 schemars 的属性——它会把描述生成进 JSON Schema,而这份 Schema 就是模型看到的参数说明:
#[derive(Debug, Deserialize, schemars::JsonSchema)]
#[schemars(description = "Input for creating a new TODO entry")]
pub struct NewTodoParameters {
#[schemars(description = "Title of the TODO item")]
title: String,
#[schemars(description = "Body of the TODO item")]
body: String,
}生成的 JSON Schema 大致是这样:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "NewTodoParameters",
"description": "Input for creating a new TODO entry",
"type": "object",
"properties": {
"body": { "description": "Body of the TODO item", "type": "string" },
"title": { "description": "Title of the TODO item", "type": "string" }
},
"required": ["title", "body"]
}工具函数用 Parameters<T> 包一层再解构:
#[tool(description = "Create a new TODO entry by passing on the title and body of the TODO item.")]
async fn create_new_todo(
Parameters(NewTodoParameters { title, body }): Parameters<NewTodoParameters>,
) -> Result<CallToolResult, McpError> {
let user_id = std::env::var("USER_ID").map_err(|_| {
McpError::internal_error(
"USER_ID environment variable MUST be set".to_string(),
None,
)
})?;
let new_todo = Todos::create_todo(NewTodo { user_id, title, body })
.await
.map_err(|e| {
McpError::internal_error(
format!("Error creating new TODO entry: {}", e),
None,
)
})?;
let return_message = format!(
"A new TODO entry has been created, here are its details: \
- id: {} - date: {} - title: {} - body: {} \
Use the ID of the TODO for any todo specific instructions.",
new_todo.id, new_todo.date, new_todo.title, new_todo.body
);
Ok(CallToolResult::success(vec![Content::text(return_message)]))
}这里有两处值得细看。
一是返回值。API 明明返回了完整的 JSON,作者却只用 format! 拼了一段摘要回去。这不是偷懒,是刻意的上下文管理:MCP 客户端并不需要那条记录的每个字段,把整个 JSON 塞回上下文窗口只会挤占本就不宽裕的空间。给多少信息,本身就是 MCP server 设计的一部分。
二是 USER_ID。示例里从环境变量读一个固定值,真实场景下这个值应该来自 JWT 之类的凭证——作者也提醒了,这个示例 API 本身没有任何鉴权。
照着敲时会卡住的三个地方
教程的代码是通的,下面几处是跟着走一遍时容易停下来找原因的地方:
schemars 可能需要自己加。 参数结构体用到了 schemars::JsonSchema 派生宏,而上面那份 Cargo.toml 里并没有列它。如果 create_new_todo 这一段编译不过,在依赖里补一行带 derive 特性的 schemars 就能解决——rmcp 正是用它把结构体转成 JSON Schema 的。
两个位置要同时更新,漏一个模型就找不到工具。 一个是 #[tool(description = ...)] 里的工具描述,一个是 get_info() 返回的 instructions。前者决定模型在众多工具里能不能挑中它,后者是给客户端的全局说明。加完工具只改一处,行为会很奇怪:工具列得出来,模型却想不起来用。
stdio 传输层下,stdout 是协议通道。 任何 println! 或者把日志往 stdout 打的行为,都会污染协议流,表现为客户端连上就断。日志统一走 stderr 是这条规范里最容易忽略、也最容易踩的一条。
接进编码助手
Inspector 里能用,不等于助手里能用。作者用 Kiro CLI 做了最后一步验证,在 agent 配置的 mcpServers 段里加:
"mcp_crud": {
"command": "/home/darko/tmp/crud_mcp/target/release/crud_mcp",
"env": { "USER_ID": "550e8400-e29b-41d4-a716-446655440001" },
"disabled": false,
"autoApprove": [],
"disabledTools": []
}command 填编译出来的二进制路径,各人机器上都不一样。这份 JSON 的结构在大多数编码助手里都通用,不用 Kiro 的话,把这段挪到自己的工具里就行。
值得多想一步的问题
教程发出来后,LinkedIn 上有人问了个挺尖锐的问题:这值得优化吗?反正 LLM 调用才是耗时大头。作者承认这话很公道——MCP server 快一点,并不会让模型回答更快。
但他还是倾向 Rust,理由是书写体验和复杂工具调用:类型系统会在编译期挡住一批问题,工具逻辑一旦变重(大量数据处理、外部系统交互),Rust 的收益就显出来了。他还留了个预测:未来可能是 FastMCP 负责 MCP 那层管道,具体工具用 Rust 实现——两层各用各的长处。
© 2026 四月
原文链接:https://www.aprilzz.com/tutorials/rust-mcp-server-rmcp-guide
相关文章
扩散语言模型是怎么构建的:从 masked diffusion 到 Mercury、Gemma Diffusion 的完整路线图
一次讲透扩散语言模型:为什么自回归不是唯一出路,masked diffusion 怎么把 BERT 变成生成模型,block diffusion、remasking、蒸馏、可控生成这些模块如何拼出 Mercury、Gemma Diffusion、Nemotron 等生产级模型。
让 AI Agent 直接读你的网站:Accept: text/markdown 内容协商实战教程
手把手教你把网站变成 AI Agent 友好:用 Accept: text/markdown 内容协商,让 Claude Code、Cursor 等工具直接读到干净的 Markdown。含 Caddy、Nginx、Next.js、Cloudflare 配置和验证方法。
从 2500 亿条缓存里抠出 100TB 内存:Cloudflare 的五个内存优化手法,每个都能直接抄
Cloudflare 对 1.1.1.1 的 DNS 缓存做了 5 个存储层优化,每条目占用从 953 字节降到 420 字节,整个机群省下约 100TB 内存,缓存反而更快了。本文逐条拆解这些可复用的 Rust 内存优化技术。