@agifyai/leadify-mcp 9.4.0 → 9.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -121,6 +121,7 @@ src/
121
121
  └── tools/
122
122
  ├── auth.ts
123
123
  ├── leads.ts
124
+ ├── lead_move.ts
124
125
  ├── events.ts
125
126
  ├── campaigns.ts
126
127
  ├── context_workspace.ts
@@ -227,8 +228,11 @@ Leadify et ne doit jamais être envoyée par un agent MCP.
227
228
  | Tool | Description |
228
229
  |------|-------------|
229
230
  | `test_api_key` | Vérifier que la clé API configurée est valide (health check). |
230
- | `list_lead_groups` | Lister les groupes accessibles d'une organisation explicitement sélectionnée, en vue compacte. |
231
- | `get_lead_group` | Consulter les métadonnées compactes d'un groupe accessible par son ID. |
231
+ | `list_lead_groups` | Lister les groupes accessibles d'une organisation explicitement sélectionnée, en vue compacte, avec le sous-menu de chacun (`folderId`, `folder`). `folder_id` garde exactement les groupes d'un sous-menu, `none` ceux qui n'en ont aucun. |
232
+ | `get_lead_group` | Consulter les métadonnées compactes d'un groupe accessible par son ID, avec son sous-menu. |
233
+ | `list_lead_group_folders` | Lister les sous-menus partagés de la barre latérale d'une organisation (nom, emoji, description, groupes). Toute l'équipe voit les mêmes. |
234
+ | `create_lead_group_folder` / `update_lead_group_folder` / `delete_lead_group_folder` | Créer, renommer, changer l'emoji (caractère ou code) ou la description, ajouter ou retirer des groupes, supprimer un sous-menu. La suppression garde les groupes. Droit d'écriture requis. |
235
+ | `move_lead_groups_to_folder` | Ranger des groupes dans un sous-menu, ou les en sortir avec `folder_id: null` ; tous bougent ou aucun. Un groupe est dans un seul sous-menu. |
232
236
  | `create_lead_group` | Créer un groupe, choisir son type canonique optionnel et rattacher atomiquement `persona_id` avec l’`offer_id` ACTIVE du même tenant. |
233
237
  | `update_lead_group` | Modifier un groupe avec réconciliation du schéma ; lorsque le serveur exige un contexte complet, transmettre ensemble `persona_id` et `offer_id` sans inférer l’offre. |
234
238
  | `add_leads` | Import plat générique vers un groupe : ne crée aucune relation d’emploi, aucun rattachement parent ni identité canonique. Ne jamais l’utiliser pour rattacher une Personne à une Company (voir `upsert_person_leads_with_employment`). |
@@ -237,6 +241,9 @@ Leadify et ne doit jamais être envoyée par un agent MCP.
237
241
  | `exclude_account` | Exclure une entreprise (établissement) et toutes ses personnes liées : cause protégée `ACCOUNT_EXCLUDED`, motif et URL source obligatoires, `level: "establishment"`. |
238
242
  | `exclude_lead` / `reintegrate_lead` | Exclure ou réintégrer une personne avec les mêmes commandes que l'interface ; raison obligatoire, aucun motif protégé levé. |
239
243
  | `get_outreach_state` | Lire causes actives, version et date de 1 à 100 fiches. |
244
+ | `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. |
245
+ | `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. |
246
+ | `undo_move_leads` / `get_lead_move` | Annuler ou relire une opération de déplacement par son identifiant. |
240
247
  | `get_leads` | Rechercher et lister des leads avec filtres, recherche et pagination, dont le critère composé `event_filter`. |
241
248
  | `get_lead` | Récupérer les détails complets d'un lead par son ID. |
242
249
  | `update_lead` | Mettre à jour un ou plusieurs champs d'un lead existant. |
@@ -402,6 +409,36 @@ de délivrabilité ni l'exclusion d'une entreprise. Chaque écriture accepte
402
409
  `get_outreach_state` relit 1 à 100 fiches. Routes serveur : `/api/outreach/*`
403
410
  (PRD-2337), auteur tracé dans Leadify : propriétaire de la clé, source Agent.
404
411
 
412
+ ### Déplacer des prospects vers un autre groupe (PRD-2478)
413
+
414
+ Pour réorganiser une base, **déplacer** les fiches plutôt que les copier
415
+ (`add_leads`) ou cloner le groupe (`clone_lead_group`). Un déplacement garde la
416
+ même fiche : même identifiant, mêmes données, notes, activités, signaux,
417
+ historique de campagne, identité et rattachements. Seuls changent son groupe et
418
+ les colonnes qui recopient ce groupe. Un déplacement ne fusionne jamais deux
419
+ fiches : un doublon déjà présent dans le groupe cible est signalé, et la fiche
420
+ est quand même déplacée à part ; pour l'éviter, la retirer de la sélection.
421
+
422
+ 1. `preview_move_leads` (aucune écriture) : `target_group_id`, `organization_id`
423
+ facultatif (sinon celle du groupe cible) et une seule forme de `selection` :
424
+ `{ kind: "lead_ids", lead_ids }` (1 à 500), `{ kind: "view", view_id }` ou
425
+ `{ kind: "filter", group_id, filters?, or_filters?, search? }` (mêmes filtres
426
+ que les vues). Au-delà de 500 fiches, la vue ou le filtre est traité par lots :
427
+ la réponse indique `selection.remaining` et un champ `guidance`.
428
+ 2. `move_leads` : mêmes paramètres, plus `expected_plan_digest` (rendu par la
429
+ prévisualisation) et `idempotency_key`. Sans ces deux valeurs, le MCP n'envoie
430
+ rien. Si le plan a changé, le serveur refuse (`MOVE_PLAN_STALE`) : refaire la
431
+ prévisualisation.
432
+ 3. `undo_move_leads` et `get_lead_move` : par `operation_id` (rendu dans
433
+ `operation.operationId`). Une deuxième annulation rend `replayed: true`.
434
+
435
+ Chaque refus (par fiche) ou blocage (global) porte un code et un message en
436
+ français qui dit quoi faire ; il ne se contourne jamais (pas de copie, pas de
437
+ réimport, pas de suppression puis recréation). Routes serveur :
438
+ `/api/leads/move` (PRD-2476 : requêtes en snake_case, réponses
439
+ `{ dry_run, preview }` ou `{ operation }`), clé `write`, refusée aux clés
440
+ assistant. Nécessite un serveur Leadify qui contient PRD-2476.
441
+
405
442
  ### Création d’organisation et qualification (PRD-2398)
406
443
 
407
444
  Dans Leadify, **Réglages → Clés API → Modifier** permet de changer les actions
package/dist/server.js CHANGED
@@ -12,10 +12,12 @@ import { registerActivityTools } from "./tools/activities.js";
12
12
  import { registerDataSourceTools } from "./tools/datasources.js";
13
13
  import { registerPersonaTools } from "./tools/personas.js";
14
14
  import { registerLeadGroupTools } from "./tools/lead_groups.js";
15
+ import { registerLeadGroupFolderTools } from "./tools/lead_group_folders.js";
15
16
  import { registerOrganizationTools } from "./tools/organizations.js";
16
17
  import { registerPipelineTools } from "./tools/pipeline.js";
17
18
  import { registerContextWorkspaceTools } from "./tools/context_workspace.js";
18
19
  import { registerLeadViewTools } from "./tools/views.js";
20
+ import { registerLeadMoveTools } from "./tools/lead_move.js";
19
21
  import { registerRelationshipTools } from "./tools/relationships.js";
20
22
  import { registerCanonicalBackfillTools } from "./tools/canonical_backfill.js";
21
23
  import { registerCommercialTruthTools } from "./tools/commercial_truth.js";
