RustでMCPサーバーを書く公式SDKの使い方
公式Rust SDK「rmcp」のマクロでツールを定義し、stdio・Streamable HTTPでMCPサーバーを動かす手順をコード付きで解説します。
RustでMCPサーバーを書くと何が手に入るか
Rust用のMCP SDKは、公式のrmcpクレートを使います。cargo add rmcp --features serverで追加し、#[tool_router(server_handler)]を付けたimplにツールを書けば、stdioで動くMCPサーバーになります。Claude Codeにはclaude mcp add 名前 -- バイナリのパスで登録できます。
MCP(Model Context Protocol)のRust実装はrmcpというクレート名で公式配布されており、Tier1(最上位の完成度・保守コミットメント)に分類されています。Tokioの非同期ランタイムを前提に設計されているため、すでにTokioベースのサービスを運用しているチームなら、既存のasyncコードベースにそのまま組み込めます。同じRustプロジェクトでBevyのようなゲームエンジンを扱う機会があれば、Claude CodeでBevyのRustゲームを開発する手順も参考になります。
このSDKは安定版のMCP仕様2026-07-28を実装しつつ、1つ前の2025-11-25リリースとも完全互換です。2026-07-28で追加された主要機能はサーバー発見・ネゴシエーション、トランスポート非依存のサブスクリプション、長時間実行タスク、レスポンスキャッシュ、複数往復のリクエスト、標準HTTPルーティングヘッダーの6つで、いずれもrmcpが対応しています。パッケージはコア機能を持つrmcpクレートと、ツールマクロを提供するrmcp-macrosクレートの2つに分かれています。
rmcpの最新版はcrates.ioで3.5.1(2026-10-05公開)で、3.1.4(2026-08-20公開)以降に、3.2.0・3.3.0・3.4.0・3.4.1・3.5.0・3.5.1の6バージョンが出ています。cargo add rmcpが取ってくるのは3系です。3系より前のコードを持ち込む場合は、リポジトリ冒頭で案内されているmigrationガイドを先に読みます。
MCPの仕組み自体や他言語SDKとの比較はMCP実用ガイド、TypeScript・PythonでのMCPサーバー自作はMCPサーバー自作ガイドが扱っています。本記事はRust SDK(rmcp)固有の実装手順に絞ります。
導入からツール定義まで — 3ステップ
ステップ1: クレートを追加する
cargo addでserver機能フラグ付きのrmcpを追加します。tokio・serde・schemars(JSON Schema 2020-12生成用)が基本の依存関係です。
cargo add rmcp --features server開発版の機能を先取りしたい場合は、--git https://github.com/modelcontextprotocol/rust-sdk --branch mainを付けてGitHubのmainブランチを直接指定できます。JSON Schemaの生成にはschemarsが要り、README記載の基本の依存はtokio・serde・schemarsの3つです。
ステップ2: マクロでツールを1つ実装する
rmcpの最大の特徴は、#[tool]・#[tool_router]・#[tool_handler]という3つのマクロが配線をほぼ自動化する点です。ツールだけを持つサーバーなら#[tool_router(server_handler)]を使い、ServerHandlerの実装を省略できます。
use rmcp::{handler::server::wrapper::Parameters, schemars, tool, tool_router, ServiceExt, transport::stdio};
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
struct AddParams {
a: i32,
b: i32,
}
#[derive(Clone)]
struct Calculator;
#[tool_router(server_handler)]
impl Calculator {
#[tool(description = "Add two numbers")]
fn add(&self, Parameters(AddParams { a, b }): Parameters<AddParams>) -> String {
(a + b).to_string()
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let service = Calculator.serve(stdio()).await?;
service.waiting().await?;
Ok(())
}inputSchemaは引数の構造体AddParamsのフィールドから自動生成されます。
サーバー名やバージョンなど独自のメタデータを設定したい場合、あるいはツールとプロンプトのように複数のcapabilityを1つのサーバーに持たせたい場合は、#[tool_router]と#[tool_handler]を分けて明示的にServerHandlerを実装します。
#[tool_router]
impl Calculator {
#[tool(description = "Add two numbers")]
fn add(&self, Parameters(AddParams { a, b }): Parameters<AddParams>) -> String {
(a + b).to_string()
}
}
#[tool_handler(name = "calculator", version = "1.0.0", instructions = "A simple calculator")]
impl ServerHandler for Calculator {}ステップ3: stdioで起動しクライアントから呼ぶ
.serve(stdio())を呼ぶだけで標準入出力ベースのサーバーが起動します。サーバーを試すクライアントは、公開されているサーバー実装(server-everything等)をnpxで子プロセスとして起動する形で書けます。次はREADMEのクライアント例です。
use rmcp::{ServiceExt, transport::{TokioChildProcess, ConfigureCommandExt}};
use tokio::process::Command;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = ().serve(TokioChildProcess::new(Command::new("npx").configure(|cmd| {
cmd.arg("-y").arg("@modelcontextprotocol/server-everything");
}))?).await?;
Ok(())
}Claude Codeに登録する
作ったバイナリをClaude Codeから呼ぶには、claude mcp addで登録します。v2.1.287のclaude mcp add --helpでは、stdioサーバーの追加例が次の形で示されます。--より後ろがサーバーの起動コマンドです。
claude mcp add calculator -- ./target/release/calculator--transportを省くとstdio扱いで、-s(--scope)は既定がlocalです。userかprojectを選ぶと適用範囲が変わります。Streamable HTTPで公開したサーバーなら、claude mcp add --transport http <名前> <URL>の形で登録します。接続手順そのものはMCPサーバー自作ガイドにも書かれています。
失敗の返し方はツールエラーとプロトコルエラーの2種類
ツールの中で失敗を返す方法は2つあり、READMEは「誰の問題か」で選ぶと説明しています。
エラーの返し方
ツールエラー
ツールは動いたが結果が失敗だったときに使います。「該当する行がない」「上流が500を返した」などです。クライアントがcontentをそのまま表示するので、メッセージが利用者に届きます。
プロトコルエラー
リクエストを受け付けて処理すること自体ができないときに使います。McpError::invalid_params(..)のようにJSON-RPCのコードを付けます。クライアントは中身を表示しないため、書いたメッセージは利用者に見えません。
READMEの例では、空のクエリをErr(McpError::invalid_params(..))で弾き、クエリは通ったが行が0件のときはOk(CallToolResult::error(vec![ContentBlock::text(..)]))で返しています。READMEは「ツールが動いて失敗した」場合のほぼ全てをツールエラーにするよう勧めています。
ツールの戻り値はStringだけではありません。CallToolResult::successにContentBlockのベクターを渡すと、テキスト・画像・音声・埋め込みリソースを混在させられます。画像と音声はbase64文字列とMIMEタイプの組で渡します。
stdioの次はStreamable HTTPで公開する
複数クライアントから同じサーバーを使わせたい、あるいはリモートに置きたい場合はStreamable HTTPトランスポートに切り替えます。rmcpはサーバー側実装をtransport-streamable-http-server機能で、クライアント側をreqwestベースのtransport-streamable-http-client-reqwest機能で提供しており、サーバー側はTowerサービスとしてaxumやhyperのルーターにそのままマウントできます。
use rmcp::transport::StreamableHttpClientTransport;
let transport = StreamableHttpClientTransport::from_uri("http://localhost:8000/mcp");
let client = ClientInfo::default().serve(transport).await?;クライアント側は同時に投げられるHTTP POSTの数をmax_concurrent_requestsで制御できます(デフォルトは16、1にすると順次実行、0は1として扱われます)。キャンセルや応答用には別枠のキューが1リクエスト分確保されており、そのタイムアウトはcontrol_request_timeout(デフォルト5秒)で設定します。セッション復旧は古いPOSTを最大5秒待ってから残りを打ち切る挙動で、打ち切られたPOSTは再送されません(サーバー側で処理済みの可能性があるため)。
ステートレスで動かす場合の注意
2026-07-28のクライアントに対しては、rmcpのサーバーは設定なしでステートレスに動きます。Mcp-Session-Idヘッダーも、単独のGET/DELETEストリームも、Last-Event-IDによる再開も使いません。2026-07-28より古いクライアントにもセッションなしで応える場合は、legacy_session_modeを無効にします。旧名はstateful_modeです。
let config = StreamableHttpServerConfig::default()
.with_legacy_session_mode(false)
.with_json_response(true);
let service = StreamableHttpService::new(
|| Ok(Counter::new()),
LocalSessionManager::default().into(),
config,
);
let router = axum::Router::new().nest_service("/mcp", service);ここで見落としやすいのは、ハンドラを作るクロージャ(service_factory)がリクエストごとに呼ばれることです。DBのコネクションプールやキャッシュは、Cloneできるハンドルに入れてクロージャに持たせます。メモリ上の状態がリクエストをまたいで残る前提で書くと、値が消えたように見えます。with_json_response(true)は単純なリクエスト/応答のツールをSSEでなく1つのapplication/jsonで返す設定で、ハンドラが先に通知やサーバーからのリクエストを出す場合はtext/event-streamに切り替わります。
旧HTTP+SSEトランスポートは意図的に未実装です。2024-11-05仕様で定義されていた2エンドポイント方式(GETのSSEチャンネル + POSTのメッセージエンドポイント)は2025-03-26でStreamable HTTPに置き換えられており、rmcpは現行仕様(2025-11-25・2026-07-28)のみを対象にしています。古い2024-11-05専用サーバーと通信する場合は、Streamable HTTPを話すプロキシを前段に置くか、置き換え以前のリリースに依存を固定します。READMEは、旧トランスポートをrmcpに戻す予定はないと明記しています。
2026-07-28の新機能をrmcpはどう実装しているか
2026-07-28仕様で追加されたTasks(長時間実行タスク)とCaching(レスポンスキャッシュ)は、rmcpではそれぞれ専用の仕組みとして実装されています。
Tasks: MCP Tasks拡張(SEP-2663)に対応しており、クライアントがenable_tasks()でcapabilityを宣言すると、サーバーはリクエストごとに同期実行と非同期タスク化のどちらにするかを選べます。時間のかかるtools/callをCreateTaskResultとして返し、クライアントはtasks/getでポーリング、tasks/updateで追加入力への応答、tasks/cancelで協調的キャンセルを行います。サーバー側のタスクライフサイクル管理はrmcp::task_manager::TaskManagerが担います。
Caching: SEP-2549のキャッシュヒント(ttlMs・cacheScope)が付いたレスポンスを、クライアントが透過的にキャッシュします。対象はserver/discover・tools/list・prompts/list・resources/list・resources/templates/list・resources/readの6種類です。既定で有効ですが、サーバーがttlMsを送らない限り何も保存されないため、対応していないサーバーとの互換性は保たれます。キャッシュはTTL経過で失効するほか、対応するlist_changed通知やresource updated通知が届くと自動的に無効化されます。呼び出し側のコードを変える必要はなく、peer.list_tools()やpeer.read_resource()の既存呼び出しがそのまま恩恵を受けます。挙動を調整したい場合はClientCacheConfigでTTLの上限・最大エントリ数・プリンシパルごとの分離キャッシュ・障害時のstale応答可否などを個別に設定でき、ClientCacheConfig::disabled()で丸ごと無効化することもできます。
残る4つの新機能(サーバー発見・ネゴシエーション、トランスポート非依存のサブスクリプション、複数往復のリクエスト、標準HTTPルーティングヘッダー)もrmcpは実装済みです。サブスクリプションはリソース更新の通知をトランスポートの種類を問わず配信する仕組みで、Streamable HTTPでもstdioでも同じAPIで購読できます。複数往復のリクエストは、1回のtools/callの中でサーバーからクライアントへの問い合わせ(Samplingやユーザー確認)を複数回挟めるようにする拡張で、対話的な承認フローを持つツールを作るときに関係してきます。
複数往復のリクエストでは、サーバーが進捗をrequestStateという不透明な文字列に入れてクライアントに預け、クライアントが次のリクエストでそのまま送り返します。READMEはこの値を信頼してはいけないものとして扱っています。クライアントが書き換えられるため、意味のあるデータを入れるなら整合性の検証が必須です。request-state機能を有効にしてRequestStateCodecで封印(HMAC付与)するか、状態はサーバー側に置いてrequestStateを識別子だけに使います。クライアント側のcall_toolなどは自動で往復を繰り返し、上限はDEFAULT_MRTR_MAX_ROUNDSの10回です。
複数のレプリカで動かす場合の署名鍵の入れ替えは、次の順です。
requestStateの署名鍵を無停止で回す
- 1
新旧の鍵を全レプリカに配る
旧鍵のまま
rs1を出し続けます(with_rs1_signing("old"))。 - 2
新鍵で署名を始める
rs2を新鍵で出しつつ、旧鍵をwith_rs1_fallback("old")で残します。 - 3
旧鍵を外す
requestStateの最大有効期間が過ぎてから、旧鍵を削除します。
つまずきやすい3点
コンパイル後に出る3つの戸惑い
新しい接続方式が使われない
serve()はレガシーのMCPライフサイクル(initialize、続けてnotifications/initialized)で始まります。別の方式を選ぶにはClientServiceExt::serve_with_lifecycleを使います。Discoverは最初からserver/discoverで始める方式です。Autoはserver/discoverを試し、未実装か10秒応答がなければレガシーに戻ります。3系の破壊的変更でビルドが通らない
3系への移行には、リポジトリ冒頭で案内されているmigrationガイドが前提です。マクロ(
#[tool_router]・#[tool_handler])を使う実装は、ツールのメソッドがそのままコンパイルできます。壊れやすいのは、ServerHandlerを手書きしてcall_toolなどの戻り値型をCallToolResponseなどに変える必要がある実装と、stateful_modeからlegacy_session_modeへの改名などです。依存を上げる前に、ガイドの変更点と自分のコードを突き合わせます。引数の構造体に付けた説明がスキーマに出ない
inputSchemaとoutputSchemaに反映されるのは、フィールド名・型・フィールドのドキュメントです。構造体の名前とドキュメントコメントは無視されます。説明を入れたい場合はフィールド側に書きます。
用途別の構成の選び分け
| 用途 | 構成 | 向き不向き |
|---|---|---|
| Claude Codeからローカルで呼ぶだけ | 構成stdio + #[tool_router(server_handler)] | 向き不向き◎。マクロだけで配線が完結する |
| 複数クライアント・リモート公開 | 構成Streamable HTTP + axum/hyperマウント | 向き不向き◎。Tower生態系にそのまま乗る |
| 既存のTokioサービスへの統合 | 構成既存の非同期ランタイムにサーバーを組み込む | 向き不向き◎。既存のtokioランタイム上でserve()を呼べる |
2024-11-05専用の古いクライアントとの通信 | 構成旧HTTP+SSEトランスポート | 向き不向き×。rmcpは非対応。プロキシで吸収する |
他言語のSDKと比べたときのRust版の特徴
Rust版は#[tool]系のマクロで、ツールの説明・引数スキーマ・ハンドラ登録を1つのimplブロックにまとめます。引数の構造体にschemars::JsonSchemaを導出するだけでJSON Schemaが生成されるので、スキーマを別ファイルで手書きする場面がありません。JavaのSpring AI連携のようにフレームワークの設定で組み立てる流儀とは、定義の置き場所が違います。各言語SDKのTier判定の基準はMCP SDK Tierシステムにまとまっています。
まとめ
手元のTokioサービスにMCPを足すなら、まず#[tool_router(server_handler)]とstdioで1ツールを動かし、claude mcp addで登録して呼べることを確かめます。そのあとStreamable HTTPへ移すときは、ハンドラがリクエストごとに作られる点と、旧SSEが使えない点を先に押さえておくと手戻りが出にくくなります。