// --------------------------------------------------------------------------- // Local app × MCP browser resume — real MCP execute + real approval API // --------------------------------------------------------------------------- // // Exercises the browser-approval shape used by CLI/MCP clients that do not // support managed MCP elicitation: // // test → MCP SDK Client.callTool("execute") → Executor MCP host // → execution engine → sandbox code → real executor tool elicitations // → local approval API records the user's response // → MCP resume consumes the approved response and returns the result // --------------------------------------------------------------------------- import { describe, expect, it } from "@effect/vitest"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { randomBytes } from "node:crypto"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Effect, Schema } from "effect"; import { createExecutionEngine } from "@executor-js/execution"; import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; import { collectTables } from "@executor-js/api/server"; import { FormElicitation, Subject, Tenant, createExecutor, definePlugin, type Executor, } from "@executor-js/sdk"; import { createMcpRequestHandler } from "./mcp"; import { createSqliteFumaDb } from "./db/sqlite-fumadb"; const TEST_BASE_URL = "http://local.test"; const EmptyInputSchema = Schema.toStandardSchemaV1( Schema.toStandardJSONSchemaV1(Schema.Struct({})), ); const approvalPlugin = definePlugin(() => ({ id: "browser-resume-test" as const, storage: () => ({}), staticIntegrations: () => [ { id: "api", kind: "in-memory", name: "API", tools: [ { name: "singleApproval", description: "Elicit exactly once and return the user's response.", inputSchema: EmptyInputSchema, handler: ({ elicit }) => Effect.gen(function* () { const response = yield* elicit( FormElicitation.make({ message: "Approve browser-resume test call", requestedSchema: { type: "object", properties: { note: { type: "string", title: "Approval note" }, }, required: ["note"], }, }), ); return { ok: true, response }; }), }, ], }, ], })); const makeExecutor = async (tmpDir: string): Promise => { const plugins = [approvalPlugin()] as const; const sqlite = await createSqliteFumaDb({ tables: collectTables(), namespace: "executor_local_browser_resume_test", path: join(tmpDir, "data.db"), }); const executor = await Effect.runPromise( createExecutor({ tenant: Tenant.make(`test-${randomBytes(4).toString("hex")}`), subject: Subject.make("local"), db: sqlite.db, plugins, onElicitation: "accept-all", }), ); const close = executor.close; return { ...executor, close: () => Effect.gen(function* () { yield* close(); yield* Effect.promise(() => sqlite.close()); }), }; }; const makeMcpFetch = (executor: Executor) => { const engine = createExecutionEngine({ executor, codeExecutor: makeQuickJsExecutor(), }); const mcp = createMcpRequestHandler({ engine }); const fetchImpl: typeof globalThis.fetch = Object.assign( (input: RequestInfo | URL, init?: RequestInit) => { const request = input instanceof Request ? input : new Request(input, init); const url = new URL(request.url); if (url.pathname.startsWith("/mcp")) return mcp.handleRequest(request); if (url.pathname.startsWith("/api/mcp-sessions/")) { return mcp.handleApprovalRequest(request); } return Promise.resolve(new Response("Not found", { status: 404 })); }, { preconnect: globalThis.fetch.preconnect }, ); return { fetch: fetchImpl, dispose: mcp.close }; }; const readStructuredRecord = (value: unknown): Record => { expect(typeof value).toBe("object"); expect(value).not.toBeNull(); return value as Record; }; const readApproval = (structured: unknown): { readonly executionId: string; readonly url: URL } => { const record = readStructuredRecord(structured); expect(record.status).toBe("user_approval_required"); expect(record).not.toHaveProperty("interaction"); expect(typeof record.executionId).toBe("string"); expect(typeof record.approvalUrl).toBe("string"); expect(record.resumePrompt).toBe( "Return text to the user telling them to approve the action at this approvalUrl. Only after you have prompted the user, call the `resume` tool with this executionId; `resume` will wait for the user's browser decision.", ); const { executionId, approvalUrl } = record as { readonly executionId: string; readonly approvalUrl: string; }; return { executionId, url: new URL(approvalUrl), }; }; const readResultArray = (structured: unknown): ReadonlyArray => { const record = readStructuredRecord(structured); expect(record.status).toBe("completed"); expect(Array.isArray(record.result)).toBe(true); return record.result as ReadonlyArray; }; describe("local MCP browser approval resume", () => { it("continues multiple elicitations from one MCP execute call through the HTTP API path", async () => { const tmpDir = mkdtempSync(join(tmpdir(), "executor-local-browser-resume-")); const executor = await makeExecutor(tmpDir); const { fetch, dispose } = makeMcpFetch(executor); const mcpClient = new Client( { name: "browser-resume-test-client", version: "1.0.0" }, { capabilities: {} }, ); const transport = new StreamableHTTPClientTransport( new URL("/mcp?elicitation_mode=browser", TEST_BASE_URL), { fetch }, ); await mcpClient.connect(transport); // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: test owns MCP transports, web handler, and executor lifecycle try { const first = await mcpClient.callTool({ name: "execute", arguments: { code: ` return await Promise.all([ tools.api.singleApproval({}), tools.api.singleApproval({}), tools.api.singleApproval({}) ]); `, }, }); expect(first.isError).toBeFalsy(); const firstApproval = readApproval(first.structuredContent); const second = await approveInBrowserThenResume(fetch, mcpClient, firstApproval); const secondApproval = readApproval(second.structuredContent); expect(secondApproval.executionId).not.toBe(firstApproval.executionId); const third = await approveInBrowserThenResume(fetch, mcpClient, secondApproval); const thirdApproval = readApproval(third.structuredContent); expect(thirdApproval.executionId).not.toBe(firstApproval.executionId); expect(thirdApproval.executionId).not.toBe(secondApproval.executionId); const completed = await approveInBrowserThenResume(fetch, mcpClient, thirdApproval); const result = readResultArray(completed.structuredContent); expect(result).toHaveLength(3); expect(result).toEqual( expect.arrayContaining([ expect.objectContaining({ data: expect.objectContaining({ response: expect.objectContaining({ action: "accept", content: expect.objectContaining({ note: expect.stringContaining("approved-"), }), }), }), }), ]), ); } finally { await mcpClient.close(); await Effect.runPromise(Effect.ignore(Effect.tryPromise(() => dispose()))); await Effect.runPromise( Effect.ignore(Effect.tryPromise(() => Effect.runPromise(executor.close()))), ); rmSync(tmpDir, { recursive: true, force: true }); } }, 10_000); }); const approveInBrowserThenResume = async ( fetch: typeof globalThis.fetch, client: Client, approval: { readonly executionId: string; readonly url: URL }, ) => { const sessionId = approval.url.searchParams.get("mcp_session_id"); expect(sessionId).not.toBeNull(); const resume = client.callTool({ name: "resume", arguments: { executionId: approval.executionId }, }); const approvalResponse = await fetch( new URL( `/api/mcp-sessions/${encodeURIComponent(sessionId!)}/executions/${encodeURIComponent(approval.executionId)}/resume`, TEST_BASE_URL, ), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action: "accept", content: { note: `approved-${approval.executionId}` }, }), }, ); expect(approvalResponse.status).toBe(200); return await resume; };