Skip to content

Commit afa2c9a

Browse files
committed
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.
1 parent 04ce4b4 commit afa2c9a

3 files changed

Lines changed: 131 additions & 3 deletions

File tree

packages/plugins/mcp/src/sdk/connection.ts

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,22 @@ export type ConnectorInput = RemoteConnectorInput | StdioConnectorInput;
6161
// Helpers
6262
// ---------------------------------------------------------------------------
6363

64+
// Ask remote MCP servers for uncompressed responses (matching Claude Code's
65+
// MCP client). Without this, runtimes send their default Accept-Encoding
66+
// (e.g. Bun: "gzip, deflate, br, zstd") and servers that miscompute
67+
// Content-Length on compressed bodies (Dune's MCP server sets it to the
68+
// UNCOMPRESSED size) produce a truncated stream that fetch rejects
69+
// (ZlibError) with no HTTP status — which the auto-transport fallback then
70+
// masks behind an unrelated SSE failure. MCP payloads are small JSON/SSE, so
71+
// compression buys nothing here. A caller-supplied Accept-Encoding still
72+
// wins.
73+
const withDefaultAcceptEncoding = (headers: Record<string, string>): Record<string, string> => {
74+
const hasAcceptEncoding = Object.keys(headers).some(
75+
(key) => key.toLowerCase() === "accept-encoding",
76+
);
77+
return hasAcceptEncoding ? headers : { ...headers, "accept-encoding": "identity" };
78+
};
79+
6480
const buildEndpointUrl = (endpoint: string, queryParams: Record<string, string>): URL => {
6581
const url = new URL(endpoint);
6682
for (const [key, value] of Object.entries(queryParams)) {
@@ -298,9 +314,9 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => {
298314
}
299315

300316
// Remote transport
301-
const headers = input.headers ?? {};
317+
const headers = withDefaultAcceptEncoding(input.headers ?? {});
302318
const remoteTransport = input.remoteTransport ?? "auto";
303-
const requestInit = Object.keys(headers).length > 0 ? { headers } : undefined;
319+
const requestInit = { headers };
304320
const fetch = input.httpClientLayer ? fetchFromHttpClientLayer(input.httpClientLayer) : undefined;
305321

306322
const endpoint = buildEndpointUrl(input.endpoint, input.queryParams ?? {});
@@ -338,7 +354,29 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => {
338354
Effect.catch((error) => {
339355
if (Predicate.isTagged(error, "McpOAuthReauthorizationRequired")) return Effect.fail(error);
340356
if (error.httpStatus === 401 || error.httpStatus === 403) return Effect.fail(error);
341-
return connectSse;
357+
// When the fallback ALSO fails, the streamable-http failure is the
358+
// interesting one: most modern servers don't serve legacy SSE at all
359+
// (GET → 405), so reporting only "Failed connecting via sse" points
360+
// the user at a transport the server never supported and hides the
361+
// real cause (e.g. a corrupt gzip stream). Keep both, primary first.
362+
return connectSse.pipe(
363+
Effect.catch(
364+
(
365+
sseError,
366+
): Effect.Effect<never, McpConnectionError | McpOAuthReauthorizationRequired> => {
367+
if (Predicate.isTagged(sseError, "McpOAuthReauthorizationRequired")) {
368+
return Effect.fail(sseError);
369+
}
370+
return Effect.fail(
371+
new McpConnectionError({
372+
transport: "streamable-http",
373+
message: `${error.message}; SSE fallback also failed: ${sseError.message}`,
374+
...(error.httpStatus === undefined ? {} : { httpStatus: error.httpStatus }),
375+
}),
376+
);
377+
},
378+
),
379+
);
342380
}),
343381
);
344382
};

packages/plugins/mcp/src/sdk/plugin.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,91 @@ describe("mcpPlugin", () => {
338338
}),
339339
);
340340

