AI開発の現場で、LLMと外部ツール・データソースの連携が大きな課題になっています。Model Context Protocol(MCP)は、この課題に対する標準化された解決策です。Rustは速度・安全性・低リソース消費の面でMCPサーバーの実装言語として優れた選択肢であり、公式SDK「rmcp」を使えば型安全なサーバーを効率的に構築できます。

MCPとは何か ─ プロトコルの基本構造

MCPはAnthropicが提唱するオープンプロトコルで、LLMアプリケーションと外部リソースをJSON-RPC 2.0ベースで接続します。Language Server Protocol(LSP)がエディタとプログラミング言語の橋渡しをしたのと同様に、MCPはAIアプリケーションと外部ツール群の間に標準的なインターフェースを提供します。

MCPの通信は3つの役割で構成されます。

役割説明具体例
HostLLMアプリケーション本体。接続を開始するClaude Desktop, Cursor
ClientHost内の接続コネクタMCPクライアントモジュール
Server機能やデータを提供するサービス自作のMCPサーバー

サーバーが提供する機能は3つのプリミティブに分類されます。

  • Tools: LLMが実行できる関数。ファイル検索、API呼び出し、計算処理など
  • Resources: ユーザーやLLMが参照できるデータソース。ドキュメント、設定ファイルなど
  • Prompts: 再利用可能なテンプレート。ワークフローの定型操作を定義

2025年11月に公開された最新仕様(バージョン2025-11-25)では、非同期タスク管理(Tasks)やOAuth 2.1ベースの認可フレームワークが追加されています(出典: MCP公式仕様)。

なぜRustでMCPサーバーを書くのか

MCPサーバーの実装言語としてはTypeScript(公式SDK)やPythonが多く使われています。Rustを選ぶ理由は主に3つあります。

1. 起動速度とメモリ効率

MCPサーバーはstdioトランスポートの場合、ツール呼び出しのたびにプロセスが起動されることがあります。Rustのバイナリは起動が高速で、Node.jsのようなランタイム初期化のオーバーヘッドがありません。メモリ使用量も数MB程度に収まるため、複数のMCPサーバーを同時に実行する環境でもリソースを圧迫しません。

2. 型安全性とコンパイル時検証

rmcpの#[tool]マクロはJSON Schemaを自動生成します。Rustの型システムにより、引数の型不一致やnull安全性の問題がコンパイル時に検出されます。TypeScript SDKでもZodによるバリデーションは可能ですが、ランタイムエラーとしてしか検出できません。

3. シングルバイナリ配布

cargo build --releaseで生成される実行ファイルは単体で動作します。Node.jsやPythonのようにnode_modulesや仮想環境を配布先に用意する必要がなく、MCPクライアントの設定も単純になります。

TypeScript SDK との機能比較

観点rmcp(Rust)@modelcontextprotocol/sdk(TypeScript)
ランタイム不要(ネイティブバイナリ)Node.js必須
型チェックコンパイル時に完結tscによる静的チェック+Zodランタイム検証
起動時間数ミリ秒100ms〜(V8初期化含む)
メモリ使用量数MB30MB〜
非同期ランタイムtokioNode.jsイベントループ
JSON Schema生成schemarsが自動生成Zodスキーマから変換
プロトコルバージョン2025-11-25対応2025-11-25対応
配布方式シングルバイナリnpm package + node_modules
エコシステム成熟度成長中最も成熟
学習コストRust経験が必要Web開発者に馴染みやすい

TypeScript SDKはエコシステムの成熟度とWeb開発者の参入しやすさで優位です。一方、パフォーマンスとデプロイの手軽さが重視される場面ではRustが適しています。

開発環境のセットアップ

前提条件

  • Rust 1.75以上
  • Cargo(Rustに同梱)

Rustがインストールされていない場合は以下のコマンドで導入します。

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

プロジェクトの作成

cargo new my-mcp-server
cd my-mcp-server

Cargo.tomlの設定

rmcpの依存関係を追加します。feature flagsで必要な機能を選択します。

[package]
name = "my-mcp-server"
version = "0.1.0"
edition = "2021"

