Skip to content

Commit a595fe9

Browse files
committed
self-host: browser approval of gated MCP actions
Self-host (and the local app) could pause a gated MCP execution but had no way for a human to approve it in the browser: the in-process MCP store served model-mode only, and the shared resume page fetched paused detail from the session-less /api/executions/:id — a different engine that never holds the MCP pause. - @executor-js/host-mcp: an in-process browser-approval store (the resume long-poll <-> HTTP decision bridge, keyed by executionId), and the in-memory session store now builds browser-mode servers, keeps each session's engine, and serves the session-scoped paused (GET) + resume (POST) endpoints. makeMcpBuildServer returns { mcpServer, engine }. - host-selfhost: mounts the approval endpoints at /api/mcp-sessions/*, gated by a Better Auth session. - react: the shared resume page fetches paused detail session-scoped, so the in-process hosts resolve it from the right engine (mirrors cloud). - apps/local: migrated onto the shared approval store + the new GET handler, dropping its duplicated waiter maps; its browser approval page now works, not just the API-direct path. - e2e: browser-approval scenarios moved to scenarios/ (cross-target) — approve + decline now green on self-host too.
1 parent 1af9520 commit a595fe9

11 files changed

Lines changed: 380 additions & 87 deletions

File tree

apps/host-selfhost/src/app.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => {
8585
routes: [
8686
// Better Auth owns /api/auth/* — the full path reaches it unmodified.
8787
HttpRouter.add("*", "/api/auth/*", HttpEffect.fromWebHandler(authHandler)),
88+
// Browser approval of paused MCP executions: the console resume page
89+
// reads paused detail (GET) and records the decision (POST .../resume),
90+
// session-cookie-gated, delegating to the in-process MCP store.
91+
HttpRouter.add("*", "/api/mcp-sessions/*", HttpEffect.fromWebHandler(mcp.approvalHandler)),
8892
// App-local admin (invite-code) API, served under /api/admin/*.
8993
makeSelfHostAdminApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }),
9094
// Public system API: /api/health + /api/setup-status (unauthenticated).

apps/host-selfhost/src/mcp/index.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Layer } from "effect";
1+
import { Effect, Layer } from "effect";
22

33
import { IdentityProvider } from "@executor-js/api/server";
44
import type { McpAuthProvider, McpErrorReporter, McpSessionStore } from "@executor-js/host-mcp";
@@ -50,10 +50,49 @@ export interface SelfHostMcpSeams {
5050
readonly sessions: Layer.Layer<McpSessionStore>;
5151
/** Route 500 defects through the host's console `ErrorCapture`. */
5252
readonly reporter: Layer.Layer<McpErrorReporter>;
53+
/**
54+
* The browser-approval HTTP handler, mounted by the app at
55+
* `/api/mcp-sessions/*`: a session-cookie-gated web handler that serves the
56+
* paused-execution detail (GET) and records the human's decision (POST
57+
* `/resume`) for the console approval page. Browser elicitation mode only.
58+
*/
59+
readonly approvalHandler: (request: Request) => Promise<Response>;
5360
/** Dispose all live in-process MCP sessions at shutdown (not a seam). */
5461
readonly close: () => Promise<void>;
5562
}
5663

