日本の山岳一覧・百名山 API を開発しましたCLICK !

Rust | rmcp で Remote MCP Server を実装する(Streamable HTTP × Axum)

  • URLをコピーしました!

日本の山岳データ API mountix に、Rust の MCP 公式 SDK である rmcp を使って Remote MCP Server を実装してみました。

今回は rmcp の使い方にフォーカスして、記事にしたためます。

  • rmcp のクレート構成(feature フラグ)
  • StreamableHttpService で Streamable HTTP transport を Axum にマウントする方法
  • #[tool_router] / #[tool] / #[tool_handler] マクロでツールを定義する方法
  • schemars と組み合わせたパラメータスキーマの作り方
  • ErrorData でのエラーマッピング
  • StreamableHttpServerConfig の設定項目(stateful / JSON / allowed hosts)

ソースコードは Github で公開しています。気になる方はチェックしてみてください。

目次

rmcp とは

rmcp は、Model Context ProtocolRust 公式 SDK です。

サーバー実装・クライアント実装の両方が同居していて、用途に応じて feature フラグで必要な機能だけを有効化する作りになっています。

今回 mountix で使ったのはサーバー側の機能と Streamable HTTP transport です。

Cargo.toml の定義です。(workspace.dependencies 抜粋)

axum = "0.8.4"
rmcp = { version = "1.7", features = ["server", "macros", "transport-streamable-http-server"] }
schemars = "1.0"
serde_json = "1.0.140"
  • server: MCP サーバー側の API(ServerHandler トレイトなど)を有効化
  • macros: #[tool_router] / #[tool] / #[tool_handler] などのマクロを有効化
  • transport-streamable-http-server: Streamable HTTP transport のサーバー実装(StreamableHttpService)を有効化

ツールの入力パラメータには JSON Schema が必要なので、schemars も合わせて入れています。

Streamable HTTP transport で公開する

MCP には stdio や SSE などいくつかの transport があります。

今回は既存の Axum サーバーにそのまま相乗りできるため、 HTTP でホストできる Streamable HTTP を採用しました。

rmcp が提供する StreamableHttpService を組み立てて、Axum の tower::Service としてマウントします。

use rmcp::transport::streamable_http_server::{
    session::local::LocalSessionManager, StreamableHttpServerConfig, StreamableHttpService,
};

pub fn mcp_service(
    modules: Arc<Modules>,
) -> StreamableHttpService<MountixMcpServer, LocalSessionManager> {
    let modules_for_factory = modules.clone();
    let config = mcp_config();

    StreamableHttpService::new(
        move || Ok(MountixMcpServer::new(modules_for_factory.clone())),
        LocalSessionManager::default().into(),
        config,
    )
}

StreamableHttpService::new には次の 3 つを渡します。

サーバーインスタンスのファクトリ: Fn() -> Result

接続・セッションごとにサーバーインスタンスを生成するクロージャです。クロージャの中で Modules(DI コンテナ)を clone して渡すことで、各ツールから共通のユースケースを呼べるようにしています。

セッションマネージャ: Arc<dyn SessionManager>

今回はインメモリの LocalSessionManager を使います。.into()Arc に変換しています。複数プロセスで状態を共有したい場合は、独自の SessionManager 実装を差し込むこともできます。

設定: StreamableHttpServerConfig

後述の mcp_config() で組み立てます。

StreamableHttpServerConfig の設定

rmcp の設定はビルダー形式の API になっていて、必要な項目だけ上書きできます。

fn mcp_config() -> StreamableHttpServerConfig {
    let mut config = StreamableHttpServerConfig::default()
        .with_stateful_mode(parse_bool_env("MCP_STATEFUL_MODE", false))
        .with_json_response(parse_bool_env("MCP_JSON_RESPONSE", true));

    if let Ok(hosts) = env::var("MCP_ALLOWED_HOSTS") {
        let allowed_hosts: Vec<String> = hosts
            .split(',')
            .map(str::trim)
            .filter(|host| !host.is_empty())
            .map(str::to_string)
            .collect();
        if !allowed_hosts.is_empty() {
            config = config.with_allowed_hosts(allowed_hosts);
        }
    } else if let Ok(host) = env::var("HOST") {
        config = config.with_allowed_hosts(["localhost", "127.0.0.1", "::1", host.as_str()]);
    }

    config
}

主な設定項目は以下です。

  • with_stateful_mode(bool): セッションを保持するかどうか。true にするとセッション ID ベースで状態を保持します。
  • with_json_response(bool): レスポンスを JSON で返すか SSE で返すか。true で JSON、false で SSE です。
  • with_allowed_hosts(impl IntoIterator<Item = impl Into<String>>): 許可する Host ヘッダのリスト。DNS rebinding 対策として、想定外のホスト名でのアクセスを弾けます。

