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
22import postgres, { type Sql } from "postgres";22import postgres, { type Sql } from "postgres";
23 23
24import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server";24import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server";
25import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval";
25import {26import {
26 McpSessionDOBase,27 McpSessionDOBase,
27 type BuiltMcpServer,28 type BuiltMcpServer,
@@ -210,12 +211,12 @@ export class McpSessionDO extends McpSessionDOBase<CloudSessionDbHandle> { · 182 unmodified lines
210 sessionElicitationMode === "browser"211 sessionElicitationMode === "browser"
211 ? {212 ? {
212 mode: "browser" as const,213 mode: "browser" as const,
213 approvalUrl: (executionId) => {214 approvalUrl: (executionId) =>
214 const origin = env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh";215 buildResumeApprovalUrl({
215 const url = new URL(`/resume/${encodeURIComponent(executionId)}`, origin);216 origin: env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh",
216 url.searchParams.set("mcp_session_id", self.sessionId);217 executionId,
217 return url.toString();218 sessionId: self.sessionId,
218 },219 }),
219 }220 }
220 : { mode: sessionElicitationMode },221 : { mode: sessionElicitationMode },
221 }).pipe(Effect.withSpan("McpSessionDO.createExecutorMcpServer"));222 }).pipe(Effect.withSpan("McpSessionDO.createExecutorMcpServer"));
apps/local/src/mcp.ts+13−54
@@ -1,4 +1,4 @@
1import { Deferred, Effect, Option, Schema } from "effect";1import { Deferred, Effect } from "effect";
2import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";2import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";3import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";4import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
@@ -8,6 +8,12 @@ import { · 3 unmodified lines
8 createExecutorMcpServer,8 createExecutorMcpServer,
9 type ExecutorMcpServerConfig,9 type ExecutorMcpServerConfig,
10} from "@executor-js/host-mcp/tool-server";10} from "@executor-js/host-mcp/tool-server";
11import {
12 approvalUrlForRequest,
13 decodeResumeResponse,
14 formatResumeAcknowledgement,
15 readElicitationMode,
16} from "@executor-js/host-mcp/browser-approval";
11import type { ResumeResponse } from "@executor-js/execution";17import type { ResumeResponse } from "@executor-js/execution";
12 18
13import { startIntegrationsRefresh } from "./integrations";19import { startIntegrationsRefresh } from "./integrations";
@@ -34,35 +40,6 @@ const formatBoundaryError = (error: unknown): unknown => { · 20 unmodified lines
34 return error;40 return error;
35};41};
36 42
37type McpElicitationMode = "browser" | "model" | "native";
38
39const MCP_ELICITATION_MODES = new Set<McpElicitationMode>(["browser", "model", "native"]);
40const ResumeResponsePayload = Schema.Struct({
41 action: Schema.Literals(["accept", "decline", "cancel"]),
42 content: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
43});
44const decodeResumeResponsePayload = Schema.decodeUnknownOption(ResumeResponsePayload);
45
46const 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
56const 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
66const ignoreClose = (close: (() => Promise<void>) | undefined): Promise<void> =>43const ignoreClose = (close: (() => Promise<void>) | undefined): Promise<void> =>
67 close44 close
68 ? Effect.runPromise(45 ? Effect.runPromise(
@@ -88,32 +65,14 @@ const readResumeResponse = (request: Request): Promise<ResumeResponse | null> => · 19 unmodified lines
88 Effect.tryPromise({65 Effect.tryPromise({
89 try: () => request.json(),66 try: () => request.json(),
90 catch: () => null,67 catch: () => null,
91 }).pipe(68 }).pipe(Effect.map((raw) => (raw === null ? null : decodeResumeResponse(raw)))),
92 Effect.map((raw) =>
93 raw === null ? null : Option.getOrNull(decodeResumeResponsePayload(raw)),
94 ),
95 ),
96 );69 );
97 70
98const resumeApprovalResult = (executionId: string, response: ResumeResponse) => {71const resumeApprovalResult = (executionId: string, response: ResumeResponse) => ({
99 const textByAction = {72 status: "completed",
100 accept: "I've approved it",73 ...formatResumeAcknowledgement(executionId, response),
101 decline: "I've denied it",74 isError: false,
102 cancel: "I've canceled it",75});
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};
117 76
118export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpRequestHandler => {77export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpRequestHandler => {
119 const transports = new Map<string, WebStandardStreamableHTTPServerTransport>();78 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
17export const INTERNAL_ACCOUNT_ID_HEADER = "x-executor-mcp-account-id";17export const INTERNAL_ACCOUNT_ID_HEADER = "x-executor-mcp-account-id";
18export const INTERNAL_ORGANIZATION_ID_HEADER = "x-executor-mcp-organization-id";18export const INTERNAL_ORGANIZATION_ID_HEADER = "x-executor-mcp-organization-id";
19 19
20const TRUE_QUERY_VALUES = new Set(["1", "true", "yes", "on"]);
21
22/** The verified identity used to stamp the DO's internal owner headers. */20/** The verified identity used to stamp the DO's internal owner headers. */
23export type VerifiedTokenHeaders = {21export type VerifiedTokenHeaders = {
24 readonly accountId: string;22 readonly accountId: string;
@@ -90,21 +88,11 @@ export const withMcpResponseHeaders = (response: Response): Response => { · 65 unmodified lines
90 });88 });
91};89};
92 90
93export type McpElicitationMode = "browser" | "model" | "native";91// The elicitation-mode query contract (`?elicitation_mode=` plus the legacy
94 92// `?allow_model_resume` alias) is shared with every host that serves the
95const MCP_ELICITATION_MODES = new Set<McpElicitationMode>(["browser", "model", "native"]);93// browser-approval flow. Re-exported here so the worker dispatcher's existing
96 94// import site (`./do-headers`) is unchanged.
97export const readElicitationMode = (request: Request): McpElicitationMode => {95export {
98 const url = new URL(request.url);96 readElicitationMode,
99 const mode = url.searchParams.get("elicitation_mode");97 type McpElicitationMode,
100 if (mode && MCP_ELICITATION_MODES.has(mode as McpElicitationMode)) {98} from "@executor-js/host-mcp/browser-approval";
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};
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
17import type { TransportState } from "agents/mcp";17import type { TransportState } from "agents/mcp";
18 18
19import { jsonRpcErrorBody } from "@executor-js/host-mcp";19import { jsonRpcErrorBody } from "@executor-js/host-mcp";
20import { formatResumeAcknowledgement } from "@executor-js/host-mcp/browser-approval";
20import { RequestWebOrigin } from "@executor-js/api/server";21import { RequestWebOrigin } from "@executor-js/api/server";
21import {22import {
22 formatPausedExecution,23 formatPausedExecution,
@@ -72,26 +73,12 @@ export type McpSessionResumeApprovalResult = · 49 unmodified lines
72const resumeApprovalResult = (73const resumeApprovalResult = (
73 executionId: string,74 executionId: string,
74 response: ResumeResponse,75 response: ResumeResponse,
75): Extract<McpSessionResumeApprovalResult, { readonly status: "ok" }> => {76): Extract<McpSessionResumeApprovalResult, { readonly status: "ok" }> => ({
76 const textByAction = {77 status: "ok",
77 accept: "I've approved it",78 executionStatus: "completed",
78 decline: "I've denied it",79 ...formatResumeAcknowledgement(executionId, response),
79 cancel: "I've canceled it",80 isError: false,
80 } satisfies Record<ResumeResponse["action"], string>;81});
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};
95 82
96const HEARTBEAT_MS = 30 * 1000;83const HEARTBEAT_MS = 30 * 1000;
97const SESSION_TIMEOUT_MS = 5 * 60 * 1000;84const SESSION_TIMEOUT_MS = 5 * 60 * 1000;
packages/hosts/mcp/package.json+4−0
@@ -15,6 +15,10 @@ · 14 unmodified lines
15 "./in-memory-session-store": {15 "./in-memory-session-store": {
16 "types": "./src/in-memory-session-store.ts",16 "types": "./src/in-memory-session-store.ts",
17 "default": "./src/in-memory-session-store.ts"17 "default": "./src/in-memory-session-store.ts"
18 },
19 "./browser-approval": {
20 "types": "./src/browser-approval.ts",
21 "default": "./src/browser-approval.ts"
18 }22 }
19 },23 },
20 "scripts": {24 "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
24import { Option, Schema } from "effect";
25
26import type { ResumeResponse } from "@executor-js/execution";
27
28export type McpElicitationMode = "browser" | "model" | "native";
29
30const MCP_ELICITATION_MODES = new Set<McpElicitationMode>(["browser", "model", "native"]);
31
32const 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 */
39export 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 */
60export 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). */
71export 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. */
78export const ResumeResponsePayload = Schema.Struct({
79 action: Schema.Literals(["accept", "decline", "cancel"]),
80 content: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
81});
82
83const decodeResumeResponsePayload = Schema.decodeUnknownOption(ResumeResponsePayload);
84
85/** Decode an untrusted resume payload, or `null` if it doesn't match the contract. */
86export const decodeResumeResponse = (raw: unknown): ResumeResponse | null =>
87 Option.getOrNull(decodeResumeResponsePayload(raw));
88
89const 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
95const 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 */
107export 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});