64+
const jsonResponse = (value: unknown, status: number): Response =>
65+
new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } });
66+
67+
/**
68+
* Gate the browser-approval endpoints behind a valid Better Auth session (the
69+
* console page calls them with the user's cookie), then delegate to the
70+
* in-process store's paused/resume handlers. Single-tenant: any authenticated
71+
* user of the one org may act on a session it still holds — the store confirms
72+
* the execution belongs to the addressed session before recording.
73+
*/
74+
const makeApprovalHandler =
75+
(
76+
store: ReturnType<typeof makeSelfHostMcpSessionStore>,
77+
betterAuth: BetterAuthHandle,
78+
): ((request: Request) => Promise<Response>) =>
79+
async (request) => {
80+
// A malformed cookie must read as unauthenticated, not 500.
81+
const session = await Effect.runPromise(
82+
Effect.tryPromise({
83+
try: () => betterAuth.auth.api.getSession({ headers: request.headers }),
84+
catch: () => "session lookup failed",
85+
}).pipe(Effect.orElseSucceed(() => null)),
86+
);
87+
if (!session) return jsonResponse({ error: "Unauthorized" }, 401);
88+
89+
return (
90+
(await store.handlePausedRequest(request)) ??
91+
(await store.handleApprovalRequest(request)) ??
92+
jsonResponse({ error: "Not found" }, 404)
93+
);
94+
};
95+
5796
/**
5897
* Build the self-host MCP serving seams over the long-lived DB handle. The auth
5998
* seam is `selfHostMcpAuth` (Better Auth mcp() OAuth), with the Better Auth
@@ -73,6 +112,7 @@ export const makeSelfHostMcpSeams = (
73112
auth,
74113
sessions: selfHostMcpSessions(sessionStore),
75114
reporter: selfHostMcpReporter,
115+
approvalHandler: makeApprovalHandler(sessionStore, betterAuth),
76116
close: sessionStore.close,
77117
};
78118
};

apps/local/src/mcp.ts

Lines changed: 39 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Deferred, Effect } from "effect";
1+
import { 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";
@@ -14,7 +14,8 @@ import {
1414
formatResumeAcknowledgement,
1515
readElicitationMode,
1616
} from "@executor-js/host-mcp/browser-approval";
17-
import type { ResumeResponse } from "@executor-js/execution";
17+
import { makeInProcessBrowserApprovalStore } from "@executor-js/host-mcp/browser-approval-store";
18+
import { formatPausedExecution, type ResumeResponse } from "@executor-js/execution";
1819

1920
import { startIntegrationsRefresh } from "./integrations";
2021

@@ -24,6 +25,9 @@ import { startIntegrationsRefresh } from "./integrations";
2425

2526
export type McpRequestHandler = {
2627
readonly handleRequest: (request: Request) => Promise<Response>;
28+
/** GET `/api/mcp-sessions/:id/executions/:id` — paused detail for the console. */
29+
readonly handlePausedRequest: (request: Request) => Promise<Response>;
30+
/** POST `/api/mcp-sessions/:id/executions/:id/resume` — record the decision. */
2731
readonly handleApprovalRequest: (request: Request) => Promise<Response>;
2832
readonly close: () => Promise<void>;
2933
};
@@ -52,6 +56,7 @@ const ignoreClose = (close: (() => Promise<void>) | undefined): Promise<void> =>
5256
)
5357
: Promise.resolve();
5458

59+
const pausedRequestPattern = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)$/;
5560
const approvalRequestPattern = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)\/resume$/;
5661

