@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,5 +1,7 @@
1
+ import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
1
2
  import { CONTACT_ENRICHMENT_PROVIDERS } from '@desler/models';
2
3
  import { createContactEnrichmentAdapters, enrichContactFromConnections, } from '@desler/models/contact-enrichment';
4
+ import { buildPreviewAttentionEnvelope, applySegmentRules, ContactPropertyAnomalyLogger, normalizeContactProperties, getAttentionDecision, getContactNeighborhood, getScheduledTouches, } from '@sendinel/domain';
3
5
  import { z } from 'zod';
4
6
  import { db, projectClientForWriteback } from '../db.js';
5
7
  import { getProjectId } from '../project.js';
@@ -8,10 +10,61 @@ import { logConsent, logAudit } from '../audit.js';
8
10
  import { assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval } from '../lib/action-approvals.js';
9
11
  import { decrypt } from '../crypto.js';
10
12
  import { normalizeActiveCampaignAccountUrl } from '../lib/url-validation.js';
13
+ import { diffCustomProperties } from '../lib/custom-properties-diff.js';
11
14
  import Anthropic from '@anthropic-ai/sdk';
12
15
  import { getAnthropicModelId } from '../anthropic-model.js';
13
16
  const txt = (text) => ({ content: [{ type: 'text', text }] });
14
17
  const j = (data) => txt(JSON.stringify(data, null, 2));
