@sayren/mcp 0.4.0 → 0.5.1

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
@@ -19,13 +19,17 @@ const METHODS = [
19
19
  "DELETE"
20
20
  ];
21
21
  /**
22
- * 돈이 나가는 쓰기 — 되돌릴 수 없으므로 에이전트에게 열지 않는다. 콘솔에서 사람이 한다.
23
- * 목록은 operationId(`{Controller}_{method}`)로 고정한다.
22
+ * 서버가 정책(`x-agent-policy`)을 싣지 않는 옛 api에만 쓰는 폴백 — 환불로 이어지는 쓰기를 막는다.
23
+ * 새 api는 문서의 정책이 원천이고 이 목록은 보지 않는다(승인 흐름으로 부를 수 있다).
24
24
  */
25
- const BLOCKED_OPERATIONS = {
25
+ const LEGACY_BLOCKED_OPERATIONS = {
26
26
  OrdersController_cancelBySeller: "셀러 직권취소는 PG 환불로 이어져 되돌릴 수 없다. 셀러 콘솔에서 처리한다",
27
27
  ClaimsController_approve: "클레임 승인은 환불로 이어져 되돌릴 수 없다. 셀러 콘솔에서 처리한다"
28
28
  };
29
+ /** 문서가 에이전트 정책을 싣는 api인가 — 하나라도 있으면 서버가 정책을 강제한다 */
30
+ function servesAgentPolicy(document) {
31
+ return Object.values(document.paths ?? {}).some((item) => Object.values(item).some((operation) => operation?.["x-agent-policy"] !== void 0));
32
+ }
29
33
  /**
30
34
  * 문서에서 에이전트가 쓸 수 있는 오퍼레이션만 고른다.
31
35
  * - `/v1/oauth2/*`: 토큰 발급 표면이다. PAT로 부를 일이 없다
@@ -34,6 +38,7 @@ const BLOCKED_OPERATIONS = {
34
38
  */
35
39
  function buildOperationIndex(document) {
36
40
  const operations = [];
41
+ const serverPolicy = servesAgentPolicy(document);
37
42
  for (const [path, item] of Object.entries(document.paths ?? {})) {
38
43
  if (!path.startsWith("/v1/") || path.startsWith("/v1/oauth2/")) continue;
39
44
  for (const method of METHODS) {
@@ -41,6 +46,7 @@ function buildOperationIndex(document) {
41
46
  if (!raw?.operationId) continue;
42
47
  const scopes = raw["x-required-scopes"] ?? [];
43
48
  if (scopes.length === 0 || raw["x-console-only"]) continue;
49
+ const policy = raw["x-agent-policy"];
44
50
  operations.push({
45
51
  operationId: raw.operationId,
46
52
  method,
@@ -49,7 +55,11 @@ function buildOperationIndex(document) {
49
55
  summary: raw.summary ?? raw.operationId,
50
56
  scopes,
51
57
  deprecated: raw.deprecated === true,
52
- blocked: BLOCKED_OPERATIONS[raw.operationId],
58
+ blocked: serverPolicy ? policy?.decision === "DENIED" ? policy.reason : void 0 : LEGACY_BLOCKED_OPERATIONS[raw.operationId],
59
+ approval: policy?.decision === "APPROVAL_REQUIRED" ? {
60
+ reason: policy.reason,
61
+ ...policy.condition ? { condition: policy.condition } : {}
62
+ } : void 0,
53
63
  raw
54
64
  });
55
65
  }
@@ -63,7 +73,8 @@ function formatOperationIndex(operations) {
63
73
  const flags = [
64
74
  op.scopes.join(","),
65
75
  op.deprecated ? "deprecated" : "",
66
- op.blocked ? "호출 불가" : ""
76
+ op.blocked ? "호출 불가" : "",
77
+ op.approval ? op.approval.condition ? "조건부 승인 필요" : "승인 필요" : ""
67
78
  ].filter(Boolean);
68
79
  const lines = byTag[op.tag] ?? [];
69
80
  lines.push(`${op.operationId} · ${op.method} ${op.path} · ${op.summary} [${flags.join(" · ")}]`);
@@ -102,6 +113,10 @@ function describeOperation(op, document) {
102
113
  requiredScopes: op.scopes,
103
114
  deprecated: op.deprecated || void 0,
104
115
  blocked: op.blocked,
116
+ approval: op.approval ? {
117
+ ...op.approval,
118
+ note: "부르면 서버가 실행하지 않고 승인 대기(202)로 둔다. 셀러가 콘솔에서 승인하면 서버가 그대로 실행한다. `dryRun: true`로 먼저 영향을 확인한다"
119
+ } : void 0,
105
120
  tool: op.blocked ? void 0 : op.method === "GET" ? "call_api_read" : "call_api_write",
106
121
  pathParams: parameters.filter((p) => p.in === "path").map((p) => p.name),
107
122
  query: parameters.filter((p) => p.in === "query").map((p) => ({
@@ -127,14 +142,14 @@ function buildPath(template, params) {
127
142
  const used = /* @__PURE__ */ new Set();
128
143
  const path = template.replace(/\{([^}]+)\}/g, (_match, name) => {
129
144
  const value = params[name];
130
- if (value === void 0 || value === "") throw new Error(`경로 값이 없어요: ${name}`);
145
+ if (value === void 0 || value === "") throw new Error(`경로 값이 없습니다: ${name}`);
131
146
  const text$1 = String(value);
132
- if (text$1 === "." || text$1 === ".." || !SAFE_SEGMENT.test(text$1)) throw new Error(`쓸 수 없는 경로 값이에요: ${name}`);
147
+ if (text$1 === "." || text$1 === ".." || !SAFE_SEGMENT.test(text$1)) throw new Error(`쓸 수 없는 경로 값입니다: ${name}`);
133
148
  used.add(name);
134
149
  return encodeURIComponent(text$1);
135
150
  });
136
151
  const extra = Object.keys(params).filter((name) => !used.has(name));
137
- if (extra.length) throw new Error(`경로에 없는 값이에요: ${extra.join(", ")}`);
152
+ if (extra.length) throw new Error(`경로에 없는 값입니다: ${extra.join(", ")}`);
138
153
  return path;
139
154
  }
140
155
  /**
@@ -148,7 +163,7 @@ function truncateData(data, maxChars) {
148
163
  while (kept > 0 && JSON.stringify(rebuild(items.slice(0, kept))).length > maxChars) kept = Math.floor(kept / 2);
149
164
  return {
150
165
  data: rebuild(items.slice(0, kept)),
151
- truncated: `목록 ${items.length}개 중 ${kept}개만 실었어요. size·page로 나눠 조회하세요`
166
+ truncated: `목록 ${items.length}개 중 ${kept}개만 실었습니다. size·page로 나눠 조회하십시오`
152
167
  };
153
168
  };
154
169
  if (Array.isArray(data)) return shrink(data, (kept) => kept);
@@ -160,7 +175,110 @@ function truncateData(data, maxChars) {
160
175
  const text$1 = JSON.stringify(data);
161
176
  return {
162
177
  data: `${text$1.slice(0, maxChars)}…`,
163
- truncated: `응답 ${text$1.length}자 중 ${maxChars}자만 실었어요`
178
+ truncated: `응답 ${text$1.length}자 중 ${maxChars}자만 실었습니다`
179
+ };
180
+ }
181
+ /** 같은 멱등키의 승인이 끝났을 때 상태별 안내 — 에이전트가 다음에 할 일 */
182
+ const DECIDED_GUIDE = {
183
+ SUCCEEDED: {
184
+ message: "이 멱등키의 요청은 셀러가 승인해서 이미 실행됐습니다. 다시 실행하지 않았습니다",
185
+ nextSteps: ["실행 결과는 `result`다. 같은 요청을 다시 보내지 않는다"]
186
+ },
187
+ FAILED: {
188
+ message: "이 멱등키의 요청은 승인 뒤 실행했지만 실패했습니다",
189
+ nextSteps: ["`result.error`로 원인을 사용자에게 알린다", "고쳐서 다시 하려면 새 멱등키로 요청한다(같은 키는 이 결과만 돌려준다)"]
190
+ },
191
+ UNKNOWN: {
192
+ message: "이 멱등키의 요청은 승인 뒤 실행 결과를 확인하지 못했습니다. 이미 실행됐을 수 있습니다",
193
+ nextSteps: ["다시 보내지 않는다. 대상 리소스를 조회해 바뀌었는지 확인하고 사용자에게 알린다", "셀러에게 콘솔의 감사 로그에서 실행 여부를 확인해 달라고 전한다"]
194
+ },
195
+ REJECTED: {
196
+ message: "이 멱등키의 요청은 셀러가 거절했습니다. 아무것도 바뀌지 않았습니다",
197
+ nextSteps: ["거절 사유(`decisionNote`)를 전한다. 사용자가 원하면 새 멱등키로 다시 요청한다"]
198
+ },
199
+ EXPIRED: {
200
+ message: "이 멱등키의 요청은 승인 기한(24시간)이 지났습니다. 아무것도 바뀌지 않았습니다",
201
+ nextSteps: ["사용자가 원하면 새 멱등키로 다시 요청한다"]
202
+ }
203
+ };
204
+ /**
205
+ * 쓰기 결과를 에이전트가 읽을 형태로 바꾼다. 승인 대기(202 `APPROVAL_REQUIRED`)는 성공이 아니다 — 아직 아무것도
206
+ * 바뀌지 않았다는 것과 셀러에게 전할 링크, 상태를 확인할 도구를 함께 준다. 같은 멱등키의 승인이 이미 끝났으면
207
+ * (200 `APPROVAL_ALREADY_DECIDED`) `data.status`로 결과와 다음 할 일을 알린다. 에이전트 정책 거부(403)는 다시 시도하지
208
+ * 않게 알린다. 다른 202(비동기 접수)는 승인 대기가 아니다 — `meta.code`로만 가른다.
209
+ */
210
+ function describeWriteResult(result) {
211
+ if (result.ok && result.code === "APPROVAL_ALREADY_DECIDED") {
212
+ const approval = result.data ?? {};
213
+ const guide = DECIDED_GUIDE[approval.status ?? ""] ?? DECIDED_GUIDE.EXPIRED;
214
+ return {
215
+ isError: approval.status !== "SUCCEEDED",
216
+ body: {
217
+ status: `APPROVAL_${approval.status ?? "DECIDED"}`,
218
+ applied: approval.status === "SUCCEEDED" ? true : approval.status === "UNKNOWN" ? "unknown" : false,
219
+ message: guide?.message,
220
+ approvalId: approval.approvalId,
221
+ result: approval.result,
222
+ decisionNote: approval.decisionNote ?? void 0,
223
+ nextSteps: guide?.nextSteps
224
+ }
225
+ };
226
+ }
227
+ if (result.ok && result.code === "APPROVAL_REQUIRED") {
228
+ const pending = result.data ?? {};
229
+ if (pending.status === "EXECUTING") return {
230
+ isError: false,
231
+ body: {
232
+ status: "APPROVAL_EXECUTING",
233
+ applied: "unknown",
234
+ message: "셀러가 승인해서 서버가 이 요청을 실행하고 있습니다",
235
+ approvalId: pending.approvalId,
236
+ nextSteps: ["같은 요청을 다시 보내지 않는다. 결과는 `get_approval_request`로 확인한다"]
237
+ }
238
+ };
239
+ return {
240
+ isError: false,
241
+ body: {
242
+ status: "APPROVAL_PENDING",
243
+ applied: false,
244
+ message: "승인 대기 중입니다. 아직 아무것도 바뀌지 않았습니다. 셀러가 콘솔에서 승인하면 서버가 이 요청을 그대로 실행합니다",
245
+ approvalId: pending.approvalId,
246
+ reason: pending.reason,
247
+ expiresAt: pending.expiresAt,
248
+ approvalUrl: pending.approvalUrl ?? void 0,
249
+ preview: pending.preview,
250
+ nextSteps: [pending.approvalUrl ? `사용자에게 승인 화면 링크를 전한다: ${pending.approvalUrl}` : "사용자에게 셀러 콘솔 › 설정 › 승인 요청에서 승인해 달라고 전한다", "같은 요청을 다시 보내지 않는다. 결과는 `get_approval_request`로 확인한다"]
251
+ }
252
+ };
253
+ }
254
+ if (result.ok && result.code === "DRY_RUN") return {
255
+ isError: false,
256
+ body: {
257
+ status: "DRY_RUN",
258
+ applied: false,
259
+ message: "실행하지 않았습니다. 서버의 판정과 영향 미리보기입니다",
260
+ ...result.data
261
+ }
262
+ };
263
+ if (!result.ok) {
264
+ if (result.error?.code === "AGENT_OPERATION_DENIED") return {
265
+ isError: true,
266
+ body: {
267
+ ...result,
268
+ nextSteps: ["에이전트는 이 작업을 할 수 없다. 다시 시도하지 말고 셀러에게 콘솔에서 직접 하도록 안내한다"]
269
+ }
270
+ };
271
+ return {
272
+ isError: true,
273
+ body: { ...result }
274
+ };
275
+ }
276
+ return {
277
+ isError: false,
278
+ body: {
279
+ status: result.status,
280
+ data: result.data
281
+ }
164
282
  };
165
283
  }
166
284
 
@@ -196,7 +314,7 @@ var AdminApi = class {
196
314
  redirect: "error",
197
315
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
198
316
  });
199
- if (!response.ok) throw new Error(`API 문서를 읽지 못했어요 (HTTP ${response.status})`);
317
+ if (!response.ok) throw new Error(`API 문서를 읽지 못했습니다 (HTTP ${response.status})`);
200
318
  const document = await response.json();
201
319
  this.spec = {
202
320
  document,
@@ -208,7 +326,7 @@ var AdminApi = class {
208
326
  async find(operationId) {
209
327
  const { document, operations } = await this.load();
210
328
  const op = operations.find((candidate) => candidate.operationId === operationId);
211
- if (!op) throw new Error(`쓸 수 있는 오퍼레이션이 아니에요: ${operationId}. list_operations의 operationId를 쓰세요`);
329
+ if (!op) throw new Error(`쓸 수 있는 오퍼레이션이 아닙니다: ${operationId}. list_operations의 operationId를 쓰십시오`);
212
330
  return {
213
331
  document,
214
332
  op
@@ -223,6 +341,7 @@ var AdminApi = class {
223
341
  };
224
342
  if (input.body !== void 0) headers["content-type"] = "application/json";
225
343
  if (input.idempotencyKey) headers["idempotency-key"] = input.idempotencyKey;
344
+ if (input.dryRun) headers["x-sayren-dry-run"] = "1";
226
345
  const response = await this.fetchImpl(url, {
227
346
  method: input.method,
228
347
  headers,
@@ -238,6 +357,7 @@ var AdminApi = class {
238
357
  return response.ok ? {
239
358
  ok: true,
240
359
  status: response.status,
360
+ ...payload?.meta?.code ? { code: payload.meta.code } : {},
241
361
  data: payload?.data ?? payload
242
362
  } : {
243
363
  ok: false,
@@ -252,7 +372,7 @@ var AdminApi = class {
252
372
  const DEFAULT_API_ORIGIN = "https://api.sayren.app";
253
373
  function loadConfig(env = process.env) {
254
374
  const token = env.SAYREN_TOKEN?.trim();
255
- if (!token) throw new Error("SAYREN_TOKEN이 없어요. 셀러 콘솔 개발자 공간 › API 토큰에서 발급한 토큰을 MCP 설정의 env에 넣어주세요");
375
+ if (!token) throw new Error("SAYREN_TOKEN이 없습니다. 셀러 콘솔 개발자 공간 › API 토큰에서 발급한 토큰을 MCP 설정의 env에 넣어주십시오");
256
376
  const origin = (env.SAYREN_API_ORIGIN ?? DEFAULT_API_ORIGIN).replace(/\/+$/, "");
257
377
  return {
258
378
  apiOrigin: origin,
@@ -270,7 +390,7 @@ async function callApi(url, init = {}) {
270
390
  const body = await response.json().catch(() => null);
271
391
  if (!response.ok) {
272
392
  const code = body?.error?.code ?? String(response.status);
273
- const message = body?.error?.message ?? "요청이 실패했어요";
393
+ const message = body?.error?.message ?? "요청이 실패했습니다";
274
394
  throw new Error(`${code}: ${message}`);
275
395
  }
276
396
  return body?.data ?? body;
@@ -292,7 +412,7 @@ async function fetchStoreContext(config$1) {
292
412
  const payment = paymentSettings ? {
293
413
  connectedProviders: connected.map((p) => p.provider),
294
414
  sandboxOnly: connected.length === 0 || connected.every((p) => p.sandboxMode)
295
- } : { unavailable: "결제 설정은 셀러 콘솔 OWNER만 볼 수 있어 이 토큰으로는 확인하지 못했어요" };
415
+ } : { unavailable: "결제 설정은 셀러 콘솔 OWNER만 볼 수 있어 이 토큰으로는 확인하지 못했습니다" };
296
416
  return {
297
417
  storeId: store.storeId,
298
418
  storeCode: store.storeCode,
@@ -399,12 +519,12 @@ function resolveSpec(name, spec, meta, options) {
399
519
  if (options.link && name.startsWith("@sayren/")) return "workspace:*";
400
520
  if (spec === "catalog:" || spec === "catalog:default") {
401
521
  const version$1 = meta.catalog[name];
402
- if (!version$1) throw new Error(`catalog에 ${name} 버전이 없어요`);
522
+ if (!version$1) throw new Error(`catalog에 ${name} 버전이 없습니다`);
403
523
  return version$1;
404
524
  }
405
525
  if (spec.startsWith("workspace:")) {
406
526
  const version$1 = meta.versions[name];
407
- if (!version$1) throw new Error(`${name}의 배포 버전을 모르겠어요`);
527
+ if (!version$1) throw new Error(`${name}의 배포 버전을 모르겠습니다`);
408
528
  return `^${version$1}`;
409
529
  }
410
530
  return spec;
@@ -478,7 +598,7 @@ function resolveRoot() {
478
598
  for (const candidate of CANDIDATES) try {
479
599
  if (statSync(candidate).isDirectory()) return candidate;
480
600
  } catch {}
481
- throw new Error("스토어프론트 템플릿을 찾지 못했어요");
601
+ throw new Error("스토어프론트 템플릿을 찾지 못했습니다");
482
602
  }
483
603
  const TEMPLATE_ROOT = resolveRoot();
484
604
  function walk(dir, acc) {
@@ -503,7 +623,7 @@ function listTemplateFiles() {
503
623
  */
504
624
  function readTemplateFile(path, options = {}) {
505
625
  const normalized = path.replace(/^\.?\//, "");
506
- if (!listTemplateFiles().includes(normalized)) throw new Error(`템플릿에 없는 파일이에요: ${path}`);
626
+ if (!listTemplateFiles().includes(normalized)) throw new Error(`템플릿에 없는 파일입니다: ${path}`);
507
627
  let file = join(TEMPLATE_ROOT, normalized);
508
628
  if (normalized.endsWith(".gitignore") && !existsSync(file)) file = file.replace(/\.gitignore$/, BUNDLED_GITIGNORE);
509
629
  const raw = readFileSync(file, "utf8");
@@ -802,7 +922,7 @@ function buildReport(files) {
802
922
  },
803
923
  results,
804
924
  findings: reported,
805
- message: findings.length ? `규칙 ${RULES.length}개 중 ${violated}개를 어겼어요(위반 ${findings.length}건). 아래 findings의 fix대로 고친 뒤 다시 검사해주세요` : `규칙 ${RULES.length}개를 모두 지켰어요. 정적 검사가 잡지 못하는 것이 있으니 직접 띄워 결제까지 한 번 해보세요`,
925
+ message: findings.length ? `규칙 ${RULES.length}개 중 ${violated}개를 어겼습니다(위반 ${findings.length}건). 아래 findings의 fix대로 고친 뒤 다시 검사해주십시오` : `규칙 ${RULES.length}개를 모두 지켰습니다. 정적 검사가 잡지 못하는 것이 있으니 직접 띄워 결제까지 한 번 해보십시오`,
806
926
  nextSteps: nextSteps(reported)
807
927
  };
808
928
  }
@@ -875,7 +995,7 @@ async function runCreate(argv) {
875
995
  }
876
996
  const target = resolve(args.dir);
877
997
  if (existsSync(target) && readdirSync(target).length > 0 && !args.force) {
878
- console.error(`폴더가 비어 있지 않아요: ${target} (--force로 덮어쓸 수 있어요)`);
998
+ console.error(`폴더가 비어 있지 않습니다: ${target} (--force로 덮어쓸 수 있습니다)`);
879
999
  return 1;
880
1000
  }
881
1001
  const name = args.name ?? basename(target);
@@ -895,7 +1015,7 @@ async function runCreate(argv) {
895
1015
  const env = `SAYREN_API_URL=${store.storefrontApiBaseUrl}\nSAYREN_STORE_CODE=${store.storeCode}\n`;
896
1016
  writeFileSync(join(target, ".env"), env);
897
1017
  console.log(`.env 작성 — ${store.name} (${store.storeCode})`);
898
- } else console.log("SAYREN_TOKEN이 없어 .env는 비워 뒀어요. SAYREN_API_URL과 SAYREN_STORE_CODE를 넣어주세요");
1018
+ } else console.log("SAYREN_TOKEN이 없어 .env는 비워 뒀습니다. SAYREN_API_URL과 SAYREN_STORE_CODE를 넣어주십시오");
899
1019
  const startedAt = Date.now();
900
1020
  const report$1 = buildReport(await collectSources(target));
901
1021
  const durationMs = Date.now() - startedAt;
@@ -906,7 +1026,7 @@ async function runCreate(argv) {
906
1026
  }
907
1027
  console.log(`\n다음:\n cd ${args.dir}\n pnpm install\n pnpm dev`);
908
1028
  console.log(`
909
- 디자인을 고친 뒤에는 MCP 도구 \`verify_storefront\`로 다시 검사해주세요. 결제·구매자 인증·방문 분석 규칙 ${report$1.summary.rules}개를 보고, 어긴 항목마다 고칠 자리와 수정 방법·예시 코드를 알려 줘요.\n결제까지 해 보려면 셀러 콘솔 설정 › 결제에서 결제 도메인에 이 쇼핑몰 주소를 등록해주세요(테스트 결제는 localhost가 항상 허용돼요).`);
1029
+ 디자인을 고친 뒤에는 MCP 도구 \`verify_storefront\`로 다시 검사해주십시오. 결제·구매자 인증·방문 분석 규칙 ${report$1.summary.rules}개를 보고, 어긴 항목마다 고칠 자리와 수정 방법·예시 코드를 알려 줍니다.\n결제까지 해 보려면 셀러 콘솔 설정 › 결제에서 결제 도메인에 이 쇼핑몰 주소를 등록해주십시오(테스트 결제는 localhost가 항상 허용됩니다).`);
910
1030
  if (config$1) await new Telemetry(config$1).reportVerify("create", report$1, durationMs);
911
1031
  return 0;
912
1032
  }
@@ -1067,8 +1187,21 @@ async function invoke(expected, input) {
1067
1187
  path: buildPath(op.path, input.pathParams ?? {}),
1068
1188
  query: input.query,
1069
1189
  body: input.body,
1070
- idempotencyKey: input.idempotencyKey
1190
+ idempotencyKey: input.idempotencyKey,
1191
+ dryRun: expected === "write" && input.dryRun === true
1071
1192
  });
1193
+ if (expected === "write") {
1194
+ const described = describeWriteResult(result);
1195
+ if (described.isError) return {
1196
+ ...text(described.body),
1197
+ isError: true
1198
+ };
1199
+ const { data: data$1, truncated: truncated$1 } = truncateData(described.body, MAX_RESPONSE_CHARS);
1200
+ return text(truncated$1 ? {
1201
+ ...data$1,
1202
+ truncated: truncated$1
1203
+ } : data$1);
1204
+ }
1072
1205
  if (!result.ok) return {
1073
1206
  ...text(result),
1074
1207
  isError: true
@@ -1095,13 +1228,14 @@ server.registerTool("call_api_read", {
1095
1228
  }, async (input) => invoke("read", input));
1096
1229
  server.registerTool("call_api_write", {
1097
1230
  title: "관리 API 변경",
1098
- description: `POST·PUT·PATCH·DELETE 오퍼레이션을 부른다. 스토어 데이터가 실제로 바뀐다. 사용자가 요청한 변경만 한다. \`describe_operation\`에서 \`idempotencyKey: true\`인 오퍼레이션은 키를 새로 만들어 넣고, 재시도할 때는 같은 키를 쓴다. 환불로 이어지는 오퍼레이션(직권취소·클레임 승인)은 막혀 있다. ${UNTRUSTED_NOTE}`,
1231
+ description: `POST·PUT·PATCH·DELETE 오퍼레이션을 부른다. 스토어 데이터가 실제로 바뀐다. 사용자가 요청한 변경만 한다. \`describe_operation\`에서 \`idempotencyKey: true\`인 오퍼레이션은 키를 새로 만들어 넣고, 재시도할 때는 같은 키를 쓴다. 바꾸기 전에 \`dryRun: true\`로 먼저 불러 대상·변경 전후·금액 영향과 승인 필요 여부를 사용자에게 보여 준다(서버가 실행하지 않는다). 환불·가격·할인처럼 승인이 필요한 쓰기는 서버가 승인 대기(\`APPROVAL_PENDING\`)로 두고, 셀러가 콘솔에서 승인하면 서버가 실행한다 — 결과 링크를 사용자에게 전하고 \`get_approval_request\`로 상태를 확인한다. 결제 설정·토큰 발급처럼 에이전트에게 거부된 작업은 다시 시도하지 않는다. ${UNTRUSTED_NOTE}`,
1099
1232
  inputSchema: {
1100
1233
  operationId: z.string().describe("`list_operations`가 준 operationId (GET 제외)"),
1101
1234
  pathParams: pathParamsInput,
1102
1235
  query: queryInput,
1103
1236
  body: z.unknown().optional().describe("요청 본문(JSON). `describe_operation`의 body 스키마를 따른다"),
1104
- idempotencyKey: z.string().max(255).optional().describe("멱등키. 재시도에는 같은 값을 쓴다")
1237
+ idempotencyKey: z.string().max(255).optional().describe("멱등키. 재시도에는 같은 값을 쓴다"),
1238
+ dryRun: z.boolean().optional().describe("true면 실행하지 않고 서버의 판정(ALLOW·APPROVAL_REQUIRED·DENIED)과 영향 미리보기만 받는다")
1105
1239
  },
1106
1240
  annotations: {
1107
1241
  readOnlyHint: false,
@@ -1110,6 +1244,29 @@ server.registerTool("call_api_write", {
1110
1244
  openWorldHint: false
1111
1245
  }
1112
1246
  }, async (input) => invoke("write", input));
1247
+ server.registerTool("get_approval_request", {
1248
+ title: "승인 요청 상태",
1249
+ description: "`call_api_write`가 승인 대기(`APPROVAL_PENDING`)로 돌려준 요청의 상태를 본다. `PENDING`은 아직 대기, `EXECUTING`은 승인 뒤 실행 중, `SUCCEEDED`·`FAILED`는 서버가 실행한 결과(`result`), `UNKNOWN`은 실행 결과를 확인하지 못함(이미 실행됐을 수 있으니 다시 보내지 말고 대상을 조회해 확인한다), `REJECTED`는 거절, `EXPIRED`는 기한(24시간) 지남이다. 승인은 셀러가 콘솔에서 한다 — 이 도구로 승인할 수 없다.",
1250
+ inputSchema: { approvalId: z.string().describe("승인 대기 응답의 approvalId") },
1251
+ annotations: {
1252
+ readOnlyHint: true,
1253
+ openWorldHint: false
1254
+ }
1255
+ }, async ({ approvalId }) => {
1256
+ const result = await adminApi.call({
1257
+ method: "GET",
1258
+ path: buildPath("/v1/approval-requests/{approvalId}", { approvalId })
1259
+ });
1260
+ if (!result.ok) return {
1261
+ ...text(result),
1262
+ isError: true
1263
+ };
1264
+ const { data, truncated } = truncateData(result.data, MAX_RESPONSE_CHARS);
1265
+ return text({
1266
+ data,
1267
+ ...truncated ? { truncated } : {}
1268
+ });
1269
+ });
1113
1270
  server.registerTool("verify_storefront", {
1114
1271
  title: "생성 결과 검증",
1115
1272
  description: "만들어진 프로젝트를 규칙과 대조한다. 결제가 조용히 실패하는 실수(브라우저 번들의 process.env, 클릭 시점 팝업 선오픈 누락, 결제 복귀 화면의 결과 확인 누락, 결과 확인 중 상태 조회 누락, 확정 배송비 미반영, 브라우저에 노출된 구매자 토큰 등)를 잡는다. 위반마다 고칠 자리(`file`·`line`)와 원인(`cause`)·수정 방법(`fix`)·예시 코드(`example`)·문서(`docUrl`)를 함께 준다. 규칙별 통과·위반은 `results`에 있다. 화면을 만든 뒤와 디자인을 고친 뒤에 부른다.",
@@ -1119,7 +1276,7 @@ server.registerTool("verify_storefront", {
1119
1276
  const files = await collectSources(projectDir);
1120
1277
  if (!files.length) return text({
1121
1278
  ok: false,
1122
- message: `소스를 찾지 못했어요: ${projectDir}`,
1279
+ message: `소스를 찾지 못했습니다: ${projectDir}`,
1123
1280
  nextSteps: ["`projectDir`이 프로젝트 루트(package.json이 있는 폴더)의 절대 경로인지 확인한다.", "템플릿을 아직 받지 않았으면 `list_template_files`·`get_template_file`로 먼저 만든다."]
1124
1281
  });
1125
1282
  const report$1 = buildReport(files);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sayren/mcp",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
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/store-sdk": "^0.13.0",
16
- "@sayren/storefront-sdk": "^0.11.0"
15
+ "@sayren/store-sdk": "^0.14.1",
16
+ "@sayren/storefront-sdk": "^0.11.1"
17
17
  },
18
18
  "devDependencies": {
19
19
  "@biomejs/biome": "^2.5.14",
@@ -7,8 +7,8 @@
7
7
  "zod": "^4.6.5"
8
8
  },
9
9
  "versions": {
10
- "@sayren/store-sdk": "0.13.0",
11
- "@sayren/storefront-sdk": "0.11.0",
10
+ "@sayren/store-sdk": "0.14.1",
11
+ "@sayren/storefront-sdk": "0.11.1",
12
12
  "@sayren/ui": "0.1.0"
13
13
  }
14
14
  }
@@ -18,6 +18,6 @@ export function resolveStoreCode(): string {
18
18
  const fromHost = storeCodeFromHost(getRequestHeader("host"));
19
19
  if (fromHost) return fromHost;
20
20
  throw new Error(
21
- "스토어 코드를 알 수 없어요. SAYREN_STORE_CODE 환경변수를 설정하거나 서브도메인으로 서비스해주세요",
21
+ "스토어 코드를 알 수 없습니다. SAYREN_STORE_CODE 환경변수를 설정하거나 서브도메인으로 서비스해주십시오",
22
22
  );
23
23
  }
@@ -67,14 +67,14 @@ function Root() {
67
67
  function RootError({ error }: ErrorComponentProps) {
68
68
  return (
69
69
  <ErrorView
70
- title="문제가 생겼어요"
70
+ title="문제가 생겼습니다"
71
71
  detail={error instanceof Error ? error.message : undefined}
72
72
  />
73
73
  );
74
74
  }
75
75
 
76
76
  function NotFound() {
77
- return <ErrorView title="찾을 수 없어요" />;
77
+ return <ErrorView title="찾을 수 없습니다" />;
78
78
  }
79
79
 
80
80
  function ErrorView({ title, detail }: { title: string; detail?: string }) {
@@ -53,7 +53,7 @@ const changeConsents = createServerFn({ method: "POST" })
53
53
  .validator(consentInput)
54
54
  .handler(async ({ data }) => {
55
55
  const accessToken = readToken();
56
- if (!accessToken) return { error: "다시 로그인해 주세요" };
56
+ if (!accessToken) return { error: "다시 로그인해 주십시오" };
57
57
  try {
58
58
  await apiFor({ accessToken }).member.updateConsents({
59
59
  ...(data.reconsent ? { terms: true as const, privacy: true as const } : {}),
@@ -73,7 +73,7 @@ const startLink = createServerFn({ method: "POST" })
73
73
  .validator(providerInput)
74
74
  .handler(async ({ data }) => {
75
75
  const accessToken = readToken();
76
- if (!accessToken) return { url: null, error: "다시 로그인해 주세요" };
76
+ if (!accessToken) return { url: null, error: "다시 로그인해 주십시오" };
77
77
  const origin = new URL(getRequestUrl()).origin;
78
78
  try {
79
79
  const started = await authFor().linkIdp(accessToken, data.provider, {
@@ -95,7 +95,7 @@ const unlink = createServerFn({ method: "POST" })
95
95
  .validator(providerInput)
96
96
  .handler(async ({ data }) => {
97
97
  const accessToken = readToken();
98
- if (!accessToken) return { error: "다시 로그인해 주세요" };
98
+ if (!accessToken) return { error: "다시 로그인해 주십시오" };
99
99
  try {
100
100
  await authFor().unlinkIdp(accessToken, data.provider);
101
101
  return { error: null };
@@ -105,18 +105,18 @@ const unlink = createServerFn({ method: "POST" })
105
105
  });
106
106
 
107
107
  const ERROR_MESSAGE: Record<string, string> = {
108
- IDENTITY_IN_USE: "이 계정은 이미 다른 회원에 연결돼 있어요",
108
+ IDENTITY_IN_USE: "이 계정은 이미 다른 회원에 연결돼 있습니다",
109
109
  PROVIDER_ALREADY_LINKED:
110
- "같은 서비스의 다른 계정이 이미 연결돼 있어요. 해제한 뒤 다시 연결해 주세요",
111
- LAST_LOGIN_METHOD: "마지막 로그인 수단은 해제할 수 없어요. 다른 계정을 먼저 연결해 주세요",
112
- access_denied: "연결을 취소했어요",
113
- state_mismatch: "연결 요청이 만료됐어요. 다시 시도해 주세요",
110
+ "같은 서비스의 다른 계정이 이미 연결돼 있습니다. 해제한 뒤 다시 연결해 주십시오",
111
+ LAST_LOGIN_METHOD: "마지막 로그인 수단은 해제할 수 없습니다. 다른 계정을 먼저 연결해 주십시오",
112
+ access_denied: "연결을 취소했습니다",
113
+ state_mismatch: "연결 요청이 만료됐습니다. 다시 시도해 주십시오",
114
114
  };
115
115
 
116
116
  function accountErrorMessage(error: unknown): string {
117
117
  if (error instanceof ApiError)
118
- return ERROR_MESSAGE[error.code] ?? "처리하지 못했어요. 잠시 후 다시 시도해 주세요";
119
- return "처리하지 못했어요. 잠시 후 다시 시도해 주세요";
118
+ return ERROR_MESSAGE[error.code] ?? "처리하지 못했습니다. 잠시 후 다시 시도해 주십시오";
119
+ return "처리하지 못했습니다. 잠시 후 다시 시도해 주십시오";
120
120
  }
121
121
 
122
122
  export const Route = createFileRoute("/account")({
@@ -135,7 +135,7 @@ function Account() {
135
135
  const error =
136
136
  actionError ??
137
137
  (callbackError
138
- ? (ERROR_MESSAGE[callbackError] ?? "연결하지 못했어요. 다시 시도해 주세요")
138
+ ? (ERROR_MESSAGE[callbackError] ?? "연결하지 못했습니다. 다시 시도해 주십시오")
139
139
  : null);
140
140
 
141
141
  const changeConsent = (data: { reconsent?: boolean; marketing?: boolean }) => {
@@ -187,7 +187,7 @@ function Account() {
187
187
  <h2 className="font-bold text-lg">로그인 수단</h2>
188
188
  {linked ? (
189
189
  <p className="rounded-md bg-chip px-3 py-2 text-sm">
190
- {PROVIDER_NAME[linked]} 계정을 연결했어요
190
+ {PROVIDER_NAME[linked]} 계정을 연결했습니다
191
191
  </p>
192
192
  ) : null}
193
193
  <form onSubmit={submit}>
@@ -240,7 +240,7 @@ function Account() {
240
240
  </form>
241
241
  {error ? <p className="text-point text-sm">{error}</p> : null}
242
242
  <p className="text-muted text-xs">
243
- 연결한 계정으로도 같은 회원으로 로그인해요. 마지막 로그인 수단은 해제할 수 없어요.
243
+ 연결한 계정으로도 같은 회원으로 로그인합니다. 마지막 로그인 수단은 해제할 수 없습니다.
244
244
  </p>
245
245
  </section>
246
246
 
@@ -252,7 +252,7 @@ function Account() {
252
252
  <span className="font-medium">이용약관 · 개인정보 수집이용</span>
253
253
  <span className="text-muted text-xs">
254
254
  {consents.reconsentRequired
255
- ? "약관이 개정됐어요. 다시 동의해 주세요"
255
+ ? "약관이 개정됐습니다. 다시 동의해 주십시오"
256
256
  : `동의 완료 · v${consents.terms.agreedVersion ?? "-"}`}
257
257
  </span>
258
258
  {/* 스토어가 약관 주소를 정했으면 열어 볼 수 있게 한다 */}
@@ -73,7 +73,7 @@ function CartPage() {
73
73
  if (!cart.items.length) {
74
74
  return (
75
75
  <div className="space-y-4 py-16 text-center">
76
- <p className="text-muted">장바구니가 비었어요</p>
76
+ <p className="text-muted">장바구니가 비었습니다</p>
77
77
  <Link to="/products" className="inline-block rounded-md bg-ink px-5 py-3 text-white">
78
78
  상품 보러 가기
79
79
  </Link>
@@ -107,7 +107,7 @@ function CartPage() {
107
107
  ) : null}
108
108
  {!item.purchasable ? (
109
109
  <span className="text-point text-xs">
110
- {item.unpurchasableReason === "SOLD_OUT" ? "품절이에요" : "지금은 살 수 없어요"}
110
+ {item.unpurchasableReason === "SOLD_OUT" ? "품절입니다" : "지금은 살 수 없습니다"}
111
111
  </span>
112
112
  ) : null}
113
113
  <div className="flex items-center gap-3 pt-1">
@@ -171,7 +171,7 @@ function CartPage() {
171
171
  {loginRequired ? (
172
172
  <div className="flex w-full flex-col gap-2 md:w-64">
173
173
  <p className="text-center text-muted text-xs">
174
- 이 스토어는 회원만 주문할 수 있어요. 담은 상품은 로그인하면 그대로 이어져요
174
+ 이 스토어는 회원만 주문할 수 있습니다. 담은 상품은 로그인하면 그대로 이어집니다
175
175
  </p>
176
176
  <Link
177
177
  to="/login"
@@ -34,11 +34,11 @@ function CheckoutComplete() {
34
34
  return (
35
35
  <div className="space-y-6 py-10 text-center">
36
36
  <div className="space-y-2">
37
- <h1 className="font-bold text-2xl">주문을 완료했어요</h1>
37
+ <h1 className="font-bold text-2xl">주문을 완료했습니다</h1>
38
38
  {orderId ? (
39
39
  <p className="text-muted text-sm">주문번호 {orderId}</p>
40
40
  ) : (
41
- <p className="text-muted text-sm">주문 내역에서 주문을 확인해주세요</p>
41
+ <p className="text-muted text-sm">주문 내역에서 주문을 확인해주십시오</p>
42
42
  )}
43
43
  </div>
44
44
 
@@ -64,7 +64,10 @@ const quoteDelivery = createServerFn({ method: "POST" })
64
64
  const quote = await api.checkout.quoteDelivery(data.checkoutId, { zipCode: data.zipCode });
65
65
  return { quote, error: null };
66
66
  } catch {
67
- return { quote: null, error: "배송비를 확인하지 못했어요. 우편번호를 다시 확인해주세요." };
67
+ return {
68
+ quote: null,
69
+ error: "배송비를 확인하지 못했습니다. 우편번호를 다시 확인해주십시오.",
70
+ };
68
71
  }
69
72
  });
70
73
 
@@ -125,20 +128,21 @@ const startPayment = createServerFn({ method: "POST" })
125
128
  });
126
129
 
127
130
  function startErrorMessage(error: unknown): string {
128
- if (!(error instanceof ApiError)) return "결제를 시작하지 못했어요. 잠시 후 다시 시도해주세요.";
131
+ if (!(error instanceof ApiError))
132
+ return "결제를 시작하지 못했습니다. 잠시 후 다시 시도해주십시오.";
129
133
  switch (error.code) {
130
134
  case "PAYMENT_OPTION_UNAVAILABLE":
131
- return "지금 쓸 수 없는 결제수단이에요. 다른 결제수단을 골라주세요.";
135
+ return "지금 쓸 수 없는 결제수단입니다. 다른 결제수단을 골라주십시오.";
132
136
  case "PAYMENT_PROVIDER_UNAVAILABLE":
133
- return "결제창을 준비하지 못했어요. 다른 결제수단으로 시도해주세요.";
137
+ return "결제창을 준비하지 못했습니다. 다른 결제수단으로 시도해주십시오.";
134
138
  case "PAYMENT_NOT_CONFIGURED":
135
- return "결제 수단을 준비하고 있어요. 잠시 후 다시 시도해주세요.";
139
+ return "결제 수단을 준비하고 있습니다. 잠시 후 다시 시도해주십시오.";
136
140
  case "CASH_RECEIPT_NOT_AVAILABLE":
137
- return "현금영수증은 계좌이체·가상계좌 결제에서만 신청할 수 있어요.";
141
+ return "현금영수증은 계좌이체·가상계좌 결제에서만 신청할 수 있습니다.";
138
142
  case "RETURN_URL_NOT_ALLOWED":
139
- return "이 쇼핑몰 주소에서는 결제할 수 없어요. 셀러 콘솔 설정 › 결제에서 결제 도메인을 확인해주세요.";
143
+ return "이 쇼핑몰 주소에서는 결제할 수 없습니다. 셀러 콘솔 설정 › 결제에서 결제 도메인을 확인해주십시오.";
140
144
  default:
141
- return "결제를 시작하지 못했어요. 입력한 내용을 확인하고 다시 시도해주세요.";
145
+ return "결제를 시작하지 못했습니다. 입력한 내용을 확인하고 다시 시도해주십시오.";
142
146
  }
143
147
  }
144
148
 
@@ -210,10 +214,10 @@ function Checkout() {
210
214
  }
211
215
  setNotice(
212
216
  result.status === "CANCELED"
213
- ? "결제를 취소했어요. 다른 결제수단으로 다시 시도해주세요."
217
+ ? "결제를 취소했습니다. 다른 결제수단으로 다시 시도해주십시오."
214
218
  : result.status === "FAILED"
215
219
  ? result.message
216
- : "결제 결과를 확인하지 못했어요. 주문 내역을 확인해주세요.",
220
+ : "결제 결과를 확인하지 못했습니다. 주문 내역을 확인해주십시오.",
217
221
  );
218
222
  };
219
223
 
@@ -221,7 +225,7 @@ function Checkout() {
221
225
  event.preventDefault();
222
226
  const option = options.find((item) => paymentOptionKey(item) === selected);
223
227
  if (!option) {
224
- setNotice("결제수단을 골라주세요.");
228
+ setNotice("결제수단을 골라주십시오.");
225
229
  return;
226
230
  }
227
231
  const form = new FormData(event.currentTarget);
@@ -233,7 +237,7 @@ function Checkout() {
233
237
  identityNumber: field("cashReceiptNumber"),
234
238
  });
235
239
  if (!parsed.success) {
236
- setNotice(parsed.error.issues[0]?.message ?? "현금영수증 번호를 확인해주세요.");
240
+ setNotice(parsed.error.issues[0]?.message ?? "현금영수증 번호를 확인해주십시오.");
237
241
  return;
238
242
  }
239
243
  cashReceipt = parsed.data;
@@ -275,7 +279,7 @@ function Checkout() {
275
279
  })
276
280
  .catch(() => {
277
281
  paymentWindow.close();
278
- setNotice("결제를 시작하지 못했어요. 잠시 후 다시 시도해주세요.");
282
+ setNotice("결제를 시작하지 못했습니다. 잠시 후 다시 시도해주십시오.");
279
283
  })
280
284
  .finally(() => setSubmitting(false));
281
285
  };
@@ -293,7 +297,7 @@ function Checkout() {
293
297
  placeholder="04524"
294
298
  required
295
299
  onBlur={handleZipBlur}
296
- hint="입력하면 지역 추가 배송비까지 반영한 배송비를 보여 줘요"
300
+ hint="입력하면 지역 추가 배송비까지 반영한 배송비를 보여 줍니다"
297
301
  />
298
302
  <Field label="주소" name="address1" required />
299
303
  <Field label="상세 주소" name="address2" />
@@ -310,7 +314,7 @@ function Checkout() {
310
314
  name="orderPassword"
311
315
  type="password"
312
316
  required
313
- hint="주문 내역을 찾을 때 쓸 6자 이상 비밀번호예요"
317
+ hint="주문 내역을 찾을 때 쓸 6자 이상 비밀번호입니다"
314
318
  />
315
319
  </section>
316
320
  )}
@@ -318,7 +322,7 @@ function Checkout() {
318
322
  <fieldset className="space-y-2">
319
323
  <legend className="mb-3 font-bold text-lg">결제수단</legend>
320
324
  {options.length === 0 ? (
321
- <p className="text-muted text-sm">지금 결제할 수 있는 결제수단이 없어요.</p>
325
+ <p className="text-muted text-sm">지금 결제할 수 있는 결제수단이 없습니다.</p>
322
326
  ) : (
323
327
  <div className="grid grid-cols-2 gap-2">
324
328
  {options.map((option) => {
@@ -438,7 +442,7 @@ function Checkout() {
438
442
  ))}
439
443
  {checkout.testPayment ? (
440
444
  <p className="rounded-md bg-chip px-3 py-2 text-xs">
441
- 테스트 결제예요. 실제로 돈이 오가지 않아요.
445
+ 테스트 결제입니다. 실제로 돈이 오가지 않습니다.
442
446
  </p>
443
447
  ) : null}
444
448
  {notice ? <p className="rounded-md bg-chip px-3 py-2 text-sm">{notice}</p> : null}
@@ -451,10 +455,10 @@ function Checkout() {
451
455
  function deliveryNotes(delivery: CheckoutDelivery): string[] {
452
456
  const notes: string[] = [];
453
457
  if (delivery.bundleCount >= 2) {
454
- notes.push(`배송 묶음 ${delivery.bundleCount}건 — 묶음마다 배송비가 붙어요.`);
458
+ notes.push(`배송 묶음 ${delivery.bundleCount}건 — 묶음마다 배송비가 붙습니다.`);
455
459
  }
456
- if (delivery.freeByThreshold) notes.push("무료배송 기준 금액을 넘겨 기본 배송비가 빠졌어요.");
457
- if (delivery.zipCode === null) notes.push("배송지를 입력하면 배송비가 확정돼요.");
460
+ if (delivery.freeByThreshold) notes.push("무료배송 기준 금액을 넘겨 기본 배송비가 빠졌습니다.");
461
+ if (delivery.zipCode === null) notes.push("배송지를 입력하면 배송비가 확정됩니다.");
458
462
  return notes;
459
463
  }
460
464
 
@@ -107,17 +107,17 @@ function PaymentReturn() {
107
107
  <div className="mx-auto max-w-md space-y-6 py-16 text-center">
108
108
  {view.kind === "working" || view.kind === "processing" ? (
109
109
  <>
110
- <h1 className="font-bold text-2xl">결제를 확인하고 있어요</h1>
110
+ <h1 className="font-bold text-2xl">결제를 확인하고 있습니다</h1>
111
111
  <p className="text-muted text-sm">
112
112
  {view.kind === "processing"
113
- ? "결제사에서 결과를 확인하는 중이에요. 이 화면을 닫지 말고 잠시만 기다려주세요."
114
- : "잠시만 기다려주세요."}
113
+ ? "결제사에서 결과를 확인하는 중입니다. 이 화면을 닫지 말고 잠시만 기다려주십시오."
114
+ : "잠시만 기다려주십시오."}
115
115
  </p>
116
116
  </>
117
117
  ) : view.kind === "failed" ? (
118
118
  <>
119
119
  <h1 className="font-bold text-2xl">
120
- {view.canceled ? "결제를 취소했어요" : "결제하지 못했어요"}
120
+ {view.canceled ? "결제를 취소했습니다" : "결제하지 못했습니다"}
121
121
  </h1>
122
122
  {view.canceled ? null : <p className="text-muted text-sm">{view.message}</p>}
123
123
  <div className="flex justify-center gap-2">
@@ -128,10 +128,10 @@ function PaymentReturn() {
128
128
  </>
129
129
  ) : (
130
130
  <>
131
- <h1 className="font-bold text-2xl">결제 결과를 확인하지 못했어요</h1>
131
+ <h1 className="font-bold text-2xl">결제 결과를 확인하지 못했습니다</h1>
132
132
  <p className="text-muted text-sm">
133
- 결제를 마쳤다면 잠시 뒤 주문 내역에 나타나요. 다시 결제하기 전에 주문 내역을 먼저
134
- 확인해주세요.
133
+ 결제를 마쳤다면 잠시 뒤 주문 내역에 나타납니다. 다시 결제하기 전에 주문 내역을 먼저
134
+ 확인해주십시오.
135
135
  </p>
136
136
  <Link to="/orders" className="inline-block rounded-md border border-ink px-5 py-3">
137
137
  주문 내역
@@ -48,7 +48,7 @@ function Home() {
48
48
  ) : null}
49
49
 
50
50
  <ProductSection listId="home:recommend" title="추천 상품" products={recommended} />
51
- <ProductSection listId="home:latest" title="새로 들어왔어요" products={latest} />
51
+ <ProductSection listId="home:latest" title="새로 들어온 상품" products={latest} />
52
52
  </div>
53
53
  );
54
54
  }
@@ -74,18 +74,18 @@ const passwordLogin = createServerFn({ method: "POST" })
74
74
 
75
75
  function loginErrorMessage(error: unknown): string {
76
76
  if (error instanceof ApiError) {
77
- if (error.code === "INVALID_CREDENTIALS") return "이메일이나 비밀번호가 맞지 않아요";
78
- if (error.code === "PASSWORD_LOGIN_DISABLED") return "이 스토어는 소셜 로그인만 받아요";
77
+ if (error.code === "INVALID_CREDENTIALS") return "이메일이나 비밀번호가 맞지 않습니다";
78
+ if (error.code === "PASSWORD_LOGIN_DISABLED") return "이 스토어는 소셜 로그인만 받습니다";
79
79
  if (error.code === "ACCOUNT_LOCKED")
80
- return "로그인을 여러 번 실패해 잠시 잠겼어요. 10분 뒤에 다시 시도해 주세요";
81
- if (error.status === 400) return "이메일과 비밀번호를 확인해 주세요";
80
+ return "로그인을 여러 번 실패해 잠시 잠겼습니다. 10분 뒤에 다시 시도해 주십시오";
81
+ if (error.status === 400) return "이메일과 비밀번호를 확인해 주십시오";
82
82
  }
83
- return "로그인하지 못했어요. 잠시 후 다시 시도해 주세요";
83
+ return "로그인하지 못했습니다. 잠시 후 다시 시도해 주십시오";
84
84
  }
85
85
 
86
86
  const CALLBACK_ERRORS: Record<string, string> = {
87
- access_denied: "로그인을 취소했어요",
88
- state_mismatch: "로그인 요청이 만료됐어요. 다시 시도해 주세요",
87
+ access_denied: "로그인을 취소했습니다",
88
+ state_mismatch: "로그인 요청이 만료됐습니다. 다시 시도해 주십시오",
89
89
  };
90
90
 
91
91
  export const Route = createFileRoute("/login")({
@@ -105,7 +105,7 @@ function Login() {
105
105
  const error =
106
106
  actionError ??
107
107
  (callbackError
108
- ? (CALLBACK_ERRORS[callbackError] ?? "로그인하지 못했어요. 다시 시도해 주세요")
108
+ ? (CALLBACK_ERRORS[callbackError] ?? "로그인하지 못했습니다. 다시 시도해 주십시오")
109
109
  : null);
110
110
 
111
111
  const submitSocial = (event: React.FormEvent<HTMLFormElement>) => {
@@ -195,7 +195,7 @@ function Login() {
195
195
  ) : null}
196
196
  {error ? <p className="text-point text-sm">{error}</p> : null}
197
197
  {social.length ? (
198
- <p className="text-center text-muted text-xs">처음 소셜 로그인하면 가입이 함께 돼요</p>
198
+ <p className="text-center text-muted text-xs">처음 소셜 로그인하면 가입이 함께 됩니다</p>
199
199
  ) : null}
200
200
  </div>
201
201
  );
@@ -62,18 +62,18 @@ const createClaim = createServerFn({ method: "POST" })
62
62
  });
63
63
 
64
64
  function claimErrorMessage(error: unknown): string {
65
- if (!(error instanceof ApiError)) return "접수하지 못했어요. 잠시 후 다시 시도해주세요.";
65
+ if (!(error instanceof ApiError)) return "접수하지 못했습니다. 잠시 후 다시 시도해주십시오.";
66
66
  switch (error.code) {
67
67
  case "INVALID_QUANTITY":
68
- return "남은 수량보다 많이 신청했어요. 수량을 확인해주세요.";
68
+ return "남은 수량보다 많이 신청했습니다. 수량을 확인해주십시오.";
69
69
  case "CLAIM_IN_PROGRESS":
70
- return "이미 진행 중인 신청이 있어요.";
70
+ return "이미 진행 중인 신청이 있습니다.";
71
71
  case "INVALID_STATUS":
72
- return "지금은 신청할 수 없는 상태예요. 주문 상태를 확인해주세요.";
72
+ return "지금은 신청할 수 없는 상태입니다. 주문 상태를 확인해주십시오.";
73
73
  case "PERIOD_EXPIRED":
74
- return "반품·교환 가능 기간이 지났어요.";
74
+ return "반품·교환 가능 기간이 지났습니다.";
75
75
  default:
76
- return error.message || "접수하지 못했어요. 잠시 후 다시 시도해주세요.";
76
+ return error.message || "접수하지 못했습니다. 잠시 후 다시 시도해주십시오.";
77
77
  }
78
78
  }
79
79
 
@@ -147,7 +147,7 @@ function OrderDetail() {
147
147
 
148
148
  {result ? (
149
149
  <div className="space-y-1 rounded-md bg-chip p-4 text-sm">
150
- <p className="font-semibold">신청이 접수됐어요 (수량 {result.quantity}개)</p>
150
+ <p className="font-semibold">신청이 접수됐습니다 (수량 {result.quantity}개)</p>
151
151
  {/* 상품 금액 환불은 수량 비율이고, 반품 배송비는 이미 빠져 있다. 배송비 환불은 따로 온다 */}
152
152
  {result.expectedRefundAmount === null ? null : (
153
153
  <p className="text-muted">
@@ -271,9 +271,9 @@ function ClaimForm({
271
271
  const quantity = Number(quantityText);
272
272
  const quantityError =
273
273
  quantityText.trim() === "" || !Number.isInteger(quantity)
274
- ? "수량을 입력해주세요."
274
+ ? "수량을 입력해주십시오."
275
275
  : quantity < 1 || quantity > item.activeQuantity
276
- ? `수량은 1개 이상 ${item.activeQuantity}개 이하로 입력해주세요.`
276
+ ? `수량은 1개 이상 ${item.activeQuantity}개 이하로 입력해주십시오.`
277
277
  : null;
278
278
  return (
279
279
  <form
@@ -20,7 +20,7 @@ function Orders() {
20
20
  const orders = Route.useLoaderData();
21
21
 
22
22
  if (!orders.contents.length) {
23
- return <p className="py-16 text-center text-muted">아직 주문이 없어요</p>;
23
+ return <p className="py-16 text-center text-muted">아직 주문이 없습니다</p>;
24
24
  }
25
25
 
26
26
  return (
@@ -107,7 +107,7 @@ function ProductDetail() {
107
107
  setError(null);
108
108
  addToCart({ data: { productId: product.productId, optionId, quantity } })
109
109
  .then(() => navigate({ to: "/cart" }))
110
- .catch(() => setError("장바구니에 담지 못했어요. 다시 시도해주세요"))
110
+ .catch(() => setError("장바구니에 담지 못했습니다. 다시 시도해주십시오"))
111
111
  .finally(() => setSubmitting(false));
112
112
  }}
113
113
  >
@@ -71,7 +71,7 @@ function Products() {
71
71
  type="search"
72
72
  name="keyword"
73
73
  defaultValue={applied.keyword ?? ""}
74
- placeholder="상품을 검색해요"
74
+ placeholder="상품 검색"
75
75
  className="h-11 flex-1 rounded-md border border-line px-3"
76
76
  />
77
77
  {applied.categoryId ? (
@@ -104,7 +104,7 @@ function Products() {
104
104
  </div>
105
105
 
106
106
  {page.contents.length === 0 ? (
107
- <p className="py-16 text-center text-muted">조건에 맞는 상품이 없어요</p>
107
+ <p className="py-16 text-center text-muted">조건에 맞는 상품이 없습니다</p>
108
108
  ) : (
109
109
  <div className="grid grid-cols-2 gap-4 md:grid-cols-4">
110
110
  {page.contents.map((product) => (