|
| 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 | +}); |
0 commit comments