From afa2c9a0c740e85e4be8cc982a44b28cc4a56c44 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:08:28 -0700 Subject: [PATCH 1/2] mcp: request uncompressed responses from remote servers Some remote MCP servers miscompute Content-Length on gzipped responses (set to the uncompressed size), which truncates the stream and fails the handshake with an opaque decompression error in runtimes that send a default Accept-Encoding. Send accept-encoding: identity on the remote transports and the shape probe (a caller-supplied header still wins); MCP payloads are small, so compression buys nothing. Also stop masking the streamable-http failure when the auto-transport SSE fallback fails too: most servers never supported legacy SSE, so reporting only the fallback's error hid the real cause. --- packages/plugins/mcp/src/sdk/connection.ts | 44 ++++++++++- packages/plugins/mcp/src/sdk/plugin.test.ts | 85 +++++++++++++++++++++ packages/plugins/mcp/src/sdk/probe-shape.ts | 5 ++ 3 files changed, 131 insertions(+), 3 deletions(-) diff --git a/packages/plugins/mcp/src/sdk/connection.ts b/packages/plugins/mcp/src/sdk/connection.ts index 4cad5f767..4fd8d7e4a 100644 --- a/packages/plugins/mcp/src/sdk/connection.ts +++ b/packages/plugins/mcp/src/sdk/connection.ts @@ -61,6 +61,22 @@ export type ConnectorInput = RemoteConnectorInput | StdioConnectorInput; // Helpers // --------------------------------------------------------------------------- +// Ask remote MCP servers for uncompressed responses (matching Claude Code's +// MCP client). Without this, runtimes send their default Accept-Encoding +// (e.g. Bun: "gzip, deflate, br, zstd") and servers that miscompute +// Content-Length on compressed bodies (Dune's MCP server sets it to the +// UNCOMPRESSED size) produce a truncated stream that fetch rejects +// (ZlibError) with no HTTP status — which the auto-transport fallback then +// masks behind an unrelated SSE failure. MCP payloads are small JSON/SSE, so +// compression buys nothing here. A caller-supplied Accept-Encoding still +// wins. +const withDefaultAcceptEncoding = (headers: Record): Record => { + const hasAcceptEncoding = Object.keys(headers).some( + (key) => key.toLowerCase() === "accept-encoding", + ); + return hasAcceptEncoding ? headers : { ...headers, "accept-encoding": "identity" }; +}; + const buildEndpointUrl = (endpoint: string, queryParams: Record): URL => { const url = new URL(endpoint); for (const [key, value] of Object.entries(queryParams)) { @@ -298,9 +314,9 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { } // Remote transport - const headers = input.headers ?? {}; + const headers = withDefaultAcceptEncoding(input.headers ?? {}); const remoteTransport = input.remoteTransport ?? "auto"; - const requestInit = Object.keys(headers).length > 0 ? { headers } : undefined; + const requestInit = { headers }; const fetch = input.httpClientLayer ? fetchFromHttpClientLayer(input.httpClientLayer) : undefined; const endpoint = buildEndpointUrl(input.endpoint, input.queryParams ?? {}); @@ -338,7 +354,29 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { Effect.catch((error) => { if (Predicate.isTagged(error, "McpOAuthReauthorizationRequired")) return Effect.fail(error); if (error.httpStatus === 401 || error.httpStatus === 403) return Effect.fail(error); - return connectSse; + // When the fallback ALSO fails, the streamable-http failure is the + // interesting one: most modern servers don't serve legacy SSE at all + // (GET → 405), so reporting only "Failed connecting via sse" points + // the user at a transport the server never supported and hides the + // real cause (e.g. a corrupt gzip stream). Keep both, primary first. + return connectSse.pipe( + Effect.catch( + ( + sseError, + ): Effect.Effect => { + if (Predicate.isTagged(sseError, "McpOAuthReauthorizationRequired")) { + return Effect.fail(sseError); + } + return Effect.fail( + new McpConnectionError({ + transport: "streamable-http", + message: `${error.message}; SSE fallback also failed: ${sseError.message}`, + ...(error.httpStatus === undefined ? {} : { httpStatus: error.httpStatus }), + }), + ); + }, + ), + ); }), ); }; diff --git a/packages/plugins/mcp/src/sdk/plugin.test.ts b/packages/plugins/mcp/src/sdk/plugin.test.ts index 0b8338684..c27604579 100644 --- a/packages/plugins/mcp/src/sdk/plugin.test.ts +++ b/packages/plugins/mcp/src/sdk/plugin.test.ts @@ -338,6 +338,91 @@ describe("mcpPlugin", () => { }), ); + it.effect("remote connector requests uncompressed responses by default", () => + Effect.gen(function* () { + const seen: Record[] = []; + const httpClientLayer = Layer.succeed(HttpClient.HttpClient)( + HttpClient.make((request: HttpClientRequest.HttpClientRequest) => { + seen.push(Object.fromEntries(Object.entries(request.headers))); + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response("blocked", { status: 500 })), + ); + }), + ); + + yield* createMcpConnector({ + transport: "remote", + endpoint: "https://internal.example/mcp", + remoteTransport: "streamable-http", + headers: { "x-api-key": "k" }, + httpClientLayer, + }).pipe(Effect.flip); + + expect(seen.length).toBeGreaterThan(0); + for (const headers of seen) { + expect(headers["accept-encoding"]).toBe("identity"); + expect(headers["x-api-key"]).toBe("k"); + } + }), + ); + + it.effect("caller-supplied Accept-Encoding overrides the identity default", () => + Effect.gen(function* () { + const seen: Record[] = []; + const httpClientLayer = Layer.succeed(HttpClient.HttpClient)( + HttpClient.make((request: HttpClientRequest.HttpClientRequest) => { + seen.push(Object.fromEntries(Object.entries(request.headers))); + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response("blocked", { status: 500 })), + ); + }), + ); + + yield* createMcpConnector({ + transport: "remote", + endpoint: "https://internal.example/mcp", + remoteTransport: "streamable-http", + headers: { "Accept-Encoding": "gzip" }, + httpClientLayer, + }).pipe(Effect.flip); + + expect(seen.length).toBeGreaterThan(0); + for (const headers of seen) { + expect(headers["accept-encoding"]).toBe("gzip"); + } + }), + ); + + it.effect( + "auto transport keeps the streamable-http failure visible when the SSE fallback also fails", + () => + Effect.gen(function* () { + // Non-auth failure (500) on every request: streamable-http fails and + // triggers the SSE fallback, which fails too. The surfaced error must + // name the primary transport's failure, not only the fallback's. + const httpClientLayer = Layer.succeed(HttpClient.HttpClient)( + HttpClient.make((request: HttpClientRequest.HttpClientRequest) => + Effect.succeed( + HttpClientResponse.fromWeb(request, new Response("boom", { status: 500 })), + ), + ), + ); + + const error = yield* createMcpConnector({ + transport: "remote", + endpoint: "https://internal.example/mcp", + httpClientLayer, + }).pipe(Effect.flip); + + if (!Predicate.isTagged(error, "McpConnectionError")) { + throw new TypeError(`expected McpConnectionError, got ${error._tag}`); + } + expect(error.message).toContain("streamable-http"); + expect(error.message).toContain("SSE fallback also failed"); + expect(error.httpStatus).toBe(500); + }), + ); + it.effect("integration catalog has no configured MCP integrations initially", () => Effect.gen(function* () { const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const })); diff --git a/packages/plugins/mcp/src/sdk/probe-shape.ts b/packages/plugins/mcp/src/sdk/probe-shape.ts index 3b3be9429..eeec9b908 100644 --- a/packages/plugins/mcp/src/sdk/probe-shape.ts +++ b/packages/plugins/mcp/src/sdk/probe-shape.ts @@ -375,6 +375,10 @@ export const probeMcpEndpointShape = ( let postRequest = HttpClientRequest.post(url.toString()).pipe( HttpClientRequest.setHeader("content-type", "application/json"), HttpClientRequest.setHeader("accept", "application/json, text/event-stream"), + // Uncompressed responses, matching the connection layer: servers that + // miscompute Content-Length on gzipped bodies truncate the stream and + // the probe would misread a live MCP server as unreachable. + HttpClientRequest.setHeader("accept-encoding", "identity"), HttpClientRequest.bodyText(INITIALIZE_BODY, "application/json"), ); for (const [name, value] of Object.entries(options.headers ?? {})) { @@ -391,6 +395,7 @@ export const probeMcpEndpointShape = ( if ([404, 405, 406, 415].includes(postResponse.status)) { let getRequest = HttpClientRequest.get(url.toString()).pipe( HttpClientRequest.setHeader("accept", "text/event-stream"), + HttpClientRequest.setHeader("accept-encoding", "identity"), ); for (const [name, value] of Object.entries(options.headers ?? {})) { getRequest = HttpClientRequest.setHeader(getRequest, name, value); From 1a8357ba103e71be44d4f20232abeaab845945ea Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:13:22 -0700 Subject: [PATCH 2/2] lint: use typed-error idioms in fallback error handling --- packages/plugins/mcp/src/sdk/connection.ts | 18 ++++++++++-------- packages/plugins/mcp/src/sdk/plugin.test.ts | 13 ++++++------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/packages/plugins/mcp/src/sdk/connection.ts b/packages/plugins/mcp/src/sdk/connection.ts index 4fd8d7e4a..0514bc7f4 100644 --- a/packages/plugins/mcp/src/sdk/connection.ts +++ b/packages/plugins/mcp/src/sdk/connection.ts @@ -351,9 +351,11 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { // error), which used to misclassify an expired token as a generic // connection failure. Propagate it as-is instead. return connectStreamableHttp.pipe( - Effect.catch((error) => { - if (Predicate.isTagged(error, "McpOAuthReauthorizationRequired")) return Effect.fail(error); - if (error.httpStatus === 401 || error.httpStatus === 403) return Effect.fail(error); + Effect.catch((failure) => { + if (Predicate.isTagged(failure, "McpOAuthReauthorizationRequired")) { + return Effect.fail(failure); + } + if (failure.httpStatus === 401 || failure.httpStatus === 403) return Effect.fail(failure); // When the fallback ALSO fails, the streamable-http failure is the // interesting one: most modern servers don't serve legacy SSE at all // (GET → 405), so reporting only "Failed connecting via sse" points @@ -362,16 +364,16 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { return connectSse.pipe( Effect.catch( ( - sseError, + sseFailure, ): Effect.Effect => { - if (Predicate.isTagged(sseError, "McpOAuthReauthorizationRequired")) { - return Effect.fail(sseError); + if (Predicate.isTagged(sseFailure, "McpOAuthReauthorizationRequired")) { + return Effect.fail(sseFailure); } return Effect.fail( new McpConnectionError({ transport: "streamable-http", - message: `${error.message}; SSE fallback also failed: ${sseError.message}`, - ...(error.httpStatus === undefined ? {} : { httpStatus: error.httpStatus }), + message: `${failure.message}; SSE fallback also failed: ${sseFailure.message}`, + ...(failure.httpStatus === undefined ? {} : { httpStatus: failure.httpStatus }), }), ); }, diff --git a/packages/plugins/mcp/src/sdk/plugin.test.ts b/packages/plugins/mcp/src/sdk/plugin.test.ts index c27604579..0971ed213 100644 --- a/packages/plugins/mcp/src/sdk/plugin.test.ts +++ b/packages/plugins/mcp/src/sdk/plugin.test.ts @@ -408,18 +408,17 @@ describe("mcpPlugin", () => { ), ); - const error = yield* createMcpConnector({ + const failure = yield* createMcpConnector({ transport: "remote", endpoint: "https://internal.example/mcp", httpClientLayer, }).pipe(Effect.flip); - if (!Predicate.isTagged(error, "McpConnectionError")) { - throw new TypeError(`expected McpConnectionError, got ${error._tag}`); - } - expect(error.message).toContain("streamable-http"); - expect(error.message).toContain("SSE fallback also failed"); - expect(error.httpStatus).toBe(500); + expect(Predicate.isTagged(failure, "McpConnectionError")).toBe(true); + if (!Predicate.isTagged(failure, "McpConnectionError")) return; + expect(failure.message).toContain("streamable-http"); + expect(failure.message).toContain("SSE fallback also failed"); + expect(failure.httpStatus).toBe(500); }), );