Building Your First MCP Server in Rust: A Calculator Service

MCP Server in Rust
What You’ll Learn
By the end of this tutorial, you’ll understand how to build a Model Context Protocol (MCP) server in Rust that exposes tools AI assistants can use. We’ll build a simple calculator service with four operations.
Prerequisites
- Rust installed (1.70+)
- Basic understanding of Rust syntax
- Familiarity with async/await concepts
What is MCP?
The Model Context Protocol (MCP) is a standard that allows AI assistants to interact with external tools and data sources. Think of it as a universal adapter that lets Claude or other AI models use your services.
Project Setup
First, create a new Rust project:
cargo new mcp-calculator
cd mcp-calculator
Add these dependencies to your Cargo.toml:
[dependencies]
anyhow = "1.0"
axum = "0.8.8"
rmcp = { version = "0.12.0", features = ["server", "transport-streamable-http-server"] }
schemars = "1.2.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1.48", features = ["macros", "rt-multi-thread", "signal"] }
With actix-web, you don’t need tokio’s “signal” feature because actix-web handles Ctrl+C shutdown automatically out of the box. Axum doesn’t – you have to opt into it.
Architecture Overview
Our MCP server has three main components:
- Tool definitions – Functions that perform calculations
- Server handler – Manages MCP protocol communication
- HTTP transport – Exposes the server over HTTP
Step 1: Define Input Types
Create src/calculator.rs and start with input validation:
use rmcp::schemars::JsonSchema;
use serde::Deserialize;
#[derive(Debug, Deserialize, JsonSchema)]
struct BinaryOpArgs {
a: f64,
b: f64,
}
What’s happening:
JsonSchemaautomatically generates schema documentation for MCPDeserializeallows parsing JSON arguments- We use
f64for floating-point arithmetic
Step 2: Create the Server Structure
use rmcp::handler::server::router::tool::ToolRouter;
use rmcp::model::*;
use rmcp::{ErrorData as McpError, ServerHandler, tool, tool_handler, tool_router};
#[derive(Clone)]
pub struct CalculatorServer {
tool_router: ToolRouter<Self>,
}
The ToolRouter is rmcp’s magic ingredient. It automatically discovers and routes tool calls to your methods.
Step 3: Implement Tools
Add the #[tool_router] macro and implement your calculator operations:
#[tool_router]
impl CalculatorServer {
pub fn new() -> Self {
Self {
tool_router: Self::tool_router(),
}
}
#[tool(description = "Add two numbers")]
pub async fn add(
&self,
Parameters(args): Parameters<BinaryOpArgs>,
) -> Result<CallToolResult, McpError> {
let result = args.a + args.b;
Ok(CallToolResult::success(vec![Content::text(result.to_string())]))
}
#[tool(description = "Subtract two numbers")]
pub async fn subtract(
&self,
Parameters(args): Parameters<BinaryOpArgs>,
) -> Result<CallToolResult, McpError> {
let result = args.a - args.b;
Ok(CallToolResult::success(vec![Content::text(result.to_string())]))
}
#[tool(description = "Multiply two numbers")]
pub async fn multiply(
&self,
Parameters(args): Parameters<BinaryOpArgs>,
) -> Result<CallToolResult, McpError> {
let result = args.a * args.b;
Ok(CallToolResult::success(vec![Content::text(result.to_string())]))
}
#[tool(description = "Divide two numbers")]
pub async fn divide(
&self,
Parameters(args): Parameters<BinaryOpArgs>,
) -> Result<CallToolResult, McpError> {
if args.b == 0.0 {
return Err(McpError::invalid_params("Cannot divide by zero", None));
}
let result = args.a / args.b;
Ok(CallToolResult::success(vec![Content::text(result.to_string())]))
}
}
Key concepts:
- The
#[tool]macro registers each function as an MCP tool Parameters<T>extracts and validates argumentsCallToolResult::success()wraps the response- Error handling for edge cases (division by zero)
Step 4: Implement the Server Handler
use rmcp::handler::server::wrapper::Parameters;
#[tool_handler]
impl ServerHandler for CalculatorServer {
fn get_info(&self) -> ServerInfo {
ServerInfo {
protocol_version: ProtocolVersion::V_2024_11_05,
capabilities: ServerCapabilities::builder().enable_tools().build(),
server_info: Implementation::from_build_env(),
instructions: Some("Basic calculator: add, subtract, multiply, divide".to_string())
}
}
}
The #[tool_handler] macro implements the rest of the ServerHandler trait, automatically routing tool calls using the ToolRouter.
Step 5: Set Up HTTP Transport
Create src/main.rs:
use rmcp::transport::streamable_http_server::{
StreamableHttpService, session::local::LocalSessionManager,
};
use std::net::SocketAddr;
mod calculator;
use calculator::CalculatorServer;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let addr: SocketAddr = "127.0.0.1:8766".parse()?;
println!("🧮 Calculator MCP Server running on http://127.0.0.1:8766/mcp");
let calculator = CalculatorServer::new();
let service = StreamableHttpService::new(
move || Ok(calculator.clone()),
LocalSessionManager::default().into(),
Default::default(),
);
let router = axum::Router::new().nest_service("/mcp", service);
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, router)
.with_graceful_shutdown(async {
tokio::signal::ctrl_c().await.expect("failed to listen for ctrl-c");
})
.await?;
Ok(())
}
What’s happening:
StreamableHttpServicewraps our calculator as an HTTP serviceLocalSessionManagerhandles session state- Axum serves the MCP endpoint at
/mcp - Graceful shutdown on Ctrl+C
Running Your Server
cargo run
You should see:
🧮 Calculator MCP Server running on http://127.0.0.1:8766/mcp
Testing with Claude Desktop
Add to your Claude Desktop config file:
{
"mcpServers": {
"calculator": {
"url": "http://127.0.0.1:8766/mcp"
}
}
}
Restart Claude Desktop and try:
- “Add 15 and 27”
- “What’s 144 divided by 12?”
- “Multiply 7 by 8”
How It Works
- Claude sends a tool call request to your MCP server
- The HTTP transport receives the request
ToolRouterroutes to the appropriate method- Your function executes and returns a result
- The response flows back to Claude
- Claude interprets the result in its response to the user
Common Patterns
Adding More Tools
#[tool(description = "Calculate square root")]
pub async fn sqrt(
&self,
Parameters(args): Parameters<SingleOpArgs>,
) -> Result<CallToolResult, McpError> {
if args.value < 0.0 {
return Err(McpError::invalid_params("Cannot take square root of negative number", None));
}
let result = args.value.sqrt();
Ok(CallToolResult::success(vec![Content::text(result.to_string())]))
}
Returning Structured Data
let response = json!({
"result": result,
"operation": "addition",
"timestamp": chrono::Utc::now().to_rfc3339()
});
Ok(CallToolResult::success(vec![Content::text(
serde_json::to_string(&response).unwrap()
)]))
Adding State
#[derive(Clone)]
pub struct CalculatorServer {
tool_router: ToolRouter<Self>,
history: Arc<Mutex<Vec<String>>>,
}
State is useful when:
- You need to track things across multiple calls
- You’re managing expensive resources (database connections, caches)
- You need to enforce limits (rate limiting, quotas)
- You want to provide history or context to users
Next Steps
- Add more mathematical operations (power, modulo, etc.)
- Implement calculation history
- Add support for complex numbers
- Create a statistics tool
- Build a unit conversion service
Troubleshooting
Port already in use: Change the port in main.rs to something else like 8767.
Claude can’t connect: Ensure the server is running and the URL in your config matches exactly.
Tool not appearing: Check that the #[tool] macro is applied and the function is public.
Key Takeaways
- MCP servers expose tools that AI assistants can use
- The
rmcpcrate handles protocol complexity - The
#[tool]and#[tool_router]macros do the heavy lifting - HTTP transport makes your server accessible
- Error handling is crucial for robust tools
Resources
This tutorial demonstrates building a production-ready MCP server in under 100 lines of Rust code.
