@agifyai/leadify-mcp 8.5.1 → 8.5.2

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 CHANGED
@@ -102,6 +102,14 @@ Mode watch :
102
102
  npm run dev
103
103
  ```
104
104
 
105
+ Canari de vérité commerciale en lecture seule :
106
+
107
+ ```bash
108
+ LEADIFY_CANARY_ORGANIZATION_ID=org-id npm run canary:commercial
109
+ ```
110
+
111
+ Le canari sélectionne une organisation et un groupe accessibles, relit la même source deux fois, ne publie aucun message et n'ajoute aucune activité. Les variables `LEADIFY_CANARY_LEAD_GROUP_ID`, `LEADIFY_CANARY_EMAIL`, `LEADIFY_CANARY_LINKEDIN_URL` et `LEADIFY_CANARY_LEAD_ID` permettent de fixer une cible déjà autorisée ; la clé et le contenu des conversations ne sont jamais affichés.
112
+
105
113
  ### Architecture
106
114
 
107
115
  ```
@@ -211,7 +219,7 @@ Leadify et ne doit jamais être envoyée par un agent MCP.
211
219
  | `get_campaign_logs` | Récupérer les logs de campagne avec filtres et pagination. |
212
220
  | `delete_campaign_log` | Supprimer une entrée de log de campagne. |
213
221
  | `update_campaign_stats` | Mettre à jour les statistiques d'email d'une campagne pour un groupe de leads. |
214
- | `create_campaign` | Créer une nouvelle campagne rattachée à un groupe de leads (statut DRAFT). |
222
+ | `create_campaign` | Créer une campagne DRAFT mono-canal rattachée à un groupe de leads. `channel` (`LINKEDIN` ou `EMAIL`) est obligatoire : aucun défaut LinkedIn implicite. La fenêtre de campagne (`startAt`/`endAt`) et le contrat de clôture ne sont pas encore supportés par l'API Leadify ; ne pas utiliser cet outil pour une campagne bornée dans le temps avant cette livraison. |
215
223
  | `list_campaigns` | Lister compactement les campagnes d'une organisation explicitement sélectionnée et leur statut courant. |
216
224
  | `get_campaign` | Récupérer les détails d'une campagne et ses KPIs temps réel. |
217
225
  | `update_campaign_status` | Changer le statut d'une campagne (DRAFT, ACTIVE, PAUSED, COMPLETED). |
@@ -221,6 +229,12 @@ Leadify et ne doit jamais être envoyée par un agent MCP.
221
229
  | `signal_disable` | Désactiver un signal (faux positif / écarté manuellement). Flip de `state` uniquement. |
222
230
  | `signal_delete` | Supprimer définitivement un signal (cas rare : donnée erronée, doublon). |
223
231
  | `add_activity` | Journaliser une interaction prospect (LinkedIn, email, call) dans le feed du lead. |
232
+ | `crm_lookup_person` | Lire la personne, les deals et le stage CRM d'une cible explicite, sans écrire dans le CRM. |
233
+ | `email_read_thread` | Lire un thread email borné et ses réponses, exclusions de délivrabilité et ambiguïtés. |
234
+ | `linkedin_read_conversation` | Lire la conversation LinkedIn bornée d'un profil explicitement sélectionné. |
235
+ | `unipile_read_messages` | Lire les messages LinkedIn Unipile bornés d'un profil explicitement sélectionné. |
236
+ | `leadify_read_activity_feed` | Lire l'audit borné d'un lead ; le feed ne constitue jamais une preuve de réponse. |
237
+ | `get_commercial_truth` | Réconcilier CRM, email, LinkedIn, Unipile et activité avec un statut, une fraîcheur et une action recommandée ; aucune écriture ni envoi. |
224
238
  | `compile_context_pack` | Compiler en lecture seule un Context Pack canonique depuis une cible explicite (`organization`, `lead_group`, `campaign`, `account`, `person`) ou un couple lead + campagne pour `WRITE_OUTREACH`. Retourne publications courantes, sources, freshness, contradictions et provenance. N’utilise jamais la Data Room legacy ni ne choisit de cible implicite. |
225
239
  | `get_context_workspace` | Lire le Context Workspace canonique versionné (`company_brain` ou `gtm_playbook`) d'une organisation explicitement sélectionnée. Retourne le brouillon et la dernière version publiée. |
226
240
  | `list_canonical_relationships` | Lire les liens canoniques typés d'une organisation, dans les deux directions. |
package/dist/server.js CHANGED
@@ -15,6 +15,7 @@ import { registerFineTuningTools } from "./tools/fine_tuning.js";
15
15
  import { registerContextWorkspaceTools } from "./tools/context_workspace.js";
16
16
  import { registerLeadViewTools } from "./tools/views.js";
17
17
  import { registerRelationshipTools } from "./tools/relationships.js";
18
+ import { registerCommercialTruthTools } from "./tools/commercial_truth.js";
18
19
  import { MCP_SERVER_NAME, MCP_VERSION } from "./version.js";
