教程·阅读约 5 分钟·
用 Rust 的 rmcp 写一个 MCP server:从空项目到接进编码助手

用 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,几十行就能跑起来:

code
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 挂在后台跑着,然后开新项目:

code
cargo new crud_mcp

Cargo.toml 里的依赖如下,版本号直接照抄:

code
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。

code
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 也只提供这个。

code
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,把模块接上并启动服务:

code
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 里:

code
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_mcp

just 是个命令行执行器,可以理解成现代化的 make,不是必须的,但复杂命令写进去省事。

just mcp-test,会打开浏览器版的 Inspector。点 Connect 连上,再点 List Tools——你会看到什么都没有。这是对的,因为确实还没写工具。这时候可以在 Inspector 里到处点一点,找找刚才发出去的 instructions 跑哪去了。

第一个工具:只读列表

先补上纯 Rust 的部分:定义 TodoTodos 两个结构体,用 reqwest 请求接口、用 serde 反序列化。

code
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。真正变身成工具只差一个属性宏:

code
#[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 里的工具清单,不然模型不知道有这么一个工具:

code
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 侧加一个辅助结构体和创建函数:

code
#[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 就是模型看到的参数说明:

code
#[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 大致是这样:

code
{
  "$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> 包一层再解构:

code
#[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 段里加:

code
"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 实现——两层各用各的长处。

分享到
微博Twitter

© 2026 四月

原文链接:https://www.aprilzz.com/tutorials/rust-mcp-server-rmcp-guide