Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/mcp-probe-user-agent.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 4 additions & 12 deletions packages/plugins/mcp/src/sdk/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -72,17 +73,6 @@ const buildEndpointUrl = (endpoint: string, queryParams: Record<string, string>)
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<string, string>): Record<string, string> =>
Object.keys(headers).some((name) => name.toLowerCase() === "user-agent")
? headers
: { "User-Agent": DEFAULT_USER_AGENT, ...headers };

type HttpMethod = Parameters<typeof HttpClientRequest.make>[0];
const HTTP_METHODS = new Set<HttpMethod>([
"DELETE",
Expand Down Expand Up @@ -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 };
Expand Down
9 changes: 8 additions & 1 deletion packages/plugins/mcp/src/sdk/probe-shape.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-
Expand Down Expand Up @@ -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<string, unknown>;
Expand Down
16 changes: 16 additions & 0 deletions packages/plugins/mcp/src/sdk/user-agent.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string>>): string | undefined =>
Object.entries(headers).find(([name]) => name.toLowerCase() === "user-agent")?.[1];

export const withDefaultUserAgent = (headers: Record<string, string>): Record<string, string> =>
configuredUserAgent(headers) === undefined
? { "User-Agent": DEFAULT_USER_AGENT, ...headers }
: headers;

export const userAgentFor = (headers: Readonly<Record<string, string>>): string =>
configuredUserAgent(headers) ?? DEFAULT_USER_AGENT;
Loading