UNPKG

japan-transfer-mcp

Version:

Model Context Protocol (MCP) server for J-Route Planner

208 lines (207 loc) 9.92 kB
#!/usr/bin/env node import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from 'zod'; import { countTokens } from 'gpt-tokenizer'; import { pathToFileURL } from 'url'; import { fetchSuggest, fetchRouteSearch } from './fetcher.js'; import { parseRouteSearchResult } from './parser.js'; import { formatRouteSearchResponse } from './formatter.js'; const server = new McpServer({ name: "japan-transfer-mcp", version: "0.0.6" }); server.registerTool("search_station_by_name", { title: "Search for stations by name", description: "Search for stations by name", inputSchema: { query: z.string().describe("The name of the station to search for (must be in Japanese)"), maxTokens: z.number().optional().describe("The maximum number of tokens to return"), onlyName: z.boolean().optional().describe("Whether to only return the name of the station. If you do not need detailed information, it is generally recommended to set this to true."), } }, async ({ query, maxTokens, onlyName }) => { try { const response = await fetchSuggest({ query, format: "json", }); const railwayPlaces = response.R?.map((place) => { if (onlyName) { return place.poiName; } // placeの中身を自然な日本語文章で展開し、citycodeも含めて変数を埋め込む // 例: "東京駅(東京都千代田区, citycode: 13101, 緯度: 35.681167, 経度: 139.767125, よみ: とうきょうえき)" return `${place.poiName}${place.prefName}${place.cityName ? place.cityName : ''}, citycode: ${place.cityCode ?? '不明'}, 緯度: ${place.location.lat}, 経度: ${place.location.lon}, よみ: ${place.poiYomi})`; }); const busPlaces = response.B?.map((place) => { if (onlyName) { return place.poiName; } return `${place.poiName}${place.prefName}${place.cityName ? place.cityName : ''}, citycode: ${place.cityCode ?? '不明'}, 緯度: ${place.location.lat}, 経度: ${place.location.lon}, よみ: ${place.poiYomi})`; }); const spots = response.S?.map((place) => { if (onlyName) { return place.poiName; } return `${place.poiName}${place.prefName}${place.cityName ? place.cityName : ''}${place.address ? ' ' + place.address : ''}, citycode: ${place.cityCode ?? '不明'}, 緯度: ${place.location.lat}, 経度: ${place.location.lon}, よみ: ${place.poiYomi})`; }); // railwayPlaces, busPlaces, spots を順に交互に配列化(R1,B1,S1,R2,B2,S2,...のような感じで) const maxLen = Math.max(railwayPlaces ? railwayPlaces.length : 0, busPlaces ? busPlaces.length : 0, spots ? spots.length : 0); const merged = []; for (let i = 0; i < maxLen; i++) { if (railwayPlaces && railwayPlaces[i] !== undefined) merged.push(railwayPlaces[i]); if (busPlaces && busPlaces[i] !== undefined) merged.push(busPlaces[i]); if (spots && spots[i] !== undefined) merged.push(spots[i]); } // merged配列を上から順に,区切りで連結し、maxTokensの範囲内で切り詰める // maxTokensが未指定の場合は全て連結 let result = ""; let tokenCount = 0; let max = typeof maxTokens === "number" ? maxTokens : Infinity; for (let i = 0; i < merged.length; i++) { const next = (result ? "," : "") + merged[i]; const newTokenCount = countTokens(result + next); if (newTokenCount > max) break; result += (result ? "," : "") + merged[i]; tokenCount = newTokenCount; } return { content: [{ type: "text", text: result }] }; } catch (error) { return { content: [{ type: "text", text: `Contact retrieval error: ${error instanceof Error ? error.message : String(error)}` }], isError: true }; } }); server.registerTool("search_route_by_station_name", { title: "Search for routes by station name", description: "Search for routes by station name", inputSchema: { from: z.string().describe("The name of the departure station. The value must be a name obtained from search_station_by_name."), to: z.string().describe("The name of the arrival station. The value must be a name obtained from search_station_by_name."), datetimeType: z.enum(["departure", "arrival", "first", "last"]).describe("The type of datetime to use for the search"), datetime: z.string().optional().describe("The datetime to use for the search. Format: YYYY-MM-DD HH:MM:SS. If not provided, the current time in Japan will be used."), maxTokens: z.number().optional().describe("The maximum number of tokens to return"), }, }, async ({ from, to, datetimeType, datetime, maxTokens }) => { try { if (!datetime) { // 日本の時刻(Asia/Tokyo)でISO形式(YYYY-MM-DD HH:MM:SS)にする const now = new Date(); const jpNow = new Date(now.toLocaleString("ja-JP", { timeZone: "Asia/Tokyo" })); const pad = (n) => n.toString().padStart(2, "0"); datetime = `${jpNow.getFullYear()}-${pad(jpNow.getMonth() + 1)}-${pad(jpNow.getDate())} ${pad(jpNow.getHours())}:${pad(jpNow.getMinutes())}:${pad(jpNow.getSeconds())}`; } // 日時の解析 const datePart = datetime.split(" ")[0]; const timePart = datetime.split(" ")[1]; const [year, month, day] = datePart.split("-").map(Number); const [hour, minute] = timePart.split(":").map(Number); // 駅タイプの判定(簡単な判定) const isFromBusStop = from.includes("〔") || from.includes("["); const isToBusStop = to.includes("〔") || to.includes("["); const response = await fetchRouteSearch({ eki1: from, eki2: to, Dyy: year, Dmm: month, Ddd: day, Dhh: hour, Dmn1: Math.floor(minute / 10), // 分の10の位 Dmn2: minute % 10, // 分の1の位 Cway: (() => { switch (datetimeType) { case "departure": return 0; case "arrival": return 1; case "first": return 2; case "last": return 3; default: return 0; } })(), // デフォルト値を補完 via_on: -1, // 経由駅なし Cfp: 1, // ICカード利用料金 Czu: 2, // ジパング倶楽部 C7: 1, // 通勤定期 C2: 0, // 飛行機利用: おまかせ C3: 0, // 高速バス利用: おまかせ C1: 0, // 有料特急: おまかせ cartaxy: 1, // 車・タクシー検索: 有効 bikeshare: 1, // シェアサイクル検索: 有効 sort: "time", // 到着が早い・出発が遅い順 C4: 5, // 座席種別: おまかせ C5: 0, // 優先列車: のぞみ優先 C6: 2, // 乗換時間: 標準 S: "検索", // 検索ボタン Cmap1: "", // UI関連 rf: "nr", // リファラ pg: 0, // ページ番号 eok1: isFromBusStop ? "B-" : "R-", // 駅1: 鉄道駅またはバス停 eok2: isToBusStop ? "B-" : "R-", // 駅2: 鉄道駅またはバス停 Csg: 1 // 検索開始フラグ }); // HTMLレスポンスを解析 const parsedResult = parseRouteSearchResult(response.data); // 自然な文章でレスポンスを構築 let resultText = formatRouteSearchResponse(parsedResult, response.url, from, to, datetime); // maxTokensによる制限 if (maxTokens) { const tokenCount = countTokens(resultText); if (tokenCount > maxTokens) { // トークン数が制限を超える場合は、ルートの数を減らす const limitedResult = { ...parsedResult, routes: parsedResult.routes.slice(0, Math.max(1, Math.floor(parsedResult.routes.length * maxTokens / tokenCount))) }; resultText = formatRouteSearchResponse(limitedResult, response.url, from, to, datetime); } } return { content: [{ type: "text", text: resultText }] }; } catch (error) { return { content: [{ type: "text", text: `Route search error: ${error instanceof Error ? error.message : String(error)}` }], isError: true }; } }); // メインモジュールとして実行された場合のみconnectする。 const currentFileUrl = import.meta.url; try { const scriptFileUrl = pathToFileURL(process.argv[1]).href; if (currentFileUrl === scriptFileUrl) { const transport = new StdioServerTransport(); await server.connect(transport); } } catch (error) { // pathToFileURLが失敗した場合(引数がundefinedなど)は何もしない // これにより、ライブラリとしてimportされた場合と同じ動作になる } export default server;