@sayren/mcp 0.4.0 → 0.5.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 +165 -8
- package/package.json +2 -2
- package/template/.template-meta.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -19,13 +19,17 @@ const METHODS = [
|
|
|
19
19
|
"DELETE"
|
|
20
20
|
];
|
|
21
21
|
/**
|
|
22
|
-
*
|
|
23
|
-
* 목록은
|
|
22
|
+
* 서버가 정책(`x-agent-policy`)을 싣지 않는 옛 api에만 쓰는 폴백 — 환불로 이어지는 쓰기를 막는다.
|
|
23
|
+
* 새 api는 문서의 정책이 원천이고 이 목록은 보지 않는다(승인 흐름으로 부를 수 있다).
|
|
24
24
|
*/
|
|
25
|
-
const
|
|
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:
|
|
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) => ({
|
|
@@ -163,6 +178,109 @@ function truncateData(data, maxChars) {
|
|
|
163
178
|
truncated: `응답 ${text$1.length}자 중 ${maxChars}자만 실었어요`
|
|
164
179
|
};
|
|
165
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
|
+
}
|
|
282
|
+
};
|
|
283
|
+
}
|
|
166
284
|
|
|
167
285
|
//#endregion
|
|
168
286
|
//#region src/user-agent.ts
|
|
@@ -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,
|
|
@@ -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\`인 오퍼레이션은 키를 새로 만들어 넣고, 재시도할 때는 같은 키를 쓴다.
|
|
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`에 있다. 화면을 만든 뒤와 디자인을 고친 뒤에 부른다.",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sayren/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"sayren-mcp": "./dist/index.mjs"
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"dependencies": {
|
|
13
13
|
"@modelcontextprotocol/sdk": "^1.22.0",
|
|
14
14
|
"zod": "^4.6.5",
|
|
15
|
-
"@sayren/store-sdk": "^0.
|
|
15
|
+
"@sayren/store-sdk": "^0.14.0",
|
|
16
16
|
"@sayren/storefront-sdk": "^0.11.0"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|