@sendinel/mcp-server 1.0.138 → 1.0.140

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
@@ -18,7 +18,7 @@ preserves REST outcomes, and redacts failures. See the
18
18
  [event-triggered campaign guide](../../docs/event-triggered-campaigns.md) for
19
19
  inputs, idempotency, subscription requirements, and conditional step examples.
20
20
 
21
- Sendinel currently exposes 35 tool groups and 343 tools (gateway mode exposes 4 meta-tools, sharing how_to with full mode). These counts are checked against the tool registry in CI, so this table matches what ships.
21
+ Sendinel currently exposes 35 tool groups and 344 tools (gateway mode exposes 4 meta-tools, sharing how_to with full mode). These counts are checked against the tool registry in CI, so this table matches what ships.
22
22
 
23
23
  | Group | Count | Purpose |
24
24
  |-------|-------|---------|
@@ -34,7 +34,7 @@ Sendinel currently exposes 35 tool groups and 343 tools (gateway mode exposes 4
34
34
  | `cloud-import` | 6 | Cloud storage import |
35
35
  | `compound` | 4 | Higher-level campaign/template/site setup and delivery diagnosis workflows |
36
36
  | `connector` | 6 | BYOD connector status, table discovery, schema/view setup, mapping, resync |
37
- | `contacts` | 39 | Subscriber CRUD, saved views, tags, email log, suppression, merge, timeline |
37
+ | `contacts` | 40 | Subscriber CRUD, saved views, tags, email log, suppression, merge, timeline |
38
38
  | `content` | 14 | Content blocks, gated asset links, blog ingest and auto-publish, sources |
39
39
  | `data` | 19 | Platform connectors, imports, API keys, migration jobs, notifications, tenant export |
40
40
  | `data-proposals` | 3 | Data-derived campaign proposal generation, listing, approval |
@@ -0,0 +1,12 @@
1
+ /** Caller-safe database errors. Never serialize database messages, details or hints. */
2
+ export declare function mapDbError(error: unknown): {
3
+ error: string;
4
+ code: string;
5
+ };
6
+ export declare function dbErrorResult(error: unknown): {
7
+ isError: boolean;
8
+ content: {
9
+ type: 'text';
10
+ text: string;
11
+ }[];
12
+ };
@@ -0,0 +1,20 @@
1
+ /** Caller-safe database errors. Never serialize database messages, details or hints. */
2
+ export function mapDbError(error) {
3
+ const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : '';
4
+ if (code === '23505')
5
+ return { error: 'A record with these values already exists.', code: 'conflict' };
6
+ if (code === '23503' || code === 'PGRST116') {
7
+ return { error: 'The requested or referenced record was not found.', code: 'not_found' };
8
+ }
9
+ if (code.startsWith('23') || code.startsWith('22')) {
10
+ return { error: 'The supplied values are not valid. Check the input and try again.', code: 'validation_failed' };
11
+ }
12
+ return { error: 'The operation could not be completed. Please try again later.', code: 'internal_error' };
13
+ }
14
+ export function dbErrorResult(error) {
15
+ console.warn('[mcp] Database operation failed:', error);
16
+ return {
17
+ isError: true,
18
+ content: [{ type: 'text', text: JSON.stringify(mapDbError(error)) }],
19
+ };
20
+ }
@@ -1,3 +1,6 @@
1
+ /** Redirect destinations may use HTTP or HTTPS, but never executable URL schemes. */
2
+ export declare function assertHttpUrl(raw: string): string;
3
+ export declare const RESERVED_LINK_SLUGS: Set<string>;
1
4
  export declare function isSafeRedirectUrl(url: unknown): url is string;
2
5
  export declare function isAllowedHttpsHostUrl(url: unknown, allowedDomains: readonly string[]): url is string;
3
6
  export declare function normalizeActiveCampaignAccountUrl(accountUrl: string): string;
@@ -1,3 +1,18 @@
1
+ /** Redirect destinations may use HTTP or HTTPS, but never executable URL schemes. */
2
+ export function assertHttpUrl(raw) {
3
+ const url = new URL(raw);
4
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
5
+ throw new Error('Only http and https URLs are supported.');
6
+ }
7
+ return url.toString();
8
+ }
9
+ // Shared /s namespace: system routes cannot be claimed by a tenant.
10
+ export const RESERVED_LINK_SLUGS = new Set([
11
+ 'admin', 'api', 'app', 'login', 'dashboard', 's', 'a', 'www', 'support', 'billing',
12
+ 'auth', 'static', 'assets', 'aup', 'ccpa', 'changelog', 'cloud-storage', 'demo-video',
13
+ 'developer', 'embed', 'gdpr', 'getting-started', 'invite', 'legal', 'lp', 'oauth',
14
+ 'pricing', 'privacy', 'reset-password', 'sign-in', 'sign-up', 'terms',
15
+ ]);
1
16
  export function isSafeRedirectUrl(url) {
2
17
  if (typeof url !== 'string' || !url)
3
18
  return false;
@@ -42,7 +42,7 @@ export function registerApprovalTools(server, options) {
42
42
  idempotentHint: true,
43
43
  openWorldHint: false,
44
44
  },
45
- description: 'List all pending approval requests for this project. Shows what actions are waiting for human approval.',
45
+ description: 'List all pending approval requests for this project. Excludes expired requests without mutating them. Shows what actions are waiting for human approval.',
46
46
  inputSchema: {},
47
47
  }, async () => {
48
48
  const { data, error } = await db()
@@ -50,10 +50,11 @@ export function registerApprovalTools(server, options) {
50
50
  .select('id, action, tier, channel, summary, status, requested_at, expires_at')
51
51
  .eq('project_id', getProjectId())
52
52
  .eq('status', 'pending')
53
+ .gt('expires_at', new Date().toISOString())
53
54
  .order('requested_at', { ascending: false })
54
55
  .limit(25);
55
56
  if (error)
56
- return txt(`Error: ${error.message}`);
57
+ return j({ error: error.message, code: 'INTERNAL_ERROR' });
57
58
  return j({ pending: data ?? [], count: data?.length ?? 0 });
58
59
  });
