@sayren/mcp 0.2.3 → 0.3.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 CHANGED
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
3
- import { readdir } from "node:fs/promises";
4
- import { basename, dirname, join, relative, resolve, sep } from "node:path";
2
+ import { createRequire } from "node:module";
5
3
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
5
  import { z } from "zod";
6
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
7
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
+ import { readdir } from "node:fs/promises";
9
10
 
10
11
  //#region src/api-tools.ts
11
12
  const METHODS = [
@@ -161,6 +162,16 @@ function truncateData(data, maxChars) {
161
162
  };
162
163
  }
163
164
 
165
+ //#endregion
166
+ //#region src/user-agent.ts
167
+ /**
168
+ * MCP가 관리 API를 부를 때 싣는 User-Agent. 접두사 `sayren-mcp/`가 서버와의 계약이다 — api가 이 값으로
169
+ * 스토어의 첫 MCP 호출(온보딩 마일스톤 FIRST_MCP_CALL, 이슈 #33)을 기록한다(`isMcpUserAgent`). 권한과는 무관하다.
170
+ * src·dist 어디서 불려도 한 단계 위가 패키지 루트다.
171
+ */
172
+ const { version } = createRequire(import.meta.url)("../package.json");
173
+ const MCP_USER_AGENT = `sayren-mcp/${version}`;
174
+
164
175
  //#endregion
165
176
  //#region src/admin-api.ts
166
177
  const SPEC_TTL_MS = 5 * 6e4;
