From f7349f0c9f4bd7372c38e06fe64330c84f58e77e Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 28 Aug 2026 02:14:11 -0700 Subject: [PATCH 1/2] Support authenticated remote MCP servers with custom request headers The add-MCP form had no way to supply request headers, so an endpoint behind an edge authenticator could not be added at all. It now carries a name/value headers editor whose values ride along on the connection check and on every later request, through the existing config field. A 403 from such a gate also no longer reads as an unreachable server. It classifies exactly as a 401 does, so the flow continues to the auth step instead of stopping on "Couldn't reach this URL". --- .changeset/mcp-remote-request-headers.md | 15 ++ e2e/selfhost/mcp-request-headers-add.test.ts | 133 ++++++++++++++++++ .../mcp/src/react/AddMcpIntegration.tsx | 25 +++- .../mcp/src/react/McpRequestHeadersEditor.tsx | 118 ++++++++++++++++ .../mcp/src/react/request-headers.test.ts | 83 +++++++++++ .../plugins/mcp/src/react/request-headers.ts | 58 ++++++++ .../plugins/mcp/src/sdk/probe-shape.test.ts | 90 ++++++++++++ packages/plugins/mcp/src/sdk/probe-shape.ts | 45 +++--- 8 files changed, 547 insertions(+), 20 deletions(-) create mode 100644 .changeset/mcp-remote-request-headers.md create mode 100644 e2e/selfhost/mcp-request-headers-add.test.ts create mode 100644 packages/plugins/mcp/src/react/McpRequestHeadersEditor.tsx create mode 100644 packages/plugins/mcp/src/react/request-headers.test.ts create mode 100644 packages/plugins/mcp/src/react/request-headers.ts diff --git a/.changeset/mcp-remote-request-headers.md b/.changeset/mcp-remote-request-headers.md new file mode 100644 index 0000000000..d7bd5c212d --- /dev/null +++ b/.changeset/mcp-remote-request-headers.md @@ -0,0 +1,15 @@ +--- +"executor": patch +--- + +**Fix: add remote MCP servers that sit behind an authenticating proxy** + +The add-MCP form now carries an optional request headers editor. The name/value +pairs are sent on the connection check and on every later request, so an +endpoint gated by an edge authenticator — a Cloudflare Access service token, +for example — can be discovered and added. + +A `403` from such a gate is also no longer read as an unreachable server. It is +classified the same way a `401` is: the endpoint needs credentials, so the add +flow continues to the auth step instead of stopping on "Couldn't reach this +URL". diff --git a/e2e/selfhost/mcp-request-headers-add.test.ts b/e2e/selfhost/mcp-request-headers-add.test.ts new file mode 100644 index 0000000000..3a8f61c324 --- /dev/null +++ b/e2e/selfhost/mcp-request-headers-add.test.ts @@ -0,0 +1,133 @@ +// Regression guard for adding a remote MCP server that sits behind an edge +// authenticator. Cloudflare Access answers an unauthenticated request with a +// `403` HTML sign-in page, so the MCP server itself is never reached: no +// Bearer challenge, no RFC 9728 metadata, no JSON-RPC body. That used to read +// as "Couldn't reach this URL", which is wrong and offered no way forward. +// +// Now the 403 classifies as auth-required, and the add flow carries a request +// headers editor whose values ride along on the connection check. Both halves +// are asserted here: the flow reaches the auth editor, and the service-token +// headers actually reach the server. +// +// Selfhost-only because the probe must reach a loopback server: the selfhost +// instance runs with EXECUTOR_ALLOW_LOCAL_NETWORK. Video is the artifact. +import { randomBytes } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect, Ref } from "effect"; +import { HttpServerResponse } from "effect/unstable/http"; +import { composePluginApi } from "@executor-js/api/server"; +import { deriveMcpNamespace } from "@executor-js/plugin-mcp"; +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; +import { IntegrationSlug } from "@executor-js/sdk/shared"; +import { serveTestHttpApp } from "@executor-js/sdk/testing"; + +import { scenario } from "../src/scenario"; +import { Api, Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; + +const api = composePluginApi([mcpHttpPlugin()] as const); + +const CLIENT_ID_HEADER = "CF-Access-Client-Id"; +const CLIENT_SECRET_HEADER = "CF-Access-Client-Secret"; +const CLIENT_ID = "e2e-service-token-id"; +const CLIENT_SECRET = "e2e-service-token-secret"; + +scenario( + "MCP headers · a 403 edge gate is addable with service-token headers", + {}, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const { client: makeApiClient } = yield* Api; + + // Cloudflare Access shape: every request is answered with a 403 HTML + // sign-in page. The headers each request carried are recorded so the + // scenario can prove the connection check sent the configured pair. + const seen = yield* Ref.make>[]>([]); + const server = yield* serveTestHttpApp((request) => + Effect.gen(function* () { + yield* Ref.update(seen, (all) => [...all, request.headers]); + if ((request.url ?? "").includes("/.well-known/")) { + return HttpServerResponse.text("missing", { status: 404 }); + } + return HttpServerResponse.text("Sign in", { + status: 403, + contentType: "text/html", + }); + }), + ); + + const endpoint = server.url("/mcp"); + // The gate reports no server name, so the probe cannot seed a unique + // identity. Selfhost identities share one tenant, so name the + // integration uniquely to keep the derived slug stable across runs. + const name = `edge-gated-403-${randomBytes(3).toString("hex")}`; + const slug = IntegrationSlug.make(deriveMcpNamespace({ name })); + const identity = yield* target.newIdentity(); + const client = yield* makeApiClient(api, identity); + + yield* Effect.gen(function* () { + yield* browser.session(identity, async ({ page, step }) => { + await step("Open the add-MCP flow pointed at the 403-gated server", async () => { + await visit(page, `/integrations/add/mcp?url=${encodeURIComponent(endpoint)}`); + // Before the fix the 403 read as unreachable and the flow stopped + // on "Couldn't reach this URL". Now it continues. + await page.getByText("How does this server authenticate?").waitFor(); + await page.getByText("Auth required").first().waitFor(); + }); + + await step("Configure the Cloudflare Access service-token headers", async () => { + await page.getByRole("button", { name: "Add header" }).click(); + await page.getByLabel("Header name").nth(0).fill(CLIENT_ID_HEADER); + await page.getByLabel("Header value").nth(0).fill(CLIENT_ID); + await page.getByRole("button", { name: "Add header" }).click(); + await page.getByLabel("Header name").nth(1).fill(CLIENT_SECRET_HEADER); + await page.getByLabel("Header value").nth(1).fill(CLIENT_SECRET); + }); + + await step("Test connection sends the headers to the server", async () => { + await page.getByRole("button", { name: "Test connection" }).click(); + // The button reports the in-flight probe with `data-loading`. + // Wait for it to clear so the re-probe has landed before we add. + await page.locator("button[data-loading]").waitFor({ state: "detached" }); + await page.getByText("Auth required").first().waitFor(); + }); + + await step("Add the integration", async () => { + await page.getByPlaceholder("e.g. Linear").fill(name); + await page.getByRole("button", { name: "Add integration" }).click(); + await page.waitForURL(/\/integrations\/(?!add\b)[^/?]+$/, { timeout: 30_000 }); + const landedSlug = new URL(page.url()).pathname.split("/").filter(Boolean).at(-1); + expect(landedSlug, "the add flow lands on the created integration").toBe(String(slug)); + }); + }); + + const requests = yield* Ref.get(seen); + const authenticated = requests.filter( + (headers) => headers[CLIENT_ID_HEADER.toLowerCase()] === CLIENT_ID, + ); + expect( + authenticated.length, + "the connection check reaches the server with the configured headers", + ).toBeGreaterThan(0); + expect( + authenticated.some( + (headers) => headers[CLIENT_SECRET_HEADER.toLowerCase()] === CLIENT_SECRET, + ), + "both halves of the service token are sent together", + ).toBe(true); + + const stored = yield* client.mcp.getServer({ params: { slug } }); + expect(stored?.config, "the headers persist on the integration").toMatchObject({ + transport: "remote", + headers: { + [CLIENT_ID_HEADER]: CLIENT_ID, + [CLIENT_SECRET_HEADER]: CLIENT_SECRET, + }, + }); + }).pipe(Effect.ensuring(client.mcp.removeServer({ params: { slug } }).pipe(Effect.ignore))); + }), + ), +); diff --git a/packages/plugins/mcp/src/react/AddMcpIntegration.tsx b/packages/plugins/mcp/src/react/AddMcpIntegration.tsx index 16aecb08b1..e493a35ab1 100644 --- a/packages/plugins/mcp/src/react/AddMcpIntegration.tsx +++ b/packages/plugins/mcp/src/react/AddMcpIntegration.tsx @@ -38,6 +38,8 @@ import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; import type { McpAuthMethodInput } from "../sdk/types"; import { probeMcpEndpoint, addMcpServer } from "./atoms"; import { McpRemoteIntegrationFields } from "./McpRemoteIntegrationFields"; +import { McpRequestHeadersEditor } from "./McpRequestHeadersEditor"; +import { mcpHeadersFromRows, type McpHeaderRow } from "./request-headers"; import { mcpAuthMethodInputFromEditorValue, mcpWireAuthInput } from "./auth-method-config"; import { cloudflareNeedsCodemodeOptOut } from "../sdk/cloudflare-codemode"; import { mcpPresets, type McpPreset } from "../sdk/presets"; @@ -208,6 +210,12 @@ export default function AddMcpIntegration(props: { remoteUrl ? { step: "url" as const, url: remoteUrl } : init, ); + // Static request headers for the endpoint (e.g. a Cloudflare Access service + // token). They gate the probe as much as the live traffic, so the same + // values feed both. + const [headerRows, setHeaderRows] = useState([]); + const headers = useMemo(() => mcpHeadersFromRows(headerRows), [headerRows]); + const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promiseExit" }); const doAddServer = useAtomSet(addMcpServer, { mode: "promiseExit" }); @@ -273,7 +281,7 @@ export default function AddMcpIntegration(props: { const handleProbe = useCallback(async () => { dispatch({ type: "probe-start" }); const exit = await doProbe({ - payload: { endpoint: state.url.trim() }, + payload: { endpoint: state.url.trim(), ...(headers ? { headers } : {}) }, }); if (Exit.isFailure(exit)) { dispatch({ @@ -283,7 +291,7 @@ export default function AddMcpIntegration(props: { return; } dispatch({ type: "probe-ok", probe: exit.value }); - }, [state.url, doProbe]); + }, [state.url, headers, doProbe]); // Keep the latest handleProbe in a ref so the debounced effect can call it // without depending on its identity (which changes every render). @@ -318,6 +326,7 @@ export default function AddMcpIntegration(props: { : {}), endpoint: state.url.trim(), ...(slug ? { slug } : {}), + ...(headers ? { headers } : {}), authenticationTemplate, }, reactivityKeys: integrationWriteKeys, @@ -331,7 +340,7 @@ export default function AddMcpIntegration(props: { } return exit.value.slug; }, - [doAddServer, probe, remoteIdentity, resolvedDescription, state.url], + [doAddServer, headers, probe, remoteIdentity, resolvedDescription, state.url], ); const handleAddRemote = useCallback(async () => { @@ -446,6 +455,16 @@ export default function AddMcpIntegration(props: { )} + {/* Static request headers. Shown in every remote state, because an + endpoint behind an edge authenticator (Cloudflare Access) fails + the very first probe until its service-token headers are set. */} + + {/* Authentication — declares the auth methods to register through the shared list editor. The credentials themselves (API key value / OAuth sign-in) are added from the integration's detail hub after diff --git a/packages/plugins/mcp/src/react/McpRequestHeadersEditor.tsx b/packages/plugins/mcp/src/react/McpRequestHeadersEditor.tsx new file mode 100644 index 0000000000..046cd44db4 --- /dev/null +++ b/packages/plugins/mcp/src/react/McpRequestHeadersEditor.tsx @@ -0,0 +1,118 @@ +import { PlusIcon, XIcon } from "lucide-react"; + +import { Button } from "@executor-js/react/components/button"; +import { + CardStack, + CardStackContent, + CardStackEntryField, +} from "@executor-js/react/components/card-stack"; +import { FieldError } from "@executor-js/react/components/field"; +import { Input } from "@executor-js/react/components/input"; + +import { emptyHeaderRow, isValidHeaderName, type McpHeaderRow } from "./request-headers"; + +// --------------------------------------------------------------------------- +// Request headers editor — the name/value pairs sent on every request to a +// remote MCP server, including the connection check. +// +// Deliberately plain: two mono fields and a remove control per row, matching +// the metadata voice the rest of the add flow uses. Rows are keyed by index +// because the values are fully controlled, exactly as the shared placement +// editor does it. +// --------------------------------------------------------------------------- + +export function McpRequestHeadersEditor(props: { + readonly rows: readonly McpHeaderRow[]; + readonly onChange: (rows: McpHeaderRow[]) => void; + /** Re-run the connection check with the headers as typed. */ + readonly onTest?: () => void; + readonly testing?: boolean; +}) { + const { rows, onChange } = props; + + const set = (index: number, patch: Partial): void => + onChange(rows.map((row, j) => (j === index ? { ...row, ...patch } : row))); + + const remove = (index: number): void => onChange(rows.filter((_row, j) => j !== index)); + + const hasInvalidName = rows.some((row) => !isValidHeaderName(row.name)); + + return ( + + + + {rows.length > 0 && ( +
+ {rows.map((row, index) => ( +
+ set(index, { name: (e.target as HTMLInputElement).value })} + placeholder="CF-Access-Client-Id" + className="h-8 min-w-0 flex-1 font-mono text-xs" + aria-invalid={isValidHeaderName(row.name) ? undefined : true} + /> + set(index, { value: (e.target as HTMLInputElement).value })} + placeholder="Value" + className="h-8 min-w-0 flex-1 font-mono text-xs" + /> + +
+ ))} +
+ )} + + {hasInvalidName && ( + A header name cannot contain spaces or a colon. + )} + +
+ + {props.onTest && rows.length > 0 && ( + + )} +
+ +

