daiso 1.0.8 → 1.0.10

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 (48) hide show
  1. package/README.md +96 -11
  2. package/dist/api/actionsProxy.js +5 -0
  3. package/dist/api/configStatus.js +8 -0
  4. package/dist/api/feedbackHandlers.js +60 -0
  5. package/dist/api/healthCheckDefinitions.js +188 -0
  6. package/dist/api/healthCheckShape.js +106 -0
  7. package/dist/api/healthCheckTypes.js +4 -0
  8. package/dist/api/healthChecks.js +13 -272
  9. package/dist/api/opinetHandlers.js +91 -0
  10. package/dist/api/routes/feedbackRoutes.js +5 -0
  11. package/dist/api/routes/healthRoutes.js +17 -1
  12. package/dist/api/routes/opinetRoutes.js +24 -0
  13. package/dist/bin.js +0 -0
  14. package/dist/cli/commandOptions.js +23 -2
  15. package/dist/cli/commands/apiCommand.js +24 -0
  16. package/dist/cli/commands/cinema.js +65 -0
  17. package/dist/cli/commands/convenience.js +57 -100
  18. package/dist/cli/http.js +1 -1
  19. package/dist/cli.js +10 -1
  20. package/dist/cliHelp.js +44 -8
  21. package/dist/core/outputSchema.js +141 -0
  22. package/dist/core/registry.js +1 -138
  23. package/dist/index.js +92 -29
  24. package/dist/pages/openapiFullSpec.js +5 -0
  25. package/dist/pages/openapiSpecActions.js +63 -0
  26. package/dist/pages/openapiSpecPathsFeedback.js +95 -0
  27. package/dist/pages/openapiSpecPathsOpinet.js +81 -0
  28. package/dist/pages/promptReferenceText.js +15 -0
  29. package/dist/services/cgv/client.js +7 -4
  30. package/dist/services/emart24/tools/searchProducts.js +37 -19
  31. package/dist/services/feedback/client.js +109 -0
  32. package/dist/services/feedback/index.js +22 -0
  33. package/dist/services/feedback/tools/submitDeveloperRequest.js +54 -0
  34. package/dist/services/feedback/types.js +3 -0
  35. package/dist/services/gs25/client.js +1 -84
  36. package/dist/services/gs25/productSearch.js +97 -0
  37. package/dist/services/oliveyoung/client.js +1 -55
  38. package/dist/services/oliveyoung/normalize.js +58 -0
  39. package/dist/services/opinet/client.js +231 -0
  40. package/dist/services/opinet/index.js +26 -0
  41. package/dist/services/opinet/location.js +119 -0
  42. package/dist/services/opinet/tools.js +80 -0
  43. package/dist/services/opinet/types.js +1 -0
  44. package/dist/services/places/client.js +29 -8
  45. package/dist/services/seveneleven/tools/searchProducts.js +36 -17
  46. package/openapi.json +82 -0
  47. package/openapi.yaml +55 -0
  48. package/package.json +24 -20
package/dist/index.js CHANGED
@@ -20,7 +20,9 @@ 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
22
  import { createPlacesService } from './services/places/index.js';
23
+ import { createOpinetService } from './services/opinet/index.js';
23
24
  import { createCompareService } from './services/compare/index.js';
25
+ import { createFeedbackService } from './services/feedback/index.js';
24
26
  import { createPromptResponse } from './pages/prompt.js';
25
27
  import { createFullOpenApiJsonResponse, createFullOpenApiYamlResponse, createOpenApiJsonResponse, createOpenApiYamlResponse, } from './pages/openapi.js';
26
28
  import { createPrivacyResponse } from './pages/privacy.js';
@@ -36,7 +38,9 @@ import { registerEmart24Routes } from './api/routes/emart24Routes.js';
36
38
  import { registerGs25Routes } from './api/routes/gs25Routes.js';
37
39
  import { registerSevenElevenRoutes } from './api/routes/sevenelevenRoutes.js';
38
40
  import { registerPlacesRoutes } from './api/routes/placesRoutes.js';
41
+ import { registerOpinetRoutes } from './api/routes/opinetRoutes.js';
39
42
  import { registerCompareRoutes } from './api/routes/compareRoutes.js';
