History

Build the MCP auth layer once per isolate

Rhys Sullivan committed 2 months ago

Commit 01e90181 parent 0520ee13 files · +37 −21Browse files

Unified · Split

3 files
  1. .changeset/apikey-validate-cache.md +1 −1
  2. apps/cloud/src/mcp/agent-handler.ts +28 −4
  3. e2e/cloud/api-key-validation-cache.test.ts +8 −16
.changeset/apikey-validate-cache.md+1−1
@@ -6,4 +6,4 @@ · 5 unmodified lines
66
77 Every MCP request and every API-key-authenticated `/api/*` request used to pay a live WorkOS round trip (~100-150ms) to validate the presented key, on every single request. The JWT bearer path beside it already verified locally against a JWKS cached for an hour; API keys had no cache at all.
88
9−Successful validations are now cached in a bounded per-isolate map for 60 seconds, keyed by the SHA-256 digest of the key value (never the raw credential). Invalid keys and upstream failures are never cached, so probing bad keys cannot pollute the map and a freshly created key works immediately. The tradeoff: a revoked key remains usable for up to 60 seconds within an isolate that validated it before revocation — far tighter than the one-hour rotation window the JWT path already accepts.
9+Successful validations are now cached in a bounded per-isolate map for 60 seconds, keyed by the SHA-256 digest of the key value (never the raw credential). The MCP handler used to rebuild its whole auth layer (and with it the cache) on every request; it now builds the layer once per isolate, so the cache holds on both the `/api/*` and `/mcp` planes. Invalid keys and upstream failures are never cached, so probing bad keys cannot pollute the map and a freshly created key works immediately. The tradeoff: a revoked key remains usable for up to 60 seconds within an isolate that validated it before revocation — far tighter than the one-hour rotation window the JWT path already accepts.
apps/cloud/src/mcp/agent-handler.ts+28−4
@@ -1,5 +1,5 @@
11 import * as OtelTracer from "@effect/opentelemetry/Tracer";
2−import { Effect, Predicate } from "effect";
2+import { Effect, ManagedRuntime, Predicate } from "effect";
33
44 import {
55 McpAuthProvider,
@@ -128,7 +128,23 @@ const authenticate = (request: Request) => · 122 unmodified lines
128128 const auth = yield* McpAuthProvider;
129129 const outcome = yield* auth.authenticate(request);
130130 return { auth, outcome };
131− }).pipe(Effect.provide(cloudMcpAuth));
131+ });
132+
133+// ONE auth layer per isolate. `authenticate` used to end in
134+// `Effect.provide(cloudMcpAuth)`, which rebuilt the entire auth stack — the
135+// WorkOS client, `ApiKeyService` and with it the api-key validation success
136+// cache — on every MCP request, so that cache never survived a request on this
137+// plane (the /api/* plane resolves the same service from the app's `boot`
138+// layer, built once per isolate). A lazily created ManagedRuntime (the
139+// auth/doc-gate.ts pattern) builds `cloudMcpAuth` on the first request and
140+// memoizes it for the isolate's lifetime; `runTraced` runs every program on it.
141+// Nothing inside the layer is request-scoped: the WorkOS client holds no
142+// sockets (just config), the JWKS cache is already module-scope, and
143+// `McpOrganizationAuthLive` builds its postgres socket FRESH inside every
144+// `authorize` call precisely so the service itself can outlive a request
145+// (Cloudflare Workers' I/O isolation — see makeMcpOrganizationAuthServices).
146+let mcpAuthRuntime: ManagedRuntime.ManagedRuntime<McpAuthProvider, never> | undefined;
147+const getMcpAuthRuntime = () => (mcpAuthRuntime ??= ManagedRuntime.make(cloudMcpAuth));
132148
133149 // The pre-Agents envelope ran the MCP auth path inside the Effect app, whose
134150 // HttpMiddleware provided the OTEL tracer — that is where the `mcp.request`
@@ -140,12 +156,20 @@ const authenticate = (request: Request) => · 5 unmodified lines
140156 // forwarded request's traceparent — which also makes `currentPropagationHeaders`
141157 // (via Effect.currentParentSpan) ferry that same trace into the session DO
142158 // instead of letting the DO start a fresh root per request.
143−const runTraced = <A>(request: Request, program: Effect.Effect<A>): Promise<A> => {
159+//
160+// Runs on the memoized auth runtime above (rather than a bare
161+// `Effect.runPromise`) so `authenticate` resolves the once-built
162+// `McpAuthProvider` from it; the telemetry layer is still provided per run, so
163+// spans keep exporting exactly as before.
164+const runTraced = <A>(
165+ request: Request,
166+ program: Effect.Effect<A, never, McpAuthProvider>,
167+): Promise<A> => {
144168 const parsed = parseTraceparent(
145169 request.headers.get("traceparent"),
146170 request.headers.get("tracestate"),
147171 );
148− return Effect.runPromise(
172+ return getMcpAuthRuntime().runPromise(
149173 (parsed ? OtelTracer.withSpanContext(program, parsed) : program).pipe(
150174 Effect.provide(WorkerTelemetryLive),
151175 ),
e2e/cloud/api-key-validation-cache.test.ts+8−16
@@ -19,15 +19,13 @@ · 18 unmodified lines
1919 // Two scenarios because the two bearer surfaces wire the cache differently:
2020 //
2121 // - /api/* resolves `ApiKeyService` from the app's boot layer, built once
22−// per isolate — the cache holds. Green.
23−// - /mcp is served outside the app envelope by the agent handler, whose
24−// `authenticate` provides the auth layer PER REQUEST
25−// (`Effect.provide(cloudMcpAuth)` in apps/cloud/src/mcp/agent-handler.ts).
26−// Each request builds a fresh `ApiKeyService` — a fresh cache map — so
27−// every MCP request still pays the WorkOS round trip the cache exists to
28−// remove. That scenario is registered as an expected failure until the
29−// MCP wiring shares one built auth layer per isolate; the assertions
30−// state the real contract, and the flag must be removed when it lands.
22+// per isolate.
23+// - /mcp is served outside the app envelope by the agent handler, which
24+// builds `cloudMcpAuth` once per isolate on a memoized ManagedRuntime
25+// (apps/cloud/src/mcp/agent-handler.ts). It used to provide that layer
26+// per request — a fresh `ApiKeyService`, a fresh cache map, a WorkOS
27+// round trip on every MCP request — so this scenario pins the shared
28+// wiring, not just the cache.
3129 //
3230 // Attribution: the emulator redacts secret-named fields (the response's
3331 // `api_key` object), but the request body's `value` survives, and every key
@@ -226,13 +224,7 @@ scenario( · 192 unmodified lines
226224
227225 scenario(
228226 "MCP · back-to-back API-key requests validate the key with WorkOS once, and each key validates independently",
229− {
230− expectedFailure:
231− "the MCP agent handler provides its auth layer per request (Effect.provide(cloudMcpAuth) " +
232− "in apps/cloud/src/mcp/agent-handler.ts), so every MCP request builds a fresh " +
233− "ApiKeyService and a fresh validation cache — each request still pays a WorkOS round " +
234− "trip. Remove this flag when the MCP path shares one built auth layer per isolate.",
235− },
227+ {},
236228 Effect.scoped(
237229 Effect.gen(function* () {
238230 const { target, keyA, keyB, readValidations } = yield* mintTwoKeys;