@crmforall/connector 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tools.js ADDED
@@ -0,0 +1,410 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerTools = registerTools;
4
+ const node_crypto_1 = require("node:crypto");
5
+ const zod_1 = require("zod");
6
+ const contracts_1 = require("./contracts");
7
+ const config_1 = require("./config");
8
+ const mapping_1 = require("./mapping");
9
+ const targets_1 = require("./targets");
10
+ function ok(data) {
11
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
12
+ }
13
+ function fail(code, message) {
14
+ return {
15
+ isError: true,
16
+ content: [
17
+ {
18
+ type: "text",
19
+ text: JSON.stringify({ error: { code, message, hint: contracts_1.ERROR_HINTS[code] } }),
20
+ },
21
+ ],
22
+ };
23
+ }
24
+ function unexpected(err) {
25
+ const message = err instanceof Error ? err.message : String(err);
26
+ if (/매핑되지 않은 필드|잘못된 식별자/.test(message)) {
27
+ return fail(contracts_1.ERROR_CODE.SCHEMA_MISSING_FIELD, message);
28
+ }
29
+ return fail(contracts_1.ERROR_CODE.NET_UNREACHABLE, `실행 실패: ${message}`);
30
+ }
31
+ /** 승인 검증 + 감사 기록 — 모든 승인 필요 도구의 공용 게이트 */
32
+ async function consumeApprovalOrFail(rt, tool, token, scope, snapshotHash) {
33
+ const verdict = await rt.platform.consumeApproval({
34
+ token,
35
+ expectedScope: scope,
36
+ snapshotHash,
37
+ });
38
+ const db = await rt.getDb();
39
+ await db.execute(`INSERT INTO ${rt.config.crmSchema}.crm_audit_logs (actor, action, detail)
40
+ VALUES ($1, $2, $3)`, [
41
+ `connector:${rt.config.connectorId}`,
42
+ `${tool}.${verdict.ok ? "approved" : "denied"}`,
43
+ JSON.stringify(verdict.ok
44
+ ? { approvalId: verdict.claims.approvalId, scope }
45
+ : { code: verdict.code, scope }),
46
+ ]);
47
+ if (!verdict.ok) {
48
+ return { ok: false, result: fail(verdict.code, verdict.message) };
49
+ }
50
+ if (verdict.claims.tenantId !== rt.config.tenantId) {
51
+ return {
52
+ ok: false,
53
+ result: fail(contracts_1.ERROR_CODE.TOKEN_SCOPE_MISMATCH, "토큰의 테넌트가 이 커넥터의 테넌트와 다릅니다."),
54
+ };
55
+ }
56
+ return { ok: true, approvalId: verdict.claims.approvalId };
57
+ }
58
+ function requireMapping(rt, mappingVersion) {
59
+ const mapping = rt.config.mapping;
60
+ if (!mapping) {
61
+ return {
62
+ ok: false,
63
+ result: fail(contracts_1.ERROR_CODE.SCHEMA_MISSING_FIELD, "확정된 필드 매핑이 없습니다. propose_field_mapping → validate_mapping(persist=true)을 먼저 실행하세요."),
64
+ };
65
+ }
66
+ if (mapping.version !== mappingVersion) {
67
+ return {
68
+ ok: false,
69
+ result: fail(contracts_1.ERROR_CODE.SCHEMA_MISSING_FIELD, `요청한 매핑 버전(${mappingVersion})이 확정본(${mapping.version})과 다릅니다.`),
70
+ };
71
+ }
72
+ return { ok: true, mapping };
73
+ }
74
+ function registerTools(server, rt) {
75
+ const s = rt.config.crmSchema;
76
+ server.tool(contracts_1.MCP_TOOL.TEST_CONNECTION, "DB 연결 상태와 권한 요약을 반환한다 (승인 불필요, 민감정보 미노출)", {}, async () => {
77
+ try {
78
+ const db = await rt.getDb();
79
+ const report = await db.checkReadiness({ crmSchema: s });
80
+ return ok({
81
+ connectionId: rt.config.connectionId,
82
+ sourceType: db.sourceType,
83
+ status: report.overprivileged
84
+ ? "overprivileged"
85
+ : report.ready
86
+ ? "ready"
87
+ : "not_ready",
88
+ checks: report.checks.map(({ name, passed, detail }) => ({
89
+ name,
90
+ passed,
91
+ detail,
92
+ })),
93
+ });
94
+ }
95
+ catch (err) {
96
+ return unexpected(err);
97
+ }
98
+ });
99
+ server.tool(contracts_1.MCP_TOOL.INSPECT_SCHEMA, "운영 DB의 테이블/컬럼 후보를 반환한다 (컬럼명 중심, 데이터 미노출)", {
100
+ schemaAllowlist: zod_1.z
101
+ .array(zod_1.z.string())
102
+ .optional()
103
+ .describe("탐색을 허용할 스키마 목록 (생략 시 시스템 스키마 외 전체)"),
104
+ }, async ({ schemaAllowlist }) => {
105
+ try {
106
+ const db = await rt.getDb();
107
+ const columns = await db.listColumns({ schemaAllowlist });
108
+ const tables = new Map();
109
+ for (const c of columns) {
110
+ const key = `${c.schema}.${c.table}`;
111
+ if (!tables.has(key)) {
112
+ tables.set(key, { schema: c.schema, table: c.table, columns: [] });
113
+ }
114
+ tables.get(key).columns.push({ column: c.column, dataType: c.dataType });
115
+ }
116
+ return ok({ tables: [...tables.values()] });
117
+ }
118
+ catch (err) {
119
+ return unexpected(err);
120
+ }
121
+ });
122
+ server.tool(contracts_1.MCP_TOOL.PROPOSE_FIELD_MAPPING, "컬럼명 휴리스틱으로 표준 필드 매핑 후보를 제안한다 (확정은 validate_mapping에서)", {
123
+ schemaAllowlist: zod_1.z.array(zod_1.z.string()).optional(),
124
+ }, async ({ schemaAllowlist }) => {
125
+ try {
126
+ const db = await rt.getDb();
127
+ const columns = await db.listColumns({ schemaAllowlist });
128
+ return ok((0, mapping_1.proposeMapping)(columns));
129
+ }
130
+ catch (err) {
131
+ return unexpected(err);
132
+ }
133
+ });
134
+ server.tool(contracts_1.MCP_TOOL.VALIDATE_MAPPING, "매핑의 필수 필드 충족과 컬럼 실재를 검증한다. persist=true면 통과본을 확정 저장한다", {
135
+ mapping: zod_1.z
136
+ .record(zod_1.z.unknown())
137
+ .describe("MappingConfig JSON — version, customer/reservation 엔터티"),
138
+ persist: zod_1.z
139
+ .boolean()
140
+ .default(false)
141
+ .describe("검증 통과 시 확정 매핑으로 저장할지 여부"),
142
+ }, async ({ mapping: rawMapping, persist }) => {
143
+ try {
144
+ const parsed = config_1.mappingConfigSchema.safeParse(rawMapping);
145
+ if (!parsed.success) {
146
+ return fail(contracts_1.ERROR_CODE.SCHEMA_MISSING_FIELD, `매핑 형식 오류: ${parsed.error.issues.map((i) => i.path.join(".")).join(", ")}`);
147
+ }
148
+ const db = await rt.getDb();
149
+ const columns = await db.listColumns({});
150
+ const validation = (0, mapping_1.validateMapping)(parsed.data, columns);
151
+ if (!validation.valid) {
152
+ return fail(contracts_1.ERROR_CODE.SCHEMA_MISSING_FIELD, `매핑 검증 실패 — 누락: [${validation.missingRequired.join(", ")}], 미존재 컬럼: [${validation.unknownColumns.join(", ")}]`);
153
+ }
154
+ let persisted = false;
155
+ if (persist) {
156
+ await rt.persistMapping(parsed.data);
157
+ await db.execute(`INSERT INTO ${s}.crm_field_mappings (version, mapping)
158
+ VALUES ($1, $2)
159
+ ON CONFLICT (version) DO UPDATE SET mapping = EXCLUDED.mapping`, [parsed.data.version, JSON.stringify(parsed.data)]);
160
+ persisted = true;
161
+ }
162
+ return ok({ valid: true, version: parsed.data.version, persisted });
163
+ }
164
+ catch (err) {
165
+ return unexpected(err);
166
+ }
167
+ });
168
+ server.tool(contracts_1.MCP_TOOL.PREVIEW_TARGETS, "조건 프로젝트의 대상 수와 제외 사유별 건수를 반환한다 (원문 전화번호 미노출)", {
169
+ projectId: zod_1.z.string(),
170
+ mappingVersion: zod_1.z.string(),
171
+ }, async ({ projectId, mappingVersion }) => {
172
+ try {
173
+ const m = requireMapping(rt, mappingVersion);
174
+ if (!m.ok)
175
+ return m.result;
176
+ const project = await rt.platform.getProjectDefinition(projectId);
177
+ const db = await rt.getDb();
178
+ const computation = await (0, targets_1.computeTargets)(db, m.mapping, project, {
179
+ now: rt.now,
180
+ });
181
+ return ok({
182
+ targetCount: computation.members.length,
183
+ exclusions: computation.exclusions,
184
+ previewHash: computation.previewHash,
185
+ });
186
+ }
187
+ catch (err) {
188
+ return unexpected(err);
189
+ }
190
+ });
191
+ server.tool(contracts_1.MCP_TOOL.CREATE_TARGET_SNAPSHOT, "승인된 미리보기를 대상 스냅샷으로 고정한다 (승인 토큰 필수, 해시 일치 검증)", {
192
+ projectId: zod_1.z.string(),
193
+ previewHash: zod_1.z.string(),
194
+ approvalToken: zod_1.z.string().min(1),
195
+ }, async ({ projectId, previewHash, approvalToken }) => {
196
+ try {
197
+ const mapping = rt.config.mapping;
198
+ if (!mapping) {
199
+ return fail(contracts_1.ERROR_CODE.SCHEMA_MISSING_FIELD, "확정된 필드 매핑이 없습니다.");
200
+ }
201
+ const gate = await consumeApprovalOrFail(rt, contracts_1.MCP_TOOL.CREATE_TARGET_SNAPSHOT, approvalToken, contracts_1.APPROVAL_SCOPE.TARGET_SNAPSHOT_CREATE, previewHash);
202
+ if (!gate.ok)
203
+ return gate.result;
204
+ const db = await rt.getDb();
205
+ // 멱등: 같은 승인의 중복 요청은 기존 스냅샷으로 수렴
206
+ const existing = await db.selectRows(`SELECT snapshot_id, snapshot_hash, target_count
207
+ FROM ${s}.crm_target_snapshots WHERE approval_id = $1`, [gate.approvalId]);
208
+ if (existing.length > 0) {
209
+ return ok({
210
+ snapshotId: String(existing[0].snapshot_id),
211
+ snapshotHash: String(existing[0].snapshot_hash),
212
+ targetCount: Number(existing[0].target_count),
213
+ deduplicated: true,
214
+ });
215
+ }
216
+ // 승인 시점과 실행 시점의 대상이 같은지 재산출로 확인
217
+ const project = await rt.platform.getProjectDefinition(projectId);
218
+ const computation = await (0, targets_1.computeTargets)(db, mapping, project, {
219
+ now: rt.now,
220
+ });
221
+ if (computation.previewHash !== previewHash) {
222
+ return fail(contracts_1.ERROR_CODE.SNAPSHOT_HASH_MISMATCH, "승인 이후 대상이 변동되었습니다. 미리보기를 다시 산출하고 재승인 받으세요.");
223
+ }
224
+ const snapshotId = (0, node_crypto_1.randomUUID)();
225
+ await db.execute(`INSERT INTO ${s}.crm_target_snapshots
226
+ (snapshot_id, project_id, project_version, mapping_version,
227
+ snapshot_hash, approval_id, target_count, exclusions)
228
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [
229
+ snapshotId,
230
+ project.projectId,
231
+ project.projectVersion,
232
+ mapping.version,
233
+ computation.previewHash,
234
+ gate.approvalId,
235
+ computation.members.length,
236
+ JSON.stringify(computation.exclusions),
237
+ ]);
238
+ for (const member of computation.members) {
239
+ await db.execute(`INSERT INTO ${s}.crm_snapshot_members (snapshot_id, customer_key, phone_hash)
240
+ VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`, [snapshotId, member.customerKey, member.phoneHash]);
241
+ }
242
+ return ok({
243
+ snapshotId,
244
+ snapshotHash: computation.previewHash,
245
+ targetCount: computation.members.length,
246
+ deduplicated: false,
247
+ });
248
+ }
249
+ catch (err) {
250
+ return unexpected(err);
251
+ }
252
+ });
253
+ server.tool(contracts_1.MCP_TOOL.WRITE_CRM_EVENT, "CRM 전용 스키마에 이벤트를 기록한다 (승인 토큰 필수, 운영 원천 테이블 비접근)", {
254
+ eventType: zod_1.z.string().min(1),
255
+ payload: zod_1.z.record(zod_1.z.unknown()),
256
+ approvalToken: zod_1.z.string().min(1),
257
+ }, async ({ eventType, payload, approvalToken }) => {
258
+ try {
259
+ const gate = await consumeApprovalOrFail(rt, contracts_1.MCP_TOOL.WRITE_CRM_EVENT, approvalToken, contracts_1.APPROVAL_SCOPE.CRM_EVENT_WRITE, undefined);
260
+ if (!gate.ok)
261
+ return gate.result;
262
+ const db = await rt.getDb();
263
+ const existing = await db.selectRows(`SELECT event_id FROM ${s}.crm_events WHERE approval_id = $1`, [gate.approvalId]);
264
+ if (existing.length > 0) {
265
+ return ok({ eventId: String(existing[0].event_id), deduplicated: true });
266
+ }
267
+ const eventId = (0, node_crypto_1.randomUUID)();
268
+ await db.execute(`INSERT INTO ${s}.crm_events (event_id, event_type, payload, approval_id)
269
+ VALUES ($1, $2, $3, $4)`, [eventId, eventType, JSON.stringify(payload), gate.approvalId]);
270
+ return ok({ eventId, deduplicated: false });
271
+ }
272
+ catch (err) {
273
+ return unexpected(err);
274
+ }
275
+ });
276
+ server.tool(contracts_1.MCP_TOOL.SEND_KAKAO_MESSAGE, "승인된 스냅샷 대상에게 발송 작업을 생성한다 (승인 토큰 필수, 스냅샷 해시 바인딩)", {
277
+ snapshotId: zod_1.z.string(),
278
+ messageVersion: zod_1.z.string(),
279
+ channel: zod_1.z.enum(["alimtalk", "brand"]),
280
+ approvalToken: zod_1.z.string().min(1),
281
+ }, async ({ snapshotId, messageVersion, channel, approvalToken }) => {
282
+ try {
283
+ const db = await rt.getDb();
284
+ const snapshots = await db.selectRows(`SELECT snapshot_hash, target_count, project_id FROM ${s}.crm_target_snapshots
285
+ WHERE snapshot_id = $1`, [snapshotId]);
286
+ if (snapshots.length === 0) {
287
+ return fail(contracts_1.ERROR_CODE.SNAPSHOT_HASH_MISMATCH, `스냅샷 ${snapshotId}이 없습니다. create_target_snapshot을 먼저 실행하세요.`);
288
+ }
289
+ const snapshotHash = String(snapshots[0].snapshot_hash);
290
+ const gate = await consumeApprovalOrFail(rt, contracts_1.MCP_TOOL.SEND_KAKAO_MESSAGE, approvalToken, contracts_1.APPROVAL_SCOPE.KAKAO_SEND, snapshotHash);
291
+ if (!gate.ok)
292
+ return gate.result;
293
+ // 멱등: 같은 승인의 중복 요청은 하나의 발송 작업으로 수렴
294
+ const existing = await db.selectRows(`SELECT send_job_id FROM ${s}.crm_send_jobs WHERE approval_id = $1`, [gate.approvalId]);
295
+ if (existing.length > 0) {
296
+ return ok({ sendJobId: String(existing[0].send_job_id), deduplicated: true });
297
+ }
298
+ const { sendJobId } = await rt.platform.createSendJob({
299
+ approvalId: gate.approvalId,
300
+ snapshotId,
301
+ snapshotHash,
302
+ messageVersion,
303
+ channel,
304
+ targetCount: Number(snapshots[0].target_count),
305
+ campaignRef: snapshots[0].project_id ? String(snapshots[0].project_id) : null,
306
+ });
307
+ await db.execute(`INSERT INTO ${s}.crm_send_jobs
308
+ (send_job_id, snapshot_id, channel, message_version, approval_id, status)
309
+ VALUES ($1, $2, $3, $4, $5, 'queued')
310
+ ON CONFLICT (approval_id) DO NOTHING`, [sendJobId, snapshotId, channel, messageVersion, gate.approvalId]);
311
+ return ok({ sendJobId, deduplicated: false });
312
+ }
313
+ catch (err) {
314
+ return unexpected(err);
315
+ }
316
+ });
317
+ server.tool(contracts_1.MCP_TOOL.SYNC_SEND_RESULT, "발송 작업 결과를 플랫폼에서 가져와 CRM 스키마에 동기화한다 (승인 불필요)", { sendJobId: zod_1.z.string() }, async ({ sendJobId }) => {
318
+ try {
319
+ const job = await rt.platform.getSendJob(sendJobId);
320
+ const db = await rt.getDb();
321
+ for (const r of job.results) {
322
+ await db.execute(`INSERT INTO ${s}.crm_send_results
323
+ (send_job_id, receiver_ref, status, provider_code, internal_code)
324
+ VALUES ($1, $2, $3, $4, $5)
325
+ ON CONFLICT (send_job_id, receiver_ref) DO UPDATE
326
+ SET status = EXCLUDED.status,
327
+ provider_code = EXCLUDED.provider_code,
328
+ internal_code = EXCLUDED.internal_code,
329
+ synced_at = now()`, [sendJobId, r.receiverRef, r.status, r.providerCode, r.internalCode]);
330
+ }
331
+ await db.execute(`UPDATE ${s}.crm_send_jobs SET status = $2 WHERE send_job_id = $1`, [sendJobId, job.status]);
332
+ const counts = job.results.reduce((acc, r) => {
333
+ acc[r.status] = (acc[r.status] ?? 0) + 1;
334
+ return acc;
335
+ }, {});
336
+ return ok({ sendJobId, status: job.status, resultCounts: counts });
337
+ }
338
+ catch (err) {
339
+ return unexpected(err);
340
+ }
341
+ });
342
+ server.tool(contracts_1.MCP_TOOL.TRACK_ENGAGEMENT, "반응(클릭/예약/픽업완료)을 crm_engagements에 기록한다 (추가 전용, 개인정보 비반환, 승인 불필요)", {
343
+ identityKey: zod_1.z.string().min(1),
344
+ engagementType: zod_1.z.enum(["click", "reservation", "pickup_completed"]),
345
+ campaignRef: zod_1.z.string().nullable(),
346
+ shortLinkRef: zod_1.z.string().nullable(),
347
+ reservationRef: zod_1.z.string().nullable(),
348
+ pickupAmount: zod_1.z.number().nullable(),
349
+ occurredAt: zod_1.z.string().datetime(),
350
+ }, async (input) => {
351
+ try {
352
+ const db = await rt.getDb();
353
+ const eventId = (0, node_crypto_1.randomUUID)();
354
+ await db.execute(`INSERT INTO ${s}.crm_engagements
355
+ (event_id, identity_key, engagement_type, campaign_ref,
356
+ short_link_ref, reservation_ref, pickup_amount, occurred_at)
357
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [
358
+ eventId,
359
+ input.identityKey,
360
+ input.engagementType,
361
+ input.campaignRef,
362
+ input.shortLinkRef,
363
+ input.reservationRef,
364
+ input.pickupAmount,
365
+ input.occurredAt,
366
+ ]);
367
+ // 플랫폼 중계는 집계용 — 실패해도 로컬 기록(원본)은 유지된다
368
+ let relayed = true;
369
+ try {
370
+ await rt.platform.reportEngagement({
371
+ identityKey: input.identityKey,
372
+ engagementType: input.engagementType,
373
+ campaignRef: input.campaignRef,
374
+ shortLinkRef: input.shortLinkRef,
375
+ reservationRef: input.reservationRef,
376
+ pickupAmount: input.pickupAmount,
377
+ occurredAt: input.occurredAt,
378
+ });
379
+ }
380
+ catch {
381
+ relayed = false;
382
+ }
383
+ return ok({ eventId, relayed });
384
+ }
385
+ catch (err) {
386
+ return unexpected(err);
387
+ }
388
+ });
389
+ // --- 2차 도구 — 승인 경계만 고정, 본 구현은 해당 마일스톤에서 ---
390
+ const futureTools = [
391
+ { name: contracts_1.MCP_TOOL.IMPORT_PROSPECTS, phase: "2차" },
392
+ { name: contracts_1.MCP_TOOL.REGISTER_ENTRY_POINT, phase: "2차" },
393
+ ];
394
+ for (const { name, phase } of futureTools) {
395
+ server.tool(name, `${name} — ${phase} 마일스톤에서 구현된다. 승인 경계는 유지된다.`, { approvalToken: zod_1.z.string().optional() }, async () => ({
396
+ isError: true,
397
+ content: [
398
+ {
399
+ type: "text",
400
+ text: JSON.stringify({
401
+ error: {
402
+ code: "E_NOT_IMPLEMENTED",
403
+ message: `${name}은 ${phase} 마일스톤에서 구현됩니다. 승인 토큰 검증 경계는 유지됩니다.`,
404
+ },
405
+ }),
406
+ },
407
+ ],
408
+ }));
409
+ }
410
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@crmforall/connector",
3
+ "version": "0.1.0",
4
+ "description": "crmforall MCP 커넥터 — 운영 DB 읽기, CRM 스키마 쓰기(승인 토큰 필수), 대상 산출",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "exports": {
8
+ ".": "./dist/index.js",
9
+ "./db": "./dist/db/index.js"
10
+ },
11
+ "typesVersions": {
12
+ "*": {
13
+ "db": [
14
+ "dist/db/index.d.ts"
15
+ ]
16
+ }
17
+ },
18
+ "bin": {
19
+ "crmforall-connector": "./dist/server.js"
20
+ },
21
+ "files": [
22
+ "dist"
23
+ ],
24
+ "scripts": {
25
+ "build": "tsc",
26
+ "typecheck": "tsc --noEmit",
27
+ "test": "vitest run",
28
+ "start": "node dist/server.js"
29
+ },
30
+ "dependencies": {
31
+ "@modelcontextprotocol/sdk": "^1.12.0",
32
+ "pg": "^8.16.0",
33
+ "zod": "^3.23.8"
34
+ },
35
+ "devDependencies": {
36
+ "@types/node": "^22.0.0",
37
+ "@types/pg": "^8.15.0",
38
+ "typescript": "^5.8.0",
39
+ "vitest": "^3.2.6"
40
+ },
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "license": "UNLICENSED",
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "https://github.com/HosungYou/crmforall-connector"
48
+ },
49
+ "engines": {
50
+ "node": ">=22"
51
+ }
52
+ }