Skip to content

Commit 8bdf315

Browse files
authored
Classify transient WorkOS failures as retryable during MCP auth (#1352)
* Keep MCP sessions alive through transient WorkOS membership-check failures The /mcp auth chain re-verifies WorkOS org membership on every request. A transient WorkOS 429/5xx/timeout collapsed to organizationId=null, which returned Forbidden — and a Forbidden carrying a session id schedules the session Durable Object for destruction (in-flight executions, paused approvals, undelivered results). A single WorkOS blip could mass-condemn every session in a shared-API-key org. Distinguish a failed membership lookup (retryable Unavailable -> 503 with Retry-After, session left intact) from a lookup that succeeds with no membership (genuine Forbidden). Session destruction now requires a positive determination that access is gone, mirroring the existing reason system classification on the JWKS/api-key verify paths. No membership cache is added: a positive TTL cache would re-open the revocation-liveness gap this check exists to close, and negative caching would pin a blip as no access; the classification fix removes the amplification risk without that tradeoff. * Fail closed on definitive WorkOS denials in the MCP membership check The previous commit classified every failed membership lookup as transient, but WorkOS also answers with deterministic 4xx denials (401 revoked/invalid API key, 403, 404 deleted org) that the SDK throws as typed exceptions and the service boundary flattened into a status-less WorkOSError. Those were being mapped to a retryable 503, preserving sessions indefinitely for a revoked customer. Thread the SDK exception's HTTP status onto WorkOSError at the service boundary (same extraction as the workos-vault plugin) and split the failure branch: 401/403/404 fail closed as Forbidden (session condemned), while 429/5xx/timeout/network stay retryable 503s. Also: render Retry-After on the shared envelope's Unavailable branch (the cloud agent-handler and envelope renderers had drifted), and document that the transient 503 and terminated-session 404 intentionally share JSON-RPC code -32001 with HTTP status as the discriminator.
1 parent a7e3091 commit 8bdf315

11 files changed

Lines changed: 724 additions & 34 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"executor": patch
3+
---
4+
5+
Treat a transient WorkOS outage during the MCP live-membership check as a retryable 503 instead of a Forbidden that destroys the session.

‎apps/cloud/src/auth/errors.test.ts‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// The WorkOS failure-classification boundary: the service adapter must thread
2+
// the SDK exception's HTTP status onto the public `WorkOSError`, and the
3+
// definitive-denial predicate must split 401/403/404 (WorkOS answered "no" —
4+
// fail closed) from 429/5xx/no-status (transient — retryable). This split is
5+
// what keeps a WorkOS blip from condemning MCP sessions AND a revoked key from
6+
// being mistaken for a blip.
7+
import { describe, expect, it } from "@effect/vitest";
8+
import {
9+
GenericServerException,
10+
NotFoundException,
11+
RateLimitExceededException,
12+
UnauthorizedException,
13+
} from "@workos-inc/node/worker";
14+
15+
import {
16+
ServiceAdapterError,
17+
isDefinitiveWorkOSDenial,
18+
isDefinitiveWorkOSDenialStatus,
19+
workosErrorFromFailure,
20+
} from "./errors";
21+
22+
describe("workosErrorFromFailure", () => {
23+
it("reads the status off the real SDK exceptions through the adapter wrapper", () => {
24+
const cases: ReadonlyArray<readonly [unknown, number]> = [
25+
[new UnauthorizedException("req_1"), 401],
26+
[
27+
new NotFoundException({ code: "not_found", message: "gone", path: "/x", requestID: "r" }),
28+
404,
29+
],
30+
[new RateLimitExceededException("slow down", "req_2", 1), 429],
31+
[new GenericServerException(503, "upstream", {}, "req_3"), 503],
32+
];
33+
for (const [sdkError, status] of cases) {
34+
const wrapped = workosErrorFromFailure(new ServiceAdapterError({ cause: sdkError }));
35+
expect(wrapped.status, `status for ${String(sdkError)}`).toBe(status);
36+
}
37+
});
38+
39+
it("leaves status unset for non-HTTP failures (network error, timeout)", () => {
40+
// The shape fetch/undici raise on a network failure: an error value with a
41+
// message and no HTTP status (statusless by construction, unlike the SDK's
42+
// typed HTTP exceptions).
43+
const networkFailure: unknown = { name: "TypeError", message: "fetch failed" };
44+
const wrapped = workosErrorFromFailure(new ServiceAdapterError({ cause: networkFailure }));
45+
expect(wrapped.status).toBeUndefined();
46+
});
47+
});
48+
49+
describe("isDefinitiveWorkOSDenial", () => {
50+
it("treats 401/403/404 as definitive and everything else as transient", () => {
51+
expect(isDefinitiveWorkOSDenialStatus(401)).toBe(true);
52+
expect(isDefinitiveWorkOSDenialStatus(403)).toBe(true);
53+
expect(isDefinitiveWorkOSDenialStatus(404)).toBe(true);
54+
expect(isDefinitiveWorkOSDenialStatus(429)).toBe(false);
55+
expect(isDefinitiveWorkOSDenialStatus(500)).toBe(false);
56+
expect(isDefinitiveWorkOSDenialStatus(503)).toBe(false);
57+
expect(isDefinitiveWorkOSDenialStatus(undefined)).toBe(false);
58+
});
59+
60+
it("only claims WorkOSError instances, never arbitrary errors with a status", () => {
61+
expect(isDefinitiveWorkOSDenial({ status: 403 })).toBe(false);
62+
expect(
63+
isDefinitiveWorkOSDenial(
64+
workosErrorFromFailure(new ServiceAdapterError({ cause: new UnauthorizedException("r") })),
65+
),
66+
).toBe(true);
67+
});
68+
});

‎apps/cloud/src/auth/errors.ts‎

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Data, Effect, Schema } from "effect";
1+
import { Data, Effect, Option, Predicate, Schema } from "effect";
22

