daiso 1.0.5 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +101 -7
  2. package/dist/api/actionsProxy.js +111 -21
  3. package/dist/api/configStatus.js +23 -0
  4. package/dist/api/gs25Handlers.js +68 -22
  5. package/dist/api/handlers.js +32 -3
  6. package/dist/api/healthChecks.js +233 -20
  7. package/dist/api/lottemartHandlers.js +17 -1
  8. package/dist/api/placesHandlers.js +44 -0
  9. package/dist/api/response.js +2 -0
  10. package/dist/api/routes/healthRoutes.js +20 -3
  11. package/dist/api/routes/placesRoutes.js +9 -0
  12. package/dist/bin.js +0 -0
  13. package/dist/cli/args.js +21 -0
  14. package/dist/cli/commandOptions.js +96 -0
  15. package/dist/cli/commands/convenience.js +73 -1
  16. package/dist/cli/commands/daiso.js +18 -2
  17. package/dist/cli/http.js +58 -0
  18. package/dist/cli/interactiveItemSearch.js +4 -2
  19. package/dist/cli/interactivePrompt.js +3 -2
  20. package/dist/cli.js +25 -1
  21. package/dist/cliHelp.js +20 -6
  22. package/dist/cliInteractive.js +75 -6
  23. package/dist/cliPicker.js +1 -0
  24. package/dist/cliRenderer.js +93 -3
  25. package/dist/core/errors.js +66 -0
  26. package/dist/core/registry.js +246 -3
  27. package/dist/index.js +19 -4
  28. package/dist/pages/openapiFullSpec.js +3 -0
  29. package/dist/pages/openapiSpecActions.js +308 -42
  30. package/dist/pages/openapiSpecPathsCu.js +7 -0
  31. package/dist/pages/openapiSpecPathsLotteMart.js +14 -0
  32. package/dist/pages/openapiSpecPathsOliveyoung.js +35 -0
  33. package/dist/pages/openapiSpecPathsPlaces.js +85 -0
  34. package/dist/pages/promptReferenceText.js +16 -7
  35. package/dist/pages/promptRetailCinemaText.js +24 -2
  36. package/dist/services/daiso/client.js +8 -0
  37. package/dist/services/daiso/index.js +2 -0
  38. package/dist/services/daiso/tools/checkInventory.js +4 -2
  39. package/dist/services/daiso/tools/findInventoryByName.js +222 -0
  40. package/dist/services/daiso/tools/getDisplayLocation.js +2 -2
  41. package/dist/services/emart24/client.js +9 -0
  42. package/dist/services/emart24/tools/searchProducts.js +27 -17
  43. package/dist/services/gs25/api.js +3 -0
  44. package/dist/services/gs25/client.js +216 -30
  45. package/dist/services/gs25/index.js +11 -3
  46. package/dist/services/gs25/tools/checkInventory.js +39 -12
  47. package/dist/services/gs25/tools/findNearbyStores.js +19 -5
  48. package/dist/services/gs25/tools/searchProducts.js +39 -25
  49. package/dist/services/lottemart/client.js +10 -1
  50. package/dist/services/lottemart/tools/searchProducts.js +8 -1
  51. package/dist/services/lottemart/zetta.js +1 -1
  52. package/dist/services/oliveyoung/client.js +206 -86
  53. package/dist/services/oliveyoung/tools/checkInventory.js +7 -2
  54. package/dist/services/places/client.js +116 -0
  55. package/dist/services/places/index.js +23 -0
  56. package/dist/services/places/tools/searchNearby.js +56 -0
  57. package/dist/services/places/types.js +1 -0
  58. package/dist/services/seveneleven/client.js +7 -0
  59. package/dist/services/seveneleven/tools/searchProducts.js +29 -18
  60. package/dist/utils/http.js +82 -10
  61. package/dist/utils/zyte.js +74 -26
  62. package/openapi.json +22 -3
  63. package/openapi.yaml +16 -3
  64. package/package.json +28 -9
@@ -10,6 +10,42 @@ import { fetchEnvelope, fetchStoresWithKeywordFallback } from './cli/interactive
10
10
  import { askMenu, askNextAction, askNonEmpty, askYesNo, printStoreDetail } from './cli/interactivePrompt.js';
11
11
  import { parseLotteCinemaTheaters, printTheaterDetail, runCuItemSearch, runDaisoItemSearch, runLotteCinemaSearch, runOliveyoungItemSearch, } from './cli/interactiveItemSearch.js';
12
12
  import { buildDaisoStoreKeywordVariants, isRecord, parseDaisoProducts, parseStores, toText } from './utils/cliInteractiveHelpers.js';
