This is page 3 of 3. Use http://codebase.md/jeong-sik/kakao-api-mcp-server?page={x} to view the full context. # Directory Structure ``` ├── .env.example ├── .gitignore ├── .pnp.cjs ├── .pnp.loader.mjs ├── .yarn │ └── install-state.gz ├── LICENSE ├── mcp.json.example ├── package.json ├── README.md ├── src │ └── index.ts ├── tsconfig.json └── yarn.lock ``` # Files -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json { "name": "kakao-api-mcp-server", "version": "1.0.0", "main": "index.js", "type": "module", "scripts": { "build": "tsc -p tsconfig.json", "start": "yarn build && node dist/index.js --mode=http --port=3000", "start:stdio": "yarn build && node dist/index.js --mode=stdio", "dev": "yarn build && node dist/index.js --mode=http --port=3000" }, "keywords": [], "author": "", "license": "MIT", "description": "", "dependencies": { "@modelcontextprotocol/sdk": "^1.8.0", "cors": "^2.8.5", "express": "^5.1.0" }, "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^5.0.1", "axios": "^1.8.4", "eventsource": "^3.0.6", "node-fetch": "^3.3.2" } } ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json { "compilerOptions": { /* Visit https://aka.ms/tsconfig to read more about this file */ /* Projects */ // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ /* Language and Environment */ "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ "lib": ["es6","dom"], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ // "jsx": "preserve", /* Specify what JSX code is generated. */ // "libReplacement": true, /* Enable lib replacement. */ // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ /* Modules */ "module": "NodeNext", "rootDir": "src", "moduleResolution": "NodeNext", // "baseUrl": "./", // "paths": {}, // "rootDirs": [], // "typeRoots": [], // "types": [], // "allowUmdGlobalAccess": true, // "moduleSuffixes": [], // "allowImportingTsExtensions": true, // "rewriteRelativeImportExtensions": true, // "resolvePackageJsonExports": true, // "resolvePackageJsonImports": true, // "customConditions": [], // "noUncheckedSideEffectImports": true, "resolveJsonModule": true, // "allowArbitraryExtensions": true, // "noResolve": true, /* JavaScript Support */ "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ // "checkJs": true, // "maxNodeModuleJsDepth": 1, /* Emit */ // "declaration": true, // "declarationMap": true, // "emitDeclarationOnly": true, // "sourceMap": true, // "inlineSourceMap": true, // "noEmit": true, // "outFile": "./", "outDir": "dist", // "removeComments": true, // "importHelpers": true, // "downlevelIteration": true, // "sourceRoot": "", // "mapRoot": "", // "inlineSources": true, // "emitBOM": true, // "newLine": "crlf", // "stripInternal": true, // "noEmitHelpers": true, // "noEmitOnError": true, // "preserveConstEnums": true, // "declarationDir": "./", /* Interop Constraints */ // "isolatedModules": true, // "verbatimModuleSyntax": true, // "isolatedDeclarations": true, // "erasableSyntaxOnly": true, // "allowSyntheticDefaultImports": true, "esModuleInterop": true, // "preserveSymlinks": true, "forceConsistentCasingInFileNames": true, /* Type Checking */ "strict": true, "noImplicitAny": true, // "strictNullChecks": true, // "strictFunctionTypes": true, // "strictBindCallApply": true, // "strictPropertyInitialization": true, // "strictBuiltinIteratorReturn": true, // "noImplicitThis": true, // "useUnknownInCatchVariables": true, // "alwaysStrict": true, // "noUnusedLocals": true, // "noUnusedParameters": true, // "exactOptionalPropertyTypes": true, // "noImplicitReturns": true, // "noFallthroughCasesInSwitch": true, // "noUncheckedIndexedAccess": true, // "noImplicitOverride": true, // "noPropertyAccessFromIndexSignature": true, // "allowUnusedLabels": true, // "allowUnreachableCode": true, /* Completeness */ // "skipDefaultLibCheck": true, "skipLibCheck": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "test.js", "test-request.json", "**/*.spec.ts"] } ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import axios, { type AxiosError } from "axios"; import dotenv from "dotenv"; import yargs from "yargs"; import { hideBin } from "yargs/helpers"; import express, { type Request, type Response } from "express"; import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import type { IncomingMessage, ServerResponse } from "node:http"; import cors from "cors"; import { fileURLToPath } from "url"; // Load environment variables from .env file dotenv.config(); // --- Configuration --- const argv = yargs(hideBin(process.argv)) .option('kakao-api-key', { alias: 'k', type: 'string', description: 'Kakao REST API Key', }) .option('mode', { type: 'string', choices: ['stdio', 'http'], default: 'stdio', description: 'Transport mode: stdio or http (default: stdio)', }) .option('port', { type: 'number', default: 3000, description: 'Port for HTTP server (HTTP mode only)', }) .help() .alias('help', 'h') .parseSync(); // Custom logger to prevent interference with stdio transport const logger = { log: (message: string, ...args: unknown[]) => { // In stdio mode, write to stderr to avoid interfering with JSON messages if (argv.mode === 'stdio') { process.stderr.write(`LOG: ${message}\n`); } else { console.log(message, ...args); } }, error: (message: string, ...args: unknown[]) => { // Always write errors to stderr if (argv.mode === 'stdio') { process.stderr.write(`ERROR: ${message}\n`); } else { console.error(message, ...args); } } }; // Get Kakao API Key: prioritize command-line arg, then env var const KAKAO_API_KEY = argv.kakaoApiKey || process.env.KAKAO_REST_API_KEY; if (!KAKAO_API_KEY) { logger.error( "Error: Kakao REST API Key not found. " + "Provide it via --kakao-api-key argument or KAKAO_REST_API_KEY environment variable." ); process.exit(1); // Exit if no key is found } logger.log("Kakao REST API Key loaded successfully."); // --- Define Kakao API Response Types --- interface KakaoPlaceDocument { place_name: string; address_name: string; category_name: string; place_url: string; phone?: string; x?: string; y?: string; } interface KakaoKeywordSearchMeta { total_count: number; pageable_count: number; is_end: boolean; } interface KakaoKeywordSearchResponse { documents: KakaoPlaceDocument[]; meta: KakaoKeywordSearchMeta; } interface KakaoAddress { address_name: string; region_1depth_name: string; region_2depth_name: string; region_3depth_name: string; mountain_yn: string; main_address_no: string; sub_address_no?: string; zip_code?: string; } interface KakaoRoadAddress { address_name: string; region_1depth_name: string; region_2depth_name: string; region_3depth_name: string; road_name: string; underground_yn: string; main_building_no: string; sub_building_no?: string; building_name?: string; zone_no: string; } interface KakaoCoord2AddressDocument { road_address: KakaoRoadAddress | null; address: KakaoAddress | null; } interface KakaoCoord2AddressResponse { meta: { total_count: number }; documents: KakaoCoord2AddressDocument[]; } // Daum 검색 API 응답 타입 정의 interface DaumSearchResponse { meta: { total_count: number; pageable_count: number; is_end: boolean; }; documents: Record<string, unknown>[]; } // <<< WaypointResult 인터페이스 정의 이동 >>> interface WaypointResult { success: boolean; name: string; placeName?: string; addressName?: string; x?: string; y?: string; } // --- MCP Server Setup --- const server = new McpServer( { name: "kakao-map", version: "0.1.0" }, { capabilities: { logging: {}, tools: {} } } ); // 위 코드 대신 MCP SDK의 문서나 타입을 확인하여 올바른 이벤트 처리 방식 적용 // 임시로 주석 처리 // 카카오맵 API용 axios 인스턴스 생성 const kakaoApiClient = axios.create({ baseURL: 'https://dapi.kakao.com', headers: { Authorization: `KakaoAK ${KAKAO_API_KEY}`, } }); // 카카오모빌리티 API용 axios 인스턴스 생성 const kakaoMobilityApiClient = axios.create({ baseURL: 'https://apis-navi.kakaomobility.com', headers: { Authorization: `KakaoAK ${KAKAO_API_KEY}`, } }); // axios 인스턴스에 인터셉터 추가 kakaoApiClient.interceptors.request.use(request => { logger.log(`Kakao API Request: ${request.method?.toUpperCase()} ${request.url}`); return request; }); kakaoApiClient.interceptors.response.use(response => { logger.log(`Kakao API Response: ${response.status} ${response.statusText}`); return response; }, error => { logger.error(`Kakao API Error: ${error.message}`); return Promise.reject(error); }); kakaoMobilityApiClient.interceptors.request.use(request => { logger.log(`Kakao Mobility API Request: ${request.method?.toUpperCase()} ${request.url}`); logger.log(`Request Headers: ${JSON.stringify(request.headers)}`); logger.log(`Request Params: ${JSON.stringify(request.params)}`); return request; }); kakaoMobilityApiClient.interceptors.response.use(response => { logger.log(`Kakao Mobility API Response: ${response.status} ${response.statusText}`); return response; }, error => { logger.error(`Kakao Mobility API Error: ${error.message}`); if (axios.isAxiosError(error)) { logger.error(`Status: ${error.response?.status}`); logger.error(`Data: ${JSON.stringify(error.response?.data)}`); } return Promise.reject(error); }); // Tool: search-places const searchPlacesSchema = z.object({ keyword: z.string().describe('검색할 키워드 (예: "강남역 맛집")'), x: z.number().optional().describe("중심 좌표의 X 또는 longitude 값 (WGS84)"), y: z.number().optional().describe("중심 좌표의 Y 또는 latitude 값 (WGS84)"), radius: z.number().int().min(0).max(20000).optional().describe("중심 좌표부터의 검색 반경(0~20000m)"), }); server.tool( "search-places", "키워드를 사용하여 카카오맵에서 장소를 검색합니다.", searchPlacesSchema.shape, async (params: z.infer<typeof searchPlacesSchema>) => { logger.log("Executing search-places tool with params:", params); try { const response = await kakaoApiClient.get<KakaoKeywordSearchResponse>( "/v2/local/search/keyword.json", { params: { query: params.keyword, x: params.x, y: params.y, radius: params.radius, }, } ); logger.log(`API Response received: ${response.status}`); logger.log(`Results count: ${response.data.documents?.length || 0}`); const formattedResponse = formatPlacesResponse(response.data); logger.log(`Formatted response: ${formattedResponse.substring(0, 100)}...`); // 결과를 STDOUT에 명시적으로 출력 (디버깅용) if (argv.mode === 'stdio') { const result = { type: "tool_response", tool: "search-places", content: [{ type: "text", text: formattedResponse }] }; console.log(JSON.stringify(result)); } return { content: [{ type: "text", text: formattedResponse }], }; } catch (error: unknown) { let errorMessage = "알 수 없는 오류 발생"; if (axios.isAxiosError(error)) { const axiosError = error as AxiosError<{ message?: string }>; logger.error("API Error status:", axiosError.response?.status); logger.error("API Error details:", JSON.stringify(axiosError.response?.data)); errorMessage = axiosError.response?.data?.message || axiosError.message; } else if (error instanceof Error) { errorMessage = error.message; } // 오류 결과를 STDOUT에 명시적으로 출력 (디버깅용) if (argv.mode === 'stdio') { const errorResult = { type: "tool_response", tool: "search-places", content: [{ type: "text", text: `장소 검색 중 오류 발생: ${errorMessage}` }] }; console.log(JSON.stringify(errorResult)); } return { content: [{ type: "text", text: `장소 검색 중 오류 발생: ${errorMessage}` }], }; } } ); // Tool: coord-to-address const coordToAddressSchema = z.object({ x: z.number().describe("경도 (longitude) WGS84 좌표"), y: z.number().describe("위도 (latitude) WGS84 좌표"), }); server.tool( "coord-to-address", "좌표(경도, 위도)를 주소(도로명, 지번)로 변환합니다.", coordToAddressSchema.shape, async (params: z.infer<typeof coordToAddressSchema>) => { logger.log("Executing coord-to-address tool with params:", params); try { const response = await kakaoApiClient.get<KakaoCoord2AddressResponse>( "https://dapi.kakao.com/v2/local/geo/coord2address.json", { params: { x: params.x, y: params.y, }, } ); const formattedResponse = formatAddressResponse(response.data); return { content: [{ type: "text", text: formattedResponse }], }; } catch (error: unknown) { let errorMessage = "알 수 없는 오류 발생"; if (axios.isAxiosError(error)) { const axiosError = error as AxiosError<{ message?: string }>; logger.error("API Error status:", axiosError.response?.status); errorMessage = axiosError.response?.data?.message || axiosError.message; } else if (error instanceof Error) { errorMessage = error.message; } return { content: [{ type: "text", text: `좌표-주소 변환 중 오류 발생: ${errorMessage}` }], }; } } ); // Tool: find-route const findRouteSchema = z.object({ origin: z.string().describe('출발지 이름 (예: "강남역")'), destination: z.string().describe('목적지 이름 (예: "코엑스")'), waypoints: z.array(z.string()).optional().describe('경유지 이름 목록 (선택사항)'), transportation_type: z.enum(["car", "public", "walk"]).default("car").describe("이동 수단 (자동차, 대중교통, 도보)"), priority: z.enum(["RECOMMEND", "TIME", "DISTANCE"]).default("RECOMMEND").describe("경로 탐색 우선순위 (추천, 최단시간, 최단거리)"), traffic_info: z.boolean().default(true).describe("교통 정보 포함 여부") }); server.tool( "find-route", "출발지에서 목적지까지의 길찾기 정보를 제공합니다.", findRouteSchema.shape, async (params: z.infer<typeof findRouteSchema>) => { logger.log("Executing find-route tool with params:", params); try { // 1. 출발지 검색 const originResponse = await kakaoApiClient.get<KakaoKeywordSearchResponse>( "https://dapi.kakao.com/v2/local/search/keyword.json", { params: { query: params.origin } } ); // <<< 출발지 응답 로깅 추가 >>> logger.log("Origin Search Response:", JSON.stringify(originResponse.data, null, 2)); if (!originResponse.data.documents || originResponse.data.documents.length === 0) { return { content: [{ type: "text", text: `출발지 "${params.origin}"를 찾을 수 없습니다.` }] }; } // 2. 목적지 검색 const destinationResponse = await kakaoApiClient.get<KakaoKeywordSearchResponse>( "https://dapi.kakao.com/v2/local/search/keyword.json", { params: { query: params.destination } } ); // <<< 목적지 응답 로깅 추가 >>> logger.log("Destination Search Response:", JSON.stringify(destinationResponse.data, null, 2)); if (!destinationResponse.data.documents || destinationResponse.data.documents.length === 0) { return { content: [{ type: "text", text: `목적지 "${params.destination}"를 찾을 수 없습니다.` }] }; } // 3. 경유지 검색 (있는 경우) const waypointsPromises = params.waypoints?.map(waypoint => kakaoApiClient.get<KakaoKeywordSearchResponse>( "https://dapi.kakao.com/v2/local/search/keyword.json", { params: { query: waypoint } } ) ) || []; const waypointsResponses = await Promise.all(waypointsPromises); const waypointsResults: WaypointResult[] = waypointsResponses.map((response, index) => { if (!response.data.documents || response.data.documents.length === 0) { return { success: false, name: params.waypoints?.[index] || "알 수 없음" }; } const place = response.data.documents[0]; return { success: true, name: params.waypoints?.[index] || "알 수 없음", placeName: place.place_name, addressName: place.address_name, x: place.x, y: place.y }; }); // 실패한 경유지가 있는지 확인 const failedWaypoints = waypointsResults.filter(wp => !wp.success); if (failedWaypoints.length > 0) { return { content: [{ type: "text", text: `다음 경유지를 찾을 수 없습니다: ${failedWaypoints.map(wp => wp.name).join(', ')}` }] }; } // 4. 결과 조합 const origin = originResponse.data.documents[0]; const destination = destinationResponse.data.documents[0]; // 기본 웹 링크 생성 (카카오맵) let formattedResult = ""; let mapUrl = `https://map.kakao.com/?sName=${encodeURIComponent(origin.place_name)}&eName=${encodeURIComponent(destination.place_name)}`; // 경유지가 있는 경우 if (waypointsResults.length > 0) { const successWaypoints = waypointsResults.filter(wp => wp.success && wp.placeName); if (successWaypoints.length > 0) { const waypointsParam = successWaypoints .map(wp => wp.placeName ? encodeURIComponent(wp.placeName) : '') .filter(Boolean) .join(','); if (waypointsParam) { mapUrl += `&waypoints=${waypointsParam}`; } } } // 이동 수단에 따라 처리 분기 if (params.transportation_type === "car") { // 자동차 경로는 카카오모빌리티 API 사용 // 카카오모빌리티 API용 axios 인스턴스 생성 const mobilityApiClient = axios.create({ headers: { Authorization: `KakaoAK ${KAKAO_API_KEY}`, 'Content-Type': 'application/json' } }); // 카카오모빌리티 API 파라미터 구성 const originCoord = `${origin.x},${origin.y}`; const destCoord = `${destination.x},${destination.y}`; // 경유지 구성 let waypointsParam = ""; if (waypointsResults.length > 0) { const successWaypoints = waypointsResults.filter(wp => wp.success && wp.x && wp.y); if (successWaypoints.length > 0) { waypointsParam = successWaypoints .map(wp => `${wp.x},${wp.y}`) .join('|'); } } // 카카오모빌리티 API 호출 try { // <<< 좌표 검색 결과 및 transportation_type 로깅 추가 >>> const originSuccess = originResponse.data.documents && originResponse.data.documents.length > 0; const destinationSuccess = destinationResponse.data.documents && destinationResponse.data.documents.length > 0; logger.log(`Checking conditions for Mobility API call:`); logger.log(` transportation_type: ${params.transportation_type}`); logger.log(` origin success: ${originSuccess}`); logger.log(` destination success: ${destinationSuccess}`); // (필요 시 waypoints 결과 로깅 추가) // logger.log(\` waypoints results: \${JSON.stringify(waypointsResults)}\`); if (params.transportation_type === "car" && originSuccess && destinationSuccess) { // 자동차 경로이고, 출발지/목적지 좌표 검색 성공 시에만 모빌리티 API 호출 // URL 파라미터 구성 const apiParams: Record<string, string> = { origin: `${origin.x},${origin.y}`, destination: `${destination.x},${destination.y}`, priority: params.priority.toLowerCase(), car_fuel: "GASOLINE", alternatives: "false", road_details: params.traffic_info ? "true" : "false", summary: "true" }; // 경유지가 있는 경우 추가 if (waypointsParam) { apiParams.waypoints = waypointsParam; } // 카카오모빌리티 API 호출 (GET 방식으로 변경) const mobilityResponse = await kakaoMobilityApiClient.get('/v1/directions', { params: apiParams }); // <<< API 응답 로깅 추가 >>> logger.log("Kakao Mobility API Response:", JSON.stringify(mobilityResponse.data, null, 2)); if (mobilityResponse.data && mobilityResponse.data.routes && mobilityResponse.data.routes.length > 0) { const route = mobilityResponse.data.routes[0]; if (route.result_code === 0) { // 성공 formattedResult = formatMobilityRouteResult(route, origin, destination, waypointsResults, params); } else { // 길찾기 실패 시 기본 맵 URL로 대체 formattedResult = formatBasicRouteResult(origin, destination, waypointsResults, params, mapUrl); } } else { // 응답 데이터 없음 - 기본 맵 URL로 대체 formattedResult = formatBasicRouteResult(origin, destination, waypointsResults, params, mapUrl); } } else { // 응답 데이터 없음 - 기본 맵 URL로 대체 formattedResult = formatBasicRouteResult(origin, destination, waypointsResults, params, mapUrl); } } catch (error) { // 더 자세한 오류 로깅 logger.error("Mobility API error:", error); if (axios.isAxiosError(error)) { logger.error("API Error details:", error.response?.data); logger.error("API Error status:", error.response?.status); } // API 호출 실패 시 기본 맵 URL로 대체 formattedResult = formatBasicRouteResult(origin, destination, waypointsResults, params, mapUrl); } } else { // 대중교통이나 도보는 기존 방식 사용 const transportMode = params.transportation_type === "public" ? "transit" : "walk"; mapUrl += `&carMode=${transportMode}`; formattedResult = formatBasicRouteResult(origin, destination, waypointsResults, params, mapUrl); } return { content: [{ type: "text", text: formattedResult }] }; } catch (error: unknown) { let errorMessage = "알 수 없는 오류 발생"; if (axios.isAxiosError(error)) { const axiosError = error as AxiosError<{ message?: string }>; logger.error("API Error status:", axiosError.response?.status); errorMessage = axiosError.response?.data?.message || axiosError.message; } else if (error instanceof Error) { errorMessage = error.message; } return { content: [{ type: "text", text: `길찾기 중 오류 발생: ${errorMessage}` }] }; } } ); // Daum 웹 검색 도구 const webSearchSchema = z.object({ query: z.string().describe('검색할 질의어'), sort: z.enum(['accuracy', 'recency']).optional().describe('결과 정렬 방식 (accuracy: 정확도순, recency: 최신순)'), page: z.number().int().min(1).max(50).optional().describe('결과 페이지 번호 (1~50, 기본값 1)'), size: z.number().int().min(1).max(50).optional().describe('한 페이지에 보여질 문서 수 (1~50, 기본값 10)'), }); server.tool( "search-web", "다음(Daum) 검색에서 웹 문서를 검색합니다.", webSearchSchema.shape, async (params: z.infer<typeof webSearchSchema>) => { logger.log("Executing search-web tool with params:", params); try { const response = await kakaoApiClient.get<DaumSearchResponse>( "/v2/search/web", { params: { query: params.query, sort: params.sort, page: params.page, size: params.size, }, } ); logger.log(`API Response received: ${response.status}`); logger.log(`Results count: ${response.data.meta.total_count}`); const formattedResponse = formatDaumSearchResponse("웹 문서", response.data); return { content: [{ type: "text", text: formattedResponse }], }; } catch (error: unknown) { let errorMessage = "알 수 없는 오류 발생"; if (axios.isAxiosError(error)) { const axiosError = error as AxiosError<{ message?: string }>; logger.error("API Error status:", axiosError.response?.status); logger.error("API Error details:", JSON.stringify(axiosError.response?.data)); errorMessage = axiosError.response?.data?.message || axiosError.message; } else if (error instanceof Error) { errorMessage = error.message; } return { content: [{ type: "text", text: `웹 검색 중 오류 발생: ${errorMessage}` }], }; } } ); // Daum 이미지 검색 도구 const imageSearchSchema = z.object({ query: z.string().describe('검색할 질의어'), sort: z.enum(['accuracy', 'recency']).optional().describe('결과 정렬 방식 (accuracy: 정확도순, recency: 최신순)'), page: z.number().int().min(1).max(50).optional().describe('결과 페이지 번호 (1~50, 기본값 1)'), size: z.number().int().min(1).max(80).optional().describe('한 페이지에 보여질 문서 수 (1~80, 기본값 10)'), }); server.tool( "search-image", "다음(Daum) 검색에서 이미지를 검색합니다.", imageSearchSchema.shape, async (params: z.infer<typeof imageSearchSchema>) => { logger.log("Executing search-image tool with params:", params); try { const response = await kakaoApiClient.get<DaumSearchResponse>( "/v2/search/image", { params: { query: params.query, sort: params.sort, page: params.page, size: params.size, }, } ); logger.log(`API Response received: ${response.status}`); logger.log(`Results count: ${response.data.meta.total_count}`); const formattedResponse = formatDaumSearchResponse("이미지", response.data); return { content: [{ type: "text", text: formattedResponse }], }; } catch (error: unknown) { let errorMessage = "알 수 없는 오류 발생"; if (axios.isAxiosError(error)) { const axiosError = error as AxiosError<{ message?: string }>; logger.error("API Error status:", axiosError.response?.status); logger.error("API Error details:", JSON.stringify(axiosError.response?.data)); errorMessage = axiosError.response?.data?.message || axiosError.message; } else if (error instanceof Error) { errorMessage = error.message; } return { content: [{ type: "text", text: `이미지 검색 중 오류 발생: ${errorMessage}` }], }; } } ); // Daum 블로그 검색 도구 const blogSearchSchema = z.object({ query: z.string().describe('검색할 질의어'), sort: z.enum(['accuracy', 'recency']).optional().describe('결과 정렬 방식 (accuracy: 정확도순, recency: 최신순)'), page: z.number().int().min(1).max(50).optional().describe('결과 페이지 번호 (1~50, 기본값 1)'), size: z.number().int().min(1).max(50).optional().describe('한 페이지에 보여질 문서 수 (1~50, 기본값 10)'), }); server.tool( "search-blog", "다음(Daum) 검색에서 블로그 글을 검색합니다.", blogSearchSchema.shape, async (params: z.infer<typeof blogSearchSchema>) => { logger.log("Executing search-blog tool with params:", params); try { const response = await kakaoApiClient.get<DaumSearchResponse>( "/v2/search/blog", { params: { query: params.query, sort: params.sort, page: params.page, size: params.size, }, } ); logger.log(`API Response received: ${response.status}`); logger.log(`Results count: ${response.data.meta.total_count}`); const formattedResponse = formatDaumSearchResponse("블로그", response.data); return { content: [{ type: "text", text: formattedResponse }], }; } catch (error: unknown) { let errorMessage = "알 수 없는 오류 발생"; if (axios.isAxiosError(error)) { const axiosError = error as AxiosError<{ message?: string }>; logger.error("API Error status:", axiosError.response?.status); logger.error("API Error details:", JSON.stringify(axiosError.response?.data)); errorMessage = axiosError.response?.data?.message || axiosError.message; } else if (error instanceof Error) { errorMessage = error.message; } return { content: [{ type: "text", text: `블로그 검색 중 오류 발생: ${errorMessage}` }], }; } } ); // Daum 카페 검색 도구 const cafeSearchSchema = z.object({ query: z.string().describe('검색할 질의어'), sort: z.enum(['accuracy', 'recency']).optional().describe('결과 정렬 방식 (accuracy: 정확도순, recency: 최신순)'), page: z.number().int().min(1).max(50).optional().describe('결과 페이지 번호 (1~50, 기본값 1)'), size: z.number().int().min(1).max(50).optional().describe('한 페이지에 보여질 문서 수 (1~50, 기본값 10)'), }); server.tool( "search-cafe", "다음(Daum) 검색에서 카페 글을 검색합니다.", cafeSearchSchema.shape, async (params: z.infer<typeof cafeSearchSchema>) => { logger.log("Executing search-cafe tool with params:", params); try { const response = await kakaoApiClient.get<DaumSearchResponse>( "/v2/search/cafe", { params: { query: params.query, sort: params.sort, page: params.page, size: params.size, }, } ); logger.log(`API Response received: ${response.status}`); logger.log(`Results count: ${response.data.meta.total_count}`); const formattedResponse = formatDaumSearchResponse("카페", response.data); return { content: [{ type: "text", text: formattedResponse }], }; } catch (error: unknown) { let errorMessage = "알 수 없는 오류 발생"; if (axios.isAxiosError(error)) { const axiosError = error as AxiosError<{ message?: string }>; logger.error("API Error status:", axiosError.response?.status); logger.error("API Error details:", JSON.stringify(axiosError.response?.data)); errorMessage = axiosError.response?.data?.message || axiosError.message; } else if (error instanceof Error) { errorMessage = error.message; } return { content: [{ type: "text", text: `카페 검색 중 오류 발생: ${errorMessage}` }], }; } } ); // --- Helper Functions --- function formatPlacesResponse(data: KakaoKeywordSearchResponse): string { if (!data || !data.documents || data.documents.length === 0) { return "검색 결과가 없습니다."; } const places = data.documents.map((place: KakaoPlaceDocument) => { let result = `이름: ${place.place_name}\n주소: ${place.address_name}\n카테고리: ${place.category_name}`; // 전화번호가 있으면 추가 if (place.phone) { result += `\n전화번호: ${place.phone}`; } // 카카오맵 링크 추가 result += `\n상세정보: ${place.place_url}`; return result; }).join("\n---\n"); const pageInfo = data.meta ? ` (결과 수: ${data.meta.pageable_count}, 총 ${data.meta.total_count}개)` : ""; return `장소 검색 결과${pageInfo}:\n${places}`; } function formatAddressResponse(data: KakaoCoord2AddressResponse): string { if (!data || !data.documents || data.documents.length === 0) { return "해당 좌표에 대한 주소 정보를 찾을 수 없습니다."; } const doc = data.documents[0]; const roadAddress = doc.road_address ? `도로명: ${doc.road_address.address_name}` : "도로명 주소 정보 없음"; const lotAddress = doc.address ? `지번: ${doc.address.address_name}` : "지번 주소 정보 없음"; return `주소 변환 결과:\n${roadAddress}\n${lotAddress}`; } // 카카오모빌리티 API 응답을 포맷팅하는 함수 function formatMobilityRouteResult( route: Record<string, any>, origin: KakaoPlaceDocument, destination: KakaoPlaceDocument, waypoints: WaypointResult[], params: z.infer<typeof findRouteSchema> ): string { let result = '🗺️ 길찾기 결과\\n\\n'; result += `출발지: ${origin.place_name} (${origin.address_name})\\n`; // 경유지가 있는 경우 if (waypoints.length > 0) { const successWaypoints = waypoints.filter(wp => wp.success); if (successWaypoints.length > 0) { result += '\\n경유지:\\n'; for (const [index, wp] of successWaypoints.entries()) { // forEach 대신 for...of 사용 result += `${index + 1}. ${wp.placeName} (${wp.addressName})\\n`; } } } result += `\\n목적지: ${destination.place_name} (${destination.address_name})\\n`; result += `\\n이동 수단: ${getTransportationName(params.transportation_type)}\\n`; // 카카오모빌리티 API 결과 표시 const summary = route.summary; if (summary && typeof summary === 'object') { // summary 타입 확인 추가 if (typeof summary.distance === 'number') { // distance 타입 확인 추가 result += `\\n총 거리: ${formatDistance(summary.distance)}\\n`; } if (typeof summary.duration === 'number') { // duration 타입 확인 추가 result += `예상 소요 시간: ${formatDuration(summary.duration)}\\n`; } // 택시 요금 표시 if (summary.fare && typeof summary.fare === 'object' && typeof summary.fare.taxi === 'number') { // 타입 확인 추가 result += `예상 택시 요금: ${summary.fare.taxi.toLocaleString()}원\\n`; } // 통행 요금 표시 if (summary.fare && typeof summary.fare === 'object' && typeof summary.fare.toll === 'number' && summary.fare.toll > 0) { // 타입 확인 추가 result += `통행 요금: ${summary.fare.toll.toLocaleString()}원\\n`; } } // 교통 정보 표시 if (params.traffic_info && Array.isArray(route.sections)) { // sections 타입 확인 추가 result += '\\n📊 교통 상황 요약:\\n'; let totalDistance = 0; let totalCongestionDistance = 0; let totalHeavyDistance = 0; let totalSlowDistance = 0; // 타입 단언 대신 타입 가드 사용 (더 안전한 방식은 API 응답 타입 정의) for (const section of route.sections) { if (section && typeof section === 'object' && Array.isArray(section.roads)) { for (const road of section.roads) { if (road && typeof road === 'object' && typeof road.distance === 'number' && typeof road.traffic_state === 'number') { totalDistance += road.distance; if (road.traffic_state === 4) { totalCongestionDistance += road.distance; } else if (road.traffic_state === 3) { totalHeavyDistance += road.distance; } else if (road.traffic_state === 2) { totalSlowDistance += road.distance; } } } } } // 전체 거리 중 교통 상태별 비율 계산 if (totalDistance > 0) { const congestionPercent = Math.round((totalCongestionDistance / totalDistance) * 100); const heavyPercent = Math.round((totalHeavyDistance / totalDistance) * 100); const slowPercent = Math.round((totalSlowDistance / totalDistance) * 100); const smoothPercent = 100 - congestionPercent - heavyPercent - slowPercent; result += `🟢 원활: ${smoothPercent}%\\n`; result += `🟡 서행: ${slowPercent}%\\n`; result += `🟠 지체: ${heavyPercent}%\\n`; result += `🔴 정체: ${congestionPercent}%\\n`; } // 주요 정체 구간 표시 (최대 3개) if (Array.isArray(route.sections) && params.traffic_info) { // sections 타입 확인 추가 const congestionRoads: { name: string; distance: number; traffic_state: number }[] = []; for (const section of route.sections) { if (section && typeof section === 'object' && Array.isArray(section.roads)) { for (const road of section.roads) { if (road && typeof road === 'object' && typeof road.traffic_state === 'number' && road.traffic_state >= 3 && typeof road.distance === 'number' && road.distance > 300 && typeof road.name === 'string') { congestionRoads.push({ name: road.name, distance: road.distance, traffic_state: road.traffic_state }); } } } } congestionRoads.sort((a, b) => b.distance - a.distance); if (congestionRoads.length > 0) { result += '\\n주요 정체 구간:\\n'; for(const road of congestionRoads.slice(0, 3)) { const trafficEmoji = road.traffic_state === 4 ? '🔴' : '🟠'; result += `${trafficEmoji} ${road.name} (${formatDistance(road.distance)})\\n`; } } } } // 카카오맵 링크 const mapUrl = `https://map.kakao.com/?sName=${encodeURIComponent(origin.place_name)}&eName=${encodeURIComponent(destination.place_name)}`; result += `\\n카카오맵에서 보기: ${mapUrl}\\n`; return result; } // 기본 경로 결과 포맷팅 함수 function formatBasicRouteResult( origin: KakaoPlaceDocument, destination: KakaoPlaceDocument, waypoints: WaypointResult[], params: z.infer<typeof findRouteSchema>, mapUrl: string ): string { let result = '🗺️ 길찾기 결과\\n\\n'; result += `출발지: ${origin.place_name} (${origin.address_name})\\n`; if (waypoints.length > 0) { const successWaypoints = waypoints.filter(wp => wp.success && wp.placeName && wp.addressName); if (successWaypoints.length > 0) { result += '\\n경유지:\\n'; successWaypoints.forEach((wp, index) => { result += `${index + 1}. ${wp.placeName} (${wp.addressName})\\n`; }); } } result += `\\n목적지: ${destination.place_name} (${destination.address_name})\\n`; result += `\\n이동 수단: ${getTransportationName(params.transportation_type)}\\n`; result += `\\n카카오맵 길찾기: ${mapUrl}\\n`; result += '\\n상세 경로 및 소요 시간은 카카오맵 링크를 통해 확인하세요.'; return result; } // 거리를 포맷팅하는 함수 function formatDistance(meters: number): string { if (meters < 1000) { return `${meters}m`; } return `${(meters / 1000).toFixed(1)}km`; } // 시간을 포맷팅하는 함수 function formatDuration(seconds: number): string { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); if (hours > 0) { return `${hours}시간 ${minutes}분`; } return `${minutes}분`; } // 이동 수단 한글 이름 반환 함수 function getTransportationName(type: string): string { switch (type) { case "car": return "자동차"; case "public": return "대중교통"; case "walk": return "도보"; default: return "자동차"; } } // 다음 검색 결과 포맷팅 함수 function formatDaumSearchResponse(searchType: string, data: DaumSearchResponse): string { if (!data || !data.documents || data.documents.length === 0) { return '검색 결과가 없습니다.'; } let result = `${searchType} 검색 결과 (총 ${data.meta.total_count}개 중 ${data.documents.length}개 표시):\n\n`; for (const [index, doc] of data.documents.entries()) { result += `${index + 1}. `; // 제목 처리 const title = doc.title; const sitename = doc.display_sitename; if (typeof title === 'string' && title) { result += `${title.replace(/<b>/g, '').replace(/<\/b>/g, '')}\n`; } else if (typeof sitename === 'string' && sitename) { result += `${sitename}\n`; } else { result += '[제목 없음]\n'; } // 내용 처리 const contents = doc.contents; if (typeof contents === 'string' && contents) { const cleanContent = contents.replace(/<b>/g, '').replace(/<\/b>/g, ''); result += ` 내용: ${cleanContent.substring(0, 100)}${cleanContent.length > 100 ? '...' : ''}\n`; } // URL 처리 if (typeof doc.url === 'string' && doc.url) { result += ` URL: ${doc.url}\n`; } // 날짜 처리 const datetimeValue = doc.datetime; if (typeof datetimeValue === 'string' || typeof datetimeValue === 'number') { try { const datetime = new Date(datetimeValue); if (!Number.isNaN(datetime.getTime())) { // isNaN 대신 Number.isNaN 사용 및 유효성 검사 강화 result += ` 날짜: ${datetime.toLocaleDateString('ko-KR')}\n`; } } catch (e) { logger.error(`Invalid date format for datetime: ${datetimeValue}`); } } // 이미지 URL 처리 const thumbnailUrl = doc.thumbnail_url; const imageUrl = doc.image_url; if (typeof thumbnailUrl === 'string' || typeof imageUrl === 'string') { result += ` 이미지: ${thumbnailUrl || imageUrl}\n`; } // 카페명 처리 if (typeof doc.cafename === 'string' && doc.cafename) { result += ` 카페: ${doc.cafename}\n`; } // 블로그명 처리 if (typeof doc.blogname === 'string' && doc.blogname) { result += ` 블로그: ${doc.blogname}\n`; } // 출처 처리 if (typeof doc.collection === 'string' && doc.collection) { result += ` 출처: ${doc.collection}\n`; } // 크기 처리 if (typeof doc.width === 'number' && typeof doc.height === 'number') { result += ` 크기: ${doc.width}x${doc.height}\n`; } result += '\n'; // 각 문서 사이에 줄바꿈 추가 } // 페이지 정보 추가 result += `현재 페이지가 마지막 페이지${data.meta.is_end ? '입니다.' : '가 아닙니다. 더 많은 결과를 보려면 page 매개변수를 증가시키세요.'}\n`; return result; // 함수 끝에 return 명시 } // --- Run the Server (based on mode) --- // Export the server instance if needed for testing or other modules // export { server }; // <-- 파일 최상단으로 이동하거나 제거 (Node.js ESM 권장사항 따름) // ESM에서는 require.main === module 대신 다른 방식으로 직접 실행 감지 // https://nodejs.org/api/esm.html#esm_no_require_exports_module_exports_filename_dirname // Node.js v20부터는 import.meta.url을 사용하여 현재 파일이 직접 실행되는지 확인할 수 있음 // const isMainModule = import.meta.url === \`file://\${process.argv[1]}\`; // 수정 전 let isMainModule = false; try { // fileURLToPath를 사용하여 올바르게 경로 비교 const currentFilePath = fileURLToPath(import.meta.url); isMainModule = currentFilePath === process.argv[1]; } catch (e) { // import.meta.url이 지원되지 않는 환경 고려 (예: CommonJS) // 또는 다른 방식으로 메인 모듈 여부 확인 필요 시 추가 } // import/export는 최상위 레벨에서만 사용 가능하므로, 서버 시작 로직을 함수로 감싸기 async function startServer() { const mode = argv.mode as 'stdio' | 'http'; const port = argv.port as number; if (mode === 'stdio') { // STDIO Mode - Direct connection via stdio logger.log("Starting Kakao Map MCP Server in stdio mode..."); const stdioTransport = new StdioServerTransport(); // 디버깅을 위한 stdio 입력 로깅 process.stdin.on('data', (data) => { const input = data.toString().trim(); logger.log(`STDIN received: ${input}`); // logger.log로 복원 // logger.error(\`STDIN received (error level): \${input}\`); // logger.error 주석 처리 try { // 입력 데이터 파싱 const parsedData = JSON.parse(input); logger.log(`Parsed message type: ${parsedData.type}, tool: ${parsedData.tool}`); // logger.log로 복원 // logger.error(\`Parsed message (error level) - Type: \${parsedData.type}, Tool: \${parsedData.tool}\`); // logger.error 주석 처리 } catch (err: unknown) { if (err instanceof Error) { logger.error(`Failed to parse input: ${err.message}`); } else { logger.error('Failed to parse input: Unknown error'); // 수정 후 } } }); server.connect(stdioTransport).then(() => { logger.log("Kakao Map MCP Server connected via stdio."); }).catch(error => { logger.error(`Failed to connect server via stdio: ${error}`); process.exit(1); }); } else { // HTTP/SSE Mode - Express server with SSE const app = express(); let sseTransport: SSEServerTransport | null = null; // CORS Configuration const corsOptions = { origin: 'http://localhost:5173', // For MCP Inspector methods: "GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS", allowedHeaders: ["Content-Type", "Authorization"], credentials: true, optionsSuccessStatus: 204 }; app.use(cors(corsOptions)); // Routes app.get("/sse", async (req: Request, res: Response) => { logger.log("New SSE connection request received."); if (sseTransport) { logger.log("An existing SSE transport is active. Replacing with the new connection."); } // Create a new transport for this request const currentTransport = new SSEServerTransport("/messages", res as unknown as ServerResponse<IncomingMessage>); sseTransport = currentTransport; try { await server.connect(sseTransport); logger.log("MCP Server connected to SSE transport."); req.on("close", () => { logger.log("SSE connection closed by client."); if (sseTransport === currentTransport) { sseTransport = null; } }); } catch (error) { logger.error(`Error connecting MCP server to SSE transport: ${error}`); if (sseTransport === currentTransport) { sseTransport = null; } if (!res.writableEnded) { res.status(500).end(); } } }); app.post("/messages", (req: Request, res: Response): void => { logger.log("Received POST /messages request."); if (!sseTransport) { logger.error("Received POST message but no active SSE transport."); res.status(400).send("No active SSE connection"); return; } sseTransport.handlePostMessage(req as unknown as IncomingMessage, res as unknown as ServerResponse<IncomingMessage>) .then(() => { logger.log("POST message handled by SSE transport."); }) .catch((error) => { logger.error(`Error handling POST message: ${error}`); if (!res.headersSent) { res.status(500).send("Error processing message"); } }); }); // Start Server app.listen(port, () => { logger.log(`Kakao Map MCP Server (HTTP/SSE) listening on port ${port}`); logger.log(`SSE endpoint available at http://localhost:${port}/sse`); logger.log(`Message endpoint available at http://localhost:${port}/messages`); }); } } // startServer 함수 닫는 중괄호 // 메인 모듈로 실행될 때만 서버 시작 if (isMainModule) { startServer().catch(error => { logger.error("Failed to start server:", error); process.exit(1); }); } // if (isMainModule) 닫는 중괄호 // 서버 인스턴스를 export해야 한다면 파일 최상단에서 export export { server }; ```