@agifyai/leadify-mcp 8.5.4 → 8.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -123,7 +123,8 @@ src/
123
123
  ├── leads.ts
124
124
  ├── events.ts
125
125
  ├── campaigns.ts
126
- ├── dataroom.ts
126
+ ├── context_workspace.ts
127
+ ├── context_entities.ts
127
128
  ├── personas.ts
128
129
  └── ... # un fichier = un domaine fonctionnel
129
130
  ```
@@ -245,12 +246,12 @@ Leadify et ne doit jamais être envoyée par un agent MCP.
245
246
  | `get_campaign_logs` | Récupérer les logs de campagne avec filtres et pagination. |
246
247
  | `delete_campaign_log` | Supprimer une entrée de log de campagne. |
247
248
  | `update_campaign_stats` | Mettre à jour les statistiques d'email d'une campagne pour un groupe de leads. |
248
- | `create_campaign` | Créer une campagne DRAFT mono-canal rattachée à un groupe de leads. `channel` (`LINKEDIN` ou `EMAIL`) est obligatoire ; `start_at`, `end_at` et `timezone` configurent la fenêtre métier. À `end_at`, Leadify ferme la campagne idempotemment, annule uniquement les jobs prouvablement pré-fournisseur et préserve les autres pour réconciliation. |
249
- | `update_campaign_configuration` | Corriger le canal ou la fenêtre d'une campagne DRAFT encore ouverte, sans activation ni enrôlement. |
250
- | `list_campaigns` | Lister compactement les campagnes d'une organisation explicitement sélectionnée et leur statut courant. |
251
- | `get_campaign` | Récupérer les détails d'une campagne et ses KPIs temps réel. |
252
- | `update_campaign_status` | Changer le statut d'une campagne (DRAFT, ACTIVE, PAUSED, COMPLETED). |
253
- | `export_campaign` | Exporter les statistiques complètes d'une campagne en CSV. |
249
+ | `create_campaign` | Créer une campagne DRAFT mono-canal rattachée à un Lead Group qui possède déjà sa paire Persona–Offre. Aucun `persona_id` ou contexte indépendant n’est accepté au niveau Campagne. `channel` (`LINKEDIN` ou `EMAIL`) est obligatoire ; `start_at` inclusif, `end_at` exclusif et `timezone` IANA configurent la fenêtre métier. À `end_at`, Leadify met la campagne en pause réversible (`WINDOW_END`) sans la clôturer. |
250
+ | `update_campaign_configuration` | Modifier la fenêtre de toute campagne encore `OPEN`; le canal reste modifiable uniquement en DRAFT. Prolonger `end_at` dans le futur ou le supprimer relance automatiquement une pause `WINDOW_END` après validation du fournisseur, mais jamais une pause manuelle. La fenêtre demandée reste enregistrée si cette validation échoue. |
251
+ | `list_campaigns` | Lister compactement les campagnes d'une organisation explicitement sélectionnée, avec état effectif, fenêtre, motif de pause et clôture. |
252
+ | `get_campaign` | Récupérer les détails d'une campagne, son état effectif, sa clôture et son éventuel rapport final figé, ainsi que ses KPIs temps réel. |
253
+ | `update_campaign_status` | Changer le statut d'une campagne. `PAUSED` est une pause manuelle réversible ; `COMPLETED` déclenche la clôture définitive terminale et son rapport final immuable. |
254
+ | `export_campaign` | Exporter les statistiques en CSV ; après clôture définitive, l'export provient du rapport final figé. |
254
255
  | `signal_upsert` | Créer ou mettre à jour un signal de business intelligence (INFO, CRITICAL, GOLDEN). Remet le state à `active`. |
255
256
  | `signal_expire` | Expirer un signal (événement périmé). Flip de `state` uniquement. |
256
257
  | `signal_disable` | Désactiver un signal (faux positif / écarté manuellement). Flip de `state` uniquement. |
@@ -262,8 +263,18 @@ Leadify et ne doit jamais être envoyée par un agent MCP.
262
263
  | `unipile_read_messages` | Lire les messages LinkedIn Unipile bornés d'un profil explicitement sélectionné. |
263
264
  | `leadify_read_activity_feed` | Lire l'audit borné d'un lead ; le feed ne constitue jamais une preuve de réponse. |
264
265
  | `get_commercial_truth` | Réconcilier CRM, email, LinkedIn, Unipile et activité avec un statut, une fraîcheur et une action recommandée ; aucune écriture ni envoi. |
265
- | `compile_context_pack` | Compiler en lecture seule un Context Pack canonique depuis une cible explicite (`organization`, `lead_group`, `campaign`, `account`, `person`) ou un couple lead + campagne pour `WRITE_OUTREACH`. Retourne publications courantes, sources, freshness, contradictions et provenance. N’utilise jamais la Data Room legacy ni ne choisit de cible implicite. |
266
- | `get_context_workspace` | Lire le Context Workspace canonique versionné (`company_brain` ou `gtm_playbook`) d'une organisation explicitement sélectionnée. Retourne le brouillon et la dernière version publiée. |
266
+ | `compile_context_pack` | Compiler en lecture seule un Context Pack canonique depuis une cible explicite (`organization`, `lead_group`, `campaign`, `account`, `person`) ou un couple lead + campagne pour `WRITE_OUTREACH`. Retourne publications courantes, sources, freshness, contradictions et provenance, sans choisir de cible implicite. |
267
+ | `get_context_workspace` | Lire le seul Context Workspace encore exposé et versionné : le `company_brain` d'une organisation explicitement sélectionnée. Retourne le brouillon et la dernière version publiée ; l'historique GTM reste stocké mais n'est plus exposé. |
268
+ | `list_verticals` / `get_vertical` | Lister ou lire les Verticales JSON simples d’une organisation explicitement sélectionnée, avec leurs Personas et Offres liées. |
269
+ | `create_vertical` | Créer une Verticale tenant-scoped en `DRAFT`, sans activation ni sélection implicite. Son schéma JSON fermé porte les règles sectorielles et `commercialExperience` ; les métadonnées de preuve/source/provenance et les champs propres à l’Offre sont refusés. |
270
+ | `update_vertical` | Modifier le nom et/ou des clés JSON d’une Verticale après relecture. `expected_updated_at` est obligatoire ; une révision périmée est refusée avec `409` et impose une nouvelle lecture. Une Verticale `ACTIVE` repasse en `DRAFT`. |
271
+ | `change_vertical_status` | Passer une Verticale entre `DRAFT`, `ACTIVE` et `ARCHIVED` avec contrôle optimiste, readiness et protection des références. |
272
+ | `list_offers` / `get_offer` | Lister ou lire les Offres JSON simples tenant-scoped, leurs Verticales et leurs Lead Groups. |
273
+ | `create_offer` | Créer une Offre tenant-scoped en `DRAFT`, sans relation ni activation implicite. Son schéma JSON fermé est l'unique propriétaire des allégations, résultats démontrés et cas clients ; les Verticales référencent les identifiants de ces cas. |
274
+ | `update_offer` | Modifier le nom et/ou des clés JSON d’une Offre avec `expected_updated_at` obligatoire et refus `409` de toute écriture périmée. Une Offre `ACTIVE` repasse en `DRAFT`. |
275
+ | `change_offer_status` | Passer une Offre entre `DRAFT`, `ACTIVE` et `ARCHIVED` avec contrôle optimiste, readiness et protection des références. |
276
+ | `link_vertical_offer` / `unlink_vertical_offer` | Créer ou retirer la relation SQL tenant-scoped. Les `updatedAt` lus pour les deux objets sont obligatoires ; aucune relation cross-tenant ou dépendance Lead Group n’est contournée. |
277
+ | `preview_resolved_context` | Résoudre sans effet Company Brain + Verticale + Offre liée + Persona, ainsi que sender/CTA du Lead Group lorsqu'ils existent. Retourne le digest courant avec `dryRun:true`, `persisted:false`, `noSend:true`, `runtimeApplied:false` et `externalActivation:0`. |
267
278
  | `list_canonical_relationships` | Lire les liens canoniques typés d'une organisation, dans les deux directions. |
268
279
  | `create_canonical_relationship` | Créer un lien canonique tenant-scoped et idempotent entre deux identités fortes. |
269
280
  | `update_canonical_relationship` | Modifier, restaurer ou tombstoner un lien via le ledger transactionnel partagé. |
@@ -271,20 +282,12 @@ Leadify et ne doit jamais être envoyée par un agent MCP.
271
282
  | `preview_canonical_relationship_migration` | Prévisualiser une migration de champs historiques, divergences et quarantaines comprises, sans mutation. |
272
283
  | `apply_canonical_relationship_migration` | Appliquer exactement un plan prévisualisé et borné grâce à son digest. |
273
284
  | `rollback_canonical_relationship_migration` | Tombstoner les liens créés par un plan de migration précis. |
274
- | `update_company_brain_sections` | Modifier uniquement les sections indiquées du Company Brain : le MCP relit le brouillon ou la dernière publication, fusionne les sections ciblées, puis sauvegarde avec révision attendue et clé d'idempotence. |
275
- | `update_gtm_playbook_sections` | Modifier uniquement les sections indiquées du GTM Playbook, avec la même lecture-fusion-révision atomiquement contrôlée. |
276
- | `publish_context_workspace` | Publier une révision prête du Context Workspace après confirmation explicite (`confirm_publish: true`). Admin de l'organisation requis ; les raisons de non-readiness sont renvoyées par le serveur. |
277
- | `describe_company_info_schema` | Lire le schéma companyInfo v2 mirroré côté MCP (enums, max-lengths, sections, patch tool par section). À appeler avant la première update. |
278
- | `update_company_info_identity` | Patch name + website. Reads + merges + PUTs le full v2 payload. |
279
- | `update_company_info_snapshot` | Patch du bloc snapshot (pitchOneLiner, marketSpecialty, stage, salesTeamSizeFR, geo, constraints). geo merge shallow. |
280
- | `update_company_info_constraint` | Add / replace / remove une seule contrainte dans snapshot.constraints[]. label + type enum + note≤150. Cap 10. |
281
- | `update_company_info_product` | Add / replace / remove un seul produit dans products[]. category enum strict + regulatory sub-block. Cap 5. |
282
- | `update_company_info_economics` | Patch du bloc economics (pricingModel, ticketRange, salesCycleMonths, triggers, defaultBaseline). |
283
- | `update_company_info_product_icp` | Add / replace / remove un seul ICP dans productICPs[]. Lookup par productId (must match a products[].name). Cap 5. |
284
- | `update_company_info_proof_wording` | Patch du bloc proofWording (keyMetrics, miniStories, forbiddenWords). |
285
- | `upsert_persona` | Créer ou mettre à jour atomiquement un Persona, avec `schema_packs` obligatoire à la création. |
286
- | `update_persona_schema_packs` | Remplacer uniquement les CRM Schema Packs d’un Persona et relire les preuves de readiness des groupes liés. |
287
- | `get_persona` | Récupérer un persona par son ID. |
285
+ | `update_company_brain_sections` | Modifier uniquement les sections globales encore actives (`identity`, `positioning`, `allowedVocabulary`, `prohibitedVocabulary`, `legalConstraints`). Les anciennes sections Offre/Verticale sont filtrées et refusées. |
286
+ | `publish_context_workspace` | Publier une révision prête du Company Brain après confirmation explicite (`confirm_publish: true`). Admin de l'organisation requis ; les raisons de non-readiness sont renvoyées par le serveur. |
287
+ | `upsert_persona` | Créer ou mettre à jour un Persona strictement lié à une organisation et à une Verticale ; `schema_packs` est obligatoire à la création et `expected_updated_at` à chaque mise à jour. Les Personas globales ou orphelines ne sont jamais créées. |
288
+ | `change_persona_status` | Passer un Persona entre `DRAFT`, `ACTIVE` et `ARCHIVED` avec le même verrou `updated_at`, readiness et protection des références. |
289
+ | `update_persona_schema_packs` | Remplacer uniquement les CRM Schema Packs d’un Persona tenant-scoped avec `expected_updated_at`, puis relire la readiness des groupes liés. |
290
+ | `get_persona` | Récupérer un Persona par son ID et son organisation explicite. Les anciennes Personas globales/orphelines sont exclues. |
288
291
  | `get_lead_group_persona` | Récupérer le persona assigné à un groupe de leads. |
289
292
  | `list_data_sources` | Lister toutes les sources de données configurées (par pays puis nom). |
290
293
  | `create_data_source` | Créer une nouvelle source de données (admin uniquement). |
@@ -298,7 +301,7 @@ Leadify et ne doit jamais être envoyée par un agent MCP.
298
301
  | `update_outreach_case_study` | Ajouter / remplacer / supprimer un case study par index, sans re-envoyer la liste. |
299
302
  | `update_outreach_urls` | Patch booking_url et/ou website_url uniquement. |
300
303
  | `set_outreach_connection_request` | Toggle du flag connectionRequestEnabled (LinkedIn invite vs cold DM). |
301
- | `trigger_outreach` | Générer un message d'outreach via l'agent Trigger.dev (dry-run par défaut). `force=true` pour re-générer sur un lead déjà traité. |
304
+ | `trigger_outreach` | Générer un message via le runtime Write Outreach (dry-run par défaut). Le rédacteur libre peut fournir un `context_selection` complet Verticale–Offre–Persona ; aucun objet ou défaut n’est inféré. Avant une écriture, le runtime relit le digest et refuse toute persistance si le contexte a changé. |
302
305
  | `append_fine_tuning` | Appendre du contenu à une section du Fine Tuning (nonNegotiableRules, pitfalls, structure, examples). Toujours en mode append — garantie contractuelle. |
303
306
  | `set_fine_tuning_output_config` | Remplacer uniquement la configuration métier de sorties d’un Fine Tuning (`linkedinConnection`, `linkedinMessage`, `email`, activations et quantités). Préserve Markdown, langues et fallback ; ne génère, ne planifie, n’active ni n’envoie aucun outreach. |
304
307
  | `pipeline_next_lead` | Sélectionner le prochain lead à traiter (score descendant, sans message). Exclusion des IDs déjà vus, limit 1-5. |
package/dist/server.js CHANGED
@@ -17,6 +17,7 @@ import { registerLeadViewTools } from "./tools/views.js";
17
17
  import { registerRelationshipTools } from "./tools/relationships.js";
18
18
  import { registerCommercialTruthTools } from "./tools/commercial_truth.js";
19
19
  import { registerEventTools } from "./tools/events.js";
20
+ import { registerContextEntityTools } from "./tools/context_entities.js";
20
21
  import { MCP_SERVER_NAME, MCP_VERSION } from "./version.js";
21
22
  export function createServer() {
22
23
  const server = new McpServer({
@@ -37,6 +38,7 @@ export function createServer() {
37
38
  registerSignalTools(server);
38
39
  registerActivityTools(server);
39
40
  registerContextWorkspaceTools(server);
41
+ registerContextEntityTools(server);
40
42
  registerDataSourceTools(server);
41
43
  registerPersonaTools(server);
42
44
  registerPipelineTools(server);
@@ -2,6 +2,14 @@ import { z } from "zod";
2
2
  import { getClient } from "../client.js";
3
3
  import { toolResult, handleToolError } from "../types.js";
4
4
  const CAMPAIGN_STATUSES = ["DRAFT", "ACTIVE", "PAUSED", "COMPLETED"];
5
+ const CAMPAIGN_EFFECTIVE_STATES = [
6
+ "DRAFT",
7
+ "SCHEDULED",
8
+ "ACTIVE",
9
+ "PAUSED_MANUAL",
10
+ "PAUSED_WINDOW_END",
11
+ "CLOSED",
12
+ ];
5
13
  function assertCampaignBelongsToOrganization(data, organizationId) {
6
14
  if (!data || typeof data !== "object") {
7
15
  throw new Error("Leadify returned an invalid campaign response");
@@ -89,20 +97,18 @@ export function registerCampaignTools(server, readClient, writeClient, mutationC
89
97
  });
90
98
  // ── create_campaign ────────────────────────────────────────────────────
91
99
  server.tool("create_campaign", "Create a mono-channel DRAFT campaign with an explicit channel, optional ISO-8601 start/end " +
92
- "window, and IANA timezone. At endAt Leadify closes the campaign idempotently, cancels only " +
93
- "pre-provider jobs, and preserves any provider-barrier job for reconciliation.", {
100
+ "window, and IANA timezone. startAt is inclusive and endAt is exclusive. Reaching endAt " +
101
+ "reversibly pauses the campaign with reason WINDOW_END while closure remains OPEN; it never " +
102
+ "creates the permanent closure report. The selected Lead Group already owns the Persona and " +
103
+ "Offer; Campaign never accepts an independent context selection.", {
94
104
  name: z.string().describe("Campaign name."),
95
105
  lead_group_id: z.string().describe("ID of the lead group to associate."),
96
106
  channel: z.enum(["LINKEDIN", "EMAIL"]).describe("Required effective delivery channel. Create separate campaigns for LinkedIn and email."),
97
107
  start_at: z.string().datetime().optional().describe("Optional inclusive campaign start timestamp in ISO-8601 UTC."),
98
- end_at: z.string().datetime().optional().describe("Optional exclusive campaign end timestamp in ISO-8601 UTC; must be after start_at."),
108
+ end_at: z.string().datetime().optional().describe("Optional exclusive campaign end timestamp in ISO-8601 UTC; must be after start_at. Reaching it pauses, but does not close, the campaign."),
99
109
  timezone: z.string().min(1).optional().describe("IANA timezone used for the campaign business window, for example Europe/Paris."),
100
110
  description: z.string().optional().describe("Optional description."),
101
- persona_id: z
102
- .string()
103
- .optional()
104
- .describe("ID of the persona to assign (optional)."),
105
- }, async ({ name, lead_group_id, channel, start_at, end_at, timezone, description, persona_id }) => {
111
+ }, async ({ name, lead_group_id, channel, start_at, end_at, timezone, description }) => {
106
112
  try {
107
113
  const body = {
108
114
  name,
@@ -117,8 +123,6 @@ export function registerCampaignTools(server, readClient, writeClient, mutationC
117
123
  body.timezone = timezone;
118
124
  if (description !== undefined)
119
125
  body.description = description;
120
- if (persona_id !== undefined)
121
- body.personaId = persona_id;
122
126
  const data = await (writeClient ?? getClient()).post("/api/campaign", body);
123
127
  return toolResult(data);
124
128
  }
@@ -126,12 +130,16 @@ export function registerCampaignTools(server, readClient, writeClient, mutationC
126
130
  return handleToolError(error);
127
131
  }
128
132
  });
129
- server.tool("update_campaign_configuration", "Correct an open DRAFT campaign's channel or lifecycle window before activation. This tool refuses ACTIVE, PAUSED, COMPLETED, or closed campaigns and never activates or enrolls prospects.", {
133
+ server.tool("update_campaign_configuration", "Edit the lifecycle window of any OPEN campaign; changing channel remains limited to DRAFT. " +
134
+ "Extending endAt into the future or removing it automatically resumes only a campaign paused " +
135
+ "because WINDOW_END, after provider validation and immediate replanning. A MANUAL pause never " +
136
+ "auto-resumes. The requested window remains saved when provider validation fails, and a CLOSED " +
137
+ "campaign is terminal.", {
130
138
  id: z.string().describe("Campaign ID."),
131
- channel: z.enum(["LINKEDIN", "EMAIL"]).optional(),
132
- start_at: z.string().datetime().nullable().optional(),
133
- end_at: z.string().datetime().nullable().optional(),
134
- timezone: z.string().min(1).optional(),
139
+ channel: z.enum(["LINKEDIN", "EMAIL"]).optional().describe("Delivery channel; mutable only while status is DRAFT."),
140
+ start_at: z.string().datetime().nullable().optional().describe("Inclusive UTC start timestamp, or null to remove the lower bound."),
141
+ end_at: z.string().datetime().nullable().optional().describe("Exclusive UTC end timestamp, or null to remove the upper bound. A future value or null can auto-resume only a WINDOW_END pause."),
142
+ timezone: z.string().min(1).optional().describe("IANA timezone used for the business window, for example Europe/Paris."),
135
143
  }, async ({ id, channel, start_at, end_at, timezone }) => {
136
144
  try {
137
145
  const body = {};
@@ -151,8 +159,9 @@ export function registerCampaignTools(server, readClient, writeClient, mutationC
151
159
  });
152
160
  // ── get_campaign ───────────────────────────────────────────────────────
153
161
  server.tool("get_campaign", "Retrieve a campaign chosen from list_campaigns, scoped to the explicitly selected " +
154
- "organization. Includes its normalized current status (DRAFT, ACTIVE, PAUSED, or COMPLETED) " +
155
- "and real-time KPIs. Never use campaign logs as the source of status.", {
162
+ "organization. Includes status, effectiveState, pauseReason, pausedAt, window, terminal " +
163
+ "closure metadata, immutable final report when present, and real-time KPIs. Never use " +
164
+ "campaign logs as the source of lifecycle state.", {
156
165
  id: z.string().describe("Campaign ID."),
157
166
  organization_id: z.string().describe("Organization selected explicitly via discover_leadify_context or list_organizations."),
158
167
  }, async ({ id, organization_id }) => {
@@ -165,9 +174,10 @@ export function registerCampaignTools(server, readClient, writeClient, mutationC
165
174
  }
166
175
  });
167
176
  // ── list_campaigns ─────────────────────────────────────────────────────
168
- server.tool("list_campaigns", "List campaigns in one explicitly selected organization with compact current status metadata. " +
169
- "Use this after discover_leadify_context to identify ACTIVE, DRAFT, PAUSED, or COMPLETED " +
170
- "campaigns without reading historical logs.", {
177
+ server.tool("list_campaigns", "List campaigns in one explicitly selected organization with compact persisted and effective " +
178
+ "lifecycle metadata. Use effectiveState to distinguish SCHEDULED, ACTIVE, a manual pause, an " +
179
+ "end-of-window pause, and permanent closure without reading historical logs. Fetch get_campaign " +
180
+ "for the immutable final report.", {
171
181
  organization_id: z.string().describe("Organization selected explicitly via discover_leadify_context or list_organizations."),
172
182
  status: z.enum(CAMPAIGN_STATUSES).optional().describe("Optional current-status filter."),
173
183
  limit: z.number().int().min(1).max(100).optional().default(50).describe("Maximum campaigns returned, bounded to 100."),
@@ -188,24 +198,46 @@ export function registerCampaignTools(server, readClient, writeClient, mutationC
188
198
  if (!CAMPAIGN_STATUSES.includes(campaign.status)) {
189
199
  throw new Error("Leadify returned a campaign with an invalid current status");
190
200
  }
201
+ if (!CAMPAIGN_EFFECTIVE_STATES.includes(campaign.effectiveState)) {
202
+ throw new Error("Leadify returned a campaign with an invalid effective state");
203
+ }
204
+ const closure = campaign.closure;
205
+ if (!closure || typeof closure !== "object") {
206
+ throw new Error("Leadify returned a campaign without closure metadata");
207
+ }
208
+ const closureRecord = closure;
191
209
  return {
192
210
  id: campaign.id,
193
211
  name: campaign.name,
194
212
  status: campaign.status,
213
+ effectiveState: campaign.effectiveState,
195
214
  channel: campaign.channel,
196
215
  leadGroupId: campaign.leadGroupId,
216
+ startAt: campaign.startAt,
217
+ endAt: campaign.endAt,
218
+ timezone: campaign.timezone,
219
+ pauseReason: campaign.pauseReason,
220
+ pausedAt: campaign.pausedAt,
221
+ closure: {
222
+ state: closureRecord.state,
223
+ closedAt: closureRecord.closedAt,
224
+ terminal: closureRecord.terminal,
225
+ reportAvailable: closureRecord.report !== null && closureRecord.report !== undefined,
226
+ },
197
227
  createdAt: campaign.createdAt,
198
228
  updatedAt: campaign.updatedAt,
199
229
  };
200
230
  });
201
231
  const filtered = status ? campaigns.filter((campaign) => campaign.status === status) : campaigns;
202
232
  const statusCounts = Object.fromEntries(CAMPAIGN_STATUSES.map((value) => [value, campaigns.filter((campaign) => campaign.status === value).length]));
233
+ const effectiveStateCounts = Object.fromEntries(CAMPAIGN_EFFECTIVE_STATES.map((value) => [value, campaigns.filter((campaign) => campaign.effectiveState === value).length]));
203
234
  return toolResult({
204
235
  organizationId: organization_id,
205
236
  campaigns: filtered.slice(0, limit),
206
237
  total: filtered.length,
207
238
  truncated: filtered.length > limit,
208
239
  statusCounts,
240
+ effectiveStateCounts,
209
241
  });
210
242
  }
211
243
  catch (error) {
@@ -214,8 +246,9 @@ export function registerCampaignTools(server, readClient, writeClient, mutationC
214
246
  });
215
247
  // ── update_campaign_status ─────────────────────────────────────────────
216
248
  server.tool("update_campaign_status", "Change a campaign's workflow status. Allowed values: DRAFT, ACTIVE, PAUSED, COMPLETED. " +
217
- "Moving to ACTIVE starts outbound execution; PAUSED halts it; COMPLETED marks the " +
218
- "campaign as finished. Always confirm with the user before calling this tool.", {
249
+ "Moving to ACTIVE starts outbound execution; PAUSED is a reversible manual pause; COMPLETED " +
250
+ "means explicit permanent closure and creates the immutable final report. Permanent closure is " +
251
+ "terminal and cannot be reopened. Always confirm with the user before calling this tool.", {
219
252
  id: z.string().describe("Campaign ID."),
220
253
  status: z
221
254
  .enum(["DRAFT", "ACTIVE", "PAUSED", "COMPLETED"])
@@ -230,8 +263,9 @@ export function registerCampaignTools(server, readClient, writeClient, mutationC
230
263
  }
231
264
  });
232
265
  // ── export_campaign ────────────────────────────────────────────────────
233
- server.tool("export_campaign", "Export a campaign's full statistics and metrics as CSV. Returns the raw CSV text. " +
234
- "Use this for external analysis or reporting.", {
266
+ server.tool("export_campaign", "Export a campaign's statistics and metrics as CSV. A permanently closed campaign is exported " +
267
+ "from its immutable final report, so late replies remain visible in current activity without " +
268
+ "rewriting the export. Returns the raw CSV text.", {
235
269
  id: z.string().describe("Campaign ID."),
236
270
  }, async ({ id }) => {
237
271
  try {