@agifyai/leadify-mcp 8.7.5 → 8.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -266,6 +266,14 @@ Leadify et ne doit jamais être envoyée par un agent MCP.
266
266
  | `preview_unenroll_campaign_audience` | Prévisualiser de façon déterministe les membres explicites sans message pour le canal de leur campagne ou sans aucun canal de contact. Retourne les `lead_ids` exacts, sans mutation. |
267
267
  | `unenroll_campaign_audience` | Désenrôler uniquement la liste complète de `lead_ids` renvoyée par le preview courant. Relit et refuse toute sélection partielle, étendue ou périmée ; aucun envoi. |
268
268
  | `export_campaign` | Exporter les statistiques en CSV ; après clôture définitive, l'export provient du rapport final figé. |
269
+ | `list_playbooks` / `get_playbook` | Lister et lire les « Messages types » (playbooks du rédacteur) d’une organisation, avec leur contenu complet à la dernière version. |
270
+ | `publish_playbook` | Publier un message type : création, duplication ou nouvelle version (avec la version relue). Les refus du contrat du rédacteur sont rendus avec leur chemin. |
271
+ | `list_campaign_senders` | Lister les comptes d’envoi (LinkedIn, boîtes mail) de la personne qui porte la clé : les messages partent à son nom. |
272
+ | `get_campaign_audience_options` | Compter les personnes d’un groupe et de chacun de ses segments pour choisir l’audience d’une campagne. |
273
+ | `get_contact_policy` | Lire la politique de contact effective de l’organisation ou d’une campagne, en lecture seule. |
274
+ | `save_campaign_draft` / `get_campaign_draft` | Créer, modifier et relire un brouillon par l’assistant de campagne : audience, messages types jamais épinglés, expéditeur, fenêtre, jours, horaires et plafond quotidien. Toujours en mode `REVIEW` : chaque message attend une validation humaine. |
275
+ | `preview_campaign_draft` | Évaluer l’audience avec le moteur d’éligibilité (personnes par message type, raisons de non-contact), sans rien écrire. |
276
+ | `launch_campaign` | Lancer un brouillon comme le bouton « Lancer la campagne » (inscription, activation, rédaction immédiate), avec la confirmation explicite `launch_campaign`. Les messages attendent dans la file « À valider ». |
269
277
  | `signal_upsert` | Créer ou mettre à jour un signal de business intelligence (INFO, CRITICAL, GOLDEN). Remet le state à `active`. |
270
278
  | `signal_expire` | Expirer un signal (événement périmé). Flip de `state` uniquement. |
271
279
  | `signal_disable` | Désactiver un signal (faux positif / écarté manuellement). Flip de `state` uniquement. |
@@ -327,47 +335,42 @@ Leadify et ne doit jamais être envoyée par un agent MCP.
327
335
 
328
336
  ### `source-icp-prospects` : sourcing Personne rattachée à une Company
329
337
 