59
60
  }
@@ -7,7 +7,8 @@ export declare function buildCampaignFiresOn(campaign: Record<string, unknown>):
7
7
  /**
8
8
  * Diagnostic warnings indicating why a campaign's trigger configuration cannot fire (#3852).
9
9
  */
10
- export declare function buildCampaignTriggerDiagnostics(campaign: Record<string, unknown>): string[];
10
+ export declare function buildCampaignTriggerDiagnostics(campaign: Record<string, unknown>, seenEvents?: ReadonlySet<string> | null): string[];
11
+ export declare function campaignTriggerKind(campaign: Record<string, unknown>): 'event' | 'tag' | 'date_field' | 'contact_event' | 'manual';
11
12
  export declare function applyCampaignTargetingFields(updates: Record<string, unknown>, input: {
12
13
  trigger_event_name?: string | null;
13
14
  trigger_tag?: string | null;
@@ -1,3 +1,4 @@
1
+ import { dbErrorResult } from '../lib/db-errors.js';
1
2
  import { resolveCampaignReentry, readCampaignPeriods, campaignPeriodFields } from "@sendinel/domain";
2
3
  import { CAMPAIGN_BRANCH_CONDITIONS, campaignBranchConditionValue, isContactPropertyConditionList, STEP_PROPERTY_CONDITIONS_ERROR, validateStepPropertyRules } from "@sendinel/domain";
3
4
  import { z } from 'zod';
@@ -105,11 +106,18 @@ export function buildCampaignFiresOn(campaign) {
105
106
  /**
106
107
  * Diagnostic warnings indicating why a campaign's trigger configuration cannot fire (#3852).
107
108
  */
108
- export function buildCampaignTriggerDiagnostics(campaign) {
109
+ export function buildCampaignTriggerDiagnostics(campaign, seenEvents) {
109
110
  const config = (campaign.trigger_config && typeof campaign.trigger_config === 'object' ? campaign.trigger_config : {});
110
111
  const events = normalizeTriggerList([...normalizeTriggerList(config.events), campaign.trigger_event_name]);
111
112
  const tags = normalizeTriggerList([...normalizeTriggerList(config.tags), campaign.trigger_tag]);
112
113
  const diagnostics = [];
114
+ if (events.length && seenEvents === null)
115
+ diagnostics.push('Event history unavailable; prior receipt of these events is unknown.');
116
+ if (seenEvents)
117
+ for (const event of events) {
118
+ if (!seenEvents.has(event))
119
+ diagnostics.push(`Event "${event}" has never been seen in this project; check the event name or send a test event.`);
120
+ }
113
121
  if ((events.length || tags.length) && campaign.type !== 'triggered') {
114
122
  diagnostics.push(`campaign type is "${String(campaign.type)}"; event and tag triggers only start campaigns of type "triggered" (it will never fire)`);
115
123
  }
@@ -133,6 +141,37 @@ export function buildCampaignTriggerDiagnostics(campaign) {
133
141
  }
134
142
  return diagnostics;
135
143
  }
144
+ export function campaignTriggerKind(campaign) {
145
+ if (campaign.trigger_type === 'date_field' || campaign.trigger_type === 'contact_event')
146
+ return campaign.trigger_type;
147
+ const config = normalizeTriggerConfig(campaign.trigger_config);
148
+ if (normalizeTriggerValue(campaign.trigger_event_name) || config.events.length)
149
+ return 'event';
150
+ if (normalizeTriggerValue(campaign.trigger_tag) || config.tags.length)
151
+ return 'tag';
152
+ return 'manual';
153
+ }
154
+ async function campaignTriggerOutput(campaign) {
155
+ let seenEvents;
156
+ const config = normalizeTriggerConfig(campaign.trigger_config);
157
+ if (normalizeTriggerValue(campaign.trigger_event_name) || config.events.length) {
158
+ try {
159
+ // /v1/events -> intakeContactEvent stores event_type=custom and title=event.
160
+ // Read all pages: a capped sample cannot establish that an event was never seen.
161
+ const rows = await readAllRows(db().from('contact_events').select('id, title')
162
+ .eq('project_id', getProjectId()).eq('event_type', 'custom'));
163
+ seenEvents = new Set(rows.map(row => normalizeTriggerValue(row.title)).filter((value) => value !== null));
164
+ }
165
+ catch {
166
+ seenEvents = null;
167
+ }
168
+ }
169
+ return {
170
+ trigger_kind: campaignTriggerKind(campaign),
171
+ fires_on: buildCampaignFiresOn(campaign),
172
+ trigger_diagnostics: buildCampaignTriggerDiagnostics(campaign, seenEvents),
173
+ };
174
+ }
136
175
  function applyCampaignSendAtField(updates, sendAt, campaignType) {
137
176
  if (sendAt === undefined)
138
177
  return null;
@@ -165,13 +204,20 @@ export async function applyCampaignTargetingFields(updates, input, projectId, ex
165
204
  if (hasTriggerEvent)
166
205
  return 'cannot set trigger_event_name on a date_field campaign';
167
206
  const config = input.trigger_config ?? {};
207
+ const unknown = Object.keys(config).filter(key => !['field', 'days_before', 'recurring'].includes(key));
208
+ if (unknown.length)
209
+ return `Unknown date_field keys: ${unknown.join(', ')}; allowed: field, days_before, recurring; use days_before, not offset_days`;
210
+ if (config.days_before !== undefined && (typeof config.days_before !== 'number' || !Number.isInteger(config.days_before) || config.days_before < 0 || config.days_before > 30))
211
+ return 'days_before must be an integer from 0 to 30; use days_before, not offset_days';
212
+ if (config.recurring !== undefined && typeof config.recurring !== 'boolean')
213
+ return 'recurring must be a boolean';
168
214
  const field = typeof config.field === 'string' ? config.field.trim() : '';
169
215
  if (!/^[A-Za-z0-9_.-]{1,80}$/.test(field))
170
216
  return 'date field is required';
171
217
  updates.trigger_type = 'date_field';
172
218
  updates.trigger_config = {
173
219
  field,
174
- days_before: Math.max(0, Math.min(30, Number(config.days_before ?? 0) || 0)),
220
+ days_before: config.days_before ?? 0,
175
221
  recurring: config.recurring !== false,
176
222
  };
177
223
  }
@@ -197,6 +243,24 @@ export async function applyCampaignTargetingFields(updates, input, projectId, ex
197
243
  updates.trigger_config = null;
198
244
  }
199
245
  else {
246
+ const unknown = Object.keys(input.trigger_config).filter(key => !['events', 'tags', 'where'].includes(key));
247
+ if (unknown.length)
248
+ return `Unknown trigger_config keys: ${unknown.join(', ')}; allowed: events, tags, where`;
249
+ if ('where' in input.trigger_config) {
250
+ const where = input.trigger_config.where;
251
+ if (!where || typeof where !== 'object' || Array.isArray(where))
252
+ return 'where must be an object of string, number or boolean values';
253
+ const keys = new Set();
254
+ for (const [key, value] of Object.entries(where)) {
255
+ const normalizedKey = key.trim().replace(/^ctx\./, '');
256
+ if (!normalizedKey || normalizedKey === '__proto__' || keys.has(normalizedKey))
257
+ return `where.${key} has an empty or duplicate normalized key`;
258
+ keys.add(normalizedKey);
259
+ if (!['string', 'number', 'boolean'].includes(typeof value) || (typeof value === 'number' && !Number.isFinite(value))) {
260
+ return `where.${key} must be a string, number or boolean; operators like $gt are not supported`;
261
+ }
262
+ }
263
+ }
200
264
  const config = buildCampaignTriggerConfig(input.trigger_config);
201
265
  if (!config)
202
266
  return 'trigger_config must include events or tags';
@@ -819,40 +883,56 @@ export function registerCampaignTools(server) {
819
883
  send_only_if_event: z.string().trim().min(1).optional().describe('Send this step only if the contact has an event with this name by send time; otherwise skip it and continue the journey.'),
820
884
  },
821
885
  }, async ({ campaign_id, step_order, subject, body_html, body_text, blocks, delay_days, delay_hours, variant_label, survey_id, brand_asset_ids, skip_if_event, send_only_if_event, skip_if_property, send_only_if_property }) => {
822
- const properties = validateStepPropertyRules({ skip_if_property, send_only_if_property });
823
- if (!properties.ok)
824
- return j({ error: properties.error, code: "INVALID_INPUT" });
825
- const renderVars = {};
826
- if (survey_id) {
827
- const surveyLink = await resolveSurveyShortLink(db(), getProjectId(), survey_id);
828
- if (!surveyLink)
829
- return j({ error: 'Survey has no short link yet', code: 'SURVEY_LINK_NOT_READY' });
830
- renderVars.survey_link = surveyLink;
886
+ try {
887
+ const properties = validateStepPropertyRules({ skip_if_property, send_only_if_property });
888
+ if (!properties.ok)
889
+ return j({ error: properties.error, code: "INVALID_INPUT" });
890
+ const { data: campaign, error: campaignError } = await db()
891
+ .schema('email')
892
+ .from('campaigns')
893
+ .select('id')
894
+ .eq('id', campaign_id)
895
+ .eq('project_id', getProjectId())
896
+ .maybeSingle();
897
+ if (campaignError)
898
+ return dbErrorResult(campaignError);
899
+ if (!campaign)
900
+ return { ...j({ error: 'Campaign not found.', code: 'not_found' }), isError: true };
901
+ const renderVars = {};
902
+ if (survey_id) {
903
+ const surveyLink = await resolveSurveyShortLink(db(), getProjectId(), survey_id);
904
+ if (!surveyLink)
905
+ return j({ error: 'Survey has no short link yet', code: 'SURVEY_LINK_NOT_READY' });
906
+ renderVars.survey_link = surveyLink;
907
+ }
908
+ const renderedSubject = survey_id ? renderTemplateVars(subject, renderVars) : subject;
909
+ const normalizedBlocks = blocks ? normalizeComposerBlocks(blocks) : undefined;
910
+ const htmlFromBlocks = normalizedBlocks?.length ? blocksToHtml(normalizedBlocks) : undefined;
911
+ const renderedHtml = body_html !== undefined && survey_id ? renderTemplateVars(body_html, renderVars) : body_html ?? htmlFromBlocks;
912
+ const renderedText = body_text !== undefined && survey_id ? renderTemplateVars(body_text, renderVars) : body_text;
913
+ const { data, error } = await db()
914
+ .from('campaign_steps')
915
+ .insert({ campaign_id, step_order, subject: renderedSubject, body_html: renderedHtml, body_text: renderedText ?? (normalizedBlocks?.length ? blocksToText(normalizedBlocks) : undefined), blocks: normalizedBlocks ?? null, delay_days, delay_hours, variant_label: variant_label ?? 'A', skip_if_event: skip_if_event ?? null, send_only_if_event: send_only_if_event ?? null, ...properties.rules })
916
+ .select()
917
+ .single();
918
+ if (error)
919
+ return dbErrorResult(error);
920
+ await recordBrandAssetUsage({
921
+ client: db(),
922
+ projectId: getProjectId(),
923
+ source: 'campaign_step',
924
+ resourceId: data.id,
925
+ html: renderedHtml,
926
+ assetIds: brand_asset_ids,
927
+ }).catch((err) => {
928
+ console.warn('Failed to record brand asset usage for MCP campaign step:', err);
929
+ });
930
+ await logAudit({ action: 'campaign.step_created', resourceType: 'campaign_step', resourceId: data.id, details: { property_rules: properties.rules } });
931
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
932
+ }
933
+ catch (error) {
934
+ return dbErrorResult(error);
831
935
  }
832
- const renderedSubject = survey_id ? renderTemplateVars(subject, renderVars) : subject;
833
- const normalizedBlocks = blocks ? normalizeComposerBlocks(blocks) : undefined;
834
- const htmlFromBlocks = normalizedBlocks?.length ? blocksToHtml(normalizedBlocks) : undefined;
835
- const renderedHtml = body_html !== undefined && survey_id ? renderTemplateVars(body_html, renderVars) : body_html ?? htmlFromBlocks;
836
- const renderedText = body_text !== undefined && survey_id ? renderTemplateVars(body_text, renderVars) : body_text;
837
- const { data, error } = await db()
838
- .from('campaign_steps')
839
- .insert({ campaign_id, step_order, subject: renderedSubject, body_html: renderedHtml, body_text: renderedText ?? (normalizedBlocks?.length ? blocksToText(normalizedBlocks) : undefined), blocks: normalizedBlocks ?? null, delay_days, delay_hours, variant_label: variant_label ?? 'A', skip_if_event: skip_if_event ?? null, send_only_if_event: send_only_if_event ?? null, ...properties.rules })
840
- .select()
841
- .single();
842
- if (error)
843
- return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
844
- await recordBrandAssetUsage({
845
- client: db(),
846
- projectId: getProjectId(),
847
- source: 'campaign_step',
848
- resourceId: data.id,
849
- html: renderedHtml,
850
- assetIds: brand_asset_ids,
851
- }).catch((err) => {
852
- console.warn('Failed to record brand asset usage for MCP campaign step:', err);
853
- });
854
- await logAudit({ action: 'campaign.step_created', resourceType: 'campaign_step', resourceId: data.id, details: { property_rules: properties.rules } });
855
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
856
936
  });
857
937
  server.registerTool('update_campaign_status', {
858
938
  title: 'Update Campaign Status',
@@ -3188,8 +3268,7 @@ Return ONLY valid JSON with this exact shape:
3188
3268
  send_class: campaign.send_class,
3189
3269
  unsub_footer_template: campaign.unsub_footer_template,
3190
3270
  ...targeting,
3191
- fires_on: buildCampaignFiresOn({ ...campaign, status: willActivate ? 'active' : 'draft' }),
3192
- trigger_diagnostics: buildCampaignTriggerDiagnostics({ ...campaign, status: willActivate ? 'active' : 'draft' }),
3271
+ ...await campaignTriggerOutput({ ...campaign, status: willActivate ? 'active' : 'draft' }),
3193
3272
  steps_created: steps.length,
3194
3273
  contacts_enrolled: enrolled,
3195
3274
  status: willActivate ? 'active' : 'draft',
@@ -3381,7 +3460,7 @@ Return ONLY valid JSON with this exact shape:
3381
3460
  server.registerTool('set_campaign_trigger', {
3382
3461
  title: 'Set Campaign Trigger',
3383
3462
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
3384
- description: 'Set or clear what a campaign fires on: trigger event name, tag, date_field, contact_event, or trigger_config events/tags and where event-data filter. Bind billing, payment failed, dunning or trial ending mail to an event trigger such as invoice_payment_failed or trial_ending. Drafts save directly; active/paused campaigns require standard human approval. Archived/completed campaigns cannot change. Returns saved trigger fields, fires_on and trigger_diagnostics.',
3463
+ description: 'Set or clear what a campaign fires on: trigger event name, tag, date_field, contact_event, or trigger_config events/tags and where event-data filter. Bind billing, payment failed, dunning or trial ending mail to an event trigger such as invoice_payment_failed or trial_ending. Drafts save directly; active/paused campaigns require standard human approval. Archived/completed campaigns cannot change. Returns saved trigger fields, derived trigger_kind, fires_on and trigger_diagnostics (including unseen-event warnings).',
3385
3464
  inputSchema: {
3386
3465
  ...reentryFields,
3387
3466
  campaign_id: z.string().uuid().optional(),
@@ -3395,7 +3474,7 @@ Return ONLY valid JSON with this exact shape:
3395
3474
  }, async ({ approval_id, ...args }) => {
3396
3475
  if (approval_id) {
3397
3476
  const result = await resumeCampaignEdit('set_campaign_trigger', approval_id, args);
3398
- return j(result.error ? result : { ...result, fires_on: buildCampaignFiresOn(result), trigger_diagnostics: buildCampaignTriggerDiagnostics(result) });
3477
+ return j(result.error ? result : { ...result, ...await campaignTriggerOutput(result) });
3399
3478
  }
3400
3479
  if (!args.campaign_id)
3401
3480
  return j({ error: 'campaign_id is required', code: 'INVALID_FIELD' });
@@ -3435,7 +3514,7 @@ Return ONLY valid JSON with this exact shape:
3435
3514
  if (reentry.period_key !== undefined)
3436
3515
  updates.period_key = reentry.period_key;
3437
3516
  const result = await finishCampaignEdit('set_campaign_trigger', args, existing, updates, params => requestApproval('set_campaign_trigger', params));
3438
- return j(result.error || result.requires_approval ? result : { ...result, fires_on: buildCampaignFiresOn(result), trigger_diagnostics: buildCampaignTriggerDiagnostics(result) });
3517
+ return j(result.error || result.requires_approval ? result : { ...result, ...await campaignTriggerOutput(result) });
3439
3518
  });
3440
3519
  server.registerTool('update_campaign', {
3441
3520
  title: 'Update Campaign',
@@ -3468,7 +3547,7 @@ Return ONLY valid JSON with this exact shape:
3468
3547
  }, async ({ approval_id, ...input }) => {
3469
3548
  if (approval_id) {
3470
3549
  const result = await resumeCampaignEdit('update_campaign', approval_id, input);
3471
- return j(result.error ? result : { ...result, fires_on: buildCampaignFiresOn(result), trigger_diagnostics: buildCampaignTriggerDiagnostics(result) });
3550
+ return j(result.error ? result : { ...result, ...await campaignTriggerOutput(result) });
3472
3551
  }
3473
3552
  const { campaign_id, name, site_id, segment_id, send_at, topic, trigger_event_name, trigger_tag, trigger_type, trigger_config, type, sends_per_hour, send_in_contact_timezone, send_class, unsub_footer_template, reentry_mode, period_key } = input;
3474
3553
  if (!campaign_id)
@@ -3599,7 +3678,7 @@ Return ONLY valid JSON with this exact shape:
3599
3678
  idempotentHint: true,
3600
3679
  openWorldHint: false,
3601
3680
  },
3602
- description: 'Get full campaign details including all steps, enrollment counts and saved trigger_type, trigger_event_name, trigger_tag, trigger_config (including where filter), fires_on and trigger_diagnostics. Read what event a dunning campaign fires on; use set_campaign_trigger to change it. ' + ACTIVATION_APPROVAL_DESCRIPTION,
3681
+ description: 'Get full campaign details including all steps, enrollment counts and derived trigger_kind (event/tag/date_field/contact_event/manual), saved trigger_type, trigger_event_name, trigger_tag, trigger_config (including where filter), fires_on and trigger_diagnostics. Read what event a dunning campaign fires on; use set_campaign_trigger to change it. ' + ACTIVATION_APPROVAL_DESCRIPTION,
3603
3682
  inputSchema: { campaign_id: z.string().uuid(), period: z.string().min(1).optional().describe("Return results for one enrollment period; totals remain all-time.") },
3604
3683
  }, async ({ campaign_id, period }) => {
3605
3684
  const projectId = getProjectId();
@@ -3672,8 +3751,7 @@ Return ONLY valid JSON with this exact shape:
3672
3751
  ...campaign,
3673
3752
  reentry_mode: campaign.period_key ? "per_event_period" : "once_ever",
3674
3753
  requires_approval_description: ACTIVATION_APPROVAL_DESCRIPTION,
3675
- fires_on: buildCampaignFiresOn(campaign),
3676
- trigger_diagnostics: buildCampaignTriggerDiagnostics(campaign),
3754
+ ...await campaignTriggerOutput(campaign),
3677
3755
  total_sent: periods?.totals?.sent ?? sentCount ?? 0,
3678
3756
  ...(periods?.totals ? { unique_opened: periods.totals.opened, unique_clicked: periods.totals.clicked } : {}),
3679
3757
  ...campaignPeriodFields(periods, period),