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 @@
1import { Context, Effect, Layer } from "effect";1import { Context, Effect, Layer } from "effect";
2import { makeUserStore } from "../auth/user-store";2import { makeUserStore } from "../auth/user-store";
3import { DbService } from "../db/db";3import { DbService } from "../db/db";
4import { UserStoreError, tryPromiseService, withServiceLogging } from "./errors";4import { tryPromiseService, userStoreErrorFromFailure, withServiceLogging } from "./errors";
5 5
6// ---------------------------------------------------------------------------6// ---------------------------------------------------------------------------
7// UserStoreService — wraps the Drizzle-backed user store with Effect7// UserStoreService — wraps the Drizzle-backed user store with Effect
@@ -10,13 +10,15 @@ import { UserStoreError, tryPromiseService, withServiceLogging } from "./errors" · 2 unmodified lines
10type RawStore = ReturnType<typeof makeUserStore>;10type RawStore = ReturnType<typeof makeUserStore>;
11 11
12// `op` names the store call so every span reads `user_store.<operation>`12// `op` names the store call so every span reads `user_store.<operation>`
13// instead of one undifferentiated "user_store" bucket, and failures log which13// instead of one undifferentiated "user_store" bucket, failures log which
14// query actually failed.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.
15const makeService = (store: RawStore) => ({17const makeService = (store: RawStore) => ({
16 use: <A>(op: string, fn: (s: RawStore) => Promise<A>) =>18 use: <A>(op: string, fn: (s: RawStore) => Promise<A>) =>
17 withServiceLogging(19 withServiceLogging(
18 `user_store.${op}`,20 `user_store.${op}`,
19 () => new UserStoreError(),21 (failure) => userStoreErrorFromFailure(op, failure),
20 tryPromiseService(() => fn(store)),22 tryPromiseService(() => fn(store)),
21 ),23 ),
22});24});
apps/cloud/src/auth/errors.ts+95−2
@@ -1,10 +1,103 @@
1import { Data, Effect, Option, Predicate, Schema } from "effect";1import { Data, Effect, Option, Predicate, Schema } from "effect";
2 2
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.
6export const USER_STORE_FAILURE_REASONS = [
7 "connect_timeout",
8 "connection_closed",
9 "query",
10 "unknown",
11] as const;
12
13export 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 */
3export class UserStoreError extends Schema.TaggedErrorClass<UserStoreError>()(26export class UserStoreError extends Schema.TaggedErrorClass<UserStoreError>()(
4 "UserStoreError",27 "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 },
6 { httpApiStatus: 500 },34 { 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. */
43export 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.
51const MAX_CAUSE_DEPTH = 8;
52
53const 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
63const 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
69const 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`. */
88export 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. */
99export const userStoreErrorFromFailure = (operation: string, failure: unknown): UserStoreError =>
100 new UserStoreError({ operation, reason: userStoreReasonFromCause(failure) });
8 101
9export class WorkOSError extends Schema.TaggedErrorClass<WorkOSError>()(102export class WorkOSError extends Schema.TaggedErrorClass<WorkOSError>()(
10 "WorkOSError",103 "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).
11import { createServer, type Server, type Socket } from "node:net";
12
13import { describe, expect, it } from "@effect/vitest";
14import { Effect, Layer, Result } from "effect";
15import postgres from "postgres";
16
17import { UserStoreService } from "./context";
18import { ServiceAdapterError, userStoreReasonFromCause, type UserStoreError } from "./errors";
19import { 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.
25const 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
50const 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 …`).
75const 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
81const stubDb = Layer.succeed(DbService)({ db: {} as never });
82
83const 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
96describe("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
27import { wrapMcpSseResponse } from "../observability/memory-metrics";27import { wrapMcpSseResponse } from "../observability/memory-metrics";
28import { WorkerTelemetryLive } from "../observability/telemetry";28import { WorkerTelemetryLive } from "../observability/telemetry";
29import { cloudMcpAuth } from "./auth-provider";29import { cloudMcpAuth } from "./auth-provider";
30import { isMcpSessionMetaUnavailable } from "./session-meta";
30import { McpSessionDOSqlite } from "./session-durable-object";31import { McpSessionDOSqlite } from "./session-durable-object";
31import { parseTraceparent } from "./traceparent";32import { parseTraceparent } from "./traceparent";
32 33
34const MCP_SESSION_UNAVAILABLE_MESSAGE = "Session storage temporarily unavailable - please retry";
35
33const corsPreflightResponse = (): Response =>36const corsPreflightResponse = (): Response =>
34 new Response(null, {37 new Response(null, {
35 status: 204,38 status: 204,
@@ -171,6 +174,13 @@ const propsForPrincipal = ( · 135 unmodified lines
171 return {174 return {
172 session: {175 session: {
173 organizationId: principal.organizationId,176 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 } : {}),
174 userId: principal.accountId,184 userId: principal.accountId,
175 elicitationMode: readElicitationMode(request),185 elicitationMode: readElicitationMode(request),
176 artifactsEnabled: readArtifactsEnabled(request),186 artifactsEnabled: readArtifactsEnabled(request),
@@ -306,6 +316,23 @@ export const makeCloudMcpAgentHandler = () => { · 129 unmodified lines
306 // vocabulary — a deploy, a storage timeout, a cancelled316 // vocabulary — a deploy, a storage timeout, a cancelled
307 // blockConcurrencyWhile — which reaches here through the agents SDK's own317 // blockConcurrencyWhile — which reaches here through the agents SDK's own
308 // `getServerByName` retry and used to 500 identically.318 // `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 }
309 const failure = classifyDurableObjectError(error);336 const failure = classifyDurableObjectError(error);
310 if (!failure) {337 if (!failure) {
311 // 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 unchanged338 // 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
70 authorize: () => Effect.succeed(null),70 authorize: () => Effect.succeed(null),
71});71});
72 72
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.
74const stubOrgAuthActive = Layer.succeed(McpOrganizationAuth)({76const stubOrgAuthActive = Layer.succeed(McpOrganizationAuth)({
75 authorize: () => Effect.succeed(ORG_ID),77 authorize: () => Effect.succeed({ id: ORG_ID, name: "Stub Org", slug: "stub-org" }),
76});78});
77 79
78// A failure that is not a WorkOSError at all (e.g. the per-request DB layer80// 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
55 McpAuthLive,55 McpAuthLive,
56 McpOrganizationAuth,56 McpOrganizationAuth,
57 McpOrganizationAuthLive,57 McpOrganizationAuthLive,
58 type AuthorizedMcpOrganization,
58 type McpAuthResult,59 type McpAuthResult,
59 type VerifiedToken,60 type VerifiedToken,
60} from "./auth";61} from "./auth";
@@ -88,16 +89,24 @@ const ORGANIZATION_AUTHORIZE_UNAVAILABLE = · 27 unmodified lines
88 89
89/**90/**
90 * Enrich a cloud {@link VerifiedToken} (which carries only accountId +91 * Enrich a cloud {@link VerifiedToken} (which carries only accountId +
91 * organizationId) into the full {@link Principal} the seam validates. The92 * organizationId) into the full {@link Principal} the seam validates.
92 * envelope only uses `accountId` + `organizationId` for ownership; cloud93 *
93 * resolves org name/email inside the DO, so the cosmetic identity fields carry94 * The org name and slug come from the record the live membership check just
94 * empty placeholders. `organizationId` is guaranteed non-null here because the95 * resolved — this is the whole point of `authorize` returning the record rather
95 * Forbidden branch already rejected the no-org case before Authenticated.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.
96 */101 */
97const principalFromToken = (token: VerifiedToken, organizationId: string): Principal => ({102const principalFromToken = (
103 token: VerifiedToken,
104 organization: AuthorizedMcpOrganization,
105): Principal => ({
98 accountId: token.accountId,106 accountId: token.accountId,
99 organizationId,107 organizationId: organization.id,
100 organizationName: "",108 organizationName: organization.name,
109 ...(organization.slug === undefined ? {} : { organizationSlug: organization.slug }),
101 email: "",110 email: "",
102 name: null,111 name: null,
103 avatarUrl: null,112 avatarUrl: null,
@@ -223,9 +232,9 @@ export const cloudMcpAuthProviderLayer: Layer.Layer< · 119 unmodified lines
223 // caller genuinely holds no active membership (revoked / never a member)232 // caller genuinely holds no active membership (revoked / never a member)
224 // — a real Forbidden, which the handler may act on by condemning the233 // — a real Forbidden, which the handler may act on by condemning the
225 // session.234 // session.
226 const organizationId = authorizeResult.success;235 const organization = authorizeResult.success;
227 if (!organizationId) return forbidden(NO_ORGANIZATION_MESSAGE, -32001);236 if (!organization) return forbidden(NO_ORGANIZATION_MESSAGE, -32001);
228 return authenticated(principalFromToken(token, organizationId));237 return authenticated(principalFromToken(token, organization));
229 });238 });
230 239
231 const toOutcome = (request: Request, result: McpAuthResult): Effect.Effect<AuthOutcome> => {240 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
160 }160 }
161>()("@executor-js/cloud/McpAuth") {}161>()("@executor-js/cloud/McpAuth") {}
162 162
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 */
170export type AuthorizedMcpOrganization = {
171 readonly id: string;
172 readonly name: string;
173 readonly slug?: string;
174};
175
163export class McpOrganizationAuth extends Context.Service<176export class McpOrganizationAuth extends Context.Service<
164 McpOrganizationAuth,177 McpOrganizationAuth,
165 {178 {
166 /**179 /**
167 * Authorize `accountId` against an org SELECTOR — a WorkOS org id180 * Authorize `accountId` against an org SELECTOR — a WorkOS org id
168 * (`org_…`, from the token or a legacy URL) or the org's URL slug (the181 * (`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 the182 * form the install card prints). Returns the resolved organization when the
170 * caller holds an active membership, `null` otherwise.183 * caller holds an active membership, `null` otherwise.
171 */184 */
172 readonly authorize: (185 readonly authorize: (
173 accountId: string,186 accountId: string,
174 organizationSelector: string,187 organizationSelector: string,
175 ) => Effect.Effect<string | null, unknown>;188 ) => Effect.Effect<AuthorizedMcpOrganization | null, unknown>;
176 }189 }
177>()("@executor-js/cloud/McpOrganizationAuth") {}190>()("@executor-js/cloud/McpOrganizationAuth") {}
178 191
@@ -216,7 +229,9 @@ export const McpOrganizationAuthLive = Layer.succeed(McpOrganizationAuth)({ · 37 unmodified lines
216 Effect.flatMap((organizationId) =>229 Effect.flatMap((organizationId) =>
217 organizationId230 organizationId
218 ? authorizeOrganization(accountId, organizationId).pipe(231 ? 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 ),
220 )235 )
221 : Effect.succeed(null),236 : Effect.succeed(null),
222 ),237 ),
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
58// `SessionAuthLive` instead.)58// `SessionAuthLive` instead.)
59import { CoreSharedServices } from "../auth/workos";59import { CoreSharedServices } from "../auth/workos";
60import { UserStoreService } from "../auth/context";60import { UserStoreService } from "../auth/context";
61import { resolveOrganization } from "../auth/organization";61import { resolveSessionMetaForToken } from "./session-meta";
62import {62import {
63 DbService,63 DbService,
64 combinedSchema,64 combinedSchema,
@@ -107,10 +107,6 @@ type CloudSessionDbHandle = DbServiceShape & { · 42 unmodified lines
107 readonly end: () => Promise<void>;107 readonly end: () => Promise<void>;
108};108};
109 109
110class OrganizationNotFoundError extends Data.TaggedError("OrganizationNotFoundError")<{
111 readonly organizationId: string;
112}> {}
113
114class McpModelResumeForwardError extends Data.TaggedError("McpModelResumeForwardError")<{110class McpModelResumeForwardError extends Data.TaggedError("McpModelResumeForwardError")<{
115 readonly cause: unknown;111 readonly cause: unknown;
116}> {}112}> {}
@@ -209,28 +205,27 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 92 unmodified lines
209 });205 });
210 }206 }
211 207
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.
213 const dbHandle = makeEphemeralDb();216 const dbHandle = makeEphemeralDb();
214 return Effect.gen(function* () {217 return resolveSessionMetaForToken(token, storedMeta).pipe(
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(
230 Effect.withSpan("McpSessionDOSqlite.resolveSessionMeta"),218 Effect.withSpan("McpSessionDOSqlite.resolveSessionMeta"),
231 Effect.provide(makeSessionServices(dbHandle)),219 Effect.provide(makeSessionServices(dbHandle)),
232 Effect.ensuring(Effect.promise(() => dbHandle.end())),220 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 bearer221 // 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
234 Effect.orDie,229 Effect.orDie,
235 );230 );
236 }231 }
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`.
9import { describe, expect, it } from "@effect/vitest";
10import { Effect, Layer, Predicate, Result } from "effect";
11
12import { defaultMcpResource } from "@executor-js/host-mcp";
13import type { McpSessionInit, SessionMeta } from "@executor-js/cloudflare/mcp/agent-durable-object";
14
15import { UserStoreService } from "../auth/context";
16import { UserStoreError } from "../auth/errors";
17import { WorkOSClient, type WorkOSClientService } from "../auth/workos";
18import {
19 isMcpSessionMetaUnavailable,
20 resolveSessionMetaForToken,
21 SESSION_META_DB_RETRIES,
22} from "./session-meta";
23
24const TOKEN: McpSessionInit = {
25 organizationId: "org_test",
26 userId: "user_test",
27 elicitationMode: "model",
28 resource: defaultMcpResource,
29 artifactsEnabled: true,
30};
31
32const 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. */
42const 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
59const 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
84const 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.
95describe("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
28import { Data, Effect, Predicate, Result, Schedule } from "effect";
29
30import type { McpSessionInit, SessionMeta } from "@executor-js/cloudflare/mcp/agent-durable-object";
31
32import { UserStoreService } from "../auth/context";
33import { WorkOSClient } from "../auth/workos";
34import {
35 isDefinitiveWorkOSDenial,
36 isTransientUserStoreReason,
37 type UserStoreError,
38} from "../auth/errors";
39import { resolveOrganization } from "../auth/organization";
40
41export type SessionMetaSource = "props" | "stored" | "database";
42
43export 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 */
55export const MCP_SESSION_META_UNAVAILABLE = "mcp_session_meta_unavailable";
56
57export 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
68export 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. */
75export 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 */
85export const SESSION_META_DB_RETRIES = 2;
86
87const RETRY_SCHEDULE = Schedule.both(
88 Schedule.exponential("200 millis"),
89 Schedule.recurs(SESSION_META_DB_RETRIES),
90);
91
92const 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 */
101export 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
109const failureReason = (failure: unknown): string =>
110 isUserStoreError(failure) ? failure.reason : "upstream";
111
112const 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 */
131const 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 */
183export 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
174 return { primary: input, pretty: null };174 return { primary: input, pretty: null };
175};175};
176 176
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.
184const CLASSIFICATION_TAG_FIELDS = ["operation", "reason", "status"] as const;
185
186const MAX_CLASSIFICATION_TAG_CHARS = 120;
187
188const 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. */
193const 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. */
206const 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
177export const captureCause = (222export const captureCause = (
178 input: unknown,223 input: unknown,
179 context: OtelCorrelationContext | null = null,224 context: OtelCorrelationContext | null = null,
180): string | undefined => {225): string | undefined => {
181 const { primary, pretty } = sentryPayloadForCause(input);226 const { primary, pretty } = sentryPayloadForCause(input);
227 const classification = classificationTagsOf(input);
182 tagCurrentSentryScopeWithOtelContext(context);228 tagCurrentSentryScopeWithOtelContext(context);
183 return Sentry.captureException(primary, (scope) => {229 return Sentry.captureException(primary, (scope) => {
184 tagSentryScopeWithOtelContext(scope, context);230 tagSentryScopeWithOtelContext(scope, context);
231 for (const [key, value] of Object.entries(classification)) scope.setTag(key, value);
185 if (pretty !== null) scope.setExtra("cause", pretty);232 if (pretty !== null) scope.setExtra("cause", pretty);
186 return scope;233 return scope;
187 });234 });
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
102 return { ...handle, end: () => handle.close() };102 return { ...handle, end: () => handle.close() };
103 }103 }
104 104
105 protected override resolveSessionMeta(token: McpSessionInit): Effect.Effect<SessionMeta> {105 protected override resolveSessionMeta(
106 token: McpSessionInit,
107 _storedMeta: SessionMeta | null,
108 ): Effect.Effect<SessionMeta> {
106 // Single-tenant: every Access principal belongs to the one configured org,109 // 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.
108 return Effect.succeed({113 return Effect.succeed({
109 organizationId: token.organizationId,114 organizationId: token.organizationId,
110 organizationName: this.cfConfig.organizationName,115 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.
17import { randomBytes } from "node:crypto";
18
19import { expect } from "@effect/vitest";
20import { Effect } from "effect";
21
22import { scenario } from "../src/scenario";
23import { Mcp, Target, Telemetry } from "../src/services";
24import type { Identity } from "../src/target";
25
26const JSON_AND_SSE = "application/json, text/event-stream";
27
28const 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
39const 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. */
43const 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
49scenario(
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 @@
1// 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 them1// 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
2import { describe, expect, it } from "@effect/vitest";2import { describe, expect, it } from "@effect/vitest";
3import { Cause, Effect } from "effect";3import { Cause, Effect, Exit } from "effect";
4import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";4import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";5import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
6import type { JSONRPCMessage, MessageExtraInfo } from "@modelcontextprotocol/sdk/types.js";6import type { JSONRPCMessage, MessageExtraInfo } from "@modelcontextprotocol/sdk/types.js";
@@ -308,6 +308,119 @@ describe("McpAgentSessionDOBase apps capability persistence", () => { · 301 unmodified lines
308 });308 });
309});309});
310 310
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.
319describe("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
311describe("McpAgentSessionDOBase transport restore", () => {424describe("McpAgentSessionDOBase transport restore", () => {
312 it("preserves hibernated response streams when a cold isolate starts", async () => {425 it("preserves hibernated response streams when a cold isolate starts", async () => {
313 const session = await makeHarnessSession();426 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
39 39
40export interface McpSessionInit {40export interface McpSessionInit {
41 readonly organizationId: string;41 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;
42 readonly userId: string;49 readonly userId: string;
43 readonly elicitationMode: McpElicitationMode;50 readonly elicitationMode: McpElicitationMode;
44 /** Whether this session serves artifacts, read off `?artifacts=` at connect51 /** Whether this session serves artifacts, read off `?artifacts=` at connect
@@ -247,7 +254,20 @@ export abstract class McpAgentSessionDOBase< · 202 unmodified lines
247 254
248 protected abstract openSessionDb(): TDbHandle | Promise<TDbHandle>;255 protected abstract openSessionDb(): TDbHandle | Promise<TDbHandle>;
249 256
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>;
251 271
252 protected abstract buildMcpServer(272 protected abstract buildMcpServer(
253 sessionMeta: SessionMeta,273 sessionMeta: SessionMeta,
@@ -524,12 +544,21 @@ export abstract class McpAgentSessionDOBase< · 270 unmodified lines
524 private resolveAndStoreSessionMeta(token: McpSessionInit) {544 private resolveAndStoreSessionMeta(token: McpSessionInit) {
525 const self = this;545 const self = this;
526 return Effect.gen(function* () {546 return Effect.gen(function* () {
527 const resolved = yield* self.resolveSessionMeta(token);547 // Read what this DO already knows BEFORE asking the host to resolve
528 // `init` runs again on every cold restore, and `resolveSessionMeta`548 // anything. `init` runs again on every cold restore, and the stored meta
529 // rebuilds meta from the bearer token — which carries no negotiated549 // is this session's own durable record of the organization it was minted
530 // capabilities. Carry the stored value forward, or restoring the session550 // for — re-deriving that identity from the host's backing store is the
531 // would erase the very bit that survives the restore.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.
532 const stored = yield* self.loadSessionMeta();555 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);
533 const sessionMeta: SessionMeta = {562 const sessionMeta: SessionMeta = {
534 ...resolved,563 ...resolved,
535 ...(token.webOrigin ? { webOrigin: token.webOrigin } : {}),564 ...(token.webOrigin ? { webOrigin: token.webOrigin } : {}),
@@ -803,6 +832,11 @@ export abstract class McpAgentSessionDOBase< · 267 unmodified lines
803 .bestEffortBookkeeping("init.mark_activity", () => self.markActivity())832 .bestEffortBookkeeping("init.mark_activity", () => self.markActivity())
804 .pipe(Effect.withSpan("McpSessionDO.markActivity"));833 .pipe(Effect.withSpan("McpSessionDO.markActivity"));
805 }).pipe(834 }).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.
806 Effect.tapCause((cause) =>840 Effect.tapCause((cause) =>
807 Effect.gen(function* () {841 Effect.gen(function* () {
808 // A Cloudflare platform reset of an in-flight init is not a defect —842 // A Cloudflare platform reset of an in-flight init is not a defect —