@sendinel/mcp-server 1.0.186 → 1.0.188

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.
@@ -119,6 +119,8 @@ type ToolResult = {
119
119
  }>;
120
120
  isError?: boolean;
121
121
  };
122
+ /** Validate live targets before any approval is requested or consumed, including registry gates. */
123
+ export declare function registerApprovalPreflight(action: string, fn: (params: Record<string, unknown>) => Promise<ToolResult | null | undefined>): void;
122
124
  export declare function approvalSatisfied(action: string): boolean;
123
125
  /** Catch preview/draft branches that historically returned before their hand-written gate.
124
126
  * Existing gated execution paths retain their frozen-payload approval flow. */
@@ -608,6 +608,11 @@ async function checkApprovalState(approvalId, options = {}) {
608
608
  ...(sandboxDecision ? { decision_channel: 'sandbox' } : {}),
609
609
  };
610
610
  }
611
+ const approvalPreflights = new Map();
612
+ /** Validate live targets before any approval is requested or consumed, including registry gates. */
613
+ export function registerApprovalPreflight(action, fn) {
614
+ approvalPreflights.set(action, fn);
615
+ }
611
616
  function toolText(value) {
612
617
  return { content: [{ type: 'text', text: value }] };
613
618
  }
@@ -663,6 +668,11 @@ export function withApproval(action, handler, options = {}) {
663
668
  const decision = await assertChannelCanExecute(action, params, channelContext);
664
669
  if (!decision.allowed)
665
670
  return toolText(decision.reason ?? 'This channel cannot request this action.');
671
+ // Use exactly the payload passed to requestApproval/checkApproval. Expected failures
672
+ // return toolError; unexpected exceptions reach the normal internal_error boundary.
673
+ const preflightResult = await approvalPreflights.get(action)?.(params);
674
+ if (preflightResult)
675
+ return preflightResult;
666
676
  let decidedBy;
667
677
  let decisionChannel;
668
678
  let consumedAt;
@@ -65,6 +65,19 @@ export const HOW_TO_RECIPES = [
65
65
  gotchas: [projectRule, 'An empty list means there is no campaign to report. Distinguish drafts and zero sends from failed reads. Raw total_opened/total_clicked are event counters, not unique people.'],
66
66
  done_when: 'The chosen campaign and its unique engagement counts are reported, with unknown counts and incomplete sends explained.',
67
67
  },
68
+ {
69
+ id: 'analytics-summary', title: 'Summarize project email analytics',
70
+ when_to_use: 'Review sender-level email performance and recent campaigns for one project.',
71
+ synonyms: ['analytics summary', 'pull an analytics summary', 'analytics for my project', 'stats summary', 'project stats', 'performance overview', 'how are my emails doing'],
72
+ requires: { scopes: ['read'], tester_ok: true },
73
+ steps: [
74
+ { n: 1, tool: 'get_sites', args_example: {}, note: 'Choose the sender for the project whose analytics you want to review.' },
75
+ { n: 2, tool: 'get_stats', args_example: { site_id: from(1, '0.id'), start_date: '2026-10-01' }, note: 'Replace start_date with the beginning of the requested reporting period; end_date defaults to today.' },
76
+ { n: 3, tool: 'list_campaigns', args_example: { site_id: from(1, '0.id'), limit: 25 }, note: 'Use campaign metrics to add context to sender-wide performance; newest-created first, not newest-sent.' },
77
+ ],
78
+ gotchas: [projectRule, 'get_stats is sender-wide for the selected site, not campaign-specific. Distinguish drafts from sent campaigns and call out the reporting period.'],
79
+ done_when: 'Summarize the selected sender’s metrics and reporting period, with recent campaign context and unknowns called out.',
80
+ },
68
81
  {
69
82
  id: 'add-or-import-contacts', title: 'Add a contact or import contacts',
70
83
  when_to_use: 'Subscribe a single contact to a sender, or bulk import an opted-in list.',
@@ -2,7 +2,7 @@ import { z } from 'zod';
2
2
  import { checkAgentMailboxLimit, validateMailboxDisplayName } from '@sendinel/domain';
3
3
  import { db, requestContext } from '../db.js';
4
4
  import { getProjectId } from '../project.js';
5
- import { withApproval, approvalExcerpt } from '../lib/action-approvals.js';
5
+ import { withApproval, approvalExcerpt, registerApprovalPreflight } from '../lib/action-approvals.js';
6
6
  import { toolError, formatZodError } from '../lib/db-error.js';
7
7
  import { checked, mailboxDb, mailboxAudit, messageOutput, resolveMailbox, sendAgentReply, visibleMessage, visibleMessages, MailboxError } from '../lib/agent-mailbox.js';
8
8
  const j = (value) => ({ content: [{ type: 'text', text: JSON.stringify(value) }] });
@@ -51,12 +51,39 @@ const draftReplySchema = z.object({
51
51
  const createMailboxSchema = z.object({
52
52
  local_part: z.string().toLowerCase().regex(/^[a-z0-9][a-z0-9._+-]{0,63}$/),
53
53
  domain: z.string().toLowerCase().regex(/^agents\.[a-z0-9.-]+\.[a-z]{2,}$/),
54
- display_name: z.string().max(200).regex(/^[^\r\n\u0000]*$/).optional(),
54
+ display_name: z.string().max(200).refine(value => validateMailboxDisplayName(value).valid, { message: 'Display name must be plain text without HTML, control characters, or bidirectional formatting characters.' }).optional(),
55
55
  mcp_connection_id: z.string().uuid(),
56
56
  site_id: z.string().uuid().optional(),
57
57
  daily_send_cap: z.number().int().positive().optional().default(50),
58
58
  approval_id,
59
59
  });
60
+ async function createMailboxPreflight(params) {
61
+ const actorId = requestContext()?.actorId;
62
+ if (!actorId)
63
+ return toolError({ code: 'forbidden', error: 'An authenticated admin login is required to create a mailbox' });
64
+ const projectId = getProjectId();
65
+ const { data: tokenRow, error: tokenErr } = await mailboxDb().from('mcp_oauth_tokens')
66
+ .select('id, expires_at, revoked_at, user_id, project_id')
67
+ .eq('id', params.mcp_connection_id).eq('project_id', projectId).eq('user_id', actorId)
68
+ .is('revoked_at', null).maybeSingle();
69
+ if (tokenErr)
70
+ throw tokenErr;
71
+ const tokenExpired = !tokenRow || (tokenRow.expires_at != null &&
72
+ (!Number.isFinite(Date.parse(tokenRow.expires_at)) || Date.parse(tokenRow.expires_at) <= Date.now()));
73
+ if (tokenExpired) {
74
+ return toolError({ code: 'not_found', error: 'No active OAuth connection with that id for this login in this project.' });
75
+ }
76
+ if (params.site_id) {
77
+ const { data: siteRow, error: siteErr } = await mailboxDb().from('sites').select('id')
78
+ .eq('id', params.site_id).eq('project_id', projectId).maybeSingle();
79
+ if (siteErr)
80
+ throw siteErr;
81
+ if (!siteRow)
82
+ return toolError({ code: 'not_found', error: 'Site not found in this project.' });
83
+ }
84
+ return null;
85
+ }
86
+ registerApprovalPreflight('create_agent_mailbox', createMailboxPreflight);
60
87
  export function registerAgentMailboxTools(server) {
61
88
  server.registerTool('list_agent_inbox', {
62
89
  title: 'List Agent Inbox', description: 'Check your bound agent mailbox inbox. Unread first, then newest; filter by thread or status. Returns metadata and short text previews.' + outside,
@@ -173,9 +200,7 @@ export function registerAgentMailboxTools(server) {
173
200
  server.registerTool('create_agent_mailbox', {
174
201
  title: 'Create Agent Mailbox', description: 'Admin: create an agent mailbox inbox on an agents subdomain and bind it to an active OAuth connection owned by this login in this project. Atomically restricts that connection to the agent role. Optional site_id is its sending anchor; no site means replies fail. Extra scope grants are a later slice.' + gate,
175
202
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
176
- inputSchema: { local_part: z.string().toLowerCase().regex(/^[a-z0-9][a-z0-9._+-]{0,63}$/),
177
- domain: z.string().toLowerCase().regex(/^agents\.[a-z0-9.-]+\.[a-z]{2,}$/), display_name: z.string().max(200).regex(/^[^\r\n\u0000]*$/).optional(),
178
- mcp_connection_id: z.string().uuid(), site_id: z.string().uuid().optional(), daily_send_cap: z.number().int().positive().optional().default(50), approval_id },
203
+ inputSchema: createMailboxSchema.shape,
179
204
  }, wrapMailboxTool(async (args, extra) => {
180
205
  if (!args?.approval_id) {
181
206
  const missing = [];
@@ -207,27 +232,9 @@ export function registerAgentMailboxTools(server) {
207
232
  : `This project's plan allows ${limit.limit} agent mailbox${limit.limit === 1 ? '' : 'es'} and it already has ${limit.current}.`,
208
233
  }), isError: true };
209
234
  }
210
- // Verify mcp_connection_id before issuing approval
211
- const projectId = getProjectId();
212
- const tokenQuery = mailboxDb().from('mcp_oauth_tokens').select('id, expires_at, revoked_at, user_id, project_id')
213
- .eq('id', args.mcp_connection_id)
214
- .eq('project_id', projectId)
215
- .eq('user_id', actorId)
216
- .is('revoked_at', null);
217
- const { data: tokenRow, error: tokenErr } = await tokenQuery.maybeSingle();
218
- const tokenExpired = !tokenRow || (tokenRow.expires_at && Date.parse(tokenRow.expires_at) <= Date.now());
219
- if (tokenErr || tokenExpired) {
220
- return toolError({ code: 'not_found', error: 'No active OAuth connection with that id for this login in this project.' });
221
- }
222
- if (args.site_id) {
223
- const { data: siteRow, error: siteErr } = await mailboxDb().from('sites').select('id')
224
- .eq('id', args.site_id)
225
- .eq('project_id', projectId)
226
- .maybeSingle();
227
- if (siteErr || !siteRow) {
228
- return toolError({ code: 'not_found', error: 'Site not found in this project.' });
229
- }
230
- }
235
+ const preflight = await createMailboxPreflight(args);
236
+ if (preflight)
237
+ return preflight;
231
238
  const boundArgs = { ...args, login_actor_id: actorId };
232
239
  return withApproval('create_agent_mailbox', async (_approved) => {
233
240
  const mailbox = checked(await mailboxDb().rpc('provision_agent_mailbox', { p_project_id: getProjectId(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.186",
3
+ "version": "1.0.188",
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",