From b6142a7eb56647a7645d8058a0c3572639f22f4a Mon Sep 17 00:00:00 2001 From: Kazuno Fukuda <4kz12zz@gmail.com> Date: Sun, 31 May 2026 00:55:55 +0900 Subject: [PATCH 1/4] feat: Implement MCP service with RMCP integration --- .cursor/mcp.json | 7 ++ Cargo.toml | 2 + mountix-driver/Cargo.toml | 3 + mountix-driver/src/lib.rs | 1 + mountix-driver/src/mcp/mod.rs | 73 +++++++++++ mountix-driver/src/mcp/server.rs | 202 ++++++++++++++++++++++++++++++ mountix-driver/src/startup/mod.rs | 12 +- sample.env | 5 + 8 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 .cursor/mcp.json create mode 100644 mountix-driver/src/mcp/mod.rs create mode 100644 mountix-driver/src/mcp/server.rs diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 0000000..570db56 --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,7 @@ +{ + "mcpServers": { + "mountix-mcp-development": { + "url": "http://127.0.0.1:8080/api/v1/mcp" + } + } +} diff --git a/Cargo.toml b/Cargo.toml index 61df654..6a6a1fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,3 +31,5 @@ tokio-test = "0.4.4" mockall = "0.13.0" hyper = "1.6.0" serde_json = "1.0.140" +rmcp = { version = "1.7", features = ["server", "macros", "transport-streamable-http-server"] } +schemars = "1.0" diff --git a/mountix-driver/Cargo.toml b/mountix-driver/Cargo.toml index c4ec68b..7c4e5dd 100644 --- a/mountix-driver/Cargo.toml +++ b/mountix-driver/Cargo.toml @@ -16,6 +16,9 @@ tracing-subscriber = { workspace = true } dotenvy = { workspace = true } tower = { workspace = true } tower-http = { workspace = true } +rmcp = { workspace = true } +schemars = { workspace = true } +serde_json = { workspace = true } [dev-dependencies] tokio-test = { workspace = true } diff --git a/mountix-driver/src/lib.rs b/mountix-driver/src/lib.rs index 17af32e..94079c1 100644 --- a/mountix-driver/src/lib.rs +++ b/mountix-driver/src/lib.rs @@ -1,3 +1,4 @@ +pub mod mcp; pub mod model; pub mod module; pub mod routes; diff --git a/mountix-driver/src/mcp/mod.rs b/mountix-driver/src/mcp/mod.rs new file mode 100644 index 0000000..f7cc590 --- /dev/null +++ b/mountix-driver/src/mcp/mod.rs @@ -0,0 +1,73 @@ +pub mod server; + +use crate::mcp::server::MountixMcpServer; +use crate::module::Modules; +use rmcp::transport::streamable_http_server::{ + session::local::LocalSessionManager, StreamableHttpServerConfig, StreamableHttpService, +}; +use std::env; +use std::sync::Arc; + +pub fn mcp_service( + modules: Arc, +) -> StreamableHttpService { + let modules_for_factory = modules.clone(); + let config = mcp_config(); + + StreamableHttpService::new( + move || Ok(MountixMcpServer::new(modules_for_factory.clone())), + LocalSessionManager::default().into(), + config, + ) +} + +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 = 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 +} + +fn parse_bool_env(key: &str, default: bool) -> bool { + env::var(key) + .ok() + .and_then(|value| match value.to_lowercase().as_str() { + "1" | "true" | "yes" | "on" => Some(true), + "0" | "false" | "no" | "off" => Some(false), + _ => None, + }) + .unwrap_or(default) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_bool_env() { + std::env::set_var("TEST_BOOL_TRUE", "true"); + std::env::set_var("TEST_BOOL_FALSE", "false"); + + assert!(parse_bool_env("TEST_BOOL_TRUE", false)); + assert!(!parse_bool_env("TEST_BOOL_FALSE", true)); + assert!(parse_bool_env("UNDEFINED_BOOL", true)); + + std::env::remove_var("TEST_BOOL_TRUE"); + std::env::remove_var("TEST_BOOL_FALSE"); + } +} diff --git a/mountix-driver/src/mcp/server.rs b/mountix-driver/src/mcp/server.rs new file mode 100644 index 0000000..1233825 --- /dev/null +++ b/mountix-driver/src/mcp/server.rs @@ -0,0 +1,202 @@ +use crate::model::mountain::{JsonBoxMountainsResponse, JsonMountain, JsonMountainsResponse}; +use crate::model::surrounding_mountain::JsonSurroundingMountainResponse; +use crate::module::{Modules, ModulesExt}; +use mountix_app::model::mountain::{MountainBoxSearchQuery, MountainSearchQuery}; +use mountix_app::model::surrounding_mountain::SurroundingMountainSearchQuery; +use mountix_kernel::model::ErrorCode; +use rmcp::handler::server::router::tool::ToolRouter; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::{tool, tool_handler, tool_router, ErrorData, ServerHandler}; +use schemars::JsonSchema; +use serde::Deserialize; +use std::sync::Arc; + +#[derive(Clone)] +pub struct MountixMcpServer { + modules: Arc, + #[allow(dead_code)] + tool_router: ToolRouter, +} + +impl MountixMcpServer { + pub fn new(modules: Arc) -> Self { + Self { + modules, + tool_router: Self::tool_router(), + } + } +} + +#[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, + #[schemars(description = "都道府県ID")] + pub prefecture: Option, + #[schemars(description = "タグID")] + pub tag: Option, + #[schemars(description = "取得開始位置")] + pub offset: Option, + #[schemars(description = "取得件数")] + pub limit: Option, + #[schemars(description = "ソート条件")] + pub sort: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct FindMountainsByBoxParams { + #[schemars(description = "検索範囲。形式: (左下経度,左下緯度),(右上経度,右上緯度)")] + pub box_coordinates: String, + #[schemars(description = "山岳名(部分一致)")] + pub name: Option, + #[schemars(description = "タグID")] + pub tag: Option, + #[schemars(description = "ソート条件")] + pub sort: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct FindSurroundingsParams { + #[schemars(description = "基準となる山岳ID")] + pub mountain_id: String, + #[schemars(description = "検索距離(メートル)")] + pub distance: Option, +} + +#[tool_router] +impl MountixMcpServer { + #[tool(description = "IDを指定して山岳情報を1件取得します。")] + async fn get_mountain( + &self, + Parameters(params): Parameters, + ) -> Result { + 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, + ) -> Result { + 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, + )), + } + } + + #[tool(description = "地理的な矩形範囲内の山岳情報を検索します。")] + async fn find_mountains_by_box( + &self, + Parameters(params): Parameters, + ) -> Result { + let search_query = MountainBoxSearchQuery { + box_coordinates: params.box_coordinates, + name: params.name, + tag: params.tag, + sort: params.sort, + }; + + match self + .modules + .mountain_use_case() + .find_box(search_query) + .await + { + Ok(result) => { + let json: JsonBoxMountainsResponse = 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, + )), + } + } + + #[tool(description = "指定した山岳の周辺にある山岳情報を検索します。")] + async fn find_surroundings( + &self, + Parameters(params): Parameters, + ) -> Result { + let search_query = SurroundingMountainSearchQuery { + distance: params.distance, + }; + + match self + .modules + .surrounding_mountain_use_case() + .find(params.mountain_id, search_query) + .await + { + Ok(result) => { + let json: JsonSurroundingMountainResponse = 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, + )), + } + } +} + +#[tool_handler( + name = "mountix", + version = "1.2.0", + instructions = "Mountix 日本の山岳データ API。百名山などの山岳情報を検索・取得できます。" +)] +impl ServerHandler for MountixMcpServer {} + +fn to_json_string(value: &T) -> Result { + serde_json::to_string_pretty(value).map_err(|error| { + ErrorData::internal_error(format!("JSONの生成に失敗しました: {error}"), None) + }) +} diff --git a/mountix-driver/src/startup/mod.rs b/mountix-driver/src/startup/mod.rs index 70156b6..e7fea29 100644 --- a/mountix-driver/src/startup/mod.rs +++ b/mountix-driver/src/startup/mod.rs @@ -1,3 +1,4 @@ +use crate::mcp::mcp_service; use crate::module::Modules; use crate::routes::health::{hc, hc_mongodb}; use crate::routes::information::info; @@ -16,7 +17,13 @@ use tracing::Level; pub async fn startup(modules: Arc) { let cors = CorsLayer::new() - .allow_methods([Method::GET, Method::OPTIONS, Method::HEAD]) + .allow_methods([ + Method::GET, + Method::POST, + Method::DELETE, + Method::OPTIONS, + Method::HEAD, + ]) .allow_origin(Any); let hc_router = Router::new() @@ -31,10 +38,13 @@ pub async fn startup(modules: Arc) { let info_router = Router::new().route("/", get(info)); + 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("/api/v1/mcp", mcp_service) .layer(cors) .layer(Extension(modules)) .layer( diff --git a/sample.env b/sample.env index 2843cc5..075ed99 100644 --- a/sample.env +++ b/sample.env @@ -12,3 +12,8 @@ MOUNTAINS_URL=http://127.0.0.1:8080/api/v1/mountains DOCUMENTS_URL=http://127.0.0.1:3000 DEFAULT_DISTANCE=5000 MAX_DISTANCE=100000 + +# MCP (Model Context Protocol) remote server settings +# MCP_STATEFUL_MODE=false +# MCP_JSON_RESPONSE=true +# MCP_ALLOWED_HOSTS=localhost,127.0.0.1,127.0.0.1:8080 From 428965ab7f18ab6d7c1425bc8bed237f2f28f62b Mon Sep 17 00:00:00 2001 From: Kazuno Fukuda <4kz12zz@gmail.com> Date: Sun, 31 May 2026 15:16:56 +0900 Subject: [PATCH 2/4] fix: Update MCP service URL paths --- .cursor/mcp.json | 2 +- mountix-driver/src/startup/mod.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.cursor/mcp.json b/.cursor/mcp.json index 570db56..2ea1e79 100644 --- a/.cursor/mcp.json +++ b/.cursor/mcp.json @@ -1,7 +1,7 @@ { "mcpServers": { "mountix-mcp-development": { - "url": "http://127.0.0.1:8080/api/v1/mcp" + "url": "http://127.0.0.1:8080/mcp" } } } diff --git a/mountix-driver/src/startup/mod.rs b/mountix-driver/src/startup/mod.rs index e7fea29..01ac902 100644 --- a/mountix-driver/src/startup/mod.rs +++ b/mountix-driver/src/startup/mod.rs @@ -44,7 +44,7 @@ pub async fn startup(modules: Arc) { .nest("/api/v1/", info_router) .nest("/api/v1/hc", hc_router) .nest("/api/v1/mountains", mountain_router) - .nest_service("/api/v1/mcp", mcp_service) + .nest_service("/mcp", mcp_service) .layer(cors) .layer(Extension(modules)) .layer( From 2d420bbe8535f96c45b5be69467e2f49c59eba4a Mon Sep 17 00:00:00 2001 From: Kazuno Fukuda <4kz12zz@gmail.com> Date: Sun, 31 May 2026 17:55:57 +0900 Subject: [PATCH 3/4] docs: Enhance documentation for MCP server and API endpoints --- CLAUDE.md | 23 +++++++++++++++++- README.md | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 779a48f..bc59676 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,6 +24,10 @@ Mountixは、クリーンアーキテクチャパターンを使用してRustで - `DATABASE_URL`にMongoDB接続文字列 - `DATABASE_NAME=mountix_db` - サーバーのホスト/ポート設定 +- MCPサーバー設定(任意): + - `MCP_STATEFUL_MODE`(既定: `false`) + - `MCP_JSON_RESPONSE`(既定: `true`) + - `MCP_ALLOWED_HOSTS`(カンマ区切り。未設定時は `HOST` の値を含む既定値が使われる) ### データベースセットアップ @@ -48,7 +52,8 @@ cd ../ - Axum Webフレームワークのセットアップとルーティング - HTTPハンドラーとJSONシリアライゼーション - エントリーポイント: `startup/mod.rs`にサーバー設定 - - ルート: `/api/v1/mountains`, `/api/v1/hc` (ヘルスチェック) + - ルート: `/api/v1/mountains`, `/api/v1/hc` (ヘルスチェック), `/mcp` (MCPサーバー) + - `rmcp` の `StreamableHttpService` を `nest_service` で `/mcp` にマウントし、MCPサーバーを公開(実装は `mcp/` 配下) 2. **mountix-app** (Use Case/Application) - ビジネスロジックとアプリケーションワークフロー @@ -78,6 +83,8 @@ cd ../ - **ログ**: tracingによるJSON出力 - **環境**: dotenvy(.envファイル読み込み) - **CORS**: API アクセス用に設定済み +- **MCP**: `rmcp` クレート(Streamable HTTP transport)によるMCPサーバー +- **JSON Schema**: `schemars`(MCPツールのパラメータスキーマ生成) ## APIエンドポイント @@ -87,6 +94,20 @@ cd ../ - `GET /api/v1/mountains/geosearch` - 地理的検索 - `GET /api/v1/hc` - ヘルスチェック - `GET /api/v1/hc/mongo` - MongoDBヘルスチェック +- `POST /mcp` - MCP(Model Context Protocol)Streamable HTTP エンドポイント + +## MCPサーバー + +`mountix-driver` 配下に Model Context Protocol サーバーを実装し、Cursor などの MCP 対応クライアントから山岳データを操作できるようにしています。 + +### 提供ツール + +すべて `mountix-app` のユースケース(`mountain_use_case` / `surrounding_mountain_use_case`)を呼び出し、REST API と同じ JSON モデル(`JsonMountain` / `JsonMountainsResponse` / `JsonBoxMountainsResponse` / `JsonSurroundingMountainResponse`)を文字列として返します。 + +- `get_mountain` - 山岳IDで山岳情報を1件取得 +- `find_mountains` - 名称・都道府県・タグなどで山岳検索 +- `find_mountains_by_box` - 緯度経度の矩形範囲で山岳検索 +- `find_surroundings` - 指定した山岳の周辺山岳を検索 ## 開発ノート diff --git a/README.md b/README.md index a6eb496..2dcbf60 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,11 @@ MOUNTAINS_URL=http://127.0.0.1:8080/api/v1/mountains DOCUMENTS_URL=http://127.0.0.1:3000 DEFAULT_DISTANCE=5000 MAX_DISTANCE=100000 + +# MCP (Model Context Protocol) remote server settings (optional) +# MCP_STATEFUL_MODE=false +# MCP_JSON_RESPONSE=true +# MCP_ALLOWED_HOSTS=localhost,127.0.0.1,127.0.0.1:8080 ``` Execute `cargo run` command. @@ -56,6 +61,70 @@ Execute `cargo run` command. cargo run ``` +## REST API + +REST API は Axum で実装されており、JSON 形式で山岳データを提供します。 + +詳細なリクエスト/レスポンス仕様は [Postman Public API Network](#postman-public-api-network) を参照してください。 + +### エンドポイント + +| メソッド | パス | 概要 | +| --- | --- | --- | +| GET | `/api/v1/` | API の基本情報(`MOUNTAINS_URL` / `DOCUMENTS_URL` など) | +| GET | `/api/v1/mountains` | 条件指定の山岳一覧取得 | +| GET | `/api/v1/mountains/{id}` | 山岳IDで1件取得 | +| GET | `/api/v1/mountains/{id}/surroundings` | 指定した山岳の周辺山岳を取得 | +| GET | `/api/v1/mountains/geosearch` | 緯度経度の矩形範囲で山岳を検索 | +| GET | `/api/v1/hc` | ヘルスチェック | +| GET | `/api/v1/hc/mongo` | MongoDB ヘルスチェック | + +### `/api/v1/mountains` の主なクエリパラメータ + +| パラメータ | 説明 | +| --- | --- | +| `name` | 山岳名(部分一致) | +| `prefecture` | 都道府県ID | +| `tag` | タグID | +| `offset` | 取得開始位置 | +| `limit` | 取得件数 | +| `sort` | ソート条件 | + +### レスポンス形式 + +成功時は `JsonMountain` / `JsonMountainsResponse` などの JSON、エラー時は `JsonErrorResponse`(`messages` 配列を含む)を返します。MCP ツールも同じスキーマの文字列を返します。 + +## MCP (Model Context Protocol) Server + +[Model Context Protocol](https://modelcontextprotocol.io/) の Streamable HTTP transport によるエンドポイントも提供しています。 + +Cursor などの MCP 対応クライアントから、山岳データを検索・取得するツールを呼び出すことができます。 + +### エンドポイント + +- `POST /mcp` - MCP Streamable HTTP エンドポイント + +### 提供ツール + +| ツール名 | 説明 | +| --- | --- | +| `get_mountain` | 山岳IDを指定して山岳情報を1件取得 | +| `find_mountains` | 山岳名・都道府県・タグなどの条件で山岳を検索 | +| `find_mountains_by_box` | 緯度経度の矩形範囲を指定して山岳を検索 | +| `find_surroundings` | 指定した山岳の周辺にある山岳を検索 | + +レスポンスは REST API と同じスキーマ(`JsonMountain` など)の JSON 文字列として返されます。 + +### サーバー設定(環境変数) + +`.env` で MCP サーバーの挙動を調整できます(いずれも任意)。 + +| 環境変数 | デフォルト | 説明 | +| --- | --- | --- | +| `MCP_STATEFUL_MODE` | `false` | Streamable HTTP のセッション管理を有効化するか | +| `MCP_JSON_RESPONSE` | `true` | レスポンスを JSON で返すか(`false` で SSE) | +| `MCP_ALLOWED_HOSTS` | `localhost,127.0.0.1,::1,$HOST` | 許可する `Host` ヘッダ。カンマ区切りで指定。未設定の場合は `HOST` 環境変数の値を含む既定値が使われる | + ## Postman Public API Network Postman Public API Network で API を公開しています。 @@ -68,6 +137,7 @@ Postman Public API Network で API を公開しています。 - ルーターとサーバーの起動を実装する - Axum の機能を利用してエンドポイントとサーバーの起動までを実装する - 内部的に行われた処理の結果、どのようなステータスコードを返すかをハンドリングしたり、JSON のシリアライズ・デシリアライズも担当する + - `rmcp` クレートを利用した MCP サーバー(`/mcp` エンドポイント)の公開もこのレイヤーで実装する - mountix-app (app or usecase) - ユースケースのレイヤーで、アプリケーションを動作させるために必要なロジックを記述する - 複数リポジトリをまたいでアプリケーションに必要なデータ構造を返すなどをおこなう From 1c839690f740a6a07711d7bd751b49661b001913 Mon Sep 17 00:00:00 2001 From: Kazuno Fukuda <4kz12zz@gmail.com> Date: Sun, 31 May 2026 18:00:34 +0900 Subject: [PATCH 4/4] chore: Bump version to 1.3.0 in Cargo.toml --- Cargo.toml | 2 +- mountix-driver/src/mcp/server.rs | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 6a6a1fd..1426e6a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = [ [workspace.package] name = "mountix" -version = "1.2.0" +version = "1.3.0" edition = "2021" [workspace.dependencies] diff --git a/mountix-driver/src/mcp/server.rs b/mountix-driver/src/mcp/server.rs index 1233825..de05156 100644 --- a/mountix-driver/src/mcp/server.rs +++ b/mountix-driver/src/mcp/server.rs @@ -190,7 +190,6 @@ impl MountixMcpServer { #[tool_handler( name = "mountix", - version = "1.2.0", instructions = "Mountix 日本の山岳データ API。百名山などの山岳情報を検索・取得できます。" )] impl ServerHandler for MountixMcpServer {}