@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,566 @@
1
+ import type { McpServer } from '@modelcontextprotocol/server';
2
+ import { z } from 'zod';
3
+ import type { SendGridClient } from '../client';
4
+ import {
5
+ AlertsListOutputSchema,
6
+ AuthenticatedDomainSchema,
7
+ AuthenticatedDomainsListOutputSchema,
8
+ BrandedLinkSchema,
9
+ BrandedLinksListOutputSchema,
10
+ jsonReadResult,
11
+ jsonText,
12
+ paginateArray,
13
+ SendGridAlertSchema,
14
+ ScopesOutputSchema,
15
+ SubuserListOutputSchema,
16
+ UserAccountOutputSchema,
17
+ UserCreditsOutputSchema,
18
+ UserProfileOutputSchema,
19
+ VerifiedSenderSchema,
20
+ VerifiedSendersListOutputSchema,
21
+ } from './output_schemas';
22
+ import {
23
+ ensureSafeToolRegistration,
24
+ ListPagingInputFields,
25
+ ReadInputFields,
26
+ } from './tool_utils';
27
+
28
+ const ConfirmTokenSchema = z
29
+ .literal('CONFIRM')
30
+ .describe('Safety token required for mutating SendGrid account settings');
31
+
32
+ export function registerAccountTools(server: McpServer, client: SendGridClient) {
33
+ ensureSafeToolRegistration(server);
34
+
35
+ server.registerTool(
36
+ 'get_account_info',
37
+ {
38
+ description:
39
+ 'Read SendGrid account overview (account type, reputation, and related metadata).',
40
+ inputSchema: z.object({ ...ReadInputFields }),
41
+ outputSchema: UserAccountOutputSchema,
42
+ },
43
+ async ({ response_format }) => {
44
+ const data = await client.getUserAccount();
45
+ return jsonReadResult(data, undefined, response_format);
46
+ },
47
+ );
48
+
49
+ server.registerTool(
50
+ 'get_user_profile',
51
+ {
52
+ description: 'Read SendGrid user profile (company, address, contact fields).',
53
+ inputSchema: z.object({ ...ReadInputFields }),
54
+ outputSchema: UserProfileOutputSchema,
55
+ },
56
+ async ({ response_format }) => {
57
+ const data = await client.getUserProfile();
58
+ return jsonReadResult(data, undefined, response_format);
59
+ },
60
+ );
61
+
62
+ server.registerTool(
63
+ 'get_user_credits',
64
+ {
65
+ description: 'Read SendGrid account email credits/quota overview.',
66
+ inputSchema: z.object({ ...ReadInputFields }),
67
+ outputSchema: UserCreditsOutputSchema,
68
+ },
69
+ async ({ response_format }) => {
70
+ const data = await client.getUserCredits();
71
+ return jsonReadResult(data, undefined, response_format);
72
+ },
73
+ );
74
+
75
+ server.registerTool(
76
+ 'get_scopes',
77
+ {
78
+ description:
79
+ 'List OAuth scopes granted to the current SendGrid API key (GET /v3/scopes). Use this after a 403 to see what the key can actually call.',
80
+ inputSchema: z.object({ ...ReadInputFields }),
81
+ outputSchema: ScopesOutputSchema,
82
+ },
83
+ async ({ response_format }) => {
84
+ const data = await client.getScopes();
85
+ const scopes = data.scopes ?? [];
86
+ return jsonReadResult(
87
+ { count: scopes.length, scopes },
88
+ scopes.length === 0
89
+ ? 'This API key has no scopes.'
90
+ : [`Scopes (${scopes.length}):`, ...scopes.map((scope) => `- ${scope}`)].join(
91
+ '\n',
92
+ ),
93
+ response_format,
94
+ );
95
+ },
96
+ );
97
+
98
+ server.registerTool(
99
+ 'list_subusers',
100
+ {
101
+ description:
102
+ 'List SendGrid subusers for the parent API key (GET /v3/subusers). Use a returned username as onBehalfOf on later calls, or as SENDGRID_ON_BEHALF_OF. Pass onBehalfOf="parent" when that env var is already set and this list must run as the parent.',
103
+ inputSchema: z.object({
104
+ username: z
105
+ .string()
106
+ .min(1)
107
+ .optional()
108
+ .describe('Filter to one subuser username'),
109
+ region: z.enum(['all', 'global', 'eu']).optional(),
110
+ includeRegion: z
111
+ .boolean()
112
+ .optional()
113
+ .describe('Include each subuser region. Default true.'),
114
+ ...ListPagingInputFields,
115
+ }),
116
+ outputSchema: SubuserListOutputSchema,
117
+ },
118
+ async ({ username, region, includeRegion, limit, offset, response_format }) => {
119
+ const pageLimit = limit ?? 50;
120
+ const pageOffset = offset ?? 0;
121
+ const subusers = await client.listSubusers({
122
+ username,
123
+ region,
124
+ includeRegion: includeRegion ?? true,
125
+ limit: pageLimit,
126
+ offset: pageOffset,
127
+ });
128
+ return jsonReadResult(
129
+ {
130
+ count: subusers.length,
131
+ limit: pageLimit,
132
+ offset: pageOffset,
133
+ has_more: subusers.length >= pageLimit,
134
+ subusers,
135
+ },
136
+ subusers.length === 0
137
+ ? 'No subusers matched.'
138
+ : subusers
139
+ .map(
140
+ (subuser) =>
141
+ `- ${subuser.username} id=${subuser.id ?? 'n/a'} disabled=${String(subuser.disabled ?? false)} region=${subuser.region ?? 'n/a'} email=${subuser.email ?? 'n/a'}`,
142
+ )
143
+ .join('\n'),
144
+ response_format,
145
+ );
146
+ },
147
+ );
148
+
149
+ server.registerTool(
150
+ 'list_verified_senders',
151
+ {
152
+ description: 'List verified sender identities configured in SendGrid.',
153
+ inputSchema: z.object({
154
+ lastSeenID: z.number().int().optional(),
155
+ id: z.number().int().optional(),
156
+ ...ListPagingInputFields,
157
+ }),
158
+ outputSchema: VerifiedSendersListOutputSchema,
159
+ },
160
+ async (params) => {
161
+ const senders = await client.listVerifiedSenders({
162
+ limit: params.limit ?? 200,
163
+ lastSeenID: params.lastSeenID,
164
+ id: params.id,
165
+ });
166
+ const { items, pagination } = paginateArray(
167
+ senders,
168
+ params.limit,
169
+ params.offset,
170
+ );
171
+ return jsonReadResult(
172
+ { ...pagination, senders: items },
173
+ jsonText(items),
174
+ params.response_format,
175
+ );
176
+ },
177
+ );
178
+
179
+ server.registerTool(
180
+ 'get_verified_sender',
181
+ {
182
+ description: 'Read one verified sender identity by ID.',
183
+ inputSchema: z.object({ id: z.number().int().positive(), ...ReadInputFields }),
184
+ outputSchema: VerifiedSenderSchema,
185
+ },
186
+ async ({ id, response_format }) => {
187
+ const sender = await client.getVerifiedSender(id);
188
+ return jsonReadResult(sender, undefined, response_format);
189
+ },
190
+ );
191
+
192
+ server.registerTool(
193
+ 'list_authenticated_domains',
194
+ {
195
+ description:
196
+ 'List domain authentication (whitelabel domain) records and DNS validation state.',
197
+ inputSchema: z.object({ ...ListPagingInputFields }),
198
+ outputSchema: AuthenticatedDomainsListOutputSchema,
199
+ },
200
+ async (params) => {
201
+ const domains = await client.listAuthenticatedDomains();
202
+ const { items, pagination } = paginateArray(
203
+ domains,
204
+ params.limit,
205
+ params.offset,
206
+ );
207
+ return jsonReadResult(
208
+ { ...pagination, domains: items },
209
+ jsonText(items),
210
+ params.response_format,
211
+ );
212
+ },
213
+ );
214
+
215
+ server.registerTool(
216
+ 'get_authenticated_domain',
217
+ {
218
+ description: 'Read one authenticated domain record by ID.',
219
+ inputSchema: z.object({ id: z.number().int().positive(), ...ReadInputFields }),
220
+ outputSchema: AuthenticatedDomainSchema,
221
+ },
222
+ async ({ id, response_format }) => {
223
+ const domain = await client.getAuthenticatedDomain(id);
224
+ return jsonReadResult(domain, undefined, response_format);
225
+ },
226
+ );
227
+
228
+ server.registerTool(
229
+ 'list_branded_links',
230
+ {
231
+ description: 'List link branding (click-tracking domain) records.',
232
+ inputSchema: z.object({ ...ListPagingInputFields }),
233
+ outputSchema: BrandedLinksListOutputSchema,
234
+ },
235
+ async (params) => {
236
+ const links = await client.listBrandedLinks();
237
+ const { items, pagination } = paginateArray(
238
+ links,
239
+ params.limit,
240
+ params.offset,
241
+ );
242
+ return jsonReadResult(
243
+ { ...pagination, links: items },
244
+ jsonText(items),
245
+ params.response_format,
246
+ );
247
+ },
248
+ );
249
+
250
+ server.registerTool(
251
+ 'get_branded_link',
252
+ {
253
+ description: 'Read one branded link record by ID.',
254
+ inputSchema: z.object({ id: z.number().int().positive(), ...ReadInputFields }),
255
+ outputSchema: BrandedLinkSchema,
256
+ },
257
+ async ({ id, response_format }) => {
258
+ const link = await client.getBrandedLink(id);
259
+ return jsonReadResult(link, undefined, response_format);
260
+ },
261
+ );
262
+
263
+ server.registerTool(
264
+ 'list_alerts',
265
+ {
266
+ description:
267
+ 'List SendGrid account alerts (usage limits and stats notifications).',
268
+ inputSchema: z.object({ ...ListPagingInputFields }),
269
+ outputSchema: AlertsListOutputSchema,
270
+ },
271
+ async (params) => {
272
+ const alerts = await client.listAlerts();
273
+ const { items, pagination } = paginateArray(
274
+ alerts,
275
+ params.limit,
276
+ params.offset,
277
+ );
278
+ return jsonReadResult(
279
+ { ...pagination, alerts: items },
280
+ jsonText(items),
281
+ params.response_format,
282
+ );
283
+ },
284
+ );
285
+
286
+ server.registerTool(
287
+ 'get_alert',
288
+ {
289
+ description: 'Read one SendGrid alert by ID.',
290
+ inputSchema: z.object({ id: z.number().int().positive(), ...ReadInputFields }),
291
+ outputSchema: SendGridAlertSchema,
292
+ },
293
+ async ({ id, response_format }) => {
294
+ const alert = await client.getAlert(id);
295
+ return jsonReadResult(alert, undefined, response_format);
296
+ },
297
+ );
298
+
299
+ server.registerTool(
300
+ 'create_verified_sender',
301
+ {
302
+ description:
303
+ 'Create a verified sender identity. SendGrid emails a verification link to from_email.',
304
+ inputSchema: z.object({
305
+ confirmToken: ConfirmTokenSchema,
306
+ nickname: z.string().min(1).max(100),
307
+ fromEmail: z.email().max(256),
308
+ replyTo: z.email().max(256),
309
+ fromName: z.string().max(256).optional(),
310
+ replyToName: z.string().max(256).optional(),
311
+ address: z.string().max(100).optional(),
312
+ address2: z.string().max(100).optional(),
313
+ state: z.string().max(2).optional(),
314
+ city: z.string().max(150).optional(),
315
+ country: z.string().max(100).optional(),
316
+ zip: z.string().max(10).optional(),
317
+ }),
318
+ },
319
+ async ({
320
+ fromEmail,
321
+ replyTo,
322
+ nickname,
323
+ fromName,
324
+ replyToName,
325
+ address,
326
+ address2,
327
+ state,
328
+ city,
329
+ country,
330
+ zip,
331
+ }) => {
332
+ const created = await client.createVerifiedSender({
333
+ nickname,
334
+ from_email: fromEmail,
335
+ reply_to: replyTo,
336
+ from_name: fromName,
337
+ reply_to_name: replyToName,
338
+ address,
339
+ address2,
340
+ state,
341
+ city,
342
+ country,
343
+ zip,
344
+ });
345
+ return {
346
+ content: [{ type: 'text', text: jsonText(created) }],
347
+ };
348
+ },
349
+ );
350
+
351
+ server.registerTool(
352
+ 'resend_verified_sender_verification',
353
+ {
354
+ description: 'Resend verification email for a verified sender identity.',
355
+ inputSchema: z.object({
356
+ confirmToken: ConfirmTokenSchema,
357
+ id: z.number().int().positive(),
358
+ }),
359
+ },
360
+ async ({ id }) => {
361
+ await client.resendVerifiedSenderVerification(id);
362
+ return {
363
+ content: [
364
+ {
365
+ type: 'text',
366
+ text: `Verification email resent for verified sender ID ${id}.`,
367
+ },
368
+ ],
369
+ };
370
+ },
371
+ );
372
+
373
+ server.registerTool(
374
+ 'delete_verified_sender',
375
+ {
376
+ description: 'Delete a verified sender identity by ID.',
377
+ inputSchema: z.object({
378
+ confirmToken: ConfirmTokenSchema,
379
+ id: z.number().int().positive(),
380
+ }),
381
+ },
382
+ async ({ id }) => {
383
+ await client.deleteVerifiedSender(id);
384
+ return {
385
+ content: [
386
+ { type: 'text', text: `Verified sender ID ${id} deleted.` },
387
+ ],
388
+ };
389
+ },
390
+ );
391
+
392
+ server.registerTool(
393
+ 'create_authenticated_domain',
394
+ {
395
+ description:
396
+ 'Start domain authentication (whitelabel domain). Returns DNS records to configure.',
397
+ inputSchema: z.object({
398
+ confirmToken: ConfirmTokenSchema,
399
+ domain: z.string().min(1),
400
+ subdomain: z.string().optional(),
401
+ username: z.string().optional(),
402
+ ips: z.array(z.string()).optional(),
403
+ customSpf: z.boolean().optional(),
404
+ default: z.boolean().optional(),
405
+ automaticSecurity: z.boolean().optional(),
406
+ customDkimSelector: z.string().optional(),
407
+ region: z.enum(['global', 'eu']).optional(),
408
+ }),
409
+ },
410
+ async ({
411
+ domain,
412
+ subdomain,
413
+ username,
414
+ ips,
415
+ customSpf,
416
+ default: isDefault,
417
+ automaticSecurity,
418
+ customDkimSelector,
419
+ region,
420
+ }) => {
421
+ const created = await client.createAuthenticatedDomain({
422
+ domain,
423
+ subdomain,
424
+ username,
425
+ ips,
426
+ custom_spf: customSpf,
427
+ default: isDefault,
428
+ automatic_security: automaticSecurity,
429
+ custom_dkim_selector: customDkimSelector,
430
+ region,
431
+ });
432
+ return {
433
+ content: [{ type: 'text', text: jsonText(created) }],
434
+ };
435
+ },
436
+ );
437
+
438
+ server.registerTool(
439
+ 'validate_authenticated_domain',
440
+ {
441
+ description:
442
+ 'Validate DNS for an authenticated domain. Returns validation results and errors.',
443
+ inputSchema: z.object({
444
+ confirmToken: ConfirmTokenSchema,
445
+ id: z.number().int().positive(),
446
+ }),
447
+ },
448
+ async ({ id }) => ({
449
+ content: [
450
+ {
451
+ type: 'text',
452
+ text: jsonText(await client.validateAuthenticatedDomain(id)),
453
+ },
454
+ ],
455
+ }),
456
+ );
457
+
458
+ server.registerTool(
459
+ 'validate_branded_link',
460
+ {
461
+ description:
462
+ 'Validate DNS for a branded link (click-tracking domain) by ID.',
463
+ inputSchema: z.object({
464
+ confirmToken: ConfirmTokenSchema,
465
+ id: z.number().int().positive(),
466
+ }),
467
+ },
468
+ async ({ id }) => ({
469
+ content: [
470
+ { type: 'text', text: jsonText(await client.validateBrandedLink(id)) },
471
+ ],
472
+ }),
473
+ );
474
+
475
+ server.registerTool(
476
+ 'update_branded_link',
477
+ {
478
+ description:
479
+ 'Update a branded link (e.g. set default=true for click-tracking domain).',
480
+ inputSchema: z.object({
481
+ confirmToken: ConfirmTokenSchema,
482
+ id: z.number().int().positive(),
483
+ default: z.boolean().optional(),
484
+ subdomain: z.string().optional(),
485
+ }),
486
+ },
487
+ async ({ id, default: isDefault, subdomain }) => {
488
+ const updated = await client.updateBrandedLink(id, {
489
+ default: isDefault,
490
+ subdomain,
491
+ });
492
+ return {
493
+ content: [{ type: 'text', text: jsonText(updated) }],
494
+ };
495
+ },
496
+ );
497
+
498
+ server.registerTool(
499
+ 'create_alert',
500
+ {
501
+ description:
502
+ 'Create a SendGrid alert (usage_limit or stats_notification).',
503
+ inputSchema: z.object({
504
+ confirmToken: ConfirmTokenSchema,
505
+ type: z.enum(['usage_limit', 'stats_notification']),
506
+ emailTo: z.email().optional(),
507
+ frequency: z.enum(['daily', 'weekly', 'monthly']).optional(),
508
+ percentage: z.number().int().min(1).max(100).optional(),
509
+ }),
510
+ },
511
+ async ({ type, emailTo, frequency, percentage }) => {
512
+ const created = await client.createAlert({
513
+ type,
514
+ email_to: emailTo,
515
+ frequency,
516
+ percentage,
517
+ });
518
+ return {
519
+ content: [{ type: 'text', text: jsonText(created) }],
520
+ };
521
+ },
522
+ );
523
+
524
+ server.registerTool(
525
+ 'update_alert',
526
+ {
527
+ description: 'Update an existing SendGrid alert by ID.',
528
+ inputSchema: z.object({
529
+ confirmToken: ConfirmTokenSchema,
530
+ id: z.number().int().positive(),
531
+ type: z.enum(['usage_limit', 'stats_notification']).optional(),
532
+ emailTo: z.email().optional(),
533
+ frequency: z.enum(['daily', 'weekly', 'monthly']).optional(),
534
+ percentage: z.number().int().min(1).max(100).optional(),
535
+ }),
536
+ },
537
+ async ({ id, type, emailTo, frequency, percentage }) => {
538
+ const updated = await client.updateAlert(id, {
539
+ type,
540
+ email_to: emailTo,
541
+ frequency,
542
+ percentage,
543
+ });
544
+ return {
545
+ content: [{ type: 'text', text: jsonText(updated) }],
546
+ };
547
+ },
548
+ );
549
+
550
+ server.registerTool(
551
+ 'delete_alert',
552
+ {
553
+ description: 'Delete a SendGrid alert by ID.',
554
+ inputSchema: z.object({
555
+ confirmToken: ConfirmTokenSchema,
556
+ id: z.number().int().positive(),
557
+ }),
558
+ },
559
+ async ({ id }) => {
560
+ await client.deleteAlert(id);
561
+ return {
562
+ content: [{ type: 'text', text: `Alert ID ${id} deleted.` }],
563
+ };
564
+ },
565
+ );
566
+ }
@@ -0,0 +1,133 @@
1
+ export interface ClassifiedError {
2
+ category: string;
3
+ probableCauses: string[];
4
+ actions: string[];
5
+ }
6
+
7
+ export function classifySendGridError(
8
+ statusCode: number | undefined,
9
+ text: string,
10
+ ): ClassifiedError {
11
+ const normalized = text.trim().toLowerCase();
12
+ const details: ClassifiedError = {
13
+ category: 'unknown',
14
+ probableCauses: ['Insufficient context to classify exactly.'],
15
+ actions: ['Check full API error body and endpoint payload.'],
16
+ };
17
+
18
+ if (
19
+ normalized.includes('from address does not match a verified sender identity') ||
20
+ normalized.includes('verified sender identity')
21
+ ) {
22
+ return {
23
+ category: 'sender_identity',
24
+ probableCauses: [
25
+ 'From address/domain is not verified for API sending.',
26
+ 'Domain authentication not configured for sender domain.',
27
+ ],
28
+ actions: [
29
+ 'Authenticate sender domain and use matching From domain.',
30
+ 'Run sender preflight checks before retrying.',
31
+ ],
32
+ };
33
+ }
34
+
35
+ if (
36
+ normalized.includes('invalid template') ||
37
+ normalized.includes('template') ||
38
+ normalized.includes('dropped')
39
+ ) {
40
+ return {
41
+ category: 'template_validation',
42
+ probableCauses: [
43
+ 'Template ID is invalid or inaccessible.',
44
+ 'Template has no active version.',
45
+ 'Template render data does not match expected handlebars variables.',
46
+ ],
47
+ actions: [
48
+ 'Verify template ID exists and has active version.',
49
+ 'Validate dynamic template data against template variables.',
50
+ ],
51
+ };
52
+ }
53
+
54
+ if (normalized.includes('attachment content must be base64')) {
55
+ return {
56
+ category: 'attachment_encoding',
57
+ probableCauses: ['Attachment payload is not base64-encoded correctly.'],
58
+ actions: [
59
+ 'Base64-encode attachment content before send.',
60
+ 'Validate attachment payload in preflight.',
61
+ ],
62
+ };
63
+ }
64
+
65
+ if (statusCode === 429 || normalized.includes('rate limit')) {
66
+ return {
67
+ category: 'rate_limit',
68
+ probableCauses: ['Endpoint rate limit exceeded.'],
69
+ actions: [
70
+ 'Back off and retry after reset.',
71
+ 'Queue requests and apply per-endpoint pacing.',
72
+ ],
73
+ };
74
+ }
75
+
76
+ if (statusCode === 413 || normalized.includes('payload too large')) {
77
+ return {
78
+ category: 'payload_too_large',
79
+ probableCauses: [
80
+ 'Email payload or attachment set exceeds API/provider limits.',
81
+ ],
82
+ actions: [
83
+ 'Reduce attachment sizes and payload footprint.',
84
+ 'Move large files to hosted links instead of attachments.',
85
+ ],
86
+ };
87
+ }
88
+
89
+ if (statusCode === 401) {
90
+ return {
91
+ category: 'auth_or_account_state',
92
+ probableCauses: [
93
+ 'Invalid/revoked API key or missing scopes.',
94
+ 'Account in disabled/frozen/credit-exceeded state.',
95
+ ],
96
+ actions: [
97
+ 'Verify API key validity and scopes.',
98
+ 'Check account/billing state before retrying sends.',
99
+ ],
100
+ };
101
+ }
102
+
103
+ if (statusCode === 403) {
104
+ return {
105
+ category: 'permissions_or_policy',
106
+ probableCauses: [
107
+ 'API key lacks required permissions.',
108
+ 'Endpoint forbidden for this account/plan state.',
109
+ ],
110
+ actions: [
111
+ 'Use key with required scopes.',
112
+ 'Validate account feature availability for the endpoint.',
113
+ ],
114
+ };
115
+ }
116
+
117
+ if (statusCode === 400) {
118
+ return {
119
+ category: 'payload_validation',
120
+ probableCauses: [
121
+ 'Malformed JSON or invalid request schema.',
122
+ 'Duplicate recipients across to/cc/bcc in a personalization block.',
123
+ 'Missing required fields (subject/content/from/personalizations).',
124
+ ],
125
+ actions: [
126
+ 'Validate payload schema and required fields.',
127
+ 'Ensure recipient uniqueness per personalization block.',
128
+ ],
129
+ };
130
+ }
131
+
132
+ return details;
133
+ }