+ Stored with the integration and sent verbatim. Use these for endpoint-level access + tokens, such as a Cloudflare Access service token. A per-account credential belongs in + an auth method instead. +

+
+
+
+ ); +} diff --git a/packages/plugins/mcp/src/react/request-headers.test.ts b/packages/plugins/mcp/src/react/request-headers.test.ts new file mode 100644 index 0000000000..3cb20a9b37 --- /dev/null +++ b/packages/plugins/mcp/src/react/request-headers.test.ts @@ -0,0 +1,83 @@ +// --------------------------------------------------------------------------- +// The row -> wire conversion behind the request-headers editor. What matters +// is that a half-typed row never reaches the transport and that a pasted +// token still works, so both are pinned here. +// --------------------------------------------------------------------------- + +import { describe, expect, it } from "@effect/vitest"; + +import { emptyHeaderRow, isValidHeaderName, mcpHeadersFromRows } from "./request-headers"; + +describe("mcpHeadersFromRows", () => { + it("returns undefined when nothing is configured", () => { + expect(mcpHeadersFromRows([])).toBeUndefined(); + expect(mcpHeadersFromRows([emptyHeaderRow()])).toBeUndefined(); + }); + + it("builds the wire map from completed rows", () => { + expect( + mcpHeadersFromRows([ + { name: "CF-Access-Client-Id", value: "client-id" }, + { name: "CF-Access-Client-Secret", value: "client-secret" }, + ]), + ).toEqual({ + "CF-Access-Client-Id": "client-id", + "CF-Access-Client-Secret": "client-secret", + }); + }); + + it("drops a row the user has not finished naming", () => { + expect( + mcpHeadersFromRows([ + { name: "X-Api-Key", value: "k" }, + { name: " ", value: "orphaned" }, + ]), + ).toEqual({ "X-Api-Key": "k" }); + }); + + it("trims surrounding whitespace off a pasted token", () => { + expect(mcpHeadersFromRows([{ name: " X-Token ", value: " abc123\n" }])).toEqual({ + "X-Token": "abc123", + }); + }); + + it("drops a name that cannot be put on the wire", () => { + expect( + mcpHeadersFromRows([ + { name: "Bad Header", value: "v" }, + { name: "Also:Bad", value: "v" }, + { name: "Good-Header", value: "v" }, + ]), + ).toEqual({ "Good-Header": "v" }); + }); + + it("keeps a header whose value is deliberately empty", () => { + expect(mcpHeadersFromRows([{ name: "X-Empty", value: "" }])).toEqual({ "X-Empty": "" }); + }); + + it("lets a later row win a duplicated name", () => { + expect( + mcpHeadersFromRows([ + { name: "X-Token", value: "old" }, + { name: "X-Token", value: "new" }, + ]), + ).toEqual({ "X-Token": "new" }); + }); +}); + +describe("isValidHeaderName", () => { + it("accepts an RFC 7230 token", () => { + expect(isValidHeaderName("CF-Access-Client-Id")).toBe(true); + expect(isValidHeaderName("X_Api_Key")).toBe(true); + }); + + it("treats a blank name as unfinished rather than invalid", () => { + expect(isValidHeaderName("")).toBe(true); + expect(isValidHeaderName(" ")).toBe(true); + }); + + it("rejects a name with a space or a colon", () => { + expect(isValidHeaderName("Bad Header")).toBe(false); + expect(isValidHeaderName("Also:Bad")).toBe(false); + }); +}); diff --git a/packages/plugins/mcp/src/react/request-headers.ts b/packages/plugins/mcp/src/react/request-headers.ts new file mode 100644 index 0000000000..aecc32b3f1 --- /dev/null +++ b/packages/plugins/mcp/src/react/request-headers.ts @@ -0,0 +1,58 @@ +// --------------------------------------------------------------------------- +// Static request headers for a remote MCP server. +// +// Some MCP endpoints sit behind an edge authenticator rather than the MCP +// authorization spec — Cloudflare Access, for example, wants a +// `CF-Access-Client-Id` / `CF-Access-Client-Secret` pair on every request and +// answers 403 without them. Those are properties of the ENDPOINT, not of an +// account: every caller sends the same pair, and the server behind them still +// does its own MCP-level auth. They therefore belong in the integration's +// static `headers` config, which the probe and the live transport already +// read, and NOT in an auth method (an auth method carries one per-account +// credential value, so it cannot express a two-part token at all). +// +// The editor keeps rows rather than a map so a half-typed row survives a +// re-render. This module owns the row -> wire conversion. +// --------------------------------------------------------------------------- + +export type McpHeaderRow = { + readonly name: string; + readonly value: string; +}; + +export const emptyHeaderRow = (): McpHeaderRow => ({ name: "", value: "" }); + +/** RFC 7230 `token`: the only characters a header field name may contain. + * A name outside this set makes the whole request unsendable, so it is + * rejected in the editor instead of being discovered at connect time. */ +const HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; + +/** Whether a row's name can be put on the wire. A blank name is not + * "invalid" — it is an unfinished row, which the editor leaves alone. */ +export const isValidHeaderName = (name: string): boolean => { + const trimmed = name.trim(); + return trimmed.length === 0 || HEADER_NAME_PATTERN.test(trimmed); +}; + +/** + * Collapse editor rows into the wire `headers` map. + * + * Blank and malformed names are dropped: the first is a row the user has not + * finished, the second cannot be sent at all. Names and values are trimmed — + * RFC 7230 treats surrounding whitespace as no part of a field value, so a + * token pasted with a trailing newline still works. + * + * Returns `undefined` when nothing is configured, so callers omit the field + * rather than sending an empty object. + */ +export const mcpHeadersFromRows = ( + rows: readonly McpHeaderRow[], +): Record | undefined => { + const headers: Record = {}; + for (const row of rows) { + const name = row.name.trim(); + if (!name || !HEADER_NAME_PATTERN.test(name)) continue; + headers[name] = row.value.trim(); + } + return Object.keys(headers).length > 0 ? headers : undefined; +}; diff --git a/packages/plugins/mcp/src/sdk/probe-shape.test.ts b/packages/plugins/mcp/src/sdk/probe-shape.test.ts index 8a61f40b47..898fd7ced2 100644 --- a/packages/plugins/mcp/src/sdk/probe-shape.test.ts +++ b/packages/plugins/mcp/src/sdk/probe-shape.test.ts @@ -267,6 +267,96 @@ describe("probeMcpEndpointShape", () => { ), ); + // Cloudflare Access shape: an edge authenticator in front of the MCP + // server answers an unauthenticated request with `403` and an HTML + // login page. The MCP server is never reached, so there is no Bearer + // challenge and no JSON-RPC body. This must read as "supply + // credentials", not as "this URL is not MCP" — the latter told users + // the endpoint was unreachable when it was merely protected. + it.effect("classifies a 403 HTML edge challenge as auth-required", () => + withServer( + () => + HttpServerResponse.text("Sign in", { + status: 403, + contentType: "text/html", + }), + (endpoint) => + Effect.gen(function* () { + const result = yield* probeMcpEndpointShape(endpoint); + expect(result).toMatchObject({ kind: "not-mcp", category: "auth-required" }); + }), + ), + ); + + it.effect("classifies 403 with Bearer + JSON-RPC error envelope as MCP+auth", () => + withServer( + () => + HttpServerResponse.jsonUnsafe( + { + jsonrpc: "2.0", + id: null, + error: { code: -32000, message: "Forbidden" }, + }, + { status: 403, headers: { "www-authenticate": "Bearer" } }, + ), + (endpoint) => + Effect.gen(function* () { + const result = yield* probeMcpEndpointShape(endpoint); + expect(result).toEqual({ kind: "mcp", requiresAuth: true }); + }), + ), + ); + + it.effect("rejects a 403 whose Bearer challenge carries a GraphQL body", () => + withServer( + () => + HttpServerResponse.jsonUnsafe( + { errors: [{ message: "Forbidden" }] }, + { status: 403, headers: { "www-authenticate": "Bearer" } }, + ), + (endpoint) => + Effect.gen(function* () { + const result = yield* probeMcpEndpointShape(endpoint); + expect(result).toMatchObject({ kind: "not-mcp", category: "auth-required" }); + }), + ), + ); + + // The other half of the Cloudflare Access story: once the service-token + // headers are configured, the same endpoint answers normally. Proves the + // probe actually puts `options.headers` on the wire. + it.effect("sends configured request headers and clears an edge challenge", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveProbeEndpoint((request) => { + if (request.headers["cf-access-client-id"] !== "client-id") { + return HttpServerResponse.text("Sign in", { + status: 403, + contentType: "text/html", + }); + } + return HttpServerResponse.jsonUnsafe({ + jsonrpc: "2.0", + id: 1, + result: { + protocolVersion: "2025-06-18", + capabilities: {}, + serverInfo: { name: "t", version: "0" }, + }, + }); + }); + + const blocked = yield* probeMcpEndpointShape(server.endpoint); + expect(blocked).toMatchObject({ kind: "not-mcp", category: "auth-required" }); + + const allowed = yield* probeMcpEndpointShape(server.endpoint, { + headers: { "CF-Access-Client-Id": "client-id" }, + }); + expect(allowed).toEqual({ kind: "mcp", requiresAuth: false }); + }), + ), + ); + it.effect("falls back to GET for OAuth-protected SSE endpoints", () => withServer( (request) => { diff --git a/packages/plugins/mcp/src/sdk/probe-shape.ts b/packages/plugins/mcp/src/sdk/probe-shape.ts index 1049fb9f22..b64f791139 100644 --- a/packages/plugins/mcp/src/sdk/probe-shape.ts +++ b/packages/plugins/mcp/src/sdk/probe-shape.ts @@ -20,10 +20,12 @@ // transport, body is an SSE stream we don't consume. // - 2xx with `Content-Type: application/json` whose body parses as a // JSON-RPC 2.0 envelope (`{jsonrpc:"2.0", result|error|method,...}`). -// - 401 with `WWW-Authenticate: Bearer` AND a JSON-RPC error envelope -// in the body. The body shape is what separates a real MCP server -// from an unrelated OAuth-protected API: GraphQL/REST/HTML 401s -// don't shape themselves as JSON-RPC. +// - 401 (or 403) with `WWW-Authenticate: Bearer` AND a JSON-RPC error +// envelope in the body. The body shape is what separates a real MCP +// server from an unrelated OAuth-protected API: GraphQL/REST/HTML +// 401s don't shape themselves as JSON-RPC. 403 travels the same path +// because an edge authenticator (Cloudflare Access) answers with 403 +// and the endpoint is not therefore unreachable. // // When POST returns 404/405/406/415 we retry with GET + `Accept: // text/event-stream` to support legacy SSE-only servers; that path @@ -82,6 +84,16 @@ const readHeader = (headers: Readonly>, name: string): st return null; }; +/** Statuses that mean "you are not authenticated for this resource". + * + * 401 is what the MCP authorization spec mandates. 403 is what an edge + * authenticator in front of the server returns instead — Cloudflare + * Access, for example, answers an unauthenticated request with 403 and + * never reaches the MCP server at all. Both say the same thing to the + * user: supply credentials and retry. Treating 403 as a wrong shape + * told them the URL was wrong, which it was not. */ +const isAuthStatus = (status: number): boolean => status === 401 || status === 403; + class ProbeTransportError extends Data.TaggedError("ProbeTransportError")<{ readonly reason: string; readonly cause: unknown; @@ -213,9 +225,9 @@ const reasonFromBoundaryCause = (cause: unknown): string => { /** Why the probe rejected an endpoint as not-MCP. * - * - `auth-required` — server returned 401. We don't know for sure it's - * an MCP server (no spec-compliant Bearer challenge or the body - * isn't JSON-RPC), but the right next step for the user is the same + * - `auth-required` — server returned 401 or 403. We don't know for + * sure it's an MCP server (no spec-compliant Bearer challenge or the + * body isn't JSON-RPC), but the right next step for the user is the same * either way: provide credentials and retry. This is what * misclassifies real MCP servers like cubic.dev (no * resource_metadata) or ref.tools (no WWW-Authenticate at all) @@ -227,7 +239,7 @@ export type McpProbeRejectCategory = "auth-required" | "wrong-shape"; export type McpShapeProbeResult = /** Server answered initialize successfully — either a 2xx with a - * JSON-RPC payload, or a 401 + WWW-Authenticate: Bearer (RFC 6750 + * JSON-RPC payload, or a 401/403 + WWW-Authenticate: Bearer (RFC 6750 * challenge) that the MCP auth spec requires. */ | { readonly kind: "mcp"; readonly requiresAuth: boolean } /** Endpoint is reachable but the response does not look like MCP. */ @@ -253,7 +265,7 @@ export interface ProbeOptions { * * Returns `{kind: "mcp"}` only when the endpoint either: * - answers with 2xx (unauth-OK MCP server), or - * - responds 401 with a `Bearer` WWW-Authenticate challenge. + * - responds 401 or 403 with a `Bearer` WWW-Authenticate challenge. * * Anything else (400, 404, 200-with-HTML, 200-with-GraphQL-errors, ...) * is classified `not-mcp`. Transport errors surface as `unreachable`. @@ -287,20 +299,20 @@ export const probeMcpEndpointShape = ( const contentType = readHeader(response.headers, "content-type") ?? ""; const isSse = /^\s*text\/event-stream\b/i.test(contentType); - if (response.status === 401) { + if (isAuthStatus(response.status)) { const wwwAuth = readHeader(response.headers, "www-authenticate"); if (!wwwAuth || !/^\s*bearer\b/i.test(wwwAuth)) { - // Spec-non-compliant 401 (no `Bearer` challenge). Before - // giving up, check whether the server still publishes - // RFC 9728 protected-resource metadata for this path — - // some real MCP servers (Datadog) do exactly this. + // Spec-non-compliant challenge (no `Bearer`). Before giving + // up, check whether the server still publishes RFC 9728 + // protected-resource metadata for this path — some real MCP + // servers (Datadog) do exactly this. if (yield* probeProtectedResourceMetadata(client, url, timeoutMs)) { return { kind: "mcp", requiresAuth: true } as const; } return { kind: "not-mcp", category: "auth-required", - reason: "401 without Bearer WWW-Authenticate — not an MCP auth challenge", + reason: `${response.status} without Bearer WWW-Authenticate — not an MCP auth challenge`, } as const; } // Spec-compliant MCP signal: the auth spec mandates a @@ -347,8 +359,7 @@ export const probeMcpEndpointShape = ( return { kind: "not-mcp", category: "auth-required", - reason: - "401 + Bearer without resource_metadata, JSON-RPC body, or OAuth error body", + reason: `${response.status} + Bearer without resource_metadata, JSON-RPC body, or OAuth error body`, } as const; } return { kind: "mcp", requiresAuth: true } as const; From ed2ec34b32798be54ba193b07916b24060e20537 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 28 Aug 2026 02:49:35 -0700 Subject: [PATCH 2/2] Declare lucide-react in the mcp plugin package The request headers editor imports lucide icons, but the package never declared the dependency. It only resolved locally because a node_modules directory above the checkout carried it; CI has no such ancestor and the typecheck failed to resolve the module. Declare it the way the openapi plugin already does. --- bun.lock | 1 + packages/plugins/mcp/package.json | 1 + 2 files changed, 2 insertions(+) diff --git a/bun.lock b/bun.lock index 19c5bb9676..31564f2592 100644 --- a/bun.lock +++ b/bun.lock @@ -999,6 +999,7 @@ "@modelcontextprotocol/client": "2.0.0", "@modelcontextprotocol/core": "2.0.0", "@modelcontextprotocol/sdk": "^1.29.0", + "lucide-react": "^1.7.0", "zod": "4.3.6", }, "devDependencies": { diff --git a/packages/plugins/mcp/package.json b/packages/plugins/mcp/package.json index 30946fae70..4368c78cf2 100644 --- a/packages/plugins/mcp/package.json +++ b/packages/plugins/mcp/package.json @@ -69,6 +69,7 @@ "@modelcontextprotocol/client": "2.0.0", "@modelcontextprotocol/core": "2.0.0", "@modelcontextprotocol/sdk": "^1.29.0", + "lucide-react": "^1.7.0", "zod": "4.3.6" }, "devDependencies": {