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.
- package/README.md +45 -7
- package/dist/api/configStatus.js +19 -0
- package/dist/api/gs25Handlers.js +25 -14
- package/dist/api/handlers.js +32 -3
- package/dist/api/healthChecks.js +185 -13
- package/dist/api/lottemartHandlers.js +17 -1
- package/dist/api/response.js +2 -0
- package/dist/api/routes/healthRoutes.js +20 -3
- package/dist/bin.js +0 -0
- package/dist/cli/args.js +21 -0
- package/dist/cli/commandOptions.js +38 -0
- package/dist/cli/commands/convenience.js +53 -1
- package/dist/cli/commands/daiso.js +18 -2
- package/dist/cli/http.js +58 -0
- package/dist/cli/interactiveItemSearch.js +4 -2
- package/dist/cli/interactivePrompt.js +3 -2
- package/dist/cli.js +22 -0
- package/dist/cliHelp.js +6 -0
- package/dist/cliInteractive.js +75 -6
- package/dist/cliPicker.js +1 -0
- package/dist/cliRenderer.js +86 -3
- package/dist/core/errors.js +66 -0
- package/dist/core/registry.js +227 -3
- package/dist/index.js +2 -1
- package/dist/pages/openapiSpecActions.js +3 -3
- package/dist/pages/openapiSpecPathsCu.js +7 -0
- package/dist/pages/openapiSpecPathsLotteMart.js +14 -0
- package/dist/pages/openapiSpecPathsOliveyoung.js +35 -0
- package/dist/pages/promptReferenceText.js +13 -7
- package/dist/pages/promptRetailCinemaText.js +2 -1
- package/dist/services/daiso/client.js +8 -0
- package/dist/services/daiso/index.js +2 -0
- package/dist/services/daiso/tools/checkInventory.js +4 -2
- package/dist/services/daiso/tools/findInventoryByName.js +222 -0
- package/dist/services/daiso/tools/getDisplayLocation.js +2 -2
- package/dist/services/emart24/client.js +9 -0
- package/dist/services/emart24/tools/searchProducts.js +27 -17
- package/dist/services/gs25/client.js +10 -0
- package/dist/services/gs25/tools/searchProducts.js +28 -21
- package/dist/services/lottemart/client.js +10 -1
- package/dist/services/lottemart/tools/searchProducts.js +8 -1
- package/dist/services/lottemart/zetta.js +1 -1
- package/dist/services/oliveyoung/client.js +124 -53
- package/dist/services/oliveyoung/tools/checkInventory.js +7 -2
- package/dist/services/seveneleven/client.js +7 -0
- package/dist/services/seveneleven/tools/searchProducts.js +29 -18
- package/dist/utils/http.js +82 -10
- package/dist/utils/zyte.js +47 -26
- package/openapi.json +3 -3
- package/openapi.yaml +3 -3
- package/package.json +26 -8
package/dist/cliInteractive.js
CHANGED
|
@@ -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
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
'
|
|
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];
|
package/dist/cliRenderer.js
CHANGED
|
@@ -29,7 +29,17 @@ function formatMeta(meta) {
|
|
|
29
29
|
return [`메타: ${pairs.join(', ')}`];
|
|
30
30
|
}
|
|
31
31
|
function detectName(item) {
|
|
32
|
-
const keys = [
|
|
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 ??
|
|
53
|
-
|
|
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
|
+
}
|
package/dist/core/registry.js
CHANGED
|
@@ -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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
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: '매장
|
|
41
|
-
{ name: 'storeName', in: 'query', required: false, description: '
|
|
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': {
|