330
- `add_leads` est un import générique de leads plats et ne garantit aucune
331
- relation d’emploi : une Personne sourcée pour une entreprise doit toujours
332
- passer par `upsert_person_leads_with_employment`, seul chemin d’écriture
333
- Person admis pour le sourcing. Ce tool crée ou réutilise la Person Lead et la
334
- Person, crée ou réutilise une relation active `EMPLOYED_BY` vers la cible
335
- légale de la Company (Legal Entity ou Establishment compatible), maintient les
336
- projections `parent_lead` / `child_leads` consommées par l’interface, écrit
337
- uniquement les champs admis par le schéma du groupe Personne, ajoute
338
- l’historique sans écraser l’existant, applique l’idempotence par entrée et
339
- relit les objets avant de répondre. Chaque entrée est transactionnelle
340
- (échec = rollback complet de l’entrée) ; un batch peut mêler succès et échecs
341
- par entrée mais ne rend jamais un succès ambigu.
342
-
343
- Chaîne obligatoire, dans l’ordre :
344
-
345
- 1. Vérifier l’identité canonique de la Company Lead. Si elle n’expose ni
346
- Account ni Legal Entity / Establishment compatible, la préparer d’abord
347
- avec `preview_canonical_identity_backfill` puis
348
- `apply_canonical_identity_backfill` en rejouant exactement le digest
349
- prévisualisé. Toute contradiction d’identité bloque proprement : jamais de
350
- rapprochement au nom approximatif.
351
- 2. Pour chaque candidat accepté, créer `create_person_employment_evidence`
352
- avec le tenant, la Company Lead exacte, l’identité Person, la provenance
353
- et une clé idempotente. Relire si nécessaire avec
354
- `get_person_employment_evidence`, puis transmettre exclusivement
355
- l’`evidence.id` retourné dans `evidence_ids` de
356
- `upsert_person_leads_with_employment`.
357
- 3. Appeler `upsert_person_leads_with_employment` avec les IDs exacts
358
- (`organization_id`, `person_group_id`, `company_lead_id` identiques au
359
- même tenant), les seuls champs admis par le schéma Personne,
360
- `card_history_append`, et une `idempotency_key` unique par entrée.
361
- Pour réparer une Person Lead orpheline exacte sans doublon, passer son
362
- `person_lead_id` : la réparation suit la même transaction et les mêmes
363
- contrôles, et préserve LinkedIn, sources, `card_history`,
364
- `qualification_status`, `hide_from_campaign` et les champs sans lien avec
365
- la relation.
366
- 4. Vérifier dans les deux sens avant de considérer la Personne rattachée :
367
- `get_lead` sur la Personne (parent résolvable) et
368
- `list_canonical_relationships` filtré sur la Person puis sur la cible
369
- légale (lien `EMPLOYED_BY` actif). Une relation ou projection manquante
370
- est un échec explicite, jamais un succès partiel.
338
+ `add_leads` crée les identités minimales des fiches typées, mais aucun emploi.
339
+ Pour sourcer une personne liée à une entreprise, utiliser
340
+ `upsert_person_leads_with_employment` : il crée ou réutilise la Personne et
341
+ son emploi vers l’Account exact, conserve les preuves et ajoute l’historique.
342
+ Chaque entrée est enregistrée entièrement ou annulée. Aucun numéro légal ni
343
+ préparation juridique de l’entreprise n’est obligatoire.
344
+
345
+ 1. Choisir l’organisation, les groupes et la fiche entreprise exacts.
346
+ 2. Créer la preuve avec `create_person_employment_evidence`, en conservant
347
+ ses dates originales. Transmettre son identifiant dans `evidence_ids`.
348
+ 3. Appeler `upsert_person_leads_with_employment` avec les champs admis par
349
+ le groupe, l’historique à ajouter et une clé de répétition unique.
350
+ `person_lead_id` permet de réconcilier une fiche existante précise.
351
+ 4. Relire les deux fiches avec `get_person_employments` et vérifier le même
352
+ identifiant d’emploi. Les anciens champs `parent_lead` et `child_leads`
353
+ ne sont plus écrits ni utilisés pour déterminer le lien.
354
+
355
+ `get_lead` et `get_leads` conservent `canonicalIdentity` et
356
+ `personAccountRelationships`, indépendamment des champs CRM demandés.
357
+ Tous les emplois restent accessibles, avec leurs dates et leur confiance.
358
+ Une confiance inconnue ne signifie pas une absence de lien. L’entreprise
359
+ utilisée pour une qualification doit être choisie explicitement.
360
+
361
+ Pour modifier un emploi existant, utiliser `manage_person_employment` :
362
+ `attach` ajoute sans fermer les autres emplois ; `end` termine une période ;
363
+ `invalidate` conserve la trace d’un lien erroné ; `replace` remplace seulement
364
+ la relation désignée ; `evidence` actualise ses preuves et sa confiance.
365
+ Les modifications exigent `expected_version`, relu avec
366
+ `get_person_employments`, et une `idempotency_key` dont le contenu ne change
367
+ pas lors d’une répétition. Les droits administrateur de l’organisation sont
368
+ vérifiés par le serveur.
369
+
370
+ Les preuves détaillées se lisent à la demande avec
371
+ `get_person_employment_evidence_page`, pour une personne, un groupe et un
372
+ emploi précis. Suivre `page.nextCursor` tant que `page.hasMore` vaut `true`.
373
+ Les dates originales sont conservées ; la pagination n’efface aucune preuve.
371
374
 
372
375
  Aucune mutation de sourcing ne qualifie le lead, n’enrichit les contacts
373
376
  (email/téléphone), ne génère de message, n’inscrit en campagne et n’active
package/dist/server.js CHANGED
@@ -4,6 +4,7 @@ import { registerLeadTools } from "./tools/leads.js";
4
4
  import { registerSchemaTools } from "./tools/schema.js";
5
5
  import { registerLogTools } from "./tools/logs.js";
6
6
  import { registerCampaignTools } from "./tools/campaigns.js";
7
+ import { registerCampaignWizardTools } from "./tools/campaign_wizard.js";
7
8
  import { registerSignalTools } from "./tools/signals.js";
8
9
  import { registerActivityTools } from "./tools/activities.js";
9
10
  import { registerDataSourceTools } from "./tools/datasources.js";
@@ -20,6 +21,7 @@ import { registerCommercialTruthTools } from "./tools/commercial_truth.js";
20
21
  import { registerEventTools } from "./tools/events.js";
21
22
  import { registerContextEntityTools } from "./tools/context_entities.js";
22
23
  import { registerPersonEmploymentTools } from "./tools/person_employment.js";
24
+ import { registerPersonAccountTools } from "./tools/person_account.js";
23
25
  import { registerClaimContradictionTools } from "./tools/claim_contradictions.js";
24
26
  import { MCP_SERVER_NAME, MCP_VERSION } from "./version.js";
