Skip to content

Commit 435c0f2

Browse files
authored
Support authenticated remote MCP servers with custom request headers (#1821)
* 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". * 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.
1 parent 66fb1a4 commit 435c0f2

10 files changed

Lines changed: 549 additions & 20 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"executor": patch
3+
---
4+
5+
**Fix: add remote MCP servers that sit behind an authenticating proxy**
6+
7+
The add-MCP form now carries an optional request headers editor. The name/value
8+
pairs are sent on the connection check and on every later request, so an
9+
endpoint gated by an edge authenticator — a Cloudflare Access service token,
10+
for example — can be discovered and added.
11+
12+
A `403` from such a gate is also no longer read as an unreachable server. It is
13+
classified the same way a `401` is: the endpoint needs credentials, so the add
14+
flow continues to the auth step instead of stopping on "Couldn't reach this
15+
URL".

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
14+
import { randomBytes } from "node:crypto";
15+
16+
import { expect } from "@effect/vitest";
17+
import { Effect, Ref } from "effect";
18+
import { HttpServerResponse } from "effect/unstable/http";
19+
import { composePluginApi } from "@executor-js/api/server";
20+
import { deriveMcpNamespace } from "@executor-js/plugin-mcp";
21+
import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
22+
import { IntegrationSlug } from "@executor-js/sdk/shared";
23+
import { serveTestHttpApp } from "@executor-js/sdk/testing";
24+
25+
import { scenario } from "../src/scenario";
26+
import { Api, Browser, Target } from "../src/services";
27+
import { visit } from "../src/surfaces/browser";
28+
29+
const api = composePluginApi([mcpHttpPlugin()] as const);
30+
31+
const CLIENT_ID_HEADER = "CF-Access-Client-Id";
32+
const CLIENT_SECRET_HEADER = "CF-Access-Client-Secret";
33+
const CLIENT_ID = "e2e-service-token-id";
34+
const CLIENT_SECRET = "e2e-service-token-secret";
35+
36+
scenario(
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/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
"@modelcontextprotocol/client": "2.0.0",
7070
"@modelcontextprotocol/core": "2.0.0",
7171
"@modelcontextprotocol/sdk": "^1.29.0",
72+
"lucide-react": "^1.7.0",
7273
"zod": "4.3.6"
7374
},
7475
"devDependencies": {

packages/plugins/mcp/src/react/AddMcpIntegration.tsx

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
3838
import type { McpAuthMethodInput } from "../sdk/types";
3939
import { probeMcpEndpoint, addMcpServer } from "./atoms";
4040
import { McpRemoteIntegrationFields } from "./McpRemoteIntegrationFields";
41+
import { McpRequestHeadersEditor } from "./McpRequestHeadersEditor";
42+
import { mcpHeadersFromRows, type McpHeaderRow } from "./request-headers";
4143
import { mcpAuthMethodInputFromEditorValue, mcpWireAuthInput } from "./auth-method-config";
4244
import { isProbableMcpEndpoint } from "./probe-url";
4345
import { cloudflareNeedsCodemodeOptOut } from "../sdk/cloudflare-codemode";
@@ -209,6 +211,12 @@ export default function AddMcpIntegration(props: {
209211
remoteUrl ? { step: "url" as const, url: remoteUrl } : init,
210212
);
211213

214+
// Static request headers for the endpoint (e.g. a Cloudflare Access service
215+
// token). They gate the probe as much as the live traffic, so the same
216+
// values feed both.
217+
const [headerRows, setHeaderRows] = useState<readonly McpHeaderRow[]>([]);
218+
const headers = useMemo(() => mcpHeadersFromRows(headerRows), [headerRows]);
219+
212220
const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promiseExit" });
213221
const doAddServer = useAtomSet(addMcpServer, { mode: "promiseExit" });
214222

@@ -285,7 +293,7 @@ export default function AddMcpIntegration(props: {
285293
const run = (probeRunRef.current += 1);
286294
dispatch({ type: "probe-start" });
287295
const exit = await doProbe({
288-
payload: { endpoint: state.url.trim() },
296+
payload: { endpoint: state.url.trim(), ...(headers ? { headers } : {}) },
289297
});
290298
if (run !== probeRunRef.current) return;
291299
if (Exit.isFailure(exit)) {
@@ -296,7 +304,7 @@ export default function AddMcpIntegration(props: {
296304
return;
297305
}
298306
dispatch({ type: "probe-ok", probe: exit.value });
299-
}, [state.url, doProbe]);
307+
}, [state.url, headers, doProbe]);
300308

301309
// Keep the latest handleProbe in a ref so the debounced effect can call it
302310
// without depending on its identity (which changes every render).
@@ -333,6 +341,7 @@ export default function AddMcpIntegration(props: {
333341
: {}),
334342
endpoint: state.url.trim(),
335343
...(slug ? { slug } : {}),
344+
...(headers ? { headers } : {}),
336345
authenticationTemplate,
337346
},
338347
reactivityKeys: integrationWriteKeys,
@@ -346,7 +355,7 @@ export default function AddMcpIntegration(props: {
346355
}
347356
return exit.value.slug;
348357
},
349-
[doAddServer, probe, remoteIdentity, resolvedDescription, state.url],
358+
[doAddServer, headers, probe, remoteIdentity, resolvedDescription, state.url],
350359
);
351360

352361
const handleAddRemote = useCallback(async () => {
@@ -461,6 +470,16 @@ export default function AddMcpIntegration(props: {
461470
</Info>
462471
)}
463472

473+
{/* Static request headers. Shown in every remote state, because an
474+
endpoint behind an edge authenticator (Cloudflare Access) fails
475+
the very first probe until its service-token headers are set. */}
476+
<McpRequestHeadersEditor
477+
rows={headerRows}
478+
onChange={setHeaderRows}
479+
onTest={handleProbe}
480+
testing={isProbing}
481+
/>
482+
464483
{/* Authentication — declares the auth methods to register through the
465484
shared list editor. The credentials themselves (API key value /
466485
OAuth sign-in) are added from the integration's detail hub after
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { PlusIcon, XIcon } from "lucide-react";
2+
3+
import { Button } from "@executor-js/react/components/button";
4+
import {
5+
CardStack,
6+
CardStackContent,
7+
CardStackEntryField,
8+
} from "@executor-js/react/components/card-stack";
9+
import { FieldError } from "@executor-js/react/components/field";
10+
import { Input } from "@executor-js/react/components/input";
11+
12+
import { 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+
24+
export 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+
}

0 commit comments

Comments
 (0)