@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.
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 +14 -13
@@ -1,8 +1,10 @@
1
+ import { encodeSavedCredentialId, extractSavedCredentialValue, parseSavedCredentialId, savedCredentialProviderLabel, } from '@sendinel/domain';
1
2
  import { createHash, randomBytes } from 'crypto';
2
3
  import { z } from 'zod';
3
4
  import { adminDb, db } from '../db.js';
4
5
  import { decrypt, encrypt } from '../crypto.js';
5
6
  import { normalizeActiveCampaignAccountUrl } from '../lib/url-validation.js';
7
+ import { assertChannelCanExecute, checkApproval, currentChannelContext, requestApproval } from '../lib/action-approvals.js';
6
8
  import { getProjectId } from '../project.js';
7
9
  import { logAudit } from '../audit.js';
8
10
  const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
@@ -14,6 +16,15 @@ function generateApiKey() {
14
16
  prefix: secret.slice(0, 8),
15
17
  };
16
18
  }
19
+ function generateCustomWebhookSecret() {
20
+ return randomBytes(32).toString('hex');
21
+ }
22
+ function appBaseUrl() {
23
+ return (process.env.NEXT_PUBLIC_APP_URL ?? process.env.SENDINEL_APP_URL ?? process.env.APP_URL ?? 'https://sendinel.ai').replace(/\/$/, '');
24
+ }
25
+ function customWebhookEndpointUrl(connectionId) {
26
+ return `${appBaseUrl()}/api/v1/webhooks/custom/${connectionId}`;
27
+ }
17
28
  function sanitizeSuppression(row) {
18
29
  return {
19
30
  ...row,
@@ -262,6 +273,219 @@ export function registerDataTools(server) {
262
273
  return j({ error: error instanceof Error ? error.message : 'Failed to list connected platforms' });
263
274
  }
264
275
  });
