@sendinel/mcp-server 1.0.9 → 1.0.11

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 (57) hide show
  1. package/README.md +35 -22
  2. package/build/anthropic-model.d.ts +30 -0
  3. package/build/anthropic-model.js +26 -1
  4. package/build/db.d.ts +17 -0
  5. package/build/db.js +15 -1
  6. package/build/index.js +6 -0
  7. package/build/lib/action-approvals.js +28 -0
  8. package/build/lib/brand-asset-persist.d.ts +1 -0
  9. package/build/lib/brand-asset-persist.js +20 -1
  10. package/build/lib/composer-blocks.d.ts +25 -25
  11. package/build/lib/composer-blocks.js +25 -5
  12. package/build/lib/connector-table-discovery.d.ts +12 -0
  13. package/build/lib/connector-table-discovery.js +117 -0
  14. package/build/lib/custom-properties-diff.d.ts +16 -0
  15. package/build/lib/custom-properties-diff.js +22 -0
  16. package/build/lib/schedule.d.ts +7 -2
  17. package/build/lib/schedule.js +8 -0
  18. package/build/lib/v1-client.d.ts +3 -0
  19. package/build/lib/v1-client.js +45 -0
  20. package/build/lib/voice.d.ts +7 -30
  21. package/build/lib/voice.js +6 -138
  22. package/build/prompts.d.ts +6 -0
  23. package/build/prompts.js +18 -0
  24. package/build/scoped-token.d.ts +1 -1
  25. package/build/server.d.ts +1 -0
  26. package/build/server.js +65 -6
  27. package/build/tools/analytics.js +97 -0
  28. package/build/tools/brand-kit.js +29 -30
  29. package/build/tools/campaigns.js +326 -52
  30. package/build/tools/cloud-import.d.ts +2 -0
  31. package/build/tools/cloud-import.js +76 -0
  32. package/build/tools/compound.d.ts +11 -0
  33. package/build/tools/compound.js +37 -7
  34. package/build/tools/connector.d.ts +2 -0
  35. package/build/tools/connector.js +280 -0
  36. package/build/tools/contacts.js +744 -16
  37. package/build/tools/content.js +228 -0
  38. package/build/tools/data.js +441 -0
  39. package/build/tools/delivery-ops.js +508 -1
  40. package/build/tools/forms.js +7 -6
  41. package/build/tools/gateway.d.ts +6 -0
  42. package/build/tools/gateway.js +92 -0
  43. package/build/tools/gdpr.js +185 -0
  44. package/build/tools/landing-pages.d.ts +2 -0
  45. package/build/tools/landing-pages.js +202 -0
  46. package/build/tools/lists.d.ts +2 -0
  47. package/build/tools/lists.js +428 -0
  48. package/build/tools/newsletters.js +1 -8
  49. package/build/tools/org.js +549 -33
  50. package/build/tools/segments.js +35 -184
  51. package/build/tools/signatures.d.ts +2 -0
  52. package/build/tools/signatures.js +168 -0
  53. package/build/tools/sites.js +3 -1
  54. package/build/tools/skills.d.ts +2 -0
  55. package/build/tools/skills.js +29 -0
  56. package/build/tools/templates.js +133 -7
  57. package/package.json +14 -13
package/build/server.js CHANGED
@@ -4,7 +4,9 @@ import { registerCampaignTools } from './tools/campaigns.js';
4
4
  import { registerContactTools } from './tools/contacts.js';
5
5
  import { registerDraftTools } from './tools/drafts.js';
6
6
  import { registerGdprTools } from './tools/gdpr.js';
7
+ import { registerLandingPageTools } from './tools/landing-pages.js';
7
8
  import { registerSegmentTools } from './tools/segments.js';
9
+ import { registerListTools } from './tools/lists.js';
8
10
  import { registerSiteTools } from './tools/sites.js';
9
11
  import { registerAbTestingTools } from './tools/ab-testing.js';
10
12
  import { registerAdvisorTools } from './tools/advisor.js';
@@ -24,17 +26,24 @@ import { registerSocialPostTools } from './tools/social-posts.js';
24
26
  import { registerDataProposalTools } from './tools/data-proposals.js';
25
27
  import { registerSurveyTools } from './tools/surveys.js';
26
28
  import { registerShortLinkTools } from './tools/short-links.js';