13
+ function isLocationToken(token) {
14
+ return /(?:역|점|동|구|시|군|읍|면|로|길)$/.test(token);
15
+ }
16
+ export function inferDaisoFreeTextIntent(input) {
17
+ const tokens = input.trim().split(/\s+/).filter(Boolean);
18
+ if (tokens.length <= 1) {
19
+ const value = tokens[0] || input.trim();
20
+ return { productQuery: value, storeKeyword: value };
21
+ }
22
+ const locationIndex = tokens.findIndex(isLocationToken);
23
+ if (locationIndex >= 0) {
24
+ const storeKeyword = tokens[locationIndex];
25
+ const productQuery = tokens.filter((_, index) => index !== locationIndex).join(' ').trim() || input.trim();
26
+ return { productQuery, storeKeyword };
27
+ }
28
+ const storeKeyword = tokens[tokens.length - 1];
29
+ const productQuery = tokens.slice(0, -1).join(' ').trim() || input.trim();
30
+ return { productQuery, storeKeyword };
31
+ }
32
+ async function askServiceChoice(prompt, writeOut) {
33
+ while (true) {
34
+ const answer = await prompt.ask('서비스 번호를 선택하세요 (0: 종료): ');
35
+ const picked = Number.parseInt(answer, 10);
36
+ if (!Number.isNaN(picked) && String(picked) === answer.trim()) {
37
+ if (picked >= 0 && picked <= 4) {
38
+ return picked;
39
+ }
40
+ writeOut('번호로 입력하세요. 0부터 4 사이에서 선택할 수 있습니다.');
41
+ continue;
42
+ }
43
+ if (answer.trim().length > 0) {
44
+ return answer.trim();
45
+ }
46
+ writeOut('번호로 입력하세요. 0부터 4 사이에서 선택할 수 있습니다.');
47
+ }
48
+ }
13
49
  function createPrompt() {
14
50
  const rl = createInterface({ input, output });
15
51
  return {
@@ -38,6 +74,7 @@ export const cliInteractiveTestables = {
38
74
  askNonEmpty,
39
75
  askNextAction,
40
76
  printStoreDetail,
77
+ inferDaisoFreeTextIntent,
41
78
  runDaisoItemSearch,
42
79
  runOliveyoungItemSearch,
43
80
  runCuItemSearch,
@@ -55,12 +92,44 @@ export async function runInteractiveCli(deps) {
55
92
  deps.writeOut('2. 올리브영');
56
93
  deps.writeOut('3. CU');
57
94
  deps.writeOut('4. 롯데시네마');
58
- const serviceChoice = await askMenu(prompt, '서비스 번호를 선택하세요 (0: 종료):', [
59
- '다이소',
60
- '올리브영',
61
- 'CU',
62
- '롯데시네마',
63
- ]);
95
+ const serviceChoice = await askServiceChoice(prompt, deps.writeOut);
96
+ if (typeof serviceChoice === 'string') {
97
+ const intent = inferDaisoFreeTextIntent(serviceChoice);
98
+ deps.writeOut('자유 입력을 다이소 조회로 처리합니다.');
99
+ deps.writeOut(`- 상품 키워드: ${intent.productQuery}`);
100
+ deps.writeOut(`- 위치 키워드: ${intent.storeKeyword}`);
101
+ const storeResult = await fetchStoresWithKeywordFallback(deps.fetchImpl, 'daiso', intent.storeKeyword);
102
+ const stores = storeResult.stores;
103
+ if (stores.length === 0) {
104
+ deps.writeOut('검색된 매장이 없습니다.');
105
+ deps.writeOut('힌트: "안산 중앙역" 대신 "안산중앙" 또는 "고잔"으로 검색해보세요.');
106
+ keepRunning = await askYesNo(prompt, '다시 시도할까요? (y/n): ');
107
+ continue;
108
+ }
109
+ const selectedStore = stores.length === 1
110
+ ? stores[0]
111
+ : await pickFromList({
112
+ prompt,
113
+ writeOut: deps.writeOut,
114
+ title: '[매장 선택]',
115
+ emptyText: '검색된 매장이 없습니다.',
116
+ cancelText: '매장 검색으로 돌아갑니다.',
117
+ items: stores,
118
+ renderItem: (store, index) => `${index + 1}. ${store.name} | ${store.address || '주소 정보 없음'}`,
119
+ filterText: (store) => `${store.name} ${store.address} ${store.phone}`,
120
+ indexText: '입력: 번호 선택 | /키워드 필터 | all 전체보기 | 0 다시 검색',
121
+ });
122
+ if (!selectedStore) {
123
+ continue;
124
+ }
125
+ printStoreDetail(deps.writeOut, selectedStore);
126
+ await runDaisoItemSearch(deps, prompt, selectedStore, intent.productQuery);
127
+ const nextAction = await askNextAction(prompt, deps.writeOut);
128
+ if (nextAction === 'exit') {
129
+ keepRunning = false;
130
+ }
131
+ continue;
132
+ }
64
133
  if (serviceChoice === 0) {
65
134
  break;
66
135
  }
package/dist/cliPicker.js CHANGED
@@ -49,6 +49,7 @@ export async function pickFromList(options) {
49
49
  }
50
50
  const picked = Number.parseInt(answer, 10);
51
51
  if (Number.isNaN(picked) || picked < 1 || picked > visibleItems.length) {
52
+ writeOut(`번호로 입력하세요. 1부터 ${visibleItems.length} 사이에서 선택하거나 0으로 취소하세요.`);
52
53
  continue;
53
54
  }
54
55
  return visibleItems[picked - 1];
@@ -29,7 +29,17 @@ function formatMeta(meta) {
29
29
  return [`메타: ${pairs.join(', ')}`];
30
30
  }
31
31
  function detectName(item) {
32
- const keys = ['name', 'productName', 'storeName', 'theaterName', 'movieName'];
32
+ const keys = [
33
+ 'name',
34
+ 'productName',
35
+ 'goodsNm',
36
+ 'goodsName',
37
+ 'itemName',
38
+ 'searchItemName',
39
+ 'storeName',
40
+ 'theaterName',
41
+ 'movieName',
42
+ ];
33
43
  for (const key of keys) {
34
44
  if (typeof item[key] === 'string' && item[key].trim().length > 0) {
35
45
  return item[key];
@@ -49,8 +59,24 @@ function formatCollection(title, items) {
49
59
  continue;
50
60
  }
51
61
  const name = detectName(entry);
52
- const id = entry.id ?? entry.productId ?? entry.PD_NO ?? entry.storeCode ?? entry.theaterId ?? entry.movieId;
53
- const price = entry.price ?? entry.prc ?? entry.PD_PRC;
62
+ const id = entry.id ??
63
+ entry.productId ??
64
+ entry.PD_NO ??
65
+ entry.itemCode ??
66
+ entry.pluCd ??
67
+ entry.goodsNumber ??
68
+ entry.productNo ??
69
+ entry.storeCode ??
70
+ entry.bizNo ??
71
+ entry.theaterId ??
72
+ entry.movieId ??
73
+ entry.link;
74
+ const price = entry.price ??
75
+ entry.prc ??
76
+ entry.PD_PRC ??
77
+ entry.viewPrice ??
78
+ entry.priceToPay ??
79
+ entry.searchItemSellPrice;
54
80
  let detail = `- ${name}`;
55
81
  if (id !== undefined) {
56
82
  detail += ` [${toText(id)}]`;
@@ -58,6 +84,22 @@ function formatCollection(title, items) {
58
84
  if (price !== undefined) {
59
85
  detail += ` / ${toText(price)}원`;
60
86
  }
87
+ const detailParts = [
88
+ entry.address ?? entry.storeAddress,
89
+ entry.distance !== undefined ? `${toText(entry.distance)}km` : undefined,
90
+ entry.distanceKm !== undefined ? `${toText(entry.distanceKm)}km` : undefined,
91
+ entry.distanceM !== undefined ? `${toText(entry.distanceM)}m` : undefined,
92
+ entry.quantity !== undefined ? `수량 ${toText(entry.quantity)}` : undefined,
93
+ entry.stock !== undefined ? `수량 ${toText(entry.stock)}` : undefined,
94
+ entry.realStockQuantity !== undefined ? `수량 ${toText(entry.realStockQuantity)}` : undefined,
95
+ entry.bizQty !== undefined ? `수량 ${toText(entry.bizQty)}` : undefined,
96
+ entry.remainQuantity !== undefined ? `수량 ${toText(entry.remainQuantity)}` : undefined,
97
+ entry.o2oRemainQuantity !== undefined ? `수량 ${toText(entry.o2oRemainQuantity)}` : undefined,
98
+ entry.stockLabel,
99
+ ].filter((value) => value !== undefined);
100
+ if (detailParts.length > 0) {
101
+ detail += ` / ${detailParts.map(toText).join(' / ')}`;
102
+ }
61
103
  lines.push(detail);
62
104
  }
63
105
  if (items.length > preview.length) {
@@ -65,6 +107,47 @@ function formatCollection(title, items) {
65
107
  }
66
108
  return lines;
67
109
  }
110
+ function formatNestedInventory(data) {
111
+ const lines = [];
112
+ if (isRecord(data.nearbyStores)) {
113
+ lines.push(...formatCollection('주변 매장', data.nearbyStores.stores));
114
+ }
115
+ if (isRecord(data.inventory)) {
116
+ lines.push(...formatCollection('재고 매장', data.inventory.stores));
117
+ lines.push(...formatCollection('재고 항목', data.inventory.items));
118
+ lines.push(...formatCollection('재고 상품', data.inventory.products));
119
+ if (Array.isArray(data.inventory.products)) {
120
+ for (const product of data.inventory.products.slice(0, 3)) {
121
+ if (!isRecord(product) || !isRecord(product.storeInventory)) {
122
+ continue;
123
+ }
124
+ lines.push(...formatCollection('상품별 매장 재고', product.storeInventory.stores));
125
+ }
126
+ }
127
+ }
128
+ return lines;
129
+ }
130
+ function formatDisplayLocation(data) {
131
+ const lines = [`진열 위치: ${data.hasLocation === true ? '있음' : '없음'}`];
132
+ if (!Array.isArray(data.locations) || data.locations.length === 0) {
133
+ return lines;
134
+ }
135
+ lines.push(`위치 목록: ${data.locations.length}건`);
136
+ for (const location of data.locations.slice(0, 5)) {
137
+ if (!isRecord(location)) {
138
+ lines.push(`- ${toText(location)}`);
139
+ continue;
140
+ }
141
+ const zoneNo = toText(location.zoneNo);
142
+ const stairNo = toText(location.stairNo);
143
+ const storeErp = location.storeErp !== undefined ? ` / storeErp ${toText(location.storeErp)}` : '';
144
+ lines.push(`- 구역 ${zoneNo} / 층 ${stairNo}${storeErp}`);
145
+ }
146
+ if (data.locations.length > 5) {
147
+ lines.push(`...외 ${data.locations.length - 5}건`);
148
+ }
149
+ return lines;
150
+ }
68
151
  function formatProductDetail(data) {
69
152
  const id = data.id ?? data.productId ?? '-';
70
153
  const name = data.name ?? data.productName ?? '-';
@@ -113,14 +196,21 @@ export function renderApiEnvelope(command, url, payload) {
113
196
  else if (command === 'inventory') {
114
197
  lines.push(...formatInventory(data));
115
198
  }
199
+ else if (command === 'display-location') {
200
+ lines.push(...formatDisplayLocation(data));
201
+ }
116
202
  else if (command === 'cu-inventory') {
117
203
  lines.push(...formatCollection('매장 목록', isRecord(data.nearbyStores) ? data.nearbyStores.stores : undefined));
118
204
  lines.push(...formatCollection('재고 항목', isRecord(data.inventory) ? data.inventory.items : undefined));
119
205
  }
206
+ else if (command.endsWith('-inventory')) {
207
+ lines.push(...formatNestedInventory(data));
208
+ }
120
209
  else {
121
210
  lines.push(...formatCollection('제품 목록', data.products));
122
211
  lines.push(...formatCollection('매장 목록', data.stores));
123
212
  lines.push(...formatCollection('극장 목록', data.theaters));
213
+ lines.push(...formatCollection('장소 목록', data.places));
124
214
  lines.push(...formatCollection('영화 목록', data.movies));
125
215
  lines.push(...formatCollection('시간표', data.timetable));
126
216
  lines.push(...formatCollection('좌석 목록', data.seats));
@@ -0,0 +1,66 @@
1
+ /**
2
+ * MCP/API 공통 에러 진단 구조
3
+ */
4
+ const SERVICE_PREFIXES = [
5
+ 'daiso',
6
+ 'gs25',
7
+ 'seveneleven',
8
+ 'cu',
9
+ 'emart24',
10
+ 'lottemart',
11
+ 'megabox',
12
+ 'lottecinema',
13
+ 'cgv',
14
+ 'oliveyoung',
15
+ ];
16
+ function normalizeService(value) {
17
+ return value.toLowerCase().replace(/_/g, '');
18
+ }
19
+ function toOperation(parts) {
20
+ const filtered = parts.filter((part) => part.length > 0 && part !== 'FAILED');
21
+ if (filtered.length === 0) {
22
+ return undefined;
23
+ }
24
+ return filtered.map((part) => part.toLowerCase()).join('_');
25
+ }
26
+ function inferServiceAndOperation(code) {
27
+ const parts = code.split('_').filter((part) => part.length > 0);
28
+ const normalizedFirst = normalizeService(parts[0] || '');
29
+ const service = SERVICE_PREFIXES.find((item) => normalizeService(item) === normalizedFirst);
30
+ if (!service) {
31
+ return { operation: toOperation(parts) };
32
+ }
33
+ return {
34
+ service,
35
+ operation: toOperation(parts.slice(1)),
36
+ };
37
+ }
38
+ function isRetryable(code, status) {
39
+ if (typeof status === 'number') {
40
+ return status >= 500 || status === 408 || status === 429;
41
+ }
42
+ const normalized = code.toUpperCase();
43
+ return normalized.includes('TIMEOUT') || normalized.includes('FAILED');
44
+ }
45
+ function buildHint(retryable) {
46
+ return retryable
47
+ ? '일시적인 외부 서비스 오류일 수 있습니다. 잠시 후 다시 시도하세요.'
48
+ : '입력값 또는 요청 조건을 확인하세요.';
49
+ }
50
+ export function toStandardErrorDiagnostics(code, message, options = {}) {
51
+ const inferred = inferServiceAndOperation(code);
52
+ const retryable = isRetryable(code, options.status || options.upstreamStatus);
53
+ return {
54
+ code,
55
+ message,
56
+ status: options.status,
57
+ retryable,
58
+ service: options.service || inferred.service,
59
+ operation: options.operation || inferred.operation,
60
+ upstreamStatus: options.upstreamStatus,
61
+ hint: buildHint(retryable),
62
+ };
63
+ }
64
+ export function getErrorMessage(error) {
65
+ return error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
66
+ }
@@ -3,6 +3,225 @@
3
3
  *
4
4
  * 서비스 프로바이더를 등록하고 MCP 서버에 도구를 연결합니다.
5
5
  */
6
+ import * as z from 'zod';
7
+ import { getErrorMessage, toStandardErrorDiagnostics } from './errors.js';
8
+ const DEFAULT_OUTPUT_SCHEMA = z.object({}).loose().describe('도구 실행 결과(JSON 객체)');
9
+ function createFallbackOutputSchema(toolName) {
10
+ if (toolName.includes('search_products') ||
11
+ toolName.includes('searchProducts') ||
12
+ toolName.endsWith('_products')) {
13
+ return {
14
+ keyword: z.string().optional(),
15
+ query: z.string().optional(),
16
+ page: z.number().optional(),
17
+ pageSize: z.number().optional(),
18
+ totalCount: z.number().optional(),
19
+ count: z.number().optional(),
20
+ products: z.array(z.unknown()).optional(),
21
+ note: z.string().optional(),
22
+ };
23
+ }
24
+ if (toolName.includes('find_stores') ||
25
+ toolName.includes('nearby_stores') ||
26
+ toolName.includes('search_stores')) {
27
+ return {
28
+ keyword: z.string().optional(),
29
+ searchParams: z.unknown().optional(),
30
+ location: z.unknown().optional(),
31
+ totalCount: z.number().optional(),
32
+ filteredCount: z.number().optional(),
33
+ count: z.number().optional(),
34
+ stores: z.array(z.unknown()).optional(),
35
+ };
36
+ }
37
+ if (toolName.includes('places')) {
38
+ return {
39
+ provider: z.string().optional(),
40
+ searchMode: z.string().optional(),
41
+ query: z.string().optional(),
42
+ category: z.string().optional(),
43
+ location: z.string().optional(),
44
+ totalCount: z.number().optional(),
45
+ count: z.number().optional(),
46
+ places: z.array(z.unknown()).optional(),
47
+ };
48
+ }
49
+ if (toolName.includes('inventory')) {
50
+ return {
51
+ productId: z.string().optional(),
52
+ keyword: z.string().optional(),
53
+ itemCode: z.string().nullable().optional(),
54
+ location: z.unknown().optional(),
55
+ product: z.unknown().optional(),
56
+ onlineStock: z.number().optional(),
57
+ inventory: z.unknown().optional(),
58
+ storeInventory: z.unknown().optional(),
59
+ };
60
+ }
61
+ if (toolName.includes('display_location')) {
62
+ return {
63
+ productId: z.string().optional(),
64
+ storeCode: z.string().optional(),
65
+ displayLocation: z.unknown().optional(),
66
+ locations: z.array(z.unknown()).optional(),
67
+ };
68
+ }
69
+ if (toolName.includes('price_info')) {
70
+ return {
71
+ productId: z.string().optional(),
72
+ productName: z.string().optional(),
73
+ product: z.unknown().optional(),
74
+ price: z.number().optional(),
75
+ };
76
+ }
77
+ if (toolName.includes('theater')) {
78
+ return {
79
+ keyword: z.string().optional(),
80
+ location: z.unknown().optional(),
81
+ count: z.number().optional(),
82
+ theaters: z.array(z.unknown()).optional(),
83
+ };
84
+ }
85
+ if (toolName.includes('movie') || toolName.includes('now_showing')) {
86
+ return {
87
+ keyword: z.string().optional(),
88
+ playDate: z.string().optional(),
89
+ theaterCode: z.string().optional(),
90
+ count: z.number().optional(),
91
+ movies: z.array(z.unknown()).optional(),
92
+ };
93
+ }
94
+ if (toolName.includes('timetable') || toolName.includes('remaining_seats')) {
95
+ return {
96
+ playDate: z.string().optional(),
97
+ theaterCode: z.string().optional(),
98
+ movieCode: z.string().optional(),
99
+ count: z.number().optional(),
100
+ showtimes: z.array(z.unknown()).optional(),
101
+ seats: z.array(z.unknown()).optional(),
102
+ };
103
+ }
104
+ if (toolName.includes('popwords')) {
105
+ return {
106
+ label: z.string().optional(),
107
+ keywords: z.array(z.string()).optional(),
108
+ count: z.number().optional(),
109
+ };
110
+ }
111
+ if (toolName.includes('catalog')) {
112
+ return {
113
+ pages: z.array(z.unknown()).optional(),
114
+ issues: z.array(z.unknown()).optional(),
115
+ exhibitions: z.array(z.unknown()).optional(),
116
+ count: z.number().optional(),
117
+ };
118
+ }
119
+ return {};
120
+ }
121
+ function createToolOutputSchema(toolName, outputSchema) {
122
+ const schema = outputSchema || createFallbackOutputSchema(toolName);
123
+ if (Object.keys(schema).length === 0) {
124
+ return DEFAULT_OUTPUT_SCHEMA;
125
+ }
126
+ return z
127
+ .object({
128
+ ...schema,
129
+ standard: z.unknown().optional().describe('서비스 공통 상품/매장/영화관 정규화 결과'),
130
+ })
131
+ .loose();
132
+ }
133
+ function parseJsonText(text) {
134
+ try {
135
+ const parsed = JSON.parse(text);
136
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
137
+ return parsed;
138
+ }
139
+ }
140
+ catch {
141
+ return null;
142
+ }
143
+ return null;
144
+ }
145
+ function firstString(...values) {
146
+ for (const value of values) {
147
+ if (typeof value === 'string' && value.trim().length > 0) {
148
+ return value.trim();
149
+ }
150
+ }
151
+ return null;
152
+ }
153
+ function firstNumber(...values) {
154
+ for (const value of values) {
155
+ if (typeof value === 'number' && Number.isFinite(value)) {
156
+ return value;
157
+ }
158
+ if (typeof value === 'string') {
159
+ const parsed = Number.parseFloat(value.replace(/km$/i, ''));
160
+ if (Number.isFinite(parsed)) {
161
+ return value.trim().toLowerCase().endsWith('km') ? Math.round(parsed * 1000) : parsed;
162
+ }
163
+ }
164
+ }
165
+ return null;
166
+ }
167
+ function normalizeProducts(products) {
168
+ return products
169
+ .filter((item) => Boolean(item) && typeof item === 'object' && !Array.isArray(item))
170
+ .map((item) => ({
171
+ code: firstString(item.itemCode, item.productCode, item.pluCd, item.id, item.productNo),
172
+ name: firstString(item.itemName, item.shortItemName, item.goodsName, item.productName, item.movieName, item.name),
173
+ price: firstNumber(item.viewPrice, item.salePrice, item.sellPrice, item.price, item.originPrice, item.searchItemSellPrice),
174
+ imageUrl: firstString(item.imageUrl),
175
+ raw: item,
176
+ }));
177
+ }
178
+ function normalizeStores(stores) {
179
+ return stores
180
+ .filter((item) => Boolean(item) && typeof item === 'object' && !Array.isArray(item))
181
+ .map((item) => ({
182
+ code: firstString(item.storeCode, item.code, item.id),
183
+ name: firstString(item.storeName, item.name, item.theaterName),
184
+ address: firstString(item.address, item.storeAddress),
185
+ distanceMeters: firstNumber(item.distanceM, item.distanceMeters, item.distance),
186
+ raw: item,
187
+ }));
188
+ }
189
+ function withStandardCollections(payload) {
190
+ const standard = {};
191
+ if (Array.isArray(payload.products)) {
192
+ standard.products = normalizeProducts(payload.products);
193
+ }
194
+ if (Array.isArray(payload.stores)) {
195
+ standard.stores = normalizeStores(payload.stores);
196
+ }
197
+ if (Array.isArray(payload.places)) {
198
+ standard.places = normalizeStores(payload.places);
199
+ }
200
+ if (Array.isArray(payload.theaters)) {
201
+ standard.theaters = normalizeStores(payload.theaters);
202
+ }
203
+ if (Array.isArray(payload.movies)) {
204
+ standard.movies = normalizeProducts(payload.movies);
205
+ }
206
+ if (Object.keys(standard).length === 0) {
207
+ return payload;
208
+ }
209
+ return {
210
+ ...payload,
211
+ standard,
212
+ };
213
+ }
214
+ function buildStructuredContent(result) {
215
+ if (result.structuredContent) {
216
+ return withStandardCollections(result.structuredContent);
217
+ }
218
+ const text = result.content[0]?.text || '';
219
+ const parsed = parseJsonText(text);
220
+ if (parsed) {
221
+ return withStandardCollections(parsed);
222
+ }
223
+ return { text };
224
+ }
6
225
  /**
7
226
  * 서비스 레지스트리 클래스
8
227
  *
@@ -73,14 +292,38 @@ export class ServiceRegistry {
73
292
  * 단일 도구를 MCP 서버에 등록
74
293
  */
75
294
  registerTool(server, tool) {
76
- server.registerTool(tool.name, tool.metadata, async (args) => {
77
- const result = await tool.handler(args);
78
- return {
295
+ const metadata = {
296
+ ...tool.metadata,
297
+ outputSchema: createToolOutputSchema(tool.name, tool.metadata.outputSchema),
298
+ };
299
+ server.registerTool(tool.name, metadata, async (args) => {
300
+ let result;
301
+ try {
302
+ result = await tool.handler(args);
303
+ }
304
+ catch (error) {
305
+ const message = getErrorMessage(error);
306
+ return {
307
+ isError: true,
308
+ content: [{ type: 'text', text: message }],
309
+ structuredContent: {
310
+ error: toStandardErrorDiagnostics('TOOL_EXECUTION_FAILED', message, {
311
+ operation: tool.name,
312
+ }),
313
+ },
314
+ };
315
+ }
316
+ const response = {
79
317
  content: result.content.map((item) => ({
80
318
  type: item.type,
81
319
  text: item.text,
82
320
  })),
321
+ structuredContent: buildStructuredContent(result),
83
322
  };
323
+ if (result.isError) {
324
+ response.isError = true;
325
+ }
326
+ return response;
84
327
  });
85
328
  }
86
329
  /**
package/dist/index.js CHANGED
@@ -19,6 +19,7 @@ import { createCuService } from './services/cu/index.js';
19
19
  import { createEmart24Service } from './services/emart24/index.js';
20
20
  import { createGs25Service } from './services/gs25/index.js';
21
21
  import { createSevenElevenService } from './services/seveneleven/index.js';
22
+ import { createPlacesService } from './services/places/index.js';
22
23
  import { createPromptResponse } from './pages/prompt.js';
23
24
  import { createFullOpenApiJsonResponse, createFullOpenApiYamlResponse, createOpenApiJsonResponse, createOpenApiYamlResponse, } from './pages/openapi.js';
24
25
  import { createPrivacyResponse } from './pages/privacy.js';
@@ -33,7 +34,9 @@ import { registerCuRoutes } from './api/routes/cuRoutes.js';
33
34
  import { registerEmart24Routes } from './api/routes/emart24Routes.js';
34
35
  import { registerGs25Routes } from './api/routes/gs25Routes.js';
35
36
  import { registerSevenElevenRoutes } from './api/routes/sevenelevenRoutes.js';
37
+ import { registerPlacesRoutes } from './api/routes/placesRoutes.js';
36
38
  import { registerHealthRoutes } from './api/routes/healthRoutes.js';
39
+ import { buildConfigStatus } from './api/configStatus.js';
37
40
  // 서버 메타데이터
38
41
  const SERVER_NAME = 'multi-service-mcp';
39
42
  const SERVER_VERSION = '1.0.0';
@@ -47,8 +50,15 @@ const createRegistry = (bindings) => {
47
50
  const registry = new ServiceRegistry();
48
51
  registry.registerAll([
49
52
  createDaisoService,
50
- createGs25Service,
53
+ () => createGs25Service({
54
+ googleMapsApiKey: bindings?.GOOGLE_MAPS_API_KEY,
55
+ zyteApiKey: bindings?.ZYTE_API_KEY,
56
+ }),
51
57
  createSevenElevenService,
58
+ () => createPlacesService({
59
+ naverClientId: bindings?.NAVER_CLIENT_ID,
60
+ naverClientSecret: bindings?.NAVER_CLIENT_SECRET,
61
+ }),
52
62
  createCuService,
53
63
  createEmart24Service,
54
64
  () => createLotteMartService({
@@ -86,9 +96,13 @@ const createMcpServer = (bindings) => {
86
96
  */
87
97
  const isInitializeRequest = (body) => {
88
98
  if (Array.isArray(body)) {
89
- return body.some((item) => typeof item === 'object' && item !== null && item.method === 'initialize');
99
+ return body.some((item) => typeof item === 'object' &&
100
+ item !== null &&
101
+ item.method === 'initialize');
90
102
  }
91
- return typeof body === 'object' && body !== null && body.method === 'initialize';
103
+ return (typeof body === 'object' &&
104
+ body !== null &&
105
+ body.method === 'initialize');
92
106
  };
93
107
  /**
94
108
  * 새 세션 transport/server를 생성합니다.
@@ -196,7 +210,7 @@ app.on(['POST', 'DELETE', 'OPTIONS'], '/', async (c) => {
196
210
  return transport.handleRequest(c.req.raw);
197
211
  });
198
212
  // 헬스 체크 엔드포인트
199
- app.get('/health', (c) => c.json({ status: 'ok' }));
213
+ app.get('/health', (c) => c.json({ status: 'ok', config: buildConfigStatus(c.env) }));
200
214
  registerHealthRoutes(app);
201
215
  // 프롬프트 페이지 (MCP 미지원 에이전트용)
202
216
  app.get('/prompt', (c) => {
@@ -229,6 +243,7 @@ app.get('/privacy', (c) => {
229
243
  registerDaisoRoutes(app);
230
244
  registerGs25Routes(app);
231
245
  registerSevenElevenRoutes(app);
246
+ registerPlacesRoutes(app);
232
247
  registerCuRoutes(app);
233
248
  registerEmart24Routes(app);
234
249
  registerLotteMartRoutes(app);
@@ -10,6 +10,7 @@ import { OPENAPI_PATHS_CGV } from './openapiSpecPathsCgv.js';
10
10
  import { OPENAPI_PATHS_LOTTECINEMA } from './openapiSpecPathsLottecinema.js';
11
11
  import { OPENAPI_PATHS_GS25 } from './openapiSpecPathsGs25.js';
12
12
  import { OPENAPI_PATHS_SEVENELEVEN } from './openapiSpecPathsSeveneleven.js';
13
+ import { OPENAPI_PATHS_PLACES } from './openapiSpecPathsPlaces.js';
13
14
  import { OPENAPI_COMPONENTS } from './openapiSpecComponents.js';
14
15
  export function generateFullOpenApiSpec(baseUrl) {
15
16
  return {
@@ -29,6 +30,7 @@ export function generateFullOpenApiSpec(baseUrl) {
29
30
  - 🎬 **메가박스 지점/영화 조회**: 주변 지점, 상영 목록, 잔여 좌석 조회
30
31
  - 🎞️ **롯데시네마 지점/영화 조회**: 주변 지점, 상영 목록, 잔여 좌석 조회
31
32
  - 🎥 **CGV 지점/영화 조회**: 극장 목록, 영화 목록, 시간표 조회
33
+ - 🍽️ **주변 음식점/카페 검색**: 네이버 지역 검색 기반 장소 조회
32
34
 
33
35
  ## 사용 팁
34
36
  1. 한글 검색어는 URL 인코딩이 자동 처리됩니다
@@ -49,6 +51,7 @@ export function generateFullOpenApiSpec(baseUrl) {
49
51
  ...OPENAPI_PATHS_LOTTEMART,
50
52
  ...OPENAPI_PATHS_GS25,
51
53
  ...OPENAPI_PATHS_SEVENELEVEN,
54
+ ...OPENAPI_PATHS_PLACES,
52
55
  ...OPENAPI_PATHS_MEGABOX,
53
56
  ...OPENAPI_PATHS_LOTTECINEMA,
54
57
  ...OPENAPI_PATHS_CGV,