276
+ server.registerTool('list_provider_credentials', {
277
+ title: 'List Provider Credentials',
278
+ annotations: {
279
+ readOnlyHint: true,
280
+ destructiveHint: false,
281
+ idempotentHint: true,
282
+ openWorldHint: false,
283
+ },
284
+ description: 'List saved provider credential metadata for the current project organization across platform connections and migration imports. Never returns decrypted secrets, masked fragments, or suffixes.',
285
+ inputSchema: {
286
+ org_id: z.string().uuid().optional(),
287
+ include_inactive: z.boolean().optional().default(false),
288
+ },
289
+ }, async ({ org_id, include_inactive = false }) => {
290
+ try {
291
+ const client = adminDb();
292
+ const context = await getPlatformProjectContext(client, org_id);
293
+ let platformQuery = client
294
+ .schema('email')
295
+ .from('platform_connections')
296
+ .select('id, platform, display_name, credentials_enc, status, is_active, created_at, updated_at, last_synced_at')
297
+ .eq('org_id', context.orgId)
298
+ .eq('project_id', context.projectId)
299
+ .order('updated_at', { ascending: false });
300
+ if (!include_inactive)
301
+ platformQuery = platformQuery.eq('is_active', true);
302
+ const importQuery = client
303
+ .schema('email')
304
+ .from('import_jobs')
305
+ .select('id, provider, credentials_enc, status, created_at, completed_at')
306
+ .eq('org_id', context.orgId)
307
+ .eq('project_id', context.projectId)
308
+ .order('created_at', { ascending: false })
309
+ .limit(50);
310
+ const [platformResult, importResult] = await Promise.all([platformQuery, importQuery]);
311
+ if (platformResult.error)
312
+ return j({ error: platformResult.error.message });
313
+ if (importResult.error)
314
+ return j({ error: importResult.error.message });
315
+ const mapRow = (row, source) => {
316
+ let credential;
317
+ try {
318
+ credential = extractSavedCredentialValue(decrypt(row.credentials_enc));
319
+ }
320
+ catch {
321
+ return null;
322
+ }
323
+ if (!credential)
324
+ return null;
325
+ const provider = source === 'platform' ? String(row.platform ?? '') : String(row.provider ?? '');
326
+ return {
327
+ id: encodeSavedCredentialId(source, row.id),
328
+ source,
329
+ provider,
330
+ provider_label: savedCredentialProviderLabel(provider),
331
+ display_name: row.display_name ?? null,
332
+ credential_key: credential.key,
333
+ status: row.status ?? null,
334
+ is_active: source === 'platform' ? (row.is_active ?? null) : null,
335
+ created_at: row.created_at ?? null,
336
+ last_used_at: source === 'platform' ? (row.last_synced_at ?? null) : (row.completed_at ?? null),
337
+ };
338
+ };
339
+ const platformCredentials = (platformResult.data ?? [])
340
+ .map((row) => mapRow(row, 'platform'))
341
+ .filter(Boolean);
342
+ const seenImportProviders = new Set();
343
+ const importCredentials = [];
344
+ for (const row of importResult.data ?? []) {
345
+ const credential = mapRow(row, 'import');
346
+ if (!credential || seenImportProviders.has(credential.provider))
347
+ continue;
348
+ seenImportProviders.add(credential.provider);
349
+ importCredentials.push(credential);
350
+ }
351
+ return j({ credentials: [...platformCredentials, ...importCredentials] });
352
+ }
353
+ catch (error) {
354
+ return j({ error: error instanceof Error ? error.message : 'Failed to list provider credentials' });
355
+ }
356
+ });
357
+ server.registerTool('delete_provider_credential', {
358
+ title: 'Delete Provider Credential',
359
+ annotations: {
360
+ readOnlyHint: false,
361
+ destructiveHint: true,
362
+ idempotentHint: false,
363
+ openWorldHint: false,
364
+ },
365
+ description: 'Remove a saved provider credential after a metadata-only preview and approval. Platform connections are deleted outright; migration-import secrets are revoked in place while retaining the import-job row. No secret value is returned.',
366
+ inputSchema: {
367
+ org_id: z.string().uuid().optional(),
368
+ credential_id: z.string().min(1),
369
+ confirmed: z.boolean().optional().default(false).describe('Two-call confirmation. First call previews exactly what is removed; second call with confirmed=true proceeds to approval.'),
370
+ approval_id: z.string().uuid().optional().describe('Approval token from a previous confirmed call without approval_id. Required when the action is not autonomous.'),
371
+ },
372
+ }, async ({ org_id, credential_id, confirmed, approval_id }) => {
373
+ try {
374
+ const parsed = parseSavedCredentialId(credential_id);
375
+ if (!parsed)
376
+ return j({ error: 'Invalid credential_id' });
377
+ const client = adminDb();
378
+ const context = await getPlatformProjectContext(client, org_id);
379
+ const table = parsed.source === 'platform' ? 'platform_connections' : 'import_jobs';
380
+ const columns = parsed.source === 'platform'
381
+ ? 'id, platform, display_name, status, is_active, created_at'
382
+ : 'id, provider, status, created_at, completed_at';
383
+ const { data: credential, error: credentialErr } = await client
384
+ .schema('email')
385
+ .from(table)
386
+ .select(columns)
387
+ .eq('id', parsed.rowId)
388
+ .eq('org_id', context.orgId)
389
+ .eq('project_id', context.projectId)
390
+ .maybeSingle();
391
+ if (credentialErr)
392
+ return j({ error: credentialErr.message });
393
+ if (!credential)
394
+ return j({ error: 'Credential not found' });
395
+ const provider = parsed.source === 'platform' ? credential.platform : credential.provider;
396
+ const approvalParams = {
397
+ credential_id,
398
+ source: parsed.source,
399
+ provider,
400
+ org_id: context.orgId,
401
+ project_id: context.projectId,
402
+ };
403
+ if (!confirmed) {
404
+ return j({
405
+ confirmation_required: true,
406
+ tool: 'delete_provider_credential',
407
+ credential: {
408
+ id: credential_id,
409
+ source: parsed.source,
410
+ provider,
411
+ provider_label: savedCredentialProviderLabel(String(provider ?? '')),
412
+ display_name: credential.display_name ?? null,
413
+ status: credential.status ?? null,
414
+ created_at: credential.created_at ?? null,
415
+ },
416
+ will_delete: parsed.source === 'platform'
417
+ ? 'The platform connection row and its encrypted credential will be permanently deleted.'
418
+ : 'The stored migration-import secret will be overwritten with a revocation marker; the import-job row will be retained.',
419
+ message: 'Call again with confirmed=true to proceed to the approval step.',
420
+ });
421
+ }
422
+ const channelContext = currentChannelContext();
423
+ const decision = assertChannelCanExecute('delete_provider_credential', approvalParams, channelContext);
424
+ if (!decision.allowed)
425
+ return j({ error: decision.reason ?? 'This channel cannot request this action.' });
426
+ if (decision.tier !== 'autonomous') {
427
+ if (!approval_id)
428
+ return j(await requestApproval('delete_provider_credential', approvalParams, undefined, channelContext));
429
+ const check = await checkApproval(approval_id, {
430
+ consume: true,
431
+ action: 'delete_provider_credential',
432
+ params: approvalParams,
433
+ });
434
+ if (!check.approved) {
435
+ return j({
436
+ error: `Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`,
437
+ code: `APPROVAL_${check.status.toUpperCase()}`,
438
+ });
439
+ }
440
+ }
441
+ if (parsed.source === 'platform') {
442
+ const { data, error } = await client
443
+ .schema('email')
444
+ .from('platform_connections')
445
+ .delete()
446
+ .eq('id', parsed.rowId)
447
+ .eq('org_id', context.orgId)
448
+ .eq('project_id', context.projectId)
449
+ .select('id, platform')
450
+ .maybeSingle();
451
+ if (error)
452
+ return j({ error: error.message });
453
+ if (!data)
454
+ return j({ error: 'Credential not found' });
455
+ await logAudit({
456
+ action: 'credential.deleted',
457
+ resourceType: 'platform_connection',
458
+ resourceId: parsed.rowId,
459
+ details: { platform: data.platform },
460
+ });
461
+ return j({ deleted: true, id: credential_id, source: 'platform', provider: data.platform });
462
+ }
463
+ const revokedAt = new Date().toISOString();
464
+ const { data, error } = await client
465
+ .schema('email')
466
+ .from('import_jobs')
467
+ .update({ credentials_enc: encrypt(JSON.stringify({ revoked: true, revoked_at: revokedAt })) })
468
+ .eq('id', parsed.rowId)
469
+ .eq('org_id', context.orgId)
470
+ .eq('project_id', context.projectId)
471
+ .select('id, provider')
472
+ .maybeSingle();
473
+ if (error)
474
+ return j({ error: error.message });
475
+ if (!data)
476
+ return j({ error: 'Credential not found' });
477
+ await logAudit({
478
+ action: 'credential.revoked',
479
+ resourceType: 'import_job',
480
+ resourceId: parsed.rowId,
481
+ details: { provider: data.provider },
482
+ });
483
+ return j({ deleted: true, id: credential_id, source: 'import', provider: data.provider });
484
+ }
485
+ catch (error) {
486
+ return j({ error: error instanceof Error ? error.message : 'Failed to delete provider credential' });
487
+ }
488
+ });
265
489
  server.registerTool('connect_platform', {
266
490
  title: 'Connect Platform',
267
491
  annotations: {
@@ -480,6 +704,223 @@ export function registerDataTools(server) {
480
704
  return j({ error: error instanceof Error ? error.message : 'Failed to configure platform trigger' });
481
705
  }
482
706
  });
707
+ server.registerTool('list_custom_webhooks', {
708
+ title: 'List Custom Webhooks',
709
+ annotations: {
710
+ readOnlyHint: true,
711
+ destructiveHint: false,
712
+ idempotentHint: true,
713
+ openWorldHint: false,
714
+ },
715
+ description: 'List custom inbound webhook connections for the current project. These are Sendinel-hosted receiving endpoints your own systems POST contact events to — separate from subscribe_webhook, which registers an outbound URL that Sendinel pushes its own events to. Stored secrets are never returned.',
716
+ inputSchema: {
717
+ org_id: z.string().uuid().optional(),
718
+ include_inactive: z.boolean().optional().default(false),
719
+ },
720
+ }, async ({ org_id, include_inactive = false }) => {
721
+ try {
722
+ const client = adminDb();
723
+ const context = await getPlatformProjectContext(client, org_id);
724
+ let query = client
725
+ .schema('email')
726
+ .from('platform_connections')
727
+ .select('id, display_name, status, is_active, created_at, updated_at')
728
+ .eq('org_id', context.orgId)
729
+ .eq('project_id', context.projectId)
730
+ .eq('platform', 'custom')
731
+ .order('created_at', { ascending: false });
732
+ if (!include_inactive)
733
+ query = query.eq('is_active', true);
734
+ const { data, error } = await query;
735
+ if (error)
736
+ return j({ error: error.message });
737
+ return j({
738
+ connections: (data ?? []).map((connection) => ({
739
+ id: connection.id,
740
+ display_name: connection.display_name,
741
+ status: connection.status,
742
+ is_active: connection.is_active,
743
+ endpoint_url: customWebhookEndpointUrl(connection.id),
744
+ has_secret: true,
745
+ created_at: connection.created_at,
746
+ updated_at: connection.updated_at,
747
+ })),
748
+ });
749
+ }
750
+ catch (error) {
751
+ return j({ error: error instanceof Error ? error.message : 'Failed to list custom webhooks' });
752
+ }
753
+ });
754
+ server.registerTool('create_custom_webhook', {
755
+ title: 'Create Custom Webhook',
756
+ annotations: {
757
+ readOnlyHint: false,
758
+ destructiveHint: false,
759
+ idempotentHint: false,
760
+ openWorldHint: false,
761
+ },
762
+ description: 'Create a Sendinel-hosted inbound webhook endpoint and return the secret once. Your own system can POST contact events to the returned endpoint_url, signed with the returned secret (HMAC-SHA256, header x-sendinel-signature: sha256=<hex>). Store the secret now because no tool can reveal the stored secret later. This is separate from subscribe_webhook: that tool registers a URL Sendinel pushes events to; this tool creates a URL that receives events from you.',
763
+ inputSchema: {
764
+ org_id: z.string().uuid().optional(),
765
+ label: z.string().min(1).max(200).optional(),
766
+ },
767
+ }, async ({ org_id, label }) => {
768
+ try {
769
+ const client = adminDb();
770
+ const context = await getPlatformProjectContext(client, org_id);
771
+ const displayName = label?.trim() || 'Custom webhook';
772
+ const secret = generateCustomWebhookSecret();
773
+ const config = {
774
+ label: displayName,
775
+ credential_keys: ['webhook_secret'],
776
+ signature_header: 'x-sendinel-signature',
777
+ signature_format: 'sha256=<hex>',
778
+ };
779
+ const { data, error } = await client
780
+ .schema('email')
781
+ .from('platform_connections')
782
+ .upsert({
783
+ user_id: context.userId,
784
+ org_id: context.orgId,
785
+ project_id: context.projectId,
786
+ platform: 'custom',
787
+ connection_mode: 'migrate',
788
+ display_name: displayName,
789
+ credentials_enc: encrypt(JSON.stringify({ webhook_secret: secret })),
790
+ config,
791
+ meta: config,
792
+ status: 'connected',
793
+ is_active: true,
794
+ last_error: null,
795
+ updated_at: new Date().toISOString(),
796
+ }, { onConflict: 'user_id,org_id,project_id,platform,connection_mode' })
797
+ .select('id')
798
+ .single();
799
+ if (error || !data)
800
+ return j({ error: error?.message ?? 'Failed to create custom webhook' });
801
+ const endpointUrl = customWebhookEndpointUrl(data.id);
802
+ await client
803
+ .schema('email')
804
+ .from('platform_connections')
805
+ .update({ config: { ...config, webhook_url: endpointUrl }, meta: { ...config, webhook_url: endpointUrl } })
806
+ .eq('id', data.id);
807
+ await logAudit({
808
+ action: 'platform_connection.custom_webhook_created',
809
+ resourceType: 'platform_connection',
810
+ resourceId: data.id,
811
+ });
812
+ return j({
813
+ connection_id: data.id,
814
+ endpoint_url: endpointUrl,
815
+ secret,
816
+ message: `Custom webhook created. Store the secret now — it will not be shown again. POST events to ${endpointUrl} with an x-sendinel-signature: sha256=<hmac-sha256-hex> header.`,
817
+ });
818
+ }
819
+ catch (error) {
820
+ return j({ error: error instanceof Error ? error.message : 'Failed to create custom webhook' });
821
+ }
822
+ });
823
+ server.registerTool('rotate_custom_webhook_secret', {
824
+ title: 'Rotate Custom Webhook Secret',
825
+ annotations: {
826
+ readOnlyHint: false,
827
+ destructiveHint: true,
828
+ idempotentHint: false,
829
+ openWorldHint: false,
830
+ },
831
+ description: 'Rotate the signing secret for a custom inbound webhook connection and return the secret once. Uses two-call confirmation plus approval before immediately invalidating the previous secret. Any system still signing requests with the old secret will get a 400 (invalid signature) on every call until it is updated with the new secret returned here. There is no grace period.',
832
+ inputSchema: {
833
+ connection_id: z.string().uuid(),
834
+ confirmed: z.boolean().optional().default(false).describe('Two-call confirmation. First call previews, second call with confirmed=true proceeds to approval.'),
835
+ approval_id: z.string().uuid().optional().describe('Approval token from a previous confirmed call without approval_id. Required when the action is not autonomous.'),
836
+ },
837
+ }, async ({ connection_id, confirmed, approval_id }) => {
838
+ try {
839
+ const client = adminDb();
840
+ const projectId = getProjectId();
841
+ const { data: connection, error: lookupError } = await client
842
+ .schema('email')
843
+ .from('platform_connections')
844
+ .select('id, project_id, display_name, config')
845
+ .eq('id', connection_id)
846
+ .eq('project_id', projectId)
847
+ .eq('platform', 'custom')
848
+ .eq('is_active', true)
849
+ .maybeSingle();
850
+ if (lookupError)
851
+ return j({ error: lookupError.message });
852
+ if (!connection)
853
+ return j({ error: 'Custom webhook not found' });
854
+ if (!confirmed) {
855
+ return j({
856
+ confirmation_required: true,
857
+ tool: 'rotate_custom_webhook_secret',
858
+ connection: {
859
+ id: connection.id,
860
+ display_name: connection.display_name,
861
+ endpoint_url: customWebhookEndpointUrl(connection.id),
862
+ },
863
+ warning: 'Rotation immediately invalidates the previous secret. Requests signed with it will be rejected (400) until the sender is updated. There is no grace period.',
864
+ message: 'Call again with confirmed=true to proceed to approval.',
865
+ });
866
+ }
867
+ const approvalParams = { connection_id };
868
+ const channelContext = currentChannelContext();
869
+ const decision = assertChannelCanExecute('rotate_custom_webhook_secret', approvalParams, channelContext);
870
+ if (!decision.allowed)
871
+ return j({ error: decision.reason ?? 'This channel cannot request this action.' });
872
+ if (decision.tier !== 'autonomous') {
873
+ if (!approval_id) {
874
+ const approval = await requestApproval('rotate_custom_webhook_secret', approvalParams, undefined, channelContext);
875
+ return j(approval);
876
+ }
877
+ const check = await checkApproval(approval_id, { consume: true, action: 'rotate_custom_webhook_secret', params: approvalParams });
878
+ if (!check.approved) {
879
+ return j({
880
+ error: `Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`,
881
+ });
882
+ }
883
+ }
884
+ const secret = generateCustomWebhookSecret();
885
+ const updatedAt = new Date().toISOString();
886
+ const config = { ...(connection.config ?? {}), rotated_at: updatedAt };
887
+ const { data: updated, error } = await client
888
+ .schema('email')
889
+ .from('platform_connections')
890
+ .update({
891
+ credentials_enc: encrypt(JSON.stringify({ webhook_secret: secret })),
892
+ config,
893
+ meta: config,
894
+ updated_at: updatedAt,
895
+ status: 'connected',
896
+ last_error: null,
897
+ })
898
+ .eq('id', connection.id)
899
+ .eq('project_id', projectId)
900
+ .eq('platform', 'custom')
901
+ .eq('is_active', true)
902
+ .select('id')
903
+ .maybeSingle();
904
+ if (error)
905
+ return j({ error: error.message });
906
+ if (!updated)
907
+ return j({ error: 'Custom webhook is no longer active' });
908
+ await logAudit({
909
+ action: 'platform_connection.custom_webhook_rotated',
910
+ resourceType: 'platform_connection',
911
+ resourceId: connection.id,
912
+ });
913
+ return j({
914
+ connection_id: connection.id,
915
+ endpoint_url: customWebhookEndpointUrl(connection.id),
916
+ secret,
917
+ warning: 'The previous secret is now invalid. Requests signed with it will be rejected (400) until the sender is updated with this new secret. The new secret will not be shown again.',
918
+ });
919
+ }
920
+ catch (error) {
921
+ return j({ error: error instanceof Error ? error.message : 'Failed to rotate custom webhook secret' });
922
+ }
923
+ });
483
924
  server.registerTool('list_import_history', {
484
925
  title: 'List Import History',
485
926
  annotations: {