@sendinel/mcp-server 1.0.231 → 1.0.233

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,5 +1,6 @@
1
1
  import { type ApprovalClass, type ApprovalLevel } from '@sendinel/domain';
2
2
  import { type HumanDecisionIdentity } from './approval-human-eligibility.js';
3
+ import { type PersonConnectionGate } from './credential-gate-table.js';
3
4
  export { APPROVAL_CLASSES, approvalClass } from '@sendinel/domain';
4
5
  export type { ApprovalClass, ApprovalLevel } from '@sendinel/domain';
5
6
  export type EffectiveApproval = {
@@ -8,6 +9,8 @@ export type EffectiveApproval = {
8
9
  class: ApprovalClass | undefined;
9
10
  level: ApprovalLevel;
10
11
  applied: boolean;
12
+ /** #4857: set only when the table's person column let a Default-level person call through without a click. */
13
+ personGate?: PersonConnectionGate;
11
14
  };
12
15
  /** No transport-supplied role or preference grants authority: verify the live person grant. */
13
16
  export declare function personApprovalLevel(ctx?: HumanDecisionIdentity | undefined, action?: string): Promise<ApprovalLevel | null>;
@@ -1,8 +1,11 @@
1
- import { approvalClass, isApprovalLevel, matchSendAllowlist, readContactTestStatus } from '@sendinel/domain';
1
+ import { approvalClass, campaignMatchesTrigger, isApprovalLevel, matchSendAllowlist, normalizeTriggerList, readContactTestStatus } from '@sendinel/domain';
2
2
  import { adminDb, requestContext } from '../db.js';
3
3
  import { getProjectId } from '../project.js';
4
4
  import { humanApprovalEligible } from './approval-human-eligibility.js';
5
5
  import { getApprovalTier } from './action-approvals.js';
6
+ import { isHumanRequestableAdminTool } from './admin-tools.js';
7
+ import { personConnectionGate } from './credential-gate-table.js';
8
+ import { sandboxAutoApproveUnavailableReason } from './sandbox-approvals.js';
6
9
  export { APPROVAL_CLASSES, approvalClass } from '@sendinel/domain';
7
10
  /** No transport-supplied role or preference grants authority: verify the live person grant. */
8
11
  export async function personApprovalLevel(ctx = requestContext(), action = 'get_approval_level') {
@@ -75,6 +78,71 @@ async function exemptTargets(action, params, ctx) {
75
78
  return false;
76
79
  }
77
80
  }
81
+ /**
82
+ * #4857: would this event enroll anyone? Mirrors event intake: active triggered campaigns in this
83
+ * project, matched on the event, its data and the contact's stored tags. True only when every read
84
+ * succeeded and nothing matched; any error, short page or unexpected value is false (keep the prompt).
85
+ */
86
+ async function eventMatchesNoSendingCampaign(params) {
87
+ const email = typeof params.email === 'string' ? params.email.trim().toLowerCase() : '';
88
+ const event = typeof params.event === 'string' ? params.event.trim() : '';
89
+ const data = params.data;
90
+ if (!email || !event || (data !== undefined && (data === null || typeof data !== 'object' || Array.isArray(data))))
91
+ return false;
92
+ const client = adminDb().schema('email');
93
+ const projectId = getProjectId();
94
+ const contact = await client.from('subscribers').select('tags').eq('project_id', projectId).eq('email', email).maybeSingle();
95
+ if (contact.error)
96
+ return false;
97
+ // Intake matches on stored tags plus request tags; the MCP tool sends none today, but any it ever
98
+ // forwards are included here, and a malformed value keeps the prompt.
99
+ if (params.tags !== undefined && !Array.isArray(params.tags))
100
+ return false;
101
+ const tags = normalizeTriggerList([...(Array.isArray(contact.data?.tags) ? contact.data.tags : []), ...(Array.isArray(params.tags) ? params.tags : [])]);
102
+ const campaigns = [];
103
+ let total = -1;
104
+ for (let page = 0; page < 100 && campaigns.length !== total; page++) {
105
+ const { data: rows, error, count } = await client.from('campaigns')
106
+ .select('id, trigger_event_name, trigger_tag, trigger_config', { count: 'exact' })
107
+ .eq('project_id', projectId).eq('status', 'active').eq('type', 'triggered')
108
+ .order('id', { ascending: true }).range(campaigns.length, campaigns.length + 999);
109
+ if (error || !Array.isArray(rows) || typeof count !== 'number' || (total !== -1 && count !== total))
110
+ return false;
111
+ total = count;
112
+ campaigns.push(...rows);
113
+ if (!rows.length || campaigns.length > total)
114
+ break;
115
+ }
116
+ if (campaigns.length !== total)
117
+ return false;
118
+ return !campaigns.some(campaign => campaignMatchesTrigger(campaign, { event, tags, data: (data ?? null) }));
119
+ }
120
+ /**
121
+ * #4857: the person column of CREDENTIAL_GATE_TABLE. Returns the gate that lets this one call run
122
+ * without a click, or null to keep today's prompt. Never for admin request-only tools; only on a
123
+ * project that reads as not a sandbox (sandbox auto-approve stays as it is).
124
+ */
125
+ async function personGateReason(action, params, ctx) {
126
+ const gate = personConnectionGate(action);
127
+ if (!gate || isHumanRequestableAdminTool(action))
128
+ return null;
129
+ try {
130
+ if (action === 'set_link_domain' && params.publish_dns !== undefined && params.publish_dns !== false)
131
+ return null;
132
+ if (gate === 'allowlisted_recipients' && (typeof params.to !== 'string' || params.cc !== undefined || params.bcc !== undefined))
133
+ return null;
134
+ if (await sandboxAutoApproveUnavailableReason(getProjectId(), action) !== 'not_sandbox')
135
+ return null;
136
+ if (gate === 'config')
137
+ return gate;
138
+ if (gate === 'allowlisted_recipients')
139
+ return await safeAddresses([params.to], ctx) ? gate : null;
140
+ return await eventMatchesNoSendingCampaign(params) ? gate : null;
141
+ }
142
+ catch {
143
+ return null;
144
+ }
145
+ }
78
146
  /** One decision function. Lookup errors preserve today's tier, including preview exceptions. */
79
147
  export async function effectiveApproval(action, params = {}, ctx = requestContext()) {
80
148
  const baseline = getApprovalTier(action, params) !== 'autonomous';
@@ -87,20 +155,23 @@ export async function effectiveApproval(action, params = {}, ctx = requestContex
87
155
  kind = 'S';
88
156
  // Live trigger edits have an additional domain gate even under the legacy autonomous mode.
89
157
  const triggerGate = ['update_campaign', 'set_campaign_trigger'].includes(action) && !!params.previous_campaign && !!params.updates;
90
- const prompt = level === 'ask_every_time' ? true : level === 'auto' ? (kind === 'F' || kind === 'D') : (baseline || triggerGate) && !exempt;
91
- return { prompt, class: kind, level, applied: true,
158
+ // #4857: Default level only; Ask every time and Auto keep their own rules.
159
+ const personGate = level === 'default' && baseline && !triggerGate && !exempt ? await personGateReason(action, params, ctx) : null;
160
+ const prompt = level === 'ask_every_time' ? true : level === 'auto' ? (kind === 'F' || kind === 'D') : (baseline || triggerGate) && !exempt && !personGate;
161
+ return { prompt, class: kind, level, applied: true, ...(personGate ? { personGate } : {}),
92
162
  channelRule: level === 'ask_every_time' && (kind === 'S' || kind === 'F' || (kind === 'E' && !exempt)) ? 'human_only' : 'any' };
93
163
  }
94
164
  /** Audit must succeed before a level can authorize execution. Never turn an audit failure into a send. */
95
165
  export async function auditApprovalLevel(action, decision) {
96
- if (decision.prompt || !decision.applied || (decision.level === 'default' && decision.class !== 'T' && decision.class !== 'E'))
166
+ if (decision.prompt || !decision.applied || (decision.level === 'default' && decision.class !== 'T' && decision.class !== 'E' && !decision.personGate))
97
167
  return;
98
168
  const ctx = requestContext();
99
169
  const { error } = await adminDb().schema('email').from('audit_log').insert({
100
170
  project_id: getProjectId(), actor_type: 'user', actor_id: ctx.actorId,
101
171
  action: 'approval.level_applied', resource_type: 'approval',
102
172
  details: { action, class: decision.class, level: decision.level, user_id: ctx.actorId,
103
- connection_id: `oauth:${ctx.oauthTokenId}`, reason: 'level' },
173
+ connection_id: `oauth:${ctx.oauthTokenId}`, reason: decision.personGate ? 'person_gate' : 'level',
174
+ ...(decision.personGate ? { gate: decision.personGate, policy: 'credential_gate_table' } : {}) },
104
175
  });
105
176
  if (error)
106
177
  throw new Error('Could not record your approval level. Nothing was run.');
@@ -20,13 +20,48 @@
20
20
  *
21
21
  * This table decides only whether a person must click. It never decides whether a credential
22
22
  * may call a tool at all: agentConnectionAllowsTool / testerConnectionAllowsTool still refuse
23
- * every delete, send, access and import tool for those connections. Person connections, API
24
- * keys and service credentials do not use this table.
23
+ * every delete, send, access and import tool for those connections. API keys and service
24
+ * credentials do not use this table. Person connections read only the `person` column of a row
25
+ * (#4857, below).
25
26
  */
26
27
  export type CredentialGate = 'autonomous' | 'draft_only' | 'gated' | 'protected';
27
- export declare const CREDENTIAL_GATE_TABLE: Readonly<Record<string, CredentialGate>>;
28
+ /**
29
+ * #4857 person connections: the person column of the same table. Kevin, 2026-11-12 (applying his
30
+ * 11-04 rule): a person's own connection gets NO prompt except for sends to real outside people
31
+ * and deletes/data loss; sends only to the org's own test/allowlisted addresses need no prompt.
32
+ *
33
+ * A row is either a tier string (the person tier is unchanged) or { credential, person }:
34
+ * credential is the agent/tester tier above, person names the check below. The person column is
35
+ * read only for a verified person-role OAuth connection at the Default approval level
36
+ * (effectiveApproval in approval-classes.ts), only on a project that reads as NOT a sandbox, and
37
+ * never for one of the admin request-only tools. Each person value names the tool's gated form and the
38
+ * check that lets one call through without a click; every check fails closed (a read error or a
39
+ * missing value keeps today's prompt) and every call it lets through writes an
40
+ * approval.level_applied audit row with reason "person_gate" before it runs:
41
+ *
42
+ * event_no_send_match no prompt when the event matches no active triggered campaign (read
43
+ * with the intake matcher and the contact's stored tags); a match keeps
44
+ * the existing recipient check (self, allowlist or saved test contact)
45
+ * allowlisted_recipients no prompt when every recipient is the person's login email or is on a
46
+ * loaded projects.send_allowlist / organizations.send_allowlist
47
+ * config a settings change that cannot send to a person or delete data
48
+ * (set_link_domain only without publish_dns: true)
49
+ *
50
+ * A row without a person column keeps today's person tier (getApprovalTier plus the #4436 level rules).
51
+ * Drafts, steps, consented single contacts and other config writes already need no person click.
52
+ */
53
+ export type PersonConnectionGate = 'event_no_send_match' | 'allowlisted_recipients' | 'config';
54
+ export type CredentialGateRow = {
55
+ readonly credential: CredentialGate;
56
+ readonly person: PersonConnectionGate;
57
+ };
58
+ export declare const CREDENTIAL_GATE_TABLE: Readonly<Record<string, CredentialGate | CredentialGateRow>>;
28
59
  /** Undefined means unclassified, which every caller must treat as gated. */
29
60
  export declare function credentialGateClass(tool: string): CredentialGate | undefined;
61
+ /** #4857: the person column of a row. Undefined means keep the person's existing tier. */
62
+ export declare function personConnectionGate(tool: string): PersonConnectionGate | undefined;
63
+ /** Every row with a person column, derived from the one table (tests and docs). */
64
+ export declare function personConnectionGateRows(): Record<string, PersonConnectionGate>;
30
65
  /**
31
66
  * Param forms of autonomous/draft_only tester setup tools that still need a person. Each returns a
32
67
  * reason when gated. A value that is not exactly the safe value is gated (fail closed).
@@ -38,7 +38,7 @@ export const CREDENTIAL_GATE_TABLE = Object.freeze({
38
38
  discard_staged_import: 'gated', // discards uploaded data (data loss)
39
39
  disconnect_platform: 'gated',
40
40
  draft_agent_reply: 'gated',
41
- emit_contact_event: 'gated',
41
+ emit_contact_event: { credential: 'gated', person: 'event_no_send_match' }, // "Saves a contact only. No email is sent." must not ask
42
42
  enroll_automation: 'gated',
43
43
  enroll_contact: 'gated',
44
44
  enroll_in_escalation_ladder: 'gated',
@@ -51,7 +51,7 @@ export const CREDENTIAL_GATE_TABLE = Object.freeze({
51
51
  invite_team_member: 'gated',
52
52
  launch_campaign: 'gated',
53
53
  publish_dmarc_record: 'gated',
54
- recheck_domain_dns: 'gated',
54
+ recheck_domain_dns: { credential: 'gated', person: 'config' }, // live DNS re-check; sends nothing, deletes nothing
55
55
  remove_blackout_date: 'gated', // can allow sends on a blocked date
56
56
  remove_campaign_branch: 'gated', // removes saved config (delete)
57
57
  remove_campaign_goal: 'gated', // removes saved config (delete)
@@ -78,10 +78,10 @@ export const CREDENTIAL_GATE_TABLE = Object.freeze({
78
78
  send_sms: 'gated',
79
79
  send_sms_campaign: 'gated',
80
80
  send_test_email: 'gated',
81
- send_transactional: 'gated',
81
+ send_transactional: { credential: 'gated', person: 'allowlisted_recipients' }, // one `to`, no cc/bcc; an outside address keeps one prompt
82
82
  set_connector_mapping: 'gated',
83
83
  set_contact_sms_consent: 'gated',
84
- set_link_domain: 'gated',
84
+ set_link_domain: { credential: 'gated', person: 'config' }, // branded link host only; publish_dns: true writes into a DNS zone and keeps the prompt
85
85
  set_member_project_access: 'gated',
86
86
  set_subscriber_tags: 'gated', // a tag can trigger campaigns that send
87
87
  set_team_member_role: 'gated',
@@ -365,7 +365,24 @@ export const CREDENTIAL_GATE_TABLE = Object.freeze({
365
365
  });
366
366
  /** Undefined means unclassified, which every caller must treat as gated. */
367
367
  export function credentialGateClass(tool) {
368
- return Object.prototype.hasOwnProperty.call(CREDENTIAL_GATE_TABLE, tool) ? CREDENTIAL_GATE_TABLE[tool] : undefined;
368
+ if (!Object.prototype.hasOwnProperty.call(CREDENTIAL_GATE_TABLE, tool))
369
+ return undefined;
370
+ const row = CREDENTIAL_GATE_TABLE[tool];
371
+ return typeof row === 'string' ? row : row.credential;
372
+ }
373
+ /** #4857: the person column of a row. Undefined means keep the person's existing tier. */
374
+ export function personConnectionGate(tool) {
375
+ if (!Object.prototype.hasOwnProperty.call(CREDENTIAL_GATE_TABLE, tool))
376
+ return undefined;
377
+ const row = CREDENTIAL_GATE_TABLE[tool];
378
+ return typeof row === 'string' ? undefined : row.person;
379
+ }
380
+ /** Every row with a person column, derived from the one table (tests and docs). */
381
+ export function personConnectionGateRows() {
382
+ return Object.fromEntries(Object.keys(CREDENTIAL_GATE_TABLE).flatMap(tool => {
383
+ const gate = personConnectionGate(tool);
384
+ return gate ? [[tool, gate]] : [];
385
+ }));
369
386
  }
370
387
  /**
371
388
  * Param forms of autonomous/draft_only tester setup tools that still need a person. Each returns a
@@ -605,7 +605,7 @@ async function exportSubscriberData(projectId, contactId) {
605
605
  export function registerContactTools(server) {
606
606
  server.registerTool('emit_contact_event', {
607
607
  title: 'Emit Contact Event',
608
- description: 'Record a contact event through POST /api/v1/events and enroll matching active triggered campaigns. Can start real sends; requires standard approval. Returns the REST outcome and sendability unchanged. Does not subscribe the contact or update contact properties.',
608
+ description: 'Record a contact event through POST /api/v1/events and enroll matching active triggered campaigns. Can start real sends; requires standard approval when it matches a sending campaign (a person connection is not asked when nothing matches). Returns the REST outcome and sendability unchanged. Does not subscribe the contact or update contact properties.',
609
609
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
610
610
  inputSchema: {
611
611
  approval_id: z.string().uuid().optional().describe('Call once to request approval, then call again with only approval_id.'),
@@ -172,7 +172,7 @@ export function registerDeliveryOpsTools(server) {
172
172
  idempotentHint: true,
173
173
  openWorldHint: true,
174
174
  },
175
- description: 'Perform a live provider/DNS verification via the domains:write API and persist domain status and Elastic Email tracking observations. Returns Sendinel readiness; raw EE TrackingStatus is advisory and may remain Broken even when ready. Standard approval applies: call without approval_id, then repeat with the same domain_id and the approved approval_id. Failed checks return an error, not a readiness verdict.',
175
+ description: 'Perform a live provider/DNS verification via the domains:write API and persist domain status and Elastic Email tracking observations. Returns Sendinel readiness; raw EE TrackingStatus is advisory and may remain Broken even when ready. Standard approval applies (a person connection at the Default level runs it without a click): call without approval_id, then repeat with the same domain_id and the approved approval_id. Failed checks return an error, not a readiness verdict.',
176
176
  inputSchema: {
177
177
  domain_id: z.string().uuid(),
178
178
  approval_id: z.string().uuid().optional().describe('Call without this to request approval, then repeat with the approved approval_id.'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.231",
3
+ "version": "1.0.233",
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",