DEV Community

Cover image for From println!() Disasters to Production. Building MCP Servers in Rust
Edward Burton
Edward Burton

Posted on

From println!() Disasters to Production. Building MCP Servers in Rust

I shipped my first MCP server on a Friday. It worked perfectly in my tests. Then Claude Code started hallucinating gibberish responses, and I spent the weekend figuring out why.

The culprit? A single println!().

That debugging session taught me more about building MCP servers than any documentation ever could. So let me save you the weekend. This tutorial walks through three patterns I wish someone had shown me before I started building MCP servers in Rust: the stdio trap, typed tool schemas, and the error-as-UI pattern that changed how I think about tool design entirely.

We are building a real thing here. A code-stats MCP server with three tools that analyse your codebase. No weather APIs. No toy examples.

What MCP Actually Is (in 30 Seconds)

MCP is a JSON-RPC 2.0 protocol. It is now a Linux Foundation project with over 97 million SDK downloads. AI clients (like Claude Code) spawn your server as a child process and send JSON-RPC messages over stdin. Your server responds on stdout.

Three capability types exist: Tools (functions the AI calls), Resources (data the AI reads), and Prompts (reusable templates). We are focusing on tools today because that is where 90% of the practical value lives.

The official Rust SDK is the rmcp crate. It has crossed 4.7 million downloads on crates.io. It gives you a macro-driven API that feels native to Rust, and the resulting binary has zero runtime dependencies. No node_modules. No Python virtual environment. Just a single static binary you can drop anywhere.

Pattern 1, The stdio Trap

This is the one that ate my weekend. Let me show you why.

When your MCP server uses stdio transport, stdout is the protocol channel. Every byte written to stdout must be valid JSON-RPC. So when you drop a friendly println!("Server started!") into your main function, you have just injected garbage into the protocol stream.

The client tries to parse your log message as JSON. It fails. Depending on the client implementation, it might silently discard it, retry, or surface bizarre errors to the user. Claude Code handles it gracefully by ignoring malformed messages, but your tool responses can get swallowed in the noise.

Here is the correct setup:

