@sendinel/mcp-server 1.0.4 → 1.0.5

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.
Files changed (60) hide show
  1. package/build/audit.d.ts +1 -0
  2. package/build/audit.js +9 -3
  3. package/build/auth.d.ts +10 -0
  4. package/build/auth.js +82 -9
  5. package/build/db.d.ts +12 -0
  6. package/build/db.js +37 -2
  7. package/build/index.js +38 -12
  8. package/build/lib/action-approvals.d.ts +28 -1
  9. package/build/lib/action-approvals.js +89 -2
  10. package/build/lib/brand-asset-persist.d.ts +35 -0
  11. package/build/lib/brand-asset-persist.js +161 -0
  12. package/build/lib/brand-asset-usage.d.ts +13 -0
  13. package/build/lib/brand-asset-usage.js +46 -0
  14. package/build/lib/email-provider.d.ts +19 -0
  15. package/build/lib/email-provider.js +281 -0
  16. package/build/lib/ssrf-pinned-fetch.d.ts +16 -0
  17. package/build/lib/ssrf-pinned-fetch.js +101 -0
  18. package/build/lib/survey-link-resolver.d.ts +6 -0
  19. package/build/lib/survey-link-resolver.js +23 -0
  20. package/build/lib/svg-sanitize.d.ts +2 -0
  21. package/build/lib/svg-sanitize.js +110 -0
  22. package/build/lib/webhook-events.d.ts +1 -1
  23. package/build/scoped-token.d.ts +11 -0
  24. package/build/scoped-token.js +59 -0
  25. package/build/server.js +30 -9
  26. package/build/tools/ab-testing.js +28 -0
  27. package/build/tools/advisor.js +52 -2
  28. package/build/tools/ai-work.d.ts +2 -0
  29. package/build/tools/ai-work.js +460 -0
  30. package/build/tools/analytics.d.ts +1 -0
  31. package/build/tools/analytics.js +203 -0
  32. package/build/tools/approvals.js +15 -1
  33. package/build/tools/automations.js +288 -65
  34. package/build/tools/brand-kit.d.ts +2 -0
  35. package/build/tools/brand-kit.js +277 -0
  36. package/build/tools/campaigns.js +900 -69
  37. package/build/tools/compound.js +28 -0
  38. package/build/tools/contacts.js +338 -17
  39. package/build/tools/content.js +266 -1
  40. package/build/tools/data-proposals.js +21 -0
  41. package/build/tools/data.js +121 -5
  42. package/build/tools/delivery-ops.js +84 -0
  43. package/build/tools/drafts.js +64 -1
  44. package/build/tools/forms.js +42 -9
  45. package/build/tools/gdpr.js +22 -3
  46. package/build/tools/newsletters.d.ts +2 -0
  47. package/build/tools/newsletters.js +583 -0
  48. package/build/tools/org.js +297 -3
  49. package/build/tools/segments.js +45 -3
  50. package/build/tools/short-links.d.ts +2 -0
  51. package/build/tools/short-links.js +157 -0
  52. package/build/tools/sites.js +35 -0
  53. package/build/tools/sms.js +49 -0
  54. package/build/tools/social-posts.js +81 -5
  55. package/build/tools/surveys.d.ts +2 -0
  56. package/build/tools/surveys.js +222 -0
  57. package/build/tools/templates.js +78 -2
  58. package/build/tools/warmup.js +28 -0
  59. package/build/tools/webhooks.js +29 -0
  60. package/package.json +9 -3
