History

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".

Rhys Sullivan committed 2 months ago

Commit f7349f01 parent ac2dec68 files · +547 −20Browse files

Unified · Split

8 files
  1. .changeset/mcp-remote-request-headers.md +15 −0
  2. e2e/selfhost/mcp-request-headers-add.test.ts +133 −0
  3. packages/plugins/mcp/src/react/AddMcpIntegration.tsx +22 −3
  4. packages/plugins/mcp/src/react/McpRequestHeadersEditor.tsx +118 −0
  5. packages/plugins/mcp/src/react/request-headers.test.ts +83 −0
  6. packages/plugins/mcp/src/react/request-headers.ts +58 −0
  7. packages/plugins/mcp/src/sdk/probe-shape.test.ts +90 −0
  8. packages/plugins/mcp/src/sdk/probe-shape.ts +28 −17
.changeset/mcp-remote-request-headers.md+15−0
@@ -0,0 +1,15 @@
1---
2"executor": patch
3---
4
5**Fix: add remote MCP servers that sit behind an authenticating proxy**
6
7The add-MCP form now carries an optional request headers editor. The name/value
8pairs are sent on the connection check and on every later request, so an
9endpoint gated by an edge authenticator — a Cloudflare Access service token,
10for example — can be discovered and added.
11
12A `403` from such a gate is also no longer read as an unreachable server. It is
13classified the same way a `401` is: the endpoint needs credentials, so the add
14flow continues to the auth step instead of stopping on "Couldn't reach this
15URL".
e2e/selfhost/mcp-request-headers-add.test.ts+133−0
@@ -0,0 +1,133 @@
1// Regression guard for adding a remote MCP server that sits behind an edge
2// authenticator. Cloudflare Access answers an unauthenticated request with a
3// `403` HTML sign-in page, so the MCP server itself is never reached: no
4// Bearer challenge, no RFC 9728 metadata, no JSON-RPC body. That used to read
5// as "Couldn't reach this URL", which is wrong and offered no way forward.
6//
7// Now the 403 classifies as auth-required, and the add flow carries a request
8// headers editor whose values ride along on the connection check. Both halves
9// are asserted here: the flow reaches the auth editor, and the service-token
10// headers actually reach the server.
11//
12// Selfhost-only because the probe must reach a loopback server: the selfhost
13// instance runs with EXECUTOR_ALLOW_LOCAL_NETWORK. Video is the artifact.
14import { randomBytes } from "node:crypto";
15
16import { expect } from "@effect/vitest";
17import { Effect, Ref } from "effect";
18import { HttpServerResponse } from "effect/unstable/http";
19import { composePluginApi } from "@executor-js/api/server";
20import { deriveMcpNamespace } from "@executor-js/plugin-mcp";
21import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
22import { IntegrationSlug } from "@executor-js/sdk/shared";
23import { serveTestHttpApp } from "@executor-js/sdk/testing";
24
25import { scenario } from "../src/scenario";
26import { Api, Browser, Target } from "../src/services";
27import { visit } from "../src/surfaces/browser";
28
29const api = composePluginApi([mcpHttpPlugin()] as const);
30
31const CLIENT_ID_HEADER = "CF-Access-Client-Id";
32const CLIENT_SECRET_HEADER = "CF-Access-Client-Secret";
33const CLIENT_ID = "e2e-service-token-id";
34const CLIENT_SECRET = "e2e-service-token-secret";
35
36scenario(
37 "MCP headers · a 403 edge gate is addable with service-token headers",
38 {},
39 Effect.scoped(
40 Effect.gen(function* () {
41 const target = yield* Target;
42 const browser = yield* Browser;
43 const { client: makeApiClient } = yield* Api;
44
45 // Cloudflare Access shape: every request is answered with a 403 HTML
46 // sign-in page. The headers each request carried are recorded so the
47 // scenario can prove the connection check sent the configured pair.
48 const seen = yield* Ref.make<readonly Readonly<Record<string, string>>[]>([]);
49 const server = yield* serveTestHttpApp((request) =>
50 Effect.gen(function* () {
51 yield* Ref.update(seen, (all) => [...all, request.headers]);
52 if ((request.url ?? "").includes("/.well-known/")) {
53 return HttpServerResponse.text("missing", { status: 404 });
54 }
55 return HttpServerResponse.text("<html><body>Sign in</body></html>", {
56 status: 403,
57 contentType: "text/html",
58 });
59 }),
60 );
61
62 const endpoint = server.url("/mcp");
63 // The gate reports no server name, so the probe cannot seed a unique
64 // identity. Selfhost identities share one tenant, so name the
65 // integration uniquely to keep the derived slug stable across runs.
66 const name = `edge-gated-403-${randomBytes(3).toString("hex")}`;
67 const slug = IntegrationSlug.make(deriveMcpNamespace({ name }));
68 const identity = yield* target.newIdentity();
69 const client = yield* makeApiClient(api, identity);
70
71 yield* Effect.gen(function* () {
72 yield* browser.session(identity, async ({ page, step }) => {
73 await step("Open the add-MCP flow pointed at the 403-gated server", async () => {
74 await visit(page, `/integrations/add/mcp?url=${encodeURIComponent(endpoint)}`);
75 // Before the fix the 403 read as unreachable and the flow stopped
76 // on "Couldn't reach this URL". Now it continues.
77 await page.getByText("How does this server authenticate?").waitFor();
78 await page.getByText("Auth required").first().waitFor();
79 });
80
81 await step("Configure the Cloudflare Access service-token headers", async () => {
82 await page.getByRole("button", { name: "Add header" }).click();
83 await page.getByLabel("Header name").nth(0).fill(CLIENT_ID_HEADER);
84 await page.getByLabel("Header value").nth(0).fill(CLIENT_ID);
85 await page.getByRole("button", { name: "Add header" }).click();
86 await page.getByLabel("Header name").nth(1).fill(CLIENT_SECRET_HEADER);
87 await page.getByLabel("Header value").nth(1).fill(CLIENT_SECRET);
88 });
89
90 await step("Test connection sends the headers to the server", async () => {
91 await page.getByRole("button", { name: "Test connection" }).click();
92 // The button reports the in-flight probe with `data-loading`.
93 // Wait for it to clear so the re-probe has landed before we add.
94 await page.locator("button[data-loading]").waitFor({ state: "detached" });
95 await page.getByText("Auth required").first().waitFor();
96 });
97
98 await step("Add the integration", async () => {
99 await page.getByPlaceholder("e.g. Linear").fill(name);
100 await page.getByRole("button", { name: "Add integration" }).click();
101 await page.waitForURL(/\/integrations\/(?!add\b)[^/?]+$/, { timeout: 30_000 });
102 const landedSlug = new URL(page.url()).pathname.split("/").filter(Boolean).at(-1);
103 expect(landedSlug, "the add flow lands on the created integration").toBe(String(slug));
104 });
105 });
106
107 const requests = yield* Ref.get(seen);
108 const authenticated = requests.filter(
109 (headers) => headers[CLIENT_ID_HEADER.toLowerCase()] === CLIENT_ID,
110 );
111 expect(
112 authenticated.length,
113 "the connection check reaches the server with the configured headers",
114 ).toBeGreaterThan(0);
115 expect(
116 authenticated.some(
117 (headers) => headers[CLIENT_SECRET_HEADER.toLowerCase()] === CLIENT_SECRET,
118 ),
119 "both halves of the service token are sent together",
120 ).toBe(true);
121
122 const stored = yield* client.mcp.getServer({ params: { slug } });
123 expect(stored?.config, "the headers persist on the integration").toMatchObject({
124 transport: "remote",
125 headers: {
126 [CLIENT_ID_HEADER]: CLIENT_ID,
127 [CLIENT_SECRET_HEADER]: CLIENT_SECRET,
128 },
129 });
130 }).pipe(Effect.ensuring(client.mcp.removeServer({ params: { slug } }).pipe(Effect.ignore)));
131 }),
132 ),
133);
packages/plugins/mcp/src/react/AddMcpIntegration.tsx+22−3
@@ -38,6 +38,8 @@ import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; · 37 unmodified lines
38import type { McpAuthMethodInput } from "../sdk/types";38import type { McpAuthMethodInput } from "../sdk/types";
39import { probeMcpEndpoint, addMcpServer } from "./atoms";39import { probeMcpEndpoint, addMcpServer } from "./atoms";
40import { McpRemoteIntegrationFields } from "./McpRemoteIntegrationFields";40import { McpRemoteIntegrationFields } from "./McpRemoteIntegrationFields";
41import { McpRequestHeadersEditor } from "./McpRequestHeadersEditor";
42import { mcpHeadersFromRows, type McpHeaderRow } from "./request-headers";
41import { mcpAuthMethodInputFromEditorValue, mcpWireAuthInput } from "./auth-method-config";43import { mcpAuthMethodInputFromEditorValue, mcpWireAuthInput } from "./auth-method-config";
42import { cloudflareNeedsCodemodeOptOut } from "../sdk/cloudflare-codemode";44import { cloudflareNeedsCodemodeOptOut } from "../sdk/cloudflare-codemode";
43import { mcpPresets, type McpPreset } from "../sdk/presets";45import { mcpPresets, type McpPreset } from "../sdk/presets";
@@ -208,6 +210,12 @@ export default function AddMcpIntegration(props: { · 164 unmodified lines
208 remoteUrl ? { step: "url" as const, url: remoteUrl } : init,210 remoteUrl ? { step: "url" as const, url: remoteUrl } : init,
209 );211 );
210 212
213 // Static request headers for the endpoint (e.g. a Cloudflare Access service
214 // token). They gate the probe as much as the live traffic, so the same
215 // values feed both.
216 const [headerRows, setHeaderRows] = useState<readonly McpHeaderRow[]>([]);
217 const headers = useMemo(() => mcpHeadersFromRows(headerRows), [headerRows]);
218
211 const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promiseExit" });219 const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promiseExit" });
212 const doAddServer = useAtomSet(addMcpServer, { mode: "promiseExit" });220 const doAddServer = useAtomSet(addMcpServer, { mode: "promiseExit" });
213 221
@@ -273,7 +281,7 @@ export default function AddMcpIntegration(props: { · 59 unmodified lines
273 const handleProbe = useCallback(async () => {281 const handleProbe = useCallback(async () => {
274 dispatch({ type: "probe-start" });282 dispatch({ type: "probe-start" });
275 const exit = await doProbe({283 const exit = await doProbe({
276 payload: { endpoint: state.url.trim() },284 payload: { endpoint: state.url.trim(), ...(headers ? { headers } : {}) },
277 });285 });
278 if (Exit.isFailure(exit)) {286 if (Exit.isFailure(exit)) {
279 dispatch({287 dispatch({
@@ -283,7 +291,7 @@ export default function AddMcpIntegration(props: { · 3 unmodified lines
283 return;291 return;
284 }292 }
285 dispatch({ type: "probe-ok", probe: exit.value });293 dispatch({ type: "probe-ok", probe: exit.value });
286 }, [state.url, doProbe]);294 }, [state.url, headers, doProbe]);
287 295
288 // Keep the latest handleProbe in a ref so the debounced effect can call it296 // Keep the latest handleProbe in a ref so the debounced effect can call it
289 // without depending on its identity (which changes every render).297 // without depending on its identity (which changes every render).
@@ -318,6 +326,7 @@ export default function AddMcpIntegration(props: { · 28 unmodified lines
318 : {}),326 : {}),
319 endpoint: state.url.trim(),327 endpoint: state.url.trim(),
320 ...(slug ? { slug } : {}),328 ...(slug ? { slug } : {}),
329 ...(headers ? { headers } : {}),
321 authenticationTemplate,330 authenticationTemplate,
322 },331 },
323 reactivityKeys: integrationWriteKeys,332 reactivityKeys: integrationWriteKeys,
@@ -331,7 +340,7 @@ export default function AddMcpIntegration(props: { · 7 unmodified lines
331 }340 }
332 return exit.value.slug;341 return exit.value.slug;
333 },342 },
334 [doAddServer, probe, remoteIdentity, resolvedDescription, state.url],343 [doAddServer, headers, probe, remoteIdentity, resolvedDescription, state.url],
335 );344 );
336 345
337 const handleAddRemote = useCallback(async () => {346 const handleAddRemote = useCallback(async () => {
@@ -446,6 +455,16 @@ export default function AddMcpIntegration(props: { · 108 unmodified lines
446 </Info>455 </Info>
447 )}456 )}
448 457
458 {/* Static request headers. Shown in every remote state, because an
459 endpoint behind an edge authenticator (Cloudflare Access) fails
460 the very first probe until its service-token headers are set. */}
461 <McpRequestHeadersEditor
462 rows={headerRows}
463 onChange={setHeaderRows}
464 onTest={handleProbe}
465 testing={isProbing}
466 />
467
449 {/* Authentication — declares the auth methods to register through the468 {/* Authentication — declares the auth methods to register through the
450 shared list editor. The credentials themselves (API key value /469 shared list editor. The credentials themselves (API key value /
451 OAuth sign-in) are added from the integration's detail hub after470 OAuth sign-in) are added from the integration's detail hub after
packages/plugins/mcp/src/react/McpRequestHeadersEditor.tsx+118−0
@@ -0,0 +1,118 @@
1import { PlusIcon, XIcon } from "lucide-react";
2
3import { Button } from "@executor-js/react/components/button";
4import {
5 CardStack,
6 CardStackContent,
7 CardStackEntryField,
8} from "@executor-js/react/components/card-stack";
9import { FieldError } from "@executor-js/react/components/field";
10import { Input } from "@executor-js/react/components/input";
11
12import { emptyHeaderRow, isValidHeaderName, type McpHeaderRow } from "./request-headers";
13
14// ---------------------------------------------------------------------------
15// Request headers editor — the name/value pairs sent on every request to a
16// remote MCP server, including the connection check.
17//
18// Deliberately plain: two mono fields and a remove control per row, matching
19// the metadata voice the rest of the add flow uses. Rows are keyed by index
20// because the values are fully controlled, exactly as the shared placement
21// editor does it.
22// ---------------------------------------------------------------------------
23
24export function McpRequestHeadersEditor(props: {
25 readonly rows: readonly McpHeaderRow[];
26 readonly onChange: (rows: McpHeaderRow[]) => void;
27 /** Re-run the connection check with the headers as typed. */
28 readonly onTest?: () => void;
29 readonly testing?: boolean;
30}) {
31 const { rows, onChange } = props;
32
33 const set = (index: number, patch: Partial<McpHeaderRow>): void =>
34 onChange(rows.map((row, j) => (j === index ? { ...row, ...patch } : row)));
35
36 const remove = (index: number): void => onChange(rows.filter((_row, j) => j !== index));
37
38 const hasInvalidName = rows.some((row) => !isValidHeaderName(row.name));
39
40 return (
41 <CardStack>
42 <CardStackContent className="border-t-0">
43 <CardStackEntryField
44 label="Request headers"
45 description="- Optional. Sent on every request, including the connection check."
46 >
47 {rows.length > 0 && (
48 <div className="flex flex-col gap-2">
49 {rows.map((row, index) => (
50 <div key={index} className="flex items-center gap-2">
51 <Input
52 aria-label="Header name"
53 value={row.name}
54 onChange={(e) => set(index, { name: (e.target as HTMLInputElement).value })}
55 placeholder="CF-Access-Client-Id"
56 className="h-8 min-w-0 flex-1 font-mono text-xs"
57 aria-invalid={isValidHeaderName(row.name) ? undefined : true}
58 />
59 <Input
60 aria-label="Header value"
61 value={row.value}
62 onChange={(e) => set(index, { value: (e.target as HTMLInputElement).value })}
63 placeholder="Value"
64 className="h-8 min-w-0 flex-1 font-mono text-xs"
65 />
66 <Button
67 type="button"
68 variant="ghost"
69 size="icon-sm"
70 aria-label="Remove header"
71 className="shrink-0 text-muted-foreground hover:text-foreground"
72 onClick={() => remove(index)}
73 >
74 <XIcon />
75 </Button>
76 </div>
77 ))}
78 </div>
79 )}
80
81 {hasInvalidName && (
82 <FieldError>A header name cannot contain spaces or a colon.</FieldError>
83 )}
84
85 <div className="flex flex-wrap items-center gap-2">
86 <Button
87 type="button"
88 variant="outline"
89 size="sm"
90 className="w-fit border-dashed"
91 onClick={() => onChange([...rows, emptyHeaderRow()])}
92 >
93 <PlusIcon />
94 Add header
95 </Button>
96 {props.onTest && rows.length > 0 && (
97 <Button
98 type="button"
99 variant="ghost"
100 size="sm"
101 onClick={props.onTest}
102 loading={props.testing}
103 >
104 Test connection
105 </Button>
106 )}
107 </div>
108
109 <p className="text-[11px] text-muted-foreground">
110 Stored with the integration and sent verbatim. Use these for endpoint-level access
111 tokens, such as a Cloudflare Access service token. A per-account credential belongs in
112 an auth method instead.
113 </p>
114 </CardStackEntryField>
115 </CardStackContent>
116 </CardStack>
117 );
118}
packages/plugins/mcp/src/react/request-headers.test.ts+83−0
@@ -0,0 +1,83 @@
1// ---------------------------------------------------------------------------
2// The row -> wire conversion behind the request-headers editor. What matters
3// is that a half-typed row never reaches the transport and that a pasted
4// token still works, so both are pinned here.
5// ---------------------------------------------------------------------------
6
7import { describe, expect, it } from "@effect/vitest";
8
9import { emptyHeaderRow, isValidHeaderName, mcpHeadersFromRows } from "./request-headers";
10
11describe("mcpHeadersFromRows", () => {
12 it("returns undefined when nothing is configured", () => {
13 expect(mcpHeadersFromRows([])).toBeUndefined();
14 expect(mcpHeadersFromRows([emptyHeaderRow()])).toBeUndefined();
15 });
16
17 it("builds the wire map from completed rows", () => {
18 expect(
19 mcpHeadersFromRows([
20 { name: "CF-Access-Client-Id", value: "client-id" },
21 { name: "CF-Access-Client-Secret", value: "client-secret" },
22 ]),
23 ).toEqual({
24 "CF-Access-Client-Id": "client-id",
25 "CF-Access-Client-Secret": "client-secret",
26 });
27 });
28
29 it("drops a row the user has not finished naming", () => {
30 expect(
31 mcpHeadersFromRows([
32 { name: "X-Api-Key", value: "k" },
33 { name: " ", value: "orphaned" },
34 ]),
35 ).toEqual({ "X-Api-Key": "k" });
36 });
37
38 it("trims surrounding whitespace off a pasted token", () => {
39 expect(mcpHeadersFromRows([{ name: " X-Token ", value: " abc123\n" }])).toEqual({
40 "X-Token": "abc123",
41 });
42 });
43
44 it("drops a name that cannot be put on the wire", () => {
45 expect(
46 mcpHeadersFromRows([
47 { name: "Bad Header", value: "v" },
48 { name: "Also:Bad", value: "v" },
49 { name: "Good-Header", value: "v" },
50 ]),
51 ).toEqual({ "Good-Header": "v" });
52 });
53
54 it("keeps a header whose value is deliberately empty", () => {
55 expect(mcpHeadersFromRows([{ name: "X-Empty", value: "" }])).toEqual({ "X-Empty": "" });
56 });
57
58 it("lets a later row win a duplicated name", () => {
59 expect(
60 mcpHeadersFromRows([
61 { name: "X-Token", value: "old" },
62 { name: "X-Token", value: "new" },
63 ]),
64 ).toEqual({ "X-Token": "new" });
65 });
66});
67
68describe("isValidHeaderName", () => {
69 it("accepts an RFC 7230 token", () => {
70 expect(isValidHeaderName("CF-Access-Client-Id")).toBe(true);
71 expect(isValidHeaderName("X_Api_Key")).toBe(true);
72 });
73
74 it("treats a blank name as unfinished rather than invalid", () => {
75 expect(isValidHeaderName("")).toBe(true);
76 expect(isValidHeaderName(" ")).toBe(true);
77 });
78
79 it("rejects a name with a space or a colon", () => {
80 expect(isValidHeaderName("Bad Header")).toBe(false);
81 expect(isValidHeaderName("Also:Bad")).toBe(false);
82 });
83});
packages/plugins/mcp/src/react/request-headers.ts+58−0
@@ -0,0 +1,58 @@
1// ---------------------------------------------------------------------------
2// Static request headers for a remote MCP server.
3//
4// Some MCP endpoints sit behind an edge authenticator rather than the MCP
5// authorization spec — Cloudflare Access, for example, wants a
6// `CF-Access-Client-Id` / `CF-Access-Client-Secret` pair on every request and
7// answers 403 without them. Those are properties of the ENDPOINT, not of an
8// account: every caller sends the same pair, and the server behind them still
9// does its own MCP-level auth. They therefore belong in the integration's
10// static `headers` config, which the probe and the live transport already
11// read, and NOT in an auth method (an auth method carries one per-account
12// credential value, so it cannot express a two-part token at all).
13//
14// The editor keeps rows rather than a map so a half-typed row survives a
15// re-render. This module owns the row -> wire conversion.
16// ---------------------------------------------------------------------------
17
18export type McpHeaderRow = {
19 readonly name: string;
20 readonly value: string;
21};
22
23export const emptyHeaderRow = (): McpHeaderRow => ({ name: "", value: "" });
24
25/** RFC 7230 `token`: the only characters a header field name may contain.
26 * A name outside this set makes the whole request unsendable, so it is
27 * rejected in the editor instead of being discovered at connect time. */
28const HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
29
30/** Whether a row's name can be put on the wire. A blank name is not
31 * "invalid" — it is an unfinished row, which the editor leaves alone. */
32export const isValidHeaderName = (name: string): boolean => {
33 const trimmed = name.trim();
34 return trimmed.length === 0 || HEADER_NAME_PATTERN.test(trimmed);
35};
36
37/**
38 * Collapse editor rows into the wire `headers` map.
39 *
40 * Blank and malformed names are dropped: the first is a row the user has not
41 * finished, the second cannot be sent at all. Names and values are trimmed —
42 * RFC 7230 treats surrounding whitespace as no part of a field value, so a
43 * token pasted with a trailing newline still works.
44 *
45 * Returns `undefined` when nothing is configured, so callers omit the field
46 * rather than sending an empty object.
47 */
48export const mcpHeadersFromRows = (
49 rows: readonly McpHeaderRow[],
50): Record<string, string> | undefined => {
51 const headers: Record<string, string> = {};
52 for (const row of rows) {
53 const name = row.name.trim();
54 if (!name || !HEADER_NAME_PATTERN.test(name)) continue;
55 headers[name] = row.value.trim();
56 }
57 return Object.keys(headers).length > 0 ? headers : undefined;
58};
packages/plugins/mcp/src/sdk/probe-shape.test.ts+90−0
@@ -267,6 +267,96 @@ describe("probeMcpEndpointShape", () => { · 266 unmodified lines
267 ),267 ),
268 );268 );
269 269
270 // Cloudflare Access shape: an edge authenticator in front of the MCP
271 // server answers an unauthenticated request with `403` and an HTML
272 // login page. The MCP server is never reached, so there is no Bearer
273 // challenge and no JSON-RPC body. This must read as "supply
274 // credentials", not as "this URL is not MCP" — the latter told users
275 // the endpoint was unreachable when it was merely protected.
276 it.effect("classifies a 403 HTML edge challenge as auth-required", () =>
277 withServer(
278 () =>
279 HttpServerResponse.text("<html><body>Sign in</body></html>", {
280 status: 403,
281 contentType: "text/html",
282 }),
283 (endpoint) =>
284 Effect.gen(function* () {
285 const result = yield* probeMcpEndpointShape(endpoint);
286 expect(result).toMatchObject({ kind: "not-mcp", category: "auth-required" });
287 }),
288 ),
289 );
290
291 it.effect("classifies 403 with Bearer + JSON-RPC error envelope as MCP+auth", () =>
292 withServer(
293 () =>
294 HttpServerResponse.jsonUnsafe(
295 {
296 jsonrpc: "2.0",
297 id: null,
298 error: { code: -32000, message: "Forbidden" },
299 },
300 { status: 403, headers: { "www-authenticate": "Bearer" } },
301 ),
302 (endpoint) =>
303 Effect.gen(function* () {
304 const result = yield* probeMcpEndpointShape(endpoint);
305 expect(result).toEqual({ kind: "mcp", requiresAuth: true });
306 }),
307 ),
308 );
309
310 it.effect("rejects a 403 whose Bearer challenge carries a GraphQL body", () =>
311 withServer(
312 () =>
313 HttpServerResponse.jsonUnsafe(
314 { errors: [{ message: "Forbidden" }] },
315 { status: 403, headers: { "www-authenticate": "Bearer" } },
316 ),
317 (endpoint) =>
318 Effect.gen(function* () {
319 const result = yield* probeMcpEndpointShape(endpoint);
320 expect(result).toMatchObject({ kind: "not-mcp", category: "auth-required" });
321 }),
322 ),
323 );
324
325 // The other half of the Cloudflare Access story: once the service-token
326 // headers are configured, the same endpoint answers normally. Proves the
327 // probe actually puts `options.headers` on the wire.
328 it.effect("sends configured request headers and clears an edge challenge", () =>
329 Effect.scoped(
330 Effect.gen(function* () {
331 const server = yield* serveProbeEndpoint((request) => {
332 if (request.headers["cf-access-client-id"] !== "client-id") {
333 return HttpServerResponse.text("<html>Sign in</html>", {
334 status: 403,
335 contentType: "text/html",
336 });
337 }
338 return HttpServerResponse.jsonUnsafe({
339 jsonrpc: "2.0",
340 id: 1,
341 result: {
342 protocolVersion: "2025-06-18",
343 capabilities: {},
344 serverInfo: { name: "t", version: "0" },
345 },
346 });
347 });
348
349 const blocked = yield* probeMcpEndpointShape(server.endpoint);
350 expect(blocked).toMatchObject({ kind: "not-mcp", category: "auth-required" });
351
352 const allowed = yield* probeMcpEndpointShape(server.endpoint, {
353 headers: { "CF-Access-Client-Id": "client-id" },
354 });
355 expect(allowed).toEqual({ kind: "mcp", requiresAuth: false });
356 }),
357 ),
358 );
359
270 it.effect("falls back to GET for OAuth-protected SSE endpoints", () =>360 it.effect("falls back to GET for OAuth-protected SSE endpoints", () =>
271 withServer(361 withServer(
272 (request) => {362 (request) => {
packages/plugins/mcp/src/sdk/probe-shape.ts+28−17
@@ -20,10 +20,12 @@ · 19 unmodified lines
20// transport, body is an SSE stream we don't consume.20// transport, body is an SSE stream we don't consume.
21// - 2xx with `Content-Type: application/json` whose body parses as a21// - 2xx with `Content-Type: application/json` whose body parses as a
22// JSON-RPC 2.0 envelope (`{jsonrpc:"2.0", result|error|method,...}`).22// JSON-RPC 2.0 envelope (`{jsonrpc:"2.0", result|error|method,...}`).
23// - 401 with `WWW-Authenticate: Bearer` AND a JSON-RPC error envelope23// - 401 (or 403) with `WWW-Authenticate: Bearer` AND a JSON-RPC error
24// in the body. The body shape is what separates a real MCP server24// envelope in the body. The body shape is what separates a real MCP
25// from an unrelated OAuth-protected API: GraphQL/REST/HTML 401s25// server from an unrelated OAuth-protected API: GraphQL/REST/HTML
26// don't shape themselves as JSON-RPC.26// 401s don't shape themselves as JSON-RPC. 403 travels the same path
27// because an edge authenticator (Cloudflare Access) answers with 403
28// and the endpoint is not therefore unreachable.
27//29//
28// When POST returns 404/405/406/415 we retry with GET + `Accept:30// When POST returns 404/405/406/415 we retry with GET + `Accept:
29// text/event-stream` to support legacy SSE-only servers; that path31// text/event-stream` to support legacy SSE-only servers; that path
@@ -82,6 +84,16 @@ const readHeader = (headers: Readonly<Record<string, string>>, name: string): st · 52 unmodified lines
82 return null;84 return null;
83};85};
84 86
87/** Statuses that mean "you are not authenticated for this resource".
88 *
89 * 401 is what the MCP authorization spec mandates. 403 is what an edge
90 * authenticator in front of the server returns instead — Cloudflare
91 * Access, for example, answers an unauthenticated request with 403 and
92 * never reaches the MCP server at all. Both say the same thing to the
93 * user: supply credentials and retry. Treating 403 as a wrong shape
94 * told them the URL was wrong, which it was not. */
95const isAuthStatus = (status: number): boolean => status === 401 || status === 403;
96
85class ProbeTransportError extends Data.TaggedError("ProbeTransportError")<{97class ProbeTransportError extends Data.TaggedError("ProbeTransportError")<{
86 readonly reason: string;98 readonly reason: string;
87 readonly cause: unknown;99 readonly cause: unknown;
@@ -213,9 +225,9 @@ const reasonFromBoundaryCause = (cause: unknown): string => { · 125 unmodified lines
213 225
214/** Why the probe rejected an endpoint as not-MCP.226/** Why the probe rejected an endpoint as not-MCP.
215 *227 *
216 * - `auth-required` — server returned 401. We don't know for sure it's228 * - `auth-required` — server returned 401 or 403. We don't know for
217 * an MCP server (no spec-compliant Bearer challenge or the body229 * sure it's an MCP server (no spec-compliant Bearer challenge or the
218 * isn't JSON-RPC), but the right next step for the user is the same230 * body isn't JSON-RPC), but the right next step for the user is the same
219 * either way: provide credentials and retry. This is what231 * either way: provide credentials and retry. This is what
220 * misclassifies real MCP servers like cubic.dev (no232 * misclassifies real MCP servers like cubic.dev (no
221 * resource_metadata) or ref.tools (no WWW-Authenticate at all)233 * resource_metadata) or ref.tools (no WWW-Authenticate at all)
@@ -227,7 +239,7 @@ export type McpProbeRejectCategory = "auth-required" | "wrong-shape"; · 5 unmodified lines
227 239
228export type McpShapeProbeResult =240export type McpShapeProbeResult =
229 /** Server answered initialize successfully — either a 2xx with a241 /** Server answered initialize successfully — either a 2xx with a
230 * JSON-RPC payload, or a 401 + WWW-Authenticate: Bearer (RFC 6750242 * JSON-RPC payload, or a 401/403 + WWW-Authenticate: Bearer (RFC 6750
231 * challenge) that the MCP auth spec requires. */243 * challenge) that the MCP auth spec requires. */
232 | { readonly kind: "mcp"; readonly requiresAuth: boolean }244 | { readonly kind: "mcp"; readonly requiresAuth: boolean }
233 /** Endpoint is reachable but the response does not look like MCP. */245 /** Endpoint is reachable but the response does not look like MCP. */
@@ -253,7 +265,7 @@ export interface ProbeOptions { · 19 unmodified lines
253 *265 *
254 * Returns `{kind: "mcp"}` only when the endpoint either:266 * Returns `{kind: "mcp"}` only when the endpoint either:
255 * - answers with 2xx (unauth-OK MCP server), or267 * - answers with 2xx (unauth-OK MCP server), or
256 * - responds 401 with a `Bearer` WWW-Authenticate challenge.268 * - responds 401 or 403 with a `Bearer` WWW-Authenticate challenge.
257 *269 *
258 * Anything else (400, 404, 200-with-HTML, 200-with-GraphQL-errors, ...)270 * Anything else (400, 404, 200-with-HTML, 200-with-GraphQL-errors, ...)
259 * is classified `not-mcp`. Transport errors surface as `unreachable`.271 * is classified `not-mcp`. Transport errors surface as `unreachable`.
@@ -287,20 +299,20 @@ export const probeMcpEndpointShape = ( · 27 unmodified lines
287 const contentType = readHeader(response.headers, "content-type") ?? "";299 const contentType = readHeader(response.headers, "content-type") ?? "";
288 const isSse = /^\s*text\/event-stream\b/i.test(contentType);300 const isSse = /^\s*text\/event-stream\b/i.test(contentType);
289 301
290 if (response.status === 401) {302 if (isAuthStatus(response.status)) {
291 const wwwAuth = readHeader(response.headers, "www-authenticate");303 const wwwAuth = readHeader(response.headers, "www-authenticate");
292 if (!wwwAuth || !/^\s*bearer\b/i.test(wwwAuth)) {304 if (!wwwAuth || !/^\s*bearer\b/i.test(wwwAuth)) {
293 // Spec-non-compliant 401 (no `Bearer` challenge). Before305 // Spec-non-compliant challenge (no `Bearer`). Before giving
294 // giving up, check whether the server still publishes306 // up, check whether the server still publishes RFC 9728
295 // RFC 9728 protected-resource metadata for this path —307 // protected-resource metadata for this path — some real MCP
296 // some real MCP servers (Datadog) do exactly this.308 // servers (Datadog) do exactly this.
297 if (yield* probeProtectedResourceMetadata(client, url, timeoutMs)) {309 if (yield* probeProtectedResourceMetadata(client, url, timeoutMs)) {
298 return { kind: "mcp", requiresAuth: true } as const;310 return { kind: "mcp", requiresAuth: true } as const;
299 }311 }
300 return {312 return {
301 kind: "not-mcp",313 kind: "not-mcp",
302 category: "auth-required",314 category: "auth-required",
303 reason: "401 without Bearer WWW-Authenticate — not an MCP auth challenge",315 reason: `${response.status} without Bearer WWW-Authenticate — not an MCP auth challenge`,
304 } as const;316 } as const;
305 }317 }
306 // Spec-compliant MCP signal: the auth spec mandates a318 // Spec-compliant MCP signal: the auth spec mandates a
@@ -347,8 +359,7 @@ export const probeMcpEndpointShape = ( · 40 unmodified lines
347 return {359 return {
348 kind: "not-mcp",360 kind: "not-mcp",
349 category: "auth-required",361 category: "auth-required",
350 reason:362 reason: `${response.status} + Bearer without resource_metadata, JSON-RPC body, or OAuth error body`,
351 "401 + Bearer without resource_metadata, JSON-RPC body, or OAuth error body",
352 } as const;363 } as const;
353 }364 }
354 return { kind: "mcp", requiresAuth: true } as const;365 return { kind: "mcp", requiresAuth: true } as const;