43
+ import { registerFeedbackRoutes } from './api/routes/feedbackRoutes.js';
40
44
  import { registerHealthRoutes } from './api/routes/healthRoutes.js';
41
45
  import { buildConfigStatus } from './api/configStatus.js';
42
46
  // 서버 메타데이터
@@ -58,10 +62,18 @@ const createRegistry = (bindings) => {
58
62
  }),
59
63
  createSevenElevenService,
60
64
  createCompareService,
65
+ () => createFeedbackService({
66
+ supabaseUrl: bindings?.SUPABASE_URL,
67
+ supabaseServiceRoleKey: bindings?.SUPABASE_SERVICE_ROLE_KEY,
68
+ }),
61
69
  () => createPlacesService({
62
70
  naverClientId: bindings?.NAVER_CLIENT_ID,
63
71
  naverClientSecret: bindings?.NAVER_CLIENT_SECRET,
64
72
  }),
73
+ () => createOpinetService({
74
+ apiKey: bindings?.OPINET_API_KEY,
75
+ googleMapsApiKey: bindings?.GOOGLE_MAPS_API_KEY,
76
+ }),
65
77
  createCuService,
66
78
  createEmart24Service,
67
79
  () => createLotteMartService({
@@ -94,6 +106,53 @@ const createMcpServer = (bindings) => {
94
106
  registry.applyToServer(server);
95
107
  return server;
96
108
  };
109
+ function buildRootInfo() {
110
+ const registry = createRegistry();
111
+ const services = registry.getServicesInfo();
112
+ const allTools = registry.getAllToolNames();
113
+ return {
114
+ name: SERVER_NAME,
115
+ version: SERVER_VERSION,
116
+ description: 'Multi-Service MCP Server for Cloudflare Workers',
117
+ endpoints: {
118
+ mcp: '/ 또는 /mcp (POST) - MCP 프로토콜 엔드포인트',
119
+ health: '/health (GET) - 헬스 체크',
120
+ healthChecks: '/api/health/checks (GET) - 서비스별 상세 헬스 체크',
121
+ openapi: '/openapi.json (GET) - OpenAI Actions용 축약 OpenAPI',
122
+ openapiFull: '/openapi-full.json (GET) - 전체 OpenAPI',
123
+ actionsQuery: '/api/actions/query (GET) - 기존 GET API 통합 facade',
124
+ compareProducts: '/api/compare/products (GET) - 키 없는 통합 상품 가격 후보 비교',
125
+ opinetAverage: '/api/opinet/average (GET) - 오피넷 전국 평균 유가',
126
+ opinetLowest: '/api/opinet/lowest (GET) - 오피넷 최저가 주유소',
127
+ developerRequests: '/api/feedback/requests (POST/GET) - 개발자 요청 제출',
128
+ },
129
+ services,
130
+ tools: allTools,
131
+ totalServices: services.length,
132
+ totalTools: allTools.length,
133
+ };
134
+ }
135
+ const ROOT_INFO_JSON = JSON.stringify(buildRootInfo());
136
+ const ROOT_INFO_BODY = new TextEncoder().encode(ROOT_INFO_JSON);
137
+ const ROOT_INFO_HEADERS = {
138
+ 'Content-Type': 'application/json; charset=utf-8',
139
+ 'Content-Length': String(ROOT_INFO_BODY.byteLength),
140
+ 'Access-Control-Allow-Origin': '*',
141
+ 'Cache-Control': 'public, max-age=300, s-maxage=3600, stale-while-revalidate=86400',
142
+ };
143
+ function isRootInfoRequest(request) {
144
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
145
+ return false;
146
+ }
147
+ const url = new URL(request.url);
148
+ return url.pathname === '/' && url.search === '';
149
+ }
150
+ function createRootInfoResponse(method) {
151
+ return new Response(method === 'HEAD' ? null : ROOT_INFO_BODY, {
152
+ status: 200,
153
+ headers: ROOT_INFO_HEADERS,
154
+ });
155
+ }
97
156
  /**
98
157
  * initialize 요청 여부를 확인합니다.
99
158
  */
@@ -166,6 +225,24 @@ const handleMcpRequest = async (c) => {
166
225
  message: `세션이 없습니다. 먼저 POST /mcp initialize 요청 후 ${SESSION_HEADER} 헤더를 사용해주세요.`,
167
226
  }, 400);
