History

MCP session cold init: carry the org identity in the session props

Rhys Sullivan committed 2 months ago

Commit b727c6b1 parent 55180cb15 files · +1000 −54Browse files

Unified · Split

15 files
  1. apps/cloud/src/auth/context.ts +6 −4
  2. apps/cloud/src/auth/errors.ts +95 −2
  3. apps/cloud/src/auth/user-store-error.node.test.ts +128 −0
  4. apps/cloud/src/mcp/agent-handler.ts +27 −0
  5. apps/cloud/src/mcp/auth-provider.test.ts +4 −2
  6. apps/cloud/src/mcp/auth-provider.ts +20 −11
  7. apps/cloud/src/mcp/auth.ts +18 −3
  8. apps/cloud/src/mcp/session-durable-object.ts +18 −23
  9. apps/cloud/src/mcp/session-meta.node.test.ts +167 −0
  10. apps/cloud/src/mcp/session-meta.ts +209 −0
  11. apps/cloud/src/observability/index.ts +47 −0
  12. apps/host-cloudflare/src/mcp/session-durable-object.ts +7 −2
  13. e2e/cloud/mcp-session-cold-init.test.ts +100 −0
  14. packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts +114 −1
  15. packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts +40 −6
apps/cloud/src/auth/context.ts+6−4
@@ -1,7 +1,7 @@
11 import { Context, Effect, Layer } from "effect";
22 import { makeUserStore } from "../auth/user-store";
33 import { DbService } from "../db/db";
4−import { UserStoreError, tryPromiseService, withServiceLogging } from "./errors";
4+import { tryPromiseService, userStoreErrorFromFailure, withServiceLogging } from "./errors";
55
66 // ---------------------------------------------------------------------------
77 // UserStoreService — wraps the Drizzle-backed user store with Effect
@@ -10,13 +10,15 @@ import { UserStoreError, tryPromiseService, withServiceLogging } from "./errors" · 2 unmodified lines
1010 type RawStore = ReturnType<typeof makeUserStore>;
1111
1212 // `op` names the store call so every span reads `user_store.<operation>`
13−// instead of one undifferentiated "user_store" bucket, and failures log which
14−// query actually failed.
13+// instead of one undifferentiated "user_store" bucket, failures log which
14+// query actually failed, and — because the same `op` is threaded onto the
15+// public error alongside the classified driver reason — an error report is
16+// diagnosable without the trace.
1517 const makeService = (store: RawStore) => ({
1618 use: <A>(op: string, fn: (s: RawStore) => Promise<A>) =>
1719 withServiceLogging(
1820 `user_store.${op}`,
19− () => new UserStoreError(),
21+ (failure) => userStoreErrorFromFailure(op, failure),
2022 tryPromiseService(() => fn(store)),
2123 ),
2224 });
apps/cloud/src/auth/errors.ts+95−2
@@ -1,10 +1,103 @@
11 import { Data, Effect, Option, Predicate, Schema } from "effect";
22
3+// How a user-store call failed, classified from the driver cause. Safe to put
4+// on the wire and on a Sentry tag: it names a failure MODE, never a query, a
5+// value, or a customer.
6+export const USER_STORE_FAILURE_REASONS = [
7+ "connect_timeout",
8+ "connection_closed",
9+ "query",
10+ "unknown",
11+] as const;
12+
13+export type UserStoreFailureReason = (typeof USER_STORE_FAILURE_REASONS)[number];
14+
15+/**
16+ * The public failure of every cloud user-store call.
17+ *
18+ * It carries the two fields that make an issue diagnosable from the error
19+ * alone: which store call failed, and how. Before those existed the error had
20+ * an empty field set, so Sentry showed a titleless, messageless issue and the
21+ * only cause detail (the pretty-printed Effect cause in a Sentry `extra`) is
22+ * scrubbed server-side — the failing operation and the driver reason existed
23+ * only in the trace store. Same shape as `WorkOSError.status`: a small, safe
24+ * classification field threaded at the service boundary.
25+ */
326 export class UserStoreError extends Schema.TaggedErrorClass<UserStoreError>()(
427 "UserStoreError",
5− {},
28+ {
29+ /** The store call that failed, e.g. `getOrganization`. */
30+ operation: Schema.String,
31+ /** How it failed, classified from the driver cause chain. */
32+ reason: Schema.Literals(USER_STORE_FAILURE_REASONS),
33+ },
634 { httpApiStatus: 500 },
7−) {}
35+) {
36+ override get message(): string {
37+ return `user store ${this.operation} failed: ${this.reason}`;
38+ }
39+}
40+
41+/** Reasons a retry can plausibly clear: the query never reached a healthy
42+ * server. A `query` failure is deterministic and must not be retried. */
43+export const isTransientUserStoreReason = (reason: UserStoreFailureReason): boolean =>
44+ reason === "connect_timeout" || reason === "connection_closed";
45+
46+// postgres.js tags its connection failures with a string `code`
47+// (`CONNECT_TIMEOUT`, `CONNECTION_CLOSED`, …) and its query failures with the
48+// SQLSTATE. Drizzle re-throws both wrapped in its own "Failed query" error with
49+// the driver error in `.cause`, and the service adapter wraps that again, so
50+// the classification walks the chain rather than inspecting one level.
51+const MAX_CAUSE_DEPTH = 8;
52+
53+const REASON_BY_DRIVER_CODE: Readonly<Record<string, UserStoreFailureReason>> = {
54+ CONNECT_TIMEOUT: "connect_timeout",
55+ ETIMEDOUT: "connect_timeout",
56+ CONNECTION_CLOSED: "connection_closed",
57+ CONNECTION_ENDED: "connection_closed",
58+ CONNECTION_DESTROYED: "connection_closed",
59+ ECONNREFUSED: "connection_closed",
60+ ECONNRESET: "connection_closed",
61+};
62+
63+const stringCodeOf = (value: unknown): string | undefined => {
64+ if (typeof value !== "object" || value === null) return undefined;
65+ const code = (value as { readonly code?: unknown }).code;
66+ return typeof code === "string" ? code : undefined;
67+};
68+
69+const driverCodesOf = (failure: unknown): readonly string[] => {
70+ const codes: string[] = [];
71+ let current: unknown = isServiceAdapterError(failure) ? failure.cause : failure;
72+ for (
73+ let depth = 0;
74+ depth < MAX_CAUSE_DEPTH && current !== undefined && current !== null;
75+ depth++
76+ ) {
77+ const code = stringCodeOf(current);
78+ if (code !== undefined) codes.push(code);
79+ current =
80+ typeof current === "object" ? (current as { readonly cause?: unknown }).cause : undefined;
81+ }
82+ return codes;
83+};
84+
85+/** Classify a raw store failure. A recognised connection code wins; any other
86+ * driver code (a SQLSTATE) is a deterministic query failure; nothing at all is
87+ * `unknown`. */
88+export const userStoreReasonFromCause = (failure: unknown): UserStoreFailureReason => {
89+ const codes = driverCodesOf(failure);
90+ for (const code of codes) {
91+ const reason = REASON_BY_DRIVER_CODE[code];
92+ if (reason !== undefined) return reason;
93+ }
94+ return codes.length > 0 ? "query" : "unknown";
95+};
96+
97+/** Build the public `UserStoreError` for a store-adapter failure, naming the
98+ * operation the call site already knows and classifying the driver cause. */
99+export const userStoreErrorFromFailure = (operation: string, failure: unknown): UserStoreError =>
100+ new UserStoreError({ operation, reason: userStoreReasonFromCause(failure) });
8101
9102 export class WorkOSError extends Schema.TaggedErrorClass<WorkOSError>()(
10103 "WorkOSError",
apps/cloud/src/auth/user-store-error.node.test.ts+128−0
@@ -0,0 +1,128 @@
1+// `UserStoreError` is the public failure of every cloud user-store call, and it
2+// used to carry NOTHING: no operation, no reason, no message. Sentry grouped
3+// every store failure — a connect timeout, a missing table, a constraint
4+// violation — into one titleless issue, and the only cause detail (the
5+// pretty-printed Effect cause stuffed into a Sentry `extra`) is scrubbed
6+// server-side, so the issue was undiagnosable from Sentry alone.
7+//
8+// This pins the two safe classification fields it must carry instead:
9+// `operation` (already in hand at the call site) and `reason` (classified from
10+// the driver cause the way `statusFromWorkOSCause` classifies WorkOS causes).
11+import { createServer, type Server, type Socket } from "node:net";
12+
13+import { describe, expect, it } from "@effect/vitest";
14+import { Effect, Layer, Result } from "effect";
15+import postgres from "postgres";
16+
17+import { UserStoreService } from "./context";
18+import { ServiceAdapterError, userStoreReasonFromCause, type UserStoreError } from "./errors";
19+import { DbService } from "../db/db";
20+
21+// A socket that completes the TCP handshake and then says nothing — exactly
22+// what a wedged Hyperdrive/Postgres endpoint looks like to postgres.js, and the
23+// only way to obtain the driver's REAL connect-timeout error object rather than
24+// a hand-written imitation of it.
25+const blackHolePort = async (): Promise<{
26+ readonly port: number;
27+ readonly close: () => Promise<void>;
28+}> => {
29+ const sockets = new Set<Socket>();
30+ const server: Server = createServer((socket) => {
31+ sockets.add(socket);
32+ socket.on("close", () => sockets.delete(socket));
33+ });
34+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
35+ const address = server.address();
36+ // oxlint-disable-next-line executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: the fixture cannot run without a bound port
37+ if (address === null || typeof address === "string") throw new Error("no port");
38+ return {
39+ port: address.port,
40+ close: () =>
41+ new Promise<void>((resolve) => {
42+ // The timed-out client leaves its half-open socket attached; without
43+ // dropping it first, `close` waits for a peer that will never speak.
44+ for (const socket of sockets) socket.destroy();
45+ server.close(() => resolve());
46+ }),
47+ };
48+};
49+
50+const realConnectTimeoutError = async (): Promise<unknown> => {
51+ const hole = await blackHolePort();
52+ const sql = postgres(`postgresql://postgres:postgres@127.0.0.1:${hole.port}/postgres`, {
53+ max: 1,
54+ connect_timeout: 1,
55+ fetch_types: false,
56+ onnotice: () => undefined,
57+ });
58+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: capturing the driver's own thrown error IS the fixture
59+ try {
60+ await sql`select 1`;
61+ // oxlint-disable-next-line executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: the fixture is unusable if the socket answered
62+ throw new Error("expected the connect to time out");
63+ } catch (error) {
64+ return error;
65+ } finally {
66+ // oxlint-disable-next-line executor/no-promise-catch -- boundary: best-effort teardown of a connection that never opened
67+ await sql.end({ timeout: 0 }).catch(() => undefined);
68+ await hole.close();
69+ }
70+};
71+
72+// Drizzle re-throws driver failures wrapped in its own error with the failing
73+// SQL in the message and the driver error in `.cause` — the shape production
74+// actually reports (`Failed query: select … -> write CONNECT_TIMEOUT …`).
75+const wrappedLikeDrizzle = (cause: unknown): Error =>
76+ // oxlint-disable-next-line executor/no-error-constructor -- boundary: reproducing the driver wrapper shape the store really fails with
77+ Object.assign(new Error('Failed query: select "id" from "organizations" where "id" = $1'), {
78+ cause,
79+ });
80+
81+const stubDb = Layer.succeed(DbService)({ db: {} as never });
82+
83+const failingStoreCall = (
84+ operation: string,
85+ failure: unknown,
86+): Effect.Effect<Result.Result<never, UserStoreError>> =>
87+ Effect.gen(function* () {
88+ const users = yield* UserStoreService;
89+ // oxlint-disable-next-line executor/no-promise-reject -- boundary: the store adapter lifts a REJECTING promise; rejecting is the fixture
90+ return yield* users.use(operation, () => Promise.reject(failure));
91+ }).pipe(
92+ Effect.provide(UserStoreService.Live.pipe(Layer.provide(stubDb))),
93+ Effect.result,
94+ ) as Effect.Effect<Result.Result<never, UserStoreError>>;
95+
96+describe("UserStoreError classification", () => {
97+ it("classifies the driver cause chain", async () => {
98+ const driverError = await realConnectTimeoutError();
99+
100+ expect(userStoreReasonFromCause(wrappedLikeDrizzle(driverError))).toBe("connect_timeout");
101+ expect(
102+ userStoreReasonFromCause(new ServiceAdapterError({ cause: wrappedLikeDrizzle(driverError) })),
103+ ).toBe("connect_timeout");
104+ expect(
105+ userStoreReasonFromCause(
106+ // oxlint-disable-next-line executor/no-error-constructor -- boundary: a SQLSTATE failure shape
107+ Object.assign(new Error('relation "organizations" does not exist'), { code: "42P01" }),
108+ ),
109+ ).toBe("query");
110+ // oxlint-disable-next-line executor/no-error-constructor -- boundary: a failure with nothing to classify
111+ expect(userStoreReasonFromCause(new Error("something else"))).toBe("unknown");
112+ }, 20_000);
113+
114+ it("carries operation, reason and a stable message on the public error", async () => {
115+ const driverError = await realConnectTimeoutError();
116+
117+ const result = await Effect.runPromise(
118+ failingStoreCall("getOrganization", wrappedLikeDrizzle(driverError)),
119+ );
120+
121+ expect(Result.isFailure(result)).toBe(true);
122+ if (!Result.isFailure(result)) return;
123+ expect(result.failure.operation).toBe("getOrganization");
124+ expect(result.failure.reason).toBe("connect_timeout");
125+ expect(result.failure.message).toContain("getOrganization");
126+ expect(result.failure.message).toContain("connect_timeout");
127+ }, 20_000);
128+});
apps/cloud/src/mcp/agent-handler.ts+27−0
@@ -27,9 +27,12 @@ import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; · 26 unmodified lines
2727 import { wrapMcpSseResponse } from "../observability/memory-metrics";
2828 import { WorkerTelemetryLive } from "../observability/telemetry";
2929 import { cloudMcpAuth } from "./auth-provider";
30+import { isMcpSessionMetaUnavailable } from "./session-meta";
3031 import { McpSessionDOSqlite } from "./session-durable-object";
3132 import { parseTraceparent } from "./traceparent";
3233
34+const MCP_SESSION_UNAVAILABLE_MESSAGE = "Session storage temporarily unavailable - please retry";
35+
3336 const corsPreflightResponse = (): Response =>
3437 new Response(null, {
3538 status: 204,
@@ -171,6 +174,13 @@ const propsForPrincipal = ( · 135 unmodified lines
171174 return {
172175 session: {
173176 organizationId: principal.organizationId,
177+ // The org record the live membership check resolved microseconds ago,
178+ // handed to the session DO so it never opens a connection of its own to
179+ // re-read it. An unnamed org (no auth plane could resolve one) is
180+ // omitted rather than sent empty, so the DO can tell "not carried" from
181+ // "carried, and blank".
182+ ...(principal.organizationName ? { organizationName: principal.organizationName } : {}),
183+ ...(principal.organizationSlug ? { organizationSlug: principal.organizationSlug } : {}),
174184 userId: principal.accountId,
175185 elicitationMode: readElicitationMode(request),
176186 artifactsEnabled: readArtifactsEnabled(request),
@@ -306,6 +316,23 @@ export const makeCloudMcpAgentHandler = () => { · 129 unmodified lines
306316 // vocabulary — a deploy, a storage timeout, a cancelled
307317 // blockConcurrencyWhile — which reaches here through the agents SDK's own
308318 // `getServerByName` retry and used to 500 identically.
319+ // The session DO could not reach the organization directory to name the
320+ // org (after its own bounded retry). Transient by construction, so it
321+ // gets the same retryable envelope a WorkOS blip gets on the auth path —
322+ // not an unclassified 500 the agents SDK then retries the whole DO
323+ // operation over, which is what turned a 10s connect timeout into a
324+ // half-minute client hang.
325+ //
326+ // Checked BEFORE the platform classifier: this is an application failure
327+ // that merely escapes through the same seam, and it names its own cause.
328+ // The classifier only recognizes the runtime's own reset vocabulary, so
329+ // the two never contend — the order just keeps it that way if either
330+ // vocabulary grows.
331+ if (isMcpSessionMetaUnavailable(error)) {
332+ return jsonRpcErrorBody(503, -32001, MCP_SESSION_UNAVAILABLE_MESSAGE, {
333+ retryAfterSeconds: UNAVAILABLE_RETRY_AFTER_SECONDS,
334+ });
335+ }
309336 const failure = classifyDurableObjectError(error);
310337 if (!failure) {
311338 // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: rethrow anything that isn't a recognized platform failure to the Workers runtime unchanged
apps/cloud/src/mcp/auth-provider.test.ts+4−2
@@ -70,9 +70,11 @@ const stubOrgAuthNoMembership = Layer.succeed(McpOrganizationAuth)({ · 69 unmodified lines
7070 authorize: () => Effect.succeed(null),
7171 });
7272
73−// `authorize` SUCCEEDS with an org id — active membership.
73+// `authorize` SUCCEEDS with the resolved org record — active membership. The
74+// record, not just the id: the session props carry the org's name and slug so
75+// the session DO never re-reads the row.
7476 const stubOrgAuthActive = Layer.succeed(McpOrganizationAuth)({
75− authorize: () => Effect.succeed(ORG_ID),
77+ authorize: () => Effect.succeed({ id: ORG_ID, name: "Stub Org", slug: "stub-org" }),
7678 });
7779
7880 // A failure that is not a WorkOSError at all (e.g. the per-request DB layer
apps/cloud/src/mcp/auth-provider.ts+20−11
@@ -55,6 +55,7 @@ import { · 54 unmodified lines
5555 McpAuthLive,
5656 McpOrganizationAuth,
5757 McpOrganizationAuthLive,
58+ type AuthorizedMcpOrganization,
5859 type McpAuthResult,
5960 type VerifiedToken,
6061 } from "./auth";
@@ -88,16 +89,24 @@ const ORGANIZATION_AUTHORIZE_UNAVAILABLE = · 27 unmodified lines
8889
8990 /**
9091 * Enrich a cloud {@link VerifiedToken} (which carries only accountId +
91− * organizationId) into the full {@link Principal} the seam validates. The
92− * envelope only uses `accountId` + `organizationId` for ownership; cloud
93− * resolves org name/email inside the DO, so the cosmetic identity fields carry
94− * empty placeholders. `organizationId` is guaranteed non-null here because the
95− * Forbidden branch already rejected the no-org case before Authenticated.
92+ * organizationId) into the full {@link Principal} the seam validates.
93+ *
94+ * The org name and slug come from the record the live membership check just
95+ * resolved — this is the whole point of `authorize` returning the record rather
96+ * than an id. They used to be dropped here (`organizationName: ""`), which left
97+ * the session Durable Object to re-read the same row over a fresh database
98+ * connection on every cold init. `email` stays a placeholder: the envelope only
99+ * uses `accountId` + `organizationId` for ownership, and nothing downstream
100+ * reads it.
96101 */
97−const principalFromToken = (token: VerifiedToken, organizationId: string): Principal => ({
102+const principalFromToken = (
103+ token: VerifiedToken,
104+ organization: AuthorizedMcpOrganization,
105+): Principal => ({
98106 accountId: token.accountId,
99− organizationId,
100− organizationName: "",
107+ organizationId: organization.id,
108+ organizationName: organization.name,
109+ ...(organization.slug === undefined ? {} : { organizationSlug: organization.slug }),
101110 email: "",
102111 name: null,
103112 avatarUrl: null,
@@ -223,9 +232,9 @@ export const cloudMcpAuthProviderLayer: Layer.Layer< · 119 unmodified lines
223232 // caller genuinely holds no active membership (revoked / never a member)
224233 // — a real Forbidden, which the handler may act on by condemning the
225234 // session.
226− const organizationId = authorizeResult.success;
227− if (!organizationId) return forbidden(NO_ORGANIZATION_MESSAGE, -32001);
228− return authenticated(principalFromToken(token, organizationId));
235+ const organization = authorizeResult.success;
236+ if (!organization) return forbidden(NO_ORGANIZATION_MESSAGE, -32001);
237+ return authenticated(principalFromToken(token, organization));
229238 });
230239
231240 const toOutcome = (request: Request, result: McpAuthResult): Effect.Effect<AuthOutcome> => {
apps/cloud/src/mcp/auth.ts+18−3
@@ -160,19 +160,32 @@ export class McpAuth extends Context.Service< · 159 unmodified lines
160160 }
161161 >()("@executor-js/cloud/McpAuth") {}
162162
163+/**
164+ * The organization an MCP request was authorized against. The full record, not
165+ * just its id: the same request later needs the org's display name and slug to
166+ * open a session, and re-reading the row for them (from the session Durable
167+ * Object, on a fresh database connection) is a redundant failure point on a
168+ * request that already has the answer.
169+ */
170+export type AuthorizedMcpOrganization = {
171+ readonly id: string;
172+ readonly name: string;
173+ readonly slug?: string;
174+};
175+
163176 export class McpOrganizationAuth extends Context.Service<
164177 McpOrganizationAuth,
165178 {
166179 /**
167180 * Authorize `accountId` against an org SELECTOR — a WorkOS org id
168181 * (`org_…`, from the token or a legacy URL) or the org's URL slug (the
169− * form the install card prints). Returns the resolved org id when the
182+ * form the install card prints). Returns the resolved organization when the
170183 * caller holds an active membership, `null` otherwise.
171184 */
172185 readonly authorize: (
173186 accountId: string,
174187 organizationSelector: string,
175− ) => Effect.Effect<string | null, unknown>;
188+ ) => Effect.Effect<AuthorizedMcpOrganization | null, unknown>;
176189 }
177190 >()("@executor-js/cloud/McpOrganizationAuth") {}
178191
@@ -216,7 +229,9 @@ export const McpOrganizationAuthLive = Layer.succeed(McpOrganizationAuth)({ · 37 unmodified lines
216229 Effect.flatMap((organizationId) =>
217230 organizationId
218231 ? authorizeOrganization(accountId, organizationId).pipe(
219− Effect.map((org) => (org ? org.id : null)),
232+ Effect.map((org) =>
233+ org ? ({ id: org.id, name: org.name, slug: org.slug } as const) : null,
234+ ),
220235 )
221236 : Effect.succeed(null),
222237 ),
apps/cloud/src/mcp/session-durable-object.ts+18−23
@@ -58,7 +58,7 @@ import { buildExecuteDescription, type ResumeResponse } from "@executor-js/execu · 57 unmodified lines
5858 // `SessionAuthLive` instead.)
5959 import { CoreSharedServices } from "../auth/workos";
6060 import { UserStoreService } from "../auth/context";
61−import { resolveOrganization } from "../auth/organization";
61+import { resolveSessionMetaForToken } from "./session-meta";
6262 import {
6363 DbService,
6464 combinedSchema,
@@ -107,10 +107,6 @@ type CloudSessionDbHandle = DbServiceShape & { · 42 unmodified lines
107107 readonly end: () => Promise<void>;
108108 };
109109
110−class OrganizationNotFoundError extends Data.TaggedError("OrganizationNotFoundError")<{
111− readonly organizationId: string;
112−}> {}
113−
114110 class McpModelResumeForwardError extends Data.TaggedError("McpModelResumeForwardError")<{
115111 readonly cause: unknown;
116112 }> {}
@@ -209,28 +205,27 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 92 unmodified lines
209205 });
210206 }
211207
212− protected override resolveSessionMeta(token: McpSessionInit): Effect.Effect<SessionMeta> {
208+ protected override resolveSessionMeta(
209+ token: McpSessionInit,
210+ storedMeta: SessionMeta | null,
211+ ): Effect.Effect<SessionMeta> {
212+ // The database handle is opened LAZILY: on the props and stored paths — the
213+ // overwhelming majority of inits — nothing here touches Postgres at all,
214+ // which is the whole point. postgres.js only dials on first query, so
215+ // building the handle costs nothing; `ensuring` still closes it.
213216 const dbHandle = makeEphemeralDb();
214− return Effect.gen(function* () {
215− const org = yield* resolveOrganization(token.organizationId);
216− if (!org) {
217− return yield* new OrganizationNotFoundError({ organizationId: token.organizationId });
218− }
219− return {
220− organizationId: org.id,
221− organizationName: org.name,
222− organizationSlug: org.slug,
223− userId: token.userId,
224− resource: token.resource,
225− elicitationMode: token.elicitationMode,
226− artifactsEnabled: token.artifactsEnabled,
227− searchToolsEnabled: token.searchToolsEnabled,
228− } satisfies SessionMeta;
229− }).pipe(
217+ return resolveSessionMetaForToken(token, storedMeta).pipe(
230218 Effect.withSpan("McpSessionDOSqlite.resolveSessionMeta"),
231219 Effect.provide(makeSessionServices(dbHandle)),
232220 Effect.ensuring(Effect.promise(() => dbHandle.end())),
233− // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: a vanished org is a defect; the worker already verified the bearer
221+ // The base's `resolveSessionMeta` seam has no error channel, and a
222+ // Durable Object's `init` can only reject its Promise — so a failure has
223+ // to leave as a defect. What changed is WHAT leaves: an unreachable
224+ // organization directory is now a bounded, classified
225+ // `McpSessionMetaUnavailableError` whose message the worker recognises
226+ // and renders as a retryable 503 (see `agent-handler.ts`), instead of an
227+ // unclassified Postgres cause that produced a 500 and a client hang.
228+ // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: the DO init seam is Promise-only; the failure is classified before it dies
234229 Effect.orDie,
235230 );
236231 }
apps/cloud/src/mcp/session-meta.node.test.ts+167−0
@@ -0,0 +1,167 @@
1+// Where an MCP session's organization identity comes from, and what happens
2+// when the only remaining source — the database — is unreachable.
3+//
4+// The production defect: every cold session-DO init read the `organizations`
5+// row over a brand-new Postgres connection, even though the worker had resolved
6+// that exact row microseconds earlier on the same request, and even when the DO
7+// already held the answer in its own storage. A connect timeout on that
8+// unnecessary connection became an unclassified defect and killed `initialize`.
9+import { describe, expect, it } from "@effect/vitest";
10+import { Effect, Layer, Predicate, Result } from "effect";
11+
12+import { defaultMcpResource } from "@executor-js/host-mcp";
13+import type { McpSessionInit, SessionMeta } from "@executor-js/cloudflare/mcp/agent-durable-object";
14+
15+import { UserStoreService } from "../auth/context";
16+import { UserStoreError } from "../auth/errors";
17+import { WorkOSClient, type WorkOSClientService } from "../auth/workos";
18+import {
19+ isMcpSessionMetaUnavailable,
20+ resolveSessionMetaForToken,
21+ SESSION_META_DB_RETRIES,
22+} from "./session-meta";
23+
24+const TOKEN: McpSessionInit = {
25+ organizationId: "org_test",
26+ userId: "user_test",
27+ elicitationMode: "model",
28+ resource: defaultMcpResource,
29+ artifactsEnabled: true,
30+};
31+
32+const STORED: SessionMeta = {
33+ organizationId: "org_test",
34+ organizationName: "Stored Org",
35+ organizationSlug: "stored-org",
36+ userId: "user_test",
37+ resource: defaultMcpResource,
38+};
39+
40+/** A user store that always fails the way a wedged Hyperdrive endpoint does,
41+ * counting how many times it was asked. */
42+const countingConnectTimeoutStore = (): {
43+ readonly layer: Layer.Layer<UserStoreService>;
44+ readonly calls: () => number;
45+} => {
46+ let calls = 0;
47+ return {
48+ calls: () => calls,
49+ layer: Layer.succeed(UserStoreService)({
50+ use: (operation: string) =>
51+ Effect.suspend(() => {
52+ calls += 1;
53+ return Effect.fail(new UserStoreError({ operation, reason: "connect_timeout" }));
54+ }),
55+ } as UserStoreService["Service"]),
56+ };
57+};
58+
59+const namingStore = (): {
60+ readonly layer: Layer.Layer<UserStoreService>;
61+ readonly calls: () => number;
62+} => {
63+ let calls = 0;
64+ return {
65+ calls: () => calls,
66+ layer: Layer.succeed(UserStoreService)({
67+ use: (_operation: string, fn: (store: never) => Promise<unknown>) =>
68+ Effect.suspend(() => {
69+ calls += 1;
70+ return Effect.promise(() =>
71+ fn({
72+ getOrganization: async (id: string) => ({
73+ id,
74+ name: "Database Org",
75+ slug: "database-org",
76+ }),
77+ } as never),
78+ );
79+ }),
80+ } as UserStoreService["Service"]),
81+ };
82+};
83+
84+const unusedWorkOS = Layer.succeed(
85+ WorkOSClient,
86+ new Proxy({} as WorkOSClientService, {
87+ get: (_target, prop) => () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`),
88+ }),
89+);
90+
91+// The props source — the one the overwhelming majority of inits take — is
92+// covered black-box by `e2e/cloud/mcp-session-cold-init.test.ts`. What stays
93+// here is what that scenario cannot reach: the sources it falls back to, and
94+// what an unreachable database does to them.
95+describe("resolveSessionMetaForToken", () => {
96+ it("falls back to the meta this session already stored, without touching the store", async () => {
97+ const store = countingConnectTimeoutStore();
98+
99+ const meta = await Effect.runPromise(
100+ resolveSessionMetaForToken(TOKEN, STORED).pipe(
101+ Effect.provide(Layer.mergeAll(store.layer, unusedWorkOS)),
102+ ),
103+ );
104+
105+ expect(meta.organizationName).toBe("Stored Org");
106+ expect(meta.organizationSlug).toBe("stored-org");
107+ expect(store.calls(), "a restore reuses what it persisted").toBe(0);
108+ });
109+
110+ it("reads the database only when nothing else names the org", async () => {
111+ const store = namingStore();
112+
113+ const meta = await Effect.runPromise(
114+ resolveSessionMetaForToken(TOKEN, null).pipe(
115+ Effect.provide(Layer.mergeAll(store.layer, unusedWorkOS)),
116+ ),
117+ );
118+
119+ expect(meta.organizationName).toBe("Database Org");
120+ expect(store.calls()).toBe(1);
121+ });
122+
123+ it("retries a connect timeout a bounded number of times, then fails retryably", async () => {
124+ const store = countingConnectTimeoutStore();
125+
126+ const result = await Effect.runPromise(
127+ resolveSessionMetaForToken(TOKEN, null).pipe(
128+ Effect.provide(Layer.mergeAll(store.layer, unusedWorkOS)),
129+ Effect.result,
130+ ),
131+ );
132+
133+ expect(Result.isFailure(result), "an unreachable directory is a failure, not a defect").toBe(
134+ true,
135+ );
136+ if (!Result.isFailure(result)) return;
137+ expect(Predicate.isTagged(result.failure, "McpSessionMetaUnavailableError")).toBe(true);
138+ expect(
139+ isMcpSessionMetaUnavailable(result.failure),
140+ "the worker can recognise it across the Durable Object boundary",
141+ ).toBe(true);
142+ expect(store.calls(), "bounded: the first attempt plus its retries").toBe(
143+ SESSION_META_DB_RETRIES + 1,
144+ );
145+ });
146+
147+ it("does not retry a deterministic query failure", async () => {
148+ let calls = 0;
149+ const store = Layer.succeed(UserStoreService)({
150+ use: (operation: string) =>
151+ Effect.suspend(() => {
152+ calls += 1;
153+ return Effect.fail(new UserStoreError({ operation, reason: "query" }));
154+ }),
155+ } as UserStoreService["Service"]);
156+
157+ const result = await Effect.runPromise(
158+ resolveSessionMetaForToken(TOKEN, null).pipe(
159+ Effect.provide(Layer.mergeAll(store, unusedWorkOS)),
160+ Effect.result,
161+ ),
162+ );
163+
164+ expect(Result.isFailure(result)).toBe(true);
165+ expect(calls, "a query the server answered will answer the same way again").toBe(1);
166+ });
167+});
apps/cloud/src/mcp/session-meta.ts+209−0
@@ -0,0 +1,209 @@
1+// ---------------------------------------------------------------------------
2+// Where an MCP session's organization identity comes from.
3+//
4+// Three sources, in the order they are preferred:
5+//
6+// props — the org record the worker resolved while authorizing THIS
7+// request, carried in the session props. Free, and always the
8+// freshest answer available.
9+// stored — the meta this session Durable Object already persisted for the
10+// same organization on an earlier init. Free, and correct for a
11+// session that already exists.
12+// database — an actual read of the `organizations` row.
13+//
14+// Only the third can fail, and it is the one that used to run unconditionally.
15+// Every cold DO init opened a brand-new Postgres connection through Hyperdrive
16+// purely to re-read a row the worker had loaded microseconds earlier on the
17+// same request and then discarded; when that connection could not be
18+// established the read hung for the full connect budget and the failure was
19+// turned into a defect, killing `initialize` on a database that was, at that
20+// same moment, answering the worker's own queries in milliseconds.
21+//
22+// So: prefer what the request already knows, and when the database genuinely is
23+// the only source, give it a bounded retry and a CLASSIFIED failure — the same
24+// transient-vs-definitive split the WorkOS membership check already uses in
25+// `auth-provider.ts` — instead of an unclassified defect.
26+// ---------------------------------------------------------------------------
27+
28+import { Data, Effect, Predicate, Result, Schedule } from "effect";
29+
30+import type { McpSessionInit, SessionMeta } from "@executor-js/cloudflare/mcp/agent-durable-object";
31+
32+import { UserStoreService } from "../auth/context";
33+import { WorkOSClient } from "../auth/workos";
34+import {
35+ isDefinitiveWorkOSDenial,
36+ isTransientUserStoreReason,
37+ type UserStoreError,
38+} from "../auth/errors";
39+import { resolveOrganization } from "../auth/organization";
40+
41+export type SessionMetaSource = "props" | "stored" | "database";
42+
43+export const SESSION_META_SOURCE_ATTRIBUTE = "mcp.session.meta_source";
44+
45+/**
46+ * The wire marker for "the organization directory was unreachable, try again".
47+ *
48+ * A Durable Object's `init` can only reject its Promise, so this travels to the
49+ * worker as an ordinary error message across the DO boundary — the same
50+ * mechanism the condemned-session `"destroyed"` abort already uses. The worker
51+ * matches on it to answer a retryable 503 instead of letting an unclassified
52+ * 500 (and the agents SDK's own DO-operation retry on top of it) turn a
53+ * transient blip into a half-minute client hang.
54+ */
55+export const MCP_SESSION_META_UNAVAILABLE = "mcp_session_meta_unavailable";
56+
57+export class McpSessionMetaUnavailableError extends Data.TaggedError(
58+ "McpSessionMetaUnavailableError",
59+)<{
60+ readonly reason: string;
61+ readonly attempts: number;
62+}> {
63+ override get message(): string {
64+ return `${MCP_SESSION_META_UNAVAILABLE}: organization directory unavailable (${this.reason}) after ${this.attempts} attempts`;
65+ }
66+}
67+
68+export class OrganizationNotFoundError extends Data.TaggedError("OrganizationNotFoundError")<{
69+ readonly organizationId: string;
70+}> {}
71+
72+/** Does this failure, seen at the worker, mean "the session DO could not reach
73+ * the organization directory"? Matches on the message because that is what
74+ * survives the Durable Object RPC boundary. */
75+export const isMcpSessionMetaUnavailable = (error: unknown): boolean =>
76+ // oxlint-disable-next-line executor/no-unknown-error-message -- adapter boundary: a Durable Object rejection reaches the worker as a plain Error whose message IS the signal (same mechanism as the "destroyed" abort)
77+ Predicate.isError(error) && error.message.includes(MCP_SESSION_META_UNAVAILABLE);
78+
79+/**
80+ * Retries for the database path. Deliberately small: the point is to ride out a
81+ * single bad connection attempt, not to sit on a client's `initialize` while a
82+ * database stays down. Exhausting them answers the client quickly and
83+ * retryably, which is strictly better than hanging.
84+ */
85+export const SESSION_META_DB_RETRIES = 2;
86+
87+const RETRY_SCHEDULE = Schedule.both(
88+ Schedule.exponential("200 millis"),
89+ Schedule.recurs(SESSION_META_DB_RETRIES),
90+);
91+
92+const isUserStoreError = Predicate.isTagged("UserStoreError") as (
93+ error: unknown,
94+) => error is UserStoreError;
95+
96+/**
97+ * Is this org-lookup failure worth another attempt? A connection that never
98+ * opened is; a query the server answered with an error, or a WorkOS denial, is
99+ * not — retrying those only burns the client's init budget.
100+ */
101+export const isRetryableOrganizationLookupFailure = (failure: unknown): boolean => {
102+ if (isUserStoreError(failure)) return isTransientUserStoreReason(failure.reason);
103+ if (isDefinitiveWorkOSDenial(failure)) return false;
104+ // A WorkOS blip (429/5xx/timeout/network) — the same class the MCP auth path
105+ // already treats as retryable.
106+ return Predicate.isTagged(failure, "WorkOSError");
107+};
108+
109+const failureReason = (failure: unknown): string =>
110+ isUserStoreError(failure) ? failure.reason : "upstream";
111+
112+const metaFromIdentity = (
113+ token: McpSessionInit,
114+ organization: { readonly name: string; readonly slug?: string },
115+): SessionMeta => ({
116+ organizationId: token.organizationId,
117+ organizationName: organization.name,
118+ ...(organization.slug === undefined ? {} : { organizationSlug: organization.slug }),
119+ userId: token.userId,
120+ resource: token.resource,
121+ elicitationMode: token.elicitationMode,
122+ artifactsEnabled: token.artifactsEnabled,
123+ searchToolsEnabled: token.searchToolsEnabled,
124+});
125+
126+/**
127+ * Read the organization row, retrying only failures a retry can clear, and
128+ * surfacing an exhausted retry as a typed, retryable failure rather than a
129+ * defect.
130+ */
131+const organizationFromDatabase = (
132+ organizationId: string,
133+): Effect.Effect<
134+ { readonly id: string; readonly name: string; readonly slug?: string },
135+ McpSessionMetaUnavailableError | OrganizationNotFoundError,
136+ UserStoreService | WorkOSClient
137+> =>
138+ Effect.gen(function* () {
139+ let attempts = 0;
140+ // `Effect.retry` retries every failure it sees, so definitive failures are
141+ // lifted OUT of the error channel before the schedule ever runs and only
142+ // the retryable ones are left in it.
143+ const attempt = Effect.suspend(() => {
144+ attempts += 1;
145+ return resolveOrganization(organizationId).pipe(
146+ Effect.result,
147+ Effect.flatMap((outcome) =>
148+ Result.isFailure(outcome) && isRetryableOrganizationLookupFailure(outcome.failure)
149+ ? Effect.fail(outcome.failure)
150+ : Effect.succeed(outcome),
151+ ),
152+ );
153+ });
154+
155+ // Two nested Results: the outer one is "the retries ran out", the inner one
156+ // is "the lookup failed definitively on the first look". Both mean the same
157+ // thing to the caller.
158+ const retried = yield* attempt.pipe(Effect.retry(RETRY_SCHEDULE), Effect.result);
159+ const outcome = Result.isFailure(retried) ? Result.fail(retried.failure) : retried.success;
160+
161+ if (Result.isFailure(outcome)) {
162+ return yield* new McpSessionMetaUnavailableError({
163+ reason: failureReason(outcome.failure),
164+ attempts,
165+ });
166+ }
167+ const organization = outcome.success;
168+ if (!organization) return yield* new OrganizationNotFoundError({ organizationId });
169+ return organization;
170+ }).pipe(
171+ Effect.withSpan("mcp.session.resolve_organization", {
172+ attributes: { "mcp.auth.organization_id": organizationId },
173+ }),
174+ );
175+
176+/**
177+ * Build the session meta for an init, preferring the org identity the request
178+ * already carries over any read of the organization directory.
179+ *
180+ * `storedMeta` is this DO's own persisted meta for the SAME organization (the
181+ * base Durable Object only offers a matching one), or `null`.
182+ */
183+export const resolveSessionMetaForToken = (
184+ token: McpSessionInit,
185+ storedMeta: SessionMeta | null,
186+): Effect.Effect<
187+ SessionMeta,
188+ McpSessionMetaUnavailableError | OrganizationNotFoundError,
189+ UserStoreService | WorkOSClient
190+> =>
191+ Effect.gen(function* () {
192+ const fromProps = token.organizationName;
193+ if (fromProps) {
194+ yield* Effect.annotateCurrentSpan(SESSION_META_SOURCE_ATTRIBUTE, "props");
195+ return metaFromIdentity(token, { name: fromProps, slug: token.organizationSlug });
196+ }
197+
198+ if (storedMeta?.organizationName) {
199+ yield* Effect.annotateCurrentSpan(SESSION_META_SOURCE_ATTRIBUTE, "stored");
200+ return metaFromIdentity(token, {
201+ name: storedMeta.organizationName,
202+ slug: storedMeta.organizationSlug,
203+ });
204+ }
205+
206+ yield* Effect.annotateCurrentSpan(SESSION_META_SOURCE_ATTRIBUTE, "database");
207+ const organization = yield* organizationFromDatabase(token.organizationId);
208+ return metaFromIdentity(token, organization);
209+ });
apps/cloud/src/observability/index.ts+47−0
@@ -174,14 +174,61 @@ export const sentryPayloadForCause = ( · 173 unmodified lines
174174 return { primary: input, pretty: null };
175175 };
176176
177+// Safe classification fields our tagged errors carry (`UserStoreError.operation`
178+// / `.reason`, `WorkOSError.status`). They are promoted to Sentry TAGS because
179+// the pretty cause is only an `extra`, and Sentry's server-side scrubber
180+// replaces that extra with "[Filtered]" — leaving an issue with no failing
181+// operation and no reason in it at all. Tags survive, group, and are
182+// searchable. Values are failure modes and operation names; never a query, a
183+// value, or anything customer-derived.
184+const CLASSIFICATION_TAG_FIELDS = ["operation", "reason", "status"] as const;
185+
186+const MAX_CLASSIFICATION_TAG_CHARS = 120;
187+
188+const MAX_CAUSE_NESTING = 3;
189+
190+/** Every error value a cause carries, failures and defects alike. A defect can
191+ * itself be a `Cause` (an inner `runPromise` rejecting with its own squashed
192+ * cause), so the walk unwraps a few levels. */
193+const errorValuesOf = (input: unknown, depth = 0): readonly unknown[] => {
194+ if (depth >= MAX_CAUSE_NESTING) return [];
195+ if (!Cause.isCause(input)) return [input];
196+ const values: unknown[] = [];
197+ for (const reason of input.reasons) {
198+ if (Cause.isFailReason(reason)) values.push(reason.error);
199+ else if (Cause.isDieReason(reason)) values.push(...errorValuesOf(reason.defect, depth + 1));
200+ }
201+ return values;
202+};
203+
204+/** Read the classification fields off the tagged errors inside a cause. First
205+ * writer wins, so the innermost reported error names the issue. */
206+const classificationTagsOf = (input: unknown): Readonly<Record<string, string>> => {
207+ const tags: Record<string, string> = {};
208+ for (const candidate of errorValuesOf(input)) {
209+ if (typeof candidate !== "object" || candidate === null) continue;
210+ const tagged = candidate as Record<string, unknown>;
211+ for (const field of CLASSIFICATION_TAG_FIELDS) {
212+ const value = tagged[field];
213+ if (tags[field] !== undefined) continue;
214+ if (typeof value === "string" || typeof value === "number") {
215+ tags[field] = String(value).slice(0, MAX_CLASSIFICATION_TAG_CHARS);
216+ }
217+ }
218+ }
219+ return tags;
220+};
221+
177222 export const captureCause = (
178223 input: unknown,
179224 context: OtelCorrelationContext | null = null,
180225 ): string | undefined => {
181226 const { primary, pretty } = sentryPayloadForCause(input);
227+ const classification = classificationTagsOf(input);
182228 tagCurrentSentryScopeWithOtelContext(context);
183229 return Sentry.captureException(primary, (scope) => {
184230 tagSentryScopeWithOtelContext(scope, context);
231+ for (const [key, value] of Object.entries(classification)) scope.setTag(key, value);
185232 if (pretty !== null) scope.setExtra("cause", pretty);
186233 return scope;
187234 });
apps/host-cloudflare/src/mcp/session-durable-object.ts+7−2
@@ -102,9 +102,14 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 101 unmodified lines
102102 return { ...handle, end: () => handle.close() };
103103 }
104104
105− protected override resolveSessionMeta(token: McpSessionInit): Effect.Effect<SessionMeta> {
105+ protected override resolveSessionMeta(
106+ token: McpSessionInit,
107+ _storedMeta: SessionMeta | null,
108+ ): Effect.Effect<SessionMeta> {
106109 // Single-tenant: every Access principal belongs to the one configured org,
107− // so there is nothing to resolve — stamp the configured org name.
110+ // so there is nothing to resolve — stamp the configured org name. Nothing
111+ // to reuse from the stored meta either; config is already the cheapest and
112+ // freshest source there is.
108113 return Effect.succeed({
109114 organizationId: token.organizationId,
110115 organizationName: this.cfConfig.organizationName,
e2e/cloud/mcp-session-cold-init.test.ts+100−0
@@ -0,0 +1,100 @@
1+// Cloud: the org identity a session needs at init travels in the session props
2+// the worker already resolved — it is NOT re-read from Postgres inside the
3+// session Durable Object.
4+//
5+// The defect this pins: on EVERY cold DO init the DO opened a brand-new
6+// Postgres connection purely to re-read the `organizations` row the worker had
7+// just read microseconds earlier on the same request (the worker threw it away:
8+// the auth principal hardcoded an empty organization name). When that fresh
9+// connection could not be established the whole `initialize` died — a hard,
10+// client-visible failure on a healthy database, because the only unhealthy
11+// thing was a socket nothing needed to open.
12+//
13+// The contract asserted here is deliberately about the DATA PATH, not the
14+// failure: `McpSessionDOSqlite.resolveSessionMeta` reports where the org
15+// identity came from, and the whole request performs exactly ONE org read (the
16+// worker's own authorization check) instead of two.
17+import { randomBytes } from "node:crypto";
18+
19+import { expect } from "@effect/vitest";
20+import { Effect } from "effect";
21+
22+import { scenario } from "../src/scenario";
23+import { Mcp, Target, Telemetry } from "../src/services";
24+import type { Identity } from "../src/target";
25+
26+const JSON_AND_SSE = "application/json, text/event-stream";
27+
28+const INITIALIZE_REQUEST = {
29+ jsonrpc: "2.0" as const,
30+ id: 1,
31+ method: "initialize",
32+ params: {
33+ protocolVersion: "2025-03-26",
34+ capabilities: {},
35+ clientInfo: { name: "executor-e2e-session-cold-init", version: "0.0.1" },
36+ },
37+};
38+
39+const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label;
40+
41+/** A client-supplied W3C trace context, so every span this one request produces
42+ * — worker plane and Durable Object alike — is addressable by one trace id. */
43+const newTraceContext = (): { readonly traceId: string; readonly traceparent: string } => {
44+ const traceId = randomBytes(16).toString("hex");
45+ const spanId = randomBytes(8).toString("hex");
46+ return { traceId, traceparent: `00-${traceId}-${spanId}-01` };
47+};
48+
49+scenario(
50+ "MCP session cold init · the org identity rides in the session props instead of a second Postgres read",
51+ { timeout: 120_000 },
52+ Effect.gen(function* () {
53+ const target = yield* Target;
54+ const mcp = yield* Mcp;
55+ const telemetry = yield* Telemetry;
56+
57+ const identity = yield* target.newIdentity();
58+ const bearer = yield* mcp.mintBearer(emailOf(identity));
59+ const trace = newTraceContext();
60+
61+ const response = yield* Effect.promise(() =>
62+ fetch(target.mcpUrl, {
63+ method: "POST",
64+ headers: {
65+ accept: JSON_AND_SSE,
66+ "content-type": "application/json",
67+ authorization: `Bearer ${bearer}`,
68+ traceparent: trace.traceparent,
69+ },
70+ body: JSON.stringify(INITIALIZE_REQUEST),
71+ }),
72+ );
73+ yield* Effect.promise(() => response.text());
74+ expect(response.status, "initialize opens a session").toBe(200);
75+ expect(response.headers.get("mcp-session-id"), "the session id is minted").toBeTruthy();
76+
77+ // The DO really did resolve meta on this request (a cold init), and it
78+ // resolved it from the props the worker handed over.
79+ const resolveSpan = yield* telemetry.expectSpan({
80+ traceId: trace.traceId,
81+ operation: "McpSessionDOSqlite.resolveSessionMeta",
82+ });
83+ // One org read for the whole request: the worker's own authorization
84+ // lookup. A second one means the DO reopened a connection to re-read a row
85+ // the request already had.
86+ const orgReads = yield* telemetry.searchSpans({
87+ traceId: trace.traceId,
88+ operation: "user_store.getOrganization",
89+ });
90+ expect(
91+ orgReads.length,
92+ "only the worker's authorization check reads the organization row",
93+ ).toBe(1);
94+
95+ expect(
96+ resolveSpan.span.tags["mcp.session.meta_source"],
97+ "the session meta comes from the props the worker already resolved",
98+ ).toBe("props");
99+ }),
100+);
packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts+114−1
@@ -1,6 +1,6 @@
11 // oxlint-disable executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: the storage fake reproduces the plain Errors the Cloudflare runtime throws, and rejecting is the only way a DurableObjectStorage reports them
22 import { describe, expect, it } from "@effect/vitest";
3−import { Cause, Effect } from "effect";
3+import { Cause, Effect, Exit } from "effect";
44 import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
55 import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
66 import type { JSONRPCMessage, MessageExtraInfo } from "@modelcontextprotocol/sdk/types.js";
@@ -308,6 +308,119 @@ describe("McpAgentSessionDOBase apps capability persistence", () => { · 301 unmodified lines
308308 });
309309 });
310310
311+// A cold restore used to re-resolve the org identity through the host's backing
312+// store (on cloud: a brand-new Postgres connection) BEFORE it ever looked at
313+// the meta this DO had already persisted for the very session it is restoring.
314+// A transient failure of that lookup killed `init` and the restore with it —
315+// for a row the DO was already holding. The DO's own storage is the
316+// authoritative copy of the org identity of a session it already minted, so it
317+// is offered to the host first; the host still rebuilds everything the CONNECT
318+// carries (resource, elicitation mode, capability flags) from the token.
319+describe("McpAgentSessionDOBase cold-restore meta reuse", () => {
320+ type RestoreSession = {
321+ ctx: MemoryStorage;
322+ getSessionId: () => string;
323+ loadSessionMeta: () => Effect.Effect<SessionMeta | null>;
324+ resolveSessionMeta: (
325+ token: unknown,
326+ storedMeta: SessionMeta | null,
327+ ) => Effect.Effect<SessionMeta>;
328+ resolveAndStoreSessionMeta: (token: unknown) => Effect.Effect<SessionMeta>;
329+ };
330+
331+ const storedMeta: SessionMeta = {
332+ organizationId: "org-1",
333+ organizationName: "Org One",
334+ organizationSlug: "org-one",
335+ userId: "user-1",
336+ resource: defaultMcpResource,
337+ };
338+
339+ const token = {
340+ organizationId: "org-1",
341+ userId: "user-1",
342+ elicitationMode: "model" as const,
343+ resource: defaultMcpResource,
344+ };
345+
346+ const makeRestoreSession = async (
347+ stored: SessionMeta | null,
348+ ): Promise<{ session: RestoreSession; storage: MemoryStorage }> => {
349+ const storage = new MemoryStorage();
350+ if (stored) await storage.put("session-meta", stored);
351+ const session = Object.create(McpAgentSessionDOBase.prototype) as RestoreSession;
352+ session.ctx = storage;
353+ session.getSessionId = () => "session-restore";
354+ return { session, storage };
355+ };
356+
357+ // The host stands in for cloud with an unreachable database: it can only
358+ // answer when the DO hands it what it already knows.
359+ const hostWithUnreachableStore =
360+ (seen: { storedMeta: SessionMeta | null; calls: number }) =>
361+ (tokenIn: unknown, stored: SessionMeta | null): Effect.Effect<SessionMeta> => {
362+ seen.calls += 1;
363+ seen.storedMeta = stored;
364+ if (!stored) return Effect.die("organization lookup: CONNECT_TIMEOUT");
365+ const t = tokenIn as { readonly userId: string; readonly organizationId: string };
366+ return Effect.succeed({
367+ organizationId: t.organizationId,
368+ organizationName: stored.organizationName,
369+ organizationSlug: stored.organizationSlug,
370+ userId: t.userId,
371+ resource: defaultMcpResource,
372+ } satisfies SessionMeta);
373+ };
374+
375+ it("restores from its own stored meta when the backing store is unreachable", async () => {
376+ const { session } = await makeRestoreSession(storedMeta);
377+ const seen = { storedMeta: null as SessionMeta | null, calls: 0 };
378+ session.resolveSessionMeta = hostWithUnreachableStore(seen);
379+
380+ const resolved = await Effect.runPromise(session.resolveAndStoreSessionMeta(token));
381+
382+ expect(seen.calls).toBe(1);
383+ expect(seen.storedMeta).toMatchObject({ organizationId: "org-1", organizationName: "Org One" });
384+ expect(resolved.organizationName).toBe("Org One");
385+ expect(resolved.organizationSlug).toBe("org-one");
386+ });
387+
388+ // Stored meta is only a shortcut for the SAME organization. A session id
389+ // reused across orgs must never inherit the previous org's identity.
390+ it("offers nothing when the stored meta belongs to another organization", async () => {
391+ const { session } = await makeRestoreSession({ ...storedMeta, organizationId: "org-other" });
392+ const seen = { storedMeta: null as SessionMeta | null, calls: 0 };
393+ session.resolveSessionMeta = hostWithUnreachableStore(seen);
394+
395+ const exit = await Effect.runPromiseExit(session.resolveAndStoreSessionMeta(token));
396+
397+ expect(Exit.isFailure(exit)).toBe(true);
398+ expect(seen.storedMeta).toBeNull();
399+ });
400+
401+ // A brand-new session has nothing stored; the host must resolve from scratch.
402+ it("offers nothing on a first init", async () => {
403+ const { session } = await makeRestoreSession(null);
404+ const seen = { storedMeta: null as SessionMeta | null, calls: 0 };
405+ session.resolveSessionMeta = (tokenIn, stored) => {
406+ seen.calls += 1;
407+ seen.storedMeta = stored;
408+ const t = tokenIn as { readonly userId: string; readonly organizationId: string };
409+ return Effect.succeed({
410+ organizationId: t.organizationId,
411+ organizationName: "Freshly Resolved",
412+ userId: t.userId,
413+ resource: defaultMcpResource,
414+ } satisfies SessionMeta);
415+ };
416+
417+ const resolved = await Effect.runPromise(session.resolveAndStoreSessionMeta(token));
418+
419+ expect(seen.storedMeta).toBeNull();
420+ expect(resolved.organizationName).toBe("Freshly Resolved");
421+ });
422+});
423+
311424 describe("McpAgentSessionDOBase transport restore", () => {
312425 it("preserves hibernated response streams when a cold isolate starts", async () => {
313426 const session = await makeHarnessSession();
packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts+40−6
@@ -39,6 +39,13 @@ export type IncomingTraceHeaders = IncomingPropagationHeaders; · 38 unmodified lines
3939
4040 export interface McpSessionInit {
4141 readonly organizationId: string;
42+ /** The organization's display name, as the worker resolved it while
43+ * authorizing this very request. Carried so the session DO never has to
44+ * re-read a row the request already loaded. Absent when the auth plane could
45+ * not name the org, in which case the host resolves it itself. */
46+ readonly organizationName?: string;
47+ /** The organization's URL slug, from the same resolved record. */
48+ readonly organizationSlug?: string;
4249 readonly userId: string;
4350 readonly elicitationMode: McpElicitationMode;
4451 /** Whether this session serves artifacts, read off `?artifacts=` at connect
@@ -247,7 +254,20 @@ export abstract class McpAgentSessionDOBase< · 202 unmodified lines
247254
248255 protected abstract openSessionDb(): TDbHandle | Promise<TDbHandle>;
249256
250− protected abstract resolveSessionMeta(token: McpSessionInit): Effect.Effect<SessionMeta>;
257+ /**
258+ * Build the session's {@link SessionMeta} for this init.
259+ *
260+ * `storedMeta` is what this DO already persisted for the SAME organization on
261+ * an earlier init, or `null`. It is offered first so a host never has to
262+ * re-resolve an org identity it is already holding — on cloud that resolution
263+ * is a fresh Postgres connection, and a cold restore used to die on it. Every
264+ * field the CONNECT carries (resource, elicitation mode, capability flags)
265+ * still comes from `token`; only the org identity may be reused.
266+ */
267+ protected abstract resolveSessionMeta(
268+ token: McpSessionInit,
269+ storedMeta: SessionMeta | null,
270+ ): Effect.Effect<SessionMeta>;
251271
252272 protected abstract buildMcpServer(
253273 sessionMeta: SessionMeta,
@@ -524,12 +544,21 @@ export abstract class McpAgentSessionDOBase< · 270 unmodified lines
524544 private resolveAndStoreSessionMeta(token: McpSessionInit) {
525545 const self = this;
526546 return Effect.gen(function* () {
527− const resolved = yield* self.resolveSessionMeta(token);
528− // `init` runs again on every cold restore, and `resolveSessionMeta`
529− // rebuilds meta from the bearer token — which carries no negotiated
530− // capabilities. Carry the stored value forward, or restoring the session
531− // would erase the very bit that survives the restore.
547+ // Read what this DO already knows BEFORE asking the host to resolve
548+ // anything. `init` runs again on every cold restore, and the stored meta
549+ // is this session's own durable record of the organization it was minted
550+ // for — re-deriving that identity from the host's backing store is the
551+ // single most failure-prone step of a restore (on cloud, a brand-new
552+ // Postgres connection) and it is redundant for a session that already
553+ // exists. It is offered only for the SAME organization; a token naming a
554+ // different org resolves from scratch.
532555 const stored = yield* self.loadSessionMeta();
556+ const reusable = stored && stored.organizationId === token.organizationId ? stored : null;
557+ // The stored meta also carries the capabilities negotiated at
558+ // `initialize`, which the bearer token knows nothing about. Carry them
559+ // forward, or restoring the session would erase the very bit that
560+ // survives the restore.
561+ const resolved = yield* self.resolveSessionMeta(token, reusable);
533562 const sessionMeta: SessionMeta = {
534563 ...resolved,
535564 ...(token.webOrigin ? { webOrigin: token.webOrigin } : {}),
@@ -803,6 +832,11 @@ export abstract class McpAgentSessionDOBase< · 267 unmodified lines
803832 .bestEffortBookkeeping("init.mark_activity", () => self.markActivity())
804833 .pipe(Effect.withSpan("McpSessionDO.markActivity"));
805834 }).pipe(
835+ // ONE capture owner for an init defect. `init` can only reject its
836+ // Promise, and the host's DO-level error instrumentation captures that
837+ // rejection too — so the DO claims the cause below and the host drops its
838+ // own echo, rather than both filing the same failure as two issues with
839+ // the same trace id and span id.
806840 Effect.tapCause((cause) =>
807841 Effect.gen(function* () {
808842 // A Cloudflare platform reset of an in-flight init is not a defect —