@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 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 314 tools (gateway mode adds 3 meta-tools). These counts are checked against the tool registry in CI, so this table matches what ships.
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` | 17 | Stats, portfolio analytics, domain health, engagement, deliverability, revenue 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
@@ -68,6 +68,7 @@ export function requestContext() {
68
68
  channelVerified: ctx.channelVerified,
69
69
  actorType: ctx.actorType,
70
70
  actorId: ctx.actorId,
71
+ oauthTokenId: ctx.oauthTokenId,
71
72
  workspaceId: ctx.workspaceId,
72
73
  v1Caller: ctx.v1Caller,
73
74
  }
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',
@@ -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(events ?? [], 'open', messageIds),
459
- clicked: distinctEventCount(events ?? [], 'click', messageIds),
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.23",
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",