This is page 3 of 3. Use http://codebase.md/jeong-sik/kakao-api-mcp-server?lines=true&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 1 | { 2 | "name": "kakao-api-mcp-server", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "type": "module", 6 | "scripts": { 7 | "build": "tsc -p tsconfig.json", 8 | "start": "yarn build && node dist/index.js --mode=http --port=3000", 9 | "start:stdio": "yarn build && node dist/index.js --mode=stdio", 10 | "dev": "yarn build && node dist/index.js --mode=http --port=3000" 11 | }, 12 | "keywords": [], 13 | "author": "", 14 | "license": "MIT", 15 | "description": "", 16 | "dependencies": { 17 | "@modelcontextprotocol/sdk": "^1.8.0", 18 | "cors": "^2.8.5", 19 | "express": "^5.1.0" 20 | }, 21 | "devDependencies": { 22 | "@types/cors": "^2.8.17", 23 | "@types/express": "^5.0.1", 24 | "axios": "^1.8.4", 25 | "eventsource": "^3.0.6", 26 | "node-fetch": "^3.3.2" 27 | } 28 | } 29 | ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 15 | "lib": ["es6","dom"], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "libReplacement": true, /* Enable lib replacement. */ 18 | // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ 19 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 20 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 21 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 22 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 23 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 24 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 25 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 26 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 27 | 28 | /* Modules */ 29 | "module": "NodeNext", 30 | "rootDir": "src", 31 | "moduleResolution": "NodeNext", 32 | // "baseUrl": "./", 33 | // "paths": {}, 34 | // "rootDirs": [], 35 | // "typeRoots": [], 36 | // "types": [], 37 | // "allowUmdGlobalAccess": true, 38 | // "moduleSuffixes": [], 39 | // "allowImportingTsExtensions": true, 40 | // "rewriteRelativeImportExtensions": true, 41 | // "resolvePackageJsonExports": true, 42 | // "resolvePackageJsonImports": true, 43 | // "customConditions": [], 44 | // "noUncheckedSideEffectImports": true, 45 | "resolveJsonModule": true, 46 | // "allowArbitraryExtensions": true, 47 | // "noResolve": true, 48 | 49 | /* JavaScript Support */ 50 | "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 51 | // "checkJs": true, 52 | // "maxNodeModuleJsDepth": 1, 53 | 54 | /* Emit */ 55 | // "declaration": true, 56 | // "declarationMap": true, 57 | // "emitDeclarationOnly": true, 58 | // "sourceMap": true, 59 | // "inlineSourceMap": true, 60 | // "noEmit": true, 61 | // "outFile": "./", 62 | "outDir": "dist", 63 | // "removeComments": true, 64 | // "importHelpers": true, 65 | // "downlevelIteration": true, 66 | // "sourceRoot": "", 67 | // "mapRoot": "", 68 | // "inlineSources": true, 69 | // "emitBOM": true, 70 | // "newLine": "crlf", 71 | // "stripInternal": true, 72 | // "noEmitHelpers": true, 73 | // "noEmitOnError": true, 74 | // "preserveConstEnums": true, 75 | // "declarationDir": "./", 76 | 77 | /* Interop Constraints */ 78 | // "isolatedModules": true, 79 | // "verbatimModuleSyntax": true, 80 | // "isolatedDeclarations": true, 81 | // "erasableSyntaxOnly": true, 82 | // "allowSyntheticDefaultImports": true, 83 | "esModuleInterop": true, 84 | // "preserveSymlinks": true, 85 | "forceConsistentCasingInFileNames": true, 86 | 87 | /* Type Checking */ 88 | "strict": true, 89 | "noImplicitAny": true, 90 | // "strictNullChecks": true, 91 | // "strictFunctionTypes": true, 92 | // "strictBindCallApply": true, 93 | // "strictPropertyInitialization": true, 94 | // "strictBuiltinIteratorReturn": true, 95 | // "noImplicitThis": true, 96 | // "useUnknownInCatchVariables": true, 97 | // "alwaysStrict": true, 98 | // "noUnusedLocals": true, 99 | // "noUnusedParameters": true, 100 | // "exactOptionalPropertyTypes": true, 101 | // "noImplicitReturns": true, 102 | // "noFallthroughCasesInSwitch": true, 103 | // "noUncheckedIndexedAccess": true, 104 | // "noImplicitOverride": true, 105 | // "noPropertyAccessFromIndexSignature": true, 106 | // "allowUnusedLabels": true, 107 | // "allowUnreachableCode": true, 108 | 109 | /* Completeness */ 110 | // "skipDefaultLibCheck": true, 111 | "skipLibCheck": true 112 | }, 113 | "include": ["src/**/*"], 114 | "exclude": ["node_modules", "dist", "test.js", "test-request.json", "**/*.spec.ts"] 115 | } 116 | ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 | import { z } from "zod"; 3 | import axios, { type AxiosError } from "axios"; 4 | import dotenv from "dotenv"; 5 | import yargs from "yargs"; 6 | import { hideBin } from "yargs/helpers"; 7 | import express, { type Request, type Response } from "express"; 8 | import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; 9 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 10 | import type { IncomingMessage, ServerResponse } from "node:http"; 11 | import cors from "cors"; 12 | import { fileURLToPath } from "url"; 13 | 14 | // Load environment variables from .env file 15 | dotenv.config(); 16 | 17 | // --- Configuration --- 18 | 19 | const argv = yargs(hideBin(process.argv)) 20 | .option('kakao-api-key', { 21 | alias: 'k', 22 | type: 'string', 23 | description: 'Kakao REST API Key', 24 | }) 25 | .option('mode', { 26 | type: 'string', 27 | choices: ['stdio', 'http'], 28 | default: 'stdio', 29 | description: 'Transport mode: stdio or http (default: stdio)', 30 | }) 31 | .option('port', { 32 | type: 'number', 33 | default: 3000, 34 | description: 'Port for HTTP server (HTTP mode only)', 35 | }) 36 | .help() 37 | .alias('help', 'h') 38 | .parseSync(); 39 | 40 | // Custom logger to prevent interference with stdio transport 41 | const logger = { 42 | log: (message: string, ...args: unknown[]) => { 43 | // In stdio mode, write to stderr to avoid interfering with JSON messages 44 | if (argv.mode === 'stdio') { 45 | process.stderr.write(`LOG: ${message}\n`); 46 | } else { 47 | console.log(message, ...args); 48 | } 49 | }, 50 | error: (message: string, ...args: unknown[]) => { 51 | // Always write errors to stderr 52 | if (argv.mode === 'stdio') { 53 | process.stderr.write(`ERROR: ${message}\n`); 54 | } else { 55 | console.error(message, ...args); 56 | } 57 | } 58 | }; 59 | 60 | // Get Kakao API Key: prioritize command-line arg, then env var 61 | const KAKAO_API_KEY = argv.kakaoApiKey || process.env.KAKAO_REST_API_KEY; 62 | 63 | if (!KAKAO_API_KEY) { 64 | logger.error( 65 | "Error: Kakao REST API Key not found. " + 66 | "Provide it via --kakao-api-key argument or KAKAO_REST_API_KEY environment variable." 67 | ); 68 | process.exit(1); // Exit if no key is found 69 | } 70 | 71 | logger.log("Kakao REST API Key loaded successfully."); 72 | 73 | // --- Define Kakao API Response Types --- 74 | 75 | interface KakaoPlaceDocument { 76 | place_name: string; 77 | address_name: string; 78 | category_name: string; 79 | place_url: string; 80 | phone?: string; 81 | x?: string; 82 | y?: string; 83 | } 84 | 85 | interface KakaoKeywordSearchMeta { 86 | total_count: number; 87 | pageable_count: number; 88 | is_end: boolean; 89 | } 90 | 91 | interface KakaoKeywordSearchResponse { 92 | documents: KakaoPlaceDocument[]; 93 | meta: KakaoKeywordSearchMeta; 94 | } 95 | 96 | interface KakaoAddress { 97 | address_name: string; 98 | region_1depth_name: string; 99 | region_2depth_name: string; 100 | region_3depth_name: string; 101 | mountain_yn: string; 102 | main_address_no: string; 103 | sub_address_no?: string; 104 | zip_code?: string; 105 | } 106 | 107 | interface KakaoRoadAddress { 108 | address_name: string; 109 | region_1depth_name: string; 110 | region_2depth_name: string; 111 | region_3depth_name: string; 112 | road_name: string; 113 | underground_yn: string; 114 | main_building_no: string; 115 | sub_building_no?: string; 116 | building_name?: string; 117 | zone_no: string; 118 | } 119 | 120 | interface KakaoCoord2AddressDocument { 121 | road_address: KakaoRoadAddress | null; 122 | address: KakaoAddress | null; 123 | } 124 | 125 | interface KakaoCoord2AddressResponse { 126 | meta: { total_count: number }; 127 | documents: KakaoCoord2AddressDocument[]; 128 | } 129 | 130 | // Daum 검색 API 응답 타입 정의 131 | interface DaumSearchResponse { 132 | meta: { 133 | total_count: number; 134 | pageable_count: number; 135 | is_end: boolean; 136 | }; 137 | documents: Record<string, unknown>[]; 138 | } 139 | 140 | // <<< WaypointResult 인터페이스 정의 이동 >>> 141 | interface WaypointResult { 142 | success: boolean; 143 | name: string; 144 | placeName?: string; 145 | addressName?: string; 146 | x?: string; 147 | y?: string; 148 | } 149 | 150 | // --- MCP Server Setup --- 151 | 152 | const server = new McpServer( 153 | { 154 | name: "kakao-map", 155 | version: "0.1.0" 156 | }, 157 | { 158 | capabilities: { 159 | logging: {}, 160 | tools: {} 161 | } 162 | } 163 | ); 164 | 165 | // 위 코드 대신 MCP SDK의 문서나 타입을 확인하여 올바른 이벤트 처리 방식 적용 166 | // 임시로 주석 처리 167 | 168 | // 카카오맵 API용 axios 인스턴스 생성 169 | const kakaoApiClient = axios.create({ 170 | baseURL: 'https://dapi.kakao.com', 171 | headers: { 172 | Authorization: `KakaoAK ${KAKAO_API_KEY}`, 173 | } 174 | }); 175 | 176 | // 카카오모빌리티 API용 axios 인스턴스 생성 177 | const kakaoMobilityApiClient = axios.create({ 178 | baseURL: 'https://apis-navi.kakaomobility.com', 179 | headers: { 180 | Authorization: `KakaoAK ${KAKAO_API_KEY}`, 181 | } 182 | }); 183 | 184 | // axios 인스턴스에 인터셉터 추가 185 | kakaoApiClient.interceptors.request.use(request => { 186 | logger.log(`Kakao API Request: ${request.method?.toUpperCase()} ${request.url}`); 187 | return request; 188 | }); 189 | 190 | kakaoApiClient.interceptors.response.use(response => { 191 | logger.log(`Kakao API Response: ${response.status} ${response.statusText}`); 192 | return response; 193 | }, error => { 194 | logger.error(`Kakao API Error: ${error.message}`); 195 | return Promise.reject(error); 196 | }); 197 | 198 | kakaoMobilityApiClient.interceptors.request.use(request => { 199 | logger.log(`Kakao Mobility API Request: ${request.method?.toUpperCase()} ${request.url}`); 200 | logger.log(`Request Headers: ${JSON.stringify(request.headers)}`); 201 | logger.log(`Request Params: ${JSON.stringify(request.params)}`); 202 | return request; 203 | }); 204 | 205 | kakaoMobilityApiClient.interceptors.response.use(response => { 206 | logger.log(`Kakao Mobility API Response: ${response.status} ${response.statusText}`); 207 | return response; 208 | }, error => { 209 | logger.error(`Kakao Mobility API Error: ${error.message}`); 210 | if (axios.isAxiosError(error)) { 211 | logger.error(`Status: ${error.response?.status}`); 212 | logger.error(`Data: ${JSON.stringify(error.response?.data)}`); 213 | } 214 | return Promise.reject(error); 215 | }); 216 | 217 | // Tool: search-places 218 | const searchPlacesSchema = z.object({ 219 | keyword: z.string().describe('검색할 키워드 (예: "강남역 맛집")'), 220 | x: z.number().optional().describe("중심 좌표의 X 또는 longitude 값 (WGS84)"), 221 | y: z.number().optional().describe("중심 좌표의 Y 또는 latitude 값 (WGS84)"), 222 | radius: z.number().int().min(0).max(20000).optional().describe("중심 좌표부터의 검색 반경(0~20000m)"), 223 | }); 224 | server.tool( 225 | "search-places", 226 | "키워드를 사용하여 카카오맵에서 장소를 검색합니다.", 227 | searchPlacesSchema.shape, 228 | async (params: z.infer<typeof searchPlacesSchema>) => { 229 | logger.log("Executing search-places tool with params:", params); 230 | try { 231 | const response = await kakaoApiClient.get<KakaoKeywordSearchResponse>( 232 | "/v2/local/search/keyword.json", 233 | { 234 | params: { 235 | query: params.keyword, 236 | x: params.x, 237 | y: params.y, 238 | radius: params.radius, 239 | }, 240 | } 241 | ); 242 | 243 | logger.log(`API Response received: ${response.status}`); 244 | logger.log(`Results count: ${response.data.documents?.length || 0}`); 245 | 246 | const formattedResponse = formatPlacesResponse(response.data); 247 | 248 | logger.log(`Formatted response: ${formattedResponse.substring(0, 100)}...`); 249 | 250 | // 결과를 STDOUT에 명시적으로 출력 (디버깅용) 251 | if (argv.mode === 'stdio') { 252 | const result = { 253 | type: "tool_response", 254 | tool: "search-places", 255 | content: [{ type: "text", text: formattedResponse }] 256 | }; 257 | console.log(JSON.stringify(result)); 258 | } 259 | 260 | return { 261 | content: [{ type: "text", text: formattedResponse }], 262 | }; 263 | } catch (error: unknown) { 264 | let errorMessage = "알 수 없는 오류 발생"; 265 | if (axios.isAxiosError(error)) { 266 | const axiosError = error as AxiosError<{ message?: string }>; 267 | logger.error("API Error status:", axiosError.response?.status); 268 | logger.error("API Error details:", JSON.stringify(axiosError.response?.data)); 269 | errorMessage = axiosError.response?.data?.message || axiosError.message; 270 | } else if (error instanceof Error) { 271 | errorMessage = error.message; 272 | } 273 | 274 | // 오류 결과를 STDOUT에 명시적으로 출력 (디버깅용) 275 | if (argv.mode === 'stdio') { 276 | const errorResult = { 277 | type: "tool_response", 278 | tool: "search-places", 279 | content: [{ type: "text", text: `장소 검색 중 오류 발생: ${errorMessage}` }] 280 | }; 281 | console.log(JSON.stringify(errorResult)); 282 | } 283 | 284 | return { 285 | content: [{ type: "text", text: `장소 검색 중 오류 발생: ${errorMessage}` }], 286 | }; 287 | } 288 | } 289 | ); 290 | 291 | // Tool: coord-to-address 292 | const coordToAddressSchema = z.object({ 293 | x: z.number().describe("경도 (longitude) WGS84 좌표"), 294 | y: z.number().describe("위도 (latitude) WGS84 좌표"), 295 | }); 296 | server.tool( 297 | "coord-to-address", 298 | "좌표(경도, 위도)를 주소(도로명, 지번)로 변환합니다.", 299 | coordToAddressSchema.shape, 300 | async (params: z.infer<typeof coordToAddressSchema>) => { 301 | logger.log("Executing coord-to-address tool with params:", params); 302 | try { 303 | const response = await kakaoApiClient.get<KakaoCoord2AddressResponse>( 304 | "https://dapi.kakao.com/v2/local/geo/coord2address.json", 305 | { 306 | params: { 307 | x: params.x, 308 | y: params.y, 309 | }, 310 | } 311 | ); 312 | const formattedResponse = formatAddressResponse(response.data); 313 | return { 314 | content: [{ type: "text", text: formattedResponse }], 315 | }; 316 | } catch (error: unknown) { 317 | let errorMessage = "알 수 없는 오류 발생"; 318 | if (axios.isAxiosError(error)) { 319 | const axiosError = error as AxiosError<{ message?: string }>; 320 | logger.error("API Error status:", axiosError.response?.status); 321 | errorMessage = axiosError.response?.data?.message || axiosError.message; 322 | } else if (error instanceof Error) { 323 | errorMessage = error.message; 324 | } 325 | return { 326 | content: [{ type: "text", text: `좌표-주소 변환 중 오류 발생: ${errorMessage}` }], 327 | }; 328 | } 329 | } 330 | ); 331 | 332 | // Tool: find-route 333 | const findRouteSchema = z.object({ 334 | origin: z.string().describe('출발지 이름 (예: "강남역")'), 335 | destination: z.string().describe('목적지 이름 (예: "코엑스")'), 336 | waypoints: z.array(z.string()).optional().describe('경유지 이름 목록 (선택사항)'), 337 | transportation_type: z.enum(["car", "public", "walk"]).default("car").describe("이동 수단 (자동차, 대중교통, 도보)"), 338 | priority: z.enum(["RECOMMEND", "TIME", "DISTANCE"]).default("RECOMMEND").describe("경로 탐색 우선순위 (추천, 최단시간, 최단거리)"), 339 | traffic_info: z.boolean().default(true).describe("교통 정보 포함 여부") 340 | }); 341 | 342 | server.tool( 343 | "find-route", 344 | "출발지에서 목적지까지의 길찾기 정보를 제공합니다.", 345 | findRouteSchema.shape, 346 | async (params: z.infer<typeof findRouteSchema>) => { 347 | logger.log("Executing find-route tool with params:", params); 348 | try { 349 | // 1. 출발지 검색 350 | const originResponse = await kakaoApiClient.get<KakaoKeywordSearchResponse>( 351 | "https://dapi.kakao.com/v2/local/search/keyword.json", 352 | { params: { query: params.origin } } 353 | ); 354 | // <<< 출발지 응답 로깅 추가 >>> 355 | logger.log("Origin Search Response:", JSON.stringify(originResponse.data, null, 2)); 356 | 357 | if (!originResponse.data.documents || originResponse.data.documents.length === 0) { 358 | return { 359 | content: [{ type: "text", text: `출발지 "${params.origin}"를 찾을 수 없습니다.` }] 360 | }; 361 | } 362 | 363 | // 2. 목적지 검색 364 | const destinationResponse = await kakaoApiClient.get<KakaoKeywordSearchResponse>( 365 | "https://dapi.kakao.com/v2/local/search/keyword.json", 366 | { params: { query: params.destination } } 367 | ); 368 | // <<< 목적지 응답 로깅 추가 >>> 369 | logger.log("Destination Search Response:", JSON.stringify(destinationResponse.data, null, 2)); 370 | 371 | if (!destinationResponse.data.documents || destinationResponse.data.documents.length === 0) { 372 | return { 373 | content: [{ type: "text", text: `목적지 "${params.destination}"를 찾을 수 없습니다.` }] 374 | }; 375 | } 376 | 377 | // 3. 경유지 검색 (있는 경우) 378 | const waypointsPromises = params.waypoints?.map(waypoint => 379 | kakaoApiClient.get<KakaoKeywordSearchResponse>( 380 | "https://dapi.kakao.com/v2/local/search/keyword.json", 381 | { params: { query: waypoint } } 382 | ) 383 | ) || []; 384 | 385 | const waypointsResponses = await Promise.all(waypointsPromises); 386 | const waypointsResults: WaypointResult[] = waypointsResponses.map((response, index) => { 387 | if (!response.data.documents || response.data.documents.length === 0) { 388 | return { success: false, name: params.waypoints?.[index] || "알 수 없음" }; 389 | } 390 | const place = response.data.documents[0]; 391 | return { 392 | success: true, 393 | name: params.waypoints?.[index] || "알 수 없음", 394 | placeName: place.place_name, 395 | addressName: place.address_name, 396 | x: place.x, 397 | y: place.y 398 | }; 399 | }); 400 | 401 | // 실패한 경유지가 있는지 확인 402 | const failedWaypoints = waypointsResults.filter(wp => !wp.success); 403 | if (failedWaypoints.length > 0) { 404 | return { 405 | content: [{ 406 | type: "text", 407 | text: `다음 경유지를 찾을 수 없습니다: ${failedWaypoints.map(wp => wp.name).join(', ')}` 408 | }] 409 | }; 410 | } 411 | 412 | // 4. 결과 조합 413 | const origin = originResponse.data.documents[0]; 414 | const destination = destinationResponse.data.documents[0]; 415 | 416 | // 기본 웹 링크 생성 (카카오맵) 417 | let formattedResult = ""; 418 | let mapUrl = `https://map.kakao.com/?sName=${encodeURIComponent(origin.place_name)}&eName=${encodeURIComponent(destination.place_name)}`; 419 | 420 | // 경유지가 있는 경우 421 | if (waypointsResults.length > 0) { 422 | const successWaypoints = waypointsResults.filter(wp => wp.success && wp.placeName); 423 | if (successWaypoints.length > 0) { 424 | const waypointsParam = successWaypoints 425 | .map(wp => wp.placeName ? encodeURIComponent(wp.placeName) : '') 426 | .filter(Boolean) 427 | .join(','); 428 | 429 | if (waypointsParam) { 430 | mapUrl += `&waypoints=${waypointsParam}`; 431 | } 432 | } 433 | } 434 | 435 | // 이동 수단에 따라 처리 분기 436 | if (params.transportation_type === "car") { 437 | // 자동차 경로는 카카오모빌리티 API 사용 438 | 439 | // 카카오모빌리티 API용 axios 인스턴스 생성 440 | const mobilityApiClient = axios.create({ 441 | headers: { 442 | Authorization: `KakaoAK ${KAKAO_API_KEY}`, 443 | 'Content-Type': 'application/json' 444 | } 445 | }); 446 | 447 | // 카카오모빌리티 API 파라미터 구성 448 | const originCoord = `${origin.x},${origin.y}`; 449 | const destCoord = `${destination.x},${destination.y}`; 450 | 451 | // 경유지 구성 452 | let waypointsParam = ""; 453 | if (waypointsResults.length > 0) { 454 | const successWaypoints = waypointsResults.filter(wp => wp.success && wp.x && wp.y); 455 | if (successWaypoints.length > 0) { 456 | waypointsParam = successWaypoints 457 | .map(wp => `${wp.x},${wp.y}`) 458 | .join('|'); 459 | } 460 | } 461 | 462 | // 카카오모빌리티 API 호출 463 | try { 464 | // <<< 좌표 검색 결과 및 transportation_type 로깅 추가 >>> 465 | const originSuccess = originResponse.data.documents && originResponse.data.documents.length > 0; 466 | const destinationSuccess = destinationResponse.data.documents && destinationResponse.data.documents.length > 0; 467 | logger.log(`Checking conditions for Mobility API call:`); 468 | logger.log(` transportation_type: ${params.transportation_type}`); 469 | logger.log(` origin success: ${originSuccess}`); 470 | logger.log(` destination success: ${destinationSuccess}`); 471 | // (필요 시 waypoints 결과 로깅 추가) 472 | // logger.log(\` waypoints results: \${JSON.stringify(waypointsResults)}\`); 473 | 474 | if (params.transportation_type === "car" && originSuccess && destinationSuccess) { 475 | // 자동차 경로이고, 출발지/목적지 좌표 검색 성공 시에만 모빌리티 API 호출 476 | 477 | // URL 파라미터 구성 478 | const apiParams: Record<string, string> = { 479 | origin: `${origin.x},${origin.y}`, 480 | destination: `${destination.x},${destination.y}`, 481 | priority: params.priority.toLowerCase(), 482 | car_fuel: "GASOLINE", 483 | alternatives: "false", 484 | road_details: params.traffic_info ? "true" : "false", 485 | summary: "true" 486 | }; 487 | 488 | // 경유지가 있는 경우 추가 489 | if (waypointsParam) { 490 | apiParams.waypoints = waypointsParam; 491 | } 492 | 493 | // 카카오모빌리티 API 호출 (GET 방식으로 변경) 494 | const mobilityResponse = await kakaoMobilityApiClient.get('/v1/directions', { 495 | params: apiParams 496 | }); 497 | 498 | // <<< API 응답 로깅 추가 >>> 499 | logger.log("Kakao Mobility API Response:", JSON.stringify(mobilityResponse.data, null, 2)); 500 | 501 | if (mobilityResponse.data && mobilityResponse.data.routes && mobilityResponse.data.routes.length > 0) { 502 | const route = mobilityResponse.data.routes[0]; 503 | 504 | if (route.result_code === 0) { // 성공 505 | formattedResult = formatMobilityRouteResult(route, origin, destination, waypointsResults, params); 506 | } else { 507 | // 길찾기 실패 시 기본 맵 URL로 대체 508 | formattedResult = formatBasicRouteResult(origin, destination, waypointsResults, params, mapUrl); 509 | } 510 | } else { 511 | // 응답 데이터 없음 - 기본 맵 URL로 대체 512 | formattedResult = formatBasicRouteResult(origin, destination, waypointsResults, params, mapUrl); 513 | } 514 | } else { 515 | // 응답 데이터 없음 - 기본 맵 URL로 대체 516 | formattedResult = formatBasicRouteResult(origin, destination, waypointsResults, params, mapUrl); 517 | } 518 | } catch (error) { 519 | // 더 자세한 오류 로깅 520 | logger.error("Mobility API error:", error); 521 | if (axios.isAxiosError(error)) { 522 | logger.error("API Error details:", error.response?.data); 523 | logger.error("API Error status:", error.response?.status); 524 | } 525 | // API 호출 실패 시 기본 맵 URL로 대체 526 | formattedResult = formatBasicRouteResult(origin, destination, waypointsResults, params, mapUrl); 527 | } 528 | } else { 529 | // 대중교통이나 도보는 기존 방식 사용 530 | const transportMode = params.transportation_type === "public" ? "transit" : "walk"; 531 | mapUrl += `&carMode=${transportMode}`; 532 | formattedResult = formatBasicRouteResult(origin, destination, waypointsResults, params, mapUrl); 533 | } 534 | 535 | return { 536 | content: [{ type: "text", text: formattedResult }] 537 | }; 538 | } catch (error: unknown) { 539 | let errorMessage = "알 수 없는 오류 발생"; 540 | if (axios.isAxiosError(error)) { 541 | const axiosError = error as AxiosError<{ message?: string }>; 542 | logger.error("API Error status:", axiosError.response?.status); 543 | errorMessage = axiosError.response?.data?.message || axiosError.message; 544 | } else if (error instanceof Error) { 545 | errorMessage = error.message; 546 | } 547 | return { 548 | content: [{ type: "text", text: `길찾기 중 오류 발생: ${errorMessage}` }] 549 | }; 550 | } 551 | } 552 | ); 553 | 554 | // Daum 웹 검색 도구 555 | const webSearchSchema = z.object({ 556 | query: z.string().describe('검색할 질의어'), 557 | sort: z.enum(['accuracy', 'recency']).optional().describe('결과 정렬 방식 (accuracy: 정확도순, recency: 최신순)'), 558 | page: z.number().int().min(1).max(50).optional().describe('결과 페이지 번호 (1~50, 기본값 1)'), 559 | size: z.number().int().min(1).max(50).optional().describe('한 페이지에 보여질 문서 수 (1~50, 기본값 10)'), 560 | }); 561 | 562 | server.tool( 563 | "search-web", 564 | "다음(Daum) 검색에서 웹 문서를 검색합니다.", 565 | webSearchSchema.shape, 566 | async (params: z.infer<typeof webSearchSchema>) => { 567 | logger.log("Executing search-web tool with params:", params); 568 | try { 569 | const response = await kakaoApiClient.get<DaumSearchResponse>( 570 | "/v2/search/web", 571 | { 572 | params: { 573 | query: params.query, 574 | sort: params.sort, 575 | page: params.page, 576 | size: params.size, 577 | }, 578 | } 579 | ); 580 | 581 | logger.log(`API Response received: ${response.status}`); 582 | logger.log(`Results count: ${response.data.meta.total_count}`); 583 | 584 | const formattedResponse = formatDaumSearchResponse("웹 문서", response.data); 585 | 586 | return { 587 | content: [{ type: "text", text: formattedResponse }], 588 | }; 589 | } catch (error: unknown) { 590 | let errorMessage = "알 수 없는 오류 발생"; 591 | if (axios.isAxiosError(error)) { 592 | const axiosError = error as AxiosError<{ message?: string }>; 593 | logger.error("API Error status:", axiosError.response?.status); 594 | logger.error("API Error details:", JSON.stringify(axiosError.response?.data)); 595 | errorMessage = axiosError.response?.data?.message || axiosError.message; 596 | } else if (error instanceof Error) { 597 | errorMessage = error.message; 598 | } 599 | 600 | return { 601 | content: [{ type: "text", text: `웹 검색 중 오류 발생: ${errorMessage}` }], 602 | }; 603 | } 604 | } 605 | ); 606 | 607 | // Daum 이미지 검색 도구 608 | const imageSearchSchema = z.object({ 609 | query: z.string().describe('검색할 질의어'), 610 | sort: z.enum(['accuracy', 'recency']).optional().describe('결과 정렬 방식 (accuracy: 정확도순, recency: 최신순)'), 611 | page: z.number().int().min(1).max(50).optional().describe('결과 페이지 번호 (1~50, 기본값 1)'), 612 | size: z.number().int().min(1).max(80).optional().describe('한 페이지에 보여질 문서 수 (1~80, 기본값 10)'), 613 | }); 614 | 615 | server.tool( 616 | "search-image", 617 | "다음(Daum) 검색에서 이미지를 검색합니다.", 618 | imageSearchSchema.shape, 619 | async (params: z.infer<typeof imageSearchSchema>) => { 620 | logger.log("Executing search-image tool with params:", params); 621 | try { 622 | const response = await kakaoApiClient.get<DaumSearchResponse>( 623 | "/v2/search/image", 624 | { 625 | params: { 626 | query: params.query, 627 | sort: params.sort, 628 | page: params.page, 629 | size: params.size, 630 | }, 631 | } 632 | ); 633 | 634 | logger.log(`API Response received: ${response.status}`); 635 | logger.log(`Results count: ${response.data.meta.total_count}`); 636 | 637 | const formattedResponse = formatDaumSearchResponse("이미지", response.data); 638 | 639 | return { 640 | content: [{ type: "text", text: formattedResponse }], 641 | }; 642 | } catch (error: unknown) { 643 | let errorMessage = "알 수 없는 오류 발생"; 644 | if (axios.isAxiosError(error)) { 645 | const axiosError = error as AxiosError<{ message?: string }>; 646 | logger.error("API Error status:", axiosError.response?.status); 647 | logger.error("API Error details:", JSON.stringify(axiosError.response?.data)); 648 | errorMessage = axiosError.response?.data?.message || axiosError.message; 649 | } else if (error instanceof Error) { 650 | errorMessage = error.message; 651 | } 652 | 653 | return { 654 | content: [{ type: "text", text: `이미지 검색 중 오류 발생: ${errorMessage}` }], 655 | }; 656 | } 657 | } 658 | ); 659 | 660 | // Daum 블로그 검색 도구 661 | const blogSearchSchema = z.object({ 662 | query: z.string().describe('검색할 질의어'), 663 | sort: z.enum(['accuracy', 'recency']).optional().describe('결과 정렬 방식 (accuracy: 정확도순, recency: 최신순)'), 664 | page: z.number().int().min(1).max(50).optional().describe('결과 페이지 번호 (1~50, 기본값 1)'), 665 | size: z.number().int().min(1).max(50).optional().describe('한 페이지에 보여질 문서 수 (1~50, 기본값 10)'), 666 | }); 667 | 668 | server.tool( 669 | "search-blog", 670 | "다음(Daum) 검색에서 블로그 글을 검색합니다.", 671 | blogSearchSchema.shape, 672 | async (params: z.infer<typeof blogSearchSchema>) => { 673 | logger.log("Executing search-blog tool with params:", params); 674 | try { 675 | const response = await kakaoApiClient.get<DaumSearchResponse>( 676 | "/v2/search/blog", 677 | { 678 | params: { 679 | query: params.query, 680 | sort: params.sort, 681 | page: params.page, 682 | size: params.size, 683 | }, 684 | } 685 | ); 686 | 687 | logger.log(`API Response received: ${response.status}`); 688 | logger.log(`Results count: ${response.data.meta.total_count}`); 689 | 690 | const formattedResponse = formatDaumSearchResponse("블로그", response.data); 691 | 692 | return { 693 | content: [{ type: "text", text: formattedResponse }], 694 | }; 695 | } catch (error: unknown) { 696 | let errorMessage = "알 수 없는 오류 발생"; 697 | if (axios.isAxiosError(error)) { 698 | const axiosError = error as AxiosError<{ message?: string }>; 699 | logger.error("API Error status:", axiosError.response?.status); 700 | logger.error("API Error details:", JSON.stringify(axiosError.response?.data)); 701 | errorMessage = axiosError.response?.data?.message || axiosError.message; 702 | } else if (error instanceof Error) { 703 | errorMessage = error.message; 704 | } 705 | 706 | return { 707 | content: [{ type: "text", text: `블로그 검색 중 오류 발생: ${errorMessage}` }], 708 | }; 709 | } 710 | } 711 | ); 712 | 713 | // Daum 카페 검색 도구 714 | const cafeSearchSchema = z.object({ 715 | query: z.string().describe('검색할 질의어'), 716 | sort: z.enum(['accuracy', 'recency']).optional().describe('결과 정렬 방식 (accuracy: 정확도순, recency: 최신순)'), 717 | page: z.number().int().min(1).max(50).optional().describe('결과 페이지 번호 (1~50, 기본값 1)'), 718 | size: z.number().int().min(1).max(50).optional().describe('한 페이지에 보여질 문서 수 (1~50, 기본값 10)'), 719 | }); 720 | 721 | server.tool( 722 | "search-cafe", 723 | "다음(Daum) 검색에서 카페 글을 검색합니다.", 724 | cafeSearchSchema.shape, 725 | async (params: z.infer<typeof cafeSearchSchema>) => { 726 | logger.log("Executing search-cafe tool with params:", params); 727 | try { 728 | const response = await kakaoApiClient.get<DaumSearchResponse>( 729 | "/v2/search/cafe", 730 | { 731 | params: { 732 | query: params.query, 733 | sort: params.sort, 734 | page: params.page, 735 | size: params.size, 736 | }, 737 | } 738 | ); 739 | 740 | logger.log(`API Response received: ${response.status}`); 741 | logger.log(`Results count: ${response.data.meta.total_count}`); 742 | 743 | const formattedResponse = formatDaumSearchResponse("카페", response.data); 744 | 745 | return { 746 | content: [{ type: "text", text: formattedResponse }], 747 | }; 748 | } catch (error: unknown) { 749 | let errorMessage = "알 수 없는 오류 발생"; 750 | if (axios.isAxiosError(error)) { 751 | const axiosError = error as AxiosError<{ message?: string }>; 752 | logger.error("API Error status:", axiosError.response?.status); 753 | logger.error("API Error details:", JSON.stringify(axiosError.response?.data)); 754 | errorMessage = axiosError.response?.data?.message || axiosError.message; 755 | } else if (error instanceof Error) { 756 | errorMessage = error.message; 757 | } 758 | 759 | return { 760 | content: [{ type: "text", text: `카페 검색 중 오류 발생: ${errorMessage}` }], 761 | }; 762 | } 763 | } 764 | ); 765 | 766 | // --- Helper Functions --- 767 | 768 | function formatPlacesResponse(data: KakaoKeywordSearchResponse): string { 769 | if (!data || !data.documents || data.documents.length === 0) { 770 | return "검색 결과가 없습니다."; 771 | } 772 | const places = data.documents.map((place: KakaoPlaceDocument) => { 773 | let result = `이름: ${place.place_name}\n주소: ${place.address_name}\n카테고리: ${place.category_name}`; 774 | 775 | // 전화번호가 있으면 추가 776 | if (place.phone) { 777 | result += `\n전화번호: ${place.phone}`; 778 | } 779 | 780 | // 카카오맵 링크 추가 781 | result += `\n상세정보: ${place.place_url}`; 782 | 783 | return result; 784 | }).join("\n---\n"); 785 | 786 | const pageInfo = data.meta ? ` (결과 수: ${data.meta.pageable_count}, 총 ${data.meta.total_count}개)` : ""; 787 | return `장소 검색 결과${pageInfo}:\n${places}`; 788 | } 789 | 790 | function formatAddressResponse(data: KakaoCoord2AddressResponse): string { 791 | if (!data || !data.documents || data.documents.length === 0) { 792 | return "해당 좌표에 대한 주소 정보를 찾을 수 없습니다."; 793 | } 794 | const doc = data.documents[0]; 795 | const roadAddress = doc.road_address ? `도로명: ${doc.road_address.address_name}` : "도로명 주소 정보 없음"; 796 | const lotAddress = doc.address ? `지번: ${doc.address.address_name}` : "지번 주소 정보 없음"; 797 | return `주소 변환 결과:\n${roadAddress}\n${lotAddress}`; 798 | } 799 | 800 | // 카카오모빌리티 API 응답을 포맷팅하는 함수 801 | function formatMobilityRouteResult( 802 | route: Record<string, any>, 803 | origin: KakaoPlaceDocument, 804 | destination: KakaoPlaceDocument, 805 | waypoints: WaypointResult[], 806 | params: z.infer<typeof findRouteSchema> 807 | ): string { 808 | let result = '🗺️ 길찾기 결과\\n\\n'; 809 | result += `출발지: ${origin.place_name} (${origin.address_name})\\n`; 810 | 811 | // 경유지가 있는 경우 812 | if (waypoints.length > 0) { 813 | const successWaypoints = waypoints.filter(wp => wp.success); 814 | if (successWaypoints.length > 0) { 815 | result += '\\n경유지:\\n'; 816 | for (const [index, wp] of successWaypoints.entries()) { // forEach 대신 for...of 사용 817 | result += `${index + 1}. ${wp.placeName} (${wp.addressName})\\n`; 818 | } 819 | } 820 | } 821 | 822 | result += `\\n목적지: ${destination.place_name} (${destination.address_name})\\n`; 823 | result += `\\n이동 수단: ${getTransportationName(params.transportation_type)}\\n`; 824 | 825 | // 카카오모빌리티 API 결과 표시 826 | const summary = route.summary; 827 | if (summary && typeof summary === 'object') { // summary 타입 확인 추가 828 | if (typeof summary.distance === 'number') { // distance 타입 확인 추가 829 | result += `\\n총 거리: ${formatDistance(summary.distance)}\\n`; 830 | } 831 | if (typeof summary.duration === 'number') { // duration 타입 확인 추가 832 | result += `예상 소요 시간: ${formatDuration(summary.duration)}\\n`; 833 | } 834 | 835 | // 택시 요금 표시 836 | if (summary.fare && typeof summary.fare === 'object' && typeof summary.fare.taxi === 'number') { // 타입 확인 추가 837 | result += `예상 택시 요금: ${summary.fare.taxi.toLocaleString()}원\\n`; 838 | } 839 | 840 | // 통행 요금 표시 841 | if (summary.fare && typeof summary.fare === 'object' && typeof summary.fare.toll === 'number' && summary.fare.toll > 0) { // 타입 확인 추가 842 | result += `통행 요금: ${summary.fare.toll.toLocaleString()}원\\n`; 843 | } 844 | } 845 | 846 | // 교통 정보 표시 847 | if (params.traffic_info && Array.isArray(route.sections)) { // sections 타입 확인 추가 848 | result += '\\n📊 교통 상황 요약:\\n'; 849 | 850 | let totalDistance = 0; 851 | let totalCongestionDistance = 0; 852 | let totalHeavyDistance = 0; 853 | let totalSlowDistance = 0; 854 | 855 | // 타입 단언 대신 타입 가드 사용 (더 안전한 방식은 API 응답 타입 정의) 856 | for (const section of route.sections) { 857 | if (section && typeof section === 'object' && Array.isArray(section.roads)) { 858 | for (const road of section.roads) { 859 | if (road && typeof road === 'object' && typeof road.distance === 'number' && typeof road.traffic_state === 'number') { 860 | totalDistance += road.distance; 861 | if (road.traffic_state === 4) { 862 | totalCongestionDistance += road.distance; 863 | } else if (road.traffic_state === 3) { 864 | totalHeavyDistance += road.distance; 865 | } else if (road.traffic_state === 2) { 866 | totalSlowDistance += road.distance; 867 | } 868 | } 869 | } 870 | } 871 | } 872 | 873 | // 전체 거리 중 교통 상태별 비율 계산 874 | if (totalDistance > 0) { 875 | const congestionPercent = Math.round((totalCongestionDistance / totalDistance) * 100); 876 | const heavyPercent = Math.round((totalHeavyDistance / totalDistance) * 100); 877 | const slowPercent = Math.round((totalSlowDistance / totalDistance) * 100); 878 | const smoothPercent = 100 - congestionPercent - heavyPercent - slowPercent; 879 | 880 | result += `🟢 원활: ${smoothPercent}%\\n`; 881 | result += `🟡 서행: ${slowPercent}%\\n`; 882 | result += `🟠 지체: ${heavyPercent}%\\n`; 883 | result += `🔴 정체: ${congestionPercent}%\\n`; 884 | } 885 | 886 | // 주요 정체 구간 표시 (최대 3개) 887 | if (Array.isArray(route.sections) && params.traffic_info) { // sections 타입 확인 추가 888 | const congestionRoads: { name: string; distance: number; traffic_state: number }[] = []; 889 | 890 | for (const section of route.sections) { 891 | if (section && typeof section === 'object' && Array.isArray(section.roads)) { 892 | for (const road of section.roads) { 893 | 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') { 894 | congestionRoads.push({ 895 | name: road.name, 896 | distance: road.distance, 897 | traffic_state: road.traffic_state 898 | }); 899 | } 900 | } 901 | } 902 | } 903 | 904 | congestionRoads.sort((a, b) => b.distance - a.distance); 905 | 906 | if (congestionRoads.length > 0) { 907 | result += '\\n주요 정체 구간:\\n'; 908 | for(const road of congestionRoads.slice(0, 3)) { 909 | const trafficEmoji = road.traffic_state === 4 ? '🔴' : '🟠'; 910 | result += `${trafficEmoji} ${road.name} (${formatDistance(road.distance)})\\n`; 911 | } 912 | } 913 | } 914 | } 915 | 916 | // 카카오맵 링크 917 | const mapUrl = `https://map.kakao.com/?sName=${encodeURIComponent(origin.place_name)}&eName=${encodeURIComponent(destination.place_name)}`; 918 | result += `\\n카카오맵에서 보기: ${mapUrl}\\n`; 919 | 920 | return result; 921 | } 922 | 923 | // 기본 경로 결과 포맷팅 함수 924 | function formatBasicRouteResult( 925 | origin: KakaoPlaceDocument, 926 | destination: KakaoPlaceDocument, 927 | waypoints: WaypointResult[], 928 | params: z.infer<typeof findRouteSchema>, 929 | mapUrl: string 930 | ): string { 931 | let result = '🗺️ 길찾기 결과\\n\\n'; 932 | result += `출발지: ${origin.place_name} (${origin.address_name})\\n`; 933 | 934 | if (waypoints.length > 0) { 935 | const successWaypoints = waypoints.filter(wp => wp.success && wp.placeName && wp.addressName); 936 | if (successWaypoints.length > 0) { 937 | result += '\\n경유지:\\n'; 938 | successWaypoints.forEach((wp, index) => { 939 | result += `${index + 1}. ${wp.placeName} (${wp.addressName})\\n`; 940 | }); 941 | } 942 | } 943 | 944 | result += `\\n목적지: ${destination.place_name} (${destination.address_name})\\n`; 945 | result += `\\n이동 수단: ${getTransportationName(params.transportation_type)}\\n`; 946 | result += `\\n카카오맵 길찾기: ${mapUrl}\\n`; 947 | result += '\\n상세 경로 및 소요 시간은 카카오맵 링크를 통해 확인하세요.'; 948 | 949 | return result; 950 | } 951 | 952 | // 거리를 포맷팅하는 함수 953 | function formatDistance(meters: number): string { 954 | if (meters < 1000) { 955 | return `${meters}m`; 956 | } 957 | return `${(meters / 1000).toFixed(1)}km`; 958 | } 959 | 960 | // 시간을 포맷팅하는 함수 961 | function formatDuration(seconds: number): string { 962 | const hours = Math.floor(seconds / 3600); 963 | const minutes = Math.floor((seconds % 3600) / 60); 964 | 965 | if (hours > 0) { 966 | return `${hours}시간 ${minutes}분`; 967 | } 968 | return `${minutes}분`; 969 | } 970 | 971 | // 이동 수단 한글 이름 반환 함수 972 | function getTransportationName(type: string): string { 973 | switch (type) { 974 | case "car": 975 | return "자동차"; 976 | case "public": 977 | return "대중교통"; 978 | case "walk": 979 | return "도보"; 980 | default: 981 | return "자동차"; 982 | } 983 | } 984 | 985 | // 다음 검색 결과 포맷팅 함수 986 | function formatDaumSearchResponse(searchType: string, data: DaumSearchResponse): string { 987 | if (!data || !data.documents || data.documents.length === 0) { 988 | return '검색 결과가 없습니다.'; 989 | } 990 | 991 | let result = `${searchType} 검색 결과 (총 ${data.meta.total_count}개 중 ${data.documents.length}개 표시):\n\n`; 992 | 993 | for (const [index, doc] of data.documents.entries()) { 994 | result += `${index + 1}. `; 995 | 996 | // 제목 처리 997 | const title = doc.title; 998 | const sitename = doc.display_sitename; 999 | if (typeof title === 'string' && title) { 1000 | result += `${title.replace(/<b>/g, '').replace(/<\/b>/g, '')}\n`; 1001 | } else if (typeof sitename === 'string' && sitename) { 1002 | result += `${sitename}\n`; 1003 | } else { 1004 | result += '[제목 없음]\n'; 1005 | } 1006 | 1007 | // 내용 처리 1008 | const contents = doc.contents; 1009 | if (typeof contents === 'string' && contents) { 1010 | const cleanContent = contents.replace(/<b>/g, '').replace(/<\/b>/g, ''); 1011 | result += ` 내용: ${cleanContent.substring(0, 100)}${cleanContent.length > 100 ? '...' : ''}\n`; 1012 | } 1013 | 1014 | // URL 처리 1015 | if (typeof doc.url === 'string' && doc.url) { 1016 | result += ` URL: ${doc.url}\n`; 1017 | } 1018 | 1019 | // 날짜 처리 1020 | const datetimeValue = doc.datetime; 1021 | if (typeof datetimeValue === 'string' || typeof datetimeValue === 'number') { 1022 | try { 1023 | const datetime = new Date(datetimeValue); 1024 | if (!Number.isNaN(datetime.getTime())) { // isNaN 대신 Number.isNaN 사용 및 유효성 검사 강화 1025 | result += ` 날짜: ${datetime.toLocaleDateString('ko-KR')}\n`; 1026 | } 1027 | } catch (e) { 1028 | logger.error(`Invalid date format for datetime: ${datetimeValue}`); 1029 | } 1030 | } 1031 | 1032 | // 이미지 URL 처리 1033 | const thumbnailUrl = doc.thumbnail_url; 1034 | const imageUrl = doc.image_url; 1035 | if (typeof thumbnailUrl === 'string' || typeof imageUrl === 'string') { 1036 | result += ` 이미지: ${thumbnailUrl || imageUrl}\n`; 1037 | } 1038 | 1039 | // 카페명 처리 1040 | if (typeof doc.cafename === 'string' && doc.cafename) { 1041 | result += ` 카페: ${doc.cafename}\n`; 1042 | } 1043 | 1044 | // 블로그명 처리 1045 | if (typeof doc.blogname === 'string' && doc.blogname) { 1046 | result += ` 블로그: ${doc.blogname}\n`; 1047 | } 1048 | 1049 | // 출처 처리 1050 | if (typeof doc.collection === 'string' && doc.collection) { 1051 | result += ` 출처: ${doc.collection}\n`; 1052 | } 1053 | 1054 | // 크기 처리 1055 | if (typeof doc.width === 'number' && typeof doc.height === 'number') { 1056 | result += ` 크기: ${doc.width}x${doc.height}\n`; 1057 | } 1058 | 1059 | result += '\n'; // 각 문서 사이에 줄바꿈 추가 1060 | } 1061 | 1062 | // 페이지 정보 추가 1063 | result += `현재 페이지가 마지막 페이지${data.meta.is_end ? '입니다.' : '가 아닙니다. 더 많은 결과를 보려면 page 매개변수를 증가시키세요.'}\n`; 1064 | 1065 | return result; // 함수 끝에 return 명시 1066 | } 1067 | 1068 | // --- Run the Server (based on mode) --- 1069 | 1070 | // Export the server instance if needed for testing or other modules 1071 | // export { server }; // <-- 파일 최상단으로 이동하거나 제거 (Node.js ESM 권장사항 따름) 1072 | 1073 | // ESM에서는 require.main === module 대신 다른 방식으로 직접 실행 감지 1074 | // https://nodejs.org/api/esm.html#esm_no_require_exports_module_exports_filename_dirname 1075 | // Node.js v20부터는 import.meta.url을 사용하여 현재 파일이 직접 실행되는지 확인할 수 있음 1076 | // const isMainModule = import.meta.url === \`file://\${process.argv[1]}\`; // 수정 전 1077 | let isMainModule = false; 1078 | try { 1079 | // fileURLToPath를 사용하여 올바르게 경로 비교 1080 | const currentFilePath = fileURLToPath(import.meta.url); 1081 | isMainModule = currentFilePath === process.argv[1]; 1082 | } catch (e) { 1083 | // import.meta.url이 지원되지 않는 환경 고려 (예: CommonJS) 1084 | // 또는 다른 방식으로 메인 모듈 여부 확인 필요 시 추가 1085 | } 1086 | 1087 | // import/export는 최상위 레벨에서만 사용 가능하므로, 서버 시작 로직을 함수로 감싸기 1088 | async function startServer() { 1089 | const mode = argv.mode as 'stdio' | 'http'; 1090 | const port = argv.port as number; 1091 | 1092 | if (mode === 'stdio') { 1093 | // STDIO Mode - Direct connection via stdio 1094 | logger.log("Starting Kakao Map MCP Server in stdio mode..."); 1095 | 1096 | const stdioTransport = new StdioServerTransport(); 1097 | 1098 | // 디버깅을 위한 stdio 입력 로깅 1099 | process.stdin.on('data', (data) => { 1100 | const input = data.toString().trim(); 1101 | logger.log(`STDIN received: ${input}`); // logger.log로 복원 1102 | // logger.error(\`STDIN received (error level): \${input}\`); // logger.error 주석 처리 1103 | 1104 | try { 1105 | // 입력 데이터 파싱 1106 | const parsedData = JSON.parse(input); 1107 | logger.log(`Parsed message type: ${parsedData.type}, tool: ${parsedData.tool}`); // logger.log로 복원 1108 | // logger.error(\`Parsed message (error level) - Type: \${parsedData.type}, Tool: \${parsedData.tool}\`); // logger.error 주석 처리 1109 | } catch (err: unknown) { 1110 | if (err instanceof Error) { 1111 | logger.error(`Failed to parse input: ${err.message}`); 1112 | } else { 1113 | logger.error('Failed to parse input: Unknown error'); // 수정 후 1114 | } 1115 | } 1116 | }); 1117 | 1118 | server.connect(stdioTransport).then(() => { 1119 | logger.log("Kakao Map MCP Server connected via stdio."); 1120 | }).catch(error => { 1121 | logger.error(`Failed to connect server via stdio: ${error}`); 1122 | process.exit(1); 1123 | }); 1124 | } else { 1125 | // HTTP/SSE Mode - Express server with SSE 1126 | const app = express(); 1127 | let sseTransport: SSEServerTransport | null = null; 1128 | 1129 | // CORS Configuration 1130 | const corsOptions = { 1131 | origin: 'http://localhost:5173', // For MCP Inspector 1132 | methods: "GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS", 1133 | allowedHeaders: ["Content-Type", "Authorization"], 1134 | credentials: true, 1135 | optionsSuccessStatus: 204 1136 | }; 1137 | app.use(cors(corsOptions)); 1138 | 1139 | // Routes 1140 | app.get("/sse", async (req: Request, res: Response) => { 1141 | logger.log("New SSE connection request received."); 1142 | 1143 | if (sseTransport) { 1144 | logger.log("An existing SSE transport is active. Replacing with the new connection."); 1145 | } 1146 | 1147 | // Create a new transport for this request 1148 | const currentTransport = new SSEServerTransport("/messages", res as unknown as ServerResponse<IncomingMessage>); 1149 | sseTransport = currentTransport; 1150 | 1151 | try { 1152 | await server.connect(sseTransport); 1153 | logger.log("MCP Server connected to SSE transport."); 1154 | 1155 | req.on("close", () => { 1156 | logger.log("SSE connection closed by client."); 1157 | if (sseTransport === currentTransport) { 1158 | sseTransport = null; 1159 | } 1160 | }); 1161 | } catch (error) { 1162 | logger.error(`Error connecting MCP server to SSE transport: ${error}`); 1163 | if (sseTransport === currentTransport) { 1164 | sseTransport = null; 1165 | } 1166 | if (!res.writableEnded) { 1167 | res.status(500).end(); 1168 | } 1169 | } 1170 | }); 1171 | 1172 | app.post("/messages", (req: Request, res: Response): void => { 1173 | logger.log("Received POST /messages request."); 1174 | if (!sseTransport) { 1175 | logger.error("Received POST message but no active SSE transport."); 1176 | res.status(400).send("No active SSE connection"); 1177 | return; 1178 | } 1179 | 1180 | sseTransport.handlePostMessage(req as unknown as IncomingMessage, res as unknown as ServerResponse<IncomingMessage>) 1181 | .then(() => { 1182 | logger.log("POST message handled by SSE transport."); 1183 | }) 1184 | .catch((error) => { 1185 | logger.error(`Error handling POST message: ${error}`); 1186 | if (!res.headersSent) { 1187 | res.status(500).send("Error processing message"); 1188 | } 1189 | }); 1190 | }); 1191 | 1192 | // Start Server 1193 | app.listen(port, () => { 1194 | logger.log(`Kakao Map MCP Server (HTTP/SSE) listening on port ${port}`); 1195 | logger.log(`SSE endpoint available at http://localhost:${port}/sse`); 1196 | logger.log(`Message endpoint available at http://localhost:${port}/messages`); 1197 | }); 1198 | } 1199 | } // startServer 함수 닫는 중괄호 1200 | 1201 | // 메인 모듈로 실행될 때만 서버 시작 1202 | if (isMainModule) { 1203 | startServer().catch(error => { 1204 | logger.error("Failed to start server:", error); 1205 | process.exit(1); 1206 | }); 1207 | } // if (isMainModule) 닫는 중괄호 1208 | 1209 | // 서버 인스턴스를 export해야 한다면 파일 최상단에서 export 1210 | export { server }; 1211 | ```