daiso 1.0.8 → 1.0.9
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 +83 -7
- package/dist/api/actionsProxy.js +5 -0
- package/dist/api/configStatus.js +8 -0
- package/dist/api/feedbackHandlers.js +60 -0
- package/dist/api/healthCheckDefinitions.js +170 -0
- package/dist/api/healthCheckShape.js +106 -0
- package/dist/api/healthCheckTypes.js +4 -0
- package/dist/api/healthChecks.js +3 -271
- package/dist/api/opinetHandlers.js +91 -0
- package/dist/api/routes/feedbackRoutes.js +5 -0
- package/dist/api/routes/healthRoutes.js +17 -1
- package/dist/api/routes/opinetRoutes.js +24 -0
- package/dist/bin.js +0 -0
- package/dist/cli/commandOptions.js +23 -2
- package/dist/cli/commands/apiCommand.js +24 -0
- package/dist/cli/commands/cinema.js +65 -0
- package/dist/cli/commands/convenience.js +57 -100
- package/dist/cli/http.js +1 -1
- package/dist/cli.js +10 -1
- package/dist/cliHelp.js +44 -8
- package/dist/core/outputSchema.js +141 -0
- package/dist/core/registry.js +1 -138
- package/dist/index.js +92 -29
- package/dist/pages/openapiFullSpec.js +5 -0
- package/dist/pages/openapiSpecActions.js +63 -0
- package/dist/pages/openapiSpecPathsFeedback.js +95 -0
- package/dist/pages/openapiSpecPathsOpinet.js +81 -0
- package/dist/pages/promptReferenceText.js +15 -0
- package/dist/services/feedback/client.js +109 -0
- package/dist/services/feedback/index.js +22 -0
- package/dist/services/feedback/tools/submitDeveloperRequest.js +54 -0
- package/dist/services/feedback/types.js +3 -0
- package/dist/services/gs25/client.js +1 -84
- package/dist/services/gs25/productSearch.js +97 -0
- package/dist/services/oliveyoung/client.js +1 -55
- package/dist/services/oliveyoung/normalize.js +58 -0
- package/dist/services/opinet/client.js +231 -0
- package/dist/services/opinet/index.js +26 -0
- package/dist/services/opinet/location.js +119 -0
- package/dist/services/opinet/tools.js +80 -0
- package/dist/services/opinet/types.js +1 -0
- package/openapi.json +82 -0
- package/openapi.yaml +55 -0
- package/package.json +18 -17
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
|
-
|
|
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
|
-
|
|
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: 올리브영 주변 매장 탐색
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { DEVELOPER_REQUEST_SEVERITIES, DEVELOPER_REQUEST_SOURCES, DEVELOPER_REQUEST_TYPES, } from './types.js';
|
|
2
|
+
function isOneOf(value, allowed) {
|
|
3
|
+
return Boolean(value && allowed.includes(value));
|
|
4
|
+
}
|
|
5
|
+
function trimText(value, maxLength) {
|
|
6
|
+
if (typeof value !== 'string') {
|
|
7
|
+
return undefined;
|
|
8
|
+
}
|
|
9
|
+
const trimmed = value.trim();
|
|
10
|
+
if (!trimmed) {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
return trimmed.length > maxLength ? trimmed.slice(0, maxLength) : trimmed;
|
|
14
|
+
}
|
|
15
|
+
function requireText(value, fieldName, maxLength) {
|
|
16
|
+
const text = trimText(value, maxLength);
|
|
17
|
+
if (!text) {
|
|
18
|
+
throw new Error(`${fieldName}를 입력해주세요.`);
|
|
19
|
+
}
|
|
20
|
+
return text;
|
|
21
|
+
}
|
|
22
|
+
function normalizeType(value) {
|
|
23
|
+
return isOneOf(value, DEVELOPER_REQUEST_TYPES) ? value : 'other';
|
|
24
|
+
}
|
|
25
|
+
function normalizeSeverity(value) {
|
|
26
|
+
return isOneOf(value, DEVELOPER_REQUEST_SEVERITIES) ? value : 'medium';
|
|
27
|
+
}
|
|
28
|
+
function normalizeSource(value) {
|
|
29
|
+
return isOneOf(value, DEVELOPER_REQUEST_SOURCES) ? value : undefined;
|
|
30
|
+
}
|
|
31
|
+
function createInsertRow(input) {
|
|
32
|
+
const row = {
|
|
33
|
+
type: normalizeType(trimText(input.type, 40)),
|
|
34
|
+
severity: normalizeSeverity(trimText(input.severity, 40)),
|
|
35
|
+
title: requireText(input.title, 'title', 160),
|
|
36
|
+
description: requireText(input.description, 'description', 4000),
|
|
37
|
+
status: 'open',
|
|
38
|
+
};
|
|
39
|
+
const service = trimText(input.service, 80);
|
|
40
|
+
const toolName = trimText(input.toolName, 120);
|
|
41
|
+
const reproduction = trimText(input.reproduction, 4000);
|
|
42
|
+
const expected = trimText(input.expected, 2000);
|
|
43
|
+
const actual = trimText(input.actual, 2000);
|
|
44
|
+
const source = normalizeSource(trimText(input.source, 40));
|
|
45
|
+
if (service)
|
|
46
|
+
row.service = service;
|
|
47
|
+
if (toolName)
|
|
48
|
+
row.tool_name = toolName;
|
|
49
|
+
if (reproduction)
|
|
50
|
+
row.reproduction = reproduction;
|
|
51
|
+
if (expected)
|
|
52
|
+
row.expected = expected;
|
|
53
|
+
if (actual)
|
|
54
|
+
row.actual = actual;
|
|
55
|
+
if (source)
|
|
56
|
+
row.source = source;
|
|
57
|
+
if (input.userContext && typeof input.userContext === 'object') {
|
|
58
|
+
row.user_context = input.userContext;
|
|
59
|
+
}
|
|
60
|
+
return row;
|
|
61
|
+
}
|
|
62
|
+
function requireConfig(config) {
|
|
63
|
+
const supabaseUrl = trimText(config.supabaseUrl, 400);
|
|
64
|
+
const key = trimText(config.supabaseServiceRoleKey, 4000);
|
|
65
|
+
if (!supabaseUrl || !key) {
|
|
66
|
+
throw new Error('SUPABASE_URL과 SUPABASE_SERVICE_ROLE_KEY가 필요합니다.');
|
|
67
|
+
}
|
|
68
|
+
return { supabaseUrl: supabaseUrl.replace(/\/+$/, ''), key };
|
|
69
|
+
}
|
|
70
|
+
function stringOrUndefined(value) {
|
|
71
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
72
|
+
}
|
|
73
|
+
function resultFromRow(row, fallback) {
|
|
74
|
+
return {
|
|
75
|
+
id: stringOrUndefined(row.id) || '',
|
|
76
|
+
type: normalizeType(stringOrUndefined(row.type) || fallback.type),
|
|
77
|
+
severity: normalizeSeverity(stringOrUndefined(row.severity) || fallback.severity),
|
|
78
|
+
title: stringOrUndefined(row.title) || fallback.title,
|
|
79
|
+
service: stringOrUndefined(row.service) || fallback.service,
|
|
80
|
+
toolName: stringOrUndefined(row.tool_name) || fallback.tool_name,
|
|
81
|
+
source: normalizeSource(stringOrUndefined(row.source)) || fallback.source,
|
|
82
|
+
status: stringOrUndefined(row.status) || fallback.status,
|
|
83
|
+
createdAt: stringOrUndefined(row.created_at),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
export async function submitDeveloperRequest(input, config) {
|
|
87
|
+
const { supabaseUrl, key } = requireConfig(config);
|
|
88
|
+
const row = createInsertRow(input);
|
|
89
|
+
const response = await fetch(`${supabaseUrl}/rest/v1/agent_requests`, {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: {
|
|
92
|
+
apikey: key,
|
|
93
|
+
Authorization: `Bearer ${key}`,
|
|
94
|
+
'Content-Type': 'application/json',
|
|
95
|
+
Prefer: 'return=representation',
|
|
96
|
+
},
|
|
97
|
+
body: JSON.stringify(row),
|
|
98
|
+
});
|
|
99
|
+
if (!response.ok) {
|
|
100
|
+
const detail = await response.text().catch(() => '');
|
|
101
|
+
throw new Error(`Supabase 저장 실패: ${response.status}${detail ? ` ${detail}` : ''}`);
|
|
102
|
+
}
|
|
103
|
+
const payload = (await response.json());
|
|
104
|
+
const returnedRow = Array.isArray(payload) ? payload[0] : payload;
|
|
105
|
+
if (!returnedRow || typeof returnedRow !== 'object') {
|
|
106
|
+
throw new Error('Supabase 저장 결과가 비어 있습니다.');
|
|
107
|
+
}
|
|
108
|
+
return resultFromRow(returnedRow, row);
|
|
109
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { createSubmitDeveloperRequestTool } from './tools/submitDeveloperRequest.js';
|
|
2
|
+
const FEEDBACK_METADATA = {
|
|
3
|
+
id: 'feedback',
|
|
4
|
+
name: '개발자 요청',
|
|
5
|
+
version: '1.0.0',
|
|
6
|
+
description: 'AI 에이전트가 MCP 오류, 개선 요청, 신규 기능 요청을 개발자에게 전달하는 서비스',
|
|
7
|
+
};
|
|
8
|
+
class FeedbackService {
|
|
9
|
+
config;
|
|
10
|
+
metadata = FEEDBACK_METADATA;
|
|
11
|
+
constructor(config = {}) {
|
|
12
|
+
this.config = config;
|
|
13
|
+
}
|
|
14
|
+
getTools() {
|
|
15
|
+
return [createSubmitDeveloperRequestTool(this.config)];
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function createFeedbackService(config = {}) {
|
|
19
|
+
return new FeedbackService(config);
|
|
20
|
+
}
|
|
21
|
+
export * from './client.js';
|
|
22
|
+
export * from './types.js';
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import * as z from 'zod';
|
|
2
|
+
import { submitDeveloperRequest } from '../client.js';
|
|
3
|
+
export function createSubmitDeveloperRequestTool(config) {
|
|
4
|
+
return {
|
|
5
|
+
name: 'submit_developer_request',
|
|
6
|
+
metadata: {
|
|
7
|
+
title: '개발자 요청 제출',
|
|
8
|
+
description: 'MCP 기능 오류, 개선 요청, 신규 기능 요청, 문서 문제를 개발자에게 전달하고 Supabase에 저장합니다.',
|
|
9
|
+
inputSchema: {
|
|
10
|
+
type: z
|
|
11
|
+
.enum(['bug', 'improvement', 'feature', 'docs', 'other'])
|
|
12
|
+
.optional()
|
|
13
|
+
.default('other')
|
|
14
|
+
.describe('요청 유형'),
|
|
15
|
+
severity: z
|
|
16
|
+
.enum(['low', 'medium', 'high', 'critical'])
|
|
17
|
+
.optional()
|
|
18
|
+
.default('medium')
|
|
19
|
+
.describe('영향도'),
|
|
20
|
+
title: z.string().describe('짧은 제목'),
|
|
21
|
+
description: z.string().describe('요청 상세 설명'),
|
|
22
|
+
service: z.string().optional().describe('관련 서비스 ID 또는 영역'),
|
|
23
|
+
toolName: z.string().optional().describe('관련 MCP 도구 이름'),
|
|
24
|
+
reproduction: z.string().optional().describe('재현 방법'),
|
|
25
|
+
expected: z.string().optional().describe('기대 결과'),
|
|
26
|
+
actual: z.string().optional().describe('실제 결과'),
|
|
27
|
+
source: z
|
|
28
|
+
.enum(['mcp', 'chatgpt', 'claude', 'cli', 'api', 'other'])
|
|
29
|
+
.optional()
|
|
30
|
+
.default('mcp')
|
|
31
|
+
.describe('요청 출처'),
|
|
32
|
+
userContext: z.object({}).loose().optional().describe('추가 컨텍스트 JSON'),
|
|
33
|
+
},
|
|
34
|
+
outputSchema: {
|
|
35
|
+
id: z.string().optional(),
|
|
36
|
+
type: z.string().optional(),
|
|
37
|
+
severity: z.string().optional(),
|
|
38
|
+
title: z.string().optional(),
|
|
39
|
+
service: z.string().optional(),
|
|
40
|
+
toolName: z.string().optional(),
|
|
41
|
+
source: z.string().optional(),
|
|
42
|
+
status: z.string().optional(),
|
|
43
|
+
createdAt: z.string().optional(),
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
handler: (async (args) => {
|
|
47
|
+
const result = await submitDeveloperRequest(args, config);
|
|
48
|
+
return {
|
|
49
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
50
|
+
structuredContent: { ...result },
|
|
51
|
+
};
|
|
52
|
+
}),
|
|
53
|
+
};
|
|
54
|
+
}
|