MCP服务器开发库

mercutio 是一个用于构建 MCP(Model Context Protocol)服务器的 Rust 开发库,采用 sans-I/O 状态机处理 JSON-RPC 消息、工具注册与分发,并支持 stdio、HTTP、WebSocket 等传输方式,适用于为 LLM 客户端提供自定义工具调用能力。

  • by mbr
  • Rust
  • 本地部署
  • 2 星标
  • 更新于
在 GitHub 查看 mbr/mercutio-rs

使用说明

mercutio

A Rust library for building MCP servers. In MCP, clients are LLM host applications (IDEs, chat interfaces) that connect to servers to give models access to tools. mercutio handles the server-side protocol (parsing messages, managing the initialization handshake, dispatching tool calls), while you handle the transport. The core is a pure state machine: feed it JSON-RPC messages, and it returns what to send back.

This sans-io design means you can run it over stdio, HTTP, WebSockets, or anything else without fighting the library.

Defining Tools

Use tool_registry! to define your tools. Field doc comments become JSON Schema descriptions that the LLM sees:

mercutio::tool_registry! {
    enum MyTools {
        GetWeather("get_weather", "Gets current weather for a city") {
            /// City name, e.g. "Llanfairpwllgwyngyllgogerychwyrndrobwllllantysiliogogogoch".
            city: String,
        },
        SetReminder("set_reminder", "Sets a reminder") {
            /// What to remind about.
            message: String,
            /// When to trigger the reminder.
            at: mercutio::Rfc3339,
            /// Minutes to wait before reminding again.
            snooze_minutes: u32,
        },
    }
}

Rfc3339 requires either the jiff or chrono feature. It emits format: "date-time" in JSON Schema, and deserialization errors include the current time as an example to help models self-correct.

Native CLI

Enable cli to expose the same registry and handler as native commands, without an MCP connection:

use mercutio::cli::ToolRegistryExt as _;

let cli = MyTools::cli("my-tools")
    .about("Check the weather and set reminders")
    .instructions(INSTRUCTIONS)
    .build()?;

cli.run_async_or_exit(&mut handler).await;

This handles help, diagnostics, and exit codes. Use run_or_exit() for synchronous handlers.

my-tools get-weather --city Berlin
printf '%s' '{"city":"Berlin"}' | my-tools --input-json get-weather
my-tools --output raw get-weather --city Berlin

Schemas supply flags, nested object flattening, and repeatable arrays; whole-input JSON covers complex inputs. Output defaults to readable text and paths to generated files, with structured JSON, raw JSON, and binary modes for scripts.

Optional .about() and .instructions() supply application help. Reuse the MCP instructions constant, or embed a document with include_str!. -h stays compact; --help includes the full reference and output controls.

For a multi-interface application, attach_to() nests the generated commands below an application-owned Clap command. Parse-only APIs let you handle dispatch and rendering yourself. See the cli API for composition and the non-exiting run() and run_async() variants.

Sans-IO Usage

The core API is a state machine. Pass in parsed messages, match on the output:

use mercutio::{McpServer, Output};

let mut server = McpServer::<MyTools>::builder()
    .name("my-server")
    .version("1.0")
    .build();

loop {
    let line = read_line_somehow();
    let msg = mercutio::parse_line(&line)?;

    match server.handle(msg) {
        Output::Send(response) => send(response.into_inner()),
        Output::ToolCall { tool, responder } => {
            let result = match tool {
                MyTools::GetWeather(input) => format!("Weather in {}: sunny", input.city),
                MyTools::SetReminder(input) => format!("Reminder set: {}", input.message),
            };
            send(responder.respond(Ok::<_, std::convert::Infallible>(result)).into_inner());
        }
        Output::ProtocolError(_) => break,
        Output::None => {}
    }
}

Transports

If you'd rather not wire up I/O yourself, the io-* feature flags provide ready-made transports. These use handler traits to process tool calls:

use mercutio::{ToolOutput, io::{McpSessionId, ToolHandler}};

struct MyHandler;

impl ToolHandler<MyTools> for MyHandler {
    type Error = std::convert::Infallible;

    async fn handle(
        &self,
        _session_id: Option<McpSessionId>,
        tool: MyTools,
    ) -> Result<ToolOutput, Self::Error> {
        match tool {
            MyTools::GetWeather(input) => {
                Ok(format!("Weather in {}: sunny", input.city).into())
            }
            MyTools::SetReminder(input) => {
                Ok(format!("Reminder set: {}", input.message).into())
            }
        }
    }
}