168
227
  };
228
+ const handleRootMcpRequest = async (c) => {
229
+ if (c.req.header(SESSION_HEADER) || c.req.method === 'DELETE') {
230
+ return handleMcpRequest(c);
231
+ }
232
+ const parsedBody = await c.req.raw
233
+ .clone()
234
+ .json()
235
+ .catch(() => undefined);
236
+ if (isInitializeRequest(parsedBody)) {
237
+ const { server, transport } = createSessionTransport(c.env);
238
+ await server.connect(transport);
239
+ return transport.handleRequest(c.req.raw, { parsedBody });
240
+ }
241
+ const transport = new WebStandardStreamableHTTPServerTransport();
242
+ const server = createMcpServer(c.env);
243
+ await server.connect(transport);
244
+ return transport.handleRequest(c.req.raw, { parsedBody });
245
+ };
169
246
  // Hono 앱 생성
170
247
  const app = new Hono();
171
248
  // CORS 설정
@@ -184,35 +261,10 @@ app.use('*', cors({
184
261
  }));
185
262
  // 기본 정보 엔드포인트 (GET 요청만)
186
263
  app.get('/', (c) => {
187
- const registry = createRegistry(c.env);
188
- const services = registry.getServicesInfo();
189
- const allTools = registry.getAllToolNames();
190
- return c.json({
191
- name: SERVER_NAME,
192
- version: SERVER_VERSION,
193
- description: 'Multi-Service MCP Server for Cloudflare Workers',
194
- endpoints: {
195
- mcp: '/ 또는 /mcp (POST) - MCP 프로토콜 엔드포인트',
196
- health: '/health (GET) - 헬스 체크',
197
- healthChecks: '/api/health/checks (GET) - 서비스별 상세 헬스 체크',
198
- openapi: '/openapi.json (GET) - OpenAI Actions용 축약 OpenAPI',
199
- openapiFull: '/openapi-full.json (GET) - 전체 OpenAPI',
200
- actionsQuery: '/api/actions/query (GET) - 기존 GET API 통합 facade',
201
- compareProducts: '/api/compare/products (GET) - 키 없는 통합 상품 가격 후보 비교',
202
- },
203
- services,
204
- tools: allTools,
205
- totalServices: services.length,
206
- totalTools: allTools.length,
207
- });
208
- });
209
- // 루트 경로에서 MCP 요청 처리 (POST, DELETE, OPTIONS)
210
- app.on(['POST', 'DELETE', 'OPTIONS'], '/', async (c) => {
211
- const transport = new WebStandardStreamableHTTPServerTransport();
212
- const server = createMcpServer(c.env);
213
- await server.connect(transport);
214
- return transport.handleRequest(c.req.raw);
264
+ return c.body(ROOT_INFO_JSON, 200, ROOT_INFO_HEADERS);
215
265
  });
266
+ // 루트 경로에서 MCP 요청 처리 (POST, DELETE)
267
+ app.on(['POST', 'DELETE'], '/', handleRootMcpRequest);
216
268
  // 헬스 체크 엔드포인트
217
269
  app.get('/health', (c) => c.json({ status: 'ok', config: buildConfigStatus(c.env) }));
218
270
  registerHealthRoutes(app);
@@ -248,7 +300,9 @@ registerDaisoRoutes(app);
248
300
  registerGs25Routes(app);
249
301
  registerSevenElevenRoutes(app);
250
302
  registerCompareRoutes(app);
303
+ registerFeedbackRoutes(app);
251
304
  registerPlacesRoutes(app);
305
+ registerOpinetRoutes(app);
252
306
  registerCuRoutes(app);
253
307
  registerEmart24Routes(app);
254
308
  registerLotteMartRoutes(app);
@@ -279,4 +333,13 @@ app.get('/api/actions/query', async (c) => {
279
333
  });
280
334
  // MCP 엔드포인트
281
335
  app.all('/mcp', handleMcpRequest);
