Skip to content

Commit 70a7bf0

Browse files
authored
diag(mcp): comprehensive HTTP logging for OAuth + streamable-HTTP transport (#5782)
* diag(mcp): comprehensive HTTP logging for OAuth + streamable-HTTP transport 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. * diag(mcp): scope body logging to initialize + redact URLs + wrap unpinned 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. * 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). * 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). * 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.
1 parent ae6a363 commit 70a7bf0

4 files changed

Lines changed: 188 additions & 2 deletions

File tree

apps/sim/lib/mcp/client.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,9 @@ describe('McpClient notification handler', () => {
381381
{
382382
authProvider,
383383
requestInit: { headers: { 'X-Sim-Via': 'workflow' } },
384+
// The transport fetch is always wrapped for diagnostics (a no-op passthrough
385+
// under test); it defaults to globalThis.fetch when the server isn't pinned.
386+
fetch: expect.any(Function),
384387
}
385388
)
386389
})

apps/sim/lib/mcp/client.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { createLogger } from '@sim/logger'
1212
import { getErrorMessage } from '@sim/utils/errors'
1313
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
1414
import { getMcpSafeErrorDiagnostics } from '@/lib/mcp/error-diagnostics'
15+
import { withMcpHttpDiagnostics } from '@/lib/mcp/http-diagnostics'
1516
import { McpOauthRedirectRequired } from '@/lib/mcp/oauth'
1617
import { createPinnedMcpFetch } from '@/lib/mcp/pinned-fetch'
1718
import {
@@ -101,7 +102,9 @@ export class McpClient {
101102
this.transport = new StreamableHTTPClientTransport(new URL(this.config.url), {
102103
authProvider: useOauth ? this.authProvider : undefined,
103104
requestInit: { headers: this.config.headers },
104-
...(pinned ? { fetch: pinned.fetch } : {}),
105+
// Wrap whether pinned or not (SDK's default is globalThis.fetch, so passing it is
106+
// behavior-neutral) so the diagnostic also covers unpinned/allowlisted servers.
107+
fetch: withMcpHttpDiagnostics(pinned?.fetch ?? globalThis.fetch, 'transport'),
105108
})
106109

107110
this.client = new Client(
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'
2+
import { createLogger } from '@sim/logger'
3+
import { sanitizeForLogging } from '@/lib/core/security/redaction'
4+
import { sanitizeUrlForLog } from '@/lib/core/utils/logging'
5+
import { getMcpSafeErrorDiagnostics } from '@/lib/mcp/error-diagnostics'
6+
7+
const logger = createLogger('McpHttpDiag')
8+
9+
const MAX_LOGGED_CHUNKS = 40
10+
const PREVIEW_CHARS = 500
11+
12+
/**
13+
* TEMPORARY diagnostic for the MCP OAuth + streamable-HTTP transport HTTP layer.
14+
* Enabled by default; set `MCP_HTTP_DIAGNOSTICS=false` to silence. Remove once the
15+
* Gauge `initialize` hang is root-caused.
16+
*
17+
* Secret-safety (the wrapped transport fetch also carries in-transport OAuth
18+
* refresh/registration, and every tool result):
19+
* - Request and OAuth response bodies are NEVER logged.
20+
* - The only response body streamed is the one whose REQUEST is an MCP `initialize`
21+
* JSON-RPC call — which excludes token/refresh responses AND `tools/call` results
22+
* (tool output can be PII/file contents/credentials). The `initialize` result is
23+
* protocol metadata only (serverInfo, capabilities), no credentials.
24+
* - URLs are logged origin+path only; query strings (`?code=`, `?token=`, …) are
25+
* redacted. Sensitive headers (authorization/cookie/www-authenticate) are omitted.
26+
*/
27+
function diagnosticsEnabled(): boolean {
28+
if (process.env.NODE_ENV === 'test' || process.env.VITEST) return false
29+
return process.env.MCP_HTTP_DIAGNOSTICS !== 'false'
30+
}
31+
32+
function rawUrl(input: string | URL | Request): string {
33+
return typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
34+
}
35+
36+
/**
37+
* The `initialize` request is a small fixed structure (protocolVersion, capabilities,
38+
* clientInfo). This length gate lets us skip parsing large `tools/call` payloads —
39+
* which can be multi-MB — entirely, keeping the check off the hot path.
40+
*/
41+
const MAX_INIT_BODY_CHARS = 4096
42+
43+
/**
44+
* True only when the request body is an MCP `initialize` JSON-RPC message. Used to
45+
* scope response-body logging to the initialize handshake and nothing else — token
46+
* requests (form-encoded) and tool calls (`method: 'tools/call'`) both fail this.
47+
* Large bodies are rejected by length before any parse (see {@link MAX_INIT_BODY_CHARS}).
48+
*/
49+
function isInitializeRequest(body: unknown): boolean {
50+
if (typeof body !== 'string' || body.length > MAX_INIT_BODY_CHARS) return false
51+
try {
52+
return (JSON.parse(body) as { method?: unknown })?.method === 'initialize'
53+
} catch {
54+
return false
55+
}
56+
}
57+
58+
/**
59+
* Wraps a `FetchLike` so every request/response in the MCP OAuth or transport flow is
60+
* logged with timing. Only the `initialize` response body is streamed (see secret-safety
61+
* note above) — that's the suspected hang.
62+
*/
63+
export function withMcpHttpDiagnostics(
64+
fetchFn: FetchLike,
65+
phase: 'oauth' | 'transport'
66+
): FetchLike {
67+
if (!diagnosticsEnabled()) return fetchFn
68+
69+
return async (input, init) => {
70+
const url = sanitizeUrlForLog(rawUrl(input as string | URL | Request))
71+
const method = init?.method ?? 'GET'
72+
const reqHeaders = new Headers((init?.headers as HeadersInit | undefined) ?? undefined)
73+
const startedAt = Date.now()
74+
75+
logger.warn('request', {
76+
phase,
77+
method,
78+
url,
79+
hasAuth: reqHeaders.has('authorization'),
80+
accept: reqHeaders.get('accept') ?? undefined,
81+
})
82+
83+
let res: Response
84+
try {
85+
res = (await fetchFn(input, init)) as Response
86+
} catch (error) {
87+
logger.warn('fetch rejected', {
88+
phase,
89+
method,
90+
url,
91+
ms: Date.now() - startedAt,
92+
error: getMcpSafeErrorDiagnostics(error),
93+
})
94+
throw error
95+
}
96+
97+
logger.warn('response', {
98+
phase,
99+
method,
100+
url,
101+
status: res.status,
102+
contentType: res.headers.get('content-type') ?? '',
103+
mcpSessionId: res.headers.get('mcp-session-id') ? 'present' : 'absent',
104+
headerMs: Date.now() - startedAt,
105+
})
106+
107+
// Stream-log ONLY the initialize handshake response — never OAuth bodies or tool results.
108+
if (phase !== 'transport' || !isInitializeRequest(init?.body) || !res.body) return res
109+
110+
let logBranch: ReadableStream<Uint8Array>
111+
let passBranch: ReadableStream<Uint8Array>
112+
try {
113+
;[logBranch, passBranch] = res.body.tee()
114+
} catch {
115+
return res
116+
}
117+
118+
// Cancel the detached log reader when the caller aborts (e.g. the SDK's 30s
119+
// initialize timeout — exactly the hang we're tracing) so this tee branch can't
120+
// keep the response stream / connection alive after the SDK has given up.
121+
const signal = init?.signal
122+
void (async () => {
123+
const reader = logBranch.getReader()
124+
const cancelReader = () => void reader.cancel().catch(() => {})
125+
if (signal?.aborted) {
126+
cancelReader()
127+
return
128+
}
129+
signal?.addEventListener('abort', cancelReader, { once: true })
130+
const decoder = new TextDecoder()
131+
let chunks = 0
132+
let bytes = 0
133+
try {
134+
for (;;) {
135+
const { done, value } = await reader.read()
136+
if (done) {
137+
logger.warn('initialize body complete', {
138+
url,
139+
chunks,
140+
bytes,
141+
ms: Date.now() - startedAt,
142+
})
143+
break
144+
}
145+
chunks += 1
146+
bytes += value.byteLength
147+
if (chunks <= MAX_LOGGED_CHUNKS) {
148+
logger.warn('initialize body chunk', {
149+
url,
150+
chunk: chunks,
151+
size: value.byteLength,
152+
ms: Date.now() - startedAt,
153+
preview: sanitizeForLogging(decoder.decode(value, { stream: true }), PREVIEW_CHARS),
154+
})
155+
}
156+
}
157+
} catch (error) {
158+
logger.warn('initialize body read error', {
159+
url,
160+
chunks,
161+
bytes,
162+
ms: Date.now() - startedAt,
163+
error: getMcpSafeErrorDiagnostics(error),
164+
})
165+
} finally {
166+
signal?.removeEventListener('abort', cancelReader)
167+
}
168+
})()
169+
170+
return new Response(passBranch, {
171+
status: res.status,
172+
statusText: res.statusText,
173+
headers: res.headers,
174+
})
175+
}
176+
}

apps/sim/lib/mcp/oauth/auth.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { auth, type OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'
2+
import { withMcpHttpDiagnostics } from '@/lib/mcp/http-diagnostics'
23
import { createSsrfGuardedMcpFetch } from '@/lib/mcp/pinned-fetch'
34

45
type McpAuthOptions = Parameters<typeof auth>[1]
@@ -15,5 +16,8 @@ export function mcpAuthGuarded(
1516
provider: OAuthClientProvider,
1617
options: McpAuthOptions
1718
): ReturnType<typeof auth> {
18-
return auth(provider, { ...options, fetchFn: options.fetchFn ?? createSsrfGuardedMcpFetch() })
19+
return auth(provider, {
20+
...options,
21+
fetchFn: withMcpHttpDiagnostics(options.fetchFn ?? createSsrfGuardedMcpFetch(), 'oauth'),
22+
})
1923
}

0 commit comments

Comments
 (0)