Skip to content
Closed
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-default-user-agent.md
Original file line number Diff line number Diff line change
@@ -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.
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.
175 changes: 175 additions & 0 deletions packages/plugins/mcp/src/sdk/connection-user-agent.test.ts
Original file line number Diff line number Diff line change
@@ -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<string | undefined>) =>
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<string | undefined> = [];

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<string | undefined> = [];

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"]),
);
}),
),
);
});
9 changes: 6 additions & 3 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 @@ -625,10 +626,12 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => {
});
}

// Remote transport
const headers = input.headers ?? {};
// 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 = Object.keys(headers).length > 0 ? { headers } : undefined;
const requestInit = { headers };
const fetch = input.httpClientLayer
? fetchFromHttpClientLayer(input.httpClientLayer, input.staticOAuthBearer === true)
: undefined;
Expand Down
98 changes: 98 additions & 0 deletions packages/plugins/mcp/src/sdk/probe-shape-user-agent.test.ts
Original file line number Diff line number Diff line change
@@ -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<RecordedRequest>,
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<RecordedRequest>) =>
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<RecordedRequest> = [];

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<RecordedRequest> = [];

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<RecordedRequest> = [];

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" },
]);
}),
);
});
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;
3 changes: 3 additions & 0 deletions packages/plugins/mcp/src/testing/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down Expand Up @@ -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";
Expand All @@ -147,6 +149,7 @@ export const serveMcpServer = (factory: () => McpServer, options: McpTestServerO
method: request.method ?? "GET",
url: requestUrl,
authorization,
userAgent,
sessionId,
},
]);
Expand Down