@sendinel/mcp-server 1.0.9 → 1.0.11
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.
- package/README.md +35 -22
- package/build/anthropic-model.d.ts +30 -0
- package/build/anthropic-model.js +26 -1
- package/build/db.d.ts +17 -0
- package/build/db.js +15 -1
- package/build/index.js +6 -0
- package/build/lib/action-approvals.js +28 -0
- package/build/lib/brand-asset-persist.d.ts +1 -0
- package/build/lib/brand-asset-persist.js +20 -1
- package/build/lib/composer-blocks.d.ts +25 -25
- package/build/lib/composer-blocks.js +25 -5
- package/build/lib/connector-table-discovery.d.ts +12 -0
- package/build/lib/connector-table-discovery.js +117 -0
- package/build/lib/custom-properties-diff.d.ts +16 -0
- package/build/lib/custom-properties-diff.js +22 -0
- package/build/lib/schedule.d.ts +7 -2
- package/build/lib/schedule.js +8 -0
- package/build/lib/v1-client.d.ts +3 -0
- package/build/lib/v1-client.js +45 -0
- package/build/lib/voice.d.ts +7 -30
- package/build/lib/voice.js +6 -138
- package/build/prompts.d.ts +6 -0
- package/build/prompts.js +18 -0
- package/build/scoped-token.d.ts +1 -1
- package/build/server.d.ts +1 -0
- package/build/server.js +65 -6
- package/build/tools/analytics.js +97 -0
- package/build/tools/brand-kit.js +29 -30
- package/build/tools/campaigns.js +326 -52
- package/build/tools/cloud-import.d.ts +2 -0
- package/build/tools/cloud-import.js +76 -0
- package/build/tools/compound.d.ts +11 -0
- package/build/tools/compound.js +37 -7
- package/build/tools/connector.d.ts +2 -0
- package/build/tools/connector.js +280 -0
- package/build/tools/contacts.js +744 -16
- package/build/tools/content.js +228 -0
- package/build/tools/data.js +441 -0
- package/build/tools/delivery-ops.js +508 -1
- package/build/tools/forms.js +7 -6
- package/build/tools/gateway.d.ts +6 -0
- package/build/tools/gateway.js +92 -0
- package/build/tools/gdpr.js +185 -0
- package/build/tools/landing-pages.d.ts +2 -0
- package/build/tools/landing-pages.js +202 -0
- package/build/tools/lists.d.ts +2 -0
- package/build/tools/lists.js +428 -0
- package/build/tools/newsletters.js +1 -8
- package/build/tools/org.js +549 -33
- package/build/tools/segments.js +35 -184
- package/build/tools/signatures.d.ts +2 -0
- package/build/tools/signatures.js +168 -0
- package/build/tools/sites.js +3 -1
- package/build/tools/skills.d.ts +2 -0
- package/build/tools/skills.js +29 -0
- package/build/tools/templates.js +133 -7
- package/package.json +14 -13
package/build/tools/org.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
@@ -157,6 +175,343 @@ export function registerOrgTools(server) {
|
|
|
157
175
|
return j({ error: error.message });
|
|
158
176
|
return j({ invitation_id: data.id, expires_at: data.expires_at });
|
|
159
177
|
});
|
|
178
|
+
server.registerTool('remove_team_member', {
|
|
179
|
+
title: 'Remove Team Member',
|
|
180
|
+
annotations: {
|
|
181
|
+
readOnlyHint: false,
|
|
182
|
+
destructiveHint: true,
|
|
183
|
+
idempotentHint: true,
|
|
184
|
+
openWorldHint: false,
|
|
185
|
+
},
|
|
186
|
+
description: 'Remove a member from the project organization. Cannot remove the last owner. Uses two-call confirmation plus approval before executing.',
|
|
187
|
+
inputSchema: {
|
|
188
|
+
user_id: z.string().uuid().describe('Auth user id of the member to remove, as returned by list_team_members.'),
|
|
189
|
+
org_id: z.string().uuid().optional(),
|
|
190
|
+
confirmed: z.boolean().optional().default(false).describe('Two-call confirmation. First call previews, second call with confirmed=true executes.'),
|
|
191
|
+
approval_id: z.string().uuid().optional().describe('Approval token from a previous confirmed call without approval_id. Required when the action is not autonomous.'),
|
|
192
|
+
},
|
|
193
|
+
}, async ({ user_id, org_id, confirmed, approval_id }) => {
|
|
194
|
+
const context = await resolveCurrentOrgId(org_id);
|
|
195
|
+
if (!context.ok)
|
|
196
|
+
return j({ error: context.error, code: context.code });
|
|
197
|
+
const { data: member, error: memberErr } = await db()
|
|
198
|
+
.from('org_members')
|
|
199
|
+
.select('id, user_id, role')
|
|
200
|
+
.eq('org_id', context.orgId)
|
|
201
|
+
.eq('user_id', user_id)
|
|
202
|
+
.maybeSingle();
|
|
203
|
+
if (memberErr)
|
|
204
|
+
return j({ error: memberErr.message });
|
|
205
|
+
if (!member)
|
|
206
|
+
return j({ error: 'Member not found', code: 'NOT_FOUND' });
|
|
207
|
+
const approvalParams = { user_id, org_id: context.orgId };
|
|
208
|
+
if (!confirmed) {
|
|
209
|
+
return j({
|
|
210
|
+
confirmation_required: true,
|
|
211
|
+
tool: 'remove_team_member',
|
|
212
|
+
member: { user_id: member.user_id, role: member.role },
|
|
213
|
+
message: `Removing this member (role: ${member.role}) revokes their access to org ${context.orgId}. Call again with confirmed=true to proceed.`,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
if (member.role === 'owner') {
|
|
217
|
+
const { count } = await db()
|
|
218
|
+
.from('org_members')
|
|
219
|
+
.select('id', { count: 'exact', head: true })
|
|
220
|
+
.eq('org_id', context.orgId)
|
|
221
|
+
.eq('role', 'owner');
|
|
222
|
+
if ((count ?? 0) <= 1)
|
|
223
|
+
return j({ error: 'Cannot remove the last owner', code: 'LAST_OWNER' });
|
|
224
|
+
}
|
|
225
|
+
const channelContext = currentChannelContext();
|
|
226
|
+
const decision = assertChannelCanExecute('remove_team_member', approvalParams, channelContext);
|
|
227
|
+
if (!decision.allowed)
|
|
228
|
+
return j({ error: decision.reason ?? 'This channel cannot request this action.' });
|
|
229
|
+
if (decision.tier !== 'autonomous') {
|
|
230
|
+
if (!approval_id)
|
|
231
|
+
return j(await requestApproval('remove_team_member', approvalParams, undefined, channelContext));
|
|
232
|
+
const check = await checkApproval(approval_id, { consume: true, action: 'remove_team_member', params: approvalParams });
|
|
233
|
+
if (!check.approved) {
|
|
234
|
+
return j({
|
|
235
|
+
error: `Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`,
|
|
236
|
+
code: `APPROVAL_${check.status.toUpperCase()}`,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const { error } = await db()
|
|
241
|
+
.from('org_members')
|
|
242
|
+
.delete()
|
|
243
|
+
.eq('org_id', context.orgId)
|
|
244
|
+
.eq('user_id', user_id);
|
|
245
|
+
if (error)
|
|
246
|
+
return j({ error: error.message });
|
|
247
|
+
void logAudit({
|
|
248
|
+
action: 'remove_member',
|
|
249
|
+
resourceType: 'org_member',
|
|
250
|
+
resourceId: member.id,
|
|
251
|
+
details: { org_id: context.orgId, removed_user: user_id, role: member.role },
|
|
252
|
+
}).catch(() => { });
|
|
253
|
+
return j({ ok: true, removed_user_id: user_id });
|
|
254
|
+
});
|
|
255
|
+
server.registerTool('set_team_member_role', {
|
|
256
|
+
title: 'Set Team Member Role',
|
|
257
|
+
annotations: {
|
|
258
|
+
readOnlyHint: false,
|
|
259
|
+
destructiveHint: false,
|
|
260
|
+
idempotentHint: true,
|
|
261
|
+
openWorldHint: false,
|
|
262
|
+
},
|
|
263
|
+
description: "Change a team member's role within the project organization. Uses two-call confirmation plus approval before executing.",
|
|
264
|
+
inputSchema: {
|
|
265
|
+
user_id: z.string().uuid(),
|
|
266
|
+
role: z.enum(['owner', 'admin', 'member']),
|
|
267
|
+
org_id: z.string().uuid().optional(),
|
|
268
|
+
confirmed: z.boolean().optional().default(false),
|
|
269
|
+
approval_id: z.string().uuid().optional(),
|
|
270
|
+
},
|
|
271
|
+
}, async ({ user_id, role, org_id, confirmed, approval_id }) => {
|
|
272
|
+
const context = await resolveCurrentOrgId(org_id);
|
|
273
|
+
if (!context.ok)
|
|
274
|
+
return j({ error: context.error, code: context.code });
|
|
275
|
+
const { data: member, error: memberErr } = await db()
|
|
276
|
+
.from('org_members')
|
|
277
|
+
.select('id, user_id, role')
|
|
278
|
+
.eq('org_id', context.orgId)
|
|
279
|
+
.eq('user_id', user_id)
|
|
280
|
+
.maybeSingle();
|
|
281
|
+
if (memberErr)
|
|
282
|
+
return j({ error: memberErr.message });
|
|
283
|
+
if (!member)
|
|
284
|
+
return j({ error: 'Member not found', code: 'NOT_FOUND' });
|
|
285
|
+
const approvalParams = { user_id, role, org_id: context.orgId };
|
|
286
|
+
if (!confirmed) {
|
|
287
|
+
return j({
|
|
288
|
+
confirmation_required: true,
|
|
289
|
+
tool: 'set_team_member_role',
|
|
290
|
+
member: { user_id: member.user_id, current_role: member.role, new_role: role },
|
|
291
|
+
message: `This changes ${user_id}'s role from "${member.role}" to "${role}". Call again with confirmed=true to proceed.`,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
const channelContext = currentChannelContext();
|
|
295
|
+
const decision = assertChannelCanExecute('set_team_member_role', approvalParams, channelContext);
|
|
296
|
+
if (!decision.allowed)
|
|
297
|
+
return j({ error: decision.reason ?? 'This channel cannot request this action.' });
|
|
298
|
+
if (decision.tier !== 'autonomous') {
|
|
299
|
+
if (!approval_id)
|
|
300
|
+
return j(await requestApproval('set_team_member_role', approvalParams, undefined, channelContext));
|
|
301
|
+
const check = await checkApproval(approval_id, { consume: true, action: 'set_team_member_role', params: approvalParams });
|
|
302
|
+
if (!check.approved) {
|
|
303
|
+
return j({
|
|
304
|
+
error: `Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`,
|
|
305
|
+
code: `APPROVAL_${check.status.toUpperCase()}`,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
const { data, error } = await db()
|
|
310
|
+
.from('org_members')
|
|
311
|
+
.update({ role })
|
|
312
|
+
.eq('org_id', context.orgId)
|
|
313
|
+
.eq('user_id', user_id)
|
|
314
|
+
.select('user_id, role')
|
|
315
|
+
.maybeSingle();
|
|
316
|
+
if (error)
|
|
317
|
+
return j({ error: error.message });
|
|
318
|
+
void logAudit({
|
|
319
|
+
action: 'update_member_role',
|
|
320
|
+
resourceType: 'org_member',
|
|
321
|
+
resourceId: member.id,
|
|
322
|
+
details: { org_id: context.orgId, user_id, new_role: role },
|
|
323
|
+
}).catch(() => { });
|
|
324
|
+
return j({ member: data });
|
|
325
|
+
});
|
|
326
|
+
server.registerTool('set_member_project_access', {
|
|
327
|
+
title: 'Set Member Project Access',
|
|
328
|
+
annotations: {
|
|
329
|
+
readOnlyHint: false,
|
|
330
|
+
destructiveHint: false,
|
|
331
|
+
idempotentHint: true,
|
|
332
|
+
openWorldHint: false,
|
|
333
|
+
},
|
|
334
|
+
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.",
|
|
335
|
+
inputSchema: {
|
|
336
|
+
user_id: z.string().uuid(),
|
|
337
|
+
projects: z.array(memberProjectAssignmentSchema),
|
|
338
|
+
org_id: z.string().uuid().optional(),
|
|
339
|
+
},
|
|
340
|
+
}, async ({ user_id, projects, org_id }) => {
|
|
341
|
+
const context = await resolveCurrentOrgId(org_id);
|
|
342
|
+
if (!context.ok)
|
|
343
|
+
return j({ error: context.error, code: context.code });
|
|
344
|
+
const { data: membership, error: membershipErr } = await db()
|
|
345
|
+
.from('org_members')
|
|
346
|
+
.select('role')
|
|
347
|
+
.eq('org_id', context.orgId)
|
|
348
|
+
.eq('user_id', user_id)
|
|
349
|
+
.maybeSingle();
|
|
350
|
+
if (membershipErr)
|
|
351
|
+
return j({ error: membershipErr.message });
|
|
352
|
+
if (!membership)
|
|
353
|
+
return j({ error: 'Member not found', code: 'NOT_FOUND' });
|
|
354
|
+
if (membership.role === 'owner') {
|
|
355
|
+
return j({ error: 'Org owners have implicit access to all projects', code: 'IMPLICIT_ACCESS' });
|
|
356
|
+
}
|
|
357
|
+
const { data: orgProjects, error: orgProjectsErr } = await db()
|
|
358
|
+
.from('projects')
|
|
359
|
+
.select('id')
|
|
360
|
+
.eq('org_id', context.orgId);
|
|
361
|
+
if (orgProjectsErr)
|
|
362
|
+
return j({ error: orgProjectsErr.message });
|
|
363
|
+
const validIds = new Set((orgProjects ?? []).map((project) => project.id));
|
|
364
|
+
const assignmentMap = new Map();
|
|
365
|
+
for (const project of projects)
|
|
366
|
+
assignmentMap.set(project.project_id, project.role);
|
|
367
|
+
const requestedProjects = Array.from(assignmentMap, ([project_id, role]) => ({ project_id, role }));
|
|
368
|
+
const requestedIds = requestedProjects.map((project) => project.project_id);
|
|
369
|
+
const invalidIds = requestedIds.filter((id) => !validIds.has(id));
|
|
370
|
+
if (invalidIds.length > 0)
|
|
371
|
+
return j({ error: `Invalid project IDs: ${invalidIds.join(', ')}`, code: 'INVALID_PROJECT' });
|
|
372
|
+
const idsToRemove = Array.from(validIds).filter((id) => !assignmentMap.has(id));
|
|
373
|
+
if (idsToRemove.length > 0) {
|
|
374
|
+
const { error: deleteErr } = await db()
|
|
375
|
+
.from('user_project_access')
|
|
376
|
+
.delete()
|
|
377
|
+
.eq('user_id', user_id)
|
|
378
|
+
.in('project_id', idsToRemove);
|
|
379
|
+
if (deleteErr)
|
|
380
|
+
return j({ error: deleteErr.message });
|
|
381
|
+
}
|
|
382
|
+
if (requestedProjects.length > 0) {
|
|
383
|
+
const rows = requestedProjects.map((project) => ({ user_id, project_id: project.project_id, role: project.role }));
|
|
384
|
+
const { error: upsertErr } = await db()
|
|
385
|
+
.from('user_project_access')
|
|
386
|
+
.upsert(rows, { onConflict: 'user_id,project_id' });
|
|
387
|
+
if (upsertErr)
|
|
388
|
+
return j({ error: upsertErr.message });
|
|
389
|
+
}
|
|
390
|
+
void logAudit({
|
|
391
|
+
action: 'set_member_project_access',
|
|
392
|
+
resourceType: 'org_member',
|
|
393
|
+
resourceId: user_id,
|
|
394
|
+
details: { org_id: context.orgId, project_ids: requestedIds },
|
|
395
|
+
}).catch(() => { });
|
|
396
|
+
return j({ ok: true, user_id, project_ids: requestedIds });
|
|
397
|
+
});
|
|
398
|
+
server.registerTool('list_invitations', {
|
|
399
|
+
title: 'List Invitations',
|
|
400
|
+
annotations: {
|
|
401
|
+
readOnlyHint: true,
|
|
402
|
+
destructiveHint: false,
|
|
403
|
+
idempotentHint: true,
|
|
404
|
+
openWorldHint: false,
|
|
405
|
+
},
|
|
406
|
+
description: 'List pending (unaccepted, unexpired) invitations for the project organization.',
|
|
407
|
+
inputSchema: { org_id: z.string().uuid().optional() },
|
|
408
|
+
}, async ({ org_id }) => {
|
|
409
|
+
const context = await resolveCurrentOrgId(org_id);
|
|
410
|
+
if (!context.ok)
|
|
411
|
+
return j({ error: context.error, code: context.code });
|
|
412
|
+
const { data, error } = await db()
|
|
413
|
+
.from('org_invitations')
|
|
414
|
+
.select('id, email, role, invited_by, created_at, expires_at, project_ids, project_roles')
|
|
415
|
+
.eq('org_id', context.orgId)
|
|
416
|
+
.is('accepted_at', null)
|
|
417
|
+
.gt('expires_at', new Date().toISOString());
|
|
418
|
+
if (error)
|
|
419
|
+
return j({ error: error.message });
|
|
420
|
+
return j({ invitations: data ?? [] });
|
|
421
|
+
});
|
|
422
|
+
server.registerTool('resend_invitation', {
|
|
423
|
+
title: 'Resend Invitation',
|
|
424
|
+
annotations: {
|
|
425
|
+
readOnlyHint: false,
|
|
426
|
+
destructiveHint: false,
|
|
427
|
+
idempotentHint: false,
|
|
428
|
+
openWorldHint: false,
|
|
429
|
+
},
|
|
430
|
+
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.",
|
|
431
|
+
inputSchema: {
|
|
432
|
+
invitation_id: z.string().uuid(),
|
|
433
|
+
org_id: z.string().uuid().optional(),
|
|
434
|
+
},
|
|
435
|
+
}, async ({ invitation_id, org_id }) => {
|
|
436
|
+
const context = await resolveCurrentOrgId(org_id);
|
|
437
|
+
if (!context.ok)
|
|
438
|
+
return j({ error: context.error, code: context.code });
|
|
439
|
+
const { data: invitation, error: invitationErr } = await db()
|
|
440
|
+
.from('org_invitations')
|
|
441
|
+
.select('id, email, role, token, expires_at')
|
|
442
|
+
.eq('id', invitation_id)
|
|
443
|
+
.eq('org_id', context.orgId)
|
|
444
|
+
.is('accepted_at', null)
|
|
445
|
+
.gt('expires_at', new Date().toISOString())
|
|
446
|
+
.maybeSingle();
|
|
447
|
+
if (invitationErr)
|
|
448
|
+
return j({ error: invitationErr.message });
|
|
449
|
+
if (!invitation)
|
|
450
|
+
return j({ error: 'Invitation not found', code: 'NOT_FOUND' });
|
|
451
|
+
const expiresAt = new Date(Date.now() + 7 * 86400000).toISOString();
|
|
452
|
+
const { data: updated, error: updateErr } = await db()
|
|
453
|
+
.from('org_invitations')
|
|
454
|
+
.update({ expires_at: expiresAt })
|
|
455
|
+
.eq('id', invitation_id)
|
|
456
|
+
.eq('org_id', context.orgId)
|
|
457
|
+
.is('accepted_at', null)
|
|
458
|
+
.select('id')
|
|
459
|
+
.maybeSingle();
|
|
460
|
+
if (updateErr)
|
|
461
|
+
return j({ error: updateErr.message });
|
|
462
|
+
if (!updated)
|
|
463
|
+
return j({ error: 'Invitation not found', code: 'NOT_FOUND' });
|
|
464
|
+
const appUrl = (process.env.NEXT_PUBLIC_APP_URL ?? 'https://sendinel.ai').replace(/\/$/, '');
|
|
465
|
+
return j({
|
|
466
|
+
invitation_id: invitation.id,
|
|
467
|
+
email: invitation.email,
|
|
468
|
+
role: invitation.role,
|
|
469
|
+
expires_at: expiresAt,
|
|
470
|
+
invite_url: `${appUrl}/invite/${invitation.token}`,
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
server.registerTool('revoke_invitation', {
|
|
474
|
+
title: 'Revoke Invitation',
|
|
475
|
+
annotations: {
|
|
476
|
+
readOnlyHint: false,
|
|
477
|
+
destructiveHint: true,
|
|
478
|
+
idempotentHint: true,
|
|
479
|
+
openWorldHint: false,
|
|
480
|
+
},
|
|
481
|
+
description: 'Revoke a pending org invitation before it is accepted.',
|
|
482
|
+
inputSchema: {
|
|
483
|
+
invitation_id: z.string().uuid(),
|
|
484
|
+
org_id: z.string().uuid().optional(),
|
|
485
|
+
},
|
|
486
|
+
}, async ({ invitation_id, org_id }) => {
|
|
487
|
+
const context = await resolveCurrentOrgId(org_id);
|
|
488
|
+
if (!context.ok)
|
|
489
|
+
return j({ error: context.error, code: context.code });
|
|
490
|
+
const { data: invitation, error: invitationErr } = await db()
|
|
491
|
+
.from('org_invitations')
|
|
492
|
+
.select('id, email, role, accepted_at')
|
|
493
|
+
.eq('id', invitation_id)
|
|
494
|
+
.eq('org_id', context.orgId)
|
|
495
|
+
.maybeSingle();
|
|
496
|
+
if (invitationErr)
|
|
497
|
+
return j({ error: invitationErr.message });
|
|
498
|
+
if (!invitation || invitation.accepted_at)
|
|
499
|
+
return j({ error: 'Invitation not found', code: 'NOT_FOUND' });
|
|
500
|
+
const { error } = await db()
|
|
501
|
+
.from('org_invitations')
|
|
502
|
+
.delete()
|
|
503
|
+
.eq('id', invitation_id)
|
|
504
|
+
.eq('org_id', context.orgId);
|
|
505
|
+
if (error)
|
|
506
|
+
return j({ error: error.message });
|
|
507
|
+
void logAudit({
|
|
508
|
+
action: 'invitation.revoked',
|
|
509
|
+
resourceType: 'org_invitation',
|
|
510
|
+
resourceId: invitation_id,
|
|
511
|
+
details: { org_id: context.orgId, email: invitation.email, role: invitation.role },
|
|
512
|
+
}).catch(() => { });
|
|
513
|
+
return j({ ok: true });
|
|
514
|
+
});
|
|
160
515
|
server.registerTool('get_plan_usage', {
|
|
161
516
|
title: 'Get Plan Usage',
|
|
162
517
|
annotations: {
|
|
@@ -197,6 +552,168 @@ export function registerOrgTools(server) {
|
|
|
197
552
|
description: 'Return plan limits for all plan tiers.',
|
|
198
553
|
inputSchema: {},
|
|
199
554
|
}, async () => j(PLAN_LIMITS));
|
|
555
|
+
server.registerTool('list_audit_log', {
|
|
556
|
+
title: 'List Audit Log',
|
|
557
|
+
annotations: {
|
|
558
|
+
readOnlyHint: true,
|
|
559
|
+
destructiveHint: false,
|
|
560
|
+
idempotentHint: true,
|
|
561
|
+
openWorldHint: false,
|
|
562
|
+
},
|
|
563
|
+
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.',
|
|
564
|
+
inputSchema: {
|
|
565
|
+
action: z.string().optional(),
|
|
566
|
+
resource_type: z.string().optional(),
|
|
567
|
+
resource_id: z.string().uuid().optional(),
|
|
568
|
+
actor_id: z.string().optional(),
|
|
569
|
+
date_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Inclusive, UTC, YYYY-MM-DD'),
|
|
570
|
+
date_to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Inclusive, UTC, YYYY-MM-DD'),
|
|
571
|
+
limit: z.number().int().min(1).max(100).optional().default(50),
|
|
572
|
+
offset: z.number().int().min(0).optional().default(0),
|
|
573
|
+
},
|
|
574
|
+
}, async ({ action, resource_type, resource_id, actor_id, date_from, date_to, limit, offset }) => {
|
|
575
|
+
const projectId = getProjectId();
|
|
576
|
+
const effectiveLimit = limit ?? 50;
|
|
577
|
+
const effectiveOffset = offset ?? 0;
|
|
578
|
+
let query = db()
|
|
579
|
+
.from('audit_log')
|
|
580
|
+
.select('id, project_id, actor_type, actor_id, action, resource_type, resource_id, details, channel, created_at', { count: 'exact' })
|
|
581
|
+
.eq('project_id', projectId)
|
|
582
|
+
.order('created_at', { ascending: false })
|
|
583
|
+
.range(effectiveOffset, effectiveOffset + effectiveLimit - 1);
|
|
584
|
+
if (action)
|
|
585
|
+
query = query.eq('action', action);
|
|
586
|
+
if (resource_type)
|
|
587
|
+
query = query.eq('resource_type', resource_type);
|
|
588
|
+
if (resource_id)
|
|
589
|
+
query = query.eq('resource_id', resource_id);
|
|
590
|
+
if (actor_id)
|
|
591
|
+
query = query.eq('actor_id', actor_id);
|
|
592
|
+
if (date_from)
|
|
593
|
+
query = query.gte('created_at', `${date_from}T00:00:00.000Z`);
|
|
594
|
+
if (date_to)
|
|
595
|
+
query = query.lte('created_at', `${date_to}T23:59:59.999Z`);
|
|
596
|
+
const { data, count, error } = await query;
|
|
597
|
+
if (error)
|
|
598
|
+
return j({ error: error.message });
|
|
599
|
+
return j({ entries: data ?? [], total: count ?? 0, limit: effectiveLimit, offset: effectiveOffset });
|
|
600
|
+
});
|
|
601
|
+
server.registerTool('get_audit_stats', {
|
|
602
|
+
title: 'Get Audit Stats',
|
|
603
|
+
annotations: {
|
|
604
|
+
readOnlyHint: true,
|
|
605
|
+
destructiveHint: false,
|
|
606
|
+
idempotentHint: true,
|
|
607
|
+
openWorldHint: false,
|
|
608
|
+
},
|
|
609
|
+
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.',
|
|
610
|
+
inputSchema: {
|
|
611
|
+
action: z.string().optional(),
|
|
612
|
+
resource_type: z.string().optional(),
|
|
613
|
+
actor_id: z.string().optional(),
|
|
614
|
+
date_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Defaults to today, UTC'),
|
|
615
|
+
date_to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Defaults to date_from'),
|
|
616
|
+
},
|
|
617
|
+
}, async ({ action, resource_type, actor_id, date_from, date_to }) => {
|
|
618
|
+
const projectId = getProjectId();
|
|
619
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
620
|
+
const dateFrom = date_from ?? today;
|
|
621
|
+
const dateTo = date_to ?? dateFrom;
|
|
622
|
+
const fetchWindow = async (fromDate, toDate) => {
|
|
623
|
+
let query = db()
|
|
624
|
+
.from('audit_log')
|
|
625
|
+
.select('action, actor_id, actor_type, created_at', { count: 'exact' })
|
|
626
|
+
.eq('project_id', projectId)
|
|
627
|
+
.gte('created_at', `${fromDate}T00:00:00.000Z`)
|
|
628
|
+
.lte('created_at', `${toDate}T23:59:59.999Z`)
|
|
629
|
+
.order('created_at', { ascending: false })
|
|
630
|
+
.range(0, 9999);
|
|
631
|
+
if (action)
|
|
632
|
+
query = query.eq('action', action);
|
|
633
|
+
if (resource_type)
|
|
634
|
+
query = query.eq('resource_type', resource_type);
|
|
635
|
+
if (actor_id)
|
|
636
|
+
query = query.eq('actor_id', actor_id);
|
|
637
|
+
return query;
|
|
638
|
+
};
|
|
639
|
+
const spanDays = dateSpanDays(dateFrom, dateTo);
|
|
640
|
+
const previousDateTo = addDaysUtc(dateFrom, -1);
|
|
641
|
+
const previousDateFrom = addDaysUtc(dateFrom, -spanDays);
|
|
642
|
+
const [current, previous] = await Promise.all([
|
|
643
|
+
fetchWindow(dateFrom, dateTo),
|
|
644
|
+
fetchWindow(previousDateFrom, previousDateTo),
|
|
645
|
+
]);
|
|
646
|
+
if (current.error)
|
|
647
|
+
return j({ error: current.error.message });
|
|
648
|
+
if (previous.error)
|
|
649
|
+
return j({ error: previous.error.message });
|
|
650
|
+
const rows = current.data ?? [];
|
|
651
|
+
const previousRows = previous.data ?? [];
|
|
652
|
+
const uniqueActors = new Set(rows.map((entry) => entry.actor_id || entry.actor_type).filter(Boolean)).size;
|
|
653
|
+
const previousUniqueActors = new Set(previousRows.map((entry) => entry.actor_id || entry.actor_type).filter(Boolean)).size;
|
|
654
|
+
const dangerousEvents = rows.filter((entry) => isDangerousAuditAction(entry.action)).length;
|
|
655
|
+
return j({
|
|
656
|
+
events: current.count ?? rows.length,
|
|
657
|
+
uniqueActors,
|
|
658
|
+
dangerousEvents,
|
|
659
|
+
previousEvents: previous.count ?? previousRows.length,
|
|
660
|
+
previousUniqueActors,
|
|
661
|
+
dateFrom,
|
|
662
|
+
dateTo,
|
|
663
|
+
previousDateFrom,
|
|
664
|
+
previousDateTo,
|
|
665
|
+
});
|
|
666
|
+
});
|
|
667
|
+
server.registerTool('list_audit_actions', {
|
|
668
|
+
title: 'List Audit Actions',
|
|
669
|
+
annotations: {
|
|
670
|
+
readOnlyHint: true,
|
|
671
|
+
destructiveHint: false,
|
|
672
|
+
idempotentHint: true,
|
|
673
|
+
openWorldHint: false,
|
|
674
|
+
},
|
|
675
|
+
description: 'List distinct audit-log action values seen for this project, for building list_audit_log filters.',
|
|
676
|
+
inputSchema: {},
|
|
677
|
+
}, async () => {
|
|
678
|
+
const projectId = getProjectId();
|
|
679
|
+
const { data, error } = await db()
|
|
680
|
+
.from('audit_log')
|
|
681
|
+
.select('action')
|
|
682
|
+
.eq('project_id', projectId)
|
|
683
|
+
.order('action', { ascending: true })
|
|
684
|
+
.limit(1000);
|
|
685
|
+
if (error)
|
|
686
|
+
return j({ error: error.message });
|
|
687
|
+
const actions = Array.from(new Set((data ?? []).map((row) => row.action).filter(Boolean)));
|
|
688
|
+
return j({ actions });
|
|
689
|
+
});
|
|
690
|
+
server.registerTool('get_model_registry', {
|
|
691
|
+
title: 'Get Model Registry',
|
|
692
|
+
annotations: {
|
|
693
|
+
readOnlyHint: true,
|
|
694
|
+
destructiveHint: false,
|
|
695
|
+
idempotentHint: true,
|
|
696
|
+
openWorldHint: false,
|
|
697
|
+
},
|
|
698
|
+
description: 'List runtime model-registry overrides, including global and org-scoped role mappings.',
|
|
699
|
+
inputSchema: { org_id: z.string().uuid().optional() },
|
|
700
|
+
}, async ({ org_id }) => {
|
|
701
|
+
const columns = 'role, org_id, model_id, note, updated_at';
|
|
702
|
+
const scopedRows = org_id
|
|
703
|
+
? await adminDb().from('model_registry').select(columns).eq('org_id', org_id)
|
|
704
|
+
: { data: [], error: null };
|
|
705
|
+
if (scopedRows.error)
|
|
706
|
+
return j({ error: scopedRows.error.message });
|
|
707
|
+
const globalRows = await adminDb().from('model_registry').select(columns).is('org_id', null);
|
|
708
|
+
if (globalRows.error)
|
|
709
|
+
return j({ error: globalRows.error.message });
|
|
710
|
+
return j({
|
|
711
|
+
overrides: [
|
|
712
|
+
...(scopedRows.data ?? []),
|
|
713
|
+
...(globalRows.data ?? []),
|
|
714
|
+
],
|
|
715
|
+
});
|
|
716
|
+
});
|
|
200
717
|
server.registerTool('get_white_label_settings', {
|
|
201
718
|
title: 'Get White Label Settings',
|
|
202
719
|
annotations: {
|
|
@@ -319,8 +836,7 @@ export function registerOrgTools(server) {
|
|
|
319
836
|
}, async ({ site_id, to_provider }) => {
|
|
320
837
|
const result = await callProviderSwitchApi('/api/dashboard/provider-switch/start', {
|
|
321
838
|
method: 'POST',
|
|
322
|
-
|
|
323
|
-
body: JSON.stringify({ site_id, to_provider }),
|
|
839
|
+
body: { site_id, to_provider },
|
|
324
840
|
});
|
|
325
841
|
return result.ok ? j(result.data) : j({ error: result.error });
|
|
326
842
|
});
|
|
@@ -337,8 +853,9 @@ export function registerOrgTools(server) {
|
|
|
337
853
|
id: z.string().uuid(),
|
|
338
854
|
},
|
|
339
855
|
}, async ({ id }) => {
|
|
340
|
-
const result = await callProviderSwitchApi(
|
|
856
|
+
const result = await callProviderSwitchApi('/api/dashboard/provider-switch/status', {
|
|
341
857
|
method: 'GET',
|
|
858
|
+
query: { id },
|
|
342
859
|
});
|
|
343
860
|
return result.ok ? j(result.data) : j({ error: result.error });
|
|
344
861
|
});
|
|
@@ -357,8 +874,7 @@ export function registerOrgTools(server) {
|
|
|
357
874
|
}, async ({ id }) => {
|
|
358
875
|
const result = await callProviderSwitchApi('/api/dashboard/provider-switch/rollback', {
|
|
359
876
|
method: 'POST',
|
|
360
|
-
|
|
361
|
-
body: JSON.stringify({ id }),
|
|
877
|
+
body: { id },
|
|
362
878
|
});
|
|
363
879
|
return result.ok ? j(result.data) : j({ error: result.error });
|
|
364
880
|
});
|