Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 46 additions & 6 deletions packages/plugins/mcp/src/sdk/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>): Record<string, string> => {
const hasAcceptEncoding = Object.keys(headers).some(
(key) => key.toLowerCase() === "accept-encoding",
);
return hasAcceptEncoding ? headers : { ...headers, "accept-encoding": "identity" };
};

const buildEndpointUrl = (endpoint: string, queryParams: Record<string, string>): URL => {
const url = new URL(endpoint);
for (const [key, value] of Object.entries(queryParams)) {
Expand Down Expand Up @@ -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 ?? {});
Expand Down Expand Up @@ -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<never, McpConnectionError | McpOAuthReauthorizationRequired> => {
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 }),
}),
);
},
),
);
}),
);
};
84 changes: 84 additions & 0 deletions packages/plugins/mcp/src/sdk/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,90 @@ describe("mcpPlugin", () => {
}),
);

it.effect("remote connector requests uncompressed responses by default", () =>
Effect.gen(function* () {
const seen: Record<string, string | undefined>[] = [];
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<string, string | undefined>[] = [];
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 }));
Expand Down
5 changes: 5 additions & 0 deletions packages/plugins/mcp/src/sdk/probe-shape.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? {})) {
Expand All @@ -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);
Expand Down
Loading