日本の山岳データ 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 Protocol の Rust 公式 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 にマウントする
StreamableHttpService は tower::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::Deserialize と schemars::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> を実装した型(String や serde_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_params か internal_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_tools や call_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 の理解を深めることができた気がします。。。


コメント