33
export class UserStoreError extends Schema.TaggedErrorClass<UserStoreError>()(
44
"UserStoreError",
@@ -8,10 +8,69 @@ export class UserStoreError extends Schema.TaggedErrorClass<UserStoreError>()(
88

99
export class WorkOSError extends Schema.TaggedErrorClass<WorkOSError>()(
1010
"WorkOSError",
11-
{},
11+
{
12+
/**
13+
* The upstream HTTP status WorkOS answered with, when the failure WAS an
14+
* HTTP answer (the SDK sets `.status` on all its typed exceptions).
15+
* Absent for network errors / timeouts. Consumers that must distinguish
16+
* "WorkOS said no" (definitive 4xx) from "WorkOS was unreachable"
17+
* (transient) branch on this — see `isDefinitiveWorkOSDenialStatus`.
18+
*/
19+
status: Schema.optional(Schema.Number),
20+
},
1221
{ httpApiStatus: 500 },
1322
) {}
1423

24+
// Statuses that are a DEFINITIVE denial from WorkOS — a deterministic "this
25+
// request cannot be authorized" answer (invalid/revoked API key, forbidden,
26+
// resource gone), not a blip. Retrying does not help, and auth decisions built
27+
// on them must fail CLOSED. Everything else (429, 5xx, or no status at all —
28+
// network error/timeout) is transient and must stay retryable.
29+
const DEFINITIVE_DENIAL_STATUSES: ReadonlySet<number> = new Set([401, 403, 404]);
30+
31+
export const isDefinitiveWorkOSDenialStatus = (status: number | undefined): boolean =>
32+
status !== undefined && DEFINITIVE_DENIAL_STATUSES.has(status);
33+
34+
// Tag-based guards (same pattern as `isOAuth2Error` in core/sdk/oauth-helpers):
35+
// the untyped failure channels these run against carry values that crossed
36+
// layer boundaries, so the discriminant is the `_tag`, not the prototype chain.
37+
const isWorkOSError = Predicate.isTagged("WorkOSError") as (error: unknown) => error is WorkOSError;
38+
39+
/** A `WorkOSError` that is a definitive denial (see above), typed on `unknown`
40+
* so auth code holding an untyped failure channel can branch safely. */
41+
export const isDefinitiveWorkOSDenial = (error: unknown): boolean =>
42+
isWorkOSError(error) && isDefinitiveWorkOSDenialStatus(error.status);
43+
44+
// Every typed exception the WorkOS node SDK throws for an HTTP answer carries a
45+
// numeric `.status` (UnauthorizedException 401, NotFoundException 404,
46+
// RateLimitExceededException 429, GenericServerException/OauthException with the
47+
// live status, ...). Network errors and timeouts throw non-SDK errors with no
48+
// status. Same extraction pattern as the workos-vault plugin's
49+
// `statusFromWorkOSCause` (packages/plugins/workos-vault/src/sdk/client.ts).
50+
const CauseWithStatusSchema = Schema.Struct({ status: Schema.Number });
51+
const decodeCauseWithStatusOption = Schema.decodeUnknownOption(CauseWithStatusSchema);
52+
53+
export const statusFromWorkOSCause = (cause: unknown): number | undefined =>
54+
Option.match(decodeCauseWithStatusOption(cause), {
55+
onNone: () => undefined,
56+
onSome: (decoded) => decoded.status,
57+
});
58+
59+
/**
60+
* Build the public `WorkOSError` for a WorkOS service-adapter failure,
61+
* threading the upstream HTTP status through when the underlying SDK exception
62+
* carried one. The failure is normally the `ServiceAdapterError` wrapper from
63+
* `tryPromiseService` (SDK exception in `.cause`); a bare cause also works.
64+
*/
65+
const isServiceAdapterError = Predicate.isTagged("ServiceAdapterError") as (
66+
failure: unknown,
67+
) => failure is ServiceAdapterError;
68+
69+
export const workosErrorFromFailure = (failure: unknown): WorkOSError =>
70+
new WorkOSError({
71+
status: statusFromWorkOSCause(isServiceAdapterError(failure) ? failure.cause : failure),
72+
});
73+
1574
export class ApiKeyManagementError extends Schema.TaggedErrorClass<ApiKeyManagementError>()(
1675
"ApiKeyManagementError",
1776
{ cause: Schema.Unknown },
@@ -47,7 +106,11 @@ export const tryPromiseService = <A>(fn: () => Promise<A>): Effect.Effect<A, Ser
47106
*/
48107
export const withServiceLogging = <A, E, R>(
49108
name: string,
50-
publicError: () => E,
109+
// Receives the raw failure (for service adapters: the `ServiceAdapterError`
110+
// whose `.cause` is the SDK exception) so the public error can carry safe
111+
// classification fields (e.g. `WorkOSError.status`). Zero-arg callers that
112+
// ignore the failure keep working unchanged.
113+
publicError: (failure: unknown) => E,
51114
effect: Effect.Effect<A, unknown, R>,
52115
): Effect.Effect<A, E, R> =>
53116
effect.pipe(

‎apps/cloud/src/auth/organization.ts‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,19 @@ export const resolveOrganization = (organizationId: string) =>
5757
// until their access token naturally expires (~10 min).
5858
//
5959
// To close that gap we verify membership live on every protected request.
60-
// `listUserMemberships` is one WorkOS call per request. If this becomes a
61-
// hot path we can layer a short per-(user, org) TTL cache underneath, or
62-
// swap it for a local memberships table fed by the WorkOS Events API.
60+
// `listUserMemberships` is one WorkOS call per request.
61+
//
62+
// Caching decision (2026-07): we deliberately do NOT add a positive TTL cache
63+
// here. A positive cache is exactly what would re-open the revocation gap this
64+
// live check exists to close — a revoked member would keep access for the cache
65+
// TTL. Negative caching is worse still (a transient WorkOS blip would get
66+
// pinned as "no access"), so it is out too. The rate-limit amplification a
67+
// shared-API-key org can cause under a WorkOS slowdown is mitigated instead by
68+
// the classification fix at the MCP call site (a blip now yields a retryable
69+
// 503, so it no longer condemns sessions or triggers reconnect storms). If per-
70+
// request WorkOS load later proves to be the bottleneck, the right structural
71+
// fix is a local memberships table fed by the WorkOS Events API (authoritative,
72+
// no staleness window), not a TTL cache over this call — tracked as follow-up.
6373
//
6474
// Returns the resolved organization (via resolveOrganization) if the user
6575
// currently holds an *active* membership in it, otherwise null. Callers

‎apps/cloud/src/auth/workos.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { env } from "cloudflare:workers";
66
import { Context, Data, Effect, Layer, Option, Schema } from "effect";
77
import { GeneratePortalLinkIntent, WorkOS } from "@workos-inc/node/worker";
88
import { parseCookie } from "./cookies";
9-
import { WorkOSError, tryPromiseService, withServiceLogging } from "./errors";
9+
import { tryPromiseService, withServiceLogging, workosErrorFromFailure } from "./errors";
1010

1111
const COOKIE_NAME = "wos-session";
1212
const INVALID_COOKIE_PASSWORD_MESSAGE = "WORKOS_COOKIE_PASSWORD must be at least 32 characters";
@@ -147,10 +147,14 @@ const make = Effect.gen(function* () {
147147

148148
const workos = new WorkOS({ apiKey, clientId, ...workosApiUrlOptions(env.WORKOS_API_URL) });
149149

150+
// The public `WorkOSError` carries the upstream HTTP status when the SDK
151+
// exception had one (all its typed exceptions do), so consumers can tell a
152+
// definitive WorkOS denial (401/403/404 — fail closed) from a transient
153+
// failure (429/5xx/network — retryable).
150154
const use = <A>(fn: (wos: WorkOS) => Promise<A>) =>
151155
withServiceLogging(
152156
"workos",
153-
() => new WorkOSError(),
157+
workosErrorFromFailure,
154158
tryPromiseService(() => fn(workos)),
155159
);
156160

‎apps/cloud/src/mcp/agent-handler.ts‎

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
McpAuthProvider,
55
jsonRpcErrorBody,
66
defaultMcpResource,
7+
UNAVAILABLE_RETRY_AFTER_SECONDS,
78
type AuthOutcome,
89
type McpResource,
910
} from "@executor-js/host-mcp";
@@ -57,7 +58,21 @@ const renderAuthError = (
5758
if (Predicate.isTagged(outcome, "Forbidden")) {
5859
return jsonRpcResponse(403, outcome.code ?? -32001, outcome.message);
5960
}
60-
return jsonRpcResponse(503, -32001, outcome.message);
61+
// Unavailable: a transient auth-infra failure (JWKS blip OR a WorkOS
62+
// membership-lookup 429/5xx/timeout). Both are retryable, so advertise a
63+
// Retry-After so the client (and any polite retry layer) backs off instead of
64+
// hammering (same rendering as the shared envelope's Unavailable branch).
65+
// Crucially, this path NEVER reaches the session-destroy branch below — a
66+
// transient failure must not condemn a live session.
67+
//
68+
// Note this 503 shares JSON-RPC code -32001 with the terminated-session 404
69+
// ("Session timed out, please reconnect"); that is intentional — -32001 is
70+
// the generic auth/session envelope code, and the HTTP STATUS is the
71+
// discriminator clients act on: 503 = retry the SAME session id, 404 = the
72+
// id is dead, reconnect.
73+
return jsonRpcErrorBody(503, -32001, outcome.message, {
74+
retryAfterSeconds: UNAVAILABLE_RETRY_AFTER_SECONDS,
75+
});
6176
};
6277

6378
const authenticate = (request: Request) =>
@@ -99,7 +114,10 @@ const propsForPrincipal = (
99114
});
100115

101116
export const makeCloudMcpAgentHandler = () => {
102-
const serveOptions = { binding: "MCP_SESSION", transport: "streamable-http" } as const;
117+
const serveOptions = {
118+
binding: "MCP_SESSION",
119+
transport: "streamable-http",
120+
} as const;
103121
// The agents SDK builds an exact-match `URLPattern` from the path handed to
104122
// `serve` (see `createStreamingHttpHandler` in `agents/dist/mcp/index.js`) —
105123
// a single `/mcp` handler never matches `/mcp/toolkits/<slug>` and falls
@@ -126,6 +144,11 @@ export const makeCloudMcpAgentHandler = () => {
126144

127145
const { auth, outcome } = await Effect.runPromise(authenticate(request));
128146
if (!Predicate.isTagged(outcome, "Authenticated")) {
147+
// Destroying a live session on auth grounds requires a POSITIVE
148+
// determination that access is genuinely gone — only `Forbidden` carries
149+
// that (valid bearer, org absent/revoked). `Unavailable` (transient WorkOS
150+
// / JWKS failure) and `Unauthorized` (retry with a fresh token) must leave
151+
// the session intact, so the condemn path is gated on `Forbidden` alone.
129152
if (Predicate.isTagged(outcome, "Forbidden") && sessionId) {
130153
await Effect.runPromise(
131154
Effect.ignore(
@@ -142,7 +165,10 @@ export const makeCloudMcpAgentHandler = () => {
142165
// Matches the old envelope's contract (@modelcontextprotocol/sdk's
143166
// `WebStandardStreamableHTTPServerTransport.handleDeleteRequest`): 200,
144167
// not 204 — see e2e/cloud/mcp-protocol.test.ts.
145-
return new Response(null, { status: 200, headers: { "access-control-allow-origin": "*" } });
168+
return new Response(null, {
169+
status: 200,
170+
headers: { "access-control-allow-origin": "*" },
171+
});
146172
}
147173

148174
if (sessionId) {

0 commit comments

Comments
 (0)