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.
Unified · Split
| @@ -85,6 +85,10 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 84 unmodified lines | |||
| 85 | routes: [ | 85 | routes: [ |
| 86 | // Better Auth owns /api/auth/* — the full path reaches it unmodified. | 86 | // Better Auth owns /api/auth/* — the full path reaches it unmodified. |
| 87 | HttpRouter.add("*", "/api/auth/*", HttpEffect.fromWebHandler(authHandler)), | 87 | 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)), | ||
| 88 | // App-local admin (invite-code) API, served under /api/admin/*. | 92 | // App-local admin (invite-code) API, served under /api/admin/*. |
| 89 | makeSelfHostAdminApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }), | 93 | makeSelfHostAdminApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }), |
| 90 | // Public system API: /api/health + /api/setup-status (unauthenticated). | 94 | // Public system API: /api/health + /api/setup-status (unauthenticated). |
| @@ -1,4 +1,4 @@ | |||
| 1 | import { Layer } from "effect"; | 1 | import { Effect, Layer } from "effect"; |
| 2 | 2 | ||
| 3 | import { IdentityProvider } from "@executor-js/api/server"; | 3 | import { IdentityProvider } from "@executor-js/api/server"; |
| 4 | import type { McpAuthProvider, McpErrorReporter, McpSessionStore } from "@executor-js/host-mcp"; | 4 | import type { McpAuthProvider, McpErrorReporter, McpSessionStore } from "@executor-js/host-mcp"; |
| @@ -50,10 +50,49 @@ export interface SelfHostMcpSeams { · 45 unmodified lines | |||
| 50 | readonly sessions: Layer.Layer<McpSessionStore>; | 50 | readonly sessions: Layer.Layer<McpSessionStore>; |
| 51 | /** Route 500 defects through the host's console `ErrorCapture`. */ | 51 | /** Route 500 defects through the host's console `ErrorCapture`. */ |
| 52 | readonly reporter: Layer.Layer<McpErrorReporter>; | 52 | 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>; | ||
| 53 | /** Dispose all live in-process MCP sessions at shutdown (not a seam). */ | 60 | /** Dispose all live in-process MCP sessions at shutdown (not a seam). */ |
| 54 | readonly close: () => Promise<void>; | 61 | readonly close: () => Promise<void>; |
| 55 | } | 62 | } |
| 56 | 63 | ||
| 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 | |||
| 57 | /** | 96 | /** |
| 58 | * Build the self-host MCP serving seams over the long-lived DB handle. The auth | 97 | * Build the self-host MCP serving seams over the long-lived DB handle. The auth |
| 59 | * seam is `selfHostMcpAuth` (Better Auth mcp() OAuth), with the Better Auth | 98 | * seam is `selfHostMcpAuth` (Better Auth mcp() OAuth), with the Better Auth |
| @@ -73,6 +112,7 @@ export const makeSelfHostMcpSeams = ( · 13 unmodified lines | |||
| 73 | auth, | 112 | auth, |
| 74 | sessions: selfHostMcpSessions(sessionStore), | 113 | sessions: selfHostMcpSessions(sessionStore), |
| 75 | reporter: selfHostMcpReporter, | 114 | reporter: selfHostMcpReporter, |
| 115 | approvalHandler: makeApprovalHandler(sessionStore, betterAuth), | ||
| 76 | close: sessionStore.close, | 116 | close: sessionStore.close, |
| 77 | }; | 117 | }; |
| 78 | }; | 118 | }; |
| @@ -1,4 +1,4 @@ | |||
| 1 | import { Deferred, Effect } from "effect"; | 1 | import { 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"; |
| @@ -14,7 +14,8 @@ import { · 9 unmodified lines | |||
| 14 | formatResumeAcknowledgement, | 14 | formatResumeAcknowledgement, |
| 15 | readElicitationMode, | 15 | readElicitationMode, |
| 16 | } from "@executor-js/host-mcp/browser-approval"; | 16 | } 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"; | ||
| 18 | 19 | ||
| 19 | import { startIntegrationsRefresh } from "./integrations"; | 20 | import { startIntegrationsRefresh } from "./integrations"; |
| 20 | 21 | ||
| @@ -24,6 +25,9 @@ import { startIntegrationsRefresh } from "./integrations"; · 3 unmodified lines | |||
| 24 | 25 | ||
| 25 | export type McpRequestHandler = { | 26 | export type McpRequestHandler = { |
| 26 | readonly handleRequest: (request: Request) => Promise<Response>; | 27 | 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. */ | ||
| 27 | readonly handleApprovalRequest: (request: Request) => Promise<Response>; | 31 | readonly handleApprovalRequest: (request: Request) => Promise<Response>; |
| 28 | readonly close: () => Promise<void>; | 32 | readonly close: () => Promise<void>; |
| 29 | }; | 33 | }; |
| @@ -52,6 +56,7 @@ const ignoreClose = (close: (() => Promise<void>) | undefined): Promise<void> => · 22 unmodified lines | |||
| 52 | ) | 56 | ) |
| 53 | : Promise.resolve(); | 57 | : Promise.resolve(); |
| 54 | 58 | ||
| 59 | const pausedRequestPattern = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)$/; | ||
| 55 | const approvalRequestPattern = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)\/resume$/; | 60 | const approvalRequestPattern = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)\/resume$/; |
| 56 | 61 | ||
| 57 | const json = (value: unknown, status = 200): Response => | 62 | const json = (value: unknown, status = 200): Response => |
| @@ -77,16 +82,29 @@ const resumeApprovalResult = (executionId: string, response: ResumeResponse) => · 19 unmodified lines | |||
| 77 | export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpRequestHandler => { | 82 | export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpRequestHandler => { |
| 78 | const transports = new Map<string, WebStandardStreamableHTTPServerTransport>(); | 83 | const transports = new Map<string, WebStandardStreamableHTTPServerTransport>(); |
| 79 | const servers = new Map<string, McpServer>(); | 84 | const servers = new Map<string, McpServer>(); |
| 80 | const approvalResponses = new Map<string, Map<string, ResumeResponse>>(); | 85 | const approvals = makeInProcessBrowserApprovalStore(); |
| 81 | const approvalWaiters = new Map<string, Map<string, Deferred.Deferred<ResumeResponse>>>(); | 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); | ||
| 82 | 102 | ||
| 83 | const dispose = async (id: string, opts: { transport?: boolean; server?: boolean } = {}) => { | 103 | const dispose = async (id: string, opts: { transport?: boolean; server?: boolean } = {}) => { |
| 84 | const t = transports.get(id); | 104 | const t = transports.get(id); |
| 85 | const s = servers.get(id); | 105 | const s = servers.get(id); |
| 86 | transports.delete(id); | 106 | transports.delete(id); |
| 87 | servers.delete(id); | 107 | servers.delete(id); |
| 88 | approvalResponses.delete(id); | ||
| 89 | approvalWaiters.delete(id); | ||
| 90 | if (opts.transport) await ignoreClose(t ? () => t.close() : undefined); | 108 | if (opts.transport) await ignoreClose(t ? () => t.close() : undefined); |
| 91 | if (opts.server) await ignoreClose(s ? () => s.close() : undefined); | 109 | if (opts.server) await ignoreClose(s ? () => s.close() : undefined); |
| 92 | }; | 110 | }; |
| @@ -125,48 +143,7 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq · 32 unmodified lines | |||
| 125 | created = await Effect.runPromise( | 143 | created = await Effect.runPromise( |
| 126 | createExecutorMcpServer({ | 144 | createExecutorMcpServer({ |
| 127 | ...config, | 145 | ...config, |
| 128 | browserApprovalStore: { | 146 | browserApprovalStore: approvals.store, |
| 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 | }, | ||
| 170 | elicitationMode: | 147 | elicitationMode: |
| 171 | elicitationMode === "browser" | 148 | elicitationMode === "browser" |
| 172 | ? { | 149 | ? { |
| @@ -197,26 +174,29 @@ export const createMcpRequestHandler = (config: ExecutorMcpServerConfig): McpReq · 24 unmodified lines | |||
| 197 | } | 174 | } |
| 198 | }, | 175 | }, |
| 199 | 176 | ||
| 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 | |||
| 200 | handleApprovalRequest: async (request) => { | 187 | handleApprovalRequest: async (request) => { |
| 201 | const url = new URL(request.url); | 188 | const match = approvalRequestPattern.exec(new URL(request.url).pathname); |
| 202 | const match = approvalRequestPattern.exec(url.pathname); | ||
| 203 | if (!match) return json({ error: "Not found" }, 404); | 189 | if (!match) return json({ error: "Not found" }, 404); |
| 204 | if (request.method !== "POST") return json({ error: "Method not allowed" }, 405); | 190 | if (request.method !== "POST") return json({ error: "Method not allowed" }, 405); |
| 205 | 191 | ||
| 206 | const sessionId = decodeURIComponent(match[1]); | ||
| 207 | const executionId = decodeURIComponent(match[2]); | 192 | 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); | ||
| 209 | 195 | ||
| 210 | const response = await readResumeResponse(request); | 196 | const response = await readResumeResponse(request); |
| 211 | if (!response) return json({ error: "Invalid approval response" }, 400); | 197 | if (!response) return json({ error: "Invalid approval response" }, 400); |
| 212 | 198 | ||
| 213 | const sessionApprovals = | 199 | await Effect.runPromise(approvals.recordResponse(executionId, response)); |
| 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 | |||
| 220 | return json(resumeApprovalResult(executionId, response)); | 200 | return json(resumeApprovalResult(executionId, response)); |
| 221 | }, | 201 | }, |
| 222 | 202 | ||
| @@ -21,6 +21,7 @@ const startTestServer = async (): Promise<string> => { · 20 unmodified lines | |||
| 21 | mcp: { | 21 | mcp: { |
| 22 | handleRequest: async () => new Response("ok"), | 22 | handleRequest: async () => new Response("ok"), |
| 23 | handleApprovalRequest: async () => new Response("ok"), | 23 | handleApprovalRequest: async () => new Response("ok"), |
| 24 | handlePausedRequest: async () => new Response("ok"), | ||
| 24 | close: async () => {}, | 25 | close: async () => {}, |
| 25 | }, | 26 | }, |
| 26 | }, | 27 | }, |
| @@ -80,6 +81,7 @@ describe("startServer network bind auth", () => { · 53 unmodified lines | |||
| 80 | mcp: { | 81 | mcp: { |
| 81 | handleRequest: async () => new Response("ok"), | 82 | handleRequest: async () => new Response("ok"), |
| 82 | handleApprovalRequest: async () => new Response("ok"), | 83 | handleApprovalRequest: async () => new Response("ok"), |
| 84 | handlePausedRequest: async () => new Response("ok"), | ||
| 83 | close: async () => {}, | 85 | close: async () => {}, |
| 84 | }, | 86 | }, |
| 85 | }, | 87 | }, |
| @@ -101,6 +103,7 @@ describe("startServer network bind auth", () => { · 15 unmodified lines | |||
| 101 | mcp: { | 103 | mcp: { |
| 102 | handleRequest: async () => new Response("ok"), | 104 | handleRequest: async () => new Response("ok"), |
| 103 | handleApprovalRequest: async () => new Response("ok"), | 105 | handleApprovalRequest: async () => new Response("ok"), |
| 106 | handlePausedRequest: async () => new Response("ok"), | ||
| 104 | close: async () => {}, | 107 | close: async () => {}, |
| 105 | }, | 108 | }, |
| 106 | }, | 109 | }, |
| @@ -131,6 +134,7 @@ describe("startServer network bind auth", () => { · 24 unmodified lines | |||
| 131 | mcp: { | 134 | mcp: { |
| 132 | handleRequest: async () => new Response("ok"), | 135 | handleRequest: async () => new Response("ok"), |
| 133 | handleApprovalRequest: async () => new Response("ok"), | 136 | handleApprovalRequest: async () => new Response("ok"), |
| 137 | handlePausedRequest: async () => new Response("ok"), | ||
| 134 | close: async () => {}, | 138 | close: async () => {}, |
| 135 | }, | 139 | }, |
| 136 | }, | 140 | }, |
| @@ -166,6 +170,7 @@ describe("startServer network bind auth", () => { · 29 unmodified lines | |||
| 166 | mcp: { | 170 | mcp: { |
| 167 | handleRequest: async () => new Response("ok"), | 171 | handleRequest: async () => new Response("ok"), |
| 168 | handleApprovalRequest: async () => new Response("ok"), | 172 | handleApprovalRequest: async () => new Response("ok"), |
| 173 | handlePausedRequest: async () => new Response("ok"), | ||
| 169 | close: async () => {}, | 174 | close: async () => {}, |
| 170 | }, | 175 | }, |
| 171 | }, | 176 | }, |
| @@ -326,7 +326,11 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server · 325 unmodified lines | |||
| 326 | } | 326 | } |
| 327 | 327 | ||
| 328 | if (url.pathname.startsWith("/api/mcp-sessions/")) { | 328 | 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)); | ||
| 330 | } | 334 | } |
| 331 | 335 | ||
| 332 | // OAuth result polling — local-only, served outside the typed API | 336 | // OAuth result polling — local-only, served outside the typed API |
| @@ -12,9 +12,10 @@ · 11 unmodified lines | |||
| 12 | // The policy is removed in an `ensuring` finalizer — a leaked require_approval | 12 | // The policy is removed in an `ensuring` finalizer — a leaked require_approval |
| 13 | // gate on a shared built-in tool would pause unrelated scenarios. | 13 | // gate on a shared built-in tool would pause unrelated scenarios. |
| 14 | // | 14 | // |
| 15 | // Lives under cloud/ for now because cloud is the only host wired for browser | 15 | // Cross-target: runs on every host that wires browser approval (cloud's Durable |
| 16 | // approval; it moves to scenarios/ (cross-target) as self-host and Cloudflare | 16 | // Object, self-host's in-process store, Cloudflare's DO). The host differences — |
| 17 | // gain the feature. | 17 | // where the approval URL points, which engine holds the pause — are invisible |
| 18 | // here; the scenario only drives the rendered console page. | ||
| 18 | import { expect } from "@effect/vitest"; | 19 | import { expect } from "@effect/vitest"; |
| 19 | import { Effect } from "effect"; | 20 | import { Effect } from "effect"; |
| 20 | import { composePluginApi } from "@executor-js/api/server"; | 21 | import { composePluginApi } from "@executor-js/api/server"; |
| @@ -4,6 +4,7 @@ import { McpErrorReporter, type Principal } from "@executor-js/host-mcp"; · 3 unmodified lines | |||
| 4 | import { | 4 | import { |
| 5 | McpEngineBuildError, | 5 | McpEngineBuildError, |
| 6 | type McpBuildServer, | 6 | type McpBuildServer, |
| 7 | type McpBuildServerOptions, | ||
| 7 | } from "@executor-js/host-mcp/in-memory-session-store"; | 8 | } from "@executor-js/host-mcp/in-memory-session-store"; |
| 8 | import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; | 9 | import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; |
| 9 | 10 | ||
| @@ -37,7 +38,7 @@ export type McpExecutionStackLayer = Layer.Layer< · 27 unmodified lines | |||
| 37 | */ | 38 | */ |
| 38 | export const makeMcpBuildServer = | 39 | export const makeMcpBuildServer = |
| 39 | (executionStack: McpExecutionStackLayer): McpBuildServer => | 40 | (executionStack: McpExecutionStackLayer): McpBuildServer => |
| 40 | (principal: Principal) => | 41 | (principal: Principal, options?: McpBuildServerOptions) => |
| 41 | makeExecutionStack( | 42 | makeExecutionStack( |
| 42 | principal.accountId, | 43 | principal.accountId, |
| 43 | principal.organizationId, | 44 | principal.organizationId, |
| @@ -46,7 +47,11 @@ export const makeMcpBuildServer = · 2 unmodified lines | |||
| 46 | Effect.map(({ engine }) => engine), | 47 | Effect.map(({ engine }) => engine), |
| 47 | Effect.provide(executionStack), | 48 | Effect.provide(executionStack), |
| 48 | Effect.mapError((cause) => new McpEngineBuildError({ cause })), | 49 | 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 | ), | ||
| 50 | ); | 55 | ); |
| 51 | 56 | ||
| 52 | /** | 57 | /** |
| @@ -19,6 +19,10 @@ · 18 unmodified lines | |||
| 19 | "./browser-approval": { | 19 | "./browser-approval": { |
| 20 | "types": "./src/browser-approval.ts", | 20 | "types": "./src/browser-approval.ts", |
| 21 | "default": "./src/browser-approval.ts" | 21 | "default": "./src/browser-approval.ts" |
| 22 | }, | ||
| 23 | "./browser-approval-store": { | ||
| 24 | "types": "./src/browser-approval-store.ts", | ||
| 25 | "default": "./src/browser-approval-store.ts" | ||
| 22 | } | 26 | } |
| 23 | }, | 27 | }, |
| 24 | "scripts": { | 28 | "scripts": { |
| @@ -0,0 +1,71 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // In-process browser-approval store — the single-process equivalent of the | ||
| 3 | // Durable Object's persisted approval responses (apps/cloud, host-cloudflare). | ||
| 4 | // | ||
| 5 | // It is the bridge between the two halves of a browser approval: | ||
| 6 | // - the MCP `resume` tool long-polls `store.waitForResponse(executionId)`, | ||
| 7 | // - the HTTP approval endpoint records the human's decision via | ||
| 8 | // `recordResponse(executionId, response)`, which wakes that waiter. | ||
| 9 | // | ||
| 10 | // Keyed by executionId alone — execution ids are unique per execution, so one | ||
| 11 | // store serves every session in the process. The in-memory MCP session store | ||
| 12 | // and the local app both build on it. | ||
| 13 | // --------------------------------------------------------------------------- | ||
| 14 | |||
| 15 | import { Deferred, Effect } from "effect"; | ||
| 16 | |||
| 17 | import type { ResumeResponse } from "@executor-js/execution"; | ||
| 18 | |||
| 19 | import type { BrowserApprovalStore } from "./tool-server"; | ||
| 20 | |||
| 21 | export interface InProcessBrowserApprovalStore { | ||
| 22 | /** The store the MCP server awaits a decision on (browser elicitation mode). */ | ||
| 23 | readonly store: BrowserApprovalStore; | ||
| 24 | /** Record a human's decision, waking any in-flight `waitForResponse`. */ | ||
| 25 | readonly recordResponse: (executionId: string, response: ResumeResponse) => Effect.Effect<void>; | ||
| 26 | /** Drop a pending decision/waiter (e.g. when its session is torn down). */ | ||
| 27 | readonly forget: (executionId: string) => void; | ||
| 28 | } | ||
| 29 | |||
| 30 | export const makeInProcessBrowserApprovalStore = (): InProcessBrowserApprovalStore => { | ||
| 31 | const responses = new Map<string, ResumeResponse>(); | ||
| 32 | const waiters = new Map<string, Deferred.Deferred<ResumeResponse>>(); | ||
| 33 | |||
| 34 | const take = (executionId: string): Effect.Effect<ResumeResponse | null> => | ||
| 35 | Effect.sync(() => { | ||
| 36 | const response = responses.get(executionId) ?? null; | ||
| 37 | if (response) responses.delete(executionId); | ||
| 38 | return response; | ||
| 39 | }); | ||
| 40 | |||
| 41 | const waitFor = (executionId: string): Effect.Effect<ResumeResponse | null> => | ||
| 42 | Effect.gen(function* () { | ||
| 43 | const existing = yield* take(executionId); | ||
| 44 | if (existing) return existing; | ||
| 45 | |||
| 46 | const waiter = waiters.get(executionId) ?? (yield* Deferred.make<ResumeResponse>()); | ||
| 47 | waiters.set(executionId, waiter); | ||
| 48 | yield* Deferred.await(waiter).pipe( | ||
| 49 | Effect.ensuring( | ||
| 50 | Effect.sync(() => { | ||
| 51 | if (waiters.get(executionId) === waiter) waiters.delete(executionId); | ||
| 52 | }), | ||
| 53 | ), | ||
| 54 | ); | ||
| 55 | return yield* take(executionId); | ||
| 56 | }); | ||
| 57 | |||
| 58 | return { | ||
| 59 | store: { takeResponse: take, waitForResponse: waitFor }, | ||
| 60 | recordResponse: (executionId, response) => | ||
| 61 | Effect.gen(function* () { | ||
| 62 | responses.set(executionId, response); | ||
| 63 | const waiter = waiters.get(executionId); | ||
| 64 | if (waiter) yield* Deferred.succeed(waiter, response); | ||
| 65 | }), | ||
| 66 | forget: (executionId) => { | ||
| 67 | responses.delete(executionId); | ||
| 68 | waiters.delete(executionId); | ||
| 69 | }, | ||
| 70 | }; | ||
| 71 | }; | ||
| @@ -1,7 +1,19 @@ | |||
| 1 | import { Data, Effect, Layer } from "effect"; | 1 | import { Cause, Data, Effect, Layer } from "effect"; |
| 2 | import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | 2 | import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; |
| 3 | import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; | 3 | import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; |
| 4 | 4 | ||
| 5 | import { formatPausedExecution, type ExecutionEngine } from "@executor-js/execution"; | ||
| 6 | |||
| 7 | import { | ||
| 8 | approvalUrlForRequest, | ||
| 9 | decodeResumeResponse, | ||
| 10 | formatResumeAcknowledgement, | ||
| 11 | readElicitationMode, | ||
| 12 | } from "./browser-approval"; | ||
| 13 | import { | ||
| 14 | makeInProcessBrowserApprovalStore, | ||
| 15 | type InProcessBrowserApprovalStore, | ||
| 16 | } from "./browser-approval-store"; | ||
| 5 | import { jsonRpcErrorBody } from "./envelope"; | 17 | import { jsonRpcErrorBody } from "./envelope"; |
| 6 | import { | 18 | import { |
| 7 | McpSessionStore, | 19 | McpSessionStore, |
| @@ -10,23 +22,26 @@ import { · 2 unmodified lines | |||
| 10 | type McpDispatchResult, | 22 | type McpDispatchResult, |
| 11 | type Principal, | 23 | type Principal, |
| 12 | } from "./seams"; | 24 | } from "./seams"; |
| 25 | import type { BrowserApprovalStore } from "./tool-server"; | ||
| 13 | 26 | ||
| 14 | // --------------------------------------------------------------------------- | 27 | // --------------------------------------------------------------------------- |
| 15 | // In-process McpSessionStore — the single-node serving store, shared by every | 28 | // In-process McpSessionStore — the single-node serving store, shared by every |
| 16 | // host that has no cross-isolate session backend (self-host, the Cloudflare | 29 | // host that has no cross-isolate session backend (self-host, the local app). |
| 17 | // QuickJS host). Cloud's Durable Object store is the cross-isolate variant of | 30 | // Cloud's Durable Object store is the cross-isolate variant of the same |
| 18 | // the same `McpSessionStore` seam. | 31 | // `McpSessionStore` seam. |
| 19 | // | 32 | // |
| 20 | // In the two-seam envelope the store owns the ENTIRE session lifecycle via | 33 | // In the two-seam envelope the store owns the ENTIRE session lifecycle via |
| 21 | // `dispatch`: create (no session id + POST initialize), forward (session id | 34 | // `dispatch`: create (no session id + POST initialize), forward (session id |
| 22 | // present), and ownership (cross-bearer). Three Maps keyed by mcp-session-id — | 35 | // present), and ownership (cross-bearer). Maps keyed by mcp-session-id hold the |
| 23 | // transports, servers, owners — hold the live in-process sessions. Closing a | 36 | // live in-process sessions: transports, servers, owners, and — for the browser |
| 24 | // session is just closing its transport + server. | 37 | // approval flow — the per-session engines. |
| 25 | // | 38 | // |
| 26 | // The engine is a store implementation detail, not an envelope seam: the store | 39 | // Browser approval: when the create request carries `?elicitation_mode=browser`, |
| 27 | // builds each per-session `McpServer` through the host-supplied `buildServer` | 40 | // the store builds the session's server in browser mode (an `approvalUrl` + the |
| 28 | // (the host's execution stack over its own DB + code substrate). The two-seam | 41 | // shared in-process approval store) and keeps the session's engine so the HTTP |
| 29 | // envelope has no engine seam — the store owns engine construction. | 42 | // approval endpoints (`handlePausedRequest` / `handleApprovalRequest`) can read |
| 43 | // the paused execution and record the human's decision. The Durable Object | ||
| 44 | // hosts do the equivalent with `ctx.storage`. | ||
| 30 | // | 45 | // |
| 31 | // `dispatch` returns the transport `Response` to pass through, or: | 46 | // `dispatch` returns the transport `Response` to pass through, or: |
| 32 | // - "not-found" (unknown session id) -> envelope renders 404 -32001 | 47 | // - "not-found" (unknown session id) -> envelope renders 404 -32001 |
| @@ -38,14 +53,42 @@ export class McpEngineBuildError extends Data.TaggedError("McpEngineBuildError") · 5 unmodified lines | |||
| 38 | readonly cause: unknown; | 53 | readonly cause: unknown; |
| 39 | }> {} | 54 | }> {} |
| 40 | 55 | ||
| 41 | /** Build the per-session `McpServer` for a principal (the host's engine + tools). */ | 56 | /** The connected MCP server plus the engine the approval endpoints drive. */ |
| 57 | export interface BuiltMcpServer { | ||
| 58 | readonly mcpServer: McpServer; | ||
| 59 | readonly engine: ExecutionEngine<Cause.YieldableError>; | ||
| 60 | } | ||
| 61 | |||
| 62 | /** The browser-mode wiring the store hands a build call when a session opts in. */ | ||
| 63 | export interface McpBuildServerOptions { | ||
| 64 | readonly elicitationMode?: | ||
| 65 | | { readonly mode: "browser"; readonly approvalUrl: (executionId: string) => string } | ||
| 66 | | { readonly mode: "model" } | ||
| 67 | | { readonly mode: "native" }; | ||
| 68 | readonly browserApprovalStore?: BrowserApprovalStore; | ||
| 69 | } | ||
| 70 | |||
| 71 | /** Build the per-session `McpServer` + engine for a principal (the host's engine + tools). */ | ||
| 42 | export type McpBuildServer = ( | 72 | export type McpBuildServer = ( |
| 43 | principal: Principal, | 73 | principal: Principal, |
| 44 | ) => Effect.Effect<McpServer, McpEngineBuildError>; | 74 | options?: McpBuildServerOptions, |
| 75 | ) => Effect.Effect<BuiltMcpServer, McpEngineBuildError>; | ||
| 45 | 76 | ||
| 46 | export interface InMemoryMcpSessionStore { | 77 | export interface InMemoryMcpSessionStore { |
| 47 | /** The `McpSessionStore` seam value to hand to `inMemoryMcpSessionsLayer`. */ | 78 | /** The `McpSessionStore` seam value to hand to `inMemoryMcpSessionsLayer`. */ |
| 48 | readonly store: McpSessionStore["Service"]; | 79 | readonly store: McpSessionStore["Service"]; |
| 80 | /** | ||
| 81 | * Serve `GET /api/mcp-sessions/:sessionId/executions/:executionId` — the | ||
| 82 | * paused-execution detail the console approval page renders. Returns the | ||
| 83 | * paused `{ text, structured }` or a 404. Null if the path does not match. | ||
| 84 | */ | ||
| 85 | readonly handlePausedRequest: (request: Request) => Promise<Response | null>; | ||
| 86 | /** | ||
| 87 | * Serve `POST /api/mcp-sessions/:sessionId/executions/:executionId/resume` — | ||
| 88 | * record the human's decision and wake the long-polling `resume` tool call. | ||
| 89 | * Null if the path does not match. | ||
| 90 | */ | ||
| 91 | readonly handleApprovalRequest: (request: Request) => Promise<Response | null>; | ||
| 49 | /** Dispose every live session — wire into the host's shutdown (not a seam). */ | 92 | /** Dispose every live session — wire into the host's shutdown (not a seam). */ |
| 50 | readonly close: () => Promise<void>; | 93 | readonly close: () => Promise<void>; |
| 51 | } | 94 | } |
| @@ -65,6 +108,12 @@ const formatBoundaryError = (error: unknown): unknown => · 13 unmodified lines | |||
| 65 | const jsonRpcError = (status: number, code: number, message: string): Response => | 108 | const jsonRpcError = (status: number, code: number, message: string): Response => |
| 66 | jsonRpcErrorBody(status, code, message, { cors: false }); | 109 | jsonRpcErrorBody(status, code, message, { cors: false }); |
| 67 | 110 | ||
| 111 | const json = (value: unknown, status = 200): Response => | ||
| 112 | new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } }); | ||
| 113 | |||
| 114 | const PAUSED_PATH = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)$/; | ||
| 115 | const RESUME_PATH = /^\/api\/mcp-sessions\/([^/?#]+)\/executions\/([^/?#]+)\/resume$/; | ||
| 116 | |||
| 68 | /** | 117 | /** |
| 69 | * Build the in-process session store plus an explicit `close()` that disposes | 118 | * Build the in-process session store plus an explicit `close()` that disposes |
| 70 | * all live sessions. `close()` is not part of the seam — it is the host lifetime | 119 | * all live sessions. `close()` is not part of the seam — it is the host lifetime |
| @@ -77,6 +126,8 @@ export const makeInMemoryMcpSessionStore = ( · 6 unmodified lines | |||
| 77 | const transports = new Map<string, WebStandardStreamableHTTPServerTransport>(); | 126 | const transports = new Map<string, WebStandardStreamableHTTPServerTransport>(); |
| 78 | const servers = new Map<string, McpServer>(); | 127 | const servers = new Map<string, McpServer>(); |
| 79 | const owners = new Map<string, Principal>(); | 128 | const owners = new Map<string, Principal>(); |
| 129 | const engines = new Map<string, ExecutionEngine<Cause.YieldableError>>(); | ||
| 130 | const approvals: InProcessBrowserApprovalStore = makeInProcessBrowserApprovalStore(); | ||
| 80 | 131 | ||
| 81 | const dispose = async (id: string, opts: { transport?: boolean; server?: boolean } = {}) => { | 132 | const dispose = async (id: string, opts: { transport?: boolean; server?: boolean } = {}) => { |
| 82 | const transport = transports.get(id); | 133 | const transport = transports.get(id); |
| @@ -84,6 +135,7 @@ export const makeInMemoryMcpSessionStore = ( · 1 unmodified line | |||
| 84 | transports.delete(id); | 135 | transports.delete(id); |
| 85 | servers.delete(id); | 136 | servers.delete(id); |
| 86 | owners.delete(id); | 137 | owners.delete(id); |
| 138 | engines.delete(id); | ||
| 87 | if (opts.transport) await ignoreClose(transport ? () => transport.close() : undefined); | 139 | if (opts.transport) await ignoreClose(transport ? () => transport.close() : undefined); |
| 88 | if (opts.server) await ignoreClose(server ? () => server.close() : undefined); | 140 | if (opts.server) await ignoreClose(server ? () => server.close() : undefined); |
| 89 | }; | 141 | }; |
| @@ -126,18 +178,44 @@ export const makeInMemoryMcpSessionStore = ( · 36 unmodified lines | |||
| 126 | return runHandleRequest(transport, request); | 178 | return runHandleRequest(transport, request); |
| 127 | }; | 179 | }; |
| 128 | 180 | ||
| 181 | /** | ||
| 182 | * The browser-mode wiring for a create request: when the client asks for | ||
| 183 | * `elicitation_mode=browser`, build the server with an `approvalUrl` (anchored | ||
| 184 | * at the request origin + the session id, minted on initialize) and the shared | ||
| 185 | * approval store. Otherwise pass the bare model/native mode through. | ||
| 186 | */ | ||
| 187 | const buildOptionsFor = ( | ||
| 188 | request: Request, | ||
| 189 | sessionId: () => string | null, | ||
| 190 | ): McpBuildServerOptions => { | ||
| 191 | if (readElicitationMode(request) !== "browser") return { elicitationMode: { mode: "model" } }; | ||
| 192 | return { | ||
| 193 | elicitationMode: { | ||
| 194 | mode: "browser", | ||
| 195 | approvalUrl: (executionId) => approvalUrlForRequest(request, executionId, sessionId()), | ||
| 196 | }, | ||
| 197 | browserApprovalStore: approvals.store, | ||
| 198 | }; | ||
| 199 | }; | ||
| 200 | |||
| 129 | /** Open a new session: build the server, connect a transport, drive the request. */ | 201 | /** Open a new session: build the server, connect a transport, drive the request. */ |
| 130 | const create = (principal: Principal, request: Request): Effect.Effect<McpDispatchResult> => | 202 | const create = (principal: Principal, request: Request): Effect.Effect<McpDispatchResult> => { |
| 131 | buildServer(principal).pipe( | 203 | let createdSessionId: string | null = null; |
| 132 | Effect.flatMap((server) => | 204 | return buildServer( |
| 205 | principal, | ||
| 206 | buildOptionsFor(request, () => createdSessionId), | ||
| 207 | ).pipe( | ||
| 208 | Effect.flatMap(({ mcpServer, engine }) => | ||
| 133 | Effect.gen(function* () { | 209 | Effect.gen(function* () { |
| 134 | const transport = new WebStandardStreamableHTTPServerTransport({ | 210 | const transport = new WebStandardStreamableHTTPServerTransport({ |
| 135 | sessionIdGenerator: () => crypto.randomUUID(), | 211 | sessionIdGenerator: () => crypto.randomUUID(), |
| 136 | enableJsonResponse: true, | 212 | enableJsonResponse: true, |
| 137 | onsessioninitialized: (sid) => { | 213 | onsessioninitialized: (sid) => { |
| 214 | createdSessionId = sid; | ||
| 138 | transports.set(sid, transport); | 215 | transports.set(sid, transport); |
| 139 | servers.set(sid, server); | 216 | servers.set(sid, mcpServer); |
| 140 | owners.set(sid, principal); | 217 | owners.set(sid, principal); |
| 218 | engines.set(sid, engine); | ||
| 141 | }, | 219 | }, |
| 142 | onsessionclosed: (sid) => void dispose(sid, { server: true }), | 220 | onsessionclosed: (sid) => void dispose(sid, { server: true }), |
| 143 | }); | 221 | }); |
| @@ -145,12 +223,12 @@ export const makeInMemoryMcpSessionStore = ( · 1 unmodified line | |||
| 145 | const sid = transport.sessionId; | 223 | const sid = transport.sessionId; |
| 146 | if (sid) void dispose(sid, { server: true }); | 224 | if (sid) void dispose(sid, { server: true }); |
| 147 | }; | 225 | }; |
| 148 | yield* Effect.promise(() => server.connect(transport)); | 226 | yield* Effect.promise(() => mcpServer.connect(transport)); |
| 149 | // The session id is minted on the first (initialize) request, so we | 227 | // The session id is minted on the first (initialize) request, so we |
| 150 | // drive `handleRequest` here; if no id results we close eagerly. | 228 | // drive `handleRequest` here; if no id results we close eagerly. |
| 151 | return yield* runHandleRequest(transport, request, () => { | 229 | return yield* runHandleRequest(transport, request, () => { |
| 152 | void ignoreClose(() => transport.close()); | 230 | void ignoreClose(() => transport.close()); |
| 153 | void ignoreClose(() => server.close()); | 231 | void ignoreClose(() => mcpServer.close()); |
| 154 | }); | 232 | }); |
| 155 | }), | 233 | }), |
| 156 | ), | 234 | ), |
| @@ -159,6 +237,7 @@ export const makeInMemoryMcpSessionStore = ( · 2 unmodified lines | |||
| 159 | Effect.succeed(jsonRpcError(500, -32603, "Internal server error")), | 237 | Effect.succeed(jsonRpcError(500, -32603, "Internal server error")), |
| 160 | ), | 238 | ), |
| 161 | ); | 239 | ); |
| 240 | }; | ||
| 162 | 241 | ||
| 163 | const store: McpSessionStore["Service"] = { | 242 | const store: McpSessionStore["Service"] = { |
| 164 | dispatch: ({ request, principal, sessionId }: McpDispatchInput) => | 243 | dispatch: ({ request, principal, sessionId }: McpDispatchInput) => |
| @@ -167,8 +246,65 @@ export const makeInMemoryMcpSessionStore = ( · 2 unmodified lines | |||
| 167 | Effect.promise(() => dispose(sessionId, { transport: true, server: true })), | 246 | Effect.promise(() => dispose(sessionId, { transport: true, server: true })), |
| 168 | }; | 247 | }; |
| 169 | 248 | ||
| 249 | /** Resolve a paused execution from the session that owns it, for HTTP approval. */ | ||
| 250 | const pausedFromSession = ( | ||
| 251 | sessionId: string, | ||
| 252 | executionId: string, | ||
| 253 | ): Promise<ReturnType<typeof formatPausedExecution> | null> => { | ||
| 254 | const engine = engines.get(sessionId); | ||
| 255 | if (!engine) return Promise.resolve(null); | ||
| 256 | return Effect.runPromise( | ||
| 257 | engine.getPausedExecution(executionId).pipe( | ||
| 258 | Effect.map((paused) => (paused ? formatPausedExecution(paused) : null)), | ||
| 259 | Effect.orElseSucceed(() => null), | ||
| 260 | ), | ||
| 261 | ); | ||
| 262 | }; | ||
| 263 | |||
| 264 | const handlePausedRequest = async (request: Request): Promise<Response | null> => { | ||
| 265 | const match = PAUSED_PATH.exec(new URL(request.url).pathname); | ||
| 266 | if (!match) return null; | ||
| 267 | if (request.method !== "GET") return json({ error: "Method not allowed" }, 405); | ||
| 268 | const paused = await pausedFromSession( | ||
| 269 | decodeURIComponent(match[1]!), | ||
| 270 | decodeURIComponent(match[2]!), | ||
| 271 | ); | ||
| 272 | if (!paused) return json({ error: "Paused execution not found" }, 404); | ||
| 273 | return json({ text: paused.text, structured: paused.structured }); | ||
| 274 | }; | ||
| 275 | |||
| 276 | const handleApprovalRequest = async (request: Request): Promise<Response | null> => { | ||
| 277 | const match = RESUME_PATH.exec(new URL(request.url).pathname); | ||
| 278 | if (!match) return null; | ||
| 279 | if (request.method !== "POST") return json({ error: "Method not allowed" }, 405); | ||
| 280 | |||
| 281 | const sessionId = decodeURIComponent(match[1]!); | ||
| 282 | const executionId = decodeURIComponent(match[2]!); | ||
| 283 | // The session must still hold the paused execution — guards stale ids and | ||
| 284 | // confirms the execution belongs to this session before recording. | ||
| 285 | const paused = await pausedFromSession(sessionId, executionId); | ||
| 286 | if (!paused) return json({ error: "Paused execution not found" }, 404); | ||
| 287 | |||
| 288 | const raw = await Effect.runPromise( | ||
| 289 | Effect.tryPromise({ try: () => request.json(), catch: () => null }).pipe( | ||
| 290 | Effect.orElseSucceed(() => null), | ||
| 291 | ), | ||
| 292 | ); | ||
| 293 | const response = raw === null ? null : decodeResumeResponse(raw); | ||
| 294 | if (!response) return json({ error: "Invalid approval response" }, 400); | ||
| 295 | |||
| 296 | await Effect.runPromise(approvals.recordResponse(executionId, response)); | ||
| 297 | return json({ | ||
| 298 | status: "completed", | ||
| 299 | ...formatResumeAcknowledgement(executionId, response), | ||
| 300 | isError: false, | ||
| 301 | }); | ||
| 302 | }; | ||
| 303 | |||
| 170 | return { | 304 | return { |
| 171 | store, | 305 | store, |
| 306 | handlePausedRequest, | ||
| 307 | handleApprovalRequest, | ||
| 172 | close: async () => { | 308 | close: async () => { |
| 173 | const ids = new Set([...transports.keys(), ...servers.keys()]); | 309 | const ids = new Set([...transports.keys(), ...servers.keys()]); |
| 174 | await Promise.all([...ids].map((id) => dispose(id, { transport: true, server: true }))); | 310 | await Promise.all([...ids].map((id) => dispose(id, { transport: true, server: true }))); |
| @@ -5,7 +5,6 @@ import * as Atom from "effect/unstable/reactivity/Atom"; · 4 unmodified lines | |||
| 5 | import { createFileRoute } from "@tanstack/react-router"; | 5 | import { createFileRoute } from "@tanstack/react-router"; |
| 6 | 6 | ||
| 7 | import { ResumeApprovalPage, ResumeApprovalPageView } from "../pages/resume-approval"; | 7 | import { ResumeApprovalPage, ResumeApprovalPageView } from "../pages/resume-approval"; |
| 8 | import { pausedExecutionAtom } from "../api/atoms"; | ||
| 9 | import type { ElicitationAction } from "../components/elicitation-approval"; | 8 | import type { ElicitationAction } from "../components/elicitation-approval"; |
| 10 | 9 | ||
| 11 | const SearchParams = Schema.toStandardSchemaV1( | 10 | const SearchParams = Schema.toStandardSchemaV1( |
| @@ -31,6 +30,48 @@ class LocalMcpResumeError extends Data.TaggedError("LocalMcpResumeError")<{ · 19 unmodified lines | |||
| 31 | readonly message: string; | 30 | readonly message: string; |
| 32 | }> {} | 31 | }> {} |
| 33 | 32 | ||
| 33 | const McpPausedExecutionInfo = Schema.Struct({ | ||
| 34 | text: Schema.String, | ||
| 35 | structured: Schema.Unknown, | ||
| 36 | }); | ||
| 37 | const decodeMcpPausedExecutionInfo = Schema.decodeUnknownOption(McpPausedExecutionInfo); | ||
| 38 | |||
| 39 | // Paused-execution detail for the in-process / Cloudflare hosts, fetched | ||
| 40 | // session-scoped: the MCP paused execution lives in its session's engine, so it | ||
| 41 | // is resolved through `/api/mcp-sessions/:id/...`, not the session-less | ||
| 42 | // `/api/executions/:id` (which hits a different engine and can't see it). Cloud | ||
| 43 | // serves the equivalent through its own route + Durable Object RPC. | ||
| 44 | const mcpPausedExecutionAtom = Atom.family( | ||
| 45 | (key: { readonly mcpSessionId: string; readonly executionId: string }) => | ||
| 46 | Atom.make( | ||
| 47 | Effect.gen(function* () { | ||
| 48 | const response = yield* Effect.tryPromise({ | ||
| 49 | try: () => | ||
| 50 | fetch( | ||
| 51 | `/api/mcp-sessions/${encodeURIComponent(key.mcpSessionId)}/executions/${encodeURIComponent(key.executionId)}`, | ||
| 52 | ), | ||
| 53 | catch: () => new LocalMcpResumeError({ message: "Failed to load the paused execution." }), | ||
| 54 | }); | ||
| 55 | if (!response.ok) { | ||
| 56 | return yield* new LocalMcpResumeError({ | ||
| 57 | message: `Paused execution unavailable (${response.status}).`, | ||
| 58 | }); | ||
| 59 | } | ||
| 60 | const body = yield* Effect.tryPromise({ | ||
| 61 | try: () => response.json(), | ||
| 62 | catch: () => new LocalMcpResumeError({ message: "Paused response was not valid JSON." }), | ||
| 63 | }); | ||
| 64 | const decoded = decodeMcpPausedExecutionInfo(body); | ||
| 65 | if (Option.isNone(decoded)) { | ||
| 66 | return yield* new LocalMcpResumeError({ | ||
| 67 | message: "Paused response had an unexpected shape.", | ||
| 68 | }); | ||
| 69 | } | ||
| 70 | return decoded.value; | ||
| 71 | }), | ||
| 72 | ), | ||
| 73 | ); | ||
| 74 | |||
| 34 | type LocalMcpResumeInput = { | 75 | type LocalMcpResumeInput = { |
| 35 | readonly mcpSessionId: string; | 76 | readonly mcpSessionId: string; |
| 36 | readonly executionId: string; | 77 | readonly executionId: string; |
| @@ -96,7 +137,9 @@ function RouteComponent() { · 59 unmodified lines | |||
| 96 | } | 137 | } |
| 97 | 138 | ||
| 98 | function LocalMcpResumeApproval(props: { executionId: string; mcpSessionId: string }) { | 139 | function LocalMcpResumeApproval(props: { executionId: string; mcpSessionId: string }) { |
| 99 | const paused = useAtomValue(pausedExecutionAtom(props.executionId)); | 140 | const paused = useAtomValue( |
| 141 | mcpPausedExecutionAtom({ mcpSessionId: props.mcpSessionId, executionId: props.executionId }), | ||
| 142 | ); | ||
| 100 | const doResume = useAtomSet(resumeLocalMcpExecution, { mode: "promiseExit" }); | 143 | const doResume = useAtomSet(resumeLocalMcpExecution, { mode: "promiseExit" }); |
| 101 | const resume = useCallback( | 144 | const resume = useCallback( |
| 102 | (executionId: string, action: ElicitationAction, content?: Record<string, unknown>) => | 145 | (executionId: string, action: ElicitationAction, content?: Record<string, unknown>) => |