Building Your First MCP Server in Rust: A Calculator Service

MCP

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"] }

Architecture Overview

Our MCP server has three main components:

  1. Tool definitions – Functions that perform calculations
  2. Server handler – Manages MCP protocol communication
  3. 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:

  • JsonSchema automatically generates schema documentation for MCP
  • Deserialize allows parsing JSON arguments
  • We use f64 for 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 arguments
  • CallToolResult::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:

  • StreamableHttpService wraps our calculator as an HTTP service
  • LocalSessionManager handles 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 (~/Library/Application Support/Claude/claude_desktop_config.json on Mac):

{
  "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

  1. Claude sends a tool call request to your MCP server
  2. The HTTP transport receives the request
  3. ToolRouter routes to the appropriate method
  4. Your function executes and returns a result
  5. The response flows back to Claude
  6. 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

  1. MCP servers expose tools that AI assistants can use
  2. The rmcp crate handles protocol complexity
  3. The #[tool] and #[tool_router] macros do the heavy lifting
  4. HTTP transport makes your server accessible
  5. Error handling is crucial for robust tools

Resources


This tutorial demonstrates building a production-ready MCP server in under 100 lines of Rust code.

Rust Programming

Previous article

Zero-Sized Types (ZSTs)New!!