@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.
- package/README.md +35 -22
- package/build/anthropic-model.d.ts +30 -0
- package/build/anthropic-model.js +26 -1
- package/build/db.d.ts +17 -0
- package/build/db.js +15 -1
- package/build/index.js +6 -0
- package/build/lib/action-approvals.js +28 -0
- package/build/lib/brand-asset-persist.d.ts +1 -0
- package/build/lib/brand-asset-persist.js +20 -1
- package/build/lib/composer-blocks.d.ts +25 -25
- package/build/lib/composer-blocks.js +25 -5
- package/build/lib/connector-table-discovery.d.ts +12 -0
- package/build/lib/connector-table-discovery.js +117 -0
- package/build/lib/custom-properties-diff.d.ts +16 -0
- package/build/lib/custom-properties-diff.js +22 -0
- package/build/lib/schedule.d.ts +7 -2
- package/build/lib/schedule.js +8 -0
- package/build/lib/v1-client.d.ts +3 -0
- package/build/lib/v1-client.js +45 -0
- package/build/lib/voice.d.ts +7 -30
- package/build/lib/voice.js +6 -138
- package/build/prompts.d.ts +6 -0
- package/build/prompts.js +18 -0
- package/build/scoped-token.d.ts +1 -1
- package/build/server.d.ts +1 -0
- package/build/server.js +65 -6
- package/build/tools/analytics.js +97 -0
- package/build/tools/brand-kit.js +29 -30
- package/build/tools/campaigns.js +326 -52
- package/build/tools/cloud-import.d.ts +2 -0
- package/build/tools/cloud-import.js +76 -0
- package/build/tools/compound.d.ts +11 -0
- package/build/tools/compound.js +37 -7
- package/build/tools/connector.d.ts +2 -0
- package/build/tools/connector.js +280 -0
- package/build/tools/contacts.js +744 -16
- package/build/tools/content.js +228 -0
- package/build/tools/data.js +441 -0
- package/build/tools/delivery-ops.js +508 -1
- package/build/tools/forms.js +7 -6
- package/build/tools/gateway.d.ts +6 -0
- package/build/tools/gateway.js +92 -0
- package/build/tools/gdpr.js +185 -0
- package/build/tools/landing-pages.d.ts +2 -0
- package/build/tools/landing-pages.js +202 -0
- package/build/tools/lists.d.ts +2 -0
- package/build/tools/lists.js +428 -0
- package/build/tools/newsletters.js +1 -8
- package/build/tools/org.js +549 -33
- package/build/tools/segments.js +35 -184
- package/build/tools/signatures.d.ts +2 -0
- package/build/tools/signatures.js +168 -0
- package/build/tools/sites.js +3 -1
- package/build/tools/skills.d.ts +2 -0
- package/build/tools/skills.js +29 -0
- package/build/tools/templates.js +133 -7
- package/package.json +14 -13
package/build/tools/compound.js
CHANGED
|
@@ -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,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
|
+
}
|