This is the Rust companion to Build an MCP Server in Go with the Official SDK and Build an MCP Server in TypeScript with the Official SDK. It builds the same kind of server — two tools and a resource — with the official Rust SDK (rmcp), served over the stdio transport so a local client launches it as a subprocess. The result is a single compiled binary with no runtime beyond itself.

The Rust SDK is macro-driven and async. You annotate methods with #[tool] inside a #[tool_router] impl block; the macro reads each method’s argument struct, derives the JSON Schema from it with schemars, and routes incoming calls to the method with the arguments already decoded and typed. A second macro, #[tool_handler], wires that router into the ServerHandler trait. The whole thing runs on tokio.

What you will build

  • A GreeterServer with two tools (add, greet) and a resource (info://server).
  • Pure tool logic in its own module, unit-tested without the SDK.
  • A stdio entry point compiled to target/release/macmcp.
  • A smoke binary that launches the built server and speaks MCP to it, plus a cargo test suite that drives the server in-process over an in-memory transport.
  • Registration with Claude Code.

Prerequisites

  • A recent stable Rust toolchain with cargo (install via rustup). This tutorial was validated on Rust 1.97.
  • make (pre-installed on macOS and most Linux distributions).
  • Familiarity with MCP concepts (tools, resources, transports).
  • macOS commands are shown, but the project is cross-platform.

Step 1: Scaffold the project

The server is a normal Cargo binary crate. We will split it into a library plus a thin binary so the tests can import the real server type and drive it in-process.

Create the files

cargo new mcp-server-rust
cd mcp-server-rust

cargo new creates Cargo.toml, src/main.rs, and a .gitignore. When run inside an existing Git repository it skips Git initialization; when run standalone it also initializes a repo. Replace the generated .gitignore with entries for this stack:

Add the code: .gitignore

# Rust
/target
# OS / editor noise
.DS_Store
*.log

Detailed breakdown

  • /target excludes the Cargo build directory: compiled binaries, intermediate artifacts, and the dependency cache. It is rebuilt on demand, so it never belongs in version control.
  • .DS_Store and *.log cover common macOS and runtime noise.

Step 2: Declare dependencies

Replace the generated Cargo.toml with the manifest below. It pulls in rmcp with the feature flags this project uses, plus the async runtime and the serialization crates the macros expand into.

Add the code: Cargo.toml

[package]
name = "macmcp"
version = "0.1.0"
edition = "2021"

[dependencies]
rmcp = { version = "2.2.0", features = [
    "server",
    "client",
    "macros",
    "transport-io",
    "transport-child-process",
] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "sync"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

Detailed breakdown

  • rmcp is the official SDK (repository github.com/modelcontextprotocol/rust-sdk). The features select exactly what is compiled:
    • server and macros provide ServerHandler plus the #[tool], #[tool_router], and #[tool_handler] macros. macros also brings in schemars, which turns an argument struct into a JSON Schema.
    • transport-io provides stdio(), the transport the entry point serves on.
    • client and transport-child-process are only needed by the smoke binary and the tests (rmcp::ServiceExt::serve on the client side, TokioChildProcess to spawn the server). A server-only crate can drop both.
  • tokio is the async runtime. rt-multi-thread and macros enable #[tokio::main]; io-std backs the stdio transport; sync is a common companion for shared state (unused here but conventional).
  • serde (with derive) and serde_json back the argument structs and the resource JSON. The package name is macmcp, which becomes the binary name and the crate path in use macmcp::....

Step 3: The tool logic

Keep the actual work in its own module, free of any SDK types, so it unit-tests directly.

Create the file

touch src/logic.rs

Add the code: src/logic.rs

//! Pure logic behind the MCP tools, with no dependency on the MCP SDK. Keeping
//! the work here means it unit-tests without a server, a transport, or a client.

/// Return the sum of two integers.
pub fn add(a: i64, b: i64) -> i64 {
    a + b
}

/// Build a greeting for `name`, optionally shouting it in upper case.
pub fn greet(name: &str, shout: bool) -> String {
    let msg = format!("Hello, {name}!");
    if shout {
        msg.to_uppercase()
    } else {
        msg
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn add_sums() {
        assert_eq!(add(2, 3), 5);
        assert_eq!(add(-1, 1), 0);
    }

    #[test]
    fn greet_plain() {
        assert_eq!(greet("Ada", false), "Hello, Ada!");
    }

    #[test]
    fn greet_shout() {
        assert_eq!(greet("Ada", true), "HELLO, ADA!");
    }
}

Detailed breakdown

  • Plain functions, no MCP imports. The server module adapts them to the protocol; the tests call them directly.
  • The #[cfg(test)] module holds unit tests compiled only under cargo test. use super::* imports the parent module so the tests reach add and greet without a full path. This is the standard Rust pattern for co-locating tests with the code they exercise.

Step 4: The library crate root

src/lib.rs turns the crate into a library that both the binary and the tests can import. It only needs to declare the modules.

Create the file

touch src/lib.rs

Add the code: src/lib.rs

//! Library crate for the MCP server. Splitting the crate into a library plus a
//! thin binary lets the integration tests in `tests/` import the real server
//! type and drive it in-process, while `src/main.rs` only wires it to stdio.

pub mod logic;
pub mod server;

Detailed breakdown

  • A Cargo package can hold both a library (src/lib.rs) and a binary (src/main.rs) at once. The library carries the reusable code; the binary is a thin main.
  • pub mod logic; and pub mod server; expose the two modules under the crate name macmcp, so src/main.rs, src/bin/smoke.rs, and the integration tests all reach them as macmcp::logic and macmcp::server. Integration tests under tests/ compile as separate crates and can only see a package’s public library API, which is why the server type has to live here rather than in main.rs.

Step 5: Construct the server

This is the core of the SDK usage. The GreeterServer type carries a ToolRouter; the #[tool_router] macro builds that router from the #[tool]-annotated methods, and #[tool_handler] connects it to the ServerHandler trait. The resource is served by hand-implementing list_resources and read_resource.

Create the file

touch src/server.rs

Add the code: src/server.rs

//! The MCP server: it registers two tools (`add`, `greet`) and one resource
//! (`info://server`) and returns the handler type. Connecting it to a transport
//! happens in the caller — `src/main.rs` runs it on stdio, and the tests attach
//! an in-memory transport to the same type.

use rmcp::{
    handler::server::{router::tool::ToolRouter, wrapper::Parameters},
    model::*,
    schemars,
    service::RequestContext,
    tool, tool_handler, tool_router,
    ErrorData as McpError, RoleServer, ServerHandler,
};
use serde_json::json;

use crate::logic;

/// Advertised server identity, also returned by the `info://server` resource.
pub const NAME: &str = "mcp-rust";
pub const VERSION: &str = "0.1.0";
pub const INFO_URI: &str = "info://server";

/// `AddRequest` and `GreetRequest` describe each tool's input. The derives drive
/// both JSON decoding (`serde::Deserialize`) and the JSON Schema the SDK
/// advertises to clients (`schemars::JsonSchema`); doc comments become field
/// descriptions in that schema.
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct AddRequest {
    /// the first addend
    pub a: i64,
    /// the second addend
    pub b: i64,
}

#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct GreetRequest {
    /// who to greet
    pub name: String,
    /// upper-case the greeting
    #[serde(default)]
    pub shout: bool,
}

/// The server handler. The `#[tool_router]` macro fills the `tool_router` field
/// with a router built from the `#[tool]`-annotated methods below.
#[derive(Clone)]
pub struct GreeterServer {
    // Read by the `#[tool_handler]`-generated dispatch code; the dead-code lint
    // misses that use because the struct also derives Clone, so silence it.
    #[allow(dead_code)]
    tool_router: ToolRouter<Self>,
}

#[tool_router]
impl GreeterServer {
    pub fn new() -> Self {
        Self {
            tool_router: Self::tool_router(),
        }
    }

    #[tool(description = "Add two integers and return the sum.")]
    fn add(&self, Parameters(AddRequest { a, b }): Parameters<AddRequest>) -> String {
        logic::add(a, b).to_string()
    }

    #[tool(description = "Return a greeting for a name.")]
    fn greet(
        &self,
        Parameters(GreetRequest { name, shout }): Parameters<GreetRequest>,
    ) -> String {
        logic::greet(&name, shout)
    }
}

impl Default for GreeterServer {
    fn default() -> Self {
        Self::new()
    }
}

#[tool_handler]
impl ServerHandler for GreeterServer {
    fn get_info(&self) -> ServerInfo {
        // Implementation is #[non_exhaustive], so start from the build-env
        // defaults and override the identity fields.
        let mut identity = Implementation::from_build_env();
        identity.name = NAME.into();
        identity.version = VERSION.into();

        ServerInfo::new(
            ServerCapabilities::builder()
                .enable_tools()
                .enable_resources()
                .build(),
        )
        .with_server_info(identity)
        .with_instructions(
            "A minimal MCP server with two tools (add, greet) and a \
             server-info resource."
                .to_string(),
        )
    }

    async fn list_resources(
        &self,
        _request: Option<PaginatedRequestParams>,
        _: RequestContext<RoleServer>,
    ) -> Result<ListResourcesResult, McpError> {
        Ok(ListResourcesResult {
            resources: vec![Resource::new(INFO_URI, "server-info".to_string())],
            next_cursor: None,
            meta: None,
        })
    }

    async fn read_resource(
        &self,
        request: ReadResourceRequestParams,
        _: RequestContext<RoleServer>,
    ) -> Result<ReadResourceResult, McpError> {
        match request.uri.as_str() {
            INFO_URI => {
                let body = json!({ "name": NAME, "version": VERSION }).to_string();
                Ok(ReadResourceResult::new(vec![ResourceContents::text(
                    body,
                    request.uri.clone(),
                )]))
            }
            _ => Err(McpError::resource_not_found(
                "resource_not_found",
                Some(json!({ "uri": request.uri })),
            )),
        }
    }
}

Detailed breakdown

  • The argument structs derive serde::Deserialize and schemars::JsonSchema. serde decodes the incoming JSON arguments; schemars generates the input schema the client sees in tools/list. A /// doc comment on a field becomes that field’s description in the schema. #[serde(default)] on shout makes it optional: omit it and it defaults to false.
  • GreeterServer holds a ToolRouter<Self> field named tool_router. The #[tool_router] macro generates an associated Self::tool_router() that builds the router from every #[tool] method in the block, and new() stores it in that field. The field is read by the generated dispatch code, but the dead-code lint misses that use once the struct derives Clone, so #[allow(dead_code)] silences a false positive.
  • #[tool(description = "...")] registers a method as a tool. The argument is destructured out of the Parameters<T> wrapper: Parameters(AddRequest { a, b }) binds the decoded fields directly. Returning String is the shortcut for a single text result; the macro wraps it in a CallToolResult with one text block. (For richer results, such as multiple blocks, images, or an error, return Result<CallToolResult, McpError> and build the result explicitly.)
  • #[tool_handler] on impl ServerHandler generates the call_tool and list_tools methods from the tool_router field, so you only write get_info and the resource methods.
  • get_info advertises capabilities. ServerCapabilities::builder() .enable_tools().enable_resources() tells clients this server has both. Implementation is #[non_exhaustive], so you cannot build it with a struct literal; start from Implementation::from_build_env() and override name and version.
  • list_resources and read_resource are hand-written, because tools have macros but resources do not. list_resources returns one Resource for info://server; read_resource matches on the requested URI, returns the server identity as JSON for the known URI, and returns McpError::resource_not_found for anything else so an unknown URI is a clean protocol error rather than a panic.
  • New/Default return the server unconnected, which is what lets Step 8’s tests attach an in-memory transport instead of stdio.

Step 6: The stdio entry point

Create the file

The src/main.rs already exists from cargo new. Replace its contents.

Add the code: src/main.rs

//! Runs the MCP server over the stdio transport — the transport a local client
//! (Claude Desktop, Claude Code) launches as a subprocess. The client speaks
//! JSON-RPC over stdin/stdout, so all logging goes to stderr; the SDK owns
//! stdout.

use anyhow::Result;
use macmcp::server::GreeterServer;
use rmcp::{transport::stdio, ServiceExt};

#[tokio::main]
async fn main() -> Result<()> {
    // Send tracing to stderr. stdout is the protocol channel; a stray write to
    // it corrupts the JSON-RPC stream and disconnects the client.
    tracing_subscriber::fmt()
        .with_writer(std::io::stderr)
        .with_ansi(false)
        .init();

    let service = GreeterServer::new().serve(stdio()).await?;
    service.waiting().await?;
    Ok(())
}

Detailed breakdown

  • GreeterServer::new().serve(stdio()) starts the server on the stdio transport. serve comes from the ServiceExt extension trait and returns a running service handle; stdio() pairs tokio’s stdin and stdout as the transport.
  • service.waiting().await blocks until the client disconnects (or the process is signalled), keeping the server alive to answer requests.
  • Logging goes to stderr. The tracing_subscriber writer is set to std::io::stderr, because stdout is the protocol channel and a stray write to it corrupts the JSON-RPC stream and drops the client. Never println! from a stdio server — use tracing/eprintln! (stderr) for diagnostics.

Step 7: Build and smoke-test

Compile the release binary:

cargo build --release

Then launch it the way a client does and call its tools. The smoke binary lives under src/bin/, so Cargo builds it as a second binary named smoke.

Create the file

mkdir -p src/bin
touch src/bin/smoke.rs

Add the code: src/bin/smoke.rs

//! Launches the built server over stdio and speaks MCP to it, the way a real
//! client does. A green run proves the exact command you register with a client
//! works. Build first (`make build`), then run from the project root so the
//! relative binary path resolves.

use anyhow::Result;
use rmcp::{
    model::{CallToolRequestParams, CallToolResult, ReadResourceRequestParams},
    object,
    transport::TokioChildProcess,
    ServiceExt,
};
use tokio::process::Command;

const SERVER_BIN: &str = "target/release/macmcp";

#[tokio::main]
async fn main() -> Result<()> {
    // The unit type `()` is the default client handler. `TokioChildProcess`
    // spawns the built binary and connects over its stdin/stdout.
    let client = ()
        .serve(TokioChildProcess::new(Command::new(SERVER_BIN))?)
        .await?;

    for tool in client.list_all_tools().await? {
        println!("tool: {}", tool.name);
    }

    let sum = client
        .call_tool(CallToolRequestParams::new("add").with_arguments(object!({ "a": 2, "b": 3 })))
        .await?;
    println!("add -> {}", first_text(&sum));

    let hi = client
        .call_tool(
            CallToolRequestParams::new("greet")
                .with_arguments(object!({ "name": "Ada", "shout": true })),
        )
        .await?;
    println!("greet -> {}", first_text(&hi));

    let res = client
        .read_resource(ReadResourceRequestParams::new("info://server"))
        .await?;
    if let Some(rmcp::model::ResourceContents::TextResourceContents { text, .. }) =
        res.contents.first()
    {
        println!("resource -> {text}");
    }

    client.cancel().await?;
    Ok(())
}

/// Pull the first text block out of a tool result.
fn first_text(result: &CallToolResult) -> String {
    result
        .content
        .iter()
        .find_map(|block| block.as_text().map(|t| t.text.clone()))
        .unwrap_or_default()
}

Run it from the project root:

cargo run --quiet --bin smoke
tool: add
tool: greet
add -> 5
greet -> HELLO, ADA!
resource -> {"name":"mcp-rust","version":"0.1.0"}

Detailed breakdown

  • ().serve(TokioChildProcess::new(Command::new(SERVER_BIN))?) is the client half of stdio. The unit type () is the SDK’s default client handler; TokioChildProcess spawns the built binary and connects over its stdin/stdout, exactly as a real client would.
  • client.call_tool(CallToolRequestParams::new("add").with_arguments(...)) sends the tool name and a JSON object of arguments; object!({ ... }) is the SDK’s JSON-object macro. read_resource fetches the resource by URI.
  • first_text walks the result’s content blocks and returns the first text one. block.as_text() returns None for non-text blocks (images, embedded resources), so the helper degrades to an empty string rather than panicking.
  • SERVER_BIN is a relative path, so run the smoke binary from the project root after cargo build --release. The output confirms the whole path end to end, using the exact launch command you will register with a client.

Step 8: Tests

Unit tests already live beside the logic (Step 3). Add an integration test that drives the real server in-process over an in-memory transport — no subprocess, no stdio. Files under tests/ compile as separate crates that see only the public library API.

Create the file

mkdir -p tests
touch tests/server.rs

Add the code: tests/server.rs

//! Drive the real server in-process over an in-memory transport pair — no
//! subprocess, no stdio. `tokio::io::duplex` returns two linked byte streams;
//! the server serves one end and the client connects to the other, all in one
//! process, which is faster and more deterministic than spawning a binary.

use anyhow::Result;
use macmcp::server::GreeterServer;
use rmcp::{
    model::{CallToolRequestParams, ReadResourceRequestParams, ResourceContents},
    object,
    service::{RoleClient, RunningService},
    ServiceExt,
};

/// Wire a client to the real server in-process and return the running client.
async fn connect() -> Result<RunningService<RoleClient, ()>> {
    let (server_transport, client_transport) = tokio::io::duplex(4096);
    tokio::spawn(async move {
        let service = GreeterServer::new().serve(server_transport).await?;
        service.waiting().await?;
        anyhow::Ok(())
    });
    let client = ().serve(client_transport).await?;
    Ok(client)
}

fn first_text(result: &rmcp::model::CallToolResult) -> String {
    result
        .content
        .iter()
        .find_map(|block| block.as_text().map(|t| t.text.clone()))
        .unwrap_or_default()
}

#[tokio::test]
async fn lists_both_tools() -> Result<()> {
    let client = connect().await?;
    let names: Vec<String> = client
        .list_all_tools()
        .await?
        .into_iter()
        .map(|t| t.name.into_owned())
        .collect();
    assert!(names.contains(&"add".to_string()), "missing add: {names:?}");
    assert!(names.contains(&"greet".to_string()), "missing greet: {names:?}");
    client.cancel().await?;
    Ok(())
}

#[tokio::test]
async fn calls_add() -> Result<()> {
    let client = connect().await?;
    let result = client
        .call_tool(CallToolRequestParams::new("add").with_arguments(object!({ "a": 40, "b": 2 })))
        .await?;
    assert_eq!(first_text(&result), "42");
    client.cancel().await?;
    Ok(())
}

#[tokio::test]
async fn calls_greet_with_shout() -> Result<()> {
    let client = connect().await?;
    let result = client
        .call_tool(
            CallToolRequestParams::new("greet")
                .with_arguments(object!({ "name": "Ada", "shout": true })),
        )
        .await?;
    assert_eq!(first_text(&result), "HELLO, ADA!");
    client.cancel().await?;
    Ok(())
}

#[tokio::test]
async fn reads_info_resource() -> Result<()> {
    let client = connect().await?;
    let result = client
        .read_resource(ReadResourceRequestParams::new("info://server"))
        .await?;
    let ResourceContents::TextResourceContents { text, .. } = result
        .contents
        .first()
        .expect("resource has contents")
    else {
        panic!("expected text resource contents");
    };
    assert_eq!(text, r#"{"name":"mcp-rust","version":"0.1.0"}"#);
    client.cancel().await?;
    Ok(())
}

Run everything:

cargo test
running 3 tests
test logic::tests::add_sums ... ok
test logic::tests::greet_shout ... ok
test logic::tests::greet_plain ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
...
running 4 tests
test reads_info_resource ... ok
test lists_both_tools ... ok
test calls_greet_with_shout ... ok
test calls_add ... ok

test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s

Detailed breakdown

  • tokio::io::duplex(4096) returns a linked pair of in-memory byte streams — the in-process equivalent of a stdio pipe. The server serves one end on a spawned task; the client connects to the other. Everything runs in one process, faster and more deterministic than spawning the binary.
  • ().serve(client_transport) builds the client, and connect() returns the running handle typed RunningService<RoleClient, ()>. Each test lists tools, calls a tool, or reads the resource, then client.cancel() shuts the session down.
  • calls_add asserts 40 + 2 = 42 and reads_info_resource asserts the exact JSON {"name":"mcp-rust","version":"0.1.0"}. serde_json orders object keys alphabetically, so name precedes version deterministically.
  • Tool names arrive as Cow<str>, hence t.name.into_owned() to compare against owned Strings. The unit tests and the four integration tests run under one cargo test invocation.

Step 9: The Makefile

Wrap the commands so plain make prints help.

Create the file

touch Makefile

Add the code: Makefile

.DEFAULT_GOAL := help

BINARY := target/release/macmcp
DIR    := $(shell pwd)

.PHONY: help build test smoke run register-code unregister-code clean

help: ## Show this help screen
	@grep -E '^[a-zA-Z_-]+:.*##' $(MAKEFILE_LIST) | \
		awk 'BEGIN {FS = ":.*## "}; {printf "  %-16s %s\n", $$1, $$2}'

build: ## Build the release binary
	cargo build --release

test: ## Run all tests
	cargo test

smoke: build ## Build, then launch the server over stdio and call its tools
	cargo run --quiet --bin smoke

run: build ## Run the server on stdio (Ctrl-C to stop)
	./$(BINARY)

register-code: build ## Register the built server with Claude Code (local scope)
	claude mcp add mcp-rust -- $(DIR)/$(BINARY)

unregister-code: ## Remove the server from Claude Code
	-claude mcp remove mcp-rust

clean: ## Remove build artifacts
	cargo clean

Verify the default target prints help:

make
  help             Show this help screen
  build            Build the release binary
  test             Run all tests
  smoke            Build, then launch the server over stdio and call its tools
  run              Run the server on stdio (Ctrl-C to stop)
  register-code    Register the built server with Claude Code (local scope)
  unregister-code  Remove the server from Claude Code
  clean            Remove build artifacts

Detailed breakdown

  • .DEFAULT_GOAL := help makes plain make print the help screen, which is built by grep/awk scraping the ## comments off each target.
  • register-code depends on build, so the binary a client points at is current, and it uses an absolute path ($(DIR)) because a client may launch the server from any directory.
  • Makefiles require tab indentation, not spaces. If a recipe line is space-indented, make reports missing separator.

Step 10: Register with Claude Code

The server is a compiled stdio program, so register it by its binary path (see Register a FastMCP Server with Claude Desktop and Claude Code for scopes and the Claude Desktop config). From the project directory, after cargo build --release:

claude mcp add mcp-rust -- "$(pwd)/target/release/macmcp"
claude mcp get mcp-rust
mcp-rust:
  Scope: Local config (private to you in this project)
  Status: ✔ Connected
  Type: stdio
  Command: /Users/you/mcp-server-rust/target/release/macmcp

✔ Connected means Claude Code launched the binary and completed the MCP handshake. Start a session and ask it to use mcp-rust to add 2 and 3. Remove it with claude mcp remove mcp-rust.

Troubleshooting

  • cargo build fails resolving rmcp features. The feature set matters: stdio() needs transport-io, and the smoke binary needs client plus transport-child-process. A server-only build can trim to ["server", "macros", "transport-io"].
  • error: cannot create non-exhaustive struct. Implementation is #[non_exhaustive]; build it from Implementation::from_build_env() and override fields, rather than with a struct literal.
  • The client connects but sees no tools, or disconnects at once. Something wrote to stdout. Point tracing_subscriber at std::io::stderr and never println! from the server — stdout is the protocol channel.
  • #[tool] or #[tool_router] does not compile. Every #[tool] method must live inside the #[tool_router] impl block, and the struct must hold a tool_router: ToolRouter<Self> field that new() fills with Self::tool_router().
  • Arguments arrive missing or mistyped. The field names in the request struct must match the JSON keys the client sends (a, b, name, shout here). #[serde(default)] makes a field optional.
  • claude mcp get shows Failed to connect. Run ./target/release/macmcp directly — it should start and wait on stdin. If it exits immediately, rebuild with cargo build --release; the binary may be missing or stale.

Recap

  • The official Rust SDK builds an MCP server from a #[tool_router] impl of #[tool] methods, wired into ServerHandler by #[tool_handler]. Argument structs derive serde::Deserialize + schemars::JsonSchema, so inputs arrive decoded and the schema is generated for you.
  • Resources have no macro: implement list_resources and read_resource on the handler by hand, matching on the requested URI.
  • GreeterServer::new().serve(stdio()) serves it as a subprocess; stdout is the protocol, so logging goes to stderr.
  • tokio::io::duplex links a client to the server in-process for fast tests, alongside a TokioChildProcess smoke binary over real stdio.
  • The compiled binary registers with any MCP client by its path.

Next improvements

  • Return structured output by returning a type that derives serde::Serialize + schemars::JsonSchema, so clients get a typed structuredContent payload alongside the text.
  • Add the Streamable HTTP transport for remote clients (enable the transport-streamable-http-server feature), keeping stdio for local use.
  • Add prompts with the #[prompt_router] / #[prompt] macros, exposing reusable templates next to the tools.
  • Cross-compile and distribute the binary for other targets (rustup target add ..., cargo build --release --target ...) so users install a single file with no toolchain.