@agifyai/leadify-mcp 9.13.0 → 9.14.1

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
@@ -211,9 +211,9 @@ Les corrections ajoutent un fait immuable et mettent à jour le snapshot
211
211
  courant dans la même opération ; elles exigent un motif. Il n’existe aucun tool
212
212
  de suppression de participation ou de preuve. Archiver un événement le retire
213
213
  des sélecteurs courants sans effacer son historique. Les champs legacy
214
- `congress_presence`, `next_congress`, `next_congress_date` et `card_congress`
215
- restent disponibles pour compatibilité/export, mais ne sont jamais synchronisés
216
- depuis ce modèle.
214
+ `congress_presence`, `next_congress` et `next_congress_date` restent disponibles
215
+ pour compatibilité/export, mais ne sont jamais synchronisés depuis ce modèle.
216
+ L'ancienne carte `card_congress` est supprimée (PRD-2831) et refusée à l'écriture.
217
217
 
218
218
  Événements d’une personne :
219
219
 
@@ -282,7 +282,7 @@ Leadify et ne doit jamais être envoyée par un agent MCP.
282
282
  | `undo_move_leads` / `get_lead_move` | Annuler ou relire une opération de déplacement par son identifiant. |
283
283
  | `get_leads` | Rechercher et lister des leads avec filtres, recherche et pagination, dont le critère composé `event_filter` (`subject` : employeur ou personne). |
284
284
  | `get_lead` | Récupérer les détails complets d'un lead par son ID. |
285
- | `update_lead` | Mettre à jour un ou plusieurs champs d'un lead existant. |
285
+ | `update_lead` | Mettre à jour un ou plusieurs champs d'un lead existant. `expect` (facultatif) : valeurs lues, l'écriture est refusée (`LEAD_WRITE_PRECONDITION_FAILED`) si l'une a changé depuis ; `reason` (facultatif) : motif noté dans l'historique. Ne s'écrit jamais `qualification_document` par cet outil. |
286
286
  | `clear_lead_field` | Effacer irréversiblement une valeur stockée, sans modifier le schéma du groupe. Exige la confirmation explicite `clear_lead_field`. |
287
287
  | `list_events` | Lister les événements tenant-scoped, filtrables par type (`event_types`) ; les événements archivés sont masqués par défaut. |
288
288
  | `get_event` | Lire un événement partagé, son type, son état d’archivage et ses nombres d’entreprises et de personnes rattachées. |
@@ -784,3 +784,33 @@ changent pas l’état du message et n’exposent pas l’enveloppe fournisseur
784
784
 
785
785
  Pour un signal daté, `signal_upsert.date_evidence` fournit le passage source qui
786
786
  établit `date`. Le serveur conserve cette preuve ; une date sans preuve est refusée.
787
+
788
+ ## Qualification V3 pour un agent externe (PRD-2831)
789
+
790
+ Un agent externe (Louise, profil Hermes `huntify`) qualifie par les mêmes routes que l'agent de la plateforme, sous son nom. Le MCP pose seul la **corrélation** de chaque écriture : `triggerRunId = agentRunId = traceId = external:<agent>:<session>`, `missionVersion = external-agent.v1`, `author = { kind: external_agent, name }`. Le modèle ne la fournit jamais.
791
+
792
+ - `LEADIFY_EXTERNAL_AGENT_NAME` (par exemple `Louise`) est obligatoire pour **écrire** ; `<agent>` est ce nom en minuscules, sans espace. Sans lui, les écritures sont refusées avant tout appel serveur, avec un message clair ; les lectures marchent.
793
+ - `<session>` est un identifiant aléatoire par processus du MCP, ou le `session_ref` facultatif de l'appel. Garder le même `session_ref` pour les propositions de contacts et leur finalisation.
794
+ - Les écritures avec essai à blanc (`dry_run`) valent `true` par défaut : relire le résultat, puis renvoyer `dry_run=false`. La clé d'idempotence est dérivée du contenu si elle n'est pas donnée.
795
+ - Refus rendus lisibles avec une consigne (`next_step`) : `QUALIFICATION_RUN_IN_PROGRESS` (attendre la fin du passage), refus du contrat avec chaque chemin, gardes périmées (relire le contexte), `QUALIFICATION_RULES_UNAVAILABLE`.
796
+
797
+ | Outil | Rôle |
798
+ | --- | --- |
799
+ | `get_qualification_context` | Entrée de l'agent, fiche actuelle avec ses auteurs, politique des champs, gardes de fraîcheur (lecture). |
800
+ | `get_qualification_document` | Fiche actuelle, gardes, et les 10 dernières versions (lecture). |
801
+ | `get_qualification_rules` | Règles de la qualification (entreprise ou personne) lues au registre de prompts (lecture). |
802
+ | `validate_qualification_section` | Essai à blanc d'une case de la fiche, sans écriture. |
803
+ | `submit_qualification_patch` | Écrit un patch de la fiche, jugé par le contrat. `reason` obligatoire, gardée par le serveur sur la version (historique de la fiche). |
804
+ | `list_qualification_versions` / `get_qualification_version` | Versions de la fiche, résumé puis version complète. |
805
+ | `restore_qualification_version` | Restaure une version (`dry_run` par défaut, raison obligatoire). |
806
+ | `mark_for_requalification` | Marque la fiche à requalifier, sans lancer de passage. |
807
+ | `qualification_write_signal` / `qualification_record_event` | Signal daté et sourcé ; événement et participation (entreprise ou personne). |
808
+ | `qualification_search_employer` / `qualification_link_employer` | Cherche l'employeur (reçu de recherche), puis rattache la personne avec preuve. |
809
+ | `qualification_read_known_contacts` / `qualification_propose_contacts` / `qualification_finalize_contacts` / `qualification_readback_contact_proposal` / `qualification_discover_contact` | Contacts découverts : vérifier les doublons, proposer, finaliser (ou relire avec `readback_only`), relire une proposition, découverte depuis une entreprise. |
810
+ | `qualification_enrich_email` / `qualification_geocode_location` | Courriel professionnel ; géocodage de la localisation. |
811
+ | `qualification_get_working_corpus` / `qualification_save_working_corpus` | Corpus de recherche figé d'une entreprise. |
812
+ | `qualification_read_history` / `qualification_read_employment_evidence` | Historique de qualification ; preuves d'un emploi. |
813
+
814
+ ## Consigne permanente : Louise (Hermes)
815
+
816
+ Toute modification d'un outil, d'un schéma ou d'une description de ce MCP qui touche la qualification ou le Company Brain met à jour Louise (agente Hermes au profil `huntify`, dépôt `mayeulrougevin/hermes` : `profiles/huntify/skills/` et `profiles/huntify/mcp-servers/huntify-leadify/contract.json`) dans la même livraison.
package/dist/client.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { externalAgentName } from "./correlation.js";
1
2
  import { LeadifyApiError } from "./types.js";
