263 lines · 9.0 KB · TypeScript
RawDownload
// ---------------------------------------------------------------------------// 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: () => ({}),  staticSources: () => [    {      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<Executor> => {  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<string, unknown> => {  expect(typeof value).toBe("object");  expect(value).not.toBeNull();  return value as Record<string, unknown>;}; 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<unknown> => {  const record = readStructuredRecord(structured);  expect(record.status).toBe("completed");  expect(Array.isArray(record.result)).toBe(true);  return record.result as ReadonlyArray<unknown>;}; 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;};