@neschadin/sendgrid-mcp 0.0.0-stage → 3.0.1
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/LICENSE +21 -0
- package/MCP_TOOLS.md +457 -0
- package/README.md +175 -2
- package/bin/sendgrid-mcp +2 -0
- package/mcp.json.example +9 -0
- package/package.json +63 -4
- package/src/client.ts +1585 -0
- package/src/config.ts +197 -0
- package/src/http.ts +132 -0
- package/src/index.ts +147 -0
- package/src/logger.ts +43 -0
- package/src/redact.ts +91 -0
- package/src/tool_signal.ts +50 -0
- package/src/tools/account.ts +566 -0
- package/src/tools/classify_error.ts +133 -0
- package/src/tools/console_settings.ts +329 -0
- package/src/tools/delivery_trace.ts +291 -0
- package/src/tools/diagnostics.ts +1557 -0
- package/src/tools/email.ts +486 -0
- package/src/tools/output_schemas.ts +488 -0
- package/src/tools/preflight.ts +768 -0
- package/src/tools/sync.ts +139 -0
- package/src/tools/templates.ts +602 -0
- package/src/tools/tool_utils.ts +341 -0
- package/src/webhook_receiver.ts +467 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/server';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { isSendGridApiError } from '../client';
|
|
4
|
+
import { redactToolResult } from '../redact';
|
|
5
|
+
import {
|
|
6
|
+
abortSignalFromExtra,
|
|
7
|
+
onBehalfOfFromArgs,
|
|
8
|
+
runWithOnBehalfOf,
|
|
9
|
+
runWithToolAbortSignal,
|
|
10
|
+
} from '../tool_signal';
|
|
11
|
+
|
|
12
|
+
export const OnBehalfOfSchema = z
|
|
13
|
+
.string()
|
|
14
|
+
.min(1)
|
|
15
|
+
.optional()
|
|
16
|
+
.describe(
|
|
17
|
+
'Subuser username, "account-id <id>", or "parent" to ignore SENDGRID_ON_BEHALF_OF for this call. Added to every tool.',
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
export const TOOL_NAME_PREFIX = 'sendgrid_';
|
|
21
|
+
export const CONFIRM_TOKEN_SUFFIX = ' Requires confirmToken="CONFIRM".';
|
|
22
|
+
export const SEND_PREFLIGHT_HINT =
|
|
23
|
+
' Run validate_send_request first unless using send_with_preflight.';
|
|
24
|
+
|
|
25
|
+
export const ResponseFormatSchema = z
|
|
26
|
+
.enum(['markdown', 'json'])
|
|
27
|
+
.default('markdown')
|
|
28
|
+
.describe(
|
|
29
|
+
'markdown for human-readable text; json for machine-readable text content',
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
export type ResponseFormat = z.infer<typeof ResponseFormatSchema>;
|
|
33
|
+
|
|
34
|
+
export const ListPagingInputFields = {
|
|
35
|
+
limit: z
|
|
36
|
+
.number()
|
|
37
|
+
.int()
|
|
38
|
+
.min(1)
|
|
39
|
+
.max(1000)
|
|
40
|
+
.optional()
|
|
41
|
+
.describe('Maximum items to return (default 50)'),
|
|
42
|
+
offset: z
|
|
43
|
+
.number()
|
|
44
|
+
.int()
|
|
45
|
+
.min(0)
|
|
46
|
+
.optional()
|
|
47
|
+
.describe('Number of items to skip (default 0)'),
|
|
48
|
+
response_format: ResponseFormatSchema.optional(),
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const ReadInputFields = {
|
|
52
|
+
response_format: ResponseFormatSchema.optional(),
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const LOCAL_TOOL_NAMES = new Set([
|
|
56
|
+
'classify_sendgrid_error',
|
|
57
|
+
'clear_received_webhook_events',
|
|
58
|
+
'get_received_webhook_events',
|
|
59
|
+
'get_webhook_receiver_status',
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
export function isReadOnlyMode(): boolean {
|
|
63
|
+
const raw = Bun.env['READ_ONLY']?.trim().toLowerCase();
|
|
64
|
+
return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function bareToolName(name: string): string {
|
|
68
|
+
return name.startsWith(TOOL_NAME_PREFIX)
|
|
69
|
+
? name.slice(TOOL_NAME_PREFIX.length)
|
|
70
|
+
: name;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function prefixedToolName(name: string): string {
|
|
74
|
+
return name.startsWith(TOOL_NAME_PREFIX) ? name : `${TOOL_NAME_PREFIX}${name}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function inputSchemaRequiresConfirm(inputSchema: unknown): boolean {
|
|
78
|
+
if (!(inputSchema instanceof z.ZodObject)) return false;
|
|
79
|
+
|
|
80
|
+
const field = inputSchema.shape['confirmToken'];
|
|
81
|
+
if (!field) return false;
|
|
82
|
+
if (field instanceof z.ZodOptional) return false;
|
|
83
|
+
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function augmentDescription(
|
|
88
|
+
description: string | undefined,
|
|
89
|
+
requiresConfirm: boolean,
|
|
90
|
+
): string | undefined {
|
|
91
|
+
if (!description) return description;
|
|
92
|
+
let next = description.trim();
|
|
93
|
+
if (requiresConfirm && !next.includes('confirmToken="CONFIRM"')) {
|
|
94
|
+
next += CONFIRM_TOKEN_SUFFIX;
|
|
95
|
+
}
|
|
96
|
+
return next;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
type ToolHandler = (
|
|
100
|
+
args: unknown,
|
|
101
|
+
extra: unknown,
|
|
102
|
+
) => Promise<unknown> | unknown;
|
|
103
|
+
|
|
104
|
+
/** Normalizes tool/runtime errors into a single user-facing string (MCP `content[].text`). */
|
|
105
|
+
export function formatToolError(error: unknown): string {
|
|
106
|
+
if (isSendGridApiError(error)) {
|
|
107
|
+
const details =
|
|
108
|
+
error.errors.length > 0
|
|
109
|
+
? `\n${error.errors.map((entry) => `- ${entry.message}`).join('\n')}`
|
|
110
|
+
: '';
|
|
111
|
+
const hints: string[] = [];
|
|
112
|
+
if (error.status === 401) {
|
|
113
|
+
hints.push('Check that SENDGRID_API_KEY is valid and has not been revoked.');
|
|
114
|
+
}
|
|
115
|
+
if (error.status === 403) {
|
|
116
|
+
hints.push(
|
|
117
|
+
'Check API key scopes for this endpoint and whether the account/plan can access it. Call sendgrid_get_scopes to list scopes on this key.',
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
if (error.status === 404) {
|
|
121
|
+
hints.push('Check the resource ID, endpoint region, and account/subuser context.');
|
|
122
|
+
}
|
|
123
|
+
if (error.status === 429) {
|
|
124
|
+
hints.push('SendGrid rate-limited the request; retry later or reduce request rate.');
|
|
125
|
+
}
|
|
126
|
+
const hintText =
|
|
127
|
+
hints.length > 0
|
|
128
|
+
? `\nNext steps:\n${hints.map((hint) => `- ${hint}`).join('\n')}`
|
|
129
|
+
: '';
|
|
130
|
+
return `SendGrid API error (${error.status}) on ${error.method} ${error.path}.${details}${hintText}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
134
|
+
return 'SendGrid request cancelled because the MCP client aborted the tool call.';
|
|
135
|
+
}
|
|
136
|
+
if (error instanceof Error) return error.message;
|
|
137
|
+
return String(error);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const SAFE_TOOL_PATCHED = Symbol('safe-tool-patched');
|
|
141
|
+
let registeredToolCount = 0;
|
|
142
|
+
const registeredToolNames: string[] = [];
|
|
143
|
+
|
|
144
|
+
export function getRegisteredToolNames(): readonly string[] {
|
|
145
|
+
return registeredToolNames;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function getRegisteredToolCount(): number {
|
|
149
|
+
return registeredToolCount;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function titleFromName(name: string): string {
|
|
153
|
+
return bareToolName(name)
|
|
154
|
+
.split('_')
|
|
155
|
+
.filter(Boolean)
|
|
156
|
+
.map((chunk) => chunk[0]?.toUpperCase() + chunk.slice(1))
|
|
157
|
+
.join(' ');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function inferAnnotations(
|
|
161
|
+
name: string,
|
|
162
|
+
config: Record<string, unknown>,
|
|
163
|
+
): {
|
|
164
|
+
readOnlyHint: boolean;
|
|
165
|
+
destructiveHint: boolean;
|
|
166
|
+
idempotentHint: boolean;
|
|
167
|
+
openWorldHint: boolean;
|
|
168
|
+
} {
|
|
169
|
+
const explicit = config['annotations'];
|
|
170
|
+
if (
|
|
171
|
+
explicit &&
|
|
172
|
+
typeof explicit === 'object' &&
|
|
173
|
+
'readOnlyHint' in explicit &&
|
|
174
|
+
typeof explicit.readOnlyHint === 'boolean'
|
|
175
|
+
) {
|
|
176
|
+
return {
|
|
177
|
+
readOnlyHint: explicit.readOnlyHint,
|
|
178
|
+
destructiveHint:
|
|
179
|
+
'destructiveHint' in explicit &&
|
|
180
|
+
typeof explicit.destructiveHint === 'boolean'
|
|
181
|
+
? explicit.destructiveHint
|
|
182
|
+
: false,
|
|
183
|
+
idempotentHint:
|
|
184
|
+
'idempotentHint' in explicit &&
|
|
185
|
+
typeof explicit.idempotentHint === 'boolean'
|
|
186
|
+
? explicit.idempotentHint
|
|
187
|
+
: explicit.readOnlyHint,
|
|
188
|
+
openWorldHint:
|
|
189
|
+
'openWorldHint' in explicit && typeof explicit.openWorldHint === 'boolean'
|
|
190
|
+
? explicit.openWorldHint
|
|
191
|
+
: true,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const bare = bareToolName(name);
|
|
196
|
+
const mutatingPrefixes = [
|
|
197
|
+
'activate_',
|
|
198
|
+
'cancel_',
|
|
199
|
+
'clear_',
|
|
200
|
+
'create_',
|
|
201
|
+
'delete_',
|
|
202
|
+
'pause_',
|
|
203
|
+
'prune_',
|
|
204
|
+
'rename_',
|
|
205
|
+
'resend_',
|
|
206
|
+
'resume_',
|
|
207
|
+
'schedule_',
|
|
208
|
+
'send_',
|
|
209
|
+
'toggle_',
|
|
210
|
+
'manage_',
|
|
211
|
+
'update_',
|
|
212
|
+
'validate_authenticated_',
|
|
213
|
+
'validate_branded_',
|
|
214
|
+
];
|
|
215
|
+
const destructivePrefixes = [
|
|
216
|
+
'cancel_',
|
|
217
|
+
'clear_',
|
|
218
|
+
'delete_',
|
|
219
|
+
'manage_',
|
|
220
|
+
'prune_',
|
|
221
|
+
];
|
|
222
|
+
const readOnlyPrefixes = [
|
|
223
|
+
'analyze_',
|
|
224
|
+
'check_',
|
|
225
|
+
'classify_',
|
|
226
|
+
'get_',
|
|
227
|
+
'list_',
|
|
228
|
+
'search_',
|
|
229
|
+
'sync_',
|
|
230
|
+
'triage_',
|
|
231
|
+
'validate_send_request',
|
|
232
|
+
];
|
|
233
|
+
|
|
234
|
+
const mutating = mutatingPrefixes.some((prefix) => bare.startsWith(prefix));
|
|
235
|
+
const destructive = destructivePrefixes.some((prefix) =>
|
|
236
|
+
bare.startsWith(prefix),
|
|
237
|
+
);
|
|
238
|
+
const readOnly =
|
|
239
|
+
!mutating && readOnlyPrefixes.some((prefix) => bare.startsWith(prefix));
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
readOnlyHint: readOnly,
|
|
243
|
+
destructiveHint: destructive || bare === 'send_with_preflight',
|
|
244
|
+
idempotentHint: readOnly,
|
|
245
|
+
openWorldHint: !LOCAL_TOOL_NAMES.has(bare),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function isReadOnlyBlocked(toolName: string): boolean {
|
|
250
|
+
if (!isReadOnlyMode()) return false;
|
|
251
|
+
return inferAnnotations(toolName, {}).readOnlyHint !== true;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function ensureSafeToolRegistration(server: McpServer) {
|
|
255
|
+
const marker = (server as unknown as Record<symbol, boolean>)[
|
|
256
|
+
SAFE_TOOL_PATCHED
|
|
257
|
+
];
|
|
258
|
+
if (marker) return;
|
|
259
|
+
|
|
260
|
+
const rawRegisterTool = server.registerTool.bind(server);
|
|
261
|
+
server.registerTool = ((
|
|
262
|
+
name: unknown,
|
|
263
|
+
config: unknown,
|
|
264
|
+
handler: ToolHandler,
|
|
265
|
+
) => {
|
|
266
|
+
const exposedName =
|
|
267
|
+
typeof name === 'string' ? prefixedToolName(name) : name;
|
|
268
|
+
|
|
269
|
+
if (typeof exposedName === 'string') {
|
|
270
|
+
registeredToolCount += 1;
|
|
271
|
+
registeredToolNames.push(exposedName);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return (rawRegisterTool as (n: unknown, c: unknown, h: ToolHandler) => void)(
|
|
275
|
+
exposedName,
|
|
276
|
+
typeof name === 'string' && typeof config === 'object' && config !== null
|
|
277
|
+
? (() => {
|
|
278
|
+
const cfg = config as Record<string, unknown>;
|
|
279
|
+
const inputSchema = cfg['inputSchema'];
|
|
280
|
+
if (
|
|
281
|
+
inputSchema instanceof z.ZodObject &&
|
|
282
|
+
!('onBehalfOf' in inputSchema.shape)
|
|
283
|
+
) {
|
|
284
|
+
cfg['inputSchema'] = inputSchema.extend({
|
|
285
|
+
onBehalfOf: OnBehalfOfSchema,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
const requiresConfirm = inputSchemaRequiresConfirm(
|
|
289
|
+
cfg['inputSchema'],
|
|
290
|
+
);
|
|
291
|
+
return {
|
|
292
|
+
...cfg,
|
|
293
|
+
description: augmentDescription(
|
|
294
|
+
typeof cfg['description'] === 'string'
|
|
295
|
+
? cfg['description']
|
|
296
|
+
: undefined,
|
|
297
|
+
requiresConfirm,
|
|
298
|
+
),
|
|
299
|
+
title: cfg['title'] ?? titleFromName(name as string),
|
|
300
|
+
annotations: inferAnnotations(name as string, cfg),
|
|
301
|
+
};
|
|
302
|
+
})()
|
|
303
|
+
: config,
|
|
304
|
+
async (args: unknown, extra: unknown) => {
|
|
305
|
+
const signal = abortSignalFromExtra(extra);
|
|
306
|
+
const onBehalfOf = onBehalfOfFromArgs(args);
|
|
307
|
+
const run = async () => {
|
|
308
|
+
try {
|
|
309
|
+
if (
|
|
310
|
+
typeof exposedName === 'string' &&
|
|
311
|
+
isReadOnlyBlocked(exposedName)
|
|
312
|
+
) {
|
|
313
|
+
return {
|
|
314
|
+
isError: true,
|
|
315
|
+
content: [
|
|
316
|
+
{
|
|
317
|
+
type: 'text',
|
|
318
|
+
text: `Blocked by READ_ONLY=true. ${exposedName} can send mail or change SendGrid. Set READ_ONLY=false to run it.`,
|
|
319
|
+
},
|
|
320
|
+
],
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
return await handler(args, extra);
|
|
324
|
+
} catch (error) {
|
|
325
|
+
return {
|
|
326
|
+
isError: true,
|
|
327
|
+
content: [{ type: 'text', text: formatToolError(error) }],
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const result = await runWithOnBehalfOf(onBehalfOf, () =>
|
|
333
|
+
runWithToolAbortSignal(signal, run),
|
|
334
|
+
);
|
|
335
|
+
return redactToolResult(result);
|
|
336
|
+
},
|
|
337
|
+
);
|
|
338
|
+
}) as typeof server.registerTool;
|
|
339
|
+
|
|
340
|
+
(server as unknown as Record<symbol, boolean>)[SAFE_TOOL_PATCHED] = true;
|
|
341
|
+
}
|