# Building an MCP Server from Scratch Using Rust

## Why Rust for MCP?

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](https://github.com/modelcontextprotocol/rust-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

```bash
# Rust stable (1.80+)
rustup update stable

# Check the toolchain
rustc --version
cargo --version
```

***

## Project Setup

```bash
cargo new mcp-dns-server --bin
cd mcp-dns-server
```

Add the dependencies to `Cargo.toml`:

```toml
[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"] }
```

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

```rust
// 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()
}
```

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

```rust
/// 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 `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

```rust
/// 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,
                ))
            }
        }
    }
}
```

***

### 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.

```rust
/// #[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()
        }
    }
}
```

***

### Step 5: Wire Up the Entry Point

```rust
#[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(())
}
```

***

## Full `src/main.rs`

```rust
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(())
}
```

***

## Building and Testing

```bash
cargo build --release
```

### Test with MCP Inspector

The MCP Inspector is the fastest way to manually test any MCP server:

```bash
npx @modelcontextprotocol/inspector ./target/release/mcp-dns-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:

```bash
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' | ./target/release/mcp-dns-server
```

***

## Integrating with Claude Desktop

Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS):

```json
{
  "mcpServers": {
    "dns": {
      "command": "/path/to/mcp-dns-server/target/release/mcp-dns-server",
      "args": []
    }
  }
}
```

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:

```json
{
  "servers": {
    "dns": {
      "type": "stdio",
      "command": "/path/to/mcp-dns-server/target/release/mcp-dns-server"
    }
  }
}
```

***

## Adding a Resource

To expose a read-only resource (e.g., a list of common DNS servers), implement `read_resource` on `ServerHandler`:

```rust
// Add to capabilities in get_info():
// capabilities: ServerCapabilities::builder()
//     .enable_tools()
//     .enable_resources()
//     .build(),

async fn read_resource(
    &self,
    ReadResourceRequestParams { uri, .. }: ReadResourceRequestParams,
    _context: RequestContext<RoleServer>,
) -> Result<ReadResourceResult, McpError> {
    match uri.as_str() {
        "dns://well-known-servers" => Ok(ReadResourceResult {
            contents: vec![ResourceContents::text(
                "8.8.8.8 (Google)\n1.1.1.1 (Cloudflare)\n9.9.9.9 (Quad9)",
                uri,
            )],
        }),
        _ => Err(McpError::new(
            ErrorCode::INVALID_PARAMS,
            format!("Unknown resource: {}", uri),
            None,
        )),
    }
}
```

***

## Key Patterns to Remember

1. **Always log to stderr** — stdout is the JSON-RPC transport wire. Any stray `println!` will corrupt the protocol framing.
2. **`#[tool_router]` + `#[tool_handler]` go together** — the router macro registers tools; the handler macro wires them into `ServerHandler`.
3. **`Parameters<T>` wraps input deserialization** — your tool args are automatically deserialized from the JSON-RPC `arguments` field into your type.
4. **`JsonSchema` on input types** = automatic `inputSchema` — the MCP client discovers what parameters your tool expects directly from your Rust type.
5. **`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

The official repo has more examples: [github.com/modelcontextprotocol/rust-sdk/tree/main/examples](https://github.com/modelcontextprotocol/rust-sdk/tree/main/examples)
