|
| 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 | +}); |
0 commit comments