@nhnpayco-dev/mcp-devcenter 1.0.4 → 1.2.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/README.md +19 -13
- package/dist/data/api-specs.json +1019 -220
- package/dist/data/guides/offline-authapi.md +1 -1
- package/dist/data/guides/online-extradata.md +1 -1
- package/dist/data/index.json +1 -1
- package/dist/excel.js +51 -15
- package/dist/index.js +7 -1
- package/dist/tools.js +204 -43
- package/package.json +5 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# PAYCO 오프라인 POS 인증승인형 연동 가이드
|
|
2
2
|
|
|
3
|
-
> **원본:** PAYCO_오프라인POS_인증승인형연동가이드_v1.0.1.0.docx (
|
|
3
|
+
> **원본:** PAYCO_오프라인POS_인증승인형연동가이드_v1.0.1.0.docx (개발자센터 미게시 문서)
|
|
4
4
|
> **카테고리:** `offline-authapi`
|
|
5
5
|
> **연동 방식:** 가맹점 단말이 결제 인증·승인을 직접 수행하는 인증승인형
|
|
6
6
|
> **API 5종:** POS 인증 / 결제 인증 / 결제 승인 / 결제취소 / 망취소
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# PAYCO 부가정보(extraData) 명세
|
|
2
2
|
|
|
3
|
-
> **원본:** PAYCO_부가정보(extraData)_v1.1.14.docx (
|
|
3
|
+
> **원본:** PAYCO_부가정보(extraData)_v1.1.14.docx (개발자센터 미게시 문서)
|
|
4
4
|
> **카테고리:** `online-extradata`
|
|
5
5
|
> **용도:** 온라인결제 주문예약 API의 extraData 파라미터(JSON String) 내부 구조 정의
|
|
6
6
|
> **사용 위치:** online-easypay-01 (reserve), online-autopay-01, online-easypay-nonmember/checkout/ordersheet 등 reserve 계열 API
|
package/dist/data/index.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"schema_version": "1.0",
|
|
3
3
|
"source_site": "https://devcenter.payco.com",
|
|
4
4
|
"scope": "PAYCO 가이드 전체 (온라인결제·자동결제·오프라인결제·오더클라우드·멤버십클라우드·결제정산대사)",
|
|
5
|
-
"generated_at": "2026-10-
|
|
5
|
+
"generated_at": "2026-10-26T02:06:57+00:00",
|
|
6
6
|
"category_count": 28,
|
|
7
7
|
"categories": [
|
|
8
8
|
{
|
package/dist/excel.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// 검수 결과 → xlsx 변환.
|
|
2
2
|
// 3 시트: 검수요약 / 항목별 상세 / 공통검수체크리스트
|
|
3
3
|
// 양식 참조: data/samples/payco_nonmember_checklist.xlsx (Arial 10pt, 진남색 헤더)
|
|
4
|
-
import { mkdir } from "node:fs/promises";
|
|
4
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
|
-
import {
|
|
6
|
+
import { join } from "node:path";
|
|
7
7
|
import ExcelJS from "exceljs";
|
|
8
8
|
import { CHECKLISTS, SERVICE_LABELS } from "./tools.js";
|
|
9
9
|
// ── 디자인 토큰 ──────────────────────────────────────────────
|
|
@@ -30,21 +30,49 @@ const STATUS_LABEL = {
|
|
|
30
30
|
manual: "⬜ 수동확인",
|
|
31
31
|
};
|
|
32
32
|
// ── 경로/파일명 ──────────────────────────────────────────────
|
|
33
|
-
|
|
33
|
+
// 저장 위치는 항상 다운로드 폴더로 고정한다. 경로는 AI 가 정해 넘기므로
|
|
34
|
+
// 다른 폴더 저장·폴더 생성·기존 파일 덮어쓰기를 허용하지 않는다.
|
|
35
|
+
export function downloadsDir() {
|
|
36
|
+
return join(homedir(), "Downloads");
|
|
37
|
+
}
|
|
38
|
+
function defaultFileName() {
|
|
34
39
|
const now = new Date();
|
|
35
40
|
const pad = (n) => String(n).padStart(2, "0");
|
|
36
41
|
const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` +
|
|
37
42
|
`-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
38
|
-
return
|
|
43
|
+
return `payco-validation-${stamp}.xlsx`;
|
|
44
|
+
}
|
|
45
|
+
/** 요청 경로에서 파일명만 남긴다. 폴더 부분이 있었으면 relocated=true. */
|
|
46
|
+
function toFileName(userPath) {
|
|
47
|
+
const raw = userPath?.trim() ?? "";
|
|
48
|
+
if (!raw)
|
|
49
|
+
return { name: defaultFileName(), relocated: false };
|
|
50
|
+
const parts = raw.split(/[\\/]/);
|
|
51
|
+
const relocated = parts.length > 1;
|
|
52
|
+
let name = parts[parts.length - 1]
|
|
53
|
+
.replace(/[<>:"|?*\u0000-\u001f]/g, "_")
|
|
54
|
+
.replace(/[. ]+$/, "");
|
|
55
|
+
if (!name || name === "..")
|
|
56
|
+
name = defaultFileName();
|
|
57
|
+
if (!name.toLowerCase().endsWith(".xlsx"))
|
|
58
|
+
name = `${name}.xlsx`;
|
|
59
|
+
return { name, relocated };
|
|
39
60
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
61
|
+
/** 같은 이름이 있으면 ' (2)', ' (3)' … 을 붙여 새 파일로 쓴다(덮어쓰기 금지). */
|
|
62
|
+
async function writeNoOverwrite(dir, name, data) {
|
|
63
|
+
const base = name.slice(0, -".xlsx".length);
|
|
64
|
+
for (let i = 1; i < 1000; i++) {
|
|
65
|
+
const p = join(dir, i === 1 ? name : `${base} (${i}).xlsx`);
|
|
66
|
+
try {
|
|
67
|
+
await writeFile(p, data, { flag: "wx" });
|
|
68
|
+
return p;
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
if (e.code !== "EEXIST")
|
|
72
|
+
throw e;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
throw new Error(`같은 이름의 파일이 너무 많습니다: ${name}`);
|
|
48
76
|
}
|
|
49
77
|
// ── 공통 헬퍼 ────────────────────────────────────────────────
|
|
50
78
|
function fillSolid(argb) {
|
|
@@ -313,8 +341,16 @@ export async function exportValidationToExcel(records, userPath) {
|
|
|
313
341
|
buildSummarySheet(wb, records);
|
|
314
342
|
buildDetailSheet(wb, records);
|
|
315
343
|
buildChecklistSheet(wb, records);
|
|
316
|
-
const
|
|
317
|
-
await
|
|
344
|
+
const dir = downloadsDir();
|
|
345
|
+
await mkdir(dir, { recursive: true }); // 다운로드 폴더만 없으면 만든다
|
|
346
|
+
const { name, relocated } = toFileName(userPath);
|
|
347
|
+
const data = Buffer.from(await wb.xlsx.writeBuffer());
|
|
348
|
+
const outPath = await writeNoOverwrite(dir, name, data);
|
|
349
|
+
const notes = [];
|
|
350
|
+
if (relocated)
|
|
351
|
+
notes.push("요청한 위치 대신 다운로드 폴더에 저장했습니다(다운로드 폴더에만 저장 가능).");
|
|
352
|
+
if (!outPath.endsWith(name))
|
|
353
|
+
notes.push(`같은 이름의 파일이 있어 새 이름으로 저장했습니다(기존 파일은 그대로 둠).`);
|
|
318
354
|
const detailRows = records.reduce((sum, r) => sum + r.specIssues.length + r.checklistRuns.length, 0);
|
|
319
|
-
return { path: outPath, recordCount: records.length, rowCount: detailRows };
|
|
355
|
+
return { path: outPath, recordCount: records.length, rowCount: detailRows, notes };
|
|
320
356
|
}
|
package/dist/index.js
CHANGED
|
@@ -8,6 +8,12 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
8
8
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
9
9
|
import { loadDataStore } from "./data.js";
|
|
10
10
|
import { registerAllTools } from "./tools.js";
|
|
11
|
+
/** MCP initialize 응답의 instructions. 클라이언트가 연결 시점에 받아 세션 내내 참고한다.
|
|
12
|
+
* 검색 결과와 무관하게 항상 지킬 규칙만 둔다(세션마다 점유하므로 짧게).
|
|
13
|
+
* 근거: online-start·offline-start 「PAYCO 통신 방화벽 OPEN」, online-easypay orderSheetUrl. */
|
|
14
|
+
const SERVER_INSTRUCTIONS = `PAYCO 연동 코드를 생성할 때 문서 검색 결과와 무관하게 항상 지킬 것:
|
|
15
|
+
- PAYCO API(주문 예약, 결제 승인·취소 등)는 방화벽에 IP를 등록한 가맹점 서버에서만 정상 처리됩니다. 브라우저나 모바일 앱에서 직접 호출하는 코드를 만들지 마세요.
|
|
16
|
+
- 브라우저는 가맹점 서버가 주문 예약 API로 받은 결제창 URL(orderSheetUrl)을 여는 역할만 합니다.`;
|
|
11
17
|
/** package.json 의 version 을 그대로 사용한다.
|
|
12
18
|
* 하드코딩하면 배포 버전과 어긋난다(1.0.0 배포본이 0.2.0 으로 보고된 전례). */
|
|
13
19
|
async function packageVersion() {
|
|
@@ -29,7 +35,7 @@ async function main() {
|
|
|
29
35
|
const server = new McpServer({
|
|
30
36
|
name: "payco-mcp-devcenter",
|
|
31
37
|
version,
|
|
32
|
-
});
|
|
38
|
+
}, { instructions: SERVER_INSTRUCTIONS });
|
|
33
39
|
registerAllTools(server, store);
|
|
34
40
|
const transport = new StdioServerTransport();
|
|
35
41
|
await server.connect(transport);
|
package/dist/tools.js
CHANGED
|
@@ -72,6 +72,106 @@ function flattenPayload(payload) {
|
|
|
72
72
|
visit(k, v);
|
|
73
73
|
return out;
|
|
74
74
|
}
|
|
75
|
+
/** 명세 필수 항목(점 경로)이 payload 에 없는 위치를 찾는다.
|
|
76
|
+
*
|
|
77
|
+
* - 배열은 원소마다 확인하고 위치를 표시한다. 예) orderProducts[1].productId
|
|
78
|
+
* - 하위 항목은 상위 항목을 보냈을 때만 확인한다(상위가 없으면 상위 자체의 필수 여부로 판단).
|
|
79
|
+
* 예) sign 을 보내지 않으면 sign.signData 는 확인하지 않는다.
|
|
80
|
+
*/
|
|
81
|
+
function findMissingRequired(root, path) {
|
|
82
|
+
const segs = path.split(".");
|
|
83
|
+
const out = [];
|
|
84
|
+
const walk = (val, i, at) => {
|
|
85
|
+
if (Array.isArray(val)) {
|
|
86
|
+
val.forEach((el, k) => walk(el, i, `${at}[${k}]`));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (i === segs.length)
|
|
90
|
+
return;
|
|
91
|
+
if (!val || typeof val !== "object")
|
|
92
|
+
return; // 상위가 객체가 아니면 타입 검사에서 다룬다
|
|
93
|
+
const key = segs[i];
|
|
94
|
+
const here = at ? `${at}.${key}` : key;
|
|
95
|
+
const obj = val;
|
|
96
|
+
if (!(key in obj) || obj[key] === undefined || obj[key] === null) { // null 은 보내지 않은 것으로 본다
|
|
97
|
+
if (i === segs.length - 1)
|
|
98
|
+
out.push(here); // 마지막 단계에서만 누락으로 본다
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
walk(obj[key], i + 1, here);
|
|
102
|
+
};
|
|
103
|
+
walk(root, 0, "");
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
/** 명세 타입 표기(string(10), integer, double, list<string>, 형식 등)와 실제 값이 맞는지.
|
|
107
|
+
* - 숫자 계열(number/integer/int/long/float/double)은 숫자 또는 숫자 형태의 문자열("67300")을 허용한다.
|
|
108
|
+
* PAYCO 서버가 숫자 문자열도 처리한다(2026-10-23 확인). 가이드 공식 샘플도 문자열로 보낸다.
|
|
109
|
+
* - 판별할 수 없는 표기(unknown, 표 파싱 잔재 등)는 검사하지 않는다(null 반환).
|
|
110
|
+
*/
|
|
111
|
+
function typeMatches(declared, value) {
|
|
112
|
+
const t = declared.toLowerCase();
|
|
113
|
+
const actual = Array.isArray(value) ? "array" : typeof value;
|
|
114
|
+
if (/^(number|integer|int|long|float|double)\b/.test(t)) {
|
|
115
|
+
return actual === "number" || (actual === "string" && /^-?\d+(\.\d+)?$/.test(value.trim()));
|
|
116
|
+
}
|
|
117
|
+
if (/^(array|list)/.test(t))
|
|
118
|
+
return actual === "array";
|
|
119
|
+
if (/^string/.test(t))
|
|
120
|
+
return actual === "string";
|
|
121
|
+
if (/^object/.test(t))
|
|
122
|
+
return actual === "object";
|
|
123
|
+
if (/^boolean/.test(t))
|
|
124
|
+
return actual === "boolean";
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
/** 조건부 항목(when)이 이 payload 에 해당하는지. 예) sign 하위 항목은 vanCorpCode 가 맞을 때만 */
|
|
128
|
+
function whenApplies(root, when) {
|
|
129
|
+
if (!when)
|
|
130
|
+
return true;
|
|
131
|
+
return Object.entries(when).every(([field, values]) => {
|
|
132
|
+
const v = root[field];
|
|
133
|
+
return typeof v === "string" && values.includes(v.trim());
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
/** extraData 는 가이드상 JSON 문자열이다. 검사용 사본에서 문자열을 풀어 안쪽 항목까지 검사한다.
|
|
137
|
+
* - 문자열이면 JSON 으로 풀어 객체로 바꾼다(타입은 정상).
|
|
138
|
+
* - 깨진 JSON 이면 문제로 보고하고 안쪽은 검사하지 않는다.
|
|
139
|
+
* - 객체로 보냈으면 안쪽은 검사하되, 문자열로 보내야 한다는 타입 문제를 보고한다.
|
|
140
|
+
*/
|
|
141
|
+
function expandExtraData(obj) {
|
|
142
|
+
const ed = obj["extraData"];
|
|
143
|
+
if (ed === undefined)
|
|
144
|
+
return { view: obj, issues: [] };
|
|
145
|
+
if (typeof ed === "string") {
|
|
146
|
+
try {
|
|
147
|
+
const parsed = JSON.parse(ed);
|
|
148
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
149
|
+
return { view: { ...obj, extraData: parsed }, issues: [] };
|
|
150
|
+
}
|
|
151
|
+
return { view: obj, issues: ["extraData: 올바른 JSON 문자열이 아닙니다 — JSON 객체를 문자열로 바꾼 값이어야 합니다"] };
|
|
152
|
+
}
|
|
153
|
+
catch (e) {
|
|
154
|
+
return { view: obj, issues: [`extraData: 올바른 JSON 문자열이 아닙니다 — ${e.message}`] };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (ed && typeof ed === "object" && !Array.isArray(ed)) {
|
|
158
|
+
return {
|
|
159
|
+
view: obj,
|
|
160
|
+
issues: ["extraData: 명세 'string' / 실제 'object' — JSON 객체를 문자열로 바꿔서(JSON.stringify) 보내야 합니다"],
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return { view: obj, issues: [] }; // 그 밖의 타입은 일반 타입 검사에서 다룬다
|
|
164
|
+
}
|
|
165
|
+
/** 최상위에 없으면 extraData(검사용으로 풀린 객체) 안에서 찾는다 */
|
|
166
|
+
function pickField(obj, field) {
|
|
167
|
+
if (field in obj)
|
|
168
|
+
return obj[field];
|
|
169
|
+
const ed = obj["extraData"];
|
|
170
|
+
if (ed && typeof ed === "object" && !Array.isArray(ed) && field in ed) {
|
|
171
|
+
return ed[field];
|
|
172
|
+
}
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
75
175
|
/** 본문에서 query 가 포함된 줄(앞뒤 줄 포함) 발췌 */
|
|
76
176
|
function snippetLines(md, query, maxHits = 5) {
|
|
77
177
|
const q = query.toLowerCase();
|
|
@@ -89,14 +189,34 @@ function autoCheckPii(obj) {
|
|
|
89
189
|
// 객체의 문자열 값들을 개별 검사 (JSON 직렬화 시 인접 숫자들이 false-positive 유발 방지)
|
|
90
190
|
const hits = [];
|
|
91
191
|
// 앞뒤에 추가 숫자가 없을 때만 매칭 (timestamp 같은 긴 숫자에서 부분 매칭 회피)
|
|
92
|
-
|
|
93
|
-
const
|
|
192
|
+
// 주민번호 13자리. 앞 6자리가 실제 날짜(YYMMDD)일 때만 본다 — 주문번호의 13자리 시각 값(1640064545311) 오탐 방지
|
|
193
|
+
const RE_RRN = /(?<!\d)(\d{2})(\d{2})(\d{2})-?[1-4]\d{6}(?!\d)/g;
|
|
194
|
+
// 카드 16자리. 카드번호 검증 규칙(Luhn)을 통과할 때만 본다 — 16자리 거래번호(2022021610443637) 오탐 방지
|
|
195
|
+
const RE_CARD = /(?<!\d)\d{4}-?\d{4}-?\d{4}-?\d{4}(?!\d)/g;
|
|
94
196
|
const RE_PHONE = /(?<!\d)01[0-9]-?\d{3,4}-?\d{4}(?!\d)/; // 휴대전화 10~11자리
|
|
197
|
+
const hasRrn = (s) => [...s.matchAll(RE_RRN)].some((m) => {
|
|
198
|
+
const mm = Number(m[2]), dd = Number(m[3]);
|
|
199
|
+
return mm >= 1 && mm <= 12 && dd >= 1 && dd <= 31;
|
|
200
|
+
});
|
|
201
|
+
const luhn = (digits) => {
|
|
202
|
+
let sum = 0;
|
|
203
|
+
for (let i = 0; i < digits.length; i++) {
|
|
204
|
+
let d = Number(digits[digits.length - 1 - i]);
|
|
205
|
+
if (i % 2 === 1) {
|
|
206
|
+
d *= 2;
|
|
207
|
+
if (d > 9)
|
|
208
|
+
d -= 9;
|
|
209
|
+
}
|
|
210
|
+
sum += d;
|
|
211
|
+
}
|
|
212
|
+
return sum % 10 === 0;
|
|
213
|
+
};
|
|
214
|
+
const hasCard = (s) => [...s.matchAll(RE_CARD)].some((m) => luhn(m[0].replace(/-/g, "")));
|
|
95
215
|
const visit = (v) => {
|
|
96
216
|
if (typeof v === "string") {
|
|
97
|
-
if (
|
|
217
|
+
if (hasRrn(v) && !hits.includes("주민번호 패턴"))
|
|
98
218
|
hits.push("주민번호 패턴");
|
|
99
|
-
if (
|
|
219
|
+
if (hasCard(v) && !hits.includes("카드번호 패턴"))
|
|
100
220
|
hits.push("카드번호 패턴");
|
|
101
221
|
if (RE_PHONE.test(v) && !hits.includes("전화번호 패턴"))
|
|
102
222
|
hits.push("전화번호 패턴");
|
|
@@ -169,35 +289,40 @@ function makeEnumCheck(field, allowed) {
|
|
|
169
289
|
}
|
|
170
290
|
function makeDateYmdtCheck(fields) {
|
|
171
291
|
return (obj) => {
|
|
172
|
-
const present = fields.filter((f) =>
|
|
292
|
+
const present = fields.filter((f) => typeof pickField(obj, f) === "string");
|
|
173
293
|
if (present.length === 0)
|
|
174
294
|
return null;
|
|
175
|
-
const fails = present.filter((f) => !/^\d{14}$/.test(obj
|
|
295
|
+
const fails = present.filter((f) => !/^\d{14}$/.test(pickField(obj, f)));
|
|
176
296
|
return fails.length === 0
|
|
177
297
|
? { pass: true }
|
|
178
|
-
: { pass: false, message: `YYYYMMDDHHmmss 14자리 필요: ${fails.map((f) => `${f}='${obj
|
|
298
|
+
: { pass: false, message: `YYYYMMDDHHmmss 14자리 필요: ${fails.map((f) => `${f}='${pickField(obj, f)}'`).join(", ")}` };
|
|
179
299
|
};
|
|
180
300
|
}
|
|
181
301
|
function makeDateYmdCheck(field, allowFull = false) {
|
|
182
302
|
return (obj) => {
|
|
183
|
-
|
|
303
|
+
const picked = pickField(obj, field);
|
|
304
|
+
if (typeof picked !== "string")
|
|
184
305
|
return null;
|
|
185
|
-
const val =
|
|
306
|
+
const val = picked;
|
|
186
307
|
const ok = /^\d{8}$/.test(val) || (allowFull && /^\d{14}$/.test(val));
|
|
187
308
|
return ok
|
|
188
309
|
? { pass: true }
|
|
189
310
|
: { pass: false, message: `${allowFull ? "YYYYMMDD(8자리) 또는 YYYYMMDDHHmmss(14자리)" : "YYYYMMDD(8자리)"} 필요. 현재: '${val}'` };
|
|
190
311
|
};
|
|
191
312
|
}
|
|
313
|
+
// -- 개인정보 체크 항목 --
|
|
314
|
+
// 온라인 검수 가이드 「Request 파라미터에 개인정보가 유입되지는 않는지」 기반.
|
|
315
|
+
// 온라인·오프라인·오더 요청에는 개인정보 칸이 없어 실수 검출용으로 적용한다.
|
|
316
|
+
// 멤버십은 적립 대상 휴대폰번호·회원카드번호 등이 정상 요청값이라 적용하지 않는다.
|
|
317
|
+
const ITEM_PII = {
|
|
318
|
+
id: "common-pii",
|
|
319
|
+
category: "보안",
|
|
320
|
+
title: "개인정보 파라미터 미포함",
|
|
321
|
+
detail: "주민번호·카드번호·전화번호 등 개인정보가 request 파라미터에 유입되지 않아야 함",
|
|
322
|
+
autoCheck: autoCheckPii,
|
|
323
|
+
};
|
|
192
324
|
// -- 공통 체크 항목 --
|
|
193
325
|
const ITEMS_COMMON = [
|
|
194
|
-
{
|
|
195
|
-
id: "common-pii",
|
|
196
|
-
category: "보안",
|
|
197
|
-
title: "개인정보 파라미터 미포함",
|
|
198
|
-
detail: "주민번호·카드번호·전화번호 등 개인정보가 request 파라미터에 유입되지 않아야 함",
|
|
199
|
-
autoCheck: autoCheckPii,
|
|
200
|
-
},
|
|
201
326
|
{
|
|
202
327
|
id: "common-encoding",
|
|
203
328
|
category: "기타",
|
|
@@ -227,6 +352,7 @@ const ITEMS_COMMON = [
|
|
|
227
352
|
];
|
|
228
353
|
// -- 온라인결제 체크리스트 --
|
|
229
354
|
const CHECKLIST_ONLINE = [
|
|
355
|
+
ITEM_PII,
|
|
230
356
|
...ITEMS_COMMON,
|
|
231
357
|
{ id: "online-firewall", category: "사전준비", title: "PAYCO 방화벽 등록 완료" },
|
|
232
358
|
{ id: "online-merchant-code", category: "사전준비", title: "PAYCO 가맹점 코드 발급 완료" },
|
|
@@ -307,6 +433,7 @@ const CHECKLIST_ONLINE = [
|
|
|
307
433
|
];
|
|
308
434
|
// -- 오프라인결제 체크리스트 --
|
|
309
435
|
const CHECKLIST_OFFLINE = [
|
|
436
|
+
ITEM_PII,
|
|
310
437
|
...ITEMS_COMMON,
|
|
311
438
|
{ id: "offline-btn", category: "결제노출", title: "단말기에 PAYCO 결제 버튼 정상 노출 (디자인 가이드 준수)" },
|
|
312
439
|
{ id: "offline-receipt", category: "공통", title: "영수증 필요 정보 정상 노출" },
|
|
@@ -340,6 +467,7 @@ const CHECKLIST_OFFLINE = [
|
|
|
340
467
|
];
|
|
341
468
|
// -- 오더클라우드 체크리스트 --
|
|
342
469
|
const CHECKLIST_ORDER = [
|
|
470
|
+
ITEM_PII,
|
|
343
471
|
...ITEMS_COMMON,
|
|
344
472
|
{ id: "order-receipt", category: "공통", title: "영수증 필요 정보 정상 노출" },
|
|
345
473
|
{ id: "order-sales", category: "매출처리", title: "주문 수락 시 POS 단말기에 매출 정상 등록" },
|
|
@@ -444,6 +572,16 @@ function groupByCategory(items) {
|
|
|
444
572
|
export function registerAllTools(server, store) {
|
|
445
573
|
const slugs = store.index.categories.map((c) => c.slug);
|
|
446
574
|
const slugList = slugs.join(", ");
|
|
575
|
+
// 가이드 참고 링크: index.json 의 실제 주소를 쓴다(주소를 조립하면 ?id= 가 없어 개발자센터에서 열리지 않음).
|
|
576
|
+
// 개발자센터에 없는 문서(internal://)는 주소 대신 MCP 안에서 여는 방법을 안내한다.
|
|
577
|
+
const guideLink = (slug) => {
|
|
578
|
+
const url = store.index.categories.find((c) => c.slug === slug)?.url;
|
|
579
|
+
if (!url)
|
|
580
|
+
return `get_guide('${slug}')`;
|
|
581
|
+
return url.startsWith("internal://")
|
|
582
|
+
? `개발자센터 미게시 문서 (전체 내용: get_guide('${slug}'))`
|
|
583
|
+
: url;
|
|
584
|
+
};
|
|
447
585
|
// 1) search_devcenter ----------------------------------------------------
|
|
448
586
|
server.registerTool("search_devcenter", {
|
|
449
587
|
title: "개발자센터 검색",
|
|
@@ -462,7 +600,7 @@ export function registerAllTools(server, store) {
|
|
|
462
600
|
const snippets = snippetLines(md, query, 5);
|
|
463
601
|
if (titleHit || snippets.length > 0) {
|
|
464
602
|
let block = `## [${cat.slug}] ${cat.title}\n`;
|
|
465
|
-
block += ` ${cat.
|
|
603
|
+
block += ` ${guideLink(cat.slug)}\n`;
|
|
466
604
|
if (snippets.length > 0) {
|
|
467
605
|
for (const s of snippets)
|
|
468
606
|
block += ` > ${s}\n`;
|
|
@@ -503,7 +641,7 @@ export function registerAllTools(server, store) {
|
|
|
503
641
|
lines.push("");
|
|
504
642
|
for (const cat of store.index.categories) {
|
|
505
643
|
lines.push(`## [${cat.slug}] ${cat.title}`);
|
|
506
|
-
lines.push(` ${cat.
|
|
644
|
+
lines.push(` ${guideLink(cat.slug)}`);
|
|
507
645
|
if (cat.sections.length > 0) {
|
|
508
646
|
for (const s of cat.sections) {
|
|
509
647
|
lines.push(` - ${s.label}`);
|
|
@@ -587,7 +725,7 @@ export function registerAllTools(server, store) {
|
|
|
587
725
|
.describe("서비스 유형: online(온라인결제) | offline(오프라인결제) | order(오더클라우드) | membership(멤버십 클라우드)"),
|
|
588
726
|
},
|
|
589
727
|
}, async ({ service }) => {
|
|
590
|
-
const checklist = CHECKLISTS[service] ?? ITEMS_COMMON;
|
|
728
|
+
const checklist = CHECKLISTS[service] ?? [ITEM_PII, ...ITEMS_COMMON];
|
|
591
729
|
const label = SERVICE_LABELS[service] ?? service;
|
|
592
730
|
const autoItems = checklist.filter((c) => c.autoCheck);
|
|
593
731
|
const manualItems = checklist.filter((c) => !c.autoCheck);
|
|
@@ -624,7 +762,7 @@ export function registerAllTools(server, store) {
|
|
|
624
762
|
lines.push("");
|
|
625
763
|
}
|
|
626
764
|
const checkGuideSlug = `${service}-check`;
|
|
627
|
-
lines.push(`> 상세 검수 가이드: get_guide('${checkGuideSlug}') 또는
|
|
765
|
+
lines.push(`> 상세 검수 가이드: get_guide('${checkGuideSlug}') 또는 ${guideLink(checkGuideSlug)}`);
|
|
628
766
|
return text(lines.join("\n"));
|
|
629
767
|
});
|
|
630
768
|
// 8) validate_payload ---------------------------------------------------
|
|
@@ -632,7 +770,7 @@ export function registerAllTools(server, store) {
|
|
|
632
770
|
title: "API 요청 페이로드 자동 검증 + 검수 체크리스트",
|
|
633
771
|
description: "PAYCO API 요청 페이로드(JSON)를 검증하고 서비스별 검수 체크리스트를 반환한다. " +
|
|
634
772
|
"반환값은 반드시 사용자에게 전체 그대로 보여줄 것 — 요약하거나 생략하지 말 것. " +
|
|
635
|
-
"체크리스트 항목은 ✓(
|
|
773
|
+
"체크리스트 항목은 ✓(통과) / ✗(수정 필요, 이유 포함) / ⊘(필드 없어 스킵) / ⬜(수동확인 필요) 로 표시됨. " +
|
|
636
774
|
"⚠️ 운영 로그(실제 카드번호/주민번호/연락처)는 절대 그대로 붙여넣지 말 것.",
|
|
637
775
|
inputSchema: {
|
|
638
776
|
api: z
|
|
@@ -661,36 +799,53 @@ export function registerAllTools(server, store) {
|
|
|
661
799
|
return notFound(`JSON 파싱 실패: ${e.message}`);
|
|
662
800
|
}
|
|
663
801
|
// --- API 명세 검증 (nested 평탄화 비교) ---
|
|
802
|
+
// extraData(JSON 문자열)는 검사용 사본(view)에서 풀어 안쪽까지 검사한다
|
|
803
|
+
const { view, issues: extraDataIssues } = expandExtraData(obj);
|
|
664
804
|
const specByName = new Map(spec.request_params.map((p) => [p.name, p]));
|
|
665
|
-
const
|
|
805
|
+
const hasChildren = (name) => spec.request_params.some((q) => q.name.startsWith(name + "."));
|
|
806
|
+
const flat = flattenPayload(view);
|
|
666
807
|
const missingRequired = [];
|
|
667
808
|
const unknownParams = [];
|
|
668
|
-
const typeMismatches = [];
|
|
669
|
-
// 필수 누락:
|
|
809
|
+
const typeMismatches = [...extraDataIssues];
|
|
810
|
+
// 필수 누락: 배열은 원소마다, 하위 항목은 상위를 보냈을 때만 확인
|
|
670
811
|
for (const p of spec.request_params) {
|
|
671
|
-
if (p.required
|
|
672
|
-
|
|
812
|
+
if (!p.required || !whenApplies(view, p.when))
|
|
813
|
+
continue;
|
|
814
|
+
for (const at of findMissingRequired(view, p.name)) {
|
|
815
|
+
missingRequired.push(`${at} (${p.type}) — ${p.description.slice(0, 60)}`);
|
|
673
816
|
}
|
|
674
817
|
}
|
|
675
818
|
// 알 수 없는 파라미터: payload 의 평탄화 키 중 명세에 없는 것
|
|
819
|
+
// 하위 항목이 정의되지 않은 객체(예: 결제 취소의 extras)는 안쪽을 판단할 수 없으므로 명세 외로 보지 않는다
|
|
820
|
+
const underOpaqueObject = (key) => {
|
|
821
|
+
const parts = key.split(".");
|
|
822
|
+
for (let i = 1; i < parts.length; i++) {
|
|
823
|
+
const anc = parts.slice(0, i).join(".");
|
|
824
|
+
if (specByName.has(anc) && !hasChildren(anc))
|
|
825
|
+
return true;
|
|
826
|
+
}
|
|
827
|
+
return false;
|
|
828
|
+
};
|
|
676
829
|
for (const key of flat.keys()) {
|
|
677
|
-
if (!specByName.has(key))
|
|
830
|
+
if (!specByName.has(key) && !underOpaqueObject(key))
|
|
678
831
|
unknownParams.push(key);
|
|
679
832
|
}
|
|
680
833
|
// 타입 불일치: 평탄화 키마다 명세 타입과 실제 값 타입 비교 (배열 안 객체는 동일 키로 여러 값)
|
|
681
834
|
for (const [key, values] of flat.entries()) {
|
|
835
|
+
if (key === "extraData")
|
|
836
|
+
continue; // expandExtraData 에서 원본 기준으로 검사함
|
|
682
837
|
const p = specByName.get(key);
|
|
683
838
|
if (!p)
|
|
684
839
|
continue;
|
|
685
840
|
const expected = p.type.toLowerCase();
|
|
686
841
|
for (const value of values) {
|
|
687
842
|
const actual = Array.isArray(value) ? "array" : typeof value;
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
843
|
+
// 하위 항목이 정의된 항목은 가이드 표기가 string 이어도 객체/배열을 허용(예: extraData.viewOptions)
|
|
844
|
+
if ((actual === "object" || actual === "array") && hasChildren(key))
|
|
845
|
+
continue;
|
|
846
|
+
if (value === null)
|
|
847
|
+
continue; // null 은 보내지 않은 것으로 본다
|
|
848
|
+
const ok = typeMatches(expected, value) !== false;
|
|
694
849
|
if (!ok) {
|
|
695
850
|
typeMismatches.push(`${key}: 명세 '${p.type}' / 실제 '${actual}'`);
|
|
696
851
|
break; // 같은 키의 다음 값들은 검사 스킵
|
|
@@ -699,11 +854,11 @@ export function registerAllTools(server, store) {
|
|
|
699
854
|
}
|
|
700
855
|
// --- 체크리스트 실행 ---
|
|
701
856
|
const service = serviceOf(api);
|
|
702
|
-
const checklist = CHECKLISTS[service] ?? ITEMS_COMMON;
|
|
857
|
+
const checklist = CHECKLISTS[service] ?? [ITEM_PII, ...ITEMS_COMMON];
|
|
703
858
|
const serviceLabel = SERVICE_LABELS[service] ?? service;
|
|
704
859
|
const runResults = checklist.map((item) => ({
|
|
705
860
|
item,
|
|
706
|
-
result: item.autoCheck ? item.autoCheck(
|
|
861
|
+
result: item.autoCheck ? item.autoCheck(view) : null,
|
|
707
862
|
}));
|
|
708
863
|
const autoFailed = runResults.filter((r) => r.item.autoCheck && r.result !== null && !r.result.pass);
|
|
709
864
|
const autoPassed = runResults.filter((r) => r.item.autoCheck && r.result !== null && r.result.pass);
|
|
@@ -770,7 +925,7 @@ export function registerAllTools(server, store) {
|
|
|
770
925
|
// ── 헤더 ──────────────────────────────────────────────
|
|
771
926
|
lines.push(`# ${pass ? "✓ PASS" : "✗ FAIL"} — ${spec.slug}`);
|
|
772
927
|
lines.push(`[${spec.method ?? "?"}] ${spec.url ?? "(no url)"} | ${spec.title}`);
|
|
773
|
-
lines.push(`서비스: ${serviceLabel} |
|
|
928
|
+
lines.push(`서비스: ${serviceLabel} | 수정 필요 ${checkFails + specFails}개`);
|
|
774
929
|
lines.push("");
|
|
775
930
|
// ── 검수 체크리스트 (항상 맨 앞에 전체 표시) ──────────
|
|
776
931
|
lines.push(`## 검수 체크리스트 — ${serviceLabel} (${checklist.length}개)`);
|
|
@@ -825,7 +980,7 @@ export function registerAllTools(server, store) {
|
|
|
825
980
|
lines.push("## API 명세 검증 ✓ — 이상 없음");
|
|
826
981
|
}
|
|
827
982
|
lines.push("");
|
|
828
|
-
lines.push(`참고 가이드:
|
|
983
|
+
lines.push(`참고 가이드: ${guideLink(spec.guide)}`);
|
|
829
984
|
lines.push("");
|
|
830
985
|
lines.push("---");
|
|
831
986
|
lines.push(`📊 **검수 결과 누적**: ${bufferSize()}건 — 검수가 끝났다면 사용자에게 **\"엑셀로 추출할까요?\"** 라고 물어보고, 원하면 \`export_validation_excel\` 도구를 호출하세요.`);
|
|
@@ -843,7 +998,8 @@ export function registerAllTools(server, store) {
|
|
|
843
998
|
path: z
|
|
844
999
|
.string()
|
|
845
1000
|
.optional()
|
|
846
|
-
.describe("저장할 파일
|
|
1001
|
+
.describe("저장할 파일 이름(선택, 예: '검수결과'). 항상 사용자의 다운로드 폴더에 저장되며 폴더 경로는 무시된다. " +
|
|
1002
|
+
"같은 이름이 있으면 덮어쓰지 않고 ' (2)' 를 붙인다. 미지정 시 payco-validation-{timestamp}.xlsx."),
|
|
847
1003
|
clearAfter: z
|
|
848
1004
|
.boolean()
|
|
849
1005
|
.optional()
|
|
@@ -855,7 +1011,7 @@ export function registerAllTools(server, store) {
|
|
|
855
1011
|
return notFound("누적된 검수 결과가 없습니다. 먼저 validate_payload 로 1건 이상 검수를 수행하세요.");
|
|
856
1012
|
}
|
|
857
1013
|
try {
|
|
858
|
-
const { path: outPath, recordCount, rowCount } = await exportValidationToExcel(records, path);
|
|
1014
|
+
const { path: outPath, recordCount, rowCount, notes } = await exportValidationToExcel(records, path);
|
|
859
1015
|
const cleared = clearAfter ? clearAllRecords() : 0;
|
|
860
1016
|
const lines = [];
|
|
861
1017
|
lines.push(`✅ 엑셀 추출 완료`);
|
|
@@ -864,6 +1020,8 @@ export function registerAllTools(server, store) {
|
|
|
864
1020
|
lines.push(`- 포함 검수 건수: ${recordCount}건`);
|
|
865
1021
|
lines.push(`- 항목별 상세 행 수: ${rowCount}행`);
|
|
866
1022
|
lines.push(`- 시트: 검수요약 / 항목별 상세 / 공통검수체크리스트 (3개)`);
|
|
1023
|
+
for (const n of notes)
|
|
1024
|
+
lines.push(`- 안내: ${n}`);
|
|
867
1025
|
if (clearAfter) {
|
|
868
1026
|
lines.push(`- 추출 후 버퍼 초기화: ${cleared}건 삭제됨`);
|
|
869
1027
|
}
|
|
@@ -871,7 +1029,7 @@ export function registerAllTools(server, store) {
|
|
|
871
1029
|
lines.push(`- 누적 버퍼 유지 (다음 검수 결과도 동일 버퍼에 누적됨)`);
|
|
872
1030
|
}
|
|
873
1031
|
lines.push("");
|
|
874
|
-
lines.push("
|
|
1032
|
+
lines.push("다운로드 폴더에서 위 파일을 열어 결과를 확인하세요.");
|
|
875
1033
|
return text(lines.join("\n"));
|
|
876
1034
|
}
|
|
877
1035
|
catch (e) {
|
|
@@ -891,7 +1049,8 @@ export function registerAllTools(server, store) {
|
|
|
891
1049
|
server.registerTool("get_error_code", {
|
|
892
1050
|
title: "에러코드/코드값 조회",
|
|
893
1051
|
description: "PAYCO 코드정리 표에서 특정 코드 값을 조회. API 응답코드(1000, 4802...), " +
|
|
894
|
-
"결제수단/주문유형/카드사/은행코드 등 모든 코드 카테고리를 한번에 검색."
|
|
1052
|
+
"결제수단/주문유형/카드사/은행코드 등 모든 코드 카테고리를 한번에 검색. " +
|
|
1053
|
+
"같은 코드가 서비스마다 다른 뜻일 수 있으므로 결과의 [서비스 > 코드표] 표시로 구분해 답할 것.",
|
|
895
1054
|
inputSchema: {
|
|
896
1055
|
code: z
|
|
897
1056
|
.string()
|
|
@@ -899,11 +1058,13 @@ export function registerAllTools(server, store) {
|
|
|
899
1058
|
.describe("조회할 코드 (예: '1000', 'CHECKOUT', 'CCBC', '02')"),
|
|
900
1059
|
},
|
|
901
1060
|
}, async ({ code }) => {
|
|
1061
|
+
// 같은 코드가 서비스마다 다른 뜻일 수 있어(예: 1001 온라인/오프라인) 서비스명을 함께 표시
|
|
1062
|
+
const where = (cat) => `${SERVICE_LABELS[cat.root] ?? cat.root} > ${cat.title}`;
|
|
902
1063
|
const hits = [];
|
|
903
1064
|
for (const cat of store.errorCodes.categories) {
|
|
904
1065
|
const found = cat.items.find((it) => it.code === code);
|
|
905
1066
|
if (found) {
|
|
906
|
-
hits.push(`[${cat
|
|
1067
|
+
hits.push(`[${where(cat)}] ${found.code} = ${found.description}`);
|
|
907
1068
|
}
|
|
908
1069
|
}
|
|
909
1070
|
if (hits.length === 0) {
|
|
@@ -911,7 +1072,7 @@ export function registerAllTools(server, store) {
|
|
|
911
1072
|
for (const cat of store.errorCodes.categories) {
|
|
912
1073
|
for (const it of cat.items) {
|
|
913
1074
|
if (it.code.startsWith(code) || it.description.includes(code)) {
|
|
914
|
-
partial.push(`[${cat
|
|
1075
|
+
partial.push(`[${where(cat)}] ${it.code} = ${it.description}`);
|
|
915
1076
|
if (partial.length >= 10)
|
|
916
1077
|
break;
|
|
917
1078
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nhnpayco-dev/mcp-devcenter",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "PAYCO 개발자센터 가이드 전체(온라인·오프라인·오더·멤버십·결제정산) 를 AI 개발 도구에서 바로 조회·검색할 수 있게 해주는 MCP 서버 (PAYCO Developer Center integration guide MCP server for Claude, Cursor and AI agents)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"typescript": "^5.5.0"
|
|
30
30
|
},
|
|
31
31
|
"engines": {
|
|
32
|
-
"node": ">=
|
|
32
|
+
"node": ">=24"
|
|
33
33
|
},
|
|
34
34
|
"keywords": [
|
|
35
35
|
"mcp",
|
|
@@ -64,5 +64,8 @@
|
|
|
64
64
|
"homepage": "https://devcenter.payco.com",
|
|
65
65
|
"bugs": {
|
|
66
66
|
"email": "dl_payco_ts@nhn.com"
|
|
67
|
+
},
|
|
68
|
+
"overrides": {
|
|
69
|
+
"uuid": "^11.1.1"
|
|
67
70
|
}
|
|
68
71
|
}
|