~/posts/mcp-servers-in-rust

building three mcp servers in rust with rmcp

one cargo workspace, three mcp servers, and a shared toolkit — plus the one rule stdio protocols never forgive

i wanted to ask an llm “is my server on fire?” without ssh-ing into the box, running htop, squinting, giving up, and running docker ps instead. i wanted it to just know — pull the cpu number, list the containers, hit prometheus, and tell me in a sentence.

the model context protocol makes that possible. you hand an llm a set of tools, it decides when to call them. fine. so i built one server for my infra. then i wanted one for spanish government open data — the boe, the tax calendar, the property registry. then a third for formula 1 telemetry, because it’s my blog and i do what i want.

three servers. and i could already see the future: three near-identical binaries, three copies of the same http-client boilerplate, three subtly different cache bugs. the copy-paste tax.

this is the first non-trivial thing i built after the switch — i already wrote up why i left go for rust , so i’ll spare you the sermon. around the same time i was also porting the drain algorithm to rust for a log tool, so it was a rust-heavy few weeks. this post is about the other one: three mcp ↗ servers that share a spine instead of being three copy-pasted forks of each other, built on rmcp , the official rust sdk.

what an mcp server actually is

strip away the branding and an mcp server over stdio is almost aggressively boring: a subprocess that reads json-rpc ↗ messages from stdin and writes them to stdout. the client (claude desktop, claude code, whatever) launches it, asks “what tools do you have?”, gets back a list with json-schema for each one, and calls them by name. that’s the whole dance.

so a “tool” is really two things: a chunk of json-schema the model reads to decide how to call you, and a function that runs when it does. rmcp’s job is to make both of those fall out of ordinary rust instead of hand-written schema. mostly it succeeds.

a tool is a rust method with a macro on it

here’s the shape of a server. the imports are load-bearing, so i’m leaving them in — note how deep some of those paths go, we’ll come back to that:

mcp-infra/src/main.rs

rust
use rmcp::handler::server::router::tool::ToolRouter;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::{ServerHandler, ServiceExt as _, tool, tool_handler, tool_router};
use schemars::JsonSchema;
use serde::Deserialize;

#[derive(Clone)]
pub struct InfraServer {
    tool_router: ToolRouter<Self>,
    config: Arc<InfraConfig>,
    http: reqwest::Client,
    cache: Arc<ResponseCache>,
}

#[tool_handler(router = self.tool_router)]
impl ServerHandler for InfraServer {}

three macros do the wiring. #[tool_router] goes on the impl block that holds your tools and generates a Self::tool_router() constructor that collects them. #[tool_handler] bolts that router onto the ServerHandler trait so rmcp knows how to dispatch. and #[tool] marks the individual methods:

mcp-infra/src/main.rs

rust
#[tool_router(router = tool_router)]
impl InfraServer {
    #[tool(
        name = "query_prometheus",
        description = "Query Prometheus metrics using PromQL. Supports instant and range queries."
    )]
    async fn query_prometheus(
        &self,
        input: Parameters<QueryPrometheusInput>,
    ) -> Result<String, String> {
        tools::prometheus::execute(
            &input.0.query,
            input.0.time.as_deref(),
            // ...
            &self.http,
            &self.cache,
            &self.config.prometheus,
        )
        .await
    }
}

that’s the entire registration story. write an async fn, wrap the argument in Parameters<T>, slap #[tool] on it with a name and a description, and the model can call it. the method body just delegates to a plain function in a tools:: module — the macro layer stays a thin skin over ordinary rust, which is exactly what you want when the macro layer is the part you understand least.

the schema clients see comes from a derive

Parameters<T> is where the magic actually lives, and T is where you spend your care. this is QueryPrometheusInput:

mcp-infra/src/main.rs

