@agifyai/leadify-mcp 6.0.0 → 7.0.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,7 +195,10 @@ Conséquences pratiques :
195
195
  | `get_campaign` | Récupérer les détails d'une campagne et ses KPIs temps réel. |
196
196
  | `update_campaign_status` | Changer le statut d'une campagne (DRAFT, ACTIVE, PAUSED, COMPLETED). |
197
197
  | `export_campaign` | Exporter les statistiques complètes d'une campagne en CSV. |
198
- | `add_signal` | Ajouter un signal de business intelligence à un lead (INFO, CRITICAL, GOLDEN). |
198
+ | `signal_upsert` | Créer ou mettre à jour un signal de business intelligence (INFO, CRITICAL, GOLDEN). Remet le state à `active`. |
199
+ | `signal_expire` | Expirer un signal (événement périmé). Flip de `state` uniquement. |
200
+ | `signal_disable` | Désactiver un signal (faux positif / écarté manuellement). Flip de `state` uniquement. |
201
+ | `signal_delete` | Supprimer définitivement un signal (cas rare : donnée erronée, doublon). |
199
202
  | `add_activity` | Journaliser une interaction prospect (LinkedIn, email, call) dans le feed du lead. |
200
203
  | `get_data_room` | Récupérer la data room complète : infos société (v2), documents, personas. |
