@agifyai/leadify-mcp 9.5.0 → 9.7.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
@@ -195,8 +195,12 @@ Conséquences pratiques :
195
195
 
196
196
  ## Sémantique des événements
197
197
 
198
- Une participation est rattachée à l’entreprise canonique (`Account`), même
199
- lorsqu’un `lead_id` est fourni au tool. `get_leads.event_filter` et
198
+ Une participation porte sur une entreprise ou sur une personne (PRD-2475,
199
+ serveur PRD-2473). Un `account_id` ou le `lead_id` d’un prospect Entreprise
200
+ écrit, comme avant, sur l’entreprise canonique (`Account`). Le `lead_id` d’un
201
+ prospect Personne, ou un `person_id` (`canonicalIdentity.id` d’un prospect Personne, `kind: PERSON`), écrit une
202
+ participation propre à la personne canonique : elle la suit dans tous ses
203
+ groupes. Les outils d’events d’entreprise fonctionnent comme avant. `get_leads.event_filter` et
200
204
  `list_event_participations` ajoutent explicitement `VERIFIED` lorsque le statut
201
205
  n’est pas fourni. Pour relire des faits `UNVERIFIED`, `DISPUTED` ou `REFUTED`,
202
206
  l’agent doit demander ces statuts explicitement. Une donnée incertaine ou
@@ -211,6 +215,31 @@ des sélecteurs courants sans effacer son historique. Les champs legacy
211
215
  restent disponibles pour compatibilité/export, mais ne sont jamais synchronisés
212
216
  depuis ce modèle.
213
217
 
218
+ Événements d’une personne :
219
+
220
+ - Rôles d’une personne : `REGISTERED` (inscrit), `ATTENDED` (présent),
221
+ `INVITED` (invité), `SPEAKER`, `ORGANIZER`, `OTHER`. Les trois premiers sont
222
+ refusés pour une entreprise. Inscrit, présent et invité sont trois faits
223
+ distincts : une inscription ne prouve jamais une présence.
224
+ - Une liste d’inscrits fournie par le client est une preuve valable de
225
+ l’inscription, y compris pour une personne désinscrite ensuite ou inscrite
226
+ plusieurs fois : rôle `REGISTERED` et `client_document { file_name,
227
+ received_on, provided_by }`, sans adresse web. `VERIFIED` exige une adresse
228
+ web ou un `client_document`.
229
+ - Un event de l’employeur n’est jamais une participation de la personne. Lue
230
+ avec le `lead_id` d’une personne, `list_event_participations` rend ses
231
+ participations propres (`origin: PERSON`) puis celles de son employeur
232
+ (`origin: EMPLOYER`). Sans `target`, `person_ids` ni `lead_id` de personne,
233
+ la liste ne rend que les entreprises, comme avant ; `target: PERSON` ou
234
+ `person_ids` lisent les personnes.
235
+ - `get_leads.event_filter.subject` vaut `EMPLOYER` (défaut, comportement
236
+ historique) ou `PERSON` (seules les personnes qui portent elles-mêmes la
237
+ participation, par exemple les inscrits d’un webinar).
238
+ - Les events ont un type facultatif `event_type` : `WEBINAR`, `CONGRESS`,
239
+ `TRADE_SHOW`, `OTHER` (filtre `event_types` dans `list_events`).
240
+ `get_event` rend `participationCounts { companies, persons }`.
241
+ - L’absence d’enregistrement ne prouve rien.
242
+
214
243
  Il n’existe plus de score de confiance événementiel : le statut et ses preuves
215
244
  portent seuls la validation utile. `Event.description` explique l’audience et
216
245
  l’utilité de l’événement ; `activity_summary` explique ce que l’entreprise y
@@ -228,8 +257,11 @@ Leadify et ne doit jamais être envoyée par un agent MCP.
228
257
  | Tool | Description |
229
258
  |------|-------------|
230
259
  | `test_api_key` | Vérifier que la clé API configurée est valide (health check). |
231
- | `list_lead_groups` | Lister les groupes accessibles d'une organisation explicitement sélectionnée, en vue compacte. |
232
- | `get_lead_group` | Consulter les métadonnées compactes d'un groupe accessible par son ID. |
260
+ | `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. |
261
+ | `get_lead_group` | Consulter les métadonnées compactes d'un groupe accessible par son ID, avec son sous-menu. |
262
+ | `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. |
263
+ | `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. |
264
+ | `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. |
233
265
  | `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. |
234
266
  | `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. |
235
267
  | `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`). |
