@neschadin/sendgrid-mcp 0.0.0-stage → 3.0.0

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.
@@ -0,0 +1,602 @@
1
+ import type { McpServer } from '@modelcontextprotocol/server';
2
+ import { z } from 'zod';
3
+ import type { SendGridClient } from '../client';
4
+ import { ensureSafeToolRegistration, ListPagingInputFields, ReadInputFields } from './tool_utils';
5
+ import {
6
+ ListTemplatesOutputSchema,
7
+ TemplateHtmlOutputSchema,
8
+ jsonReadResult,
9
+ paginateArray,
10
+ } from './output_schemas';
11
+
12
+ const ConfirmTokenSchema = z
13
+ .literal('CONFIRM')
14
+ .describe('Required for mutating SendGrid template state');
15
+
16
+ function requireConfirm(confirmToken: 'CONFIRM' | undefined, action: string) {
17
+ if (confirmToken !== 'CONFIRM') {
18
+ throw new Error(`Set confirmToken="CONFIRM" to ${action}.`);
19
+ }
20
+ }
21
+
22
+ export function registerTemplateTools(
23
+ server: McpServer,
24
+ client: SendGridClient,
25
+ ) {
26
+ ensureSafeToolRegistration(server);
27
+ const TemplateName = z
28
+ .string()
29
+ .min(1)
30
+ .max(100)
31
+ .describe('Template name (max 100 chars)');
32
+
33
+ server.registerTool(
34
+ 'list_templates',
35
+ {
36
+ description:
37
+ 'List all dynamic SendGrid templates with their IDs, names, and version counts',
38
+ inputSchema: z.object({ ...ListPagingInputFields }),
39
+ outputSchema: ListTemplatesOutputSchema,
40
+ },
41
+ async (params) => {
42
+ const result = await client.listAllDynamicTemplates(200);
43
+ const templates = result.map((t) => {
44
+ const active = t.versions.find((v) => v.active === 1);
45
+ return {
46
+ id: t.id,
47
+ name: t.name,
48
+ versions: t.versions.length,
49
+ activeSubject: active?.subject ?? null,
50
+ updatedAt: t.updated_at,
51
+ };
52
+ });
53
+ const { items, pagination } = paginateArray(
54
+ templates,
55
+ params.limit,
56
+ params.offset,
57
+ );
58
+ const rows = items.map(
59
+ (t) =>
60
+ `• [${t.id}] ${t.name} (versions: ${t.versions}, active subject: "${t.activeSubject ?? '—'}", updated: ${t.updatedAt})`,
61
+ );
62
+ return jsonReadResult(
63
+ { ...pagination, templates: items },
64
+ items.length === 0
65
+ ? 'No dynamic templates found.'
66
+ : `Found ${pagination.total_count} dynamic template(s):\n\n${rows.join('\n')}`,
67
+ params.response_format,
68
+ );
69
+ },
70
+ );
71
+
72
+ server.registerTool(
73
+ 'rename_template',
74
+ {
75
+ description:
76
+ 'Rename a dynamic template (updates the template name, not a version label)',
77
+ inputSchema: z.object({
78
+ confirmToken: ConfirmTokenSchema,
79
+ templateId: z.string().describe('Template ID, e.g. d-xxxxxxxxxxxxxxxx'),
80
+ newName: TemplateName.describe('New template name'),
81
+ }),
82
+ },
83
+ async ({ confirmToken, templateId, newName }) => {
84
+ requireConfirm(confirmToken, 'rename a template');
85
+ const updated = await client.updateTemplate(templateId, {
86
+ name: newName,
87
+ });
88
+ return {
89
+ content: [
90
+ {
91
+ type: 'text',
92
+ text: `✅ Renamed template ${updated.id} → "${updated.name}" (updated: ${updated.updated_at})`,
93
+ },
94
+ ],
95
+ };
96
+ },
97
+ );
98
+
99
+ server.registerTool(
100
+ 'rename_templates_bulk',
101
+ {
102
+ description:
103
+ 'Bulk rename templates by ID and/or current name. Supports dry-run and stop-on-error.',
104
+ inputSchema: z.object({
105
+ renames: z
106
+ .array(
107
+ z.object({
108
+ templateId: z
109
+ .string()
110
+ .optional()
111
+ .describe('Template ID to rename'),
112
+ oldName: z
113
+ .string()
114
+ .optional()
115
+ .describe('Current template name to match'),
116
+ newName: TemplateName.describe('New template name'),
117
+ }),
118
+ )
119
+ .min(1)
120
+ .describe(
121
+ 'Rename operations. Provide templateId or oldName for each item.',
122
+ ),
123
+ dryRun: z
124
+ .boolean()
125
+ .optional()
126
+ .describe('If true, only print planned changes'),
127
+ confirmToken: ConfirmTokenSchema.optional(),
128
+ stopOnError: z
129
+ .boolean()
130
+ .optional()
131
+ .describe('If true, stop after the first failed rename'),
132
+ requireUniqueOldName: z
133
+ .boolean()
134
+ .optional()
135
+ .describe('If true, oldName must match exactly one template'),
136
+ }),
137
+ },
138
+ async ({
139
+ renames,
140
+ dryRun,
141
+ confirmToken,
142
+ stopOnError,
143
+ requireUniqueOldName,
144
+ }) => {
145
+ const wantDryRun = dryRun ?? false;
146
+ if (!wantDryRun) {
147
+ requireConfirm(confirmToken, 'rename templates in bulk');
148
+ }
149
+ const wantStopOnError = stopOnError ?? false;
150
+ const wantRequireUnique = requireUniqueOldName ?? true;
151
+
152
+ const templates = await client.listAllDynamicTemplates(200);
153
+ const byId = new Map(templates.map((t) => [t.id, t]));
154
+ const byName = new Map<string, typeof templates>();
155
+ for (const t of templates) {
156
+ const arr = byName.get(t.name) ?? [];
157
+ arr.push(t);
158
+ byName.set(t.name, arr);
159
+ }
160
+
161
+ type PlanItem =
162
+ | { kind: 'ok'; templateId: string; oldName: string; newName: string }
163
+ | {
164
+ kind: 'skip';
165
+ reason: string;
166
+ templateId?: string;
167
+ oldName?: string;
168
+ newName: string;
169
+ }
170
+ | {
171
+ kind: 'error';
172
+ reason: string;
173
+ templateId?: string;
174
+ oldName?: string;
175
+ newName: string;
176
+ };
177
+
178
+ const plan: PlanItem[] = [];
179
+
180
+ for (const r of renames) {
181
+ if (!r.templateId && !r.oldName) {
182
+ plan.push({
183
+ kind: 'error',
184
+ reason: 'missing both templateId and oldName',
185
+ newName: r.newName,
186
+ });
187
+ if (wantStopOnError) break;
188
+ continue;
189
+ }
190
+
191
+ let resolved = r.templateId ? byId.get(r.templateId) : undefined;
192
+ if (!resolved && r.oldName) {
193
+ const matches = byName.get(r.oldName) ?? [];
194
+ if (matches.length === 0) {
195
+ plan.push({
196
+ kind: 'error',
197
+ reason: `oldName not found: "${r.oldName}"`,
198
+ oldName: r.oldName,
199
+ newName: r.newName,
200
+ });
201
+ if (wantStopOnError) break;
202
+ continue;
203
+ }
204
+ if (wantRequireUnique && matches.length !== 1) {
205
+ plan.push({
206
+ kind: 'error',
207
+ reason: `oldName is not unique ("${r.oldName}" matches ${matches.length} templates: ${matches
208
+ .map((m) => m.id)
209
+ .join(', ')})`,
210
+ oldName: r.oldName,
211
+ newName: r.newName,
212
+ });
213
+ if (wantStopOnError) break;
214
+ continue;
215
+ }
216
+ resolved = matches[0];
217
+ }
218
+
219
+ if (!resolved) {
220
+ plan.push({
221
+ kind: 'error',
222
+ reason: `templateId not found: "${r.templateId}"`,
223
+ templateId: r.templateId,
224
+ oldName: r.oldName,
225
+ newName: r.newName,
226
+ });
227
+ if (wantStopOnError) break;
228
+ continue;
229
+ }
230
+
231
+ if (resolved.name === r.newName) {
232
+ plan.push({
233
+ kind: 'skip',
234
+ reason: 'no-op (already has that name)',
235
+ templateId: resolved.id,
236
+ oldName: resolved.name,
237
+ newName: r.newName,
238
+ });
239
+ continue;
240
+ }
241
+
242
+ plan.push({
243
+ kind: 'ok',
244
+ templateId: resolved.id,
245
+ oldName: resolved.name,
246
+ newName: r.newName,
247
+ });
248
+ }
249
+
250
+ const lines: string[] = [];
251
+ const okCount = plan.filter((p) => p.kind === 'ok').length;
252
+ const skipCount = plan.filter((p) => p.kind === 'skip').length;
253
+ const errCount = plan.filter((p) => p.kind === 'error').length;
254
+
255
+ if (wantDryRun) {
256
+ lines.push(
257
+ `🧪 Dry run. Planned: ok=${okCount}, skip=${skipCount}, error=${errCount}`,
258
+ );
259
+ for (const p of plan) {
260
+ if (p.kind === 'ok')
261
+ lines.push(
262
+ `- ✅ [${p.templateId}] "${p.oldName}" → "${p.newName}"`,
263
+ );
264
+ if (p.kind === 'skip')
265
+ lines.push(
266
+ `- ⏭ [${p.templateId ?? '—'}] ${p.reason}: "${p.oldName ?? p.oldName ?? '—'}"`,
267
+ );
268
+ if (p.kind === 'error')
269
+ lines.push(
270
+ `- ❌ ${p.reason} (templateId=${p.templateId ?? '—'}, oldName=${p.oldName ?? '—'}, newName="${p.newName}")`,
271
+ );
272
+ }
273
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
274
+ }
275
+
276
+ lines.push(
277
+ `Executing renames: ok=${okCount}, skip=${skipCount}, error=${errCount}`,
278
+ );
279
+
280
+ const results: Array<{
281
+ templateId: string;
282
+ status: 'renamed' | 'failed';
283
+ message: string;
284
+ }> = [];
285
+ for (const p of plan) {
286
+ if (p.kind !== 'ok') continue;
287
+ try {
288
+ const updated = await client.updateTemplate(p.templateId, {
289
+ name: p.newName,
290
+ });
291
+ results.push({
292
+ templateId: updated.id,
293
+ status: 'renamed',
294
+ message: `"${p.oldName}" → "${updated.name}" (${updated.updated_at})`,
295
+ });
296
+ } catch (e) {
297
+ const msg = String(e);
298
+ results.push({
299
+ templateId: p.templateId,
300
+ status: 'failed',
301
+ message: msg,
302
+ });
303
+ if (wantStopOnError) break;
304
+ }
305
+ }
306
+
307
+ for (const r of results) {
308
+ lines.push(
309
+ r.status === 'renamed'
310
+ ? `- ✅ [${r.templateId}] ${r.message}`
311
+ : `- ❌ [${r.templateId}] ${r.message}`,
312
+ );
313
+ }
314
+
315
+ const failed = results.filter((r) => r.status === 'failed').length;
316
+ if (failed > 0)
317
+ lines.push(
318
+ `\nFailures: ${failed}. Re-run with dryRun=true to inspect plan.`,
319
+ );
320
+
321
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
322
+ },
323
+ );
324
+
325
+ server.registerTool(
326
+ 'get_template_html',
327
+ {
328
+ description:
329
+ "Get full HTML content of a template's active (or specific) version",
330
+ inputSchema: z.object({
331
+ templateId: z.string().describe('Template ID, e.g. d-xxxxxxxxxxxxxxxx'),
332
+ versionId: z
333
+ .string()
334
+ .optional()
335
+ .describe('Version ID — omit to use the active version'),
336
+ ...ReadInputFields,
337
+ }),
338
+ outputSchema: TemplateHtmlOutputSchema,
339
+ },
340
+ async ({ templateId, versionId, response_format }) => {
341
+ let resolvedVersionId = versionId;
342
+
343
+ if (!resolvedVersionId) {
344
+ const template = await client.getTemplate(templateId);
345
+ const active = template.versions.find((v) => v.active === 1);
346
+ if (!active) {
347
+ return {
348
+ content: [
349
+ {
350
+ type: 'text',
351
+ text: `Template ${templateId} has no active version.`,
352
+ },
353
+ ],
354
+ };
355
+ }
356
+ resolvedVersionId = active.id;
357
+ }
358
+
359
+ const version = await client.getTemplateVersion(
360
+ templateId,
361
+ resolvedVersionId,
362
+ );
363
+ const structured = {
364
+ templateId,
365
+ versionId: version.id,
366
+ active: version.active === 1,
367
+ name: version.name,
368
+ subject: version.subject,
369
+ updatedAt: version.updated_at,
370
+ htmlContent: version.html_content,
371
+ };
372
+
373
+ return jsonReadResult(
374
+ structured,
375
+ [
376
+ `Template: ${templateId}`,
377
+ `Version: ${version.id} (active: ${version.active === 1 ? 'yes' : 'no'})`,
378
+ `Name: ${version.name}`,
379
+ `Subject: ${version.subject}`,
380
+ `Updated: ${version.updated_at}`,
381
+ ``,
382
+ `─── HTML ────────────────────────────────────────────────────`,
383
+ version.html_content,
384
+ ].join('\n'),
385
+ response_format,
386
+ );
387
+ },
388
+ );
389
+
390
+ server.registerTool(
391
+ 'create_template',
392
+ {
393
+ description: 'Create a new dynamic template with a first version',
394
+ inputSchema: z.object({
395
+ confirmToken: ConfirmTokenSchema,
396
+ name: z.string().describe('Template name, e.g. "listing.approved"'),
397
+ versionName: z.string().describe('Version label, e.g. "v1"'),
398
+ subject: z
399
+ .string()
400
+ .describe('Email subject line (supports Handlebars: {{var}})'),
401
+ htmlContent: z
402
+ .string()
403
+ .describe('Full HTML body (supports Handlebars: {{var}})'),
404
+ }),
405
+ },
406
+ async ({ confirmToken, name, versionName, subject, htmlContent }) => {
407
+ requireConfirm(confirmToken, 'create a template');
408
+ const template = await client.createTemplate(name);
409
+ const version = await client.createTemplateVersion(template.id, {
410
+ name: versionName,
411
+ subject,
412
+ htmlContent,
413
+ active: 1,
414
+ });
415
+
416
+ return {
417
+ content: [
418
+ {
419
+ type: 'text',
420
+ text: [
421
+ `✅ Template created`,
422
+ `Template ID: ${template.id}`,
423
+ `Version ID: ${version.id}`,
424
+ `Active: yes`,
425
+ ``,
426
+ `Add to your template registry:`,
427
+ ` '${name}': '${template.id}',`,
428
+ ].join('\n'),
429
+ },
430
+ ],
431
+ };
432
+ },
433
+ );
434
+
435
+ server.registerTool(
436
+ 'update_template_html',
437
+ {
438
+ description:
439
+ 'Update the HTML, subject, or name of a specific template version',
440
+ inputSchema: z
441
+ .object({
442
+ confirmToken: ConfirmTokenSchema,
443
+ templateId: z.string().describe('Template ID'),
444
+ versionId: z.string().describe('Version ID to update'),
445
+ htmlContent: z.string().optional().describe('New HTML content'),
446
+ subject: z.string().optional().describe('New email subject'),
447
+ name: z.string().optional().describe('New version label'),
448
+ })
449
+ .refine(
450
+ (value) =>
451
+ value.htmlContent !== undefined ||
452
+ value.subject !== undefined ||
453
+ value.name !== undefined,
454
+ 'Provide at least one of htmlContent, subject, or name.',
455
+ ),
456
+ },
457
+ async ({
458
+ confirmToken,
459
+ templateId,
460
+ versionId,
461
+ htmlContent,
462
+ subject,
463
+ name,
464
+ }) => {
465
+ requireConfirm(confirmToken, 'update a template version');
466
+ const version = await client.updateTemplateVersion(
467
+ templateId,
468
+ versionId,
469
+ {
470
+ htmlContent,
471
+ subject,
472
+ name,
473
+ },
474
+ );
475
+ return {
476
+ content: [
477
+ {
478
+ type: 'text',
479
+ text: `✅ Version ${version.id} updated (${version.updated_at})`,
480
+ },
481
+ ],
482
+ };
483
+ },
484
+ );
485
+
486
+ server.registerTool(
487
+ 'activate_template_version',
488
+ {
489
+ description:
490
+ 'Activate a specific version of a template (only one version can be active at a time)',
491
+ inputSchema: z.object({
492
+ confirmToken: ConfirmTokenSchema,
493
+ templateId: z.string().describe('Template ID'),
494
+ versionId: z.string().describe('Version ID to activate'),
495
+ }),
496
+ },
497
+ async ({ confirmToken, templateId, versionId }) => {
498
+ requireConfirm(confirmToken, 'activate a template version');
499
+ const version = await client.activateTemplateVersion(
500
+ templateId,
501
+ versionId,
502
+ );
503
+ return {
504
+ content: [
505
+ {
506
+ type: 'text',
507
+ text: `✅ Version "${version.name}" (${version.id}) is now active for template ${templateId}`,
508
+ },
509
+ ],
510
+ };
511
+ },
512
+ );
513
+
514
+ server.registerTool(
515
+ 'prune_inactive_template_versions',
516
+ {
517
+ description:
518
+ 'Delete all inactive versions for one or more templates (keeps the active version only)',
519
+ inputSchema: z.object({
520
+ templateIds: z
521
+ .array(z.string())
522
+ .min(1)
523
+ .describe('SendGrid template IDs to prune'),
524
+ dryRun: z
525
+ .boolean()
526
+ .optional()
527
+ .describe('Default true. List versions that would be deleted without deleting'),
528
+ confirmToken: ConfirmTokenSchema.optional(),
529
+ }),
530
+ },
531
+ async ({ templateIds, dryRun = true, confirmToken }) => {
532
+ if (!dryRun) {
533
+ requireConfirm(confirmToken, 'delete inactive template versions');
534
+ }
535
+ const lines: string[] = [];
536
+ let deleted = 0;
537
+ let kept = 0;
538
+
539
+ for (const templateId of templateIds) {
540
+ const template = await client.getTemplate(templateId);
541
+ const inactive = template.versions.filter((version) => version.active !== 1);
542
+ const active = template.versions.filter((version) => version.active === 1);
543
+
544
+ if (active.length !== 1) {
545
+ lines.push(
546
+ `WARN ${template.name} (${templateId}): expected 1 active version, found ${String(active.length)}`,
547
+ );
548
+ }
549
+
550
+ for (const version of active) {
551
+ lines.push(`keep active: ${template.name} / ${version.name} (${version.id})`);
552
+ kept++;
553
+ }
554
+
555
+ for (const version of inactive) {
556
+ if (dryRun) {
557
+ lines.push(
558
+ `would delete: ${template.name} / ${version.name} (${version.id})`,
559
+ );
560
+ continue;
561
+ }
562
+
563
+ await client.deleteTemplateVersion(templateId, version.id);
564
+ lines.push(`deleted: ${template.name} / ${version.name} (${version.id})`);
565
+ deleted++;
566
+ }
567
+ }
568
+
569
+ lines.push('');
570
+ lines.push(
571
+ dryRun
572
+ ? `Dry run complete for ${String(templateIds.length)} template(s).`
573
+ : `Done. kept=${String(kept)} deleted=${String(deleted)}`,
574
+ );
575
+
576
+ return {
577
+ content: [{ type: 'text', text: lines.join('\n') }],
578
+ };
579
+ },
580
+ );
581
+
582
+ server.registerTool(
583
+ 'delete_template',
584
+ {
585
+ description:
586
+ 'Permanently delete a SendGrid template and all its versions. Use with caution.',
587
+ inputSchema: z.object({
588
+ templateId: z.string().describe('Template ID to delete'),
589
+ confirmToken: z
590
+ .literal('CONFIRM')
591
+ .describe('Safety token required for destructive operations'),
592
+ }),
593
+ },
594
+ async ({ templateId, confirmToken }) => {
595
+ requireConfirm(confirmToken, 'delete a template');
596
+ await client.deleteTemplate(templateId);
597
+ return {
598
+ content: [{ type: 'text', text: `🗑 Template ${templateId} deleted.` }],
599
+ };
600
+ },
601
+ );
602
+ }