201
204
  | `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. |
@@ -276,16 +276,17 @@ export function registerDataRoomTools(server) {
276
276
  }
277
277
  });
278
278
  // ── get_outreach_context ──────────────────────────────────────────────
279
- server.tool("get_outreach_context", "Get a curated outreach context for a lead group: the outreach-relevant slice " +
280
- "of companyInfo only (identity, pitch, constraints, curated products, economics " +
281
- "triggers/baseline, product ICP roles, proof wording). " +
279
+ server.tool("get_outreach_context", "Get a lean outreach context for a lead group. Returns the shape " +
280
+ "`{ leadGroupId, leadGroupName, organizationId, company }` where `company` is the " +
281
+ "outreach-relevant slice of companyInfo (identity, pitch, constraints, curated " +
282
+ "products, economics triggers/baseline, product ICP roles, proof wording). " +
282
283
  "Takes a lead_group_id (NOT organization_id) — the org is resolved server-side, " +
283
284
  "eliminating the cross-org footgun of get_data_room. " +
284
- "Returns a HARD error if no persona is linked to the group. " +
285
- "NOTE: the `outreach` settings block is NOT returned by this tool " +
286
- "outreach configuration (positioning, tone, rules, templates, etc.) is " +
287
- "managed exclusively via the Fine Tuning tools (append_fine_tuning / " +
288
- "set_fine_tuning / get_fine_tuning). " +
285
+ "Does NOT require a persona: the group needs no linked persona and an empty data " +
286
+ "room returns success (not an error). No persona or outreach block is returned. " +
287
+ "Outreach configuration (positioning, tone, rules, templates, etc.) is managed " +
288
+ "exclusively via the Fine Tuning tools (append_fine_tuning / set_fine_tuning / " +
289
+ "get_fine_tuning). " +
289
290
  "Use this instead of get_data_room + get_lead_group_persona in the outreach " +
290
291
  "pipeline Phase 0.", {
291
292
  lead_group_id: z.string().describe("ID of the lead group to resolve context for."),
@@ -29,7 +29,9 @@ export function registerFineTuningTools(server) {
29
29
  lead_group_id: z
30
30
  .string()
31
31
  .describe("ID of the lead group whose fine tuning configuration to fetch. " +
32
- "Resolve via list_organizations + get_leads if needed."),
32
+ "Pass the lead_group_id directly; if you need to resolve it, use " +
33
+ "pipeline_next_lead (do NOT use get_leads — its payload is huge and is " +
34
+ "banned in the outreach pipeline)."),
33
35
  }, async ({ lead_group_id }) => {
34
36
  try {
35
37
  const data = await getClient().get(`/api/lead-group/${encodeURIComponent(lead_group_id)}/fine-tuning`);
@@ -21,7 +21,13 @@ export function registerPipelineTools(server) {
21
21
  "`get_lead` call); the caller only needs `lead_id`. " +
22
22
  "The response is decorated with a Trigger.dev `traceUrl` deep-link when missing " +
23
23
  "(https://cloud.trigger.dev/runs/{runId}), so you can always jump to the trace. " +
24
- "Waits up to 300s for the remote agent to complete.", {
24
+ "WAIT BEHAVIOR: this is a single blocking HTTP call to the backend with no " +
25
+ "client-side timeout — the wait is entirely server-side. The backend may return " +
26
+ "BEFORE the agent finishes (observed ~120s) with `status: EXECUTING` and a runId; " +
27
+ "this is NOT a failure — the run keeps going server-side, follow it via the " +
28
+ "traceUrl. In dry-run (dryrun=true) the call is safe to retry, since nothing is " +
29
+ "persisted; for production writes (dryrun=false) prefer following the trace over " +
30
+ "blindly retrying to avoid duplicate generation.", {
25
31
  lead_id: z.string().describe("ID of the lead to generate a message for."),
26
32
  dryrun: z
27
33
  .boolean()
@@ -1,47 +1,242 @@
1
1
  import { z } from "zod";
2
2
  import { getClient } from "../client.js";
3
- import { toolResult, handleToolError } from "../types.js";
3
+ import { toolResult, toolError, handleToolError } from "../types.js";
4
+ // Anti-loop guardrail, NOT a business limit: bounds how many signal mutations
5
+ // a single server run (= one agent session) can perform.
6
+ const MAX_SIGNAL_MUTATIONS_PER_RUN = 20;
7
+ let signalMutationCount = 0;
8
+ function capExceeded() {
9
+ if (signalMutationCount >= MAX_SIGNAL_MUTATIONS_PER_RUN) {
10
+ return toolError(`Signal mutation cap reached (${MAX_SIGNAL_MUTATIONS_PER_RUN} per run). ` +
11
+ "This is an anti-loop guardrail, not a business limit. If you genuinely " +
12
+ "need more mutations, ask the user to restart the MCP session.");
13
+ }
14
+ return null;
15
+ }
16
+ // Best-effort: a feed failure must never fail the signal mutation itself.
17
+ async function appendFeed(entry) {
18
+ try {
19
+ await getClient().post("/activity-feed", { mode: "agent", ...entry });
20
+ return null;
21
+ }
22
+ catch (error) {
23
+ return error instanceof Error ? error.message : String(error);
24
+ }
25
+ }
26
+ function mutationResult(data, feedError) {
27
+ if (feedError === null)
28
+ return toolResult(data);
29
+ return toolResult({
30
+ result: data,
31
+ warning: `Signal mutation succeeded but the activity-feed entry failed: ${feedError}`,
32
+ });
33
+ }
34
+ const reasonParam = z
35
+ .string()
36
+ .describe("One factual sentence explaining WHY you are performing this mutation " +
37
+ "(e.g. 'Tender closed on 2026-05-30, budget consumed.'). Recorded in the " +
38
+ "lead's append-only activity feed.");
4
39
  export function registerSignalTools(server) {
5
- // ── add_signal ─────────────────────────────────────────────────────────
6
- server.tool("add_signal", "Attach a business intelligence signal to a lead. Signals report key events picked " +
7
- "up by external agents — budget approvals, position changes, tender publications, " +
8
- "etc. Each signal has a level (INFO, CRITICAL, GOLDEN) and is shown in the lead's " +
9
- "feed. Use GOLDEN sparingly for truly actionable buying-intent signals.", {
10
- lead_id: z.string().describe("ID of the target lead."),
11
- level: z
12
- .enum(["INFO", "CRITICAL", "GOLDEN"])
13
- .describe("Signal priority level."),
14
- title: z
15
- .string()
16
- .describe("Short title of the signal (e.g. 'Budget IRM approuvé')."),
17
- content: z
18
- .string()
19
- .describe("Detailed description of the signal."),
20
- agent_source: z
21
- .string()
22
- .optional()
23
- .describe("Short identifier of the source agent. MUST be 3 to 4 words max, " +
24
- "kebab-case or space-separated (e.g. 'leadify qualification', " +
25
- "'briefify research', 'dealmind coach'). Default: 'unknown'."),
26
- source_url: z
27
- .string()
28
- .url()
29
- .optional()
30
- .describe("URL of the original information source."),
31
- }, async ({ lead_id, level, title, content, agent_source, source_url }) => {
40
+ // ── signal_upsert ──────────────────────────────────────────────────────
41
+ server.registerTool("signal_upsert", {
42
+ title: "Upsert a business signal",
43
+ description: "Create a new business intelligence signal on a lead, or update an existing " +
44
+ "one's content by `id`. Signals report key events picked up by agents — budget " +
45
+ "approvals, position changes, tender publications, etc. and are shown in the " +
46
+ "lead's feed.\n\n" +
47
+ "WITHOUT `id` → creates a signal (`lead_id`, `level`, `title`, `content` required).\n" +
48
+ "WITH `id` → PARTIAL update of the existing signal: only the fields you pass are " +
49
+ "modified. Ids are server-generated — never invent one; an unknown `id` returns 404. " +
50
+ "`lead_id` is immutable after creation.\n\n" +
51
+ "Every upsert (re)sets the signal's state to `active`: updating an expired or " +
52
+ "disabled signal reactivates it — this is also the way to reactivate a signal. To " +
53
+ "expire or disable a signal use `signal_expire` / `signal_disable` instead.\n\n" +
54
+ "Levels: GOLDEN = major actionable buying-intent (use sparingly), CRITICAL = " +
55
+ "important info needing attention, INFO = monitoring-grade info.\n" +
56
+ "Always provide `ai_summary` when `content` exceeds ~3 sentences, otherwise the UI " +
57
+ "truncates `content`.",
58
+ inputSchema: {
59
+ id: z
60
+ .string()
61
+ .optional()
62
+ .describe("ID of an existing signal to update. OMIT to create a new signal. " +
63
+ "Unknown id → 404 (ids are server-generated, never invent one)."),
64
+ lead_id: z
65
+ .string()
66
+ .optional()
67
+ .describe("ID of the target lead. Required when creating; immutable afterwards."),
68
+ level: z
69
+ .enum(["INFO", "CRITICAL", "GOLDEN"])
70
+ .optional()
71
+ .describe("Signal priority level. Required when creating."),
72
+ title: z
73
+ .string()
74
+ .optional()
75
+ .describe("Short one-line title (e.g. 'Budget IRM approuvé'). Required when creating."),
76
+ content: z
77
+ .string()
78
+ .optional()
79
+ .describe("Long markdown version: full details, sources, context. Required when creating."),
80
+ ai_summary: z
81
+ .string()
82
+ .optional()
83
+ .describe("3-4 sentence markdown summary, shown by default in the UI with a 'see more' " +
84
+ "toggle expanding `content`."),
85
+ date: z
86
+ .string()
87
+ .optional()
88
+ .describe("Date the business event actually occurred (≠ insertion date). Partial or full " +
89
+ "ISO 8601: 'YYYY' / 'YYYY-MM' / 'YYYY-MM-DD' / datetime. Use the finest " +
90
+ "precision you can reliably determine."),
91
+ source: z
92
+ .string()
93
+ .optional()
94
+ .describe("Human-readable source name ('LinkedIn', 'BOAMP', 'Les Echos'). Main badge in UI."),
95
+ agent_source: z
96
+ .string()
97
+ .optional()
98
+ .describe("Technical identifier of the producing agent, 3-4 words max, kebab-case or " +
99
+ "space-separated (e.g. 'leadify qualification', 'budget-monitor')."),
100
+ source_url: z.string().url().optional().describe("URL of the original source."),
101
+ reason: reasonParam,
102
+ },
103
+ annotations: { idempotentHint: true },
104
+ }, async ({ id, lead_id, level, title, content, ai_summary, date, source, agent_source, source_url, reason, }) => {
105
+ const capError = capExceeded();
106
+ if (capError)
107
+ return capError;
32
108
  try {
33
- const body = {
34
- leadId: lead_id,
35
- level,
36
- title,
37
- content,
38
- };
109
+ const body = { state: "active" };
110
+ if (id !== undefined)
111
+ body.id = id;
112
+ if (lead_id !== undefined)
113
+ body.leadId = lead_id;
114
+ if (level !== undefined)
115
+ body.level = level;
116
+ if (title !== undefined)
117
+ body.title = title;
118
+ if (content !== undefined)
119
+ body.content = content;
120
+ if (ai_summary !== undefined)
121
+ body.aiSummary = ai_summary;
122
+ if (date !== undefined)
123
+ body.date = date;
124
+ if (source !== undefined)
125
+ body.source = source;
39
126
  if (agent_source !== undefined)
40
127
  body.agentSource = agent_source;
41
128
  if (source_url !== undefined)
42
129
  body.sourceUrl = source_url;
43
- const data = await getClient().post("/add-signal", body);
44
- return toolResult(data);
130
+ const data = (await getClient().post("/signals", body));
131
+ signalMutationCount++;
132
+ const feedLeadId = data.signal?.leadId ?? lead_id;
133
+ const feedError = feedLeadId
134
+ ? await appendFeed({
135
+ leadId: feedLeadId,
136
+ action: data.created ? "signal_added" : "signal_updated",
137
+ target: data.signal?.id ?? id,
138
+ after: data.signal?.title ?? title,
139
+ reason,
140
+ })
141
+ : "leadId missing from upsert response";
142
+ return mutationResult(data, feedError);
143
+ }
144
+ catch (error) {
145
+ return handleToolError(error);
146
+ }
147
+ });
148
+ // ── signal_expire / signal_disable ─────────────────────────────────────
149
+ const stateFlips = [
150
+ {
151
+ name: "signal_expire",
152
+ title: "Expire a business signal",
153
+ state: "expired",
154
+ action: "signal_expired",
155
+ description: "Mark an existing signal as EXPIRED: the underlying business event is no longer " +
156
+ "current (budget consumed, tender closed, contact left the position...). The signal " +
157
+ "is kept for history but stops counting as active intelligence. This only flips the " +
158
+ "signal's lifecycle state — content is untouched. Re-upserting the signal with " +
159
+ "`signal_upsert` reactivates it. Do NOT use this for wrong/duplicate data — that is " +
160
+ "`signal_delete`.",
161
+ },
162
+ {
163
+ name: "signal_disable",
164
+ title: "Disable a business signal",
165
+ state: "disabled",
166
+ action: "signal_disabled",
167
+ description: "Mark an existing signal as DISABLED: the signal is set aside as a false positive " +
168
+ "or manually discarded (wrong interpretation, irrelevant to this lead...). The " +
169
+ "signal is kept for traceability but ignored by agents and the UI. This only flips " +
170
+ "the signal's lifecycle state — content is untouched. Re-upserting the signal with " +
171
+ "`signal_upsert` reactivates it. For factually wrong or duplicate data, prefer " +
172
+ "`signal_delete`.",
173
+ },
174
+ ];
175
+ for (const flip of stateFlips) {
176
+ server.registerTool(flip.name, {
177
+ title: flip.title,
178
+ description: flip.description,
179
+ inputSchema: {
180
+ id: z.string().describe("ID of the signal. Unknown id → 404."),
181
+ reason: reasonParam,
182
+ },
183
+ annotations: { idempotentHint: true },
184
+ }, async ({ id, reason }) => {
185
+ const capError = capExceeded();
186
+ if (capError)
187
+ return capError;
188
+ try {
189
+ const data = (await getClient().post("/signals", {
190
+ id,
191
+ state: flip.state,
192
+ }));
193
+ signalMutationCount++;
194
+ const feedLeadId = data.signal?.leadId;
195
+ const feedError = feedLeadId
196
+ ? await appendFeed({
197
+ leadId: feedLeadId,
198
+ action: flip.action,
199
+ target: id,
200
+ after: flip.state.toUpperCase(),
201
+ reason,
202
+ })
203
+ : "leadId missing from upsert response";
204
+ return mutationResult(data, feedError);
205
+ }
206
+ catch (error) {
207
+ return handleToolError(error);
208
+ }
209
+ });
210
+ }
211
+ // ── signal_delete ──────────────────────────────────────────────────────
212
+ server.registerTool("signal_delete", {
213
+ title: "Delete a business signal (rare)",
214
+ description: "PERMANENTLY delete a signal. RARE case reserved for factually wrong data or " +
215
+ "duplicates. The normal lifecycle never deletes: an outdated signal becomes " +
216
+ "`expired` (`signal_expire`) and a false positive becomes `disabled` " +
217
+ "(`signal_disable`), both keeping history. Deletion is irreversible.",
218
+ inputSchema: {
219
+ id: z.string().describe("ID of the signal to delete. Unknown id → 404."),
220
+ lead_id: z
221
+ .string()
222
+ .describe("ID of the lead the signal belongs to (used for the activity feed)."),
223
+ reason: reasonParam,
224
+ },
225
+ annotations: { destructiveHint: true, idempotentHint: false },
226
+ }, async ({ id, lead_id, reason }) => {
227
+ const capError = capExceeded();
228
+ if (capError)
229
+ return capError;
230
+ try {
231
+ const data = await getClient().delete(`/signals/${encodeURIComponent(id)}`);
232
+ signalMutationCount++;
233
+ const feedError = await appendFeed({
234
+ leadId: lead_id,
235
+ action: "signal_deleted",
236
+ target: id,
237
+ reason,
238
+ });
239
+ return mutationResult(data, feedError);
45
240
  }
46
241
  catch (error) {
47
242
  return handleToolError(error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agifyai/leadify-mcp",
3
- "version": "6.0.0",
3
+ "version": "7.0.0",
4
4
  "description": "MCP server for Leadify lead management API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",