@sendinel/mcp-server 1.0.9 → 1.0.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -22
- package/build/anthropic-model.d.ts +30 -0
- package/build/anthropic-model.js +26 -1
- package/build/db.d.ts +17 -0
- package/build/db.js +15 -1
- package/build/index.js +6 -0
- package/build/lib/action-approvals.js +28 -0
- package/build/lib/brand-asset-persist.d.ts +1 -0
- package/build/lib/brand-asset-persist.js +20 -1
- package/build/lib/composer-blocks.d.ts +25 -25
- package/build/lib/composer-blocks.js +25 -5
- package/build/lib/connector-table-discovery.d.ts +12 -0
- package/build/lib/connector-table-discovery.js +117 -0
- package/build/lib/custom-properties-diff.d.ts +16 -0
- package/build/lib/custom-properties-diff.js +22 -0
- package/build/lib/schedule.d.ts +7 -2
- package/build/lib/schedule.js +8 -0
- package/build/lib/v1-client.d.ts +3 -0
- package/build/lib/v1-client.js +45 -0
- package/build/lib/voice.d.ts +7 -30
- package/build/lib/voice.js +6 -138
- package/build/prompts.d.ts +6 -0
- package/build/prompts.js +18 -0
- package/build/scoped-token.d.ts +1 -1
- package/build/server.d.ts +1 -0
- package/build/server.js +65 -6
- package/build/tools/analytics.js +97 -0
- package/build/tools/brand-kit.js +29 -30
- package/build/tools/campaigns.js +326 -52
- package/build/tools/cloud-import.d.ts +2 -0
- package/build/tools/cloud-import.js +76 -0
- package/build/tools/compound.d.ts +11 -0
- package/build/tools/compound.js +37 -7
- package/build/tools/connector.d.ts +2 -0
- package/build/tools/connector.js +280 -0
- package/build/tools/contacts.js +744 -16
- package/build/tools/content.js +228 -0
- package/build/tools/data.js +441 -0
- package/build/tools/delivery-ops.js +508 -1
- package/build/tools/forms.js +7 -6
- package/build/tools/gateway.d.ts +6 -0
- package/build/tools/gateway.js +92 -0
- package/build/tools/gdpr.js +185 -0
- package/build/tools/landing-pages.d.ts +2 -0
- package/build/tools/landing-pages.js +202 -0
- package/build/tools/lists.d.ts +2 -0
- package/build/tools/lists.js +428 -0
- package/build/tools/newsletters.js +1 -8
- package/build/tools/org.js +549 -33
- package/build/tools/segments.js +35 -184
- package/build/tools/signatures.d.ts +2 -0
- package/build/tools/signatures.js +168 -0
- package/build/tools/sites.js +3 -1
- package/build/tools/skills.d.ts +2 -0
- package/build/tools/skills.js +29 -0
- package/build/tools/templates.js +133 -7
- package/package.json +14 -13
package/build/tools/contacts.js
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
745
|
-
|
|
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
|
-
...
|
|
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
|
-
|
|
1128
|
-
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
}
|