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;