@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.
- package/build/audit.d.ts +1 -0
- package/build/audit.js +9 -3
- package/build/auth.d.ts +10 -0
- package/build/auth.js +82 -9
- package/build/db.d.ts +12 -0
- package/build/db.js +37 -2
- package/build/index.js +38 -12
- package/build/lib/action-approvals.d.ts +28 -1
- package/build/lib/action-approvals.js +89 -2
- package/build/lib/brand-asset-persist.d.ts +35 -0
- package/build/lib/brand-asset-persist.js +161 -0
- package/build/lib/brand-asset-usage.d.ts +13 -0
- package/build/lib/brand-asset-usage.js +46 -0
- package/build/lib/email-provider.d.ts +19 -0
- package/build/lib/email-provider.js +281 -0
- package/build/lib/ssrf-pinned-fetch.d.ts +16 -0
- package/build/lib/ssrf-pinned-fetch.js +101 -0
- package/build/lib/survey-link-resolver.d.ts +6 -0
- package/build/lib/survey-link-resolver.js +23 -0
- package/build/lib/svg-sanitize.d.ts +2 -0
- package/build/lib/svg-sanitize.js +110 -0
- package/build/lib/webhook-events.d.ts +1 -1
- package/build/scoped-token.d.ts +11 -0
- package/build/scoped-token.js +59 -0
- package/build/server.js +30 -9
- package/build/tools/ab-testing.js +28 -0
- package/build/tools/advisor.js +52 -2
- package/build/tools/ai-work.d.ts +2 -0
- package/build/tools/ai-work.js +460 -0
- package/build/tools/analytics.d.ts +1 -0
- package/build/tools/analytics.js +203 -0
- package/build/tools/approvals.js +15 -1
- package/build/tools/automations.js +288 -65
- package/build/tools/brand-kit.d.ts +2 -0
- package/build/tools/brand-kit.js +277 -0
- package/build/tools/campaigns.js +900 -69
- package/build/tools/compound.js +28 -0
- package/build/tools/contacts.js +338 -17
- package/build/tools/content.js +266 -1
- package/build/tools/data-proposals.js +21 -0
- package/build/tools/data.js +121 -5
- package/build/tools/delivery-ops.js +84 -0
- package/build/tools/drafts.js +64 -1
- package/build/tools/forms.js +42 -9
- package/build/tools/gdpr.js +22 -3
- package/build/tools/newsletters.d.ts +2 -0
- package/build/tools/newsletters.js +583 -0
- package/build/tools/org.js +297 -3
- package/build/tools/segments.js +45 -3
- package/build/tools/short-links.d.ts +2 -0
- package/build/tools/short-links.js +157 -0
- package/build/tools/sites.js +35 -0
- package/build/tools/sms.js +49 -0
- package/build/tools/social-posts.js +81 -5
- package/build/tools/surveys.d.ts +2 -0
- package/build/tools/surveys.js +222 -0
- package/build/tools/templates.js +78 -2
- package/build/tools/warmup.js +28 -0
- package/build/tools/webhooks.js +29 -0
- 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(/</gi, '<').replace(/>/gi, '>').replace(/"/gi, '"')
|
|
42
|
+
.replace(/'/gi, "'").replace(/&/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(
|
|
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(),
|
package/build/tools/advisor.js
CHANGED
|
@@ -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
|
-
|
|
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
|