← Back to Blog

A Day Building MCP Servers in Rust

9:00 AM — The Problem

A client’s agent needs to manage customer invoices. The agent should be able to create invoices, list outstanding balances, apply payments, and generate PDF statements. Today I’m building the MCP server that gives the agent these capabilities.

The key constraint: agents are unpredictable callers. A human user might create one invoice at a time. An agent processing a batch reconciliation might fire off 200 create-invoice calls in ten seconds. The MCP server needs to handle both gracefully.

9:30 AM — Schema Design

Before writing any Rust, I define the tool schemas. This is the contract between the agent and the system—what tools are available, what parameters they accept, and what they return.

pub fn register_tools(server: &mut McpServer) {
    server.register(Tool {
        name: "create_invoice",
        description: "Create a new invoice for a customer",
        schema: ToolSchema::new()
            .param("customer_id", ParamType::String, true)
            .param("line_items", ParamType::Array(Box::new(ParamType::Object(vec![
                ("description", ParamType::String, true),
                ("amount_cents", ParamType::Integer, true),
                ("quantity", ParamType::Integer, false),
            ]))), true)
            .param("due_date", ParamType::String, false)
            .param("currency", ParamType::String, false),
        handler: handle_create_invoice,
    });

    server.register(Tool {
        name: "list_invoices",
        description: "List invoices for a customer, optionally filtered by status",
        schema: ToolSchema::new()
            .param("customer_id", ParamType::String, true)
            .param("status", ParamType::Enum(vec!["draft", "sent", "paid", "overdue"]), false)
            .param("limit", ParamType::Integer, false),
        handler: handle_list_invoices,
    });
}

The schema serves triple duty: it tells agents what’s available, validates every incoming call before execution, and documents the API automatically. No separate OpenAPI spec to maintain.

10:30 AM — The Handler Layer

Each tool handler follows the same pattern: validate the input (already done by the schema layer), execute the business logic, return structured output.

async fn handle_create_invoice(
    State(ctx): State<Arc<AppContext>>,
    params: ValidatedParams,
) -> ToolResult {
    let customer_id: String = params.get("customer_id")?;
    let line_items: Vec<LineItem> = params.get("line_items")?;
    let due_date = params.get_optional::<String>("due_date")?
        .map(|d| NaiveDate::parse_from_str(&d, "%Y-%m-%d"))
        .transpose()
        .map_err(|e| ToolError::InvalidParam(format!("Invalid date: {e}")))?;

    let invoice = ctx.invoice_service
        .create(customer_id, line_items, due_date)
        .await
        .map_err(|e| ToolError::Internal(e.to_string()))?;

    ToolResult::success(json!({
        "invoice_id": invoice.id,
        "total_cents": invoice.total_cents,
        "status": "draft",
        "created_at": invoice.created_at.to_rfc3339(),
    }))
}

The ValidatedParams type is something we built internally. It wraps the raw JSON parameters and provides typed extraction with clear error messages. When an agent sends a malformed request, the error message tells it exactly what went wrong—agents are better at self-correcting when the feedback is specific.

1:00 PM — Concurrency and Backpressure

This is where Rust earns its keep. The MCP server will handle requests from multiple agents simultaneously, and some of those requests hit the database. Without careful design, a burst of agent activity can overwhelm the connection pool.

We use Tower middleware to add concurrency limits and backpressure:

let service = ServiceBuilder::new()
    .layer(ConcurrencyLimitLayer::new(64))
    .layer(RateLimitLayer::new(1000, Duration::from_secs(1)))
    .layer(TimeoutLayer::new(Duration::from_secs(30)))
    .service(mcp_handler);

64 concurrent requests, 1000 requests per second, 30-second timeout. These numbers come from load testing against our staging environment. When the limits are hit, the MCP server returns a structured error that agents understand—they’ll back off and retry, which is exactly what we want.

3:00 PM — Testing

We test MCP servers at three levels. Unit tests for individual handlers, integration tests that spin up the full server and make real tool calls, and agent simulation tests that replay recorded agent sessions against the new server.

#[tokio::test]
async fn test_create_invoice_burst() {
    let server = TestServer::start().await;

    let futures: Vec<_> = (0..100)
        .map(|i| {
            let client = server.client();
            async move {
                client.call_tool("create_invoice", json!({
                    "customer_id": format!("cust_{i}"),
                    "line_items": [{"description": "Widget", "amount_cents": 1000}],
                })).await
            }
        })
        .collect();

    let results = join_all(futures).await;
    let successes = results.iter().filter(|r| r.is_ok()).count();
    assert!(successes >= 95, "Expected at least 95% success under burst load");
}

The burst test is the one that matters most. If the server can’t handle 100 concurrent creates, it’s not ready for production agents.

5:00 PM — Deploy

The MCP server gets packaged as a Docker image, defined as a Nomad job, and deployed to staging. The Nomad job includes auto-scaling based on the tool call queue depth metric that Prometheus collects.

By end of day, the agent can create invoices, list them, and apply payments—all through typed MCP tool calls backed by Rust services. Tomorrow I’ll add the PDF generation tool and hook up the observability dashboard.

What This Day Teaches

Building an MCP server in Rust is a full-stack exercise. You touch schema design, async Rust, database interactions, concurrency control, testing, and deployment in a single day. The Rust compiler catches the concurrency bugs before they reach staging. The type system ensures that tool schemas and handler implementations stay in sync.

This is what we mean when we say we write Rust in production. Not as an academic exercise—as the daily work of building systems that agents depend on.