From 8d36fa2b2fa15d755193c1cef65b5859de2e22ab Mon Sep 17 00:00:00 2001 From: Rhys Sullivan Date: Fri, 12 Jun 2026 15:10:29 -0700 Subject: [PATCH 1/3] host-mcp: extract shared browser-approval primitives The browser-approval wire shape (elicitation-mode query parsing, the /resume approval URL, the resume-payload schema, and the decision acknowledgement) was copy-pasted across the in-process handler (apps/local) and the Durable Object base (@executor-js/cloudflare). Hoist it into @executor-js/host-mcp/browser-approval and refactor the existing copies onto it. Each host keeps only its transport envelope (HTTP JSON vs DO RPC result), which is the part that legitimately differs. Behaviour-preserving; no wire changes. --- apps/cloud/src/mcp/session-durable-object.ts | 13 +- apps/local/src/mcp.ts | 67 ++-------- .../hosts/cloudflare/src/mcp/do-headers.ts | 28 ++--- .../src/mcp/session-durable-object.ts | 27 ++-- packages/hosts/mcp/package.json | 4 + packages/hosts/mcp/src/browser-approval.ts | 116 ++++++++++++++++++ 6 files changed, 155 insertions(+), 100 deletions(-) create mode 100644 packages/hosts/mcp/src/browser-approval.ts diff --git a/apps/cloud/src/mcp/session-durable-object.ts b/apps/cloud/src/mcp/session-durable-object.ts index d26bcf58a..46b339d94 100644 --- a/apps/cloud/src/mcp/session-durable-object.ts +++ b/apps/cloud/src/mcp/session-durable-object.ts @@ -22,6 +22,7 @@ import { drizzle } from "drizzle-orm/postgres-js"; import postgres, { type Sql } from "postgres"; import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; +import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; import { McpSessionDOBase, type BuiltMcpServer, @@ -210,12 +211,12 @@ export class McpSessionDO extends McpSessionDOBase { sessionElicitationMode === "browser" ? { mode: "browser" as const, - approvalUrl: (executionId) => { - const origin = env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh"; - const url = new URL(`/resume/${encodeURIComponent(executionId)}`, origin); - url.searchParams.set("mcp_session_id", self.sessionId); - return url.toString(); - }, + approvalUrl: (executionId) => + buildResumeApprovalUrl({ + origin: env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh", + executionId, + sessionId: self.sessionId, + }), } : { mode: sessionElicitationMode }, }).pipe(Effect.withSpan("McpSessionDO.createExecutorMcpServer")); diff --git a/apps/local/src/mcp.ts b/apps/local/src/mcp.ts index 21d2afe1d..4b9b80977 100644 --- a/apps/local/src/mcp.ts +++ b/apps/local/src/mcp.ts @@ -1,4 +1,4 @@ -import { Deferred, Effect, Option, Schema } from "effect"; +import { Deferred, Effect } from "effect"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; @@ -8,6 +8,12 @@ import { createExecutorMcpServer, type ExecutorMcpServerConfig, } from "@executor-js/host-mcp/tool-server"; +import { + approvalUrlForRequest, + decodeResumeResponse, + formatResumeAcknowledgement, + readElicitationMode, +} from "@executor-js/host-mcp/browser-approval"; import type { ResumeResponse } from "@executor-js/execution"; import { startIntegrationsRefresh } from "./integrations"; @@ -34,35 +40,6 @@ const formatBoundaryError = (error: unknown): unknown => { return error; }; -type McpElicitationMode = "browser" | "model" | "native"; - -const MCP_ELICITATION_MODES = new Set(["browser", "model", "native"]); -const ResumeResponsePayload = Schema.Struct({ - action: Schema.Literals(["accept", "decline", "cancel"]), - content: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), -}); -const decodeResumeResponsePayload = Schema.decodeUnknownOption(ResumeResponsePayload); - -const readElicitationMode = (request: Request): McpElicitationMode => { - const url = new URL(request.url); - const mode = url.searchParams.get("elicitation_mode"); - if (mode && MCP_ELICITATION_MODES.has(mode as McpElicitationMode)) { - return mode as McpElicitationMode; - } - - return "model"; -}; - -const approvalUrlForRequest = ( - request: Request, - executionId: string, - sessionId: string | null, -): string => { - const url = new URL(`/resume/${encodeURIComponent(executionId)}`, request.url); - if (sessionId) url.searchParams.set("mcp_session_id", sessionId); - return url.toString(); -}; - const ignoreClose = (close: (() => Promise) | undefined): Promise => close ? Effect.runPromise( @@ -88,32 +65,14 @@ const readResumeResponse = (request: Request): Promise => Effect.tryPromise({ try: () => request.json(), catch: () => null, - }).pipe( - Effect.map((raw) => - raw === null ? null : Option.getOrNull(decodeResumeResponsePayload(raw)), - ), - ), + }).pipe(Effect.map((raw) => (raw === null ? null : decodeResumeResponse(raw)))), ); -const resumeApprovalResult = (executionId: string, response: ResumeResponse) => { - const textByAction = { - accept: "I've approved it", - decline: "I've denied it", - cancel: "I've canceled it", - } satisfies Record; - const statusByAction = { - accept: "approved", - decline: "denied", - cancel: "canceled", - } satisfies Record; - - return { - status: "completed", - text: textByAction[response.action], - structured: { status: statusByAction[response.action], executionId }, - isError: false, - }; -}; +const resumeApprovalResult = (executionId: string, response: ResumeResponse) => ({ + status: "completed", + ...formatResumeAcknowledgement(executionId, response), + isError: false, +}); export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpRequestHandler => { const transports = new Map(); diff --git a/packages/hosts/cloudflare/src/mcp/do-headers.ts b/packages/hosts/cloudflare/src/mcp/do-headers.ts index 18499dad1..822893f31 100644 --- a/packages/hosts/cloudflare/src/mcp/do-headers.ts +++ b/packages/hosts/cloudflare/src/mcp/do-headers.ts @@ -17,8 +17,6 @@ import { Effect } from "effect"; export const INTERNAL_ACCOUNT_ID_HEADER = "x-executor-mcp-account-id"; export const INTERNAL_ORGANIZATION_ID_HEADER = "x-executor-mcp-organization-id"; -const TRUE_QUERY_VALUES = new Set(["1", "true", "yes", "on"]); - /** The verified identity used to stamp the DO's internal owner headers. */ export type VerifiedTokenHeaders = { readonly accountId: string; @@ -90,21 +88,11 @@ export const withMcpResponseHeaders = (response: Response): Response => { }); }; -export type McpElicitationMode = "browser" | "model" | "native"; - -const MCP_ELICITATION_MODES = new Set(["browser", "model", "native"]); - -export const readElicitationMode = (request: Request): McpElicitationMode => { - const url = new URL(request.url); - const mode = url.searchParams.get("elicitation_mode"); - if (mode && MCP_ELICITATION_MODES.has(mode as McpElicitationMode)) { - return mode as McpElicitationMode; - } - - const legacyModelResume = url.searchParams.get("allow_model_resume"); - if (legacyModelResume !== null && TRUE_QUERY_VALUES.has(legacyModelResume.toLowerCase())) { - return "model"; - } - - return "model"; -}; +// The elicitation-mode query contract (`?elicitation_mode=` plus the legacy +// `?allow_model_resume` alias) is shared with every host that serves the +// browser-approval flow. Re-exported here so the worker dispatcher's existing +// import site (`./do-headers`) is unchanged. +export { + readElicitationMode, + type McpElicitationMode, +} from "@executor-js/host-mcp/browser-approval"; diff --git a/packages/hosts/cloudflare/src/mcp/session-durable-object.ts b/packages/hosts/cloudflare/src/mcp/session-durable-object.ts index 250911f31..123fb6721 100644 --- a/packages/hosts/cloudflare/src/mcp/session-durable-object.ts +++ b/packages/hosts/cloudflare/src/mcp/session-durable-object.ts @@ -17,6 +17,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { TransportState } from "agents/mcp"; import { jsonRpcErrorBody } from "@executor-js/host-mcp"; +import { formatResumeAcknowledgement } from "@executor-js/host-mcp/browser-approval"; import { RequestWebOrigin } from "@executor-js/api/server"; import { formatPausedExecution, @@ -72,26 +73,12 @@ export type McpSessionResumeApprovalResult = const resumeApprovalResult = ( executionId: string, response: ResumeResponse, -): Extract => { - const textByAction = { - accept: "I've approved it", - decline: "I've denied it", - cancel: "I've canceled it", - } satisfies Record; - const statusByAction = { - accept: "approved", - decline: "denied", - cancel: "canceled", - } satisfies Record; - - return { - status: "ok", - executionStatus: "completed", - text: textByAction[response.action], - structured: { status: statusByAction[response.action], executionId }, - isError: false, - }; -}; +): Extract => ({ + status: "ok", + executionStatus: "completed", + ...formatResumeAcknowledgement(executionId, response), + isError: false, +}); const HEARTBEAT_MS = 30 * 1000; const SESSION_TIMEOUT_MS = 5 * 60 * 1000; diff --git a/packages/hosts/mcp/package.json b/packages/hosts/mcp/package.json index 4ea803fe9..ac5655b31 100644 --- a/packages/hosts/mcp/package.json +++ b/packages/hosts/mcp/package.json @@ -15,6 +15,10 @@ "./in-memory-session-store": { "types": "./src/in-memory-session-store.ts", "default": "./src/in-memory-session-store.ts" + }, + "./browser-approval": { + "types": "./src/browser-approval.ts", + "default": "./src/browser-approval.ts" } }, "scripts": { diff --git a/packages/hosts/mcp/src/browser-approval.ts b/packages/hosts/mcp/src/browser-approval.ts new file mode 100644 index 000000000..0bea2ea3b --- /dev/null +++ b/packages/hosts/mcp/src/browser-approval.ts @@ -0,0 +1,116 @@ +// --------------------------------------------------------------------------- +// Browser-approval primitives shared by every host that surfaces a paused MCP +// execution to a human for approval in the browser. +// +// When a connection runs in `elicitation_mode=browser`, a gated tool call +// pauses and the host returns an `approvalUrl` pointing at the console's +// `/resume/:executionId` page. The user approves or declines there; the host +// records the decision and the model's `resume` tool call — long-polling the +// host's `BrowserApprovalStore` — consumes it. +// +// Three hosts implement that flow over two transports: the in-process handler +// (apps/local, host-selfhost) and the Durable Object (cloud, host-cloudflare). +// The wire-shape pieces are identical across all of them, so they live here +// once: how the mode is read off the request, how the approval URL is built, +// the resume-payload schema, and the acknowledgement text/structured content. +// Each caller keeps its own transport envelope (HTTP JSON vs DO RPC result) and +// wraps this neutral core — that is the only part that legitimately differs. +// +// This module stays dependency-light (effect + a type from execution + Web +// APIs) so the Cloudflare worker/DO bundles can pull it without dragging in the +// HTTP API assembly. +// --------------------------------------------------------------------------- + +import { Option, Schema } from "effect"; + +import type { ResumeResponse } from "@executor-js/execution"; + +export type McpElicitationMode = "browser" | "model" | "native"; + +const MCP_ELICITATION_MODES = new Set(["browser", "model", "native"]); + +const TRUE_QUERY_VALUES = new Set(["1", "true", "yes", "on"]); + +/** + * Read the elicitation mode off an MCP request's `?elicitation_mode=` query. + * Unknown or absent values fall back to `model` (the default — the agent calls + * `resume` inline). `?allow_model_resume=true` is a legacy alias for `model`. + */ +export const readElicitationMode = (request: Request): McpElicitationMode => { + const url = new URL(request.url); + const mode = url.searchParams.get("elicitation_mode"); + if (mode && MCP_ELICITATION_MODES.has(mode as McpElicitationMode)) { + return mode as McpElicitationMode; + } + + const legacyModelResume = url.searchParams.get("allow_model_resume"); + if (legacyModelResume !== null && TRUE_QUERY_VALUES.has(legacyModelResume.toLowerCase())) { + return "model"; + } + + return "model"; +}; + +/** + * Build the console approval URL for a paused execution: + * `/resume/?mcp_session_id=`. The + * `mcp_session_id` query routes the console's resume page back to the host's + * approval endpoint for that session. + */ +export const buildResumeApprovalUrl = (input: { + readonly origin: string | URL; + readonly executionId: string; + readonly sessionId?: string | null; +}): string => { + const url = new URL(`/resume/${encodeURIComponent(input.executionId)}`, input.origin); + if (input.sessionId) url.searchParams.set("mcp_session_id", input.sessionId); + return url.toString(); +}; + +/** `buildResumeApprovalUrl` anchored at the request's own origin (in-process hosts). */ +export const approvalUrlForRequest = ( + request: Request, + executionId: string, + sessionId: string | null, +): string => buildResumeApprovalUrl({ origin: request.url, executionId, sessionId }); + +/** The resume decision a console posts back: an action plus optional form content. */ +export const ResumeResponsePayload = Schema.Struct({ + action: Schema.Literals(["accept", "decline", "cancel"]), + content: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), +}); + +const decodeResumeResponsePayload = Schema.decodeUnknownOption(ResumeResponsePayload); + +/** Decode an untrusted resume payload, or `null` if it doesn't match the contract. */ +export const decodeResumeResponse = (raw: unknown): ResumeResponse | null => + Option.getOrNull(decodeResumeResponsePayload(raw)); + +const ACKNOWLEDGEMENT_TEXT = { + accept: "I've approved it", + decline: "I've denied it", + cancel: "I've canceled it", +} satisfies Record; + +const ACKNOWLEDGEMENT_STATUS = { + accept: "approved", + decline: "denied", + cancel: "canceled", +} satisfies Record; + +/** + * The transport-neutral acknowledgement a host returns once a browser approval + * decision is recorded: human-facing `text` plus `structured` content the + * console renders. Each host wraps this in its own envelope (HTTP JSON for the + * in-process handler, the DO RPC result for Cloudflare). + */ +export const formatResumeAcknowledgement = ( + executionId: string, + response: ResumeResponse, +): { + readonly text: string; + readonly structured: { readonly status: string; readonly executionId: string }; +} => ({ + text: ACKNOWLEDGEMENT_TEXT[response.action], + structured: { status: ACKNOWLEDGEMENT_STATUS[response.action], executionId }, +}); From e2b6bc5c4bc09b4236db928323815dccef01f856 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan Date: Fri, 12 Jun 2026 15:22:01 -0700 Subject: [PATCH 2/3] e2e: browser-approval scenarios on cloud + browser-mode MCP surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the first end-to-end coverage of a human approving/declining a gated MCP action in the rendered console approval page — the leg unit tests structurally cannot reach. - MCP surface: a session can run in elicitation_mode=browser, exposes awaitResume (the no-action long-poll resume), and parseBrowserApproval pulls the {executionId, approvalUrl} out of a paused result. - Surface fix: give each MCP session a unique mcporter server name. mcporter caches OAuth tokens per server name, so the old constant name let a later session reuse an earlier identity's token (wrong org). A decline scenario was the first identity-sensitive flow to expose it. - cloud/browser-approval.test.ts: approve runs the gated tool to completion; decline blocks it. Moves to scenarios/ once self-host and Cloudflare gain the feature. --- e2e/cloud/browser-approval.test.ts | 153 +++++++++++++++++++++++++++++ e2e/src/surfaces/mcp.ts | 67 ++++++++++++- 2 files changed, 216 insertions(+), 4 deletions(-) create mode 100644 e2e/cloud/browser-approval.test.ts diff --git a/e2e/cloud/browser-approval.test.ts b/e2e/cloud/browser-approval.test.ts new file mode 100644 index 000000000..c4706cd65 --- /dev/null +++ b/e2e/cloud/browser-approval.test.ts @@ -0,0 +1,153 @@ +// Browser approval of a gated MCP action, end to end through the real console. +// +// A `require_approval` policy turns a built-in tool into an action that pauses +// for a human. The MCP session runs in `elicitation_mode=browser`, so the gated +// `execute` does not let the model resume inline — it pauses and hands back an +// `approvalUrl`. A real browser (signed in as the same identity) opens that +// console page and clicks Approve / Decline; meanwhile `resume` long-polls for +// the decision. Approve lets the tool run and return its result; Decline blocks +// it. This is the leg unit tests structurally cannot cover: a human clicking the +// button in the rendered ResumeApprovalPage. +// +// The policy is removed in an `ensuring` finalizer — a leaked require_approval +// gate on a shared built-in tool would pause unrelated scenarios. +// +// Lives under cloud/ for now because cloud is the only host wired for browser +// approval; it moves to scenarios/ (cross-target) as self-host and Cloudflare +// gain the feature. +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; + +import { scenario } from "../src/scenario"; +import { Api, Browser, Mcp, Target } from "../src/services"; +import { type McpBrowserApproval, parseBrowserApproval } from "../src/surfaces/mcp"; +import type { BrowserSurface } from "../src/surfaces/browser"; +import type { Identity } from "../src/target"; + +const coreApi = composePluginApi([] as const); + +// Gating a built-in read tool keeps the scenario hermetic — no external server +// to host a destructive tool. The gate, not the tool, is what's under test: any +// action the engine pauses on flows through the same approval path. +const GATE_TOOL = "executor.coreTools.policies.list"; + +// The gated call returns the policy listing, which includes the policy we just +// created — so the created policy's id appears in the result iff the tool +// actually ran (i.e. the human approved). +const GATED_CODE = ` +const result = await tools.executor.coreTools.policies.list({}); +return JSON.stringify(result); +`; + +/** Open the console approval page as `identity` and click Approve or Decline. */ +const decideInBrowser = ( + browser: BrowserSurface, + identity: Identity, + approval: McpBrowserApproval, + decision: "Approve" | "Decline", +): Effect.Effect => + browser.session(identity, async ({ page, step }) => { + await step( + `Open the approval page and ${decision.toLowerCase()} the paused action`, + async () => { + await page.goto(approval.approvalUrl, { waitUntil: "networkidle" }); + await page.getByRole("button", { name: decision }).click(); + // The page confirms the decision was recorded ("Approve sent" / "Decline sent"). + await page.getByText(`${decision} sent`).waitFor(); + }, + ); + }); + +scenario( + "MCP · a gated action approved in the browser runs to completion", + { timeout: 180_000 }, + Effect.gen(function* () { + const target = yield* Target; + const api = yield* Api; + const browser = yield* Browser; + const mcp = yield* Mcp; + const identity = yield* target.newIdentity(); + const client = yield* api.client(coreApi, identity); + + const policy = yield* client.policies.create({ + payload: { owner: "org", pattern: GATE_TOOL, action: "require_approval" }, + }); + + yield* Effect.gen(function* () { + const session = mcp.session(identity, { elicitationMode: "browser" }); + const tools = yield* session.listTools(); + expect(tools).toContain("execute"); + + const paused = yield* session.call("execute", { code: GATED_CODE }); + const approval = parseBrowserApproval(paused); + expect(approval.approvalUrl, "approval URL targets the resume page").toContain( + `/resume/${approval.executionId}`, + ); + + // `resume` blocks for the human's decision; approve it in the browser + // concurrently, then the resumed call returns the gated tool's result. + const [resumed] = yield* Effect.all( + [ + session.awaitResume(approval.executionId), + decideInBrowser(browser, identity, approval, "Approve"), + ], + { concurrency: "unbounded" }, + ); + + expect(resumed.ok, "the approved execution completed without error").toBe(true); + expect(resumed.text, "the gated tool ran and returned the policy listing").toContain( + policy.id, + ); + }).pipe( + Effect.ensuring( + client.policies + .remove({ params: { policyId: policy.id }, payload: { owner: "org" } }) + .pipe(Effect.ignore), + ), + ); + }), +); + +scenario( + "MCP · a gated action declined in the browser is blocked", + { timeout: 180_000 }, + Effect.gen(function* () { + const target = yield* Target; + const api = yield* Api; + const browser = yield* Browser; + const mcp = yield* Mcp; + const identity = yield* target.newIdentity(); + const client = yield* api.client(coreApi, identity); + + const policy = yield* client.policies.create({ + payload: { owner: "org", pattern: GATE_TOOL, action: "require_approval" }, + }); + + yield* Effect.gen(function* () { + const session = mcp.session(identity, { elicitationMode: "browser" }); + yield* session.listTools(); + + const paused = yield* session.call("execute", { code: GATED_CODE }); + const approval = parseBrowserApproval(paused); + + const [resumed] = yield* Effect.all( + [ + session.awaitResume(approval.executionId), + decideInBrowser(browser, identity, approval, "Decline"), + ], + { concurrency: "unbounded" }, + ); + + // The decision propagated (resume returned rather than hanging) and the + // gated tool never ran — its output (the policy id) is absent. + expect(resumed.text, "the gated tool did not run after a decline").not.toContain(policy.id); + }).pipe( + Effect.ensuring( + client.policies + .remove({ params: { policyId: policy.id }, payload: { owner: "org" } }) + .pipe(Effect.ignore), + ), + ); + }), +); diff --git a/e2e/src/surfaces/mcp.ts b/e2e/src/surfaces/mcp.ts index aa89a828e..de9a0c74c 100644 --- a/e2e/src/surfaces/mcp.ts +++ b/e2e/src/surfaces/mcp.ts @@ -95,6 +95,42 @@ export interface McpCallResult { readonly ok: boolean; } +/** How a connection surfaces a paused (approval-gated) execution. `browser` is + * what the browser-approval scenarios drive: the pause yields an `approvalUrl` + * for a human to open instead of letting the model resume inline. */ +export type McpElicitationMode = "browser" | "model" | "native"; + +/** The paused-execution handle a `browser`-mode call returns: the id to resume + * and the console URL a human opens to approve or decline it. */ +export interface McpBrowserApproval { + readonly executionId: string; + readonly approvalUrl: string; +} + +/** + * Pull the `{ executionId, approvalUrl }` out of a `browser`-mode paused result. + * Throws if the call did not pause for approval (so a missing gate fails loudly + * rather than silently skipping the browser leg). + */ +export const parseBrowserApproval = (result: McpCallResult): McpBrowserApproval => { + const structured = (result.raw as { structuredContent?: unknown })?.structuredContent; + const record = (structured ?? {}) as { + status?: unknown; + executionId?: unknown; + approvalUrl?: unknown; + }; + if ( + record.status !== "user_approval_required" || + typeof record.executionId !== "string" || + typeof record.approvalUrl !== "string" + ) { + throw new Error( + `expected a browser approval-required result, got: ${JSON.stringify(structured)}`, + ); + } + return { executionId: record.executionId, approvalUrl: record.approvalUrl }; +}; + export interface McpSession { readonly listTools: () => Effect.Effect>; readonly call: (name: string, args?: Record) => Effect.Effect; @@ -103,12 +139,21 @@ export interface McpSession { text: string, content?: Record, ) => Effect.Effect; + /** + * Call `resume` with only an executionId — the browser-mode contract, where + * `resume` long-polls until a human records a decision through the console. + * Run this concurrently with the browser leg that approves/declines. + */ + readonly awaitResume: (executionId: string) => Effect.Effect; } export interface McpSurface { /** The target's MCP endpoint — yield this surface to depend on it existing. */ readonly url: string; - readonly session: (identity: Identity) => McpSession; + readonly session: ( + identity: Identity, + options?: { readonly elicitationMode?: McpElicitationMode }, + ) => McpSession; /** * Mint a real MCP bearer headlessly: protected-resource discovery → * authorization-server discovery → dynamic client registration → authorize @@ -207,9 +252,20 @@ const mintBearerFlow = async (target: Target, email: string): Promise => export const makeMcpSurface = (target: Target, runDir?: string): McpSurface => ({ url: target.mcpUrl, mintBearer: (email) => Effect.promise(() => mintBearerFlow(target, email)), - session: (identity) => { + session: (identity, options) => { if (runDir) installTraceparentFetch(target.mcpUrl, runDir); - const serverName = target.name; + // mcporter caches OAuth tokens (and the DCR client) per server NAME, so a + // constant name would let a later session reuse an earlier identity's token + // — landing in the wrong org. A unique name per session keeps each + // identity's OAuth isolated. The traceparent ledger keys off the URL, not + // this name, so it is unaffected. + const serverName = `${target.name}-${randomUUID().slice(0, 8)}`; + // `browser` mode is selected per the ecosystem convention — an + // `?elicitation_mode=` query on the MCP endpoint — so a paused execution + // yields an approvalUrl instead of letting the model resume inline. + const sessionUrl = options?.elicitationMode + ? `${target.mcpUrl}?elicitation_mode=${options.elicitationMode}` + : target.mcpUrl; let runtimePromise: Promise | undefined; let connected = false; @@ -225,7 +281,7 @@ export const makeMcpSurface = (target: Target, runDir?: string): McpSurface => ( writeFileSync( join(dir, "mcporter.json"), JSON.stringify({ - mcpServers: { [serverName]: { url: target.mcpUrl } }, + mcpServers: { [serverName]: { url: sessionUrl } }, }), ); runtimePromise = createRuntime({ @@ -266,6 +322,9 @@ export const makeMcpSurface = (target: Target, runDir?: string): McpSurface => ( content: JSON.stringify(content), }); }), + // No action argument: in browser mode `resume` blocks until the human's + // decision arrives via the console, then returns the resumed result. + awaitResume: (executionId) => call("resume", { executionId }), }; }, }); From 88ae2c1a5fdea51f976c835828cfa5b10c799a3b Mon Sep 17 00:00:00 2001 From: Rhys Sullivan Date: Sat, 13 Jun 2026 22:44:33 -0700 Subject: [PATCH 3/3] e2e(selfhost): drive the forced MCP OAuth consent screen The self-host serving layer forces prompt=consent on every MCP authorize, so the headless e2e target completes the /mcp-consent approval the way the page does (sign in, authorize, POST /api/auth/oauth2/consent) instead of the old direct-code cookieConsentStrategy. --- e2e/targets/selfhost.ts | 61 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/e2e/targets/selfhost.ts b/e2e/targets/selfhost.ts index 2f490fa95..621bd133e 100644 --- a/e2e/targets/selfhost.ts +++ b/e2e/targets/selfhost.ts @@ -1,11 +1,9 @@ // The self-host app as a target: its real dev server (`bunx --bun vite dev`) -// on a throwaway data dir, with Better Auth + the bootstrap admin. MCP OAuth -// is fully headless via the mcporter fork's cookieConsentStrategy. Boot lives -// in setup/selfhost.globalsetup.ts. +// on a throwaway data dir, with Better Auth + the bootstrap admin. MCP OAuth is +// headless via `forcedMcpConsent` below. Boot lives in +// setup/selfhost.globalsetup.ts. import { Effect } from "effect"; -import { cookieConsentStrategy } from "@executor-js/mcporter"; - import { e2ePort } from "../src/ports"; import type { Identity, Target } from "../src/target"; @@ -45,6 +43,56 @@ export const signInSession = async ( return { cookieHeader: pairs.join("; "), cookies }; }; +// Headless MCP OAuth consent. The self-host serving layer forces +// `prompt=consent` on every MCP authorize (src/auth/force-mcp-consent), so an +// authenticated authorize no longer redirects straight to the callback with a +// `code` — it stops on the `/mcp-consent` approval screen with a `consent_code`. +// mcporter's `cookieConsentStrategy` only handles the old direct-code redirect, +// so this completes the screen the way the page does: sign in, drive authorize, +// then POST the same `/api/auth/oauth2/consent` grant the Allow button fires. +const forcedMcpConsent = + (baseUrl: string, credentials: { readonly email: string; readonly password: string }) => + async ({ authorizationUrl }: { authorizationUrl: string }): Promise<{ code: string }> => { + const origin = new URL(baseUrl).origin; + const { cookieHeader } = await signInSession(baseUrl, credentials); + + const authorize = await fetch(authorizationUrl, { + headers: { cookie: cookieHeader }, + redirect: "manual", + }); + const location = authorize.headers.get("location"); + if (!location) { + throw new Error(`forcedMcpConsent: authorize did not redirect (status ${authorize.status})`); + } + // The consent redirect is relative (`/mcp-consent?...`) — resolve it against + // the instance origin. If the server issued a code directly (consent not + // forced), use it; otherwise complete the forced approval below. + const redirect = new URL(location, baseUrl); + const direct = redirect.searchParams.get("code"); + if (direct) return { code: direct }; + const consentCode = redirect.searchParams.get("consent_code"); + if (!consentCode) { + throw new Error(`forcedMcpConsent: no consent_code in authorize redirect: ${location}`); + } + + const decision = await fetch(new URL("/api/auth/oauth2/consent", baseUrl), { + method: "POST", + headers: { "content-type": "application/json", origin, cookie: cookieHeader }, + body: JSON.stringify({ accept: true, consent_code: consentCode }), + }); + if (!decision.ok) { + throw new Error(`forcedMcpConsent: consent grant failed (status ${decision.status})`); + } + const body = (await decision.json()) as { redirectURI?: string }; + const code = body.redirectURI ? new URL(body.redirectURI).searchParams.get("code") : null; + if (!code) { + throw new Error( + `forcedMcpConsent: no code in consent redirect: ${body.redirectURI ?? "(none)"}`, + ); + } + return { code }; + }; + export const selfhostTarget = (): Target => ({ name: "selfhost", baseUrl: SELFHOST_BASE_URL, @@ -68,8 +116,7 @@ export const selfhostTarget = (): Target => ({ }; }), mcpConsent: (identity: Identity) => - cookieConsentStrategy({ - appBaseUrl: SELFHOST_BASE_URL, + forcedMcpConsent(SELFHOST_BASE_URL, { email: identity.credentials?.email ?? SELFHOST_ADMIN.email, password: identity.credentials?.password ?? SELFHOST_ADMIN.password, }),