diff --git a/packages/plugins/mcp/src/sdk/connection.ts b/packages/plugins/mcp/src/sdk/connection.ts index 4cad5f767..0514bc7f4 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 ?? {}); @@ -335,10 +351,34 @@ 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); - return connectSse; + 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 + // 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( + ( + sseFailure, + ): Effect.Effect => { + if (Predicate.isTagged(sseFailure, "McpOAuthReauthorizationRequired")) { + return Effect.fail(sseFailure); + } + return Effect.fail( + new McpConnectionError({ + transport: "streamable-http", + 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 0b8338684..0971ed213 100644 --- a/packages/plugins/mcp/src/sdk/plugin.test.ts +++ b/packages/plugins/mcp/src/sdk/plugin.test.ts @@ -338,6 +338,90 @@ 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 failure = yield* createMcpConnector({ + transport: "remote", + endpoint: "https://internal.example/mcp", + httpClientLayer, + }).pipe(Effect.flip); + + 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); + }), + ); + 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);