@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.
package/src/config.ts ADDED
@@ -0,0 +1,197 @@
1
+ import { z } from 'zod';
2
+
3
+ const RegionSchema = z
4
+ .string()
5
+ .trim()
6
+ .toLowerCase()
7
+ .pipe(z.enum(['global', 'eu']));
8
+
9
+ const HttpUrlSchema = z.url({ protocol: /^https?$/ });
10
+
11
+ const ConfigInputSchema = z.object({
12
+ apiKey: z.string().min(1),
13
+ fromEmail: z.email(),
14
+ fromName: z.string().min(1).default('SendGrid MCP'),
15
+ region: RegionSchema.default('global'),
16
+ apiBaseUrl: HttpUrlSchema.optional(),
17
+ onBehalfOf: z.string().trim().min(1).optional(),
18
+ });
19
+
20
+ export type McpTransport = 'stdio' | 'http';
21
+ export type HttpAuthMode = 'token' | 'none';
22
+
23
+ export interface HttpEndpointConfig {
24
+ host: string;
25
+ port: number;
26
+ authMode: HttpAuthMode;
27
+ authToken?: string;
28
+ allowedHosts: string[];
29
+ allowedOrigins: string[];
30
+ trustProxy: boolean;
31
+ tlsKeyFile?: string;
32
+ tlsCertFile?: string;
33
+ }
34
+
35
+ export type AppConfig = z.output<typeof ConfigInputSchema> & {
36
+ apiBaseUrl: string;
37
+ preferEmailLogs: boolean;
38
+ transport: McpTransport;
39
+ http?: HttpEndpointConfig;
40
+ };
41
+
42
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']);
43
+
44
+ const CONFIG_HELP = `SendGrid MCP credentials missing.
45
+
46
+ Set SENDGRID_API_KEY and SENDGRID_FROM_EMAIL.
47
+ Optional: SENDGRID_REGION=global|eu (default global), SENDGRID_API_BASE_URL, SENDGRID_FROM_NAME, SENDGRID_ON_BEHALF_OF, READ_ONLY=true.
48
+ Remote MCP: MCP_TRANSPORT=http, MCP_HTTP_HOST, MCP_HTTP_PORT, MCP_AUTH_TOKEN.
49
+ Non-loopback HTTP requires MCP_AUTH_TOKEN and either TLS files or MCP_TRUST_PROXY=true.`;
50
+
51
+ function blankToUndefined(value: string | undefined): string | undefined {
52
+ const trimmed = value?.trim();
53
+ return trimmed ? trimmed : undefined;
54
+ }
55
+
56
+ export function loadConfig(
57
+ env: Record<string, string | undefined> = Bun.env,
58
+ ): AppConfig {
59
+ const apiKey = blankToUndefined(env['SENDGRID_API_KEY']);
60
+ const fromEmail = blankToUndefined(env['SENDGRID_FROM_EMAIL']);
61
+ if (!apiKey || !fromEmail) {
62
+ throw new Error(CONFIG_HELP);
63
+ }
64
+
65
+ const region = blankToUndefined(env['SENDGRID_REGION']);
66
+ const apiBaseUrl = blankToUndefined(env['SENDGRID_API_BASE_URL']);
67
+ const fromName = blankToUndefined(env['SENDGRID_FROM_NAME']);
68
+ const onBehalfOf = blankToUndefined(env['SENDGRID_ON_BEHALF_OF']);
69
+
70
+ const parsed = ConfigInputSchema.safeParse({
71
+ apiKey,
72
+ fromEmail,
73
+ ...(fromName ? { fromName } : {}),
74
+ ...(region ? { region } : {}),
75
+ ...(apiBaseUrl ? { apiBaseUrl } : {}),
76
+ ...(onBehalfOf ? { onBehalfOf } : {}),
77
+ });
78
+ if (!parsed.success) {
79
+ throw new Error(
80
+ `Invalid SendGrid MCP config: ${z.prettifyError(parsed.error)}`,
81
+ );
82
+ }
83
+
84
+ const resolvedBaseUrl =
85
+ parsed.data.apiBaseUrl ??
86
+ (parsed.data.region === 'eu'
87
+ ? 'https://api.eu.sendgrid.com/v3'
88
+ : 'https://api.sendgrid.com/v3');
89
+
90
+ const transport = resolveTransport(env);
91
+ const http = transport === 'http' ? resolveHttpEndpoint(env) : undefined;
92
+
93
+ return {
94
+ ...parsed.data,
95
+ apiBaseUrl: resolvedBaseUrl.replace(/\/+$/u, ''),
96
+ preferEmailLogs: parsed.data.region === 'eu',
97
+ transport,
98
+ ...(http ? { http } : {}),
99
+ };
100
+ }
101
+
102
+ function resolveTransport(env: Record<string, string | undefined>): McpTransport {
103
+ const raw = blankToUndefined(env['MCP_TRANSPORT'])?.toLowerCase() ?? 'stdio';
104
+ if (raw === 'stdio' || raw === 'http') return raw;
105
+ throw new Error('MCP_TRANSPORT must be stdio or http');
106
+ }
107
+
108
+ function splitList(value: string | undefined): string[] {
109
+ return (value ?? '')
110
+ .split(',')
111
+ .map((item) => item.trim())
112
+ .filter((item) => item.length > 0);
113
+ }
114
+
115
+ function resolveHttpEndpoint(
116
+ env: Record<string, string | undefined>,
117
+ ): HttpEndpointConfig {
118
+ const host = blankToUndefined(env['MCP_HTTP_HOST']) ?? '127.0.0.1';
119
+ const portRaw = blankToUndefined(env['MCP_HTTP_PORT']) ?? '3000';
120
+ const port = Number(portRaw);
121
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
122
+ throw new Error('MCP_HTTP_PORT must be an integer from 1 to 65535');
123
+ }
124
+
125
+ const authRaw = blankToUndefined(env['MCP_AUTH_MODE'])?.toLowerCase() ?? 'token';
126
+ if (authRaw !== 'token' && authRaw !== 'none') {
127
+ throw new Error('MCP_AUTH_MODE must be token or none');
128
+ }
129
+ const authToken = blankToUndefined(env['MCP_AUTH_TOKEN']);
130
+ const loopback = LOOPBACK_HOSTS.has(host);
131
+ if (authRaw === 'none' && !loopback) {
132
+ throw new Error(
133
+ 'MCP_AUTH_MODE=none is only allowed when MCP_HTTP_HOST is loopback. Set MCP_AUTH_TOKEN.',
134
+ );
135
+ }
136
+ if (authRaw === 'token' && !authToken) {
137
+ throw new Error('MCP_AUTH_TOKEN is required when MCP_TRANSPORT=http and MCP_AUTH_MODE=token');
138
+ }
139
+
140
+ const tlsKeyFile = blankToUndefined(env['MCP_TLS_KEY_FILE']);
141
+ const tlsCertFile = blankToUndefined(env['MCP_TLS_CERT_FILE']);
142
+ if (Boolean(tlsKeyFile) !== Boolean(tlsCertFile)) {
143
+ throw new Error('MCP_TLS_KEY_FILE and MCP_TLS_CERT_FILE must be set together');
144
+ }
145
+ const trustProxy = ['1', 'true', 'yes', 'on'].includes(
146
+ blankToUndefined(env['MCP_TRUST_PROXY'])?.toLowerCase() ?? '',
147
+ );
148
+ if (!loopback && !tlsKeyFile && !trustProxy) {
149
+ throw new Error(
150
+ `Refusing plaintext HTTP on ${host}. Set MCP_TLS_KEY_FILE and MCP_TLS_CERT_FILE, or MCP_TRUST_PROXY=true behind a TLS proxy.`,
151
+ );
152
+ }
153
+
154
+ const allowedHosts = splitList(env['MCP_ALLOWED_HOSTS']);
155
+ const allowedOrigins = splitList(env['MCP_ALLOWED_ORIGINS']);
156
+ if (!loopback && allowedHosts.length === 0) {
157
+ throw new Error(
158
+ 'MCP_ALLOWED_HOSTS is required when MCP_HTTP_HOST is not loopback',
159
+ );
160
+ }
161
+
162
+ return {
163
+ host,
164
+ port,
165
+ authMode: authRaw,
166
+ ...(authToken ? { authToken } : {}),
167
+ allowedHosts,
168
+ allowedOrigins,
169
+ trustProxy,
170
+ ...(tlsKeyFile ? { tlsKeyFile } : {}),
171
+ ...(tlsCertFile ? { tlsCertFile } : {}),
172
+ };
173
+ }
174
+
175
+ export const MAX_TOOL_INPUT_ELEMENTS = 10_000;
176
+
177
+ export function buildServerInstructions(config: AppConfig): string {
178
+ const behalf = config.onBehalfOf
179
+ ? `on-behalf-of: ${config.onBehalfOf}`
180
+ : 'on-behalf-of: not set';
181
+
182
+ return `SendGrid MCP for transactional email, dynamic templates, delivery diagnostics, and console settings. Contact and list marketing CRUD is out of scope.
183
+
184
+ This instance: region ${config.region}, API ${config.apiBaseUrl}, from ${config.fromEmail}. ${behalf}.
185
+ READ_ONLY is checked on each call. When true, send and mutating tools return an error before any SendGrid request.
186
+
187
+ Workflow:
188
+ 1. Safe send: sendgrid_validate_send_request, then sendgrid_send_with_preflight. sendgrid_send_email_advanced skips those checks.
189
+ 2. POST /v3/mail/send returns x-message-id, which is not msg_id. Pass that header as xMessageId to sendgrid_search_message_activity (compiled to msg_id LIKE '<id>%'), then sendgrid_get_message_activity for the event chain (reason, bounce_type, asm_group_id, outbound_ip).
190
+ 3. Delivery incident: search activity, sendgrid_check_suppression, sendgrid_triage_delivery_issue. Remove one suppression with sendgrid_delete_suppression and confirmToken "CONFIRM". GET /v3/messages has no offset. On Activity 403/404, and on an empty Activity result when region is eu, use Email Logs (sendgrid_search_email_logs).
191
+ 4. 403: sendgrid_get_scopes. Email Activity may require the add-on. Browser, device, and client stats retain about 7 days.
192
+ 5. Event Webhook config is in SendGrid. A local receiver exists only when SENDGRID_EVENT_WEBHOOK_PORT is set; its buffer is in-memory.
193
+ 6. Mutating tools require confirmToken "CONFIRM". Tool arguments are rejected above ${MAX_TOOL_INPUT_ELEMENTS} combined array elements and object members. Responses redact oauth_client_secret and api_key values.
194
+ 7. Subusers: sendgrid_list_subusers, then pass username as onBehalfOf. onBehalfOf="parent" ignores SENDGRID_ON_BEHALF_OF for that call.
195
+ 8. Event Webhooks: list/get/update/toggle, or sendgrid_manage_event_webhook with action create|delete|test and confirmToken "CONFIRM".
196
+ 9. Transport: ${config.transport}${config.http ? ` at ${config.http.host}:${config.http.port}/mcp (${config.http.authMode} auth)` : ''}.`;
197
+ }
package/src/http.ts ADDED
@@ -0,0 +1,132 @@
1
+ import { timingSafeEqual } from 'node:crypto';
2
+ import {
3
+ createMcpHandler,
4
+ hostHeaderValidationResponse,
5
+ localhostAllowedHostnames,
6
+ localhostAllowedOrigins,
7
+ originValidationResponse,
8
+ type AuthInfo,
9
+ type McpServer,
10
+ } from '@modelcontextprotocol/server';
11
+ import type { HttpEndpointConfig } from './config';
12
+ import { logError, logInfo } from './logger';
13
+
14
+ function tokenEquals(presented: string, expected: string): boolean {
15
+ const left = Buffer.from(presented);
16
+ const right = Buffer.from(expected);
17
+ return left.length === right.length && timingSafeEqual(left, right);
18
+ }
19
+
20
+ export function authorizeBearer(
21
+ request: Request,
22
+ expectedToken: string,
23
+ ): AuthInfo | Response {
24
+ const header = request.headers.get('authorization');
25
+ const presented = header?.match(/^Bearer\s+(\S+)\s*$/iu)?.[1];
26
+ if (!presented || !tokenEquals(presented, expectedToken)) {
27
+ return new Response(JSON.stringify({ error: 'invalid_token' }), {
28
+ status: 401,
29
+ headers: {
30
+ 'content-type': 'application/json',
31
+ 'www-authenticate': 'Bearer error="invalid_token"',
32
+ 'cache-control': 'no-store',
33
+ },
34
+ });
35
+ }
36
+
37
+ return {
38
+ token: presented,
39
+ clientId: 'static-token',
40
+ scopes: [],
41
+ expiresAt: Math.floor(Date.now() / 1000) + 3600,
42
+ };
43
+ }
44
+
45
+ export function createHttpFetch(options: {
46
+ factory: () => McpServer;
47
+ http: HttpEndpointConfig;
48
+ onerror?: (error: Error) => void;
49
+ }): {
50
+ fetch: (request: Request) => Promise<Response>;
51
+ close: () => Promise<void>;
52
+ } {
53
+ const handler = createMcpHandler(options.factory, {
54
+ legacy: 'stateless',
55
+ onerror: options.onerror,
56
+ });
57
+ const hosts =
58
+ options.http.allowedHosts.length > 0
59
+ ? options.http.allowedHosts
60
+ : localhostAllowedHostnames();
61
+ const origins =
62
+ options.http.allowedOrigins.length > 0
63
+ ? options.http.allowedOrigins
64
+ : localhostAllowedOrigins();
65
+
66
+ return {
67
+ close: () => handler.close(),
68
+ fetch: async (request) => {
69
+ const url = new URL(request.url);
70
+ if (
71
+ request.method === 'GET' &&
72
+ (url.pathname === '/health' || url.pathname === '/health/')
73
+ ) {
74
+ return Response.json({ status: 'ok' });
75
+ }
76
+ if (url.pathname !== '/mcp' && url.pathname !== '/mcp/') {
77
+ return new Response('Not found', { status: 404 });
78
+ }
79
+
80
+ const rejected =
81
+ hostHeaderValidationResponse(request, hosts) ??
82
+ originValidationResponse(request, origins);
83
+ if (rejected) return rejected;
84
+
85
+ let authInfo: AuthInfo | undefined;
86
+ if (options.http.authMode === 'token') {
87
+ const auth = authorizeBearer(request, options.http.authToken ?? '');
88
+ if (auth instanceof Response) return auth;
89
+ authInfo = auth;
90
+ }
91
+
92
+ return handler.fetch(request, authInfo ? { authInfo } : undefined);
93
+ },
94
+ };
95
+ }
96
+
97
+ export function startHttpServer(options: {
98
+ factory: () => McpServer;
99
+ http: HttpEndpointConfig;
100
+ }): { url: string; stop: () => Promise<void> } {
101
+ const endpoint = createHttpFetch({
102
+ factory: options.factory,
103
+ http: options.http,
104
+ onerror: (error) => {
105
+ logError(`MCP HTTP error: ${error.message}`);
106
+ },
107
+ });
108
+ const tls =
109
+ options.http.tlsKeyFile && options.http.tlsCertFile
110
+ ? {
111
+ key: Bun.file(options.http.tlsKeyFile),
112
+ cert: Bun.file(options.http.tlsCertFile),
113
+ }
114
+ : undefined;
115
+ const server = Bun.serve({
116
+ hostname: options.http.host,
117
+ port: options.http.port,
118
+ fetch: endpoint.fetch,
119
+ ...(tls ? { tls } : {}),
120
+ });
121
+ const scheme = tls ? 'https' : 'http';
122
+ const url = `${scheme}://${server.hostname ?? options.http.host}:${server.port}/mcp`;
123
+ logInfo(`HTTP MCP listening on ${url}`);
124
+
125
+ return {
126
+ url,
127
+ stop: async () => {
128
+ await endpoint.close();
129
+ await server.stop(true);
130
+ },
131
+ };
132
+ }
package/src/index.ts ADDED
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env bun
2
+ import { McpServer } from '@modelcontextprotocol/server';
3
+ import {
4
+ serveStdio,
5
+ StdioServerTransport,
6
+ } from '@modelcontextprotocol/server/stdio';
7
+ import { version } from '../package.json';
8
+ import { SendGridClient } from './client';
9
+ import {
10
+ buildServerInstructions,
11
+ loadConfig,
12
+ MAX_TOOL_INPUT_ELEMENTS,
13
+ } from './config';
14
+ import { startHttpServer } from './http';
15
+ import { logError, logInfo, logWarn } from './logger';
16
+ import { registerDiagnosticsTools } from './tools/diagnostics';
17
+ import { registerEmailTools } from './tools/email';
18
+ import { registerPreflightTools } from './tools/preflight';
19
+ import { registerSyncTools } from './tools/sync';
20
+ import { registerTemplateTools } from './tools/templates';
21
+ import { registerAccountTools } from './tools/account';
22
+ import { registerConsoleSettingsTools } from './tools/console_settings';
23
+ import {
24
+ startWebhookReceiverFromEnv,
25
+ stopWebhookReceiver,
26
+ } from './webhook_receiver';
27
+
28
+ function buildServer(
29
+ client: SendGridClient,
30
+ fromEmail: string,
31
+ fromName: string,
32
+ instructions: string,
33
+ ): McpServer {
34
+ const server = new McpServer(
35
+ {
36
+ name: 'sendgrid-mcp-server',
37
+ version,
38
+ },
39
+ {
40
+ instructions,
41
+ maxToolInputElements: MAX_TOOL_INPUT_ELEMENTS,
42
+ },
43
+ );
44
+
45
+ registerTemplateTools(server, client);
46
+ registerEmailTools(server, client, fromEmail, fromName);
47
+ registerPreflightTools(server, client);
48
+ registerDiagnosticsTools(server, client);
49
+ registerSyncTools(server, client);
50
+ registerAccountTools(server, client);
51
+ registerConsoleSettingsTools(server, client);
52
+
53
+ return server;
54
+ }
55
+
56
+ async function main() {
57
+ const env = loadConfig();
58
+ if (!env.apiKey.startsWith('SG.')) {
59
+ logWarn('SENDGRID_API_KEY does not start with SG.');
60
+ }
61
+ try {
62
+ startWebhookReceiverFromEnv();
63
+ } catch (error) {
64
+ logWarn(`Webhook receiver not started: ${String(error)}`);
65
+ }
66
+ const client = new SendGridClient(env.apiKey, env.apiBaseUrl, {
67
+ onBehalfOf: env.onBehalfOf,
68
+ preferEmailLogs: env.preferEmailLogs,
69
+ });
70
+ const instructions = buildServerInstructions(env);
71
+ const factory = () =>
72
+ buildServer(client, env.fromEmail, env.fromName, instructions);
73
+
74
+ if (env.transport === 'http' && env.http) {
75
+ const endpoint = startHttpServer({ factory, http: env.http });
76
+ let shuttingDown = false;
77
+ const shutdown = async (signal: string) => {
78
+ if (shuttingDown) return;
79
+ shuttingDown = true;
80
+ logInfo(`Shutdown requested (${signal})`);
81
+ try {
82
+ await endpoint.stop();
83
+ } finally {
84
+ stopWebhookReceiver();
85
+ process.exit(0);
86
+ }
87
+ };
88
+ process.on('SIGINT', () => {
89
+ void shutdown('SIGINT');
90
+ });
91
+ process.on('SIGTERM', () => {
92
+ void shutdown('SIGTERM');
93
+ });
94
+ logInfo('Server started');
95
+ return;
96
+ }
97
+
98
+ // serveStdio owns onclose. Wrap close so the webhook listener (a keep-alive
99
+ // handle) is released when stdin ends or the process is signalled.
100
+ const transport = new StdioServerTransport();
101
+ const closeTransport = transport.close.bind(transport);
102
+ transport.close = async () => {
103
+ try {
104
+ await closeTransport();
105
+ } finally {
106
+ logInfo('Transport closed');
107
+ stopWebhookReceiver();
108
+ }
109
+ };
110
+
111
+ const handle = serveStdio(factory,
112
+ {
113
+ transport,
114
+ onerror: (error) => {
115
+ logError(`MCP transport error: ${error.message}`);
116
+ },
117
+ },
118
+ );
119
+
120
+ let shuttingDown = false;
121
+ const shutdown = async (signal: string) => {
122
+ if (shuttingDown) return;
123
+ shuttingDown = true;
124
+ logInfo(`Shutdown requested (${signal})`);
125
+ try {
126
+ await handle.close();
127
+ } finally {
128
+ stopWebhookReceiver();
129
+ process.exit(0);
130
+ }
131
+ };
132
+
133
+ process.on('SIGINT', () => {
134
+ void shutdown('SIGINT');
135
+ });
136
+ process.on('SIGTERM', () => {
137
+ void shutdown('SIGTERM');
138
+ });
139
+
140
+ logInfo('Server started');
141
+ }
142
+
143
+ main().catch((err) => {
144
+ logError(`Fatal: ${String(err)}`);
145
+ stopWebhookReceiver();
146
+ process.exit(1);
147
+ });
package/src/logger.ts ADDED
@@ -0,0 +1,43 @@
1
+ type LogLevel = 'debug' | 'info' | 'warn' | 'error';
2
+
3
+ const LEVEL_ORDER: Record<LogLevel, number> = {
4
+ debug: 10,
5
+ info: 20,
6
+ warn: 30,
7
+ error: 40,
8
+ };
9
+
10
+ function parseLevel(input: string | undefined): LogLevel {
11
+ const normalized = (input ?? '').trim().toLowerCase();
12
+ if (normalized === 'debug') return 'debug';
13
+ if (normalized === 'warn') return 'warn';
14
+ if (normalized === 'error') return 'error';
15
+ return 'info';
16
+ }
17
+
18
+ const currentLevel = parseLevel(Bun.env['SENDGRID_MCP_LOG_LEVEL']);
19
+
20
+ function canLog(level: LogLevel): boolean {
21
+ return LEVEL_ORDER[level] >= LEVEL_ORDER[currentLevel];
22
+ }
23
+
24
+ export function log(level: LogLevel, message: string): void {
25
+ if (!canLog(level)) return;
26
+ process.stderr.write(`[sendgrid-mcp] ${level.toUpperCase()} ${message}\n`);
27
+ }
28
+
29
+ export function logDebug(message: string): void {
30
+ log('debug', message);
31
+ }
32
+
33
+ export function logInfo(message: string): void {
34
+ log('info', message);
35
+ }
36
+
37
+ export function logWarn(message: string): void {
38
+ log('warn', message);
39
+ }
40
+
41
+ export function logError(message: string): void {
42
+ log('error', message);
43
+ }
package/src/redact.ts ADDED
@@ -0,0 +1,91 @@
1
+ const SECRET_KEYS = new Set([
2
+ 'oauth_client_secret',
3
+ 'api_key',
4
+ 'apikey',
5
+ 'authorization',
6
+ 'password',
7
+ ]);
8
+
9
+ const JSON_SECRET_STRING =
10
+ /"(oauth_client_secret|api_key|apikey|authorization|password)"\s*:\s*("(?:\\.|[^"\\])*"|null)/gi;
11
+
12
+ export function formatRedactedSecret(value: unknown): string {
13
+ if (typeof value === 'string' && value.startsWith('<redacted')) {
14
+ return value;
15
+ }
16
+ if (typeof value === 'string') {
17
+ return `<redacted len=${value.length}>`;
18
+ }
19
+ if (value == null) return '<redacted>';
20
+ return '<redacted>';
21
+ }
22
+
23
+ export function redactSensitiveFields(value: unknown): unknown {
24
+ if (Array.isArray(value)) {
25
+ return value.map(redactSensitiveFields);
26
+ }
27
+
28
+ if (value !== null && typeof value === 'object') {
29
+ const out: Record<string, unknown> = {};
30
+ for (const [key, nested] of Object.entries(
31
+ value as Record<string, unknown>,
32
+ )) {
33
+ out[key] = SECRET_KEYS.has(key.toLowerCase())
34
+ ? formatRedactedSecret(nested)
35
+ : redactSensitiveFields(nested);
36
+ }
37
+ return out;
38
+ }
39
+
40
+ return value;
41
+ }
42
+
43
+ export function redactSecretsInText(text: string): string {
44
+ if (!JSON_SECRET_STRING.test(text)) {
45
+ JSON_SECRET_STRING.lastIndex = 0;
46
+ return text;
47
+ }
48
+ JSON_SECRET_STRING.lastIndex = 0;
49
+
50
+ try {
51
+ return JSON.stringify(redactSensitiveFields(JSON.parse(text)), null, 2);
52
+ } catch {
53
+ JSON_SECRET_STRING.lastIndex = 0;
54
+ return text.replace(JSON_SECRET_STRING, (_match, key: string, raw: string) => {
55
+ try {
56
+ return `"${key}": ${JSON.stringify(formatRedactedSecret(JSON.parse(raw)))}`;
57
+ } catch {
58
+ return `"${key}": ${JSON.stringify(formatRedactedSecret(undefined))}`;
59
+ }
60
+ });
61
+ }
62
+ }
63
+
64
+ export function redactToolResult(result: unknown): unknown {
65
+ if (!result || typeof result !== 'object') return result;
66
+ const record = result as Record<string, unknown>;
67
+ const content = Array.isArray(record['content'])
68
+ ? record['content'].map((block) => {
69
+ if (
70
+ block &&
71
+ typeof block === 'object' &&
72
+ (block as { type?: unknown }).type === 'text' &&
73
+ typeof (block as { text?: unknown }).text === 'string'
74
+ ) {
75
+ return {
76
+ ...(block as Record<string, unknown>),
77
+ text: redactSecretsInText((block as { text: string }).text),
78
+ };
79
+ }
80
+ return block;
81
+ })
82
+ : record['content'];
83
+
84
+ return {
85
+ ...record,
86
+ ...(content !== undefined ? { content } : {}),
87
+ ...(record['structuredContent'] !== undefined
88
+ ? { structuredContent: redactSensitiveFields(record['structuredContent']) }
89
+ : {}),
90
+ };
91
+ }
@@ -0,0 +1,50 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+
3
+ const toolAbortSignal = new AsyncLocalStorage<AbortSignal>();
4
+ const onBehalfOfStore = new AsyncLocalStorage<string>();
5
+
6
+ /** Call the parent account even when SENDGRID_ON_BEHALF_OF is set. */
7
+ export const PARENT_ACCOUNT = 'parent';
8
+
9
+ export function currentOnBehalfOf(): string | undefined {
10
+ return onBehalfOfStore.getStore();
11
+ }
12
+
13
+ export function resolveOnBehalfOfHeader(
14
+ override: string | undefined,
15
+ configured: string | undefined,
16
+ ): string | undefined {
17
+ if (override === PARENT_ACCOUNT) return undefined;
18
+ return override ?? configured;
19
+ }
20
+
21
+ export function runWithOnBehalfOf<T>(
22
+ value: string | undefined,
23
+ run: () => Promise<T>,
24
+ ): Promise<T> {
25
+ if (value === undefined) return run();
26
+ return onBehalfOfStore.run(value, run);
27
+ }
28
+
29
+ export function onBehalfOfFromArgs(args: unknown): string | undefined {
30
+ if (!args || typeof args !== 'object') return undefined;
31
+ const value = (args as { onBehalfOf?: unknown }).onBehalfOf;
32
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
33
+ }
34
+
35
+ export function currentToolAbortSignal(): AbortSignal | undefined {
36
+ return toolAbortSignal.getStore();
37
+ }
38
+
39
+ export function runWithToolAbortSignal<T>(
40
+ signal: AbortSignal | undefined,
41
+ run: () => Promise<T>,
42
+ ): Promise<T> {
43
+ return signal ? toolAbortSignal.run(signal, run) : run();
44
+ }
45
+
46
+ export function abortSignalFromExtra(extra: unknown): AbortSignal | undefined {
47
+ if (!extra || typeof extra !== 'object') return undefined;
48
+ const signal = (extra as { mcpReq?: { signal?: unknown } }).mcpReq?.signal;
49
+ return signal instanceof AbortSignal ? signal : undefined;
50
+ }