@sendinel/mcp-server 1.0.173 → 1.0.175

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.
@@ -382,7 +382,7 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
382
382
  throw new Error(`Failed to create approval request: ${error.message}`);
383
383
  let projectName;
384
384
  let sandboxDisplayRead = false;
385
- const sandboxApproved = !(effective.applied && (effective.level === 'ask_every_time' || effective.class === 'F')) && tier === 'gated' && await trySandboxApproval(data.id, getProjectId(), action, approvalPayloadHash(action, frozenParams), name => { projectName = name; sandboxDisplayRead = true; });
385
+ const sandboxApproved = !(effective.applied && (effective.level === 'ask_every_time' || (effective.class === 'F' && !action.startsWith('delete_')))) && await trySandboxApproval(data.id, getProjectId(), action, approvalPayloadHash(action, frozenParams), name => { projectName = name; sandboxDisplayRead = true; }, frozenParams);
386
386
  if (isTesterRequest(action))
387
387
  await logTesterRequestAudit('tester_request', action, data.id, null);
388
388
  fireWebhookEvent('approval.requested', {
@@ -534,8 +534,8 @@ async function checkApprovalState(approvalId, options = {}) {
534
534
  return { approved: false, status: 'human_decision_required' };
535
535
  }
536
536
  const sandboxDecision = data.decided_via === 'sandbox';
537
- if (data.status === 'approved' && sandboxDecision && (data.project_id !== getProjectId() || data.tier !== 'gated' || PROTECTED_ACTIONS.has(data.action) ||
538
- !await sandboxApprovalEligible(getProjectId(), data.action))) {
537
+ if (data.status === 'approved' && sandboxDecision && (data.project_id !== getProjectId() || !['gated', 'protected'].includes(data.tier) || (data.tier === 'protected' && !['delete_segment', 'delete_subscriber_data'].includes(data.action)) ||
538
+ !await sandboxApprovalEligible(getProjectId(), data.action, undefined, data.params))) {
539
539
  return { approved: false, status: 'sandbox_decision_no_longer_eligible' };
540
540
  }
541
541
  if (data.status === 'approved' && data.params?.__approval_snapshot?.approval_channel_rule === 'human_only' && !['dashboard', 'chat_confirm'].includes(data.decided_via)) {
@@ -598,7 +598,7 @@ async function checkApprovalState(approvalId, options = {}) {
598
598
  approved: true,
599
599
  status: 'approved',
600
600
  decided_by: claimed.decided_by,
601
- ...(options.action && ['send_test_email', 'identify_subscriber', 'emit_contact_event'].includes(options.action) ? { consumed_at: consumedAt } : {}),
601
+ ...(options.action && ['send_test_email', 'identify_subscriber', 'emit_contact_event', 'delete_campaign'].includes(options.action) ? { consumed_at: consumedAt } : {}),
602
602
  ...(sandboxDecision ? { decision_channel: 'sandbox' } : {}),
603
603
  };
604
604
  }
@@ -0,0 +1,28 @@
1
+ /** Table names are the entity types in the service-only provenance ledger. */
2
+ export declare const DELETE_ITEM_TARGETS: {
3
+ readonly delete_campaign: readonly ['campaigns', 'campaign_id', 'campaign_ids'];
4
+ readonly delete_campaign_step: readonly ['campaign_steps', 'step_id', 'step_ids'];
5
+ readonly delete_content_block: readonly ['content_blocks', 'block_id', 'block_ids'];
6
+ readonly delete_custom_field: readonly ['custom_field_definitions', 'id', 'ids'];
7
+ readonly delete_landing_page: readonly ['landing_pages', 'page_id', 'page_ids'];
8
+ readonly delete_list: readonly ['contact_lists', 'list_id', 'list_ids'];
9
+ readonly delete_segment: readonly ['segments', 'segment_id', 'segment_ids'];
10
+ readonly delete_signature: readonly ['signature_blocks', 'id', 'ids'];
11
+ readonly delete_social_post: readonly ['social_posts', 'post_id', 'post_ids'];
12
+ readonly delete_subscriber_data: readonly ['subscribers', 'contact_id', 'contact_ids'];
13
+ readonly delete_subscriber_view: readonly ['subscriber_list_views', 'view_id', 'view_ids'];
14
+ readonly delete_template: readonly ['campaign_templates', 'id', 'ids'];
15
+ readonly delete_webhook_subscription: readonly ['webhook_subscriptions', 'subscription_id', 'subscription_ids'];
16
+ };
17
+ export type CreatedItemType = typeof DELETE_ITEM_TARGETS[keyof typeof DELETE_ITEM_TARGETS][0];
18
+ /** Only server-authenticated credential identity; never user IDs or tool arguments. */
19
+ export declare function requestingCreatorKey(): string | null;
20
+ /** Insert-only provenance: updates, retries and reconnects must never claim an existing item. */
21
+ export declare function recordCreatedItems(entityType: CreatedItemType, data: any): Promise<void>;
22
+ /** The query must return only newly inserted rows, never the result of an updating upsert. */
23
+ export declare function withCreatedItems<T extends {
24
+ data: any;
25
+ error: any;
26
+ }>(entityType: CreatedItemType, query: PromiseLike<T>): Promise<T>;
27
+ /** Unknown actions/targets and partial reads fail closed, including defensive bulk inputs. */
28
+ export declare function ownsDeleteTargets(projectId: string, action: string, params: Record<string, unknown>, creatorKey: string | null): Promise<boolean>;
@@ -0,0 +1,101 @@
1
+ import { adminDb, requestContext } from '../db.js';
2
+ import { getProjectId } from '../project.js';
3
+ /** Table names are the entity types in the service-only provenance ledger. */
4
+ export const DELETE_ITEM_TARGETS = {
5
+ delete_campaign: ['campaigns', 'campaign_id', 'campaign_ids'],
6
+ delete_campaign_step: ['campaign_steps', 'step_id', 'step_ids'],
7
+ delete_content_block: ['content_blocks', 'block_id', 'block_ids'],
8
+ delete_custom_field: ['custom_field_definitions', 'id', 'ids'],
9
+ delete_landing_page: ['landing_pages', 'page_id', 'page_ids'],
10
+ delete_list: ['contact_lists', 'list_id', 'list_ids'],
11
+ delete_segment: ['segments', 'segment_id', 'segment_ids'],
12
+ delete_signature: ['signature_blocks', 'id', 'ids'],
13
+ delete_social_post: ['social_posts', 'post_id', 'post_ids'],
14
+ delete_subscriber_data: ['subscribers', 'contact_id', 'contact_ids'],
15
+ delete_subscriber_view: ['subscriber_list_views', 'view_id', 'view_ids'],
16
+ delete_template: ['campaign_templates', 'id', 'ids'],
17
+ delete_webhook_subscription: ['webhook_subscriptions', 'subscription_id', 'subscription_ids'],
18
+ };
19
+ /** Only server-authenticated credential identity; never user IDs or tool arguments. */
20
+ export function requestingCreatorKey() {
21
+ const ctx = requestContext();
22
+ if (ctx?.authKind === 'oauth' && ctx.oauthClientId)
23
+ return `oauth:${ctx.oauthClientId}`;
24
+ if (ctx?.authKind === 'api_key' && ctx.keyId)
25
+ return `apikey:${ctx.keyId}`;
26
+ return null;
27
+ }
28
+ /** Insert-only provenance: updates, retries and reconnects must never claim an existing item. */
29
+ export async function recordCreatedItems(entityType, data) {
30
+ const creatorKey = requestingCreatorKey();
31
+ if (!creatorKey)
32
+ return;
33
+ const rows = (Array.isArray(data) ? data : data ? [data] : [])
34
+ .filter(row => typeof row.id === 'string')
35
+ .map(row => ({ project_id: getProjectId(), entity_type: entityType, entity_id: row.id, creator_key: creatorKey }));
36
+ if (!rows.length)
37
+ return;
38
+ try {
39
+ const { error } = await adminDb().schema('email').from('mcp_created_items')
40
+ .upsert(rows, { onConflict: 'project_id,entity_type,entity_id', ignoreDuplicates: true });
41
+ if (error)
42
+ console.warn('MCP creation provenance unavailable; deletion will require human approval.');
43
+ }
44
+ catch {
45
+ // The item already exists. Unknown provenance must stay unknown, never inferred later.
46
+ console.warn('MCP creation provenance unavailable; deletion will require human approval.');
47
+ }
48
+ }
49
+ /** The query must return only newly inserted rows, never the result of an updating upsert. */
50
+ export async function withCreatedItems(entityType, query) {
51
+ const result = await query;
52
+ if (!result.error)
53
+ await recordCreatedItems(entityType, result.data);
54
+ return result;
55
+ }
56
+ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
57
+ /** Unknown actions/targets and partial reads fail closed, including defensive bulk inputs. */
58
+ export async function ownsDeleteTargets(projectId, action, params, creatorKey) {
59
+ const target = DELETE_ITEM_TARGETS[action];
60
+ if (!creatorKey || !target)
61
+ return false;
62
+ const [entityType, single, bulk] = target;
63
+ const source = adminDb().schema('email');
64
+ let ids = [];
65
+ if (params[single] !== undefined)
66
+ ids.push(params[single]);
67
+ if (params[bulk] !== undefined) {
68
+ if (!Array.isArray(params[bulk]) || !params[bulk].length)
69
+ return false;
70
+ ids.push(...params[bulk]);
71
+ }
72
+ // GDPR snapshots carry both ID and email. Verify the address names that same target.
73
+ if (action === 'delete_subscriber_data' && params.email !== undefined) {
74
+ if (typeof params.email !== 'string' || !params.email.trim())
75
+ return false;
76
+ const { data, error } = await source.from('subscribers').select('id').eq('project_id', projectId)
77
+ .eq('email', params.email.trim().toLowerCase()).maybeSingle();
78
+ if (error || !data || (ids.length && (ids.length !== 1 || ids[0] !== data.id)))
79
+ return false;
80
+ ids.push(data.id);
81
+ }
82
+ if (!ids.length || ids.some(id => typeof id !== 'string' || !uuid.test(id)))
83
+ return false;
84
+ ids = [...new Set(ids)];
85
+ const owns = async (type, targets) => {
86
+ const { data, error } = await source.from('mcp_created_items').select('entity_id')
87
+ .eq('project_id', projectId).eq('entity_type', type).eq('creator_key', creatorKey).in('entity_id', targets);
88
+ return !error && Array.isArray(data) && targets.every(id => data.some(row => row.entity_id === id));
89
+ };
90
+ if (!await owns(entityType, ids))
91
+ return false;
92
+ // Deleting a campaign also deletes its steps; another client may have added one.
93
+ if (action === 'delete_campaign') {
94
+ const { data, error } = await source.from('campaign_steps').select('id').in('campaign_id', ids);
95
+ if (error || !Array.isArray(data))
96
+ return false;
97
+ if (data.length && !await owns('campaign_steps', data.map(row => row.id)))
98
+ return false;
99
+ }
100
+ return true;
101
+ }
@@ -27,5 +27,5 @@ export declare const SANDBOX_ADMIN_SCOPE: {
27
27
  readonly tenant_export_full: 'project';
28
28
  };
29
29
  /** Never consult token claims, BYOD clients or cached display data for this authority. */
30
- export declare function sandboxApprovalEligible(projectId: string, action: string, onProjectRead?: (name: string | undefined) => void): Promise<boolean>;
31
- export declare function trySandboxApproval(id: string, projectId: string, action: string, payloadHash: string, onProjectRead?: (name: string | undefined) => void): Promise<boolean>;
30
+ export declare function sandboxApprovalEligible(projectId: string, action: string, onProjectRead?: (name: string | undefined) => void, params?: Record<string, unknown>): Promise<boolean>;
31
+ export declare function trySandboxApproval(id: string, projectId: string, action: string, payloadHash: string, onProjectRead?: (name: string | undefined) => void, params?: Record<string, unknown>): Promise<boolean>;
@@ -1,4 +1,6 @@
1
1
  import { adminDb } from '../db.js';
2
+ import { DELETE_ITEM_TARGETS, ownsDeleteTargets, requestingCreatorKey } from './mcp-created-items.js';
3
+ import { approvalClass } from '@sendinel/domain';
2
4
  import { PROTECTED_ACTIONS } from './approval-actions.js';
3
5
  import { isHumanRequestableAdminTool } from './admin-tools.js';
4
6
  /** Conservative classification: shared org credentials and grants require an all-sandbox org. */
@@ -14,8 +16,12 @@ export const SANDBOX_ADMIN_SCOPE = {
14
16
  tenant_export_full: 'project',
15
17
  };
16
18
  /** Never consult token claims, BYOD clients or cached display data for this authority. */
17
- export async function sandboxApprovalEligible(projectId, action, onProjectRead) {
18
- if (PROTECTED_ACTIONS.has(action))
19
+ export async function sandboxApprovalEligible(projectId, action, onProjectRead, params = {}) {
20
+ const deletion = approvalClass(action) === 'D' || action.startsWith('delete_');
21
+ // No exception for shared org credentials, unknown delete actions or other protected effects.
22
+ if (deletion && (!(action in DELETE_ITEM_TARGETS) || !requestingCreatorKey()))
23
+ return false;
24
+ if (action === 'delete_provider_credential' || (!deletion && PROTECTED_ACTIONS.has(action)))
19
25
  return false;
20
26
  try {
21
27
  const source = adminDb().schema('email');
@@ -24,6 +30,8 @@ export async function sandboxApprovalEligible(projectId, action, onProjectRead)
24
30
  onProjectRead?.(!error && typeof data?.name === 'string' ? data.name : undefined);
25
31
  if (error || data?.id !== projectId || data.sandbox_mode !== true)
26
32
  return false;
33
+ if (deletion)
34
+ return await ownsDeleteTargets(projectId, action, params, requestingCreatorKey());
27
35
  if (!isHumanRequestableAdminTool(action))
28
36
  return true;
29
37
  const scope = SANDBOX_ADMIN_SCOPE[action];
@@ -39,8 +47,8 @@ export async function sandboxApprovalEligible(projectId, action, onProjectRead)
39
47
  return false;
40
48
  }
41
49
  }
42
- export async function trySandboxApproval(id, projectId, action, payloadHash, onProjectRead) {
43
- if (!await sandboxApprovalEligible(projectId, action, onProjectRead))
50
+ export async function trySandboxApproval(id, projectId, action, payloadHash, onProjectRead, params = {}) {
51
+ if (!await sandboxApprovalEligible(projectId, action, onProjectRead, params))
44
52
  return false;
45
53
  try {
46
54
  // The RPC performs one conditional UPDATE + audit INSERT in one transaction.
@@ -1,3 +1,4 @@
1
+ import { withCreatedItems } from '../lib/mcp-created-items.js';
1
2
  import { countEmailRows, groupMetricRows, percentRate, isBouncedRow, AI_MAX_TOKENS, outputConfig, anthropicCheckedText, readAllRows } from "@sendinel/domain";
2
3
  import { z } from 'zod';
3
4
  import Anthropic from '@anthropic-ai/sdk';
@@ -361,7 +362,7 @@ Each subject should be distinct in approach (urgency, curiosity, benefit, social
361
362
  const labels = ['B', 'C'];
362
363
  variantsCreated = [];
363
364
  for (let i = 0; i < 2; i++) {
364
- const { error: insertErr } = await db()
365
+ const { error: insertErr } = await withCreatedItems('campaign_steps', db()
365
366
  .from('campaign_steps')
366
367
  .insert({
367
368
  campaign_id,
@@ -372,7 +373,7 @@ Each subject should be distinct in approach (urgency, curiosity, benefit, social
372
373
  body_text: currentStep.text_body,
373
374
  delay_days: currentStep.delay_days,
374
375
  delay_hours: currentStep.delay_hours,
375
- });
376
+ }).select('id'));
376
377
  if (!insertErr)
377
378
  variantsCreated.push(labels[i]);
378
379
  }
@@ -1,3 +1,4 @@
1
+ import { withCreatedItems } from '../lib/mcp-created-items.js';
1
2
  import { createHash, randomUUID } from 'node:crypto';
2
3
  import { z } from 'zod';
3
4
  import Anthropic from '@anthropic-ai/sdk';
@@ -149,7 +150,7 @@ export async function writeCampaignDrafts(projectId, siteId, drafts, source) {
149
150
  }, projectId);
150
151
  if (targetingError)
151
152
  return { error: { error: targetingError, code: 'INVALID_FIELD', campaign: c.name, created_before_failure: createdIds } };
152
- const { data: campaign, error } = await db().from('campaigns').insert(insertPayload).select('id').single();
153
+ const { data: campaign, error } = await withCreatedItems('campaigns', db().from('campaigns').insert(insertPayload).select('id').single());
153
154
  if (error || !campaign)
154
155
  return { error: { error: (error ? dbToolError(error).error : undefined) ?? 'Failed to create campaign', code: 'CAMPAIGN_CREATE_FAILED', campaign: c.name, created_before_failure: createdIds } };
155
156
  createdIds.push(campaign.id);
@@ -164,7 +165,7 @@ export async function writeCampaignDrafts(projectId, siteId, drafts, source) {
164
165
  delay_hours: e.delay_hours,
165
166
  variant_label: 'A',
166
167
  }));
167
- const { error: stepError } = await db().from('campaign_steps').insert(rows);
168
+ const { error: stepError } = await withCreatedItems('campaign_steps', db().from('campaign_steps').insert(rows).select('id'));
168
169
  if (stepError)
169
170
  return { error: { ...dbToolError(stepError), code: 'STEP_CREATE_FAILED', campaign: c.name, created_before_failure: createdIds } };
170
171
  }
@@ -1,3 +1,4 @@
1
+ import { withCreatedItems } from '../lib/mcp-created-items.js';
1
2
  import { validateStepMultiEventRules, stepMultiEventRulesView, validateStepAbsenceRule, stepAbsenceRuleView } from "@sendinel/domain";
2
3
  import { readCampaignMetricGroups, SENT_STATUSES } from "@sendinel/domain";
3
4
  import { resolveCampaignReentry, readCampaignPeriods, campaignPeriodFields } from "@sendinel/domain";
@@ -19,7 +20,7 @@ import { dbErrorResult, dbToolError, toolError } from '../lib/db-error.js';
19
20
  import { checkPlanLimit } from '../plan-limits.js';
20
21
  import { buildVoicePrompt, buildEmailSystemPrompt, deriveTemplateVariables, validateTemplate } from '../lib/voice.js';
21
22
  import { fireWebhookEvent } from '../lib/webhook-events.js';
22
- import { approvalNext, approvalExcerpt, assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval, withApproval } from '../lib/action-approvals.js';
23
+ import { approvalNext, approvalExcerpt, assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval, withApproval, markNotExecuted } from '../lib/action-approvals.js';
23
24
  import { sandboxAudience, broadcastApprovalNext } from '../lib/sandbox-audience.js';
24
25
  import { resolveSegmentContactIds, resolveSendAudience } from '../lib/send-audience.js';
25
26
  import { AudienceNotUnderstoodError, generateEmailFromBrief, parseAudienceRules, unsupportedAudienceReason } from '../lib/email-generation.js';
@@ -478,7 +479,7 @@ async function executeBroadcast(input) {
478
479
  if (!limit.allowed) {
479
480
  return { ok: false, error: `Campaign limit reached (${limit.current}/${limit.limit}). Upgrade your plan for more campaigns.`, code: 'PLAN_LIMIT' };
480
481
  }
481
- const { data: campaign, error: campaignErr } = await db()
482
+ const { data: campaign, error: campaignErr } = await withCreatedItems('campaigns', db()
482
483
  .from('campaigns')
483
484
  .insert({
484
485
  project_id: input.projectId,
@@ -494,11 +495,11 @@ async function executeBroadcast(input) {
494
495
  metadata: { source: input.source, ...(input.metadata ?? {}), content_key: contentKey },
495
496
  })
496
497
  .select('id')
497
- .single();
498
+ .single());
498
499
  if (campaignErr || !campaign) {
499
500
  return { ok: false, error: (campaignErr ? dbToolError(campaignErr).error : undefined) ?? 'Failed to create campaign', code: 'CAMPAIGN_CREATE_FAILED' };
500
501
  }
501
- const { data: step, error: stepErr } = await db()
502
+ const { data: step, error: stepErr } = await withCreatedItems('campaign_steps', db()
502
503
  .from('campaign_steps')
503
504
  .insert({
504
505
  campaign_id: campaign.id,
@@ -511,7 +512,7 @@ async function executeBroadcast(input) {
511
512
  delay_hours: 0,
512
513
  })
513
514
  .select('id')
514
- .single();
515
+ .single());
515
516
  if (stepErr || !step) {
516
517
  return { ok: false, error: (stepErr ? dbToolError(stepErr).error : undefined) ?? 'Campaign created but failed to add broadcast step', code: 'STEP_CREATE_FAILED', campaign_id: campaign.id };
517
518
  }
@@ -899,11 +900,11 @@ export function registerCampaignTools(server) {
899
900
  const targetingError = await applyCampaignTargetingFields(insertPayload, { trigger_event_name, trigger_tag, trigger_type, trigger_config, segment_id }, projectId);
900
901
  if (targetingError)
901
902
  return j({ error: targetingError, code: targetingError === 'Segment not found' ? 'NOT_FOUND' : 'validation_failed' });
902
- const { data, error } = await db()
903
+ const { data, error } = await withCreatedItems('campaigns', db()
903
904
  .from('campaigns')
904
905
  .insert(insertPayload)
905
906
  .select()
906
- .single();
907
+ .single());
907
908
  if (error)
908
909
  return { content: [{ type: 'text', text: `Error: ${dbToolError(error).error}` }] };
909
910
  const warnings = await campaignTimezoneWarnings(data);
@@ -983,11 +984,11 @@ export function registerCampaignTools(server) {
983
984
  const renderedSubject = survey_id ? renderTemplateVars(subject, renderVars) : subject;
984
985
  const renderedHtml = body_html !== undefined && survey_id ? renderTemplateVars(body_html, renderVars) : body_html ?? htmlFromBlocks;
985
986
  const renderedText = body_text !== undefined && survey_id ? renderTemplateVars(body_text, renderVars) : body_text;
986
- const { data, error } = await db()
987
+ const { data, error } = await withCreatedItems('campaign_steps', db()
987
988
  .from('campaign_steps')
988
989
  .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, ...events.rules, ...absence.rules, ...properties.rules })
989
990
  .select()
990
- .single();
991
+ .single());
991
992
  if (error)
992
993
  return dbErrorResult(error);
993
994
  await recordBrandAssetUsage({
@@ -1722,7 +1723,7 @@ export function registerCampaignTools(server) {
1722
1723
  const limit = await checkPlanLimit('active_campaigns');
1723
1724
  if (!limit.allowed)
1724
1725
  return toolError({ error: `Active campaign limit reached (${limit.current}/${limit.limit}).`, code: 'plan_limit' });
1725
- const { data: newCampaign, error: createErr } = await db()
1726
+ const { data: newCampaign, error: createErr } = await withCreatedItems('campaigns', db()
1726
1727
  .from('campaigns')
1727
1728
  .insert({
1728
1729
  project_id: projectId,
@@ -1735,7 +1736,7 @@ export function registerCampaignTools(server) {
1735
1736
  status: 'draft',
1736
1737
  })
1737
1738
  .select()
1738
- .single();
1739
+ .single());
1739
1740
  if (createErr)
1740
1741
  return toolError(dbToolError(createErr));
1741
1742
  const steps = (original.campaign_steps ?? []).sort((a, b) => a.step_order - b.step_order);
@@ -1750,7 +1751,7 @@ export function registerCampaignTools(server) {
1750
1751
  delay_days: s.delay_days,
1751
1752
  delay_hours: s.delay_hours,
1752
1753
  }));
1753
- const { error: stepErr } = await db().from('campaign_steps').insert(stepRows);
1754
+ const { error: stepErr } = await withCreatedItems('campaign_steps', db().from('campaign_steps').insert(stepRows).select('id'));
1754
1755
  if (stepErr)
1755
1756
  return toolError({ ...dbToolError(stepErr, 'Campaign created but copying steps failed'), campaign_id: newCampaign.id });
1756
1757
  }
@@ -1861,7 +1862,7 @@ export function registerCampaignTools(server) {
1861
1862
  return txt('Error: A sending domain is required before cloning a template — create one with create_site first, or pass site_id.');
1862
1863
  }
1863
1864
  const cloneName = new_name?.trim() || `${template.name} (cloned)`;
1864
- const { data: campaign, error: campaignError } = await db()
1865
+ const { data: campaign, error: campaignError } = await withCreatedItems('campaigns', db()
1865
1866
  .from('campaigns')
1866
1867
  .insert({
1867
1868
  project_id: projectId,
@@ -1871,12 +1872,12 @@ export function registerCampaignTools(server) {
1871
1872
  status: 'draft',
1872
1873
  })
1873
1874
  .select()
1874
- .single();
1875
+ .single());
1875
1876
  if (campaignError)
1876
1877
  return txt(`Error creating campaign: ${dbToolError(campaignError).error}`);
1877
1878
  const steps = Array.isArray(template.template?.steps) ? template.template.steps : [];
1878
1879
  if (steps.length > 0) {
1879
- const { error: stepsError } = await db()
1880
+ const { error: stepsError } = await withCreatedItems('campaign_steps', db()
1880
1881
  .from('campaign_steps')
1881
1882
  .insert(steps.map((step, index) => ({
1882
1883
  campaign_id: campaign.id,
@@ -1888,7 +1889,7 @@ export function registerCampaignTools(server) {
1888
1889
  delay_hours: step.delay_hours ?? 0,
1889
1890
  condition_type: step.condition_type ?? null,
1890
1891
  condition_lookback_hours: step.condition_lookback_hours ?? null,
1891
- })));
1892
+ }))).select('id'));
1892
1893
  if (stepsError)
1893
1894
  return txt(`Campaign created but steps failed: ${dbToolError(stepsError).error}`);
1894
1895
  }
@@ -2103,11 +2104,11 @@ Adapt your tone accordingly:
2103
2104
  const rulesCheck = validateSegmentRules(plan.rules);
2104
2105
  if (!rulesCheck.ok)
2105
2106
  return txt(`Error: Could not use the audience rules. ${rulesCheck.errors[0]} Nothing was created.`);
2106
- const { data: seg, error: segErr } = await db()
2107
+ const { data: seg, error: segErr } = await withCreatedItems('segments', db()
2107
2108
  .from('segments')
2108
2109
  .insert({ project_id: projectId, name: plan.segment_name, description: segment_description, rules: plan.rules })
2109
2110
  .select('id')
2110
- .single();
2111
+ .single());
2111
2112
  if (segErr)
2112
2113
  return txt(`Error creating segment: ${dbToolError(segErr).error}`);
2113
2114
  segId = seg.id;
@@ -2115,17 +2116,17 @@ Adapt your tone accordingly:
2115
2116
  const campLimit = await checkPlanLimit('active_campaigns');
2116
2117
  if (!campLimit.allowed)
2117
2118
  return txt(`Active campaign limit reached (${campLimit.current}/${campLimit.limit}).`);
2118
- const { data: campaign, error: campErr } = await db()
2119
+ const { data: campaign, error: campErr } = await withCreatedItems('campaigns', db()
2119
2120
  .from('campaigns')
2120
2121
  .insert({ project_id: projectId, site_id, name, type: type ?? 'sequence', segment_id: segId, sends_per_hour: sends_per_hour ?? null, requires_approval: false })
2121
2122
  .select('id')
2122
- .single();
2123
+ .single());
2123
2124
  if (campErr)
2124
2125
  return txt(`Error creating campaign: ${dbToolError(campErr).error}`);
2125
2126
  const steps = [];
2126
2127
  for (let i = 0; i < plan.steps.length; i++) {
2127
2128
  const planned = plan.steps[i];
2128
- const { data: step, error: stepErr } = await db()
2129
+ const { data: step, error: stepErr } = await withCreatedItems('campaign_steps', db()
2129
2130
  .from('campaign_steps')
2130
2131
  .insert({
2131
2132
  campaign_id: campaign.id, step_order: i,
@@ -2134,7 +2135,7 @@ Adapt your tone accordingly:
2134
2135
  delay_days: planned.delay_days, delay_hours: planned.delay_hours,
2135
2136
  })
2136
2137
  .select('id, step_order, subject')
2137
- .single();
2138
+ .single());
2138
2139
  if (stepErr)
2139
2140
  return txt(`Error adding step ${i}: ${dbToolError(stepErr).error}`);
2140
2141
  steps.push(step);
@@ -3314,7 +3315,7 @@ Return ONLY valid JSON with this exact shape:
3314
3315
  }
3315
3316
  // Execute mode (second call)
3316
3317
  // 1. Create campaign
3317
- const { data: campaign, error: campErr } = await db()
3318
+ const { data: campaign, error: campErr } = await withCreatedItems('campaigns', db()
3318
3319
  .from('campaigns')
3319
3320
  .insert({
3320
3321
  project_id: getProjectId(),
@@ -3329,7 +3330,7 @@ Return ONLY valid JSON with this exact shape:
3329
3330
  sends_per_hour: sends_per_hour ?? null,
3330
3331
  })
3331
3332
  .select()
3332
- .single();
3333
+ .single());
3333
3334
  if (campErr)
3334
3335
  return txt(`Failed to create campaign: ${dbToolError(campErr).error}`);
3335
3336
  // 2. Add steps
@@ -3347,9 +3348,9 @@ Return ONLY valid JSON with this exact shape:
3347
3348
  ...stepMultiEventRulesView(s),
3348
3349
  ...stepAbsenceRuleView(s),
3349
3350
  }));
3350
- const { error: stepErr } = await db()
3351
+ const { error: stepErr } = await withCreatedItems('campaign_steps', db()
3351
3352
  .from('campaign_steps')
3352
- .insert(stepInserts);
3353
+ .insert(stepInserts).select('id'));
3353
3354
  if (stepErr)
3354
3355
  return txt(`Campaign created (${campaign.id}) but failed to add steps: ${dbToolError(stepErr).error}`);
3355
3356
  // 3. Enroll segment if provided
@@ -3599,10 +3600,13 @@ Return ONLY valid JSON with this exact shape:
3599
3600
  .eq('id', campaign_id)
3600
3601
  .eq('project_id', projectId)
3601
3602
  .maybeSingle();
3602
- if (fetchErr || !campaign)
3603
- return j({ error: 'Campaign not found', code: 'NOT_FOUND' });
3603
+ // No write has happened: release the claim so the same approval can be retried.
3604
+ if (fetchErr)
3605
+ return markNotExecuted(j(dbToolError(fetchErr)));
3606
+ if (!campaign)
3607
+ return markNotExecuted(j({ error: 'Campaign not found', code: 'NOT_FOUND' }));
3604
3608
  if (campaign.status === 'active') {
3605
- return j({ error: 'Pause the campaign before deleting', code: 'INVALID_STATUS' });
3609
+ return markNotExecuted(j({ error: 'Pause the campaign before deleting', code: 'INVALID_STATUS' }));
3606
3610
  }
3607
3611
  const { error } = await db()
3608
3612
  .from('campaigns')
@@ -1,3 +1,4 @@
1
+ import { withCreatedItems } from '../lib/mcp-created-items.js';
1
2
  import { anthropicRequiredText, outputConfig } from '@sendinel/domain';
2
3
  import { z } from 'zod';
3
4
  import Anthropic from '@anthropic-ai/sdk';
@@ -115,7 +116,7 @@ Respond with a JSON array.`,
115
116
  catch (error) {
116
117
  return j({ error: `AI generation failed: ${error instanceof Error ? error.message : String(error)}`, code: 'GENERATION_FAILED' });
117
118
  }
118
- const { data: template, error: templateErr } = await sb.from('campaign_templates')
119
+ const { data: template, error: templateErr } = await withCreatedItems('campaign_templates', sb.from('campaign_templates')
119
120
  .insert({
120
121
  project_id: projectId,
121
122
  name: `${campaign_name} - Template`,
@@ -127,19 +128,19 @@ Respond with a JSON array.`,
127
128
  description: `Base template generated for campaign "${campaign_name}"`,
128
129
  })
