Unified · Split
| @@ -1,7 +1,7 @@ | |||
| 1 | import { Context, Effect, Layer } from "effect"; | 1 | import { Context, Effect, Layer } from "effect"; |
| 2 | import { makeUserStore } from "../auth/user-store"; | 2 | import { makeUserStore } from "../auth/user-store"; |
| 3 | import { DbService } from "../db/db"; | 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 | // UserStoreService — wraps the Drizzle-backed user store with Effect | 7 | // UserStoreService — wraps the Drizzle-backed user store with Effect |
| @@ -10,13 +10,15 @@ import { UserStoreError, tryPromiseService, withServiceLogging } from "./errors" · 2 unmodified lines | |||
| 10 | type RawStore = ReturnType<typeof makeUserStore>; | 10 | type 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 which | 13 | // 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. | ||
| 15 | const makeService = (store: RawStore) => ({ | 17 | const 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 | }); |
| @@ -1,10 +1,103 @@ | |||
| 1 | import { Data, Effect, Option, Predicate, Schema } from "effect"; | 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 | export class UserStoreError extends Schema.TaggedErrorClass<UserStoreError>()( | 26 | export 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. */ | ||
| 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 | export class WorkOSError extends Schema.TaggedErrorClass<WorkOSError>()( | 102 | export class WorkOSError extends Schema.TaggedErrorClass<WorkOSError>()( |
| 10 | "WorkOSError", | 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 | import { wrapMcpSseResponse } from "../observability/memory-metrics"; | 27 | import { wrapMcpSseResponse } from "../observability/memory-metrics"; |
| 28 | import { WorkerTelemetryLive } from "../observability/telemetry"; | 28 | import { WorkerTelemetryLive } from "../observability/telemetry"; |
| 29 | import { cloudMcpAuth } from "./auth-provider"; | 29 | import { cloudMcpAuth } from "./auth-provider"; |
| 30 | import { isMcpSessionMetaUnavailable } from "./session-meta"; | ||
| 30 | import { McpSessionDOSqlite } from "./session-durable-object"; | 31 | import { McpSessionDOSqlite } from "./session-durable-object"; |
| 31 | import { parseTraceparent } from "./traceparent"; | 32 | import { parseTraceparent } from "./traceparent"; |
| 32 | 33 | ||
| 34 | const MCP_SESSION_UNAVAILABLE_MESSAGE = "Session storage temporarily unavailable - please retry"; | ||
| 35 | |||
| 33 | const corsPreflightResponse = (): Response => | 36 | const 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 cancelled | 316 | // vocabulary — a deploy, a storage timeout, a cancelled |
| 307 | // blockConcurrencyWhile — which reaches here through the agents SDK's own | 317 | // 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 unchanged | 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 | 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. | ||
| 74 | const stubOrgAuthActive = Layer.succeed(McpOrganizationAuth)({ | 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 | // A failure that is not a WorkOSError at all (e.g. the per-request DB layer | 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 | 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. The | 92 | * organizationId) into the full {@link Principal} the seam validates. |
| 92 | * envelope only uses `accountId` + `organizationId` for ownership; cloud | 93 | * |
| 93 | * resolves org name/email inside the DO, so the cosmetic identity fields carry | 94 | * The org name and slug come from the record the live membership check just |
| 94 | * empty placeholders. `organizationId` is guaranteed non-null here because the | 95 | * 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 | */ |
| 97 | const principalFromToken = (token: VerifiedToken, organizationId: string): Principal => ({ | 102 | const 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 the | 233 | // — 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> => { |
| @@ -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 | */ | ||
| 170 | export type AuthorizedMcpOrganization = { | ||
| 171 | readonly id: string; | ||
| 172 | readonly name: string; | ||
| 173 | readonly slug?: string; | ||
| 174 | }; | ||
| 175 | |||
| 163 | export class McpOrganizationAuth extends Context.Service< | 176 | export class McpOrganizationAuth extends Context.Service< |
| 164 | McpOrganizationAuth, | 177 | McpOrganizationAuth, |
| 165 | { | 178 | { |
| 166 | /** | 179 | /** |
| 167 | * Authorize `accountId` against an org SELECTOR — a WorkOS org id | 180 | * 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 (the | 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 | * 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 | organizationId | 230 | 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 | ), |
| @@ -58,7 +58,7 @@ import { buildExecuteDescription, type ResumeResponse } from "@executor-js/execu · 57 unmodified lines | |||
| 58 | // `SessionAuthLive` instead.) | 58 | // `SessionAuthLive` instead.) |
| 59 | import { CoreSharedServices } from "../auth/workos"; | 59 | import { CoreSharedServices } from "../auth/workos"; |
| 60 | import { UserStoreService } from "../auth/context"; | 60 | import { UserStoreService } from "../auth/context"; |
| 61 | import { resolveOrganization } from "../auth/organization"; | 61 | import { resolveSessionMetaForToken } from "./session-meta"; |
| 62 | import { | 62 | import { |
| 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 | ||
| 110 | class OrganizationNotFoundError extends Data.TaggedError("OrganizationNotFoundError")<{ | ||
| 111 | readonly organizationId: string; | ||
| 112 | }> {} | ||
| 113 | |||
| 114 | class McpModelResumeForwardError extends Data.TaggedError("McpModelResumeForwardError")<{ | 110 | class 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 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 | Effect.orDie, | 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 | 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. | ||
| 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 | export const captureCause = ( | 222 | export 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 | }); |
| @@ -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, |
| @@ -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 | // 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 | 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 | import { describe, expect, it } from "@effect/vitest"; | 2 | import { describe, expect, it } from "@effect/vitest"; |
| 3 | import { Cause, Effect } from "effect"; | 3 | import { Cause, Effect, Exit } from "effect"; |
| 4 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | 4 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; |
| 5 | import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; | 5 | import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; |
| 6 | import type { JSONRPCMessage, MessageExtraInfo } from "@modelcontextprotocol/sdk/types.js"; | 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 | describe("McpAgentSessionDOBase transport restore", () => { | 424 | describe("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(); |
| @@ -39,6 +39,13 @@ export type IncomingTraceHeaders = IncomingPropagationHeaders; · 38 unmodified lines | |||
| 39 | 39 | ||
| 40 | export interface McpSessionInit { | 40 | export 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 connect | 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 | 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 negotiated | 549 | // is this session's own durable record of the organization it was minted |
| 530 | // capabilities. Carry the stored value forward, or restoring the session | 550 | // 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 — |