Axum の Router にマウントする

StreamableHttpServicetower::Service を実装しているので、Axum の nest_service でそのままマウントできます。

let mcp_service = mcp_service(modules.clone());

let app = Router::new()
    .nest("/api/v1/", info_router)
    .nest("/api/v1/hc", hc_router)
    .nest("/api/v1/mountains", mountain_router)
    .nest_service("/mcp", mcp_service) // ← MCP エンドポイント
    .layer(cors)
    .layer(Extension(modules));

これで POST /mcp が MCP の Streamable HTTP エンドポイントになります。

ツールを定義する

ここからが rmcp の本領発揮ポイントです。サーバー本体・パラメータ・ツール関数・ハンドラの 4 つを書けば、ツールの一覧化や呼び出しのディスパッチはマクロが全部やってくれます。

サーバー構造体

MCP サーバー本体は普通の struct として定義します。#[derive(Clone)] を付けて、ツールルータを保持しておきます。

use rmcp::handler::server::router::tool::ToolRouter;

#[derive(Clone)]
pub struct MountixMcpServer {
    modules: Arc<Modules>,
    #[allow(dead_code)]
    tool_router: ToolRouter<Self>,
}

impl MountixMcpServer {
    pub fn new(modules: Arc<Modules>) -> Self {
        Self {
            modules,
            tool_router: Self::tool_router(),
        }
    }
}

Self::tool_router() というメソッドは、後ほど登場する #[tool_router] マクロが自動生成してくれるものです。ToolRouter<Self> 型のインスタンスを返してくれて、これがツール一覧の管理と呼び出しのディスパッチを担います。

パラメータ構造体 – schemars

ツールの入力パラメータは、serde::Deserializeschemars::JsonSchema を derive した構造体で定義します。#[schemars(description = "...")] を付けると、その説明がそのまま MCP クライアントに見えるツールパラメータの説明になります。

use schemars::JsonSchema;
use serde::Deserialize;

#[derive(Debug, Deserialize, JsonSchema)]
pub struct GetMountainParams {
    #[schemars(description = "山岳ID")]
    pub id: String,
}

#[derive(Debug, Deserialize, JsonSchema)]
pub struct FindMountainsParams {
    #[schemars(description = "山岳名(部分一致)")]
    pub name: Option<String>,
    #[schemars(description = "都道府県ID")]
    pub prefecture: Option<String>,
    #[schemars(description = "タグID")]
    pub tag: Option<String>,
    #[schemars(description = "取得開始位置")]
    pub offset: Option<String>,
    #[schemars(description = "取得件数")]
    pub limit: Option<String>,
    #[schemars(description = "ソート条件")]
    pub sort: Option<String>,
}

Option<T> にしたフィールドはそのまま「省略可能なパラメータ」として JSON Schema に反映されます。MCP クライアント側はこのスキーマを見てツールの呼び出し UI を組み立てたり、LLM へのツール仕様として提示したりします。

ツール関数 –#[tool_router] / #[tool]

ツールの実体は、#[tool_router] を付けた impl ブロックの中に、#[tool] を付けた async fn として書きます。

引数で Parameters<T> を受け取ると、rmcp がリクエストの JSON を T に自動でデシリアライズしてくれます。返り値は Result<R, ErrorData> の形で、R には Into<CallToolResult> を実装した型(Stringserde_json::Value など)を指定できます。

use rmcp::handler::server::wrapper::Parameters;
use rmcp::{tool, tool_router, ErrorData};

#[tool_router]
impl MountixMcpServer {
    #[tool(description = "IDを指定して山岳情報を1件取得します。")]
    async fn get_mountain(
        &self,
        Parameters(params): Parameters<GetMountainParams>,
    ) -> Result<String, ErrorData> {
        let result = self.modules.mountain_use_case().get(params.id).await;

        match result {
            Ok(Some(mountain)) => {
                let json: JsonMountain = mountain.into();
                to_json_string(&json)
            }
            Ok(None) => Err(ErrorData::invalid_params(
                "山岳情報が見つかりませんでした。",
                None,
            )),
            Err(error) if error.error_code == ErrorCode::InvalidId => Err(
                ErrorData::invalid_params("指定された山岳IDが不正です。", None),
            ),
            Err(_) => Err(ErrorData::internal_error(
                "山岳情報を取得中に予期せぬエラーが発生しました。",
                None,
            )),
        }
    }

