@sendinel/mcp-server 1.0.130 → 1.0.132

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.
@@ -11,7 +11,7 @@ import { createHash } from 'node:crypto';
11
11
  import { approvalSetupSummary, buildApprovalPreview, isHumanApprovalChannel, triggerChangeSummary, decodeHtmlEntities, stripHtmlComments } from '@sendinel/domain';
12
12
  import { requestContext } from '../db.js';
13
13
  import { getProjectId } from '../project.js';
14
- import { captureApprovalSnapshot, withoutApprovalSnapshot } from './approval-snapshot.js';
14
+ import { captureApprovalSnapshot, withoutApprovalSnapshot, withoutApprovalDisplay } from './approval-snapshot.js';
15
15
  import { fireWebhookEvent } from './webhook-events.js';
16
16
  import { offerApprovalApp } from './approval-app.js';
17
17
  import { elicitApproval } from './approval-elicitation.js';
@@ -161,6 +161,9 @@ function launchSummary(p, d) {
161
161
  }
162
162
  /** Build a human-readable summary of the action for the approval screen */
163
163
  export function buildApprovalSummary(action, params, display = {}) {
164
+ if (params.__approval_snapshot?.display?.target_name) {
165
+ return buildApprovalPreview({ action, params }).plain_summary;
166
+ }
164
167
  const setup = approvalSetupSummary(action, params);
165
168
  if (setup)
166
169
  return setup;
@@ -425,7 +428,7 @@ export async function checkApproval(approvalId, options = {}) {
425
428
  if (data.params?.__approval_snapshot) {
426
429
  try {
427
430
  const current = await captureSnapshot(data.action, withoutApprovalSnapshot(data.params));
428
- if (!approvalParamsEqual(current, data.params.__approval_snapshot)) {
431
+ if (!approvalParamsEqual(withoutApprovalDisplay(current), withoutApprovalDisplay(data.params.__approval_snapshot))) {
429
432
  return { approved: false, status: 'target_changed_after_approval' };
430
433
  }
431
434
  }
@@ -1,4 +1,11 @@
1
1
  type Params = Record<string, any>;
2
+ /** Optional display facts only. Failures must never prevent requesting or using an approval. */
3
+ export declare function captureApprovalDisplay(action: string, params: Params, projectId: string): Promise<{
4
+ target_name: string;
5
+ target_detail?: string | undefined;
6
+ } | undefined>;
7
+ /** Exclude only optional display facts from revalidation, never from the payload hash. */
8
+ export declare function withoutApprovalDisplay(snapshot: Params): Params;
2
9
  /** Server-owned display facts, saved inside the hash-bound params. Never supplied by a tool. */
3
10
  export declare function captureApprovalSnapshot(action: string, params: Params, projectId: string): Promise<Params>;
4
11
  export declare function withoutApprovalSnapshot(params: unknown): Record<string, unknown>;
@@ -1,12 +1,141 @@
1
1
  import { isTesterRequest } from './tester-policy.js';
2
2
  import { captureTesterRequestSnapshot } from './tester-request-snapshot.js';
3
- import { readAllRows, chunkIds, scopeSegmentRulesToSite } from '@sendinel/domain';
3
+ import { readAllRows, chunkIds, scopeSegmentRulesToSite, approvalDisplayFact, parseLinkedInCommentUrl } from '@sendinel/domain';
4
4
  import { callSendinelV1 } from './v1-client.js';
5
- import { db } from '../db.js';
5
+ import { adminDb, db } from '../db.js';
6
6
  import { resolveSendAudience, resolveSegmentContactIds } from './send-audience.js';
7
7
  const TARGET_ACTIONS = new Set(['send_broadcast', 'broadcast_to_segment', 'launch_campaign', 'activate_campaigns',
8
8
  'update_campaign_status', 'set_campaign_trigger', 'create_campaign_with_content', 'enroll_segment',
9
9
  'send_transactional', 'send_awaiting_reply', 'send_test_email', 'send_newsletter_issue', 'schedule_newsletter_issue']);
10
+ // Columns and scope paths verified against the SQL migrations; never select credentials.
11
+ const DISPLAY_TARGETS = {
12
+ delete_segment: ['segments', 'segment_id', 'name'],
13
+ delete_template: ['campaign_templates', 'id', 'name'],
14
+ delete_signature: ['signature_blocks', 'id', 'name'],
15
+ delete_campaign: ['campaigns', 'campaign_id', 'name'],
16
+ delete_content_block: ['content_blocks', 'block_id', 'name'],
17
+ delete_custom_field: ['custom_field_definitions', 'id', 'label', true],
18
+ delete_landing_page: ['landing_pages', 'page_id', 'title'],
19
+ delete_list: ['contact_lists', 'list_id', 'name'],
20
+ delete_social_post: ['social_posts', 'post_id', 'title', true],
21
+ delete_subscriber_view: ['subscriber_list_views', 'view_id', 'name'],
22
+ };
23
+ const MEMBER_ACTIONS = new Set(['remove_team_member', 'set_team_member_role', 'set_member_project_access']);
24
+ /** Optional display facts only. Failures must never prevent requesting or using an approval. */
25
+ export async function captureApprovalDisplay(action, params, projectId) {
26
+ try {
27
+ if (!DISPLAY_TARGETS[action] && !MEMBER_ACTIONS.has(action) && ![
28
+ 'revoke_api_key', 'revoke_invitation', 'rotate_custom_webhook_secret',
29
+ 'delete_webhook_subscription', 'update_webhook_subscription',
30
+ 'delete_campaign_step', 'update_campaign_step', 'reply_to_comment',
31
+ ].includes(action))
32
+ return undefined;
33
+ const source = db();
34
+ const client = typeof source.schema === 'function' ? source.schema('email') : source;
35
+ const read = async (query) => {
36
+ const { data, error } = await query.maybeSingle();
37
+ return error ? null : data;
38
+ };
39
+ const fact = (value, max = 45) => {
40
+ const text = approvalDisplayFact(value);
41
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
42
+ };
43
+ const facts = (name, detail) => {
44
+ const target_name = fact(name);
45
+ const target_detail = fact(detail, 25);
46
+ return target_name ? { target_name, ...(target_detail ? { target_detail } : {}) } : undefined;
47
+ };
48
+ // Org rows have no project_id: derive their scope from the current project first.
49
+ const projectOrg = async () => {
50
+ const project = await read(client.from('projects').select('org_id').eq('id', projectId));
51
+ return typeof project?.org_id === 'string' && (!params.org_id || params.org_id === project.org_id)
52
+ ? project.org_id : null;
53
+ };
54
+ const target = DISPLAY_TARGETS[action];
55
+ if (target) {
56
+ const [table, param, column, orgScoped] = target;
57
+ if (typeof params[param] !== 'string')
58
+ return undefined;
59
+ let query = client.from(table).select(column).eq('id', params[param]).eq('project_id', projectId);
60
+ if (orgScoped) {
61
+ const orgId = await projectOrg();
62
+ if (!orgId)
63
+ return undefined;
64
+ query = query.eq('org_id', orgId);
65
+ }
66
+ return facts((await read(query))?.[column]);
67
+ }
68
+ if (action === 'revoke_api_key') {
69
+ const key = await read(client.from('api_keys').select('id, name')
70
+ .eq('id', params.key_id).eq('project_id', projectId));
71
+ // The UUID suffix is public identity, never key material, a secret or a hash.
72
+ return key ? facts(key.name, `…${String(key.id).slice(-4)}`) : undefined;
73
+ }
74
+ if (MEMBER_ACTIONS.has(action) || action === 'revoke_invitation') {
75
+ const orgId = await projectOrg();
76
+ if (!orgId)
77
+ return undefined;
78
+ if (action === 'revoke_invitation') {
79
+ return facts((await read(client.from('org_invitations').select('email')
80
+ .eq('id', params.invitation_id).eq('org_id', orgId)))?.email);
81
+ }
82
+ const member = await read(client.from('org_members').select('user_id')
83
+ .eq('user_id', params.user_id).eq('org_id', orgId));
84
+ if (!member)
85
+ return undefined;
86
+ // Only read Auth after proving this user belongs to the project's organization.
87
+ const { data, error } = await adminDb().auth.admin.getUserById(member.user_id);
88
+ return error ? undefined : facts(data?.user?.email);
89
+ }
90
+ if (action === 'delete_campaign_step' || action === 'update_campaign_step') {
91
+ // Steps inherit project scope from their campaign; inner join scopes the read itself.
92
+ const step = await read(client.from('campaign_steps')
93
+ .select('step_order, subject, campaign:campaigns!inner(name)')
94
+ .eq('id', params.step_id).eq('campaign.project_id', projectId));
95
+ if (!step)
96
+ return undefined;
97
+ const name = fact(step.campaign?.name, 25);
98
+ const number = Number.isInteger(step.step_order) && step.step_order >= 0 ? `email ${step.step_order + 1}` : 'email';
99
+ return facts(name ? `${name}: ${number}` : '', step.subject);
100
+ }
101
+ if (action === 'reply_to_comment') {
102
+ const { postUrn, commentUrn } = parseLinkedInCommentUrl(params.comment_url_or_urn);
103
+ const orgId = await projectOrg();
104
+ if (!orgId)
105
+ return undefined;
106
+ // Upload Post stores its returned id verbatim: it may be a full URN, a
107
+ // share-link URN or a bare comment id. The thread/project/org still bind it.
108
+ const commentIds = [...new Set([commentUrn, commentUrn.replace('(urn:li:', '('),
109
+ commentUrn.match(/,(\d+)\)$/)?.[1]].filter((id) => typeof id === 'string'))];
110
+ const comment = await read(client.from('social_engagements').select('sender_name, body')
111
+ .eq('project_id', projectId).eq('org_id', orgId).eq('platform', 'linkedin')
112
+ .eq('external_thread_id', postUrn).in('external_message_id', commentIds));
113
+ return comment ? facts([fact(comment.sender_name, 18), fact(comment.body, 40)].filter(Boolean).join(': ')) : undefined;
114
+ }
115
+ if (action === 'rotate_custom_webhook_secret') {
116
+ const orgId = await projectOrg();
117
+ if (!orgId)
118
+ return undefined;
119
+ return facts((await read(client.from('platform_connections').select('display_name')
120
+ .eq('id', params.connection_id).eq('project_id', projectId).eq('org_id', orgId)
121
+ .eq('platform', 'custom')))?.display_name);
122
+ }
123
+ const webhook = await read(client.from('webhook_subscriptions').select('description, url')
124
+ .eq('id', params.subscription_id).eq('project_id', projectId));
125
+ if (!webhook)
126
+ return undefined;
127
+ return facts(fact(webhook.description) || new URL(webhook.url).hostname);
128
+ }
129
+ catch {
130
+ // Optional names cannot change approval eligibility or target validation.
131
+ return undefined;
132
+ }
133
+ }
134
+ /** Exclude only optional display facts from revalidation, never from the payload hash. */
135
+ export function withoutApprovalDisplay(snapshot) {
136
+ const { display: _display, ...rest } = snapshot;
137
+ return rest;
138
+ }
10
139
  /** Server-owned display facts, saved inside the hash-bound params. Never supplied by a tool. */
11
140
  export async function captureApprovalSnapshot(action, params, projectId) {
12
141
  if (action === 'invite_team_member') {
@@ -17,10 +146,12 @@ export async function captureApprovalSnapshot(action, params, projectId) {
17
146
  }
18
147
  return { version: 1, invitation: preview, recipient_count: 1 };
19
148
  }
149
+ const display = await captureApprovalDisplay(action, params, projectId);
150
+ const withDisplay = (value) => display ? { ...value, display } : value;
20
151
  const setup = isTesterRequest(action) ? await captureTesterRequestSnapshot(action, params, projectId) : null;
21
152
  const snapshot = { version: 1, campaigns: [], steps: [], sites: [], recipient_count: null };
22
153
  if (!TARGET_ACTIONS.has(action))
23
- return setup ?? snapshot;
154
+ return withDisplay(setup ?? snapshot);
24
155
  const source = db();
25
156
  const client = typeof source.schema === 'function' ? source.schema('email') : source;
26
157
  const body = params.payload ?? params;
@@ -110,7 +241,7 @@ export async function captureApprovalSnapshot(action, params, projectId) {
110
241
  }
111
242
  snapshot.campaigns.sort((a, b) => String(a.id).localeCompare(String(b.id)));
112
243
  snapshot.steps.sort((a, b) => String(a.id).localeCompare(String(b.id)));
113
- return setup ? { ...snapshot, ...setup, recipient_count: snapshot.recipient_count, audience_snapshot: snapshot } : snapshot;
244
+ return withDisplay(setup ? { ...snapshot, ...setup, recipient_count: snapshot.recipient_count, audience_snapshot: snapshot } : snapshot);
114
245
  }
115
246
  export function withoutApprovalSnapshot(params) {
116
247
  const { __approval_snapshot: _snapshot, ...rest } = (params ?? {});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.130",
3
+ "version": "1.0.132",
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",