[dependencies]
rmcp = { version = "0.14", features = ["server", "transport-io"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
schemars = "0.8"
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

rmcpの主要なfeature flags

フラグ用途
serverMCPサーバー機能を有効化
clientMCPクライアント機能を有効化
transport-iostdin/stdout通信(最も基本的な方式)
transport-child-process子プロセスとしての起動対応
transport-streamable-http-clientStreamable HTTPクライアント
macros#[tool]マクロ(デフォルトで有効)

stdioトランスポートのみでよい場合はservertransport-ioの2つで十分です。SSEやHTTPベースのリモート接続が必要な場合は、対応するトランスポートフラグを追加します。

最小構成のMCPサーバー実装

ツールの定義

rmcpでは#[tool]アトリビュートマクロを使って、構造体のメソッドをMCPツールとして公開します。

use rmcp::{
    ServerHandler, ServiceExt,
    model::*,
    tool, tool_router,
    transport::stdio,
    ErrorData as McpError,
    schemars,
};
use serde::Deserialize;

#[derive(Debug, Clone)]
pub struct MyServer;

#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct GreetParams {
    /// 挨拶する相手の名前
    pub name: String,
}

#[tool_router]
impl MyServer {
    #[tool(description = "指定された名前に挨拶を返す")]
    async fn greet(&self, #[tool(params)] params: GreetParams) -> Result<CallToolResult, McpError> {
        let message = format!("こんにちは、{}さん!", params.name);
        Ok(CallToolResult::success(vec![
            Content::text(message),
        ]))
    }
}

ポイントは以下の3つです。

  • #[tool_router]implブロックに付与し、ツール群の入れ物であることを宣言
  • 各メソッドに#[tool(description = "...")]でツールの説明を記述
  • 引数の型にschemars::JsonSchemaをderiveし、JSON Schemaが自動生成される仕組み

サーバー情報の宣言

ServerHandlerトレイトでサーバーのメタ情報を定義します。#[tool_handler]マクロでToolルーティングを自動実装できます。

use rmcp::tool_handler;

#[tool_handler]
impl ServerHandler for MyServer {
    fn get_info(&self) -> ServerInfo {
        ServerInfo {
            instructions: Some("名前を指定して挨拶を返すサーバーです".into()),
            capabilities: ServerCapabilities::builder()
                .enable_tools()
                .build(),
            ..Default::default()
        }
    }
}

ServerCapabilities::builder()でサーバーがサポートする機能(Tools、Resources、Prompts)を宣言します。上記の例ではToolsのみを有効にしています。

main関数とサーバー起動

use tracing_subscriber::EnvFilter;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // ロギングの初期化(stderr出力)
    tracing_subscriber::fmt()
        .with_env_filter(EnvFilter::from_default_env())
        .with_writer(std::io::stderr)
        .init();

    tracing::info!("MCPサーバーを起動します");

    let service = MyServer.serve(stdio()).await?;
    service.waiting().await?;

    Ok(())
}

重要な点として、ロギング出力は必ずstderrに向けます。stdoutはMCPのJSON-RPC通信に使用されるため、ログメッセージがstdoutに混入するとプロトコルエラーの原因になります。

サーバーの起動はServiceExtトレイトが提供する.serve()メソッドで行います。stdio()はstdin/stdoutのトランスポートペアを返す関数で、transport-ioフィーチャーで有効になります。.waiting()でサーバーがシャットダウンされるまでブロックします。

ビルドと動作確認

cargo build --release

生成されたバイナリはtarget/release/my-mcp-serverに出力されます。

MCPクライアントとの接続設定

Claude Desktopの設定

Claude Desktopではclaude_desktop_config.jsonにサーバーを登録します。

macOS: ~/Library/Application Support/Claude/claude_desktop_config.json Windows: %APPDATA%\Claude\claude_desktop_config.json

{
  "mcpServers": {
    "my-mcp-server": {
      "command": "/path/to/target/release/my-mcp-server",
      "args": []
    }
  }
}

設定ファイルを保存してClaude Desktopを再起動すると、チャット画面のツール一覧にgreetが表示されます。

Cursorでの設定

Cursorの場合は.cursor/mcp.jsonをプロジェクトルートに配置します。

{
  "mcpServers": {
    "my-mcp-server": {
      "command": "/path/to/target/release/my-mcp-server",
      "args": []
    }
  }
}

Claude Codeでの設定

Claude Codeではclaude mcp addコマンドで登録します。

claude mcp add my-mcp-server /path/to/target/release/my-mcp-server