@@ -241,17 +273,17 @@ Leadify et ne doit jamais être envoyée par un agent MCP.
241
273
  | `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. |
242
274
  | `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. |
243
275
  | `undo_move_leads` / `get_lead_move` | Annuler ou relire une opération de déplacement par son identifiant. |
244
- | `get_leads` | Rechercher et lister des leads avec filtres, recherche et pagination, dont le critère composé `event_filter`. |
276
+ | `get_leads` | Rechercher et lister des leads avec filtres, recherche et pagination, dont le critère composé `event_filter` (`subject` : employeur ou personne). |
245
277
  | `get_lead` | Récupérer les détails complets d'un lead par son ID. |
246
278
  | `update_lead` | Mettre à jour un ou plusieurs champs d'un lead existant. |
247
279
  | `clear_lead_field` | Effacer irréversiblement une valeur stockée, sans modifier le schéma du groupe. Exige la confirmation explicite `clear_lead_field`. |
248
- | `list_events` | Lister les événements tenant-scoped ; les événements archivés sont masqués par défaut. |
249
- | `get_event` | Lire un événement partagé et son état d’archivage. |
250
- | `create_event` | Créer un événement partagé avec une clé d’idempotence. |
251
- | `update_event` | Modifier ou archiver sans suppression un événement partagé, avec version attendue. |
252
- | `list_event_participations` | Lister les participations d’entreprise ; filtre explicitement `VERIFIED` par défaut. |
253
- | `get_event_participation` | Lire un snapshot courant et son historique ; `VERIFIED` par défaut, statut incertain à demander explicitement. |
254
- | `record_event_participations` | Valider à blanc ou enregistrer un lot idempotent de 1 à 100 observations/corrections. |
280
+ | `list_events` | Lister les événements tenant-scoped, filtrables par type (`event_types`) ; les événements archivés sont masqués par défaut. |
281
+ | `get_event` | Lire un événement partagé, son type, son état d’archivage et ses nombres d’entreprises et de personnes rattachées. |
282
+ | `create_event` | Créer un événement partagé, avec son type facultatif, et une clé d’idempotence. |
283
+ | `update_event` | Modifier (dont le type) ou archiver sans suppression un événement partagé, avec version attendue. |
284
+ | `list_event_participations` | Lister les participations d’entreprise et de personne, avec leur provenance ; filtres `target`, `person_ids` ; `VERIFIED` par défaut. |
285
+ | `get_event_participation` | Lire un snapshot courant d’entreprise ou de personne et son historique ; `VERIFIED` par défaut, statut incertain à demander explicitement. |
286
+ | `record_event_participations` | Valider à blanc ou enregistrer un lot idempotent de 1 à 100 observations/corrections d’entreprise ou de personne (inscrit, présent, invité, preuve « document du client »). |
255
287
  | `preview_reset_sequence_messages` / `execute_reset_sequence_messages` | Prévisualiser puis effacer irréversiblement les messages générés d'un prospect, d'une liste explicite ou d'un groupe explicite. L'exécution exige le digest de preview et une clé d'idempotence. |
256
288
  | `preview_reset_ai_fields` / `execute_reset_ai_fields` | Prévisualiser puis effacer irréversiblement les champs IA d'un prospect, d'une liste explicite ou d'un groupe explicite, sans toucher aux contacts, flags manuels, activités ou campagnes. |
257
289
  | `delete_leads` | Supprimer définitivement des leads par leurs IDs. |
package/dist/server.js CHANGED
@@ -12,6 +12,7 @@ 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";
@@ -34,6 +35,7 @@ export function createServer() {
34
35
  registerAuthTools(server);
35
36
  registerOrganizationTools(server);
36
37
  registerLeadGroupTools(server);
38
+ registerLeadGroupFolderTools(server);
37
39
  registerLeadViewTools(server);
38
40
  registerLeadTools(server);
39
41
  registerLeadMoveTools(server);
@@ -1,2 +1,8 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ export declare const COMPANY_EVENT_ROLES: readonly ["EXHIBITOR", "SPONSOR", "SPEAKER", "PARTNER", "ORGANIZER", "CONTRIBUTOR", "PARTICIPANT", "UNKNOWN", "OTHER"];
4
+ export declare const PERSON_ONLY_EVENT_ROLES: readonly ["REGISTERED", "ATTENDED", "INVITED"];
5
+ export declare const PERSON_EVENT_ROLES: readonly ["REGISTERED", "ATTENDED", "INVITED", "SPEAKER", "ORGANIZER", "OTHER"];
6
+ export declare const eventRole: z.ZodEnum<["EXHIBITOR", "SPONSOR", "SPEAKER", "PARTNER", "ORGANIZER", "CONTRIBUTOR", "PARTICIPANT", "UNKNOWN", "OTHER", "REGISTERED", "ATTENDED", "INVITED"]>;
7
+ export declare const eventType: z.ZodEnum<["WEBINAR", "CONGRESS", "TRADE_SHOW", "OTHER"]>;
2
8
  export declare function registerEventTools(server: McpServer, client?: import("../client.js").LeadifyClient): void;
@@ -7,7 +7,10 @@ const dateOnly = z.string()
7
7
  const parsed = new Date(`${value}T00:00:00.000Z`);
8
8
  return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
9
9
  }, "Use a real calendar date in YYYY-MM-DD format.");
