@sendinel/mcp-server 1.0.110 → 1.0.112

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/build/db.d.ts CHANGED
@@ -32,6 +32,7 @@ export declare function setProjectClient(client: any): void;
32
32
  * @param fn - Async function to run within this client's context.
33
33
  */
34
34
  export declare function runWithClient<T>(client: any, fn: () => Promise<T>, request?: {
35
+ scopes?: readonly string[];
35
36
  projectId?: string;
36
37
  approvalMode?: string;
37
38
  channel?: string;
@@ -48,12 +49,16 @@ export declare function runWithClient<T>(client: any, fn: () => Promise<T>, requ
48
49
  kid?: string | null;
49
50
  v1Caller?: V1Caller;
50
51
  }): Promise<T>;
51
- export declare function runWithToolContext<T>(toolGroup: string, toolName: string, fn: () => Promise<T> | T): Promise<T>;
52
+ export declare function runWithToolContext<T>(toolGroup: string, toolName: string, fn: () => Promise<T> | T, scopes?: readonly string[] | null): Promise<T>;
53
+ /** Keep the consumed-approval marker inside this async call; model input cannot set it. */
54
+ export declare function runWithApprovedTesterRequest<T>(action: string, fn: () => Promise<T> | T): Promise<T>;
52
55
  /**
53
56
  * Request-scoped identity set via runWithClient() (HTTP transport).
54
57
  * Undefined under stdio, where the process env is the single-project source of truth.
55
58
  */
56
59
  export declare function requestContext(): {
60
+ scopes?: readonly string[];
61
+ approvedTesterAction?: string;
57
62
  projectId?: string;
58
63
  approvalMode?: string;
59
64
  channel?: string;
package/build/db.js CHANGED
@@ -48,11 +48,18 @@ export async function runWithClient(
48
48
  client, fn, request) {
49
49
  return _requestStore.run({ client, ...request }, fn);
50
50
  }
51
- export async function runWithToolContext(toolGroup, toolName, fn) {
51
+ export async function runWithToolContext(toolGroup, toolName, fn, scopes) {
52
52
  const current = _requestStore.getStore();
53
53
  if (!current)
54
- return await _requestStore.run({ client: _processClient, toolGroup, toolName }, fn);
55
- return await _requestStore.run({ ...current, toolGroup, toolName }, fn);
54
+ return await _requestStore.run({ client: _processClient, toolGroup, toolName, scopes: scopes ?? undefined }, fn);
55
+ return await _requestStore.run({ ...current, toolGroup, toolName, scopes: scopes ?? current.scopes }, fn);
56
+ }
57
+ /** Keep the consumed-approval marker inside this async call; model input cannot set it. */
58
+ export async function runWithApprovedTesterRequest(action, fn) {
59
+ const current = _requestStore.getStore();
60
+ if (!current)
61
+ throw new Error('Tester application requires request context');
62
+ return _requestStore.run({ ...current, approvedTesterAction: action }, fn);
56
63
  }
57
64
  /**
58
65
  * Request-scoped identity set via runWithClient() (HTTP transport).
@@ -62,6 +69,8 @@ export function requestContext() {
62
69
  const ctx = _requestStore.getStore();
63
70
  return ctx
64
71
  ? {
72
+ scopes: ctx.scopes,
73
+ approvedTesterAction: ctx.approvedTesterAction,
65
74
  projectId: ctx.projectId,
66
75
  approvalMode: ctx.approvalMode,
67
76
  channel: ctx.channel,
@@ -1,6 +1,9 @@
1
+ import { isTesterRequest, isApplyingTesterRequest } from './tester-policy.js';
2
+ import { logTesterRequestAudit } from './tester-request-audit.js';
3
+ import { approvalStore } from './tester-requests-store.js';
1
4
  import { createHash } from 'node:crypto';
2
- import { buildApprovalPreview, isHumanApprovalChannel, triggerChangeSummary, decodeHtmlEntities, stripHtmlComments } from '@sendinel/domain';
3
- import { db, requestContext } from '../db.js';
5
+ import { approvalSetupSummary, buildApprovalPreview, isHumanApprovalChannel, triggerChangeSummary, decodeHtmlEntities, stripHtmlComments } from '@sendinel/domain';
6
+ import { requestContext } from '../db.js';
4
7
  import { getProjectId } from '../project.js';
5
8
  import { captureApprovalSnapshot, withoutApprovalSnapshot } from './approval-snapshot.js';
6
9
  import { fireWebhookEvent } from './webhook-events.js';
@@ -97,6 +100,8 @@ export function isApprovalGatedAction(action) {
97
100
  return GATED_ACTIONS.has(action) || PROTECTED_ACTIONS.has(action);
98
101
  }
99
102
  export function getApprovalTier(action, params) {
103
+ if (isTesterRequest(action))
104
+ return isApplyingTesterRequest(action) ? 'autonomous' : 'gated';
100
105
  // CRITICAL: Protected actions ALWAYS require approval, even in autonomous mode.
101
106
  // These are irreversible (GDPR delete, merge, etc.) — no opt-out.
102
107
  if (PROTECTED_ACTIONS.has(action)) {
@@ -235,6 +240,9 @@ function launchSummary(p, d) {
235
240
  }
236
241
  /** Build a human-readable summary of the action for the approval screen */
237
242
  export function buildApprovalSummary(action, params, display = {}) {
243
+ const setup = approvalSetupSummary(action, params);
244
+ if (setup)
245
+ return setup;
238
246
  if ((action === 'update_campaign' || action === 'set_campaign_trigger') && params.previous_campaign && params.updates)
239
247
  return triggerChangeSummary(String(params.previous_campaign.name ?? params.campaign_id), params.previous_campaign, params.updates);
240
248
  const summaries = {
@@ -325,7 +333,6 @@ function approvalParamsEqual(expected, actual) {
325
333
  */
326
334
  export async function requestApproval(action, params, apiKeyId, channelContext, display) {
327
335
  const tier = getApprovalTier(action, params);
328
- const summary = buildApprovalSummary(action, params, display);
329
336
  const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
330
337
  const { channel, verified } = channelContext ?? currentChannelContext();
331
338
  const actor = requestContext();
@@ -338,7 +345,8 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
338
345
  throw new Error('Approval audience changed while preparing the preview. Request a new approval.');
339
346
  }
340
347
  const frozenParams = { ...cleanParams, __approval_snapshot: snapshot };
341
- const { data, error } = await db()
348
+ const summary = buildApprovalSummary(action, frozenParams, display);
349
+ const { data, error } = await approvalStore()
342
350
  .from('action_approvals')
343
351
  .insert({
344
352
  project_id: getProjectId(),
@@ -358,6 +366,8 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
358
366
  .single();
359
367
  if (error)
360
368
  throw new Error(`Failed to create approval request: ${error.message}`);
369
+ if (isTesterRequest(action))
370
+ await logTesterRequestAudit('tester_request', action, data.id, null);
361
371
  fireWebhookEvent('approval.requested', {
362
372
  approval_id: data.id,
363
373
  kind: action,
@@ -384,7 +394,7 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
384
394
  * Returns the approval if approved, null if still pending, throws if rejected/expired.
385
395
  */
386
396
  export async function checkApproval(approvalId, options = {}) {
387
- const { data, error } = await db()
397
+ const { data, error } = await approvalStore()
388
398
  .from('action_approvals')
389
399
  .select('action, status, decided_by, decided_via, expires_at, used_at, params, payload_hash, requested_at')
390
400
  .eq('id', approvalId)
@@ -440,7 +450,7 @@ export async function checkApproval(approvalId, options = {}) {
440
450
  }
441
451
  if (expiry <= Date.now())
442
452
  return { approved: false, status: 'expired' };
443
- const claimQuery = db()
453
+ const claimQuery = approvalStore()
444
454
  .from('action_approvals')
445
455
  .update({ status: 'used', used_at: new Date().toISOString() })
446
456
  .eq('id', approvalId)
@@ -1,7 +1,7 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { buildApprovalPreview, isHumanApprovalChannel } from '@sendinel/domain';
4
- import { db } from '../db.js';
4
+ import { approvalStore } from './tester-requests-store.js';
5
5
  import { approvalAppClientAllowed, approvalClientName, approval_channel_available, probeApprovalCapabilities } from './approval-capabilities.js';
6
6
  // Keyed by the SDK Server, not the McpServer wrapper: other installers (the Apps tier) wrap
7
7
  // McpServer in a Proxy, which changes the wrapper's identity but never the connection's Server.
@@ -87,7 +87,7 @@ export async function elicitApproval(result, frozen, projectId) {
87
87
  // URL accept only means the page may open. Only its authenticated decision counts.
88
88
  // Observe while this request is alive; do not retain a background poller after timeout.
89
89
  while (!controller.signal.aborted) {
90
- const source = db();
90
+ const source = approvalStore();
91
91
  const client = typeof source.schema === 'function' ? source.schema('email') : source;
92
92
  const { data, error } = await Promise.race([client.from('action_approvals')
93
93
  .select('status, decided_via').eq('id', result.approval_id).eq('project_id', projectId).maybeSingle(), stopped]);
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { db } from '../db.js';
2
+ import { approvalStore } from './tester-requests-store.js';
3
3
  /**
4
4
  * Server integration only. The transport creates this closure for one authenticated human
5
5
  * interaction and retains it outside the tool registry. Neither its id nor decision is a tool
@@ -16,7 +16,7 @@ export function createHumanApprovalCallback(binding) {
16
16
  if (response.decision !== 'approve' && response.decision !== 'reject')
17
17
  return false;
18
18
  completed = true;
19
- const source = db();
19
+ const source = approvalStore();
20
20
  const client = typeof source.schema === 'function' ? source.schema('email') : source;
21
21
  const now = new Date().toISOString();
22
22
  const { data, error } = await client.from('action_approvals')
@@ -1,3 +1,5 @@
1
+ import { isTesterRequest } from './tester-policy.js';
2
+ import { captureTesterRequestSnapshot } from './tester-request-snapshot.js';
1
3
  import { readAllRows, chunkIds, scopeSegmentRulesToSite } from '@sendinel/domain';
2
4
  import { db } from '../db.js';
3
5
  import { resolveSendAudience, resolveSegmentContactIds } from './send-audience.js';
@@ -6,9 +8,10 @@ const TARGET_ACTIONS = new Set(['send_broadcast', 'broadcast_to_segment', 'launc
6
8
  'send_transactional', 'send_awaiting_reply', 'send_test_email', 'send_newsletter_issue', 'schedule_newsletter_issue']);
7
9
  /** Server-owned display facts, saved inside the hash-bound params. Never supplied by a tool. */
8
10
  export async function captureApprovalSnapshot(action, params, projectId) {
11
+ const setup = isTesterRequest(action) ? await captureTesterRequestSnapshot(action, params, projectId) : null;
9
12
  const snapshot = { version: 1, campaigns: [], steps: [], sites: [], recipient_count: null };
10
13
  if (!TARGET_ACTIONS.has(action))
11
- return snapshot;
14
+ return setup ?? snapshot;
12
15
  const source = db();
13
16
  const client = typeof source.schema === 'function' ? source.schema('email') : source;
14
17
  const body = params.payload ?? params;
@@ -98,7 +101,7 @@ export async function captureApprovalSnapshot(action, params, projectId) {
98
101
  }
99
102
  snapshot.campaigns.sort((a, b) => String(a.id).localeCompare(String(b.id)));
100
103
  snapshot.steps.sort((a, b) => String(a.id).localeCompare(String(b.id)));
101
- return snapshot;
104
+ return setup ? { ...snapshot, ...setup, recipient_count: snapshot.recipient_count, audience_snapshot: snapshot } : snapshot;
102
105
  }
103
106
  export function withoutApprovalSnapshot(params) {
104
107
  const { __approval_snapshot: _snapshot, ...rest } = (params ?? {});
@@ -1,3 +1,4 @@
1
+ import { isApplyingTesterRequest } from './tester-policy.js';
1
2
  import { isDeepStrictEqual } from 'node:util';
2
3
  import { campaignTriggerChanges, campaignTriggerRevision, triggerChangeNeedsApproval } from '@sendinel/domain';
3
4
  import { db } from '../db.js';
@@ -22,7 +23,7 @@ export async function finishCampaignEdit(action, input, existing, updates, reque
22
23
  if (campaignTriggerChanges(existing, updates) && !['draft', 'active', 'paused'].includes(existing.status)) {
23
24
  return campaignEditError(`Cannot change a ${existing.status} campaign trigger`, 'INVALID_STATUS');
24
25
  }
25
- if (!triggerChangeNeedsApproval(existing, updates))
26
+ if (isApplyingTesterRequest(action) || !triggerChangeNeedsApproval(existing, updates))
26
27
  return writeEdit(input.campaign_id, existing, updates);
27
28
  const params = { campaign_id: input.campaign_id, input, previous_campaign: existing, updates };
28
29
  const context = currentChannelContext();
@@ -3,9 +3,28 @@ import { rankTools, scoreTool, tokenize, TOOL_ALIASES } from '../tools/tool-sear
3
3
  const approval = { required: true, how: 'returns approve_url; re-call with approval_id' };
4
4
  const from = (n, field) => `<from step ${n}: ${field}>`;
5
5
  const projectRule = 'On hosted /mcp, pass project_id on each call. It is required for approval-gated writes after switching away from the home project. With invoke_tool put project_id on the outer call; keep approval-only inner args to approval_id.';
6
- const previewRule = 'preview_email only substitutes sample merge variables; it is not the real send renderer. send_test_email with dry_run:true uses the real renderer without sending, but still follows the approval flow in standard mode; keep the same args plus approval_id when re-calling it. Stdio requires SENDINEL_API_KEY.';
6
+ const previewRule = 'preview_email uses the real send renderer with sample contact data. send_test_email with dry_run:true uses the real renderer without sending, but still follows the approval flow in standard mode; keep the same args plus approval_id when re-calling it. Stdio requires SENDINEL_API_KEY.';
7
7
  const sender = { n: 1, tool: 'get_sites', args_example: {}, note: 'Choose the intended sender from the returned array; do not assume the first sender is correct. Replace example content and addresses with the user’s own choices.' };
8
8
  export const HOW_TO_RECIPES = [
9
+ {
10
+ id: 'event-driven-billing-mail-end-to-end', title: 'Event-driven billing mail end to end',
11
+ when_to_use: 'Build and verify event-driven billing, dunning, payment failed or trial ending transactional mail, then request human approval for activation.',
12
+ synonyms: ['event-driven billing mail end to end', 'billing', 'dunning', 'trial ending', 'transactional', 'payment failed'],
13
+ requires: { scopes: ['admin'], tester_ok: false },
14
+ steps: [sender,
15
+ { n: 2, tool: 'create_campaign_with_content', args_example: { site_id: from(1, '0.id'), name: 'Payment failed', type: 'triggered', trigger_event_name: 'invoice_payment_failed', send_class: 'product', steps: [{ subject: 'Please update your payment method', body_html: '<p>Please sign in to review your payment method.</p>', body_text: 'Please sign in to review your payment method.', delay_days: 0 }], activate: false, confirmed: false }, note: 'Preview the draft creation. Replace the event and content with the actual integration event and reviewed copy. product is the account/trial/billing consent class; transactional is not an enum value.' },
16
+ { n: 3, tool: 'create_campaign_with_content', args_example: { site_id: from(1, '0.id'), name: 'Payment failed', type: 'triggered', trigger_event_name: 'invoice_payment_failed', send_class: 'product', steps: [{ subject: 'Please update your payment method', body_html: '<p>Please sign in to review your payment method.</p>', body_text: 'Please sign in to review your payment method.', delay_days: 0 }], activate: false, confirmed: true }, note: 'Repeat the reviewed creation args with confirmed:true. Keep activate:false; no segment is required. Follow any returned setup approval before execution.' },
17
+ { n: 4, tool: 'set_campaign_trigger', args_example: { campaign_id: from(3, 'campaign_id'), trigger_event_name: 'invoice_payment_failed', trigger_config: { events: ['invoice_payment_failed'], where: { plan: 'paid' } } }, approval, note: 'Use event-data keys actually emitted by the integration. Drafts save directly; if approval is requested, wait for the human and follow the returned re-call instructions. trial_ending and trial_ending_no_card are alternative event names.' },
18
+ { n: 5, tool: 'update_site', args_example: { site_id: from(1, '0.id'), unsub_footer_template: "You're receiving this because you have an account. <unsubscribe>Unsubscribe</unsubscribe>" }, note: 'Site-wide change: review its effect on every campaign using this sender, keep previous_unsub_footer_template for undo, and follow any returned approval. Leave physical_address unchanged. For only this campaign, skip this call and instead pass unsub_footer_template with this same tagged text to BOTH creation calls; a null campaign override uses the site template.' },
19
+ { n: 6, tool: 'list_campaign_steps', args_example: { campaign_id: from(3, 'campaign_id') }, note: 'Choose the intended saved step; preview each step in a multi-step sequence.' },
20
+ { n: 7, tool: 'preview_email', args_example: { step_id: from(6, 'steps.0.id') }, note: 'Check the rendered footer (campaign override else site), unsubscribe scope for product, sender and physical address. Sample placeholder links are not delivery proof.' },
21
+ { n: 8, tool: 'activate_campaigns', args_example: { campaign_ids: [from(3, 'campaign_id')] }, approval, note: 'Human approval is required. Show the exact content, sender, event/filter and approve_url. Stop until a human approves; never approve on their behalf.' },
22
+ { n: 9, tool: 'activate_campaigns', args_example: { approval_id: from(8, 'approval_id') }, approval, note: 'Only after human approval, re-call with ONLY approval_id. This activates the saved campaign and can cause future matching events to send mail.' },
23
+ { n: 10, tool: 'get_campaign', args_example: { campaign_id: from(3, 'campaign_id') }, note: 'Final read back: verify fires_on, trigger_diagnostics, status, send_class, unsub_footer_template and the saved trigger_config.where.' },
24
+ ],
25
+ gotchas: [projectRule, previewRule, 'Never activate without human approval. A trigger binding does not emit an event or enroll historical events. Product mail still follows consent/suppression gates; preview does not prove eligibility or delivery. Setup writes may require their own approval depending on the connection role.'],
26
+ done_when: 'The reviewed draft renders the intended footer and product scope, activation has human approval, and get_campaign confirms fires_on and trigger_diagnostics. Real delivery remains unverified until a separately authorized event test.',
27
+ },
9
28
  {
10
29
  id: 'set-campaign-trigger', title: 'Set what a campaign fires on',
11
30
  when_to_use: 'Bind an existing campaign to an event such as invoice_payment_failed for dunning, or change its tag/date/contact-event trigger.',
@@ -23,7 +42,7 @@ export const HOW_TO_RECIPES = [
23
42
  id: 'send-one-off-email', title: 'Send a one-off email to my list',
24
43
  when_to_use: 'Send an announcement or email blast to an audience with human approval.',
25
44
  synonyms: TOOL_ALIASES.send_broadcast,
26
- requires: { scopes: ['write'], tester_ok: true },
45
+ requires: { scopes: ['write'], tester_ok: false },
27
46
  steps: [
28
47
  { n: 1, tool: 'send_broadcast', args_example: { audience: 'subscribers to the intended sender', subject: 'A quick update', html: '<p>Here is the update you requested.</p>' }, approval, note: 'Specify site_id if there is more than one sender. Show the subject, recipient_count, sender, finished email and approve_url; wait for a person to approve.' },
29
48
  { n: 2, tool: 'send_broadcast', args_example: { approval_id: from(1, 'approval_id') }, approval, note: 'Only after human approval. Pass ONLY approval_id; changed content needs a new request.' },
@@ -0,0 +1,6 @@
1
+ /** Campaign setup only. Never infer this permission from a prefix or model arguments. */
2
+ export declare const TESTER_REQUEST_TOOLS: ReadonlySet<string>;
3
+ export declare const TESTER_SITE_FIELDS: ReadonlySet<string>;
4
+ export declare function isTesterRequest(action: string): boolean;
5
+ /** Server-only context set after the shared core has consumed a human-approved payload. */
6
+ export declare function isApplyingTesterRequest(action: string): boolean;
@@ -0,0 +1,18 @@
1
+ import { requestContext } from '../db.js';
2
+ /** Campaign setup only. Never infer this permission from a prefix or model arguments. */
3
+ export const TESTER_REQUEST_TOOLS = new Set([
4
+ 'update_site', 'update_campaign', 'set_campaign_trigger', 'create_campaign',
5
+ 'create_campaign_with_content', 'add_campaign_step', 'update_campaign_step',
6
+ ]);
7
+ export const TESTER_SITE_FIELDS = new Set([
8
+ 'unsub_link_enabled', 'unsub_one_click_enabled', 'unsub_custom_url',
9
+ 'unsub_footer_template', 'unsub_custom_message', 'unsub_brand_logo_url', 'unsub_brand_color',
10
+ ]);
11
+ export function isTesterRequest(action) {
12
+ const ctx = requestContext();
13
+ return !!ctx?.scopes?.includes('tester') && !ctx.scopes.includes('agent') && TESTER_REQUEST_TOOLS.has(action);
14
+ }
15
+ /** Server-only context set after the shared core has consumed a human-approved payload. */
16
+ export function isApplyingTesterRequest(action) {
17
+ return isTesterRequest(action) && requestContext()?.approvedTesterAction === action;
18
+ }
@@ -0,0 +1,2 @@
1
+ /** Central audit infrastructure, including when the edited site belongs to a BYOD project. */
2
+ export declare function logTesterRequestAudit(event: 'tester_request' | 'tester_applied', action: string, approvalId: string, approver: string | null): Promise<void>;
@@ -0,0 +1,15 @@
1
+ import { requestContext } from '../db.js';
2
+ import { getProjectId } from '../project.js';
3
+ import { approvalStore } from './tester-requests-store.js';
4
+ /** Central audit infrastructure, including when the edited site belongs to a BYOD project. */
5
+ export async function logTesterRequestAudit(event, action, approvalId, approver) {
6
+ const ctx = requestContext();
7
+ const { error } = await approvalStore().from('audit_log').insert({
8
+ project_id: getProjectId(), actor_type: ctx?.actorType ?? 'mcp', actor_id: ctx?.actorId ?? null,
9
+ action: `mcp_connection.${event}`, resource_type: 'mcp_connection', resource_id: ctx?.oauthTokenId,
10
+ channel: ctx?.channel ?? 'mcp',
11
+ details: { connection_id: ctx?.oauthTokenId, approval_id: approvalId, tool: action, approver },
12
+ });
13
+ if (error)
14
+ throw new Error(`Failed to audit tester ${event}: ${error.message}`);
15
+ }
@@ -0,0 +1,4 @@
1
+ type Row = Record<string, any>;
2
+ /** Setup targets and before values, frozen by the same hash and revalidation as sending approvals. */
3
+ export declare function captureTesterRequestSnapshot(action: string, params: Row, projectId: string): Promise<Row>;
4
+ export {};
@@ -0,0 +1,46 @@
1
+ import { readAllRows } from '@sendinel/domain';
2
+ import { db, requestContext } from '../db.js';
3
+ /** Setup targets and before values, frozen by the same hash and revalidation as sending approvals. */
4
+ export async function captureTesterRequestSnapshot(action, params, projectId) {
5
+ const source = db();
6
+ const client = typeof source.schema === 'function' ? source.schema('email') : source;
7
+ const snapshot = { version: 1, tester_connection_id: requestContext()?.oauthTokenId,
8
+ campaigns: [], steps: [], sites: [], recipient_count: null };
9
+ let campaignId = params.campaign_id;
10
+ if (action === 'update_campaign_step') {
11
+ const { data, error } = await client.from('campaign_steps').select('*').eq('id', params.step_id).maybeSingle();
12
+ if (error || !data)
13
+ throw new Error('Approval step unavailable');
14
+ campaignId = data.campaign_id;
15
+ }
16
+ if (campaignId) {
17
+ const { data, error } = await client.from('campaigns')
18
+ .select('id, name, description, site_id, segment_id, type, status, topic, send_at, sends_per_hour, send_in_contact_timezone, send_class, requires_approval, unsub_footer_template, trigger_type, trigger_event_name, trigger_tag, trigger_config, updated_at')
19
+ .eq('id', campaignId).eq('project_id', projectId).maybeSingle();
20
+ if (error || !data)
21
+ throw new Error('Approval campaign unavailable');
22
+ snapshot.campaigns = [data];
23
+ snapshot.steps = await readAllRows(client.from('campaign_steps').select('*').eq('campaign_id', campaignId));
24
+ snapshot.steps.sort((a, b) => String(a.id).localeCompare(String(b.id)));
25
+ if (params.step_id && !snapshot.steps.some((step) => step.id === params.step_id))
26
+ throw new Error('Approval step unavailable');
27
+ }
28
+ const siteIds = [...new Set([params.site_id, ...snapshot.campaigns.map((campaign) => campaign.site_id)]
29
+ .filter((id) => typeof id === 'string'))].sort();
30
+ for (const siteId of siteIds) {
31
+ const { data, error } = await client.from('sites')
32
+ .select('id, name, from_name, from_email, sending_domain, physical_address, unsub_link_enabled, unsub_one_click_enabled, unsub_custom_url, unsub_footer_template, unsub_custom_message, unsub_brand_logo_url, unsub_brand_color, updated_at')
33
+ .eq('id', siteId).eq('project_id', projectId).maybeSingle();
34
+ if (error || !data)
35
+ throw new Error('Approval site unavailable');
36
+ snapshot.sites.push(data);
37
+ }
38
+ if (params.segment_id) {
39
+ const { data, error } = await client.from('segments').select('id, name, rules, site_id')
40
+ .eq('id', params.segment_id).eq('project_id', projectId).maybeSingle();
41
+ if (error || !data)
42
+ throw new Error('Approval segment unavailable');
43
+ snapshot.segment = data;
44
+ }
45
+ return snapshot;
46
+ }
@@ -0,0 +1,2 @@
1
+ /** Approval infrastructure stays central, including when update_site uses a BYOD client. */
2
+ export declare function approvalStore(): any;
@@ -0,0 +1,8 @@
1
+ import { adminDb, db, requestContext } from '../db.js';
2
+ /** Approval infrastructure stays central, including when update_site uses a BYOD client. */
3
+ export function approvalStore() {
4
+ if (!requestContext()?.scopes?.includes('tester'))
5
+ return db();
6
+ const source = adminDb();
7
+ return typeof source.schema === 'function' ? source.schema('email') : source;
8
+ }
@@ -0,0 +1,5 @@
1
+ /** Only installed for an authenticated tester connection, never selected by tool arguments. */
2
+ export declare function testerRequestTool(action: string, config: any, handler: (...args: any[]) => any): {
3
+ config: any;
4
+ handler: (input: Record<string, unknown>, extra?: unknown) => Promise<any>;
5
+ };
@@ -0,0 +1,71 @@
1
+ import { isDeepStrictEqual } from 'node:util';
2
+ import { z } from 'zod';
3
+ import { requestContext, runWithApprovedTesterRequest } from '../db.js';
4
+ import { getProjectId } from '../project.js';
5
+ import { withApproval } from './action-approvals.js';
6
+ import { withoutApprovalSnapshot } from './approval-snapshot.js';
7
+ import { approvalStore } from './tester-requests-store.js';
8
+ import { logTesterRequestAudit } from './tester-request-audit.js';
9
+ import { TESTER_SITE_FIELDS } from './tester-policy.js';
10
+ const text = (message) => ({ content: [{ type: 'text', text: message }] });
11
+ /** Only installed for an authenticated tester connection, never selected by tool arguments. */
12
+ export function testerRequestTool(action, config, handler) {
13
+ const original = z.object(config.inputSchema);
14
+ // Wrapping before optional prevents Zod defaults from populating an approval-id-only retry.
15
+ const inputSchema = Object.fromEntries(Object.entries(config.inputSchema).map(([key, schema]) => [key, z.preprocess(value => value, schema).optional()]));
16
+ inputSchema.approval_id = z.string().uuid().optional();
17
+ return {
18
+ config: { ...config, inputSchema },
19
+ handler: async (input, extra) => {
20
+ if (!requestContext()?.oauthTokenId)
21
+ return text('Tester setup requires an authenticated connection id.');
22
+ const { approval_id, ...supplied } = input;
23
+ let params;
24
+ if (approval_id) {
25
+ const { data, error } = await approvalStore().from('action_approvals').select('*')
26
+ .eq('id', approval_id).eq('project_id', getProjectId()).maybeSingle();
27
+ if (error || !data || data.action !== action || data.oauth_token_id !== requestContext()?.oauthTokenId) {
28
+ return text('Approval not found for this action and tester connection.');
29
+ }
30
+ params = withoutApprovalSnapshot(data.params);
31
+ for (const [key, value] of Object.entries(supplied)) {
32
+ if (value !== undefined && !isDeepStrictEqual(value, params[key]))
33
+ return text('params_mismatch: request differs from the frozen payload.');
34
+ }
35
+ }
36
+ else {
37
+ const parsed = original.safeParse(supplied);
38
+ if (!parsed.success)
39
+ return text(`Invalid request: ${parsed.error.message}`);
40
+ params = parsed.data;
41
+ delete params.approval_id;
42
+ // This human approval replaces the compound tool's model confirmation preview.
43
+ if (action === 'create_campaign_with_content')
44
+ params.confirmed = true;
45
+ }
46
+ if (action === 'update_site' && Object.keys(params).some(key => key !== 'site_id' && params[key] !== undefined && !TESTER_SITE_FIELDS.has(key))) {
47
+ return text('Tester connections may request only site footer and unsubscribe settings.');
48
+ }
49
+ return withApproval(action, async (frozen) => {
50
+ const result = await runWithApprovedTesterRequest(action, () => handler(frozen, extra));
51
+ // All seven setup tools report a successful apply as a resource id or an explicit ok.
52
+ let value;
53
+ try {
54
+ value = JSON.parse(result.content.filter((block) => block.type === 'text')
55
+ .map((block) => block.text).join('\n'));
56
+ }
57
+ catch {
58
+ return result;
59
+ }
60
+ if (!result.isError && !value.error && (value.id || value.campaign_id || value.ok === true)) {
61
+ const { data, error } = await approvalStore().from('action_approvals').select('decided_by')
62
+ .eq('id', approval_id).eq('project_id', getProjectId()).maybeSingle();
63
+ if (error || !data?.decided_by)
64
+ throw new Error('Applied tester request has no recorded approver');
65
+ await logTesterRequestAudit('tester_applied', action, String(approval_id), data.decided_by);
66
+ }
67
+ return result;
68
+ })({ ...params, approval_id: approval_id }, extra);
69
+ },
70
+ };
71
+ }
package/build/server.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export { TESTER_REQUEST_TOOLS } from './lib/tester-policy.js';
2
3
  export interface ToolRegistrationOptions {
3
4
  scopes?: string[];
4
5
  toolGroups?: string[];
@@ -19,7 +20,7 @@ export declare function isAgentConnection(scopes: readonly string[] | null | und
19
20
  export declare function agentConnectionAllowsTool(name: string): boolean;
20
21
  export declare function agentConnectionRefusal(name: string): string;
21
22
  export declare function isTesterConnection(scopes: readonly string[] | null | undefined): boolean;
22
- /** Tools a tester connection may see and call: reads, plus anything that goes through a person's approval. */
23
+ /** Tester reads, explicit setup requests and the existing campaign activation paths. */
23
24
  export declare function testerConnectionAllowsTool(name: string): boolean;
24
25
  /**
25
26
  * Tools whose own input already has a top-level `project_id` that means something other than "the project
@@ -31,7 +32,7 @@ export declare const PROJECT_ID_NATIVE_TOOLS: ReadonlySet<string>;
31
32
  * Tools that need a stated project when the connection is switched away from its home project (#4033):
32
33
  * anything that goes through a person's approval, plus the two always-gated actions.
33
34
  */
34
- export declare function isProjectPinnedWriteTool(name: string): boolean;
35
+ export declare function isProjectPinnedWriteTool(name: string, scopes?: readonly string[]): boolean;
35
36
  export declare function testerConnectionRefusal(name: string): string;
36
37
  export declare const GATEWAY_TOOL_NAMES: string[];
37
38
  /**
@@ -46,4 +47,3 @@ export declare function getRegisteredToolGroups(options?: ToolRegistrationOption
46
47
  */
47
48
  export declare const SENDINEL_SERVER_INSTRUCTIONS: string;
48
49
  export declare function registerAllTools(server: McpServer, options?: ToolRegistrationOptions): void;
49
- export {};
package/build/server.js CHANGED
@@ -2,6 +2,9 @@ import { installApprovalCapabilityProbe } from './lib/approval-capabilities.js';
2
2
  import { installApprovalApp } from './lib/approval-app.js';
3
3
  import { runWithApprovalElicitation } from './lib/approval-elicitation.js';
4
4
  import { runWithToolContext } from './db.js';
5
+ import { TESTER_REQUEST_TOOLS } from './lib/tester-policy.js';
6
+ import { testerRequestTool } from './lib/tester-requests.js';
7
+ export { TESTER_REQUEST_TOOLS } from './lib/tester-policy.js';
5
8
  import { isApprovalGatedAction } from './lib/action-approvals.js';
6
9
  import { registerAgentMailboxTools } from './tools/agent-mailboxes.js';
7
10
  import { registerAnalyticsTools } from './tools/analytics.js';
@@ -203,14 +206,14 @@ export function agentConnectionRefusal(name) {
203
206
  export function isTesterConnection(scopes) {
204
207
  return !!scopes?.includes(TESTER_CONNECTION_SCOPE);
205
208
  }
206
- /** Tools a tester connection may see and call: reads, plus anything that goes through a person's approval. */
209
+ /** Tester reads, explicit setup requests and the existing campaign activation paths. */
207
210
  export function testerConnectionAllowsTool(name) {
208
- if (name === 'create_agent_mailbox')
209
- return false;
211
+ // Retain the existing campaign activation path. A globally gated tool is not by itself
212
+ // permission for a tester: sends, deletes and access/infrastructure writes stay refused.
210
213
  return getRequiredScope(name) === 'read'
211
- || name === 'send_broadcast'
214
+ || TESTER_REQUEST_TOOLS.has(name)
212
215
  || name === 'activate_campaigns'
213
- || isApprovalGatedAction(name);
216
+ || name === 'update_campaign_status';
214
217
  }
215
218
  /**
216
219
  * Tools whose own input already has a top-level `project_id` that means something other than "the project
@@ -222,11 +225,11 @@ export const PROJECT_ID_NATIVE_TOOLS = new Set(['set_active_project', 'get_outbo
222
225
  * Tools that need a stated project when the connection is switched away from its home project (#4033):
223
226
  * anything that goes through a person's approval, plus the two always-gated actions.
224
227
  */
225
- export function isProjectPinnedWriteTool(name) {
226
- return name === 'send_broadcast' || name === 'activate_campaigns' || isApprovalGatedAction(name);
228
+ export function isProjectPinnedWriteTool(name, scopes) {
229
+ return (isTesterConnection(scopes) && TESTER_REQUEST_TOOLS.has(name)) || name === 'send_broadcast' || name === 'activate_campaigns' || isApprovalGatedAction(name);
227
230
  }
228
231
  export function testerConnectionRefusal(name) {
229
- return `"${name}" is not available to a tester connection. A tester connection can read, and can ask a person to approve outward actions: call an approval-gated tool (for example send_broadcast) and a person approves it from the approve_url it returns. To change this tool directly, ask the workspace owner to promote the connection in Dashboard > Agent Connect.`;
232
+ return `"${name}" is not available to a tester connection. Testers can read and request human approval for campaign setup: ${[...TESTER_REQUEST_TOOLS].join(', ')}. update_site is limited to footer/unsubscribe fields. Every setup call returns approval_id, approve_url and preview_text; after a person approves, retry with only approval_id. Deletes, sends, access/role/key/team tools, provider credentials, imports and create_agent_mailbox are refused. Ask the workspace owner to promote the connection in Dashboard > Agent Connect for direct writes.`;
230
233
  }
231
234
  // Names of the meta-tools gateway mode registers in place of the real catalog
232
235
  // (see tools/gateway.ts). Grouped as 'gateway' rather than any real tool
@@ -350,13 +353,18 @@ function createScopeProxy(server, scopes, toolGroup, allowedTools) {
350
353
  // Don't register tools the key can't access — they won't appear in tool list
351
354
  return;
352
355
  }
356
+ if (tester && !agent && TESTER_REQUEST_TOOLS.has(name)) {
357
+ const wrapped = testerRequestTool(name, config, handler);
358
+ config = wrapped.config;
359
+ handler = wrapped.handler;
360
+ }
353
361
  // Wrap handler with runtime scope check (defense in depth)
354
362
  const guardedHandler = (...args) => {
355
363
  if (!allowed()) {
356
364
  const text = agent ? agentConnectionRefusal(name) : tester ? testerConnectionRefusal(name) : `Forbidden: this action requires "${required}" scope.`;
357
365
  return { content: [{ type: 'text', text }] };
358
366
  }
359
- return runWithToolContext(toolGroup, name, () => runWithApprovalElicitation(target, args[1], () => handler(...args)));
367
+ return runWithToolContext(toolGroup, name, () => runWithApprovalElicitation(target, args[1], () => handler(...args)), scopes);
360
368
  };
361
369
  return target.registerTool(name, config, guardedHandler);
362
370
  }
@@ -2279,17 +2279,18 @@ Do not include body_html in your response; Sendinel renders body_html from block
2279
2279
  idempotentHint: true,
2280
2280
  openWorldHint: false,
2281
2281
  },
2282
- description: 'Preview email through the real send renderer using sample contact data; unresolved_tags lists remaining tags. Provide content via step_id, draft_id, or raw body_html/subject.',
2282
+ description: 'Preview email through the real send renderer: final campaign/site footer, unsubscribe text and send_class scope, sender and physical address. Uses sample contact data; unresolved_tags lists remaining tags. Provide campaign_id, step_id, draft_id, or raw body_html/subject.',
2283
2283
  inputSchema: {
2284
2284
  site_id: z.string().uuid().optional().describe('Site UUID for sender tags and footers'),
2285
+ campaign_id: z.string().uuid().optional().describe('Campaign UUID; previews its first step in send order. Use step_id to choose a specific step.'),
2285
2286
  step_id: z.string().uuid().optional().describe('Campaign step UUID'),
2286
2287
  draft_id: z.string().uuid().optional().describe('AI draft UUID'),
2287
2288
  subject: z.string().optional().describe('Raw subject'),
2288
2289
  body_html: z.string().optional().describe('Raw HTML'),
2289
2290
  },
2290
- }, async ({ step_id, draft_id, subject, body_html, site_id }) => {
2291
+ }, async ({ campaign_id, step_id, draft_id, subject, body_html, site_id }) => {
2291
2292
  const result = await callSendinelV1('/api/v1/campaigns/preview', {
2292
- body: { step_id, draft_id, subject, body_html, site_id },
2293
+ body: { campaign_id, step_id, draft_id, subject, body_html, site_id },
2293
2294
  });
2294
2295
  return result.ok ? j(result.data) : j({ error: result.error, status: result.status });
2295
2296
  });
@@ -2961,7 +2962,7 @@ Return ONLY valid JSON with this exact shape:
2961
2962
  idempotentHint: false,
2962
2963
  openWorldHint: true,
2963
2964
  },
2964
- description: 'Create a sequence, broadcast or triggered campaign with pre-built email content in one call. Set trigger_event_name for dunning (invoice_payment_failed, trial_ending), trigger_tag, or date_field/contact_event trigger_config; events/tags and where filters are supported. Triggered campaigns need no segment. Returns fires_on and trigger_diagnostics. Adds email steps, optionally enrolls a segment, and activates behind standard approval. Ideal for orchestrators that draft their own content — skips AI generation. Uses two-call confirmation: first call returns a preview, second call with confirmed=true executes.',
2965
+ description: 'Create billing, dunning, trial ending or transactional mail with send_class=product (broadcast is the marketing default). Build a sequence, broadcast or triggered campaign with pre-built steps. Override unsubscribe text with unsub_footer_template. Set trigger_event_name for dunning (invoice_payment_failed, trial_ending), trigger_tag, or date_field/contact_event trigger_config; events/tags and where filters are supported. Triggered campaigns need no segment. Returns fires_on and trigger_diagnostics. Adds email steps, optionally enrolls a segment, and activates behind standard approval. Ideal for orchestrators that draft their own content — skips AI generation. Uses two-call confirmation: first call returns a preview, second call with confirmed=true executes.',
2965
2966
  inputSchema: {
2966
2967
  site_id: z.string().uuid().describe('Site to send from'),
2967
2968
  name: z.string().min(1).max(200).describe('Campaign name'),
@@ -3357,7 +3358,7 @@ Return ONLY valid JSON with this exact shape:
3357
3358
  server.registerTool('set_campaign_trigger', {
3358
3359
  title: 'Set Campaign Trigger',
3359
3360
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
3360
- description: 'Set or clear what a campaign fires on: trigger event name, tag, date_field, contact_event, or trigger_config events/tags and where event-data filter. Use for dunning on invoice_payment_failed or trial_ending. Drafts save directly; active/paused campaigns require standard human approval. Archived/completed campaigns cannot change. Returns saved trigger fields, fires_on and trigger_diagnostics.',
3361
+ description: 'Set or clear what a campaign fires on: trigger event name, tag, date_field, contact_event, or trigger_config events/tags and where event-data filter. Bind billing, payment failed, dunning or trial ending mail to an event trigger such as invoice_payment_failed or trial_ending. Drafts save directly; active/paused campaigns require standard human approval. Archived/completed campaigns cannot change. Returns saved trigger fields, fires_on and trigger_diagnostics.',
3361
3362
  inputSchema: {
3362
3363
  campaign_id: z.string().uuid().optional(),
3363
3364
  trigger_event_name: z.string().nullable().optional(),
@@ -13,7 +13,9 @@ export const STOPWORDS = new Set([
13
13
  */
14
14
  export const TOOL_ALIASES = {
15
15
  update_site: ['footer', 'email footer', 'unsubscribe text', 'unsubscribe link', 'site footer', 'unsubscribe settings'],
16
- set_campaign_trigger: ['trigger', 'event', 'fires on', 'fires-on', 'dunning', 'set campaign trigger event name', 'invoice payment failed', 'trial ending'],
16
+ set_campaign_trigger: ['trigger', 'event', 'fires on', 'fires-on', 'dunning', 'set campaign trigger event name', 'invoice payment failed', 'payment failed', 'trial ending', 'event trigger', 'billing'],
17
+ create_campaign_with_content: ['billing', 'dunning', 'trial ending', 'transactional', 'payment failed', 'product email', 'event-driven billing mail'],
18
+ preview_email: ['preview footer', 'footer', 'unsubscribe text', 'preview billing email', 'unsubscribe scope'],
17
19
  send_broadcast: [
18
20
  'send email', 'send an email', 'email blast', 'blast', 'newsletter', 'announce', 'announcement', 'one-off email',
19
21
  'email my list', 'send to my list', 'email to my list', 'email my subscribers',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.110",
3
+ "version": "1.0.112",
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",