@agifyai/leadify-mcp 9.12.0 → 9.14.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 +50 -1
- package/dist/client.js +5 -0
- package/dist/correlation.d.ts +38 -0
- package/dist/correlation.js +69 -0
- package/dist/server.js +4 -0
- package/dist/tools/context_workspace.js +58 -11
- package/dist/tools/leads.js +27 -8
- package/dist/tools/outreach_review.d.ts +14 -0
- package/dist/tools/outreach_review.js +62 -0
- package/dist/tools/qualification.d.ts +20 -0
- package/dist/tools/qualification.js +411 -0
- package/dist/tools/signals.js +8 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -276,12 +276,13 @@ Leadify et ne doit jamais être envoyée par un agent MCP.
|
|
|
276
276
|
| `get_capture_coverage` | Boîtes mail et comptes LinkedIn réellement captés, avec leur état. |
|
|
277
277
|
| `get_follow_up_cadence` | Rythme de relance de l'organisation (lecture seule). |
|
|
278
278
|
| `get_prospect_approach` | Avant d'écrire à une personne : son Approche (quotidien, problématique, ce qu'elle veut, ce qu'on lui apporte). |
|
|
279
|
+
| `list_outreach_review_queue` | La file « À valider » de Leadify (page `/to-review`) en un appel, en lecture seule : filtres campagne, canal, type de message et état (`TO_REVIEW` par défaut), `total`, `counts`, `truncated`. Le texte des messages seulement avec `include_text`. |
|
|
279
280
|
| `preview_move_leads` | Essai à blanc du déplacement de fiches vers un autre groupe de la même organisation : sélection par identifiants, vue ou filtre (500 fiches au plus par appel, lots ensuite), refus par fiche avec motif, doublons dans le groupe cible, champs de schéma et relations à ajouter, blocages et empreinte du plan. |
|
|
280
281
|
| `move_leads` | Appliquer un déplacement prévisualisé ; exige l'empreinte de la prévisualisation et une clé d'idempotence. La fiche garde son identifiant et tout son historique ; jamais de fusion. |
|
|
281
282
|
| `undo_move_leads` / `get_lead_move` | Annuler ou relire une opération de déplacement par son identifiant. |
|
|
282
283
|
| `get_leads` | Rechercher et lister des leads avec filtres, recherche et pagination, dont le critère composé `event_filter` (`subject` : employeur ou personne). |
|
|
283
284
|
| `get_lead` | Récupérer les détails complets d'un lead par son ID. |
|
|
284
|
-
| `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. |
|
|
285
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`. |
|
|
286
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. |
|
|
287
288
|
| `get_event` | Lire un événement partagé, son type, son état d’archivage et ses nombres d’entreprises et de personnes rattachées. |
|
|
@@ -495,6 +496,24 @@ plus long se termine par `… [truncated: N characters in total]`. `list_follow_
|
|
|
495
496
|
accepte `include_text` pour ajouter le message entier (`lastMessage.text`) à côté
|
|
496
497
|
de son extrait de 500 caractères.
|
|
497
498
|
|
|
499
|
+
### File « À valider » (PRD-2815)
|
|
500
|
+
|
|
501
|
+
`list_outreach_review_queue` lit en un appel la file de la page `/to-review` :
|
|
502
|
+
les messages de campagne qui attendent une décision humaine, dans l'ordre de la
|
|
503
|
+
page, au plus 500, sans pagination. Filtres : `campaign_id`, `channel` (`EMAIL`,
|
|
504
|
+
`LINKEDIN`), `message_type_id` et `state` (`TO_REVIEW` par défaut, `VALIDATED`,
|
|
505
|
+
`CORRECTED`, `REFUSED`, ou `ALL`). Les identifiants des filtres viennent de
|
|
506
|
+
`filters.campaigns` et `filters.messageTypes`. Chaque message garde l'essentiel
|
|
507
|
+
(prospect, entreprise, campagne, canal, étape, type, départ prévu, créneau
|
|
508
|
+
manqué, statut, objet) ; la révision, la date de création et les identifiants de
|
|
509
|
+
campagne et de type, déjà dans `filters`, sont retirés. Sans le texte des
|
|
510
|
+
messages, la file réelle de 73 messages tient en environ 30 000 caractères, sous
|
|
511
|
+
la limite de 50 000 d'Hermès ; `include_text: true` ajoute `body`, le message
|
|
512
|
+
entier (environ 500 caractères de plus par message) : au-delà d'une quarantaine
|
|
513
|
+
de messages, filtrer d'abord. Si `truncated` vaut `true`, filtrer par campagne.
|
|
514
|
+
Aucun outil ne valide, corrige ou refuse : cela reste humain. Route :
|
|
515
|
+
`GET /api/outreach-review/queue` (permission `read`).
|
|
516
|
+
|
|
498
517
|
### Déplacer des prospects vers un autre groupe (PRD-2478)
|
|
499
518
|
|
|
500
519
|
Pour réorganiser une base, **déplacer** les fiches plutôt que les copier
|
|
@@ -765,3 +784,33 @@ changent pas l’état du message et n’exposent pas l’enveloppe fournisseur
|
|
|
765
784
|
|
|
766
785
|
Pour un signal daté, `signal_upsert.date_evidence` fournit le passage source qui
|
|
767
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,7 +1,9 @@
|
|
|
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";
|
|
6
|
+
import { registerOutreachReviewTools } from "./tools/outreach_review.js";
|
|
5
7
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
6
8
|
import { registerAuthTools } from "./tools/auth.js";
|
|
7
9
|
import { registerLeadTools } from "./tools/leads.js";
|
|
@@ -61,7 +63,9 @@ export function createServer() {
|
|
|
61
63
|
registerPersonaTools(server);
|
|
62
64
|
registerPipelineTools(server);
|
|
63
65
|
registerQualificationRunTools(server);
|
|
66
|
+
registerQualificationTools(server);
|
|
64
67
|
registerOutreachExclusionTools(server);
|
|
65
68
|
registerProspectionTrackingTools(server);
|
|
69
|
+
registerOutreachReviewTools(server);
|
|
66
70
|
return server;
|
|
67
71
|
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
308
|
+
return withNextStepOnError(error);
|
|
262
309
|
}
|
|
263
310
|
});
|
|
264
311
|
}
|
package/dist/tools/leads.js
CHANGED
|
@@ -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', '
|
|
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,
|
|
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
|
|
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 (
|
|
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
|
-
"
|
|
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. A field that was absent is not the same as null. 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
|
-
|
|
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 held null)."),
|
|
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
|
-
|
|
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,14 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { type LeadifyClient } from "../client.js";
|
|
3
|
+
export declare const OUTREACH_REVIEW_TOOL_NAMES: readonly ["list_outreach_review_queue"];
|
|
4
|
+
export declare const OUTREACH_REVIEW_STATES: readonly ["ALL", "TO_REVIEW", "VALIDATED", "CORRECTED", "REFUSED"];
|
|
5
|
+
export declare const OUTREACH_REVIEW_CHANNELS: readonly ["EMAIL", "LINKEDIN"];
|
|
6
|
+
type Client = Pick<LeadifyClient, "get">;
|
|
7
|
+
/**
|
|
8
|
+
* One queue item without what only a write would need (revision, createdAt) and
|
|
9
|
+
* without the campaign and message type ids, which filters.campaigns and
|
|
10
|
+
* filters.messageTypes carry once. The body only with include_text.
|
|
11
|
+
*/
|
|
12
|
+
export declare function projectReviewItem(item: unknown, includeText: boolean): Record<string, unknown>;
|
|
13
|
+
export declare function registerOutreachReviewTools(server: McpServer, client?: Client): void;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { getClient } from "../client.js";
|
|
3
|
+
import { handleToolError } from "../types.js";
|
|
4
|
+
// PRD-2815 — la file « À valider » de Leadify (page /to-review) en lecture seule,
|
|
5
|
+
// par la route serveur GET /api/outreach-review/queue. Valider, corriger ou
|
|
6
|
+
// refuser reste humain : aucune écriture ici.
|
|
7
|
+
export const OUTREACH_REVIEW_TOOL_NAMES = ["list_outreach_review_queue"];
|
|
8
|
+
export const OUTREACH_REVIEW_STATES = ["ALL", "TO_REVIEW", "VALIDATED", "CORRECTED", "REFUSED"];
|
|
9
|
+
export const OUTREACH_REVIEW_CHANNELS = ["EMAIL", "LINKEDIN"];
|
|
10
|
+
const id = z.string().trim().min(1).max(200);
|
|
11
|
+
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
|
|
12
|
+
const object = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : {};
|
|
13
|
+
/** One line of JSON: an agent runtime sets aside tool results above 50,000 characters. */
|
|
14
|
+
const compact = (data) => ({ content: [{ type: "text", text: JSON.stringify(data) }] });
|
|
15
|
+
/**
|
|
16
|
+
* One queue item without what only a write would need (revision, createdAt) and
|
|
17
|
+
* without the campaign and message type ids, which filters.campaigns and
|
|
18
|
+
* filters.messageTypes carry once. The body only with include_text.
|
|
19
|
+
*/
|
|
20
|
+
export function projectReviewItem(item, includeText) {
|
|
21
|
+
const i = object(item);
|
|
22
|
+
const result = {
|
|
23
|
+
id: i.id, leadId: i.leadId, leadName: i.leadName, companyName: i.companyName, campaignName: i.campaignName,
|
|
24
|
+
channel: i.channel, step: i.step, stepKind: i.stepKind, messageTypeName: object(i.messageType).name ?? null,
|
|
25
|
+
plannedDepartureAt: i.plannedDepartureAt, missedSlot: i.missedSlot, status: i.status, editedByHuman: i.editedByHuman,
|
|
26
|
+
subject: i.subject,
|
|
27
|
+
};
|
|
28
|
+
if (includeText)
|
|
29
|
+
result.body = i.body;
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
export function registerOutreachReviewTools(server, client = getClient()) {
|
|
33
|
+
server.tool("list_outreach_review_queue", "The Leadify 'À valider' queue (page /to-review) in ONE call: the campaign messages waiting for a human decision, in " +
|
|
34
|
+
"the page's order. Read-only: validating, correcting or refusing a message stays human, in Leadify. Answer: " +
|
|
35
|
+
"{organizationId, state, limit, total, counts {all, toReview, validated, corrected, refused}, truncated, items, " +
|
|
36
|
+
"filters {campaigns [{id, name}], messageTypes [{id, name}]}, notContactedToday {count, reasons}}. Each item: id, " +
|
|
37
|
+
"leadId, leadName, companyName, campaignName, channel, step, stepKind, messageTypeName, plannedDepartureAt, " +
|
|
38
|
+
"missedSlot, status (TO_REVIEW, APPROVED, SCHEDULED, SENT, REJECTED), editedByHuman, subject. To stay well under " +
|
|
39
|
+
"50,000 characters, the message text is left out by default (about 30,000 characters for 73 messages); " +
|
|
40
|
+
"include_text=true adds body, the whole message, about 500 characters more per message (about 68,000 for those 73): " +
|
|
41
|
+
"above about 40 messages, narrow with campaign_id, channel or message_type_id first. At most 500 messages, no pagination: when truncated " +
|
|
42
|
+
"is true, filter by campaign_id. Pass the ids found in filters.campaigns and filters.messageTypes.", {
|
|
43
|
+
organization_id: id.describe("Organization to read. Must be in the API key's scope."),
|
|
44
|
+
state: z.enum(OUTREACH_REVIEW_STATES).optional().describe("TO_REVIEW (default): waiting for validation. VALIDATED, CORRECTED, REFUSED: decided in the last 7 days. ALL: TO_REVIEW plus the messages validated, scheduled, sent or refused in the last 7 days."),
|
|
45
|
+
campaign_id: id.optional().describe("Only this campaign (filters.campaigns[].id)."),
|
|
46
|
+
channel: z.enum(OUTREACH_REVIEW_CHANNELS).optional().describe("EMAIL or LINKEDIN."),
|
|
47
|
+
message_type_id: id.optional().describe("Only this message type (filters.messageTypes[].id)."),
|
|
48
|
+
include_text: z.boolean().optional().describe("Also return body, the whole message text. Off by default."),
|
|
49
|
+
}, READ_ONLY, (async ({ organization_id, state, campaign_id, channel, message_type_id, include_text }) => {
|
|
50
|
+
try {
|
|
51
|
+
const query = new URLSearchParams(Object.entries({ organizationId: organization_id, campaignId: campaign_id, channel, messageTypeId: message_type_id, state: state ?? "TO_REVIEW" })
|
|
52
|
+
.filter((entry) => entry[1] !== undefined));
|
|
53
|
+
const { items, ...rest } = object(await client.get("/api/outreach-review/queue", query));
|
|
54
|
+
const { organizationId, state: readState, limit, total, counts, truncated, ...others } = rest;
|
|
55
|
+
return compact({ organizationId, state: readState, limit, total, counts, truncated,
|
|
56
|
+
items: Array.isArray(items) ? items.map(item => projectReviewItem(item, include_text === true)) : items, ...others });
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
return handleToolError(error);
|
|
60
|
+
}
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
@@ -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
|
+
}
|
package/dist/tools/signals.js
CHANGED
|
@@ -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
|
-
|
|
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;
|