@sendinel/mcp-server 1.0.10 → 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 +13 -12
@@ -1,11 +1,29 @@
1
1
  import { z } from 'zod';
2
2
  import Anthropic from '@anthropic-ai/sdk';
3
- import { db } from '../db.js';
3
+ import { adminDb, db } from '../db.js';
4
4
  import { getProjectId } from '../project.js';
5
- import { buildVoicePrompt } from '../lib/voice.js';
5
+ import { buildVoicePrompt, deriveTemplateVariables } from '../lib/voice.js';
6
6
  import { getAnthropicFastModelId } from '../anthropic-model.js';
7
7
  import { BLOCK_GENERATION_SCHEMA, blocksToHtml, blocksToText, normalizeComposerBlocks } from '../lib/composer-blocks.js';
8
8
  const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
9
+ const HEX6 = /^#[0-9A-Fa-f]{6}$/;
10
+ function stringArray(value) {
11
+ return Array.isArray(value) ? value.filter((item) => typeof item === 'string' && item.trim().length > 0) : [];
12
+ }
13
+ export function resolveBrandVisualContext(site, brandSettings) {
14
+ const brandColors = stringArray(brandSettings?.brand_colors);
15
+ const visualStyle = typeof brandSettings?.visual_style === 'string' ? brandSettings.visual_style.trim() : '';
16
+ const logoUrl = typeof brandSettings?.logo_url === 'string' ? brandSettings.logo_url.trim() : '';
17
+ const rawSiteColor = site?.unsub_brand_color ?? '';
18
+ const siteColor = HEX6.test(rawSiteColor) ? rawSiteColor : null;
19
+ const resolvedButtonColor = siteColor ?? brandColors[0] ?? undefined;
20
+ const promptBlock = [
21
+ brandColors.length ? `BRAND COLORS: ${brandColors.join(', ')}. Heading and button blocks may set text_color/bg_color from this palette when it fits the brief.` : '',
22
+ visualStyle ? `VISUAL STYLE: ${visualStyle}` : '',
23
+ logoUrl ? `LOGO: ${logoUrl}. Use the image block type to place it in a header when it improves the template.` : '',
24
+ ].filter(Boolean).join('\n');
25
+ return { brandColors, visualStyle, logoUrl, resolvedButtonColor, promptBlock };
26
+ }
9
27
  function getAnthropicClient() {
10
28
  const apiKey = process.env.ANTHROPIC_API_KEY;
11
29
  if (!apiKey)
@@ -46,10 +64,13 @@ export function registerCompoundTools(server) {
46
64
  const projectId = getProjectId();
47
65
  const sb = db();
48
66
  const { data: site } = await sb.from('sites')
49
- .select('name, brand_voice')
67
+ .select('name, brand_voice, unsub_brand_color')
50
68
  .eq('id', site_id)
51
69
  .eq('project_id', projectId)
52
70
  .maybeSingle();
71
+ const { data: project } = await adminDb().from('projects').select('brand_settings').eq('id', projectId).maybeSingle();
72
+ const brandSettings = (project?.brand_settings ?? {});
73
+ const brandVisual = resolveBrandVisualContext(site, brandSettings);
53
74
  const voiceContext = buildVoicePrompt(site?.brand_voice);
54
75
  let steps;
55
76
  try {
@@ -63,6 +84,7 @@ export function registerCompoundTools(server) {
63
84
  CAMPAIGN BRIEF: ${brief}
64
85
 
65
86
  BRAND VOICE: ${voiceContext}
87
+ ${brandVisual.promptBlock ? `\n${brandVisual.promptBlock}` : ''}
66
88
 
67
89
  Generate exactly ${num_steps} emails. For each email, provide:
68
90
  - subject
@@ -79,7 +101,7 @@ Respond with a JSON array.`,
79
101
  });
80
102
  const raw = msg.content[0].text;
81
103
  steps = extractJsonArray(raw).map((step, index) => {
82
- const blocks = normalizeComposerBlocks(step.blocks);
104
+ const blocks = normalizeComposerBlocks(step.blocks, brandVisual.resolvedButtonColor);
83
105
  return {
84
106
  subject: typeof step.subject === 'string' ? step.subject : `Step ${index + 1}`,
85
107
  body_html: blocks.length ? blocksToHtml(blocks) : '',
@@ -101,6 +123,7 @@ Respond with a JSON array.`,
101
123
  brief_template: brief,
102
124
  subject_hint: steps[0]?.subject ?? '',
103
125
  body_html: steps[0]?.body_html ?? null,
126
+ variables: deriveTemplateVariables({ body_html: steps[0]?.body_html ?? null, subject_hint: steps[0]?.subject ?? '' }),
104
127
  category: 'campaign',
105
128
  description: `Base template generated for campaign "${campaign_name}"`,
106
129
  })
@@ -161,10 +184,13 @@ Respond with a JSON array.`,
161
184
  }, async ({ site_id, name, brief, category }) => {
162
185
  const projectId = getProjectId();
163
186
  const { data: site } = await db().from('sites')
164
- .select('name, brand_voice')
187
+ .select('name, brand_voice, unsub_brand_color')
165
188
  .eq('id', site_id)
166
189
  .eq('project_id', projectId)
167
190
  .maybeSingle();
191
+ const { data: project } = await adminDb().from('projects').select('brand_settings').eq('id', projectId).maybeSingle();
192
+ const brandSettings = (project?.brand_settings ?? {});
193
+ const brandVisual = resolveBrandVisualContext(site, brandSettings);
168
194
  let generated;
169
195
  try {
170
196
  const msg = await getAnthropicClient().messages.create({
@@ -175,6 +201,7 @@ Respond with a JSON array.`,
175
201
  content: `Write an email for "${site?.name ?? 'our product'}".
176
202
  BRIEF: ${brief}
177
203
  BRAND VOICE: ${buildVoicePrompt(site?.brand_voice)}
204
+ ${brandVisual.promptBlock ? `${brandVisual.promptBlock}\n` : ''}
178
205
  Generate structured Composer blocks first. ComposerBlock[] must conform to this JSON Schema:
179
206
  ${JSON.stringify(BLOCK_GENERATION_SCHEMA)}
180
207
  Do not include body_html. Sendinel renders body_html from blocks.
@@ -183,7 +210,7 @@ Respond with JSON: { "subject": "...", "blocks": [...], "body_text": "..." }`,
183
210
  });
