@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
@@ -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',
@@ -139,6 +146,8 @@ function createPlatformMigrationTotals(artifactTypes) {
139
146
  templates: { total: enabled('templates') ? 1 : 0, processed: 0 },
140
147
  campaigns: { total: enabled('campaigns') ? 1 : 0, processed: 0 },
141
148
  suppressions: { total: enabled('suppressions') ? 1 : 0, processed: 0 },
149
+ segments: { total: enabled('segments') ? 1 : 0, processed: 0 },
150
+ enrollments: { total: enabled('enrollments') ? 1 : 0, processed: 0 },
142
151
  processed: 0,
143
152
  errors: 0,
144
153
  account_name: 'Platform migration',
@@ -147,6 +156,8 @@ function createPlatformMigrationTotals(artifactTypes) {
147
156
  import_templates: enabled('templates'),
148
157
  import_campaigns: enabled('campaigns'),
149
158
  import_suppressions: enabled('suppressions'),
159
+ import_segments: enabled('segments'),
160
+ import_enrollments: enabled('enrollments'),
150
161
  migration_entry_point: 'mcp_connection',
151
162
  },
152
163
  };
@@ -201,6 +212,13 @@ function createZip(files) {
201
212
  }
202
213
  export function registerDataTools(server) {
203
214
  server.registerTool('list_connected_platforms', {
215
+ title: 'List Connected Platforms',
216
+ annotations: {
217
+ readOnlyHint: true,
218
+ destructiveHint: false,
219
+ idempotentHint: true,
220
+ openWorldHint: false,
221
+ },
204
222
  description: 'List connected external platforms and integration status for the current project organization. Does not return credential material.',
205
223
  inputSchema: {
206
224
  org_id: z.string().uuid().optional(),
@@ -232,13 +250,20 @@ export function registerDataTools(server) {
232
250
  }
233
251
  });
234
252
  server.registerTool('connect_platform', {
253
+ title: 'Connect Platform',
254
+ annotations: {
255
+ readOnlyHint: false,
256
+ destructiveHint: false,
257
+ idempotentHint: false,
258
+ openWorldHint: true,
259
+ },
235
260
  description: 'Store encrypted credentials for an external platform connection and mark it ready for trigger configuration.',
236
261
  inputSchema: {
237
262
  org_id: z.string().uuid().optional(),
238
263
  platform: z.enum(CONNECTED_PLATFORMS),
239
- credentials: z.record(z.unknown()),
264
+ credentials: z.record(z.string(), z.unknown()),
240
265
  display_name: z.string().min(1).optional(),
241
- config: z.record(z.unknown()).optional().default({}),
266
+ config: z.record(z.string(), z.unknown()).optional().default({}),
242
267
  status: z.enum(PLATFORM_STATUSES).optional().default('connected'),
243
268
  },
244
269
  }, async ({ org_id, platform, credentials, display_name, config = {}, status = 'connected' }) => {
@@ -291,6 +316,13 @@ export function registerDataTools(server) {
291
316
  }
292
317
  });
293
318
  server.registerTool('disconnect_platform', {
319
+ title: 'Disconnect Platform',
320
+ annotations: {
321
+ readOnlyHint: false,
322
+ destructiveHint: true,
323
+ idempotentHint: false,
324
+ openWorldHint: false,
325
+ },
294
326
  description: 'Disconnect an external platform connection for the current project organization and remove stored credentials.',
295
327
  inputSchema: {
296
328
  org_id: z.string().uuid().optional(),
@@ -329,6 +361,13 @@ export function registerDataTools(server) {
329
361
  }
330
362
  });
331
363
  server.registerTool('list_platform_events', {
364
+ title: 'List Platform Events',
365
+ annotations: {
366
+ readOnlyHint: true,
367
+ destructiveHint: false,
368
+ idempotentHint: true,
369
+ openWorldHint: false,
370
+ },
332
371
  description: 'List recent raw events received from connected platforms for troubleshooting trigger mappings.',
333
372
  inputSchema: {
334
373
  org_id: z.string().uuid().optional(),
@@ -359,6 +398,13 @@ export function registerDataTools(server) {
359
398
  }
360
399
  });
361
400
  server.registerTool('configure_platform_trigger', {
401
+ title: 'Configure Platform Trigger',
402
+ annotations: {
403
+ readOnlyHint: false,
404
+ destructiveHint: false,
405
+ idempotentHint: false,
406
+ openWorldHint: false,
407
+ },
362
408
  description: 'Configure an event-to-action mapping for a connected platform, such as adding a Shopify customer to a list after an order.',
363
409
  inputSchema: {
364
410
  org_id: z.string().uuid().optional(),
@@ -422,19 +468,40 @@ export function registerDataTools(server) {
422
468
  }
423
469
  });
424
470
  server.registerTool('list_import_history', {
471
+ title: 'List Import History',
472
+ annotations: {
473
+ readOnlyHint: true,
474
+ destructiveHint: false,
475
+ idempotentHint: true,
476
+ openWorldHint: false,
477
+ },
425
478
  description: 'List recent subscriber import history rows.',
426
479
  inputSchema: { limit: z.number().int().min(1).max(100).optional().default(25) },
427
480
  }, async ({ limit = 25 }) => {
428
481
  const { data, error } = await db().from('import_log')
429
- .select('id, source, status, imported_count, failed_count, created_at')
482
+ .select('id, source, imported, skipped, invalid, total_found, dry_run, tags, created_at')
430
483
  .eq('project_id', getProjectId())
431
484
  .order('created_at', { ascending: false })
432
485
  .limit(limit);
433
486
  if (error)
434
487
  return j({ error: error.message });
435
- return j({ imports: data ?? [] });
488
+ return j({
489
+ imports: (data ?? []).map((row) => ({
490
+ ...row,
491
+ status: importStatus(row),
492
+ imported_count: row.imported,
493
+ failed_count: row.invalid,
494
+ })),
495
+ });
436
496
  });
437
497
  server.registerTool('list_api_keys', {
498
+ title: 'List API Keys',
499
+ annotations: {
500
+ readOnlyHint: true,
501
+ destructiveHint: false,
502
+ idempotentHint: true,
503
+ openWorldHint: false,
504
+ },
438
505
  description: 'List API keys without returning secret material.',
439
506
  inputSchema: {},
440
507
  }, async () => {
@@ -448,6 +515,13 @@ export function registerDataTools(server) {
448
515
  return j({ keys: data ?? [] });
449
516
  });
450
517
  server.registerTool('create_api_key', {
518
+ title: 'Create API Key',
519
+ annotations: {
520
+ readOnlyHint: false,
521
+ destructiveHint: false,
522
+ idempotentHint: false,
523
+ openWorldHint: false,
524
+ },
451
525
  description: 'Create an API key and return the secret once.',
452
526
  inputSchema: {
453
527
  name: z.string().min(1),
@@ -466,6 +540,13 @@ export function registerDataTools(server) {
466
540
  return j({ key_id: data.id, name: data.name, slug: data.slug, secret });
467
541
  });
468
542
  server.registerTool('revoke_api_key', {
543
+ title: 'Revoke API Key',
544
+ annotations: {
545
+ readOnlyHint: false,
546
+ destructiveHint: true,
547
+ idempotentHint: false,
548
+ openWorldHint: false,
549
+ },
469
550
  description: 'Revoke an API key.',
470
551
  inputSchema: { key_id: z.string().uuid() },
471
552
  }, async ({ key_id }) => {
@@ -475,6 +556,13 @@ export function registerDataTools(server) {
475
556
  return j({ revoked: true });
476
557
  });
477
558
  server.registerTool('migrate_campaigns_from', {
559
+ title: 'Migrate Campaigns From',
560
+ annotations: {
561
+ readOnlyHint: false,
562
+ destructiveHint: false,
563
+ idempotentHint: false,
564
+ openWorldHint: true,
565
+ },
478
566
  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
567
  inputSchema: {
480
568
  provider: z.enum(MIGRATION_PROVIDERS),
@@ -539,10 +627,17 @@ export function registerDataTools(server) {
539
627
  });
540
628
  });
541
629
  server.registerTool('start_platform_migration', {
630
+ title: 'Start Platform Migration',
631
+ annotations: {
632
+ readOnlyHint: false,
633
+ destructiveHint: false,
634
+ idempotentHint: false,
635
+ openWorldHint: true,
636
+ },
542
637
  description: 'Start a migration from a stored external platform connection into Sendinel. Returns a job_id for status polling.',
543
638
  inputSchema: {
544
639
  connection_id: z.string().uuid(),
545
- artifact_types: z.array(z.enum(['contacts', 'templates', 'campaigns', 'suppressions'])).optional(),
640
+ artifact_types: z.array(z.enum(['contacts', 'templates', 'campaigns', 'suppressions', 'segments', 'enrollments'])).optional(),
546
641
  dry_run: z.boolean().optional().default(false),
547
642
  },
548
643
  }, async ({ connection_id, artifact_types, dry_run = false }) => {
@@ -588,6 +683,13 @@ export function registerDataTools(server) {
588
683
  return j({ job_id: data.id, status: 'pending', connection_id, provider: connection.platform });
589
684
  });
590
685
  server.registerTool('list_notifications', {
686
+ title: 'List Notifications',
687
+ annotations: {
688
+ readOnlyHint: true,
689
+ destructiveHint: false,
690
+ idempotentHint: true,
691
+ openWorldHint: false,
692
+ },
591
693
  description: 'List in-app notifications.',
592
694
  inputSchema: { unread_only: z.boolean().optional().default(false) },
593
695
  }, async ({ unread_only = false }) => {
@@ -600,6 +702,13 @@ export function registerDataTools(server) {
600
702
  return j({ notifications: data ?? [] });
601
703
  });
602
704
  server.registerTool('mark_notifications_read', {
705
+ title: 'Mark Notifications Read',
706
+ annotations: {
707
+ readOnlyHint: false,
708
+ destructiveHint: false,
709
+ idempotentHint: true,
710
+ openWorldHint: false,
711
+ },
603
712
  description: 'Mark selected notifications read, or all unread notifications if ids is omitted.',
604
713
  inputSchema: { ids: z.array(z.string().uuid()).optional() },
605
714
  }, async ({ ids }) => {
@@ -612,6 +721,13 @@ export function registerDataTools(server) {
612
721
  return j({ updated: data?.length ?? 0 });
613
722
  });
614
723
  server.registerTool('tenant_export_full', {
724
+ title: 'Tenant Export Full',
725
+ annotations: {
726
+ readOnlyHint: true,
727
+ destructiveHint: false,
728
+ idempotentHint: true,
729
+ openWorldHint: false,
730
+ },
615
731
  description: 'Export all tenant data classes for portability. Returns inline data for small exports or a signed archive URL for large exports.',
616
732
  inputSchema: {
617
733
  siteId: z.string().uuid(),