@agifyai/leadify-mcp 3.3.0 → 3.5.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/dist/server.js +1 -1
- package/dist/tools/lead_groups.js +20 -5
- package/dist/tools/personas.js +107 -17
- package/dist/tools/pipeline.js +8 -19
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -16,7 +16,7 @@ import { registerPipelineTools } from "./tools/pipeline.js";
|
|
|
16
16
|
export function createServer() {
|
|
17
17
|
const server = new McpServer({
|
|
18
18
|
name: "leadify",
|
|
19
|
-
version: "3.
|
|
19
|
+
version: "3.5.0",
|
|
20
20
|
});
|
|
21
21
|
registerAuthTools(server);
|
|
22
22
|
registerOrganizationTools(server);
|
|
@@ -160,24 +160,39 @@ export function registerLeadGroupTools(server) {
|
|
|
160
160
|
"Returns null persona if none is assigned. Use this to check what targeting " +
|
|
161
161
|
"rules apply to a group before generating messages. " +
|
|
162
162
|
"By default, `persona` is SLIM ({id, name, organizationId}) — about ~150 chars " +
|
|
163
|
-
"instead of
|
|
164
|
-
"Pass `view=
|
|
165
|
-
"
|
|
163
|
+
"instead of 63k — just enough to resolve the org / link the persona to a lead group. " +
|
|
164
|
+
"Pass `view=outreach` to get the outreach-specific projection (only when you actually " +
|
|
165
|
+
"need the outreach trio — prefer the dedicated get_outreach_templates tool for that). " +
|
|
166
|
+
"SILO: outreach data is its own concern. It does not leak into the default persona payload.", {
|
|
166
167
|
lead_group_id: z.string().describe("Lead group ID."),
|
|
167
168
|
view: z
|
|
168
|
-
.enum(["slim", "outreach"
|
|
169
|
+
.enum(["slim", "outreach"])
|
|
169
170
|
.optional()
|
|
170
171
|
.default("slim")
|
|
171
172
|
.describe("Persona projection. " +
|
|
172
173
|
"'slim' (default) = {id, name, organizationId} — fast, context-friendly. " +
|
|
173
174
|
"'outreach' = projection used by the outreach agent (messaging + targeting). " +
|
|
174
|
-
"'full'
|
|
175
|
+
"The legacy 'full' view is gone (SILO enforcement) — use get_persona + " +
|
|
176
|
+
"get_outreach_templates for a complete picture, scoped per concern."),
|
|
175
177
|
}, async ({ lead_group_id, view }) => {
|
|
176
178
|
try {
|
|
177
179
|
const params = new URLSearchParams();
|
|
178
180
|
if (view)
|
|
179
181
|
params.set("view", view);
|
|
180
182
|
const data = await getClient().get(`/api/lead-group/${encodeURIComponent(lead_group_id)}/persona`, params);
|
|
183
|
+
// SILO defense in depth: even if the API returns the full payload for
|
|
184
|
+
// `view=slim` (the view param is best-effort on the backend), strip the
|
|
185
|
+
// outreach trio locally so it can never leak through this tool.
|
|
186
|
+
// Outreach data belongs to the dedicated outreach tools.
|
|
187
|
+
if (view !== "outreach" && data && typeof data === "object") {
|
|
188
|
+
const wrapper = data;
|
|
189
|
+
const persona = wrapper.persona ?? null;
|
|
190
|
+
if (persona && typeof persona === "object") {
|
|
191
|
+
delete persona.outreachTemplates;
|
|
192
|
+
delete persona.outreachSignalRouting;
|
|
193
|
+
delete persona.outreachFields;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
181
196
|
return toolResult(data);
|
|
182
197
|
}
|
|
183
198
|
catch (error) {
|
package/dist/tools/personas.js
CHANGED
|
@@ -445,6 +445,31 @@ export function registerPersonaTools(server) {
|
|
|
445
445
|
.describe("Override SDR identity at persona level ({name, title})."),
|
|
446
446
|
}, async (params) => {
|
|
447
447
|
try {
|
|
448
|
+
// SILO guard: the upsert escape hatch must NOT be used for outreach
|
|
449
|
+
// data. Outreach is its own concern with its own granular tools
|
|
450
|
+
// (update_persona_outreach_template, update_persona_outreach_signal_routing).
|
|
451
|
+
// Reject early with a clear pointer so the caller knows what to use.
|
|
452
|
+
if (params.outreach_signal_routing !== undefined ||
|
|
453
|
+
params.outreach_templates !== undefined ||
|
|
454
|
+
params.outreach_fields !== undefined) {
|
|
455
|
+
return toolError("SILO: upsert_persona does NOT accept outreach fields. " +
|
|
456
|
+
"Use the dedicated outreach tools instead: " +
|
|
457
|
+
"update_persona_outreach_template (add/replace/remove a template), " +
|
|
458
|
+
"update_persona_outreach_signal_routing (patch the signal routing block). " +
|
|
459
|
+
"For a read of the trio, use get_outreach_templates({ lead_group_id }).", {
|
|
460
|
+
rejectedParams: [
|
|
461
|
+
...(params.outreach_signal_routing !== undefined
|
|
462
|
+
? ["outreach_signal_routing"]
|
|
463
|
+
: []),
|
|
464
|
+
...(params.outreach_templates !== undefined
|
|
465
|
+
? ["outreach_templates"]
|
|
466
|
+
: []),
|
|
467
|
+
...(params.outreach_fields !== undefined
|
|
468
|
+
? ["outreach_fields"]
|
|
469
|
+
: []),
|
|
470
|
+
],
|
|
471
|
+
});
|
|
472
|
+
}
|
|
448
473
|
const body = { name: params.name };
|
|
449
474
|
if (params.id !== undefined)
|
|
450
475
|
body.id = params.id;
|
|
@@ -503,21 +528,12 @@ export function registerPersonaTools(server) {
|
|
|
503
528
|
rules: (params.signal_tier_rules.rules ?? []).map(mapSignalTierRule),
|
|
504
529
|
};
|
|
505
530
|
}
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
osr.defaultSignal = params.outreach_signal_routing.default_signal;
|
|
513
|
-
if (params.outreach_signal_routing.signals !== undefined)
|
|
514
|
-
osr.signals = params.outreach_signal_routing.signals;
|
|
515
|
-
body.outreachSignalRouting = osr;
|
|
516
|
-
}
|
|
517
|
-
if (params.outreach_templates !== undefined)
|
|
518
|
-
body.outreachTemplates = params.outreach_templates.map(mapOutreachTemplate);
|
|
519
|
-
if (params.outreach_fields !== undefined)
|
|
520
|
-
body.outreachFields = params.outreach_fields;
|
|
531
|
+
// SILO: outreach_* params are accepted on the schema (so users discover
|
|
532
|
+
// they exist here) but ALWAYS rejected by the guard at the top of this
|
|
533
|
+
// handler. Outreach data flows through update_persona_outreach_template
|
|
534
|
+
// and update_persona_outreach_signal_routing only. The unreachable
|
|
535
|
+
// mapping code below was removed — TypeScript narrows the params to
|
|
536
|
+
// `never` after the guard, so the old branches would never run anyway.
|
|
521
537
|
if (params.tone_instructions !== undefined)
|
|
522
538
|
body.toneInstructions = params.tone_instructions;
|
|
523
539
|
if (params.context !== undefined)
|
|
@@ -535,17 +551,91 @@ export function registerPersonaTools(server) {
|
|
|
535
551
|
}
|
|
536
552
|
});
|
|
537
553
|
// ── get_persona ────────────────────────────────────────────────────────
|
|
538
|
-
server.tool("get_persona", "Retrieve a single persona by ID, including all lead groups it's assigned to."
|
|
554
|
+
server.tool("get_persona", "Retrieve a single persona by ID, including all lead groups it's assigned to. " +
|
|
555
|
+
"SILO: the outreach trio (outreachTemplates, outreachSignalRouting, outreachFields) " +
|
|
556
|
+
"is STRIPPED by default to keep the response focused on the persona's targeting & " +
|
|
557
|
+
"qualification concerns (~37k chars instead of ~63k). Pass include_outreach=true to " +
|
|
558
|
+
"get the full payload — but for outreach-only reads prefer get_outreach_templates " +
|
|
559
|
+
"(a much smaller, dedicated read).", {
|
|
539
560
|
id: z.string().describe("Persona ID."),
|
|
540
|
-
|
|
561
|
+
include_outreach: z
|
|
562
|
+
.boolean()
|
|
563
|
+
.optional()
|
|
564
|
+
.default(false)
|
|
565
|
+
.describe("If true, keep the outreach trio in the response (outreachTemplates, " +
|
|
566
|
+
"outreachSignalRouting, outreachFields). Default false (SILO: outreach lives " +
|
|
567
|
+
"in its own tool)."),
|
|
568
|
+
}, async ({ id, include_outreach }) => {
|
|
541
569
|
try {
|
|
542
570
|
const data = await getClient().get(`/api/persona/${encodeURIComponent(id)}`);
|
|
571
|
+
// SILO defense in depth: strip the outreach trio locally unless the
|
|
572
|
+
// caller explicitly opted in. Outreach data belongs to the dedicated
|
|
573
|
+
// outreach tools — never mix it with the persona read by default.
|
|
574
|
+
if (!include_outreach && data && typeof data === "object") {
|
|
575
|
+
const wrapper = data;
|
|
576
|
+
if (wrapper && typeof wrapper === "object") {
|
|
577
|
+
delete wrapper.outreachTemplates;
|
|
578
|
+
delete wrapper.outreachSignalRouting;
|
|
579
|
+
delete wrapper.outreachFields;
|
|
580
|
+
}
|
|
581
|
+
// The persona can also be wrapped under {persona: {...}} depending
|
|
582
|
+
// on the API path. Strip there too.
|
|
583
|
+
const inner = wrapper.persona;
|
|
584
|
+
if (inner && typeof inner === "object") {
|
|
585
|
+
delete inner.outreachTemplates;
|
|
586
|
+
delete inner.outreachSignalRouting;
|
|
587
|
+
delete inner.outreachFields;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
543
590
|
return toolResult(data);
|
|
544
591
|
}
|
|
545
592
|
catch (error) {
|
|
546
593
|
return handlePersonaToolError(error);
|
|
547
594
|
}
|
|
548
595
|
});
|
|
596
|
+
// ── get_outreach_templates ─────────────────────────────────────────────
|
|
597
|
+
server.tool("get_outreach_templates", "Read the persona's outreach trio ONLY (outreachSignalRouting, outreachTemplates, " +
|
|
598
|
+
"outreachFields). SILO: this is the dedicated read for outreach data — never call " +
|
|
599
|
+
"get_persona with include_outreach=true for routine reads. Returns a small payload " +
|
|
600
|
+
"regardless of persona size, so it's safe to call mid-session without saturating " +
|
|
601
|
+
"the LLM context. " +
|
|
602
|
+
"Resolves the persona from the lead group (lead_group_id → personaId → persona), " +
|
|
603
|
+
"then projects the trio. If the lead group has no persona, returns an error.", {
|
|
604
|
+
lead_group_id: z
|
|
605
|
+
.string()
|
|
606
|
+
.describe("Lead group ID. The persona is resolved server-side from the group's personaId."),
|
|
607
|
+
}, async ({ lead_group_id }) => {
|
|
608
|
+
try {
|
|
609
|
+
// Step 1: resolve the persona assigned to this lead group (slim view).
|
|
610
|
+
// We only need the persona's id from here — we re-fetch the full
|
|
611
|
+
// persona below to grab the outreach trio.
|
|
612
|
+
const groupPersona = (await getClient().get(`/api/lead-group/${encodeURIComponent(lead_group_id)}/persona`));
|
|
613
|
+
const personaId = groupPersona?.persona?.id;
|
|
614
|
+
if (!personaId) {
|
|
615
|
+
return toolError(`No persona is assigned to lead group "${lead_group_id}". ` +
|
|
616
|
+
`Assign one via update_lead_group({ persona_id }) before reading outreach templates.`);
|
|
617
|
+
}
|
|
618
|
+
// Step 2: fetch the full persona so we can project the trio.
|
|
619
|
+
const full = (await getClient().get(`/api/persona/${encodeURIComponent(personaId)}`));
|
|
620
|
+
// The API may return either a bare persona or {persona: {...}}.
|
|
621
|
+
const persona = full.persona ?? full;
|
|
622
|
+
const trio = {
|
|
623
|
+
outreachSignalRouting: persona.outreachSignalRouting ?? null,
|
|
624
|
+
outreachTemplates: persona.outreachTemplates ?? [],
|
|
625
|
+
outreachFields: persona.outreachFields ?? [],
|
|
626
|
+
};
|
|
627
|
+
return toolResult({
|
|
628
|
+
personaId,
|
|
629
|
+
...trio,
|
|
630
|
+
_note: "SILO projection. To edit, use update_persona_outreach_template or " +
|
|
631
|
+
"update_persona_outreach_signal_routing. Do NOT use upsert_persona for outreach " +
|
|
632
|
+
"fields (SILO rejection).",
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
catch (error) {
|
|
636
|
+
return handlePersonaToolError(error);
|
|
637
|
+
}
|
|
638
|
+
});
|
|
549
639
|
// ── list_personas ──────────────────────────────────────────────────────
|
|
550
640
|
server.tool("list_personas", "List all personas accessible to the caller, optionally scoped to a specific organization " +
|
|
551
641
|
"and/or including global personas. Use this to discover persona IDs before calling " +
|
package/dist/tools/pipeline.js
CHANGED
|
@@ -15,24 +15,14 @@ export function registerPipelineTools(server) {
|
|
|
15
15
|
"card_* sequence fields to lead.data). " +
|
|
16
16
|
"The 'force' flag (default false) bypasses the 'already has a message?' guard — " +
|
|
17
17
|
"useful for re-generating on an already-processed lead. " +
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"file pair in the Trigger.dev repo (pilots/{campaign_id}.md + .json), paired to the lead's " +
|
|
22
|
-
"group. The writer hard-fails if the pilot is missing on a real run. " +
|
|
23
|
-
"The lead's `leadGroupId` is resolved server-side from the lead (one extra " +
|
|
18
|
+
"The pilot pinning (which Trigger.dev pilot/copywriting ruleset to follow) is resolved " +
|
|
19
|
+
"server-side from the lead's group — the caller never has to pass a campaign id. " +
|
|
20
|
+
"The lead's `leadGroupId` is also resolved server-side from the lead (one extra " +
|
|
24
21
|
"`get_lead` call); the caller only needs `lead_id`. " +
|
|
25
22
|
"The response is decorated with a Trigger.dev `traceUrl` deep-link when missing " +
|
|
26
23
|
"(https://cloud.trigger.dev/runs/{runId}), so you can always jump to the trace. " +
|
|
27
24
|
"Waits up to 120s for the remote agent to complete.", {
|
|
28
25
|
lead_id: z.string().describe("ID of the lead to generate a message for."),
|
|
29
|
-
campaign_id: z
|
|
30
|
-
.string()
|
|
31
|
-
.optional()
|
|
32
|
-
.describe("OPTIONAL pilot slug driving the copywriting (e.g. 'agify-medtech-fr-v2'). NOT a DB " +
|
|
33
|
-
"campaign id. Must match a pilot file pair in the Trigger.dev repo whose .json is " +
|
|
34
|
-
"paired to this lead's group. The writer hard-fails if the pilot is missing. " +
|
|
35
|
-
"Omit to skip the pilot-pinning (e.g. for dry-runs or smoke tests)."),
|
|
36
26
|
dryrun: z
|
|
37
27
|
.boolean()
|
|
38
28
|
.optional()
|
|
@@ -46,7 +36,7 @@ export function registerPipelineTools(server) {
|
|
|
46
36
|
.default(false)
|
|
47
37
|
.describe("true = bypass the 'already has a message?' check so you can re-generate. " +
|
|
48
38
|
"false (default) = normal behaviour: skip leads that already have card_message."),
|
|
49
|
-
}, async ({ lead_id,
|
|
39
|
+
}, async ({ lead_id, dryrun, force }) => {
|
|
50
40
|
try {
|
|
51
41
|
// Resolve leadGroupId server-side. The Trigger.dev task payload requires
|
|
52
42
|
// `leadGroupId`, but the lead_id alone is enough on the MCP side — we
|
|
@@ -62,17 +52,16 @@ export function registerPipelineTools(server) {
|
|
|
62
52
|
`The lead was not found, or it has no groupId. ` +
|
|
63
53
|
`Check that the lead exists via get_lead.`);
|
|
64
54
|
}
|
|
55
|
+
// No campaign_id is sent — pilot pinning is resolved server-side from
|
|
56
|
+
// the lead's group. Keeping the field out of the MCP surface is a
|
|
57
|
+
// SILO move: the caller (human or LLM) does not need to know about
|
|
58
|
+
// pilot slugs, and there's no risk of passing a stale or wrong slug.
|
|
65
59
|
const body = {
|
|
66
60
|
leadId: lead_id,
|
|
67
61
|
leadGroupId,
|
|
68
62
|
dryrun,
|
|
69
63
|
force,
|
|
70
64
|
};
|
|
71
|
-
// campaignId is optional: only include it when the caller passed one.
|
|
72
|
-
// Backend defaults are left to handle the omitted case.
|
|
73
|
-
if (campaign_id !== undefined) {
|
|
74
|
-
body.campaignId = campaign_id;
|
|
75
|
-
}
|
|
76
65
|
const data = (await getClient().post("/api/trigger-outreach", body));
|
|
77
66
|
// Decorate the response with a Trigger.dev run deep-link when the
|
|
78
67
|
// backend didn't ship one. Applies to BOTH success and failure paths
|