184
211
  const raw = msg.content[0].text;
185
212
  const parsed = extractJsonObject(raw);
186
- const blocks = normalizeComposerBlocks(parsed.blocks);
213
+ const blocks = normalizeComposerBlocks(parsed.blocks, brandVisual.resolvedButtonColor);
187
214
  if (!blocks.length)
188
215
  throw new Error('AI response did not include editable Composer blocks');
189
216
  generated = {
@@ -203,6 +230,7 @@ Respond with JSON: { "subject": "...", "blocks": [...], "body_text": "..." }`,
203
230
  brief_template: brief,
204
231
  subject_hint: generated.subject,
205
232
  body_html: generated.body_html,
233
+ variables: deriveTemplateVariables({ body_html: generated.body_html, subject_hint: generated.subject }),
206
234
  category: category ?? 'general',
207
235
  description: 'Generated from MCP brief',
208
236
  })
@@ -291,6 +319,7 @@ Identify the most likely root cause and give 3-5 concise actionable next steps.`
291
319
  inputSchema: {
292
320
  name: z.string().min(1),
293
321
  domain: z.string().min(1),
322
+ physical_address: z.string().min(1).describe('CAN-SPAM-required physical mailing address for this site. Must be an explicit value - there is no default.'),
294
323
  provider: z.enum(['resend', 'sendgrid', 'ses', 'postmark']).default('resend'),
295
324
  daily_send_volume: z.number().int().min(1).default(100),
296
325
  brand_settings: z.object({
@@ -299,7 +328,7 @@ Identify the most likely root cause and give 3-5 concise actionable next steps.`
299
328
  signoff_name: z.string().optional(),
300
329
  }).optional(),
301
330
  },
302
- }, async ({ name, domain, provider, daily_send_volume, brand_settings }) => {
331
+ }, async ({ name, domain, physical_address, provider, daily_send_volume, brand_settings }) => {
303
332
  const projectId = getProjectId();
304
333
  const sb = db();
305
334
  const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'site';
@@ -330,6 +359,7 @@ Identify the most likely root cause and give 3-5 concise actionable next steps.`
330
359
  sending_domain: domain,
331
360
  from_name: fromName,
332
361
  from_email: fromEmail,
362
+ physical_address,
333
363
  active: true,
334
364
  domain_id: createdDomain.id,
335
365
  brand_voice: brand_settings?.tone_words ? { tone_words: brand_settings.tone_words, mission: brand_settings.mission } : {},
@@ -0,0 +1,2 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerConnectorTools(server: McpServer): void;
@@ -0,0 +1,280 @@
1
+ import { z } from 'zod';
2
+ import { adminDb, db } from '../db.js';
3
+ import { getProjectId } from '../project.js';
4
+ import { listConnectorTablesFromDb } from '../lib/connector-table-discovery.js';
5
+ import { callSendinelV1 } from '../lib/v1-client.js';
6
+ import { assertChannelCanExecute, checkApproval, currentChannelContext, requestApproval, } from '../lib/action-approvals.js';
7
+ const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
8
+ const SECRET_RESPONSE_KEYS = new Set([
9
+ 'connector_supabase_key_enc',
10
+ 'connector_supabase_pat_enc',
11
+ 'connector_webhook_secret_enc',
12
+ 'service_role_key',
13
+ 'supabase_pat',
14
+ 'webhook_secret',
15
+ // Live-view SQL embeds the decrypted connector webhook secret.
16
+ 'sql',
17
+ ]);
18
+ function sanitizeConnectorResponse(value) {
19
+ if (Array.isArray(value))
20
+ return value.map(sanitizeConnectorResponse);
21
+ if (!value || typeof value !== 'object')
22
+ return value;
23
+ return Object.fromEntries(Object.entries(value)
24
+ .filter(([key]) => !SECRET_RESPONSE_KEYS.has(key))
25
+ .map(([key, entry]) => [key, sanitizeConnectorResponse(entry)]));
26
+ }
27
+ async function callConnectorApi(path, init) {
28
+ const result = await callSendinelV1(path, init);
29
+ return result.ok
30
+ ? { ...result, data: sanitizeConnectorResponse(result.data) }
31
+ : { ...result, error: sanitizeConnectorResponse(result.error) };
32
+ }
33
+ function connectorWebhookUrl(projectId) {
34
+ const base = (process.env.NEXT_PUBLIC_APP_URL ?? process.env.APP_URL ?? 'https://sendinel.ai').replace(/\/$/, '');
35
+ return `${base}/api/webhooks/byod/${projectId}`;
36
+ }
37
+ async function getConnectorStatus() {
38
+ const projectId = getProjectId();
39
+ const sb = db();
40
+ const { data: row, error } = await sb
41
+ .schema('email')
42
+ .from('projects')
43
+ .select('connector_type, connector_supabase_url, connector_db_host, connector_db_name, ' +
44
+ 'connector_airtable_base_id, connector_schema_version, connector_health_status, ' +
45
+ 'connector_health_checked_at, connector_source_table, connector_mapping, ' +
46
+ 'connector_view_installed, connector_view_error, connector_view_installed_at, ' +
47
+ 'connector_webhook_secret_enc, connector_supabase_pat_enc, org:organizations(connector_supabase_pat_enc)')
48
+ .eq('id', projectId)
49
+ .maybeSingle();
50
+ if (error)
51
+ throw new Error(error.message);
52
+ if (!row)
53
+ throw new Error(`Project ${projectId} not found.`);
54
+ const org = Array.isArray(row.org) ? row.org[0] : row.org;
55
+ const info = {
56
+ type: row.connector_type ?? 'sendinel',
57
+ projectId,
58
+ supabaseUrl: row.connector_supabase_url ?? undefined,
59
+ dbHost: row.connector_db_host ?? undefined,
60
+ dbName: row.connector_db_name ?? undefined,
61
+ airtableBaseId: row.connector_airtable_base_id ?? undefined,
62
+ schemaVersion: row.connector_schema_version ?? 0,
63
+ healthStatus: row.connector_health_status ?? null,
64
+ healthCheckedAt: row.connector_health_checked_at ?? null,
65
+ sourceTable: row.connector_source_table ?? undefined,
66
+ mapping: row.connector_mapping ?? undefined,
67
+ viewInstalled: row.connector_view_installed === true,
68
+ viewError: row.connector_view_error ?? null,
69
+ viewInstalledAt: row.connector_view_installed_at ?? null,
70
+ webhookUrl: connectorWebhookUrl(projectId),
71
+ webhookSecretConfigured: Boolean(row.connector_webhook_secret_enc && row.connector_webhook_secret_enc !== 'PENDING_REVEAL'),
72
+ patConfigured: Boolean(row.connector_supabase_pat_enc || org?.connector_supabase_pat_enc),
73
+ };
74
+ if (info.type === 'byod') {
75
+ const [subscribers, domain, campaign] = await Promise.all([
76
+ sb.schema('email').from('subscribers').select('id', { count: 'exact', head: true }).eq('project_id', projectId),
77
+ sb.schema('email').from('domains').select('id', { count: 'exact', head: true }).eq('project_id', projectId).not('verified_at', 'is', null),
78
+ sb.schema('email').from('campaigns').select('id', { count: 'exact', head: true }).eq('project_id', projectId),
79
+ ]);
80
+ info.onboarding = {
81
+ hasSubscribers: (subscribers.count ?? 0) > 0,
82
+ hasVerifiedDomain: (domain.count ?? 0) > 0,
83
+ hasCampaign: (campaign.count ?? 0) > 0,
84
+ };
85
+ }
86
+ return info;
87
+ }
88
+ async function authorizeConnectorMutation(action, confirmed, approvalId, approvalParams, preview) {
89
+ if (!confirmed) {
90
+ return j({
91
+ confirmation_required: true,
92
+ tool: action,
93
+ ...preview,
94
+ message: `Review this connector change, then call ${action} again with confirmed=true to request approval.`,
95
+ });
96
+ }
97
+ const channelContext = currentChannelContext();
98
+ const decision = assertChannelCanExecute(action, approvalParams, channelContext);
99
+ if (!decision.allowed)
100
+ return j({ error: decision.reason ?? 'This channel cannot request this action.' });
101
+ if (decision.tier === 'autonomous')
102
+ return null;
103
+ if (!approvalId) {
104
+ return j(await requestApproval(action, approvalParams, undefined, channelContext));
105
+ }
106
+ const check = await checkApproval(approvalId, {
107
+ consume: true,
108
+ action,
109
+ params: approvalParams,
110
+ });
111
+ if (!check.approved) {
112
+ return j({
113
+ error: `Approval ${approvalId} is ${check.status}.`,
114
+ approval_id: approvalId,
115
+ approval_status: check.status,
116
+ message: check.status === 'pending'
117
+ ? 'Waiting for human approval via dashboard.'
118
+ : 'Request a new approval.',
119
+ });
120
+ }
121
+ return null;
122
+ }
123
+ const approvalFields = {
124
+ confirmed: z.boolean().optional().default(false)
125
+ .describe('Two-call confirmation. First call previews; call again with true to request approval.'),
126
+ approval_id: z.string().uuid().optional()
127
+ .describe('Approval token from a previous confirmed call. Required before the connector mutation executes.'),
128
+ };
129
+ export function registerConnectorTools(server) {
130
+ server.registerTool('get_connector_status', {
131
+ title: 'Get Connector Status',
132
+ annotations: {
133
+ readOnlyHint: true,
134
+ destructiveHint: false,
135
+ idempotentHint: true,
136
+ openWorldHint: true,
137
+ },
138
+ description: 'Return this project\'s BYOD connector status, health, mapping, onboarding progress, and explicit viewInstalled boolean. Check viewInstalled before assuming customer-database writeback is active. Never returns connector credentials, a stored Supabase PAT, or a webhook secret.',
139
+ inputSchema: {},
140
+ }, async () => {
141
+ let data;
142
+ try {
143
+ data = await getConnectorStatus();
144
+ }
145
+ catch (error) {
146
+ return j({ error: error instanceof Error ? error.message : 'Connector status query failed' });
147
+ }
148
+ if (!data || typeof data !== 'object' || typeof data.viewInstalled !== 'boolean') {
149
+ return j({ error: 'Connector status did not include the required viewInstalled boolean.' });
150
+ }
151
+ return j(data);
152
+ });
153
+ server.registerTool('list_connector_tables', {
154
+ title: 'List Connector Tables',
155
+ annotations: {
156
+ readOnlyHint: true,
157
+ destructiveHint: false,
158
+ idempotentHint: true,
159
+ openWorldHint: true,
160
+ },
161
+ description: 'List candidate tables in the connected BYOD Supabase database, scored by likely fit as the contact source table.',
162
+ inputSchema: {},
163
+ }, async () => {
164
+ const projectId = getProjectId();
165
+ const { data: project, error } = await adminDb().schema('email').from('projects')
166
+ .select('connector_type').eq('id', projectId).maybeSingle();
167
+ if (error)
168
+ return j({ error: error.message });
169
+ if (project?.connector_type !== 'byod') {
170
+ return j({ error: 'Connect your Supabase database in Settings before importing from it.' });
171
+ }
172
+ try {
173
+ return j({ tables: await listConnectorTablesFromDb(db()) });
174
+ }
175
+ catch (tableError) {
176
+ return j({ error: tableError instanceof Error ? tableError.message : 'Could not list database tables' });
177
+ }
178
+ });
179
+ server.registerTool('install_connector_schema', {
180
+ title: 'Install Connector Schema',
181
+ annotations: {
182
+ readOnlyHint: false,
183
+ destructiveHint: false,
184
+ idempotentHint: true,
185
+ openWorldHint: true,
186
+ },
187
+ description: 'Install the canonical Sendinel schema in the customer\'s BYOD Supabase using the dashboard auto-installer. This changes the customer database and requires confirmation plus human approval. If auto-install is unavailable, use the manual Settings flow.',
188
+ inputSchema: approvalFields,
189
+ }, async ({ confirmed, approval_id }) => {
190
+ const gate = await authorizeConnectorMutation('install_connector_schema', confirmed, approval_id, {}, { effect: 'Install or update Sendinel-owned schema objects in the connected customer database.' });
191
+ if (gate)
192
+ return gate;
193
+ const result = await callConnectorApi('/api/dashboard/connector/schema/install', { method: 'POST' });
194
+ return j(result.ok ? result.data : { error: result.error });
195
+ });
196
+ server.registerTool('install_connector_view', {
197
+ title: 'Install Connector Live View',
198
+ annotations: {
199
+ readOnlyHint: false,
200
+ destructiveHint: true,
201
+ idempotentHint: true,
202
+ openWorldHint: true,
203
+ },
204
+ description: 'Install or deliberately reinstall the snapshot sendinel_contact_view and subscriber webhook trigger in the customer\'s BYOD Supabase. This changes the customer database and requires confirmation plus human approval. A Supabase PAT may be supplied for first-time Cloud installation; stored PATs and generated SQL are never returned.',
205
+ inputSchema: {
206
+ supabase_pat: z.string().min(1).optional()
207
+ .describe('Supabase Personal Access Token, only when Cloud auto-install does not already have one stored.'),
208
+ ...approvalFields,
209
+ },
210
+ }, async ({ supabase_pat, confirmed, approval_id }) => {
211
+ const approvalParams = { supabase_pat_provided: Boolean(supabase_pat) };
212
+ const gate = await authorizeConnectorMutation('install_connector_view', confirmed, approval_id, approvalParams, {
213
+ effect: 'Drop and recreate the installed contact view and webhook trigger in the connected customer database.',
214
+ supabase_pat_provided: Boolean(supabase_pat),
215
+ });
216
+ if (gate)
217
+ return gate;
218
+ const result = await callConnectorApi('/api/dashboard/connector/view', {
219
+ method: 'POST',
220
+ body: supabase_pat ? { supabase_pat } : {},
221
+ });
222
+ return j(result.ok ? result.data : { error: result.error });
223
+ });
224
+ server.registerTool('set_connector_mapping', {
225
+ title: 'Set Connector Mapping',
226
+ annotations: {
227
+ readOnlyHint: false,
228
+ destructiveHint: true,
229
+ idempotentHint: true,
230
+ openWorldHint: true,
231
+ },
232
+ description: 'Save the BYOD source table and contact-column mapping. The dashboard route clears deselected custom-property keys, attempts to refresh the installed view, and resyncs contacts immediately. Requires confirmation plus human approval; call get_connector_status afterward to verify viewInstalled explicitly.',
233
+ inputSchema: {
234
+ table_id: z.string().min(1).describe('Table identifier from list_connector_tables, for example public.users.'),
235
+ mapping: z.object({
236
+ email: z.string().min(1).describe('Column mapped to contact email.'),
237
+ name: z.string().optional(),
238
+ first_name: z.string().optional(),
239
+ last_name: z.string().optional(),
240
+ joined_at: z.string().optional(),
241
+ is_test: z.string().optional(),
242
+ }),
243
+ metadata_columns: z.array(z.string()).max(100).optional()
244
+ .describe('Extra source columns copied into contact custom_properties.'),
245
+ ...approvalFields,
246
+ },
247
+ }, async ({ table_id, mapping, metadata_columns, confirmed, approval_id }) => {
248
+ const approvalParams = { table_id, mapping, metadata_columns };
249
+ const gate = await authorizeConnectorMutation('set_connector_mapping', confirmed, approval_id, approvalParams, {
250
+ effect: 'Replace the connector mapping, clear removed custom properties, refresh the live view, and resync contacts.',
251
+ table_id,
252
+ mapping,
253
+ metadata_columns: metadata_columns ?? [],
254
+ });
255
+ if (gate)
256
+ return gate;
257
+ const result = await callConnectorApi('/api/dashboard/connector/mapping', {
258
+ method: 'PATCH',
259
+ body: { table_id, mapping, metadata_columns },
260
+ });
261
+ return j(result.ok ? result.data : { error: result.error });
262
+ });
263
+ server.registerTool('resync_connector', {
264
+ title: 'Resync Connector Contacts',
265
+ annotations: {
266
+ readOnlyHint: false,
267
+ destructiveHint: false,
268
+ idempotentHint: true,
269
+ openWorldHint: true,
270
+ },
271
+ description: 'Run the dashboard connector\'s full contact sync from the configured BYOD source table into Sendinel. This can update many contact rows and requires confirmation plus human approval.',
272
+ inputSchema: approvalFields,
273
+ }, async ({ confirmed, approval_id }) => {
274
+ const gate = await authorizeConnectorMutation('resync_connector', confirmed, approval_id, {}, { effect: 'Resync all contacts from the configured BYOD source table.' });
275
+ if (gate)
276
+ return gate;
277
+ const result = await callConnectorApi('/api/dashboard/connector/resync', { method: 'POST' });
278
+ return j(result.ok ? result.data : { error: result.error });
279
+ });
280
+ }