@sendinel/mcp-server 1.0.65 → 1.0.67

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
+ }
@@ -3140,6 +3140,16 @@ Return ONLY valid JSON with this exact shape:
3140
3140
  survey_id: z.string().uuid().optional(),
3141
3141
  skip_if_event: z.string().trim().min(1).nullable().optional().describe('Event name that skips this step at send time; null clears the rule.'),
3142
3142
  send_only_if_event: z.string().trim().min(1).nullable().optional().describe('Event required for this step to send; null clears the rule.'),
3143
+ link_actions: z.array(z.discriminatedUnion('action', [
3144
+ z.object({ url_pattern: z.string().trim().min(1).max(500), action: z.enum(['add_tag', 'remove_tag']), tag: z.string().regex(/^[a-z0-9][a-z0-9-]{0,62}$/) }),
3145
+ z.object({
3146
+ url_pattern: z.string().trim().min(1).max(500).describe('Placeholder URL in the email body (a trailing * matches a prefix). Matching links are rewritten at send time to a signed /api/pick/<token> confirm page.'),
3147
+ action: z.literal('set_property'),
3148
+ property: z.string().regex(/^[a-z][a-z0-9_]{0,62}$/).describe('Custom contact property to set, for example "track". Consent, suppression and unsubscribe fields are rejected.'),
3149
+ value: z.string().trim().min(1).max(100),
3150
+ continue_url: z.string().url().max(500).optional().describe('Optional http(s) link shown on the thank-you page.'),
3151
+ }),
3152
+ ])).max(10).optional().describe('Link actions for this step. set_property links show a Confirm page; only the Confirm click writes the property (last click wins). Replaces the whole list.'),
3143
3153
  },
3144
3154
  }, async ({ step_id, ...updates }) => {
3145
3155
  const projectId = getProjectId();
@@ -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.65",
3
+ "version": "1.0.67",
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",