Skip to content

Commit 8d36fa2

Browse files
committed
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.
1 parent 013fa38 commit 8d36fa2

6 files changed

Lines changed: 155 additions & 100 deletions

File tree

apps/cloud/src/mcp/session-durable-object.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { drizzle } from "drizzle-orm/postgres-js";
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> {
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

Lines changed: 13 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -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 {
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 => {
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> =>
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

Lines changed: 8 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,6 @@ import { Effect } from "effect";
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 => {
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

Lines changed: 7 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
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 =
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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@
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": {
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
});

0 commit comments

Comments
 (0)