#
tokens: 17430/50000 3/6 files (page 3/3)
lines: on (toggle) GitHub
raw markdown copy reset
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 | 
```
Page 3/3FirstPrevNextLast