29
+ import { registerSignatureTools } from './tools/signatures.js';
27
30
  import { registerAiWorkTools } from './tools/ai-work.js';
28
31
  import { registerBrandKitTools } from './tools/brand-kit.js';
29
32
  import { registerNewsletterTools } from './tools/newsletters.js';
33
+ import { registerGatewayTools } from './tools/gateway.js';
34
+ import { registerSkillTools } from './tools/skills.js';
35
+ import { registerConnectorTools } from './tools/connector.js';
36
+ import { registerCloudImportTools } from './tools/cloud-import.js';
30
37
  const GROUP_REGISTRY = {
31
38
  analytics: registerAnalyticsTools,
32
39
  campaigns: registerCampaignTools,
33
40
  contacts: registerContactTools,
34
41
  segments: registerSegmentTools,
42
+ lists: registerListTools,
35
43
  drafts: registerDraftTools,
36
44
  sites: registerSiteTools,
37
45
  gdpr: registerGdprTools,
46
+ 'landing-pages': registerLandingPageTools,
38
47
  'ab-testing': registerAbTestingTools,
39
48
  advisor: registerAdvisorTools,
40
49
  webhooks: registerWebhookTools,
@@ -53,36 +62,46 @@ const GROUP_REGISTRY = {
53
62
  'data-proposals': registerDataProposalTools,
54
63
  surveys: registerSurveyTools,
55
64
  'short-links': registerShortLinkTools,
65
+ signatures: registerSignatureTools,
56
66
  'ai-work': registerAiWorkTools,
57
67
  brand_kit: registerBrandKitTools,
58
68
  newsletters: registerNewsletterTools,
69
+ skills: registerSkillTools,
70
+ connector: registerConnectorTools,
71
+ 'cloud-import': registerCloudImportTools,
59
72
  };
60
73
  export const ALL_TOOL_GROUPS = Object.keys(GROUP_REGISTRY);
61
74
  // ── Scope enforcement ─────────────────────────────────────────────────────
62
75
  // Maps tool names to required scope. Mirrors dashboard's WRITE_TOOLS/ADMIN_TOOLS.
63
76
  const WRITE_TOOLS = new Set([
64
- 'add_subscriber', 'import_subscribers', 'update_subscriber',
77
+ 'add_subscriber', 'import_subscribers', 'update_subscriber', 'bulk_update_subscribers',
78
+ 'create_subscriber_view', 'update_subscriber_view', 'delete_subscriber_view',
65
79
  'enrich_subscriber',
66
80
  'create_campaign', 'update_campaign', 'delete_campaign', 'add_campaign_step', 'update_campaign_status',
67
81
  'review_campaign_portfolio',
68
82
  'list_campaign_steps', 'update_campaign_step', 'delete_campaign_step',
69
- 'enroll_contact', 'enroll_segment', 'broadcast_to_segment', 'get_enrollment_status', 'clone_campaign', 'generate_email',
83
+ 'enroll_contact', 'enroll_segment', 'broadcast_to_segment', 'get_enrollment_status', 'clone_campaign', 'clone_sequence_template', 'generate_email',
70
84
  'create_social_campaign', 'schedule_social_post',
71
85
  'create_social_post', 'update_social_post', 'delete_social_post',
72
86
  'create_draft', 'approve_draft', 'reject_draft',
73
87
  'create_site', 'create_sender', 'update_site',
74
- 'create_template', 'update_template', 'delete_template', 'translate_template',
88
+ 'create_template', 'update_template', 'delete_template', 'restore_template_version', 'delete_provider_credential', 'translate_template',
75
89
  'delete_subscriber_data', 'identify_inactive', 'set_subscriber_tags',
76
90
  'update_hygiene_config',
91
+ 'create_custom_field', 'update_custom_field', 'delete_custom_field',
77
92
  'create_segment', 'update_segment', 'delete_segment', 'create_segment_nl',
93
+ 'create_list', 'update_list', 'delete_list', 'add_list_members', 'remove_list_members', 'add_list_members_by_filter',
78
94
  'launch_campaign', 'merge_subscribers', 'create_campaign_with_content',
79
95
  'send_test_email', 'send_transactional', 'subscribe_webhook', 'update_webhook_subscription',
80
96
  'create_ab_test', 'promote_ab_winner', 'optimize_subject_lines',
81
97
  'unsubscribe_subscriber', 'add_suppression', 'remove_suppression',
82
98
  'trigger_automation', 'enroll_automation', 'create_escalation_ladder', 'enroll_in_escalation_ladder',
83
- 'update_scoring_rules', 'register_domain',
99
+ 'update_scoring_rules', 'register_domain', 'publish_dmarc_record',
84
100
  'update_warmup_schedule', 'pause_warmup', 'resume_warmup',
85
101
  'create_form', 'invite_team_member',
102
+ 'remove_team_member', 'set_team_member_role', 'set_member_project_access',
103
+ 'resend_invitation', 'revoke_invitation',
104
+ 'create_landing_page', 'update_landing_page', 'delete_landing_page',
86
105
  'start_provider_switch', 'get_provider_switch_status', 'rollback_provider_switch',
87
106
  'create_api_key', 'revoke_api_key', 'mark_notifications_read',
88
107
  'migrate_campaigns_from', 'start_platform_migration',
@@ -90,30 +109,48 @@ const WRITE_TOOLS = new Set([
90
109
  'create_asset_link',
91
110
  'set_white_label_settings', 'add_white_label_domain',
92
111
  'create_blog_post',
112
+ 'generate_blog_post_image',
93
113
  'run_list_hygiene', 'run_portfolio_analysis',
94
114
  'abuse_monitor_override',
95
115
  'send_sms', 'send_sms_campaign', 'set_contact_sms_consent',
96
116
  'setup_campaign_from_brief', 'create_template_from_brief', 'diagnose_delivery_issue', 'onboard_new_site',
97
117
  'connect_platform', 'disconnect_platform', 'configure_platform_trigger',
118
+ 'create_custom_webhook', 'rotate_custom_webhook_secret',
98
119
  'update_brand_voice', 'promote_draft_to_campaign',
99
120
  'propose_campaign_from_data', 'approve_data_proposal',
100
121
  'create_survey', 'update_survey', 'create_short_link', 'generate_qr_code',
122
+ 'create_signature', 'update_signature',
101
123
  'mark_ai_work_item_seen', 'snooze_ai_work_item', 'dismiss_ai_work_item',
102
124
  'submit_ai_context_event',
103
- 'import_brand_kit_text', 'ingest_brand_assets_by_url', 'upload_brand_asset',
125
+ 'import_brand_kit_text', 'rehost_brand_asset', 'ingest_brand_assets_by_url', 'upload_brand_asset',
104
126
  'create_newsletter', 'update_newsletter', 'create_newsletter_issue', 'schedule_newsletter_issue',
105
127
  'send_newsletter_issue', 'subscribe_to_newsletter', 'unsubscribe_from_newsletter',
128
+ 'retry_send_queue_rows', 'cancel_send_queue_rows',
129
+ 'replay_enrollment_dlq_rows',
130
+ 'commit_staged_import', 'discard_staged_import', 'add_synced_folder', 'remove_synced_folder',
131
+ 'retry_send_queue_rows', 'cancel_send_queue_rows', 'add_blackout_date', 'remove_blackout_date',
106
132
  ]);
107
133
  const ADMIN_TOOLS = new Set([
108
- 'delete_subscriber_data', 'list_deletion_log',
134
+ // Connector mutations write schema into a customer-owned database and
135
+ // rewrite how their data maps in (admin, not write: Kevin, 2026-09-11).
136
+ // NOTE: no apostrophes in comments inside these Sets -- tool-scopes.test.ts
137
+ // parses the block by matching single-quoted strings.
138
+ 'install_connector_schema', 'install_connector_view', 'set_connector_mapping', 'resync_connector',
139
+ 'delete_subscriber_data', 'list_deletion_log', 'list_ccpa_requests', 'action_ccpa_request',
140
+ 'delete_subscriber_data', 'list_deletion_log', 'list_audit_log', 'get_audit_stats',
109
141
  'unsubscribe_subscriber', 'add_suppression', 'remove_suppression',
110
142
  'create_site', 'update_site',
111
143
  'delete_template', 'register_domain', 'onboard_new_site',
112
144
  'create_api_key', 'revoke_api_key', 'invite_team_member',
113
145
  'connect_platform', 'disconnect_platform', 'configure_platform_trigger',
146
+ 'create_custom_webhook', 'rotate_custom_webhook_secret',
147
+ 'remove_team_member', 'set_team_member_role', 'set_member_project_access',
148
+ 'list_invitations', 'resend_invitation', 'revoke_invitation',
149
+ 'connect_platform', 'disconnect_platform', 'configure_platform_trigger', 'delete_provider_credential',
114
150
  'abuse_monitor_override',
115
151
  'tenant_export_full',
116
152
  'identify_inactive', 'delete_segment',
153
+ 'delete_signature',
117
154
  'merge_subscribers', 'delete_webhook_subscription',
118
155
  'set_white_label_settings', 'add_white_label_domain',
119
156
  ]);
@@ -129,12 +166,27 @@ function scopeAllows(keyScopes, required) {
129
166
  const requiredLevel = SCOPE_HIERARCHY[required] ?? 99;
130
167
  return keyScopes.some((s) => (SCOPE_HIERARCHY[s] ?? 0) >= requiredLevel);
131
168
  }
169
+ // Names of the meta-tools gateway mode registers in place of the real catalog
170
+ // (see tools/gateway.ts). Grouped as 'gateway' rather than any real tool
171
+ // group -- a gateway-mode client's toolGroups list holds the groups it can
172
+ // reach *through* invoke_tool, not a group these three tools belong to.
173
+ export const GATEWAY_TOOL_NAMES = ['search_tools', 'describe_tool', 'invoke_tool'];
132
174
  /**
133
175
  * Collects tool-name → group metadata from the same group registry used by
134
176
  * registerAllTools(). Scope filtering and definition scanning are applied
135
177
  * through the same proxy path as real MCP registration.
136
178
  */
137
179
  export function getRegisteredToolGroups(options) {
180
+ const rawScopes = options?.scopes ?? [];
181
+ if (rawScopes.includes('gateway')) {
182
+ // Mirrors registerAllTools()'s own gateway branch: a client with only
183
+ // 'gateway' and no real permission scope (read/write/admin) alongside it
184
+ // gets nothing registered, so it must get nothing labeled here either.
185
+ const permissionScopes = rawScopes.filter((scope) => scope !== 'gateway');
186
+ if (!scopeAllows(permissionScopes, 'read'))
187
+ return {};
188
+ return Object.fromEntries(GATEWAY_TOOL_NAMES.map((name) => [name, 'gateway']));
189
+ }
138
190
  const groups = options?.toolGroups === undefined
139
191
  ? ALL_TOOL_GROUPS
140
192
  : options.toolGroups;
@@ -245,6 +297,13 @@ function createScopeProxy(server, scopes, toolGroup) {
245
297
  });
246
298
  }
247
299
  export function registerAllTools(server, options) {
300
+ const rawScopes = options?.scopes ?? [];
301
+ if (rawScopes.includes('gateway')) {
302
+ const permissionScopes = rawScopes.filter((scope) => scope !== 'gateway');
303
+ const proxied = createScopeProxy(server, permissionScopes, 'gateway');
304
+ registerGatewayTools(proxied, { scopes: permissionScopes, toolGroups: options?.toolGroups });
305
+ return;
306
+ }
248
307
  const groups = options?.toolGroups === undefined
249
308
  ? ALL_TOOL_GROUPS
250
309
  : options.toolGroups;
@@ -9,6 +9,7 @@ const percent = (rate) => `${(rate * 100).toFixed(1)}%`;
9
9
  const pctNumber = (rate) => Math.round(rate * 1000) / 10;
10
10
  const roundRate = (rate) => Math.round(rate * 1000) / 1000;
11
11
  const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
12
+ const stripeLifecycleStatuses = ['active', 'trialing', 'past_due', 'canceled', 'unpaid', 'incomplete', 'incomplete_expired', 'paused'];
12
13
  const socialPlatforms = ['instagram', 'linkedin', 'twitter', 'facebook', 'tiktok', 'youtube', 'pinterest', 'uploadpost', 'other'];
13
14
  const socialPublishers = ['uploadpost', 'buffer'];
14
15
  const UPLOADPOST_API_BASE = process.env.UPLOADPOST_API_BASE || 'https://api.upload-post.com/api';
@@ -142,6 +143,61 @@ function addDeviationFlag(flags, metric, recent, baseline) {
142
143
  flags.push(`${metric} ${deviation > 0 ? 'up' : 'down'} ${(Math.abs(deviation) * 100).toFixed(1)}% vs 30d baseline`);
143
144
  }
144
145
  }
146
+ function monthlyNormalizedCents(amountCents, interval, intervalCount) {
147
+ const count = intervalCount && intervalCount > 0 ? intervalCount : 1;
148
+ switch (interval) {
149
+ case 'month':
150
+ return amountCents / count;
151
+ case 'year':
152
+ return amountCents / (12 * count);
153
+ case 'week':
154
+ return (amountCents * 52) / (12 * count);
155
+ case 'day':
156
+ return (amountCents * 365) / (12 * count);
157
+ default:
158
+ return amountCents;
159
+ }
160
+ }
161
+ function buildRevenueAnalytics(params) {
162
+ const now = new Date().toISOString();
163
+ const lifecycle = Object.fromEntries(stripeLifecycleStatuses.map(status => [status, 0]));
164
+ for (const subscription of params.subscriptions) {
165
+ if (subscription.status && lifecycle[subscription.status] !== undefined) {
166
+ lifecycle[subscription.status] += 1;
167
+ }
168
+ }
169
+ const mrr = params.subscriptions
170
+ .filter(subscription => subscription.status === 'active' || subscription.status === 'trialing')
171
+ .reduce((sum, subscription) => {
172
+ const amount = subscription.amount_cents ?? 0;
173
+ return sum + monthlyNormalizedCents(amount, subscription.billing_interval, subscription.billing_interval_count);
174
+ }, 0);
175
+ const activeAtPeriodStart = params.subscriptions.filter(subscription => (subscription.stripe_created_at
176
+ && subscription.stripe_created_at < params.start
177
+ && (!subscription.canceled_at || subscription.canceled_at >= params.start))).length;
178
+ const canceledInPeriod = params.subscriptions.filter(subscription => (subscription.canceled_at
179
+ && subscription.canceled_at >= params.start
180
+ && subscription.canceled_at <= now)).length;
181
+ const newSubscriptionsInPeriod = params.subscriptions.filter(subscription => (subscription.stripe_created_at && subscription.stripe_created_at >= params.start)).length;
182
+ const revenueCollectedCents = params.invoices
183
+ .filter(invoice => invoice.paid === true && invoice.stripe_created_at && invoice.stripe_created_at >= params.start)
184
+ .reduce((sum, invoice) => sum + (invoice.amount_paid_cents ?? 0), 0);
185
+ const failedPaymentsInPeriod = params.invoices.filter(invoice => (invoice.paid === false && invoice.stripe_created_at && invoice.stripe_created_at >= params.start)).length;
186
+ return {
187
+ period_days: params.days,
188
+ period: { days: params.days, start: params.start },
189
+ connection_id: params.connectionId,
190
+ mrr_cents: Math.round(mrr),
191
+ lifecycle,
192
+ active_at_period_start: activeAtPeriodStart,
193
+ canceled_in_period: canceledInPeriod,
194
+ churn_rate: activeAtPeriodStart > 0 ? roundRate(canceledInPeriod / activeAtPeriodStart) : 0,
195
+ new_subscriptions_in_period: newSubscriptionsInPeriod,
196
+ revenue_collected_cents: revenueCollectedCents,
197
+ failed_payments_in_period: failedPaymentsInPeriod,
198
+ failed_payments_note: 'Best-effort count of unpaid invoices in the period; stripe_invoices does not store a dedicated payment_failed flag.',
199
+ };
200
+ }
145
201
  function distinctEventCount(events, eventType, logIds) {
146
202
  return new Set(events
147
203
  .filter(event => event.event_type === eventType && event.email_log_id && logIds.has(event.email_log_id))
@@ -1003,6 +1059,47 @@ export function registerAnalyticsTools(server) {
1003
1059
  },
1004
1060
  });
1005
1061
  });
