From 6e768da9dad13c7c469d2c2071bf542157f1033c Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:43:35 -0700 Subject: [PATCH 1/4] Send a default User-Agent on remote MCP requests (#2254) --- .changeset/mcp-default-user-agent.md | 5 +++++ packages/plugins/mcp/src/sdk/connection.ts | 15 +++++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 .changeset/mcp-default-user-agent.md diff --git a/.changeset/mcp-default-user-agent.md b/.changeset/mcp-default-user-agent.md new file mode 100644 index 0000000000..b557d205b6 --- /dev/null +++ b/.changeset/mcp-default-user-agent.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +Send a default User-Agent on remote MCP requests so servers that reject requests without one accept the connection. A configured User-Agent header still wins. diff --git a/packages/plugins/mcp/src/sdk/connection.ts b/packages/plugins/mcp/src/sdk/connection.ts index 344de1f640..94fed481d4 100644 --- a/packages/plugins/mcp/src/sdk/connection.ts +++ b/packages/plugins/mcp/src/sdk/connection.ts @@ -72,6 +72,17 @@ const buildEndpointUrl = (endpoint: string, queryParams: Record) return url; }; +// Some upstreams (Cloudflare bot rules among them) reject requests without a +// User-Agent, and Workers' fetch adds none. Both remote SDK transports apply +// `requestInit.headers` to every request they send, including OAuth metadata +// discovery, so the default goes there. A configured User-Agent wins. +const DEFAULT_USER_AGENT = "executor"; + +const withDefaultUserAgent = (headers: Record): Record => + Object.keys(headers).some((name) => name.toLowerCase() === "user-agent") + ? headers + : { "User-Agent": DEFAULT_USER_AGENT, ...headers }; + type HttpMethod = Parameters[0]; const HTTP_METHODS = new Set([ "DELETE", @@ -626,9 +637,9 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { } // Remote transport - const headers = input.headers ?? {}; + const headers = withDefaultUserAgent(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, input.staticOAuthBearer === true) : undefined; From 9fa93c28ee1c575d437a66a5e196c731dfc1a8d1 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:43:36 -0700 Subject: [PATCH 2/4] Test the default MCP User-Agent (#2255) --- .../mcp/src/sdk/connection-user-agent.test.ts | 175 ++++++++++++++++++ packages/plugins/mcp/src/testing/server.ts | 3 + 2 files changed, 178 insertions(+) create mode 100644 packages/plugins/mcp/src/sdk/connection-user-agent.test.ts diff --git a/packages/plugins/mcp/src/sdk/connection-user-agent.test.ts b/packages/plugins/mcp/src/sdk/connection-user-agent.test.ts new file mode 100644 index 0000000000..d16a13a5b5 --- /dev/null +++ b/packages/plugins/mcp/src/sdk/connection-user-agent.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import type { OAuthClientProvider } from "@modelcontextprotocol/client"; + +import { createMcpConnector } from "./connection"; +import { makeEchoMcpServer, serveMcpServer } from "../testing"; + +const endpoint = "https://mcp.example/mcp"; + +/** Records each request's User-Agent and answers 405, so `auto` exercises + * both the Streamable HTTP attempt and the SSE fallback. */ +const recordingClientLayer = (userAgents: Array) => + Layer.succeed(HttpClient.HttpClient)( + HttpClient.make((request: HttpClientRequest.HttpClientRequest) => { + userAgents.push(request.headers["user-agent"]); + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response("unsupported", { status: 405 })), + ); + }), + ); + +const unauthorizedProvider: OAuthClientProvider = { + get redirectUrl() { + return "http://localhost/oauth/callback"; + }, + get clientMetadata() { + return { + redirect_uris: ["http://localhost/oauth/callback"], + grant_types: ["authorization_code"] as string[], + response_types: ["code"] as string[], + token_endpoint_auth_method: "none" as const, + client_name: "Executor", + }; + }, + clientInformation: () => ({ client_id: "test-client" }), + saveClientInformation: () => undefined, + tokens: () => ({ access_token: "rejected-token", token_type: "Bearer" }), + saveTokens: () => undefined, + redirectToAuthorization: () => undefined, + saveCodeVerifier: () => undefined, + codeVerifier: () => "unused", +}; + +describe("MCP remote User-Agent", () => { + it.effect("sends a default User-Agent on both transports through the HTTP client layer", () => + Effect.gen(function* () { + const userAgents: Array = []; + + yield* createMcpConnector({ + transport: "remote", + endpoint, + remoteTransport: "auto", + httpClientLayer: recordingClientLayer(userAgents), + }).pipe(Effect.flip); + + expect(userAgents.length).toBeGreaterThanOrEqual(2); + expect(new Set(userAgents)).toEqual(new Set(["executor"])); + }), + ); + + it.effect("keeps a configured User-Agent through the HTTP client layer", () => + Effect.gen(function* () { + const userAgents: Array = []; + + yield* createMcpConnector({ + transport: "remote", + endpoint, + remoteTransport: "auto", + headers: { "user-agent": "custom-agent/1.0" }, + httpClientLayer: recordingClientLayer(userAgents), + }).pipe(Effect.flip); + + expect(userAgents.length).toBeGreaterThanOrEqual(2); + expect(new Set(userAgents)).toEqual(new Set(["custom-agent/1.0"])); + }), + ); + + it.effect("sends a default User-Agent over Streamable HTTP with global fetch", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveMcpServer(() => makeEchoMcpServer()); + + const connection = yield* createMcpConnector({ + transport: "remote", + endpoint: server.endpoint, + remoteTransport: "streamable-http", + }); + yield* Effect.promise(() => connection.client.listTools()); + yield* Effect.promise(() => connection.close()); + + const requests = yield* server.requests; + expect(requests.length).toBeGreaterThan(0); + expect(new Set(requests.map((request) => request.userAgent))).toEqual( + new Set(["executor"]), + ); + }), + ), + ); + + it.effect("keeps a configured User-Agent over Streamable HTTP with global fetch", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveMcpServer(() => makeEchoMcpServer()); + + const connection = yield* createMcpConnector({ + transport: "remote", + endpoint: server.endpoint, + remoteTransport: "streamable-http", + headers: { "User-Agent": "custom-agent/1.0" }, + }); + yield* Effect.promise(() => connection.close()); + + const requests = yield* server.requests; + expect(requests.length).toBeGreaterThan(0); + expect(new Set(requests.map((request) => request.userAgent))).toEqual( + new Set(["custom-agent/1.0"]), + ); + }), + ), + ); + + it.effect("sends a default User-Agent on the SSE stream with global fetch", () => + Effect.scoped( + Effect.gen(function* () { + // The test server speaks Streamable HTTP only, so the SSE dial fails; + // the request it made is what this asserts on. + const server = yield* serveMcpServer(() => makeEchoMcpServer()); + + yield* createMcpConnector({ + transport: "remote", + endpoint: server.endpoint, + remoteTransport: "sse", + }).pipe(Effect.flip); + + const requests = yield* server.requests; + expect(requests.map((request) => request.method)).toContain("GET"); + expect(new Set(requests.map((request) => request.userAgent))).toEqual( + new Set(["executor"]), + ); + }), + ), + ); + + it.effect("sends a default User-Agent on OAuth resource metadata discovery", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveMcpServer(() => makeEchoMcpServer(), { + auth: { + validateAuthorization: () => Effect.succeed(false), + // Unreachable on purpose: discovery stops after the resource + // metadata request this test inspects. + authorizationServerUrls: ["http://127.0.0.1:1"], + }, + }); + + yield* createMcpConnector({ + transport: "remote", + endpoint: server.endpoint, + remoteTransport: "streamable-http", + authProvider: unauthorizedProvider, + }).pipe(Effect.flip); + + const requests = yield* server.requests; + const discovery = requests.filter((request) => + request.url.startsWith("/.well-known/oauth-protected-resource"), + ); + expect(discovery.length).toBeGreaterThan(0); + expect(new Set(requests.map((request) => request.userAgent))).toEqual( + new Set(["executor"]), + ); + }), + ), + ); +}); diff --git a/packages/plugins/mcp/src/testing/server.ts b/packages/plugins/mcp/src/testing/server.ts index 2c0ca2f20c..af116968d0 100644 --- a/packages/plugins/mcp/src/testing/server.ts +++ b/packages/plugins/mcp/src/testing/server.ts @@ -31,6 +31,7 @@ export type McpTestRequest = { readonly method: string; readonly url: string; readonly authorization: string | undefined; + readonly userAgent: string | undefined; readonly sessionId: string | undefined; }; @@ -137,6 +138,7 @@ export const serveMcpServer = (factory: () => McpServer, options: McpTestServerO const authorization = Array.isArray(request.headers.authorization) ? request.headers.authorization[0] : request.headers.authorization; + const userAgent = request.headers["user-agent"]; const origin = request.headers.host ? `http://${request.headers.host}` : "http://127.0.0.1"; @@ -147,6 +149,7 @@ export const serveMcpServer = (factory: () => McpServer, options: McpTestServerO method: request.method ?? "GET", url: requestUrl, authorization, + userAgent, sessionId, }, ]); From 8e4a8a40048e8252f4d7f182456da62ca1d5c296 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:00:16 -0700 Subject: [PATCH 3/4] Send the default User-Agent on the MCP endpoint probe (#2258) --- .changeset/mcp-probe-user-agent.md | 5 +++++ packages/plugins/mcp/src/sdk/connection.ts | 16 ++++------------ packages/plugins/mcp/src/sdk/probe-shape.ts | 9 ++++++++- packages/plugins/mcp/src/sdk/user-agent.ts | 16 ++++++++++++++++ 4 files changed, 33 insertions(+), 13 deletions(-) create mode 100644 .changeset/mcp-probe-user-agent.md create mode 100644 packages/plugins/mcp/src/sdk/user-agent.ts diff --git a/.changeset/mcp-probe-user-agent.md b/.changeset/mcp-probe-user-agent.md new file mode 100644 index 0000000000..bc264ea5da --- /dev/null +++ b/.changeset/mcp-probe-user-agent.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +Send the default User-Agent on the MCP endpoint probe and its OAuth metadata request too, so adding a server that rejects requests without one no longer fails. A configured User-Agent still wins. diff --git a/packages/plugins/mcp/src/sdk/connection.ts b/packages/plugins/mcp/src/sdk/connection.ts index 94fed481d4..f8ddefca7e 100644 --- a/packages/plugins/mcp/src/sdk/connection.ts +++ b/packages/plugins/mcp/src/sdk/connection.ts @@ -21,6 +21,7 @@ import { McpOAuthReauthorizationRequired, } from "./errors"; import { connectionHttpStatusFromCause, isStreamableHttpProtocolError } from "./http-status"; +import { withDefaultUserAgent } from "./user-agent"; import { detectInsufficientScope } from "@executor-js/sdk/core"; // --------------------------------------------------------------------------- @@ -72,17 +73,6 @@ const buildEndpointUrl = (endpoint: string, queryParams: Record) return url; }; -// Some upstreams (Cloudflare bot rules among them) reject requests without a -// User-Agent, and Workers' fetch adds none. Both remote SDK transports apply -// `requestInit.headers` to every request they send, including OAuth metadata -// discovery, so the default goes there. A configured User-Agent wins. -const DEFAULT_USER_AGENT = "executor"; - -const withDefaultUserAgent = (headers: Record): Record => - Object.keys(headers).some((name) => name.toLowerCase() === "user-agent") - ? headers - : { "User-Agent": DEFAULT_USER_AGENT, ...headers }; - type HttpMethod = Parameters[0]; const HTTP_METHODS = new Set([ "DELETE", @@ -636,7 +626,9 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { }); } - // Remote transport + // Remote transport. Both remote SDK transports apply `requestInit.headers` + // to every request they send, including OAuth metadata discovery, so the + // default User-Agent goes there. const headers = withDefaultUserAgent(input.headers ?? {}); const remoteTransport = input.remoteTransport ?? "auto"; const requestInit = { headers }; diff --git a/packages/plugins/mcp/src/sdk/probe-shape.ts b/packages/plugins/mcp/src/sdk/probe-shape.ts index b64f791139..5fd6f192de 100644 --- a/packages/plugins/mcp/src/sdk/probe-shape.ts +++ b/packages/plugins/mcp/src/sdk/probe-shape.ts @@ -44,6 +44,7 @@ import { Data, Duration, Effect, Layer, Option, Schema } from "effect"; import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { userAgentFor } from "./user-agent"; /** MCP initialize request body used as the shape probe. Any real MCP * server either answers it (unauth-OK server) or returns the spec- @@ -277,7 +278,13 @@ export const probeMcpEndpointShape = ( Effect.gen(function* () { const timeoutMs = options.timeoutMs ?? 8_000; const outcome = yield* Effect.gen(function* () { - const client = yield* HttpClient.HttpClient; + // Every probe request — including the RFC 9728 metadata fallback, which + // carries none of the configured headers — sends the User-Agent. + const client = (yield* HttpClient.HttpClient).pipe( + HttpClient.mapRequest( + HttpClientRequest.setHeader("user-agent", userAgentFor(options.headers ?? {})), + ), + ); const readBody = (response: { readonly text: Effect.Effect; diff --git a/packages/plugins/mcp/src/sdk/user-agent.ts b/packages/plugins/mcp/src/sdk/user-agent.ts new file mode 100644 index 0000000000..11fd5fe58e --- /dev/null +++ b/packages/plugins/mcp/src/sdk/user-agent.ts @@ -0,0 +1,16 @@ +// Some upstreams (Cloudflare bot rules among them) reject requests without a +// User-Agent, and Workers' fetch adds none. Every request the MCP plugin sends +// to an MCP server or its OAuth metadata carries this default. A configured +// User-Agent wins. +export const DEFAULT_USER_AGENT = "executor"; + +const configuredUserAgent = (headers: Readonly>): string | undefined => + Object.entries(headers).find(([name]) => name.toLowerCase() === "user-agent")?.[1]; + +export const withDefaultUserAgent = (headers: Record): Record => + configuredUserAgent(headers) === undefined + ? { "User-Agent": DEFAULT_USER_AGENT, ...headers } + : headers; + +export const userAgentFor = (headers: Readonly>): string => + configuredUserAgent(headers) ?? DEFAULT_USER_AGENT; From a54b513e81759370295291c8157ab473f2a53871 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:00:16 -0700 Subject: [PATCH 4/4] Test the MCP probe User-Agent (#2259) --- .../src/sdk/probe-shape-user-agent.test.ts | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 packages/plugins/mcp/src/sdk/probe-shape-user-agent.test.ts diff --git a/packages/plugins/mcp/src/sdk/probe-shape-user-agent.test.ts b/packages/plugins/mcp/src/sdk/probe-shape-user-agent.test.ts new file mode 100644 index 0000000000..67087dc5b2 --- /dev/null +++ b/packages/plugins/mcp/src/sdk/probe-shape-user-agent.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +import { probeMcpEndpointShape } from "./probe-shape"; + +const endpoint = "https://mcp.example/mcp"; +const metadataUrl = "https://mcp.example/.well-known/oauth-protected-resource/mcp"; + +interface RecordedRequest { + readonly method: string; + readonly url: string; + readonly mcpProtocolVersion: string | undefined; + readonly userAgent: string | undefined; +} + +/** Records every request and answers with `respond`'s status. */ +const recordingClientLayer = ( + recorded: Array, + respond: (request: HttpClientRequest.HttpClientRequest) => number, +) => + Layer.succeed(HttpClient.HttpClient)( + HttpClient.make((request: HttpClientRequest.HttpClientRequest) => { + recorded.push({ + method: request.method, + url: request.url, + mcpProtocolVersion: request.headers["mcp-protocol-version"], + userAgent: request.headers["user-agent"], + }); + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response("", { status: respond(request) })), + ); + }), + ); + +/** POST → 405, so the probe falls back to GET; GET → 401 with no Bearer + * challenge, so the probe reads RFC 9728 metadata, which is missing. */ +const challengeWithoutBearer = (request: HttpClientRequest.HttpClientRequest): number => + request.method === "POST" ? 405 : request.url === endpoint ? 401 : 404; + +const summarize = (recorded: ReadonlyArray) => + recorded.map((request) => ({ + request: `${request.method} ${request.url}${ + request.mcpProtocolVersion ? ` (${request.mcpProtocolVersion})` : "" + }`, + userAgent: request.userAgent, + })); + +describe("MCP shape probe User-Agent", () => { + it.effect("sends a default User-Agent on the probe and its OAuth metadata request", () => + Effect.gen(function* () { + const recorded: Array = []; + + yield* probeMcpEndpointShape(endpoint, { + httpClientLayer: recordingClientLayer(recorded, challengeWithoutBearer), + }); + + expect(summarize(recorded)).toEqual([ + { request: `POST ${endpoint}`, userAgent: "executor" }, + { request: `GET ${endpoint}`, userAgent: "executor" }, + { request: `GET ${metadataUrl}`, userAgent: "executor" }, + ]); + }), + ); + + it.effect("sends a default User-Agent on the discover request", () => + Effect.gen(function* () { + const recorded: Array = []; + + yield* probeMcpEndpointShape(endpoint, { + httpClientLayer: recordingClientLayer(recorded, () => 404), + }); + + expect(summarize(recorded)).toEqual([ + { request: `POST ${endpoint}`, userAgent: "executor" }, + { request: `GET ${endpoint}`, userAgent: "executor" }, + { request: `POST ${endpoint} (2026-07-28)`, userAgent: "executor" }, + ]); + }), + ); + + it.effect("keeps a configured User-Agent on every probe request", () => + Effect.gen(function* () { + const recorded: Array = []; + + yield* probeMcpEndpointShape(endpoint, { + headers: { "User-Agent": "custom-agent/1.0" }, + httpClientLayer: recordingClientLayer(recorded, challengeWithoutBearer), + }); + + expect(summarize(recorded)).toEqual([ + { request: `POST ${endpoint}`, userAgent: "custom-agent/1.0" }, + { request: `GET ${endpoint}`, userAgent: "custom-agent/1.0" }, + { request: `GET ${metadataUrl}`, userAgent: "custom-agent/1.0" }, + ]); + }), + ); +});