282
- export default app;
336
+ const worker = {
337
+ fetch(request, env, executionCtx) {
338
+ if (isRootInfoRequest(request)) {
339
+ return createRootInfoResponse(request.method);
340
+ }
341
+ return app.fetch(request, env, executionCtx);
342
+ },
343
+ request: app.request.bind(app),
344
+ };
345
+ export default worker;
@@ -11,7 +11,9 @@ 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
13
  import { OPENAPI_PATHS_PLACES } from './openapiSpecPathsPlaces.js';
14
+ import { OPENAPI_PATHS_OPINET } from './openapiSpecPathsOpinet.js';
14
15
  import { OPENAPI_PATHS_COMPARE } from './openapiSpecPathsCompare.js';
16
+ import { OPENAPI_PATHS_FEEDBACK } from './openapiSpecPathsFeedback.js';
15
17
  import { OPENAPI_COMPONENTS } from './openapiSpecComponents.js';
16
18
  export function generateFullOpenApiSpec(baseUrl) {
17
19
  return {
@@ -33,6 +35,7 @@ export function generateFullOpenApiSpec(baseUrl) {
33
35
  - 🎞️ **롯데시네마 지점/영화 조회**: 주변 지점, 상영 목록, 잔여 좌석 조회
34
36
  - 🎥 **CGV 지점/영화 조회**: 극장 목록, 영화 목록, 시간표 조회
35
37
  - 🍽️ **주변 음식점/카페 검색**: 네이버 지역 검색 기반 장소 조회
38
+ - ⛽ **오피넷 유가 정보**: 전국 평균 유가, 최저가 주유소, 반경 내 주유소 조회
36
39
 
37
40
  ## 사용 팁
38
41
  1. 한글 검색어는 URL 인코딩이 자동 처리됩니다
@@ -54,7 +57,9 @@ export function generateFullOpenApiSpec(baseUrl) {
54
57
  ...OPENAPI_PATHS_GS25,
55
58
  ...OPENAPI_PATHS_SEVENELEVEN,
56
59
  ...OPENAPI_PATHS_COMPARE,
60
+ ...OPENAPI_PATHS_FEEDBACK,
57
61
  ...OPENAPI_PATHS_PLACES,
62
+ ...OPENAPI_PATHS_OPINET,
58
63
  ...OPENAPI_PATHS_MEGABOX,
59
64
  ...OPENAPI_PATHS_LOTTECINEMA,
60
65
  ...OPENAPI_PATHS_CGV,
@@ -70,6 +70,69 @@ const ACTION_QUERY_PARAMETERS = [
70
70
  description: '통합 비교 서비스 목록(daiso,gs25,seveneleven,emart24)',
71
71
  schema: { type: 'string' },
72
72
  },
73
+ {
74
+ name: 'type',
75
+ in: 'query',
76
+ required: false,
77
+ description: '개발자 요청 유형(bug, improvement, feature, docs, other)',
78
+ schema: { type: 'string' },
79
+ },
80
+ {
81
+ name: 'severity',
82
+ in: 'query',
83
+ required: false,
84
+ description: '개발자 요청 영향도(low, medium, high, critical)',
85
+ schema: { type: 'string' },
86
+ },
87
+ {
88
+ name: 'title',
89
+ in: 'query',
90
+ required: false,
91
+ description: '개발자 요청 제목',
92
+ schema: { type: 'string' },
93
+ },
94
+ {
95
+ name: 'description',
96
+ in: 'query',
97
+ required: false,
98
+ description: '개발자 요청 상세 설명',
99
+ schema: { type: 'string' },
100
+ },
101
+ {
102
+ name: 'toolName',
103
+ in: 'query',
104
+ required: false,
105
+ description: '문제가 발생했거나 개선이 필요한 MCP 도구 이름',
106
+ schema: { type: 'string' },
107
+ },
108
+ {
109
+ name: 'reproduction',
110
+ in: 'query',
111
+ required: false,
112
+ description: '오류 재현 방법',
113
+ schema: { type: 'string' },
114
+ },
115
+ {
116
+ name: 'expected',
117
+ in: 'query',
118
+ required: false,
119
+ description: '기대 결과',
120
+ schema: { type: 'string' },
121
+ },
122
+ {
123
+ name: 'actual',
124
+ in: 'query',
125
+ required: false,
126
+ description: '실제 결과',
127
+ schema: { type: 'string' },
128
+ },
129
+ {
130
+ name: 'source',
131
+ in: 'query',
132
+ required: false,
133
+ description: '요청 출처(mcp, chatgpt, claude, cli, api, other)',
134
+ schema: { type: 'string' },
135
+ },
73
136
  {
74
137
  name: 'sido',
75
138
  in: 'query',
@@ -0,0 +1,95 @@
1
+ const REQUEST_PROPERTIES = {
2
+ type: {
3
+ type: 'string',
4
+ enum: ['bug', 'improvement', 'feature', 'docs', 'other'],
5
+ default: 'other',
6
+ },
7
+ severity: {
8
+ type: 'string',
9
+ enum: ['low', 'medium', 'high', 'critical'],
10
+ default: 'medium',
11
+ },
12
+ title: { type: 'string' },
13
+ description: { type: 'string' },
14
+ service: { type: 'string' },
15
+ toolName: { type: 'string' },
16
+ reproduction: { type: 'string' },
17
+ expected: { type: 'string' },
18
+ actual: { type: 'string' },
19
+ source: {
20
+ type: 'string',
21
+ enum: ['mcp', 'chatgpt', 'claude', 'cli', 'api', 'other'],
22
+ },
23
+ };
24
+ const RESPONSE_SCHEMA = {
25
+ type: 'object',
26
+ properties: {
27
+ success: { type: 'boolean' },
28
+ data: {
29
+ type: 'object',
30
+ properties: {
31
+ id: { type: 'string' },
32
+ type: { type: 'string' },
33
+ severity: { type: 'string' },
34
+ title: { type: 'string' },
35
+ service: { type: 'string' },
36
+ toolName: { type: 'string' },
37
+ source: { type: 'string' },
38
+ status: { type: 'string' },
39
+ createdAt: { type: 'string' },
40
+ },
41
+ },
42
+ },
43
+ };
44
+ export const OPENAPI_PATHS_FEEDBACK = {
45
+ '/api/feedback/requests': {
46
+ post: {
47
+ operationId: 'submitDeveloperRequest',
48
+ summary: '개발자 요청 제출',
49
+ description: 'MCP 기능 오류, 개선 요청, 신규 기능 요청, 문서 문제를 개발자에게 전달하고 Supabase에 저장합니다.',
50
+ requestBody: {
51
+ required: true,
52
+ content: {
53
+ 'application/json': {
54
+ schema: {
55
+ type: 'object',
56
+ required: ['title', 'description'],
57
+ properties: REQUEST_PROPERTIES,
58
+ },
59
+ },
60
+ },
61
+ },
62
+ responses: {
63
+ '200': {
64
+ description: '개발자 요청 저장 결과',
65
+ content: {
66
+ 'application/json': {
67
+ schema: RESPONSE_SCHEMA,
68
+ },
69
+ },
70
+ },
71
+ },
72
+ },
73
+ get: {
74
+ operationId: 'submitDeveloperRequestViaQuery',
75
+ summary: '개발자 요청 제출(GET)',
76
+ description: 'OpenAI Actions facade 등 GET만 가능한 환경에서 개발자 요청을 제출합니다.',
77
+ parameters: Object.entries(REQUEST_PROPERTIES).map(([name, schema]) => ({
78
+ name,
79
+ in: 'query',
80
+ required: name === 'title' || name === 'description',
81
+ schema,
82
+ })),
83
+ responses: {
84
+ '200': {
85
+ description: '개발자 요청 저장 결과',
86
+ content: {
87
+ 'application/json': {
88
+ schema: RESPONSE_SCHEMA,
89
+ },
90
+ },
91
+ },
92
+ },
93
+ },
94
+ },
95
+ };
@@ -0,0 +1,81 @@
1
+ /**
2
+ * OpenAPI 경로 정의 (오피넷)
3
+ */
4
+ const fuelCodeSchema = {
5
+ type: 'string',
6
+ enum: ['B027', 'D047', 'B034', 'C004', 'K015'],
7
+ default: 'B027',
8
+ };
9
+ const successResponse = {
10
+ description: '조회 성공',
11
+ content: {
12
+ 'application/json': {
13
+ schema: {
14
+ type: 'object',
15
+ properties: {
16
+ success: { type: 'boolean' },
17
+ data: { type: 'object' },
18
+ },
19
+ },
20
+ },
21
+ },
22
+ };
23
+ export const OPENAPI_PATHS_OPINET = {
24
+ '/api/opinet/average': {
25
+ get: {
26
+ operationId: 'opinetGetAveragePrices',
27
+ summary: '오피넷 전국 평균 유가',
28
+ description: '한국석유공사 오피넷 전국 평균 유가를 조회합니다.',
29
+ parameters: [],
30
+ responses: {
31
+ '200': successResponse,
32
+ '500': {
33
+ description: '오피넷 키 누락 또는 API 호출 실패',
34
+ content: { 'application/json': { schema: { $ref: '#/components/schemas/ErrorResponse' } } },
35
+ },
36
+ },
37
+ },
38
+ },
39
+ '/api/opinet/lowest': {
40
+ get: {
41
+ operationId: 'opinetGetLowestPriceStations',
42
+ summary: '오피넷 최저가 주유소',
43
+ description: '전국 또는 지역별 최저가 주유소를 유종별로 조회합니다.',
44
+ parameters: [
45
+ { name: 'fuelCode', in: 'query', required: false, description: '유종 코드', schema: fuelCodeSchema },
46
+ { name: 'areaCode', in: 'query', required: false, description: '오피넷 지역 코드', schema: { type: 'string' } },
47
+ { name: 'count', in: 'query', required: false, description: '결과 수(1~20)', schema: { type: 'integer', minimum: 1, maximum: 20, default: 10 } },
48
+ ],
49
+ responses: { '200': successResponse },
50
+ },
51
+ },
52
+ '/api/opinet/stations/around': {
53
+ get: {
54
+ operationId: 'opinetSearchStationsAround',
55
+ summary: '오피넷 반경 내 주유소',
56
+ description: 'KATEC x/y, WGS84 위경도, 또는 location 키워드 기준 반경 내 주유소를 조회합니다.',
57
+ parameters: [
58
+ { name: 'x', in: 'query', required: false, description: '기준 위치 X좌표(KATEC)', schema: { type: 'number' } },
59
+ { name: 'y', in: 'query', required: false, description: '기준 위치 Y좌표(KATEC)', schema: { type: 'number' } },
60
+ { name: 'lat', in: 'query', required: false, description: '위도(WGS84)', schema: { type: 'number' } },
61
+ { name: 'lng', in: 'query', required: false, description: '경도(WGS84)', schema: { type: 'number' } },
62
+ { name: 'location', in: 'query', required: false, description: '장소/주소 키워드. 예: 강남역', schema: { type: 'string' } },
63
+ { name: 'radiusMeters', in: 'query', required: false, description: '검색 반경 m(100~5000)', schema: { type: 'integer', minimum: 100, maximum: 5000, default: 3000 } },
64
+ { name: 'fuelCode', in: 'query', required: false, description: '유종 코드', schema: fuelCodeSchema },
65
+ { name: 'sort', in: 'query', required: false, description: '정렬 기준', schema: { type: 'string', enum: ['price', 'distance'], default: 'price' } },
66
+ ],
67
+ responses: { '200': successResponse },
68
+ },
69
+ },
70
+ '/api/opinet/station': {
71
+ get: {
72
+ operationId: 'opinetGetStationDetail',
73
+ summary: '오피넷 주유소 상세정보',
74
+ description: '오피넷 주유소 ID로 상세 정보, 부가시설, 유종별 가격을 조회합니다.',
75
+ parameters: [
76
+ { name: 'id', in: 'query', required: true, description: '오피넷 주유소 ID', schema: { type: 'string' } },
77
+ ],
78
+ responses: { '200': successResponse },
79
+ },
80
+ },
81
+ };
@@ -58,6 +58,20 @@ export function buildPromptReferenceText(baseUrl) {
58
58
  | LOTTEMART_PRODUCT_SEARCH_FAILED | 롯데마트 상품 조회 실패 |
59
59
  | SEVENELEVEN_INVENTORY_CHECK_FAILED | 세븐일레븐 재고 조회 실패 |
60
60
  | PLACES_SEARCH_FAILED | 주변 장소 검색 실패 |
61
+ | DEVELOPER_REQUEST_SUBMIT_FAILED | 개발자 요청 저장 실패 |
62
+
63
+ ---
64
+
65
+ ## 에이전트 실행 레시피
66
+
67
+ - "콜라 어디가 싸?", "컵라면 가격 비교해줘": 먼저 \`compare_products\` 또는 /api/compare/products를 호출합니다. 실제 재고와 매장 행사가가 필요하면 최저가 후보의 서비스별 재고/상품 도구로 한 번 더 확인합니다.
68
+ - "강남역 근처 카페", "성수동 브런치 음식점": 브랜드를 되묻지 말고 \`places_search_nearby\` 또는 /api/places/search를 호출합니다. 이 검색은 키워드 기반이므로 좌표 반경 결과처럼 단정하지 않습니다.
69
+ - "다이소 핫식스 재고", "올리브영 두바이초콜릿 있어?": 상품이 브랜드와 어색해 보여도 브랜드를 바꾸지 말고 먼저 다이소로 검색하거나 명시된 서비스로 검색합니다. 결과가 없을 때만 다른 서비스 대안을 제안합니다.
70
+ - "GS25 강남 오감자 재고": 상품명과 위치를 분리해 상품 후보를 먼저 찾고, 선택한 itemCode나 keyword와 storeKeyword로 재고를 확인합니다.
71
+ - "오늘 강남 CGV 시간표": 오늘 날짜는 KST 기준 YYYYMMDD로 계산하고, theaterCode가 없으면 극장 검색 후 시간표를 조회합니다.
72
+ - MCP 기능이 제대로 동작하지 않거나 개선/신규 기능 요청이 있으면 \`submit_developer_request\` 또는 /api/feedback/requests를 사용해 개발자에게 전달합니다. 제목과 설명은 필수입니다.
73
+ - 도구 호출이 실패하면 같은 도구를 한 번 더 좁은 조건으로 재시도합니다. 두 번 실패하면 실패한 서비스와 요청 조건을 밝히고 추측으로 결과를 만들지 않습니다.
74
+ - 결과에 imageUrl이 있으면 각 항목 이미지 표시를 우선합니다. 쿼리 문자열이 있는 이미지 URL도 그대로 사용합니다.
61
75
 
62
76
  ---
63
77
 
@@ -125,6 +139,7 @@ MCP 연결 정보: ${baseUrl}/mcp
125
139
  - seveneleven_get_search_popwords: 세븐일레븐 인기 검색어 조회
126
140
  - seveneleven_get_catalog_snapshot: 세븐일레븐 카탈로그 조회
127
141
  - compare_products: 키 없는 통합 상품 가격 후보 비교
142
+ - submit_developer_request: MCP 오류, 개선 요청, 신규 기능 요청을 개발자에게 전달
128
143
  - places_search_nearby: 음식점/카페 등 주변 장소 검색
129
144
  - oliveyoung_search_products: 올리브영 상품 검색
130
145
  - oliveyoung_find_nearby_stores: 올리브영 주변 매장 탐색
@@ -6,6 +6,9 @@ import { CGV_API } from './api.js';
6
6
  import { requestCgv } from './transport.js';
7
7
  const DEFAULT_THEATER_CODE = '0056';
8
8
  const MAX_FALLBACK_THEATERS = 5;
9
+ function asArray(value) {
10
+ return Array.isArray(value) ? value : [];
11
+ }
9
12
  async function resolveTheaterCode(playDate, theaterCode, params) {
10
13
  if (theaterCode) {
11
14
  return theaterCode;
@@ -25,7 +28,7 @@ async function fetchMoviesByTheaterCode(playDate, theaterCode, params) {
25
28
  scnYmd: playDate,
26
29
  });
27
30
  const response = await requestCgv(CGV_API.MOVIE_LIST_PATH, searchParams, params.timeout, params.zyteApiKey);
28
- return (response.data || [])
31
+ return asArray(response.data)
29
32
  .filter((item) => item.movNo && item.movNm)
30
33
  .map((item) => ({
31
34
  movieCode: item.movNo,
@@ -42,7 +45,7 @@ async function fetchTimetableByMovieCode(playDate, theaterCode, movieCode, param
42
45
  rtctlScopCd: CGV_API.TIMETABLE_SCOPE_CODE,
43
46
  });
44
47
  const response = await requestCgv(CGV_API.TIMETABLE_PATH, searchParams, params.timeout, params.zyteApiKey);
45
- return (response.data || [])
48
+ return asArray(response.data)
46
49
  .filter((item) => item.siteNo && item.movNo && item.scnYmd)
47
50
  .map((item) => ({
48
51
  scheduleId: `${item.scnYmd}${item.siteNo}${item.scnSseq || ''}`,
@@ -65,7 +68,7 @@ async function fetchTimetableBySite(playDate, theaterCode, params) {
65
68
  rtctlScopCd: CGV_API.TIMETABLE_SITE_SCOPE_CODE,
66
69
  });
67
70
  const response = await requestCgv(CGV_API.TIMETABLE_BY_SITE_PATH, searchParams, params.timeout, params.zyteApiKey);
68
- return (response.data || [])
71
+ return asArray(response.data)
69
72
  .filter((item) => item.siteNo && item.scnYmd)
70
73
  .map((item) => ({
71
74
  scheduleId: `${item.scnYmd}${item.siteNo}${item.scnSseq || ''}`,
@@ -85,7 +88,7 @@ export async function fetchCgvTheaters(params) {
85
88
  coCd: CGV_API.COMPANY_CODE,
86
89
  });
87
90
  const response = await requestCgv(CGV_API.THEATER_LIST_PATH, searchParams, params.timeout, params.zyteApiKey);
88
- const list = (response.data || []).flatMap((region) => (region.siteList || []).map((site) => ({
91
+ const list = asArray(response.data).flatMap((region) => asArray(region.siteList).map((site) => ({
89
92
  theaterCode: site.siteNo || '',
90
93
  theaterName: site.siteNm || '',
91
94
  regionCode: region.regnGrpCd || undefined,
@@ -14,25 +14,41 @@ async function searchProducts(args) {
14
14
  if (!keyword || keyword.trim().length === 0) {
15
15
  throw new Error('상품 검색어(keyword)를 입력해주세요.');
16
16
  }
17
- const result = await searchEmart24Products({
18
- keyword,
19
- page,
20
- pageSize,
21
- sortType,
22
- saleProductYn,
23
- }, {
24
- timeout: timeoutMs,
25
- });
26
- return buildTextResponse({
27
- keyword,
28
- page,
29
- pageSize,
30
- sortType,
31
- saleProductYn,
32
- totalCount: result.totalCount,
33
- count: result.products.length,
34
- products: result.products,
35
- });
17
+ try {
18
+ const result = await searchEmart24Products({
19
+ keyword,
20
+ page,
21
+ pageSize,
22
+ sortType,
23
+ saleProductYn,
24
+ }, {
25
+ timeout: timeoutMs,
26
+ });
27
+ return buildTextResponse({
28
+ keyword,
29
+ page,
30
+ pageSize,
31
+ sortType,
32
+ saleProductYn,
33
+ totalCount: result.totalCount,
34
+ count: result.products.length,
35
+ products: result.products,
36
+ });
37
+ }
38
+ catch (error) {
39
+ return buildTextResponse({
40
+ keyword,
41
+ page,
42
+ pageSize,
43
+ sortType,
44
+ saleProductYn,
45
+ totalCount: 0,
46
+ count: 0,
47
+ products: [],
48
+ status: 'degraded',
49
+ message: error instanceof Error ? error.message : '이마트24 상품 검색에 실패했습니다.',
50
+ });
51
+ }
36
52
  }
37
53
  const searchProductsOutputSchema = {
38
54
  keyword: z.string().describe('검색어'),
@@ -43,6 +59,8 @@ const searchProductsOutputSchema = {
43
59
  totalCount: z.number().describe('검색된 전체 상품 수'),
44
60
  count: z.number().describe('반환된 상품 수'),
45
61
  products: z.array(z.unknown()).describe('이마트24 상품 검색 결과'),
62
+ status: z.string().optional().describe('외부 API 차단 등으로 결과가 제한된 경우 degraded'),
63
+ message: z.string().optional().describe('degraded 상태의 원인 메시지'),
46
64
  };
47
65
  export function createSearchProductsTool() {
48
66
  return {