25
27
  export function createServer() {
@@ -36,11 +38,13 @@ export function createServer() {
36
38
  registerCanonicalBackfillTools(server);
37
39
  registerClaimContradictionTools(server);
38
40
  registerPersonEmploymentTools(server);
41
+ registerPersonAccountTools(server);
39
42
  registerEventTools(server);
40
43
  registerCommercialTruthTools(server);
41
44
  registerSchemaTools(server);
42
45
  registerLogTools(server);
43
46
  registerCampaignTools(server);
47
+ registerCampaignWizardTools(server);
44
48
  registerSignalTools(server);
45
49
  registerActivityTools(server);
46
50
  registerContextWorkspaceTools(server);
@@ -0,0 +1,5 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { type LeadifyClient } from "../client.js";
3
+ type WizardClient = Pick<LeadifyClient, "get" | "post">;
4
+ export declare function registerCampaignWizardTools(server: McpServer, injectedClient?: WizardClient): void;
5
+ export {};
@@ -0,0 +1,204 @@
1
+ import { z } from "zod";
2
+ import { getClient } from "../client.js";
3
+ import { toolResult, handleToolError } from "../types.js";
4
+ const organizationId = z.string().min(1).describe("Organization selected explicitly via discover_leadify_context or list_organizations.");
5
+ const time = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/);
6
+ function scoped(params) {
7
+ const query = new URLSearchParams();
8
+ for (const [key, value] of Object.entries(params))
9
+ if (value !== undefined)
10
+ query.set(key, value);
11
+ return query;
12
+ }
13
+ /** The server always stores REVIEW; refuse to report success if that ever changes. */
14
+ function assertReviewMode(data) {
15
+ const campaign = data && typeof data === "object" ? data.campaign : undefined;
16
+ const mode = campaign && typeof campaign === "object" ? campaign.autonomyMode : undefined;
17
+ if (mode !== "REVIEW") {
18
+ throw new Error(`Leadify returned a campaign whose autonomy mode is not REVIEW (${String(mode)}); stop and check the campaign in Leadify.`);
19
+ }
20
+ return data;
21
+ }
22
+ export function registerCampaignWizardTools(server, injectedClient) {
23
+ const client = () => injectedClient ?? getClient();
24
+ // ── Messages types (playbooks) ─────────────────────────────────────────
25
+ server.tool("list_playbooks", "List the organization's 'Messages types' (outreach playbooks) used by the outreach writer: id, name, kind " +
26
+ "(EVENT or GENERIC), latest version, validity window, linked event, channels with steps, and campaign usage.", {
27
+ organization_id: organizationId,
28
+ include_archived: z.boolean().optional().default(false).describe("Include archived playbooks."),
29
+ }, async ({ organization_id, include_archived }) => {
30
+ try {
31
+ return toolResult(await client().get("/api/playbooks", scoped({ organizationId: organization_id, includeArchived: include_archived ? "true" : undefined })));
32
+ }
33
+ catch (error) {
34
+ return handleToolError(error);
35
+ }
36
+ });
37
+ server.tool("get_playbook", "Read one 'Message type' at its latest version, with its full editable content (intent, eligibility, " +
38
+ "judgment guidance, promise, proofs, CTA, sequences and examples). Use it before publishing a new version.", {
39
+ organization_id: organizationId,
40
+ playbook_id: z.string().min(1).describe("Playbook ID from list_playbooks."),
41
+ }, async ({ organization_id, playbook_id }) => {
42
+ try {
43
+ return toolResult(await client().get(`/api/playbooks/${encodeURIComponent(playbook_id)}`, scoped({ organizationId: organization_id })));
44
+ }
45
+ catch (error) {
46
+ return handleToolError(error);
47
+ }
48
+ });
49
+ server.tool("publish_playbook", "Publish a 'Message type' (outreach playbook): create one (no playbook_id), duplicate one (duplicated_from_id), " +
50
+ "or publish a new version of an existing one (playbook_id plus the expected_version read by get_playbook). " +
51
+ "content follows the outreach contract without key, version, exclusivityKey and kind: name, intent, validity " +
52
+ "{startsAt?, endsAt?}, eligibility {all: [...]}, judgmentGuidance, promise, proofs [{id, statement, sourceUrl?}], " +
53
+ "cta {description, url?}, sequences [{channel: EMAIL|LINKEDIN, steps: [{step, kind: EMAIL|LINKEDIN_INVITE|LINKEDIN_MESSAGE, delayDays, purpose}]}], " +
54
+ "examples [{channel, step, subject?, body, commentary?}]. An EVENT playbook requires an eligibility condition " +
55
+ "{type: 'event_participation', eventId, status: 'VERIFIED'}. Contract refusals are returned with their JSON path.", {
56
+ organization_id: organizationId,
57
+ kind: z.enum(["EVENT", "GENERIC"]).describe("EVENT requires a verified event participation; GENERIC does not. Fixed after creation."),
58
+ content: z.record(z.unknown()).describe("Playbook content (see the tool description)."),
59
+ playbook_id: z.string().min(1).optional().describe("Existing playbook to publish a new version of."),
60
+ expected_version: z.number().int().min(1).optional().describe("Latest version read by get_playbook; required with playbook_id."),
61
+ duplicated_from_id: z.string().min(1).optional().describe("Playbook duplicated into a new one."),
62
+ }, async ({ organization_id, kind, content, playbook_id, expected_version, duplicated_from_id }) => {
63
+ try {
64
+ const body = { organizationId: organization_id, kind, content };
65
+ if (playbook_id !== undefined)
66
+ body.playbookId = playbook_id;
67
+ if (expected_version !== undefined)
68
+ body.expectedVersion = expected_version;
69
+ if (duplicated_from_id !== undefined)
70
+ body.duplicatedFromId = duplicated_from_id;
71
+ return toolResult(await client().post("/api/playbooks", body));
72
+ }
73
+ catch (error) {
74
+ return handleToolError(error);
75
+ }
76
+ });
77
+ // ── Campaign wizard ────────────────────────────────────────────────────
78
+ server.tool("list_campaign_senders", "List the sending accounts of the person who owns the API key in this organization (connected LinkedIn " +
79
+ "accounts and mailboxes). Messages are sent in that person's name, so a campaign can only use these senders.", { organization_id: organizationId }, async ({ organization_id }) => {
80
+ try {
81
+ return toolResult(await client().get("/api/campaign-wizard/senders", scoped({ organizationId: organization_id })));
82
+ }
83
+ catch (error) {
84
+ return handleToolError(error);
85
+ }
86
+ });
87
+ server.tool("get_campaign_audience_options", "Count the people of a lead group and of each saved view (segment), to choose a campaign audience. " +
88
+ "A campaign audience is the whole group or one saved view; create a view with create_lead_view to target a subset " +
89
+ "(for example an __event_participation filter for one event).", {
90
+ organization_id: organizationId,
91
+ lead_group_id: z.string().min(1).describe("Person lead group ID."),
92
+ }, async ({ organization_id, lead_group_id }) => {
93
+ try {
94
+ return toolResult(await client().get("/api/campaign-wizard/audience-options", scoped({ organizationId: organization_id, leadGroupId: lead_group_id })));
95
+ }
96
+ catch (error) {
97
+ return handleToolError(error);
98
+ }
99
+ });
100
+ server.tool("get_contact_policy", "Read the effective contact policy (cooldown days, maximum people per account and window, maximum qualification age) " +
101
+ "of the organization, or of one campaign when campaign_id is given. Read-only.", {
102
+ organization_id: organizationId,
103
+ campaign_id: z.string().min(1).optional().describe("Optional campaign ID for its effective policy."),
104
+ }, async ({ organization_id, campaign_id }) => {
105
+ try {
106
+ return toolResult(await client().get("/api/campaign-wizard/contact-policy", scoped({ organizationId: organization_id, campaignId: campaign_id })));
107
+ }
108
+ catch (error) {
109
+ return handleToolError(error);
110
+ }
111
+ });
112
+ server.tool("save_campaign_draft", "Create or update a DRAFT campaign through the Leadify campaign wizard: audience (group or saved view), " +
113
+ "'Messages types' (never pinned to a version), sender, window, send days, hours and daily limit. The campaign " +
114
+ "is always in REVIEW mode: every written message waits for a human validation before sending. Nothing is " +
115
+ "written or sent before launch_campaign. Pass campaign_id to update an existing draft.", {
116
+ organization_id: organizationId,
117
+ campaign_id: z.string().min(1).optional().describe("Existing DRAFT campaign to update."),
118
+ name: z.string().min(1).max(200).describe("Campaign name."),
119
+ lead_group_id: z.string().min(1).describe("Person lead group ID."),
120
+ audience_view_id: z.string().min(1).nullable().describe("Saved view used as audience, or null for the whole group."),
121
+ primary_playbook_ids: z.array(z.string().min(1)).max(20).describe("Primary 'Messages types', in priority order."),
122
+ fallback_playbook_id: z.string().min(1).nullable().optional().describe("Optional fallback 'Message type'."),
123
+ channel: z.enum(["LINKEDIN", "EMAIL"]).describe("Delivery channel."),
124
+ unipile_account_id: z.string().min(1).nullable().optional().describe("LinkedIn sender from list_campaign_senders."),
125
+ mailbox_provider: z.enum(["OUTLOOK", "GMAIL", "SMTP"]).nullable().optional().describe("Email sender provider from list_campaign_senders."),
126
+ mailbox_owner_user_id: z.string().min(1).nullable().optional().describe("Email sender owner from list_campaign_senders."),
127
+ start_at: z.string().datetime().nullable().describe("Inclusive start (ISO-8601 UTC); required before launch."),
128
+ end_at: z.string().datetime().nullable().describe("Exclusive end (ISO-8601 UTC); required before launch."),
129
+ timezone: z.string().min(1).describe("IANA timezone, for example Europe/Paris."),
130
+ workday_start: time.describe("Sending window start, HH:MM."),
131
+ workday_end: time.describe("Sending window end, HH:MM."),
132
+ send_days: z
133
+ .object({ monday: z.boolean(), tuesday: z.boolean(), wednesday: z.boolean(), thursday: z.boolean(), friday: z.boolean(), saturday: z.boolean(), sunday: z.boolean() })
134
+ .describe("Allowed sending days."),
135
+ daily_limit: z.number().int().min(1).describe("Daily sending limit for the channel."),
136
+ }, async (input) => {
137
+ try {
138
+ const linkedin = input.channel === "LINKEDIN";
139
+ const body = {
140
+ organizationId: input.organization_id,
141
+ ...(input.campaign_id ? { campaignId: input.campaign_id } : {}),
142
+ name: input.name,
143
+ leadGroupId: input.lead_group_id,
144
+ audienceViewId: input.audience_view_id,
145
+ primaryPlaybookIds: input.primary_playbook_ids,
146
+ fallbackEnabled: Boolean(input.fallback_playbook_id),
147
+ fallbackPlaybookId: input.fallback_playbook_id ?? null,
148
+ channel: input.channel,
149
+ unipileAccountId: linkedin ? input.unipile_account_id ?? null : null,
150
+ mailboxProvider: linkedin ? null : input.mailbox_provider ?? null,
151
+ mailboxOwnerUserId: linkedin ? null : input.mailbox_owner_user_id ?? null,
152
+ startAt: input.start_at,
153
+ endAt: input.end_at,
154
+ defaultTimezone: input.timezone,
155
+ workdayStart: input.workday_start,
156
+ workdayEnd: input.workday_end,
157
+ sendDays: input.send_days,
158
+ dailyLimit: input.daily_limit,
159
+ };
160
+ return toolResult(assertReviewMode(await client().post("/api/campaign-wizard", body)));
161
+ }
162
+ catch (error) {
163
+ return handleToolError(error);
164
+ }
165
+ });
166
+ server.tool("get_campaign_draft", "Read a campaign as the wizard sees it: audience, 'Messages types', sender, window, days, daily limit, " +
167
+ "autonomyMode (always REVIEW) and the launch blockers still missing.", {
168
+ organization_id: organizationId,
169
+ campaign_id: z.string().min(1).describe("Campaign ID."),
170
+ }, async ({ organization_id, campaign_id }) => {
171
+ try {
172
+ return toolResult(await client().get(`/api/campaign-wizard/${encodeURIComponent(campaign_id)}`, scoped({ organizationId: organization_id })));
173
+ }
174
+ catch (error) {
175
+ return handleToolError(error);
176
+ }
177
+ });
178
+ server.tool("preview_campaign_draft", "Dry-run the eligibility engine on the campaign audience: people per 'Message type' and people not contacted " +
179
+ "with their reasons. Nothing is written or sent.", {
180
+ organization_id: organizationId,
181
+ campaign_id: z.string().min(1).describe("Campaign ID."),
182
+ }, async ({ organization_id, campaign_id }) => {
183
+ try {
184
+ return toolResult(await client().get(`/api/campaign-wizard/${encodeURIComponent(campaign_id)}/preview`, scoped({ organizationId: organization_id })));
185
+ }
186
+ catch (error) {
187
+ return handleToolError(error);
188
+ }
189
+ });
190
+ server.tool("launch_campaign", "Launch a DRAFT campaign exactly like the 'Lancer la campagne' button: enroll the audience, activate the campaign " +
191
+ "and start the outreach writer immediately. In REVIEW mode every written message waits in the 'À valider' queue " +
192
+ "for a human validation before anything is sent. Confirm with the user before calling this tool.", {
193
+ organization_id: organizationId,
194
+ campaign_id: z.string().min(1).describe("DRAFT campaign ID."),
195
+ confirmation: z.literal("launch_campaign").describe("Exact acknowledgement required before launching."),
196
+ }, async ({ organization_id, campaign_id }) => {
197
+ try {
198
+ return toolResult(assertReviewMode(await client().post(`/api/campaign-wizard/${encodeURIComponent(campaign_id)}/launch`, { organizationId: organization_id })));
199
+ }
200
+ catch (error) {
201
+ return handleToolError(error);
202
+ }
203
+ });
204
+ }
@@ -80,6 +80,11 @@ function projectLead(lead, fields) {
80
80
  for (const field of fields)
81
81
  if (data[field] !== undefined)
82
82
  projected[field] = data[field];
83
+ // Relationship metadata belongs to the server contract, not selectable CRM fields.
84
+ for (const key of ["canonicalIdentity", "personAccountRelationships"]) {
85
+ if (source[key] !== undefined)
86
+ projected[key] = source[key];
87
+ }
83
88
  return projected;
84
89
  }
85
90
  function compactLeadsResponse(data, fields, includeSchema) {
@@ -117,14 +122,14 @@ export function registerLeadTools(server, client = getClient()) {
117
122
  }
118
123
  // ── add_leads ──────────────────────────────────────────────────────────
119
124
  server.tool("add_leads", "Flat generic lead import only: add one or more leads to a specific lead group in Leadify. " +
120
- "It stores plain field values and guarantees NO employment relationship, NO parent link and NO canonical identity. " +
125
+ "It creates each typed lead with its minimal Person or Account identity, but creates no employment relationship. " +
121
126
  "It must never be used to attach a Person to a Company: Person sourcing attached to a Company must use " +
122
- "upsert_person_leads_with_employment (after create_person_employment_evidence, and preview_canonical_identity_backfill plus " +
123
- "apply_canonical_identity_backfill when the Company lacks canonical identity). Provide the group ID " +
127
+ "upsert_person_leads_with_employment (after create_person_employment_evidence; no legal identity backfill is required). " +
128
+ "Provide the group ID " +
124
129
  "and an array of lead objects with any combination of fields (email, firstName, " +
125
130
  "lastName, company, jobTitle, phone, custom fields, card_* fields, percent_* " +
126
131
  "fields, etc.). Canonical relation fields are read-only here: use " +
127
- "create_canonical_relationship or the migration tools instead. Optionally specify which fields should be " +
132
+ "manage_person_employment for employment links instead. Optionally specify which fields should be " +
128
133
  "treated as 'select' dropdowns in the UI. Returns the count of successfully " +
129
134
  "imported leads and any errors. Use this for both bulk imports and single lead creation.", {
130
135
  lead_group_id: z
@@ -265,7 +270,7 @@ export function registerLeadTools(server, client = getClient()) {
265
270
  "name, new value, and optionally whether the field is a 'select' dropdown type. " +
266
271
  "Supports all field types including special fields (card_*, percent_*, boolean flags " +
267
272
  "like decisionMaking/excluded). When a configured canonical relation field is changed, " +
268
- "the backend routes it through the typed relationship service and keeps the JSON field read-only. Returns the updated lead. " +
273
+ "employment fields are refused: use manage_person_employment. Other relationship types retain their own service. Returns the updated lead. " +
269
274
  "location must be the canonical object {city, region?, postalCode?, countryCode, street?}; never submit geo. " +
270
275
  "For bulk updates across many leads, call this tool once per lead.", {
271
276
  lead_id: z.string().describe("ID of the lead to update."),
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function registerPersonAccountTools(server: McpServer, client?: import("../client.js").LeadifyClient): void;
@@ -0,0 +1,56 @@
1
+ import { z } from "zod";
2
+ import { getClient } from "../client.js";
3
+ import { handleToolError, toolResult } from "../types.js";
4
+ const id = z.string().trim().min(1);
5
+ const instant = z.string().datetime({ offset: true });
6
+ const trust = z.enum(["CONFIRMED", "PROPOSED", "AMBIGUOUS", "CONFLICTED", "UNKNOWN"]);
7
+ const existing = { relationship_id: id, expected_version: z.number().int().positive() };
8
+ const link = { person_lead_id: id, company_lead_id: id, trust_state: trust.optional(),
9
+ evidence_ids: z.array(id).max(100).optional(), role_title: id.nullable().optional() };
10
+ const command = z.discriminatedUnion("operation", [
11
+ z.object({ operation: z.literal("attach"), ...link, valid_from: instant.nullable().optional() }).strict(),
12
+ z.object({ operation: z.literal("end"), ...existing, ended_at: instant }).strict(),
13
+ z.object({ operation: z.literal("invalidate"), ...existing, reason: id }).strict(),
14
+ z.object({ operation: z.literal("evidence"), ...existing, evidence_ids: z.array(id).min(1).max(100), trust_state: trust }).strict(),
15
+ z.object({ operation: z.literal("replace"), ...existing, ...link, effective_at: instant }).strict(),
16
+ ]);
17
+ const commandKeys = {
18
+ operation: "operation", relationship_id: "relationshipId", expected_version: "expectedVersion",
19
+ person_lead_id: "personLeadId", company_lead_id: "companyLeadId", trust_state: "trustState",
20
+ evidence_ids: "evidenceIds", role_title: "roleTitle", valid_from: "validFrom", ended_at: "endedAt",
21
+ reason: "reason", effective_at: "effectiveAt",
22
+ };
23
+ export function registerPersonAccountTools(server, client = getClient()) {
24
+ server.tool("get_person_employments", "Read all employment periods for one exact Person or Company lead in its organization, including historical and invalidated links. Both directions use the same Person Account registry. Preserve trust and dates; UNKNOWN or stale evidence does not erase a link. Read relationship version before updating it; never choose the first employer implicitly.", { organization_id: id, lead_id: id }, async ({ organization_id, lead_id }) => {
25
+ try {
26
+ return toolResult(await client.get(`/api/person-employment/links/${encodeURIComponent(lead_id)}`, new URLSearchParams({ organizationId: organization_id })));
27
+ }
28
+ catch (error) {
29
+ return handleToolError(error);
30
+ }
31
+ });
32
+ server.tool("manage_person_employment", "Attach, end, invalidate, replace one exact employment, or update its evidence through the shared Person Account command. Requires organization admin access. Minimal Person and Account identities are ensured without SIREN or legal identity prerequisites. attach preserves other jobs; replace ends only the named relationship and preserves other simultaneous jobs. end preserves history; invalidate records an erroneous link and cannot be automatically reversed. Existing links require their latest expected_version from get_person_employments. Retries require the same idempotency key and unchanged payload. This never sends messages, qualifies a lead or activates a campaign.", { organization_id: id, idempotency_key: id.max(180), command }, async ({ organization_id, idempotency_key, command: input }) => {
33
+ try {
34
+ const checked = command.parse(input);
35
+ const body = Object.fromEntries(Object.entries(checked).map(([key, value]) => [commandKeys[key], value]));
36
+ return toolResult(await client.post("/api/person-employment/command", {
37
+ organizationId: organization_id, idempotencyKey: idempotency_key, ...body,
38
+ }));
39
+ }
40
+ catch (error) {
41
+ return handleToolError(error);
42
+ }
43
+ });
44
+ server.tool("get_person_employment_evidence_page", "Read detailed evidence on demand for one employment belonging to the exact Person lead, group and organization. Follow page.nextCursor while page.hasMore is true. Original observation and collection dates are preserved; an empty page is not a claim that another employment has no evidence.", { organization_id: id.max(200), lead_id: id.max(200), lead_group_id: id.max(200), relationship_id: id.max(200),
45
+ cursor: id.max(200).optional(), limit: z.number().int().min(1).max(5).optional().default(5) }, async ({ organization_id, lead_id, lead_group_id, relationship_id, cursor, limit }) => {
46
+ try {
47
+ return toolResult(await client.post("/api/qualify-lead/employment-evidence", {
48
+ organizationId: organization_id, leadId: lead_id, leadGroupId: lead_group_id,
49
+ relationshipId: relationship_id, cursor, limit,
50
+ }));
51
+ }
52
+ catch (error) {
53
+ return handleToolError(error);
54
+ }
55
+ });
56
+ }
@@ -44,7 +44,7 @@ export function registerPersonEmploymentTools(server, client = getClient()) {
44
44
  return handleToolError(error);
45
45
  }
46
46
  });
47
- server.tool("upsert_person_leads_with_employment", "Atomically create or reconcile Person Leads with their canonical PERSON identity, an active EMPLOYED_BY relationship to the Company legal target, parent_lead/child_leads projections, evidence and append-only card_history. company_lead_id must be exact and already project an Account plus Legal Entity or compatible Establishment: when it does not, prepare it first with preview_canonical_identity_backfill then apply_canonical_identity_backfill using the returned digest, and never fuzzy-match identity. Pass person_lead_id to repair one exact orphan Person Lead without creating a duplicate; otherwise a new Person Lead is created. fields must only contain keys admitted by the Person group schema and are forwarded verbatim so the backend rejects unknown keys; history is append-only via card_history_append and existing card_history is never overwritten. Each batch entry is independently transactional (a failure rolls that entry back entirely) and idempotent by idempotency_key; a changed payload under the same key is rejected, never merged. The success readback carries personLeadId, personId, companyLeadId, legalTarget, relationshipId, relationship, projections, cardHistory, noSend and externalActivation: verify the EMPLOYED_BY link in both directions (get_lead plus list_canonical_relationships) before treating the Person as attached. This is the only supported Person write path for sourcing; never fall back to add_leads, direct canonical backfill writes, or separate relationship writes. It never creates emails or phones, never qualifies the lead, never generates messages, never enrolls in a campaign and never activates any external channel.", {
47
+ server.tool("upsert_person_leads_with_employment", "Atomically create or reconcile Person Leads with their minimal PERSON identity and an active EMPLOYED_BY relationship to the exact Company ACCOUNT, evidence and append-only card_history. company_lead_id must identify an existing Company lead in the same organization; no legal identity, SIREN or prior backfill is required. Never fuzzy-match identity. Pass person_lead_id to reconcile one exact existing Person Lead without creating a duplicate. fields must only contain keys admitted by the Person group schema; card_history_append preserves existing history. Each entry is independently transactional and idempotent by idempotency_key; changed payloads under the same key are refused. Other simultaneous jobs are preserved. Verify the returned personId, accountId and relationshipId through get_person_employments on both lead IDs. No parent_lead or child_leads values are written. This is the only supported Person write path for sourcing; never fall back to add_leads or legal backfill to attach a sourced person. For explicit changes to existing jobs, use manage_person_employment. It never qualifies a lead, enriches contacts, generates messages, enrolls in a campaign or activates an external channel.", {
48
48
  people: z.array(z.object({
49
49
  organization_id: z.string().trim().min(1),
50
50
  person_group_id: z.string().trim().min(1),
@@ -10,7 +10,7 @@ const confidence = z.object({
10
10
  assessed_at: z.string().datetime(),
11
11
  }).strict();
12
12
  export function registerRelationshipTools(server, client = getClient()) {
13
- server.tool("list_canonical_relationships", "List tenant-scoped canonical relationships. The response is direction-independent for consumers: filter by one entity to find both incoming and outgoing links. Legacy lead relation fields are compatibility projections, not the source of truth.", {
13
+ server.tool("list_canonical_relationships", "List tenant-scoped canonical relationships. The response is direction-independent for consumers: filter by one entity to find both incoming and outgoing links. Employment links are PERSON EMPLOYED_BY ACCOUNT. Use get_person_employments to read them from lead IDs; stored parent_lead and child_leads are retired.", {
14
14
  organization_id: z.string().min(1),
15
15
  entity_kind: entityKind.optional(),
16
16
  entity_id: z.string().min(1).optional(),
@@ -37,7 +37,7 @@ export function registerRelationshipTools(server, client = getClient()) {
37
37
  return handleToolError(error);
38
38
  }
39
39
  });
40
- server.tool("create_canonical_relationship", "Create one typed canonical relationship in a tenant. Endpoints must already be strong canonical identities in that tenant. Retries with the same idempotency key and payload are safe; a changed payload is rejected. This tool never creates placeholder identities or activates outreach.", {
40
+ server.tool("create_canonical_relationship", "Create one typed canonical relationship in a tenant. Endpoints must already be strong canonical identities in that tenant. Retries with the same idempotency key and payload are safe; a changed payload is rejected. For EMPLOYED_BY use manage_person_employment instead; this generic write route rejects employment changes. This tool never creates placeholder identities or activates outreach.", {
41
41
  organization_id: z.string().min(1), relationship_type: relationshipType,
42
42
  source: entityRef, target: entityRef,
43
43
  trust_state: z.enum(["CONFIRMED", "PROPOSED", "AMBIGUOUS", "CONFLICTED", "UNKNOWN"]).optional().default("UNKNOWN"),
@@ -58,7 +58,7 @@ export function registerRelationshipTools(server, client = getClient()) {
58
58
  return handleToolError(error);
59
59
  }
60
60
  });
61
- server.tool("update_canonical_relationship", "Update, tombstone, or restore one canonical relationship through the shared transactional service. Every mutation is versioned in the immutable ledger and requires a unique idempotency key.", {
61
+ server.tool("update_canonical_relationship", "Update, tombstone, or restore one canonical relationship through the shared transactional service. Every mutation is versioned in the immutable ledger and requires a unique idempotency key. EMPLOYED_BY changes are refused here; use manage_person_employment with the current relationship version.", {
62
62
  organization_id: z.string().min(1), relationship_id: z.string().min(1),
63
63
  relationship_type: relationshipType.optional(), source: entityRef.optional(), target: entityRef.optional(),
64
64
  trust_state: z.enum(["CONFIRMED", "PROPOSED", "AMBIGUOUS", "CONFLICTED", "UNKNOWN"]).optional(),
@@ -78,7 +78,7 @@ export function registerRelationshipTools(server, client = getClient()) {
78
78
  return handleToolError(error);
79
79
  }
80
80
  });
81
- server.tool("tombstone_canonical_relationship", "Soft-delete one canonical relationship while preserving its evidence and immutable history. Use this instead of deleting legacy relation fields.", { organization_id: z.string().min(1), relationship_id: z.string().min(1), idempotency_key: z.string().min(1).max(255) }, async ({ organization_id, relationship_id, idempotency_key }) => {
81
+ server.tool("tombstone_canonical_relationship", "Soft-delete one canonical relationship while preserving its evidence and immutable history. For employment use manage_person_employment with operation invalidate or end; this generic route rejects EMPLOYED_BY.", { organization_id: z.string().min(1), relationship_id: z.string().min(1), idempotency_key: z.string().min(1).max(255) }, async ({ organization_id, relationship_id, idempotency_key }) => {
82
82
  try {
83
83
  return toolResult(await client.delete(`/api/canonical-relationships/${encodeURIComponent(relationship_id)}`, { organizationId: organization_id, idempotencyKey: idempotency_key }));
84
84
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agifyai/leadify-mcp",
3
- "version": "8.7.5",
3
+ "version": "8.8.0",
4
4
  "description": "MCP server for Leadify lead management API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",