Skip to main content
Version: 1.1.0

Build Your First MCP Server

The Quickstart got you a server with a single say_hello tool, matched by name in the handler. This tutorial goes deeper: multiple tools with clean dispatch via tool_box!, proper error handling, and annotations that help LLMs use your tools safely.

Note: This tutorial stays focused on tools - check the Get Started path for Resources and Prompts.

What you'll build: a weather server with two tools (current_weather, forecast) that returns typed results with proper error handling.

Prerequisites: Quickstart completed. You understand the basic server structure.

Step 1 - Create the projectโ€‹

cargo new weather-mcp && cd weather-mcp
cargo add rust-mcp-sdk@1 async-trait tokio serde serde_json

Step 2 - Define your toolsโ€‹

Tools are Rust structs. The mcp_tool macro generates the MCP Tool spec plus JSON Schemas for the struct fields:

src/tools.rs
use rust_mcp_sdk::macros;

#[macros::mcp_tool(
name = "current_weather",
description = "Get the current weather for a city",
read_only_hint = true,
)]
#[derive(Debug, serde::Deserialize, serde::Serialize, macros::JsonSchema)]
pub struct CurrentWeatherTool {
/// City name (e.g. "London", "Tokyo")
pub city: String,
/// Temperature unit
#[serde(default = "default_unit")]
pub unit: String,
}

fn default_unit() -> String { "celsius".into() }

#[macros::mcp_tool(
name = "forecast",
description = "Get a 5-day weather forecast for a city",
read_only_hint = true,
)]
#[derive(Debug, serde::Deserialize, serde::Serialize, macros::JsonSchema)]
pub struct ForecastTool {
/// City name
pub city: String,
/// Number of days to forecast (1โ€“5)
pub days: u32,
}

Step 3 - Write the tool logicโ€‹

Each tool struct gets call_tool() - the actual work. To keep the tutorial self-contained, the responses are hard-coded - no real weather service is called. Swap in your favorite weather API where noted if you want live data:

src/tools.rs (continued)
use rust_mcp_sdk::schema::{CallToolResult, schema_utils::CallToolError};

impl CurrentWeatherTool {
pub fn call_tool(&self) -> Result<CallToolResult, CallToolError> {
// In a real app, call a weather API here
if self.city.is_empty() {
return Err(CallToolError::from_message("City name is required"));
}

let temp = match self.unit.as_str() {
"fahrenheit" => 75.0,
_ => 24.0,
};
let unit_label = if self.unit == "fahrenheit" { "ยฐF" } else { "ยฐC" };

Ok(CallToolResult::text_content(vec![format!(
"Current weather in {}: {}{}, sunny",
self.city, temp, unit_label
)
.into()]))
}
}

impl ForecastTool {
pub fn call_tool(&self) -> Result<CallToolResult, CallToolError> {
if self.days < 1 || self.days > 5 {
return Err(CallToolError::from_message(
"Days must be between 1 and 5",
));
}

let lines: Vec<String> = (1..=self.days)
.map(|d| format!(" Day {d}: {}ยฐ, partly cloudy", 20 + d))
.collect();

Ok(CallToolResult::text_content(vec![format!(
"{}-day forecast for {}:\n{}",
self.days,
self.city,
lines.join("\n")
)
.into()]))
}
}

Step 4 - Organize with tool_box!โ€‹

When you have multiple tools, use tool_box! to generate a dispatch enum:

src/tools.rs (continued)
use rust_mcp_sdk::tool_box;

tool_box!(WeatherTools, [CurrentWeatherTool, ForecastTool]);

This generates:

  • WeatherTools::tools() โ†’ Vec<Tool> for listing
  • WeatherTools::try_from(CallToolRequestParams) โ†’ parses and dispatches to the right variant

Step 5 - Write the handlerโ€‹

The handler just delegates to tool_box!:

src/handler.rs
use async_trait::async_trait;
use rust_mcp_sdk::{
mcp_server::ServerHandler,
schema::{ListToolsResult, PaginatedRequestParams, RpcError, schema_utils::CallToolError, CallToolRequestParams, CallToolResult},
McpServer,
};
use std::sync::Arc;
use crate::tools::WeatherTools;

pub struct WeatherHandler;

#[async_trait]
impl ServerHandler for WeatherHandler {
async fn handle_list_tools_request(
&self,
_request: Option<PaginatedRequestParams>,
_runtime: Arc<dyn McpServer>,
) -> Result<ListToolsResult, RpcError> {
Ok(ListToolsResult {
tools: WeatherTools::tools(),
meta: None,
next_cursor: None,
})
}

async fn handle_call_tool_request(
&self,
params: CallToolRequestParams,
_runtime: Arc<dyn McpServer>,
) -> Result<CallToolResult, CallToolError> {
// try_from matches the tool name, parses its arguments and returns
// CallToolError for unknown tools or invalid arguments
let tool: WeatherTools = WeatherTools::try_from(params)?;

match tool {
WeatherTools::CurrentWeatherTool(t) => t.call_tool(),
WeatherTools::ForecastTool(t) => t.call_tool(),
}
}
}

Step 6 - Start the serverโ€‹

src/main.rs
mod tools;
mod handler;

use rust_mcp_sdk::{
error::SdkResult,
mcp_server::{server_runtime, McpServerOptions},
schema::{
Implementation, InitializeResult, ProtocolVersion, ServerCapabilities,
ServerCapabilitiesTools,
},
McpServer, StdioTransport, ToMcpServerHandler, TransportOptions,
};
use handler::WeatherHandler;

#[tokio::main]
async fn main() -> SdkResult<()> {
// server name, version and capabilities
let server_details = InitializeResult {
server_info: Implementation {
name: "weather-mcp".into(),
version: "1.0.0".into(),
description: Some("A weather server with current conditions and forecasts".into()),
icons: vec![],
website_url: None,
title: Some("Weather MCP Server".into()),
},
capabilities: ServerCapabilities {
tools: Some(ServerCapabilitiesTools::default()),
..Default::default()
},
protocol_version: ProtocolVersion::V2025_11_25.into(),
instructions: None,
meta: None,
};

let transport = StdioTransport::new(TransportOptions::default())?;

// instantiate our custom handler for handling MCP messages
let handler = WeatherHandler {};

// create the MCP server
let server = server_runtime::create_server(McpServerOptions {
server_details,
transport,
handler: handler.to_mcp_server_handler(),
task_store: None,
client_task_store: None,
message_observer: None,
});

// Start the server
server.start().await
}

Step 7 - Test itโ€‹

Test your server with the MCP Inspector:

Build the release binary, then pass its absolute path to the Inspector:

cargo build --release
npx -y @modelcontextprotocol/inspector@latest /Users/Shared/weather-mcp/target/release/weather-mcp

In the MCP Inspector you'll see two tools: current_weather and forecast. Try providing bad input - a forecast for 8 days, say - and the server returns a clear error message.

Weather tools in the MCP Inspector

What you learnedโ€‹

  • Multiple tools with mcp_tool + tool_box! for clean dispatch
  • Validation - return CallToolError for bad input
  • Annotations - read_only_hint = true tells clients these are safe to call without confirmation
  • Organization - tools in their own module, handler delegates to tool_box!

Where nextโ€‹