設定は~/.claude.jsonに保存されます。

実用的なMCPサーバーの構築例

最小構成からステップアップして、ファイル検索ツールを持つMCPサーバーを実装します。

ファイル検索ツール付きサーバー

use rmcp::{
    ServerHandler, ServiceExt,
    model::*,
    tool, tool_handler, tool_router,
    transport::stdio,
    ErrorData as McpError,
    schemars,
};
use serde::Deserialize;
use std::path::PathBuf;

#[derive(Debug, Clone)]
pub struct FileSearchServer {
    root_dir: PathBuf,
}

impl FileSearchServer {
    pub fn new(root_dir: PathBuf) -> Self {
        Self { root_dir }
    }
}

#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct SearchParams {
    /// 検索するファイル名のパターン(部分一致)
    pub pattern: String,
    /// 検索対象の拡張子(例: "rs", "toml")
    pub extension: Option<String>,
}

#[tool_router]
impl FileSearchServer {
    #[tool(description = "指定パターンに一致するファイルを検索する")]
    async fn search_files(
        &self,
        #[tool(params)] params: SearchParams,
    ) -> Result<CallToolResult, McpError> {
        let mut results = Vec::new();

        self.walk_dir(&self.root_dir, &params.pattern, &params.extension, &mut results)
            .map_err(|e| McpError {
                code: -1,
                message: format!("ファイル検索エラー: {}", e).into(),
                data: None,
            })?;

        if results.is_empty() {
            return Ok(CallToolResult::success(vec![
                Content::text("一致するファイルが見つかりませんでした"),
            ]));
        }

        let output = results.join("\n");
        Ok(CallToolResult::success(vec![Content::text(output)]))
    }
}

impl FileSearchServer {
    fn walk_dir(
        &self,
        dir: &PathBuf,
        pattern: &str,
        extension: &Option<String>,
        results: &mut Vec<String>,
    ) -> anyhow::Result<()> {
        for entry in std::fs::read_dir(dir)? {
            let entry = entry?;
            let path = entry.path();
            if path.is_dir() {
                self.walk_dir(&path, pattern, extension, results)?;
            } else if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
                let matches_pattern = name.contains(pattern);
                let matches_ext = extension.as_ref().map_or(true, |ext| {
                    path.extension().and_then(|e| e.to_str()) == Some(ext.as_str())
                });
                if matches_pattern && matches_ext {
                    results.push(path.display().to_string());
                }
            }
        }
        Ok(())
    }
}

#[tool_handler]
impl ServerHandler for FileSearchServer {
    fn get_info(&self) -> ServerInfo {
        ServerInfo {
            instructions: Some("プロジェクト内のファイルを検索するサーバーです".into()),
            capabilities: ServerCapabilities::builder()
                .enable_tools()
                .build(),
            ..Default::default()
        }
    }
}

このサーバーでは、初期化時に検索ルートディレクトリを受け取り、再帰的にファイルを検索するToolを公開しています。Option<String>型のパラメータは、MCPクライアント側では省略可能な引数として認識されます。

トランスポート層の選択

MCPはプロトコル仕様レベルで複数のトランスポートを定義しています。rmcpでは用途に応じてfeature flagで切り替えます。

トランスポートfeature flag通信方式適用場面
stdiotransport-iostdin/stdoutローカル実行。最も簡単
Streamable HTTPtransport-streamable-http-clientHTTP + SSEリモートサーバー。Webデプロイ向き

stdioトランスポート

ローカル環境での標準的な方式です。MCPクライアント(Claude Desktop, Cursorなど)がサーバーバイナリを子プロセスとして起動し、stdin/stdoutでJSON-RPCメッセージをやり取りします。

セットアップの手軽さが最大の利点です。バイナリのパスを設定ファイルに書くだけで接続できます。制約として、クライアントとサーバーが同一マシン上で動作する必要があります。

Streamable HTTPトランスポート

2025-11-25仕様で追加された方式です。HTTPリクエストでメッセージを送信し、Server-Sent Events(SSE)でストリーミングレスポンスを受け取ります。従来のSSEトランスポートを置き換える位置づけです。

rmcpでStreamable HTTPを利用する場合は、axumなどのWebフレームワークと組み合わせます。リモートサーバーとして公開でき、複数のクライアントからの接続も受け付けられるため、チーム開発やクラウド環境での運用に向いています。