@@ -0,0 +1,110 @@
1
+ // Self-contained port of packages/dashboard/lib/validation/svg-sanitize.ts —
2
+ // see ssrf-pinned-fetch.ts for why this is a copy, not a cross-package import.
3
+ import { XMLBuilder, XMLParser, XMLValidator } from 'fast-xml-parser';
4
+ // ALLOWLIST, not blocklist. A blocklist lets any tag it doesn't know about through —
5
+ // and SVG has active-content vectors beyond the obvious <script>: animation elements
6
+ // (<set>/<animate>/<animateTransform>) inject event handlers/hrefs at render time,
7
+ // <use>/<image> pull external/data refs, <style> carries CSS injection, <a> carries
8
+ // links. Only known-safe PRESENTATIONAL elements survive; everything else (and its
9
+ // subtree) is dropped. DOM-free (fast-xml-parser) so it pulls no jsdom.
10
+ const allowedTags = new Set([
11
+ 'svg', 'g', 'defs', 'symbol', 'title', 'desc', 'a',
12
+ 'path', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon',
13
+ 'text', 'tspan', 'textpath',
14
+ 'lineargradient', 'radialgradient', 'stop', 'pattern', 'clippath', 'mask', 'marker',
15
+ 'filter', 'fedropshadow', 'fegaussianblur', 'fecolormatrix', 'feoffset', 'feblend',
16
+ 'femerge', 'femergenode', 'fecomposite', 'feflood', 'fetile', 'feturbulence',
17
+ 'fedisplacementmap', 'fespecularlighting', 'fediffuselighting', 'fepointlight',
18
+ 'fespotlight', 'fedistantlight', 'fecomponenttransfer', 'fefunca', 'fefuncr',
19
+ 'fefuncg', 'fefuncb', 'feconvolvematrix', 'femorphology',
20
+ ]);
21
+ const urlAttributes = new Set(['href', 'xlink:href', 'src', 'xlink:src']);
22
+ const parserOptions = {
23
+ allowBooleanAttributes: true,
24
+ ignoreAttributes: false,
25
+ parseAttributeValue: false,
26
+ parseTagValue: false,
27
+ trimValues: false,
28
+ };
29
+ const parser = new XMLParser(parserOptions);
30
+ const builder = new XMLBuilder(parserOptions);
31
+ function isRecord(value) {
32
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
33
+ }
34
+ function normalizeName(name) {
35
+ return name.startsWith('@_') ? name.slice(2).toLowerCase() : name.toLowerCase();
36
+ }
37
+ function decodeEntities(value) {
38
+ return value
39
+ .replace(/&#x([0-9a-f]+);?/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))
40
+ .replace(/&#(\d+);?/g, (_, d) => String.fromCodePoint(parseInt(d, 10)))
41
+ .replace(/&lt;/gi, '<').replace(/&gt;/gi, '>').replace(/&quot;/gi, '"')
42
+ .replace(/&apos;/gi, "'").replace(/&amp;/gi, '&');
43
+ }
44
+ function isUnsafeAttribute(name, value) {
45
+ const normalizedName = normalizeName(name);
46
+ if (normalizedName.startsWith('on'))
47
+ return true;
48
+ if (normalizedName.startsWith('data-'))
49
+ return true;
50
+ if (normalizedName === 'style')
51
+ return true;
52
+ if (typeof value !== 'string')
53
+ return false;
54
+ const normalizedValue = decodeEntities(value).replace(/\s+/g, '').toLowerCase();
55
+ if (normalizedValue.includes('javascript:'))
56
+ return true;
57
+ if (normalizedValue.includes('vbscript:'))
58
+ return true;
59
+ if (urlAttributes.has(normalizedName) && normalizedValue.startsWith('data:'))
60
+ return true;
61
+ return false;
62
+ }
63
+ function sanitizeNode(value) {
64
+ if (Array.isArray(value)) {
65
+ return value.map(sanitizeNode).filter((item) => item !== undefined);
66
+ }
67
+ if (!isRecord(value)) {
68
+ return value;
69
+ }
70
+ const sanitized = {};
71
+ for (const [key, child] of Object.entries(value)) {
72
+ if (key.startsWith('@_')) {
73
+ if (!isUnsafeAttribute(key, child))
74
+ sanitized[key] = child;
75
+ continue;
76
+ }
77
+ if (key === '#text') {
78
+ sanitized[key] = child;
79
+ continue;
80
+ }
81
+ if (!allowedTags.has(normalizeName(key)))
82
+ continue;
83
+ const sanitizedChild = sanitizeNode(child);
84
+ if (sanitizedChild !== undefined) {
85
+ sanitized[key] = sanitizedChild;
86
+ }
87
+ }
88
+ return sanitized;
89
+ }
90
+ export function sanitizeSvg(svg) {
91
+ if (XMLValidator.validate(svg) !== true) {
92
+ return '';
93
+ }
94
+ const parsed = parser.parse(svg);
95
+ if (!isRecord(parsed) || !isRecord(parsed.svg)) {
96
+ return '';
97
+ }
98
+ const sanitized = sanitizeNode({ svg: parsed.svg });
99
+ if (!isRecord(sanitized)) {
100
+ return '';
101
+ }
102
+ return builder
103
+ .build(sanitized)
104
+ .replace(/\s+on[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
105
+ .replace(/\s+(href|xlink:href|src)\s*=\s*("|')\s*javascript:[\s\S]*?\2/gi, '')
106
+ .replace(/\s+(href|xlink:href|src)\s*=\s*("|')\s*data:[\s\S]*?\2/gi, '');
107
+ }
108
+ export function sanitizeSvgBytes(bytes) {
109
+ return Buffer.from(sanitizeSvg(Buffer.from(bytes).toString('utf8')), 'utf8');
110
+ }
@@ -1,4 +1,4 @@
1
- export type WebhookEvent = 'campaign.activated' | 'campaign.completed' | 'campaign.paused' | 'email.delivered' | 'email.bounced' | 'email.complained' | 'bounce_rate.warning' | 'domain.dns_lost' | 'approval.requested' | 'approval.decided' | 'social.post.created' | 'social.post.updated' | 'social.post.scheduled' | 'social.post.published' | 'social.post.failed' | 'social.post.archived';
1
+ export type WebhookEvent = 'campaign.activated' | 'campaign.completed' | 'campaign.paused' | 'email.delivered' | 'email.bounced' | 'email.complained' | 'bounce_rate.warning' | 'domain.dns_lost' | 'approval.requested' | 'approval.decided' | 'social.post.created' | 'social.post.updated' | 'social.post.scheduled' | 'social.post.published' | 'social.post.failed' | 'social.post.archived' | 'asset_link.viewed';
2
2
  /**
3
3
  * Fire a webhook event to all active subscriptions matching the event type.
4
4
  * Non-blocking — failures are logged to webhook_deliveries for retry.
@@ -0,0 +1,11 @@
1
+ export declare const SCOPED_TOKEN_SCOPES: readonly ["data", "service:provision"];
2
+ export type ScopedTokenScope = (typeof SCOPED_TOKEN_SCOPES)[number];
3
+ export type ScopedTokenClaims = {
4
+ userId: string;
5
+ projectId: string | null;
6
+ workspaceId: string | null;
7
+ scope: ScopedTokenScope;
8
+ };
9
+ export declare function scopedJwtVerificationEnabled(): boolean;
10
+ export declare function looksLikeScopedJwt(token: string): boolean;
11
+ export declare function verifyScopedToken(token: string): Promise<ScopedTokenClaims>;
@@ -0,0 +1,59 @@
1
+ import { createRemoteJWKSet, jwtVerify } from 'jose';
2
+ const ISSUER = 'synchronex';
3
+ const AUDIENCE = 'sendinel';
4
+ const ALGORITHM = 'RS256';
5
+ const MAX_TOKEN_AGE = '5m';
6
+ export const SCOPED_TOKEN_SCOPES = ['data', 'service:provision'];
7
+ let jwks = null;
8
+ export function scopedJwtVerificationEnabled() {
9
+ return Boolean(process.env.SYNCHRONEX_JWKS_URL);
10
+ }
11
+ export function looksLikeScopedJwt(token) {
12
+ return !token.startsWith('snk_') && token.split('.').length === 3;
13
+ }
14
+ function getJwks() {
15
+ if (!jwks) {
16
+ const url = process.env.SYNCHRONEX_JWKS_URL;
17
+ if (!url)
18
+ throw new Error('SYNCHRONEX_JWKS_URL is not configured');
19
+ jwks = createRemoteJWKSet(new URL(url));
20
+ }
21
+ return jwks;
22
+ }
23
+ export async function verifyScopedToken(token) {
24
+ const { payload } = await jwtVerify(token, getJwks(), {
25
+ issuer: ISSUER,
26
+ audience: AUDIENCE,
27
+ algorithms: [ALGORITHM],
28
+ requiredClaims: ['sub', 'scope', 'sendinel_project_id', 'iat', 'exp'],
29
+ maxTokenAge: MAX_TOKEN_AGE,
30
+ });
31
+ const userId = typeof payload.sub === 'string' ? payload.sub.trim() : '';
32
+ const scope = typeof payload.scope === 'string' ? payload.scope : '';
33
+ const projectId = typeof payload.sendinel_project_id === 'string'
34
+ ? payload.sendinel_project_id.trim()
35
+ : null;
36
+ const workspaceId = typeof payload.workspace_id === 'string'
37
+ ? payload.workspace_id.trim()
38
+ : null;
39
+ if (!userId)
40
+ throw new Error('Scoped token subject is required');
41
+ if (!SCOPED_TOKEN_SCOPES.includes(scope)) {
42
+ throw new Error('Scoped token scope is invalid');
43
+ }
44
+ if (scope === 'data' && !projectId) {
45
+ throw new Error('Data-scoped token project is required');
46
+ }
47
+ if (payload.sendinel_project_id !== null && typeof payload.sendinel_project_id !== 'string') {
48
+ throw new Error('Scoped token project claim is invalid');
49
+ }
50
+ if (payload.workspace_id !== null && payload.workspace_id !== undefined && typeof payload.workspace_id !== 'string') {
51
+ throw new Error('Scoped token workspace claim is invalid');
52
+ }
53
+ return {
54
+ userId,
55
+ projectId,
56
+ workspaceId,
57
+ scope: scope,
58
+ };
59
+ }
package/build/server.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { runWithToolContext } from './db.js';
1
2
  import { registerAnalyticsTools } from './tools/analytics.js';
2
3
  import { registerCampaignTools } from './tools/campaigns.js';
3
4
  import { registerContactTools } from './tools/contacts.js';
@@ -21,6 +22,11 @@ import { registerOrgTools } from './tools/org.js';
21
22
  import { registerSmsTools } from './tools/sms.js';
22
23
  import { registerSocialPostTools } from './tools/social-posts.js';
23
24
  import { registerDataProposalTools } from './tools/data-proposals.js';
25
+ import { registerSurveyTools } from './tools/surveys.js';
26
+ import { registerShortLinkTools } from './tools/short-links.js';
27
+ import { registerAiWorkTools } from './tools/ai-work.js';
28
+ import { registerBrandKitTools } from './tools/brand-kit.js';
29
+ import { registerNewsletterTools } from './tools/newsletters.js';
24
30
  const GROUP_REGISTRY = {
25
31
  analytics: registerAnalyticsTools,
26
32
  campaigns: registerCampaignTools,
@@ -45,6 +51,11 @@ const GROUP_REGISTRY = {
45
51
  sms: registerSmsTools,
46
52
  'social-posts': registerSocialPostTools,
47
53
  'data-proposals': registerDataProposalTools,
54
+ surveys: registerSurveyTools,
55
+ 'short-links': registerShortLinkTools,
56
+ 'ai-work': registerAiWorkTools,
57
+ brand_kit: registerBrandKitTools,
58
+ newsletters: registerNewsletterTools,
48
59
  };
49
60
  export const ALL_TOOL_GROUPS = Object.keys(GROUP_REGISTRY);
50
61
  // ── Scope enforcement ─────────────────────────────────────────────────────
@@ -53,8 +64,9 @@ const WRITE_TOOLS = new Set([
53
64
  'add_subscriber', 'import_subscribers', 'update_subscriber',
54
65
  'enrich_subscriber',
55
66
  'create_campaign', 'update_campaign', 'delete_campaign', 'add_campaign_step', 'update_campaign_status',
67
+ 'review_campaign_portfolio',
56
68
  'list_campaign_steps', 'update_campaign_step', 'delete_campaign_step',
57
- 'enroll_contact', 'enroll_segment', 'get_enrollment_status', 'clone_campaign', 'generate_email',
69
+ 'enroll_contact', 'enroll_segment', 'broadcast_to_segment', 'get_enrollment_status', 'clone_campaign', 'generate_email',
58
70
  'create_social_campaign', 'schedule_social_post',
59
71
  'create_social_post', 'update_social_post', 'delete_social_post',
60
72
  'create_draft', 'approve_draft', 'reject_draft',
@@ -67,13 +79,17 @@ const WRITE_TOOLS = new Set([
67
79
  'send_test_email', 'send_transactional', 'subscribe_webhook', 'update_webhook_subscription',
68
80
  'create_ab_test', 'promote_ab_winner', 'optimize_subject_lines',
69
81
  'unsubscribe_subscriber', 'add_suppression', 'remove_suppression',
70
- 'trigger_automation', 'enroll_automation',
82
+ 'trigger_automation', 'enroll_automation', 'create_escalation_ladder', 'enroll_in_escalation_ladder',
71
83
  'update_scoring_rules', 'register_domain',
72
84
  'update_warmup_schedule', 'pause_warmup', 'resume_warmup',
73
85
  'create_form', 'invite_team_member',
86
+ 'start_provider_switch', 'get_provider_switch_status', 'rollback_provider_switch',
74
87
  'create_api_key', 'revoke_api_key', 'mark_notifications_read',
75
88
  'migrate_campaigns_from', 'start_platform_migration',
76
89
  'create_content_block', 'delete_content_block', 'trigger_content_pipeline',
90
+ 'create_asset_link',
91
+ 'set_white_label_settings', 'add_white_label_domain',
92
+ 'create_blog_post',
77
93
  'run_list_hygiene', 'run_portfolio_analysis',
78
94
  'abuse_monitor_override',
79
95
  'send_sms', 'send_sms_campaign', 'set_contact_sms_consent',
@@ -81,6 +97,12 @@ const WRITE_TOOLS = new Set([
81
97
  'connect_platform', 'disconnect_platform', 'configure_platform_trigger',
82
98
  'update_brand_voice', 'promote_draft_to_campaign',
83
99
  'propose_campaign_from_data', 'approve_data_proposal',
100
+ 'create_survey', 'update_survey', 'create_short_link', 'generate_qr_code',
101
+ 'mark_ai_work_item_seen', 'snooze_ai_work_item', 'dismiss_ai_work_item',
102
+ 'submit_ai_context_event',
103
+ 'import_brand_kit_text', 'ingest_brand_assets_by_url', 'upload_brand_asset',
104
+ 'create_newsletter', 'update_newsletter', 'create_newsletter_issue', 'schedule_newsletter_issue',
105
+ 'send_newsletter_issue', 'subscribe_to_newsletter', 'unsubscribe_from_newsletter',
84
106
  ]);
85
107
  const ADMIN_TOOLS = new Set([
86
108
  'delete_subscriber_data', 'list_deletion_log',
@@ -93,6 +115,7 @@ const ADMIN_TOOLS = new Set([
93
115
  'tenant_export_full',
94
116
  'identify_inactive', 'delete_segment',
95
117
  'merge_subscribers', 'delete_webhook_subscription',
118
+ 'set_white_label_settings', 'add_white_label_domain',
96
119
  ]);
97
120
  const SCOPE_HIERARCHY = { read: 1, write: 2, admin: 3 };
98
121
  export function getRequiredScope(name) {
@@ -122,12 +145,12 @@ export function getRegisteredToolGroups(options) {
122
145
  toolGroups[name] = currentGroup;
123
146
  },
124
147
  };
125
- const effectiveServer = createScopeProxy(collector, options?.scopes?.length ? options.scopes : null);
126
148
  for (const group of groups) {
127
149
  const register = GROUP_REGISTRY[group];
128
150
  if (!register)
129
151
  continue;
130
152
  currentGroup = group;
153
+ const effectiveServer = createScopeProxy(collector, options?.scopes?.length ? options.scopes : null, group);
131
154
  register(effectiveServer);
132
155
  }
133
156
  return toolGroups;
@@ -183,7 +206,7 @@ function scanToolDefinition(name, config) {
183
206
  * and wraps handlers with scope enforcement.
184
207
  */
185
208
  // scopes=null means no scope restriction (all tools allowed) but scan still runs.
186
- function createScopeProxy(server, scopes) {
209
+ function createScopeProxy(server, scopes, toolGroup) {
187
210
  return new Proxy(server, {
188
211
  get(target, prop, receiver) {
189
212
  if (prop === 'registerTool') {
@@ -210,11 +233,11 @@ function createScopeProxy(server, scopes) {
210
233
  if (!scopeAllows(scopes, required)) {
211
234
  return { content: [{ type: 'text', text: `Forbidden: this action requires "${required}" scope.` }] };
212
235
  }
213
- return handler(...args);
236
+ return runWithToolContext(toolGroup, name, () => handler(...args));
214
237
  };
215
238
  return target.registerTool(name, config, guardedHandler);
216
239
  }
217
- return target.registerTool(name, config, handler);
240
+ return target.registerTool(name, config, (...args) => runWithToolContext(toolGroup, name, () => handler(...args)));
218
241
  };
219
242
  }
220
243
  return Reflect.get(target, prop, receiver);
@@ -225,11 +248,9 @@ export function registerAllTools(server, options) {
225
248
  const groups = options?.toolGroups === undefined
226
249
  ? ALL_TOOL_GROUPS
227
250
  : options.toolGroups;
228
- // Always wrap — scanner runs for every caller, scope enforcement only when scopes provided
229
- const effectiveServer = createScopeProxy(server, options?.scopes?.length ? options.scopes : null);
230
251
  for (const group of groups) {
231
252
  const register = GROUP_REGISTRY[group];
232
253
  if (register)
233
- register(effectiveServer);
254
+ register(createScopeProxy(server, options?.scopes?.length ? options.scopes : null, group));
234
255
  }
235
256
  }
@@ -5,6 +5,13 @@ const txt = (text) => ({ content: [{ type: 'text', text }] });
5
5
  const j = (data) => txt(JSON.stringify(data, null, 2));
6
6
  export function registerAbTestingTools(server) {
7
7
  server.registerTool('create_ab_test', {
8
+ title: 'Create A/B Test',
9
+ annotations: {
10
+ readOnlyHint: false,
11
+ destructiveHint: false,
12
+ idempotentHint: false,
13
+ openWorldHint: false,
14
+ },
8
15
  description: 'Create an A/B test for a campaign step. Requires at least 2 variant steps (add via add_campaign_step with variant_label). Returns the test config for review.',
9
16
  inputSchema: {
10
17
  campaign_id: z.string().uuid(),
@@ -72,6 +79,13 @@ export function registerAbTestingTools(server) {
72
79
  });
73
80
  });
74
81
  server.registerTool('list_ab_tests', {
82
+ title: 'List A/B Tests',
83
+ annotations: {
84
+ readOnlyHint: true,
85
+ destructiveHint: false,
86
+ idempotentHint: true,
87
+ openWorldHint: false,
88
+ },
75
89
  description: 'List A/B tests, optionally filtered by campaign or status',
76
90
  inputSchema: {
77
91
  campaign_id: z.string().uuid().optional(),
@@ -95,6 +109,13 @@ export function registerAbTestingTools(server) {
95
109
  return j(data);
96
110
  });
97
111
  server.registerTool('check_ab_significance', {
112
+ title: 'Check A/B Significance',
113
+ annotations: {
114
+ readOnlyHint: true,
115
+ destructiveHint: false,
116
+ idempotentHint: true,
117
+ openWorldHint: false,
118
+ },
98
119
  description: 'Check statistical significance of an A/B test. Uses a two-proportion z-test. Returns per-variant rates, z-score, p-value, and whether the result is significant at the configured confidence level.',
99
120
  inputSchema: {
100
121
  test_id: z.string().uuid(),
@@ -131,6 +152,13 @@ export function registerAbTestingTools(server) {
131
152
  return j(data);
132
153
  });
133
154
  server.registerTool('promote_ab_winner', {
155
+ title: 'Promote A/B Winner',
156
+ annotations: {
157
+ readOnlyHint: false,
158
+ destructiveHint: true,
159
+ idempotentHint: false,
160
+ openWorldHint: false,
161
+ },
134
162
  description: 'End an A/B test and keep only the winning variant. Requires confirmation (two-call pattern). Deletes the losing variant step(s) from campaign_steps.',
135
163
  inputSchema: {
136
164
  test_id: z.string().uuid(),
@@ -4,6 +4,7 @@ import { db } from '../db.js';
4
4
  import { getProjectId } from '../project.js';
5
5
  import { buildVoicePrompt } from '../lib/voice.js';
6
6
  import { getAnthropicModelId } from '../anthropic-model.js';
7
+ import { dkimRemediationText } from '../lib/email-provider.js';
7
8
  const txt = (text) => ({ content: [{ type: 'text', text }] });
8
9
  const j = (data) => txt(JSON.stringify(data, null, 2));
9
10
  const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
@@ -58,6 +59,13 @@ export function buildTimeBuckets(rows, groupBy, metric) {
58
59
  // ── Registration ──────────────────────────────────────────────────────
59
60
  export function registerAdvisorTools(server) {
60
61
  server.registerTool('run_portfolio_analysis', {
62
+ title: 'Run Portfolio Analysis',
63
+ annotations: {
64
+ readOnlyHint: true,
65
+ destructiveHint: false,
66
+ idempotentHint: true,
67
+ openWorldHint: false,
68
+ },
61
69
  description: 'Run a grounded portfolio health analysis across active campaigns.',
62
70
  inputSchema: { force_run: z.boolean().optional().default(false) },
63
71
  }, async ({ force_run = false }) => {
@@ -98,6 +106,13 @@ export function registerAdvisorTools(server) {
98
106
  });
99
107
  // ── 1. campaign_advisor ────────────────────────────────────────────
100
108
  server.registerTool('campaign_advisor', {
109
+ title: 'Campaign Advisor',
110
+ annotations: {
111
+ readOnlyHint: true,
112
+ destructiveHint: false,
113
+ idempotentHint: true,
114
+ openWorldHint: true,
115
+ },
101
116
  description: 'Analyze campaign performance and get actionable recommendations. Returns period-over-period metrics, domain health issues, engagement distribution, and prioritized recommendations with severity levels.',
102
117
  inputSchema: {
103
118
  site_id: z.string().uuid().optional().describe('Filter to a specific site'),
@@ -278,6 +293,13 @@ export function registerAdvisorTools(server) {
278
293
  });
279
294
  // ── 2. optimize_subject_lines ──────────────────────────────────────
280
295
  server.registerTool('optimize_subject_lines', {
296
+ title: 'Optimize Subject Lines',
297
+ annotations: {
298
+ readOnlyHint: true,
299
+ destructiveHint: false,
300
+ idempotentHint: true,
301
+ openWorldHint: true,
302
+ },
281
303
  description: 'Generate optimized subject line variants using AI, informed by historical top performers and brand voice. Optionally creates campaign step variants for A/B testing.',
282
304
  inputSchema: {
283
305
  campaign_id: z.string().uuid(),
@@ -402,6 +424,13 @@ Each subject should be distinct in approach (urgency, curiosity, benefit, social
402
424
  });
403
425
  // ── 3. suggest_content_topics ────────────────────────────────────
404
426
  server.registerTool('suggest_content_topics', {
427
+ title: 'Suggest Content Topics',
428
+ annotations: {
429
+ readOnlyHint: true,
430
+ destructiveHint: false,
431
+ idempotentHint: true,
432
+ openWorldHint: true,
433
+ },
405
434
  description: 'Analyze past email performance and recommend content topics for future emails. Uses AI to identify patterns in high- and low-performing content, then suggests specific topics with expected engagement levels.',
406
435
  inputSchema: {
407
436
  site_id: z.string().uuid().describe('Site UUID to analyze'),
@@ -515,6 +544,13 @@ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rat
515
544
  });
516
545
  // ── 4. suggest_send_time ───────────────────────────────────────────
517
546
  server.registerTool('suggest_send_time', {
547
+ title: 'Suggest Send Time',
548
+ annotations: {
549
+ readOnlyHint: true,
550
+ destructiveHint: false,
551
+ idempotentHint: true,
552
+ openWorldHint: false,
553
+ },
518
554
  description: 'Analyze historical engagement patterns to recommend optimal send times. Groups by hour-of-day and day-of-week to identify when your audience is most responsive.',
519
555
  inputSchema: {
520
556
  site_id: z.string().uuid(),
@@ -583,6 +619,13 @@ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rat
583
619
  });
584
620
  // ── 5. recovery_plan ───────────────────────────────────────────────
585
621
  server.registerTool('recovery_plan', {
622
+ title: 'Recovery Plan',
623
+ annotations: {
624
+ readOnlyHint: true,
625
+ destructiveHint: false,
626
+ idempotentHint: true,
627
+ openWorldHint: false,
628
+ },
586
629
  description: 'Analyze deliverability issues for a site and generate a structured recovery plan. Examines domain health (SPF/DKIM/DMARC), bounce/complaint rates, recent send stats, and new suppressions to produce prioritized immediate, short-term, and monitoring steps. Deterministic — no AI call, pure rule-based analysis.',
587
630
  inputSchema: {
588
631
  site_id: z.string().uuid().describe('Site UUID with deliverability issues'),
@@ -670,8 +713,15 @@ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rat
670
713
  // DNS fixes
671
714
  if (!spfValid)
672
715
  immediate.push('Add SPF record for sending domain');
673
- if (!dkimValid)
674
- immediate.push('Configure DKIM record (CNAME for resend._domainkey)');
716
+ if (!dkimValid) {
717
+ const { data: dkimSite } = await db()
718
+ .from('sites')
719
+ .select('provider')
720
+ .eq('project_id', projectId)
721
+ .eq('id', site_id)
722
+ .maybeSingle();
723
+ immediate.push(dkimRemediationText(dkimSite?.provider));
724
+ }
675
725
  if (!dmarcValid)
676
726
  shortTerm.push('Add DMARC record (start with p=none for monitoring)');
677
727
  // List hygiene
@@ -0,0 +1,2 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerAiWorkTools(server: McpServer): void;