@sendinel/mcp-server 1.0.3 → 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 +10 -4
@@ -0,0 +1,277 @@
1
+ import { z } from 'zod';
2
+ import { adminDb } from '../db.js';
3
+ import { getProjectId } from '../project.js';
4
+ import { downloadPublicAsset } from '../lib/ssrf-pinned-fetch.js';
5
+ import { BRAND_ASSET_KINDS, isValidKind, persistBrandAsset, resolveKind, resolveMime, } from '../lib/brand-asset-persist.js';
6
+ const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
7
+ function brandKitApiBase() {
8
+ return (process.env.NEXT_PUBLIC_APP_URL ?? process.env.SENDINEL_APP_URL ?? process.env.APP_URL ?? 'https://sendinel.ai').replace(/\/$/, '');
9
+ }
10
+ // import_brand_kit_text parses free-form Brand Core/Brand Brain/Content Library
11
+ // markdown into voice fields, colors, templates, and social posts — a different,
12
+ // more complex capability than the media-library asset tools below. Left on the
13
+ // HTTP-proxy path (still requires SENDINEL_API_KEY) rather than re-implemented
14
+ // here; see #2356 for the media-library-scoped fix this file otherwise contains.
15
+ async function callBrandKitApi(path, body) {
16
+ const apiKey = process.env.SENDINEL_API_KEY;
17
+ if (!apiKey)
18
+ return { ok: false, error: 'SENDINEL_API_KEY is required to drive this brand_kit tool from MCP' };
19
+ const res = await fetch(`${brandKitApiBase()}${path}`, {
20
+ method: 'POST',
21
+ headers: {
22
+ Authorization: `Bearer ${apiKey}`,
23
+ 'X-Sendinel-Project-Id': getProjectId(),
24
+ 'Content-Type': 'application/json',
25
+ },
26
+ body: JSON.stringify(body),
27
+ });
28
+ const text = await res.text();
29
+ let parsed = text;
30
+ try {
31
+ parsed = JSON.parse(text);
32
+ }
33
+ catch { }
34
+ if (!res.ok)
35
+ return { ok: false, error: parsed || `HTTP ${res.status}` };
36
+ return { ok: true, data: parsed };
37
+ }
38
+ async function getProjectOrgId(projectId) {
39
+ const { data, error } = await adminDb()
40
+ .schema('email')
41
+ .from('projects')
42
+ .select('org_id')
43
+ .eq('id', projectId)
44
+ .maybeSingle();
45
+ if (error)
46
+ throw new Error(error.message);
47
+ if (!data?.org_id)
48
+ throw new Error('Project not found');
49
+ return data.org_id;
50
+ }
51
+ const kindSchema = z.enum(BRAND_ASSET_KINDS);
52
+ export function registerBrandKitTools(server) {
53
+ server.registerTool('import_brand_kit_text', {
54
+ title: 'Import Brand Kit Text',
55
+ annotations: {
56
+ readOnlyHint: false,
57
+ destructiveHint: false,
58
+ idempotentHint: false,
59
+ openWorldHint: false,
60
+ },
61
+ description: 'Import raw Brand Core / Brand Brain / Content Library / Brand Kit markdown text into the current project.',
62
+ inputSchema: {
63
+ text: z.string().min(1).describe('Raw markdown text from the Brand Core / Brand Kit companion output.'),
64
+ },
65
+ }, async ({ text }) => {
66
+ const result = await callBrandKitApi('/api/v1/brand-kit/import-text', { text });
67
+ return result.ok ? j(result.data) : j({ error: result.error });
68
+ });
69
+ server.registerTool('list_brand_assets', {
70
+ title: 'List Brand Assets',
71
+ annotations: {
72
+ readOnlyHint: true,
73
+ destructiveHint: false,
74
+ idempotentHint: true,
75
+ openWorldHint: false,
76
+ },
77
+ description: 'List the current project\'s confirmed brand assets (logos, images, avatars, documents, fonts, videos) from the media library, with download URLs. Call this before recommending or generating content so suggestions can reference real uploaded assets instead of guessing what exists.',
78
+ inputSchema: {
79
+ kind: kindSchema.optional().describe('Filter to one asset kind'),
80
+ collection: z.string().optional().describe('Filter to one collection name'),
81
+ tag: z.string().optional().describe('Filter to assets carrying this tag'),
82
+ limit: z.number().int().min(1).max(100).optional().describe('Max assets to return (default 50, max 100)'),
83
+ },
84
+ }, async ({ kind, collection, tag, limit }) => {
85
+ const projectId = getProjectId();
86
+ const sb = adminDb();
87
+ const cappedLimit = Math.min(100, Math.max(1, limit ?? 50));
88
+ const { data, error } = await sb
89
+ .schema('email')
90
+ .from('brand_media_assets')
91
+ .select('id, kind, filename, storage_path, mime_type, size_bytes, collection, tags, last_referenced_at, last_referenced_source, last_referenced_resource_id, reference_count, created_at, storage_backend')
92
+ .eq('project_id', projectId)
93
+ .eq('review_status', 'confirmed')
94
+ .order('created_at', { ascending: false });
95
+ if (error)
96
+ return j({ error: error.message });
97
+ // Project-wide across ALL brand kits — deliberately not scoped to a single
98
+ // kit_id, so projects with more than one kit aren't silently truncated.
99
+ let rows = (data ?? []);
100
+ if (kind)
101
+ rows = rows.filter((r) => r.kind === kind);
102
+ if (collection)
103
+ rows = rows.filter((r) => r.collection === collection);
104
+ if (tag)
105
+ rows = rows.filter((r) => Array.isArray(r.tags) && r.tags.includes(tag));
106
+ const countsByKind = {};
107
+ for (const r of data ?? []) {
108
+ const k = String(r.kind);
109
+ countsByKind[k] = (countsByKind[k] ?? 0) + 1;
110
+ }
111
+ const totalMatching = rows.length;
112
+ const page = rows.slice(0, cappedLimit);
113
+ const paths = page.filter((r) => r.storage_backend !== 'r2').map((r) => r.storage_path);
114
+ const signedByPath = new Map();
115
+ if (paths.length > 0) {
116
+ const { data: signed } = await sb.storage.from('brand-assets').createSignedUrls(paths, 3600, { download: true });
117
+ for (const entry of signed ?? []) {
118
+ if (entry.path && entry.signedUrl)
119
+ signedByPath.set(entry.path, entry.signedUrl);
120
+ }
121
+ }
122
+ return j({
123
+ assets: page.map((r) => ({
124
+ id: r.id,
125
+ kind: r.kind,
126
+ filename: r.filename,
127
+ collection: r.collection,
128
+ tags: r.tags ?? [],
129
+ mime_type: r.mime_type,
130
+ size_bytes: r.size_bytes,
131
+ created_at: r.created_at,
132
+ last_referenced_at: r.last_referenced_at,
133
+ last_referenced_source: r.last_referenced_source,
134
+ last_referenced_resource_id: r.last_referenced_resource_id,
135
+ reference_count: r.reference_count ?? 0,
136
+ unused: !r.last_referenced_at && !(r.reference_count ?? 0),
137
+ url: signedByPath.get(r.storage_path) ?? null,
138
+ })),
139
+ total_confirmed_assets: (data ?? []).length,
140
+ total_matching_filters: totalMatching,
141
+ returned: page.length,
142
+ truncated: totalMatching > page.length,
143
+ counts_by_kind: countsByKind,
144
+ });
145
+ });
146
+ server.registerTool('upload_brand_asset', {
147
+ title: 'Upload Brand Asset',
148
+ annotations: {
149
+ readOnlyHint: false,
150
+ destructiveHint: false,
151
+ idempotentHint: false,
152
+ openWorldHint: true,
153
+ },
154
+ description: 'Upload a file to the current project\'s media library directly (base64-encoded bytes). For assets already hosted at a public HTTPS URL, use ingest_brand_assets_by_url instead. Video uploads are not supported via MCP yet.',
155
+ inputSchema: {
156
+ filename: z.string().min(1).max(255).describe('Original filename, including extension (e.g. logo.png)'),
157
+ content_base64: z.string().min(1).describe('Base64-encoded file bytes (no data: prefix)'),
158
+ kind: kindSchema.optional().describe('Asset kind. Inferred from filename/mime if omitted.'),
159
+ collection: z.string().max(120).optional(),
160
+ tags: z.array(z.string()).max(50).optional(),
161
+ brand_kit_id: z.string().uuid().optional(),
162
+ },
163
+ }, async ({ filename, content_base64, kind, collection, tags, brand_kit_id }) => {
164
+ const projectId = getProjectId();
165
+ let bytes;
166
+ try {
167
+ bytes = Buffer.from(content_base64, 'base64');
168
+ }
169
+ catch {
170
+ return j({ error: 'content_base64 is not valid base64' });
171
+ }
172
+ if (bytes.byteLength === 0)
173
+ return j({ error: 'Decoded file is empty' });
174
+ try {
175
+ const orgId = await getProjectOrgId(projectId);
176
+ if (brand_kit_id) {
177
+ const { data: kitRow } = await adminDb().schema('email').from('brand_kits')
178
+ .select('id').eq('id', brand_kit_id).eq('project_id', projectId).maybeSingle();
179
+ if (!kitRow)
180
+ return j({ error: 'brand_kit_id not found in this project' });
181
+ }
182
+ const mimeType = resolveMime(filename);
183
+ const resolvedKind = resolveKind(kind, filename, mimeType);
184
+ const persisted = await persistBrandAsset({
185
+ sb: adminDb(),
186
+ orgId,
187
+ projectId,
188
+ bytes,
189
+ filename,
190
+ mimeType,
191
+ kind: resolvedKind,
192
+ source: 'uploaded',
193
+ collection: collection ?? null,
194
+ tags: tags ?? [],
195
+ brandKitId: brand_kit_id ?? null,
196
+ });
197
+ return j({
198
+ ok: true,
199
+ asset_id: persisted.assetId,
200
+ filename,
201
+ kind: resolvedKind,
202
+ mime_type: mimeType,
203
+ skipped_existing: persisted.skippedExisting,
204
+ });
205
+ }
206
+ catch (error) {
207
+ return j({ error: error instanceof Error ? error.message : 'Upload failed' });
208
+ }
209
+ });
210
+ server.registerTool('ingest_brand_assets_by_url', {
211
+ title: 'Ingest Brand Assets By URL',
212
+ annotations: {
213
+ readOnlyHint: false,
214
+ destructiveHint: false,
215
+ idempotentHint: false,
216
+ openWorldHint: true,
217
+ },
218
+ description: 'Ingest public HTTPS brand asset URLs into the current project media library. Raw bytes are not accepted here — use upload_brand_asset for that.',
219
+ inputSchema: {
220
+ assets: z.array(z.object({
221
+ url: z.string().url().describe('Public HTTPS asset URL. Private/internal addresses and redirects are rejected.'),
222
+ kind: kindSchema.optional(),
223
+ filename: z.string().optional(),
224
+ })).min(1).max(25),
225
+ collection: z.string().optional(),
226
+ tags: z.array(z.string()).optional(),
227
+ brand_kit_id: z.string().uuid().optional(),
228
+ },
229
+ }, async ({ assets, collection, tags, brand_kit_id }) => {
230
+ const projectId = getProjectId();
231
+ let orgId;
232
+ try {
233
+ orgId = await getProjectOrgId(projectId);
234
+ if (brand_kit_id) {
235
+ const { data: kitRow } = await adminDb().schema('email').from('brand_kits')
236
+ .select('id').eq('id', brand_kit_id).eq('project_id', projectId).maybeSingle();
237
+ if (!kitRow)
238
+ return j({ error: 'brand_kit_id not found in this project' });
239
+ }
240
+ }
241
+ catch (error) {
242
+ return j({ error: error instanceof Error ? error.message : 'Project lookup failed' });
243
+ }
244
+ const results = [];
245
+ for (const asset of assets) {
246
+ try {
247
+ const downloaded = await downloadPublicAsset(asset.url);
248
+ const filename = (asset.filename?.trim() || decodeURIComponent(downloaded.url.pathname).split('/').pop() || 'asset')
249
+ .replace(/[^\w.\- ()]/g, '_').slice(0, 255) || 'asset';
250
+ const mimeType = resolveMime(filename, downloaded.mimeType);
251
+ const resolvedKind = isValidKind(asset.kind) ? asset.kind : resolveKind(undefined, filename, mimeType);
252
+ const persisted = await persistBrandAsset({
253
+ sb: adminDb(),
254
+ orgId,
255
+ projectId,
256
+ bytes: downloaded.bytes,
257
+ filename,
258
+ mimeType,
259
+ kind: resolvedKind,
260
+ source: 'imported',
261
+ collection: collection ?? null,
262
+ tags: tags ?? [],
263
+ brandKitId: brand_kit_id ?? null,
264
+ });
265
+ results.push({ url: asset.url, ok: true, filename, mime_type: mimeType, asset_id: persisted.assetId, skipped_existing: persisted.skippedExisting });
266
+ }
267
+ catch (error) {
268
+ results.push({ url: asset.url, ok: false, error: error instanceof Error ? error.message : 'Asset ingest failed' });
269
+ }
270
+ }
271
+ return j({
272
+ imported: results.filter((r) => r.ok).length,
273
+ failed: results.filter((r) => !r.ok).length,
274
+ results,
275
+ });
276
+ });
277
+ }