I've written MCP servers in Python and TypeScript before. They're productive and have great SDK support. But when I started thinking about production workloads — servers that get called thousands of times per minute by AI agents — Rust became the obvious choice. Zero-cost abstractions, no runtime overhead, memory safety without a GC, and async built on Tokio. The official Rust SDK (rmcp) is mature, well-maintained, and sits at over 3k stars on GitHub.
This article walks through building a real MCP server from scratch using rmcp, the official Rust MCP SDK. I'll build a DNS lookup server — something practical that I've actually needed in my tooling.
What We're Building
An MCP server that exposes a single tool: dns_lookup. Given a hostname, it returns the resolved IP addresses. Simple, useful, and a good vehicle for learning all the moving parts.
The server will:
Run over stdio (suitable for use with Claude Desktop, VS Code Copilot, and any MCP host)
Expose one tool with typed input parameters
Return structured text results
Handle errors cleanly with proper McpError types
Prerequisites
# Rust stable (1.80+)rustupupdatestable# Check the toolchainrustc--versioncargo--version
Project Setup
Add the dependencies to Cargo.toml:
Key dependencies explained:
rmcp — the official MCP Rust SDK with the server feature enabled
tokio — async runtime (rmcp requires it)
schemars — generates JSON Schema from Rust types (used for tool input schema)
serde / serde_json — serialization for JSON-RPC messages
tracing — structured logging (write to stderr, never stdout — stdout is the transport)
Core Concepts Before We Write Code
The rmcp SDK is built around three key traits:
Trait
What it does
ServerHandler
Defines server identity and capabilities via get_info()
#[tool_router] macro
Registers methods as MCP tools using #[tool(...)] attribute
#[tool_handler] macro
Wires the router into the ServerHandler implementation
ServiceExt::serve()
Starts the server over a transport (stdio, HTTP, etc.)
The macros generate the JSON Schema for your tool inputs from your Rust types automatically via schemars. This means your tool's inputSchema in the MCP protocol is always in sync with your actual function signature.
Implementation
Step 1: Define the Input Type
The #[derive(JsonSchema)] on DnsLookupInput is what allows rmcp to generate the correct inputSchema JSON automatically for the tool registration — no manual schema writing needed.
Step 2: Define the Server Struct
The ToolRouter<Self> field is a compile-time generated registry of all #[tool]-annotated methods on this struct. The #[tool_router] macro on the impl block populates it.
Step 3: Implement the Tool
Step 4: Implement ServerHandler
The #[tool_handler] macro wires the ToolRouter into the ServerHandler trait automatically. You only need to implement get_info() manually — this is where you declare your server's identity and capabilities.
Step 5: Wire Up the Entry Point
Full src/main.rs
Building and Testing
Test with MCP Inspector
The MCP Inspector is the fastest way to manually test any MCP server:
Then in the inspector UI, call dns_lookup with {"hostname": "github.com"}.
Test Manually via stdin
Since the protocol is JSON-RPC 2.0 over stdio, you can test directly:
Integrating with Claude Desktop
Add to your Claude Desktop config (~/Library/Application Support/Claude/claude_desktop_config.json on macOS):
Restart Claude Desktop and ask: "What are the IP addresses for github.com?"
Integrating with VS Code Copilot
Add .vscode/mcp.json to your project:
Adding a Resource
To expose a read-only resource (e.g., a list of common DNS servers), implement read_resource on ServerHandler:
Key Patterns to Remember
Always log to stderr — stdout is the JSON-RPC transport wire. Any stray println! will corrupt the protocol framing.
#[tool_router] + #[tool_handler] go together — the router macro registers tools; the handler macro wires them into ServerHandler.
Parameters<T> wraps input deserialization — your tool args are automatically deserialized from the JSON-RPC arguments field into your type.
JsonSchema on input types = automatic inputSchema — the MCP client discovers what parameters your tool expects directly from your Rust type.
service.waiting().await? — this blocks until the MCP host closes the connection. Don't exit early.
What's Next
Add multiple tools to the same server
Add Resources to expose static or dynamic data
Add Prompts for reusable LLM interaction templates
Switch to Streamable HTTP transport for a remote server deployment
[package]
name = "mcp-dns-server"
version = "0.1.0"
edition = "2021"
[dependencies]
rmcp = { version = "0.16", features = ["server"] }
tokio = { version = "1", features = ["full"] }
schemars = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
// src/main.rs
use rmcp::{
ErrorData as McpError,
ServerHandler, ServiceExt,
handler::server::tool::ToolRouter,
model::*,
tool, tool_handler, tool_router,
transport::stdio,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::net::ToSocketAddrs;
use std::sync::Arc;
use tracing_subscriber::EnvFilter;
/// Input schema for the dns_lookup tool.
/// schemars derives the JSON Schema automatically.
#[derive(Debug, Deserialize, JsonSchema)]
pub struct DnsLookupInput {
/// The hostname to resolve (e.g., "example.com")
pub hostname: String,
/// Optional record type hint (for display only, actual resolution uses system resolver)
#[serde(default = "default_record_type")]
pub record_type: String,
}
fn default_record_type() -> String {
"A".to_string()
}
/// Our MCP server struct.
/// The tool_router field is required by the #[tool_router] macro.
#[derive(Clone)]
pub struct DnsServer {
tool_router: ToolRouter<Self>,
}
/// The #[tool_router] macro scans this impl block for #[tool] methods
/// and registers them with their JSON Schema input types.
#[tool_router]
impl DnsServer {
pub fn new() -> Self {
Self {
tool_router: Self::tool_router(),
}
}
/// DNS lookup tool — resolves a hostname to IP addresses.
#[tool(
description = "Resolve a hostname to its IP addresses using the system DNS resolver"
)]
async fn dns_lookup(
&self,
// Parameters<T> wraps deserialization of the incoming JSON arguments
rmcp::handler::server::tool::Parameters(input): rmcp::handler::server::tool::Parameters<DnsLookupInput>,
) -> Result<CallToolResult, McpError> {
// Use Rust's standard library DNS resolution
// ToSocketAddrs requires a port; we use 0 as a placeholder
let addr_str = format!("{}:0", input.hostname);
match addr_str.to_socket_addrs() {
Ok(addrs) => {
let ips: Vec<String> = addrs
.map(|a| a.ip().to_string())
.collect::<std::collections::HashSet<_>>() // deduplicate
.into_iter()
.collect();
if ips.is_empty() {
return Ok(CallToolResult::success(vec![Content::text(format!(
"No addresses found for {}",
input.hostname
))]));
}
let result = format!(
"DNS lookup for {} (type: {}):\n{}",
input.hostname,
input.record_type,
ips.join("\n")
);
Ok(CallToolResult::success(vec![Content::text(result)]))
}
Err(e) => {
// McpError carries an error code and message back to the MCP client
Err(McpError::new(
rmcp::model::ErrorCode::INTERNAL_ERROR,
format!("DNS resolution failed for '{}': {}", input.hostname, e),
None,
))
}
}
}
}
/// #[tool_handler] implements the tool dispatch methods on ServerHandler
/// by delegating to the ToolRouter registered in #[tool_router].
#[tool_handler]
impl ServerHandler for DnsServer {
fn get_info(&self) -> ServerInfo {
ServerInfo {
// Declare that this server exposes tools
capabilities: ServerCapabilities::builder()
.enable_tools()
.build(),
// Server identity shown during capability negotiation
server_info: Implementation {
name: "mcp-dns-server".into(),
version: env!("CARGO_PKG_VERSION").into(),
},
instructions: Some(
"DNS lookup server. Use the dns_lookup tool to resolve hostnames to IP addresses."
.into(),
),
..Default::default()
}
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// IMPORTANT: Log to stderr — stdout is reserved for the MCP stdio transport.
// Anything written to stdout will corrupt the JSON-RPC framing.
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::from_default_env()
.add_directive(tracing::Level::INFO.into()),
)
.with_writer(std::io::stderr)
.with_ansi(false) // Disable colors for clean log output in hosts
.init();
tracing::info!("Starting mcp-dns-server");
// stdio() returns (tokio::io::stdin(), tokio::io::stdout())
// This is the standard transport for local MCP servers.
let service = DnsServer::new()
.serve(stdio())
.await
.inspect_err(|e| {
tracing::error!("Server startup error: {:?}", e);
})?;
// Wait for the MCP host to close the connection
service.waiting().await?;
Ok(())
}
use rmcp::{
ErrorData as McpError,
ServerHandler, ServiceExt,
handler::server::tool::{Parameters, ToolRouter},
model::*,
tool, tool_handler, tool_router,
transport::stdio,
};
use schemars::JsonSchema;
use serde::Deserialize;
use std::net::ToSocketAddrs;
use tracing_subscriber::EnvFilter;
#[derive(Debug, Deserialize, JsonSchema)]
pub struct DnsLookupInput {
/// The hostname to resolve
pub hostname: String,
#[serde(default = "default_record_type")]
pub record_type: String,
}
fn default_record_type() -> String {
"A".to_string()
}
#[derive(Clone)]
pub struct DnsServer {
tool_router: ToolRouter<Self>,
}
#[tool_router]
impl DnsServer {
pub fn new() -> Self {
Self {
tool_router: Self::tool_router(),
}
}
#[tool(description = "Resolve a hostname to its IP addresses using the system DNS resolver")]
async fn dns_lookup(
&self,
Parameters(input): Parameters<DnsLookupInput>,
) -> Result<CallToolResult, McpError> {
let addr_str = format!("{}:0", input.hostname);
match addr_str.to_socket_addrs() {
Ok(addrs) => {
let mut ips: Vec<String> = addrs
.map(|a| a.ip().to_string())
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
ips.sort();
if ips.is_empty() {
return Ok(CallToolResult::success(vec![Content::text(format!(
"No addresses found for {}",
input.hostname
))]));
}
Ok(CallToolResult::success(vec![Content::text(format!(
"DNS lookup for {} (type: {}):\n{}",
input.hostname,
input.record_type,
ips.join("\n")
))]))
}
Err(e) => Err(McpError::new(
ErrorCode::INTERNAL_ERROR,
format!("DNS resolution failed for '{}': {}", input.hostname, e),
None,
)),
}
}
}
#[tool_handler]
impl ServerHandler for DnsServer {
fn get_info(&self) -> ServerInfo {
ServerInfo {
capabilities: ServerCapabilities::builder().enable_tools().build(),
server_info: Implementation {
name: "mcp-dns-server".into(),
version: env!("CARGO_PKG_VERSION").into(),
},
instructions: Some(
"DNS lookup server. Use dns_lookup to resolve hostnames to IP addresses.".into(),
),
..Default::default()
}
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env().add_directive(tracing::Level::INFO.into()))
.with_writer(std::io::stderr)
.with_ansi(false)
.init();
tracing::info!("Starting mcp-dns-server");
let service = DnsServer::new()
.serve(stdio())
.await
.inspect_err(|e| tracing::error!("Server startup error: {:?}", e))?;
service.waiting().await?;
Ok(())
}