1062
+ server.registerTool('get_revenue_analytics', {
1063
+ title: 'Get Revenue Analytics',
1064
+ annotations: {
1065
+ readOnlyHint: true,
1066
+ destructiveHint: false,
1067
+ idempotentHint: true,
1068
+ openWorldHint: false,
1069
+ },
1070
+ description: 'Get Stripe connector revenue, churn, MRR, subscription lifecycle, and failed-payment analytics for the current project.',
1071
+ inputSchema: {
1072
+ days: z.number().int().min(1).max(365).optional().default(30),
1073
+ connection_id: z.string().uuid().optional(),
1074
+ },
1075
+ }, async ({ days = 30, connection_id }) => {
1076
+ const projectId = getProjectId();
1077
+ const start = new Date(Date.now() - days * 86_400_000).toISOString();
1078
+ let subscriptionQuery = db()
1079
+ .from('stripe_subscriptions')
1080
+ .select('status, amount_cents, billing_interval, billing_interval_count, stripe_created_at, canceled_at')
1081
+ .eq('project_id', projectId);
1082
+ let invoiceQuery = db()
1083
+ .from('stripe_invoices')
1084
+ .select('paid, status, amount_paid_cents, stripe_created_at')
1085
+ .eq('project_id', projectId);
1086
+ if (connection_id) {
1087
+ subscriptionQuery = subscriptionQuery.eq('connection_id', connection_id);
1088
+ invoiceQuery = invoiceQuery.eq('connection_id', connection_id);
1089
+ }
1090
+ const [subscriptionRes, invoiceRes] = await Promise.all([subscriptionQuery, invoiceQuery]);
1091
+ if (subscriptionRes.error)
1092
+ return j({ error: subscriptionRes.error.message });
1093
+ if (invoiceRes.error)
1094
+ return j({ error: invoiceRes.error.message });
1095
+ return j(buildRevenueAnalytics({
1096
+ days,
1097
+ start,
1098
+ subscriptions: (subscriptionRes.data ?? []),
1099
+ invoices: (invoiceRes.data ?? []),
1100
+ connectionId: connection_id ?? null,
1101
+ }));
1102
+ });
1006
1103
  server.registerTool('abuse_monitor_status', {
1007
1104
  title: 'Abuse Monitor Status',
1008
1105
  annotations: {
@@ -2,38 +2,15 @@ import { z } from 'zod';
2
2
  import { adminDb } from '../db.js';
3
3
  import { getProjectId } from '../project.js';
4
4
  import { downloadPublicAsset } from '../lib/ssrf-pinned-fetch.js';
5
+ import { callSendinelV1 } from '../lib/v1-client.js';
5
6
  import { BRAND_ASSET_KINDS, isValidKind, persistBrandAsset, resolveKind, resolveMime, } from '../lib/brand-asset-persist.js';
6
7
  const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
7
- function brandKitApiBase() {
8
- return (process.env.NEXT_PUBLIC_APP_URL ?? process.env.SENDINEL_APP_URL ?? process.env.APP_URL ?? 'https://sendinel.ai').replace(/\/$/, '');
9
- }
10
8
  // import_brand_kit_text parses free-form Brand Core/Brand Brain/Content Library
11
9
  // markdown into voice fields, colors, templates, and social posts — a different,
12
- // more complex capability than the media-library asset tools below. Left on the
13
- // HTTP-proxy path (still requires SENDINEL_API_KEY) rather than re-implemented
14
- // here; see #2356 for the media-library-scoped fix this file otherwise contains.
10
+ // more complex capability than the media-library asset tools below. Hosted MCP
11
+ // calls it in-process; stdio keeps the authenticated v1 HTTP path.
15
12
  async function callBrandKitApi(path, body) {
16
- const apiKey = process.env.SENDINEL_API_KEY;
17
- if (!apiKey)
18
- return { ok: false, error: 'SENDINEL_API_KEY is required to drive this brand_kit tool from MCP' };
19
- const res = await fetch(`${brandKitApiBase()}${path}`, {
20
- method: 'POST',
21
- headers: {
22
- Authorization: `Bearer ${apiKey}`,
23
- 'X-Sendinel-Project-Id': getProjectId(),
24
- 'Content-Type': 'application/json',
25
- },
26
- body: JSON.stringify(body),
27
- });
28
- const text = await res.text();
29
- let parsed = text;
30
- try {
31
- parsed = JSON.parse(text);
32
- }
33
- catch { }
34
- if (!res.ok)
35
- return { ok: false, error: parsed || `HTTP ${res.status}` };
36
- return { ok: true, data: parsed };
13
+ return callSendinelV1(path, { body });
37
14
  }
38
15
  async function getProjectOrgId(projectId) {
39
16
  const { data, error } = await adminDb()
@@ -66,6 +43,24 @@ export function registerBrandKitTools(server) {
66
43
  const result = await callBrandKitApi('/api/v1/brand-kit/import-text', { text });
67
44
  return result.ok ? j(result.data) : j({ error: result.error });
68
45
  });
46
+ server.registerTool('rehost_brand_asset', {
47
+ title: 'Rehost Drive-Linked Brand Asset',
48
+ annotations: {
49
+ readOnlyHint: false,
50
+ destructiveHint: false,
51
+ idempotentHint: false,
52
+ openWorldHint: false,
53
+ },
54
+ description: 'Convert a brand asset into a real hosted copy that can be used in email or social content. Drive-linked assets have a null url in list_brand_assets and the v1 brand-kit API; call this first to get a usable copy before referencing the asset in generated content.',
55
+ inputSchema: {
56
+ asset_id: z.string().uuid(),
57
+ purpose: z.enum(['email', 'social']),
58
+ platforms: z.array(z.string()).optional().describe('Required context when purpose is "social" and TikTok is a target platform (JPEG/WebP only).'),
59
+ },
60
+ }, async ({ asset_id, purpose, platforms }) => {
61
+ const result = await callBrandKitApi(`/api/v1/brand-kit/assets/${asset_id}/rehost`, { purpose, platforms });
62
+ return result.ok ? j(result.data) : j({ error: result.error });
63
+ });
69
64
  server.registerTool('list_brand_assets', {
70
65
  title: 'List Brand Assets',
71
66
  annotations: {
@@ -88,7 +83,7 @@ export function registerBrandKitTools(server) {
88
83
  const { data, error } = await sb
89
84
  .schema('email')
90
85
  .from('brand_media_assets')
91
- .select('id, kind, filename, storage_path, mime_type, size_bytes, collection, tags, last_referenced_at, last_referenced_source, last_referenced_resource_id, reference_count, created_at, storage_backend')
86
+ .select('id, kind, filename, storage_path, description, backstory, mime_type, size_bytes, collection, tags, last_referenced_at, last_referenced_source, last_referenced_resource_id, reference_count, created_at, storage_backend')
92
87
  .eq('project_id', projectId)
93
88
  .eq('review_status', 'confirmed')
94
89
  .order('created_at', { ascending: false });
@@ -110,7 +105,7 @@ export function registerBrandKitTools(server) {
110
105
  }
111
106
  const totalMatching = rows.length;
112
107
  const page = rows.slice(0, cappedLimit);
113
- const paths = page.filter((r) => r.storage_backend !== 'r2').map((r) => r.storage_path);
108
+ const paths = page.filter((r) => r.storage_backend !== 'r2' && r.storage_backend !== 'drive_link').map((r) => r.storage_path);
114
109
  const signedByPath = new Map();
115
110
  if (paths.length > 0) {
116
111
  const { data: signed } = await sb.storage.from('brand-assets').createSignedUrls(paths, 3600, { download: true });
@@ -124,6 +119,8 @@ export function registerBrandKitTools(server) {
124
119
  id: r.id,
125
120
  kind: r.kind,
126
121
  filename: r.filename,
122
+ description: r.description ?? null,
123
+ backstory: r.backstory ?? null,
127
124
  collection: r.collection,
128
125
  tags: r.tags ?? [],
129
126
  mime_type: r.mime_type,
@@ -159,8 +156,9 @@ export function registerBrandKitTools(server) {
159
156
  collection: z.string().max(120).optional(),
160
157
  tags: z.array(z.string()).max(50).optional(),
161
158
  brand_kit_id: z.string().uuid().optional(),
159
+ backstory: z.string().max(2000).optional().describe('Character backstory/profile text for avatar assets. Ignored for non-avatar assets.'),
162
160
  },
163
- }, async ({ filename, content_base64, kind, collection, tags, brand_kit_id }) => {
161
+ }, async ({ filename, content_base64, kind, collection, tags, brand_kit_id, backstory }) => {
164
162
  const projectId = getProjectId();
165
163
  let bytes;
166
164
  try {
@@ -193,6 +191,7 @@ export function registerBrandKitTools(server) {
193
191
  collection: collection ?? null,
194
192
  tags: tags ?? [],
195
193
  brandKitId: brand_kit_id ?? null,
194
+ backstory: resolvedKind === 'avatar' ? backstory?.trim() || null : null,
196
195
  });
197
196
  return j({
198
197
  ok: true,