ToolHandler takes &self for concurrent contexts; MutToolHandler takes &mut self for exclusive access. The session ID is Some for HTTP (multiple clients share one server), None for stdio (one process = one session). Closures work via blanket impl: |_session_id, tool| async move { ... }.

io-tokio

Async stdin/stdout using Tokio:

let server = McpServer::<MyTools>::builder().name("my-server").version("1.0").build();
mercutio::io::tokio::run_stdio(server, MyHandler).await?;

io-stdlib

Synchronous stdin/stdout (no async runtime):

let server = McpServer::<MyTools>::builder().name("my-server").version("1.0").build();
mercutio::io::stdlib::run_stdio(server, |_session_id, tool| handle_tool(tool))?;

io-axum

HTTP transport with session management:

let mut builder = McpServer::<MyTools>::builder();
builder.name("my-server").version("1.0");

let router = mercutio::io::axum::mcp_router(builder, MyHandler);
let app = axum::Router::new().nest("/mcp", router);

For custom session storage, use McpRouter::builder() with .storage().

Testing

To test your handler, construct it with test fixtures and call handle directly:

use mercutio::{ToolOutput, ToolRegistry, io::ToolHandler};

mercutio::tool_registry! {
    enum Tools {
        Greet("greet", "Greets someone") {
            name: String,
        },
    }
}

struct Handler;

impl ToolHandler<Tools> for Handler {
    type Error = std::convert::Infallible;

    async fn handle(&self, _: Option<mercutio::io::McpSessionId>, tool: Tools) -> Result<ToolOutput, Self::Error> {
        match tool {
            Tools::Greet(g) => Ok(format!("Hello, {}!", g.name).into()),
        }
    }
}

# let rt = tokio::runtime::Runtime::new().unwrap();
# rt.block_on(async {
let handler = Handler;
let tool = Tools::Greet(Greet { name: "Alice".into() });

let output = handler.handle(None, tool).await.expect("handler failed");
assert_eq!(output.as_text(), Some("Hello, Alice!"));

// Tool outputs are text blocks that can grow large; insta snapshots help manage them:
insta::assert_snapshot!(output, @"Hello, Alice!");
# });

To test that invalid inputs produce useful error messages, use [ToolRegistry::parse]:

use mercutio::ToolRegistry;

mercutio::tool_registry! {
    enum Tools {
        Greet("greet", "Greets someone") { name: String },
    }
}

let err = Tools::parse("greet", serde_json::json!({})).err().expect("should fail");
assert!(err.to_string().contains("name"));

Example

A complete server supporting both transports:

use clap::{Parser, Subcommand};
use mercutio::{McpServer, ToolOutput, io::{McpSessionId, ToolHandler}};

mercutio::tool_registry! {
    enum MyTools {
        Greet("greet", "Greets someone") { name: String },
    }
}

struct MyHandler;

impl ToolHandler<MyTools> for MyHandler {
    type Error = std::convert::Infallible;

    async fn handle(&self, _: Option<McpSessionId>, tool: MyTools) -> Result<ToolOutput, Self::Error> {
        match tool {
            MyTools::Greet(input) => Ok(format!("Hello, {}!", input.name).into()),
        }
    }
}

#[derive(Parser)]
struct Args {
    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    Mcp,
    McpHttp { bind: std::net::SocketAddr },
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let args = Args::parse();
    let mut builder = McpServer::<MyTools>::builder();
    builder.name("greeter").version("1.0");

    match args.command {
        Command::Mcp => {
            mercutio::io::tokio::run_stdio(builder.build(), MyHandler).await?;
        }
        Command::McpHttp { bind } => {
            let router = mercutio::io::axum::mcp_router(builder, MyHandler);
            let listener = tokio::net::TcpListener::bind(bind).await?;
            axum::serve(listener, router).await?;
        }
    }
    Ok(())
}

Feature Flags

Feature Description
cli Native schema-driven command-line interface
io-stdlib Synchronous stdin/stdout transport
io-tokio Async stdin/stdout transport (Tokio)
io-axum HTTP transport (Axum) with session management
jiff Rfc3339 timestamp type using jiff (mutually exclusive with chrono)
chrono Rfc3339 timestamp type using chrono (mutually exclusive with jiff)