What you are building and which SDK to use
An MCP server exposes tools, resources, and prompts to an AI client like Claude Desktop, Claude Code, or Cursor. In Rust you build one with the official SDK, rmcp, which is async on Tokio and leans on the type system: you describe a tool's arguments as a struct, and the SDK derives the JSON Schema, validates incoming arguments, and hands your handler a typed value.
The SDK is on crates.io, so you add it like any other dependency; there's no git dependency to wire up. As of 2026-07-28, rmcp has a new major version: 3.0.x (3.0.1 at time of writing) targets that spec revision (sessionless HTTP, no initialize, MSRV Rust 1.88), and an unpinned cargo add rmcp will now pull it in. This guide's code targets the 2.x line, whose last release is 2.2.0 — still published and unyanked, but superseded by 3.x — so pin it explicitly. Create a project and add rmcp plus the runtime and serialization crates it works with:
$cargo new weather-server --bin$cd weather-server$cargo add rmcp@2.2.0 --features server,macros,transport-io$cargo add tokio --features full$cargo add serde --features derive$cargo add schemars serde_json anyhow
The server and macros features are on by default, but naming them keeps the intent obvious. transport-io is the server-side stdio transport, and you'll add the Streamable HTTP feature later when you deploy remotely. If you're starting fresh against 2026-07-28 instead, rmcp 3.0's API is different enough from what's below that this guide doesn't try to cover both — see the migration notes in the rust-sdk GitHub Discussions.
Prerequisites
- A recent stable Rust toolchain with
cargo(rustup is the easy way to get it). - Comfort with
async/awaitand Tokio, since every handler is an async fn. - An MCP client to point at the finished binary: Claude Desktop, Claude Code, or the MCP Inspector.
The smallest server that actually does something
Here's the shape of an rmcp server. You define a struct for your tool's input, write a typed handler, and register it by annotating the impl block with #[tool_router] and each method with #[tool]. The router that macro builds gets stored on your server struct, and #[tool_handler] on the ServerHandler impl wires that router into the protocol so tools/list and tools/call just work. This tool looks up a value instead of adding two numbers, because a real tool wraps something the model can't do on its own.
// src/main.rs
use rmcp::{
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
tool, tool_handler, tool_router,
ServerHandler, ServiceExt,
transport::stdio,
};
use schemars::JsonSchema;
use serde::Deserialize;
#[derive(Debug, Deserialize, JsonSchema)]
struct ForecastArgs {
/// The city to get the forecast for.
city: String,
}
#[derive(Clone)]
struct WeatherServer {
tool_router: ToolRouter<Self>,
}
#[tool_router]
impl WeatherServer {
fn new() -> Self {
Self { tool_router: Self::tool_router() }
}
#[tool(description = "Current forecast for a city")]
async fn get_forecast(&self, Parameters(args): Parameters<ForecastArgs>) -> String {
// Call a real weather API here; a canned string keeps the example runnable.
format!("Forecast for {}: 18C, clear", args.city)
}
}
#[tool_handler]
impl ServerHandler for WeatherServer {}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let service = WeatherServer::new().serve(stdio()).await?;
service.waiting().await?;
Ok(())
}Run it in development with cargo run. A few things are doing real work here. The #[tool_router] macro reads each #[tool] method and generates a Self::tool_router() constructor that returns a ToolRouter<Self>; you store its result in the tool_router field (tool macro test, v2.2.0). The Parameters<T> wrapper is what tells the SDK "this argument is the tool's input": it derives the input schema from T, deserializes and validates the incoming arguments, and unwraps to your struct. The doc comment on the city field becomes the field's schema description. You never hand-write JSON Schema.
The return type is the other half of the contract. A handler that returns String becomes a text content block, because the SDK converts anything implementing IntoContents (a String does) into a successful result (tool.rs, v2.2.0). Returning () produces an empty result. That's why the smallest useful tool is just an async fn returning a String.
You didn't set a protocol version anywhere, and that's deliberate. On the rmcp 2.2.0 line this guide pins, ServerInfo defaults to that SDK's latest supported revision, 2025-11-25, and the server negotiates down if a client asks for an older one (model.rs, v2.2.0). (Don't assume rmcp 3.0 changes that default: it adds explicit 2026-07-28 support as a separate ProtocolVersion::STANDARD_HEADERS constant, but as of 3.0.1 ProtocolVersion::LATEST — and therefore what default() advertises — is still 2025-11-25 (model.rs, v3.0.1). Check what your server actually negotiates rather than assuming a newer SDK means a newer default.) Pinning a specific version by hand is how servers get stuck advertising a revision from years ago, so leave it on the default unless you have a concrete reason not to.
Getting the error contract right
There are two very different ways a tool call can go wrong, and they map to two different return shapes. Getting this right is the single most important thing about writing a tool, because it decides whether the model can recover.
A tool error means the tool ran and the work failed: the API was down, the file wasn't there, the input was valid but produced no answer. You want the model to see that message and react to it. Return Ok(CallToolResult::error(...)), which sets is_error on the result and keeps your text in the content block (model.rs, v2.2.0). This is the right choice for almost every "the tool didn't work" case.
use rmcp::model::{CallToolResult, ContentBlock};
#[tool(description = "Fetch the forecast, or report why it failed")]
async fn get_forecast(&self, Parameters(args): Parameters<ForecastArgs>) -> CallToolResult {
match fetch_upstream(&args.city).await {
Ok(text) => CallToolResult::success(vec![ContentBlock::text(text)]),
Err(e) => CallToolResult::error(vec![ContentBlock::text(
format!("weather lookup failed: {e}"),
)]),
}
}A protocol error means the server couldn't route or run the request at all: a malformed call, an internal fault that makes the server itself unusable. Return Err(ErrorData) (the SDK re-exports it, and it's conventional to alias it as McpError) with a JSON-RPC code. Clients typically render protocol errors opaquely, so the model does not reliably see your message (model.rs, v2.2.0). Reserve it for genuinely unroutable input.
use rmcp::ErrorData as McpError;
if args.city.is_empty() {
return Err(McpError::invalid_params("city must not be empty", None));
}The convenient part is that a handler returning Result<T, McpError> does the sorting for you: Ok becomes a success, and Err(McpError) becomes a protocol error with the right JSON-RPC code (tool.rs, v2.2.0). When in doubt, prefer the tool error: an opaque protocol error is a dead end for the model.
Structured output: return data, not just a string
Tools can hand back machine-readable results alongside the text, a capability the spec added in its 2025-06-18 revision. With rmcp you wrap your return value in Json<T>: the SDK derives an output schema from T and puts the serialized value in the result's structured_content field (json.rs, v2.2.0).
use rmcp::{ErrorData as McpError, Json};
use serde::Serialize;
#[derive(Debug, Serialize, JsonSchema)]
struct Forecast {
city: String,
temp_c: f64,
summary: String,
}
#[tool(description = "Current forecast for a city, as structured data")]
async fn get_forecast_json(
&self,
Parameters(args): Parameters<ForecastArgs>,
) -> Result<Json<Forecast>, McpError> {
Ok(Json(Forecast { city: args.city, temp_c: 18.0, summary: "clear".into() }))
}Because the output type carries a JsonSchema derive, the tool advertises an outputSchema in tools/list, and clients that understand structured output get typed data. Clients that predate it still get a readable text block, since the structured result also carries a text rendering of the JSON alongside the structured field (model.rs, v2.2.0). You get both from one return value.
The two transports
The MCP spec defines exactly two transports: stdio and Streamable HTTP (MCP spec, Transports). The old two-endpoint HTTP+SSE transport from the 2024-11-05 spec is deprecated and kept only for backwards compatibility, so this guide doesn't use it.
stdio is for local use: the client launches your server as a subprocess and talks over stdin/stdout. That's the stdio() argument to serve in the first example. It's the right fit for a desktop integration or a quick local test, and it serves one client per process.
One rule comes with stdio, and it bites people: stdout is the JSON-RPC channel, so nothing else can go there. The spec says the server must not write anything to stdout that isn't a valid MCP message (MCP spec, Transports). A stray println! drops a non-JSON line into the stream, and the client hits it as a parse error that often surfaces as a dropped connection. Send diagnostics to stderr instead. The tracing crate writes to stderr by default, or you can use eprintln! for something quick.
Streamable HTTP is the transport for remote, multi-client, and production servers, and it's the one most real deployments use. It uses a single endpoint (commonly /mcp). The code below is rmcp 2.2.0's session-based Streamable HTTP: it correlates requests into sessions with an MCP-Session-Id header, minted on an initialize call. The 2026-07-28 spec revision removes both initialize and session IDs in favor of a stateless, per-request model; rmcp 3.0.x is sessionless by default under that revision, with a legacy session mode retained for clients still speaking initialize. In rmcp 2.2.0 you wrap your server in a tower service with StreamableHttpService::new, which takes a factory that builds a fresh server per session, a session manager, and a config; you then mount that service on an HTTP server like axum. Turn on the feature first, and add axum since the SDK doesn't pull it in for you: cargo add rmcp@2.2.0 --features transport-streamable-http-server and cargo add axum.
use rmcp::transport::streamable_http_server::{
StreamableHttpService, StreamableHttpServerConfig,
session::local::LocalSessionManager,
};
let service = StreamableHttpService::new(
|| Ok(WeatherServer::new()),
LocalSessionManager::default().into(),
StreamableHttpServerConfig::default(),
);
let app = axum::Router::new().nest_service("/mcp", service);
let listener = tokio::net::TcpListener::bind("127.0.0.1:8000").await?;
axum::serve(listener, app).await?;The factory closure runs once per session, so each client gets its own WeatherServer; the LocalSessionManager keeps session state in memory. One thing to add for anything public: the spec requires validating the Origin header to prevent DNS-rebinding attacks, and binding to localhost rather than all interfaces when you run locally (MCP spec, Transports). The full runnable HTTP server, including graceful shutdown, lives in the SDK's examples pinned to the tag you installed (counter_streamhttp.rs, v2.2.0).
Building a binary and connecting it to a client
For deployment, compile in release mode so the client launches an optimized binary with no toolchain present:
$cargo build --release
Test the binary in isolation with the MCP Inspector, which lists your tools and lets you call them by hand:
$npx @modelcontextprotocol/inspector ./target/release/weather-server
To run it locally over stdio, point a client at the binary. In Claude Code:
$claude mcp add weather -- /absolute/path/to/target/release/weather-server
Use an absolute path, since the client sets its own working directory. For Claude Desktop, add an entry under mcpServers in the config file (~/Library/Application Support/Claude/claude_desktop_config.json on macOS) with command set to that same absolute path. A Streamable HTTP server is reached by URL instead of a launched binary.
Secrets are worth a note here. A stdio client launches your server as a bare subprocess and doesn't forward your shell environment, so a key read from std::env::var may simply be missing at runtime. Set it explicitly in the client config's env block, or read it from a file your server opens itself, rather than assuming the ambient environment carries through.
A safer database tool
A common real tool runs a read-only query and formats the rows. The guardrail is the part worth showing: reject anything that isn't a SELECT before it reaches the database, and cap the rows you serialize so a broad query can't flood the model's context. The two error paths each do a different job here.
use rmcp::{model::{CallToolResult, ContentBlock}, ErrorData as McpError};
#[tool(description = "Run a read-only SELECT query")]
async fn query(&self, Parameters(args): Parameters<QueryArgs>) -> Result<CallToolResult, McpError> {
// Tool error: the model should see this and rewrite the query.
if !args.sql.trim_start().to_lowercase().starts_with("select") {
return Ok(CallToolResult::error(vec![ContentBlock::text(
"only SELECT queries are allowed",
)]));
}
// Protocol error: the server itself couldn't complete the request.
let rows = run_select(&self.pool, &args.sql, self.max_rows)
.await
.map_err(|e| McpError::internal_error(format!("query failed: {e}"), None))?;
Ok(CallToolResult::success(vec![ContentBlock::text(rows)]))
}A rejected non-SELECT is a tool error, since the model should see it and rewrite the query, while a database fault is a protocol error, since the server couldn't complete the request. Keep the input struct (QueryArgs) as its own #[derive(Deserialize, JsonSchema)] type, exactly like ForecastArgs, so the SQL string and any bind parameters arrive validated.
The actual query execution lives in run_select, which is where the real safety work happens. Don't interpolate untrusted values into the SQL text: current sqlx won't even let you pass a runtime String to sqlx::query without an explicit opt-out, precisely to push you toward bound parameters (sqlx::query). Bind values with .bind(...) and cap the row count as you decode, so a broad query can't overwhelm the response. The sqlx query docs cover the binding and row-decoding API in full.
Where to go next
The SDK's examples/servers directory has runnable servers for stdio, Streamable HTTP, structured output, auth, and elicitation, all pinned to the tag you installed (rust-sdk examples, v2.2.0). To build the same server in another language, see the Go, TypeScript, and Python FastMCP guides. For the session-lifecycle details of a multi-client HTTP deployment, see Configuring MCP servers for multiple simultaneous connections.
Related Guides
Building an MCP server in Go
Build an MCP server with the official Go SDK: mcp.NewServer plus the generic AddTool that infers schemas from your structs, resources, prompts, and both transports (stdio and Streamable HTTP), including the stateless model the 2026-07-28 spec revision introduced and how to turn it on in go-sdk v1.7.0.
Building an MCP server in TypeScript
Build an MCP server with the TypeScript SDK: the @modelcontextprotocol/sdk 1.x McpServer API pinned to 1.30.0, registerTool with structured output, and both transports (stdio and Streamable HTTP), plus a note on the newer split @modelcontextprotocol/server 2.0 line for the 2026-07-28 spec revision.
Building an MCP server in Python using FastMCP
Build an MCP server in Python with FastMCP, with a clear answer to the question that trips everyone up: the in-SDK MCPServer (called FastMCP before the 2026-07-28 rename) versus the standalone fastmcp package, and which one to install.