5762
const json = (value: unknown, status = 200): Response =>
@@ -77,16 +82,29 @@ const resumeApprovalResult = (executionId: string, response: ResumeResponse) =>
7782
export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpRequestHandler => {
7883
const transports = new Map<string, WebStandardStreamableHTTPServerTransport>();
7984
const servers = new Map<string, McpServer>();
80-
const approvalResponses = new Map<string, Map<string, ResumeResponse>>();
81-
const approvalWaiters = new Map<string, Map<string, Deferred.Deferred<ResumeResponse>>>();
85+
const approvals = makeInProcessBrowserApprovalStore();
86+
// Local runs one shared engine across every MCP session (main.ts builds it and
87+
// passes it in), so the paused-execution lookup for browser approval reads it
88+
// directly — there is no per-session engine to track.
89+
const engine = "engine" in config ? config.engine : null;
90+
91+
const pausedDetail = (
92+
executionId: string,
93+
): Promise<ReturnType<typeof formatPausedExecution> | null> =>
94+
engine
95+
? Effect.runPromise(
96+
engine.getPausedExecution(executionId).pipe(
97+
Effect.map((paused) => (paused ? formatPausedExecution(paused) : null)),
98+
Effect.orElseSucceed(() => null),
99+
),
100+
)
101+
: Promise.resolve(null);
82102

83103
const dispose = async (id: string, opts: { transport?: boolean; server?: boolean } = {}) => {
84104
const t = transports.get(id);
85105
const s = servers.get(id);
86106
transports.delete(id);
87107
servers.delete(id);
88-
approvalResponses.delete(id);
89-
approvalWaiters.delete(id);
90108
if (opts.transport) await ignoreClose(t ? () => t.close() : undefined);
91109
if (opts.server) await ignoreClose(s ? () => s.close() : undefined);
92110
};
@@ -125,48 +143,7 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq
125143
created = await Effect.runPromise(
126144
createExecutorMcpServer({
127145
...config,
128-
browserApprovalStore: {
129-
takeResponse: (executionId) =>
130-
Effect.sync(() => {
131-
if (!createdSessionId) return null;
132-
const sessionApprovals = approvalResponses.get(createdSessionId);
133-
const response = sessionApprovals?.get(executionId) ?? null;
134-
sessionApprovals?.delete(executionId);
135-
return response;
136-
}),
137-
waitForResponse: (executionId) =>
138-
Effect.gen(function* () {
139-
if (!createdSessionId) return null;
140-
const sessionApprovals = approvalResponses.get(createdSessionId);
141-
const response = sessionApprovals?.get(executionId) ?? null;
142-
if (response) {
143-
sessionApprovals?.delete(executionId);
144-
return response;
145-
}
146-
147-
const sessionWaiters =
148-
approvalWaiters.get(createdSessionId) ??
149-
new Map<string, Deferred.Deferred<ResumeResponse>>();
150-
const waiter =
151-
sessionWaiters.get(executionId) ?? (yield* Deferred.make<ResumeResponse>());
152-
sessionWaiters.set(executionId, waiter);
153-
approvalWaiters.set(createdSessionId, sessionWaiters);
154-
155-
yield* Deferred.await(waiter).pipe(
156-
Effect.ensuring(
157-
Effect.sync(() => {
158-
if (sessionWaiters.get(executionId) === waiter) {
159-
sessionWaiters.delete(executionId);
160-
}
161-
}),
162-
),
163-
);
164-
const approvals = approvalResponses.get(createdSessionId);
165-
const approved = approvals?.get(executionId) ?? null;
166-
approvals?.delete(executionId);
167-
return approved;
168-
}),
169-
},
146+
browserApprovalStore: approvals.store,
170147
elicitationMode:
171148
elicitationMode === "browser"
172149
? {
@@ -197,26 +174,29 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq
197174
}
198175
},
199176

177+
handlePausedRequest: async (request) => {
178+
const match = pausedRequestPattern.exec(new URL(request.url).pathname);
179+
if (!match) return json({ error: "Not found" }, 404);
180+
if (request.method !== "GET") return json({ error: "Method not allowed" }, 405);
181+
182+
const paused = await pausedDetail(decodeURIComponent(match[2]));
183+
if (!paused) return json({ error: "Paused execution not found" }, 404);
184+
return json({ text: paused.text, structured: paused.structured });
185+
},
186+
200187
handleApprovalRequest: async (request) => {
201-
const url = new URL(request.url);
202-
const match = approvalRequestPattern.exec(url.pathname);
188+
const match = approvalRequestPattern.exec(new URL(request.url).pathname);
203189
if (!match) return json({ error: "Not found" }, 404);
204190
if (request.method !== "POST") return json({ error: "Method not allowed" }, 405);
205191

206-
const sessionId = decodeURIComponent(match[1]);
207192
const executionId = decodeURIComponent(match[2]);
208-
if (!servers.has(sessionId)) return json({ error: "MCP session not found" }, 404);
193+
// The shared engine must still hold the paused execution — guards stale ids.
194+
if (!(await pausedDetail(executionId))) return json({ error: "MCP session not found" }, 404);
209195

210196
const response = await readResumeResponse(request);
211197
if (!response) return json({ error: "Invalid approval response" }, 400);
212198

213-
const sessionApprovals =
214-
approvalResponses.get(sessionId) ?? new Map<string, ResumeResponse>();
215-
sessionApprovals.set(executionId, response);
216-
approvalResponses.set(sessionId, sessionApprovals);
217-
const waiter = approvalWaiters.get(sessionId)?.get(executionId);
218-
if (waiter) await Effect.runPromise(Deferred.succeed(waiter, response));
219-
199+
await Effect.runPromise(approvals.recordResponse(executionId, response));
220200
return json(resumeApprovalResult(executionId, response));
221201
},
222202

apps/local/src/serve.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const startTestServer = async (): Promise<string> => {
2121
mcp: {
2222
handleRequest: async () => new Response("ok"),
2323
handleApprovalRequest: async () => new Response("ok"),
24+
handlePausedRequest: async () => new Response("ok"),
2425
close: async () => {},
2526
},
2627
},
@@ -80,6 +81,7 @@ describe("startServer network bind auth", () => {
8081
mcp: {
8182
handleRequest: async () => new Response("ok"),
8283
handleApprovalRequest: async () => new Response("ok"),
84+
handlePausedRequest: async () => new Response("ok"),
8385
close: async () => {},
8486
},
8587
},
@@ -101,6 +103,7 @@ describe("startServer network bind auth", () => {
101103
mcp: {
102104
handleRequest: async () => new Response("ok"),
103105
handleApprovalRequest: async () => new Response("ok"),
106+
handlePausedRequest: async () => new Response("ok"),
104107
close: async () => {},
105108
},
106109
},
@@ -131,6 +134,7 @@ describe("startServer network bind auth", () => {
131134
mcp: {
132135
handleRequest: async () => new Response("ok"),
133136
handleApprovalRequest: async () => new Response("ok"),
137+
handlePausedRequest: async () => new Response("ok"),
134138
close: async () => {},
135139
},
136140
},
@@ -166,6 +170,7 @@ describe("startServer network bind auth", () => {
166170
mcp: {
167171
handleRequest: async () => new Response("ok"),
168172
handleApprovalRequest: async () => new Response("ok"),
173+
handlePausedRequest: async () => new Response("ok"),
169174
close: async () => {},
170175
},
171176
},

apps/local/src/serve.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,11 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server
326326
}
327327

328328
if (url.pathname.startsWith("/api/mcp-sessions/")) {
329-
return maybeWithCorsHeaders(await handlers.mcp.handleApprovalRequest(req));
329+
const handler =
330+
req.method === "GET"
331+
? handlers.mcp.handlePausedRequest
332+
: handlers.mcp.handleApprovalRequest;
333+
return maybeWithCorsHeaders(await handler(req));
330334
}
331335

332336
// OAuth result polling — local-only, served outside the typed API
Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@
1212
// The policy is removed in an `ensuring` finalizer — a leaked require_approval
1313
// gate on a shared built-in tool would pause unrelated scenarios.
1414
//
15-
// Lives under cloud/ for now because cloud is the only host wired for browser
16-
// approval; it moves to scenarios/ (cross-target) as self-host and Cloudflare
17-
// gain the feature.
15+
// Cross-target: runs on every host that wires browser approval (cloud's Durable
16+
// Object, self-host's in-process store, Cloudflare's DO). The host differences —
17+
// where the approval URL points, which engine holds the pause — are invisible
18+
// here; the scenario only drives the rendered console page.
1819
import { expect } from "@effect/vitest";
1920
import { Effect } from "effect";
2021
import { composePluginApi } from "@executor-js/api/server";

packages/core/api/src/server/mcp-build.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { McpErrorReporter, type Principal } from "@executor-js/host-mcp";
44
import {
55
McpEngineBuildError,
66
type McpBuildServer,
7+
type McpBuildServerOptions,
78
} from "@executor-js/host-mcp/in-memory-session-store";
89
import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server";
910

@@ -37,7 +38,7 @@ export type McpExecutionStackLayer = Layer.Layer<
3738
*/
3839
export const makeMcpBuildServer =
3940
(executionStack: McpExecutionStackLayer): McpBuildServer =>
40-
(principal: Principal) =>
41+
(principal: Principal, options?: McpBuildServerOptions) =>
4142
makeExecutionStack(
4243
principal.accountId,
4344
principal.organizationId,
@@ -46,7 +47,11 @@ export const makeMcpBuildServer =
4647
Effect.map(({ engine }) => engine),
4748
Effect.provide(executionStack),
4849
Effect.mapError((cause) => new McpEngineBuildError({ cause })),
49-
Effect.flatMap((engine) => createExecutorMcpServer({ engine })),
50+
Effect.flatMap((engine) =>
51+
createExecutorMcpServer({ engine, ...(options ?? {}) }).pipe(
52+
Effect.map((mcpServer) => ({ mcpServer, engine })),
53+
),
54+
),
5055
);
5156

5257
/**

packages/hosts/mcp/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@
1919
"./browser-approval": {
2020
"types": "./src/browser-approval.ts",
2121
"default": "./src/browser-approval.ts"
22+
},
23+
"./browser-approval-store": {
24+
"types": "./src/browser-approval-store.ts",
25+
"default": "./src/browser-approval-store.ts"
2226
}
2327
},
2428
"scripts": {

0 commit comments

Comments
 (0)