diff --git a/apps/cloud/src/auth/context.ts b/apps/cloud/src/auth/context.ts index e76272592..bfd3ea25c 100644 --- a/apps/cloud/src/auth/context.ts +++ b/apps/cloud/src/auth/context.ts @@ -27,3 +27,16 @@ export class UserStoreService extends Context.Service makeService(makeUserStore(db))), ); } + +/** + * A FRESH `UserStoreService` layer (new layer value per call). `UserStoreService.Live` + * captures its `db` at build time; when a long-lived runtime's shared memo map + * memoizes that const layer once, it pins the first request's postgres socket and + * reuses it on later requests — illegal under Cloudflare's per-request I/O. Build + * this (over a fresh `DbService` layer) anywhere a service is constructed once but + * invoked across many requests (the MCP org-authorization seam). See [[makeDbLayer]]. + */ +export const makeUserStoreLayer = (): Layer.Layer => + Layer.effect(UserStoreService)( + Effect.map(DbService.asEffect(), ({ db }) => makeService(makeUserStore(db))), + ); diff --git a/apps/cloud/src/db/db.ts b/apps/cloud/src/db/db.ts index 5610e4b2d..219beaf38 100644 --- a/apps/cloud/src/db/db.ts +++ b/apps/cloud/src/db/db.ts @@ -93,3 +93,18 @@ export class DbService extends Context.Service()( Effect.acquireRelease(Effect.sync(makePostgresResource), (resource) => resource.close()), ); } + +/** + * A FRESH `DbService` layer (a new layer value on every call). Provide this + * — rather than the shared `DbService.Live` — anywhere a service is built ONCE + * by the facade but invoked across many Workers requests (e.g. the MCP + * org-authorization seam). A single shared `DbService.Live` opens its postgres + * socket on the first request and reuses it on later ones, which Cloudflare + * forbids ("Cannot perform I/O on behalf of a different request"). A distinct + * layer value per call gets its own request-scoped socket, acquired and + * released within that request. + */ +export const makeDbLayer = (): Layer.Layer => + Layer.effect(DbService)( + Effect.acquireRelease(Effect.sync(makePostgresResource), (resource) => resource.close()), + ); diff --git a/apps/cloud/src/mcp/auth.ts b/apps/cloud/src/mcp/auth.ts index 79a6e7d33..c6efb0eb4 100644 --- a/apps/cloud/src/mcp/auth.ts +++ b/apps/cloud/src/mcp/auth.ts @@ -16,9 +16,9 @@ import { createCachedRemoteJWKSet } from "../auth/jwks-cache"; import { ApiKeyService } from "../auth/api-keys"; import { BEARER_PREFIX } from "../auth/bearer"; import { authorizeOrganization } from "../auth/organization"; -import { UserStoreService } from "../auth/context"; +import { makeUserStoreLayer } from "../auth/context"; import { CoreSharedServices } from "../auth/workos"; -import { DbService } from "../db/db"; +import { makeDbLayer } from "../db/db"; import { bearerChallenge } from "./responses"; import { McpJwtVerificationError, verifyWorkOSMcpAccessToken, type VerifiedToken } from "./jwt"; @@ -150,15 +150,25 @@ const verifyJwt = (token: string) => audience: WORKOS_CLIENT_ID, }); -const DbLive = DbService.Live; -const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive)); -const McpOrganizationAuthServices = Layer.mergeAll(DbLive, UserStoreLive, CoreSharedServices); +// Built FRESH per `authorize` call. `McpOrganizationAuthLive` is constructed +// once by the facade but `authorize` runs on every MCP request; a shared +// `DbService.Live` would open its postgres socket on the first request and +// illegally reuse it on later ones ("Cannot perform I/O on behalf of a +// different request"), failing the org lookup on every follow-up — the +// "connected · tools fetch failed" symptom. A fresh DB + UserStore layer per +// call gives each request its own request-scoped socket. `CoreSharedServices` +// (WorkOS, no per-request socket) stays shared. +const makeMcpOrganizationAuthServices = () => { + const dbLive = makeDbLayer(); + const userStoreLive = makeUserStoreLayer().pipe(Layer.provide(dbLive)); + return Layer.mergeAll(dbLive, userStoreLive, CoreSharedServices); +}; export const McpOrganizationAuthLive = Layer.succeed(McpOrganizationAuth)({ authorize: (accountId, organizationId) => authorizeOrganization(accountId, organizationId).pipe( Effect.map((org) => org !== null), - Effect.provide(McpOrganizationAuthServices), + Effect.provide(makeMcpOrganizationAuthServices()), ), });