エラーハンドリングの設計

MCPサーバーでは、ツール実行時のエラーを適切にクライアントへ伝えることが重要です。rmcpでは2つのエラー種別を使い分けます。

プロトコルエラー(ErrorData)

JSON-RPCレベルのエラーです。ツールが見つからない、パラメータが不正などの場合に返します。rmcpではErrorData型をMcpErrorとしてエイリアスするのが一般的です。

use rmcp::ErrorData as McpError;

Err(McpError {
    code: -32602,
    message: "nameパラメータは必須です".into(),
    data: None,
})

ツール実行エラー(CallToolResult)

ツール自体は正常に呼び出せたが、処理内容としてエラーが発生した場合に使います。

Ok(CallToolResult::error(vec![
    Content::text("指定されたファイルが見つかりません"),
]))

CallToolResult::errorはHTTPの4xx/5xxに相当するもので、LLMはこの結果を見て次のアクションを判断します。外部API呼び出しの失敗やファイルの存在チェックなど、ビジネスロジック上のエラーはこちらで返します。

開発時のデバッグ手法

MCP Inspectorの活用

MCP公式が提供するInspectorツールを使うと、サーバーとの通信内容をブラウザ上で確認できます。

npx @modelcontextprotocol/inspector target/release/my-mcp-server

Inspector画面では以下の操作が可能です。

  • サーバーが公開しているTools/Resources/Promptsの一覧表示
  • 任意のToolを手動で呼び出してレスポンスを確認
  • JSON-RPCメッセージの送受信ログの閲覧

Claude DesktopやCursorで実際にテストする前に、Inspectorで動作を確認しておくとデバッグが効率的です。

ログ出力の活用

tracingクレートのログレベルを環境変数で制御します。

RUST_LOG=debug target/release/my-mcp-server

よくあるトラブルと対処法

ツールが表示されない

  • ServerCapabilities.enable_tools()を呼んでいるか確認します
  • #[tool_router]#[tool_handler]がそれぞれ正しいimplブロックに付与されているか確認します
  • MCPクライアントを再起動して設定の再読み込みを行います

JSON-RPCエラーが発生する

  • ログ出力がstdoutに流れていないか確認します。tracing_subscriber.with_writer(std::io::stderr)設定が必須です
  • println!マクロが残っていないかコードを検索します

パラメータの型が合わない

  • 引数の構造体にschemars::JsonSchemaのderiveが付いているか確認します
  • Option<T>を使って省略可能なパラメータを定義し、必須パラメータと区別します

バイナリが見つからない

  • MCPクライアントの設定ファイルに記載するパスは絶対パスを指定します
  • cargo build --releaseでリリースビルドを作成し、そのパスを指定します

Rust MCPライブラリの選択肢

rmcp以外にもRustでMCPサーバーを構築するためのライブラリが存在します。

ライブラリ開発元特徴プロトコルバージョン
rmcpMCP公式公式SDK。tokioベース。#[tool]マクロによる宣言的定義2025-11-25
rust-mcp-sdkコミュニティプロトコル準拠を重視。仕様の完全実装を明示2025-11-25
mcpkitコミュニティ#[mcp_server]マクロでボイラープレートを削減2025-11-25

rmcpはAnthropicが管理する公式リポジトリ(GitHub Star数 約3,000、コントリビュータ130名以上)に属しており、仕様追従の速さとコミュニティの規模で優位です(出典: GitHub)。プロダクション利用ではrmcpを第一選択肢とするのが妥当です。

まとめ

RustとrmcpによるMCPサーバー開発の要点を整理します。

  • rmcpは公式SDK: cargo add rmcpで導入し、#[tool]マクロでツールを宣言的に定義
  • stdioトランスポートが基本: ローカル環境ではstdin/stdout通信が最もシンプル
  • ログはstderrへ: stdoutはJSON-RPC通信専用。ログ混入はプロトコルエラーの原因
  • シングルバイナリ配布: cargo build --releaseの成果物だけでMCPクライアントと連携可能
  • MCP Inspector: npx @modelcontextprotocol/inspectorでブラウザからツールをテスト

rmcpの公式リポジトリ: https://github.com/modelcontextprotocol/rust-sdk

rmcpのドキュメント: https://docs.rs/rmcp