@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.
@@ -0,0 +1,85 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HttpPlatformClient = exports.PlatformError = void 0;
4
+ const contracts_1 = require("./contracts");
5
+ class PlatformError extends Error {
6
+ code;
7
+ constructor(code, message) {
8
+ super(message);
9
+ this.code = code;
10
+ this.name = "PlatformError";
11
+ }
12
+ }
13
+ exports.PlatformError = PlatformError;
14
+ class HttpPlatformClient {
15
+ opts;
16
+ fetchImpl;
17
+ constructor(opts) {
18
+ this.opts = opts;
19
+ this.fetchImpl = opts.fetchImpl ?? fetch;
20
+ }
21
+ async request(method, path, body) {
22
+ let res;
23
+ try {
24
+ res = await this.fetchImpl(new URL(path, this.opts.platformUrl), {
25
+ method,
26
+ headers: {
27
+ "content-type": "application/json",
28
+ "x-connector-id": this.opts.connectorId,
29
+ authorization: `Bearer ${this.opts.connectorSecret}`,
30
+ },
31
+ body: body === undefined ? undefined : JSON.stringify(body),
32
+ });
33
+ }
34
+ catch (err) {
35
+ throw new PlatformError(contracts_1.ERROR_CODE.PLATFORM_UNREACHABLE, `플랫폼 요청 실패: ${err instanceof Error ? err.message : String(err)}`);
36
+ }
37
+ if (res.status === 401 || res.status === 403) {
38
+ throw new PlatformError(contracts_1.ERROR_CODE.AUTH_FAILED, "커넥터 자격증명이 거부되었습니다. connect init으로 재등록하세요.");
39
+ }
40
+ const json = (await res.json().catch(() => null));
41
+ if (!res.ok) {
42
+ throw new PlatformError(json?.error?.code ?? contracts_1.ERROR_CODE.PLATFORM_UNREACHABLE, json?.error?.message ?? `플랫폼 응답 오류 (HTTP ${res.status})`);
43
+ }
44
+ return json;
45
+ }
46
+ async consumeApproval(input) {
47
+ // consume은 거부 사유를 에러가 아닌 결과로 받는다 — 도구가 코드별 힌트를 내보내야 하므로
48
+ try {
49
+ const data = await this.request("POST", "/api/approvals/consume", {
50
+ token: input.token,
51
+ expectedScope: input.expectedScope,
52
+ snapshotHash: input.snapshotHash ?? null,
53
+ });
54
+ if (data.ok && data.claims)
55
+ return { ok: true, claims: data.claims };
56
+ return {
57
+ ok: false,
58
+ code: data.error?.code ?? contracts_1.ERROR_CODE.AUTH_FAILED,
59
+ message: data.error?.message ?? "승인 토큰 검증 실패",
60
+ };
61
+ }
62
+ catch (err) {
63
+ if (err instanceof PlatformError) {
64
+ return { ok: false, code: err.code, message: err.message };
65
+ }
66
+ throw err;
67
+ }
68
+ }
69
+ async getProjectDefinition(projectId) {
70
+ const data = await this.request("GET", `/api/projects/${encodeURIComponent(projectId)}/definition`);
71
+ return data.project;
72
+ }
73
+ async reportEngagement(input) {
74
+ await this.request("POST", "/api/engagements", input);
75
+ }
76
+ async createSendJob(input) {
77
+ const data = await this.request("POST", "/api/sends", input);
78
+ return { sendJobId: data.sendJobId };
79
+ }
80
+ async getSendJob(sendJobId) {
81
+ const data = await this.request("GET", `/api/sends/${encodeURIComponent(sendJobId)}`);
82
+ return data.job;
83
+ }
84
+ }
85
+ exports.HttpPlatformClient = HttpPlatformClient;
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/server.js ADDED
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
5
+ const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
6
+ const config_1 = require("./config");
7
+ const index_1 = require("./index");
8
+ const platform_1 = require("./platform");
9
+ const tools_1 = require("./tools");
10
+ /**
11
+ * crmforall MCP 커넥터 서버 (stdio).
12
+ * 설정은 connect init이 만든 ~/.crmforall/connector.json에서 읽는다.
13
+ * 자격증명은 도구 출력 어디에도 노출되지 않는다 (AI 비노출 원칙).
14
+ */
15
+ async function main() {
16
+ const config = (0, config_1.loadConfig)();
17
+ if (!config) {
18
+ console.error("[crmforall-connector] 설정이 없습니다. 먼저 `npx @crmforall/connect init`을 실행하세요.");
19
+ process.exit(1);
20
+ }
21
+ let db = null;
22
+ const getDb = async () => {
23
+ if (!db) {
24
+ const adapter = (0, index_1.buildAdapter)(config);
25
+ await adapter.connect();
26
+ db = adapter;
27
+ }
28
+ return db;
29
+ };
30
+ const server = new mcp_js_1.McpServer({
31
+ name: "crmforall-connector",
32
+ version: "0.1.0",
33
+ });
34
+ (0, tools_1.registerTools)(server, {
35
+ config,
36
+ platform: new platform_1.HttpPlatformClient({
37
+ platformUrl: config.platformUrl,
38
+ connectorId: config.connectorId,
39
+ connectorSecret: config.connectorSecret,
40
+ }),
41
+ getDb,
42
+ persistMapping: async (mapping) => {
43
+ config.mapping = mapping;
44
+ (0, config_1.saveConfig)(config);
45
+ },
46
+ });
47
+ const transport = new stdio_js_1.StdioServerTransport();
48
+ await server.connect(transport);
49
+ console.error("[crmforall-connector] MCP 서버 시작 (stdio)");
50
+ const shutdown = async () => {
51
+ await db?.disconnect().catch(() => { });
52
+ process.exit(0);
53
+ };
54
+ process.on("SIGINT", shutdown);
55
+ process.on("SIGTERM", shutdown);
56
+ }
57
+ main().catch((err) => {
58
+ console.error("[crmforall-connector] 시작 실패:", err);
59
+ process.exit(1);
60
+ });
@@ -0,0 +1,40 @@
1
+ import type { SqlExecutor } from "./db/adapter";
2
+ import type { MappingConfig } from "./config";
3
+ import { type ExclusionReason } from "./contracts";
4
+ import type { ProjectDefinition } from "./platform";
5
+ /**
6
+ * 대상 산출 — preview_targets / create_target_snapshot의 공용 계산.
7
+ *
8
+ * fail-closed 원칙 (기존 SPEC §카카오 발송 어댑터):
9
+ * 광고성 수신동의·채널 상태 중 하나라도 누락/불명확하면 브랜드 메시지(광고) 대상에서 제외한다.
10
+ * 제외는 사유별 건수로만 보고한다 — 원문 전화번호는 이 계층을 통과하지 않는다.
11
+ */
12
+ export interface TargetMember {
13
+ customerKey: string;
14
+ phoneHash: string;
15
+ }
16
+ export interface TargetComputation {
17
+ members: TargetMember[];
18
+ exclusions: Array<{
19
+ reason: ExclusionReason;
20
+ count: number;
21
+ }>;
22
+ previewHash: string;
23
+ }
24
+ /**
25
+ * 스냅샷/미리보기 해시 — 같은 대상 집합이면 항상 같은 값.
26
+ * 승인 토큰의 snapshotHash 바인딩과 비교되므로 결정적이어야 한다.
27
+ */
28
+ export declare function computePreviewHash(input: {
29
+ projectId: string;
30
+ projectVersion: string;
31
+ mappingVersion: string;
32
+ members: TargetMember[];
33
+ }): string;
34
+ /**
35
+ * 재구매 조건 프로젝트의 대상을 산출한다.
36
+ * 읽기 전용 SELECT만 수행 — 운영 원천 테이블에는 어떤 쓰기도 하지 않는다.
37
+ */
38
+ export declare function computeTargets(db: SqlExecutor, mapping: MappingConfig, project: ProjectDefinition, options?: {
39
+ now?: () => number;
40
+ }): Promise<TargetComputation>;
@@ -0,0 +1,121 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.computePreviewHash = computePreviewHash;
4
+ exports.computeTargets = computeTargets;
5
+ const node_crypto_1 = require("node:crypto");
6
+ const contracts_1 = require("./contracts");
7
+ function ident(name) {
8
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]{0,62}$/.test(name)) {
9
+ throw new Error(`잘못된 식별자: ${name}`);
10
+ }
11
+ return `"${name}"`;
12
+ }
13
+ function tableRef(t) {
14
+ return `${ident(t.schema)}.${ident(t.table)}`;
15
+ }
16
+ /**
17
+ * 스냅샷/미리보기 해시 — 같은 대상 집합이면 항상 같은 값.
18
+ * 승인 토큰의 snapshotHash 바인딩과 비교되므로 결정적이어야 한다.
19
+ */
20
+ function computePreviewHash(input) {
21
+ const keys = input.members.map((m) => m.customerKey).sort();
22
+ const canonical = JSON.stringify({
23
+ projectId: input.projectId,
24
+ projectVersion: input.projectVersion,
25
+ mappingVersion: input.mappingVersion,
26
+ customerKeys: keys,
27
+ });
28
+ return (0, node_crypto_1.createHash)("sha256").update(canonical).digest("hex");
29
+ }
30
+ /**
31
+ * 재구매 조건 프로젝트의 대상을 산출한다.
32
+ * 읽기 전용 SELECT만 수행 — 운영 원천 테이블에는 어떤 쓰기도 하지 않는다.
33
+ */
34
+ async function computeTargets(db, mapping, project, options) {
35
+ const now = options?.now ?? Date.now;
36
+ const c = mapping.customer;
37
+ const r = mapping.reservation;
38
+ const col = (entity, field) => {
39
+ const column = entity.fields[field];
40
+ if (!column)
41
+ throw new Error(`매핑되지 않은 필드: ${field}`);
42
+ return ident(column);
43
+ };
44
+ const channelCol = c.fields.kakaoChannelStatus
45
+ ? `cust.${col(c, "kakaoChannelStatus")}`
46
+ : "NULL";
47
+ const cond = project.conditions;
48
+ const nowMs = now();
49
+ const windowEnd = new Date(nowMs - cond.daysSincePickupMin * 86_400_000);
50
+ const windowStart = new Date(nowMs - cond.daysSincePickupMax * 86_400_000);
51
+ const params = [windowStart.toISOString(), windowEnd.toISOString()];
52
+ let productFilter = "";
53
+ if (cond.productIds?.length) {
54
+ params.push(cond.productIds);
55
+ productFilter = `AND res.${col(r, "productId")}::text = ANY($${params.length})`;
56
+ }
57
+ // 윈도우 내 픽업완료가 있는 고객만 — 동의/채널 판정은 fail-closed로 TS에서 수행
58
+ const rows = (await db.selectRows(`SELECT DISTINCT
59
+ cust.${col(c, "customerKey")}::text AS customer_key,
60
+ cust.${col(c, "phoneNumberHash")} AS phone_hash,
61
+ cust.${col(c, "marketingOptIn")} AS marketing_opt_in,
62
+ ${channelCol} AS kakao_channel_status
63
+ FROM ${tableRef(c.table)} cust
64
+ JOIN ${tableRef(r.table)} res
65
+ ON res.${col(r, "customerKey")}::text = cust.${col(c, "customerKey")}::text
66
+ WHERE res.${col(r, "pickupCompletedAt")} >= $1
67
+ AND res.${col(r, "pickupCompletedAt")} < $2
68
+ ${productFilter}`, params));
69
+ const exclusionCounts = new Map();
70
+ const exclude = (reason) => exclusionCounts.set(reason, (exclusionCounts.get(reason) ?? 0) + 1);
71
+ const seenPhoneHashes = new Set();
72
+ const members = [];
73
+ for (const row of rows) {
74
+ const phoneHash = row.phone_hash == null ? null : String(row.phone_hash);
75
+ if (!phoneHash) {
76
+ exclude(contracts_1.EXCLUSION_REASON.PHONE_MISSING);
77
+ continue;
78
+ }
79
+ const optIn = row.marketing_opt_in;
80
+ if (optIn === false) {
81
+ exclude(contracts_1.EXCLUSION_REASON.MARKETING_OPT_OUT);
82
+ continue;
83
+ }
84
+ // 브랜드 메시지(광고성)는 동의·채널 상태가 명확해야 한다 — 불명확이면 무조건 제외
85
+ if (project.channel === "brand") {
86
+ const channel = row.kakao_channel_status == null ? null : String(row.kakao_channel_status);
87
+ if (optIn !== true || channel === null || channel === "needs_check") {
88
+ exclude(contracts_1.EXCLUSION_REASON.CONSENT_AMBIGUOUS);
89
+ continue;
90
+ }
91
+ if (channel === "blocked") {
92
+ exclude(contracts_1.EXCLUSION_REASON.CHANNEL_BLOCKED);
93
+ continue;
94
+ }
95
+ if (channel === "unsubscribed") {
96
+ exclude(contracts_1.EXCLUSION_REASON.UNSUBSCRIBED);
97
+ continue;
98
+ }
99
+ }
100
+ if (seenPhoneHashes.has(phoneHash)) {
101
+ exclude(contracts_1.EXCLUSION_REASON.DUPLICATE);
102
+ continue;
103
+ }
104
+ seenPhoneHashes.add(phoneHash);
105
+ members.push({ customerKey: String(row.customer_key), phoneHash });
106
+ }
107
+ const previewHash = computePreviewHash({
108
+ projectId: project.projectId,
109
+ projectVersion: project.projectVersion,
110
+ mappingVersion: mapping.version,
111
+ members,
112
+ });
113
+ return {
114
+ members,
115
+ exclusions: [...exclusionCounts.entries()].map(([reason, count]) => ({
116
+ reason,
117
+ count,
118
+ })),
119
+ previewHash,
120
+ };
121
+ }
@@ -0,0 +1,20 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { ConnectorConfig, MappingConfig } from "./config";
3
+ import type { DbAdapter } from "./db/adapter";
4
+ import type { PlatformClient } from "./platform";
5
+ /**
6
+ * MCP 도구 본 구현 (MVP 9종).
7
+ * 승인 경계: 쓰기/발송 도구는 플랫폼의 /api/approvals/consume으로 토큰을
8
+ * 검증·소비한 뒤에만 실행된다. 검증 실패 코드는 ERROR_HINTS와 함께 반환되어
9
+ * AI 운전 계층이 다음 행동을 결정할 수 있다 (AGENTS.md 계약).
10
+ */
11
+ export interface ToolRuntime {
12
+ config: ConnectorConfig;
13
+ platform: PlatformClient;
14
+ /** 최초 사용 시 connect()까지 끝난 어댑터를 반환해야 한다 */
15
+ getDb: () => Promise<DbAdapter>;
16
+ /** validate_mapping(persist=true) 통과본을 설정에 반영 */
17
+ persistMapping: (mapping: MappingConfig) => Promise<void>;
18
+ now?: () => number;
19
+ }
20
+ export declare function registerTools(server: McpServer, rt: ToolRuntime): void;