@sendinel/mcp-server 1.0.190 → 1.0.192

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
@@ -59,7 +59,9 @@ Sendinel currently exposes 35 tool groups and 349 tools (gateway mode exposes 4
59
59
  | `webhooks` | 5 | Webhook subscription management |
60
60
 
61
61
  `cancel_approval({approval_id})` rejects an unexpired, unused pending/approved
62
- request in the current project for any write-scoped credential.
62
+ request in the current project for any write-scoped credential. It returns
63
+ `status: "rejected"` (kept for compatibility) plus `outcome: "cancelled"` and
64
+ `cancelled: true`.
63
65
  `approve_approval({approval_id})` requires the person's own live OAuth connection
64
66
  (`connection_role: NULL`) and its own pending request in that project. Tester,
65
67
  agent, API key, service-token and scoped-JWT credentials cannot approve. Sends
@@ -100,6 +100,8 @@ export declare function checkApproval(approvalId: string, options?: {
100
100
  }): Promise<Omit<Awaited<ReturnType<typeof checkApprovalState>>, 'decision_channel'> & {
101
101
  decision_channel?: string;
102
102
  decision_evidence?: DecisionEvidence;
103
+ outcome?: 'cancelled';
104
+ cancelled?: true;
103
105
  }>;
104
106
  declare function checkApprovalState(approvalId: string, options?: {
105
107
  consume?: boolean;
@@ -469,7 +469,7 @@ export async function checkApproval(approvalId, options = {}) {
469
469
  .eq('action', 'approval.cancelled').limit(1);
470
470
  const cancel = data?.[0];
471
471
  if (!error && cancel?.actor_id && typeof cancel.details?.channel === 'string') {
472
- return { ...result, decided_by: cancel.actor_id, decision_channel: cancel.details.channel };
472
+ return { ...result, decided_by: cancel.actor_id, decision_channel: cancel.details.channel, outcome: 'cancelled', cancelled: true };
473
473
  }
474
474
  }
475
475
  catch { /* A failed audit read cannot grant authority or change the status. */ }
@@ -604,10 +604,17 @@ async function checkApprovalState(approvalId, options = {}) {
604
604
  approved: true,
605
605
  status: 'approved',
606
606
  decided_by: claimed.decided_by,
607
- ...(options.action && ['send_test_email', 'identify_subscriber', 'emit_contact_event', 'delete_campaign'].includes(options.action) ? { consumed_at: consumedAt } : {}),
607
+ ...(options.action && RELEASABLE_APPROVAL_ACTIONS.has(options.action) ? { consumed_at: consumedAt } : {}),
608
608
  ...(sandboxDecision ? { decision_channel: 'sandbox' } : {}),
609
609
  };
610
610
  }
611
+ /** Actions whose approval claim can be released (markNotExecuted) after a refusal that changed nothing. */
612
+ const RELEASABLE_APPROVAL_ACTIONS = new Set([
613
+ 'send_test_email', 'identify_subscriber', 'emit_contact_event', 'delete_campaign',
614
+ // Deletes refused by a foreign key (#4503): nothing was removed, so the same approval can be retried.
615
+ 'delete_campaign_step', 'delete_content_block', 'delete_landing_page', 'delete_signature', 'delete_list',
616
+ 'delete_segment', 'delete_subscriber_view', 'delete_custom_field', 'delete_webhook_subscription',
617
+ ]);
611
618
  const approvalPreflights = new Map();
612
619
  /** Validate live targets before any approval is requested or consumed, including registry gates. */
613
620
  export function registerApprovalPreflight(action, fn) {
@@ -4,6 +4,8 @@ export declare function cancelApproval(approvalId: string, transportClientName?:
4
4
  } | {
5
5
  approval_id: string;
6
6
  status: 'rejected';
7
+ outcome: 'cancelled';
8
+ cancelled: true;
7
9
  }>;
8
10
  export declare function approveApproval(approvalId: string, transportClientName?: string): Promise<{
9
11
  code: string;
@@ -53,12 +53,12 @@ export async function cancelApproval(approvalId, transportClientName) {
53
53
  project_id: projectId, actor_type: actor.actorType ?? actor.authKind ?? 'api_key',
54
54
  actor_id: actor.actorId ?? actor.keyId ?? actor.oauthTokenId ?? null,
55
55
  action: 'approval.cancelled', resource_type: 'approval', resource_id: approvalId,
56
- details: { status: 'rejected', channel, client_name: name, auth_kind: actor.authKind ?? null,
56
+ details: { status: 'rejected', outcome: 'cancelled', channel, client_name: name, auth_kind: actor.authKind ?? null,
57
57
  oauth_token_id: actor.oauthTokenId ?? null, api_key_id: actor.keyId ?? null },
58
58
  });
59
59
  if (auditError)
60
60
  return refuse('approval_audit_failed', 'Approval cancelled, but audit failed. Check its status before retrying.');
61
- return { approval_id: approvalId, status: 'rejected' };
61
+ return { approval_id: approvalId, status: 'rejected', outcome: 'cancelled', cancelled: true };
62
62
  }
63
63
  catch {
64
64
  return refuse('approval_failed', 'Cancellation failed. Check approval status before retrying.');
@@ -28,6 +28,32 @@ export declare function toolError(body: unknown): {
28
28
  text: string;
29
29
  }[];
30
30
  };
31
+ /** A table known to block a delete. `count` is best-effort; it may throw or return null. */
32
+ export interface DeleteBlocker {
33
+ table: string;
34
+ singular: string;
35
+ plural: string;
36
+ count: () => Promise<number | null | undefined>;
37
+ }
38
+ /**
39
+ * A DELETE refused by a foreign key (23503) is a conflict ("something still uses this"), not a
40
+ * missing record. Returns null for any other error so callers fall through to their normal path.
41
+ * The global 23503 -> not_found mapping stays correct for INSERTs and is deliberately untouched.
42
+ * Never copies the database message or constraint names; counts are best-effort and a failed
43
+ * count never changes the conflict outcome.
44
+ */
45
+ export declare function deleteBlockedResult(error: unknown, subject: string, blockers?: DeleteBlocker[]): Promise<{
46
+ error: string;
47
+ code: 'conflict';
48
+ } | null>;
49
+ /** MCP-result form of deleteBlockedResult: an isError toolError, or null when not a 23503. */
50
+ export declare function deleteBlockedToolResult(error: unknown, subject: string, blockers?: DeleteBlocker[]): Promise<{
51
+ isError: true;
52
+ content: {
53
+ type: 'text';
54
+ text: string;
55
+ }[];
56
+ } | null>;
31
57
  /** Formats Zod validation issues into plain-English sentences without raw JSON. */
32
58
  export declare function formatZodError(error: z.ZodError | {
33
59
  issues: Array<{
@@ -47,6 +47,39 @@ export function dbToolError(error, message = 'Unable to complete database operat
47
47
  export function toolError(body) {
48
48
  return { isError: true, content: [{ type: 'text', text: JSON.stringify(body, null, 2) }] };
49
49
  }
50
+ /**
51
+ * A DELETE refused by a foreign key (23503) is a conflict ("something still uses this"), not a
52
+ * missing record. Returns null for any other error so callers fall through to their normal path.
53
+ * The global 23503 -> not_found mapping stays correct for INSERTs and is deliberately untouched.
54
+ * Never copies the database message or constraint names; counts are best-effort and a failed
55
+ * count never changes the conflict outcome.
56
+ */
57
+ export async function deleteBlockedResult(error, subject, blockers = []) {
58
+ const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : '';
59
+ if (code !== '23503')
60
+ return null;
61
+ console.warn('[mcp] Delete blocked by linked records:', error);
62
+ const results = await Promise.allSettled(blockers.map(async (b) => b.count()));
63
+ const linked = [];
64
+ const unknown = [];
65
+ results.forEach((result, index) => {
66
+ const blocker = blockers[index];
67
+ const count = result.status === 'fulfilled' ? result.value : null;
68
+ if (typeof count !== 'number' || !Number.isInteger(count) || count < 0)
69
+ unknown.push(blocker.table);
70
+ else if (count > 0)
71
+ linked.push(`${count} ${count === 1 ? blocker.singular : blocker.plural} (${blocker.table})`);
72
+ });
73
+ const reason = linked.length
74
+ ? `This ${subject} still has ${linked.join(', ')} linked to it, so it cannot be deleted.`
75
+ : `This ${subject} cannot be deleted because linked records still reference it.`;
76
+ return { error: unknown.length ? `${reason} Counts for ${unknown.join(', ')} are unavailable.` : reason, code: 'conflict' };
77
+ }
78
+ /** MCP-result form of deleteBlockedResult: an isError toolError, or null when not a 23503. */
79
+ export async function deleteBlockedToolResult(error, subject, blockers = []) {
80
+ const body = await deleteBlockedResult(error, subject, blockers);
81
+ return body ? toolError(body) : null;
82
+ }
50
83
  /** Formats Zod validation issues into plain-English sentences without raw JSON. */
51
84
  export function formatZodError(error) {
52
85
  const messages = [];
@@ -0,0 +1,7 @@
1
+ import type { DeleteBlocker } from './db-error.js';
2
+ /** Best-effort count of rows in email.<table> where <column> = id. Returns null when unknown. */
3
+ export declare function countBlocker(table: string, column: string, id: string, singular: string, plural: string): DeleteBlocker;
4
+ export declare const campaignStepBlockers: (stepId: string) => DeleteBlocker[];
5
+ export declare const segmentBlockers: (segmentId: string) => DeleteBlocker[];
6
+ export declare const subscriberBlockers: (contactId: string) => DeleteBlocker[];
7
+ export declare const campaignBlockers: (campaignId: string) => DeleteBlocker[];
@@ -0,0 +1,31 @@
1
+ import { db } from '../db.js';
2
+ /** Best-effort count of rows in email.<table> where <column> = id. Returns null when unknown. */
3
+ export function countBlocker(table, column, id, singular, plural) {
4
+ return {
5
+ table,
6
+ singular,
7
+ plural,
8
+ count: async () => {
9
+ const { count, error } = await db().schema('email').from(table)
10
+ .select('id', { count: 'exact', head: true }).eq(column, id);
11
+ return error ? null : count;
12
+ },
13
+ };
14
+ }
15
+ // NO ACTION foreign keys found by the 2026-11-09 prod scan. History tables stay blockers.
16
+ export const campaignStepBlockers = (stepId) => [
17
+ countBlocker('email_log', 'step_id', stepId, 'email history record', 'email history records'),
18
+ ];
19
+ export const segmentBlockers = (segmentId) => [
20
+ countBlocker('campaigns', 'segment_id', segmentId, 'campaign', 'campaigns'),
21
+ ];
22
+ export const subscriberBlockers = (contactId) => [
23
+ countBlocker('email_log', 'contact_id', contactId, 'email history record', 'email history records'),
24
+ countBlocker('revenue_attribution', 'contact_id', contactId, 'revenue attribution record', 'revenue attribution records'),
25
+ ];
26
+ export const campaignBlockers = (campaignId) => [
27
+ countBlocker('ai_drafts', 'campaign_id', campaignId, 'AI draft', 'AI drafts'),
28
+ countBlocker('email_log', 'campaign_id', campaignId, 'email history record', 'email history records'),
29
+ countBlocker('revenue_attribution', 'campaign_id', campaignId, 'revenue attribution record', 'revenue attribution records'),
30
+ countBlocker('campaign_goals', 'target_campaign_id', campaignId, 'campaign goal target', 'campaign goal targets'),
31
+ ];
@@ -23,7 +23,7 @@ export function registerApprovalTools(server, options) {
23
23
  });
24
24
  server.registerTool('cancel_approval', {
25
25
  title: 'Cancel Approval',
26
- description: 'Cancel an unexpired, unused pending or approved action approval in this project. Read-scoped OAuth connections can cancel only their own token’s requests; write/admin credentials can cancel any eligible request in this project. Sets it to rejected and records who cancelled and the request channel in the audit; check_approval returns that cancellation attribution. Cannot approve or undo cancellation.',
26
+ description: 'Cancel an unexpired, unused pending or approved action approval in this project. Read-scoped OAuth connections can cancel only their own token’s requests; write/admin credentials can cancel any eligible request in this project. Marks it cancelled (stored and reported as status `rejected` for backward compatibility; `outcome: "cancelled"` and `cancelled: true` distinguish a withdrawal from a person’s rejection) and records who cancelled and the request channel in the audit; check_approval returns that cancellation attribution. Cannot approve or undo cancellation.',
27
27
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
28
28
  inputSchema: { approval_id: z.string().uuid() },
29
29
  }, async ({ approval_id }) => {
@@ -59,7 +59,7 @@ export function registerApprovalTools(server, options) {
59
59
  idempotentHint: true,
60
60
  openWorldHint: false,
61
61
  },
62
- description: 'Check the status of a pending action approval. Use this to poll after requesting an action that requires human approval. Returns approved/pending/rejected/expired.',
62
+ description: 'Check the status of a pending action approval. Use this to poll after requesting an action that requires human approval. Returns approved/pending/rejected/expired; a cancelled approval keeps status `rejected` and adds `outcome: "cancelled"` and `cancelled: true`.',
63
63
  inputSchema: {
64
64
  approval_id: z.string().uuid().describe('The approval ID returned by the original tool call'),
65
65
  },
@@ -17,7 +17,8 @@ import { finishCampaignEdit, resumeCampaignEdit, TRIGGER_EDIT_SELECT } from '../
17
17
  import { validTimezone } from '@sendinel/domain';
18
18
  import { adminDb, db, requestContext } from '../db.js';
19
19
  import { getProjectId } from '../project.js';
20
- import { dbErrorResult, dbToolError, toolError } from '../lib/db-error.js';
20
+ import { dbErrorResult, dbToolError, toolError, deleteBlockedToolResult } from '../lib/db-error.js';
21
+ import { campaignBlockers, campaignStepBlockers } from '../lib/delete-blockers.js';
21
22
  import { checkPlanLimit } from '../plan-limits.js';
22
23
  import { buildVoicePrompt, buildEmailSystemPrompt, deriveTemplateVariables, validateTemplate } from '../lib/voice.js';
23
24
  import { fireWebhookEvent } from '../lib/webhook-events.js';
@@ -38,36 +39,6 @@ import { callSendinelV1 } from '../lib/v1-client.js';
38
39
  import { countUniqueEngagementByCampaign } from '../lib/campaign-engagement.js';
39
40
  const txt = (text) => ({ content: [{ type: 'text', text }] });
40
41
  const j = (data) => txt(JSON.stringify(data, null, 2));
41
- /** Diagnose a rejected DELETE, without exposing Postgres diagnostics or guessing missing counts. */
42
- async function campaignDeleteConflict(campaignId) {
43
- const sources = [
44
- { table: 'ai_drafts', column: 'campaign_id', singular: 'AI draft', plural: 'AI drafts' },
45
- { table: 'email_log', column: 'campaign_id', singular: 'email history record', plural: 'email history records' },
46
- { table: 'revenue_attribution', column: 'campaign_id', singular: 'revenue attribution record', plural: 'revenue attribution records' },
47
- { table: 'campaign_goals', column: 'target_campaign_id', singular: 'campaign goal target', plural: 'campaign goal targets' },
48
- ];
49
- // The caller has verified ownership of this campaign. Count all references to its UUID;
50
- // campaign_goals has no project_id, and filtering by the owning campaign misses targets.
51
- const results = await Promise.allSettled(sources.map(async (source) => {
52
- const { count, error } = await db().schema('email').from(source.table)
53
- .select('id', { count: 'exact', head: true }).eq(source.column, campaignId);
54
- return !error && typeof count === 'number' && Number.isInteger(count) && count >= 0 ? count : null;
55
- }));
56
- const linked = [];
57
- const unknown = [];
58
- results.forEach((result, index) => {
59
- const source = sources[index];
60
- const count = result.status === 'fulfilled' ? result.value : null;
61
- if (count === null)
62
- unknown.push(source.table);
63
- else if (count > 0)
64
- linked.push(`${count} ${count === 1 ? source.singular : source.plural} (${source.table})`);
65
- });
66
- const reason = linked.length
67
- ? `This campaign still has ${linked.join(', ')} linked to it, so it cannot be deleted.`
68
- : 'This campaign cannot be deleted because linked records still reference it.';
69
- return unknown.length ? `${reason} Counts for ${unknown.join(', ')} are unavailable.` : reason;
70
- }
71
42
  const ACTIVATION_APPROVAL_DESCRIPTION = 'Activation-only: requires_approval gates campaign activation. It does not stop sends of an already-active campaign or sends triggered by other paths.';
72
43
  const REENTRY_DESCRIPTION = 'Re-entry defaults to once ever. per_event_period uses a flat event-data period_key on event-triggered campaigns only. Changing a live campaign returns an approval request. Existing enrollments without a period cannot re-enter; sends wait for campaign-cycle approval.';
73
44
  const reentryFields = {
@@ -3611,6 +3582,9 @@ Return ONLY valid JSON with this exact shape:
3611
3582
  .from('campaign_steps')
3612
3583
  .delete()
3613
3584
  .eq('id', step_id);
3585
+ const blocked = await deleteBlockedToolResult(error, 'step', campaignStepBlockers(step_id));
3586
+ if (blocked)
3587
+ return markNotExecuted(blocked);
3614
3588
  if (error)
3615
3589
  return j(dbToolError(error));
3616
3590
  return j({ ok: true, deleted: step_id });
@@ -3646,10 +3620,10 @@ Return ONLY valid JSON with this exact shape:
3646
3620
  .delete()
3647
3621
  .eq('id', campaign_id)
3648
3622
  .eq('project_id', projectId);
3649
- if (error?.code === '23503') {
3650
- // A rejected statement made no changes, so the same approval is safe to retry.
3651
- return markNotExecuted(toolError({ code: 'conflict', error: await campaignDeleteConflict(campaign_id) }));
3652
- }
3623
+ // A rejected statement made no changes, so the same approval is safe to retry.
3624
+ const blocked = await deleteBlockedToolResult(error, 'campaign', campaignBlockers(campaign_id));
3625
+ if (blocked)
3626
+ return markNotExecuted(blocked);
3653
3627
  if (error)
3654
3628
  return j(dbToolError(error));
3655
3629
  return j({ success: true, deleted: campaign.name });
@@ -8,7 +8,7 @@ import { z } from 'zod';
8
8
  import { callSendinelV1 } from '../lib/v1-client.js';
9
9
  import { db, projectClientForWriteback } from '../db.js';
10
10
  import { getProjectId } from '../project.js';
11
- import { dbErrorResult, dbToolError, toolError } from '../lib/db-error.js';
11
+ import { dbErrorResult, dbToolError, toolError, deleteBlockedToolResult } from '../lib/db-error.js';
12
12
  import { resolveSegmentContactIds } from '../lib/send-audience.js';
13
13
  import { checkPlanLimit, contactLimitMessage } from '../plan-limits.js';
14
14
  import { logConsent, logAudit } from '../audit.js';
@@ -1230,6 +1230,9 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1230
1230
  .eq('project_id', getProjectId())
1231
1231
  .select('id')
1232
1232
  .maybeSingle();
1233
+ const blocked = await deleteBlockedToolResult(error, 'subscriber view');
1234
+ if (blocked)
1235
+ return markNotExecuted(blocked);
1233
1236
  if (error)
1234
1237
  return txt(`Error: ${dbToolError(error).error}`);
1235
1238
  if (!data)
@@ -2940,6 +2943,9 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2940
2943
  .eq('project_id', projectId)
2941
2944
  .select('id, key')
2942
2945
  .maybeSingle();
2946
+ const blocked = await deleteBlockedToolResult(error, 'custom field');
2947
+ if (blocked)
2948
+ return markNotExecuted(blocked);
2943
2949
  if (error)
2944
2950
  return txt(`Error: ${dbToolError(error).error}`);
2945
2951
  if (!data)
@@ -1,10 +1,10 @@
1
1
  import { withCreatedItems } from '../lib/mcp-created-items.js';
2
- import { withApproval } from '../lib/action-approvals.js';
2
+ import { markNotExecuted, withApproval } from '../lib/action-approvals.js';
3
3
  import { z } from 'zod';
4
4
  import { adminDb, db } from '../db.js';
5
5
  import { getProjectId } from '../project.js';
6
6
  import { refuseActiveTextFields, stripActiveEmailContent, validateUnsubscribeFooterTemplate } from '@sendinel/domain';
7
- import { dbToolError, toolError } from '../lib/db-error.js';
7
+ import { dbToolError, toolError, deleteBlockedToolResult } from '../lib/db-error.js';
8
8
  import { logAudit } from '../audit.js';
9
9
  import { assertHttpUrl } from '../lib/url-validation.js';
10
10
  const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
@@ -263,6 +263,9 @@ export function registerContentTools(server) {
263
263
  inputSchema: { approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with only approval_id to run the action.'), block_id: z.string().uuid() },
264
264
  }, withApproval('delete_content_block', async ({ block_id }) => {
265
265
  const { error } = await db().from('content_blocks').delete().eq('project_id', getProjectId()).eq('id', block_id);
266
+ const blocked = await deleteBlockedToolResult(error, 'content block');
267
+ if (blocked)
268
+ return markNotExecuted(blocked);
266
269
  if (error)
267
270
  return j(dbToolError(error));
268
271
  return j({ deleted: true });
@@ -7,7 +7,7 @@ import { normalizeActiveCampaignAccountUrl } from '../lib/url-validation.js';
7
7
  import { assertChannelCanExecute, checkApproval, currentChannelContext, requestApproval, withApproval } from '../lib/action-approvals.js';
8
8
  import { getProjectId } from '../project.js';
9
9
  import { logAudit } from '../audit.js';
10
- import { dbToolError } from '../lib/db-error.js';
10
+ import { dbToolError, deleteBlockedToolResult } from '../lib/db-error.js';
11
11
  const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
12
12
  function generateApiKey() {
13
13
  const secret = `snk_${randomBytes(32).toString('hex')}`;
@@ -449,6 +449,9 @@ export function registerDataTools(server) {
449
449
  .eq('project_id', context.projectId)
450
450
  .select('id, platform')
451
451
  .maybeSingle();
452
+ const blocked = await deleteBlockedToolResult(error, 'provider connection');
453
+ if (blocked)
454
+ return blocked;
452
455
  if (error)
453
456
  return j(dbToolError(error));
454
457
  if (!data)
@@ -4,7 +4,8 @@ import { db } from '../db.js';
4
4
  import { getProjectId } from '../project.js';
5
5
  import { logAudit } from '../audit.js';
6
6
  import { approvalSatisfied, assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval } from '../lib/action-approvals.js';
7
- import { dbToolError } from '../lib/db-error.js';
7
+ import { dbToolError, deleteBlockedToolResult } from '../lib/db-error.js';
8
+ import { subscriberBlockers } from '../lib/delete-blockers.js';
8
9
  const txt = (text) => ({ content: [{ type: 'text', text }] });
9
10
  const j = (data) => txt(JSON.stringify(data, null, 2));
10
11
  export function registerGdprTools(server) {
@@ -52,6 +53,9 @@ export function registerGdprTools(server) {
52
53
  p_reason: reason ?? 'gdpr_request',
53
54
  p_deleted_by: 'mcp',
54
55
  });
56
+ const blocked = await deleteBlockedToolResult(error, 'subscriber', subscriberBlockers(contact_id));
57
+ if (blocked)
58
+ return blocked;
55
59
  if (error)
56
60
  return txt(`Error: ${dbToolError(error).error}`);
57
61
  logAudit({ action: 'contact.gdpr_deleted', resourceType: 'contact', resourceId: contact_id, details: { reason, result: data } });
@@ -1,11 +1,11 @@
1
1
  import { withCreatedItems } from '../lib/mcp-created-items.js';
2
- import { withApproval } from '../lib/action-approvals.js';
2
+ import { markNotExecuted, withApproval } from '../lib/action-approvals.js';
3
3
  import { z } from 'zod';
4
4
  import { db } from '../db.js';
5
5
  import { getProjectId } from '../project.js';
6
6
  import { logAudit } from '../audit.js';
7
7
  import { stripActiveEmailContent } from '@sendinel/domain';
8
- import { dbToolError, toolError } from '../lib/db-error.js';
8
+ import { dbToolError, toolError, deleteBlockedToolResult } from '../lib/db-error.js';
9
9
  const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
10
10
  const SLUG_RE = /^[a-z0-9][a-z0-9\-]{0,62}$/;
11
11
  const VALID_STATUSES = ['draft', 'published', 'archived'];
@@ -274,6 +274,9 @@ export function registerLandingPageTools(server) {
274
274
  .delete()
275
275
  .eq('id', page_id)
276
276
  .eq('project_id', projectId);
277
+ const blocked = await deleteBlockedToolResult(error, 'landing page');
278
+ if (blocked)
279
+ return markNotExecuted(blocked);
277
280
  if (error)
278
281
  return j(dbToolError(error));
279
282
  await logAudit({ action: 'landing_page.deleted', resourceType: 'landing_page', resourceId: page_id });
@@ -5,8 +5,8 @@ import { applySegmentRules, buildSegmentBaseQuery, evaluateSegmentRules, hydrate
5
5
  import { db } from '../db.js';
6
6
  import { getProjectId } from '../project.js';
7
7
  import { logAudit } from '../audit.js';
8
- import { assertChannelCanExecute, checkApproval, currentChannelContext, requestApproval, withApproval, } from '../lib/action-approvals.js';
9
- import { dbToolError } from '../lib/db-error.js';
8
+ import { assertChannelCanExecute, checkApproval, currentChannelContext, requestApproval, markNotExecuted, withApproval, } from '../lib/action-approvals.js';
9
+ import { dbToolError, deleteBlockedToolResult } from '../lib/db-error.js';
10
10
  const LIST_MEMBER_BATCH_SIZE = 500;
11
11
  const txt = (text) => ({ content: [{ type: 'text', text }] });
12
12
  const j = (data) => txt(JSON.stringify(data, null, 2));
@@ -196,6 +196,9 @@ export function registerListTools(server) {
196
196
  .delete()
197
197
  .eq('id', list_id)
198
198
  .eq('project_id', projectId);
199
+ const blocked = await deleteBlockedToolResult(error, 'list');
200
+ if (blocked)
201
+ return markNotExecuted(blocked);
199
202
  if (error)
200
203
  return txt(`Error: ${dbToolError(error).error}`);
201
204
  await logAudit({ action: 'list.deleted', resourceType: 'list', resourceId: list_id });
@@ -1,10 +1,11 @@
1
1
  import { withCreatedItems } from '../lib/mcp-created-items.js';
2
- import { withApproval } from '../lib/action-approvals.js';
2
+ import { markNotExecuted, withApproval } from '../lib/action-approvals.js';
3
3
  import { z } from 'zod';
4
4
  import Anthropic from '@anthropic-ai/sdk';
5
5
  import { db } from '../db.js';
6
6
  import { getProjectId } from '../project.js';
7
- import { dbErrorResult, dbToolError, toolError } from '../lib/db-error.js';
7
+ import { dbErrorResult, dbToolError, toolError, deleteBlockedToolResult } from '../lib/db-error.js';
8
+ import { segmentBlockers } from '../lib/delete-blockers.js';
8
9
  import { getAnthropicModelId } from '../anthropic-model.js';
9
10
  import { AI_MAX_TOKENS, outputConfig, anthropicCheckedText } from '@sendinel/domain';
10
11
  import { SEGMENT_OPERATORS_DESCRIPTION, applySegmentRules, buildSegmentBaseQuery, evaluateSegmentRules, hydrateEmailSendsContacts, isSegmentRulesError, normalizeSegmentRulesToTree, prepareSegmentRulesForStorage, readAllRows, } from '@sendinel/domain';
@@ -219,6 +220,9 @@ export function registerSegmentTools(server) {
219
220
  .delete()
220
221
  .eq('id', segment_id)
221
222
  .eq('project_id', getProjectId());
223
+ const blocked = await deleteBlockedToolResult(error, 'segment', segmentBlockers(segment_id));
224
+ if (blocked)
225
+ return markNotExecuted(blocked);
222
226
  if (error)
223
227
  return txt(`Error: ${dbToolError(error).error}`);
224
228
  return j({ deleted: true, segment_id });
@@ -1,10 +1,10 @@
1
1
  import { withCreatedItems } from '../lib/mcp-created-items.js';
2
- import { withApproval } from '../lib/action-approvals.js';
2
+ import { markNotExecuted, withApproval } from '../lib/action-approvals.js';
3
3
  import { z } from 'zod';
4
4
  import { db } from '../db.js';
5
5
  import { getProjectId } from '../project.js';
6
6
  import { logAudit } from '../audit.js';
7
- import { dbToolError } from '../lib/db-error.js';
7
+ import { dbToolError, deleteBlockedToolResult } from '../lib/db-error.js';
8
8
  const txt = (text) => ({ content: [{ type: 'text', text }] });
9
9
  const j = (data) => txt(JSON.stringify(data, null, 2));
10
10
  const COLUMNS = 'id, site_id, name, html, is_default, created_at, updated_at';
@@ -158,6 +158,9 @@ export function registerSignatureTools(server) {
158
158
  .delete()
159
159
  .eq('id', id)
160
160
  .eq('project_id', projectId);
161
+ const blocked = await deleteBlockedToolResult(error, 'signature');
162
+ if (blocked)
163
+ return markNotExecuted(blocked);
161
164
  if (error)
162
165
  return j(dbToolError(error));
163
166
  await logAudit({
@@ -1,11 +1,11 @@
1
1
  import { withCreatedItems } from '../lib/mcp-created-items.js';
2
- import { withApproval } from '../lib/action-approvals.js';
2
+ import { markNotExecuted, withApproval } from '../lib/action-approvals.js';
3
3
  import { z } from 'zod';
4
4
  import { listSandboxWebhookEvents } from '@sendinel/domain';
5
5
  import { db, adminDb } from '../db.js';
6
6
  import { getProjectId } from '../project.js';
7
7
  import { validateWebhookUrl, generateWebhookSecret } from '../lib/webhook-events.js';
8
- import { dbToolError } from '../lib/db-error.js';
8
+ import { dbToolError, deleteBlockedToolResult } from '../lib/db-error.js';
9
9
  const txt = (text) => ({ content: [{ type: 'text', text }] });
10
10
  const j = (data) => txt(JSON.stringify(data, null, 2));
11
11
  const VALID_EVENTS = [
@@ -169,6 +169,9 @@ export function registerWebhookTools(server) {
169
169
  .delete()
170
170
  .eq('id', subscription_id)
171
171
  .eq('project_id', getProjectId());
172
+ const blocked = await deleteBlockedToolResult(error, 'webhook subscription');
173
+ if (blocked)
174
+ return markNotExecuted(blocked);
172
175
  if (error)
173
176
  return txt(`Error: ${dbToolError(error).error}`);
174
177
  return txt(`Subscription ${subscription_id} deleted.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.190",
3
+ "version": "1.0.192",
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",