rust
#[derive(Debug, Deserialize, JsonSchema)]
pub struct QueryPrometheusInput {
    /// `PromQL` query expression.
    pub query: String,
    /// Evaluation timestamp (RFC3339 or Unix). Defaults to now.
    pub time: Option<String>,
    /// Range query start time.
    pub start: Option<String>,
    /// Range query step interval (e.g., 15s, 1m, 5m).
    pub step: Option<String>,
}

three derives, three jobs. Deserialize parses the model’s arguments. JsonSchema — from schemars ↗ — generates the schema the model reads before it calls, and here’s the part that matters: those doc comments become the field descriptions in that schema. /// PromQL query expression is not for me. it’s a prompt. the model reads it to decide what to put in query. an Option<String> becomes an optional field. schemars keeps the schema honest with how serde would actually deserialize, so the thing the model is told matches the thing your code accepts. write those comments like the reader is a slightly literal robot, because it is.

one workspace, not three repos

now the part that keeps three servers from being three codebases. it’s a single cargo workspace ↗ , and the root Cargo.toml owns every version and every lint exactly once:

Cargo.toml

toml
[workspace]
members = ["mcp-common", "mcp-infra", "mcp-spain", "mcp-motorsport"]
resolver = "3"

[workspace.package]
version = "0.1.0"
edition = "2024"
license = "MIT OR Apache-2.0"

[workspace.dependencies]
rmcp = { version = "1.2", features = ["server", "transport-io", "macros"] }
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
moka = { version = "0.12", features = ["future"] }
# ...

[workspace.lints.clippy]
pedantic = { level = "warn", priority = -1 }

then each member crate inherits instead of redeclaring. this is the entire mcp-common manifest, near enough:

mcp-common/Cargo.toml

toml
[package]
name = "mcp-common"
version.workspace = true
edition.workspace = true
license.workspace = true

[dependencies]
serde = { workspace = true }
reqwest = { workspace = true }
moka = { workspace = true }

[lints]
workspace = true

version.workspace = true. reqwest = { workspace = true }. [lints] workspace = true. one place decides that this whole repo is edition 2024, on reqwest 0.12 with rustls, and linted at clippy::pedantic. bump rmcp once and all three servers move together. i cannot have the infra server on one tokio and the f1 server on another, because there is only one tokio, written down once. this is the cheapest good decision in the whole project and it’s four lines of toml per crate.

the shared toolkit: mcp-common

mcp-common is the spine — the crate the three servers actually share. it’s small on purpose: a cache, a rate limiter, an http-client builder, config loading, and one error enum. no traits-for-the-sake-of-traits, no plugin framework. just the stuff all three servers were about to duplicate.

the cache is a thin wrapper over moka::future::Cache with one helper that earns its keep — cache-aside in a single call:

mcp-common/src/cache.rs

rust
pub async fn get_or_fetch<F, Fut>(
    &self,
    key: &str,
    fetch: F,
) -> Result<serde_json::Value, crate::McpServerError>
where
    F: FnOnce() -> Fut,
    Fut: std::future::Future<Output = Result<serde_json::Value, crate::McpServerError>>,
{
    if let Some(cached) = self.inner.get(key).await {
        tracing::debug!(key, "cache hit");
        return Ok(cached);
    }

    tracing::debug!(key, "cache miss, fetching");
    let value = fetch().await?;
    self.inner.insert(key.to_owned(), value.clone()).await;
    Ok(value)
}

the pattern at the call site reads like a sentence — “give me this key, or run this to make it.” here’s the prometheus tool using it, so the http call only happens on a miss:

mcp-infra/src/tools/prometheus.rs

rust
let value = cache
    .get_or_fetch(&cache_key, || async {
        let url = format!("{}/api/v1/{endpoint}", config.url);
        // ... build request, send, check status ...
        resp.json::<serde_json::Value>().await.map_err(/* ... */)
    })
    .await
    .map_err(|e| e.to_string())?;

moka gives me ttl expiry and a bounded capacity for free, so a chatty model hammering query_prometheus with the same promql doesn’t turn into a hundred identical scrapes of my server.