18
+ const CUSTOM_FIELD_TYPES = [
19
+ 'email', 'text', 'textarea', 'select', 'checkbox', 'number', 'tel', 'url', 'date',
20
+ 'multi_select', 'contact_reference',
21
+ ];
22
+ const CUSTOM_FIELD_KEY_RE = /^[a-z][a-z0-9_]{0,49}$/;
23
+ const SUBSCRIBER_VIEW_MAX_NAME_LENGTH = 100;
24
+ const SUBSCRIBER_VIEW_MAX_COLUMNS = 150;
25
+ const SUBSCRIBER_VIEW_KEY_RE = /^[a-zA-Z0-9_.:-]{1,160}$/;
26
+ const SUBSCRIBER_VIEW_FILTER_KEYS = new Set([
27
+ 'search',
28
+ 'tag',
29
+ 'stage',
30
+ 'status',
31
+ 'cp_key',
32
+ 'cp_value',
33
+ 'site',
34
+ 'sort',
35
+ 'dir',
36
+ 'show_test',
37
+ ]);
38
+ const SUBSCRIBER_VIEW_SORT_FIELDS = new Set([
39
+ 'created_at',
40
+ 'engagement_score',
41
+ 'total_opens',
42
+ 'total_clicks',
43
+ 'last_emailed_at',
44
+ 'lifecycle_stage',
45
+ 'email',
46
+ ]);
47
+ const SUBSCRIBER_VIEW_CUSTOM_PROPERTY_KEY_RE = /^[a-zA-Z0-9_.-]{1,64}$/;
48
+ const SUBSCRIBER_VIEW_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
49
+ const subscriberViewColumnsSchema = z.object({
50
+ order: z.array(z.string()).optional(),
51
+ hidden: z.array(z.string()).optional(),
52
+ });
53
+ function normalizeCustomFieldOptions(options) {
54
+ if (options === undefined)
55
+ return undefined;
56
+ if (options === null)
57
+ return null;
58
+ return options.map((option) => option.trim()).filter(Boolean);
59
+ }
60
+ function validateCustomFieldOptions(type, options) {
61
+ if (type !== 'select' && type !== 'multi_select')
62
+ return null;
63
+ if (!Array.isArray(options) || options.some((option) => typeof option !== 'string')) {
64
+ return `type "${type}" requires an options array of strings`;
65
+ }
66
+ return null;
67
+ }
15
68
  async function byodWritebackClient(projectId) {
16
69
  const customer = projectClientForWriteback();
17
70
  if (!customer)
@@ -130,6 +183,145 @@ function normalizeCompanyDomain(value) {
130
183
  function normalizeEmail(value) {
131
184
  return String(value ?? '').trim().toLowerCase();
132
185
  }
186
+ function isPlainObject(value) {
187
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
188
+ }
189
+ function normalizeSubscriberViewKeys(value) {
190
+ if (!Array.isArray(value))
191
+ return [];
192
+ return [...new Set(value
193
+ .filter((key) => typeof key === 'string')
194
+ .map((key) => key.trim())
195
+ .filter((key) => SUBSCRIBER_VIEW_KEY_RE.test(key)))]
196
+ .slice(0, SUBSCRIBER_VIEW_MAX_COLUMNS);
197
+ }
198
+ function normalizeSubscriberViewColumns(value) {
199
+ const input = isPlainObject(value) ? value : {};
200
+ return {
201
+ order: normalizeSubscriberViewKeys(input.order),
202
+ hidden: normalizeSubscriberViewKeys(input.hidden),
203
+ };
204
+ }
205
+ function normalizeSubscriberViewFilters(value) {
206
+ if (!isPlainObject(value))
207
+ return {};
208
+ const output = {};
209
+ for (const [key, raw] of Object.entries(value)) {
210
+ if (!SUBSCRIBER_VIEW_FILTER_KEYS.has(key))
211
+ continue;
212
+ if (key === 'show_test') {
213
+ output[key] = raw === true;
214
+ }
215
+ else if (typeof raw === 'string') {
216
+ output[key] = raw.slice(0, 500);
217
+ }
218
+ }
219
+ if (output.dir !== 'asc' && output.dir !== 'desc')
220
+ delete output.dir;
221
+ return output;
222
+ }
223
+ function subscriberViewFilterString(filters, key) {
224
+ const value = filters[key];
225
+ return typeof value === 'string' ? value.trim() : '';
226
+ }
227
+ function subscriberViewCustomPropertyPath(key) {
228
+ const dot = key.indexOf('.');
229
+ if (dot === -1)
230
+ return `custom_properties->>'${key}'`;
231
+ return `custom_properties->'${key.slice(0, dot)}'->>'${key.slice(dot + 1)}'`;
232
+ }
233
+ async function resolveSubscriberViewSiteId(projectId, siteParam) {
234
+ const { data: sites, error } = await db()
235
+ .from('sites')
236
+ .select('id')
237
+ .eq('project_id', projectId)
238
+ .eq('active', true)
239
+ .order('name');
240
+ if (error)
241
+ return { error: error.message };
242
+ const all = (sites ?? []);
243
+ if (SUBSCRIBER_VIEW_UUID_RE.test(siteParam)) {
244
+ return { siteId: all.some((site) => site.id === siteParam) ? siteParam : undefined };
245
+ }
246
+ const displayId = Number(siteParam);
247
+ return {
248
+ siteId: Number.isInteger(displayId) && displayId > 0 ? all[displayId - 1]?.id : undefined,
249
+ };
250
+ }
251
+ async function getProjectOrgId(projectId) {
252
+ const { data } = await db().from('projects').select('org_id').eq('id', projectId).maybeSingle();
253
+ return typeof data?.org_id === 'string' && data.org_id ? data.org_id : projectId;
254
+ }
255
+ async function loadContactPropertyDefinitions(projectId) {
256
+ try {
257
+ const client = db();
258
+ const scoped = typeof client.schema === 'function' ? client.schema('email') : client;
259
+ const { data, error } = await scoped
260
+ .from('custom_field_definitions')
261
+ .select('key, type, options')
262
+ .eq('project_id', projectId);
263
+ if (error) {
264
+ console.warn('[contact-properties] MCP definition load failed:', error.message ?? error);
265
+ return {};
266
+ }
267
+ const fields = {};
268
+ for (const row of data ?? []) {
269
+ const key = typeof row.key === 'string' ? row.key : '';
270
+ const type = row.type;
271
+ if (!key || !type)
272
+ continue;
273
+ fields[key] = {
274
+ type,
275
+ options: Array.isArray(row.options) ? row.options : null,
276
+ };
277
+ }
278
+ return fields;
279
+ }
280
+ catch (error) {
281
+ console.warn('[contact-properties] MCP definition load threw:', error);
282
+ return {};
283
+ }
284
+ }
285
+ async function normalizeMcpContactPropertyPatch(raw, options) {
286
+ if (!raw)
287
+ return {};
288
+ const fields = options.fields ?? await loadContactPropertyDefinitions(options.projectId);
289
+ const knownScalarPatch = {};
290
+ const passthrough = {};
291
+ const nullDeletions = {};
292
+ for (const [key, value] of Object.entries(raw)) {
293
+ if (value === null && options.preserveNullDeletions) {
294
+ nullDeletions[key] = null;
295
+ continue;
296
+ }
297
+ if (Object.prototype.hasOwnProperty.call(fields, key) && !isPlainObject(value)) {
298
+ knownScalarPatch[key] = value;
299
+ }
300
+ else {
301
+ passthrough[key] = value;
302
+ }
303
+ }
304
+ const { properties, anomalies } = normalizeContactProperties(knownScalarPatch, { fields });
305
+ if (anomalies.length > 0) {
306
+ const logger = options.anomalyLogger ?? new ContactPropertyAnomalyLogger({
307
+ orgId: options.orgId,
308
+ projectId: options.projectId,
309
+ subscriberId: options.subscriberId ?? null,
310
+ pipeline: 'mcp_subscriber_tools',
311
+ });
312
+ if (options.subscriberId !== undefined)
313
+ logger.setSubscriberId(options.subscriberId);
314
+ for (const anomaly of anomalies)
315
+ logger.log(anomaly.key, anomaly.rawValue, anomaly.reason);
316
+ if (!options.anomalyLogger)
317
+ await logger.flush(db());
318
+ }
319
+ return {
320
+ ...passthrough,
321
+ ...properties,
322
+ ...nullDeletions,
323
+ };
324
+ }
133
325
  const companiesEmbedCache = new Map();
134
326
  function isMissingRelationshipError(error) {
135
327
  if (!error)
@@ -588,28 +780,73 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
588
780
  idempotentHint: true,
589
781
  openWorldHint: false,
590
782
  },
591
- description: 'List contacts, optionally filtered by site and/or search term',
783
+ description: 'List contacts, optionally filtered by site, search term, and/or a saved subscriber view. Explicit arguments and saved-view filters are combined to narrow the result.',
592
784
  inputSchema: {
593
785
  site_id: z.string().uuid().optional(),
594
786
  company_id: z.string().uuid().optional(),
595
787
  company_domain: z.string().optional(),
596
788
  search: z.string().optional().describe('Partial match on email, first_name, or last_name'),
789
+ view_id: z.string().uuid().optional().describe('Saved subscriber view whose filters should also be applied'),
597
790
  limit: z.number().int().min(1).max(200).optional().default(50),
598
791
  include_unsubscribed: z.boolean().optional().default(false),
599
792
  },
600
- }, async ({ site_id, company_id, company_domain, search, limit, include_unsubscribed }) => {
793
+ }, async ({ site_id, company_id, company_domain, search, view_id, limit, include_unsubscribed }) => {
601
794
  const projectId = getProjectId();
795
+ let viewFilters = null;
796
+ if (view_id) {
797
+ const { data: view, error: viewError } = await db()
798
+ .from('subscriber_list_views')
799
+ .select('filters')
800
+ .eq('id', view_id)
801
+ .eq('project_id', projectId)
802
+ .maybeSingle();
803
+ if (viewError)
804
+ return txt(`Error: ${viewError.message}`);
805
+ if (!view)
806
+ return txt('Error: View not found');
807
+ viewFilters = normalizeSubscriberViewFilters(view.filters);
808
+ }
809
+ const viewSearch = viewFilters ? subscriberViewFilterString(viewFilters, 'search') : '';
810
+ const viewStage = viewFilters ? subscriberViewFilterString(viewFilters, 'stage') : '';
811
+ const viewCustomPropertyKey = viewFilters ? subscriberViewFilterString(viewFilters, 'cp_key') : '';
812
+ const viewCustomPropertyValue = viewFilters ? subscriberViewFilterString(viewFilters, 'cp_value') : '';
813
+ const viewSiteParam = viewFilters ? subscriberViewFilterString(viewFilters, 'site') : '';
814
+ const viewStatus = viewFilters ? subscriberViewFilterString(viewFilters, 'status') : '';
815
+ const viewSort = viewFilters ? subscriberViewFilterString(viewFilters, 'sort') : '';
816
+ const viewDir = viewFilters ? subscriberViewFilterString(viewFilters, 'dir') : '';
817
+ const viewTags = viewFilters
818
+ ? [...new Set(subscriberViewFilterString(viewFilters, 'tag').split(',').map((tag) => tag.trim()).filter(Boolean))].slice(0, 20)
819
+ : [];
820
+ let viewSiteId;
821
+ if (viewSiteParam && viewSiteParam !== 'all') {
822
+ const resolved = await resolveSubscriberViewSiteId(projectId, viewSiteParam);
823
+ if (resolved.error)
824
+ return txt(`Error: ${resolved.error}`);
825
+ viewSiteId = resolved.siteId;
826
+ }
602
827
  const embedOk = await hasCompaniesEmbed(projectId);
828
+ const sortField = SUBSCRIBER_VIEW_SORT_FIELDS.has(viewSort) ? viewSort : 'created_at';
603
829
  let q = db()
604
830
  .from('subscribers')
605
831
  .select(embedOk ? SUBSCRIBER_SELECT_WITH_COMPANY : SUBSCRIBER_SELECT_NO_COMPANY)
606
832
  .eq('project_id', projectId)
607
- .order('created_at', { ascending: false })
833
+ .order(sortField, { ascending: viewFilters !== null && viewDir === 'asc' })
608
834
  .limit(limit ?? 50);
609
835
  if (!include_unsubscribed)
610
836
  q = q.eq('global_unsubscribed', false);
611
837
  if (search)
612
838
  q = q.or(`email.ilike.%${search}%,first_name.ilike.%${search}%,last_name.ilike.%${search}%`);
839
+ if (viewSearch)
840
+ q = q.or(`email.ilike.%${viewSearch}%,first_name.ilike.%${viewSearch}%,last_name.ilike.%${viewSearch}%`);
841
+ if (viewTags.length > 0)
842
+ q = q.contains('tags', viewTags);
843
+ if (viewStage)
844
+ q = q.eq('lifecycle_stage', viewStage);
845
+ if (viewCustomPropertyKey && viewCustomPropertyValue && SUBSCRIBER_VIEW_CUSTOM_PROPERTY_KEY_RE.test(viewCustomPropertyKey)) {
846
+ q = q.filter(subscriberViewCustomPropertyPath(viewCustomPropertyKey), 'ilike', `%${viewCustomPropertyValue}%`);
847
+ }
848
+ if (viewFilters && viewFilters.show_test !== true)
849
+ q = q.not('is_test', 'is', true);
613
850
  if (company_id)
614
851
  q = q.eq('company_id', company_id);
615
852
  if (site_id) {
@@ -623,6 +860,32 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
623
860
  return { content: [{ type: 'text', text: '[]' }] };
624
861
  q = q.in('id', ids);
625
862
  }
863
+ const statusSiteId = viewSiteId ?? site_id;
864
+ if (statusSiteId && ['active', 'unsubscribed', 'not_subscribed'].includes(viewStatus)) {
865
+ const { data: siteSubscriptions, error: siteSubscriptionsError } = await db()
866
+ .from('site_subscriptions')
867
+ .select('contact_id, subscribed')
868
+ .eq('site_id', statusSiteId);
869
+ if (siteSubscriptionsError)
870
+ return txt(`Error: ${siteSubscriptionsError.message}`);
871
+ if (viewStatus === 'not_subscribed') {
872
+ const subscribedIds = (siteSubscriptions ?? [])
873
+ .map((subscription) => subscription.contact_id)
874
+ .filter(Boolean);
875
+ if (subscribedIds.length > 0)
876
+ q = q.not('id', 'in', `(${subscribedIds.join(',')})`);
877
+ }
878
+ else {
879
+ const shouldBeSubscribed = viewStatus === 'active';
880
+ const matchingIds = (siteSubscriptions ?? [])
881
+ .filter((subscription) => subscription.subscribed === shouldBeSubscribed)
882
+ .map((subscription) => subscription.contact_id)
883
+ .filter(Boolean);
884
+ if (!matchingIds.length)
885
+ return txt('[]');
886
+ q = q.in('id', matchingIds);
887
+ }
888
+ }
626
889
  if (company_domain) {
627
890
  const { data: companies } = await db()
628
891
  .from('companies')
@@ -639,6 +902,137 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
639
902
  return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
640
903
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
641
904
  });
905
+ server.registerTool('list_subscriber_views', {
906
+ title: 'List Subscriber Views',
907
+ annotations: {
908
+ readOnlyHint: true,
909
+ destructiveHint: false,
910
+ idempotentHint: true,
911
+ openWorldHint: false,
912
+ },
913
+ description: 'List saved subscriber views for the current project in creation order.',
914
+ inputSchema: {},
915
+ }, async () => {
916
+ const { data, error } = await db()
917
+ .from('subscriber_list_views')
918
+ .select('id, name, view_mode, columns, filters, created_by, created_at, updated_at')
919
+ .eq('project_id', getProjectId())
920
+ .order('created_at', { ascending: true });
921
+ if (error)
922
+ return txt(`Error: ${error.message}`);
923
+ return j({ views: data ?? [] });
924
+ });
925
+ server.registerTool('create_subscriber_view', {
926
+ title: 'Create Subscriber View',
927
+ annotations: {
928
+ readOnlyHint: false,
929
+ destructiveHint: false,
930
+ idempotentHint: false,
931
+ openWorldHint: false,
932
+ },
933
+ description: 'Create a saved subscriber view for the current project. The saved filters can be applied with list_subscribers view_id.',
934
+ inputSchema: {
935
+ name: z.string().min(1),
936
+ view_mode: z.enum(['table', 'grid']),
937
+ columns: subscriberViewColumnsSchema.optional(),
938
+ filters: z.record(z.string(), z.unknown()).optional(),
939
+ },
940
+ }, async ({ name, view_mode, columns, filters }) => {
941
+ const normalizedName = name.trim().slice(0, SUBSCRIBER_VIEW_MAX_NAME_LENGTH);
942
+ if (!normalizedName)
943
+ return txt('Error: name is required');
944
+ const { data, error } = await db()
945
+ .from('subscriber_list_views')
946
+ .insert({
947
+ project_id: getProjectId(),
948
+ created_by: null,
949
+ name: normalizedName,
950
+ view_mode,
951
+ columns: normalizeSubscriberViewColumns(columns),
952
+ filters: normalizeSubscriberViewFilters(filters),
953
+ })
954
+ .select('id, name, view_mode, columns, filters, created_by, created_at, updated_at')
955
+ .maybeSingle();
956
+ if (error?.code === '23505')
957
+ return txt(`Error: A view named "${normalizedName}" already exists`);
958
+ if (error)
959
+ return txt(`Error: ${error.message}`);
960
+ return j({ view: data });
961
+ });
962
+ server.registerTool('update_subscriber_view', {
963
+ title: 'Update Subscriber View',
964
+ annotations: {
965
+ readOnlyHint: false,
966
+ destructiveHint: false,
967
+ idempotentHint: true,
968
+ openWorldHint: false,
969
+ },
970
+ description: 'Update selected fields on a saved subscriber view owned by the current project.',
971
+ inputSchema: {
972
+ view_id: z.string().uuid(),
973
+ name: z.string().optional(),
974
+ view_mode: z.enum(['table', 'grid']).optional(),
975
+ columns: subscriberViewColumnsSchema.optional(),
976
+ filters: z.record(z.string(), z.unknown()).optional(),
977
+ },
978
+ }, async ({ view_id, name, view_mode, columns, filters }) => {
979
+ if (name === undefined && view_mode === undefined && columns === undefined && filters === undefined) {
980
+ return txt('Nothing to update');
981
+ }
982
+ const updates = {};
983
+ if (name !== undefined) {
984
+ const normalizedName = name.trim().slice(0, SUBSCRIBER_VIEW_MAX_NAME_LENGTH);
985
+ if (!normalizedName)
986
+ return txt('Error: name is required');
987
+ updates.name = normalizedName;
988
+ }
989
+ if (view_mode !== undefined)
990
+ updates.view_mode = view_mode;
991
+ if (columns !== undefined)
992
+ updates.columns = normalizeSubscriberViewColumns(columns);
993
+ if (filters !== undefined)
994
+ updates.filters = normalizeSubscriberViewFilters(filters);
995
+ const { data, error } = await db()
996
+ .from('subscriber_list_views')
997
+ .update(updates)
998
+ .eq('id', view_id)
999
+ .eq('project_id', getProjectId())
1000
+ .select('id, name, view_mode, columns, filters, created_by, created_at, updated_at')
1001
+ .maybeSingle();
1002
+ if (error?.code === '23505')
1003
+ return txt('Error: A view with that name already exists');
1004
+ if (error)
1005
+ return txt(`Error: ${error.message}`);
1006
+ if (!data)
1007
+ return txt('View not found');
1008
+ return j({ view: data });
1009
+ });
1010
+ server.registerTool('delete_subscriber_view', {
1011
+ title: 'Delete Subscriber View',
1012
+ annotations: {
1013
+ readOnlyHint: false,
1014
+ destructiveHint: true,
1015
+ idempotentHint: false,
1016
+ openWorldHint: false,
1017
+ },
1018
+ description: 'Delete a saved subscriber view owned by the current project.',
1019
+ inputSchema: {
1020
+ view_id: z.string().uuid(),
1021
+ },
1022
+ }, async ({ view_id }) => {
1023
+ const { data, error } = await db()
1024
+ .from('subscriber_list_views')
1025
+ .delete()
1026
+ .eq('id', view_id)
1027
+ .eq('project_id', getProjectId())
1028
+ .select('id')
1029
+ .maybeSingle();
1030
+ if (error)
1031
+ return txt(`Error: ${error.message}`);
1032
+ if (!data)
1033
+ return txt('View not found');
1034
+ return j({ deleted: true, view_id });
1035
+ });
642
1036
  server.registerTool('list_companies', {
643
1037
  title: 'List Companies',
644
1038
  annotations: {
@@ -741,11 +1135,11 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
741
1135
  .maybeSingle();
742
1136
  const row = { project_id: projectId, email, first_name, last_name, company_id: resolvedCompanyId, source, source_site: site_id };
743
1137
  if (company_name || company_domain) {
744
- row.custom_properties = {
745
- ...(typeof row.custom_properties === 'object' && row.custom_properties ? row.custom_properties : {}),
1138
+ const orgId = await getProjectOrgId(projectId);
1139
+ row.custom_properties = await normalizeMcpContactPropertyPatch({
746
1140
  ...(company_name ? { company_name } : {}),
747
1141
  ...(company_domain ? { company_domain: company_domain.trim().toLowerCase() } : {}),
748
- };
1142
+ }, { orgId, projectId });
749
1143
  }
750
1144
  if (!needsConfirm)
751
1145
  row.opted_in_at = new Date().toISOString();
@@ -1086,6 +1480,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1086
1480
  const projectId = getProjectId();
1087
1481
  const embedOk = await hasCompaniesEmbed(projectId);
1088
1482
  const updates = {};
1483
+ let customPropertyChanges = [];
1089
1484
  if (email !== undefined)
1090
1485
  updates.email = email;
1091
1486
  if (first_name !== undefined)
@@ -1110,12 +1505,24 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1110
1505
  .eq('id', contact_id)
1111
1506
  .eq('project_id', projectId)
1112
1507
  .maybeSingle();
1508
+ const orgId = await getProjectOrgId(projectId);
1509
+ const normalizedPatch = await normalizeMcpContactPropertyPatch(custom_properties, {
1510
+ orgId,
1511
+ projectId,
1512
+ subscriberId: contact_id,
1513
+ preserveNullDeletions: true,
1514
+ });
1515
+ customPropertyChanges = diffCustomProperties(existing?.custom_properties ?? null, normalizedPatch);
1113
1516
  updates.custom_properties = {
1114
1517
  ...(existing?.custom_properties ?? {}),
1115
- ...custom_properties,
1518
+ ...normalizedPatch,
1116
1519
  ...(company_name ? { company_name } : {}),
1117
1520
  ...(company_domain ? { company_domain: normalizeCompanyDomain(company_domain) } : {}),
1118
1521
  };
1522
+ for (const [key, value] of Object.entries(normalizedPatch)) {
1523
+ if (value === null)
1524
+ delete updates.custom_properties[key];
1525
+ }
1119
1526
  }
1120
1527
  else if (company_name || company_domain) {
1121
1528
  const { data: existing } = await db()
@@ -1124,10 +1531,14 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1124
1531
  .eq('id', contact_id)
1125
1532
  .eq('project_id', projectId)
1126
1533
  .maybeSingle();
1127
- updates.custom_properties = {
1128
- ...(existing?.custom_properties ?? {}),
1534
+ const orgId = await getProjectOrgId(projectId);
1535
+ const companyPatch = await normalizeMcpContactPropertyPatch({
1129
1536
  ...(company_name ? { company_name } : {}),
1130
1537
  ...(company_domain ? { company_domain: normalizeCompanyDomain(company_domain) } : {}),
1538
+ }, { orgId, projectId, subscriberId: contact_id });
1539
+ updates.custom_properties = {
1540
+ ...(existing?.custom_properties ?? {}),
1541
+ ...companyPatch,
1131
1542
  };
1132
1543
  }
1133
1544
  if (!Object.keys(updates).length)
@@ -1142,6 +1553,14 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1142
1553
  .single();
1143
1554
  if (error)
1144
1555
  return txt(`Error: ${error.message}`);
1556
+ if (customPropertyChanges.length > 0) {
1557
+ logAudit({
1558
+ action: 'contact.custom_properties_changed',
1559
+ resourceType: 'contact',
1560
+ resourceId: contact_id,
1561
+ details: { changes: customPropertyChanges },
1562
+ });
1563
+ }
1145
1564
  await writeBackMcpContact(projectId, data, {
1146
1565
  ...(updates.email !== undefined ? { email: updates.email } : {}),
1147
1566
  ...(updates.first_name !== undefined ? { first_name: updates.first_name } : {}),
@@ -1211,11 +1630,17 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1211
1630
  const enriched = await enrichContactFromProviders(projectId, contact.email, provider);
1212
1631
  if (!enriched)
1213
1632
  return j({ contact, enriched: false, applied_fields: [] });
1633
+ const orgId = await getProjectOrgId(projectId);
1634
+ const normalizedEnrichedProperties = await normalizeMcpContactPropertyPatch(enriched.properties, {
1635
+ orgId,
1636
+ projectId,
1637
+ subscriberId: contact_id,
1638
+ });
1214
1639
  const nextProperties = {
1215
1640
  ...(contact.custom_properties ?? {}),
1216
1641
  [enriched.provider]: {
1217
1642
  ...((contact.custom_properties ?? {})[enriched.provider] ?? {}),
1218
- ...enriched.properties,
1643
+ ...normalizedEnrichedProperties,
1219
1644
  },
1220
1645
  };
1221
1646
  const companyId = await resolveCompanyId(projectId, {
@@ -1313,6 +1738,76 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1313
1738
  }
1314
1739
  return j({ updated, total: contact_ids.length, add_tags, remove_tags });
1315
1740
  });
1741
+ server.registerTool('bulk_update_subscribers', {
1742
+ title: 'Bulk Update Subscribers',
1743
+ annotations: {
1744
+ readOnlyHint: false,
1745
+ destructiveHint: false,
1746
+ idempotentHint: true,
1747
+ openWorldHint: false,
1748
+ },
1749
+ description: 'Merge custom_properties into many contacts at once. Same merge semantics as update_subscriber, applied across a batch.',
1750
+ inputSchema: {
1751
+ contact_ids: z.array(z.string().uuid()).min(1),
1752
+ custom_properties: z.record(z.string(), z.unknown()).describe('Merged into each contact\'s existing custom_properties; a key set to null deletes that key'),
1753
+ },
1754
+ }, async ({ contact_ids, custom_properties }) => {
1755
+ const projectId = getProjectId();
1756
+ const orgId = await getProjectOrgId(projectId);
1757
+ const fields = await loadContactPropertyDefinitions(projectId);
1758
+ const anomalyLogger = new ContactPropertyAnomalyLogger({
1759
+ orgId,
1760
+ projectId,
1761
+ subscriberId: null,
1762
+ pipeline: 'mcp_subscriber_tools',
1763
+ });
1764
+ const normalizedPatch = await normalizeMcpContactPropertyPatch(custom_properties, {
1765
+ orgId,
1766
+ projectId,
1767
+ preserveNullDeletions: true,
1768
+ fields,
1769
+ anomalyLogger,
1770
+ });
1771
+ await anomalyLogger.flush(db());
1772
+ let updated = 0;
1773
+ for (const cid of contact_ids) {
1774
+ const { data: contact } = await db()
1775
+ .from('subscribers')
1776
+ .select('id, email, first_name, last_name, custom_properties')
1777
+ .eq('id', cid)
1778
+ .eq('project_id', projectId)
1779
+ .maybeSingle();
1780
+ if (!contact)
1781
+ continue;
1782
+ const changes = diffCustomProperties(contact.custom_properties ?? null, normalizedPatch);
1783
+ const merged = {
1784
+ ...(contact.custom_properties ?? {}),
1785
+ ...normalizedPatch,
1786
+ };
1787
+ for (const [key, value] of Object.entries(normalizedPatch)) {
1788
+ if (value === null)
1789
+ delete merged[key];
1790
+ }
1791
+ const { error } = await db()
1792
+ .from('subscribers')
1793
+ .update({ custom_properties: merged, updated_at: new Date().toISOString() })
1794
+ .eq('id', cid)
1795
+ .eq('project_id', projectId);
1796
+ if (error)
1797
+ continue;
1798
+ updated++;
1799
+ if (changes.length > 0) {
1800
+ logAudit({
1801
+ action: 'contact.custom_properties_changed',
1802
+ resourceType: 'contact',
1803
+ resourceId: cid,
1804
+ details: { changes },
1805
+ });
1806
+ }
1807
+ await writeBackMcpContact(projectId, contact, { custom_properties: merged });
1808
+ }
1809
+ return j({ updated, total: contact_ids.length });
1810
+ });
1316
1811
  server.registerTool('estimate_segment_size', {
1317
1812
  title: 'Estimate Segment Size',
1318
1813
  annotations: {
@@ -1370,13 +1865,9 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1370
1865
  q = q.contains('tags', [params.tag]);
1371
1866
  if (params.company_id)
1372
1867
  q = q.eq('company_id', params.company_id);
1868
+ const fieldTypes = params.custom_property ? await loadContactPropertyDefinitions(projectId) : {};
1373
1869
  if (params.custom_property) {
1374
- const eqIdx = params.custom_property.indexOf('=');
1375
- if (eqIdx > 0) {
1376
- const key = params.custom_property.slice(0, eqIdx);
1377
- const val = params.custom_property.slice(eqIdx + 1);
1378
- q = q.contains('custom_properties', { [key]: val });
1379
- }
1870
+ ({ query: q } = await applySegmentRules(q, { custom_property: params.custom_property }, db(), projectId, fieldTypes));
1380
1871
  }
1381
1872
  if (params.site_id) {
1382
1873
  const { data: subs } = await db()
@@ -1431,6 +1922,9 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1431
1922
  countQ = countQ.is('last_clicked_at', null);
1432
1923
  if (params.tag)
1433
1924
  countQ = countQ.contains('tags', [params.tag]);
1925
+ if (params.custom_property) {
1926
+ ({ query: countQ } = await applySegmentRules(countQ, { custom_property: params.custom_property }, db(), projectId, fieldTypes));
1927
+ }
1434
1928
  const { count } = await countQ;
1435
1929
  return j({ total: count ?? (data ?? []).length, sample: data ?? [] });
1436
1930
  });
@@ -1801,4 +2295,238 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1801
2295
  note: 'Use merge_tag syntax in email body/subject. For sensitive data, add a fallback: {{custom.plan|subscriber}}. ⚠️ custom_properties data passes through the Anthropic API — avoid storing sensitive PII (SSNs, passwords, financial account numbers).',
1802
2296
  });
1803
2297
  });
2298
+ server.registerTool('create_custom_field', {
2299
+ title: 'Create Custom Field',
2300
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
2301
+ description: 'Register a typed custom field definition for contact custom properties and segment filtering.',
2302
+ inputSchema: {
2303
+ key: z.string().trim().min(1).max(50).regex(CUSTOM_FIELD_KEY_RE, 'must be lowercase snake_case, e.g. "plan_tier"'),
2304
+ label: z.string().trim().min(1),
2305
+ type: z.enum(CUSTOM_FIELD_TYPES),
2306
+ required: z.boolean().optional().default(false),
2307
+ options: z.array(z.string()).optional(),
2308
+ display_order: z.number().int().optional().default(0),
2309
+ },
2310
+ }, async ({ key, label, type, required, options, display_order }) => {
2311
+ const optionsError = validateCustomFieldOptions(type, options);
2312
+ if (optionsError)
2313
+ return txt(`Error: ${optionsError}`);
2314
+ const normalizedOptions = type === 'select' || type === 'multi_select'
2315
+ ? normalizeCustomFieldOptions(options)
2316
+ : null;
2317
+ const projectId = getProjectId();
2318
+ const orgId = await getProjectOrgId(projectId);
2319
+ const { data, error } = await db()
2320
+ .from('custom_field_definitions')
2321
+ .insert({
2322
+ org_id: orgId,
2323
+ project_id: projectId,
2324
+ key,
2325
+ label,
2326
+ type,
2327
+ required: required ?? false,
2328
+ options: normalizedOptions ?? null,
2329
+ display_order: display_order ?? 0,
2330
+ })
2331
+ .select('id, key, label, type, required, options, display_order')
2332
+ .single();
2333
+ if (error) {
2334
+ if (error.code === '23505')
2335
+ return txt(`Error: A custom field with key "${key}" already exists.`);
2336
+ return txt(`Error: ${error.message}`);
2337
+ }
2338
+ await logAudit({
2339
+ action: 'custom_field.created',
2340
+ resourceType: 'custom_field_definition',
2341
+ resourceId: data.id,
2342
+ details: { key },
2343
+ });
2344
+ return j(data);
2345
+ });
2346
+ server.registerTool('update_custom_field', {
2347
+ title: 'Update Custom Field',
2348
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
2349
+ description: 'Update a custom field definition label, type, required state, options, or display order. The field key is immutable.',
2350
+ inputSchema: {
2351
+ id: z.string().uuid(),
2352
+ label: z.string().trim().min(1).optional(),
2353
+ type: z.enum(CUSTOM_FIELD_TYPES).optional(),
2354
+ required: z.boolean().optional(),
2355
+ options: z.array(z.string()).optional(),
2356
+ display_order: z.number().int().optional(),
2357
+ },
2358
+ }, async ({ id, label, type, required, options, display_order }) => {
2359
+ const projectId = getProjectId();
2360
+ const { data: current, error: currentError } = await db()
2361
+ .from('custom_field_definitions')
2362
+ .select('id, key, label, type, required, options, display_order')
2363
+ .eq('id', id)
2364
+ .eq('project_id', projectId)
2365
+ .maybeSingle();
2366
+ if (currentError)
2367
+ return txt(`Error: ${currentError.message}`);
2368
+ if (!current)
2369
+ return j({ error: 'Custom field not found', code: 'NOT_FOUND' });
2370
+ const nextType = (type ?? current.type);
2371
+ const nextOptions = options === undefined ? current.options : options;
2372
+ const optionsError = validateCustomFieldOptions(nextType, nextOptions);
2373
+ if (optionsError)
2374
+ return txt(`Error: ${optionsError}`);
2375
+ const normalizedOptions = nextType === 'select' || nextType === 'multi_select'
2376
+ ? normalizeCustomFieldOptions(nextOptions)
2377
+ : null;
2378
+ const { data, error } = await db()
2379
+ .from('custom_field_definitions')
2380
+ .update({
2381
+ label: label ?? current.label,
2382
+ type: nextType,
2383
+ required: required ?? current.required,
2384
+ options: normalizedOptions,
2385
+ display_order: display_order ?? current.display_order,
2386
+ updated_at: new Date().toISOString(),
2387
+ })
2388
+ .eq('id', id)
2389
+ .eq('project_id', projectId)
2390
+ .select('id, key, label, type, required, options, display_order')
2391
+ .maybeSingle();
2392
+ if (error)
2393
+ return txt(`Error: ${error.message}`);
2394
+ if (!data)
2395
+ return j({ error: 'Custom field not found', code: 'NOT_FOUND' });
2396
+ await logAudit({
2397
+ action: 'custom_field.updated',
2398
+ resourceType: 'custom_field_definition',
2399
+ resourceId: data.id,
2400
+ details: { key: data.key },
2401
+ });
2402
+ return j(data);
2403
+ });
2404
+ server.registerTool('delete_custom_field', {
2405
+ title: 'Delete Custom Field',
2406
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
2407
+ description: 'Delete a custom field definition without deleting existing contact values stored under that key.',
2408
+ inputSchema: { id: z.string().uuid() },
2409
+ }, async ({ id }) => {
2410
+ const projectId = getProjectId();
2411
+ const { data, error } = await db()
2412
+ .from('custom_field_definitions')
2413
+ .delete()
2414
+ .eq('id', id)
2415
+ .eq('project_id', projectId)
2416
+ .select('id, key')
2417
+ .maybeSingle();
2418
+ if (error)
2419
+ return txt(`Error: ${error.message}`);
2420
+ if (!data)
2421
+ return j({ error: 'Custom field not found', code: 'NOT_FOUND' });
2422
+ await logAudit({
2423
+ action: 'custom_field.deleted',
2424
+ resourceType: 'custom_field_definition',
2425
+ resourceId: data.id,
2426
+ details: { key: data.key },
2427
+ });
2428
+ return j({ deleted: true, id: data.id, key: data.key });
2429
+ });
2430
+ server.registerTool('get_contact_neighborhood', {
2431
+ title: 'Get Contact Neighborhood',
2432
+ annotations: {
2433
+ readOnlyHint: true,
2434
+ destructiveHint: false,
2435
+ idempotentHint: true,
2436
+ openWorldHint: false,
2437
+ },
2438
+ description: 'Operational neighborhood graph for one contact: identity, hygiene bucket, suppression, subscriptions, in-flight enrollments, send queue, recent email_log, scheduled newsletter issues, and open agent proposals. Includes a preview attention envelope (decided: false) — not a send decision.',
2439
+ inputSchema: {
2440
+ contact_id: z.string().uuid(),
2441
+ window_hours: z.number().int().min(1).max(720).optional().default(168),
2442
+ },
2443
+ }, async ({ contact_id, window_hours }) => {
2444
+ const { data, error } = await getContactNeighborhood(db(), getProjectId(), contact_id, window_hours);
2445
+ if (error)
2446
+ return txt(`Error: ${error.message}`);
2447
+ if (data == null)
2448
+ return j({ error: 'Subscriber not found', code: 'NOT_FOUND' });
2449
+ return j({ ...data, attention: buildPreviewAttentionEnvelope() });
2450
+ });
2451
+ server.registerTool('get_scheduled_touches', {
2452
+ title: 'Get Scheduled Touches',
2453
+ annotations: {
2454
+ readOnlyHint: true,
2455
+ destructiveHint: false,
2456
+ idempotentHint: true,
2457
+ openWorldHint: false,
2458
+ },
2459
+ description: 'Cheap future-touch subset for one contact: enrollments with next_send_at in the window, queued send_queue rows, and scheduled newsletter issues. Includes a preview attention envelope (decided: false) — not a send decision.',
2460
+ inputSchema: {
2461
+ contact_id: z.string().uuid(),
2462
+ window_hours: z.number().int().min(1).max(720).optional().default(168),
2463
+ },
2464
+ }, async ({ contact_id, window_hours }) => {
2465
+ const { data, error } = await getScheduledTouches(db(), getProjectId(), contact_id, window_hours);
2466
+ if (error)
2467
+ return txt(`Error: ${error.message}`);
2468
+ if (data == null)
2469
+ return j({ error: 'Subscriber not found', code: 'NOT_FOUND' });
2470
+ return j({ ...data, attention: buildPreviewAttentionEnvelope() });
2471
+ });
2472
+ server.registerTool('get_attention_decision', {
2473
+ title: 'Get Attention Decision',
2474
+ annotations: {
2475
+ readOnlyHint: true,
2476
+ destructiveHint: false,
2477
+ idempotentHint: true,
2478
+ openWorldHint: false,
2479
+ },
2480
+ description: 'Poll for the canonical enqueue-time attention decision (decided: true) on a send or hold. Provide email_log_id for a respond decision, or enrollment_id for a hold with no log row. Returns { decided: false } if the target exists but no decision has been written yet — do not treat launch_campaign / enroll_* / broadcast_to_segment success as a send decision.',
2481
+ inputSchema: {
2482
+ email_log_id: z.string().uuid().optional(),
2483
+ enrollment_id: z.string().uuid().optional(),
2484
+ },
2485
+ }, async ({ email_log_id, enrollment_id }) => {
2486
+ if (!email_log_id && !enrollment_id) {
2487
+ return j({ error: 'Provide email_log_id or enrollment_id', code: 'BAD_REQUEST' });
2488
+ }
2489
+ const { data, error } = await getAttentionDecision(db(), getProjectId(), {
2490
+ emailLogId: email_log_id,
2491
+ enrollmentId: enrollment_id,
2492
+ });
2493
+ if (error)
2494
+ return txt(`Error: ${error.message}`);
2495
+ if (data == null)
2496
+ return j({ error: 'Not found', code: 'NOT_FOUND' });
2497
+ return j(data);
2498
+ });
2499
+ // Resource registration is additive; mcp-proxy may ignore it. Skip safely when
2500
+ // the server proxy (tool-group scanner) does not expose registerResource.
2501
+ const registerResource = server.registerResource?.bind(server);
2502
+ if (typeof registerResource === 'function') {
2503
+ registerResource('contact-neighborhood', new ResourceTemplate('sendinel://contacts/{contact_id}/neighborhood', { list: undefined }), {
2504
+ title: 'Contact neighborhood',
2505
+ description: 'Operational graph for one contact (preview, not a send decision).',
2506
+ }, async (uri, vars) => {
2507
+ const contactId = String(vars.contact_id ?? '');
2508
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(contactId)) {
2509
+ return {
2510
+ contents: [{
2511
+ uri: uri.href,
2512
+ mimeType: 'application/json',
2513
+ text: JSON.stringify({ error: 'Invalid contact_id', code: 'BAD_REQUEST' }),
2514
+ }],
2515
+ };
2516
+ }
2517
+ const { data, error } = await getContactNeighborhood(db(), getProjectId(), contactId);
2518
+ const payload = error
2519
+ ? { error: error.message }
2520
+ : data == null
2521
+ ? { error: 'Subscriber not found', code: 'NOT_FOUND' }
2522
+ : data;
2523
+ return {
2524
+ contents: [{
2525
+ uri: uri.href,
2526
+ mimeType: 'application/json',
2527
+ text: JSON.stringify(payload, null, 2),
2528
+ }],
2529
+ };
2530
+ });
2531
+ }
1804
2532
  }