@@ -33,8 +35,10 @@ export function createServer() {
33
35
  registerAuthTools(server);
34
36
  registerOrganizationTools(server);
35
37
  registerLeadGroupTools(server);
38
+ registerLeadGroupFolderTools(server);
36
39
  registerLeadViewTools(server);
37
40
  registerLeadTools(server);
41
+ registerLeadMoveTools(server);
38
42
  registerRelationshipTools(server);
39
43
  registerCanonicalBackfillTools(server);
40
44
  registerClaimContradictionTools(server);
@@ -0,0 +1,5 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { type LeadifyClient } from "../client.js";
3
+ /** Accepts "🇩🇪" or its code "1f1e9-1f1ea"; Leadify stores the code. */
4
+ export declare function emojiCode(value: string): string;
5
+ export declare function registerLeadGroupFolderTools(server: McpServer, client?: Pick<LeadifyClient, "get" | "post" | "put" | "delete">): void;
@@ -0,0 +1,104 @@
1
+ import { z } from "zod";
2
+ import { getClient } from "../client.js";
3
+ import { toolResult, handleToolError } from "../types.js";
4
+ /**
5
+ * PRD-2480: the shared lead group folders ("sous-menus" of the Leadify
6
+ * sidebar, PRD-2471). One organization shares them; a group sits in one
7
+ * folder at most. Every change here is what the whole team sees in Leadify.
8
+ */
9
+ const UNIFIED = /^[a-f0-9]{2,6}(?:-[a-f0-9]{2,6}){0,15}$/i;
10
+ /** Accepts "🇩🇪" or its code "1f1e9-1f1ea"; Leadify stores the code. */
11
+ export function emojiCode(value) {
12
+ const trimmed = value.trim();
13
+ if (UNIFIED.test(trimmed))
14
+ return trimmed.toLowerCase();
15
+ return Array.from(trimmed).map((char) => char.codePointAt(0).toString(16)).join("-");
16
+ }
17
+ const emoji = z.string().trim().min(1).max(64)
18
+ .describe("Emoji shown before the folder name, as the character (🇩🇪) or its code (1f1e9-1f1ea).");
19
+ const groupIds = z.array(z.string().trim().min(1)).max(500);
20
+ const folderId = z.string().trim().min(1).describe("Folder ID from list_lead_group_folders.");
21
+ const path = (id) => `/api/lead-group-folders/${encodeURIComponent(id)}`;
22
+ export function registerLeadGroupFolderTools(server, client = getClient()) {
23
+ server.tool("list_lead_group_folders", "List the shared folders (sidebar sub-menus such as \"🇩🇪 DACH\") of one organization, each with its " +
24
+ "name, emoji code, description and the ids of the lead groups it holds. The whole team sees the same " +
25
+ "folders in Leadify. To list the groups of one folder with their details, use list_lead_groups with folder_id.", {
26
+ organization_id: z.string().trim().min(1).optional()
27
+ .describe("Organization ID from discover_leadify_context. Required when the key reaches several organizations."),
28
+ }, async ({ organization_id }) => {
29
+ try {
30
+ const params = organization_id ? new URLSearchParams({ organizationId: organization_id }) : undefined;
31
+ return toolResult(await client.get("/api/lead-group-folders", params));
32
+ }
33
+ catch (error) {
34
+ return handleToolError(error);
35
+ }
36
+ });
37
+ server.tool("create_lead_group_folder", "Create a shared folder in the Leadify sidebar of one organization, optionally with groups already in it. " +
38
+ "A group sits in one folder at most: listed groups leave their previous folder. Needs write permission. " +
39
+ "Visible at once to the whole team.", {
40
+ organization_id: z.string().trim().min(1).optional()
41
+ .describe("Organization ID. Required when the key reaches several organizations."),
42
+ name: z.string().trim().min(1).max(100).describe("Folder name, e.g. DACH."),
43
+ emoji: emoji.optional(),
44
+ description: z.string().trim().max(2000).optional(),
45
+ lead_group_ids: groupIds.optional().describe("Lead groups of this organization to put in the folder."),
46
+ }, async ({ organization_id, name, emoji: icon, description, lead_group_ids }) => {
47
+ try {
48
+ return toolResult(await client.post("/api/lead-group-folders", {
49
+ ...(organization_id ? { organizationId: organization_id } : {}),
50
+ name,
51
+ ...(icon ? { emoji: emojiCode(icon) } : {}),
52
+ ...(description !== undefined ? { description } : {}),
53
+ ...(lead_group_ids?.length ? { leadGroupIds: lead_group_ids } : {}),
54
+ }));
55
+ }
56
+ catch (error) {
57
+ return handleToolError(error);
58
+ }
59
+ });
60
+ server.tool("update_lead_group_folder", "Change a shared folder: rename it, set or clear its emoji and description, add groups to it (they leave " +
61
+ "their previous folder) or take groups out of it. Only the fields given change, so a colleague's other " +
62
+ "edits are kept. Needs write permission.", {
63
+ folder_id: folderId,
64
+ name: z.string().trim().min(1).max(100).optional(),
65
+ emoji: emoji.nullable().optional().describe("New emoji, or null to remove it."),
66
+ description: z.string().trim().max(2000).nullable().optional().describe("New description, or null to remove it."),
67
+ add_lead_group_ids: groupIds.optional().describe("Groups moved into this folder."),
68
+ remove_lead_group_ids: groupIds.optional().describe("Groups taken out of this folder; they stay outside any folder."),
69
+ }, async ({ folder_id, name, emoji: icon, description, add_lead_group_ids, remove_lead_group_ids }) => {
70
+ try {
71
+ return toolResult(await client.put(path(folder_id), {
72
+ ...(name !== undefined ? { name } : {}),
73
+ ...(icon !== undefined ? { emoji: icon === null ? null : emojiCode(icon) } : {}),
74
+ ...(description !== undefined ? { description } : {}),
75
+ ...(add_lead_group_ids?.length ? { addLeadGroupIds: add_lead_group_ids } : {}),
76
+ ...(remove_lead_group_ids?.length ? { removeLeadGroupIds: remove_lead_group_ids } : {}),
77
+ }));
78
+ }
79
+ catch (error) {
80
+ return handleToolError(error);
81
+ }
82
+ });
83
+ server.tool("delete_lead_group_folder", "Delete a shared folder. Its groups are kept and simply leave the folder; no lead or group is deleted. " +
84
+ "Needs write permission.", { folder_id: folderId }, async ({ folder_id }) => {
85
+ try {
86
+ return toolResult(await client.delete(path(folder_id)));
87
+ }
88
+ catch (error) {
89
+ return handleToolError(error);
90
+ }
91
+ });
92
+ server.tool("move_lead_groups_to_folder", "Put lead groups in a shared folder, or take them out of any folder with folder_id null. All groups move " +
93
+ "or none does. Needs write permission.", {
94
+ lead_group_ids: groupIds.min(1).describe("Lead groups to file, all from the same organization."),
95
+ folder_id: folderId.nullable().describe("Target folder ID, or null to take the groups out of any folder."),
96
+ }, async ({ lead_group_ids, folder_id }) => {
97
+ try {
98
+ return toolResult(await client.put("/api/lead-group-folders/assignments", { leadGroupIds: lead_group_ids, folderId: folder_id }));
99
+ }
100
+ catch (error) {
101
+ return handleToolError(error);
102
+ }
103
+ });
104
+ }
@@ -0,0 +1,482 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { type LeadifyClient } from "../client.js";
4
+ import { type ToolResult } from "../types.js";
5
+ export declare const LEAD_MOVE_MAX_LEADS = 500;
6
+ declare const selectionSchema: z.ZodEffects<z.ZodDiscriminatedUnion<"kind", [z.ZodObject<{
7
+ kind: z.ZodLiteral<"lead_ids">;
8
+ lead_ids: z.ZodArray<z.ZodString, "many">;
9
+ }, "strict", z.ZodTypeAny, {
10
+ lead_ids: string[];
11
+ kind: "lead_ids";
12
+ }, {
13
+ lead_ids: string[];
14
+ kind: "lead_ids";
15
+ }>, z.ZodObject<{
16
+ kind: z.ZodLiteral<"view">;
17
+ view_id: z.ZodString;
18
+ }, "strict", z.ZodTypeAny, {
19
+ view_id: string;
20
+ kind: "view";
21
+ }, {
22
+ view_id: string;
23
+ kind: "view";
24
+ }>, z.ZodObject<{
25
+ kind: z.ZodLiteral<"filter">;
26
+ group_id: z.ZodString;
27
+ filters: z.ZodOptional<z.ZodArray<z.ZodObject<{
28
+ field: z.ZodString;
29
+ operator: z.ZodEnum<["equals", "contains", "gte", "lte", "not_equals", "not_contains", "is_true", "is_false", "is_null", "is_not_null", "activity_days_ago", "activity_count_equals", "activity_count_gte", "activity_count_lte", "bbox"]>;
30
+ value: z.ZodOptional<z.ZodUnknown>;
31
+ }, "strip", z.ZodTypeAny, {
32
+ field: string;
33
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
34
+ value?: unknown;
35
+ }, {
36
+ field: string;
37
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
38
+ value?: unknown;
39
+ }>, "many">>;
40
+ or_filters: z.ZodOptional<z.ZodArray<z.ZodObject<{
41
+ field: z.ZodString;
42
+ operator: z.ZodEnum<["equals", "contains", "gte", "lte", "not_equals", "not_contains", "is_true", "is_false", "is_null", "is_not_null", "activity_days_ago", "activity_count_equals", "activity_count_gte", "activity_count_lte", "bbox"]>;
43
+ value: z.ZodOptional<z.ZodUnknown>;
44
+ }, "strip", z.ZodTypeAny, {
45
+ field: string;
46
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
47
+ value?: unknown;
48
+ }, {
49
+ field: string;
50
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
51
+ value?: unknown;
52
+ }>, "many">>;
53
+ search: z.ZodOptional<z.ZodString>;
54
+ }, "strict", z.ZodTypeAny, {
55
+ group_id: string;
56
+ kind: "filter";
57
+ search?: string | undefined;
58
+ filters?: {
59
+ field: string;
60
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
61
+ value?: unknown;
62
+ }[] | undefined;
63
+ or_filters?: {
64
+ field: string;
65
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
66
+ value?: unknown;
67
+ }[] | undefined;
68
+ }, {
69
+ group_id: string;
70
+ kind: "filter";
71
+ search?: string | undefined;
72
+ filters?: {
73
+ field: string;
74
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
75
+ value?: unknown;
76
+ }[] | undefined;
77
+ or_filters?: {
78
+ field: string;
79
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
80
+ value?: unknown;
81
+ }[] | undefined;
82
+ }>]>, {
83
+ lead_ids: string[];
84
+ kind: "lead_ids";
85
+ } | {
86
+ view_id: string;
87
+ kind: "view";
88
+ } | {
89
+ group_id: string;
90
+ kind: "filter";
91
+ search?: string | undefined;
92
+ filters?: {
93
+ field: string;
94
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
95
+ value?: unknown;
96
+ }[] | undefined;
97
+ or_filters?: {
98
+ field: string;
99
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
100
+ value?: unknown;
101
+ }[] | undefined;
102
+ }, {
103
+ lead_ids: string[];
104
+ kind: "lead_ids";
105
+ } | {
106
+ view_id: string;
107
+ kind: "view";
108
+ } | {
109
+ group_id: string;
110
+ kind: "filter";
111
+ search?: string | undefined;
112
+ filters?: {
113
+ field: string;
114
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
115
+ value?: unknown;
116
+ }[] | undefined;
117
+ or_filters?: {
118
+ field: string;
119
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
120
+ value?: unknown;
121
+ }[] | undefined;
122
+ }>;
123
+ declare const previewSchema: z.ZodObject<{
124
+ organization_id: z.ZodOptional<z.ZodString>;
125
+ target_group_id: z.ZodString;
126
+ selection: z.ZodEffects<z.ZodDiscriminatedUnion<"kind", [z.ZodObject<{
127
+ kind: z.ZodLiteral<"lead_ids">;
128
+ lead_ids: z.ZodArray<z.ZodString, "many">;
129
+ }, "strict", z.ZodTypeAny, {
130
+ lead_ids: string[];
131
+ kind: "lead_ids";
132
+ }, {
133
+ lead_ids: string[];
134
+ kind: "lead_ids";
135
+ }>, z.ZodObject<{
136
+ kind: z.ZodLiteral<"view">;
137
+ view_id: z.ZodString;
138
+ }, "strict", z.ZodTypeAny, {
139
+ view_id: string;
140
+ kind: "view";
141
+ }, {
142
+ view_id: string;
143
+ kind: "view";
144
+ }>, z.ZodObject<{
145
+ kind: z.ZodLiteral<"filter">;
146
+ group_id: z.ZodString;
147
+ filters: z.ZodOptional<z.ZodArray<z.ZodObject<{
148
+ field: z.ZodString;
149
+ operator: z.ZodEnum<["equals", "contains", "gte", "lte", "not_equals", "not_contains", "is_true", "is_false", "is_null", "is_not_null", "activity_days_ago", "activity_count_equals", "activity_count_gte", "activity_count_lte", "bbox"]>;
150
+ value: z.ZodOptional<z.ZodUnknown>;
151
+ }, "strip", z.ZodTypeAny, {
152
+ field: string;
153
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
154
+ value?: unknown;
155
+ }, {
156
+ field: string;
157
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
158
+ value?: unknown;
159
+ }>, "many">>;
160
+ or_filters: z.ZodOptional<z.ZodArray<z.ZodObject<{
161
+ field: z.ZodString;
162
+ operator: z.ZodEnum<["equals", "contains", "gte", "lte", "not_equals", "not_contains", "is_true", "is_false", "is_null", "is_not_null", "activity_days_ago", "activity_count_equals", "activity_count_gte", "activity_count_lte", "bbox"]>;
163
+ value: z.ZodOptional<z.ZodUnknown>;
164
+ }, "strip", z.ZodTypeAny, {
165
+ field: string;
166
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
167
+ value?: unknown;
168
+ }, {
169
+ field: string;
170
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
171
+ value?: unknown;
172
+ }>, "many">>;
173
+ search: z.ZodOptional<z.ZodString>;
174
+ }, "strict", z.ZodTypeAny, {
175
+ group_id: string;
176
+ kind: "filter";
177
+ search?: string | undefined;
178
+ filters?: {
179
+ field: string;
180
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
181
+ value?: unknown;
182
+ }[] | undefined;
183
+ or_filters?: {
184
+ field: string;
185
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
186
+ value?: unknown;
187
+ }[] | undefined;
188
+ }, {
189
+ group_id: string;
190
+ kind: "filter";
191
+ search?: string | undefined;
192
+ filters?: {
193
+ field: string;
194
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
195
+ value?: unknown;
196
+ }[] | undefined;
197
+ or_filters?: {
198
+ field: string;
199
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
200
+ value?: unknown;
201
+ }[] | undefined;
202
+ }>]>, {
203
+ lead_ids: string[];
204
+ kind: "lead_ids";
205
+ } | {
206
+ view_id: string;
207
+ kind: "view";
208
+ } | {
209
+ group_id: string;
210
+ kind: "filter";
211
+ search?: string | undefined;
212
+ filters?: {
213
+ field: string;
214
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
215
+ value?: unknown;
216
+ }[] | undefined;
217
+ or_filters?: {
218
+ field: string;
219
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
220
+ value?: unknown;
221
+ }[] | undefined;
222
+ }, {
223
+ lead_ids: string[];
224
+ kind: "lead_ids";
225
+ } | {
226
+ view_id: string;
227
+ kind: "view";
228
+ } | {
229
+ group_id: string;
230
+ kind: "filter";
231
+ search?: string | undefined;
232
+ filters?: {
233
+ field: string;
234
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
235
+ value?: unknown;
236
+ }[] | undefined;
237
+ or_filters?: {
238
+ field: string;
239
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
240
+ value?: unknown;
241
+ }[] | undefined;
242
+ }>;
243
+ }, "strict", z.ZodTypeAny, {
244
+ target_group_id: string;
245
+ selection: {
246
+ lead_ids: string[];
247
+ kind: "lead_ids";
248
+ } | {
249
+ view_id: string;
250
+ kind: "view";
251
+ } | {
252
+ group_id: string;
253
+ kind: "filter";
254
+ search?: string | undefined;
255
+ filters?: {
256
+ field: string;
257
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
258
+ value?: unknown;
259
+ }[] | undefined;
260
+ or_filters?: {
261
+ field: string;
262
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
263
+ value?: unknown;
264
+ }[] | undefined;
265
+ };
266
+ organization_id?: string | undefined;
267
+ }, {
268
+ target_group_id: string;
269
+ selection: {
270
+ lead_ids: string[];
271
+ kind: "lead_ids";
272
+ } | {
273
+ view_id: string;
274
+ kind: "view";
275
+ } | {
276
+ group_id: string;
277
+ kind: "filter";
278
+ search?: string | undefined;
279
+ filters?: {
280
+ field: string;
281
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
282
+ value?: unknown;
283
+ }[] | undefined;
284
+ or_filters?: {
285
+ field: string;
286
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
287
+ value?: unknown;
288
+ }[] | undefined;
289
+ };
290
+ organization_id?: string | undefined;
291
+ }>;
292
+ declare const applySchema: z.ZodObject<{
293
+ expected_plan_digest: z.ZodString;
294
+ idempotency_key: z.ZodString;
295
+ organization_id: z.ZodOptional<z.ZodString>;
296
+ target_group_id: z.ZodString;
297
+ selection: z.ZodEffects<z.ZodDiscriminatedUnion<"kind", [z.ZodObject<{
298
+ kind: z.ZodLiteral<"lead_ids">;
299
+ lead_ids: z.ZodArray<z.ZodString, "many">;
300
+ }, "strict", z.ZodTypeAny, {
301
+ lead_ids: string[];
302
+ kind: "lead_ids";
303
+ }, {
304
+ lead_ids: string[];
305
+ kind: "lead_ids";
306
+ }>, z.ZodObject<{
307
+ kind: z.ZodLiteral<"view">;
308
+ view_id: z.ZodString;
309
+ }, "strict", z.ZodTypeAny, {
310
+ view_id: string;
311
+ kind: "view";
312
+ }, {
313
+ view_id: string;
314
+ kind: "view";
315
+ }>, z.ZodObject<{
316
+ kind: z.ZodLiteral<"filter">;
317
+ group_id: z.ZodString;
318
+ filters: z.ZodOptional<z.ZodArray<z.ZodObject<{
319
+ field: z.ZodString;
320
+ operator: z.ZodEnum<["equals", "contains", "gte", "lte", "not_equals", "not_contains", "is_true", "is_false", "is_null", "is_not_null", "activity_days_ago", "activity_count_equals", "activity_count_gte", "activity_count_lte", "bbox"]>;
321
+ value: z.ZodOptional<z.ZodUnknown>;
322
+ }, "strip", z.ZodTypeAny, {
323
+ field: string;
324
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
325
+ value?: unknown;
326
+ }, {
327
+ field: string;
328
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
329
+ value?: unknown;
330
+ }>, "many">>;
331
+ or_filters: z.ZodOptional<z.ZodArray<z.ZodObject<{
332
+ field: z.ZodString;
333
+ operator: z.ZodEnum<["equals", "contains", "gte", "lte", "not_equals", "not_contains", "is_true", "is_false", "is_null", "is_not_null", "activity_days_ago", "activity_count_equals", "activity_count_gte", "activity_count_lte", "bbox"]>;
334
+ value: z.ZodOptional<z.ZodUnknown>;
335
+ }, "strip", z.ZodTypeAny, {
336
+ field: string;
337
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
338
+ value?: unknown;
339
+ }, {
340
+ field: string;
341
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
342
+ value?: unknown;
343
+ }>, "many">>;
344
+ search: z.ZodOptional<z.ZodString>;
345
+ }, "strict", z.ZodTypeAny, {
346
+ group_id: string;
347
+ kind: "filter";
348
+ search?: string | undefined;
349
+ filters?: {
350
+ field: string;
351
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
352
+ value?: unknown;
353
+ }[] | undefined;
354
+ or_filters?: {
355
+ field: string;
356
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
357
+ value?: unknown;
358
+ }[] | undefined;
359
+ }, {
360
+ group_id: string;
361
+ kind: "filter";
362
+ search?: string | undefined;
363
+ filters?: {
364
+ field: string;
365
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
366
+ value?: unknown;
367
+ }[] | undefined;
368
+ or_filters?: {
369
+ field: string;
370
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
371
+ value?: unknown;
372
+ }[] | undefined;
373
+ }>]>, {
374
+ lead_ids: string[];
375
+ kind: "lead_ids";
376
+ } | {
377
+ view_id: string;
378
+ kind: "view";
379
+ } | {
380
+ group_id: string;
381
+ kind: "filter";
382
+ search?: string | undefined;
383
+ filters?: {
384
+ field: string;
385
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
386
+ value?: unknown;
387
+ }[] | undefined;
388
+ or_filters?: {
389
+ field: string;
390
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
391
+ value?: unknown;
392
+ }[] | undefined;
393
+ }, {
394
+ lead_ids: string[];
395
+ kind: "lead_ids";
396
+ } | {
397
+ view_id: string;
398
+ kind: "view";
399
+ } | {
400
+ group_id: string;
401
+ kind: "filter";
402
+ search?: string | undefined;
403
+ filters?: {
404
+ field: string;
405
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
406
+ value?: unknown;
407
+ }[] | undefined;
408
+ or_filters?: {
409
+ field: string;
410
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
411
+ value?: unknown;
412
+ }[] | undefined;
413
+ }>;
414
+ }, "strict", z.ZodTypeAny, {
415
+ idempotency_key: string;
416
+ target_group_id: string;
417
+ selection: {
418
+ lead_ids: string[];
419
+ kind: "lead_ids";
420
+ } | {
421
+ view_id: string;
422
+ kind: "view";
423
+ } | {
424
+ group_id: string;
425
+ kind: "filter";
426
+ search?: string | undefined;
427
+ filters?: {
428
+ field: string;
429
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
430
+ value?: unknown;
431
+ }[] | undefined;
432
+ or_filters?: {
433
+ field: string;
434
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
435
+ value?: unknown;
436
+ }[] | undefined;
437
+ };
438
+ expected_plan_digest: string;
439
+ organization_id?: string | undefined;
440
+ }, {
441
+ idempotency_key: string;
442
+ target_group_id: string;
443
+ selection: {
444
+ lead_ids: string[];
445
+ kind: "lead_ids";
446
+ } | {
447
+ view_id: string;
448
+ kind: "view";
449
+ } | {
450
+ group_id: string;
451
+ kind: "filter";
452
+ search?: string | undefined;
453
+ filters?: {
454
+ field: string;
455
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
456
+ value?: unknown;
457
+ }[] | undefined;
458
+ or_filters?: {
459
+ field: string;
460
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
461
+ value?: unknown;
462
+ }[] | undefined;
463
+ };
464
+ expected_plan_digest: string;
465
+ organization_id?: string | undefined;
466
+ }>;
467
+ type Selection = z.infer<typeof selectionSchema>;
468
+ type LeadMoveClient = Pick<LeadifyClient, "get" | "post">;
469
+ export declare const leadMoveApi: {
470
+ movePath: string;
471
+ operationPath: (operationId: string) => string;
472
+ undoPath: (operationId: string) => string;
473
+ selection(selection: Selection): Record<string, unknown>;
474
+ previewBody(input: z.infer<typeof previewSchema>): Record<string, unknown>;
475
+ applyBody(input: z.infer<typeof applySchema>): Record<string, unknown>;
476
+ errorCode(body: unknown): string | undefined;
477
+ errorMessage(body: unknown): string | undefined;
478
+ };
479
+ export declare const LEAD_MOVE_ERRORS: Record<string, string>;
480
+ export declare function leadMoveError(error: unknown): ToolResult;
481
+ export declare function registerLeadMoveTools(server: McpServer, client?: LeadMoveClient): void;
482
+ export {};
@@ -0,0 +1,251 @@
1
+ import { z } from "zod";
2
+ import { getClient } from "../client.js";
3
+ import { LeadifyApiError, handleToolError, toolError, toolResult } from "../types.js";
4
+ import { filterSchema } from "./views.js";
5
+ // PRD-2478 — déplacer des prospects vers un autre groupe (service PRD-2476).
6
+ // Contrat figé le 8 octobre 2026 (LEAD_MOVE_PLAN_VERSION = '2026-10-08') :
7
+ // la fiche garde son identifiant ; seuls changent son groupe et les colonnes
8
+ // qui recopient le groupe de cette fiche. Rien n'est supprimé ni fusionné.
9
+ export const LEAD_MOVE_MAX_LEADS = 500;
10
+ const id = z.string().trim().min(1).max(200);
11
+ const digest = z.string().trim().regex(/^[a-f0-9]{64}$/, "Empreinte SHA-256 attendue (64 caractères hexadécimaux).");
12
+ const idempotencyKey = z.string().trim().min(8).max(200);
13
+ const selectionSchema = z.discriminatedUnion("kind", [
14
+ z.object({
15
+ kind: z.literal("lead_ids"),
16
+ lead_ids: z.array(id).min(1).max(LEAD_MOVE_MAX_LEADS).describe(`1 to ${LEAD_MOVE_MAX_LEADS} lead ids, from one or several source groups. More than ${LEAD_MOVE_MAX_LEADS} is refused (SELECTION_TOO_LARGE): split the list into batches.`),
17
+ }).strict(),
18
+ z.object({
19
+ kind: z.literal("view"),
20
+ view_id: id.describe("Saved view whose leads are moved (see list_lead_views)."),
21
+ }).strict(),
22
+ z.object({
23
+ kind: z.literal("filter"),
24
+ group_id: id.describe("Source lead group the filter reads."),
25
+ filters: z.array(filterSchema).max(100).optional().describe("AND conditions, same shape as lead views."),
26
+ or_filters: z.array(filterSchema).max(100).optional().describe("OR conditions, same shape as lead views."),
27
+ search: z.string().trim().min(1).max(500).optional().describe("Full-text search, as in get_leads."),
28
+ }).strict(),
29
+ ]).superRefine((value, context) => {
30
+ if (value.kind === "lead_ids" && new Set(value.lead_ids).size !== value.lead_ids.length) {
31
+ context.addIssue({ code: z.ZodIssueCode.custom, path: ["lead_ids"], message: "lead_ids ne doit pas contenir de doublon." });
32
+ }
33
+ }).describe(`Exactly one selection form. A view or a filter larger than ${LEAD_MOVE_MAX_LEADS} leads is handled in batches: the preview takes the first ${LEAD_MOVE_MAX_LEADS} leads (id order) and returns selection.totalMatching and selection.remaining.`);
34
+ const previewShape = {
35
+ organization_id: id.optional().describe("Organization that owns the leads and the target group. Optional: when omitted, Leadify uses the target group's organization, if the API key can access it."),
36
+ target_group_id: id.describe("Lead group the leads move into. Same entity kind as the source groups (person to person, company to company)."),
37
+ selection: selectionSchema,
38
+ };
39
+ const applyShape = {
40
+ ...previewShape,
41
+ expected_plan_digest: digest.describe("expectedPlanDigest returned by preview_move_leads for this exact selection and target."),
42
+ idempotency_key: idempotencyKey.describe("Unique key for this move. Retrying with the same key and digest returns the operation already applied (replayed: true); the same key with another digest is refused (IDEMPOTENCY_KEY_REUSED)."),
43
+ };
44
+ const operationShape = {
45
+ operation_id: id.describe("operationId returned by move_leads (operation.operationId). Leadify checks that the API key can access its organization."),
46
+ };
47
+ const previewSchema = z.object(previewShape).strict();
48
+ const applySchema = z.object(applyShape).strict();
49
+ const operationSchema = z.object(operationShape).strict();
50
+ // ── Correspondance avec l'API REST (PRD-2476, apps/server/src/routers/api-lead-move.ts)
51
+ // Requêtes en snake_case, réponses en camelCase :
52
+ // POST /api/leads/move → { dry_run: true, preview } | { dry_run: false, operation }
53
+ // POST /api/leads/move/{operation_id}/undo → { operation }
54
+ // GET /api/leads/move/{operation_id} → { operation }
55
+ // Erreurs : { error: { code, message, details? } }.
56
+ export const leadMoveApi = {
57
+ movePath: "/api/leads/move",
58
+ operationPath: (operationId) => `/api/leads/move/${encodeURIComponent(operationId)}`,
59
+ undoPath: (operationId) => `/api/leads/move/${encodeURIComponent(operationId)}/undo`,
60
+ selection(selection) {
61
+ if (selection.kind === "lead_ids")
62
+ return { kind: "lead_ids", lead_ids: selection.lead_ids };
63
+ if (selection.kind === "view")
64
+ return { kind: "view", view_id: selection.view_id };
65
+ return {
66
+ kind: "filter", group_id: selection.group_id,
67
+ ...(selection.filters !== undefined ? { filters: selection.filters } : {}),
68
+ ...(selection.or_filters !== undefined ? { or_filters: selection.or_filters } : {}),
69
+ ...(selection.search !== undefined ? { search: selection.search } : {}),
70
+ };
71
+ },
72
+ previewBody(input) {
73
+ return {
74
+ ...(input.organization_id !== undefined ? { organization_id: input.organization_id } : {}),
75
+ target_group_id: input.target_group_id,
76
+ selection: leadMoveApi.selection(input.selection),
77
+ dry_run: true,
78
+ };
79
+ },
80
+ applyBody(input) {
81
+ return {
82
+ ...leadMoveApi.previewBody(input),
83
+ dry_run: false,
84
+ expected_plan_digest: input.expected_plan_digest,
85
+ idempotency_key: input.idempotency_key,
86
+ };
87
+ },
88
+ errorCode(body) {
89
+ if (!body || typeof body !== "object" || Array.isArray(body))
90
+ return undefined;
91
+ const record = body;
92
+ const nested = record.error && typeof record.error === "object" ? record.error : undefined;
93
+ for (const value of [nested?.code, record.code, record.error]) {
94
+ if (typeof value === "string" && /^[A-Z][A-Z0-9_]+$/.test(value))
95
+ return value;
96
+ }
97
+ return undefined;
98
+ },
99
+ errorMessage(body) {
100
+ if (!body || typeof body !== "object" || Array.isArray(body))
101
+ return undefined;
102
+ const record = body;
103
+ const nested = record.error && typeof record.error === "object" ? record.error : undefined;
104
+ const value = nested?.message ?? record.message;
105
+ return typeof value === "string" && value.trim() ? value : undefined;
106
+ },
107
+ };
108
+ // ── Refus et blocages expliqués en français simple ─────────────────────────
109
+ const NO_BYPASS = "Ce refus ne se contourne pas : ne copiez pas la fiche, ne la recréez pas et ne changez pas son groupe autrement.";
110
+ export const LEAD_MOVE_ERRORS = {
111
+ SELECTION_TOO_LARGE: `La sélection dépasse ${LEAD_MOVE_MAX_LEADS} fiches. Découpez la liste en lots de ${LEAD_MOVE_MAX_LEADS} au plus, ou passez par une vue ou un filtre : la prévisualisation prend alors les ${LEAD_MOVE_MAX_LEADS} premières fiches et indique combien il en reste.`,
112
+ SELECTION_EMPTY: "La sélection ne contient aucune fiche. Vérifiez les identifiants, la vue ou le filtre.",
113
+ TARGET_NOT_FOUND: "Le groupe cible est introuvable dans cette organisation. Vérifiez son identifiant avec list_lead_groups.",
114
+ TARGET_NOT_ACTIVE: "Le groupe cible n'est plus actif (il a été fusionné). Choisissez le groupe qui l'a remplacé.",
115
+ TARGET_GENERIC: "Le groupe cible n'a pas de nature déclarée (personne ou établissement). Déclarez-la d'abord avec update_lead_group (entity_kind).",
116
+ SCHEMA_VALUE_CONFLICT: "Un champ a des valeurs qu'aucun type ne peut accepter ensemble. Rien n'est tronqué : corrigez d'abord ces valeurs, puis relancez la prévisualisation.",
117
+ RELATION_CONFIG_CONFLICT: "Le groupe cible a déjà un champ de relation du même nom qui pointe vers un autre groupe. Réglez d'abord ce conflit de relation.",
118
+ MOVE_PLAN_STALE: "Les fiches ou les groupes ont changé depuis la prévisualisation. Relancez preview_move_leads et utilisez la nouvelle empreinte.",
119
+ IDEMPOTENCY_KEY_REUSED: "Cette clé d'idempotence a déjà servi pour un autre déplacement. Prenez une nouvelle clé pour ce nouveau plan.",
120
+ UNDO_LEADS_CHANGED: "L'annulation est refusée : certaines fiches ont changé ou ne sont plus dans le groupe cible depuis le déplacement. La liste des fiches concernées est dans le détail.",
121
+ UNDO_SOURCE_NOT_ACTIVE: "L'annulation est refusée : un groupe d'origine n'est plus actif.",
122
+ MOVE_CONFIRMATION_REQUIRED: "Pour appliquer le déplacement, il faut l'empreinte rendue par preview_move_leads (expected_plan_digest) et une clé d'idempotence.",
123
+ MOVE_PLAN_BLOCKED: "Le déplacement est bloqué. Relancez preview_move_leads : les blocages et les refus y sont expliqués.",
124
+ OPERATION_NOT_FOUND: "Ce déplacement est introuvable dans les organisations de cette clé. Vérifiez operation_id.",
125
+ MOVE_REFUSED_BY_DATA_GUARD: "La base a refusé le déplacement pour protéger l'identité des fiches (identité incomplète ou migration des identités en cours). Rien n'a été modifié.",
126
+ MOVE_SAFETY_CHECK_FAILED: "Un contrôle de sécurité a échoué pendant le déplacement. Rien n'a été modifié.",
127
+ WRITE_FORBIDDEN: "Cette clé API ne peut que lire : le déplacement demande le droit d'écriture.",
128
+ ORGANIZATION_FORBIDDEN: "Cette clé API n'a pas accès à cette organisation.",
129
+ UNAUTHENTICATED: "La clé API est absente ou invalide. Vérifiez-la avec test_api_key.",
130
+ ACTIVE_ORGANIZATION_REQUIRED: "Indiquez l'organisation (organization_id).",
131
+ LEAD_NOT_FOUND: "Une fiche est introuvable dans cette organisation. Vérifiez la sélection.",
132
+ ALREADY_IN_TARGET: "La fiche est déjà dans le groupe cible : il n'y a rien à faire.",
133
+ ENTITY_KIND_MISMATCH: "La fiche et le groupe cible ne sont pas de même nature (personne et établissement). Choisissez un groupe de même nature.",
134
+ CAMPAIGN_IN_PROGRESS: "La fiche est dans une campagne en cours ou a un envoi en attente. Retirez-la de l'audience ou attendez la fin de la campagne.",
135
+ QUALIFICATION_IN_PROGRESS: "Une qualification est en cours sur la fiche, son établissement ou son groupe. Attendez qu'elle se termine.",
136
+ };
137
+ const HTTP_MESSAGES = {
138
+ 401: "La clé API est refusée. Vérifiez la clé configurée avec test_api_key.",
139
+ 403: "La clé API n'a pas le droit de déplacer des fiches : il faut le droit d'écriture, et les clés assistant ne peuvent pas déplacer.",
140
+ 404: "Le déplacement demandé est introuvable dans cette organisation.",
141
+ };
142
+ // Refus qu'on peut simplement retenter : pas de mention « ne se contourne pas ».
143
+ const RETRYABLE = new Set(["MOVE_BUSY", "MOVE_FAILED", "MOVE_SAFETY_CHECK_FAILED", "INVALID_INPUT"]);
144
+ const ACCESS = new Set(["UNAUTHENTICATED", "WRITE_FORBIDDEN", "ORGANIZATION_FORBIDDEN", "OPERATION_NOT_FOUND", "ACTIVE_ORGANIZATION_REQUIRED"]);
145
+ export function leadMoveError(error) {
146
+ if (!(error instanceof LeadifyApiError))
147
+ return handleToolError(error);
148
+ const code = leadMoveApi.errorCode(error.responseBody);
149
+ const message = (code ? LEAD_MOVE_ERRORS[code] : undefined)
150
+ ?? leadMoveApi.errorMessage(error.responseBody)
151
+ ?? HTTP_MESSAGES[error.statusCode]
152
+ ?? `Le serveur Leadify a répondu par une erreur (statut ${error.statusCode}).`;
153
+ const refusal = Boolean(code) && !RETRYABLE.has(code) && !ACCESS.has(code) && error.statusCode < 500;
154
+ return toolError(refusal ? `${message} ${NO_BYPASS}` : message, {
155
+ code, statusCode: error.statusCode, response: error.responseBody,
156
+ });
157
+ }
158
+ function invalidInput(error) {
159
+ return toolError("Paramètres invalides : " + error.issues.map((issue) => `${issue.path.join(".") || "(racine)"} : ${issue.message}`).join(" ; "), { issues: error.issues });
160
+ }
161
+ // ── Indications pour l'agent sur le résultat de la prévisualisation ───────
162
+ function previewGuidance(data) {
163
+ if (!data || typeof data !== "object")
164
+ return undefined;
165
+ const envelope = data;
166
+ const record = envelope.preview && typeof envelope.preview === "object" ? envelope.preview : envelope;
167
+ if (record.status === "BLOCKED") {
168
+ return "Rien ne peut être déplacé : lisez blockers et les refus par fiche, réglez la cause, puis relancez la prévisualisation. Ne contournez pas un blocage.";
169
+ }
170
+ const selection = record.selection && typeof record.selection === "object" ? record.selection : {};
171
+ const remaining = typeof selection.remaining === "number" ? selection.remaining : 0;
172
+ if (remaining > 0) {
173
+ return `Ce lot couvre les ${LEAD_MOVE_MAX_LEADS} premières fiches ; il en reste ${remaining}. Après move_leads, relancez preview_move_leads avec la même sélection (les fiches déplacées quittent le groupe source) jusqu'à remaining = 0.`;
174
+ }
175
+ return undefined;
176
+ }
177
+ const CORE_RULES = "A move keeps the same lead record: same id, with all its history (data, notes, activities, signals, campaign history, " +
178
+ "messages, canonical identity, company links). Only its group and the columns copying that group change. A move never merges " +
179
+ "two records: a lead already in the target with the same email, LinkedIn URL or identity is reported in duplicates and still " +
180
+ "moved as a separate record; remove it from the selection to keep it out. Each refusal (per lead) or blocker (global) comes " +
181
+ "with a code and a message saying what to do: follow it, never bypass it (no copy, no re-import, no delete-and-recreate). " +
182
+ "To reorganise leads between groups, prefer moving over add_leads copies or clone_lead_group.";
183
+ export function registerLeadMoveTools(server, client) {
184
+ const api = () => client ?? getClient();
185
+ server.tool("preview_move_leads", "Dry run (no write) of moving leads into another lead group of the same organization. Select by lead ids, by saved view or by filter; " +
186
+ `at most ${LEAD_MOVE_MAX_LEADS} leads per call, larger views or filters are handled in batches (selection.remaining). Returns status ` +
187
+ "READY or BLOCKED, target and source groups, counts (selected, movable, refused, duplicates), the decision per lead (MOVE or REFUSE " +
188
+ "with refusal code and message), duplicates already in the target, schema fields to add or retype in the target (the source schema " +
189
+ "never changes), relation settings to add, global blockers, and expectedPlanDigest to pass to move_leads. The answer is { dry_run: true, preview }. Refusal codes: " +
190
+ "LEAD_NOT_FOUND, ALREADY_IN_TARGET, ENTITY_KIND_MISMATCH, CAMPAIGN_IN_PROGRESS, QUALIFICATION_IN_PROGRESS. " + CORE_RULES, previewShape, { readOnlyHint: true }, async (params) => {
191
+ const parsed = previewSchema.safeParse(params);
192
+ if (!parsed.success)
193
+ return invalidInput(parsed.error);
194
+ try {
195
+ const data = await api().post(leadMoveApi.movePath, leadMoveApi.previewBody(parsed.data));
196
+ const guidance = previewGuidance(data);
197
+ return toolResult(guidance && data && typeof data === "object" && !Array.isArray(data) ? { ...data, guidance } : data);
198
+ }
199
+ catch (error) {
200
+ return leadMoveError(error);
201
+ }
202
+ });
203
+ server.tool("move_leads", "Apply a move reviewed with preview_move_leads. Requires that preview's expectedPlanDigest and a unique idempotency_key, with the " +
204
+ "same organization, target and selection. Leadify re-checks the plan in one transaction and refuses with MOVE_PLAN_STALE if " +
205
+ "anything changed: preview again. Only leads marked MOVE are moved; refused leads stay where they are. Returns { dry_run: false, operation } " +
206
+ "with operation.operationId, to read with get_lead_move or revert with undo_move_leads. Show the preview to the user before applying. " + CORE_RULES, applyShape, async (params) => {
207
+ const missing = ["expected_plan_digest", "idempotency_key"].filter((key) => {
208
+ const value = params[key];
209
+ return typeof value !== "string" || value.trim() === "";
210
+ });
211
+ if (missing.length > 0) {
212
+ return toolError(`Le déplacement n'est pas appliqué : il manque ${missing.join(" et ")}. Lancez d'abord preview_move_leads, relisez le plan, ` +
213
+ "puis passez son expectedPlanDigest et une clé d'idempotence unique.", { missing });
214
+ }
215
+ const parsed = applySchema.safeParse(params);
216
+ if (!parsed.success)
217
+ return invalidInput(parsed.error);
218
+ try {
219
+ return toolResult(await api().post(leadMoveApi.movePath, leadMoveApi.applyBody(parsed.data)));
220
+ }
221
+ catch (error) {
222
+ return leadMoveError(error);
223
+ }
224
+ });
225
+ server.tool("undo_move_leads", "Undo a move: puts the leads back in their source groups with the copied columns and canonical memberships, and reverts the target " +
226
+ "schema and relations if they did not change since (otherwise they are kept and reported). Refused with UNDO_LEADS_CHANGED when a " +
227
+ "lead left the target group or its data changed since the move (the list is returned), or UNDO_SOURCE_NOT_ACTIVE when a source " +
228
+ "group is no longer active. A second undo returns replayed: true. Nothing is deleted or merged.", operationShape, async (params) => {
229
+ const parsed = operationSchema.safeParse(params);
230
+ if (!parsed.success)
231
+ return invalidInput(parsed.error);
232
+ try {
233
+ return toolResult(await api().post(leadMoveApi.undoPath(parsed.data.operation_id), {}));
234
+ }
235
+ catch (error) {
236
+ return leadMoveError(error);
237
+ }
238
+ });
239
+ server.tool("get_lead_move", "Read one move operation: who and when, target group, source groups, plan digest, moved leads with their source group, and whether " +
240
+ "it was undone. Use it to check a move or before undo_move_leads.", operationShape, { readOnlyHint: true }, async (params) => {
241
+ const parsed = operationSchema.safeParse(params);
242
+ if (!parsed.success)
243
+ return invalidInput(parsed.error);
244
+ try {
245
+ return toolResult(await api().get(leadMoveApi.operationPath(parsed.data.operation_id)));
246
+ }
247
+ catch (error) {
248
+ return leadMoveError(error);
249
+ }
250
+ });
251
+ }
@@ -1,6 +1,15 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { type LeadifyClient } from "../client.js";
3
3
  export declare function assertLeadGroupsBelongToOrganization(data: unknown, organizationId: string): Array<Record<string, unknown>>;
4
+ /** PRD-2480: the shared sidebar folder of a group, or null when it sits outside any folder. */
5
+ export declare function leadGroupFolder(group: Record<string, unknown>): {
6
+ folderId: string | null;
7
+ folder: {
8
+ id: unknown;
9
+ name: unknown;
10
+ emoji: unknown;
11
+ } | null;
12
+ };
4
13
  /** Keep group reads navigable: never return schema, profiles, or lead data. */
5
14
  export declare function compactLeadGroup(group: unknown): Record<string, unknown>;
6
15
  export declare function registerOrganizationTools(server: McpServer, client?: Pick<LeadifyClient, "get" | "post" | "put">): void;
@@ -22,6 +22,26 @@ export function assertLeadGroupsBelongToOrganization(data, organizationId) {
22
22
  }
23
23
  return leadGroups;
24
24
  }
