@sendinel/mcp-server 1.0.4 → 1.0.6

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 +140 -8
  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
@@ -1,9 +1,113 @@
1
1
  import { z } from 'zod';
2
- import { db } from '../db.js';
2
+ import { adminDb, db } from '../db.js';
3
3
  import { getProjectId } from '../project.js';
4
4
  const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
5
5
  export function registerContentTools(server) {
6
+ server.registerTool('create_asset_link', {
7
+ title: 'Create Asset Link',
8
+ annotations: {
9
+ readOnlyHint: false,
10
+ destructiveHint: false,
11
+ idempotentHint: false,
12
+ openWorldHint: false,
13
+ },
14
+ description: 'Create an email-gated, trackable asset link. Viewers submit an email at /a/{slug}; each view creates or attaches a contact and emits asset_link.viewed.',
15
+ inputSchema: {
16
+ title: z.string().min(1).max(255),
17
+ slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).max(100),
18
+ asset_url: z.string().url().max(2000),
19
+ asset_type: z.enum(['pdf', 'page', 'deck', 'video', 'other']).optional().default('page'),
20
+ site_id: z.string().uuid().optional(),
21
+ source_attribution: z.string().max(500).optional(),
22
+ gate_headline: z.string().max(255).optional(),
23
+ gate_body: z.string().max(1000).optional(),
24
+ },
25
+ }, async ({ title, slug, asset_url, asset_type = 'page', site_id, source_attribution, gate_headline, gate_body }) => {
26
+ const { data, error } = await db().from('asset_links')
27
+ .insert({
28
+ project_id: getProjectId(),
29
+ site_id: site_id ?? null,
30
+ title,
31
+ slug,
32
+ asset_url,
33
+ asset_type,
34
+ source_attribution: source_attribution ?? null,
35
+ gate_headline: gate_headline ?? null,
36
+ gate_body: gate_body ?? null,
37
+ })
38
+ .select('id, title, slug, asset_type, status, source_attribution, gate_headline, gate_body, total_views, total_captures, created_at')
39
+ .single();
40
+ if (error)
41
+ return j({ error: error.message, code: error.code });
42
+ const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? process.env.SENDINEL_APP_URL ?? 'https://sendinel.ai';
43
+ return j({ asset_link: { ...data, url: `${appUrl.replace(/\/$/, '')}/a/${data.slug}` } });
44
+ });
45
+ server.registerTool('list_asset_links', {
46
+ title: 'List Asset Links',
47
+ annotations: {
48
+ readOnlyHint: true,
49
+ destructiveHint: false,
50
+ idempotentHint: true,
51
+ openWorldHint: false,
52
+ },
53
+ description: 'List email-gated asset links and aggregate capture/view counters.',
54
+ inputSchema: {
55
+ status: z.enum(['draft', 'active', 'archived']).optional(),
56
+ limit: z.number().int().min(1).max(200).optional().default(50),
57
+ },
58
+ }, async ({ status, limit = 50 }) => {
59
+ let query = db().from('asset_links')
60
+ .select('id, title, slug, asset_type, status, source_attribution, total_views, total_captures, created_at, updated_at')
61
+ .eq('project_id', getProjectId())
62
+ .order('created_at', { ascending: false })
63
+ .limit(limit);
64
+ if (status)
65
+ query = query.eq('status', status);
66
+ const { data, error } = await query;
67
+ if (error)
68
+ return j({ error: error.message });
69
+ const appUrl = (process.env.NEXT_PUBLIC_APP_URL ?? process.env.SENDINEL_APP_URL ?? 'https://sendinel.ai').replace(/\/$/, '');
70
+ return j({ asset_links: (data ?? []).map((row) => ({ ...row, url: `${appUrl}/a/${row.slug}` })) });
71
+ });
72
+ server.registerTool('get_asset_link_views', {
73
+ title: 'Get Asset Link Views',
74
+ annotations: {
75
+ readOnlyHint: true,
76
+ destructiveHint: false,
77
+ idempotentHint: true,
78
+ openWorldHint: false,
79
+ },
80
+ description: 'List per-view events for a gated asset link, including captured email, contact id, and duration when available.',
81
+ inputSchema: {
82
+ asset_link_id: z.string().uuid(),
83
+ limit: z.number().int().min(1).max(200).optional().default(50),
84
+ },
85
+ }, async ({ asset_link_id, limit = 50 }) => {
86
+ const { data: assetLink } = await db().from('asset_links')
87
+ .select('id')
88
+ .eq('id', asset_link_id)
89
+ .eq('project_id', getProjectId())
90
+ .maybeSingle();
91
+ if (!assetLink)
92
+ return j({ error: 'Asset link not found', code: 'NOT_FOUND' });
93
+ const { data, error } = await db().from('asset_link_views')
94
+ .select('id, contact_id, viewer_email, is_new_contact, source_attribution, viewed_at, left_at, duration_seconds, referrer')
95
+ .eq('asset_link_id', asset_link_id)
96
+ .eq('project_id', getProjectId())
97
+ .order('viewed_at', { ascending: false })
98
+ .limit(limit);
99
+ if (error)
100
+ return j({ error: error.message });
101
+ return j({ views: data ?? [] });
102
+ });
6
103
  server.registerTool('list_content_blocks', {
104
+ title: 'List Content Blocks',
105
+ annotations: {
106
+ readOnlyHint: true,
107
+ destructiveHint: false,
108
+ idempotentHint: true,
109
+ openWorldHint: false,
110
+ },
7
111
  description: 'List reusable content blocks for the project, optionally filtered by category.',
8
112
  inputSchema: { category: z.enum(['header', 'footer', 'cta', 'legal', 'general']).optional() },
9
113
  }, async ({ category }) => {
@@ -16,6 +120,13 @@ export function registerContentTools(server) {
16
120
  return j({ blocks: data ?? [] });
17
121
  });
18
122
  server.registerTool('create_content_block', {
123
+ title: 'Create Content Block',
124
+ annotations: {
125
+ readOnlyHint: false,
126
+ destructiveHint: false,
127
+ idempotentHint: false,
128
+ openWorldHint: false,
129
+ },
19
130
  description: 'Create a reusable content block.',
20
131
  inputSchema: {
21
132
  name: z.string().min(1),
@@ -33,6 +144,13 @@ export function registerContentTools(server) {
33
144
  return j({ block: data });
34
145
  });
35
146
  server.registerTool('delete_content_block', {
147
+ title: 'Delete Content Block',
148
+ annotations: {
149
+ readOnlyHint: false,
150
+ destructiveHint: true,
151
+ idempotentHint: false,
152
+ openWorldHint: false,
153
+ },
36
154
  description: 'Delete a reusable content block.',
37
155
  inputSchema: { block_id: z.string().uuid() },
38
156
  }, async ({ block_id }) => {
@@ -42,6 +160,13 @@ export function registerContentTools(server) {
42
160
  return j({ deleted: true });
43
161
  });
44
162
  server.registerTool('list_content_sources', {
163
+ title: 'List Content Sources',
164
+ annotations: {
165
+ readOnlyHint: true,
166
+ destructiveHint: false,
167
+ idempotentHint: true,
168
+ openWorldHint: false,
169
+ },
45
170
  description: 'List configured content sources and draft counts.',
46
171
  inputSchema: {},
47
172
  }, async () => {
@@ -54,6 +179,13 @@ export function registerContentTools(server) {
54
179
  return j({ sources: (data ?? []).map((row) => ({ id: row.id, name: row.name, type: row.source_type, url: row.feed_url, active: row.active, last_fetched: row.last_polled_at, draft_count: row.items_generated ?? 0 })) });
55
180
  });
56
181
  server.registerTool('trigger_content_pipeline', {
182
+ title: 'Trigger Content Pipeline',
183
+ annotations: {
184
+ readOnlyHint: false,
185
+ destructiveHint: false,
186
+ idempotentHint: false,
187
+ openWorldHint: true,
188
+ },
57
189
  description: 'Mark one or all content sources ready for the content pipeline cron.',
58
190
  inputSchema: { source_id: z.string().uuid().optional() },
59
191
  }, async ({ source_id }) => {
@@ -65,4 +197,137 @@ export function registerContentTools(server) {
65
197
  return j({ error: error.message });
66
198
  return j({ drafts_created: 0, queued_for_pipeline: true });
67
199
  });
200
+ // Blogs (#2033): the primary agent door for ingesting a blog written by the
201
+ // user's LLM/agent. Lands as a draft staged for review; publishing to the
202
+ // user's own site is a separate step. Sender-owns-schema -- only title is
203
+ // required, and unmapped fields are preserved in source_payload.
204
+ const slugify = (s) => s.toLowerCase().trim().replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-').slice(0, 120).replace(/^-|-$/g, '');
205
+ server.registerTool('create_blog_post', {
206
+ title: 'Create Blog Post',
207
+ annotations: {
208
+ readOnlyHint: false,
209
+ destructiveHint: false,
210
+ idempotentHint: false,
211
+ openWorldHint: true,
212
+ },
213
+ description: 'Ingest a blog post (written by your AI/agent) into Sendinel. By default it lands as a draft in the review queue. For a trusted, hands-off flow, set auto_publish=true with publish_to (a destination name or id from list_blog_destinations) to skip review and publish to the user\'s own site — now, or at scheduled_at. Only title is required; everything else is optional.',
214
+ inputSchema: {
215
+ title: z.string().min(1).max(300),
216
+ body: z.string().optional(),
217
+ excerpt: z.string().max(1000).optional(),
218
+ author: z.string().max(120).optional(),
219
+ slug: z.string().max(120).optional(),
220
+ tags: z.array(z.string().max(64)).max(30).optional(),
221
+ cover_image_url: z.string().url().max(2000).optional(),
222
+ site_id: z.string().uuid().optional(),
223
+ seo: z.record(z.string(), z.unknown()).optional(),
224
+ publish_destination: z.record(z.string(), z.unknown()).optional(),
225
+ scheduled_at: z.string().datetime().optional(),
226
+ auto_publish: z.boolean().optional(),
227
+ publish_to: z.string().max(200).optional(),
228
+ },
229
+ }, async (args) => {
230
+ const { data: proj, error: projErr } = await adminDb().from('projects').select('org_id').eq('id', getProjectId()).single();
231
+ if (projErr || !proj?.org_id)
232
+ return j({ error: 'Organization not found for project' });
233
+ const { title, body, excerpt, author, slug, tags, cover_image_url, site_id, seo, publish_destination, scheduled_at, auto_publish, publish_to } = args;
234
+ // One-shot auto-publish: skip the review queue and hand the post straight to
235
+ // the publish worker (status='scheduled', due now unless scheduled_at is set).
236
+ // Requires a resolvable, active destination so a typo can't silently drop the
237
+ // post into a limbo state.
238
+ let status = 'draft';
239
+ let resolvedScheduledAt = scheduled_at ?? null;
240
+ let resolvedDestination = publish_destination ?? {};
241
+ if (auto_publish) {
242
+ if (!publish_to) {
243
+ return j({ error: 'auto_publish requires publish_to (a destination name or id). Call list_blog_destinations to see the options.' });
244
+ }
245
+ const { data: dests, error: destErr } = await db().from('blog_destinations')
246
+ .select('id, name')
247
+ .eq('project_id', getProjectId())
248
+ .eq('is_active', true);
249
+ if (destErr)
250
+ return j({ error: destErr.message });
251
+ const target = publish_to.trim().toLowerCase();
252
+ const match = (dests ?? []).find((d) => d.id === publish_to || String(d.name ?? '').toLowerCase() === target);
253
+ if (!match) {
254
+ return j({ error: `No active publish destination matching "${publish_to}". Add one on the Blog page, or call list_blog_destinations.` });
255
+ }
256
+ status = 'scheduled';
257
+ resolvedScheduledAt = scheduled_at ?? new Date().toISOString();
258
+ resolvedDestination = { destination_id: match.id };
259
+ }
260
+ const { data, error } = await db().from('blog_posts')
261
+ .insert({
262
+ org_id: proj.org_id,
263
+ project_id: getProjectId(),
264
+ site_id: site_id ?? null,
265
+ title,
266
+ slug: slug ? slugify(slug) : slugify(title),
267
+ body: body ?? null,
268
+ excerpt: excerpt ?? null,
269
+ author: author ?? null,
270
+ tags: tags ?? [],
271
+ cover_image_url: cover_image_url ?? null,
272
+ seo: seo ?? {},
273
+ status,
274
+ source: 'mcp',
275
+ source_payload: args,
276
+ publish_destination: resolvedDestination,
277
+ scheduled_at: resolvedScheduledAt,
278
+ })
279
+ .select('id, title, slug, status, source, scheduled_at, created_at')
280
+ .single();
281
+ if (error)
282
+ return j({ error: error.message, code: error.code });
283
+ return j({ blog_post: data, ...(auto_publish ? { note: scheduled_at ? 'Scheduled — the publish worker will send it at scheduled_at.' : 'Queued for immediate publish — the worker publishes it within a few minutes.' } : {}) });
284
+ });
285
+ server.registerTool('list_blog_posts', {
286
+ title: 'List Blog Posts',
287
+ annotations: {
288
+ readOnlyHint: true,
289
+ destructiveHint: false,
290
+ idempotentHint: true,
291
+ openWorldHint: false,
292
+ },
293
+ description: 'List ingested blog posts for this project, newest first. Optional status filter.',
294
+ inputSchema: {
295
+ status: z.enum(['draft', 'scheduled', 'published', 'archived']).optional(),
296
+ limit: z.number().int().min(1).max(200).optional().default(50),
297
+ },
298
+ }, async ({ status, limit = 50 }) => {
299
+ let query = db().from('blog_posts')
300
+ .select('id, title, slug, status, source, external_url, scheduled_at, published_at, created_at')
301
+ .eq('project_id', getProjectId())
302
+ .order('created_at', { ascending: false })
303
+ .limit(limit);
304
+ if (status)
305
+ query = query.eq('status', status);
306
+ else
307
+ query = query.neq('status', 'archived');
308
+ const { data, error } = await query;
309
+ if (error)
310
+ return j({ error: error.message });
311
+ return j({ blog_posts: data ?? [] });
312
+ });
313
+ server.registerTool('list_blog_destinations', {
314
+ title: 'List Blog Destinations',
315
+ annotations: {
316
+ readOnlyHint: true,
317
+ destructiveHint: false,
318
+ idempotentHint: true,
319
+ openWorldHint: false,
320
+ },
321
+ description: 'List this project\'s configured blog publish destinations (WordPress sites / signed webhooks) by id, name, and kind — pass a name or id as create_blog_post\'s publish_to to auto-publish. Never returns credentials.',
322
+ inputSchema: {},
323
+ }, async () => {
324
+ const { data, error } = await db().from('blog_destinations')
325
+ .select('id, name, kind, is_active')
326
+ .eq('project_id', getProjectId())
327
+ .eq('is_active', true)
328
+ .order('created_at', { ascending: false });
329
+ if (error)
330
+ return j({ error: error.message });
331
+ return j({ destinations: data ?? [] });
332
+ });
68
333
  }
@@ -26,6 +26,13 @@ async function engagementSnapshot(projectId) {
26
26
  }
27
27
  export function registerDataProposalTools(server) {
28
28
  server.registerTool('propose_campaign_from_data', {
29
+ title: 'Propose Campaign From Data',
30
+ annotations: {
31
+ readOnlyHint: false,
32
+ destructiveHint: false,
33
+ idempotentHint: false,
34
+ openWorldHint: true,
35
+ },
29
36
  description: 'Create an approve-gated campaign proposal from recent engagement signals and project Brand Brain.',
30
37
  inputSchema: {
31
38
  source_type: z.enum(['engagement_signal', 'segment_trend', 'cross_channel_synthesis']).optional().default('engagement_signal'),
@@ -72,6 +79,13 @@ export function registerDataProposalTools(server) {
72
79
  return j({ proposal: data });
73
80
  });
74
81
  server.registerTool('list_data_proposals', {
82
+ title: 'List Data Proposals',
83
+ annotations: {
84
+ readOnlyHint: true,
85
+ destructiveHint: false,
86
+ idempotentHint: true,
87
+ openWorldHint: false,
88
+ },
75
89
  description: 'List data-triggered campaign proposals for the current project.',
76
90
  inputSchema: {
77
91
  status: z.enum(['pending', 'approved', 'rejected', 'converted_to_draft', 'expired']).optional(),
@@ -92,6 +106,13 @@ export function registerDataProposalTools(server) {
92
106
  return j({ proposals: data ?? [] });
93
107
  });
94
108
  server.registerTool('approve_data_proposal', {
109
+ title: 'Approve Data Proposal',
110
+ annotations: {
111
+ readOnlyHint: false,
112
+ destructiveHint: false,
113
+ idempotentHint: false,
114
+ openWorldHint: false,
115
+ },
95
116
  description: 'Approve a data-triggered campaign proposal and convert it into an AI draft for review.',
96
117
  inputSchema: {
97
118
  proposal_id: z.string().uuid(),
@@ -30,6 +30,13 @@ function toCsv(rows, columns) {
30
30
  function toJsonl(rows) {
31
31
  return rows.map((row) => JSON.stringify(row)).join('\n');
32
32
  }
33
+ function importStatus(row) {
34
+ if (row.dry_run === true)
35
+ return 'dry_run';
36
+ if ((row.invalid ?? 0) > 0)
37
+ return 'partial';
38
+ return 'completed';
39
+ }
33
40
  const MIGRATION_PROVIDERS = ['customerio', 'mailchimp', 'klaviyo', 'activecampaign', 'convertkit', 'kit', 'resend'];
34
41
  const CONNECTED_PLATFORMS = [
35
42
  'stripe',
@@ -131,14 +138,23 @@ function createCampaignMigrationTotals(options) {
131
138
  },
132
139
  };
133
140
  }
134
- function createPlatformMigrationTotals(artifactTypes) {
141
+ function createPlatformMigrationTotals(artifactTypes, platform) {
135
142
  const requested = artifactTypes?.length ? new Set(artifactTypes) : null;
136
- const enabled = (key) => !requested || requested.has(key);
143
+ const customerIoOnly = new Set(['newsletters', 'broadcasts', 'transactional', 'snippets', 'collections', 'messages']);
144
+ const enabled = (key) => (!customerIoOnly.has(key) || platform === 'customerio') && (!requested || requested.has(key));
137
145
  return {
138
146
  contacts: { total: enabled('contacts') ? 1 : 0, processed: 0 },
139
147
  templates: { total: enabled('templates') ? 1 : 0, processed: 0 },
140
148
  campaigns: { total: enabled('campaigns') ? 1 : 0, processed: 0 },
141
149
  suppressions: { total: enabled('suppressions') ? 1 : 0, processed: 0 },
150
+ segments: { total: enabled('segments') ? 1 : 0, processed: 0 },
151
+ enrollments: { total: enabled('enrollments') ? 1 : 0, processed: 0 },
152
+ newsletters: { total: enabled('newsletters') ? 1 : 0, processed: 0 },
153
+ broadcasts: { total: enabled('broadcasts') ? 1 : 0, processed: 0 },
154
+ transactional: { total: enabled('transactional') ? 1 : 0, processed: 0 },
155
+ snippets: { total: enabled('snippets') ? 1 : 0, processed: 0 },
156
+ collections: { total: enabled('collections') ? 1 : 0, processed: 0 },
157
+ messages: { total: enabled('messages') ? 1 : 0, processed: 0 },
142
158
  processed: 0,
143
159
  errors: 0,
144
160
  account_name: 'Platform migration',
@@ -147,6 +163,14 @@ function createPlatformMigrationTotals(artifactTypes) {
147
163
  import_templates: enabled('templates'),
148
164
  import_campaigns: enabled('campaigns'),
149
165
  import_suppressions: enabled('suppressions'),
166
+ import_segments: enabled('segments'),
167
+ import_enrollments: enabled('enrollments'),
168
+ import_newsletters: enabled('newsletters'),
169
+ import_broadcasts: enabled('broadcasts'),
170
+ import_transactional: enabled('transactional'),
171
+ import_snippets: enabled('snippets'),
172
+ import_collections: enabled('collections'),
173
+ import_messages: enabled('messages'),
150
174
  migration_entry_point: 'mcp_connection',
151
175
  },
152
176
  };
@@ -201,6 +225,13 @@ function createZip(files) {
201
225
  }
202
226
  export function registerDataTools(server) {
203
227
  server.registerTool('list_connected_platforms', {
228
+ title: 'List Connected Platforms',
229
+ annotations: {
230
+ readOnlyHint: true,
231
+ destructiveHint: false,
232
+ idempotentHint: true,
233
+ openWorldHint: false,
234
+ },
204
235
  description: 'List connected external platforms and integration status for the current project organization. Does not return credential material.',
205
236
  inputSchema: {
206
237
  org_id: z.string().uuid().optional(),
@@ -232,13 +263,20 @@ export function registerDataTools(server) {
232
263
  }
233
264
  });
234
265
  server.registerTool('connect_platform', {
266
+ title: 'Connect Platform',
267
+ annotations: {
268
+ readOnlyHint: false,
269
+ destructiveHint: false,
270
+ idempotentHint: false,
271
+ openWorldHint: true,
272
+ },
235
273
  description: 'Store encrypted credentials for an external platform connection and mark it ready for trigger configuration.',
236
274
  inputSchema: {
237
275
  org_id: z.string().uuid().optional(),
238
276
  platform: z.enum(CONNECTED_PLATFORMS),
239
- credentials: z.record(z.unknown()),
277
+ credentials: z.record(z.string(), z.unknown()),
240
278
  display_name: z.string().min(1).optional(),
241
- config: z.record(z.unknown()).optional().default({}),
279
+ config: z.record(z.string(), z.unknown()).optional().default({}),
242
280
  status: z.enum(PLATFORM_STATUSES).optional().default('connected'),
243
281
  },
244
282
  }, async ({ org_id, platform, credentials, display_name, config = {}, status = 'connected' }) => {
@@ -291,6 +329,13 @@ export function registerDataTools(server) {
291
329
  }
292
330
  });
293
331
  server.registerTool('disconnect_platform', {
332
+ title: 'Disconnect Platform',
333
+ annotations: {
334
+ readOnlyHint: false,
335
+ destructiveHint: true,
336
+ idempotentHint: false,
337
+ openWorldHint: false,
338
+ },
294
339
  description: 'Disconnect an external platform connection for the current project organization and remove stored credentials.',
295
340
  inputSchema: {
296
341
  org_id: z.string().uuid().optional(),
@@ -329,6 +374,13 @@ export function registerDataTools(server) {
329
374
  }
330
375
  });
331
376
  server.registerTool('list_platform_events', {
377
+ title: 'List Platform Events',
378
+ annotations: {
379
+ readOnlyHint: true,
380
+ destructiveHint: false,
381
+ idempotentHint: true,
382
+ openWorldHint: false,
383
+ },
332
384
  description: 'List recent raw events received from connected platforms for troubleshooting trigger mappings.',
333
385
  inputSchema: {
334
386
  org_id: z.string().uuid().optional(),
@@ -359,6 +411,13 @@ export function registerDataTools(server) {
359
411
  }
360
412
  });
361
413
  server.registerTool('configure_platform_trigger', {
414
+ title: 'Configure Platform Trigger',
415
+ annotations: {
416
+ readOnlyHint: false,
417
+ destructiveHint: false,
418
+ idempotentHint: false,
419
+ openWorldHint: false,
420
+ },
362
421
  description: 'Configure an event-to-action mapping for a connected platform, such as adding a Shopify customer to a list after an order.',
363
422
  inputSchema: {
364
423
  org_id: z.string().uuid().optional(),
@@ -422,19 +481,40 @@ export function registerDataTools(server) {
422
481
  }
423
482
  });
424
483
  server.registerTool('list_import_history', {
484
+ title: 'List Import History',
485
+ annotations: {
486
+ readOnlyHint: true,
487
+ destructiveHint: false,
488
+ idempotentHint: true,
489
+ openWorldHint: false,
490
+ },
425
491
  description: 'List recent subscriber import history rows.',
426
492
  inputSchema: { limit: z.number().int().min(1).max(100).optional().default(25) },
427
493
  }, async ({ limit = 25 }) => {
428
494
  const { data, error } = await db().from('import_log')
429
- .select('id, source, status, imported_count, failed_count, created_at')
495
+ .select('id, source, imported, skipped, invalid, total_found, dry_run, tags, created_at')
430
496
  .eq('project_id', getProjectId())
431
497
  .order('created_at', { ascending: false })
432
498
  .limit(limit);
433
499
  if (error)
434
500
  return j({ error: error.message });
435
- return j({ imports: data ?? [] });
501
+ return j({
502
+ imports: (data ?? []).map((row) => ({
503
+ ...row,
504
+ status: importStatus(row),
505
+ imported_count: row.imported,
506
+ failed_count: row.invalid,
507
+ })),
508
+ });
436
509
  });
437
510
  server.registerTool('list_api_keys', {
511
+ title: 'List API Keys',
512
+ annotations: {
513
+ readOnlyHint: true,
514
+ destructiveHint: false,
515
+ idempotentHint: true,
516
+ openWorldHint: false,
517
+ },
438
518
  description: 'List API keys without returning secret material.',
439
519
  inputSchema: {},
440
520
  }, async () => {
@@ -448,6 +528,13 @@ export function registerDataTools(server) {
448
528
  return j({ keys: data ?? [] });
449
529
  });
450
530
  server.registerTool('create_api_key', {
531
+ title: 'Create API Key',
532
+ annotations: {
533
+ readOnlyHint: false,
534
+ destructiveHint: false,
535
+ idempotentHint: false,
536
+ openWorldHint: false,
537
+ },
451
538
  description: 'Create an API key and return the secret once.',
452
539
  inputSchema: {
453
540
  name: z.string().min(1),
@@ -466,6 +553,13 @@ export function registerDataTools(server) {
466
553
  return j({ key_id: data.id, name: data.name, slug: data.slug, secret });
467
554
  });
468
555
  server.registerTool('revoke_api_key', {
556
+ title: 'Revoke API Key',
557
+ annotations: {
558
+ readOnlyHint: false,
559
+ destructiveHint: true,
560
+ idempotentHint: false,
561
+ openWorldHint: false,
562
+ },
469
563
  description: 'Revoke an API key.',
470
564
  inputSchema: { key_id: z.string().uuid() },
471
565
  }, async ({ key_id }) => {
@@ -475,6 +569,13 @@ export function registerDataTools(server) {
475
569
  return j({ revoked: true });
476
570
  });
477
571
  server.registerTool('migrate_campaigns_from', {
572
+ title: 'Migrate Campaigns From',
573
+ annotations: {
574
+ readOnlyHint: false,
575
+ destructiveHint: false,
576
+ idempotentHint: false,
577
+ openWorldHint: true,
578
+ },
478
579
  description: 'Start a campaign-artifact migration from a provider token. Imports templates and campaigns as draft, approval-required Sendinel assets; Customer.io is the first-class path.',
479
580
  inputSchema: {
480
581
  provider: z.enum(MIGRATION_PROVIDERS),
@@ -539,10 +640,20 @@ export function registerDataTools(server) {
539
640
  });
540
641
  });
541
642
  server.registerTool('start_platform_migration', {
643
+ title: 'Start Platform Migration',
644
+ annotations: {
645
+ readOnlyHint: false,
646
+ destructiveHint: false,
647
+ idempotentHint: false,
648
+ openWorldHint: true,
649
+ },
542
650
  description: 'Start a migration from a stored external platform connection into Sendinel. Returns a job_id for status polling.',
543
651
  inputSchema: {
544
652
  connection_id: z.string().uuid(),
545
- artifact_types: z.array(z.enum(['contacts', 'templates', 'campaigns', 'suppressions'])).optional(),
653
+ artifact_types: z.array(z.enum([
654
+ 'contacts', 'templates', 'campaigns', 'suppressions', 'segments', 'enrollments',
655
+ 'newsletters', 'broadcasts', 'transactional', 'snippets', 'collections', 'messages',
656
+ ])).optional(),
546
657
  dry_run: z.boolean().optional().default(false),
547
658
  },
548
659
  }, async ({ connection_id, artifact_types, dry_run = false }) => {
@@ -574,7 +685,7 @@ export function registerDataTools(server) {
574
685
  provider: connection.platform,
575
686
  status: 'pending',
576
687
  progress_pct: 0,
577
- totals: createPlatformMigrationTotals(artifact_types),
688
+ totals: createPlatformMigrationTotals(artifact_types, connection.platform),
578
689
  errors: [],
579
690
  credentials_enc: encrypt(JSON.stringify(credentials)),
580
691
  field_mapping: null,
@@ -588,6 +699,13 @@ export function registerDataTools(server) {
588
699
  return j({ job_id: data.id, status: 'pending', connection_id, provider: connection.platform });
589
700
  });
590
701
  server.registerTool('list_notifications', {
702
+ title: 'List Notifications',
703
+ annotations: {
704
+ readOnlyHint: true,
705
+ destructiveHint: false,
706
+ idempotentHint: true,
707
+ openWorldHint: false,
708
+ },
591
709
  description: 'List in-app notifications.',
592
710
  inputSchema: { unread_only: z.boolean().optional().default(false) },
593
711
  }, async ({ unread_only = false }) => {
@@ -600,6 +718,13 @@ export function registerDataTools(server) {
600
718
  return j({ notifications: data ?? [] });
601
719
  });
602
720
  server.registerTool('mark_notifications_read', {
721
+ title: 'Mark Notifications Read',
722
+ annotations: {
723
+ readOnlyHint: false,
724
+ destructiveHint: false,
725
+ idempotentHint: true,
726
+ openWorldHint: false,
727
+ },
603
728
  description: 'Mark selected notifications read, or all unread notifications if ids is omitted.',
604
729
  inputSchema: { ids: z.array(z.string().uuid()).optional() },
605
730
  }, async ({ ids }) => {
@@ -612,6 +737,13 @@ export function registerDataTools(server) {
612
737
  return j({ updated: data?.length ?? 0 });
613
738
  });
614
739
  server.registerTool('tenant_export_full', {
740
+ title: 'Tenant Export Full',
741
+ annotations: {
742
+ readOnlyHint: true,
743
+ destructiveHint: false,
744
+ idempotentHint: true,
745
+ openWorldHint: false,
746
+ },
615
747
  description: 'Export all tenant data classes for portability. Returns inline data for small exports or a signed archive URL for large exports.',
616
748
  inputSchema: {
617
749
  siteId: z.string().uuid(),