From d110c2e4541eb66e964cfcfaf2fc9d6bfeafa53a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 20 Jul 2026 16:07:41 -0700 Subject: [PATCH 1/5] diag(mcp): comprehensive HTTP logging for OAuth + streamable-HTTP transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Temporary diagnostic to root-cause the Gauge MCP 'initialize' hang. Wraps both the OAuth guarded fetch and the transport pinned fetch to log every request/response with timing: method, url, status, content-type, mcp-session-id, safe headers, and — for the transport phase only — the response body streamed chunk-by-chunk. So one authorize reveals exactly what Gauge returns for initialize (SSE that stalls vs never-sent result vs fast JSON) and where it stalls. Enabled by default; MCP_HTTP_DIAGNOSTICS=false to silence; disabled under test. Never logs request bodies or the OAuth token-response body (carry tokens); every logged value passes through sanitizeForLogging. Revert once root-caused. --- apps/sim/lib/mcp/client.ts | 3 +- apps/sim/lib/mcp/http-diagnostics.ts | 152 +++++++++++++++++++++++++++ apps/sim/lib/mcp/oauth/auth.ts | 6 +- 3 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 apps/sim/lib/mcp/http-diagnostics.ts diff --git a/apps/sim/lib/mcp/client.ts b/apps/sim/lib/mcp/client.ts index 80931413320..befa569abc9 100644 --- a/apps/sim/lib/mcp/client.ts +++ b/apps/sim/lib/mcp/client.ts @@ -12,6 +12,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' import { getMcpSafeErrorDiagnostics } from '@/lib/mcp/error-diagnostics' +import { withMcpHttpDiagnostics } from '@/lib/mcp/http-diagnostics' import { McpOauthRedirectRequired } from '@/lib/mcp/oauth' import { createPinnedMcpFetch } from '@/lib/mcp/pinned-fetch' import { @@ -101,7 +102,7 @@ export class McpClient { this.transport = new StreamableHTTPClientTransport(new URL(this.config.url), { authProvider: useOauth ? this.authProvider : undefined, requestInit: { headers: this.config.headers }, - ...(pinned ? { fetch: pinned.fetch } : {}), + ...(pinned ? { fetch: withMcpHttpDiagnostics(pinned.fetch, 'transport') } : {}), }) this.client = new Client( diff --git a/apps/sim/lib/mcp/http-diagnostics.ts b/apps/sim/lib/mcp/http-diagnostics.ts new file mode 100644 index 00000000000..f96d6d94f5d --- /dev/null +++ b/apps/sim/lib/mcp/http-diagnostics.ts @@ -0,0 +1,152 @@ +import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js' +import { createLogger } from '@sim/logger' +import { sanitizeForLogging } from '@/lib/core/security/redaction' +import { getMcpSafeErrorDiagnostics } from '@/lib/mcp/error-diagnostics' + +const logger = createLogger('McpHttpDiag') + +const MAX_LOGGED_CHUNKS = 40 +const PREVIEW_CHARS = 500 + +/** + * TEMPORARY diagnostic for the MCP OAuth + streamable-HTTP transport HTTP layer. + * Enabled by default; set `MCP_HTTP_DIAGNOSTICS=false` to silence. Remove once the + * Gauge `initialize` hang is root-caused. + * + * Safety: request bodies and the OAuth token-response body carry the authorization + * code / access / refresh tokens, so they are NEVER logged. Only the *transport* + * response body — MCP JSON-RPC protocol messages, which contain no credentials — is + * streamed and logged, and every logged value passes through `sanitizeForLogging`. + */ +function diagnosticsEnabled(): boolean { + if (process.env.NODE_ENV === 'test' || process.env.VITEST) return false + return process.env.MCP_HTTP_DIAGNOSTICS !== 'false' +} + +function requestUrl(input: string | URL | Request): string { + if (typeof input === 'string') return input + if (input instanceof URL) return input.href + return input.url +} + +/** + * Wraps a `FetchLike` so every request/response in the MCP OAuth or transport flow is + * logged with timing. `phase` distinguishes the two so the transport `initialize` — the + * suspected hang — can be traced chunk-by-chunk while OAuth bodies stay unlogged. + */ +export function withMcpHttpDiagnostics( + fetchFn: FetchLike, + phase: 'oauth' | 'transport' +): FetchLike { + if (!diagnosticsEnabled()) return fetchFn + + return async (input, init) => { + const url = requestUrl(input as string | URL | Request) + const method = init?.method ?? 'GET' + const reqHeaders = new Headers((init?.headers as HeadersInit | undefined) ?? undefined) + const startedAt = Date.now() + + logger.info('request', { + phase, + method, + url, + hasAuth: reqHeaders.has('authorization'), + accept: reqHeaders.get('accept') ?? undefined, + }) + + let res: Response + try { + res = (await fetchFn(input, init)) as Response + } catch (error) { + logger.warn('fetch rejected', { + phase, + method, + url, + ms: Date.now() - startedAt, + error: getMcpSafeErrorDiagnostics(error), + }) + throw error + } + + const contentType = res.headers.get('content-type') ?? '' + const safeHeaders: Record = {} + for (const [name, value] of res.headers.entries()) { + const lower = name.toLowerCase() + safeHeaders[name] = + lower === 'set-cookie' || lower === 'authorization' || lower === 'www-authenticate' + ? '' + : sanitizeForLogging(value, 200) + } + + logger.info('response', { + phase, + method, + url, + status: res.status, + contentType, + mcpSessionId: res.headers.get('mcp-session-id') ? 'present' : 'absent', + headerMs: Date.now() - startedAt, + headers: safeHeaders, + }) + + // Only the transport body is safe to log (MCP protocol JSON, no credentials). + if (phase !== 'transport' || !res.body) return res + + let logBranch: ReadableStream + let passBranch: ReadableStream + try { + ;[logBranch, passBranch] = res.body.tee() + } catch { + return res + } + + void (async () => { + const reader = logBranch.getReader() + const decoder = new TextDecoder() + let chunks = 0 + let bytes = 0 + try { + for (;;) { + const { done, value } = await reader.read() + if (done) { + logger.info('transport body complete', { + phase, + url, + chunks, + bytes, + ms: Date.now() - startedAt, + }) + break + } + chunks += 1 + bytes += value.byteLength + if (chunks <= MAX_LOGGED_CHUNKS) { + logger.info('transport body chunk', { + phase, + url, + chunk: chunks, + size: value.byteLength, + ms: Date.now() - startedAt, + preview: sanitizeForLogging(decoder.decode(value, { stream: true }), PREVIEW_CHARS), + }) + } + } + } catch (error) { + logger.warn('transport body read error', { + phase, + url, + chunks, + bytes, + ms: Date.now() - startedAt, + error: getMcpSafeErrorDiagnostics(error), + }) + } + })() + + return new Response(passBranch, { + status: res.status, + statusText: res.statusText, + headers: res.headers, + }) + } +} diff --git a/apps/sim/lib/mcp/oauth/auth.ts b/apps/sim/lib/mcp/oauth/auth.ts index e94a91b058a..226dbd0145e 100644 --- a/apps/sim/lib/mcp/oauth/auth.ts +++ b/apps/sim/lib/mcp/oauth/auth.ts @@ -1,4 +1,5 @@ import { auth, type OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js' +import { withMcpHttpDiagnostics } from '@/lib/mcp/http-diagnostics' import { createSsrfGuardedMcpFetch } from '@/lib/mcp/pinned-fetch' type McpAuthOptions = Parameters[1] @@ -15,5 +16,8 @@ export function mcpAuthGuarded( provider: OAuthClientProvider, options: McpAuthOptions ): ReturnType { - return auth(provider, { ...options, fetchFn: options.fetchFn ?? createSsrfGuardedMcpFetch() }) + return auth(provider, { + ...options, + fetchFn: withMcpHttpDiagnostics(options.fetchFn ?? createSsrfGuardedMcpFetch(), 'oauth'), + }) } From ed55598c2764411a1244429e85990aa15c24b78f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 20 Jul 2026 16:21:38 -0700 Subject: [PATCH 2/5] diag(mcp): scope body logging to initialize + redact URLs + wrap unpinned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes (Greptile/Cursor): - Only stream-log the response body whose REQUEST is an MCP initialize JSON-RPC call. The transport fetch also carries in-transport OAuth refresh and every tools/call result, so gating on phase alone leaked token responses and tool output (PII/file contents). initialize gating excludes both. - Redact URL query strings (?code=/?token=/?api_key=) — log origin+path only. - Wrap the transport fetch whether pinned or not (SDK default is globalThis.fetch, so passing it is behavior-neutral) so unpinned/allowlisted servers are covered too. --- apps/sim/lib/mcp/client.test.ts | 3 ++ apps/sim/lib/mcp/client.ts | 4 +- apps/sim/lib/mcp/http-diagnostics.ts | 75 ++++++++++++++++------------ 3 files changed, 48 insertions(+), 34 deletions(-) diff --git a/apps/sim/lib/mcp/client.test.ts b/apps/sim/lib/mcp/client.test.ts index dda637bbe88..73e77836d8f 100644 --- a/apps/sim/lib/mcp/client.test.ts +++ b/apps/sim/lib/mcp/client.test.ts @@ -381,6 +381,9 @@ describe('McpClient notification handler', () => { { authProvider, requestInit: { headers: { 'X-Sim-Via': 'workflow' } }, + // The transport fetch is always wrapped for diagnostics (a no-op passthrough + // under test); it defaults to globalThis.fetch when the server isn't pinned. + fetch: expect.any(Function), } ) }) diff --git a/apps/sim/lib/mcp/client.ts b/apps/sim/lib/mcp/client.ts index befa569abc9..3acb59cbada 100644 --- a/apps/sim/lib/mcp/client.ts +++ b/apps/sim/lib/mcp/client.ts @@ -102,7 +102,9 @@ export class McpClient { this.transport = new StreamableHTTPClientTransport(new URL(this.config.url), { authProvider: useOauth ? this.authProvider : undefined, requestInit: { headers: this.config.headers }, - ...(pinned ? { fetch: withMcpHttpDiagnostics(pinned.fetch, 'transport') } : {}), + // Wrap whether pinned or not (SDK's default is globalThis.fetch, so passing it is + // behavior-neutral) so the diagnostic also covers unpinned/allowlisted servers. + fetch: withMcpHttpDiagnostics(pinned?.fetch ?? globalThis.fetch, 'transport'), }) this.client = new Client( diff --git a/apps/sim/lib/mcp/http-diagnostics.ts b/apps/sim/lib/mcp/http-diagnostics.ts index f96d6d94f5d..c5fe8b83413 100644 --- a/apps/sim/lib/mcp/http-diagnostics.ts +++ b/apps/sim/lib/mcp/http-diagnostics.ts @@ -1,6 +1,5 @@ import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js' import { createLogger } from '@sim/logger' -import { sanitizeForLogging } from '@/lib/core/security/redaction' import { getMcpSafeErrorDiagnostics } from '@/lib/mcp/error-diagnostics' const logger = createLogger('McpHttpDiag') @@ -13,26 +12,50 @@ const PREVIEW_CHARS = 500 * Enabled by default; set `MCP_HTTP_DIAGNOSTICS=false` to silence. Remove once the * Gauge `initialize` hang is root-caused. * - * Safety: request bodies and the OAuth token-response body carry the authorization - * code / access / refresh tokens, so they are NEVER logged. Only the *transport* - * response body — MCP JSON-RPC protocol messages, which contain no credentials — is - * streamed and logged, and every logged value passes through `sanitizeForLogging`. + * Secret-safety (the wrapped transport fetch also carries in-transport OAuth + * refresh/registration, and every tool result): + * - Request and OAuth response bodies are NEVER logged. + * - The only response body streamed is the one whose REQUEST is an MCP `initialize` + * JSON-RPC call — which excludes token/refresh responses AND `tools/call` results + * (tool output can be PII/file contents/credentials). The `initialize` result is + * protocol metadata only (serverInfo, capabilities), no credentials. + * - URLs are logged origin+path only; query strings (`?code=`, `?token=`, …) are + * redacted. Sensitive headers (authorization/cookie/www-authenticate) are omitted. */ function diagnosticsEnabled(): boolean { if (process.env.NODE_ENV === 'test' || process.env.VITEST) return false return process.env.MCP_HTTP_DIAGNOSTICS !== 'false' } -function requestUrl(input: string | URL | Request): string { - if (typeof input === 'string') return input - if (input instanceof URL) return input.href - return input.url +/** Origin + path only — query values can carry `code`/`token`/`api_key`, so they're dropped. */ +function safeUrl(input: string | URL | Request): string { + const raw = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + try { + const u = new URL(raw) + return u.search ? `${u.origin}${u.pathname}?` : `${u.origin}${u.pathname}` + } catch { + return '' + } +} + +/** + * True only when the request body is an MCP `initialize` JSON-RPC message. Used to + * scope response-body logging to the initialize handshake and nothing else — token + * requests (form-encoded) and tool calls (`method: 'tools/call'`) both fail this. + */ +function isInitializeRequest(body: unknown): boolean { + if (typeof body !== 'string') return false + try { + return (JSON.parse(body) as { method?: unknown })?.method === 'initialize' + } catch { + return false + } } /** * Wraps a `FetchLike` so every request/response in the MCP OAuth or transport flow is - * logged with timing. `phase` distinguishes the two so the transport `initialize` — the - * suspected hang — can be traced chunk-by-chunk while OAuth bodies stay unlogged. + * logged with timing. Only the `initialize` response body is streamed (see secret-safety + * note above) — that's the suspected hang. */ export function withMcpHttpDiagnostics( fetchFn: FetchLike, @@ -41,7 +64,7 @@ export function withMcpHttpDiagnostics( if (!diagnosticsEnabled()) return fetchFn return async (input, init) => { - const url = requestUrl(input as string | URL | Request) + const url = safeUrl(input as string | URL | Request) const method = init?.method ?? 'GET' const reqHeaders = new Headers((init?.headers as HeadersInit | undefined) ?? undefined) const startedAt = Date.now() @@ -68,29 +91,18 @@ export function withMcpHttpDiagnostics( throw error } - const contentType = res.headers.get('content-type') ?? '' - const safeHeaders: Record = {} - for (const [name, value] of res.headers.entries()) { - const lower = name.toLowerCase() - safeHeaders[name] = - lower === 'set-cookie' || lower === 'authorization' || lower === 'www-authenticate' - ? '' - : sanitizeForLogging(value, 200) - } - logger.info('response', { phase, method, url, status: res.status, - contentType, + contentType: res.headers.get('content-type') ?? '', mcpSessionId: res.headers.get('mcp-session-id') ? 'present' : 'absent', headerMs: Date.now() - startedAt, - headers: safeHeaders, }) - // Only the transport body is safe to log (MCP protocol JSON, no credentials). - if (phase !== 'transport' || !res.body) return res + // Stream-log ONLY the initialize handshake response — never OAuth bodies or tool results. + if (phase !== 'transport' || !isInitializeRequest(init?.body) || !res.body) return res let logBranch: ReadableStream let passBranch: ReadableStream @@ -109,8 +121,7 @@ export function withMcpHttpDiagnostics( for (;;) { const { done, value } = await reader.read() if (done) { - logger.info('transport body complete', { - phase, + logger.info('initialize body complete', { url, chunks, bytes, @@ -121,19 +132,17 @@ export function withMcpHttpDiagnostics( chunks += 1 bytes += value.byteLength if (chunks <= MAX_LOGGED_CHUNKS) { - logger.info('transport body chunk', { - phase, + logger.info('initialize body chunk', { url, chunk: chunks, size: value.byteLength, ms: Date.now() - startedAt, - preview: sanitizeForLogging(decoder.decode(value, { stream: true }), PREVIEW_CHARS), + preview: decoder.decode(value, { stream: true }).slice(0, PREVIEW_CHARS), }) } } } catch (error) { - logger.warn('transport body read error', { - phase, + logger.warn('initialize body read error', { url, chunks, bytes, From b5edb9e5ead498d3a621114d5d819641a2cd54c7 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 20 Jul 2026 16:29:51 -0700 Subject: [PATCH 3/5] diag(mcp): sanitize initialize preview + log at warn for visibility Review fixes (Cursor): - Run the initialize body preview through sanitizeForLogging (defense-in-depth against a server stuffing token-like values into serverInfo/capabilities). - Log diagnostics at warn, not info, so they surface even if an environment's LOG_LEVEL drops info (staging runs INFO, but this removes the dependency). --- apps/sim/lib/mcp/http-diagnostics.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/mcp/http-diagnostics.ts b/apps/sim/lib/mcp/http-diagnostics.ts index c5fe8b83413..3ca0422a87d 100644 --- a/apps/sim/lib/mcp/http-diagnostics.ts +++ b/apps/sim/lib/mcp/http-diagnostics.ts @@ -1,5 +1,6 @@ import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js' import { createLogger } from '@sim/logger' +import { sanitizeForLogging } from '@/lib/core/security/redaction' import { getMcpSafeErrorDiagnostics } from '@/lib/mcp/error-diagnostics' const logger = createLogger('McpHttpDiag') @@ -69,7 +70,7 @@ export function withMcpHttpDiagnostics( const reqHeaders = new Headers((init?.headers as HeadersInit | undefined) ?? undefined) const startedAt = Date.now() - logger.info('request', { + logger.warn('request', { phase, method, url, @@ -91,7 +92,7 @@ export function withMcpHttpDiagnostics( throw error } - logger.info('response', { + logger.warn('response', { phase, method, url, @@ -121,7 +122,7 @@ export function withMcpHttpDiagnostics( for (;;) { const { done, value } = await reader.read() if (done) { - logger.info('initialize body complete', { + logger.warn('initialize body complete', { url, chunks, bytes, @@ -132,12 +133,12 @@ export function withMcpHttpDiagnostics( chunks += 1 bytes += value.byteLength if (chunks <= MAX_LOGGED_CHUNKS) { - logger.info('initialize body chunk', { + logger.warn('initialize body chunk', { url, chunk: chunks, size: value.byteLength, ms: Date.now() - startedAt, - preview: decoder.decode(value, { stream: true }).slice(0, PREVIEW_CHARS), + preview: sanitizeForLogging(decoder.decode(value, { stream: true }), PREVIEW_CHARS), }) } } From d8467f48d5afead24b789479e5417fa493dc0800 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 20 Jul 2026 16:42:05 -0700 Subject: [PATCH 4/5] diag(mcp): reuse sanitizeUrlForLog + cancel log reader on abort Review fixes (Cursor): - Replace the local safeUrl helper with the shared sanitizeUrlForLog so URL redaction/truncation stays consistent. - The detached initialize-body log reader now observes init.signal and cancels its tee-branch reader on abort, so it can't keep the response stream / connection alive after the SDK's initialize timeout gives up (the hang we're tracing). --- apps/sim/lib/mcp/http-diagnostics.ts | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/apps/sim/lib/mcp/http-diagnostics.ts b/apps/sim/lib/mcp/http-diagnostics.ts index 3ca0422a87d..3a84923186d 100644 --- a/apps/sim/lib/mcp/http-diagnostics.ts +++ b/apps/sim/lib/mcp/http-diagnostics.ts @@ -1,6 +1,7 @@ import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js' import { createLogger } from '@sim/logger' import { sanitizeForLogging } from '@/lib/core/security/redaction' +import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import { getMcpSafeErrorDiagnostics } from '@/lib/mcp/error-diagnostics' const logger = createLogger('McpHttpDiag') @@ -28,15 +29,8 @@ function diagnosticsEnabled(): boolean { return process.env.MCP_HTTP_DIAGNOSTICS !== 'false' } -/** Origin + path only — query values can carry `code`/`token`/`api_key`, so they're dropped. */ -function safeUrl(input: string | URL | Request): string { - const raw = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url - try { - const u = new URL(raw) - return u.search ? `${u.origin}${u.pathname}?` : `${u.origin}${u.pathname}` - } catch { - return '' - } +function rawUrl(input: string | URL | Request): string { + return typeof input === 'string' ? input : input instanceof URL ? input.href : input.url } /** @@ -65,7 +59,7 @@ export function withMcpHttpDiagnostics( if (!diagnosticsEnabled()) return fetchFn return async (input, init) => { - const url = safeUrl(input as string | URL | Request) + const url = sanitizeUrlForLog(rawUrl(input as string | URL | Request)) const method = init?.method ?? 'GET' const reqHeaders = new Headers((init?.headers as HeadersInit | undefined) ?? undefined) const startedAt = Date.now() @@ -113,8 +107,18 @@ export function withMcpHttpDiagnostics( return res } + // Cancel the detached log reader when the caller aborts (e.g. the SDK's 30s + // initialize timeout — exactly the hang we're tracing) so this tee branch can't + // keep the response stream / connection alive after the SDK has given up. + const signal = init?.signal void (async () => { const reader = logBranch.getReader() + const cancelReader = () => void reader.cancel().catch(() => {}) + if (signal?.aborted) { + cancelReader() + return + } + signal?.addEventListener('abort', cancelReader, { once: true }) const decoder = new TextDecoder() let chunks = 0 let bytes = 0 @@ -150,6 +154,8 @@ export function withMcpHttpDiagnostics( ms: Date.now() - startedAt, error: getMcpSafeErrorDiagnostics(error), }) + } finally { + signal?.removeEventListener('abort', cancelReader) } })() From 443e0239fe311254f2b8ca077d9264eb883ef980 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 20 Jul 2026 16:50:23 -0700 Subject: [PATCH 5/5] diag(mcp): length-gate initialize check to skip parsing large tool payloads isInitializeRequest now rejects bodies over 4096 chars before any JSON.parse. The initialize message is a small fixed structure, so this keeps large tools/call payloads (potentially multi-MB) off the parse hot path while still detecting initialize. --- apps/sim/lib/mcp/http-diagnostics.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/mcp/http-diagnostics.ts b/apps/sim/lib/mcp/http-diagnostics.ts index 3a84923186d..7845815f459 100644 --- a/apps/sim/lib/mcp/http-diagnostics.ts +++ b/apps/sim/lib/mcp/http-diagnostics.ts @@ -33,13 +33,21 @@ function rawUrl(input: string | URL | Request): string { return typeof input === 'string' ? input : input instanceof URL ? input.href : input.url } +/** + * The `initialize` request is a small fixed structure (protocolVersion, capabilities, + * clientInfo). This length gate lets us skip parsing large `tools/call` payloads — + * which can be multi-MB — entirely, keeping the check off the hot path. + */ +const MAX_INIT_BODY_CHARS = 4096 + /** * True only when the request body is an MCP `initialize` JSON-RPC message. Used to * scope response-body logging to the initialize handshake and nothing else — token * requests (form-encoded) and tool calls (`method: 'tools/call'`) both fail this. + * Large bodies are rejected by length before any parse (see {@link MAX_INIT_BODY_CHARS}). */ function isInitializeRequest(body: unknown): boolean { - if (typeof body !== 'string') return false + if (typeof body !== 'string' || body.length > MAX_INIT_BODY_CHARS) return false try { return (JSON.parse(body) as { method?: unknown })?.method === 'initialize' } catch {