25
+ /** PRD-2480: the shared sidebar folder of a group, or null when it sits outside any folder. */
26
+ export function leadGroupFolder(group) {
27
+ const folder = group.folder && typeof group.folder === "object" ? group.folder : null;
28
+ return {
29
+ folderId: typeof group.folderId === "string" ? group.folderId : null,
30
+ folder: folder ? { id: folder.id, name: folder.name, emoji: folder.emoji ?? null } : null,
31
+ };
32
+ }
33
+ /** One line of a group list: navigation fields and its folder, never schema or leads. */
34
+ function listedLeadGroup(group) {
35
+ return {
36
+ id: group.id,
37
+ name: group.name,
38
+ organizationId: group.organizationId,
39
+ leadCount: group._count?.leads ?? 0,
40
+ published: group.published ?? false,
41
+ refreshEnabled: group.refreshEnabled ?? false,
42
+ ...leadGroupFolder(group),
43
+ };
44
+ }
25
45
  /** Keep group reads navigable: never return schema, profiles, or lead data. */
26
46
  export function compactLeadGroup(group) {
27
47
  const source = group && typeof group === "object" ? group : {};
@@ -42,6 +62,7 @@ export function compactLeadGroup(group) {
42
62
  refreshEnabled: source.refreshEnabled ?? false,
43
63
  refreshFrequencyDays: source.refreshFrequencyDays ?? null,
44
64
  createdAt: source.createdAt ?? null,
65
+ ...leadGroupFolder(source),
45
66
  };
46
67
  }
