@sendinel/mcp-server 1.0.10 → 1.0.12

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.
Files changed (60) hide show
  1. package/README.md +35 -22
  2. package/build/anthropic-model.d.ts +30 -0
  3. package/build/anthropic-model.js +26 -1
  4. package/build/db.d.ts +17 -0
  5. package/build/db.js +15 -1
  6. package/build/index.js +6 -0
  7. package/build/lib/action-approvals.js +28 -0
  8. package/build/lib/brand-asset-persist.d.ts +1 -0
  9. package/build/lib/brand-asset-persist.js +20 -1
  10. package/build/lib/composer-blocks.d.ts +25 -25
  11. package/build/lib/composer-blocks.js +25 -5
  12. package/build/lib/connector-table-discovery.d.ts +12 -0
  13. package/build/lib/connector-table-discovery.js +117 -0
  14. package/build/lib/custom-properties-diff.d.ts +16 -0
  15. package/build/lib/custom-properties-diff.js +22 -0
  16. package/build/lib/schedule.d.ts +7 -2
  17. package/build/lib/schedule.js +8 -0
  18. package/build/lib/v1-client.d.ts +3 -0
  19. package/build/lib/v1-client.js +45 -0
  20. package/build/lib/voice.d.ts +7 -30
  21. package/build/lib/voice.js +6 -138
  22. package/build/prompts.d.ts +6 -0
  23. package/build/prompts.js +18 -0
  24. package/build/scoped-token.d.ts +1 -1
  25. package/build/server.d.ts +1 -0
  26. package/build/server.js +65 -6
  27. package/build/tools/advisor.js +3 -3
  28. package/build/tools/analytics.d.ts +1 -0
  29. package/build/tools/analytics.js +105 -8
  30. package/build/tools/brand-kit.js +29 -30
  31. package/build/tools/campaigns.js +349 -60
  32. package/build/tools/cloud-import.d.ts +2 -0
  33. package/build/tools/cloud-import.js +76 -0
  34. package/build/tools/compound.d.ts +11 -0
  35. package/build/tools/compound.js +37 -7
  36. package/build/tools/connector.d.ts +2 -0
  37. package/build/tools/connector.js +280 -0
  38. package/build/tools/contacts.js +744 -16
  39. package/build/tools/content.js +228 -0
  40. package/build/tools/data-proposals.js +1 -1
  41. package/build/tools/data.js +448 -4
  42. package/build/tools/delivery-ops.js +525 -7
  43. package/build/tools/forms.js +7 -6
  44. package/build/tools/gateway.d.ts +6 -0
  45. package/build/tools/gateway.js +92 -0
  46. package/build/tools/gdpr.js +185 -0
  47. package/build/tools/landing-pages.d.ts +2 -0
  48. package/build/tools/landing-pages.js +202 -0
  49. package/build/tools/lists.d.ts +2 -0
  50. package/build/tools/lists.js +428 -0
  51. package/build/tools/newsletters.js +1 -8
  52. package/build/tools/org.js +565 -35
  53. package/build/tools/segments.js +35 -184
  54. package/build/tools/signatures.d.ts +2 -0
  55. package/build/tools/signatures.js +168 -0
  56. package/build/tools/sites.js +3 -1
  57. package/build/tools/skills.d.ts +2 -0
  58. package/build/tools/skills.js +29 -0
  59. package/build/tools/templates.js +133 -7
  60. package/package.json +13 -12
@@ -1,38 +1,14 @@
1
1
  import { randomBytes } from 'crypto';
2
2
  import { z } from 'zod';
3
3
  import { logAudit } from '../audit.js';
4
- import { db } from '../db.js';
4
+ import { adminDb, db } from '../db.js';
5
5
  import { getProjectId } from '../project.js';
6
+ import { assertChannelCanExecute, checkApproval, currentChannelContext, requestApproval } from '../lib/action-approvals.js';
7
+ import { callSendinelV1 } from '../lib/v1-client.js';
6
8
  const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
7
9
  const PROVIDER_SWITCH_V1_PROVIDERS = ['resend', 'ses', 'elastic-email'];