19
20
  export function createServer() {
20
21
  const server = new McpServer({
@@ -27,6 +28,7 @@ export function createServer() {
27
28
  registerLeadViewTools(server);
28
29
  registerLeadTools(server);
29
30
  registerRelationshipTools(server);
31
+ registerCommercialTruthTools(server);
30
32
  registerSchemaTools(server);
31
33
  registerLogTools(server);
32
34
  registerCampaignTools(server);
@@ -1,5 +1,6 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { type LeadifyClient } from "../client.js";
3
3
  type CampaignReadClient = Pick<LeadifyClient, "get">;
4
- export declare function registerCampaignTools(server: McpServer, readClient?: CampaignReadClient): void;
4
+ type CampaignWriteClient = Pick<LeadifyClient, "post">;
5
+ export declare function registerCampaignTools(server: McpServer, readClient?: CampaignReadClient, writeClient?: CampaignWriteClient): void;
5
6
  export {};
@@ -19,7 +19,7 @@ function assertCampaignBelongsToOrganization(data, organizationId) {
19
19
  }
20
20
  return campaign;
21
21
  }
22
- export function registerCampaignTools(server, readClient) {
22
+ export function registerCampaignTools(server, readClient, writeClient) {
23
23
  // ── update_campaign_stats ──────────────────────────────────────────────
24
24
  server.tool("update_campaign_stats", "Update email campaign statistics for a lead group. If the given campaignSlug " +
25
25
  "doesn't exist in the group's stats yet, it is created; otherwise provided fields " +
@@ -88,27 +88,31 @@ export function registerCampaignTools(server, readClient) {
88
88
  }
89
89
  });
90
90
  // ── create_campaign ────────────────────────────────────────────────────
91
- server.tool("create_campaign", "Create a new campaign linked to an existing lead group. The campaign is initialized " +
92
- "in DRAFT status and can be configured with a persona. Returns the new campaign ID. " +
93
- "Use update_campaign_status afterwards to move it to ACTIVE when ready.", {
91
+ server.tool("create_campaign", "Create a mono-channel DRAFT campaign linked to an existing lead group. channel is required " +
92
+ "so no campaign silently defaults to LinkedIn. The current Leadify API does not yet persist " +
93
+ "a campaign start/end window or closure contract: do not use this tool for a time-bounded " +
94
+ "campaign until that API support is released. Returns the new campaign ID; use " +
95
+ "update_campaign_status only after the campaign is fully configured and explicitly approved.", {
94
96
  name: z.string().describe("Campaign name."),
95
97
  lead_group_id: z.string().describe("ID of the lead group to associate."),
98
+ channel: z.enum(["LINKEDIN", "EMAIL"]).describe("Required effective delivery channel. Create separate campaigns for LinkedIn and email."),
96
99
  description: z.string().optional().describe("Optional description."),
97
100
  persona_id: z
98
101
  .string()
99
102
  .optional()
100
103
  .describe("ID of the persona to assign (optional)."),
101
- }, async ({ name, lead_group_id, description, persona_id }) => {
104
+ }, async ({ name, lead_group_id, channel, description, persona_id }) => {
102
105
  try {
103
106
  const body = {
104
107
  name,
105
108
  leadGroupId: lead_group_id,
109
+ channel,
106
110
  };
107
111
  if (description !== undefined)
108
112
  body.description = description;
109
113
  if (persona_id !== undefined)
110
114
  body.personaId = persona_id;
111
- const data = await getClient().post("/api/campaign", body);
115
+ const data = await (writeClient ?? getClient()).post("/api/campaign", body);
112
116
  return toolResult(data);
113
117
  }
114
118
  catch (error) {
@@ -0,0 +1,48 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { type LeadifyClient } from "../client.js";
3
+ type CommercialReadClient = Pick<LeadifyClient, "get">;
4
+ type RecordValue = Record<string, unknown>;
5
+ export type CommercialSource = "crm" | "email" | "linkedin" | "unipile" | "leadify_activity";
6
+ export type CommercialDirection = "inbound" | "outbound" | "system" | "mixed" | "unknown";
7
+ export type CommercialStatus = "client" | "prospect" | "in_crm" | "not_in_crm" | "replied" | "contacted" | "excluded" | "audit_only" | "no_observed_activity" | "no_action" | "review_required" | "handoff_required" | "unknown";
8
+ export interface CommercialFreshness {
9
+ observedAt: string | null;
10
+ state: "fresh" | "stale" | "unknown";
11
+ ageMs?: number;
12
+ }
13
+ export interface CommercialProjection {
14
+ source: CommercialSource | "reconciled";
15
+ date: string | null;
16
+ direction: CommercialDirection;
17
+ status: CommercialStatus;
18
+ optOut: boolean;
19
+ bounce: boolean;
20
+ suppression: boolean;
21
+ handoff: boolean;
22
+ freshness: CommercialFreshness;
23
+ ambiguities: string[];
24
+ }
25
+ export interface CommercialSourceSnapshot {
26
+ source: CommercialSource;
27
+ configured: boolean;
28
+ projection: CommercialProjection;
29
+ pagination: RecordValue;
30
+ evidence: RecordValue;
31
+ payload: RecordValue;
32
+ }
33
+ export interface CommercialReconciliation {
34
+ status: CommercialStatus;
35
+ recommendedAction: string;
36
+ responded: boolean;
37
+ inCrm: boolean;
38
+ crmStage: "client" | "prospect" | "none" | "unknown";
39
+ optOut: boolean;
40
+ bounce: boolean;
41
+ suppression: boolean;
42
+ handoff: boolean;
43
+ freshness: CommercialFreshness;
44
+ ambiguities: string[];
45
+ }
46
+ export declare function reconcileCommercialSources(sources: CommercialSourceSnapshot[]): CommercialReconciliation;
47
+ export declare function registerCommercialTruthTools(server: McpServer, client?: CommercialReadClient): void;
48
+ export {};
@@ -0,0 +1,1141 @@
1
+ import { z } from "zod";
2
+ import { getClient } from "../client.js";
3
+ import { handleToolError, toolError, toolResult } from "../types.js";
4
+ const DEFAULT_LIMIT = 25;
5
+ const MAX_LIMIT = 50;
6
+ const DEFAULT_CONTENT_LIMIT = 1_000;
7
+ const MAX_CONTENT_LIMIT = 5_000;
8
+ const DEFAULT_FRESHNESS_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1_000;
9
+ const MAX_FRESHNESS_MAX_AGE_MS = 90 * 24 * 60 * 60 * 1_000;
10
+ const MAX_TO_ADDRESSES = 10;
11
+ const MAX_CONTACTS = 10;
12
+ const MAX_AMBIGUITIES = 20;
13
+ class IntegrationNotConfiguredError extends Error {
14
+ source;
15
+ organizationId;
16
+ constructor(source, organizationId) {
17
+ super(`${source} integration is not configured for organization "${organizationId}"`);
18
+ this.name = "IntegrationNotConfiguredError";
19
+ this.source = source;
20
+ this.organizationId = organizationId;
21
+ }
22
+ }
23
+ function record(value) {
24
+ return value && typeof value === "object" && !Array.isArray(value)
25
+ ? value
26
+ : {};
27
+ }
28
+ function stringValue(value, max = 500) {
29
+ if (typeof value !== "string")
30
+ return null;
31
+ const trimmed = value.trim();
32
+ if (!trimmed)
33
+ return null;
34
+ const marker = "… [truncated]";
35
+ return trimmed.length > max
36
+ ? `${trimmed.slice(0, Math.max(0, max - marker.length))}${marker}`
37
+ : trimmed;
38
+ }
39
+ function firstString(source, keys, max = 500) {
40
+ return keys.map((key) => stringValue(source[key], max)).find((value) => value !== null) ?? null;
41
+ }
42
+ function booleanValue(value) {
43
+ if (typeof value === "boolean")
44
+ return value;
45
+ if (typeof value !== "string")
46
+ return undefined;
47
+ const normalized = value.trim().toLowerCase();
48
+ if (["true", "1", "yes", "y", "oui"].includes(normalized))
49
+ return true;
50
+ if (["false", "0", "no", "n", "non", "null", "none"].includes(normalized))
51
+ return false;
52
+ return undefined;
53
+ }
54
+ function safeUrl(value, max = 500) {
55
+ const raw = stringValue(value, max);
56
+ if (!raw)
57
+ return null;
58
+ try {
59
+ const url = new URL(raw);
60
+ url.search = "";
61
+ url.hash = "";
62
+ return url.toString().slice(0, max);
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ const SECRET_KEY = /(api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|authorization|cookie|password|secret|private[_-]?key)/i;
69
+ function safeValue(value, contentLimit, depth = 0) {
70
+ if (value === null || typeof value === "boolean" || typeof value === "number")
71
+ return value;
72
+ if (typeof value === "string")
73
+ return stringValue(value, contentLimit);
74
+ if (depth >= 2)
75
+ return "[omitted]";
76
+ if (Array.isArray(value))
77
+ return value.slice(0, 10).map((item) => safeValue(item, contentLimit, depth + 1));
78
+ if (typeof value !== "object")
79
+ return undefined;
80
+ return Object.entries(value)
81
+ .slice(0, 20)
82
+ .reduce((result, [key, item]) => {
83
+ if (!SECRET_KEY.test(key))
84
+ result[key] = safeValue(item, contentLimit, depth + 1);
85
+ return result;
86
+ }, {});
87
+ }
88
+ function boundedArray(value, limit) {
89
+ return Array.isArray(value) ? value.slice(0, limit) : [];
90
+ }
91
+ function arrayAt(source, keys) {
92
+ return keys.map((key) => source[key]).find((value) => Array.isArray(value)) ?? [];
93
+ }
94
+ function nestedArrayAt(source, parentKeys, keys) {
95
+ return parentKeys
96
+ .map((parentKey) => record(source[parentKey]))
97
+ .map((parent) => arrayAt(parent, keys))
98
+ .find((items) => items.length > 0) ?? [];
99
+ }
100
+ function normalizeDirection(value) {
101
+ const normalized = String(value ?? "").trim().toLowerCase();
102
+ if (["inbound", "incoming", "received", "replied"].includes(normalized))
103
+ return "inbound";
104
+ if (["outbound", "outgoing", "sent", "from_us"].includes(normalized))
105
+ return "outbound";
106
+ if (["system", "internal"].includes(normalized))
107
+ return "system";
108
+ return "unknown";
109
+ }
110
+ function normalizeStatus(value) {
111
+ return String(value ?? "").trim().toLowerCase().replace(/[\s-]+/g, "_");
112
+ }
113
+ function looksLikeDate(value) {
114
+ return typeof value === "string" && !Number.isNaN(Date.parse(value));
115
+ }
116
+ function observedDate(values) {
117
+ return values.filter(looksLikeDate).sort().at(-1) ?? null;
118
+ }
119
+ function dateFrom(source) {
120
+ return firstString(source, [
121
+ "date",
122
+ "occurredAt",
123
+ "createdAt",
124
+ "updatedAt",
125
+ "timestamp",
126
+ "sentAt",
127
+ "receivedAt",
128
+ "lastActivity",
129
+ ], 100);
130
+ }
131
+ function limitFor(target) {
132
+ return Math.min(Math.max(1, Math.floor(target.limit ?? DEFAULT_LIMIT)), MAX_LIMIT);
133
+ }
134
+ function contentLimitFor(target) {
135
+ return Math.min(Math.max(100, Math.floor(target.max_content_chars ?? DEFAULT_CONTENT_LIMIT)), MAX_CONTENT_LIMIT);
136
+ }
137
+ function freshnessMaxAgeFor(target) {
138
+ return Math.min(Math.max(1, Math.floor(target.freshness_max_age_ms ?? DEFAULT_FRESHNESS_MAX_AGE_MS)), MAX_FRESHNESS_MAX_AGE_MS);
139
+ }
140
+ function makeFreshness(date, target) {
141
+ if (!date || !looksLikeDate(date))
142
+ return { observedAt: date, state: "unknown" };
143
+ const ageMs = Math.max(0, Date.now() - Date.parse(date));
144
+ return {
145
+ observedAt: date,
146
+ ageMs,
147
+ state: ageMs <= freshnessMaxAgeFor(target) ? "fresh" : "stale",
148
+ };
149
+ }
150
+ function uniqueAmbiguities(values) {
151
+ return [...new Set(values.filter(Boolean))].slice(0, MAX_AMBIGUITIES);
152
+ }
153
+ function makeProjection(source, target, values) {
154
+ return {
155
+ source,
156
+ date: values.date ?? null,
157
+ direction: values.direction ?? "unknown",
158
+ status: values.status ?? "unknown",
159
+ optOut: values.optOut === true,
160
+ bounce: values.bounce === true,
161
+ suppression: values.suppression === true,
162
+ handoff: values.handoff === true,
163
+ freshness: makeFreshness(values.date ?? null, target),
164
+ ambiguities: uniqueAmbiguities(values.ambiguities ?? []),
165
+ };
166
+ }
167
+ function addPagination(query, target) {
168
+ query.set("limit", String(limitFor(target)));
169
+ if (target.cursor)
170
+ query.set("cursor", target.cursor);
171
+ }
172
+ function scopedQuery(target) {
173
+ const query = new URLSearchParams({
174
+ organizationId: target.organization_id,
175
+ leadGroupId: target.lead_group_id,
176
+ });
177
+ if (target.campaign_id)
178
+ query.set("campaignId", target.campaign_id);
179
+ if (target.page !== undefined)
180
+ query.set("page", String(Math.max(1, Math.floor(target.page))));
181
+ addPagination(query, target);
182
+ return query;
183
+ }
184
+ function pagination(data, target, returned, rawCount) {
185
+ const source = record(data.pagination ?? data.pageInfo ?? data.meta);
186
+ const keys = ["page", "limit", "total", "totalPages", "hasMore", "nextCursor", "next_cursor", "previousCursor", "previous_cursor"];
187
+ const result = keys.reduce((output, key) => {
188
+ if (source[key] !== undefined)
189
+ output[key] = safeValue(source[key], 200);
190
+ return output;
191
+ }, {});
192
+ const next = firstString(data, ["nextCursor", "next_cursor"], 500)
193
+ ?? firstString(source, ["nextCursor", "next_cursor"], 500);
194
+ if (next && result.nextCursor === undefined && result.next_cursor === undefined)
195
+ result.nextCursor = next;
196
+ if (result.hasMore === undefined && next)
197
+ result.hasMore = true;
198
+ result.returned = returned;
199
+ if ((rawCount ?? returned) > returned)
200
+ result.truncated = true;
201
+ return result;
202
+ }
203
+ function responseOrganizationId(data) {
204
+ const organization = record(data.organization);
205
+ const leadGroup = record(data.leadGroup);
206
+ const campaign = record(data.campaign);
207
+ const nestedData = record(data.data);
208
+ const response = record(data.response);
209
+ return firstString(data, ["organizationId", "organization_id"], 200)
210
+ ?? firstString(organization, ["id", "organizationId"], 200)
211
+ ?? firstString(leadGroup, ["organizationId", "organization_id"], 200)
212
+ ?? firstString(campaign, ["organizationId", "organization_id"], 200)
213
+ ?? firstString(nestedData, ["organizationId", "organization_id"], 200)
214
+ ?? firstString(response, ["organizationId", "organization_id"], 200);
215
+ }
216
+ function responseGroupId(data) {
217
+ const leadGroup = record(data.leadGroup);
218
+ const nestedData = record(data.data);
219
+ const response = record(data.response);
220
+ return firstString(data, ["leadGroupId", "lead_group_id", "groupId", "group_id"], 200)
221
+ ?? firstString(leadGroup, ["id", "leadGroupId", "groupId"], 200)
222
+ ?? firstString(nestedData, ["leadGroupId", "lead_group_id", "groupId", "group_id"], 200)
223
+ ?? firstString(response, ["leadGroupId", "lead_group_id", "groupId", "group_id"], 200);
224
+ }
225
+ function responseLeadId(data) {
226
+ const nestedData = record(data.data);
227
+ return firstString(data, ["leadId", "lead_id"], 200)
228
+ ?? firstString(nestedData, ["leadId", "lead_id"], 200);
229
+ }
230
+ function responseCampaignId(data) {
231
+ const campaign = record(data.campaign);
232
+ const nestedData = record(data.data);
233
+ return firstString(data, ["campaignId", "campaign_id"], 200)
234
+ ?? firstString(campaign, ["id", "campaignId"], 200)
235
+ ?? firstString(nestedData, ["campaignId", "campaign_id"], 200);
236
+ }
237
+ function assertScopedResponse(data, target, source) {
238
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
239
+ throw new Error(`Leadify returned an invalid ${source} read response`);
240
+ }
241
+ const value = data;
242
+ const actualOrganizationId = responseOrganizationId(value);
243
+ if (actualOrganizationId !== null && actualOrganizationId !== target.organization_id) {
244
+ throw new Error(`${source} response belongs to organization "${actualOrganizationId}", not the explicitly selected organization "${target.organization_id}"`);
245
+ }
246
+ const actualGroupId = responseGroupId(value);
247
+ if (actualGroupId !== null && actualGroupId !== target.lead_group_id) {
248
+ throw new Error(`${source} response belongs to lead group "${actualGroupId}", not the explicitly selected group "${target.lead_group_id}"`);
249
+ }
250
+ const actualLeadId = responseLeadId(value);
251
+ if (target.lead_id && actualLeadId !== null && actualLeadId !== target.lead_id) {
252
+ throw new Error(`${source} response belongs to lead "${actualLeadId}", not the explicitly selected lead "${target.lead_id}"`);
253
+ }
254
+ const actualCampaignId = responseCampaignId(value);
255
+ if (target.campaign_id && actualCampaignId !== null && actualCampaignId !== target.campaign_id) {
256
+ throw new Error(`${source} response belongs to campaign "${actualCampaignId}", not the explicitly selected campaign "${target.campaign_id}"`);
257
+ }
258
+ const nestedItems = ["messages", "entries", "activities", "items", "data", "deals", "domainContacts", "conversations"]
259
+ .flatMap((key) => Array.isArray(value[key]) ? value[key].slice(0, MAX_LIMIT) : []);
260
+ nestedItems.forEach((itemValue) => {
261
+ const item = record(itemValue);
262
+ const itemOrganizationId = responseOrganizationId(item);
263
+ if (itemOrganizationId !== null && itemOrganizationId !== target.organization_id) {
264
+ throw new Error(`${source} response contains a record from organization "${itemOrganizationId}" outside the explicitly selected organization`);
265
+ }
266
+ const itemGroupId = responseGroupId(item);
267
+ if (itemGroupId !== null && itemGroupId !== target.lead_group_id) {
268
+ throw new Error(`${source} response contains a record from lead group "${itemGroupId}" outside the explicitly selected group`);
269
+ }
270
+ const itemLeadId = responseLeadId(item);
271
+ if (target.lead_id && itemLeadId !== null && itemLeadId !== target.lead_id) {
272
+ throw new Error(`${source} response contains a record from lead "${itemLeadId}" outside the explicitly selected lead`);
273
+ }
274
+ });
275
+ return value;
276
+ }
277
+ function configuredFlag(data, source) {
278
+ const key = {
279
+ crm: "crmConfigured",
280
+ email: "emailConfigured",
281
+ linkedin: "linkedinConfigured",
282
+ unipile: "unipileConfigured",
283
+ leadify_activity: "activityConfigured",
284
+ }[source];
285
+ if (data[key] === false || booleanValue(data[key]) === false)
286
+ return false;
287
+ if (data.configured === false || booleanValue(data.configured) === false || data.integrationConfigured === false || booleanValue(data.integrationConfigured) === false)
288
+ return false;
289
+ return true;
290
+ }
291
+ function requireConfigured(data, target, source) {
292
+ if (!configuredFlag(data, source))
293
+ throw new IntegrationNotConfiguredError(source, target.organization_id);
294
+ }
295
+ function explicitFlag(value, keys) {
296
+ const wanted = new Set(keys.map((key) => key.toLowerCase().replace(/[^a-z]/g, "")));
297
+ const visit = (candidate, depth) => {
298
+ if (depth > 4 || !candidate || typeof candidate !== "object")
299
+ return false;
300
+ if (Array.isArray(candidate))
301
+ return candidate.some((item) => visit(item, depth + 1));
302
+ return Object.entries(candidate).some(([key, item]) => {
303
+ const normalizedKey = key.toLowerCase().replace(/[^a-z]/g, "");
304
+ if (wanted.has(normalizedKey)) {
305
+ if (booleanValue(item) === true)
306
+ return true;
307
+ if (normalizedKey.includes("handoff") && item && typeof item === "object")
308
+ return true;
309
+ if (typeof item === "string" && item.trim() && (normalizedKey.endsWith("at") || normalizedKey.includes("handoff")))
310
+ return true;
311
+ }
312
+ if (["status", "deliverystatus", "event"].includes(normalizedKey)) {
313
+ const normalizedValue = normalizeStatus(item);
314
+ if (keys.includes("bounce") && ["bounce", "bounced", "hard_bounce", "soft_bounce", "undeliverable"].includes(normalizedValue))
315
+ return true;
316
+ if (keys.includes("optOut") && ["opted_out", "unsubscribed", "do_not_contact"].includes(normalizedValue))
317
+ return true;
318
+ if (keys.includes("suppression") && ["suppressed", "suppression", "do_not_contact"].includes(normalizedValue))
319
+ return true;
320
+ if (keys.includes("handoff") && ["handoff", "handed_off", "crm_handoff"].includes(normalizedValue))
321
+ return true;
322
+ }
323
+ return visit(item, depth + 1);
324
+ });
325
+ };
326
+ return visit(value, 0);
327
+ }
328
+ function commercialFlags(value) {
329
+ return {
330
+ optOut: explicitFlag(value, ["optOut", "optedOut", "unsubscribed", "unsubscribe", "unsubscribedAt", "optOutAt", "doNotContact", "excluded"]),
331
+ bounce: explicitFlag(value, ["bounce", "bounced", "hardBounce", "softBounce", "bouncedAt", "bounceAt", "undeliverable"]),
332
+ suppression: explicitFlag(value, ["suppression", "suppressed", "suppressionStatus", "suppressedAt"]),
333
+ handoff: explicitFlag(value, ["handoff", "crmHandoff", "handedOff", "handoffAt"]),
334
+ };
335
+ }
336
+ function safePerson(value) {
337
+ const source = record(value);
338
+ if (Object.keys(source).length === 0)
339
+ return null;
340
+ return {
341
+ id: firstString(source, ["id", "personId"], 200),
342
+ name: firstString(source, ["name", "fullName"], 300),
343
+ email: firstString(source, ["email"], 320),
344
+ jobTitle: firstString(source, ["jobTitle", "title"], 300),
345
+ companyId: firstString(source, ["companyId"], 200),
346
+ companyName: firstString(source, ["companyName", "company"], 300),
347
+ };
348
+ }
349
+ function safeDeal(value, contentLimit) {
350
+ const source = record(value);
351
+ const contacts = boundedArray(source.contacts, MAX_CONTACTS).map((contact) => {
352
+ const item = record(contact);
353
+ return {
354
+ id: firstString(item, ["id"], 200),
355
+ name: firstString(item, ["name", "fullName"], 300),
356
+ email: firstString(item, ["email"], 320),
357
+ };
358
+ });
359
+ const result = {
360
+ id: firstString(source, ["id", "dealId"], 200),
361
+ name: firstString(source, ["name", "title"], 300),
362
+ status: normalizeStatus(source.status),
363
+ nativeStage: firstString(source, ["nativeStage", "stage"], 300),
364
+ amount: typeof source.amount === "number" ? source.amount : null,
365
+ currency: firstString(source, ["currency"], 50),
366
+ contacts,
367
+ };
368
+ const date = dateFrom(source);
369
+ if (date)
370
+ result.date = date;
371
+ if (source.description !== undefined)
372
+ result.description = stringValue(source.description, contentLimit);
373
+ return result;
374
+ }
375
+ function safeDomainContact(value) {
376
+ return safePerson(value) ?? { id: null, name: null, email: null };
377
+ }
378
+ function safeMailboxes(value) {
379
+ return boundedArray(value, MAX_LIMIT).map((mailbox) => {
380
+ const source = record(mailbox);
381
+ return {
382
+ provider: firstString(source, ["provider"], 100),
383
+ email: firstString(source, ["email", "mailboxEmail"], 320),
384
+ readable: booleanValue(source.readable) ?? false,
385
+ status: firstString(source, ["status"], 200),
386
+ reason: firstString(source, ["reason", "error"], 500),
387
+ };
388
+ });
389
+ }
390
+ function safeAddresses(value) {
391
+ return boundedArray(value, MAX_TO_ADDRESSES)
392
+ .map((item) => stringValue(item, 320))
393
+ .filter((item) => item !== null);
394
+ }
395
+ function safeEmailMessage(value, contentLimit) {
396
+ const source = record(value);
397
+ const result = {
398
+ id: firstString(source, ["id", "messageId"], 200),
399
+ threadId: firstString(source, ["threadId", "thread_id"], 200),
400
+ conversationId: firstString(source, ["conversationId", "conversation_id"], 200),
401
+ direction: normalizeDirection(source.direction),
402
+ counterpartyEmail: firstString(source, ["counterpartyEmail", "counterparty_email"], 320),
403
+ from: firstString(source, ["from", "sender", "senderEmail"], 320),
404
+ to: safeAddresses(source.to),
405
+ date: dateFrom(source),
406
+ subject: stringValue(source.subject, contentLimit),
407
+ snippet: stringValue(source.snippet, contentLimit),
408
+ bodyText: stringValue(source.bodyText ?? source.body ?? source.text, contentLimit),
409
+ };
410
+ const mailboxProvider = firstString(source, ["mailboxProvider", "provider"], 100);
411
+ const mailboxEmail = firstString(source, ["mailboxEmail"], 320);
412
+ if (mailboxProvider)
413
+ result.mailboxProvider = mailboxProvider;
414
+ if (mailboxEmail)
415
+ result.mailboxEmail = mailboxEmail;
416
+ const status = firstString(source, ["status", "deliveryStatus"], 100);
417
+ if (status)
418
+ result.status = status;
419
+ const flags = commercialFlags(source);
420
+ if (flags.optOut)
421
+ result.optOut = true;
422
+ if (flags.bounce)
423
+ result.bounced = true;
424
+ if (flags.suppression)
425
+ result.suppressed = true;
426
+ return result;
427
+ }
428
+ function safeSocialMessage(value, contentLimit) {
429
+ const source = record(value);
430
+ const result = {
431
+ id: firstString(source, ["id", "messageId"], 200),
432
+ conversationId: firstString(source, ["conversationId", "conversation_id", "threadId"], 200),
433
+ direction: normalizeDirection(source.direction),
434
+ from: firstString(source, ["from", "sender", "senderName"], 320),
435
+ date: dateFrom(source),
436
+ text: stringValue(source.text ?? source.bodyText ?? source.body ?? source.content, contentLimit),
437
+ };
438
+ const profileUrl = safeUrl(source.profileUrl ?? source.linkedinUrl ?? source.participantLinkedinUrl);
439
+ if (profileUrl)
440
+ result.profileUrl = profileUrl;
441
+ const attachments = boundedArray(source.attachments, MAX_TO_ADDRESSES).map((attachment) => {
442
+ const item = record(attachment);
443
+ return { name: stringValue(item.name, 300), url: safeUrl(item.url) };
444
+ });
445
+ if (attachments.length > 0)
446
+ result.attachments = attachments;
447
+ const flags = commercialFlags(source);
448
+ if (flags.optOut)
449
+ result.optOut = true;
450
+ if (flags.bounce)
451
+ result.bounced = true;
452
+ if (flags.suppression)
453
+ result.suppressed = true;
454
+ return result;
455
+ }
456
+ function dedupeEmailMessages(messages) {
457
+ const seen = new Set();
458
+ return messages.filter((message) => {
459
+ const source = record(message);
460
+ const key = `${dateFrom(source) ?? ""}|${(firstString(source, ["from", "sender", "senderEmail"], 320) ?? "").toLowerCase()}|${(firstString(source, ["subject"], 500) ?? "").toLowerCase()}`;
461
+ if (seen.has(key) && key !== "||")
462
+ return false;
463
+ seen.add(key);
464
+ return true;
465
+ });
466
+ }
467
+ function latestDirection(messages) {
468
+ const directions = messages
469
+ .map((message) => normalizeDirection(message.direction))
470
+ .filter((direction) => direction !== "unknown");
471
+ if (directions.length === 0)
472
+ return "unknown";
473
+ if (new Set(directions).size > 1)
474
+ return "mixed";
475
+ return directions[0];
476
+ }
477
+ function statusForMessages(messages, flags) {
478
+ if (flags.optOut || flags.bounce || flags.suppression)
479
+ return "excluded";
480
+ if (messages.some((message) => normalizeDirection(message.direction) === "inbound"))
481
+ return "replied";
482
+ if (messages.some((message) => normalizeDirection(message.direction) === "outbound"))
483
+ return "contacted";
484
+ return "no_observed_activity";
485
+ }
486
+ function crmProjection(data, target) {
487
+ requireConfigured(data, target, "crm");
488
+ const contentLimit = contentLimitFor(target);
489
+ const rawDeals = arrayAt(data, ["deals"]);
490
+ const deals = boundedArray(rawDeals, limitFor(target)).map((deal) => safeDeal(deal, contentLimit));
491
+ const domainContacts = boundedArray(data.domainContacts, limitFor(target)).map(safeDomainContact);
492
+ const person = safePerson(data.person);
493
+ const statuses = rawDeals.map((deal) => normalizeStatus(record(deal).status));
494
+ const found = booleanValue(data.found) ?? person !== null;
495
+ const stage = statuses.some((status) => ["won", "closed_won"].includes(status))
496
+ ? "client"
497
+ : statuses.some((status) => ["open", "in_progress", "qualified"].includes(status))
498
+ ? "prospect"
499
+ : found
500
+ ? "in_crm"
501
+ : "not_in_crm";
502
+ const flags = commercialFlags(data);
503
+ const dates = [dateFrom(data), ...rawDeals.map((deal) => dateFrom(record(deal))), dateFrom(record(data.person))];
504
+ const ambiguities = [];
505
+ if (rawDeals.length > deals.length)
506
+ ambiguities.push(`CRM deals truncated to ${limitFor(target)} records`);
507
+ if (booleanValue(data.domainSearched) === true && domainContacts.length >= limitFor(target))
508
+ ambiguities.push("CRM domain contacts may be truncated");
509
+ const projectionValue = makeProjection("crm", target, {
510
+ date: observedDate(dates),
511
+ direction: "system",
512
+ status: flags.optOut || flags.bounce || flags.suppression ? "excluded" : stage,
513
+ ...flags,
514
+ ambiguities,
515
+ });
516
+ const payload = {
517
+ source: "crm",
518
+ target: targetDescription(target),
519
+ organizationId: target.organization_id,
520
+ leadGroupId: target.lead_group_id,
521
+ ...(target.campaign_id ? { campaignId: target.campaign_id } : {}),
522
+ crmConfigured: true,
523
+ provider: firstString(data, ["provider"], 100),
524
+ found,
525
+ person,
526
+ deals,
527
+ companyDomain: firstString(data, ["domain", "companyDomain"], 300),
528
+ domainSearched: booleanValue(data.domainSearched) ?? false,
529
+ domainContacts,
530
+ pagination: pagination(data, target, deals.length, rawDeals.length),
531
+ stage,
532
+ optOut: projectionValue.optOut,
533
+ bounce: projectionValue.bounce,
534
+ suppression: projectionValue.suppression,
535
+ handoff: projectionValue.handoff,
536
+ freshness: projectionValue.freshness,
537
+ ambiguities: projectionValue.ambiguities,
538
+ projection: projectionValue,
539
+ commercialTruth: projectionValue,
540
+ redactions: { secretsOmitted: true, maxContentChars: contentLimit },
541
+ };
542
+ return { source: "crm", configured: true, projection: projectionValue, pagination: payload.pagination, evidence: { found, dealCount: deals.length, stage }, payload };
543
+ }
544
+ function emailProjection(data, target) {
545
+ requireConfigured(data, target, "email");
546
+ const contentLimit = contentLimitFor(target);
547
+ const rawMessages = arrayAt(data, ["messages"]);
548
+ const uniqueMessages = dedupeEmailMessages(rawMessages);
549
+ const selectedMessages = boundedArray(uniqueMessages, limitFor(target));
550
+ const messages = selectedMessages.map((message) => safeEmailMessage(message, contentLimit));
551
+ const messageRecords = messages.map(record);
552
+ const flags = commercialFlags({ ...data, messages: rawMessages });
553
+ const reportedMessageCount = typeof data.messageCount === "number" ? data.messageCount : rawMessages.length;
554
+ const mailboxes = safeMailboxes(data.mailboxesSearched);
555
+ const problematicMailboxes = mailboxes.filter((mailbox) => mailbox.readable !== true || mailbox.reason !== null);
556
+ const ambiguities = [];
557
+ if (rawMessages.length !== uniqueMessages.length)
558
+ ambiguities.push("Duplicate mailbox copies were collapsed before reconciliation");
559
+ if (Math.max(rawMessages.length, reportedMessageCount) > selectedMessages.length)
560
+ ambiguities.push(`Email messages truncated to ${limitFor(target)} records`);
561
+ if (problematicMailboxes.length > 0)
562
+ ambiguities.push(`${problematicMailboxes.length} mailbox(es) could not be fully read`);
563
+ const status = statusForMessages(messageRecords, flags);
564
+ const projectionValue = makeProjection("email", target, {
565
+ date: observedDate([dateFrom(data), ...messageRecords.map(dateFrom)]),
566
+ direction: latestDirection(messageRecords),
567
+ status,
568
+ ...flags,
569
+ ambiguities,
570
+ });
571
+ const payload = {
572
+ source: "email",
573
+ target: targetDescription(target),
574
+ organizationId: target.organization_id,
575
+ leadGroupId: target.lead_group_id,
576
+ ...(target.campaign_id ? { campaignId: target.campaign_id } : {}),
577
+ emailConfigured: true,
578
+ counterparty: firstString(data, ["counterparty"], 320),
579
+ companyDomain: firstString(data, ["domain", "companyDomain"], 300),
580
+ domainSearched: booleanValue(data.domainSearched) ?? false,
581
+ mailboxesSearched: mailboxes,
582
+ messageCount: messages.length,
583
+ rawMessageCount: reportedMessageCount,
584
+ messages,
585
+ pagination: pagination(data, target, messages.length, Math.max(rawMessages.length, reportedMessageCount)),
586
+ responded: status === "replied",
587
+ optOut: projectionValue.optOut,
588
+ bounce: projectionValue.bounce,
589
+ suppression: projectionValue.suppression,
590
+ handoff: projectionValue.handoff,
591
+ freshness: projectionValue.freshness,
592
+ ambiguities: projectionValue.ambiguities,
593
+ projection: projectionValue,
594
+ commercialTruth: projectionValue,
595
+ redactions: { secretsOmitted: true, maxContentChars: contentLimit },
596
+ };
597
+ return {
598
+ source: "email",
599
+ configured: true,
600
+ projection: projectionValue,
601
+ pagination: payload.pagination,
602
+ evidence: {
603
+ messageCount: messages.length,
604
+ inboundCount: messageRecords.filter((message) => normalizeDirection(message.direction) === "inbound").length,
605
+ outboundCount: messageRecords.filter((message) => normalizeDirection(message.direction) === "outbound").length,
606
+ responseObserved: status === "replied",
607
+ },
608
+ payload,
609
+ };
610
+ }
611
+ function socialProjection(data, target, source) {
612
+ requireConfigured(data, target, source);
613
+ const contentLimit = contentLimitFor(target);
614
+ const nestedMessages = nestedArrayAt(data, ["conversation", "thread", "data"], ["messages"]);
615
+ const rawMessages = arrayAt(data, ["messages"]);
616
+ const allMessages = rawMessages.length > 0 ? rawMessages : nestedMessages;
617
+ const selectedMessages = boundedArray(allMessages, limitFor(target));
618
+ const messages = selectedMessages.map((message) => safeSocialMessage(message, contentLimit));
619
+ const messageRecords = messages.map(record);
620
+ const flags = commercialFlags({ ...data, messages: allMessages });
621
+ const reportedMessageCount = typeof data.totalMessageCount === "number" ? data.totalMessageCount : allMessages.length;
622
+ const ambiguities = [];
623
+ if (Math.max(allMessages.length, reportedMessageCount) > selectedMessages.length)
624
+ ambiguities.push(`${source} messages truncated to ${limitFor(target)} records`);
625
+ const status = statusForMessages(messageRecords, flags);
626
+ const configuredKey = source === "linkedin" ? "linkedinConfigured" : "unipileConfigured";
627
+ const conversations = arrayAt(data, ["conversations"]);
628
+ const conversation = record(data.conversation);
629
+ const safeConversations = boundedArray(conversations, limitFor(target)).map((value) => {
630
+ const item = record(value);
631
+ return {
632
+ id: firstString(item, ["id", "conversationId"], 200),
633
+ participantName: firstString(item, ["participantName", "name"], 300),
634
+ participantLinkedinUrl: safeUrl(item.participantLinkedinUrl ?? item.linkedinUrl),
635
+ lastActivity: dateFrom(item),
636
+ messageCount: typeof item.messageCount === "number" ? item.messageCount : null,
637
+ };
638
+ });
639
+ if (Object.keys(conversation).length > 0 && safeConversations.length === 0) {
640
+ safeConversations.push({
641
+ id: firstString(conversation, ["id", "conversationId"], 200),
642
+ participantName: firstString(conversation, ["participantName", "name"], 300),
643
+ participantLinkedinUrl: safeUrl(conversation.participantLinkedinUrl ?? conversation.linkedinUrl),
644
+ lastActivity: dateFrom(conversation),
645
+ messageCount: typeof conversation.messageCount === "number" ? conversation.messageCount : messages.length,
646
+ });
647
+ }
648
+ const projectionValue = makeProjection(source, target, {
649
+ date: observedDate([dateFrom(data), ...messageRecords.map(dateFrom), ...safeConversations.map(dateFrom)]),
650
+ direction: latestDirection(messageRecords),
651
+ status,
652
+ ...flags,
653
+ ambiguities,
654
+ });
655
+ const payload = {
656
+ source,
657
+ target: targetDescription(target),
658
+ organizationId: target.organization_id,
659
+ leadGroupId: target.lead_group_id,
660
+ ...(target.campaign_id ? { campaignId: target.campaign_id } : {}),
661
+ [configuredKey]: true,
662
+ counterparty: firstString(data, ["counterparty"], 320),
663
+ conversations: safeConversations,
664
+ messages,
665
+ totalMessageCount: reportedMessageCount,
666
+ pagination: pagination(data, target, messages.length, Math.max(allMessages.length, reportedMessageCount)),
667
+ responded: status === "replied",
668
+ optOut: projectionValue.optOut,
669
+ bounce: projectionValue.bounce,
670
+ suppression: projectionValue.suppression,
671
+ handoff: projectionValue.handoff,
672
+ freshness: projectionValue.freshness,
673
+ ambiguities: projectionValue.ambiguities,
674
+ projection: projectionValue,
675
+ commercialTruth: projectionValue,
676
+ redactions: { secretsOmitted: true, maxContentChars: contentLimit },
677
+ };
678
+ return {
679
+ source,
680
+ configured: true,
681
+ projection: projectionValue,
682
+ pagination: payload.pagination,
683
+ evidence: {
684
+ messageCount: messages.length,
685
+ inboundCount: messageRecords.filter((message) => normalizeDirection(message.direction) === "inbound").length,
686
+ outboundCount: messageRecords.filter((message) => normalizeDirection(message.direction) === "outbound").length,
687
+ responseObserved: status === "replied",
688
+ },
689
+ payload,
690
+ };
691
+ }
692
+ function activityProjection(data, target) {
693
+ requireConfigured(data, target, "leadify_activity");
694
+ const contentLimit = contentLimitFor(target);
695
+ const rawEntries = arrayAt(data, ["entries", "activities", "items", "data"]);
696
+ const selectedEntries = boundedArray(rawEntries, limitFor(target));
697
+ const entries = selectedEntries.map((entry) => {
698
+ const item = record(entry);
699
+ return {
700
+ id: firstString(item, ["id"], 200),
701
+ leadId: firstString(item, ["leadId", "lead_id"], 200),
702
+ action: firstString(item, ["action"], 200),
703
+ target: firstString(item, ["target"], 200),
704
+ before: safeValue(item.before, contentLimit),
705
+ after: safeValue(item.after, contentLimit),
706
+ reason: stringValue(item.reason, contentLimit),
707
+ date: dateFrom(item),
708
+ createdAt: firstString(item, ["createdAt"], 100),
709
+ };
710
+ });
711
+ const ambiguities = ["Activity feed is an audit trail; it never proves that a person replied."];
712
+ if (rawEntries.length > selectedEntries.length)
713
+ ambiguities.push(`Activity entries truncated to ${limitFor(target)} records`);
714
+ const projectionValue = makeProjection("leadify_activity", target, {
715
+ date: observedDate([dateFrom(data), ...entries.map((entry) => entry.date)]),
716
+ direction: "system",
717
+ status: "audit_only",
718
+ // Activity rows are a mutation audit trail and cannot become commercial truth.
719
+ optOut: false,
720
+ bounce: false,
721
+ suppression: false,
722
+ handoff: false,
723
+ ambiguities,
724
+ });
725
+ const payload = {
726
+ source: "leadify_activity",
727
+ target: targetDescription(target),
728
+ organizationId: target.organization_id,
729
+ leadGroupId: target.lead_group_id,
730
+ ...(target.campaign_id ? { campaignId: target.campaign_id } : {}),
731
+ entries,
732
+ pagination: pagination(data, target, entries.length, rawEntries.length),
733
+ optOut: false,
734
+ bounce: false,
735
+ suppression: false,
736
+ handoff: false,
737
+ freshness: projectionValue.freshness,
738
+ ambiguities: projectionValue.ambiguities,
739
+ projection: projectionValue,
740
+ commercialTruth: projectionValue,
741
+ redactions: { secretsOmitted: true, maxContentChars: contentLimit, runIdentityOmitted: true },
742
+ };
743
+ return {
744
+ source: "leadify_activity",
745
+ configured: true,
746
+ projection: projectionValue,
747
+ pagination: payload.pagination,
748
+ evidence: { entryCount: entries.length, responseObserved: false, auditOnly: true },
749
+ payload,
750
+ };
751
+ }
752
+ function emailQuery(target) {
753
+ const query = scopedQuery(target);
754
+ query.set("email", target.email);
755
+ return query;
756
+ }
757
+ function linkedinQuery(target) {
758
+ const query = scopedQuery(target);
759
+ query.set("linkedinUrl", target.linkedin_url);
760
+ return query;
761
+ }
762
+ function activityQuery(target) {
763
+ const query = scopedQuery(target);
764
+ query.set("leadId", target.lead_id);
765
+ if (target.target)
766
+ query.set("target", target.target);
767
+ return query;
768
+ }
769
+ async function readCrm(client, target) {
770
+ const data = await client.get("/api/crm/lookup-person", emailQuery(target));
771
+ return crmProjection(assertScopedResponse(data, target, "crm"), target);
772
+ }
773
+ async function readEmail(client, target) {
774
+ const data = await client.get("/api/email/read-thread", emailQuery(target));
775
+ return emailProjection(assertScopedResponse(data, target, "email"), target);
776
+ }
777
+ async function readLinkedin(client, target) {
778
+ const data = await client.get("/api/linkedin/conversation", linkedinQuery(target));
779
+ return socialProjection(assertScopedResponse(data, target, "linkedin"), target, "linkedin");
780
+ }
781
+ async function readUnipile(client, target) {
782
+ const data = await client.get("/api/unipile/read-messages", linkedinQuery(target));
783
+ return socialProjection(assertScopedResponse(data, target, "unipile"), target, "unipile");
784
+ }
785
+ async function readActivity(client, target) {
786
+ const data = await client.get("/activity-feed", activityQuery(target));
787
+ return activityProjection(assertScopedResponse(data, target, "leadify_activity"), target);
788
+ }
789
+ function readTargetFromInput(input) {
790
+ return {
791
+ ...input,
792
+ organization_id: input.organization_id.trim(),
793
+ lead_group_id: input.lead_group_id.trim(),
794
+ email: input.email?.trim().toLowerCase(),
795
+ linkedin_url: input.linkedin_url?.trim(),
796
+ lead_id: input.lead_id?.trim(),
797
+ page: input.page,
798
+ campaign_id: input.campaign_id?.trim(),
799
+ cursor: input.cursor?.trim(),
800
+ target: input.target?.trim(),
801
+ };
802
+ }
803
+ function targetDescription(target) {
804
+ return {
805
+ organizationId: target.organization_id,
806
+ leadGroupId: target.lead_group_id,
807
+ ...(target.campaign_id ? { campaignId: target.campaign_id } : {}),
808
+ ...(target.email ? { email: target.email } : {}),
809
+ ...(target.linkedin_url ? { linkedinUrl: safeUrl(target.linkedin_url) ?? "[invalid-url]" } : {}),
810
+ ...(target.lead_id ? { leadId: target.lead_id } : {}),
811
+ };
812
+ }
813
+ export function reconcileCommercialSources(sources) {
814
+ const crm = sources.find((source) => source.source === "crm");
815
+ const conversationSources = sources.filter((source) => ["email", "linkedin", "unipile"].includes(source.source));
816
+ const responded = conversationSources.some((source) => source.projection.status === "replied" || source.evidence.responseObserved === true);
817
+ const optOut = sources.some((source) => source.projection.optOut);
818
+ const bounce = sources.some((source) => source.projection.bounce);
819
+ const suppression = sources.some((source) => source.projection.suppression);
820
+ const handoff = sources.some((source) => source.projection.handoff);
821
+ const crmStage = crm?.projection.status === "client"
822
+ ? "client"
823
+ : crm?.projection.status === "prospect"
824
+ ? "prospect"
825
+ : crm?.projection.status === "in_crm"
826
+ ? "none"
827
+ : "unknown";
828
+ const inCrm = crm?.projection.status === "client" || crm?.projection.status === "prospect" || crm?.projection.status === "in_crm";
829
+ const ambiguities = sources.flatMap((source) => source.projection.ambiguities);
830
+ if (crm?.projection.status === "not_in_crm" && conversationSources.some((source) => source.evidence.responseObserved === true || Number(source.evidence.messageCount ?? 0) > 0)) {
831
+ ambiguities.push("CRM found no matching person and a conversation source has observed messages");
832
+ }
833
+ const email = sources.find((source) => source.source === "email");
834
+ const unipile = sources.find((source) => source.source === "unipile");
835
+ if (email && unipile && Number(email.evidence.messageCount ?? 0) === 0 && Number(unipile.evidence.inboundCount ?? 0) > 0) {
836
+ ambiguities.push("Email has no observed messages and Unipile has an inbound message");
837
+ }
838
+ if (sources.some((source) => source.projection.freshness.state === "stale")) {
839
+ ambiguities.push("At least one commercial source is older than the requested freshness window");
840
+ }
841
+ const unique = uniqueAmbiguities(ambiguities);
842
+ let status;
843
+ let recommendedAction;
844
+ if (optOut || bounce || suppression) {
845
+ status = "excluded";
846
+ recommendedAction = "Do not contact, send, or create a follow-up activity; preserve the observed exclusion evidence.";
847
+ }
848
+ else if (unique.some((ambiguity) => /CRM found no|Email has no observed/i.test(ambiguity))) {
849
+ status = "review_required";
850
+ recommendedAction = "Pause outreach and review the divergent source evidence ahead of any qualification or CRM handoff.";
851
+ }
852
+ else if (crmStage === "client") {
853
+ status = "client";
854
+ recommendedAction = "Do not cold-prospect this company; review the existing client relationship instead.";
855
+ }
856
+ else if (handoff) {
857
+ status = "handoff_required";
858
+ recommendedAction = "Review the existing CRM handoff evidence; do not create a duplicate handoff or activity.";
859
+ }
860
+ else if (responded) {
861
+ status = "replied";
862
+ recommendedAction = "Stop cold outreach and review the observed reply before deciding on a human follow-up.";
863
+ }
864
+ else if (crmStage === "prospect") {
865
+ status = "prospect";
866
+ recommendedAction = "Treat as warm only; the CRM has an open sales process.";
867
+ }
868
+ else if (inCrm) {
869
+ status = "in_crm";
870
+ recommendedAction = "Use the existing CRM record and avoid creating a duplicate person or handoff.";
871
+ }
872
+ else if (conversationSources.some((source) => source.projection.status === "contacted")) {
873
+ status = "contacted";
874
+ recommendedAction = "Do not duplicate the observed outbound contact; inspect the source ahead of another touch.";
875
+ }
876
+ else if (sources.length === 0 || !sources.some((source) => !["no_observed_activity", "not_in_crm", "audit_only"].includes(source.projection.status))) {
877
+ status = "no_observed_activity";
878
+ recommendedAction = "Leave without action when no configured source provides new evidence.";
879
+ }
880
+ else {
881
+ status = "no_action";
882
+ recommendedAction = "No action is justified by the observed commercial sources.";
883
+ }
884
+ const dates = sources.map((source) => source.projection.date).filter((date) => date !== null);
885
+ const freshnessDate = observedDate(dates);
886
+ const freshness = freshnessDate
887
+ ? {
888
+ observedAt: freshnessDate,
889
+ state: sources.some((source) => source.projection.freshness.state === "stale") ? "stale" : "fresh",
890
+ ageMs: Math.max(...sources.map((source) => source.projection.freshness.ageMs ?? 0)),
891
+ }
892
+ : { observedAt: null, state: "unknown" };
893
+ return {
894
+ status,
895
+ recommendedAction,
896
+ responded,
897
+ inCrm,
898
+ crmStage,
899
+ optOut,
900
+ bounce,
901
+ suppression,
902
+ handoff,
903
+ freshness,
904
+ ambiguities: unique,
905
+ };
906
+ }
907
+ function integrationAction(source) {
908
+ if (source === "crm")
909
+ return "Connect a CRM integration in this organization, then retry the read.";
910
+ if (source === "email")
911
+ return "Connect a readable Gmail or Outlook mailbox in this organization, then retry the read.";
912
+ if (source === "unipile")
913
+ return "Connect an Unipile account in this organization, then retry the read.";
914
+ if (source === "linkedin")
915
+ return "Configure the LinkedIn conversation integration in this organization, then retry the read.";
916
+ return "Enable the Leadify activity feed in this organization, then retry the read.";
917
+ }
918
+ function missingIntegrationResult(error) {
919
+ const action = integrationAction(error.source);
920
+ return toolError(`${error.message}. ${action}`, {
921
+ code: "INTEGRATION_NOT_CONFIGURED",
922
+ source: error.source,
923
+ organizationId: error.organizationId,
924
+ readOnly: true,
925
+ action,
926
+ });
927
+ }
928
+ function safeErrorMessage(error) {
929
+ const message = error instanceof Error ? error.message : String(error);
930
+ return message.replace(/Bearer\s+[^\s]+/gi, "Bearer [redacted]").slice(0, 500);
931
+ }
932
+ function handleCommercialError(error) {
933
+ const result = handleToolError(error);
934
+ try {
935
+ const body = JSON.parse(result.content[0].text);
936
+ if (body.error !== undefined)
937
+ body.error = safeErrorMessage(body.error);
938
+ const details = record(body.details);
939
+ if (Object.hasOwn(details, "response"))
940
+ details.response = safeValue(details.response, DEFAULT_CONTENT_LIMIT);
941
+ if (Object.keys(details).length > 0)
942
+ body.details = details;
943
+ return {
944
+ ...result,
945
+ content: [{ type: "text", text: JSON.stringify(body, null, 2) }],
946
+ };
947
+ }
948
+ catch {
949
+ return result;
950
+ }
951
+ }
952
+ async function readSelectedSources(client, target) {
953
+ const requests = [
954
+ { source: "crm", enabled: Boolean(target.email), read: () => readCrm(client, target) },
955
+ { source: "email", enabled: Boolean(target.email), read: () => readEmail(client, target) },
956
+ { source: "linkedin", enabled: Boolean(target.linkedin_url), read: () => readLinkedin(client, target) },
957
+ { source: "unipile", enabled: Boolean(target.linkedin_url), read: () => readUnipile(client, target) },
958
+ { source: "leadify_activity", enabled: Boolean(target.lead_id), read: () => readActivity(client, target) },
959
+ ];
960
+ const snapshots = [];
961
+ const errors = [];
962
+ const skipped = [];
963
+ const processRequest = async (request) => {
964
+ if (!request.enabled) {
965
+ skipped.push(request.source);
966
+ return;
967
+ }
968
+ try {
969
+ snapshots.push(await request.read());
970
+ }
971
+ catch (error) {
972
+ if (error instanceof IntegrationNotConfiguredError) {
973
+ errors.push({ source: request.source, code: "INTEGRATION_NOT_CONFIGURED", message: safeErrorMessage(error), action: integrationAction(request.source) });
974
+ }
975
+ else {
976
+ errors.push({ source: request.source, code: "READ_FAILED", message: safeErrorMessage(error) });
977
+ }
978
+ }
979
+ };
980
+ await requests.reduce((chain, request) => chain.then(() => processRequest(request)), Promise.resolve());
981
+ return { snapshots, errors, skipped };
982
+ }
983
+ function aggregatePayload(target, result) {
984
+ const baseReconciliation = reconcileCommercialSources(result.snapshots);
985
+ const reconciliation = result.errors.length === 0 || baseReconciliation.status === "excluded"
986
+ ? baseReconciliation
987
+ : {
988
+ ...baseReconciliation,
989
+ status: "review_required",
990
+ recommendedAction: "Pause action and configure or retry every source reported in errors before relying on this read.",
991
+ ambiguities: uniqueAmbiguities([
992
+ ...baseReconciliation.ambiguities,
993
+ ...result.errors.map((error) => `${String(error.source)} could not be read`),
994
+ ]),
995
+ };
996
+ const sourcePayloads = result.snapshots.map((snapshot) => ({
997
+ source: snapshot.source,
998
+ configured: snapshot.configured,
999
+ projection: snapshot.projection,
1000
+ evidence: snapshot.evidence,
1001
+ pagination: snapshot.pagination,
1002
+ data: snapshot.payload,
1003
+ }));
1004
+ const directionSet = new Set(result.snapshots.map((snapshot) => snapshot.projection.direction));
1005
+ const projectionValue = {
1006
+ source: "reconciled",
1007
+ date: reconciliation.freshness.observedAt,
1008
+ direction: result.snapshots.length === 0 ? "unknown" : directionSet.size === 1 ? result.snapshots[0].projection.direction : "mixed",
1009
+ status: reconciliation.status,
1010
+ optOut: reconciliation.optOut,
1011
+ bounce: reconciliation.bounce,
1012
+ suppression: reconciliation.suppression,
1013
+ handoff: reconciliation.handoff,
1014
+ freshness: reconciliation.freshness,
1015
+ ambiguities: reconciliation.ambiguities,
1016
+ };
1017
+ return {
1018
+ target: targetDescription(target),
1019
+ readOnly: true,
1020
+ noWrites: true,
1021
+ sources: sourcePayloads,
1022
+ skippedSources: result.skipped,
1023
+ errors: result.errors,
1024
+ projection: projectionValue,
1025
+ commercialTruth: projectionValue,
1026
+ reconciliation,
1027
+ recommendedAction: reconciliation.recommendedAction,
1028
+ sourceCount: sourcePayloads.length,
1029
+ };
1030
+ }
1031
+ function readSchema() {
1032
+ return {
1033
+ organization_id: z.string().trim().min(1).max(200).describe("Organization selected explicitly from list_organizations or discover_leadify_context."),
1034
+ lead_group_id: z.string().trim().min(1).max(200).describe("Lead group selected explicitly inside that organization; no implicit group is allowed."),
1035
+ campaign_id: z.string().trim().min(1).max(200).optional().describe("Optional campaign selected explicitly to narrow the read."),
1036
+ page: z.number().int().positive().max(10_000).optional().describe("One-based page number when the source uses page pagination."),
1037
+ limit: z.number().int().min(1).max(MAX_LIMIT).optional().default(DEFAULT_LIMIT).describe(`Maximum records returned; hard-bounded to ${MAX_LIMIT}.`),
1038
+ cursor: z.string().trim().min(1).max(500).optional().describe("Opaque cursor returned by the previous page."),
1039
+ max_content_chars: z.number().int().min(100).max(MAX_CONTENT_LIMIT).optional().default(DEFAULT_CONTENT_LIMIT).describe(`Maximum characters kept in each content field; hard-bounded to ${MAX_CONTENT_LIMIT}.`),
1040
+ freshness_max_age_ms: z.number().int().positive().max(MAX_FRESHNESS_MAX_AGE_MS).optional().default(DEFAULT_FRESHNESS_MAX_AGE_MS).describe("Maximum source age used by the common freshness projection; at most 90 days."),
1041
+ };
1042
+ }
1043
+ const commonDescription = "The organization, group, person and optional campaign are explicit. This read-only result is tenant-checked, paginated and redacted; it never sends a message or writes an activity.";
1044
+ function registerReadOnlyTool(server, name, description, schema, handler) {
1045
+ const candidate = server;
1046
+ if (typeof candidate.registerTool === "function") {
1047
+ candidate.registerTool(name, {
1048
+ title: name,
1049
+ description,
1050
+ inputSchema: schema,
1051
+ annotations: { readOnlyHint: true },
1052
+ }, handler);
1053
+ return;
1054
+ }
1055
+ server.tool(name, description, schema, handler);
1056
+ }
1057
+ export function registerCommercialTruthTools(server, client = getClient()) {
1058
+ registerReadOnlyTool(server, "crm_lookup_person", `Read normalized CRM person and sales-stage evidence for one explicit person. ${commonDescription} A won deal is exposed as client, an open deal as prospect, and a missing person is distinct from a missing company deal. If no CRM is configured, the response explains the configuration action.`, {
1059
+ ...readSchema(),
1060
+ email: z.string().trim().email().max(320).describe("Exact email of the person to look up."),
1061
+ }, async (params) => {
1062
+ try {
1063
+ const target = readTargetFromInput(params);
1064
+ return toolResult((await readCrm(client, target)).payload);
1065
+ }
1066
+ catch (error) {
1067
+ return error instanceof IntegrationNotConfiguredError ? missingIntegrationResult(error) : handleCommercialError(error);
1068
+ }
1069
+ });
1070
+ registerReadOnlyTool(server, "email_read_thread", `Read the normalized email thread for one explicit person. ${commonDescription} Inbound messages are observed replies, delivery exclusions are preserved, and unreadable mailboxes are reported as ambiguity rather than a false empty thread.`, {
1071
+ ...readSchema(),
1072
+ email: z.string().trim().email().max(320).describe("Exact email whose thread must be read."),
1073
+ }, async (params) => {
1074
+ try {
1075
+ const target = readTargetFromInput(params);
1076
+ return toolResult((await readEmail(client, target)).payload);
1077
+ }
1078
+ catch (error) {
1079
+ return error instanceof IntegrationNotConfiguredError ? missingIntegrationResult(error) : handleCommercialError(error);
1080
+ }
1081
+ });
1082
+ registerReadOnlyTool(server, "linkedin_read_conversation", `Read the normalized LinkedIn conversation of one explicit profile. ${commonDescription} Only bounded message content and conversation metadata are returned; enrichment, send and activity endpoints are never called.`, {
1083
+ ...readSchema(),
1084
+ linkedin_url: z.string().trim().url().max(500).describe("Explicit LinkedIn profile URL whose conversation must be read."),
1085
+ }, async (params) => {
1086
+ try {
1087
+ const target = readTargetFromInput(params);
1088
+ return toolResult((await readLinkedin(client, target)).payload);
1089
+ }
1090
+ catch (error) {
1091
+ return error instanceof IntegrationNotConfiguredError ? missingIntegrationResult(error) : handleCommercialError(error);
1092
+ }
1093
+ });
1094
+ registerReadOnlyTool(server, "unipile_read_messages", `Read normalized Unipile LinkedIn messages of one explicit profile. ${commonDescription} Attachment URLs are reduced to a safe path and message text is bounded.`, {
1095
+ ...readSchema(),
1096
+ linkedin_url: z.string().trim().url().max(500).describe("Explicit LinkedIn profile URL whose Unipile messages must be read."),
1097
+ }, async (params) => {
1098
+ try {
1099
+ const target = readTargetFromInput(params);
1100
+ return toolResult((await readUnipile(client, target)).payload);
1101
+ }
1102
+ catch (error) {
1103
+ return error instanceof IntegrationNotConfiguredError ? missingIntegrationResult(error) : handleCommercialError(error);
1104
+ }
1105
+ });
1106
+ registerReadOnlyTool(server, "leadify_read_activity_feed", `Read the bounded Leadify activity feed of one explicit lead. ${commonDescription} This is an audit source only: agent logs and synthetic entries never prove that a person replied.`, {
1107
+ ...readSchema(),
1108
+ lead_id: z.string().trim().min(1).max(200).describe("Explicit Leadify lead whose activity feed must be read."),
1109
+ target: z.string().trim().min(1).max(200).optional().describe("Optional exact activity target filter, such as a signal ID."),
1110
+ }, async (params) => {
1111
+ try {
1112
+ const target = readTargetFromInput(params);
1113
+ return toolResult((await readActivity(client, target)).payload);
1114
+ }
1115
+ catch (error) {
1116
+ return error instanceof IntegrationNotConfiguredError ? missingIntegrationResult(error) : handleCommercialError(error);
1117
+ }
1118
+ });
1119
+ registerReadOnlyTool(server, "get_commercial_truth", `Reconcile observed CRM, email, LinkedIn, Unipile and Leadify activity evidence of one explicit target. ${commonDescription} Provide an email, a LinkedIn URL and/or a lead ID to select person-level sources; omitted sources are listed as skipped. Exclusions win, divergent CRM/conversation evidence becomes review_required, and activity feed rows never prove a reply.`, {
1120
+ ...readSchema(),
1121
+ email: z.string().trim().email().max(320).optional().describe("Exact person email; enables CRM and email reads."),
1122
+ linkedin_url: z.string().trim().url().max(500).optional().describe("Explicit LinkedIn profile URL; enables LinkedIn and Unipile reads."),
1123
+ lead_id: z.string().trim().min(1).max(200).optional().describe("Explicit Leadify lead ID; enables the bounded activity read."),
1124
+ }, async (params) => {
1125
+ try {
1126
+ const target = readTargetFromInput(params);
1127
+ if (!target.email && !target.linkedin_url && !target.lead_id) {
1128
+ return toolError("Provide at least one explicit person selector: email, linkedin_url, or lead_id. The MCP never reads a whole group or organization implicitly.", {
1129
+ code: "EXPLICIT_PERSON_SELECTOR_REQUIRED",
1130
+ organizationId: target.organization_id,
1131
+ leadGroupId: target.lead_group_id,
1132
+ });
1133
+ }
1134
+ const result = await readSelectedSources(client, target);
1135
+ return toolResult(aggregatePayload(target, result));
1136
+ }
1137
+ catch (error) {
1138
+ return handleCommercialError(error);
1139
+ }
1140
+ });
1141
+ }
package/dist/version.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  export declare const MCP_PACKAGE_NAME = "@agifyai/leadify-mcp";
2
2
  export declare const MCP_SERVER_NAME = "leadify";
3
- export declare const MCP_VERSION = "8.5.1";
3
+ export declare const MCP_VERSION = "8.5.2";
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  export const MCP_PACKAGE_NAME = "@agifyai/leadify-mcp";
2
2
  export const MCP_SERVER_NAME = "leadify";
3
- export const MCP_VERSION = "8.5.1";
3
+ export const MCP_VERSION = "8.5.2";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agifyai/leadify-mcp",
3
- "version": "8.5.1",
3
+ "version": "8.5.2",
4
4
  "description": "MCP server for Leadify lead management API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -15,8 +15,9 @@
15
15
  "build": "rm -rf dist && tsc && chmod +x dist/index.js",
16
16
  "start": "node dist/index.js",
17
17
  "dev": "tsc --watch",
18
+ "canary:commercial": "npm run build && zsh scripts/run-commercial-truth-read-canary.sh",
18
19
  "prepublishOnly": "npm run build",
19
- "test": "npm run build && node --test test/**/*.test.mjs"
20
+ "test": "npm run build && node --test test/*.test.mjs"
20
21
  },
21
22
  "repository": {
22
23
  "type": "git",