| @@ -0,0 +1,221 @@ |
| | 1 | // End-to-end coverage for the cloud MCP server. |
| | 2 | // |
| | 3 | // The `McpSessionDO` in mcp-session.ts wires several things that previously |
| | 4 | // had zero integration coverage: |
| | 5 | // - `createScopedExecutor` against a real drizzle adapter (the 2026-04-16 |
| | 6 | // prod outage was a schema spread bug here; see services/db.schema.test.ts) |
| | 7 | // - `createExecutionEngine` with an in-process code executor |
| | 8 | // - `createExecutorMcpServer` for the MCP request surface |
| | 9 | // - Real `@modelcontextprotocol/sdk` Client → server round-trips |
| | 10 | // |
| | 11 | // This test replicates the DO's init path (minus the WorkerTransport and |
| | 12 | // Durable Object routing, which are thin CF plumbing) and drives it with a |
| | 13 | // real MCP Client over in-memory transports. If any of the wiring drifts — |
| | 14 | // schema, plugin list, engine contract, MCP handshake — these tests fail |
| | 15 | // before prod does. |
| | 16 | |
| | 17 | import { describe, expect, it } from "@effect/vitest"; |
| | 18 | import { Effect } from "effect"; |
| | 19 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; |
| | 20 | import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; |
| | 21 | import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; |
| | 22 | import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js"; |
| | 23 | |
| | 24 | import { createExecutorMcpServer } from "@executor/host-mcp"; |
| | 25 | import { createExecutionEngine } from "@executor/execution"; |
| | 26 | import { |
| | 27 | ElicitationResponse, |
| | 28 | FormElicitation, |
| | 29 | Scope, |
| | 30 | ScopeId, |
| | 31 | collectSchemas, |
| | 32 | createExecutor, |
| | 33 | definePlugin, |
| | 34 | } from "@executor/sdk"; |
| | 35 | import { |
| | 36 | makePostgresAdapter, |
| | 37 | makePostgresBlobStore, |
| | 38 | } from "@executor/storage-postgres"; |
| | 39 | import { openApiPlugin } from "@executor/plugin-openapi"; |
| | 40 | import { mcpPlugin } from "@executor/plugin-mcp"; |
| | 41 | import { graphqlPlugin } from "@executor/plugin-graphql"; |
| | 42 | import { workosVaultPlugin } from "@executor/plugin-workos-vault"; |
| | 43 | |
| | 44 | import { DbService } from "./services/db"; |
| | 45 | import { makeFakeVaultClient } from "./services/__test-harness__/api-harness"; |
| | 46 | |
| | 47 | // --------------------------------------------------------------------------- |
| | 48 | // Test-only plugin: exposes one in-memory tool that elicits once. Lets the |
| | 49 | // eliciting test drive the real engine + sandbox rather than a stub engine. |
| | 50 | // --------------------------------------------------------------------------- |
| | 51 | |
| | 52 | const EMPTY_INPUT_SCHEMA = { |
| | 53 | type: "object", |
| | 54 | properties: {}, |
| | 55 | additionalProperties: false, |
| | 56 | } as const; |
| | 57 | |
| | 58 | const elicitingTestPlugin = definePlugin(() => ({ |
| | 59 | id: "eliciting-test" as const, |
| | 60 | storage: () => ({}), |
| | 61 | staticSources: () => [ |
| | 62 | { |
| | 63 | id: "e2e", |
| | 64 | kind: "in-memory", |
| | 65 | name: "E2E Test", |
| | 66 | tools: [ |
| | 67 | { |
| | 68 | name: "needsApproval", |
| | 69 | description: "Tool that asks the caller to approve before returning.", |
| | 70 | inputSchema: EMPTY_INPUT_SCHEMA, |
| | 71 | handler: ({ elicit }: { elicit: (r: FormElicitation) => Effect.Effect<typeof ElicitationResponse.Type, unknown> }) => |
| | 72 | Effect.gen(function* () { |
| | 73 | const response = yield* elicit( |
| | 74 | new FormElicitation({ |
| | 75 | message: "Approve?", |
| | 76 | requestedSchema: { |
| | 77 | type: "object", |
| | 78 | properties: { approved: { type: "boolean" } }, |
| | 79 | }, |
| | 80 | }), |
| | 81 | ); |
| | 82 | return { action: response.action, content: response.content ?? null }; |
| | 83 | }).pipe(Effect.orDie), |
| | 84 | }, |
| | 85 | ], |
| | 86 | }, |
| | 87 | ], |
| | 88 | })); |
| | 89 | |
| | 90 | // --------------------------------------------------------------------------- |
| | 91 | // Session harness — mirrors McpSessionDO.init() minus the WorkerTransport |
| | 92 | // --------------------------------------------------------------------------- |
| | 93 | |
| | 94 | const ELICITATION_CAPS: ClientCapabilities = { |
| | 95 | elicitation: { form: {}, url: {} }, |
| | 96 | }; |
| | 97 | |
| | 98 | type BuildOptions = { readonly withElicitingPlugin?: boolean }; |
| | 99 | |
| | 100 | const buildScopedExecutor = ( |
| | 101 | scopeId: string, |
| | 102 | scopeName: string, |
| | 103 | options: BuildOptions = {}, |
| | 104 | ) => |
| | 105 | Effect.gen(function* () { |
| | 106 | const { db } = yield* DbService; |
| | 107 | const basePlugins = [ |
| | 108 | openApiPlugin(), |
| | 109 | mcpPlugin({ dangerouslyAllowStdioMCP: false }), |
| | 110 | graphqlPlugin(), |
| | 111 | workosVaultPlugin({ client: makeFakeVaultClient() }), |
| | 112 | ] as const; |
| | 113 | const plugins = options.withElicitingPlugin |
| | 114 | ? ([...basePlugins, elicitingTestPlugin()] as const) |
| | 115 | : basePlugins; |
| | 116 | const schema = collectSchemas(plugins); |
| | 117 | const adapter = makePostgresAdapter({ db, schema }); |
| | 118 | const blobs = makePostgresBlobStore({ db }); |
| | 119 | const scope = new Scope({ |
| | 120 | id: ScopeId.make(scopeId), |
| | 121 | name: scopeName, |
| | 122 | createdAt: new Date(), |
| | 123 | }); |
| | 124 | return yield* createExecutor({ scope, adapter, blobs, plugins }); |
| | 125 | }); |
| | 126 | |
| | 127 | type McpSession = { readonly client: Client }; |
| | 128 | |
| | 129 | // Builds a scope, wires a real execution engine + MCP server, and yields |
| | 130 | // them connected to an in-memory MCP client. Shaped as an acquireRelease so |
| | 131 | // the transport teardown is guaranteed when the test scope closes. |
| | 132 | const openSession = ( |
| | 133 | orgId: string, |
| | 134 | options: BuildOptions & { readonly caps?: ClientCapabilities } = {}, |
| | 135 | ) => |
| | 136 | Effect.acquireRelease( |
| | 137 | Effect.gen(function* () { |
| | 138 | const executor = yield* buildScopedExecutor(orgId, `Org ${orgId}`, options); |
| | 139 | const engine = createExecutionEngine({ executor }); |
| | 140 | const mcpServer = yield* Effect.promise(() => createExecutorMcpServer({ engine })); |
| | 141 | const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); |
| | 142 | const client = new Client( |
| | 143 | { name: "cloud-e2e-test", version: "1.0.0" }, |
| | 144 | { capabilities: options.caps ?? ELICITATION_CAPS }, |
| | 145 | ); |
| | 146 | yield* Effect.promise(() => mcpServer.connect(serverTransport)); |
| | 147 | yield* Effect.promise(() => client.connect(clientTransport)); |
| | 148 | return { client, clientTransport, serverTransport }; |
| | 149 | }), |
| | 150 | ({ clientTransport, serverTransport }) => |
| | 151 | Effect.promise(async () => { |
| | 152 | await clientTransport.close().catch(() => undefined); |
| | 153 | await serverTransport.close().catch(() => undefined); |
| | 154 | }), |
| | 155 | ).pipe(Effect.map(({ client }) => ({ client }))); |
| | 156 | |
| | 157 | const nextOrgId = (() => { |
| | 158 | let seq = 0; |
| | 159 | return () => `org_mcp_e2e_${++seq}_${crypto.randomUUID().slice(0, 8)}`; |
| | 160 | })(); |
| | 161 | |
| | 162 | // --------------------------------------------------------------------------- |
| | 163 | // Tests |
| | 164 | // --------------------------------------------------------------------------- |
| | 165 | |
| | 166 | describe("cloud MCP session end-to-end", () => { |
| | 167 | it.effect("initializes and exposes the execute tool to the MCP client", () => |
| | 168 | Effect.gen(function* () { |
| | 169 | const { client } = yield* openSession(nextOrgId()); |
| | 170 | const tools = yield* Effect.promise(() => client.listTools()); |
| | 171 | const names = tools.tools.map((t) => t.name); |
| | 172 | expect(names).toContain("execute"); |
| | 173 | }).pipe(Effect.provide(DbService.Live), Effect.scoped), |
| | 174 | ); |
| | 175 | |
| | 176 | it.effect("runs user code via the execute tool end-to-end", () => |
| | 177 | Effect.gen(function* () { |
| | 178 | const { client } = yield* openSession(nextOrgId()); |
| | 179 | const result = yield* Effect.promise(() => |
| | 180 | client.callTool({ name: "execute", arguments: { code: "return 1 + 2" } }), |
| | 181 | ); |
| | 182 | expect(result.isError).toBeFalsy(); |
| | 183 | const text = (result.content as Array<{ type: string; text: string }>)[0]!.text; |
| | 184 | expect(text).toContain("3"); |
| | 185 | }).pipe(Effect.provide(DbService.Live), Effect.scoped), |
| | 186 | ); |
| | 187 | |
| | 188 | // Isolates the drizzle adapter path so a schema spread drift surfaces as |
| | 189 | // a raw "unknown model" error. The prod outage on 2026-04-16 would have |
| | 190 | // thrown at `executor.sources.list()` when the MCP session's drizzle |
| | 191 | // instance lost the executor-schema tables. |
| | 192 | it.effect("exercises the drizzle adapter directly via executor.sources.list", () => |
| | 193 | Effect.gen(function* () { |
| | 194 | const executor = yield* buildScopedExecutor(nextOrgId(), "drizzle-probe"); |
| | 195 | const sources = yield* executor.sources.list(); |
| | 196 | expect(Array.isArray(sources)).toBe(true); |
| | 197 | }).pipe(Effect.provide(DbService.Live), Effect.scoped), |
| | 198 | ); |
| | 199 | |
| | 200 | it.effect("bridges a form elicitation from engine to client and back", () => |
| | 201 | Effect.gen(function* () { |
| | 202 | const { client } = yield* openSession(nextOrgId(), { withElicitingPlugin: true }); |
| | 203 | |
| | 204 | client.setRequestHandler(ElicitRequestSchema, async () => ({ |
| | 205 | action: "accept" as const, |
| | 206 | content: { approved: true }, |
| | 207 | })); |
| | 208 | |
| | 209 | const result = yield* Effect.promise(() => |
| | 210 | client.callTool({ |
| | 211 | name: "execute", |
| | 212 | arguments: { code: "return await tools.e2e.needsApproval({});" }, |
| | 213 | }), |
| | 214 | ); |
| | 215 | expect(result.isError).toBeFalsy(); |
| | 216 | const text = (result.content as Array<{ type: string; text: string }>)[0]!.text; |
| | 217 | expect(text).toContain("accept"); |
| | 218 | expect(text).toContain("approved"); |
| | 219 | }).pipe(Effect.provide(DbService.Live), Effect.scoped), |
| | 220 | ); |
| | 221 | }); |