@sendinel/mcp-server 1.0.10 → 1.0.12
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/advisor.js +3 -3
- package/build/tools/analytics.d.ts +1 -0
- package/build/tools/analytics.js +105 -8
- package/build/tools/brand-kit.js +29 -30
- package/build/tools/campaigns.js +349 -60
- 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-proposals.js +1 -1
- package/build/tools/data.js +448 -4
- package/build/tools/delivery-ops.js +525 -7
- 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 +565 -35
- 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 +13 -12
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
2
|
+
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
|
3
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { getRegisteredToolGroups, registerAllTools } from '../server.js';
|
|
6
|
+
const textResult = (text) => ({ content: [{ type: 'text', text }] });
|
|
7
|
+
const jsonResult = (data) => textResult(JSON.stringify(data, null, 2));
|
|
8
|
+
const notFound = (message) => ({
|
|
9
|
+
isError: true,
|
|
10
|
+
content: [{ type: 'text', text: message }],
|
|
11
|
+
});
|
|
12
|
+
async function buildCatalog(options) {
|
|
13
|
+
const innerServer = new McpServer({ name: 'sendinel-gateway-inner', version: '1.0.0' });
|
|
14
|
+
registerAllTools(innerServer, { scopes: options.scopes, toolGroups: options.toolGroups });
|
|
15
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
16
|
+
const client = new Client({ name: 'sendinel-gateway', version: '1.0.0' });
|
|
17
|
+
await innerServer.connect(serverTransport);
|
|
18
|
+
await client.connect(clientTransport);
|
|
19
|
+
const toolGroupMap = getRegisteredToolGroups({ scopes: options.scopes, toolGroups: options.toolGroups });
|
|
20
|
+
const { tools } = await client.listTools();
|
|
21
|
+
const entries = tools
|
|
22
|
+
.map((tool) => ({
|
|
23
|
+
name: tool.name,
|
|
24
|
+
group: toolGroupMap[tool.name] ?? 'unknown',
|
|
25
|
+
description: tool.description ?? '',
|
|
26
|
+
inputSchema: tool.inputSchema,
|
|
27
|
+
}))
|
|
28
|
+
.sort((a, b) => {
|
|
29
|
+
const groupCompare = a.group.localeCompare(b.group);
|
|
30
|
+
return groupCompare !== 0 ? groupCompare : a.name.localeCompare(b.name);
|
|
31
|
+
});
|
|
32
|
+
return { client, entries, byName: new Map(entries.map((entry) => [entry.name, entry])) };
|
|
33
|
+
}
|
|
34
|
+
export function registerGatewayTools(server, options) {
|
|
35
|
+
let catalogPromise = null;
|
|
36
|
+
const getCatalog = () => (catalogPromise ??= buildCatalog(options));
|
|
37
|
+
server.registerTool('search_tools', {
|
|
38
|
+
title: 'Search Tools',
|
|
39
|
+
description: 'Search the permitted Sendinel MCP tool catalog by keyword and/or group. Returns name, group, and description. Call describe_tool for the full input schema before invoke_tool.',
|
|
40
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
41
|
+
inputSchema: {
|
|
42
|
+
query: z.string().optional().describe('Keyword to match against tool name, group, or description'),
|
|
43
|
+
group: z.string().optional().describe('Restrict results to one tool group, for example campaigns'),
|
|
44
|
+
},
|
|
45
|
+
}, async ({ query, group }) => {
|
|
46
|
+
const catalog = await getCatalog();
|
|
47
|
+
const q = query?.trim().toLowerCase() ?? '';
|
|
48
|
+
const requestedGroup = group?.trim();
|
|
49
|
+
const results = catalog.entries.filter((tool) => {
|
|
50
|
+
if (requestedGroup && tool.group !== requestedGroup)
|
|
51
|
+
return false;
|
|
52
|
+
if (!q)
|
|
53
|
+
return true;
|
|
54
|
+
return (tool.name.toLowerCase().includes(q) ||
|
|
55
|
+
tool.group.toLowerCase().includes(q) ||
|
|
56
|
+
tool.description.toLowerCase().includes(q));
|
|
57
|
+
});
|
|
58
|
+
return jsonResult({
|
|
59
|
+
tools: results.map(({ name, group, description }) => ({ name, group, description })),
|
|
60
|
+
total: results.length,
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
server.registerTool('describe_tool', {
|
|
64
|
+
title: 'Describe Tool',
|
|
65
|
+
description: 'Return one permitted Sendinel MCP tool input schema by name. Call this before invoke_tool so arguments match the real signature.',
|
|
66
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
67
|
+
inputSchema: {
|
|
68
|
+
name: z.string().min(1).describe('Tool name to describe'),
|
|
69
|
+
},
|
|
70
|
+
}, async ({ name }) => {
|
|
71
|
+
const catalog = await getCatalog();
|
|
72
|
+
const entry = catalog.byName.get(name);
|
|
73
|
+
if (!entry)
|
|
74
|
+
return notFound(`Tool not found: ${name}`);
|
|
75
|
+
return jsonResult(entry);
|
|
76
|
+
});
|
|
77
|
+
server.registerTool('invoke_tool', {
|
|
78
|
+
title: 'Invoke Tool',
|
|
79
|
+
description: 'Execute one permitted Sendinel MCP tool by name with the same scope and approval enforcement as calling it directly. Use search_tools and describe_tool first.',
|
|
80
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
81
|
+
inputSchema: {
|
|
82
|
+
name: z.string().min(1).describe('Tool name to invoke'),
|
|
83
|
+
args: z.record(z.string(), z.unknown()).optional().default({}).describe('Arguments for the target tool'),
|
|
84
|
+
},
|
|
85
|
+
}, async ({ name, args }) => {
|
|
86
|
+
const catalog = await getCatalog();
|
|
87
|
+
if (!catalog.byName.has(name)) {
|
|
88
|
+
return notFound(`Tool not found or not permitted: ${name}`);
|
|
89
|
+
}
|
|
90
|
+
return await catalog.client.callTool({ name, arguments: args ?? {} });
|
|
91
|
+
});
|
|
92
|
+
}
|
package/build/tools/gdpr.js
CHANGED
|
@@ -77,4 +77,189 @@ export function registerGdprTools(server) {
|
|
|
77
77
|
return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
|
|
78
78
|
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
79
79
|
});
|
|
80
|
+
server.registerTool('list_ccpa_requests', {
|
|
81
|
+
title: 'List CCPA Requests',
|
|
82
|
+
annotations: {
|
|
83
|
+
readOnlyHint: true,
|
|
84
|
+
destructiveHint: false,
|
|
85
|
+
idempotentHint: true,
|
|
86
|
+
openWorldHint: false,
|
|
87
|
+
},
|
|
88
|
+
description: 'List CCPA (know/delete/opt_out) requests in this project\'s compliance queue. Excludes full data-export and details payloads; use the dashboard to inspect exported request data.',
|
|
89
|
+
inputSchema: {
|
|
90
|
+
status: z.enum(['pending', 'verified', 'completed', 'expired']).optional(),
|
|
91
|
+
request_type: z.enum(['know', 'delete', 'opt_out']).optional(),
|
|
92
|
+
search: z.string().optional().describe('Filter by email (partial match)'),
|
|
93
|
+
limit: z.number().int().min(1).max(200).optional().default(50),
|
|
94
|
+
},
|
|
95
|
+
}, async ({ status, request_type, search, limit }) => {
|
|
96
|
+
let query = db()
|
|
97
|
+
.from('compliance_requests')
|
|
98
|
+
.select('id, email, request_type, status, verified_at, processed_at, completed_at, expires_at, created_at')
|
|
99
|
+
.eq('project_id', getProjectId())
|
|
100
|
+
.eq('regulation', 'ccpa');
|
|
101
|
+
if (status)
|
|
102
|
+
query = query.eq('status', status);
|
|
103
|
+
if (request_type)
|
|
104
|
+
query = query.eq('request_type', request_type);
|
|
105
|
+
if (search)
|
|
106
|
+
query = query.ilike('email', `%${search}%`);
|
|
107
|
+
const { data, error } = await query
|
|
108
|
+
.order('created_at', { ascending: false })
|
|
109
|
+
.limit(limit ?? 50);
|
|
110
|
+
if (error)
|
|
111
|
+
return txt(`Error: ${error.message}`);
|
|
112
|
+
return j({ requests: data ?? [], count: data?.length ?? 0 });
|
|
113
|
+
});
|
|
114
|
+
server.registerTool('action_ccpa_request', {
|
|
115
|
+
title: 'Action CCPA Request',
|
|
116
|
+
annotations: {
|
|
117
|
+
readOnlyHint: false,
|
|
118
|
+
destructiveHint: true,
|
|
119
|
+
idempotentHint: false,
|
|
120
|
+
openWorldHint: false,
|
|
121
|
+
},
|
|
122
|
+
description: 'Move a CCPA request through pending -> verified -> completed. Verification is allowed only while a pending request is unexpired. Completing a delete request permanently erases all subscriber data for its email address and requires protected human approval; call once without approval_id to see exactly what will be deleted, then call again after approval.',
|
|
123
|
+
inputSchema: {
|
|
124
|
+
request_id: z.string().uuid(),
|
|
125
|
+
status: z.enum(['verified', 'completed']),
|
|
126
|
+
approval_id: z.string().uuid().optional().describe('Required to complete a delete request after a human approves the summary in the dashboard.'),
|
|
127
|
+
},
|
|
128
|
+
}, async ({ request_id, status, approval_id }) => {
|
|
129
|
+
const projectId = getProjectId();
|
|
130
|
+
const { data: request, error: fetchErr } = await db()
|
|
131
|
+
.from('compliance_requests')
|
|
132
|
+
.select('id, email, request_type, status, expires_at')
|
|
133
|
+
.eq('id', request_id)
|
|
134
|
+
.eq('project_id', projectId)
|
|
135
|
+
.eq('regulation', 'ccpa')
|
|
136
|
+
.maybeSingle();
|
|
137
|
+
if (fetchErr)
|
|
138
|
+
return txt(`Error: ${fetchErr.message}`);
|
|
139
|
+
if (!request)
|
|
140
|
+
return txt('CCPA request not found or does not belong to this project.');
|
|
141
|
+
if (request.status === 'completed' || request.status === 'expired') {
|
|
142
|
+
return txt(`Request is already "${request.status}" and cannot be actioned further.`);
|
|
143
|
+
}
|
|
144
|
+
if (status === 'verified') {
|
|
145
|
+
if (request.status !== 'pending') {
|
|
146
|
+
return txt(`Request must be "pending" to verify (currently "${request.status}").`);
|
|
147
|
+
}
|
|
148
|
+
const nowIso = new Date().toISOString();
|
|
149
|
+
if (!request.expires_at || new Date(request.expires_at) <= new Date(nowIso)) {
|
|
150
|
+
return txt('Request has expired and can no longer be verified.');
|
|
151
|
+
}
|
|
152
|
+
const { data, error } = await db()
|
|
153
|
+
.from('compliance_requests')
|
|
154
|
+
.update({ status: 'verified', verified_at: nowIso })
|
|
155
|
+
.eq('id', request_id)
|
|
156
|
+
.eq('project_id', projectId)
|
|
157
|
+
.eq('regulation', 'ccpa')
|
|
158
|
+
.eq('status', 'pending')
|
|
159
|
+
.gt('expires_at', nowIso)
|
|
160
|
+
.select('id, email, status, verified_at')
|
|
161
|
+
.maybeSingle();
|
|
162
|
+
if (error)
|
|
163
|
+
return txt(`Error: ${error.message}`);
|
|
164
|
+
if (!data)
|
|
165
|
+
return txt('Request changed or expired before it could be verified. Refresh the queue and try again.');
|
|
166
|
+
void logAudit({
|
|
167
|
+
action: 'ccpa.request_verified',
|
|
168
|
+
resourceType: 'compliance_request',
|
|
169
|
+
resourceId: request_id,
|
|
170
|
+
details: { email: request.email },
|
|
171
|
+
});
|
|
172
|
+
return j({ ok: true, request: data });
|
|
173
|
+
}
|
|
174
|
+
if (request.status !== 'verified') {
|
|
175
|
+
return txt(`Request must be "verified" before it can be completed (currently "${request.status}"). Call this tool with status: "verified" first.`);
|
|
176
|
+
}
|
|
177
|
+
if (request.request_type === 'delete') {
|
|
178
|
+
const approvalParams = {
|
|
179
|
+
request_id,
|
|
180
|
+
status: 'completed',
|
|
181
|
+
request_type: request.request_type,
|
|
182
|
+
email: request.email,
|
|
183
|
+
};
|
|
184
|
+
if (!approval_id) {
|
|
185
|
+
const channelContext = currentChannelContext();
|
|
186
|
+
const decision = assertChannelCanExecute('action_ccpa_request', approvalParams, channelContext);
|
|
187
|
+
if (!decision.allowed)
|
|
188
|
+
return txt(decision.reason ?? 'This channel cannot request this action.');
|
|
189
|
+
return j(await requestApproval('action_ccpa_request', approvalParams, undefined, channelContext));
|
|
190
|
+
}
|
|
191
|
+
const check = await checkApproval(approval_id, {
|
|
192
|
+
consume: true,
|
|
193
|
+
action: 'action_ccpa_request',
|
|
194
|
+
params: approvalParams,
|
|
195
|
+
});
|
|
196
|
+
if (!check.approved) {
|
|
197
|
+
return txt(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
|
|
198
|
+
}
|
|
199
|
+
const { data: contact, error: contactErr } = await db()
|
|
200
|
+
.from('subscribers')
|
|
201
|
+
.select('id, email')
|
|
202
|
+
.eq('project_id', projectId)
|
|
203
|
+
.eq('email', request.email)
|
|
204
|
+
.maybeSingle();
|
|
205
|
+
if (contactErr)
|
|
206
|
+
return txt(`Error: ${contactErr.message}`);
|
|
207
|
+
if (contact) {
|
|
208
|
+
const { error: deleteErr } = await db().rpc('delete_contact_gdpr', {
|
|
209
|
+
p_contact_id: contact.id,
|
|
210
|
+
p_reason: 'ccpa_delete_request',
|
|
211
|
+
p_deleted_by: 'mcp_ccpa_action',
|
|
212
|
+
});
|
|
213
|
+
if (deleteErr)
|
|
214
|
+
return txt(`Deletion failed: ${deleteErr.message}`);
|
|
215
|
+
}
|
|
216
|
+
const completedAt = new Date().toISOString();
|
|
217
|
+
const { data, error } = await db()
|
|
218
|
+
.from('compliance_requests')
|
|
219
|
+
.update({
|
|
220
|
+
status: 'completed',
|
|
221
|
+
processed_at: completedAt,
|
|
222
|
+
completed_at: completedAt,
|
|
223
|
+
...(contact ? {} : { data_export: { message: 'No data found for this email address.' } }),
|
|
224
|
+
})
|
|
225
|
+
.eq('id', request_id)
|
|
226
|
+
.eq('project_id', projectId)
|
|
227
|
+
.eq('regulation', 'ccpa')
|
|
228
|
+
.eq('status', 'verified')
|
|
229
|
+
.select('id, email, status, completed_at')
|
|
230
|
+
.maybeSingle();
|
|
231
|
+
if (error)
|
|
232
|
+
return txt(`Error: ${error.message}`);
|
|
233
|
+
if (!data)
|
|
234
|
+
return txt('Request changed before it could be completed. Refresh the queue before retrying.');
|
|
235
|
+
void logAudit({
|
|
236
|
+
action: 'ccpa.delete_completed',
|
|
237
|
+
resourceType: 'contact',
|
|
238
|
+
resourceId: contact?.id ?? request_id,
|
|
239
|
+
details: { email: request.email, request_id },
|
|
240
|
+
});
|
|
241
|
+
return j({ ok: true, request: data, subscriber_deleted: Boolean(contact) });
|
|
242
|
+
}
|
|
243
|
+
const completedAt = new Date().toISOString();
|
|
244
|
+
const { data, error } = await db()
|
|
245
|
+
.from('compliance_requests')
|
|
246
|
+
.update({ status: 'completed', processed_at: completedAt, completed_at: completedAt })
|
|
247
|
+
.eq('id', request_id)
|
|
248
|
+
.eq('project_id', projectId)
|
|
249
|
+
.eq('regulation', 'ccpa')
|
|
250
|
+
.eq('status', 'verified')
|
|
251
|
+
.select('id, email, status, completed_at')
|
|
252
|
+
.maybeSingle();
|
|
253
|
+
if (error)
|
|
254
|
+
return txt(`Error: ${error.message}`);
|
|
255
|
+
if (!data)
|
|
256
|
+
return txt('Request changed before it could be completed. Refresh the queue before retrying.');
|
|
257
|
+
void logAudit({
|
|
258
|
+
action: 'ccpa.request_completed',
|
|
259
|
+
resourceType: 'compliance_request',
|
|
260
|
+
resourceId: request_id,
|
|
261
|
+
details: { email: request.email, request_type: request.request_type },
|
|
262
|
+
});
|
|
263
|
+
return j({ ok: true, request: data });
|
|
264
|
+
});
|
|
80
265
|
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { db } from '../db.js';
|
|
3
|
+
import { getProjectId } from '../project.js';
|
|
4
|
+
import { logAudit } from '../audit.js';
|
|
5
|
+
const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
|
|
6
|
+
const SLUG_RE = /^[a-z0-9][a-z0-9\-]{0,62}$/;
|
|
7
|
+
const VALID_STATUSES = ['draft', 'published', 'archived'];
|
|
8
|
+
const LIST_COLUMNS = 'id, title, slug, site_id, status, form_id, blocks, meta_title, meta_description, conversion_event, views, conversions, background_color, accent_color, custom_css, created_at, updated_at, published_at';
|
|
9
|
+
function slugify(text) {
|
|
10
|
+
return text
|
|
11
|
+
.toLowerCase()
|
|
12
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
13
|
+
.replace(/^-+|-+$/g, '')
|
|
14
|
+
.slice(0, 63);
|
|
15
|
+
}
|
|
16
|
+
export function registerLandingPageTools(server) {
|
|
17
|
+
server.registerTool('list_landing_pages', {
|
|
18
|
+
title: 'List Landing Pages',
|
|
19
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
20
|
+
description: 'List project-scoped landing pages, optionally filtered by site.',
|
|
21
|
+
inputSchema: { site_id: z.string().uuid().optional() },
|
|
22
|
+
}, async ({ site_id }) => {
|
|
23
|
+
let query = db()
|
|
24
|
+
.from('landing_pages')
|
|
25
|
+
.select(LIST_COLUMNS)
|
|
26
|
+
.eq('project_id', getProjectId());
|
|
27
|
+
if (site_id)
|
|
28
|
+
query = query.eq('site_id', site_id);
|
|
29
|
+
const { data, error } = await query.order('created_at', { ascending: false });
|
|
30
|
+
if (error)
|
|
31
|
+
return j({ error: error.message });
|
|
32
|
+
return j({ pages: data ?? [] });
|
|
33
|
+
});
|
|
34
|
+
server.registerTool('get_landing_page', {
|
|
35
|
+
title: 'Get Landing Page',
|
|
36
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
37
|
+
description: 'Get one project-scoped landing page, including its full block content.',
|
|
38
|
+
inputSchema: { page_id: z.string().uuid() },
|
|
39
|
+
}, async ({ page_id }) => {
|
|
40
|
+
const { data, error } = await db()
|
|
41
|
+
.from('landing_pages')
|
|
42
|
+
.select('*')
|
|
43
|
+
.eq('project_id', getProjectId())
|
|
44
|
+
.eq('id', page_id)
|
|
45
|
+
.maybeSingle();
|
|
46
|
+
if (error)
|
|
47
|
+
return j({ error: error.message });
|
|
48
|
+
if (!data)
|
|
49
|
+
return j({ error: 'Landing page not found', code: 'NOT_FOUND' });
|
|
50
|
+
return j(data);
|
|
51
|
+
});
|
|
52
|
+
server.registerTool('create_landing_page', {
|
|
53
|
+
title: 'Create Landing Page',
|
|
54
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
55
|
+
description: 'Create a draft landing page. Publish it with update_landing_page by setting status to published.',
|
|
56
|
+
inputSchema: {
|
|
57
|
+
title: z.string().trim().min(1).max(200),
|
|
58
|
+
slug: z.string().trim().regex(SLUG_RE, 'Lowercase alphanumeric + hyphens, max 63 chars').optional(),
|
|
59
|
+
site_id: z.string().uuid().optional(),
|
|
60
|
+
form_id: z.string().uuid().optional(),
|
|
61
|
+
blocks: z.array(z.record(z.string(), z.unknown())).optional().describe('Ordered page content blocks'),
|
|
62
|
+
meta_title: z.string().optional(),
|
|
63
|
+
meta_description: z.string().optional(),
|
|
64
|
+
conversion_event: z.string().optional(),
|
|
65
|
+
background_color: z.string().optional(),
|
|
66
|
+
accent_color: z.string().optional(),
|
|
67
|
+
custom_css: z.string().optional(),
|
|
68
|
+
},
|
|
69
|
+
}, async ({ title, slug, site_id, form_id, blocks, meta_title, meta_description, conversion_event, background_color, accent_color, custom_css }) => {
|
|
70
|
+
const projectId = getProjectId();
|
|
71
|
+
const normalizedTitle = title.trim();
|
|
72
|
+
const normalizedSlug = (slug ?? slugify(normalizedTitle)).trim();
|
|
73
|
+
if (!SLUG_RE.test(normalizedSlug))
|
|
74
|
+
return j({ error: 'Invalid slug (lowercase alphanumeric + hyphens, max 63 chars)' });
|
|
75
|
+
const insert = {
|
|
76
|
+
project_id: projectId,
|
|
77
|
+
title: normalizedTitle,
|
|
78
|
+
slug: normalizedSlug,
|
|
79
|
+
};
|
|
80
|
+
if (site_id)
|
|
81
|
+
insert.site_id = site_id;
|
|
82
|
+
if (form_id)
|
|
83
|
+
insert.form_id = form_id;
|
|
84
|
+
if (blocks)
|
|
85
|
+
insert.blocks = blocks;
|
|
86
|
+
if (meta_title)
|
|
87
|
+
insert.meta_title = meta_title;
|
|
88
|
+
if (meta_description)
|
|
89
|
+
insert.meta_description = meta_description;
|
|
90
|
+
if (conversion_event)
|
|
91
|
+
insert.conversion_event = conversion_event;
|
|
92
|
+
if (background_color)
|
|
93
|
+
insert.background_color = background_color;
|
|
94
|
+
if (accent_color)
|
|
95
|
+
insert.accent_color = accent_color;
|
|
96
|
+
if (custom_css)
|
|
97
|
+
insert.custom_css = custom_css;
|
|
98
|
+
const { data, error } = await db()
|
|
99
|
+
.from('landing_pages')
|
|
100
|
+
.insert(insert)
|
|
101
|
+
.select()
|
|
102
|
+
.single();
|
|
103
|
+
if (error) {
|
|
104
|
+
if (error.code === '23505')
|
|
105
|
+
return j({ error: 'A page with this slug already exists' });
|
|
106
|
+
return j({ error: error.message });
|
|
107
|
+
}
|
|
108
|
+
await logAudit({
|
|
109
|
+
action: 'landing_page.created',
|
|
110
|
+
resourceType: 'landing_page',
|
|
111
|
+
resourceId: data.id,
|
|
112
|
+
details: { title: data.title, slug: data.slug, status: data.status },
|
|
113
|
+
});
|
|
114
|
+
return j(data);
|
|
115
|
+
});
|
|
116
|
+
server.registerTool('update_landing_page', {
|
|
117
|
+
title: 'Update Landing Page',
|
|
118
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
119
|
+
description: 'Update a landing page. Setting status to published stamps published_at; pass site_id or form_id as null to clear it.',
|
|
120
|
+
inputSchema: {
|
|
121
|
+
page_id: z.string().uuid(),
|
|
122
|
+
title: z.string().trim().min(1).max(200).optional(),
|
|
123
|
+
slug: z.string().trim().regex(SLUG_RE, 'Lowercase alphanumeric + hyphens, max 63 chars').optional(),
|
|
124
|
+
status: z.enum(VALID_STATUSES).optional(),
|
|
125
|
+
site_id: z.string().uuid().nullable().optional(),
|
|
126
|
+
form_id: z.string().uuid().nullable().optional(),
|
|
127
|
+
blocks: z.array(z.record(z.string(), z.unknown())).optional(),
|
|
128
|
+
meta_title: z.string().optional(),
|
|
129
|
+
meta_description: z.string().optional(),
|
|
130
|
+
conversion_event: z.string().optional(),
|
|
131
|
+
background_color: z.string().optional(),
|
|
132
|
+
accent_color: z.string().optional(),
|
|
133
|
+
custom_css: z.string().optional(),
|
|
134
|
+
},
|
|
135
|
+
}, async ({ page_id, title, slug, status, site_id, form_id, blocks, meta_title, meta_description, conversion_event, background_color, accent_color, custom_css }) => {
|
|
136
|
+
const projectId = getProjectId();
|
|
137
|
+
const updates = { updated_at: new Date().toISOString() };
|
|
138
|
+
if (title !== undefined)
|
|
139
|
+
updates.title = title.trim();
|
|
140
|
+
if (slug !== undefined)
|
|
141
|
+
updates.slug = slug.trim();
|
|
142
|
+
if (status !== undefined) {
|
|
143
|
+
updates.status = status;
|
|
144
|
+
if (status === 'published')
|
|
145
|
+
updates.published_at = new Date().toISOString();
|
|
146
|
+
}
|
|
147
|
+
if (site_id !== undefined)
|
|
148
|
+
updates.site_id = site_id;
|
|
149
|
+
if (form_id !== undefined)
|
|
150
|
+
updates.form_id = form_id;
|
|
151
|
+
if (blocks !== undefined)
|
|
152
|
+
updates.blocks = blocks;
|
|
153
|
+
if (meta_title !== undefined)
|
|
154
|
+
updates.meta_title = meta_title;
|
|
155
|
+
if (meta_description !== undefined)
|
|
156
|
+
updates.meta_description = meta_description;
|
|
157
|
+
if (conversion_event !== undefined)
|
|
158
|
+
updates.conversion_event = conversion_event;
|
|
159
|
+
if (background_color !== undefined)
|
|
160
|
+
updates.background_color = background_color;
|
|
161
|
+
if (accent_color !== undefined)
|
|
162
|
+
updates.accent_color = accent_color;
|
|
163
|
+
if (custom_css !== undefined)
|
|
164
|
+
updates.custom_css = custom_css;
|
|
165
|
+
const { data, error } = await db()
|
|
166
|
+
.from('landing_pages')
|
|
167
|
+
.update(updates)
|
|
168
|
+
.eq('id', page_id)
|
|
169
|
+
.eq('project_id', projectId)
|
|
170
|
+
.select()
|
|
171
|
+
.single();
|
|
172
|
+
if (error) {
|
|
173
|
+
if (error.code === '23505')
|
|
174
|
+
return j({ error: 'A page with this slug already exists' });
|
|
175
|
+
return j({ error: error.message });
|
|
176
|
+
}
|
|
177
|
+
await logAudit({
|
|
178
|
+
action: 'landing_page.updated',
|
|
179
|
+
resourceType: 'landing_page',
|
|
180
|
+
resourceId: data.id,
|
|
181
|
+
details: { title: data.title, slug: data.slug, status: data.status },
|
|
182
|
+
});
|
|
183
|
+
return j(data);
|
|
184
|
+
});
|
|
185
|
+
server.registerTool('delete_landing_page', {
|
|
186
|
+
title: 'Delete Landing Page',
|
|
187
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
188
|
+
description: 'Permanently delete a landing page. Set status to archived instead to unpublish without deleting.',
|
|
189
|
+
inputSchema: { page_id: z.string().uuid() },
|
|
190
|
+
}, async ({ page_id }) => {
|
|
191
|
+
const projectId = getProjectId();
|
|
192
|
+
const { error } = await db()
|
|
193
|
+
.from('landing_pages')
|
|
194
|
+
.delete()
|
|
195
|
+
.eq('id', page_id)
|
|
196
|
+
.eq('project_id', projectId);
|
|
197
|
+
if (error)
|
|
198
|
+
return j({ error: error.message });
|
|
199
|
+
await logAudit({ action: 'landing_page.deleted', resourceType: 'landing_page', resourceId: page_id });
|
|
200
|
+
return j({ deleted: true });
|
|
201
|
+
});
|
|
202
|
+
}
|