daiso 1.0.5 → 1.0.6

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 (51) hide show
  1. package/README.md +45 -7
  2. package/dist/api/configStatus.js +19 -0
  3. package/dist/api/gs25Handlers.js +25 -14
  4. package/dist/api/handlers.js +32 -3
  5. package/dist/api/healthChecks.js +185 -13
  6. package/dist/api/lottemartHandlers.js +17 -1
  7. package/dist/api/response.js +2 -0
  8. package/dist/api/routes/healthRoutes.js +20 -3
  9. package/dist/bin.js +0 -0
  10. package/dist/cli/args.js +21 -0
  11. package/dist/cli/commandOptions.js +38 -0
  12. package/dist/cli/commands/convenience.js +53 -1
  13. package/dist/cli/commands/daiso.js +18 -2
  14. package/dist/cli/http.js +58 -0
  15. package/dist/cli/interactiveItemSearch.js +4 -2
  16. package/dist/cli/interactivePrompt.js +3 -2
  17. package/dist/cli.js +22 -0
  18. package/dist/cliHelp.js +6 -0
  19. package/dist/cliInteractive.js +75 -6
  20. package/dist/cliPicker.js +1 -0
  21. package/dist/cliRenderer.js +86 -3
  22. package/dist/core/errors.js +66 -0
  23. package/dist/core/registry.js +227 -3
  24. package/dist/index.js +2 -1
  25. package/dist/pages/openapiSpecActions.js +3 -3
  26. package/dist/pages/openapiSpecPathsCu.js +7 -0
  27. package/dist/pages/openapiSpecPathsLotteMart.js +14 -0
  28. package/dist/pages/openapiSpecPathsOliveyoung.js +35 -0
  29. package/dist/pages/promptReferenceText.js +13 -7
  30. package/dist/pages/promptRetailCinemaText.js +2 -1
  31. package/dist/services/daiso/client.js +8 -0
  32. package/dist/services/daiso/index.js +2 -0
  33. package/dist/services/daiso/tools/checkInventory.js +4 -2
  34. package/dist/services/daiso/tools/findInventoryByName.js +222 -0
  35. package/dist/services/daiso/tools/getDisplayLocation.js +2 -2
  36. package/dist/services/emart24/client.js +9 -0
  37. package/dist/services/emart24/tools/searchProducts.js +27 -17
  38. package/dist/services/gs25/client.js +10 -0
  39. package/dist/services/gs25/tools/searchProducts.js +28 -21
  40. package/dist/services/lottemart/client.js +10 -1
  41. package/dist/services/lottemart/tools/searchProducts.js +8 -1
  42. package/dist/services/lottemart/zetta.js +1 -1
  43. package/dist/services/oliveyoung/client.js +124 -53
  44. package/dist/services/oliveyoung/tools/checkInventory.js +7 -2
  45. package/dist/services/seveneleven/client.js +7 -0
  46. package/dist/services/seveneleven/tools/searchProducts.js +29 -18
  47. package/dist/utils/http.js +82 -10
  48. package/dist/utils/zyte.js +47 -26
  49. package/openapi.json +3 -3
  50. package/openapi.yaml +3 -3
  51. package/package.json +26 -8
@@ -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,18 @@ 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
+ const price = entry.price ?? entry.prc ?? entry.PD_PRC ?? entry.viewPrice ?? entry.priceToPay ?? entry.searchItemSellPrice;
54
74
  let detail = `- ${name}`;