129
130
  .select('id')
130
- .single();
131
+ .single());
131
132
  if (templateErr)
132
133
  return j({ error: `Template creation failed: ${dbToolError(templateErr).error}` });
133
- const { data: campaign, error: campaignErr } = await sb.from('campaigns')
134
+ const { data: campaign, error: campaignErr } = await withCreatedItems('campaigns', sb.from('campaigns')
134
135
  .insert({ name: campaign_name, site_id, project_id: projectId, type: campaign_type, status: 'draft' })
135
136
  .select('id, name, status')
136
- .single();
137
+ .single());
137
138
  if (campaignErr)
138
139
  return j({ error: `Campaign creation failed: ${dbToolError(campaignErr).error}` });
139
140
  const createdSteps = [];
140
141
  for (let i = 0; i < steps.length; i++) {
141
142
  const step = steps[i];
142
- const { data, error } = await sb.from('campaign_steps')
143
+ const { data, error } = await withCreatedItems('campaign_steps', sb.from('campaign_steps')
143
144
  .insert({
144
145
  campaign_id: campaign.id,
145
146
  step_order: i,
@@ -152,7 +153,7 @@ Respond with a JSON array.`,
152
153
  template_id: String(template.id),
153
154
  })
154
155
  .select('id, step_order, subject')
155
- .single();
156
+ .single());
156
157
  if (error)
157
158
  return j({ error: `Step ${i + 1} creation failed: ${dbToolError(error).error}`, campaign_id: campaign.id });
158
159
  createdSteps.push(data);
@@ -224,7 +225,7 @@ Respond with JSON: { "subject": "...", "blocks": [...], "body_text": "..." }`,
224
225
  console.error("[compound] Generation failed:", error);
225
226
  return toolError(dbToolError(error, "Generation failed: unable to generate template"));
226
227
  }
227
- const { data: template, error } = await db().from('campaign_templates')
228
+ const { data: template, error } = await withCreatedItems('campaign_templates', db().from('campaign_templates')
228
229
  .insert({
229
230
  project_id: projectId,
230
231
  name,
@@ -236,7 +237,7 @@ Respond with JSON: { "subject": "...", "blocks": [...], "body_text": "..." }`,
236
237
  description: 'Generated from MCP brief',
237
238
  })
238
239
  .select('id, name, subject_hint, created_at')
239
- .single();
240
+ .single());
240
241
  if (error)
241
242
  return j(dbToolError(error));
242
243
  return j({ ok: true, template, preview: generated });
@@ -1,3 +1,4 @@
1
+ import { recordCreatedItems, withCreatedItems } from '../lib/mcp-created-items.js';
1
2
  import { normalizeTagInput, tagInputWarnings } from '../lib/tags.js';
2
3
  import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
3
4
  import { CONTACT_ENRICHMENT_PROVIDERS } from '@desler/models';
@@ -671,8 +672,12 @@ export function registerContactTools(server) {
671
672
  },
672
673
  }, withApproval('identify_subscriber', async (params) => {
673
674
  const result = await callSendinelV1('/api/v1/identify', { method: 'POST', body: params });
674
- if (result.ok)
675
+ if (result.ok) {
676
+ const identified = result.data;
677
+ if (identified.created === true && identified.contact_id)
678
+ await recordCreatedItems('subscribers', { id: identified.contact_id });
675
679
  return j(result.data);
680
+ }
676
681
  const failed = err(JSON.stringify(result.error));
677
682
  return isPreExecutionStatus(result.status) ? markNotExecuted(failed) : failed;
678
683
  }));
@@ -1133,7 +1138,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1133
1138
  const normalizedName = name.trim().slice(0, SUBSCRIBER_VIEW_MAX_NAME_LENGTH);
1134
1139
  if (!normalizedName)
1135
1140
  return toolError({ error: 'name is required', code: 'validation_failed' });
1136
- const { data, error } = await db()
1141
+ const { data, error } = await withCreatedItems('subscriber_list_views', db()
1137
1142
  .from('subscriber_list_views')
1138
1143
  .insert({
1139
1144
  project_id: getProjectId(),
@@ -1144,7 +1149,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1144
1149
  filters: normalizeSubscriberViewFilters(filters),
1145
1150
  })
1146
1151
  .select('id, name, view_mode, columns, filters, created_by, created_at, updated_at')
1147
- .maybeSingle();
1152
+ .maybeSingle());
1148
1153
  if (error?.code === '23505')
1149
1154
  return toolError({ error: `A view named "${normalizedName}" already exists`, code: 'conflict' });
1150
1155
  if (error)
@@ -1378,9 +1383,9 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1378
1383
  row.utm_content = utm_content;
1379
1384
  if (utm_term)
1380
1385
  row.utm_term = utm_term;
1381
- const { data, error } = await db().from('subscribers')
1386
+ const { data, error } = await withCreatedItems('subscribers', db().from('subscribers')
1382
1387
  .upsert(row, { onConflict: 'project_id,email', ignoreDuplicates: true })
1383
- .select(selection).maybeSingle();
1388
+ .select(selection).maybeSingle());
1384
1389
  if (error)
1385
1390
  return dbErrorResult(error);
1386
1391
  contact = data;
@@ -1532,6 +1537,12 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1532
1537
  source: 'import',
1533
1538
  opted_in_at: new Date().toISOString(),
1534
1539
  }));
1540
+ // ON CONFLICT DO NOTHING returns only actual inserts, even under concurrent imports.
1541
+ // The updating upsert below must never claim a pre-existing subscriber.
1542
+ const { error: creationError } = await withCreatedItems('subscribers', db().from('subscribers')
1543
+ .upsert(rows, { onConflict: 'project_id,email', ignoreDuplicates: true }).select('id'));
1544
+ if (creationError)
1545
+ return j(dbToolError(creationError));
1535
1546
  const { data: upserted, error: uErr } = await db()
1536
1547
  .from('subscribers')
1537
1548
  .upsert(rows, { onConflict: 'project_id,email', ignoreDuplicates: false })
@@ -2326,55 +2337,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2326
2337
  return toolError({ error: error.message, code: 'validation_failed' });
2327
2338
  throw error;
2328
2339
  }
2329
- let q = db()
2330
- .from('subscribers')
2331
- .select('id, email, first_name, last_name, engagement_score, lifecycle_stage, source, created_at, last_opened_at, last_clicked_at')
2332
- .eq('project_id', projectId)
2333
- .eq('global_unsubscribed', false);
2334
- if (params.lifecycle_stage)
2335
- q = q.eq('lifecycle_stage', params.lifecycle_stage);
2336
- if (params.min_score !== undefined)
2337
- q = q.gte('engagement_score', params.min_score);
2338
- if (params.max_score !== undefined)
2339
- q = q.lte('engagement_score', params.max_score);
2340
- if (params.source)
2341
- q = q.eq('source', params.source);
2342
- if (params.subscribed_after)
2343
- q = q.gte('created_at', `${params.subscribed_after}T00:00:00Z`);
2344
- if (params.subscribed_before)
2345
- q = q.lte('created_at', `${params.subscribed_before}T23:59:59Z`);
2346
- if (params.has_opened === true)
2347
- q = q.not('last_opened_at', 'is', null);
2348
- if (params.has_opened === false)
2349
- q = q.is('last_opened_at', null);
2350
- if (params.has_clicked === true)
2351
- q = q.not('last_clicked_at', 'is', null);
2352
- if (params.has_clicked === false)
2353
- q = q.is('last_clicked_at', null);
2354
- if (params.tag)
2355
- q = q.contains('tags', [params.tag]);
2356
- if (params.company_id)
2357
- q = q.eq('company_id', params.company_id);
2358
- const fieldTypes = params.custom_property ? await loadContactPropertyDefinitions(projectId) : {};
2359
- if (params.custom_property) {
2360
- try {
2361
- // #4270: the query runs as-is, so a filter it cannot express is an error, not dropped.
2362
- ({ query: q } = await applySegmentRules(q, { custom_property: params.custom_property }, db(), projectId, fieldTypes, { pushDownOnly: true }));
2363
- }
2364
- catch (error) {
2365
- if (isSegmentRulesError(error))
2366
- return toolError({ error: error.message, code: 'validation_failed' });
2367
- throw error;
2368
- }
2369
- }
2370
- if (params.site_id) {
2371
- // Shared site filter: an inner join, not an id list that overflows past ~350 contacts (#3674).
2372
- // Deliberately everyone on the site, so allowEmpty.
2373
- const siteFiltered = await applySegmentRules(q, { site_id: params.site_id }, db(), projectId, {}, { allowEmpty: true });
2374
- if (siteFiltered.siteContactIds && siteFiltered.siteContactIds.length === 0)
2375
- return j({ total: 0, sample: [] });
2376
- q = siteFiltered.query;
2377
- }
2340
+ let companyIds;
2378
2341
  if (params.company_domain) {
2379
2342
  const { data: companies, error } = await db()
2380
2343
  .from('companies')
@@ -2386,45 +2349,86 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2386
2349
  const ids = (companies ?? []).map((company) => company.id);
2387
2350
  if (!ids.length)
2388
2351
  return j({ total: 0, sample: [] });
2389
- q = q.in('company_id', ids);
2352
+ companyIds = ids;
2390
2353
  }
2354
+ const fieldTypes = params.custom_property ? await loadContactPropertyDefinitions(projectId) : {};
2355
+ const applyEstimateFilters = async (baseQuery) => {
2356
+ let filtered = baseQuery;
2357
+ if (params.lifecycle_stage)
2358
+ filtered = filtered.eq('lifecycle_stage', params.lifecycle_stage);
2359
+ if (params.min_score !== undefined)
2360
+ filtered = filtered.gte('engagement_score', params.min_score);
2361
+ if (params.max_score !== undefined)
2362
+ filtered = filtered.lte('engagement_score', params.max_score);
2363
+ if (params.source)
2364
+ filtered = filtered.eq('source', params.source);
2365
+ if (params.subscribed_after)
2366
+ filtered = filtered.gte('created_at', `${params.subscribed_after}T00:00:00Z`);
2367
+ if (params.subscribed_before)
2368
+ filtered = filtered.lte('created_at', `${params.subscribed_before}T23:59:59Z`);
2369
+ if (params.has_opened === true)
2370
+ filtered = filtered.not('last_opened_at', 'is', null);
2371
+ if (params.has_opened === false)
2372
+ filtered = filtered.is('last_opened_at', null);
2373
+ if (params.has_clicked === true)
2374
+ filtered = filtered.not('last_clicked_at', 'is', null);
2375
+ if (params.has_clicked === false)
2376
+ filtered = filtered.is('last_clicked_at', null);
2377
+ if (params.tag)
2378
+ filtered = filtered.contains('tags', [params.tag]);
2379
+ if (params.company_id)
2380
+ filtered = filtered.eq('company_id', params.company_id);
2381
+ if (companyIds)
2382
+ filtered = filtered.in('company_id', companyIds);
2383
+ if (params.custom_property) {
2384
+ try {
2385
+ // #4270: the query runs as-is, so a filter it cannot express is an error, not dropped.
2386
+ const res = await applySegmentRules(filtered, { custom_property: params.custom_property }, db(), projectId, fieldTypes, { pushDownOnly: true });
2387
+ filtered = res.query;
2388
+ }
2389
+ catch (error) {
2390
+ if (isSegmentRulesError(error))
2391
+ return { query: filtered, error: toolError({ error: error.message, code: 'validation_failed' }) };
2392
+ throw error;
2393
+ }
2394
+ }
2395
+ if (params.site_id) {
2396
+ // Shared site filter: an inner join, not an id list that overflows past ~350 contacts (#3674).
2397
+ // Deliberately everyone on the site, so allowEmpty.
2398
+ const siteFiltered = await applySegmentRules(filtered, { site_id: params.site_id }, db(), projectId, {}, { allowEmpty: true });
2399
+ if (siteFiltered.siteContactIds && siteFiltered.siteContactIds.length === 0) {
2400
+ return { query: siteFiltered.query, empty: true };
2401
+ }
2402
+ filtered = siteFiltered.query;
2403
+ }
2404
+ return { query: filtered };
2405
+ };
2406
+ let sampleBase = db()
2407
+ .from('subscribers')
2408
+ .select('id, email, first_name, last_name, engagement_score, lifecycle_stage, source, created_at, last_opened_at, last_clicked_at')
2409
+ .eq('project_id', projectId)
2410
+ .eq('global_unsubscribed', false);
2411
+ const sampleFilterResult = await applyEstimateFilters(sampleBase);
2412
+ if (sampleFilterResult.error)
2413
+ return sampleFilterResult.error;
2414
+ if (sampleFilterResult.empty)
2415
+ return j({ total: 0, sample: [] });
2416
+ let q = sampleFilterResult.query;
2391
2417
  const { data, error } = await q.order('engagement_score', { ascending: false }).limit(sampleSize);
2392
2418
  if (error)
2393
2419
  return dbErrorResult(error);
2394
- // Get full count separately
2395
- let countQ = db()
2420
+ // Get full count separately using the exact same filters
2421
+ let countBase = db()
2396
2422
  .from('subscribers')
2397
2423
  .select('id', { count: 'exact', head: true })
2398
2424
  .eq('project_id', projectId)
2399
2425
  .eq('global_unsubscribed', false);
2400
- if (params.lifecycle_stage)
2401
- countQ = countQ.eq('lifecycle_stage', params.lifecycle_stage);
2402
- if (params.min_score !== undefined)
2403
- countQ = countQ.gte('engagement_score', params.min_score);
2404
- if (params.max_score !== undefined)
2405
- countQ = countQ.lte('engagement_score', params.max_score);
2406
- if (params.source)
2407
- countQ = countQ.eq('source', params.source);
2408
- if (params.subscribed_after)
2409
- countQ = countQ.gte('created_at', `${params.subscribed_after}T00:00:00Z`);
2410
- if (params.subscribed_before)
2411
- countQ = countQ.lte('created_at', `${params.subscribed_before}T23:59:59Z`);
2412
- if (params.has_opened === true)
2413
- countQ = countQ.not('last_opened_at', 'is', null);
2414
- if (params.has_opened === false)
2415
- countQ = countQ.is('last_opened_at', null);
2416
- if (params.has_clicked === true)
2417
- countQ = countQ.not('last_clicked_at', 'is', null);
2418
- if (params.has_clicked === false)
2419
- countQ = countQ.is('last_clicked_at', null);
2420
- if (params.tag)
2421
- countQ = countQ.contains('tags', [params.tag]);
2422
- if (params.custom_property) {
2423
- ({ query: countQ } = await applySegmentRules(countQ, { custom_property: params.custom_property }, db(), projectId, fieldTypes, { pushDownOnly: true }));
2424
- }
2425
- // The total must count the same site the sample came from (#3674).
2426
- if (params.site_id)
2427
- ({ query: countQ } = await applySegmentRules(countQ, { site_id: params.site_id }, db(), projectId, {}, { allowEmpty: true }));
2426
+ const countFilterResult = await applyEstimateFilters(countBase);
2427
+ if (countFilterResult.error)
2428
+ return countFilterResult.error;
2429
+ if (countFilterResult.empty)
2430
+ return j({ total: 0, sample: [] });
2431
+ let countQ = countFilterResult.query;
2428
2432
  const { count, error: countError } = await countQ;
2429
2433
  if (countError)
2430
2434
  return dbErrorResult(countError);
@@ -2837,7 +2841,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2837
2841
  : null;
2838
2842
  const projectId = getProjectId();
2839
2843
  const orgId = await getProjectOrgId(projectId);
2840
- const { data, error } = await db()
2844
+ const { data, error } = await withCreatedItems('custom_field_definitions', db()
2841
2845
  .from('custom_field_definitions')
2842
2846
  .insert({
2843
2847
  org_id: orgId,
@@ -2850,7 +2854,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2850
2854
  display_order: display_order ?? 0,
2851
2855
  })
2852
2856
  .select('id, key, label, type, required, options, display_order')
2853
- .single();
2857
+ .single());
2854
2858
  if (error) {
2855
2859
  if (error.code === '23505')
2856
2860
  return contactInputError('conflict', `Error: A custom field with key "${key}" already exists.`);
@@ -1,3 +1,4 @@
1
+ import { withCreatedItems } from '../lib/mcp-created-items.js';
1
2
  import { withApproval } from '../lib/action-approvals.js';
2
3
  import { z } from 'zod';
3
4
  import { adminDb, db } from '../db.js';
@@ -241,10 +242,10 @@ export function registerContentTools(server) {
241
242
  if (category === 'footer' && !/href\s*=\s*["']\s*\{\{\s*unsubscribe_url\s*\}\}/i.test(html) && !validateUnsubscribeFooterTemplate(html).ok) {
242
243
  return toolError({ error: 'Footer blocks require {{unsubscribe_url}} in a link or <unsubscribe>link text</unsubscribe>', code: 'validation_failed' });
243
244
  }
244
- const { data, error } = await db().from('content_blocks')
245
+ const { data, error } = await withCreatedItems('content_blocks', db().from('content_blocks')
245
246
  .insert({ project_id: getProjectId(), name, category, html, description: description ?? null })
246
247
  .select('id, name, category, html, description, created_at')
247
- .single();
248
+ .single());
248
249
  if (error)
249
250
  return toolError(dbToolError(error));
250
251
  await logAudit({ action: 'content_block.created', resourceType: 'content_block', resourceId: data.id });
@@ -1,3 +1,4 @@
1
+ import { withCreatedItems } from '../lib/mcp-created-items.js';
1
2
  import { z } from 'zod';
2
3
  import { db } from '../db.js';
3
4
  import { getProjectId } from '../project.js';
@@ -179,14 +180,14 @@ export function registerDraftTools(server) {
179
180
  insert.trigger_event_name = trigger_event_name;
180
181
  if (trigger_tag)
181
182
  insert.trigger_tag = trigger_tag.trim().toLowerCase();
182
- const { data: campaign, error: campaignError } = await db()
183
+ const { data: campaign, error: campaignError } = await withCreatedItems('campaigns', db()
183
184
  .from('campaigns')
184
185
  .insert(insert)
185
186
  .select()
186
- .single();
187
+ .single());
187
188
  if (campaignError)
188
189
  return { content: [{ type: 'text', text: `Failed to create campaign: ${dbToolError(campaignError).error}` }] };
189
- const { data: step, error: stepError } = await db()
190
+ const { data: step, error: stepError } = await withCreatedItems('campaign_steps', db()
190
191
  .from('campaign_steps')
191
192
  .insert({
192
193
  campaign_id: campaign.id,
@@ -198,7 +199,7 @@ export function registerDraftTools(server) {
198
199
  delay_hours: 0,
199
200
  })
200
201
  .select()
201
- .single();
202
+ .single());
202
203
  if (stepError)
203
204
  return { content: [{ type: 'text', text: `Campaign created (${campaign.id}) but failed to create step: ${dbToolError(stepError).error}` }] };
204
205
  await recordBrandAssetUsage({
@@ -1,3 +1,4 @@
1
+ import { withCreatedItems } from '../lib/mcp-created-items.js';
1
2
  import { withApproval } from '../lib/action-approvals.js';
2
3
  import { z } from 'zod';
3
4
  import { db } from '../db.js';
@@ -147,11 +148,11 @@ export function registerLandingPageTools(server) {
147
148
  insert.accent_color = accent_color;
148
149
  if (custom_css)
149
150
  insert.custom_css = custom_css;
150
- const { data, error } = await db()
151
+ const { data, error } = await withCreatedItems('landing_pages', db()
151
152
  .from('landing_pages')
152
153
  .insert(insert)
153
154
  .select()
154
- .single();
155
+ .single());
155
156
  if (error) {
156
157
  if (error.code === '23505')
157
158
  return toolError({ error: 'A page with this slug already exists', code: 'conflict' });
@@ -1,3 +1,4 @@
1
+ import { withCreatedItems } from '../lib/mcp-created-items.js';
1
2
  import { literalIlikePattern } from '@sendinel/domain';
2
3
  import { z } from 'zod';
3
4
  import { applySegmentRules, buildSegmentBaseQuery, evaluateSegmentRules, hydrateEmailSendsContacts, isSegmentRulesError, normalizeSegmentRulesToTree, readAllRows, } from '@sendinel/domain';
@@ -127,7 +128,7 @@ export function registerListTools(server) {
127
128
  }, async ({ name, description }) => {
128
129
  const projectId = getProjectId();
129
130
  const normalizedName = name.trim();
130
- const { data, error } = await db()
131
+ const { data, error } = await withCreatedItems('contact_lists', db()
131
132
  .from('contact_lists')
132
133
  .insert({
133
134
  project_id: projectId,
@@ -135,7 +136,7 @@ export function registerListTools(server) {
135
136
  description: description?.trim() ?? null,
136
137
  })
137
138
  .select('id, name, description, created_at')
138
- .single();
139
+ .single());
139
140
  if (error)
140
141
  return txt(`Error: ${dbToolError(error).error}`);
141
142
  await logAudit({
@@ -1,3 +1,4 @@
1
+ import { withCreatedItems } from '../lib/mcp-created-items.js';
1
2
  import { withApproval } from '../lib/action-approvals.js';
2
3
  import { z } from 'zod';
3
4
  import { stripActiveEmailContent } from '@sendinel/domain';
@@ -88,7 +89,7 @@ async function ensureNewsletterCampaign(newsletter, issue, projectId, scheduledA
88
89
  campaignId = null;
89
90
  }
90
91
  if (!campaignId) {
91
- const { data: campaign, error } = await db().from('campaigns').insert({
92
+ const { data: campaign, error } = await withCreatedItems('campaigns', db().from('campaigns').insert({
92
93
  project_id: projectId,
93
94
  site_id: newsletter.site_id,
94
95
  name: campaignName,
@@ -97,7 +98,7 @@ async function ensureNewsletterCampaign(newsletter, issue, projectId, scheduledA
97
98
  requires_approval: false,
98
99
  send_at: scheduledAt,
99
100
  metadata,
100
- }).select('id').single();
101
+ }).select('id').single());
101
102
  if (error || !campaign)
102
103
  return { error: (error ? dbToolError(error).error : undefined) ?? 'Campaign create failed' };
103
104
  campaignId = campaign.id;
@@ -117,7 +118,7 @@ async function ensureNewsletterCampaign(newsletter, issue, projectId, scheduledA
117
118
  return { ...dbToolError(error) };
118
119
  }
119
120
  else {
120
- const { error } = await db().from('campaign_steps').insert({
121
+ const { error } = await withCreatedItems('campaign_steps', db().from('campaign_steps').insert({
121
122
  campaign_id: campaignId,
122
123
  step_order: 0,
123
124
  subject: issue.subject,
@@ -125,7 +126,7 @@ async function ensureNewsletterCampaign(newsletter, issue, projectId, scheduledA
125
126
  body_text: issue.preheader,
126
127
  delay_days: 0,
127
128
  delay_hours: 0,
128
- });
129
+ }).select('id'));
129
130
  if (error)
130
131
  return { ...dbToolError(error) };
131
132
  }
@@ -504,6 +505,12 @@ export function registerNewsletterTools(server) {
504
505
  return j(dbToolError(newsletterError));
505
506
  if (!newsletter)
506
507
  return j({ error: 'Newsletter not found', code: 'NOT_FOUND' });
508
+ // Separate insert from update so reactivating a seed/other client's subscriber grants no ownership.
509
+ const { error: creationError } = await withCreatedItems('subscribers', db().from('subscribers').upsert({
510
+ project_id: projectId, email: normalizedEmail, first_name: first_name ?? null, last_name: last_name ?? null, source,
511
+ }, { onConflict: 'project_id,email', ignoreDuplicates: true }).select('id'));
512
+ if (creationError)
513
+ return j(dbToolError(creationError));
507
514
  const { data: subscriber, error: subscriberError } = await db().from('subscribers').upsert({
508
515
  project_id: projectId,
509
516
  email: normalizedEmail,
@@ -1,3 +1,4 @@
1
+ import { withCreatedItems } from '../lib/mcp-created-items.js';
1
2
  import { withApproval } from '../lib/action-approvals.js';
2
3
  import { z } from 'zod';
3
4
  import Anthropic from '@anthropic-ai/sdk';
@@ -94,11 +95,11 @@ export function registerSegmentTools(server) {
94
95
  return invalid;
95
96
  throw rulesError;
96
97
  }
97
- const { data, error } = await db()
98
+ const { data, error } = await withCreatedItems('segments', db()
98
99
  .from('segments')
99
100
  .insert({ project_id: getProjectId(), name, description, rules: storedRules })
100
101
  .select('id, name, description, rules, created_at')
101
- .single();
102
+ .single());
102
103
  if (error?.code === '23505')
103
104
  return toolError({ error: 'A segment with this name already exists', code: 'conflict' });
104
105
  if (error)
@@ -247,14 +248,14 @@ export function registerSegmentTools(server) {
247
248
  if (error)
248
249
  return dbErrorResult(error);
249
250
  if (!seg)
250
- return txt('Error: segment not found');
251
+ return toolError({ error: 'Segment not found', code: 'not_found' });
251
252
  segRules = seg.rules;
252
253
  }
253
254
  else if (rules !== undefined) {
254
255
  segRules = rules;
255
256
  }
256
257
  else {
257
- return txt('Provide either segment_id or rules.');
258
+ return toolError({ error: 'Provide either segment_id or rules.', code: 'validation_failed' });
258
259
  }
259
260
  let ids;
260
261
  try {
@@ -335,11 +336,11 @@ export function registerSegmentTools(server) {
335
336
  message: `Parsed "${description}" into rules. ${ids.length} contacts match. Call again with confirmed: true to create.`,
336
337
  });
337
338
  }
338
- const { data, error } = await db()
339
+ const { data, error } = await withCreatedItems('segments', db()
339
340
  .from('segments')
340
341
  .insert({ project_id: getProjectId(), name, description, rules: parsedRules })
341
342
  .select('id, name, description, rules, created_at')
342
- .single();
343
+ .single());
343
344
  if (error)
344
345
  return txt(`Error: ${dbToolError(error).error}`);
345
346
  return j({ ...data, estimated_contacts: ids.length });
@@ -1,3 +1,4 @@
1
+ import { withCreatedItems } from '../lib/mcp-created-items.js';
1
2
  import { withApproval } from '../lib/action-approvals.js';
2
3
  import { z } from 'zod';
3
4
  import { db } from '../db.js';
@@ -58,7 +59,7 @@ export function registerSignatureTools(server) {
58
59
  if (clearError)
59
60
  return j(dbToolError(clearError));
60
61
  }
61
- const { data, error } = await db()
62
+ const { data, error } = await withCreatedItems('signature_blocks', db()
62
63
  .from('signature_blocks')
63
64
  .insert({
64
65
  project_id: projectId,
@@ -68,7 +69,7 @@ export function registerSignatureTools(server) {
68
69
  is_default: is_default ?? false,
69
70
  })
70
71
  .select(COLUMNS)
71
- .single();
72
+ .single());
72
73
  if (error)
73
74
  return j(dbToolError(error));
74
75
  await logAudit({
@@ -1,3 +1,4 @@
1
+ import { withCreatedItems } from '../lib/mcp-created-items.js';
1
2
  import { assertProjectAllowsSms } from "@sendinel/domain";
2
3
  import { randomUUID } from 'crypto';
3
4
  import { withApproval } from '../lib/action-approvals.js';
@@ -419,7 +420,7 @@ export function registerSmsTools(server) {
419
420
  },
420
421
  }, withApproval('send_sms_campaign', async ({ site_id, name, body, type, segment_id, scheduled_at, activate }) => {
421
422
  const projectId = getProjectId();
422
- const { data: campaign, error: campaignError } = await db()
423
+ const { data: campaign, error: campaignError } = await withCreatedItems('campaigns', db()
423
424
  .from('campaigns')
424
425
  .insert({
425
426
  project_id: projectId,
@@ -431,10 +432,10 @@ export function registerSmsTools(server) {
431
432
  send_at: scheduled_at ?? null,
432
433
  })
433
434
  .select('id, name, type, status, channel, site_id, send_at')
434
- .single();
435
+ .single());
435
436
  if (campaignError || !campaign)
436
437
  return j({ error: (campaignError ? dbToolError(campaignError).error : undefined) ?? 'Failed to create campaign' });
437
- const { data: step, error: stepError } = await db()
438
+ const { data: step, error: stepError } = await withCreatedItems('campaign_steps', db()
438
439
  .from('campaign_steps')
439
440
  .insert({
440
441
  project_id: projectId,
@@ -448,7 +449,7 @@ export function registerSmsTools(server) {
448
449
  status: 'active',
449
450
  })
450
451
  .select('id, step_order, channel')
451
- .single();
452
+ .single());
452
453
  if (stepError)
453
454
  return j({ ...dbToolError(stepError), campaign });
454
455
  return j({
@@ -1,3 +1,4 @@
1
+ import { withCreatedItems } from '../lib/mcp-created-items.js';
1
2
  import Anthropic from '@anthropic-ai/sdk';
2
3
  import { withApproval } from '../lib/action-approvals.js';
3
4
  import { z } from 'zod';
@@ -535,7 +536,7 @@ export function registerSocialPostTools(server) {
535
536
  socialCampaignId = socialCampaign.id;
536
537
  }
537
538
  const status = statusFromSchedule(scheduledAt, Boolean(queue_requested));
538
- const { data, error } = await db()
539
+ const { data, error } = await withCreatedItems('social_posts', db()
539
540
  .from('social_posts')
540
541
  .insert({
541
542
  org_id: orgId,
@@ -557,7 +558,7 @@ export function registerSocialPostTools(server) {
557
558
  status,
558
559
  })
559
560
  .select()
560
- .single();
561
+ .single());
561
562
  if (error)
562
563
  return txt(`Error: ${dbToolError(error).error}`);
563
564
  fireWebhookEvent('social.post.created', { social_post_id: data.id, title: data.title, status: data.status, platforms: data.platforms }, projectId).catch(() => { });
@@ -1,3 +1,4 @@
1
+ import { withCreatedItems } from '../lib/mcp-created-items.js';
1
2
  import { literalIlikePattern } from '@sendinel/domain';
2
3
  import { withApproval } from '../lib/action-approvals.js';
3
4
  import { snapshotTemplateVersion, versionHasBodySnapshot } from '@sendinel/domain';
@@ -164,7 +165,7 @@ export function registerTemplateTools(server) {
164
165
  return j(dbToolError(error));
165
166
  let savedTemplateId = null;
166
167
  if (save) {
167
- const { data: saved, error: saveError } = await db().from('campaign_templates').insert({
168
+ const { data: saved, error: saveError } = await withCreatedItems('campaign_templates', db().from('campaign_templates').insert({
168
169
  project_id: projectId,
169
170
  name: translated.name,
170
171
  category: 'migration',
@@ -179,7 +180,7 @@ export function registerTemplateTools(server) {
179
180
  tags: ['migration', provider],
180
181
  imported_from: provider,
181
182
  external_id: source_template_id ?? null,
182
- }).select('id').single();
183
+ }).select('id').single());
183
184
  if (saveError)
184
185
  return j(dbToolError(saveError));
185
186
  savedTemplateId = saved?.id ?? null;
@@ -242,7 +243,7 @@ export function registerTemplateTools(server) {
242
243
  },
243
244
  }, async ({ name, brief, subject_hint, body_html, category, description, tags, is_transactional, brand_asset_ids }) => {
244
245
  const projectId = getProjectId();
245
- const { data, error } = await db().from('campaign_templates')
246
+ const { data, error } = await withCreatedItems('campaign_templates', db().from('campaign_templates')
246
247
  .insert({
247
248
  project_id: projectId,
248
249
  name,
@@ -256,7 +257,7 @@ export function registerTemplateTools(server) {
256
257
  is_transactional: is_transactional ?? false,
257
258
  })
258
259
  .select('id, name, category, description, subject_hint, brief_template, body_html, body_source, body_text, format, variables, tags, created_at')
259
- .single();
260
+ .single());
260
261
  if (error)
261
262
  return j(dbToolError(error));
262
263
  await recordBrandAssetUsage({
@@ -1,3 +1,4 @@
1
+ import { withCreatedItems } from '../lib/mcp-created-items.js';
1
2
  import { withApproval } from '../lib/action-approvals.js';
2
3
  import { z } from 'zod';
3
4
  import { listSandboxWebhookEvents } from '@sendinel/domain';
@@ -61,7 +62,7 @@ export function registerWebhookTools(server) {
61
62
  if (!validation.valid)
62
63
  return txt(`Invalid URL: ${validation.reason}`);
63
64
  const secret = generateWebhookSecret();
64
- const { data, error } = await db()
65
+ const { data, error } = await withCreatedItems('webhook_subscriptions', db()
65
66
  .from('webhook_subscriptions')
66
67
  .insert({
67
68
  project_id: getProjectId(),
@@ -71,7 +72,7 @@ export function registerWebhookTools(server) {
71
72
  description: description ?? null,
72
73
  })
73
74
  .select('id, url, events, active, created_at')
74
- .single();
75
+ .single());
75
76
  if (error)
76
77
  return txt(`Error creating subscription: ${dbToolError(error).error}`);
77
78
  return j({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.173",
3
+ "version": "1.0.175",
4
4
  "description": "AI-controlled email operations control plane for campaigns, contacts, segments, analytics, and multi-provider sending via MCP.",
5
5
  "mcpName": "ai.sendinel/mcp-server",
6
6
  "type": "module",