@sendinel/mcp-server 1.0.117 → 1.0.118

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.
@@ -117,7 +117,7 @@ export function getApprovalTier(action, params) {
117
117
  }
118
118
  // send_broadcast exists for agents and always needs a person's approval, even in autonomous
119
119
  // mode: it must never approve itself (#3669, AI-worker boundary in CLAUDE.md).
120
- if (action === 'draft_agent_reply' || action === 'create_agent_mailbox' || action === 'send_broadcast' || action === 'activate_campaigns')
120
+ if (action === 'draft_agent_reply' || action === 'create_agent_mailbox' || action === 'send_broadcast' || action === 'activate_campaigns' || action === 'invite_team_member')
121
121
  return 'gated';
122
122
  // Autonomous mode: user explicitly opted out of approval gates for gated actions
123
123
  // (request-scoped under HTTP — env is only trustworthy for single-project stdio)
@@ -289,6 +289,10 @@ export function buildApprovalSummary(action, params, display = {}) {
289
289
  add_list_members: (p) => `Add ${p.contact_ids?.length ?? 0} contact(s) to list ${p.list_id}`,
290
290
  remove_list_members: (p) => `Remove ${p.contact_ids?.length ?? 0} contact(s) from list ${p.list_id}`,
291
291
  add_list_members_by_filter: (p) => `Add contacts matching filter rules to list ${p.list_id}`,
292
+ invite_team_member: (p) => {
293
+ const invite = p.__approval_snapshot?.invitation;
294
+ return `Send invitation to ${p.email} as ${p.role}; project_ids: ${JSON.stringify(p.project_ids ?? null)} (null = org-wide, [] = no projects); organization: ${invite?.org_name ?? 'unavailable'} (${invite?.org_id ?? 'unavailable'}); subject: "${invite?.subject ?? 'unavailable'}"`;
295
+ },
292
296
  remove_team_member: (p) => `Remove team member ${p.user_id} from the organization`,
293
297
  set_team_member_role: (p) => `Change team member ${p.user_id}'s role to ${p.role}`,
294
298
  delete_provider_credential: (p) => `Delete saved ${p.provider ?? 'provider'} credential ${p.credential_id} (${p.source})`,
@@ -1,6 +1,7 @@
1
1
  import { isTesterRequest } from './tester-policy.js';
2
2
  import { captureTesterRequestSnapshot } from './tester-request-snapshot.js';
3
3
  import { readAllRows, chunkIds, scopeSegmentRulesToSite } from '@sendinel/domain';
4
+ import { callSendinelV1 } from './v1-client.js';
4
5
  import { db } from '../db.js';
5
6
  import { resolveSendAudience, resolveSegmentContactIds } from './send-audience.js';
6
7
  const TARGET_ACTIONS = new Set(['send_broadcast', 'broadcast_to_segment', 'launch_campaign', 'activate_campaigns',
@@ -8,6 +9,14 @@ const TARGET_ACTIONS = new Set(['send_broadcast', 'broadcast_to_segment', 'launc
8
9
  'send_transactional', 'send_awaiting_reply', 'send_test_email', 'send_newsletter_issue', 'schedule_newsletter_issue']);
9
10
  /** Server-owned display facts, saved inside the hash-bound params. Never supplied by a tool. */
10
11
  export async function captureApprovalSnapshot(action, params, projectId) {
12
+ if (action === 'invite_team_member') {
13
+ const result = await callSendinelV1('/api/v1/org/invitations', { method: 'POST', body: { ...params, preview_only: true } });
14
+ const preview = result.ok ? result.data.preview : null;
15
+ if (!preview || typeof preview.org_id !== 'string' || typeof preview.org_name !== 'string' || typeof preview.subject !== 'string') {
16
+ throw new Error('Invitation approval preview unavailable; no email was sent.');
17
+ }
18
+ return { version: 1, invitation: preview, recipient_count: 1 };
19
+ }
11
20
  const setup = isTesterRequest(action) ? await captureTesterRequestSnapshot(action, params, projectId) : null;
12
21
  const snapshot = { version: 1, campaigns: [], steps: [], sites: [], recipient_count: null };
13
22
  if (!TARGET_ACTIONS.has(action))
@@ -1,5 +1,4 @@
1
1
  import { readPlanUsage, readAllRows } from "@sendinel/domain";
2
- import { randomBytes } from 'crypto';
3
2
  import { z } from 'zod';
4
3
  import { logAudit } from '../audit.js';
5
4
  import { adminDb, db } from '../db.js';
@@ -174,21 +173,20 @@ export function registerOrgTools(server) {
174
173
  idempotentHint: false,
175
174
  openWorldHint: false,
176
175
  },
177
- description: 'Create a team invitation for the current project organization.',
178
- inputSchema: { approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'), email: z.string().email(), role: z.enum(['owner', 'admin', 'member']).default('member') },
179
- }, withApproval('invite_team_member', async ({ email, role }) => {
180
- const { data: project } = await db().from('projects').select('org_id').eq('id', getProjectId()).maybeSingle();
181
- if (!project?.org_id)
182
- return j({ error: 'Project has no organization', code: 'NO_ORG' });
183
- const token = randomBytes(16).toString('hex');
184
- const expiresAt = new Date(Date.now() + 7 * 86400000).toISOString();
185
- const { data, error } = await db().from('org_invitations')
186
- .insert({ org_id: project.org_id, email, role, token, expires_at: expiresAt })
187
- .select('id, expires_at')
188
- .single();
189
- if (error)
190
- return j({ error: error.message });
191
- return j({ invitation_id: data.id, expires_at: data.expires_at });
176
+ description: 'Invite a team member by email to the current project organization using the system invitation template. Reuses a matching pending invitation. Always requires human approval; request once, then repeat identical email, role and project_ids with approval_id. Owner invitations are unsupported by the API.',
177
+ inputSchema: {
178
+ approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with the same arguments plus this approval_id to run the action.'),
179
+ email: z.string().email(),
180
+ role: z.enum(['owner', 'admin', 'member', 'tester']).default('member'),
181
+ project_ids: z.array(z.string().uuid()).optional().describe('Same-org project IDs. Omit for org-wide access; [] grants no projects. Must match an existing pending invitation.'),
182
+ },
183
+ }, withApproval('invite_team_member', async (args) => {
184
+ const result = await callSendinelV1('/api/v1/org/invitations', { method: 'POST', body: args });
185
+ if (!result.ok)
186
+ return j({ error: result.error, status: result.status });
187
+ const data = result.data;
188
+ // Preserve the original top-level response fields; add the API's send outcome.
189
+ return j({ ...data, invitation_id: data.invitation.id, expires_at: data.invitation.expires_at });
192
190
  }));
193
191
  server.registerTool('remove_team_member', {
194
192
  title: 'Remove Team Member',
@@ -443,7 +441,7 @@ export function registerOrgTools(server) {
443
441
  idempotentHint: false,
444
442
  openWorldHint: false,
445
443
  },
446
- description: "Extend a pending invitation's expiry and return its invite URL. Does not send an email from the MCP server -- relay invite_url to the invitee directly.",
444
+ description: "Extend a pending invitation's expiry and return its invite URL. Link-only: sends no email. Use invite_team_member with the same email, role and project_ids to request an approval-gated template email.",
447
445
  inputSchema: {
448
446
  invitation_id: z.string().uuid(),
449
447
  org_id: z.string().uuid().optional(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.117",
3
+ "version": "1.0.118",
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",