|
#!/usr/bin/env node |
|
|
|
// osm-download-mcp.js |
|
// OpenStreetMapデータをファイルにダウンロードするMCPサーバー |
|
// レスポンスをトークン化せず、直接ファイルに保存します |
|
|
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js'; |
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; |
|
import { |
|
CallToolRequestSchema, |
|
ListToolsRequestSchema, |
|
} from '@modelcontextprotocol/sdk/types.js'; |
|
import https from 'https'; |
|
import fs from 'fs/promises'; |
|
import path from 'path'; |
|
import { createWriteStream } from 'fs'; |
|
|
|
class OSMDownloadServer { |
|
constructor() { |
|
// MCPサーバーのインスタンスを初期化 |
|
this.server = new Server( |
|
{ |
|
name: 'osm-download-server', |
|
version: '1.0.0', |
|
}, |
|
{ |
|
capabilities: { |
|
tools: {}, // ツール機能を有効化 |
|
}, |
|
} |
|
); |
|
|
|
// Overpass APIサーバーの設定 |
|
// IPアドレスを直接使用してDNS解決の問題を回避 |
|
this.servers = [ |
|
{ url: 'https://162.55.144.139/api/interpreter', host: 'overpass-api.de' }, |
|
{ url: 'https://65.109.112.52/api/interpreter', host: 'lz4.overpass-api.de' }, |
|
{ url: 'https://193.219.97.30/api/interpreter', host: 'overpass.kumi.systems' } |
|
]; |
|
|
|
// ツールハンドラーの設定 |
|
this.setupToolHandlers(); |
|
} |
|
|
|
setupToolHandlers() { |
|
// 利用可能なツールのリストを返すハンドラー |
|
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ |
|
tools: [ |
|
{ |
|
name: 'download_osm_data', |
|
description: 'OpenStreetMapデータをダウンロードしてファイルに保存します。大きなデータセットも扱えます。カスタムのOverpass QLクエリを使用できます。', |
|
inputSchema: { |
|
type: 'object', |
|
properties: { |
|
query: { |
|
type: 'string', |
|
description: 'Overpass QLクエリ文字列(例: [out:json];node["amenity"="restaurant"](bbox);out;)' |
|
}, |
|
output_path: { |
|
type: 'string', |
|
description: '保存先ファイルパス(例: ./data/tokyo_buildings.json)。ディレクトリは自動作成されます。' |
|
}, |
|
format: { |
|
type: 'string', |
|
enum: ['json', 'xml'], |
|
default: 'json', |
|
description: '出力データ形式(json または xml)' |
|
} |
|
}, |
|
required: ['query', 'output_path'] |
|
} |
|
}, |
|
{ |
|
name: 'download_area_buildings', |
|
description: '指定した矩形エリア内の建物データをダウンロードします。建物の輪郭がポリゴンデータとして保存されます。', |
|
inputSchema: { |
|
type: 'object', |
|
properties: { |
|
minLon: { type: 'number', description: '最小経度(西端)' }, |
|
minLat: { type: 'number', description: '最小緯度(南端)' }, |
|
maxLon: { type: 'number', description: '最大経度(東端)' }, |
|
maxLat: { type: 'number', description: '最大緯度(北端)' }, |
|
output_path: { |
|
type: 'string', |
|
description: '保存先ファイルパス(例: ./buildings/shinjuku.json)' |
|
} |
|
}, |
|
required: ['minLon', 'minLat', 'maxLon', 'maxLat', 'output_path'] |
|
} |
|
}, |
|
{ |
|
name: 'download_area_all', |
|
description: '指定エリアの全データ(建物、道路、POI、水域など)を一括ダウンロードします。大容量になる可能性があるため、小さなエリアから始めることを推奨します。', |
|
inputSchema: { |
|
type: 'object', |
|
properties: { |
|
minLon: { type: 'number', description: '最小経度(西端)' }, |
|
minLat: { type: 'number', description: '最小緯度(南端)' }, |
|
maxLon: { type: 'number', description: '最大経度(東端)' }, |
|
maxLat: { type: 'number', description: '最大緯度(北端)' }, |
|
output_path: { |
|
type: 'string', |
|
description: '保存先ファイルパス' |
|
} |
|
}, |
|
required: ['minLon', 'minLat', 'maxLon', 'maxLat', 'output_path'] |
|
} |
|
}, |
|
{ |
|
name: 'convert_to_geojson', |
|
description: 'ダウンロード済みのOSM JSONファイルをGeoJSON形式に変換します。GeoJSONは地図表示ソフトウェアで広くサポートされている形式です。', |
|
inputSchema: { |
|
type: 'object', |
|
properties: { |
|
input_path: { |
|
type: 'string', |
|
description: 'OSMデータファイルのパス(JSONファイル)' |
|
}, |
|
output_path: { |
|
type: 'string', |
|
description: 'GeoJSON出力ファイルのパス(.geojson拡張子推奨)' |
|
} |
|
}, |
|
required: ['input_path', 'output_path'] |
|
} |
|
} |
|
] |
|
})); |
|
|
|
// ツール実行のハンドラー |
|
this.server.setRequestHandler(CallToolRequestSchema, async (request) => { |
|
const { name, arguments: args } = request.params; |
|
|
|
// ツール名に応じて適切なメソッドを呼び出す |
|
switch (name) { |
|
case 'download_osm_data': |
|
return await this.downloadOSMData(args); |
|
case 'download_area_buildings': |
|
return await this.downloadAreaBuildings(args); |
|
case 'download_area_all': |
|
return await this.downloadAreaAll(args); |
|
case 'convert_to_geojson': |
|
return await this.convertToGeoJSON(args); |
|
default: |
|
throw new Error(`不明なツール: ${name}`); |
|
} |
|
}); |
|
} |
|
|
|
// ストリーミング方式でファイルにダウンロード |
|
// メモリに全データを読み込まないため、大容量ファイルも扱える |
|
async downloadToFile(url, query, outputPath, headers = {}) { |
|
return new Promise((resolve, reject) => { |
|
const urlObj = new URL(url); |
|
|
|
// HTTPSリクエストのオプション設定 |
|
const options = { |
|
hostname: urlObj.hostname, |
|
port: 443, |
|
path: urlObj.pathname, |
|
method: 'POST', |
|
headers: { |
|
'Content-Type': 'text/plain', |
|
'Content-Length': Buffer.byteLength(query), |
|
'User-Agent': 'OSM-Download-MCP/1.0', |
|
...headers // 追加のヘッダー(主にHostヘッダー) |
|
}, |
|
rejectUnauthorized: false // IPアドレス直接アクセスのため証明書検証を無効化 |
|
}; |
|
|
|
const req = https.request(options, (res) => { |
|
// HTTPステータスコードの確認 |
|
if (res.statusCode !== 200) { |
|
reject(new Error(`HTTPエラー ${res.statusCode}: サーバーからエラーが返されました`)); |
|
return; |
|
} |
|
|
|
// 出力先ディレクトリを作成(存在しない場合) |
|
const dir = path.dirname(outputPath); |
|
fs.mkdir(dir, { recursive: true }).then(() => { |
|
// ファイル書き込みストリームを作成 |
|
const writeStream = createWriteStream(outputPath); |
|
let downloadedBytes = 0; |
|
|
|
// データ受信時の処理 |
|
res.on('data', (chunk) => { |
|
downloadedBytes += chunk.length; |
|
// 1MBごとに進捗を報告 |
|
if (downloadedBytes % (1024 * 1024) === 0) { |
|
console.error(`ダウンロード中: ${(downloadedBytes / 1024 / 1024).toFixed(1)} MB`); |
|
} |
|
}); |
|
|
|
// レスポンスを直接ファイルにパイプ |
|
res.pipe(writeStream); |
|
|
|
// 書き込み完了時の処理 |
|
writeStream.on('finish', () => { |
|
const sizeMB = (downloadedBytes / 1024 / 1024).toFixed(2); |
|
resolve({ |
|
success: true, |
|
path: outputPath, |
|
size: `${sizeMB} MB`, |
|
bytes: downloadedBytes |
|
}); |
|
}); |
|
|
|
// エラー処理 |
|
writeStream.on('error', reject); |
|
}).catch(reject); |
|
}); |
|
|
|
// リクエストエラーの処理 |
|
req.on('error', (error) => { |
|
reject(new Error(`ネットワークエラー: ${error.message}`)); |
|
}); |
|
|
|
// タイムアウト設定(5分) |
|
req.setTimeout(300000, () => { |
|
req.destroy(); |
|
reject(new Error('リクエストがタイムアウトしました(5分経過)')); |
|
}); |
|
|
|
// クエリを送信 |
|
req.write(query); |
|
req.end(); |
|
}); |
|
} |
|
|
|
// カスタムクエリでOSMデータをダウンロード |
|
async downloadOSMData(args) { |
|
const { query, output_path, format = 'json' } = args; |
|
|
|
// クエリの先頭に出力形式を追加(まだ指定されていない場合) |
|
const fullQuery = format === 'json' ? |
|
(query.startsWith('[out:json]') ? query : `[out:json];${query}`) : |
|
(query.startsWith('[out:xml]') ? query : `[out:xml];${query}`); |
|
|
|
console.error(`OSMデータをダウンロード中: ${output_path}`); |
|
|
|
// 複数のサーバーを順番に試す |
|
let lastError = null; |
|
for (const server of this.servers) { |
|
try { |
|
console.error(`サーバーに接続中: ${server.host}`); |
|
|
|
const result = await this.downloadToFile( |
|
server.url, |
|
fullQuery, |
|
output_path, |
|
{ 'Host': server.host } // 正しいHostヘッダーを設定 |
|
); |
|
|
|
return { |
|
content: [{ |
|
type: 'text', |
|
text: JSON.stringify({ |
|
status: '成功', |
|
message: 'データのダウンロードが完了しました', |
|
file: output_path, |
|
size: result.size, |
|
server: server.host |
|
}, null, 2) |
|
}] |
|
}; |
|
} catch (error) { |
|
lastError = error; |
|
console.error(`${server.host} での処理に失敗: ${error.message}`); |
|
} |
|
} |
|
|
|
throw new Error(`すべてのサーバーで失敗しました: ${lastError?.message}`); |
|
} |
|
|
|
// エリア内の建物データをダウンロード |
|
async downloadAreaBuildings(args) { |
|
const { minLon, minLat, maxLon, maxLat, output_path } = args; |
|
|
|
// 建物データ取得用のOverpass QLクエリ |
|
const query = `[out:json][timeout:180]; |
|
( |
|
way["building"](${minLat},${minLon},${maxLat},${maxLon}); |
|
relation["building"](${minLat},${minLon},${maxLat},${maxLon}); |
|
); |
|
out body; |
|
>; |
|
out skel qt;`; |
|
|
|
console.error(`建物データをダウンロード中: エリア [${minLon},${minLat},${maxLon},${maxLat}]`); |
|
|
|
return await this.downloadOSMData({ query, output_path, format: 'json' }); |
|
} |
|
|
|
// エリア内の全データをダウンロード |
|
async downloadAreaAll(args) { |
|
const { minLon, minLat, maxLon, maxLat, output_path } = args; |
|
|
|
// エリアサイズをチェック |
|
const area = (maxLon - minLon) * (maxLat - minLat); |
|
if (area > 0.01) { |
|
console.error(`警告: 大きなエリア(${area.toFixed(4)}平方度)が指定されています。`); |
|
console.error('ダウンロードに時間がかかる可能性があります。'); |
|
} |
|
|
|
// すべてのデータを取得する包括的なクエリ |
|
const query = `[out:json][timeout:300]; |
|
( |
|
node(${minLat},${minLon},${maxLat},${maxLon}); |
|
way(${minLat},${minLon},${maxLat},${maxLon}); |
|
relation(${minLat},${minLon},${maxLat},${maxLon}); |
|
); |
|
out body; |
|
>; |
|
out skel qt;`; |
|
|
|
console.error(`全データをダウンロード中: エリア [${minLon},${minLat},${maxLon},${maxLat}]`); |
|
|
|
return await this.downloadOSMData({ query, output_path, format: 'json' }); |
|
} |
|
|
|
// OSM JSONをGeoJSONに変換 |
|
async convertToGeoJSON(args) { |
|
const { input_path, output_path } = args; |
|
|
|
try { |
|
console.error(`GeoJSONに変換中: ${input_path} → ${output_path}`); |
|
|
|
// ファイルを読み込み |
|
const data = await fs.readFile(input_path, 'utf8'); |
|
const osmData = JSON.parse(data); |
|
|
|
// GeoJSONに変換 |
|
const geojson = this.osmToGeoJSON(osmData); |
|
|
|
// 出力ディレクトリを作成 |
|
await fs.mkdir(path.dirname(output_path), { recursive: true }); |
|
|
|
// GeoJSONファイルとして保存 |
|
await fs.writeFile(output_path, JSON.stringify(geojson, null, 2)); |
|
|
|
// ファイルサイズを取得 |
|
const stats = await fs.stat(output_path); |
|
const sizeMB = (stats.size / 1024 / 1024).toFixed(2); |
|
|
|
return { |
|
content: [{ |
|
type: 'text', |
|
text: JSON.stringify({ |
|
status: '成功', |
|
message: 'GeoJSONへの変換が完了しました', |
|
input_file: input_path, |
|
output_file: output_path, |
|
size: `${sizeMB} MB`, |
|
feature_count: geojson.features.length |
|
}, null, 2) |
|
}] |
|
}; |
|
} catch (error) { |
|
return { |
|
content: [{ |
|
type: 'text', |
|
text: JSON.stringify({ |
|
status: 'エラー', |
|
message: `変換に失敗しました: ${error.message}`, |
|
input_file: input_path |
|
}, null, 2) |
|
}] |
|
}; |
|
} |
|
} |
|
|
|
// OSMデータをGeoJSONに変換する内部メソッド |
|
osmToGeoJSON(osmData) { |
|
const features = []; // GeoJSONフィーチャーの配列 |
|
const nodes = {}; // ノードIDと座標のマッピング |
|
|
|
// データが存在しない場合は空のFeatureCollectionを返す |
|
if (!osmData.elements) { |
|
return { type: 'FeatureCollection', features: [] }; |
|
} |
|
|
|
// ステップ1: すべてのノードを収集 |
|
// ウェイはノードIDの参照で構成されているため、先に座標情報を収集 |
|
osmData.elements.forEach(element => { |
|
if (element.type === 'node') { |
|
nodes[element.id] = [element.lon, element.lat]; |
|
} |
|
}); |
|
|
|
// ステップ2: 各要素をGeoJSONフィーチャーに変換 |
|
osmData.elements.forEach(element => { |
|
let geometry = null; |
|
|
|
switch (element.type) { |
|
case 'node': |
|
// ノードは点(Point)として表現 |
|
if (element.lon !== undefined && element.lat !== undefined) { |
|
geometry = { |
|
type: 'Point', |
|
coordinates: [element.lon, element.lat] |
|
}; |
|
} |
|
break; |
|
|
|
case 'way': |
|
// ウェイは線(LineString)または多角形(Polygon)として表現 |
|
if (element.nodes && element.nodes.length > 0) { |
|
// ノードIDから座標を取得 |
|
const coordinates = element.nodes |
|
.map(nodeId => nodes[nodeId]) |
|
.filter(coord => coord !== undefined); |
|
|
|
if (coordinates.length > 0) { |
|
// 閉じたウェイかどうか確認(最初と最後のノードが同じ) |
|
const isClosed = element.nodes[0] === element.nodes[element.nodes.length - 1]; |
|
|
|
// 閉じていて4点以上ある場合はポリゴン(建物など) |
|
if (isClosed && coordinates.length > 3) { |
|
geometry = { |
|
type: 'Polygon', |
|
coordinates: [coordinates] // GeoJSONポリゴンは配列の配列 |
|
}; |
|
} else { |
|
// それ以外は線(道路など) |
|
geometry = { |
|
type: 'LineString', |
|
coordinates: coordinates |
|
}; |
|
} |
|
} |
|
} |
|
break; |
|
|
|
// リレーションは現在サポートしていません |
|
// (複雑な多角形や境界線などに使用される) |
|
} |
|
|
|
// ジオメトリが作成できた場合、フィーチャーとして追加 |
|
if (geometry) { |
|
features.push({ |
|
type: 'Feature', |
|
id: `${element.type}/${element.id}`, |
|
properties: element.tags || {}, // OSMタグをプロパティとして保存 |
|
geometry: geometry |
|
}); |
|
} |
|
}); |
|
|
|
// GeoJSON FeatureCollectionとして返す |
|
return { |
|
type: 'FeatureCollection', |
|
features: features |
|
}; |
|
} |
|
|
|
// MCPサーバーの起動 |
|
async run() { |
|
const transport = new StdioServerTransport(); |
|
await this.server.connect(transport); |
|
console.error('OSM Download MCPサーバーが起動しました...'); |
|
console.error('大容量のOSMデータをファイルに直接ダウンロードできます。'); |
|
} |
|
} |
|
|
|
// サーバーインスタンスの作成と起動 |
|
const server = new OSMDownloadServer(); |
|
server.run().catch((error) => { |
|
console.error('サーバーの起動に失敗しました:', error); |
|
process.exit(1); |
|
}); |