47
68
  export function registerOrganizationTools(server, client = getClient()) {
@@ -86,25 +107,22 @@ export function registerOrganizationTools(server, client = getClient()) {
86
107
  }
87
108
  });
88
109
  server.tool("list_lead_groups", "List lead groups visible in one explicitly selected organization. Returns a compact " +
89
- "navigation view only: id, name, organizationId, lead count, published and refresh state. " +
110
+ "navigation view only: id, name, organizationId, lead count, published and refresh state, and the " +
111
+ "shared sidebar folder (folderId, folder { id, name, emoji }). Pass folder_id to keep exactly the " +
112
+ "groups of one folder (list_lead_group_folders), or \"none\" for the groups outside any folder. " +
90
113
  "Start with discover_leadify_context when no organization_id is known.", {
91
114
  organization_id: z.string().describe("Organization ID explicitly selected from list_organizations or discover_leadify_context."),
115
+ folder_id: z.string().trim().min(1).optional().describe("Keep only the groups of this folder, or \"none\" for groups outside any folder."),
92
116
  limit: z.number().int().min(1).max(100).optional().default(50).describe("Maximum groups returned, bounded to 100."),
93
- }, async ({ organization_id, limit }) => {
117
+ }, async ({ organization_id, folder_id, limit }) => {
94
118
  try {
95
- const params = new URLSearchParams({ organizationId: organization_id });
96
- const data = await getClient().get("/api/lead-groups", params);
119
+ const params = new URLSearchParams({ organizationId: organization_id, ...(folder_id ? { folderId: folder_id } : {}) });
120
+ const data = await client.get("/api/lead-groups", params);
97
121
  const groups = assertLeadGroupsBelongToOrganization(data, organization_id);
98
122
  return toolResult({
99
123
  organizationId: organization_id,
100
- leadGroups: groups.slice(0, limit).map((group) => ({
101
- id: group.id,
102
- name: group.name,
103
- organizationId: group.organizationId,
104
- leadCount: group._count?.leads ?? 0,
105
- published: group.published ?? false,
106
- refreshEnabled: group.refreshEnabled ?? false,
107
- })),
124
+ ...(folder_id ? { folderId: folder_id } : {}),
125
+ leadGroups: groups.slice(0, limit).map(listedLeadGroup),
108
126
  total: groups.length,
109
127
  truncated: groups.length > limit,
110
128
  });
@@ -115,12 +133,13 @@ export function registerOrganizationTools(server, client = getClient()) {
115
133
  });
116
134
  server.tool("get_lead_group", "Read the compact metadata of one accessible lead group by ID. Use this after list_lead_groups " +
117
135
  "or discover_leadify_context to confirm its type and operational state. Returns only navigation " +
118
- "metadata (no lead records, schema, company profile, or persona payload) and reports inaccessible " +
136
+ "metadata (no lead records, schema, company profile, or persona payload), with its shared sidebar " +
137
+ "folder (folderId, folder), and reports inaccessible " +
119
138
  "groups as not found, without revealing their organization.", {
120
139
  lead_group_id: z.string().describe("Lead group ID returned by Leadify discovery or another authorized Leadify response."),
121
140
  }, async ({ lead_group_id }) => {
122
141
  try {
123
- const data = await getClient().get(`/api/lead-group/${encodeURIComponent(lead_group_id)}`);
142
+ const data = await client.get(`/api/lead-group/${encodeURIComponent(lead_group_id)}`);
124
143
  const source = data && typeof data === "object" ? data : {};
125
144
  return toolResult({ leadGroup: compactLeadGroup(source.leadGroup) });
126
145
  }
@@ -130,13 +149,14 @@ export function registerOrganizationTools(server, client = getClient()) {
130
149
  });
131
150
  server.tool("discover_leadify_context", "Safe zero-ID entry point for Leadify. With no organization_id, lists accessible organizations " +
132
151
  "and never chooses one when several are available. With an explicit organization_id, returns " +
133
- "a compact list of its lead groups. Use the returned lead_group_id with lead, campaign, persona " +
152
+ "a compact list of its lead groups with their folder, and the organization's shared sidebar folders. " +
153
+ "Use the returned lead_group_id with lead, campaign, persona " +
134
154
  "and Context Workspace tools.", {
135
155
  organization_id: z.string().optional().describe("Organization selected explicitly after discovery. Omit only for the first step."),
136
156
  limit: z.number().int().min(1).max(100).optional().default(50).describe("Maximum lead groups returned after selection."),
137
157
  }, async ({ organization_id, limit }) => {
138
158
  try {
139
- const orgData = await getClient().get("/api/organizations");
159
+ const orgData = await client.get("/api/organizations");
140
160
  const organizations = Array.isArray(orgData.organizations)
141
161
  ? orgData.organizations.map((org) => ({ id: org.id, name: org.name ?? null, slug: org.slug ?? null }))
142
162
  : [];
@@ -149,11 +169,28 @@ export function registerOrganizationTools(server, client = getClient()) {
149
169
  if (!organizations.some((org) => org.id === organization_id)) {
150
170
  return toolResult({ organizations, selectionRequired: true, message: "organization_id is not accessible to this key." });
151
171
  }
152
- const groupsData = await getClient().get("/api/lead-groups", new URLSearchParams({ organizationId: organization_id }));
172
+ const groupsData = await client.get("/api/lead-groups", new URLSearchParams({ organizationId: organization_id }));
153
173
  const groups = assertLeadGroupsBelongToOrganization(groupsData, organization_id);
174
+ // The folders complete the map; a failure to read them never hides the groups.
175
+ let folders = null;
176
+ let foldersError;
177
+ try {
178
+ const folderData = await client.get("/api/lead-group-folders", new URLSearchParams({ organizationId: organization_id }));
179
+ folders = Array.isArray(folderData?.folders)
180
+ ? folderData.folders.map((folder) => ({ id: folder.id, name: folder.name, emoji: folder.emoji ?? null, leadGroupIds: folder.leadGroupIds ?? [] }))
181
+ : [];
182
+ }
183
+ catch (error) {
184
+ foldersError = error instanceof Error ? error.message : String(error);
185
+ }
154
186
  return toolResult({
155
187
  organization: organizations.find((org) => org.id === organization_id),
156
- leadGroups: groups.slice(0, limit).map((group) => ({ id: group.id, name: group.name, organizationId: group.organizationId, leadCount: group._count?.leads ?? 0, published: group.published ?? false })),
188
+ folders,
189
+ ...(foldersError ? { foldersError } : {}),
190
+ leadGroups: groups.slice(0, limit).map((group) => {
191
+ const { refreshEnabled: _refreshEnabled, ...listed } = listedLeadGroup(group);
192
+ return listed;
193
+ }),
157
194
  total: groups.length,
158
195
  truncated: groups.length > limit,
159
196
  next: "Choose a lead_group_id, then use get_leads, get_context_workspace, or list_persona_contracts.",
@@ -1,2 +1,16 @@
1
+ import { z } from "zod";
1
2
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ export declare const filterSchema: z.ZodObject<{
4
+ field: z.ZodString;
5
+ operator: z.ZodEnum<["equals", "contains", "gte", "lte", "not_equals", "not_contains", "is_true", "is_false", "is_null", "is_not_null", "activity_days_ago", "activity_count_equals", "activity_count_gte", "activity_count_lte", "bbox"]>;
6
+ value: z.ZodOptional<z.ZodUnknown>;
7
+ }, "strip", z.ZodTypeAny, {
8
+ field: string;
9
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
10
+ value?: unknown;
11
+ }, {
12
+ field: string;
13
+ operator: "equals" | "contains" | "gte" | "lte" | "not_equals" | "not_contains" | "is_true" | "is_false" | "is_null" | "is_not_null" | "activity_days_ago" | "activity_count_equals" | "activity_count_gte" | "activity_count_lte" | "bbox";
14
+ value?: unknown;
15
+ }>;
2
16
  export declare function registerLeadViewTools(server: McpServer): void;
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { getClient } from "../client.js";
3
3
  import { handleToolError, toolResult } from "../types.js";
4
- const filterSchema = z.object({
4
+ export const filterSchema = z.object({
5
5
  field: z.string().describe("A standard, virtual, or selected lead-group schema field."),
6
6
  operator: z.enum(["equals", "contains", "gte", "lte", "not_equals", "not_contains", "is_true", "is_false", "is_null", "is_not_null", "activity_days_ago", "activity_count_equals", "activity_count_gte", "activity_count_lte", "bbox"]),
7
7
  value: z.unknown().optional(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agifyai/leadify-mcp",
3
- "version": "9.4.0",
3
+ "version": "9.6.0",
4
4
  "description": "MCP server for Leadify lead management API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",