History

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.

Rhys Sullivan committed 5 months ago

Commit 8d36fa21 parent 013fa386 files · +155 −100Browse files

Unified · Split

6 files
  1. apps/cloud/src/mcp/session-durable-object.ts +7 −6
  2. apps/local/src/mcp.ts +13 −54
  3. packages/hosts/cloudflare/src/mcp/do-headers.ts +8 −20
  4. packages/hosts/cloudflare/src/mcp/session-durable-object.ts +7 −20
  5. packages/hosts/mcp/package.json +4 −0
  6. packages/hosts/mcp/src/browser-approval.ts +116 −0
apps/cloud/src/mcp/session-durable-object.ts+7−6
@@ -22,6 +22,7 @@ import { drizzle } from "drizzle-orm/postgres-js"; · 21 unmodified lines
2222 import postgres, { type Sql } from "postgres";
2323
2424 import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server";
25+import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval";
2526 import {
2627 McpSessionDOBase,
2728 type BuiltMcpServer,
@@ -210,12 +211,12 @@ export class McpSessionDO extends McpSessionDOBase<CloudSessionDbHandle> { · 182 unmodified lines
210211 sessionElicitationMode === "browser"
211212 ? {
212213 mode: "browser" as const,
213− approvalUrl: (executionId) => {
214− const origin = env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh";
215− const url = new URL(`/resume/${encodeURIComponent(executionId)}`, origin);
216− url.searchParams.set("mcp_session_id", self.sessionId);
217− return url.toString();
218− },
214+ approvalUrl: (executionId) =>
215+ buildResumeApprovalUrl({
216+ origin: env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh",
217+ executionId,
218+ sessionId: self.sessionId,
219+ }),
219220 }
220221 : { mode: sessionElicitationMode },
221222 }).pipe(Effect.withSpan("McpSessionDO.createExecutorMcpServer"));
apps/local/src/mcp.ts+13−54
@@ -1,4 +1,4 @@
1−import { Deferred, Effect, Option, Schema } from "effect";
1+import { Deferred, Effect } from "effect";
22 import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
33 import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
44 import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
@@ -8,6 +8,12 @@ import { · 3 unmodified lines
88 createExecutorMcpServer,
99 type ExecutorMcpServerConfig,
1010 } from "@executor-js/host-mcp/tool-server";
11+import {
12+ approvalUrlForRequest,
13+ decodeResumeResponse,
14+ formatResumeAcknowledgement,
15+ readElicitationMode,
16+} from "@executor-js/host-mcp/browser-approval";
1117 import type { ResumeResponse } from "@executor-js/execution";
1218
1319 import { startIntegrationsRefresh } from "./integrations";
@@ -34,35 +40,6 @@ const formatBoundaryError = (error: unknown): unknown => { · 20 unmodified lines
3440 return error;
3541 };
3642
37−type McpElicitationMode = "browser" | "model" | "native";
38−
39−const MCP_ELICITATION_MODES = new Set<McpElicitationMode>(["browser", "model", "native"]);
40−const ResumeResponsePayload = Schema.Struct({
41− action: Schema.Literals(["accept", "decline", "cancel"]),
42− content: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
43−});
44−const decodeResumeResponsePayload = Schema.decodeUnknownOption(ResumeResponsePayload);
45−
46−const readElicitationMode = (request: Request): McpElicitationMode => {
47− const url = new URL(request.url);
48− const mode = url.searchParams.get("elicitation_mode");
49− if (mode && MCP_ELICITATION_MODES.has(mode as McpElicitationMode)) {
50− return mode as McpElicitationMode;
51− }
52−
53− return "model";
54−};
55−
56−const approvalUrlForRequest = (
57− request: Request,
58− executionId: string,
59− sessionId: string | null,
60−): string => {
61− const url = new URL(`/resume/${encodeURIComponent(executionId)}`, request.url);
62− if (sessionId) url.searchParams.set("mcp_session_id", sessionId);
63− return url.toString();
64−};
65−
6643 const ignoreClose = (close: (() => Promise<void>) | undefined): Promise<void> =>
6744 close
6845 ? Effect.runPromise(
@@ -88,32 +65,14 @@ const readResumeResponse = (request: Request): Promise<ResumeResponse | null> => · 19 unmodified lines
8865 Effect.tryPromise({
8966 try: () => request.json(),
9067 catch: () => null,
91− }).pipe(
92− Effect.map((raw) =>
93− raw === null ? null : Option.getOrNull(decodeResumeResponsePayload(raw)),
94− ),
95− ),
68+ }).pipe(Effect.map((raw) => (raw === null ? null : decodeResumeResponse(raw)))),
9669 );
9770
98−const resumeApprovalResult = (executionId: string, response: ResumeResponse) => {
99− const textByAction = {
100− accept: "I've approved it",
101− decline: "I've denied it",
102− cancel: "I've canceled it",
103− } satisfies Record<ResumeResponse["action"], string>;
104− const statusByAction = {
105− accept: "approved",
106− decline: "denied",
107− cancel: "canceled",
108− } satisfies Record<ResumeResponse["action"], string>;
109−
110− return {
111− status: "completed",
112− text: textByAction[response.action],
113− structured: { status: statusByAction[response.action], executionId },
114− isError: false,
115− };
116−};
71+const resumeApprovalResult = (executionId: string, response: ResumeResponse) => ({
72+ status: "completed",
73+ ...formatResumeAcknowledgement(executionId, response),
74+ isError: false,
75+});
11776
11877 export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpRequestHandler => {
11978 const transports = new Map<string, WebStandardStreamableHTTPServerTransport>();
packages/hosts/cloudflare/src/mcp/do-headers.ts+8−20
@@ -17,8 +17,6 @@ import { Effect } from "effect"; · 16 unmodified lines
1717 export const INTERNAL_ACCOUNT_ID_HEADER = "x-executor-mcp-account-id";
1818 export const INTERNAL_ORGANIZATION_ID_HEADER = "x-executor-mcp-organization-id";
1919
20−const TRUE_QUERY_VALUES = new Set(["1", "true", "yes", "on"]);
21−
2220 /** The verified identity used to stamp the DO's internal owner headers. */
2321 export type VerifiedTokenHeaders = {
2422 readonly accountId: string;
@@ -90,21 +88,11 @@ export const withMcpResponseHeaders = (response: Response): Response => { · 65 unmodified lines
9088 });
9189 };
9290
93−export type McpElicitationMode = "browser" | "model" | "native";
94−
95−const MCP_ELICITATION_MODES = new Set<McpElicitationMode>(["browser", "model", "native"]);
96−
97−export const readElicitationMode = (request: Request): McpElicitationMode => {
98− const url = new URL(request.url);
99− const mode = url.searchParams.get("elicitation_mode");
100− if (mode && MCP_ELICITATION_MODES.has(mode as McpElicitationMode)) {
101− return mode as McpElicitationMode;
102− }
103−
104− const legacyModelResume = url.searchParams.get("allow_model_resume");
105− if (legacyModelResume !== null && TRUE_QUERY_VALUES.has(legacyModelResume.toLowerCase())) {
106− return "model";
107− }
108−
109− return "model";
110−};
91+// The elicitation-mode query contract (`?elicitation_mode=` plus the legacy
92+// `?allow_model_resume` alias) is shared with every host that serves the
93+// browser-approval flow. Re-exported here so the worker dispatcher's existing
94+// import site (`./do-headers`) is unchanged.
95+export {
96+ readElicitationMode,
97+ type McpElicitationMode,
98+} from "@executor-js/host-mcp/browser-approval";
packages/hosts/cloudflare/src/mcp/session-durable-object.ts+7−20
@@ -17,6 +17,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; · 16 unmodified lines
1717 import type { TransportState } from "agents/mcp";
1818
1919 import { jsonRpcErrorBody } from "@executor-js/host-mcp";
20+import { formatResumeAcknowledgement } from "@executor-js/host-mcp/browser-approval";
2021 import { RequestWebOrigin } from "@executor-js/api/server";
2122 import {
2223 formatPausedExecution,
@@ -72,26 +73,12 @@ export type McpSessionResumeApprovalResult = · 49 unmodified lines
7273 const resumeApprovalResult = (
7374 executionId: string,
7475 response: ResumeResponse,
75−): Extract<McpSessionResumeApprovalResult, { readonly status: "ok" }> => {
76− const textByAction = {
77− accept: "I've approved it",
78− decline: "I've denied it",
79− cancel: "I've canceled it",
80− } satisfies Record<ResumeResponse["action"], string>;
81− const statusByAction = {
82− accept: "approved",
83− decline: "denied",
84− cancel: "canceled",
85− } satisfies Record<ResumeResponse["action"], string>;
86−
87− return {
88− status: "ok",
89− executionStatus: "completed",
90− text: textByAction[response.action],
91− structured: { status: statusByAction[response.action], executionId },
92− isError: false,
93− };
94−};
76+): Extract<McpSessionResumeApprovalResult, { readonly status: "ok" }> => ({
77+ status: "ok",
78+ executionStatus: "completed",
79+ ...formatResumeAcknowledgement(executionId, response),
80+ isError: false,
81+});
9582
9683 const HEARTBEAT_MS = 30 * 1000;
9784 const SESSION_TIMEOUT_MS = 5 * 60 * 1000;
packages/hosts/mcp/package.json+4−0
@@ -15,6 +15,10 @@ · 14 unmodified lines
1515 "./in-memory-session-store": {
1616 "types": "./src/in-memory-session-store.ts",
1717 "default": "./src/in-memory-session-store.ts"
18+ },
19+ "./browser-approval": {
20+ "types": "./src/browser-approval.ts",
21+ "default": "./src/browser-approval.ts"
1822 }
1923 },
2024 "scripts": {
packages/hosts/mcp/src/browser-approval.ts+116−0
@@ -0,0 +1,116 @@
1+// ---------------------------------------------------------------------------
2+// Browser-approval primitives shared by every host that surfaces a paused MCP
3+// execution to a human for approval in the browser.
4+//
5+// When a connection runs in `elicitation_mode=browser`, a gated tool call
6+// pauses and the host returns an `approvalUrl` pointing at the console's
7+// `/resume/:executionId` page. The user approves or declines there; the host
8+// records the decision and the model's `resume` tool call — long-polling the
9+// host's `BrowserApprovalStore` — consumes it.
10+//
11+// Three hosts implement that flow over two transports: the in-process handler
12+// (apps/local, host-selfhost) and the Durable Object (cloud, host-cloudflare).
13+// The wire-shape pieces are identical across all of them, so they live here
14+// once: how the mode is read off the request, how the approval URL is built,
15+// the resume-payload schema, and the acknowledgement text/structured content.
16+// Each caller keeps its own transport envelope (HTTP JSON vs DO RPC result) and
17+// wraps this neutral core — that is the only part that legitimately differs.
18+//
19+// This module stays dependency-light (effect + a type from execution + Web
20+// APIs) so the Cloudflare worker/DO bundles can pull it without dragging in the
21+// HTTP API assembly.
22+// ---------------------------------------------------------------------------
23+
24+import { Option, Schema } from "effect";
25+
26+import type { ResumeResponse } from "@executor-js/execution";
27+
28+export type McpElicitationMode = "browser" | "model" | "native";
29+
30+const MCP_ELICITATION_MODES = new Set<McpElicitationMode>(["browser", "model", "native"]);
31+
32+const TRUE_QUERY_VALUES = new Set(["1", "true", "yes", "on"]);
33+
34+/**
35+ * Read the elicitation mode off an MCP request's `?elicitation_mode=` query.
36+ * Unknown or absent values fall back to `model` (the default — the agent calls
37+ * `resume` inline). `?allow_model_resume=true` is a legacy alias for `model`.
38+ */
39+export const readElicitationMode = (request: Request): McpElicitationMode => {
40+ const url = new URL(request.url);
41+ const mode = url.searchParams.get("elicitation_mode");
42+ if (mode && MCP_ELICITATION_MODES.has(mode as McpElicitationMode)) {
43+ return mode as McpElicitationMode;
44+ }
45+
46+ const legacyModelResume = url.searchParams.get("allow_model_resume");
47+ if (legacyModelResume !== null && TRUE_QUERY_VALUES.has(legacyModelResume.toLowerCase())) {
48+ return "model";
49+ }
50+
51+ return "model";
52+};
53+
54+/**
55+ * Build the console approval URL for a paused execution:
56+ * `<origin>/resume/<executionId>?mcp_session_id=<sessionId>`. The
57+ * `mcp_session_id` query routes the console's resume page back to the host's
58+ * approval endpoint for that session.
59+ */
60+export const buildResumeApprovalUrl = (input: {
61+ readonly origin: string | URL;
62+ readonly executionId: string;
63+ readonly sessionId?: string | null;
64+}): string => {
65+ const url = new URL(`/resume/${encodeURIComponent(input.executionId)}`, input.origin);
66+ if (input.sessionId) url.searchParams.set("mcp_session_id", input.sessionId);
67+ return url.toString();
68+};
69+
70+/** `buildResumeApprovalUrl` anchored at the request's own origin (in-process hosts). */
71+export const approvalUrlForRequest = (
72+ request: Request,
73+ executionId: string,
74+ sessionId: string | null,
75+): string => buildResumeApprovalUrl({ origin: request.url, executionId, sessionId });
76+
77+/** The resume decision a console posts back: an action plus optional form content. */
78+export const ResumeResponsePayload = Schema.Struct({
79+ action: Schema.Literals(["accept", "decline", "cancel"]),
80+ content: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
81+});
82+
83+const decodeResumeResponsePayload = Schema.decodeUnknownOption(ResumeResponsePayload);
84+
85+/** Decode an untrusted resume payload, or `null` if it doesn't match the contract. */
86+export const decodeResumeResponse = (raw: unknown): ResumeResponse | null =>
87+ Option.getOrNull(decodeResumeResponsePayload(raw));
88+
89+const ACKNOWLEDGEMENT_TEXT = {
90+ accept: "I've approved it",
91+ decline: "I've denied it",
92+ cancel: "I've canceled it",
93+} satisfies Record<ResumeResponse["action"], string>;
94+
95+const ACKNOWLEDGEMENT_STATUS = {
96+ accept: "approved",
97+ decline: "denied",
98+ cancel: "canceled",
99+} satisfies Record<ResumeResponse["action"], string>;
100+
101+/**
102+ * The transport-neutral acknowledgement a host returns once a browser approval
103+ * decision is recorded: human-facing `text` plus `structured` content the
104+ * console renders. Each host wraps this in its own envelope (HTTP JSON for the
105+ * in-process handler, the DO RPC result for Cloudflare).
106+ */
107+export const formatResumeAcknowledgement = (
108+ executionId: string,
109+ response: ResumeResponse,
110+): {
111+ readonly text: string;
112+ readonly structured: { readonly status: string; readonly executionId: string };
113+} => ({
114+ text: ACKNOWLEDGEMENT_TEXT[response.action],
115+ structured: { status: ACKNOWLEDGEMENT_STATUS[response.action], executionId },
116+});