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
13 changes: 7 additions & 6 deletions apps/cloud/src/mcp/session-durable-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -210,12 +211,12 @@ export class McpSessionDO extends McpSessionDOBase<CloudSessionDbHandle> {
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"));
Expand Down
67 changes: 13 additions & 54 deletions apps/local/src/mcp.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand All @@ -34,35 +40,6 @@ const formatBoundaryError = (error: unknown): unknown => {
return error;
};

type McpElicitationMode = "browser" | "model" | "native";

const MCP_ELICITATION_MODES = new Set<McpElicitationMode>(["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<void>) | undefined): Promise<void> =>
close
? Effect.runPromise(
Expand All @@ -88,32 +65,14 @@ const readResumeResponse = (request: Request): Promise<ResumeResponse | null> =>
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<ResumeResponse["action"], string>;
const statusByAction = {
accept: "approved",
decline: "denied",
cancel: "canceled",
} satisfies Record<ResumeResponse["action"], string>;

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<string, WebStandardStreamableHTTPServerTransport>();
Expand Down
153 changes: 153 additions & 0 deletions e2e/cloud/browser-approval.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> =>
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),
),
);
}),
);
67 changes: 63 additions & 4 deletions e2e/src/surfaces/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReadonlyArray<string>>;
readonly call: (name: string, args?: Record<string, unknown>) => Effect.Effect<McpCallResult>;
Expand All @@ -103,12 +139,21 @@ export interface McpSession {
text: string,
content?: Record<string, unknown>,
) => Effect.Effect<McpCallResult>;
/**
* 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<McpCallResult>;
}

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
Expand Down Expand Up @@ -207,9 +252,20 @@ const mintBearerFlow = async (target: Target, email: string): Promise<string> =>
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;
Comment on lines +266 to +268

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The sessionUrl is built by string-concatenating onto target.mcpUrl rather than using URL APIs. If target.mcpUrl ever carries an existing query string (e.g. from a configured override like http://localhost:3000/mcp?trace=1), the result would be …/mcp?trace=1?elicitation_mode=browser — a URL that no parser will interpret as two separate parameters. Using URL + searchParams avoids this silently.

Suggested change
const sessionUrl = options?.elicitationMode
? `${target.mcpUrl}?elicitation_mode=${options.elicitationMode}`
: target.mcpUrl;
const sessionUrl = (() => {
if (!options?.elicitationMode) return target.mcpUrl;
const u = new URL(target.mcpUrl);
u.searchParams.set("elicitation_mode", options.elicitationMode);
return u.toString();
})();

let runtimePromise: Promise<Runtime> | undefined;
let connected = false;

Expand All @@ -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({
Expand Down Expand Up @@ -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 }),
};
},
});
Loading
Loading