341+
it.effect("remote connector requests uncompressed responses by default", () =>
342+
Effect.gen(function* () {
343+
const seen: Record<string, string | undefined>[] = [];
344+
const httpClientLayer = Layer.succeed(HttpClient.HttpClient)(
345+
HttpClient.make((request: HttpClientRequest.HttpClientRequest) => {
346+
seen.push(Object.fromEntries(Object.entries(request.headers)));
347+
return Effect.succeed(
348+
HttpClientResponse.fromWeb(request, new Response("blocked", { status: 500 })),
349+
);
350+
}),
351+
);
352+
353+
yield* createMcpConnector({
354+
transport: "remote",
355+
endpoint: "https://internal.example/mcp",
356+
remoteTransport: "streamable-http",
357+
headers: { "x-api-key": "k" },
358+
httpClientLayer,
359+
}).pipe(Effect.flip);
360+
361+
expect(seen.length).toBeGreaterThan(0);
362+
for (const headers of seen) {
363+
expect(headers["accept-encoding"]).toBe("identity");
364+
expect(headers["x-api-key"]).toBe("k");
365+
}
366+
}),
367+
);
368+
369+
it.effect("caller-supplied Accept-Encoding overrides the identity default", () =>
370+
Effect.gen(function* () {
371+
const seen: Record<string, string | undefined>[] = [];
372+
const httpClientLayer = Layer.succeed(HttpClient.HttpClient)(
373+
HttpClient.make((request: HttpClientRequest.HttpClientRequest) => {
374+
seen.push(Object.fromEntries(Object.entries(request.headers)));
375+
return Effect.succeed(
376+
HttpClientResponse.fromWeb(request, new Response("blocked", { status: 500 })),
377+
);
378+
}),
379+
);
380+
381+
yield* createMcpConnector({
382+
transport: "remote",
383+
endpoint: "https://internal.example/mcp",
384+
remoteTransport: "streamable-http",
385+
headers: { "Accept-Encoding": "gzip" },
386+
httpClientLayer,
387+
}).pipe(Effect.flip);
388+
389+
expect(seen.length).toBeGreaterThan(0);
390+
for (const headers of seen) {
391+
expect(headers["accept-encoding"]).toBe("gzip");
392+
}
393+
}),
394+
);
395+
396+
it.effect(
397+
"auto transport keeps the streamable-http failure visible when the SSE fallback also fails",
398+
() =>
399+
Effect.gen(function* () {
400+
// Non-auth failure (500) on every request: streamable-http fails and
401+
// triggers the SSE fallback, which fails too. The surfaced error must
402+
// name the primary transport's failure, not only the fallback's.
403+
const httpClientLayer = Layer.succeed(HttpClient.HttpClient)(
404+
HttpClient.make((request: HttpClientRequest.HttpClientRequest) =>
405+
Effect.succeed(
406+
HttpClientResponse.fromWeb(request, new Response("boom", { status: 500 })),
407+
),
408+
),
409+
);
410+
411+
const error = yield* createMcpConnector({
412+
transport: "remote",
413+
endpoint: "https://internal.example/mcp",
414+
httpClientLayer,
415+
}).pipe(Effect.flip);
416+
417+
if (!Predicate.isTagged(error, "McpConnectionError")) {
418+
throw new TypeError(`expected McpConnectionError, got ${error._tag}`);
419+
}
420+
expect(error.message).toContain("streamable-http");
421+
expect(error.message).toContain("SSE fallback also failed");
422+
expect(error.httpStatus).toBe(500);
423+
}),
424+
);
425+
341426
it.effect("integration catalog has no configured MCP integrations initially", () =>
342427
Effect.gen(function* () {
343428
const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const }));

packages/plugins/mcp/src/sdk/probe-shape.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,10 @@ export const probeMcpEndpointShape = (
375375
let postRequest = HttpClientRequest.post(url.toString()).pipe(
376376
HttpClientRequest.setHeader("content-type", "application/json"),
377377
HttpClientRequest.setHeader("accept", "application/json, text/event-stream"),
378+
// Uncompressed responses, matching the connection layer: servers that
379+
// miscompute Content-Length on gzipped bodies truncate the stream and
380+
// the probe would misread a live MCP server as unreachable.
381+
HttpClientRequest.setHeader("accept-encoding", "identity"),
378382
HttpClientRequest.bodyText(INITIALIZE_BODY, "application/json"),
379383
);
380384
for (const [name, value] of Object.entries(options.headers ?? {})) {
@@ -391,6 +395,7 @@ export const probeMcpEndpointShape = (
391395
if ([404, 405, 406, 415].includes(postResponse.status)) {
392396
let getRequest = HttpClientRequest.get(url.toString()).pipe(
393397
HttpClientRequest.setHeader("accept", "text/event-stream"),
398+
HttpClientRequest.setHeader("accept-encoding", "identity"),
394399
);
395400
for (const [name, value] of Object.entries(options.headers ?? {})) {
396401
getRequest = HttpClientRequest.setHeader(getRequest, name, value);

0 commit comments

Comments
 (0)