@sayren/mcp 0.1.0
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/dist/index.mjs +361 -0
- package/package.json +42 -0
- package/template/.env +2 -0
- package/template/README.md +42 -0
- package/template/app/app.css +22 -0
- package/template/app/components/product-card.tsx +45 -0
- package/template/app/components/product-description.tsx +28 -0
- package/template/app/components/site-header.tsx +25 -0
- package/template/app/lib/api.server.ts +34 -0
- package/template/app/lib/api.ts +7 -0
- package/template/app/lib/cart-session.server.ts +18 -0
- package/template/app/lib/checkout-intent.ts +30 -0
- package/template/app/lib/config.server.ts +19 -0
- package/template/app/lib/config.test.ts +16 -0
- package/template/app/lib/config.ts +19 -0
- package/template/app/lib/format.ts +10 -0
- package/template/app/lib/payment-popup.ts +98 -0
- package/template/app/lib/session.server.ts +24 -0
- package/template/app/root.tsx +57 -0
- package/template/app/routes/cart.tsx +161 -0
- package/template/app/routes/checkout-complete.tsx +66 -0
- package/template/app/routes/checkout.tsx +251 -0
- package/template/app/routes/home.tsx +67 -0
- package/template/app/routes/login.tsx +52 -0
- package/template/app/routes/order-detail.tsx +76 -0
- package/template/app/routes/orders.tsx +48 -0
- package/template/app/routes/product-detail.tsx +153 -0
- package/template/app/routes/products.tsx +117 -0
- package/template/app/routes.ts +17 -0
- package/template/package.json +37 -0
- package/template/react-router.config.ts +6 -0
- package/template/tsconfig.json +16 -0
- package/template/vite.config.ts +7 -0
- package/template/vitest.config.ts +5 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
3
|
+
import { readdir } from "node:fs/promises";
|
|
4
|
+
import { dirname, join, relative, sep } from "node:path";
|
|
5
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
6
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
//#region src/config.ts
|
|
11
|
+
const DEFAULT_API_ORIGIN = "https://api.sayren.app";
|
|
12
|
+
function loadConfig(env = process.env) {
|
|
13
|
+
const token = env.SAYREN_TOKEN?.trim();
|
|
14
|
+
if (!token) throw new Error("SAYREN_TOKEN이 없어요. 셀러 콘솔 개발자 공간 › API 토큰에서 발급한 토큰을 MCP 설정의 env에 넣어주세요");
|
|
15
|
+
const origin = (env.SAYREN_API_ORIGIN ?? DEFAULT_API_ORIGIN).replace(/\/+$/, "");
|
|
16
|
+
return {
|
|
17
|
+
adminBaseUrl: `${origin}/v1`,
|
|
18
|
+
storefrontBaseUrl: `${origin}/storefront/v1`,
|
|
19
|
+
token
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
//#endregion
|
|
24
|
+
//#region src/rules.ts
|
|
25
|
+
const RULES = [
|
|
26
|
+
{
|
|
27
|
+
id: "server-only-env",
|
|
28
|
+
title: "`process.env`는 `*.server.ts`에만 둔다",
|
|
29
|
+
why: "브라우저 번들에 들어가면 `process is not defined`로 그 화면 모듈이 통째로 깨진다. 결제 화면에서 터지면 결제가 시작조차 안 된다"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: "tenant-header",
|
|
33
|
+
title: "테넌트는 `X-Store-Code` 헤더로 보낸다",
|
|
34
|
+
why: "공유 API 호스트에서 스토어를 가르는 값이다. 빠지면 다른 스토어 데이터가 보이거나 404가 난다"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: "no-module-token",
|
|
38
|
+
title: "토큰을 모듈 전역에 담지 않는다",
|
|
39
|
+
why: "서버 렌더는 요청을 공유한다. 전역에 담으면 다른 구매자의 요청에 토큰이 섞인다"
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: "loader-first",
|
|
43
|
+
title: "데이터는 loader에서 받는다",
|
|
44
|
+
why: "클라이언트에서 다시 받으면 첫 화면이 비고 검색 노출이 죽는다. SSR을 끄지 않는다"
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: "popup-sync-open",
|
|
48
|
+
title: "결제 팝업은 클릭 콜스택에서 동기적으로 연다",
|
|
49
|
+
why: "API 응답을 기다린 뒤 열면 브라우저가 팝업을 막는다. 빈 창을 먼저 열고 주소를 넣는다"
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: "popup-client-submit",
|
|
53
|
+
title: "주문서 제출은 클라이언트 제출(`<Form>`)로 한다",
|
|
54
|
+
why: "네이티브 폼은 문서를 다시 그려 팝업 제어 상태가 사라진다. 결제창이 빈 창으로 남는다"
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: "parent-origin",
|
|
58
|
+
title: "결제 요청에 우리 화면의 origin을 실어 보낸다",
|
|
59
|
+
why: "결제 팝업은 그 값으로만 부모 창에 결과를 알린다. 빠지면 결제는 되는데 주문서가 완료를 못 받는다"
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: "popup-origin-check",
|
|
63
|
+
title: "팝업 메시지는 origin과 source를 모두 검증한다",
|
|
64
|
+
why: "검증 없이 받으면 아무 창이나 결제 완료를 흉내 낼 수 있다"
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: "closed-not-failed",
|
|
68
|
+
title: "팝업이 결과 없이 닫히면 실패로 단정하지 않는다",
|
|
69
|
+
why: "이미 승인됐을 수 있다. 서버에 결제 상태를 물어봐야 이중 결제와 누락 주문을 막는다"
|
|
70
|
+
}
|
|
71
|
+
];
|
|
72
|
+
/** 주석을 걷어낸다 — 규칙을 설명하는 주석이 위반으로 잡히지 않게 한다 */
|
|
73
|
+
function stripComments(source) {
|
|
74
|
+
return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/(^|[^:])\/\/[^\n]*/g, "$1 ");
|
|
75
|
+
}
|
|
76
|
+
const CLIENT_FILE = /\.(ts|tsx)$/;
|
|
77
|
+
const SERVER_FILE = /\.server\.(ts|tsx)$/;
|
|
78
|
+
/**
|
|
79
|
+
* 만들어진 프로젝트의 소스를 규칙과 대조한다. 정적 검사라 모든 문제를 잡지는 못하지만,
|
|
80
|
+
* 이 아홉 가지는 실제로 반복해서 깨지는 것들이다.
|
|
81
|
+
*/
|
|
82
|
+
function verifySources(files) {
|
|
83
|
+
const findings = [];
|
|
84
|
+
const cleaned = files.map((file) => ({
|
|
85
|
+
path: file.path,
|
|
86
|
+
content: stripComments(file.content)
|
|
87
|
+
}));
|
|
88
|
+
const has = (needle) => cleaned.some((file) => file.content.includes(needle));
|
|
89
|
+
for (const file of cleaned) {
|
|
90
|
+
if (!CLIENT_FILE.test(file.path)) continue;
|
|
91
|
+
if (!(SERVER_FILE.test(file.path) || /\/(server|\.server)\//.test(file.path)) && /process\.env\.[A-Z_]/.test(file.content)) findings.push({
|
|
92
|
+
ruleId: "server-only-env",
|
|
93
|
+
file: file.path,
|
|
94
|
+
detail: "브라우저로 갈 수 있는 파일에서 process.env를 읽는다. `*.server.ts`로 옮긴다"
|
|
95
|
+
});
|
|
96
|
+
if (/createStorefrontClient\(/.test(file.content) && !/storeCode/.test(file.content)) findings.push({
|
|
97
|
+
ruleId: "tenant-header",
|
|
98
|
+
file: file.path,
|
|
99
|
+
detail: "API 클라이언트를 만들 때 storeCode를 주지 않는다"
|
|
100
|
+
});
|
|
101
|
+
if (/^(const|let)\s+\w*[Tt]oken\s*=\s*["'`]/m.test(file.content)) findings.push({
|
|
102
|
+
ruleId: "no-module-token",
|
|
103
|
+
file: file.path,
|
|
104
|
+
detail: "토큰처럼 보이는 값을 모듈 전역에 두었다"
|
|
105
|
+
});
|
|
106
|
+
if (/window\.open\(/.test(file.content) && /await .*\n?.*window\.open\(/.test(file.content)) findings.push({
|
|
107
|
+
ruleId: "popup-sync-open",
|
|
108
|
+
file: file.path,
|
|
109
|
+
detail: "await 뒤에 팝업을 연다. 브라우저가 막는다"
|
|
110
|
+
});
|
|
111
|
+
if (/addEventListener\("message"/.test(file.content)) {
|
|
112
|
+
if (!/event\.origin|\.origin\s*!==/.test(file.content)) findings.push({
|
|
113
|
+
ruleId: "popup-origin-check",
|
|
114
|
+
file: file.path,
|
|
115
|
+
detail: "message 리스너가 origin을 검증하지 않는다"
|
|
116
|
+
});
|
|
117
|
+
if (!/event\.source|\.source\s*!==/.test(file.content)) findings.push({
|
|
118
|
+
ruleId: "popup-origin-check",
|
|
119
|
+
file: file.path,
|
|
120
|
+
detail: "message 리스너가 source(우리가 연 창)를 검증하지 않는다"
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
if (/requestPayment\(/.test(file.content) && /<form\s/.test(file.content)) findings.push({
|
|
124
|
+
ruleId: "popup-client-submit",
|
|
125
|
+
file: file.path,
|
|
126
|
+
detail: "결제 화면이 네이티브 <form>을 쓴다. react-router의 <Form>으로 바꾼다"
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
if (has("requestPayment(") && !has("Origin")) findings.push({
|
|
130
|
+
ruleId: "parent-origin",
|
|
131
|
+
file: "(프로젝트 전체)",
|
|
132
|
+
detail: "결제 요청에 Origin 헤더를 싣는 코드가 없다"
|
|
133
|
+
});
|
|
134
|
+
if (has("requestPayment(") && !has("getStatus(")) findings.push({
|
|
135
|
+
ruleId: "closed-not-failed",
|
|
136
|
+
file: "(프로젝트 전체)",
|
|
137
|
+
detail: "팝업이 결과 없이 닫힌 경우 결제 상태를 조회하는 코드가 없다"
|
|
138
|
+
});
|
|
139
|
+
if (has("createStorefrontClient(") && !has("loader")) findings.push({
|
|
140
|
+
ruleId: "loader-first",
|
|
141
|
+
file: "(프로젝트 전체)",
|
|
142
|
+
detail: "loader에서 데이터를 받는 코드가 없다. 클라이언트 전용으로 그리면 SSR이 죽는다"
|
|
143
|
+
});
|
|
144
|
+
return findings;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
//#endregion
|
|
148
|
+
//#region src/store-context.ts
|
|
149
|
+
async function callApi(url, init = {}) {
|
|
150
|
+
const response = await fetch(url, init);
|
|
151
|
+
const body = await response.json().catch(() => null);
|
|
152
|
+
if (!response.ok) {
|
|
153
|
+
const code = body?.error?.code ?? String(response.status);
|
|
154
|
+
const message = body?.error?.message ?? "요청이 실패했어요";
|
|
155
|
+
throw new Error(`${code}: ${message}`);
|
|
156
|
+
}
|
|
157
|
+
return body?.data ?? body;
|
|
158
|
+
}
|
|
159
|
+
async function fetchStoreContext(config$1) {
|
|
160
|
+
const authHeaders = { authorization: `Bearer ${config$1.token}` };
|
|
161
|
+
const store = await callApi(`${config$1.adminBaseUrl}/store`, { headers: authHeaders });
|
|
162
|
+
const storeHeaders = { "x-store-code": store.storeCode };
|
|
163
|
+
const [categories, products, paymentSettings] = await Promise.all([
|
|
164
|
+
callApi(`${config$1.storefrontBaseUrl}/categories`, { headers: storeHeaders }),
|
|
165
|
+
callApi(`${config$1.storefrontBaseUrl}/products?size=5`, { headers: storeHeaders }),
|
|
166
|
+
callApi(`${config$1.adminBaseUrl}/store/payment-settings`, { headers: authHeaders }).catch(() => null)
|
|
167
|
+
]);
|
|
168
|
+
const connected = (paymentSettings?.providers ?? []).filter((p) => p.connected && p.enabled);
|
|
169
|
+
return {
|
|
170
|
+
storeId: store.storeId,
|
|
171
|
+
storeCode: store.storeCode,
|
|
172
|
+
name: store.name,
|
|
173
|
+
logoUrl: store.logoUrl,
|
|
174
|
+
storefrontUrl: store.storefrontUrl,
|
|
175
|
+
storefrontApiBaseUrl: config$1.storefrontBaseUrl,
|
|
176
|
+
categories: categories.map((category) => ({
|
|
177
|
+
categoryId: category.categoryId,
|
|
178
|
+
name: category.name
|
|
179
|
+
})),
|
|
180
|
+
productCount: products.totalElements,
|
|
181
|
+
sampleProducts: products.contents,
|
|
182
|
+
payment: {
|
|
183
|
+
connectedProviders: connected.map((p) => p.provider),
|
|
184
|
+
sandboxOnly: connected.length === 0 || connected.every((p) => p.sandboxMode)
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
//#endregion
|
|
190
|
+
//#region src/template.ts
|
|
191
|
+
/**
|
|
192
|
+
* 스토어프론트 템플릿 파일 제공.
|
|
193
|
+
*
|
|
194
|
+
* 템플릿은 저장소 안에 있는 **실제로 도는 앱**이다. CI가 이 앱을 빌드·테스트하므로,
|
|
195
|
+
* 에이전트가 받아 쓰는 코드가 항상 동작하는 상태다. 즉석에서 지어낸 코드와 다른 점이 여기다.
|
|
196
|
+
*/
|
|
197
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
198
|
+
/** 번들에 복사된 template/ 이 먼저이고, 저장소에서 바로 실행할 때는 templates/ 를 쓴다 */
|
|
199
|
+
const CANDIDATES = [join(HERE, "..", "template"), join(HERE, "..", "..", "..", "templates", "storefront-react-router")];
|
|
200
|
+
const SKIP_DIRS = new Set([
|
|
201
|
+
"node_modules",
|
|
202
|
+
"build",
|
|
203
|
+
".react-router",
|
|
204
|
+
"dist",
|
|
205
|
+
".git"
|
|
206
|
+
]);
|
|
207
|
+
function resolveRoot() {
|
|
208
|
+
for (const candidate of CANDIDATES) try {
|
|
209
|
+
if (statSync(candidate).isDirectory()) return candidate;
|
|
210
|
+
} catch {}
|
|
211
|
+
throw new Error("스토어프론트 템플릿을 찾지 못했어요");
|
|
212
|
+
}
|
|
213
|
+
const TEMPLATE_ROOT = resolveRoot();
|
|
214
|
+
function walk(dir, acc) {
|
|
215
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
216
|
+
if (entry.name.startsWith(".") && entry.name !== ".gitignore") continue;
|
|
217
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
218
|
+
const full = join(dir, entry.name);
|
|
219
|
+
if (entry.isDirectory()) walk(full, acc);
|
|
220
|
+
else acc.push(relative(TEMPLATE_ROOT, full).split(sep).join("/"));
|
|
221
|
+
}
|
|
222
|
+
return acc;
|
|
223
|
+
}
|
|
224
|
+
function listTemplateFiles() {
|
|
225
|
+
return walk(TEMPLATE_ROOT, []).sort();
|
|
226
|
+
}
|
|
227
|
+
/** 경로 탈출을 막는다 — 목록에 있는 파일만 읽는다 */
|
|
228
|
+
function readTemplateFile(path) {
|
|
229
|
+
const normalized = path.replace(/^\.?\//, "");
|
|
230
|
+
if (!listTemplateFiles().includes(normalized)) throw new Error(`템플릿에 없는 파일이에요: ${path}`);
|
|
231
|
+
return readFileSync(join(TEMPLATE_ROOT, normalized), "utf8");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
//#endregion
|
|
235
|
+
//#region src/index.ts
|
|
236
|
+
const config = loadConfig();
|
|
237
|
+
const server = new McpServer({
|
|
238
|
+
name: "sayren",
|
|
239
|
+
version: "0.1.0"
|
|
240
|
+
});
|
|
241
|
+
const text = (value) => ({ content: [{
|
|
242
|
+
type: "text",
|
|
243
|
+
text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
|
|
244
|
+
}] });
|
|
245
|
+
server.registerTool("get_store_context", {
|
|
246
|
+
title: "스토어 컨텍스트",
|
|
247
|
+
description: "연결된 스토어의 사실을 모아 준다. 스토어 코드, 스토어프론트 API 주소, 카테고리, 상품 표본, 결제 설정 상태다. 화면을 만들기 전에 먼저 부른다 — 카테고리와 상품을 지어내지 않게 한다.",
|
|
248
|
+
inputSchema: {}
|
|
249
|
+
}, async () => text(await fetchStoreContext(config)));
|
|
250
|
+
server.registerTool("get_scaffold_plan", {
|
|
251
|
+
title: "스토어프론트 생성 계획",
|
|
252
|
+
description: "React Router(SSR) 스토어프론트를 만드는 순서와 지켜야 할 규칙, 그리고 템플릿 파일 목록을 준다. 구현을 시작하기 전에 부른다.",
|
|
253
|
+
inputSchema: {}
|
|
254
|
+
}, async () => {
|
|
255
|
+
const store = await fetchStoreContext(config).catch(() => null);
|
|
256
|
+
return text({
|
|
257
|
+
steps: [
|
|
258
|
+
"1. `list_template_files`로 파일 목록을 받는다.",
|
|
259
|
+
"2. 각 파일을 `get_template_file`로 받아 **그대로** 프로젝트에 쓴다. 코드를 새로 짓지 않는다.",
|
|
260
|
+
"3. `.env`에 `SAYREN_API_URL`과 `SAYREN_STORE_CODE`를 넣는다(아래 값).",
|
|
261
|
+
"4. `pnpm install` 후 `pnpm dev`로 띄워 홈에 상품이 보이는지 확인한다.",
|
|
262
|
+
"5. 사용자가 원하는 디자인은 확장 지점에서만 바꾼다.",
|
|
263
|
+
"6. 끝나면 `verify_storefront`로 규칙 위반이 없는지 확인한다."
|
|
264
|
+
],
|
|
265
|
+
env: store ? {
|
|
266
|
+
SAYREN_API_URL: store.storefrontApiBaseUrl,
|
|
267
|
+
SAYREN_STORE_CODE: store.storeCode
|
|
268
|
+
} : {
|
|
269
|
+
SAYREN_API_URL: config.storefrontBaseUrl,
|
|
270
|
+
SAYREN_STORE_CODE: "(get_store_context 참고)"
|
|
271
|
+
},
|
|
272
|
+
extensionPoints: [
|
|
273
|
+
{
|
|
274
|
+
what: "브랜드 색·서체",
|
|
275
|
+
where: "app/app.css의 @theme"
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
what: "헤더·전역 내비",
|
|
279
|
+
where: "app/components/site-header.tsx"
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
what: "상품 카드",
|
|
283
|
+
where: "app/components/product-card.tsx"
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
what: "화면 추가",
|
|
287
|
+
where: "app/routes.ts"
|
|
288
|
+
}
|
|
289
|
+
],
|
|
290
|
+
doNotTouch: [
|
|
291
|
+
"app/lib/payment-popup.ts — 결제 팝업 규칙",
|
|
292
|
+
"app/lib/api.server.ts — 테넌트 헤더·토큰 전달·부모 origin",
|
|
293
|
+
"loader에서 데이터를 받는 구조"
|
|
294
|
+
],
|
|
295
|
+
rules: RULES,
|
|
296
|
+
templateFiles: listTemplateFiles()
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
server.registerTool("list_template_files", {
|
|
300
|
+
title: "템플릿 파일 목록",
|
|
301
|
+
description: "검증된 스토어프론트 템플릿의 파일 목록이다. 이 목록 그대로 프로젝트를 만든다.",
|
|
302
|
+
inputSchema: {}
|
|
303
|
+
}, async () => text({ files: listTemplateFiles() }));
|
|
304
|
+
server.registerTool("get_template_file", {
|
|
305
|
+
title: "템플릿 파일 내용",
|
|
306
|
+
description: "템플릿 파일 하나의 원문을 준다. 받은 내용을 그대로 쓴다 — 이 코드는 CI가 빌드·테스트해 동작을 보장하는 소스다.",
|
|
307
|
+
inputSchema: { path: z.string().describe("`list_template_files`가 준 경로") }
|
|
308
|
+
}, async ({ path }) => text(readTemplateFile(path)));
|
|
309
|
+
server.registerTool("verify_storefront", {
|
|
310
|
+
title: "생성 결과 검증",
|
|
311
|
+
description: "만들어진 프로젝트를 규칙과 대조한다. 결제가 조용히 실패하는 실수(브라우저 번들의 process.env, 팝업 origin 미검증, 부모 origin 누락 등)를 잡는다.",
|
|
312
|
+
inputSchema: { projectDir: z.string().describe("검사할 프로젝트 경로(절대 경로)") }
|
|
313
|
+
}, async ({ projectDir }) => {
|
|
314
|
+
const files = await collectSources(projectDir);
|
|
315
|
+
if (!files.length) return text({
|
|
316
|
+
ok: false,
|
|
317
|
+
message: `소스를 찾지 못했어요: ${projectDir}`
|
|
318
|
+
});
|
|
319
|
+
const findings = verifySources(files);
|
|
320
|
+
return text({
|
|
321
|
+
ok: findings.length === 0,
|
|
322
|
+
checkedFiles: files.length,
|
|
323
|
+
findings: findings.map((finding) => ({
|
|
324
|
+
...finding,
|
|
325
|
+
rule: RULES.find((rule) => rule.id === finding.ruleId)
|
|
326
|
+
})),
|
|
327
|
+
message: findings.length === 0 ? "규칙 위반을 찾지 못했어요. 직접 띄워 결제까지 한 번 해보세요" : "아래 항목을 고친 뒤 다시 검사해주세요"
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
const SKIP = new Set([
|
|
331
|
+
"node_modules",
|
|
332
|
+
"build",
|
|
333
|
+
"dist",
|
|
334
|
+
".react-router",
|
|
335
|
+
".git"
|
|
336
|
+
]);
|
|
337
|
+
async function collectSources(dir) {
|
|
338
|
+
try {
|
|
339
|
+
if (!statSync(dir).isDirectory()) return [];
|
|
340
|
+
} catch {
|
|
341
|
+
return [];
|
|
342
|
+
}
|
|
343
|
+
const out = [];
|
|
344
|
+
const walk$1 = async (current) => {
|
|
345
|
+
for (const entry of await readdir(current, { withFileTypes: true })) {
|
|
346
|
+
if (SKIP.has(entry.name) || entry.name.startsWith(".")) continue;
|
|
347
|
+
const full = join(current, entry.name);
|
|
348
|
+
if (entry.isDirectory()) await walk$1(full);
|
|
349
|
+
else if (/\.(ts|tsx|js|jsx)$/.test(entry.name)) out.push({
|
|
350
|
+
path: relative(dir, full).split(sep).join("/"),
|
|
351
|
+
content: readFileSync(full, "utf8")
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
await walk$1(dir);
|
|
356
|
+
return out;
|
|
357
|
+
}
|
|
358
|
+
await server.connect(new StdioServerTransport());
|
|
359
|
+
|
|
360
|
+
//#endregion
|
|
361
|
+
export { };
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sayren/mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"sayren-mcp": "./dist/index.mjs"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"template"
|
|
11
|
+
],
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@modelcontextprotocol/sdk": "^1.22.0",
|
|
14
|
+
"zod": "^4.6.5",
|
|
15
|
+
"@sayren/store-sdk": "0.1.0",
|
|
16
|
+
"@sayren/storefront-sdk": "0.1.0"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@biomejs/biome": "^2.5.14",
|
|
20
|
+
"@types/node": "^24.13.5",
|
|
21
|
+
"tsdown": "^0.16.4",
|
|
22
|
+
"tsx": "^4.20.3",
|
|
23
|
+
"typescript": "^6.0.3",
|
|
24
|
+
"vitest": "^5.0.1"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"license": "UNLICENSED",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/cochoio/shop.24.git"
|
|
33
|
+
},
|
|
34
|
+
"description": "sayren MCP 서버 — 스토어 컨텍스트 조회와 스토어프론트 생성",
|
|
35
|
+
"scripts": {
|
|
36
|
+
"dev": "tsx src/index.ts",
|
|
37
|
+
"build": "tsdown && node scripts/copy-template.mjs",
|
|
38
|
+
"lint": "biome check --write . && tsc --noEmit",
|
|
39
|
+
"typecheck": "tsc --noEmit",
|
|
40
|
+
"test": "vitest run"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/template/.env
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# 스토어프론트 템플릿 (React Router SSR)
|
|
2
|
+
|
|
3
|
+
sayren MCP가 내려주는 스토어프론트 시작점이다. 구매 흐름 전체가 이미 붙어 있다.
|
|
4
|
+
홈, 목록·검색, 상세, 장바구니, 주문서·결제 팝업, 완료, 로그인, 주문 내역이다.
|
|
5
|
+
|
|
6
|
+
## 시작하기
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pnpm install
|
|
10
|
+
SAYREN_API_URL=https://api.sayren.app/storefront/v1 \
|
|
11
|
+
SAYREN_STORE_CODE=mystore \
|
|
12
|
+
pnpm dev
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
| 환경변수 | 뜻 |
|
|
16
|
+
| --- | --- |
|
|
17
|
+
| `SAYREN_API_URL` | 스토어프론트 API 베이스 |
|
|
18
|
+
| `SAYREN_STORE_CODE` | 테넌트 스토어 코드. 서브도메인으로 서비스하면 비워 둔다 |
|
|
19
|
+
|
|
20
|
+
## 확장 지점
|
|
21
|
+
|
|
22
|
+
| 자리 | 파일 |
|
|
23
|
+
| --- | --- |
|
|
24
|
+
| 브랜드 색·서체 | `app/app.css`의 `@theme` |
|
|
25
|
+
| 헤더·전역 내비 | `app/components/site-header.tsx` |
|
|
26
|
+
| 상품 카드 | `app/components/product-card.tsx` |
|
|
27
|
+
| 화면 추가 | `app/routes.ts` |
|
|
28
|
+
|
|
29
|
+
## 건드리지 않는 편이 좋은 것
|
|
30
|
+
|
|
31
|
+
- `app/lib/payment-popup.ts` — 결제 팝업 규칙이다. 팝업은 클릭 콜스택에서 동기적으로 열고,
|
|
32
|
+
postMessage는 origin과 source를 모두 검증하며, 결과 없이 닫히면 서버에 상태를 물어본다.
|
|
33
|
+
하나라도 어기면 결제가 조용히 실패한다.
|
|
34
|
+
- `app/lib/api.ts` — 테넌트 헤더와 토큰 전달 방식이다. 토큰을 모듈 전역에 담으면 서버 렌더에서
|
|
35
|
+
다른 사용자의 요청에 섞인다.
|
|
36
|
+
- loader에서 데이터를 받아 그리는 구조 — 클라이언트에서 다시 받으면 검색 노출과 첫 화면이 죽는다.
|
|
37
|
+
|
|
38
|
+
## 주의
|
|
39
|
+
|
|
40
|
+
- 주문서(`/checkout`)는 들어올 때마다 주문서 세션을 새로 만든다. 조회 API가 없고, 가격과 재고를
|
|
41
|
+
매번 다시 계산해야 하기 때문이다.
|
|
42
|
+
- 비회원 장바구니는 서버가 발급한 토큰으로만 찾는다. 쿠키를 지우면 담은 상품이 사라진다.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
/* 상세설명이 에디터가 내보낸 HTML이라 그 모양 규칙이 필요하다 — 사진 줄, 재생기 틀,
|
|
3
|
+
구분선. 활자는 product-description.tsx가 정하고 이 파일은 건드리지 않는다. */
|
|
4
|
+
@import "@avarlabs/editor/styles/reader.css";
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* 확장 지점 — 브랜드 색과 서체는 여기서 바꾼다. 화면 코드는 토큰만 쓰므로
|
|
8
|
+
* 이 블록만 고쳐도 전체 톤이 바뀐다.
|
|
9
|
+
*/
|
|
10
|
+
@theme {
|
|
11
|
+
--color-ink: #202429;
|
|
12
|
+
--color-muted: #5f6773;
|
|
13
|
+
--color-line: #ededed;
|
|
14
|
+
--color-chip: #f5f5f5;
|
|
15
|
+
--color-point: #ff204b;
|
|
16
|
+
--font-sans: "Pretendard Variable", Pretendard, -apple-system, "Apple SD Gothic Neo", sans-serif;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
html {
|
|
20
|
+
font-family: var(--font-sans);
|
|
21
|
+
word-break: keep-all;
|
|
22
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ProductCard as ProductCardData } from "@sayren/storefront-sdk";
|
|
2
|
+
import { Link } from "react-router";
|
|
3
|
+
import { formatPrice } from "../lib/format";
|
|
4
|
+
|
|
5
|
+
/** 상품 카드 — 확장 지점: 찜 버튼·리뷰 별점 같은 요소는 여기에 붙인다 */
|
|
6
|
+
export function ProductCard({ product }: { product: ProductCardData }) {
|
|
7
|
+
const price = product.discountedPrice ?? product.salePrice;
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<Link to={`/products/${product.productId}`} className="group flex flex-col gap-2">
|
|
11
|
+
<div className="relative aspect-square overflow-hidden rounded-lg bg-chip">
|
|
12
|
+
<img
|
|
13
|
+
src={product.thumbnailUrl}
|
|
14
|
+
alt=""
|
|
15
|
+
className="h-full w-full object-cover transition group-hover:scale-105"
|
|
16
|
+
loading="lazy"
|
|
17
|
+
/>
|
|
18
|
+
{product.soldOut ? (
|
|
19
|
+
<span className="absolute inset-0 flex items-center justify-center bg-white/70 font-semibold text-sm">
|
|
20
|
+
품절
|
|
21
|
+
</span>
|
|
22
|
+
) : null}
|
|
23
|
+
</div>
|
|
24
|
+
<div className="flex flex-col gap-1">
|
|
25
|
+
<span className="line-clamp-2 text-sm">{product.name}</span>
|
|
26
|
+
<span className="flex items-baseline gap-2">
|
|
27
|
+
{product.discountRate ? (
|
|
28
|
+
<span className="font-bold text-point">{product.discountRate}%</span>
|
|
29
|
+
) : null}
|
|
30
|
+
<span className="font-bold">{formatPrice(price)}</span>
|
|
31
|
+
{product.discountedPrice != null ? (
|
|
32
|
+
<span className="text-muted text-xs line-through">
|
|
33
|
+
{formatPrice(product.salePrice)}
|
|
34
|
+
</span>
|
|
35
|
+
) : null}
|
|
36
|
+
</span>
|
|
37
|
+
{product.reviewCount > 0 ? (
|
|
38
|
+
<span className="text-muted text-xs">
|
|
39
|
+
리뷰 {product.reviewCount}개 · 평점 {product.averageRating.toFixed(1)}
|
|
40
|
+
</span>
|
|
41
|
+
) : null}
|
|
42
|
+
</div>
|
|
43
|
+
</Link>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 상품 상세설명 렌더.
|
|
3
|
+
*
|
|
4
|
+
* 상세설명은 셀러가 콘솔 에디터로 쓴 HTML이다. API가 **쓰기 시점에 허용목록으로 정화한
|
|
5
|
+
* HTML만 저장**하므로(@avarlabs/editor의 허용목록) 그대로 그려도 된다.
|
|
6
|
+
*
|
|
7
|
+
* 단, 그 기능 이전에 저장된 레거시 평문은 정화된 적이 없다. 그래서 "HTML인지"를
|
|
8
|
+
* 단순히 '<' 포함이 아니라 **에디터가 생성하는 블록 태그로 시작하는지**로 판별한다.
|
|
9
|
+
* 레거시 평문(`10<20`, `<img onerror=...>`)은 이 패턴으로 시작하지 않으므로 평문 분기로
|
|
10
|
+
* 가 React가 이스케이프 렌더 → XSS·표시 깨짐이 모두 방지된다.
|
|
11
|
+
*
|
|
12
|
+
* 확장 지점 — 활자(본문 크기·제목·인용)는 아래 클래스가 정한다. 사진 줄·재생기 틀·
|
|
13
|
+
* 구분선 같은 에디터 고유 모양은 `app.css`가 불러오는 reader.css가 맡는다.
|
|
14
|
+
*/
|
|
15
|
+
const EDITOR_HTML_START = /^\s*<(?:p|h[1-6]|ul|ol|hr|figure|table|blockquote|pre)[\s>/]/i;
|
|
16
|
+
|
|
17
|
+
export function ProductDescription({ description }: { description: string }) {
|
|
18
|
+
if (!EDITOR_HTML_START.test(description)) {
|
|
19
|
+
return <p className="whitespace-pre-line text-sm leading-relaxed">{description}</p>;
|
|
20
|
+
}
|
|
21
|
+
return (
|
|
22
|
+
<div
|
|
23
|
+
className="text-sm leading-relaxed [&_a]:underline [&_blockquote]:my-4 [&_blockquote]:border-line [&_blockquote]:border-l-2 [&_blockquote]:pl-4 [&_figcaption]:mt-2 [&_figcaption]:text-center [&_figcaption]:text-muted [&_figcaption]:text-xs [&_figure]:my-5 [&_h2]:mt-4 [&_h2]:mb-2 [&_h2]:font-bold [&_h2]:text-lg [&_h3]:mt-3 [&_h3]:mb-1 [&_h3]:font-semibold [&_h3]:text-base [&_hr]:my-4 [&_img]:max-w-full [&_ol]:list-decimal [&_ol]:pl-5 [&_pre]:my-4 [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:bg-chip [&_pre]:p-4 [&_pre]:text-xs [&_table]:my-4 [&_table]:w-full [&_table]:border-collapse [&_td]:border [&_td]:border-line [&_td]:p-2 [&_th]:border [&_th]:border-line [&_th]:bg-chip [&_th]:p-2 [&_ul]:list-disc [&_ul]:pl-5"
|
|
24
|
+
// biome-ignore lint/security/noDangerouslySetInnerHtml: 에디터 블록 태그로 시작하는 콘텐츠만 진입 — 서버 쓰기 시점 정화 불변식 신뢰 (위 주석)
|
|
25
|
+
dangerouslySetInnerHTML={{ __html: description }}
|
|
26
|
+
/>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Link } from "react-router";
|
|
2
|
+
|
|
3
|
+
/** 확장 지점 — 로고·전역 내비게이션은 여기서 바꾼다 */
|
|
4
|
+
export function SiteHeader() {
|
|
5
|
+
return (
|
|
6
|
+
<header className="border-line border-b">
|
|
7
|
+
<div className="mx-auto flex w-full max-w-5xl items-center justify-between px-4 py-4">
|
|
8
|
+
<Link to="/" className="font-bold text-lg">
|
|
9
|
+
스토어
|
|
10
|
+
</Link>
|
|
11
|
+
<nav className="flex items-center gap-4 text-sm">
|
|
12
|
+
<Link to="/products" className="hover:text-point">
|
|
13
|
+
전체 상품
|
|
14
|
+
</Link>
|
|
15
|
+
<Link to="/cart" className="hover:text-point">
|
|
16
|
+
장바구니
|
|
17
|
+
</Link>
|
|
18
|
+
<Link to="/orders" className="hover:text-point">
|
|
19
|
+
주문 내역
|
|
20
|
+
</Link>
|
|
21
|
+
</nav>
|
|
22
|
+
</div>
|
|
23
|
+
</header>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createStorefrontClient } from "@sayren/storefront-sdk";
|
|
2
|
+
import { API_BASE_URL, resolveStoreCode } from "./config.server";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 서버(loader·action)에서 쓰는 스토어프론트 API 클라이언트.
|
|
6
|
+
*
|
|
7
|
+
* 테넌트는 `X-Store-Code` 헤더로 보낸다. 토큰은 요청마다 넘긴다 — 모듈 전역에 담으면 서버 렌더에서
|
|
8
|
+
* 다른 사용자의 요청에 섞인다.
|
|
9
|
+
*
|
|
10
|
+
* 비회원 장바구니 토큰은 서버가 새로 발급할 수 있다. `onCartToken`으로 받아 쿠키에 다시 심는다.
|
|
11
|
+
*/
|
|
12
|
+
export function apiFor(
|
|
13
|
+
request: Request,
|
|
14
|
+
options: {
|
|
15
|
+
accessToken?: string | null;
|
|
16
|
+
cartToken?: string | null;
|
|
17
|
+
onCartToken?: (token: string) => void;
|
|
18
|
+
} = {},
|
|
19
|
+
) {
|
|
20
|
+
return createStorefrontClient({
|
|
21
|
+
baseUrl: API_BASE_URL,
|
|
22
|
+
storeCode: resolveStoreCode(request),
|
|
23
|
+
accessToken: options.accessToken ?? undefined,
|
|
24
|
+
cartToken: options.cartToken ?? undefined,
|
|
25
|
+
onCartToken: options.onCartToken,
|
|
26
|
+
// 결제 요청에 우리 화면의 origin을 실어 보낸다. 결제 팝업은 이 값으로만 부모 창에
|
|
27
|
+
// 결과를 알린다 — 빠지면 결제는 되지만 주문서가 완료를 못 받는다.
|
|
28
|
+
fetch: (input, init) => {
|
|
29
|
+
const headers = new Headers(init?.headers);
|
|
30
|
+
headers.set("Origin", new URL(request.url).origin);
|
|
31
|
+
return fetch(input, { ...init, headers });
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { createStorefrontClient } from "@sayren/storefront-sdk";
|
|
2
|
+
import type { PublicConfig } from "./config";
|
|
3
|
+
|
|
4
|
+
/** 브라우저용 — loader가 내려준 설정으로 만든다(결제 상태 폴링에 쓴다) */
|
|
5
|
+
export function browserApi(config: PublicConfig) {
|
|
6
|
+
return createStorefrontClient({ baseUrl: config.apiBaseUrl, storeCode: config.storeCode });
|
|
7
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { createCookie } from "react-router";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 비회원 장바구니 토큰 — 서버가 `X-Cart-Token` 헤더로 발급한다. 회원은 액세스 토큰으로
|
|
5
|
+
* 장바구니를 찾지만 비회원은 이 토큰이 유일한 열쇠라, 잃어버리면 담은 상품이 사라진다.
|
|
6
|
+
*/
|
|
7
|
+
export const cartCookie = createCookie("sayren_cart", {
|
|
8
|
+
httpOnly: true,
|
|
9
|
+
sameSite: "lax",
|
|
10
|
+
path: "/",
|
|
11
|
+
secure: process.env.NODE_ENV === "production",
|
|
12
|
+
maxAge: 60 * 60 * 24 * 30,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export async function readCartToken(request: Request): Promise<string | null> {
|
|
16
|
+
const value = await cartCookie.parse(request.headers.get("cookie"));
|
|
17
|
+
return typeof value === "string" && value ? value : null;
|
|
18
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 주문서 진입 의도 — URL 파라미터로 받는다.
|
|
3
|
+
*
|
|
4
|
+
* 스토어프론트 API에는 "만들어 둔 주문서 다시 조회"가 없다. 주문서 세션은 만들 때 한 번 내려오고
|
|
5
|
+
* 짧게 만료된다. 그래서 주문서 화면은 들어올 때마다 세션을 새로 만든다. 조건이 URL에 있으면
|
|
6
|
+
* 새로고침·뒤로가기·링크 공유가 그대로 동작한다.
|
|
7
|
+
*
|
|
8
|
+
* 값을 사용자가 바꿀 수 있지만 위험하지 않다. 장바구니 항목은 장바구니 토큰으로 소유가 확인되고,
|
|
9
|
+
* 가격·재고는 서버가 다시 계산한다.
|
|
10
|
+
*/
|
|
11
|
+
export interface CheckoutIntent {
|
|
12
|
+
cartItemIds?: string[];
|
|
13
|
+
directItem?: { productId: string; optionId?: string; quantity: number };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function checkoutIntentFrom(params: URLSearchParams): CheckoutIntent | null {
|
|
17
|
+
const cartItemIds = params.getAll("cartItemId").filter(Boolean);
|
|
18
|
+
if (cartItemIds.length) return { cartItemIds };
|
|
19
|
+
|
|
20
|
+
const productId = params.get("productId");
|
|
21
|
+
if (!productId) return null;
|
|
22
|
+
const quantity = Number(params.get("quantity") ?? 1);
|
|
23
|
+
return {
|
|
24
|
+
directItem: {
|
|
25
|
+
productId,
|
|
26
|
+
optionId: params.get("optionId") ?? undefined,
|
|
27
|
+
quantity: Number.isFinite(quantity) && quantity > 0 ? Math.floor(quantity) : 1,
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|