8
10
  async function callProviderSwitchApi(path, init) {
9
- const apiKey = process.env.SENDINEL_API_KEY;
10
- if (!apiKey)
11
- return { ok: false, error: 'SENDINEL_API_KEY is required to drive provider-switch tools from MCP' };
12
- const base = (process.env.NEXT_PUBLIC_APP_URL ?? process.env.SENDINEL_APP_URL ?? process.env.APP_URL ?? 'https://sendinel.ai').replace(/\/$/, '');
13
- const res = await fetch(`${base}${path}`, {
14
- ...init,
15
- headers: {
16
- Authorization: `Bearer ${apiKey}`,
17
- 'X-Sendinel-Project-Id': getProjectId(),
18
- ...(init?.headers ?? {}),
19
- },
20
- });
21
- const body = await res.text();
22
- if (!res.ok) {
23
- try {
24
- return { ok: false, error: JSON.parse(body) };
25
- }
26
- catch {
27
- return { ok: false, error: body || `HTTP ${res.status}` };
28
- }
29
- }
30
- try {
31
- return { ok: true, data: JSON.parse(body) };
32
- }
33
- catch {
34
- return { ok: true, data: body };
35
- }
11
+ return callSendinelV1(path, init);
36
12
  }
37
13
  const PLAN_LIMITS = {
38
14
  free: { contacts: 250, monthly_sends: 500, sites: 1, ai_drafts: 5, log_retention_days: 30, white_label: false },
@@ -41,16 +17,57 @@ const PLAN_LIMITS = {
41
17
  'agency-plus': { contacts: -1, monthly_sends: 500000, sites: -1, ai_drafts: -1, log_retention_days: -1, white_label: true },
42
18
  enterprise: { contacts: -1, monthly_sends: 250000, sites: 50, ai_drafts: -1, log_retention_days: -1, white_label: true },
43
19
  };
20
+ const DANGEROUS_AUDIT_ACTIONS = new Set([
21
+ 'contact.deleted',
22
+ 'contact.deleted_gdpr',
23
+ 'campaign.deleted',
24
+ 'draft.rejected',
25
+ 'segment.deleted',
26
+ 'api_key.revoked',
27
+ ]);
28
+ const isDangerousAuditAction = (action) => DANGEROUS_AUDIT_ACTIONS.has(action || '') || /deleted|revoked|gdpr/i.test(action || '');
29
+ function addDaysUtc(date, days) {
30
+ const value = new Date(`${date}T00:00:00.000Z`);
31
+ value.setUTCDate(value.getUTCDate() + days);
32
+ return value.toISOString().slice(0, 10);
33
+ }
34
+ function dateSpanDays(from, to) {
35
+ const start = new Date(`${from}T00:00:00.000Z`).getTime();
36
+ const end = new Date(`${to}T00:00:00.000Z`).getTime();
37
+ if (!Number.isFinite(start) || !Number.isFinite(end))
38
+ return 1;
39
+ return Math.max(1, Math.round((end - start) / 86400000) + 1);
40
+ }
44
41
  const whiteLabelSettingsSchema = z.object({
45
42
  logo_url: z.string().url().nullable().optional(),
46
43
  favicon_url: z.string().url().nullable().optional(),
47
44
  brand_colors: z.record(z.string(), z.unknown()).optional(),
45
+ font_choice: z.enum(['space_grotesk', 'system', 'serif']).nullable().optional(),
48
46
  product_name: z.string().trim().min(1).max(80).nullable().optional(),
49
47
  remove_sendinel_branding: z.boolean().optional(),
50
48
  custom_from_identity: z.string().trim().min(1).max(255).nullable().optional(),
51
49
  sign_in_branding: z.record(z.string(), z.unknown()).optional(),
52
50
  support_url: z.string().url().nullable().optional(),
53
51
  });
52
+ const memberProjectAssignmentSchema = z.object({
53
+ project_id: z.string().uuid(),
54
+ role: z.enum(['owner', 'admin', 'member']).default('member'),
55
+ });
56
+ async function resolveCurrentOrgId(requestedOrgId) {
57
+ const { data: project, error } = await db()
58
+ .from('projects')
59
+ .select('org_id')
60
+ .eq('id', getProjectId())
61
+ .maybeSingle();
62
+ if (error)
63
+ return { ok: false, error: error.message };
64
+ if (!project?.org_id)
65
+ return { ok: false, error: 'Project has no organization', code: 'NO_ORG' };
66
+ if (requestedOrgId && requestedOrgId !== project.org_id) {
67
+ return { ok: false, error: 'Organization does not belong to the current project', code: 'ORG_MISMATCH' };
68
+ }
69
+ return { ok: true, orgId: project.org_id };
70
+ }
54
71
  function normalizeHost(host) {
55
72
  if (!host)
56
73
  return null;
@@ -86,6 +103,7 @@ function formatWhiteLabelSettings(row) {
86
103
  logo_url: row.logo_url ?? null,
87
104
  favicon_url: row.favicon_url ?? null,
88
105
  brand_colors: row.brand_colors && typeof row.brand_colors === 'object' ? row.brand_colors : {},
106
+ font_choice: row.font_choice ?? null,
89
107
  product_name: row.product_name ?? null,
90
108
  remove_sendinel_branding: row.remove_sendinel_branding === true,
91
109
  custom_from_identity: row.custom_from_identity ?? null,
@@ -128,10 +146,24 @@ export function registerOrgTools(server) {
128
146
  }
129
147
  if (!orgId)
130
148
  return j({ members: [] });
131
- const { data, error } = await db().from('org_members').select('user_id, email, role, created_at').eq('org_id', orgId);
149
+ const { data, error } = await db().from('org_members').select('user_id, role, created_at').eq('org_id', orgId);
132
150
  if (error)
133
151
  return j({ error: error.message });
134
- return j({ members: (data ?? []).map((member) => ({ user_id: member.user_id, email: member.email ?? null, role: member.role, joined_at: member.created_at })) });
152
+ const membersWithUsers = await Promise.all((data ?? []).map(async (member) => {
153
+ const { data: userData, error: userError } = await adminDb().auth.admin.getUserById(member.user_id);
154
+ return { member, user: userData?.user ?? null, error: userError };
155
+ }));
156
+ const userError = membersWithUsers.find((result) => result.error)?.error;
157
+ if (userError)
158
+ return j({ error: userError.message });
159
+ return j({
160
+ members: membersWithUsers.map(({ member, user }) => ({
161
+ user_id: member.user_id,
162
+ email: user?.email ?? null,
163
+ role: member.role,
164
+ joined_at: member.created_at,
165
+ })),
166
+ });
135
167
  });
136
168
  server.registerTool('invite_team_member', {
137
169
  title: 'Invite Team Member',
@@ -157,6 +189,343 @@ export function registerOrgTools(server) {
157
189
  return j({ error: error.message });
158
190
  return j({ invitation_id: data.id, expires_at: data.expires_at });
159
191
  });
192
+ server.registerTool('remove_team_member', {
193
+ title: 'Remove Team Member',
194
+ annotations: {
195
+ readOnlyHint: false,
196
+ destructiveHint: true,
197
+ idempotentHint: true,
198
+ openWorldHint: false,
199
+ },
200
+ description: 'Remove a member from the project organization. Cannot remove the last owner. Uses two-call confirmation plus approval before executing.',
201
+ inputSchema: {
202
+ user_id: z.string().uuid().describe('Auth user id of the member to remove, as returned by list_team_members.'),
203
+ org_id: z.string().uuid().optional(),
204
+ confirmed: z.boolean().optional().default(false).describe('Two-call confirmation. First call previews, second call with confirmed=true executes.'),
205
+ approval_id: z.string().uuid().optional().describe('Approval token from a previous confirmed call without approval_id. Required when the action is not autonomous.'),
206
+ },
207
+ }, async ({ user_id, org_id, confirmed, approval_id }) => {
208
+ const context = await resolveCurrentOrgId(org_id);
209
+ if (!context.ok)
210
+ return j({ error: context.error, code: context.code });
211
+ const { data: member, error: memberErr } = await db()
212
+ .from('org_members')
213
+ .select('id, user_id, role')
214
+ .eq('org_id', context.orgId)
215
+ .eq('user_id', user_id)
216
+ .maybeSingle();
217
+ if (memberErr)
218
+ return j({ error: memberErr.message });
219
+ if (!member)
220
+ return j({ error: 'Member not found', code: 'NOT_FOUND' });
221
+ const approvalParams = { user_id, org_id: context.orgId };
222
+ if (!confirmed) {
223
+ return j({
224
+ confirmation_required: true,
225
+ tool: 'remove_team_member',
226
+ member: { user_id: member.user_id, role: member.role },
227
+ message: `Removing this member (role: ${member.role}) revokes their access to org ${context.orgId}. Call again with confirmed=true to proceed.`,
228
+ });
229
+ }
230
+ if (member.role === 'owner') {
231
+ const { count } = await db()
232
+ .from('org_members')
233
+ .select('id', { count: 'exact', head: true })
234
+ .eq('org_id', context.orgId)
235
+ .eq('role', 'owner');
236
+ if ((count ?? 0) <= 1)
237
+ return j({ error: 'Cannot remove the last owner', code: 'LAST_OWNER' });
238
+ }
239
+ const channelContext = currentChannelContext();
240
+ const decision = assertChannelCanExecute('remove_team_member', approvalParams, channelContext);
241
+ if (!decision.allowed)
242
+ return j({ error: decision.reason ?? 'This channel cannot request this action.' });
243
+ if (decision.tier !== 'autonomous') {
244
+ if (!approval_id)
245
+ return j(await requestApproval('remove_team_member', approvalParams, undefined, channelContext));
246
+ const check = await checkApproval(approval_id, { consume: true, action: 'remove_team_member', params: approvalParams });
247
+ if (!check.approved) {
248
+ return j({
249
+ error: `Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`,
250
+ code: `APPROVAL_${check.status.toUpperCase()}`,
251
+ });
252
+ }
253
+ }
254
+ const { error } = await db()
255
+ .from('org_members')
256
+ .delete()
257
+ .eq('org_id', context.orgId)
258
+ .eq('user_id', user_id);
259
+ if (error)
260
+ return j({ error: error.message });
261
+ void logAudit({
262
+ action: 'remove_member',
263
+ resourceType: 'org_member',
264
+ resourceId: member.id,
265
+ details: { org_id: context.orgId, removed_user: user_id, role: member.role },
266
+ }).catch(() => { });
267
+ return j({ ok: true, removed_user_id: user_id });
268
+ });
269
+ server.registerTool('set_team_member_role', {
270
+ title: 'Set Team Member Role',
271
+ annotations: {
272
+ readOnlyHint: false,
273
+ destructiveHint: false,
274
+ idempotentHint: true,
275
+ openWorldHint: false,
276
+ },
277
+ description: "Change a team member's role within the project organization. Uses two-call confirmation plus approval before executing.",
278
+ inputSchema: {
279
+ user_id: z.string().uuid(),
280
+ role: z.enum(['owner', 'admin', 'member']),
281
+ org_id: z.string().uuid().optional(),
282
+ confirmed: z.boolean().optional().default(false),
283
+ approval_id: z.string().uuid().optional(),
284
+ },
285
+ }, async ({ user_id, role, org_id, confirmed, approval_id }) => {
286
+ const context = await resolveCurrentOrgId(org_id);
287
+ if (!context.ok)
288
+ return j({ error: context.error, code: context.code });
289
+ const { data: member, error: memberErr } = await db()
290
+ .from('org_members')
291
+ .select('id, user_id, role')
292
+ .eq('org_id', context.orgId)
293
+ .eq('user_id', user_id)
294
+ .maybeSingle();
295
+ if (memberErr)
296
+ return j({ error: memberErr.message });
297
+ if (!member)
298
+ return j({ error: 'Member not found', code: 'NOT_FOUND' });
299
+ const approvalParams = { user_id, role, org_id: context.orgId };
300
+ if (!confirmed) {
301
+ return j({
302
+ confirmation_required: true,
303
+ tool: 'set_team_member_role',
304
+ member: { user_id: member.user_id, current_role: member.role, new_role: role },
305
+ message: `This changes ${user_id}'s role from "${member.role}" to "${role}". Call again with confirmed=true to proceed.`,
306
+ });
307
+ }
308
+ const channelContext = currentChannelContext();
309
+ const decision = assertChannelCanExecute('set_team_member_role', approvalParams, channelContext);
310
+ if (!decision.allowed)
311
+ return j({ error: decision.reason ?? 'This channel cannot request this action.' });
312
+ if (decision.tier !== 'autonomous') {
313
+ if (!approval_id)
314
+ return j(await requestApproval('set_team_member_role', approvalParams, undefined, channelContext));
315
+ const check = await checkApproval(approval_id, { consume: true, action: 'set_team_member_role', params: approvalParams });
316
+ if (!check.approved) {
317
+ return j({
318
+ error: `Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`,
319
+ code: `APPROVAL_${check.status.toUpperCase()}`,
320
+ });
321
+ }
322
+ }
323
+ const { data, error } = await db()
324
+ .from('org_members')
325
+ .update({ role })
326
+ .eq('org_id', context.orgId)
327
+ .eq('user_id', user_id)
328
+ .select('user_id, role')
329
+ .maybeSingle();
330
+ if (error)
331
+ return j({ error: error.message });
332
+ void logAudit({
333
+ action: 'update_member_role',
334
+ resourceType: 'org_member',
335
+ resourceId: member.id,
336
+ details: { org_id: context.orgId, user_id, new_role: role },
337
+ }).catch(() => { });
338
+ return j({ member: data });
339
+ });
340
+ server.registerTool('set_member_project_access', {
341
+ title: 'Set Member Project Access',
342
+ annotations: {
343
+ readOnlyHint: false,
344
+ destructiveHint: false,
345
+ idempotentHint: true,
346
+ openWorldHint: false,
347
+ },
348
+ description: "Replace a team member's per-project access within the org (full replace, not a merge). Org owners have implicit access to all projects and cannot be scoped this way.",
349
+ inputSchema: {
350
+ user_id: z.string().uuid(),
351
+ projects: z.array(memberProjectAssignmentSchema),
352
+ org_id: z.string().uuid().optional(),
353
+ },
354
+ }, async ({ user_id, projects, org_id }) => {
355
+ const context = await resolveCurrentOrgId(org_id);
356
+ if (!context.ok)
357
+ return j({ error: context.error, code: context.code });
358
+ const { data: membership, error: membershipErr } = await db()
359
+ .from('org_members')
360
+ .select('role')
361
+ .eq('org_id', context.orgId)
362
+ .eq('user_id', user_id)
363
+ .maybeSingle();
364
+ if (membershipErr)
365
+ return j({ error: membershipErr.message });
366
+ if (!membership)
367
+ return j({ error: 'Member not found', code: 'NOT_FOUND' });
368
+ if (membership.role === 'owner') {
369
+ return j({ error: 'Org owners have implicit access to all projects', code: 'IMPLICIT_ACCESS' });
370
+ }
371
+ const { data: orgProjects, error: orgProjectsErr } = await db()
372
+ .from('projects')
373
+ .select('id')
374
+ .eq('org_id', context.orgId);
375
+ if (orgProjectsErr)
376
+ return j({ error: orgProjectsErr.message });
377
+ const validIds = new Set((orgProjects ?? []).map((project) => project.id));
378
+ const assignmentMap = new Map();
379
+ for (const project of projects)
380
+ assignmentMap.set(project.project_id, project.role);
381
+ const requestedProjects = Array.from(assignmentMap, ([project_id, role]) => ({ project_id, role }));
382
+ const requestedIds = requestedProjects.map((project) => project.project_id);
383
+ const invalidIds = requestedIds.filter((id) => !validIds.has(id));
384
+ if (invalidIds.length > 0)
385
+ return j({ error: `Invalid project IDs: ${invalidIds.join(', ')}`, code: 'INVALID_PROJECT' });
386
+ const idsToRemove = Array.from(validIds).filter((id) => !assignmentMap.has(id));
387
+ if (idsToRemove.length > 0) {
388
+ const { error: deleteErr } = await db()
389
+ .from('user_project_access')
390
+ .delete()
391
+ .eq('user_id', user_id)
392
+ .in('project_id', idsToRemove);
393
+ if (deleteErr)
394
+ return j({ error: deleteErr.message });
395
+ }
396
+ if (requestedProjects.length > 0) {
397
+ const rows = requestedProjects.map((project) => ({ user_id, project_id: project.project_id, role: project.role }));
398
+ const { error: upsertErr } = await db()
399
+ .from('user_project_access')
400
+ .upsert(rows, { onConflict: 'user_id,project_id' });
401
+ if (upsertErr)
402
+ return j({ error: upsertErr.message });
403
+ }
404
+ void logAudit({
405
+ action: 'set_member_project_access',
406
+ resourceType: 'org_member',
407
+ resourceId: user_id,
408
+ details: { org_id: context.orgId, project_ids: requestedIds },
409
+ }).catch(() => { });
410
+ return j({ ok: true, user_id, project_ids: requestedIds });
411
+ });
412
+ server.registerTool('list_invitations', {
413
+ title: 'List Invitations',
414
+ annotations: {
415
+ readOnlyHint: true,
416
+ destructiveHint: false,
417
+ idempotentHint: true,
418
+ openWorldHint: false,
419
+ },
420
+ description: 'List pending (unaccepted, unexpired) invitations for the project organization.',
421
+ inputSchema: { org_id: z.string().uuid().optional() },
422
+ }, async ({ org_id }) => {
423
+ const context = await resolveCurrentOrgId(org_id);
424
+ if (!context.ok)
425
+ return j({ error: context.error, code: context.code });
426
+ const { data, error } = await db()
427
+ .from('org_invitations')
428
+ .select('id, email, role, invited_by, created_at, expires_at, project_ids, project_roles')
429
+ .eq('org_id', context.orgId)
430
+ .is('accepted_at', null)
431
+ .gt('expires_at', new Date().toISOString());
432
+ if (error)
433
+ return j({ error: error.message });
434
+ return j({ invitations: data ?? [] });
435
+ });
436
+ server.registerTool('resend_invitation', {
437
+ title: 'Resend Invitation',
438
+ annotations: {
439
+ readOnlyHint: false,
440
+ destructiveHint: false,
441
+ idempotentHint: false,
442
+ openWorldHint: false,
443
+ },
444
+ 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.",
445
+ inputSchema: {
446
+ invitation_id: z.string().uuid(),
447
+ org_id: z.string().uuid().optional(),
448
+ },
449
+ }, async ({ invitation_id, org_id }) => {
450
+ const context = await resolveCurrentOrgId(org_id);
451
+ if (!context.ok)
452
+ return j({ error: context.error, code: context.code });
453
+ const { data: invitation, error: invitationErr } = await db()
454
+ .from('org_invitations')
455
+ .select('id, email, role, token, expires_at')
456
+ .eq('id', invitation_id)
457
+ .eq('org_id', context.orgId)
458
+ .is('accepted_at', null)
459
+ .gt('expires_at', new Date().toISOString())
460
+ .maybeSingle();
461
+ if (invitationErr)
462
+ return j({ error: invitationErr.message });
463
+ if (!invitation)
464
+ return j({ error: 'Invitation not found', code: 'NOT_FOUND' });
465
+ const expiresAt = new Date(Date.now() + 7 * 86400000).toISOString();
466
+ const { data: updated, error: updateErr } = await db()
467
+ .from('org_invitations')
468
+ .update({ expires_at: expiresAt })
469
+ .eq('id', invitation_id)
470
+ .eq('org_id', context.orgId)
471
+ .is('accepted_at', null)
472
+ .select('id')
473
+ .maybeSingle();
474
+ if (updateErr)
475
+ return j({ error: updateErr.message });
476
+ if (!updated)
477
+ return j({ error: 'Invitation not found', code: 'NOT_FOUND' });
478
+ const appUrl = (process.env.NEXT_PUBLIC_APP_URL ?? 'https://sendinel.ai').replace(/\/$/, '');
479
+ return j({
480
+ invitation_id: invitation.id,
481
+ email: invitation.email,
482
+ role: invitation.role,
483
+ expires_at: expiresAt,
484
+ invite_url: `${appUrl}/invite/${invitation.token}`,
485
+ });
486
+ });
487
+ server.registerTool('revoke_invitation', {
488
+ title: 'Revoke Invitation',
489
+ annotations: {
490
+ readOnlyHint: false,
491
+ destructiveHint: true,
492
+ idempotentHint: true,
493
+ openWorldHint: false,
494
+ },
495
+ description: 'Revoke a pending org invitation before it is accepted.',
496
+ inputSchema: {
497
+ invitation_id: z.string().uuid(),
498
+ org_id: z.string().uuid().optional(),
499
+ },
500
+ }, async ({ invitation_id, org_id }) => {
501
+ const context = await resolveCurrentOrgId(org_id);
502
+ if (!context.ok)
503
+ return j({ error: context.error, code: context.code });
504
+ const { data: invitation, error: invitationErr } = await db()
505
+ .from('org_invitations')
506
+ .select('id, email, role, accepted_at')
507
+ .eq('id', invitation_id)
508
+ .eq('org_id', context.orgId)
509
+ .maybeSingle();
510
+ if (invitationErr)
511
+ return j({ error: invitationErr.message });
512
+ if (!invitation || invitation.accepted_at)
513
+ return j({ error: 'Invitation not found', code: 'NOT_FOUND' });
514
+ const { error } = await db()
515
+ .from('org_invitations')
516
+ .delete()
517
+ .eq('id', invitation_id)
518
+ .eq('org_id', context.orgId);
519
+ if (error)
520
+ return j({ error: error.message });
521
+ void logAudit({
522
+ action: 'invitation.revoked',
523
+ resourceType: 'org_invitation',
524
+ resourceId: invitation_id,
525
+ details: { org_id: context.orgId, email: invitation.email, role: invitation.role },
526
+ }).catch(() => { });
527
+ return j({ ok: true });
528
+ });
160
529
  server.registerTool('get_plan_usage', {
161
530
  title: 'Get Plan Usage',
162
531
  annotations: {
@@ -197,6 +566,168 @@ export function registerOrgTools(server) {
197
566
  description: 'Return plan limits for all plan tiers.',
198
567
  inputSchema: {},
199
568
  }, async () => j(PLAN_LIMITS));
569
+ server.registerTool('list_audit_log', {
570
+ title: 'List Audit Log',
571
+ annotations: {
572
+ readOnlyHint: true,
573
+ destructiveHint: false,
574
+ idempotentHint: true,
575
+ openWorldHint: false,
576
+ },
577
+ description: 'List audit log entries for the project, filterable by action, actor, resource, and date range. Audit details match the dashboard response and may contain personal data, so this tool requires admin scope.',
578
+ inputSchema: {
579
+ action: z.string().optional(),
580
+ resource_type: z.string().optional(),
581
+ resource_id: z.string().uuid().optional(),
582
+ actor_id: z.string().optional(),
583
+ date_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Inclusive, UTC, YYYY-MM-DD'),
584
+ date_to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Inclusive, UTC, YYYY-MM-DD'),
585
+ limit: z.number().int().min(1).max(100).optional().default(50),
586
+ offset: z.number().int().min(0).optional().default(0),
587
+ },
588
+ }, async ({ action, resource_type, resource_id, actor_id, date_from, date_to, limit, offset }) => {
589
+ const projectId = getProjectId();
590
+ const effectiveLimit = limit ?? 50;
591
+ const effectiveOffset = offset ?? 0;
592
+ let query = db()
593
+ .from('audit_log')
594
+ .select('id, project_id, actor_type, actor_id, action, resource_type, resource_id, details, channel, created_at', { count: 'exact' })
595
+ .eq('project_id', projectId)
596
+ .order('created_at', { ascending: false })
597
+ .range(effectiveOffset, effectiveOffset + effectiveLimit - 1);
598
+ if (action)
599
+ query = query.eq('action', action);
600
+ if (resource_type)
601
+ query = query.eq('resource_type', resource_type);
602
+ if (resource_id)
603
+ query = query.eq('resource_id', resource_id);
604
+ if (actor_id)
605
+ query = query.eq('actor_id', actor_id);
606
+ if (date_from)
607
+ query = query.gte('created_at', `${date_from}T00:00:00.000Z`);
608
+ if (date_to)
609
+ query = query.lte('created_at', `${date_to}T23:59:59.999Z`);
610
+ const { data, count, error } = await query;
611
+ if (error)
612
+ return j({ error: error.message });
613
+ return j({ entries: data ?? [], total: count ?? 0, limit: effectiveLimit, offset: effectiveOffset });
614
+ });
615
+ server.registerTool('get_audit_stats', {
616
+ title: 'Get Audit Stats',
617
+ annotations: {
618
+ readOnlyHint: true,
619
+ destructiveHint: false,
620
+ idempotentHint: true,
621
+ openWorldHint: false,
622
+ },
623
+ description: 'Return event, unique-actor, and dangerous-event counts for an audit-log date window, plus the immediately preceding window of equal length. Requires admin scope.',
624
+ inputSchema: {
625
+ action: z.string().optional(),
626
+ resource_type: z.string().optional(),
627
+ actor_id: z.string().optional(),
628
+ date_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Defaults to today, UTC'),
629
+ date_to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Defaults to date_from'),
630
+ },
631
+ }, async ({ action, resource_type, actor_id, date_from, date_to }) => {
632
+ const projectId = getProjectId();
633
+ const today = new Date().toISOString().slice(0, 10);
634
+ const dateFrom = date_from ?? today;
635
+ const dateTo = date_to ?? dateFrom;
636
+ const fetchWindow = async (fromDate, toDate) => {
637
+ let query = db()
638
+ .from('audit_log')
639
+ .select('action, actor_id, actor_type, created_at', { count: 'exact' })
640
+ .eq('project_id', projectId)
641
+ .gte('created_at', `${fromDate}T00:00:00.000Z`)
642
+ .lte('created_at', `${toDate}T23:59:59.999Z`)
643
+ .order('created_at', { ascending: false })
644
+ .range(0, 9999);
645
+ if (action)
646
+ query = query.eq('action', action);
647
+ if (resource_type)
648
+ query = query.eq('resource_type', resource_type);
649
+ if (actor_id)
650
+ query = query.eq('actor_id', actor_id);
651
+ return query;
652
+ };
653
+ const spanDays = dateSpanDays(dateFrom, dateTo);
654
+ const previousDateTo = addDaysUtc(dateFrom, -1);
655
+ const previousDateFrom = addDaysUtc(dateFrom, -spanDays);
656
+ const [current, previous] = await Promise.all([
657
+ fetchWindow(dateFrom, dateTo),
658
+ fetchWindow(previousDateFrom, previousDateTo),
659
+ ]);
660
+ if (current.error)
661
+ return j({ error: current.error.message });
662
+ if (previous.error)
663
+ return j({ error: previous.error.message });
664
+ const rows = current.data ?? [];
665
+ const previousRows = previous.data ?? [];
666
+ const uniqueActors = new Set(rows.map((entry) => entry.actor_id || entry.actor_type).filter(Boolean)).size;
667
+ const previousUniqueActors = new Set(previousRows.map((entry) => entry.actor_id || entry.actor_type).filter(Boolean)).size;
668
+ const dangerousEvents = rows.filter((entry) => isDangerousAuditAction(entry.action)).length;
669
+ return j({
670
+ events: current.count ?? rows.length,
671
+ uniqueActors,
672
+ dangerousEvents,
673
+ previousEvents: previous.count ?? previousRows.length,
674
+ previousUniqueActors,
675
+ dateFrom,
676
+ dateTo,
677
+ previousDateFrom,
678
+ previousDateTo,
679
+ });
680
+ });
681
+ server.registerTool('list_audit_actions', {
682
+ title: 'List Audit Actions',
683
+ annotations: {
684
+ readOnlyHint: true,
685
+ destructiveHint: false,
686
+ idempotentHint: true,
687
+ openWorldHint: false,
688
+ },
689
+ description: 'List distinct audit-log action values seen for this project, for building list_audit_log filters.',
690
+ inputSchema: {},
691
+ }, async () => {
692
+ const projectId = getProjectId();
693
+ const { data, error } = await db()
694
+ .from('audit_log')
695
+ .select('action')
696
+ .eq('project_id', projectId)
697
+ .order('action', { ascending: true })
698
+ .limit(1000);
699
+ if (error)
700
+ return j({ error: error.message });
701
+ const actions = Array.from(new Set((data ?? []).map((row) => row.action).filter(Boolean)));
702
+ return j({ actions });
703
+ });
704
+ server.registerTool('get_model_registry', {
705
+ title: 'Get Model Registry',
706
+ annotations: {
707
+ readOnlyHint: true,
708
+ destructiveHint: false,
709
+ idempotentHint: true,
710
+ openWorldHint: false,
711
+ },
712
+ description: 'List runtime model-registry overrides, including global and org-scoped role mappings.',
713
+ inputSchema: { org_id: z.string().uuid().optional() },
714
+ }, async ({ org_id }) => {
715
+ const columns = 'role, org_id, model_id, note, updated_at';
716
+ const scopedRows = org_id
717
+ ? await adminDb().from('model_registry').select(columns).eq('org_id', org_id)
718
+ : { data: [], error: null };
719
+ if (scopedRows.error)
720
+ return j({ error: scopedRows.error.message });
721
+ const globalRows = await adminDb().from('model_registry').select(columns).is('org_id', null);
722
+ if (globalRows.error)
723
+ return j({ error: globalRows.error.message });
724
+ return j({
725
+ overrides: [
726
+ ...(scopedRows.data ?? []),
727
+ ...(globalRows.data ?? []),
728
+ ],
729
+ });
730
+ });
200
731
  server.registerTool('get_white_label_settings', {
201
732
  title: 'Get White Label Settings',
202
733
  annotations: {
@@ -319,8 +850,7 @@ export function registerOrgTools(server) {
319
850
  }, async ({ site_id, to_provider }) => {
320
851
  const result = await callProviderSwitchApi('/api/dashboard/provider-switch/start', {
321
852
  method: 'POST',
322
- headers: { 'Content-Type': 'application/json' },
323
- body: JSON.stringify({ site_id, to_provider }),
853
+ body: { site_id, to_provider },
324
854
  });
325
855
  return result.ok ? j(result.data) : j({ error: result.error });
326
856
  });
@@ -337,8 +867,9 @@ export function registerOrgTools(server) {
337
867
  id: z.string().uuid(),
338
868
  },
339
869
  }, async ({ id }) => {
340
- const result = await callProviderSwitchApi(`/api/dashboard/provider-switch/status?id=${encodeURIComponent(id)}`, {
870
+ const result = await callProviderSwitchApi('/api/dashboard/provider-switch/status', {
341
871
  method: 'GET',
872
+ query: { id },
342
873
  });
343
874
  return result.ok ? j(result.data) : j({ error: result.error });
344
875
  });
@@ -357,8 +888,7 @@ export function registerOrgTools(server) {
357
888
  }, async ({ id }) => {
358
889
  const result = await callProviderSwitchApi('/api/dashboard/provider-switch/rollback', {
359
890
  method: 'POST',
360
- headers: { 'Content-Type': 'application/json' },
361
- body: JSON.stringify({ id }),
891
+ body: { id },
362
892
  });
363
893
  return result.ok ? j(result.data) : j({ error: result.error });
364
894
  });