#[tokio::main]
async fn main() -> Result<()> {
    // ALL logging goes to stderr. This is non-negotiable.
    tracing_subscriber::fmt()
        .with_env_filter(
            tracing_subscriber::EnvFilter::from_default_env()
                .add_directive("code_stats=info".parse()?)
        )
        .with_writer(std::io::stderr)
        .init();

    info!("Starting code-stats MCP server");

    let server = CodeStatsServer;
    let transport = rmcp::transport::io::stdio();
    let server_handle = server.serve(transport).await?;
    server_handle.waiting().await?;

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Notice .with_writer(std::io::stderr). That single line is the difference between a working MCP server and a mysterious debugging nightmare. The info!() macro now writes to stderr, which Claude Code captures separately for diagnostics.

The rule is absolute. If it goes to stdout, it must be JSON-RPC. Everything else goes to stderr. No exceptions. If you are pulling in a library that prints to stdout, you need to redirect it or find an alternative.

For deeper deployment patterns around transport and process management, the production deployment guide covers systemd integration and health checks.

Setting Up the Project

Before we hit the interesting patterns, let me get the boring bits out of the way:

cargo new code-stats
cd code-stats
Enter fullscreen mode Exit fullscreen mode

Your Cargo.toml dependencies:

[dependencies]
rmcp = { version = "0.16", features = ["server", "transport-io", "macros"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
schemars = "0.8"
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
Enter fullscreen mode Exit fullscreen mode

The rmcp features matter. server gives you the server handler trait. transport-io enables stdio. macros unlocks the #[tool] attribute macros that make everything ergonomic.

The schemars crate is the secret weapon we will explore in Pattern 2.

Pattern 2, Typed Tool Schemas with JsonSchema

Most MCP tutorials show you defining tool parameters as raw JSON objects. That works, but it throws away everything Rust is good at. The rmcp crate takes a different approach. You define a Rust struct, derive JsonSchema, and the SDK generates the parameter schema automatically.

Watch:

use schemars::JsonSchema;
use serde::Deserialize;

#[derive(Debug, Deserialize, JsonSchema)]
pub struct CountLinesInput {
    /// The directory path to search in
    pub path: String,
    /// File extension to filter by (e.g. "rs", "py", "js"). Do not include the dot.
    pub extension: String,
}
Enter fullscreen mode Exit fullscreen mode

Those doc comments? They become parameter descriptions in the JSON Schema that Claude Code sees. When the AI reads your tool definition, it gets:

{
  "path": { "type": "string", "description": "The directory path to search in" },
  "extension": { "type": "string", "description": "File extension to filter by (e.g. \"rs\", \"py\", \"js\"). Do not include the dot." }
}
Enter fullscreen mode Exit fullscreen mode

This is not just convenient. It is a fundamentally better development experience. The compiler enforces that every tool parameter has a type. The JsonSchema derive ensures the AI knows what to send. And if you change your struct, the schema updates automatically.

Here is how it connects to the tool implementation:

#[derive(Debug, Clone)]
pub struct CodeStatsServer;

#[tool(tool_box)]
impl CodeStatsServer {
    #[tool(description = "Count total lines in files matching a given extension")]
    pub async fn count_lines(
        &self,
        #[tool(aggr)] input: Json<CountLinesInput>,
    ) -> Result<String, anyhow::Error> {
        let path = PathBuf::from(&input.path);
        if !path.exists() {
            return Ok(format!("Error: path '{}' does not exist", input.path));
        }
        if !path.is_dir() {
            return Ok(format!("Error: path '{}' is not a directory", input.path));
        }

        let mut total_lines: u64 = 0;
        let mut file_count: u64 = 0;
        count_lines_recursive(&path, &input.extension, &mut total_lines, &mut file_count)?;

        Ok(format!(
            "Found {} .{} files containing {} total lines in '{}'",
            file_count, input.extension, total_lines, input.path
        ))
    }
}
Enter fullscreen mode Exit fullscreen mode

Three macros do all the heavy lifting:

  • #[tool(tool_box)] on the impl block registers all tools within it
  • #[tool(description = "...")] on each method defines the tool's purpose for the AI
  • #[tool(aggr)] tells rmcp to aggregate all parameters into the input struct

The Json<CountLinesInput> wrapper handles deserialisation from the JSON-RPC request. Your function receives a fully typed, validated Rust struct. If the AI sends malformed parameters, the SDK handles the error before your code ever runs.

For optional parameters, just use Option<T>:

#[derive(Debug, Deserialize, JsonSchema)]
pub struct FindLargestFilesInput {
    /// The directory path to search in
    pub path: String,
    /// Maximum number of files to return (defaults to 10)
    pub limit: Option<u32>,
}
Enter fullscreen mode Exit fullscreen mode

The generated schema correctly marks limit as optional. Claude Code knows it can omit it. Your code handles the default:

let limit = input.limit.unwrap_or(10) as usize;
Enter fullscreen mode Exit fullscreen mode

This pattern scales beautifully. At systemprompt.io, we run 8 plugins with 34+ skills in production. Every single tool uses typed schemas. We have never once had a parameter mismatch reach production because the compiler catches them at build time.

If you are comparing this approach with plain CLI tools, the MCP vs CLI tools comparison breaks down when each approach makes sense.

Pattern 3, Error as UI

This pattern took me longest to internalise. Read this tool implementation carefully:

#[tool(description = "Find the largest files in a directory, sorted by size")]
pub async fn find_largest_files(
    &self,
    #[tool(aggr)] input: Json<FindLargestFilesInput>,
) -> Result<String, anyhow::Error> {
    let path = PathBuf::from(&input.path);
    if !path.exists() {
        return Ok(format!("Error: path '{}' does not exist", input.path));
    }
    if !path.is_dir() {
        return Ok(format!("Error: path '{}' is not a directory", input.path));
    }

    let limit = input.limit.unwrap_or(10) as usize;
    let mut files: Vec<(PathBuf, u64)> = Vec::new();
    collect_file_sizes(&path, &mut files)?;

    files.sort_by(|a, b| b.1.cmp(&a.1));
    files.truncate(limit);

    let mut output = format!("Top {} largest files in '{}':\n\n", limit, input.path);
    for (file_path, size) in &files {
        let display_path = file_path
            .strip_prefix(&path)
            .unwrap_or(file_path)
            .display();
        output.push_str(&format_file_size(*size, &display_path.to_string()));
        output.push('\n');
    }

    Ok(output)
}
Enter fullscreen mode Exit fullscreen mode

See the error handling? When a path does not exist, the function returns Ok(format!("Error: ...")). Not Err(...).

This is deliberate. This is the pattern.

When you return Err(...) from an MCP tool, the protocol treats it as a tool execution failure. The client may retry. It may show a generic error. The AI loses context about what went wrong.

When you return Ok("Error: path does not exist"), the AI receives that message as the tool's output. It can read it, understand it, and respond intelligently. "That directory doesn't exist. Did you mean /home/user/projects instead?" The error becomes part of the conversation, not a protocol-level failure.

Reserve Err(...) for genuine infrastructure failures. The transport died. The server ran out of memory. Something is catastrophically wrong. For anything the user or AI could reasonably act on, return it as Ok(String).

This philosophy extends to how you format successful responses too. Your output is the AI's input. Make it structured, clear, and information-dense:

#[tool(description = "Get a breakdown of programming languages used in a project")]
pub async fn language_breakdown(
    &self,
    #[tool(aggr)] input: Json<LanguageBreakdownInput>,
) -> Result<String, anyhow::Error> {
    let path = PathBuf::from(&input.path);
    if !path.exists() {
        return Ok(format!("Error: path '{}' does not exist", input.path));
    }

    let mut stats: HashMap<String, LanguageStats> = HashMap::new();
    collect_language_stats(&path, &mut stats)?;

    let mut sorted: Vec<(String, LanguageStats)> = stats.into_iter().collect();
    sorted.sort_by(|a, b| b.1.lines.cmp(&a.1.lines));

    let total_files: u64 = sorted.iter().map(|(_, s)| s.files).sum();
    let total_lines: u64 = sorted.iter().map(|(_, s)| s.lines).sum();

    let mut output = format!(
        "Language breakdown for '{}':\n\n{:<20} {:>8} {:>12}\n{}\n",
        input.path, "Language", "Files", "Lines", "-".repeat(44)
    );

    for (language, language_stats) in &sorted {
        output.push_str(&format!(
            "{:<20} {:>8} {:>12}\n",
            language, language_stats.files, language_stats.lines
        ));
    }

    output.push_str(&format!(
        "{}\n{:<20} {:>8} {:>12}\n",
        "-".repeat(44), "Total", total_files, total_lines
    ));

    Ok(output)
}
Enter fullscreen mode Exit fullscreen mode

The tabular format gives the AI structured data it can reason about. "Your project is 80% Rust by line count" is the kind of insight that falls out naturally when you give the AI clean data.

Wiring Up the Server Handler

The ServerHandler trait tells the MCP client who you are:

#[tool(tool_box)]
impl ServerHandler for CodeStatsServer {
    fn name(&self) -> String {
        "code-stats".to_string()
    }

    fn instructions(&self) -> String {
        "A server that provides code statistics tools. Use count_lines to count \
         lines of code by file extension, find_largest_files to identify the \
         biggest files, and language_breakdown to get a summary of languages \
         used in a project."
            .to_string()
    }
}
Enter fullscreen mode Exit fullscreen mode

The instructions string is worth spending time on. It is the first thing the AI reads about your server. Be specific about what each tool does and when to use it. Think of it as a README for an AI reader.

Testing It

Build the release binary:

cargo build --release
Enter fullscreen mode Exit fullscreen mode

Test with a raw JSON-RPC message:

echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0.1.0"}}}' | ./target/release/code-stats
Enter fullscreen mode Exit fullscreen mode

You should get back a JSON response listing your three tools with their schemas.

Connect it to Claude Code:

claude mcp add --transport stdio code-stats -- /absolute/path/to/target/release/code-stats
Enter fullscreen mode Exit fullscreen mode

Or drop an .mcp.json in your project root:

{
  "mcpServers": {
    "code-stats": {
      "command": "/absolute/path/to/target/release/code-stats"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Verify with claude mcp list and then just ask Claude to analyse your codebase. It will discover and call your tools automatically.

The Bit Nobody Talks About

The most effective MCP tools encode domain knowledge. Our count_lines tool knows to skip target/ and node_modules/. Our language_breakdown tool knows that .tsx is TSX and .hbs is Handlebars. That is not generic file system access. That is opinionated tooling that makes the right thing easy.

This is where MCP tools diverge from REST APIs. APIs aim to be generic. MCP tools should be specific, context-aware, and return exactly what the AI needs to reason about your domain.

Rust's type system amplifies this. The compiler catches errors before they reach the AI. The resulting binary is fast enough that the AI never waits for your tool to respond. And when you need to lock down what your tools can access, you can enforce path restrictions at compile time. The authentication and security guide covers the production hardening side of this.

The Whole Thing in 250 Lines

That is genuinely how small this server is. Three useful tools, typed parameter schemas, proper error handling, structured output, zero runtime dependencies. The entire implementation fits in a single file.

You can extend it trivially. Add a tool to find TODO comments. Compute cyclomatic complexity. Expose project configuration as an MCP Resource. Each addition is another method with a #[tool] attribute and a typed input struct.

The complete guide with full source code is on systemprompt.io. It includes the recursive helper functions, the extension-to-language mapping, and all the boilerplate I trimmed from this article.

Three patterns. One println!() disaster. A working MCP server. Your weekend is safe.

Top comments (0)