Unified · Split
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { Context, Effect, Layer } from "effect"; |
| 2 | 2 | import { makeUserStore } from "../auth/user-store"; |
| 3 | 3 | import { DbService } from "../db/db"; |
| 4 | −import { UserStoreError, tryPromiseService, withServiceLogging } from "./errors"; | |
| 4 | +import { tryPromiseService, userStoreErrorFromFailure, withServiceLogging } from "./errors"; | |
| 5 | 5 | |
| 6 | 6 | // --------------------------------------------------------------------------- |
| 7 | 7 | // UserStoreService — wraps the Drizzle-backed user store with Effect |
| @@ -10,13 +10,15 @@ import { UserStoreError, tryPromiseService, withServiceLogging } from "./errors" · 2 unmodified lines | ||
| 10 | 10 | type RawStore = ReturnType<typeof makeUserStore>; |
| 11 | 11 | |
| 12 | 12 | // `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. | |
| 15 | 17 | const makeService = (store: RawStore) => ({ |
| 16 | 18 | use: <A>(op: string, fn: (s: RawStore) => Promise<A>) => |
| 17 | 19 | withServiceLogging( |
| 18 | 20 | `user_store.${op}`, |
| 19 | − () => new UserStoreError(), | |
| 21 | + (failure) => userStoreErrorFromFailure(op, failure), | |
| 20 | 22 | tryPromiseService(() => fn(store)), |
| 21 | 23 | ), |
| 22 | 24 | }); |
| @@ -1,10 +1,103 @@ | ||
| 1 | 1 | import { 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. | |
| 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 | + */ | |
| 3 | 26 | export class UserStoreError extends Schema.TaggedErrorClass<UserStoreError>()( |
| 4 | 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 | 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. */ | |
| 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) }); | |
| 8 | 101 | |
| 9 | 102 | export class WorkOSError extends Schema.TaggedErrorClass<WorkOSError>()( |
| 10 | 103 | "WorkOSError", |
| @@ -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 | +}); | |
| @@ -27,9 +27,12 @@ import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; · 26 unmodified lines | ||
| 27 | 27 | import { wrapMcpSseResponse } from "../observability/memory-metrics"; |
| 28 | 28 | import { WorkerTelemetryLive } from "../observability/telemetry"; |
| 29 | 29 | import { cloudMcpAuth } from "./auth-provider"; |
| 30 | +import { isMcpSessionMetaUnavailable } from "./session-meta"; | |
| 30 | 31 | import { McpSessionDOSqlite } from "./session-durable-object"; |
| 31 | 32 | import { parseTraceparent } from "./traceparent"; |
| 32 | 33 | |
| 34 | +const MCP_SESSION_UNAVAILABLE_MESSAGE = "Session storage temporarily unavailable - please retry"; | |
| 35 | + | |
| 33 | 36 | const corsPreflightResponse = (): Response => |
| 34 | 37 | new Response(null, { |
| 35 | 38 | status: 204, |
| @@ -171,6 +174,13 @@ const propsForPrincipal = ( · 135 unmodified lines | ||
| 171 | 174 | return { |
| 172 | 175 | session: { |
| 173 | 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 | 184 | userId: principal.accountId, |
| 175 | 185 | elicitationMode: readElicitationMode(request), |
| 176 | 186 | artifactsEnabled: readArtifactsEnabled(request), |
| @@ -306,6 +316,23 @@ export const makeCloudMcpAgentHandler = () => { · 129 unmodified lines | ||
| 306 | 316 | // vocabulary — a deploy, a storage timeout, a cancelled |
| 307 | 317 | // blockConcurrencyWhile — which reaches here through the agents SDK's own |
| 308 | 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 | 336 | const failure = classifyDurableObjectError(error); |
| 310 | 337 | if (!failure) { |
| 311 | 338 | // 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 |
| @@ -70,9 +70,11 @@ const stubOrgAuthNoMembership = Layer.succeed(McpOrganizationAuth)({ · 69 unmodified lines | ||
| 70 | 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. | |
| 74 | 76 | const 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 | 80 | // A failure that is not a WorkOSError at all (e.g. the per-request DB layer |
| @@ -55,6 +55,7 @@ import { · 54 unmodified lines | ||
| 55 | 55 | McpAuthLive, |
| 56 | 56 | McpOrganizationAuth, |
| 57 | 57 | McpOrganizationAuthLive, |
| 58 | + type AuthorizedMcpOrganization, | |
| 58 | 59 | type McpAuthResult, |
| 59 | 60 | type VerifiedToken, |
| 60 | 61 | } from "./auth"; |
| @@ -88,16 +89,24 @@ const ORGANIZATION_AUTHORIZE_UNAVAILABLE = · 27 unmodified lines | ||
| 88 | 89 | |
| 89 | 90 | /** |
| 90 | 91 | * 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. | |
| 96 | 101 | */ |
| 97 | −const principalFromToken = (token: VerifiedToken, organizationId: string): Principal => ({ | |
| 102 | +const principalFromToken = ( | |
| 103 | + token: VerifiedToken, | |
| 104 | + organization: AuthorizedMcpOrganization, | |
| 105 | +): Principal => ({ | |
| 98 | 106 | accountId: token.accountId, |
| 99 | − organizationId, | |
| 100 | − organizationName: "", | |
| 107 | + organizationId: organization.id, | |
| 108 | + organizationName: organization.name, | |
| 109 | + ...(organization.slug === undefined ? {} : { organizationSlug: organization.slug }), | |
| 101 | 110 | email: "", |
| 102 | 111 | name: null, |
| 103 | 112 | avatarUrl: null, |
| @@ -223,9 +232,9 @@ export const cloudMcpAuthProviderLayer: Layer.Layer< · 119 unmodified lines | ||
| 223 | 232 | // caller genuinely holds no active membership (revoked / never a member) |
| 224 | 233 | // — a real Forbidden, which the handler may act on by condemning the |
| 225 | 234 | // 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)); | |
| 229 | 238 | }); |
| 230 | 239 | |
| 231 | 240 | const toOutcome = (request: Request, result: McpAuthResult): Effect.Effect<AuthOutcome> => { |
| @@ -160,19 +160,32 @@ export class McpAuth extends Context.Service< · 159 unmodified lines | ||
| 160 | 160 | } |
| 161 | 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 | + */ | |
| 170 | +export type AuthorizedMcpOrganization = { | |
| 171 | + readonly id: string; | |
| 172 | + readonly name: string; | |
| 173 | + readonly slug?: string; | |
| 174 | +}; | |
| 175 | + | |
| 163 | 176 | export class McpOrganizationAuth extends Context.Service< |
| 164 | 177 | McpOrganizationAuth, |
| 165 | 178 | { |
| 166 | 179 | /** |
| 167 | 180 | * Authorize `accountId` against an org SELECTOR — a WorkOS org id |
| 168 | 181 | * (`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 | |
| 170 | 183 | * caller holds an active membership, `null` otherwise. |
| 171 | 184 | */ |
| 172 | 185 | readonly authorize: ( |
| 173 | 186 | accountId: string, |
| 174 | 187 | organizationSelector: string, |
| 175 | − ) => Effect.Effect<string | null, unknown>; | |
| 188 | + ) => Effect.Effect<AuthorizedMcpOrganization | null, unknown>; | |
| 176 | 189 | } |
| 177 | 190 | >()("@executor-js/cloud/McpOrganizationAuth") {} |
| 178 | 191 | |
| @@ -216,7 +229,9 @@ export const McpOrganizationAuthLive = Layer.succeed(McpOrganizationAuth)({ · 37 unmodified lines | ||
| 216 | 229 | Effect.flatMap((organizationId) => |
| 217 | 230 | organizationId |
| 218 | 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 | 236 | : Effect.succeed(null), |
| 222 | 237 | ), |
| @@ -58,7 +58,7 @@ import { buildExecuteDescription, type ResumeResponse } from "@executor-js/execu · 57 unmodified lines | ||
| 58 | 58 | // `SessionAuthLive` instead.) |
| 59 | 59 | import { CoreSharedServices } from "../auth/workos"; |
| 60 | 60 | import { UserStoreService } from "../auth/context"; |
| 61 | −import { resolveOrganization } from "../auth/organization"; | |
| 61 | +import { resolveSessionMetaForToken } from "./session-meta"; | |
| 62 | 62 | import { |
| 63 | 63 | DbService, |
| 64 | 64 | combinedSchema, |
| @@ -107,10 +107,6 @@ type CloudSessionDbHandle = DbServiceShape & { · 42 unmodified lines | ||
| 107 | 107 | readonly end: () => Promise<void>; |
| 108 | 108 | }; |
| 109 | 109 | |
| 110 | −class OrganizationNotFoundError extends Data.TaggedError("OrganizationNotFoundError")<{ | |
| 111 | − readonly organizationId: string; | |
| 112 | −}> {} | |
| 113 | − | |
| 114 | 110 | class McpModelResumeForwardError extends Data.TaggedError("McpModelResumeForwardError")<{ |
| 115 | 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 | 216 | 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( | |
| 230 | 218 | Effect.withSpan("McpSessionDOSqlite.resolveSessionMeta"), |
| 231 | 219 | Effect.provide(makeSessionServices(dbHandle)), |
| 232 | 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 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 | |
| 234 | 229 | Effect.orDie, |
| 235 | 230 | ); |
| 236 | 231 | } |
| @@ -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 | +}); | |
| @@ -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 | + }); | |
| @@ -174,14 +174,61 @@ export const sentryPayloadForCause = ( · 173 unmodified lines | ||
| 174 | 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. | |
| 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 | + | |
| 177 | 222 | export const captureCause = ( |
| 178 | 223 | input: unknown, |
| 179 | 224 | context: OtelCorrelationContext | null = null, |
| 180 | 225 | ): string | undefined => { |
| 181 | 226 | const { primary, pretty } = sentryPayloadForCause(input); |
| 227 | + const classification = classificationTagsOf(input); | |
| 182 | 228 | tagCurrentSentryScopeWithOtelContext(context); |
| 183 | 229 | return Sentry.captureException(primary, (scope) => { |
| 184 | 230 | tagSentryScopeWithOtelContext(scope, context); |
| 231 | + for (const [key, value] of Object.entries(classification)) scope.setTag(key, value); | |
| 185 | 232 | if (pretty !== null) scope.setExtra("cause", pretty); |
| 186 | 233 | return scope; |
| 187 | 234 | }); |
| @@ -102,9 +102,14 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 101 unmodified lines | ||
| 102 | 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 | 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 | 113 | return Effect.succeed({ |
| 109 | 114 | organizationId: token.organizationId, |
| 110 | 115 | organizationName: this.cfConfig.organizationName, |
| @@ -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 | +); | |
| @@ -1,6 +1,6 @@ | ||
| 1 | 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 them |
| 2 | 2 | import { describe, expect, it } from "@effect/vitest"; |
| 3 | −import { Cause, Effect } from "effect"; | |
| 3 | +import { Cause, Effect, Exit } from "effect"; | |
| 4 | 4 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; |
| 5 | 5 | import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; |
| 6 | 6 | import 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. | |
| 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 | + | |
| 311 | 424 | describe("McpAgentSessionDOBase transport restore", () => { |
| 312 | 425 | it("preserves hibernated response streams when a cold isolate starts", async () => { |
| 313 | 426 | const session = await makeHarnessSession(); |
| @@ -39,6 +39,13 @@ export type IncomingTraceHeaders = IncomingPropagationHeaders; · 38 unmodified lines | ||
| 39 | 39 | |
| 40 | 40 | export interface McpSessionInit { |
| 41 | 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 | 49 | readonly userId: string; |
| 43 | 50 | readonly elicitationMode: McpElicitationMode; |
| 44 | 51 | /** Whether this session serves artifacts, read off `?artifacts=` at connect |
| @@ -247,7 +254,20 @@ export abstract class McpAgentSessionDOBase< · 202 unmodified lines | ||
| 247 | 254 | |
| 248 | 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 | 272 | protected abstract buildMcpServer( |
| 253 | 273 | sessionMeta: SessionMeta, |
| @@ -524,12 +544,21 @@ export abstract class McpAgentSessionDOBase< · 270 unmodified lines | ||
| 524 | 544 | private resolveAndStoreSessionMeta(token: McpSessionInit) { |
| 525 | 545 | const self = this; |
| 526 | 546 | 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. | |
| 532 | 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 | 562 | const sessionMeta: SessionMeta = { |
| 534 | 563 | ...resolved, |
| 535 | 564 | ...(token.webOrigin ? { webOrigin: token.webOrigin } : {}), |
| @@ -803,6 +832,11 @@ export abstract class McpAgentSessionDOBase< · 267 unmodified lines | ||
| 803 | 832 | .bestEffortBookkeeping("init.mark_activity", () => self.markActivity()) |
| 804 | 833 | .pipe(Effect.withSpan("McpSessionDO.markActivity")); |
| 805 | 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 | 840 | Effect.tapCause((cause) => |
| 807 | 841 | Effect.gen(function* () { |
| 808 | 842 | // A Cloudflare platform reset of an in-flight init is not a defect — |