Testing the Cloud MCP Server
Three suites cover the cloud MCP surface. Each has a specific reason to exist; deleting one silently drops coverage that the others can't replace.
Suites at a glance
| file | pool | drives | what it proves |
|---|---|---|---|
apps/cloud/src/mcp-session.e2e.node.test.ts |
node | InMemoryTransport + SDK Client |
engine + plugin wiring; schema drift; elicitation semantics |
apps/cloud/src/mcp-flow.test.ts |
workerd (vitest-pool-workers) | SELF.fetch + hand-rolled JSON-RPC |
edge HTTP pipeline that does not require a live multi-request DO session |
apps/cloud/src/mcp-miniflare.e2e.node.test.ts |
node + miniflare-on-real-port | SDK Client + real StreamableHTTPClientTransport |
the long-lived-socket DO runtime actually works end-to-end; elicitation round-trips real HTTP |
The workerd cross-request I/O wall
Symptom. In vitest-pool-workers, the second request to a McpSessionDO
instance crashes with Cannot perform I/O on behalf of a different request (I/O type: RefcountedFulfiller).
Cause. postgres.js's Cloudflare Workers adapter creates socket callbacks
bound to the request context that opened the socket. The MCP session DO holds a
long-lived postgres connection across its lifetime. That's fine in prod (the
DO's own context outlives any single fetch handler) and fine under Miniflare on
a real port, but vitest-pool-workers enforces a strict cross-request I/O
check that prod doesn't.
Decision. Do not carry a request-scoped runtime branch in production code just to satisfy the workerd-pool limitation. The workerd-pool suite only covers edge behavior that does not require a live multi-request MCP session (CORS, auth failures, metadata, stale-session handling). The real-port Miniflare suite owns multi-request MCP session coverage because it exercises the same long-lived-postgres DO runtime path that ships to Cloudflare.
Miniflare harness gotchas
Use unstable_dev, not unstable_startWorker. unstable_startWorker's
esbuild pipeline errors on transitive deps (cross-spawn, mime-types,
isexe, which) with Could not resolve "path" / "fs" / "child_process"
even when nodejs_compat is enabled. unstable_dev handles them. Both boot
the worker on a real port via Miniflare internally; the bundling path differs.
Pin IPv4. node fetch resolves localhost to ::1 first on macOS;
miniflare binds only to IPv4. Set ip: "127.0.0.1" on unstable_dev and
construct the base URL from worker.address / worker.port.
Per-test timeout. @effect/vitest's layer(env, { timeout }) covers the
layer build but not individual it.effect cases. Pass 30_000 as the third
arg to each it.effect — real-HTTP tests through the DO take ~10s each
(postgres connect + isolate startup + SDK handshake).
Don't import test-worker.ts from node tests. test-worker.ts imports
./mcp which imports cloudflare:workers. That bubbles up through the node
ESM loader before vitest's alias fires. Keep shared test utilities
(bearer format, etc.) in a zero-dep module — see apps/cloud/src/test-bearer.ts.
MCP elicitation in this codebase
Execute-tool bridge. packages/hosts/mcp/src/server.ts — executeCode
checks supportsManagedElicitation(server) (requires the client to advertise
capabilities.elicitation.form). When true it passes
{ onElicitation: makeMcpElicitationHandler(server) } into engine.execute.
Handler body: server.server.elicitInput(params) over the MCP transport.
What triggers it. Two distinct paths, both hit the same handler:
- Executor-level approval.
executor.ts→enforceApprovalruns beforeinvokeToolwhenannotations.requiresApprovalis set. Fires a form elicit with the tool'sapprovalDescription. - Plugin-level elicit. Static plugin tools get an
elicitarg in their handler (seeelicitingTestPlugininmcp-session.e2e.node.test.ts).
openApiPlugin elicits for non-GET ops. invoke.ts → annotationsForOperation
marks POST/PUT/PATCH/DELETE with requiresApproval: true. When user code
invokes such an op, the executor's enforceApproval fires. (This is subtle
because invoke.ts itself doesn't call elicit() directly — the executor
wrapper does.)
Driving elicitation from an e2e test
Pattern used in mcp-miniflare.e2e.node.test.ts:
- Stand up a tiny
HttpApiwithHttpApiGroup+HttpApiEndpoint.postvia@effect/platform. Implement the handler withHttpApiBuilder.group. - Serve it via
HttpApiBuilder.serve()+NodeHttpServer.layer(createServer, { port: 0, host: "127.0.0.1" }). - Read the bound port from
HttpServer.HttpServer.address(TcpAddress tag) inside aLayer.effectthat produces the test service. - Generate the spec with
OpenApi.fromApi(api)and injectservers: [{ url:http://127.0.0.1:${port}` }]beforeJSON.stringify`. - SDK Client advertises
capabilities: { elicitation: { form: {} } }and registersclient.setRequestHandler(ElicitRequestSchema, ...)that returns{ action: "accept", content: {} }. - Call
executewith code that (a) callstools.executor.openapi.addSource({ spec, namespace }), (b) invokes the POST operation.
Tool id format inside the execute sandbox.
tools.<sourceId>.<group>.<operation> for openapi. The Effect
HttpApiGroup.make("approve") name becomes part of the path, so an endpoint
approveThing under group approve under source approveapi is
tools.approveapi.approve.approveThing({}). The executor's tool row id is
literally <sourceId>.<group>.<operation>.
Why the invoke must run in the same execute call as addSource. Both
tests it: the write commits immediately, and a second execute call in the
same session sees the new tool. Keeping them together is just convenient.
Things that looked like bugs but weren't
- "The openapi plugin doesn't elicit." It does, indirectly, through the
executor's
enforceApprovalwrapper.invoke.tsdoesn't have anelicit()call — easy to miss on a shallow grep. - "Tools added via
addSourcearen't visible." The tools proxy in the dynamic worker module (__makeToolsProxy) is a recursiveProxy— every.x.y(args)access turns into a__dispatcher.call("x.y", ...). There's no pre-computed allow-list, so newly-added tools are immediately callable. IfToolNotFoundErrorfires with a plausible-looking id, the id format is probably wrong (see group-name note above), not a visibility bug.