10
- const role = z.enum([
10
+ // Company roles are the nine historical ones. REGISTERED, ATTENDED and
11
+ // INVITED are three distinct facts about a person (PRD-2473 contract):
12
+ // a registration never proves attendance, an invitation proves neither.
13
+ export const COMPANY_EVENT_ROLES = [
11
14
  "EXHIBITOR",
12
15
  "SPONSOR",
13
16
  "SPEAKER",
@@ -17,7 +20,13 @@ const role = z.enum([
17
20
  "PARTICIPANT",
18
21
  "UNKNOWN",
19
22
  "OTHER",
20
- ]);
23
+ ];
24
+ export const PERSON_ONLY_EVENT_ROLES = ["REGISTERED", "ATTENDED", "INVITED"];
25
+ export const PERSON_EVENT_ROLES = ["REGISTERED", "ATTENDED", "INVITED", "SPEAKER", "ORGANIZER", "OTHER"];
26
+ export const eventRole = z.enum([...COMPANY_EVENT_ROLES, ...PERSON_ONLY_EVENT_ROLES]);
27
+ const role = eventRole;
28
+ export const eventType = z.enum(["WEBINAR", "CONGRESS", "TRADE_SHOW", "OTHER"]);
29
+ const target = z.enum(["COMPANY", "PERSON"]);
21
30
  const status = z.enum(["UNVERIFIED", "VERIFIED", "DISPUTED", "REFUTED"]);
22
31
  const nullableLabel = z.string().trim().min(1).max(500).nullable().optional();
23
32
  const nullableNarrative = z.string().trim().min(1).max(5_000).nullable().optional();
@@ -35,10 +44,12 @@ const eventFields = {
35
44
  official_url: nullableUrl,
36
45
  last_verified_on: dateOnly.nullable().optional(),
37
46
  };
47
+ const eventTypeDescription = "Event type: WEBINAR, CONGRESS, TRADE_SHOW or OTHER. Omitted means not specified.";
38
48
  const createEventShape = {
39
49
  organization_id: z.string().trim().min(1),
40
50
  slug: z.string().trim().min(1).max(160).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
41
51
  ...eventFields,
52
+ event_type: eventType.optional().describe(eventTypeDescription),
42
53
  idempotency_key: idempotencyKey,
43
54
  };
44
55
  const createEventInput = z.object(createEventShape).strict().superRefine((value, context) => {
@@ -59,12 +70,13 @@ const updateEventShape = {
59
70
  venue: eventFields.venue,
60
71
  official_url: eventFields.official_url,
61
72
  last_verified_on: eventFields.last_verified_on,
73
+ event_type: eventType.nullable().optional().describe("New event type (WEBINAR, CONGRESS, TRADE_SHOW, OTHER), or null to mark it as not specified."),
62
74
  archive: z.boolean().optional().default(false),
63
75
  expected_version: z.number().int().positive(),
64
76
  idempotency_key: idempotencyKey,
65
77
  };
66
78
  const updateEventInput = z.object(updateEventShape).strict().superRefine((value, context) => {
67
- const mutable = ["name", "edition", "description", "starts_on", "ends_on", "city", "country_code", "venue", "official_url", "last_verified_on"];
79
+ const mutable = ["name", "edition", "description", "starts_on", "ends_on", "city", "country_code", "venue", "official_url", "last_verified_on", "event_type"];
68
80
  const supplied = mutable.filter((key) => value[key] !== undefined);
69
81
  if (value.archive && supplied.length > 0) {
70
82
  context.addIssue({ code: z.ZodIssueCode.custom, path: ["archive"], message: "Archive and field updates must be separate idempotent operations." });
@@ -76,16 +88,23 @@ const updateEventInput = z.object(updateEventShape).strict().superRefine((value,
76
88
  context.addIssue({ code: z.ZodIssueCode.custom, path: ["ends_on"], message: "ends_on must be on or after starts_on." });
77
89
  }
78
90
  });
91
+ const clientDocument = z.object({
92
+ file_name: z.string().trim().min(1).max(300).describe("Name of the file the client sent, e.g. the registrants spreadsheet."),
93
+ received_on: dateOnly.describe("Date the file was received, YYYY-MM-DD."),
94
+ provided_by: z.string().trim().min(1).max(300).describe("Who provided the file (client organization or contact)."),
95
+ }).strict().describe("Proof by a document provided by the client, without a web address. A client's registrants list is valid proof of registration (role REGISTERED), including for a person who later unsubscribed or registered several times. It is not proof of attendance.");
79
96
  const participationItem = z.object({
80
97
  event_id: z.string().trim().min(1),
81
- account_id: z.string().trim().min(1).optional(),
82
- lead_id: z.string().trim().min(1).optional(),
83
- roles: z.array(role).min(1).max(20),
98
+ account_id: z.string().trim().min(1).optional().describe("Company target: canonical account id."),
99
+ lead_id: z.string().trim().min(1).optional().describe("Company lead (writes on its account) or Person lead (writes a personal participation on its canonical person)."),
100
+ person_id: z.string().trim().min(1).optional().describe("Person target: canonical person id: canonicalIdentity.id of a Person lead (canonicalIdentity.kind PERSON), as returned by get_lead."),
101
+ roles: z.array(role).min(1).max(20).describe("Company roles: EXHIBITOR, SPONSOR, SPEAKER, PARTNER, ORGANIZER, CONTRIBUTOR, PARTICIPANT, UNKNOWN, OTHER. Person roles: REGISTERED, ATTENDED, INVITED, SPEAKER, ORGANIZER, OTHER. REGISTERED, ATTENDED and INVITED are reserved to persons and are three distinct facts."),
84
102
  role_details: z.array(z.string().trim().min(1).max(500)).max(50).optional().default([]),
85
103
  stands: z.array(z.string().trim().min(1).max(200)).max(50).optional().default([]),
86
104
  source_urls: z.array(z.string().trim().url().max(2048)).max(50).optional().default([]),
105
+ client_document: clientDocument.optional(),
87
106
  source_provider: nullableLabel,
88
- activity_summary: nullableNarrative.describe("Readable summary of what the company does, presents or targets at this event."),
107
+ activity_summary: nullableNarrative.describe("Readable summary of what the company or person does, presents or targets at this event."),
89
108
  proof_excerpt: z.string().trim().min(1).max(20_000),
90
109
  observed_on: dateOnly,
91
110
  verified_on: dateOnly.nullable().optional(),
@@ -93,11 +112,24 @@ const participationItem = z.object({
93
112
  correction_reason: z.string().trim().min(1).max(5_000).optional(),
94
113
  idempotency_key: idempotencyKey,
95
114
  }).strict().superRefine((value, context) => {
96
- if (Number(Boolean(value.account_id)) + Number(Boolean(value.lead_id)) !== 1) {
97
- context.addIssue({ code: z.ZodIssueCode.custom, message: "Provide exactly one account_id or lead_id." });
115
+ const targets = [value.account_id, value.lead_id, value.person_id].filter(Boolean).length;
116
+ if (targets !== 1) {
117
+ context.addIssue({ code: z.ZodIssueCode.custom, message: "Provide exactly one account_id, lead_id or person_id." });
98
118
  }
99
- if (value.status === "VERIFIED" && value.source_urls.length === 0) {
100
- context.addIssue({ code: z.ZodIssueCode.custom, path: ["source_urls"], message: "VERIFIED requires at least one source URL." });
119
+ if (value.account_id) {
120
+ const refused = value.roles.filter((item) => PERSON_ONLY_EVENT_ROLES.includes(item));
121
+ if (refused.length) {
122
+ context.addIssue({ code: z.ZodIssueCode.custom, path: ["roles"], message: `Roles reserved to a person cannot be recorded on a company: ${refused.join(", ")}.` });
123
+ }
124
+ }
125
+ if (value.person_id) {
126
+ const refused = value.roles.filter((item) => !PERSON_EVENT_ROLES.includes(item));
127
+ if (refused.length) {
128
+ context.addIssue({ code: z.ZodIssueCode.custom, path: ["roles"], message: `Roles not accepted for a person: ${refused.join(", ")}. Use REGISTERED, ATTENDED, INVITED, SPEAKER, ORGANIZER or OTHER.` });
129
+ }
130
+ }
131
+ if (value.status === "VERIFIED" && value.source_urls.length === 0 && !value.client_document) {
132
+ context.addIssue({ code: z.ZodIssueCode.custom, path: ["source_urls"], message: "VERIFIED requires at least one source URL or a client_document." });
101
133
  }
102
134
  if (value.status === "VERIFIED" && !value.verified_on) {
103
135
  context.addIssue({ code: z.ZodIssueCode.custom, path: ["verified_on"], message: "VERIFIED requires verified_on." });
@@ -118,10 +150,18 @@ function participationPayload(item) {
118
150
  eventId: item.event_id,
119
151
  accountId: item.account_id,
120
152
  leadId: item.lead_id,
153
+ personId: item.person_id,
121
154
  roles: item.roles,
122
155
  roleDetails: item.role_details,
123
156
  stands: item.stands,
124
157
  sourceUrls: item.source_urls,
158
+ clientDocument: item.client_document
159
+ ? {
160
+ fileName: item.client_document.file_name,
161
+ receivedOn: item.client_document.received_on,
162
+ providedBy: item.client_document.provided_by,
163
+ }
164
+ : undefined,
125
165
  sourceProvider: item.source_provider,
126
166
  activitySummary: item.activity_summary,
127
167
  proofExcerpt: item.proof_excerpt,
@@ -160,16 +200,17 @@ function filterFactHistory(data, acceptedStatuses) {
160
200
  return source;
161
201
  }
162
202
  export function registerEventTools(server, client = getClient()) {
163
- server.tool("list_events", "List tenant-scoped events. Archived events are hidden unless include_archived=true. An event alone is not evidence that a company attended; cite participation only from a matching VERIFIED participation.", {
203
+ server.tool("list_events", "List tenant-scoped events. Archived events are hidden unless include_archived=true. Filter by event_types (WEBINAR, CONGRESS, TRADE_SHOW, OTHER). An event alone is not evidence that a company or a person took part; cite participation only from a matching VERIFIED participation.", {
164
204
  organization_id: z.string().trim().min(1),
165
205
  include_archived: z.boolean().optional().default(false),
166
206
  search: z.string().trim().min(1).max(200).optional(),
167
207
  editions: z.array(z.string().trim().min(1).max(200)).max(100).optional(),
208
+ event_types: z.array(eventType).min(1).max(4).optional(),
168
209
  starts_on_from: dateOnly.optional(),
169
210
  starts_on_to: dateOnly.optional(),
170
211
  limit: z.number().int().min(1).max(100).optional().default(50),
171
212
  cursor: z.string().trim().min(1).optional(),
172
- }, async ({ organization_id, include_archived, search, editions, starts_on_from, starts_on_to, limit, cursor }) => {
213
+ }, async ({ organization_id, include_archived, search, editions, event_types, starts_on_from, starts_on_to, limit, cursor }) => {
173
214
  try {
174
215
  const params = new URLSearchParams({
175
216
  organizationId: organization_id,
@@ -179,6 +220,7 @@ export function registerEventTools(server, client = getClient()) {
179
220
  if (search)
180
221
  params.set("search", search);
181
222
  setCsv(params, "editions", editions);
223
+ setCsv(params, "eventTypes", event_types);
182
224
  if (starts_on_from)
183
225
  params.set("startsOnFrom", starts_on_from);
184
226
  if (starts_on_to)
@@ -191,7 +233,7 @@ export function registerEventTools(server, client = getClient()) {
191
233
  return handleToolError(error);
192
234
  }
193
235
  });
194
- server.tool("get_event", "Get one tenant-scoped event, including its archive state and current participation count. This does not establish that any particular company attended.", {
236
+ server.tool("get_event", "Get one tenant-scoped event, including its type, archive state and participationCounts { companies, persons }: the number of companies and of persons that carry a participation themselves. A count does not establish that any particular company or person attended, and a person is never counted through their employer.", {
195
237
  organization_id: z.string().trim().min(1),
196
238
  event_id: z.string().trim().min(1),
197
239
  }, async ({ organization_id, event_id }) => {
@@ -202,7 +244,7 @@ export function registerEventTools(server, client = getClient()) {
202
244
  return handleToolError(error);
203
245
  }
204
246
  });
205
- server.tool("create_event", "Create one reusable tenant-scoped event. The idempotency key makes an identical retry safe; changing the payload under the same key is rejected. Creating an event does not create company participation evidence.", createEventShape, async (params) => {
247
+ server.tool("create_event", "Create one reusable tenant-scoped event, optionally typed with event_type (WEBINAR, CONGRESS, TRADE_SHOW, OTHER). The idempotency key makes an identical retry safe; changing the payload under the same key is rejected. Creating an event does not create any company or person participation evidence.", createEventShape, async (params) => {
206
248
  try {
207
249
  const input = createEventInput.parse(params);
208
250
  return toolResult(await client.post("/api/events", compact({
@@ -218,6 +260,7 @@ export function registerEventTools(server, client = getClient()) {
218
260
  venue: input.venue,
219
261
  officialUrl: input.official_url,
220
262
  lastVerifiedOn: input.last_verified_on,
263
+ eventType: input.event_type,
221
264
  idempotencyKey: input.idempotency_key,
222
265
  })));
223
266
  }
@@ -225,7 +268,7 @@ export function registerEventTools(server, client = getClient()) {
225
268
  return handleToolError(error);
226
269
  }
227
270
  });
228
- server.tool("update_event", "Update or archive one shared event with optimistic versioning and idempotency. Set archive=true as a separate operation with no field changes. This affects every company view that references the event; archiving is non-destructive and keeps all participation evidence.", updateEventShape, async (params) => {
271
+ server.tool("update_event", "Update or archive one shared event with optimistic versioning and idempotency, including its event_type (WEBINAR, CONGRESS, TRADE_SHOW, OTHER, or null for not specified). Set archive=true as a separate operation with no field changes. This affects every company and person view that references the event; archiving is non-destructive and keeps all participation evidence.", updateEventShape, async (params) => {
229
272
  try {
230
273
  const input = updateEventInput.parse(params);
231
274
  const body = input.archive
@@ -247,6 +290,7 @@ export function registerEventTools(server, client = getClient()) {
247
290
  venue: input.venue,
248
291
  officialUrl: input.official_url,
249
292
  lastVerifiedOn: input.last_verified_on,
293
+ eventType: input.event_type,
250
294
  expectedVersion: input.expected_version,
251
295
  idempotencyKey: input.idempotency_key,
252
296
  });
@@ -256,11 +300,13 @@ export function registerEventTools(server, client = getClient()) {
256
300
  return handleToolError(error);
257
301
  }
258
302
  });
259
- server.tool("list_event_participations", "List current company-event participation snapshots in one tenant. Defaults explicitly to VERIFIED only. To review uncertain evidence or uncertain historical facts, pass statuses containing UNVERIFIED, DISPUTED or REFUTED; unrequested fact statuses are hidden and counted. Never cite uncertain data, or the absence of a record, as proof of attendance or non-attendance.", {
303
+ server.tool("list_event_participations", "List current event participation snapshots of companies and persons in one tenant. Without target, person_ids or a Person lead_id, only company participations are returned (historical behavior); pass target=PERSON, person_ids or the lead_id of a Person lead to read persons. Each item carries target (COMPANY or PERSON), accountId, personId, clientDocument and, for a person, personLeads. With the lead_id of a Person lead, the result holds the person's own participations (origin PERSON) and those of their employer (origin EMPLOYER): an employer's event is never a participation of the person. REGISTERED (registered), ATTENDED (attended) and INVITED (invited) are distinct facts: a registration never proves attendance. Defaults explicitly to VERIFIED only. To review uncertain evidence or uncertain historical facts, pass statuses containing UNVERIFIED, DISPUTED or REFUTED; unrequested fact statuses are hidden and counted. Never cite uncertain data, or the absence of a record, as proof of attendance or non-attendance.", {
260
304
  organization_id: z.string().trim().min(1),
261
305
  event_ids: z.array(z.string().trim().min(1)).max(100).optional(),
262
306
  account_ids: z.array(z.string().trim().min(1)).max(100).optional(),
263
- lead_id: z.string().trim().min(1).optional(),
307
+ person_ids: z.array(z.string().trim().min(1)).max(100).optional().describe("Canonical person ids: canonicalIdentity.id of Person leads (kind PERSON)."),
308
+ target: target.optional().describe("COMPANY or PERSON. Omitted: companies only, unless person_ids or a Person lead_id is given."),
309
+ lead_id: z.string().trim().min(1).optional().describe("Company lead: its account's participations. Person lead: its own participations (origin PERSON) and its employer's (origin EMPLOYER)."),
264
310
  editions: z.array(z.string().trim().min(1).max(200)).max(100).optional(),
265
311
  roles: z.array(role).max(20).optional(),
266
312
  statuses: z.array(status).min(1).max(10).optional().default(["VERIFIED"]),
@@ -268,8 +314,8 @@ export function registerEventTools(server, client = getClient()) {
268
314
  starts_on_to: dateOnly.optional(),
269
315
  include_facts: z.boolean().optional().default(false),
270
316
  limit: z.number().int().min(1).max(100).optional().default(50),
271
- cursor: z.string().trim().min(1).optional(),
272
- }, async ({ organization_id, event_ids, account_ids, lead_id, editions, roles, statuses, starts_on_from, starts_on_to, include_facts, limit, cursor }) => {
317
+ cursor: z.string().trim().min(1).optional().describe("Opaque cursor returned by the previous page; pass it back unchanged."),
318
+ }, async ({ organization_id, event_ids, account_ids, person_ids, target, lead_id, editions, roles, statuses, starts_on_from, starts_on_to, include_facts, limit, cursor }) => {
273
319
  try {
274
320
  const params = new URLSearchParams({
275
321
  organizationId: organization_id,
@@ -279,6 +325,9 @@ export function registerEventTools(server, client = getClient()) {
279
325
  });
280
326
  setCsv(params, "eventIds", event_ids);
281
327
  setCsv(params, "accountIds", account_ids);
328
+ setCsv(params, "personIds", person_ids);
329
+ if (target)
330
+ params.set("target", target);
282
331
  if (lead_id)
283
332
  params.set("leadId", lead_id);
284
333
  setCsv(params, "editions", editions);
@@ -296,7 +345,7 @@ export function registerEventTools(server, client = getClient()) {
296
345
  return handleToolError(error);
297
346
  }
298
347
  });
299
- server.tool("get_event_participation", "Get one explicitly selected current participation and its immutable fact history. Access defaults to VERIFIED only; pass statuses explicitly to review UNVERIFIED, DISPUTED or REFUTED facts. Uncertain evidence is not verified attendance, and missing data never means non-participation.", {
348
+ server.tool("get_event_participation", "Get one explicitly selected current participation of a company or a person and its immutable fact history, with target, accountId, personId, clientDocument and, for a person, personLeads. Access defaults to VERIFIED only; pass statuses explicitly to review UNVERIFIED, DISPUTED or REFUTED facts. REGISTERED is not ATTENDED. Uncertain evidence is not verified attendance, and missing data never means non-participation.", {
300
349
  organization_id: z.string().trim().min(1),
301
350
  participation_id: z.string().trim().min(1),
302
351
  statuses: z.array(status).min(1).max(10).optional().default(["VERIFIED"]),
@@ -320,7 +369,7 @@ export function registerEventTools(server, client = getClient()) {
320
369
  return handleToolError(error);
321
370
  }
322
371
  });
323
- server.tool("record_event_participations", "Create participation evidence, add a corroborating source, or correct 1-100 company-event observations. Set validate_only=true for a no-write dry-run. activity_summary is the readable account of what the company does at the event; proof_excerpt remains the exact agent/audit evidence. Each item targets exactly one account_id or resolvable lead_id and has its own idempotency key. Corrections append an immutable fact and require correction_reason; there is no delete operation. VERIFIED requires a source URL, proof excerpt and verification date. Uncertain or absent data cannot be cited as attendance.", {
372
+ server.tool("record_event_participations", "Create participation evidence, add a corroborating source, or correct 1-100 event observations of companies or persons. Set validate_only=true for a no-write dry-run. Each item targets exactly one account_id, lead_id or person_id and has its own idempotency key. A Company lead or account_id writes a company participation, exactly as before. The lead_id of a Person lead, or a person_id, writes a personal participation on the canonical person: it follows that person across every group. Person roles: REGISTERED, ATTENDED, INVITED, SPEAKER, ORGANIZER, OTHER; REGISTERED, ATTENDED and INVITED are refused for a company. Registered, attended and invited are three distinct facts: a registration never proves attendance. A registrants list provided by the client is valid proof of registration: record role REGISTERED with client_document { file_name, received_on, provided_by } and no URL, including for a person who later unsubscribed or registered several times. An employer's event is never a participation of the person: never record a person from their company's participation. activity_summary is the readable account of what the company or person does at the event; proof_excerpt remains the exact agent/audit evidence. Corrections append an immutable fact and require correction_reason; there is no delete operation. VERIFIED requires a source URL or a client_document, a proof excerpt and a verification date. Uncertain or absent data cannot be cited as attendance, and the absence of a record proves nothing.", {
324
373
  organization_id: z.string().trim().min(1),
325
374
  batch_id: z.string().trim().min(1).max(255),
326
375
  validate_only: z.boolean().optional().default(false),
@@ -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
+ }
@@ -1,17 +1,16 @@
1
1
  import { z } from "zod";
2
2
  import { getClient } from "../client.js";
3
3
  import { toolResult, handleToolError } from "../types.js";
4
+ import { eventRole } from "./events.js";
4
5
  const DEFAULT_LEAD_FIELDS = ["firstName", "lastName", "email", "company", "jobTitle", "linkedin"];
5
6
  const eventFilterSchema = z.object({
6
7
  event_ids: z.array(z.string().trim().min(1)).max(100).optional(),
7
8
  editions: z.array(z.string().trim().min(1).max(200)).max(100).optional(),
8
- roles: z.array(z.enum([
9
- "EXHIBITOR", "SPONSOR", "SPEAKER", "PARTNER", "ORGANIZER",
10
- "CONTRIBUTOR", "PARTICIPANT", "UNKNOWN", "OTHER",
11
- ])).max(20).optional(),
9
+ roles: z.array(eventRole).max(20).optional(),
12
10
  statuses: z.array(z.enum(["UNVERIFIED", "VERIFIED", "DISPUTED", "REFUTED"])).min(1).max(10).optional(),
13
11
  starts_on_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
14
12
  starts_on_to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
13
+ subject: z.enum(["EMPLOYER", "PERSON"]).optional().describe("EMPLOYER (default, historical behavior): leads whose company carries the participation. PERSON: only Person leads that carry the participation themselves, e.g. the registrants of a webinar. An employer's event is never a participation of the person."),
15
14
  }).strict().superRefine((value, context) => {
16
15
  if (value.starts_on_from && value.starts_on_to && value.starts_on_to < value.starts_on_from) {
17
16
  context.addIssue({ code: z.ZodIssueCode.custom, path: ["starts_on_to"], message: "starts_on_to must be on or after starts_on_from." });
@@ -169,7 +168,9 @@ export function registerLeadTools(server, client = getClient()) {
169
168
  "full-text search, saved views, and compact field projections. Returns lead ID plus " +
170
169
  "common identity fields by default. Pass `fields` for a workflow-specific projection " +
171
170
  "and `include_schema=true` only when the schema is explicitly needed. event_filter " +
172
- "uses one correlated company participation and defaults explicitly to VERIFIED; " +
171
+ "uses one correlated participation and defaults explicitly to VERIFIED; subject=PERSON " +
172
+ "keeps only persons who carry the participation themselves, subject=EMPLOYER (default) " +
173
+ "matches through the company. A registration (REGISTERED) is not attendance (ATTENDED); " +
173
174
  "uncertain or absent data must never be cited as attendance or non-attendance.", {
174
175
  group_id: z.string().describe("ID of the lead group to query."),
175
176
  view_id: z
@@ -202,7 +203,7 @@ export function registerLeadTools(server, client = getClient()) {
202
203
  "operator to value. Operators: equals, not_equals, contains, not_contains, " +
203
204
  "gte, lte, is_true, is_false, is_null, is_not_null. " +
204
205
  'Example: {"industry": {"contains": "tech"}, "company": {"is_not_null": "true"}}'),
205
- event_filter: eventFilterSchema.optional().describe("Structured company-event participation filter. Values within one list use OR; event, edition, role, status and date dimensions use AND against the same participation. statuses defaults to [VERIFIED]. Pass uncertain statuses explicitly for review. There is no confidence score, negative operator or absence filter."),
206
+ event_filter: eventFilterSchema.optional().describe("Structured event participation filter. subject chooses whose participation counts: EMPLOYER (default, the company's) or PERSON (the person's own). Values within one list use OR; event, edition, role, status and date dimensions use AND against the same participation. statuses defaults to [VERIFIED]. Pass uncertain statuses explicitly for review. There is no confidence score, negative operator or absence filter: the absence of a record proves nothing."),
206
207
  }, async ({ group_id, view_id, page, limit, search, fields, include_schema, filters, event_filter }) => {
207
208
  try {
208
209
  const params = new URLSearchParams();
@@ -231,6 +232,7 @@ export function registerLeadTools(server, client = getClient()) {
231
232
  statuses: checked.statuses ?? ["VERIFIED"],
232
233
  ...(checked.starts_on_from ? { startsOnFrom: checked.starts_on_from } : {}),
233
234
  ...(checked.starts_on_to ? { startsOnTo: checked.starts_on_to } : {}),
235
+ ...(checked.subject ? { subject: checked.subject } : {}),
234
236
  }));
235
237
  }
236
238
  const data = await client.get("/get-leads", params);
@@ -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.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agifyai/leadify-mcp",
3
- "version": "9.5.0",
3
+ "version": "9.7.0",
4
4
  "description": "MCP server for Leadify lead management API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",