@sendinel/mcp-server 1.0.221 → 1.0.223

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.
@@ -1,6 +1,7 @@
1
1
  import type { DecisionEvidence } from './approval-elicitation-evidence.js';
2
2
  import { type AutoApproveUnavailableReason } from './sandbox-approvals.js';
3
3
  export { isApprovalGatedAction } from './approval-actions.js';
4
+ export { assertResourcesBelongToActiveProject, ResourceProjectRefusal } from './resource-project.js';
4
5
  /**
5
6
  * Action approval tiers:
6
7
  *
@@ -17,7 +17,9 @@ import { captureApprovalSnapshot, withoutApprovalSnapshot, withoutApprovalDispla
17
17
  import { fireWebhookEvent } from './webhook-events.js';
18
18
  import { offerApprovalApp } from './approval-app.js';
19
19
  import { elicitApproval } from './approval-elicitation.js';
20
- import { toolError, formatZodError } from './db-error.js';
20
+ import { toolError, formatZodError, plainRefusalResult } from './db-error.js';
21
+ import { assertResourcesBelongToActiveProject, ResourceProjectRefusal } from './resource-project.js';
22
+ export { assertResourcesBelongToActiveProject, ResourceProjectRefusal } from './resource-project.js';
21
23
  export function getApprovalTier(action, params) {
22
24
  // #4308 / #4214: a request-only connection cannot bypass approval via autonomous/preview modes.
23
25
  const scopes = requestContext()?.scopes;
@@ -356,6 +358,8 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
356
358
  const actor = requestContext();
357
359
  // Ignore any model-supplied reserved field; freeze facts read by the server.
358
360
  const cleanParams = withoutApprovalSnapshot(params);
361
+ // #4447: refuse a resource outside the active project before anything is stored, sent or auto-approved.
362
+ await assertResourcesBelongToActiveProject(action, cleanParams);
359
363
  const snapshot = { ...await captureSnapshot(action, cleanParams), ...(effective.applied ? { approval_class: effective.class, approval_channel_rule: effective.channelRule } : {}),
360
364
  ...(display && 'recipient_count' in display ? { display_recipient_count: display.recipient_count } : {}) };
361
365
  if (typeof display?.merge_variable_summary === 'string') {
@@ -704,7 +708,15 @@ export function withApproval(action, handler, options = {}) {
704
708
  if (decision.tier !== 'autonomous') {
705
709
  if (!approval_id) {
706
710
  const display = options.display ? await options.display(params) : undefined;
707
- return toolText(JSON.stringify(await requestApproval(action, params, undefined, channelContext, display), null, 2));
711
+ try {
712
+ return toolText(JSON.stringify(await requestApproval(action, params, undefined, channelContext, display), null, 2));
713
+ }
714
+ catch (error) {
715
+ // The plain sentence, unchanged: no approval row exists to describe.
716
+ if (error instanceof ResourceProjectRefusal)
717
+ return plainRefusalResult(error.message);
718
+ throw error;
719
+ }
708
720
  }
709
721
  const check = await checkApproval(approval_id, { consume: true, action, params });
710
722
  decidedBy = check.decided_by;
@@ -28,6 +28,21 @@ export declare function toolError(body: unknown): {
28
28
  text: string;
29
29
  }[];
30
30
  };
31
+ /**
32
+ * An expected, caller-safe refusal whose message is the whole answer (#4447). Handlers throw it
33
+ * from deep helpers; withToolErrors and withApproval return the sentence unchanged instead of
34
+ * mapping it to a generic database error or wrapping it in a JSON envelope.
35
+ */
36
+ export declare class PlainToolRefusal extends Error {
37
+ }
38
+ /** The refusal as tool text with isError, exempt from JSON re-coding by codedToolResult. */
39
+ export declare function plainRefusalResult(message: string): {
40
+ isError: true;
41
+ content: {
42
+ type: 'text';
43
+ text: string;
44
+ }[];
45
+ };
31
46
  /** A table known to block a delete. `count` is best-effort; it may throw or return null. */