55
75
  if (id !== undefined) {
56
76
  detail += ` [${toText(id)}]`;
@@ -58,6 +78,22 @@ function formatCollection(title, items) {
58
78
  if (price !== undefined) {
59
79
  detail += ` / ${toText(price)}원`;
60
80
  }
81
+ const detailParts = [
82
+ entry.address ?? entry.storeAddress,
83
+ entry.distance !== undefined ? `${toText(entry.distance)}km` : undefined,
84
+ entry.distanceKm !== undefined ? `${toText(entry.distanceKm)}km` : undefined,
85
+ entry.distanceM !== undefined ? `${toText(entry.distanceM)}m` : undefined,
86
+ entry.quantity !== undefined ? `수량 ${toText(entry.quantity)}` : undefined,
87
+ entry.stock !== undefined ? `수량 ${toText(entry.stock)}` : undefined,
88
+ entry.realStockQuantity !== undefined ? `수량 ${toText(entry.realStockQuantity)}` : undefined,
89
+ entry.bizQty !== undefined ? `수량 ${toText(entry.bizQty)}` : undefined,
90
+ entry.remainQuantity !== undefined ? `수량 ${toText(entry.remainQuantity)}` : undefined,
91
+ entry.o2oRemainQuantity !== undefined ? `수량 ${toText(entry.o2oRemainQuantity)}` : undefined,
92
+ entry.stockLabel,
93
+ ].filter((value) => value !== undefined);
94
+ if (detailParts.length > 0) {
95
+ detail += ` / ${detailParts.map(toText).join(' / ')}`;
96
+ }
61
97
  lines.push(detail);
62
98
  }
63
99
  if (items.length > preview.length) {
@@ -65,6 +101,47 @@ function formatCollection(title, items) {
65
101
  }
66
102
  return lines;
67
103
  }
104
+ function formatNestedInventory(data) {
105
+ const lines = [];
106
+ if (isRecord(data.nearbyStores)) {
107
+ lines.push(...formatCollection('주변 매장', data.nearbyStores.stores));
108
+ }
109
+ if (isRecord(data.inventory)) {
110
+ lines.push(...formatCollection('재고 매장', data.inventory.stores));
111
+ lines.push(...formatCollection('재고 항목', data.inventory.items));
112
+ lines.push(...formatCollection('재고 상품', data.inventory.products));
113
+ if (Array.isArray(data.inventory.products)) {
114
+ for (const product of data.inventory.products.slice(0, 3)) {
115
+ if (!isRecord(product) || !isRecord(product.storeInventory)) {
116
+ continue;
117
+ }
118
+ lines.push(...formatCollection('상품별 매장 재고', product.storeInventory.stores));
119
+ }
120
+ }
121
+ }
122
+ return lines;
123
+ }
124
+ function formatDisplayLocation(data) {
125
+ const lines = [`진열 위치: ${data.hasLocation === true ? '있음' : '없음'}`];
126
+ if (!Array.isArray(data.locations) || data.locations.length === 0) {
127
+ return lines;
128
+ }
129
+ lines.push(`위치 목록: ${data.locations.length}건`);
130
+ for (const location of data.locations.slice(0, 5)) {
131
+ if (!isRecord(location)) {
132
+ lines.push(`- ${toText(location)}`);
133
+ continue;
134
+ }
135
+ const zoneNo = toText(location.zoneNo);
136
+ const stairNo = toText(location.stairNo);
137
+ const storeErp = location.storeErp !== undefined ? ` / storeErp ${toText(location.storeErp)}` : '';
138
+ lines.push(`- 구역 ${zoneNo} / 층 ${stairNo}${storeErp}`);
139
+ }
140
+ if (data.locations.length > 5) {
141
+ lines.push(`...외 ${data.locations.length - 5}건`);
142
+ }
143
+ return lines;
144
+ }
68
145
  function formatProductDetail(data) {
69
146
  const id = data.id ?? data.productId ?? '-';
70
147
  const name = data.name ?? data.productName ?? '-';
@@ -113,10 +190,16 @@ export function renderApiEnvelope(command, url, payload) {
113
190
  else if (command === 'inventory') {
114
191
  lines.push(...formatInventory(data));
115
192
  }
193
+ else if (command === 'display-location') {
194
+ lines.push(...formatDisplayLocation(data));
195
+ }
116
196
  else if (command === 'cu-inventory') {
117
197
  lines.push(...formatCollection('매장 목록', isRecord(data.nearbyStores) ? data.nearbyStores.stores : undefined));
118
198
  lines.push(...formatCollection('재고 항목', isRecord(data.inventory) ? data.inventory.items : undefined));
119
199
  }
200
+ else if (command.endsWith('-inventory')) {
201
+ lines.push(...formatNestedInventory(data));
202
+ }
120
203
  else {
121
204
  lines.push(...formatCollection('제품 목록', data.products));
122
205
  lines.push(...formatCollection('매장 목록', data.stores));
@@ -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,206 @@
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') || toolName.includes('searchProducts') || toolName.endsWith('_products')) {
11
+ return {
12
+ keyword: z.string().optional(),
13
+ query: z.string().optional(),
14
+ page: z.number().optional(),
15
+ pageSize: z.number().optional(),
16
+ totalCount: z.number().optional(),
17
+ count: z.number().optional(),
18
+ products: z.array(z.unknown()).optional(),
19
+ note: z.string().optional(),
20
+ };
21
+ }
22
+ if (toolName.includes('find_stores') || toolName.includes('nearby_stores') || toolName.includes('search_stores')) {
23
+ return {
24
+ keyword: z.string().optional(),
25
+ searchParams: z.unknown().optional(),
26
+ location: z.unknown().optional(),
27
+ totalCount: z.number().optional(),
28
+ filteredCount: z.number().optional(),
29
+ count: z.number().optional(),
30
+ stores: z.array(z.unknown()).optional(),
31
+ };
32
+ }
33
+ if (toolName.includes('inventory')) {
34
+ return {
35
+ productId: z.string().optional(),
36
+ keyword: z.string().optional(),
37
+ itemCode: z.string().nullable().optional(),
38
+ location: z.unknown().optional(),
39
+ product: z.unknown().optional(),
40
+ onlineStock: z.number().optional(),
41
+ inventory: z.unknown().optional(),
42
+ storeInventory: z.unknown().optional(),
43
+ };
44
+ }
45
+ if (toolName.includes('display_location')) {
46
+ return {
47
+ productId: z.string().optional(),
48
+ storeCode: z.string().optional(),
49
+ displayLocation: z.unknown().optional(),
50
+ locations: z.array(z.unknown()).optional(),
51
+ };
52
+ }
53
+ if (toolName.includes('price_info')) {
54
+ return {
55
+ productId: z.string().optional(),
56
+ productName: z.string().optional(),
57
+ product: z.unknown().optional(),
58
+ price: z.number().optional(),
59
+ };
60
+ }
61
+ if (toolName.includes('theater')) {
62
+ return {
63
+ keyword: z.string().optional(),
64
+ location: z.unknown().optional(),
65
+ count: z.number().optional(),
66
+ theaters: z.array(z.unknown()).optional(),
67
+ };
68
+ }
69
+ if (toolName.includes('movie') || toolName.includes('now_showing')) {
70
+ return {
71
+ keyword: z.string().optional(),
72
+ playDate: z.string().optional(),
73
+ theaterCode: z.string().optional(),
74
+ count: z.number().optional(),
75
+ movies: z.array(z.unknown()).optional(),
76
+ };
77
+ }
78
+ if (toolName.includes('timetable') || toolName.includes('remaining_seats')) {
79
+ return {
80
+ playDate: z.string().optional(),
81
+ theaterCode: z.string().optional(),
82
+ movieCode: z.string().optional(),
83
+ count: z.number().optional(),
84
+ showtimes: z.array(z.unknown()).optional(),
85
+ seats: z.array(z.unknown()).optional(),
86
+ };
87
+ }
88
+ if (toolName.includes('popwords')) {
89
+ return {
90
+ label: z.string().optional(),
91
+ keywords: z.array(z.string()).optional(),
92
+ count: z.number().optional(),
93
+ };
94
+ }
95
+ if (toolName.includes('catalog')) {
96
+ return {
97
+ pages: z.array(z.unknown()).optional(),
98
+ issues: z.array(z.unknown()).optional(),
99
+ exhibitions: z.array(z.unknown()).optional(),
100
+ count: z.number().optional(),
101
+ };
102
+ }
103
+ return {};
104
+ }
105
+ function createToolOutputSchema(toolName, outputSchema) {
106
+ const schema = outputSchema || createFallbackOutputSchema(toolName);
107
+ if (Object.keys(schema).length === 0) {
108
+ return DEFAULT_OUTPUT_SCHEMA;
109
+ }
110
+ return z
111
+ .object({
112
+ ...schema,
113
+ standard: z.unknown().optional().describe('서비스 공통 상품/매장/영화관 정규화 결과'),
114
+ })
115
+ .loose();
116
+ }
117
+ function parseJsonText(text) {
118
+ try {
119
+ const parsed = JSON.parse(text);
120
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
121
+ return parsed;
122
+ }
123
+ }
124
+ catch {
125
+ return null;
126
+ }
127
+ return null;
128
+ }
129
+ function firstString(...values) {
130
+ for (const value of values) {
131
+ if (typeof value === 'string' && value.trim().length > 0) {
132
+ return value.trim();
133
+ }
134
+ }
135
+ return null;
136
+ }
137
+ function firstNumber(...values) {
138
+ for (const value of values) {
139
+ if (typeof value === 'number' && Number.isFinite(value)) {
140
+ return value;
141
+ }
142
+ if (typeof value === 'string') {
143
+ const parsed = Number.parseFloat(value.replace(/km$/i, ''));
144
+ if (Number.isFinite(parsed)) {
145
+ return value.trim().toLowerCase().endsWith('km') ? Math.round(parsed * 1000) : parsed;
146
+ }
147
+ }
148
+ }
149
+ return null;
150
+ }
151
+ function normalizeProducts(products) {
152
+ return products
153
+ .filter((item) => Boolean(item) && typeof item === 'object' && !Array.isArray(item))
154
+ .map((item) => ({
155
+ code: firstString(item.itemCode, item.productCode, item.pluCd, item.id, item.productNo),
156
+ name: firstString(item.itemName, item.shortItemName, item.goodsName, item.productName, item.movieName, item.name),
157
+ price: firstNumber(item.viewPrice, item.salePrice, item.sellPrice, item.price, item.originPrice, item.searchItemSellPrice),
158
+ imageUrl: firstString(item.imageUrl),
159
+ raw: item,
160
+ }));
161
+ }
162
+ function normalizeStores(stores) {
163
+ return stores
164
+ .filter((item) => Boolean(item) && typeof item === 'object' && !Array.isArray(item))
165
+ .map((item) => ({
166
+ code: firstString(item.storeCode, item.code, item.id),
167
+ name: firstString(item.storeName, item.name, item.theaterName),
168
+ address: firstString(item.address, item.storeAddress),
169
+ distanceMeters: firstNumber(item.distanceM, item.distanceMeters, item.distance),
170
+ raw: item,
171
+ }));
172
+ }
173
+ function withStandardCollections(payload) {
174
+ const standard = {};
175
+ if (Array.isArray(payload.products)) {
176
+ standard.products = normalizeProducts(payload.products);
177
+ }
178
+ if (Array.isArray(payload.stores)) {
179
+ standard.stores = normalizeStores(payload.stores);
180
+ }
181
+ if (Array.isArray(payload.theaters)) {
182
+ standard.theaters = normalizeStores(payload.theaters);
183
+ }
184
+ if (Array.isArray(payload.movies)) {
185
+ standard.movies = normalizeProducts(payload.movies);
186
+ }
187
+ if (Object.keys(standard).length === 0) {
188
+ return payload;
189
+ }
190
+ return {
191
+ ...payload,
192
+ standard,
193
+ };
194
+ }
195
+ function buildStructuredContent(result) {
196
+ if (result.structuredContent) {
197
+ return withStandardCollections(result.structuredContent);
198
+ }
199
+ const text = result.content[0]?.text || '';
200
+ const parsed = parseJsonText(text);
201
+ if (parsed) {
202
+ return withStandardCollections(parsed);
203
+ }
204
+ return { text };
205
+ }
6
206
  /**
7
207
  * 서비스 레지스트리 클래스
8
208
  *
@@ -73,14 +273,38 @@ export class ServiceRegistry {
73
273
  * 단일 도구를 MCP 서버에 등록
74
274
  */
75
275
  registerTool(server, tool) {
76
- server.registerTool(tool.name, tool.metadata, async (args) => {
77
- const result = await tool.handler(args);
78
- return {
276
+ const metadata = {
277
+ ...tool.metadata,
278
+ outputSchema: createToolOutputSchema(tool.name, tool.metadata.outputSchema),
279
+ };
280
+ server.registerTool(tool.name, metadata, async (args) => {
281
+ let result;
282
+ try {
283
+ result = await tool.handler(args);
284
+ }
285
+ catch (error) {
286
+ const message = getErrorMessage(error);
287
+ return {
288
+ isError: true,
289
+ content: [{ type: 'text', text: message }],
290
+ structuredContent: {
291
+ error: toStandardErrorDiagnostics('TOOL_EXECUTION_FAILED', message, {
292
+ operation: tool.name,
293
+ }),
294
+ },
295
+ };
296
+ }
297
+ const response = {
79
298
  content: result.content.map((item) => ({
80
299
  type: item.type,
81
300
  text: item.text,
82
301
  })),
302
+ structuredContent: buildStructuredContent(result),
83
303
  };
304
+ if (result.isError) {
305
+ response.isError = true;
306
+ }
307
+ return response;
84
308
  });
85
309
  }
86
310
  /**
package/dist/index.js CHANGED
@@ -34,6 +34,7 @@ import { registerEmart24Routes } from './api/routes/emart24Routes.js';
34
34
  import { registerGs25Routes } from './api/routes/gs25Routes.js';
35
35
  import { registerSevenElevenRoutes } from './api/routes/sevenelevenRoutes.js';
36
36
  import { registerHealthRoutes } from './api/routes/healthRoutes.js';
37
+ import { buildConfigStatus } from './api/configStatus.js';
37
38
  // 서버 메타데이터
38
39
  const SERVER_NAME = 'multi-service-mcp';
39
40
  const SERVER_VERSION = '1.0.0';
@@ -196,7 +197,7 @@ app.on(['POST', 'DELETE', 'OPTIONS'], '/', async (c) => {
196
197
  return transport.handleRequest(c.req.raw);
197
198
  });
198
199
  // 헬스 체크 엔드포인트
199
- app.get('/health', (c) => c.json({ status: 'ok' }));
200
+ app.get('/health', (c) => c.json({ status: 'ok', config: buildConfigStatus(c.env) }));
200
201
  registerHealthRoutes(app);
201
202
  // 프롬프트 페이지 (MCP 미지원 에이전트용)
202
203
  app.get('/prompt', (c) => {
@@ -17,7 +17,7 @@ const ACTION_QUERY_PARAMETERS = [
17
17
  { name: 'q', in: 'query', required: false, description: '다이소 제품 검색어', schema: { type: 'string' } },
18
18
  { name: 'query', in: 'query', required: false, description: '세븐일레븐 상품 검색어', schema: { type: 'string' } },
19
19
  { name: 'itemCode', in: 'query', required: false, description: 'GS25 상품 코드', schema: { type: 'string' } },
20
- { name: 'productId', in: 'query', required: false, description: '제품 ID 또는 상세 조회용 path 파라미터', schema: { type: 'string' } },
20
+ { name: 'productId', in: 'query', required: false, description: '제품 ID 또는 상세 조회용 path 파라미터. 상품명만 알면 먼저 daisoSearchProducts 또는 /api/daiso/products로 productId를 확인하세요.', schema: { type: 'string' } },
21
21
  { name: 'keyword', in: 'query', required: false, description: '공통 키워드 파라미터', schema: { type: 'string' } },
22
22
  { name: 'sido', in: 'query', required: false, description: '다이소 시/도', schema: { type: 'string' } },
23
23
  { name: 'gugun', in: 'query', required: false, description: '다이소 구/군', schema: { type: 'string' } },
@@ -37,8 +37,8 @@ const ACTION_QUERY_PARAMETERS = [
37
37
  { name: 'offset', in: 'query', required: false, description: 'CU 검색 오프셋', schema: { type: 'integer' } },
38
38
  { name: 'limit', in: 'query', required: false, description: '최대 결과 수', schema: { type: 'integer' } },
39
39
  { name: 'storeLimit', in: 'query', required: false, description: '매장 결과 제한 수', schema: { type: 'integer' } },
40
- { name: 'storeCode', in: 'query', required: false, description: '매장 코드', schema: { type: 'string' } },
41
- { name: 'storeName', in: 'query', required: false, description: '매장명', schema: { type: 'string' } },
40
+ { name: 'storeCode', in: 'query', required: false, description: '매장 코드. 다이소 진열 위치는 /api/daiso/inventory 응답의 storeInventory.stores[].storeCode에서 확인하세요.', schema: { type: 'string' } },
41
+ { name: 'storeName', in: 'query', required: false, description: '매장명. 롯데마트 상품 조회에서 storeCode가 없을 때 사용하며, 모르면 먼저 lottemartFindNearbyStores 또는 /api/lottemart/stores로 확인하세요.', schema: { type: 'string' } },
42
42
  { name: 'storeKeyword', in: 'query', required: false, description: '매장 키워드', schema: { type: 'string' } },
43
43
  { name: 'playDate', in: 'query', required: false, description: '상영일(YYYYMMDD)', schema: { type: 'string' } },
44
44
  { name: 'regionCode', in: 'query', required: false, description: 'CGV 지역 코드', schema: { type: 'string' } },
@@ -114,6 +114,13 @@ export const OPENAPI_PATHS_CU = {
114
114
  description: '재고 정렬 방식',
115
115
  schema: { type: 'string', default: 'recom' },
116
116
  },
117
+ {
118
+ name: 'storeCheck',
119
+ in: 'query',
120
+ required: false,
121
+ description: 'false이면 주변 매장 조회를 생략하고 재고 상품 검색만 수행',
122
+ schema: { type: 'boolean', default: true },
123
+ },
117
124
  ],
118
125
  responses: {
119
126
  '200': {
@@ -111,6 +111,20 @@ export const OPENAPI_PATHS_LOTTEMART = {
111
111
  description: '추가 조회할 최대 페이지 수',
112
112
  schema: { type: 'integer', default: 3, minimum: 1, maximum: 20 },
113
113
  },
114
+ {
115
+ name: 'source',
116
+ in: 'query',
117
+ required: false,
118
+ description: '상품 검색 경로',
119
+ schema: { type: 'string', enum: ['auto', 'legacy', 'zetta'], default: 'auto' },
120
+ },
121
+ {
122
+ name: 'timeoutMs',
123
+ in: 'query',
124
+ required: false,
125
+ description: '요청 제한 시간(ms)',
126
+ schema: { type: 'integer', default: 45000, minimum: 1 },
127
+ },
114
128
  ],
115
129
  responses: {
116
130
  '200': {