@sendinel/mcp-server 1.0.187 → 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;
@@ -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.187",
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",