@sendinel/mcp-server 1.0.23 → 1.0.25
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 +2 -2
- package/build/db.d.ts +2 -0
- package/build/db.js +1 -0
- package/build/server.js +1 -0
- package/build/tools/analytics.js +140 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,14 +12,14 @@ AI-controlled email operations control plane for campaigns, contacts, segments,
|
|
|
12
12
|
|
|
13
13
|
## Tool groups
|
|
14
14
|
|
|
15
|
-
Sendinel currently exposes 34 tool groups and
|
|
15
|
+
Sendinel currently exposes 34 tool groups and 315 tools (gateway mode adds 3 meta-tools). These counts are checked against the tool registry in CI, so this table matches what ships.
|
|
16
16
|
|
|
17
17
|
| Group | Count | Purpose |
|
|
18
18
|
|-------|-------|---------|
|
|
19
19
|
| `ab-testing` | 4 | A/B test creation, listing, significance checks, winner promotion |
|
|
20
20
|
| `advisor` | 6 | Campaign advisor: recommendations, benchmarks, subject/content/send-time guidance |
|
|
21
21
|
| `ai-work` | 9 | AI work items, recommended segments, context events, agent proposals |
|
|
22
|
-
| `analytics` |
|
|
22
|
+
| `analytics` | 18 | Stats, portfolio analytics, project switching, domain health, engagement, deliverability, revenue analytics |
|
|
23
23
|
| `approvals` | 2 | Poll and list risk-tiered action approvals |
|
|
24
24
|
| `automations` | 7 | Automation triggers, previews, and response-gated escalation ladders |
|
|
25
25
|
| `brand_kit` | 5 | Brand Core text import, media library upload/URL ingest, asset-context reads |
|
package/build/db.d.ts
CHANGED
|
@@ -37,6 +37,7 @@ export declare function runWithClient<T>(client: any, fn: () => Promise<T>, requ
|
|
|
37
37
|
channelVerified?: boolean;
|
|
38
38
|
actorType?: string;
|
|
39
39
|
actorId?: string | null;
|
|
40
|
+
oauthTokenId?: string | null;
|
|
40
41
|
workspaceId?: string | null;
|
|
41
42
|
v1Caller?: V1Caller;
|
|
42
43
|
}): Promise<T>;
|
|
@@ -52,6 +53,7 @@ export declare function requestContext(): {
|
|
|
52
53
|
channelVerified?: boolean;
|
|
53
54
|
actorType?: string;
|
|
54
55
|
actorId?: string | null;
|
|
56
|
+
oauthTokenId?: string | null;
|
|
55
57
|
workspaceId?: string | null;
|
|
56
58
|
v1Caller?: V1Caller;
|
|
57
59
|
} | undefined;
|
package/build/db.js
CHANGED
package/build/server.js
CHANGED
|
@@ -103,6 +103,7 @@ const WRITE_TOOLS = new Set([
|
|
|
103
103
|
'update_warmup_schedule', 'pause_warmup', 'resume_warmup',
|
|
104
104
|
'create_form', 'invite_team_member',
|
|
105
105
|
'remove_team_member', 'set_team_member_role', 'set_member_project_access',
|
|
106
|
+
'set_active_project',
|
|
106
107
|
'resend_invitation', 'revoke_invitation',
|
|
107
108
|
'create_landing_page', 'update_landing_page', 'delete_landing_page',
|
|
108
109
|
'start_provider_switch', 'get_provider_switch_status', 'rollback_provider_switch',
|
package/build/tools/analytics.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { fetchAllRows } from '@sendinel/domain';
|
|
1
2
|
import { z } from 'zod';
|
|
2
|
-
import { db, adminDb } from '../db.js';
|
|
3
|
+
import { db, adminDb, requestContext } from '../db.js';
|
|
3
4
|
import { getProjectId } from '../project.js';
|
|
4
5
|
import { resolveProjectClient } from '../byod-client.js';
|
|
5
6
|
import { decrypt } from '../crypto.js';
|
|
7
|
+
import { logAudit } from '../audit.js';
|
|
6
8
|
const txt = (text) => ({ content: [{ type: 'text', text }] });
|
|
7
9
|
const j = (data) => txt(JSON.stringify(data, null, 2));
|
|
8
10
|
const percent = (rate) => `${(rate * 100).toFixed(1)}%`;
|
|
@@ -347,9 +349,63 @@ export function registerAnalyticsTools(server) {
|
|
|
347
349
|
description: 'List all projects accessible to this API key. Returns project ID, name, plan, and associated organization. Useful for orchestrators managing multiple products through a single Sendinel account.',
|
|
348
350
|
inputSchema: {},
|
|
349
351
|
}, async () => {
|
|
352
|
+
const projectId = getProjectId();
|
|
353
|
+
const ctx = requestContext();
|
|
354
|
+
if (ctx?.channel === 'mcp_oauth_session' && ctx.actorId) {
|
|
355
|
+
const [{ data: directAccess, error: directError }, { data: ownedOrgs, error: ownerError }] = await Promise.all([
|
|
356
|
+
adminDb()
|
|
357
|
+
.from('user_project_access')
|
|
358
|
+
.select('project_id')
|
|
359
|
+
.eq('user_id', ctx.actorId),
|
|
360
|
+
adminDb()
|
|
361
|
+
.from('org_members')
|
|
362
|
+
.select('org_id')
|
|
363
|
+
.eq('user_id', ctx.actorId)
|
|
364
|
+
.eq('role', 'owner'),
|
|
365
|
+
]);
|
|
366
|
+
if (directError)
|
|
367
|
+
return txt(`Error: ${directError.message}`);
|
|
368
|
+
if (ownerError)
|
|
369
|
+
return txt(`Error: ${ownerError.message}`);
|
|
370
|
+
const directProjectIds = Array.from(new Set((directAccess ?? []).map((row) => row.project_id).filter(Boolean)));
|
|
371
|
+
const ownedOrgIds = Array.from(new Set((ownedOrgs ?? []).map((row) => row.org_id).filter(Boolean)));
|
|
372
|
+
const [{ data: directProjects, error: directProjectsError }, { data: ownedProjects, error: ownedProjectsError }] = await Promise.all([
|
|
373
|
+
directProjectIds.length
|
|
374
|
+
? adminDb().from('projects').select('id, name, org_id').in('id', directProjectIds)
|
|
375
|
+
: Promise.resolve({ data: [], error: null }),
|
|
376
|
+
ownedOrgIds.length
|
|
377
|
+
? adminDb().from('projects').select('id, name, org_id').in('org_id', ownedOrgIds)
|
|
378
|
+
: Promise.resolve({ data: [], error: null }),
|
|
379
|
+
]);
|
|
380
|
+
if (directProjectsError)
|
|
381
|
+
return txt(`Error: ${directProjectsError.message}`);
|
|
382
|
+
if (ownedProjectsError)
|
|
383
|
+
return txt(`Error: ${ownedProjectsError.message}`);
|
|
384
|
+
const byId = new Map();
|
|
385
|
+
for (const project of [...(directProjects ?? []), ...(ownedProjects ?? [])]) {
|
|
386
|
+
byId.set(project.id, project);
|
|
387
|
+
}
|
|
388
|
+
const orgIds = Array.from(new Set(Array.from(byId.values()).map((project) => project.org_id).filter((id) => Boolean(id))));
|
|
389
|
+
const { data: organizations, error: organizationsError } = orgIds.length
|
|
390
|
+
? await adminDb().from('organizations').select('id, name').in('id', orgIds)
|
|
391
|
+
: { data: [], error: null };
|
|
392
|
+
if (organizationsError)
|
|
393
|
+
return txt(`Error: ${organizationsError.message}`);
|
|
394
|
+
const orgNames = new Map((organizations ?? []).map((org) => [org.id, org.name]));
|
|
395
|
+
return j({
|
|
396
|
+
projects: Array.from(byId.values())
|
|
397
|
+
.sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id))
|
|
398
|
+
.map((project) => ({
|
|
399
|
+
id: project.id,
|
|
400
|
+
name: project.name,
|
|
401
|
+
org_id: project.org_id,
|
|
402
|
+
org_name: project.org_id ? orgNames.get(project.org_id) ?? null : null,
|
|
403
|
+
active: project.id === projectId,
|
|
404
|
+
})),
|
|
405
|
+
});
|
|
406
|
+
}
|
|
350
407
|
// API keys are scoped to a single project, but an organization can have multiple projects.
|
|
351
408
|
// Look up the org for this project, then list all projects in that org.
|
|
352
|
-
const projectId = getProjectId();
|
|
353
409
|
const { data: project, error: projErr } = await adminDb()
|
|
354
410
|
.from('projects')
|
|
355
411
|
.select('id, name, org_id')
|
|
@@ -384,6 +440,59 @@ export function registerAnalyticsTools(server) {
|
|
|
384
440
|
})),
|
|
385
441
|
});
|
|
386
442
|
});
|
|
443
|
+
server.registerTool('set_active_project', {
|
|
444
|
+
title: 'Set Active Project',
|
|
445
|
+
annotations: {
|
|
446
|
+
readOnlyHint: false,
|
|
447
|
+
destructiveHint: false,
|
|
448
|
+
idempotentHint: true,
|
|
449
|
+
openWorldHint: false,
|
|
450
|
+
},
|
|
451
|
+
description: 'Switch this connection to act on a different project you have access to, without reconnecting. Only available on an OAuth-connected session (claude.ai / Claude Code / Cursor); API keys and Synchronex connections stay pinned to their original project.',
|
|
452
|
+
inputSchema: {
|
|
453
|
+
project_id: z.string().uuid().describe('Project UUID to switch to. Use list_projects to see what you can access.'),
|
|
454
|
+
},
|
|
455
|
+
}, async ({ project_id }) => {
|
|
456
|
+
const ctx = requestContext();
|
|
457
|
+
if (ctx?.channel !== 'mcp_oauth_session' || !ctx.oauthTokenId || !ctx.actorId) {
|
|
458
|
+
return j({
|
|
459
|
+
error: 'set_active_project is only available on an OAuth-connected session (claude.ai, Claude Code, Cursor). This connection is pinned to a single project.',
|
|
460
|
+
code: 'SWITCH_NOT_SUPPORTED',
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
const { data: hasAccess, error: accessError } = await adminDb().rpc('user_has_project_access', {
|
|
464
|
+
p_user_id: ctx.actorId,
|
|
465
|
+
p_project_id: project_id,
|
|
466
|
+
});
|
|
467
|
+
if (accessError)
|
|
468
|
+
return j({ error: accessError.message });
|
|
469
|
+
if (!hasAccess)
|
|
470
|
+
return j({ error: 'You do not have access to this project.', code: 'PROJECT_ACCESS_DENIED' });
|
|
471
|
+
const previousProjectId = getProjectId();
|
|
472
|
+
const { error: updateError } = await adminDb()
|
|
473
|
+
.from('mcp_oauth_tokens')
|
|
474
|
+
.update({ active_project_id: project_id })
|
|
475
|
+
.eq('id', ctx.oauthTokenId);
|
|
476
|
+
if (updateError)
|
|
477
|
+
return j({ error: updateError.message });
|
|
478
|
+
const { data: project } = await adminDb()
|
|
479
|
+
.from('projects')
|
|
480
|
+
.select('id, name, org_id')
|
|
481
|
+
.eq('id', project_id)
|
|
482
|
+
.maybeSingle();
|
|
483
|
+
void logAudit({
|
|
484
|
+
action: 'mcp.set_active_project',
|
|
485
|
+
resourceType: 'project',
|
|
486
|
+
resourceId: project_id,
|
|
487
|
+
details: { previous_project_id: previousProjectId },
|
|
488
|
+
channel: 'mcp_oauth_session',
|
|
489
|
+
}).catch(() => { });
|
|
490
|
+
return j({
|
|
491
|
+
ok: true,
|
|
492
|
+
active_project: project ? { id: project.id, name: project.name, org_id: project.org_id } : { id: project_id },
|
|
493
|
+
note: 'Applies starting with your next tool call on this connection.',
|
|
494
|
+
});
|
|
495
|
+
});
|
|
387
496
|
server.registerTool('get_sites', {
|
|
388
497
|
title: 'Get Sites',
|
|
389
498
|
annotations: {
|
|
@@ -431,42 +540,54 @@ export function registerAnalyticsTools(server) {
|
|
|
431
540
|
if (siteError)
|
|
432
541
|
return { content: [{ type: 'text', text: `Error: ${siteError.message}` }] };
|
|
433
542
|
const trackingEnabled = site?.tracking_enabled ?? false;
|
|
434
|
-
const { data, error } = await db()
|
|
543
|
+
const { data, error, pageCount: logPageCount } = await fetchAllRows(async (offset, limit) => db()
|
|
435
544
|
.from('email_log')
|
|
436
545
|
.select('id, provider_message_id, status, sent_at')
|
|
437
546
|
.eq('project_id', projectId)
|
|
438
547
|
.eq('site_id', site_id)
|
|
439
548
|
.gte('sent_at', `${start_date}T00:00:00Z`)
|
|
440
|
-
.lte('sent_at', `${end}T23:59:59Z`)
|
|
549
|
+
.lte('sent_at', `${end}T23:59:59Z`)
|
|
550
|
+
.order('id', { ascending: true })
|
|
551
|
+
.range(offset, offset + limit - 1));
|
|
441
552
|
if (error)
|
|
442
553
|
return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
|
|
443
554
|
const rows = (data ?? []);
|
|
444
555
|
let eventCounts = { opened: 0, clicked: 0 };
|
|
556
|
+
let eventRows = [];
|
|
557
|
+
let eventPageCount = 0;
|
|
445
558
|
if (trackingEnabled) {
|
|
446
|
-
const { data: events, error: eventsError } = await db()
|
|
559
|
+
const { data: events, error: eventsError, pageCount } = await fetchAllRows(async (offset, limit) => db()
|
|
447
560
|
.from('email_events')
|
|
448
561
|
.select('provider_message_id, event_type')
|
|
449
562
|
.eq('project_id', projectId)
|
|
450
563
|
.eq('site_id', site_id)
|
|
451
564
|
.in('event_type', ['open', 'click'])
|
|
452
565
|
.gte('occurred_at', `${start_date}T00:00:00Z`)
|
|
453
|
-
.lte('occurred_at', `${end}T23:59:59Z`)
|
|
566
|
+
.lte('occurred_at', `${end}T23:59:59Z`)
|
|
567
|
+
.order('id', { ascending: true })
|
|
568
|
+
.range(offset, offset + limit - 1));
|
|
454
569
|
if (eventsError)
|
|
455
570
|
return { content: [{ type: 'text', text: `Error: ${eventsError.message}` }] };
|
|
571
|
+
eventRows = events ?? [];
|
|
572
|
+
eventPageCount = pageCount;
|
|
456
573
|
const messageIds = new Set(rows.map(row => row.provider_message_id).filter((id) => Boolean(id)));
|
|
457
574
|
eventCounts = {
|
|
458
|
-
opened: distinctEventCount(
|
|
459
|
-
clicked: distinctEventCount(
|
|
575
|
+
opened: distinctEventCount(eventRows, 'open', messageIds),
|
|
576
|
+
clicked: distinctEventCount(eventRows, 'click', messageIds),
|
|
460
577
|
};
|
|
461
578
|
}
|
|
462
579
|
const metrics = computeAnalyticsMetrics(rows, trackingEnabled ? eventCounts : undefined);
|
|
463
580
|
const periodDays = daysBetween(start_date, end);
|
|
464
581
|
const priorStart = dateOnly(addDays(new Date(`${start_date}T00:00:00Z`), -periodDays));
|
|
465
582
|
const priorEnd = dateOnly(addDays(new Date(`${start_date}T00:00:00Z`), -1));
|
|
466
|
-
const { data: subscribers } = await db()
|
|
583
|
+
const { data: subscribers, error: subscribersError, pageCount: subscriberPageCount } = await fetchAllRows(async (offset, limit) => db()
|
|
467
584
|
.from('subscribers')
|
|
468
585
|
.select('created_at, last_engaged_at, last_opened_at, last_clicked_at')
|
|
469
|
-
.eq('project_id', projectId)
|
|
586
|
+
.eq('project_id', projectId)
|
|
587
|
+
.order('id', { ascending: true })
|
|
588
|
+
.range(offset, offset + limit - 1));
|
|
589
|
+
if (subscribersError)
|
|
590
|
+
return { content: [{ type: 'text', text: `Error: ${subscribersError.message}` }] };
|
|
470
591
|
const subscriberRows = (subscribers ?? []);
|
|
471
592
|
const currentNew = subscriberRows.filter(s => s.created_at && s.created_at >= `${start_date}T00:00:00Z` && s.created_at <= `${end}T23:59:59Z`).length;
|
|
472
593
|
const priorNew = subscriberRows.filter(s => s.created_at && s.created_at >= `${priorStart}T00:00:00Z` && s.created_at <= `${priorEnd}T23:59:59Z`).length;
|
|
@@ -488,6 +609,15 @@ export function registerAnalyticsTools(server) {
|
|
|
488
609
|
list_growth_rate: subscriberRows.length > 0 ? pctNumber(currentNew / subscriberRows.length) : 0,
|
|
489
610
|
inactive_pct: subscriberRows.length > 0 ? pctNumber(inactive / subscriberRows.length) : 0,
|
|
490
611
|
sender_health_score: computeSenderHealthScore(metrics, trackingEnabled),
|
|
612
|
+
coverage: {
|
|
613
|
+
log_rows_scanned: rows.length,
|
|
614
|
+
log_page_count: logPageCount,
|
|
615
|
+
event_rows_scanned: trackingEnabled ? eventRows.length : null,
|
|
616
|
+
event_page_count: trackingEnabled ? eventPageCount : null,
|
|
617
|
+
subscriber_rows_scanned: subscriberRows.length,
|
|
618
|
+
subscriber_page_count: subscriberPageCount,
|
|
619
|
+
complete: true,
|
|
620
|
+
},
|
|
491
621
|
};
|
|
492
622
|
if (trackingEnabled) {
|
|
493
623
|
response.open_rate = metrics.sent > 0 ? percent(metrics.open_rate) : 'N/A';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.25",
|
|
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",
|