@agifyai/leadify-mcp 3.3.0 → 3.4.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 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.3.0",
19
+ version: "3.4.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 69k — just enough to resolve the org / link the persona to a lead group. " +
164
- "Pass `view=full` to get the complete persona (legacy behaviour). " +
165
- "Pass `view=outreach` to get the outreach-specific projection.", {
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", "full"])
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' = complete persona payload (legacy 69k-char shape, escape hatch)."),
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) {
@@ -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
- if (params.outreach_signal_routing !== undefined) {
507
- const osr = {};
508
- if (params.outreach_signal_routing.signal_source_field !== undefined)
509
- osr.signalSourceField =
510
- params.outreach_signal_routing.signal_source_field;
511
- if (params.outreach_signal_routing.default_signal !== undefined)
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
- }, async ({ id }) => {
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agifyai/leadify-mcp",
3
- "version": "3.3.0",
3
+ "version": "3.4.0",
4
4
  "description": "MCP server for Leadify lead management API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",