next, the http client. exactly one, built once, shared everywhere:

mcp-common/src/http_client.rs

rust
pub fn build_http_client() -> Result<reqwest::Client, crate::McpServerError> {
    reqwest::Client::builder()
        .timeout(Duration::from_secs(30))
        .connect_timeout(Duration::from_secs(10))
        .pool_max_idle_per_host(5)
        .user_agent(concat!("mcp-servers/", env!("CARGO_PKG_VERSION")))
        .build()
        .map_err(|e| crate::McpServerError::Config(format!("failed to build HTTP client: {e}")))
}

notice the server struct holds http: reqwest::Client — not Arc<Client>. that’s not sloppiness. the reqwest docs ↗ are explicit that Client already wraps an Arc internally, and cloning it clones a pointer to the same connection pool, not a new one. wrapping it in another Arc would be belt-and-braces for no reason. every tool call reuses the same pooled, keep-alive connections. build it per request and you’d pay a fresh tls handshake every single time — the number one reqwest footgun.

and the rate limiter, because openf1 and the spanish apis will absolutely throttle me if a model gets excited. a token bucket, hand-rolled, forty lines:

mcp-common/src/rate_limit.rs

rust
pub async fn acquire(&self) {
    loop {
        let wait_duration = {
            let mut state = self.state.lock().await;
            let now = Instant::now();
            let elapsed = now.duration_since(state.last_refill).as_secs_f64();
            state.tokens = (state.tokens + elapsed * state.refill_rate).min(state.max_tokens);
            state.last_refill = now;

            if state.tokens >= 1.0 {
                state.tokens -= 1.0;
                return;
            }

            let deficit = 1.0 - state.tokens;
            std::time::Duration::from_secs_f64(deficit / state.refill_rate)
        };

        tokio::time::sleep(wait_duration).await;
    }
}

tokens refill at a fixed rate, each acquire() spends one, and if the bucket’s empty you sleep for exactly as long as it takes to earn one back. the f1 server calls self.rate_limiter.acquire().await at the top of every tool. the spain server does something i’m mildly proud of: it takes the most conservative limit across all five of its apis and applies that to everything, so one shared bucket can’t accidentally blow the tightest budget. three servers, three different rate policies, one implementation.

stdout is sacred

now the rule. the one you break exactly once.

go back to the top of this post: an mcp stdio server talks json-rpc over stdout. that channel is not yours. it belongs to the protocol. the spec doesn’t hedge about it — here it is, verbatim ↗ :

the server MUST NOT write anything to its stdout that is not a valid MCP message.

one stray println!, one library that logs a friendly “connected!” to stdout, one dbg! you forgot to delete — and you’ve injected a non-json byte into the middle of a json-rpc frame. the client’s parser chokes, the message is corrupt, and the failure mode is baffling because your logic is fine. the transport is just quietly poisoned. i have done this. debugging it feels like being haunted.

the fix is discipline enforced in one place. every log goes to stderr, which the spec explicitly blesses for “informational, debug, and error messages.” so the very first thing each main does, before anything can possibly log:

mcp-motorsport/src/main.rs

rust
tracing_subscriber::fmt()
    .with_env_filter(&cli.log_level)
    .with_writer(std::io::stderr)
    .with_ansi(false)
    .init();

two lines carry the weight. with_writer(std::io::stderr) sends every tracing event to stderr, so stdout stays pristine for the protocol. and with_ansi(false) kills the color codes — nobody’s watching this in a terminal, it’s a subprocess, and ansi escape bytes in a captured log are just garbage. use tracing everywhere and never reach for println!, and the invariant holds itself: the only thing that can reach stdout is rmcp writing a real message. treat stdout as write-only for the protocol and this entire class of bug disappears. that’s the whole trick, and it’s worth a whole section because it will otherwise eat an afternoon of your life.

the honest part: the rough edges

the blog rule here is that i don’t get to pretend it’s clean. so:

every tool returns Result<String, String>, and that’s a choice, not a limitation. rmcp can return richer things — structured content, typed json, the full call-tool-result shape. i return a string on success (pretty-printed json) and a string on failure (a message written for the model to read). on a missing config a tool returns "Grafana is not configured. Add a [grafana] section to your config file." — that sentence goes straight to the llm, which can relay it or act on it. the deliberate simplification is that i decided the model is my error ui. it’s a real ceiling, though: the client never gets machine-readable typed output, only json-in-a-string it has to re-parse. for these servers that’s a fine trade. for something where a downstream program consumes the result, it’d be the wrong one, and i’d reach for the typed return.

rmcp moves fast and the docs lag. i pinned 1.2 in the workspace and left it there on purpose — the crate’s well past that now — because the macro and module surface has shifted between releases and i didn’t want a surprise. and look again at those imports: rmcp::handler::server::router::tool::ToolRouter, rmcp::handler::server::wrapper::Parameters. you are not guessing those from the readme. i found them by reading source and other people’s code. the macros also generate a tool_router() associated function out of thin air — great when it works, opaque when it doesn’t, because there’s nothing to step through. it’s a young sdk. it’s good, but you’ll spend time in its guts.

the rate limiter is hand-rolled and i’d defend it, narrowly. there’s a perfectly good crate for this (governor) that does token buckets more carefully than my forty lines. i wrote my own because it’s forty lines, it’s exactly the behavior i wanted, it’s one fewer dependency to audit, and i understand every branch of it at 2am. that reasoning does not generalize — for anything with real concurrency demands or fairness requirements, use the crate. this is the acceptable end of not-invented-here, and i want to be honest that it’s a judgment call i could see going the other way.

the tests, briefly

the tools hit real http apis, so the fast tests fake the network with wiremock ↗ : spin up a mock server, assert the tool parses the response, no internet required.

mcp-infra/tests/integration.rs

rust
Mock::given(method("GET"))
    .and(path("/api/v1/query"))
    .respond_with(ResponseTemplate::new(200).set_body_json(json!({
        "status": "success",
        "data": { "resultType": "vector", "result": [/* ... */] }
    })))
    .expect(1)
    .mount(&mock_server)
    .await;

that .expect(1) is quietly doing real work — one of the tests fires the same query twice and asserts the mock is hit exactly once, which is how i prove the cache actually served the second call instead of trusting that it did.

then there’s a separate file of tests that hit the genuine boe and openf1 endpoints, every one marked #[ignore] so cargo test never touches the network. those run by hand before a release with cargo test --test real_api_tests -- --ignored. hermetic by default, real when i ask. the split matters: ci stays fast and offline, but “does the actual api still return what i think” is one command away.

lessons learned

  • the shared crate is the whole point, and it should be boring. mcp-common has no traits it doesn’t need and no abstraction it didn’t earn. it’s a cache, a limiter, a client builder, an error enum. the value isn’t cleverness, it’s that the cache bug can only exist in one place.
  • for stdio protocols, stdout is not yours. log to stderr, with_ansi(false), never println!. this is one sentence in the spec and one config block in your code, and getting it wrong is the most confusing bug you’ll hit all week.
  • write your tool descriptions like a prompt, because they are one. those /// doc comments are the interface the model reads. schemars turns them into schema; the model turns schema into behavior. sloppy comment, sloppy tool call.
  • pin your fast-moving sdk and read its source. rmcp is worth using today, but treat the version number as load-bearing and don’t expect the docs to hold your hand.

next up: wiring these into claude and actually watching a model chain resolve_session_keyresolve_driver_numberget_lap_times on its own to answer “how much faster was verstappen than his teammate in sector two?” — the tools are the easy part; the interesting bit is how badly it goes when a description is one word off.

the whole thing is on github ↗ — three servers, one spine, mit/apache. poke at it.

letters to the editor

no letters yet. the editor is patient.