2
3
  export class LeadifyClient {
3
4
  baseUrl;
@@ -25,6 +26,10 @@ export class LeadifyClient {
25
26
  const headers = {
26
27
  Authorization: `Bearer ${this.apiKey}`,
27
28
  };
29
+ // PRD-2831 : le serveur note l'agent externe dans l'historique et refuse ses écritures pendant un passage.
30
+ const agent = externalAgentName();
31
+ if (agent)
32
+ headers["x-leadify-external-agent"] = encodeURIComponent(agent); // toujours encodé : le serveur décode
28
33
  const init = { method, headers };
29
34
  if (body !== undefined &&
30
35
  (method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE")) {
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Corrélation d'un agent externe (Louise) pour les écritures de qualification.
3
+ * Elle est posée par le MCP, jamais demandée au modèle : seul `session_ref`
4
+ * (facultatif) peut la préciser.
5
+ */
6
+ export declare const EXTERNAL_AGENT_ENV = "LEADIFY_EXTERNAL_AGENT_NAME";
7
+ export declare const EXTERNAL_MISSION_VERSION = "external-agent.v1";
8
+ export declare class ExternalAgentNameMissingError extends Error {
9
+ constructor();
10
+ }
11
+ export interface ExternalCorrelation {
12
+ triggerRunId: string;
13
+ agentRunId: string;
14
+ traceId: string;
15
+ missionVersion: string;
16
+ author: {
17
+ kind: "external_agent";
18
+ name: string;
19
+ };
20
+ }
21
+ export declare function externalAgentName(env?: NodeJS.ProcessEnv): string | undefined;
22
+ /** Identifiant de session externe : `external:<agent>:<session>`. Refuse sans nom d'agent. */
23
+ export declare function externalRunId(sessionRef?: string): string;
24
+ export declare function externalCorrelation(sessionRef?: string): ExternalCorrelation;
25
+ /** Corrélation d'une lecture : le nom de l'agent n'est pas exigé, la lecture n'écrit rien. */
26
+ export declare function readCorrelation(sessionRef?: string): ExternalCorrelation;
27
+ /** Variante des routes dont la corrélation n'accepte pas `missionVersion` (proposition de contact). */
28
+ export declare function externalCorrelationWithoutMission(sessionRef?: string): {
29
+ triggerRunId: string;
30
+ agentRunId: string;
31
+ traceId: string;
32
+ author: {
33
+ kind: "external_agent";
34
+ name: string;
35
+ };
36
+ };
37
+ /** `lead_group_id` -> `leadGroupId`, récursivement, pour les charges utiles décrites en snake_case. */
38
+ export declare function camelizeKeys(value: unknown): unknown;
@@ -0,0 +1,69 @@
1
+ import { randomUUID } from "node:crypto";
2
+ /**
3
+ * Corrélation d'un agent externe (Louise) pour les écritures de qualification.
4
+ * Elle est posée par le MCP, jamais demandée au modèle : seul `session_ref`
5
+ * (facultatif) peut la préciser.
6
+ */
7
+ export const EXTERNAL_AGENT_ENV = "LEADIFY_EXTERNAL_AGENT_NAME";
8
+ export const EXTERNAL_MISSION_VERSION = "external-agent.v1";
9
+ const PROCESS_SESSION = randomUUID().replace(/-/g, "").slice(0, 16);
10
+ export class ExternalAgentNameMissingError extends Error {
11
+ constructor() {
12
+ super(`${EXTERNAL_AGENT_ENV} n'est pas défini : une écriture de qualification doit être faite au nom d'un agent identifié. ` +
13
+ `Lancez le MCP avec ${EXTERNAL_AGENT_ENV}=<Nom> (par exemple Louise). Les lectures restent disponibles sans ce nom.`);
14
+ this.name = "ExternalAgentNameMissingError";
15
+ }
16
+ }
17
+ export function externalAgentName(env = process.env) {
18
+ const name = env[EXTERNAL_AGENT_ENV]?.trim();
19
+ return name ? name.slice(0, 120) : undefined;
20
+ }
21
+ function cleanSessionRef(ref) {
22
+ const cleaned = (ref ?? "").trim().replace(/\s+/g, "-").slice(0, 80);
23
+ return cleaned || PROCESS_SESSION;
24
+ }
25
+ /** Identifiant de session externe : `external:<agent>:<session>`. Refuse sans nom d'agent. */
26
+ export function externalRunId(sessionRef) {
27
+ const name = externalAgentName();
28
+ if (!name)
29
+ throw new ExternalAgentNameMissingError();
30
+ const agent = name.toLowerCase().replace(/\s+/g, "");
31
+ return `external:${agent}:${cleanSessionRef(sessionRef)}`;
32
+ }
33
+ export function externalCorrelation(sessionRef) {
34
+ const name = externalAgentName();
35
+ if (!name)
36
+ throw new ExternalAgentNameMissingError();
37
+ const id = externalRunId(sessionRef);
38
+ return {
39
+ triggerRunId: id,
40
+ agentRunId: id,
41
+ traceId: id,
42
+ missionVersion: EXTERNAL_MISSION_VERSION,
43
+ author: { kind: "external_agent", name },
44
+ };
45
+ }
46
+ /** Corrélation d'une lecture : le nom de l'agent n'est pas exigé, la lecture n'écrit rien. */
47
+ export function readCorrelation(sessionRef) {
48
+ if (externalAgentName())
49
+ return externalCorrelation(sessionRef);
50
+ const id = `external:reader:${cleanSessionRef(sessionRef)}`;
51
+ return { triggerRunId: id, agentRunId: id, traceId: id, missionVersion: EXTERNAL_MISSION_VERSION, author: { kind: "external_agent", name: "Lecture seule" } };
52
+ }
53
+ /** Variante des routes dont la corrélation n'accepte pas `missionVersion` (proposition de contact). */
54
+ export function externalCorrelationWithoutMission(sessionRef) {
55
+ const { missionVersion: _omit, ...rest } = externalCorrelation(sessionRef);
56
+ return rest;
57
+ }
58
+ /** `lead_group_id` -> `leadGroupId`, récursivement, pour les charges utiles décrites en snake_case. */
59
+ export function camelizeKeys(value) {
60
+ if (Array.isArray(value))
61
+ return value.map(camelizeKeys);
62
+ if (value && typeof value === "object") {
63
+ return Object.fromEntries(Object.entries(value).map(([key, inner]) => [
64
+ key.replace(/_([a-z0-9])/g, (_m, c) => c.toUpperCase()),
65
+ camelizeKeys(inner),
66
+ ]));
67
+ }
68
+ return value;
69
+ }
package/dist/server.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { registerAgentOutreachTools } from "./tools/agent_outreach.js";
2
2
  import { registerQualificationRunTools } from "./tools/qualification_runs.js";
3
+ import { registerQualificationTools } from "./tools/qualification.js";
3
4
  import { registerOutreachExclusionTools } from "./tools/outreach_exclusion.js";
4
5
  import { registerProspectionTrackingTools } from "./tools/prospection_tracking.js";
5
6
  import { registerOutreachReviewTools } from "./tools/outreach_review.js";
@@ -62,6 +63,7 @@ export function createServer() {
62
63
  registerPersonaTools(server);
63
64
  registerPipelineTools(server);
64
65
  registerQualificationRunTools(server);
66
+ registerQualificationTools(server);
65
67
  registerOutreachExclusionTools(server);
66
68
  registerProspectionTrackingTools(server);
67
69
  registerOutreachReviewTools(server);
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { getClient } from "../client.js";
3
- import { handleToolError, toolResult } from "../types.js";
3
+ import { LeadifyApiError, handleToolError, toolError, toolResult } from "../types.js";
4
4
  const CONTEXT_KIND = "company_brain";
5
5
  const MAX_READINESS_ISSUES = 20;
6
6
  const ACTIVE_CONTEXT_SECTIONS = [
@@ -12,11 +12,47 @@ const ACTIVE_CONTEXT_SECTIONS = [
12
12
  ];
13
13
  const shortText = z.string().trim().max(500);
14
14
  const longText = z.string().trim().max(20_000);
15
+ const EVIDENCE_HELP = "Evidence references that support this section, written freely as plain text (up to 100). They are NOT identifiers created by the server: " +
16
+ "write a public URL, a document path (for example a Company Brain note of Agify with its path), or a short dated internal proof " +
17
+ "(e.g. \"Call with the CEO, 2026-10-30: pricing confirmed\"). At least one non-empty reference per section is required to publish; [] blocks publication.";
18
+ const MISSING_EVIDENCE_CODE = "MISSING_REQUIRED_EVIDENCE";
19
+ const MISSING_EVIDENCE_NEXT_STEP = "Each section needs at least one evidence reference (evidenceIds) to be published. Add one per section with update_company_brain_sections " +
20
+ "(a public URL, a document path, or a short dated internal proof, written as free text), then read again and publish.";
15
21
  const section = (value) => z.object({
16
22
  value,
17
- evidenceIds: z.array(z.string().min(1)).max(100),
18
- approved: z.boolean(),
23
+ evidenceIds: z.array(z.string().trim().min(1)).max(100).describe(EVIDENCE_HELP),
24
+ approved: z.boolean().describe("true once a human validated the section content; required to publish."),
19
25
  }).strict();
26
+ const FORBIDDEN_SCOPE_NEXT_STEP = "FORBIDDEN_ORGANIZATION_SCOPE : écrire ou publier le Company Brain exige une clé d'API d'un administrateur de cette organisation (ou le bon organization_id).";
27
+ const DRAFT_NOT_READY_NEXT_STEP = "Le brouillon reste ready:false. Lisez readiness.issues : chaque problème nomme sa section et son code (MISSING_REQUIRED_SECTION, EMPTY_REQUIRED_SECTION, UNAPPROVED_REQUIRED_SECTION -> approved:true, MISSING_REQUIRED_EVIDENCE -> evidenceIds). Corrigez avec update_company_brain_sections, puis publiez.";
28
+ /** Consigne à joindre à un refus du serveur, selon son code ; undefined si rien à ajouter. */
29
+ function companyBrainNextStep(error) {
30
+ const body = error.responseBody;
31
+ if (mentionsMissingEvidence(body) || mentionsMissingEvidence(error.message))
32
+ return MISSING_EVIDENCE_NEXT_STEP;
33
+ const code = body && typeof body === "object" ? body.error : undefined;
34
+ if (code === "READINESS_FAILED" || error.message === "READINESS_FAILED")
35
+ return DRAFT_NOT_READY_NEXT_STEP;
36
+ if (code === "FORBIDDEN_ORGANIZATION_SCOPE" || error.message === "FORBIDDEN_ORGANIZATION_SCOPE")
37
+ return FORBIDDEN_SCOPE_NEXT_STEP;
38
+ return undefined;
39
+ }
40
+ function withNextStepOnError(error) {
41
+ if (error instanceof LeadifyApiError) {
42
+ const next_step = companyBrainNextStep(error);
43
+ if (next_step)
44
+ return toolError(error.message, { statusCode: error.statusCode, response: error.responseBody, next_step });
45
+ }
46
+ return handleToolError(error);
47
+ }
48
+ function mentionsMissingEvidence(value) {
49
+ try {
50
+ return JSON.stringify(value)?.includes(MISSING_EVIDENCE_CODE) ?? false;
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ }
20
56
  const sectionPatchSchema = z.object({
21
57
  identity: section(z.object({
22
58
  companyName: shortText.optional(),
@@ -192,7 +228,7 @@ export function registerContextWorkspaceTools(server, client = getClient()) {
192
228
  });
193
229
  server.registerTool("get_context_workspace", {
194
230
  title: "Read canonical versioned Context Workspace",
195
- description: "Read the versioned Company Brain for one explicitly selected organization. " +
231
+ description: "Read the versioned Company Brain for one explicitly selected organization. If the draft readiness lists MISSING_REQUIRED_EVIDENCE, the result carries a next_step: add an evidence reference per section with update_company_brain_sections. " +
196
232
  "Returns the current draft plus the latest published version, with bounded readiness metadata. " +
197
233
  "Verticals, Offers and Personas use their dedicated non-versioned tools; historical GTM Playbook rows are not exposed.",
198
234
  inputSchema: {
@@ -203,7 +239,12 @@ export function registerContextWorkspaceTools(server, client = getClient()) {
203
239
  try {
204
240
  const params = new URLSearchParams({ organizationId: organization_id });
205
241
  const data = await client.get(`/api/context-workspace/${CONTEXT_KIND}`, params);
206
- return toolResult(projectContextWorkspaceRead(data));
242
+ const projected = projectContextWorkspaceRead(data);
243
+ return toolResult(mentionsMissingEvidence(record(projected.draft).readiness)
244
+ ? { ...projected, next_step: MISSING_EVIDENCE_NEXT_STEP }
245
+ : record(record(projected.draft).readiness).ready === false
246
+ ? { ...projected, next_step: DRAFT_NOT_READY_NEXT_STEP }
247
+ : projected);
207
248
  }
208
249
  catch (error) {
209
250
  return handleToolError(error);
@@ -213,10 +254,13 @@ export function registerContextWorkspaceTools(server, client = getClient()) {
213
254
  title: "Update Company Brain sections",
214
255
  description: "Update only the named Company Brain sections. The MCP re-reads the current draft (or latest published version), merges only those sections, " +
215
256
  `and saves with optimistic concurrency. Allowed sections: ${ACTIVE_CONTEXT_SECTIONS.join(", ")}. ` +
216
- "Historical fields moved to Vertical, Offer, Persona or Lead Group are rejected by the closed schema.",
257
+ "Historical fields moved to Vertical, Offer, Persona or Lead Group are rejected by the closed schema. " +
258
+ "Each section is { value, evidenceIds, approved }. To publish, every section needs at least one non-empty evidence reference in `evidenceIds`: " +
259
+ "free text, not a server-created id: a public URL, a document path (for example a Company Brain note of Agify with its path), " +
260
+ "or a short dated internal proof. Never send `evidenceIds: []` for a section you intend to publish.",
217
261
  inputSchema: {
218
262
  organization_id: z.string().trim().min(1).describe("Required organization ID; section writes require admin access."),
219
- sections: sectionPatchSchema.describe("Only active Company Brain sections to change. Each section includes value, evidenceIds, and approved."),
263
+ sections: sectionPatchSchema.describe("Only active Company Brain sections to change. Each section includes value, evidenceIds (free-text evidence references, at least one to publish), and approved."),
220
264
  expected_revision: z.number().int().nonnegative().describe("Current draft revision, or 0 when no draft exists. Re-read first."),
221
265
  idempotency_key: z.string().trim().min(1).max(200).describe("Stable unique key for this exact section patch."),
222
266
  },
@@ -229,17 +273,20 @@ export function registerContextWorkspaceTools(server, client = getClient()) {
229
273
  expectedRevision: expected_revision,
230
274
  idempotencyKey: idempotency_key,
231
275
  });
232
- return toolResult(compactDraft(data));
276
+ const draft = compactDraft(data);
277
+ const readiness = draft && typeof draft === "object" ? record(draft.readiness) : {};
278
+ return toolResult(readiness.ready === false ? { ...draft, next_step: DRAFT_NOT_READY_NEXT_STEP } : draft);
233
279
  }
234
280
  catch (error) {
235
- return handleToolError(error);
281
+ return withNextStepOnError(error);
236
282
  }
237
283
  });
238
284
  server.registerTool("publish_context_workspace", {
239
285
  title: "Publish a ready Context Workspace draft",
240
286
  description: "Publish exactly one ready draft revision of the canonical Context Workspace. This is a consequential action: it requires " +
241
287
  "an organization-admin write key, the precise current draft revision, and `confirm_publish: true`. The server rejects incomplete " +
242
- "content with its readiness reasons and rejects stale revisions without overwriting anything.",
288
+ "content with its readiness reasons and rejects stale revisions without overwriting anything. If it fails with MISSING_REQUIRED_EVIDENCE, " +
289
+ "add at least one evidence reference (evidenceIds: free-text URL, document path or short dated proof) to each section with update_company_brain_sections, then retry.",
243
290
  inputSchema: {
244
291
  organization_id: z.string().trim().min(1).describe("Required organization ID; publication is restricted to an admin key for this tenant."),
245
292
  expected_revision: z.number().int().positive().describe("Exact ready draft revision to publish, obtained from `get_context_workspace`."),
@@ -258,7 +305,7 @@ export function registerContextWorkspaceTools(server, client = getClient()) {
258
305
  return toolResult(compactPublished(data));
259
306
  }
260
307
  catch (error) {
261
- return handleToolError(error);
308
+ return withNextStepOnError(error);
262
309
  }
263
310
  });
264
311
  }
@@ -34,7 +34,7 @@ const leadInputSchema = z.record(z.unknown()).superRefine((lead, context) => {
34
34
  }
35
35
  });
36
36
  const leadUpdateSchema = z.object({
37
- property_name: z.string().describe("Name of the field to update (e.g. 'email', 'status', 'card_analysis')."),
37
+ property_name: z.string().describe("Name of the field to update (e.g. 'email', 'status', 'jobTitle'). It must already exist in the lead group schema; declare a new one first with update_schema."),
38
38
  value: z.unknown().describe("New value for the field. location must use the canonical object; location.geo is forbidden."),
39
39
  is_select: z.boolean().optional().describe("If true, the field is created/updated as a 'select' dropdown type. Use for categorical fields like status, source, etc."),
40
40
  }).superRefine((update, context) => {
@@ -126,7 +126,7 @@ export function registerLeadTools(server, client = getClient()) {
126
126
  "upsert_person_leads_with_employment (after create_person_employment_evidence; no legal identity backfill is required). " +
127
127
  "Provide the group ID " +
128
128
  "and an array of lead objects with any combination of fields (email, firstName, " +
129
- "lastName, company, jobTitle, phone, custom fields, card_* fields, percent_* " +
129
+ "lastName, company, jobTitle, phone, custom fields, percent_* " +
130
130
  "fields, etc.). Canonical relation fields are read-only here: use " +
131
131
  "manage_person_employment for employment links instead. Optionally specify which fields should be " +
132
132
  "treated as 'select' dropdowns in the UI. Returns the count of successfully " +
@@ -139,7 +139,7 @@ export function registerLeadTools(server, client = getClient()) {
139
139
  .describe("Array of lead objects. Each object is a key-value map of field names to values. " +
140
140
  "Common fields: email, firstName, lastName, company, jobTitle, phone, linkedin, " +
141
141
  "website, seniority, specialty, age. The company field is flat display text only and never creates an employment relationship or parent link. " +
142
- "location must be {city, region?, postalCode?, countryCode, street?}; geo is computed by Leadify. Also supports card_* fields, " +
142
+ "location must be {city, region?, postalCode?, countryCode, street?}; geo is computed by Leadify. Also supports " +
143
143
  "percent_* fields and boolean flags (decisionMaking, excluded). Canonical relation fields are rejected."),
144
144
  is_select_fields: z
145
145
  .array(z.string())
@@ -270,20 +270,37 @@ export function registerLeadTools(server, client = getClient()) {
270
270
  // ── update_lead ────────────────────────────────────────────────────────
271
271
  server.tool("update_lead", "Update one or more fields on an existing lead. Each update specifies the field " +
272
272
  "name, new value, and optionally whether the field is a 'select' dropdown type. " +
273
- "Supports all field types including special fields (card_*, percent_*, boolean flags " +
273
+ "Supports all field types including special fields (percent_*, boolean flags " +
274
274
  "like decisionMaking/excluded). When a configured canonical relation field is changed, " +
275
275
  "employment fields are refused: use manage_person_employment. Other relationship types retain their own service. Returns the updated lead. " +
276
276
  "location must be the canonical object {city, region?, postalCode?, countryCode, street?}; never submit geo. " +
277
- "For bulk updates across many leads, call this tool once per lead.", {
277
+ "The qualification sheet (`qualification_document`) is never written by this tool: the server refuses it. " +
278
+ "Use the qualification tools (`submit_qualification_patch`) instead. " +
279
+ "An unknown field is refused with the name of the closest existing field: declare a new field first with `update_schema`. " +
280
+ "`expect` (optional) protects against overwriting a newer value: pass, for each field you depend on, the value you read " +
281
+ "({ property_name, value }). The write is refused with LEAD_WRITE_PRECONDITION_FAILED, and the value actually in place is " +
282
+ "returned, if any of them changed since your read. To protect the first write of an empty field, expect null: it matches a field that is null or absent. Without `expect`, the write is unconditional. " +
283
+ "`reason` (optional) is one sentence saying why you change the lead; it is recorded in the lead history. " +
284
+ "For bulk updates across many leads, call this tool once per lead. A write during a platform agent run on this lead (external agent only) is refused with 409 QUALIFICATION_RUN_IN_PROGRESS (runId in the answer); nothing is written: wait for the run to end, read the lead again, then retry.", {
278
285
  lead_id: z.string().describe("ID of the lead to update."),
279
286
  updates: z
280
287
  .array(leadUpdateSchema)
281
288
  .min(1)
282
289
  .describe("Array of field updates to apply."),
283
- }, async ({ lead_id, updates }) => {
290
+ expect: z
291
+ .array(z.object({
292
+ property_name: z.string().trim().min(1).describe("Field you read."),
293
+ value: z.unknown().describe("Exact value you read for that field. Required key; null means the field was empty (null or absent)."),
294
+ }).strict())
295
+ .optional()
296
+ .describe("Optional preconditions: the values you read earlier. The write is refused (LEAD_WRITE_PRECONDITION_FAILED, with the current values) if any field no longer holds its expected value. Deep equality, checked in the same transaction as the write."),
297
+ reason: z.string().trim().min(1).max(500).optional().describe("One sentence saying why this change is made; recorded in the lead history."),
298
+ }, async ({ lead_id, updates, expect, reason }) => {
284
299
  try {
285
300
  const body = {
286
301
  leadId: lead_id,
302
+ ...(expect ? { expect: expect.map((e) => ({ propertyName: e.property_name, value: e.value })) } : {}),
303
+ ...(reason ? { reason } : {}),
287
304
  updates: updates.map((u) => ({
288
305
  propertyName: u.property_name,
289
306
  value: u.value,
@@ -298,15 +315,17 @@ export function registerLeadTools(server, client = getClient()) {
298
315
  }
299
316
  });
300
317
  server.tool("clear_lead_field", "Irreversibly remove one stored value from a lead without changing its lead-group schema. " +
301
- "Canonical relationship fields require their dedicated relationship mutation. Always confirm with the user before calling this tool.", {
318
+ "Canonical relationship fields require their dedicated relationship mutation. Always confirm with the user before calling this tool. A write during a platform agent run on this lead (external agent only) is refused with 409 QUALIFICATION_RUN_IN_PROGRESS (runId in the answer); nothing is written: wait for the run to end, read the lead again, then retry.", {
302
319
  lead_id: z.string().describe("ID of the lead to change."),
303
320
  property_name: z.string().min(1).describe("Stored field name to clear. The field definition remains in the schema."),
304
321
  confirmation: z.literal("clear_lead_field").describe("Exact acknowledgement required before removing the stored value."),
305
- }, async ({ lead_id, property_name }) => {
322
+ reason: z.string().trim().min(1).max(1_000).optional().describe("One sentence saying why the value is removed; recorded in the lead's history."),
323
+ }, async ({ lead_id, property_name, reason }) => {
306
324
  try {
307
325
  return toolResult(await client.delete("/clear-lead-field", {
308
326
  leadId: lead_id,
309
327
  propertyName: property_name,
328
+ ...(reason ? { reason } : {}),
310
329
  }));
311
330
  }
312
331
  catch (error) {
@@ -0,0 +1,20 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { type LeadifyClient } from "../client.js";
3
+ import { type ToolResult } from "../types.js";
4
+ /**
5
+ * Qualification V3 pour un agent externe (PRD-2831). Chaque écriture porte une
6
+ * corrélation posée par le MCP (voir correlation.ts). Les noms de champs des
7
+ * outils sont en snake_case ; les enveloppes du contrat de qualification
8
+ * (`patch`) restent telles que le contrat les écrit.
9
+ */
10
+ type Client = Pick<LeadifyClient, "get" | "post" | "put">;
11
+ /** Dernière version du contrat connue du MCP ; `contract_version` la remplace si le serveur en annonce une autre. */
12
+ export declare const QUALIFICATION_CONTRACT_VERSION_DEFAULT = "3.8.0";
13
+ /** Fait suivre chaque refus du serveur d'une consigne claire quand le code est connu. */
14
+ export declare function qualificationError(error: unknown): ToolResult;
15
+ /** Outils qui ne font que lire : annotés readOnlyHint, utilisables sans nom d'agent. */
16
+ export declare const QUALIFICATION_READ_TOOL_NAMES: string[];
17
+ export declare const QUALIFICATION_WRITE_TOOL_NAMES: string[];
18
+ export declare const QUALIFICATION_TOOL_NAMES: string[];
19
+ export declare function registerQualificationTools(server: McpServer, client?: Client): void;
20
+ export {};
@@ -0,0 +1,411 @@
1
+ import { createHash } from "node:crypto";
2
+ import { z } from "zod";
3
+ import { getClient } from "../client.js";
4
+ import { ExternalAgentNameMissingError, camelizeKeys, externalCorrelation, externalCorrelationWithoutMission, readCorrelation, externalRunId, } from "../correlation.js";
5
+ import { LeadifyApiError, handleToolError, toolError, toolResult } from "../types.js";
6
+ /** Dernière version du contrat connue du MCP ; `contract_version` la remplace si le serveur en annonce une autre. */
7
+ export const QUALIFICATION_CONTRACT_VERSION_DEFAULT = "3.8.0";
8
+ const SIGNAL_CONTRACT_VERSION = "1.4.0";
9
+ const EVENT_CONTRACT_VERSION = "1.1.0";
10
+ const EMPLOYER_CONTRACT_VERSION = "2.0.0";
11
+ const CONTACT_CONTRACT_VERSION = "1.0.0";
12
+ const LOCATION_CONTRACT_VERSION = "1.0.0";
13
+ const WORKING_CORPUS_CONTRACT_VERSION = "1.0.0";
14
+ const id = z.string().trim().min(1).max(200);
15
+ const org = id.describe("Organization ID, explicitly selected (discover_leadify_context). Never defaulted.");
16
+ const sessionRef = z.string().trim().min(1).max(80).optional().describe("Optional. Names this working session; defaults to a random id per MCP process. The correlation (external:<agent>:<session>) is filled in by the MCP.");
17
+ const dryRun = z.boolean().default(true).describe("Defaults to true: the server validates and answers without writing (persisted=false). Send false to write.");
18
+ const idempotencyKey = z.string().trim().min(8).max(200).optional().describe("Stable key for this exact write. Omitted: derived from the content, so retrying the same call is safe.");
19
+ const publicUrl = z.string().trim().url().max(2_000);
20
+ const sourceUrls = z.array(publicUrl).min(1).max(10).describe("Public http(s) pages that prove the value.");
21
+ const sourced = (value) => z.object({ value, source_urls: sourceUrls }).strict();
22
+ const day = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "YYYY-MM-DD");
23
+ const dayOrMonth = z.string().regex(/^\d{4}-(?:0[1-9]|1[0-2])(?:-(?:0[1-9]|[12]\d|3[01]))?$/, "YYYY-MM or YYYY-MM-DD");
24
+ function derivedKey(prefix, payload) {
25
+ return `${prefix}-${createHash("sha256").update(JSON.stringify(payload)).digest("hex").slice(0, 40)}`;
26
+ }
27
+ /** Fait suivre chaque refus du serveur d'une consigne claire quand le code est connu. */
28
+ export function qualificationError(error) {
29
+ if (error instanceof ExternalAgentNameMissingError)
30
+ return toolError(error.message);
31
+ if (!(error instanceof LeadifyApiError))
32
+ return handleToolError(error);
33
+ const body = (error.responseBody && typeof error.responseBody === "object" ? error.responseBody : {});
34
+ const code = typeof body.error === "string" ? body.error : undefined;
35
+ let next_step;
36
+ if (code === "QUALIFICATION_RUN_IN_PROGRESS") {
37
+ next_step = `Un passage de l'agent de qualification (runId ${String(body.runId ?? "inconnu")}) traite cette fiche. Rien n'a été écrit : attendez la fin du passage, relisez avec get_qualification_context, puis réessayez.`;
38
+ }
39
+ else if (code === "QUALIFICATION_RUN_CHECK_UNAVAILABLE") {
40
+ next_step = "Le serveur n'a pas pu vérifier si un passage est en cours. Rien n'a été écrit : réessayez dans un instant.";
41
+ }
42
+ else if (code === "QUALIFICATION_CONTRACT_REFUSED" && Array.isArray(body.issues)) {
43
+ const lines = body.issues
44
+ .map((i) => `${String(i.path || "(racine)")} : ${String(i.code)} - ${String(i.message)}`);
45
+ next_step = `Le contrat refuse l'écriture. Corrigez chaque chemin, puis réessayez (validate_qualification_section peut l'essayer case par case) :\n${lines.join("\n")}`;
46
+ }
47
+ else if (code && /^QUALIFY_SUBMISSION_(SCHEMA_STALE|LEAD_STALE|CONTEXT_CHANGED)$/.test(code)) {
48
+ next_step = "Les gardes (expected_schema_hash, expected_lead_revision, expected_context_digest) sont périmées. Rien n'a été écrit : relisez get_qualification_context, reprenez les gardes rendues, vérifiez que votre patch tient encore, puis réessayez.";
49
+ }
50
+ else if (code === "QUALIFICATION_RULES_NOT_CONFIGURED") {
51
+ next_step = "Le registre de prompts de la plateforme n'est pas configuré sur ce serveur. Réessayer ne servira à rien : prévenez l'administrateur, et n'improvisez pas les règles.";
52
+ }
53
+ else if (code === "QUALIFICATION_RULES_UNAVAILABLE") {
54
+ next_step = "Le registre de prompts de la plateforme ne répond pas. Aucune règle n'est rendue : réessayez dans un instant, sans improviser les règles.";
55
+ }
56
+ return toolError(error.message, {
57
+ statusCode: error.statusCode,
58
+ response: error.responseBody,
59
+ ...(next_step ? { next_step } : {}),
60
+ });
61
+ }
62
+ function run(client, fn) {
63
+ return Promise.resolve().then(fn).then(toolResult, qualificationError);
64
+ }
65
+ // -- Schémas de charges utiles, décrits en snake_case -----------------------
66
+ const factualDate = z.object({ date: dayOrMonth, evidence: z.string().trim().min(1).describe("Quote or location that proves this date.") }).strict();
67
+ const signalPayload = z.object({
68
+ evidence_type: z.enum(["PUBLIC_CONTENT", "PUBLIC_EVENT", "DIRECT_INTENT"]),
69
+ title: z.string().trim().min(1),
70
+ preview: z.string().trim().min(1),
71
+ content: z.string().trim().min(1).describe("Exactly 3 paragraphs separated by a blank line: the dated fact, the concrete substance, the commercial read."),
72
+ dates: z.object({
73
+ publication: factualDate.optional(),
74
+ update: factualDate.optional(),
75
+ event: z.object({ start: dayOrMonth, end: day.optional().describe("Exact day, YYYY-MM-DD."), evidence: z.string().trim().min(1) }).strict().optional(),
76
+ }).strict().describe("At least one proven publication, update or event date is required."),
77
+ source_url: publicUrl.describe("Exact public page (a LinkedIn post permalink, never a /in/ profile)."),
78
+ source_label: z.string().trim().min(1),
79
+ confidence: z.enum(["medium", "high"]),
80
+ level: z.enum(["INFO", "STRONG", "CRITICAL", "GOLDEN"]),
81
+ commercial_evidence: z.string().trim().min(1).optional().describe("Required for STRONG, CRITICAL and GOLDEN."),
82
+ commercial_pressure_type: z.enum([
83
+ "EXPLICIT_PROBLEM_OR_RISK", "EXPLICIT_DEADLINE_OR_MANDATE", "ACTIVE_PROCUREMENT_OR_EVALUATION", "ACTIVE_CONTACT_WINDOW",
84
+ "INCIDENT_OR_ENFORCEMENT", "CONTRACT_RENEWAL_OR_REPLACEMENT", "VERIFIED_OPERATIONAL_BOTTLENECK", "REGULATORY_CLEARANCE_OR_CERTIFICATION",
85
+ ]).optional().describe("Required for STRONG, CRITICAL and GOLDEN. GOLDEN needs DIRECT_INTENT, ACTIVE_CONTACT_WINDOW or ACTIVE_PROCUREMENT_OR_EVALUATION."),
86
+ }).strict();
87
+ const provenDate = z.object({ date: day, evidence: z.string().trim().min(12).max(2_000) }).strict();
88
+ const eventRef = z.object({
89
+ event_id: id.optional().describe("An event already read from the history. Never combined with creation fields."),
90
+ name: z.string().trim().min(1).max(300).optional().describe("Event name without the year. Required without event_id."),
91
+ edition: z.string().trim().min(1).max(200).optional().describe("Edition year alone, e.g. 2026. Required without event_id."),
92
+ starts_on: provenDate.optional().describe("Required without event_id."),
93
+ ends_on: provenDate.optional(),
94
+ city: z.string().trim().min(1).max(500).nullable().optional(),
95
+ country_code: z.string().trim().regex(/^[A-Za-z]{2}$/).nullable().optional().describe("ISO-2."),
96
+ venue: z.string().trim().min(1).max(500).nullable().optional(),
97
+ official_url: publicUrl.nullable().optional(),
98
+ event_type: z.enum(["WEBINAR", "CONGRESS", "TRADE_SHOW", "OTHER"]).optional(),
99
+ not_duplicate_of: z.array(id).max(20).optional().describe("Only after a QUALIFY_EVENT_AMBIGUOUS refusal: candidate ids the source proves are different events."),
100
+ }).strict();
101
+ const participation = z.object({
102
+ roles: z.array(z.enum(["EXHIBITOR", "SPONSOR", "SPEAKER", "PARTNER", "ORGANIZER", "CONTRIBUTOR", "PARTICIPANT", "UNKNOWN", "OTHER", "REGISTERED", "ATTENDED", "INVITED"])).min(1).max(20)
103
+ .describe("PERSON target: REGISTERED, ATTENDED, INVITED, SPEAKER, ORGANIZER, OTHER. COMPANY target: all except REGISTERED/ATTENDED/INVITED. A registration never proves attendance."),
104
+ role_details: z.array(z.string().trim().min(1).max(500)).max(50).default([]),
105
+ stands: z.array(z.string().trim().min(1).max(200)).max(50).default([]),
106
+ source_urls: z.array(publicUrl.max(2_048)).max(50).default([]).describe("Required unless client_document proves the participation."),
107
+ client_document: z.object({
108
+ file_name: z.string().trim().min(1).max(300),
109
+ received_on: day,
110
+ provided_by: z.string().trim().min(1).max(300),
111
+ }).strict().nullable().optional().describe("Document supplied by the client (no web address)."),
112
+ source_provider: z.string().trim().min(1).max(500).nullable().optional(),
113
+ activity_summary: z.string().trim().max(5_000).nullable().optional(),
114
+ proof_excerpt: z.string().trim().min(1).max(20_000).optional().describe("Readable excerpt quoting the proof. Required when creating."),
115
+ observed_on: day,
116
+ verified_on: day.nullable().optional().describe("Required when status is VERIFIED; never before observed_on."),
117
+ status: z.enum(["UNVERIFIED", "VERIFIED", "DISPUTED", "REFUTED"]),
118
+ correction_reason: z.string().trim().min(1).max(5_000).optional().describe("Required when correcting (not merely corroborating) the current snapshot."),
119
+ }).strict();
120
+ const sourcedText = (max) => sourced(z.string().trim().min(1).max(max));
121
+ const canonicalLocation = z.object({
122
+ city: z.string().trim().min(1).max(200).optional(),
123
+ region: z.string().trim().min(1).max(200).optional(),
124
+ postal_code: z.string().trim().min(1).max(200).optional(),
125
+ country_code: z.string().trim().regex(/^[A-Z]{2}$/),
126
+ street: z.string().trim().min(1).max(500).optional(),
127
+ }).strict();
128
+ const proposedCandidate = z.object({
129
+ display_name: sourcedText(300),
130
+ company_name: sourcedText(300).optional(),
131
+ first_name: sourcedText(150).optional(),
132
+ last_name: sourcedText(150).optional(),
133
+ job_title: sourcedText(300).optional(),
134
+ email: sourced(z.string().trim().email().max(320)).optional(),
135
+ linkedin_url: sourced(z.string().url()).optional().describe("A LinkedIn person URL (https://www.linkedin.com/in/...)."),
136
+ rpps_number: sourced(z.string().trim().regex(/^\d{11}$/)).optional().describe("11-digit RPPS, never invented."),
137
+ location: sourced(canonicalLocation).optional(),
138
+ image_url: sourced(z.string().url()).optional(),
139
+ provider_record: z.object({ provider: id, id, source_urls: sourceUrls }).strict().optional(),
140
+ }).strict();
141
+ const longSourceUrls = z.array(z.string().min(12).max(2_000).regex(/^https?:\/\/\S+$/)).min(1).max(10);
142
+ const sourcedLong = (max) => z.object({ value: z.string().trim().min(1).max(max), source_urls: longSourceUrls }).strict();
143
+ const discoverCandidate = z.object({
144
+ display_name: sourcedLong(500),
145
+ company_name: sourcedLong(500),
146
+ first_name: sourcedLong(500).optional(),
147
+ last_name: sourcedLong(500).optional(),
148
+ job_title: sourcedLong(500).optional(),
149
+ email: z.object({ value: z.string().min(3).max(320).regex(/^[^@\s]+@[^@\s]+\.[^@\s]+$/), source_urls: longSourceUrls }).strict().optional(),
150
+ linkedin_url: z.object({ value: z.string().min(12).max(500).regex(/^https?:\/\/([A-Za-z0-9-]+\.)*linkedin\.com\/in\/\S+$/), source_urls: longSourceUrls }).strict().optional(),
151
+ location: sourcedLong(500).optional(),
152
+ image_url: z.object({ value: z.string().min(12).max(2_000).regex(/^https?:\/\/\S+$/), source_urls: longSourceUrls }).strict().optional(),
153
+ provider_record: z.object({ provider: z.string().trim().min(1).max(200), record_id: z.string().trim().min(1).max(500), source_urls: longSourceUrls }).strict().optional(),
154
+ }).strict();
155
+ const employerCompany = z.object({
156
+ company_lead_id: id.optional().describe("Exact id of a candidate from qualification_search_employer. Never guessed from a domain or SIREN."),
157
+ company_name: sourcedText(500).optional(),
158
+ company_website: sourced(z.string().trim().url().max(2_000)).optional(),
159
+ finess: sourcedText(40).optional(),
160
+ location: sourced(z.object({
161
+ city: z.string().trim().min(1).max(200).optional(), postal_code: z.string().trim().min(1).max(40).optional(),
162
+ region: z.string().trim().min(1).max(200).optional(), country_code: z.string().trim().min(2).max(3).optional(),
163
+ street: z.string().trim().min(1).max(500).optional(),
164
+ }).strict()).optional(),
165
+ legal_identity: z.object({
166
+ legal_name: sourcedText(500).optional(), siren: sourcedText(40).optional(), siret: sourcedText(40).optional(),
167
+ foreign_registration_number: sourcedText(80).optional(), country_code: sourced(z.string().trim().min(2).max(3)).optional(),
168
+ }).strict().optional(),
169
+ }).strict();
170
+ // ---------------------------------------------------------------------------
171
+ /** Outils qui ne font que lire : annotés readOnlyHint, utilisables sans nom d'agent. */
172
+ export const QUALIFICATION_READ_TOOL_NAMES = [
173
+ "get_qualification_context", "get_qualification_document", "get_qualification_rules", "validate_qualification_section",
174
+ "list_qualification_versions", "get_qualification_version", "qualification_search_employer", "qualification_readback_contact_proposal",
175
+ "qualification_get_working_corpus", "qualification_read_history", "qualification_read_known_contacts", "qualification_read_employment_evidence",
176
+ ];
177
+ export const QUALIFICATION_WRITE_TOOL_NAMES = [
178
+ "submit_qualification_patch", "restore_qualification_version", "mark_for_requalification", "qualification_write_signal", "qualification_record_event",
179
+ "qualification_link_employer", "qualification_propose_contacts", "qualification_finalize_contacts", "qualification_discover_contact",
180
+ "qualification_enrich_email", "qualification_geocode_location", "qualification_save_working_corpus",
181
+ ];
182
+ export const QUALIFICATION_TOOL_NAMES = [...QUALIFICATION_READ_TOOL_NAMES, ...QUALIFICATION_WRITE_TOOL_NAMES];
183
+ export function registerQualificationTools(server, client = getClient()) {
184
+ const post = (path, body) => client.post(`/api/qualify-lead${path}`, body);
185
+ const def = (name, description, shape, cb) => {
186
+ const readOnly = QUALIFICATION_READ_TOOL_NAMES.includes(name);
187
+ server.tool(name, description, shape, { readOnlyHint: readOnly, destructiveHint: false }, cb);
188
+ };
189
+ // -- Lectures ------------------------------------------------------------
190
+ def("get_qualification_context", "Read the qualification input for one lead: the agent input, the current qualification sheet with its authors, the business-field policy, " +
191
+ "readiness, and the three freshness guards (guards.expected_schema_hash, expected_lead_revision, expected_context_digest) that submit_qualification_patch requires. " +
192
+ "Read again before every write: the guards change after each write or schema change. Read-only.", { organization_id: org, lead_id: id, lead_group_id: id }, ({ organization_id, lead_id, lead_group_id }) => run(client, () => post("/v3/context", { organizationId: organization_id, leadId: lead_id, leadGroupId: lead_group_id })));
193
+ def("get_qualification_document", "Read the current qualification sheet of a lead (qualification_document, with authors and any issues), the freshness guards and readiness, " +
194
+ "plus the summary list of its 10 latest versions. Use get_qualification_version for one full version. Read-only.", { organization_id: org, lead_id: id, lead_group_id: id }, ({ organization_id, lead_id, lead_group_id }) => run(client, async () => {
195
+ const context = await post("/v3/context", { organizationId: organization_id, leadId: lead_id, leadGroupId: lead_group_id });
196
+ const versions = await client.get("/api/qualify-lead/v3/versions", new URLSearchParams({ organizationId: organization_id, leadId: lead_id, limit: "10" }));
197
+ return {
198
+ lead_id,
199
+ guards: context.guards,
200
+ qualification_document: context.record?.qualification_document ?? null,
201
+ qualification_document_issues: context.record?.qualification_document_issues ?? [],
202
+ readiness: context.readiness,
203
+ versions: versions?.versions ?? versions,
204
+ };
205
+ }));
206
+ def("get_qualification_rules", "Read the qualification rules as the platform's qualification agent receives them (from the platform prompt registry), for a company or a person. " +
207
+ "Do not improvise rules: if it fails with QUALIFICATION_RULES_UNAVAILABLE, retry later; with QUALIFICATION_RULES_NOT_CONFIGURED the platform prompt registry is not configured on this server: stop and tell the administrator, retrying will not help. Read-only.", { organization_id: org, entity: z.enum(["company", "person"]) }, ({ organization_id, entity }) => run(client, () => client.get("/api/qualify-lead/rules", new URLSearchParams({ organizationId: organization_id, entity }))));
208
+ def("list_qualification_versions", "List the versions of a lead's qualification sheet, most recent first (summary only, with authors). Read-only.", { organization_id: org, lead_id: id, limit: z.number().int().min(1).max(200).optional() }, ({ organization_id, lead_id, limit }) => run(client, () => {
209
+ const q = new URLSearchParams({ organizationId: organization_id, leadId: lead_id });
210
+ if (limit)
211
+ q.set("limit", String(limit));
212
+ return client.get("/api/qualify-lead/v3/versions", q);
213
+ }));
214
+ def("get_qualification_version", "Read one version of a qualification sheet in full, to compare it with the current one or before restoring it. Read-only.", { organization_id: org, submission_id: id.describe("Id of a version from list_qualification_versions.") }, ({ organization_id, submission_id }) => run(client, () => client.get(`/api/qualify-lead/v3/versions/${encodeURIComponent(submission_id)}`, new URLSearchParams({ organizationId: organization_id }))));
215
+ def("validate_qualification_section", "Try one section of the qualification sheet against the contract with the lead's current input and sheet. Writes nothing. " +
216
+ "Use it before submit_qualification_patch to fix each refused path. `patch` uses the contract's own snake_case keys and must carry entity_type: \"company\" or \"person\" (matching the lead's group).", {
217
+ organization_id: org, lead_id: id, lead_group_id: id,
218
+ section: z.string().trim().min(1).describe("A section of the write order (see the contract / get_qualification_rules)."),
219
+ patch: z.record(z.string(), z.unknown()).describe("The section's patch, in the contract's own keys."),
220
+ session_ref: sessionRef,
221
+ }, ({ organization_id, lead_id, lead_group_id, section, patch, session_ref }) => run(client, async () => {
222
+ let triggerRunId;
223
+ try {
224
+ triggerRunId = externalRunId(session_ref);
225
+ }
226
+ catch {
227
+ triggerRunId = undefined;
228
+ }
229
+ return post("/v3/validate-section", { organizationId: organization_id, leadId: lead_id, leadGroupId: lead_group_id, section, patch, ...(triggerRunId ? { triggerRunId } : {}) });
230
+ }));
231
+ // -- Fiche -----------------------------------------------------------------
232
+ def("submit_qualification_patch", "Write a patch to a lead's qualification sheet, judged by the qualification contract. dry_run defaults to TRUE: validate first, read the diff, then send dry_run=false. " +
233
+ "Guards (expected_schema_hash, expected_lead_revision, expected_context_digest) come unchanged from get_qualification_context; stale guards are refused and nothing is written. " +
234
+ "`reason` is required: one short, concrete sentence saying what changes, why and on which proof. The server stores it on the version (shown in the sheet's history) and it is part of the derived idempotency key. " +
235
+ "The write is refused with QUALIFICATION_RUN_IN_PROGRESS while the platform agent processes this lead. Every contract refusal returns each path to fix. " +
236
+ "`patch` uses the contract's own snake_case keys and must carry entity_type (\"company\" or \"person\", matching the lead's group). The MCP builds the envelope (lead_id, run_id, contract_version) and the external-agent correlation.", {
237
+ organization_id: org, lead_group_id: id, lead_id: id,
238
+ patch: z.record(z.string(), z.unknown()).describe("QualificationPatchV3, in the contract's own keys."),
239
+ expected_schema_hash: z.string().trim().min(1), expected_lead_revision: z.number().int().min(0), expected_context_digest: z.string().trim().min(1),
240
+ reason: z.string().trim().min(1).max(1_000),
241
+ contract_version: z.string().trim().min(1).optional().describe(`Contract version from get_qualification_context. Defaults to ${QUALIFICATION_CONTRACT_VERSION_DEFAULT}.`),
242
+ dry_run: dryRun, idempotency_key: idempotencyKey, session_ref: sessionRef,
243
+ }, (a) => run(client, async () => {
244
+ const correlation = externalCorrelation(a.session_ref);
245
+ const submission = {
246
+ contract_version: a.contract_version ?? QUALIFICATION_CONTRACT_VERSION_DEFAULT,
247
+ lead_id: a.lead_id, run_id: correlation.triggerRunId,
248
+ expected_schema_hash: a.expected_schema_hash, expected_lead_revision: a.expected_lead_revision,
249
+ expected_context_digest: a.expected_context_digest, patch: a.patch,
250
+ };
251
+ const result = await post("/v3/submit", {
252
+ organizationId: a.organization_id, leadGroupId: a.lead_group_id,
253
+ idempotencyKey: a.idempotency_key ?? derivedKey("mcp-qual", { submission, reason: a.reason }),
254
+ dryRun: a.dry_run, reason: a.reason, correlation, submission,
255
+ });
256
+ return result;
257
+ }));
258
+ def("restore_qualification_version", "Restore a previous version of a qualification sheet as a new write, judged by the contract. dry_run defaults to TRUE. `reason` is required and recorded by the server. " +
259
+ "expected_lead_revision comes from get_qualification_context. Refused with QUALIFICATION_RUN_IN_PROGRESS during an agent run.", {
260
+ organization_id: org, lead_group_id: id, lead_id: id, submission_id: id.describe("Version to restore (list_qualification_versions)."),
261
+ expected_lead_revision: z.number().int().min(0), reason: z.string().trim().min(1).max(1_000),
262
+ dry_run: dryRun, idempotency_key: idempotencyKey, session_ref: sessionRef,
263
+ }, (a) => run(client, () => post("/v3/restore", {
264
+ organizationId: a.organization_id, leadGroupId: a.lead_group_id, leadId: a.lead_id, submissionId: a.submission_id,
265
+ idempotencyKey: a.idempotency_key ?? derivedKey("mcp-restore", { l: a.lead_id, s: a.submission_id, r: a.expected_lead_revision }),
266
+ expectedLeadRevision: a.expected_lead_revision, reason: a.reason, dryRun: a.dry_run, correlation: externalCorrelation(a.session_ref),
267
+ })));
268
+ def("mark_for_requalification", "Mark a lead for requalification with a reason. It launches no run: the platform's scheduled pass picks it up. Refused during an agent run.", { organization_id: org, lead_id: id, reason: z.string().trim().min(1).max(1_000), session_ref: sessionRef }, (a) => run(client, () => post("/v3/requalify", { organizationId: a.organization_id, leadId: a.lead_id, reason: a.reason, correlation: externalCorrelation(a.session_ref) })));
269
+ // -- Signaux et événements ---------------------------------------------------
270
+ def("qualification_write_signal", "Write one dated, sourced signal on a lead (qualification contract). dry_run defaults to TRUE. content is exactly 3 paragraphs (dated fact, substance, commercial read). " +
271
+ "STRONG/CRITICAL/GOLDEN need commercial_evidence and commercial_pressure_type; a person's event participation signal is written by the product, not here.", { organization_id: org, lead_id: id, lead_group_id: id, account_id: id.optional(), signal: signalPayload, dry_run: dryRun, idempotency_key: idempotencyKey, session_ref: sessionRef }, (a) => run(client, () => {
272
+ const signal = camelizeKeys(a.signal);
273
+ return post("/signals", {
274
+ contractVersion: SIGNAL_CONTRACT_VERSION, organizationId: a.organization_id, leadId: a.lead_id, leadGroupId: a.lead_group_id,
275
+ ...(a.account_id ? { accountId: a.account_id } : {}),
276
+ idempotencyKey: a.idempotency_key ?? derivedKey("mcp-signal", { l: a.lead_id, signal }),
277
+ dryRun: a.dry_run, signal, correlation: externalCorrelation(a.session_ref),
278
+ });
279
+ }));
280
+ def("qualification_record_event", "Record an event and a participation (company or person) on a lead. dry_run defaults to TRUE. target defaults to COMPANY; PERSON writes on the person lead (no account_id, no witness, person roles only). " +
281
+ "Either event_id (known event) or name+edition+starts_on (new/matching event). A registration (REGISTERED) never proves attendance (ATTENDED).", {
282
+ organization_id: org, lead_id: id, lead_group_id: id, account_id: id.optional(),
283
+ target: z.enum(["COMPANY", "PERSON"]).optional(),
284
+ event: eventRef, participation,
285
+ witness: z.object({ person_lead_id: id, person_lead_group_id: id }).strict().optional().describe("COMPANY target only: a person whose presence is proven."),
286
+ dry_run: dryRun, idempotency_key: idempotencyKey, session_ref: sessionRef,
287
+ }, (a) => run(client, () => {
288
+ const body = camelizeKeys({ event: a.event, participation: a.participation, ...(a.witness ? { witness: a.witness } : {}) });
289
+ return post("/events", {
290
+ contractVersion: EVENT_CONTRACT_VERSION, ...(a.target ? { target: a.target } : {}),
291
+ organizationId: a.organization_id, leadId: a.lead_id, leadGroupId: a.lead_group_id,
292
+ ...(a.account_id ? { accountId: a.account_id } : {}),
293
+ idempotencyKey: a.idempotency_key ?? derivedKey("mcp-event", { l: a.lead_id, t: a.target, body }),
294
+ dryRun: a.dry_run, ...body, correlation: externalCorrelation(a.session_ref),
295
+ });
296
+ }));
297
+ // -- Employeur -------------------------------------------------------------
298
+ def("qualification_search_employer", "Search the organization's company leads for a person's employer. Returns candidates and a searchReceipt to copy unchanged into qualification_link_employer (valid 30 minutes). " +
299
+ "A search with totalCandidates=0 is what allows creating the company. Read-only.", {
300
+ organization_id: org, person_lead_id: id, person_lead_group_id: id,
301
+ query: z.object({ company_name: z.string().trim().min(1).max(500), city: z.string().trim().min(1).max(200).optional(),
302
+ finess: z.string().trim().min(1).max(40).optional(), siren: z.string().trim().min(1).max(40).optional() }).strict(),
303
+ after_company_lead_id: id.optional().describe("nextAfterCompanyLeadId of the previous page; keep the same criteria."),
304
+ session_ref: sessionRef,
305
+ }, (a) => run(client, () => post("/employer/search", {
306
+ contractVersion: EMPLOYER_CONTRACT_VERSION, organizationId: a.organization_id, personLeadId: a.person_lead_id, personLeadGroupId: a.person_lead_group_id,
307
+ query: camelizeKeys(a.query), ...(a.after_company_lead_id ? { afterCompanyLeadId: a.after_company_lead_id } : {}),
308
+ correlation: readCorrelation(a.session_ref),
309
+ })));
310
+ def("qualification_link_employer", "Attach a person to their employer company (an existing candidate by company_lead_id, or a new company), with evidence. Needs the search_receipt from qualification_search_employer. " +
311
+ "Closes previous employments unless close_previous_employment=false (a proven cumulative job only). Refused during an agent run.", {
312
+ organization_id: org, person_lead_id: id, person_lead_group_id: id, company: employerCompany,
313
+ search_receipt: z.string().min(1).max(64_000).describe("Copied unchanged from qualification_search_employer."),
314
+ evidence: z.object({
315
+ provider: z.string().trim().min(1).max(120), source_url: publicUrl, excerpt: z.string().trim().min(1).max(20_000),
316
+ observed_at: z.string().datetime({ offset: true }).describe("ISO 8601 with timezone."), verified_at: z.string().datetime({ offset: true }).describe("ISO 8601 with timezone, never before observed_at."),
317
+ }).strict(),
318
+ close_previous_employment: z.boolean().default(true), reason: z.string().trim().min(1).max(2_000),
319
+ idempotency_key: idempotencyKey, session_ref: sessionRef,
320
+ }, (a) => run(client, () => {
321
+ const company = camelizeKeys(a.company);
322
+ const evidence = camelizeKeys(a.evidence);
323
+ return post("/employer", {
324
+ contractVersion: EMPLOYER_CONTRACT_VERSION, organizationId: a.organization_id, personLeadId: a.person_lead_id, personLeadGroupId: a.person_lead_group_id,
325
+ company, searchReceipt: a.search_receipt, evidence, closePreviousEmployment: a.close_previous_employment, reason: a.reason,
326
+ idempotencyKey: a.idempotency_key ?? derivedKey("mcp-employer", { p: a.person_lead_id, company, evidence, c: a.close_previous_employment }),
327
+ correlation: externalCorrelation(a.session_ref),
328
+ });
329
+ }));
330
+ // -- Contacts découverts -----------------------------------------------------
331
+ def("qualification_propose_contacts", "Propose one discovered contact (a person found while qualifying a lead) for the lead's person group. dry_run defaults to TRUE. " +
332
+ "Every value carries its public source_urls. Check qualification_read_known_contacts first to avoid duplicates. Call once per candidate, then qualification_finalize_contacts.", { organization_id: org, source_lead_id: id, lead_group_id: id, candidate: proposedCandidate, dry_run: dryRun, idempotency_key: idempotencyKey, session_ref: sessionRef }, (a) => run(client, () => {
333
+ const candidate = camelizeKeys(a.candidate);
334
+ return post("/discovered-contacts/propose", {
335
+ contractVersion: CONTACT_CONTRACT_VERSION, organizationId: a.organization_id, sourceLeadId: a.source_lead_id, leadGroupId: a.lead_group_id,
336
+ idempotencyKey: a.idempotency_key ?? derivedKey("mcp-contact", { s: a.source_lead_id, candidate }),
337
+ dryRun: a.dry_run, candidate, correlation: externalCorrelationWithoutMission(a.session_ref),
338
+ });
339
+ }));
340
+ def("qualification_finalize_contacts", "Finalize the contacts proposed in this session (same session_ref) for a lead: creates the person leads. With readback_only=true it only reads the state of that finalization (ready, complete, per-proposal outcome) and writes nothing. " +
341
+ "Refused during an agent run.", { organization_id: org, source_lead_id: id, lead_group_id: id, account_run_id: id.optional(), readback_only: z.boolean().default(false), session_ref: sessionRef }, (a) => run(client, () => post(a.readback_only ? "/discovered-contacts/finalization-readback" : "/discovered-contacts/finalize", {
342
+ contractVersion: CONTACT_CONTRACT_VERSION, organizationId: a.organization_id, sourceLeadId: a.source_lead_id, leadGroupId: a.lead_group_id,
343
+ sourceTriggerRunId: externalRunId(a.session_ref), ...(a.account_run_id ? { accountRunId: a.account_run_id } : {}),
344
+ })));
345
+ def("qualification_readback_contact_proposal", "Read back one proposed contact by proposal_id (as returned by qualification_propose_contacts). Read-only.", { organization_id: org, proposal_id: id }, (a) => run(client, () => post("/discovered-contacts/readback", { organizationId: a.organization_id, proposalId: a.proposal_id })));
346
+ def("qualification_discover_contact", "Propose a discovered contact for a COMPANY lead through the discovery route, with the persona-eligibility decision and the candidates screened out. dry_run defaults to TRUE. " +
347
+ "screened_out must be non-empty unless no_other_person_found=true (and then it must be empty).", {
348
+ organization_id: org, lead_group_id: id, lead_id: id, candidate: discoverCandidate,
349
+ persona_eligibility: z.object({ decision: z.literal("eligible"), reason: z.string().trim().min(1).max(1_000), source_urls: longSourceUrls }).strict(),
350
+ screened_out: z.array(z.object({
351
+ label: z.string().trim().min(1).max(500).optional(),
352
+ reason: z.enum(["same_as_subject", "not_same_company", "not_relevant", "identity_ambiguous", "insufficient_current_evidence", "search_noise"]),
353
+ source_urls: longSourceUrls,
354
+ }).strict()).optional(),
355
+ no_other_person_found: z.boolean().optional(),
356
+ dry_run: dryRun, idempotency_key: idempotencyKey, session_ref: sessionRef,
357
+ }, (a) => run(client, () => {
358
+ const rest = camelizeKeys({ candidate: a.candidate, persona_eligibility: a.persona_eligibility, screened_out: a.screened_out, no_other_person_found: a.no_other_person_found });
359
+ return post("/discover-contact", {
360
+ contractVersion: CONTACT_CONTRACT_VERSION, organizationId: a.organization_id, leadGroupId: a.lead_group_id, leadId: a.lead_id,
361
+ idempotencyKey: a.idempotency_key ?? derivedKey("mcp-discover", { l: a.lead_id, rest }),
362
+ dryRun: a.dry_run, ...rest, correlation: externalCorrelation(a.session_ref),
363
+ });
364
+ }));
365
+ // -- Enrichissements ---------------------------------------------------------
366
+ def("qualification_enrich_email", "Look up a person's professional email (provider enrichment) after their qualification sheet is persisted. Writes the email when found. Uses the session id as the run id.", { organization_id: org, lead_id: id, lead_group_id: id, qualification_submission_id: id.optional(), session_ref: sessionRef }, (a) => run(client, () => {
367
+ const runId = externalRunId(a.session_ref);
368
+ return post("/email-enrichment", {
369
+ contractVersion: "1.0.0", organizationId: a.organization_id, leadId: a.lead_id, leadGroupId: a.lead_group_id,
370
+ ...(a.qualification_submission_id ? { qualificationSubmissionId: a.qualification_submission_id } : {}),
371
+ qualificationTriggerRunId: runId, traceId: runId,
372
+ });
373
+ }));
374
+ def("qualification_geocode_location", "Geocode a lead's canonical location after it changed. Pass requested_source_location_hash (sha256:...) to refuse if the location changed meanwhile.", {
375
+ organization_id: org, lead_id: id, lead_group_id: id,
376
+ requested_source_location_hash: z.string().regex(/^sha256:[a-f0-9]{64}$/).optional(),
377
+ reason: z.enum(["qualification_location_changed", "manual_location_changed", "backfill"]).default("qualification_location_changed"),
378
+ session_ref: sessionRef,
379
+ }, (a) => run(client, async () => {
380
+ externalRunId(a.session_ref); // refuse without an agent name, like every write
381
+ return post("/location-geocode", {
382
+ contractVersion: LOCATION_CONTRACT_VERSION, organizationId: a.organization_id, leadId: a.lead_id, leadGroupId: a.lead_group_id,
383
+ ...(a.requested_source_location_hash ? { requestedSourceLocationHash: a.requested_source_location_hash } : {}), reason: a.reason,
384
+ });
385
+ }));
386
+ // -- Corpus de travail -------------------------------------------------------
387
+ def("qualification_get_working_corpus", "Read the frozen working corpus (research text) saved for a company lead. Answers WORKING_CORPUS_NOT_STORED (404) when none was saved. Read-only.", { organization_id: org, company_lead_id: id }, (a) => run(client, () => client.get("/api/qualify-lead/working-corpus", new URLSearchParams({ organizationId: a.organization_id, companyLeadId: a.company_lead_id }))));
388
+ def("qualification_save_working_corpus", "Save the frozen working corpus of a company lead (up to 12000 characters), so the people of that company are qualified from the same research.", { organization_id: org, company_lead_id: id, text: z.string().min(1).max(12_000), frozen_at: z.string().datetime({ offset: true }).optional().describe("ISO 8601; defaults to now."), session_ref: sessionRef }, (a) => run(client, () => client.put("/api/qualify-lead/working-corpus", {
389
+ contractVersion: WORKING_CORPUS_CONTRACT_VERSION, organizationId: a.organization_id, companyLeadId: a.company_lead_id, text: a.text,
390
+ triggerRunId: externalRunId(a.session_ref), frozenAt: a.frozen_at ?? new Date().toISOString(),
391
+ })));
392
+ // -- Lectures de recherche ---------------------------------------------------
393
+ def("qualification_read_history", "Read the lead's qualification history (past submissions, signals, events). Read-only.", { organization_id: org, lead_id: id, lead_group_id: id, limit: z.number().int().min(1).max(50).default(20), force: z.boolean().default(false).describe("Bypass the freshness gates, as the platform input does.") }, (a) => run(client, () => post("/history", { organizationId: a.organization_id, leadId: a.lead_id, leadGroupId: a.lead_group_id, limit: a.limit, force: a.force })));
394
+ def("qualification_read_known_contacts", "Check up to 30 candidate contacts against the people already in the lead group (by email, LinkedIn, RPPS, name) before proposing them. Writes nothing.", {
395
+ organization_id: org, source_lead_id: id, lead_group_id: id,
396
+ candidates: z.array(z.object({
397
+ display_name: z.string().trim().min(1).max(300), first_name: z.string().trim().min(1).max(150).optional(), last_name: z.string().trim().min(1).max(150).optional(),
398
+ email: z.string().trim().min(3).max(320).optional(), linkedin_url: z.string().trim().min(1).max(2_048).optional(),
399
+ rpps_number: z.string().trim().regex(/^\d{11}$/).optional(),
400
+ }).strict()).min(1).max(30),
401
+ session_ref: sessionRef,
402
+ }, (a) => run(client, () => post("/known-contacts", {
403
+ contractVersion: CONTACT_CONTRACT_VERSION, organizationId: a.organization_id, sourceLeadId: a.source_lead_id, leadGroupId: a.lead_group_id,
404
+ candidates: camelizeKeys(a.candidates),
405
+ correlation: readCorrelation(a.session_ref),
406
+ })));
407
+ def("qualification_read_employment_evidence", "Read the evidence behind one employment relationship of a person lead, 5 items at most per page; pass the returned cursor for the next page. Read-only.", { organization_id: org, lead_id: id, lead_group_id: id, relationship_id: id, cursor: id.optional(), limit: z.number().int().min(1).max(5).default(5) }, (a) => run(client, () => post("/employment-evidence", {
408
+ organizationId: a.organization_id, leadId: a.lead_id, leadGroupId: a.lead_group_id, relationshipId: a.relationship_id,
409
+ ...(a.cursor ? { cursor: a.cursor } : {}), limit: a.limit,
410
+ })));
411
+ }
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { getClient } from "../client.js";
3
+ import { externalAgentName } from "../correlation.js";
3
4
  import { toolResult, toolError, handleToolError } from "../types.js";
4
5
  // Anti-loop guardrail, NOT a business limit: bounds how many signal mutations
5
6
  // a single server run (= one agent session) can perform.
@@ -97,7 +98,8 @@ export function registerSignalTools(server) {
97
98
  .string()
98
99
  .optional()
99
100
  .describe("Technical identifier of the producing agent, 3-4 words max, kebab-case or " +
100
- "space-separated (e.g. 'leadify qualification', 'budget-monitor')."),
101
+ "space-separated (e.g. 'leadify qualification', 'budget-monitor'). Ignored when the MCP runs as an external agent " +
102
+ "(LEADIFY_EXTERNAL_AGENT_NAME): the signal is then signed external-agent:<name>."),
101
103
  source_url: z.string().url().optional().describe("URL of the original source."),
102
104
  reason: reasonParam,
103
105
  },
@@ -126,7 +128,11 @@ export function registerSignalTools(server) {
126
128
  body.dateEvidence = date_evidence;
127
129
  if (source !== undefined)
128
130
  body.source = source;
129
- if (agent_source !== undefined)
131
+ // PRD-2831 : un agent externe (Louise) signe ses signaux de son nom, quel que soit agent_source.
132
+ const externalName = externalAgentName();
133
+ if (externalName)
134
+ body.agentSource = `external-agent:${externalName}`;
135
+ else if (agent_source !== undefined)
130
136
  body.agentSource = agent_source;
131
137
  if (source_url !== undefined)
132
138
  body.sourceUrl = source_url;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agifyai/leadify-mcp",
3
- "version": "9.13.0",
3
+ "version": "9.14.1",
4
4
  "description": "MCP server for Leadify lead management API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",