Build MCP servers
How to build an MCP server in Rust
A notes server in Rust with the official SDK, running over stdio and Streamable HTTP, checked with the SDK's own client and set up for Claude Code.
New to MCP? What is an MCP server? covers the basics first.
In Rust, the official SDK rmcp uses #[tool] macros to turn an impl block into an MCP server, and how you return an error decides whether it reaches the client as a tool result or as a protocol error. The notes server below has three tools. list_notes and read_note only read, and add_note writes a file, which matters later when deciding which agents get which tools.
Prerequisites
- Rust 1.88 or newer (I used 1.98.0), installed with
rustupor your package manager. - Claude Code, to try the server there in step 5.
Step 1: Create the project
Make a new binary crate and add the SDK:
cargo new notes-server
cd notes-server
cargo add rmcp --features server,transport-io,transport-child-process,transport-streamable-http-server,transport-streamable-http-client-reqwest,client
cargo add tokio --features full
cargo add serde --features derive
cargo add serde_json
cargo add axum
cargo add anyhowCheck the version cargo add picked:
cargo tree -p rmcpThat printed rmcp v3.5.1. The feature list is long because this crate builds every piece used below: server and its macros, transport-io and transport-child-process for stdio, and transport-streamable-http-* for step 4's HTTP version. client pulls in the SDK's own client, used in steps 3 and 4 to test the server, which a deployed server does not need.
The project ends up with four binaries sharing one library crate. Point Cargo at them in Cargo.toml:
[[bin]]
name = "stdio"
path = "src/bin/stdio.rs"
[[bin]]
name = "client"
path = "src/bin/client.rs"
[[bin]]
name = "http"
path = "src/bin/http.rs"
[[bin]]
name = "http_client"
path = "src/bin/http_client.rs"Delete the src/main.rs that cargo new creates. Tool logic goes in src/lib.rs; each binary under src/bin/ is just a transport wired to it.
Step 2: Write the tools with #[tool] macros
Create src/lib.rs:
use std::env;
use std::path::{Path, PathBuf};
use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::{CallToolResult, ContentBlock};
use rmcp::{ErrorData as McpError, ServerHandler, schemars, tool, tool_handler, tool_router};
/// Map a title to a file inside the notes directory, and refuse anything
/// that would escape it.
fn slugify(title: &str) -> String {
let mut slug = String::new();
let mut last_dash = false;
for c in title.to_lowercase().chars() {
if c.is_ascii_alphanumeric() {
slug.push(c);
last_dash = false;
} else if !last_dash {
slug.push('-');
last_dash = true;
}
}
slug.trim_matches('-').to_string()
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct ReadNoteParams {
/// Title of the note to read.
title: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct AddNoteParams {
/// Title of the note to save.
title: String,
/// Body text of the note.
body: String,
}
#[derive(Clone)]
pub struct Notes {
dir: PathBuf,
}
impl Notes {
pub fn new(dir: impl AsRef<Path>) -> Self {
let dir = dir.as_ref().to_path_buf();
std::fs::create_dir_all(&dir).expect("failed to create notes directory");
Self { dir }
}
pub fn from_env() -> Self {
let dir = env::var("NOTES_DIR").unwrap_or_else(|_| "notes".to_string());
Self::new(dir)
}
fn note_path(&self, title: &str) -> Result<PathBuf, CallToolResult> {
let slug = slugify(title);
if slug.is_empty() {
return Err(CallToolResult::error(vec![ContentBlock::text(
"A note needs a title with at least one letter or number.",
)]));
}
Ok(self.dir.join(format!("{slug}.md")))
}
}
#[tool_router]
impl Notes {
#[tool(
description = "List the titles of every saved note.",
annotations(read_only_hint = true)
)]
fn list_notes(&self) -> Result<CallToolResult, McpError> {
let mut titles: Vec<String> = std::fs::read_dir(&self.dir)
.map_err(|e| McpError::internal_error(e.to_string(), None))?
.filter_map(|entry| entry.ok())
.filter_map(|entry| {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("md") {
path.file_stem().map(|s| s.to_string_lossy().into_owned())
} else {
None
}
})
.collect();
titles.sort();
let text = serde_json::to_string(&titles).unwrap_or_default();
Ok(CallToolResult::success(vec![ContentBlock::text(text)]))
}
#[tool(
description = "Return the text of one note.",
annotations(read_only_hint = true)
)]
fn read_note(
&self,
Parameters(ReadNoteParams { title }): Parameters<ReadNoteParams>,
) -> Result<CallToolResult, McpError> {
let path = match self.note_path(&title) {
Ok(path) => path,
Err(result) => return Ok(result),
};
if !path.exists() {
return Ok(CallToolResult::error(vec![ContentBlock::text(format!(
"No note called '{title}'."
))]));
}
let text = std::fs::read_to_string(&path)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![ContentBlock::text(text)]))
}
#[tool(
description = "Save a note. Fails if a note with that title already exists.",
annotations(read_only_hint = false, destructive_hint = false)
)]
fn add_note(
&self,
Parameters(AddNoteParams { title, body }): Parameters<AddNoteParams>,
) -> Result<CallToolResult, McpError> {
let path = match self.note_path(&title) {
Ok(path) => path,
Err(result) => return Ok(result),
};
if path.exists() {
return Ok(CallToolResult::error(vec![ContentBlock::text(format!(
"A note called '{title}' already exists."
))]));
}
std::fs::write(&path, &body).map_err(|e| McpError::internal_error(e.to_string(), None))?;
let stem = path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
Ok(CallToolResult::success(vec![ContentBlock::text(format!(
"Saved {stem}."
))]))
}
}
#[tool_handler(name = "notes", version = "1.0.0")]
impl ServerHandler for Notes {}#[tool_router] reads the impl block and builds the dispatch table; #[tool_handler] generates call_tool and list_tools on ServerHandler from that table, taking the server's advertised name and version. #[tool_router(server_handler)] folds both into one pass when there is nothing else to configure; this uses the two-macro form because tool_handler needs the name and version arguments.
The parameter struct is the schema. #[derive(schemars::JsonSchema)] turns ReadNoteParams into the JSON Schema clients see for read_note, and Parameters<T> deserializes the call's arguments into it before your function runs. Each field's doc comment becomes its description in the schema; the tool's own description comes from the #[tool] attribute.
note_path never lets a title reach the filesystem as typed. slugify turns every title into a lowercase run of letters, digits, and single dashes, so ../../etc/passwd becomes etc-passwd.md inside the notes directory. Treat tool arguments like form input on a website: a model's arguments may come from a document it just read.
Two kinds of failure, two return paths. Result<CallToolResult, McpError> gives you both, and they differ: Err(McpError) is a protocol-level error, kept as Err by the SDK's conversion, so the client renders it opaquely and the caller never sees your message. Ok(CallToolResult::error(..)) is the one the model reads, used here for every outcome the caller should act on: an empty title, a missing note, a duplicate note. read_note and add_note route the first case through note_path's own Result<PathBuf, CallToolResult>, writing the empty-title message once.
Annotations are hints, not enforcement. read_only_hint: true tells a client this tool changes nothing, which it can use when deciding what to confirm with you. Nothing in the protocol checks that the function keeps that promise.
Step 3: Test it with a client
Create src/bin/stdio.rs:
use notes_server::Notes;
use rmcp::ServiceExt;
use rmcp::transport::stdio;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let server = Notes::from_env().serve(stdio()).await?;
server.waiting().await?;
Ok(())
}serve(stdio()) carries the protocol over standard input and output, the same way Claude Code launches it. Standard output is the protocol channel, so anything meant for a human must go to standard error instead; this server prints nothing else.
Then a client to drive it. Create src/bin/client.rs:
use rmcp::ServiceExt;
use rmcp::model::CallToolRequestParams;
use rmcp::transport::{ConfigureCommandExt, TokioChildProcess};
use tokio::process::Command;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let transport = TokioChildProcess::new(Command::new("cargo").configure(|cmd| {
cmd.arg("run").arg("--quiet").arg("--bin").arg("stdio");
}))?;
let client = ().serve(transport).await?;
let tools = client.list_all_tools().await?;
let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
println!("{names:?}");
client
.call_tool(CallToolRequestParams::new("add_note").with_arguments(rmcp::object!({
"title": "Release checklist",
"body": "Tag, build, announce.",
})))
.await?;
let listed = client
.call_tool(CallToolRequestParams::new("list_notes"))
.await?;
println!("{:?}", listed.content);
let note = client
.call_tool(
CallToolRequestParams::new("read_note")
.with_arguments(rmcp::object!({ "title": "release checklist" })),
)
.await?;
println!("{:?}", note.content);
let dup = client
.call_tool(CallToolRequestParams::new("add_note").with_arguments(rmcp::object!({
"title": "Release checklist",
"body": "again",
})))
.await?;
println!("{:?} {:?}", dup.is_error, dup.content);
client.cancel().await?;
Ok(())
}Run it:
cargo run --quiet --bin client["add_note", "list_notes", "read_note"]
[Text(TextContent { text: "[\"release-checklist\"]", meta: None, annotations: None })]
[Text(TextContent { text: "Tag, build, announce.", meta: None, annotations: None })]
Some(true) [Text(TextContent { text: "A note called 'Release checklist' already exists.", meta: None, annotations: None })]().serve(transport) is a client with no handlers of its own; the unit type () implements the client side of ServiceExt for this case. TokioChildProcess spawns cargo run --bin stdio and talks to it over stdin and stdout, same as Claude Code spawns the compiled binary. Underneath, .serve() still runs the classic initialize / notifications/initialized handshake before the first tools/list; the next step shows which protocol version that negotiates to.
Step 4: Serve it over Streamable HTTP with axum
Create src/bin/http.rs:
use notes_server::Notes;
use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let config = StreamableHttpServerConfig::default();
let service = StreamableHttpService::new(
|| Ok(Notes::from_env()),
LocalSessionManager::default().into(),
config,
);
let router = axum::Router::new().nest_service("/mcp", service);
let listener = tokio::net::TcpListener::bind("127.0.0.1:8000").await?;
eprintln!("notes server on http://127.0.0.1:8000/mcp");
axum::serve(listener, router).await?;
Ok(())
}cargo run --quiet --release --bin httpnotes server on http://127.0.0.1:8000/mcpStreamableHttpService is a Tower service, so nest_service mounts it on an ordinary axum::Router like any other handler. It validates the Host header on every request with no extra configuration, blocking DNS rebinding: a web page can point its own domain at 127.0.0.1, but a request arriving with a non-local Host gets rejected before it reaches a tool.
curl -s -o /dev/null -w "%{http_code}\n" -H "Host: evil.example" -X POST http://127.0.0.1:8000/mcp403With the HTTP server still running, and release-checklist.md already in the shared notes/ directory from step 3, point the client at the URL instead. Create src/bin/http_client.rs:
use rmcp::ServiceExt;
use rmcp::model::CallToolRequestParams;
use rmcp::transport::StreamableHttpClientTransport;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let transport = StreamableHttpClientTransport::from_uri("http://127.0.0.1:8000/mcp");
let client = ().serve(transport).await?;
let dup = client
.call_tool(CallToolRequestParams::new("add_note").with_arguments(rmcp::object!({
"title": "Release checklist",
"body": "again",
})))
.await?;
println!("{:?} {:?}", dup.is_error, dup.content);
let odd = client
.call_tool(
CallToolRequestParams::new("read_note")
.with_arguments(rmcp::object!({ "title": "../../etc/passwd" })),
)
.await?;
println!("{:?} {:?}", odd.is_error, odd.content);
client.cancel().await?;
Ok(())
}cargo run --quiet --release --bin http_clientSome(true) [Text(TextContent { text: "A note called 'Release checklist' already exists.", meta: None, annotations: None })]
Some(true) [Text(TextContent { text: "No note called '../../etc/passwd'.", meta: None, annotations: None })]The duplicate came back the same way it did over stdio, and the path that tried to climb out of the notes directory found nothing, since it was looked up as etc-passwd.md inside it.
Sending the handshake by hand with curl shows exactly what protocol version this gets negotiated down to:
curl -s -X POST http://127.0.0.1:8000/mcp \
-H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2026-07-28","capabilities":{},"clientInfo":{"name":"curl-test","version":"1.0.0"}}}'data:
id: 0
retry: 3000
data: {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{}},"serverInfo":{"name":"notes","version":"1.0.0"}}}The reply is an event stream. The first event is empty apart from id: 0 and retry: 3000: rmcp sends it so a client that drops the connection has an event ID to resume from and knows to wait three seconds before reconnecting. The second event carries the response.
Even asking for 2026-07-28 by name, the server answers 2025-11-25. That spec dropped the connection-scoped initialize handshake for stateless servers in favor of per-request metadata, but initialize is still the only lifecycle ServiceExt::serve() speaks in this release of rmcp, and 2025-11-25 is the newest version that still has it. The README documents a separate ClientLifecycleMode::Discover for the newer handshake, unused here since the SDK's default client and server skip it too.
The server binds to 127.0.0.1, so only your machine can reach it. Put authentication in front before it goes anywhere public, or it will run its tools for anyone who finds the URL.
Step 5: Add it to Claude Code
From the project folder, build a release binary and register the stdio version for this project:
cargo build --release --bin stdio
claude mcp add --scope project notes -- "$PWD/target/release/stdio"That writes a .mcp.json file in the folder:
{
"mcpServers": {
"notes": {
"type": "stdio",
"command": "/path/to/notes-server/target/release/stdio",
"args": [],
"env": {}
}
}
}claude mcp get notes shows it pending approval: Claude Code asks before running a project server, since cloning a committed .mcp.json shouldn't run a binary on your machine. Start claude, approve it, and ask it to save a note.
To use the HTTP version instead, with the server from step 4 running:
claude mcp add --scope project --transport http notes http://127.0.0.1:8000/mcpClaude Code reads its servers at session start, so open a new session after adding one.
Step 6: Put it behind Statio
Statio is a gateway that grants tools one at a time, so list_notes and read_note can go to the agents that look things up and add_note only to the one that writes. Connect the HTTP version by URL on any plan, or from the Team plan up give Statio a Docker image to run.
The setup is in Connect a client and Permissions, and MCP permissions compares the other ways to limit an agent's tools.
Conclusion
You now have a Rust MCP server that runs over stdio or Streamable HTTP and is set up for Claude Code. In rmcp the way you return a failure decides where it lands. Ok(CallToolResult::error(..)) is a tool result the model can read and act on, and Err(McpError) goes back as a JSON-RPC error instead.
Where to go next:
- How to build an MCP server in Python, the same server in Python.
- How to build an MCP server in TypeScript, the same server in TypeScript.
- How to build an MCP server in Go, the same server in Go.
- How to turn an OpenAPI spec into an MCP server, for when the API already exists.
- How to add MCP servers to Claude Code and how to set up MCP servers in Cursor, for the client side.
- How to give an AI agent read-only GitHub access, the same read and write split applied to a server you did not build.