How to Build an MCP Server in Rust
An MCP server in Rust is a program that lists its tools and runs them when an AI app calls them. Add the official rmcp crate, write each tool as a method with a typed parameter struct, and start the server over stdio with serve(stdio()). The bookmarks server below is one file of about 180 lines. It does the same job as the Python and TypeScript version, and you can connect it to the same AI apps.
What you'll have at the end
A bookmarks server with two tools, save_bookmark and search_bookmarks, that any MCP-compatible AI app can call. This is the result of saving a page and then searching for it with MCP Inspector:
{"result":{"content":[{"type":"text","text":"Saved 'MCP docs'. Total bookmarks: 1"}],"isError":false}}
{"result":{"content":[{"type":"text","text":"- MCP docs: https://modelcontextprotocol.io"}],"isError":false}}
The AI app starts your binary as a child process and exchanges JSON-RPC messages with it over standard input and output. Your server never talks to the model directly; the app passes tool calls through.
Before you start
- Rust 1.88 or later. The
rmcpcrate requires it. rustup installs the current stable release, which is newer than that. - Cargo, which comes with rustup.
- Node.js 22.19 or later, for MCP Inspector.
- Comfort running commands in a terminal.
- To connect it at the end: Claude Desktop, or any other app from MCP clients.
1. Install Rust and create the project
Install Rust with rustup, the official installer. Run this in a terminal:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Open a new terminal so cargo is on your PATH, then create the project:
cargo new bookmarks
cd bookmarks
Replace the generated Cargo.toml with this one:
[package]
name = "bookmarks"
version = "1.0.0"
edition = "2024"
rust-version = "1.88"
[dependencies]
rmcp = { version = "3.6.0", features = ["server", "macros", "transport-io"] }
schemars = "1.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std", "sync"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["fmt"] }
url = "2.5"
The rmcp crate is the official Rust MCP SDK. Its three feature flags turn on what this server uses: server for the server side, macros for the #[tool] attributes, and transport-io for stdio. The other crates do the rest. schemars builds the JSON Schema for each tool's parameters, serde reads arguments and stores bookmarks as JSON, tokio runs the async server, tracing writes logs, and url checks that a saved URL is valid.
2. Write the server
Each tool needs a name, a description the model reads to decide when to call it, and a schema for its parameters. In Rust, one method marked #[tool] gives you all three.
Replace the generated src/main.rs with this file:
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use rmcp::{
ErrorData as McpError, ServerHandler, ServiceExt,
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
model::{CallToolResult, ContentBlock},
schemars, tool, tool_handler, tool_router,
transport::stdio,
};
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use tracing::info;
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Bookmark {
url: String,
title: String,
tags: Vec<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct SaveBookmarkParams {
/// Full URL of the page, including https://
url: String,
/// Short human-readable title for the page
title: String,
/// Optional lowercase tags, e.g. ["recipes", "python"]
#[serde(default)]
tags: Vec<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct SearchBookmarksParams {
/// One or more words to look for, case-insensitive
query: String,
}
fn text(value: impl Into<String>) -> CallToolResult {
CallToolResult::success(vec![ContentBlock::text(value)])
}
// A tool-level error: the tool ran, failed, and the client shows this message to the model.
fn failure(message: impl Into<String>) -> CallToolResult {
CallToolResult::error(vec![ContentBlock::text(message)])
}
fn load_bookmarks(path: &Path) -> Result<Vec<Bookmark>, String> {
match std::fs::read_to_string(path) {
Ok(contents) => serde_json::from_str(&contents)
.map_err(|e| format!("Could not parse {}: {e}", path.display())),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Vec::new()),
Err(e) => Err(format!("Could not read {}: {e}", path.display())),
}
}
fn save_bookmarks(path: &Path, bookmarks: &[Bookmark]) -> Result<(), String> {
let json = serde_json::to_string_pretty(bookmarks).map_err(|e| e.to_string())?;
std::fs::write(path, json).map_err(|e| format!("Could not write {}: {e}", path.display()))
}
#[derive(Clone)]
struct BookmarksServer {
tool_router: ToolRouter<Self>,
file: PathBuf,
// Serializes read-modify-write cycles so two saves can't overwrite each other.
write_lock: Arc<Mutex<()>>,
}
#[tool_router]
impl BookmarksServer {
fn new(file: PathBuf) -> Self {
Self {
tool_router: Self::tool_router(),
file,
write_lock: Arc::new(Mutex::new(())),
}
}
#[tool(
description = "Save a web page to the user's bookmarks. Use this when the user asks to save, bookmark or remember a link."
)]
async fn save_bookmark(
&self,
Parameters(params): Parameters<SaveBookmarkParams>,
) -> Result<CallToolResult, McpError> {
if let Err(e) = url::Url::parse(¶ms.url) {
return Ok(failure(format!("'{}' is not a valid URL: {e}", params.url)));
}
let _guard = self.write_lock.lock().await;
let mut bookmarks = match load_bookmarks(&self.file) {
Ok(bookmarks) => bookmarks,
Err(message) => return Ok(failure(message)),
};
if bookmarks.iter().any(|b| b.url == params.url) {
return Ok(text(format!("Already saved: {}", params.url)));
}
bookmarks.push(Bookmark {
url: params.url.clone(),
title: params.title.clone(),
tags: params.tags,
});
if let Err(message) = save_bookmarks(&self.file, &bookmarks) {
return Ok(failure(message));
}
info!("Saved bookmark {}", params.url);
Ok(text(format!(
"Saved '{}'. Total bookmarks: {}",
params.title,
bookmarks.len()
)))
}
#[tool(description = "Search saved bookmarks by words in the title, URL or tags.")]
async fn search_bookmarks(
&self,
Parameters(params): Parameters<SearchBookmarksParams>,
) -> Result<CallToolResult, McpError> {
let bookmarks = match load_bookmarks(&self.file) {
Ok(bookmarks) => bookmarks,
Err(message) => return Ok(failure(message)),
};
let words: Vec<String> = params
.query
.to_lowercase()
.split_whitespace()
.map(str::to_owned)
.collect();
let matches: Vec<&Bookmark> = bookmarks
.iter()
.filter(|b| {
let mut fields = vec![b.title.as_str(), b.url.as_str()];
fields.extend(b.tags.iter().map(String::as_str));
let haystack = fields.join(" ").to_lowercase();
words.iter().all(|w| haystack.contains(w.as_str()))
})
.collect();
if matches.is_empty() {
return Ok(text(format!("No bookmarks match '{}'.", params.query)));
}
let lines: Vec<String> = matches
.iter()
.map(|b| format!("- {}: {}", b.title, b.url))
.collect();
Ok(text(lines.join("\n")))
}
}
#[tool_handler(router = self.tool_router, name = "bookmarks", version = "1.0.0")]
impl ServerHandler for BookmarksServer {}
// BOOKMARKS_FILE overrides the default location in the home directory.
fn bookmarks_file() -> PathBuf {
if let Some(path) = std::env::var_os("BOOKMARKS_FILE") {
return PathBuf::from(path);
}
let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_default();
home.join(".bookmarks-mcp.json")
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Logs go to stderr. Anything written to stdout corrupts the stdio transport.
tracing_subscriber::fmt()
.with_writer(io::stderr)
.with_ansi(false)
.init();
let server = BookmarksServer::new(bookmarks_file());
let service = server.serve(stdio()).await?;
service.waiting().await?;
Ok(())
}
A few things in this file do the work:
#[tool_router]collects every method marked#[tool]. The method name becomes the tool name, and thedescriptionargument becomes the tool description.Parameters<SaveBookmarkParams>parses the arguments into a struct. The struct derivesschemars::JsonSchema, so each field's doc comment becomes the description the model sees for that parameter.CallToolResult::errorreports a failure the model should see, such as a URL that doesn't parse. The model gets the message and can try again.McpErroris for requests the server can't process at all, and most clients don't show its message to the model.#[tool_handler(router = self.tool_router, ...)]implementsServerHandlerfrom the router, so the server answerstools/listandtools/call.serve(stdio())starts the server on stdin and stdout and waits until the client disconnects.
The lock in write_lock makes each save a read, a check and a write, so two saves that arrive together can't overwrite each other.
Over stdio, standard output carries the protocol messages. A stray println! puts text that isn't a protocol message into that stream, and the client may report an error or disconnect. The file sets up tracing to write to stderr. Use eprintln! or tracing for anything you want to see while the server runs.
3. Test it with MCP Inspector
MCP Inspector connects to your server the same way an AI app does. Build the binary, then list the tools with the current protocol revision:
cargo build --release
npx @modelcontextprotocol/inspector --cli ./target/release/bookmarks --protocol-era modern --method tools/list
You should see both tools, each with the parameter descriptions you wrote. Trimmed:
{
"tools": [
{
"name": "save_bookmark",
"description": "Save a web page to the user's bookmarks. Use this when the user asks to save, bookmark or remember a link.",
"inputSchema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"tags": {
"default": [],
"description": "Optional lowercase tags, e.g. [\"recipes\", \"python\"]",
"items": {
"type": "string"
},
"type": "array"
},
...
The Inspector defaults to the legacy era, which uses the older initialize handshake. Run the same command with --protocol-era legacy to confirm older apps can connect too. Both runs should list save_bookmark and search_bookmarks.
Then call a tool. This saves a page, and --format json prints the result on one line:
npx @modelcontextprotocol/inspector --cli --format json ./target/release/bookmarks --protocol-era modern \
--method tools/call --tool-name save_bookmark \
--tool-arg url=https://modelcontextprotocol.io --tool-arg title="MCP docs"
The default bookmarks file is ~/.bookmarks-mcp.json, in your home directory. Set BOOKMARKS_FILE to use another path. A failed tool call makes the Inspector exit with code 5, and --format json shows the message the tool returned.
4. Connect it to Claude Desktop
Open Claude Desktop's config file in a text editor, creating it if it doesn't exist:
| OS | Config file |
|---|---|
| macOS | ~/Library/Application Support/Claude/claude_desktop_config.json |
| Windows | %APPDATA%\Claude\claude_desktop_config.json |
| Linux | ~/.config/Claude/claude_desktop_config.json |
Add your server under the mcpServers key. Use the absolute path to the binary you built. Run pwd in the project folder to get the start of it, then add /target/release/bookmarks.
{
"mcpServers": {
"bookmarks": {
"command": "/ABSOLUTE/PATH/TO/bookmarks/target/release/bookmarks"
}
}
}
Replace /ABSOLUTE/PATH/TO/bookmarks/target/release/bookmarks with your path. Save the file and restart Claude Desktop. Ask it to bookmark a page, then ask it to find that bookmark in a new chat.
For the other apps, see Claude Code, Cursor, VS Code and Codex.
Common problems
The server doesn't appear in Claude Desktop.
Check that command is the absolute path to a binary you built with cargo build --release, and that the file exists at that path. Then restart the app. For other causes, see Fix MCP connection errors.
cargo: command not found after installing Rust.
The terminal you installed from was opened before the install finished. Open a new terminal and run cargo --version again.
The server starts, then disconnects straight away.
Something is writing to stdout. Look for println! or print! calls, including ones in code you added, and move them to eprintln! or tracing.
'modelcontextprotocol.io' is not a valid URL: relative URL without a base.
This comes from the tool, not the protocol. save_bookmark returns it when the URL has no scheme. Pass the full address, starting with https://.
The Inspector exits with code 5 and tool_is_error.
The tool ran and returned isError: true. Add --format json to see the message the tool returned.
FAQ
Is rmcp the official Rust SDK for MCP?
Yes. The rmcp crate is published from the modelcontextprotocol/rust-sdk repository, which describes itself as an official Rust Model Context Protocol SDK. This guide uses version 3.6.0.
Which Rust version do I need for rmcp?
The rmcp crate declares Rust 1.88 as its minimum. rustup installs the current stable release, which is newer than that, so a fresh install is enough.
Does a Rust MCP server work with older AI apps?
Yes. The stdio server answers the 2026-07-28 revision through server discovery and the earlier revisions through the initialize handshake. The test in this guide covers the modern era and the Inspector legacy era, which negotiates 2025-11-25.
Do I need Docker to build this server?
No. Cargo builds the server on your own machine, and rustup is the only install you need. Docker was only the test harness for this guide.
- Build an MCP server (Python and TypeScript)The same bookmarks server in Python and TypeScript.
- Build an MCP server in GoThe same server with the official Go SDK.
- Test with MCP InspectorUse the Inspector web interface to debug tools, resources and prompts.
- Tools, resources and promptsWhat else an MCP server can offer besides tools.
- Local vs remote serversWhen to move from stdio to Streamable HTTP.