@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
@@ -0,0 +1,428 @@
1
+ import { z } from 'zod';
2
+ import { applySegmentRules, buildSegmentBaseQuery, evaluateSegmentRules, normalizeSegmentRulesToTree, } from '@sendinel/domain';
3
+ import { db } from '../db.js';
4
+ import { getProjectId } from '../project.js';
5
+ import { logAudit } from '../audit.js';
6
+ import { assertChannelCanExecute, checkApproval, currentChannelContext, requestApproval, } from '../lib/action-approvals.js';
7
+ const LIST_MEMBER_BATCH_SIZE = 500;
8
+ const txt = (text) => ({ content: [{ type: 'text', text }] });
9
+ const j = (data) => txt(JSON.stringify(data, null, 2));
10
+ async function loadFieldTypes(projectId) {
11
+ const client = db();
12
+ const scoped = typeof client.schema === 'function' ? client.schema('email') : client;
13
+ const { data, error } = await scoped
14
+ .from('custom_field_definitions')
15
+ .select('key, type, options')
16
+ .eq('project_id', projectId);
17
+ if (error) {
18
+ console.warn('[lists] custom field definition load failed:', error.message ?? error);
19
+ return {};
20
+ }
21
+ const fields = {};
22
+ for (const row of data ?? []) {
23
+ if (typeof row.key !== 'string' || typeof row.type !== 'string')
24
+ continue;
25
+ fields[row.key] = { type: row.type, options: Array.isArray(row.options) ? row.options : null };
26
+ }
27
+ return fields;
28
+ }
29
+ async function getSegmentContactIds(rules) {
30
+ const projectId = getProjectId();
31
+ const tree = normalizeSegmentRulesToTree(rules);
32
+ const fieldTypes = rules.custom_property ? await loadFieldTypes(projectId) : {};
33
+ const q = buildSegmentBaseQuery(db(), projectId);
34
+ const { query: filtered, siteContactIds } = await applySegmentRules(q, rules, db(), projectId, fieldTypes);
35
+ if (siteContactIds && siteContactIds.length === 0)
36
+ return [];
37
+ const { data, error } = await filtered;
38
+ if (error || !data)
39
+ return [];
40
+ const contacts = data.filter((contact) => evaluateSegmentRules(contact, tree, fieldTypes));
41
+ let ids = contacts.map((contact) => String(contact.id));
42
+ if (tree.exclude_suppressed !== false && ids.length > 0) {
43
+ const { data: suppressed } = await db()
44
+ .from('suppressions')
45
+ .select('email')
46
+ .eq('project_id', projectId);
47
+ if (suppressed?.length) {
48
+ const suppressedEmails = new Set(suppressed.map((row) => row.email));
49
+ ids = contacts
50
+ .filter((contact) => !suppressedEmails.has(String(contact.email)))
51
+ .map((contact) => String(contact.id));
52
+ }
53
+ }
54
+ return ids;
55
+ }
56
+ async function readValidContactIds(projectId, contactIds) {
57
+ const { data, error } = await db()
58
+ .from('subscribers')
59
+ .select('id')
60
+ .eq('project_id', projectId)
61
+ .in('id', contactIds);
62
+ return { ids: (data ?? []).map((row) => row.id), error };
63
+ }
64
+ async function readMemberIds(listId, contactIds) {
65
+ if (contactIds.length === 0)
66
+ return { ids: [], error: null };
67
+ const { data, error } = await db()
68
+ .from('contact_list_members')
69
+ .select('contact_id')
70
+ .eq('list_id', listId)
71
+ .in('contact_id', contactIds);
72
+ return { ids: (data ?? []).map((row) => row.contact_id), error };
73
+ }
74
+ function difference(ids, excludedIds) {
75
+ const excluded = new Set(excludedIds);
76
+ return ids.filter((id) => !excluded.has(id));
77
+ }
78
+ export function registerListTools(server) {
79
+ server.registerTool('list_lists', {
80
+ title: 'List Contact Lists',
81
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
82
+ description: 'List static contact lists for the current project, including grouped member counts.',
83
+ inputSchema: {
84
+ search: z.string().optional(),
85
+ limit: z.number().int().min(1).max(200).optional().default(50),
86
+ },
87
+ }, async ({ search, limit }) => {
88
+ const projectId = getProjectId();
89
+ let query = db()
90
+ .from('contact_lists')
91
+ .select('id, name, description, created_at, updated_at')
92
+ .eq('project_id', projectId);
93
+ const trimmedSearch = search?.trim();
94
+ if (trimmedSearch)
95
+ query = query.ilike('name', `%${trimmedSearch}%`);
96
+ const { data, error } = await query
97
+ .order('created_at', { ascending: false })
98
+ .limit(limit);
99
+ if (error)
100
+ return txt(`Error: ${error.message}`);
101
+ const lists = data ?? [];
102
+ const listIds = lists.map((list) => list.id);
103
+ const countMap = new Map();
104
+ if (listIds.length > 0) {
105
+ const { data: counts, error: countError } = await db().rpc('get_list_member_counts', { p_list_ids: listIds });
106
+ if (countError)
107
+ return txt(`Error: ${countError.message}`);
108
+ for (const row of counts ?? []) {
109
+ countMap.set(row.list_id, Number(row.member_count));
110
+ }
111
+ }
112
+ return j(lists.map((list) => ({
113
+ ...list,
114
+ member_count: countMap.get(list.id) ?? 0,
115
+ })));
116
+ });
117
+ server.registerTool('create_list', {
118
+ title: 'Create Contact List',
119
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
120
+ description: 'Create a static contact list for the current project.',
121
+ inputSchema: {
122
+ name: z.string().min(1).max(200),
123
+ description: z.string().optional(),
124
+ },
125
+ }, async ({ name, description }) => {
126
+ const projectId = getProjectId();
127
+ const normalizedName = name.trim();
128
+ const { data, error } = await db()
129
+ .from('contact_lists')
130
+ .insert({
131
+ project_id: projectId,
132
+ name: normalizedName,
133
+ description: description?.trim() ?? null,
134
+ })
135
+ .select('id, name, description, created_at')
136
+ .single();
137
+ if (error)
138
+ return txt(`Error: ${error.message}`);
139
+ await logAudit({
140
+ action: 'list.created',
141
+ resourceType: 'list',
142
+ resourceId: data.id,
143
+ details: { name: normalizedName },
144
+ });
145
+ return j(data);
146
+ });
147
+ server.registerTool('update_list', {
148
+ title: 'Update Contact List',
149
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
150
+ description: 'Update the name or description of a static contact list.',
151
+ inputSchema: {
152
+ list_id: z.string().uuid(),
153
+ name: z.string().min(1).max(200).optional(),
154
+ description: z.string().optional(),
155
+ },
156
+ }, async ({ list_id, name, description }) => {
157
+ if (name === undefined && description === undefined)
158
+ return txt('Nothing to update');
159
+ const projectId = getProjectId();
160
+ const updates = { updated_at: new Date().toISOString() };
161
+ if (name !== undefined)
162
+ updates.name = name.trim();
163
+ if (description !== undefined)
164
+ updates.description = description.trim();
165
+ const { data, error } = await db()
166
+ .from('contact_lists')
167
+ .update(updates)
168
+ .eq('id', list_id)
169
+ .eq('project_id', projectId)
170
+ .select('id, name, description, updated_at')
171
+ .maybeSingle();
172
+ if (error)
173
+ return txt(`Error: ${error.message}`);
174
+ if (!data)
175
+ return txt('List not found');
176
+ await logAudit({
177
+ action: 'list.updated',
178
+ resourceType: 'list',
179
+ resourceId: list_id,
180
+ details: { name: name?.trim(), description: description?.trim() ?? null },
181
+ });
182
+ return j(data);
183
+ });
184
+ server.registerTool('delete_list', {
185
+ title: 'Delete Contact List',
186
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
187
+ description: 'Permanently delete a static contact list and its memberships.',
188
+ inputSchema: { list_id: z.string().uuid() },
189
+ }, async ({ list_id }) => {
190
+ const projectId = getProjectId();
191
+ const { error } = await db()
192
+ .from('contact_lists')
193
+ .delete()
194
+ .eq('id', list_id)
195
+ .eq('project_id', projectId);
196
+ if (error)
197
+ return txt(`Error: ${error.message}`);
198
+ await logAudit({ action: 'list.deleted', resourceType: 'list', resourceId: list_id });
199
+ return j({ deleted: true, list_id });
200
+ });
201
+ server.registerTool('add_list_members', {
202
+ title: 'Add List Members',
203
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
204
+ description: 'Add contacts to a static list after previewing the affected count and completing the standard bulk-action approval flow.',
205
+ inputSchema: {
206
+ list_id: z.string().uuid(),
207
+ contact_ids: z.array(z.string().uuid()).min(1),
208
+ confirmed: z.boolean().optional().default(false),
209
+ approval_id: z.string().uuid().optional(),
210
+ },
211
+ }, async ({ list_id, contact_ids, confirmed, approval_id }) => {
212
+ const projectId = getProjectId();
213
+ const approvalParams = { list_id, contact_ids };
214
+ const { data: list, error: listError } = await db()
215
+ .from('contact_lists')
216
+ .select('id, name')
217
+ .eq('id', list_id)
218
+ .eq('project_id', projectId)
219
+ .maybeSingle();
220
+ if (listError)
221
+ return j({ error: listError.message });
222
+ if (!list)
223
+ return j({ error: 'List not found', code: 'LIST_NOT_FOUND' });
224
+ const validResult = await readValidContactIds(projectId, contact_ids);
225
+ if (validResult.error)
226
+ return j({ error: validResult.error.message });
227
+ const memberResult = await readMemberIds(list_id, validResult.ids);
228
+ if (memberResult.error)
229
+ return j({ error: memberResult.error.message });
230
+ const wouldAdd = difference(validResult.ids, memberResult.ids);
231
+ if (!confirmed) {
232
+ return j({
233
+ confirmation_required: true,
234
+ tool: 'add_list_members',
235
+ list: { id: list.id, name: list.name },
236
+ requested: contact_ids.length,
237
+ valid_contacts: validResult.ids.length,
238
+ already_members: memberResult.ids.length,
239
+ would_add: wouldAdd.length,
240
+ message: `Adding ${contact_ids.length} contact(s) to list "${list.name}" will add ${wouldAdd.length} new member(s) (${memberResult.ids.length} already members). Call again with confirmed=true to proceed.`,
241
+ });
242
+ }
243
+ const channelContext = currentChannelContext();
244
+ const decision = assertChannelCanExecute('add_list_members', approvalParams, channelContext);
245
+ if (!decision.allowed)
246
+ return txt(decision.reason ?? 'This channel cannot request this action.');
247
+ if (decision.tier !== 'autonomous') {
248
+ if (!approval_id)
249
+ return j(await requestApproval('add_list_members', approvalParams, undefined, channelContext));
250
+ const check = await checkApproval(approval_id, { consume: true, action: 'add_list_members', params: approvalParams });
251
+ if (!check.approved) {
252
+ return txt(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
253
+ }
254
+ }
255
+ const freshValidResult = await readValidContactIds(projectId, contact_ids);
256
+ if (freshValidResult.error)
257
+ return j({ error: freshValidResult.error.message });
258
+ const freshMemberResult = await readMemberIds(list_id, freshValidResult.ids);
259
+ if (freshMemberResult.error)
260
+ return j({ error: freshMemberResult.error.message });
261
+ const freshWouldAdd = difference(freshValidResult.ids, freshMemberResult.ids);
262
+ let added = 0;
263
+ if (freshWouldAdd.length > 0) {
264
+ const { data: inserted, error } = await db()
265
+ .from('contact_list_members')
266
+ .upsert(freshWouldAdd.map((contact_id) => ({ list_id, contact_id })), {
267
+ onConflict: 'list_id,contact_id',
268
+ ignoreDuplicates: true,
269
+ })
270
+ .select('id');
271
+ if (error)
272
+ return j({ error: error.message });
273
+ added = inserted?.length ?? 0;
274
+ }
275
+ await logAudit({
276
+ action: 'list.member_added',
277
+ resourceType: 'list',
278
+ resourceId: list_id,
279
+ details: { contact_ids: added },
280
+ });
281
+ return j({ ok: true, added, list_id });
282
+ });
283
+ server.registerTool('remove_list_members', {
284
+ title: 'Remove List Members',
285
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
286
+ description: 'Remove contacts from a static list after previewing the affected count and completing the standard bulk-action approval flow.',
287
+ inputSchema: {
288
+ list_id: z.string().uuid(),
289
+ contact_ids: z.array(z.string().uuid()).min(1),
290
+ confirmed: z.boolean().optional().default(false),
291
+ approval_id: z.string().uuid().optional(),
292
+ },
293
+ }, async ({ list_id, contact_ids, confirmed, approval_id }) => {
294
+ const projectId = getProjectId();
295
+ const approvalParams = { list_id, contact_ids };
296
+ const { data: list, error: listError } = await db()
297
+ .from('contact_lists')
298
+ .select('id, name')
299
+ .eq('id', list_id)
300
+ .eq('project_id', projectId)
301
+ .maybeSingle();
302
+ if (listError)
303
+ return j({ error: listError.message });
304
+ if (!list)
305
+ return j({ error: 'List not found', code: 'LIST_NOT_FOUND' });
306
+ const memberResult = await readMemberIds(list_id, contact_ids);
307
+ if (memberResult.error)
308
+ return j({ error: memberResult.error.message });
309
+ if (!confirmed) {
310
+ return j({
311
+ confirmation_required: true,
312
+ tool: 'remove_list_members',
313
+ list: { id: list.id, name: list.name },
314
+ requested: contact_ids.length,
315
+ would_remove: memberResult.ids.length,
316
+ message: `Removing ${contact_ids.length} contact(s) from list "${list.name}" will remove ${memberResult.ids.length} current member(s). Call again with confirmed=true to proceed.`,
317
+ });
318
+ }
319
+ const channelContext = currentChannelContext();
320
+ const decision = assertChannelCanExecute('remove_list_members', approvalParams, channelContext);
321
+ if (!decision.allowed)
322
+ return txt(decision.reason ?? 'This channel cannot request this action.');
323
+ if (decision.tier !== 'autonomous') {
324
+ if (!approval_id)
325
+ return j(await requestApproval('remove_list_members', approvalParams, undefined, channelContext));
326
+ const check = await checkApproval(approval_id, { consume: true, action: 'remove_list_members', params: approvalParams });
327
+ if (!check.approved) {
328
+ return txt(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
329
+ }
330
+ }
331
+ const freshMemberResult = await readMemberIds(list_id, contact_ids);
332
+ if (freshMemberResult.error)
333
+ return j({ error: freshMemberResult.error.message });
334
+ const removed = freshMemberResult.ids.length;
335
+ if (removed > 0) {
336
+ const { error } = await db()
337
+ .from('contact_list_members')
338
+ .delete()
339
+ .eq('list_id', list_id)
340
+ .in('contact_id', freshMemberResult.ids);
341
+ if (error)
342
+ return j({ error: error.message });
343
+ }
344
+ await logAudit({
345
+ action: 'list.member_removed',
346
+ resourceType: 'list',
347
+ resourceId: list_id,
348
+ details: { contact_ids: removed },
349
+ });
350
+ return j({ ok: true, removed, list_id });
351
+ });
352
+ server.registerTool('add_list_members_by_filter', {
353
+ title: 'Add List Members By Filter',
354
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
355
+ description: 'Add every eligible contact matching segment rules after previewing the affected count and completing the standard bulk-action approval flow.',
356
+ inputSchema: {
357
+ list_id: z.string().uuid(),
358
+ rules: z.record(z.string(), z.unknown()),
359
+ confirmed: z.boolean().optional().default(false),
360
+ approval_id: z.string().uuid().optional(),
361
+ },
362
+ }, async ({ list_id, rules, confirmed, approval_id }) => {
363
+ const projectId = getProjectId();
364
+ const normalizedRules = normalizeSegmentRulesToTree(rules);
365
+ const approvalParams = { list_id, rules: normalizedRules };
366
+ const { data: list, error: listError } = await db()
367
+ .from('contact_lists')
368
+ .select('id, name')
369
+ .eq('id', list_id)
370
+ .eq('project_id', projectId)
371
+ .maybeSingle();
372
+ if (listError)
373
+ return j({ error: listError.message });
374
+ if (!list)
375
+ return j({ error: 'List not found', code: 'LIST_NOT_FOUND' });
376
+ const matchedIds = await getSegmentContactIds(normalizedRules);
377
+ const memberResult = await readMemberIds(list_id, matchedIds);
378
+ if (memberResult.error)
379
+ return j({ error: memberResult.error.message });
380
+ const wouldAdd = difference(matchedIds, memberResult.ids);
381
+ if (!confirmed) {
382
+ return j({
383
+ confirmation_required: true,
384
+ tool: 'add_list_members_by_filter',
385
+ list: { id: list.id, name: list.name },
386
+ matched: matchedIds.length,
387
+ already_members: memberResult.ids.length,
388
+ would_add: wouldAdd.length,
389
+ message: `Filter matches ${matchedIds.length} contact(s); ${wouldAdd.length} would be newly added to list "${list.name}". Call again with confirmed=true to proceed.`,
390
+ });
391
+ }
392
+ const channelContext = currentChannelContext();
393
+ const decision = assertChannelCanExecute('add_list_members_by_filter', approvalParams, channelContext);
394
+ if (!decision.allowed)
395
+ return txt(decision.reason ?? 'This channel cannot request this action.');
396
+ if (decision.tier !== 'autonomous') {
397
+ if (!approval_id)
398
+ return j(await requestApproval('add_list_members_by_filter', approvalParams, undefined, channelContext));
399
+ const check = await checkApproval(approval_id, { consume: true, action: 'add_list_members_by_filter', params: approvalParams });
400
+ if (!check.approved) {
401
+ return txt(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
402
+ }
403
+ }
404
+ const freshMatchedIds = await getSegmentContactIds(normalizedRules);
405
+ const freshMemberResult = await readMemberIds(list_id, freshMatchedIds);
406
+ if (freshMemberResult.error)
407
+ return j({ error: freshMemberResult.error.message });
408
+ const freshWouldAdd = difference(freshMatchedIds, freshMemberResult.ids);
409
+ let added = 0;
410
+ for (let index = 0; index < freshWouldAdd.length; index += LIST_MEMBER_BATCH_SIZE) {
411
+ const rows = freshWouldAdd.slice(index, index + LIST_MEMBER_BATCH_SIZE).map((contact_id) => ({ list_id, contact_id }));
412
+ const { data: inserted, error } = await db()
413
+ .from('contact_list_members')
414
+ .upsert(rows, { onConflict: 'list_id,contact_id', ignoreDuplicates: true })
415
+ .select('id');
416
+ if (error)
417
+ return j({ error: error.message });
418
+ added += inserted?.length ?? 0;
419
+ }
420
+ await logAudit({
421
+ action: 'list.members_added_by_filter',
422
+ resourceType: 'list',
423
+ resourceId: list_id,
424
+ details: { added, matched: freshMatchedIds.length, source: 'filter' },
425
+ });
426
+ return j({ ok: true, added, matched: freshMatchedIds.length, list_id });
427
+ });
428
+ }
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { db } from '../db.js';
3
3
  import { getProjectId } from '../project.js';
4
+ import { parseFutureDate } from '../lib/schedule.js';
4
5
  const txt = (text) => ({ content: [{ type: 'text', text }] });
5
6
  const j = (data) => txt(JSON.stringify(data, null, 2));
6
7
  const NEWSLETTER_COLUMNS = 'id, org_id, project_id, site_id, name, slug, description, sender_name, sender_email, reply_to, cadence, branding, public_signup_enabled, double_opt_in, status, subscriber_count, source, source_payload, created_at, updated_at';
@@ -144,14 +145,6 @@ async function ensureNewsletterCampaign(newsletter, issue, projectId, scheduledA
144
145
  return { error: statusError.message };
145
146
  return { campaignId, enrolled: rows.length };
146
147
  }
147
- function parseFutureDate(value) {
148
- const date = new Date(value);
149
- if (Number.isNaN(date.getTime()))
150
- return { error: 'scheduled_at must be a valid ISO timestamp' };
151
- if (date.getTime() <= Date.now())
152
- return { error: 'scheduled_at must be in the future' };
153
- return { scheduledAt: date.toISOString() };
154
- }
155
148
  export function registerNewsletterTools(server) {
156
149
  server.registerTool('list_newsletters', {
157
150
  title: 'List Newsletters',