@@ -203,7 +214,10 @@ var AdminApi = class {
203
214
  async call(input) {
204
215
  const url = new URL(`${this.config.apiOrigin}${input.path}`);
205
216
  for (const [key, value] of Object.entries(input.query ?? {})) url.searchParams.set(key, String(value));
206
- const headers = { authorization: `Bearer ${this.config.token}` };
217
+ const headers = {
218
+ authorization: `Bearer ${this.config.token}`,
219
+ "user-agent": MCP_USER_AGENT
220
+ };
207
221
  if (input.body !== void 0) headers["content-type"] = "application/json";
208
222
  if (input.idempotencyKey) headers["idempotency-key"] = input.idempotencyKey;
209
223
  const response = await this.fetchImpl(url, {
@@ -258,7 +272,10 @@ async function callApi(url, init = {}) {
258
272
  return body?.data ?? body;
259
273
  }
260
274
  async function fetchStoreContext(config$1) {
261
- const authHeaders = { authorization: `Bearer ${config$1.token}` };
275
+ const authHeaders = {
276
+ authorization: `Bearer ${config$1.token}`,
277
+ "user-agent": MCP_USER_AGENT
278
+ };
262
279
  const store = await callApi(`${config$1.adminBaseUrl}/store`, { headers: authHeaders });
263
280
  const storeHeaders = { "x-store-code": store.storeCode };
264
281
  const [categories, products, adminProducts, paymentSettings] = await Promise.all([
@@ -310,14 +327,14 @@ function renderTemplatePackageJson(raw, meta, options = {}) {
310
327
  function resolveSpec(name, spec, meta, options) {
311
328
  if (options.link && name.startsWith("@sayren/")) return "workspace:*";
312
329
  if (spec === "catalog:" || spec === "catalog:default") {
313
- const version = meta.catalog[name];
314
- if (!version) throw new Error(`catalog에 ${name} 버전이 없어요`);
315
- return version;
330
+ const version$1 = meta.catalog[name];
331
+ if (!version$1) throw new Error(`catalog에 ${name} 버전이 없어요`);
332
+ return version$1;
316
333
  }
317
334
  if (spec.startsWith("workspace:")) {
318
- const version = meta.versions[name];
319
- if (!version) throw new Error(`${name}의 배포 버전을 모르겠어요`);
320
- return `^${version}`;
335
+ const version$1 = meta.versions[name];
336
+ if (!version$1) throw new Error(`${name}의 배포 버전을 모르겠어요`);
337
+ return `^${version$1}`;
321
338
  }
322
339
  return spec;
323
340
  }
@@ -423,134 +440,174 @@ function readTemplateFile(path, options = {}) {
423
440
  return renderTemplatePackageJson(raw, loadTemplateMeta(TEMPLATE_ROOT), options);
424
441
  }
425
442
 
426
- //#endregion
427
- //#region src/cli.ts
428
- const USAGE = `사용법: sayren-mcp create <폴더> [--link] [--name <이름>] [--force]
429
-
430
- --link @sayren/* 의존성을 workspace:*로 둔다(모노레포 안 예제용)
431
- --name package.json 이름(기본: 폴더 이름)
432
- --force 비어 있지 않은 폴더에도 쓴다
433
-
434
- SAYREN_TOKEN이 있으면 스토어 정보를 받아 .env까지 채운다.`;
435
- function parseArgs(argv) {
436
- const rest = [...argv];
437
- const args = {
438
- dir: "",
439
- link: false,
440
- force: false
441
- };
442
- while (rest.length) {
443
- const token = rest.shift();
444
- if (token === "--link") args.link = true;
445
- else if (token === "--force") args.force = true;
446
- else if (token === "--name") args.name = rest.shift();
447
- else if (token.startsWith("-")) return null;
448
- else if (!args.dir) args.dir = token;
449
- else return null;
450
- }
451
- return args.dir ? args : null;
452
- }
453
- /**
454
- * MCP 도구가 에이전트에게 주는 것과 **같은 파일**을 사람이 직접 받는 길이다.
455
- * 에이전트 경로(list/get_template_file)와 결과가 갈리지 않게 같은 함수로 읽는다.
456
- */
457
- async function runCreate(argv) {
458
- const args = parseArgs(argv);
459
- if (!args) {
460
- console.error(USAGE);
461
- return 1;
462
- }
463
- const target = resolve(args.dir);
464
- if (existsSync(target) && readdirSync(target).length > 0 && !args.force) {
465
- console.error(`폴더가 비어 있지 않아요: ${target} (--force로 덮어쓸 수 있어요)`);
466
- return 1;
467
- }
468
- const name = args.name ?? basename(target);
469
- const files = listTemplateFiles();
470
- for (const path of files) {
471
- const out = join(target, path);
472
- mkdirSync(dirname(out), { recursive: true });
473
- writeFileSync(out, readTemplateFile(path, {
474
- name,
475
- link: args.link
476
- }));
477
- }
478
- console.log(`템플릿 ${files.length}개 → ${target}`);
479
- if (process.env.SAYREN_TOKEN) {
480
- const store = await fetchStoreContext(loadConfig());
481
- const env = `SAYREN_API_URL=${store.storefrontApiBaseUrl}\nSAYREN_STORE_CODE=${store.storeCode}\n`;
482
- writeFileSync(join(target, ".env"), env);
483
- console.log(`.env 작성 — ${store.name} (${store.storeCode})`);
484
- } else console.log("SAYREN_TOKEN이 없어 .env는 비워 뒀어요. SAYREN_API_URL과 SAYREN_STORE_CODE를 넣어주세요");
485
- console.log(`\n다음:\n cd ${args.dir}\n pnpm install\n pnpm dev`);
486
- return 0;
487
- }
488
-
489
443
  //#endregion
490
444
  //#region src/rules.ts
445
+ /** 문서 사이트 — `doc` 경로를 절대 주소로 만들 때 쓴다 */
446
+ const DOCS_BASE_URL = "https://docs.sayren.app";
447
+ const docUrl = (doc) => `${DOCS_BASE_URL}${doc}`;
491
448
  const RULES = [
492
449
  {
493
450
  id: "server-only-env",
494
451
  title: "`process.env`는 `*.server.ts`에만 두고 서버 함수 안에서 읽는다",
495
- why: "브라우저 번들에 들어가면 `process is not defined`로 그 화면 모듈이 통째로 깨진다. 결제 화면에서 터지면 결제가 시작조차 안 된다"
452
+ why: "브라우저 번들에 들어가면 `process is not defined`로 그 화면 모듈이 통째로 깨진다. 결제 화면에서 터지면 결제가 시작조차 안 된다",
453
+ fix: "`process.env`를 읽는 줄을 `*.server.ts`로 옮기고, 화면에 필요한 값은 서버 함수(loader)가 내려준다. 브라우저 파일에서는 그 값을 인자로 받는다",
454
+ example: "// src/lib/config.server.ts\nexport const API_BASE_URL = process.env.SAYREN_API_URL ?? \"\";\n\n// 화면은 loader가 내려준 값을 쓴다\nconst getConfig = createServerFn().handler(() => ({ apiBaseUrl: API_BASE_URL }));",
455
+ doc: "/guides/mcp-customize",
456
+ templateFile: "src/lib/config.server.ts"
496
457
  },
497
458
  {
498
459
  id: "tenant-header",
499
460
  title: "테넌트는 `X-Store-Code` 헤더로 보낸다",
500
- why: "공유 API 호스트에서 스토어를 가르는 값이다. 빠지면 다른 스토어 데이터가 보이거나 404가 난다"
461
+ why: "공유 API 호스트에서 스토어를 가르는 값이다. 빠지면 다른 스토어 데이터가 보이거나 404가 난다",
462
+ fix: "`createStorefrontClient`에 `storeCode`를 넘긴다. SDK가 헤더를 붙인다. 서브도메인으로 서비스할 때만 생략한다",
463
+ example: "createStorefrontClient({ baseUrl: API_BASE_URL, storeCode: resolveStoreCode() });",
464
+ doc: "/guides/storefront-sdk",
465
+ templateFile: "src/lib/api.server.ts"
501
466
  },
502
467
  {
503
468
  id: "no-module-token",
504
469
  title: "토큰을 모듈 전역에 담지 않는다",
505
- why: "서버 렌더는 요청을 공유한다. 전역에 담으면 다른 구매자의 요청에 토큰이 섞인다"
470
+ why: "서버 렌더는 요청을 공유한다. 전역에 담으면 다른 구매자의 요청에 토큰이 섞인다",
471
+ fix: "토큰은 요청마다 읽는다. 서버 함수 안에서 `readToken()`으로 받아 API 클라이언트에 넘기고, 모듈 변수에 저장하지 않는다",
472
+ example: "const load = createServerFn().handler(async () => {\n const api = apiFor({ accessToken: readToken() }); // 요청마다 읽는다\n return api.orders.list();\n});",
473
+ doc: "/guides/storefront-auth",
474
+ templateFile: "src/lib/session.server.ts"
506
475
  },
507
476
  {
508
477
  id: "loader-first",
509
478
  title: "데이터는 라우트 loader에서 받는다(서버 함수 `createServerFn`)",
510
- why: "클라이언트에서 다시 받으면 첫 화면이 비고 검색 노출이 죽는다. SSR을 끄지 않는다"
479
+ why: "클라이언트에서 다시 받으면 첫 화면이 비고 검색 노출이 죽는다. SSR을 끄지 않는다",
480
+ fix: "화면마다 `createFileRoute(...)({ loader })`를 두고 loader가 서버 함수를 부른다. `useEffect`에서 처음 데이터를 받지 않는다",
481
+ example: "const load = createServerFn().handler(() => apiFor({}).products.list({ size: 20 }));\nexport const Route = createFileRoute(\"/products/\")({ loader: () => load(), component: Products });",
482
+ doc: "/guides/mcp-customize",
483
+ templateFile: "src/routes/products.index.tsx"
511
484
  },
512
485
  {
513
486
  id: "payment-browser-launch",
514
487
  title: "결제창은 브라우저에서 `@sayren/storefront-sdk/payments`로 연다(`start` 또는 `open`)",
515
- why: "결제창은 결제 서비스 주소를 브라우저 창으로 여는 일이다. 서버 파일에서 부르면 아무것도 열리지 않고 결제가 시작되지 않는다. 결제 시작(`startPayment`)은 서버 함수, 결제창은 브라우저다"
488
+ why: "결제창은 결제 서비스 주소를 브라우저 창으로 여는 일이다. 서버 파일에서 부르면 아무것도 열리지 않고 결제가 시작되지 않는다. 결제 시작(`startPayment`)은 서버 함수, 결제창은 브라우저다",
489
+ fix: "결제 모듈 import를 서버 파일에서 지우고 컴포넌트(클릭 핸들러·effect)로 옮긴다. 서버 함수는 `checkout.startPayment`로 결제 시작 값만 만들어 내려주고, 그 값을 브라우저에서 `payments.open()`에 넘긴다",
490
+ example: "// 서버 함수: 결제 시작(토큰은 서버에만)\nconst start = await api.checkout.startPayment(checkoutId, { option, returnUrl });\n\n// 브라우저: 결제창 열기\nconst payments = paymentsFor(config);\nawait payments.open(paymentStartSchema.parse(JSON.parse(start)), { window });",
491
+ doc: "/guides/checkout-flow",
492
+ templateFile: "src/lib/payments.ts"
516
493
  },
517
494
  {
518
495
  id: "payment-popup-prepare",
519
496
  title: "결제 시작이 서버 함수를 거치면 클릭 시점에 `payments.prepareWindow()`로 창을 먼저 연다",
520
- why: "팝업은 사용자 클릭에서 동기로 열어야 차단되지 않는다. 서버 응답을 기다린 뒤 열면 브라우저가 막아 결제가 시작되지 않는다"
497
+ why: "팝업은 사용자 클릭에서 동기로 열어야 차단되지 않는다. 서버 응답을 기다린 뒤 열면 브라우저가 막아 결제가 시작되지 않는다",
498
+ fix: "클릭 핸들러 첫 줄에서 `payments.prepareWindow()`로 빈 창을 열고, 서버 함수가 끝나면 그 핸들을 `payments.open(start, { window })`에 넘긴다. 결제 시작이 실패하면 `window.close()`로 닫는다",
499
+ example: "const paymentWindow = payments.prepareWindow(); // 클릭 시점에 먼저 연다\nconst { start, error } = await startPayment({ data });\nif (!start) {\n paymentWindow.close();\n return;\n}\nawait payments.open(parsed, { window: paymentWindow });",
500
+ doc: "/guides/checkout-flow",
501
+ templateFile: "src/routes/checkout.index.tsx"
521
502
  },
522
503
  {
523
504
  id: "payment-return-route",
524
505
  title: "결제 복귀 화면에서 `payments.result()`로 결과를 읽는다",
525
- why: "모바일·팝업 차단은 리다이렉트 결제다. 결제 서비스가 복귀 주소로 돌려보내므로 복귀 화면이 결제 상태를 확인하지 않으면 결제한 구매자가 결과를 볼 수 없다"
506
+ why: "모바일·팝업 차단은 리다이렉트 결제다. 결제 서비스가 복귀 주소로 돌려보내므로 복귀 화면이 결제 상태를 확인하지 않으면 결제한 구매자가 결과를 볼 수 없다",
507
+ fix: "복귀 주소의 경로(`/checkout/return`)에 화면을 만들고 effect에서 `payments.result(window.location.href)`를 부른다. 주소의 `sayrenPaymentId`는 지우지 않는다(SDK가 읽는다)",
508
+ example: "useEffect(() => {\n void payments.result(window.location.href).then((result) => {\n if (result.status === \"COMPLETED\") navigate({ to: \"/checkout/complete\", search: { orderId: result.orderId } });\n });\n}, []);",
509
+ doc: "/guides/checkout-flow",
510
+ templateFile: "src/routes/checkout.return.tsx"
526
511
  },
527
512
  {
528
513
  id: "payment-processing-status",
529
514
  title: "결과 확인 중(`PROCESSING`)이면 결제 상태를 다시 조회한다",
530
- why: "결과를 아직 모를 때 실패로 안내하면 구매자가 다시 결제해 이중 결제가 난다. 확정될 때까지 `payments.result()`·`payments.getStatus()`로 확인한다"
515
+ why: "결과를 아직 모를 때 실패로 안내하면 구매자가 다시 결제해 이중 결제가 난다. 확정될 때까지 `payments.result()`·`payments.getStatus()`로 확인한다",
516
+ fix: "`PROCESSING`은 실패로 다루지 않는다. 안내 문구를 띄운 채 몇 초 간격으로 다시 조회하고, 그래도 모르면 주문 내역을 확인하라고 안내한다(재결제 버튼을 주지 않는다)",
517
+ example: "if (result.status === \"PROCESSING\") {\n setView({ kind: \"processing\" }); // 실패로 단정하지 않는다\n await new Promise((r) => setTimeout(r, 2000));\n const next = await payments.getStatus(result.paymentId);\n}",
518
+ doc: "/guides/checkout-flow",
519
+ templateFile: "src/routes/checkout.return.tsx"
520
+ },
521
+ {
522
+ id: "payment-amounts-final",
523
+ title: "결제 시작 응답의 `amounts`를 확정 금액으로 반영한다",
524
+ why: "주문서를 만들 때는 배송지를 몰라 제주·도서산간 추가 배송비가 빠져 있다. 결제 시작 응답이 보낸 배송지로 배송비를 다시 계산한 확정 금액이라, 화면에 반영하지 않으면 결제창 금액과 화면 금액이 어긋난다",
525
+ fix: "`payments.open()`에 넘기기 전에 결제 시작 값의 `amounts`·`delivery`로 화면 금액을 갱신한다",
526
+ example: "const paymentStart = paymentStartSchema.parse(JSON.parse(start));\n// 확정 금액 — 서버가 보낸 배송지로 배송비를 다시 계산했다\nsetQuote({ amounts: paymentStart.amounts, delivery: paymentStart.delivery });\nawait payments.open(paymentStart, { window: paymentWindow });",
527
+ doc: "/guides/shipping-fees",
528
+ templateFile: "src/routes/checkout.index.tsx"
531
529
  },
532
530
  {
533
531
  id: "checkout-origin",
534
532
  title: "복귀 주소는 배포된 주소(요청 origin)에서 만들고 그 도메인을 셀러 콘솔 설정 › 결제에 등록한다",
535
- why: "결제 도메인 밖의 복귀 주소는 서버가 거절한다. 주소를 코드에 고정하면 로컬·스테이징·운영 중 한 곳에서만 결제가 된다"
533
+ why: "결제 도메인 밖의 복귀 주소는 서버가 거절한다. 주소를 코드에 고정하면 로컬·스테이징·운영 중 한 곳에서만 결제가 된다",
534
+ fix: "서버 함수에서 `getRequestUrl()`의 origin으로 복귀 주소를 만든다. 배포 도메인은 셀러 콘솔 설정 › 결제의 결제 도메인에 등록한다(테스트 결제는 localhost가 항상 허용된다)",
535
+ example: "const returnUrl = new URL(PAYMENT_RETURN_PATH, new URL(getRequestUrl()).origin);\nawait api.checkout.startPayment(checkoutId, { option, returnUrl: returnUrl.toString() });",
536
+ doc: "/guides/payment-settings",
537
+ templateFile: "src/routes/checkout.index.tsx"
538
+ },
539
+ {
540
+ id: "delivery-quote-preview",
541
+ title: "우편번호를 받으면 `checkout.quoteDelivery`로 배송비를 다시 계산해 보여 준다",
542
+ why: "주문서의 배송비는 배송지를 모르는 값이다. 미리 보여 주지 않으면 결제 버튼의 금액과 결제창 금액이 달라져 구매자가 결제를 중단한다",
543
+ fix: "우편번호 입력이 끝나면(blur) 서버 함수로 `checkout.quoteDelivery(checkoutId, { zipCode })`를 부르고 응답의 `amounts`·`delivery`로 화면 금액을 갱신한다. 주문서를 바꾸지 않는 읽기 계산이다",
544
+ example: "const quote = await api.checkout.quoteDelivery(checkoutId, { zipCode });\nsetQuote(quote); // amounts.totalAmount · delivery.remoteSurcharge를 화면에 쓴다",
545
+ doc: "/guides/shipping-fees",
546
+ templateFile: "src/routes/checkout.index.tsx"
547
+ },
548
+ {
549
+ id: "buyer-token-cookie",
550
+ title: "구매자 토큰 쌍은 서버에서 HttpOnly 쿠키에 담는다",
551
+ why: "브라우저 JS나 `localStorage`에 둔 토큰은 스크립트 하나로 새 나간다. 리프레시 토큰이 새면 그 구매자 계정으로 계속 로그인된다",
552
+ fix: "로그인·가입·소셜 콜백을 서버 함수·서버 라우트에서 처리하고 받은 토큰 쌍을 `httpOnly` 쿠키 하나에 담는다. 브라우저 저장소에 토큰을 쓰지 않고, 인증 모듈(`@sayren/storefront-sdk/auth`)은 `*.server.ts`에서만 만든다",
553
+ example: "setCookie(\"sayren_member\", JSON.stringify({ accessToken, refreshToken, expiresAt }), {\n httpOnly: true,\n sameSite: \"lax\",\n secure: process.env.NODE_ENV === \"production\",\n});",
554
+ doc: "/guides/storefront-auth",
555
+ templateFile: "src/lib/session.server.ts"
556
+ },
557
+ {
558
+ id: "buyer-session-refresh",
559
+ title: "액세스 토큰은 만료 전에 리프레시 토큰으로 갱신한다",
560
+ why: "액세스 토큰은 30분이다. 갱신하지 않으면 구매자가 장바구니·주문서에서 갑자기 로그아웃된다. 리프레시 토큰은 한 번 쓰면 폐기되므로 같은 토큰으로 동시에 갱신하면 늦은 쪽이 401을 받는다",
561
+ fix: "전역 요청 미들웨어에서 요청마다 만료를 보고 만료 1분 전이면 `auth.refresh(refreshToken)`로 새 쌍을 받아 쿠키를 갈아 끼운다. 같은 리프레시 토큰의 동시 갱신은 프로세스 안에서 한 번으로 모은다. 401이면 쿠키를 지우고, 네트워크 오류·5xx면 지우지 않는다",
562
+ example: "if (stored.expiresAt - 60_000 <= Date.now()) {\n const tokens = await refreshOnce(stored.refreshToken); // 같은 토큰은 한 번만\n writeSession(tokens);\n}",
563
+ doc: "/guides/storefront-auth",
564
+ templateFile: "src/lib/session.server.ts"
565
+ },
566
+ {
567
+ id: "buyer-reconsent",
568
+ title: "`reconsentRequired`가 true면 재동의를 받는다",
569
+ why: "약관·개인정보 처리방침이 개정되면 이전 동의는 최신본 동의가 아니다. 화면이 재동의를 받지 않으면 동의 이력이 최신본으로 남지 않는다",
570
+ fix: "`member.consents()`(또는 `member.me()`)의 `reconsentRequired`를 읽어 true면 개정된 문서 링크와 재동의 버튼을 보여 주고, 누르면 `member.updateConsents({ terms: true, privacy: true })`를 부른다",
571
+ example: "const consents = await api.member.consents();\nif (consents.reconsentRequired) {\n // 개정 문서 링크(currentDocumentUrl)를 보여 주고 동의를 받는다\n await api.member.updateConsents({ terms: true, privacy: true });\n}",
572
+ doc: "/guides/storefront-auth",
573
+ templateFile: "src/routes/account.tsx"
536
574
  },
537
575
  {
538
576
  id: "analytics-start",
539
577
  title: "방문 분석은 브라우저에서 한 번 시작한다(`@sayren/storefront-sdk/analytics`)",
540
- why: "빠지면 셀러 콘솔 애널리틱스가 비어 있다. 서버 파일에서 부르면 아무것도 하지 않는다"
578
+ why: "빠지면 셀러 콘솔 애널리틱스가 비어 있다. 서버 파일에서 부르면 아무것도 하지 않는다",
579
+ fix: "루트 컴포넌트의 effect에서 `createAnalytics`를 한 번 부르고(설정은 loader가 내려준다), 화면은 그 인스턴스의 `track`으로 행동을 남긴다. 장바구니·결제 시작·구매는 서버가 기록하므로 보내지 않는다",
580
+ example: "import { createAnalytics } from \"@sayren/storefront-sdk/analytics\";\n\nuseEffect(() => {\n startAnalytics({ apiBaseUrl, storeCode, debug }); // 브라우저에서 한 번\n}, []);",
581
+ doc: "/guides/storefront-analytics",
582
+ templateFile: "src/lib/analytics.ts"
541
583
  },
542
584
  {
543
585
  id: "analytics-ids",
544
586
  title: "서버의 API 클라이언트에 방문 식별 쿠키를 싣는다(`analyticsIdsFromCookie`)",
545
- why: "빠지면 장바구니·결제 시작·구매가 방문과 이어지지 않아 퍼널과 유입별 매출에서 빠진다"
587
+ why: "빠지면 장바구니·결제 시작·구매가 방문과 이어지지 않아 퍼널과 유입별 매출에서 빠진다",
588
+ fix: "서버 함수에서 API 클라이언트를 만들 때 요청 쿠키를 `analyticsIdsFromCookie`로 풀어 함께 넘긴다",
589
+ example: "createStorefrontClient({\n baseUrl: API_BASE_URL,\n storeCode: resolveStoreCode(),\n ...analyticsIdsFromCookie(getRequestHeader(\"cookie\")),\n});",
590
+ doc: "/guides/storefront-analytics",
591
+ templateFile: "src/lib/api.server.ts"
546
592
  }
547
593
  ];
548
- /** 주석을 걷어낸다 — 규칙을 설명하는 주석이 위반으로 잡히지 않게 한다 */
594
+ /**
595
+ * 주석을 걷어낸다 — 규칙을 설명하는 주석이 위반으로 잡히지 않게 한다.
596
+ * 줄 번호를 알려 주려면 줄 수가 보존돼야 해서 블록 주석은 개행만 남긴다.
597
+ */
549
598
  function stripComments(source) {
550
- return source.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/(^|[^:])\/\/[^\n]*/g, "$1 ");
599
+ return source.replace(/\/\*[\s\S]*?\*\//g, (block) => block.replace(/[^\n]/g, " ")).replace(/(^|[^:])\/\/[^\n]*/g, "$1 ");
600
+ }
601
+ /** 패턴이 처음 걸린 줄 번호(1부터). 없으면 undefined */
602
+ function lineOf(content, pattern) {
603
+ const match = new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, "")).exec(content);
604
+ if (!match) return void 0;
605
+ return content.slice(0, match.index).split("\n").length;
551
606
  }
552
607
  const CLIENT_FILE = /\.(ts|tsx)$/;
553
608
  const SERVER_FILE = /\.server\.(ts|tsx)$/;
609
+ /** 토큰을 브라우저 저장소에 쓰는 코드 */
610
+ const BROWSER_TOKEN_STORAGE = /(localStorage|sessionStorage)\.setItem\(\s*[`'"][^`'"]*([Tt]oken|refresh|session|sayren_member)/;
554
611
  /**
555
612
  * 만들어진 프로젝트의 소스를 규칙과 대조한다. 정적 검사라 모든 문제를 잡지는 못하지만,
556
613
  * 여기 있는 것들은 실제로 반복해서 깨지는 것들이다.
@@ -563,80 +620,222 @@ function verifySources(files) {
563
620
  }));
564
621
  const has = (needle) => cleaned.some((file) => file.content.includes(needle));
565
622
  const matches = (pattern) => cleaned.find((file) => pattern.test(file.content));
566
- for (const file of cleaned) {
567
- if (!CLIENT_FILE.test(file.path)) continue;
568
- const isServer = SERVER_FILE.test(file.path) || /\/(server|\.server)\//.test(file.path);
569
- if (!isServer && /process\.env\.[A-Z_]/.test(file.content)) findings.push({
570
- ruleId: "server-only-env",
571
- file: file.path,
572
- detail: "브라우저로 갈 수 있는 파일에서 process.env를 읽는다. `*.server.ts`로 옮긴다"
573
- });
574
- if (/createStorefrontClient\(/.test(file.content) && !/storeCode/.test(file.content)) findings.push({
575
- ruleId: "tenant-header",
576
- file: file.path,
577
- detail: "API 클라이언트를 만들 때 storeCode를 주지 않는다"
578
- });
579
- if (/^(const|let)\s+\w*[Tt]oken\s*=\s*["'`]/m.test(file.content)) findings.push({
580
- ruleId: "no-module-token",
623
+ /** 파일 한 곳의 위반. `at`이 있으면 그 패턴이 걸린 줄을 싣는다 */
624
+ const inFile = (ruleId, file, detail, at) => {
625
+ findings.push({
626
+ ruleId,
581
627
  file: file.path,
582
- detail: "토큰처럼 보이는 값을 모듈 전역에 두었다"
628
+ line: at ? lineOf(file.content, at) : void 0,
629
+ detail
583
630
  });
584
- if (isServer && /@sayren\/storefront-sdk\/payments/.test(file.content)) findings.push({
585
- ruleId: "payment-browser-launch",
586
- file: file.path,
587
- detail: "서버 파일에서 결제 모듈을 쓴다. 결제창(`start`·`open`)과 복귀 처리(`result`)는 브라우저(컴포넌트·effect)에서 부른다"
588
- });
589
- if (isServer && /createAnalytics\(/.test(file.content)) findings.push({
590
- ruleId: "analytics-start",
591
- file: file.path,
592
- detail: "서버 파일에서 방문 분석을 시작한다. 서버에서는 아무것도 하지 않으니 브라우저(effect)에서 부른다"
593
- });
594
- if (isServer && /createStorefrontClient\(/.test(file.content) && !/analyticsIdsFromCookie|visitorId/.test(file.content)) findings.push({
595
- ruleId: "analytics-ids",
596
- file: file.path,
597
- detail: "서버 API 클라이언트에 방문 식별자가 없다. `...analyticsIdsFromCookie(request.headers.get(\"cookie\"))`를 넘긴다"
631
+ };
632
+ /** 프로젝트 전체를 보고 판정한 위반 — 고칠 자리는 규칙의 `templateFile`이 알려 준다 */
633
+ const inProject = (ruleId, detail) => {
634
+ findings.push({
635
+ ruleId,
636
+ file: "(프로젝트 전체)",
637
+ detail
598
638
  });
639
+ };
640
+ for (const file of cleaned) {
641
+ if (!CLIENT_FILE.test(file.path)) continue;
642
+ const isServer = SERVER_FILE.test(file.path) || /\/(server|\.server)\//.test(file.path);
643
+ const env = /process\.env\.[A-Z_]/;
644
+ if (!isServer && env.test(file.content)) inFile("server-only-env", file, "브라우저로 갈 수 있는 파일에서 process.env를 읽는다. `*.server.ts`로 옮긴다", env);
645
+ if (/createStorefrontClient\(/.test(file.content) && !/storeCode/.test(file.content)) inFile("tenant-header", file, "API 클라이언트를 만들 때 storeCode를 주지 않는다", /createStorefrontClient\(/);
646
+ const moduleToken = /^(const|let)\s+\w*[Tt]oken\s*=\s*["'`]|^let\s+[\w$]*[Tt]oken\b/m;
647
+ if (moduleToken.test(file.content)) inFile("no-module-token", file, "토큰처럼 보이는 값을 모듈 전역에 두었다", moduleToken);
648
+ const paymentsImport = /@sayren\/storefront-sdk\/payments/;
649
+ if (isServer && paymentsImport.test(file.content)) inFile("payment-browser-launch", file, "서버 파일에서 결제 모듈을 쓴다. 결제창(`start`·`open`)과 복귀 처리(`result`)는 브라우저(컴포넌트·effect)에서 부른다", paymentsImport);
650
+ const authFactory = /createStorefrontAuth\(/;
651
+ if (!isServer && authFactory.test(file.content)) inFile("buyer-token-cookie", file, "브라우저로 갈 수 있는 파일에서 인증 모듈을 만든다. 토큰 쌍이 브라우저 JS에 노출된다 — `*.server.ts`로 옮기고 HttpOnly 쿠키에 담는다", authFactory);
652
+ if (BROWSER_TOKEN_STORAGE.test(file.content)) inFile("buyer-token-cookie", file, "토큰을 브라우저 저장소(localStorage·sessionStorage)에 쓴다. 서버에서 HttpOnly 쿠키에 담는다", BROWSER_TOKEN_STORAGE);
653
+ const cookieWrite = /setCookie\(/;
654
+ if (cookieWrite.test(file.content) && /refreshToken/.test(file.content) && !/httpOnly/.test(file.content)) inFile("buyer-token-cookie", file, "토큰 쌍을 쿠키에 담는데 `httpOnly`가 없다. 브라우저 JS가 읽을 수 있다", cookieWrite);
655
+ const analyticsFactory = /createAnalytics\(/;
656
+ if (isServer && analyticsFactory.test(file.content)) inFile("analytics-start", file, "서버 파일에서 방문 분석을 시작한다. 서버에서는 아무것도 하지 않으니 브라우저(effect)에서 부른다", analyticsFactory);
657
+ if (isServer && /createStorefrontClient\(/.test(file.content) && !/analyticsIdsFromCookie|visitorId/.test(file.content)) inFile("analytics-ids", file, "서버 API 클라이언트에 방문 식별자가 없다. `...analyticsIdsFromCookie(request.headers.get(\"cookie\"))`를 넘긴다", /createStorefrontClient\(/);
599
658
  }
600
- if (has("startPayment(") && !has("@sayren/storefront-sdk/payments")) findings.push({
601
- ruleId: "payment-browser-launch",
602
- file: "(프로젝트 전체)",
603
- detail: "결제를 시작하지만 `@sayren/storefront-sdk/payments`로 결제창을 여는 코드가 없다"
604
- });
659
+ if (has("startPayment(") && !has("@sayren/storefront-sdk/payments")) inProject("payment-browser-launch", "결제를 시작하지만 `@sayren/storefront-sdk/payments`로 결제창을 여는 코드가 없다");
605
660
  const opensPayment = matches(/\bpayments\.open\(|\.open\(\s*(start|paymentStart)\b/);
606
- if (opensPayment && !has("prepareWindow(")) findings.push({
607
- ruleId: "payment-popup-prepare",
608
- file: opensPayment.path,
609
- detail: "받아 둔 결제 시작 값으로 결제창을 열지만 클릭 시점에 `payments.prepareWindow()`로 창을 먼저 여는 코드가 없다"
610
- });
661
+ if (opensPayment && !has("prepareWindow(")) inFile("payment-popup-prepare", opensPayment, "받아 둔 결제 시작 값으로 결제창을 열지만 클릭 시점에 `payments.prepareWindow()`로 창을 먼저 여는 코드가 없다", /\bpayments\.open\(|\.open\(\s*(start|paymentStart)\b/);
662
+ if (opensPayment && !/amounts/.test(opensPayment.content)) inFile("payment-amounts-final", opensPayment, "결제창을 열지만 결제 시작 응답의 `amounts`(확정 금액)를 화면에 반영하지 않는다", /\bpayments\.open\(/);
611
663
  const fixedReturnUrl = matches(/returnUrl\s*[:=]\s*["'`]https?:\/\//);
612
- if (fixedReturnUrl) findings.push({
613
- ruleId: "checkout-origin",
614
- file: fixedReturnUrl.path,
615
- detail: "복귀 주소를 고정 문자열로 두었다. 배포된 주소(요청 origin)에서 만들고 그 도메인을 결제 도메인에 등록한다"
616
- });
617
- if (has("startPayment(") && !has(".result(")) findings.push({
618
- ruleId: "payment-return-route",
619
- file: "(프로젝트 전체)",
620
- detail: "결제 복귀 화면에서 `payments.result()`를 부르는 코드가 없다"
621
- });
622
- if (has(".result(") && !has("PROCESSING")) findings.push({
623
- ruleId: "payment-processing-status",
624
- file: "(프로젝트 전체)",
625
- detail: "결과 확인 중(`PROCESSING`)일 때 결제 상태를 다시 조회하는 코드가 없다"
626
- });
627
- if (has("createStorefrontClient(") && !has("@sayren/storefront-sdk/analytics")) findings.push({
628
- ruleId: "analytics-start",
629
- file: "(프로젝트 전체)",
630
- detail: "방문 분석을 시작하는 코드가 없다. `@sayren/storefront-sdk/analytics`의 createAnalytics를 브라우저에서 한 번 부른다"
631
- });
632
- if (has("createStorefrontClient(") && !has("loader")) findings.push({
633
- ruleId: "loader-first",
634
- file: "(프로젝트 전체)",
635
- detail: "loader에서 데이터를 받는 코드가 없다. 클라이언트 전용으로 그리면 SSR이 죽는다"
636
- });
664
+ if (fixedReturnUrl) inFile("checkout-origin", fixedReturnUrl, "복귀 주소를 고정 문자열로 두었다. 배포된 주소(요청 origin)에서 만들고 그 도메인을 결제 도메인에 등록한다", /returnUrl\s*[:=]\s*["'`]https?:\/\//);
665
+ if (has("startPayment(") && !has(".result(")) inProject("payment-return-route", "결제 복귀 화면에서 `payments.result()`를 부르는 코드가 없다");
666
+ if (has(".result(") && !has("PROCESSING")) inProject("payment-processing-status", "결과 확인 중(`PROCESSING`)일 때 결제 상태를 다시 조회하는 코드가 없다");
667
+ if (has("startPayment(") && has("zipCode") && !has("quoteDelivery(")) inProject("delivery-quote-preview", "배송지 우편번호를 받지만 `checkout.quoteDelivery`로 배송비를 다시 계산하는 코드가 없다");
668
+ if (has("createStorefrontAuth(")) {
669
+ if (!has(".refresh(")) inProject("buyer-session-refresh", "로그인은 하지만 액세스 토큰을 갱신하는 코드가 없다(`auth.refresh(refreshToken)`)");
670
+ if (!has("reconsentRequired")) inProject("buyer-reconsent", "재동의가 필요한 구매자를 처리하는 코드가 없다(`reconsentRequired`)");
671
+ }
672
+ if (has("createStorefrontClient(") && !has("@sayren/storefront-sdk/analytics")) inProject("analytics-start", "방문 분석을 시작하는 코드가 없다. `@sayren/storefront-sdk/analytics`의 createAnalytics를 브라우저에서 한 번 부른다");
673
+ if (has("createStorefrontClient(") && !has("loader")) inProject("loader-first", "loader에서 데이터를 받는 코드가 없다. 클라이언트 전용으로 그리면 SSR이 죽는다");
637
674
  return findings;
638
675
  }
639
676
 
677
+ //#endregion
678
+ //#region src/verify.ts
679
+ const ruleOf = (ruleId) => RULES.find((rule) => rule.id === ruleId);
680
+ function report(finding) {
681
+ const rule = ruleOf(finding.ruleId);
682
+ return {
683
+ ruleId: finding.ruleId,
684
+ rule: rule?.title ?? finding.ruleId,
685
+ file: finding.file,
686
+ ...finding.line === void 0 ? {} : { line: finding.line },
687
+ detail: finding.detail,
688
+ cause: rule?.why ?? "",
689
+ fix: rule?.fix ?? "",
690
+ example: rule?.example ?? "",
691
+ doc: rule?.doc ?? "",
692
+ docUrl: rule ? docUrl(rule.doc) : "",
693
+ ...rule?.templateFile ? { templateFile: rule.templateFile } : {}
694
+ };
695
+ }
696
+ /** 위반을 고치는 순서 — 에이전트가 무엇부터 할지 묻지 않게 한다 */
697
+ function nextSteps(findings) {
698
+ if (!findings.length) return [
699
+ "개발 서버를 띄워 홈에 상품이 보이는지 확인한다.",
700
+ "테스트 결제로 주문을 한 번 만들어 주문 완료 화면까지 간다.",
701
+ "정적 검사가 잡지 못하는 것은 실제 결제 한 번이 잡는다."
702
+ ];
703
+ const files = [...new Set(findings.map((finding) => finding.templateFile).filter(Boolean))];
704
+ return [
705
+ "위반마다 `fix`대로 고친다. `file`·`line`이 고칠 자리이고 `example`이 옳은 쪽 코드다.",
706
+ ...files.length ? [`손대지 않아야 하는 파일을 고친 결과일 수 있다 — \`get_template_file\`로 ${files.join(", ")}의 원본을 받아 대조한다.`] : [],
707
+ "고친 뒤 `verify_storefront`를 다시 부른다.",
708
+ "규칙 배경이 더 필요하면 위반의 `docUrl` 문서를 읽는다."
709
+ ];
710
+ }
711
+ /** 소스를 규칙과 대조해 고칠 수 있는 보고서로 만든다 */
712
+ function buildReport(files) {
713
+ const findings = verifySources(files);
714
+ const counts = /* @__PURE__ */ new Map();
715
+ for (const finding of findings) counts.set(finding.ruleId, (counts.get(finding.ruleId) ?? 0) + 1);
716
+ const results = RULES.map((rule) => ({
717
+ ruleId: rule.id,
718
+ status: counts.has(rule.id) ? "violation" : "pass",
719
+ count: counts.get(rule.id) ?? 0
720
+ }));
721
+ const violated = results.filter((result) => result.status === "violation").length;
722
+ const reported = findings.map(report);
723
+ return {
724
+ ok: findings.length === 0,
725
+ checkedFiles: files.length,
726
+ summary: {
727
+ rules: RULES.length,
728
+ passed: RULES.length - violated,
729
+ violated,
730
+ findings: findings.length
731
+ },
732
+ results,
733
+ findings: reported,
734
+ message: findings.length ? `규칙 ${RULES.length}개 중 ${violated}개를 어겼어요(위반 ${findings.length}건). 아래 findings의 fix대로 고친 뒤 다시 검사해주세요` : `규칙 ${RULES.length}개를 모두 지켰어요. 정적 검사가 잡지 못하는 것이 있으니 직접 띄워 결제까지 한 번 해보세요`,
735
+ nextSteps: nextSteps(reported)
736
+ };
737
+ }
738
+ const SKIP = new Set([
739
+ "node_modules",
740
+ "dist",
741
+ ".output",
742
+ ".tanstack",
743
+ ".git"
744
+ ]);
745
+ /** 프로젝트 폴더에서 검사할 소스를 모은다. 폴더가 아니거나 소스가 없으면 빈 배열 */
746
+ async function collectSources(dir) {
747
+ try {
748
+ if (!statSync(dir).isDirectory()) return [];
749
+ } catch {
750
+ return [];
751
+ }
752
+ const out = [];
753
+ const walk$1 = async (current) => {
754
+ for (const entry of await readdir(current, { withFileTypes: true })) {
755
+ if (SKIP.has(entry.name) || entry.name.startsWith(".")) continue;
756
+ const full = join(current, entry.name);
757
+ if (entry.isDirectory()) await walk$1(full);
758
+ else if (/\.(ts|tsx|js|jsx)$/.test(entry.name)) out.push({
759
+ path: relative(dir, full).split(sep).join("/"),
760
+ content: readFileSync(full, "utf8")
761
+ });
762
+ }
763
+ };
764
+ await walk$1(dir);
765
+ return out;
766
+ }
767
+
768
+ //#endregion
769
+ //#region src/cli.ts
770
+ const USAGE = `사용법: sayren-mcp create <폴더> [--link] [--name <이름>] [--force]
771
+
772
+ --link @sayren/* 의존성을 workspace:*로 둔다(모노레포 안 예제용)
773
+ --name package.json 이름(기본: 폴더 이름)
774
+ --force 비어 있지 않은 폴더에도 쓴다
775
+
776
+ SAYREN_TOKEN이 있으면 스토어 정보를 받아 .env까지 채운다.`;
777
+ function parseArgs(argv) {
778
+ const rest = [...argv];
779
+ const args = {
780
+ dir: "",
781
+ link: false,
782
+ force: false
783
+ };
784
+ while (rest.length) {
785
+ const token = rest.shift();
786
+ if (token === "--link") args.link = true;
787
+ else if (token === "--force") args.force = true;
788
+ else if (token === "--name") args.name = rest.shift();
789
+ else if (token.startsWith("-")) return null;
790
+ else if (!args.dir) args.dir = token;
791
+ else return null;
792
+ }
793
+ return args.dir ? args : null;
794
+ }
795
+ /**
796
+ * MCP 도구가 에이전트에게 주는 것과 **같은 파일**을 사람이 직접 받는 길이다.
797
+ * 에이전트 경로(list/get_template_file)와 결과가 갈리지 않게 같은 함수로 읽는다.
798
+ */
799
+ async function runCreate(argv) {
800
+ const args = parseArgs(argv);
801
+ if (!args) {
802
+ console.error(USAGE);
803
+ return 1;
804
+ }
805
+ const target = resolve(args.dir);
806
+ if (existsSync(target) && readdirSync(target).length > 0 && !args.force) {
807
+ console.error(`폴더가 비어 있지 않아요: ${target} (--force로 덮어쓸 수 있어요)`);
808
+ return 1;
809
+ }
810
+ const name = args.name ?? basename(target);
811
+ const files = listTemplateFiles();
812
+ for (const path of files) {
813
+ const out = join(target, path);
814
+ mkdirSync(dirname(out), { recursive: true });
815
+ writeFileSync(out, readTemplateFile(path, {
816
+ name,
817
+ link: args.link
818
+ }));
819
+ }
820
+ console.log(`템플릿 ${files.length}개 → ${target}`);
821
+ if (process.env.SAYREN_TOKEN) {
822
+ const store = await fetchStoreContext(loadConfig());
823
+ const env = `SAYREN_API_URL=${store.storefrontApiBaseUrl}\nSAYREN_STORE_CODE=${store.storeCode}\n`;
824
+ writeFileSync(join(target, ".env"), env);
825
+ console.log(`.env 작성 — ${store.name} (${store.storeCode})`);
826
+ } else console.log("SAYREN_TOKEN이 없어 .env는 비워 뒀어요. SAYREN_API_URL과 SAYREN_STORE_CODE를 넣어주세요");
827
+ const report$1 = buildReport(await collectSources(target));
828
+ console.log(`\n규칙 검사: ${report$1.summary.passed}/${report$1.summary.rules} 통과`);
829
+ for (const finding of report$1.findings) {
830
+ const at = finding.line ? `${finding.file}:${finding.line}` : finding.file;
831
+ console.log(` · [${finding.ruleId}] ${at} — ${finding.fix}`);
832
+ }
833
+ console.log(`\n다음:\n cd ${args.dir}\n pnpm install\n pnpm dev`);
834
+ console.log(`
835
+ 디자인을 고친 뒤에는 MCP 도구 \`verify_storefront\`로 다시 검사해주세요. 결제·구매자 인증·방문 분석 규칙 ${report$1.summary.rules}개를 보고, 어긴 항목마다 고칠 자리와 수정 방법·예시 코드를 알려 줘요.\n결제까지 해 보려면 셀러 콘솔 설정 › 결제에서 결제 도메인에 이 쇼핑몰 주소를 등록해주세요(테스트 결제는 localhost가 항상 허용돼요).`);
836
+ return 0;
837
+ }
838
+
640
839
  //#endregion
641
840
  //#region src/index.ts
642
841
  if (process.argv[2] === "create") process.exit(await runCreate(process.argv.slice(3)));
@@ -672,7 +871,7 @@ server.registerTool("get_scaffold_plan", {
672
871
  "3. `.env`에 `SAYREN_API_URL`과 `SAYREN_STORE_CODE`를 넣는다(아래 값).",
673
872
  "4. `pnpm install` 후 `pnpm dev`로 띄워 홈에 상품이 보이는지 확인한다.",
674
873
  "5. 사용자가 원하는 디자인은 확장 지점에서만 바꾼다.",
675
- "6. 끝나면 `verify_storefront`로 규칙 위반이 없는지 확인한다."
874
+ "6. 끝나면 `verify_storefront`로 검사한다. 위반마다 고칠 자리(`file`·`line`)와 수정 방법(`fix`)·예시(`example`)가 실려 오니 그대로 고치고 `ok: true`가 될 때까지 다시 부른다."
676
875
  ],
677
876
  env: store ? {
678
877
  SAYREN_API_URL: store.storefrontApiBaseUrl,
@@ -706,7 +905,21 @@ server.registerTool("get_scaffold_plan", {
706
905
  "src/lib/analytics.ts — 방문 분석 시작(브라우저 한 번)과 시작 전 이벤트 큐",
707
906
  "라우트 loader가 서버 함수(createServerFn)로 데이터를 받는 구조"
708
907
  ],
709
- rules: RULES,
908
+ rules: RULES.map((rule) => ({
909
+ ...rule,
910
+ docUrl: docUrl(rule.doc)
911
+ })),
912
+ optional: [{
913
+ what: "현금영수증 신청 입력",
914
+ where: "주문서(`src/routes/checkout.index.tsx`)의 현금영수증 영역",
915
+ note: "계좌이체·가상계좌에서만 받는다(`cashReceiptAvailable(option.method)`). 넣지 않으면 신청 없이 결제된다",
916
+ doc: docUrl("/guides/checkout-flow")
917
+ }, {
918
+ what: "쿠키 동의 배너",
919
+ where: "`src/lib/analytics.ts`의 `consent`",
920
+ note: "템플릿은 `\"granted\"`로 시작한다. EU 등 사전 동의가 필요한 스토어는 `\"pending\"`으로 두고 배너에서 `setConsent`를 부른다",
921
+ doc: docUrl("/guides/storefront-analytics")
922
+ }],
710
923
  templateFiles: listTemplateFiles()
711
924
  });
712
925
  });
@@ -822,53 +1035,17 @@ server.registerTool("call_api_write", {
822
1035
  }, async (input) => invoke("write", input));
823
1036
  server.registerTool("verify_storefront", {
824
1037
  title: "생성 결과 검증",
825
- description: "만들어진 프로젝트를 규칙과 대조한다. 결제가 조용히 실패하는 실수(브라우저 번들의 process.env, 클릭 시점 팝업 선오픈 누락, 결제 복귀 화면의 결과 확인 누락, 결과 확인 중 상태 조회 누락 등)를 잡는다.",
1038
+ description: "만들어진 프로젝트를 규칙과 대조한다. 결제가 조용히 실패하는 실수(브라우저 번들의 process.env, 클릭 시점 팝업 선오픈 누락, 결제 복귀 화면의 결과 확인 누락, 결과 확인 중 상태 조회 누락, 확정 배송비 미반영, 브라우저에 노출된 구매자 토큰 등)를 잡는다. 위반마다 고칠 자리(`file`·`line`)와 원인(`cause`)·수정 방법(`fix`)·예시 코드(`example`)·문서(`docUrl`)를 함께 준다. 규칙별 통과·위반은 `results`에 있다. 화면을 만든 뒤와 디자인을 고친 뒤에 부른다.",
826
1039
  inputSchema: { projectDir: z.string().describe("검사할 프로젝트 경로(절대 경로)") }
827
1040
  }, async ({ projectDir }) => {
828
1041
  const files = await collectSources(projectDir);
829
1042
  if (!files.length) return text({
830
1043
  ok: false,
831
- message: `소스를 찾지 못했어요: ${projectDir}`
832
- });
833
- const findings = verifySources(files);
834
- return text({
835
- ok: findings.length === 0,
836
- checkedFiles: files.length,
837
- findings: findings.map((finding) => ({
838
- ...finding,
839
- rule: RULES.find((rule) => rule.id === finding.ruleId)
840
- })),
841
- message: findings.length === 0 ? "규칙 위반을 찾지 못했어요. 직접 띄워 결제까지 한 번 해보세요" : "아래 항목을 고친 뒤 다시 검사해주세요"
1044
+ message: `소스를 찾지 못했어요: ${projectDir}`,
1045
+ nextSteps: ["`projectDir`이 프로젝트 루트(package.json이 있는 폴더)의 절대 경로인지 확인한다.", "템플릿을 아직 받지 않았으면 `list_template_files`·`get_template_file`로 먼저 만든다."]
842
1046
  });
1047
+ return text(buildReport(files));
843
1048
  });
844
- const SKIP = new Set([
845
- "node_modules",
846
- "dist",
847
- ".output",
848
- ".tanstack",
849
- ".git"
850
- ]);
851
- async function collectSources(dir) {
852
- try {
853
- if (!statSync(dir).isDirectory()) return [];
854
- } catch {
855
- return [];
856
- }
857
- const out = [];
858
- const walk$1 = async (current) => {
859
- for (const entry of await readdir(current, { withFileTypes: true })) {
860
- if (SKIP.has(entry.name) || entry.name.startsWith(".")) continue;
861
- const full = join(current, entry.name);
862
- if (entry.isDirectory()) await walk$1(full);
863
- else if (/\.(ts|tsx|js|jsx)$/.test(entry.name)) out.push({
864
- path: relative(dir, full).split(sep).join("/"),
865
- content: readFileSync(full, "utf8")
866
- });
867
- }
868
- };
869
- await walk$1(dir);
870
- return out;
871
- }
872
1049
  await server.connect(new StdioServerTransport());
873
1050
 
874
1051
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sayren/mcp",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sayren-mcp": "./dist/index.mjs"
@@ -12,8 +12,8 @@
12
12
  "dependencies": {
13
13
  "@modelcontextprotocol/sdk": "^1.22.0",
14
14
  "zod": "^4.6.5",
15
- "@sayren/storefront-sdk": "^0.11.0",
16
- "@sayren/store-sdk": "^0.11.0"
15
+ "@sayren/store-sdk": "^0.12.0",
16
+ "@sayren/storefront-sdk": "^0.11.0"
17
17
  },
18
18
  "devDependencies": {
19
19
  "@biomejs/biome": "^2.5.14",
@@ -7,7 +7,7 @@
7
7
  "zod": "^4.6.5"
8
8
  },
9
9
  "versions": {
10
- "@sayren/store-sdk": "0.11.0",
10
+ "@sayren/store-sdk": "0.12.0",
11
11
  "@sayren/storefront-sdk": "0.11.0",
12
12
  "@sayren/ui": "0.1.0"
13
13
  }