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.
Unified · Split
| @@ -22,6 +22,7 @@ import { drizzle } from "drizzle-orm/postgres-js"; · 21 unmodified lines | |||
| 22 | import postgres, { type Sql } from "postgres"; | 22 | import postgres, { type Sql } from "postgres"; |
| 23 | 23 | ||
| 24 | import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; | 24 | import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; |
| 25 | import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; | ||
| 25 | import { | 26 | import { |
| 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")); |
| @@ -1,4 +1,4 @@ | |||
| 1 | import { Deferred, Effect, Option, Schema } from "effect"; | 1 | import { Deferred, Effect } from "effect"; |
| 2 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | 2 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; |
| 3 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; | 3 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; |
| 4 | import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; | 4 | import { 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"; |
| 11 | import { | ||
| 12 | approvalUrlForRequest, | ||
| 13 | decodeResumeResponse, | ||
| 14 | formatResumeAcknowledgement, | ||
| 15 | readElicitationMode, | ||
| 16 | } from "@executor-js/host-mcp/browser-approval"; | ||
| 11 | import type { ResumeResponse } from "@executor-js/execution"; | 17 | import type { ResumeResponse } from "@executor-js/execution"; |
| 12 | 18 | ||
| 13 | import { startIntegrationsRefresh } from "./integrations"; | 19 | import { 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 | ||
| 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 | |||
| 66 | const ignoreClose = (close: (() => Promise<void>) | undefined): Promise<void> => | 43 | const ignoreClose = (close: (() => Promise<void>) | undefined): Promise<void> => |
| 67 | close | 44 | 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 | ||
| 98 | const resumeApprovalResult = (executionId: string, response: ResumeResponse) => { | 71 | const 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 | ||
| 118 | export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpRequestHandler => { | 77 | export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpRequestHandler => { |
| 119 | const transports = new Map<string, WebStandardStreamableHTTPServerTransport>(); | 78 | const transports = new Map<string, WebStandardStreamableHTTPServerTransport>(); |
| @@ -17,8 +17,6 @@ import { Effect } from "effect"; · 16 unmodified lines | |||
| 17 | export const INTERNAL_ACCOUNT_ID_HEADER = "x-executor-mcp-account-id"; | 17 | export const INTERNAL_ACCOUNT_ID_HEADER = "x-executor-mcp-account-id"; |
| 18 | export const INTERNAL_ORGANIZATION_ID_HEADER = "x-executor-mcp-organization-id"; | 18 | export const INTERNAL_ORGANIZATION_ID_HEADER = "x-executor-mcp-organization-id"; |
| 19 | 19 | ||
| 20 | const 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. */ |
| 23 | export type VerifiedTokenHeaders = { | 21 | export 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 | ||
| 93 | export 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 | |
| 95 | const 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. | |
| 97 | export const readElicitationMode = (request: Request): McpElicitationMode => { | 95 | export { |
| 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 | }; | ||
| @@ -17,6 +17,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; · 16 unmodified lines | |||
| 17 | import type { TransportState } from "agents/mcp"; | 17 | import type { TransportState } from "agents/mcp"; |
| 18 | 18 | ||
| 19 | import { jsonRpcErrorBody } from "@executor-js/host-mcp"; | 19 | import { jsonRpcErrorBody } from "@executor-js/host-mcp"; |
| 20 | import { formatResumeAcknowledgement } from "@executor-js/host-mcp/browser-approval"; | ||
| 20 | import { RequestWebOrigin } from "@executor-js/api/server"; | 21 | import { RequestWebOrigin } from "@executor-js/api/server"; |
| 21 | import { | 22 | import { |
| 22 | formatPausedExecution, | 23 | formatPausedExecution, |
| @@ -72,26 +73,12 @@ export type McpSessionResumeApprovalResult = · 49 unmodified lines | |||
| 72 | const resumeApprovalResult = ( | 73 | const 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 | ||
| 96 | const HEARTBEAT_MS = 30 * 1000; | 83 | const HEARTBEAT_MS = 30 * 1000; |
| 97 | const SESSION_TIMEOUT_MS = 5 * 60 * 1000; | 84 | const SESSION_TIMEOUT_MS = 5 * 60 * 1000; |
| @@ -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": { |
| @@ -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 | }); | ||