@sendinel/mcp-server 1.0.66 → 1.0.68

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,7 +12,7 @@ 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 328 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 329 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
  |-------|-------|---------|
@@ -38,7 +38,7 @@ Sendinel currently exposes 34 tool groups and 328 tools (gateway mode adds 3 met
38
38
  | `landing-pages` | 5 | Landing page CRUD |
39
39
  | `lists` | 7 | Static contact lists and membership management |
40
40
  | `newsletters` | 12 | Newsletter publications, issues, subscribers, scheduling, sending, stats |
41
- | `org` | 23 | Team members, plan usage/limits, audit log, model registry, white-label, support tickets, provider switching |
41
+ | `org` | 24 | Team members, plan usage/limits, audit log, model registry, white-label, support tickets, outbound digest, provider switching |
42
42
  | `segments` | 6 | Audience builder: create, edit, natural-language generation, preview |
43
43
  | `short-links` | 3 | Short links, click stats, QR codes |
44
44
  | `signatures` | 4 | Sending signature blocks |
package/build/db.d.ts CHANGED
@@ -40,6 +40,7 @@ export declare function runWithClient<T>(client: any, fn: () => Promise<T>, requ
40
40
  oauthTokenId?: string | null;
41
41
  authKind?: string | null;
42
42
  oauthClientId?: string | null;
43
+ agentLabel?: string | null;
43
44
  keyId?: string | null;
44
45
  workspaceId?: string | null;
45
46
  v1Caller?: V1Caller;
@@ -59,6 +60,7 @@ export declare function requestContext(): {
59
60
  oauthTokenId?: string | null;
60
61
  authKind?: string | null;
61
62
  oauthClientId?: string | null;
63
+ agentLabel?: string | null;
62
64
  keyId?: string | null;
63
65
  workspaceId?: string | null;
64
66
  v1Caller?: V1Caller;
package/build/db.js CHANGED
@@ -71,6 +71,7 @@ export function requestContext() {
71
71
  oauthTokenId: ctx.oauthTokenId,
72
72
  authKind: ctx.authKind,
73
73
  oauthClientId: ctx.oauthClientId,
74
+ agentLabel: ctx.agentLabel,
74
75
  keyId: ctx.keyId,
75
76
  workspaceId: ctx.workspaceId,
76
77
  v1Caller: ctx.v1Caller,
@@ -48,6 +48,7 @@ export interface ApprovalResult {
48
48
  approve_url: string;
49
49
  message: string;
50
50
  }
51
+ export declare function approvalPageUrl(approvalId: string): string;
51
52
  /**
52
53
  * Request human approval for an action. Creates a pending approval token
53
54
  * and returns instructions for the agent to wait.
@@ -282,7 +282,7 @@ export function buildApprovalSummary(action, params, display = {}) {
282
282
  const payload = JSON.stringify(normalizeApprovalParams(params));
283
283
  return `Execute ${action} with: ${payload.length > 400 ? `${payload.slice(0, 399)}…` : payload}`;
284
284
  }
285
- function approvalPageUrl(approvalId) {
285
+ export function approvalPageUrl(approvalId) {
286
286
  const base = (process.env.NEXT_PUBLIC_APP_URL ?? process.env.SENDINEL_APP_URL ?? process.env.APP_URL ?? 'https://sendinel.ai').replace(/\/$/, '');
287
287
  return `${base}/dashboard/approvals/${approvalId}`;
288
288
  }
@@ -310,11 +310,14 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
310
310
  const summary = buildApprovalSummary(action, params, display);
311
311
  const expiresAt = new Date(Date.now() + 30 * 60 * 1000).toISOString();
312
312
  const { channel, verified } = channelContext ?? currentChannelContext();
313
+ const actor = requestContext();
313
314
  const { data, error } = await db()
314
315
  .from('action_approvals')
315
316
  .insert({
316
317
  project_id: getProjectId(),
317
- api_key_id: apiKeyId ?? null,
318
+ api_key_id: apiKeyId ?? actor?.keyId ?? null,
319
+ oauth_token_id: actor?.oauthTokenId ?? null,
320
+ agent_label: actor?.agentLabel ?? null,
318
321
  action,
319
322
  tier,
320
323
  params,
@@ -0,0 +1,57 @@
1
+ type AgentKind = 'worker' | 'connected_agent' | 'user' | 'system';
2
+ export declare function digestAgent(label: string | null): {
3
+ agent_kind: AgentKind | null;
4
+ agent_id: string | null;
5
+ agent_label: string | null;
6
+ };
7
+ type Counts = {
8
+ sent: number;
9
+ delivered: number;
10
+ bounced: number;
11
+ complained: number;
12
+ opened: number;
13
+ };
14
+ export type OutboundItem = ReturnType<typeof digestAgent> & {
15
+ project_id: string;
16
+ item: {
17
+ type: 'email' | 'broadcast';
18
+ id: string;
19
+ summary: string;
20
+ platform: null;
21
+ preview_url: string | null;
22
+ };
23
+ audience_count: number | null;
24
+ counts: Counts | null;
25
+ is_test: boolean;
26
+ approval_id: string | null;
27
+ approve_url: string | null;
28
+ outcome: 'pending_approval' | 'approved' | 'declined' | 'expired' | 'scheduled' | 'sent' | 'failed';
29
+ approved_by: string | null;
30
+ approved_at: string | null;
31
+ occurred_at: string;
32
+ provider_ref: string | null;
33
+ };
34
+ type DigestDb = {
35
+ schema(name: 'email'): {
36
+ rpc(name: string, args: {
37
+ p_from: string;
38
+ p_to: string;
39
+ p_project_id: string | null;
40
+ }): PromiseLike<{
41
+ data: unknown;
42
+ error: unknown;
43
+ }>;
44
+ };
45
+ };
46
+ /** Sources return scalar JSON arrays: no recipient reads and no PostgREST row cap. */
47
+ export declare function getOutboundDigest(sb: DigestDb, from: string, to: string, projectId?: string): Promise<{
48
+ product: 'sendinel';
49
+ range: {
50
+ from: string;
51
+ to: string;
52
+ };
53
+ status: string;
54
+ unknown_sources: string[];
55
+ items: OutboundItem[];
56
+ }>;
57
+ export {};
@@ -0,0 +1,75 @@
1
+ import { approvalPageUrl } from './action-approvals.js';
2
+ export function digestAgent(label) {
3
+ const match = label?.match(/^(worker|connected_agent|user|system):([^\s:]+)$/);
4
+ return {
5
+ agent_kind: match ? match[1] : null,
6
+ agent_id: match ? match[2] : null,
7
+ agent_label: label,
8
+ };
9
+ }
10
+ function approvalFields(approval) {
11
+ const url = approval ? approvalPageUrl(approval.id) : null;
12
+ return {
13
+ ...digestAgent(approval?.agent_label ?? null),
14
+ approval_id: approval?.id ?? null, approve_url: url,
15
+ approved_by: approval?.decided_by ?? null, approved_at: approval?.decided_at ?? null,
16
+ };
17
+ }
18
+ /** Sources return scalar JSON arrays: no recipient reads and no PostgREST row cap. */
19
+ export async function getOutboundDigest(sb, from, to, projectId) {
20
+ const unknownSources = [];
21
+ const args = { p_from: from, p_to: to, p_project_id: projectId ?? null };
22
+ async function source(name, rpc) {
23
+ try {
24
+ const result = await sb.schema('email').rpc(rpc, args);
25
+ if (result.error || !Array.isArray(result.data))
26
+ throw new Error('Source unavailable');
27
+ return result.data;
28
+ }
29
+ catch {
30
+ unknownSources.push(name);
31
+ return [];
32
+ }
33
+ }
34
+ // Separate reads preserve the readable source if the other fails, including during rollout.
35
+ const approvals = await source('action_approvals', 'outbound_digest_approvals');
36
+ const sends = await source('email_log', 'outbound_digest_sends');
37
+ const items = sends.map(send => {
38
+ const fields = approvalFields(send.approval);
39
+ const broadcast = send.campaign_type === 'broadcast';
40
+ return {
41
+ project_id: send.project_id, ...fields,
42
+ item: {
43
+ type: broadcast ? 'broadcast' : 'email',
44
+ // Standalone logs without a campaign/step are one unknown group per category, never per recipient.
45
+ id: (broadcast ? send.campaign_id : send.step_id) ?? 'unknown',
46
+ summary: send.subject ?? send.campaign_name ?? 'unknown', platform: null, preview_url: fields.approve_url,
47
+ },
48
+ audience_count: send.audience_count, counts: send.counts, is_test: send.category === 'test',
49
+ outcome: send.failed ? 'failed' : 'sent', occurred_at: send.occurred_at, provider_ref: send.provider_ref,
50
+ };
51
+ });
52
+ const sentApprovals = new Set(sends.flatMap(send => send.approval ? [send.approval.id] : []));
53
+ for (const approval of approvals) {
54
+ if (sentApprovals.has(approval.id))
55
+ continue;
56
+ const fields = approvalFields(approval);
57
+ const outcome = approval.status === 'pending'
58
+ ? (Date.parse(approval.expires_at) <= Date.parse(to) ? 'expired' : 'pending_approval')
59
+ : approval.status === 'rejected' ? 'declined'
60
+ : approval.status === 'expired' ? 'expired' : 'approved';
61
+ items.push({
62
+ project_id: approval.project_id, ...fields,
63
+ item: { type: approval.action.includes('broadcast') ? 'broadcast' : 'email', id: approval.id,
64
+ summary: approval.summary, platform: null, preview_url: fields.approve_url },
65
+ audience_count: null, counts: null, is_test: approval.action === 'send_test_email',
66
+ outcome, occurred_at: approval.occurred_at, provider_ref: null,
67
+ });
68
+ }
69
+ items.sort((a, b) => a.occurred_at.localeCompare(b.occurred_at) || a.project_id.localeCompare(b.project_id) || a.item.id.localeCompare(b.item.id));
70
+ return {
71
+ product: 'sendinel', range: { from, to },
72
+ status: unknownSources.length === 0 ? 'complete' : unknownSources.length === 2 ? 'unknown' : 'partial',
73
+ unknown_sources: unknownSources, items,
74
+ };
75
+ }
@@ -5,16 +5,23 @@
5
5
  // than a cross-package import — keep both in sync if the SSRF guard changes.
6
6
  import dns from 'node:dns/promises';
7
7
  import net from 'node:net';
8
- import { Agent } from 'undici';
8
+ // #4049: fetch must come from the same undici as Agent. Node's global fetch bundles its own undici and
9
+ // rejects a foreign dispatcher (UND_ERR_INVALID_ARG) before any request, so every pinned fetch failed.
10
+ import { Agent, fetch as undiciFetch } from 'undici';
9
11
  export function createPinnedIpDispatcher(hostname, resolved) {
10
12
  const expectedHost = hostname.toLowerCase();
11
13
  return new Agent({
12
14
  connect: {
13
- lookup(requestedHost, _options, callback) {
15
+ lookup(requestedHost, options, callback) {
14
16
  if (String(requestedHost).toLowerCase() !== expectedHost) {
15
17
  callback(new Error('Pinned fetch attempted to resolve an unexpected host'), []);
16
18
  return;
17
19
  }
20
+ // Node's connect asks for every address when options.all is set; answer in the shape it asked for.
21
+ if (options?.all) {
22
+ callback(null, [{ address: resolved.address, family: resolved.family }]);
23
+ return;
24
+ }
18
25
  callback(null, resolved.address, resolved.family);
19
26
  },
20
27
  },
@@ -23,7 +30,7 @@ export function createPinnedIpDispatcher(hostname, resolved) {
23
30
  }
24
31
  export function fetchPinnedToResolvedIp(url, resolved, init = {}) {
25
32
  const dispatcher = createPinnedIpDispatcher(url.hostname, resolved);
26
- return fetch(url, { ...init, dispatcher });
33
+ return undiciFetch(url, { ...init, dispatcher });
27
34
  }
28
35
  function isPrivateIp(address) {
29
36
  if (net.isIPv4(address)) {
@@ -902,6 +902,23 @@ export function registerOrgTools(server) {
902
902
  });
903
903
  return result.ok ? j(result.data) : j({ error: result.error });
904
904
  });
905
+ server.registerTool('get_outbound_digest', {
906
+ title: 'Get Outbound Digest',
907
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
908
+ description: 'Daily fleet outbound digest of approvals and SQL-aggregated sends, including separate test sends. Platform-admin only; tenant credentials receive Not available. Unknown attribution stays null; failed sources are explicit.',
909
+ inputSchema: {
910
+ since: z.iso.datetime({ offset: true }).optional().describe('Start of the window; defaults to 24 hours ago.'),
911
+ project_id: z.string().uuid().optional(),
912
+ },
913
+ }, async ({ since, project_id }) => {
914
+ const query = {};
915
+ if (since)
916
+ query.since = since;
917
+ if (project_id)
918
+ query.project_id = project_id;
919
+ const result = await callSendinelV1('/api/v1/outbound-digest', { method: 'GET', query });
920
+ return result.ok ? j(result.data) : j({ error: result.error });
921
+ });
905
922
  server.registerTool('start_provider_switch', {
906
923
  title: 'Start Provider Switch',
907
924
  annotations: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.66",
3
+ "version": "1.0.68",
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",