Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/mcp-remote-request-headers.md
Original file line number Diff line number Diff line change
@@ -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".
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

133 changes: 133 additions & 0 deletions e2e/selfhost/mcp-request-headers-add.test.ts
Original file line number Diff line number Diff line change
@@ -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<readonly Readonly<Record<string, string>>[]>([]);
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("<html><body>Sign in</body></html>", {
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)));
}),
),
);
1 change: 1 addition & 0 deletions packages/plugins/mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
25 changes: 22 additions & 3 deletions packages/plugins/mcp/src/react/AddMcpIntegration.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<readonly McpHeaderRow[]>([]);
const headers = useMemo(() => mcpHeadersFromRows(headerRows), [headerRows]);

const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promiseExit" });
const doAddServer = useAtomSet(addMcpServer, { mode: "promiseExit" });

Expand Down Expand Up @@ -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({
Expand All @@ -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).
Expand Down Expand Up @@ -318,6 +326,7 @@ export default function AddMcpIntegration(props: {
: {}),
endpoint: state.url.trim(),
...(slug ? { slug } : {}),
...(headers ? { headers } : {}),
authenticationTemplate,
},
reactivityKeys: integrationWriteKeys,
Expand All @@ -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 () => {
Expand Down Expand Up @@ -446,6 +455,16 @@ export default function AddMcpIntegration(props: {
</Info>
)}

{/* 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. */}
<McpRequestHeadersEditor
rows={headerRows}
onChange={setHeaderRows}
onTest={handleProbe}
testing={isProbing}
/>

{/* 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
Expand Down
118 changes: 118 additions & 0 deletions packages/plugins/mcp/src/react/McpRequestHeadersEditor.tsx
Original file line number Diff line number Diff line change
@@ -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<McpHeaderRow>): 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 (
<CardStack>
<CardStackContent className="border-t-0">
<CardStackEntryField
label="Request headers"
description="- Optional. Sent on every request, including the connection check."
>
{rows.length > 0 && (
<div className="flex flex-col gap-2">
{rows.map((row, index) => (
<div key={index} className="flex items-center gap-2">
<Input
aria-label="Header name"
value={row.name}
onChange={(e) => 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}
/>
<Input
aria-label="Header value"
value={row.value}
onChange={(e) => set(index, { value: (e.target as HTMLInputElement).value })}
placeholder="Value"
className="h-8 min-w-0 flex-1 font-mono text-xs"
/>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Remove header"
className="shrink-0 text-muted-foreground hover:text-foreground"
onClick={() => remove(index)}
>
<XIcon />
</Button>
</div>
))}
</div>
)}

{hasInvalidName && (
<FieldError>A header name cannot contain spaces or a colon.</FieldError>
)}

<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="w-fit border-dashed"
onClick={() => onChange([...rows, emptyHeaderRow()])}
>
<PlusIcon />
Add header
</Button>
{props.onTest && rows.length > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={props.onTest}
loading={props.testing}
>
Test connection
</Button>
)}
</div>

<p className="text-[11px] text-muted-foreground">
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.
</p>
</CardStackEntryField>
</CardStackContent>
</CardStack>
);
}
Loading
Loading