UNPKG

@devyhan/naver-maps-mcp

Version:

MCP 서버 for 네이버 지도 API

587 lines (534 loc) 20.1 kB
/** * 네이버 지도 MCP 서버 * 네이버 Maps API를 MCP 프로토콜로 제공하는 서버 구현입니다. */ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import naverMapsClient from './api/naverMapsClient.js'; import logger from './utils/logger.js'; import config from './utils/config.js'; /** * MCP 서버 초기화 및 설정 * @returns {Promise<void>} */ export async function startServer() { // 환경 변수 검증 if (!config.validate()) { logger.error('환경 변수 검증 실패. 서버를 시작할 수 없습니다.'); process.exit(1); } logger.info('네이버 지도 MCP 서버를 초기화합니다...'); // MCP 서버 인스턴스 생성 const server = new Server({ name: 'naver-maps-mcp', version: '1.0.0' }, { capabilities: { // MCP 기능 지원 명시 tools: {} // 네이버 지도 API 도구 지원 } }); // 사용 가능한 도구 목록 제공 server.setRequestHandler(ListToolsRequestSchema, async () => { logger.debug('도구 목록 요청 처리'); return { tools: [ // 지오코딩 도구 { name: 'geocode', description: '주소를 좌표(위도, 경도)로 변환합니다.', inputSchema: { type: 'object', properties: { address: { type: 'string', description: '검색할 주소' }, filter: { type: 'string', description: '지역 필터링 (구, 동 등)' } }, required: ['address'] } }, // 역지오코딩 도구 { name: 'reverseGeocode', description: '좌표(위도, 경도)를 주소로 변환합니다.', inputSchema: { type: 'object', properties: { latitude: { type: 'number', description: '위도' }, longitude: { type: 'number', description: '경도' }, coords_type: { type: 'string', description: '좌표계 타입', enum: ['latlng', 'utmk', 'tm128', 'epsg', 'naver', 'bessel'], default: 'latlng' } }, required: ['latitude', 'longitude'] } }, // 장소 검색 도구 { name: 'searchPlaces', description: '키워드로 장소를 검색합니다.', inputSchema: { type: 'object', properties: { query: { type: 'string', description: '검색 키워드' }, coordinate: { type: 'object', description: '검색 중심 좌표', properties: { latitude: { type: 'number' }, longitude: { type: 'number' } } }, radius: { type: 'number', description: '검색 반경 (미터 단위)' } }, required: ['query'] } }, // 경로 탐색 도구 (방향 5) { name: 'getDirections', description: '출발지와 목적지 사이의 최적 경로를 제공합니다.', inputSchema: { type: 'object', properties: { start: { type: 'object', description: '출발 좌표', properties: { latitude: { type: 'number' }, longitude: { type: 'number' } }, required: ['latitude', 'longitude'] }, goal: { type: 'object', description: '도착 좌표', properties: { latitude: { type: 'number' }, longitude: { type: 'number' } }, required: ['latitude', 'longitude'] }, option: { type: 'string', description: '경로 옵션', enum: ['trafast', 'tracomfort', 'traoptimal', 'traavoidtoll'], default: 'trafast' }, lang: { type: 'string', description: '안내 언어', enum: ['ko', 'en', 'ja', 'zh'], default: 'ko' } }, required: ['start', 'goal'] } }, // 자연어 주소 기반 경로 탐색 도구 { name: 'getDirectionsByNaturalLanguage', description: '자연어로 된 출발지와 목적지를 자동으로 지오코딩하여 경로를 제공합니다.', inputSchema: { type: 'object', properties: { startAddress: { type: 'string', description: '출발지 주소 또는 장소명 (자연어)' }, goalAddress: { type: 'string', description: '도착지 주소 또는 장소명 (자연어)' }, option: { type: 'string', description: '경로 옵션', enum: ['trafast', 'tracomfort', 'traoptimal', 'traavoidtoll'], default: 'trafast' }, lang: { type: 'string', description: '안내 언어', enum: ['ko', 'en', 'ja', 'zh'], default: 'ko' } }, required: ['startAddress', 'goalAddress'] } }, // 자연어 주소 기반 경유지 포함 경로 탐색 도구 { name: 'getDirectionsWithWaypointsByNaturalLanguage', description: '자연어로 된 출발지, 경유지, 목적지를 자동으로 지오코딩하여 경로를 제공합니다.', inputSchema: { type: 'object', properties: { startAddress: { type: 'string', description: '출발지 주소 또는 장소명 (자연어)' }, goalAddress: { type: 'string', description: '도착지 주소 또는 장소명 (자연어)' }, waypointAddresses: { type: 'array', description: '경유지 주소 또는 장소명 목록 (자연어)', items: { type: 'string' } }, option: { type: 'string', description: '경로 옵션', enum: ['trafast', 'tracomfort', 'traoptimal', 'traavoidtoll'], default: 'trafast' } }, required: ['startAddress', 'goalAddress'] } }, // 경유지 포함 경로 탐색 도구 (방향 15) { name: 'getDirectionsWithWaypoints', description: '경유지를 포함한 최적 경로를 제공합니다.', inputSchema: { type: 'object', properties: { start: { type: 'object', description: '출발 좌표', properties: { latitude: { type: 'number' }, longitude: { type: 'number' } }, required: ['latitude', 'longitude'] }, goal: { type: 'object', description: '도착 좌표', properties: { latitude: { type: 'number' }, longitude: { type: 'number' } }, required: ['latitude', 'longitude'] }, waypoints: { type: 'array', description: '경유지 좌표 목록', items: { type: 'object', properties: { latitude: { type: 'number' }, longitude: { type: 'number' } }, required: ['latitude', 'longitude'] } }, option: { type: 'string', description: '경로 옵션', enum: ['trafast', 'tracomfort', 'traoptimal', 'traavoidtoll'], default: 'trafast' } }, required: ['start', 'goal'] } }, // 좌표계 변환 도구 { name: 'transformCoordinates', description: '다양한 좌표계 간에 좌표를 변환합니다.', inputSchema: { type: 'object', properties: { coords: { type: 'object', description: '변환할 좌표', properties: { x: { type: 'number' }, y: { type: 'number' } }, required: ['x', 'y'] }, fromCoordSys: { type: 'string', description: '원본 좌표계', enum: ['EPSG:4326', 'NAVER', 'UTMK', 'TM128', 'BESSEL'] }, toCoordSys: { type: 'string', description: '변환할 좌표계', enum: ['EPSG:4326', 'NAVER', 'UTMK', 'TM128', 'BESSEL'] } }, required: ['coords', 'fromCoordSys', 'toCoordSys'] } } ] }; }); // 도구 호출 처리 server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; logger.debug(`도구 호출: ${name}`, args); try { let result; // 각 도구별 구현 switch (name) { case 'geocode': result = await naverMapsClient.geocode(args.address, { filter: args.filter }); break; case 'reverseGeocode': result = await naverMapsClient.reverseGeocode( args.latitude, args.longitude, { coords_type: args.coords_type } ); break; case 'searchPlaces': // 실제 구현은 네이버 API 문서 확인 후 추가 필요 // 현재는 더미 응답 반환 result = { places: [ { name: '검색 결과 예시', address: '서울특별시 강남구', location: { latitude: 37.5, longitude: 127.0 } } ] }; break; case 'getDirections': result = await naverMapsClient.getDirections5( args.start, args.goal, { option: args.option, lang: args.lang } ); break; case 'getDirectionsWithWaypoints': result = await naverMapsClient.getDirections15( args.start, args.goal, args.waypoints || [], { option: args.option } ); break; case 'getDirectionsByNaturalLanguage': // 자연어 주소 기반 경로 탐색 구현 try { // 1. 출발지 주소를 좌표로 변환 (지오코딩) logger.info(`출발지 지오코딩 시작: ${args.startAddress}`); const startGeocode = await naverMapsClient.geocode(args.startAddress); if (!startGeocode?.addresses || startGeocode.addresses.length === 0) { throw new Error(`출발지 주소를 변환할 수 없습니다: ${args.startAddress}`); } // 첫 번째 결과 사용 const startCoord = { latitude: parseFloat(startGeocode.addresses[0].y), longitude: parseFloat(startGeocode.addresses[0].x) }; logger.info(`출발지 변환 결과: ${JSON.stringify(startCoord)}`); // 2. 도착지 주소를 좌표로 변환 (지오코딩) logger.info(`도착지 지오코딩 시작: ${args.goalAddress}`); const goalGeocode = await naverMapsClient.geocode(args.goalAddress); if (!goalGeocode?.addresses || goalGeocode.addresses.length === 0) { throw new Error(`도착지 주소를 변환할 수 없습니다: ${args.goalAddress}`); } // 첫 번째 결과 사용 const goalCoord = { latitude: parseFloat(goalGeocode.addresses[0].y), longitude: parseFloat(goalGeocode.addresses[0].x) }; logger.info(`도착지 변환 결과: ${JSON.stringify(goalCoord)}`); // 3. 변환된 좌표로 경로 탐색 const directionsResult = await naverMapsClient.getDirections5( startCoord, goalCoord, { option: args.option || 'trafast', lang: args.lang || 'ko' } ); // 4. 원본 주소 정보 추가 result = { ...directionsResult, originalAddresses: { start: { query: args.startAddress, resolved: startGeocode.addresses[0].roadAddress || startGeocode.addresses[0].jibunAddress, coordinates: startCoord }, goal: { query: args.goalAddress, resolved: goalGeocode.addresses[0].roadAddress || goalGeocode.addresses[0].jibunAddress, coordinates: goalCoord } } }; } catch (error) { logger.error(`자연어 주소 기반 경로 탐색 실패: ${error.message}`, error); throw error; } break; case 'getDirectionsWithWaypointsByNaturalLanguage': // 자연어 주소 기반 경유지 포함 경로 탐색 구현 try { // 1. 출발지 주소를 좌표로 변환 (지오코딩) logger.info(`출발지 지오코딩 시작: ${args.startAddress}`); const startGeocode = await naverMapsClient.geocode(args.startAddress); if (!startGeocode?.addresses || startGeocode.addresses.length === 0) { throw new Error(`출발지 주소를 변환할 수 없습니다: ${args.startAddress}`); } const startCoord = { latitude: parseFloat(startGeocode.addresses[0].y), longitude: parseFloat(startGeocode.addresses[0].x) }; // 2. 도착지 주소를 좌표로 변환 (지오코딩) logger.info(`도착지 지오코딩 시작: ${args.goalAddress}`); const goalGeocode = await naverMapsClient.geocode(args.goalAddress); if (!goalGeocode?.addresses || goalGeocode.addresses.length === 0) { throw new Error(`도착지 주소를 변환할 수 없습니다: ${args.goalAddress}`); } const goalCoord = { latitude: parseFloat(goalGeocode.addresses[0].y), longitude: parseFloat(goalGeocode.addresses[0].x) }; // 3. 경유지 주소들을 좌표로 변환 (지오코딩) const waypointCoords = []; const waypointDetails = []; if (args.waypointAddresses && args.waypointAddresses.length > 0) { for (const waypointAddress of args.waypointAddresses) { logger.info(`경유지 지오코딩 시작: ${waypointAddress}`); const waypointGeocode = await naverMapsClient.geocode(waypointAddress); if (!waypointGeocode?.addresses || waypointGeocode.addresses.length === 0) { throw new Error(`경유지 주소를 변환할 수 없습니다: ${waypointAddress}`); } const waypointCoord = { latitude: parseFloat(waypointGeocode.addresses[0].y), longitude: parseFloat(waypointGeocode.addresses[0].x) }; waypointCoords.push(waypointCoord); waypointDetails.push({ query: waypointAddress, resolved: waypointGeocode.addresses[0].roadAddress || waypointGeocode.addresses[0].jibunAddress, coordinates: waypointCoord }); } } // 4. 변환된 좌표로 경로 탐색 const directionsResult = await naverMapsClient.getDirections15( startCoord, goalCoord, waypointCoords, { option: args.option || 'trafast' } ); // 5. 원본 주소 정보 추가 result = { ...directionsResult, originalAddresses: { start: { query: args.startAddress, resolved: startGeocode.addresses[0].roadAddress || startGeocode.addresses[0].jibunAddress, coordinates: startCoord }, goal: { query: args.goalAddress, resolved: goalGeocode.addresses[0].roadAddress || goalGeocode.addresses[0].jibunAddress, coordinates: goalCoord }, waypoints: waypointDetails } }; } catch (error) { logger.error(`자연어 주소 기반 경유지 포함 경로 탐색 실패: ${error.message}`, error); throw error; } break; case 'transformCoordinates': result = naverMapsClient.transformCoordinates( args.coords, args.fromCoordSys, args.toCoordSys ); break; default: throw new Error(`알 수 없는 도구: ${name}`); } // 결과 반환 return { content: [ { type: 'text', text: JSON.stringify(result, null, 2) } ] }; } catch (error) { logger.error(`도구 실행 오류: ${name}`, error); // 오류 결과 반환 return { isError: true, content: [ { type: 'text', text: `오류: ${error.message || '알 수 없는 오류'}` } ] }; } }); try { // 표준 입출력을 사용하는 전송 계층 생성 const transport = new StdioServerTransport(); // 서버 시작 logger.info('네이버 지도 MCP 서버를 시작합니다...'); await server.connect(transport); logger.info('네이버 지도 MCP 서버가 시작되었습니다.'); // 프로세스 종료 처리 process.on('SIGINT', async () => { logger.info('서버를 종료합니다...'); await transport.close(); process.exit(0); }); } catch (error) { logger.error('서버 시작 실패', error); process.exit(1); } } // 명령줄에서 직접 실행되는 경우 서버 시작 if (import.meta.url === `file://${process.argv[1]}`) { startServer().catch(error => { logger.error('서버 실행 중 오류 발생', error); process.exit(1); }); }