@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.
@@ -0,0 +1,467 @@
1
+ import { logDebug, logInfo, logWarn } from './logger';
2
+
3
+ export interface ReceivedWebhookEvent {
4
+ receivedAt: string;
5
+ signatureVerified: boolean;
6
+ payload: Record<string, unknown>;
7
+ }
8
+
9
+ export interface WebhookReceiverStatus {
10
+ enabled: boolean;
11
+ listening: boolean;
12
+ host?: string;
13
+ port?: number;
14
+ path?: string;
15
+ maxStoredEvents: number;
16
+ storedEvents: number;
17
+ eventTypeBreakdown: Record<string, number>;
18
+ signatureRequired: boolean;
19
+ signatureConfigured: boolean;
20
+ lastReceivedAt?: string;
21
+ lastError?: string;
22
+ }
23
+
24
+ interface ReceiverRuntime {
25
+ host: string;
26
+ port: number;
27
+ path: string;
28
+ healthPath: string;
29
+ verbose: boolean;
30
+ signatureRequired: boolean;
31
+ publicKey?: string;
32
+ maxStoredEvents: number;
33
+ }
34
+
35
+ let runtime: ReceiverRuntime | undefined;
36
+ let listening = false;
37
+ let lastError: string | undefined;
38
+ const storedEvents: ReceivedWebhookEvent[] = [];
39
+ let webhookServer: ReturnType<typeof Bun.serve> | undefined;
40
+
41
+ function parseBooleanEnv(
42
+ value: string | undefined,
43
+ defaultValue = false,
44
+ ): boolean {
45
+ if (!value) return defaultValue;
46
+ const v = value.trim().toLowerCase();
47
+ return v === '1' || v === 'true' || v === 'yes' || v === 'on';
48
+ }
49
+
50
+ function normalizePublicKey(publicKey: string): string {
51
+ const trimmed = publicKey.trim();
52
+ if (trimmed.includes('BEGIN PUBLIC KEY')) return trimmed;
53
+
54
+ const base64Body = trimmed.replace(/\s+/gu, '');
55
+ const chunks = base64Body.match(/.{1,64}/gu) ?? [];
56
+ return [
57
+ '-----BEGIN PUBLIC KEY-----',
58
+ ...chunks,
59
+ '-----END PUBLIC KEY-----',
60
+ ].join('\n');
61
+ }
62
+
63
+ function pemToDerBytes(publicKeyPem: string): Uint8Array {
64
+ const base64Body = publicKeyPem
65
+ .replace(/-----BEGIN PUBLIC KEY-----/gu, '')
66
+ .replace(/-----END PUBLIC KEY-----/gu, '')
67
+ .replace(/\s+/gu, '');
68
+ return Uint8Array.from(atob(base64Body), (char) => char.charCodeAt(0));
69
+ }
70
+
71
+ function base64ToBytes(value: string): Uint8Array {
72
+ return Uint8Array.from(atob(value), (char) => char.charCodeAt(0));
73
+ }
74
+
75
+ function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
76
+ return bytes.buffer.slice(
77
+ bytes.byteOffset,
78
+ bytes.byteOffset + bytes.byteLength,
79
+ ) as ArrayBuffer;
80
+ }
81
+
82
+ async function verifyWebhookSignature(params: {
83
+ payloadBytes: Uint8Array;
84
+ timestamp: string;
85
+ signatureBase64: string;
86
+ publicKey: string;
87
+ }): Promise<boolean> {
88
+ const signature = base64ToBytes(params.signatureBase64);
89
+ const pem = normalizePublicKey(params.publicKey);
90
+ const key = await crypto.subtle.importKey(
91
+ 'spki',
92
+ toArrayBuffer(pemToDerBytes(pem)),
93
+ { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
94
+ false,
95
+ ['verify'],
96
+ );
97
+
98
+ const timestampBytes = new TextEncoder().encode(params.timestamp);
99
+ const signedPayload = new Uint8Array(
100
+ timestampBytes.length + params.payloadBytes.length,
101
+ );
102
+ signedPayload.set(timestampBytes, 0);
103
+ signedPayload.set(params.payloadBytes, timestampBytes.length);
104
+
105
+ return crypto.subtle.verify(
106
+ { name: 'RSASSA-PKCS1-v1_5' },
107
+ key,
108
+ toArrayBuffer(signature),
109
+ toArrayBuffer(signedPayload),
110
+ );
111
+ }
112
+
113
+ function toRecord(value: unknown): Record<string, unknown> | undefined {
114
+ return typeof value === 'object' && value !== null
115
+ ? (value as Record<string, unknown>)
116
+ : undefined;
117
+ }
118
+
119
+ function eventTypeOf(payload: Record<string, unknown>): string {
120
+ const rawEvent = payload['event'];
121
+ if (typeof rawEvent === 'string' && rawEvent.trim().length > 0) {
122
+ return rawEvent.trim().toLowerCase();
123
+ }
124
+ return 'unknown';
125
+ }
126
+
127
+ /**
128
+ * Truncated User-Agent for log lines. MCP stdio uses stdout for JSON-RPC only,
129
+ * so info logs use stderr — many IDEs label that stream as `[error]` even when it is not a failure.
130
+ */
131
+ function formatUaSuffix(request: Request): string {
132
+ const ua = request.headers.get('user-agent') ?? '';
133
+ const uaShort = ua.length > 96 ? `${ua.slice(0, 93)}...` : ua;
134
+ return uaShort ? ` ua=${JSON.stringify(uaShort)}` : '';
135
+ }
136
+
137
+ function logWebhookTraffic(
138
+ runtime: ReceiverRuntime,
139
+ label: string,
140
+ request: Request,
141
+ extra?: string,
142
+ ) {
143
+ if (!runtime.verbose) return;
144
+ const pathname = new URL(request.url).pathname;
145
+ logDebug(
146
+ `webhook ${label} ${request.method} ${pathname}` +
147
+ formatUaSuffix(request) +
148
+ (extra ? ` | ${extra}` : ''),
149
+ );
150
+ }
151
+
152
+ function formatKeyValueBlock(fields: Array<[string, string]>): string {
153
+ const maxKey = fields.reduce((m, [k]) => Math.max(m, k.length), 0);
154
+ return fields.map(([k, v]) => ` ${k.padEnd(maxKey, ' ')} ${v}`).join('\n');
155
+ }
156
+
157
+ function pushStoredEvents(
158
+ events: ReceivedWebhookEvent[],
159
+ maxStoredEvents: number,
160
+ ) {
161
+ if (events.length === 0) return;
162
+ storedEvents.push(...events);
163
+ if (storedEvents.length > maxStoredEvents) {
164
+ const overflow = storedEvents.length - maxStoredEvents;
165
+ storedEvents.splice(0, overflow);
166
+ }
167
+ }
168
+
169
+ export function startWebhookReceiverFromEnv() {
170
+ const env = Bun.env;
171
+ const verbose = parseBooleanEnv(env['SENDGRID_EVENT_WEBHOOK_VERBOSE'], false);
172
+ const portRaw = env['SENDGRID_EVENT_WEBHOOK_PORT'];
173
+ if (!portRaw || portRaw.trim().length === 0) {
174
+ if (verbose) {
175
+ logDebug(
176
+ 'Webhook receiver disabled (SENDGRID_EVENT_WEBHOOK_PORT not set)',
177
+ );
178
+ }
179
+ return;
180
+ }
181
+ if (listening) return;
182
+
183
+ const parsedPort = Number(portRaw);
184
+ if (!Number.isInteger(parsedPort) || parsedPort <= 0) {
185
+ throw new Error(
186
+ `Invalid SENDGRID_EVENT_WEBHOOK_PORT: "${portRaw}". Expected positive integer.`,
187
+ );
188
+ }
189
+
190
+ const host = env['SENDGRID_EVENT_WEBHOOK_HOST']?.trim() || '0.0.0.0';
191
+ const path = env['SENDGRID_EVENT_WEBHOOK_PATH']?.trim() || '/sendgrid/events';
192
+ const healthPath =
193
+ env['SENDGRID_EVENT_WEBHOOK_HEALTH_PATH']?.trim() ||
194
+ '/sendgrid/events/health';
195
+ const signatureRequired = parseBooleanEnv(
196
+ env['SENDGRID_EVENT_WEBHOOK_REQUIRE_SIGNATURE'],
197
+ false,
198
+ );
199
+ const publicKey = env['SENDGRID_EVENT_WEBHOOK_PUBLIC_KEY']?.trim();
200
+ const maxStoredEvents = Math.max(
201
+ 100,
202
+ Number(env['SENDGRID_EVENT_WEBHOOK_MAX_EVENTS'] ?? '5000') || 5000,
203
+ );
204
+
205
+ if (signatureRequired && !publicKey) {
206
+ throw new Error(
207
+ 'SENDGRID_EVENT_WEBHOOK_REQUIRE_SIGNATURE=true requires SENDGRID_EVENT_WEBHOOK_PUBLIC_KEY.',
208
+ );
209
+ }
210
+
211
+ runtime = {
212
+ host,
213
+ port: parsedPort,
214
+ path,
215
+ healthPath,
216
+ verbose,
217
+ signatureRequired,
218
+ publicKey,
219
+ maxStoredEvents,
220
+ };
221
+
222
+ try {
223
+ webhookServer = Bun.serve({
224
+ hostname: host,
225
+ port: parsedPort,
226
+ fetch: async (request) => {
227
+ if (!runtime) {
228
+ return new Response('Receiver runtime missing', { status: 500 });
229
+ }
230
+
231
+ const url = new URL(request.url);
232
+ if (url.pathname === runtime.healthPath) {
233
+ logWebhookTraffic(
234
+ runtime,
235
+ 'health',
236
+ request,
237
+ request.method === 'GET' ? '200' : '405 expected GET',
238
+ );
239
+ if (request.method === 'GET') {
240
+ return new Response(
241
+ JSON.stringify(getWebhookReceiverStatus(), null, 2),
242
+ {
243
+ status: 200,
244
+ headers: { 'content-type': 'application/json' },
245
+ },
246
+ );
247
+ }
248
+ return new Response('Method Not Allowed', { status: 405 });
249
+ }
250
+
251
+ if (url.pathname !== runtime.path) {
252
+ return new Response('Not found', { status: 404 });
253
+ }
254
+
255
+ const uaSuffix = formatUaSuffix(request);
256
+
257
+ if (request.method !== 'POST') {
258
+ if (runtime.verbose) {
259
+ logDebug(
260
+ `webhook event ${request.method} ${runtime.path}${uaSuffix} -> 405 (expected POST)`,
261
+ );
262
+ }
263
+ return new Response('Method Not Allowed', { status: 405 });
264
+ }
265
+
266
+ try {
267
+ const payloadBytes = new Uint8Array(await request.arrayBuffer());
268
+ const payloadText = new TextDecoder().decode(payloadBytes);
269
+ const signature = request.headers.get(
270
+ 'x-twilio-email-event-webhook-signature',
271
+ );
272
+ const timestamp = request.headers.get(
273
+ 'x-twilio-email-event-webhook-timestamp',
274
+ );
275
+
276
+ let signatureVerified = false;
277
+ if (runtime.publicKey && signature && timestamp) {
278
+ signatureVerified = await verifyWebhookSignature({
279
+ payloadBytes,
280
+ timestamp,
281
+ signatureBase64: signature,
282
+ publicKey: runtime.publicKey,
283
+ });
284
+ } else if (!runtime.publicKey && !runtime.signatureRequired) {
285
+ signatureVerified = false;
286
+ }
287
+
288
+ if (runtime.signatureRequired && !signatureVerified) {
289
+ lastError =
290
+ 'Signature verification failed for incoming webhook payload.';
291
+ if (runtime.verbose) {
292
+ logWarn(
293
+ `webhook event POST ${runtime.path}${uaSuffix} -> 401 signature_verify failed bytes=${payloadBytes.length}`,
294
+ );
295
+ }
296
+ return new Response(
297
+ JSON.stringify({ error: 'invalid webhook signature' }),
298
+ { status: 401, headers: { 'content-type': 'application/json' } },
299
+ );
300
+ }
301
+
302
+ const parsed = JSON.parse(payloadText) as unknown;
303
+ const records = Array.isArray(parsed) ? parsed : [parsed];
304
+ const normalized = records
305
+ .map(toRecord)
306
+ .filter(
307
+ (record): record is Record<string, unknown> => record !== undefined,
308
+ )
309
+ .map((record) => ({
310
+ receivedAt: new Date().toISOString(),
311
+ signatureVerified,
312
+ payload: record,
313
+ }));
314
+
315
+ pushStoredEvents(normalized, runtime.maxStoredEvents);
316
+ lastError = undefined;
317
+
318
+ if (runtime.verbose) {
319
+ const types = normalized.map((item) => eventTypeOf(item.payload));
320
+ const typeSummary = types.reduce<Record<string, number>>((acc, t) => {
321
+ acc[t] = (acc[t] ?? 0) + 1;
322
+ return acc;
323
+ }, {});
324
+ logDebug(
325
+ [
326
+ `webhook event POST ${runtime.path}${uaSuffix} -> 200`,
327
+ formatKeyValueBlock([
328
+ ['accepted', String(normalized.length)],
329
+ ['signatureVerified', signatureVerified ? 'true' : 'false'],
330
+ ['bytes', String(payloadBytes.length)],
331
+ ['types', JSON.stringify(typeSummary)],
332
+ ]),
333
+ ].join('\n'),
334
+ );
335
+ }
336
+
337
+ return new Response(
338
+ JSON.stringify({
339
+ accepted: normalized.length,
340
+ signatureVerified,
341
+ }),
342
+ { status: 200, headers: { 'content-type': 'application/json' } },
343
+ );
344
+ } catch (error) {
345
+ lastError = String(error);
346
+ if (runtime.verbose) {
347
+ logWarn(
348
+ `webhook event POST ${runtime.path}${uaSuffix} -> 400 parse error: ${lastError}`,
349
+ );
350
+ }
351
+ return new Response(
352
+ JSON.stringify({
353
+ error: 'invalid webhook payload',
354
+ details: lastError,
355
+ }),
356
+ { status: 400, headers: { 'content-type': 'application/json' } },
357
+ );
358
+ }
359
+ },
360
+ });
361
+ } catch (error) {
362
+ runtime = undefined;
363
+ logWarn(
364
+ `Webhook receiver disabled (port ${parsedPort} unavailable): ${String(error)}`,
365
+ );
366
+ return;
367
+ }
368
+
369
+ listening = true;
370
+
371
+ if (verbose) {
372
+ logInfo(
373
+ [
374
+ 'Webhook receiver started',
375
+ `listen=http://${host}:${parsedPort}${path}`,
376
+ `health=http://${host}:${parsedPort}${healthPath}`,
377
+ `signed_required=${signatureRequired ? 'yes' : 'no'}`,
378
+ `signed_configured=${publicKey ? 'yes' : 'no'}`,
379
+ `max_events=${maxStoredEvents}`,
380
+ ].join(' | '),
381
+ );
382
+ }
383
+ }
384
+
385
+ export function stopWebhookReceiver() {
386
+ if (!webhookServer) return;
387
+ try {
388
+ webhookServer.stop();
389
+ logInfo('Webhook receiver stopped');
390
+ } catch (error) {
391
+ logWarn(`Failed to stop webhook receiver: ${String(error)}`);
392
+ } finally {
393
+ webhookServer = undefined;
394
+ runtime = undefined;
395
+ listening = false;
396
+ }
397
+ }
398
+
399
+ export function getWebhookReceiverStatus(): WebhookReceiverStatus {
400
+ const breakdown: Record<string, number> = {};
401
+ for (const event of storedEvents) {
402
+ const type = eventTypeOf(event.payload);
403
+ breakdown[type] = (breakdown[type] ?? 0) + 1;
404
+ }
405
+
406
+ return {
407
+ enabled: runtime !== undefined,
408
+ listening,
409
+ host: runtime?.host,
410
+ port: runtime?.port,
411
+ path: runtime?.path,
412
+ maxStoredEvents: runtime?.maxStoredEvents ?? 0,
413
+ storedEvents: storedEvents.length,
414
+ eventTypeBreakdown: breakdown,
415
+ signatureRequired: runtime?.signatureRequired ?? false,
416
+ signatureConfigured: Boolean(runtime?.publicKey),
417
+ lastReceivedAt: storedEvents[storedEvents.length - 1]?.receivedAt,
418
+ lastError,
419
+ };
420
+ }
421
+
422
+ export function getStoredWebhookEvents(params?: {
423
+ limit?: number;
424
+ eventType?: string;
425
+ email?: string;
426
+ messageId?: string;
427
+ onlyVerified?: boolean;
428
+ }): ReceivedWebhookEvent[] {
429
+ const limit = Math.max(1, Math.min(params?.limit ?? 50, 5000));
430
+ const eventType = params?.eventType?.trim().toLowerCase();
431
+ const email = params?.email?.trim().toLowerCase();
432
+ const messageId = params?.messageId?.trim().toLowerCase();
433
+
434
+ const filtered = storedEvents.filter((item) => {
435
+ if (params?.onlyVerified && !item.signatureVerified) return false;
436
+
437
+ if (eventType) {
438
+ const current = eventTypeOf(item.payload);
439
+ if (current !== eventType) return false;
440
+ }
441
+
442
+ if (email) {
443
+ const payloadEmail = item.payload['email'];
444
+ if (
445
+ typeof payloadEmail !== 'string' ||
446
+ payloadEmail.toLowerCase() !== email
447
+ ) {
448
+ return false;
449
+ }
450
+ }
451
+
452
+ if (messageId) {
453
+ const msg = item.payload['sg_message_id'];
454
+ if (typeof msg !== 'string' || !msg.toLowerCase().includes(messageId)) {
455
+ return false;
456
+ }
457
+ }
458
+
459
+ return true;
460
+ });
461
+
462
+ return filtered.slice(-limit).reverse();
463
+ }
464
+
465
+ export function clearStoredWebhookEvents() {
466
+ storedEvents.length = 0;
467
+ }