Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions apps/cloud/src/auth/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,16 @@ export class UserStoreService extends Context.Service<UserStoreService, UserStor
Effect.map(DbService.asEffect(), ({ db }) => 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<UserStoreService, never, DbService> =>
Layer.effect(UserStoreService)(
Effect.map(DbService.asEffect(), ({ db }) => makeService(makeUserStore(db))),
);
15 changes: 15 additions & 0 deletions apps/cloud/src/db/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,18 @@ export class DbService extends Context.Service<DbService, DbServiceShape>()(
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<DbService> =>
Layer.effect(DbService)(
Effect.acquireRelease(Effect.sync(makePostgresResource), (resource) => resource.close()),
);
22 changes: 16 additions & 6 deletions apps/cloud/src/mcp/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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()),
),
});

Expand Down
Loading