32
47
  export interface DeleteBlocker {
33
48
  table: string;
@@ -47,6 +47,20 @@ 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
+ * An expected, caller-safe refusal whose message is the whole answer (#4447). Handlers throw it
52
+ * from deep helpers; withToolErrors and withApproval return the sentence unchanged instead of
53
+ * mapping it to a generic database error or wrapping it in a JSON envelope.
54
+ */
55
+ export class PlainToolRefusal extends Error {
56
+ }
57
+ const plainRefusalResults = new WeakSet();
58
+ /** The refusal as tool text with isError, exempt from JSON re-coding by codedToolResult. */
59
+ export function plainRefusalResult(message) {
60
+ const result = { isError: true, content: [{ type: 'text', text: message }] };
61
+ plainRefusalResults.add(result);
62
+ return result;
63
+ }
50
64
  /**
51
65
  * A DELETE refused by a foreign key (23503) is a conflict ("something still uses this"), not a
52
66
  * missing record. Returns null for any other error so callers fall through to their normal path.
@@ -239,6 +253,8 @@ export function toolErrorClass(body) {
239
253
  }
240
254
  /** Add a stable class to refusals without dropping existing fields or success data. */
241
255
  export function codedToolResult(result) {
256
+ if (plainRefusalResults.has(result))
257
+ return result;
242
258
  let failed = result.isError === true;
243
259
  const content = result.content.map(block => {
244
260
  if (block.type !== 'text' || typeof block.text !== 'string')
@@ -293,6 +309,8 @@ export function withToolErrors(handler) {
293
309
  catch (error) {
294
310
  if (error instanceof McpError && error.code === ErrorCode.UrlElicitationRequired)
295
311
  throw error;
312
+ if (error instanceof PlainToolRefusal)
313
+ return plainRefusalResult(error.message);
296
314
  return codedToolResult(dbErrorResult(error));
297
315
  }
298
316
  };
@@ -0,0 +1,339 @@
1
+ import { PlainToolRefusal } from './db-error.js';
2
+ /**
3
+ * #4447: a gated call must only name resources of the project the connection is on.
4
+ *
5
+ * `requestApproval` is the one path every gated tool reaches, so the check lives there (before the
6
+ * snapshot, the approval row, the webhook and the sandbox auto-approve). Handlers that already
7
+ * validate ownership keep doing so; this closes the ones that never did.
8
+ *
9
+ * The registry is keyed by action, then by param path. A path may be nested (`consent.site_id`,
10
+ * `payload.contact_ids`, `projects[].project_id`); arrays are walked at every level. The static test
11
+ * `resource-project-registry.test.ts` fails when a UUID field on a gated tool schema is missing here.
12
+ */
13
+ export declare class ResourceProjectRefusal extends PlainToolRefusal {
14
+ /** Internal audit context only; never include it in tool output. */
15
+ readonly ownerProjectId?: string;
16
+ constructor(message: string, ownerProjectId?: string);
17
+ }
18
+ type Scope = 'project' | 'org';
19
+ export interface Lookup {
20
+ table: string;
21
+ /** Column that holds the id named in the call. */
22
+ column: string;
23
+ /** `project_id` rows compare with the active project; `org_id` rows with its organization. */
24
+ scope: Scope;
25
+ /** Resolve the owner through a parent row (campaign_steps has no project_id). */
26
+ parent?: {
27
+ fk: string;
28
+ table: string;
29
+ };
30
+ /** The id is a label inside a JSON column, so it can match several rows (one query per id). */
31
+ perValue?: boolean;
32
+ }
33
+ declare const RESOURCES: {
34
+ readonly site: {
35
+ readonly noun: 'site';
36
+ readonly lookup: {
37
+ readonly table: 'sites';
38
+ readonly column: 'id';
39
+ readonly scope: 'project';
40
+ };
41
+ };
42
+ readonly campaign: {
43
+ readonly noun: 'campaign';
44
+ readonly lookup: {
45
+ readonly table: 'campaigns';
46
+ readonly column: 'id';
47
+ readonly scope: 'project';
48
+ };
49
+ };
50
+ readonly campaign_set: {
51
+ readonly noun: 'campaign set';
52
+ readonly lookup: {
53
+ readonly table: 'campaigns';
54
+ readonly column: 'metadata->>campaign_set_id';
55
+ readonly scope: 'project';
56
+ readonly perValue: true;
57
+ };
58
+ };
59
+ readonly step: {
60
+ readonly noun: 'campaign step';
61
+ readonly lookup: {
62
+ readonly table: 'campaign_steps';
63
+ readonly column: 'id';
64
+ readonly scope: 'project';
65
+ readonly parent: {
66
+ readonly fk: 'campaign_id';
67
+ readonly table: 'campaigns';
68
+ };
69
+ };
70
+ };
71
+ readonly contact: {
72
+ readonly noun: 'contact';
73
+ readonly lookup: {
74
+ readonly table: 'subscribers';
75
+ readonly column: 'id';
76
+ readonly scope: 'project';
77
+ };
78
+ };
79
+ readonly segment: {
80
+ readonly noun: 'segment';
81
+ readonly lookup: {
82
+ readonly table: 'segments';
83
+ readonly column: 'id';
84
+ readonly scope: 'project';
85
+ };
86
+ };
87
+ readonly list: {
88
+ readonly noun: 'list';
89
+ readonly lookup: {
90
+ readonly table: 'contact_lists';
91
+ readonly column: 'id';
92
+ readonly scope: 'project';
93
+ };
94
+ };
95
+ readonly form: {
96
+ readonly noun: 'form';
97
+ readonly lookup: {
98
+ readonly table: 'signup_forms';
99
+ readonly column: 'id';
100
+ readonly scope: 'project';
101
+ };
102
+ };
103
+ readonly short_link: {
104
+ readonly noun: 'short link';
105
+ readonly lookup: {
106
+ readonly table: 'short_links';
107
+ readonly column: 'id';
108
+ readonly scope: 'project';
109
+ };
110
+ };
111
+ readonly asset_link: {
112
+ readonly noun: 'asset link';
113
+ readonly lookup: {
114
+ readonly table: 'asset_links';
115
+ readonly column: 'id';
116
+ readonly scope: 'project';
117
+ };
118
+ };
119
+ readonly domain: {
120
+ readonly noun: 'domain';
121
+ readonly lookup: {
122
+ readonly table: 'domains';
123
+ readonly column: 'id';
124
+ readonly scope: 'project';
125
+ };
126
+ };
127
+ readonly message: {
128
+ readonly noun: 'message';
129
+ readonly lookup: {
130
+ readonly table: 'agent_messages';
131
+ readonly column: 'id';
132
+ readonly scope: 'project';
133
+ };
134
+ };
135
+ readonly ccpa_request: {
136
+ readonly noun: 'CCPA request';
137
+ readonly lookup: {
138
+ readonly table: 'compliance_requests';
139
+ readonly column: 'id';
140
+ readonly scope: 'project';
141
+ };
142
+ };
143
+ readonly connection: {
144
+ readonly noun: 'connection';
145
+ readonly lookup: {
146
+ readonly table: 'platform_connections';
147
+ readonly column: 'id';
148
+ readonly scope: 'project';
149
+ };
150
+ };
151
+ readonly import_job: {
152
+ readonly noun: 'import';
153
+ readonly lookup: {
154
+ readonly table: 'import_jobs';
155
+ readonly column: 'id';
156
+ readonly scope: 'project';
157
+ };
158
+ };
159
+ readonly api_key: {
160
+ readonly noun: 'API key';
161
+ readonly lookup: {
162
+ readonly table: 'api_keys';
163
+ readonly column: 'id';
164
+ readonly scope: 'project';
165
+ };
166
+ };
167
+ readonly ab_test: {
168
+ readonly noun: 'A/B test';
169
+ readonly lookup: {
170
+ readonly table: 'ab_tests';
171
+ readonly column: 'id';
172
+ readonly scope: 'project';
173
+ };
174
+ };
175
+ readonly webhook: {
176
+ readonly noun: 'webhook subscription';
177
+ readonly lookup: {
178
+ readonly table: 'webhook_subscriptions';
179
+ readonly column: 'id';
180
+ readonly scope: 'project';
181
+ };
182
+ };
183
+ readonly social_post: {
184
+ readonly noun: 'social post';
185
+ readonly lookup: {
186
+ readonly table: 'social_posts';
187
+ readonly column: 'id';
188
+ readonly scope: 'project';
189
+ };
190
+ };
191
+ readonly custom_field: {
192
+ readonly noun: 'custom field';
193
+ readonly lookup: {
194
+ readonly table: 'custom_field_definitions';
195
+ readonly column: 'id';
196
+ readonly scope: 'project';
197
+ };
198
+ };
199
+ readonly view: {
200
+ readonly noun: 'saved view';
201
+ readonly lookup: {
202
+ readonly table: 'subscriber_list_views';
203
+ readonly column: 'id';
204
+ readonly scope: 'project';
205
+ };
206
+ };
207
+ readonly block: {
208
+ readonly noun: 'content block';
209
+ readonly lookup: {
210
+ readonly table: 'content_blocks';
211
+ readonly column: 'id';
212
+ readonly scope: 'project';
213
+ };
214
+ };
215
+ readonly page: {
216
+ readonly noun: 'landing page';
217
+ readonly lookup: {
218
+ readonly table: 'landing_pages';
219
+ readonly column: 'id';
220
+ readonly scope: 'project';
221
+ };
222
+ };
223
+ readonly newsletter: {
224
+ readonly noun: 'newsletter';
225
+ readonly lookup: {
226
+ readonly table: 'newsletters';
227
+ readonly column: 'id';
228
+ readonly scope: 'project';
229
+ };
230
+ };
231
+ readonly issue: {
232
+ readonly noun: 'newsletter issue';
233
+ readonly lookup: {
234
+ readonly table: 'newsletter_issues';
235
+ readonly column: 'id';
236
+ readonly scope: 'project';
237
+ };
238
+ };
239
+ readonly template: {
240
+ readonly noun: 'template';
241
+ readonly lookup: {
242
+ readonly table: 'campaign_templates';
243
+ readonly column: 'id';
244
+ readonly scope: 'project';
245
+ };
246
+ };
247
+ readonly signature: {
248
+ readonly noun: 'signature';
249
+ readonly lookup: {
250
+ readonly table: 'signature_blocks';
251
+ readonly column: 'id';
252
+ readonly scope: 'project';
253
+ };
254
+ };
255
+ readonly draft: {
256
+ readonly noun: 'draft';
257
+ readonly lookup: {
258
+ readonly table: 'ai_drafts';
259
+ readonly column: 'id';
260
+ readonly scope: 'project';
261
+ };
262
+ };
263
+ readonly brand_asset: {
264
+ readonly noun: 'brand asset';
265
+ readonly lookup: {
266
+ readonly table: 'brand_media_assets';
267
+ readonly column: 'id';
268
+ readonly scope: 'project';
269
+ };
270
+ };
271
+ readonly mcp_connection: {
272
+ readonly noun: 'MCP connection';
273
+ readonly lookup: {
274
+ readonly table: 'mcp_oauth_tokens';
275
+ readonly column: 'id';
276
+ readonly scope: 'project';
277
+ };
278
+ };
279
+ readonly invitation: {
280
+ readonly noun: 'invitation';
281
+ readonly lookup: {
282
+ readonly table: 'org_invitations';
283
+ readonly column: 'id';
284
+ readonly scope: 'org';
285
+ };
286
+ };
287
+ readonly user: {
288
+ readonly noun: 'user';
289
+ readonly lookup: {
290
+ readonly table: 'org_members';
291
+ readonly column: 'user_id';
292
+ readonly scope: 'org';
293
+ };
294
+ };
295
+ readonly project: {
296
+ readonly noun: 'project';
297
+ readonly lookup: {
298
+ readonly table: 'projects';
299
+ readonly column: 'id';
300
+ readonly scope: 'org';
301
+ };
302
+ };
303
+ readonly org: {
304
+ readonly noun: 'organization';
305
+ };
306
+ readonly saved_credential: {
307
+ readonly noun: 'credential';
308
+ };
309
+ };
310
+ export type ResourceKey = keyof typeof RESOURCES;
311
+ /**
312
+ * Action -> param path -> resource. Paths are relative to the params given to requestApproval.
313
+ * Order is the order refusals are reported in. `[]` is a readability marker; arrays are always walked.
314
+ */
315
+ export declare const RESOURCE_PARAM_REGISTRY: Readonly<Record<string, Readonly<Record<string, ResourceKey>>>>;
316
+ /** Paths registered for an action, in report order (empty when the action names no resource). */
317
+ export declare function resourceParamPaths(action: string): string[];
318
+ export interface ResourceRef {
319
+ key: ResourceKey;
320
+ noun: string;
321
+ value: string | null;
322
+ lookup?: Lookup;
323
+ }
324
+ /**
325
+ * Every registered resource the call names, in registry order and de-duplicated. Pure: no reads.
326
+ * Also used by tests to seed exactly the rows a call needs.
327
+ */
328
+ export declare function collectResourceRefs(action: string, params: Record<string, unknown>): ResourceRef[];
329
+ /**
330
+ * Refuse a gated call that names a resource outside the project this connection is on (#4447).
331
+ * Reads with the service-role client because the project-scoped client cannot see another
332
+ * project's row; selects only ids and project_id/org_id. Fails closed.
333
+ * Missing, malformed and foreign ids share one sentence to avoid an existence oracle.
334
+ *
335
+ * Throws ResourceProjectRefusal whose message is the plain sentence for the caller; returns when
336
+ * every named resource is in the active project, or when the call names no registered resource.
337
+ */
338
+ export declare function assertResourcesBelongToActiveProject(action: string, params: Record<string, unknown>): Promise<void>;
339
+ export {};
@@ -0,0 +1,331 @@
1
+ import { chunkIds, parseSavedCredentialId } from '@sendinel/domain';
2
+ import { adminDb } from '../db.js';
3
+ import { getProjectId } from '../project.js';
4
+ import { PlainToolRefusal } from './db-error.js';
5
+ /**
6
+ * #4447: a gated call must only name resources of the project the connection is on.
7
+ *
8
+ * `requestApproval` is the one path every gated tool reaches, so the check lives there (before the
9
+ * snapshot, the approval row, the webhook and the sandbox auto-approve). Handlers that already
10
+ * validate ownership keep doing so; this closes the ones that never did.
11
+ *
12
+ * The registry is keyed by action, then by param path. A path may be nested (`consent.site_id`,
13
+ * `payload.contact_ids`, `projects[].project_id`); arrays are walked at every level. The static test
14
+ * `resource-project-registry.test.ts` fails when a UUID field on a gated tool schema is missing here.
15
+ */
16
+ export class ResourceProjectRefusal extends PlainToolRefusal {
17
+ /** Internal audit context only; never include it in tool output. */
18
+ ownerProjectId;
19
+ constructor(message, ownerProjectId) {
20
+ super(message);
21
+ this.name = 'ResourceProjectRefusal';
22
+ Object.defineProperty(this, 'ownerProjectId', { value: ownerProjectId, enumerable: false });
23
+ }
24
+ }
25
+ // Every table and column below was confirmed against packages/supabase/migrations.
26
+ const RESOURCES = {
27
+ site: { noun: 'site', lookup: { table: 'sites', column: 'id', scope: 'project' } },
28
+ campaign: { noun: 'campaign', lookup: { table: 'campaigns', column: 'id', scope: 'project' } },
29
+ // Sets are a label in campaigns.metadata (campaign-sets.ts), not a table of their own.
30
+ campaign_set: { noun: 'campaign set', lookup: { table: 'campaigns', column: 'metadata->>campaign_set_id', scope: 'project', perValue: true } },
31
+ step: { noun: 'campaign step', lookup: { table: 'campaign_steps', column: 'id', scope: 'project', parent: { fk: 'campaign_id', table: 'campaigns' } } },
32
+ contact: { noun: 'contact', lookup: { table: 'subscribers', column: 'id', scope: 'project' } },
33
+ segment: { noun: 'segment', lookup: { table: 'segments', column: 'id', scope: 'project' } },
34
+ list: { noun: 'list', lookup: { table: 'contact_lists', column: 'id', scope: 'project' } },
35
+ form: { noun: 'form', lookup: { table: 'signup_forms', column: 'id', scope: 'project' } },
36
+ short_link: { noun: 'short link', lookup: { table: 'short_links', column: 'id', scope: 'project' } },
37
+ asset_link: { noun: 'asset link', lookup: { table: 'asset_links', column: 'id', scope: 'project' } },
38
+ domain: { noun: 'domain', lookup: { table: 'domains', column: 'id', scope: 'project' } },
39
+ message: { noun: 'message', lookup: { table: 'agent_messages', column: 'id', scope: 'project' } },
40
+ ccpa_request: { noun: 'CCPA request', lookup: { table: 'compliance_requests', column: 'id', scope: 'project' } },
41
+ connection: { noun: 'connection', lookup: { table: 'platform_connections', column: 'id', scope: 'project' } },
42
+ import_job: { noun: 'import', lookup: { table: 'import_jobs', column: 'id', scope: 'project' } },
43
+ api_key: { noun: 'API key', lookup: { table: 'api_keys', column: 'id', scope: 'project' } },
44
+ ab_test: { noun: 'A/B test', lookup: { table: 'ab_tests', column: 'id', scope: 'project' } },
45
+ webhook: { noun: 'webhook subscription', lookup: { table: 'webhook_subscriptions', column: 'id', scope: 'project' } },
46
+ // Both tables carry project_id and org_id; their handlers filter on project_id.
47
+ social_post: { noun: 'social post', lookup: { table: 'social_posts', column: 'id', scope: 'project' } },
48
+ custom_field: { noun: 'custom field', lookup: { table: 'custom_field_definitions', column: 'id', scope: 'project' } },
49
+ view: { noun: 'saved view', lookup: { table: 'subscriber_list_views', column: 'id', scope: 'project' } },
50
+ block: { noun: 'content block', lookup: { table: 'content_blocks', column: 'id', scope: 'project' } },
51
+ page: { noun: 'landing page', lookup: { table: 'landing_pages', column: 'id', scope: 'project' } },
52
+ newsletter: { noun: 'newsletter', lookup: { table: 'newsletters', column: 'id', scope: 'project' } },
53
+ issue: { noun: 'newsletter issue', lookup: { table: 'newsletter_issues', column: 'id', scope: 'project' } },
54
+ template: { noun: 'template', lookup: { table: 'campaign_templates', column: 'id', scope: 'project' } },
55
+ signature: { noun: 'signature', lookup: { table: 'signature_blocks', column: 'id', scope: 'project' } },
56
+ draft: { noun: 'draft', lookup: { table: 'ai_drafts', column: 'id', scope: 'project' } },
57
+ brand_asset: { noun: 'brand asset', lookup: { table: 'brand_media_assets', column: 'id', scope: 'project' } },
58
+ mcp_connection: { noun: 'MCP connection', lookup: { table: 'mcp_oauth_tokens', column: 'id', scope: 'project' } },
59
+ // Organization-scoped rows: no project_id, so the owner is the organization.
60
+ invitation: { noun: 'invitation', lookup: { table: 'org_invitations', column: 'id', scope: 'org' } },
61
+ user: { noun: 'user', lookup: { table: 'org_members', column: 'user_id', scope: 'org' } },
62
+ project: { noun: 'project', lookup: { table: 'projects', column: 'id', scope: 'org' } },
63
+ // The id is the organization itself; it is compared with the active project's org_id.
64
+ org: { noun: 'organization' },
65
+ // `platform:<uuid>` or `import:<uuid>`; expanded into connection / import_job below.
66
+ saved_credential: { noun: 'credential' },
67
+ };
68
+ /**
69
+ * Action -> param path -> resource. Paths are relative to the params given to requestApproval.
70
+ * Order is the order refusals are reported in. `[]` is a readability marker; arrays are always walked.
71
+ */
72
+ export const RESOURCE_PARAM_REGISTRY = {
73
+ action_ccpa_request: { request_id: 'ccpa_request' },
74
+ activate_campaigns: { campaign_set_id: 'campaign_set', campaign_ids: 'campaign' },
75
+ add_list_members: { list_id: 'list', contact_ids: 'contact' },
76
+ add_list_members_by_filter: { list_id: 'list' },
77
+ archive_form: { form_id: 'form' },
78
+ broadcast_to_segment: { site_id: 'site', segment_id: 'segment', 'brand_asset_ids[]': 'brand_asset' },
79
+ bulk_update_subscribers: { contact_ids: 'contact' },
80
+ create_agent_mailbox: { mcp_connection_id: 'mcp_connection', site_id: 'site' },
81
+ create_blog_post: { site_id: 'site' },
82
+ create_campaign_with_content: { site_id: 'site', segment_id: 'segment' },
83
+ delete_campaign: { campaign_id: 'campaign', campaign_ids: 'campaign' },
84
+ delete_campaign_step: { step_id: 'step', step_ids: 'step' },
85
+ delete_content_block: { block_id: 'block', block_ids: 'block' },
86
+ delete_custom_field: { id: 'custom_field', ids: 'custom_field' },
87
+ delete_landing_page: { page_id: 'page', page_ids: 'page' },
88
+ delete_list: { list_id: 'list', list_ids: 'list' },
89
+ delete_provider_credential: { org_id: 'org', credential_id: 'saved_credential' },
90
+ delete_segment: { segment_id: 'segment', segment_ids: 'segment' },
91
+ delete_signature: { id: 'signature', ids: 'signature' },
92
+ delete_social_post: { post_id: 'social_post', post_ids: 'social_post' },
93
+ delete_subscriber_data: { contact_id: 'contact', contact_ids: 'contact' },
94
+ delete_subscriber_view: { view_id: 'view', view_ids: 'view' },
95
+ delete_template: { id: 'template', ids: 'template' },
96
+ delete_webhook_subscription: { subscription_id: 'webhook', subscription_ids: 'webhook' },
97
+ disconnect_platform: { org_id: 'org', connection_id: 'connection' },
98
+ draft_agent_reply: { message_id: 'message' },
99
+ emit_contact_event: { 'consent.site_id': 'site' },
100
+ enroll_automation: { subscriber_id: 'contact', campaign_id: 'campaign', site_id: 'site' },
101
+ enroll_contact: { campaign_id: 'campaign', contact_id: 'contact' },
102
+ enroll_in_escalation_ladder: { subscriber_id: 'contact', campaign_id: 'campaign' },
103
+ enroll_segment: { campaign_id: 'campaign', site_id: 'site' },
104
+ identify_inactive: { site_id: 'site' },
105
+ identify_subscriber: { site_id: 'site' },
106
+ import_subscribers: { site_id: 'site' },
107
+ invite_team_member: { 'project_ids[]': 'project' },
108
+ launch_campaign: { site_id: 'site', segment_id: 'segment', 'generated.contact_ids': 'contact' },
109
+ merge_subscribers: { target_id: 'contact', 'source_ids[]': 'contact' },
110
+ promote_ab_winner: { test_id: 'ab_test' },
111
+ recheck_domain_dns: { domain_id: 'domain' },
112
+ remove_list_members: { list_id: 'list', contact_ids: 'contact' },
113
+ remove_team_member: { org_id: 'org', user_id: 'user' },
114
+ replay_enrollment_dlq_rows: { site_id: 'site' },
115
+ reply_to_comment: { target_member_id: 'user' },
116
+ revoke_api_key: { key_id: 'api_key' },
117
+ revoke_invitation: { org_id: 'org', invitation_id: 'invitation' },
118
+ rotate_custom_webhook_secret: { connection_id: 'connection' },
119
+ schedule_newsletter_issue: { newsletter_id: 'newsletter', issue_id: 'issue' },
120
+ schedule_social_post: { campaign_id: 'campaign' },
121
+ send_awaiting_reply: { site_id: 'site', template_id: 'template' },
122
+ send_broadcast: {
123
+ site_id: 'site', segment_id: 'segment', 'brand_asset_ids[]': 'brand_asset',
124
+ 'payload.site_id': 'site', 'payload.segment_id': 'segment', 'payload.brand_asset_ids[]': 'brand_asset',
125
+ 'payload.contact_ids': 'contact',
126
+ },
127
+ send_newsletter_issue: { newsletter_id: 'newsletter', issue_id: 'issue' },
128
+ send_sms: { site_id: 'site', contact_id: 'contact' },
129
+ send_sms_campaign: { site_id: 'site', segment_id: 'segment' },
130
+ send_test_email: { site_id: 'site', step_id: 'step', draft_id: 'draft' },
131
+ send_transactional: { site_id: 'site', template_id: 'template' },
132
+ set_campaign_trigger: { campaign_id: 'campaign', 'input.campaign_id': 'campaign' },
133
+ set_contact_sms_consent: { contact_id: 'contact' },
134
+ set_link_domain: { site_id: 'site' },
135
+ set_member_project_access: { org_id: 'org', user_id: 'user', 'projects[].project_id': 'project' },
136
+ set_team_member_role: { org_id: 'org', user_id: 'user' },
137
+ start_provider_switch: { site_id: 'site' },
138
+ tenant_export_full: { siteId: 'site' },
139
+ update_asset_link: { asset_link_id: 'asset_link' },
140
+ update_campaign: {
141
+ campaign_id: 'campaign', site_id: 'site', segment_id: 'segment',
142
+ 'input.campaign_id': 'campaign', 'input.site_id': 'site', 'input.segment_id': 'segment',
143
+ 'updates.site_id': 'site', 'updates.segment_id': 'segment',
144
+ },
145
+ update_campaign_status: { campaign_id: 'campaign' },
146
+ update_form: { form_id: 'form' },
147
+ update_landing_page: { page_id: 'page', site_id: 'site', form_id: 'form' },
148
+ update_short_link: { short_link_id: 'short_link' },
149
+ update_webhook_subscription: { subscription_id: 'webhook' },
150
+ };
151
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
152
+ /** Paths registered for an action, in report order (empty when the action names no resource). */
153
+ export function resourceParamPaths(action) {
154
+ return Object.keys(RESOURCE_PARAM_REGISTRY[action] ?? {});
155
+ }
156
+ /** Every value at `path`, walking arrays at each level. Absent fields yield nothing. */
157
+ function valuesAt(value, segments, out) {
158
+ if (Array.isArray(value)) {
159
+ for (const item of value)
160
+ valuesAt(item, segments, out);
161
+ return;
162
+ }
163
+ if (segments.length === 0) {
164
+ out.push(value);
165
+ return;
166
+ }
167
+ if (!value || typeof value !== 'object')
168
+ return;
169
+ const [head, ...rest] = segments;
170
+ if (!Object.prototype.hasOwnProperty.call(value, head))
171
+ return;
172
+ valuesAt(value[head], rest, out);
173
+ }
174
+ /** `value` is null when the text can never be an id (reported as not found, never queried). */
175
+ function refsFor(key, raw) {
176
+ const spec = RESOURCES[key];
177
+ if (typeof raw !== 'string')
178
+ return [{ key, noun: spec.noun, value: null }];
179
+ if (key === 'saved_credential') {
180
+ const parsed = parseSavedCredentialId(raw);
181
+ if (!parsed)
182
+ return [{ key, noun: spec.noun, value: null }];
183
+ const target = parsed.source === 'platform' ? 'connection' : 'import_job';
184
+ return [{ key: target, noun: spec.noun, value: parsed.rowId, lookup: RESOURCES[target].lookup }];
185
+ }
186
+ return [{ key, noun: spec.noun, value: UUID.test(raw) ? raw : null, lookup: spec.lookup }];
187
+ }
188
+ class LookupFailure extends Error {
189
+ }
190
+ const adminEmail = () => adminDb().schema('email');
191
+ async function must(query) {
192
+ const { data, error } = await query;
193
+ if (error)
194
+ throw new LookupFailure('lookup failed');
195
+ return data;
196
+ }
197
+ /** id -> the project or organization ids that own it. A missing id has no entry. */
198
+ async function owners(lookup, values) {
199
+ const scopeColumn = lookup.scope === 'project' ? 'project_id' : 'org_id';
200
+ const found = new Map();
201
+ const add = (id, owner) => {
202
+ if (typeof id !== 'string' || typeof owner !== 'string')
203
+ return;
204
+ found.set(id, [...(found.get(id) ?? []), owner]);
205
+ };
206
+ if (lookup.perValue) {
207
+ for (const value of values) {
208
+ const rows = await must(adminEmail().from(lookup.table).select(`id, ${scopeColumn}`).eq(lookup.column, value));
209
+ for (const row of rows ?? [])
210
+ add(value, row[scopeColumn]);
211
+ }
212
+ return found;
213
+ }
214
+ for (const chunk of chunkIds(values)) {
215
+ if (lookup.parent) {
216
+ const { fk, table } = lookup.parent;
217
+ const children = await must(adminEmail().from(lookup.table).select(`${lookup.column}, ${fk}`).in(lookup.column, chunk));
218
+ const parentIds = [...new Set((children ?? []).map(row => row[fk]).filter((id) => typeof id === 'string'))];
219
+ const parents = new Map();
220
+ for (const parentChunk of chunkIds(parentIds)) {
221
+ const rows = await must(adminEmail().from(table).select(`id, ${scopeColumn}`).in('id', parentChunk));
222
+ for (const row of rows ?? [])
223
+ if (typeof row.id === 'string' && typeof row[scopeColumn] === 'string')
224
+ parents.set(row.id, row[scopeColumn]);
225
+ }
226
+ for (const row of children ?? [])
227
+ add(row[lookup.column], parents.get(row[fk]));
228
+ continue;
229
+ }
230
+ const rows = await must(adminEmail().from(lookup.table).select(`${lookup.column}, ${scopeColumn}`).in(lookup.column, chunk));
231
+ for (const row of rows ?? [])
232
+ add(row[lookup.column], row[scopeColumn]);
233
+ }
234
+ return found;
235
+ }
236
+ /**
237
+ * Every registered resource the call names, in registry order and de-duplicated. Pure: no reads.
238
+ * Also used by tests to seed exactly the rows a call needs.
239
+ */
240
+ export function collectResourceRefs(action, params) {
241
+ const registered = RESOURCE_PARAM_REGISTRY[action];
242
+ const refs = [];
243
+ if (!registered)
244
+ return refs;
245
+ const seen = new Set();
246
+ for (const [path, key] of Object.entries(registered)) {
247
+ const raw = [];
248
+ valuesAt(params, path.replace(/\[\]/g, '').split('.'), raw);
249
+ for (const value of raw) {
250
+ if (value === undefined || value === null || value === '')
251
+ continue;
252
+ for (const ref of refsFor(key, value)) {
253
+ const id = `${ref.key}:${ref.value ?? `invalid:${String(value)}`}`;
254
+ if (seen.has(id))
255
+ continue;
256
+ seen.add(id);
257
+ refs.push(ref);
258
+ }
259
+ }
260
+ }
261
+ return refs;
262
+ }
263
+ const FAILED_CLOSED = 'Could not check which project the resources in this request belong to, so no approval was requested. Try again.';
264
+ const NOT_FOUND = "I can't find that item in this project.";
265
+ /**
266
+ * Refuse a gated call that names a resource outside the project this connection is on (#4447).
267
+ * Reads with the service-role client because the project-scoped client cannot see another
268
+ * project's row; selects only ids and project_id/org_id. Fails closed.
269
+ * Missing, malformed and foreign ids share one sentence to avoid an existence oracle.
270
+ *
271
+ * Throws ResourceProjectRefusal whose message is the plain sentence for the caller; returns when
272
+ * every named resource is in the active project, or when the call names no registered resource.
273
+ */
274
+ export async function assertResourcesBelongToActiveProject(action, params) {
275
+ const refs = collectResourceRefs(action, params);
276
+ if (refs.length === 0)
277
+ return;
278
+ // Malformed ids cannot exist; refuse before even reading the active project.
279
+ if (refs[0].value === null)
280
+ throw new ResourceProjectRefusal(NOT_FOUND);
281
+ try {
282
+ const projectId = getProjectId();
283
+ const active = await must(adminEmail().from('projects').select('id, org_id').eq('id', projectId).maybeSingle());
284
+ if (active?.id !== projectId || typeof active.org_id !== 'string')
285
+ throw new LookupFailure('active project unavailable');
286
+ const byKey = new Map();
287
+ for (const ref of refs) {
288
+ if (ref.value === null || ref.key === 'org')
289
+ continue;
290
+ let values = byKey.get(ref.key);
291
+ if (!values) {
292
+ values = new Set();
293
+ byKey.set(ref.key, values);
294
+ }
295
+ values.add(ref.value);
296
+ }
297
+ const found = new Map();
298
+ // Resolve lazily in registry order so a later lookup failure cannot hide the first refusal.
299
+ for (const ref of refs) {
300
+ if (ref.value === null)
301
+ throw new ResourceProjectRefusal(NOT_FOUND);
302
+ if (ref.key === 'org') {
303
+ if (ref.value === active.org_id)
304
+ continue;
305
+ // No foreign-only reads: their timing or failures could reveal whether an id exists.
306
+ throw new ResourceProjectRefusal(NOT_FOUND);
307
+ }
308
+ const lookup = RESOURCES[ref.key].lookup;
309
+ if (!found.has(ref.key))
310
+ found.set(ref.key, await owners(lookup, [...byKey.get(ref.key)]));
311
+ const ownerIds = found.get(ref.key)?.get(ref.value);
312
+ if (!ownerIds?.length)
313
+ throw new ResourceProjectRefusal(NOT_FOUND);
314
+ const home = lookup.scope === 'project' ? projectId : active.org_id;
315
+ if (ownerIds.includes(home))
316
+ continue;
317
+ if (lookup.scope === 'org') {
318
+ throw new ResourceProjectRefusal(NOT_FOUND);
319
+ }
320
+ // Keep the owner id for internal audit without a foreign-only project/name lookup.
321
+ throw new ResourceProjectRefusal(NOT_FOUND, ownerIds[0]);
322
+ }
323
+ }
324
+ catch (error) {
325
+ if (error instanceof ResourceProjectRefusal)
326
+ throw error;
327
+ // Never describe an id as missing because a read failed, and never let the call continue.
328
+ console.warn('[mcp] Resource project check could not complete; refusing the request.');
329
+ throw new ResourceProjectRefusal(FAILED_CLOSED);
330
+ }
331
+ }
@@ -0,0 +1,3 @@
1
+ export declare function extractStepLinks(bodyHtml: string): string[];
2
+ export declare function extractStepPreheader(bodyHtml: string): string | null;
3
+ export declare function summarizeStepForStatus(step: Record<string, unknown>): Record<string, unknown>;
@@ -0,0 +1,41 @@
1
+ const HREF_RE = /<a\b[^>]*?\shref\s*=\s*(?:"([^"]*)"|'([^']*)')/gi;
2
+ const PREHEADER_RE = /^\s*<span\b[^>]*\bstyle\s*=\s*(?:"[^"]*display:\s*none[^"]*"|'[^']*display:\s*none[^']*')[^>]*>([\s\S]*?)<\/span>/i;
3
+ function htmlUnescape(text) {
4
+ return text
5
+ .replace(/&amp;/g, '&')
6
+ .replace(/&lt;/g, '<')
7
+ .replace(/&gt;/g, '>')
8
+ .replace(/&quot;/g, '"')
9
+ .replace(/&#39;/g, "'");
10
+ }
11
+ export function extractStepLinks(bodyHtml) {
12
+ const links = [];
13
+ const seen = new Set();
14
+ HREF_RE.lastIndex = 0;
15
+ let match;
16
+ while ((match = HREF_RE.exec(bodyHtml)) !== null) {
17
+ const href = match[1] ?? match[2] ?? '';
18
+ if (!seen.has(href)) {
19
+ seen.add(href);
20
+ links.push(href);
21
+ }
22
+ }
23
+ return links;
24
+ }
25
+ export function extractStepPreheader(bodyHtml) {
26
+ const match = bodyHtml.match(PREHEADER_RE);
27
+ if (!match)
28
+ return null;
29
+ return htmlUnescape(match[1]);
30
+ }
31
+ export function summarizeStepForStatus(step) {
32
+ const { body_html, body_text, blocks, ...rest } = step;
33
+ const bodyHtml = typeof body_html === 'string' ? body_html : '';
34
+ return {
35
+ ...rest,
36
+ subject: step.subject,
37
+ preheader: bodyHtml ? extractStepPreheader(bodyHtml) : null,
38
+ links: extractStepLinks(bodyHtml),
39
+ body_length: bodyHtml.length,
40
+ };
41
+ }
@@ -36,6 +36,7 @@ import { checkCampaignMergeVariables, persistMergeVariableWarnings, summarizeMer
36
36
  import { recordBrandAssetUsage } from '../lib/brand-asset-usage.js';
37
37
  import { BLOCK_GENERATION_SCHEMA, blocksToHtml, blocksToText, normalizeComposerBlocks } from '../lib/composer-blocks.js';
38
38
  import { callSendinelV1 } from '../lib/v1-client.js';
39
+ import { summarizeStepForStatus } from '../lib/summarize-step-for-status.js';
39
40
  import { countUniqueEngagementByCampaign } from '../lib/campaign-engagement.js';
40
41
  const txt = (text) => ({ content: [{ type: 'text', text }] });
41
42
  const j = (data) => txt(JSON.stringify(data, null, 2));
@@ -4028,9 +4029,13 @@ Return ONLY valid JSON with this exact shape:
4028
4029
  idempotentHint: true,
4029
4030
  openWorldHint: false,
4030
4031
  },
4031
- description: 'Get full campaign details including all steps, enrollment counts and derived trigger_kind (event/tag/date_field/contact_event/manual), saved trigger_type, trigger_event_name, trigger_tag, trigger_config (including where filter), fires_on and trigger_diagnostics. Read what event a dunning campaign fires on; use set_campaign_trigger to change it. trigger_kind is the canonical answer to what fires the campaign; trigger_type is the stored schedule mode and stays "manual" for event and tag triggers. ' + ACTIVATION_APPROVAL_DESCRIPTION,
4032
- inputSchema: { campaign_id: z.string().uuid(), period: z.string().min(1).optional().describe("Return results for one enrollment period; totals remain all-time.") },
4033
- }, async ({ campaign_id, period }) => {
4032
+ description: 'Get full campaign details including all steps, enrollment counts and derived trigger_kind (event/tag/date_field/contact_event/manual), saved trigger_type, trigger_event_name, trigger_tag, trigger_config (including where filter), fires_on and trigger_diagnostics. Read what event a dunning campaign fires on; use set_campaign_trigger to change it. trigger_kind is the canonical answer to what fires the campaign; trigger_type is the stored schedule mode and stays "manual" for event and tag triggers. Optional include_bodies (default true): when false, each step returns subject, preheader, links and body_length instead of body_html, body_text, and blocks. ' + ACTIVATION_APPROVAL_DESCRIPTION,
4033
+ inputSchema: {
4034
+ campaign_id: z.string().uuid(),
4035
+ period: z.string().min(1).optional().describe("Return results for one enrollment period; totals remain all-time."),
4036
+ include_bodies: z.boolean().optional().default(true).describe('When false, each step returns subject, preheader, links and body_length instead of body_html/body_text/blocks. Default true.'),
4037
+ },
4038
+ }, async ({ campaign_id, period, include_bodies }) => {
4034
4039
  const projectId = getProjectId();
4035
4040
  const { data: campaign, error: campErr } = await db()
4036
4041
  .from('campaigns')
@@ -4100,6 +4105,7 @@ Return ONLY valid JSON with this exact shape:
4100
4105
  const queuedCount = queuedEmailCount ?? 0;
4101
4106
  const failedCount = failedSendCount ?? 0;
4102
4107
  const unresolvedCount = dlqUnresolvedCount ?? 0;
4108
+ const mappedSteps = (steps ?? []).map((step) => ({ ...step, ...stepMultiEventRulesView(step), ...stepAbsenceRuleView(step), total_sent: stepMetrics.sent.get(step.id) ?? 0 }));
4103
4109
  return j({
4104
4110
  ...campaignMetricsView(campaign),
4105
4111
  merge_variable_warnings: campaign.metadata?.merge_variable_warnings ?? [],
@@ -4109,7 +4115,7 @@ Return ONLY valid JSON with this exact shape:
4109
4115
  total_sent: periods?.totals?.sent ?? sentCount ?? 0,
4110
4116
  ...(periods?.totals ? { unique_opened: periods.totals.opened, unique_clicked: periods.totals.clicked } : {}),
4111
4117
  ...campaignPeriodFields(periods, period),
4112
- steps: (steps ?? []).map((step) => ({ ...step, ...stepMultiEventRulesView(step), ...stepAbsenceRuleView(step), total_sent: stepMetrics.sent.get(step.id) ?? 0 })),
4118
+ steps: include_bodies === false ? mappedSteps.map(summarizeStepForStatus) : mappedSteps,
4113
4119
  active_enrollments: enrollCount ?? 0,
4114
4120
  send_incomplete: queuedCount > 0 || failedCount > 0 || outstandingRows.length > 0 || unresolvedCount > 0,
4115
4121
  queued_email_count: queuedCount,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.221",
3
+ "version": "1.0.223",
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",