    #[tool(description = "条件を指定して山岳情報を検索します。")]
    async fn find_mountains(
        &self,
        Parameters(params): Parameters<FindMountainsParams>,
    ) -> Result<String, ErrorData> {
        let search_query = MountainSearchQuery {
            name: params.name,
            prefecture: params.prefecture,
            tag: params.tag,
            offset: params.offset,
            limit: params.limit,
            sort: params.sort,
        };

        match self.modules.mountain_use_case().find(search_query).await {
            Ok(result) => {
                let json: JsonMountainsResponse = result.into();
                to_json_string(&json)
            }
            Err(error) if error.error_code == ErrorCode::InvalidQueryParam => {
                Err(ErrorData::invalid_params(error.messages.join("\n"), None))
            }
            Err(_) => Err(ErrorData::internal_error(
                "山岳情報を検索中に予期せぬエラーが発生しました。",
                None,
            )),
        }
    }

    // find_mountains_by_box / find_surroundings ...
}

主なポイントは以下です。

  • #[tool_router] を付けた impl ブロック内の #[tool] 関数は、まとめて Self::tool_router() から取り出せる ToolRouter<Self> に登録されます。
  • #[tool(description = "...")]description がツールの説明になります。LLM のツール選択にも効くので、できるだけ具体的に書くのがおすすめです。
  • 関数名がそのままツール名になります(上の例だと get_mountain / find_mountains)。
  • Parameters<T> で受け取った T は前述の JsonSchema 派生の構造体です。これだけで JSON Schema を自動生成しつつ、リクエスト JSON のデシリアライズもやってくれます。

エラーハンドリング – ErrorData

ツールから返すエラーは rmcp::ErrorData です。

便利なコンストラクタがいくつか用意されていて、用途に応じて使い分けます。

  • ErrorData::invalid_params(message, data): パラメータ不正など、クライアント側に起因するエラー
  • ErrorData::internal_error(message, data): サーバー内部のエラー
  • 他にも method_not_found / invalid_request などがあります

Mountix では、ドメイン側のエラーコード(ErrorCode::InvalidId / InvalidQueryParam)を見て invalid_paramsinternal_error に振り分けています。MCP クライアント側にも分かりやすいエラーメッセージが届くようになります。

ServerHandler の実装 – #[tool_handler]

最後に ServerHandler を実装します。rmcp には #[tool_handler] という属性マクロがあり、サーバー名や instructions を渡すだけで、ツールの一覧化や呼び出しのディスパッチをまるごと面倒見てくれます。

use rmcp::{tool_handler, ServerHandler};

#[tool_handler(
    name = "mountix",
    instructions = "Mountix 日本の山岳データ API。百名山などの山岳情報を検索・取得できます。"
)]
impl ServerHandler for MountixMcpServer {}

name はサーバーの識別名、instructions はクライアントに伝えるサーバーの説明文(LLM への system 指示に近い使われ方)です。

impl ServerHandler for MountixMcpServer {} の中身が空でも、マクロが list_toolscall_tool といった必要なメソッドを自動実装してくれます。#[tool_router] で登録されたツール群が、そのまま MCP のツール一覧として公開される、という仕組みです。

JSON 文字列で返すヘルパー

ツールの戻り値として JSON 文字列を返すケースが多いので、共通のヘルパーを作っておくと便利です。

fn to_json_string<T: serde::Serialize>(value: &T) -> Result<String, ErrorData> {
    serde_json::to_string_pretty(value).map_err(|error| {
        ErrorData::internal_error(format!("JSONの生成に失敗しました: {error}"), None)
    })
}

String を返すと、rmcp 側で MCP の CallToolResult(テキストコンテンツ)に変換してくれます。

MCP を使ってみる

inspector でデバッグ

MCP Inspector を使って、動作確認することができます。

Model Context Protocol が公式で提供している MCP サーバーを対話的にテスト・デバッグする公式の開発者向けツールです。

次のコマンドで実行できます。

npx @modelcontextprotocol/inspector

ローカルでブラウザが立ち上がります。URL を適宜変更して、接続すると実装したツールを検証することができます。

Cursor から接続する

Streamable HTTP transport の Remote MCP Server なので、Cursor などのクライアントからは HTTP の URL を指定するだけで接続できます。mcp.json に次のように書きます。

{
  "mcpServers": {
    "mountix-mcp": {
      "name": "mountix-mcp",
      "url": "https://mountix.codemountains.org/mcp",
      "headers": {}
    }
  }
}

実際に MCP を使ってみる…

富士山の標高を教えて。

富士山から半径100kmにある山を教えて。

ちゃんと MCP のツールが実行されていることを確認できました!

まとめ

いつもはただ使う側でしたが…

自分で MCP を実装することで、MCP の理解を深めることができた気がします。。。

この記事が気に入ったら
フォローしてね!

  • URLをコピーしました!

コメント

コメントする

目次