From 0b24aecba8d8a66eaae15d062d15dc9d328d395a Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:17:25 +0200 Subject: [PATCH 001/133] fix(sdk): bound a credential provider call so an unreachable store fails, not hangs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A credential provider is frequently remote — the 1Password backend talks to a service over HTTP, and any custom provider may be a network store — so "stopped answering" is one of its ordinary failure modes rather than an exotic one. Nothing bounded the call, so a store that went away did not fail a tool invocation, it hung it, and nothing in the resulting silence named the provider. Measured before changing anything: with a provider whose `get` never returns, seeding and connection creation both succeed and the resolution never comes back. A control provider resolves normally, so the hang is the provider call and not the harness. `CredentialProvider` documents nothing about timing — no expectation that `get` returns promptly, no note that the caller will not bound it — so neither side owned this. Executor already bounds its other remote calls the same way, in OAuth discovery and in the MCP plugin's probes; credential resolution was the one that did not. Bounded once at the registration funnel rather than at each call site, so a method added later is bounded by default instead of by whoever remembers. Optional methods stay optional: a provider that cannot enumerate must not appear to. The failure names the provider and the operation, so the diagnostic points at the store rather than at whatever the caller happened to be doing. Thirty seconds is a backstop against a dead dependency, not a latency budget. The tests advance a virtual clock past it rather than waiting. --- packages/core/sdk/src/executor.ts | 66 ++++++++++- .../sdk/src/provider-call-timeout.test.ts | 108 ++++++++++++++++++ 2 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 packages/core/sdk/src/provider-call-timeout.test.ts diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 4a962c3ab6..07a8b2e30e 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -1,4 +1,4 @@ -import { Effect, Inspectable, Layer, Option, Predicate, Schema } from "effect"; +import { Duration, Effect, Inspectable, Layer, Option, Predicate, Schema } from "effect"; import { FetchHttpClient, type HttpClient } from "effect/unstable/http"; import { fumadb } from "@executor-js/fumadb"; import { memoryAdapter } from "@executor-js/fumadb/adapters/memory"; @@ -1672,6 +1672,68 @@ export const createExecutor = ( + effect: Effect.Effect, + key: string, + operation: string, + ): Effect.Effect => + effect.pipe( + Effect.timeoutOrElse({ + duration: Duration.millis(CREDENTIAL_PROVIDER_TIMEOUT_MS), + orElse: () => + Effect.fail( + new StorageError({ + message: + `Credential provider "${key}" did not answer ${operation} within ` + + `${CREDENTIAL_PROVIDER_TIMEOUT_MS}ms. The store is unreachable or not responding; ` + + `the credential was not resolved.`, + cause: undefined, + }), + ), + }), + ); + + /** Wrap a provider so every call it exposes is bounded. + * + * Done once at the registration funnel rather than at each call site: every + * provider passes through here, so a method added later is bounded by default + * instead of by whoever remembers. Optional methods stay optional — a provider + * that cannot enumerate must not appear to. */ + const boundedProvider = (provider: CredentialProvider, key: string): CredentialProvider => { + const { has, set, delete: remove, list } = provider; + return { + ...provider, + get: (id) => boundedCall(provider.get(id), key, "get"), + ...(has ? { has: (id: ProviderItemId) => boundedCall(has(id), key, "has") } : {}), + ...(set + ? { set: (id: ProviderItemId, value: string) => boundedCall(set(id, value), key, "set") } + : {}), + ...(remove + ? { delete: (id: ProviderItemId) => boundedCall(remove(id), key, "delete") } + : {}), + ...(list ? { list: () => boundedCall(list(), key, "list") } : {}), + }; + }; + const registerCredentialProvider = ( provider: CredentialProvider, sourceLabel: string, @@ -1685,7 +1747,7 @@ export const createExecutor = ({ + key: STORE, + writable: true, + get, + set: () => Effect.void, +}); + +const plugin = (provider: CredentialProvider) => + definePlugin(() => ({ + id: "acme" as const, + credentialProviders: [provider], + storage: () => ({}), + extension: (ctx) => ({ + seed: () => ctx.core.integrations.register({ slug: INTEG, description: "Acme", config: {} }), + read: () => ctx.connections.resolveValue({ owner: "org", integration: INTEG, name: CONN }), + }), + }))(); + +const executorWithConnection = (provider: CredentialProvider) => + Effect.gen(function* () { + const executor = yield* createExecutor( + makeTestConfig({ plugins: [plugin(provider)] as const }), + ); + yield* executor.acme.seed(); + yield* executor.connections.create({ + owner: "org", + name: CONN, + integration: INTEG, + template: AuthTemplateSlug.make("api_key"), + from: { provider: STORE, id: ProviderItemId.make("item-1") }, + }); + return executor; + }); + +describe("a credential provider that stops answering", () => { + it.effect("fails the resolution instead of hanging it", () => + Effect.gen(function* () { + const executor = yield* executorWithConnection(providerWith(() => Effect.never)); + + const fiber = yield* Effect.forkChild(Effect.exit(executor.acme.read())); + yield* TestClock.adjust(Duration.minutes(5)); + const exit = yield* Fiber.join(fiber); + + expect(exit._tag).toBe("Failure"); + }), + ); + + it.effect("names the provider and the operation, not just a failure", () => + Effect.gen(function* () { + // A bare timeout would leave an operator looking at whatever the caller was + // doing rather than at the store that stopped answering. + const executor = yield* executorWithConnection(providerWith(() => Effect.never)); + + const fiber = yield* Effect.forkChild(Effect.exit(executor.acme.read())); + yield* TestClock.adjust(Duration.minutes(5)); + const exit = yield* Fiber.join(fiber); + + expect(String(exit)).toContain("remote-store"); + expect(String(exit)).toContain("did not answer"); + }), + ); + + it.effect("still resolves normally when the provider answers", () => + Effect.gen(function* () { + // The control. A bound that refused everything would satisfy both assertions + // above while breaking every working deployment. + const executor = yield* executorWithConnection(providerWith(() => Effect.succeed("tok"))); + + expect(yield* executor.acme.read()).toBe("tok"); + }), + ); +}); From 7cc550f9df0b324d0870775e2d22cf98c2bcb548 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:24:20 +0200 Subject: [PATCH 002/133] fix(sdk): keep the provider's receiver when bounding its calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wrapper destructured the four optional methods and called the bindings bare, which drops `this`. `get` was already called on the provider, so the two disagreed. Every provider in the tree is an object literal and cannot notice; a provider written as a class — which is exactly what "wrap any provider" invites — threw TypeError on its first optional call. Covered by a class-based provider test, with an object-literal control that is identical except for that one difference, so a red result can only mean the receiver. Also pins the operation in the message-shape test, which asserted the provider and the phrasing but not the operation it is named for, and uses Exit.isFailure rather than inspecting _tag, which the repo's own no-manual-tag-check rule rejects. --- packages/core/sdk/src/executor.ts | 31 ++++++--- .../sdk/src/provider-call-timeout.test.ts | 67 ++++++++++++++++++- 2 files changed, 86 insertions(+), 12 deletions(-) diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 07a8b2e30e..93407a4bb6 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -1715,22 +1715,33 @@ export const createExecutor = { - const { has, set, delete: remove, list } = provider; + // Every method is invoked ON the provider. Destructuring them and calling the + // bindings bare drops `this`, which every in-tree provider survives only because + // it happens to be an object literal — a provider written as a class throws + // TypeError on its first call. Wrapping arbitrary providers is the point of this + // funnel, so it must not change how their own methods are called. return { ...provider, get: (id) => boundedCall(provider.get(id), key, "get"), - ...(has ? { has: (id: ProviderItemId) => boundedCall(has(id), key, "has") } : {}), - ...(set - ? { set: (id: ProviderItemId, value: string) => boundedCall(set(id, value), key, "set") } + ...(provider.has + ? { has: (id: ProviderItemId) => boundedCall(provider.has!(id), key, "has") } : {}), - ...(remove - ? { delete: (id: ProviderItemId) => boundedCall(remove(id), key, "delete") } + ...(provider.set + ? { + set: (id: ProviderItemId, value: string) => + boundedCall(provider.set!(id, value), key, "set"), + } + : {}), + ...(provider.delete + ? { delete: (id: ProviderItemId) => boundedCall(provider.delete!(id), key, "delete") } : {}), - ...(list ? { list: () => boundedCall(list(), key, "list") } : {}), + ...(provider.list ? { list: () => boundedCall(provider.list!(), key, "list") } : {}), }; }; diff --git a/packages/core/sdk/src/provider-call-timeout.test.ts b/packages/core/sdk/src/provider-call-timeout.test.ts index f61c38a93f..2f2e262b6f 100644 --- a/packages/core/sdk/src/provider-call-timeout.test.ts +++ b/packages/core/sdk/src/provider-call-timeout.test.ts @@ -15,7 +15,7 @@ // --------------------------------------------------------------------------- import { describe, expect, it } from "@effect/vitest"; -import { Duration, Effect, Fiber } from "effect"; +import { Duration, Effect, Exit, Fiber } from "effect"; import { TestClock } from "effect/testing"; import { createExecutor } from "./executor"; @@ -77,7 +77,7 @@ describe("a credential provider that stops answering", () => { yield* TestClock.adjust(Duration.minutes(5)); const exit = yield* Fiber.join(fiber); - expect(exit._tag).toBe("Failure"); + expect(Exit.isFailure(exit)).toBe(true); }), ); @@ -93,6 +93,69 @@ describe("a credential provider that stops answering", () => { expect(String(exit)).toContain("remote-store"); expect(String(exit)).toContain("did not answer"); + // The operation, too — without this the test passes its own name by accident: + // the operation could drop out of the message entirely and nothing would notice. + expect(String(exit)).toContain("get"); + }), + ); + + it.effect("an object-literal provider stores a pasted value — the control", () => + Effect.gen(function* () { + // The control for the class case below: identical in every respect except that the + // provider is an object literal. Without it, a red class test could mean anything. + const items = new Map(); + const lit: CredentialProvider = { + key: STORE, + writable: true, + get: (id) => Effect.sync(() => items.get(String(id)) ?? null), + set: (id, value) => Effect.sync(() => void items.set(String(id), value)), + }; + const executor = yield* createExecutor(makeTestConfig({ plugins: [plugin(lit)] as const })); + yield* executor.acme.seed(); + yield* executor.connections.create({ + owner: "org", + name: CONN, + integration: INTEG, + template: AuthTemplateSlug.make("api_key"), + value: "tok", + }); + expect(yield* executor.acme.read()).toBe("tok"); + }), + ); + + it.effect("wraps a CLASS-based provider without breaking its methods", () => + Effect.gen(function* () { + // The wrapper must not change HOW a provider's own methods are called. `get` was + // invoked with its receiver (`provider.get(id)`) but the optional methods were + // destructured and called bare, which silently drops `this`. Every in-tree provider + // is an object literal and cannot notice; a provider written as a class — exactly + // what "wrap any provider" invites — throws TypeError on the first optional call. + class ClassProvider { + readonly key = STORE; + readonly writable = true; + private readonly items = new Map(); + get(id: ProviderItemId) { + return Effect.sync(() => this.items.get(String(id)) ?? null); + } + set(id: ProviderItemId, value: string) { + // `this` is the whole point: bare invocation makes this line throw. + return Effect.sync(() => void this.items.set(String(id), value)); + } + } + + const executor = yield* createExecutor( + makeTestConfig({ plugins: [plugin(new ClassProvider() as CredentialProvider)] as const }), + ); + yield* executor.acme.seed(); + yield* executor.connections.create({ + owner: "org", + name: CONN, + integration: INTEG, + template: AuthTemplateSlug.make("api_key"), + value: "tok", + }); + + expect(yield* executor.acme.read()).toBe("tok"); }), ); From f9c799910f45e55c1f52ecf0d892d60dee575dc3 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:51:38 +0200 Subject: [PATCH 003/133] fix(sdk): stop the bounded wrapper dropping a provider's prototype members MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wrapper spread the provider. A spread copies only own ENUMERABLE properties, so everything on a class's prototype — its methods, and accessors like `writable` — was dropped silently. Nothing raised: the wrapper simply appeared not to have the capability, and the caller took a path the provider meant to own. A class-based provider whose `writable` is an accessor stops being seen as a writable store at all, so creating a connection from a pasted value fails with "provider not registered: default". It now inherits through Object.create and shadows only the five methods it bounds, which also means a capability added to CredentialProvider later survives the wrapper without anyone remembering to list it here. Covered by a class-based provider whose `writable` lives on the prototype, verified failing before the change with exactly that error. --- packages/core/sdk/src/executor.ts | 50 +++++++++++-------- .../sdk/src/provider-call-timeout.test.ts | 41 +++++++++++++++ 2 files changed, 70 insertions(+), 21 deletions(-) diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 93407a4bb6..4b099bb57b 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -1721,28 +1721,36 @@ export const createExecutor = { - // Every method is invoked ON the provider. Destructuring them and calling the - // bindings bare drops `this`, which every in-tree provider survives only because - // it happens to be an object literal — a provider written as a class throws - // TypeError on its first call. Wrapping arbitrary providers is the point of this - // funnel, so it must not change how their own methods are called. - return { - ...provider, - get: (id) => boundedCall(provider.get(id), key, "get"), - ...(provider.has - ? { has: (id: ProviderItemId) => boundedCall(provider.has!(id), key, "has") } - : {}), - ...(provider.set - ? { - set: (id: ProviderItemId, value: string) => - boundedCall(provider.set!(id, value), key, "set"), - } - : {}), - ...(provider.delete - ? { delete: (id: ProviderItemId) => boundedCall(provider.delete!(id), key, "delete") } - : {}), - ...(provider.list ? { list: () => boundedCall(provider.list!(), key, "list") } : {}), + // Wrapping must change neither how the provider's methods are CALLED nor what the + // object LOOKS like. + // + // Spreading would break the second: a spread copies only own ENUMERABLE properties, so + // everything on a class's prototype — its methods, and accessors like `writable` — is + // dropped silently. Nothing raises; the wrapper simply appears not to have the capability + // and the caller takes a path the provider meant to own. `Object.create` keeps the whole + // object reachable, including anything added to the interface later. + // + // Each bounded method is invoked ON the provider, which is the first half: a destructured + // binding called bare loses `this`, and a class-based provider throws TypeError on its + // first call. Every provider in this repo is an object literal and cannot notice either + // problem, but "wrap any provider" is the whole point of this funnel. + const bounded: Record = { + get: (id: ProviderItemId) => boundedCall(provider.get(id), key, "get"), }; + if (provider.has) { + bounded.has = (id: ProviderItemId) => boundedCall(provider.has!(id), key, "has"); + } + if (provider.set) { + bounded.set = (id: ProviderItemId, value: string) => + boundedCall(provider.set!(id, value), key, "set"); + } + if (provider.delete) { + bounded.delete = (id: ProviderItemId) => boundedCall(provider.delete!(id), key, "delete"); + } + if (provider.list) { + bounded.list = () => boundedCall(provider.list!(), key, "list"); + } + return Object.assign(Object.create(provider) as CredentialProvider, bounded); }; const registerCredentialProvider = ( diff --git a/packages/core/sdk/src/provider-call-timeout.test.ts b/packages/core/sdk/src/provider-call-timeout.test.ts index 2f2e262b6f..81039d9fc6 100644 --- a/packages/core/sdk/src/provider-call-timeout.test.ts +++ b/packages/core/sdk/src/provider-call-timeout.test.ts @@ -159,6 +159,47 @@ describe("a credential provider that stops answering", () => { }), ); + it.effect("keeps a capability the provider defines on its PROTOTYPE", () => + Effect.gen(function* () { + // The wrapper must not change the provider's SHAPE either. A spread copies only own + // ENUMERABLE properties, so anything on a class's prototype — every method, and any + // accessor like the `writable` below — is dropped silently. Nothing raises; the wrapper + // simply appears not to have it, and the caller takes a path the provider meant to own. + // Here that means `defaultWritableProvider` no longer sees a writable store, so creating + // a connection from a pasted value fails with no provider at all. + class PrototypeProvider { + readonly key = STORE; + private readonly items = new Map(); + // On the PROTOTYPE, not the instance — this is the property a spread loses. + get writable() { + return true; + } + get(id: ProviderItemId) { + return Effect.sync(() => this.items.get(String(id)) ?? null); + } + set(id: ProviderItemId, value: string) { + return Effect.sync(() => void this.items.set(String(id), value)); + } + } + + const executor = yield* createExecutor( + makeTestConfig({ + plugins: [plugin(new PrototypeProvider() as CredentialProvider)] as const, + }), + ); + yield* executor.acme.seed(); + yield* executor.connections.create({ + owner: "org", + name: CONN, + integration: INTEG, + template: AuthTemplateSlug.make("api_key"), + value: "tok", + }); + + expect(yield* executor.acme.read()).toBe("tok"); + }), + ); + it.effect("still resolves normally when the provider answers", () => Effect.gen(function* () { // The control. A bound that refused everything would satisfy both assertions From 1b5f931d90b52fa9eca7b6f53359a117d757c7c1 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 16 Aug 2026 10:39:26 +0000 Subject: [PATCH 004/133] Add integrations.remove to core tools (#1600) integrations.list reports canRemove per integration but the agent surface had no way to act on it; removal existed only on the HTTP API and the console. The tool takes the slug from integrations.list, is approval-gated, and reports removed: false when no catalog row matched so an absent slug or built-in namespace is distinguishable from a real removal. --- .changeset/core-tools-integrations-remove.md | 9 +++ packages/core/sdk/src/core-tools.ts | 27 ++++++++ packages/core/sdk/src/executor.test.ts | 70 ++++++++++++++++++++ 3 files changed, 106 insertions(+) create mode 100644 .changeset/core-tools-integrations-remove.md diff --git a/.changeset/core-tools-integrations-remove.md b/.changeset/core-tools-integrations-remove.md new file mode 100644 index 0000000000..d4932f8ccb --- /dev/null +++ b/.changeset/core-tools-integrations-remove.md @@ -0,0 +1,9 @@ +--- +"executor": patch +--- + +**Add `integrations.remove` to the core tools so an agent can drop a catalog integration** + +`integrations.list` advertises `canRemove` per integration, but nothing on the agent surface could act on it: removal existed only on the HTTP API and the web console, so an agent that could add an integration could never take one back out. Cleaning up a catalog meant clicking through the UI once per integration. + +The core-tools plugin now contributes `integrations.remove`, taking the `slug` reported by `integrations.list` and cascading to every connection under the integration and the tools those produced. It is approval-gated, being strictly more destructive than `connections.remove`. The `removed` flag is honest rather than always-true: `false` means no catalog row matched, so an already-absent slug and a built-in namespace like `executor` are distinguishable from a real removal, and an integration pinned with `canRemove: false` is refused with `IntegrationRemovalNotAllowedError` instead of silently surviving. diff --git a/packages/core/sdk/src/core-tools.ts b/packages/core/sdk/src/core-tools.ts index 2b0ef306e9..b01952747f 100644 --- a/packages/core/sdk/src/core-tools.ts +++ b/packages/core/sdk/src/core-tools.ts @@ -51,6 +51,8 @@ const IntegrationsListOutput = Schema.Struct({ integrations: Schema.Array(IntegrationOutput), }); +const IntegrationRemoveInput = Schema.Struct({ slug: Schema.String }); + const DetectInput = Schema.Struct({ url: Schema.String }); const DetectOutput = Schema.Struct({ results: Schema.Array( @@ -331,6 +333,7 @@ const OAuthCancelInput = Schema.Struct({ // Standard-schema versions for the tool() builder. const IntegrationsListOutputStd = schemaToStandard(IntegrationsListOutput); +const IntegrationRemoveInputStd = schemaToStandard(IntegrationRemoveInput); const DetectInputStd = schemaToStandard(DetectInput); const DetectOutputStd = schemaToStandard(DetectOutput); const ConnectionsListInputStd = schemaToStandard(ConnectionsListInput); @@ -559,6 +562,30 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { })), })), }), + tool({ + name: "integrations.remove", + description: + "Remove an integration from the workspace catalog by slug, dropping every connection under it and every tool those produced. `removed: false` means no catalog row matched: the slug was already gone, or it names a built-in namespace that is not catalog-backed. Integrations whose `canRemove` is false are refused.", + inputSchema: IntegrationRemoveInputStd, + outputSchema: RemovedOutputStd, + // Strictly more destructive than `connections.remove`, which is + // already approval-gated: this cascades to every connection under the + // integration and takes the catalog row with it, so re-adding means + // re-importing the definition, not just reconnecting an account. + annotations: { requiresApproval: true }, + execute: (input: typeof IntegrationRemoveInput.Type, { ctx }) => + Effect.gen(function* () { + const slug = IntegrationSlug.make(input.slug); + // `core.integrations.get` reads catalog ROWS only, so a built-in + // static namespace reports absent here. Checking first is what + // keeps `removed` honest — the underlying remove is a silent + // no-op for a slug it can't find. + const existing = yield* ctx.core.integrations.get(slug); + if (existing === null) return { removed: false }; + yield* ctx.core.integrations.remove(slug); + return { removed: true }; + }), + }), tool({ name: "connections.list", description: diff --git a/packages/core/sdk/src/executor.test.ts b/packages/core/sdk/src/executor.test.ts index f5d18cfc87..029b06d75d 100644 --- a/packages/core/sdk/src/executor.test.ts +++ b/packages/core/sdk/src/executor.test.ts @@ -38,6 +38,7 @@ const memoryProvider = (): CredentialProvider => { }; const INTEG = IntegrationSlug.make("demo"); +const PINNED = IntegrationSlug.make("demo-pinned"); const TEMPLATE = AuthTemplateSlug.make("apiKey"); const CONN = ConnectionName.make("main"); @@ -94,6 +95,15 @@ const demoPlugin = definePlugin(() => ({ description: "Demo", config: {}, }), + /** A catalog row the host pins in place (`canRemove: false`), the shape + * `integrations.remove` has to refuse rather than drop. */ + seedPinned: () => + ctx.core.integrations.register({ + slug: PINNED, + description: "Demo (pinned)", + config: {}, + canRemove: false, + }), storagePut: (owner: "org" | "user", key: string, value: string) => ctx.storage.put(owner, key, value), storageList: () => ctx.storage.list(), @@ -347,6 +357,66 @@ describe("createExecutor", () => { }), ); + it.effect("removes catalog integrations through the built-in Executor tools", () => + Effect.gen(function* () { + const executor = yield* makeTestExecutor({ + plugins: [demoPlugin] as const, + coreTools: {}, + }); + yield* executor.demo.seed(); + yield* executor.demo.seedPinned(); + yield* executor.execute( + ToolAddress.make("executor.coreTools.connections.create"), + { + owner: "org", + name: String(CONN), + integration: String(INTEG), + template: String(TEMPLATE), + from: { provider: "memory", id: "secret-token" }, + }, + { onElicitation: "accept-all" }, + ); + + const remove = ToolAddress.make("executor.coreTools.integrations.remove"); + + // Removing the integration cascades to the connections under it. + const removed = yield* executor.execute( + remove, + { slug: String(INTEG) }, + { onElicitation: "accept-all" }, + ); + expect(removed).toEqual({ removed: true }); + const listed = yield* executor.integrations.list(); + expect(listed.map((integration) => String(integration.slug))).not.toContain(String(INTEG)); + expect(yield* executor.connections.list()).toHaveLength(0); + + // An already-absent slug and a built-in namespace both report honestly + // instead of claiming a removal that never happened. + expect( + yield* executor.execute(remove, { slug: String(INTEG) }, { onElicitation: "accept-all" }), + ).toEqual({ removed: false }); + expect( + yield* executor.execute(remove, { slug: "executor" }, { onElicitation: "accept-all" }), + ).toEqual({ removed: false }); + + // A pinned integration is refused, and survives the attempt. + const refused = yield* Effect.result( + executor.execute(remove, { slug: String(PINNED) }, { onElicitation: "accept-all" }), + ); + expect(Result.isFailure(refused)).toBe(true); + if (!Result.isFailure(refused)) return; + expect(Predicate.isTagged(refused.failure, "ToolInvocationError")).toBe(true); + expect( + Predicate.isTagged( + (refused.failure as { readonly cause?: unknown }).cause, + "IntegrationRemovalNotAllowedError", + ), + ).toBe(true); + const afterRefusal = yield* executor.integrations.list(); + expect(afterRefusal.map((integration) => String(integration.slug))).toContain(String(PINNED)); + }), + ); + it.effect("surfaces failed tool sync diagnostics through connection tools", () => Effect.gen(function* () { const executor = yield* makeTestExecutor({ From 624e85f033632a7624c2bddf0944112166b1f481 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:43:30 +0000 Subject: [PATCH 005/133] Report honest removed flag from oauth.clients.remove (#1603) The tool mapped an idempotent storage no-op to removed: true, so a typo'd slug, an already-deleted client and the wrong owner all read as a real deletion. Clients are keyed by (owner, slug), so a sweep under one hardcoded owner silently skipped the other scope's copies. The tool now checks the visible client set first; the service-level removeClient stays idempotent. --- .changeset/oauth-clients-remove-honest.md | 11 +++ packages/core/sdk/src/core-tools.ts | 22 ++++-- .../core/sdk/src/oauth-remove-client.test.ts | 67 ++++++++++++++++++- 3 files changed, 94 insertions(+), 6 deletions(-) create mode 100644 .changeset/oauth-clients-remove-honest.md diff --git a/.changeset/oauth-clients-remove-honest.md b/.changeset/oauth-clients-remove-honest.md new file mode 100644 index 0000000000..88371592b0 --- /dev/null +++ b/.changeset/oauth-clients-remove-honest.md @@ -0,0 +1,11 @@ +--- +"executor": patch +--- + +**Fix: `oauth.clients.remove` reported success for clients it never removed** + +The tool returned `{ removed: true }` unconditionally. `oauth.removeClient` is idempotent by design at the storage layer — `deleteMany` on a missing row is a no-op, which is the right behaviour for a delete — but the tool mapped that silence to success, so a typo'd slug, an already-deleted client, and the wrong owner were all indistinguishable from a real deletion. + +This bites hardest because clients are keyed by BOTH owner and slug, so the same slug can exist separately under `org` and `user`. An agent sweeping a list of slugs under one hardcoded owner would delete only half of them and report every call as a success, leaving org-owned OAuth apps registered after everything they authorized was gone. + +The tool now checks the caller-visible client set first and returns `removed: false` when nothing matched that `(owner, slug)` pair. The service-level `removeClient` is unchanged and stays idempotent. diff --git a/packages/core/sdk/src/core-tools.ts b/packages/core/sdk/src/core-tools.ts index b01952747f..e6dc78ce77 100644 --- a/packages/core/sdk/src/core-tools.ts +++ b/packages/core/sdk/src/core-tools.ts @@ -819,7 +819,7 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { tool({ name: "oauth.clients.remove", description: - "Remove an owner-scoped OAuth client by owner and slug. Existing connections are not cascaded.", + "Remove an owner-scoped OAuth client by owner and slug. `removed: false` means no client matched that owner and slug — clients are keyed by BOTH, so the same slug can exist separately under `org` and `user`. Existing connections are not cascaded.", inputSchema: OAuthRemoveClientInputStd, outputSchema: RemovedOutputStd, // Removing a client breaks token refresh for every connection that @@ -828,10 +828,22 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { // `sources.bindings.remove`. annotations: { requiresApproval: true }, execute: (input: typeof OAuthRemoveClientInput.Type, { ctx }) => - Effect.map( - ctx.oauth.removeClient(input.owner as Owner, OAuthClientSlug.make(input.slug)), - () => ({ removed: true }), - ), + Effect.gen(function* () { + const owner = input.owner as Owner; + const slug = OAuthClientSlug.make(input.slug); + // `removeClient` is idempotent by design at the storage layer, so + // on its own it cannot distinguish a real deletion from a typo'd + // slug or the wrong owner — and a caller sweeping a list of slugs + // under one hardcoded owner would read every no-op as success. + // Checking the visible set first is what keeps `removed` honest. + const clients = yield* ctx.oauth.listClients(); + const matched = clients.some( + (client) => client.owner === owner && String(client.slug) === String(slug), + ); + if (!matched) return { removed: false }; + yield* ctx.oauth.removeClient(owner, slug); + return { removed: true }; + }), }), tool({ name: "oauth.probe", diff --git a/packages/core/sdk/src/oauth-remove-client.test.ts b/packages/core/sdk/src/oauth-remove-client.test.ts index a5c90af07c..7ce3ffccc4 100644 --- a/packages/core/sdk/src/oauth-remove-client.test.ts +++ b/packages/core/sdk/src/oauth-remove-client.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; import { Effect } from "effect"; -import { OAuthClientSlug } from "./ids"; +import { OAuthClientSlug, ToolAddress } from "./ids"; import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; // removeClient permanently deletes an owner-scoped oauth_client row, keyed by @@ -175,4 +175,69 @@ describe("oauth.removeClient", () => { }), ), ); + + // The `oauth.clients.remove` TOOL reports `removed` honestly on top of the + // idempotent service call above, so an agent sweeping a list of slugs cannot + // read a no-op as a deletion. + it.effect("the remove tool distinguishes a real deletion from a no-op", () => + Effect.scoped( + Effect.gen(function* () { + const { executor } = yield* makeTestWorkspaceHarness({ + plugins, + coreTools: {}, + }); + const remove = ToolAddress.make("executor.coreTools.oauth.clients.remove"); + + // The same slug registered under BOTH owners — the shape that made a + // hardcoded `owner: "user"` sweep silently skip the org copy. + for (const owner of ["org", "user"] as const) { + yield* executor.oauth.createClient({ + owner, + slug: ORG_CLIENT, + authorizationUrl: "https://acme.test/authorize", + tokenUrl: "https://acme.test/token", + grant: "authorization_code", + clientId: `${owner}-client-id`, + clientSecret: `${owner}-secret`, + }); + } + + // A slug that never existed is not a removal. + expect( + yield* executor.execute( + remove, + { owner: "user", slug: "never-existed" }, + { onElicitation: "accept-all" }, + ), + ).toEqual({ removed: false }); + + // Removing the user copy leaves the org copy, which still reports as a + // real removal of its own rather than as already-gone. + expect( + yield* executor.execute( + remove, + { owner: "user", slug: String(ORG_CLIENT) }, + { onElicitation: "accept-all" }, + ), + ).toEqual({ removed: true }); + expect( + yield* executor.execute( + remove, + { owner: "org", slug: String(ORG_CLIENT) }, + { onElicitation: "accept-all" }, + ), + ).toEqual({ removed: true }); + + // Both are gone, and a repeat of either is now a no-op. + expect(yield* executor.oauth.listClients()).toEqual([]); + expect( + yield* executor.execute( + remove, + { owner: "org", slug: String(ORG_CLIENT) }, + { onElicitation: "accept-all" }, + ), + ).toEqual({ removed: false }); + }), + ), + ); }); From 8cb0d22a3413b7e6234f78dfe025c187d139e59b Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:37:51 -0600 Subject: [PATCH 006/133] Migrate outbound MCP client to SDK v2 (spec 2026-07-28) * Align stale @modelcontextprotocol/sdk floors to ^1.29.0 * Migrate outbound MCP client to SDK v2 (spec 2026-07-28) * Reject sessionless non-initialize POSTs in the MCP test fixture --- apps/local/package.json | 2 +- bun.lock | 18 +++- packages/hosts/mcp-apps-shell/package.json | 2 +- packages/hosts/mcp/package.json | 2 +- packages/plugins/mcp/package.json | 2 + .../plugins/mcp/src/sdk/connection-pool.ts | 4 + packages/plugins/mcp/src/sdk/connection.ts | 30 +++--- .../plugins/mcp/src/sdk/elicitation.test.ts | 6 +- .../plugins/mcp/src/sdk/http-status.test.ts | 20 +++- packages/plugins/mcp/src/sdk/http-status.ts | 45 +++++---- packages/plugins/mcp/src/sdk/invoke.test.ts | 15 ++- packages/plugins/mcp/src/sdk/invoke.ts | 39 +++++--- packages/plugins/mcp/src/sdk/plugin.ts | 11 ++- .../plugins/mcp/src/sdk/probe-shape.test.ts | 92 ++++++++++++++++++- packages/plugins/mcp/src/sdk/probe-shape.ts | 61 ++++++++++-- .../plugins/mcp/src/sdk/stdio-connector.ts | 7 +- .../mcp/src/sdk/testing-fixtures.test.ts | 8 +- packages/plugins/mcp/src/testing/server.ts | 39 +++++++- 18 files changed, 315 insertions(+), 88 deletions(-) diff --git a/apps/local/package.json b/apps/local/package.json index 6c5ae91317..26f877d9b4 100644 --- a/apps/local/package.json +++ b/apps/local/package.json @@ -46,7 +46,7 @@ "@executor-js/sdk": "workspace:*", "@executor-js/vite-plugin": "workspace:*", "@libsql/client": "catalog:", - "@modelcontextprotocol/sdk": "^1.12.1", + "@modelcontextprotocol/sdk": "^1.29.0", "@tanstack/react-router": "catalog:", "drizzle-orm": "catalog:", "effect": "catalog:", diff --git a/bun.lock b/bun.lock index adf616729e..43c3519f48 100644 --- a/bun.lock +++ b/bun.lock @@ -300,7 +300,7 @@ "@executor-js/sdk": "workspace:*", "@executor-js/vite-plugin": "workspace:*", "@libsql/client": "catalog:", - "@modelcontextprotocol/sdk": "^1.12.1", + "@modelcontextprotocol/sdk": "^1.29.0", "@tanstack/react-router": "catalog:", "drizzle-orm": "catalog:", "effect": "catalog:", @@ -703,7 +703,7 @@ "@executor-js/execution": "workspace:*", "@executor-js/sdk": "workspace:*", "@modelcontextprotocol/ext-apps": "^1.7.4", - "@modelcontextprotocol/sdk": "^1.12.1", + "@modelcontextprotocol/sdk": "^1.29.0", "effect": "catalog:", "zod": "4.3.6", }, @@ -721,7 +721,7 @@ "@executor-js/react": "workspace:*", "@executor-js/runtime-quickjs": "workspace:*", "@modelcontextprotocol/ext-apps": "^1.7.4", - "@modelcontextprotocol/sdk": "^1.12.1", + "@modelcontextprotocol/sdk": "^1.29.0", "@tanstack/react-query": "^5.99.0", "effect": "catalog:", "esbuild": "^0.27.7", @@ -994,6 +994,8 @@ "@effect/platform-node": "catalog:", "@executor-js/config": "workspace:*", "@executor-js/sdk": "workspace:*", + "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/core": "2.0.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "4.3.6", }, @@ -2126,6 +2128,10 @@ "@mishieck/ink-titled-box": ["@mishieck/ink-titled-box@0.3.0", "", { "peerDependencies": { "ink": "^6.0.0", "react": "^19.1.0", "typescript": "^5" } }, "sha512-ugzVH9hixp3hwKfQ8On/qnsrdAxS3y9rTu/aGOFed4zVUvtZyGZNIR4rxAwXult8HKI4vJEh0OM8wib9NPrwUg=="], + "@modelcontextprotocol/client": ["@modelcontextprotocol/client@2.0.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.0.0", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "jose": "^6.1.3", "pkce-challenge": "^5.0.0", "zod": "^4.2.0" } }, "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw=="], + + "@modelcontextprotocol/core": ["@modelcontextprotocol/core@2.0.0", "", { "dependencies": { "zod": "^4.2.0" } }, "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA=="], + "@modelcontextprotocol/ext-apps": ["@modelcontextprotocol/ext-apps@1.7.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-TjPH2S2y5UEGKhmI6+XGFuqfqOV4ppe1x6DA3txnUaEWkgtA4G5vo14jGKFZmegdkZ1H4QMLyujLvoU1BEdnAg=="], "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], @@ -6118,6 +6124,12 @@ "@manypkg/get-packages/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], + "@modelcontextprotocol/client/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], + + "@modelcontextprotocol/client/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + + "@modelcontextprotocol/core/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "@modelcontextprotocol/sdk/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], "@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], diff --git a/packages/hosts/mcp-apps-shell/package.json b/packages/hosts/mcp-apps-shell/package.json index af2ca34b76..f2f762f380 100644 --- a/packages/hosts/mcp-apps-shell/package.json +++ b/packages/hosts/mcp-apps-shell/package.json @@ -69,7 +69,7 @@ "@executor-js/react": "workspace:*", "@executor-js/runtime-quickjs": "workspace:*", "@modelcontextprotocol/ext-apps": "^1.7.4", - "@modelcontextprotocol/sdk": "^1.12.1", + "@modelcontextprotocol/sdk": "^1.29.0", "@tanstack/react-query": "^5.99.0", "effect": "catalog:", "esbuild": "^0.27.7", diff --git a/packages/hosts/mcp/package.json b/packages/hosts/mcp/package.json index 2c0345b466..1e8dd7c4fc 100644 --- a/packages/hosts/mcp/package.json +++ b/packages/hosts/mcp/package.json @@ -48,7 +48,7 @@ "@executor-js/execution": "workspace:*", "@executor-js/sdk": "workspace:*", "@modelcontextprotocol/ext-apps": "^1.7.4", - "@modelcontextprotocol/sdk": "^1.12.1", + "@modelcontextprotocol/sdk": "^1.29.0", "effect": "catalog:", "zod": "4.3.6" }, diff --git a/packages/plugins/mcp/package.json b/packages/plugins/mcp/package.json index d899b77212..13aedbbfb2 100644 --- a/packages/plugins/mcp/package.json +++ b/packages/plugins/mcp/package.json @@ -65,6 +65,8 @@ "@effect/platform-node": "catalog:", "@executor-js/config": "workspace:*", "@executor-js/sdk": "workspace:*", + "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/core": "2.0.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "4.3.6" }, diff --git a/packages/plugins/mcp/src/sdk/connection-pool.ts b/packages/plugins/mcp/src/sdk/connection-pool.ts index 6b732caf78..bf25c866a2 100644 --- a/packages/plugins/mcp/src/sdk/connection-pool.ts +++ b/packages/plugins/mcp/src/sdk/connection-pool.ts @@ -3,6 +3,10 @@ import { Cause, Effect, Exit, Predicate } from "effect"; import type { McpConnection, McpConnector } from "./connection"; import type { McpInvocationError } from "./errors"; +// The pool preserves sessions for sessionful legacy servers. Stateless +// 2026-07-28 servers do not need it, but retaining a cheap idle client is +// harmless and keeps one lifecycle for both protocol eras. + const IDLE_TTL_MS = 5 * 60 * 1_000; type IdleConnection = { diff --git a/packages/plugins/mcp/src/sdk/connection.ts b/packages/plugins/mcp/src/sdk/connection.ts index 82a98cd0d1..22fb1647cd 100644 --- a/packages/plugins/mcp/src/sdk/connection.ts +++ b/packages/plugins/mcp/src/sdk/connection.ts @@ -1,16 +1,18 @@ -import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; -import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js"; -import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker"; +import { + Client, + SSEClientTransport, + StreamableHTTPClientTransport, + type FetchLike, + type OAuthClientProvider, +} from "@modelcontextprotocol/client"; +import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/client/validators/cf-worker"; import { Effect, Layer, Predicate, Stream } from "effect"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; // NOTE: `StdioClientTransport` is NOT imported eagerly. The upstream module -// (`@modelcontextprotocol/sdk/client/stdio.js`) touches `node:child_process` -// at evaluation time, which crashes workerd (incl. vitest-pool-workers) at -// SIGSEGV on module instantiation. Cloud callers set +// (`@modelcontextprotocol/client/stdio`) still imports Node process/stream and +// `cross-spawn` eagerly at evaluation time, which crashes workerd (including +// vitest-pool-workers) with SIGSEGV on module instantiation. Cloud callers set // `dangerouslyAllowStdioMCP: false` and never reach the stdio branch below; // prod bundles that DO use stdio load it via a dynamic import inside the // stdio branch of `createMcpConnector`. @@ -201,12 +203,13 @@ const fetchFromHttpClientLayer = ( // MCP plugin runs inside a Cloudflare Worker (executor.sh). The // cfworker validator does not use code generation and works in every // runtime we ship to. -const createClient = (): Client => +const createClient = (versionNegotiation?: { readonly mode: "auto" }): Client => new Client( { name: "executor-mcp", version: "0.1.0" }, { capabilities: { elicitation: { form: {}, url: {} } }, jsonSchemaValidator: new CfWorkerJsonSchemaValidator(), + ...(versionNegotiation === undefined ? {} : { versionNegotiation }), }, ); @@ -247,9 +250,10 @@ const connectionFailure = ( const connectClient = (input: { transport: string; createTransport: () => Parameters[0]; + versionNegotiation?: { readonly mode: "auto" }; }): Effect.Effect => Effect.gen(function* () { - const client = createClient(); + const client = createClient(input.versionNegotiation); const transportInstance = input.createTransport(); yield* Effect.tryPromise({ @@ -314,8 +318,12 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { const endpoint = buildEndpointUrl(input.endpoint, input.queryParams ?? {}); + // Auto-negotiate the 2026-07-28 era only on Streamable HTTP. SSE is a + // legacy-only transport, and stdio servers are spawned per call where the + // SDK recommends retaining its legacy-default handshake. const connectStreamableHttp = connectClient({ transport: "streamable-http", + versionNegotiation: { mode: "auto" }, createTransport: () => new StreamableHTTPClientTransport(endpoint, { requestInit, diff --git a/packages/plugins/mcp/src/sdk/elicitation.test.ts b/packages/plugins/mcp/src/sdk/elicitation.test.ts index 4ca64f32a2..cc2bed354f 100644 --- a/packages/plugins/mcp/src/sdk/elicitation.test.ts +++ b/packages/plugins/mcp/src/sdk/elicitation.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Predicate, Schema, Semaphore } from "effect"; -import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker"; -import type { JsonSchemaType } from "@modelcontextprotocol/sdk/validation/types"; +import type { JsonSchemaType } from "@modelcontextprotocol/client"; +import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/client/validators/cf-worker"; import { AuthTemplateSlug, @@ -226,7 +226,7 @@ describe("MCP elicitation (end-to-end)", () => { ]), ); expect(schema?.outputTypeScript).toContain('type: "text"'); - expect(schema?.outputTypeScript).toContain("structuredContent?: { [k: string]: unknown; }"); + expect(schema?.outputTypeScript).toContain("structuredContent?: unknown;"); const result = yield* executor.execute( simpleEcho.address, diff --git a/packages/plugins/mcp/src/sdk/http-status.test.ts b/packages/plugins/mcp/src/sdk/http-status.test.ts index 2276509f7e..0344a71986 100644 --- a/packages/plugins/mcp/src/sdk/http-status.test.ts +++ b/packages/plugins/mcp/src/sdk/http-status.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; +import { InsufficientScopeError, SdkErrorCode, SdkHttpError } from "@modelcontextprotocol/client"; // oxlint-disable executor/no-error-constructor -- boundary: these tests reproduce the MCP SDK's own transport rejections, which are built-in Errors import { insufficientScopeFromCause } from "./http-status"; @@ -9,7 +10,8 @@ import { insufficientScopeFromCause } from "./http-status"; // - with an authProvider (the production OAuth path): the StreamableHTTP // transport consumes the insufficient_scope challenge itself, retries // with the broader scope, and only when THAT fails throws the fixed -// "Server returned 403 after trying upscoping" message. +// typed `InsufficientScopeError`, or after retry exhaustion the fixed +// `SdkHttpError` step-up message. describe("insufficientScopeFromCause", () => { it("detects the OAuth error body embedded in a transport message", () => { expect( @@ -31,9 +33,21 @@ describe("insufficientScopeFromCause", () => { ).toBe(true); }); - it("detects the SDK's exhausted-upscoping failure (the authProvider path)", () => { + it("detects the SDK's typed insufficient-scope failure", () => { expect( - insufficientScopeFromCause(new Error("Server returned 403 after trying upscoping")), + insufficientScopeFromCause(new InsufficientScopeError({ requiredScope: "files.read" })), + ).toBe(true); + }); + + it("detects the SDK's exhausted step-up failure (the authProvider path)", () => { + expect( + insufficientScopeFromCause( + new SdkHttpError( + SdkErrorCode.ClientHttpForbidden, + "Server returned 403 insufficient_scope after step-up re-authorization (retry limit 2 reached)", + { status: 403 }, + ), + ), ).toBe(true); }); diff --git a/packages/plugins/mcp/src/sdk/http-status.ts b/packages/plugins/mcp/src/sdk/http-status.ts index a4442f8d23..541c631f34 100644 --- a/packages/plugins/mcp/src/sdk/http-status.ts +++ b/packages/plugins/mcp/src/sdk/http-status.ts @@ -1,7 +1,8 @@ // --------------------------------------------------------------------------- // Extract the HTTP status from an MCP SDK transport error. The SDK surfaces -// transport failures two ways: a `StreamableHTTPError` subclass carrying a -// numeric `code`, and an SSE POST failure whose message embeds `(HTTP nnn)`. +// transport failures two ways: an `SdkHttpError` carrying a numeric `status`, +// and an `SseError` carrying a numeric `code`. The SSE transport also retains +// its historic POST-failure message for errors created below EventSource. // Shared by the invoke path (classifies tool-call failures) and the connect // path (so a 401/403 during the handshake reaches the liveness health check). // --------------------------------------------------------------------------- @@ -9,13 +10,13 @@ import { Option, Schema } from "effect"; import { insufficientScopeFromEmbeddedJson } from "@executor-js/sdk/core"; -import { StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { InsufficientScopeError, SdkHttpError, SseError } from "@modelcontextprotocol/client"; const SsePostErrorCause = Schema.Struct({ message: Schema.String }); const decodeSsePostErrorCause = Schema.decodeUnknownOption(SsePostErrorCause); -// Matches the SDK's SSEClientTransport POST-failure message (sse.js); re-verify -// on SDK bumps. A format drift just yields undefined (generic error, no crash). +// V2 still constructs this exact message in SSEClientTransport._send. A format +// drift just yields undefined (generic error, no crash). const statusFromSsePostError = (cause: unknown): number | undefined => Option.match(decodeSsePostErrorCause(cause), { onNone: () => undefined, @@ -26,32 +27,36 @@ const statusFromSsePostError = (cause: unknown): number | undefined => }, }); -const statusFromStreamableHttpError = (cause: unknown): number | undefined => { - // oxlint-disable-next-line executor/no-instanceof-tagged-error -- boundary: MCP SDK exposes transport HTTP failures as this Error subclass; protocol errors can carry the same numeric code - if (!(cause instanceof StreamableHTTPError)) return undefined; - const code = cause.code; - return code !== undefined && code >= 100 && code <= 599 ? code : undefined; +const statusFromTypedTransportError = (cause: unknown): number | undefined => { + if (SdkHttpError.isInstance(cause)) return cause.status; + if (SseError.isInstance(cause)) { + const code = cause.code; + return code !== undefined && code >= 100 && code <= 599 ? code : undefined; + } + return undefined; }; export const httpStatusFromCause = (cause: unknown): number | undefined => - statusFromStreamableHttpError(cause) ?? statusFromSsePostError(cause); + statusFromTypedTransportError(cause) ?? statusFromSsePostError(cause); // The SDK embeds the upstream response text in the transport error message // ("Error POSTing to endpoint: "), which is the only place a 403's body // survives for connections without an authProvider. For OAuth connections the -// StreamableHTTP transport consumes the insufficient_scope challenge ITSELF: -// it re-runs auth requesting the broader scope, and only when that upscoped -// retry still 403s does it throw — with the fixed message matched below -// (verified against @modelcontextprotocol/sdk streamableHttp.js; re-verify on -// SDK bumps). Both paths mean the same thing: the grant does not cover the -// operation, and re-running the identical flow cannot help. Strict matching +// StreamableHTTP transport consumes the insufficient_scope challenge itself. +// V2 throws `InsufficientScopeError` when configured not to reauthorize; after +// exhausting its step-up retries it throws `SdkHttpError` with the exact fixed +// message matched below (verified against the installed v2 transport source). +// Both paths mean the same thing: the grant does not cover the operation, and +// re-running the identical flow cannot help. Strict matching // (exact serialized field forms via the shared core detector, or the SDK's -// exact upscoping message) — a miss stays on the generic auth path. -const SDK_UPSCOPING_EXHAUSTED_RE = /Server returned 403 after trying upscoping/; +// exact step-up message) — a miss stays on the generic auth path. +const SDK_STEP_UP_EXHAUSTED_RE = + /^Server returned 403 insufficient_scope after step-up re-authorization \(retry limit \d+ reached\)$/; export const insufficientScopeFromCause = (cause: unknown): boolean => + InsufficientScopeError.isInstance(cause) || Option.match(decodeSsePostErrorCause(cause), { onNone: () => false, onSome: ({ message }) => - insufficientScopeFromEmbeddedJson(message) || SDK_UPSCOPING_EXHAUSTED_RE.test(message), + insufficientScopeFromEmbeddedJson(message) || SDK_STEP_UP_EXHAUSTED_RE.test(message), }); diff --git a/packages/plugins/mcp/src/sdk/invoke.test.ts b/packages/plugins/mcp/src/sdk/invoke.test.ts index 3b5aaae66f..ce133bfd5f 100644 --- a/packages/plugins/mcp/src/sdk/invoke.test.ts +++ b/packages/plugins/mcp/src/sdk/invoke.test.ts @@ -2,9 +2,12 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Predicate } from "effect"; import { HttpServerResponse } from "effect/unstable/http"; -import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; -import { StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; -import { McpError } from "@modelcontextprotocol/sdk/types.js"; +import { + ProtocolError, + SdkErrorCode, + SdkHttpError, + type OAuthClientProvider, +} from "@modelcontextprotocol/client"; import { ElicitationResponse } from "@executor-js/sdk"; import { serveTestHttpApp } from "@executor-js/sdk/testing"; @@ -108,14 +111,16 @@ const invocationRejectionCases = [ name: "wraps callTool rejection with a stable message and status", toolId: "blocked", transport: "streamable-http", - cause: new StreamableHTTPError(401, "token=do-not-leak"), + cause: new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, "token=do-not-leak", { + status: 401, + }), expectedStatus: 401 as number | undefined, }, { name: "does not treat MCP protocol error codes as HTTP statuses", toolId: "protocol_error", transport: "streamable-http", - cause: new McpError(401, "application-level do-not-leak"), + cause: new ProtocolError(401, "application-level do-not-leak"), expectedStatus: undefined, }, { diff --git a/packages/plugins/mcp/src/sdk/invoke.ts b/packages/plugins/mcp/src/sdk/invoke.ts index 3e5aa7695f..46a08bc663 100644 --- a/packages/plugins/mcp/src/sdk/invoke.ts +++ b/packages/plugins/mcp/src/sdk/invoke.ts @@ -8,7 +8,7 @@ // legitimately retain state in that session. The pool keeps one idle // connection per resolved identity and leases it exclusively per invoke; // stdio and callers without a pool remain strictly per-call. -// 2. Installing a per-invocation `ElicitRequestSchema` handler that bridges +// 2. Installing a per-invocation `elicitation/create` handler that bridges // MCP's elicit capability into the host's elicit function threaded via // `InvokeToolInput.elicit`. // 3. Calling `client.callTool({ name, arguments })`. @@ -16,12 +16,7 @@ import { Cause, Effect, Exit, Option, Predicate, Schema } from "effect"; -import { - ElicitRequestSchema, - ErrorCode, - McpError, - ToolListChangedNotificationSchema, -} from "@modelcontextprotocol/sdk/types.js"; +import { ProtocolError, ProtocolErrorCode } from "@modelcontextprotocol/client"; import { ElicitationId, @@ -66,9 +61,10 @@ export const isUnknownToolMessage = (message: string, toolName: string): boolean const isUnknownToolCause = (cause: unknown, toolName: string): boolean => // oxlint-disable-next-line executor/no-instanceof-tagged-error -- boundary: MCP SDK surfaces JSON-RPC protocol errors as this Error subclass - cause instanceof McpError && - (cause.code === ErrorCode.InvalidParams || cause.code === ErrorCode.MethodNotFound) && - // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: instanceof narrows to the SDK's McpError, whose message carries the only unknown-tool discriminator the protocol provides + cause instanceof ProtocolError && + (cause.code === ProtocolErrorCode.InvalidParams || + cause.code === ProtocolErrorCode.MethodNotFound) && + // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: instanceof narrows to the SDK's ProtocolError, whose message carries the only unknown-tool discriminator the protocol provides isUnknownToolMessage(cause.message, toolName); // --------------------------------------------------------------------------- @@ -93,6 +89,17 @@ const McpElicitParams = Schema.Union([ type McpElicitParams = typeof McpElicitParams.Type; const decodeElicitParams = Schema.decodeUnknownSync(McpElicitParams); +const decodeElicitContent = Schema.decodeUnknownSync( + Schema.Record( + Schema.String, + Schema.Union([ + Schema.String, + Schema.Number, + Schema.Boolean, + Schema.mutable(Schema.Array(Schema.String)), + ]), + ), +); const toElicitationRequest = (params: McpElicitParams): ElicitationRequest => params.mode === "url" @@ -107,7 +114,7 @@ const toElicitationRequest = (params: McpElicitParams): ElicitationRequest => }); const installElicitationHandler = (client: McpConnection["client"], elicit: Elicit): void => { - client.setRequestHandler(ElicitRequestSchema, async (request: { params: unknown }) => { + client.setRequestHandler("elicitation/create", async (request: { params: unknown }) => { const params = decodeElicitParams(request.params); const req = toElicitationRequest(params); // Use runPromiseExit so we can inspect typed failures — `elicit` @@ -119,7 +126,9 @@ const installElicitationHandler = (client: McpConnection["client"], elicit: Elic const response = exit.value; return { action: response.action, - ...(response.action === "accept" && response.content ? { content: response.content } : {}), + ...(response.action === "accept" && response.content + ? { content: decodeElicitContent(response.content) } + : {}), }; } const failure = exit.cause.reasons.find(Cause.isFailReason); @@ -149,7 +158,7 @@ const installToolListChangedHandler = ( onToolListChanged: (() => void) | undefined, ): void => { if (!onToolListChanged) return; - client.setNotificationHandler(ToolListChangedNotificationSchema, () => { + client.setNotificationHandler("notifications/tools/list_changed", () => { onToolListChanged(); }); }; @@ -189,8 +198,8 @@ const useConnection = ( }); } const status = httpStatusFromCause(cause); - // oxlint-disable-next-line executor/no-instanceof-tagged-error -- boundary: MCP SDK protocol failures are its McpError subclass; transport failures use other error shapes - const protocolFailure = cause instanceof McpError; + // oxlint-disable-next-line executor/no-instanceof-tagged-error -- boundary: MCP SDK protocol failures are its ProtocolError subclass; transport failures use other error shapes + const protocolFailure = cause instanceof ProtocolError; return new McpInvocationError({ toolName, message: `MCP tool call failed for ${toolName}`, diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index e3b7a6857e..70ec0e69e6 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -1,9 +1,8 @@ import { Effect, Layer, Option, Result, Schema } from "effect"; import type { HttpClient } from "effect/unstable/http"; -import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; -import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js"; -import * as z from "zod/v4"; +import type { OAuthClientProvider } from "@modelcontextprotocol/client"; +import { CallToolResultSchema } from "@modelcontextprotocol/core"; import { authToolFailure, @@ -392,7 +391,7 @@ type JsonSchemaObject = Record & { readonly properties?: Record; }; -const McpCallToolResultJsonSchema = z.toJSONSchema(CallToolResultSchema) as JsonSchemaObject; +const McpCallToolResultJsonSchema: JsonSchemaObject = CallToolResultSchema.toJSONSchema(); const mcpCallToolResultOutputSchema = (structuredContentSchema?: unknown): JsonSchemaObject => { const defaultStructuredContentSchema = @@ -493,7 +492,9 @@ export const userFacingProbeMessage = ( // MCP-SDK OAuth provider adapter — wraps a pre-resolved access token so the // transport sends it as a Bearer header. Refresh is core's responsibility // (the connection row carries the OAuth grant); this adapter never initiates -// a new flow and fails loudly if the SDK tries to. +// a new flow and fails loudly if the SDK tries to. V2 stamps stored credentials +// with the authorization-server issuer and offers scoped invalidation; this +// single-token boundary intentionally persists neither. // --------------------------------------------------------------------------- const makeOAuthProvider = (accessToken: string): OAuthClientProvider => ({ diff --git a/packages/plugins/mcp/src/sdk/probe-shape.test.ts b/packages/plugins/mcp/src/sdk/probe-shape.test.ts index 7eb124ebaf..8a61f40b47 100644 --- a/packages/plugins/mcp/src/sdk/probe-shape.test.ts +++ b/packages/plugins/mcp/src/sdk/probe-shape.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Ref } from "effect"; -import { HttpServerResponse } from "effect/unstable/http"; +import { Effect, Layer, Ref } from "effect"; +import { + HttpClient, + HttpClientError, + HttpClientRequest, + HttpClientResponse, + HttpServerResponse, +} from "effect/unstable/http"; import { serveTestHttpApp } from "@executor-js/sdk/testing"; import { probeMcpEndpointShape } from "./probe-shape"; @@ -320,6 +326,88 @@ describe("probeMcpEndpointShape", () => { ), ); + it.effect("falls through a wrong-shape legacy GET retry to modern server discovery", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveProbeEndpoint((request) => { + if (request.body.includes('"method":"server/discover"')) { + return HttpServerResponse.jsonUnsafe({ + jsonrpc: "2.0", + id: 2, + error: { code: -32601, message: "Method not found" }, + }); + } + if (request.method === "GET") { + return HttpServerResponse.jsonUnsafe({ error: "legacy SSE is unsupported" }); + } + return HttpServerResponse.empty({ status: 405 }); + }); + + const result = yield* probeMcpEndpointShape(server.endpoint); + expect(result).toEqual({ kind: "mcp", requiresAuth: false }); + + const requests = yield* server.requests; + expect(requests).toHaveLength(3); + expect(requests[0]?.body).toContain('"protocolVersion":"2025-11-25"'); + expect(requests[1]?.method).toBe("GET"); + expect(requests[2]?.body).toBe( + JSON.stringify({ + jsonrpc: "2.0", + id: 2, + method: "server/discover", + params: { + _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" }, + }, + }), + ); + expect(requests[2]?.headers["mcp-protocol-version"]).toBe("2026-07-28"); + }), + ), + ); + + // First request (initialize) answers 200 HTML; second (the discover + // fallback) dies at the transport. The endpoint already proved reachable, + // so the verdict must stay the initialize classification, not "unreachable". + it.effect("keeps the initialize verdict when the discover fallback fails at the transport", () => + Effect.gen(function* () { + let requestCount = 0; + const httpClientLayer = Layer.succeed(HttpClient.HttpClient)( + HttpClient.make((request: HttpClientRequest.HttpClientRequest) => { + requestCount += 1; + if (requestCount > 1) { + return Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request, + description: "connection reset by peer", + }), + }), + ); + } + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response("not mcp", { + status: 200, + headers: { "content-type": "text/html" }, + }), + ), + ); + }), + ); + + const result = yield* probeMcpEndpointShape("https://internal.example/mcp", { + httpClientLayer, + }); + expect(result).toEqual({ + kind: "not-mcp", + category: "wrong-shape", + reason: "2xx POST body is not a JSON-RPC envelope", + }); + expect(requestCount).toBe(2); + }), + ); + it.effect("rejects 2xx with HTML body as wrong-shape", () => withServer( () => diff --git a/packages/plugins/mcp/src/sdk/probe-shape.ts b/packages/plugins/mcp/src/sdk/probe-shape.ts index 3b3be94297..1049fb9f22 100644 --- a/packages/plugins/mcp/src/sdk/probe-shape.ts +++ b/packages/plugins/mcp/src/sdk/probe-shape.ts @@ -13,7 +13,7 @@ // and (b) plenty of real MCP servers authenticate with static API // keys and publish no OAuth metadata at all (e.g. cubic.dev). // -// The probe issues an unauth JSON-RPC `initialize` POST and accepts +// The primary probe issues an unauth JSON-RPC `initialize` POST and accepts // only the wire shapes a real MCP server can return: // // - 2xx with `Content-Type: text/event-stream` — streamable HTTP @@ -30,8 +30,14 @@ // only accepts 2xx with `text/event-stream` or the same 401+Bearer // shape. // -// One `fetch` (occasionally two), no MCP-SDK session state, no OAuth -// round-trip, no DCR — every non-MCP endpoint exits here. +// If initialize ultimately has the wrong shape, a second JSON-RPC POST probes +// `server/discover` using the 2026-07-28 envelope and protocol header. This +// catches modern-only servers that reject initialize with a non-JSON-RPC +// response. Authentication outcomes remain terminal because they do not vary +// by transport era. +// +// One primary request (occasionally plus legacy GET and modern discover), no +// MCP-SDK session state, no OAuth round-trip, no DCR. // --------------------------------------------------------------------------- import { Data, Duration, Effect, Layer, Option, Schema } from "effect"; @@ -48,12 +54,21 @@ const INITIALIZE_BODY = JSON.stringify({ id: 1, method: "initialize", params: { - protocolVersion: "2025-06-18", + protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "executor-probe", version: "0" }, }, }); +const DISCOVER_BODY = JSON.stringify({ + jsonrpc: "2.0", + id: 2, + method: "server/discover", + params: { + _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" }, + }, +}); + /** Header-name lookup is case-insensitive per RFC 7230. `fetch`'s * `Response.headers` already lower-cases, but we normalise explicitly * to stay robust against test mocks that construct `Headers` loosely. */ @@ -385,10 +400,9 @@ export const probeMcpEndpointShape = ( .execute(postRequest) .pipe(Effect.timeout(Duration.millis(timeoutMs))); - const postResult = yield* classify(postResponse, "POST"); - if (postResult) return postResult; + let initializeResult = yield* classify(postResponse, "POST"); - if ([404, 405, 406, 415].includes(postResponse.status)) { + if (initializeResult === null && [404, 405, 406, 415].includes(postResponse.status)) { let getRequest = HttpClientRequest.get(url.toString()).pipe( HttpClientRequest.setHeader("accept", "text/event-stream"), ); @@ -398,15 +412,42 @@ export const probeMcpEndpointShape = ( const getResponse = yield* client .execute(getRequest) .pipe(Effect.timeout(Duration.millis(timeoutMs))); - const getResult = yield* classify(getResponse, "GET"); - if (getResult) return getResult; + initializeResult = yield* classify(getResponse, "GET"); } - return { + initializeResult ??= { kind: "not-mcp", category: "wrong-shape", reason: `unexpected status ${postResponse.status} for initialize`, } as const; + + if (initializeResult.kind !== "not-mcp" || initializeResult.category !== "wrong-shape") { + return initializeResult; + } + + let discoverRequest = HttpClientRequest.post(url.toString()).pipe( + HttpClientRequest.setHeader("content-type", "application/json"), + HttpClientRequest.setHeader("accept", "application/json, text/event-stream"), + HttpClientRequest.bodyText(DISCOVER_BODY, "application/json"), + ); + for (const [name, value] of Object.entries(options.headers ?? {})) { + discoverRequest = HttpClientRequest.setHeader(discoverRequest, name, value); + } + discoverRequest = HttpClientRequest.setHeader( + discoverRequest, + "MCP-Protocol-Version", + "2026-07-28", + ); + + // The endpoint already answered the primary probe, so a transport + // failure on this secondary request must not overwrite that verdict + // with "unreachable" — keep the initialize classification instead. + const discoverResult = yield* client.execute(discoverRequest).pipe( + Effect.timeout(Duration.millis(timeoutMs)), + Effect.flatMap((discoverResponse) => classify(discoverResponse, "POST")), + Effect.catch(() => Effect.succeed(null)), + ); + return discoverResult ?? initializeResult; }).pipe( Effect.provide(options.httpClientLayer ?? FetchHttpClient.layer), Effect.mapError( diff --git a/packages/plugins/mcp/src/sdk/stdio-connector.ts b/packages/plugins/mcp/src/sdk/stdio-connector.ts index 99a0f72e37..6fec6f0617 100644 --- a/packages/plugins/mcp/src/sdk/stdio-connector.ts +++ b/packages/plugins/mcp/src/sdk/stdio-connector.ts @@ -3,8 +3,9 @@ // --------------------------------------------------------------------------- // // Kept in its own module so `connection.ts` never imports it eagerly at -// module load. `@modelcontextprotocol/sdk/client/stdio.js` pulls in -// `node:child_process` at evaluation time; under `@cloudflare/vitest-pool-workers` +// module load. The v2 `@modelcontextprotocol/client/stdio` entry still eagerly +// evaluates Node-only process/stream imports and `cross-spawn` (which loads +// `node:child_process`); under `@cloudflare/vitest-pool-workers` // that crashes workerd at module instantiation with SIGSEGV (prod bundles // tree-shake it away when `dangerouslyAllowStdioMCP: false`, tests do not). // @@ -13,7 +14,7 @@ // the import and therefore never touch `node:child_process`. // --------------------------------------------------------------------------- -import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; export type StdioTransportConfig = { readonly command: string; diff --git a/packages/plugins/mcp/src/sdk/testing-fixtures.test.ts b/packages/plugins/mcp/src/sdk/testing-fixtures.test.ts index b804335399..02b911abc4 100644 --- a/packages/plugins/mcp/src/sdk/testing-fixtures.test.ts +++ b/packages/plugins/mcp/src/sdk/testing-fixtures.test.ts @@ -1,7 +1,6 @@ import { expect, layer } from "@effect/vitest"; import { Effect } from "effect"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; import { OAuthTestServer } from "@executor-js/sdk/testing"; import { makeEchoMcpServer, serveMcpServerWithOAuth } from "../testing"; @@ -16,7 +15,10 @@ const createGreetingMcpServer = () => }); const makeClient = (endpoint: string, accessToken: string) => { - const client = new Client({ name: "executor-test-client", version: "1.0.0" }); + const client = new Client( + { name: "executor-test-client", version: "1.0.0" }, + { versionNegotiation: { mode: "auto" } }, + ); const transport = new StreamableHTTPClientTransport(new URL(endpoint), { requestInit: { headers: { authorization: `Bearer ${accessToken}` }, diff --git a/packages/plugins/mcp/src/testing/server.ts b/packages/plugins/mcp/src/testing/server.ts index 0b6edf0f04..dceac0db2a 100644 --- a/packages/plugins/mcp/src/testing/server.ts +++ b/packages/plugins/mcp/src/testing/server.ts @@ -1,7 +1,8 @@ -import { Context, Data, Effect, Layer, Ref, Scope } from "effect"; +import { Context, Data, Effect, Layer, Option, Ref, Schema, Scope } from "effect"; import * as http from "node:http"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; import { OAuthTestServer } from "@executor-js/sdk/testing"; import z from "zod"; @@ -57,6 +58,22 @@ const writeText = (response: http.ServerResponse, status: number, body: string) response.end(body); }; +const readRequestBody = ( + request: http.IncomingMessage, +): Effect.Effect => + Effect.tryPromise({ + try: () => + new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + request.on("data", (chunk: Buffer) => chunks.push(chunk)); + request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + request.on("error", reject); + }), + catch: (cause) => new McpTestServerError({ cause }), + }); + +const decodeJsonBody = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); + const isMcpPath = (url: string, path: string): boolean => { const parsed = new URL(url, "http://executor.test"); return parsed.pathname === path; @@ -157,6 +174,24 @@ export const serveMcpServer = (factory: () => McpServer, options: McpTestServerO return; } + // Mirror the real v1 transport's stateful contract: only an + // `initialize` POST opens a session; any other sessionless POST + // (e.g. a v2 client's `server/discover` era probe) is rejected + // with 400 + a JSON-RPC error and no session is minted. + let parsedBody: unknown; + if (request.method === "POST") { + const body = yield* readRequestBody(request); + parsedBody = Option.getOrUndefined(decodeJsonBody(body)); + if (!isInitializeRequest(parsedBody)) { + writeJson(response, 400, { + jsonrpc: "2.0", + error: { code: -32000, message: "Bad Request: Server not initialized" }, + id: null, + }); + return; + } + } + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID(), onsessioninitialized: (sid) => { @@ -172,7 +207,7 @@ export const serveMcpServer = (factory: () => McpServer, options: McpTestServerO catch: (cause) => new McpTestServerError({ cause }), }); yield* Effect.tryPromise({ - try: () => transport.handleRequest(request, response), + try: () => transport.handleRequest(request, response, parsedBody), catch: (cause) => new McpTestServerError({ cause }), }); }).pipe( From 0b1739be62c0079e85d0fa9c940d11fa9593d441 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:18:55 -0600 Subject: [PATCH 007/133] Serve MCP spec 2026-07-28 and consolidate onto SDK v2 * Add SDK v2 tool-server assembly with shared registration core * Serve MCP 2026-07-28 clients on selfhost and local HTTP * Tamper an interior requestState character in the rejection test * Serve MCP 2026-07-28 clients on the Cloudflare hosts * Serve both MCP eras from the v2 stack on neutral hosts * Host cloud MCP sessions on our own DO with the v2 stack * Delete the v1 MCP assembly and retire the transitional naming * Fix session-id parsing, stranded-stream replay, priming, and restore races * Add modern-spec e2e coverage in both protocol directions * Harden requestState binding, add inbound rollback switch, fix CLI bridge era handling --- apps/cli/package.json | 1 + apps/cli/src/main.ts | 2 +- apps/cloud/src/auth/handlers.ts | 6 + apps/cloud/src/env-augment.d.ts | 4 + apps/cloud/src/mcp-session.e2e.node.test.ts | 10 +- apps/cloud/src/mcp/agent-handler.ts | 138 +- apps/cloud/src/mcp/index.ts | 13 +- apps/cloud/src/mcp/mount.ts | 12 +- apps/cloud/src/mcp/session-durable-object.ts | 238 +- apps/cloud/src/mcp/telemetry-modern.test.ts | 77 + apps/cloud/src/mcp/telemetry.ts | 19 +- apps/cloud/wrangler.jsonc | 13 +- apps/host-cloudflare/src/config.ts | 4 + apps/host-cloudflare/src/mcp/agent-handler.ts | 106 +- apps/host-cloudflare/src/mcp/index.ts | 8 +- .../src/mcp/session-durable-object.ts | 197 +- .../src/worker.e2e.node.test.ts | 1 + apps/host-cloudflare/src/worker.ts | 5 +- apps/host-cloudflare/wrangler.jsonc | 11 +- apps/host-selfhost/package.json | 2 +- apps/host-selfhost/src/app.ts | 14 +- apps/host-selfhost/src/config.ts | 3 + apps/host-selfhost/src/mcp/index.ts | 13 +- apps/host-selfhost/src/mcp/mcp.test.ts | 35 + apps/host-selfhost/src/mcp/session-store.ts | 24 +- apps/host-selfhost/src/testing/test-app.ts | 2 + apps/local/package.json | 4 +- apps/local/src/main.ts | 1 + apps/local/src/mcp-modern.test.ts | 106 + apps/local/src/mcp-stdio-test-server.ts | 44 + apps/local/src/mcp.ts | 130 +- bun.lock | 262 +- e2e/cloud/mcp-modern-protocol.test.ts | 235 ++ e2e/local/cli-mcp-protocol.test.ts | 117 + e2e/package.json | 3 + e2e/scenarios/mcp-modern-only-server.test.ts | 131 + e2e/selfhost/mcp-modern-protocol.test.ts | 52 + e2e/setup/cloud.boot.ts | 1 + e2e/setup/cloudflare.boot.ts | 2 + e2e/src/fixtures/modern-only-mcp.ts | 83 + e2e/src/surfaces/modern-mcp.ts | 112 + package.json | 1 - packages/core/api/src/server.ts | 1 + packages/core/api/src/server/executor-app.ts | 47 +- packages/core/api/src/server/mcp-build.ts | 80 +- packages/hosts/cloudflare/package.json | 6 +- .../mcp/agent-session-durable-object.test.ts | 914 +++--- .../src/mcp/agent-session-durable-object.ts | 1043 +++++-- .../mcp/agent-session-model-resume.test.ts | 61 +- .../src/mcp/agent-session-modern.test.ts | 331 ++ .../src/mcp/agents-event-store.test.ts | 176 -- .../src/mcp/agents-priming-event.test.ts | 219 -- .../src/mcp/agents-sse-max-age.test.ts | 570 ---- .../cloudflare/src/mcp/do-event-store.test.ts | 189 ++ .../cloudflare/src/mcp/do-event-store.ts | 275 ++ .../hosts/cloudflare/src/mcp/do-headers.ts | 17 +- .../src/mcp/execution-owner-directory.ts | 18 +- .../src/mcp/modern-request-router.test.ts | 479 +++ .../src/mcp/modern-request-router.ts | 269 ++ .../src/mcp/session-alarm-policy.ts | 4 +- .../cloudflare/src/mcp/session-stub.test.ts | 30 + .../hosts/cloudflare/src/mcp/session-stub.ts | 51 +- .../src/mcp/sse-response-rotation.test.ts | 91 + .../src/mcp/sse-response-rotation.ts | 97 + .../src/test-stubs/cloudflare-workers.ts | 10 +- packages/hosts/cloudflare/vitest.config.ts | 5 - .../src/shell-resource.smoke.test.ts | 8 +- .../src/shell/mcp-app.browser.test.ts | 8 +- packages/hosts/mcp/package.json | 11 +- .../hosts/mcp/src/artifacts-tools.test.ts | 38 +- packages/hosts/mcp/src/envelope.test.ts | 166 +- packages/hosts/mcp/src/envelope.ts | 177 +- .../mcp/src/in-memory-session-store.test.ts | 189 +- .../hosts/mcp/src/in-memory-session-store.ts | 38 +- packages/hosts/mcp/src/index.ts | 12 +- packages/hosts/mcp/src/mcp-apps.test.ts | 109 + packages/hosts/mcp/src/mcp-apps.ts | 117 + packages/hosts/mcp/src/seams.ts | 52 +- .../hosts/mcp/src/stdio-integration.test.ts | 87 +- packages/hosts/mcp/src/tool-server-core.ts | 2178 ++++++++++++++ .../mcp/src/tool-server-protocol.test.ts | 388 +++ packages/hosts/mcp/src/tool-server.test.ts | 328 +- packages/hosts/mcp/src/tool-server.ts | 2650 +++-------------- packages/plugins/mcp/src/testing/server.ts | 1 + patches/agents@0.17.3.patch | 911 ------ scripts/bootstrap.ts | 6 +- scripts/check-patched-deps.ts | 145 +- 87 files changed, 9017 insertions(+), 5827 deletions(-) create mode 100644 apps/cloud/src/mcp/telemetry-modern.test.ts create mode 100644 apps/local/src/mcp-modern.test.ts create mode 100644 apps/local/src/mcp-stdio-test-server.ts create mode 100644 e2e/cloud/mcp-modern-protocol.test.ts create mode 100644 e2e/local/cli-mcp-protocol.test.ts create mode 100644 e2e/scenarios/mcp-modern-only-server.test.ts create mode 100644 e2e/selfhost/mcp-modern-protocol.test.ts create mode 100644 e2e/src/fixtures/modern-only-mcp.ts create mode 100644 e2e/src/surfaces/modern-mcp.ts create mode 100644 packages/hosts/cloudflare/src/mcp/agent-session-modern.test.ts delete mode 100644 packages/hosts/cloudflare/src/mcp/agents-event-store.test.ts delete mode 100644 packages/hosts/cloudflare/src/mcp/agents-priming-event.test.ts delete mode 100644 packages/hosts/cloudflare/src/mcp/agents-sse-max-age.test.ts create mode 100644 packages/hosts/cloudflare/src/mcp/do-event-store.test.ts create mode 100644 packages/hosts/cloudflare/src/mcp/do-event-store.ts create mode 100644 packages/hosts/cloudflare/src/mcp/modern-request-router.test.ts create mode 100644 packages/hosts/cloudflare/src/mcp/modern-request-router.ts create mode 100644 packages/hosts/cloudflare/src/mcp/session-stub.test.ts create mode 100644 packages/hosts/cloudflare/src/mcp/sse-response-rotation.test.ts create mode 100644 packages/hosts/cloudflare/src/mcp/sse-response-rotation.ts create mode 100644 packages/hosts/mcp/src/mcp-apps.test.ts create mode 100644 packages/hosts/mcp/src/mcp-apps.ts create mode 100644 packages/hosts/mcp/src/tool-server-core.ts create mode 100644 packages/hosts/mcp/src/tool-server-protocol.test.ts delete mode 100644 patches/agents@0.17.3.patch diff --git a/apps/cli/package.json b/apps/cli/package.json index 572da191fc..26c257c69a 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -30,6 +30,7 @@ "@executor-js/runtime-quickjs": "workspace:*", "@executor-js/sdk": "workspace:*", "@jitl/quickjs-wasmfile-release-sync": "catalog:", + "@modelcontextprotocol/client": "2.0.0", "@modelcontextprotocol/sdk": "^1.29.0", "@sentry/bun": "^10.57.0", "effect": "catalog:", diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index 47d3227ca6..2b59190897 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -75,7 +75,7 @@ import type { PlatformError } from "effect/PlatformError"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Cause from "effect/Cause"; -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; diff --git a/apps/cloud/src/auth/handlers.ts b/apps/cloud/src/auth/handlers.ts index fca2cf6a5e..35d18cfa66 100644 --- a/apps/cloud/src/auth/handlers.ts +++ b/apps/cloud/src/auth/handlers.ts @@ -624,6 +624,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( Effect.gen(function* () { const owner = yield* requireSelectedOrganization; const stub = getMcpSessionStub(params.mcpSessionId); + if (!stub) { + return yield* new McpExecutionNotFoundError({ executionId: params.executionId }); + } const result = yield* Effect.promise(() => stub.getPausedExecutionForApproval(params.executionId, { accountId: owner.accountId, @@ -645,6 +648,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( Effect.gen(function* () { const owner = yield* requireSelectedOrganization; const stub = getMcpSessionStub(params.mcpSessionId); + if (!stub) { + return yield* new McpExecutionNotFoundError({ executionId: params.executionId }); + } const result = yield* Effect.promise(() => stub.resumeExecutionForApproval( params.executionId, diff --git a/apps/cloud/src/env-augment.d.ts b/apps/cloud/src/env-augment.d.ts index 4ef130bfa6..5b27d19de0 100644 --- a/apps/cloud/src/env-augment.d.ts +++ b/apps/cloud/src/env-augment.d.ts @@ -67,6 +67,10 @@ declare global { MCP_RESOURCE_ORIGIN?: string; MCP_SESSION_TIMEOUT_MS?: string; MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS?: string; + /** HMAC key for MCP 2026-07-28 continuation state (32+ byte secret). */ + MCP_REQUEST_STATE_KEY?: string; + /** Emergency rollback for inbound MCP 2026-07-28 traffic only. */ + MCP_2026_07_28_ENABLED?: string; NODE_ENV?: string; // Shared with frontend diff --git a/apps/cloud/src/mcp-session.e2e.node.test.ts b/apps/cloud/src/mcp-session.e2e.node.test.ts index 37d140ab01..078200c237 100644 --- a/apps/cloud/src/mcp-session.e2e.node.test.ts +++ b/apps/cloud/src/mcp-session.e2e.node.test.ts @@ -6,7 +6,7 @@ // FumaDB/Drizzle handle (the 2026-04-16 prod outage was a schema spread bug // here; see db/db.schema.test.ts) // - `createExecutionEngine` with an in-process code executor -// - `createExecutorMcpServer` for the MCP request surface +// - `buildMcpServer` for the MCP request surface // - Real `@modelcontextprotocol/sdk` Client → server round-trips // // This test replicates the DO's init path (minus the WorkerTransport and @@ -22,7 +22,7 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js"; -import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; +import { buildMcpServer } from "@executor-js/host-mcp/tool-server"; import { createExecutionEngine } from "@executor-js/execution"; import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; import { collectTables } from "@executor-js/api/server"; @@ -138,8 +138,12 @@ const openSession = ( Effect.gen(function* () { const executor = yield* buildScopedExecutor(organizationId, `Org ${organizationId}`, options); const engine = createExecutionEngine({ executor, codeExecutor: makeQuickJsExecutor() }); - const mcpServer = yield* createExecutorMcpServer({ + const mcpServer = yield* buildMcpServer({ engine, + appsEnabled: false, + requestStateSigningKey: new Uint8Array(32).fill(23), + requestStatePrincipal: `cloud-mcp-test:${organizationId}`, + sessionful: true, elicitationMode: options.elicitationMode ? { mode: options.elicitationMode } : undefined, }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); diff --git a/apps/cloud/src/mcp/agent-handler.ts b/apps/cloud/src/mcp/agent-handler.ts index 7738e79c6a..c4abd5afa2 100644 --- a/apps/cloud/src/mcp/agent-handler.ts +++ b/apps/cloud/src/mcp/agent-handler.ts @@ -4,38 +4,37 @@ import { Effect, Predicate } from "effect"; import { McpAuthProvider, jsonRpcErrorBody, + mcpModernDisabledResponse, defaultMcpResource, UNAVAILABLE_RETRY_AFTER_SECONDS, type AuthOutcome, type McpResource, } from "@executor-js/host-mcp"; +import { requestBodyFromRequest } from "@executor-js/host-mcp/tool-server"; import { currentPropagationHeaders, readArtifactsEnabled, readElicitationMode, + withMcpResponseHeaders, + withPropagationHeaders, withVerifiedIdentityHeaders, } from "@executor-js/cloudflare/mcp/do-headers"; import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object"; -import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; +import { + classifyMcpProtocolEra, + makeMcpModernRequestRouter, + mcpCorsPreflightResponse, + requireMcpRequestStateKey, +} from "@executor-js/cloudflare/mcp/modern-request-router"; +import { mcpExecutionOwnerDirectoryFromNamespace } from "@executor-js/cloudflare/mcp/execution-owner-directory"; +import { createMcpSessionStub, mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; import { wrapMcpSseResponse } from "../observability/memory-metrics"; import { WorkerTelemetryLive } from "../observability/telemetry"; import { cloudMcpAuth } from "./auth-provider"; -import { McpSessionDOSqlite } from "./session-durable-object"; +import { makeCloudModernMcpServerBuilder } from "./session-durable-object"; import { parseTraceparent } from "./traceparent"; -const corsPreflightResponse = (): Response => - new Response(null, { - status: 204, - headers: { - "access-control-allow-origin": "*", - "access-control-allow-methods": "GET, POST, DELETE, OPTIONS", - "access-control-allow-headers": - "content-type, authorization, mcp-session-id, accept, mcp-protocol-version", - "access-control-expose-headers": "mcp-session-id, WWW-Authenticate", - }, - }); - const jsonRpcResponse = ( status: number, code: number, @@ -86,7 +85,7 @@ const authenticate = (request: Request) => return { auth, outcome }; }).pipe(Effect.provide(cloudMcpAuth)); -// The pre-Agents envelope ran the MCP auth path inside the Effect app, whose +// The earlier shared envelope ran the MCP auth path inside the Effect app, whose // HttpMiddleware provided the OTEL tracer — that is where the `mcp.request` // span (client fingerprint, rpc method, auth outcome) exported from. This // handler dispatches from the raw worker entry instead, so a bare @@ -141,29 +140,15 @@ const propsForPrincipal = ( }); export const makeCloudMcpAgentHandler = () => { - const serveOptions = { - binding: "MCP_SESSION", - transport: "streamable-http", - } as const; - // The agents SDK builds an exact-match `URLPattern` from the path handed to - // `serve` (see `createStreamingHttpHandler` in `agents/dist/mcp/index.js`) — - // a single `/mcp` handler never matches `/mcp/toolkits/` and falls - // through to its own internal 404. A second `serve` mounted on the - // parameterized path picks it up (`URLPattern` supports `:slug` segments); - // the auth/ownership/props logic above is unchanged and shared, only the - // final dispatch target differs. - const serve = McpSessionDOSqlite.serve("/mcp", serveOptions); - const serveToolkit = McpSessionDOSqlite.serve("/mcp/toolkits/:slug", serveOptions); - + const modern = makeMcpModernRequestRouter(); const ALLOWED_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]); return async (request: Request, env: Env, ctx: ExecutionContext): Promise => { - if (request.method === "OPTIONS") return corsPreflightResponse(); - // The old envelope (packages/hosts/mcp/src/envelope.ts) answered anything - // outside GET/POST/DELETE/OPTIONS with a JSON-RPC 405; the agents SDK - // handler only understands its own transport verbs and falls through to - // a bare 404. Reject before authenticating so PUT/PATCH/etc never reach - // the session engine. + if (request.method === "OPTIONS") { + return mcpCorsPreflightResponse(request.headers.get("access-control-request-headers")); + } + // Preserve the old envelope's JSON-RPC 405 before authenticating, so + // unsupported methods never reach the session engine. if (!ALLOWED_METHODS.has(request.method)) { return jsonRpcResponse(405, -32001, "Method not allowed"); } @@ -177,17 +162,49 @@ export const makeCloudMcpAgentHandler = () => { // / JWKS failure) and `Unauthorized` (retry with a fresh token) must leave // the session intact, so the condemn path is gated on `Forbidden` alone. if (Predicate.isTagged(outcome, "Forbidden") && sessionId) { + const session = mcpSessionStub(env.MCP_SESSION, sessionId); await Effect.runPromise( Effect.ignore( - Effect.tryPromise(() => - mcpSessionStub(env.MCP_SESSION, sessionId)._cf_scheduleDestroy(), - ), + session ? Effect.tryPromise(() => session._cf_scheduleDestroy()) : Effect.void, ), ); } return renderAuthError(auth, request, outcome); } + const parsedBody = await Effect.runPromise(requestBodyFromRequest(request)); + const era = await classifyMcpProtocolEra(request, parsedBody); + if (era === "modern") { + if (env.MCP_2026_07_28_ENABLED === "false") { + return mcpModernDisabledResponse(); + } + const resource = resourceFromPath(request); + const props = await runTraced( + request, + propsForPrincipal(request, outcome.principal, resource), + ); + (ctx as ExecutionContext & { props?: McpSessionProps }).props = props; + const forwarded = withVerifiedIdentityHeaders( + request, + { + accountId: outcome.principal.accountId, + organizationId: outcome.principal.organizationId, + }, + resource, + ); + return modern.fetch({ + request: forwarded, + parsedBody, + principal: outcome.principal, + resource, + props, + requestStateSigningKey: requireMcpRequestStateKey(env.MCP_REQUEST_STATE_KEY), + builder: makeCloudModernMcpServerBuilder(props.session), + sessions: env.MCP_SESSION, + executionOwners: mcpExecutionOwnerDirectoryFromNamespace(env.MCP_EXECUTION_OWNER), + }); + } + if (!sessionId && request.method === "DELETE") { // Matches the old envelope's contract (@modelcontextprotocol/sdk's // `WebStandardStreamableHTTPServerTransport.handleDeleteRequest`): 200, @@ -198,8 +215,12 @@ export const makeCloudMcpAgentHandler = () => { }); } - if (sessionId) { - const owner = await mcpSessionStub(env.MCP_SESSION, sessionId).validateMcpSessionOwner({ + const existingSession = sessionId ? mcpSessionStub(env.MCP_SESSION, sessionId) : null; + if (sessionId && !existingSession) { + return jsonRpcResponse(404, -32001, "Session not found"); + } + if (existingSession) { + const owner = await existingSession.validateMcpSessionOwner({ accountId: outcome.principal.accountId, organizationId: outcome.principal.organizationId, }); @@ -218,27 +239,29 @@ export const makeCloudMcpAgentHandler = () => { } const resource = resourceFromPath(request); - const props = await runTraced(request, propsForPrincipal(request, outcome.principal, resource)); - (ctx as ExecutionContext & { props?: McpSessionProps }).props = props; - const forwarded = withVerifiedIdentityHeaders( - request, - { - accountId: outcome.principal.accountId, - organizationId: outcome.principal.organizationId, - }, - resource, + const propagation = await runTraced(request, currentPropagationHeaders(request)); + const forwarded = withPropagationHeaders( + withVerifiedIdentityHeaders( + request, + { + accountId: outcome.principal.accountId, + organizationId: outcome.principal.organizationId, + }, + resource, + ), + propagation, ); - const target = resource.kind === "toolkit" ? serveToolkit : serve; + const target = existingSession ?? createMcpSessionStub(env.MCP_SESSION).stub; let response: Response; - // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: the agents SDK aborts the isolate (throws) instead of returning a response for a condemned session + // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: a condemned DO abort can reject its direct fetch try { - response = await target.fetch(forwarded, env, ctx); + response = await target.fetch(forwarded); } catch (error) { // `_cf_scheduleDestroy` (called above via DELETE) marks the DO - // condemned and schedules its alarm; the alarm's `destroy()` then + // condemned and schedules its alarm; the alarm's storage wipe then // `ctx.abort("destroyed")`s the isolate. A request that lands after the // alarm has already fired — same DO, same tick budget as the DELETE in - // tests — throws that abort reason out of `serve.fetch` instead of the + // tests — throws that abort reason out of `stub.fetch` instead of the // DO ever getting to answer. Map it to the old envelope's reconnect // error for a dead session (e2e/cloud/mcp-protocol.test.ts expects the // client to be told to reconnect, matching a timed-out session). @@ -249,11 +272,6 @@ export const makeCloudMcpAgentHandler = () => { // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: rethrow anything that isn't the condemned-DO abort to the Workers runtime unchanged throw error; } - // The agents SDK answers a bare DELETE with 204; the old envelope's - // contract (see above) was 200 — rewrite for consistency. - if (request.method === "DELETE" && response.status === 204) { - return new Response(null, { status: 200, headers: response.headers }); - } - return wrapMcpSseResponse(request, env, response); + return withMcpResponseHeaders(wrapMcpSseResponse(request, env, response)); }; }; diff --git a/apps/cloud/src/mcp/index.ts b/apps/cloud/src/mcp/index.ts index a7ec769364..fdc69035e1 100644 --- a/apps/cloud/src/mcp/index.ts +++ b/apps/cloud/src/mcp/index.ts @@ -5,12 +5,13 @@ // - auth -> cloudMcpAuth (WorkOS JWT + API-key + org-liveness + the two OAuth // discovery docs) // -// `server.ts` intercepts `/mcp` transport for the hibernatable Agent bridge, so -// the app envelope mounts only cloud's OAuth discovery docs (no `sessions` or -// `reporter` seam). The MCP-path predicate lives in `./mount` (`classifyMcpPath` -// / `prepareMcpOrgScope`), imported directly there. The MCP session Durable -// Object class itself stays a platform-side export (server.ts) and imports its -// siblings directly, NOT this barrel, to keep the DO bundle react-start-free. +// `server.ts` intercepts `/mcp` transport for direct session Durable Object +// dispatch, so the app envelope mounts only cloud's OAuth discovery docs (no +// `sessions` or `reporter` seam). The MCP-path predicate lives in `./mount` +// (`classifyMcpPath` / `prepareMcpOrgScope`), imported directly there. The MCP +// session Durable Object class itself stays a platform-side export (server.ts) +// and imports its siblings directly, NOT this barrel, to keep the DO bundle +// react-start-free. // --------------------------------------------------------------------------- // `cloudMcpAuth` is the packaged seam (the WorkOS JWT/api-key auth provider with diff --git a/apps/cloud/src/mcp/mount.ts b/apps/cloud/src/mcp/mount.ts index 96bb435305..9e72d7fa14 100644 --- a/apps/cloud/src/mcp/mount.ts +++ b/apps/cloud/src/mcp/mount.ts @@ -3,7 +3,7 @@ // `server.ts`'s request dispatch. // --------------------------------------------------------------------------- // -// PRODUCTION serves /mcp through `server.ts`'s hibernatable Agent bridge. +// PRODUCTION serves /mcp through `server.ts`'s direct session DO dispatch. // Discovery docs flow through `app.ts`'s unified `ExecutorApp.make` handler // (the `auth` seam's discovery routes). This module exposes: // - `classifyMcpPath` — the "is this an MCP path?" predicate (`/mcp` + the @@ -129,8 +129,8 @@ export const prepareMcpOrgScope = (request: Request): Request => { return rewritten; }; -// Production no longer mounts the /mcp transport here. `server.ts` intercepts MCP -// transport requests for the hibernatable Agent bridge, while `ExecutorApp.make` -// serves the OAuth discovery docs through the `auth` seam's discovery routes. -// `classifyMcpPath` + `prepareMcpOrgScope` remain because `server.ts`'s request -// dispatch uses them to recognize and normalize MCP paths. +// Production no longer mounts the /mcp transport here. `server.ts` authenticates +// and forwards transport requests directly to their session Durable Objects, +// while `ExecutorApp.make` serves the OAuth discovery docs through the `auth` +// seam's discovery routes. `classifyMcpPath` + `prepareMcpOrgScope` remain because +// `server.ts`'s request dispatch uses them to recognize and normalize MCP paths. diff --git a/apps/cloud/src/mcp/session-durable-object.ts b/apps/cloud/src/mcp/session-durable-object.ts index 31d6d4bf6b..c83a5eddb5 100644 --- a/apps/cloud/src/mcp/session-durable-object.ts +++ b/apps/cloud/src/mcp/session-durable-object.ts @@ -1,6 +1,6 @@ // --------------------------------------------------------------------------- // Cloud MCP Session Durable Object — the cloud binding of the shared -// `McpAgentSessionDOBase` (@executor-js/cloudflare). Hibernatable transport +// `McpAgentSessionDOBase` (@executor-js/cloudflare). Direct HTTP transport // serving, cold restore, the inactivity alarm, owner validation, browser // approval storage, and the per-request span bridge live in the base. Cloud // supplies ONLY its injected dependencies: @@ -16,14 +16,19 @@ import { env } from "cloudflare:workers"; import { Data, Effect, Layer } from "effect"; import type { Cause } from "effect"; +import type * as Tracer from "effect/Tracer"; import * as OtelTracer from "@effect/opentelemetry/Tracer"; import { drizzle } from "drizzle-orm/postgres-js"; import postgres, { type Sql } from "postgres"; import { PAUSED_APPROVAL_TIMEOUT_MS, - createExecutorMcpServer, + buildMcpServer, + mcpRequestStatePrincipal, + type PausedExecutionHooks, + type ResumeFallbackOutcome, } from "@executor-js/host-mcp/tool-server"; +import type { McpModernServerBuilder, Principal } from "@executor-js/host-mcp"; import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker"; @@ -31,18 +36,20 @@ import { smokeRenderArtifact } from "@executor-js/mcp-apps-shell/smoke-render"; import { McpAgentSessionDOBase, type BuiltMcpServer, + type BuiltModernMcpRuntime, type IncomingTraceHeaders, type McpApprovalOwner, type McpSessionModelResumeResult, type McpSessionInit, type SessionMeta, } from "@executor-js/cloudflare/mcp/agent-durable-object"; +import { requireMcpRequestStateKey } from "@executor-js/cloudflare/mcp/modern-request-router"; import { mcpExecutionOwnerDirectoryFromNamespace, type McpExecutionOwnerDirectory, type McpExecutionOwnerRoute, } from "@executor-js/cloudflare/mcp/execution-owner-directory"; -import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; +import { mcpSessionStubForOwner } from "@executor-js/cloudflare/mcp/session-stub"; import { buildExecuteDescription, type ResumeResponse } from "@executor-js/execution"; // The DO meters executions just like the HTTP `/api/*` plane: it builds its @@ -117,6 +124,10 @@ class McpModelResumeForwardError extends Data.TaggedError("McpModelResumeForward readonly cause: unknown; }> {} +class CloudModernMcpBuildError extends Data.TaggedError("CloudModernMcpBuildError")<{ + readonly cause: unknown; +}> {} + /** * The DO keeps one postgres.js client for the MCP session runtime. postgres.js * closes idle sockets quickly, while the runtime object stays alive so the MCP @@ -168,6 +179,127 @@ const loadAppShellHtml = makeAssetsShellHtmlLoader({ import("virtual:executor-mcp-apps-shell-dev-html").then((mod) => mod.devShellHtml), }); +const resolveCloudSessionMeta = (token: McpSessionInit, dbHandle: CloudSessionDbHandle) => + Effect.gen(function* () { + const org = yield* resolveOrganization(token.organizationId); + if (!org) { + return yield* new OrganizationNotFoundError({ organizationId: token.organizationId }); + } + return { + organizationId: org.id, + organizationName: org.name, + organizationSlug: org.slug, + userId: token.userId, + resource: token.resource, + elicitationMode: token.elicitationMode, + artifactsEnabled: token.artifactsEnabled, + webOrigin: token.webOrigin, + } satisfies SessionMeta; + }).pipe(Effect.provide(makeSessionServices(dbHandle))); + +const makeCloudExecutionRuntime = (sessionMeta: SessionMeta, dbHandle: CloudSessionDbHandle) => + Effect.gen(function* () { + yield* Effect.promise(() => preloadQuickJs()); + const { executor, engine } = yield* makeExecutionStack( + sessionMeta.userId, + sessionMeta.organizationId, + sessionMeta.organizationName, + { mcpResource: sessionMeta.resource }, + ).pipe( + Effect.provide(CloudMeteredExecutionStackLayer.pipe(Layer.provide(AutumnService.Default))), + Effect.withSpan("McpSessionDOSqlite.makeExecutionStack"), + ); + const description = yield* buildExecuteDescription(executor).pipe( + Effect.withSpan("mcp.execute.description.build"), + ); + return { executor, engine, description }; + }).pipe(Effect.provide(makeSessionServices(dbHandle))); + +type CloudExecutionRuntime = Effect.Success>; + +type CloudModernLifecycle = { + readonly pausedExecutionHooks?: PausedExecutionHooks; + readonly resumeFallback?: ( + executionId: string, + response: ResumeResponse, + ) => Effect.Effect; + readonly parentSpan?: () => Tracer.AnySpan | undefined; +}; + +const makeCloudModernRuntime = ( + sessionMeta: SessionMeta, + runtime: CloudExecutionRuntime, + lifecycle: CloudModernLifecycle = {}, +): BuiltModernMcpRuntime => ({ + engine: runtime.engine, + buildServer: (options) => + buildMcpServer({ + engine: runtime.engine, + description: runtime.description, + artifacts: runtime.executor.artifacts, + connections: runtime.executor.connections, + artifactsEnabled: sessionMeta.artifactsEnabled ?? true, + loadAppShellHtml, + smokeRenderArtifact, + artifactUrl: artifactUrlFor( + env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh", + sessionMeta.organizationSlug, + ), + debug: env.EXECUTOR_MCP_DEBUG === "true", + elicitationMode: { mode: "native" }, + ...(lifecycle.parentSpan ? { parentSpan: lifecycle.parentSpan } : {}), + ...(lifecycle.pausedExecutionHooks + ? { + pausedExecutionHooks: lifecycle.pausedExecutionHooks, + pausedExecutionLeaseMs: PAUSED_APPROVAL_TIMEOUT_MS, + } + : {}), + ...(lifecycle.resumeFallback ? { resumeFallback: lifecycle.resumeFallback } : {}), + ...options, + }), +}); + +const closeModernServerWithDb = Promise }>( + server: Server, + dbHandle: CloudSessionDbHandle, +): Server => { + const closeServer = server.close.bind(server); + server.close = () => + Effect.runPromise( + Effect.promise(closeServer).pipe(Effect.ensuring(Effect.promise(() => dbHandle.end()))), + ); + return server; +}; + +/** Build one worker-side stateless MCP server over a fresh cloud runtime. */ +export const makeCloudModernMcpServerBuilder = ( + session: McpSessionInit, +): McpModernServerBuilder["Service"] => ({ + build: (principal: Principal, options) => { + const dbHandle = makeEphemeralDb(); + const { resource, ...requestOptions } = options; + const token: McpSessionInit = { + ...session, + userId: principal.accountId, + organizationId: principal.organizationId, + resource, + }; + return resolveCloudSessionMeta(token, dbHandle).pipe( + Effect.flatMap((sessionMeta) => + makeCloudExecutionRuntime(sessionMeta, dbHandle).pipe( + Effect.map((runtime) => ({ runtime, sessionMeta })), + ), + ), + Effect.flatMap(({ runtime, sessionMeta }) => + makeCloudModernRuntime(sessionMeta, runtime).buildServer(requestOptions), + ), + Effect.map((server) => closeModernServerWithDb(server, dbHandle)), + Effect.tapCause(() => Effect.promise(() => dbHandle.end())), + Effect.mapError((cause) => new CloudModernMcpBuildError({ cause })), + ); + }, +}); + // --------------------------------------------------------------------------- // Durable Object // --------------------------------------------------------------------------- @@ -193,13 +325,12 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase { + const ownerSession = mcpSessionStubForOwner(env.MCP_SESSION, owner); + if (!ownerSession) { + return Effect.succeed({ status: "execution_expired", ttlMs: PAUSED_APPROVAL_TIMEOUT_MS }); + } return Effect.tryPromise({ - try: () => - mcpSessionStub(env.MCP_SESSION, owner.sessionId).resumeExecutionForModel( - executionId, - identity, - response, - ), + try: () => ownerSession.resumeExecutionForModel(executionId, identity, response), catch: (cause) => new McpModelResumeForwardError({ cause }), }); } @@ -213,23 +344,8 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase { const dbHandle = makeEphemeralDb(); - return Effect.gen(function* () { - const org = yield* resolveOrganization(token.organizationId); - if (!org) { - return yield* new OrganizationNotFoundError({ organizationId: token.organizationId }); - } - return { - organizationId: org.id, - organizationName: org.name, - organizationSlug: org.slug, - userId: token.userId, - resource: token.resource, - elicitationMode: token.elicitationMode, - artifactsEnabled: token.artifactsEnabled, - } satisfies SessionMeta; - }).pipe( + return resolveCloudSessionMeta(token, dbHandle).pipe( Effect.withSpan("McpSessionDOSqlite.resolveSessionMeta"), - Effect.provide(makeSessionServices(dbHandle)), Effect.ensuring(Effect.promise(() => dbHandle.end())), // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: a vanished org is a defect; the worker already verified the bearer Effect.orDie, @@ -242,36 +358,15 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase { const self = this; return Effect.gen(function* () { - // QuickJS-WASM must be loaded before anything asks for a sandbox: the - // default variant cannot fetch its own `.wasm` on Workers. Cloud runs - // user `execute` code on the dynamic-worker runtime, but the artifact - // smoke render is a QuickJS sandbox on every host — without this it fails - // open on each create and the check silently does nothing. - // Idempotent per isolate. - yield* Effect.promise(() => preloadQuickJs()); - const { executor, engine } = yield* makeExecutionStack( - sessionMeta.userId, - sessionMeta.organizationId, - sessionMeta.organizationName, - { mcpResource: sessionMeta.resource }, - ).pipe( - // The metered stack tracks each execution to Autumn. It requires - // `AutumnService | DbService`; `AutumnService.Default` is provided here - // (it only reads `env`, no further deps), and `DbService` flows from the - // outer `makeSessionServices`. When `AUTUMN_SECRET_KEY` is unset the - // billing service degrades to a no-op tracker, so this stays inert in - // cloud dev/preview environments that run without a billing backend. - Effect.provide(CloudMeteredExecutionStackLayer.pipe(Layer.provide(AutumnService.Default))), - Effect.withSpan("McpSessionDOSqlite.makeExecutionStack"), - ); - // Build the description here so `executor.connections.list()` stays under - // the DO startup span and the MCP SDK receives a concrete string instead - // of invoking `engine.getDescription` across its async boundary. - const description = yield* buildExecuteDescription(executor).pipe( - Effect.withSpan("mcp.execute.description.build"), - ); + const runtime = yield* makeCloudExecutionRuntime(sessionMeta, dbHandle); + const { executor, engine, description } = runtime; + const modernRuntime = makeCloudModernRuntime(sessionMeta, runtime, { + pausedExecutionHooks: self.modernPausedExecutionHooks, + resumeFallback: self.modernModelResumeFallback, + parentSpan: () => self.currentParentSpan(), + }); const sessionElicitationMode = sessionMeta.elicitationMode ?? "model"; - const mcpServer = yield* createExecutorMcpServer({ + const mcpServer = yield* buildMcpServer({ engine, description, artifacts: executor.artifacts, @@ -284,6 +379,13 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase self.persistAppsEnabled(appsEnabled), + appsEnabled: false, + sessionful: true, + requestStateSigningKey: self.modernRequestStateSigningKey(), + requestStatePrincipal: mcpRequestStatePrincipal({ + accountId: sessionMeta.userId, + organizationId: sessionMeta.organizationId, + }), loadAppShellHtml, smokeRenderArtifact, artifactUrl: artifactUrlFor( @@ -309,16 +411,38 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase { + const self = this; + return makeCloudExecutionRuntime(sessionMeta, dbHandle).pipe( + Effect.map((runtime) => + makeCloudModernRuntime(sessionMeta, runtime, { + pausedExecutionHooks: self.modernPausedExecutionHooks, + resumeFallback: self.modernModelResumeFallback, + parentSpan: () => self.currentParentSpan(), + }), + ), + Effect.withSpan("McpSessionDOSqlite.buildModernMcpRuntime"), + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: runtime-build failures surface through the base RPC cleanup path + Effect.orDie, + ); + } + + protected override modernRequestStateSigningKey(): string { + return requireMcpRequestStateKey(env.MCP_REQUEST_STATE_KEY); + } + protected override withTelemetry( effect: Effect.Effect, incoming?: IncomingTraceHeaders, diff --git a/apps/cloud/src/mcp/telemetry-modern.test.ts b/apps/cloud/src/mcp/telemetry-modern.test.ts new file mode 100644 index 0000000000..66db34036f --- /dev/null +++ b/apps/cloud/src/mcp/telemetry-modern.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import type * as Tracer from "effect/Tracer"; + +import { annotateMcpRequest } from "./telemetry"; + +const makeRecordingTracer = (): { + readonly tracer: Tracer.Tracer; + readonly requestAttributes: () => ReadonlyMap | undefined; +} => { + const recorded: Array<{ + readonly name: string; + readonly attributes: Map; + }> = []; + const tracer: Tracer.Tracer = { + span: (options) => { + const attributes = new Map(); + recorded.push({ name: options.name, attributes }); + let status: Tracer.SpanStatus = { _tag: "Started", startTime: options.startTime }; + return { + _tag: "Span", + name: options.name, + spanId: `span-${recorded.length}`, + traceId: "trace-modern", + parent: options.parent, + annotations: options.annotations, + get status() { + return status; + }, + attributes, + links: options.links, + sampled: options.sampled, + kind: options.kind, + end: (endTime, exit) => { + status = { _tag: "Ended", startTime: options.startTime, endTime, exit }; + }, + attribute: (key, value) => { + attributes.set(key, value); + }, + event: () => undefined, + addLinks: () => undefined, + }; + }, + }; + return { + tracer, + requestAttributes: () => recorded.find(({ name }) => name === "mcp.request")?.attributes, + }; +}; + +describe("annotateMcpRequest modern envelope", () => { + it.effect("records the 2026 protocol version outside initialize", () => { + const { tracer, requestAttributes } = makeRecordingTracer(); + const request = new Request("https://executor.sh/mcp", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/list", + params: { + _meta: { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + }, + }, + }), + }); + + return Effect.gen(function* () { + yield* annotateMcpRequest(request, { token: null, parseBody: true }); + const attributes = requestAttributes(); + expect(attributes?.get("mcp.rpc.method")).toBe("tools/list"); + expect(attributes?.get("mcp.client.protocol_version")).toBe("2026-07-28"); + }).pipe(Effect.withSpan("mcp.request"), Effect.withTracer(tracer)); + }); +}); diff --git a/apps/cloud/src/mcp/telemetry.ts b/apps/cloud/src/mcp/telemetry.ts index 5f4c370f72..1e2d631335 100644 --- a/apps/cloud/src/mcp/telemetry.ts +++ b/apps/cloud/src/mcp/telemetry.ts @@ -102,6 +102,14 @@ const InitializeParams = Schema.Struct({ capabilities: Schema.optional(UnknownRecord), }); +const ModernEnvelopeParams = Schema.Struct({ + _meta: Schema.optional( + Schema.Struct({ + "io.modelcontextprotocol/protocolVersion": Schema.optional(Schema.String), + }), + ), +}); + const NamedParams = Schema.Struct({ name: Schema.optional(Schema.String) }); const UriParams = Schema.Struct({ uri: Schema.optional(Schema.String) }); @@ -119,6 +127,7 @@ const decodeJsonRpcEnvelopeString = Schema.decodeUnknownOption( Schema.fromJsonString(JsonRpcEnvelope), ); const decodeInitializeParams = Schema.decodeUnknownOption(InitializeParams); +const decodeModernEnvelopeParams = Schema.decodeUnknownOption(ModernEnvelopeParams); const decodeNamedParams = Schema.decodeUnknownOption(NamedParams); const decodeUriParams = Schema.decodeUnknownOption(UriParams); const decodeCancelledParams = Schema.decodeUnknownOption(CancelledParams); @@ -136,7 +145,14 @@ const readJsonRpcEnvelope = (request: Request): Effect.Effect => { const params = envelope.params ?? {}; - return Match.value(envelope.method).pipe( + const protocolAttrs = Option.match(decodeModernEnvelopeParams(params), { + onNone: () => ({}), + onSome: (modern) => { + const protocolVersion = modern._meta?.["io.modelcontextprotocol/protocolVersion"]; + return protocolVersion ? { "mcp.client.protocol_version": protocolVersion } : {}; + }, + }); + const methodSpecific = Match.value(envelope.method).pipe( Match.when("initialize", () => Option.match(decodeInitializeParams(params), { onNone: () => ({}) as Record, @@ -181,6 +197,7 @@ const methodAttrs = (envelope: JsonRpcEnvelope): Record => { Match.option, Option.getOrElse(() => ({}) as Record), ); + return { ...protocolAttrs, ...methodSpecific }; }; const replyAttrs = (envelope: JsonRpcEnvelope): Record => { diff --git a/apps/cloud/wrangler.jsonc b/apps/cloud/wrangler.jsonc index dfd9bbd800..ac00660556 100644 --- a/apps/cloud/wrangler.jsonc +++ b/apps/cloud/wrangler.jsonc @@ -40,12 +40,12 @@ }, ], }, - // The MCP session DO moved to the Cloudflare Agents (`McpAgent`) base, which - // stores state in SQLite. The original `McpSessionDO` was created on the + // The MCP session DO previously moved to a SQLite-backed class. The original + // `McpSessionDO` was created on the // key-value backend (`new_classes`) and cannot be converted in place. Cloudflare // also refuses to delete a class in the same deploy that moves its binding (it // validates the delete against the live binding), so v2 only CREATES the new - // SQLite class `McpSessionDOSqlite` and the `MCP_SESSION` binding moves to it. + // SQLite class `McpSessionDOSqlite` and the `MCP_SESSION` binding moved to it. // The old KV `McpSessionDO` is left orphaned (unbound, kept as a stub export in // server.ts so the migration stays valid); it can be deleted in a later deploy // now that nothing binds it. Session state is ephemeral, so nothing is lost. @@ -96,7 +96,14 @@ "binding": "LOADER", }, ], + // DEPLOYMENT PREREQUISITE: MCP 2026-07-28 requestState is shared between + // stateless Worker isolates and session DOs. Configure the same 32+ byte + // secret for both with `wrangler secret put MCP_REQUEST_STATE_KEY`. + // It must never be placed in `vars` or generated independently per isolate. "vars": { + // MCP_2026_07_28_ENABLED is intentionally absent: unset enables modern + // inbound serving. Set the Worker var to "false" for emergency rollback; + // legacy serving remains available. "VITE_PUBLIC_SITE_URL": "https://executor.sh", "VITE_PUBLIC_POSTHOG_KEY": "phc_nNLrNMALpRsfrEkZovUkfMxYbcJvHnsJHeoSPavprgLL", // Browser OTLP spans → same-origin, forwarded to Axiom by the worker diff --git a/apps/host-cloudflare/src/config.ts b/apps/host-cloudflare/src/config.ts index c397c4ef87..f6a3d3c09e 100644 --- a/apps/host-cloudflare/src/config.ts +++ b/apps/host-cloudflare/src/config.ts @@ -47,6 +47,10 @@ export interface CloudflareEnv { readonly SELF_HOSTED_ORG_SLUG?: string; /** At-rest secret-encryption key (a `wrangler secret`, NOT a var). */ readonly EXECUTOR_SECRET_KEY?: string; + /** HMAC key for MCP 2026-07-28 continuation state (32+ byte secret). */ + readonly MCP_REQUEST_STATE_KEY?: string; + /** Emergency rollback for inbound MCP 2026-07-28 traffic only. */ + readonly MCP_2026_07_28_ENABLED?: string; readonly ALLOW_LOCAL_NETWORK?: string; readonly VITE_PUBLIC_SITE_URL?: string; /** diff --git a/apps/host-cloudflare/src/mcp/agent-handler.ts b/apps/host-cloudflare/src/mcp/agent-handler.ts index 5ec09fc1b3..3a729f86c9 100644 --- a/apps/host-cloudflare/src/mcp/agent-handler.ts +++ b/apps/host-cloudflare/src/mcp/agent-handler.ts @@ -3,34 +3,33 @@ import { Effect, Predicate } from "effect"; import { McpAuthProvider, jsonRpcErrorBody, + mcpModernDisabledResponse, defaultMcpResource, type AuthOutcome, type Principal, } from "@executor-js/host-mcp"; +import { requestBodyFromRequest } from "@executor-js/host-mcp/tool-server"; import { currentPropagationHeaders, readArtifactsEnabled, readElicitationMode, + withMcpResponseHeaders, + withPropagationHeaders, withVerifiedIdentityHeaders, } from "@executor-js/cloudflare/mcp/do-headers"; import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object"; -import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; +import { + classifyMcpProtocolEra, + makeMcpModernRequestRouter, + mcpCorsPreflightResponse, + requireMcpRequestStateKey, +} from "@executor-js/cloudflare/mcp/modern-request-router"; +import { mcpExecutionOwnerDirectoryFromNamespace } from "@executor-js/cloudflare/mcp/execution-owner-directory"; +import { createMcpSessionStub, mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; import type { CloudflareConfig, CloudflareEnv } from "../config"; import { cloudflareAccessMcpAuth } from "./auth"; -import { McpSessionDO } from "./session-durable-object"; - -const corsPreflightResponse = (): Response => - new Response(null, { - status: 204, - headers: { - "access-control-allow-origin": "*", - "access-control-allow-methods": "GET, POST, DELETE, OPTIONS", - "access-control-allow-headers": - "content-type, authorization, mcp-session-id, accept, mcp-protocol-version", - "access-control-expose-headers": "mcp-session-id, WWW-Authenticate", - }, - }); +import { makeCloudflareModernMcpServerBuilder } from "./session-durable-object"; const jsonRpcResponse = ( status: number, @@ -80,8 +79,8 @@ const propsForPrincipal = ( userId: principal.accountId, elicitationMode: readElicitationMode(request), artifactsEnabled: readArtifactsEnabled(request), - // host-cloudflare only routes the bare `/mcp` endpoint to the Agent - // bridge (see worker.ts), so the session always serves the default + // host-cloudflare only routes the bare `/mcp` endpoint to the session + // Durable Object (see worker.ts), so it always serves the default // resource. resource: defaultMcpResource, webOrigin: new URL(request.url).origin, @@ -91,35 +90,65 @@ const propsForPrincipal = ( }); export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { - const serve = McpSessionDO.serve("/mcp", { - binding: "MCP_SESSION", - transport: "streamable-http", - }); - + const modern = makeMcpModernRequestRouter(); return async (request: Request, env: CloudflareEnv, ctx: ExecutionContext): Promise => { - if (request.method === "OPTIONS") return corsPreflightResponse(); + if (request.method === "OPTIONS") { + return mcpCorsPreflightResponse(request.headers.get("access-control-request-headers")); + } const sessionId = request.headers.get("mcp-session-id"); const { auth, outcome } = await Effect.runPromise(authenticate(request, config)); if (!Predicate.isTagged(outcome, "Authenticated")) { if (Predicate.isTagged(outcome, "Forbidden") && sessionId) { + const session = mcpSessionStub(env.MCP_SESSION, sessionId); await Effect.runPromise( Effect.ignore( - Effect.tryPromise(() => - mcpSessionStub(env.MCP_SESSION, sessionId)._cf_scheduleDestroy(), - ), + session ? Effect.tryPromise(() => session._cf_scheduleDestroy()) : Effect.void, ), ); } return renderAuthError(auth, request, outcome); } + const parsedBody = await Effect.runPromise(requestBodyFromRequest(request)); + const era = await classifyMcpProtocolEra(request, parsedBody); + if (era === "modern") { + if (env.MCP_2026_07_28_ENABLED === "false") { + return mcpModernDisabledResponse(); + } + const props = await Effect.runPromise(propsForPrincipal(request, outcome.principal)); + (ctx as ExecutionContext & { props?: McpSessionProps }).props = props; + const forwarded = withVerifiedIdentityHeaders( + request, + { + accountId: outcome.principal.accountId, + organizationId: outcome.principal.organizationId, + }, + defaultMcpResource, + ); + return modern.fetch({ + request: forwarded, + parsedBody, + principal: outcome.principal, + resource: defaultMcpResource, + props, + requestStateSigningKey: requireMcpRequestStateKey(env.MCP_REQUEST_STATE_KEY), + builder: makeCloudflareModernMcpServerBuilder(env, config, props.session), + sessions: env.MCP_SESSION, + executionOwners: mcpExecutionOwnerDirectoryFromNamespace(env.MCP_EXECUTION_OWNER), + }); + } + if (!sessionId && request.method === "DELETE") { return new Response(null, { status: 204, headers: { "access-control-allow-origin": "*" } }); } - if (sessionId) { - const owner = await mcpSessionStub(env.MCP_SESSION, sessionId).validateMcpSessionOwner({ + const existingSession = sessionId ? mcpSessionStub(env.MCP_SESSION, sessionId) : null; + if (sessionId && !existingSession) { + return jsonRpcResponse(404, -32001, "Session not found"); + } + if (existingSession) { + const owner = await existingSession.validateMcpSessionOwner({ accountId: outcome.principal.accountId, organizationId: outcome.principal.organizationId, }); @@ -136,16 +165,19 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { } } - const props = await Effect.runPromise(propsForPrincipal(request, outcome.principal)); - (ctx as ExecutionContext & { props?: McpSessionProps }).props = props; - const forwarded = withVerifiedIdentityHeaders( - request, - { - accountId: outcome.principal.accountId, - organizationId: outcome.principal.organizationId, - }, - defaultMcpResource, + const propagation = await Effect.runPromise(currentPropagationHeaders(request)); + const forwarded = withPropagationHeaders( + withVerifiedIdentityHeaders( + request, + { + accountId: outcome.principal.accountId, + organizationId: outcome.principal.organizationId, + }, + defaultMcpResource, + ), + propagation, ); - return serve.fetch(forwarded, env, ctx); + const target = existingSession ?? createMcpSessionStub(env.MCP_SESSION).stub; + return withMcpResponseHeaders(await target.fetch(forwarded)); }; }; diff --git a/apps/host-cloudflare/src/mcp/index.ts b/apps/host-cloudflare/src/mcp/index.ts index 1de283c205..a37a5a8023 100644 --- a/apps/host-cloudflare/src/mcp/index.ts +++ b/apps/host-cloudflare/src/mcp/index.ts @@ -35,7 +35,9 @@ export const makeCloudflareApprovalHandler = ( const paused = PAUSED_PATH.exec(pathname); if (paused && request.method === "GET") { - const result = await stubFor(decodeURIComponent(paused[1]!)).getPausedExecutionForApproval( + const stub = stubFor(decodeURIComponent(paused[1]!)); + if (!stub) return jsonResponse({ error: "Paused execution not found" }, 404); + const result = await stub.getPausedExecutionForApproval( decodeURIComponent(paused[2]!), owner, ); @@ -53,7 +55,9 @@ export const makeCloudflareApprovalHandler = ( const response = raw === null ? null : decodeResumeResponse(raw); if (!response) return jsonResponse({ error: "Invalid approval response" }, 400); - const result = await stubFor(decodeURIComponent(resume[1]!)).resumeExecutionForApproval( + const stub = stubFor(decodeURIComponent(resume[1]!)); + if (!stub) return jsonResponse({ error: "Paused execution not found" }, 404); + const result = await stub.resumeExecutionForApproval( decodeURIComponent(resume[2]!), owner, response, diff --git a/apps/host-cloudflare/src/mcp/session-durable-object.ts b/apps/host-cloudflare/src/mcp/session-durable-object.ts index e75199dc07..766b1ed18c 100644 --- a/apps/host-cloudflare/src/mcp/session-durable-object.ts +++ b/apps/host-cloudflare/src/mcp/session-durable-object.ts @@ -2,8 +2,12 @@ import { Data, Effect } from "effect"; import { PAUSED_APPROVAL_TIMEOUT_MS, - createExecutorMcpServer, + buildMcpServer, + mcpRequestStatePrincipal, + type PausedExecutionHooks, + type ResumeFallbackOutcome, } from "@executor-js/host-mcp/tool-server"; +import type { McpModernServerBuilder, Principal } from "@executor-js/host-mcp"; import { buildResumeApprovalUrl } from "@executor-js/host-mcp/browser-approval"; import { artifactUrlFor } from "@executor-js/host-mcp/create-artifact"; import { makeAssetsShellHtmlLoader } from "@executor-js/mcp-apps-shell/worker"; @@ -12,18 +16,20 @@ import type { ExecutorDbHandle } from "@executor-js/api/server"; import { McpAgentSessionDOBase, type BuiltMcpServer, + type BuiltModernMcpRuntime, type McpApprovalOwner, type McpSessionModelResumeResult, type McpSessionInit, type SessionMeta, } from "@executor-js/cloudflare/mcp/agent-durable-object"; +import { requireMcpRequestStateKey } from "@executor-js/cloudflare/mcp/modern-request-router"; import { mcpExecutionOwnerDirectoryFromNamespace, type McpExecutionOwnerDirectory, type McpExecutionOwnerRoute, } from "@executor-js/cloudflare/mcp/execution-owner-directory"; -import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; -import type { ResumeResponse } from "@executor-js/execution"; +import { mcpSessionStubForOwner } from "@executor-js/cloudflare/mcp/session-stub"; +import { buildExecuteDescription, type ResumeResponse } from "@executor-js/execution"; import { loadConfig, type CloudflareConfig, type CloudflareEnv } from "../config"; import { createD1ExecutorDb } from "../db/d1"; @@ -54,6 +60,124 @@ class McpModelResumeForwardError extends Data.TaggedError("McpModelResumeForward readonly cause: unknown; }> {} +class CloudflareModernMcpBuildError extends Data.TaggedError("CloudflareModernMcpBuildError")<{ + readonly cause: unknown; +}> {} + +const makeCloudflareExecutionRuntime = ( + sessionMeta: SessionMeta, + dbHandle: CfSessionDbHandle, + config: CloudflareConfig, +) => + Effect.gen(function* () { + yield* Effect.promise(() => preloadQuickJs()); + const { engine, executor } = yield* makeExecutionStack( + sessionMeta.userId, + sessionMeta.organizationId, + sessionMeta.organizationName, + { mcpResource: sessionMeta.resource }, + ).pipe(Effect.provide(makeCloudflareExecutionStackLayer(config, dbHandle))); + const description = yield* buildExecuteDescription(executor); + return { engine, executor, description }; + }); + +type CloudflareExecutionRuntime = Effect.Success>; + +type CloudflareModernLifecycle = { + readonly pausedExecutionHooks?: PausedExecutionHooks; + readonly resumeFallback?: ( + executionId: string, + response: ResumeResponse, + ) => Effect.Effect; +}; + +const makeCloudflareModernRuntime = ( + sessionMeta: SessionMeta, + runtime: CloudflareExecutionRuntime, + loadAppShellHtml: () => Promise, + config: CloudflareConfig, + lifecycle: CloudflareModernLifecycle = {}, +): BuiltModernMcpRuntime => { + const artifactOrigin = sessionMeta.webOrigin ?? config.webBaseUrl; + return { + engine: runtime.engine, + buildServer: (options) => + buildMcpServer({ + engine: runtime.engine, + description: runtime.description, + artifacts: runtime.executor.artifacts, + connections: runtime.executor.connections, + artifactsEnabled: sessionMeta.artifactsEnabled ?? true, + loadAppShellHtml, + smokeRenderArtifact, + ...(artifactOrigin + ? { artifactUrl: artifactUrlFor(artifactOrigin, sessionMeta.organizationSlug) } + : {}), + elicitationMode: { mode: "native" }, + ...(lifecycle.pausedExecutionHooks + ? { + pausedExecutionHooks: lifecycle.pausedExecutionHooks, + pausedExecutionLeaseMs: PAUSED_APPROVAL_TIMEOUT_MS, + } + : {}), + ...(lifecycle.resumeFallback ? { resumeFallback: lifecycle.resumeFallback } : {}), + ...options, + }), + }; +}; + +const closeModernServerWithDb = Promise }>( + server: Server, + dbHandle: CfSessionDbHandle, +): Server => { + const closeServer = server.close.bind(server); + server.close = () => + Effect.runPromise( + Effect.promise(closeServer).pipe(Effect.ensuring(Effect.promise(() => dbHandle.end()))), + ); + return server; +}; + +/** Build the worker-side MCP server over a fresh D1 execution runtime. */ +export const makeCloudflareModernMcpServerBuilder = ( + env: CloudflareEnv, + config: CloudflareConfig, + session: McpSessionInit, +): McpModernServerBuilder["Service"] => ({ + build: (principal: Principal, options) => + Effect.promise(async () => { + const handle = await createD1ExecutorDb(env.DB, env.BLOBS); + return { ...handle, end: () => handle.close() } satisfies CfSessionDbHandle; + }).pipe( + Effect.flatMap((dbHandle) => { + const { resource, ...requestOptions } = options; + const sessionMeta: SessionMeta = { + organizationId: principal.organizationId, + organizationName: config.organizationName, + organizationSlug: config.organizationSlug, + userId: principal.accountId, + resource, + elicitationMode: session.elicitationMode, + artifactsEnabled: session.artifactsEnabled, + webOrigin: session.webOrigin, + }; + return makeCloudflareExecutionRuntime(sessionMeta, dbHandle, config).pipe( + Effect.flatMap((runtime) => + makeCloudflareModernRuntime( + sessionMeta, + runtime, + makeAssetsShellHtmlLoader({ assets: env.ASSETS }), + config, + ).buildServer(requestOptions), + ), + Effect.map((server) => closeModernServerWithDb(server, dbHandle)), + Effect.tapCause(() => Effect.promise(() => dbHandle.end())), + Effect.mapError((cause) => new CloudflareModernMcpBuildError({ cause })), + ); + }), + ), +}); + export class McpSessionDO extends McpAgentSessionDOBase { private readonly cfEnv: CloudflareEnv; private readonly cfConfig: CloudflareConfig; @@ -86,13 +210,12 @@ export class McpSessionDO extends McpAgentSessionDOBase { + const ownerSession = mcpSessionStubForOwner(this.cfEnv.MCP_SESSION, owner); + if (!ownerSession) { + return Effect.succeed({ status: "execution_expired", ttlMs: PAUSED_APPROVAL_TIMEOUT_MS }); + } return Effect.tryPromise({ - try: () => - mcpSessionStub(this.cfEnv.MCP_SESSION, owner.sessionId).resumeExecutionForModel( - executionId, - identity, - response, - ), + try: () => ownerSession.resumeExecutionForModel(executionId, identity, response), catch: (cause) => new McpModelResumeForwardError({ cause }), }); } @@ -113,6 +236,7 @@ export class McpSessionDO extends McpAgentSessionDOBase preloadQuickJs()); - const { engine, executor } = yield* makeExecutionStack( - sessionMeta.userId, - sessionMeta.organizationId, - sessionMeta.organizationName, - { mcpResource: sessionMeta.resource }, - ).pipe(Effect.provide(makeCloudflareExecutionStackLayer(config, dbHandle))); + const runtime = yield* makeCloudflareExecutionRuntime(sessionMeta, dbHandle, config); + const { engine, executor, description } = runtime; + const modernRuntime = makeCloudflareModernRuntime( + sessionMeta, + runtime, + self.loadAppShellHtml, + config, + { + pausedExecutionHooks: self.modernPausedExecutionHooks, + resumeFallback: self.modernModelResumeFallback, + }, + ); // Browser elicitation mode (the base owns the approval store + the HTTP // approval RPCs): a gated execution pauses and returns an approvalUrl into // the console resume page. The URL origin is the create request's origin @@ -141,8 +268,9 @@ export class McpSessionDO extends McpAgentSessionDOBase self.persistAppsEnabled(appsEnabled), + appsEnabled: false, + sessionful: true, + requestStateSigningKey: self.modernRequestStateSigningKey(), + requestStatePrincipal: mcpRequestStatePrincipal({ + accountId: sessionMeta.userId, + organizationId: sessionMeta.organizationId, + }), loadAppShellHtml: self.loadAppShellHtml, smokeRenderArtifact, ...(artifactOrigin @@ -188,11 +323,33 @@ export class McpSessionDO extends McpAgentSessionDOBase { + const self = this; + return makeCloudflareExecutionRuntime(sessionMeta, dbHandle, this.cfConfig).pipe( + Effect.map((runtime) => + makeCloudflareModernRuntime(sessionMeta, runtime, self.loadAppShellHtml, self.cfConfig, { + pausedExecutionHooks: self.modernPausedExecutionHooks, + resumeFallback: self.modernModelResumeFallback, + }), + ), + Effect.withSpan("McpSessionDO.buildModernMcpRuntime"), + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: runtime-build failures surface through the base RPC cleanup path + Effect.orDie, + ); + } + + protected override modernRequestStateSigningKey(): string { + return requireMcpRequestStateKey(this.cfEnv.MCP_REQUEST_STATE_KEY); + } } diff --git a/apps/host-cloudflare/src/worker.e2e.node.test.ts b/apps/host-cloudflare/src/worker.e2e.node.test.ts index 738a358827..cd3e2da9ed 100644 --- a/apps/host-cloudflare/src/worker.e2e.node.test.ts +++ b/apps/host-cloudflare/src/worker.e2e.node.test.ts @@ -102,6 +102,7 @@ describe("cloudflare host e2e (workerd/miniflare)", () => { experimental: { disableExperimentalWarning: true }, vars: { EXECUTOR_SECRET_KEY: "test-secret-key-0123456789abcdef", + MCP_REQUEST_STATE_KEY: "test-mcp-request-state-key-0123456789abcdef", ENABLE_DEV_AUTH: "true", }, }); diff --git a/apps/host-cloudflare/src/worker.ts b/apps/host-cloudflare/src/worker.ts index ac9c1b30b7..b8808c73cf 100644 --- a/apps/host-cloudflare/src/worker.ts +++ b/apps/host-cloudflare/src/worker.ts @@ -11,9 +11,8 @@ export { McpExecutionOwnerDirectoryDO, McpSessionDO } from "./mcp"; // --------------------------------------------------------------------------- // The Worker fetch entry. Most requests go to `ExecutorApp.make`'s Effect web -// handler. `/mcp` stays at this edge boundary because `McpAgent.serve()` needs -// the Cloudflare `ExecutionContext` to pass authenticated session props into the -// hibernatable Durable Object bridge. +// handler. `/mcp` stays at this edge boundary so the Worker authenticates and +// binds ownership before forwarding the request to its session Durable Object. // --------------------------------------------------------------------------- let handlerPromise: Promise<{ diff --git a/apps/host-cloudflare/wrangler.jsonc b/apps/host-cloudflare/wrangler.jsonc index f97e19507e..26d8471189 100644 --- a/apps/host-cloudflare/wrangler.jsonc +++ b/apps/host-cloudflare/wrangler.jsonc @@ -67,10 +67,15 @@ ], // Cloudflare Access is the entire auth layer. ACCESS_TEAM_DOMAIN, ACCESS_AUD, // and ADMIN_EMAILS are installation-specific live vars, set after the first - // deploy and preserved by keep_vars. EXECUTOR_SECRET_KEY (the at-rest - // secret-encryption key) is a SECRET, set it with - // `wrangler secret put EXECUTOR_SECRET_KEY`, never in vars. + // deploy and preserved by keep_vars. EXECUTOR_SECRET_KEY (at-rest encryption) + // and MCP_REQUEST_STATE_KEY (MCP 2026-07-28 continuation signing, 32+ bytes) + // are SECRETS, set with `wrangler secret put `, never in vars. The + // MCP key is a deployment prerequisite shared by Worker and session DOs; + // never generate it independently per isolate. "vars": { + // MCP_2026_07_28_ENABLED is intentionally absent: unset enables modern + // inbound serving. Set the Worker var to "false" for emergency rollback; + // legacy serving remains available. "ACCESS_NAME_CLAIM": "name", "ACCESS_GROUPS_CLAIM": "groups", // Never preserve a production dev-auth override through keep_vars. diff --git a/apps/host-selfhost/package.json b/apps/host-selfhost/package.json index 9a6f70ce13..b15f14a811 100644 --- a/apps/host-selfhost/package.json +++ b/apps/host-selfhost/package.json @@ -40,7 +40,6 @@ "@executor-js/sdk": "workspace:*", "@libsql/client": "catalog:", "@libsql/kysely-libsql": "catalog:", - "@modelcontextprotocol/sdk": "^1.29.0", "@tanstack/react-router": "catalog:", "better-auth": "^1.6.11", "drizzle-orm": "catalog:", @@ -53,6 +52,7 @@ "devDependencies": { "@effect/vitest": "catalog:", "@executor-js/vite-plugin": "workspace:*", + "@modelcontextprotocol/client": "2.0.0", "@tailwindcss/vite": "catalog:", "@tanstack/router-plugin": "^1.167.12", "@tanstack/virtual-file-routes": "^1.162.0", diff --git a/apps/host-selfhost/src/app.ts b/apps/host-selfhost/src/app.ts index 4eaf631f54..8753eec0a5 100644 --- a/apps/host-selfhost/src/app.ts +++ b/apps/host-selfhost/src/app.ts @@ -78,7 +78,12 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { // ---- the in-process MCP serving seams (+ shutdown hook) ---------------- // Pass the pinned public origin so browser-approval URLs are reachable behind // a reverse proxy (not the internal 127.0.0.1 bind from the request URL). - const mcp = makeSelfHostMcpSeams(dbHandle, betterAuth, config.webBaseUrl); + const mcp = makeSelfHostMcpSeams( + dbHandle, + betterAuth, + config.webBaseUrl, + config.mcp20260728Enabled, + ); // CLI device-login discovery (`executor login`). Points the CLI at Better // Auth's device endpoints; `requestFormat: "json"` because those endpoints @@ -110,7 +115,12 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { // plane's decorator is wired in mcp/session-store.ts's stack layer). decorator: SelfHostAnalyticsEngineDecorator, }, - mcp: { auth: mcp.auth, sessions: mcp.sessions, reporter: mcp.reporter }, + mcp: { + auth: mcp.auth, + sessions: mcp.sessions, + modern: mcp.modern, + reporter: mcp.reporter, + }, plugins: { provider: SelfHostPluginsProvider, config: SelfHostHostConfig }, errorCapture: ErrorCaptureLive, }, diff --git a/apps/host-selfhost/src/config.ts b/apps/host-selfhost/src/config.ts index e0cd282d52..a435b117eb 100644 --- a/apps/host-selfhost/src/config.ts +++ b/apps/host-selfhost/src/config.ts @@ -32,6 +32,8 @@ export interface SelfHostConfig { * internal network unless an operator opts in. */ readonly allowLocalNetwork: boolean; + /** Emergency rollback for inbound MCP 2026-07-28 traffic only. */ + readonly mcp20260728Enabled: boolean; // Better Auth session secret. Always resolved (env, else generated + persisted // under the data dir) so a single-container deploy boots with no env; the auth // layer still validates an explicitly-set env secret is long enough. @@ -142,6 +144,7 @@ export const loadConfig = (): SelfHostConfig => { dbPath: process.env.EXECUTOR_DB_PATH ?? join(dataDir, "data.db"), webBaseUrl: resolveWebBaseUrl(port), allowLocalNetwork: process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true", + mcp20260728Enabled: process.env.MCP_2026_07_28_ENABLED !== "false", authSecret: resolveAuthSecret(), bootstrapAdminEmail: process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL, bootstrapAdminPassword: process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD, diff --git a/apps/host-selfhost/src/mcp/index.ts b/apps/host-selfhost/src/mcp/index.ts index 52287518cd..a11d5cd8c9 100644 --- a/apps/host-selfhost/src/mcp/index.ts +++ b/apps/host-selfhost/src/mcp/index.ts @@ -4,6 +4,7 @@ import { IdentityProvider } from "@executor-js/api/server"; import type { McpAuthProvider, McpErrorReporter, + McpModernServerBuilder, McpSessionStore, Principal, } from "@executor-js/host-mcp"; @@ -13,6 +14,7 @@ import type { SelfHostDbHandle } from "../db/self-host-db"; import { selfHostMcpAuth } from "./auth"; import { makeSelfHostMcpSessionStore, + makeSelfHostMcpModernServerBuilder, selfHostMcpReporter, selfHostMcpSessions, } from "./session-store"; @@ -20,6 +22,7 @@ import { export { selfHostMcpAuth } from "./auth"; export { makeSelfHostMcpSessionStore, + makeSelfHostMcpModernServerBuilder, selfHostMcpReporter, selfHostMcpSessions, McpEngineBuildError, @@ -34,13 +37,15 @@ export { // own auth + session handling and is mounted OUTSIDE the API's execution // middleware, like /api/auth. // -// Self-host provides the TWO envelope seams plus an error-reporter override: +// Self-host provides both era seams plus auth and an error-reporter override: // - McpAuthProvider -> `selfHostMcpAuth` (Better Auth mcp() OAuth). It still // requires `IdentityProvider`, which `make` provides from // the resolved identity seam. // - McpSessionStore -> `selfHostMcpSessions`: in-process Map. The store owns // dispatch (create + forward + ownership) and builds its // engine internally over the shared SelfHostDb. +// - McpModernServerBuilder -> one stateless MCP server per request over +// the same scoped execution stack and tool config. // - McpErrorReporter -> `selfHostMcpReporter`: route 500 defects through the // host's console capture. // @@ -53,6 +58,8 @@ export interface SelfHostMcpSeams { readonly auth: Layer.Layer; /** The in-process session store seam (dispatch + lifetime). */ readonly sessions: Layer.Layer; + /** Stateless MCP server construction for modern requests. */ + readonly modern: Layer.Layer; /** Route 500 defects through the host's console `ErrorCapture`. */ readonly reporter: Layer.Layer; /** @@ -126,13 +133,14 @@ const makeApprovalHandler = * Build the self-host MCP serving seams over the long-lived DB handle. The auth * seam is `selfHostMcpAuth` (Better Auth mcp() OAuth), with the Better Auth * instance provided; it still requires `IdentityProvider` from the resolved - * identity seam. Returns the three seam Layers plus the `close()` lifetime hook + * identity seam. Returns the four seam Layers plus the `close()` lifetime hook * the app wires into shutdown. */ export const makeSelfHostMcpSeams = ( dbHandle: SelfHostDbHandle, betterAuth: BetterAuthHandle, webBaseUrl?: string, + modernEnabled = true, ): SelfHostMcpSeams => { const sessionStore = makeSelfHostMcpSessionStore(dbHandle, webBaseUrl); const auth: Layer.Layer = selfHostMcpAuth.pipe( @@ -141,6 +149,7 @@ export const makeSelfHostMcpSeams = ( return { auth, sessions: selfHostMcpSessions(sessionStore), + modern: makeSelfHostMcpModernServerBuilder(dbHandle, modernEnabled), reporter: selfHostMcpReporter, approvalHandler: makeApprovalHandler(sessionStore, betterAuth), close: sessionStore.close, diff --git a/apps/host-selfhost/src/mcp/mcp.test.ts b/apps/host-selfhost/src/mcp/mcp.test.ts index 42a9379744..4bda4bbf10 100644 --- a/apps/host-selfhost/src/mcp/mcp.test.ts +++ b/apps/host-selfhost/src/mcp/mcp.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, expect, test } from "@effect/vitest"; +import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; import { mintInviteCode } from "../testing/mint-invite"; @@ -87,6 +88,40 @@ test("an authenticated MCP client initializes, lists tools, and executes code", expect(JSON.stringify(await call.json())).toContain("42"); }); +test("an authenticated modern MCP client discovers, lists tools, and executes code", async () => { + const token = await signUp("modern@mcp.test"); + const seenMethods: string[] = []; + const transport = new StreamableHTTPClientTransport(new URL(`${BASE}/mcp`), { + fetch: async (input, init) => { + const request = + input instanceof Request ? new Request(input, init) : new Request(input.toString(), init); + const body = (await request.clone().json()) as { readonly method?: string }; + if (body.method) seenMethods.push(body.method); + const headers = new Headers(request.headers); + headers.set("authorization", `Bearer ${token}`); + return handler(new Request(request, { headers })); + }, + }); + const client = new Client( + { name: "selfhost-modern-test", version: "1.0.0" }, + { capabilities: {}, versionNegotiation: { mode: { pin: "2026-07-28" } } }, + ); + + await client.connect(transport); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always close the authenticated modern client + try { + expect(seenMethods).toContain("server/discover"); + expect((await client.listTools()).tools.map(({ name }) => name)).toContain("execute"); + const result = await client.callTool({ + name: "execute", + arguments: { code: "export default 6 * 7" }, + }); + expect(JSON.stringify(result)).toContain("42"); + } finally { + await client.close(); + } +}); + test("an MCP session cannot be reused by another user, and unauth is rejected", async () => { const alice = await signUp("alice2@mcp.test"); const bob = await signUp("bob2@mcp.test"); diff --git a/apps/host-selfhost/src/mcp/session-store.ts b/apps/host-selfhost/src/mcp/session-store.ts index c17a8fa4db..436671f3a2 100644 --- a/apps/host-selfhost/src/mcp/session-store.ts +++ b/apps/host-selfhost/src/mcp/session-store.ts @@ -1,7 +1,7 @@ import { Layer } from "effect"; import { makeConsoleMcpErrorReporter, makeMcpBuildServer } from "@executor-js/api/server"; -import type { McpErrorReporter } from "@executor-js/host-mcp"; +import { McpModernServerBuilder, type McpErrorReporter } from "@executor-js/host-mcp"; import { inMemoryMcpSessionsLayer, makeInMemoryMcpSessionStore, @@ -19,8 +19,8 @@ import { SelfHostExecutionStackLayer } from "../execution"; // ALL shared (`@executor-js/host-mcp/in-memory-session-store` + `makeMcpBuildServer` // / `makeConsoleMcpErrorReporter` in `@executor-js/api/server`). Self-host // supplies only its fully-provided execution-stack layer (QuickJS over the -// long-lived `SelfHostDb`) and its `ErrorCapture`. The Cloudflare host wires the -// identical seam with its own stack layer. +// long-lived `SelfHostDb`) and its `ErrorCapture`; the builder creates the +// connection-lifetime assembly used by the shared store. // --------------------------------------------------------------------------- import { loadMcpAppsShellHtml } from "@executor-js/mcp-apps-shell"; @@ -51,6 +51,24 @@ export const makeSelfHostMcpSessionStore = ( { webBaseUrl }, ); +/** Build the stateless MCP server seam over the same self-host stack/config. */ +export const makeSelfHostMcpModernServerBuilder = ( + db: SelfHostDbHandle, + enabled = true, +): Layer.Layer => + Layer.succeed(McpModernServerBuilder)({ + enabled, + build: makeMcpBuildServer( + SelfHostExecutionStackLayer.pipe(Layer.provide(Layer.succeed(SelfHostDb)(db))), + { + loadAppShellHtml: loadMcpAppsShellHtml, + smokeRenderArtifact, + onArtifactUsage: (action) => + selfHostAnalytics.record(`artifact_${action}`, { via: "agent" }), + }, + ), + }); + /** The `McpSessionStore` envelope seam over a freshly built in-process store. */ export const selfHostMcpSessions = inMemoryMcpSessionsLayer; diff --git a/apps/host-selfhost/src/testing/test-app.ts b/apps/host-selfhost/src/testing/test-app.ts index 2261de31c8..480a1e03f2 100644 --- a/apps/host-selfhost/src/testing/test-app.ts +++ b/apps/host-selfhost/src/testing/test-app.ts @@ -28,6 +28,7 @@ import { import executorConfig from "../../executor.config"; import { loadConfig, SELF_HOST_NAMESPACE, SELF_HOST_SCHEMA_VERSION } from "../config"; import { + makeSelfHostMcpModernServerBuilder, makeSelfHostMcpSessionStore, selfHostMcpReporter, selfHostMcpSessions, @@ -236,6 +237,7 @@ export const makeSelfHostTestApp = async ( mcp: { auth: stubMcpAuth, sessions: selfHostMcpSessions(sessionStore), + modern: makeSelfHostMcpModernServerBuilder(dbHandle), reporter: selfHostMcpReporter, }, plugins: { provider: pluginsProvider, config: SelfHostHostConfig }, diff --git a/apps/local/package.json b/apps/local/package.json index 26f877d9b4..255c0438d7 100644 --- a/apps/local/package.json +++ b/apps/local/package.json @@ -46,7 +46,7 @@ "@executor-js/sdk": "workspace:*", "@executor-js/vite-plugin": "workspace:*", "@libsql/client": "catalog:", - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/server": "2.0.0", "@tanstack/react-router": "catalog:", "drizzle-orm": "catalog:", "effect": "catalog:", @@ -55,6 +55,8 @@ "react-dom": "catalog:" }, "devDependencies": { + "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/sdk": "^1.29.0", "@rhyssul/portless": "^0.13.0", "@tailwindcss/vite": "catalog:", "@tanstack/router-plugin": "^1.167.12", diff --git a/apps/local/src/main.ts b/apps/local/src/main.ts index 26378f58f1..14e272f37a 100644 --- a/apps/local/src/main.ts +++ b/apps/local/src/main.ts @@ -119,6 +119,7 @@ export const createServerHandlers = async (token: string): Promise Effect.succeed({ result: `ran: ${code}` }), + executeWithPause: (code) => + Effect.succeed({ status: "completed", result: { result: `ran: ${code}` } }), + resume: () => Effect.succeed(null), + isExecutionSettled: () => Effect.succeed(false), + getPausedExecution: () => Effect.succeed(null), + pausedExecutionCount: () => Effect.succeed(0), + hasPausedExecutions: () => Effect.succeed(false), + getDescription: Effect.succeed("local modern MCP test executor"), +}; + +describe("local modern MCP HTTP", () => { + it("discovers, lists tools, and executes without creating a legacy session", async () => { + const mcp = createMcpRequestHandler({ engine }); + const sessionHeaders: Array = []; + const transport = new StreamableHTTPClientTransport(new URL("http://local.test/mcp"), { + fetch: async (input, init) => { + const request = + input instanceof Request ? new Request(input, init) : new Request(input.toString(), init); + const response = await mcp.handleRequest(request); + sessionHeaders.push(response.headers.get("mcp-session-id")); + return response; + }, + }); + const client = new Client( + { name: "local-modern-test", version: "1.0.0" }, + { capabilities: {}, versionNegotiation: { mode: { pin: "2026-07-28" } } }, + ); + + await client.connect(transport); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always close the client and local handler + try { + expect((await client.listTools()).tools.map(({ name }) => name)).toContain("execute"); + const result = await client.callTool({ + name: "execute", + arguments: { code: "2 + 2" }, + }); + expect(result.content).toEqual([{ type: "text", text: "ran: 2 + 2" }]); + expect(sessionHeaders.every((sessionId) => sessionId === null)).toBe(true); + } finally { + await client.close(); + await mcp.close(); + } + }); + + it("rejects a pinned modern client with the unsupported-version protocol error", async () => { + const mcp = createMcpRequestHandler({ defaultConfig: { engine }, modernEnabled: false }); + const transport = new StreamableHTTPClientTransport(new URL("http://local.test/mcp"), { + fetch: (input, init) => + mcp.handleRequest( + input instanceof Request ? new Request(input, init) : new Request(input.toString(), init), + ), + }); + const client = new Client( + { name: "local-modern-disabled-test", version: "1.0.0" }, + { capabilities: {}, versionNegotiation: { mode: { pin: "2026-07-28" } } }, + ); + + // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always close a client whose pinned negotiation is expected to fail + try { + await expect(client.connect(transport)).rejects.toThrow(/version negotiation failed/i); + } finally { + await client.close(); + await mcp.close(); + } + }); + + it("lets an auto-mode v2 client fall back to legacy when modern inbound is disabled", async () => { + const mcp = createMcpRequestHandler({ defaultConfig: { engine }, modernEnabled: false }); + const transport = new StreamableHTTPClientTransport(new URL("http://local.test/mcp"), { + fetch: (input, init) => + mcp.handleRequest( + input instanceof Request ? new Request(input, init) : new Request(input.toString(), init), + ), + }); + const client = new Client( + { name: "local-auto-fallback-test", version: "1.0.0" }, + { capabilities: {}, versionNegotiation: { mode: "auto" } }, + ); + + await client.connect(transport); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always close the fallback client and local handler + try { + expect(client.getProtocolEra()).toBe("legacy"); + expect((await client.listTools()).tools.map(({ name }) => name)).toContain("execute"); + const result = await client.callTool({ + name: "execute", + arguments: { code: "3 + 4" }, + }); + expect(result.content).toEqual([{ type: "text", text: "ran: 3 + 4" }]); + expect(transport.sessionId).toBeTruthy(); + } finally { + await client.close(); + await mcp.close(); + } + }); +}); diff --git a/apps/local/src/mcp-stdio-test-server.ts b/apps/local/src/mcp-stdio-test-server.ts new file mode 100644 index 0000000000..043df7c8a6 --- /dev/null +++ b/apps/local/src/mcp-stdio-test-server.ts @@ -0,0 +1,44 @@ +import { Effect } from "effect"; + +import type { ExecutionEngine, ExecutionResult } from "@executor-js/execution"; +import { FormElicitation, ToolAddress } from "@executor-js/sdk"; + +import { runMcpStdioServer } from "./mcp"; + +const TOOL_ADDRESS = ToolAddress.make("tools.test.org.main.approve"); +const paused: Extract = { + status: "paused", + execution: { + id: "stdio-execution", + elicitationContext: { + address: TOOL_ADDRESS, + args: {}, + request: FormElicitation.make({ + message: "Approve the stdio action?", + requestedSchema: { + type: "object", + properties: { value: { type: "string" } }, + required: ["value"], + }, + }), + }, + }, +}; + +const engine: ExecutionEngine = { + execute: () => Effect.succeed({ result: "unused" }), + executeWithPause: (code) => + code === "needs approval" + ? Effect.succeed(paused) + : Effect.succeed({ status: "completed", result: { result: 4 } }), + resume: (_executionId, response) => + Effect.succeed({ status: "completed", result: { result: response.content?.value } }), + isExecutionSettled: () => Effect.succeed(false), + getPausedExecution: (executionId) => + Effect.succeed(executionId === paused.execution.id ? paused.execution : null), + pausedExecutionCount: () => Effect.succeed(1), + hasPausedExecutions: () => Effect.succeed(true), + getDescription: Effect.succeed("stdio integration test executor"), +}; + +await runMcpStdioServer({ engine, elicitationMode: { mode: "native" } }); diff --git a/apps/local/src/mcp.ts b/apps/local/src/mcp.ts index 780782c19c..c7efd3c179 100644 --- a/apps/local/src/mcp.ts +++ b/apps/local/src/mcp.ts @@ -1,17 +1,27 @@ import { Effect, type Cause } from "effect"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; +import { + createMcpHandler, + isLegacyRequest, + McpServer, + WebStandardStreamableHTTPServerTransport, + type McpHttpHandler, +} from "@modelcontextprotocol/server"; +import { serveStdio } from "@modelcontextprotocol/server/stdio"; import { defaultMcpResource, jsonRpcErrorBody, + mcpModernDisabledResponse, mcpResourceKey, type McpResource, } from "@executor-js/host-mcp"; import { - createExecutorMcpServer, - type ExecutorMcpServerConfig, + appsEnabledForClientCapabilities, + buildMcpServer, + clientCapabilitiesFromRequest, + mcpRequestStateBindingFromBody, + requestBodyFromRequest, + type ExecutorMcpToolConfig, } from "@executor-js/host-mcp/tool-server"; import { approvalUrlForRequest, @@ -45,12 +55,14 @@ export type McpRequestHandler = { }; export interface LocalMcpServerConfig { - readonly config: ExecutorMcpServerConfig; + readonly config: ExecutorMcpToolConfig; readonly close?: () => Promise; } export interface LocalMcpRequestHandlerConfig { - readonly defaultConfig: ExecutorMcpServerConfig; + readonly defaultConfig: ExecutorMcpToolConfig; + /** Emergency rollback for inbound MCP 2026-07-28 traffic only. */ + readonly modernEnabled?: boolean; readonly createConfigForResource?: ( resource: McpResource, ) => Promise | LocalMcpServerConfig; @@ -113,15 +125,15 @@ const resourceFromRequest = (request: Request): McpResource | null => { return { kind: "toolkit", slug: decodeURIComponent(match[1]) }; }; -const engineFromConfig = (config: ExecutorMcpServerConfig): AnyExecutionEngine | null => +const engineFromConfig = (config: ExecutorMcpToolConfig): AnyExecutionEngine | null => "engine" in config ? config.engine : null; const normalizeHandlerConfig = ( - input: ExecutorMcpServerConfig | LocalMcpRequestHandlerConfig, + input: ExecutorMcpToolConfig | LocalMcpRequestHandlerConfig, ): LocalMcpRequestHandlerConfig => ("defaultConfig" in input ? input : { defaultConfig: input }); export const createMcpRequestHandler = ( - input: ExecutorMcpServerConfig | LocalMcpRequestHandlerConfig, + input: ExecutorMcpToolConfig | LocalMcpRequestHandlerConfig, ): McpRequestHandler => { const handlerConfig = normalizeHandlerConfig(input); const transports = new Map(); @@ -129,8 +141,14 @@ export const createMcpRequestHandler = ( const resources = new Map(); const sessionEngines = new Map(); const sessionClosers = new Map Promise>(); + const modernHandlers = new Map(); + const modernRequestBodies = new WeakMap(); const approvals = makeInProcessBrowserApprovalStore(); const defaultEngine = engineFromConfig(handlerConfig.defaultConfig); + let requestStateSigningKey: Uint8Array | undefined; + + const signingKey = (): Uint8Array => + (requestStateSigningKey ??= crypto.getRandomValues(new Uint8Array(32))); const pausedDetail = ( sessionId: string, @@ -164,10 +182,71 @@ export const createMcpRequestHandler = ( await ignoreClose(close); }; + const modernHandlerFor = (resource: McpResource): McpHttpHandler => { + const key = mcpResourceKey(resource); + const cached = modernHandlers.get(key); + if (cached) return cached; + + const handler = createMcpHandler( + (context) => { + const request = context.requestInfo; + if (!request) { + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: the third-party McpServerFactory Promise contract has no typed failure channel; missing documented request context is an SDK defect + return Effect.runPromise(Effect.die("Modern MCP request context has no request")); + } + const parsedBody = modernRequestBodies.get(request); + return Effect.runPromise( + Effect.gen(function* () { + const resourceConfig = yield* Effect.promise(() => configForResource(resource)); + const clientCapabilities = yield* clientCapabilitiesFromRequest(request); + const requestStateBinding = yield* Effect.promise(() => + mcpRequestStateBindingFromBody({ + body: parsedBody, + principal: "local", + resource, + }), + ); + const server = yield* buildMcpServer({ + ...resourceConfig.config, + artifactsEnabled: readArtifactsEnabled(request), + appsEnabled: appsEnabledForClientCapabilities(clientCapabilities), + requestStateSigningKey: signingKey(), + requestStatePrincipal: "local", + ...(requestStateBinding === null ? {} : { requestStateBinding }), + }); + if (resourceConfig.close) { + const closeServer = server.close.bind(server); + const closeConfig = resourceConfig.close; + let closed = false; + server.close = async () => { + if (closed) return; + closed = true; + await ignoreClose(closeServer); + await ignoreClose(closeConfig); + }; + } + return server; + }), + ); + }, + { legacy: "reject" }, + ); + modernHandlers.set(key, handler); + return handler; + }; + return { handleRequest: async (request) => { const resource = resourceFromRequest(request); if (!resource) return jsonError(404, -32001, "MCP resource not found"); + if (!(await isLegacyRequest(request))) { + if (handlerConfig.modernEnabled === false) { + return mcpModernDisabledResponse({ cors: false }); + } + const parsedBody = await Effect.runPromise(requestBodyFromRequest(request)); + modernRequestBodies.set(request, parsedBody); + return modernHandlerFor(resource).fetch(request, { parsedBody }); + } const sessionId = request.headers.get("mcp-session-id"); if (sessionId) { @@ -216,10 +295,14 @@ export const createMcpRequestHandler = ( const elicitationMode = readElicitationMode(request); resourceConfig = await configForResource(resource); created = await Effect.runPromise( - createExecutorMcpServer({ + buildMcpServer({ ...resourceConfig.config, browserApprovalStore: approvals.store, artifactsEnabled: readArtifactsEnabled(request), + appsEnabled: resourceConfig.config.restoredAppsEnabled ?? false, + requestStateSigningKey: signingKey(), + requestStatePrincipal: "local", + sessionful: true, elicitationMode: elicitationMode === "browser" ? { @@ -283,7 +366,10 @@ export const createMcpRequestHandler = ( close: async () => { const ids = new Set([...transports.keys(), ...servers.keys()]); - await Promise.all([...ids].map((id) => dispose(id, { transport: true, server: true }))); + await Promise.all([ + ...[...ids].map((id) => dispose(id, { transport: true, server: true })), + ...[...modernHandlers.values()].map((handler) => handler.close()), + ]); }, }; }; @@ -292,11 +378,21 @@ export const createMcpRequestHandler = ( // Stdio transport // --------------------------------------------------------------------------- -export const runMcpStdioServer = async (config: ExecutorMcpServerConfig): Promise => { +export const runMcpStdioServer = async (config: ExecutorMcpToolConfig): Promise => { startIntegrationsRefresh(); - const server = await Effect.runPromise(createExecutorMcpServer(config)); - const transport = new StdioServerTransport(); + const requestStateSigningKey = crypto.getRandomValues(new Uint8Array(32)); + const stdio = serveStdio(() => + Effect.runPromise( + buildMcpServer({ + ...config, + appsEnabled: config.restoredAppsEnabled ?? false, + requestStateSigningKey, + requestStatePrincipal: "local", + sessionful: true, + }), + ), + ); const waitForExit = () => new Promise((resolve) => { @@ -315,10 +411,8 @@ export const runMcpStdioServer = async (config: ExecutorMcpServerConfig): Promis // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: stdio server lifetime uses Promise-based SDK/process APIs and always closes resources try { - await server.connect(transport); await waitForExit(); } finally { - await ignoreClose(() => transport.close()); - await ignoreClose(() => server.close()); + await ignoreClose(() => stdio.close()); } }; diff --git a/bun.lock b/bun.lock index 43c3519f48..7ee2877aca 100644 --- a/bun.lock +++ b/bun.lock @@ -43,6 +43,7 @@ "@executor-js/runtime-quickjs": "workspace:*", "@executor-js/sdk": "workspace:*", "@jitl/quickjs-wasmfile-release-sync": "catalog:", + "@modelcontextprotocol/client": "2.0.0", "@modelcontextprotocol/sdk": "^1.29.0", "@sentry/bun": "^10.57.0", "effect": "catalog:", @@ -244,7 +245,6 @@ "@executor-js/sdk": "workspace:*", "@libsql/client": "catalog:", "@libsql/kysely-libsql": "catalog:", - "@modelcontextprotocol/sdk": "^1.29.0", "@tanstack/react-router": "catalog:", "better-auth": "^1.6.11", "drizzle-orm": "catalog:", @@ -257,6 +257,7 @@ "devDependencies": { "@effect/vitest": "catalog:", "@executor-js/vite-plugin": "workspace:*", + "@modelcontextprotocol/client": "2.0.0", "@tailwindcss/vite": "catalog:", "@tanstack/router-plugin": "^1.167.12", "@tanstack/virtual-file-routes": "^1.162.0", @@ -300,7 +301,7 @@ "@executor-js/sdk": "workspace:*", "@executor-js/vite-plugin": "workspace:*", "@libsql/client": "catalog:", - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/server": "2.0.0", "@tanstack/react-router": "catalog:", "drizzle-orm": "catalog:", "effect": "catalog:", @@ -309,6 +310,8 @@ "react-dom": "catalog:", }, "devDependencies": { + "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/sdk": "^1.29.0", "@rhyssul/portless": "^0.13.0", "@tailwindcss/vite": "catalog:", "@tanstack/router-plugin": "^1.167.12", @@ -363,7 +366,10 @@ "@executor-js/plugin-toolkits": "workspace:*", "@executor-js/sdk": "workspace:*", "@kitlangton/terminal-control": "^0.3.0", + "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/core": "2.0.0", "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/server": "2.0.0", "asciinema-player": "^3.15.1", "effect": "catalog:", "monaco-editor": "^0.55.1", @@ -682,7 +688,7 @@ "@executor-js/host-mcp": "workspace:*", "@executor-js/sdk": "workspace:*", "@modelcontextprotocol/sdk": "^1.29.0", - "agents": "^0.17.3", + "@modelcontextprotocol/server": "2.0.0", "effect": "catalog:", }, "devDependencies": { @@ -699,16 +705,17 @@ "name": "@executor-js/host-mcp", "version": "1.4.4", "dependencies": { - "@cfworker/json-schema": "^4.1.1", "@executor-js/execution": "workspace:*", "@executor-js/sdk": "workspace:*", - "@modelcontextprotocol/ext-apps": "^1.7.4", - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/core": "2.0.0", + "@modelcontextprotocol/server": "2.0.0", "effect": "catalog:", "zod": "4.3.6", }, "devDependencies": { "@effect/vitest": "catalog:", + "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/sdk": "^1.29.0", "@types/node": "catalog:", "bun-types": "catalog:", "vitest": "catalog:", @@ -1244,7 +1251,6 @@ "@electric-sql/pglite-socket@0.1.4": "patches/@electric-sql%2Fpglite-socket@0.1.4.patch", "libsql@0.5.29": "patches/libsql@0.5.29.patch", "@1password/sdk-core@0.4.1-beta.1": "patches/@1password%2Fsdk-core@0.4.1-beta.1.patch", - "agents@0.17.3": "patches/agents@0.17.3.patch", "postgres@3.4.9": "patches/postgres@3.4.9.patch", }, "catalog": { @@ -1292,12 +1298,6 @@ "@1password/sdk-core": ["@1password/sdk-core@0.4.1-beta.1", "", {}, "sha512-/otbg1JVhsEn6oUIeReoT9TmFr8J7KBwr9UuRVfJFwwGG3bHPF8ewT+LhRimQeJtypqQ69ZVuOYkxknD4iQHxw=="], - "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.99", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8/UuzFY8p+T8j4XP/9m841pUb5bhnFt8cecSnJpd2zhBttNZ6GbfjZTmsqnvM/RwJOvzIsdFULZrU+E9QFREsQ=="], - - "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], - - "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.23", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg=="], - "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.2.5", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw=="], "@ant-design/colors": ["@ant-design/colors@8.0.1", "", { "dependencies": { "@ant-design/fast-color": "^3.0.0" } }, "sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ=="], @@ -1422,28 +1422,16 @@ "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], - "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@8.0.0", "", { "dependencies": { "@babel/types": "^8.0.0" } }, "sha512-NSpMkMsvvZqzThJ0p1B02cbtA2ObEyfBvq950bmNkyxsxvcxwhvvCB036rKhlEnuBBo30bOrk13u3FzlKSoRrw=="], - "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], - "@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@8.0.1", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^8.0.0", "@babel/helper-member-expression-to-functions": "^8.0.0", "@babel/helper-optimise-call-expression": "^8.0.0", "@babel/helper-replace-supers": "^8.0.1", "@babel/helper-skip-transparent-expression-wrappers": "^8.0.0", "@babel/traverse": "^8.0.0", "semver": "^7.7.3" }, "peerDependencies": { "@babel/core": "^8.0.0" } }, "sha512-++t3ZktzlLmASAxIlxeXQK9Z2YwUafYGYcvGBFevqOqt16HozVHStUoQvWD09fzAZOb/uJGpUTBuGK41AJAuOA=="], - "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], - "@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@8.0.0", "", { "dependencies": { "@babel/traverse": "^8.0.0", "@babel/types": "^8.0.0" } }, "sha512-xkXrMbtk87Gk7+oKBVmBc6EORg/Qwx++AHESldmHkpvG8wgccdhJJFwrzqlF382Fk8wfXhJHWE/g/43QvEGNPQ=="], - "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], - "@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@8.0.0", "", { "dependencies": { "@babel/types": "^8.0.0" } }, "sha512-3W6satvtPuCUkUx63S2jMoW9EQNYkADgs1HTfufmL7gCmAulHMKupA/12WNz4A0GMMFn/YnWWwqOT9IZrJHQjg=="], - "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], - "@babel/helper-replace-supers": ["@babel/helper-replace-supers@8.0.1", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^8.0.0", "@babel/helper-optimise-call-expression": "^8.0.0", "@babel/traverse": "^8.0.0" }, "peerDependencies": { "@babel/core": "^8.0.0" } }, "sha512-B1SZADIcy3tmH8CmWvj4SHi/oAPom4UL3uknTc2QRNsPVLFk/sPnZvQL/8kj7Y5omvjMqie0vklvs6XM4OLW5Q=="], - - "@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@8.0.0", "", { "dependencies": { "@babel/traverse": "^8.0.0", "@babel/types": "^8.0.0" } }, "sha512-xmCA9kP3IhySsqhzwIdWGlDN/1A4cCKNBO/uwZx/3YzmDoMePwno2Q5/Bq0q+tYaKbeF940YiKV/kaW8Mzvpjw=="], - "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], @@ -1454,10 +1442,6 @@ "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], - "@babel/plugin-proposal-decorators": ["@babel/plugin-proposal-decorators@8.0.2", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^8.0.1", "@babel/helper-plugin-utils": "^8.0.1", "@babel/plugin-syntax-decorators": "^8.0.1" }, "peerDependencies": { "@babel/core": "^8.0.0" } }, "sha512-+C6O6KKXU7BBq1GNaIkFJxrALUVGRcr+WeWm4OcuRl3h+l/CmNfcTLMrT2Lm3uvGBimBH/8pEBRrXJFLoO67Gg=="], - - "@babel/plugin-syntax-decorators": ["@babel/plugin-syntax-decorators@8.0.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^8.0.1" }, "peerDependencies": { "@babel/core": "^8.0.0" } }, "sha512-NI+0S/6MvR6GlcQFwjDZ+WIc2qvG6TXN534lYs9llNldwW4b7Dh6KTtk030FA0xWdYGs4t1lWo+OEWN8wGB+Nw=="], - "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="], "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="], @@ -1470,8 +1454,6 @@ "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], - "@babel/runtime-corejs3": ["@babel/runtime-corejs3@7.29.2", "", { "dependencies": { "core-js-pure": "^3.48.0" } }, "sha512-Lc94FOD5+0aXhdb0Tdg3RUtqT6yWbI/BbFWvlaSJ3gAb9Ks+99nHRDKADVqC37er4eCB0fHyWT+y+K3QOvJKbw=="], - "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], @@ -1564,8 +1546,6 @@ "@clerk/shared": ["@clerk/shared@4.22.0", "", { "dependencies": { "@tanstack/query-core": "^5.100.6", "dequal": "2.0.3", "glob-to-regexp": "0.4.1", "js-cookie": "3.0.7" }, "peerDependencies": { "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-GZ56kzUB2UBb8MCF+Eo8WIey+W4RP1tAkyOL+geiHxrQxJlUcgD+xalY2YPJLH8WVFwtOnfIl8KPEo0M0e/DRg=="], - "@cloudflare/codemode": ["@cloudflare/codemode@0.4.2", "", { "dependencies": { "@types/json-schema": "^7.0.15", "acorn": "^8.17.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.0", "@tanstack/ai": ">=0.8.0 <1.0.0", "ai": "^6.0.0", "zod": "^4.0.0" }, "optionalPeers": ["@modelcontextprotocol/sdk", "@tanstack/ai", "ai", "zod"] }, "sha512-6sLMZDRY2USbXirrI4tjmGSMYAYM+E/PJIaDQLLbMdvQjk1z1TmJNSLomrZwErO1zFPXvGX2kaqkkxvixkfV2w=="], - "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="], "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="], @@ -2136,6 +2116,8 @@ "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + "@modelcontextprotocol/server": ["@modelcontextprotocol/server@2.0.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.0.0", "zod": "^4.2.0" } }, "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw=="], + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="], "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="], @@ -3142,10 +3124,6 @@ "@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="], - "@types/jsesc": ["@types/jsesc@2.5.1", "", {}, "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw=="], - - "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - "@types/katex": ["@types/katex@0.16.8", "", {}, "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg=="], "@types/keyv": ["@types/keyv@3.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="], @@ -3264,8 +3242,6 @@ "@use-gesture/react": ["@use-gesture/react@10.3.1", "", { "dependencies": { "@use-gesture/core": "10.3.1" }, "peerDependencies": { "react": ">= 16.8.0" } }, "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g=="], - "@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="], - "@vercel/sdk": ["@vercel/sdk@1.28.4", "", { "dependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-3r3nopI45UmoOC6MlNzZhB2l/Itn6X14uonAbaOZ5W5zkNUi7mhJc3CncRia/FE/N1mvsx6La9G5C90dRZrdEg=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="], @@ -3318,12 +3294,8 @@ "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - "agents": ["agents@0.17.3", "", { "dependencies": { "@babel/plugin-proposal-decorators": "^8.0.2", "@cfworker/json-schema": "^4.1.1", "@cloudflare/codemode": "^0.4.2", "@modelcontextprotocol/sdk": "1.29.0", "@rolldown/plugin-babel": "^0.2.3", "cron-schedule": "^6.0.0", "esbuild": "^0.28.1", "mimetext": "^3.0.28", "nanoid": "^5.1.16", "partyserver": "^0.5.8", "partysocket": "1.3.0", "yaml": "^2.9.0", "yargs": "^18.0.0" }, "peerDependencies": { "@ai-sdk/react": "^3.0.204", "@tanstack/ai": ">=0.10.2 <1.0.0", "@x402/core": "^2.0.0", "@x402/evm": "^2.0.0", "ai": "^6.0.0", "chat": "^4.29.0", "just-bash": "^3.0.0", "react": "^19.0.0", "vite": ">=6.0.0 <9.0.0", "zod": "^4.0.0" }, "optionalPeers": ["@ai-sdk/react", "@tanstack/ai", "@x402/core", "@x402/evm", "ai", "chat", "just-bash", "vite"], "bin": { "agents": "dist/cli/index.js" } }, "sha512-h0rc+dXwe/B6WblHH+Q3625nN/aryZntj6NIJX7tf+VSjmnI1EZyWtVBYMdX4FJZLShpl/vcx/A1AkxytWCPNQ=="], - "ahooks": ["ahooks@3.9.7", "", { "dependencies": { "@babel/runtime": "^7.21.0", "@types/js-cookie": "^3.0.6", "dayjs": "^1.9.1", "intersection-observer": "^0.12.0", "js-cookie": "^3.0.5", "lodash": "^4.17.21", "react-fast-compare": "^3.2.2", "resize-observer-polyfill": "^1.5.1", "screenfull": "^5.0.0", "tslib": "^2.4.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-S0lvzhbdlhK36RFBkGv+RbOM/dbbweym+BIHM/bwwuWVSVN5TuVErHPMWo4w0t1NDYg5KPp2iEf7Y7E5LASYiw=="], - "ai": ["ai@6.0.162", "", { "dependencies": { "@ai-sdk/gateway": "3.0.99", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-1PSvNEK1PEbpUXahnFrcey6l7DJXMVWmg0ibQ8h8oMSe9V1Vx5d+R3xNu0hzBtwqfxYj21ddZo+EUYVs6GOEyA=="], - "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], @@ -3644,8 +3616,6 @@ "core-js": ["core-js@3.49.0", "", {}, "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg=="], - "core-js-pure": ["core-js-pure@3.49.0", "", {}, "sha512-XM4RFka59xATyJv/cS3O3Kml72hQXUeGRuuTmMYFxwzc9/7C8OYTaIR/Ji+Yt8DXzsFLNhat15cE/JP15HrCgw=="], - "core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="], "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], @@ -3656,8 +3626,6 @@ "crc": ["crc@3.8.0", "", { "dependencies": { "buffer": "^5.1.0" } }, "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ=="], - "cron-schedule": ["cron-schedule@6.0.0", "", {}, "sha512-BoZaseYGXOo5j5HUwTaegIog3JJbuH4BbrY9A1ArLjXpy+RWb3mV28F/9Gv1dDA7E2L8kngWva4NWisnLTyfgQ=="], - "cross-dirname": ["cross-dirname@0.1.0", "", {}, "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q=="], "cross-inspect": ["cross-inspect@1.0.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A=="], @@ -3970,8 +3938,6 @@ "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], - "event-target-polyfill": ["event-target-polyfill@0.0.4", "", {}, "sha512-Gs6RLjzlLRdT8X9ZipJdIZI/Y6/HhRLyq9RdDlCsnpxr/+Nn6bU2EFGuC94GjxqhM+Nmij2Vcq98yoHrU8uNFQ=="], - "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], @@ -4734,8 +4700,6 @@ "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], - "mimetext": ["mimetext@3.0.28", "", { "dependencies": { "@babel/runtime": "^7.26.0", "@babel/runtime-corejs3": "^7.26.0", "js-base64": "^3.7.7", "mime-types": "^2.1.35" } }, "sha512-eQXpbNrtxLCjUtiVbR/qR09dbPgZ2o+KR1uA7QKqGhbn8QV7HIL16mXXsobBL4/8TqoYh1us31kfz+dNfCev9g=="], - "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], @@ -4788,7 +4752,7 @@ "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], - "nanoid": ["nanoid@5.1.16", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="], + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "nanostores": ["nanostores@1.3.0", "", {}, "sha512-XPUa/jz+P1oJvN9VBxw4L9MtdFfaH3DAryqPssqhb2kXjmb9npz0dly6rCsgFWOPr4Yg9mTfM3MDZgZZ+7A3lA=="], @@ -4940,10 +4904,6 @@ "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], - "partyserver": ["partyserver@0.5.8", "", { "dependencies": { "nanoid": "^5.1.9" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260424.1" } }, "sha512-htgSwiBcBu9zIYLrsxBAOvdkjukHvncbTk0nDrJgfruvZ08rxtEN1Ab4T7j9osykP80Bq3zA2oWFd3ngc4Z9uw=="], - - "partysocket": ["partysocket@1.3.0", "", { "dependencies": { "event-target-polyfill": "^0.0.4" }, "peerDependencies": { "react": ">=17" }, "optionalPeers": ["react"] }, "sha512-1zToNyolZFK/7nuAw/K2bZrNzFqaZyRoCEkS+9vG6WSC5ikrN6qWRe96q6ImU51uptz2r+dAwSkwhJVdQi4LiA=="], - "patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="], "path-data-parser": ["path-data-parser@0.1.0", "", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="], @@ -5896,34 +5856,12 @@ "@babel/generator/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], - "@babel/helper-annotate-as-pure/@babel/types": ["@babel/types@8.0.0", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.0" } }, "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw=="], - "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@babel/helper-create-class-features-plugin/@babel/traverse": ["@babel/traverse@8.0.0", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/generator": "^8.0.0", "@babel/helper-globals": "^8.0.0", "@babel/parser": "^8.0.0", "@babel/template": "^8.0.0", "@babel/types": "^8.0.0", "obug": "^2.1.1" } }, "sha512-bxTj/W2VclGE6CctlfQOpxg8MPDzXArRqkOBePw8EHfebcjF7fETWSS3BriEECo+UiU/Yblq+xUtSImFu7cTbw=="], - - "@babel/helper-create-class-features-plugin/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - - "@babel/helper-member-expression-to-functions/@babel/traverse": ["@babel/traverse@8.0.0", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/generator": "^8.0.0", "@babel/helper-globals": "^8.0.0", "@babel/parser": "^8.0.0", "@babel/template": "^8.0.0", "@babel/types": "^8.0.0", "obug": "^2.1.1" } }, "sha512-bxTj/W2VclGE6CctlfQOpxg8MPDzXArRqkOBePw8EHfebcjF7fETWSS3BriEECo+UiU/Yblq+xUtSImFu7cTbw=="], - - "@babel/helper-member-expression-to-functions/@babel/types": ["@babel/types@8.0.0", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.0" } }, "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw=="], - - "@babel/helper-optimise-call-expression/@babel/types": ["@babel/types@8.0.0", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.0" } }, "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw=="], - - "@babel/helper-replace-supers/@babel/traverse": ["@babel/traverse@8.0.0", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/generator": "^8.0.0", "@babel/helper-globals": "^8.0.0", "@babel/parser": "^8.0.0", "@babel/template": "^8.0.0", "@babel/types": "^8.0.0", "obug": "^2.1.1" } }, "sha512-bxTj/W2VclGE6CctlfQOpxg8MPDzXArRqkOBePw8EHfebcjF7fETWSS3BriEECo+UiU/Yblq+xUtSImFu7cTbw=="], - - "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse": ["@babel/traverse@8.0.0", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/generator": "^8.0.0", "@babel/helper-globals": "^8.0.0", "@babel/parser": "^8.0.0", "@babel/template": "^8.0.0", "@babel/types": "^8.0.0", "obug": "^2.1.1" } }, "sha512-bxTj/W2VclGE6CctlfQOpxg8MPDzXArRqkOBePw8EHfebcjF7fETWSS3BriEECo+UiU/Yblq+xUtSImFu7cTbw=="], - - "@babel/helper-skip-transparent-expression-wrappers/@babel/types": ["@babel/types@8.0.0", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.0" } }, "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw=="], - "@babel/parser/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], - "@babel/plugin-proposal-decorators/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@8.0.1", "", { "peerDependencies": { "@babel/core": "^8.0.0" } }, "sha512-3PKFgjTyPlhFhorfP+SjKQxLViIL++zWjFOO4hGriYU+Bsm983DxEM1JmDRJVWXV0O9npu+xXRqz7Pbd3mh70g=="], - - "@babel/plugin-syntax-decorators/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@8.0.1", "", { "peerDependencies": { "@babel/core": "^8.0.0" } }, "sha512-3PKFgjTyPlhFhorfP+SjKQxLViIL++zWjFOO4hGriYU+Bsm983DxEM1JmDRJVWXV0O9npu+xXRqz7Pbd3mh70g=="], - "@babel/template/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], "@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], @@ -5946,8 +5884,6 @@ "@clerk/shared/js-cookie": ["js-cookie@3.0.7", "", {}, "sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw=="], - "@cloudflare/codemode/acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], - "@cloudflare/vite-plugin/@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.0", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-8ovsRpwzPoEqPUzoErAYVv8l3FMZNeBVQfJTvtzP4AgLSRGZISRfuChFxHWUQd3n6cnrwkuTGxT+2cGo8EsyYg=="], "@cloudflare/vite-plugin/miniflare": ["miniflare@4.20260415.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.8", "workerd": "1.20260415.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-JoExRWN4YBI2luA5BoSMFEgi8rQWXUGzo3mtE+58VXCLV3jj/Xnk5Yeqs/IXWz8Es5GJIaq6BtsixDvAxXSIng=="], @@ -6132,6 +6068,8 @@ "@modelcontextprotocol/sdk/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], + "@modelcontextprotocol/server/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], "@opentelemetry/exporter-logs-otlp-proto/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="], @@ -6434,14 +6372,6 @@ "@vercel/sdk/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - "agents/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], - - "agents/yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], - - "agents/yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="], - - "ai/@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], - "ajv-keywords/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], @@ -6650,8 +6580,6 @@ "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], - "mimetext/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "miniflare/undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="], "miniflare/workerd": ["workerd@1.20260424.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260424.1", "@cloudflare/workerd-darwin-arm64": "1.20260424.1", "@cloudflare/workerd-linux-64": "1.20260424.1", "@cloudflare/workerd-linux-arm64": "1.20260424.1", "@cloudflare/workerd-windows-64": "1.20260424.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-oKsB0Xo/mfkYMdSACoS06XZg09VUK4rXwHfF/1t3P++sMbwzf4UHQvMO57+zxpEB2nVrY/ZkW0bYFGq4GdAFSQ=="], @@ -6690,8 +6618,6 @@ "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], - "postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], - "posthog-js/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="], "posthog-js/@opentelemetry/exporter-logs-otlp-http": ["@opentelemetry/exporter-logs-otlp-http@0.208.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.208.0", "@opentelemetry/core": "2.2.0", "@opentelemetry/otlp-exporter-base": "0.208.0", "@opentelemetry/otlp-transformer": "0.208.0", "@opentelemetry/sdk-logs": "0.208.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg=="], @@ -6830,68 +6756,8 @@ "@azure/identity/@azure/msal-node/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], - "@babel/helper-annotate-as-pure/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0", "", {}, "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg=="], - - "@babel/helper-annotate-as-pure/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.2", "", {}, "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA=="], - "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/code-frame": ["@babel/code-frame@8.0.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^8.0.0", "js-tokens": "^10.0.0" } }, "sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw=="], - - "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/generator": ["@babel/generator@8.0.0", "", { "dependencies": { "@babel/parser": "^8.0.0", "@babel/types": "^8.0.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "@types/jsesc": "^2.5.0", "jsesc": "^3.0.2" } }, "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g=="], - - "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/helper-globals": ["@babel/helper-globals@8.0.0", "", {}, "sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw=="], - - "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/parser": ["@babel/parser@8.0.0", "", { "dependencies": { "@babel/types": "^8.0.0" }, "bin": "./bin/babel-parser.js" }, "sha512-aLxAE+imI9bCcyaPrUDjBv3uSkWieifjLe0kuFOZF0zli0L6GCsTmsePnTr55adbIAgYz2zhN1vnFimCBUYcRQ=="], - - "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/template": ["@babel/template@8.0.0", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/parser": "^8.0.0", "@babel/types": "^8.0.0" } }, "sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ=="], - - "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/types": ["@babel/types@8.0.0", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.0" } }, "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw=="], - - "@babel/helper-member-expression-to-functions/@babel/traverse/@babel/code-frame": ["@babel/code-frame@8.0.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^8.0.0", "js-tokens": "^10.0.0" } }, "sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw=="], - - "@babel/helper-member-expression-to-functions/@babel/traverse/@babel/generator": ["@babel/generator@8.0.0", "", { "dependencies": { "@babel/parser": "^8.0.0", "@babel/types": "^8.0.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "@types/jsesc": "^2.5.0", "jsesc": "^3.0.2" } }, "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g=="], - - "@babel/helper-member-expression-to-functions/@babel/traverse/@babel/helper-globals": ["@babel/helper-globals@8.0.0", "", {}, "sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw=="], - - "@babel/helper-member-expression-to-functions/@babel/traverse/@babel/parser": ["@babel/parser@8.0.0", "", { "dependencies": { "@babel/types": "^8.0.0" }, "bin": "./bin/babel-parser.js" }, "sha512-aLxAE+imI9bCcyaPrUDjBv3uSkWieifjLe0kuFOZF0zli0L6GCsTmsePnTr55adbIAgYz2zhN1vnFimCBUYcRQ=="], - - "@babel/helper-member-expression-to-functions/@babel/traverse/@babel/template": ["@babel/template@8.0.0", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/parser": "^8.0.0", "@babel/types": "^8.0.0" } }, "sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ=="], - - "@babel/helper-member-expression-to-functions/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0", "", {}, "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg=="], - - "@babel/helper-member-expression-to-functions/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.2", "", {}, "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA=="], - - "@babel/helper-optimise-call-expression/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0", "", {}, "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg=="], - - "@babel/helper-optimise-call-expression/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.2", "", {}, "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA=="], - - "@babel/helper-replace-supers/@babel/traverse/@babel/code-frame": ["@babel/code-frame@8.0.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^8.0.0", "js-tokens": "^10.0.0" } }, "sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw=="], - - "@babel/helper-replace-supers/@babel/traverse/@babel/generator": ["@babel/generator@8.0.0", "", { "dependencies": { "@babel/parser": "^8.0.0", "@babel/types": "^8.0.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "@types/jsesc": "^2.5.0", "jsesc": "^3.0.2" } }, "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g=="], - - "@babel/helper-replace-supers/@babel/traverse/@babel/helper-globals": ["@babel/helper-globals@8.0.0", "", {}, "sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw=="], - - "@babel/helper-replace-supers/@babel/traverse/@babel/parser": ["@babel/parser@8.0.0", "", { "dependencies": { "@babel/types": "^8.0.0" }, "bin": "./bin/babel-parser.js" }, "sha512-aLxAE+imI9bCcyaPrUDjBv3uSkWieifjLe0kuFOZF0zli0L6GCsTmsePnTr55adbIAgYz2zhN1vnFimCBUYcRQ=="], - - "@babel/helper-replace-supers/@babel/traverse/@babel/template": ["@babel/template@8.0.0", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/parser": "^8.0.0", "@babel/types": "^8.0.0" } }, "sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ=="], - - "@babel/helper-replace-supers/@babel/traverse/@babel/types": ["@babel/types@8.0.0", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.0" } }, "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw=="], - - "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/code-frame": ["@babel/code-frame@8.0.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^8.0.0", "js-tokens": "^10.0.0" } }, "sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw=="], - - "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/generator": ["@babel/generator@8.0.0", "", { "dependencies": { "@babel/parser": "^8.0.0", "@babel/types": "^8.0.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "@types/jsesc": "^2.5.0", "jsesc": "^3.0.2" } }, "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g=="], - - "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/helper-globals": ["@babel/helper-globals@8.0.0", "", {}, "sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw=="], - - "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/parser": ["@babel/parser@8.0.0", "", { "dependencies": { "@babel/types": "^8.0.0" }, "bin": "./bin/babel-parser.js" }, "sha512-aLxAE+imI9bCcyaPrUDjBv3uSkWieifjLe0kuFOZF0zli0L6GCsTmsePnTr55adbIAgYz2zhN1vnFimCBUYcRQ=="], - - "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/template": ["@babel/template@8.0.0", "", { "dependencies": { "@babel/code-frame": "^8.0.0", "@babel/parser": "^8.0.0", "@babel/types": "^8.0.0" } }, "sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ=="], - - "@babel/helper-skip-transparent-expression-wrappers/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0", "", {}, "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg=="], - - "@babel/helper-skip-transparent-expression-wrappers/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.2", "", {}, "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA=="], - "@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], "@babel/parser/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], @@ -7222,62 +7088,6 @@ "@types/yauzl/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], - "agents/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], - - "agents/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], - - "agents/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], - - "agents/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], - - "agents/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], - - "agents/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], - - "agents/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], - - "agents/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], - - "agents/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], - - "agents/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], - - "agents/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], - - "agents/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], - - "agents/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], - - "agents/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], - - "agents/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], - - "agents/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], - - "agents/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], - - "agents/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], - - "agents/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], - - "agents/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], - - "agents/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], - - "agents/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], - - "agents/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], - - "agents/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], - - "agents/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], - - "agents/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], - - "agents/yargs/cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], - - "agents/yargs/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], - "ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "app-builder-lib/@electron/get/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], @@ -7490,8 +7300,6 @@ "kayvee/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], - "mimetext/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - "miniflare/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260424.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-yFR1XaJbSDLg/qbwtrYaU2xwFXatIPKR5nrMQCN1q/m6+Qe/j6r+kCnFEvOJjMZOm9iCKsE6Qly5clgl4u32qw=="], "miniflare/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260424.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LqWKcE7x/9KyC2iQvKPeb20hKST3dYXDZlYTvFymgR1DfLS0OFOCzVGTloVNd7WqvK4SkdzBYfxo7QMIAeBK0w=="], @@ -7624,30 +7432,6 @@ "yargs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], - "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.2", "", {}, "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA=="], - - "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/code-frame/js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], - - "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0", "", {}, "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg=="], - - "@babel/helper-create-class-features-plugin/@babel/traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.2", "", {}, "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA=="], - - "@babel/helper-member-expression-to-functions/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.2", "", {}, "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA=="], - - "@babel/helper-member-expression-to-functions/@babel/traverse/@babel/code-frame/js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], - - "@babel/helper-replace-supers/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.2", "", {}, "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA=="], - - "@babel/helper-replace-supers/@babel/traverse/@babel/code-frame/js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], - - "@babel/helper-replace-supers/@babel/traverse/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0", "", {}, "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg=="], - - "@babel/helper-replace-supers/@babel/traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.2", "", {}, "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA=="], - - "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.2", "", {}, "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA=="], - - "@babel/helper-skip-transparent-expression-wrappers/@babel/traverse/@babel/code-frame/js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], - "@cloudflare/vite-plugin/miniflare/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260415.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-dsxaKsQm3LnPGNPEdsRv09QN3Y4DqCw7kX5j6noKqbAtro2jTr95sVlYM1jUxZ5FkOl1f7SXgaKKB9t5H5Nkbg=="], "@cloudflare/vite-plugin/miniflare/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260415.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+JgSgVA49KyKteHRA1SnonE4Zn5Ei5zdAp5FQMxFmXI8qulZw4Hl7safXxRyK4i9sTO8gl7TFOKO5Q64VPvSDQ=="], @@ -7786,12 +7570,6 @@ "@tanstack/router-plugin/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], - "agents/yargs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - - "agents/yargs/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], - - "agents/yargs/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - "astro/@clack/prompts/fast-string-width/fast-string-truncated-width": ["fast-string-truncated-width@1.2.1", "", {}, "sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow=="], "dir-compare/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], @@ -7832,10 +7610,6 @@ "@react-grab/cli/ora/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], - "agents/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - - "agents/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "temp/rimraf/glob/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], "@executor-js/motel/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], diff --git a/e2e/cloud/mcp-modern-protocol.test.ts b/e2e/cloud/mcp-modern-protocol.test.ts new file mode 100644 index 0000000000..752c014472 --- /dev/null +++ b/e2e/cloud/mcp-modern-protocol.test.ts @@ -0,0 +1,235 @@ +import { randomBytes, randomUUID } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { isInputRequiredResult } from "@modelcontextprotocol/client"; + +import { scenario } from "../src/scenario"; +import { Api, Mcp, Target } from "../src/services"; +import { + callModernToolWithInputRequired, + connectModernMcpClient, + MODERN_MCP_PROTOCOL_VERSION, + modernToolText, + readModernMcpAuthChallenge, +} from "../src/surfaces/modern-mcp"; +import type { Identity } from "../src/target"; + +const coreApi = composePluginApi([] as const); +const LEGACY_PROTOCOL_VERSION = "2025-03-26"; +const JSON_AND_SSE = "application/json, text/event-stream"; + +const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label; + +const legacyInitialize = { + jsonrpc: "2.0" as const, + id: 1, + method: "initialize", + params: { + protocolVersion: LEGACY_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: "executor-modern-e2e-control", version: "1.0.0" }, + }, +}; + +const postLegacy = ( + url: string, + body: unknown, + options?: { readonly bearer?: string; readonly sessionId?: string }, +): Promise => + fetch(url, { + method: "POST", + headers: { + accept: JSON_AND_SSE, + "content-type": "application/json", + "mcp-protocol-version": LEGACY_PROTOCOL_VERSION, + ...(options?.bearer ? { authorization: `Bearer ${options.bearer}` } : {}), + ...(options?.sessionId ? { "mcp-session-id": options.sessionId } : {}), + }, + body: JSON.stringify(body), + }); + +scenario( + "MCP modern protocol · a pinned 2026 client discovers, lists, and executes while legacy isolation stays intact", + {}, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const mcp = yield* Mcp; + const identity = yield* target.newIdentity(); + const bearer = yield* mcp.mintBearer(emailOf(identity)); + const client = yield* connectModernMcpClient({ + url: target.mcpUrl, + bearer, + mode: { pin: MODERN_MCP_PROTOCOL_VERSION }, + }); + + expect(client.getProtocolEra(), "the pinned client selected the modern era").toBe("modern"); + expect(client.getNegotiatedProtocolVersion(), "the exact revision was selected").toBe( + MODERN_MCP_PROTOCOL_VERSION, + ); + expect( + client.getDiscoverResult()?.supportedVersions, + "server/discover advertises the pinned revision", + ).toContain(MODERN_MCP_PROTOCOL_VERSION); + + const tools = yield* Effect.promise(() => client.listTools()); + expect( + tools.tools.map((tool) => tool.name), + "the modern catalog advertises Executor's execute tool", + ).toContain("execute"); + + const result = yield* Effect.promise(() => + client.callTool({ name: "execute", arguments: { code: "return 6 * 7;" } }), + ); + expect(result.isError, "the modern execute call completes successfully").not.toBe(true); + expect(modernToolText(result), "the sandbox result crosses the modern wire").toBe("42"); + + const foreignSession = yield* Effect.promise(() => + postLegacy( + target.mcpUrl, + { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }, + { bearer, sessionId: `foreign-${randomUUID()}` }, + ), + ); + expect( + foreignSession.status, + "a foreign legacy session id remains a clean not-found response", + ).toBe(404); + const foreignBody = (yield* Effect.promise(() => foreignSession.json())) as { + readonly error?: { readonly code?: number; readonly message?: string }; + }; + expect(foreignBody.error?.code, "the legacy rejection remains a JSON-RPC error").toBe(-32001); + expect( + foreignBody.error?.message, + "the unknown legacy session stays a clean not-found error", + ).toBe("Session not found"); + + const [modernChallenge, legacyChallenge] = yield* Effect.all([ + readModernMcpAuthChallenge(target.mcpUrl), + Effect.promise(() => postLegacy(target.mcpUrl, legacyInitialize)), + ]); + expect(modernChallenge.status, "the unauthenticated modern probe is challenged").toBe(401); + expect(legacyChallenge.status, "the unauthenticated legacy initialize is challenged").toBe( + 401, + ); + expect( + modernChallenge.wwwAuthenticate, + "modern and legacy entry paths publish the same Bearer challenge", + ).toBe(legacyChallenge.headers.get("www-authenticate")); + }), + ), +); + +scenario( + "MCP modern protocol · a default v2 auto client probes Executor and selects modern", + {}, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const mcp = yield* Mcp; + const identity = yield* target.newIdentity(); + const bearer = yield* mcp.mintBearer(emailOf(identity)); + const client = yield* connectModernMcpClient({ + url: target.mcpUrl, + bearer, + mode: "auto", + }); + + expect(client.getProtocolEra(), "auto negotiation selected the modern path").toBe("modern"); + expect(client.getNegotiatedProtocolVersion(), "auto selected the current revision").toBe( + MODERN_MCP_PROTOCOL_VERSION, + ); + expect( + client.getDiscoverResult()?.supportedVersions, + "the probe result records the server's modern offer", + ).toContain(MODERN_MCP_PROTOCOL_VERSION); + expect( + (yield* Effect.promise(() => client.listTools())).tools.map((tool) => tool.name), + "the selected path is usable", + ).toContain("execute"); + }), + ), +); + +scenario( + "MCP modern protocol · native input_required resumes an approval-gated execution", + {}, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const { client: makeApiClient } = yield* Api; + const mcp = yield* Mcp; + const identity = yield* target.newIdentity(); + const api = yield* makeApiClient(coreApi, identity); + const bearer = yield* mcp.mintBearer(emailOf(identity)); + const pattern = `modern-native-${randomBytes(4).toString("hex")}.*`; + const code = ` +const result = await tools.executor.coreTools.policies.create({ + owner: "user", + pattern: ${JSON.stringify(pattern)}, + action: "block", +}); +return JSON.stringify(result); +`; + + const cleanup = api.policies.list().pipe( + Effect.flatMap((policies) => + Effect.forEach( + policies.filter((policy) => policy.pattern === pattern), + (policy) => + api.policies + .remove({ params: { policyId: policy.id }, payload: { owner: "user" } }) + .pipe(Effect.ignore), + ), + ), + Effect.ignore, + ); + + yield* Effect.gen(function* () { + const nativeUrl = new URL(target.mcpUrl); + nativeUrl.searchParams.set("elicitation_mode", "native"); + const client = yield* connectModernMcpClient({ + url: nativeUrl.toString(), + bearer, + mode: { pin: MODERN_MCP_PROTOCOL_VERSION }, + manualInputRequired: true, + }); + + const first = yield* callModernToolWithInputRequired(client, { + name: "execute", + arguments: { code }, + }); + expect(isInputRequiredResult(first), "the gated action requests native input").toBe(true); + if (!isInputRequiredResult(first)) return; + expect( + first.inputRequests?.elicitation, + "the pause carries an elicitation request", + ).toMatchObject({ method: "elicitation/create" }); + expect(first.requestState, "the continuation state is opaque and present").toEqual( + expect.any(String), + ); + + const completed = yield* callModernToolWithInputRequired(client, { + name: "execute", + arguments: { code }, + inputResponses: { elicitation: { action: "accept", content: {} } }, + requestState: first.requestState, + }); + expect(isInputRequiredResult(completed), "the accepted round completes").toBe(false); + if (isInputRequiredResult(completed)) return; + expect(completed.isError, "the resumed execution succeeds").not.toBe(true); + expect( + modernToolText(completed), + "the tool result returns after the second round", + ).toContain('"ok":true'); + + expect( + (yield* api.policies.list()).map((policy) => policy.pattern), + "the approved side effect ran", + ).toContain(pattern); + }).pipe(Effect.ensuring(cleanup)); + }), + ), +); diff --git a/e2e/local/cli-mcp-protocol.test.ts b/e2e/local/cli-mcp-protocol.test.ts new file mode 100644 index 0000000000..144883d1fc --- /dev/null +++ b/e2e/local/cli-mcp-protocol.test.ts @@ -0,0 +1,117 @@ +// Regression for #1449: a modern MCP client starts stdio negotiation with +// `server/discover`, before `initialize`. This crosses the real CLI bridge: +// +// v2/v1 stdio client -> `executor mcp` -> local daemon HTTP MCP endpoint +// +// It reconnects with the v1 SDK too, proving discovery forwarding does not +// regress established legacy stdio clients. +import { expect } from "@effect/vitest"; +import { Client as ModernClient } from "@modelcontextprotocol/client"; +import { StdioClientTransport as ModernStdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { Client as LegacyClient } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport as LegacyStdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { Effect } from "effect"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { scenario } from "../src/scenario"; + +const repoRoot = fileURLToPath(new URL("../../", import.meta.url)); +const testScope = join(repoRoot, "apps/local"); + +const bridgeCommand = (dataDir: string) => ({ + command: "bun", + args: ["run", "dev:cli", "mcp", "--scope", testScope], + cwd: repoRoot, + env: { + ...process.env, + EXECUTOR_DATA_DIR: dataDir, + EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1", + } as Record, + stderr: "pipe" as const, +}); + +const stopAutoSpawnedDaemon = (dataDir: string): void => { + // The bridge is transient, while its auto-started daemon is detached. Reap + // that owner before deleting this scenario's private data directory. + // oxlint-disable-next-line executor/no-try-catch-or-throw -- cleanup tolerates a bridge that failed before writing its manifest + try { + const manifest = JSON.parse( + readFileSync(join(dataDir, "server-control", "server.json"), "utf8"), + ) as { readonly pid?: number }; + if (manifest.pid) process.kill(manifest.pid, "SIGTERM"); + } catch { + // No manifest means there is no auto-started daemon to stop. + } +}; + +const withTempData = Effect.acquireRelease( + Effect.sync(() => { + const root = mkdtempSync(join(tmpdir(), "executor-mcp-protocol-")); + return { root, dataDir: join(root, "data") }; + }), + ({ root, dataDir }) => + Effect.sync(() => { + stopAutoSpawnedDaemon(dataDir); + rmSync(root, { recursive: true, force: true }); + }), +); + +scenario( + "Local CLI MCP · modern discovery and legacy initialize cross the stdio bridge", + { timeout: 240_000 }, + Effect.gen(function* () { + const { dataDir } = yield* withTempData; + + const modernTransport = new ModernStdioClientTransport(bridgeCommand(dataDir)); + const modernClient = new ModernClient( + { name: "executor-cli-modern-e2e", version: "1.0.0" }, + { versionNegotiation: { mode: "auto" } }, + ); + + yield* Effect.promise(async () => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: always reap the real CLI child when an assertion fails + try { + await modernClient.connect(modernTransport); + expect(modernClient.getProtocolEra()).toBe("modern"); + expect((await modernClient.listTools()).tools.map(({ name }) => name)).toContain("execute"); + const executed = await modernClient.callTool({ + name: "execute", + arguments: { code: "return 42" }, + }); + expect(executed.structuredContent).toMatchObject({ + status: "completed", + result: 42, + }); + } finally { + await modernClient.close(); + } + }); + + const legacyTransport = new LegacyStdioClientTransport(bridgeCommand(dataDir)); + const legacyClient = new LegacyClient({ + name: "executor-cli-legacy-e2e", + version: "1.0.0", + }); + + yield* Effect.promise(async () => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: always reap the real CLI child when an assertion fails + try { + await legacyClient.connect(legacyTransport); + expect((await legacyClient.listTools()).tools.map(({ name }) => name)).toContain("execute"); + const executed = await legacyClient.callTool({ + name: "execute", + arguments: { code: "return 42" }, + }); + expect(executed.structuredContent).toMatchObject({ + status: "completed", + result: 42, + }); + } finally { + await legacyClient.close(); + } + }); + }).pipe(Effect.scoped), +); diff --git a/e2e/package.json b/e2e/package.json index 969f027352..679c9a9f9e 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -30,7 +30,10 @@ "@executor-js/plugin-toolkits": "workspace:*", "@executor-js/sdk": "workspace:*", "@kitlangton/terminal-control": "^0.3.0", + "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/core": "2.0.0", "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/server": "2.0.0", "asciinema-player": "^3.15.1", "effect": "catalog:", "monaco-editor": "^0.55.1", diff --git a/e2e/scenarios/mcp-modern-only-server.test.ts b/e2e/scenarios/mcp-modern-only-server.test.ts new file mode 100644 index 0000000000..4e4eb71934 --- /dev/null +++ b/e2e/scenarios/mcp-modern-only-server.test.ts @@ -0,0 +1,131 @@ +import { randomBytes } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; + +import { serveModernOnlyMcp } from "../src/fixtures/modern-only-mcp"; +import { scenario } from "../src/scenario"; +import { Api, Target } from "../src/services"; + +const api = composePluginApi([mcpHttpPlugin()] as const); +const LEGACY_PROTOCOL_VERSION = "2025-03-26"; + +const legacyInitialize = { + jsonrpc: "2.0" as const, + id: 1, + method: "initialize", + params: { + protocolVersion: LEGACY_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: "executor-e2e-negative-control", version: "1.0.0" }, + }, +}; + +scenario( + "MCP outbound · Executor discovers and invokes a modern-only server that rejects legacy", + {}, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const { client: makeApiClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeApiClient(api, identity); + const fixture = yield* serveModernOnlyMcp(); + const slug = IntegrationSlug.make(`modern_only_${randomBytes(4).toString("hex")}`); + const connectionName = ConnectionName.make("main"); + + const legacy = yield* Effect.promise(() => + fetch(fixture.url, { + method: "POST", + headers: { + accept: "application/json, text/event-stream", + "content-type": "application/json", + }, + body: JSON.stringify(legacyInitialize), + }), + ); + expect(legacy.status, "the fixture rejects the legacy handshake on the wire").toBe(400); + expect( + yield* Effect.promise(() => legacy.json()), + "the negative control is the SDK's unsupported-version error", + ).toEqual({ + jsonrpc: "2.0", + id: 1, + error: { + code: -32022, + message: `Unsupported protocol version: ${LEGACY_PROTOCOL_VERSION}`, + data: { + requested: LEGACY_PROTOCOL_VERSION, + supported: ["2026-07-28"], + }, + }, + }); + + yield* client.mcp.addServer({ + payload: { + transport: "remote", + name: "Modern-only MCP", + endpoint: fixture.url, + slug: String(slug), + remoteTransport: "streamable-http", + }, + }); + + yield* Effect.gen(function* () { + yield* client.connections.create({ + payload: { + owner: "org", + name: connectionName, + integration: slug, + template: AuthTemplateSlug.make("none"), + value: "", + }, + }); + + const catalog = yield* client.tools.list({ query: { integration: slug } }); + expect( + catalog.map((tool) => String(tool.name)).sort(), + "catalog sync discovered both modern-only tools", + ).toEqual(["modern_identity", "modern_ping"]); + + const executed = yield* client.executions.execute({ + payload: { + code: ` +const result = await tools.${String(slug)}.org.main.modern_ping({}); +return { ok: result.ok, value: result.ok ? result.data : result.error }; +`, + autoApprove: true, + }, + }); + expect(executed.status, "the invocation completed through Executor").toBe("completed"); + const outcome = JSON.parse(executed.text) as { + readonly ok?: boolean; + readonly value?: unknown; + }; + expect(outcome.ok, `modern-only invocation result: ${executed.text}`).toBe(true); + expect( + JSON.stringify(outcome.value), + "the modern-only server's tool result returns through the sandbox", + ).toContain("pong-modern"); + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + yield* client.connections + .remove({ + params: { + owner: "org", + integration: slug, + name: connectionName, + }, + }) + .pipe(Effect.ignore); + yield* client.mcp.removeServer({ params: { slug } }).pipe(Effect.ignore); + }), + ), + ); + }), + ), +); diff --git a/e2e/selfhost/mcp-modern-protocol.test.ts b/e2e/selfhost/mcp-modern-protocol.test.ts new file mode 100644 index 0000000000..88f2e60f7d --- /dev/null +++ b/e2e/selfhost/mcp-modern-protocol.test.ts @@ -0,0 +1,52 @@ +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; +import { Mcp, Target } from "../src/services"; +import { + connectModernMcpClient, + MODERN_MCP_PROTOCOL_VERSION, + modernToolText, +} from "../src/surfaces/modern-mcp"; +import type { Identity } from "../src/target"; + +const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label; + +scenario( + "MCP modern protocol · self-host accepts a pinned 2026 client end to end", + {}, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const mcp = yield* Mcp; + const identity = yield* target.newIdentity(); + const bearer = yield* mcp.mintBearer(emailOf(identity)); + const client = yield* connectModernMcpClient({ + url: target.mcpUrl, + bearer, + mode: { pin: MODERN_MCP_PROTOCOL_VERSION }, + }); + + expect(client.getProtocolEra(), "the self-host endpoint selects modern").toBe("modern"); + expect(client.getNegotiatedProtocolVersion(), "the pinned revision is exact").toBe( + MODERN_MCP_PROTOCOL_VERSION, + ); + expect( + client.getDiscoverResult()?.supportedVersions, + "self-host server/discover offers the pinned revision", + ).toContain(MODERN_MCP_PROTOCOL_VERSION); + + const tools = yield* Effect.promise(() => client.listTools()); + expect( + tools.tools.map((tool) => tool.name), + "self-host advertises Executor's tools over modern MCP", + ).toContain("execute"); + + const result = yield* Effect.promise(() => + client.callTool({ name: "execute", arguments: { code: "return 20 + 22;" } }), + ); + expect(result.isError, "the self-host modern call succeeds").not.toBe(true); + expect(modernToolText(result), "the sandbox result crosses the modern wire").toBe("42"); + }), + ), +); diff --git a/e2e/setup/cloud.boot.ts b/e2e/setup/cloud.boot.ts index 458e0f3687..3c072a2f4b 100644 --- a/e2e/setup/cloud.boot.ts +++ b/e2e/setup/cloud.boot.ts @@ -90,6 +90,7 @@ export const bootCloud = async (options: CloudBootOptions): Promise // The AuthKit domain (MCP OAuth metadata + JWKS) is the emulator too. MCP_AUTHKIT_DOMAIN: workosUrl, MCP_RESOURCE_ORIGIN: options.publicUrl, + MCP_REQUEST_STATE_KEY: "e2e-mcp-request-state-key-0123456789abcdef", MCP_SESSION_TIMEOUT_MS: process.env.MCP_SESSION_TIMEOUT_MS, MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS: process.env.MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS, ALLOW_LOCAL_NETWORK: "true", diff --git a/e2e/setup/cloudflare.boot.ts b/e2e/setup/cloudflare.boot.ts index df58a54b7c..b883084f28 100644 --- a/e2e/setup/cloudflare.boot.ts +++ b/e2e/setup/cloudflare.boot.ts @@ -48,6 +48,8 @@ export const bootCloudflare = async (options: CloudflareBootOptions): Promise { + const headers = new Headers(); + for (const [name, value] of Object.entries(source)) { + if (Array.isArray(value)) { + for (const item of value) headers.append(name, item); + } else if (value !== undefined) { + headers.set(name, value); + } + } + return headers; +}; + +const requestBody = (request: IncomingMessage): Promise => + new Promise((resolve) => { + const chunks: Uint8Array[] = []; + request.on("data", (chunk: Uint8Array) => chunks.push(chunk)); + request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + }); + +const makeServer = (): McpServer => { + const server = new McpServer({ name: "executor-modern-only-e2e", version: "1.0.0" }); + server.registerTool( + "modern_ping", + { description: "Answers from a modern-only MCP server" }, + async () => ({ content: [{ type: "text", text: "pong-modern" }] }), + ); + server.registerTool( + "modern_identity", + { description: "Names the fixture's protocol posture" }, + async () => ({ content: [{ type: "text", text: "modern-only" }] }), + ); + return server; +}; + +/** + * Serve a real v2 MCP handler that rejects every legacy-classified request. + * The fixture follows the e2e suite's scoped, ephemeral localhost convention. + */ +export const serveModernOnlyMcp = (): Effect.Effect => + Effect.acquireRelease( + Effect.callback Promise }>((resume) => { + const handler = createMcpHandler(makeServer, { legacy: "reject" }); + const server = createServer((incoming, outgoing) => { + void requestBody(incoming).then(async (body) => { + const host = incoming.headers.host ?? "127.0.0.1"; + const request = new Request(new URL(incoming.url ?? "/", `http://${host}`), { + method: incoming.method, + headers: requestHeaders(incoming.headers), + ...(incoming.method === "GET" || incoming.method === "HEAD" ? {} : { body }), + }); + const response = await handler.fetch(request); + outgoing.writeHead(response.status, Object.fromEntries(response.headers)); + outgoing.end(Buffer.from(await response.arrayBuffer())); + }); + }); + + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + resume( + Effect.succeed({ + url: `http://127.0.0.1:${port}/mcp`, + close: async () => { + server.close(); + server.closeAllConnections(); + await handler.close(); + }, + }), + ); + }); + }), + (fixture) => Effect.promise(fixture.close).pipe(Effect.ignore), + ); diff --git a/e2e/src/surfaces/modern-mcp.ts b/e2e/src/surfaces/modern-mcp.ts new file mode 100644 index 0000000000..720f5605f3 --- /dev/null +++ b/e2e/src/surfaces/modern-mcp.ts @@ -0,0 +1,112 @@ +import { Effect } from "effect"; +import { + Client, + StreamableHTTPClientTransport, + withInputRequired, + type InputRequiredResult, + type Request as McpRequest, +} from "@modelcontextprotocol/client"; +import { CallToolResultSchema } from "@modelcontextprotocol/core"; + +/** The first MCP revision served through the modern per-request protocol. */ +export const MODERN_MCP_PROTOCOL_VERSION = "2026-07-28"; + +/** Negotiation postures covered by the modern e2e client. */ +export type ModernMcpNegotiationMode = + | "auto" + | { readonly pin: typeof MODERN_MCP_PROTOCOL_VERSION }; + +/** Connection inputs for a scoped modern MCP client. */ +export type ModernMcpClientOptions = { + readonly url: string; + readonly bearer: string; + readonly mode: ModernMcpNegotiationMode; + readonly manualInputRequired?: boolean; +}; + +const makeClient = (mode: ModernMcpNegotiationMode, manualInputRequired: boolean): Client => + new Client( + { name: "executor-modern-e2e", version: "1.0.0" }, + { + capabilities: { elicitation: { form: {} } }, + versionNegotiation: { mode }, + ...(manualInputRequired ? { inputRequired: { autoFulfill: false } } : {}), + }, + ); + +/** + * Connect a real v2 MCP client and close it when the surrounding Effect scope + * ends. The caller selects pinned or auto negotiation explicitly. + */ +export const connectModernMcpClient = (options: ModernMcpClientOptions) => + Effect.acquireRelease( + Effect.promise(async () => { + const client = makeClient(options.mode, options.manualInputRequired ?? false); + const transport = new StreamableHTTPClientTransport(new URL(options.url), { + requestInit: { headers: { authorization: `Bearer ${options.bearer}` } }, + }); + await client.connect(transport); + return client; + }), + (client) => Effect.promise(() => client.close()).pipe(Effect.ignore), + ); + +/** + * Issue a modern tool call in manual input-required mode, returning either the + * completed tool result or the server's next input request. + */ +export const callModernToolWithInputRequired = ( + client: Client, + params: Record, +): Effect.Effect>> => { + const request: McpRequest = { method: "tools/call", params }; + return Effect.promise(() => + client.request(request, withInputRequired(CallToolResultSchema), { + allowInputRequired: true, + }), + ); +}; + +/** Join the text content returned by an MCP tool call. */ +export const modernToolText = (result: Awaited>): string => + result.content + .filter((part) => part.type === "text") + .map((part) => part.text) + .join("\n"); + +/** The authentication response observed during an unauthenticated modern probe. */ +export type ModernMcpAuthChallenge = { + readonly status: number | undefined; + readonly wwwAuthenticate: string | null; +}; + +/** + * Drive the real pinned-modern probe without credentials and capture the HTTP + * authentication challenge that prevented the connection. + */ +export const readModernMcpAuthChallenge = (url: string): Effect.Effect => + Effect.promise(async () => { + let challenge: ModernMcpAuthChallenge = { + status: undefined, + wwwAuthenticate: null, + }; + const client = makeClient({ pin: MODERN_MCP_PROTOCOL_VERSION }, false); + const transport = new StreamableHTTPClientTransport(new URL(url), { + fetch: async (input, init) => { + const response = await fetch(input, init); + if (response.status === 401) { + challenge = { + status: response.status, + wwwAuthenticate: response.headers.get("www-authenticate"), + }; + } + return response; + }, + }); + await client.connect(transport).then( + () => undefined, + () => undefined, + ); + await client.close(); + return challenge; + }); diff --git a/package.json b/package.json index 6f8ab54ab4..f3ca8e7237 100644 --- a/package.json +++ b/package.json @@ -135,7 +135,6 @@ "@1password/sdk-core@0.4.1-beta.1": "patches/@1password%2Fsdk-core@0.4.1-beta.1.patch", "postgres@3.4.9": "patches/postgres@3.4.9.patch", "@cloudflare/vite-plugin@1.31.2": "patches/@cloudflare%2Fvite-plugin@1.31.2.patch", - "agents@0.17.3": "patches/agents@0.17.3.patch", "libsql@0.5.29": "patches/libsql@0.5.29.patch", "@electric-sql/pglite-socket@0.1.4": "patches/@electric-sql%2Fpglite-socket@0.1.4.patch" } diff --git a/packages/core/api/src/server.ts b/packages/core/api/src/server.ts index 104d841c39..5425e1d8c7 100644 --- a/packages/core/api/src/server.ts +++ b/packages/core/api/src/server.ts @@ -59,6 +59,7 @@ export { export { makeMcpBuildServer, makeConsoleMcpErrorReporter, + type McpBuildServer, type McpExecutionStackLayer, } from "./server/mcp-build"; // Host-composition seams re-homed out of `@executor-js/sdk` (the plugin-author diff --git a/packages/core/api/src/server/executor-app.ts b/packages/core/api/src/server/executor-app.ts index 382e72efe1..01cf3f933a 100644 --- a/packages/core/api/src/server/executor-app.ts +++ b/packages/core/api/src/server/executor-app.ts @@ -15,8 +15,8 @@ // + that stack + plugin tuple + failure strategy) (auth + per-request executor) // 3. the protected (plugin) API = makeProtectedApiLayer(plugins, { errorCapture, // router: prefixed(mountPrefix) }) wrapped by (2) -// 4. the MCP serving envelope = McpServingRoutes + the 2-3 seams (auth/sessions -// /reporter), double-provided like the host did (the seams) +// 4. the MCP serving envelope = McpServingRoutes + the auth/session/modern +// builder/reporter seams (the seams) // 5. the account API = makeAccountApiLayer(accountMiddleware, { router }) // 6. each extensions.route (Better Auth handler, Swagger, marketing, /autumn) // 7. provideMerge(boot) (+ optional requestScoped) -> the AppLayer @@ -53,6 +53,7 @@ import { McpErrorReporterNoop, type McpAuthProvider, type McpErrorReporter, + type McpModernServerBuilder, type McpSessionStore, } from "@executor-js/host-mcp"; @@ -132,19 +133,32 @@ export interface EngineProviders { * identity fallback sets `RMcpAuth = IdentityProvider` (self-host) and one whose * MCP plane is a separate credential surface leaves it `never` (cloud). */ -export interface McpProviders { +interface McpProviderBase { /** Resolve a request to an MCP `AuthOutcome` + declare the discovery routes. */ readonly auth: Layer.Layer; - /** - * Owns the entire serving-session lifecycle (in-process Map vs DO). Optional: - * a host that serves `/mcp` transport outside this envelope (the Cloudflare - * Agent bridge) omits it, and only the discovery routes are mounted. - */ - readonly sessions?: Layer.Layer; /** Forward an orchestration defect to the host's capture; default no-op. */ readonly reporter?: Layer.Layer; } +/** MCP providers either serve discovery alone or provide both protocol eras. */ +export type McpProviders = McpProviderBase & + ( + | { + /** + * Owns the legacy serving-session lifecycle (in-process Map vs DO). + */ + readonly sessions: Layer.Layer; + /** Builds one stateless MCP server for each modern request. */ + readonly modern: Layer.Layer; + } + | { + /** Omitted when another platform surface serves `/mcp` transport. */ + readonly sessions?: undefined; + /** Discovery-only providers do not construct modern servers here. */ + readonly modern?: undefined; + } + ); + /** * The provider seams common to BOTH execution models (scoped + fixed): identity, * the optional account API, the optional MCP envelope, and error capture. The @@ -546,12 +560,12 @@ export const make = < : pluginApiLive; // ---- (4) the MCP serving envelope (optional) -------------------------- - // The two providers, by design (mirrors makeSelfHostMcp): + // The serving providers, by design (mirrors makeSelfHostMcp): // - `Layer.provide(mcpAuth)` satisfies the `HttpRouter.use` callback's // build-time `McpAuthProvider` requirement (it registers a GET per // provider-declared discovery path). // - `HttpRouter.provideRequest(McpSeams)` clears the route handlers' - // per-request `Requires` markers (auth + session store + reporter) so the + // per-request `Requires` markers (auth + both eras + reporter) so the // /mcp routes carry no leftover requirements when merged into the router. // The auth seam may require the neutral `IdentityProvider` (`RMcpAuth = // IdentityProvider` for self-host, whose MCP auth genuinely reads the fallback; @@ -603,7 +617,7 @@ export const make = < }; /** - * Compose the MCP serving routes over the auth/sessions/reporter seams. The auth + * Compose the MCP serving routes over the auth/legacy/modern/reporter seams. The auth * seam may require the neutral `IdentityProvider` (`RMcpAuth`); the facade provides * the complete identity seam ONCE (memoized) and shares it across the build-time * `Layer.provide` AND the per-request `HttpRouter.provideRequest`, so a single @@ -625,10 +639,15 @@ const buildMcpRoutes = ( // No session store: the host serves `/mcp` transport elsewhere (the Cloudflare // Agent bridge), so mount only the auth-declared discovery routes. The discovery // handlers are captured from the auth seam at build, so no per-request seams. - if (!mcp.sessions) { + if (mcp.sessions === undefined) { return McpDiscoveryRoutes.pipe(Layer.provide(mcpAuthLive)); } - const mcpSeams = Layer.mergeAll(mcpAuthLive, mcp.sessions, mcp.reporter ?? McpErrorReporterNoop); + const mcpSeams = Layer.mergeAll( + mcpAuthLive, + mcp.sessions, + mcp.modern, + mcp.reporter ?? McpErrorReporterNoop, + ); return McpServingRoutes.pipe(HttpRouter.provideRequest(mcpSeams), Layer.provide(mcpAuthLive)); }; diff --git a/packages/core/api/src/server/mcp-build.ts b/packages/core/api/src/server/mcp-build.ts index 3b9302faca..2db2ced06e 100644 --- a/packages/core/api/src/server/mcp-build.ts +++ b/packages/core/api/src/server/mcp-build.ts @@ -1,12 +1,17 @@ import { Effect, Layer } from "effect"; -import { McpErrorReporter, type Principal } from "@executor-js/host-mcp"; +import { + McpErrorReporter, + type McpModernServerBuilder, + type McpModernServerBuildOptions, + type Principal, +} from "@executor-js/host-mcp"; import { McpEngineBuildError, - type McpBuildServer, - type McpBuildServerOptions, + type McpBuildServer as McpSessionBuildServer, + type McpBuildServerOptions as McpSessionBuildOptions, } from "@executor-js/host-mcp/in-memory-session-store"; -import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; +import { buildMcpServer } from "@executor-js/host-mcp/tool-server"; import { artifactUrlFor, type ArtifactSmokeRenderResult, @@ -20,14 +25,8 @@ import { HostConfig, PluginsProvider, RequestOrgSlug } from "./scoped-executor"; // --------------------------------------------------------------------------- // Shared in-process MCP host helpers. // -// Every host that serves MCP from one isolate (self-host, the Cloudflare QuickJS -// host) builds its per-session McpServer the same way — assemble the scoped -// engine via `makeExecutionStack`, wrap it with `createExecutorMcpServer` — and -// reports orchestration defects through the same console `ErrorCapture` seam. -// These two factories are the single home for that logic; a host supplies ONLY -// its fully-provided execution-stack layer and its `ErrorCapture` layer. The -// cross-isolate variant (cloud's Durable Object store) is the exception that -// builds its engine inside the DO. +// Neutral hosts build both sessionful legacy-wire connections and stateless +// modern requests from the same assembly over a scoped execution stack. // --------------------------------------------------------------------------- /** The five execution-stack seams a host fully provides (no residual). */ @@ -35,36 +34,55 @@ export type McpExecutionStackLayer = Layer.Layer< DbProvider | PluginsProvider | HostConfig | CodeExecutorProvider | EngineDecorator >; +type McpSessionBuildEffect = ReturnType; +type McpModernBuildEffect = ReturnType; + +/** A single build seam accepted by both the session store and modern envelope. */ +export interface McpBuildServer { + /** Build a connection-lifetime server and retain its engine for session-owned approvals. */ + (principal: Principal, options: McpSessionBuildOptions): McpSessionBuildEffect; + /** Build a stateless server for one modern request. */ + (principal: Principal, options: McpModernServerBuildOptions): McpModernBuildEffect; +} + /** - * Build the per-session MCP server factory over a host's execution stack: - * `makeExecutionStack` → engine → `createExecutorMcpServer`. Hosts differ only - * in the injected stack layer (libSQL vs D1, etc.). + * Build the unified MCP server factory over a host's execution stack. + * Session callers receive the server plus its approval-owning engine; modern + * request callers receive the server directly. */ -export const makeMcpBuildServer = - (executionStack: McpExecutionStackLayer, hostOptions?: McpBuildHostOptions): McpBuildServer => - (principal: Principal, options?: McpBuildServerOptions) => - Effect.gen(function* () { +export const makeMcpBuildServer = ( + executionStack: McpExecutionStackLayer, + hostOptions?: McpBuildHostOptions, +): McpBuildServer => { + function build(principal: Principal, options: McpSessionBuildOptions): McpSessionBuildEffect; + function build(principal: Principal, options: McpModernServerBuildOptions): McpModernBuildEffect; + function build( + principal: Principal, + options: McpSessionBuildOptions | McpModernServerBuildOptions, + ): Effect.Effect< + Effect.Success | Effect.Success, + Effect.Error | Effect.Error + > { + const { resource, ...serverOptions } = options; + return Effect.gen(function* () { const { engine, executor } = yield* makeExecutionStack( principal.accountId, principal.organizationId, principal.organizationName, - { mcpResource: options?.resource }, + { mcpResource: resource }, ).pipe(Effect.withSpan("mcp.execution_stack.build")); // Read inside the provided boundary: `webBaseUrl` is a host seam, and - // hosts that can't know their public URL at boot leave it unset — in - // which case artifacts still persist but carry no deep link. + // hosts that cannot know their public URL at boot leave it unset. const hostConfig = yield* HostConfig; return { engine, executor, webBaseUrl: hostConfig.webBaseUrl }; }).pipe( - // Pin browser-handoff URLs to the principal's org slug when present; - // absent slug leaves the service unprovided and the URL stays bare. principal.organizationSlug !== undefined ? Effect.provideService(RequestOrgSlug, { slug: principal.organizationSlug }) : (effect) => effect, Effect.provide(executionStack), Effect.mapError((cause) => new McpEngineBuildError({ cause })), Effect.flatMap(({ engine, executor, webBaseUrl }) => - createExecutorMcpServer({ + buildMcpServer({ engine, artifacts: executor.artifacts, connections: executor.connections, @@ -75,20 +93,20 @@ export const makeMcpBuildServer = ? { smokeRenderArtifact: hostOptions.smokeRenderArtifact } : {}), ...(hostOptions?.onArtifactUsage ? { onArtifactUsage: hostOptions.onArtifactUsage } : {}), - // Same org pinning as `RequestOrgSlug` above: self-host serves its - // console under `/` (`default` when unconfigured), so the - // deep link carries the principal's slug rather than relying on the - // browser's active org to canonicalize a bare path after landing. ...(webBaseUrl ? { artifactUrl: artifactUrlFor(webBaseUrl, principal.organizationSlug) } : {}), - ...(options ?? {}), + ...serverOptions, }).pipe( Effect.withSpan("mcp.server.create"), - Effect.map((mcpServer) => ({ mcpServer, engine })), + Effect.map((mcpServer) => ("sessionful" in options ? { mcpServer, engine } : mcpServer)), ), ), ); + } + + return build; +}; /** Per-host (not per-session) MCP wiring. Kept separate from * `McpBuildServerOptions`, which the session store fills in per request. */ diff --git a/packages/hosts/cloudflare/package.json b/packages/hosts/cloudflare/package.json index be36277590..26f3ed67eb 100644 --- a/packages/hosts/cloudflare/package.json +++ b/packages/hosts/cloudflare/package.json @@ -23,6 +23,10 @@ "./mcp/session-stub": { "types": "./src/mcp/session-stub.ts", "default": "./src/mcp/session-stub.ts" + }, + "./mcp/modern-request-router": { + "types": "./src/mcp/modern-request-router.ts", + "default": "./src/mcp/modern-request-router.ts" } }, "scripts": { @@ -36,7 +40,7 @@ "@executor-js/host-mcp": "workspace:*", "@executor-js/sdk": "workspace:*", "@modelcontextprotocol/sdk": "^1.29.0", - "agents": "^0.17.3", + "@modelcontextprotocol/server": "2.0.0", "effect": "catalog:" }, "devDependencies": { diff --git a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts index 8072df7e54..100068f7e8 100644 --- a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts +++ b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts @@ -1,485 +1,579 @@ -import { describe, expect, it } from "@effect/vitest"; +import { afterEach, describe, expect, it, vi } from "@effect/vitest"; import { Cause, Effect } from "effect"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; -import type { JSONRPCMessage, MessageExtraInfo } from "@modelcontextprotocol/sdk/types.js"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import type { ExecutionEngine } from "@executor-js/execution"; import { defaultMcpResource } from "@executor-js/host-mcp"; -import type { ExecutionEngine, ExecutionResult, ResumeResponse } from "@executor-js/execution"; +import { buildMcpServer, mcpRequestStatePrincipal } from "@executor-js/host-mcp/tool-server"; +import { withVerifiedIdentityHeaders } from "./do-headers"; import { McpAgentSessionDOBase, - type McpApprovalOwner, - type McpSessionModelResumeResult, + type BuiltMcpServer, + type McpSessionInit, type SessionMeta, } from "./agent-session-durable-object"; -class MemoryStorage { - private readonly data = new Map(); - alarm: number | undefined; +const SESSION_ID = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const ACCOUNT_ID = "acct_test"; +const ORGANIZATION_ID = "org_test"; +const REQUEST_STATE_KEY = "0123456789abcdef0123456789abcdef"; + +class MemoryStorage implements DurableObjectStorage, DurableObjectTransaction { + private readonly values = new Map(); + readonly sql = {} as DurableObjectStorage["sql"]; + readonly kv = {} as DurableObjectStorage["kv"]; + alarmAt: number | null = null; + + async get(key: string): Promise; + async get(keys: string[]): Promise>; + async get(keyOrKeys: string | string[]): Promise> { + if (Array.isArray(keyOrKeys)) { + return new Map(keyOrKeys.map((key) => [key, this.values.get(key) as T])); + } + return this.values.get(keyOrKeys) as T | undefined; + } - readonly sql = { - exec: () => [], - }; + async put(key: string, value: T): Promise; + async put(entries: Record | Map): Promise; + async put( + keyOrEntries: string | Record | Map, + value?: T, + ): Promise { + if (typeof keyOrEntries === "string") { + this.values.set(keyOrEntries, value); + return; + } + const entries = + keyOrEntries instanceof Map ? keyOrEntries.entries() : Object.entries(keyOrEntries); + for (const [key, entry] of entries) this.values.set(key, entry); + } - async get(key: string): Promise { - return this.data.get(key) as T | undefined; + async delete(key: string): Promise; + async delete(keys: string[]): Promise; + async delete(keyOrKeys: string | string[]): Promise { + if (!Array.isArray(keyOrKeys)) return this.values.delete(keyOrKeys); + let deleted = 0; + for (const key of keyOrKeys) { + if (this.values.delete(key)) deleted += 1; + } + return deleted; } - async put(key: string, value: unknown): Promise { - this.data.set(key, value); + async list(options: DurableObjectListOptions = {}): Promise> { + let keys = [...this.values.keys()] + .filter((key) => (options.prefix === undefined ? true : key.startsWith(options.prefix))) + .filter((key) => (options.start === undefined ? true : key >= options.start)) + .filter((key) => (options.startAfter === undefined ? true : key > options.startAfter)) + .sort(); + if (options.reverse) keys = keys.reverse(); + if (options.limit !== undefined) keys = keys.slice(0, options.limit); + return new Map(keys.map((key) => [key, this.values.get(key) as T])); } - async setAlarm(time: number | Date): Promise { - this.alarm = typeof time === "number" ? time : time.getTime(); + async deleteAll(): Promise { + this.values.clear(); + this.alarmAt = null; } - async deleteAlarm(): Promise { - this.alarm = undefined; + transaction(closure: (txn: DurableObjectTransaction) => Promise): Promise { + return closure(this); } - async delete(key: string | readonly string[]): Promise { - if (typeof key === "string") { - this.data.delete(key); - return; - } - for (const entry of key) { - this.data.delete(entry); - } + rollback(): void {} + + transactionSync(closure: () => T): T { + return closure(); } - async deleteAll(): Promise { - this.data.clear(); + async sync(): Promise {} + + async getAlarm(): Promise { + return this.alarmAt; } - async list( - options: { readonly prefix?: string; readonly limit?: number } = {}, - ): Promise> { - const rows = new Map(); - for (const [key, value] of this.data) { - if (options.prefix && !key.startsWith(options.prefix)) continue; - rows.set(key, value as T); - if (options.limit && rows.size >= options.limit) break; - } - return rows; + async setAlarm(scheduledTime: number | Date): Promise { + this.alarmAt = scheduledTime instanceof Date ? scheduledTime.getTime() : scheduledTime; } - async blockConcurrencyWhile(callback: () => T | Promise): Promise { - return callback(); + async deleteAlarm(): Promise { + this.alarmAt = null; } - get id(): { readonly name: string } { - return { name: "streamable-http:session-reconnect" }; + async getCurrentBookmark(): Promise { + return "test-bookmark"; } - get storage(): MemoryStorage { - return this; + async getBookmarkForTime(_timestamp: number | Date): Promise { + return "test-bookmark"; } - waitUntil(_promise: Promise): void {} + onNextSessionRestoreBookmark(_bookmark: string): Promise { + return Promise.resolve("test-bookmark"); + } } -type HarnessSession = { - alarm: () => Promise; - ctx: MemoryStorage; - dbHandle: { readonly end: () => void } | null; - engine: ExecutionEngine | null; - getConnections?: () => Iterable; - getSessionId: () => string; - initialized: boolean; - lastActivityMs: number; - maxPausedSessionIdleMs: () => number; - onStart: () => Promise; - pendingApprovalLeases: Map; - props: Record; - runMcpAgentOnStart: () => Promise; - server?: McpServer; - sessionMeta: SessionMeta; - sessionTimeoutMs: () => number; - resumeExecutionForModel: ( - executionId: string, - identity: McpApprovalOwner, - response: ResumeResponse, - ) => Promise; - validateMcpSessionOwner: (identity: { - readonly accountId: string; - readonly organizationId: string; - }) => Promise<"ok" | "not_found" | "forbidden" | "terminated">; -}; +class MemoryDurableObjectState implements DurableObjectState { + readonly id: DurableObjectId; + readonly props: unknown = undefined; + readonly facets = {} as DurableObjectState["facets"]; + readonly storage: MemoryStorage; + private waitUntilPromises: Promise[] = []; + abortedWith: string | undefined; + + constructor(storage = new MemoryStorage()) { + this.storage = storage; + const id: Pick = { + equals: (other) => other.toString() === SESSION_ID, + toString: () => SESSION_ID, + }; + this.id = id as DurableObjectId; + } -class StaleCloseTransport implements Transport { - onclose?: () => void; - onerror?: (error: Error) => void; - onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; + waitUntil(promise: Promise): void { + this.waitUntilPromises.push(promise); + } - async start(): Promise {} + async flushWaitUntil(): Promise { + while (this.waitUntilPromises.length > 0) { + const pending = this.waitUntilPromises.splice(0); + await Promise.all(pending); + } + } - async close(): Promise {} + blockConcurrencyWhile(callback: () => Promise): Promise { + return callback(); + } - async send(_message: JSONRPCMessage): Promise {} + acceptWebSocket(_ws: WebSocket, _tags?: string[]): void {} + getWebSockets(_tag?: string): WebSocket[] { + return []; + } + getTags(_ws: WebSocket): string[] { + return []; + } + setWebSocketAutoResponse(_pair?: WebSocketRequestResponsePair): void {} + getWebSocketAutoResponse(): WebSocketRequestResponsePair | null { + return null; + } + getWebSocketAutoResponseTimestamp(_ws: WebSocket): Date | null { + return null; + } + setHibernatableWebSocketEventTimeout(_timeoutMs?: number): void {} + getHibernatableWebSocketEventTimeout(): number | null { + return null; + } + abort(reason?: string): void { + this.abortedWith = reason; + } } -class RestoredTransport implements Transport { - onclose?: () => void; - onerror?: (error: Error) => void; - onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void; - - async start(): Promise {} +const engine: ExecutionEngine = { + execute: (code) => Effect.succeed({ result: code }), + executeWithPause: (code) => + Effect.succeed({ status: "completed" as const, result: { result: code } }), + resume: () => Effect.succeed(null), + isExecutionSettled: () => Effect.succeed(false), + getPausedExecution: () => Effect.succeed(null), + pausedExecutionCount: () => Effect.succeed(0), + hasPausedExecutions: () => Effect.succeed(false), + getDescription: Effect.succeed("test Durable Object executor"), +}; - async close(): Promise { - this.onclose?.(); +class HarnessSession extends McpAgentSessionDOBase< + Cloudflare.Env, + { readonly end: () => void | Promise } +> { + constructor( + ctx: DurableObjectState, + env: Cloudflare.Env, + private readonly sessionEngine: ExecutionEngine = engine, + private readonly runtimeOptions: { + readonly end?: () => void | Promise; + readonly sessionTimeoutMs?: number; + } = {}, + ) { + super(ctx, env); } - async send(_message: JSONRPCMessage): Promise {} -} - -const makeServer = () => new McpServer({ name: "executor-test", version: "1.0.0" }); + protected override openSessionDb(): { readonly end: () => void | Promise } { + return { end: this.runtimeOptions.end ?? (() => undefined) }; + } -const makeDeferred = (): { readonly promise: Promise; readonly resolve: () => void } => { - let resolve: () => void = () => undefined; - const promise = new Promise((settle) => { - resolve = settle; - }); - return { promise, resolve }; -}; + protected override sessionTimeoutMs(): number { + return this.runtimeOptions.sessionTimeoutMs ?? super.sessionTimeoutMs(); + } -type ResumeCall = { - readonly executionId: string; - readonly response: ResumeResponse; -}; + protected override resolveSessionMeta(token: McpSessionInit): Effect.Effect { + return Effect.succeed({ + organizationId: token.organizationId, + organizationName: "Test Org", + userId: token.userId, + elicitationMode: token.elicitationMode, + artifactsEnabled: token.artifactsEnabled, + resource: token.resource, + webOrigin: token.webOrigin, + }); + } -const completed = (result: unknown): ExecutionResult => ({ - status: "completed", - result: { result }, -}); + protected override buildMcpServer(sessionMeta: SessionMeta): Effect.Effect { + const elicitationMode = sessionMeta.elicitationMode ?? "model"; + return buildMcpServer({ + engine: this.sessionEngine, + appsEnabled: false, + restoredAppsEnabled: sessionMeta.appsEnabled, + onAppsEnabledChange: (appsEnabled) => this.persistAppsEnabled(appsEnabled), + requestStateSigningKey: REQUEST_STATE_KEY, + requestStatePrincipal: mcpRequestStatePrincipal({ + accountId: sessionMeta.userId, + organizationId: sessionMeta.organizationId, + }), + sessionful: true, + elicitationMode: + elicitationMode === "browser" + ? { mode: "browser", approvalUrl: () => "https://executor.test/approve" } + : { mode: elicitationMode }, + }).pipe(Effect.map((mcpServer) => ({ mcpServer, engine: this.sessionEngine }))); + } +} -const makeEngine = ( - resultForResume: (executionId: string, response: ResumeResponse) => ExecutionResult | null = () => - completed("resume-result"), -): { readonly calls: ResumeCall[]; readonly engine: ExecutionEngine } => { - const calls: ResumeCall[] = []; +const verifiedRequest = (request: Request): Request => + withVerifiedIdentityHeaders( + request, + { accountId: ACCOUNT_ID, organizationId: ORGANIZATION_ID }, + defaultMcpResource, + ); + +const makeClientHarness = (state = new MemoryDurableObjectState()) => { + let session = new HarnessSession(state, {} as Cloudflare.Env); + const fetch = async (input: string | URL | Request, init?: RequestInit): Promise => { + const request = + input instanceof Request ? new Request(input, init) : new Request(input.toString(), init); + return session.fetch(verifiedRequest(request)); + }; + const transport = new StreamableHTTPClientTransport( + new URL("https://executor.test/mcp?elicitation_mode=model"), + { fetch }, + ); + const client = new Client({ name: "legacy-do-client", version: "1.0.0" }); return { - calls, - engine: { - execute: () => Effect.succeed({ result: "execute-result" }), - executeWithPause: () => Effect.succeed(completed("execute-result")), - resume: (executionId, response) => - Effect.sync(() => { - calls.push({ executionId, response }); - return resultForResume(executionId, response); - }), - getPausedExecution: () => Effect.succeed(null), - pausedExecutionCount: () => Effect.succeed(0), - hasPausedExecutions: () => Effect.succeed(false), - getDescription: Effect.succeed("test engine"), + client, + state, + transport, + evict: () => { + session = new HarnessSession(state, {} as Cloudflare.Env); }, }; }; -const approval = { - action: "accept", - content: { approved: true }, -} satisfies ResumeResponse; - -const makeHarnessSession = async (): Promise => { - const sessionId = "session-reconnect"; - const sessionMeta: SessionMeta = { - organizationId: "org-1", - organizationName: "Org 1", - userId: "user-1", - resource: defaultMcpResource, - }; - const storage = new MemoryStorage(); - const server = makeServer(); - await server.connect(new StaleCloseTransport()); - - const session = Object.create(McpAgentSessionDOBase.prototype) as HarnessSession; - session.ctx = storage; - session.dbHandle = { end: () => undefined }; - session.engine = makeEngine().engine; - session.getSessionId = () => sessionId; - session.initialized = true; - session.lastActivityMs = Date.now() - 10; - session.maxPausedSessionIdleMs = () => 1_000; - session.pendingApprovalLeases = new Map(); - session.props = {}; - session.server = server; - session.sessionMeta = sessionMeta; - session.sessionTimeoutMs = () => 1; - session.runMcpAgentOnStart = async () => { - const restored = session.server ?? makeServer(); - session.server = restored; - await restored.connect(new RestoredTransport()); - session.engine = makeEngine().engine; - session.initialized = true; - }; - - return session; -}; - -// The negotiated MCP-Apps capability arrives once, at `initialize`, and lives -// in the rebuilt server's memory. These pin the storage round-trip that lets a -// cold-restored session rebuild with it instead of silently downgrading every -// artifact to a deep link. -describe("McpAgentSessionDOBase apps capability persistence", () => { - type CapabilitySession = HarnessSession & { - persistAppsEnabled: (appsEnabled: boolean) => Effect.Effect; - loadSessionMeta: () => Effect.Effect; - resolveSessionMeta: (token: unknown) => Effect.Effect; - resolveAndStoreSessionMeta: (token: unknown) => Effect.Effect; - }; - - const baseMeta: SessionMeta = { - organizationId: "org-1", - organizationName: "Org 1", - userId: "user-1", - resource: defaultMcpResource, - }; - - const makeCapabilitySession = async ( - stored: SessionMeta = baseMeta, - ): Promise<{ session: CapabilitySession; storage: MemoryStorage }> => { - const storage = new MemoryStorage(); - await storage.put("session-meta", stored); - const session = Object.create(McpAgentSessionDOBase.prototype) as CapabilitySession; - session.ctx = storage; - session.getSessionId = () => "session-caps"; - return { session, storage }; - }; - - it("persists the negotiated capability so a later restore can read it back", async () => { - const { session, storage } = await makeCapabilitySession(); - - await Effect.runPromise(session.persistAppsEnabled(true)); - - expect(await storage.get("session-meta")).toMatchObject({ - organizationId: "org-1", - appsEnabled: true, - }); - }); - - it("records a client that loses apps support just as durably", async () => { - const { session, storage } = await makeCapabilitySession({ ...baseMeta, appsEnabled: true }); - - await Effect.runPromise(session.persistAppsEnabled(false)); - - expect(await storage.get("session-meta")).toMatchObject({ appsEnabled: false }); +describe("McpAgentSessionDOBase session serving", () => { + afterEach(() => { + vi.restoreAllMocks(); }); - // `init` runs again on every cold restore and rebuilds meta from the bearer - // token, which carries no capabilities. If that overwrite won, restoring the - // session would erase the very bit meant to survive it. - it("carries the stored capability through the re-resolve on cold restore", async () => { - const { session, storage } = await makeCapabilitySession({ ...baseMeta, appsEnabled: true }); - // What the token resolves to: no `appsEnabled` anywhere in sight. - session.resolveSessionMeta = () => Effect.succeed(baseMeta); - - const resolved = await Effect.runPromise( - session.resolveAndStoreSessionMeta({ organizationId: "org-1", userId: "user-1" }), - ); - - expect(resolved.appsEnabled).toBe(true); - expect(await storage.get("session-meta")).toMatchObject({ appsEnabled: true }); + it("serves and reuses a legacy v1 SDK client through the Durable Object", async () => { + const harness = makeClientHarness(); + await harness.client.connect(harness.transport); + + // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always release the MCP client's streamed transport after assertions + try { + const tools = await harness.client.listTools(); + expect(tools.tools.map(({ name }) => name)).toContain("execute"); + + const result = await harness.client.callTool({ + name: "execute", + arguments: { code: "return 42" }, + }); + expect(result.content).toEqual([{ type: "text", text: "return 42" }]); + expect(harness.transport.sessionId).toBe(SESSION_ID); + } finally { + await harness.client.close(); + } }); - it("leaves a session with no negotiated capability untouched", async () => { - const { session, storage } = await makeCapabilitySession(); - session.resolveSessionMeta = () => Effect.succeed(baseMeta); - - const resolved = await Effect.runPromise( - session.resolveAndStoreSessionMeta({ organizationId: "org-1", userId: "user-1" }), - ); - - expect(resolved.appsEnabled).toBeUndefined(); - expect(await storage.get("session-meta")).not.toHaveProperty("appsEnabled"); + it("cold-restores the same v1 session without replaying initialize", async () => { + const harness = makeClientHarness(); + await harness.client.connect(harness.transport); + + // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always release the MCP client's streamed transport after assertions + try { + await harness.client.listTools(); + harness.evict(); + const tools = await harness.client.listTools(); + expect(tools.tools.map(({ name }) => name)).toContain("execute"); + } finally { + await harness.client.close(); + } }); - // Persistence is best-effort observation of a capability, never a reason to - // fail the session that was merely trying to render something. - it("stays silent when there is no stored meta to merge into", async () => { - const storage = new MemoryStorage(); - const session = Object.create(McpAgentSessionDOBase.prototype) as CapabilitySession; - session.ctx = storage; - session.getSessionId = () => "session-caps"; - - await expect(Effect.runPromise(session.persistAppsEnabled(true))).resolves.toBeUndefined(); - expect(await storage.get("session-meta")).toBeUndefined(); - }); -}); - -describe("McpAgentSessionDOBase transport restore", () => { - it("preserves hibernated response streams when a cold isolate starts", async () => { - const session = await makeHarnessSession(); - let closeCalls = 0; - - session.initialized = false; - session.engine = null; - session.dbHandle = null; - delete session.server; - session.getConnections = () => [ - { - close: () => { - closeCalls += 1; - }, - }, - ]; - session.runMcpAgentOnStart = async () => { - session.server = makeServer(); - session.engine = makeEngine().engine; - session.initialized = true; + it("primes a slow legacy tool stream and replays its result after disconnect", async () => { + let startExecution = (): void => undefined; + const executionStarted = new Promise((resolve) => { + startExecution = resolve; + }); + let finishExecution = (): void => undefined; + const executionResult = new Promise<{ readonly result: string }>((resolve) => { + finishExecution = () => resolve({ result: "slow result" }); + }); + const slowEngine: ExecutionEngine = { + ...engine, + execute: () => + Effect.promise(() => { + startExecution(); + return executionResult; + }), + executeWithPause: () => + Effect.promise(() => { + startExecution(); + return executionResult; + }).pipe(Effect.map((result) => ({ status: "completed" as const, result }))), }; - - await session.onStart(); - - expect(closeCalls).toBe(0); - expect(session.initialized).toBe(true); - }); - - it("closes response streams when an in-memory runtime restarts", async () => { - const session = await makeHarnessSession(); - let closeCalls = 0; - - session.getConnections = () => [ - { - close: () => { - closeCalls += 1; + const state = new MemoryDurableObjectState(); + const session = new HarnessSession(state, {} as Cloudflare.Env, slowEngine); + const post = (body: unknown, sessionId?: string): Request => + verifiedRequest( + new Request("https://executor.test/mcp?elicitation_mode=model", { + method: "POST", + headers: { + accept: "application/json, text/event-stream", + "content-type": "application/json", + ...(sessionId + ? { "mcp-session-id": sessionId, "mcp-protocol-version": "2025-06-18" } + : {}), + }, + body: JSON.stringify(body), + }), + ); + + const initialize = await session.fetch( + post({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "legacy-reconnect-test", version: "1.0.0" }, }, - }, - ]; - session.runMcpAgentOnStart = async () => { - session.server = makeServer(); - session.engine = makeEngine().engine; - session.initialized = true; - }; - - await session.onStart(); - - expect(closeCalls).toBe(1); - expect(session.initialized).toBe(true); - }); - - it("restores a same-session request after idle disposal leaves a stale server transport", async () => { - const session = await makeHarnessSession(); + }), + ); + expect(initialize.headers.get("mcp-session-id")).toBe(SESSION_ID); + await initialize.text(); + await session.fetch( + post({ jsonrpc: "2.0", method: "notifications/initialized", params: {} }, SESSION_ID), + ); - await session.alarm(); + const toolResponse = await session.fetch( + post( + { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { name: "execute", arguments: { code: "return slow" } }, + }, + SESSION_ID, + ), + ); + const reader = toolResponse.body?.getReader(); + const first = await reader?.read(); + const primingFrame = new TextDecoder().decode(first?.value); + expect(primingFrame).toContain("event: mcp-priming"); + const eventId = /^id: (.+)$/m.exec(primingFrame)?.[1]; + const replayEventId = eventId ?? ""; + expect(replayEventId).not.toBe(""); + await reader?.cancel("simulated network drop"); + + await executionStarted; + finishExecution(); + await Promise.resolve(); + await Promise.resolve(); - await expect( - session.validateMcpSessionOwner({ accountId: "user-1", organizationId: "org-1" }), - ).resolves.toBe("ok"); + const replay = await session.fetch( + verifiedRequest( + new Request("https://executor.test/mcp", { + method: "GET", + headers: { + accept: "text/event-stream", + "mcp-session-id": SESSION_ID, + "mcp-protocol-version": "2025-06-18", + "last-event-id": replayEventId, + }, + }), + ), + ); + const replayBody = await replay.text(); + expect(replayBody).toContain("slow result"); + expect(replayBody).toContain(`id: ${replayEventId.slice(0, replayEventId.lastIndexOf(":"))}:`); + + const standaloneReplay = await session.fetch( + verifiedRequest( + new Request("https://executor.test/mcp", { + method: "GET", + headers: { + accept: "text/event-stream", + "mcp-session-id": SESSION_ID, + "mcp-protocol-version": "2025-06-18", + }, + }), + ), + ); + const standaloneReplayBody = await standaloneReplay.text(); + expect(standaloneReplayBody).toContain("slow result"); + expect(standaloneReplayBody).toContain("event: message"); + await state.flushWaitUntil(); }); - it("single-flights concurrent same-session restore after idle disposal", async () => { - const session = await makeHarnessSession(); - const firstRestoreEntered = makeDeferred(); - const finishRestore = makeDeferred(); - let onStartCalls = 0; - let restoredServer: McpServer | undefined; - - session.runMcpAgentOnStart = async () => { - onStartCalls += 1; - const restored = session.server ?? makeServer(); - restoredServer ??= restored; - session.server = restored; - firstRestoreEntered.resolve(); - await finishRestore.promise; - await restored.connect(new RestoredTransport()); - session.initialized = true; - }; - - await session.alarm(); - - const first = session.validateMcpSessionOwner({ - accountId: "user-1", - organizationId: "org-1", + it("restores once while an idle runtime generation is still closing", async () => { + let now = 1_000; + vi.spyOn(Date, "now").mockImplementation(() => now); + let closeStarted = (): void => undefined; + const closing = new Promise((resolve) => { + closeStarted = resolve; }); - const second = session.validateMcpSessionOwner({ - accountId: "user-1", - organizationId: "org-1", + let finishClose = (): void => undefined; + const closeGate = new Promise((resolve) => { + finishClose = resolve; }); - - await firstRestoreEntered.promise; - await Promise.resolve(); - finishRestore.resolve(); - - await expect(Promise.all([first, second])).resolves.toEqual(["ok", "ok"]); - expect(onStartCalls).toBe(1); - expect(session.server).toBe(restoredServer); - }); - - it("single-flights SDK onStart callers with same-session restore", async () => { - const session = await makeHarnessSession(); - const firstStartEntered = makeDeferred(); - const finishStart = makeDeferred(); - let onStartCalls = 0; - - session.runMcpAgentOnStart = async () => { - onStartCalls += 1; - const restored = session.server ?? makeServer(); - session.server = restored; - firstStartEntered.resolve(); - await finishStart.promise; - await restored.connect(new RestoredTransport()); - session.initialized = true; - }; - - await session.alarm(); - - const restore = session.validateMcpSessionOwner({ - accountId: "user-1", - organizationId: "org-1", + let closeCount = 0; + const state = new MemoryDurableObjectState(); + const session = new HarnessSession(state, {} as Cloudflare.Env, engine, { + sessionTimeoutMs: 10, + end: () => { + closeCount += 1; + if (closeCount !== 1) return; + closeStarted(); + return closeGate; + }, }); - const sdkStart = session.onStart(); - - await firstStartEntered.promise; - await Promise.resolve(); - finishStart.resolve(); + const post = (body: unknown): Request => + verifiedRequest( + new Request("https://executor.test/mcp", { + method: "POST", + headers: { + accept: "application/json, text/event-stream", + "content-type": "application/json", + "mcp-session-id": SESSION_ID, + "mcp-protocol-version": "2025-06-18", + }, + body: JSON.stringify(body), + }), + ); + + const initialize = await session.fetch( + verifiedRequest( + new Request("https://executor.test/mcp", { + method: "POST", + headers: { + accept: "application/json, text/event-stream", + "content-type": "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: "initialize", + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "restore-race", version: "1.0.0" }, + }, + }), + }), + ), + ); + await initialize.text(); + await session.fetch(post({ jsonrpc: "2.0", method: "notifications/initialized", params: {} })); + + now += 100; + const alarm = session.alarm(); + await closing; + + const get = session.fetch( + verifiedRequest( + new Request("https://executor.test/mcp", { + method: "GET", + headers: { + accept: "text/event-stream", + "mcp-session-id": SESSION_ID, + "mcp-protocol-version": "2025-06-18", + }, + }), + ), + ); + const list = session.fetch( + post({ jsonrpc: "2.0", id: "concurrent-list", method: "tools/list", params: {} }), + ); - await expect(Promise.all([restore, sdkStart])).resolves.toEqual(["ok", undefined]); - expect(onStartCalls).toBe(1); + finishClose(); + const [getResponse, listResponse] = await Promise.all([get, list]); + expect(getResponse.status).toBe(200); + await getResponse.body?.cancel(); + expect(listResponse.status).toBe(200); + expect(await listResponse.text()).toContain("execute"); + await alarm; + await expect(state.storage.get("executor:mcp:v2:last-activity-ms")).resolves.toBe(now); + await expect(state.storage.getAlarm()).resolves.toBe(now + 10); + + const followUp = await session.fetch( + post({ jsonrpc: "2.0", id: "follow-up-list", method: "tools/list", params: {} }), + ); + expect(followUp.status).toBe(200); + expect(await followUp.text()).toContain("execute"); }); - it("single-flights model resume restore with SDK onStart", async () => { - const session = await makeHarnessSession(); - const firstStartEntered = makeDeferred(); - const finishStart = makeDeferred(); - const restoredEngine = makeEngine(() => completed("model-result")); - let onStartCalls = 0; - - session.runMcpAgentOnStart = async () => { - onStartCalls += 1; - const restored = session.server ?? makeServer(); - session.server = restored; - firstStartEntered.resolve(); - await finishStart.promise; - await restored.connect(new RestoredTransport()); - session.engine = restoredEngine.engine; - session.initialized = true; - }; - - await session.alarm(); + it("persists session metadata and rejects a different principal", async () => { + const harness = makeClientHarness(); + await harness.client.connect(harness.transport); + + // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always release the MCP client's streamed transport after assertions + try { + const stored = await harness.state.storage.get("executor:mcp:v2:session-meta"); + expect(stored).toMatchObject({ + organizationId: ORGANIZATION_ID, + userId: ACCOUNT_ID, + elicitationMode: "model", + appsEnabled: false, + }); + expect(stored?.createdAtMs).toEqual(expect.any(Number)); + + const session = new HarnessSession(harness.state, {} as Cloudflare.Env); + await expect( + session.validateMcpSessionOwner({ + accountId: "acct_other", + organizationId: ORGANIZATION_ID, + }), + ).resolves.toBe("forbidden"); + } finally { + await harness.client.close(); + } + }); - const resume = session.resumeExecutionForModel( - "exec-model", - { accountId: "user-1", organizationId: "org-1" }, - approval, + it("returns a clean 404 for storage created by the retired Agent stack", async () => { + const state = new MemoryDurableObjectState(); + await state.storage.put("session-meta", { + organizationId: ORGANIZATION_ID, + organizationName: "Old Agent Org", + userId: ACCOUNT_ID, + resource: defaultMcpResource, + } satisfies SessionMeta); + const session = new HarnessSession(state, {} as Cloudflare.Env); + const request = verifiedRequest( + new Request("https://executor.test/mcp", { + method: "POST", + headers: { + accept: "application/json, text/event-stream", + "content-type": "application/json", + "mcp-session-id": SESSION_ID, + }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }), + }), ); - const sdkStart = session.onStart(); - await firstStartEntered.promise; - await Promise.resolve(); - finishStart.resolve(); - - const [resumeResult] = await Promise.all([resume, sdkStart]); - expect(resumeResult).toMatchObject({ - status: "result", - result: { - structuredContent: { - status: "completed", - result: "model-result", - }, - }, + const response = await session.fetch(request); + + expect(response.status).toBe(404); + await expect(response.json()).resolves.toMatchObject({ + error: { code: -32001, message: "Session not found" }, }); - expect(onStartCalls).toBe(1); - expect(restoredEngine.calls).toEqual([{ executionId: "exec-model", response: approval }]); }); }); diff --git a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts index 4ccc51965a..d2454fabb9 100644 --- a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts +++ b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts @@ -1,8 +1,17 @@ -import { Cause, Deferred, Effect, Exit, Option, Schema } from "effect"; +import { DurableObject } from "cloudflare:workers"; +import { Cause, Data, Deferred, Effect, Option, Schema } from "effect"; import type * as Tracer from "effect/Tracer"; -import type { Connection, ConnectionContext } from "agents"; -import { McpAgent } from "agents/mcp"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + createMcpHandler, + DEFAULT_NEGOTIATED_PROTOCOL_VERSION, + type JSONRPCMessage, + type McpServer, + type MessageExtraInfo, + type McpHttpHandler, + type McpRequestContext, + type RequestId, + WebStandardStreamableHTTPServerTransport, +} from "@modelcontextprotocol/server"; import { RequestOrgSlug, RequestWebOrigin } from "@executor-js/api/server"; import { @@ -13,18 +22,34 @@ import { type ResumeResponse, } from "@executor-js/execution"; import { + appsEnabledForClientCapabilities, + clientCapabilitiesFromRequestBody, + mcpRequestStateBindingFromBody, PAUSED_APPROVAL_TIMEOUT_MS, formatMcpExecutionOutcome, + mcpRequestStatePrincipal, + requestBodyFromRequest, type PausedExecutionHooks, type ResumeFallbackOutcome, } from "@executor-js/host-mcp/tool-server"; -import { defaultMcpResource, type McpResource } from "@executor-js/host-mcp"; - -import type { IncomingPropagationHeaders, McpElicitationMode } from "./do-headers"; -import type { - McpExecutionOwnerDirectory, - McpExecutionOwnerRecord, - McpExecutionOwnerRoute, +import { + defaultMcpResource, + jsonRpcErrorBody, + mcpResourceKey, + type McpResource, +} from "@executor-js/host-mcp"; +import { + readArtifactsEnabled, + readElicitationMode, + verifiedMcpRequestHeaders, + type IncomingPropagationHeaders, + type McpElicitationMode, +} from "./do-headers"; +import { + modernMcpExecutionOwnerRoute, + type McpExecutionOwnerDirectory, + type McpExecutionOwnerRecord, + type McpExecutionOwnerRoute, } from "./execution-owner-directory"; import { MAX_PAUSED_SESSION_IDLE_MS, @@ -33,6 +58,8 @@ import { pausedLeaseExtensionLog, runningLeaseExtensionLog, } from "./session-alarm-policy"; +import { DurableObjectMcpEventStore } from "./do-event-store"; +import { rotateSseResponse } from "./sse-response-rotation"; export type IncomingTraceHeaders = IncomingPropagationHeaders; @@ -120,11 +147,31 @@ export interface SessionMeta { * unknown, which behaves as disabled until the next `initialize`. */ readonly appsEnabled?: boolean; + /** Creation time of this session, retained across isolate eviction. */ + readonly createdAtMs?: number; } export interface BuiltMcpServer { readonly mcpServer: McpServer; readonly engine: ExecutionEngine; + /** Modern per-request server factory sharing this legacy runtime's engine. */ + readonly modernRuntime?: BuiltModernMcpRuntime; +} + +/** Request-specific inputs added to a DO-local MCP server. */ +export interface ModernMcpServerRequestOptions { + readonly appsEnabled: boolean; + readonly requestStateSigningKey: Uint8Array | string; + readonly requestStatePrincipal: string; + readonly requestStateBinding?: string; +} + +/** Long-lived DO execution runtime shared by per-request MCP servers. */ +export interface BuiltModernMcpRuntime { + readonly engine: ExecutionEngine; + readonly buildServer: ( + options: ModernMcpServerRequestOptions, + ) => Effect.Effect; } export interface BrowserApprovalStore { @@ -132,15 +179,21 @@ export interface BrowserApprovalStore { readonly waitForResponse: (executionId: string) => Effect.Effect; } -const SESSION_META_KEY = "session-meta"; -const LAST_ACTIVITY_KEY = "last-activity-ms"; -const PARTYSERVER_NAME_KEY = "__ps_name"; -/** The agents SDK's durable "condemned" marker (`_cf_scheduleDestroy`). */ -const AGENTS_DESTROY_PENDING_KEY = "cf_agents_destroy_pending"; -const MCP_HTTP_METHOD_HEADER = "cf-mcp-method"; -const MCP_MESSAGE_HEADER = "cf-mcp-message"; +type ModernRuntimeAccess = + | { readonly status: "ok"; readonly runtime: BuiltModernMcpRuntime } + | { readonly status: "forbidden" }; + +class ModernMcpRuntimeNotConfigured extends Data.TaggedError("ModernMcpRuntimeNotConfigured") {} + +const LEGACY_AGENT_SESSION_META_KEY = "executor:mcp:v2:session-meta"; +const LEGACY_AGENT_LAST_ACTIVITY_KEY = "executor:mcp:v2:last-activity-ms"; +const MODERN_SESSION_META_KEY = "session-meta"; +const MODERN_LAST_ACTIVITY_KEY = "last-activity-ms"; +const MODERN_SESSION_KEY = "modern-session"; +const DESTROY_PENDING_KEY = "executor:mcp:v2:destroy-pending"; +const DESTROY_ALARM_DELAY_MS = 1_000; const MODEL_RESUME_FORWARD_TIMEOUT_MS = 10_000; -const MCP_STREAM_REQS_KEY_PREFIX = "__mcp_stream_reqs__:"; +const LEGACY_PRIMING_PROTOCOL_VERSION = "2025-11-25"; const approvalResponseKey = (executionId: string) => `approval-response:${executionId}`; type JsonRpcRequestId = string | number; @@ -148,8 +201,6 @@ const JsonRpcRequestWithId = Schema.Struct({ id: Schema.Union([Schema.String, Schema.Number]), method: Schema.String, }); -const JsonRpcPostPayload = Schema.fromJsonString(Schema.Unknown); -const decodeJsonRpcPostPayload = Schema.decodeUnknownOption(JsonRpcPostPayload); const decodeJsonRpcRequestWithId = Schema.decodeUnknownOption(JsonRpcRequestWithId); const resumeApprovalResult = ( @@ -176,59 +227,85 @@ const resumeApprovalResult = ( }; }; -const isSessionProps = (props: unknown): props is McpSessionProps => - typeof props === "object" && - props !== null && - "session" in props && - typeof (props as { readonly session?: unknown }).session === "object" && - (props as { readonly session?: unknown }).session !== null; - -const readActivePostRequestIds = (request: Request): readonly JsonRpcRequestId[] => { - if (request.headers.get(MCP_HTTP_METHOD_HEADER) !== "POST") return []; - const encoded = request.headers.get(MCP_MESSAGE_HEADER); - if (!encoded) return []; - const decoded = Effect.runSyncExit( - Effect.try({ - try: () => atob(encoded), - catch: () => "invalid_base64" as const, - }), - ); - if (Exit.isFailure(decoded)) { - console.warn( - JSON.stringify({ - event: "mcp_active_post_response_wait_parse_failed", - reason: "invalid_base64", - }), - ); - return []; - } - const parsed = decodeJsonRpcPostPayload(decoded.value); - if (Option.isNone(parsed)) { - console.warn( - JSON.stringify({ - event: "mcp_active_post_response_wait_parse_failed", - reason: "invalid_json", - }), - ); - return []; - } - const messages = Array.isArray(parsed.value) ? parsed.value : [parsed.value]; +const jsonRpcMessages = (parsedBody: unknown): ReadonlyArray => + Array.isArray(parsedBody) ? parsedBody : [parsedBody]; + +const isInitializeBody = (parsedBody: unknown): boolean => + jsonRpcMessages(parsedBody).some((message) => { + const decoded = decodeJsonRpcRequestWithId(message); + return Option.isSome(decoded) && decoded.value.method === "initialize"; + }); + +const legacyToolCallRequestIds = (parsedBody: unknown): readonly JsonRpcRequestId[] => { const requestIds: JsonRpcRequestId[] = []; - for (const message of messages) { + for (const message of jsonRpcMessages(parsedBody)) { const decoded = decodeJsonRpcRequestWithId(message); - if (Option.isSome(decoded)) requestIds.push(decoded.value.id); + if (Option.isSome(decoded) && decoded.value.method === "tools/call") { + requestIds.push(decoded.value.id); + } } return requestIds; }; +const LEGACY_PRIMING_MESSAGE = { + jsonrpc: "2.0", + method: "notifications/message", + params: { level: "debug", data: "mcp-stream-priming" }, +} satisfies JSONRPCMessage; + +const legacyPrimingFrame = (eventId: string): Uint8Array => + new TextEncoder().encode( + `event: mcp-priming\nid: ${eventId}\ndata: ${JSON.stringify(LEGACY_PRIMING_MESSAGE)}\n\n`, + ); + +const replayFrame = (eventId: string, message: JSONRPCMessage): Uint8Array => + new TextEncoder().encode(`event: message\nid: ${eventId}\ndata: ${JSON.stringify(message)}\n\n`); + +const combineFrames = (frames: readonly Uint8Array[]): ArrayBuffer => { + const byteLength = frames.reduce((total, frame) => total + frame.byteLength, 0); + const buffer = new ArrayBuffer(byteLength); + const combined = new Uint8Array(buffer); + let offset = 0; + for (const frame of frames) { + combined.set(frame, offset); + offset += frame.byteLength; + } + return buffer; +}; + +const mcpResourceFromKey = (resourceKey: string): McpResource => + resourceKey.startsWith("toolkit:") && resourceKey.length > "toolkit:".length + ? { kind: "toolkit", slug: resourceKey.slice("toolkit:".length) } + : defaultMcpResource; + +type RuntimeKind = "legacy" | "modern"; + +type QueuedTransportMessage = { + readonly message: JSONRPCMessage; + readonly extra?: MessageExtraInfo; +}; + export abstract class McpAgentSessionDOBase< Env extends Cloudflare.Env = Cloudflare.Env, TDbHandle extends SessionDbHandle = SessionDbHandle, -> extends McpAgent { - server!: McpServer; +> extends DurableObject { + server?: McpServer; + private transport: WebStandardStreamableHTTPServerTransport | null = null; + private readonly eventStore: DurableObjectMcpEventStore; private engine: ExecutionEngine | null = null; private dbHandle: TDbHandle | null = null; private sessionMeta: SessionMeta | null = null; + private modernRuntime: BuiltModernMcpRuntime | null = null; + private modernRuntimePromise: Promise | null = null; + private modernHandler: McpHttpHandler | null = null; + private modernRunningRequestCount = 0; + private modernRequestBodies = new WeakMap(); + private modernRequestPropagation = new WeakMap(); + private legacyRunningRequestCount = 0; + private activeLegacyStreamCount = 0; + private keepAliveCount = 0; + private transportRequestTail = Promise.resolve(); + private runtimeKind: RuntimeKind | null = null; private initialized = false; private onStartPromise: Promise | null = null; private lastActivityMs = 0; @@ -236,6 +313,11 @@ export abstract class McpAgentSessionDOBase< private approvalWaiters = new Map>(); private pendingApprovalLeases = new Map(); + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + this.eventStore = new DurableObjectMcpEventStore(ctx.storage); + } + protected abstract openSessionDb(): TDbHandle | Promise; protected abstract resolveSessionMeta(token: McpSessionInit): Effect.Effect; @@ -245,6 +327,20 @@ export abstract class McpAgentSessionDOBase< dbHandle: TDbHandle, ): Effect.Effect; + /** Build the engine and per-request MCP server factory for a modern-only DO. */ + protected buildModernMcpRuntime( + _sessionMeta: SessionMeta, + _dbHandle: TDbHandle, + ): Effect.Effect { + return Effect.fail(new ModernMcpRuntimeNotConfigured()); + } + + /** Read and validate the deployment-provided modern request-state signing key. */ + protected modernRequestStateSigningKey(): string { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- composition boundary: subclasses serving modern MCP must provide a shared deployment key + throw new Error("Modern MCP request-state signing is not configured"); + } + protected withTelemetry( effect: Effect.Effect, _incoming?: IncomingTraceHeaders, @@ -270,7 +366,7 @@ export abstract class McpAgentSessionDOBase< } protected get sessionId(): string { - return this.getSessionId(); + return this.ctx.id.toString(); } protected currentParentSpan(): Tracer.AnySpan | undefined { @@ -293,6 +389,18 @@ export abstract class McpAgentSessionDOBase< return { sessionId: this.sessionId }; } + private modernExecutionOwnerRoute(): McpExecutionOwnerRoute { + return this.runtimeKind === "legacy" || this.ctx.id.name + ? this.executionOwnerRoute() + : modernMcpExecutionOwnerRoute(this.ctx.id.toString()); + } + + private runtimeOwnerId(): string { + return this.runtimeKind === "modern" + ? this.modernExecutionOwnerRoute().sessionId + : this.sessionId; + } + protected sameExecutionOwnerRoute(a: McpExecutionOwnerRoute, b: McpExecutionOwnerRoute): boolean { return a.sessionId === b.sessionId; } @@ -320,6 +428,12 @@ export abstract class McpAgentSessionDOBase< ): Effect.Effect => this.resumeFromExecutionOwnerDirectory(executionId, response); + protected readonly modernModelResumeFallback = ( + executionId: string, + response: ResumeResponse, + ): Effect.Effect => + this.resumeFromExecutionOwnerDirectory(executionId, response, this.modernExecutionOwnerRoute()); + protected readonly pausedExecutionHooks: PausedExecutionHooks = { onExecutionPaused: (executionId, deadline) => Effect.sync(() => { @@ -329,18 +443,16 @@ export abstract class McpAgentSessionDOBase< onResumeSettled: (executionId) => this.finishPendingApprovalResume(executionId), }; - override async onConnect(conn: Connection, context: ConnectionContext): Promise { - const requestIds = readActivePostRequestIds(context.request); - if (requestIds.length === 0) { - await super.onConnect(conn, context); - return; - } - - await this.keepAliveWhile(async () => { - await this.setStreamRequestIds(conn.id, [...requestIds]); - await super.onConnect(conn, context); - }); - } + /** + * Modern pause hooks await the directory write before the `input_required` + * result leaves the DO, so its signed continuation is immediately routable. + */ + protected readonly modernPausedExecutionHooks: PausedExecutionHooks = { + onExecutionPaused: (executionId, deadline) => + this.startPendingApprovalLease(executionId, deadline, this.modernExecutionOwnerRoute()), + onResumeStarted: (executionId) => this.beginPendingApprovalResume(executionId), + onResumeSettled: (executionId) => this.finishPendingApprovalResume(executionId), + }; private openSessionDbHandle(): Effect.Effect { return Effect.promise(() => Promise.resolve(this.openSessionDb())); @@ -349,7 +461,20 @@ export abstract class McpAgentSessionDOBase< private loadSessionMeta(): Effect.Effect { return Effect.promise(async () => { if (this.sessionMeta) return this.sessionMeta; - const stored = await this.ctx.storage.get(SESSION_META_KEY); + + const legacy = await this.ctx.storage.get(LEGACY_AGENT_SESSION_META_KEY); + if (legacy) { + this.runtimeKind = "legacy"; + this.sessionMeta = { ...legacy, resource: legacy.resource ?? defaultMcpResource }; + return this.sessionMeta; + } + + const isModern = + this.runtimeKind === "modern" || + (await this.ctx.storage.get(MODERN_SESSION_KEY)) === true; + if (!isModern) return null; + this.runtimeKind = "modern"; + const stored = await this.ctx.storage.get(MODERN_SESSION_META_KEY); // Backfill `resource` for sessions persisted before scoped toolkits added // the field. Their stored meta has no `resource`, and every such session // was minted against the default `/mcp` endpoint, so default it here @@ -363,13 +488,15 @@ export abstract class McpAgentSessionDOBase< private async saveSessionMeta(sessionMeta: SessionMeta): Promise { this.sessionMeta = sessionMeta; - await this.ctx.storage.put(SESSION_META_KEY, sessionMeta); + const key = + this.runtimeKind === "modern" ? MODERN_SESSION_META_KEY : LEGACY_AGENT_SESSION_META_KEY; + await this.ctx.storage.put(key, sessionMeta); } /** * Persist the MCP-Apps support negotiated at `initialize`, so a later cold * restore can rebuild the server with it. Subclasses hand this to - * `createExecutorMcpServer` as `onAppsEnabledChange`. + * `buildMcpServer` as `onAppsEnabledChange`. * * A no-op before meta exists: `initialize` always follows `init`, so there is * nothing to merge into and nothing worth failing the session over. @@ -390,78 +517,40 @@ export abstract class McpAgentSessionDOBase< private async markActivity(now = Date.now()): Promise { this.lastActivityMs = now; + const key = + this.runtimeKind === "modern" ? MODERN_LAST_ACTIVITY_KEY : LEGACY_AGENT_LAST_ACTIVITY_KEY; await Promise.all([ - this.ctx.storage.put(LAST_ACTIVITY_KEY, now), + this.ctx.storage.put(key, now), this.ctx.storage.setAlarm(now + this.sessionTimeoutMs()), ]); } private async loadLastActivity(): Promise { if (this.lastActivityMs > 0) return this.lastActivityMs; - const stored = await this.ctx.storage.get(LAST_ACTIVITY_KEY); + const key = + this.runtimeKind === "modern" ? MODERN_LAST_ACTIVITY_KEY : LEGACY_AGENT_LAST_ACTIVITY_KEY; + const stored = await this.ctx.storage.get(key); this.lastActivityMs = stored ?? 0; return this.lastActivityMs; } - private async hasPartyServerName(): Promise { - if (this.ctx.id.name) return true; - const stored = await this.ctx.storage.get(PARTYSERVER_NAME_KEY); - return !!stored; - } - - private activeStreamCount(): number { - return this.connectionsOrNone().length; - } - - private async runningExecutionCount(): Promise { - // Only requests still awaiting a result count as running work. Undelivered - // response markers (the transport's __mcp_undelivered_stream__: keys) - // deliberately do NOT extend the lease: the response is persisted in storage, - // which survives disposeIdleRuntime, so a later reconnect GET re-inits the - // DO and replays it. Counting them would make every delivered-but-unacked - // POST response pin the runtime alive indefinitely. - const rows = await this.ctx.storage.list({ - prefix: MCP_STREAM_REQS_KEY_PREFIX, - limit: 1_000, + /** Hold the in-memory approval runtime until the matching pause settles. */ + protected keepAlive(): Promise<() => void> { + this.keepAliveCount += 1; + let disposed = false; + return Promise.resolve(() => { + if (disposed) return; + disposed = true; + this.keepAliveCount = Math.max(0, this.keepAliveCount - 1); }); - let count = 0; - for (const requestIds of rows.values()) { - if (Array.isArray(requestIds)) count += requestIds.length; - } - return count; } - private closeActiveStreams(): void { - for (const connection of this.connectionsOrNone()) { - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: best-effort WebSocket close during runtime disposal. - try { - connection.close(1000, "Session closed"); - } catch {} - } + private activeStreamCount(): number { + return this.activeLegacyStreamCount; } - /** - * partyserver's `getConnections` dereferences a `#connectionManager` - * private field that is only initialized once the DO has accepted a - * websocket (never in unit harnesses), and partyserver exposes no - * non-throwing probe for that state, so "it throws" IS the signal for - * "no connections yet". Treating that as an empty set is safe for both - * callers: `closeActiveStreams` then has nothing to close, and - * `activeStreamCount` feeds the idle-lease decision where zero at worst - * disposes an idle-looking runtime whose undelivered responses are - * persisted in durable storage and replayed by the next reconnect GET. - * Before this guard the alarm crashed and retried instead, which kept - * the session pinned without ever making progress. - */ - private connectionsOrNone(): ReadonlyArray { - const getConnections = (this as { getConnections?: () => Iterable }).getConnections; - if (!getConnections) return []; - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: see doc comment; partyserver offers no non-throwing way to ask whether the connection manager exists. - try { - return Array.from(getConnections.call(this)); - } catch { - return []; - } + private runningExecutionCount(): number { + return this.legacyRunningRequestCount + this.modernRunningRequestCount; } private async cleanupUnaddressableSessionAlarm(): Promise { @@ -469,30 +558,39 @@ export abstract class McpAgentSessionDOBase< await Effect.runPromise( Effect.all([ Effect.ignore(Effect.tryPromise(() => this.ctx.storage.deleteAlarm())), - Effect.ignore(Effect.tryPromise(() => this.ctx.storage.delete(LAST_ACTIVITY_KEY))), + Effect.ignore( + Effect.tryPromise(() => + this.ctx.storage.delete([LEGACY_AGENT_LAST_ACTIVITY_KEY, MODERN_LAST_ACTIVITY_KEY]), + ), + ), ]), ); } private async disposeIdleRuntime(input: { readonly idleMs: number; + readonly lastActivityMs: number; readonly pausedExecutionCount: number; }): Promise { console.info( JSON.stringify({ event: "mcp_session_idle_runtime_dispose", - sessionId: this.sessionId, + sessionId: this.runtimeOwnerId(), idleMs: input.idleMs, pausedExecutionCount: input.pausedExecutionCount, }), ); await Effect.runPromise(this.closeRuntime()); - await Effect.runPromise( - Effect.all([ - Effect.ignore(Effect.tryPromise(() => this.ctx.storage.deleteAlarm())), - Effect.ignore(Effect.tryPromise(() => this.ctx.storage.delete(LAST_ACTIVITY_KEY))), - ]), - ); + const activityKey = + this.runtimeKind === "modern" ? MODERN_LAST_ACTIVITY_KEY : LEGACY_AGENT_LAST_ACTIVITY_KEY; + const cleared = await this.ctx.storage.transaction(async (transaction) => { + const current = await transaction.get(activityKey); + if (current !== input.lastActivityMs) return false; + await transaction.delete([LEGACY_AGENT_LAST_ACTIVITY_KEY, MODERN_LAST_ACTIVITY_KEY]); + await transaction.deleteAlarm(); + return true; + }); + if (cleared) this.lastActivityMs = 0; } private resolveAndStoreSessionMeta(token: McpSessionInit) { @@ -507,7 +605,8 @@ export abstract class McpAgentSessionDOBase< const sessionMeta: SessionMeta = { ...resolved, ...(token.webOrigin ? { webOrigin: token.webOrigin } : {}), - ...(stored?.appsEnabled === undefined ? {} : { appsEnabled: stored.appsEnabled }), + appsEnabled: stored?.appsEnabled ?? false, + createdAtMs: stored?.createdAtMs ?? Date.now(), }; yield* Effect.promise(() => self.saveSessionMeta(sessionMeta)).pipe( Effect.withSpan("mcp.session.save_meta"), @@ -540,7 +639,7 @@ export abstract class McpAgentSessionDOBase< event: "mcp_execution_owner_directory_error", operation: input.operation, executionId: input.executionId, - sessionId: self.sessionId, + sessionId: self.runtimeOwnerId(), exceptionType: first?.name ?? "Error", exceptionMessage: first?.message ?? "unknown", cause: Cause.pretty(input.cause), @@ -565,7 +664,7 @@ export abstract class McpAgentSessionDOBase< JSON.stringify({ event: "mcp_model_resume_forward_error", executionId: input.executionId, - sessionId: self.sessionId, + sessionId: self.runtimeOwnerId(), ownerSessionId: input.owner.sessionId, exceptionType: first?.name ?? "Error", exceptionMessage: first?.message ?? "unknown", @@ -591,7 +690,7 @@ export abstract class McpAgentSessionDOBase< event: "mcp_model_resume_forward_error", reason: "timeout", executionId: input.executionId, - sessionId: self.sessionId, + sessionId: self.runtimeOwnerId(), ownerSessionId: input.owner.sessionId, timeoutMs: input.timeoutMs, }), @@ -619,26 +718,171 @@ export abstract class McpAgentSessionDOBase< : built; } - private closeRuntime(options: { readonly closeStreams?: boolean } = {}): Effect.Effect { + private buildModernRuntime(sessionMeta: SessionMeta, dbHandle: TDbHandle) { + const built = sessionMeta.organizationSlug + ? this.buildModernMcpRuntime(sessionMeta, dbHandle).pipe( + Effect.provideService(RequestOrgSlug, { slug: sessionMeta.organizationSlug }), + ) + : this.buildModernMcpRuntime(sessionMeta, dbHandle); + return sessionMeta.webOrigin + ? built.pipe(Effect.provideService(RequestWebOrigin, { origin: sessionMeta.webOrigin })) + : built; + } + + private modernPropsOwnSession(sessionMeta: SessionMeta, props: McpSessionProps): boolean { + return ( + props.session.userId === sessionMeta.userId && + props.session.organizationId === sessionMeta.organizationId && + mcpResourceKey(props.session.resource) === mcpResourceKey(sessionMeta.resource) + ); + } + + private startModernRuntime(props: McpSessionProps): Promise { + if (this.modernRuntimePromise) return this.modernRuntimePromise; + + const self = this; + const program = Effect.gen(function* () { + yield* self.prepareErrorCaptureScope(); + const stored = yield* self.loadSessionMeta(); + if (stored && !self.modernPropsOwnSession(stored, props)) { + return { status: "forbidden" as const }; + } + if (!stored) self.runtimeKind = "modern"; + const sessionMeta = stored ?? (yield* self.resolveAndStoreSessionMeta(props.session)); + if (self.runtimeKind === "legacy" && (!self.modernRuntime || !self.engine)) { + yield* self.initializeLegacyRuntime(props, sessionMeta); + } + if (self.modernRuntime && self.engine) { + yield* Effect.promise(() => self.markActivity()); + return { status: "ok" as const, runtime: self.modernRuntime }; + } + + const dbHandle = self.dbHandle ?? (yield* self.openSessionDbHandle()); + self.dbHandle = dbHandle; + const runtime = yield* self.buildModernRuntime(sessionMeta, dbHandle); + self.modernRuntime = runtime; + self.engine = runtime.engine; + yield* Effect.promise(() => + self.runtimeKind === "modern" + ? Promise.all([self.ctx.storage.put(MODERN_SESSION_KEY, true), self.markActivity()]).then( + () => undefined, + ) + : self.markActivity(), + ); + return { status: "ok" as const, runtime }; + }).pipe( + Effect.tapCause((cause) => + Effect.gen(function* () { + console.error("[mcp-session] modern runtime init failed:", Cause.pretty(cause)); + yield* self.captureCauseEffect(cause); + yield* self.recordCauseOnSpan(cause); + yield* self.closeRuntime(); + }), + ), + Effect.withSpan("McpSessionDO.startModernRuntime", { + attributes: { "mcp.auth.organization_id": props.session.organizationId }, + }), + (effect) => self.withTelemetry(effect, props.propagation), + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: Durable Object RPC methods can only reject their Promise + Effect.orDie, + (effect) => self.withSpanFlush(effect), + ); + + const starting = Effect.runPromise(program); + this.modernRuntimePromise = starting; + starting.then( + () => { + if (this.modernRuntimePromise === starting) this.modernRuntimePromise = null; + }, + () => { + if (this.modernRuntimePromise === starting) this.modernRuntimePromise = null; + }, + ); + return starting; + } + + private modernHandlerForRuntime(): McpHttpHandler { + if (this.modernHandler) return this.modernHandler; + const self = this; + this.modernHandler = createMcpHandler( + (context: McpRequestContext) => { + const request = context.requestInfo; + const runtime = self.modernRuntime; + const sessionMeta = self.sessionMeta; + if (!request || !runtime || !sessionMeta || !self.modernRequestBodies.has(request)) { + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: the third-party factory Promise has no typed failure channel; absent DO request context is an SDK defect + return Effect.runPromise(Effect.die("Modern MCP Durable Object has no request runtime")); + } + const parsedBody = self.modernRequestBodies.get(request); + const propagation = self.modernRequestPropagation.get(request); + const capabilities = clientCapabilitiesFromRequestBody(parsedBody); + return Effect.runPromise( + Effect.gen(function* () { + const requestStatePrincipal = mcpRequestStatePrincipal({ + accountId: sessionMeta.userId, + organizationId: sessionMeta.organizationId, + }); + const requestStateBinding = yield* Effect.promise(() => + mcpRequestStateBindingFromBody({ + body: parsedBody, + principal: requestStatePrincipal, + resource: sessionMeta.resource, + }), + ); + return yield* runtime.buildServer({ + appsEnabled: appsEnabledForClientCapabilities(capabilities), + requestStateSigningKey: self.modernRequestStateSigningKey(), + requestStatePrincipal, + ...(requestStateBinding === null ? {} : { requestStateBinding }), + }); + }).pipe( + (effect) => self.withTelemetry(effect, propagation), + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: the third-party factory Promise can only reject + Effect.orDie, + ), + ); + }, + { legacy: "reject" }, + ); + return this.modernHandler; + } + + private closeRuntime(): Effect.Effect { const self = this; return Effect.gen(function* () { + // Detach the complete generation before awaiting cleanup. A request that + // interleaves with a slow server/DB close must build a fresh generation, + // never observe initialized=true with a closing/null transport, and the + // old cleanup must never clear fields belonging to that fresh runtime. + const transport = self.transport; + const server = self.server; + const modernHandler = self.modernHandler; + const dbHandle = self.dbHandle; + self.transport = null; + delete (self as { server?: McpServer }).server; + self.modernHandler = null; + self.dbHandle = null; + self.engine = null; + self.modernRuntime = null; + self.activeLegacyStreamCount = 0; + self.legacyRunningRequestCount = 0; + self.modernRequestBodies = new WeakMap(); + self.modernRequestPropagation = new WeakMap(); + self.initialized = false; + yield* self.releaseAllPendingApprovalLeases(); - if (options.closeStreams ?? true) { - yield* Effect.sync(() => self.closeActiveStreams()); + if (transport) { + yield* Effect.promise(() => transport.close()).pipe(Effect.ignore); } - if (self.server) { - const server = self.server; - delete (self as { server?: McpServer }).server; + if (server) { yield* Effect.promise(() => server.close()).pipe(Effect.ignore); } - Reflect.set(self, "_transport", undefined); - self.engine = null; - if (self.dbHandle) { - const dbHandle = self.dbHandle; - self.dbHandle = null; + if (modernHandler) { + yield* Effect.promise(() => modernHandler.close()).pipe(Effect.ignore); + } + if (dbHandle) { yield* Effect.promise(() => Promise.resolve(dbHandle.end())).pipe(Effect.ignore); } - self.initialized = false; }); } @@ -657,62 +901,66 @@ export abstract class McpAgentSessionDOBase< }).pipe(Effect.withSpan("McpSessionDO.ensure_runtime_for_approval")); } - private startRuntimeFromOnStart(props?: McpSessionProps): Effect.Effect { - const self = this; - return Effect.gen(function* () { - // PartyServer can rehydrate WebSockets before onStart runs in a - // cold-restored isolate. With no in-memory runtime to replace, those - // sockets are the live MCP response streams that triggered the restore. - const hasInMemoryRuntime = - self.initialized || - self.engine !== null || - self.dbHandle !== null || - self.server !== undefined; - yield* self.closeRuntime({ closeStreams: hasInMemoryRuntime }); - const started = yield* Effect.exit(Effect.promise(() => self.runMcpAgentOnStart(props))); - if (Exit.isFailure(started)) { - yield* self.closeRuntime(); - return yield* Effect.failCause(started.cause); - } - }); + private propsFromSessionMeta( + sessionMeta: SessionMeta, + propagation?: IncomingTraceHeaders, + ): McpSessionProps { + return { + session: { + organizationId: sessionMeta.organizationId, + userId: sessionMeta.userId, + elicitationMode: sessionMeta.elicitationMode ?? "model", + artifactsEnabled: sessionMeta.artifactsEnabled, + resource: sessionMeta.resource, + webOrigin: sessionMeta.webOrigin, + }, + propagation, + }; } - protected runMcpAgentOnStart(props?: McpSessionProps): Promise { - return super.onStart(props); + private restoreTransportSession(transport: WebStandardStreamableHTTPServerTransport): void { + transport.sessionId = this.sessionId; + // SAFETY: the SDK exposes `sessionId` but not a public cold-restore setter. + // The installed transport's only additional session-validation bit is the + // runtime `_initialized` boolean. Restoring just those transport fields + // intentionally leaves McpServer client capabilities absent, so the + // sessionful assembly falls back to the persisted apps seed. + Reflect.set(transport, "_initialized", true); } - override async onStart(props?: McpSessionProps): Promise { - if (this.onStartPromise) return this.onStartPromise; - - const starting = Effect.runPromise(this.startRuntimeFromOnStart(props)); - this.onStartPromise = starting; - starting.then( - () => { - if (this.onStartPromise === starting) this.onStartPromise = null; - }, - () => { - if (this.onStartPromise === starting) this.onStartPromise = null; - }, - ); - return starting; + private makeLegacyTransport(restoring: boolean): WebStandardStreamableHTTPServerTransport { + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => this.sessionId, + enableJsonResponse: false, + eventStore: this.eventStore, + retryInterval: 1_000, + onsessionclosed: () => this._cf_scheduleDestroy(), + }); + transport.onerror = (error) => { + console.error("[mcp-session] transport error:", error); + }; + if (restoring) this.restoreTransportSession(transport); + return transport; } - async init(): Promise { - if (this.initialized) return; - const props = isSessionProps(this.props) ? this.props : null; - if (!props) { - // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: McpAgent.init is a Promise-only framework hook and props are required before any Effect runtime exists. - throw new Error("MCP session props are required"); - } + private initializeLegacyRuntime( + props: McpSessionProps, + storedMeta: SessionMeta | null, + ): Effect.Effect { const self = this; - const program = Effect.gen(function* () { + return Effect.gen(function* () { yield* self.prepareErrorCaptureScope(); - const sessionMeta = yield* self.resolveAndStoreSessionMeta(props.session); + self.runtimeKind = "legacy"; + const sessionMeta = storedMeta ?? (yield* self.resolveAndStoreSessionMeta(props.session)); const dbHandle = yield* self.openSessionDbHandle(); - const { mcpServer, engine } = yield* self.buildRuntime(sessionMeta, dbHandle); + const { mcpServer, engine, modernRuntime } = yield* self.buildRuntime(sessionMeta, dbHandle); + const transport = self.makeLegacyTransport(storedMeta !== null); self.dbHandle = dbHandle; self.server = mcpServer; self.engine = engine; + self.modernRuntime = modernRuntime ?? null; + self.transport = transport; + yield* Effect.promise(() => mcpServer.connect(transport)); self.initialized = true; yield* Effect.promise(() => self.markActivity()).pipe( Effect.withSpan("McpSessionDO.markActivity"), @@ -720,33 +968,331 @@ export abstract class McpAgentSessionDOBase< }).pipe( Effect.tapCause((cause) => Effect.gen(function* () { - console.error("[mcp-session] init failed:", Cause.pretty(cause)); + console.error("[mcp-session] legacy runtime init failed:", Cause.pretty(cause)); yield* self.captureCauseEffect(cause); yield* self.recordCauseOnSpan(cause); }), ), Effect.catchCause((cause) => Effect.gen(function* () { - yield* Effect.promise(() => self.cleanup()); + yield* self.closeRuntime(); return yield* Effect.failCause(cause); }), ), - Effect.withSpan("McpSessionDO.init", { - attributes: { - "mcp.auth.organization_id": props?.session.organizationId ?? "", - }, + Effect.withSpan("McpSessionDO.initializeLegacyRuntime", { + attributes: { "mcp.auth.organization_id": props.session.organizationId }, }), + (effect) => self.withTelemetry(effect, props.propagation), + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: Durable Object entrypoints can only reject their Promise + Effect.orDie, + (effect) => self.withSpanFlush(effect), ); - const traced = this.withTelemetry(program, props?.propagation); - return Effect.runPromise( - traced.pipe( - // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: Durable Object init method can only reject its Promise - Effect.orDie, - (effect) => self.withSpanFlush(effect), - ), + } + + async onStart(props?: McpSessionProps): Promise { + if (this.initialized && this.engine) return; + if (this.onStartPromise) return this.onStartPromise; + + const self = this; + const starting = Effect.runPromise( + Effect.gen(function* () { + const stored = yield* self.loadSessionMeta(); + const resolvedProps = props ?? (stored ? self.propsFromSessionMeta(stored) : null); + if (!resolvedProps) return; + if (self.runtimeKind === "modern") { + yield* Effect.promise(() => self.startModernRuntime(resolvedProps)); + return; + } + yield* self.initializeLegacyRuntime(resolvedProps, stored); + }), + ); + this.onStartPromise = starting; + starting.then( + () => { + if (this.onStartPromise === starting) this.onStartPromise = null; + }, + () => { + if (this.onStartPromise === starting) this.onStartPromise = null; + }, + ); + return starting; + } + + private requestStreamId( + transport: WebStandardStreamableHTTPServerTransport, + requestId: RequestId, + ): string | null { + // SAFETY: the SDK currently has no public hook exposing the per-POST stream + // ID. The installed transport stores the exact request-id → stream-id map + // used by replay. Reading it lets the legacy compatibility prime share the + // same replay stream as the eventual result without changing SDK code. + const mapping: unknown = Reflect.get(transport, "_requestToStreamMapping"); + if (!(mapping instanceof Map)) return null; + const streamId: unknown = mapping.get(requestId); + return typeof streamId === "string" ? streamId : null; + } + + private async supersedeReplayStream( + transport: WebStandardStreamableHTTPServerTransport, + lastEventId: string, + ): Promise { + const streamId = await this.eventStore.getStreamIdForEventId(lastEventId); + if (!streamId) return; + // SAFETY: the installed SDK exposes closeSSEStream(requestId), but not the + // reverse request-id map needed to supersede a stale POST connection before + // replay. This is the same pinned map used by requestStreamId above. + const mapping: unknown = Reflect.get(transport, "_requestToStreamMapping"); + if (!(mapping instanceof Map)) return; + for (const [requestId, mappedStreamId] of mapping) { + if ( + mappedStreamId === streamId && + (typeof requestId === "string" || typeof requestId === "number") + ) { + transport.closeSSEStream(requestId); + return; + } + } + } + + private trackedLegacyResponse = ( + response: Response, + options: { readonly initialFrame?: Uint8Array; readonly acknowledge?: readonly string[] } = {}, + ): Response => + rotateSseResponse(response, { + ...(options.initialFrame ? { initialFrame: options.initialFrame } : {}), + onOpen: () => { + this.activeLegacyStreamCount += 1; + }, + onClose: (reason) => { + this.activeLegacyStreamCount = Math.max(0, this.activeLegacyStreamCount - 1); + if (reason === "complete" && options.acknowledge && options.acknowledge.length > 0) { + this.ctx.waitUntil(this.eventStore.acknowledgeUndeliveredStreams(options.acknowledge)); + } + }, + }); + + private async replayUndeliveredOnStandaloneGet(request: Request): Promise { + if (request.method !== "GET" || request.headers.has("last-event-id")) return null; + const frames: Uint8Array[] = []; + const streamIds = await this.eventStore.replayUndeliveredStreams({ + send: (eventId, message) => { + frames.push(replayFrame(eventId, message)); + return Promise.resolve(); + }, + }); + if (frames.length === 0) return null; + return this.trackedLegacyResponse( + new Response(combineFrames(frames), { + headers: { + "content-type": "text/event-stream", + "cache-control": "no-cache, no-transform", + connection: "keep-alive", + "x-accel-buffering": "no", + "mcp-session-id": this.sessionId, + }, + }), + { acknowledge: streamIds }, ); } + private async serializedTransportRequest(run: () => Promise): Promise { + const previous = this.transportRequestTail; + let release = (): void => undefined; + this.transportRequestTail = new Promise((resolve) => { + release = resolve; + }); + await previous; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- concurrency boundary: release the next DO transport request on both success and rejection + try { + return await run(); + } finally { + release(); + } + } + + private async handleLegacyTransportRequest( + request: Request, + parsedBody: unknown, + ): Promise { + return this.serializedTransportRequest(async () => { + const transport = this.transport; + if (!transport) { + return jsonRpcErrorBody(404, -32001, "Session not found", { cors: false }); + } + if (request.method === "GET") { + const lastEventId = request.headers.get("last-event-id"); + if (lastEventId) { + await this.supersedeReplayStream(transport, lastEventId); + } else { + // Latest-listener-wins. Client cancellation is not reliably relayed + // through every workerd/Vite streaming hop, so explicitly retire a + // stale standalone mapping before opening its replacement. + transport.closeStandaloneSSEStream(); + const replay = await this.replayUndeliveredOnStandaloneGet(request); + if (replay) return replay; + } + } + const toolCallIds = legacyToolCallRequestIds(parsedBody); + const protocolVersion = + request.headers.get("mcp-protocol-version") ?? DEFAULT_NEGOTIATED_PROTOCOL_VERSION; + const needsLegacyPrime = + toolCallIds.length > 0 && protocolVersion < LEGACY_PRIMING_PROTOCOL_VERSION; + if (!needsLegacyPrime) { + const response = await transport.handleRequest(request); + const streamId = toolCallIds[0] ? this.requestStreamId(transport, toolCallIds[0]) : null; + if (streamId) await this.eventStore.markStreamUndelivered(streamId); + return this.trackedLegacyResponse(response); + } + + const originalOnMessage = transport.onmessage; + const queued: QueuedTransportMessage[] = []; + transport.onmessage = (message, extra) => { + queued.push(extra === undefined ? { message } : { message, extra }); + }; + let response: Response; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- SDK adapter boundary: restore the connected server handler even when request parsing fails + try { + response = await transport.handleRequest(request); + } finally { + transport.onmessage = originalOnMessage; + } + + const streamId = this.requestStreamId(transport, toolCallIds[0]!); + const eventId = streamId + ? await this.eventStore.storeEvent(streamId, LEGACY_PRIMING_MESSAGE) + : null; + if (streamId) await this.eventStore.markStreamUndelivered(streamId); + const rotated = this.trackedLegacyResponse(response, { + ...(eventId ? { initialFrame: legacyPrimingFrame(eventId) } : {}), + }); + // ReadableStream.start enqueues the priming frame synchronously while + // building `rotated`; only then may the server see the tools/call. + for (const item of queued) originalOnMessage?.(item.message, item.extra); + return rotated; + }); + } + + private propsFromLegacyRequest( + request: Request, + verified: NonNullable>, + ): McpSessionProps { + return { + session: { + organizationId: verified.organizationId, + userId: verified.accountId, + elicitationMode: readElicitationMode(request), + artifactsEnabled: readArtifactsEnabled(request), + resource: mcpResourceFromKey(verified.resourceKey), + webOrigin: new URL(request.url).origin, + }, + propagation: { + traceparent: request.headers.get("traceparent") ?? undefined, + tracestate: request.headers.get("tracestate") ?? undefined, + baggage: request.headers.get("baggage") ?? undefined, + }, + }; + } + + /** Serve one authenticated legacy MCP exchange directly from this DO. */ + override async fetch(request: Request): Promise { + const verified = verifiedMcpRequestHeaders(request); + if (!verified) { + return jsonRpcErrorBody(403, -32003, "Invalid MCP Durable Object identity", { + cors: false, + }); + } + if ((await this.ctx.storage.get(DESTROY_PENDING_KEY)) === true) { + return jsonRpcErrorBody(404, -32001, "Session timed out, please reconnect", { + cors: false, + }); + } + + const parsedBody = await Effect.runPromise(requestBodyFromRequest(request)); + const stored = await Effect.runPromise(this.loadSessionMeta()); + if (!stored) { + if (!isInitializeBody(parsedBody)) { + return request.headers.has("mcp-session-id") + ? jsonRpcErrorBody(404, -32001, "Session not found", { cors: false }) + : jsonRpcErrorBody(400, -32000, "Bad Request: Server not initialized", { + cors: false, + }); + } + this.runtimeKind = "legacy"; + await this.onStart(this.propsFromLegacyRequest(request, verified)); + } else { + if ( + this.runtimeKind !== "legacy" || + stored.userId !== verified.accountId || + stored.organizationId !== verified.organizationId || + mcpResourceKey(stored.resource) !== verified.resourceKey + ) { + return jsonRpcErrorBody(403, -32003, "MCP session does not belong to the current bearer", { + cors: false, + }); + } + await this.onStart( + this.propsFromSessionMeta(stored, { + traceparent: request.headers.get("traceparent") ?? undefined, + tracestate: request.headers.get("tracestate") ?? undefined, + baggage: request.headers.get("baggage") ?? undefined, + }), + ); + } + + this.legacyRunningRequestCount += 1; + await this.markActivity(); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: running-request accounting must settle on transport failure too + try { + return await this.handleLegacyTransportRequest(request, parsedBody); + } finally { + this.legacyRunningRequestCount = Math.max(0, this.legacyRunningRequestCount - 1); + } + } + + /** + * Serve one authenticated modern request without entering the legacy + * sessionful streamable-HTTP transport. + */ + async serveModernMcp( + request: Request, + props: McpSessionProps, + parsedBody: unknown, + ): Promise { + this.modernRequestStateSigningKey(); + const verified = verifiedMcpRequestHeaders(request); + if ( + !verified || + verified.accountId !== props.session.userId || + verified.organizationId !== props.session.organizationId || + verified.resourceKey !== mcpResourceKey(props.session.resource) + ) { + return jsonRpcErrorBody(403, -32003, "Invalid MCP Durable Object identity", { + cors: false, + }); + } + const access = await this.startModernRuntime(props); + const sessionMeta = this.sessionMeta; + if ( + access.status === "forbidden" || + !sessionMeta || + !this.modernPropsOwnSession(sessionMeta, props) + ) { + return jsonRpcErrorBody(403, -32003, "MCP session does not belong to the current bearer", { + cors: false, + }); + } + + this.modernRequestBodies.set(request, parsedBody); + this.modernRequestPropagation.set(request, props.propagation); + this.modernRunningRequestCount += 1; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: the RPC must decrement its in-memory running lease on both handler resolution and rejection + try { + return await this.modernHandlerForRuntime().fetch(request, { parsedBody }); + } finally { + this.modernRunningRequestCount = Math.max(0, this.modernRunningRequestCount - 1); + } + } + async validateMcpSessionOwner( identity: McpApprovalOwner, ): Promise<"ok" | "not_found" | "forbidden" | "terminated"> { @@ -755,15 +1301,13 @@ export abstract class McpAgentSessionDOBase< Effect.gen(function* () { yield* self.prepareErrorCaptureScope(); // A DELETE-terminated session is condemned via `_cf_scheduleDestroy`, - // which writes a durable marker and defers the actual `destroy()` to + // which writes a durable marker and defers the actual storage wipe to // an alarm (~1s later). A request that races into that window still // sees the session's storage intact, so without this gate the session // would restore and answer — but the protocol contract is that a - // terminated id is dead the moment the DELETE returns. (The old code - // won this race by accident: its onConnect drain-wait stalled the - // request until the destroy alarm aborted the isolate.) + // terminated id is dead the moment the DELETE returns. const destroyPending = yield* Effect.promise(() => - self.ctx.storage.get(AGENTS_DESTROY_PENDING_KEY), + self.ctx.storage.get(DESTROY_PENDING_KEY), ); if (destroyPending === true) return "terminated" as const; const sessionMeta = yield* self.loadSessionMeta(); @@ -917,9 +1461,17 @@ export abstract class McpAgentSessionDOBase< ); } - override async destroy(): Promise { + /** Condemn this session and arm a fresh alarm invocation to wipe it. */ + async _cf_scheduleDestroy(): Promise { + await this.ctx.storage.put(DESTROY_PENDING_KEY, true); + await this.ctx.storage.setAlarm(Date.now() + DESTROY_ALARM_DELAY_MS); + } + + private async destroySession(): Promise { await this.cleanup(); - await super.destroy(); + await this.ctx.storage.deleteAlarm(); + await this.ctx.storage.deleteAll(); + setTimeout(() => this.ctx.abort("destroyed"), 0); } private async pausedExecutionCount(): Promise { @@ -928,14 +1480,20 @@ export abstract class McpAgentSessionDOBase< } override async alarm(): Promise { - if (!(await this.hasPartyServerName())) { + if ((await this.ctx.storage.get(DESTROY_PENDING_KEY)) === true) { + await this.destroySession(); + return; + } + const sessionMeta = await Effect.runPromise(this.loadSessionMeta()); + if (!sessionMeta) { await this.cleanupUnaddressableSessionAlarm(); return; } + const isModernSession = this.runtimeKind === "modern"; const lastActivityMs = await this.loadLastActivity(); const idleMs = lastActivityMs > 0 ? Date.now() - lastActivityMs : 0; const pausedExecutionCount = await this.pausedExecutionCount(); - const runningExecutionCount = await this.runningExecutionCount(); + const runningExecutionCount = this.runningExecutionCount(); const activeStreamCount = this.activeStreamCount(); const decision = decideSessionAlarm({ idleMs, @@ -947,15 +1505,17 @@ export abstract class McpAgentSessionDOBase< }); if (decision.kind === "idle_within_timeout") { - await super.alarm(); + await this.ctx.storage.setAlarm(Date.now() + Math.max(1, this.sessionTimeoutMs() - idleMs)); return; } + const ownerId = isModernSession ? this.modernExecutionOwnerRoute().sessionId : this.sessionId; + if (decision.kind === "extend_paused_lease") { console.info( JSON.stringify( pausedLeaseExtensionLog({ - sessionId: this.sessionId, + sessionId: ownerId, pausedExecutionCount, idleMs, leaseMs: decision.leaseMs, @@ -970,7 +1530,7 @@ export abstract class McpAgentSessionDOBase< console.info( JSON.stringify( runningLeaseExtensionLog({ - sessionId: this.sessionId, + sessionId: ownerId, runningExecutionCount, activeStreamCount, idleMs, @@ -978,15 +1538,14 @@ export abstract class McpAgentSessionDOBase< }), ), ); - // Open streamable-HTTP bridges and persisted request ids represent work - // that can still deliver or replay a response. Buggy dead pipes are - // closed by the SSE writer's terminal failure path, so they stop - // extending the lease once the bridge observes the failure. + // A direct streamed response represents work that can still deliver or + // replay a result. Cancellation, completion, and max-age rotation all + // decrement activeStreamCount, so dead streams stop extending the lease. await this.ctx.storage.setAlarm(Date.now() + decision.leaseMs); return; } - await this.disposeIdleRuntime({ idleMs, pausedExecutionCount }); + await this.disposeIdleRuntime({ idleMs, lastActivityMs, pausedExecutionCount }); } private validateApprovalIdentity( @@ -1030,6 +1589,7 @@ export abstract class McpAgentSessionDOBase< private writeExecutionOwnerEntry( executionId: string, deadline: PausedExecutionDeadline | undefined, + owner: McpExecutionOwnerRoute = this.executionOwnerRoute(), ): Effect.Effect { const directory = this.executionOwnerDirectory(); if (!directory || !deadline) return Effect.void; @@ -1039,7 +1599,7 @@ export abstract class McpAgentSessionDOBase< if (!sessionMeta) return; const record: McpExecutionOwnerRecord = { executionId, - owner: self.executionOwnerRoute(), + owner, accountId: sessionMeta.userId, organizationId: sessionMeta.organizationId, expiresAt: deadline.expiresAt, @@ -1082,6 +1642,7 @@ export abstract class McpAgentSessionDOBase< private resumeFromExecutionOwnerDirectory( executionId: string, response: ResumeResponse, + currentOwner: McpExecutionOwnerRoute = this.executionOwnerRoute(), ): Effect.Effect { const directory = this.executionOwnerDirectory(); if (!directory) return Effect.succeed(null); @@ -1108,7 +1669,7 @@ export abstract class McpAgentSessionDOBase< return { status: "execution_forbidden" } as const; } - if (self.sameExecutionOwnerRoute(record.owner, self.executionOwnerRoute())) { + if (self.sameExecutionOwnerRoute(record.owner, currentOwner)) { yield* self.deleteExecutionOwnerEntry(executionId); return { status: "execution_expired", ttlMs: record.ttlMs } as const; } @@ -1155,20 +1716,16 @@ export abstract class McpAgentSessionDOBase< private startPendingApprovalLease( executionId: string, deadline: PausedExecutionDeadline | undefined, + owner: McpExecutionOwnerRoute = this.executionOwnerRoute(), ): Effect.Effect { const self = this; return Effect.gen(function* () { yield* self.prepareErrorCaptureScope(); if (self.pendingApprovalLeases.has(executionId)) return; - // keepAlive BEFORE markActivity: acquiring the first keepAlive ref runs - // the SDK's _scheduleNextAlarm, which re-arms the DO alarm to its 30s - // heartbeat and would overwrite the idle alarm markActivity sets. With - // this ordering markActivity's setAlarm(now + sessionTimeoutMs) lands - // last, so the idle/paused-expiry clock keeps ticking while the lease - // holds the runtime alive. (Round 1 removed onConnect's drain-wait, - // which used to hold a ref across the pause and mask this by keeping - // the ref transition away from 0->1.) + // The base owns alarm arming now: record the in-memory lease first, then + // mark activity so the session alarm is durably scheduled for the idle / + // paused-expiry policy while this approval is outstanding. const disposeKeepAlive = yield* Effect.promise(() => self.keepAlive()); yield* Effect.promise(() => self.markActivity()).pipe( Effect.withSpan("McpSessionDO.markActivity"), @@ -1177,7 +1734,7 @@ export abstract class McpAgentSessionDOBase< self.queuePendingApprovalLeaseExpiration(executionId); }, PAUSED_APPROVAL_TIMEOUT_MS); self.pendingApprovalLeases.set(executionId, { disposeKeepAlive, timeout, expiring: false }); - yield* self.writeExecutionOwnerEntry(executionId, deadline); + yield* self.writeExecutionOwnerEntry(executionId, deadline, owner); }).pipe( Effect.withSpan("McpSessionDO.pending_approval_lease.start", { attributes: { "mcp.execution.id": executionId }, diff --git a/packages/hosts/cloudflare/src/mcp/agent-session-model-resume.test.ts b/packages/hosts/cloudflare/src/mcp/agent-session-model-resume.test.ts index 3fa87cdc4c..712b52eac1 100644 --- a/packages/hosts/cloudflare/src/mcp/agent-session-model-resume.test.ts +++ b/packages/hosts/cloudflare/src/mcp/agent-session-model-resume.test.ts @@ -1,8 +1,6 @@ -import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; -// oxlint-disable-next-line executor/no-vitest-import -- boundary: vi.mock must come from vitest itself for mock hoisting to resolve -import { vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest"; import { Cause, Effect } from "effect"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "@modelcontextprotocol/server"; import { defaultMcpResource } from "@executor-js/host-mcp"; import { PAUSED_APPROVAL_TIMEOUT_MS, @@ -26,7 +24,6 @@ import { import { McpExecutionOwnerDirectoryDO, mcpExecutionOwnerDirectoryFromNamespace, - mcpSessionDurableObjectName, type McpExecutionOwnerDirectory, type McpExecutionOwnerDirectoryNamespace, type McpExecutionOwnerRecord, @@ -34,40 +31,6 @@ import { } from "./execution-owner-directory"; import { mcpSessionStub } from "./session-stub"; -vi.mock("agents/mcp", () => ({ - McpAgent: class { - protected readonly ctx: DurableObjectState; - - constructor(ctx: DurableObjectState) { - this.ctx = ctx; - } - - getSessionId(): string { - return this.ctx.id.toString(); - } - - keepAlive(): Promise<() => void> { - return Promise.resolve(() => undefined); - } - - getStreamRequestIds(): Promise { - return Promise.resolve([]); - } - - onConnect(): Promise { - return Promise.resolve(); - } - - alarm(): Promise { - return Promise.resolve(); - } - - destroy(): Promise { - return Promise.resolve(); - } - }, -})); - class FakeStorage implements DurableObjectStorage { private readonly values = new Map(); readonly sql = {} as DurableObjectStorage["sql"]; @@ -366,7 +329,7 @@ class HarnessSession extends McpAgentSessionDOBase } async storeSessionMeta(): Promise { - await this.fakeState.storage.put("session-meta", this.meta); + await this.fakeState.storage.put("executor:mcp:v2:session-meta", this.meta); } async startPause(executionId: string): Promise { @@ -430,12 +393,6 @@ const flushMicrotasks = async (): Promise => { await Promise.resolve(); }; -describe("mcpSessionDurableObjectName", () => { - it("uses the Agents streamable-http durable object name", () => { - expect(mcpSessionDurableObjectName("session_123")).toBe("streamable-http:session_123"); - }); -}); - describe("McpAgentSessionDOBase cross-session model resume", () => { beforeEach(() => { vi.useFakeTimers(); @@ -457,7 +414,7 @@ describe("McpAgentSessionDOBase cross-session model resume", () => { const requesterEngine = makeEngine(() => null); const sessions = new Map(); const sessionNamespace = { - idFromName: (name: string) => name, + idFromString: (id: string) => id, get: (id: string) => sessions.get(id), }; const forward = vi.fn( @@ -468,11 +425,9 @@ describe("McpAgentSessionDOBase cross-session model resume", () => { response: ResumeResponse, ) => Effect.promise(async () => { - return mcpSessionStub(sessionNamespace, owner.sessionId).resumeExecutionForModel( - executionId, - identity, - response, - ); + const ownerSession = mcpSessionStub(sessionNamespace, owner.sessionId); + if (!ownerSession) return { status: "execution_expired" as const, ttlMs: 0 }; + return ownerSession.resumeExecutionForModel(executionId, identity, response); }), ); @@ -487,7 +442,7 @@ describe("McpAgentSessionDOBase cross-session model resume", () => { directoryNamespace: namespace, forwardModelResumeToOwner: forward, }); - sessions.set(mcpSessionDurableObjectName("session-a"), sessionA); + sessions.set("session-a", sessionA); await sessionA.storeSessionMeta(); await sessionB.storeSessionMeta(); diff --git a/packages/hosts/cloudflare/src/mcp/agent-session-modern.test.ts b/packages/hosts/cloudflare/src/mcp/agent-session-modern.test.ts new file mode 100644 index 0000000000..fe13561e90 --- /dev/null +++ b/packages/hosts/cloudflare/src/mcp/agent-session-modern.test.ts @@ -0,0 +1,331 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect } from "effect"; + +import type { + ExecutionEngine, + ExecutionResult, + PausedExecution, + ResumeResponse, +} from "@executor-js/execution"; +import { defaultMcpResource } from "@executor-js/host-mcp"; +import { PAUSED_APPROVAL_TIMEOUT_MS } from "@executor-js/host-mcp/tool-server"; +import { buildMcpServer } from "@executor-js/host-mcp/tool-server"; +import { FormElicitation, ToolAddress } from "@executor-js/sdk"; + +import { + McpAgentSessionDOBase, + type BuiltMcpServer, + type BuiltModernMcpRuntime, + type McpSessionInit, + type McpSessionProps, + type ModernMcpServerRequestOptions, + type SessionMeta, +} from "./agent-session-durable-object"; +import { + modernMcpExecutionOwnerRoute, + type McpExecutionOwnerDirectory, + type McpExecutionOwnerRecord, + type McpExecutionOwnerRoute, +} from "./execution-owner-directory"; + +const REQUEST_STATE_KEY = "0123456789abcdef0123456789abcdef"; +const EXECUTION_ID = "exec-modern-pause"; + +class MemoryStorage { + private readonly values = new Map(); + alarm: number | undefined; + + async get(key: string): Promise { + return this.values.get(key) as T | undefined; + } + + async put(key: string, value: unknown): Promise { + this.values.set(key, value); + } + + async delete(key: string | readonly string[]): Promise { + if (typeof key === "string") { + this.values.delete(key); + return; + } + for (const entry of key) this.values.delete(entry); + } + + async list(options: { readonly prefix?: string } = {}): Promise> { + return new Map( + Array.from(this.values.entries()) + .filter(([key]) => !options.prefix || key.startsWith(options.prefix)) + .map(([key, value]) => [key, value as T]), + ); + } + + async setAlarm(time: number | Date): Promise { + this.alarm = typeof time === "number" ? time : time.getTime(); + } + + async deleteAlarm(): Promise { + this.alarm = undefined; + } +} + +class MemoryContext { + readonly storage = new MemoryStorage(); + readonly id = { + name: undefined, + toString: () => "modern-do-id", + }; + readonly waitUntilPromises: Promise[] = []; + + waitUntil(promise: Promise): void { + this.waitUntilPromises.push(promise); + } +} + +class MemoryDirectory implements McpExecutionOwnerDirectory { + readonly records = new Map(); + + put(record: McpExecutionOwnerRecord): Effect.Effect { + return Effect.sync(() => { + this.records.set(record.executionId, record); + }); + } + + get(executionId: string): Effect.Effect { + return Effect.sync(() => this.records.get(executionId) ?? null); + } + + delete(executionId: string): Effect.Effect { + return Effect.sync(() => { + this.records.delete(executionId); + }); + } +} + +type Harness = { + approvalResponses: Map; + approvalWaiters: Map; + beginPendingApprovalResume: (executionId: string) => Effect.Effect; + buildMcpServer: () => Effect.Effect; + buildModernMcpRuntime: () => Effect.Effect; + ctx: MemoryContext; + dbHandle: { readonly end: () => void } | null; + engine: ExecutionEngine | null; + executionOwnerDirectory: () => McpExecutionOwnerDirectory; + finishPendingApprovalResume: (executionId: string) => Effect.Effect; + initialized: boolean; + keepAlive: () => Promise<() => void>; + lastActivityMs: number; + modernHandler: null; + modernPausedExecutionHooks: { + readonly onExecutionPaused: ( + executionId: string, + deadline: { readonly expiresAt: string; readonly ttlMs: number } | undefined, + ) => Effect.Effect; + readonly onResumeStarted: (executionId: string) => Effect.Effect; + readonly onResumeSettled: (executionId: string) => Effect.Effect; + }; + modernRequestBodies: WeakMap; + modernRequestPropagation: WeakMap; + modernRequestStateSigningKey: () => string; + modernRunningRequestCount: number; + modernRuntime: BuiltModernMcpRuntime | null; + modernRuntimePromise: Promise | null; + onStartPromise: Promise | null; + openSessionDb: () => { readonly end: () => void }; + pendingApprovalLeases: Map; + resolveSessionMeta: (token: McpSessionInit) => Effect.Effect; + serveModernMcp: ( + request: Request, + props: McpSessionProps, + parsedBody: unknown, + ) => Promise; + server?: never; + sessionMeta: SessionMeta | null; + startPendingApprovalLease: ( + executionId: string, + deadline: { readonly expiresAt: string; readonly ttlMs: number } | undefined, + owner: McpExecutionOwnerRoute, + ) => Effect.Effect; +}; + +const makeEngine = (): { + readonly engine: ExecutionEngine; + readonly resumeCalls: ResumeResponse[]; +} => { + const paused = new Map(); + const resumeCalls: ResumeResponse[] = []; + const execution: PausedExecution = { + id: EXECUTION_ID, + elicitationContext: { + address: ToolAddress.make("tools.test.org.main.confirm"), + args: {}, + request: FormElicitation.make({ message: "Confirm?", requestedSchema: {} }), + }, + }; + const engine: ExecutionEngine = { + execute: () => Effect.succeed({ result: "unused" }), + executeWithPause: () => + Effect.sync(() => { + paused.set(execution.id, execution); + return { status: "paused" as const, execution }; + }), + resume: (executionId, response) => + Effect.sync((): ExecutionResult | null => { + if (!paused.delete(executionId)) return null; + resumeCalls.push(response); + return { status: "completed", result: { result: response.content?.approved } }; + }), + isExecutionSettled: () => Effect.succeed(false), + getPausedExecution: (executionId) => Effect.sync(() => paused.get(executionId) ?? null), + pausedExecutionCount: () => Effect.sync(() => paused.size), + hasPausedExecutions: () => Effect.sync(() => paused.size > 0), + getDescription: Effect.succeed("test engine"), + }; + return { engine, resumeCalls }; +}; + +const makeHarness = () => { + const ctx = new MemoryContext(); + const directory = new MemoryDirectory(); + const { engine, resumeCalls } = makeEngine(); + const session = Object.create(McpAgentSessionDOBase.prototype) as Harness; + session.ctx = ctx; + session.engine = null; + session.dbHandle = null; + session.sessionMeta = null; + session.modernRuntime = null; + session.modernRuntimePromise = null; + session.modernHandler = null; + session.modernRunningRequestCount = 0; + session.modernRequestBodies = new WeakMap(); + session.modernRequestPropagation = new WeakMap(); + session.initialized = false; + session.onStartPromise = null; + session.lastActivityMs = 0; + session.approvalResponses = new Map(); + session.approvalWaiters = new Map(); + session.pendingApprovalLeases = new Map(); + session.openSessionDb = () => ({ end: () => undefined }); + session.keepAlive = () => Promise.resolve(() => undefined); + session.executionOwnerDirectory = () => directory; + session.modernRequestStateSigningKey = () => REQUEST_STATE_KEY; + session.resolveSessionMeta = (token) => + Effect.succeed({ + organizationId: token.organizationId, + organizationName: "Test Org", + userId: token.userId, + resource: token.resource, + elicitationMode: token.elicitationMode, + artifactsEnabled: token.artifactsEnabled, + webOrigin: token.webOrigin, + }); + session.buildMcpServer = () => Effect.die("legacy build is not used by this harness"); + session.modernPausedExecutionHooks = { + onExecutionPaused: (executionId, deadline) => + session.startPendingApprovalLease( + executionId, + deadline, + modernMcpExecutionOwnerRoute(ctx.id.toString()), + ), + onResumeStarted: (executionId) => session.beginPendingApprovalResume(executionId), + onResumeSettled: (executionId) => session.finishPendingApprovalResume(executionId), + }; + session.buildModernMcpRuntime = () => + Effect.succeed({ + engine, + buildServer: (options: ModernMcpServerRequestOptions) => + buildMcpServer({ + engine, + elicitationMode: { mode: "native" }, + pausedExecutionHooks: session.modernPausedExecutionHooks, + pausedExecutionLeaseMs: PAUSED_APPROVAL_TIMEOUT_MS, + ...options, + }), + }); + return { session, directory, resumeCalls }; +}; + +const requestBody = (input?: { readonly requestState?: string }) => ({ + jsonrpc: "2.0", + id: input?.requestState ? 2 : 1, + method: "tools/call", + params: { + name: "execute", + arguments: { code: "await tools.test.confirm()" }, + ...(input?.requestState + ? { + requestState: input.requestState, + inputResponses: { + elicitation: { action: "accept", content: { approved: true } }, + }, + } + : {}), + _meta: { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": { elicitation: { form: {} } }, + }, + }, +}); + +const requestFor = (body: ReturnType): Request => + new Request("https://executor.test/mcp", { + method: "POST", + headers: { + "content-type": "application/json", + "mcp-protocol-version": "2026-07-28", + "mcp-method": "tools/call", + "mcp-name": "execute", + "x-executor-mcp-account-id": "acct_1", + "x-executor-mcp-organization-id": "org_1", + "x-executor-mcp-resource-key": "default", + }, + body: JSON.stringify(body), + }); + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const requestStateFromResponse = (value: unknown): string | null => { + if (!isRecord(value) || !isRecord(value.result)) return null; + return typeof value.result.requestState === "string" ? value.result.requestState : null; +}; + +describe("McpAgentSessionDOBase modern entry", () => { + it("serves a pause, registers modern ownership, and resumes in the same DO", async () => { + const { session, directory, resumeCalls } = makeHarness(); + const props: McpSessionProps = { + session: { + organizationId: "org_1", + userId: "acct_1", + elicitationMode: "native", + resource: defaultMcpResource, + webOrigin: "https://executor.test", + }, + }; + + const firstBody = requestBody(); + const first = await session.serveModernMcp(requestFor(firstBody), props, firstBody); + const firstPayload: unknown = await first.json(); + const requestState = requestStateFromResponse(firstPayload); + + expect(first.status).toBe(200); + expect(requestState).not.toBeNull(); + expect(directory.records.get(EXECUTION_ID)).toMatchObject({ + executionId: EXECUTION_ID, + owner: { sessionId: "modern:modern-do-id" }, + accountId: "acct_1", + organizationId: "org_1", + ttlMs: PAUSED_APPROVAL_TIMEOUT_MS, + }); + if (!requestState) return; + + const secondBody = requestBody({ requestState }); + const second = await session.serveModernMcp(requestFor(secondBody), props, secondBody); + const secondText = JSON.stringify(await second.json()); + + expect(second.status).toBe(200); + expect(secondText).toContain("true"); + expect(resumeCalls).toEqual([{ action: "accept", content: { approved: true } }]); + expect(directory.records.has(EXECUTION_ID)).toBe(false); + }); +}); diff --git a/packages/hosts/cloudflare/src/mcp/agents-event-store.test.ts b/packages/hosts/cloudflare/src/mcp/agents-event-store.test.ts deleted file mode 100644 index e97bbbbfe8..0000000000 --- a/packages/hosts/cloudflare/src/mcp/agents-event-store.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -// Unit coverage for the patched agents DurableObjectEventStore (see -// patches/agents@0.17.3.patch). The store is the durable half of the MCP -// result-replay fix: a final tool response persisted here is the only copy a -// recovery GET can replay after the client's POST response body died, so -// trimStream must never evict it. -import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest"; -import { DurableObjectEventStore } from "agents/mcp"; - -type ListOptions = { - readonly prefix?: string; - readonly start?: string; - readonly limit?: number; - readonly reverse?: boolean; -}; - -/** Minimal in-memory stand-in for DurableObjectStorage's sorted KV surface. */ -const makeFakeStorage = () => { - const entries = new Map(); - return { - entries, - put: (key: string, value: unknown) => { - entries.set(key, value); - return Promise.resolve(); - }, - delete: (keys: string | ReadonlyArray) => { - for (const key of Array.isArray(keys) ? keys : [keys]) entries.delete(key); - return Promise.resolve(); - }, - list: (options: ListOptions = {}) => { - const keys = [...entries.keys()] - .filter((key) => (options.prefix === undefined ? true : key.startsWith(options.prefix))) - .filter((key) => (options.start === undefined ? true : key >= options.start)) - .sort(); - if (options.reverse === true) keys.reverse(); - const limited = options.limit === undefined ? keys : keys.slice(0, options.limit); - return Promise.resolve(new Map(limited.map((key) => [key, entries.get(key)]))); - }, - }; -}; - -const makeStore = () => { - const storage = makeFakeStorage(); - // The store only touches put/list/delete; the fake covers exactly that. - const store = new DurableObjectEventStore(storage as never); - return { storage, store }; -}; - -const eventKeys = (storage: ReturnType): ReadonlyArray => - [...storage.entries.keys()].sort(); - -describe("DurableObjectEventStore trimStream", () => { - let warnings: string[] = []; - - beforeEach(() => { - warnings = []; - vi.spyOn(console, "warn").mockImplementation((line: string) => { - warnings.push(line); - }); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("skips storing a message beyond DO storage's per-value cap, returning no replay id", async () => { - // Real DO storage rejects any value over 128 KiB. This used to be - // discovered inside storage.put — thrown BEFORE the live SSE write, so an - // oversize response (the ~5MB ui:// shell document) was neither stored nor - // delivered and the client hung on keepalives. The pinned contract now: - // oversize messages skip persistence with a warning and resolve undefined, - // and the caller delivers them live without a replay id. - const { storage, store } = makeStore(); - const hugeResult = { - jsonrpc: "2.0" as const, - id: 1, - result: { content: [{ type: "text", text: "x".repeat(3 * 1024 * 1024) }] }, - }; - - const eventId = await store.storeEvent("post-stream", hugeResult); - - expect(eventId, "no replay id for an unstorable message").toBeUndefined(); - expect(eventKeys(storage), "nothing was persisted").toEqual([]); - expect(warnings.length, "the skip is logged").toBeGreaterThan(0); - expect(warnings.at(-1)).toContain("mcp_event_store_skipped_oversize"); - expect(warnings.at(-1)).toContain("post-stream"); - }); - - it("does not advance the stream sequence when an oversize message is skipped", async () => { - const { storage, store } = makeStore(); - await store.storeEvent("post-stream", { - jsonrpc: "2.0" as const, - method: "notifications/progress", - params: { blob: "x".repeat(3 * 1024 * 1024) }, - }); - - const eventId = await store.storeEvent("post-stream", { - jsonrpc: "2.0" as const, - id: 1, - result: { ok: true }, - }); - - expect(eventId, "the next storable event takes the first sequence slot").toBe( - "post-stream:0000000000000001", - ); - expect(eventKeys(storage)).toEqual(["__mcp_event__:post-stream:0000000000000001"]); - }); - - it("evicts oldest events at the byte cap but never the newest", async () => { - const { storage, store } = makeStore(); - // 100 KiB each: storable (under the 120 KiB per-value guard), but 25 of - // them exceed the 2 MB per-stream byte cap. - const bigMessage = (marker: string) => ({ - jsonrpc: "2.0" as const, - method: "notifications/progress", - params: { marker, blob: "y".repeat(100 * 1024) }, - }); - - const total = 25; - for (let index = 0; index < total; index += 1) { - await store.storeEvent("post-stream", bigMessage(`msg-${index}`)); - } - - const remaining = eventKeys(storage); - expect(remaining[remaining.length - 1], "the newest event is always retained").toBe( - `__mcp_event__:post-stream:${total.toString(16).padStart(16, "0")}`, - ); - expect( - remaining.length, - "older events were evicted to satisfy the 2MB stream cap", - ).toBeLessThan(total); - expect(warnings.length, "eviction logs a warning").toBeGreaterThan(0); - expect(warnings.at(-1)).toContain("mcp_event_store_evicted"); - expect(warnings.at(-1)).toContain("post-stream"); - }); - - it("evicts oldest events past the per-stream event-count cap", async () => { - const { storage, store } = makeStore(); - const total = 70; // MAX_EVENTS_PER_STREAM is 64 - for (let index = 0; index < total; index += 1) { - await store.storeEvent("chatty-stream", { - jsonrpc: "2.0" as const, - method: "notifications/progress", - params: { index }, - }); - } - - const remaining = eventKeys(storage); - expect(remaining.length, "stream is capped at 64 events").toBe(64); - expect(remaining[remaining.length - 1], "the newest event survives the count cap").toBe( - `__mcp_event__:chatty-stream:${total.toString(16).padStart(16, "0")}`, - ); - expect(remaining[0], "the oldest surviving event is the one just inside the cap").toBe( - `__mcp_event__:chatty-stream:${(total - 63).toString(16).padStart(16, "0")}`, - ); - }); - - it("leaves streams under both caps untouched", async () => { - const { storage, store } = makeStore(); - await store.storeEvent("quiet-stream", { - jsonrpc: "2.0" as const, - id: 1, - result: { ok: true }, - }); - await store.storeEvent("quiet-stream", { - jsonrpc: "2.0" as const, - id: 2, - result: { ok: true }, - }); - - expect(eventKeys(storage)).toEqual([ - "__mcp_event__:quiet-stream:0000000000000001", - "__mcp_event__:quiet-stream:0000000000000002", - ]); - expect(warnings).toEqual([]); - }); -}); diff --git a/packages/hosts/cloudflare/src/mcp/agents-priming-event.test.ts b/packages/hosts/cloudflare/src/mcp/agents-priming-event.test.ts deleted file mode 100644 index 9edd16594c..0000000000 --- a/packages/hosts/cloudflare/src/mcp/agents-priming-event.test.ts +++ /dev/null @@ -1,219 +0,0 @@ -// Unit coverage for the POST-stream priming SSE event (see -// patches/agents@0.17.3.patch). Executor's POST tools/call stream used to emit -// its first and only event `id:` together with the final result, so the MCP TS -// SDK's StreamableHTTPClientTransport never set hasPrimingEvent and would not -// auto-reconnect a stream that dropped mid-call: callTool hung while the DO -// held the completed result. The patch writes a priming event as the first -// frame on the POST stream, carrying a real event-store id that sorts before -// the response so a `last-event-id: ` reconnect replays the result. -// -// Two properties are pinned here against real code: -// 1. Event-store ordering + replay: with the real DurableObjectEventStore, a -// priming event stored before the response sorts first, and -// replayEventsAfter(primingId) yields exactly the response. -// 2. Client contract: fed the exact priming frame the transport emits, the -// real SDK StreamableHTTPClientTransport records the priming id (so it -// would reconnect) WITHOUT dispatching it as a JSON-RPC message, then -// dispatches a following result frame normally. -import { describe, expect, it } from "@effect/vitest"; -import { DurableObjectEventStore } from "agents/mcp"; -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; -import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; - -type ListOptions = { - readonly prefix?: string; - readonly start?: string; - readonly limit?: number; - readonly reverse?: boolean; -}; - -/** Minimal in-memory stand-in for DurableObjectStorage's sorted KV surface. */ -const makeFakeStorage = () => { - const entries = new Map(); - return { - entries, - put: (key: string, value: unknown) => { - entries.set(key, value); - return Promise.resolve(); - }, - delete: (keys: string | ReadonlyArray) => { - for (const key of Array.isArray(keys) ? keys : [keys]) entries.delete(key); - return Promise.resolve(); - }, - list: (options: ListOptions = {}) => { - const keys = [...entries.keys()] - .filter((key) => (options.prefix === undefined ? true : key.startsWith(options.prefix))) - .filter((key) => (options.start === undefined ? true : key >= options.start)) - .sort(); - if (options.reverse === true) keys.reverse(); - const limited = options.limit === undefined ? keys : keys.slice(0, options.limit); - return Promise.resolve(new Map(limited.map((key) => [key, entries.get(key)]))); - }, - }; -}; - -// The exact priming notification the patched transport persists, and the exact -// SSE frame it writes to the client. Mirrors emitPrimingEvent / -// writePrimingSSEEvent in patches/agents@0.17.3.patch: a benign JSON-RPC -// notification stored (so a plain-GET replay via writeSSEEvent is ignorable), -// framed live under a non-`message` event type so the SDK primes but does not -// dispatch it. -const PRIMING_MESSAGE = { - jsonrpc: "2.0" as const, - method: "notifications/message", - params: { level: "debug", data: "mcp-stream-priming" }, -}; -const primingFrame = (eventId: string): string => - `event: mcp-priming\nid: ${eventId}\ndata: ${JSON.stringify(PRIMING_MESSAGE)}\n\n`; -const messageFrame = (eventId: string, message: unknown): string => - `event: message\nid: ${eventId}\ndata: ${JSON.stringify(message)}\n\n`; - -describe("POST-stream priming event: store ordering and replay", () => { - it("persists the priming event before the response so replayEventsAfter(primingId) yields the response", async () => { - const storage = makeFakeStorage(); - const store = new DurableObjectEventStore(storage as never); - const streamId = "post-stream"; - - // Transport order: priming event first, then the tool response. Both are - // tiny, so storeEvent always persists them and returns an id — the - // `undefined` arm of its signature is the oversize skip, pinned in - // agents-event-store.test.ts. - const primingId = await store.storeEvent(streamId, PRIMING_MESSAGE); - const response = { - jsonrpc: "2.0" as const, - id: 1, - result: { content: [{ type: "text", text: "MARKER" }] }, - }; - const responseId = await store.storeEvent(streamId, response); - - expect(primingId, "priming event is seq 1 for the stream").toBe(`${streamId}:0000000000000001`); - expect(responseId, "response is seq 2, after the priming id").toBe( - `${streamId}:0000000000000002`, - ); - expect( - (primingId ?? "") < (responseId ?? ""), - "priming id sorts strictly before the response id", - ).toBe(true); - - const replayed: Array<{ readonly eventId: string; readonly message: unknown }> = []; - await store.replayEventsAfter(primingId ?? "", { - send: async (eventId: string, message: unknown) => { - replayed.push({ eventId, message }); - }, - }); - - expect( - replayed.map((entry) => entry.eventId), - "a reconnect with last-event-id= replays exactly the response", - ).toEqual([responseId]); - expect(replayed[0]?.message).toEqual(response); - }); - - it("does not replay the priming event itself on a last-event-id reconnect", async () => { - const storage = makeFakeStorage(); - const store = new DurableObjectEventStore(storage as never); - const streamId = "post-stream"; - const primingId = await store.storeEvent(streamId, PRIMING_MESSAGE); - expect(primingId, "a tiny priming message always persists and gets an id").toBeDefined(); - - const replayed: string[] = []; - await store.replayEventsAfter(primingId ?? "", { - send: async (eventId: string) => { - replayed.push(eventId); - }, - }); - - expect(replayed, "nothing after the priming event yet, so replay is empty").toEqual([]); - }); -}); - -describe("POST-stream priming event: SDK client contract", () => { - // Drive the real SDK StreamableHTTPClientTransport with a controlled fetch - // that returns a POST tools/call SSE stream: priming frame first, then the - // result. Assert the SDK records the priming id as a resumption token (so it - // would reconnect) but only dispatches the result as a JSON-RPC message. - const drivePostStream = async (frames: ReadonlyArray) => { - const encoder = new TextEncoder(); - const body = new ReadableStream({ - start(controller) { - for (const frame of frames) controller.enqueue(encoder.encode(frame)); - controller.close(); - }, - }); - // oxlint-disable-next-line executor/no-double-cast -- boundary: a minimal fetch stub for a unit test; only the Response shape the SDK reads matters. - const fetchStub = (async () => - new Response(body, { - status: 200, - headers: { "content-type": "text/event-stream" }, - })) as unknown as typeof fetch; - - const transport = new StreamableHTTPClientTransport(new URL("https://executor.sh/mcp"), { - fetch: fetchStub, - }); - - const messages: JSONRPCMessage[] = []; - const resumptionTokens: string[] = []; - transport.onmessage = (message) => { - messages.push(message); - }; - - await transport.start(); - // POST a tools/call request; the stub returns the SSE stream above. - await transport.send( - { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "execute", arguments: {} } }, - { - onresumptiontoken: (token: string) => { - resumptionTokens.push(token); - }, - }, - ); - // Let the SSE stream drain. - for (let i = 0; i < 20; i += 1) await Promise.resolve(); - await transport.close(); - return { messages, resumptionTokens }; - }; - - it("records the priming id as a resumption token without dispatching it, then dispatches the result", async () => { - const primingId = "post-stream:0000000000000001"; - const responseId = "post-stream:0000000000000002"; - const result = { - jsonrpc: "2.0" as const, - id: 1, - result: { content: [{ type: "text", text: "MARKER" }] }, - }; - - const { messages, resumptionTokens } = await drivePostStream([ - primingFrame(primingId), - messageFrame(responseId, result), - ]); - - expect( - resumptionTokens, - "the SDK records the priming id first (this is what sets hasPrimingEvent), then the response id", - ).toEqual([primingId, responseId]); - expect( - messages, - "the priming frame is NOT dispatched as a JSON-RPC message; only the result is", - ).toEqual([result]); - }); - - it("would not prime on a stream whose first event id arrives only with the result (the old behavior)", async () => { - // Sanity anchor for the fix: without a priming frame, the first recorded - // resumption token is the result's own id, which the SDK only sees at the - // same instant it receives the result. There is no earlier id to reconnect - // from, which is exactly the hang this patch removes. - const responseId = "post-stream:0000000000000001"; - const result = { - jsonrpc: "2.0" as const, - id: 1, - result: { content: [{ type: "text", text: "MARKER" }] }, - }; - - const { messages, resumptionTokens } = await drivePostStream([ - messageFrame(responseId, result), - ]); - - expect(resumptionTokens, "the only id ever seen is the result's own id").toEqual([responseId]); - expect(messages).toEqual([result]); - }); -}); diff --git a/packages/hosts/cloudflare/src/mcp/agents-sse-max-age.test.ts b/packages/hosts/cloudflare/src/mcp/agents-sse-max-age.test.ts deleted file mode 100644 index 5a603b47bf..0000000000 --- a/packages/hosts/cloudflare/src/mcp/agents-sse-max-age.test.ts +++ /dev/null @@ -1,570 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest"; -import { MAX_SSE_AGE_MS, McpAgent } from "agents/mcp"; -import { Effect, Option, Schema } from "effect"; - -import { SESSION_TIMEOUT_MS } from "./session-alarm-policy"; - -const KEEPALIVE_INTERVAL_MS = 25_000; -const MAX_PENDING_SSE_BYTES = 8 * 1024 * 1024; - -type FakeWebSocket = EventTarget & { - accepted: boolean; - closeCode: number | undefined; - closeReason: string | undefined; - sent: string[]; - accept: () => void; - close: (code?: number, reason?: string) => void; - send: (message: string) => void; -}; - -type FakeAgentStub = { - readonly setName: (name: string, props?: unknown) => Promise; - readonly getInitializeRequest: () => Promise; - readonly fetch: (request: Request) => Promise<{ readonly webSocket: FakeWebSocket }>; -}; - -type RotationLog = { - readonly event: "sse_max_age_close"; - readonly sessionId: string; - readonly variant: "streamable-get" | "streamable-post" | "legacy-sse"; - readonly ageMs: number; - readonly pendingBytes: number; -}; - -const encoder = new TextEncoder(); -const RotationLogEvent = Schema.Struct({ - ageMs: Schema.Number, - event: Schema.Literal("sse_max_age_close"), - pendingBytes: Schema.Number, - sessionId: Schema.String, - variant: Schema.Union([ - Schema.Literal("streamable-get"), - Schema.Literal("streamable-post"), - Schema.Literal("legacy-sse"), - ]), -}); -const DeliveryAck = Schema.Struct({ - streamId: Schema.String, - type: Schema.Literal("cf_mcp_delivery_ack"), -}); -const decodeRotationLogEvent = Schema.decodeUnknownOption(Schema.fromJsonString(RotationLogEvent)); -const decodeDeliveryAck = Schema.decodeUnknownOption(Schema.fromJsonString(DeliveryAck)); -const deliveryAcks = (sent: ReadonlyArray): ReadonlyArray => - sent.flatMap((line) => { - const decoded = decodeDeliveryAck(line); - return Option.isSome(decoded) ? [decoded.value.streamId] : []; - }); - -const flushMicrotasks = async (): Promise => { - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); -}; - -const waitFor = async (predicate: () => boolean): Promise => { - for (let attempt = 0; attempt < 20; attempt += 1) { - if (predicate()) return; - await flushMicrotasks(); - } - expect(predicate()).toBe(true); -}; - -const drainResponse = async (response: Response): Promise => { - const decoder = new TextDecoder(); - let body = ""; - - await Effect.runPromise( - Effect.ignore( - Effect.tryPromise({ - try: () => - response.body?.pipeTo( - new WritableStream({ - close: () => { - body += decoder.decode(); - }, - write: (chunk) => { - body += decoder.decode(chunk, { stream: true }); - }, - }), - ) ?? Promise.resolve(), - catch: () => undefined, - }), - ), - ); - - return body; -}; - -const installStallingTransformStream = () => { - let abortReason: unknown; - let writeCount = 0; - let stalledWriteStarted: (() => void) | undefined; - const stalledWrite = new Promise((resolve) => { - stalledWriteStarted = resolve; - }); - const writer = { - abort: (reason: unknown) => { - abortReason = reason; - return Promise.resolve(); - }, - close: () => Promise.resolve(), - write: () => { - writeCount += 1; - stalledWriteStarted?.(); - return new Promise(() => {}); - }, - }; - - vi.stubGlobal( - "TransformStream", - class { - readonly readable = new ReadableStream(); - readonly writable = { - getWriter: () => writer, - }; - }, - ); - - return { - abortReason: () => abortReason, - stalledWrite, - writeCount: () => writeCount, - }; -}; - -const installRejectingTransformStream = () => { - let abortReason: unknown; - let writeCount = 0; - const writer = { - abort: (reason: unknown) => { - abortReason = reason; - return Promise.resolve(); - }, - close: () => Promise.resolve(), - write: () => { - writeCount += 1; - // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: fake writer models WHATWG stream write rejection in a unit test. - return Promise.reject(new Error("client disconnected")); - }, - }; - - vi.stubGlobal( - "TransformStream", - class { - readonly readable = new ReadableStream(); - readonly writable = { - getWriter: () => writer, - }; - }, - ); - - return { - abortReason: () => abortReason, - writeCount: () => writeCount, - }; -}; - -const installClosedRejectingTransformStream = () => { - let abortReason: unknown; - let rejectClosed: ((error: Error) => void) | undefined; - const closed = new Promise((_, reject) => { - rejectClosed = reject; - }); - const writer = { - abort: (reason: unknown) => { - abortReason = reason; - return Promise.resolve(); - }, - close: () => Promise.resolve(), - closed, - write: () => Promise.resolve(), - }; - - vi.stubGlobal( - "TransformStream", - class { - readonly readable = new ReadableStream(); - readonly writable = { - getWriter: () => writer, - }; - }, - ); - - return { - abortReason: () => abortReason, - // oxlint-disable-next-line executor/no-error-constructor -- boundary: fake writer models WHATWG writer.closed rejection in a unit test. - rejectClosed: () => rejectClosed?.(new Error("client canceled response body")), - }; -}; - -const makeExecutionContext = (): ExecutionContext => ({ - passThroughOnException: () => {}, - props: undefined, - waitUntil: () => {}, -}); - -const makeWebSocket = (): FakeWebSocket => { - const ws = new EventTarget() as FakeWebSocket; - ws.accepted = false; - ws.closeCode = undefined; - ws.closeReason = undefined; - ws.sent = []; - ws.accept = () => { - ws.accepted = true; - }; - ws.close = (code?: number, reason?: string) => { - ws.closeCode = code; - ws.closeReason = reason; - }; - ws.send = (message: string) => { - ws.sent.push(message); - }; - return ws; -}; - -const makeAgentStub = (ws: FakeWebSocket): FakeAgentStub => ({ - setName: async () => {}, - getInitializeRequest: async () => ({}), - fetch: async () => ({ webSocket: ws }), -}); - -const makeNamespace = (agent: FakeAgentStub) => ({ - newUniqueId: () => ({ toString: () => "generated-session" }), - idFromName: (name: string) => ({ - equals: () => true, - name, - toString: () => name, - }), - get: () => agent, -}); - -const openSse = async () => { - const ws = makeWebSocket(); - const agent = makeAgentStub(ws); - const namespace = makeNamespace(agent); - const handler = McpAgent.serve("/mcp", { - binding: "MCP_SESSION", - transport: "streamable-http", - }); - const response = await handler.fetch( - new Request("https://executor.sh/mcp", { - headers: { - accept: "text/event-stream", - "mcp-session-id": "session-1", - }, - method: "GET", - }), - { MCP_SESSION: namespace }, - makeExecutionContext(), - ); - - expect(response.status).toBe(200); - expect(ws.accepted).toBe(true); - expect(response.body).toBeDefined(); - - return { response, ws }; -}; - -const openPostSse = async () => { - const ws = makeWebSocket(); - const agent = makeAgentStub(ws); - const namespace = makeNamespace(agent); - const handler = McpAgent.serve("/mcp", { - binding: "MCP_SESSION", - transport: "streamable-http", - }); - const response = await handler.fetch( - new Request("https://executor.sh/mcp", { - body: JSON.stringify({ - id: 1, - jsonrpc: "2.0", - method: "tools/call", - params: { - arguments: {}, - name: "example", - }, - }), - headers: { - accept: "application/json, text/event-stream", - "content-type": "application/json", - "mcp-session-id": "session-1", - }, - method: "POST", - }), - { MCP_SESSION: namespace }, - makeExecutionContext(), - ); - - expect(response.status).toBe(200); - expect(ws.accepted).toBe(true); - expect(response.body).toBeDefined(); - - return { response, ws }; -}; - -const emitAgentEvent = ( - ws: FakeWebSocket, - event: string, - close?: true, - extra?: Record, -): void => { - ws.dispatchEvent( - new MessageEvent("message", { - data: JSON.stringify({ - close, - event, - type: "cf_mcp_agent_event", - ...extra, - }), - }), - ); -}; - -const emitClose = (ws: FakeWebSocket): void => { - ws.dispatchEvent(new Event("close")); -}; - -const rotationLogs = (logs: ReadonlyArray): ReadonlyArray => - logs.flatMap((line) => { - const decoded = decodeRotationLogEvent(line); - return Option.isSome(decoded) ? [decoded.value] : []; - }); - -describe("agents SSE max-age rotation", () => { - let errorLogs: string[] = []; - let infoLogs: string[] = []; - - beforeEach(() => { - errorLogs = []; - infoLogs = []; - vi.useFakeTimers(); - vi.setSystemTime(0); - vi.spyOn(console, "error").mockImplementation((line) => { - errorLogs.push(String(line)); - }); - vi.spyOn(console, "log").mockImplementation((line) => { - infoLogs.push(String(line)); - }); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - vi.restoreAllMocks(); - vi.useRealTimers(); - }); - - it("keeps the default max age well above the session idle timeout", () => { - expect(MAX_SSE_AGE_MS).toBe(30 * 60 * 1000); - expect(MAX_SSE_AGE_MS).toBeGreaterThanOrEqual(6 * SESSION_TIMEOUT_MS); - }); - - it("closes a healthy draining SSE connection within one keepalive tick after max age", async () => { - const { response, ws } = await openSse(); - const drained = drainResponse(response); - - await vi.advanceTimersByTimeAsync(MAX_SSE_AGE_MS + KEEPALIVE_INTERVAL_MS); - await waitFor(() => ws.closeCode === 1000); - - expect(ws.closeReason).toBe("sse_max_age_rotation"); - const [rotationLog] = rotationLogs(infoLogs); - expect(rotationLog?.event).toBe("sse_max_age_close"); - expect(rotationLog?.ageMs).toBeGreaterThan(MAX_SSE_AGE_MS); - expect(rotationLog?.ageMs).toBeLessThanOrEqual(MAX_SSE_AGE_MS + KEEPALIVE_INTERVAL_MS); - expect(rotationLog?.pendingBytes).toBeGreaterThanOrEqual(0); - expect(errorLogs).toEqual([]); - expect(vi.getTimerCount()).toBe(0); - - await expect(drained).resolves.toContain(": max-age rotation, reconnect\n\n"); - }); - - it("does not rotate an in-flight POST response past max age", async () => { - const { response, ws } = await openPostSse(); - const drained = drainResponse(response); - - emitAgentEvent(ws, `event: message\ndata: {"jsonrpc":"2.0","id":1,"result":{}}\n\n`); - await vi.advanceTimersByTimeAsync(MAX_SSE_AGE_MS + KEEPALIVE_INTERVAL_MS * 4); - await flushMicrotasks(); - - expect(ws.closeCode).toBeUndefined(); - expect(ws.closeReason).toBeUndefined(); - expect(rotationLogs(infoLogs)).toEqual([]); - - emitAgentEvent( - ws, - `event: message\ndata: {"jsonrpc":"2.0","id":1,"result":{"ok":true}}\n\n`, - true, - ); - await drained; - - expect(ws.closeCode).toBe(1000); - expect(ws.closeReason).toBe("SSE response delivered"); - expect(vi.getTimerCount()).toBe(0); - }); - - it("never acknowledges a POST response delivery, even after a clean drain", async () => { - // workerd resolves writer.close() even when the client canceled the POST - // response body, so a clean close is not proof of delivery. The bridge must - // NOT send cf_mcp_delivery_ack for POST streams: the DO keeps the response - // persisted and the client's reconnect GET replays and acks it instead. - const { response, ws } = await openPostSse(); - const drained = drainResponse(response); - - emitAgentEvent( - ws, - `event: message\nid: post-stream:0000000000000001\ndata: {"jsonrpc":"2.0","id":1,"result":{"ok":true}}\n\n`, - true, - { - eventId: "post-stream:0000000000000001", - streamId: "post-stream", - }, - ); - await drained; - - expect(ws.closeCode).toBe(1000); - expect(ws.closeReason).toBe("SSE response delivered"); - expect(ws.sent).toEqual([]); - }); - - it("treats an SSE writer rejection as terminal and does not acknowledge delivery", async () => { - const transform = installRejectingTransformStream(); - const { ws } = await openPostSse(); - - emitAgentEvent( - ws, - `event: message\nid: post-stream:0000000000000001\ndata: {"jsonrpc":"2.0","id":1,"result":{"ok":true}}\n\n`, - true, - { - eventId: "post-stream:0000000000000001", - streamId: "post-stream", - }, - ); - await waitFor(() => ws.closeCode === 1013); - - expect(transform.writeCount()).toBe(1); - expect(transform.abortReason()).toBeInstanceOf(Error); - expect(ws.closeReason).toBe("SSE client not draining"); - expect(ws.sent).toEqual([]); - expect(vi.getTimerCount()).toBe(0); - }); - - it("does not acknowledge a final POST response after the response body was canceled", async () => { - const transform = installClosedRejectingTransformStream(); - const { ws } = await openPostSse(); - - transform.rejectClosed(); - await flushMicrotasks(); - - emitAgentEvent( - ws, - `event: message\nid: post-stream:0000000000000001\ndata: {"jsonrpc":"2.0","id":1,"result":{"ok":true}}\n\n`, - true, - { - eventId: "post-stream:0000000000000001", - streamId: "post-stream", - }, - ); - await flushMicrotasks(); - - expect(transform.abortReason()).toBeInstanceOf(Error); - expect(ws.closeCode).toBeUndefined(); - expect(ws.sent).toEqual([]); - expect(vi.getTimerCount()).toBe(0); - }); - - it("acks each replayed stream only after the recovery GET drained and closed", async () => { - // The replay path never clears storage on enqueue: the transport sends a - // replay-complete close frame and the bridge echoes one delivery ack per - // replayed stream only once writer.close() resolved with the client still - // attached. McpAgent.onMessage clears storage on those acks. - const { response, ws } = await openSse(); - const drained = drainResponse(response); - - emitAgentEvent( - ws, - `event: message\nid: stream-a:0000000000000001\ndata: {"jsonrpc":"2.0","id":1,"result":{"ok":true}}\n\n`, - ); - emitAgentEvent(ws, ": replay-complete\n\n", true, { - ackStreamIds: ["stream-a", "stream-b"], - }); - await drained; - - expect(ws.closeCode).toBe(1000); - expect(ws.closeReason).toBe("SSE response delivered"); - expect(deliveryAcks(ws.sent), "one ack per replayed stream").toEqual(["stream-a", "stream-b"]); - }); - - it("does not ack replayed streams when the recovery GET body was canceled", async () => { - // A recovery GET can itself be a dead pipe (workerd surfaces nothing at - // write time). The bridge must not ack in that case, so the responses stay - // persisted and replayable for the next reconnect. - const transform = installClosedRejectingTransformStream(); - const { ws } = await openSse(); - - transform.rejectClosed(); - await flushMicrotasks(); - - emitAgentEvent( - ws, - `event: message\nid: stream-a:0000000000000001\ndata: {"jsonrpc":"2.0","id":1,"result":{"ok":true}}\n\n`, - ); - emitAgentEvent(ws, ": replay-complete\n\n", true, { - ackStreamIds: ["stream-a"], - }); - await flushMicrotasks(); - - expect(transform.abortReason()).toBeInstanceOf(Error); - expect(deliveryAcks(ws.sent), "no acks for a dead recovery GET").toEqual([]); - expect(vi.getTimerCount()).toBe(0); - }); - - it("leaves an SSE connection younger than max age untouched", async () => { - const { response, ws } = await openSse(); - const drained = drainResponse(response); - - await vi.advanceTimersByTimeAsync(MAX_SSE_AGE_MS - KEEPALIVE_INTERVAL_MS * 2); - await flushMicrotasks(); - - expect(ws.closeCode).toBeUndefined(); - expect(rotationLogs(infoLogs)).toEqual([]); - - emitClose(ws); - await drained; - expect(vi.getTimerCount()).toBe(0); - }); - - it("still closes a stalled SSE writer at the byte cap without logging rotation", async () => { - const stalledFrame = `event: message\ndata: ${"x".repeat(2 * 1024 * 1024)}\n\n`; - const transform = installStallingTransformStream(); - const { ws } = await openSse(); - - emitAgentEvent(ws, stalledFrame); - await transform.stalledWrite; - expect(transform.writeCount()).toBe(1); - - const stalledFrameBytes = encoder.encode(stalledFrame).byteLength; - expect(stalledFrameBytes).toBeLessThan(MAX_PENDING_SSE_BYTES); - - for (let attempt = 0; attempt < 8 && ws.closeCode === undefined; attempt += 1) { - emitAgentEvent(ws, stalledFrame); - } - - expect(ws.closeCode).toBe(1013); - expect(ws.closeReason).toBe("SSE client not draining"); - expect(transform.abortReason()).toBeInstanceOf(Error); - expect(rotationLogs(infoLogs)).toEqual([]); - expect(vi.getTimerCount()).toBe(0); - }); - - it("cleans up timers when the client closes before max age", async () => { - const { response, ws } = await openSse(); - const drained = drainResponse(response); - - await vi.advanceTimersByTimeAsync(KEEPALIVE_INTERVAL_MS); - emitClose(ws); - await drained; - - expect(ws.closeCode).toBeUndefined(); - expect(rotationLogs(infoLogs)).toEqual([]); - expect(vi.getTimerCount()).toBe(0); - }); -}); diff --git a/packages/hosts/cloudflare/src/mcp/do-event-store.test.ts b/packages/hosts/cloudflare/src/mcp/do-event-store.test.ts new file mode 100644 index 0000000000..938e9e12e5 --- /dev/null +++ b/packages/hosts/cloudflare/src/mcp/do-event-store.test.ts @@ -0,0 +1,189 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest"; + +import { DurableObjectMcpEventStore } from "./do-event-store"; + +type ListOptions = { + readonly prefix?: string; + readonly start?: string; + readonly startAfter?: string; + readonly limit?: number; + readonly reverse?: boolean; +}; + +const makeFakeStorage = () => { + const entries = new Map(); + let failWrites = false; + return { + entries, + failWrites: () => { + failWrites = true; + }, + put: async (key: string, value: unknown) => { + if (failWrites) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- test boundary: simulate the Promise-based Durable Object storage API rejecting + throw new Error("storage unavailable"); + } + entries.set(key, value); + }, + delete: (keys: string | ReadonlyArray) => { + for (const key of Array.isArray(keys) ? keys : [keys]) entries.delete(key); + return Promise.resolve(true); + }, + list: (options: ListOptions = {}) => { + const keys = [...entries.keys()] + .filter((key) => (options.prefix === undefined ? true : key.startsWith(options.prefix))) + .filter((key) => (options.start === undefined ? true : key >= options.start)) + .filter((key) => (options.startAfter === undefined ? true : key > options.startAfter)) + .sort(); + if (options.reverse === true) keys.reverse(); + const limited = options.limit === undefined ? keys : keys.slice(0, options.limit); + return Promise.resolve(new Map(limited.map((key) => [key, entries.get(key)]))); + }, + }; +}; + +const makeStore = () => { + const storage = makeFakeStorage(); + const store = new DurableObjectMcpEventStore(storage as never); + return { storage, store }; +}; + +const eventKeys = (storage: ReturnType): ReadonlyArray => + [...storage.entries.keys()].sort(); + +describe("DurableObjectMcpEventStore", () => { + let warnings: string[] = []; + + beforeEach(() => { + warnings = []; + vi.spyOn(console, "warn").mockImplementation((line: string) => { + warnings.push(line); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("leaves an oversize event live-only without touching Durable Object storage", async () => { + const { storage, store } = makeStore(); + const eventId = await store.storeEvent("post-stream", { + jsonrpc: "2.0", + id: 1, + result: { content: [{ type: "text", text: "x".repeat(3 * 1024 * 1024) }] }, + }); + + expect(eventId).toBe("post-stream:0000000000000001"); + expect(eventKeys(storage)).toEqual([]); + expect(warnings.at(-1)).toContain("mcp_event_store_skipped_oversize"); + }); + + it("does not reject the send path when Durable Object storage fails", async () => { + const { storage, store } = makeStore(); + storage.failWrites(); + + await expect( + store.storeEvent("post-stream", { jsonrpc: "2.0", id: 1, result: { ok: true } }), + ).resolves.toBe("post-stream:0000000000000001"); + expect(warnings.at(-1)).toContain("mcp_event_store_put_failed"); + }); + + it("replays stored events strictly after the client's last event", async () => { + const { store } = makeStore(); + const first = await store.storeEvent("post-stream", { + jsonrpc: "2.0", + method: "notifications/progress", + params: { progress: 1 }, + }); + const second = await store.storeEvent("post-stream", { + jsonrpc: "2.0", + id: 1, + result: { ok: true }, + }); + const replayed: string[] = []; + + const streamId = await store.replayEventsAfter(first, { + send: (eventId) => { + replayed.push(eventId); + return Promise.resolve(); + }, + }); + + expect(streamId).toBe("post-stream"); + expect(replayed).toEqual([second]); + }); + + it("replays a marked POST stream on standalone recovery and clears it after acknowledgement", async () => { + const { storage, store } = makeStore(); + await store.markStreamUndelivered("post-stream"); + const prime = await store.storeEvent("post-stream", { + jsonrpc: "2.0", + method: "notifications/message", + params: { level: "debug", data: "prime" }, + }); + const result = await store.storeEvent("post-stream", { + jsonrpc: "2.0", + id: "call-1", + result: { content: [{ type: "text", text: "completed" }] }, + }); + const replayed: string[] = []; + + const streamIds = await store.replayUndeliveredStreams({ + send: (eventId) => { + replayed.push(eventId); + return Promise.resolve(); + }, + }); + + expect(streamIds).toEqual(["post-stream"]); + expect(replayed).toEqual([prime, result]); + + await store.acknowledgeUndeliveredStreams(streamIds); + await expect( + store.replayUndeliveredStreams({ send: () => Promise.resolve() }), + ).resolves.toEqual([]); + expect(eventKeys(storage)).toEqual([]); + }); + + it("evicts oldest events at the byte cap but never the newest", async () => { + const { storage, store } = makeStore(); + const bigMessage = (marker: string) => ({ + jsonrpc: "2.0" as const, + method: "notifications/progress", + params: { marker, blob: "y".repeat(100 * 1024) }, + }); + + const total = 25; + for (let index = 0; index < total; index += 1) { + await store.storeEvent("post-stream", bigMessage(`msg-${index}`)); + } + + const remaining = eventKeys(storage); + expect(remaining.at(-1)).toBe( + `executor:mcp:v2:event:post-stream:${total.toString(16).padStart(16, "0")}`, + ); + expect(remaining.length).toBeLessThan(total); + expect(warnings.at(-1)).toContain("mcp_event_store_evicted"); + }); + + it("retains only the newest 64 events from a chatty stream", async () => { + const { storage, store } = makeStore(); + const total = 70; + for (let index = 0; index < total; index += 1) { + await store.storeEvent("chatty-stream", { + jsonrpc: "2.0", + method: "notifications/progress", + params: { progress: index }, + }); + } + + const remaining = eventKeys(storage); + expect(remaining).toHaveLength(64); + expect(remaining[0]).toBe( + `executor:mcp:v2:event:chatty-stream:${(total - 63).toString(16).padStart(16, "0")}`, + ); + expect(remaining.at(-1)).toBe( + `executor:mcp:v2:event:chatty-stream:${total.toString(16).padStart(16, "0")}`, + ); + }); +}); diff --git a/packages/hosts/cloudflare/src/mcp/do-event-store.ts b/packages/hosts/cloudflare/src/mcp/do-event-store.ts new file mode 100644 index 0000000000..b0d58bfd3a --- /dev/null +++ b/packages/hosts/cloudflare/src/mcp/do-event-store.ts @@ -0,0 +1,275 @@ +import type { EventId, EventStore, JSONRPCMessage, StreamId } from "@modelcontextprotocol/server"; + +const EVENT_KEY_PREFIX = "executor:mcp:v2:event:"; +const UNDELIVERED_STREAM_KEY_PREFIX = "executor:mcp:v2:undelivered-stream:"; +const SEQUENCE_WIDTH = 16; +const REPLAY_LIMIT = 1_000; +const DELETE_CHUNK_SIZE = 128; + +/** Maximum number of replayable events retained for one SDK response stream. */ +export const MAX_EVENTS_PER_MCP_STREAM = 64; + +/** Maximum approximate JSON bytes retained for one SDK response stream. */ +export const MAX_BYTES_PER_MCP_STREAM = 2 * 1024 * 1024; + +/** + * Conservative payload ceiling below Durable Object storage's 128 KiB + * per-value limit. Larger events remain live-deliverable but are not persisted. + */ +export const MAX_STORABLE_MCP_EVENT_BYTES = 120 * 1024; + +type McpEventStorage = Pick; + +type StoredEntry = { + readonly key: string; + readonly bytes: number; +}; + +const eventPrefix = (streamId: StreamId): string => `${EVENT_KEY_PREFIX}${streamId}:`; + +const undeliveredStreamKey = (streamId: StreamId): string => + `${UNDELIVERED_STREAM_KEY_PREFIX}${streamId}`; + +const eventIdFromKey = (key: string): EventId => key.slice(EVENT_KEY_PREFIX.length); + +const streamIdFromEventId = (eventId: EventId): StreamId | undefined => { + const separator = eventId.lastIndexOf(":"); + return separator > 0 ? eventId.slice(0, separator) : undefined; +}; + +const messageBytes = (message: JSONRPCMessage): number | null => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- serialization boundary: an unstringifiable SDK payload is live-only + try { + return new TextEncoder().encode(JSON.stringify(message)).byteLength; + } catch { + return null; + } +}; + +const logStoreWarning = (event: string, fields: Record): void => { + console.warn(JSON.stringify({ event, ...fields })); +}; + +/** + * MCP replay storage backed by one session Durable Object's KV store. + * + * Writes are deliberately best-effort: storage limits or outages never escape + * into the transport's send path. Every call still returns a monotonic event + * ID so the SDK can deliver the message live; an event whose persistence failed + * simply has no replayable payload behind that ID. + */ +export class DurableObjectMcpEventStore implements EventStore { + private readonly sequenceByStream = new Map(); + private readonly sequenceLoads = new Map>(); + + constructor(private readonly storage: McpEventStorage) {} + + private async ensureSequenceLoaded(streamId: StreamId): Promise { + if (this.sequenceByStream.has(streamId)) return; + const existing = this.sequenceLoads.get(streamId); + if (existing) return existing; + + const loading = (async () => { + let sequence = 0; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- storage boundary: sequence recovery is best-effort and falls back to this isolate's monotonic counter + try { + const rows = await this.storage.list({ + prefix: eventPrefix(streamId), + reverse: true, + limit: 1, + }); + const newestKey = rows.keys().next().value; + if (typeof newestKey === "string") { + const encoded = newestKey.slice(eventPrefix(streamId).length); + const parsed = Number.parseInt(encoded, 16); + if (Number.isSafeInteger(parsed) && parsed >= 0) sequence = parsed; + } + } catch { + logStoreWarning("mcp_event_store_list_failed", { + operation: "load_sequence", + streamId, + }); + } + this.sequenceByStream.set(streamId, sequence); + })(); + this.sequenceLoads.set(streamId, loading); + await loading; + this.sequenceLoads.delete(streamId); + } + + private nextEventId(streamId: StreamId): EventId { + const sequence = (this.sequenceByStream.get(streamId) ?? 0) + 1; + this.sequenceByStream.set(streamId, sequence); + return `${streamId}:${sequence.toString(16).padStart(SEQUENCE_WIDTH, "0")}`; + } + + private async trimStream(streamId: StreamId): Promise { + const rows = await this.storage.list({ + prefix: eventPrefix(streamId), + limit: REPLAY_LIMIT, + }); + let totalBytes = 0; + const entries: StoredEntry[] = Array.from(rows, ([key, message]) => { + const bytes = messageBytes(message) ?? MAX_BYTES_PER_MCP_STREAM; + totalBytes += bytes; + return { key, bytes }; + }); + const deleteKeys: string[] = []; + + while ( + entries.length > 1 && + (entries.length > MAX_EVENTS_PER_MCP_STREAM || totalBytes > MAX_BYTES_PER_MCP_STREAM) + ) { + const evicted = entries.shift(); + if (!evicted) break; + deleteKeys.push(evicted.key); + totalBytes -= evicted.bytes; + } + + if (deleteKeys.length === 0) return; + logStoreWarning("mcp_event_store_evicted", { + streamId, + evictedCount: deleteKeys.length, + remainingCount: entries.length, + remainingBytes: totalBytes, + }); + for (let index = 0; index < deleteKeys.length; index += DELETE_CHUNK_SIZE) { + await this.storage.delete(deleteKeys.slice(index, index + DELETE_CHUNK_SIZE)); + } + } + + /** Store one event if it fits, returning its live-delivery ID in all cases. */ + async storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise { + await this.ensureSequenceLoaded(streamId); + const eventId = this.nextEventId(streamId); + const bytes = messageBytes(message); + if (bytes === null || bytes > MAX_STORABLE_MCP_EVENT_BYTES) { + logStoreWarning("mcp_event_store_skipped_oversize", { + streamId, + messageBytes: bytes, + limit: MAX_STORABLE_MCP_EVENT_BYTES, + }); + return eventId; + } + + // oxlint-disable-next-line executor/no-try-catch-or-throw -- storage boundary: persistence must never prevent the transport's subsequent live write + try { + await this.storage.put(`${EVENT_KEY_PREFIX}${eventId}`, message); + await this.trimStream(streamId); + } catch { + logStoreWarning("mcp_event_store_put_failed", { + streamId, + }); + } + return eventId; + } + + /** Resolve the stream encoded into an Executor event ID. */ + getStreamIdForEventId(eventId: EventId): Promise { + return Promise.resolve(streamIdFromEventId(eventId)); + } + + /** + * Mark a tool-call stream as requiring at-least-once delivery confirmation. + * + * Direct workerd streaming cannot prove that a completed POST body reached + * the remote client, so the marker remains until a later standalone GET + * drains the stream and its response body completes. + */ + async markStreamUndelivered(streamId: StreamId): Promise { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- storage boundary: a marker failure cannot block the live tool request + try { + await this.storage.put(undeliveredStreamKey(streamId), true); + } catch { + logStoreWarning("mcp_event_store_put_failed", { + operation: "mark_undelivered", + streamId, + }); + } + } + + /** Replay every marked POST stream onto a standalone recovery response. */ + async replayUndeliveredStreams({ + send, + }: { + readonly send: (eventId: EventId, message: JSONRPCMessage) => Promise; + }): Promise { + const replayedStreamIds: StreamId[] = []; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- storage/replay boundary: recovery is best-effort and leaves markers intact for a later GET + try { + const markers = await this.storage.list({ + prefix: UNDELIVERED_STREAM_KEY_PREFIX, + limit: REPLAY_LIMIT, + }); + for (const key of markers.keys()) { + const streamId = key.slice(UNDELIVERED_STREAM_KEY_PREFIX.length); + let replayed = false; + const rows = await this.storage.list({ + prefix: eventPrefix(streamId), + limit: REPLAY_LIMIT, + }); + for (const [eventKey, message] of rows) { + await send(eventIdFromKey(eventKey), message); + replayed = true; + } + if (replayed) replayedStreamIds.push(streamId); + } + } catch { + logStoreWarning("mcp_event_store_list_failed", { + operation: "replay_undelivered", + }); + } + return replayedStreamIds; + } + + /** Clear successfully drained recovery streams and their delivery markers. */ + async acknowledgeUndeliveredStreams(streamIds: readonly StreamId[]): Promise { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- storage boundary: acknowledgement cleanup is best-effort and duplicate replay is safe + try { + for (const streamId of streamIds) { + const rows = await this.storage.list({ + prefix: eventPrefix(streamId), + limit: REPLAY_LIMIT, + }); + const keys = [undeliveredStreamKey(streamId), ...rows.keys()]; + for (let index = 0; index < keys.length; index += DELETE_CHUNK_SIZE) { + await this.storage.delete(keys.slice(index, index + DELETE_CHUNK_SIZE)); + } + } + } catch { + logStoreWarning("mcp_event_store_delete_failed", { + operation: "acknowledge_undelivered", + streamCount: streamIds.length, + }); + } + } + + /** Replay persisted events after the supplied ID, in storage-key order. */ + async replayEventsAfter( + lastEventId: EventId, + { send }: { readonly send: (eventId: EventId, message: JSONRPCMessage) => Promise }, + ): Promise { + const streamId = streamIdFromEventId(lastEventId); + if (!streamId) return ""; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- storage/replay boundary: a failed replay is logged and leaves the live session usable + try { + const rows = await this.storage.list({ + prefix: eventPrefix(streamId), + startAfter: `${EVENT_KEY_PREFIX}${lastEventId}`, + limit: REPLAY_LIMIT, + }); + for (const [key, message] of rows) { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- client replay callback failures must not prevent later stored events from being offered + try { + await send(eventIdFromKey(key), message); + } catch {} + } + } catch { + logStoreWarning("mcp_event_store_list_failed", { + operation: "replay", + streamId, + }); + } + return streamId; + } +} diff --git a/packages/hosts/cloudflare/src/mcp/do-headers.ts b/packages/hosts/cloudflare/src/mcp/do-headers.ts index 996cfedb5d..36e62b2d47 100644 --- a/packages/hosts/cloudflare/src/mcp/do-headers.ts +++ b/packages/hosts/cloudflare/src/mcp/do-headers.ts @@ -25,6 +25,21 @@ export type VerifiedTokenHeaders = { readonly organizationId: string; }; +/** Parsed worker-stamped identity and resource received by a session DO. */ +export type VerifiedMcpRequestHeaders = VerifiedTokenHeaders & { + readonly resourceKey: string; +}; + +/** Parse the complete worker-stamped modern identity header set. */ +export const verifiedMcpRequestHeaders = (request: Request): VerifiedMcpRequestHeaders | null => { + const accountId = request.headers.get(INTERNAL_ACCOUNT_ID_HEADER); + const organizationId = request.headers.get(INTERNAL_ORGANIZATION_ID_HEADER); + const resourceKey = request.headers.get(INTERNAL_RESOURCE_KEY_HEADER); + return accountId && organizationId && resourceKey + ? { accountId, organizationId, resourceKey } + : null; +}; + // Worker and DO run in separate isolates with independent WebSdk tracer // providers. Neither one can see the other's OTEL context, so the DO used // to emit a brand-new root trace on every stub call. Ferry the worker span @@ -89,7 +104,7 @@ export const withVerifiedIdentityHeaders = ( export const withMcpResponseHeaders = (response: Response): Response => { const headers = new Headers(response.headers); headers.set("access-control-allow-origin", "*"); - headers.set("access-control-expose-headers", "mcp-session-id"); + headers.set("access-control-expose-headers", "mcp-session-id, mcp-protocol-version"); return new Response(response.body, { status: response.status, statusText: response.statusText, diff --git a/packages/hosts/cloudflare/src/mcp/execution-owner-directory.ts b/packages/hosts/cloudflare/src/mcp/execution-owner-directory.ts index 54583f2e55..f40df95e8d 100644 --- a/packages/hosts/cloudflare/src/mcp/execution-owner-directory.ts +++ b/packages/hosts/cloudflare/src/mcp/execution-owner-directory.ts @@ -5,6 +5,21 @@ export type McpExecutionOwnerRoute = { readonly sessionId: string; }; +/** Prefix distinguishing a modern request DO from a sessionful transport DO. */ +export const MODERN_MCP_EXECUTION_OWNER_PREFIX = "modern:"; + +/** Encode a unique modern session DO id in the existing owner route slot. */ +export const modernMcpExecutionOwnerRoute = (durableObjectId: string): McpExecutionOwnerRoute => ({ + sessionId: `${MODERN_MCP_EXECUTION_OWNER_PREFIX}${durableObjectId}`, +}); + +/** Decode the unique DO id from a modern owner route, or return null for sessionful owners. */ +export const modernMcpDurableObjectId = (route: McpExecutionOwnerRoute): string | null => { + if (!route.sessionId.startsWith(MODERN_MCP_EXECUTION_OWNER_PREFIX)) return null; + const id = route.sessionId.slice(MODERN_MCP_EXECUTION_OWNER_PREFIX.length); + return id.length > 0 ? id : null; +}; + export type McpExecutionOwnerRecord = { readonly executionId: string; readonly owner: McpExecutionOwnerRoute; @@ -36,9 +51,6 @@ type McpExecutionOwnerDirectoryStorage = DurableObjectState["storage"]; const toMcpExecutionOwnerDirectoryStub = (stub: unknown): McpExecutionOwnerDirectoryStub => stub as McpExecutionOwnerDirectoryStub; -export const mcpSessionDurableObjectName = (sessionId: string): string => - `streamable-http:${sessionId}`; - class McpExecutionOwnerDirectoryRpcError extends Data.TaggedError( "McpExecutionOwnerDirectoryRpcError", )<{ diff --git a/packages/hosts/cloudflare/src/mcp/modern-request-router.test.ts b/packages/hosts/cloudflare/src/mcp/modern-request-router.test.ts new file mode 100644 index 0000000000..b2bfa2004a --- /dev/null +++ b/packages/hosts/cloudflare/src/mcp/modern-request-router.test.ts @@ -0,0 +1,479 @@ +import { describe, expect, it } from "@effect/vitest"; +import { createRequestStateCodec } from "@modelcontextprotocol/server"; +import { Effect } from "effect"; + +import type { ExecutionEngine } from "@executor-js/execution"; +import { + defaultMcpResource, + type McpModernServerBuilder, + type McpResource, + type Principal, +} from "@executor-js/host-mcp"; +import { + buildMcpServer, + mcpRequestStateBindingFromBody, + mcpRequestStatePrincipal, +} from "@executor-js/host-mcp/tool-server"; + +import type { McpSessionProps } from "./agent-session-durable-object"; +import { + modernMcpExecutionOwnerRoute, + type McpExecutionOwnerDirectory, + type McpExecutionOwnerRecord, +} from "./execution-owner-directory"; +import { + classifyMcpProtocolEra, + makeMcpModernRequestRouter, + mcpCorsPreflightResponse, + requireMcpRequestStateKey, + type McpModernSessionNamespace, + type McpModernSessionStub, +} from "./modern-request-router"; + +const REQUEST_STATE_KEY = "0123456789abcdef0123456789abcdef"; + +const principal: Principal = { + accountId: "acct_1", + organizationId: "org_1", + organizationName: "Org 1", + email: "user@example.test", + name: "Test User", + avatarUrl: null, + roles: [], +}; + +const props: McpSessionProps = { + session: { + organizationId: principal.organizationId, + userId: principal.accountId, + elicitationMode: "native", + resource: defaultMcpResource, + webOrigin: "https://executor.test", + }, +}; + +const engine: ExecutionEngine = { + execute: (code) => Effect.succeed({ result: code }), + executeWithPause: (code) => + Effect.succeed({ status: "completed" as const, result: { result: code } }), + resume: () => Effect.succeed(null), + isExecutionSettled: () => Effect.succeed(false), + getPausedExecution: () => Effect.succeed(null), + pausedExecutionCount: () => Effect.succeed(0), + hasPausedExecutions: () => Effect.succeed(false), + getDescription: Effect.succeed("test engine"), +}; + +const modernBody = (input: { + readonly method: string; + readonly name?: string; + readonly arguments?: Record; + readonly requestState?: string; +}) => ({ + jsonrpc: "2.0", + id: 1, + method: input.method, + params: { + ...(input.name ? { name: input.name } : {}), + ...(input.arguments ? { arguments: input.arguments } : {}), + ...(input.requestState ? { requestState: input.requestState } : {}), + _meta: { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + }, + }, +}); + +const modernRequest = (body: ReturnType): Request => + new Request("https://executor.test/mcp", { + method: "POST", + headers: { + "content-type": "application/json", + "mcp-protocol-version": "2026-07-28", + "mcp-method": body.method, + ...(typeof body.params.name === "string" ? { "mcp-name": body.params.name } : {}), + }, + body: JSON.stringify(body), + }); + +class MemoryDirectory implements McpExecutionOwnerDirectory { + readonly records = new Map(); + + put(record: McpExecutionOwnerRecord): Effect.Effect { + return Effect.sync(() => { + this.records.set(record.executionId, record); + }); + } + + get(executionId: string): Effect.Effect { + return Effect.sync(() => this.records.get(executionId) ?? null); + } + + delete(executionId: string): Effect.Effect { + return Effect.sync(() => { + this.records.delete(executionId); + }); + } +} + +type ForwardedRequest = { + readonly id: string; + readonly body: unknown; +}; + +class MemorySessions implements McpModernSessionNamespace { + readonly forwarded: ForwardedRequest[] = []; + uniqueIds = 0; + + constructor(private readonly rejectStringIds = false) {} + + newUniqueId(): string { + this.uniqueIds += 1; + return `unique-${this.uniqueIds}`; + } + + idFromName(name: string): string { + return `name:${name}`; + } + + idFromString(id: string): string { + if (this.rejectStringIds) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- test boundary: model Cloudflare rejecting a foreign/checksum-invalid owner id + throw new Error("invalid Durable Object id"); + } + return `id:${id}`; + } + + get(id: string): McpModernSessionStub { + return { + serveModernMcp: async (_request, _props, parsedBody) => { + this.forwarded.push({ id, body: parsedBody }); + return new Response(JSON.stringify({ id }), { + headers: { "content-type": "application/json" }, + }); + }, + }; + } +} + +const makeBuilder = (builds: { count: number }): McpModernServerBuilder["Service"] => ({ + build: (_principal, options) => { + builds.count += 1; + const { resource: _resource, ...requestOptions } = options; + return buildMcpServer({ + engine, + elicitationMode: { mode: "native" }, + ...requestOptions, + }); + }, +}); + +const dispatch = async (input: { + readonly body: ReturnType; + readonly sessions: MemorySessions; + readonly directory: MemoryDirectory; + readonly builder: McpModernServerBuilder["Service"]; + readonly resource?: McpResource; +}) => { + const request = modernRequest(input.body); + return makeMcpModernRequestRouter().fetch({ + request, + parsedBody: input.body, + principal, + resource: input.resource ?? defaultMcpResource, + props, + requestStateSigningKey: REQUEST_STATE_KEY, + builder: input.builder, + sessions: input.sessions, + executionOwners: input.directory, + }); +}; + +const mintRequestState = async ( + executionId: string, + options: { + readonly code?: string; + readonly resource?: McpResource; + readonly ttlSeconds?: number; + } = {}, +): Promise => { + const body = modernBody({ + method: "tools/call", + name: "execute", + arguments: { code: options.code ?? "1 + 1" }, + }); + const binding = await mcpRequestStateBindingFromBody({ + body, + principal: mcpRequestStatePrincipal(principal), + resource: options.resource ?? defaultMcpResource, + }); + expect(binding).not.toBeNull(); + const codec = createRequestStateCodec<{ readonly executionId: string }>({ + key: REQUEST_STATE_KEY, + ttlSeconds: options.ttlSeconds ?? 60, + bind: () => binding ?? "", + }); + const encoded: unknown = await Reflect.apply(codec.mint, codec, [{ executionId }, {}]); + return typeof encoded === "string" ? encoded : ""; +}; + +describe("modern Cloudflare MCP worker routing", () => { + it("echoes dynamic preflight headers with a static modern fallback", () => { + const requested = "content-type, authorization, mcp-param-search"; + expect(mcpCorsPreflightResponse(requested).headers.get("access-control-allow-headers")).toBe( + requested, + ); + expect(mcpCorsPreflightResponse().headers.get("access-control-allow-headers")).toContain( + "mcp-method", + ); + }); + + it("fails clearly when the shared modern signing secret is missing or short", () => { + expect(() => requireMcpRequestStateKey(undefined)).toThrow("MCP_REQUEST_STATE_KEY"); + expect(() => requireMcpRequestStateKey("too-short")).toThrow("at least 32 bytes"); + expect(requireMcpRequestStateKey(REQUEST_STATE_KEY)).toBe(REQUEST_STATE_KEY); + }); + + it("keeps the canonical legacy classification on the existing transport branch", async () => { + const legacyBody = { jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }; + const legacy = new Request("https://executor.test/mcp", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(legacyBody), + }); + const modern = modernRequest(modernBody({ method: "tools/list" })); + + await expect(classifyMcpProtocolEra(legacy, legacyBody)).resolves.toBe("legacy"); + await expect( + classifyMcpProtocolEra(modern, modernBody({ method: "tools/list" })), + ).resolves.toBe("modern"); + }); + + it("serves modern non-tools/call methods worker-side without touching a DO", async () => { + const sessions = new MemorySessions(); + const builds = { count: 0 }; + const response = await dispatch({ + body: modernBody({ method: "tools/list" }), + sessions, + directory: new MemoryDirectory(), + builder: makeBuilder(builds), + }); + + expect(response.status).toBe(200); + expect(builds.count).toBe(1); + expect(sessions.uniqueIds).toBe(0); + expect(sessions.forwarded).toEqual([]); + }); + + it("forwards a fresh modern execute call to a new unique DO", async () => { + const sessions = new MemorySessions(); + const builds = { count: 0 }; + const response = await dispatch({ + body: modernBody({ + method: "tools/call", + name: "execute", + arguments: { code: "1 + 1" }, + }), + sessions, + directory: new MemoryDirectory(), + builder: makeBuilder(builds), + }); + + expect(await response.json()).toEqual({ id: "unique-1" }); + expect(builds.count).toBe(0); + expect(sessions.forwarded.map(({ id }) => id)).toEqual(["unique-1"]); + }); + + it("forwards malformed modern tools/call requests to a new unique DO", async () => { + const sessions = new MemorySessions(); + const builds = { count: 0 }; + + await dispatch({ + body: modernBody({ method: "tools/call" }), + sessions, + directory: new MemoryDirectory(), + builder: makeBuilder(builds), + }); + + expect(builds.count).toBe(0); + expect(sessions.forwarded.map(({ id }) => id)).toEqual(["unique-1"]); + }); + + it("verifies continuation state and forwards to its modern owner DO", async () => { + const executionId = "exec-owned"; + const state = await mintRequestState(executionId); + const directory = new MemoryDirectory(); + directory.records.set(executionId, { + executionId, + owner: modernMcpExecutionOwnerRoute("owner-do-id"), + accountId: principal.accountId, + organizationId: principal.organizationId, + expiresAt: new Date(Date.now() + 60_000).toISOString(), + ttlMs: 60_000, + }); + const sessions = new MemorySessions(); + const builds = { count: 0 }; + + await dispatch({ + body: modernBody({ + method: "tools/call", + name: "execute", + arguments: { code: "1 + 1" }, + requestState: state, + }), + sessions, + directory, + builder: makeBuilder(builds), + }); + + expect(builds.count).toBe(0); + expect(sessions.uniqueIds).toBe(0); + expect(sessions.forwarded.map(({ id }) => id)).toEqual(["id:owner-do-id"]); + }); + + it("uses a fresh worker server for an unknown continuation owner", async () => { + const state = await mintRequestState("exec-missing"); + const sessions = new MemorySessions(); + const response = await dispatch({ + body: modernBody({ + method: "tools/call", + name: "execute", + arguments: { code: "1 + 1" }, + requestState: state, + }), + sessions, + directory: new MemoryDirectory(), + builder: makeBuilder({ count: 0 }), + }); + const body = await response.json(); + + expect(body).toMatchObject({ + result: { structuredContent: { status: "execution_not_found" } }, + }); + expect(sessions.uniqueIds).toBe(0); + expect(sessions.forwarded).toEqual([]); + }); + + it("routes modern resume calls to an existing legacy owner when recorded", async () => { + const executionId = "exec-legacy"; + const directory = new MemoryDirectory(); + directory.records.set(executionId, { + executionId, + owner: { sessionId: "legacy-session" }, + accountId: principal.accountId, + organizationId: principal.organizationId, + expiresAt: new Date(Date.now() + 60_000).toISOString(), + ttlMs: 60_000, + }); + const sessions = new MemorySessions(); + + await dispatch({ + body: modernBody({ + method: "tools/call", + name: "resume", + arguments: { executionId, action: "accept" }, + }), + sessions, + directory, + builder: makeBuilder({ count: 0 }), + }); + + expect(sessions.forwarded.map(({ id }) => id)).toEqual(["id:legacy-session"]); + }); + + it("falls back to the worker when a persisted modern owner id is invalid", async () => { + const executionId = "exec-invalid-owner"; + const directory = new MemoryDirectory(); + directory.records.set(executionId, { + executionId, + owner: modernMcpExecutionOwnerRoute("foreign-do-id"), + accountId: principal.accountId, + organizationId: principal.organizationId, + expiresAt: new Date(Date.now() + 60_000).toISOString(), + ttlMs: 60_000, + }); + const sessions = new MemorySessions(true); + const builds = { count: 0 }; + + const response = await dispatch({ + body: modernBody({ + method: "tools/call", + name: "resume", + arguments: { executionId, action: "accept" }, + }), + sessions, + directory, + builder: makeBuilder(builds), + }); + + expect(await response.json()).toMatchObject({ error: { code: -32602 } }); + expect(builds.count).toBe(1); + expect(sessions.forwarded).toEqual([]); + }); + + it("rejects tampered and expired continuation state without touching a DO", async () => { + const valid = await mintRequestState("exec-invalid"); + const middle = Math.floor(valid.length / 2); + const tampered = `${valid.slice(0, middle)}${valid[middle] === "A" ? "B" : "A"}${valid.slice(middle + 1)}`; + const expired = await mintRequestState("exec-expired", { ttlSeconds: -1 }); + + for (const requestState of [tampered, expired]) { + const sessions = new MemorySessions(); + const response = await dispatch({ + body: modernBody({ + method: "tools/call", + name: "execute", + arguments: { code: "1 + 1" }, + requestState, + }), + sessions, + directory: new MemoryDirectory(), + builder: makeBuilder({ count: 0 }), + }); + + const body = await response.json(); + expect(body).toMatchObject({ error: { code: -32602 } }); + expect(sessions.uniqueIds).toBe(0); + expect(sessions.forwarded).toEqual([]); + } + }); + + it("rejects continuation state when the resource or code digest binding changes", async () => { + const requestState = await mintRequestState("exec-bound"); + const cases = [ + { + body: modernBody({ + method: "tools/call", + name: "execute", + arguments: { code: "different code" }, + requestState, + }), + resource: defaultMcpResource, + }, + { + body: modernBody({ + method: "tools/call", + name: "execute", + arguments: { code: "1 + 1" }, + requestState, + }), + resource: { kind: "toolkit", slug: "other" } as const, + }, + ]; + + for (const { body, resource } of cases) { + const sessions = new MemorySessions(); + const response = await dispatch({ + body, + resource, + sessions, + directory: new MemoryDirectory(), + builder: makeBuilder({ count: 0 }), + }); + + expect(await response.json()).toMatchObject({ error: { code: -32602 } }); + expect(sessions.uniqueIds).toBe(0); + expect(sessions.forwarded).toEqual([]); + } + }); +}); diff --git a/packages/hosts/cloudflare/src/mcp/modern-request-router.ts b/packages/hosts/cloudflare/src/mcp/modern-request-router.ts new file mode 100644 index 0000000000..1ea95e19b6 --- /dev/null +++ b/packages/hosts/cloudflare/src/mcp/modern-request-router.ts @@ -0,0 +1,269 @@ +import { Effect, Exit, Option, Schema } from "effect"; +import { + createMcpHandler, + isLegacyRequest, + type McpHttpHandler, + type McpRequestContext, +} from "@modelcontextprotocol/server"; + +import { + jsonRpcErrorBody, + mcpResourceKey, + type McpModernServerBuilder, + type McpResource, + type Principal, +} from "@executor-js/host-mcp"; +import { + appsEnabledForClientCapabilities, + clientCapabilitiesFromRequestBody, + mcpRequestStateBindingFromBody, + mcpRequestStatePrincipal, + verifyNativeRequestState, +} from "@executor-js/host-mcp/tool-server"; + +import type { McpSessionProps } from "./agent-session-durable-object"; +import type { McpExecutionOwnerDirectory } from "./execution-owner-directory"; +import { mcpSessionStubForOwner } from "./session-stub"; + +const MCP_CORS_EXPOSED_HEADERS = "mcp-session-id, mcp-protocol-version, WWW-Authenticate"; +const MCP_CORS_ALLOWED_HEADERS = + "content-type, authorization, mcp-session-id, accept, mcp-protocol-version, mcp-method, mcp-name"; + +const UnknownRecord = Schema.Record(Schema.String, Schema.Unknown); +const ModernToolsCallMethod = Schema.Struct({ method: Schema.Literal("tools/call") }); +const ModernToolCall = Schema.Struct({ + method: Schema.Literal("tools/call"), + params: Schema.Struct({ + name: Schema.String, + arguments: Schema.optional(UnknownRecord), + requestState: Schema.optional(Schema.String), + }), +}); +type ModernToolCall = typeof ModernToolCall.Type; +const decodeModernToolsCallMethod = Schema.decodeUnknownOption(ModernToolsCallMethod); +const decodeModernToolCall = Schema.decodeUnknownOption(ModernToolCall); + +interface ModernRequestInputs { + readonly builder: McpModernServerBuilder["Service"]; + readonly parsedBody: unknown; + readonly principal: Principal; + readonly requestStateSigningKey: string; +} + +/** Durable Object namespace surface required by modern execution routing. */ +export interface McpModernSessionNamespace { + readonly newUniqueId: () => Id; + readonly idFromName: (name: string) => Id; + readonly idFromString: (id: string) => Id; + readonly get: (id: Id) => unknown; +} + +/** Worker-callable modern RPC exposed by the MCP session Durable Object. */ +export interface McpModernSessionStub { + readonly serveModernMcp: ( + request: Request, + props: McpSessionProps, + parsedBody: unknown, + ) => Promise; +} + +/** Inputs needed to dispatch one authenticated modern MCP request. */ +export interface McpModernRequestDispatch { + readonly request: Request; + readonly parsedBody: unknown; + readonly principal: Principal; + readonly resource: McpResource; + readonly props: McpSessionProps; + readonly requestStateSigningKey: string; + readonly builder: McpModernServerBuilder["Service"]; + readonly sessions: McpModernSessionNamespace; + readonly executionOwners: McpExecutionOwnerDirectory | null; +} + +/** Resource-cached worker router for authenticated 2026-07-28 requests. */ +export interface McpModernRequestRouter { + readonly fetch: (input: McpModernRequestDispatch) => Promise; + readonly close: () => Promise; +} + +/** Validate the shared request-state secret at the first modern request boundary. */ +export const requireMcpRequestStateKey = (value: string | undefined): string => { + if (value !== undefined && new TextEncoder().encode(value).byteLength >= 32) return value; + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- composition boundary: modern MCP cannot safely serve or route continuation state without a deployment-provided HMAC key + throw new Error( + "MCP_REQUEST_STATE_KEY must be set to a secret of at least 32 bytes before serving MCP 2026-07-28 requests", + ); +}; + +/** Build the MCP preflight response, echoing dynamic modern header names. */ +export const mcpCorsPreflightResponse = (requestedHeaders?: string | null): Response => + new Response(null, { + status: 204, + headers: { + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET, POST, DELETE, OPTIONS", + "access-control-allow-headers": + requestedHeaders && requestedHeaders.trim() !== "" + ? requestedHeaders + : MCP_CORS_ALLOWED_HEADERS, + "access-control-expose-headers": MCP_CORS_EXPOSED_HEADERS, + }, + }); + +/** Classify an already-parsed request with the SDK's canonical era predicate. */ +export const classifyMcpProtocolEra = ( + request: Request, + parsedBody: unknown, +): Promise<"legacy" | "modern"> => + isLegacyRequest(request, parsedBody).then((legacy) => (legacy ? "legacy" : "modern")); + +const withModernMcpCors = (response: Response): Response => { + const headers = new Headers(response.headers); + headers.set("access-control-allow-origin", "*"); + headers.set("access-control-expose-headers", MCP_CORS_EXPOSED_HEADERS); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +}; + +const toModernSessionStub = (stub: unknown): McpModernSessionStub => + // oxlint-disable-next-line executor/no-double-cast -- boundary: Workers generates the RPC surface from the bound Durable Object class, while the portable namespace type exposes unknown. + stub as unknown as McpModernSessionStub; + +const stubForOwner = ( + sessions: McpModernSessionNamespace, + owner: { readonly sessionId: string }, +): McpModernSessionStub | null => { + const stub = mcpSessionStubForOwner(sessions, owner); + return stub ? toModernSessionStub(stub) : null; +}; + +const freshStub = (sessions: McpModernSessionNamespace): McpModernSessionStub => + toModernSessionStub(sessions.get(sessions.newUniqueId())); + +const resumeExecutionId = (call: ModernToolCall): string | null => { + if (call.params.name !== "resume") return null; + const executionId = call.params.arguments?.executionId; + return typeof executionId === "string" && executionId.length > 0 ? executionId : null; +}; + +/** Build the shared worker-side modern handler and DO-affinity router. */ +export const makeMcpModernRequestRouter = (): McpModernRequestRouter => { + const handlers = new Map(); + const requestInputs = new WeakMap(); + + const handlerFor = (resource: McpResource): McpHttpHandler => { + const resourceKey = mcpResourceKey(resource); + const cached = handlers.get(resourceKey); + if (cached) return cached; + + const handler = createMcpHandler( + (context: McpRequestContext) => { + const request = context.requestInfo; + const inputs = request ? requestInputs.get(request) : undefined; + if (!request || !inputs) { + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: the third-party factory Promise has no typed failure channel; absent request context is an SDK defect + return Effect.runPromise(Effect.die("Modern MCP request has no authenticated context")); + } + const capabilities = clientCapabilitiesFromRequestBody(inputs.parsedBody); + return Effect.runPromise( + Effect.gen(function* () { + const requestStatePrincipal = mcpRequestStatePrincipal(inputs.principal); + const requestStateBinding = yield* Effect.promise(() => + mcpRequestStateBindingFromBody({ + body: inputs.parsedBody, + principal: requestStatePrincipal, + resource, + }), + ); + return yield* inputs.builder.build(inputs.principal, { + resource, + appsEnabled: appsEnabledForClientCapabilities(capabilities), + requestStateSigningKey: inputs.requestStateSigningKey, + requestStatePrincipal, + ...(requestStateBinding === null ? {} : { requestStateBinding }), + }); + }), + ); + }, + { legacy: "reject" }, + ); + handlers.set(resourceKey, handler); + return handler; + }; + + const serveWorker = async (input: McpModernRequestDispatch): Promise => { + requestInputs.set(input.request, { + builder: input.builder, + parsedBody: input.parsedBody, + principal: input.principal, + requestStateSigningKey: input.requestStateSigningKey, + }); + return handlerFor(input.resource).fetch(input.request, { parsedBody: input.parsedBody }); + }; + + const serveDo = ( + stub: McpModernSessionStub, + input: McpModernRequestDispatch, + ): Promise => stub.serveModernMcp(input.request, input.props, input.parsedBody); + + return { + fetch: async (input) => { + if (Option.isNone(decodeModernToolsCallMethod(input.parsedBody))) { + return withModernMcpCors(await serveWorker(input)); + } + + const decoded = decodeModernToolCall(input.parsedBody); + if (Option.isNone(decoded)) { + return withModernMcpCors(await serveDo(freshStub(input.sessions), input)); + } + + const call = decoded.value; + let executionId = resumeExecutionId(call); + if (call.params.name === "execute" && call.params.requestState !== undefined) { + const verified = await Effect.runPromiseExit( + verifyNativeRequestState({ + state: call.params.requestState, + body: input.parsedBody, + resource: input.resource, + requestStateSigningKey: input.requestStateSigningKey, + requestStatePrincipal: mcpRequestStatePrincipal(input.principal), + }), + ); + if (Exit.isFailure(verified)) { + return withModernMcpCors(await serveWorker(input)); + } + executionId = verified.value.executionId; + } + + if (executionId === null) { + return withModernMcpCors(await serveDo(freshStub(input.sessions), input)); + } + + const owner = input.executionOwners + ? await Effect.runPromise(input.executionOwners.get(executionId)) + : null; + if (!owner) { + return withModernMcpCors(await serveWorker(input)); + } + if ( + owner.accountId !== input.principal.accountId || + owner.organizationId !== input.principal.organizationId + ) { + return withModernMcpCors( + jsonRpcErrorBody(403, -32003, "MCP execution does not belong to the current bearer"), + ); + } + const ownerStub = stubForOwner(input.sessions, owner.owner); + return withModernMcpCors( + ownerStub ? await serveDo(ownerStub, input) : await serveWorker(input), + ); + }, + close: () => + Promise.all(Array.from(handlers.values(), (handler) => handler.close())).then( + () => undefined, + ), + }; +}; diff --git a/packages/hosts/cloudflare/src/mcp/session-alarm-policy.ts b/packages/hosts/cloudflare/src/mcp/session-alarm-policy.ts index 4502771c3d..e44a53502d 100644 --- a/packages/hosts/cloudflare/src/mcp/session-alarm-policy.ts +++ b/packages/hosts/cloudflare/src/mcp/session-alarm-policy.ts @@ -18,8 +18,8 @@ export const RUNNING_EXECUTION_LEASE_MS = PAUSED_APPROVAL_TIMEOUT_MS; */ export const MAX_PAUSED_SESSION_IDLE_MS = SESSION_TIMEOUT_MS + PAUSED_EXECUTION_LEASE_MS; -/** Matches the patched agents transport's MAX_SSE_AGE_MS (30 minutes). */ -const SSE_MAX_AGE_MS = 30 * 60 * 1000; +/** Maximum lifetime of one client-facing SSE response before reconnect rotation. */ +export const SSE_MAX_AGE_MS = 30 * 60 * 1000; /** * Hard upper bound on idle time while running work or open streams keep diff --git a/packages/hosts/cloudflare/src/mcp/session-stub.test.ts b/packages/hosts/cloudflare/src/mcp/session-stub.test.ts new file mode 100644 index 0000000000..23a6eb5e35 --- /dev/null +++ b/packages/hosts/cloudflare/src/mcp/session-stub.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from "@effect/vitest"; + +import { mcpSessionStub } from "./session-stub"; + +describe("mcpSessionStub", () => { + it("returns null when Cloudflare rejects a client-supplied Durable Object id", () => { + const get = vi.fn(); + const namespace = { + idFromString: (_id: string): string => { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- test boundary: model Cloudflare's throwing namespace checksum parser + throw new Error("Durable Object ID is not valid for this namespace"); + }, + get, + }; + + expect(mcpSessionStub(namespace, "0".repeat(64))).toBeNull(); + expect(get).not.toHaveBeenCalled(); + }); + + it("resolves a namespace-validated id to its generated RPC stub", () => { + const stub = { fetch: vi.fn() }; + const namespace = { + idFromString: (id: string): string => `parsed:${id}`, + get: vi.fn(() => stub), + }; + + expect(mcpSessionStub(namespace, "issued-id")).toBe(stub); + expect(namespace.get).toHaveBeenCalledWith("parsed:issued-id"); + }); +}); diff --git a/packages/hosts/cloudflare/src/mcp/session-stub.ts b/packages/hosts/cloudflare/src/mcp/session-stub.ts index 3a003ff0cc..ede3e6d92c 100644 --- a/packages/hosts/cloudflare/src/mcp/session-stub.ts +++ b/packages/hosts/cloudflare/src/mcp/session-stub.ts @@ -7,14 +7,22 @@ import type { McpSessionModelResumeResult, McpSessionResumeApprovalResult, } from "./agent-session-durable-object"; -import { mcpSessionDurableObjectName } from "./execution-owner-directory"; +import { modernMcpDurableObjectId, type McpExecutionOwnerRoute } from "./execution-owner-directory"; export interface McpSessionNamespace { - readonly idFromName: (name: string) => Id; + readonly idFromString: (id: string) => Id; readonly get: (id: Id) => unknown; } +/** Session namespace surface for unique sessionful and modern Durable Objects. */ +export type McpOwnerSessionNamespace = McpSessionNamespace; + +export interface McpSessionFactoryNamespace extends McpSessionNamespace { + readonly newUniqueId: () => Id; +} + export interface McpSessionStub { + readonly fetch: (request: Request) => Promise; readonly validateMcpSessionOwner: ( identity: McpApprovalOwner, ) => Promise<"ok" | "not_found" | "forbidden" | "terminated">; @@ -41,8 +49,37 @@ export interface McpSessionStub { export const mcpSessionStub = ( namespace: McpSessionNamespace, sessionId: string, -): McpSessionStub => - // oxlint-disable-next-line executor/no-double-cast -- boundary: Workers types expose only DurableObjectStub, but RPC methods are generated from the bound DO class. - namespace.get( - namespace.idFromName(mcpSessionDurableObjectName(sessionId)), - ) as unknown as McpSessionStub; +): McpSessionStub | null => { + let id: Id; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- platform boundary: Cloudflare validates the namespace checksum only through throwing idFromString + try { + id = namespace.idFromString(sessionId); + } catch { + return null; + } + return ( + // oxlint-disable-next-line executor/no-double-cast -- boundary: Workers types expose only DurableObjectStub, but fetch and RPC methods are generated from the bound DO class. + namespace.get(id) as unknown as McpSessionStub + ); +}; + +/** Allocate one unique session DO and return its client-visible ID and stub. */ +export const createMcpSessionStub = ( + namespace: McpSessionFactoryNamespace, +): { readonly sessionId: string; readonly stub: McpSessionStub } => { + const id = namespace.newUniqueId(); + return { + sessionId: String(id), + // oxlint-disable-next-line executor/no-double-cast -- boundary: Workers generates fetch and RPC methods from the bound DO class. + stub: namespace.get(id) as unknown as McpSessionStub, + }; +}; + +/** Resolve an execution owner route to its unique modern or sessionful DO. */ +export const mcpSessionStubForOwner = ( + namespace: McpOwnerSessionNamespace, + owner: McpExecutionOwnerRoute, +): McpSessionStub | null => { + const modernId = modernMcpDurableObjectId(owner); + return mcpSessionStub(namespace, modernId ?? owner.sessionId); +}; diff --git a/packages/hosts/cloudflare/src/mcp/sse-response-rotation.test.ts b/packages/hosts/cloudflare/src/mcp/sse-response-rotation.test.ts new file mode 100644 index 0000000000..85e31eadac --- /dev/null +++ b/packages/hosts/cloudflare/src/mcp/sse-response-rotation.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest"; + +import { SESSION_TIMEOUT_MS, SSE_MAX_AGE_MS } from "./session-alarm-policy"; +import { + SSE_MAX_AGE_RECONNECT_FRAME, + rotateSseResponse, + type SseResponseCloseReason, +} from "./sse-response-rotation"; + +const sseResponse = (cancelled: { value: boolean }): Response => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(": keepalive\n\n")); + }, + cancel() { + cancelled.value = true; + }, + }), + { headers: { "content-type": "text/event-stream", "content-length": "100" } }, + ); + +describe("rotateSseResponse", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("keeps the production max age well above the session idle timeout", () => { + expect(SSE_MAX_AGE_MS).toBe(30 * 60 * 1000); + expect(SSE_MAX_AGE_MS).toBeGreaterThanOrEqual(6 * SESSION_TIMEOUT_MS); + }); + + it("closes any still-open SSE response with a reconnect comment at max age", async () => { + const cancelled = { value: false }; + const closes: SseResponseCloseReason[] = []; + const response = rotateSseResponse(sseResponse(cancelled), { + maxAgeMs: 1_000, + onClose: (reason) => closes.push(reason), + }); + const bodyPromise = response.text(); + + await vi.advanceTimersByTimeAsync(1_000); + + await expect(bodyPromise).resolves.toBe(`: keepalive\n\n${SSE_MAX_AGE_RECONNECT_FRAME}`); + expect(cancelled.value).toBe(true); + expect(closes).toEqual(["rotate"]); + expect(vi.getTimerCount()).toBe(0); + }); + + it("prepends a compatibility frame before SDK stream bytes", async () => { + const source = new Response("event: message\ndata: {}\n\n", { + headers: { "content-type": "text/event-stream" }, + }); + const response = rotateSseResponse(source, { + maxAgeMs: 1_000, + initialFrame: new TextEncoder().encode("event: mcp-priming\nid: stream:1\ndata: {}\n\n"), + }); + + await expect(response.text()).resolves.toBe( + "event: mcp-priming\nid: stream:1\ndata: {}\n\nevent: message\ndata: {}\n\n", + ); + expect(vi.getTimerCount()).toBe(0); + }); + + it("cancels the max-age timer when the client closes first", async () => { + const cancelled = { value: false }; + const closes: SseResponseCloseReason[] = []; + const response = rotateSseResponse(sseResponse(cancelled), { + maxAgeMs: 1_000, + onClose: (reason) => closes.push(reason), + }); + const reader = response.body?.getReader(); + await reader?.read(); + await reader?.cancel(); + + await vi.advanceTimersByTimeAsync(1_000); + + expect(cancelled.value).toBe(true); + expect(closes).toEqual(["cancel"]); + expect(vi.getTimerCount()).toBe(0); + }); + + it("leaves non-SSE responses unchanged", () => { + const response = new Response("ok", { headers: { "content-type": "application/json" } }); + expect(rotateSseResponse(response)).toBe(response); + }); +}); diff --git a/packages/hosts/cloudflare/src/mcp/sse-response-rotation.ts b/packages/hosts/cloudflare/src/mcp/sse-response-rotation.ts new file mode 100644 index 0000000000..c84bda1f1c --- /dev/null +++ b/packages/hosts/cloudflare/src/mcp/sse-response-rotation.ts @@ -0,0 +1,97 @@ +import { SSE_MAX_AGE_MS } from "./session-alarm-policy"; + +/** Comment emitted immediately before a max-age close asks clients to resume. */ +export const SSE_MAX_AGE_RECONNECT_FRAME = ": max-age rotation, reconnect\n\n"; + +export type SseResponseCloseReason = "cancel" | "complete" | "error" | "rotate"; + +export interface SseResponseRotationOptions { + readonly maxAgeMs?: number; + readonly initialFrame?: Uint8Array; + readonly onOpen?: () => void; + readonly onClose?: (reason: SseResponseCloseReason) => void; +} + +const isSseResponse = (response: Response): boolean => + response.body !== null && + (response.headers.get("content-type") ?? "").includes("text/event-stream"); + +/** + * Bound one streamed response's lifetime and preserve direct response + * streaming. Rotation emits a benign comment, closes this HTTP body, and + * cancels the SDK body so its stream bookkeeping is released; the event store + * supplies any later replay to the client's reconnect GET. + */ +export const rotateSseResponse = ( + response: Response, + options: SseResponseRotationOptions = {}, +): Response => { + if (!isSseResponse(response) || !response.body) return response; + + const reader = response.body.getReader(); + const reconnectFrame = new TextEncoder().encode(SSE_MAX_AGE_RECONNECT_FRAME); + const maxAgeMs = options.maxAgeMs ?? SSE_MAX_AGE_MS; + let controller: ReadableStreamDefaultController; + let closed = false; + let timer: ReturnType | undefined; + + const finish = (reason: SseResponseCloseReason): void => { + if (closed) return; + closed = true; + if (timer !== undefined) clearTimeout(timer); + if (reason === "rotate") { + controller.enqueue(reconnectFrame); + controller.close(); + void reader.cancel("mcp_sse_max_age_rotation").then( + () => undefined, + () => undefined, + ); + } else if (reason === "complete") { + controller.close(); + } + options.onClose?.(reason); + }; + + const body = new ReadableStream({ + start(streamController) { + controller = streamController; + options.onOpen?.(); + if (options.initialFrame) controller.enqueue(options.initialFrame); + timer = setTimeout(() => finish("rotate"), maxAgeMs); + }, + async pull() { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- stream boundary: propagate the source body's rejected read to the response consumer + try { + const next = await reader.read(); + if (closed) return; + if (next.done) { + finish("complete"); + return; + } + controller.enqueue(next.value); + } catch (cause) { + if (closed) return; + closed = true; + if (timer !== undefined) clearTimeout(timer); + options.onClose?.("error"); + controller.error(cause); + } + }, + async cancel(reason) { + if (!closed) { + closed = true; + if (timer !== undefined) clearTimeout(timer); + options.onClose?.("cancel"); + } + await reader.cancel(reason); + }, + }); + + const headers = new Headers(response.headers); + headers.delete("content-length"); + return new Response(body, { + status: response.status, + statusText: response.statusText, + headers, + }); +}; diff --git a/packages/hosts/cloudflare/src/test-stubs/cloudflare-workers.ts b/packages/hosts/cloudflare/src/test-stubs/cloudflare-workers.ts index 469f966902..2c68615b42 100644 --- a/packages/hosts/cloudflare/src/test-stubs/cloudflare-workers.ts +++ b/packages/hosts/cloudflare/src/test-stubs/cloudflare-workers.ts @@ -1,6 +1,14 @@ export const env: Record = {}; -export class DurableObject {} +export class DurableObject { + protected readonly ctx: DurableObjectState; + protected readonly env: Env; + + constructor(ctx: DurableObjectState, env: Env) { + this.ctx = ctx; + this.env = env; + } +} export class RpcTarget {} diff --git a/packages/hosts/cloudflare/vitest.config.ts b/packages/hosts/cloudflare/vitest.config.ts index 57bf3225ac..3340719599 100644 --- a/packages/hosts/cloudflare/vitest.config.ts +++ b/packages/hosts/cloudflare/vitest.config.ts @@ -15,10 +15,5 @@ export default defineConfig({ include: ["src/**/*.test.ts"], passWithNoTests: true, setupFiles: ["./src/test-setup.ts"], - server: { - deps: { - inline: ["agents", "partyserver"], - }, - }, }, }); diff --git a/packages/hosts/mcp-apps-shell/src/shell-resource.smoke.test.ts b/packages/hosts/mcp-apps-shell/src/shell-resource.smoke.test.ts index 0461389684..d51d0afcc8 100644 --- a/packages/hosts/mcp-apps-shell/src/shell-resource.smoke.test.ts +++ b/packages/hosts/mcp-apps-shell/src/shell-resource.smoke.test.ts @@ -4,7 +4,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server"; import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js"; -import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; +import { buildMcpServer } from "@executor-js/host-mcp/tool-server"; import { MCP_APPS_SHELL_RESOURCE_URI } from "@executor-js/host-mcp/create-artifact"; import type { ExecutionEngine } from "@executor-js/execution"; @@ -40,8 +40,12 @@ describe("MCP-Apps shell resource", () => { const mcpServer = await Effect.runPromise( // Artifacts are opt-in per connection; the shell resource only exists on // a session that asked for them. - createExecutorMcpServer({ + buildMcpServer({ engine: stubEngine, + appsEnabled: false, + requestStateSigningKey: new Uint8Array(32).fill(17), + requestStatePrincipal: "shell-resource-test-principal", + sessionful: true, loadAppShellHtml: loadMcpAppsShellHtml, artifactsEnabled: true, }), diff --git a/packages/hosts/mcp-apps-shell/src/shell/mcp-app.browser.test.ts b/packages/hosts/mcp-apps-shell/src/shell/mcp-app.browser.test.ts index 057684955e..fa03cbbfc8 100644 --- a/packages/hosts/mcp-apps-shell/src/shell/mcp-app.browser.test.ts +++ b/packages/hosts/mcp-apps-shell/src/shell/mcp-app.browser.test.ts @@ -38,7 +38,7 @@ import { chromium, type Browser, type Frame, type Page } from "playwright-core"; import { createServer as createViteServer } from "vite"; import type * as Cause from "effect/Cause"; -import { createExecutorMcpServer } from "@executor-js/host-mcp/tool-server"; +import { buildMcpServer } from "@executor-js/host-mcp/tool-server"; import { loadMcpAppsShellHtml } from "../shell-html"; @@ -1241,8 +1241,12 @@ const startMcpHarnessForEngine = async ( engine: ExecutionEngine, ): Promise => { const mcpServer = await Effect.runPromise( - createExecutorMcpServer({ + buildMcpServer({ engine, + appsEnabled: false, + requestStateSigningKey: new Uint8Array(32).fill(19), + requestStatePrincipal: "mcp-app-browser-test-principal", + sessionful: true, loadAppShellHtml: loadMcpAppsShellHtml, artifacts: makeInMemoryArtifacts(), // Artifacts are opt-in per connection; this harness drives the artifact diff --git a/packages/hosts/mcp/package.json b/packages/hosts/mcp/package.json index 1e8dd7c4fc..43be1951b0 100644 --- a/packages/hosts/mcp/package.json +++ b/packages/hosts/mcp/package.json @@ -12,6 +12,10 @@ "types": "./src/tool-server.ts", "default": "./src/tool-server.ts" }, + "./mcp-apps": { + "types": "./src/mcp-apps.ts", + "default": "./src/mcp-apps.ts" + }, "./create-artifact": { "types": "./src/create-artifact.ts", "default": "./src/create-artifact.ts" @@ -44,16 +48,17 @@ "typecheck:slow": "bunx tsc --noEmit -p tsconfig.json" }, "dependencies": { - "@cfworker/json-schema": "^4.1.1", "@executor-js/execution": "workspace:*", "@executor-js/sdk": "workspace:*", - "@modelcontextprotocol/ext-apps": "^1.7.4", - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/core": "2.0.0", + "@modelcontextprotocol/server": "2.0.0", "effect": "catalog:", "zod": "4.3.6" }, "devDependencies": { "@effect/vitest": "catalog:", + "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/sdk": "^1.29.0", "@types/node": "catalog:", "bun-types": "catalog:", "vitest": "catalog:" diff --git a/packages/hosts/mcp/src/artifacts-tools.test.ts b/packages/hosts/mcp/src/artifacts-tools.test.ts index a8a3b794b4..4a27dce2e9 100644 --- a/packages/hosts/mcp/src/artifacts-tools.test.ts +++ b/packages/hosts/mcp/src/artifacts-tools.test.ts @@ -1,9 +1,7 @@ import { describe, expect, it, vi } from "@effect/vitest"; import { Data, Effect } from "effect"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; -import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js"; -import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server"; +import { Client } from "@modelcontextprotocol/client"; +import { InMemoryTransport, type ClientCapabilities } from "@modelcontextprotocol/server"; import type * as Cause from "effect/Cause"; import { @@ -18,7 +16,8 @@ import type { ExecutionEngine, ExecutionResult } from "@executor-js/execution"; import type { BindableConnection } from "./artifact-bindings"; import { readArtifactsEnabled } from "./browser-approval"; import { MCP_APPS_SHELL_RESOURCE_URI, artifactUrlFor } from "./create-artifact"; -import { createExecutorMcpServer, type ExecutorMcpServerConfig } from "./tool-server"; +import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "./mcp-apps"; +import { buildMcpServer, type ExecutorMcpServerConfig } from "./tool-server"; /** The caller's connection inventory, as `create-artifact` sees it when it * binds an artifact's integration roles. */ @@ -130,6 +129,14 @@ const makeArtifactStore = () => { }; }; +const TEST_REQUEST_STATE_KEY = new Uint8Array(32).fill(11); +const SESSION_SERVER_OPTIONS = { + appsEnabled: false, + requestStateSigningKey: TEST_REQUEST_STATE_KEY, + requestStatePrincipal: "artifact-test-principal", + sessionful: true, +} as const; + const withClient = async ( engine: ExecutionEngine, capabilities: ClientCapabilities, @@ -137,8 +144,9 @@ const withClient = async ( config?: Partial>, ) => { const mcpServer = await Effect.runPromise( - createExecutorMcpServer({ + buildMcpServer({ engine, + ...SESSION_SERVER_OPTIONS, loadAppShellHtml: () => Promise.resolve(SHELL_HTML), // Artifacts are on by default and nearly every test in this file // exercises the artifact surface; spelled out here anyway so the cases @@ -164,13 +172,10 @@ const withClient = async ( const SHELL_HTML = "
"; -// What a client that renders MCP Apps advertises at `initialize`. The SDK's -// `ClientCapabilities` has no `extensions` field yet (pending SEP-1724), which -// is exactly why ext-apps ships `getUiCapability` to read it. -// oxlint-disable-next-line executor/no-double-cast -- boundary: MCP SDK ClientCapabilities predates the ext-apps `extensions` field +// What a client that renders MCP Apps advertises at `initialize`. const APPS_CAPS = { extensions: { [EXTENSION_ID]: { mimeTypes: [RESOURCE_MIME_TYPE] } }, -} as unknown as ClientCapabilities; +} satisfies ClientCapabilities; const NO_APPS_CAPS: ClientCapabilities = {}; @@ -244,8 +249,9 @@ describe("MCP host — artifact tool visibility", () => { it("keeps the app-only tools visible on a cold restore that replays no initialize", async () => { const store = makeArtifactStore(); const mcpServer = await Effect.runPromise( - createExecutorMcpServer({ + buildMcpServer({ engine: makeStubEngine({}), + ...SESSION_SERVER_OPTIONS, artifacts: store.port, artifactsEnabled: true, loadAppShellHtml: () => Promise.resolve(SHELL_HTML), @@ -307,8 +313,9 @@ describe("MCP host — artifact tool visibility", () => { it("renders inline on a restore that replays initialize without the initialized notification", async () => { const store = makeArtifactStore(); const mcpServer = await Effect.runPromise( - createExecutorMcpServer({ + buildMcpServer({ engine: makeStubEngine({}), + ...SESSION_SERVER_OPTIONS, artifacts: store.port, artifactsEnabled: true, loadAppShellHtml: () => Promise.resolve(SHELL_HTML), @@ -460,8 +467,9 @@ describe("MCP host — artifact tool visibility", () => { it("registers no ui tools at all when no shell loader is configured", async () => { const store = makeArtifactStore(); const mcpServer = await Effect.runPromise( - createExecutorMcpServer({ + buildMcpServer({ engine: makeStubEngine({}), + ...SESSION_SERVER_OPTIONS, artifacts: store.port, // Opted in, so the only thing withholding the surface is the missing // shell loader — otherwise this would pass on the connection default. @@ -1303,7 +1311,7 @@ describe("MCP host — artifact retrieval", () => { await client.callTool({ name: "create-artifact", - arguments: { code: COUNTER_CODE, title: "Dashboard v2", artifactId: "art_1" }, + arguments: { code: COUNTER_CODE, title: "Revised dashboard", artifactId: "art_1" }, }); expect(usage).toEqual(["created", "updated"]); diff --git a/packages/hosts/mcp/src/envelope.test.ts b/packages/hosts/mcp/src/envelope.test.ts index 523dc60a0b..90c0a38e8b 100644 --- a/packages/hosts/mcp/src/envelope.test.ts +++ b/packages/hosts/mcp/src/envelope.test.ts @@ -10,6 +10,7 @@ // --------------------------------------------------------------------------- import { describe, expect, it } from "@effect/vitest"; +import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; import { Cause, Effect, Layer, Ref } from "effect"; import { HttpRouter, HttpServer } from "effect/unstable/http"; @@ -19,13 +20,18 @@ import { McpAuthProvider, McpErrorReporter, McpErrorReporterNoop, + McpModernServerBuilder, McpServingRoutes, McpDiscoveryRoutes, McpSessionStore, + unauthorized, type McpResource, type McpDispatchResult, type Principal, } from "./index"; +import type { ExecutionEngine } from "@executor-js/execution"; +import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "./mcp-apps"; +import { buildMcpServer } from "./tool-server"; const DISCOVERY_PATH = "/.well-known/oauth-protected-resource" as const; @@ -39,6 +45,25 @@ const TEST_PRINCIPAL: Principal = { roles: ["user"], }; +const testEngine: ExecutionEngine = { + execute: (code) => Effect.succeed({ result: `ran: ${code}` }), + executeWithPause: (code) => + Effect.succeed({ status: "completed", result: { result: `ran: ${code}` } }), + resume: () => Effect.succeed(null), + isExecutionSettled: () => Effect.succeed(false), + getPausedExecution: () => Effect.succeed(null), + pausedExecutionCount: () => Effect.succeed(0), + hasPausedExecutions: () => Effect.succeed(false), + getDescription: Effect.succeed("envelope test executor"), +}; + +const ModernBuilderLive = Layer.succeed(McpModernServerBuilder)({ + build: (_principal, options) => { + const { resource: _resource, ...requestOptions } = options; + return buildMcpServer({ engine: testEngine, ...requestOptions }); + }, +}); + /** An auth provider that authenticates everything (so dispatch is reached). */ const AuthProviderLive = Layer.succeed(McpAuthProvider)({ discoveryRoutes: [ @@ -68,8 +93,9 @@ const buildHandler = ( store: Layer.Layer, reporter: Layer.Layer, authProvider: Layer.Layer = AuthProviderLive, + modernBuilder: Layer.Layer = ModernBuilderLive, ): ((request: Request) => Promise) => { - const Seams = Layer.mergeAll(authProvider, store, reporter); + const Seams = Layer.mergeAll(authProvider, store, modernBuilder, reporter); const RouteLive = McpServingRoutes.pipe( HttpRouter.provideRequest(Seams), Layer.provide(authProvider), @@ -114,7 +140,122 @@ describe("McpServingRoutes envelope", () => { expect(response.status).toBe(204); expect(response.headers.get("access-control-allow-origin")).toBe("*"); expect(response.headers.get("access-control-allow-methods")).toBe("GET, POST, DELETE, OPTIONS"); - expect(response.headers.get("access-control-allow-headers") ?? "").toContain("authorization"); + const allowedHeaders = response.headers.get("access-control-allow-headers") ?? ""; + expect(allowedHeaders).toContain("authorization"); + expect(allowedHeaders).toContain("mcp-method"); + expect(allowedHeaders).toContain("mcp-name"); + }); + + it("echoes requested preflight headers so dynamic Mcp-Param names pass", async () => { + const handler = buildHandler(OkStoreLive, McpErrorReporterNoop); + const requested = "content-type, authorization, mcp-protocol-version, mcp-param-search"; + const response = await handler( + new Request("https://host.test/mcp", { + method: "OPTIONS", + headers: { + origin: "https://claude.ai", + "access-control-request-method": "POST", + "access-control-request-headers": requested, + }, + }), + ); + expect(response.status).toBe(204); + expect(response.headers.get("access-control-allow-headers")).toBe(requested); + }); + + it("serves modern list/call traffic without dispatching a legacy session", async () => { + const legacyDispatches = await Effect.runPromise(Ref.make(0)); + const appsEnabled = await Effect.runPromise(Ref.make(false)); + const RecordingStoreLive = Layer.succeed(McpSessionStore)({ + dispatch: () => + Ref.update(legacyDispatches, (count) => count + 1).pipe(Effect.as("not-found")), + dispose: () => Effect.void, + }); + const RecordingModernBuilder = Layer.succeed(McpModernServerBuilder)({ + build: (_principal, options) => { + const { resource: _resource, ...requestOptions } = options; + return Ref.set(appsEnabled, options.appsEnabled).pipe( + Effect.flatMap(() => buildMcpServer({ engine: testEngine, ...requestOptions })), + ); + }, + }); + const handler = buildHandler( + RecordingStoreLive, + McpErrorReporterNoop, + AuthProviderLive, + RecordingModernBuilder, + ); + const transport = new StreamableHTTPClientTransport(new URL("https://host.test/mcp"), { + fetch: (input, init) => + handler( + input instanceof Request ? new Request(input, init) : new Request(input.toString(), init), + ), + }); + const client = new Client( + { name: "envelope-modern-test", version: "1.0.0" }, + { + capabilities: { + extensions: { [EXTENSION_ID]: { mimeTypes: [RESOURCE_MIME_TYPE] } }, + }, + versionNegotiation: { mode: { pin: "2026-07-28" } }, + }, + ); + + await client.connect(transport); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always close the in-process modern client + try { + expect((await client.listTools()).tools.map(({ name }) => name)).toContain("execute"); + const result = await client.callTool({ + name: "execute", + arguments: { code: "1 + 1" }, + }); + expect(result.content).toEqual([{ type: "text", text: "ran: 1 + 1" }]); + expect(await Effect.runPromise(Ref.get(legacyDispatches))).toBe(0); + expect(await Effect.runPromise(Ref.get(appsEnabled))).toBe(true); + } finally { + await client.close(); + } + }); + + it("returns the existing 401 challenge before routing a modern request", async () => { + const challenge = 'Bearer resource_metadata="https://host.test/custom-metadata"'; + const UnauthorizedAuthProviderLive = Layer.succeed(McpAuthProvider)({ + discoveryRoutes: [], + resourceMetadataUrl: () => "https://host.test/custom-metadata", + authenticate: () => Effect.succeed(unauthorized(challenge)), + }); + const handler = buildHandler(OkStoreLive, McpErrorReporterNoop, UnauthorizedAuthProviderLive); + const response = await handler(modernRequest("https://host.test/mcp")); + + expect(response.status).toBe(401); + expect(response.headers.get("www-authenticate")).toBe(challenge); + }); + + it("gracefully rejects modern discovery when inbound 2026-07-28 is disabled", async () => { + const DisabledModernBuilder = Layer.succeed(McpModernServerBuilder)({ + enabled: false, + build: () => Effect.die("disabled modern builder should not run"), + }); + const handler = buildHandler( + OkStoreLive, + McpErrorReporterNoop, + AuthProviderLive, + DisabledModernBuilder, + ); + + const response = await handler(modernRequest("https://host.test/mcp")); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + jsonrpc: "2.0", + error: { code: -32022, message: "MCP 2026-07-28 support is disabled" }, + id: null, + }); + }); + + it("404s a modern request whose toolkit route is not served", async () => { + const handler = buildHandler(OkStoreLive, McpErrorReporterNoop); + const response = await handler(modernRequest("https://host.test/mcp/toolkits/unknown/extra")); + expect(response.status).toBe(404); }); it("renders 500 -32603 + CORS and fires the reporter on an orchestration defect", async () => { @@ -178,6 +319,27 @@ describe("McpServingRoutes envelope", () => { }); }); +const modernRequest = (url: string): Request => + new Request(url, { + method: "POST", + headers: { + "content-type": "application/json", + "mcp-protocol-version": "2026-07-28", + "mcp-method": "server/discover", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "server/discover", + params: { + _meta: { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + }, + }, + }), + }); + it("dispatches toolkit MCP routes with the parsed toolkit resource", async () => { const seen = await Effect.runPromise(Ref.make(null)); const RecordingStoreLive = Layer.succeed(McpSessionStore)({ diff --git a/packages/hosts/mcp/src/envelope.ts b/packages/hosts/mcp/src/envelope.ts index fe5483978e..d0db0e8d74 100644 --- a/packages/hosts/mcp/src/envelope.ts +++ b/packages/hosts/mcp/src/envelope.ts @@ -1,15 +1,31 @@ import { Effect, Match, Predicate } from "effect"; import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import { + createMcpHandler, + isLegacyRequest, + type McpHttpHandler, + type McpRequestContext, +} from "@modelcontextprotocol/server"; import { defaultMcpResource, McpAuthProvider, McpErrorReporter, + McpModernServerBuilder, McpSessionStore, + mcpResourceKey, type AuthOutcome, type McpDispatchResult, type McpResource, + type Principal, } from "./seams"; +import { + appsEnabledForClientCapabilities, + clientCapabilitiesFromRequest, + mcpRequestStateBindingFromBody, + mcpRequestStatePrincipal, + requestBodyFromRequest, +} from "./tool-server"; // --------------------------------------------------------------------------- // Provider-neutral MCP serving envelope. @@ -30,7 +46,7 @@ import { // The envelope hard-codes ONLY the MCP serving paths and CORS. Everything else // — every `/.well-known/*` path, the resource-metadata URL, the authn/authz // semantics, and the entire session lifecycle (create + forward + ownership) — -// comes from the two seams. +// comes from the three seams. // // Runtime-agnostic: built on `effect/unstable/http` (HttpRouter), NO // platform-bun. The `/mcp` flow is fully Effect; the streamable-HTTP transport @@ -42,6 +58,14 @@ import { const MCP_PATH = "/mcp"; const TOOLKIT_MCP_PATH = "/mcp/toolkits/:toolkitSlug"; +// Static fallback only: the 2026-07-28 era mirrors request params into +// dynamic `Mcp-Param-` headers (SEP-2243), and CORS header names never +// glob — the preflight must echo `Access-Control-Request-Headers` verbatim to +// admit them. `*` would not help either: it is ignored for credentialed +// requests and never covers `Authorization`. +const MCP_CORS_ALLOWED_HEADERS = + "content-type, authorization, mcp-session-id, accept, mcp-protocol-version, mcp-method, mcp-name"; +const MCP_CORS_EXPOSED_HEADERS = "mcp-session-id, mcp-protocol-version, WWW-Authenticate"; /** The methods the streamable-HTTP transport accepts on `/mcp`. */ const ALLOWED_MCP_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]); @@ -68,15 +92,19 @@ const fromWebResponse = (response: Response): HttpServerResponse.HttpServerRespo * preflight against the metadata docs too (RFC 9728 discovery from a 401), so * the envelope answers OPTIONS for those paths, not only `/mcp`. */ -const corsPreflightResponse = (): Response => +const corsPreflightResponse = (requestedHeaders?: string | null): Response => new Response(null, { status: 204, headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "GET, POST, DELETE, OPTIONS", + // Echo the browser's requested headers so dynamic `Mcp-Param-` + // names pass; the static list is the no-preflight-header fallback. "access-control-allow-headers": - "content-type, authorization, mcp-session-id, accept, mcp-protocol-version", - "access-control-expose-headers": "mcp-session-id, WWW-Authenticate", + requestedHeaders && requestedHeaders.trim() !== "" + ? requestedHeaders + : MCP_CORS_ALLOWED_HEADERS, + "access-control-expose-headers": MCP_CORS_EXPOSED_HEADERS, }, }); @@ -128,6 +156,10 @@ export const jsonRpcErrorBody = ( }); }; +/** Graceful rollback response that lets auto-negotiating v2 clients try legacy. */ +export const mcpModernDisabledResponse = (opts?: { readonly cors?: boolean }): Response => + jsonRpcErrorBody(400, -32022, "MCP 2026-07-28 support is disabled", opts); + /** * Advertised on transient-auth 503s (`Unavailable` outcomes) so clients back * off before retrying. Short: upstream auth-infra blips (JWKS fetch, IdP @@ -212,8 +244,92 @@ const renderDispatchError = (lookup: "not-found" | "forbidden"): Response => ? jsonRpcResponse(404, -32001, "Session not found") : jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer"); +const withModernMcpCors = (response: Response): Response => { + const headers = new Headers(response.headers); + headers.set("access-control-allow-origin", "*"); + headers.set("access-control-expose-headers", MCP_CORS_EXPOSED_HEADERS); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +}; + +interface ModernRequestInputs { + readonly builder: McpModernServerBuilder["Service"]; + readonly parsedBody: unknown; + readonly principal: Principal; +} + +interface ModernMcpRouter { + readonly fetch: ( + request: Request, + principal: Principal, + resource: McpResource, + builder: McpModernServerBuilder["Service"], + ) => Promise; +} + +/** Build the resource-keyed, process-lifetime modern handler cache. */ +const makeModernMcpRouter = (): ModernMcpRouter => { + const handlers = new Map(); + const requestInputs = new WeakMap(); + let signingKey: Uint8Array | undefined; + + const getSigningKey = (): Uint8Array => + (signingKey ??= crypto.getRandomValues(new Uint8Array(32))); + + const handlerFor = (resource: McpResource): McpHttpHandler => { + const key = mcpResourceKey(resource); + const cached = handlers.get(key); + if (cached) return cached; + + const handler = createMcpHandler( + (context: McpRequestContext) => { + const request = context.requestInfo; + const inputs = request ? requestInputs.get(request) : undefined; + if (!request || !inputs) { + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: the third-party McpServerFactory Promise contract has no typed failure channel; missing documented request context is an SDK defect + return Effect.runPromise(Effect.die("Modern MCP request has no authenticated context")); + } + return Effect.runPromise( + Effect.gen(function* () { + const clientCapabilities = yield* clientCapabilitiesFromRequest(request); + const requestStatePrincipal = mcpRequestStatePrincipal(inputs.principal); + const requestStateBinding = yield* Effect.promise(() => + mcpRequestStateBindingFromBody({ + body: inputs.parsedBody, + principal: requestStatePrincipal, + resource, + }), + ); + return yield* inputs.builder.build(inputs.principal, { + resource, + appsEnabled: appsEnabledForClientCapabilities(clientCapabilities), + requestStateSigningKey: getSigningKey(), + requestStatePrincipal, + ...(requestStateBinding === null ? {} : { requestStateBinding }), + }); + }), + ); + }, + { legacy: "reject" }, + ); + handlers.set(key, handler); + return handler; + }; + + return { + fetch: async (request, principal, resource, builder) => { + const parsedBody = await Effect.runPromise(requestBodyFromRequest(request)); + requestInputs.set(request, { builder, parsedBody, principal }); + return handlerFor(resource).fetch(request, { parsedBody }); + }, + }; +}; + /** Dispatch an MCP request through authenticate -> store.dispatch -> transport. */ -const mcpDispatch = (resource: McpResource) => +const mcpDispatch = (resource: McpResource, modern: ModernMcpRouter) => Effect.gen(function* () { const httpRequest = yield* HttpServerRequest.HttpServerRequest; const auth = yield* McpAuthProvider; @@ -222,7 +338,9 @@ const mcpDispatch = (resource: McpResource) => // CORS preflight: answer before auth so unauthenticated clients can probe. if (request.method === "OPTIONS") { - return fromWebResponse(corsPreflightResponse()); + return fromWebResponse( + corsPreflightResponse(request.headers.get("access-control-request-headers")), + ); } // Streamable-HTTP only defines GET/POST/DELETE on the endpoint. Any other @@ -245,6 +363,17 @@ const mcpDispatch = (resource: McpResource) => } const principal = outcome.principal; + if (!(yield* Effect.promise(() => isLegacyRequest(request)))) { + const builder = yield* McpModernServerBuilder; + if (builder.enabled === false) { + return fromWebResponse(mcpModernDisabledResponse()); + } + const response = yield* Effect.promise(() => + modern.fetch(request, principal, resource, builder), + ); + return fromWebResponse(withModernMcpCors(response)); + } + // No session id: per the streamable-HTTP transport contract, only POST opens // a session. A GET needs an existing id (400); a DELETE on nothing is a // no-op (204). Both short-circuit BEFORE dispatch so the store never spins up @@ -280,8 +409,8 @@ const mcpDispatch = (resource: McpResource) => * otherwise, since the envelope returns a `Response`) and rendered as a stable * JSON-RPC 500 -32603 + CORS, rather than a bare platform 500 with no body. */ -const mcpRoute = (resource: McpResource) => - mcpDispatch(resource).pipe( +const mcpRoute = (resource: McpResource, modern: ModernMcpRouter) => + mcpDispatch(resource, modern).pipe( Effect.catchCause((cause) => Effect.gen(function* () { const reporter = yield* McpErrorReporter; @@ -291,15 +420,16 @@ const mcpRoute = (resource: McpResource) => ), ); -const toolkitMcpRoute = Effect.gen(function* () { - const params = yield* HttpRouter.params; - const slug = params.toolkitSlug; - return yield* mcpRoute(slug ? { kind: "toolkit", slug } : defaultMcpResource); -}); +const toolkitMcpRoute = (modern: ModernMcpRouter) => + Effect.gen(function* () { + const params = yield* HttpRouter.params; + const slug = params.toolkitSlug; + return yield* mcpRoute(slug ? { kind: "toolkit", slug } : defaultMcpResource, modern); + }); /** * The shared MCP serving routes, as an `HttpRouter.use` Layer. A host merges - * this with its other routes and provides the two seam Layers + the HTTP + * this with its other routes and provides the three seam Layers + the HTTP * platform services. Provider-neutral: cloud adopts the same Layer next. * * The discovery `GET` routes come from `McpAuthProvider.discoveryRoutes`, so @@ -310,16 +440,22 @@ const toolkitMcpRoute = Effect.gen(function* () { export const McpServingRoutes = HttpRouter.use((router) => Effect.gen(function* () { const auth = yield* McpAuthProvider; + const modern = makeModernMcpRouter(); for (const route of auth.discoveryRoutes) { yield* router.add("GET", route.path, discoveryRoute(route.handler)); yield* router.add( "OPTIONS", route.path, - Effect.sync(() => fromWebResponse(corsPreflightResponse())), + Effect.gen(function* () { + const preflight = yield* HttpServerRequest.HttpServerRequest; + return fromWebResponse( + corsPreflightResponse(preflight.headers["access-control-request-headers"] ?? null), + ); + }), ); } - yield* router.add("*", MCP_PATH, mcpRoute(defaultMcpResource)); - yield* router.add("*", TOOLKIT_MCP_PATH, toolkitMcpRoute); + yield* router.add("*", MCP_PATH, mcpRoute(defaultMcpResource, modern)); + yield* router.add("*", TOOLKIT_MCP_PATH, toolkitMcpRoute(modern)); }), ); @@ -341,7 +477,12 @@ export const McpDiscoveryRoutes = HttpRouter.use((router) => yield* router.add( "OPTIONS", route.path, - Effect.sync(() => fromWebResponse(corsPreflightResponse())), + Effect.gen(function* () { + const preflight = yield* HttpServerRequest.HttpServerRequest; + return fromWebResponse( + corsPreflightResponse(preflight.headers["access-control-request-headers"] ?? null), + ); + }), ); } }), diff --git a/packages/hosts/mcp/src/in-memory-session-store.test.ts b/packages/hosts/mcp/src/in-memory-session-store.test.ts index 8d87f56970..6c61e9bcab 100644 --- a/packages/hosts/mcp/src/in-memory-session-store.test.ts +++ b/packages/hosts/mcp/src/in-memory-session-store.test.ts @@ -1,12 +1,20 @@ -import { expect, it } from "@effect/vitest"; +import { describe, expect, it } from "@effect/vitest"; import { Effect } from "effect"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; + +import type { ExecutionEngine, ExecutionResult, ResumeResponse } from "@executor-js/execution"; +import { FormElicitation, ToolAddress } from "@executor-js/sdk"; import { makeInMemoryMcpSessionStore, McpEngineBuildError, type McpBuildServerOptions, } from "./in-memory-session-store"; +import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "./mcp-apps"; import { defaultMcpResource, type Principal } from "./seams"; +import { buildMcpServer } from "./tool-server"; const TEST_PRINCIPAL: Principal = { accountId: "acct_test", @@ -18,37 +26,158 @@ const TEST_PRINCIPAL: Principal = { roles: ["user"], }; -it("preserves native elicitation mode when creating an in-memory MCP session", async () => { - let buildOptions: McpBuildServerOptions | undefined; - const sessions = makeInMemoryMcpSessionStore((_principal, options) => { - buildOptions = options; - return Effect.fail(new McpEngineBuildError({ cause: "stop after capturing options" })); +const TOOL_ADDRESS = ToolAddress.make("tools.test.org.main.approve"); + +const makeElicitingEngine = (): { + readonly engine: ExecutionEngine; + readonly resumedWith: () => ResumeResponse | undefined; +} => { + const request = FormElicitation.make({ + message: "Which value?", + requestedSchema: { + type: "object", + properties: { value: { type: "string" } }, + required: ["value"], + }, }); + const paused: Extract = { + status: "paused", + execution: { + id: "execution-legacy", + elicitationContext: { address: TOOL_ADDRESS, args: {}, request }, + }, + }; + let resumedWith: ResumeResponse | undefined; + return { + engine: { + execute: () => Effect.succeed({ result: "unused" }), + executeWithPause: () => Effect.succeed(paused), + resume: (_executionId, response) => { + resumedWith = response; + return Effect.succeed({ + status: "completed", + result: { result: response.content?.value }, + }); + }, + isExecutionSettled: () => Effect.succeed(false), + getPausedExecution: (executionId) => + Effect.succeed(executionId === paused.execution.id ? paused.execution : null), + pausedExecutionCount: () => Effect.succeed(1), + hasPausedExecutions: () => Effect.succeed(true), + getDescription: Effect.succeed("store integration test executor"), + }, + resumedWith: () => resumedWith, + }; +}; - const result = await Effect.runPromise( - sessions.store.dispatch({ - request: new Request("https://executor.test/mcp?elicitation_mode=native", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - jsonrpc: "2.0", - id: 1, - method: "initialize", - params: { - protocolVersion: "2025-06-18", - capabilities: { elicitation: { form: {} } }, - clientInfo: { name: "test-client", version: "1.0.0" }, - }, +describe("in-memory MCP session store", () => { + it("preserves native elicitation mode and supplies the session inputs", async () => { + let buildOptions: McpBuildServerOptions | undefined; + const sessions = makeInMemoryMcpSessionStore((_principal, options) => { + buildOptions = options; + return Effect.fail(new McpEngineBuildError({ cause: "stop after capturing options" })); + }); + + const result = await Effect.runPromise( + sessions.store.dispatch({ + request: new Request("https://executor.test/mcp?elicitation_mode=native", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: { elicitation: { form: {} } }, + clientInfo: { name: "test-client", version: "1.0.0" }, + }, + }), }), + principal: TEST_PRINCIPAL, + resource: defaultMcpResource, + sessionId: null, + method: "POST", }), - principal: TEST_PRINCIPAL, - resource: defaultMcpResource, - sessionId: null, - method: "POST", - }), - ); - - expect(result).toBeInstanceOf(Response); - expect((result as Response).status).toBe(500); - expect(buildOptions?.elicitationMode).toEqual({ mode: "native" }); + ); + + expect(result).toBeInstanceOf(Response); + expect((result as Response).status).toBe(500); + expect(buildOptions?.elicitationMode).toEqual({ mode: "native" }); + expect(buildOptions).toMatchObject({ + appsEnabled: false, + requestStatePrincipal: `${TEST_PRINCIPAL.accountId}\u0000${TEST_PRINCIPAL.organizationId}`, + sessionful: true, + }); + expect(buildOptions?.requestStateSigningKey).toBeInstanceOf(Uint8Array); + }); + + it("serves a legacy client with live Apps capabilities, elicitation, and reuse", async () => { + const { engine, resumedWith } = makeElicitingEngine(); + const sessions = makeInMemoryMcpSessionStore((_principal, options) => + buildMcpServer({ + engine, + ...options, + loadAppShellHtml: async () => "", + }).pipe(Effect.map((mcpServer) => ({ mcpServer, engine }))), + ); + const fetch = async (input: string | URL | Request, init?: RequestInit): Promise => { + const request = + input instanceof Request ? new Request(input, init) : new Request(input.toString(), init); + const result = await Effect.runPromise( + sessions.store.dispatch({ + request, + principal: TEST_PRINCIPAL, + resource: defaultMcpResource, + sessionId: request.headers.get("mcp-session-id"), + method: request.method, + }), + ); + return result instanceof Response + ? result + : new Response(result === "forbidden" ? "Forbidden" : "Not found", { + status: result === "forbidden" ? 403 : 404, + }); + }; + const transport = new StreamableHTTPClientTransport( + new URL("https://executor.test/mcp?elicitation_mode=native"), + { fetch }, + ); + const client = new Client( + { name: "legacy-store-client", version: "1.0.0" }, + { + capabilities: { + elicitation: { form: {} }, + extensions: { [EXTENSION_ID]: { mimeTypes: [RESOURCE_MIME_TYPE] } }, + }, + }, + ); + let elicitationRequests = 0; + client.setRequestHandler(ElicitRequestSchema, async (request) => { + elicitationRequests += 1; + expect(request.params).toMatchObject({ message: "Which value?" }); + return { action: "accept" as const, content: { value: "approved" } }; + }); + + await client.connect(transport); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always close the sessionful client and store + try { + const tools = await client.listTools(); + expect(tools.tools.map(({ name }) => name)).toContain("execute"); + expect(tools.tools.map(({ name }) => name)).toContain("execute-action"); + + const result = await client.callTool({ + name: "execute", + arguments: { code: "await tools.test.approve()" }, + }); + expect(result.content).toEqual([{ type: "text", text: "approved" }]); + expect(result.isError).toBeFalsy(); + expect(elicitationRequests).toBe(1); + expect(resumedWith()).toEqual({ action: "accept", content: { value: "approved" } }); + expect(sessions.sessionCount()).toBe(1); + } finally { + await client.close(); + await sessions.close(); + } + }); }); diff --git a/packages/hosts/mcp/src/in-memory-session-store.ts b/packages/hosts/mcp/src/in-memory-session-store.ts index 2cd870fc1b..64ca5f9d92 100644 --- a/packages/hosts/mcp/src/in-memory-session-store.ts +++ b/packages/hosts/mcp/src/in-memory-session-store.ts @@ -1,6 +1,8 @@ import { Cause, Data, Effect, Layer } from "effect"; -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; +import { + type McpServer, + WebStandardStreamableHTTPServerTransport, +} from "@modelcontextprotocol/server"; import { formatPausedExecution, type ExecutionEngine } from "@executor-js/execution"; @@ -26,7 +28,7 @@ import { type Principal, type McpResource, } from "./seams"; -import type { BrowserApprovalStore } from "./tool-server"; +import { mcpRequestStatePrincipal, type BrowserApprovalStore } from "./tool-server"; // --------------------------------------------------------------------------- // In-process McpSessionStore — the single-node serving store, shared by every @@ -75,12 +77,20 @@ export interface McpBuildServerOptions { * with `?artifacts=false`; opted out, the built server registers none of * the artifact tools, resource, or skills. */ readonly artifactsEnabled?: boolean; + /** The sessionful assembly starts disabled and replaces this from initialize. */ + readonly appsEnabled: false; + /** Process-lifetime HMAC key for legacy-shim continuation state. */ + readonly requestStateSigningKey: Uint8Array; + /** Stable authenticated owner bound into continuation state. */ + readonly requestStatePrincipal: string; + /** Selects live negotiated capabilities instead of stateless request policy. */ + readonly sessionful: true; } /** Build the per-session `McpServer` + engine for a principal (the host's engine + tools). */ export type McpBuildServer = ( principal: Principal, - options?: McpBuildServerOptions, + options: McpBuildServerOptions, ) => Effect.Effect; export interface InMemoryMcpSessionStore { @@ -104,10 +114,17 @@ export interface InMemoryMcpSessionStore { request: Request, principal?: Principal, ) => Promise; + /** Number of live initialized sessions currently owned by this store. */ + readonly sessionCount: () => number; /** Dispose every live session — wire into the host's shutdown (not a seam). */ readonly close: () => Promise; } +type McpRequestBuildOptions = Pick< + McpBuildServerOptions, + "artifactsEnabled" | "browserApprovalStore" | "elicitationMode" +>; + const ignoreClose = (close: (() => Promise) | undefined): Promise => close ? Effect.runPromise(Effect.ignore(Effect.tryPromise({ try: close, catch: () => undefined }))) @@ -162,6 +179,7 @@ export const makeInMemoryMcpSessionStore = ( const owners = new Map(); const engines = new Map>(); const approvals: InProcessBrowserApprovalStore = makeInProcessBrowserApprovalStore(); + const requestStateSigningKey = crypto.getRandomValues(new Uint8Array(32)); const dispose = async (id: string, opts: { transport?: boolean; server?: boolean } = {}) => { const transport = transports.get(id); @@ -222,7 +240,7 @@ export const makeInMemoryMcpSessionStore = ( const buildOptionsFor = ( request: Request, sessionId: () => string | null, - ): McpBuildServerOptions => { + ): McpRequestBuildOptions => { const artifactsEnabled = readArtifactsEnabled(request); const mode = readElicitationMode(request); if (mode !== "browser") return { artifactsEnabled, elicitationMode: { mode } }; @@ -253,12 +271,19 @@ export const makeInMemoryMcpSessionStore = ( return buildServer(principal, { ...buildOptionsFor(request, () => createdSessionId), resource, + appsEnabled: false, + requestStateSigningKey, + requestStatePrincipal: mcpRequestStatePrincipal(principal), + sessionful: true, }).pipe( Effect.flatMap(({ mcpServer, engine }) => Effect.gen(function* () { const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID(), - enableJsonResponse: true, + // Native mode needs an open SSE response for the legacy shim's + // server→client elicitation request. Other modes preserve the + // store's existing single-JSON response behavior. + enableJsonResponse: readElicitationMode(request) !== "native", onsessioninitialized: (sid) => { createdSessionId = sid; transports.set(sid, transport); @@ -376,6 +401,7 @@ export const makeInMemoryMcpSessionStore = ( store, handlePausedRequest, handleApprovalRequest, + sessionCount: () => transports.size, close: async () => { const ids = new Set([...transports.keys(), ...servers.keys()]); await Promise.all([...ids].map((id) => dispose(id, { transport: true, server: true }))); diff --git a/packages/hosts/mcp/src/index.ts b/packages/hosts/mcp/src/index.ts index 2e536296d9..77da891a1f 100644 --- a/packages/hosts/mcp/src/index.ts +++ b/packages/hosts/mcp/src/index.ts @@ -5,17 +5,17 @@ // its seams (`McpAuthProvider` / `McpSessionStore` / `McpErrorReporter` / // `Principal`) + the canonical JSON-RPC error renderer (`jsonRpcErrorBody`). // -// The executor TOOL factory (`createExecutorMcpServer` — the execute/resume -// tools, the elicitation/browser-approval bridge, the Zod input schemas) is a -// different center of gravity: a host's session store builds an `McpServer` -// from it. It lives behind the `@executor-js/host-mcp/tool-server` subpath so -// the serving surface stays small and dependency-light. +// The executor tool assemblies (execute/resume tools, elicitation and browser +// approval bridges, Zod input schemas) are a different center of gravity. They +// live behind the `tool-server` subpath so this serving +// surface stays small and dependency-light. // --------------------------------------------------------------------------- export { Principal, McpAuthProvider, McpSessionStore, + McpModernServerBuilder, McpErrorReporter, McpErrorReporterNoop, defaultMcpResource, @@ -33,6 +33,7 @@ export { type McpDiscoveryRoute, type McpDispatchInput, type McpDispatchResult, + type McpModernServerBuildOptions, type McpResource, } from "./seams"; @@ -40,5 +41,6 @@ export { McpServingRoutes, McpDiscoveryRoutes, jsonRpcErrorBody, + mcpModernDisabledResponse, UNAVAILABLE_RETRY_AFTER_SECONDS, } from "./envelope"; diff --git a/packages/hosts/mcp/src/mcp-apps.test.ts b/packages/hosts/mcp/src/mcp-apps.test.ts new file mode 100644 index 0000000000..ee95271a7b --- /dev/null +++ b/packages/hosts/mcp/src/mcp-apps.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Client } from "@modelcontextprotocol/client"; +import { InMemoryTransport, McpServer } from "@modelcontextprotocol/server"; + +import { + EXTENSION_ID, + getUiCapability, + registerAppResource, + registerAppTool, + RESOURCE_MIME_TYPE, + RESOURCE_URI_META_KEY, +} from "./mcp-apps"; + +const APP_URI = "ui://executor/test.html"; + +const withClient = async ( + configure: (server: McpServer) => void, + run: (client: Client) => Promise, +) => { + const server = new McpServer( + { name: "apps-helper-test", version: "1.0.0" }, + { capabilities: { resources: {}, tools: {} } }, + ); + configure(server); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: "apps-helper-client", version: "1.0.0" }); + await server.connect(serverTransport); + await client.connect(clientTransport); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: test helper owns both linked transports and always closes them + try { + await run(client); + } finally { + await clientTransport.close(); + await serverTransport.close(); + } +}; + +describe("vendored MCP Apps helpers", () => { + it("mirrors nested resourceUri metadata to the legacy key and preserves visibility", async () => { + await withClient( + (server) => { + registerAppTool( + server, + "nested-meta", + { + _meta: { + ui: { resourceUri: APP_URI, visibility: ["model"] }, + }, + }, + async () => ({ content: [{ type: "text", text: "ok" }] }), + ); + }, + async (client) => { + const tool = (await client.listTools()).tools.find(({ name }) => name === "nested-meta"); + expect(tool?._meta).toEqual({ + ui: { resourceUri: APP_URI, visibility: ["model"] }, + [RESOURCE_URI_META_KEY]: APP_URI, + }); + }, + ); + }); + + it("mirrors the legacy resourceUri key to nested UI metadata", async () => { + await withClient( + (server) => { + registerAppTool( + server, + "legacy-meta", + { _meta: { [RESOURCE_URI_META_KEY]: APP_URI } }, + async () => ({ content: [{ type: "text", text: "ok" }] }), + ); + }, + async (client) => { + const tool = (await client.listTools()).tools.find(({ name }) => name === "legacy-meta"); + expect(tool?._meta).toEqual({ + [RESOURCE_URI_META_KEY]: APP_URI, + ui: { resourceUri: APP_URI }, + }); + }, + ); + }); + + it("defaults app resources to the MCP Apps MIME type", async () => { + await withClient( + (server) => { + registerAppResource(server, "Test App", APP_URI, {}, async () => ({ + contents: [{ uri: APP_URI, text: "" }], + })); + }, + async (client) => { + const resource = (await client.listResources()).resources.find( + ({ uri }) => uri === APP_URI, + ); + expect(resource?.mimeType).toBe(RESOURCE_MIME_TYPE); + }, + ); + }); + + it("extracts the MCP Apps extension capability", () => { + const capability = { mimeTypes: [RESOURCE_MIME_TYPE] }; + expect( + getUiCapability({ + extensions: { [EXTENSION_ID]: capability }, + }), + ).toBe(capability); + expect(getUiCapability({})).toBeUndefined(); + expect(getUiCapability(undefined)).toBeUndefined(); + }); +}); diff --git a/packages/hosts/mcp/src/mcp-apps.ts b/packages/hosts/mcp/src/mcp-apps.ts new file mode 100644 index 0000000000..b73d042f59 --- /dev/null +++ b/packages/hosts/mcp/src/mcp-apps.ts @@ -0,0 +1,117 @@ +/** + * Temporary MCP Apps server helpers for the current MCP SDK. + * + * This is a wire-compatible local copy of the helpers currently published by + * the upstream ext-apps server package. Remove this module once + * https://github.com/modelcontextprotocol/ext-apps/issues/702 is resolved. + */ +import type { + ClientCapabilities, + McpServer, + ReadResourceCallback, + RegisteredResource, + RegisteredTool, + ResourceMetadata, + StandardSchemaWithJSON, + ToolAnnotations, + ToolCallback, +} from "@modelcontextprotocol/server"; + +/** The legacy flat metadata key understood by older MCP Apps hosts. */ +export const RESOURCE_URI_META_KEY = "ui/resourceUri"; + +/** MIME type used by MCP Apps HTML resources. */ +export const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"; + +/** MCP capability-extension identifier for MCP Apps support. */ +export const EXTENSION_ID = "io.modelcontextprotocol/ui"; + +/** Model/app visibility scopes supported by MCP Apps tool metadata. */ +export type McpAppToolVisibility = "model" | "app"; + +/** MCP Apps metadata attached to a tool. */ +export type McpAppToolMeta = { + readonly resourceUri?: string; + readonly visibility?: readonly McpAppToolVisibility[]; +}; + +/** MCP Apps capability data advertised by a client. */ +export type McpUiClientCapabilities = { + readonly mimeTypes?: readonly string[]; +}; + +/** Client capabilities shape carrying the MCP Apps extension. */ +export type McpAppsClientCapabilities = ClientCapabilities & { + readonly extensions?: Record; +}; + +/** Tool configuration accepted by {@link registerAppTool}. */ +export type McpAppToolConfig< + InputArgs extends StandardSchemaWithJSON | undefined = undefined, + OutputArgs extends StandardSchemaWithJSON | undefined = undefined, +> = { + readonly title?: string; + readonly description?: string; + readonly inputSchema?: InputArgs; + readonly outputSchema?: OutputArgs; + readonly annotations?: ToolAnnotations; + readonly _meta: Record & { + readonly ui?: McpAppToolMeta; + readonly [RESOURCE_URI_META_KEY]?: string; + }; +}; + +/** Resource configuration accepted by {@link registerAppResource}. */ +export type McpAppResourceConfig = ResourceMetadata & { + readonly _meta?: Record & { + readonly ui?: Record; + }; +}; + +/** + * Register an MCP Apps tool while mirroring nested and legacy resource URI + * metadata in both directions. + */ +export const registerAppTool = < + InputArgs extends StandardSchemaWithJSON | undefined = undefined, + OutputArgs extends StandardSchemaWithJSON | undefined = undefined, +>( + server: Pick, + name: string, + config: McpAppToolConfig, + callback: ToolCallback, +): RegisteredTool => { + const ui = config._meta.ui; + const legacyResourceUri = config._meta[RESOURCE_URI_META_KEY]; + let metadata = config._meta; + + if (ui?.resourceUri && !legacyResourceUri) { + metadata = { ...config._meta, [RESOURCE_URI_META_KEY]: ui.resourceUri }; + } else if (legacyResourceUri && !ui?.resourceUri) { + metadata = { ...config._meta, ui: { ...ui, resourceUri: legacyResourceUri } }; + } + + return server.registerTool( + name, + { + ...config, + _meta: metadata, + }, + callback, + ); +}; + +/** Register an MCP Apps resource, defaulting its MIME type when omitted. */ +export const registerAppResource = ( + server: Pick, + name: string, + uri: string, + config: McpAppResourceConfig, + readCallback: ReadResourceCallback, +): RegisteredResource => + server.registerResource(name, uri, { mimeType: RESOURCE_MIME_TYPE, ...config }, readCallback); + +/** Read MCP Apps extension data from a client's capabilities. */ +export const getUiCapability = ( + clientCapabilities: McpAppsClientCapabilities | null | undefined, +): McpUiClientCapabilities | undefined => clientCapabilities?.extensions?.[EXTENSION_ID]; diff --git a/packages/hosts/mcp/src/seams.ts b/packages/hosts/mcp/src/seams.ts index 12e713dd91..45ee61b639 100644 --- a/packages/hosts/mcp/src/seams.ts +++ b/packages/hosts/mcp/src/seams.ts @@ -1,10 +1,11 @@ import { Context, Effect, Layer, Schema } from "effect"; import type { Cause } from "effect"; +import type { McpServer } from "@modelcontextprotocol/server"; // --------------------------------------------------------------------------- // Provider-neutral MCP serving seams. // -// The shared MCP serving envelope (see `./envelope`) depends ONLY on these TWO +// The shared MCP serving envelope (see `./envelope`) depends ONLY on these THREE // seams. Each product (self-host, cloud, local) provides its own Layer // satisfying the same tags; the envelope never changes. The seams are kept // deliberately small — anything provider-specific (Durable-Object trace @@ -12,17 +13,18 @@ import type { Cause } from "effect"; // per-org engine construction) is configured *inside* a provider's adapter and // never baked into the envelope. // -// Two seams, deliberately: +// Three seams, deliberately: // 1. McpAuthProvider — called on EVERY request. Authenticate AND authorize // (it may read the `mcp-session-id` header to do session-aware org-authz). // 2. McpSessionStore — owns the serving session lifecycle: create + forward + // ownership, end to end, via a single `dispatch`. The store builds/forwards // the transport and returns the transport `Response`. +// 3. McpModernServerBuilder — builds one stateless MCP server for each +// authenticated modern request. The envelope owns handler/bus lifetime. // -// There is deliberately NO envelope-level engine seam. Self-host's in-process -// store builds its engine via an INTERNAL dependency (its Layer provides it); -// cloud's Durable-Object store builds its engine inside the DO. The engine is a -// store implementation detail, not an envelope seam. +// There is deliberately NO envelope-level engine seam. Both server builders +// remain host adapters; the envelope only chooses the protocol era and supplies +// request/resource/auth context. // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- @@ -271,7 +273,43 @@ export class McpSessionStore extends Context.Service< >()("@executor-js/host-mcp/McpSessionStore") {} // =========================================================================== -// SEAM 3 (optional) — McpErrorReporter: observe a request-orchestration defect. +// SEAM 3 — McpModernServerBuilder: one stateless MCP server per request. +// =========================================================================== + +/** Request-scoped inputs the envelope adds to a host's modern server config. */ +export interface McpModernServerBuildOptions { + /** The served endpoint whose capability policy the server must apply. */ + readonly resource: McpResource; + /** Whether this request's client advertised MCP Apps HTML support. */ + readonly appsEnabled: boolean; + /** Process-lifetime key used to sign opaque request continuation state. */ + readonly requestStateSigningKey: Uint8Array | string; + /** Stable authenticated-owner key bound into signed continuation state. */ + readonly requestStatePrincipal: string; + /** Closed resource/tool/code binding for this parsed modern request. */ + readonly requestStateBinding?: string; +} + +/** + * Build one stateless MCP server for an authenticated modern request. + * + * The envelope owns the cached `createMcpHandler` and its subscriptions bus; + * providers own execution-stack construction and tool configuration here. + */ +export class McpModernServerBuilder extends Context.Service< + McpModernServerBuilder, + { + /** Inbound-only emergency switch. Unset means modern serving is enabled. */ + readonly enabled?: boolean; + readonly build: ( + principal: Principal, + options: McpModernServerBuildOptions, + ) => Effect.Effect; + } +>()("@executor-js/host-mcp/McpModernServerBuilder") {} + +// =========================================================================== +// SEAM 4 (optional) — McpErrorReporter: observe a request-orchestration defect. // // The envelope wraps the entire `/mcp` handling in a top-level `catchCause` and // renders a JSON-RPC 500 -32603 (the streamable-HTTP transport never sees the diff --git a/packages/hosts/mcp/src/stdio-integration.test.ts b/packages/hosts/mcp/src/stdio-integration.test.ts index d66f9893fc..5f6e03482f 100644 --- a/packages/hosts/mcp/src/stdio-integration.test.ts +++ b/packages/hosts/mcp/src/stdio-integration.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "@effect/vitest"; +import { Client as ModernClient } from "@modelcontextprotocol/client"; +import { StdioClientTransport as ModernStdioClientTransport } from "@modelcontextprotocol/client/stdio"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; import { Effect } from "effect"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -9,22 +12,25 @@ import { join, resolve } from "node:path"; const repoRoot = resolve(import.meta.dirname, "../../../.."); const cliEntry = resolve(repoRoot, "apps/cli/src/main.ts"); const testScope = resolve(repoRoot, "apps/local"); +const stdioServerEntry = resolve(repoRoot, "apps/local/src/mcp-stdio-test-server.ts"); +const stdioServer = { + command: "bun", + args: ["run", stdioServerEntry], +}; describe("MCP stdio integration", () => { it.effect( - "execute tool returns result over stdio transport", + "execute tool returns result over the CLI stdio bridge", () => Effect.gen(function* () { // Fresh temp dir so the test doesn't migrate against the developer's // real ~/.executor/data.db. const dataDir = mkdtempSync(join(tmpdir(), "executor-mcp-test-")); - const transport = new StdioClientTransport({ command: "bun", args: ["run", cliEntry, "mcp", "--scope", testScope], env: { ...process.env, EXECUTOR_DATA_DIR: dataDir }, }); - const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} }); yield* Effect.acquireRelease( @@ -33,7 +39,7 @@ describe("MCP stdio integration", () => { ); const { tools } = yield* Effect.promise(() => client.listTools()); - expect(tools.map((t) => t.name)).toContain("execute"); + expect(tools.map(({ name }) => name)).toContain("execute"); const result = yield* Effect.promise(() => client.callTool({ @@ -48,4 +54,77 @@ describe("MCP stdio integration", () => { }).pipe(Effect.scoped), { timeout: 30_000 }, ); + + it.effect( + "serves a legacy client and completes the native elicitation round-trip", + () => + Effect.gen(function* () { + const transport = new StdioClientTransport(stdioServer); + const client = new Client( + { name: "legacy-stdio-test-client", version: "1.0.0" }, + { capabilities: { elicitation: { form: {} } } }, + ); + let elicitationRequests = 0; + client.setRequestHandler(ElicitRequestSchema, async (request) => { + elicitationRequests += 1; + expect(request.params).toMatchObject({ message: "Approve the stdio action?" }); + return { action: "accept" as const, content: { value: "approved" } }; + }); + + yield* Effect.acquireRelease( + Effect.promise(() => client.connect(transport)), + () => Effect.promise(() => transport.close()), + ); + + const { tools } = yield* Effect.promise(() => client.listTools()); + expect(tools.map((t) => t.name)).toContain("execute"); + + const result = yield* Effect.promise(() => + client.callTool({ + name: "execute", + arguments: { code: "needs approval" }, + }), + ); + + const text = (result.content as Array<{ type: string; text: string }>)[0]?.text; + expect(text).toContain("approved"); + expect(result.isError).toBeFalsy(); + expect(elicitationRequests).toBe(1); + }).pipe(Effect.scoped), + { timeout: 30_000 }, + ); + + it.effect( + "serves a modern-pinned client over the same stdio entry", + () => + Effect.gen(function* () { + const transport = new ModernStdioClientTransport(stdioServer); + const client = new ModernClient( + { name: "modern-stdio-test-client", version: "1.0.0" }, + { + capabilities: {}, + versionNegotiation: { mode: { pin: "2026-07-28" } }, + }, + ); + + yield* Effect.acquireRelease( + Effect.promise(() => client.connect(transport)), + () => Effect.promise(() => transport.close()), + ); + + const { tools } = yield* Effect.promise(() => client.listTools()); + expect(tools.map(({ name }) => name)).toContain("execute"); + + const result = yield* Effect.promise(() => + client.callTool({ + name: "execute", + arguments: { code: "return 2+2" }, + }), + ); + + expect(result.content).toEqual([{ type: "text", text: "4" }]); + expect(result.isError).toBeFalsy(); + }).pipe(Effect.scoped), + { timeout: 30_000 }, + ); }); diff --git a/packages/hosts/mcp/src/tool-server-core.ts b/packages/hosts/mcp/src/tool-server-core.ts new file mode 100644 index 0000000000..ccb07e2f8c --- /dev/null +++ b/packages/hosts/mcp/src/tool-server-core.ts @@ -0,0 +1,2178 @@ +import { Duration, Effect, Match, Option, Predicate, Result, Schema } from "effect"; +import * as Cause from "effect/Cause"; +import { ContentBlockSchema, ToolAnnotationsSchema } from "@modelcontextprotocol/core"; +import type { InputRequiredResult } from "@modelcontextprotocol/server"; +import * as z from "zod/v4"; + +import { isToolFile, sanitizeArtifactPreviewMarkup } from "@executor-js/sdk"; +import type { + Artifact, + ArtifactBinding, + ArtifactSummary, + ElicitationRequest, + SaveArtifactInput, + ToolFileValue, +} from "@executor-js/sdk"; +import type * as Tracer from "effect/Tracer"; +import { + createExecutionEngine, + formatExecuteResult, + formatPausedExecution, + formatTtlDuration, + findSkill, + renderSkillsIndex, + skillCatalogFor, + EXECUTE_SKILL, + INTEGRATION_INVENTORY_HEADER, + type Skill, + type ExecutionEngine, + type ExecutionEngineConfig, + type ResumeResponse, + type ExecutionResult, + type PausedExecution, + type PausedExecutionDeadline, +} from "@executor-js/execution"; +import { + MCP_APPS_SHELL_RESOURCE_URI, + applyArtifactEdits, + smokeRenderRejection, + validateArtifactCode, + type ArtifactEdit, + type ArtifactSmokeRenderResult, +} from "./create-artifact"; +import { TOOL_CALL_CONTRACT_MESSAGE } from "./tool-call-code"; +import { resolveArtifactAction } from "./artifact-action"; +import { + extractArtifactRoles, + resolveArtifactBindings, + type BindableConnection, +} from "./artifact-bindings"; +import { RESOURCE_MIME_TYPE } from "./mcp-apps"; + +// --------------------------------------------------------------------------- +// Shared config +// --------------------------------------------------------------------------- + +type SharedMcpServerConfig = { + /** + * Pre-built `execute` tool description. When provided, the factory skips + * its internal `engine.getDescription` yield. Useful when the caller + * wants to compute the description inside its own Effect tracer context + * so sub-spans (`executor.integrations.list`, `executor.tools.list`) nest as + * children of the caller's root span. + */ + readonly description?: string; + /** + * Parent span override for engine calls. The factory captures the + * caller's context at construction time, but `Effect.runPromiseWith` + * starts a fresh fiber per SDK callback — so the `currentSpan` + * FiberRef resets to root unless explicitly anchored. + * + * Accepts either a fixed span (per-request McpServer instances) or a + * getter (session-scoped instances that need to anchor each callback + * under whichever request triggered it; see the Cloud DO). + */ + readonly parentSpan?: Tracer.AnySpan | (() => Tracer.AnySpan | undefined); + /** + * Enable verbose MCP capability / elicitation debug logging. + */ + readonly debug?: boolean; + /** + * Controls how elicitation is handled for this MCP connection. The default + * is model-managed resume, where paused executions expose interaction + * metadata and the model can call `resume` with the user's response. + */ + readonly elicitationMode?: + | { + readonly mode: "browser"; + readonly approvalUrl: (executionId: string) => string; + } + | { + readonly mode: "model"; + } + | { + readonly mode: "native"; + }; + readonly browserApprovalStore?: BrowserApprovalStore; + /** + * Host-owned lifecycle for paused executions. The MCP server reports pause + * boundaries; the host decides whether that means a keepAlive lease, browser + * wait, durable record, or no-op. + */ + readonly pausedExecutionHooks?: PausedExecutionHooks; + /** + * Host-provided approval lease duration. When present, paused payloads carry + * an absolute deadline and hooks receive the same deadline. + */ + readonly pausedExecutionLeaseMs?: number; + /** + * Optional host-owned model resume fallback. Used by Cloudflare session + * Durable Objects to route a resume miss to the session that owns the pause. + */ + readonly resumeFallback?: ( + executionId: string, + response: ResumeResponse, + ) => Effect.Effect; + /** + * Loads the MCP-Apps shell HTML served as the `ui://executor/shell.html` + * resource. Injected rather than imported: the shell carries React, Recharts + * and Tailwind, and this package also runs on Workers. Hosts that can serve + * it pass `loadMcpAppsShellHtml` from `@executor-js/mcp-apps-shell`; hosts + * that leave it unset simply don't register the resource or the ui tools. + */ + readonly loadAppShellHtml?: () => Promise; + /** + * Per-connection artifacts opt-out. Defaults to true. A client that connects + * with `?artifacts=false` gets NO artifact surface at all: none of the five + * artifact tools, no `ui://` shell resource, and no artifact entries in the + * `skills` inventory — the same shape a host without `loadAppShellHtml` + * serves. `execute`, `skills` and `resume` are untouched. + */ + readonly artifactsEnabled?: boolean; + /** + * Renders an artifact once, server-side, before it is saved — so a component + * that throws on its first render is refused at create time with the real + * error instead of saving cleanly and dying on the user's page. + * + * Injected for the same reason `loadAppShellHtml` is: it needs React, + * react-dom/server and the whole component barrel, and this package must not + * drag any of that into the graph of a host that only ever calls `execute`. + * Hosts that can afford it pass `smokeRenderArtifact` from + * `@executor-js/mcp-apps-shell`, which loads it behind a dynamic import. + * + * Unset means no smoke check: creates are validated statically and saved, as + * they were before. That is also what happens when the check itself fails — + * see the fail-open path in `createArtifact`. + */ + readonly smokeRenderArtifact?: (code: string) => Promise; + /** + * The scoped executor's artifact operations, so `create-artifact` can persist what + * it renders and `list-artifacts` / `show-artifact` can read it back. Only + * the three operations the MCP surface needs, so hosts don't have to hand the + * whole `Executor` across this boundary. + */ + readonly artifacts?: McpArtifactsPort; + /** + * The caller's saved connections, for binding an artifact's integration roles + * at create time. Structurally satisfied by `executor.connections`; hosts pass + * the same scoped executor they pass `artifacts`. + * + * Absent means `create-artifact` cannot bind, so it refuses code that calls an + * integration rather than saving an artifact that could never run. + */ + readonly connections?: McpConnectionsPort; + /** + * Builds the web-app deep link for a saved artifact. Clients that can't + * render MCP Apps get this URL instead of an inline widget. Absent (stdio has + * no origin at all) means `create-artifact` still persists and reports the id, but + * has no URL to offer. + */ + readonly artifactUrl?: (artifactId: string) => string; + /** + * Notified when an agent-facing artifact tool completes a user-meaningful + * operation: `create-artifact` (created, or updated when it overwrote an + * existing id) and `show-artifact` (viewed). Internal artifact reads — + * binding resolution inside `execute-action` — deliberately do not notify. + * Best-effort observation: failures are swallowed and cannot affect the tool + * result. Hosts recording product analytics supply it; core stays agnostic. + */ + readonly onArtifactUsage?: (action: "created" | "viewed" | "updated") => Effect.Effect; + /** + * Whether the client this session belongs to can render MCP Apps, as + * negotiated at a previous `initialize`. + * + * Capabilities normally arrive from the client at `initialize` and live only + * in the server instance. A session whose host evicted and cold-restored it + * (deploy, idle) is rebuilt mid-conversation with no `initialize` to replay, + * so without this the rebuilt server assumes no apps support and silently + * downgrades every artifact to a deep link. Hosts that persist the + * negotiated value pass it back here; the next `initialize`, if one comes, + * overwrites it. + */ + readonly restoredAppsEnabled?: boolean; + /** + * Called when `initialize` negotiates the client's MCP-Apps support, so the + * host can persist it for {@link restoredAppsEnabled} on a later cold + * restore. Best-effort: failures are swallowed and never affect the session. + */ + readonly onAppsEnabledChange?: (appsEnabled: boolean) => Effect.Effect; +}; + +/** + * The narrow artifact surface the MCP tools need. Structurally satisfied by + * `Executor["artifacts"]`, so hosts holding a scoped executor can pass + * `executor.artifacts` directly. + */ +export type McpArtifactsPort = { + readonly list: () => Effect.Effect; + readonly get: (id: string) => Effect.Effect; + readonly save: (input: SaveArtifactInput) => Effect.Effect; +}; + +/** + * The connection surface binding needs: list what this caller can reach. The + * scoped executor has already narrowed it, so an inferred binding can never + * name a connection the caller couldn't call themselves. + */ +export type McpConnectionsPort = { + readonly list: () => Effect.Effect; +}; + +export type ExecutorMcpToolConfig = + | (ExecutionEngineConfig & SharedMcpServerConfig) + | ({ readonly engine: ExecutionEngine } & SharedMcpServerConfig) + | (ExecutionEngineConfig & SharedMcpServerConfig & { readonly stateless: true }) + | ({ readonly engine: ExecutionEngine; readonly stateless: true } & SharedMcpServerConfig); + +export type BrowserApprovalStore = { + readonly takeResponse: (executionId: string) => Effect.Effect; + readonly waitForResponse?: (executionId: string) => Effect.Effect; +}; + +export const PAUSED_APPROVAL_TIMEOUT_MS = 4 * 60 * 1000; +const BROWSER_APPROVAL_WAIT_TIMEOUT_MS = PAUSED_APPROVAL_TIMEOUT_MS + 1000; + +export type PausedExecutionHooks = { + readonly onExecutionPaused?: ( + executionId: string, + deadline: PausedExecutionDeadline | undefined, + ) => Effect.Effect; + readonly onResumeStarted?: (executionId: string) => Effect.Effect; + readonly onResumeSettled?: (executionId: string) => Effect.Effect; +}; + +export type ResumeUnavailableStatus = + | "execution_not_found" + | "execution_expired" + | "execution_forbidden" + | "execution_already_settled"; + +export type ResumeFallbackOutcome = + | { + readonly status: "result"; + readonly result: McpToolResult; + } + | { + readonly status: Exclude; + readonly ttlMs?: number; + } + | { + readonly status: "execution_not_found"; + }; + +/** Request identity normalized from an MCP SDK callback context. */ +export type McpRequestJoinKeys = { + readonly requestId: string | number; + readonly sessionId?: string | undefined; +}; + +/** 2026-07-28 input-required result returned by the MCP assembly. */ +export type McpInputRequiredResult = InputRequiredResult; + +/** Result shape produced by Executor MCP handlers. */ +export type McpHandlerResult = McpToolResult | McpInputRequiredResult; + +/** Enable/disable controls returned by the MCP SDK for registered tools. */ +export type RegisteredMcpTool = { + readonly enable: () => void; + readonly disable: () => void; +}; + +type McpToolConfig = { + readonly title?: string; + readonly description?: string; + readonly inputSchema: Shape; + readonly annotations?: z.infer; + readonly _meta?: Record; +}; + +type MutableMcpToolShape = { + -readonly [Key in keyof Shape]: Shape[Key]; +}; + +type McpAppResourceConfig = { + readonly title?: string; + readonly description?: string; + readonly mimeType?: string; + readonly _meta?: Record; +}; + +type McpResourceResult = { + readonly contents: readonly ( + | { + readonly uri: string; + readonly mimeType?: string; + readonly text: string; + readonly _meta?: Record; + } + | { + readonly uri: string; + readonly mimeType?: string; + readonly blob: string; + readonly _meta?: Record; + } + )[]; +}; + +/** Services supplied to an assembly's SDK-specific native elicitation bridge. */ +export type NativeExecutionServices< + E extends Cause.YieldableError, + RequestContext extends McpRequestJoinKeys, +> = { + readonly engine: ExecutionEngine; + readonly code: string; + readonly requestContext: RequestContext; + readonly source: "execute" | "execute_action"; + readonly debugLog: (event: string, data: Record) => void; + readonly complete: (result: Parameters[0]) => McpToolResult; + readonly resume: ( + executionId: string, + response: ResumeResponse, + ) => Effect.Effect; + readonly executionPaused: (execution: PausedExecution) => Effect.Effect; +}; + +/** + * Minimal SDK-specific surface needed by the shared Executor tool assembly. + * Both SDK versions are adapted to this interface at their composition roots. + */ +export type ExecutorMcpAssembly = { + readonly server: Server; + readonly initialAppsEnabled: boolean; + readonly getClientCapabilities: () => unknown | null; + readonly getElicitationSupport: () => { readonly form: boolean; readonly url: boolean }; + readonly getUiCapability: () => { readonly mimeTypes?: readonly string[] } | undefined; + readonly onInitialized: (callback: () => void) => void; + readonly registerTool: ( + name: string, + config: McpToolConfig, + callback: ( + args: z.output>>, + requestContext: RequestContext, + ) => Promise, + ) => RegisteredMcpTool; + readonly registerAppTool: ( + name: string, + config: McpToolConfig & { readonly _meta: Record }, + callback: ( + args: z.output>>, + requestContext: RequestContext, + ) => Promise, + ) => RegisteredMcpTool; + readonly registerAppResource: ( + name: string, + uri: string, + config: McpAppResourceConfig, + callback: () => McpResourceResult | Promise, + ) => void; + readonly executeNative: ( + services: NativeExecutionServices, + ) => Effect.Effect; +}; + +// --------------------------------------------------------------------------- +// Shared elicitation helpers +// --------------------------------------------------------------------------- + +const readDebugDefault = (): boolean => { + if (typeof process === "undefined" || !process.env) return false; + const value = process.env.EXECUTOR_MCP_DEBUG; + return value === "1" || value === "true"; +}; + +export const elicitationRequestTag = (request: ElicitationRequest): ElicitationRequest["_tag"] => + Match.value(request).pipe( + Match.tag("UrlElicitation", () => "UrlElicitation" as const), + Match.tag("FormElicitation", () => "FormElicitation" as const), + Match.exhaustive, + ); + +const pausedInteractionKind = (request: ElicitationRequest): ElicitationRequest["_tag"] => + elicitationRequestTag(request); + +// --------------------------------------------------------------------------- +// MCP result formatting +// --------------------------------------------------------------------------- + +export type McpToolResult = { + content: ContentBlock[]; + structuredContent?: Record; + isError?: boolean; +}; + +type ContentBlock = z.infer; + +type FormattedExecuteInput = Parameters[0]; +type ExecuteOutputItem = NonNullable[number]; + +const TEXT_FILE_CONTENT_MAX_CHARS = 64_000; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const toolFileName = (file: ToolFileValue): string => file.name ?? "tool-output"; + +const fileResourceUri = (file: ToolFileValue): string => + `executor-file:///${encodeURIComponent(toolFileName(file))}`; + +const normalizedMimeType = (file: ToolFileValue): string => + file.mimeType.split(";")[0]?.trim().toLowerCase() ?? ""; + +const toolFileKind = (file: ToolFileValue): "image" | "audio" | "text" | "resource" => { + const mimeType = normalizedMimeType(file); + if (mimeType.startsWith("image/")) return "image"; + if (mimeType.startsWith("audio/")) return "audio"; + if ( + mimeType.startsWith("text/") || + mimeType === "application/json" || + mimeType.endsWith("+json") || + mimeType === "application/xml" || + mimeType.endsWith("+xml") || + mimeType === "application/javascript" || + mimeType === "application/x-javascript" || + mimeType === "application/yaml" || + mimeType === "application/x-yaml" + ) { + return "text"; + } + return "resource"; +}; + +const bytesFromBase64 = (base64: string): Uint8Array => { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; +}; + +const decodeTextFile = (file: ToolFileValue): string => { + const text = new TextDecoder("utf-8", { fatal: false }).decode(bytesFromBase64(file.data)); + if (text.length <= TEXT_FILE_CONTENT_MAX_CHARS) return text; + return `${text.slice(0, TEXT_FILE_CONTENT_MAX_CHARS)}\n\n[truncated ${ + text.length - TEXT_FILE_CONTENT_MAX_CHARS + } characters]`; +}; + +const toolFileContent = (file: ToolFileValue): ContentBlock[] => { + const kind = toolFileKind(file); + if (kind === "image") { + return [{ type: "image", data: file.data, mimeType: file.mimeType }]; + } + if (kind === "audio") { + return [{ type: "audio", data: file.data, mimeType: file.mimeType }]; + } + if (kind === "text") { + return [{ type: "text", text: decodeTextFile(file) }]; + } + return [ + { + type: "resource", + resource: { + uri: fileResourceUri(file), + mimeType: file.mimeType, + blob: file.data, + }, + }, + ]; +}; + +const toolFileSummaryLine = (file: ToolFileValue, index?: number): string => { + const prefix = index === undefined ? "" : `${index + 1}. `; + return `${prefix}${toolFileName(file)} (${file.mimeType}, ${file.byteLength} bytes)`; +}; + +const outputFileContent = (file: ToolFileValue): ContentBlock[] => [ + { + type: "text", + text: `File output: ${toolFileSummaryLine(file)}`, + }, + ...toolFileContent(file), +]; + +const isFileOutputItem = ( + item: ExecuteOutputItem, +): item is { readonly type: "file"; readonly file: ToolFileValue } => + isRecord(item) && item.type === "file" && isToolFile(item.file); + +const isMcpContentBlock = (value: unknown): value is ContentBlock => + ContentBlockSchema.safeParse(value).success; + +const isContentOutputItem = ( + item: ExecuteOutputItem, +): item is { readonly type: "content"; readonly content: ContentBlock } => + isRecord(item) && item.type === "content" && isMcpContentBlock(item.content); + +const outputItemContent = (item: ExecuteOutputItem): ContentBlock[] => { + if (isFileOutputItem(item)) { + return outputFileContent(item.file); + } + if (isContentOutputItem(item)) { + return [item.content]; + } + return [{ type: "text", text: "Invalid execution output item omitted." }]; +}; + +const toMcpOutputResult = ( + result: FormattedExecuteInput, + output: readonly ExecuteOutputItem[], +): McpToolResult => { + const formatted = formatExecuteResult(result); + const content = output.flatMap(outputItemContent); + const extraText: string[] = []; + if (result.error) { + extraText.push(formatted.text); + } else if (result.result != null) { + // A script may both emit() and return: keep the returned value in the + // content channel too, or clients that ignore structuredContent drop it. + // formatted.text already renders the return value plus any logs. + extraText.push(formatted.text); + } else if (result.logs && result.logs.length > 0) { + extraText.push(`Logs:\n${result.logs.join("\n")}`); + } + content.push(...extraText.map((text): ContentBlock => ({ type: "text", text }))); + + return { + content, + structuredContent: formatted.structured, + isError: formatted.isError || undefined, + }; +}; + +const toMcpResult = (result: FormattedExecuteInput): McpToolResult => { + if (result.output && result.output.length > 0) return toMcpOutputResult(result, result.output); + const formatted = formatExecuteResult(result); + return { + content: [{ type: "text", text: formatted.text }], + structuredContent: formatted.structured, + isError: formatted.isError || undefined, + }; +}; + +const toMcpPausedResult = (formatted: ReturnType): McpToolResult => ({ + content: [{ type: "text", text: formatted.text }], + structuredContent: formatted.structured, +}); + +export const formatMcpExecutionOutcome = ( + outcome: ExecutionResult, + options?: { readonly pausedDeadline?: PausedExecutionDeadline }, +): McpToolResult => + outcome.status === "completed" + ? toMcpResult(outcome.result) + : toMcpPausedResult( + formatPausedExecution(outcome.execution, { deadline: options?.pausedDeadline }), + ); + +// `execute` failures reaching the MCP host are infra defects — domain +// failures from tools are now expressed as `ToolResult` values (success +// channel) and flow through `formatExecuteResult`. Emit an opaque +// generic plus a fresh correlation id and log the cause out-of-band so +// the model can't read internal context off `.message`. +const newCorrelationId = (): string => + Math.floor(Math.random() * 0x1_0000_0000) + .toString(16) + .padStart(8, "0"); + +const defaultResumeApprovalUrl = (executionId: string): string => + `/resume/${encodeURIComponent(executionId)}`; + +const browserApprovalReturnPrompt = + "Return text to the user telling them to approve the action at this approvalUrl. Only after you have prompted the user, call the `resume` tool with this executionId; `resume` will wait for the user's browser decision."; + +const formatResumeApprovalRequired = (input: { + readonly executionId: string; + readonly approvalUrl: string; +}): McpToolResult => ({ + content: [ + { + type: "text", + text: [ + "User approval required.", + "", + "Tell the user to open this URL while signed in and approve or decline the paused interaction:", + input.approvalUrl, + "", + "Required next steps for this agent:", + browserApprovalReturnPrompt, + ].join("\n"), + }, + ], + structuredContent: { + status: "user_approval_required", + executionId: input.executionId, + approvalUrl: input.approvalUrl, + resumePrompt: browserApprovalReturnPrompt, + }, +}); + +const toMcpFailureResult = (cause: Cause.Cause): McpToolResult => { + const correlationId = newCorrelationId(); + const defect = Cause.findDefect(cause); + const nativeElicitationFailed = + Result.isSuccess(defect) && + Predicate.isTagged("McpNativeElicitationTransportError")(defect.success); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: best-effort defect logging must tolerate non-serializable causes + try { + console.error( + `[executor:mcp] execute defect correlation_id=${correlationId}`, + Cause.pretty(cause), + ); + } catch { + /* ignore logger failures */ + } + const text = nativeElicitationFailed + ? `Native elicitation transport failed [${correlationId}]. Reconnect the MCP client and try again.` + : `Internal tool error [${correlationId}]`; + return { + content: [{ type: "text", text: `Error: ${text}` }], + structuredContent: { + status: "error", + error: text, + ...(nativeElicitationFailed ? { errorCode: "native_elicitation_transport_failed" } : {}), + }, + isError: true, + }; +}; + +const recoveryText = + "To recover, run the execute tool again with the original code; if it pauses, a fresh executionId will be issued."; + +const resumeUnavailableResult = (input: { + readonly status: ResumeUnavailableStatus; + readonly executionId: string; + readonly ttlMs?: number; +}): McpToolResult => { + const windowMs = input.ttlMs ?? PAUSED_APPROVAL_TIMEOUT_MS; + const approvalWindow = formatTtlDuration(windowMs); + const textByStatus: Record = { + execution_not_found: [ + `Paused execution is unknown: ${input.executionId}.`, + `Paused executions are only resumable for a limited window; this id may have expired or never existed.`, + recoveryText, + ], + execution_expired: [ + `Paused execution expired: ${input.executionId}.`, + `Approval windows last ${approvalWindow}; the owning session no longer has a live pause for this executionId.`, + recoveryText, + ], + execution_forbidden: [ + `Paused execution cannot be resumed by this authenticated identity: ${input.executionId}.`, + "Resume must be called by the same account and organization that owns the paused session.", + ], + execution_already_settled: [ + `Paused execution has already settled: ${input.executionId}.`, + "The resume result is no longer available for replay.", + "Run execute again only if the result is still needed.", + ], + }; + return { + content: [ + { + type: "text" as const, + text: textByStatus[input.status].join(" "), + }, + ], + structuredContent: { + status: input.status, + executionId: input.executionId, + ...(input.status === "execution_expired" ? { ttlMs: windowMs } : {}), + ...(input.status === "execution_forbidden" ? {} : { recovery: "re_execute" }), + }, + isError: true, + }; +}; + +const missingExecutionResult = (executionId: string): McpToolResult => + resumeUnavailableResult({ status: "execution_not_found", executionId }); + +const alreadySettledResult = (executionId: string): McpToolResult => + resumeUnavailableResult({ status: "execution_already_settled", executionId }); + +const fallbackOutcomeResult = ( + executionId: string, + outcome: ResumeFallbackOutcome, +): McpToolResult => { + if (outcome.status === "result") return outcome.result; + return resumeUnavailableResult({ + status: outcome.status, + executionId, + ttlMs: "ttlMs" in outcome ? outcome.ttlMs : undefined, + }); +}; + +// The `skills` tool serves named, static how-to docs (see the execution +// package's skills registry). No name -> the index; a known name -> that +// skill's body; an unknown name -> the index plus a not-found note so the model +// retries with a listed name instead of the same miss. +// +// The skill body IS the payload, returned as plain text content. We do NOT +// attach `structuredContent`: a client that prefers structured output (Claude +// Code does) will surface only that and drop the text, so the long-form guide +// silently fails to load. The not-found case keeps `isError` (a separate field +// clients honor) so a bad name still reads as a failure. +// +// The `execute` skill also gets the live integration inventory appended, the +// same block the execute tool description carries, so a model reading the guide +// sees what is connected without a second round trip. +// +// The catalog is per-session: a connection that opted out of artifacts never +// sees the artifact skills, so the index cannot advertise a how-to for tools it +// does not have, and fetching one by name misses like any unknown skill. +const skillsResult = ( + name: string | undefined, + executeInventory: string, + catalog: readonly Skill[], +): McpToolResult => { + const trimmed = name?.trim(); + if (!trimmed) { + return { content: [{ type: "text", text: renderSkillsIndex(catalog) }] }; + } + const skill = findSkill(trimmed, catalog); + if (!skill) { + return { + content: [ + { type: "text", text: `No skill named "${trimmed}".\n\n${renderSkillsIndex(catalog)}` }, + ], + isError: true, + }; + } + const text = + skill.name === EXECUTE_SKILL.name && executeInventory.length > 0 + ? `${skill.body}\n\n${executeInventory}` + : skill.body; + return { content: [{ type: "text", text }] }; +}; + +/** Pull the live integration inventory block out of the built execute + * description (it runs from its header to the end), so the `skills` tool can + * re-use it without rebuilding the inventory from the executor. */ +const extractInventory = (description: string): string => { + const index = description.indexOf(INTEGRATION_INVENTORY_HEADER); + return index === -1 ? "" : description.slice(index).trimEnd(); +}; + +// --------------------------------------------------------------------------- +// Hang-visibility join keys +// --------------------------------------------------------------------------- +// A killed execution exports nothing: OTEL only ships a span when it ends, and +// a Cloudflare deploy/eviction cancels the request without an error, so a hung +// `execute` is invisible in the trace store. Two mitigations live here: +// 1. Every execution-path span carries the JSON-RPC id + transport session id +// (`mcp.rpc.id`, `mcp.request.session_id`), so a client's +// `notifications/cancelled` — which names the cancelled request id — can +// be joined to the exact call it gave up on. +// 2. A zero-duration start marker span (`.start`, a +// 1:1 pairing so "started without finishing" is a single unambiguous +// query) is emitted the moment execution begins. It ends immediately, so +// it becomes exportable while the execution is still running; whether it +// actually ships before a kill depends on the host's span processor +// draining first (cloud batches on a 1s timer, so markers for executions +// that survive >1s export, sub-second kills can still lose theirs). A +// start marker without a matching completion span is a true positive for +// an execution that died mid-flight. + +// `mcp.request.session_id` is emitted unconditionally (empty string when the +// transport carries none) to match the worker-side `annotateMcpRequest` +// producer: JSON-RPC ids are small per-session integers, so a row without the +// session key would make `mcp.rpc.id` globally ambiguous. +const joinKeyAttributes = (joinKeys: McpRequestJoinKeys): Record => ({ + "mcp.rpc.id": String(joinKeys.requestId), + "mcp.request.session_id": joinKeys.sessionId ?? "", +}); + +const startMarker = (name: string, attributes: Record): Effect.Effect => + Effect.void.pipe(Effect.withSpan(name, { attributes })); + +// --------------------------------------------------------------------------- +// Artifacts / MCP Apps result formatting +// --------------------------------------------------------------------------- +// +// Delivery is negotiated, not branched on by the model: an artifact reaches the +// user as an inline widget when the client renders MCP Apps, and as a link into +// the web app when it doesn't. Both carry `artifactId`, because either way the +// artifact was saved and can be reopened later. + +const renderRejectedResult = (reason: string): McpToolResult => ({ + content: [{ type: "text", text: `create-artifact rejected: ${reason}` }], + structuredContent: { status: "error", error: reason }, + isError: true, +}); + +/** An edit batch that could not be applied. Carries the current stored source + * so the model can rebuild its edits without a `show-artifact` round trip. */ +const editRejectedResult = (reason: string, currentCode: string): McpToolResult => ({ + content: [ + { + type: "text", + text: [ + `edit-artifact rejected: ${reason}`, + "Nothing was changed. The artifact's current source is in structuredContent.code — build the retry against it.", + ].join("\n"), + }, + ], + structuredContent: { status: "error", error: reason, code: currentCode }, + isError: true, +}); + +/** `execute-action` was handed something other than a single proxy-shaped tool + * call. Names the contract rather than just refusing, since the reader is + * either a confused iframe or someone probing the app channel by hand. */ +const actionRejectedResult = (): McpToolResult => ({ + content: [{ type: "text", text: TOOL_CALL_CONTRACT_MESSAGE }], + structuredContent: { status: "error", error: "invalid_action_code" }, + isError: true, +}); + +/** + * The artifact whose bindings a call must be resolved through is missing or + * isn't this caller's. + * + * One result for both, deliberately: distinguishing "no such artifact" from + * "not yours" would let the app channel probe for ids that exist. + */ +const actionArtifactUnavailableResult = (): McpToolResult => ({ + content: [ + { + type: "text", + text: "This action refers to an artifact that isn't available on this account.", + }, + ], + structuredContent: { status: "error", error: "artifact_unavailable" }, + isError: true, +}); + +/** + * A role in the artifact's code has no connection behind it. + * + * Structured rather than prose-only because the binding UI that ships with + * sharing renders exactly this: which role failed, for which integration, and + * what the viewer could bind it to instead. The apps plugin's `BindingError` + * carries the same three facts for the same reason. + */ +const bindingUnresolvedResult = (input: { + readonly role: string; + readonly integration: string; + readonly message: string; + readonly candidates: readonly string[]; +}): McpToolResult => ({ + content: [ + { + type: "text", + text: + input.candidates.length > 0 + ? `${input.message} Choose one of ${input.candidates.join(", ")}.` + : input.message, + }, + ], + structuredContent: { + status: "error", + error: "binding_unresolved", + role: input.role, + integration: input.integration, + candidates: input.candidates, + }, + isError: true, +}); + +const renderedInAppResult = (input: { + readonly code: string; + readonly artifactId: string; + readonly title: string; + readonly url?: string | undefined; +}): McpToolResult => ({ + content: [ + { + type: "text", + text: [ + `Rendered "${input.title}" as an interactive UI component. Saved as artifact ${input.artifactId}.`, + // The link rides along even though the widget rendered: clients lose + // rendered widgets in ways the server never sees (a transcript + // reopened without re-reading the ui:// resource shows raw JSON), and + // when that happens this URL in the conversation is the only path + // back to the artifact the model can offer. + ...(input.url ? [`It also stays available at ${input.url}`] : []), + ].join("\n"), + }, + ], + structuredContent: { + code: input.code, + artifactId: input.artifactId, + ...(input.url ? { url: input.url } : {}), + }, +}); + +const renderedAsLinkResult = (input: { + readonly url: string; + readonly artifactId: string; + readonly title: string; +}): McpToolResult => ({ + content: [ + { + type: "text", + text: [ + `Saved "${input.title}" as artifact ${input.artifactId}.`, + "This MCP client cannot display MCP Apps, so give the user this URL to open it:", + input.url, + ].join("\n"), + }, + ], + structuredContent: { + status: "fallback_url", + url: input.url, + artifactId: input.artifactId, + }, +}); + +const renderedWithoutSurfaceResult = (input: { + readonly artifactId: string; + readonly title: string; +}): McpToolResult => ({ + content: [ + { + type: "text", + text: [ + `Saved "${input.title}" as artifact ${input.artifactId}.`, + "This MCP client cannot display MCP Apps and this deployment has no web UI configured, so there is nowhere to show it right now.", + "Tell the user the artifact was saved and can be opened from a client that supports MCP Apps.", + ].join("\n"), + }, + ], + structuredContent: { + status: "fallback_unavailable", + reason: "mcp_apps_unsupported", + artifactId: input.artifactId, + }, +}); + +const artifactsUnavailableResult = (): McpToolResult => ({ + content: [ + { + type: "text", + text: "Artifacts are not available on this connection.", + }, + ], + structuredContent: { status: "error", error: "artifacts_unavailable" }, + isError: true, +}); + +const artifactListResult = (artifacts: readonly ArtifactSummary[]): McpToolResult => { + const items = artifacts.map((artifact) => ({ + id: artifact.id, + title: artifact.title, + description: artifact.description, + updatedAt: artifact.updatedAt.toISOString(), + })); + const text = + items.length === 0 + ? "No saved artifacts yet. Use create-artifact to make one." + : [ + "Saved artifacts:", + ...items.map( + (item) => + `- ${item.id} — ${item.title}${item.description ? `: ${item.description}` : ""} (updated ${item.updatedAt})`, + ), + ].join("\n"); + return { content: [{ type: "text", text }], structuredContent: { artifacts: items } }; +}; + +const artifactNotFoundResult = (id: string): McpToolResult => ({ + content: [ + { + type: "text", + text: `No artifact with id "${id}". Call list-artifacts to see what is saved.`, + }, + ], + structuredContent: { status: "error", error: "artifact_not_found", id }, + isError: true, +}); + +const JsonObjectFromString = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)); +const decodeJsonObjectString = Schema.decodeUnknownOption(JsonObjectFromString); + +const parseJsonContent = (raw: string): Record | undefined => { + if (raw === "{}") return undefined; + const parsed = decodeJsonObjectString(raw); + return Option.isSome(parsed) ? parsed.value : undefined; +}; + +// --------------------------------------------------------------------------- +// Server factory +// --------------------------------------------------------------------------- + +/** Assemble the shared Executor tools through one SDK-specific adapter. */ +export const buildExecutorMcpTools = < + E extends Cause.YieldableError, + Server, + RequestContext extends McpRequestJoinKeys, +>( + config: ExecutorMcpToolConfig, + createAssembly: () => ExecutorMcpAssembly, +): Effect.Effect => + Effect.gen(function* () { + const engine = "engine" in config ? config.engine : createExecutionEngine(config); + const description = + config.description ?? + (yield* engine.getDescription.pipe(Effect.withSpan("mcp.host.get_description"))); + // The same live integration inventory the description carries, re-used by + // the `skills` tool so the `execute` guide lists what is connected too. + const executeInventory = extractInventory(description); + // Artifacts are on unless this connection opted out (`?artifacts=false`). + // One flag decides the whole surface: the tools, the shell resource, and + // the skills catalog below. + const artifactsEnabled = config.artifactsEnabled ?? true; + const skillCatalog: readonly Skill[] = skillCatalogFor({ artifacts: artifactsEnabled }); + + // Captured at construction time. SDK callbacks fire later (often + // deferred past the outer Effect's await), so we use the runtime to + // re-enter Effect-land at each callback edge. + const context = yield* Effect.context(); + const debugEnabled = config.debug ?? readDebugDefault(); + const debugLog = (event: string, data: Record) => { + if (!debugEnabled) return; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: debug logging must tolerate non-serializable SDK capability snapshots + try { + console.error(`[executor:mcp] ${event} ${JSON.stringify(data)}`); + } catch { + console.error(`[executor:mcp] ${event}`, data); + } + }; + const elicitationMode = + config.elicitationMode ?? + ({ + mode: "model", + } as const); + const pauseDeadline = (): PausedExecutionDeadline | undefined => { + const ttlMs = config.pausedExecutionLeaseMs; + return ttlMs === undefined || ttlMs <= 0 + ? undefined + : { ttlMs, expiresAt: new Date(Date.now() + ttlMs).toISOString() }; + }; + const onExecutionPaused = ( + executionId: string, + deadline: PausedExecutionDeadline | undefined, + ): Effect.Effect => + config.pausedExecutionHooks?.onExecutionPaused?.(executionId, deadline) ?? Effect.void; + const onResumeStarted = (executionId: string): Effect.Effect => + config.pausedExecutionHooks?.onResumeStarted?.(executionId) ?? Effect.void; + const onResumeSettled = (executionId: string): Effect.Effect => + config.pausedExecutionHooks?.onResumeSettled?.(executionId) ?? Effect.void; + const resumeWithLifecycle = (executionId: string, response: ResumeResponse) => + Effect.gen(function* () { + yield* onResumeStarted(executionId); + return yield* engine.resume(executionId, response); + }).pipe(Effect.ensuring(onResumeSettled(executionId))); + + const localExecutionAlreadySettled = (executionId: string): Effect.Effect => + engine.isExecutionSettled?.(executionId) ?? Effect.succeed(false); + + const resumeFallback = ( + executionId: string, + response: ResumeResponse, + ): Effect.Effect => + config + .resumeFallback?.(executionId, response) + .pipe(Effect.catchCause(() => Effect.succeed(null))) ?? Effect.succeed(null); + + const formatPausedModelResult = ( + execution: PausedExecution, + source: "execute" | "execute_action" | "resume" | "browser_resume", + ): Effect.Effect => + Effect.gen(function* () { + const deadline = pauseDeadline(); + yield* Effect.annotateCurrentSpan({ + "mcp.execute.paused": true, + "mcp.execute.paused_execution_id": execution.id, + "mcp.execute.pause_source": source, + }); + yield* onExecutionPaused(execution.id, deadline); + return toMcpPausedResult(formatPausedExecution(execution, { deadline })); + }); + + const resolveParentSpan = (): Tracer.AnySpan | undefined => { + const ps = config.parentSpan; + return typeof ps === "function" ? ps() : ps; + }; + const anchor = (effect: Effect.Effect): Effect.Effect => { + const parent = resolveParentSpan(); + return parent ? Effect.withParentSpan(effect, parent) : effect; + }; + const runToolEffect = (effect: Effect.Effect) => + Effect.runPromiseWith(context)( + anchor(effect).pipe( + Effect.catchCause((cause) => Effect.succeed(toMcpFailureResult(cause))), + ), + ); + + const assembly = yield* Effect.sync(createAssembly).pipe( + Effect.withSpan("mcp.host.create_server"), + ); + const server = assembly.server; + + const executeWithNativeElicitation = ( + code: string, + extra: RequestContext, + source: "execute" | "execute_action", + ): Effect.Effect => + assembly.executeNative({ + engine, + code, + requestContext: extra, + source, + debugLog, + complete: toMcpResult, + resume: resumeWithLifecycle, + executionPaused: (execution) => + Effect.gen(function* () { + const deadline = pauseDeadline(); + yield* Effect.annotateCurrentSpan({ + "mcp.execute.paused": true, + "mcp.execute.paused_execution_id": execution.id, + "mcp.execute.pause_source": source, + }); + yield* onExecutionPaused(execution.id, deadline); + }), + }); + + const executeCode = (code: string, extra: RequestContext): Effect.Effect => + Effect.gen(function* () { + yield* startMarker("mcp.host.tool.execute.start", { + "mcp.tool.name": "execute", + "mcp.execute.code_length": code.length, + }); + debugLog("execute.call", { + elicitationMode: elicitationMode.mode, + elicitationSupport: assembly.getElicitationSupport(), + clientCapabilities: assembly.getClientCapabilities(), + codeLength: code.length, + }); + if (elicitationMode.mode === "native") { + return yield* executeWithNativeElicitation(code, extra, "execute"); + } + const outcome = yield* engine.executeWithPause(code); + debugLog("execute.paused_flow_result", { + status: outcome.status, + executionId: outcome.status === "paused" ? outcome.execution.id : undefined, + interactionKind: + outcome.status === "paused" + ? pausedInteractionKind(outcome.execution.elicitationContext.request) + : undefined, + }); + if (outcome.status === "paused") { + const deadline = pauseDeadline(); + yield* Effect.annotateCurrentSpan({ + "mcp.execute.paused": true, + "mcp.execute.paused_execution_id": outcome.execution.id, + "mcp.execute.pause_source": "execute", + }); + yield* onExecutionPaused(outcome.execution.id, deadline); + return elicitationMode.mode === "browser" + ? yield* requireUserResumeApproval(outcome.execution.id) + : toMcpPausedResult(formatPausedExecution(outcome.execution, { deadline })); + } + return toMcpResult(outcome.result); + }).pipe( + Effect.withSpan("mcp.host.tool.execute", { + attributes: { + "mcp.tool.name": "execute", + "mcp.execute.code_length": code.length, + }, + }), + Effect.annotateSpans(joinKeyAttributes(extra)), + ); + + /** What the caller could bind an unresolved role to. Best effort: the + * connections port is optional, and a failure to enumerate must not + * replace the real error with a different one. */ + const bindingCandidates = (integration: string): Effect.Effect => + config.connections + ? config.connections.list().pipe( + Effect.map((all) => + all + .filter((connection) => connection.integration === integration) + .map( + (connection) => + `${connection.integration}.${connection.owner}.${connection.name}`, + ), + ), + Effect.catchCause(() => Effect.succeed([] as readonly string[])), + ) + : Effect.succeed([]); + + /** The artifact as THIS caller can read it. A miss and a row owned by + * someone else are the same answer, because they are the same query. */ + const loadArtifact = (id: string): Effect.Effect => + config.artifacts + ? config.artifacts.get(id).pipe(Effect.catchCause(() => Effect.succeed(null))) + : Effect.succeed(null); + + // `execute-action` is `execute` as called by the shell rather than by the + // model, and the difference is who owns approval. The shell renders the + // approval modal itself in its trusted outer frame, so a pause here must + // come back as the `waiting_for_interaction` payload the shell knows how to + // resolve — never as a browser approval URL, which the user would have no + // way to act on from inside a widget. That holds even when the session's + // elicitation mode is `browser`, which is why this doesn't just call + // `executeCode`. + // + // The other difference is WIDTH. `execute` takes arbitrary code because the + // model writes it; this channel takes exactly one proxy-shaped tool call, + // because that is all a declarative artifact can produce. See + // `tool-call-code.ts`. + // + // The third difference is that the incoming path is not yet an ADDRESS. + // Artifact code names an integration and, optionally, a role; the tier and + // connection are held on the artifact row. So this channel re-writes the + // call against those bindings before executing, and the executed code is + // built HERE, from a parsed path and a stored binding, never taken from the + // iframe verbatim. That is what makes the short form safe: an iframe that + // invented a five-segment address would only be naming a role the artifact + // has no binding for, and would be refused. + const executeCodeFromApp = ( + code: string, + artifactId: string | undefined, + extra: RequestContext, + ): Effect.Effect => + Effect.gen(function* () { + const resolution = yield* resolveArtifactAction({ code, artifactId, loadArtifact }); + debugLog("execute_action.call", { + elicitationMode: elicitationMode.mode, + elicitationSupport: assembly.getElicitationSupport(), + codeLength: code.length, + status: resolution.status, + artifactId: artifactId ?? null, + }); + if (resolution.status === "invalid_action_code") { + yield* Effect.annotateCurrentSpan({ "mcp.execute_action.rejected": true }); + return actionRejectedResult(); + } + if (resolution.status === "artifact_unavailable") { + return actionArtifactUnavailableResult(); + } + if (resolution.status === "binding_unresolved") { + yield* Effect.annotateCurrentSpan({ + "mcp.execute_action.binding_unresolved": true, + "mcp.execute_action.role": resolution.role, + }); + return bindingUnresolvedResult({ + role: resolution.role, + integration: resolution.integration, + message: resolution.message, + candidates: yield* bindingCandidates(resolution.integration), + }); + } + const boundCode = resolution.code; + + if (elicitationMode.mode === "native") { + return yield* executeWithNativeElicitation(boundCode, extra, "execute_action"); + } + const outcome = yield* engine.executeWithPause(boundCode); + debugLog("execute_action.paused_flow_result", { + status: outcome.status, + executionId: outcome.status === "paused" ? outcome.execution.id : undefined, + interactionKind: + outcome.status === "paused" + ? pausedInteractionKind(outcome.execution.elicitationContext.request) + : undefined, + }); + if (outcome.status === "paused") { + return yield* formatPausedModelResult(outcome.execution, "execute_action"); + } + return toMcpResult(outcome.result); + }).pipe( + Effect.withSpan("mcp.host.tool.execute_action", { + attributes: { + "mcp.tool.name": "execute-action", + "mcp.execute.code_length": code.length, + }, + }), + ); + + const resumeExecution = ( + executionId: string, + action: "accept" | "decline" | "cancel", + content: Record | undefined, + extra: RequestContext, + ): Effect.Effect => + Effect.gen(function* () { + yield* startMarker("mcp.host.tool.resume.start", { + "mcp.tool.name": "resume", + "mcp.execute.execution_id": executionId, + }); + debugLog("resume.call", { + executionId, + action, + hasContent: content !== undefined, + clientCapabilities: assembly.getClientCapabilities(), + }); + const outcome = yield* resumeWithLifecycle(executionId, { action, content }); + if (!outcome) { + debugLog("resume.missing_execution", { executionId }); + if (yield* localExecutionAlreadySettled(executionId)) { + return alreadySettledResult(executionId); + } + const fallback = yield* resumeFallback(executionId, { action, content }); + if (fallback) { + debugLog("resume.fallback_result", { executionId, status: fallback.status }); + return fallbackOutcomeResult(executionId, fallback); + } + return missingExecutionResult(executionId); + } + debugLog("resume.result", { + executionId, + status: outcome.status, + nextExecutionId: outcome.status === "paused" ? outcome.execution.id : undefined, + interactionKind: + outcome.status === "paused" + ? pausedInteractionKind(outcome.execution.elicitationContext.request) + : undefined, + }); + if (outcome.status === "paused") { + return yield* formatPausedModelResult(outcome.execution, "resume"); + } + return toMcpResult(outcome.result); + }).pipe( + Effect.withSpan("mcp.host.tool.resume", { + attributes: { + "mcp.tool.name": "resume", + "mcp.execute.resume.action": action, + "mcp.execute.execution_id": executionId, + }, + }), + Effect.annotateSpans(joinKeyAttributes(extra)), + ); + + const requireUserResumeApproval = (executionId: string): Effect.Effect => + Effect.sync(() => { + const approvalUrl = + elicitationMode.mode === "browser" + ? elicitationMode.approvalUrl(executionId) + : defaultResumeApprovalUrl(executionId); + debugLog("resume.user_approval_required", { + executionId, + approvalUrl, + clientCapabilities: assembly.getClientCapabilities(), + }); + return formatResumeApprovalRequired({ executionId, approvalUrl }); + }).pipe( + Effect.withSpan("mcp.host.tool.resume.user_approval_required", { + attributes: { + "mcp.tool.name": "resume", + "mcp.execute.execution_id": executionId, + }, + }), + ); + + const takeBrowserApprovalResponse = ( + executionId: string, + ): Effect.Effect => { + return config.browserApprovalStore?.takeResponse(executionId) ?? Effect.succeed(null); + }; + + const waitForBrowserApprovalResponse = ( + executionId: string, + ): Effect.Effect => { + const waitForResponse = config.browserApprovalStore?.waitForResponse; + if (!waitForResponse) return takeBrowserApprovalResponse(executionId); + + return waitForResponse(executionId).pipe( + Effect.timeoutOrElse({ + duration: Duration.millis(BROWSER_APPROVAL_WAIT_TIMEOUT_MS), + orElse: () => Effect.succeed(null), + }), + ); + }; + + const resumeAfterBrowserApproval = ( + executionId: string, + extra: RequestContext, + ): Effect.Effect => + Effect.gen(function* () { + yield* startMarker("mcp.host.tool.resume.browser_approval.start", { + "mcp.tool.name": "resume", + "mcp.execute.execution_id": executionId, + }); + const response = yield* waitForBrowserApprovalResponse(executionId); + if (!response) return yield* requireUserResumeApproval(executionId); + + const outcome = yield* resumeWithLifecycle(executionId, response); + if (!outcome) { + return missingExecutionResult(executionId); + } + if (outcome.status === "paused") { + const deadline = pauseDeadline(); + yield* Effect.annotateCurrentSpan({ + "mcp.execute.paused": true, + "mcp.execute.paused_execution_id": outcome.execution.id, + "mcp.execute.pause_source": "browser_resume", + }); + yield* onExecutionPaused(outcome.execution.id, deadline); + } + return outcome.status === "completed" + ? toMcpResult(outcome.result) + : yield* requireUserResumeApproval(outcome.execution.id); + }).pipe( + Effect.withSpan("mcp.host.tool.resume.browser_approval", { + attributes: { + "mcp.tool.name": "resume", + "mcp.execute.execution_id": executionId, + }, + }), + Effect.annotateSpans(joinKeyAttributes(extra)), + ); + + // --- tools --- + + yield* Effect.sync(() => + assembly.registerTool( + "execute", + { + description, + inputSchema: { code: z.string().trim().min(1) }, + }, + ({ code }, extra) => runToolEffect(executeCode(code, extra)), + ), + ).pipe( + Effect.withSpan("mcp.host.register_tool", { + attributes: { "mcp.tool.name": "execute" }, + }), + ); + + yield* Effect.sync(() => + assembly.registerTool( + "skills", + { + description: [ + "Fetch a named how-to skill. Skills hold the long-form guidance that would otherwise bloat another tool's always-loaded description.", + 'Call `skills({ name: "execute" })` for the full guide to writing code for the `execute` tool (search the catalog, call tools, emit results, resume paused runs).', + "Call with no name to list the available skills.", + ].join("\n"), + inputSchema: { + name: z + .string() + .optional() + .describe('The skill to fetch, e.g. "execute". Omit to list available skills.'), + }, + }, + ({ name }) => + runToolEffect(Effect.succeed(skillsResult(name, executeInventory, skillCatalog))), + ), + ).pipe( + Effect.withSpan("mcp.host.register_tool", { + attributes: { "mcp.tool.name": "skills" }, + }), + ); + + yield* Effect.sync(() => { + if (elicitationMode.mode === "native") { + return undefined; + } + + if (elicitationMode.mode === "model") { + return assembly.registerTool( + "resume", + { + description: [ + "Resume a paused execution using the executionId returned by execute.", + "This connection explicitly allows model-side resume via elicitation_mode=model.", + ].join("\n"), + inputSchema: { + executionId: z.string().describe("The execution ID from the paused result"), + action: z + .enum(["accept", "decline", "cancel"]) + .describe("How to respond to the interaction"), + content: z + .string() + .describe("Optional JSON-encoded response content for form elicitations") + .default("{}"), + }, + }, + ({ executionId, action, content: rawContent }, extra) => + runToolEffect( + resumeExecution(executionId, action, parseJsonContent(rawContent), extra), + ), + ); + } + + return assembly.registerTool( + "resume", + { + description: [ + "Request user approval to resume a paused execution.", + "Call this with the executionId returned by execute. If the user has not approved in the browser yet, tell them to open the returned approval URL. If they have approved, this returns the resumed execution result.", + "This connection does not allow the model to choose accept, decline, cancel, or content.", + ].join("\n"), + inputSchema: { + executionId: z.string().describe("The execution ID from the paused result"), + }, + }, + ({ executionId }, extra) => runToolEffect(resumeAfterBrowserApproval(executionId, extra)), + ); + }).pipe( + Effect.withSpan("mcp.host.register_tool", { + attributes: { "mcp.tool.name": "resume" }, + }), + ); + + // --- artifacts / MCP Apps --- + // + // These register unconditionally once a shell loader is configured. Whether + // the client can actually *render* an app is only known after `initialize`, + // so the app-only tools are toggled in `syncToolAvailability` below; the + // model-facing three stay enabled either way and fall back to a deep link. + + const artifacts = config.artifacts; + + // Set from the client's advertised capabilities at `initialize`. Read by + // the render handlers to choose inline widget vs. deep link. Seeded from + // the host's persisted value so a cold-restored session keeps rendering + // inline for a client that had already negotiated apps support. + // + // This is a cache, not the source of truth: `appsSupported()` below reads + // the live server on every render, because a cold restore re-establishes + // capabilities without ever running the hook that maintains this variable. + let appsEnabled = assembly.initialAppsEnabled; + let executeActionTool: { enable: () => void; disable: () => void } | undefined; + let executeActionResumeTool: { enable: () => void; disable: () => void } | undefined; + + /** + * Move the cached flag and the app-only tools together. + * + * `execute-action` is only callable from inside a rendered app, so a client + * that can't render one should never see it. `create-artifact`, + * `list-artifacts` and `show-artifact` stay visible regardless: they still + * persist, and still return something useful (a deep link). + */ + const applyAppsEnabled = (next: boolean): void => { + appsEnabled = next; + if (next) { + executeActionTool?.enable(); + executeActionResumeTool?.enable(); + } else { + executeActionTool?.disable(); + executeActionResumeTool?.disable(); + } + }; + + // Best-effort usage observation; a failing observer never affects the tool. + const notifyArtifactUsage = (action: "created" | "viewed" | "updated"): Effect.Effect => + config.onArtifactUsage + ? config.onArtifactUsage(action).pipe(Effect.ignoreCause({ log: false })) + : Effect.void; + + const saveAndDeliverArtifact = (input: { + readonly code: string; + readonly title: string; + readonly description?: string; + readonly existingId?: string; + readonly bindings?: Readonly>; + /** Sanitized layout markup from the smoke render, when it produced any. */ + readonly preview?: string | null; + }): Effect.Effect => + Effect.gen(function* () { + if (!artifacts) return artifactsUnavailableResult(); + const saved = yield* artifacts.save({ + ...(input.existingId === undefined ? {} : { id: input.existingId }), + title: input.title, + description: input.description ?? null, + code: input.code, + ...(input.bindings === undefined ? {} : { bindings: input.bindings }), + preview: input.preview ?? null, + }); + yield* notifyArtifactUsage(input.existingId === undefined ? "created" : "updated"); + // Resolve once and report the value actually used, so the span can + // never disagree with what the client received. + const delivered = deliverArtifact({ + code: saved.code, + artifactId: saved.id, + title: saved.title, + }); + yield* Effect.annotateCurrentSpan({ + "mcp.artifact.id": saved.id, + "mcp.artifact.apps_enabled": appsEnabled, + }); + return delivered; + }); + + /** + * Whether the client can render an app, resolved at render time. + * + * `appsEnabled` alone is not enough. On a cold restore the host replays the + * persisted `initialize` *request* — which does set the server's client + * capabilities — but never the `notifications/initialized` notification, + * and `oninitialized` (the only hook that re-runs `syncToolAvailability`) + * fires solely on that notification. So a restored session can hold full + * apps capabilities while `appsEnabled` still reads its seeded value, and + * the replay is dispatched un-awaited, so a tool call can land before it. + * + * Reading the live server here makes both orderings produce the same + * answer, and keeps the seeded value as the fallback for the window before + * any capabilities exist. + */ + const appsSupported = (): boolean => { + const live = assembly.getClientCapabilities(); + if (!live) return appsEnabled; + const uiCapability = assembly.getUiCapability(); + const supported = Boolean(uiCapability?.mimeTypes?.includes(RESOURCE_MIME_TYPE)); + // Reconcile the tools too: a restore that re-established capabilities + // without firing `oninitialized` would otherwise render inline while + // `execute-action` — the tool that rendered app calls back into — stayed + // hidden, leaving the widget unable to do anything. + if (supported !== appsEnabled) applyAppsEnabled(supported); + return supported; + }; + + const deliverArtifact = (input: { + readonly code: string; + readonly artifactId: string; + readonly title: string; + }): McpToolResult => { + const url = config.artifactUrl?.(input.artifactId); + if (appsSupported()) return renderedInAppResult({ ...input, url }); + return url + ? renderedAsLinkResult({ url, artifactId: input.artifactId, title: input.title }) + : renderedWithoutSurfaceResult({ artifactId: input.artifactId, title: input.title }); + }; + + /** + * The shared back half of `create-artifact` and `edit-artifact`: everything + * that happens once the full candidate source is in hand. Static checks, + * the smoke render, binding and the save are identical whether the code + * arrived whole or was assembled from stored source plus edits — sharing + * the pipeline is what guarantees an edit cannot save anything a create + * would have refused. + */ + const validateRenderAndSave = (input: { + readonly code: string; + readonly title: string; + readonly description?: string | undefined; + readonly connections?: Readonly> | undefined; + readonly existing: Artifact | null; + }): Effect.Effect => + Effect.gen(function* () { + const rejection = validateArtifactCode(input.code); + if (rejection) return renderRejectedResult(rejection); + + // Static checks first, then the real one: render it. See + // `smokeRenderRejection` for what the model is told. + // + // FAIL OPEN. The renderer is injected, runs on three different hosts, + // and is the newest thing in this path — if IT breaks (a missing + // module, an environment gap on some host), the right outcome is a + // saved artifact and a logged warning, never a refused create of code + // that is perfectly good. Only a definite `failed` blocks a save. + const smoke = config.smokeRenderArtifact; + // The render that validates the artifact is also the render that + // previews it: the same pass produces the loading-state markup the + // gallery draws, so a preview costs nothing beyond sanitizing it. + let preview: string | null = null; + if (smoke) { + const smokeResult: ArtifactSmokeRenderResult = yield* Effect.tryPromise(() => + smoke(input.code), + ).pipe( + Effect.catchCause((cause) => + Effect.as(Effect.logWarning("create-artifact smoke render was unavailable", cause), { + status: "ok", + } satisfies ArtifactSmokeRenderResult), + ), + ); + const renderRejection = smokeRenderRejection(smokeResult); + if (renderRejection) { + yield* Effect.annotateCurrentSpan({ "mcp.artifact.smoke_render": "failed" }); + return renderRejectedResult(renderRejection); + } + // Fail open, exactly as the verdict does: a preview that cannot be + // produced or cannot be sanitized is a card that falls back to its + // schematic, never a create that is refused. + preview = + smokeResult.status === "ok" && smokeResult.markup !== undefined + ? sanitizeArtifactPreviewMarkup(smokeResult.markup) + : null; + } + + const saveInput = { + code: input.code, + title: input.title, + preview, + ...(input.description === undefined ? {} : { description: input.description }), + ...(input.existing === null ? {} : { existingId: input.existing.id }), + }; + + const roles = extractArtifactRoles(input.code); + if (roles.length === 0 && input.connections === undefined) { + return yield* saveAndDeliverArtifact({ ...saveInput, bindings: {} }); + } + + if (!config.connections) { + return renderRejectedResult( + "This connection cannot bind integrations, so an artifact that calls one cannot be saved here.", + ); + } + + const available = yield* config.connections + .list() + .pipe(Effect.catchCause(() => Effect.succeed([] as readonly BindableConnection[]))); + const resolved = resolveArtifactBindings({ + roles, + connections: input.connections, + available, + }); + if (!resolved.ok) return renderRejectedResult(resolved.message); + + yield* Effect.annotateCurrentSpan({ + "mcp.artifact.role_count": roles.length, + }); + return yield* saveAndDeliverArtifact({ ...saveInput, bindings: resolved.bindings }); + }); + + /** + * Bind the integration roles an artifact's code uses, at create time. + * + * Binding happens HERE rather than at render time because this is the only + * moment the author, the code and their connections are all in hand — and + * because a create that can't bind is a create that would have saved a + * broken artifact. The model finds out now, with the candidate list, rather + * than the user finding out later through a query error inside the UI. + * + * `artifactId` turns the same call into an update in place — for a REWRITE, + * where the new source shares little with the old and edits would be longer + * than the code. A tweak belongs on `edit-artifact`, which patches the + * stored source instead of replacing it. Either way one row is kept: a copy + * per revision is the thing the model has to ask for, never the default. + * + * An update replaces the code outright — v1 keeps no version history — and + * re-extracts and re-resolves the bindings from the NEW source, because the + * roles the new code uses are not necessarily the ones the old code did. + * `title` and `description` are optional on an update and absent means keep + * what is stored, so a pure code tweak doesn't have to restate them. + */ + const createArtifact = (input: { + readonly code: string; + readonly title?: string; + readonly description?: string; + readonly connections?: Readonly>; + readonly artifactId?: string; + }): Effect.Effect => + Effect.gen(function* () { + // An update reads the existing row FIRST, both to carry its title and + // description forward and to refuse a foreign id before any work. The + // refusal is `artifact_unavailable` — the same answer `execute-action` + // gives — so create-artifact cannot be used to probe which ids exist. + const existing = + input.artifactId === undefined ? null : yield* loadArtifact(input.artifactId); + if (input.artifactId !== undefined && !existing) return actionArtifactUnavailableResult(); + + const title = input.title ?? existing?.title; + if (title === undefined) { + return renderRejectedResult( + "title is required when creating an artifact. Give it a short human-readable name.", + ); + } + // Only an update inherits; a create with no description stores none. + const description = input.description ?? existing?.description ?? undefined; + + return yield* validateRenderAndSave({ + code: input.code, + title, + description, + connections: input.connections, + existing, + }); + }).pipe( + Effect.withSpan("mcp.host.tool.create_artifact", { + attributes: { + "mcp.tool.name": "create-artifact", + "mcp.artifact.update": input.artifactId !== undefined, + "mcp.execute.code_length": input.code.length, + }, + }), + ); + + /** + * `edit-artifact`: the update path for tweaks, patching the stored source + * with exact find-and-replace edits so the call scales with the change + * rather than the component. The edited result runs the same + * validate → smoke-render → bind → save pipeline as a full create, so an + * edit cannot save anything a create would have refused. + * + * A failed edit hands the CURRENT source back in `structuredContent.code`. + * The model's usual recovery — `show-artifact`, re-read, retry — is a whole + * extra round trip to fetch a thing this call already loaded; giving it + * back here makes the retry immediate. + */ + const editArtifact = (input: { + readonly artifactId: string; + readonly edits: readonly ArtifactEdit[]; + readonly title?: string; + readonly description?: string; + readonly connections?: Readonly>; + }): Effect.Effect => + Effect.gen(function* () { + // Same probe-proof refusal as create-artifact's update arm. + const existing = yield* loadArtifact(input.artifactId); + if (!existing) return actionArtifactUnavailableResult(); + + const applied = applyArtifactEdits(existing.code, input.edits); + if (!applied.ok) return editRejectedResult(applied.message, existing.code); + + yield* Effect.annotateCurrentSpan({ + "mcp.artifact.edit_count": input.edits.length, + }); + return yield* validateRenderAndSave({ + code: applied.code, + title: input.title ?? existing.title, + description: input.description ?? existing.description ?? undefined, + connections: input.connections, + existing, + }); + }).pipe( + Effect.withSpan("mcp.host.tool.edit_artifact", { + attributes: { + "mcp.tool.name": "edit-artifact", + "mcp.artifact.id": input.artifactId, + }, + }), + ); + + const listArtifacts = (): Effect.Effect => + Effect.gen(function* () { + if (!artifacts) return artifactsUnavailableResult(); + return artifactListResult(yield* artifacts.list()); + }).pipe( + Effect.withSpan("mcp.host.tool.list_artifacts", { + attributes: { "mcp.tool.name": "list-artifacts" }, + }), + ); + + const showArtifact = (id: string): Effect.Effect => + Effect.gen(function* () { + if (!artifacts) return artifactsUnavailableResult(); + // A miss is the ordinary case (the model guessed an id, or the row was + // deleted), so it becomes an isError result rather than a defect. + const artifact: Artifact | null = yield* artifacts + .get(id) + .pipe(Effect.catchCause(() => Effect.succeed(null))); + if (!artifact) return artifactNotFoundResult(id); + yield* notifyArtifactUsage("viewed"); + return deliverArtifact({ + code: artifact.code, + artifactId: artifact.id, + title: artifact.title, + }); + }).pipe( + Effect.withSpan("mcp.host.tool.show_artifact", { + attributes: { "mcp.tool.name": "show-artifact", "mcp.artifact.id": id }, + }), + ); + + // Two independent reasons to serve no artifact surface: the host cannot + // (no shell loader), or this connection opted out (`?artifacts=false`). + // Either way nothing below registers, so a disabled session is byte-for-byte + // a session on a host that never had artifacts. + const loadAppShellHtml = artifactsEnabled ? config.loadAppShellHtml : undefined; + + if (loadAppShellHtml) { + yield* Effect.sync(() => { + assembly.registerAppResource( + "Executor Shell", + MCP_APPS_SHELL_RESOURCE_URI, + { mimeType: RESOURCE_MIME_TYPE }, + async () => ({ + contents: [ + { + uri: MCP_APPS_SHELL_RESOURCE_URI, + mimeType: RESOURCE_MIME_TYPE, + text: await loadAppShellHtml(), + // Zero allowed domains: the shell may open no network + // connection of its own. Every read and write goes back over + // the MCP bridge through `execute-action`. + _meta: { ui: { csp: { connectDomains: [], resourceDomains: [] } } }, + }, + ], + }), + ); + }).pipe( + Effect.withSpan("mcp.host.register_resource", { + attributes: { "mcp.resource.uri": MCP_APPS_SHELL_RESOURCE_URI }, + }), + ); + + yield* Effect.sync(() => + assembly.registerAppTool( + "create-artifact", + { + description: [ + "Render an interactive React UI component as an MCP app, and save it as a reusable artifact.", + 'Call `skills({ name: "create-artifact" })` for the full guide: the discovery-then-render protocol, TanStack Query rules, and every component already in scope. Call `skills({ name: "artifact-style" })` for how it must look — artifacts render inside the Executor console and must match its design system.', + "Write a component named `App` in `code`. Do not import anything and do not paste fetched data into JSX — read it live with `useQuery(tools...queryOptions(args))`.", + "Lay it out as an app, not a document: an artifact may be given the whole viewport, so make the root `flex h-full flex-col`, keep headers and filters as ordinary children, and give the one long table or list `flex-1 min-h-0 overflow-auto` — its header then stays put while the rows scroll under it.", + "Artifact code addresses an INTEGRATION, never a connection: write `tools.vercel.domains.getDomains`, not the full `tools.vercel.user.personalVercel.domains.getDomains` address `execute` uses for discovery. The connection is bound when the artifact is saved, so it stays portable. Code containing a `.user.` or `.org.` segment is rejected.", + 'To use two accounts of the same integration, tag each call site with a role — `tools.linear("prod").issues.list` and `tools.linear("staging").issues.list` — and map every role in `connections`.', + "All data access is declarative `tools.*`: `.queryOptions()` to read, `.infiniteQueryOptions()` to page through a cursor, `.mutationOptions()` to write. There is no `run()` and no arbitrary code — never hand-roll `useQuery({ queryKey, queryFn })`, or invalidation breaks.", + "To read every page of a paginated tool, call `useInfiniteQuery(tools...infiniteQueryOptions(args, { cursorKey, getNextPageParam }))` once and render `data.pages`. Never call hooks inside a loop — a `useQuery` per page is rejected.", + "To CHANGE an artifact that already exists, use `edit-artifact` — it patches the stored source with find-and-replace edits, so a tweak costs only the changed lines. Only use create-artifact with `artifactId` for a full rewrite, sending the complete new component. Never create a second artifact for a revision of an existing one.", + "Clients that cannot display MCP apps receive a link to the saved artifact instead; pass it to the user.", + ].join("\n"), + inputSchema: { + code: z.string().trim().min(1).describe("The React component source. Export `App`."), + artifactId: z + .string() + .trim() + .min(1) + .optional() + .describe( + "The artifact to REWRITE in place, from `list-artifacts` or a previous create. Omit to create a new one. `code` fully replaces the stored source and the connection bindings are re-resolved from it, so send the complete component, not a fragment. For a tweak, use `edit-artifact` instead.", + ), + connections: z + .record(z.string(), z.string()) + .optional() + .describe( + 'Which connection each integration role in `code` uses, as `..` (the address `connections.list` reports, minus the leading `tools.`). Keys are roles: the integration slug for an untagged `tools.linear.…`, or the tag for `tools.linear("prod").…`. Optional when you have exactly one connection per integration used — that one binds automatically. Required when you have several, and the error lists them.', + ), + title: z + .string() + .trim() + .min(1) + .optional() + .describe( + 'Short human-readable name for the artifact, e.g. "Active users dashboard". The user sees this and you match against it later. Required when creating; on an update, omit it to keep the current title.', + ), + description: z + .string() + .optional() + .describe( + "What this UI shows, in a sentence. Used to find the artifact again on a later request. On an update, omit it to keep the current description.", + ), + }, + _meta: { + ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["model"] }, + }, + }, + ({ code, title, description, connections, artifactId }) => + runToolEffect(createArtifact({ code, title, description, connections, artifactId })), + ), + ).pipe( + Effect.withSpan("mcp.host.register_tool", { + attributes: { "mcp.tool.name": "create-artifact" }, + }), + ); + + yield* Effect.sync(() => + assembly.registerAppTool( + "edit-artifact", + { + description: [ + "Change an existing artifact by patching its stored source with exact find-and-replace edits, and re-render it.", + "PREFER THIS over create-artifact for tweaks — a new column, a fixed label, a restyled section — because you send only the changed lines, not the whole component. Use create-artifact with `artifactId` only for a rewrite where most of the code changes.", + "Each edit's `oldText` must appear EXACTLY ONCE in the current source, verbatim (whitespace included); include enough surrounding lines to make it unique, or set `replaceAll: true` to change every occurrence. Edits apply in order, each seeing the previous one's result.", + "The batch is atomic: if any edit fails to match, nothing is saved and the error returns the current source in structuredContent.code — rebuild the edits from that instead of calling show-artifact again.", + "The edited component is validated and smoke-rendered exactly like a create, and connection bindings are re-resolved from the result; pass `connections` if an edit introduces an ambiguous integration.", + ].join("\n"), + inputSchema: { + artifactId: z + .string() + .trim() + .min(1) + .describe("The artifact to edit, from `list-artifacts` or a previous create."), + edits: z + .array( + z.object({ + oldText: z + .string() + .min(1) + .describe( + "Exact text to find in the current source, whitespace included. Must match exactly once unless replaceAll is true.", + ), + newText: z.string().describe("The replacement text."), + replaceAll: z + .boolean() + .optional() + .describe("Replace every occurrence instead of requiring a unique match."), + }), + ) + .min(1) + .describe("Find-and-replace edits, applied in order. All-or-nothing."), + connections: z + .record(z.string(), z.string()) + .optional() + .describe( + "Connection for each integration role the EDITED code uses, exactly as on create-artifact. Only needed when an edit introduces an integration with several connections.", + ), + title: z + .string() + .trim() + .min(1) + .optional() + .describe("New title. Omit to keep the current one."), + description: z + .string() + .optional() + .describe("New description. Omit to keep the current one."), + }, + _meta: { + ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["model"] }, + }, + }, + ({ artifactId, edits, connections, title, description }) => + runToolEffect(editArtifact({ artifactId, edits, connections, title, description })), + ), + ).pipe( + Effect.withSpan("mcp.host.register_tool", { + attributes: { "mcp.tool.name": "edit-artifact" }, + }), + ); + + yield* Effect.sync(() => + assembly.registerTool( + "list-artifacts", + { + description: [ + "List the saved UI artifacts for this account, newest first.", + "Match the user's phrasing against the returned titles and descriptions, then call `show-artifact` with that id.", + ].join("\n"), + inputSchema: {}, + }, + () => runToolEffect(listArtifacts()), + ), + ).pipe( + Effect.withSpan("mcp.host.register_tool", { + attributes: { "mcp.tool.name": "list-artifacts" }, + }), + ); + + yield* Effect.sync(() => + assembly.registerAppTool( + "show-artifact", + { + description: [ + "Re-render a saved UI artifact by id.", + "Use `list-artifacts` first to find the id whose title or description matches what the user asked for.", + "Clients that cannot display MCP apps receive a link to the artifact instead.", + ].join("\n"), + inputSchema: { + id: z.string().trim().min(1).describe("The artifact id from `list-artifacts`."), + }, + _meta: { + ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["model"] }, + }, + }, + ({ id }) => runToolEffect(showArtifact(id)), + ), + ).pipe( + Effect.withSpan("mcp.host.register_tool", { + attributes: { "mcp.tool.name": "show-artifact" }, + }), + ); + + yield* Effect.sync(() => { + executeActionTool = assembly.registerAppTool( + "execute-action", + { + description: + "Execute code from the UI shell. Used by interactive components to call tools and run mutations.", + inputSchema: { + code: z.string().trim().min(1), + artifactId: z + .string() + .trim() + .min(1) + .optional() + .describe( + "The artifact making the call. Its stored bindings resolve the integration role in `code` to a connection.", + ), + }, + _meta: { + ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["app"] }, + }, + }, + ({ code, artifactId }, extra) => + runToolEffect(executeCodeFromApp(code, artifactId, extra)), + ); + + executeActionResumeTool = assembly.registerAppTool( + "execute-action-resume", + { + description: "Resume an interactive UI action after shell-owned user approval.", + inputSchema: { + executionId: z.string().describe("The execution ID from the paused UI action"), + action: z + .enum(["accept", "decline", "cancel"]) + .describe("How to respond to the interaction"), + content: z + .string() + .describe("Optional JSON-encoded response content for form elicitations") + .default("{}"), + }, + _meta: { + ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["app"] }, + }, + }, + ({ executionId, action, content: rawContent }, extra) => + runToolEffect( + resumeExecution(executionId, action, parseJsonContent(rawContent), extra), + ), + ); + }).pipe( + Effect.withSpan("mcp.host.register_tool", { + attributes: { "mcp.tool.name": "execute-action" }, + }), + ); + } + + // Client capabilities only exist after `initialize`, and `tools/list` is + // answered from whatever is registered at that moment — so app-only tool + // visibility has to be re-synced from the `oninitialized` hook rather than + // decided at construction. + // + // This hook covers live clients only. It does NOT run on a cold restore: + // the host replays the persisted `initialize` request, but `oninitialized` + // fires on the `notifications/initialized` notification, which is never + // persisted. `appsSupported()` is what makes the restored case correct. + const syncToolAvailability = () => { + const clientCapabilities = assembly.getClientCapabilities(); + const uiCapability = assembly.getUiCapability(); + // Absent capabilities (the SDK returns `undefined`) mean `initialize` + // hasn't happened on THIS server instance — the construction-time call + // below, or a cold restore that resumed mid-conversation. Neither is + // evidence the client lost apps support, so the restored value stands + // until a real `initialize` replaces it. Reading `false` off an absent + // value here is exactly what made a cold-restored session fall back to + // deep links. + const negotiated = clientCapabilities + ? Boolean(uiCapability?.mimeTypes?.includes(RESOURCE_MIME_TYPE)) + : appsEnabled; + const changed = negotiated !== appsEnabled; + applyAppsEnabled(negotiated); + + // Persist only a real negotiation that moved the value, so the next cold + // restore seeds itself. Best-effort: the session must not fail on it. + // The `clientCapabilities` guard matters beyond skipping a no-op write: + // persisting an absent-capability reading would make a downgrade durable + // for every future restore of the session. + const onAppsEnabledChange = config.onAppsEnabledChange; + if (clientCapabilities && changed && onAppsEnabledChange) { + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: `oninitialized` is a sync SDK hook; persistence is fire-and-forget and its failure must not fail the session + void Effect.runPromiseWith(context)( + onAppsEnabledChange(negotiated).pipe(Effect.ignoreCause({ log: false })), + ); + } + + debugLog("tool.visibility", { + clientCapabilities: clientCapabilities ?? null, + elicitationSupport: assembly.getElicitationSupport(), + elicitationMode: elicitationMode.mode, + resumeEnabled: elicitationMode.mode !== "native", + appsSupport: uiCapability ?? null, + appsEnabled, + executeActionEnabled: appsEnabled, + }); + }; + + yield* Effect.sync(() => { + syncToolAvailability(); + assembly.onInitialized(syncToolAvailability); + }).pipe(Effect.withSpan("mcp.host.sync_tool_availability")); + + return server; + }).pipe(Effect.withSpan("mcp.host.create_executor_server")); diff --git a/packages/hosts/mcp/src/tool-server-protocol.test.ts b/packages/hosts/mcp/src/tool-server-protocol.test.ts new file mode 100644 index 0000000000..d3f536d031 --- /dev/null +++ b/packages/hosts/mcp/src/tool-server-protocol.test.ts @@ -0,0 +1,388 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + Client, + StreamableHTTPClientTransport, + withInputRequired, + type Request as McpRequest, +} from "@modelcontextprotocol/client"; +import { CallToolResultSchema } from "@modelcontextprotocol/core"; +import { + createMcpHandler, + isInputRequiredResult, + type InputRequiredResult, +} from "@modelcontextprotocol/server"; +import { Effect } from "effect"; + +import type { ExecutionEngine, ExecutionResult, ResumeResponse } from "@executor-js/execution"; +import { defaultMcpResource, type McpResource } from "./seams"; +import { FormElicitation, ToolAddress } from "@executor-js/sdk"; + +import { + appsEnabledForClientCapabilities, + buildMcpServer, + mcpRequestStateBindingFromBody, +} from "./tool-server"; +import { RESOURCE_MIME_TYPE, RESOURCE_URI_META_KEY } from "./mcp-apps"; + +const REQUEST_STATE_KEY = new Uint8Array(32).fill(7); +const TOOL_ADDRESS = ToolAddress.make("tools.test.org.main.echo"); +const APP_URI = "ui://executor/shell.html"; + +type TestServerConfig = { + readonly engine: ExecutionEngine; + readonly appsEnabled: boolean; + readonly elicitationMode?: { readonly mode: "model" } | { readonly mode: "native" }; + readonly loadAppShellHtml?: () => Promise; + /** Evaluated per request, so a test can swap principals between rounds. */ + readonly requestStatePrincipal?: () => string; + /** Evaluated per request, so a test can swap resources between rounds. */ + readonly requestStateResource?: () => McpResource; +}; + +const makeStubEngine = ( + overrides: { + readonly executeWithPause?: ExecutionEngine["executeWithPause"]; + readonly resume?: ExecutionEngine["resume"]; + readonly getPausedExecution?: ExecutionEngine["getPausedExecution"]; + } = {}, +): ExecutionEngine => ({ + execute: (code) => Effect.succeed({ result: `ran: ${code}` }), + executeWithPause: + overrides.executeWithPause ?? + ((code) => Effect.succeed({ status: "completed", result: { result: `ran: ${code}` } })), + resume: overrides.resume ?? (() => Effect.succeed(null)), + isExecutionSettled: () => Effect.succeed(false), + getPausedExecution: overrides.getPausedExecution ?? (() => Effect.succeed(null)), + pausedExecutionCount: () => Effect.succeed(0), + hasPausedExecutions: () => Effect.succeed(false), + getDescription: Effect.succeed("test executor"), +}); + +const withClient = async ( + config: TestServerConfig, + run: (client: Client) => Promise, + options?: { readonly manualInputRequired?: boolean }, +) => { + const requestBodies = new WeakMap(); + const handler = createMcpHandler( + (context) => + Effect.runPromise( + Effect.gen(function* () { + const requestStatePrincipal = config.requestStatePrincipal?.() ?? "principal-test"; + const requestStateResource = config.requestStateResource?.() ?? defaultMcpResource; + const requestStateBinding = yield* Effect.promise(() => + mcpRequestStateBindingFromBody({ + body: context.requestInfo ? requestBodies.get(context.requestInfo) : undefined, + principal: requestStatePrincipal, + resource: requestStateResource, + }), + ); + return yield* buildMcpServer({ + ...config, + requestStateSigningKey: REQUEST_STATE_KEY, + requestStatePrincipal, + ...(requestStateBinding === null ? {} : { requestStateBinding }), + }); + }), + ), + { legacy: "reject" }, + ); + const transport = new StreamableHTTPClientTransport(new URL("http://executor.test/mcp"), { + fetch: async (input, init) => { + const request = + input instanceof Request ? new Request(input, init) : new Request(input.toString(), init); + requestBodies.set(request, await request.clone().json()); + return handler.fetch(request, { parsedBody: requestBodies.get(request) }); + }, + }); + const client = new Client( + { name: "executor-protocol-test", version: "1.0.0" }, + { + capabilities: { elicitation: { form: {} } }, + versionNegotiation: { mode: { pin: "2026-07-28" } }, + ...(options?.manualInputRequired ? { inputRequired: { autoFulfill: false } } : {}), + }, + ); + await client.connect(transport); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: test helper owns the client transport and in-process HTTP handler + try { + await run(client); + } finally { + await client.close(); + await handler.close(); + } +}; + +const manualToolCall = ( + client: Client, + params: Record, +): Promise>> => { + const request: McpRequest = { method: "tools/call", params }; + return client.request(request, withInputRequired(CallToolResultSchema), { + allowInputRequired: true, + }); +}; + +describe("Executor MCP protocol assembly", () => { + it("lists Executor tools and executes code end to end over the modern HTTP entry", async () => { + await withClient({ engine: makeStubEngine(), appsEnabled: false }, async (client) => { + const names = (await client.listTools()).tools.map(({ name }) => name); + expect(names).toContain("execute"); + expect(names).toContain("skills"); + expect(names).toContain("resume"); + + const result = await client.callTool({ + name: "execute", + arguments: { code: "1 + 1" }, + }); + expect(result.content).toEqual([{ type: "text", text: "ran: 1 + 1" }]); + expect(result.isError).toBeFalsy(); + }); + }); + + it("registers app metadata and app-only tools only for apps-enabled requests", async () => { + const inspect = async (appsEnabled: boolean) => { + let observed: + | { + readonly names: readonly string[]; + readonly createMeta: Record | undefined; + readonly resourceCount: number; + } + | undefined; + await withClient( + { + engine: makeStubEngine(), + appsEnabled, + loadAppShellHtml: async () => "", + }, + async (client) => { + const tools = (await client.listTools()).tools; + observed = { + names: tools.map(({ name }) => name), + createMeta: tools.find(({ name }) => name === "create-artifact")?._meta, + resourceCount: (await client.listResources()).resources.length, + }; + }, + ); + return observed; + }; + + const enabled = await inspect(true); + expect(enabled?.names).toContain("execute-action"); + expect(enabled?.createMeta).toMatchObject({ + ui: { resourceUri: APP_URI, visibility: ["model"] }, + [RESOURCE_URI_META_KEY]: APP_URI, + }); + expect(enabled?.resourceCount).toBe(1); + + const disabled = await inspect(false); + expect(disabled?.names).not.toContain("execute-action"); + expect(disabled?.createMeta).toBeUndefined(); + expect(disabled?.resourceCount).toBe(0); + }); + + it("returns input_required and resumes native elicitation from signed requestState", async () => { + const request = FormElicitation.make({ + message: "Which value?", + requestedSchema: { + type: "object", + properties: { value: { type: "string" } }, + required: ["value"], + }, + }); + const paused: Extract = { + status: "paused", + execution: { + id: "execution-1", + elicitationContext: { address: TOOL_ADDRESS, args: {}, request }, + }, + }; + let resumedWith: ResumeResponse | undefined; + const engine = makeStubEngine({ + executeWithPause: () => Effect.succeed(paused), + getPausedExecution: () => Effect.succeed(paused.execution), + resume: (_executionId, response) => { + resumedWith = response; + return Effect.succeed({ + status: "completed", + result: { result: response.content?.value }, + }); + }, + }); + + await withClient( + { engine, appsEnabled: false, elicitationMode: { mode: "native" } }, + async (client) => { + const first = await manualToolCall(client, { + name: "execute", + arguments: { code: "await tools.test.echo()" }, + }); + expect(isInputRequiredResult(first)).toBe(true); + if (!isInputRequiredResult(first)) return; + expect(first.inputRequests?.elicitation).toMatchObject({ + method: "elicitation/create", + params: { message: "Which value?" }, + }); + expect(typeof first.requestState).toBe("string"); + + const completed = await manualToolCall(client, { + name: "execute", + arguments: { code: "await tools.test.echo()" }, + inputResponses: { + elicitation: { action: "accept", content: { value: "approved" } }, + }, + requestState: first.requestState, + }); + expect(isInputRequiredResult(completed)).toBe(false); + expect(completed.content).toEqual([{ type: "text", text: "approved" }]); + expect(resumedWith).toEqual({ action: "accept", content: { value: "approved" } }); + }, + { manualInputRequired: true }, + ); + }); + + it("rejects a tampered native-elicitation requestState before resuming", async () => { + const request = FormElicitation.make({ + message: "Confirm", + requestedSchema: {}, + }); + const paused: Extract = { + status: "paused", + execution: { + id: "execution-2", + elicitationContext: { address: TOOL_ADDRESS, args: {}, request }, + }, + }; + let resumeCalls = 0; + const engine = makeStubEngine({ + executeWithPause: () => Effect.succeed(paused), + getPausedExecution: () => Effect.succeed(paused.execution), + resume: () => { + resumeCalls += 1; + return Effect.succeed(null); + }, + }); + + await withClient( + { engine, appsEnabled: false, elicitationMode: { mode: "native" } }, + async (client) => { + const first = await manualToolCall(client, { + name: "execute", + arguments: { code: "await tools.test.echo()" }, + }); + expect(isInputRequiredResult(first)).toBe(true); + if (!isInputRequiredResult(first) || !first.requestState) return; + + // Corrupt an interior character: changing the final one can only touch + // discarded base64url padding bits, which lenient decoders (Bun) drop — + // the decoded bytes would be identical and the signature would verify. + const middle = Math.floor(first.requestState.length / 2); + const swapped = first.requestState[middle] === "A" ? "B" : "A"; + const tampered = `${first.requestState.slice(0, middle)}${swapped}${first.requestState.slice(middle + 1)}`; + await expect( + manualToolCall(client, { + name: "execute", + arguments: { code: "await tools.test.echo()" }, + inputResponses: { elicitation: { action: "accept", content: {} } }, + requestState: tampered, + }), + ).rejects.toMatchObject({ code: -32602 }); + expect(resumeCalls).toBe(0); + }, + { manualInputRequired: true }, + ); + }); + + it("rejects a requestState echoed with a different principal, resource, or code", async () => { + const request = FormElicitation.make({ + message: "Confirm", + requestedSchema: {}, + }); + const paused: Extract = { + status: "paused", + execution: { + id: "execution-3", + elicitationContext: { address: TOOL_ADDRESS, args: {}, request }, + }, + }; + let resumeCalls = 0; + const engine = makeStubEngine({ + executeWithPause: () => Effect.succeed(paused), + getPausedExecution: () => Effect.succeed(paused.execution), + resume: () => { + resumeCalls += 1; + return Effect.succeed(null); + }, + }); + + let principal = "user-a"; + let resource: McpResource = defaultMcpResource; + await withClient( + { + engine, + appsEnabled: false, + elicitationMode: { mode: "native" }, + requestStatePrincipal: () => principal, + requestStateResource: () => resource, + }, + async (client) => { + const first = await manualToolCall(client, { + name: "execute", + arguments: { code: "await tools.test.echo()" }, + }); + expect(isInputRequiredResult(first)).toBe(true); + if (!isInputRequiredResult(first) || !first.requestState) return; + + principal = "user-b"; + await expect( + manualToolCall(client, { + name: "execute", + arguments: { code: "await tools.test.echo()" }, + inputResponses: { elicitation: { action: "accept", content: {} } }, + requestState: first.requestState, + }), + ).rejects.toMatchObject({ code: -32602 }); + + principal = "user-a"; + resource = { kind: "toolkit", slug: "other" }; + await expect( + manualToolCall(client, { + name: "execute", + arguments: { code: "await tools.test.echo()" }, + inputResponses: { elicitation: { action: "accept", content: {} } }, + requestState: first.requestState, + }), + ).rejects.toMatchObject({ code: -32602 }); + + resource = defaultMcpResource; + await expect( + manualToolCall(client, { + name: "execute", + arguments: { code: "await tools.test.different()" }, + inputResponses: { elicitation: { action: "accept", content: {} } }, + requestState: first.requestState, + }), + ).rejects.toMatchObject({ code: -32602 }); + expect(resumeCalls).toBe(0); + }, + { manualInputRequired: true }, + ); + }); + + it("derives request-scoped app support from the exact MCP Apps MIME capability", () => { + expect( + appsEnabledForClientCapabilities({ + extensions: { + "io.modelcontextprotocol/ui": { mimeTypes: [RESOURCE_MIME_TYPE] }, + }, + }), + ).toBe(true); + expect( + appsEnabledForClientCapabilities({ + extensions: { + "io.modelcontextprotocol/ui": { mimeTypes: ["text/html"] }, + }, + }), + ).toBe(false); + }); +}); diff --git a/packages/hosts/mcp/src/tool-server.test.ts b/packages/hosts/mcp/src/tool-server.test.ts index 80118f37a3..2a0febebd1 100644 --- a/packages/hosts/mcp/src/tool-server.test.ts +++ b/packages/hosts/mcp/src/tool-server.test.ts @@ -1,10 +1,8 @@ import { describe, expect, it } from "@effect/vitest"; import { Data, Deferred, Effect } from "effect"; import type * as Tracer from "effect/Tracer"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; -import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; -import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js"; +import { Client } from "@modelcontextprotocol/client"; +import { InMemoryTransport, type ClientCapabilities } from "@modelcontextprotocol/server"; import type * as Cause from "effect/Cause"; import { @@ -18,7 +16,7 @@ import type { ToolFileValue } from "@executor-js/sdk"; import type { ExecutionEngine, ExecutionResult } from "@executor-js/execution"; import { - createExecutorMcpServer, + buildMcpServer, formatMcpExecutionOutcome, type ExecutorMcpServerConfig, } from "./tool-server"; @@ -45,18 +43,25 @@ const makeStubEngine = (overrides: { resume?: ExecutionEngine["resume"]; isExecutionSettled?: ExecutionEngine["isExecutionSettled"]; description?: string; -}): ExecutionEngine => ({ - execute: overrides.execute ?? (() => Effect.succeed({ result: "default" })), - executeWithPause: - overrides.executeWithPause ?? - (() => Effect.succeed({ status: "completed", result: { result: "default" } })), - resume: overrides.resume ?? (() => Effect.succeed(null)), - isExecutionSettled: overrides.isExecutionSettled, - getPausedExecution: () => Effect.succeed(null), - pausedExecutionCount: () => Effect.succeed(0), - hasPausedExecutions: () => Effect.succeed(false), - getDescription: Effect.succeed(overrides.description ?? "test executor"), -}); +}): ExecutionEngine => { + const execute: ExecutionEngine["execute"] = + overrides.execute ?? (() => Effect.succeed({ result: "default" })); + return { + execute, + executeWithPause: + overrides.executeWithPause ?? + ((code) => + execute(code, { + onElicitation: () => Effect.die("Unexpected elicitation in completed execution test"), + }).pipe(Effect.map((result) => ({ status: "completed" as const, result })))), + resume: overrides.resume ?? (() => Effect.succeed(null)), + isExecutionSettled: overrides.isExecutionSettled, + getPausedExecution: () => Effect.succeed(null), + pausedExecutionCount: () => Effect.succeed(0), + hasPausedExecutions: () => Effect.succeed(false), + getDescription: Effect.succeed(overrides.description ?? "test executor"), + }; +}; type TestServerConfig = Pick< ExecutorMcpServerConfig, @@ -76,7 +81,14 @@ const withClient = async ( config?: TestServerConfig & { readonly tracer?: Tracer.Tracer }, ) => { const { tracer, ...serverConfig } = config ?? {}; - const create = createExecutorMcpServer({ engine, ...serverConfig }); + const create = buildMcpServer({ + engine, + appsEnabled: false, + requestStateSigningKey: new Uint8Array(32).fill(13), + requestStatePrincipal: "tool-server-test-principal", + sessionful: true, + ...serverConfig, + }); const mcpServer = await Effect.runPromise(tracer ? Effect.withTracer(create, tracer) : create); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities }); @@ -154,7 +166,6 @@ const withTracedClient = async ( const ELICITATION_CAPS: ClientCapabilities = { elicitation: { form: {}, url: {} }, }; -const FORM_ONLY_CAPS: ClientCapabilities = { elicitation: { form: {} } }; const NO_CAPS: ClientCapabilities = {}; /** Extract the first text content from a callTool result. */ @@ -192,33 +203,15 @@ const toolFile = (input: { byteLength: input.byteLength, }); -/** Build an engine whose execute triggers one elicitation and returns the handler's result. */ -const makeElicitingEngine = ( - request: FormElicitation | UrlElicitation, - formatResult: (response: { action: string; content?: Record }) => unknown = ( - r, - ) => r.action, -): ExecutionEngine => - makeStubEngine({ - execute: (_code, { onElicitation }) => - Effect.gen(function* () { - const response = yield* onElicitation({ - address: STUB_TOOL_ADDRESS, - args: {}, - request, - }); - return { result: formatResult(response) }; - }), - }); - // --------------------------------------------------------------------------- // Explicit native elicitation mode // --------------------------------------------------------------------------- describe("MCP host server — native elicitation mode", () => { - it("execute tool calls engine.execute and returns result", async () => { + it("execute tool calls engine.executeWithPause and returns result", async () => { const engine = makeStubEngine({ - execute: (code) => Effect.succeed({ result: `ran: ${code}` }), + executeWithPause: (code) => + Effect.succeed({ status: "completed", result: { result: `ran: ${code}` } }), }); await withNativeClient(engine, ELICITATION_CAPS, async (client) => { @@ -769,52 +762,6 @@ describe("MCP host server — native elicitation mode", () => { }); }); - it("form elicitation is bridged from engine to MCP client and back", async () => { - const engine = makeElicitingEngine( - FormElicitation.make({ - message: "Approve this action?", - requestedSchema: { - type: "object", - properties: { approved: { type: "boolean" } }, - }, - }), - (r) => (r.action === "accept" && r.content?.approved ? "approved" : "denied"), - ); - - await withNativeClient(engine, ELICITATION_CAPS, async (client) => { - client.setRequestHandler(ElicitRequestSchema, async () => ({ - action: "accept" as const, - content: { approved: true }, - })); - - const result = await client.callTool({ - name: "execute", - arguments: { code: "do-it" }, - }); - expect(result.content).toEqual([{ type: "text", text: "approved" }]); - }); - }); - - it("form elicitation declined by client → engine sees decline", async () => { - const engine = makeElicitingEngine( - FormElicitation.make({ message: "Accept?", requestedSchema: {} }), - (r) => `action:${r.action}`, - ); - - await withNativeClient(engine, ELICITATION_CAPS, async (client) => { - client.setRequestHandler(ElicitRequestSchema, async () => ({ - action: "decline" as const, - content: {}, - })); - - const result = await client.callTool({ - name: "execute", - arguments: { code: "x" }, - }); - expect(result.content).toEqual([{ type: "text", text: "action:decline" }]); - }); - }); - it("browser approval mode does not auto-switch to native elicitation", async () => { let approvalUrlCalled = false; let executeCalled = false; @@ -837,11 +784,6 @@ describe("MCP host server — native elicitation mode", () => { engine, ELICITATION_CAPS, async (client) => { - client.setRequestHandler(ElicitRequestSchema, async () => ({ - action: "accept" as const, - content: {}, - })); - const { tools } = await client.listTools(); expect(tools.map((t) => t.name)).toContain("resume"); @@ -870,56 +812,6 @@ describe("MCP host server — native elicitation mode", () => { ); }); - it("empty form schema gets wrapped with minimal valid schema", async () => { - let receivedSchema: unknown; - const engine = makeElicitingEngine( - FormElicitation.make({ message: "Just approve", requestedSchema: {} }), - ); - - await withNativeClient(engine, ELICITATION_CAPS, async (client) => { - client.setRequestHandler(ElicitRequestSchema, async (request) => { - const params = request.params; - if ("requestedSchema" in params) { - receivedSchema = params.requestedSchema; - } - return { action: "accept" as const, content: {} }; - }); - - await client.callTool({ - name: "execute", - arguments: { code: "approve" }, - }); - expect(receivedSchema).toEqual({ type: "object", properties: {} }); - }); - }); - - it("UrlElicitation is sent as native mode:url elicitation", async () => { - let receivedParams: Record | undefined; - const engine = makeElicitingEngine( - UrlElicitation.make({ - message: "Please authenticate", - url: "https://example.com/oauth", - elicitationId: ElicitationId.make("elic-1"), - }), - ); - - await withNativeClient(engine, ELICITATION_CAPS, async (client) => { - client.setRequestHandler(ElicitRequestSchema, async (request) => { - receivedParams = request.params as Record; - return { action: "accept" as const, content: {} }; - }); - - await client.callTool({ - name: "execute", - arguments: { code: "oauth" }, - }); - expect(receivedParams?.mode).toBe("url"); - expect(receivedParams?.message).toBe("Please authenticate"); - expect(receivedParams?.url).toBe("https://example.com/oauth"); - expect(receivedParams?.elicitationId).toBe("elic-1"); - }); - }); - it("engine error is surfaced as isError result", async () => { const engine = makeStubEngine({ execute: () => @@ -977,61 +869,6 @@ describe("MCP host server — native elicitation mode", () => { }); }); -// --------------------------------------------------------------------------- -// Client with form-only elicitation in native mode -// --------------------------------------------------------------------------- - -describe("MCP host server — native form-only elicitation", () => { - it("resume tool is hidden in native mode", async () => { - await withNativeClient(makeStubEngine({}), FORM_ONLY_CAPS, async (client) => { - const { tools } = await client.listTools(); - expect(tools.map((t) => t.name)).toContain("execute"); - expect(tools.map((t) => t.name)).not.toContain("resume"); - }); - }); - - it("uses native elicitation path when client supports form", async () => { - const engine = makeStubEngine({ - execute: (code) => Effect.succeed({ result: `native: ${code}` }), - }); - - await withNativeClient(engine, FORM_ONLY_CAPS, async (client) => { - const result = await client.callTool({ - name: "execute", - arguments: { code: "test" }, - }); - expect(result.content).toEqual([{ type: "text", text: "native: test" }]); - }); - }); - - it("UrlElicitation falls back to form when client lacks url support", async () => { - let receivedMessage: string | undefined; - const engine = makeElicitingEngine( - UrlElicitation.make({ - message: "Please authenticate", - url: "https://auth.example.com/oauth", - elicitationId: ElicitationId.make("elic-1"), - }), - ); - - await withNativeClient(engine, FORM_ONLY_CAPS, async (client) => { - client.setRequestHandler(ElicitRequestSchema, async (request) => { - receivedMessage = - typeof request.params.message === "string" ? request.params.message : undefined; - return { action: "accept" as const, content: {} }; - }); - - const result = await client.callTool({ - name: "execute", - arguments: { code: "oauth" }, - }); - expect(result.content).toEqual([{ type: "text", text: "accept" }]); - expect(receivedMessage).toContain("https://auth.example.com/oauth"); - expect(receivedMessage).toContain("Please authenticate"); - }); - }); -}); - // --------------------------------------------------------------------------- // Client WITHOUT elicitation (pause/resume path) // --------------------------------------------------------------------------- @@ -1639,46 +1476,6 @@ describe("MCP host server — client without elicitation (pause/resume)", () => }); }); -// --------------------------------------------------------------------------- -// Elicitation error handling -// --------------------------------------------------------------------------- - -describe("MCP host server — elicitation error handling", () => { - it("elicitInput failure is not reported as user cancellation", async () => { - const engine = makeElicitingEngine( - FormElicitation.make({ - message: "will fail", - requestedSchema: { - type: "object", - properties: { x: { type: "string" } }, - }, - }), - (r) => `fallback:${r.action}`, - ); - - await withNativeClient(engine, ELICITATION_CAPS, async (client) => { - client.setRequestHandler(ElicitRequestSchema, async () => { - // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: MCP client request handler rejects to exercise server fallback - throw new Error("client cannot handle this"); - }); - - const result = await client.callTool({ - name: "execute", - arguments: { code: "fail" }, - }); - expect(result.isError).toBe(true); - expect(textOf(result)).toMatch( - /^Error: Native elicitation transport failed \[[0-9a-f]{8}\]\. Reconnect the MCP client and try again\.$/, - ); - expect(result.structuredContent).toMatchObject({ - status: "error", - errorCode: "native_elicitation_transport_failed", - }); - expect(textOf(result)).not.toContain("fallback:cancel"); - }); - }); -}); - // --------------------------------------------------------------------------- // Resume content parsing edge cases // --------------------------------------------------------------------------- @@ -1736,65 +1533,6 @@ describe("MCP host server — resume content parsing", () => { }); }); -// --------------------------------------------------------------------------- -// Multiple elicitations in a single execution -// --------------------------------------------------------------------------- - -describe("MCP host server — multiple elicitations", () => { - it("engine can elicit multiple times during a single execute call", async () => { - const engine = makeStubEngine({ - execute: (_code, { onElicitation }) => - Effect.gen(function* () { - const r1 = yield* onElicitation({ - address: STUB_TOOL_ADDRESS, - args: {}, - request: FormElicitation.make({ - message: "What is your name?", - requestedSchema: { - type: "object", - properties: { name: { type: "string" } }, - }, - }), - }); - - const r2 = yield* onElicitation({ - address: STUB_TOOL_ADDRESS, - args: {}, - request: FormElicitation.make({ - message: `Confirm: ${r1.content?.name}?`, - requestedSchema: { - type: "object", - properties: { confirmed: { type: "boolean" } }, - }, - }), - }); - - return { - result: `name=${r1.content?.name},confirmed=${r2.content?.confirmed}`, - }; - }), - }); - - await withNativeClient(engine, ELICITATION_CAPS, async (client) => { - let callCount = 0; - client.setRequestHandler(ElicitRequestSchema, async () => { - callCount++; - if (callCount === 1) { - return { action: "accept" as const, content: { name: "Alice" } }; - } - return { action: "accept" as const, content: { confirmed: true } }; - }); - - const result = await client.callTool({ - name: "execute", - arguments: { code: "multi" }, - }); - expect(result.content).toEqual([{ type: "text", text: "name=Alice,confirmed=true" }]); - expect(callCount).toBe(2); - }); - }); -}); - // --------------------------------------------------------------------------- // skills tool // --------------------------------------------------------------------------- diff --git a/packages/hosts/mcp/src/tool-server.ts b/packages/hosts/mcp/src/tool-server.ts index 41c61bcd0a..3458f27797 100644 --- a/packages/hosts/mcp/src/tool-server.ts +++ b/packages/hosts/mcp/src/tool-server.ts @@ -1,2259 +1,555 @@ -import { Data, Duration, Effect, Match, Option, Predicate, Result, Schema } from "effect"; +/** + * MCP server assembly shared by stateless modern requests and sessionful + * connections. Stateless callers supply request-scoped capability policy; + * sessionful callers register the full surface once and read negotiated client + * capabilities from the live server. + */ +import { Data, Effect, Match, Option, Schema } from "effect"; import * as Cause from "effect/Cause"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { - ContentBlockSchema, - type ClientCapabilities, - type ContentBlock, -} from "@modelcontextprotocol/sdk/types.js"; + acceptedContent, + CLIENT_CAPABILITIES_META_KEY, + createRequestStateCodec, + fromJsonSchema, + inputRequired, + inputResponse, + McpServer, + type CallToolResult, + type InputRequiredResult, + type ServerContext, +} from "@modelcontextprotocol/server"; +import * as z from "zod/v4"; + +import type { ElicitationRequest } from "@executor-js/sdk"; + +import { mcpResourceKey, type McpResource } from "./seams"; import { getUiCapability, + EXTENSION_ID, registerAppResource, registerAppTool, RESOURCE_MIME_TYPE, -} from "@modelcontextprotocol/ext-apps/server"; -import type { - jsonSchemaValidator, - JsonSchemaType, - JsonSchemaValidator, -} from "@modelcontextprotocol/sdk/validation/types.js"; -import { Validator } from "@cfworker/json-schema"; -import * as z from "zod/v4"; - -import { isToolFile, sanitizeArtifactPreviewMarkup } from "@executor-js/sdk"; -import type { - Artifact, - ArtifactBinding, - ArtifactSummary, - ElicitationResponse, - ElicitationHandler, - ElicitationContext, - ElicitationRequest, - SaveArtifactInput, - ToolFileValue, -} from "@executor-js/sdk"; -import type * as Tracer from "effect/Tracer"; + RESOURCE_URI_META_KEY, + type McpAppsClientCapabilities, + type McpAppToolMeta, +} from "./mcp-apps"; import { - createExecutionEngine, - formatExecuteResult, - formatPausedExecution, - formatTtlDuration, - findSkill, - renderSkillsIndex, - skillCatalogFor, - EXECUTE_SKILL, - INTEGRATION_INVENTORY_HEADER, - type Skill, - type ExecutionEngine, - type ExecutionEngineConfig, - type ResumeResponse, - type ExecutionResult, - type PausedExecution, - type PausedExecutionDeadline, -} from "@executor-js/execution"; -import { - MCP_APPS_SHELL_RESOURCE_URI, - applyArtifactEdits, - smokeRenderRejection, - validateArtifactCode, - type ArtifactEdit, - type ArtifactSmokeRenderResult, -} from "./create-artifact"; -import { TOOL_CALL_CONTRACT_MESSAGE } from "./tool-call-code"; -import { resolveArtifactAction } from "./artifact-action"; -import { - extractArtifactRoles, - resolveArtifactBindings, - type BindableConnection, -} from "./artifact-bindings"; - -// --------------------------------------------------------------------------- -// Workers-compatible JSON Schema validator (replaces Ajv which uses new Function()) -// --------------------------------------------------------------------------- - -class CfWorkerJsonSchemaValidator implements jsonSchemaValidator { - getValidator(schema: JsonSchemaType): JsonSchemaValidator { - const validator = new Validator(schema as Record, "2020-12", false); - return (input: unknown) => { - const result = validator.validate(input); - if (result.valid) { - return { valid: true, data: input as T, errorMessage: undefined }; - } - const errorMessage = result.errors.map((e) => `${e.instanceLocation}: ${e.error}`).join("; "); - return { valid: false, data: undefined, errorMessage }; - }; - } -} - -// --------------------------------------------------------------------------- -// Config -// --------------------------------------------------------------------------- - -type SharedMcpServerConfig = { - /** - * Pre-built `execute` tool description. When provided, the factory skips - * its internal `engine.getDescription` yield. Useful when the caller - * wants to compute the description inside its own Effect tracer context - * so sub-spans (`executor.integrations.list`, `executor.tools.list`) nest as - * children of the caller's root span. - */ - readonly description?: string; - /** - * Parent span override for engine calls. The factory captures the - * caller's context at construction time, but `Effect.runPromiseWith` - * starts a fresh fiber per SDK callback — so the `currentSpan` - * FiberRef resets to root unless explicitly anchored. - * - * Accepts either a fixed span (per-request McpServer instances) or a - * getter (session-scoped instances that need to anchor each callback - * under whichever request triggered it; see the Cloud DO). - */ - readonly parentSpan?: Tracer.AnySpan | (() => Tracer.AnySpan | undefined); - /** - * Enable verbose MCP capability / elicitation debug logging. - */ - readonly debug?: boolean; - /** - * Controls how elicitation is handled for this MCP connection. The default - * is model-managed resume, where paused executions expose interaction - * metadata and the model can call `resume` with the user's response. - */ - readonly elicitationMode?: - | { - readonly mode: "browser"; - readonly approvalUrl: (executionId: string) => string; - } - | { - readonly mode: "model"; - } - | { - readonly mode: "native"; - }; - readonly browserApprovalStore?: BrowserApprovalStore; - /** - * Host-owned lifecycle for paused executions. The MCP server reports pause - * boundaries; the host decides whether that means a keepAlive lease, browser - * wait, durable record, or no-op. - */ - readonly pausedExecutionHooks?: PausedExecutionHooks; - /** - * Host-provided approval lease duration. When present, paused payloads carry - * an absolute deadline and hooks receive the same deadline. - */ - readonly pausedExecutionLeaseMs?: number; - /** - * Optional host-owned model resume fallback. Used by Cloudflare session - * Durable Objects to route a resume miss to the session that owns the pause. - */ - readonly resumeFallback?: ( - executionId: string, - response: ResumeResponse, - ) => Effect.Effect; - /** - * Loads the MCP-Apps shell HTML served as the `ui://executor/shell.html` - * resource. Injected rather than imported: the shell carries React, Recharts - * and Tailwind, and this package also runs on Workers. Hosts that can serve - * it pass `loadMcpAppsShellHtml` from `@executor-js/mcp-apps-shell`; hosts - * that leave it unset simply don't register the resource or the ui tools. - */ - readonly loadAppShellHtml?: () => Promise; - /** - * Per-connection artifacts opt-out. Defaults to true. A client that connects - * with `?artifacts=false` gets NO artifact surface at all: none of the five - * artifact tools, no `ui://` shell resource, and no artifact entries in the - * `skills` inventory — the same shape a host without `loadAppShellHtml` - * serves. `execute`, `skills` and `resume` are untouched. - */ - readonly artifactsEnabled?: boolean; - /** - * Renders an artifact once, server-side, before it is saved — so a component - * that throws on its first render is refused at create time with the real - * error instead of saving cleanly and dying on the user's page. - * - * Injected for the same reason `loadAppShellHtml` is: it needs React, - * react-dom/server and the whole component barrel, and this package must not - * drag any of that into the graph of a host that only ever calls `execute`. - * Hosts that can afford it pass `smokeRenderArtifact` from - * `@executor-js/mcp-apps-shell`, which loads it behind a dynamic import. - * - * Unset means no smoke check: creates are validated statically and saved, as - * they were before. That is also what happens when the check itself fails — - * see the fail-open path in `createArtifact`. - */ - readonly smokeRenderArtifact?: (code: string) => Promise; - /** - * The scoped executor's artifact operations, so `create-artifact` can persist what - * it renders and `list-artifacts` / `show-artifact` can read it back. Only - * the three operations the MCP surface needs, so hosts don't have to hand the - * whole `Executor` across this boundary. - */ - readonly artifacts?: McpArtifactsPort; - /** - * The caller's saved connections, for binding an artifact's integration roles - * at create time. Structurally satisfied by `executor.connections`; hosts pass - * the same scoped executor they pass `artifacts`. - * - * Absent means `create-artifact` cannot bind, so it refuses code that calls an - * integration rather than saving an artifact that could never run. - */ - readonly connections?: McpConnectionsPort; - /** - * Builds the web-app deep link for a saved artifact. Clients that can't - * render MCP Apps get this URL instead of an inline widget. Absent (stdio has - * no origin at all) means `create-artifact` still persists and reports the id, but - * has no URL to offer. - */ - readonly artifactUrl?: (artifactId: string) => string; - /** - * Notified when an agent-facing artifact tool completes a user-meaningful - * operation: `create-artifact` (created, or updated when it overwrote an - * existing id) and `show-artifact` (viewed). Internal artifact reads — - * binding resolution inside `execute-action` — deliberately do not notify. - * Best-effort observation: failures are swallowed and cannot affect the tool - * result. Hosts recording product analytics supply it; core stays agnostic. - */ - readonly onArtifactUsage?: (action: "created" | "viewed" | "updated") => Effect.Effect; - /** - * Whether the client this session belongs to can render MCP Apps, as - * negotiated at a previous `initialize`. - * - * Capabilities normally arrive from the client at `initialize` and live only - * in the server instance. A session whose host evicted and cold-restored it - * (deploy, idle) is rebuilt mid-conversation with no `initialize` to replay, - * so without this the rebuilt server assumes no apps support and silently - * downgrades every artifact to a deep link. Hosts that persist the - * negotiated value pass it back here; the next `initialize`, if one comes, - * overwrites it. - */ - readonly restoredAppsEnabled?: boolean; - /** - * Called when `initialize` negotiates the client's MCP-Apps support, so the - * host can persist it for {@link restoredAppsEnabled} on a later cold - * restore. Best-effort: failures are swallowed and never affect the session. - */ - readonly onAppsEnabledChange?: (appsEnabled: boolean) => Effect.Effect; -}; - -/** - * The narrow artifact surface the MCP tools need. Structurally satisfied by - * `Executor["artifacts"]`, so hosts holding a scoped executor can pass - * `executor.artifacts` directly. - */ -export type McpArtifactsPort = { - readonly list: () => Effect.Effect; - readonly get: (id: string) => Effect.Effect; - readonly save: (input: SaveArtifactInput) => Effect.Effect; -}; + buildExecutorMcpTools, + type ExecutorMcpAssembly, + type ExecutorMcpToolConfig, + type McpHandlerResult, + type McpRequestJoinKeys, + type McpToolResult, + type NativeExecutionServices, +} from "./tool-server-core"; + +export { formatMcpExecutionOutcome, PAUSED_APPROVAL_TIMEOUT_MS } from "./tool-server-core"; +export type { + BrowserApprovalStore, + ExecutorMcpToolConfig, + McpArtifactsPort, + McpConnectionsPort, + McpToolResult, + PausedExecutionHooks, + ResumeFallbackOutcome, + ResumeUnavailableStatus, +} from "./tool-server-core"; + +const NATIVE_ELICITATION_RESPONSE_KEY = "elicitation"; + +const NativeRequestStateSchema = Schema.Struct({ executionId: Schema.String }); +/** Verified payload carried by a modern native-elicitation continuation. */ +export type NativeRequestState = typeof NativeRequestStateSchema.Type; +const decodeNativeRequestState = Schema.decodeUnknownOption(NativeRequestStateSchema); + +const NativeRequestStateCallSchema = Schema.Struct({ + method: Schema.Literal("tools/call"), + params: Schema.Struct({ + name: Schema.String, + arguments: Schema.Struct({ code: Schema.String }), + }), +}); +const decodeNativeRequestStateCall = Schema.decodeUnknownOption(NativeRequestStateCallSchema); -/** - * The connection surface binding needs: list what this caller can reach. The - * scoped executor has already narrowed it, so an inferred binding can never - * name a connection the caller couldn't call themselves. - */ -export type McpConnectionsPort = { - readonly list: () => Effect.Effect; +type McpServerRequestContext = McpRequestJoinKeys & { + readonly serverContext: ServerContext; }; +/** Configuration required to build an Executor MCP server. */ export type ExecutorMcpServerConfig = - | (ExecutionEngineConfig & SharedMcpServerConfig) - | ({ readonly engine: ExecutionEngine } & SharedMcpServerConfig) - | (ExecutionEngineConfig & SharedMcpServerConfig & { readonly stateless: true }) - | ({ readonly engine: ExecutionEngine; readonly stateless: true } & SharedMcpServerConfig); - -export type BrowserApprovalStore = { - readonly takeResponse: (executionId: string) => Effect.Effect; - readonly waitForResponse?: (executionId: string) => Effect.Effect; -}; + ExecutorMcpToolConfig & { + /** Initial/static MCP Apps policy. Sessionful servers replace it after initialize. */ + readonly appsEnabled: boolean; + /** + * Register a connection-lifetime server whose capability-dependent behavior + * follows the live initialize-negotiated state. Omitted for stateless modern + * request factories, which keep using {@link appsEnabled} as fixed policy. + */ + readonly sessionful?: boolean; + /** HMAC key used to sign opaque native-elicitation continuation state. */ + readonly requestStateSigningKey: Uint8Array | string; + /** + * Stable identifier of the authenticated principal this server instance + * was built for (org/user/subject). Bound into the signed continuation + * state so a `requestState` minted for one principal is rejected when + * echoed by another — the spec's user-binding MUST for state that + * influences authorization. Single-user hosts pass a constant. + */ + readonly requestStatePrincipal: string; + /** Closed request binding derived before the modern server is constructed. */ + readonly requestStateBinding?: string; + /** Lifetime of signed continuation state in seconds; the SDK defaults to ten minutes. */ + readonly requestStateTtlSeconds?: number; + }; -export const PAUSED_APPROVAL_TIMEOUT_MS = 4 * 60 * 1000; -const BROWSER_APPROVAL_WAIT_TIMEOUT_MS = PAUSED_APPROVAL_TIMEOUT_MS + 1000; +/** Decide whether client capabilities advertise support for MCP Apps HTML. */ +export const appsEnabledForClientCapabilities = ( + clientCapabilities: McpAppsClientCapabilities | null | undefined, +): boolean => Boolean(getUiCapability(clientCapabilities)?.mimeTypes?.includes(RESOURCE_MIME_TYPE)); + +/** Bind modern continuation state to the ownership identity used by MCP hosts. */ +export const mcpRequestStatePrincipal = (principal: { + readonly accountId: string; + readonly organizationId: string; +}): string => `${principal.accountId}\u0000${principal.organizationId}`; + +/** Fields whose exact values bind one modern native-elicitation continuation. */ +export interface McpRequestStateBindingInput { + readonly principal: string; + readonly resource: McpResource; + readonly method: string; + readonly toolName: string; + readonly codeDigest: string; +} -export type PausedExecutionHooks = { - readonly onExecutionPaused?: ( - executionId: string, - deadline: PausedExecutionDeadline | undefined, - ) => Effect.Effect; - readonly onResumeStarted?: (executionId: string) => Effect.Effect; - readonly onResumeSettled?: (executionId: string) => Effect.Effect; +/** Build the canonical NUL-separated modern continuation binding. */ +export const mcpRequestStateBinding = (input: McpRequestStateBindingInput): string => + [ + input.principal, + mcpResourceKey(input.resource), + input.method, + input.toolName, + input.codeDigest, + ].join("\u0000"); + +/** Return the lowercase SHA-256 digest used to bind an execute code argument. */ +export const mcpCodeDigest = async (code: string): Promise => { + const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(code)); + return Array.from(new Uint8Array(bytes), (byte) => byte.toString(16).padStart(2, "0")).join(""); }; -export type ResumeUnavailableStatus = - | "execution_not_found" - | "execution_expired" - | "execution_forbidden" - | "execution_already_settled"; - -export type ResumeFallbackOutcome = - | { - readonly status: "result"; - readonly result: McpToolResult; - } - | { - readonly status: Exclude; - readonly ttlMs?: number; - } - | { - readonly status: "execution_not_found"; - }; - -// --------------------------------------------------------------------------- -// Elicitation bridge -// --------------------------------------------------------------------------- - -const getElicitationSupport = (server: McpServer): { form: boolean; url: boolean } => { - const capabilities = server.server.getClientCapabilities(); - if (capabilities === undefined || !capabilities.elicitation) return { form: false, url: false }; - const elicitation = capabilities.elicitation as Record; - return { form: Boolean(elicitation.form), url: Boolean(elicitation.url) }; +/** Derive a continuation binding from an already-parsed modern tools/call body. */ +export const mcpRequestStateBindingFromBody = async (input: { + readonly body: unknown; + readonly principal: string; + readonly resource: McpResource; +}): Promise => { + const call = decodeNativeRequestStateCall(input.body); + if (Option.isNone(call)) return null; + return mcpRequestStateBinding({ + principal: input.principal, + resource: input.resource, + method: call.value.method, + toolName: call.value.params.name, + codeDigest: await mcpCodeDigest(call.value.params.arguments.code), + }); }; -const readDebugDefault = (): boolean => { - if (typeof process === "undefined" || !process.env) return false; - const value = process.env.EXECUTOR_MCP_DEBUG; - return value === "1" || value === "true"; +/** Route-level failure verifying untrusted modern continuation state. */ +export class McpRequestStateVerificationError extends Data.TaggedError( + "McpRequestStateVerificationError", +)<{ readonly cause: unknown }> {} + +class McpRequestStateBindingError extends Data.TaggedError("McpRequestStateBindingError")<{}> {} + +const requestStateBindingForContext = ( + binding: string | undefined, + principal: string, + context: ServerContext, +): Promise => { + if (binding !== undefined) return Promise.resolve(binding); + if (context.mcpReq.envelope === undefined) { + return Promise.resolve(`${context.mcpReq.method}\u0000${principal}`); + } + return Effect.runPromise(Effect.fail(new McpRequestStateBindingError())); }; -const capabilitySnapshot = (server: McpServer) => ({ - clientCapabilities: server.server.getClientCapabilities() ?? null, - elicitationSupport: getElicitationSupport(server), -}); - -class McpNativeElicitationTransportError extends Data.TaggedError( - "McpNativeElicitationTransportError", -)<{ - readonly cause: unknown; -}> {} - -type ElicitInputParams = - | { - mode?: "form"; - message: string; - requestedSchema: { readonly [key: string]: unknown }; - } - | { mode: "url"; message: string; url: string; elicitationId: string }; - -const elicitationRequestTag = (request: ElicitationRequest): ElicitationRequest["_tag"] => - Match.value(request).pipe( - Match.tag("UrlElicitation", () => "UrlElicitation" as const), - Match.tag("FormElicitation", () => "FormElicitation" as const), - Match.exhaustive, - ); - -const requestedSchemaIsNonEmpty = (request: ElicitationRequest): boolean => - Match.value(request).pipe( - Match.tag("FormElicitation", (req) => Object.keys(req.requestedSchema).length > 0), - Match.tag("UrlElicitation", () => false), - Match.exhaustive, - ); - -const elicitationRequestUrl = (request: ElicitationRequest): string | undefined => - Match.value(request).pipe( - Match.tag("UrlElicitation", (req): string | undefined => req.url), - Match.tag("FormElicitation", (): string | undefined => undefined), - Match.exhaustive, - ); - -const pausedInteractionKind = (request: ElicitationRequest): ElicitationRequest["_tag"] => - elicitationRequestTag(request); - -const elicitationRequestToParams: (request: ElicitationRequest) => ElicitInputParams = - Match.type().pipe( - Match.tag("UrlElicitation", (req) => ({ - mode: "url" as const, - message: req.message, - url: req.url, - elicitationId: req.elicitationId, - })), - Match.tag("FormElicitation", (req) => ({ - message: req.message, - // The MCP SDK validates requestedSchema as a JSON Schema with - // `type: "object"` and `properties`. For approval-only elicitations - // where no fields are needed, provide a minimal valid schema. - requestedSchema: - Object.keys(req.requestedSchema).length === 0 - ? { type: "object" as const, properties: {} } - : req.requestedSchema, - })), - Match.exhaustive, - ); - -const makeMcpElicitationHandler = - ( - server: McpServer, - relatedRequestId: string | number, - debugLog?: (event: string, data: Record) => void, - ): ElicitationHandler => - (ctx: ElicitationContext): Effect.Effect => { - const { url: supportsUrl } = getElicitationSupport(server); - - // If client doesn't support url mode, fall back to a form asking the user - // to visit the URL manually and confirm when done. - const params = Match.value(ctx.request).pipe( - Match.tag( - "UrlElicitation", - (req): ElicitInputParams => - !supportsUrl - ? { - message: `${req.message}\n\nPlease visit this URL:\n${req.url}\n\nClick accept once you have completed the flow.`, - requestedSchema: { type: "object" as const, properties: {} }, - } - : elicitationRequestToParams(req), - ), - Match.tag("FormElicitation", (req): ElicitInputParams => elicitationRequestToParams(req)), - Match.exhaustive, - ); - - return Effect.promise(async (): Promise => { - const requestTag = elicitationRequestTag(ctx.request); - debugLog?.("elicitation.request", { - requestTag, - supportsUrl, - message: ctx.request.message, - hasRequestedSchema: requestedSchemaIsNonEmpty(ctx.request), - url: elicitationRequestUrl(ctx.request), - clientCapabilities: server.server.getClientCapabilities() ?? null, - }); - - const response = await server.server.elicitInput( - params as Parameters[0], - { relatedRequestId }, - ); - - debugLog?.("elicitation.response", { - requestTag, - action: response.action, - hasContent: - typeof response.content === "object" && - response.content !== null && - Object.keys(response.content).length > 0, - }); - - return { - action: response.action as typeof ElicitationResponse.Type.action, - content: response.content, - }; - }).pipe( - Effect.tapDefect((defect) => - Effect.sync(() => { - debugLog?.("elicitation.error", { - requestTag: elicitationRequestTag(ctx.request), - error: formatBoundaryError(defect), - clientCapabilities: server.server.getClientCapabilities() ?? null, - }); +/** + * Verify and parse a modern continuation before a stateless worker uses its + * execution id for Durable Object routing. + */ +export const verifyNativeRequestState = (input: { + readonly state: string; + readonly body: unknown; + readonly resource: McpResource; + readonly requestStateSigningKey: Uint8Array | string; + readonly requestStatePrincipal: string; +}): Effect.Effect => + Effect.gen(function* () { + const binding = yield* Effect.tryPromise({ + try: () => + mcpRequestStateBindingFromBody({ + body: input.body, + principal: input.requestStatePrincipal, + resource: input.resource, }), - ), - Effect.catchDefect((cause) => - // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: ElicitationHandler has no error channel, so retain a classified defect for the MCP result boundary. - Effect.die(new McpNativeElicitationTransportError({ cause })), - ), + catch: (cause) => new McpRequestStateVerificationError({ cause }), + }); + if (binding === null) { + return yield* new McpRequestStateVerificationError({ + cause: "invalid request-state binding", + }); + } + const codec = createRequestStateCodec({ + key: input.requestStateSigningKey, + bind: () => binding, + }); + const decoded = yield* Effect.tryPromise({ + // The route-level verifier has no handler context. Its codec binding is a + // closed value derived from the parsed call, resource, and principal, so + // the SDK callback never observes this inert placeholder. + try: () => Reflect.apply(codec.verify, codec, [input.state, null]) as Promise, + catch: (cause) => new McpRequestStateVerificationError({ cause }), + }); + return yield* Schema.decodeUnknownEffect(NativeRequestStateSchema)(decoded).pipe( + Effect.mapError((cause) => new McpRequestStateVerificationError({ cause })), ); - }; - -const formatBoundaryError = (err: unknown): { name?: string; message: string; stack?: string } => { - // oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- boundary: SDK Promise rejection supplies unknown JS errors for logging only - if (err instanceof Error) return { name: err.name, message: err.message, stack: err.stack }; - // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: fallback log formatting for unknown SDK Promise rejection values - return { message: String(err) }; -}; - -// --------------------------------------------------------------------------- -// MCP result formatting -// --------------------------------------------------------------------------- - -export type McpToolResult = { - content: ContentBlock[]; - structuredContent?: Record; - isError?: boolean; -}; - -type FormattedExecuteInput = Parameters[0]; -type ExecuteOutputItem = NonNullable[number]; - -const TEXT_FILE_CONTENT_MAX_CHARS = 64_000; + }); const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); -const toolFileName = (file: ToolFileValue): string => file.name ?? "tool-output"; - -const fileResourceUri = (file: ToolFileValue): string => - `executor-file:///${encodeURIComponent(toolFileName(file))}`; - -const normalizedMimeType = (file: ToolFileValue): string => - file.mimeType.split(";")[0]?.trim().toLowerCase() ?? ""; - -const toolFileKind = (file: ToolFileValue): "image" | "audio" | "text" | "resource" => { - const mimeType = normalizedMimeType(file); - if (mimeType.startsWith("image/")) return "image"; - if (mimeType.startsWith("audio/")) return "audio"; - if ( - mimeType.startsWith("text/") || - mimeType === "application/json" || - mimeType.endsWith("+json") || - mimeType === "application/xml" || - mimeType.endsWith("+xml") || - mimeType === "application/javascript" || - mimeType === "application/x-javascript" || - mimeType === "application/yaml" || - mimeType === "application/x-yaml" - ) { - return "text"; +const appsClientCapabilitiesFromUnknown = ( + capabilities: unknown, +): McpAppsClientCapabilities | null => { + if (!isRecord(capabilities)) return null; + const extensions = capabilities.extensions; + if (!isRecord(extensions)) return null; + const ui = extensions[EXTENSION_ID]; + if (!isRecord(ui)) return null; + const mimeTypes = ui.mimeTypes; + if (mimeTypes === undefined) return { extensions: { [EXTENSION_ID]: {} } }; + if (!Array.isArray(mimeTypes) || !mimeTypes.every((value) => typeof value === "string")) { + return null; } - return "resource"; + return { extensions: { [EXTENSION_ID]: { mimeTypes } } }; }; -const bytesFromBase64 = (base64: string): Uint8Array => { - const binary = atob(base64); - const bytes = new Uint8Array(binary.length); - for (let index = 0; index < binary.length; index += 1) { - bytes[index] = binary.charCodeAt(index); - } - return bytes; -}; - -const decodeTextFile = (file: ToolFileValue): string => { - const text = new TextDecoder("utf-8", { fatal: false }).decode(bytesFromBase64(file.data)); - if (text.length <= TEXT_FILE_CONTENT_MAX_CHARS) return text; - return `${text.slice(0, TEXT_FILE_CONTENT_MAX_CHARS)}\n\n[truncated ${ - text.length - TEXT_FILE_CONTENT_MAX_CHARS - } characters]`; -}; - -const toolFileContent = (file: ToolFileValue): ContentBlock[] => { - const kind = toolFileKind(file); - if (kind === "image") { - return [{ type: "image", data: file.data, mimeType: file.mimeType }]; - } - if (kind === "audio") { - return [{ type: "audio", data: file.data, mimeType: file.mimeType }]; +const elicitationSupportFromUnknown = ( + capabilities: unknown, +): { readonly form: boolean; readonly url: boolean } => { + if (!isRecord(capabilities) || !isRecord(capabilities.elicitation)) { + return { form: false, url: false }; } - if (kind === "text") { - return [{ type: "text", text: decodeTextFile(file) }]; - } - return [ - { - type: "resource", - resource: { - uri: fileResourceUri(file), - mimeType: file.mimeType, - blob: file.data, - }, - }, - ]; -}; - -const toolFileSummaryLine = (file: ToolFileValue, index?: number): string => { - const prefix = index === undefined ? "" : `${index + 1}. `; - return `${prefix}${toolFileName(file)} (${file.mimeType}, ${file.byteLength} bytes)`; -}; - -const outputFileContent = (file: ToolFileValue): ContentBlock[] => [ - { - type: "text", - text: `File output: ${toolFileSummaryLine(file)}`, - }, - ...toolFileContent(file), -]; - -const isFileOutputItem = ( - item: ExecuteOutputItem, -): item is { readonly type: "file"; readonly file: ToolFileValue } => - isRecord(item) && item.type === "file" && isToolFile(item.file); - -const isMcpContentBlock = (value: unknown): value is ContentBlock => - ContentBlockSchema.safeParse(value).success; - -const isContentOutputItem = ( - item: ExecuteOutputItem, -): item is { readonly type: "content"; readonly content: ContentBlock } => - isRecord(item) && item.type === "content" && isMcpContentBlock(item.content); - -const outputItemContent = (item: ExecuteOutputItem): ContentBlock[] => { - if (isFileOutputItem(item)) { - return outputFileContent(item.file); - } - if (isContentOutputItem(item)) { - return [item.content]; - } - return [{ type: "text", text: "Invalid execution output item omitted." }]; -}; - -const toMcpOutputResult = ( - result: FormattedExecuteInput, - output: readonly ExecuteOutputItem[], -): McpToolResult => { - const formatted = formatExecuteResult(result); - const content = output.flatMap(outputItemContent); - const extraText: string[] = []; - if (result.error) { - extraText.push(formatted.text); - } else if (result.result != null) { - // A script may both emit() and return: keep the returned value in the - // content channel too, or clients that ignore structuredContent drop it. - // formatted.text already renders the return value plus any logs. - extraText.push(formatted.text); - } else if (result.logs && result.logs.length > 0) { - extraText.push(`Logs:\n${result.logs.join("\n")}`); - } - content.push(...extraText.map((text): ContentBlock => ({ type: "text", text }))); - + const elicitation = capabilities.elicitation; + const hasExplicitModes = "form" in elicitation || "url" in elicitation; return { - content, - structuredContent: formatted.structured, - isError: formatted.isError || undefined, + form: hasExplicitModes ? Boolean(elicitation.form) : true, + url: Boolean(elicitation.url), }; }; -const toMcpResult = (result: FormattedExecuteInput): McpToolResult => { - if (result.output && result.output.length > 0) return toMcpOutputResult(result, result.output); - const formatted = formatExecuteResult(result); - return { - content: [{ type: "text", text: formatted.text }], - structuredContent: formatted.structured, - isError: formatted.isError || undefined, - }; +/** Parse the MCP Apps capability subset from an already-decoded modern body. */ +export const clientCapabilitiesFromRequestBody = ( + body: unknown, +): McpAppsClientCapabilities | null => { + if (!isRecord(body)) return null; + const params = body.params; + if (!isRecord(params)) return null; + const metadata = params._meta; + if (!isRecord(metadata)) return null; + const capabilities = metadata[CLIENT_CAPABILITIES_META_KEY]; + return appsClientCapabilitiesFromUnknown(capabilities); }; -const toMcpPausedResult = (formatted: ReturnType): McpToolResult => ({ - content: [{ type: "text", text: formatted.text }], - structuredContent: formatted.structured, -}); - -export const formatMcpExecutionOutcome = ( - outcome: ExecutionResult, - options?: { readonly pausedDeadline?: PausedExecutionDeadline }, -): McpToolResult => - outcome.status === "completed" - ? toMcpResult(outcome.result) - : toMcpPausedResult( - formatPausedExecution(outcome.execution, { deadline: options?.pausedDeadline }), - ); - -// `execute` failures reaching the MCP host are infra defects — domain -// failures from tools are now expressed as `ToolResult` values (success -// channel) and flow through `formatExecuteResult`. Emit an opaque -// generic plus a fresh correlation id and log the cause out-of-band so -// the model can't read internal context off `.message`. -const newCorrelationId = (): string => - Math.floor(Math.random() * 0x1_0000_0000) - .toString(16) - .padStart(8, "0"); - -const defaultResumeApprovalUrl = (executionId: string): string => - `/resume/${encodeURIComponent(executionId)}`; - -const browserApprovalReturnPrompt = - "Return text to the user telling them to approve the action at this approvalUrl. Only after you have prompted the user, call the `resume` tool with this executionId; `resume` will wait for the user's browser decision."; +/** Parse a cloned HTTP request body without consuming the request itself. */ +export const requestBodyFromRequest = (request: Request): Effect.Effect => + Effect.tryPromise({ + try: () => request.clone().json(), + catch: () => null, + }).pipe( + Effect.match({ + onFailure: () => null, + onSuccess: (body) => body, + }), + ); -const formatResumeApprovalRequired = (input: { - readonly executionId: string; - readonly approvalUrl: string; -}): McpToolResult => ({ - content: [ - { - type: "text", - text: [ - "User approval required.", - "", - "Tell the user to open this URL while signed in and approve or decline the paused interaction:", - input.approvalUrl, - "", - "Required next steps for this agent:", - browserApprovalReturnPrompt, - ].join("\n"), - }, - ], - structuredContent: { - status: "user_approval_required", - executionId: input.executionId, - approvalUrl: input.approvalUrl, - resumePrompt: browserApprovalReturnPrompt, - }, +/** + * Parse the MCP Apps capability subset from a modern request's `_meta` + * envelope without consuming the request body used by the SDK handler. + */ +export const clientCapabilitiesFromRequest = ( + request: Request, +): Effect.Effect => + requestBodyFromRequest(request).pipe(Effect.map(clientCapabilitiesFromRequestBody)); + +const requestJoinKeys = (context: ServerContext): McpServerRequestContext => ({ + requestId: context.mcpReq.id, + ...(context.sessionId === undefined ? {} : { sessionId: context.sessionId }), + serverContext: context, }); -const toMcpFailureResult = (cause: Cause.Cause): McpToolResult => { - const correlationId = newCorrelationId(); - const defect = Cause.findDefect(cause); - const nativeElicitationFailed = - Result.isSuccess(defect) && - Predicate.isTagged("McpNativeElicitationTransportError")(defect.success); - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: best-effort defect logging must tolerate non-serializable causes - try { - console.error( - `[executor:mcp] execute defect correlation_id=${correlationId}`, - Cause.pretty(cause), - ); - } catch { - /* ignore logger failures */ - } - const text = nativeElicitationFailed - ? `Native elicitation transport failed [${correlationId}]. Reconnect the MCP client and try again.` - : `Internal tool error [${correlationId}]`; +const appToolMeta = (metadata: Record): McpAppToolMeta | undefined => { + const ui = metadata.ui; + if (!isRecord(ui)) return undefined; + const resourceUri = typeof ui.resourceUri === "string" ? ui.resourceUri : undefined; + const visibility = Array.isArray(ui.visibility) + ? ui.visibility.filter( + (value): value is "model" | "app" => value === "model" || value === "app", + ) + : undefined; return { - content: [{ type: "text", text: `Error: ${text}` }], - structuredContent: { - status: "error", - error: text, - ...(nativeElicitationFailed ? { errorCode: "native_elicitation_transport_failed" } : {}), - }, - isError: true, + ...(resourceUri === undefined ? {} : { resourceUri }), + ...(visibility === undefined ? {} : { visibility }), }; }; -const recoveryText = - "To recover, run the execute tool again with the original code; if it pauses, a fresh executionId will be issued."; - -const resumeUnavailableResult = (input: { - readonly status: ResumeUnavailableStatus; - readonly executionId: string; - readonly ttlMs?: number; -}): McpToolResult => { - const windowMs = input.ttlMs ?? PAUSED_APPROVAL_TIMEOUT_MS; - const approvalWindow = formatTtlDuration(windowMs); - const textByStatus: Record = { - execution_not_found: [ - `Paused execution is unknown: ${input.executionId}.`, - `Paused executions are only resumable for a limited window; this id may have expired or never existed.`, - recoveryText, - ], - execution_expired: [ - `Paused execution expired: ${input.executionId}.`, - `Approval windows last ${approvalWindow}; the owning session no longer has a live pause for this executionId.`, - recoveryText, - ], - execution_forbidden: [ - `Paused execution cannot be resumed by this authenticated identity: ${input.executionId}.`, - "Resume must be called by the same account and organization that owns the paused session.", - ], - execution_already_settled: [ - `Paused execution has already settled: ${input.executionId}.`, - "The resume result is no longer available for replay.", - "Run execute again only if the result is still needed.", - ], - }; +const normalizedAppMetadata = (metadata: Record) => { + const ui = appToolMeta(metadata); + const legacyResourceUri = metadata[RESOURCE_URI_META_KEY]; return { - content: [ - { - type: "text" as const, - text: textByStatus[input.status].join(" "), - }, - ], - structuredContent: { - status: input.status, - executionId: input.executionId, - ...(input.status === "execution_expired" ? { ttlMs: windowMs } : {}), - ...(input.status === "execution_forbidden" ? {} : { recovery: "re_execute" }), - }, - isError: true, + ...metadata, + ...(ui === undefined ? {} : { ui }), + ...(typeof legacyResourceUri === "string" + ? { [RESOURCE_URI_META_KEY]: legacyResourceUri } + : {}), }; }; -const missingExecutionResult = (executionId: string): McpToolResult => - resumeUnavailableResult({ status: "execution_not_found", executionId }); - -const alreadySettledResult = (executionId: string): McpToolResult => - resumeUnavailableResult({ status: "execution_already_settled", executionId }); - -const fallbackOutcomeResult = ( - executionId: string, - outcome: ResumeFallbackOutcome, -): McpToolResult => { - if (outcome.status === "result") return outcome.result; - return resumeUnavailableResult({ - status: outcome.status, - executionId, - ttlMs: "ttlMs" in outcome ? outcome.ttlMs : undefined, - }); +const withoutAppMetadata = (metadata: Record): Record => { + const { ui: _ui, [RESOURCE_URI_META_KEY]: _resourceUri, ...rest } = metadata; + return rest; }; -// The `skills` tool serves named, static how-to docs (see the execution -// package's skills registry). No name -> the index; a known name -> that -// skill's body; an unknown name -> the index plus a not-found note so the model -// retries with a listed name instead of the same miss. -// -// The skill body IS the payload, returned as plain text content. We do NOT -// attach `structuredContent`: a client that prefers structured output (Claude -// Code does) will surface only that and drop the text, so the long-form guide -// silently fails to load. The not-found case keeps `isError` (a separate field -// clients honor) so a bad name still reads as a failure. -// -// The `execute` skill also gets the live integration inventory appended, the -// same block the execute tool description carries, so a model reading the guide -// sees what is connected without a second round trip. -// -// The catalog is per-session: a connection that opted out of artifacts never -// sees the artifact skills, so the index cannot advertise a how-to for tools it -// does not have, and fetching one by name misses like any unknown skill. -const skillsResult = ( - name: string | undefined, - executeInventory: string, - catalog: readonly Skill[], -): McpToolResult => { - const trimmed = name?.trim(); - if (!trimmed) { - return { content: [{ type: "text", text: renderSkillsIndex(catalog) }] }; - } - const skill = findSkill(trimmed, catalog); - if (!skill) { - return { - content: [ - { type: "text", text: `No skill named "${trimmed}".\n\n${renderSkillsIndex(catalog)}` }, - ], - isError: true, - }; - } - const text = - skill.name === EXECUTE_SKILL.name && executeInventory.length > 0 - ? `${skill.body}\n\n${executeInventory}` - : skill.body; - return { content: [{ type: "text", text }] }; -}; - -/** Pull the live integration inventory block out of the built execute - * description (it runs from its header to the end), so the `skills` tool can - * re-use it without rebuilding the inventory from the executor. */ -const extractInventory = (description: string): string => { - const index = description.indexOf(INTEGRATION_INVENTORY_HEADER); - return index === -1 ? "" : description.slice(index).trimEnd(); -}; - -// --------------------------------------------------------------------------- -// Hang-visibility join keys -// --------------------------------------------------------------------------- -// A killed execution exports nothing: OTEL only ships a span when it ends, and -// a Cloudflare deploy/eviction cancels the request without an error, so a hung -// `execute` is invisible in the trace store. Two mitigations live here: -// 1. Every execution-path span carries the JSON-RPC id + transport session id -// (`mcp.rpc.id`, `mcp.request.session_id`), so a client's -// `notifications/cancelled` — which names the cancelled request id — can -// be joined to the exact call it gave up on. -// 2. A zero-duration start marker span (`.start`, a -// 1:1 pairing so "started without finishing" is a single unambiguous -// query) is emitted the moment execution begins. It ends immediately, so -// it becomes exportable while the execution is still running; whether it -// actually ships before a kill depends on the host's span processor -// draining first (cloud batches on a 1s timer, so markers for executions -// that survive >1s export, sub-second kills can still lose theirs). A -// start marker without a matching completion span is a true positive for -// an execution that died mid-flight. - -type McpRequestJoinKeys = { - readonly requestId: string | number; - readonly sessionId?: string | undefined; -}; - -// `mcp.request.session_id` is emitted unconditionally (empty string when the -// transport carries none) to match the worker-side `annotateMcpRequest` -// producer: JSON-RPC ids are small per-session integers, so a row without the -// session key would make `mcp.rpc.id` globally ambiguous. -const joinKeyAttributes = (joinKeys: McpRequestJoinKeys): Record => ({ - "mcp.rpc.id": String(joinKeys.requestId), - "mcp.request.session_id": joinKeys.sessionId ?? "", -}); - -const startMarker = (name: string, attributes: Record): Effect.Effect => - Effect.void.pipe(Effect.withSpan(name, { attributes })); - -// --------------------------------------------------------------------------- -// Artifacts / MCP Apps result formatting -// --------------------------------------------------------------------------- -// -// Delivery is negotiated, not branched on by the model: an artifact reaches the -// user as an inline widget when the client renders MCP Apps, and as a link into -// the web app when it doesn't. Both carry `artifactId`, because either way the -// artifact was saved and can be reopened later. - -const renderRejectedResult = (reason: string): McpToolResult => ({ - content: [{ type: "text", text: `create-artifact rejected: ${reason}` }], - structuredContent: { status: "error", error: reason }, - isError: true, -}); - -/** An edit batch that could not be applied. Carries the current stored source - * so the model can rebuild its edits without a `show-artifact` round trip. */ -const editRejectedResult = (reason: string, currentCode: string): McpToolResult => ({ - content: [ - { - type: "text", - text: [ - `edit-artifact rejected: ${reason}`, - "Nothing was changed. The artifact's current source is in structuredContent.code — build the retry against it.", - ].join("\n"), - }, - ], - structuredContent: { status: "error", error: reason, code: currentCode }, - isError: true, -}); - -/** `execute-action` was handed something other than a single proxy-shaped tool - * call. Names the contract rather than just refusing, since the reader is - * either a confused iframe or someone probing the app channel by hand. */ -const actionRejectedResult = (): McpToolResult => ({ - content: [{ type: "text", text: TOOL_CALL_CONTRACT_MESSAGE }], - structuredContent: { status: "error", error: "invalid_action_code" }, - isError: true, -}); +const visibilityIncludes = ( + metadata: Record, + visibility: "model" | "app", +): boolean => appToolMeta(metadata)?.visibility?.includes(visibility) ?? true; -/** - * The artifact whose bindings a call must be resolved through is missing or - * isn't this caller's. - * - * One result for both, deliberately: distinguishing "no such artifact" from - * "not yours" would let the app channel probe for ids that exist. - */ -const actionArtifactUnavailableResult = (): McpToolResult => ({ - content: [ - { - type: "text", - text: "This action refers to an artifact that isn't available on this account.", - }, - ], - structuredContent: { status: "error", error: "artifact_unavailable" }, - isError: true, -}); +const toolResult = (result: McpHandlerResult): CallToolResult | InputRequiredResult => result; -/** - * A role in the artifact's code has no connection behind it. - * - * Structured rather than prose-only because the binding UI that ships with - * sharing renders exactly this: which role failed, for which integration, and - * what the viewer could bind it to instead. The apps plugin's `BindingError` - * carries the same three facts for the same reason. - */ -const bindingUnresolvedResult = (input: { - readonly role: string; - readonly integration: string; - readonly message: string; - readonly candidates: readonly string[]; -}): McpToolResult => ({ - content: [ - { - type: "text", - text: - input.candidates.length > 0 - ? `${input.message} Choose one of ${input.candidates.join(", ")}.` - : input.message, - }, - ], - structuredContent: { - status: "error", - error: "binding_unresolved", - role: input.role, - integration: input.integration, - candidates: input.candidates, - }, - isError: true, -}); - -const renderedInAppResult = (input: { - readonly code: string; - readonly artifactId: string; - readonly title: string; - readonly url?: string | undefined; -}): McpToolResult => ({ - content: [ - { - type: "text", - text: [ - `Rendered "${input.title}" as an interactive UI component. Saved as artifact ${input.artifactId}.`, - // The link rides along even though the widget rendered: clients lose - // rendered widgets in ways the server never sees (a transcript - // reopened without re-reading the ui:// resource shows raw JSON), and - // when that happens this URL in the conversation is the only path - // back to the artifact the model can offer. - ...(input.url ? [`It also stays available at ${input.url}`] : []), - ].join("\n"), - }, - ], - structuredContent: { - code: input.code, - artifactId: input.artifactId, - ...(input.url ? { url: input.url } : {}), - }, -}); - -const renderedAsLinkResult = (input: { - readonly url: string; - readonly artifactId: string; - readonly title: string; -}): McpToolResult => ({ - content: [ - { - type: "text", - text: [ - `Saved "${input.title}" as artifact ${input.artifactId}.`, - "This MCP client cannot display MCP Apps, so give the user this URL to open it:", - input.url, - ].join("\n"), - }, - ], - structuredContent: { - status: "fallback_url", - url: input.url, - artifactId: input.artifactId, - }, -}); +const elicitationInputRequest = (request: ElicitationRequest) => + Match.value(request).pipe( + Match.tag("FormElicitation", (form) => + inputRequired.elicit({ + message: form.message, + requestedSchema: + Object.keys(form.requestedSchema).length === 0 + ? fromJsonSchema({ type: "object" as const, properties: {} }) + : fromJsonSchema(form.requestedSchema), + }), + ), + Match.tag("UrlElicitation", (url) => + inputRequired.elicitUrl({ message: url.message, url: url.url }), + ), + Match.exhaustive, + ); -const renderedWithoutSurfaceResult = (input: { - readonly artifactId: string; - readonly title: string; -}): McpToolResult => ({ +const missingNativeExecution = (executionId: string): McpToolResult => ({ content: [ { type: "text", - text: [ - `Saved "${input.title}" as artifact ${input.artifactId}.`, - "This MCP client cannot display MCP Apps and this deployment has no web UI configured, so there is nowhere to show it right now.", - "Tell the user the artifact was saved and can be opened from a client that supports MCP Apps.", - ].join("\n"), + text: `Paused execution is unknown: ${executionId}. Run execute again to start a fresh flow.`, }, ], structuredContent: { - status: "fallback_unavailable", - reason: "mcp_apps_unsupported", - artifactId: input.artifactId, + status: "execution_not_found", + executionId, }, -}); - -const artifactsUnavailableResult = (): McpToolResult => ({ - content: [ - { - type: "text", - text: "Artifacts are not available on this connection.", - }, - ], - structuredContent: { status: "error", error: "artifacts_unavailable" }, - isError: true, -}); - -const artifactListResult = (artifacts: readonly ArtifactSummary[]): McpToolResult => { - const items = artifacts.map((artifact) => ({ - id: artifact.id, - title: artifact.title, - description: artifact.description, - updatedAt: artifact.updatedAt.toISOString(), - })); - const text = - items.length === 0 - ? "No saved artifacts yet. Use create-artifact to make one." - : [ - "Saved artifacts:", - ...items.map( - (item) => - `- ${item.id} — ${item.title}${item.description ? `: ${item.description}` : ""} (updated ${item.updatedAt})`, - ), - ].join("\n"); - return { content: [{ type: "text", text }], structuredContent: { artifacts: items } }; -}; - -const artifactNotFoundResult = (id: string): McpToolResult => ({ - content: [ - { - type: "text", - text: `No artifact with id "${id}". Call list-artifacts to see what is saved.`, - }, - ], - structuredContent: { status: "error", error: "artifact_not_found", id }, isError: true, }); -const JsonObjectFromString = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)); -const decodeJsonObjectString = Schema.decodeUnknownOption(JsonObjectFromString); - -const parseJsonContent = (raw: string): Record | undefined => { - if (raw === "{}") return undefined; - const parsed = decodeJsonObjectString(raw); - return Option.isSome(parsed) ? parsed.value : undefined; -}; - -// --------------------------------------------------------------------------- -// Server factory -// --------------------------------------------------------------------------- - -export const createExecutorMcpServer = ( +const createMcpAssembly = ( config: ExecutorMcpServerConfig, -): Effect.Effect => - Effect.gen(function* () { - const engine = "engine" in config ? config.engine : createExecutionEngine(config); - const description = - config.description ?? - (yield* engine.getDescription.pipe(Effect.withSpan("mcp.host.get_description"))); - // The same live integration inventory the description carries, re-used by - // the `skills` tool so the `execute` guide lists what is connected too. - const executeInventory = extractInventory(description); - // Artifacts are on unless this connection opted out (`?artifacts=false`). - // One flag decides the whole surface: the tools, the shell resource, and - // the skills catalog below. - const artifactsEnabled = config.artifactsEnabled ?? true; - const skillCatalog: readonly Skill[] = skillCatalogFor({ artifacts: artifactsEnabled }); - - // Captured at construction time. SDK callbacks fire later (often - // deferred past the outer Effect's await), so we use the runtime to - // re-enter Effect-land at each callback edge. - const context = yield* Effect.context(); - const debugEnabled = config.debug ?? readDebugDefault(); - const debugLog = (event: string, data: Record) => { - if (!debugEnabled) return; - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: debug logging must tolerate non-serializable SDK capability snapshots - try { - console.error(`[executor:mcp] ${event} ${JSON.stringify(data)}`); - } catch { - console.error(`[executor:mcp] ${event}`, data); - } - }; - const elicitationMode = - config.elicitationMode ?? - ({ - mode: "model", - } as const); - const pauseDeadline = (): PausedExecutionDeadline | undefined => { - const ttlMs = config.pausedExecutionLeaseMs; - return ttlMs === undefined || ttlMs <= 0 - ? undefined - : { ttlMs, expiresAt: new Date(Date.now() + ttlMs).toISOString() }; - }; - const onExecutionPaused = ( - executionId: string, - deadline: PausedExecutionDeadline | undefined, - ): Effect.Effect => - config.pausedExecutionHooks?.onExecutionPaused?.(executionId, deadline) ?? Effect.void; - const onResumeStarted = (executionId: string): Effect.Effect => - config.pausedExecutionHooks?.onResumeStarted?.(executionId) ?? Effect.void; - const onResumeSettled = (executionId: string): Effect.Effect => - config.pausedExecutionHooks?.onResumeSettled?.(executionId) ?? Effect.void; - const resumeWithLifecycle = (executionId: string, response: ResumeResponse) => - Effect.gen(function* () { - yield* onResumeStarted(executionId); - return yield* engine.resume(executionId, response); - }).pipe(Effect.ensuring(onResumeSettled(executionId))); - - const localExecutionAlreadySettled = (executionId: string): Effect.Effect => - engine.isExecutionSettled?.(executionId) ?? Effect.succeed(false); - - const resumeFallback = ( - executionId: string, - response: ResumeResponse, - ): Effect.Effect => - config - .resumeFallback?.(executionId, response) - .pipe(Effect.catchCause(() => Effect.succeed(null))) ?? Effect.succeed(null); - - const formatPausedModelResult = ( - execution: PausedExecution, - source: "execute" | "execute_action" | "resume" | "browser_resume", - ): Effect.Effect => - Effect.gen(function* () { - const deadline = pauseDeadline(); - yield* Effect.annotateCurrentSpan({ - "mcp.execute.paused": true, - "mcp.execute.paused_execution_id": execution.id, - "mcp.execute.pause_source": source, - }); - yield* onExecutionPaused(execution.id, deadline); - return toMcpPausedResult(formatPausedExecution(execution, { deadline })); - }); - - const resolveParentSpan = (): Tracer.AnySpan | undefined => { - const ps = config.parentSpan; - return typeof ps === "function" ? ps() : ps; - }; - const anchor = (effect: Effect.Effect): Effect.Effect => { - const parent = resolveParentSpan(); - return parent ? Effect.withParentSpan(effect, parent) : effect; - }; - const runToolEffect = (effect: Effect.Effect) => - Effect.runPromiseWith(context)( - anchor(effect).pipe( - Effect.catchCause((cause) => Effect.succeed(toMcpFailureResult(cause))), - ), - ); - - const server = yield* Effect.sync( - () => - new McpServer( - { name: "executor", version: "1.0.0" }, - { - // `resources` is required to serve the MCP-Apps shell at - // `ui://executor/shell.html`; it stays advertised even when no - // shell loader is configured so the capability set doesn't vary - // per host. - capabilities: { resources: {}, tools: {} }, - jsonSchemaValidator: new CfWorkerJsonSchemaValidator(), - }, - ), - ).pipe(Effect.withSpan("mcp.host.create_server")); - - const executeWithNativeElicitation = ( - code: string, - extra: McpRequestJoinKeys, - ): Effect.Effect => - engine - .execute(code, { - onElicitation: makeMcpElicitationHandler(server, extra.requestId, debugLog), - }) - .pipe(Effect.map(toMcpResult)); - - const executeCode = ( - code: string, - extra: McpRequestJoinKeys, - ): Effect.Effect => - Effect.gen(function* () { - yield* startMarker("mcp.host.tool.execute.start", { - "mcp.tool.name": "execute", - "mcp.execute.code_length": code.length, - }); - debugLog("execute.call", { - elicitationMode: elicitationMode.mode, - elicitationSupport: getElicitationSupport(server), - clientCapabilities: server.server.getClientCapabilities() ?? null, - codeLength: code.length, - }); - if (elicitationMode.mode === "native") { - return yield* executeWithNativeElicitation(code, extra); - } - const outcome = yield* engine.executeWithPause(code); - debugLog("execute.paused_flow_result", { - status: outcome.status, - executionId: outcome.status === "paused" ? outcome.execution.id : undefined, - interactionKind: - outcome.status === "paused" - ? pausedInteractionKind(outcome.execution.elicitationContext.request) - : undefined, - }); - if (outcome.status === "paused") { - const deadline = pauseDeadline(); - yield* Effect.annotateCurrentSpan({ - "mcp.execute.paused": true, - "mcp.execute.paused_execution_id": outcome.execution.id, - "mcp.execute.pause_source": "execute", - }); - yield* onExecutionPaused(outcome.execution.id, deadline); - return elicitationMode.mode === "browser" - ? yield* requireUserResumeApproval(outcome.execution.id) - : toMcpPausedResult(formatPausedExecution(outcome.execution, { deadline })); - } - return toMcpResult(outcome.result); - }).pipe( - Effect.withSpan("mcp.host.tool.execute", { - attributes: { - "mcp.tool.name": "execute", - "mcp.execute.code_length": code.length, - }, - }), - Effect.annotateSpans(joinKeyAttributes(extra)), - ); - - /** What the caller could bind an unresolved role to. Best effort: the - * connections port is optional, and a failure to enumerate must not - * replace the real error with a different one. */ - const bindingCandidates = (integration: string): Effect.Effect => - config.connections - ? config.connections.list().pipe( - Effect.map((all) => - all - .filter((connection) => connection.integration === integration) - .map( - (connection) => - `${connection.integration}.${connection.owner}.${connection.name}`, - ), - ), - Effect.catchCause(() => Effect.succeed([] as readonly string[])), - ) - : Effect.succeed([]); - - /** The artifact as THIS caller can read it. A miss and a row owned by - * someone else are the same answer, because they are the same query. */ - const loadArtifact = (id: string): Effect.Effect => - config.artifacts - ? config.artifacts.get(id).pipe(Effect.catchCause(() => Effect.succeed(null))) - : Effect.succeed(null); - - // `execute-action` is `execute` as called by the shell rather than by the - // model, and the difference is who owns approval. The shell renders the - // approval modal itself in its trusted outer frame, so a pause here must - // come back as the `waiting_for_interaction` payload the shell knows how to - // resolve — never as a browser approval URL, which the user would have no - // way to act on from inside a widget. That holds even when the session's - // elicitation mode is `browser`, which is why this doesn't just call - // `executeCode`. - // - // The other difference is WIDTH. `execute` takes arbitrary code because the - // model writes it; this channel takes exactly one proxy-shaped tool call, - // because that is all a declarative artifact can produce. See - // `tool-call-code.ts`. - // - // The third difference is that the incoming path is not yet an ADDRESS. - // Artifact code names an integration and, optionally, a role; the tier and - // connection are held on the artifact row. So this channel re-writes the - // call against those bindings before executing, and the executed code is - // built HERE, from a parsed path and a stored binding, never taken from the - // iframe verbatim. That is what makes the short form safe: an iframe that - // invented a five-segment address would only be naming a role the artifact - // has no binding for, and would be refused. - const executeCodeFromApp = ( - code: string, - artifactId: string | undefined, - extra: McpRequestJoinKeys, - ): Effect.Effect => - Effect.gen(function* () { - const resolution = yield* resolveArtifactAction({ code, artifactId, loadArtifact }); - debugLog("execute_action.call", { - elicitationMode: elicitationMode.mode, - elicitationSupport: getElicitationSupport(server), - codeLength: code.length, - status: resolution.status, - artifactId: artifactId ?? null, - }); - if (resolution.status === "invalid_action_code") { - yield* Effect.annotateCurrentSpan({ "mcp.execute_action.rejected": true }); - return actionRejectedResult(); - } - if (resolution.status === "artifact_unavailable") { - return actionArtifactUnavailableResult(); - } - if (resolution.status === "binding_unresolved") { - yield* Effect.annotateCurrentSpan({ - "mcp.execute_action.binding_unresolved": true, - "mcp.execute_action.role": resolution.role, - }); - return bindingUnresolvedResult({ - role: resolution.role, - integration: resolution.integration, - message: resolution.message, - candidates: yield* bindingCandidates(resolution.integration), - }); - } - const boundCode = resolution.code; - - if (elicitationMode.mode === "native") { - return yield* executeWithNativeElicitation(boundCode, extra); - } - const outcome = yield* engine.executeWithPause(boundCode); - debugLog("execute_action.paused_flow_result", { - status: outcome.status, - executionId: outcome.status === "paused" ? outcome.execution.id : undefined, - interactionKind: - outcome.status === "paused" - ? pausedInteractionKind(outcome.execution.elicitationContext.request) - : undefined, - }); - if (outcome.status === "paused") { - return yield* formatPausedModelResult(outcome.execution, "execute_action"); - } - return toMcpResult(outcome.result); - }).pipe( - Effect.withSpan("mcp.host.tool.execute_action", { - attributes: { - "mcp.tool.name": "execute-action", - "mcp.execute.code_length": code.length, - }, - }), - ); - - const resumeExecution = ( - executionId: string, - action: "accept" | "decline" | "cancel", - content: Record | undefined, - extra: McpRequestJoinKeys, - ): Effect.Effect => - Effect.gen(function* () { - yield* startMarker("mcp.host.tool.resume.start", { - "mcp.tool.name": "resume", - "mcp.execute.execution_id": executionId, - }); - debugLog("resume.call", { - executionId, - action, - hasContent: content !== undefined, - clientCapabilities: server.server.getClientCapabilities() ?? null, - }); - const outcome = yield* resumeWithLifecycle(executionId, { action, content }); - if (!outcome) { - debugLog("resume.missing_execution", { executionId }); - if (yield* localExecutionAlreadySettled(executionId)) { - return alreadySettledResult(executionId); - } - const fallback = yield* resumeFallback(executionId, { action, content }); - if (fallback) { - debugLog("resume.fallback_result", { executionId, status: fallback.status }); - return fallbackOutcomeResult(executionId, fallback); - } - return missingExecutionResult(executionId); - } - debugLog("resume.result", { - executionId, - status: outcome.status, - nextExecutionId: outcome.status === "paused" ? outcome.execution.id : undefined, - interactionKind: - outcome.status === "paused" - ? pausedInteractionKind(outcome.execution.elicitationContext.request) - : undefined, - }); - if (outcome.status === "paused") { - return yield* formatPausedModelResult(outcome.execution, "resume"); - } - return toMcpResult(outcome.result); - }).pipe( - Effect.withSpan("mcp.host.tool.resume", { - attributes: { - "mcp.tool.name": "resume", - "mcp.execute.resume.action": action, - "mcp.execute.execution_id": executionId, - }, - }), - Effect.annotateSpans(joinKeyAttributes(extra)), - ); - - const requireUserResumeApproval = (executionId: string): Effect.Effect => - Effect.sync(() => { - const approvalUrl = - elicitationMode.mode === "browser" - ? elicitationMode.approvalUrl(executionId) - : defaultResumeApprovalUrl(executionId); - debugLog("resume.user_approval_required", { - executionId, - approvalUrl, - clientCapabilities: server.server.getClientCapabilities() ?? null, - }); - return formatResumeApprovalRequired({ executionId, approvalUrl }); - }).pipe( - Effect.withSpan("mcp.host.tool.resume.user_approval_required", { - attributes: { - "mcp.tool.name": "resume", - "mcp.execute.execution_id": executionId, - }, - }), - ); - - const takeBrowserApprovalResponse = ( - executionId: string, - ): Effect.Effect => { - return config.browserApprovalStore?.takeResponse(executionId) ?? Effect.succeed(null); - }; - - const waitForBrowserApprovalResponse = ( - executionId: string, - ): Effect.Effect => { - const waitForResponse = config.browserApprovalStore?.waitForResponse; - if (!waitForResponse) return takeBrowserApprovalResponse(executionId); - - return waitForResponse(executionId).pipe( - Effect.timeoutOrElse({ - duration: Duration.millis(BROWSER_APPROVAL_WAIT_TIMEOUT_MS), - orElse: () => Effect.succeed(null), - }), - ); - }; - - const resumeAfterBrowserApproval = ( - executionId: string, - extra: McpRequestJoinKeys, - ): Effect.Effect => - Effect.gen(function* () { - yield* startMarker("mcp.host.tool.resume.browser_approval.start", { - "mcp.tool.name": "resume", - "mcp.execute.execution_id": executionId, - }); - const response = yield* waitForBrowserApprovalResponse(executionId); - if (!response) return yield* requireUserResumeApproval(executionId); - - const outcome = yield* resumeWithLifecycle(executionId, response); - if (!outcome) { - return missingExecutionResult(executionId); - } - if (outcome.status === "paused") { - const deadline = pauseDeadline(); - yield* Effect.annotateCurrentSpan({ - "mcp.execute.paused": true, - "mcp.execute.paused_execution_id": outcome.execution.id, - "mcp.execute.pause_source": "browser_resume", - }); - yield* onExecutionPaused(outcome.execution.id, deadline); - } - return outcome.status === "completed" - ? toMcpResult(outcome.result) - : yield* requireUserResumeApproval(outcome.execution.id); - }).pipe( - Effect.withSpan("mcp.host.tool.resume.browser_approval", { - attributes: { - "mcp.tool.name": "resume", - "mcp.execute.execution_id": executionId, - }, - }), - Effect.annotateSpans(joinKeyAttributes(extra)), - ); - - // --- tools --- - - yield* Effect.sync(() => - server.registerTool( - "execute", - { - description, - inputSchema: { code: z.string().trim().min(1) }, - }, - ({ code }, extra) => runToolEffect(executeCode(code, extra)), - ), - ).pipe( - Effect.withSpan("mcp.host.register_tool", { - attributes: { "mcp.tool.name": "execute" }, - }), +): ExecutorMcpAssembly => { + const sessionful = config.sessionful ?? false; + const initialAppsEnabled = sessionful + ? (config.restoredAppsEnabled ?? config.appsEnabled) + : config.appsEnabled; + const requestStateCodec = (binding: string) => + createRequestStateCodec({ + key: config.requestStateSigningKey, + ...(config.requestStateTtlSeconds === undefined + ? {} + : { ttlSeconds: config.requestStateTtlSeconds }), + bind: () => binding, + }); + const verifyRequestState = async (state: string, context: ServerContext) => { + const binding = await requestStateBindingForContext( + config.requestStateBinding, + config.requestStatePrincipal, + context, ); + const decoded = await requestStateCodec(binding).verify(state, context); + return Effect.runPromise(Schema.decodeUnknownEffect(NativeRequestStateSchema)(decoded)); + }; + const server = new McpServer( + { name: "executor", version: "1.0.0" }, + { + capabilities: { resources: {}, tools: {} }, + requestState: { verify: verifyRequestState }, + }, + ); - yield* Effect.sync(() => - server.registerTool( - "skills", - { - description: [ - "Fetch a named how-to skill. Skills hold the long-form guidance that would otherwise bloat another tool's always-loaded description.", - 'Call `skills({ name: "execute" })` for the full guide to writing code for the `execute` tool (search the catalog, call tools, emit results, resume paused runs).', - "Call with no name to list the available skills.", - ].join("\n"), - inputSchema: { - name: z - .string() - .optional() - .describe('The skill to fetch, e.g. "execute". Omit to list available skills.'), - }, - }, - ({ name }) => - runToolEffect(Effect.succeed(skillsResult(name, executeInventory, skillCatalog))), - ), - ).pipe( - Effect.withSpan("mcp.host.register_tool", { - attributes: { "mcp.tool.name": "skills" }, - }), + const registerTool: ExecutorMcpAssembly["registerTool"] = ( + name, + toolConfig, + callback, + ) => { + const inputSchema = z.object(toolConfig.inputSchema); + return server.registerTool, typeof inputSchema>( + name, + { ...toolConfig, inputSchema }, + async (args, context) => toolResult(await callback(args, requestJoinKeys(context))), ); + }; - yield* Effect.sync(() => { - if (elicitationMode.mode === "native") { - return undefined; - } - - if (elicitationMode.mode === "model") { - return server.registerTool( - "resume", - { - description: [ - "Resume a paused execution using the executionId returned by execute.", - "This connection explicitly allows model-side resume via elicitation_mode=model.", - ].join("\n"), - inputSchema: { - executionId: z.string().describe("The execution ID from the paused result"), - action: z - .enum(["accept", "decline", "cancel"]) - .describe("How to respond to the interaction"), - content: z - .string() - .describe("Optional JSON-encoded response content for form elicitations") - .default("{}"), - }, - }, - ({ executionId, action, content: rawContent }, extra) => - runToolEffect( - resumeExecution(executionId, action, parseJsonContent(rawContent), extra), - ), - ); - } - - return server.registerTool( - "resume", + const registerApp: ExecutorMcpAssembly["registerAppTool"] = ( + name, + toolConfig, + callback, + ) => { + const inputSchema = z.object(toolConfig.inputSchema); + const metadata = normalizedAppMetadata(toolConfig._meta); + if (!sessionful && !config.appsEnabled && visibilityIncludes(metadata, "model")) { + const plainMetadata = withoutAppMetadata(metadata); + return server.registerTool, typeof inputSchema>( + name, { - description: [ - "Request user approval to resume a paused execution.", - "Call this with the executionId returned by execute. If the user has not approved in the browser yet, tell them to open the returned approval URL. If they have approved, this returns the resumed execution result.", - "This connection does not allow the model to choose accept, decline, cancel, or content.", - ].join("\n"), - inputSchema: { - executionId: z.string().describe("The execution ID from the paused result"), - }, + ...toolConfig, + inputSchema, + ...(Object.keys(plainMetadata).length === 0 + ? { _meta: undefined } + : { _meta: plainMetadata }), }, - ({ executionId }, extra) => runToolEffect(resumeAfterBrowserApproval(executionId, extra)), + async (args, context) => toolResult(await callback(args, requestJoinKeys(context))), ); - }).pipe( - Effect.withSpan("mcp.host.register_tool", { - attributes: { "mcp.tool.name": "resume" }, - }), - ); - - // --- artifacts / MCP Apps --- - // - // These register unconditionally once a shell loader is configured. Whether - // the client can actually *render* an app is only known after `initialize`, - // so the app-only tools are toggled in `syncToolAvailability` below; the - // model-facing three stay enabled either way and fall back to a deep link. - - const artifacts = config.artifacts; - - // Set from the client's advertised capabilities at `initialize`. Read by - // the render handlers to choose inline widget vs. deep link. Seeded from - // the host's persisted value so a cold-restored session keeps rendering - // inline for a client that had already negotiated apps support. - // - // This is a cache, not the source of truth: `appsSupported()` below reads - // the live server on every render, because a cold restore re-establishes - // capabilities without ever running the hook that maintains this variable. - let appsEnabled = config.restoredAppsEnabled ?? false; - let executeActionTool: { enable: () => void; disable: () => void } | undefined; - let executeActionResumeTool: { enable: () => void; disable: () => void } | undefined; + } - /** - * Move the cached flag and the app-only tools together. - * - * `execute-action` is only callable from inside a rendered app, so a client - * that can't render one should never see it. `create-artifact`, - * `list-artifacts` and `show-artifact` stay visible regardless: they still - * persist, and still return something useful (a deep link). - */ - const applyAppsEnabled = (next: boolean): void => { - appsEnabled = next; - if (next) { - executeActionTool?.enable(); - executeActionResumeTool?.enable(); - } else { - executeActionTool?.disable(); - executeActionResumeTool?.disable(); - } - }; + return registerAppTool>( + server, + name, + { ...toolConfig, inputSchema, _meta: metadata }, + async (args, context) => toolResult(await callback(args, requestJoinKeys(context))), + ); + }; - // Best-effort usage observation; a failing observer never affects the tool. - const notifyArtifactUsage = (action: "created" | "viewed" | "updated"): Effect.Effect => - config.onArtifactUsage - ? config.onArtifactUsage(action).pipe(Effect.ignoreCause({ log: false })) - : Effect.void; + const nativeInputRequired = async ( + services: NativeExecutionServices, + execution: Parameters< + NativeExecutionServices["executionPaused"] + >[0], + ): Promise => { + const binding = await requestStateBindingForContext( + config.requestStateBinding, + config.requestStatePrincipal, + services.requestContext.serverContext, + ); + const requestState = await requestStateCodec(binding).mint( + { executionId: execution.id }, + services.requestContext.serverContext, + ); + return inputRequired({ + inputRequests: { + [NATIVE_ELICITATION_RESPONSE_KEY]: elicitationInputRequest( + execution.elicitationContext.request, + ), + }, + requestState, + }); + }; - const saveAndDeliverArtifact = (input: { - readonly code: string; - readonly title: string; - readonly description?: string; - readonly existingId?: string; - readonly bindings?: Readonly>; - /** Sanitized layout markup from the smoke render, when it produced any. */ - readonly preview?: string | null; - }): Effect.Effect => - Effect.gen(function* () { - if (!artifacts) return artifactsUnavailableResult(); - const saved = yield* artifacts.save({ - ...(input.existingId === undefined ? {} : { id: input.existingId }), - title: input.title, - description: input.description ?? null, - code: input.code, - ...(input.bindings === undefined ? {} : { bindings: input.bindings }), - preview: input.preview ?? null, - }); - yield* notifyArtifactUsage(input.existingId === undefined ? "created" : "updated"); - // Resolve once and report the value actually used, so the span can - // never disagree with what the client received. - const delivered = deliverArtifact({ - code: saved.code, - artifactId: saved.id, - title: saved.title, - }); - yield* Effect.annotateCurrentSpan({ - "mcp.artifact.id": saved.id, - "mcp.artifact.apps_enabled": appsEnabled, - }); - return delivered; + return { + server, + initialAppsEnabled, + getClientCapabilities: () => + sessionful ? (server.server.getClientCapabilities() ?? null) : null, + getElicitationSupport: () => + sessionful + ? elicitationSupportFromUnknown(server.server.getClientCapabilities()) + : { form: true, url: true }, + getUiCapability: () => + sessionful + ? getUiCapability(appsClientCapabilitiesFromUnknown(server.server.getClientCapabilities())) + : config.appsEnabled + ? { mimeTypes: [RESOURCE_MIME_TYPE] } + : undefined, + onInitialized: (callback) => { + if (sessionful) server.server.oninitialized = callback; + }, + registerTool, + registerAppTool: registerApp, + registerAppResource: (name, uri, resourceConfig, callback) => { + if (!sessionful && !config.appsEnabled) return; + registerAppResource(server, name, uri, resourceConfig, async () => { + const result = await callback(); + return { contents: [...result.contents] }; }); - - /** - * Whether the client can render an app, resolved at render time. - * - * `appsEnabled` alone is not enough. On a cold restore the host replays the - * persisted `initialize` *request* — which does set the server's client - * capabilities — but never the `notifications/initialized` notification, - * and `oninitialized` (the only hook that re-runs `syncToolAvailability`) - * fires solely on that notification. So a restored session can hold full - * apps capabilities while `appsEnabled` still reads its seeded value, and - * the replay is dispatched un-awaited, so a tool call can land before it. - * - * Reading the live server here makes both orderings produce the same - * answer, and keeps the seeded value as the fallback for the window before - * any capabilities exist. - */ - const appsSupported = (): boolean => { - const live = server.server.getClientCapabilities(); - if (!live) return appsEnabled; - const uiCapability = getUiCapability( - live as ClientCapabilities & { extensions?: Record }, - ); - const supported = Boolean(uiCapability?.mimeTypes?.includes(RESOURCE_MIME_TYPE)); - // Reconcile the tools too: a restore that re-established capabilities - // without firing `oninitialized` would otherwise render inline while - // `execute-action` — the tool that rendered app calls back into — stayed - // hidden, leaving the widget unable to do anything. - if (supported !== appsEnabled) applyAppsEnabled(supported); - return supported; - }; - - const deliverArtifact = (input: { - readonly code: string; - readonly artifactId: string; - readonly title: string; - }): McpToolResult => { - const url = config.artifactUrl?.(input.artifactId); - if (appsSupported()) return renderedInAppResult({ ...input, url }); - return url - ? renderedAsLinkResult({ url, artifactId: input.artifactId, title: input.title }) - : renderedWithoutSurfaceResult({ artifactId: input.artifactId, title: input.title }); - }; - - /** - * The shared back half of `create-artifact` and `edit-artifact`: everything - * that happens once the full candidate source is in hand. Static checks, - * the smoke render, binding and the save are identical whether the code - * arrived whole or was assembled from stored source plus edits — sharing - * the pipeline is what guarantees an edit cannot save anything a create - * would have refused. - */ - const validateRenderAndSave = (input: { - readonly code: string; - readonly title: string; - readonly description?: string | undefined; - readonly connections?: Readonly> | undefined; - readonly existing: Artifact | null; - }): Effect.Effect => + }, + executeNative: ( + services: NativeExecutionServices, + ) => Effect.gen(function* () { - const rejection = validateArtifactCode(input.code); - if (rejection) return renderRejectedResult(rejection); + const decodedState = decodeNativeRequestState( + services.requestContext.serverContext.mcpReq.requestState(), + ); - // Static checks first, then the real one: render it. See - // `smokeRenderRejection` for what the model is told. - // - // FAIL OPEN. The renderer is injected, runs on three different hosts, - // and is the newest thing in this path — if IT breaks (a missing - // module, an environment gap on some host), the right outcome is a - // saved artifact and a logged warning, never a refused create of code - // that is perfectly good. Only a definite `failed` blocks a save. - const smoke = config.smokeRenderArtifact; - // The render that validates the artifact is also the render that - // previews it: the same pass produces the loading-state markup the - // gallery draws, so a preview costs nothing beyond sanitizing it. - let preview: string | null = null; - if (smoke) { - const smokeResult: ArtifactSmokeRenderResult = yield* Effect.tryPromise(() => - smoke(input.code), - ).pipe( - Effect.catchCause((cause) => - Effect.as(Effect.logWarning("create-artifact smoke render was unavailable", cause), { - status: "ok", - } satisfies ArtifactSmokeRenderResult), - ), + if (Option.isSome(decodedState)) { + const paused = yield* services.engine.getPausedExecution(decodedState.value.executionId); + if (!paused) return missingNativeExecution(decodedState.value.executionId); + const response = inputResponse( + services.requestContext.serverContext.mcpReq.inputResponses, + NATIVE_ELICITATION_RESPONSE_KEY, ); - const renderRejection = smokeRenderRejection(smokeResult); - if (renderRejection) { - yield* Effect.annotateCurrentSpan({ "mcp.artifact.smoke_render": "failed" }); - return renderRejectedResult(renderRejection); + if (response.kind === "elicit") { + const content = Match.value(paused.elicitationContext.request).pipe( + Match.tag("UrlElicitation", () => response.content), + Match.tag("FormElicitation", (form) => + response.action === "accept" + ? acceptedContent( + services.requestContext.serverContext.mcpReq.inputResponses, + NATIVE_ELICITATION_RESPONSE_KEY, + fromJsonSchema>( + Object.keys(form.requestedSchema).length === 0 + ? { type: "object", properties: {} } + : form.requestedSchema, + ), + ) + : response.content, + ), + Match.exhaustive, + ); + if (response.action === "accept" && content === undefined) { + return yield* Effect.promise(() => nativeInputRequired(services, paused)); + } + const outcome = yield* services.resume(decodedState.value.executionId, { + action: response.action, + content, + }); + if (!outcome) return missingNativeExecution(decodedState.value.executionId); + if (outcome.status === "completed") return services.complete(outcome.result); + yield* services.executionPaused(outcome.execution); + return yield* Effect.promise(() => nativeInputRequired(services, outcome.execution)); } - // Fail open, exactly as the verdict does: a preview that cannot be - // produced or cannot be sanitized is a card that falls back to its - // schematic, never a create that is refused. - preview = - smokeResult.status === "ok" && smokeResult.markup !== undefined - ? sanitizeArtifactPreviewMarkup(smokeResult.markup) - : null; - } - - const saveInput = { - code: input.code, - title: input.title, - preview, - ...(input.description === undefined ? {} : { description: input.description }), - ...(input.existing === null ? {} : { existingId: input.existing.id }), - }; - const roles = extractArtifactRoles(input.code); - if (roles.length === 0 && input.connections === undefined) { - return yield* saveAndDeliverArtifact({ ...saveInput, bindings: {} }); + return yield* Effect.promise(() => nativeInputRequired(services, paused)); } - if (!config.connections) { - return renderRejectedResult( - "This connection cannot bind integrations, so an artifact that calls one cannot be saved here.", - ); - } - - const available = yield* config.connections - .list() - .pipe(Effect.catchCause(() => Effect.succeed([] as readonly BindableConnection[]))); - const resolved = resolveArtifactBindings({ - roles, - connections: input.connections, - available, - }); - if (!resolved.ok) return renderRejectedResult(resolved.message); - - yield* Effect.annotateCurrentSpan({ - "mcp.artifact.role_count": roles.length, - }); - return yield* saveAndDeliverArtifact({ ...saveInput, bindings: resolved.bindings }); - }); - - /** - * Bind the integration roles an artifact's code uses, at create time. - * - * Binding happens HERE rather than at render time because this is the only - * moment the author, the code and their connections are all in hand — and - * because a create that can't bind is a create that would have saved a - * broken artifact. The model finds out now, with the candidate list, rather - * than the user finding out later through a query error inside the UI. - * - * `artifactId` turns the same call into an update in place — for a REWRITE, - * where the new source shares little with the old and edits would be longer - * than the code. A tweak belongs on `edit-artifact`, which patches the - * stored source instead of replacing it. Either way one row is kept: a copy - * per revision is the thing the model has to ask for, never the default. - * - * An update replaces the code outright — v1 keeps no version history — and - * re-extracts and re-resolves the bindings from the NEW source, because the - * roles the new code uses are not necessarily the ones the old code did. - * `title` and `description` are optional on an update and absent means keep - * what is stored, so a pure code tweak doesn't have to restate them. - */ - const createArtifact = (input: { - readonly code: string; - readonly title?: string; - readonly description?: string; - readonly connections?: Readonly>; - readonly artifactId?: string; - }): Effect.Effect => - Effect.gen(function* () { - // An update reads the existing row FIRST, both to carry its title and - // description forward and to refuse a foreign id before any work. The - // refusal is `artifact_unavailable` — the same answer `execute-action` - // gives — so create-artifact cannot be used to probe which ids exist. - const existing = - input.artifactId === undefined ? null : yield* loadArtifact(input.artifactId); - if (input.artifactId !== undefined && !existing) return actionArtifactUnavailableResult(); - - const title = input.title ?? existing?.title; - if (title === undefined) { - return renderRejectedResult( - "title is required when creating an artifact. Give it a short human-readable name.", - ); - } - // Only an update inherits; a create with no description stores none. - const description = input.description ?? existing?.description ?? undefined; - - return yield* validateRenderAndSave({ - code: input.code, - title, - description, - connections: input.connections, - existing, - }); - }).pipe( - Effect.withSpan("mcp.host.tool.create_artifact", { - attributes: { - "mcp.tool.name": "create-artifact", - "mcp.artifact.update": input.artifactId !== undefined, - "mcp.execute.code_length": input.code.length, - }, - }), - ); - - /** - * `edit-artifact`: the update path for tweaks, patching the stored source - * with exact find-and-replace edits so the call scales with the change - * rather than the component. The edited result runs the same - * validate → smoke-render → bind → save pipeline as a full create, so an - * edit cannot save anything a create would have refused. - * - * A failed edit hands the CURRENT source back in `structuredContent.code`. - * The model's usual recovery — `show-artifact`, re-read, retry — is a whole - * extra round trip to fetch a thing this call already loaded; giving it - * back here makes the retry immediate. - */ - const editArtifact = (input: { - readonly artifactId: string; - readonly edits: readonly ArtifactEdit[]; - readonly title?: string; - readonly description?: string; - readonly connections?: Readonly>; - }): Effect.Effect => - Effect.gen(function* () { - // Same probe-proof refusal as create-artifact's update arm. - const existing = yield* loadArtifact(input.artifactId); - if (!existing) return actionArtifactUnavailableResult(); - - const applied = applyArtifactEdits(existing.code, input.edits); - if (!applied.ok) return editRejectedResult(applied.message, existing.code); - - yield* Effect.annotateCurrentSpan({ - "mcp.artifact.edit_count": input.edits.length, - }); - return yield* validateRenderAndSave({ - code: applied.code, - title: input.title ?? existing.title, - description: input.description ?? existing.description ?? undefined, - connections: input.connections, - existing, - }); - }).pipe( - Effect.withSpan("mcp.host.tool.edit_artifact", { - attributes: { - "mcp.tool.name": "edit-artifact", - "mcp.artifact.id": input.artifactId, - }, - }), - ); - - const listArtifacts = (): Effect.Effect => - Effect.gen(function* () { - if (!artifacts) return artifactsUnavailableResult(); - return artifactListResult(yield* artifacts.list()); - }).pipe( - Effect.withSpan("mcp.host.tool.list_artifacts", { - attributes: { "mcp.tool.name": "list-artifacts" }, - }), - ); - - const showArtifact = (id: string): Effect.Effect => - Effect.gen(function* () { - if (!artifacts) return artifactsUnavailableResult(); - // A miss is the ordinary case (the model guessed an id, or the row was - // deleted), so it becomes an isError result rather than a defect. - const artifact: Artifact | null = yield* artifacts - .get(id) - .pipe(Effect.catchCause(() => Effect.succeed(null))); - if (!artifact) return artifactNotFoundResult(id); - yield* notifyArtifactUsage("viewed"); - return deliverArtifact({ - code: artifact.code, - artifactId: artifact.id, - title: artifact.title, - }); - }).pipe( - Effect.withSpan("mcp.host.tool.show_artifact", { - attributes: { "mcp.tool.name": "show-artifact", "mcp.artifact.id": id }, - }), - ); - - // Two independent reasons to serve no artifact surface: the host cannot - // (no shell loader), or this connection opted out (`?artifacts=false`). - // Either way nothing below registers, so a disabled session is byte-for-byte - // a session on a host that never had artifacts. - const loadAppShellHtml = artifactsEnabled ? config.loadAppShellHtml : undefined; - - if (loadAppShellHtml) { - yield* Effect.sync(() => { - registerAppResource( - server, - "Executor Shell", - MCP_APPS_SHELL_RESOURCE_URI, - { mimeType: RESOURCE_MIME_TYPE }, - async () => ({ - contents: [ - { - uri: MCP_APPS_SHELL_RESOURCE_URI, - mimeType: RESOURCE_MIME_TYPE, - text: await loadAppShellHtml(), - // Zero allowed domains: the shell may open no network - // connection of its own. Every read and write goes back over - // the MCP bridge through `execute-action`. - _meta: { ui: { csp: { connectDomains: [], resourceDomains: [] } } }, - }, - ], - }), - ); - }).pipe( - Effect.withSpan("mcp.host.register_resource", { - attributes: { "mcp.resource.uri": MCP_APPS_SHELL_RESOURCE_URI }, - }), - ); - - yield* Effect.sync(() => - registerAppTool( - server, - "create-artifact", - { - description: [ - "Render an interactive React UI component as an MCP app, and save it as a reusable artifact.", - 'Call `skills({ name: "create-artifact" })` for the full guide: the discovery-then-render protocol, TanStack Query rules, and every component already in scope. Call `skills({ name: "artifact-style" })` for how it must look — artifacts render inside the Executor console and must match its design system.', - "Write a component named `App` in `code`. Do not import anything and do not paste fetched data into JSX — read it live with `useQuery(tools...queryOptions(args))`.", - "Lay it out as an app, not a document: an artifact may be given the whole viewport, so make the root `flex h-full flex-col`, keep headers and filters as ordinary children, and give the one long table or list `flex-1 min-h-0 overflow-auto` — its header then stays put while the rows scroll under it.", - "Artifact code addresses an INTEGRATION, never a connection: write `tools.vercel.domains.getDomains`, not the full `tools.vercel.user.personalVercel.domains.getDomains` address `execute` uses for discovery. The connection is bound when the artifact is saved, so it stays portable. Code containing a `.user.` or `.org.` segment is rejected.", - 'To use two accounts of the same integration, tag each call site with a role — `tools.linear("prod").issues.list` and `tools.linear("staging").issues.list` — and map every role in `connections`.', - "All data access is declarative `tools.*`: `.queryOptions()` to read, `.infiniteQueryOptions()` to page through a cursor, `.mutationOptions()` to write. There is no `run()` and no arbitrary code — never hand-roll `useQuery({ queryKey, queryFn })`, or invalidation breaks.", - "To read every page of a paginated tool, call `useInfiniteQuery(tools...infiniteQueryOptions(args, { cursorKey, getNextPageParam }))` once and render `data.pages`. Never call hooks inside a loop — a `useQuery` per page is rejected.", - "To CHANGE an artifact that already exists, use `edit-artifact` — it patches the stored source with find-and-replace edits, so a tweak costs only the changed lines. Only use create-artifact with `artifactId` for a full rewrite, sending the complete new component. Never create a second artifact for a revision of an existing one.", - "Clients that cannot display MCP apps receive a link to the saved artifact instead; pass it to the user.", - ].join("\n"), - inputSchema: { - code: z.string().trim().min(1).describe("The React component source. Export `App`."), - artifactId: z - .string() - .trim() - .min(1) - .optional() - .describe( - "The artifact to REWRITE in place, from `list-artifacts` or a previous create. Omit to create a new one. `code` fully replaces the stored source and the connection bindings are re-resolved from it, so send the complete component, not a fragment. For a tweak, use `edit-artifact` instead.", - ), - connections: z - .record(z.string(), z.string()) - .optional() - .describe( - 'Which connection each integration role in `code` uses, as `..` (the address `connections.list` reports, minus the leading `tools.`). Keys are roles: the integration slug for an untagged `tools.linear.…`, or the tag for `tools.linear("prod").…`. Optional when you have exactly one connection per integration used — that one binds automatically. Required when you have several, and the error lists them.', - ), - title: z - .string() - .trim() - .min(1) - .optional() - .describe( - 'Short human-readable name for the artifact, e.g. "Active users dashboard". The user sees this and you match against it later. Required when creating; on an update, omit it to keep the current title.', - ), - description: z - .string() - .optional() - .describe( - "What this UI shows, in a sentence. Used to find the artifact again on a later request. On an update, omit it to keep the current description.", - ), - }, - _meta: { - ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["model"] }, - }, - }, - ({ code, title, description, connections, artifactId }) => - runToolEffect(createArtifact({ code, title, description, connections, artifactId })), - ), - ).pipe( - Effect.withSpan("mcp.host.register_tool", { - attributes: { "mcp.tool.name": "create-artifact" }, - }), - ); - - yield* Effect.sync(() => - registerAppTool( - server, - "edit-artifact", - { - description: [ - "Change an existing artifact by patching its stored source with exact find-and-replace edits, and re-render it.", - "PREFER THIS over create-artifact for tweaks — a new column, a fixed label, a restyled section — because you send only the changed lines, not the whole component. Use create-artifact with `artifactId` only for a rewrite where most of the code changes.", - "Each edit's `oldText` must appear EXACTLY ONCE in the current source, verbatim (whitespace included); include enough surrounding lines to make it unique, or set `replaceAll: true` to change every occurrence. Edits apply in order, each seeing the previous one's result.", - "The batch is atomic: if any edit fails to match, nothing is saved and the error returns the current source in structuredContent.code — rebuild the edits from that instead of calling show-artifact again.", - "The edited component is validated and smoke-rendered exactly like a create, and connection bindings are re-resolved from the result; pass `connections` if an edit introduces an ambiguous integration.", - ].join("\n"), - inputSchema: { - artifactId: z - .string() - .trim() - .min(1) - .describe("The artifact to edit, from `list-artifacts` or a previous create."), - edits: z - .array( - z.object({ - oldText: z - .string() - .min(1) - .describe( - "Exact text to find in the current source, whitespace included. Must match exactly once unless replaceAll is true.", - ), - newText: z.string().describe("The replacement text."), - replaceAll: z - .boolean() - .optional() - .describe("Replace every occurrence instead of requiring a unique match."), - }), - ) - .min(1) - .describe("Find-and-replace edits, applied in order. All-or-nothing."), - connections: z - .record(z.string(), z.string()) - .optional() - .describe( - "Connection for each integration role the EDITED code uses, exactly as on create-artifact. Only needed when an edit introduces an integration with several connections.", - ), - title: z - .string() - .trim() - .min(1) - .optional() - .describe("New title. Omit to keep the current one."), - description: z - .string() - .optional() - .describe("New description. Omit to keep the current one."), - }, - _meta: { - ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["model"] }, - }, - }, - ({ artifactId, edits, connections, title, description }) => - runToolEffect(editArtifact({ artifactId, edits, connections, title, description })), - ), - ).pipe( - Effect.withSpan("mcp.host.register_tool", { - attributes: { "mcp.tool.name": "edit-artifact" }, - }), - ); - - yield* Effect.sync(() => - server.registerTool( - "list-artifacts", - { - description: [ - "List the saved UI artifacts for this account, newest first.", - "Match the user's phrasing against the returned titles and descriptions, then call `show-artifact` with that id.", - ].join("\n"), - inputSchema: {}, - }, - () => runToolEffect(listArtifacts()), - ), - ).pipe( - Effect.withSpan("mcp.host.register_tool", { - attributes: { "mcp.tool.name": "list-artifacts" }, - }), - ); - - yield* Effect.sync(() => - registerAppTool( - server, - "show-artifact", - { - description: [ - "Re-render a saved UI artifact by id.", - "Use `list-artifacts` first to find the id whose title or description matches what the user asked for.", - "Clients that cannot display MCP apps receive a link to the artifact instead.", - ].join("\n"), - inputSchema: { - id: z.string().trim().min(1).describe("The artifact id from `list-artifacts`."), - }, - _meta: { - ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["model"] }, - }, - }, - ({ id }) => runToolEffect(showArtifact(id)), - ), - ).pipe( - Effect.withSpan("mcp.host.register_tool", { - attributes: { "mcp.tool.name": "show-artifact" }, - }), - ); - - yield* Effect.sync(() => { - executeActionTool = registerAppTool( - server, - "execute-action", - { - description: - "Execute code from the UI shell. Used by interactive components to call tools and run mutations.", - inputSchema: { - code: z.string().trim().min(1), - artifactId: z - .string() - .trim() - .min(1) - .optional() - .describe( - "The artifact making the call. Its stored bindings resolve the integration role in `code` to a connection.", - ), - }, - _meta: { - ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["app"] }, - }, - }, - ({ code, artifactId }, extra) => - runToolEffect(executeCodeFromApp(code, artifactId, extra)), - ); - - executeActionResumeTool = registerAppTool( - server, - "execute-action-resume", - { - description: "Resume an interactive UI action after shell-owned user approval.", - inputSchema: { - executionId: z.string().describe("The execution ID from the paused UI action"), - action: z - .enum(["accept", "decline", "cancel"]) - .describe("How to respond to the interaction"), - content: z - .string() - .describe("Optional JSON-encoded response content for form elicitations") - .default("{}"), - }, - _meta: { - ui: { resourceUri: MCP_APPS_SHELL_RESOURCE_URI, visibility: ["app"] }, - }, - }, - ({ executionId, action, content: rawContent }, extra) => - runToolEffect( - resumeExecution(executionId, action, parseJsonContent(rawContent), extra), - ), - ); - }).pipe( - Effect.withSpan("mcp.host.register_tool", { - attributes: { "mcp.tool.name": "execute-action" }, - }), - ); - } - - // Client capabilities only exist after `initialize`, and `tools/list` is - // answered from whatever is registered at that moment — so app-only tool - // visibility has to be re-synced from the `oninitialized` hook rather than - // decided at construction. - // - // This hook covers live clients only. It does NOT run on a cold restore: - // the host replays the persisted `initialize` request, but `oninitialized` - // fires on the `notifications/initialized` notification, which is never - // persisted. `appsSupported()` is what makes the restored case correct. - const syncToolAvailability = () => { - const clientCapabilities = server.server.getClientCapabilities(); - const uiCapability = getUiCapability( - clientCapabilities as - | (ClientCapabilities & { extensions?: Record }) - | null, - ); - // Absent capabilities (the SDK returns `undefined`) mean `initialize` - // hasn't happened on THIS server instance — the construction-time call - // below, or a cold restore that resumed mid-conversation. Neither is - // evidence the client lost apps support, so the restored value stands - // until a real `initialize` replaces it. Reading `false` off an absent - // value here is exactly what made a cold-restored session fall back to - // deep links. - const negotiated = clientCapabilities - ? Boolean(uiCapability?.mimeTypes?.includes(RESOURCE_MIME_TYPE)) - : appsEnabled; - const changed = negotiated !== appsEnabled; - applyAppsEnabled(negotiated); - - // Persist only a real negotiation that moved the value, so the next cold - // restore seeds itself. Best-effort: the session must not fail on it. - // The `clientCapabilities` guard matters beyond skipping a no-op write: - // persisting an absent-capability reading would make a downgrade durable - // for every future restore of the session. - const onAppsEnabledChange = config.onAppsEnabledChange; - if (clientCapabilities && changed && onAppsEnabledChange) { - // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: `oninitialized` is a sync SDK hook; persistence is fire-and-forget and its failure must not fail the session - void Effect.runPromiseWith(context)( - onAppsEnabledChange(negotiated).pipe(Effect.ignoreCause({ log: false })), - ); - } - - console.error( - "[executor] MCP session mode", - JSON.stringify({ - ...capabilitySnapshot(server), - elicitationMode: elicitationMode.mode, - resumeEnabled: elicitationMode.mode !== "native", - }), - ); - debugLog("tool.visibility", { - clientCapabilities: clientCapabilities ?? null, - elicitationSupport: getElicitationSupport(server), - elicitationMode: elicitationMode.mode, - resumeEnabled: elicitationMode.mode !== "native", - appsSupport: uiCapability ?? null, - appsEnabled, - executeActionEnabled: appsEnabled, - }); - }; - - yield* Effect.sync(() => { - syncToolAvailability(); - server.server.oninitialized = syncToolAvailability; - }).pipe(Effect.withSpan("mcp.host.sync_tool_availability")); + const outcome = yield* services.engine.executeWithPause(services.code); + if (outcome.status === "completed") return services.complete(outcome.result); + yield* services.executionPaused(outcome.execution); + return yield* Effect.promise(() => nativeInputRequired(services, outcome.execution)); + }), + }; +}; - return server; - }).pipe(Effect.withSpan("mcp.host.create_executor_server")); +/** + * Build one Executor MCP server. + * + * Stateless hosts must reuse the signing key across every request that can + * participate in the same native-elicitation continuation flow. Sessionful + * hosts keep one instance connected and may use a connection-lifetime key. + */ +export const buildMcpServer = ( + config: ExecutorMcpServerConfig, +): Effect.Effect => buildExecutorMcpTools(config, () => createMcpAssembly(config)); diff --git a/packages/plugins/mcp/src/testing/server.ts b/packages/plugins/mcp/src/testing/server.ts index dceac0db2a..a59a7e1610 100644 --- a/packages/plugins/mcp/src/testing/server.ts +++ b/packages/plugins/mcp/src/testing/server.ts @@ -1,5 +1,6 @@ import { Context, Data, Effect, Layer, Option, Ref, Schema, Scope } from "effect"; import * as http from "node:http"; +// Intentionally stays on the legacy SDK as a wire-interop fixture for MCP plugin clients. import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; diff --git a/patches/agents@0.17.3.patch b/patches/agents@0.17.3.patch deleted file mode 100644 index df7396f95d..0000000000 --- a/patches/agents@0.17.3.patch +++ /dev/null @@ -1,911 +0,0 @@ -diff --git a/node_modules/agents/.bun-tag-c0c639aa2299e502 b/.bun-tag-c0c639aa2299e502 -new file mode 100644 -index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 -diff --git a/dist/agent-tool-types-CNyE1iz_.d.ts b/dist/agent-tool-types-CNyE1iz_.d.ts -index 571eececebd5a1eaf7f2fbf5278801c4d34728ba..317e526489fcd3fd477a50525da0df666253bf0b 100644 ---- a/dist/agent-tool-types-CNyE1iz_.d.ts -+++ b/dist/agent-tool-types-CNyE1iz_.d.ts -@@ -480,7 +480,10 @@ declare class DurableObjectEventStore implements EventStore { - private readonly seqByStream; - private readonly seqInit; - constructor(storage: DurableObjectStorage); -- storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise; -+ /** Resolves `undefined` for a message too large for DO storage's 128 KiB -+ * per-value cap: the event is delivered live without a replay id rather -+ * than failing the send. */ -+ storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise; - getStreamIdForEventId(eventId: EventId): Promise; - replayEventsAfter( - lastEventId: EventId, -diff --git a/dist/mcp/index.d.ts b/dist/mcp/index.d.ts -index c8fad448e8797b89690a99d93490d1363851b225..77f9fe3f6f2375eadc9f7a2974f0d202fe75b3bd 100644 ---- a/dist/mcp/index.d.ts -+++ b/dist/mcp/index.d.ts -@@ -29,6 +29,7 @@ import { - xt as MCPClientOAuthCallbackConfig, - zt as ElicitResult - } from "../agent-tool-types-CNyE1iz_.js"; -+declare const MAX_SSE_AGE_MS = 1800000; - export { - type ClearableEventStore, - type CreateMcpHandlerOptions, -@@ -41,6 +42,7 @@ export { - type MCPConnectionResult, - type MCPDiscoverResult, - type MCPServerOptions, -+ MAX_SSE_AGE_MS, - MCP_SERVER_ID_MAX_LENGTH, - McpAgent, - type McpAuthContext, -diff --git a/dist/mcp/index.js b/dist/mcp/index.js -index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..444312ff41fbcff60e2ea681a9d5c756cd72faef 100644 ---- a/dist/mcp/index.js -+++ b/dist/mcp/index.js -@@ -28,13 +28,17 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/ - const KEEPALIVE_INTERVAL_MS = 25e3; - /** SSE comment frame the parser drops before any event dispatch. */ - const KEEPALIVE_FRAME = ": keepalive\n\n"; -+// Max age is a stalled-client memory backstop, not session idleness. Keep it -+// well above Executor's 5 minute session idle timeout so active clients rarely -+// rotate. -+const MAX_SSE_AGE_MS = 30 * 60 * 1000; - /** - * Start an SSE keepalive on `writer`. Returns a `clearInterval` handle - * that the stream cleanup must invoke when the stream closes. - */ --function startKeepalive(writer, encoder) { -+function startKeepalive(writeFrame, encoder) { - const handle = setInterval(() => { -- writer.write(encoder.encode(KEEPALIVE_FRAME)).catch(() => clearInterval(handle)); -+ writeFrame(encoder.encode(KEEPALIVE_FRAME)); - }, KEEPALIVE_INTERVAL_MS); - return handle; - } -@@ -180,10 +184,15 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => { - }); - return new Response(body, { status: 404 }); - } -- const { readable, writable } = new TransformStream(); -- const writer = writable.getWriter(); -- const encoder = new TextEncoder(); -- const existingHeaders = {}; -+ const { readable, writable } = new TransformStream(); -+ const writer = writable.getWriter(); -+ const encoder = new TextEncoder(); -+ const MAX_PENDING_SSE_BYTES = 8 * 1024 * 1024; -+ let __pendingBytes = 0; -+ let __writeChain = Promise.resolve(); -+ let __sseClosed = false; -+ let keepAlive; -+ const existingHeaders = {}; - request.headers.forEach((value, key) => { - existingHeaders[key] = value; - }); -@@ -206,45 +215,99 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => { - jsonrpc: "2.0" - }); - return new Response(body, { status: 500 }); -- } -- ws.accept(); -- if (messages.every((msg) => isJSONRPCNotification(msg) || isJSONRPCResultResponse(msg))) { -- ws.close(); -- return new Response(null, { -+ } -+ ws.accept(); -+ const __closeSse = () => { -+ if (__sseClosed) return; -+ __sseClosed = true; -+ try { -+ clearInterval(keepAlive); -+ } catch {} -+ try { -+ ws.close(1013, "SSE client not draining"); -+ } catch {} -+ writer.abort(new Error("SSE client not draining")).catch(() => {}); -+ }; -+ const __markSseClientClosed = () => { -+ if (__sseClosed) return; -+ __sseClosed = true; -+ try { -+ clearInterval(keepAlive); -+ } catch {} -+ writer.abort(new Error("SSE client disconnected")).catch(() => {}); -+ }; -+ writer.closed?.catch(() => { -+ __markSseClientClosed(); -+ }); -+ request.signal.addEventListener("abort", __closeSse, { once: true }); -+ const __forwardSse = (frame) => { -+ if (__sseClosed) return __writeChain; -+ if (__pendingBytes + frame.byteLength > MAX_PENDING_SSE_BYTES) { -+ __closeSse(); -+ return Promise.resolve(); -+ } -+ __pendingBytes += frame.byteLength; -+ __writeChain = __writeChain.then(() => writer.write(frame)).catch(() => { -+ __closeSse(); -+ }).finally(() => { -+ __pendingBytes -= frame.byteLength; -+ }); -+ return __writeChain; -+ }; -+ keepAlive = startKeepalive(__forwardSse, encoder); -+ if (messages.every((msg) => isJSONRPCNotification(msg) || isJSONRPCResultResponse(msg))) { -+ clearInterval(keepAlive); -+ ws.close(); -+ return new Response(null, { - headers: corsHeaders(request, options.corsOptions), - status: 202 -- }); -- } -- const keepAlive = startKeepalive(writer, encoder); -- ws.addEventListener("message", (event) => { -+ }); -+ } -+ ws.addEventListener("message", (event) => { - async function onMessage(event) { - try { -- const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data); -- const message = JSON.parse(data); -- if (message.type !== "cf_mcp_agent_event") return; -- await writer.write(encoder.encode(message.event)); -- if (message.close) { -- clearInterval(keepAlive); -- ws?.close(); -- await writer.close().catch(() => {}); -- } -+ const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data); -+ const message = JSON.parse(data); -+ if (message.type !== "cf_mcp_agent_event") return; -+ const writePromise = __forwardSse(encoder.encode(message.event)); -+ if (message.close) { -+ clearInterval(keepAlive); -+ await writePromise; -+ await writer.close(); -+ if (!__sseClosed && !request.signal.aborted) { -+ // workerd resolves writer.close() even when the client -+ // canceled the POST response body, and request.signal does -+ // not reliably fire for that cancellation, so a successful -+ // close is NOT proof of delivery. Never ack POST-stream -+ // deliveries: the DO keeps the response persisted and the -+ // client's own reconnect GET replays and acks it. A client -+ // that DID receive the result closes the POST body reader -+ // without a Last-Event-ID reconnect, and the SDK drops -+ // responses for request ids it no longer tracks, so the -+ // worst case of this at-least-once choice is a benign -+ // replay to a fresh GET, not a wedged tool call. -+ ws?.close(1000, "SSE response delivered"); -+ } -+ } - } catch (error) { - console.error("Error forwarding message to SSE:", error); - } - } - onMessage(event).catch(console.error); - }); -- ws.addEventListener("error", (error) => { -- async function onError(_error) { -- clearInterval(keepAlive); -- await writer.close().catch(() => {}); -+ ws.addEventListener("error", (error) => { -+ async function onError(_error) { -+ __sseClosed = true; -+ clearInterval(keepAlive); -+ await writer.close().catch(() => {}); - } - onError(error).catch(console.error); - }); -- ws.addEventListener("close", () => { -- async function onClose() { -- clearInterval(keepAlive); -- await writer.close().catch(() => {}); -+ ws.addEventListener("close", () => { -+ async function onClose() { -+ __sseClosed = true; -+ clearInterval(keepAlive); -+ await writer.close().catch(() => {}); - } - onClose().catch(console.error); - }); -@@ -279,10 +342,16 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => { - id: null, - jsonrpc: "2.0" - }), { status: 400 }); -- const { readable, writable } = new TransformStream(); -- const writer = writable.getWriter(); -- const encoder = new TextEncoder(); -- const agent = await getAgentByName(namespace, `streamable-http:${sessionId}`, { -+ const { readable, writable } = new TransformStream(); -+ const writer = writable.getWriter(); -+ const encoder = new TextEncoder(); -+ const __openedAt = Date.now(); -+ const MAX_PENDING_SSE_BYTES = 8 * 1024 * 1024; -+ let __pendingBytes = 0; -+ let __writeChain = Promise.resolve(); -+ let __sseClosed = false; -+ let keepAlive; -+ const agent = await getAgentByName(namespace, `streamable-http:${sessionId}`, { - props: ctx.props, - jurisdiction: options.jurisdiction - }); -@@ -306,27 +375,116 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => { - if (!ws) { - await writer.close(); - return new Response("Failed to establish WS to DO", { status: 500 }); -- } -- ws.accept(); -- ws.addEventListener("message", (event) => { -+ } -+ ws.accept(); -+ const __abortSse = () => { -+ if (__sseClosed) return; -+ __sseClosed = true; -+ try { -+ clearInterval(keepAlive); -+ } catch {} -+ try { -+ ws.close(1013, "SSE client not draining"); -+ } catch {} -+ writer.abort(new Error("SSE client not draining")).catch(() => {}); -+ }; -+ writer.closed?.catch(() => { -+ __abortSse(); -+ }); -+ request.signal.addEventListener("abort", __abortSse, { once: true }); -+ const __closeSseGracefully = () => { -+ if (__sseClosed) return __writeChain; -+ __sseClosed = true; -+ try { -+ clearInterval(keepAlive); -+ } catch {} -+ try { -+ const finalFrame = encoder.encode(": max-age rotation, reconnect\n\n"); -+ __writeChain = __writeChain.then(() => writer.write(finalFrame)).catch(() => {}).then(() => writer.close()).catch(() => { -+ writer.abort().catch(() => {}); -+ }); -+ } catch { -+ __writeChain = writer.close().catch(() => { -+ writer.abort().catch(() => {}); -+ }); -+ } -+ try { -+ ws.close(1000, "sse_max_age_rotation"); -+ } catch {} -+ return __writeChain; -+ }; -+ const __forwardSse = (frame) => { -+ if (__sseClosed) return __writeChain; -+ const ageMs = Date.now() - __openedAt; -+ if (ageMs > MAX_SSE_AGE_MS) { -+ console.log(JSON.stringify({ -+ event: "sse_max_age_close", -+ sessionId, -+ variant: "streamable-get", -+ ageMs, -+ pendingBytes: __pendingBytes -+ })); -+ __closeSseGracefully(); -+ return Promise.resolve(); -+ } -+ if (__pendingBytes + frame.byteLength > MAX_PENDING_SSE_BYTES) { -+ __abortSse(); -+ return Promise.resolve(); -+ } -+ __pendingBytes += frame.byteLength; -+ __writeChain = __writeChain.then(() => writer.write(frame)).catch(() => { -+ __abortSse(); -+ }).finally(() => { -+ __pendingBytes -= frame.byteLength; -+ }); -+ return __writeChain; -+ }; -+ keepAlive = startKeepalive(__forwardSse, encoder); -+ ws.addEventListener("message", (event) => { - try { - async function onMessage(ev) { -- const data = typeof ev.data === "string" ? ev.data : new TextDecoder().decode(ev.data); -- const message = JSON.parse(data); -- if (message.type !== "cf_mcp_agent_event") return; -- await writer.write(encoder.encode(message.event)); -- } -+ const data = typeof ev.data === "string" ? ev.data : new TextDecoder().decode(ev.data); -+ const message = JSON.parse(data); -+ if (message.type !== "cf_mcp_agent_event") return; -+ const writePromise = __forwardSse(encoder.encode(message.event)); -+ if (message.close) { -+ clearInterval(keepAlive); -+ await writePromise; -+ await writer.close(); -+ if (!__sseClosed && !request.signal.aborted) { -+ // Storage is cleared only on this ack, which fires only -+ // after writer.close() resolved with the client still -+ // attached; a replayed response enqueued into a dead GET -+ // never acks and stays replayable. `ackStreamIds` lets a -+ // replay-complete frame confirm several replayed streams -+ // at once; a live final response carries its `streamId`. -+ const ackStreamIds = Array.isArray(message.ackStreamIds) ? message.ackStreamIds : message.streamId ? [message.streamId] : []; -+ for (const ackStreamId of ackStreamIds) try { -+ ws.send(JSON.stringify({ -+ type: "cf_mcp_delivery_ack", -+ eventId: message.eventId, -+ streamId: ackStreamId -+ })); -+ } catch {} -+ ws?.close(1000, "SSE response delivered"); -+ } -+ } -+ } - onMessage(event).catch(console.error); - } catch (e) { - console.error("Error forwarding message to SSE:", e); - } -- }); -- ws.addEventListener("error", () => { -- writer.close().catch(() => {}); -- }); -- ws.addEventListener("close", () => { -- writer.close().catch(() => {}); -- }); -+ }); -+ ws.addEventListener("error", () => { -+ __sseClosed = true; -+ clearInterval(keepAlive); -+ writer.close().catch(() => {}); -+ }); -+ ws.addEventListener("close", () => { -+ __sseClosed = true; -+ clearInterval(keepAlive); -+ writer.close().catch(() => {}); -+ }); - return new Response(readable, { - headers: { - "Cache-Control": "no-cache", -@@ -389,10 +547,16 @@ const createLegacySseHandler = (basePath, namespace, options = {}) => { - const url = new URL(request.url); - if (request.method === "GET" && basePattern.test(url)) { - const sessionId = url.searchParams.get("sessionId") || namespace.newUniqueId().toString(); -- const { readable, writable } = new TransformStream(); -- const writer = writable.getWriter(); -- const encoder = new TextEncoder(); -- const endpointUrl = new URL(request.url); -+ const { readable, writable } = new TransformStream(); -+ const writer = writable.getWriter(); -+ const encoder = new TextEncoder(); -+ const __openedAt = Date.now(); -+ const MAX_PENDING_SSE_BYTES = 8 * 1024 * 1024; -+ let __pendingBytes = 0; -+ let __writeChain = Promise.resolve(); -+ let __sseClosed = false; -+ let keepAlive; -+ const endpointUrl = new URL(request.url); - endpointUrl.pathname = encodeURI(`${basePath}/message`); - endpointUrl.searchParams.set("sessionId", sessionId); - const endpointMessage = `event: endpoint\ndata: ${endpointUrl.pathname + endpointUrl.search + endpointUrl.hash}\n\n`; -@@ -414,35 +578,94 @@ const createLegacySseHandler = (basePath, namespace, options = {}) => { - console.error("Failed to establish WebSocket connection"); - await writer.close(); - return new Response("Failed to establish WebSocket connection", { status: 500 }); -- } -- ws.accept(); -- ws.addEventListener("message", (event) => { -+ } -+ ws.accept(); -+ const __abortSse = () => { -+ __sseClosed = true; -+ try { -+ clearInterval(keepAlive); -+ } catch {} -+ try { -+ ws.close(1013, "SSE client not draining"); -+ } catch {} -+ writer.abort(new Error("SSE client not draining")).catch(() => {}); -+ }; -+ const __closeSseGracefully = () => { -+ __sseClosed = true; -+ try { -+ clearInterval(keepAlive); -+ } catch {} -+ try { -+ const finalFrame = encoder.encode(": max-age rotation, reconnect\n\n"); -+ __writeChain = __writeChain.then(() => writer.write(finalFrame)).catch(() => {}).then(() => writer.close()).catch(() => { -+ writer.abort().catch(() => {}); -+ }); -+ } catch { -+ __writeChain = writer.close().catch(() => { -+ writer.abort().catch(() => {}); -+ }); -+ } -+ try { -+ ws.close(1000, "sse_max_age_rotation"); -+ } catch {} -+ return __writeChain; -+ }; -+ const __forwardSse = (frame) => { -+ if (__sseClosed) return __writeChain; -+ const ageMs = Date.now() - __openedAt; -+ if (ageMs > MAX_SSE_AGE_MS) { -+ console.log(JSON.stringify({ -+ event: "sse_max_age_close", -+ sessionId, -+ variant: "legacy-sse", -+ ageMs, -+ pendingBytes: __pendingBytes -+ })); -+ __closeSseGracefully(); -+ return Promise.resolve(); -+ } -+ if (__pendingBytes + frame.byteLength > MAX_PENDING_SSE_BYTES) { -+ __abortSse(); -+ return Promise.resolve(); -+ } -+ __pendingBytes += frame.byteLength; -+ __writeChain = __writeChain.then(() => writer.write(frame)).catch(() => {}).finally(() => { -+ __pendingBytes -= frame.byteLength; -+ }); -+ return __writeChain; -+ }; -+ keepAlive = startKeepalive(__forwardSse, encoder); -+ ws.addEventListener("message", (event) => { - async function onMessage(event) { - try { - const message = JSON.parse(event.data); -- const result = JSONRPCMessageSchema.safeParse(message); -- if (!result.success) return; -- const messageText = `event: message\ndata: ${JSON.stringify(result.data)}\n\n`; -- await writer.write(encoder.encode(messageText)); -- } catch (error) { -+ const result = JSONRPCMessageSchema.safeParse(message); -+ if (!result.success) return; -+ const messageText = `event: message\ndata: ${JSON.stringify(result.data)}\n\n`; -+ __forwardSse(encoder.encode(messageText)); -+ } catch (error) { - console.error("Error forwarding message to SSE:", error); - } - } - onMessage(event).catch(console.error); - }); -- ws.addEventListener("error", (error) => { -- async function onError(_error) { -- try { -- await writer.close(); -- } catch (_e) {} -+ ws.addEventListener("error", (error) => { -+ async function onError(_error) { -+ try { -+ __sseClosed = true; -+ clearInterval(keepAlive); -+ await writer.close(); -+ } catch (_e) {} - } - onError(error).catch(console.error); - }); -- ws.addEventListener("close", () => { -- async function onClose() { -- try { -- await writer.close(); -- } catch (error) { -+ ws.addEventListener("close", () => { -+ async function onClose() { -+ try { -+ __sseClosed = true; -+ clearInterval(keepAlive); -+ await writer.close(); -+ } catch (error) { - console.error("Error closing SSE connection:", error); - } - } -@@ -634,7 +857,23 @@ var StreamableHTTPServerTransport = class { - } - this.supersedePriorStreamConnections(agent, connection.id, resumedStreamId); - connection.setState(resumeState); -- await this.replayEvents(lastEventId); -+ const ackStreamIds = []; -+ const replayedResponse = await this.replayEvents(lastEventId); -+ if (resumedStreamId !== STANDALONE_STREAM_ID && replayedResponse) ackStreamIds.push(resumedStreamId); -+ // A reconnect can carry a Last-Event-ID for an already-delivered -+ // stream (e.g. the initialize response) while a tool result -+ // completed on a since-abandoned POST stream. Replay those other -+ // undelivered responses on this connection too, otherwise they are -+ // stranded until the session is torn down. -+ ackStreamIds.push(...await this.replayUndeliveredResponses(agent, connection, resumedStreamId)); -+ // Storage is NOT cleared here: replayed events are only enqueued -+ // on the WS bridge, and workerd cannot tell a dead client from a -+ // live one at write time. The close frame below makes the bridge -+ // drain the writes, close the HTTP response, and send one -+ // cf_mcp_delivery_ack per replayed stream only if the client was -+ // still attached; McpAgent.onMessage clears storage on that ack. -+ // A dead recovery GET therefore leaves everything replayable. -+ if (ackStreamIds.length > 0) this.sendReplayComplete(connection, ackStreamIds); - return; - } - } -@@ -644,6 +883,26 @@ var StreamableHTTPServerTransport = class { - _standaloneSse: true - }; - connection.setState(standaloneState); -+ const replayedStreamIds = await this.replayUndeliveredResponses(agent, connection); -+ // Same delivery-confirmed clearing as the resume branch above. When -+ // nothing was replayed no close frame is sent and this connection stays -+ // open as the session's long-lived standalone listener. -+ if (replayedStreamIds.length > 0) this.sendReplayComplete(connection, replayedStreamIds); -+ } -+ /** -+ * Ask the Worker bridge to flush everything queued on `connection`, -+ * close the client-facing SSE response, and, only if the writer close -+ * completed with the client still attached, echo one -+ * `cf_mcp_delivery_ack` per stream id in `ackStreamIds`. The event -+ * payload is an SSE comment so client parsers drop it. -+ */ -+ sendReplayComplete(connection, ackStreamIds) { -+ return connection.send(JSON.stringify({ -+ type: "cf_mcp_agent_event", -+ event: ": replay-complete\n\n", -+ ackStreamIds, -+ close: true -+ })); - } - /** - * Close any connection (other than `selfId`) currently bound to -@@ -664,12 +923,14 @@ var StreamableHTTPServerTransport = class { - * Only used when resumability is enabled - */ - async replayEvents(lastEventId) { -- if (!this._eventStore) return; -+ if (!this._eventStore) return false; - const { connection } = getCurrentAgent(); - if (!connection) throw new Error("Connection was not available in replayEvents"); -+ let replayedResponse = false; - try { - await this._eventStore?.replayEventsAfter(lastEventId, { send: async (eventId, message) => { - try { -+ if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) replayedResponse = true; - this.writeSSEEvent(connection, message, eventId); - } catch (error) { - this.onerror?.(error); -@@ -678,6 +939,33 @@ var StreamableHTTPServerTransport = class { - } catch (error) { - this.onerror?.(error); - } -+ return replayedResponse; -+ } -+ /** -+ * Enqueue every undelivered stream's events on `connection` and return -+ * the stream ids whose replay included a response. Deliberately does -+ * NOT clear storage: the caller sends a replay-complete close frame and -+ * the bridge acks each stream only after the client-facing writer -+ * drained and closed with the client still attached. -+ */ -+ async replayUndeliveredResponses(agent, connection, skipStreamId) { -+ const replayedStreamIds = []; -+ if (!this._eventStore?.replayEventsForStream) return replayedStreamIds; -+ const streamIds = await agent.getUndeliveredStreamIds(); -+ for (const streamId of streamIds) { -+ if (skipStreamId !== void 0 && streamId === skipStreamId) continue; -+ let replayedResponse = false; -+ await this._eventStore.replayEventsForStream(streamId, { send: async (eventId, message) => { -+ try { -+ if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) replayedResponse = true; -+ this.writeSSEEvent(connection, message, eventId); -+ } catch (error) { -+ this.onerror?.(error); -+ } -+ } }); -+ if (replayedResponse) replayedStreamIds.push(streamId); -+ } -+ return replayedStreamIds; - } - /** - * Writes an event to the SSE stream with proper formatting -@@ -689,10 +977,65 @@ var StreamableHTTPServerTransport = class { - return connection.send(JSON.stringify({ - type: "cf_mcp_agent_event", - event: eventData, -+ eventId, -+ streamId: eventId ? eventId.slice(0, eventId.lastIndexOf(":")) : void 0, - close - })); - } - /** -+ * Persist and write the priming SSE event for a freshly opened POST -+ * stream. The stored message is a benign JSON-RPC notification so that -+ * if a plain recovery GET ever replays this stream via -+ * {@link writeSSEEvent} (which forces `event: message`), a compliant -+ * client parses and ignores it rather than erroring. The live priming -+ * frame, however, uses a non-`message` SSE event type so the SDK -+ * records the id (setting hasPrimingEvent / lastEventId) WITHOUT -+ * dispatching it as a JSON-RPC message: its SSE loop skips any event -+ * whose type is not `message`. A data line is required because the -+ * SDK's SSE parser drops events with empty data before recording the -+ * id, so an id-only frame would not prime. -+ */ -+ async emitPrimingEvent(agent, connection, streamId) { -+ if (!this._eventStore) return; -+ const primingMessage = { -+ jsonrpc: "2.0", -+ method: "notifications/message", -+ params: { -+ level: "debug", -+ data: "mcp-stream-priming" -+ } -+ }; -+ let eventId; -+ try { -+ eventId = await this._eventStore.storeEvent(streamId, primingMessage); -+ } catch (error) { -+ this.onerror?.(error); -+ return; -+ } -+ if (!eventId) return; -+ try { -+ this.writePrimingSSEEvent(connection, primingMessage, eventId); -+ } catch (error) { -+ this.onerror?.(error); -+ } -+ } -+ /** -+ * Write a priming SSE frame: `event: mcp-priming`, an `id:` carrying a -+ * real replayable event-store id, and a data line the SDK ignores -+ * because the event type is not `message`. Never sets `close`. -+ */ -+ writePrimingSSEEvent(connection, message, eventId) { -+ let eventData = "event: mcp-priming\n"; -+ eventData += `id: ${eventId}\n`; -+ eventData += `data: ${JSON.stringify(message)}\n\n`; -+ return connection.send(JSON.stringify({ -+ type: "cf_mcp_agent_event", -+ event: eventData, -+ eventId, -+ streamId: eventId.slice(0, eventId.lastIndexOf(":")) -+ })); -+ } -+ /** - * Handles POST requests containing JSON-RPC messages - */ - async handlePostRequest(req, parsedBody) { -@@ -733,6 +1076,22 @@ var StreamableHTTPServerTransport = class { - }; - connection.setState(postState); - if (this._eventStore) await agent.setStreamRequestIds(streamId, requestIds); -+ // Emit a priming SSE event as the very first frame on this POST -+ // response stream, before dispatching the request(s). The MCP TS SDK -+ // only auto-reconnects a dropped POST SSE stream when it saw an event -+ // `id:` before the drop (hasPrimingEvent). Without this, a network -+ // blip or edge close mid-call leaves the SDK with hasPrimingEvent -+ // false, so it never issues the recovery GET that replays the -+ // persisted result, and callTool hangs forever. The priming event is -+ // a real event-store entry so its id sorts BEFORE the eventual -+ // response; a reconnect with `last-event-id: ` replays -+ // everything after it (i.e. the result). See patches/agents patch. -+ // Scoped to tools/call streams: reconnect-with-replay only matters -+ // where the result can outlive the connection (long-running tool -+ // calls). initialize/tools/list resolve in milliseconds and clients -+ // retry them; priming those streams adds a storage write and an -+ // extra SSE frame per request for nothing. -+ if (messages.some((message) => isJSONRPCRequest(message) && message.method === "tools/call")) await this.emitPrimingEvent(agent, connection, streamId); - for (const message of messages) { - if (this.messageInterceptor) { - if (await this.messageInterceptor(message, { -@@ -760,7 +1119,22 @@ var StreamableHTTPServerTransport = class { - * when the originating WS has dropped. - */ - async sendOnStream(agent, streamId, relatedIds, liveConnection, message, requestId) { -- const eventId = await this._eventStore?.storeEvent(streamId, message); -+ // Persistence is best-effort and must never block live delivery: a -+ // storeEvent failure (storage cap, storage outage) used to throw here, -+ // before writeSSEEvent, so the response was neither stored NOR sent and -+ // the client hung on keepalives. Deliver-live-first; a message without -+ // an eventId just isn't replayable after a drop. -+ let eventId; -+ try { -+ eventId = await this._eventStore?.storeEvent(streamId, message); -+ } catch (error) { -+ console.warn(JSON.stringify({ -+ event: "mcp_event_store_put_failed", -+ streamId, -+ error: String(error) -+ })); -+ this.onerror?.(error); -+ } - let shouldClose = false; - if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) { - let responseIds = this._streamResponseIds.get(streamId); -@@ -777,9 +1151,11 @@ var StreamableHTTPServerTransport = class { - } catch (error) { - this.onerror?.(error); - } -- if (shouldClose) { -+ if (shouldClose && !this._eventStore) { -+ await agent.deleteStreamRequestIds(streamId); -+ } else if (shouldClose) { -+ await agent.markStreamUndelivered(streamId); - await agent.deleteStreamRequestIds(streamId); -- if (this._eventStore && isClearableEventStore(this._eventStore)) await this._eventStore.clearStream(streamId); - } - } - async send(message, options) { -@@ -861,12 +1237,10 @@ var StreamableHTTPServerTransport = class { - * - * ## Lifecycle - * --* Each POST tool-call stream's events live only until the final --* response is delivered. The transport calls {@link clearStream} --* immediately after writing the close frame, so storage growth is --* bounded by the in-flight POST streams plus the standalone GET --* stream. There is no background sweep — quiescent agents do no work, --* and the DO itself dies with the session. -+* Each POST tool-call stream's response events live until the Worker -+* bridge confirms the final SSE write or a reconnect GET replays them. -+* Per-stream storage is capped at 64 events or roughly 2 MB; oldest -+* entries are evicted first. - * - * Standalone GET stream events (`_GET_stream`) are *not* cleared - * automatically; they accumulate for the lifetime of the DO. Bounded -@@ -893,12 +1267,34 @@ var DurableObjectEventStore = class DurableObjectEventStore { - } - async storeEvent(streamId, message) { - if (streamId.includes(":")) throw new Error(`DurableObjectEventStore: streamId must not contain ':' (got ${JSON.stringify(streamId)})`); -+ // DO storage caps each value at 128 KiB; storage.put of a larger -+ // message throws, and before this guard that throw escaped through -+ // sendOnStream BEFORE the live SSE write, so an oversize response -+ // (e.g. the ~5MB ui:// shell document) was never delivered at all — -+ // the client saw only keepalives. Skip persistence instead: the -+ // event is delivered live without a replay id, which is strictly -+ // better than never delivering it. Undeliverable-if-dropped is the -+ // documented cost, logged so it is visible. -+ let messageBytes = 0; -+ try { -+ messageBytes = new TextEncoder().encode(JSON.stringify(message)).byteLength; -+ } catch {} -+ if (messageBytes > DurableObjectEventStore.MAX_STORABLE_EVENT_BYTES) { -+ console.warn(JSON.stringify({ -+ event: "mcp_event_store_skipped_oversize", -+ streamId, -+ messageBytes, -+ limit: DurableObjectEventStore.MAX_STORABLE_EVENT_BYTES -+ })); -+ return void 0; -+ } - await this.ensureSeqLoaded(streamId); - const seq = (this.seqByStream.get(streamId) ?? 0) + 1; - this.seqByStream.set(streamId, seq); - const eventId = `${streamId}:${seq.toString(16).padStart(DurableObjectEventStore.SEQ_PAD, "0")}`; - const eventKey = `${DurableObjectEventStore.EVENT_KEY_PREFIX}${eventId}`; - await this.storage.put(eventKey, message); -+ await this.trimStream(streamId); - return eventId; - } - async getStreamIdForEventId(eventId) { -@@ -915,9 +1311,59 @@ var DurableObjectEventStore = class DurableObjectEventStore { - start: startKey, - limit: DurableObjectEventStore.REPLAY_LIMIT - }); -- for (const [key, message] of rows) await send(key.slice(DurableObjectEventStore.EVENT_KEY_PREFIX.length), message); -+ for (const [key, message] of rows) try { -+ await send(key.slice(DurableObjectEventStore.EVENT_KEY_PREFIX.length), message); -+ } catch {} -+ return streamId; -+ } -+ async replayEventsForStream(streamId, { send }) { -+ const prefix = `${DurableObjectEventStore.EVENT_KEY_PREFIX}${streamId}:`; -+ const rows = await this.storage.list({ -+ prefix, -+ limit: DurableObjectEventStore.REPLAY_LIMIT -+ }); -+ for (const [key, message] of rows) try { -+ await send(key.slice(DurableObjectEventStore.EVENT_KEY_PREFIX.length), message); -+ } catch {} - return streamId; - } -+ async trimStream(streamId) { -+ const prefix = `${DurableObjectEventStore.EVENT_KEY_PREFIX}${streamId}:`; -+ const rows = await this.storage.list({ -+ prefix, -+ limit: DurableObjectEventStore.REPLAY_LIMIT -+ }); -+ let totalBytes = 0; -+ const entries = [...rows].map(([key, message]) => { -+ let bytes = DurableObjectEventStore.MAX_EVENT_BYTES; -+ try { -+ bytes = new TextEncoder().encode(JSON.stringify(message)).byteLength; -+ } catch {} -+ totalBytes += bytes; -+ return { key, bytes }; -+ }); -+ const deleteKeys = []; -+ // Never evict the newest entry: trimStream runs right after storeEvent -+ // put it, and for a final tool response it is the only copy a recovery -+ // GET can replay. An oversize final response is kept even past the byte -+ // cap; the cap then squeezes out older events instead. -+ while (entries.length > 1 && (entries.length > DurableObjectEventStore.MAX_EVENTS_PER_STREAM || totalBytes > DurableObjectEventStore.MAX_BYTES_PER_STREAM)) { -+ const evicted = entries.shift(); -+ if (!evicted) break; -+ deleteKeys.push(evicted.key); -+ totalBytes -= evicted.bytes; -+ } -+ if (deleteKeys.length > 0) { -+ console.warn(JSON.stringify({ -+ event: "mcp_event_store_evicted", -+ streamId, -+ evictedCount: deleteKeys.length, -+ remainingCount: entries.length, -+ remainingBytes: totalBytes -+ })); -+ for (let i = 0; i < deleteKeys.length; i += DurableObjectEventStore.DELETE_CHUNK) await this.storage.delete(deleteKeys.slice(i, i + DurableObjectEventStore.DELETE_CHUNK)); -+ } -+ } - /** - * Drop the event log for a single stream. Called by the transport - * immediately after a POST's final response has been written to the -@@ -973,6 +1419,13 @@ DurableObjectEventStore.EVENT_KEY_PREFIX = "__mcp_event__:"; - DurableObjectEventStore.SEQ_PAD = 16; - DurableObjectEventStore.DELETE_CHUNK = 128; - DurableObjectEventStore.REPLAY_LIMIT = 1e3; -+DurableObjectEventStore.MAX_EVENTS_PER_STREAM = 64; -+DurableObjectEventStore.MAX_BYTES_PER_STREAM = 2 * 1024 * 1024; -+DurableObjectEventStore.MAX_EVENT_BYTES = 2 * 1024 * 1024; -+// DO storage's per-value hard cap is 128 KiB. The JSON byte length measured in -+// storeEvent is a close proxy for the runtime's serialized size; the margin -+// below it absorbs the difference. Anything larger is delivered live only. -+DurableObjectEventStore.MAX_STORABLE_EVENT_BYTES = 120 * 1024; - //#endregion - //#region src/mcp/client-transports.ts - /** -@@ -1381,6 +1834,47 @@ var McpAgent = class McpAgent extends Agent { - async deleteStreamRequestIds(streamId) { - await this.ctx.storage.delete(`${McpAgent.STREAM_REQS_KEY_PREFIX}${streamId}`); - } -+ async markStreamUndelivered(streamId) { -+ await this.ctx.storage.put(`${McpAgent.UNDELIVERED_STREAM_KEY_PREFIX}${streamId}`, true); -+ } -+ async deleteUndeliveredStream(streamId) { -+ await this.ctx.storage.delete(`${McpAgent.UNDELIVERED_STREAM_KEY_PREFIX}${streamId}`); -+ } -+ async getUndeliveredStreamIds() { -+ const rows = await this.ctx.storage.list({ -+ prefix: McpAgent.UNDELIVERED_STREAM_KEY_PREFIX, -+ limit: 1e3 -+ }); -+ return [...rows.keys()].map((key) => key.slice(McpAgent.UNDELIVERED_STREAM_KEY_PREFIX.length)); -+ } -+ /** List persisted POST stream request ids for replay and idle accounting. @internal */ -+ async getOpenStreamRequestIds() { -+ const rows = await this.ctx.storage.list({ -+ prefix: McpAgent.STREAM_REQS_KEY_PREFIX, -+ limit: 1e3 -+ }); -+ return [...rows].flatMap(([key, requestIds]) => Array.isArray(requestIds) && requestIds.length > 0 ? [{ -+ streamId: key.slice(McpAgent.STREAM_REQS_KEY_PREFIX.length), -+ requestIds -+ }] : []); -+ } -+ async acknowledgeDeliveredStream(streamId) { -+ await this.deleteStreamRequestIds(streamId); -+ await this.deleteUndeliveredStream(streamId); -+ const eventStore = this._transport?.["_eventStore"]; -+ if (eventStore && isClearableEventStore(eventStore)) await eventStore.clearStream(streamId); -+ } -+ async onMessage(connection, message) { -+ if (typeof message !== "string") return super.onMessage(connection, message); -+ try { -+ const parsed = JSON.parse(message); -+ if (parsed?.type === "cf_mcp_delivery_ack" && typeof parsed.streamId === "string") { -+ await this.acknowledgeDeliveredStream(parsed.streamId); -+ return; -+ } -+ } catch {} -+ return super.onMessage(connection, message); -+ } - /** - * Reverse lookup: find which POST stream a given `requestId` belongs - * to, and return the stream's full `requestIds` list in the same -@@ -1697,7 +2191,8 @@ var McpAgent = class McpAgent extends Agent { - } - }; - McpAgent.STREAM_REQS_KEY_PREFIX = "__mcp_stream_reqs__:"; -+McpAgent.UNDELIVERED_STREAM_KEY_PREFIX = "__mcp_undelivered_stream__:"; - //#endregion --export { DurableObjectEventStore, ElicitRequestSchema, MCP_SERVER_ID_MAX_LENGTH, McpAgent, RPCClientTransport, RPCServerTransport, RPC_DO_PREFIX, SSEEdgeClientTransport, StreamableHTTPEdgeClientTransport, WorkerTransport, createMcpHandler, experimental_createMcpHandler, getMcpAuthContext, normalizeServerId }; -+export { DurableObjectEventStore, ElicitRequestSchema, MAX_SSE_AGE_MS, MCP_SERVER_ID_MAX_LENGTH, McpAgent, RPCClientTransport, RPCServerTransport, RPC_DO_PREFIX, SSEEdgeClientTransport, StreamableHTTPEdgeClientTransport, WorkerTransport, createMcpHandler, experimental_createMcpHandler, getMcpAuthContext, normalizeServerId }; - - //# sourceMappingURL=index.js.map -\ No newline at end of file diff --git a/scripts/bootstrap.ts b/scripts/bootstrap.ts index a290803705..2527e5a3bb 100644 --- a/scripts/bootstrap.ts +++ b/scripts/bootstrap.ts @@ -23,11 +23,7 @@ const run = (label: string, cmd: string, args: ReadonlyArray) => { // apps' vite dev servers fail without in a fresh worktree. run("dependencies (+ prepare builds)", "bun", ["install"]); -// Assert load-bearing patched dependencies actually installed in patched form. -// A bun cache edge case can leave a stale, unpatched dist in node_modules even -// though the lockfile records the patch (bun reports "no changes"). That would -// silently drop the agents MCP transport hang fix; fail here so a fresh -// checkout or worktree surfaces it immediately instead of at deploy time. +// Catch patched-dependency entries whose checked-in patch file was removed. run("verify patched deps", "bun", ["run", "scripts/check-patched-deps.ts"]); // e2e browser scenarios need Playwright's chromium; the cache is shared diff --git a/scripts/check-patched-deps.ts b/scripts/check-patched-deps.ts index 7f982c0547..6e01649a19 100644 --- a/scripts/check-patched-deps.ts +++ b/scripts/check-patched-deps.ts @@ -1,35 +1,5 @@ #!/usr/bin/env bun -/** - * Asserts that patched dependencies are actually installed in patched form. - * - * We patch several npm packages via `bun patch` (see `patchedDependencies` in - * package.json and the `patches/*.patch` files). At least one of these patches - * is load-bearing at runtime, not just a build-time convenience: the - * `agents@0.17.3` patch carries the MCP transport persist/replay fix (the SSE - * "hang" fix that preserves tool results across dropped connections). If the - * installed `agents` dist is the STALE, unpatched upstream build, everything - * still compiles and tests mostly pass while the deployed transport silently - * lacks the fix, so prod ships the pre-fix transport with zero signal. - * - * This has actually happened: a bun cache edge case left a checkout with an - * unpatched `agents` dist in node_modules even though the lockfile and - * package.json recorded the patch. `bun install` reported no changes because - * the store entry it wanted was already present; it just wasn't the patched - * content. - * - * So we don't trust the lockfile — we read the installed dist off disk and - * assert the post-patch sentinel strings are present. The check is a couple of - * file reads plus greps, so it adds ~zero wall-clock time and is safe to run in - * CI before the test job and in `bootstrap` on every fresh checkout. - * - * Usage: - * bun run scripts/check-patched-deps.ts - * - * Env overrides (used by the self-test): - * CHECK_PATCHED_DEPS_AGENTS_MCP=/abs/path/to/agents/dist/mcp/index.js - * Force the agents MCP entry path instead of resolving it, so the self-test - * can point the check at a deliberately-corrupted copy. - */ +/** Verify that every root patched-dependency entry still names a real file. */ import { existsSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; @@ -38,121 +8,26 @@ const repoRoot = resolve(import.meta.dir, ".."); type Failure = { readonly package: string; readonly detail: string }; const failures: Failure[] = []; -/** - * Each patched package whose *runtime content* we assert against. Sentinels are - * identifiers that exist only in the patched dist (added by the patch), so - * their presence proves the installed file is the patched one, not a stale - * upstream build. Keep sentinels to a couple of stable, patch-unique symbols. - */ -type RuntimeCheck = { - readonly package: string; - /** Resolve the installed entry file we assert against. */ - readonly resolveEntry: () => string; - /** Strings that only appear post-patch. All must be present. */ - readonly sentinels: readonly string[]; - /** Human hint for what the patch does, shown on failure. */ - readonly purpose: string; -}; - -/** - * Resolve a subpath export of a dependency robustly, without hardcoding the - * bun store hash in `node_modules/.bun/@+/…`. We resolve - * from a workspace package that actually depends on it so the module graph is - * the real one; `require.resolve` then walks bun's store for us. - */ -const resolveFrom = (specifier: string, fromPackageDir: string): string => { - const fromDir = resolve(repoRoot, fromPackageDir); - return require.resolve(specifier, { paths: [fromDir] }); -}; - -const runtimeChecks: readonly RuntimeCheck[] = [ - { - package: "agents@0.17.3 (agents/mcp)", - purpose: - "MCP transport persist/replay fix (preserves tool results across dropped SSE connections)", - resolveEntry: () => { - const override = process.env.CHECK_PATCHED_DEPS_AGENTS_MCP; - if (override && override.length > 0) return resolve(override); - // `packages/hosts/cloudflare` is the workspace package that depends on - // `agents`, so resolve the `agents/mcp` export from there. - return resolveFrom("agents/mcp", "packages/hosts/cloudflare"); - }, - // These identifiers are introduced by patches/agents@0.17.3.patch and do - // not exist in the upstream 0.17.3 dist. - sentinels: ["markStreamUndelivered", "replayUndeliveredResponses"], - }, -]; - -for (const check of runtimeChecks) { - let entry: string; - try { - entry = check.resolveEntry(); - } catch (err) { - failures.push({ - package: check.package, - detail: `could not resolve installed entry: ${(err as Error).message.split("\n")[0]}`, - }); - continue; - } - - if (!existsSync(entry)) { - failures.push({ package: check.package, detail: `installed entry does not exist: ${entry}` }); - continue; - } - - const contents = readFileSync(entry, "utf8"); - const missing = check.sentinels.filter((s) => !contents.includes(s)); - if (missing.length > 0) { - failures.push({ - package: check.package, - detail: - `installed dist is missing post-patch sentinel(s): ${missing.join(", ")}\n` + - ` entry: ${entry}\n` + - ` patch purpose: ${check.purpose}`, - }); - } -} - -/** - * Lightweight generalized layer: every package listed in `patchedDependencies` - * must still have its referenced patch file on disk. This does not verify the - * installed *content* for packages without a dedicated runtime check above - * (that requires per-package sentinels), but it catches a patch entry pointing - * at a missing file, which would make `bun install` silently skip patching. - */ const rootPkg = JSON.parse(readFileSync(resolve(repoRoot, "package.json"), "utf8")) as { patchedDependencies?: Record; }; -for (const [dep, patchPath] of Object.entries(rootPkg.patchedDependencies ?? {})) { - const abs = resolve(repoRoot, patchPath); - if (!existsSync(abs)) { - failures.push({ - package: dep, - detail: `patchedDependencies references a missing patch file: ${patchPath}`, - }); - } +for (const [dependency, patchPath] of Object.entries(rootPkg.patchedDependencies ?? {})) { + if (existsSync(resolve(repoRoot, patchPath))) continue; + failures.push({ + package: dependency, + detail: `patchedDependencies references a missing patch file: ${patchPath}`, + }); } if (failures.length > 0) { - const lines = failures.map((f) => ` - ${f.package}: ${f.detail}`).join("\n"); + const lines = failures.map((failure) => ` - ${failure.package}: ${failure.detail}`).join("\n"); console.error( `\nPatched-dependency check FAILED (${failures.length} problem(s)):\n${lines}\n\n` + - "Cause: the installed dependency content does not match the patch we ship.\n" + - "This is usually a stale bun store entry: bun kept an unpatched build of the\n" + - "package in its cache and `bun install` reported no changes without applying\n" + - "the patch. The deployed/tested code then silently lacks the patched behavior\n" + - "(for `agents`, the MCP transport hang fix), with no other signal.\n\n" + - "Fix: force a clean reinstall of the affected package's store entry, e.g.\n" + - " rm -rf node_modules/.bun/agents@* node_modules/agents\n" + - " bun install\n" + - "If that does not take, clear bun's global cache for it:\n" + - " bun pm cache rm\n" + - " bun install\n", + "Every patchedDependencies entry must reference a checked-in patch file.\n", ); process.exit(1); } console.log( - `Patched-dependency check passed: ${runtimeChecks.length} runtime sentinel check(s), ` + - `${Object.keys(rootPkg.patchedDependencies ?? {}).length} patch file(s) present.`, + `Patched-dependency check passed: ${Object.keys(rootPkg.patchedDependencies ?? {}).length} patch file(s) present.`, ); From a9b33d25c32fbb4a292b7e8963e22392f862a16f Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 17 Aug 2026 05:19:17 -0600 Subject: [PATCH 008/133] Add changeset for MCP 2026-07-28 support (#1617) --- .changeset/mcp-spec-2026-07-28.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/mcp-spec-2026-07-28.md diff --git a/.changeset/mcp-spec-2026-07-28.md b/.changeset/mcp-spec-2026-07-28.md new file mode 100644 index 0000000000..fc678aab94 --- /dev/null +++ b/.changeset/mcp-spec-2026-07-28.md @@ -0,0 +1,5 @@ +--- +"@executor-js/plugin-mcp": patch +--- + +Support MCP spec 2026-07-28 end to end. The MCP client negotiates the protocol era automatically (`server/discover` probe with legacy fallback), so integrations hosted on modern-only MCP servers connect without the server enabling legacy compatibility. Executor-hosted MCP endpoints serve both eras, and the `executor mcp` stdio bridge passes either era through to the daemon. From 60ad50c2132e8c3b669230d6fb2983da06b9453f Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 17 Aug 2026 05:22:31 -0600 Subject: [PATCH 009/133] Version Packages (#1557) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .../admin-users-batched-connection-read.md | 20 ------ .changeset/core-tools-integrations-remove.md | 9 --- .../local-native-elicitation-streaming.md | 7 -- .changeset/mcp-spec-2026-07-28.md | 5 -- .changeset/oauth-clients-remove-honest.md | 11 ---- apps/cli/CHANGELOG.md | 28 ++++++++ apps/cli/package.json | 2 +- apps/cloud/CHANGELOG.md | 21 ++++++ apps/cloud/package.json | 2 +- apps/desktop/CHANGELOG.md | 2 + apps/desktop/package.json | 2 +- apps/host-selfhost/CHANGELOG.md | 21 ++++++ apps/host-selfhost/package.json | 2 +- apps/local/CHANGELOG.md | 27 ++++++++ apps/local/package.json | 2 +- bun.lock | 64 +++++++++---------- e2e/CHANGELOG.md | 12 ++++ e2e/package.json | 2 +- examples/all-plugins/CHANGELOG.md | 14 ++++ examples/all-plugins/package.json | 2 +- examples/docs-sdk-quickstart/CHANGELOG.md | 8 +++ examples/docs-sdk-quickstart/package.json | 2 +- packages/core/analytics/CHANGELOG.md | 7 ++ packages/core/analytics/package.json | 2 +- packages/core/api/CHANGELOG.md | 9 +++ packages/core/api/package.json | 2 +- packages/core/cli/CHANGELOG.md | 7 ++ packages/core/cli/package.json | 2 +- packages/core/config/CHANGELOG.md | 7 ++ packages/core/config/package.json | 2 +- packages/core/execution/CHANGELOG.md | 8 +++ packages/core/execution/package.json | 2 +- packages/core/sdk/CHANGELOG.md | 21 ++++++ packages/core/sdk/package.json | 2 +- packages/core/vite-plugin/CHANGELOG.md | 7 ++ packages/core/vite-plugin/package.json | 2 +- packages/hosts/cloudflare/CHANGELOG.md | 10 +++ packages/hosts/cloudflare/package.json | 2 +- packages/hosts/mcp-apps-shell/CHANGELOG.md | 8 +++ packages/hosts/mcp-apps-shell/package.json | 2 +- packages/kernel/core/CHANGELOG.md | 2 + packages/kernel/core/package.json | 2 +- packages/kernel/runtime-quickjs/CHANGELOG.md | 7 ++ packages/kernel/runtime-quickjs/package.json | 2 +- .../runtime-workerd-subprocess/CHANGELOG.md | 7 ++ .../runtime-workerd-subprocess/package.json | 2 +- .../plugins/desktop-settings/CHANGELOG.md | 7 ++ .../plugins/desktop-settings/package.json | 2 +- .../plugins/encrypted-secrets/CHANGELOG.md | 7 ++ .../plugins/encrypted-secrets/package.json | 2 +- packages/plugins/example/CHANGELOG.md | 7 ++ packages/plugins/example/package.json | 2 +- packages/plugins/file-secrets/CHANGELOG.md | 7 ++ packages/plugins/file-secrets/package.json | 2 +- packages/plugins/graphql/CHANGELOG.md | 10 +++ packages/plugins/graphql/package.json | 2 +- packages/plugins/keychain/CHANGELOG.md | 7 ++ packages/plugins/keychain/package.json | 2 +- packages/plugins/mcp/CHANGELOG.md | 12 ++++ packages/plugins/mcp/package.json | 2 +- packages/plugins/onepassword/CHANGELOG.md | 9 +++ packages/plugins/onepassword/package.json | 2 +- packages/plugins/openapi/CHANGELOG.md | 10 +++ packages/plugins/openapi/package.json | 2 +- .../provider-service-split/CHANGELOG.md | 8 +++ .../provider-service-split/package.json | 2 +- packages/plugins/toolkits/CHANGELOG.md | 9 +++ packages/plugins/toolkits/package.json | 2 +- packages/react/CHANGELOG.md | 8 +++ packages/react/package.json | 2 +- 70 files changed, 398 insertions(+), 116 deletions(-) delete mode 100644 .changeset/admin-users-batched-connection-read.md delete mode 100644 .changeset/core-tools-integrations-remove.md delete mode 100644 .changeset/local-native-elicitation-streaming.md delete mode 100644 .changeset/mcp-spec-2026-07-28.md delete mode 100644 .changeset/oauth-clients-remove-honest.md diff --git a/.changeset/admin-users-batched-connection-read.md b/.changeset/admin-users-batched-connection-read.md deleted file mode 100644 index ff58bd0520..0000000000 --- a/.changeset/admin-users-batched-connection-read.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@executor-js/sdk": patch ---- - -**Fix: the admin joined user view no longer issues one connection query per subject** - -`admin.listSubjectsWithConnections` read a page of subjects and then queried -connections once per subject, sequentially. A default page therefore cost 100 -round trips inside a single request, which on a per-request socket dominated -the response. It now reads the page and then batches every subject's -connections into one query, so the cost is two queries regardless of page size. -A subject with no connections still reports an empty array rather than dropping -out of the page, and the batched read carries the same `owner: "user"` and -tenant scoping the per-subject read did. - -The `?email=` filter on the admin users endpoints is also applied before the -read rather than after it: the address resolves to a principal id and that id is -read directly, instead of paging the tenant and keeping the row that matched. -Paging still applies to a filtered response, but to the selected row — one row -at `offset: 0`, empty beyond it. diff --git a/.changeset/core-tools-integrations-remove.md b/.changeset/core-tools-integrations-remove.md deleted file mode 100644 index d4932f8ccb..0000000000 --- a/.changeset/core-tools-integrations-remove.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"executor": patch ---- - -**Add `integrations.remove` to the core tools so an agent can drop a catalog integration** - -`integrations.list` advertises `canRemove` per integration, but nothing on the agent surface could act on it: removal existed only on the HTTP API and the web console, so an agent that could add an integration could never take one back out. Cleaning up a catalog meant clicking through the UI once per integration. - -The core-tools plugin now contributes `integrations.remove`, taking the `slug` reported by `integrations.list` and cascading to every connection under the integration and the tools those produced. It is approval-gated, being strictly more destructive than `connections.remove`. The `removed` flag is honest rather than always-true: `false` means no catalog row matched, so an already-absent slug and a built-in namespace like `executor` are distinguishable from a real removal, and an integration pinned with `canRemove: false` is refused with `IntegrationRemovalNotAllowedError` instead of silently surviving. diff --git a/.changeset/local-native-elicitation-streaming.md b/.changeset/local-native-elicitation-streaming.md deleted file mode 100644 index 9d5faf060f..0000000000 --- a/.changeset/local-native-elicitation-streaming.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"executor": patch ---- - -**Fix: native MCP elicitation now reaches clients on the local HTTP endpoint instead of timing out** - -The local daemon's Streamable HTTP transport ran with `enableJsonResponse: true`, which buffers a `tools/call` into a single JSON body and leaves no open stream for the server to write on. A server-to-client `elicitation/create` raised during that call was therefore never delivered, and approval-gated tools failed with a `-32001` request timeout even though the session had negotiated `elicitation_mode=native` and the client's `elicitation.form` capability. The transport now uses the spec-default SSE streaming, so the reverse request rides the originating tool call's stream — matching the Cloudflare host's behaviour. diff --git a/.changeset/mcp-spec-2026-07-28.md b/.changeset/mcp-spec-2026-07-28.md deleted file mode 100644 index fc678aab94..0000000000 --- a/.changeset/mcp-spec-2026-07-28.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@executor-js/plugin-mcp": patch ---- - -Support MCP spec 2026-07-28 end to end. The MCP client negotiates the protocol era automatically (`server/discover` probe with legacy fallback), so integrations hosted on modern-only MCP servers connect without the server enabling legacy compatibility. Executor-hosted MCP endpoints serve both eras, and the `executor mcp` stdio bridge passes either era through to the daemon. diff --git a/.changeset/oauth-clients-remove-honest.md b/.changeset/oauth-clients-remove-honest.md deleted file mode 100644 index 88371592b0..0000000000 --- a/.changeset/oauth-clients-remove-honest.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"executor": patch ---- - -**Fix: `oauth.clients.remove` reported success for clients it never removed** - -The tool returned `{ removed: true }` unconditionally. `oauth.removeClient` is idempotent by design at the storage layer — `deleteMany` on a missing row is a no-op, which is the right behaviour for a delete — but the tool mapped that silence to success, so a typo'd slug, an already-deleted client, and the wrong owner were all indistinguishable from a real deletion. - -This bites hardest because clients are keyed by BOTH owner and slug, so the same slug can exist separately under `org` and `user`. An agent sweeping a list of slugs under one hardcoded owner would delete only half of them and report every call as a success, leaving org-owned OAuth apps registered after everything they authorized was gone. - -The tool now checks the caller-visible client set first and returns `removed: false` when nothing matched that `(owner, slug)` pair. The service-level `removeClient` is unchanged and stays idempotent. diff --git a/apps/cli/CHANGELOG.md b/apps/cli/CHANGELOG.md index 3764116771..788c9fb6dd 100644 --- a/apps/cli/CHANGELOG.md +++ b/apps/cli/CHANGELOG.md @@ -1,5 +1,33 @@ # executor +## 1.5.41 + +### Patch Changes + +- [#1600](https://github.com/UsefulSoftwareCo/executor/pull/1600) [`1b5f931`](https://github.com/UsefulSoftwareCo/executor/commit/1b5f931d90b52fa9eca7b6f53359a117d757c7c1) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Add `integrations.remove` to the core tools so an agent can drop a catalog integration** + + `integrations.list` advertises `canRemove` per integration, but nothing on the agent surface could act on it: removal existed only on the HTTP API and the web console, so an agent that could add an integration could never take one back out. Cleaning up a catalog meant clicking through the UI once per integration. + + The core-tools plugin now contributes `integrations.remove`, taking the `slug` reported by `integrations.list` and cascading to every connection under the integration and the tools those produced. It is approval-gated, being strictly more destructive than `connections.remove`. The `removed` flag is honest rather than always-true: `false` means no catalog row matched, so an already-absent slug and a built-in namespace like `executor` are distinguishable from a real removal, and an integration pinned with `canRemove: false` is refused with `IntegrationRemovalNotAllowedError` instead of silently surviving. + +- [#1556](https://github.com/UsefulSoftwareCo/executor/pull/1556) [`f674fb8`](https://github.com/UsefulSoftwareCo/executor/commit/f674fb80eebd597f922edd5ec21b8035ab195a78) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Fix: native MCP elicitation now reaches clients on the local HTTP endpoint instead of timing out** + + The local daemon's Streamable HTTP transport ran with `enableJsonResponse: true`, which buffers a `tools/call` into a single JSON body and leaves no open stream for the server to write on. A server-to-client `elicitation/create` raised during that call was therefore never delivered, and approval-gated tools failed with a `-32001` request timeout even though the session had negotiated `elicitation_mode=native` and the client's `elicitation.form` capability. The transport now uses the spec-default SSE streaming, so the reverse request rides the originating tool call's stream — matching the Cloudflare host's behaviour. + +- [#1603](https://github.com/UsefulSoftwareCo/executor/pull/1603) [`624e85f`](https://github.com/UsefulSoftwareCo/executor/commit/624e85f033632a7624c2bddf0944112166b1f481) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Fix: `oauth.clients.remove` reported success for clients it never removed** + + The tool returned `{ removed: true }` unconditionally. `oauth.removeClient` is idempotent by design at the storage layer — `deleteMany` on a missing row is a no-op, which is the right behaviour for a delete — but the tool mapped that silence to success, so a typo'd slug, an already-deleted client, and the wrong owner were all indistinguishable from a real deletion. + + This bites hardest because clients are keyed by BOTH owner and slug, so the same slug can exist separately under `org` and `user`. An agent sweeping a list of slugs under one hardcoded owner would delete only half of them and report every call as a success, leaving org-owned OAuth apps registered after everything they authorized was gone. + + The tool now checks the caller-visible client set first and returns `removed: false` when nothing matched that `(owner, slug)` pair. The service-level `removeClient` is unchanged and stays idempotent. + +- Updated dependencies [[`d572658`](https://github.com/UsefulSoftwareCo/executor/commit/d572658d74097917412256f10a3ea2e3974f44dd)]: + - @executor-js/sdk@1.5.41 + - @executor-js/local@1.5.41 + - @executor-js/api@1.4.61 + - @executor-js/runtime-quickjs@1.5.41 + ## 1.5.40 ### Patch Changes diff --git a/apps/cli/package.json b/apps/cli/package.json index 26c257c69a..17283aeb5c 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "executor", - "version": "1.5.40", + "version": "1.5.41", "private": true, "bin": { "executor": "./bin/executor.ts" diff --git a/apps/cloud/CHANGELOG.md b/apps/cloud/CHANGELOG.md index acabecc719..6cbd8108b9 100644 --- a/apps/cloud/CHANGELOG.md +++ b/apps/cloud/CHANGELOG.md @@ -1,5 +1,26 @@ # @executor-js/cloud +## 1.4.59 + +### Patch Changes + +- Updated dependencies [[`d572658`](https://github.com/UsefulSoftwareCo/executor/commit/d572658d74097917412256f10a3ea2e3974f44dd), [`a9b33d2`](https://github.com/UsefulSoftwareCo/executor/commit/a9b33d25c32fbb4a292b7e8963e22392f862a16f)]: + - @executor-js/sdk@1.5.41 + - @executor-js/plugin-mcp@1.5.41 + - @executor-js/api@1.4.61 + - @executor-js/execution@1.5.41 + - @executor-js/vite-plugin@0.0.58 + - @executor-js/cloudflare@0.0.40 + - @executor-js/host-mcp@1.4.4 + - @executor-js/mcp-apps-shell@1.4.9 + - @executor-js/runtime-dynamic-worker@1.4.4 + - @executor-js/plugin-graphql@1.5.41 + - @executor-js/plugin-openapi@1.5.41 + - @executor-js/plugin-toolkits@1.5.33 + - @executor-js/plugin-workos-vault@0.0.2 + - @executor-js/react@1.4.61 + - @executor-js/runtime-quickjs@1.5.41 + ## 1.4.58 ### Patch Changes diff --git a/apps/cloud/package.json b/apps/cloud/package.json index 6ce693125e..cd801b0723 100644 --- a/apps/cloud/package.json +++ b/apps/cloud/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/cloud", - "version": "1.4.58", + "version": "1.4.59", "private": true, "type": "module", "scripts": { diff --git a/apps/desktop/CHANGELOG.md b/apps/desktop/CHANGELOG.md index fafcf32216..f5b8a9f0d2 100644 --- a/apps/desktop/CHANGELOG.md +++ b/apps/desktop/CHANGELOG.md @@ -1,5 +1,7 @@ # @executor-js/desktop +## 1.5.41 + ## 1.5.40 ## 1.5.39 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 57e96c240d..41757cb8f7 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/desktop", - "version": "1.5.40", + "version": "1.5.41", "private": true, "homepage": "https://github.com/UsefulSoftwareCo/executor", "license": "MIT", diff --git a/apps/host-selfhost/CHANGELOG.md b/apps/host-selfhost/CHANGELOG.md index 808bc643f7..d36703c0c5 100644 --- a/apps/host-selfhost/CHANGELOG.md +++ b/apps/host-selfhost/CHANGELOG.md @@ -1,5 +1,26 @@ # @executor-js/host-selfhost +## 0.0.40 + +### Patch Changes + +- Updated dependencies [[`d572658`](https://github.com/UsefulSoftwareCo/executor/commit/d572658d74097917412256f10a3ea2e3974f44dd), [`a9b33d2`](https://github.com/UsefulSoftwareCo/executor/commit/a9b33d25c32fbb4a292b7e8963e22392f862a16f)]: + - @executor-js/sdk@1.5.41 + - @executor-js/plugin-mcp@1.5.41 + - @executor-js/app@1.4.4 + - @executor-js/analytics@0.1.5 + - @executor-js/api@1.4.61 + - @executor-js/execution@1.5.41 + - @executor-js/host-mcp@1.4.4 + - @executor-js/mcp-apps-shell@1.4.9 + - @executor-js/plugin-encrypted-secrets@0.0.40 + - @executor-js/plugin-graphql@1.5.41 + - @executor-js/plugin-openapi@1.5.41 + - @executor-js/plugin-provider-service-split@0.0.12 + - @executor-js/plugin-toolkits@1.5.33 + - @executor-js/react@1.4.61 + - @executor-js/runtime-quickjs@1.5.41 + ## 0.0.39 ### Patch Changes diff --git a/apps/host-selfhost/package.json b/apps/host-selfhost/package.json index b15f14a811..6be5f51117 100644 --- a/apps/host-selfhost/package.json +++ b/apps/host-selfhost/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/host-selfhost", - "version": "0.0.39", + "version": "0.0.40", "private": true, "type": "module", "exports": { diff --git a/apps/local/CHANGELOG.md b/apps/local/CHANGELOG.md index 816ed53feb..f6048198c0 100644 --- a/apps/local/CHANGELOG.md +++ b/apps/local/CHANGELOG.md @@ -1,5 +1,32 @@ # @executor-js/local +## 1.5.41 + +### Patch Changes + +- Updated dependencies [[`d572658`](https://github.com/UsefulSoftwareCo/executor/commit/d572658d74097917412256f10a3ea2e3974f44dd), [`a9b33d2`](https://github.com/UsefulSoftwareCo/executor/commit/a9b33d25c32fbb4a292b7e8963e22392f862a16f)]: + - @executor-js/sdk@1.5.41 + - @executor-js/plugin-mcp@1.5.41 + - @executor-js/app@1.4.4 + - @executor-js/analytics@0.1.5 + - @executor-js/api@1.4.61 + - @executor-js/config@1.5.41 + - @executor-js/execution@1.5.41 + - @executor-js/vite-plugin@0.0.58 + - @executor-js/host-mcp@1.4.4 + - @executor-js/mcp-apps-shell@1.4.9 + - @executor-js/plugin-desktop-settings@1.5.41 + - @executor-js/plugin-example@1.5.41 + - @executor-js/plugin-file-secrets@1.5.41 + - @executor-js/plugin-graphql@1.5.41 + - @executor-js/plugin-keychain@1.5.41 + - @executor-js/plugin-onepassword@1.5.41 + - @executor-js/plugin-openapi@1.5.41 + - @executor-js/plugin-provider-service-split@0.0.12 + - @executor-js/plugin-toolkits@1.5.33 + - @executor-js/react@1.4.61 + - @executor-js/runtime-quickjs@1.5.41 + ## 1.5.40 ### Patch Changes diff --git a/apps/local/package.json b/apps/local/package.json index 255c0438d7..cff47c8940 100644 --- a/apps/local/package.json +++ b/apps/local/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/local", - "version": "1.5.40", + "version": "1.5.41", "private": true, "type": "module", "exports": { diff --git a/bun.lock b/bun.lock index 7ee2877aca..a4266d315b 100644 --- a/bun.lock +++ b/bun.lock @@ -31,7 +31,7 @@ }, "apps/cli": { "name": "executor", - "version": "1.5.40", + "version": "1.5.41", "bin": { "executor": "./bin/executor.ts", }, @@ -61,7 +61,7 @@ }, "apps/cloud": { "name": "@executor-js/cloud", - "version": "1.4.58", + "version": "1.4.59", "dependencies": { "@cloudflare/vite-plugin": "^1.31.1", "@effect/atom-react": "catalog:", @@ -134,7 +134,7 @@ }, "apps/desktop": { "name": "@executor-js/desktop", - "version": "1.5.40", + "version": "1.5.41", "dependencies": { "@sentry/bun": "^10.57.0", "@sentry/electron": "^7.13.0", @@ -221,7 +221,7 @@ }, "apps/host-selfhost": { "name": "@executor-js/host-selfhost", - "version": "0.0.39", + "version": "0.0.40", "dependencies": { "@better-auth/api-key": "^1.6.11", "@cloudflare/worker-bundler": "0.2.1", @@ -273,7 +273,7 @@ }, "apps/local": { "name": "@executor-js/local", - "version": "1.5.40", + "version": "1.5.41", "dependencies": { "@effect/atom-react": "catalog:", "@effect/platform-node": "catalog:", @@ -355,7 +355,7 @@ }, "e2e": { "name": "@executor-js/e2e", - "version": "0.0.37", + "version": "0.0.38", "dependencies": { "@executor-js/api": "workspace:*", "@executor-js/emulate": "^0.13.9", @@ -393,7 +393,7 @@ }, "examples/all-plugins": { "name": "@executor-js/example-all-plugins", - "version": "0.0.58", + "version": "0.0.59", "dependencies": { "@executor-js/plugin-file-secrets": "workspace:*", "@executor-js/plugin-graphql": "workspace:*", @@ -412,7 +412,7 @@ }, "examples/docs-sdk-quickstart": { "name": "@executor-js/example-docs-sdk-quickstart", - "version": "0.0.43", + "version": "0.0.44", "dependencies": { "@executor-js/plugin-openapi": "workspace:*", "@executor-js/sdk": "workspace:*", @@ -467,7 +467,7 @@ }, "packages/core/analytics": { "name": "@executor-js/analytics", - "version": "0.1.4", + "version": "0.1.5", "dependencies": { "@effect/platform-node": "catalog:", "@executor-js/execution": "workspace:*", @@ -483,7 +483,7 @@ }, "packages/core/api": { "name": "@executor-js/api", - "version": "1.4.60", + "version": "1.4.61", "dependencies": { "@executor-js/execution": "workspace:*", "@executor-js/host-mcp": "workspace:*", @@ -500,7 +500,7 @@ }, "packages/core/cli": { "name": "@executor-js/cli", - "version": "0.2.47", + "version": "0.2.48", "bin": { "executor-sdk": "./dist/index.js", }, @@ -521,7 +521,7 @@ }, "packages/core/config": { "name": "@executor-js/config", - "version": "1.5.40", + "version": "1.5.41", "dependencies": { "@executor-js/sdk": "workspace:*", "jiti": "^2.6.1", @@ -542,7 +542,7 @@ }, "packages/core/execution": { "name": "@executor-js/execution", - "version": "1.5.40", + "version": "1.5.41", "dependencies": { "@executor-js/codemode-core": "workspace:*", "@executor-js/sdk": "workspace:*", @@ -608,7 +608,7 @@ }, "packages/core/sdk": { "name": "@executor-js/sdk", - "version": "1.5.40", + "version": "1.5.41", "dependencies": { "@executor-js/fumadb": "workspace:*", "@standard-schema/spec": "^1.1.0", @@ -661,7 +661,7 @@ }, "packages/core/vite-plugin": { "name": "@executor-js/vite-plugin", - "version": "0.0.57", + "version": "0.0.58", "dependencies": { "@executor-js/sdk": "workspace:*", "jiti": "^2.6.1", @@ -681,7 +681,7 @@ }, "packages/hosts/cloudflare": { "name": "@executor-js/cloudflare", - "version": "0.0.39", + "version": "0.0.40", "dependencies": { "@executor-js/api": "workspace:*", "@executor-js/execution": "workspace:*", @@ -723,7 +723,7 @@ }, "packages/hosts/mcp-apps-shell": { "name": "@executor-js/mcp-apps-shell", - "version": "1.4.8", + "version": "1.4.9", "dependencies": { "@executor-js/react": "workspace:*", "@executor-js/runtime-quickjs": "workspace:*", @@ -761,7 +761,7 @@ }, "packages/kernel/core": { "name": "@executor-js/codemode-core", - "version": "1.5.40", + "version": "1.5.41", "dependencies": { "@babel/parser": "^7.29.2", "@standard-schema/spec": "^1.0.0", @@ -834,7 +834,7 @@ }, "packages/kernel/runtime-quickjs": { "name": "@executor-js/runtime-quickjs", - "version": "1.5.40", + "version": "1.5.41", "dependencies": { "@executor-js/codemode-core": "workspace:*", "quickjs-emscripten": "catalog:", @@ -854,7 +854,7 @@ }, "packages/kernel/runtime-workerd-subprocess": { "name": "@executor-js/runtime-workerd-subprocess", - "version": "0.0.12", + "version": "0.0.13", "dependencies": { "@executor-js/codemode-core": "workspace:*", "effect": "catalog:", @@ -869,7 +869,7 @@ }, "packages/plugins/desktop-settings": { "name": "@executor-js/plugin-desktop-settings", - "version": "1.5.40", + "version": "1.5.41", "dependencies": { "@executor-js/sdk": "workspace:*", "react": "catalog:", @@ -882,7 +882,7 @@ }, "packages/plugins/encrypted-secrets": { "name": "@executor-js/plugin-encrypted-secrets", - "version": "0.0.39", + "version": "0.0.40", "dependencies": { "@executor-js/sdk": "workspace:*", "effect": "catalog:", @@ -897,7 +897,7 @@ }, "packages/plugins/example": { "name": "@executor-js/plugin-example", - "version": "1.5.40", + "version": "1.5.41", "dependencies": { "@executor-js/sdk": "workspace:*", }, @@ -920,7 +920,7 @@ }, "packages/plugins/file-secrets": { "name": "@executor-js/plugin-file-secrets", - "version": "1.5.40", + "version": "1.5.41", "dependencies": { "@executor-js/sdk": "workspace:*", }, @@ -937,7 +937,7 @@ }, "packages/plugins/graphql": { "name": "@executor-js/plugin-graphql", - "version": "1.5.40", + "version": "1.5.41", "dependencies": { "@effect/platform-node": "catalog:", "@executor-js/config": "workspace:*", @@ -976,7 +976,7 @@ }, "packages/plugins/keychain": { "name": "@executor-js/plugin-keychain", - "version": "1.5.40", + "version": "1.5.41", "dependencies": { "@executor-js/sdk": "workspace:*", "@napi-rs/keyring": "^1.2.0", @@ -995,7 +995,7 @@ }, "packages/plugins/mcp": { "name": "@executor-js/plugin-mcp", - "version": "1.5.40", + "version": "1.5.41", "dependencies": { "@cfworker/json-schema": "^4.1.1", "@effect/platform-node": "catalog:", @@ -1037,7 +1037,7 @@ }, "packages/plugins/onepassword": { "name": "@executor-js/plugin-onepassword", - "version": "1.5.40", + "version": "1.5.41", "dependencies": { "@1password/op-js": "^0.1.13", "@1password/sdk": "^0.4.1-beta.1", @@ -1071,7 +1071,7 @@ }, "packages/plugins/openapi": { "name": "@executor-js/plugin-openapi", - "version": "1.5.40", + "version": "1.5.41", "dependencies": { "@effect/platform-node": "catalog:", "@executor-js/config": "workspace:*", @@ -1112,7 +1112,7 @@ }, "packages/plugins/provider-service-split": { "name": "@executor-js/plugin-provider-service-split", - "version": "0.0.11", + "version": "0.0.12", "dependencies": { "@executor-js/plugin-openapi": "workspace:*", "@executor-js/sdk": "workspace:*", @@ -1129,7 +1129,7 @@ }, "packages/plugins/toolkits": { "name": "@executor-js/plugin-toolkits", - "version": "1.5.32", + "version": "1.5.33", "dependencies": { "@executor-js/sdk": "workspace:*", }, @@ -1198,7 +1198,7 @@ }, "packages/react": { "name": "@executor-js/react", - "version": "1.4.60", + "version": "1.4.61", "dependencies": { "@base-ui/react": "^1.3.0", "@effect/atom-react": "catalog:", diff --git a/e2e/CHANGELOG.md b/e2e/CHANGELOG.md index c93cc1de9d..fe3bf7c892 100644 --- a/e2e/CHANGELOG.md +++ b/e2e/CHANGELOG.md @@ -1,5 +1,17 @@ # @executor-js/e2e +## 0.0.38 + +### Patch Changes + +- Updated dependencies [[`d572658`](https://github.com/UsefulSoftwareCo/executor/commit/d572658d74097917412256f10a3ea2e3974f44dd), [`a9b33d2`](https://github.com/UsefulSoftwareCo/executor/commit/a9b33d25c32fbb4a292b7e8963e22392f862a16f)]: + - @executor-js/sdk@1.5.41 + - @executor-js/plugin-mcp@1.5.41 + - @executor-js/api@1.4.61 + - @executor-js/plugin-graphql@1.5.41 + - @executor-js/plugin-openapi@1.5.41 + - @executor-js/plugin-toolkits@1.5.33 + ## 0.0.37 ### Patch Changes diff --git a/e2e/package.json b/e2e/package.json index 679c9a9f9e..3a793f50fa 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/e2e", - "version": "0.0.37", + "version": "0.0.38", "private": true, "type": "module", "scripts": { diff --git a/examples/all-plugins/CHANGELOG.md b/examples/all-plugins/CHANGELOG.md index 35c33a5f38..5c5293d87c 100644 --- a/examples/all-plugins/CHANGELOG.md +++ b/examples/all-plugins/CHANGELOG.md @@ -1,5 +1,19 @@ # @executor-js/example-all-plugins +## 0.0.59 + +### Patch Changes + +- Updated dependencies [[`d572658`](https://github.com/UsefulSoftwareCo/executor/commit/d572658d74097917412256f10a3ea2e3974f44dd), [`a9b33d2`](https://github.com/UsefulSoftwareCo/executor/commit/a9b33d25c32fbb4a292b7e8963e22392f862a16f)]: + - @executor-js/sdk@1.5.41 + - @executor-js/plugin-mcp@1.5.41 + - @executor-js/plugin-file-secrets@1.5.41 + - @executor-js/plugin-graphql@1.5.41 + - @executor-js/plugin-keychain@1.5.41 + - @executor-js/plugin-onepassword@1.5.41 + - @executor-js/plugin-openapi@1.5.41 + - @executor-js/plugin-workos-vault@0.0.2 + ## 0.0.58 ### Patch Changes diff --git a/examples/all-plugins/package.json b/examples/all-plugins/package.json index 704d79974f..67b3383ee0 100644 --- a/examples/all-plugins/package.json +++ b/examples/all-plugins/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/example-all-plugins", - "version": "0.0.58", + "version": "0.0.59", "private": true, "type": "module", "scripts": { diff --git a/examples/docs-sdk-quickstart/CHANGELOG.md b/examples/docs-sdk-quickstart/CHANGELOG.md index 5e45775862..8836e9b462 100644 --- a/examples/docs-sdk-quickstart/CHANGELOG.md +++ b/examples/docs-sdk-quickstart/CHANGELOG.md @@ -1,5 +1,13 @@ # @executor-js/example-docs-sdk-quickstart +## 0.0.44 + +### Patch Changes + +- Updated dependencies [[`d572658`](https://github.com/UsefulSoftwareCo/executor/commit/d572658d74097917412256f10a3ea2e3974f44dd)]: + - @executor-js/sdk@1.5.41 + - @executor-js/plugin-openapi@1.5.41 + ## 0.0.43 ### Patch Changes diff --git a/examples/docs-sdk-quickstart/package.json b/examples/docs-sdk-quickstart/package.json index 1f0c8996ae..fc9c331e94 100644 --- a/examples/docs-sdk-quickstart/package.json +++ b/examples/docs-sdk-quickstart/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/example-docs-sdk-quickstart", - "version": "0.0.43", + "version": "0.0.44", "private": true, "type": "module", "scripts": { diff --git a/packages/core/analytics/CHANGELOG.md b/packages/core/analytics/CHANGELOG.md index 0bc298317e..c7f7b34bdc 100644 --- a/packages/core/analytics/CHANGELOG.md +++ b/packages/core/analytics/CHANGELOG.md @@ -1,5 +1,12 @@ # @executor-js/analytics +## 0.1.5 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/execution@1.5.41 + ## 0.1.4 ### Patch Changes diff --git a/packages/core/analytics/package.json b/packages/core/analytics/package.json index 95c03eaebe..bcc42e977a 100644 --- a/packages/core/analytics/package.json +++ b/packages/core/analytics/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/analytics", - "version": "0.1.4", + "version": "0.1.5", "private": true, "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/analytics", "bugs": { diff --git a/packages/core/api/CHANGELOG.md b/packages/core/api/CHANGELOG.md index 79012dca2b..7c4e9baf87 100644 --- a/packages/core/api/CHANGELOG.md +++ b/packages/core/api/CHANGELOG.md @@ -1,5 +1,14 @@ # @executor-js/api +## 1.4.61 + +### Patch Changes + +- Updated dependencies [[`d572658`](https://github.com/UsefulSoftwareCo/executor/commit/d572658d74097917412256f10a3ea2e3974f44dd)]: + - @executor-js/sdk@1.5.41 + - @executor-js/execution@1.5.41 + - @executor-js/host-mcp@1.4.4 + ## 1.4.60 ### Patch Changes diff --git a/packages/core/api/package.json b/packages/core/api/package.json index ddebb9e9f7..b162989e81 100644 --- a/packages/core/api/package.json +++ b/packages/core/api/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/api", - "version": "1.4.60", + "version": "1.4.61", "private": true, "type": "module", "exports": { diff --git a/packages/core/cli/CHANGELOG.md b/packages/core/cli/CHANGELOG.md index 75106a874f..56396bb0ce 100644 --- a/packages/core/cli/CHANGELOG.md +++ b/packages/core/cli/CHANGELOG.md @@ -1,5 +1,12 @@ # @executor-js/cli +## 0.2.48 + +### Patch Changes + +- Updated dependencies [[`d572658`](https://github.com/UsefulSoftwareCo/executor/commit/d572658d74097917412256f10a3ea2e3974f44dd)]: + - @executor-js/sdk@1.5.41 + ## 0.2.47 ### Patch Changes diff --git a/packages/core/cli/package.json b/packages/core/cli/package.json index 633f3de253..d22baeaf3c 100644 --- a/packages/core/cli/package.json +++ b/packages/core/cli/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/cli", - "version": "0.2.47", + "version": "0.2.48", "description": "CLI for the executor SDK — schema generation, migrations", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/cli", "bugs": { diff --git a/packages/core/config/CHANGELOG.md b/packages/core/config/CHANGELOG.md index 4b007a9b98..37f3278614 100644 --- a/packages/core/config/CHANGELOG.md +++ b/packages/core/config/CHANGELOG.md @@ -1,5 +1,12 @@ # @executor-js/config +## 1.5.41 + +### Patch Changes + +- Updated dependencies [[`d572658`](https://github.com/UsefulSoftwareCo/executor/commit/d572658d74097917412256f10a3ea2e3974f44dd)]: + - @executor-js/sdk@1.5.41 + ## 1.5.40 ### Patch Changes diff --git a/packages/core/config/package.json b/packages/core/config/package.json index 90c125e64f..64f281307c 100644 --- a/packages/core/config/package.json +++ b/packages/core/config/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/config", - "version": "1.5.40", + "version": "1.5.41", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/config", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/core/execution/CHANGELOG.md b/packages/core/execution/CHANGELOG.md index 8d4af000f5..ad3dbcc04f 100644 --- a/packages/core/execution/CHANGELOG.md +++ b/packages/core/execution/CHANGELOG.md @@ -1,5 +1,13 @@ # @executor-js/execution +## 1.5.41 + +### Patch Changes + +- Updated dependencies [[`d572658`](https://github.com/UsefulSoftwareCo/executor/commit/d572658d74097917412256f10a3ea2e3974f44dd)]: + - @executor-js/sdk@1.5.41 + - @executor-js/codemode-core@1.5.41 + ## 1.5.40 ### Patch Changes diff --git a/packages/core/execution/package.json b/packages/core/execution/package.json index d007bb579e..6e5718eaec 100644 --- a/packages/core/execution/package.json +++ b/packages/core/execution/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/execution", - "version": "1.5.40", + "version": "1.5.41", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/execution", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/core/sdk/CHANGELOG.md b/packages/core/sdk/CHANGELOG.md index 90f53fc220..d74c5642a7 100644 --- a/packages/core/sdk/CHANGELOG.md +++ b/packages/core/sdk/CHANGELOG.md @@ -1,5 +1,26 @@ # @executor-js/sdk +## 1.5.41 + +### Patch Changes + +- [#1580](https://github.com/UsefulSoftwareCo/executor/pull/1580) [`d572658`](https://github.com/UsefulSoftwareCo/executor/commit/d572658d74097917412256f10a3ea2e3974f44dd) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Fix: the admin joined user view no longer issues one connection query per subject** + + `admin.listSubjectsWithConnections` read a page of subjects and then queried + connections once per subject, sequentially. A default page therefore cost 100 + round trips inside a single request, which on a per-request socket dominated + the response. It now reads the page and then batches every subject's + connections into one query, so the cost is two queries regardless of page size. + A subject with no connections still reports an empty array rather than dropping + out of the page, and the batched read carries the same `owner: "user"` and + tenant scoping the per-subject read did. + + The `?email=` filter on the admin users endpoints is also applied before the + read rather than after it: the address resolves to a principal id and that id is + read directly, instead of paging the tenant and keeping the row that matched. + Paging still applies to a filtered response, but to the selected row — one row + at `offset: 0`, empty beyond it. + ## 1.5.40 ### Patch Changes diff --git a/packages/core/sdk/package.json b/packages/core/sdk/package.json index 49cc48e2bf..974a88022b 100644 --- a/packages/core/sdk/package.json +++ b/packages/core/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/sdk", - "version": "1.5.40", + "version": "1.5.41", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/sdk", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/core/vite-plugin/CHANGELOG.md b/packages/core/vite-plugin/CHANGELOG.md index beb34e89ee..93320a0c90 100644 --- a/packages/core/vite-plugin/CHANGELOG.md +++ b/packages/core/vite-plugin/CHANGELOG.md @@ -1,5 +1,12 @@ # @executor-js/vite-plugin +## 0.0.58 + +### Patch Changes + +- Updated dependencies [[`d572658`](https://github.com/UsefulSoftwareCo/executor/commit/d572658d74097917412256f10a3ea2e3974f44dd)]: + - @executor-js/sdk@1.5.41 + ## 0.0.57 ### Patch Changes diff --git a/packages/core/vite-plugin/package.json b/packages/core/vite-plugin/package.json index 04876dd591..9e5cc55cba 100644 --- a/packages/core/vite-plugin/package.json +++ b/packages/core/vite-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/vite-plugin", - "version": "0.0.57", + "version": "0.0.58", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/vite-plugin", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/hosts/cloudflare/CHANGELOG.md b/packages/hosts/cloudflare/CHANGELOG.md index 739570b034..14c9e5870e 100644 --- a/packages/hosts/cloudflare/CHANGELOG.md +++ b/packages/hosts/cloudflare/CHANGELOG.md @@ -1,5 +1,15 @@ # @executor-js/cloudflare +## 0.0.40 + +### Patch Changes + +- Updated dependencies [[`d572658`](https://github.com/UsefulSoftwareCo/executor/commit/d572658d74097917412256f10a3ea2e3974f44dd)]: + - @executor-js/sdk@1.5.41 + - @executor-js/api@1.4.61 + - @executor-js/execution@1.5.41 + - @executor-js/host-mcp@1.4.4 + ## 0.0.39 ### Patch Changes diff --git a/packages/hosts/cloudflare/package.json b/packages/hosts/cloudflare/package.json index 26f3ed67eb..45f148a1f0 100644 --- a/packages/hosts/cloudflare/package.json +++ b/packages/hosts/cloudflare/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/cloudflare", - "version": "0.0.39", + "version": "0.0.40", "private": true, "type": "module", "exports": { diff --git a/packages/hosts/mcp-apps-shell/CHANGELOG.md b/packages/hosts/mcp-apps-shell/CHANGELOG.md index 3f8c57b346..3bf4d4f92d 100644 --- a/packages/hosts/mcp-apps-shell/CHANGELOG.md +++ b/packages/hosts/mcp-apps-shell/CHANGELOG.md @@ -1,5 +1,13 @@ # @executor-js/mcp-apps-shell +## 1.4.9 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/react@1.4.61 + - @executor-js/runtime-quickjs@1.5.41 + ## 1.4.8 ### Patch Changes diff --git a/packages/hosts/mcp-apps-shell/package.json b/packages/hosts/mcp-apps-shell/package.json index f2f762f380..97c1e70bf4 100644 --- a/packages/hosts/mcp-apps-shell/package.json +++ b/packages/hosts/mcp-apps-shell/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/mcp-apps-shell", - "version": "1.4.8", + "version": "1.4.9", "private": true, "type": "module", "exports": { diff --git a/packages/kernel/core/CHANGELOG.md b/packages/kernel/core/CHANGELOG.md index 5507cbc713..e78d6a09f3 100644 --- a/packages/kernel/core/CHANGELOG.md +++ b/packages/kernel/core/CHANGELOG.md @@ -1,5 +1,7 @@ # @executor-js/codemode-core +## 1.5.41 + ## 1.5.40 ## 1.5.39 diff --git a/packages/kernel/core/package.json b/packages/kernel/core/package.json index e6bc54e58f..c0df8960df 100644 --- a/packages/kernel/core/package.json +++ b/packages/kernel/core/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/codemode-core", - "version": "1.5.40", + "version": "1.5.41", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/kernel/core", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/kernel/runtime-quickjs/CHANGELOG.md b/packages/kernel/runtime-quickjs/CHANGELOG.md index 1dd016f8f4..93343b53d6 100644 --- a/packages/kernel/runtime-quickjs/CHANGELOG.md +++ b/packages/kernel/runtime-quickjs/CHANGELOG.md @@ -1,5 +1,12 @@ # @executor-js/runtime-quickjs +## 1.5.41 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/codemode-core@1.5.41 + ## 1.5.40 ### Patch Changes diff --git a/packages/kernel/runtime-quickjs/package.json b/packages/kernel/runtime-quickjs/package.json index aa2517e141..ecdea06bce 100644 --- a/packages/kernel/runtime-quickjs/package.json +++ b/packages/kernel/runtime-quickjs/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/runtime-quickjs", - "version": "1.5.40", + "version": "1.5.41", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/kernel/runtime-quickjs", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/kernel/runtime-workerd-subprocess/CHANGELOG.md b/packages/kernel/runtime-workerd-subprocess/CHANGELOG.md index 38b6d0afa2..bf54459ac7 100644 --- a/packages/kernel/runtime-workerd-subprocess/CHANGELOG.md +++ b/packages/kernel/runtime-workerd-subprocess/CHANGELOG.md @@ -1,5 +1,12 @@ # @executor-js/runtime-workerd-subprocess +## 0.0.13 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/codemode-core@1.5.41 + ## 0.0.12 ### Patch Changes diff --git a/packages/kernel/runtime-workerd-subprocess/package.json b/packages/kernel/runtime-workerd-subprocess/package.json index e3aaf86f56..45b0fff929 100644 --- a/packages/kernel/runtime-workerd-subprocess/package.json +++ b/packages/kernel/runtime-workerd-subprocess/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/runtime-workerd-subprocess", - "version": "0.0.12", + "version": "0.0.13", "private": true, "type": "module", "exports": { diff --git a/packages/plugins/desktop-settings/CHANGELOG.md b/packages/plugins/desktop-settings/CHANGELOG.md index 3eb31d1cc3..3a934d8cc1 100644 --- a/packages/plugins/desktop-settings/CHANGELOG.md +++ b/packages/plugins/desktop-settings/CHANGELOG.md @@ -1,5 +1,12 @@ # @executor-js/plugin-desktop-settings +## 1.5.41 + +### Patch Changes + +- Updated dependencies [[`d572658`](https://github.com/UsefulSoftwareCo/executor/commit/d572658d74097917412256f10a3ea2e3974f44dd)]: + - @executor-js/sdk@1.5.41 + ## 1.5.40 ### Patch Changes diff --git a/packages/plugins/desktop-settings/package.json b/packages/plugins/desktop-settings/package.json index 81cf91e0c0..9e38635f71 100644 --- a/packages/plugins/desktop-settings/package.json +++ b/packages/plugins/desktop-settings/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-desktop-settings", - "version": "1.5.40", + "version": "1.5.41", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/desktop-settings", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/plugins/encrypted-secrets/CHANGELOG.md b/packages/plugins/encrypted-secrets/CHANGELOG.md index a7bb18a5d5..2e514b7e4a 100644 --- a/packages/plugins/encrypted-secrets/CHANGELOG.md +++ b/packages/plugins/encrypted-secrets/CHANGELOG.md @@ -1,5 +1,12 @@ # @executor-js/plugin-encrypted-secrets +## 0.0.40 + +### Patch Changes + +- Updated dependencies [[`d572658`](https://github.com/UsefulSoftwareCo/executor/commit/d572658d74097917412256f10a3ea2e3974f44dd)]: + - @executor-js/sdk@1.5.41 + ## 0.0.39 ### Patch Changes diff --git a/packages/plugins/encrypted-secrets/package.json b/packages/plugins/encrypted-secrets/package.json index 2da6cd92e1..ce6c763ab9 100644 --- a/packages/plugins/encrypted-secrets/package.json +++ b/packages/plugins/encrypted-secrets/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-encrypted-secrets", - "version": "0.0.39", + "version": "0.0.40", "private": true, "type": "module", "exports": { diff --git a/packages/plugins/example/CHANGELOG.md b/packages/plugins/example/CHANGELOG.md index 3e56f2cf29..887b7e24c5 100644 --- a/packages/plugins/example/CHANGELOG.md +++ b/packages/plugins/example/CHANGELOG.md @@ -1,5 +1,12 @@ # @executor-js/plugin-example +## 1.5.41 + +### Patch Changes + +- Updated dependencies [[`d572658`](https://github.com/UsefulSoftwareCo/executor/commit/d572658d74097917412256f10a3ea2e3974f44dd)]: + - @executor-js/sdk@1.5.41 + ## 1.5.40 ### Patch Changes diff --git a/packages/plugins/example/package.json b/packages/plugins/example/package.json index a8ebd0d39f..180618b16e 100644 --- a/packages/plugins/example/package.json +++ b/packages/plugins/example/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-example", - "version": "1.5.40", + "version": "1.5.41", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/example", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/plugins/file-secrets/CHANGELOG.md b/packages/plugins/file-secrets/CHANGELOG.md index a0b6f34049..5ebb8d4a1e 100644 --- a/packages/plugins/file-secrets/CHANGELOG.md +++ b/packages/plugins/file-secrets/CHANGELOG.md @@ -1,5 +1,12 @@ # @executor-js/plugin-file-secrets +## 1.5.41 + +### Patch Changes + +- Updated dependencies [[`d572658`](https://github.com/UsefulSoftwareCo/executor/commit/d572658d74097917412256f10a3ea2e3974f44dd)]: + - @executor-js/sdk@1.5.41 + ## 1.5.40 ### Patch Changes diff --git a/packages/plugins/file-secrets/package.json b/packages/plugins/file-secrets/package.json index e85c05d739..0cdb7c7558 100644 --- a/packages/plugins/file-secrets/package.json +++ b/packages/plugins/file-secrets/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-file-secrets", - "version": "1.5.40", + "version": "1.5.41", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/file-secrets", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/plugins/graphql/CHANGELOG.md b/packages/plugins/graphql/CHANGELOG.md index 69bfa7f9e7..a96ef8dd0f 100644 --- a/packages/plugins/graphql/CHANGELOG.md +++ b/packages/plugins/graphql/CHANGELOG.md @@ -1,5 +1,15 @@ # @executor-js/plugin-graphql +## 1.5.41 + +### Patch Changes + +- Updated dependencies [[`d572658`](https://github.com/UsefulSoftwareCo/executor/commit/d572658d74097917412256f10a3ea2e3974f44dd)]: + - @executor-js/sdk@1.5.41 + - @executor-js/api@1.4.61 + - @executor-js/config@1.5.41 + - @executor-js/react@1.4.61 + ## 1.5.40 ### Patch Changes diff --git a/packages/plugins/graphql/package.json b/packages/plugins/graphql/package.json index b01dab9048..34ca6bc979 100644 --- a/packages/plugins/graphql/package.json +++ b/packages/plugins/graphql/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-graphql", - "version": "1.5.40", + "version": "1.5.41", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/graphql", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/plugins/keychain/CHANGELOG.md b/packages/plugins/keychain/CHANGELOG.md index 76b69e6fa3..7126e0b863 100644 --- a/packages/plugins/keychain/CHANGELOG.md +++ b/packages/plugins/keychain/CHANGELOG.md @@ -1,5 +1,12 @@ # @executor-js/plugin-keychain +## 1.5.41 + +### Patch Changes + +- Updated dependencies [[`d572658`](https://github.com/UsefulSoftwareCo/executor/commit/d572658d74097917412256f10a3ea2e3974f44dd)]: + - @executor-js/sdk@1.5.41 + ## 1.5.40 ### Patch Changes diff --git a/packages/plugins/keychain/package.json b/packages/plugins/keychain/package.json index c4574b1e29..fa364aeb0a 100644 --- a/packages/plugins/keychain/package.json +++ b/packages/plugins/keychain/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-keychain", - "version": "1.5.40", + "version": "1.5.41", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/keychain", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/plugins/mcp/CHANGELOG.md b/packages/plugins/mcp/CHANGELOG.md index 5bb19bc6c4..d729d6b605 100644 --- a/packages/plugins/mcp/CHANGELOG.md +++ b/packages/plugins/mcp/CHANGELOG.md @@ -1,5 +1,17 @@ # @executor-js/plugin-mcp +## 1.5.41 + +### Patch Changes + +- [#1617](https://github.com/UsefulSoftwareCo/executor/pull/1617) [`a9b33d2`](https://github.com/UsefulSoftwareCo/executor/commit/a9b33d25c32fbb4a292b7e8963e22392f862a16f) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Support MCP spec 2026-07-28 end to end. The MCP client negotiates the protocol era automatically (`server/discover` probe with legacy fallback), so integrations hosted on modern-only MCP servers connect without the server enabling legacy compatibility. Executor-hosted MCP endpoints serve both eras, and the `executor mcp` stdio bridge passes either era through to the daemon. + +- Updated dependencies [[`d572658`](https://github.com/UsefulSoftwareCo/executor/commit/d572658d74097917412256f10a3ea2e3974f44dd)]: + - @executor-js/sdk@1.5.41 + - @executor-js/api@1.4.61 + - @executor-js/config@1.5.41 + - @executor-js/react@1.4.61 + ## 1.5.40 ### Patch Changes diff --git a/packages/plugins/mcp/package.json b/packages/plugins/mcp/package.json index 13aedbbfb2..13451d29bc 100644 --- a/packages/plugins/mcp/package.json +++ b/packages/plugins/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-mcp", - "version": "1.5.40", + "version": "1.5.41", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/mcp", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/plugins/onepassword/CHANGELOG.md b/packages/plugins/onepassword/CHANGELOG.md index c934cff95f..a1ea0218c2 100644 --- a/packages/plugins/onepassword/CHANGELOG.md +++ b/packages/plugins/onepassword/CHANGELOG.md @@ -1,5 +1,14 @@ # @executor-js/plugin-onepassword +## 1.5.41 + +### Patch Changes + +- Updated dependencies [[`d572658`](https://github.com/UsefulSoftwareCo/executor/commit/d572658d74097917412256f10a3ea2e3974f44dd)]: + - @executor-js/sdk@1.5.41 + - @executor-js/api@1.4.61 + - @executor-js/react@1.4.61 + ## 1.5.40 ### Patch Changes diff --git a/packages/plugins/onepassword/package.json b/packages/plugins/onepassword/package.json index 854eb8cfe8..f42e5a966c 100644 --- a/packages/plugins/onepassword/package.json +++ b/packages/plugins/onepassword/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-onepassword", - "version": "1.5.40", + "version": "1.5.41", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/onepassword", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/plugins/openapi/CHANGELOG.md b/packages/plugins/openapi/CHANGELOG.md index e836c7cd57..3369c43f11 100644 --- a/packages/plugins/openapi/CHANGELOG.md +++ b/packages/plugins/openapi/CHANGELOG.md @@ -1,5 +1,15 @@ # @executor-js/plugin-openapi +## 1.5.41 + +### Patch Changes + +- Updated dependencies [[`d572658`](https://github.com/UsefulSoftwareCo/executor/commit/d572658d74097917412256f10a3ea2e3974f44dd)]: + - @executor-js/sdk@1.5.41 + - @executor-js/api@1.4.61 + - @executor-js/config@1.5.41 + - @executor-js/react@1.4.61 + ## 1.5.40 ### Patch Changes diff --git a/packages/plugins/openapi/package.json b/packages/plugins/openapi/package.json index ca98826ceb..a4a3521889 100644 --- a/packages/plugins/openapi/package.json +++ b/packages/plugins/openapi/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-openapi", - "version": "1.5.40", + "version": "1.5.41", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/openapi", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/plugins/provider-service-split/CHANGELOG.md b/packages/plugins/provider-service-split/CHANGELOG.md index b2aa5d3d88..1380067295 100644 --- a/packages/plugins/provider-service-split/CHANGELOG.md +++ b/packages/plugins/provider-service-split/CHANGELOG.md @@ -1,5 +1,13 @@ # @executor-js/plugin-provider-service-split +## 0.0.12 + +### Patch Changes + +- Updated dependencies [[`d572658`](https://github.com/UsefulSoftwareCo/executor/commit/d572658d74097917412256f10a3ea2e3974f44dd)]: + - @executor-js/sdk@1.5.41 + - @executor-js/plugin-openapi@1.5.41 + ## 0.0.11 ### Patch Changes diff --git a/packages/plugins/provider-service-split/package.json b/packages/plugins/provider-service-split/package.json index e42d5917b0..612ae13919 100644 --- a/packages/plugins/provider-service-split/package.json +++ b/packages/plugins/provider-service-split/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-provider-service-split", - "version": "0.0.11", + "version": "0.0.12", "private": true, "type": "module", "exports": { diff --git a/packages/plugins/toolkits/CHANGELOG.md b/packages/plugins/toolkits/CHANGELOG.md index aaa3bcc6e7..4b172603a5 100644 --- a/packages/plugins/toolkits/CHANGELOG.md +++ b/packages/plugins/toolkits/CHANGELOG.md @@ -1,5 +1,14 @@ # @executor-js/plugin-toolkits +## 1.5.33 + +### Patch Changes + +- Updated dependencies [[`d572658`](https://github.com/UsefulSoftwareCo/executor/commit/d572658d74097917412256f10a3ea2e3974f44dd)]: + - @executor-js/sdk@1.5.41 + - @executor-js/api@1.4.61 + - @executor-js/react@1.4.61 + ## 1.5.32 ### Patch Changes diff --git a/packages/plugins/toolkits/package.json b/packages/plugins/toolkits/package.json index 5fccb36610..d451d3dfec 100644 --- a/packages/plugins/toolkits/package.json +++ b/packages/plugins/toolkits/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-toolkits", - "version": "1.5.32", + "version": "1.5.33", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/toolkits", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index 22fcbf58e4..8e0cc56a2c 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -1,5 +1,13 @@ # @executor-js/react +## 1.4.61 + +### Patch Changes + +- Updated dependencies [[`d572658`](https://github.com/UsefulSoftwareCo/executor/commit/d572658d74097917412256f10a3ea2e3974f44dd)]: + - @executor-js/sdk@1.5.41 + - @executor-js/api@1.4.61 + ## 1.4.60 ### Patch Changes diff --git a/packages/react/package.json b/packages/react/package.json index 6bc15b9504..8cbbd971b8 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/react", - "version": "1.4.60", + "version": "1.4.61", "private": true, "type": "module", "exports": { From 6dff8916344a394c9166221df7e1d2345cefec9c Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 17 Aug 2026 10:22:40 -0600 Subject: [PATCH 010/133] Isolate the legacy GHCR mirror in its own release job (#1620) --- .github/workflows/publish-selfhost-docker.yml | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish-selfhost-docker.yml b/.github/workflows/publish-selfhost-docker.yml index 51715ff860..1da6470320 100644 --- a/.github/workflows/publish-selfhost-docker.yml +++ b/.github/workflows/publish-selfhost-docker.yml @@ -281,9 +281,6 @@ jobs: runs-on: blacksmith-4vcpu-ubuntu-2404 permissions: packages: write - env: - HAS_LEGACY_TOKEN: ${{ secrets.GHCR_LEGACY_TOKEN != '' }} - steps: - name: Download image digests uses: actions/download-artifact@v4 @@ -330,10 +327,28 @@ jobs: docker buildx imagetools create "${tag_args[@]}" "${sources[@]}" + # Mirrors each release to the pre-org-move namespace + # (ghcr.io/rhyssullivan/executor-selfhost), which GHCR cannot redirect. + # Deliberately a separate job: the canonical publish above never depends on + # it, and a failure here (an expired GHCR_LEGACY_TOKEN, historically) shows + # as one red job pointing at exactly what to fix. + mirror-legacy: + needs: + - metadata + - merge + runs-on: blacksmith-4vcpu-ubuntu-2404 + env: + HAS_LEGACY_TOKEN: ${{ secrets.GHCR_LEGACY_TOKEN != '' }} + + steps: - name: Skip legacy GHCR mirror (GHCR_LEGACY_TOKEN not configured) if: env.HAS_LEGACY_TOKEN != 'true' run: echo "GHCR_LEGACY_TOKEN is not configured; skipping legacy GHCR mirror." + - name: Set up Docker Buildx + if: env.HAS_LEGACY_TOKEN == 'true' + uses: docker/setup-buildx-action@v3 + - name: Log in to legacy GHCR namespace if: env.HAS_LEGACY_TOKEN == 'true' uses: docker/login-action@v3 @@ -342,7 +357,7 @@ jobs: username: rhyssullivan password: ${{ secrets.GHCR_LEGACY_TOKEN }} - - name: Mirror self-host image to legacy GHCR namespace + - name: Mirror release tags to the legacy namespace if: env.HAS_LEGACY_TOKEN == 'true' shell: bash env: From 86c68afef9bf8b7c19ab58f59acfedca0b3c4ca7 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 17 Aug 2026 10:48:21 -0600 Subject: [PATCH 011/133] Stamp client identity on MCP execution spans (#1621) --- .changeset/mcp-client-span-attribution.md | 8 ++ apps/cloud/src/mcp/session-durable-object.ts | 4 + .../src/mcp/agent-session-durable-object.ts | 54 ++++++++++++ packages/hosts/mcp/src/tool-server-core.ts | 84 ++++++++++++++++++- packages/hosts/mcp/src/tool-server.test.ts | 74 ++++++++++++++++ packages/hosts/mcp/src/tool-server.ts | 43 ++++++++++ 6 files changed, 263 insertions(+), 4 deletions(-) create mode 100644 .changeset/mcp-client-span-attribution.md diff --git a/.changeset/mcp-client-span-attribution.md b/.changeset/mcp-client-span-attribution.md new file mode 100644 index 0000000000..c3fc4cda24 --- /dev/null +++ b/.changeset/mcp-client-span-attribution.md @@ -0,0 +1,8 @@ +--- +"@executor-js/host-mcp": patch +"@executor-js/cloudflare": patch +--- + +**MCP execution spans now carry the client identity (`mcp.client.*`)** + +The `clientInfo` a client self-reports at `initialize` (or in a modern request's `_meta`) previously existed only on the initialize request itself, which has no session id yet, so execution telemetry could not be segmented by client. Execute, execute-action, and resume spans (and their descendants) now carry `mcp.client.name` / `mcp.client.version` / `mcp.client.title` alongside the existing session join keys. Cloudflare session Durable Objects persist the reported identity in session meta, so attribution survives cold restores; it feeds telemetry only, never behavior. diff --git a/apps/cloud/src/mcp/session-durable-object.ts b/apps/cloud/src/mcp/session-durable-object.ts index c83a5eddb5..b2bdbe11d2 100644 --- a/apps/cloud/src/mcp/session-durable-object.ts +++ b/apps/cloud/src/mcp/session-durable-object.ts @@ -379,6 +379,10 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase self.persistAppsEnabled(appsEnabled), + // Same restore contract for the client identity that keys the + // `mcp.client.*` span attribution on execution spans. + ...(sessionMeta.clientInfo ? { restoredClientInfo: sessionMeta.clientInfo } : {}), + onClientInfoChange: (clientInfo) => self.persistClientInfo(clientInfo), appsEnabled: false, sessionful: true, requestStateSigningKey: self.modernRequestStateSigningKey(), diff --git a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts index d2454fabb9..79b3e3c5ef 100644 --- a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts +++ b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts @@ -24,11 +24,13 @@ import { import { appsEnabledForClientCapabilities, clientCapabilitiesFromRequestBody, + clientInfoFromRequestBody, mcpRequestStateBindingFromBody, PAUSED_APPROVAL_TIMEOUT_MS, formatMcpExecutionOutcome, mcpRequestStatePrincipal, requestBodyFromRequest, + type McpClientInfo, type PausedExecutionHooks, type ResumeFallbackOutcome, } from "@executor-js/host-mcp/tool-server"; @@ -147,6 +149,14 @@ export interface SessionMeta { * unknown, which behaves as disabled until the next `initialize`. */ readonly appsEnabled?: boolean; + /** + * The client identity (`clientInfo`) self-reported at `initialize` or in a + * modern request's `_meta`. Persisted for the same reason as + * {@link appsEnabled}: a cold-restored server never sees an `initialize`, + * and without this the execution spans' `mcp.client.*` attribution vanishes + * mid-conversation. Telemetry/display only, never behavior. + */ + readonly clientInfo?: McpClientInfo; /** Creation time of this session, retained across isolate eviction. */ readonly createdAtMs?: number; } @@ -164,6 +174,8 @@ export interface ModernMcpServerRequestOptions { readonly requestStateSigningKey: Uint8Array | string; readonly requestStatePrincipal: string; readonly requestStateBinding?: string; + /** Client identity for span attribution: this request's `_meta`, else the session's persisted copy. */ + readonly restoredClientInfo?: McpClientInfo; } /** Long-lived DO execution runtime shared by per-request MCP servers. */ @@ -515,6 +527,34 @@ export abstract class McpAgentSessionDOBase< ); } + /** + * Persist the client identity self-reported at `initialize` (or in a modern + * request's `_meta`), so a cold restore keeps span attribution. Subclasses + * hand this to `buildMcpServer` as `onClientInfoChange`; the modern request + * path calls it directly. Same no-op-before-meta contract as + * {@link persistAppsEnabled}. + */ + protected persistClientInfo(clientInfo: McpClientInfo): Effect.Effect { + const self = this; + return Effect.gen(function* () { + const stored = yield* self.loadSessionMeta(); + if ( + !stored || + (stored.clientInfo?.name === clientInfo.name && + stored.clientInfo?.version === clientInfo.version && + stored.clientInfo?.title === clientInfo.title) + ) { + return; + } + yield* Effect.promise(() => self.saveSessionMeta({ ...stored, clientInfo })); + }).pipe( + Effect.withSpan("mcp.session.persist_client_info", { + attributes: { "mcp.client.name": clientInfo.name }, + }), + Effect.ignoreCause({ log: false }), + ); + } + private async markActivity(now = Date.now()): Promise { this.lastActivityMs = now; const key = @@ -606,6 +646,7 @@ export abstract class McpAgentSessionDOBase< ...resolved, ...(token.webOrigin ? { webOrigin: token.webOrigin } : {}), appsEnabled: stored?.appsEnabled ?? false, + ...(stored?.clientInfo ? { clientInfo: stored.clientInfo } : {}), createdAtMs: stored?.createdAtMs ?? Date.now(), }; yield* Effect.promise(() => self.saveSessionMeta(sessionMeta)).pipe( @@ -816,6 +857,7 @@ export abstract class McpAgentSessionDOBase< const parsedBody = self.modernRequestBodies.get(request); const propagation = self.modernRequestPropagation.get(request); const capabilities = clientCapabilitiesFromRequestBody(parsedBody); + const clientInfo = clientInfoFromRequestBody(parsedBody) ?? sessionMeta.clientInfo; return Effect.runPromise( Effect.gen(function* () { const requestStatePrincipal = mcpRequestStatePrincipal({ @@ -834,6 +876,7 @@ export abstract class McpAgentSessionDOBase< requestStateSigningKey: self.modernRequestStateSigningKey(), requestStatePrincipal, ...(requestStateBinding === null ? {} : { requestStateBinding }), + ...(clientInfo === undefined ? {} : { restoredClientInfo: clientInfo }), }); }).pipe( (effect) => self.withTelemetry(effect, propagation), @@ -1282,6 +1325,17 @@ export abstract class McpAgentSessionDOBase< }); } + // Modern clients self-report identity per request (`_meta` clientInfo) or + // at `initialize`; persist it so meta-less requests and cold restores keep + // their span attribution. Best-effort by construction (persistClientInfo + // swallows failures) and a storage no-op unless the identity changed. + const reportedClientInfo = clientInfoFromRequestBody(parsedBody); + if (reportedClientInfo) { + await Effect.runPromise( + this.withTelemetry(this.persistClientInfo(reportedClientInfo), props.propagation), + ); + } + this.modernRequestBodies.set(request, parsedBody); this.modernRequestPropagation.set(request, props.propagation); this.modernRunningRequestCount += 1; diff --git a/packages/hosts/mcp/src/tool-server-core.ts b/packages/hosts/mcp/src/tool-server-core.ts index ccb07e2f8c..1d77f17130 100644 --- a/packages/hosts/mcp/src/tool-server-core.ts +++ b/packages/hosts/mcp/src/tool-server-core.ts @@ -196,6 +196,30 @@ type SharedMcpServerConfig = { * restore. Best-effort: failures are swallowed and never affect the session. */ readonly onAppsEnabledChange?: (appsEnabled: boolean) => Effect.Effect; + /** + * The client identity self-reported at a previous `initialize` (or in a + * modern request's `_meta`), restored for the same reason as + * {@link restoredAppsEnabled}. Feeds only the `mcp.client.*` span + * attributes on execution spans, never behavior or security decisions. + */ + readonly restoredClientInfo?: McpClientInfo; + /** + * Called when `initialize` reports the client identity, so the host can + * persist it for {@link restoredClientInfo} on a later cold restore. + * Best-effort: failures are swallowed and never affect the session. + */ + readonly onClientInfoChange?: (clientInfo: McpClientInfo) => Effect.Effect; +}; + +/** + * Client software identity as self-reported over MCP (`clientInfo` at + * `initialize`, `_meta` on modern requests). Display and telemetry vocabulary + * only: the spec forbids relying on it for behavior or security. + */ +export type McpClientInfo = { + readonly name: string; + readonly version?: string; + readonly title?: string; }; /** @@ -340,6 +364,8 @@ export type ExecutorMcpAssembly unknown | null; + /** The live `initialize`-reported client identity, when the SDK has one. */ + readonly getClientInfo: () => McpClientInfo | null; readonly getElicitationSupport: () => { readonly form: boolean; readonly url: boolean }; readonly getUiCapability: () => { readonly mimeTypes?: readonly string[] } | undefined; readonly onInitialized: (callback: () => void) => void; @@ -782,6 +808,21 @@ const joinKeyAttributes = (joinKeys: McpRequestJoinKeys): Record => + clientInfo === null + ? {} + : { + "mcp.client.name": clientInfo.name, + ...(clientInfo.version !== undefined ? { "mcp.client.version": clientInfo.version } : {}), + ...(clientInfo.title !== undefined ? { "mcp.client.title": clientInfo.title } : {}), + }; + const startMarker = (name: string, attributes: Record): Effect.Effect => Effect.void.pipe(Effect.withSpan(name, { attributes })); @@ -1110,6 +1151,15 @@ export const buildExecutorMcpTools = < ); const server = assembly.server; + // Seeded from the host's persisted copy; a live `initialize` on this + // instance replaces it via `syncClientInfo` below. Read lazily at each + // tool call so spans always carry the newest identity. + let clientInfo: McpClientInfo | null = config.restoredClientInfo ?? null; + const requestSpanAttributes = (joinKeys: McpRequestJoinKeys): Record => ({ + ...joinKeyAttributes(joinKeys), + ...clientInfoAttributes(clientInfo), + }); + const executeWithNativeElicitation = ( code: string, extra: RequestContext, @@ -1179,7 +1229,7 @@ export const buildExecutorMcpTools = < "mcp.execute.code_length": code.length, }, }), - Effect.annotateSpans(joinKeyAttributes(extra)), + Effect.annotateSpans(requestSpanAttributes(extra)), ); /** What the caller could bind an unresolved role to. Best effort: the @@ -1340,7 +1390,7 @@ export const buildExecutorMcpTools = < "mcp.execute.execution_id": executionId, }, }), - Effect.annotateSpans(joinKeyAttributes(extra)), + Effect.annotateSpans(requestSpanAttributes(extra)), ); const requireUserResumeApproval = (executionId: string): Effect.Effect => @@ -1419,7 +1469,7 @@ export const buildExecutorMcpTools = < "mcp.execute.execution_id": executionId, }, }), - Effect.annotateSpans(joinKeyAttributes(extra)), + Effect.annotateSpans(requestSpanAttributes(extra)), ); // --- tools --- @@ -2169,9 +2219,35 @@ export const buildExecutorMcpTools = < }); }; + // Client identity arrives with the same `initialize` that carries the + // capabilities above. An absent live value (cold restore, construction + // time) is not evidence the client changed, so the restored value stands, + // the same asymmetry `syncToolAvailability` documents for capabilities. + const syncClientInfo = () => { + const live = assembly.getClientInfo(); + if (live === null) return; + const changed = + live.name !== clientInfo?.name || + live.version !== clientInfo?.version || + live.title !== clientInfo?.title; + if (!changed) return; + clientInfo = live; + const onClientInfoChange = config.onClientInfoChange; + if (onClientInfoChange) { + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: `oninitialized` is a sync SDK hook; persistence is fire-and-forget and its failure must not fail the session + void Effect.runPromiseWith(context)( + onClientInfoChange(live).pipe(Effect.ignoreCause({ log: false })), + ); + } + }; + yield* Effect.sync(() => { syncToolAvailability(); - assembly.onInitialized(syncToolAvailability); + syncClientInfo(); + assembly.onInitialized(() => { + syncToolAvailability(); + syncClientInfo(); + }); }).pipe(Effect.withSpan("mcp.host.sync_tool_availability")); return server; diff --git a/packages/hosts/mcp/src/tool-server.test.ts b/packages/hosts/mcp/src/tool-server.test.ts index 2a0febebd1..14b6bb4c3d 100644 --- a/packages/hosts/mcp/src/tool-server.test.ts +++ b/packages/hosts/mcp/src/tool-server.test.ts @@ -17,8 +17,10 @@ import type { ExecutionEngine, ExecutionResult } from "@executor-js/execution"; import { buildMcpServer, + clientInfoFromRequestBody, formatMcpExecutionOutcome, type ExecutorMcpServerConfig, + type McpClientInfo, } from "./tool-server"; // --------------------------------------------------------------------------- @@ -71,6 +73,7 @@ type TestServerConfig = Pick< | "pausedExecutionHooks" | "pausedExecutionLeaseMs" | "resumeFallback" + | "onClientInfoChange" >; /** Connect a real MCP Client to our executor MCP server over in-memory transports. */ @@ -1610,6 +1613,77 @@ describe("MCP host server — skills tool", () => { }); }); +describe("MCP host server — client attribution", () => { + it("stamps the initialize-reported client identity on execution spans", async () => { + await withTracedClient(makeStubEngine({}), async (client, spans) => { + await client.callTool({ name: "execute", arguments: { code: "1+1" } }); + + const execute = spans.find((span) => span.name === "mcp.host.tool.execute"); + expectDefined(execute); + expect(execute.attributes.get("mcp.client.name")).toBe("test-client"); + expect(execute.attributes.get("mcp.client.version")).toBe("1.0.0"); + }); + }); + + it("reports the initialize-reported identity to onClientInfoChange once", async () => { + const reported: McpClientInfo[] = []; + await withClient( + makeStubEngine({}), + NO_CAPS, + async (client) => { + await client.callTool({ name: "execute", arguments: { code: "1+1" } }); + expect(reported).toEqual([{ name: "test-client", version: "1.0.0" }]); + }, + { + onClientInfoChange: (clientInfo) => + Effect.sync(() => { + reported.push(clientInfo); + }), + }, + ); + }); +}); + +describe("clientInfoFromRequestBody", () => { + const META_KEY = "io.modelcontextprotocol/clientInfo"; + + it("prefers the per-request _meta identity", () => { + expect( + clientInfoFromRequestBody({ + method: "tools/call", + params: { _meta: { [META_KEY]: { name: "meta-client", version: "2.0.0" } } }, + }), + ).toEqual({ name: "meta-client", version: "2.0.0" }); + }); + + it("falls back to the initialize body's native clientInfo", () => { + expect( + clientInfoFromRequestBody({ + method: "initialize", + params: { clientInfo: { name: "init-client", version: "1.2.3", title: "Init Client" } }, + }), + ).toEqual({ name: "init-client", version: "1.2.3", title: "Init Client" }); + }); + + it("decodes a malformed or absent identity to null", () => { + expect(clientInfoFromRequestBody(null)).toBeNull(); + expect(clientInfoFromRequestBody({ method: "tools/call", params: {} })).toBeNull(); + expect( + clientInfoFromRequestBody({ + method: "tools/call", + params: { _meta: { [META_KEY]: { version: "no-name" } } }, + }), + ).toBeNull(); + // Native clientInfo is only trusted on `initialize`, where the spec puts it. + expect( + clientInfoFromRequestBody({ + method: "tools/call", + params: { clientInfo: { name: "misplaced" } }, + }), + ).toBeNull(); + }); +}); + describe("MCP host server — hang-visibility tracing", () => { it("execute emits a start marker and stamps the JSON-RPC id on execution spans", async () => { const engine = makeStubEngine({}); diff --git a/packages/hosts/mcp/src/tool-server.ts b/packages/hosts/mcp/src/tool-server.ts index 3458f27797..4d3469eec1 100644 --- a/packages/hosts/mcp/src/tool-server.ts +++ b/packages/hosts/mcp/src/tool-server.ts @@ -9,12 +9,14 @@ import * as Cause from "effect/Cause"; import { acceptedContent, CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, createRequestStateCodec, fromJsonSchema, inputRequired, inputResponse, McpServer, type CallToolResult, + type Implementation, type InputRequiredResult, type ServerContext, } from "@modelcontextprotocol/server"; @@ -37,6 +39,7 @@ import { buildExecutorMcpTools, type ExecutorMcpAssembly, type ExecutorMcpToolConfig, + type McpClientInfo, type McpHandlerResult, type McpRequestJoinKeys, type McpToolResult, @@ -48,6 +51,7 @@ export type { BrowserApprovalStore, ExecutorMcpToolConfig, McpArtifactsPort, + McpClientInfo, McpConnectionsPort, McpToolResult, PausedExecutionHooks, @@ -249,6 +253,43 @@ const elicitationSupportFromUnknown = ( }; }; +const ClientInfoSchema = Schema.Struct({ + name: Schema.String, + version: Schema.optional(Schema.String), + title: Schema.optional(Schema.String), +}); +const decodeClientInfo = Schema.decodeUnknownOption(ClientInfoSchema); + +const toClientInfo = (value: unknown): McpClientInfo | null => + Option.getOrNull(decodeClientInfo(value)); + +const clientInfoFromImplementation = ( + implementation: Implementation | undefined, +): McpClientInfo | null => + implementation === undefined + ? null + : { + name: implementation.name, + version: implementation.version, + ...(implementation.title !== undefined ? { title: implementation.title } : {}), + }; + +/** + * Parse the self-reported client identity from a modern request's `_meta` + * envelope (`io.modelcontextprotocol/clientInfo`, sent per request), falling + * back to the `initialize` body's native `params.clientInfo`. Malformed or + * absent identity decodes to null: it is display/telemetry data, never load-bearing. + */ +export const clientInfoFromRequestBody = (body: unknown): McpClientInfo | null => { + if (!isRecord(body)) return null; + const params = body.params; + if (!isRecord(params)) return null; + const metadata = params._meta; + const fromMeta = isRecord(metadata) ? toClientInfo(metadata[CLIENT_INFO_META_KEY]) : null; + if (fromMeta) return fromMeta; + return body.method === "initialize" ? toClientInfo(params.clientInfo) : null; +}; + /** Parse the MCP Apps capability subset from an already-decoded modern body. */ export const clientCapabilitiesFromRequestBody = ( body: unknown, @@ -464,6 +505,8 @@ const createMcpAssembly = ( initialAppsEnabled, getClientCapabilities: () => sessionful ? (server.server.getClientCapabilities() ?? null) : null, + getClientInfo: () => + sessionful ? clientInfoFromImplementation(server.server.getClientVersion()) : null, getElicitationSupport: () => sessionful ? elicitationSupportFromUnknown(server.server.getClientCapabilities()) From 19a1d21370de7b2ab7b2c4ff5155fea87768c59d Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:20:38 -0600 Subject: [PATCH 012/133] Improve trace quality: redact headers, fix DO span parenting, cut noise (#1623) * Redact all non-allowlisted headers on spans Effect's HttpClient tracer records every request and response header on the span, masking only a four-name default blocklist. Upstream integrations carry credentials in provider-specific headers, so any name outside the list shipped verbatim to the trace backend. Invert the model: redact everything except a short allowlist of structurally safe headers. * Parent DO tool spans under the live request currentParentSpan() was a stub returning undefined, so runToolEffect always fell back to the Effect context captured when the session server was built. Every tool execution span in a legacy session parented under the long-closed construction span instead of the request that triggered it. Track the most recent request's traceparent in the DO and resolve it to an external parent span. The grammar moves to do-headers beside the producer; the worker edge parser delegates to it. * Drop zero-duration bootstrap and annotation spans mcp.host.register_tool, mcp.host.register_resource, mcp.host.create_server, mcp.request.annotate, and the executor.stack sub-spans wrap synchronous context lookups and registrations. Together they were about a fifth of weekly span ingest at exactly 0ms each, with zero recorded errors. The stack keeps its executor.stack.build umbrella span; mcp.request keeps the fingerprint attributes the annotate wrapper was duplicating onto itself. * Name workos and user_store spans by operation The two highest-volume span names in the corpus were bare "workos" and "user_store" wrappers with no attributes, swallowing every distinct SDK and store call. use() now takes the operation name, so spans read workos.userManagement.getUser and user_store.getOrganization, and failure logs say which call failed. The WorkOSClient layer construction span goes away with them: synchronous, per-build, and told us nothing. * Record exceptions on worker http.server error spans The worker-boundary catch set ERROR with no message and no exception event, leaving those spans with zero diagnostic content. Record the exception and a status message, and stamp HTTP on the 5xx path. * Clarify where header-name lowercasing happens --- .../account/org-api-key-revoke.node.test.ts | 2 +- .../src/account/workos-account-service.ts | 4 +- .../api/protected-api-key-auth.node.test.ts | 2 +- .../src/api/protected-jwt-auth.node.test.ts | 2 +- apps/cloud/src/auth/context.ts | 7 +- apps/cloud/src/auth/handlers.ts | 10 +- .../src/auth/org-api-key-auth.node.test.ts | 2 +- .../src/auth/org-selector-auth.node.test.ts | 2 +- apps/cloud/src/auth/organization.ts | 8 +- apps/cloud/src/auth/ssr-gate.ts | 2 +- apps/cloud/src/auth/workos.ts | 92 ++++++++++++------- .../src/extensions/billing/route.node.test.ts | 2 +- apps/cloud/src/mcp/auth-provider.ts | 5 +- apps/cloud/src/mcp/auth.ts | 4 +- apps/cloud/src/mcp/telemetry.ts | 1 - apps/cloud/src/mcp/traceparent.ts | 19 ++-- .../src/observability/header-redaction.ts | 65 +++++++++++++ apps/cloud/src/observability/telemetry.ts | 25 +++-- apps/cloud/src/server.ts | 22 ++++- .../core/api/src/server/execution-stack.ts | 10 +- .../core/api/src/server/scoped-executor.ts | 16 +--- .../src/mcp/agent-session-durable-object.ts | 32 ++++++- .../hosts/cloudflare/src/mcp/do-headers.ts | 27 ++++++ packages/hosts/mcp/src/tool-server-core.ts | 46 +--------- 24 files changed, 265 insertions(+), 142 deletions(-) create mode 100644 apps/cloud/src/observability/header-redaction.ts diff --git a/apps/cloud/src/account/org-api-key-revoke.node.test.ts b/apps/cloud/src/account/org-api-key-revoke.node.test.ts index 5deb62f6b8..ca8d3e0522 100644 --- a/apps/cloud/src/account/org-api-key-revoke.node.test.ts +++ b/apps/cloud/src/account/org-api-key-revoke.node.test.ts @@ -77,7 +77,7 @@ const stubWorkOS = Layer.succeed( ); const stubUsers = Layer.succeed(UserStoreService)({ - use: (fn) => + use: (_op, fn) => Effect.promise(() => fn({ ensureAccount: async (id: string) => ({ id, createdAt }), diff --git a/apps/cloud/src/account/workos-account-service.ts b/apps/cloud/src/account/workos-account-service.ts index 739e91d27b..6e70a5b9a1 100644 --- a/apps/cloud/src/account/workos-account-service.ts +++ b/apps/cloud/src/account/workos-account-service.ts @@ -386,7 +386,9 @@ export const workosAccountProvider: Layer.Layer< .updateOrganization(org.id, name) .pipe(Effect.catchTag("WorkOSError", toAccountError)); yield* users - .use((s) => s.upsertOrganization({ id: updated.id, name: updated.name })) + .use("upsertOrganization", (s) => + s.upsertOrganization({ id: updated.id, name: updated.name }), + ) .pipe(Effect.catchTag("UserStoreError", toAccountError)); return { name: updated.name }; }), diff --git a/apps/cloud/src/api/protected-api-key-auth.node.test.ts b/apps/cloud/src/api/protected-api-key-auth.node.test.ts index 6027133349..3ba01a61b4 100644 --- a/apps/cloud/src/api/protected-api-key-auth.node.test.ts +++ b/apps/cloud/src/api/protected-api-key-auth.node.test.ts @@ -47,7 +47,7 @@ const stubWorkOS = Layer.succeed( ); const stubUsers = Layer.succeed(UserStoreService)({ - use: (fn) => + use: (_op, fn) => Effect.promise(() => fn({ ensureAccount: async (id: string) => ({ id, createdAt }), diff --git a/apps/cloud/src/api/protected-jwt-auth.node.test.ts b/apps/cloud/src/api/protected-jwt-auth.node.test.ts index 2120c9004c..dbf35e1c2c 100644 --- a/apps/cloud/src/api/protected-jwt-auth.node.test.ts +++ b/apps/cloud/src/api/protected-jwt-auth.node.test.ts @@ -64,7 +64,7 @@ const stubWorkOS = Layer.succeed( ); const stubUsers = Layer.succeed(UserStoreService)({ - use: (fn) => + use: (_op, fn) => Effect.promise(() => fn({ ensureAccount: async (id: string) => ({ id, createdAt }), diff --git a/apps/cloud/src/auth/context.ts b/apps/cloud/src/auth/context.ts index bfd3ea25c0..dd713b189d 100644 --- a/apps/cloud/src/auth/context.ts +++ b/apps/cloud/src/auth/context.ts @@ -9,10 +9,13 @@ import { UserStoreError, tryPromiseService, withServiceLogging } from "./errors" type RawStore = ReturnType; +// `op` names the store call so every span reads `user_store.` +// instead of one undifferentiated "user_store" bucket, and failures log which +// query actually failed. const makeService = (store: RawStore) => ({ - use:
(fn: (s: RawStore) => Promise) => + use: (op: string, fn: (s: RawStore) => Promise) => withServiceLogging( - "user_store", + `user_store.${op}`, () => new UserStoreError(), tryPromiseService(() => fn(store)), ), diff --git a/apps/cloud/src/auth/handlers.ts b/apps/cloud/src/auth/handlers.ts index 35d18cfa66..d948787351 100644 --- a/apps/cloud/src/auth/handlers.ts +++ b/apps/cloud/src/auth/handlers.ts @@ -202,7 +202,7 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( const result = yield* workos.authenticateWithCode(query.code); // Mirror the account locally - yield* users.use((s) => s.ensureAccount(result.user.id)); + yield* users.use("ensureAccount", (s) => s.ensureAccount(result.user.id)); let sealedSession = result.sealedSession; @@ -420,7 +420,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( const org = yield* workos.createOrganization(name); yield* workos.createMembership(org.id, session.accountId, "admin"); // `upsertOrganization` mints the slug at insert — no separate heal step. - const mirrored = yield* users.use((s) => + const mirrored = yield* users.use("upsertOrganization", (s) => s.upsertOrganization({ id: org.id, name: org.name }), ); @@ -474,7 +474,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( // The typed confirmation must match the org's current name — the same // label the settings page shows. Trimmed on both sides. - const org = yield* users.use((s) => s.getOrganization(organizationId)); + const org = yield* users.use("getOrganization", (s) => s.getOrganization(organizationId)); if (!org || payload.confirmName.trim() !== org.name.trim()) { return yield* new OrganizationDeletionForbidden(); } @@ -492,7 +492,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( // everyone (unreachable) but its secrets/tenant rows linger orphaned — // alert loudly so that window gets swept, then surface the failure. yield* users - .use((s) => s.deleteOrganizationCascade(organizationId)) + .use("deleteOrganizationCascade", (s) => s.deleteOrganizationCascade(organizationId)) .pipe( Effect.tapError((error) => Effect.logError( @@ -593,7 +593,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( // Mirror the org locally so domain tables can FK against it; the // upsert mints the slug at insert — no separate heal step. const org = yield* workos.getOrganization(invitation.organizationId); - const mirrored = yield* users.use((s) => + const mirrored = yield* users.use("upsertOrganization", (s) => s.upsertOrganization({ id: org.id, name: org.name }), ); diff --git a/apps/cloud/src/auth/org-api-key-auth.node.test.ts b/apps/cloud/src/auth/org-api-key-auth.node.test.ts index 1d1e00b9ad..ec87511c3b 100644 --- a/apps/cloud/src/auth/org-api-key-auth.node.test.ts +++ b/apps/cloud/src/auth/org-api-key-auth.node.test.ts @@ -62,7 +62,7 @@ const stubWorkOS = Layer.succeed( ); const stubUsers = Layer.succeed(UserStoreService)({ - use: (fn) => + use: (_op, fn) => Effect.promise(() => fn({ ensureAccount: async (id: string) => ({ id, createdAt }), diff --git a/apps/cloud/src/auth/org-selector-auth.node.test.ts b/apps/cloud/src/auth/org-selector-auth.node.test.ts index 5c59ba7de4..ead56fb893 100644 --- a/apps/cloud/src/auth/org-selector-auth.node.test.ts +++ b/apps/cloud/src/auth/org-selector-auth.node.test.ts @@ -64,7 +64,7 @@ const stubWorkOS = Layer.succeed( ); const stubUsers = Layer.succeed(UserStoreService)({ - use: (fn) => + use: (_op, fn) => Effect.promise(() => fn({ ensureAccount: async (id: string) => ({ id, createdAt }), diff --git a/apps/cloud/src/auth/organization.ts b/apps/cloud/src/auth/organization.ts index 51944cfbbe..073dfacb32 100644 --- a/apps/cloud/src/auth/organization.ts +++ b/apps/cloud/src/auth/organization.ts @@ -38,12 +38,14 @@ import { WorkOSClient } from "./workos"; export const resolveOrganization = (organizationId: string) => Effect.gen(function* () { const users = yield* UserStoreService; - const existing = yield* users.use((s) => s.getOrganization(organizationId)); + const existing = yield* users.use("getOrganization", (s) => s.getOrganization(organizationId)); if (existing) return existing; const workos = yield* WorkOSClient; const fresh = yield* workos.getOrganization(organizationId); - return yield* users.use((s) => s.upsertOrganization({ id: fresh.id, name: fresh.name })); + return yield* users.use("upsertOrganization", (s) => + s.upsertOrganization({ id: fresh.id, name: fresh.name }), + ); }); // --------------------------------------------------------------------------- @@ -125,7 +127,7 @@ export const authorizeOrganizationSelector = (userId: string, selector: string) return yield* authorizeOrganization(userId, selector); } const users = yield* UserStoreService; - const org = yield* users.use((s) => s.getOrganizationBySlug(selector)); + const org = yield* users.use("getOrganizationBySlug", (s) => s.getOrganizationBySlug(selector)); if (!org) return null; return yield* authorizeOrganization(userId, org.id); }); diff --git a/apps/cloud/src/auth/ssr-gate.ts b/apps/cloud/src/auth/ssr-gate.ts index a95c225dff..a85f9ffe1e 100644 --- a/apps/cloud/src/auth/ssr-gate.ts +++ b/apps/cloud/src/auth/ssr-gate.ts @@ -153,7 +153,7 @@ const organizationDisplay = async ( ): Promise<{ name: string; slug: string }> => { const exit = await getRuntime().runPromiseExit( Effect.flatMap(UserStoreService.asEffect(), (users) => - users.use((store) => store.getOrganization(organizationId)), + users.use("getOrganization", (store) => store.getOrganization(organizationId)), ).pipe(Effect.provide(Layer.provide(makeUserStoreLayer(), makeDbLayer()))), ); return Exit.isSuccess(exit) diff --git a/apps/cloud/src/auth/workos.ts b/apps/cloud/src/auth/workos.ts index 88281115af..51959784a8 100644 --- a/apps/cloud/src/auth/workos.ts +++ b/apps/cloud/src/auth/workos.ts @@ -338,9 +338,12 @@ const make = Effect.gen(function* () { // exception had one (all its typed exceptions do), so consumers can tell a // definitive WorkOS denial (401/403/404 — fail closed) from a transient // failure (429/5xx/network — retryable). - const use = (fn: (wos: WorkOS) => Promise) => + // `op` names the SDK call (mirroring its `namespace.method` path) so every + // span reads `workos.` instead of one undifferentiated "workos" + // bucket, and failures log which call actually failed. + const use = (op: string, fn: (wos: WorkOS) => Promise) => withServiceLogging( - "workos", + `workos.${op}`, workosErrorFromFailure, tryPromiseService(() => fn(workos)), ); @@ -376,7 +379,7 @@ const make = Effect.gen(function* () { if (isLocalSessionInvalidCookie(local)) return null; // Try refreshing - const refreshed = yield* use(() => session.refresh()).pipe( + const refreshed = yield* use("session.refresh", () => session.refresh()).pipe( Effect.orElseSucceed(() => ({ authenticated: false as const })), ); @@ -405,7 +408,7 @@ const make = Effect.gen(function* () { }), authenticateWithCode: (code: string) => - use((wos) => + use("userManagement.authenticateWithCode", (wos) => wos.userManagement.authenticateWithCode({ code, clientId, @@ -415,11 +418,13 @@ const make = Effect.gen(function* () { /** Create a new organization in WorkOS. */ createOrganization: (name: string) => - use((wos) => wos.organizations.createOrganization({ name })), + use("organizations.createOrganization", (wos) => + wos.organizations.createOrganization({ name }), + ), /** Add a user to an organization. */ createMembership: (organizationId: string, userId: string, roleSlug?: string) => - use((wos) => + use("userManagement.createOrganizationMembership", (wos) => wos.userManagement.createOrganizationMembership({ organizationId, userId, @@ -429,7 +434,7 @@ const make = Effect.gen(function* () { /** List organization memberships for a user. */ listUserMemberships: (userId: string) => - use(async (wos) => + use("userManagement.listOrganizationMemberships", async (wos) => collectWorkOSList( await wos.userManagement.listOrganizationMemberships({ userId, @@ -448,7 +453,7 @@ const make = Effect.gen(function* () { sessionData, cookiePassword, }); - const refreshed = yield* use(() => + const refreshed = yield* use("session.refresh", () => session.refresh(organizationId ? { organizationId } : undefined), ); if (!refreshed.authenticated || !("sealedSession" in refreshed)) return null; @@ -517,10 +522,13 @@ const make = Effect.gen(function* () { * auth/api-keys.ts. */ validateApiKey: (value: string) => - use((wos) => wos.apiKeys.validateApiKey({ value }) as Promise), + use( + "apiKeys.validateApiKey", + (wos) => wos.apiKeys.validateApiKey({ value }) as Promise, + ), listUserApiKeys: (userId: string, organizationId: string) => - use(async (wos) => { + use("userManagement.listUserApiKeys", async (wos) => { const raw = wos as RawWorkOS; return collectRawWorkOSList(async (after) => { const response = await raw.get(`/user_management/users/${userId}/api_keys`, { @@ -535,7 +543,7 @@ const make = Effect.gen(function* () { }), createUserApiKey: (params: { userId: string; organizationId: string; name: string }) => - use(async (wos) => { + use("userManagement.createUserApiKey", async (wos) => { const raw = wos as RawWorkOS; const response = await raw.post(`/user_management/users/${params.userId}/api_keys`, { name: params.name, @@ -552,7 +560,7 @@ const make = Effect.gen(function* () { * other key response. */ listOrgApiKeys: (organizationId: string) => - use(async (wos) => + use("organizations.listOrganizationApiKeys", async (wos) => collectWorkOSList(await wos.organizations.listOrganizationApiKeys({ organizationId })), ), @@ -564,6 +572,7 @@ const make = Effect.gen(function* () { */ createOrgApiKey: (params: { organizationId: string; name: string }) => use( + "organizations.createOrganizationApiKey", (wos) => wos.organizations.createOrganizationApiKey({ organizationId: params.organizationId, @@ -571,11 +580,12 @@ const make = Effect.gen(function* () { }) as Promise, ), - deleteApiKey: (id: string) => use((wos) => wos.apiKeys.deleteApiKey(id)), + deleteApiKey: (id: string) => + use("apiKeys.deleteApiKey", (wos) => wos.apiKeys.deleteApiKey(id)), /** List organization memberships with user details. */ listOrgMembers: (organizationId: string) => - use(async (wos) => + use("userManagement.listOrganizationMemberships", async (wos) => collectWorkOSList( await wos.userManagement.listOrganizationMemberships({ organizationId, @@ -586,7 +596,7 @@ const make = Effect.gen(function* () { /** Get a user's membership in an organization. */ getUserOrgMembership: (organizationId: string, userId: string) => - use(async (wos) => { + use("userManagement.listOrganizationMemberships", async (wos) => { const response = await wos.userManagement.listOrganizationMemberships({ organizationId, userId, @@ -596,11 +606,12 @@ const make = Effect.gen(function* () { }), /** Get a user by ID. */ - getUser: (userId: string) => use((wos) => wos.userManagement.getUser(userId)), + getUser: (userId: string) => + use("userManagement.getUser", (wos) => wos.userManagement.getUser(userId)), /** List users matching an email within one organization. */ listUsers: (params: { email: string; organizationId: string }) => - use(async (wos) => + use("userManagement.listUsers", async (wos) => collectWorkOSList( await wos.userManagement.listUsers({ email: params.email, @@ -611,7 +622,7 @@ const make = Effect.gen(function* () { /** Send an organization invitation. */ sendInvitation: (params: { email: string; organizationId: string; roleSlug?: string }) => - use((wos) => + use("userManagement.sendInvitation", (wos) => wos.userManagement.sendInvitation({ email: params.email, organizationId: params.organizationId, @@ -625,7 +636,7 @@ const make = Effect.gen(function* () { * API level, so we filter after. */ listPendingInvitations: (organizationId: string) => - use(async (wos) => + use("userManagement.listInvitations", async (wos) => collectWorkOSList( await wos.userManagement.listInvitations({ organizationId, @@ -640,7 +651,7 @@ const make = Effect.gen(function* () { /** List invitations for an email address (across all orgs). */ listInvitationsByEmail: (email: string) => - use(async (wos) => + use("userManagement.listInvitations", async (wos) => collectWorkOSList( await wos.userManagement.listInvitations({ email, @@ -650,19 +661,25 @@ const make = Effect.gen(function* () { /** Accept an invitation; returns the (now accepted) invitation. */ acceptInvitation: (invitationId: string) => - use((wos) => wos.userManagement.acceptInvitation(invitationId)), + use("userManagement.acceptInvitation", (wos) => + wos.userManagement.acceptInvitation(invitationId), + ), /** Remove an organization membership. */ deleteOrgMembership: (membershipId: string) => - use((wos) => wos.userManagement.deleteOrganizationMembership(membershipId)), + use("userManagement.deleteOrganizationMembership", (wos) => + wos.userManagement.deleteOrganizationMembership(membershipId), + ), /** Get the role for a membership. */ getOrgMembership: (membershipId: string) => - use((wos) => wos.userManagement.getOrganizationMembership(membershipId)), + use("userManagement.getOrganizationMembership", (wos) => + wos.userManagement.getOrganizationMembership(membershipId), + ), /** Update a membership's role. */ updateOrgMembershipRole: (membershipId: string, roleSlug: string) => - use((wos) => + use("userManagement.updateOrganizationMembership", (wos) => wos.userManagement.updateOrganizationMembership(membershipId, { roleSlug, }), @@ -670,15 +687,19 @@ const make = Effect.gen(function* () { /** List available roles for an organization. */ listOrgRoles: (organizationId: string) => - use((wos) => wos.organizations.listOrganizationRoles({ organizationId })), + use("organizations.listOrganizationRoles", (wos) => + wos.organizations.listOrganizationRoles({ organizationId }), + ), /** Get an organization (includes domains). */ getOrganization: (organizationId: string) => - use((wos) => wos.organizations.getOrganization(organizationId)), + use("organizations.getOrganization", (wos) => + wos.organizations.getOrganization(organizationId), + ), /** Update an organization. */ updateOrganization: (organizationId: string, name: string) => - use((wos) => + use("organizations.updateOrganization", (wos) => wos.organizations.updateOrganization({ organization: organizationId, name, @@ -690,11 +711,13 @@ const make = Effect.gen(function* () { * invitations, and domains go with it, so every member loses access. */ deleteOrganization: (organizationId: string) => - use((wos) => wos.organizations.deleteOrganization(organizationId)), + use("organizations.deleteOrganization", (wos) => + wos.organizations.deleteOrganization(organizationId), + ), /** Generate an Admin Portal link for domain verification. */ generateDomainVerificationPortalLink: (organizationId: string, returnUrl: string) => - use((wos) => + use("portal.generateLink", (wos) => wos.portal.generateLink({ organization: organizationId, intent: GeneratePortalLinkIntent.DomainVerification, @@ -704,11 +727,11 @@ const make = Effect.gen(function* () { /** Get a domain by ID. */ getOrganizationDomain: (domainId: string) => - use((wos) => wos.organizationDomains.get(domainId)), + use("organizationDomains.get", (wos) => wos.organizationDomains.get(domainId)), /** Delete a domain claim. */ deleteOrganizationDomain: (domainId: string) => - use((wos) => wos.organizationDomains.delete(domainId)), + use("organizationDomains.delete", (wos) => wos.organizationDomains.delete(domainId)), }; }); @@ -717,9 +740,10 @@ export type WorkOSClientService = Effect.Success; export class WorkOSClient extends Context.Service()( "@executor-js/cloud/WorkOSClient", ) { - static Default = Layer.effect(this)(make).pipe( - Layer.withSpan("WorkOSClient", { attributes: { module: "WorkOSClient" } }), - ); + // Deliberately unspanned: client construction is synchronous and ran per + // layer build, which produced one of the highest-volume zero-duration span + // names in the whole trace corpus while telling us nothing. + static Default = Layer.effect(this)(make); } // The boot-scoped WorkOS client root — the one neutral service the stateless diff --git a/apps/cloud/src/extensions/billing/route.node.test.ts b/apps/cloud/src/extensions/billing/route.node.test.ts index f6274facc7..dc2a5a7316 100644 --- a/apps/cloud/src/extensions/billing/route.node.test.ts +++ b/apps/cloud/src/extensions/billing/route.node.test.ts @@ -34,7 +34,7 @@ const stubWorkOS = Layer.succeed( ); const stubUsers = Layer.succeed(UserStoreService)({ - use: (fn) => + use: (_op, fn) => Effect.promise(() => fn({ ensureAccount: async (id: string) => ({ id, createdAt }), diff --git a/apps/cloud/src/mcp/auth-provider.ts b/apps/cloud/src/mcp/auth-provider.ts index 628007a2fd..8a5944f052 100644 --- a/apps/cloud/src/mcp/auth-provider.ts +++ b/apps/cloud/src/mcp/auth-provider.ts @@ -21,8 +21,9 @@ // clearExistingSession. // - verified + org allowed -> Authenticated(principal) // -// The rich `mcp.request.annotate` client-fingerprint span (cloud-specific, no -// envelope seam) is emitted from here so telemetry parity is preserved. +// The rich client-fingerprint annotations (cloud-specific, no envelope seam) +// are stamped onto the `mcp.request` span from here so telemetry parity is +// preserved. // // The OAuth endpoints (/authorize, /token, /register) are NOT cloud's — they // live at WorkOS/AuthKit (external); only the two discovery docs are mounted. diff --git a/apps/cloud/src/mcp/auth.ts b/apps/cloud/src/mcp/auth.ts index 6ffdcf4383..347a79faf7 100644 --- a/apps/cloud/src/mcp/auth.ts +++ b/apps/cloud/src/mcp/auth.ts @@ -204,7 +204,9 @@ const resolveOrgSelector = (selector: string) => ? Effect.succeed(selector) : Effect.gen(function* () { const users = yield* UserStoreService; - const org = yield* users.use((s) => s.getOrganizationBySlug(selector)); + const org = yield* users.use("getOrganizationBySlug", (s) => + s.getOrganizationBySlug(selector), + ); return org?.id ?? null; }); diff --git a/apps/cloud/src/mcp/telemetry.ts b/apps/cloud/src/mcp/telemetry.ts index 1e2d631335..de53c3a49c 100644 --- a/apps/cloud/src/mcp/telemetry.ts +++ b/apps/cloud/src/mcp/telemetry.ts @@ -254,5 +254,4 @@ export const annotateMcpRequest = ( }; yield* Effect.annotateCurrentSpan(attrs); - yield* Effect.annotateCurrentSpan(attrs).pipe(Effect.withSpan("mcp.request.annotate")); }); diff --git a/apps/cloud/src/mcp/traceparent.ts b/apps/cloud/src/mcp/traceparent.ts index 0be604025a..72555df4cf 100644 --- a/apps/cloud/src/mcp/traceparent.ts +++ b/apps/cloud/src/mcp/traceparent.ts @@ -1,13 +1,13 @@ // --------------------------------------------------------------------------- // W3C traceparent parsing shared by the worker edge (server.ts), the MCP agent -// handler, and the session DO. Single-sourced so the producer (the worker span -// stamping traceparent onto the forwarded request) and the consumers (the -// Effect programs joining that span) cannot drift on the header grammar. +// handler, and the session DO. The header grammar itself is single-sourced in +// `@executor-js/cloudflare/mcp/do-headers` beside the producer that stamps the +// header; this module only layers the OTel `tracestate` carrier on top for the +// consumers that join the span via the OTel API. // --------------------------------------------------------------------------- import { createTraceState } from "@opentelemetry/api"; - -const TRACEPARENT_PATTERN = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/; +import { parseTraceparentHeader } from "@executor-js/cloudflare/mcp/do-headers"; export type IncomingSpanContext = { readonly traceId: string; @@ -20,13 +20,10 @@ export const parseTraceparent = ( traceparent: string | null | undefined, tracestate: string | null | undefined, ): IncomingSpanContext | null => { - if (!traceparent) return null; - const match = TRACEPARENT_PATTERN.exec(traceparent); - if (!match) return null; + const parsed = parseTraceparentHeader(traceparent); + if (!parsed) return null; return { - traceId: match[2]!, - spanId: match[3]!, - traceFlags: parseInt(match[4]!, 16), + ...parsed, ...(tracestate ? { traceState: createTraceState(tracestate) } : {}), }; }; diff --git a/apps/cloud/src/observability/header-redaction.ts b/apps/cloud/src/observability/header-redaction.ts new file mode 100644 index 0000000000..188046682c --- /dev/null +++ b/apps/cloud/src/observability/header-redaction.ts @@ -0,0 +1,65 @@ +// --------------------------------------------------------------------------- +// Span header redaction. +// +// Effect's HttpClient/HttpServer tracer records every request and response +// header as a span attribute, masking only the names in +// `Headers.CurrentRedactedNames` (default: authorization, cookie, set-cookie, +// x-api-key). That blocklist can never be right here: executor's whole job is +// calling arbitrary upstream APIs whose credentials ride in provider-specific +// headers (x-goog-api-key, api-key, x-auth-token, ...), and any name missing +// from the list ships a live secret to the trace backend verbatim. +// +// So the override inverts the model: every header is redacted unless its name +// is on the allowlist of structurally safe, diagnostically useful headers. +// The alternative of enumerating known secret-bearing names was rejected: new +// integrations add new credential headers faster than a blocklist can learn +// them, and one miss is a leaked customer credential. +// --------------------------------------------------------------------------- + +import { Layer } from "effect"; +import { Headers } from "effect/unstable/http"; + +// Names that never carry credentials and are worth reading on a span while +// debugging: negotiation, caching, routing, and the tracing headers +// themselves. Everything else renders as ``. +const SAFE_HEADER_NAMES = [ + "accept", + "accept-encoding", + "accept-language", + "age", + "cache-control", + "connection", + "content-encoding", + "content-length", + "content-type", + "date", + "etag", + "expires", + "host", + "if-modified-since", + "if-none-match", + "last-modified", + "location", + "mcp-protocol-version", + "mcp-session-id", + "origin", + "referer", + "retry-after", + "traceparent", + "tracestate", + "transfer-encoding", + "user-agent", + "vary", + "via", + "x-request-id", +] as const; + +// Header names are lowercased at `Headers` construction, and `Headers.redact` +// masks every name a RegExp entry matches. One negative lookahead turns the +// allowlist into "redact everything else". +const redactAllButSafe = new RegExp(`^(?!(?:${SAFE_HEADER_NAMES.join("|")})$)`); + +export const SpanHeaderRedactionLive: Layer.Layer = Layer.succeed( + Headers.CurrentRedactedNames, + [redactAllButSafe], +); diff --git a/apps/cloud/src/observability/telemetry.ts b/apps/cloud/src/observability/telemetry.ts index 7bd5baa589..e71701d7f1 100644 --- a/apps/cloud/src/observability/telemetry.ts +++ b/apps/cloud/src/observability/telemetry.ts @@ -45,6 +45,7 @@ import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic import { env } from "cloudflare:workers"; import { Effect, Layer } from "effect"; +import { SpanHeaderRedactionLive } from "./header-redaction"; import { CountingSpanExporter, CountingSpanProcessor, @@ -124,15 +125,21 @@ export const flushTracerProvider = async (): Promise => { }; const makeTelemetryLive = (): Layer.Layer => - Layer.unwrap( - Effect.sync(() => - ensureGlobalTracerProvider() - ? OtelTracer.layerGlobal.pipe( - Layer.provide( - Resource.layer({ serviceName: SERVICE_NAME, serviceVersion: SERVICE_VERSION }), - ), - ) - : Layer.empty, + Layer.mergeAll( + // Redaction applies even when the exporter is not installed: Effect still + // builds spans (and their header attributes) in-memory, and any future + // consumer of those spans must never observe an unredacted credential. + SpanHeaderRedactionLive, + Layer.unwrap( + Effect.sync(() => + ensureGlobalTracerProvider() + ? OtelTracer.layerGlobal.pipe( + Layer.provide( + Resource.layer({ serviceName: SERVICE_NAME, serviceVersion: SERVICE_VERSION }), + ), + ) + : Layer.empty, + ), ), ); diff --git a/apps/cloud/src/server.ts b/apps/cloud/src/server.ts index 12aac6773d..8443a9d8b7 100644 --- a/apps/cloud/src/server.ts +++ b/apps/cloud/src/server.ts @@ -172,11 +172,18 @@ const cloudflareHandler: ExportedHandler = { const response = await mcpAgentHandler(new Request(forwarded, { headers }), env, ctx); span.setAttribute(ATTR_HTTP_RESPONSE_STATUS_CODE, response.status); if (response.status >= 500) { - span.setStatus({ code: SpanStatusCode.ERROR }); + span.setStatus({ code: SpanStatusCode.ERROR, message: `HTTP ${response.status}` }); } return response; } catch (err) { - span.setStatus({ code: SpanStatusCode.ERROR }); + // Record the exception itself, not just the status bit: without it + // these spans are ERROR with zero diagnostic content. + // oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- adapter boundary: Cloudflare's fetch callback throws untyped; normalized only for the OTel span record, the original error is rethrown below + const cause = err instanceof Error ? err : String(err); + span.recordException(cause); + // oxlint-disable-next-line executor/no-unknown-error-message -- adapter boundary: same normalization as the recordException line above + const message = typeof cause === "string" ? cause : cause.message; + span.setStatus({ code: SpanStatusCode.ERROR, message }); // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; preserve original error to Cloudflare runtime throw err; } finally { @@ -237,11 +244,18 @@ const cloudflareHandler: ExportedHandler = { const response = await fetchHandler(request, env, ctx); span.setAttribute(ATTR_HTTP_RESPONSE_STATUS_CODE, response.status); if (response.status >= 500) { - span.setStatus({ code: SpanStatusCode.ERROR }); + span.setStatus({ code: SpanStatusCode.ERROR, message: `HTTP ${response.status}` }); } return response; } catch (err) { - span.setStatus({ code: SpanStatusCode.ERROR }); + // Record the exception itself, not just the status bit: without it + // these spans are ERROR with zero diagnostic content. + // oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- adapter boundary: Cloudflare's fetch callback throws untyped; normalized only for the OTel span record, the original error is rethrown below + const cause = err instanceof Error ? err : String(err); + span.recordException(cause); + // oxlint-disable-next-line executor/no-unknown-error-message -- adapter boundary: same normalization as the recordException line above + const message = typeof cause === "string" ? cause : cause.message; + span.setStatus({ code: SpanStatusCode.ERROR, message }); // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; preserve original error to Cloudflare runtime throw err; } finally { diff --git a/packages/core/api/src/server/execution-stack.ts b/packages/core/api/src/server/execution-stack.ts index eb7756a51d..d951f3b813 100644 --- a/packages/core/api/src/server/execution-stack.ts +++ b/packages/core/api/src/server/execution-stack.ts @@ -124,13 +124,9 @@ export const makeExecutionStack = < organizationId, organizationName, { plugins: { mcpResource: options?.mcpResource } }, - ).pipe(Effect.withSpan("executor.stack.scoped_executor")); - const codeExecutor = yield* CodeExecutorProvider.asEffect().pipe( - Effect.withSpan("executor.stack.code_executor"), - ); - const { decorate } = yield* EngineDecorator.asEffect().pipe( - Effect.withSpan("executor.stack.decorator"), ); + const codeExecutor = yield* CodeExecutorProvider.asEffect(); + const { decorate } = yield* EngineDecorator.asEffect(); const engine = yield* Effect.sync(() => decorate( createExecutionEngine({ executor, codeExecutor }), @@ -141,7 +137,7 @@ export const makeExecutionStack = < }, { mcpResource: options?.mcpResource }, ), - ).pipe(Effect.withSpan("executor.stack.engine.init")); + ); return { executor, engine }; }).pipe(Effect.withSpan("executor.stack.build")); diff --git a/packages/core/api/src/server/scoped-executor.ts b/packages/core/api/src/server/scoped-executor.ts index ea0e33ce61..53f14592f8 100644 --- a/packages/core/api/src/server/scoped-executor.ts +++ b/packages/core/api/src/server/scoped-executor.ts @@ -221,13 +221,9 @@ export const makeScopedExecutor = < options?: { readonly plugins?: PluginsProviderContext }, ): Effect.Effect, StorageFailure, DbProvider | PluginsProvider | HostConfig> => Effect.gen(function* () { - const { db, blobs } = yield* DbProvider.asEffect().pipe( - Effect.withSpan("executor.stack.db_provider"), - ); - const { plugins: pluginsFactory } = yield* PluginsProvider.asEffect().pipe( - Effect.withSpan("executor.stack.plugins_provider"), - ); - const config = yield* HostConfig.asEffect().pipe(Effect.withSpan("executor.stack.host_config")); + const { db, blobs } = yield* DbProvider.asEffect(); + const { plugins: pluginsFactory } = yield* PluginsProvider.asEffect(); + const config = yield* HostConfig.asEffect(); // Explicit config wins; otherwise fall back to the request origin if a host // provided one (HTTP middleware / MCP session DO). Stays `undefined` for // non-request callers — `coreTools.webBaseUrl` is optional and only the @@ -263,9 +259,7 @@ export const makeScopedExecutor = < oauthCallbackPath: config.oauthCallbackPath, }); - const plugins = yield* Effect.sync(() => pluginsFactory(options?.plugins)).pipe( - Effect.withSpan("executor.plugins.init"), - ); + const plugins = yield* Effect.sync(() => pluginsFactory(options?.plugins)); const hostedHttpOptions = { allowLocalNetwork: config.allowLocalNetwork, }; @@ -292,7 +286,7 @@ export const makeScopedExecutor = < orgSlug, includeProviders: config.exposeCredentialProviders ?? true, }, - }).pipe(Effect.withSpan("executor.stack.create_executor")); + }); // Record the sighting. THIS is the seam every HTTP request and MCP session // on every host passes through, so it is where the `subject` table gets // populated: a principal earns a row the first time it authenticates, diff --git a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts index 79b3e3c5ef..937b9928d7 100644 --- a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts +++ b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts @@ -1,6 +1,6 @@ import { DurableObject } from "cloudflare:workers"; import { Cause, Data, Deferred, Effect, Option, Schema } from "effect"; -import type * as Tracer from "effect/Tracer"; +import * as Tracer from "effect/Tracer"; import { createMcpHandler, DEFAULT_NEGOTIATED_PROTOCOL_VERSION, @@ -41,6 +41,7 @@ import { type McpResource, } from "@executor-js/host-mcp"; import { + parseTraceparentHeader, readArtifactsEnabled, readElicitationMode, verifiedMcpRequestHeaders, @@ -313,6 +314,15 @@ export abstract class McpAgentSessionDOBase< private modernRunningRequestCount = 0; private modernRequestBodies = new WeakMap(); private modernRequestPropagation = new WeakMap(); + // Trace context of the most recently entered MCP request, kept for + // `currentParentSpan`. Deliberately not cleared when the request settles: + // deferred SDK callbacks fire after the response resolves, and parenting + // them under the latest request is right far more often than falling back + // to the session's construction-time span. Concurrent requests on one DO + // can mis-parent to each other's trace (last writer wins); threading a + // per-request context through the MCP SDK's callback surface is the + // rejected-for-now alternative. + private lastIncomingTrace: IncomingTraceHeaders | undefined; private legacyRunningRequestCount = 0; private activeLegacyStreamCount = 0; private keepAliveCount = 0; @@ -381,8 +391,20 @@ export abstract class McpAgentSessionDOBase< return this.ctx.id.toString(); } + // Parent for spans opened by deferred MCP SDK callbacks (tool handlers run + // through `runToolEffect`, which otherwise inherits the Effect context + // captured when the server was BUILT). The legacy sessionful runtime builds + // its server once per session and reuses it across every request, so + // without this the whole tool execution tree parents under the long-closed + // session-construction span instead of the request that triggered it. protected currentParentSpan(): Tracer.AnySpan | undefined { - return undefined; + const parsed = parseTraceparentHeader(this.lastIncomingTrace?.traceparent); + if (!parsed) return undefined; + return Tracer.externalSpan({ + traceId: parsed.traceId, + spanId: parsed.spanId, + sampled: (parsed.traceFlags & 1) === 1, + }); } protected sessionTimeoutMs(): number { @@ -1244,6 +1266,11 @@ export abstract class McpAgentSessionDOBase< cors: false, }); } + this.lastIncomingTrace = { + traceparent: request.headers.get("traceparent") ?? undefined, + tracestate: request.headers.get("tracestate") ?? undefined, + baggage: request.headers.get("baggage") ?? undefined, + }; if ((await this.ctx.storage.get(DESTROY_PENDING_KEY)) === true) { return jsonRpcErrorBody(404, -32001, "Session timed out, please reconnect", { cors: false, @@ -1338,6 +1365,7 @@ export abstract class McpAgentSessionDOBase< this.modernRequestBodies.set(request, parsedBody); this.modernRequestPropagation.set(request, props.propagation); + this.lastIncomingTrace = props.propagation; this.modernRunningRequestCount += 1; // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: the RPC must decrement its in-memory running lease on both handler resolution and rejection try { diff --git a/packages/hosts/cloudflare/src/mcp/do-headers.ts b/packages/hosts/cloudflare/src/mcp/do-headers.ts index 36e62b2d47..1a20b44715 100644 --- a/packages/hosts/cloudflare/src/mcp/do-headers.ts +++ b/packages/hosts/cloudflare/src/mcp/do-headers.ts @@ -52,6 +52,33 @@ export type IncomingPropagationHeaders = { readonly baggage?: string; }; +// W3C traceparent grammar, owned here beside the producer that stamps the +// header (`currentPropagationHeaders`) so producer and consumers cannot +// drift. The worker edge's richer parser (apps/cloud, which also carries +// tracestate into the OTel API) delegates its grammar to this one. +const TRACEPARENT_PATTERN = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/; + +export type ParsedTraceparent = { + readonly traceId: string; + readonly spanId: string; + readonly traceFlags: number; +}; + +export const parseTraceparentHeader = ( + traceparent: string | null | undefined, +): ParsedTraceparent | null => { + if (!traceparent) return null; + const match = TRACEPARENT_PATTERN.exec(traceparent); + if (!match) return null; + // SAFETY: the pattern has exactly four capture groups, so a match always + // fills indices 1-4. + return { + traceId: match[2]!, + spanId: match[3]!, + traceFlags: parseInt(match[4]!, 16), + }; +}; + // `currentParentSpan`, not `currentSpan`: the worker's MCP handler joins the // edge `http.server` span via `OtelTracer.withSpanContext`, which provides an // ExternalSpan as the fiber's ParentSpan without opening an Effect-local span. diff --git a/packages/hosts/mcp/src/tool-server-core.ts b/packages/hosts/mcp/src/tool-server-core.ts index 1d77f17130..a4cf5eab48 100644 --- a/packages/hosts/mcp/src/tool-server-core.ts +++ b/packages/hosts/mcp/src/tool-server-core.ts @@ -1146,9 +1146,7 @@ export const buildExecutorMcpTools = < ), ); - const assembly = yield* Effect.sync(createAssembly).pipe( - Effect.withSpan("mcp.host.create_server"), - ); + const assembly = yield* Effect.sync(createAssembly); const server = assembly.server; // Seeded from the host's persisted copy; a live `initialize` on this @@ -1483,10 +1481,6 @@ export const buildExecutorMcpTools = < }, ({ code }, extra) => runToolEffect(executeCode(code, extra)), ), - ).pipe( - Effect.withSpan("mcp.host.register_tool", { - attributes: { "mcp.tool.name": "execute" }, - }), ); yield* Effect.sync(() => @@ -1508,10 +1502,6 @@ export const buildExecutorMcpTools = < ({ name }) => runToolEffect(Effect.succeed(skillsResult(name, executeInventory, skillCatalog))), ), - ).pipe( - Effect.withSpan("mcp.host.register_tool", { - attributes: { "mcp.tool.name": "skills" }, - }), ); yield* Effect.sync(() => { @@ -1559,11 +1549,7 @@ export const buildExecutorMcpTools = < }, ({ executionId }, extra) => runToolEffect(resumeAfterBrowserApproval(executionId, extra)), ); - }).pipe( - Effect.withSpan("mcp.host.register_tool", { - attributes: { "mcp.tool.name": "resume" }, - }), - ); + }); // --- artifacts / MCP Apps --- // @@ -1942,11 +1928,7 @@ export const buildExecutorMcpTools = < ], }), ); - }).pipe( - Effect.withSpan("mcp.host.register_resource", { - attributes: { "mcp.resource.uri": MCP_APPS_SHELL_RESOURCE_URI }, - }), - ); + }); yield* Effect.sync(() => assembly.registerAppTool( @@ -2002,10 +1984,6 @@ export const buildExecutorMcpTools = < ({ code, title, description, connections, artifactId }) => runToolEffect(createArtifact({ code, title, description, connections, artifactId })), ), - ).pipe( - Effect.withSpan("mcp.host.register_tool", { - attributes: { "mcp.tool.name": "create-artifact" }, - }), ); yield* Effect.sync(() => @@ -2067,10 +2045,6 @@ export const buildExecutorMcpTools = < ({ artifactId, edits, connections, title, description }) => runToolEffect(editArtifact({ artifactId, edits, connections, title, description })), ), - ).pipe( - Effect.withSpan("mcp.host.register_tool", { - attributes: { "mcp.tool.name": "edit-artifact" }, - }), ); yield* Effect.sync(() => @@ -2085,10 +2059,6 @@ export const buildExecutorMcpTools = < }, () => runToolEffect(listArtifacts()), ), - ).pipe( - Effect.withSpan("mcp.host.register_tool", { - attributes: { "mcp.tool.name": "list-artifacts" }, - }), ); yield* Effect.sync(() => @@ -2109,10 +2079,6 @@ export const buildExecutorMcpTools = < }, ({ id }) => runToolEffect(showArtifact(id)), ), - ).pipe( - Effect.withSpan("mcp.host.register_tool", { - attributes: { "mcp.tool.name": "show-artifact" }, - }), ); yield* Effect.sync(() => { @@ -2163,11 +2129,7 @@ export const buildExecutorMcpTools = < resumeExecution(executionId, action, parseJsonContent(rawContent), extra), ), ); - }).pipe( - Effect.withSpan("mcp.host.register_tool", { - attributes: { "mcp.tool.name": "execute-action" }, - }), - ); + }); } // Client capabilities only exist after `initialize`, and `tools/list` is From a8d3d3c19218c561cef751943680d4d34873b095 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:38:07 -0600 Subject: [PATCH 013/133] Sign out browsers whose session has already ended (#1624) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: repro sign-out answering with a raw Unauthorized page * fix: sign out browsers whose session has already ended Sign-out was declared on the session-authenticated API group, so a browser whose sealed session no longer authenticated at click time got a 401 from SessionAuth instead of being signed out. The console posts sign-out as a top-level form navigation, so that error body was rendered as the page: a screenful of {"_tag":"Unauthorized"}. Move the endpoint to the public group and read the sealed session off the request cookie. Nothing here needs authorizing — the request can only end the session whose cookie it presents — and the handler already fell back to "/" when the cookie would not unseal. Cookies are dropped only when the request actually presented one, so the now-public endpoint cannot be used cross-site to sign a user out. --- apps/cloud/src/auth/api.ts | 10 +- apps/cloud/src/auth/handlers.ts | 69 ++++++----- e2e/cloud/logout-stale-session.test.ts | 165 +++++++++++++++++++++++++ 3 files changed, 212 insertions(+), 32 deletions(-) create mode 100644 e2e/cloud/logout-stale-session.test.ts diff --git a/apps/cloud/src/auth/api.ts b/apps/cloud/src/auth/api.ts index 9d14c8d643..5c64be3c5c 100644 --- a/apps/cloud/src/auth/api.ts +++ b/apps/cloud/src/auth/api.ts @@ -181,6 +181,15 @@ const McpApprovalErrors = [ /** Public auth endpoints — no authentication required */ export class CloudAuthPublicApi extends HttpApiGroup.make("cloudAuthPublic") .add(HttpApiEndpoint.get("login", "/auth/login", { query: AuthLoginSearch })) + // Sign-out is PUBLIC on purpose. The console posts it as a top-level form + // navigation (the WorkOS hop is cross-origin, so it can't be a fetch), which + // means an error response is rendered as the page — and a browser whose + // session has already ended is exactly the browser most likely to click it + // (a second tab, a re-submit from history, an expired or revoked session). + // Behind SessionAuth all of those got a raw `{"_tag":"Unauthorized"}` screen + // instead of being signed out. There is nothing to authorize here anyway: + // the request can only end the session whose cookie it presents. + .add(HttpApiEndpoint.post("logout", "/auth/logout")) .add( HttpApiEndpoint.get("callback", "/auth/callback", { query: AuthCallbackSearch, @@ -201,7 +210,6 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth") error: AuthErrors, }), ) - .add(HttpApiEndpoint.post("logout", "/auth/logout")) .add( HttpApiEndpoint.get("organizations", "/auth/organizations", { success: AuthOrganizationsResponse, diff --git a/apps/cloud/src/auth/handlers.ts b/apps/cloud/src/auth/handlers.ts index d948787351..6445393b61 100644 --- a/apps/cloud/src/auth/handlers.ts +++ b/apps/cloud/src/auth/handlers.ts @@ -144,8 +144,8 @@ const deleteResponseCookie = (response: HttpServerResponse.HttpServerResponse, n HttpServerResponse.setCookieUnsafe(response, name, "", DELETE_COOKIE_OPTIONS); // --------------------------------------------------------------------------- -// Single non-protected API surface — public (login/callback) + session -// (me/logout/organizations/switch-organization). The session group has SessionAuth on it. +// Single non-protected API surface — public (login/callback/logout) + session +// (me/organizations/switch-organization). The session group has SessionAuth on it. // --------------------------------------------------------------------------- export const NonProtectedApi = HttpApi.make("cloudWeb").add(CloudAuthPublicApi).add(CloudAuthApi); @@ -274,6 +274,42 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( ); }), ) + .handleRaw("logout", ({ request }) => + Effect.gen(function* () { + const workos = yield* WorkOSClient; + // The session this browser presents, NOT one the middleware vouched + // for — signing out of a session that has already ended must still + // sign the browser out (see the group declaration in ./api.ts). + const sealedSession = request.cookies["wos-session"] ?? ""; + + // WorkOS's documented sign-out: send the browser through the WorkOS + // logout endpoint, which ends the AuthKit session upstream and then + // redirects to the registered sign-out URL. Without this hop, the + // hosted session survives and the next "Sign in" silently + // re-authenticates (issue #1445). Fail-open when the cookie won't + // unseal — there is then nothing to end upstream, and local sign-out + // must still complete, so fall back to "/". + const origin = env.VITE_PUBLIC_SITE_URL ?? ""; + const logoutUrl = sealedSession + ? yield* workos.logoutUrl(sealedSession, origin ? `${origin}/` : undefined) + : null; + + const response = HttpServerResponse.redirect(logoutUrl ?? "/", { status: 302 }); + + // Drop only what this browser actually presented. Both cookies are + // SameSite=Lax, so a cross-site form POST carries neither — it gets + // the bare redirect and cannot be used to sign anyone out. + if (!sealedSession && request.cookies[AUTH_HINT_COOKIE] === undefined) return response; + + // The auth-hint travels with the session: leaving it behind would + // make the next page load optimistically paint the app shell for a + // signed-out browser. + return deleteResponseCookie( + deleteResponseCookie(response, "wos-session"), + AUTH_HINT_COOKIE, + ); + }), + ) // CLI device-login discovery. The WorkOS device endpoints live on the // WorkOS API host (`WORKOS_API_URL`, or api.workos.com in production, // the SAME base the SDK uses, so e2e points the CLI at the emulator with @@ -319,35 +355,6 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( }; }), ) - .handleRaw("logout", () => - Effect.gen(function* () { - const workos = yield* WorkOSClient; - const session = yield* SessionContext; - - // WorkOS's documented sign-out: send the browser through the WorkOS - // logout endpoint, which ends the AuthKit session upstream and then - // redirects to the registered sign-out URL. Without this hop, the - // hosted session survives and the next "Sign in" silently - // re-authenticates (issue #1445). Fail-open when the cookie won't - // unseal: local sign-out must still complete, so fall back to "/". - const origin = env.VITE_PUBLIC_SITE_URL ?? ""; - const logoutUrl = yield* workos.logoutUrl( - session.sealedSession, - origin ? `${origin}/` : undefined, - ); - - // The auth-hint travels with the session: leaving it behind would - // make the next page load optimistically paint the app shell for a - // signed-out browser. - return deleteResponseCookie( - deleteResponseCookie( - HttpServerResponse.redirect(logoutUrl ?? "/", { status: 302 }), - "wos-session", - ), - AUTH_HINT_COOKIE, - ); - }), - ) .handle("organizations", () => Effect.gen(function* () { const workos = yield* WorkOSClient; diff --git a/e2e/cloud/logout-stale-session.test.ts b/e2e/cloud/logout-stale-session.test.ts new file mode 100644 index 0000000000..8093cc24d2 --- /dev/null +++ b/e2e/cloud/logout-stale-session.test.ts @@ -0,0 +1,165 @@ +// Cloud-only: signing out must ALWAYS sign the browser out. The shell's +// sign-out is a top-level form POST (the WorkOS hop is cross-origin, so it +// can't be a fetch), which means whatever `/api/auth/logout` answers is +// rendered AS THE PAGE. An error response is therefore not a silent failure — +// it is a screenful of raw API JSON where the homepage should be. Reported +// from the field as `{"_tag":"Unauthorized"}`. +// +// The endpoint used to sit behind SessionAuth, so a browser whose sealed +// session no longer authenticated AT THE MOMENT OF THE CLICK got the +// middleware's 401 instead of being signed out. Reaching that state needs +// nothing exotic: a second open tab does it (sign out in one, the other's +// shell is still painted but its cookie is gone), and an expired or +// upstream-revoked session lands on the same branch. Signing out of a session +// that is already over is the one request that must never fail — the user is +// asking for the state they are already in. +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import type { Page } from "playwright"; + +import { scenario } from "../src/scenario"; +import { Api, Browser, Target } from "../src/services"; + +/** The display-only identity cookie the SSR gate mints (non-HttpOnly). */ +const HINT_COOKIE = "executor-auth-hint"; + +/** First `Set-Cookie` header for `name`, as the raw header string. */ +const setCookieFor = (response: Response, name: string): string => { + for (const header of response.headers.getSetCookie()) { + if (header.startsWith(`${name}=`)) return header; + } + return ""; +}; + +// Open the account dropdown and sign out. Bounded retry: a click can land +// while the shell is still hydrating and the radix menu never opens (same +// idiom as auth-hint / org-switcher). +const clickSignOut = async (page: Page) => { + for (let attempt = 1; ; attempt++) { + try { + await page.keyboard.press("Escape"); + await page.getByRole("button", { name: /Test User/ }).click(); + await page.getByRole("menuitem", { name: "Sign out" }).click({ timeout: 5_000 }); + return; + } catch (error) { + if (attempt >= 3) throw error; + } + } +}; + +scenario( + "Sign-out · a browser whose session is already over is signed out, not handed an API error page", + {}, + Effect.gen(function* () { + // Gate: the REST API plane is mounted on this target. + yield* Api; + const target = yield* Target; + + const signOut = (cookie?: string) => + Effect.promise(() => + fetch(new URL("/api/auth/logout", target.baseUrl), { + method: "POST", + redirect: "manual", + ...(cookie ? { headers: { cookie } } : {}), + }), + ); + + // A session cookie that no longer authenticates — expired, or revoked + // upstream while the tab sat open. There is nothing to end at WorkOS, but + // this browser must still leave signed out: a stale session cookie left in + // place keeps routing / into the app instead of marketing. + const stale = yield* signOut("wos-session=stale-sealed-session"); + const staleBody = yield* Effect.promise(() => stale.text()); + + // The response IS the page the user reads. This is the reported symptom. + expect(staleBody, "sign-out never renders an API error envelope").not.toContain("_tag"); + expect(stale.status, "sign-out sends the browser onward").toBe(302); + expect( + new URL(stale.headers.get("location") ?? "", target.baseUrl).pathname, + "sign-out lands the browser home", + ).toBe("/"); + expect(setCookieFor(stale, "wos-session"), "the dead session cookie is dropped").toContain( + "Max-Age=0", + ); + expect( + setCookieFor(stale, HINT_COOKIE), + "the auth hint never outlives the session it describes", + ).toContain("Max-Age=0"); + + // A POST carrying no cookies at all is what a CROSS-SITE form reaching + // this endpoint looks like: both cookies are SameSite=Lax, so another + // origin's POST arrives bare. Signing out is public, so it must answer + // such a request without touching this browser's session — otherwise any + // site could sign our users out. + const bare = yield* signOut(); + const bareBody = yield* Effect.promise(() => bare.text()); + + expect(bareBody, "a session-less sign-out renders no error envelope either").not.toContain( + "_tag", + ); + expect(bare.status, "it is sent home like any other sign-out").toBe(302); + expect( + new URL(bare.headers.get("location") ?? "", target.baseUrl).pathname, + "it lands home", + ).toBe("/"); + expect( + bare.headers.getSetCookie(), + "a request that presented no session cannot expire anyone else's", + ).toEqual([]); + }), +); + +scenario( + "Sign-out · a second open tab signs out too instead of showing raw JSON", + {}, + Effect.gen(function* () { + const browser = yield* Browser; + const target = yield* Target; + const identity = yield* target.newIdentity(); + + // `page` is the tab the user is LOOKING at when it breaks — the run's + // video and screenshots follow it, so the artifacts show the reported + // symptom rather than the tab that already signed out cleanly. + yield* browser.session(identity, async ({ page, step }) => { + const otherTab = await page.context().newPage(); + + await step("The user has the app open in two tabs", async () => { + await page.goto("/", { waitUntil: "commit" }); + await page.getByRole("link", { name: "Policies" }).waitFor(); + await otherTab.goto("/", { waitUntil: "commit" }); + await otherTab.getByRole("link", { name: "Policies" }).waitFor(); + }); + + await step("Sign out in the other tab", async () => { + await clickSignOut(otherTab); + await otherTab.waitForURL((url) => url.pathname === "/" || url.pathname === "/login", { + timeout: 15_000, + }); + }); + + // This tab was never told: its shell is still painted, and its sign-out + // button still works the only way it knows how. + const before = page.url(); + await step("Sign out in this tab, which still shows the app", async () => { + await clickSignOut(page); + await page.waitForURL((url) => url.toString() !== before, { + timeout: 15_000, + }); + await page.waitForLoadState("networkidle"); + }); + + const shown = (await page.locator("body").innerText()).trim(); + expect(shown, "sign-out never renders the API's error envelope as a page").not.toContain( + "_tag", + ); + expect( + new URL(page.url()).pathname, + "this tab lands on a signed-out page like the other one", + ).toMatch(/^\/(login)?$/); + expect( + (await page.context().cookies()).map((cookie) => cookie.name), + "the browser is left with no session either way", + ).not.toContain("wos-session"); + }); + }), +); From f3ec48d495ff6b2ee8fcbc6c7016ababa12ff244 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:55:13 -0600 Subject: [PATCH 014/133] Answer dead-session standalone GETs with 405 to stop reconnect loops (#1622) --- apps/cloud/src/mcp/agent-handler.ts | 27 ++++++++++++-- apps/host-cloudflare/src/mcp/agent-handler.ts | 25 +++++++++++-- .../mcp/agent-session-durable-object.test.ts | 28 ++++++++++++++ .../src/mcp/agent-session-durable-object.ts | 22 ++++++++++- packages/hosts/mcp/src/envelope.test.ts | 37 +++++++++++++++++++ packages/hosts/mcp/src/envelope.ts | 32 +++++++++++++--- 6 files changed, 156 insertions(+), 15 deletions(-) diff --git a/apps/cloud/src/mcp/agent-handler.ts b/apps/cloud/src/mcp/agent-handler.ts index c4abd5afa2..a2e7a92404 100644 --- a/apps/cloud/src/mcp/agent-handler.ts +++ b/apps/cloud/src/mcp/agent-handler.ts @@ -45,6 +45,25 @@ const jsonRpcResponse = ( ? jsonRpcErrorBody(status, code, message) : jsonRpcErrorBody(status, code, message, { challenge }); +/** + * A dead session id answers by request method. POST/DELETE keep the 404 that + * tells a compliant client to re-initialize. A standalone GET gets 405: the + * v1 SDK treats that as "no SSE stream offered" and stops retrying quietly, + * which breaks the reconnect loops of pre-cutover always-on deployments — + * their GET-404 path never re-initialized, it just retried forever. + */ +const deadSessionResponse = (method: string, message: string): Response => + method === "GET" + ? new Response(JSON.stringify({ jsonrpc: "2.0", error: { code: -32001, message }, id: null }), { + status: 405, + headers: { + "content-type": "application/json", + allow: "POST, DELETE", + "access-control-allow-origin": "*", + }, + }) + : jsonRpcResponse(404, -32001, message); + const renderAuthError = ( auth: McpAuthProvider["Service"], request: Request, @@ -217,7 +236,7 @@ export const makeCloudMcpAgentHandler = () => { const existingSession = sessionId ? mcpSessionStub(env.MCP_SESSION, sessionId) : null; if (sessionId && !existingSession) { - return jsonRpcResponse(404, -32001, "Session not found"); + return deadSessionResponse(request.method, "Session not found"); } if (existingSession) { const owner = await existingSession.validateMcpSessionOwner({ @@ -225,13 +244,13 @@ export const makeCloudMcpAgentHandler = () => { organizationId: outcome.principal.organizationId, }); if (owner === "not_found") { - return jsonRpcResponse(404, -32001, "Session not found"); + return deadSessionResponse(request.method, "Session not found"); } if (owner === "terminated") { // DELETE-condemned but the deferred destroy alarm hasn't wiped storage // yet. Same envelope as the post-destroy race below: the client must // treat the id as dead and reconnect. - return jsonRpcResponse(404, -32001, "Session timed out, please reconnect"); + return deadSessionResponse(request.method, "Session timed out, please reconnect"); } if (owner === "forbidden") { return jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer"); @@ -267,7 +286,7 @@ export const makeCloudMcpAgentHandler = () => { // client to be told to reconnect, matching a timed-out session). // oxlint-disable-next-line executor/no-unknown-error-message -- adapter boundary: the abort reason is a plain runtime Error whose message IS the signal if (Predicate.isError(error) && error.message === "destroyed") { - return jsonRpcResponse(404, -32001, "Session timed out, please reconnect"); + return deadSessionResponse(request.method, "Session timed out, please reconnect"); } // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: rethrow anything that isn't the condemned-DO abort to the Workers runtime unchanged throw error; diff --git a/apps/host-cloudflare/src/mcp/agent-handler.ts b/apps/host-cloudflare/src/mcp/agent-handler.ts index 3a729f86c9..277d34542a 100644 --- a/apps/host-cloudflare/src/mcp/agent-handler.ts +++ b/apps/host-cloudflare/src/mcp/agent-handler.ts @@ -41,6 +41,25 @@ const jsonRpcResponse = ( ? jsonRpcErrorBody(status, code, message) : jsonRpcErrorBody(status, code, message, { challenge }); +/** + * A dead session id answers by request method. POST/DELETE keep the 404 that + * tells a compliant client to re-initialize. A standalone GET gets 405: the + * v1 SDK treats that as "no SSE stream offered" and stops retrying quietly, + * which breaks the reconnect loops of pre-cutover always-on deployments — + * their GET-404 path never re-initialized, it just retried forever. + */ +const deadSessionResponse = (method: string, message: string): Response => + method === "GET" + ? new Response(JSON.stringify({ jsonrpc: "2.0", error: { code: -32001, message }, id: null }), { + status: 405, + headers: { + "content-type": "application/json", + allow: "POST, DELETE", + "access-control-allow-origin": "*", + }, + }) + : jsonRpcResponse(404, -32001, message); + const renderAuthError = ( auth: McpAuthProvider["Service"], request: Request, @@ -145,7 +164,7 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { const existingSession = sessionId ? mcpSessionStub(env.MCP_SESSION, sessionId) : null; if (sessionId && !existingSession) { - return jsonRpcResponse(404, -32001, "Session not found"); + return deadSessionResponse(request.method, "Session not found"); } if (existingSession) { const owner = await existingSession.validateMcpSessionOwner({ @@ -153,12 +172,12 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { organizationId: outcome.principal.organizationId, }); if (owner === "not_found") { - return jsonRpcResponse(404, -32001, "Session not found"); + return deadSessionResponse(request.method, "Session not found"); } if (owner === "terminated") { // DELETE-condemned but the deferred destroy alarm hasn't wiped storage // yet; the terminated id must read as dead immediately. - return jsonRpcResponse(404, -32001, "Session timed out, please reconnect"); + return deadSessionResponse(request.method, "Session timed out, please reconnect"); } if (owner === "forbidden") { return jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer"); diff --git a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts index 100068f7e8..5fec25ea0a 100644 --- a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts +++ b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts @@ -576,4 +576,32 @@ describe("McpAgentSessionDOBase session serving", () => { error: { code: -32001, message: "Session not found" }, }); }); + + it("answers a dead-session standalone GET with 405 so old clients stop retrying", async () => { + const state = new MemoryDurableObjectState(); + await state.storage.put("session-meta", { + organizationId: ORGANIZATION_ID, + organizationName: "Old Agent Org", + userId: ACCOUNT_ID, + resource: defaultMcpResource, + } satisfies SessionMeta); + const session = new HarnessSession(state, {} as Cloudflare.Env); + const request = verifiedRequest( + new Request("https://executor.test/mcp", { + method: "GET", + headers: { + accept: "text/event-stream", + "mcp-session-id": SESSION_ID, + }, + }), + ); + + const response = await session.fetch(request); + + expect(response.status).toBe(405); + expect(response.headers.get("allow")).toBe("POST, DELETE"); + await expect(response.json()).resolves.toMatchObject({ + error: { code: -32001, message: "Session not found" }, + }); + }); }); diff --git a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts index 937b9928d7..a27213eaf4 100644 --- a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts +++ b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts @@ -298,6 +298,24 @@ type QueuedTransportMessage = { readonly extra?: MessageExtraInfo; }; +/** + * Dead-session answer by method: POST/DELETE keep the 404 that drives client + * re-initialization; a standalone GET gets 405, which the v1 SDK reads as + * "no SSE stream offered" and stops retrying — breaking the reconnect loops + * of pre-cutover deployments whose GET-404 path never re-initialized. + */ +const deadSessionDoResponse = (method: string): Response => + method === "GET" + ? new Response( + JSON.stringify({ + jsonrpc: "2.0", + error: { code: -32001, message: "Session not found" }, + id: null, + }), + { status: 405, headers: { "content-type": "application/json", allow: "POST, DELETE" } }, + ) + : jsonRpcErrorBody(404, -32001, "Session not found", { cors: false }); + export abstract class McpAgentSessionDOBase< Env extends Cloudflare.Env = Cloudflare.Env, TDbHandle extends SessionDbHandle = SessionDbHandle, @@ -1182,7 +1200,7 @@ export abstract class McpAgentSessionDOBase< return this.serializedTransportRequest(async () => { const transport = this.transport; if (!transport) { - return jsonRpcErrorBody(404, -32001, "Session not found", { cors: false }); + return deadSessionDoResponse(request.method); } if (request.method === "GET") { const lastEventId = request.headers.get("last-event-id"); @@ -1282,7 +1300,7 @@ export abstract class McpAgentSessionDOBase< if (!stored) { if (!isInitializeBody(parsedBody)) { return request.headers.has("mcp-session-id") - ? jsonRpcErrorBody(404, -32001, "Session not found", { cors: false }) + ? deadSessionDoResponse(request.method) : jsonRpcErrorBody(400, -32000, "Bad Request: Server not initialized", { cors: false, }); diff --git a/packages/hosts/mcp/src/envelope.test.ts b/packages/hosts/mcp/src/envelope.test.ts index 90c0a38e8b..65e2828376 100644 --- a/packages/hosts/mcp/src/envelope.test.ts +++ b/packages/hosts/mcp/src/envelope.test.ts @@ -252,6 +252,43 @@ describe("McpServingRoutes envelope", () => { }); }); + it("answers a dead-session standalone GET with 405 so old clients stop retrying", async () => { + const NotFoundStoreLive = Layer.succeed(McpSessionStore)({ + dispatch: (): Effect.Effect => Effect.succeed("not-found"), + dispose: () => Effect.void, + }); + const handler = buildHandler(NotFoundStoreLive, McpErrorReporterNoop); + + const get = await handler( + new Request("https://host.test/mcp", { + method: "GET", + headers: { + authorization: "Bearer x", + accept: "text/event-stream", + "mcp-session-id": "dead-session", + "mcp-protocol-version": "2025-06-18", + }, + }), + ); + expect(get.status).toBe(405); + expect(get.headers.get("allow")).toBe("POST, DELETE"); + expect(await get.json()).toMatchObject({ error: { code: -32001 } }); + + const post = await handler( + new Request("https://host.test/mcp", { + method: "POST", + headers: { + authorization: "Bearer x", + "content-type": "application/json", + "mcp-session-id": "dead-session", + "mcp-protocol-version": "2025-06-18", + }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }), + }), + ); + expect(post.status).toBe(404); + }); + it("404s a modern request whose toolkit route is not served", async () => { const handler = buildHandler(OkStoreLive, McpErrorReporterNoop); const response = await handler(modernRequest("https://host.test/mcp/toolkits/unknown/extra")); diff --git a/packages/hosts/mcp/src/envelope.ts b/packages/hosts/mcp/src/envelope.ts index d0db0e8d74..a32fd8213e 100644 --- a/packages/hosts/mcp/src/envelope.ts +++ b/packages/hosts/mcp/src/envelope.ts @@ -238,11 +238,29 @@ const renderAuthError = ( Match.exhaustive, ); -/** Render a non-`Response` {@link McpDispatchResult} discriminant. */ -const renderDispatchError = (lookup: "not-found" | "forbidden"): Response => - lookup === "not-found" - ? jsonRpcResponse(404, -32001, "Session not found") - : jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer"); +/** + * Render a non-`Response` {@link McpDispatchResult} discriminant. A dead + * session answers by method: POST/DELETE keep the 404 that drives client + * re-initialization; a standalone GET gets 405, which the v1 SDK reads as + * "no SSE stream offered" and stops retrying — breaking pre-cutover + * reconnect loops whose GET-404 path never re-initialized. + */ +const renderDispatchError = (lookup: "not-found" | "forbidden", method: string): Response => { + if (lookup === "forbidden") { + return jsonRpcResponse(403, -32003, "MCP session does not belong to the current bearer"); + } + if (method === "GET") { + return new Response( + JSON.stringify({ + jsonrpc: "2.0", + error: { code: -32001, message: "Session not found" }, + id: null, + }), + { status: 405, headers: { "content-type": "application/json", allow: "POST, DELETE" } }, + ); + } + return jsonRpcResponse(404, -32001, "Session not found"); +}; const withModernMcpCors = (response: Response): Response => { const headers = new Headers(response.headers); @@ -398,7 +416,9 @@ const mcpDispatch = (resource: McpResource, modern: ModernMcpRouter) => sessionId, method: request.method, }); - return fromWebResponse(result instanceof Response ? result : renderDispatchError(result)); + return fromWebResponse( + result instanceof Response ? result : renderDispatchError(result, request.method), + ); }); /** From dd8aab7cb19e348a4ad6f71d2e0251184f779560 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:34:50 -0600 Subject: [PATCH 015/133] Pin worker logpush in wrangler config (#1625) The account Logpush job exports workers_trace_events to Axiom, but the script-level logpush flag was lost in the July deploy migration and the export silently stopped. Pin it in config so deploys preserve it; the flag was re-enabled via API today. --- apps/cloud/wrangler.jsonc | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/cloud/wrangler.jsonc b/apps/cloud/wrangler.jsonc index ac00660556..a38224c957 100644 --- a/apps/cloud/wrangler.jsonc +++ b/apps/cloud/wrangler.jsonc @@ -21,6 +21,12 @@ "observability": { "enabled": true, }, + // Script-level logpush feeds the account's workers_trace_events Logpush job + // (invocation logs, outcomes like exceededMemory, console output) into + // Axiom. Pinned here because the setting lives on the script: a deploy that + // omits it can reset it to false, which is how the export silently died in + // the July deploy-tooling migration. + "logpush": true, "durable_objects": { "bindings": [ { From d3f0617deec06c57e0d6e1479fe668f79daf977d Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:38:01 -0600 Subject: [PATCH 016/133] Bind span header redaction to the hosted HTTP client (#1626) * Bind span header redaction to the hosted HTTP client Live verification after the telemetry-layer redaction shipped showed post-deploy http.client spans still recording non-allowlisted headers: consumers capture the client value at construction and execute requests on fibers a host telemetry layer never reaches, so providing the redaction reference by layer was not enough. Bind it to the client itself so every request effect carries it, and keep the cloud layer as the server-side half. Regression test drives a request through the real client and asserts on the recorded span attributes. * Add DOM.Iterable to the mcp plugin tsconfig The package's tsc build failed on Headers.entries() and the iterable protocol: its lib had DOM without DOM.Iterable, so the fetch Headers type lacked both. Latent on main; every CI run served the package build from the turbo cache until the sdk change invalidated it. Same lib pairing hosts/cloudflare already uses. --- .../hosted-client-span-header-redaction.md | 5 ++ .../src/observability/header-redaction.ts | 66 +++-------------- packages/core/sdk/src/host-internal.ts | 1 + .../core/sdk/src/hosted-http-client.test.ts | 68 +++++++++++++++++ packages/core/sdk/src/hosted-http-client.ts | 73 ++++++++++++++++++- packages/plugins/mcp/tsconfig.json | 2 +- 6 files changed, 156 insertions(+), 59 deletions(-) create mode 100644 .changeset/hosted-client-span-header-redaction.md diff --git a/.changeset/hosted-client-span-header-redaction.md b/.changeset/hosted-client-span-header-redaction.md new file mode 100644 index 0000000000..ac53f0f276 --- /dev/null +++ b/.changeset/hosted-client-span-header-redaction.md @@ -0,0 +1,5 @@ +--- +"@executor-js/sdk": patch +--- + +Redact every span header attribute outside a safe allowlist on the hosted HTTP client. The tracer's default four-name blocklist let provider-specific credential headers reach the trace backend verbatim; the hosted client now inverts the model and masks everything except structurally safe negotiation, caching, and tracing headers. diff --git a/apps/cloud/src/observability/header-redaction.ts b/apps/cloud/src/observability/header-redaction.ts index 188046682c..8c7fd3fc08 100644 --- a/apps/cloud/src/observability/header-redaction.ts +++ b/apps/cloud/src/observability/header-redaction.ts @@ -1,65 +1,19 @@ // --------------------------------------------------------------------------- -// Span header redaction. -// -// Effect's HttpClient/HttpServer tracer records every request and response -// header as a span attribute, masking only the names in -// `Headers.CurrentRedactedNames` (default: authorization, cookie, set-cookie, -// x-api-key). That blocklist can never be right here: executor's whole job is -// calling arbitrary upstream APIs whose credentials ride in provider-specific -// headers (x-goog-api-key, api-key, x-auth-token, ...), and any name missing -// from the list ships a live secret to the trace backend verbatim. -// -// So the override inverts the model: every header is redacted unless its name -// is on the allowlist of structurally safe, diagnostically useful headers. -// The alternative of enumerating known secret-bearing names was rejected: new -// integrations add new credential headers faster than a blocklist can learn -// them, and one miss is a leaked customer credential. +// Span header redaction for fibers composed under the cloud telemetry layers +// (the Effect HTTP server tracer and any request-scoped client use). The +// allowlist itself lives beside the hosted HTTP client in +// `@executor-js/sdk/host-internal`, which also binds it directly to that +// client: consumers capture the client value at construction, so a context +// layer alone cannot reach their request fibers. This layer is the +// server-side/defense-in-depth half of the same decision; see +// hosted-http-client.ts for the rationale and the allowlist. // --------------------------------------------------------------------------- import { Layer } from "effect"; import { Headers } from "effect/unstable/http"; - -// Names that never carry credentials and are worth reading on a span while -// debugging: negotiation, caching, routing, and the tracing headers -// themselves. Everything else renders as ``. -const SAFE_HEADER_NAMES = [ - "accept", - "accept-encoding", - "accept-language", - "age", - "cache-control", - "connection", - "content-encoding", - "content-length", - "content-type", - "date", - "etag", - "expires", - "host", - "if-modified-since", - "if-none-match", - "last-modified", - "location", - "mcp-protocol-version", - "mcp-session-id", - "origin", - "referer", - "retry-after", - "traceparent", - "tracestate", - "transfer-encoding", - "user-agent", - "vary", - "via", - "x-request-id", -] as const; - -// Header names are lowercased at `Headers` construction, and `Headers.redact` -// masks every name a RegExp entry matches. One negative lookahead turns the -// allowlist into "redact everything else". -const redactAllButSafe = new RegExp(`^(?!(?:${SAFE_HEADER_NAMES.join("|")})$)`); +import { spanRedactedHeaderNames } from "@executor-js/sdk/host-internal"; export const SpanHeaderRedactionLive: Layer.Layer = Layer.succeed( Headers.CurrentRedactedNames, - [redactAllButSafe], + spanRedactedHeaderNames, ); diff --git a/packages/core/sdk/src/host-internal.ts b/packages/core/sdk/src/host-internal.ts index e62cb15bbb..834e0e4e81 100644 --- a/packages/core/sdk/src/host-internal.ts +++ b/packages/core/sdk/src/host-internal.ts @@ -33,6 +33,7 @@ export { HostedOutboundRequestBlocked, makeHostedFetch, makeHostedHttpClientLayer, + spanRedactedHeaderNames, type HostedHttpClientOptions, } from "./hosted-http-client"; diff --git a/packages/core/sdk/src/hosted-http-client.test.ts b/packages/core/sdk/src/hosted-http-client.test.ts index 61f4590e08..bdf553f8f1 100644 --- a/packages/core/sdk/src/hosted-http-client.test.ts +++ b/packages/core/sdk/src/hosted-http-client.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Predicate, Result } from "effect"; +import type * as Tracer from "effect/Tracer"; import { HttpClient, HttpClientRequest } from "effect/unstable/http"; import { @@ -266,4 +267,71 @@ describe("hosted outbound HTTP client", () => { expect(calls).toBe(1); }), ); + + it.effect("redacts every span header attribute outside the safe allowlist", () => + Effect.gen(function* () { + const spanAttributes = new Map(); + const recordingTracer: Tracer.Tracer = { + span: (options) => { + let status: Tracer.SpanStatus = { _tag: "Started", startTime: options.startTime }; + return { + _tag: "Span", + name: options.name, + spanId: "0000000000000001", + traceId: "00000000000000000000000000000001", + parent: options.parent, + annotations: options.annotations, + get status() { + return status; + }, + attributes: spanAttributes, + links: options.links, + sampled: options.sampled, + kind: options.kind, + end: (endTime, exit) => { + status = { _tag: "Ended", startTime: options.startTime, endTime, exit }; + }, + attribute: (key, value) => { + spanAttributes.set(key, value); + }, + event: () => undefined, + addLinks: () => undefined, + }; + }, + }; + + const fakeFetch: typeof globalThis.fetch = (async () => + new Response("{}", { + status: 200, + headers: { + "content-type": "application/json", + "cf-ray": "a2ca5b47bb7f3550", + }, + })) as typeof globalThis.fetch; + + yield* Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + return yield* client.execute( + HttpClientRequest.get("https://api.example/data").pipe( + HttpClientRequest.setHeaders({ + accept: "application/json", + "x-goog-api-key": "live-credential", + }), + ), + ); + }).pipe( + Effect.provide( + makeHostedHttpClientLayer({ fetch: fakeFetch, resolveHostname: publicResolver }), + ), + Effect.withTracer(recordingTracer), + ); + + expect(String(spanAttributes.get("http.request.header.accept"))).toBe("application/json"); + expect(String(spanAttributes.get("http.request.header.x-goog-api-key"))).toBe(""); + expect(String(spanAttributes.get("http.response.header.content-type"))).toBe( + "application/json", + ); + expect(String(spanAttributes.get("http.response.header.cf-ray"))).toBe(""); + }), + ); }); diff --git a/packages/core/sdk/src/hosted-http-client.ts b/packages/core/sdk/src/hosted-http-client.ts index 37f1ea427c..25c226078c 100644 --- a/packages/core/sdk/src/hosted-http-client.ts +++ b/packages/core/sdk/src/hosted-http-client.ts @@ -1,5 +1,5 @@ import { Effect, Layer, Schema } from "effect"; -import { FetchHttpClient, HttpClient } from "effect/unstable/http"; +import { FetchHttpClient, Headers as HttpHeaders, HttpClient } from "effect/unstable/http"; export class HostedOutboundRequestBlocked extends Schema.TaggedErrorClass()( "HostedOutboundRequestBlocked", @@ -250,10 +250,79 @@ export const makeHostedFetch = (options: HostedHttpClientOptions = {}): typeof g // oxlint-disable-next-line executor/no-raw-fetch -- boundary: exposes a guarded Fetch API adapter for libraries that require fetch guardFetch(options.fetch ?? globalThis.fetch, options); +// --------------------------------------------------------------------------- +// Span header redaction. +// +// The HttpClient tracer records every request and response header as a span +// attribute, masking only the names in `Headers.CurrentRedactedNames` +// (default: authorization, cookie, set-cookie, x-api-key). That blocklist can +// never be right for a hosted client whose whole job is calling arbitrary +// upstream APIs: credentials ride in provider-specific headers +// (x-goog-api-key, api-key, x-auth-token, ...), and any name missing from the +// list ships a live secret to the trace backend verbatim. So the model is +// inverted: every header is redacted unless its name is on the allowlist of +// structurally safe, diagnostically useful headers. Enumerating known +// secret-bearing names was rejected: new integrations add credential headers +// faster than a blocklist can learn them, and one miss is a leaked customer +// credential. +// +// Bound to the client itself (not a host telemetry layer): consumers capture +// the client value at construction and execute requests on fibers whose +// context a host layer never sees, so the reference must travel with every +// request effect. +// --------------------------------------------------------------------------- + +const SPAN_SAFE_HEADER_NAMES = [ + "accept", + "accept-encoding", + "accept-language", + "age", + "cache-control", + "connection", + "content-encoding", + "content-length", + "content-type", + "date", + "etag", + "expires", + "host", + "if-modified-since", + "if-none-match", + "last-modified", + "location", + "mcp-protocol-version", + "mcp-session-id", + "origin", + "referer", + "retry-after", + "traceparent", + "tracestate", + "transfer-encoding", + "user-agent", + "vary", + "via", + "x-request-id", +] as const; + +// Header names are lowercased at `Headers` construction, and `Headers.redact` +// masks every name a RegExp entry matches. One negative lookahead turns the +// allowlist into "redact everything else". +export const spanRedactedHeaderNames: readonly (string | RegExp)[] = [ + new RegExp(`^(?!(?:${SPAN_SAFE_HEADER_NAMES.join("|")})$)`), +]; + +const withSpanHeaderRedaction = (client: HttpClient.HttpClient): HttpClient.HttpClient => + HttpClient.transformResponse(client, (effect) => + Effect.provideService(effect, HttpHeaders.CurrentRedactedNames, spanRedactedHeaderNames), + ); + export const makeHostedHttpClientLayer = ( options: HostedHttpClientOptions = {}, ): Layer.Layer => - FetchHttpClient.layer.pipe( + Layer.effect(HttpClient.HttpClient)( + Effect.map(Effect.service(HttpClient.HttpClient), withSpanHeaderRedaction), + ).pipe( + Layer.provide(FetchHttpClient.layer), Layer.provide( options.fetch ? Layer.succeed(FetchHttpClient.Fetch)(guardFetch(options.fetch, options)) diff --git a/packages/plugins/mcp/tsconfig.json b/packages/plugins/mcp/tsconfig.json index c9ad068608..1a31057f9d 100644 --- a/packages/plugins/mcp/tsconfig.json +++ b/packages/plugins/mcp/tsconfig.json @@ -5,7 +5,7 @@ "moduleResolution": "Bundler", "strict": true, "skipLibCheck": true, - "lib": ["ES2022", "DOM"], + "lib": ["ES2022", "DOM", "DOM.Iterable"], "types": ["bun-types", "node"], "noUnusedLocals": true, "noImplicitOverride": true, From 2ea74946cbbb2b9ca6bb8df4d8a5b7f54522b4f0 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:53:06 -0600 Subject: [PATCH 017/133] Negative-cache dead MCP session ids in the workers (#1627) --- apps/cloud/src/mcp/agent-handler.test.ts | 146 +++++++++++++++++ apps/cloud/src/mcp/agent-handler.ts | 100 ++++++++++-- apps/cloud/src/server.ts | 151 +++++++++-------- apps/host-cloudflare/src/app.ts | 5 +- .../src/mcp/agent-handler.test.ts | 153 ++++++++++++++++++ apps/host-cloudflare/src/mcp/agent-handler.ts | 88 +++++++++- .../test-stubs/cloudflare-workers.ts | 7 + apps/host-cloudflare/vitest.config.ts | 7 + 8 files changed, 571 insertions(+), 86 deletions(-) create mode 100644 apps/cloud/src/mcp/agent-handler.test.ts create mode 100644 apps/host-cloudflare/src/mcp/agent-handler.test.ts create mode 100644 apps/host-cloudflare/test-stubs/cloudflare-workers.ts diff --git a/apps/cloud/src/mcp/agent-handler.test.ts b/apps/cloud/src/mcp/agent-handler.test.ts new file mode 100644 index 0000000000..0f6e539a53 --- /dev/null +++ b/apps/cloud/src/mcp/agent-handler.test.ts @@ -0,0 +1,146 @@ +import { beforeEach, describe, expect, it, vi } from "@effect/vitest"; +import { Effect, Layer } from "effect"; + +import { + authenticated, + McpAuthProvider, + unauthorized, + type Principal, +} from "@executor-js/host-mcp"; + +import { cloudDeadSessionCacheForTest, makeCloudMcpAgentHandler } from "./agent-handler"; + +const principal: Principal = { + accountId: "acct_test", + organizationId: "org_test", + organizationName: "Test Org", + email: "test@example.com", + name: "Test", + avatarUrl: null, + roles: ["member"], +}; + +const AuthProviderLive = Layer.succeed(McpAuthProvider)({ + discoveryRoutes: [], + resourceMetadataUrl: (request) => new URL("/.well-known/mcp", request.url).toString(), + authenticate: (request) => + Effect.succeed( + request.headers.has("authorization") ? authenticated(principal) : unauthorized(), + ), +}); + +const requestFor = (method: "GET" | "POST", sessionId: string, authenticated = true): Request => + new Request("https://executor.test/mcp", { + method, + headers: { + ...(authenticated ? { authorization: "Bearer test" } : {}), + "mcp-session-id": sessionId, + ...(method === "POST" ? { "content-type": "application/json" } : {}), + }, + ...(method === "POST" + ? { + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }), + } + : {}), + }); + +const makeHarness = (owner: "not_found" | "terminated" = "not_found") => { + const ownerChecks = { count: 0 }; + const traces = { count: 0 }; + const stub = { + validateMcpSessionOwner: async () => { + ownerChecks.count += 1; + return owner; + }, + }; + const namespace = { + idFromString: (sessionId: string) => sessionId, + get: () => stub, + }; + // oxlint-disable-next-line executor/no-double-cast -- test boundary: the handler only reads the MCP_SESSION namespace in these legacy dead-session cases + const env = { MCP_SESSION: namespace } as unknown as Env; + // oxlint-disable-next-line executor/no-double-cast -- test boundary: no ExecutionContext capability is used before the dead-session response + const ctx = {} as unknown as ExecutionContext; + const handler = makeCloudMcpAgentHandler({ + authProvider: AuthProviderLive, + makeModernServerBuilder: () => ({ build: () => Effect.die("unused modern builder") }), + traceRequest: async (request, _env, _ctx, handle) => { + traces.count += 1; + return handle(request); + }, + }); + return { ctx, env, handler, ownerChecks, traces }; +}; + +describe("cloud MCP dead-session negative cache", () => { + beforeEach(() => { + vi.useRealTimers(); + cloudDeadSessionCacheForTest.clear(); + }); + + it.each([ + { method: "GET" as const, status: 405 }, + { method: "POST" as const, status: 404 }, + ])("serves a repeated $method without another DO lookup", async ({ method, status }) => { + const { ctx, env, handler, ownerChecks, traces } = makeHarness(); + const sessionId = `dead-${method.toLowerCase()}`; + + const first = await handler(requestFor(method, sessionId), env, ctx); + const second = await handler(requestFor(method, sessionId), env, ctx); + + expect(first.status).toBe(status); + expect(second.status).toBe(status); + expect(ownerChecks.count).toBe(1); + expect(traces.count).toBe(1); + }); + + it("keeps authentication ahead of cached session existence", async () => { + const { ctx, env, handler, ownerChecks, traces } = makeHarness(); + const sessionId = "dead-auth"; + + expect((await handler(requestFor("GET", sessionId), env, ctx)).status).toBe(405); + const unauthenticated = await handler(requestFor("GET", sessionId, false), env, ctx); + + expect(unauthenticated.status).toBe(401); + expect(ownerChecks.count).toBe(1); + expect(traces.count).toBe(1); + }); + + it("preserves the terminated-session response on a cached hit", async () => { + const { ctx, env, handler, ownerChecks, traces } = makeHarness("terminated"); + const sessionId = "dead-terminated"; + + const first = await handler(requestFor("POST", sessionId), env, ctx); + const second = await handler(requestFor("POST", sessionId), env, ctx); + + expect(first.status).toBe(404); + expect(second.status).toBe(404); + expect(await second.text()).toBe(await first.text()); + expect(ownerChecks.count).toBe(1); + expect(traces.count).toBe(1); + }); + + it("consults the DO again after five minutes", async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const { ctx, env, handler, ownerChecks } = makeHarness(); + const sessionId = "dead-expiry"; + + expect((await handler(requestFor("GET", sessionId), env, ctx)).status).toBe(405); + vi.advanceTimersByTime(5 * 60 * 1_000 + 1); + expect((await handler(requestFor("GET", sessionId), env, ctx)).status).toBe(405); + + expect(ownerChecks.count).toBe(2); + }); + + it("evicts the oldest entry without exceeding 4,096 sessions", () => { + for (let index = 0; index <= 4_096; index += 1) { + cloudDeadSessionCacheForTest.remember(`dead-${index}`, 0); + } + + expect(cloudDeadSessionCacheForTest.size()).toBe(4_096); + expect(cloudDeadSessionCacheForTest.has("dead-0", 1)).toBe(false); + expect(cloudDeadSessionCacheForTest.has("dead-1", 1)).toBe(true); + expect(cloudDeadSessionCacheForTest.has("dead-4096", 1)).toBe(true); + }); +}); diff --git a/apps/cloud/src/mcp/agent-handler.ts b/apps/cloud/src/mcp/agent-handler.ts index a2e7a92404..0a8980bfe9 100644 --- a/apps/cloud/src/mcp/agent-handler.ts +++ b/apps/cloud/src/mcp/agent-handler.ts @@ -1,5 +1,5 @@ import * as OtelTracer from "@effect/opentelemetry/Tracer"; -import { Effect, Predicate } from "effect"; +import { Effect, Layer, Predicate } from "effect"; import { McpAuthProvider, @@ -8,6 +8,7 @@ import { defaultMcpResource, UNAVAILABLE_RETRY_AFTER_SECONDS, type AuthOutcome, + type McpModernServerBuilder, type McpResource, } from "@executor-js/host-mcp"; import { requestBodyFromRequest } from "@executor-js/host-mcp/tool-server"; @@ -32,9 +33,53 @@ import { createMcpSessionStub, mcpSessionStub } from "@executor-js/cloudflare/mc import { wrapMcpSseResponse } from "../observability/memory-metrics"; import { WorkerTelemetryLive } from "../observability/telemetry"; import { cloudMcpAuth } from "./auth-provider"; -import { makeCloudModernMcpServerBuilder } from "./session-durable-object"; import { parseTraceparent } from "./traceparent"; +const DEAD_SESSION_CACHE_TTL_MS = 5 * 60 * 1_000; +const DEAD_SESSION_CACHE_MAX_ENTRIES = 4_096; +const deadSessionExpiries = new Map(); +const timedOutSessionIds = new Set(); + +type DeadSessionReason = "not_found" | "timed_out"; + +const isDeadSessionCached = (sessionId: string, now = Date.now()): boolean => { + const expiry = deadSessionExpiries.get(sessionId); + if (expiry === undefined) return false; + if (expiry > now) return true; + deadSessionExpiries.delete(sessionId); + timedOutSessionIds.delete(sessionId); + return false; +}; + +const cacheDeadSession = (sessionId: string, reason: DeadSessionReason, now = Date.now()): void => { + deadSessionExpiries.delete(sessionId); + if (deadSessionExpiries.size >= DEAD_SESSION_CACHE_MAX_ENTRIES) { + const oldestSessionId = deadSessionExpiries.keys().next().value; + if (oldestSessionId !== undefined) { + deadSessionExpiries.delete(oldestSessionId); + timedOutSessionIds.delete(oldestSessionId); + } + } + deadSessionExpiries.set(sessionId, now + DEAD_SESSION_CACHE_TTL_MS); + if (reason === "timed_out") timedOutSessionIds.add(sessionId); + else timedOutSessionIds.delete(sessionId); +}; + +const cachedDeadSessionMessage = (sessionId: string): string => + timedOutSessionIds.has(sessionId) ? "Session timed out, please reconnect" : "Session not found"; + +/** Test-only access to reset and verify the isolate-local dead-session cache. */ +export const cloudDeadSessionCacheForTest = { + clear: (): void => { + deadSessionExpiries.clear(); + timedOutSessionIds.clear(); + }, + remember: (sessionId: string, now?: number): void => + cacheDeadSession(sessionId, "not_found", now), + has: isDeadSessionCached, + size: (): number => deadSessionExpiries.size, +}; + const jsonRpcResponse = ( status: number, code: number, @@ -97,12 +142,12 @@ const renderAuthError = ( }); }; -const authenticate = (request: Request) => +const authenticate = (request: Request, authProvider: Layer.Layer) => Effect.gen(function* () { const auth = yield* McpAuthProvider; const outcome = yield* auth.authenticate(request); return { auth, outcome }; - }).pipe(Effect.provide(cloudMcpAuth)); + }).pipe(Effect.provide(authProvider)); // The earlier shared envelope ran the MCP auth path inside the Effect app, whose // HttpMiddleware provided the OTEL tracer — that is where the `mcp.request` @@ -126,6 +171,21 @@ const runTraced = (request: Request, program: Effect.Effect): Promise = ); }; +type TraceCloudMcpRequest = ( + request: Request, + env: Env, + ctx: ExecutionContext, + handle: (tracedRequest: Request) => Promise, +) => Promise; + +interface CloudMcpAgentHandlerOptions { + readonly makeModernServerBuilder: ( + session: McpSessionProps["session"], + ) => McpModernServerBuilder["Service"]; + readonly authProvider?: Layer.Layer; + readonly traceRequest?: TraceCloudMcpRequest; +} + // The MCP resource the request targets. `server.ts` routes both the bare `/mcp` // and `/mcp/toolkits/` to this handler (`prepareMcpOrgScope` strips the org // selector but keeps the toolkit segment), so a session minted on a toolkit path @@ -158,11 +218,14 @@ const propsForPrincipal = ( }; }); -export const makeCloudMcpAgentHandler = () => { +/** Build the cloud worker's authenticated legacy/modern MCP request handler. */ +export const makeCloudMcpAgentHandler = (options: CloudMcpAgentHandlerOptions) => { + const authProvider = options.authProvider ?? cloudMcpAuth; + const traceRequest = options.traceRequest ?? ((request, _env, _ctx, handle) => handle(request)); const modern = makeMcpModernRequestRouter(); const ALLOWED_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]); - return async (request: Request, env: Env, ctx: ExecutionContext): Promise => { + const handle = async (request: Request, env: Env, ctx: ExecutionContext): Promise => { if (request.method === "OPTIONS") { return mcpCorsPreflightResponse(request.headers.get("access-control-request-headers")); } @@ -173,7 +236,7 @@ export const makeCloudMcpAgentHandler = () => { } const sessionId = request.headers.get("mcp-session-id"); - const { auth, outcome } = await runTraced(request, authenticate(request)); + const { auth, outcome } = await runTraced(request, authenticate(request, authProvider)); if (!Predicate.isTagged(outcome, "Authenticated")) { // Destroying a live session on auth grounds requires a POSITIVE // determination that access is genuinely gone — only `Forbidden` carries @@ -218,7 +281,7 @@ export const makeCloudMcpAgentHandler = () => { resource, props, requestStateSigningKey: requireMcpRequestStateKey(env.MCP_REQUEST_STATE_KEY), - builder: makeCloudModernMcpServerBuilder(props.session), + builder: options.makeModernServerBuilder(props.session), sessions: env.MCP_SESSION, executionOwners: mcpExecutionOwnerDirectoryFromNamespace(env.MCP_EXECUTION_OWNER), }); @@ -238,18 +301,20 @@ export const makeCloudMcpAgentHandler = () => { if (sessionId && !existingSession) { return deadSessionResponse(request.method, "Session not found"); } - if (existingSession) { + if (existingSession && sessionId) { const owner = await existingSession.validateMcpSessionOwner({ accountId: outcome.principal.accountId, organizationId: outcome.principal.organizationId, }); if (owner === "not_found") { + cacheDeadSession(sessionId, "not_found"); return deadSessionResponse(request.method, "Session not found"); } if (owner === "terminated") { // DELETE-condemned but the deferred destroy alarm hasn't wiped storage // yet. Same envelope as the post-destroy race below: the client must // treat the id as dead and reconnect. + cacheDeadSession(sessionId, "timed_out"); return deadSessionResponse(request.method, "Session timed out, please reconnect"); } if (owner === "forbidden") { @@ -286,6 +351,7 @@ export const makeCloudMcpAgentHandler = () => { // client to be told to reconnect, matching a timed-out session). // oxlint-disable-next-line executor/no-unknown-error-message -- adapter boundary: the abort reason is a plain runtime Error whose message IS the signal if (Predicate.isError(error) && error.message === "destroyed") { + if (sessionId) cacheDeadSession(sessionId, "timed_out"); return deadSessionResponse(request.method, "Session timed out, please reconnect"); } // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: rethrow anything that isn't the condemned-DO abort to the Workers runtime unchanged @@ -293,4 +359,20 @@ export const makeCloudMcpAgentHandler = () => { } return withMcpResponseHeaders(wrapMcpSseResponse(request, env, response)); }; + + return async (request: Request, env: Env, ctx: ExecutionContext): Promise => { + const sessionId = request.headers.get("mcp-session-id"); + const cacheEligible = request.method !== "OPTIONS" && ALLOWED_METHODS.has(request.method); + if (cacheEligible && sessionId && isDeadSessionCached(sessionId)) { + return Effect.runPromise( + Effect.gen(function* () { + const { auth, outcome } = yield* authenticate(request, authProvider); + return Predicate.isTagged(outcome, "Authenticated") + ? deadSessionResponse(request.method, cachedDeadSessionMessage(sessionId)) + : renderAuthError(auth, request, outcome); + }).pipe(Effect.withTracerEnabled(false)), + ); + } + return traceRequest(request, env, ctx, (tracedRequest) => handle(tracedRequest, env, ctx)); + }; }; diff --git a/apps/cloud/src/server.ts b/apps/cloud/src/server.ts index 8443a9d8b7..549d35f43d 100644 --- a/apps/cloud/src/server.ts +++ b/apps/cloud/src/server.ts @@ -15,7 +15,10 @@ import { isAppOwnedPath } from "./app-paths"; import { makeCloudMcpAgentHandler } from "./mcp/agent-handler"; import { classifyMcpPath, prepareMcpOrgScope } from "./mcp/mount"; import { parseTraceparent } from "./mcp/traceparent"; -import { McpSessionDOSqlite as McpSessionDOBase } from "./mcp/session-durable-object"; +import { + makeCloudModernMcpServerBuilder, + McpSessionDOSqlite as McpSessionDOBase, +} from "./mcp/session-durable-object"; import { beforeSendWithOtelCorrelation, captureCause, @@ -85,15 +88,12 @@ export { McpExecutionOwnerDirectoryDO } from "@executor-js/cloudflare/mcp/execut // migration — without the OTel-SDK version-conflict that package would now // drag in (it pins `@opentelemetry/otlp-* ^0.200.0`, we ship ^0.214.0). // -// ONLY for paths the Effect app does not own. App-owned paths (/api/*, /mcp, -// /.well-known/* — see app-paths.ts) get their `http.server` span from -// Effect's own HttpMiddleware.tracer, which parses `traceparent` itself and -// parents the workos/store/db child spans. Wrapping those here too produced -// two identical sibling `http.server` spans per request (scope -// `executor-cloud-worker` next to scope `executor-cloud`) — double ingest, -// and the waterfall showed a childless twin. The worker span remains for -// everything Effect never sees: Start SSR, the marketing proxy, /_astro -// assets. +// App-owned paths (/api/* and /.well-known/* — see app-paths.ts) get their +// `http.server` span from Effect's HttpMiddleware tracer. `/mcp` is dispatched +// directly and uses `traceCloudMcpRequest` below so the agent handler can skip +// the entire span envelope for negative-cache hits. Other paths keep this +// worker span. Wrapping Effect-owned paths here too produced duplicate sibling +// spans per request. // // SimpleSpanProcessor exports synchronously at span end but the underlying // `fetch()` to Axiom is fire-and-forget; the Worker may terminate before it @@ -108,7 +108,70 @@ const fetchHandler = handler.fetch as ( ) => Response | Promise; const tracer = trace.getTracer("executor-cloud-worker"); -const mcpAgentHandler = makeCloudMcpAgentHandler(); + +const traceCloudMcpRequest = async ( + request: Request, + _env: Env, + ctx: ExecutionContext, + handle: (tracedRequest: Request) => Promise, +): Promise => { + if (!installTracerProvider()) return handle(request); + + const url = new URL(request.url); + const inbound = parseTraceparent(request.headers.get("traceparent"), null); + const parentContext = inbound + ? trace.setSpanContext(context.active(), { + traceId: inbound.traceId, + spanId: inbound.spanId, + traceFlags: inbound.traceFlags, + isRemote: true, + }) + : context.active(); + + return tracer.startActiveSpan( + `http.server ${request.method}`, + { kind: SpanKind.SERVER }, + parentContext, + async (span) => { + span.setAttribute(ATTR_HTTP_REQUEST_METHOD, request.method); + span.setAttribute(ATTR_URL_FULL, request.url); + span.setAttribute(ATTR_URL_PATH, url.pathname); + span.setAttribute(ATTR_URL_SCHEME, url.protocol.replace(/:$/, "")); + const spanContext = span.spanContext(); + const headers = new Headers(request.headers); + headers.set( + "traceparent", + `00-${spanContext.traceId}-${spanContext.spanId}-${(spanContext.traceFlags & 0xff).toString(16).padStart(2, "0")}`, + ); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; observe response/error for span status, keep trace export alive after the Agents bridge resolves or rejects + try { + const response = await handle(new Request(request, { headers })); + span.setAttribute(ATTR_HTTP_RESPONSE_STATUS_CODE, response.status); + if (response.status >= 500) { + span.setStatus({ code: SpanStatusCode.ERROR, message: `HTTP ${response.status}` }); + } + return response; + } catch (err) { + // oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- adapter boundary: Cloudflare's fetch callback throws untyped; normalized only for the OTel span record, the original error is rethrown below + const cause = err instanceof Error ? err : String(err); + span.recordException(cause); + // oxlint-disable-next-line executor/no-unknown-error-message -- adapter boundary: same normalization as the recordException line above + const message = typeof cause === "string" ? cause : cause.message; + span.setStatus({ code: SpanStatusCode.ERROR, message }); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; preserve original error to Cloudflare runtime + throw err; + } finally { + span.end(); + ctx.waitUntil(flushTracerProvider()); + } + }, + ); +}; + +const mcpAgentHandler = makeCloudMcpAgentHandler({ + makeModernServerBuilder: makeCloudModernMcpServerBuilder, + traceRequest: traceCloudMcpRequest, +}); const cloudflareHandler: ExportedHandler = { fetch: async (request, env, ctx) => { @@ -120,10 +183,17 @@ const cloudflareHandler: ExportedHandler = { // The MCP dispatch is classified up front, independent of whether // telemetry installs — an unset `AXIOM_TOKEN` (tracer not installed) must // never take /mcp requests down with it. See `installTracerProvider`'s - // early return below: it only governs the tracing envelope for - // non-MCP paths. + // early return below: the handler invokes it for uncached MCP traffic, and + // this entry invokes it for non-MCP paths. const url = new URL(request.url); const mcpRoute = classifyMcpPath(url.pathname); + if (mcpRoute?.kind === "mcp") { + // The Cloudflare Agents MCP bridge needs the platform ExecutionContext + // to pass authenticated session props into the hibernatable DO. + // Discovery docs still flow through the app-level MCP envelope. + const forwarded = prepareMcpOrgScope(request); + return mcpAgentHandler(forwarded, env, ctx); + } const tracingInstalled = installTracerProvider(); // Join the caller's W3C trace when the request carries one — the web UI // sends traceparent on every API fetch, so the browser's spans and this @@ -138,61 +208,6 @@ const cloudflareHandler: ExportedHandler = { isRemote: true, }) : context.active(); - if (mcpRoute?.kind === "mcp") { - // The Cloudflare Agents MCP bridge needs the platform ExecutionContext - // to pass authenticated session props into the hibernatable DO. - // Discovery docs still flow through the app-level MCP envelope. - const forwarded = prepareMcpOrgScope(request); - if (!tracingInstalled) { - return mcpAgentHandler(forwarded, env, ctx); - } - // /mcp left the Effect app in the Agents-bridge migration, so no - // downstream HttpMiddleware.tracer opens the request envelope anymore — - // this worker span is now THE `http.server` span for MCP traffic. Its - // context is stamped onto the forwarded request's traceparent so the - // agent handler's Effect programs (mcp.request and children) and the - // session DO parent under it instead of exporting orphaned roots. - return tracer.startActiveSpan( - `http.server ${request.method}`, - { kind: SpanKind.SERVER }, - parentContext, - async (span) => { - span.setAttribute(ATTR_HTTP_REQUEST_METHOD, request.method); - span.setAttribute(ATTR_URL_FULL, request.url); - span.setAttribute(ATTR_URL_PATH, url.pathname); - span.setAttribute(ATTR_URL_SCHEME, url.protocol.replace(/:$/, "")); - const spanContext = span.spanContext(); - const headers = new Headers(forwarded.headers); - headers.set( - "traceparent", - `00-${spanContext.traceId}-${spanContext.spanId}-${(spanContext.traceFlags & 0xff).toString(16).padStart(2, "0")}`, - ); - // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; observe response/error for span status, keep trace export alive after the Agents bridge resolves or rejects - try { - const response = await mcpAgentHandler(new Request(forwarded, { headers }), env, ctx); - span.setAttribute(ATTR_HTTP_RESPONSE_STATUS_CODE, response.status); - if (response.status >= 500) { - span.setStatus({ code: SpanStatusCode.ERROR, message: `HTTP ${response.status}` }); - } - return response; - } catch (err) { - // Record the exception itself, not just the status bit: without it - // these spans are ERROR with zero diagnostic content. - // oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- adapter boundary: Cloudflare's fetch callback throws untyped; normalized only for the OTel span record, the original error is rethrown below - const cause = err instanceof Error ? err : String(err); - span.recordException(cause); - // oxlint-disable-next-line executor/no-unknown-error-message -- adapter boundary: same normalization as the recordException line above - const message = typeof cause === "string" ? cause : cause.message; - span.setStatus({ code: SpanStatusCode.ERROR, message }); - // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; preserve original error to Cloudflare runtime - throw err; - } finally { - span.end(); - ctx.waitUntil(flushTracerProvider()); - } - }, - ); - } if (!tracingInstalled) { return fetchHandler(request, env, ctx); } diff --git a/apps/host-cloudflare/src/app.ts b/apps/host-cloudflare/src/app.ts index 8c3eebf789..7a2b1bbfa5 100644 --- a/apps/host-cloudflare/src/app.ts +++ b/apps/host-cloudflare/src/app.ts @@ -16,6 +16,7 @@ import { ErrorCaptureLive } from "./observability"; import { cloudflareAccountMiddleware } from "./account/account-provider"; import { makeCloudflareApprovalHandler } from "./mcp"; import { makeCloudflareMcpAgentHandler } from "./mcp/agent-handler"; +import { makeCloudflareModernMcpServerBuilder } from "./mcp/session-durable-object"; import { preloadQuickJs } from "./quickjs"; // =========================================================================== @@ -45,7 +46,9 @@ export const makeCloudflareApp = async (env: CloudflareEnv) => { // handle the per-request scoped executor reads through the DbProvider seam. const dbHandle = await createD1ExecutorDb(env.DB, env.BLOBS); const identityLayer = cloudflareAccessIdentityLayer(config); - const mcpAgentHandler = makeCloudflareMcpAgentHandler(config); + const mcpAgentHandler = makeCloudflareMcpAgentHandler(config, { + makeModernServerBuilder: makeCloudflareModernMcpServerBuilder, + }); const approvalHandler = makeCloudflareApprovalHandler(config, env); const { appLayer, toWebHandler } = ExecutorApp.make({ diff --git a/apps/host-cloudflare/src/mcp/agent-handler.test.ts b/apps/host-cloudflare/src/mcp/agent-handler.test.ts new file mode 100644 index 0000000000..0d21240c53 --- /dev/null +++ b/apps/host-cloudflare/src/mcp/agent-handler.test.ts @@ -0,0 +1,153 @@ +import { beforeEach, describe, expect, it, vi } from "@effect/vitest"; +import { Effect, Layer } from "effect"; + +import { + authenticated, + McpAuthProvider, + unauthorized, + type Principal, +} from "@executor-js/host-mcp"; + +import type { CloudflareConfig, CloudflareEnv } from "../config"; +import { cloudflareDeadSessionCacheForTest, makeCloudflareMcpAgentHandler } from "./agent-handler"; + +const principal: Principal = { + accountId: "acct_test", + organizationId: "org_test", + organizationName: "Test Org", + email: "test@example.com", + name: "Test", + avatarUrl: null, + roles: ["member"], +}; + +const config = { + accessTeamDomain: "test.cloudflareaccess.com", + accessAud: "aud_test", + accessNameClaim: "name", + accessGroupsClaim: "groups", + adminEmails: [], + organizationId: "org_test", + organizationName: "Test Org", + organizationSlug: "test-org", + secretKey: "test-secret-key-0123456789", + allowLocalNetwork: false, + enableDevAuth: false, +} satisfies CloudflareConfig; + +const AuthProviderLive = Layer.succeed(McpAuthProvider)({ + discoveryRoutes: [], + resourceMetadataUrl: (request) => new URL("/.well-known/mcp", request.url).toString(), + authenticate: (request) => + Effect.succeed( + request.headers.has("authorization") ? authenticated(principal) : unauthorized(), + ), +}); + +const requestFor = (method: "GET" | "POST", sessionId: string, authenticated = true): Request => + new Request("https://executor.test/mcp", { + method, + headers: { + ...(authenticated ? { authorization: "Bearer test" } : {}), + "mcp-session-id": sessionId, + ...(method === "POST" ? { "content-type": "application/json" } : {}), + }, + ...(method === "POST" + ? { + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }), + } + : {}), + }); + +const makeHarness = (owner: "not_found" | "terminated" = "not_found") => { + const ownerChecks = { count: 0 }; + const stub = { + validateMcpSessionOwner: async () => { + ownerChecks.count += 1; + return owner; + }, + }; + const namespace = { + idFromString: (sessionId: string) => sessionId, + get: () => stub, + }; + // oxlint-disable-next-line executor/no-double-cast -- test boundary: the handler only reads the MCP_SESSION namespace in these legacy dead-session cases + const env = { MCP_SESSION: namespace } as unknown as CloudflareEnv; + // oxlint-disable-next-line executor/no-double-cast -- test boundary: no ExecutionContext capability is used before the dead-session response + const ctx = {} as unknown as ExecutionContext; + const handler = makeCloudflareMcpAgentHandler(config, { + authProvider: AuthProviderLive, + makeModernServerBuilder: () => ({ build: () => Effect.die("unused modern builder") }), + }); + return { ctx, env, handler, ownerChecks }; +}; + +describe("standalone Cloudflare MCP dead-session negative cache", () => { + beforeEach(() => { + vi.useRealTimers(); + cloudflareDeadSessionCacheForTest.clear(); + }); + + it.each([ + { method: "GET" as const, status: 405 }, + { method: "POST" as const, status: 404 }, + ])("serves a repeated $method without another DO lookup", async ({ method, status }) => { + const { ctx, env, handler, ownerChecks } = makeHarness(); + const sessionId = `dead-${method.toLowerCase()}`; + + const first = await handler(requestFor(method, sessionId), env, ctx); + const second = await handler(requestFor(method, sessionId), env, ctx); + + expect(first.status).toBe(status); + expect(second.status).toBe(status); + expect(ownerChecks.count).toBe(1); + }); + + it("keeps authentication ahead of cached session existence", async () => { + const { ctx, env, handler, ownerChecks } = makeHarness(); + const sessionId = "dead-auth"; + + expect((await handler(requestFor("GET", sessionId), env, ctx)).status).toBe(405); + const unauthenticated = await handler(requestFor("GET", sessionId, false), env, ctx); + + expect(unauthenticated.status).toBe(401); + expect(ownerChecks.count).toBe(1); + }); + + it("preserves the terminated-session response on a cached hit", async () => { + const { ctx, env, handler, ownerChecks } = makeHarness("terminated"); + const sessionId = "dead-terminated"; + + const first = await handler(requestFor("POST", sessionId), env, ctx); + const second = await handler(requestFor("POST", sessionId), env, ctx); + + expect(first.status).toBe(404); + expect(second.status).toBe(404); + expect(await second.text()).toBe(await first.text()); + expect(ownerChecks.count).toBe(1); + }); + + it("consults the DO again after five minutes", async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const { ctx, env, handler, ownerChecks } = makeHarness(); + const sessionId = "dead-expiry"; + + expect((await handler(requestFor("GET", sessionId), env, ctx)).status).toBe(405); + vi.advanceTimersByTime(5 * 60 * 1_000 + 1); + expect((await handler(requestFor("GET", sessionId), env, ctx)).status).toBe(405); + + expect(ownerChecks.count).toBe(2); + }); + + it("evicts the oldest entry without exceeding 4,096 sessions", () => { + for (let index = 0; index <= 4_096; index += 1) { + cloudflareDeadSessionCacheForTest.remember(`dead-${index}`, 0); + } + + expect(cloudflareDeadSessionCacheForTest.size()).toBe(4_096); + expect(cloudflareDeadSessionCacheForTest.has("dead-0", 1)).toBe(false); + expect(cloudflareDeadSessionCacheForTest.has("dead-1", 1)).toBe(true); + expect(cloudflareDeadSessionCacheForTest.has("dead-4096", 1)).toBe(true); + }); +}); diff --git a/apps/host-cloudflare/src/mcp/agent-handler.ts b/apps/host-cloudflare/src/mcp/agent-handler.ts index 277d34542a..9fcdbd47cf 100644 --- a/apps/host-cloudflare/src/mcp/agent-handler.ts +++ b/apps/host-cloudflare/src/mcp/agent-handler.ts @@ -1,4 +1,4 @@ -import { Effect, Predicate } from "effect"; +import { Effect, Layer, Predicate } from "effect"; import { McpAuthProvider, @@ -6,6 +6,7 @@ import { mcpModernDisabledResponse, defaultMcpResource, type AuthOutcome, + type McpModernServerBuilder, type Principal, } from "@executor-js/host-mcp"; import { requestBodyFromRequest } from "@executor-js/host-mcp/tool-server"; @@ -29,7 +30,51 @@ import { createMcpSessionStub, mcpSessionStub } from "@executor-js/cloudflare/mc import type { CloudflareConfig, CloudflareEnv } from "../config"; import { cloudflareAccessMcpAuth } from "./auth"; -import { makeCloudflareModernMcpServerBuilder } from "./session-durable-object"; + +const DEAD_SESSION_CACHE_TTL_MS = 5 * 60 * 1_000; +const DEAD_SESSION_CACHE_MAX_ENTRIES = 4_096; +const deadSessionExpiries = new Map(); +const timedOutSessionIds = new Set(); + +type DeadSessionReason = "not_found" | "timed_out"; + +const isDeadSessionCached = (sessionId: string, now = Date.now()): boolean => { + const expiry = deadSessionExpiries.get(sessionId); + if (expiry === undefined) return false; + if (expiry > now) return true; + deadSessionExpiries.delete(sessionId); + timedOutSessionIds.delete(sessionId); + return false; +}; + +const cacheDeadSession = (sessionId: string, reason: DeadSessionReason, now = Date.now()): void => { + deadSessionExpiries.delete(sessionId); + if (deadSessionExpiries.size >= DEAD_SESSION_CACHE_MAX_ENTRIES) { + const oldestSessionId = deadSessionExpiries.keys().next().value; + if (oldestSessionId !== undefined) { + deadSessionExpiries.delete(oldestSessionId); + timedOutSessionIds.delete(oldestSessionId); + } + } + deadSessionExpiries.set(sessionId, now + DEAD_SESSION_CACHE_TTL_MS); + if (reason === "timed_out") timedOutSessionIds.add(sessionId); + else timedOutSessionIds.delete(sessionId); +}; + +const cachedDeadSessionMessage = (sessionId: string): string => + timedOutSessionIds.has(sessionId) ? "Session timed out, please reconnect" : "Session not found"; + +/** Test-only access to reset and verify the isolate-local dead-session cache. */ +export const cloudflareDeadSessionCacheForTest = { + clear: (): void => { + deadSessionExpiries.clear(); + timedOutSessionIds.clear(); + }, + remember: (sessionId: string, now?: number): void => + cacheDeadSession(sessionId, "not_found", now), + has: isDeadSessionCached, + size: (): number => deadSessionExpiries.size, +}; const jsonRpcResponse = ( status: number, @@ -79,12 +124,12 @@ const renderAuthError = ( return jsonRpcResponse(503, -32001, outcome.message); }; -const authenticate = (request: Request, config: CloudflareConfig) => +const authenticate = (request: Request, authProvider: Layer.Layer) => Effect.gen(function* () { const auth = yield* McpAuthProvider; const outcome = yield* auth.authenticate(request); return { auth, outcome }; - }).pipe(Effect.provide(cloudflareAccessMcpAuth(config))); + }).pipe(Effect.provide(authProvider)); const propsForPrincipal = ( request: Request, @@ -108,7 +153,21 @@ const propsForPrincipal = ( }; }); -export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { +interface CloudflareMcpAgentHandlerOptions { + readonly makeModernServerBuilder: ( + env: CloudflareEnv, + config: CloudflareConfig, + session: McpSessionProps["session"], + ) => McpModernServerBuilder["Service"]; + readonly authProvider?: Layer.Layer; +} + +/** Build the standalone Cloudflare worker's authenticated MCP request handler. */ +export const makeCloudflareMcpAgentHandler = ( + config: CloudflareConfig, + options: CloudflareMcpAgentHandlerOptions, +) => { + const authProvider = options.authProvider ?? cloudflareAccessMcpAuth(config); const modern = makeMcpModernRequestRouter(); return async (request: Request, env: CloudflareEnv, ctx: ExecutionContext): Promise => { if (request.method === "OPTIONS") { @@ -116,7 +175,18 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { } const sessionId = request.headers.get("mcp-session-id"); - const { auth, outcome } = await Effect.runPromise(authenticate(request, config)); + if (sessionId && isDeadSessionCached(sessionId)) { + return Effect.runPromise( + Effect.gen(function* () { + const { auth, outcome } = yield* authenticate(request, authProvider); + return Predicate.isTagged(outcome, "Authenticated") + ? deadSessionResponse(request.method, cachedDeadSessionMessage(sessionId)) + : renderAuthError(auth, request, outcome); + }).pipe(Effect.withTracerEnabled(false)), + ); + } + + const { auth, outcome } = await Effect.runPromise(authenticate(request, authProvider)); if (!Predicate.isTagged(outcome, "Authenticated")) { if (Predicate.isTagged(outcome, "Forbidden") && sessionId) { const session = mcpSessionStub(env.MCP_SESSION, sessionId); @@ -152,7 +222,7 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { resource: defaultMcpResource, props, requestStateSigningKey: requireMcpRequestStateKey(env.MCP_REQUEST_STATE_KEY), - builder: makeCloudflareModernMcpServerBuilder(env, config, props.session), + builder: options.makeModernServerBuilder(env, config, props.session), sessions: env.MCP_SESSION, executionOwners: mcpExecutionOwnerDirectoryFromNamespace(env.MCP_EXECUTION_OWNER), }); @@ -166,17 +236,19 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { if (sessionId && !existingSession) { return deadSessionResponse(request.method, "Session not found"); } - if (existingSession) { + if (existingSession && sessionId) { const owner = await existingSession.validateMcpSessionOwner({ accountId: outcome.principal.accountId, organizationId: outcome.principal.organizationId, }); if (owner === "not_found") { + cacheDeadSession(sessionId, "not_found"); return deadSessionResponse(request.method, "Session not found"); } if (owner === "terminated") { // DELETE-condemned but the deferred destroy alarm hasn't wiped storage // yet; the terminated id must read as dead immediately. + cacheDeadSession(sessionId, "timed_out"); return deadSessionResponse(request.method, "Session timed out, please reconnect"); } if (owner === "forbidden") { diff --git a/apps/host-cloudflare/test-stubs/cloudflare-workers.ts b/apps/host-cloudflare/test-stubs/cloudflare-workers.ts new file mode 100644 index 0000000000..7c4b769fc8 --- /dev/null +++ b/apps/host-cloudflare/test-stubs/cloudflare-workers.ts @@ -0,0 +1,7 @@ +/** Node-test stand-ins for Cloudflare module exports reached during handler imports. */ +export const env: Record = {}; +export class WorkerEntrypoint {} +export class DurableObject {} +export class WorkflowEntrypoint {} +export class RpcTarget {} +export const exports: Record = {}; diff --git a/apps/host-cloudflare/vitest.config.ts b/apps/host-cloudflare/vitest.config.ts index 5bfa2d586e..44cc8cc150 100644 --- a/apps/host-cloudflare/vitest.config.ts +++ b/apps/host-cloudflare/vitest.config.ts @@ -1,6 +1,13 @@ +import { resolve } from "node:path"; + import { defineConfig } from "vitest/config"; export default defineConfig({ + resolve: { + alias: { + "cloudflare:workers": resolve(__dirname, "./test-stubs/cloudflare-workers.ts"), + }, + }, test: { include: ["src/**/*.test.ts"], passWithNoTests: true, From 4263f3384b4ae72d38848ac91348194f4980e55d Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:37:21 -0600 Subject: [PATCH 018/133] Warm the Start server graph on isolate first-fetch (#1628) --- apps/cloud/src/server.ts | 44 +++++++++++++++++++++++ apps/cloud/src/start-virtual-entries.d.ts | 8 +++++ 2 files changed, 52 insertions(+) create mode 100644 apps/cloud/src/start-virtual-entries.d.ts diff --git a/apps/cloud/src/server.ts b/apps/cloud/src/server.ts index 549d35f43d..04d0002f11 100644 --- a/apps/cloud/src/server.ts +++ b/apps/cloud/src/server.ts @@ -173,8 +173,51 @@ const mcpAgentHandler = makeCloudMcpAgentHandler({ traceRequest: traceCloudMcpRequest, }); +// --------------------------------------------------------------------------- +// Start server-graph warmup +// +// TanStack Start's server entry loads the router and start instance behind a +// dynamic import on the first Start-handled request per isolate +// (start-server-core's `loadEntries`). That graph is the whole app bundle +// (React SSR + the Effect app), so the request that pays the load stalls for +// seconds on production metal. Under heavy MCP traffic the worker spreads +// across enough isolates that nearly every page/asset request IS such a first +// request — the 2026-08-16 session-reset reconnect storm took the homepage +// p50 from ~15ms to ~2.6s exactly this way. +// +// So: on each isolate's first fetch, kick the same imports off in the +// background. MCP traffic then pre-warms an isolate long before a page +// request reaches it. The specifiers are the virtual module ids the Start +// vite plugin registers for `loadEntries`' own imports, so both resolve to +// the same chunk and `loadEntries`' cache finds it already evaluated. +// +// Deliberately NOT at module scope: a full warmup there trips workerd's +// global-scope I/O restriction, and DO-only isolates should not carry the +// SSR graph in memory. +// --------------------------------------------------------------------------- +let startGraphWarm = false; +let startGraphWarmupStarted = false; +const warmStartGraph = () => { + if (startGraphWarmupStarted) return; + startGraphWarmupStarted = true; + // oxlint-disable-next-line executor/no-promise-catch -- adapter boundary; fire-and-forget warmup outside any Effect runtime + void Promise.all([import("#tanstack-router-entry"), import("#tanstack-start-entry")]) + .then(() => { + startGraphWarm = true; + }) + .catch(() => { + // Advisory only — the request path still loads the graph lazily. + startGraphWarmupStarted = false; + }); +}; + const cloudflareHandler: ExportedHandler = { fetch: async (request, env, ctx) => { + warmStartGraph(); + // Captured before any await: whether the Start graph was already + // evaluated when this request arrived (stamped on the page span below — + // a cold graph is the known multi-second page-latency mode). + const startGraphWasWarm = startGraphWarm; // Browser OTLP ingress — before the server span opens: exporter traffic // must never trace itself (the browser already excludes /v1/traces from // its own tracing for the same reason). @@ -234,6 +277,7 @@ const cloudflareHandler: ExportedHandler = { span.setAttribute(ATTR_URL_FULL, request.url); span.setAttribute(ATTR_URL_PATH, url.pathname); span.setAttribute(ATTR_URL_SCHEME, url.protocol.replace(/:$/, "")); + span.setAttribute("executor.start_graph.warm", startGraphWasWarm); // Adapter boundary: Cloudflare's fetch handler is a Promise-based // callback and the OTel span lifecycle needs to observe both the // resolved response and any thrown error before `span.end()`. Sentry's diff --git a/apps/cloud/src/start-virtual-entries.d.ts b/apps/cloud/src/start-virtual-entries.d.ts new file mode 100644 index 0000000000..9fb43d8206 --- /dev/null +++ b/apps/cloud/src/start-virtual-entries.d.ts @@ -0,0 +1,8 @@ +// TanStack Start's internal virtual server-entry modules (registered by the +// Start vite plugin; the same ids `start-server-core`'s `loadEntries` +// imports). server.ts imports them for the isolate warmup — only the +// module-evaluation side effect matters there, so the value shape is left +// untyped. Kept in a standalone declaration file: shorthand ambient modules +// only register from a non-module file (env-augment.d.ts is a module). +declare module "#tanstack-router-entry"; +declare module "#tanstack-start-entry"; From 003fb07d2e8ad20796f7cf827fb6266ac46048f6 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:36:21 -0600 Subject: [PATCH 019/133] First-party OAuth clients (#1527) * Add first-party OAuth clients (host-operated GitHub/Google apps) * Allow endpoint overrides for the first-party GitHub app in tests/dev * Launch built-in Google OAuth for Calendar and Sheets --- apps/cloud/src/engine/execution-stack.ts | 52 ++- apps/cloud/src/env-augment.d.ts | 15 + apps/marketing/src/pages/privacy.astro | 20 ++ e2e/scenarios/first-party-oauth.test.ts | 252 ++++++++++++++ e2e/setup/cloud.boot.ts | 15 + packages/core/api/src/oauth/api.ts | 9 + .../core/api/src/server/scoped-executor.ts | 10 + packages/core/sdk/src/executor.ts | 86 ++++- packages/core/sdk/src/index.ts | 5 + packages/core/sdk/src/oauth-client.ts | 69 +++- .../core/sdk/src/oauth-first-party.test.ts | 324 ++++++++++++++++++ packages/core/sdk/src/oauth-service.ts | 134 +++++++- packages/core/sdk/src/shared.ts | 4 + packages/core/sdk/src/test-config.ts | 2 + .../openapi/src/providers/google/index.ts | 1 + .../openapi/src/providers/google/presets.ts | 13 +- packages/plugins/openapi/src/sdk/presets.ts | 16 + .../src/components/add-account-modal.tsx | 16 +- .../use-effective-oauth-client.test.ts | 82 +++++ .../plugins/use-effective-oauth-client.tsx | 74 +++- 20 files changed, 1152 insertions(+), 47 deletions(-) create mode 100644 e2e/scenarios/first-party-oauth.test.ts create mode 100644 packages/core/sdk/src/oauth-first-party.test.ts diff --git a/apps/cloud/src/engine/execution-stack.ts b/apps/cloud/src/engine/execution-stack.ts index 869bf58167..ecbdee7142 100644 --- a/apps/cloud/src/engine/execution-stack.ts +++ b/apps/cloud/src/engine/execution-stack.ts @@ -42,8 +42,9 @@ import { PluginsProvider, collectTables, } from "@executor-js/api/server"; +import { googleCatalogOAuthScopesForPreset } from "@executor-js/plugin-openapi/providers/google"; import { makeDynamicWorkerExecutor } from "@executor-js/runtime-dynamic-worker"; -import type { AnyPlugin } from "@executor-js/sdk"; +import type { AnyPlugin, FirstPartyOAuthClientConfig } from "@executor-js/sdk"; import executorConfig from "../../executor.config"; import { DbService } from "../db/db"; @@ -88,6 +89,54 @@ export const CloudPluginsProvider: Layer.Layer = Layer.succeed( */ export const CLOUD_MOUNT_PREFIX = "/api" as const; +// Initial Google launch boundary. Calendar + Sheets are sensitive scopes but +// not restricted Workspace scopes; Gmail and account-wide Drive remain absent +// until their separate verification/security work is complete. The same scope +// source builds the catalog auth templates, preventing config drift. +const GOOGLE_FIRST_PARTY_ALLOWED_SCOPES: readonly string[] = [ + ...new Set([ + ...googleCatalogOAuthScopesForPreset("google-calendar"), + ...googleCatalogOAuthScopesForPreset("google-sheets"), + ]), +]; + +// Executor-owned provider apps, enabled per provider by setting BOTH env vars +// (id + secret). Each provider-side registration must list +// `${VITE_PUBLIC_SITE_URL}/api/oauth/callback` as its callback; the org slug +// travels inside OAuth `state`, so the single static callback serves every org. +// +// The endpoint URLs default to the real provider; the `_AUTHORIZE_URL` / +// `_TOKEN_URL` overrides exist so tests and dev instances can point the app at +// an emulated provider (`@executor-js/emulate`) and run the complete flow. +// Production leaves them unset. +const cloudFirstPartyOAuthClients = (): readonly FirstPartyOAuthClientConfig[] => [ + ...(env.FIRST_PARTY_GITHUB_CLIENT_ID && env.FIRST_PARTY_GITHUB_CLIENT_SECRET + ? [ + { + name: "github", + authorizationUrl: + env.FIRST_PARTY_GITHUB_AUTHORIZE_URL ?? "https://github.com/login/oauth/authorize", + tokenUrl: + env.FIRST_PARTY_GITHUB_TOKEN_URL ?? "https://github.com/login/oauth/access_token", + clientId: env.FIRST_PARTY_GITHUB_CLIENT_ID, + clientSecret: env.FIRST_PARTY_GITHUB_CLIENT_SECRET, + }, + ] + : []), + ...(env.FIRST_PARTY_GOOGLE_CLIENT_ID && env.FIRST_PARTY_GOOGLE_CLIENT_SECRET + ? [ + { + name: "google", + authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth", + tokenUrl: "https://oauth2.googleapis.com/token", + clientId: env.FIRST_PARTY_GOOGLE_CLIENT_ID, + clientSecret: env.FIRST_PARTY_GOOGLE_CLIENT_SECRET, + allowedScopes: GOOGLE_FIRST_PARTY_ALLOWED_SCOPES, + }, + ] + : []), +]; + export const CloudHostConfig: Layer.Layer = Layer.sync(HostConfig, () => ({ // SSRF / private-network egress guard. Config-driven, NOT a test flag: // production leaves `ALLOW_LOCAL_NETWORK` unset so the guard stays ON (`false`); @@ -99,6 +148,7 @@ export const CloudHostConfig: Layer.Layer = Layer.sync(HostConfig, ( // WorkOS Vault is cloud's credential storage implementation detail, not a // user-selectable provider surface. exposeCredentialProviders: false, + firstPartyOAuthClients: cloudFirstPartyOAuthClients(), })); export const CloudCodeExecutorProvider: Layer.Layer = Layer.sync( diff --git a/apps/cloud/src/env-augment.d.ts b/apps/cloud/src/env-augment.d.ts index 5b27d19de0..c9d76063e2 100644 --- a/apps/cloud/src/env-augment.d.ts +++ b/apps/cloud/src/env-augment.d.ts @@ -53,6 +53,21 @@ declare global { // number to drive the backstop. Production leaves it unset. EXECUTION_RATE_LIMIT_PER_HOUR?: string; + // First-party OAuth apps (executor-owned provider registrations). Each + // pair enables one-click connect through `first-party:`; an + // unset pair simply ships no first-party app for that provider. The + // registered callback on the provider side must be + // `${VITE_PUBLIC_SITE_URL}/api/oauth/callback`. + FIRST_PARTY_GITHUB_CLIENT_ID?: string; + FIRST_PARTY_GITHUB_CLIENT_SECRET?: string; + // Endpoint overrides for the GitHub first-party app, so tests/dev can + // point it at an emulated provider and complete the whole flow. Unset in + // production (the real github.com endpoints are the defaults). + FIRST_PARTY_GITHUB_AUTHORIZE_URL?: string; + FIRST_PARTY_GITHUB_TOKEN_URL?: string; + FIRST_PARTY_GOOGLE_CLIENT_ID?: string; + FIRST_PARTY_GOOGLE_CLIENT_SECRET?: string; + // Billing AUTUMN_SECRET_KEY?: string; /** Optional Autumn base-URL override (Autumn emulator in tests/dev). */ diff --git a/apps/marketing/src/pages/privacy.astro b/apps/marketing/src/pages/privacy.astro index b7d0c56b17..143c58ebe9 100644 --- a/apps/marketing/src/pages/privacy.astro +++ b/apps/marketing/src/pages/privacy.astro @@ -53,6 +53,26 @@ import LegalLayout from "../components/LegalLayout.astro"; a call, run code, or execute a workflow.

+

Google Workspace API data

+

+ If you connect a Google Workspace service, Executor uses the permissions you grant to perform the actions you + request through that integration. Depending on the service and permissions you choose, this may include accessing + or modifying Google Calendar events, Google Sheets spreadsheets, or other Google Workspace content. Executor stores + OAuth credentials and connection metadata so the integration can continue working, and processes Google Workspace + content when carrying out your requested tool calls and returning their results to you or the agent you directed to + make the call. +

+

+ We do not use Google Workspace API data for advertising or to train generalized artificial-intelligence or + machine-learning models. We disclose it only as needed to provide the user-requested integration, operate and secure + the Services, comply with law, or as otherwise described in this Policy with the user's consent. +

+

+ Executor's use and transfer of information received from Google Workspace APIs adheres to the + Google API Services User Data Policy, + including its Limited Use requirements. +

+

Website inputs and support communications

If you submit a URL to our website's API detection tool, contact us by email, or otherwise communicate with us, diff --git a/e2e/scenarios/first-party-oauth.test.ts b/e2e/scenarios/first-party-oauth.test.ts new file mode 100644 index 0000000000..cb483882c5 --- /dev/null +++ b/e2e/scenarios/first-party-oauth.test.ts @@ -0,0 +1,252 @@ +// First-party OAuth clients: the cloud host declares executor-owned apps via +// env (`FIRST_PARTY_GITHUB_CLIENT_ID/SECRET`, set by the e2e cloud boot), and +// every org can connect through them with nothing to paste. Three guarantees: +// +// 1. Listing: `oauth.listClients` surfaces `first-party:github` with a +// `first_party` origin and its public client id — no create call ever ran. +// 2. Flow: `oauth.start` through the first-party slug redirects to the +// provider's authorize endpoint carrying the env-configured client id and +// this platform's `/api/oauth/callback` — proof the config-resolved +// identity (not a stored row) drives the flow. The redirect is asserted, +// never followed: github.com is not visited. +// 3. Guardrails: the reserved `first-party:` namespace is rejected by +// createClient, so no org can shadow the host's app with its own row. +import { randomBytes } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + OAuthClientSlug, +} from "@executor-js/sdk/shared"; + +import { scenario } from "../src/scenario"; +import { Api, Target } from "../src/services"; + +const api = composePluginApi([openApiHttpPlugin()] as const); + +const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; + +/** A minimal integration whose OAuth template points at GitHub's endpoints, so + * the first-party `first-party:github` app is the matching client for it. */ +const githubShapedIntegrationSpec = { + spec: { + kind: "blob" as const, + value: JSON.stringify({ + openapi: "3.0.3", + info: { title: "GitHub-shaped API", version: "1.0.0" }, + paths: { + "/user": { + get: { + operationId: "getUser", + tags: ["default"], + responses: { "200": { description: "the caller" } }, + }, + }, + }, + }), + }, + baseUrl: "https://api.github.com", + authenticationTemplate: [ + { + slug: "oauth", + kind: "oauth2" as const, + authorizationUrl: "https://github.com/login/oauth/authorize", + tokenUrl: "https://github.com/login/oauth/access_token", + scopes: ["repo", "read:org"], + }, + ], +} as const; + +const googleShapedIntegrationSpec = (scopes: readonly string[]) => ({ + spec: { + kind: "blob" as const, + value: JSON.stringify({ + openapi: "3.0.3", + info: { title: "Google-shaped API", version: "1.0.0" }, + paths: { + "/resource": { + get: { + operationId: "getResource", + tags: ["default"], + responses: { "200": { description: "a Google resource" } }, + }, + }, + }, + }), + }, + baseUrl: "https://www.googleapis.com", + authenticationTemplate: [ + { + slug: "oauth", + kind: "oauth2" as const, + authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth", + tokenUrl: "https://oauth2.googleapis.com/token", + scopes, + }, + ], +}); + +scenario( + "First-party OAuth · the host-declared GitHub app is listed and drives the authorize redirect", + {}, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + // First-party registrations are a cloud-host capability. This scenario + // intentionally does not apply to self-host, whose operator supplies + // their own OAuth apps through its existing registration flow. + if (target.name !== "cloud") return; + const { client: makeApiClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeApiClient(api, identity); + + // 1. The config-declared app appears in listings with its public id. + const clients = yield* client.oauth.listClients(); + const firstParty = clients.find((c) => String(c.slug) === "first-party:github"); + expect(firstParty, "the env-declared first-party GitHub app is listed").toBeDefined(); + expect(firstParty?.origin.kind).toBe("first_party"); + expect(firstParty?.clientId).toBe("e2e-first-party-github"); + + // 2. A start through the first-party slug builds GitHub's authorize URL + // from the config identity and this platform's served callback. + const integration = IntegrationSlug.make(unique("fpgh")); + yield* client.openapi.addSpec({ + payload: { ...githubShapedIntegrationSpec, slug: integration }, + }); + const started = yield* client.oauth.start({ + payload: { + client: OAuthClientSlug.make("first-party:github"), + clientOwner: "org", + owner: "org", + name: ConnectionName.make("main"), + integration, + template: AuthTemplateSlug.make("oauth"), + }, + }); + expect(started.status, "oauth.start redirects to the provider").toBe("redirect"); + const authorizationUrl = started.status === "redirect" ? started.authorizationUrl : ""; + const authorize = new URL(authorizationUrl); + expect(authorize.origin + authorize.pathname).toBe( + "https://github.com/login/oauth/authorize", + ); + expect(authorize.searchParams.get("client_id")).toBe("e2e-first-party-github"); + expect(authorize.searchParams.get("redirect_uri")).toBe( + new URL("/api/oauth/callback", target.baseUrl).toString(), + ); + + // 3. The reserved namespace cannot be shadowed by a stored row. The + // server rejects with a StorageError, which the HTTP edge scrubs to an + // opaque InternalError — assert the rejection, then prove the listed + // app is still the config-declared one (same public id, same origin). + yield* client.oauth + .createClient({ + payload: { + owner: "org", + slug: OAuthClientSlug.make("first-party:github"), + authorizationUrl: "https://github.com/login/oauth/authorize", + tokenUrl: "https://github.com/login/oauth/access_token", + grant: "authorization_code", + clientId: "impostor", + clientSecret: "impostor-secret", + }, + }) + .pipe(Effect.flip); + const after = yield* client.oauth.listClients(); + const survivors = after.filter((c) => String(c.slug) === "first-party:github"); + expect(survivors, "exactly one first-party:github remains listed").toHaveLength(1); + expect(survivors[0]?.origin.kind).toBe("first_party"); + expect(survivors[0]?.clientId, "the impostor never shadowed the host's app").toBe( + "e2e-first-party-github", + ); + }), + ), +); + +scenario( + "First-party OAuth · Google offers Calendar and Sheets but refuses Gmail scopes", + {}, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + if (target.name !== "cloud") return; + const { client: makeApiClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeApiClient(api, identity); + + const clients = yield* client.oauth.listClients(); + const google = clients.find((candidate) => String(candidate.slug) === "first-party:google"); + expect(google, "the env-declared first-party Google app is listed").toBeDefined(); + expect(google?.origin.kind).toBe("first_party"); + if (google?.origin.kind !== "first_party") return; + expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/calendar"); + expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/spreadsheets"); + expect(google.origin.allowedScopes).not.toContain("https://mail.google.com/"); + expect(google.origin.allowedScopes).not.toContain("https://www.googleapis.com/auth/drive"); + + const calendar = IntegrationSlug.make(unique("google_calendar")); + yield* client.openapi.addSpec({ + payload: { + ...googleShapedIntegrationSpec([ + "openid", + "email", + "profile", + "https://www.googleapis.com/auth/calendar", + ]), + slug: calendar, + }, + }); + const started = yield* client.oauth.start({ + payload: { + client: OAuthClientSlug.make("first-party:google"), + clientOwner: "org", + owner: "org", + name: ConnectionName.make("calendar"), + integration: calendar, + template: AuthTemplateSlug.make("oauth"), + }, + }); + expect(started.status).toBe("redirect"); + const authorizationUrl = started.status === "redirect" ? started.authorizationUrl : ""; + const authorize = new URL(authorizationUrl); + expect(authorize.origin + authorize.pathname).toBe( + "https://accounts.google.com/o/oauth2/v2/auth", + ); + expect(authorize.searchParams.get("client_id")).toBe("e2e-first-party-google"); + expect(authorize.searchParams.get("access_type")).toBe("offline"); + expect(new Set(authorize.searchParams.get("scope")?.split(" ") ?? [])).toEqual( + new Set(["openid", "email", "profile", "https://www.googleapis.com/auth/calendar"]), + ); + + const gmail = IntegrationSlug.make(unique("google_gmail")); + yield* client.openapi.addSpec({ + payload: { + ...googleShapedIntegrationSpec([ + "openid", + "email", + "profile", + "https://mail.google.com/", + ]), + slug: gmail, + }, + }); + const blocked = yield* client.oauth + .start({ + payload: { + client: OAuthClientSlug.make("first-party:google"), + clientOwner: "org", + owner: "org", + name: ConnectionName.make("gmail"), + integration: gmail, + template: AuthTemplateSlug.make("oauth"), + }, + }) + .pipe(Effect.flip); + expect(blocked).toBeDefined(); + }), + ), +); diff --git a/e2e/setup/cloud.boot.ts b/e2e/setup/cloud.boot.ts index 3c072a2f4b..3544afabe0 100644 --- a/e2e/setup/cloud.boot.ts +++ b/e2e/setup/cloud.boot.ts @@ -94,6 +94,21 @@ export const bootCloud = async (options: CloudBootOptions): Promise MCP_SESSION_TIMEOUT_MS: process.env.MCP_SESSION_TIMEOUT_MS, MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS: process.env.MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS, ALLOW_LOCAL_NETWORK: "true", + // A first-party GitHub app for the first-party-oauth scenario: proves the + // env → HostConfig → executor plumbing end to end. The scenario asserts the + // authorize REDIRECT only (client id + callback), never visits github.com. + FIRST_PARTY_GITHUB_CLIENT_ID: "e2e-first-party-github", + FIRST_PARTY_GITHUB_CLIENT_SECRET: "e2e-first-party-github-secret", + // Optional endpoint overrides pass through so a dev instance (`cli up + // cloud`) can point the first-party app at an emulated GitHub and run the + // complete authorize → token dance instead of stopping at github.com. + FIRST_PARTY_GITHUB_AUTHORIZE_URL: process.env.FIRST_PARTY_GITHUB_AUTHORIZE_URL, + FIRST_PARTY_GITHUB_TOKEN_URL: process.env.FIRST_PARTY_GITHUB_TOKEN_URL, + // Fake Google registration for redirect-only first-party coverage. The + // scenario never visits Google or exchanges a code; it proves cloud env, + // scope policy, and authorization URL construction end to end. + FIRST_PARTY_GOOGLE_CLIENT_ID: "e2e-first-party-google", + FIRST_PARTY_GOOGLE_CLIENT_SECRET: "e2e-first-party-google-secret", // Shrink the per-org hourly execution cap (prod default 1000) to a number // the rate-limit-backstop scenario can actually exhaust with real // executions — but see execution-limits.ts: it must stay above every other diff --git a/packages/core/api/src/oauth/api.ts b/packages/core/api/src/oauth/api.ts index dcb71ccd96..6a54d46426 100644 --- a/packages/core/api/src/oauth/api.ts +++ b/packages/core/api/src/oauth/api.ts @@ -121,6 +121,15 @@ const OAuthClientSummaryResponse = Schema.Struct({ kind: Schema.Literal("dynamic_client_registration"), integration: Schema.optional(Schema.NullOr(IntegrationSlug)), }), + /** Host-operated app declared in executor config — every org connects + * through it; nothing to paste. `integrations` ranks it as the default + * for those integrations; `allowedScopes` is the host-enforced scope + * boundary the picker mirrors before offering it. */ + Schema.Struct({ + kind: Schema.Literal("first_party"), + integrations: Schema.optional(Schema.Array(IntegrationSlug)), + allowedScopes: Schema.optional(Schema.Array(Schema.String)), + }), ]), }); diff --git a/packages/core/api/src/server/scoped-executor.ts b/packages/core/api/src/server/scoped-executor.ts index 53f14592f8..6c2b342311 100644 --- a/packages/core/api/src/server/scoped-executor.ts +++ b/packages/core/api/src/server/scoped-executor.ts @@ -41,6 +41,7 @@ import { type AnyPlugin, type Executor, type ExecutorConfig, + type FirstPartyOAuthClientConfig, type StorageFailure, } from "@executor-js/sdk"; import { @@ -97,6 +98,14 @@ export interface HostConfigShape { * Hosts that record product analytics supply it; omitted -> no observation. */ readonly onIntegrationChange?: ExecutorConfig["onIntegrationChange"]; + /** + * Host-operated OAuth apps (`first-party:`), threaded verbatim into + * `createExecutor`. Declared here — not per-request — because the registered + * redirect URI on the provider side is fixed per deployment, and both request + * planes (HTTP API, MCP session DO) must resolve the same apps. Hosts that + * ship none simply omit it. + */ + readonly firstPartyOAuthClients?: readonly FirstPartyOAuthClientConfig[]; } export class HostConfig extends Context.Service()( @@ -281,6 +290,7 @@ export const makeScopedExecutor = < onElicitation: "accept-all", redirectUri, oauthCallbackStateOrgSlug: orgSlug, + firstPartyOAuthClients: config.firstPartyOAuthClients, coreTools: { webBaseUrl, orgSlug, diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 4a962c3ab6..04c742063d 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -108,7 +108,8 @@ import { type MintOAuthConnectionInput, type OAuthScopePolicy, } from "./oauth-service"; -import type { OAuthService } from "./oauth-client"; +import { isFirstPartyOAuthClientSlug, type OAuthService } from "./oauth-client"; +import type { FirstPartyOAuthClientConfig } from "./oauth-client"; import { comparePolicyRow, isValidPattern, @@ -625,6 +626,14 @@ export interface ExecutorConfig`. Users connect through them with + * nothing to paste. Config-resolved — never persisted; secrets stay in host + * env and are never written to a credential provider or returned over any + * read surface. Minted connections and their tokens remain per-owner. + */ + readonly firstPartyOAuthClients?: readonly FirstPartyOAuthClientConfig[]; /** * Enable the built-in `core-tools` plugin which contributes agent-facing * static tools over the v2 surface (integrations / connections / policies). @@ -1753,6 +1762,23 @@ export const createExecutor = b.and(byOwner(owner)(b), b("slug", "=", slug)), }); + // Config-declared first-party apps, keyed by prefixed slug — the refresh + // path's counterpart to the OAuth service's config-first resolution. + const firstPartyOAuthBySlug = new Map( + (config.firstPartyOAuthClients ?? []).map((client) => [`first-party:${client.name}`, client]), + ); + + /** The app identity a refresh runs against, uniformly resolved: a stored + * row's secret comes out of the credential provider by item id; a + * first-party app's comes from host config and never touches a provider. */ + interface RefreshClient { + readonly clientId: string; + readonly clientSecret: string; + readonly tokenUrl: string; + readonly grant: string; + readonly resource: string | null; + } + /** What drove a refresh: the pre-call expiry check (`proactive`), or an * upstream 401 on a token we believed was still valid (`reactive`). */ type RefreshTrigger = "proactive" | "reactive"; @@ -1839,19 +1865,42 @@ export const createExecutor = + slug.startsWith(FIRST_PARTY_OAUTH_CLIENT_PREFIX); + +export const firstPartyOAuthClientSlug = (name: string): OAuthClientSlug => + OAuthClientSlug.make(`${FIRST_PARTY_OAUTH_CLIENT_PREFIX}${name}`); + +/** A first-party OAuth app the HOST declares at composition time — the + * deployment operator's own registered app for a provider. The secret comes + * from host env/config and stays in memory: it is never written to a + * credential provider and never surfaced over any read surface. Minted + * connections (and their tokens) remain per-owner exactly as with BYO apps; + * only the app identity is shared. */ +export interface FirstPartyOAuthClientConfig { + /** Unprefixed name, e.g. `"github"`; addressed as `first-party:github`. */ + readonly name: string; + readonly authorizationUrl: string; + readonly tokenUrl: string; + readonly clientId: string; + /** Literal secret from host env. Empty string for a public/PKCE client. */ + readonly clientSecret: string; + /** Integrations this app is intended for, used by pickers to rank it as the + * exact-match default for those integrations. Endpoint-host matching still + * applies when omitted. */ + readonly integrations?: readonly IntegrationSlug[]; + /** OAuth scopes this deployment permits the app to request. Omit to allow + * every scope declared by a matching integration. When present, OAuth start + * and completion fail unless every requested scope belongs to this set. */ + readonly allowedScopes?: readonly string[]; +} + +/** Whether a first-party app may request an integration's complete OAuth scope + * set. An omitted policy preserves provider-wide clients; an explicit policy + * is fail-closed and requires every requested scope to be listed. */ +export const firstPartyOAuthClientAllowsScopes = ( + config: Pick, + requestedScopes: readonly string[], +): boolean => { + if (config.allowedScopes === undefined) return true; + const allowed = new Set(config.allowedScopes); + return requestedScopes.every((scope) => allowed.has(scope)); +}; + export type CreateOAuthClientInput = OAuthClient & { - readonly origin?: OAuthClientOrigin; + /** Stored-row origins only — `first_party` is config-declared, never created + * through this surface (the service also rejects the slug namespace). */ + readonly origin?: Exclude; readonly originIssuer?: string | null; /** The redirect URI a DCR registration sent as the client's `redirect_uris` * entry. Persisted so reuse can detect a changed callback (strict servers diff --git a/packages/core/sdk/src/oauth-first-party.test.ts b/packages/core/sdk/src/oauth-first-party.test.ts new file mode 100644 index 0000000000..193b3b4fc2 --- /dev/null +++ b/packages/core/sdk/src/oauth-first-party.test.ts @@ -0,0 +1,324 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Predicate } from "effect"; + +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + OAuthClientSlug, + ToolAddress, + ToolName, +} from "./ids"; +import { + firstPartyOAuthClientSlug, + type FirstPartyOAuthClientConfig, + type OAuthStartError, +} from "./oauth-client"; +import { definePlugin } from "./plugin"; +import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; +import { serveOAuthTestServer } from "./testing/oauth-test-server"; + +// First-party OAuth clients: host-operated apps declared in executor config +// (`firstPartyOAuthClients`), addressed as `first-party:`. Resolved from +// config, never storage — these tests prove the whole lifecycle (start → +// complete → execute → refresh) runs off the config-declared identity, that the +// client CRUD surface rejects the reserved namespace, and that listings project +// the app without ever having written its secret to a credential provider. + +const INTEG = IntegrationSlug.make("acme"); +const TEMPLATE = AuthTemplateSlug.make("oauth"); +const FIRST_PARTY = firstPartyOAuthClientSlug("acme"); + +const oauthPlugin = definePlugin(() => ({ + id: "acme" as const, + storage: () => ({}), + resolveTools: () => + Effect.succeed({ + tools: [{ name: ToolName.make("whoami"), description: "whoami" }], + }), + describeAuthMethods: (record) => { + const config = record.config as { readonly scopes?: readonly string[] } | null; + return [ + { + id: "oauth", + label: "OAuth2", + kind: "oauth" as const, + template: String(TEMPLATE), + oauth: { scopes: config?.scopes ?? [] }, + }, + ]; + }, + invokeTool: ({ credential }) => Effect.succeed({ token: credential.value }), + checkHealth: ({ credential }) => + Effect.succeed({ + status: credential.value === null ? "expired" : "healthy", + checkedAt: Date.now(), + }), + extension: (ctx) => ({ + seed: (scopes: readonly string[] = []) => + ctx.core.integrations.register({ + slug: INTEG, + description: "Acme", + config: { scopes }, + }), + }), +}))(); + +const plugins = [memoryCredentialsPlugin(), oauthPlugin] as const; + +const firstPartyClientFor = (server: { + readonly authorizationEndpoint: string; + readonly tokenEndpoint: string; +}): FirstPartyOAuthClientConfig => ({ + name: "acme", + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + clientId: "test-client", + clientSecret: "test-secret", + integrations: [INTEG], +}); + +describe("first-party oauth clients", () => { + it.effect( + "start → complete through a config-declared client mints an executable connection", + () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); + const { executor } = yield* makeTestWorkspaceHarness({ + plugins, + firstPartyOAuthClients: [firstPartyClientFor(server)], + }); + yield* executor.acme.seed(); + + // No createClient call — the app exists purely in config. + const started = yield* executor.oauth.start({ + owner: "org", + client: FIRST_PARTY, + clientOwner: "org", + name: ConnectionName.make("main-account"), + integration: INTEG, + template: TEMPLATE, + }); + expect(started.status).toBe("redirect"); + if (started.status !== "redirect") return; + + const callback = yield* server.completeAuthorizationCodeFlow({ + authorizationUrl: started.authorizationUrl, + }); + const connection = yield* executor.oauth.complete({ + state: started.state, + code: callback.code, + }); + expect(String(connection.address)).toBe("tools.acme.org.mainAccount"); + + const out = (yield* executor.execute( + ToolAddress.make("tools.acme.org.mainAccount.whoami"), + {}, + )) as { token: string }; + expect(out.token).toMatch(/^at_/); + expect(yield* server.acceptsAccessToken(out.token)).toBe(true); + }), + ), + ); + + it.effect("refresh resolves the config-declared client (no oauth_client row exists)", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); + const harness = yield* makeTestWorkspaceHarness({ + plugins, + firstPartyOAuthClients: [firstPartyClientFor(server)], + }); + const { executor, config } = harness; + yield* executor.acme.seed(); + + const started = yield* executor.oauth.start({ + owner: "org", + client: FIRST_PARTY, + clientOwner: "org", + name: ConnectionName.make("main"), + integration: INTEG, + template: TEMPLATE, + }); + if (started.status !== "redirect") return; + const callback = yield* server.completeAuthorizationCodeFlow({ + authorizationUrl: started.authorizationUrl, + }); + yield* executor.oauth.complete({ state: started.state, code: callback.code }); + + const firstToken = (yield* executor.execute( + ToolAddress.make("tools.acme.org.main.whoami"), + {}, + )) as { token: string }; + + // Force expiry so the next resolve refreshes through the config client. + yield* Effect.promise(() => + config.db.updateMany("connection", { + where: (b) => b("name", "=", "main"), + set: { expires_at: Date.now() - 60_000 }, + }), + ); + + const refreshedToken = (yield* executor.execute( + ToolAddress.make("tools.acme.org.main.whoami"), + {}, + )) as { token: string }; + expect(refreshedToken.token).not.toBe(firstToken.token); + expect(yield* server.acceptsAccessToken(refreshedToken.token)).toBe(true); + }), + ), + ); + + it.effect("listClients projects the first-party app ahead of stored rows, secretless", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); + const { executor } = yield* makeTestWorkspaceHarness({ + plugins, + firstPartyOAuthClients: [ + { ...firstPartyClientFor(server), allowedScopes: ["openid", "read"] }, + ], + }); + yield* executor.acme.seed(); + + yield* executor.oauth.createClient({ + owner: "org", + slug: OAuthClientSlug.make("byo-app"), + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + grant: "authorization_code", + clientId: "byo-client", + clientSecret: "byo-secret", + }); + + const clients = yield* executor.oauth.listClients(); + expect(clients.map((c) => String(c.slug))).toEqual(["first-party:acme", "byo-app"]); + const firstParty = clients[0]!; + expect(firstParty.origin).toEqual({ + kind: "first_party", + integrations: [INTEG], + allowedScopes: ["openid", "read"], + }); + expect(firstParty.clientId).toBe("test-client"); + expect("clientSecret" in firstParty).toBe(false); + }), + ), + ); + + it.effect("a scope-limited first-party app rejects an integration outside its policy", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read", "write"] }); + const { executor } = yield* makeTestWorkspaceHarness({ + plugins, + firstPartyOAuthClients: [{ ...firstPartyClientFor(server), allowedScopes: ["read"] }], + }); + yield* executor.acme.seed(["write"]); + + const error = yield* executor.oauth + .start({ + owner: "org", + client: FIRST_PARTY, + clientOwner: "org", + name: ConnectionName.make("blocked"), + integration: INTEG, + template: TEMPLATE, + }) + .pipe(Effect.flip); + + expect(Predicate.isTagged("OAuthStartError")(error)).toBe(true); + const startError = error as OAuthStartError; + expect(startError.message).toContain("not enabled for integration acme"); + }), + ), + ); + + it.effect("createClient and removeClient reject the reserved first-party namespace", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); + const { executor } = yield* makeTestWorkspaceHarness({ + plugins, + firstPartyOAuthClients: [firstPartyClientFor(server)], + }); + + const createError = yield* executor.oauth + .createClient({ + owner: "org", + slug: FIRST_PARTY, + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + grant: "authorization_code", + clientId: "impostor", + clientSecret: "impostor-secret", + }) + .pipe(Effect.flip); + expect(createError.message).toContain("reserved first-party namespace"); + + const removeError = yield* executor.oauth + .removeClient("org", FIRST_PARTY) + .pipe(Effect.flip); + expect(removeError.message).toContain("cannot be removed"); + }), + ), + ); + + it.effect("start with an undeclared first-party slug fails as client-not-found", () => + Effect.scoped( + Effect.gen(function* () { + yield* serveOAuthTestServer({ scopes: ["read"] }); + const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + yield* executor.acme.seed(); + + const error = yield* executor.oauth + .start({ + owner: "org", + client: firstPartyOAuthClientSlug("nope"), + clientOwner: "org", + name: ConnectionName.make("main"), + integration: INTEG, + template: TEMPLATE, + }) + .pipe(Effect.flip); + // `OAuthStartError` carries a typed `message`; the `Predicate.isTagged` + // guard narrows the union so this read is on a typed failure. + expect(Predicate.isTagged("OAuthStartError")(error)).toBe(true); + const startError = error as OAuthStartError; + expect(startError.message).toContain("not found"); + }), + ), + ); + + it.effect("a Personal connection can mint through a first-party app", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); + const { executor } = yield* makeTestWorkspaceHarness({ + plugins, + firstPartyOAuthClients: [firstPartyClientFor(server)], + }); + yield* executor.acme.seed(); + + const started = yield* executor.oauth.start({ + owner: "user", + client: FIRST_PARTY, + clientOwner: "org", + name: ConnectionName.make("mine"), + integration: INTEG, + template: TEMPLATE, + }); + expect(started.status).toBe("redirect"); + if (started.status !== "redirect") return; + const callback = yield* server.completeAuthorizationCodeFlow({ + authorizationUrl: started.authorizationUrl, + }); + const connection = yield* executor.oauth.complete({ + state: started.state, + code: callback.code, + }); + expect(String(connection.address)).toBe("tools.acme.user.mine"); + }), + ), + ); +}); diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index 742cf65adb..a4dd8ebfa0 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -36,8 +36,12 @@ import { OAuthRegisterDynamicError, OAuthSessionNotFoundError, OAuthStartError, + firstPartyOAuthClientAllowsScopes, + firstPartyOAuthClientSlug, + isFirstPartyOAuthClientSlug, type ConnectResult, type CreateOAuthClientInput, + type FirstPartyOAuthClientConfig, type OAuthClientOrigin, type OAuthClientSummary, type OAuthCompleteInput, @@ -174,6 +178,12 @@ export interface OAuthServiceDeps { readonly redirectUri: string | null; /** URL selected organization slug to round-trip through OAuth `state`. */ readonly callbackStateOrgSlug?: string | null; + /** Host-operated apps declared at composition time (`first-party:` + * slugs). Resolved from config, never from storage: `loadClient` intercepts + * the prefix ahead of the DB, `listClients` appends their summaries, and the + * client CRUD surface rejects the namespace. Empty/omitted on hosts that + * ship no first-party apps. */ + readonly firstPartyClients?: readonly FirstPartyOAuthClientConfig[]; } type LooseDb = { @@ -490,9 +500,42 @@ const validateClientEndpoints = ( } }); +/** Resolve a config-declared first-party app to the loaded-client shape the + * flow/refresh paths consume. First-party apps are authorization_code only: + * client_credentials mints machine tokens under the OPERATOR's app identity, + * which must never be shared across tenants. */ +export const loadedFirstPartyClient = ( + config: FirstPartyOAuthClientConfig, +): { + readonly slug: string; + readonly authorizationUrl: string; + readonly tokenUrl: string; + readonly grant: OAuthGrant; + readonly clientId: string; + readonly clientSecret: string; + readonly resource: null; +} => ({ + slug: String(firstPartyOAuthClientSlug(config.name)), + authorizationUrl: config.authorizationUrl, + tokenUrl: config.tokenUrl, + grant: "authorization_code", + clientId: config.clientId, + clientSecret: config.clientSecret, + resource: null, +}); + export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { const httpClientLayer = deps.httpClientLayer ?? FetchHttpClient.layer; const fetch = deps.fetch; + // Config-declared first-party apps, keyed by their prefixed slug. Config is + // the source of truth — no row exists, so every stored-row path (CRUD, GC) + // is bypassed by construction, and rotating a secret is an env change. + const firstPartyBySlug = new Map( + (deps.firstPartyClients ?? []).map((client) => [ + String(firstPartyOAuthClientSlug(client.name)), + client, + ]), + ); // EXPLICIT — no localhost default. `null` means this executor has no OAuth // callback; redirect-requiring flows fail loudly via `requireRedirectUri`. const redirectUri = deps.redirectUri; @@ -597,6 +640,15 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { input: CreateOAuthClientInput, ): Effect.Effect => Effect.gen(function* () { + // The `first-party:` namespace is reserved for config-declared apps — a + // stored row under it would be shadowed by (or worse, impersonate) the + // host's own app. + if (isFirstPartyOAuthClientSlug(String(input.slug))) { + return yield* new StorageError({ + message: `OAuth client slug "${String(input.slug)}" uses the reserved first-party namespace.`, + cause: undefined, + }); + } yield* validateClientEndpoints(input, deps.endpointUrlPolicy); const keys = yield* Effect.try({ try: () => deps.ownedKeys(input.owner), @@ -682,6 +734,15 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { // ----------------------------------------------------------------------- const removeClient = (owner: Owner, slug: OAuthClientSlug): Effect.Effect => Effect.gen(function* () { + // Config-declared apps have no row to remove; removing one is an env + // change on the host, not a storage operation. Fail loudly rather than + // returning a success that changed nothing. + if (isFirstPartyOAuthClientSlug(String(slug))) { + return yield* new StorageError({ + message: `OAuth client "${String(slug)}" is a first-party app declared in host config; it cannot be removed through this surface.`, + cause: undefined, + }); + } yield* deps.fuma .use("oauth_client.delete", (db) => looseDb(db).deleteMany("oauth_client", { @@ -953,8 +1014,28 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { // tenant's org rows + this subject's own user rows, so no explicit filter is // needed. The `client_secret` column is deliberately never projected. // ----------------------------------------------------------------------- - const listClients = (): Effect.Effect => - deps.fuma + const listClients = (): Effect.Effect => { + // First-party apps lead the list: config-resolved, visible to every caller, + // and projected exactly like stored rows — clientId only, never the secret. + // Owner is reported as "org" (the widest visibility the summary shape can + // express); the flow itself ignores owner for first-party slugs. + const firstPartySummaries: readonly OAuthClientSummary[] = [...firstPartyBySlug.values()].map( + (config) => ({ + owner: "org", + slug: firstPartyOAuthClientSlug(config.name), + grant: "authorization_code", + authorizationUrl: config.authorizationUrl, + tokenUrl: config.tokenUrl, + resource: null, + clientId: config.clientId, + origin: { + kind: "first_party", + ...(config.integrations !== undefined ? { integrations: config.integrations } : {}), + ...(config.allowedScopes !== undefined ? { allowedScopes: config.allowedScopes } : {}), + }, + }), + ); + return deps.fuma .use("oauth_client.findMany", (db) => looseDb(db).findMany("oauth_client", {})) .pipe( Effect.flatMap((rows) => @@ -982,7 +1063,9 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { } satisfies OAuthClientSummary); }), ), + Effect.map((stored) => [...firstPartySummaries, ...stored]), ); + }; // ----------------------------------------------------------------------- // Load an oauth_client row by (owner, slug). @@ -990,8 +1073,15 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { const loadClient = ( owner: Owner, slug: OAuthClientSlug, - ): Effect.Effect => - deps.fuma + ): Effect.Effect => { + // First-party apps resolve from config, never storage. Owner is irrelevant: + // the app belongs to the DEPLOYMENT, and visibility policy has nothing to + // narrow — only the minted connection (and its tokens) is owner-scoped. + if (isFirstPartyOAuthClientSlug(String(slug))) { + const config = firstPartyBySlug.get(String(slug)); + return Effect.succeed(config ? loadedFirstPartyClient(config) : null); + } + return deps.fuma .use("oauth_client.findFirst", (db) => looseDb(db).findFirst("oauth_client", { where: (b: any) => b.and(b("owner", "=", owner), b("slug", "=", String(slug))), @@ -1038,6 +1128,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { }); }), ); + }; // ----------------------------------------------------------------------- // start — begin a flow through a client to mint a connection. @@ -1058,7 +1149,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { // cannot be backed by a member's private (user) app. The connection owner // and the app owner are otherwise independent — a Personal connection // through a shared Workspace app is the supported cross-owner case. - if (input.owner === "org" && input.clientOwner === "user") { + // First-party apps are deployment-owned, outside the owner lattice + // entirely, so the rule does not apply to them. + const firstPartyFlow = isFirstPartyOAuthClientSlug(String(input.client)); + if (!firstPartyFlow && input.owner === "org" && input.clientOwner === "user") { return yield* new OAuthStartError({ message: "A Workspace connection must use a Workspace app.", }); @@ -1129,6 +1223,22 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { ) : dedupeScopes(scopePolicy.scopes); + // An explicitly scope-limited first-party app is an authorization + // boundary, not picker decoration. Endpoint matching can associate one + // Google client with every Google API, so enforce the complete requested + // set here before persisting an OAuth session or redirecting the browser. + if (firstPartyFlow) { + const firstParty = firstPartyBySlug.get(String(input.client)); + if ( + firstParty !== undefined && + !firstPartyOAuthClientAllowsScopes(firstParty, requestedScopes) + ) { + return yield* new OAuthStartError({ + message: `The built-in OAuth app is not enabled for integration ${input.integration}.`, + }); + } + } + // client_credentials: exchange immediately and mint the connection. if (client.grant === "client_credentials") { const token = yield* exchangeClientCredentials({ @@ -1310,6 +1420,20 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { restartRequired: true, }); } + if (isFirstPartyOAuthClientSlug(String(session.clientSlug))) { + const firstParty = firstPartyBySlug.get(String(session.clientSlug)); + if ( + firstParty !== undefined && + firstParty.allowedScopes !== undefined && + (session.requestedScopes === null || + !firstPartyOAuthClientAllowsScopes(firstParty, session.requestedScopes)) + ) { + return yield* new OAuthCompleteError({ + message: `The built-in OAuth app is no longer enabled for integration ${session.integration}; restart the flow.`, + restartRequired: true, + }); + } + } // The PKCE verifier is minted by `start` for every authorization_code // session. A null/missing one means a corrupt session row — exchanging diff --git a/packages/core/sdk/src/shared.ts b/packages/core/sdk/src/shared.ts index 615c7c6c57..4a13eac961 100644 --- a/packages/core/sdk/src/shared.ts +++ b/packages/core/sdk/src/shared.ts @@ -144,6 +144,10 @@ export { // OAuth wire contracts (data + tagged errors; the flow impl is server-only). export { + FIRST_PARTY_OAUTH_CLIENT_PREFIX, + firstPartyOAuthClientSlug, + isFirstPartyOAuthClientSlug, + type FirstPartyOAuthClientConfig, type OAuthGrant, type OAuthAuthentication, type OAuthClient, diff --git a/packages/core/sdk/src/test-config.ts b/packages/core/sdk/src/test-config.ts index 9ad7945ff2..4cac77d711 100644 --- a/packages/core/sdk/src/test-config.ts +++ b/packages/core/sdk/src/test-config.ts @@ -123,6 +123,7 @@ export type TestConfigOptions["onIntegrationChange"]; + readonly firstPartyOAuthClients?: ExecutorConfig["firstPartyOAuthClients"]; }; export const makeTestConfig = ( @@ -163,6 +164,7 @@ export const makeTestConfig = > = { "google-cloud-resource-manager": { operation: "cloudresourcemanager.projects.list" }, }; +/** Complete Google OAuth scope set requested by a catalog preset, including + * identity scopes used to label and distinguish connected accounts. */ +export const googleCatalogOAuthScopesForPreset = (presetId: string): readonly string[] => + compactGoogleOAuthScopes([ + ...GOOGLE_IDENTITY_SCOPES, + ...googleOAuthConsentScopesForPreset(presetId), + ]); + const googleCatalogAuthTemplate = (presetId: string) => [ { slug: GOOGLE_OAUTH_SECURITY_SCHEME, kind: "oauth2" as const, authorizationUrl: GOOGLE_OAUTH_AUTHORIZATION_URL, tokenUrl: GOOGLE_OAUTH_TOKEN_URL, - scopes: compactGoogleOAuthScopes([ - ...GOOGLE_IDENTITY_SCOPES, - ...googleOAuthConsentScopesForPreset(presetId), - ]), + scopes: googleCatalogOAuthScopesForPreset(presetId), }, ]; diff --git a/packages/plugins/openapi/src/sdk/presets.ts b/packages/plugins/openapi/src/sdk/presets.ts index dcc2f81b75..6ecf2cd3dd 100644 --- a/packages/plugins/openapi/src/sdk/presets.ts +++ b/packages/plugins/openapi/src/sdk/presets.ts @@ -16,6 +16,21 @@ export interface OpenApiPreset { readonly healthCheck?: HealthCheckSpec; } +/** GitHub OAuth-app flow (classic apps, not GitHub Apps): scopes are requested + * at authorize time, tokens don't expire, and the token endpoint returns + * form-encoded unless the request sends `Accept: application/json` — which the + * SDK's token exchange always does (oauth4webapi sets it). `repo` + `read:org` + * + `user:email` covers the common repo/issue/PR surface without admin grants. */ +export const GITHUB_SUPPORTED_OAUTH_SCOPES = ["repo", "read:org", "user:email"] as const; + +export const GITHUB_OAUTH_TEMPLATE: IntegrationPresetAuthentication = { + slug: "oauth2", + kind: "oauth2", + authorizationUrl: "https://github.com/login/oauth/authorize", + tokenUrl: "https://github.com/login/oauth/access_token", + scopes: GITHUB_SUPPORTED_OAUTH_SCOPES, +}; + export const FIGMA_SUPPORTED_OAUTH_SCOPES = [ "current_user:read", "file_comments:read", @@ -68,6 +83,7 @@ const openApiOnlyPresets: readonly OpenApiPreset[] = [ url: "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json", icon: "https://svgl.app/library/github_dark.svg", featured: true, + authTemplate: [GITHUB_OAUTH_TEMPLATE], }, { id: "vercel", diff --git a/packages/react/src/components/add-account-modal.tsx b/packages/react/src/components/add-account-modal.tsx index a79eafe560..21e6beb490 100644 --- a/packages/react/src/components/add-account-modal.tsx +++ b/packages/react/src/components/add-account-modal.tsx @@ -876,11 +876,18 @@ function OAuthAppRadioRow(props: { {clientDisplayName(String(app.slug))} - {clientHost(app.tokenUrl)} ·{" "} - {app.grant === "client_credentials" ? "app-to-app" : "you'll sign in"} + {app.origin.kind === "first_party" + ? "No setup needed · you'll sign in" + : `${clientHost(app.tokenUrl)} · ${ + app.grant === "client_credentials" ? "app-to-app" : "you'll sign in" + }`} - {showOwnerLabel ? {ownerLabel(app.owner)} : null} + {app.origin.kind === "first_party" ? ( + Built-in + ) : showOwnerLabel ? ( + {ownerLabel(app.owner)} + ) : null} {onManage ? ( @@ -1509,6 +1516,7 @@ function AddAccountModalView(props: AddAccountModalProps) { // unrelated provider's app. tokenUrl: method?.oauth?.tokenUrl ?? oauthFallbackProbe?.tokenUrl, authorizationUrl: method?.oauth?.authorizationUrl ?? oauthFallbackProbe?.authorizationUrl, + scopes: method?.oauth?.scopes, // Recorded intent: a manual app registered from THIS integration's dialog is // a tier-1 match regardless of host. integration, @@ -1595,6 +1603,8 @@ function AddAccountModalView(props: AddAccountModalProps) { const manageHandlersFor = ( appOption: OAuthClientOption, ): { readonly onEdit: () => void; readonly onRemove: () => void } | undefined => { + // First-party apps are host config, not rows: nothing to edit or remove. + if (appOption.origin.kind === "first_party") return undefined; const summary = clientSummaries.find( (c: OAuthClientSummary) => c.owner === appOption.owner && String(c.slug) === String(appOption.slug), diff --git a/packages/react/src/plugins/use-effective-oauth-client.test.ts b/packages/react/src/plugins/use-effective-oauth-client.test.ts index 6bdbf612f8..d240298a7d 100644 --- a/packages/react/src/plugins/use-effective-oauth-client.test.ts +++ b/packages/react/src/plugins/use-effective-oauth-client.test.ts @@ -267,6 +267,88 @@ describe("selectClientsForEndpoints", () => { "spotify-app-2", ]); }); + + it("ranks a first-party app above BYO apps when both match", () => { + const integration = IntegrationSlug.make("github_rest"); + const byo = app("my-github-app", { + authorizationUrl: "https://github.com/login/oauth/authorize", + tokenUrl: "https://github.com/login/oauth/access_token", + }); + const firstParty = app("first-party:github", { + owner: "org", + authorizationUrl: "https://github.com/login/oauth/authorize", + tokenUrl: "https://github.com/login/oauth/access_token", + origin: { kind: "first_party", integrations: [integration] }, + }); + const result = selectClientsForEndpoints([byo, firstParty], { + authorizationUrl: "https://github.com/login/oauth/authorize", + tokenUrl: "https://github.com/login/oauth/access_token", + integration, + }); + expect(result.endpointMatched).toBe(true); + // First-party leads despite being org-owned (user-owned normally sorts first). + expect(result.matched.map((a: OAuthClientOption) => String(a.slug))).toEqual([ + "first-party:github", + "my-github-app", + ]); + }); + + it("hides a scope-limited first-party app from another API on the same provider", () => { + const firstParty = app("first-party:google", { + owner: "org", + authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth", + tokenUrl: "https://oauth2.googleapis.com/token", + origin: { + kind: "first_party", + allowedScopes: ["openid", "email", "https://www.googleapis.com/auth/calendar"], + }, + }); + const result = selectClientsForEndpoints([firstParty], { + authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth", + tokenUrl: "https://oauth2.googleapis.com/token", + integration: IntegrationSlug.make("renamed_gmail"), + scopes: ["openid", "email", "https://mail.google.com/"], + }); + + expect(result.endpointMatched).toBe(false); + expect(result.matched).toEqual([]); + expect(result.nearMatches).toEqual([]); + expect(result.unmatched).toEqual([]); + }); + + it("intent-matches a first-party app to its declared integrations even without endpoints", () => { + const integration = IntegrationSlug.make("github_rest"); + const firstParty = app("first-party:github", { + owner: "org", + authorizationUrl: "https://github.com/login/oauth/authorize", + tokenUrl: "https://github.com/login/oauth/access_token", + origin: { kind: "first_party", integrations: [integration] }, + }); + const result = selectClientsForEndpoints([firstParty], { + requireEndpointMatch: true, + integration, + }); + expect(result.endpointMatched).toBe(true); + expect(result.matched.map((a: OAuthClientOption) => String(a.slug))).toEqual([ + "first-party:github", + ]); + }); + + it("does not surface a first-party app for an unrelated integration", () => { + const firstParty = app("first-party:github", { + owner: "org", + authorizationUrl: "https://github.com/login/oauth/authorize", + tokenUrl: "https://github.com/login/oauth/access_token", + origin: { kind: "first_party", integrations: [IntegrationSlug.make("github_rest")] }, + }); + const result = selectClientsForEndpoints([firstParty], { + authorizationUrl: "https://accounts.spotify.com/authorize", + tokenUrl: "https://accounts.spotify.com/api/token", + integration: IntegrationSlug.make("spotify"), + }); + expect(result.endpointMatched).toBe(false); + expect(result.matched).toEqual([]); + }); }); describe("selectDcrClientsForIntegration", () => { diff --git a/packages/react/src/plugins/use-effective-oauth-client.tsx b/packages/react/src/plugins/use-effective-oauth-client.tsx index 0c0467254b..8c8fae9013 100644 --- a/packages/react/src/plugins/use-effective-oauth-client.tsx +++ b/packages/react/src/plugins/use-effective-oauth-client.tsx @@ -47,6 +47,25 @@ export interface OAuthClientOption { export const isDcrClient = (app: OAuthClientOption): boolean => app.origin.kind === "dynamic_client_registration"; +/** True for host-operated first-party apps (config-declared, `first-party:` + * slugs). They rank ABOVE user/workspace apps when they match an integration: + * the one-click "nothing to paste" path is the default, BYO the escape hatch. */ +export const isFirstPartyClient = (app: OAuthClientOption): boolean => + app.origin.kind === "first_party"; + +/** Mirror the host's first-party scope boundary in the picker. The server is + * authoritative; this prevents offering a built-in app for a flow it will + * reject. An explicit policy with unknown scopes fails closed. */ +const firstPartyClientAllowsScopes = ( + app: OAuthClientOption, + requestedScopes: readonly string[] | undefined, +): boolean => { + if (app.origin.kind !== "first_party" || app.origin.allowedScopes === undefined) return true; + if (requestedScopes === undefined) return false; + const allowed = new Set(app.origin.allowedScopes); + return requestedScopes.every((scope) => allowed.has(scope)); +}; + const hostOf = (url: string): string | undefined => { // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL() throws on invalid input; treat as "no host" try { @@ -115,12 +134,14 @@ const EMPTY_CLIENTS: readonly OAuthClientOption[] = []; const hostEq = (a: string | undefined, b: string | undefined): boolean => a !== undefined && b !== undefined && a === b; -/** Sort apps user-owned first (so the user's own apps surface before shared - * workspace apps). */ +/** Sort first-party apps first (the one-click default), then user-owned before + * shared workspace apps. */ const sortUserFirst = (apps: readonly OAuthClientOption[]): readonly OAuthClientOption[] => - [...apps].sort((a: OAuthClientOption, b: OAuthClientOption) => - a.owner === b.owner ? 0 : a.owner === "user" ? -1 : 1, - ); + [...apps].sort((a: OAuthClientOption, b: OAuthClientOption) => { + const aFirstParty = isFirstPartyClient(a); + if (aFirstParty !== isFirstPartyClient(b)) return aFirstParty ? -1 : 1; + return a.owner === b.owner ? 0 : a.owner === "user" ? -1 : 1; + }); /** * Pure matcher (no React/atoms) — split owner-visible apps into three honest @@ -154,6 +175,9 @@ export function selectClientsForEndpoints( /** The integration whose picker this is. A manual app stamped with this * integration (recorded intent) is a tier-1 match regardless of host. */ readonly integration?: IntegrationSlug; + /** Complete scope set declared by the selected OAuth auth method. Used to + * hide scope-limited first-party apps that the host will reject. */ + readonly scopes?: readonly string[]; /** When set, an integration that targets a SPECIFIC server (MCP, whose * endpoints are discovered at connect) must match by endpoint — absent * endpoints mean NO match (show the register CTA), never "every app @@ -167,14 +191,24 @@ export function selectClientsForEndpoints( readonly endpointMatched: boolean; } { // DCR clients are plumbing, never picker options. - const manual = all.filter((app) => !isDcrClient(app)); + const manual = all.filter( + (app) => !isDcrClient(app) && firstPartyClientAllowsScopes(app, endpoints.scopes), + ); const intent = endpoints.integration; - const matchesIntent = (app: OAuthClientOption): boolean => - intent != null && - app.origin.kind === "manual" && - app.origin.integration != null && - app.origin.integration === intent; + const matchesIntent = (app: OAuthClientOption): boolean => { + if (intent == null) return false; + // A first-party app declaring this integration is intent-matched the same + // way a BYO app registered from this dialog is. + if (app.origin.kind === "first_party") { + return (app.origin.integrations ?? []).includes(intent); + } + return ( + app.origin.kind === "manual" && + app.origin.integration != null && + app.origin.integration === intent + ); + }; const wantedTokenHost = endpoints.tokenUrl ? hostOf(endpoints.tokenUrl) : undefined; const wantedAuthorizationHost = endpoints.authorizationUrl @@ -246,6 +280,7 @@ export function useOAuthClientsForIntegration(opts: { readonly tokenUrl?: string; readonly authorizationUrl?: string; readonly integration?: IntegrationSlug; + readonly scopes?: readonly string[]; readonly requireEndpointMatch?: boolean; }): UseOAuthClientsResult { // Read the optimistic list so a just-registered/edited/removed app paints @@ -267,9 +302,17 @@ export function useOAuthClientsForIntegration(opts: { tokenUrl: opts.tokenUrl, authorizationUrl: opts.authorizationUrl, integration: opts.integration, + scopes: opts.scopes, requireEndpointMatch: opts.requireEndpointMatch, }), - [all, opts.tokenUrl, opts.authorizationUrl, opts.integration, opts.requireEndpointMatch], + [ + all, + opts.tokenUrl, + opts.authorizationUrl, + opts.integration, + opts.scopes, + opts.requireEndpointMatch, + ], ); if (!loaded) { @@ -377,9 +420,12 @@ export function optimisticDcrClientSlug(issuerOrEndpoint: string): OAuthClientSl return OAuthClientSlug.make(`dcr-${base || "authorization-server"}`); } -/** Humanize a client slug for display ("spotify-prod" → "Spotify prod"). */ +/** Humanize a client slug for display ("spotify-prod" → "Spotify prod"). + * First-party slugs drop their namespace prefix ("first-party:github" → + * "Github") — the row's badge already says it's the built-in app. */ export function clientDisplayName(slug: string): string { - const text = slug.replace(/[-_]/g, " ").trim(); + const bare = slug.startsWith("first-party:") ? slug.slice("first-party:".length) : slug; + const text = bare.replace(/[-_]/g, " ").trim(); return text.length > 0 ? text.charAt(0).toUpperCase() + text.slice(1) : slug; } From 75a3c4536ecdc062c44dff2f4258e3173956e728 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:42:26 -0600 Subject: [PATCH 020/133] Keep the standalone MCP SSE listener open after undelivered replay (#1629) --- .../mcp/agent-session-durable-object.test.ts | 14 ++++- .../src/mcp/agent-session-durable-object.ts | 56 +++++++++++++------ 2 files changed, 52 insertions(+), 18 deletions(-) diff --git a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts index 5fec25ea0a..f0d579217b 100644 --- a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts +++ b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts @@ -418,9 +418,21 @@ describe("McpAgentSessionDOBase session serving", () => { }), ), ); - const standaloneReplayBody = await standaloneReplay.text(); + // The standalone listener replays undelivered responses as its opening + // frames and then STAYS OPEN (a close-after-replay here put every active + // client into a permanent reconnect loop), so read incrementally instead + // of draining to EOF. + const standaloneReader = standaloneReplay.body?.getReader(); + const decoder = new TextDecoder(); + let standaloneReplayBody = ""; + while (!standaloneReplayBody.includes("slow result")) { + const next = await standaloneReader?.read(); + if (!next || next.done) break; + standaloneReplayBody += decoder.decode(next.value, { stream: true }); + } expect(standaloneReplayBody).toContain("slow result"); expect(standaloneReplayBody).toContain("event: message"); + await standaloneReader?.cancel("test complete"); await state.flushWaitUntil(); }); diff --git a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts index a27213eaf4..8e949fd90e 100644 --- a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts +++ b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts @@ -1148,14 +1148,36 @@ export abstract class McpAgentSessionDOBase< }, onClose: (reason) => { this.activeLegacyStreamCount = Math.max(0, this.activeLegacyStreamCount - 1); - if (reason === "complete" && options.acknowledge && options.acknowledge.length > 0) { + // "complete": the source body drained to its natural end. "rotate": + // the client stayed attached for the whole max-age window, so the + // initial replay frames were drained long before. Both count as + // delivered. "cancel"/"error" leave the streams replayable for the + // client's reconnect GET. + if ( + (reason === "complete" || reason === "rotate") && + options.acknowledge && + options.acknowledge.length > 0 + ) { this.ctx.waitUntil(this.eventStore.acknowledgeUndeliveredStreams(options.acknowledge)); } }, }); - private async replayUndeliveredOnStandaloneGet(request: Request): Promise { - if (request.method !== "GET" || request.headers.has("last-event-id")) return null; + /** + * Collect every undelivered stream's events as one SSE frame block, for + * prepending onto the standalone GET stream. Returning a FINITE response + * here instead (the pre-2026-08-17 behavior) was catastrophic: the client's + * standalone listener closed the moment the replay flushed, and because + * every POST marks its stream undelivered until acknowledged, an active + * session ALWAYS had something to replay — so every listener GET became a + * ~3s reconnect short-poll. Fleet-wide, that reconnect storm multiplied + * /mcp volume ~15x, drove the worker into memory-limit kills, and (by + * recycling isolates) pushed homepage TTFB from ~15ms to ~3s. + */ + private async collectUndeliveredReplay(): Promise<{ + readonly frame: Uint8Array; + readonly streamIds: readonly string[]; + } | null> { const frames: Uint8Array[] = []; const streamIds = await this.eventStore.replayUndeliveredStreams({ send: (eventId, message) => { @@ -1164,18 +1186,7 @@ export abstract class McpAgentSessionDOBase< }, }); if (frames.length === 0) return null; - return this.trackedLegacyResponse( - new Response(combineFrames(frames), { - headers: { - "content-type": "text/event-stream", - "cache-control": "no-cache, no-transform", - connection: "keep-alive", - "x-accel-buffering": "no", - "mcp-session-id": this.sessionId, - }, - }), - { acknowledge: streamIds }, - ); + return { frame: new Uint8Array(combineFrames(frames)), streamIds }; } private async serializedTransportRequest(run: () => Promise): Promise { @@ -1211,8 +1222,19 @@ export abstract class McpAgentSessionDOBase< // through every workerd/Vite streaming hop, so explicitly retire a // stale standalone mapping before opening its replacement. transport.closeStandaloneSSEStream(); - const replay = await this.replayUndeliveredOnStandaloneGet(request); - if (replay) return replay; + const replay = await this.collectUndeliveredReplay(); + if (replay) { + // Prepend the replay onto the transport's own long-lived + // standalone stream — the stream MUST stay open afterwards (see + // collectUndeliveredReplay). Acknowledgement moves to stream end: + // "complete"/"rotate" imply the client stayed attached long + // enough to have drained the prepended frames. + const response = await transport.handleRequest(request); + return this.trackedLegacyResponse(response, { + initialFrame: replay.frame, + acknowledge: replay.streamIds, + }); + } } } const toolCallIds = legacyToolCallRequestIds(parsedBody); From df2eeaa2eac756119ef25309c503c0ed68a4539c Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:27:23 -0600 Subject: [PATCH 021/133] Add Gmail modify support (#1630) --- apps/cloud/src/engine/execution-stack.ts | 9 +-- apps/marketing/src/pages/index.astro | 9 +-- apps/marketing/src/pages/privacy.astro | 18 +++-- e2e/scenarios/first-party-oauth.test.ts | 40 +++++++++-- .../src/providers/google/discovery.test.ts | 72 +++++++++++++++++++ .../openapi/src/providers/google/discovery.ts | 11 ++- .../openapi/src/providers/google/presets.ts | 2 +- .../providers/google/spec-format-adapter.ts | 5 +- packages/plugins/openapi/src/sdk/plugin.ts | 15 +++- .../plugins/openapi/src/sdk/spec-format.ts | 3 + .../src/components/add-account-modal.tsx | 19 +++++ 11 files changed, 177 insertions(+), 26 deletions(-) diff --git a/apps/cloud/src/engine/execution-stack.ts b/apps/cloud/src/engine/execution-stack.ts index ecbdee7142..9846ca84cf 100644 --- a/apps/cloud/src/engine/execution-stack.ts +++ b/apps/cloud/src/engine/execution-stack.ts @@ -89,13 +89,14 @@ export const CloudPluginsProvider: Layer.Layer = Layer.succeed( */ export const CLOUD_MOUNT_PREFIX = "/api" as const; -// Initial Google launch boundary. Calendar + Sheets are sensitive scopes but -// not restricted Workspace scopes; Gmail and account-wide Drive remain absent -// until their separate verification/security work is complete. The same scope -// source builds the catalog auth templates, preventing config drift. +// Initial Google launch boundary. Gmail uses gmail.modify for read, send, and +// trash operations while immediate permanent deletion remains absent until the +// broader mail.google.com scope is approved. Account-wide Drive remains absent. +// The same scope source builds the catalog auth templates, preventing drift. const GOOGLE_FIRST_PARTY_ALLOWED_SCOPES: readonly string[] = [ ...new Set([ ...googleCatalogOAuthScopesForPreset("google-calendar"), + ...googleCatalogOAuthScopesForPreset("google-gmail"), ...googleCatalogOAuthScopesForPreset("google-sheets"), ]), ]; diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index cd0940b879..d147d171c5 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -133,14 +133,15 @@ Source (and the place to start if something breaks): https://github.com/UsefulSo

- Connect any agent to everything. + Executor connects any agent to everything.

- Executor is an MCP gateway. Anything that speaks MCP, like Claude - Code, Cursor, or Codex, points at one endpoint and reaches every - tool you connect. + Executor is an integration platform and MCP gateway for AI agents. + It lets you securely connect services such as Google Workspace, + GitHub, and Slack, then read data and run the actions you request + through one endpoint.

diff --git a/apps/marketing/src/pages/privacy.astro b/apps/marketing/src/pages/privacy.astro index 143c58ebe9..b1a4818eb2 100644 --- a/apps/marketing/src/pages/privacy.astro +++ b/apps/marketing/src/pages/privacy.astro @@ -57,10 +57,15 @@ import LegalLayout from "../components/LegalLayout.astro";

If you connect a Google Workspace service, Executor uses the permissions you grant to perform the actions you request through that integration. Depending on the service and permissions you choose, this may include accessing - or modifying Google Calendar events, Google Sheets spreadsheets, or other Google Workspace content. Executor stores - OAuth credentials and connection metadata so the integration can continue working, and processes Google Workspace - content when carrying out your requested tool calls and returning their results to you or the agent you directed to - make the call. + or modifying Google Calendar events, Google Sheets spreadsheets, Gmail messages, drafts, threads, attachments, + labels, or other Google Workspace content. For Gmail, this can include reading and searching email, composing and + sending messages, and organizing or moving messages to Trash when you request those actions. +

+

+ Executor stores OAuth credentials and connection metadata so the integration can continue working. It processes + Google Workspace content only while carrying out your requested tool calls and returning their results to you or + the agent you directed to make the call. That content can appear in the resulting agent session or in another + service when you explicitly direct Executor to send it there.

We do not use Google Workspace API data for advertising or to train generalized artificial-intelligence or @@ -72,6 +77,11 @@ import LegalLayout from "../components/LegalLayout.astro"; Google API Services User Data Policy, including its Limited Use requirements.

+

+ You can stop Executor's access by deleting the Google connection in Executor or revoking Executor from your + Google Account permissions. To request deletion of your Executor account, stored OAuth credentials, connection + metadata, or other personal information, email rhys@executor.sh. +

Website inputs and support communications

diff --git a/e2e/scenarios/first-party-oauth.test.ts b/e2e/scenarios/first-party-oauth.test.ts index cb483882c5..39fcdc2664 100644 --- a/e2e/scenarios/first-party-oauth.test.ts +++ b/e2e/scenarios/first-party-oauth.test.ts @@ -168,7 +168,7 @@ scenario( ); scenario( - "First-party OAuth · Google offers Calendar and Sheets but refuses Gmail scopes", + "First-party OAuth · Google offers Gmail modify but refuses full Gmail and Drive scopes", {}, Effect.scoped( Effect.gen(function* () { @@ -184,6 +184,7 @@ scenario( expect(google?.origin.kind).toBe("first_party"); if (google?.origin.kind !== "first_party") return; expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/calendar"); + expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/gmail.modify"); expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/spreadsheets"); expect(google.origin.allowedScopes).not.toContain("https://mail.google.com/"); expect(google.origin.allowedScopes).not.toContain("https://www.googleapis.com/auth/drive"); @@ -229,19 +230,50 @@ scenario( "openid", "email", "profile", - "https://mail.google.com/", + "https://www.googleapis.com/auth/gmail.modify", ]), slug: gmail, }, }); + const gmailStarted = yield* client.oauth.start({ + payload: { + client: OAuthClientSlug.make("first-party:google"), + clientOwner: "org", + owner: "org", + name: ConnectionName.make("gmail"), + integration: gmail, + template: AuthTemplateSlug.make("oauth"), + }, + }); + expect(gmailStarted.status).toBe("redirect"); + const gmailAuthorizationUrl = + gmailStarted.status === "redirect" ? gmailStarted.authorizationUrl : ""; + expect( + new Set(new URL(gmailAuthorizationUrl).searchParams.get("scope")?.split(" ") ?? []), + ).toEqual( + new Set(["openid", "email", "profile", "https://www.googleapis.com/auth/gmail.modify"]), + ); + + const fullGmail = IntegrationSlug.make(unique("google_gmail_full")); + yield* client.openapi.addSpec({ + payload: { + ...googleShapedIntegrationSpec([ + "openid", + "email", + "profile", + "https://mail.google.com/", + ]), + slug: fullGmail, + }, + }); const blocked = yield* client.oauth .start({ payload: { client: OAuthClientSlug.make("first-party:google"), clientOwner: "org", owner: "org", - name: ConnectionName.make("gmail"), - integration: gmail, + name: ConnectionName.make("gmail-full"), + integration: fullGmail, template: AuthTemplateSlug.make("oauth"), }, }) diff --git a/packages/plugins/openapi/src/providers/google/discovery.test.ts b/packages/plugins/openapi/src/providers/google/discovery.test.ts index 9579685851..5f4f2829a9 100644 --- a/packages/plugins/openapi/src/providers/google/discovery.test.ts +++ b/packages/plugins/openapi/src/providers/google/discovery.test.ts @@ -959,6 +959,78 @@ it.effect("bundles Google Discovery documents into one Google OpenAPI integratio }), ); +it.effect("filters Gmail operations to the explicitly selected consent scope", () => + Effect.gen(function* () { + const modifyScope = "https://www.googleapis.com/auth/gmail.modify"; + const fullScope = "https://mail.google.com/"; + const result = yield* convertGoogleDiscoveryBundleToOpenApi({ + consentScopes: [modifyScope], + documents: [ + { + discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest", + // @effect-diagnostics-next-line preferSchemaOverJson:off + documentText: JSON.stringify({ + name: "gmail", + version: "v1", + title: "Gmail API", + rootUrl: "https://gmail.googleapis.com/", + servicePath: "", + auth: { + oauth2: { + scopes: { + [modifyScope]: { description: "Read and modify Gmail" }, + [fullScope]: { description: "Full Gmail access" }, + }, + }, + }, + resources: { + users: { + resources: { + messages: { + methods: { + list: { + id: "gmail.users.messages.list", + httpMethod: "GET", + path: "gmail/v1/users/{userId}/messages", + scopes: [modifyScope, fullScope], + parameters: { + userId: { location: "path", required: true, type: "string" }, + }, + }, + delete: { + id: "gmail.users.messages.delete", + httpMethod: "DELETE", + path: "gmail/v1/users/{userId}/messages/{id}", + scopes: [fullScope], + parameters: { + userId: { location: "path", required: true, type: "string" }, + id: { location: "path", required: true, type: "string" }, + }, + }, + }, + }, + }, + }, + }, + schemas: {}, + }), + }, + ], + }); + + const spec = decodeConvertedSpec(result.specText); + const operationIds = Object.values(spec.paths).flatMap((path) => + Object.values(path).map((operation) => operation.operationId), + ); + expect(operationIds).toContain("gmail.users.messages.list"); + expect(operationIds).not.toContain("gmail.users.messages.delete"); + const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); + expect(oauthTemplate?.kind === "oauth2" ? oauthTemplate.scopes : undefined).toEqual([ + modifyScope, + ]); + }), +); + // --------------------------------------------------------------------------- // The merged bundle scope set is the COMPACTED + FILTERED union: sub-scopes // collapse under their broad parent (`gmail.*` → `mail.google.com/`, diff --git a/packages/plugins/openapi/src/providers/google/discovery.ts b/packages/plugins/openapi/src/providers/google/discovery.ts index 96f2abeefb..00c798b035 100644 --- a/packages/plugins/openapi/src/providers/google/discovery.ts +++ b/packages/plugins/openapi/src/providers/google/discovery.ts @@ -831,9 +831,6 @@ const GOOGLE_PHOTOS_APPENDONLY_SCOPE = "https://www.googleapis.com/auth/photosli const GOOGLE_PHOTOS_UPLOAD_TOOL_PATH = "photoslibrary.mediaItems.upload"; const GOOGLE_PHOTOS_UPLOAD_PATH = "/v1/uploads"; -const isGooglePhotosService = (service: string): boolean => - service === GOOGLE_PHOTOS_LIBRARY_SERVICE || service === GOOGLE_PHOTOS_PICKER_SERVICE; - const discoveryScopesForService = ( service: string, document: DiscoveryDocument, @@ -1124,10 +1121,10 @@ export const convertGoogleDiscoveryBundleToOpenApi = Effect.fn( const schemaPrefix = schemaComponentPart(`${info.service}_${info.version}`); const schemaNameForRef = (name: string) => `${schemaPrefix}_${schemaComponentPart(name)}`; const scopeDescriptions = discoveryScopesForService(info.service, info.document); - const filterPhotosScopes = consentScopeSet !== null && isGooglePhotosService(info.service); + const filterConsentScopes = consentScopeSet !== null; for (const [scope, description] of Object.entries(scopeDescriptions)) { - if (filterPhotosScopes && !consentScopeSet.has(scope)) continue; + if (filterConsentScopes && !consentScopeSet.has(scope)) continue; rawScopes[scope] ??= description; } @@ -1140,10 +1137,10 @@ export const convertGoogleDiscoveryBundleToOpenApi = Effect.fn( const rawPathTemplate = Option.getOrUndefined(method.path); if (!methodId || !rawPathTemplate || !method.httpMethod) continue; const methodScopes = discoveryMethodScopesForService(info.service, method); - const oauthScopes = filterPhotosScopes + const oauthScopes = filterConsentScopes ? methodScopes.filter((scope) => consentScopeSet.has(scope)) : methodScopes; - if (filterPhotosScopes && methodScopes.length > 0 && oauthScopes.length === 0) continue; + if (filterConsentScopes && methodScopes.length > 0 && oauthScopes.length === 0) continue; const toolPath = methodId; const wirePath = rawPathTemplate.startsWith("/") ? rawPathTemplate : `/${rawPathTemplate}`; diff --git a/packages/plugins/openapi/src/providers/google/presets.ts b/packages/plugins/openapi/src/providers/google/presets.ts index 33fbab3830..5c2131971b 100644 --- a/packages/plugins/openapi/src/providers/google/presets.ts +++ b/packages/plugins/openapi/src/providers/google/presets.ts @@ -250,7 +250,7 @@ export const googlePhotosOpenApiPresets: readonly GoogleOpenApiPreset[] = export const googleOAuthConsentScopes: Readonly> = { "google-calendar": ["https://www.googleapis.com/auth/calendar"], - "google-gmail": ["https://mail.google.com/"], + "google-gmail": ["https://www.googleapis.com/auth/gmail.modify"], "google-sheets": ["https://www.googleapis.com/auth/spreadsheets"], "google-drive": ["https://www.googleapis.com/auth/drive"], "google-docs": ["https://www.googleapis.com/auth/documents"], diff --git a/packages/plugins/openapi/src/providers/google/spec-format-adapter.ts b/packages/plugins/openapi/src/providers/google/spec-format-adapter.ts index 05872126ab..f7a5d14186 100644 --- a/packages/plugins/openapi/src/providers/google/spec-format-adapter.ts +++ b/packages/plugins/openapi/src/providers/google/spec-format-adapter.ts @@ -61,7 +61,10 @@ export const googleDiscoveryAdapter: SpecFormatAdapter = { ), { concurrency: 4 }, ); - const conversion = yield* convertGoogleDiscoveryBundleToOpenApi({ documents }); + const conversion = yield* convertGoogleDiscoveryBundleToOpenApi({ + documents, + ...(input.consentScopes ? { consentScopes: input.consentScopes } : {}), + }); const document = documents.length === 1 ? yield* parseJson(documents[0]!.documentText) diff --git a/packages/plugins/openapi/src/sdk/plugin.ts b/packages/plugins/openapi/src/sdk/plugin.ts index 234b163f3c..4b5c6a6f07 100644 --- a/packages/plugins/openapi/src/sdk/plugin.ts +++ b/packages/plugins/openapi/src/sdk/plugin.ts @@ -646,7 +646,13 @@ export const openApiPlugin = definePlugin< const resolveSpecForInput = ( config: Pick< OpenApiSpecConfig, - "spec" | "specFormat" | "specOverrides" | "headers" | "queryParams" | "baseUrl" + | "spec" + | "specFormat" + | "specOverrides" + | "headers" + | "queryParams" + | "baseUrl" + | "authenticationTemplate" >, httpClientLayer: Layer.Layer, ): Effect.Effect< @@ -670,6 +676,13 @@ export const openApiPlugin = definePlugin< ...(config.headers ? { headers: config.headers } : {}), ...(config.queryParams ? { queryParams: config.queryParams } : {}), }, + ...(config.authenticationTemplate + ? { + consentScopes: config.authenticationTemplate.flatMap((template) => + "kind" in template && template.kind === "oauth2" ? template.scopes : [], + ), + } + : {}), httpClientLayer, }); return yield* applyOverridesToResolvedSpec(resolved, config.specOverrides); diff --git a/packages/plugins/openapi/src/sdk/spec-format.ts b/packages/plugins/openapi/src/sdk/spec-format.ts index 1d45244a49..c42c8c2869 100644 --- a/packages/plugins/openapi/src/sdk/spec-format.ts +++ b/packages/plugins/openapi/src/sdk/spec-format.ts @@ -15,6 +15,9 @@ export interface SpecFetchCredentials { export interface SpecFetchInput { readonly urls: readonly string[]; readonly credentials?: SpecFetchCredentials; + /** Explicit OAuth scopes selected by the caller. Format adapters may use + * these to omit operations that the resulting connection cannot invoke. */ + readonly consentScopes?: readonly string[]; readonly httpClientLayer: Layer.Layer; } diff --git a/packages/react/src/components/add-account-modal.tsx b/packages/react/src/components/add-account-modal.tsx index 21e6beb490..2db411754c 100644 --- a/packages/react/src/components/add-account-modal.tsx +++ b/packages/react/src/components/add-account-modal.tsx @@ -1565,6 +1565,9 @@ function AddAccountModalView(props: AddAccountModalProps) { [...oauthApps, ...oauthNearApps, ...oauthOtherApps].find( (c: OAuthClientOption) => String(c.slug) === selectedApp, ) ?? null; + const isBuiltInGoogleClient = + chosenClient?.origin.kind === "first_party" && + String(chosenClient.slug) === "first-party:google"; const oauthBusy = ccBusy || oauthPopup.busy; const cimdConnecting = cimdBusy || oauthPopup.busy; const dcrConnecting = dcrBusy || oauthPopup.busy; @@ -2880,6 +2883,22 @@ function AddAccountModalView(props: AddAccountModalProps) { />

)} + + {isBuiltInGoogleClient && showPlaceStep ? ( +

+ Executor uses the Google data you authorize only to perform the actions you + request. Read the{` `} + + privacy policy + + . +

+ ) : null}
{continueError ? ( From c443219f92a4ddd43823e24c781857a41db351a7 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:46:26 -0600 Subject: [PATCH 022/133] Hold MCP listener streams open: keepalives + drained Last-Event-ID routing (#1632) * Send keepalive comments on rotated MCP SSE streams * Route drained Last-Event-ID reconnects to the standalone listener * Resume reconnects for streams with in-flight requests --- .../src/mcp/agent-session-durable-object.ts | 65 +++++++++++++++---- .../cloudflare/src/mcp/do-event-store.ts | 29 +++++++++ .../src/mcp/sse-response-rotation.ts | 24 ++++++- 3 files changed, 104 insertions(+), 14 deletions(-) diff --git a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts index 8e949fd90e..63b84b5c5c 100644 --- a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts +++ b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts @@ -1115,6 +1115,27 @@ export abstract class McpAgentSessionDOBase< return typeof streamId === "string" ? streamId : null; } + /** + * Whether the stream named by `lastEventId` still has a request awaiting + * its response. SAFETY: reads the transport's pinned + * `_requestToStreamMapping` for the same reason `requestStreamId` and + * `supersedeReplayStream` do — the SDK exposes no public pending-request + * probe. + */ + private async lastEventIdStreamHasPendingRequest( + transport: WebStandardStreamableHTTPServerTransport, + lastEventId: string, + ): Promise { + const streamId = await this.eventStore.getStreamIdForEventId(lastEventId); + if (!streamId) return false; + const mapping: unknown = Reflect.get(transport, "_requestToStreamMapping"); + if (!(mapping instanceof Map)) return false; + for (const mappedStreamId of mapping.values()) { + if (mappedStreamId === streamId) return true; + } + return false; + } + private async supersedeReplayStream( transport: WebStandardStreamableHTTPServerTransport, lastEventId: string, @@ -1215,26 +1236,44 @@ export abstract class McpAgentSessionDOBase< } if (request.method === "GET") { const lastEventId = request.headers.get("last-event-id"); - if (lastEventId) { + // A reconnect id is only "drained" when the stream has no stored + // events after it AND no request still in flight on it. An in-flight + // tool call has produced no events past the priming frame yet, but + // its POST stream must still be resumed so the eventual result lands + // on this connection (the severed-POST recovery contract). + const resumable = + lastEventId !== null && + ((await this.eventStore.hasEventsAfter(lastEventId)) || + (await this.lastEventIdStreamHasPendingRequest(transport, lastEventId))); + if (lastEventId && resumable) { await this.supersedeReplayStream(transport, lastEventId); } else { + // Standalone listener path — taken for a bare GET AND for a GET + // whose Last-Event-ID stream is fully drained. EventSource clients + // echo their last id on every reconnect, so a drained id must not + // enter the transport's resume (it closes a completed stream's + // resume immediately, turning the echo into a reconnect loop). + let serveRequest = request; + if (lastEventId) { + const headers = new Headers(request.headers); + headers.delete("last-event-id"); + serveRequest = new Request(request, { headers }); + } // Latest-listener-wins. Client cancellation is not reliably relayed // through every workerd/Vite streaming hop, so explicitly retire a // stale standalone mapping before opening its replacement. transport.closeStandaloneSSEStream(); const replay = await this.collectUndeliveredReplay(); - if (replay) { - // Prepend the replay onto the transport's own long-lived - // standalone stream — the stream MUST stay open afterwards (see - // collectUndeliveredReplay). Acknowledgement moves to stream end: - // "complete"/"rotate" imply the client stayed attached long - // enough to have drained the prepended frames. - const response = await transport.handleRequest(request); - return this.trackedLegacyResponse(response, { - initialFrame: replay.frame, - acknowledge: replay.streamIds, - }); - } + // Replayed responses ride as the opening frames of the transport's + // own long-lived standalone stream — the stream MUST stay open + // afterwards (see collectUndeliveredReplay). Acknowledgement moves + // to stream end: "complete"/"rotate" imply the client stayed + // attached long enough to have drained the prepended frames. + const response = await transport.handleRequest(serveRequest); + return this.trackedLegacyResponse( + response, + replay ? { initialFrame: replay.frame, acknowledge: replay.streamIds } : {}, + ); } } const toolCallIds = legacyToolCallRequestIds(parsedBody); diff --git a/packages/hosts/cloudflare/src/mcp/do-event-store.ts b/packages/hosts/cloudflare/src/mcp/do-event-store.ts index b0d58bfd3a..7a9a0a7dd4 100644 --- a/packages/hosts/cloudflare/src/mcp/do-event-store.ts +++ b/packages/hosts/cloudflare/src/mcp/do-event-store.ts @@ -245,6 +245,35 @@ export class DurableObjectMcpEventStore implements EventStore { } /** Replay persisted events after the supplied ID, in storage-key order. */ + /** + * Whether the stream named by `lastEventId` still has stored events after + * it — i.e. whether a reconnect GET carrying this id has anything left to + * resume. EventSource clients echo the last id they saw on EVERY + * reconnect, so a drained id must route the connection to the standalone + * listener path instead of a resume (the transport closes a completed + * stream's resume immediately, which turns the echo into a reconnect + * loop). + */ + async hasEventsAfter(lastEventId: EventId): Promise { + const streamId = streamIdFromEventId(lastEventId); + if (!streamId) return false; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- storage boundary: on probe failure fall back to resume semantics + try { + const rows = await this.storage.list({ + prefix: eventPrefix(streamId), + startAfter: `${EVENT_KEY_PREFIX}${lastEventId}`, + limit: 1, + }); + return rows.size > 0; + } catch { + logStoreWarning("mcp_event_store_list_failed", { + operation: "has_events_after", + streamId, + }); + return true; + } + } + async replayEventsAfter( lastEventId: EventId, { send }: { readonly send: (eventId: EventId, message: JSONRPCMessage) => Promise }, diff --git a/packages/hosts/cloudflare/src/mcp/sse-response-rotation.ts b/packages/hosts/cloudflare/src/mcp/sse-response-rotation.ts index c84bda1f1c..0cc6c3758e 100644 --- a/packages/hosts/cloudflare/src/mcp/sse-response-rotation.ts +++ b/packages/hosts/cloudflare/src/mcp/sse-response-rotation.ts @@ -5,8 +5,19 @@ export const SSE_MAX_AGE_RECONNECT_FRAME = ": max-age rotation, reconnect\n\n"; export type SseResponseCloseReason = "cancel" | "complete" | "error" | "rotate"; +/** + * Comment frame emitted while the source is quiet so intermediaries and + * client idle timers see a live stream. The pre-v2 worker bridge sent one + * every 25s; without it, held-open standalone GET listeners die to ~30s + * client/proxy idle timeouts and every session reconnect-cycles at that + * cadence. + */ +export const SSE_KEEPALIVE_FRAME = ": keepalive\n\n"; +export const SSE_KEEPALIVE_INTERVAL_MS = 20_000; + export interface SseResponseRotationOptions { readonly maxAgeMs?: number; + readonly keepaliveMs?: number; readonly initialFrame?: Uint8Array; readonly onOpen?: () => void; readonly onClose?: (reason: SseResponseCloseReason) => void; @@ -29,16 +40,21 @@ export const rotateSseResponse = ( if (!isSseResponse(response) || !response.body) return response; const reader = response.body.getReader(); - const reconnectFrame = new TextEncoder().encode(SSE_MAX_AGE_RECONNECT_FRAME); + const encoder = new TextEncoder(); + const reconnectFrame = encoder.encode(SSE_MAX_AGE_RECONNECT_FRAME); + const keepaliveFrame = encoder.encode(SSE_KEEPALIVE_FRAME); const maxAgeMs = options.maxAgeMs ?? SSE_MAX_AGE_MS; + const keepaliveMs = options.keepaliveMs ?? SSE_KEEPALIVE_INTERVAL_MS; let controller: ReadableStreamDefaultController; let closed = false; let timer: ReturnType | undefined; + let keepaliveTimer: ReturnType | undefined; const finish = (reason: SseResponseCloseReason): void => { if (closed) return; closed = true; if (timer !== undefined) clearTimeout(timer); + if (keepaliveTimer !== undefined) clearInterval(keepaliveTimer); if (reason === "rotate") { controller.enqueue(reconnectFrame); controller.close(); @@ -58,6 +74,10 @@ export const rotateSseResponse = ( options.onOpen?.(); if (options.initialFrame) controller.enqueue(options.initialFrame); timer = setTimeout(() => finish("rotate"), maxAgeMs); + keepaliveTimer = setInterval(() => { + if (closed) return; + controller.enqueue(keepaliveFrame); + }, keepaliveMs); }, async pull() { // oxlint-disable-next-line executor/no-try-catch-or-throw -- stream boundary: propagate the source body's rejected read to the response consumer @@ -73,6 +93,7 @@ export const rotateSseResponse = ( if (closed) return; closed = true; if (timer !== undefined) clearTimeout(timer); + if (keepaliveTimer !== undefined) clearInterval(keepaliveTimer); options.onClose?.("error"); controller.error(cause); } @@ -81,6 +102,7 @@ export const rotateSseResponse = ( if (!closed) { closed = true; if (timer !== undefined) clearTimeout(timer); + if (keepaliveTimer !== undefined) clearInterval(keepaliveTimer); options.onClose?.("cancel"); } await reader.cancel(reason); From deb39f7af65aba9f00ad7778c8cd8d99715c3f31 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:27:28 -0600 Subject: [PATCH 023/133] Trace first-party OAuth usage (#1633) --- .../core/sdk/src/oauth-first-party.test.ts | 49 +++++++++++++++++-- packages/core/sdk/src/oauth-service.ts | 6 +++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/packages/core/sdk/src/oauth-first-party.test.ts b/packages/core/sdk/src/oauth-first-party.test.ts index 193b3b4fc2..cd081d83be 100644 --- a/packages/core/sdk/src/oauth-first-party.test.ts +++ b/packages/core/sdk/src/oauth-first-party.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Predicate } from "effect"; +import type * as Tracer from "effect/Tracer"; import { AuthTemplateSlug, @@ -29,6 +30,37 @@ const INTEG = IntegrationSlug.make("acme"); const TEMPLATE = AuthTemplateSlug.make("oauth"); const FIRST_PARTY = firstPartyOAuthClientSlug("acme"); +const makeRecordingTracer = (spans: Map>): Tracer.Tracer => ({ + span: (options) => { + const attributes = new Map(); + spans.set(options.name, attributes); + let status: Tracer.SpanStatus = { _tag: "Started", startTime: options.startTime }; + return { + _tag: "Span", + name: options.name, + spanId: "0000000000000001", + traceId: "00000000000000000000000000000001", + parent: options.parent, + annotations: options.annotations, + get status() { + return status; + }, + attributes, + links: options.links, + sampled: options.sampled, + kind: options.kind, + end: (endTime, exit) => { + status = { _tag: "Ended", startTime: options.startTime, endTime, exit }; + }, + attribute: (key, value) => { + attributes.set(key, value); + }, + event: () => undefined, + addLinks: () => undefined, + }; + }, +}); + const oauthPlugin = definePlugin(() => ({ id: "acme" as const, storage: () => ({}), @@ -81,8 +113,9 @@ const firstPartyClientFor = (server: { describe("first-party oauth clients", () => { it.effect( "start → complete through a config-declared client mints an executable connection", - () => - Effect.scoped( + () => { + const spans = new Map>(); + return Effect.scoped( Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["read"] }); const { executor } = yield* makeTestWorkspaceHarness({ @@ -101,6 +134,9 @@ describe("first-party oauth clients", () => { template: TEMPLATE, }); expect(started.status).toBe("redirect"); + expect( + spans.get("test.oauth.first_party")?.get("executor.oauth.client_first_party"), + ).toBe(true); if (started.status !== "redirect") return; const callback = yield* server.completeAuthorizationCodeFlow({ @@ -111,6 +147,9 @@ describe("first-party oauth clients", () => { code: callback.code, }); expect(String(connection.address)).toBe("tools.acme.org.mainAccount"); + expect( + spans.get("executor.oauth.complete")?.get("executor.oauth.client_first_party"), + ).toBe(true); const out = (yield* executor.execute( ToolAddress.make("tools.acme.org.mainAccount.whoami"), @@ -119,7 +158,11 @@ describe("first-party oauth clients", () => { expect(out.token).toMatch(/^at_/); expect(yield* server.acceptsAccessToken(out.token)).toBe(true); }), - ), + ).pipe( + Effect.withSpan("test.oauth.first_party"), + Effect.withTracer(makeRecordingTracer(spans)), + ); + }, ); it.effect("refresh resolves the config-declared client (no oauth_client row exists)", () => diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index a4dd8ebfa0..186631c916 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -1152,6 +1152,9 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { // First-party apps are deployment-owned, outside the owner lattice // entirely, so the rule does not apply to them. const firstPartyFlow = isFirstPartyOAuthClientSlug(String(input.client)); + yield* Effect.annotateCurrentSpan({ + "executor.oauth.client_first_party": firstPartyFlow, + }); if (!firstPartyFlow && input.owner === "org" && input.clientOwner === "user") { return yield* new OAuthStartError({ message: "A Workspace connection must use a Workspace app.", @@ -1404,6 +1407,9 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { "executor.connection": String(session.name), "executor.template": String(session.template), "executor.oauth.client": String(session.clientSlug), + "executor.oauth.client_first_party": isFirstPartyOAuthClientSlug( + String(session.clientSlug), + ), }); // Expired sessions are not redeemable — drop + treat as not found. From 194ef26f91f041068fbc43e0da5c9d52611103b5 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:34:36 -0600 Subject: [PATCH 024/133] Revert "Warm the Start server graph on isolate first-fetch (#1628)" (#1634) This reverts commit 4263f3384b4ae72d38848ac91348194f4980e55d. --- apps/cloud/src/server.ts | 44 ----------------------- apps/cloud/src/start-virtual-entries.d.ts | 8 ----- 2 files changed, 52 deletions(-) delete mode 100644 apps/cloud/src/start-virtual-entries.d.ts diff --git a/apps/cloud/src/server.ts b/apps/cloud/src/server.ts index 04d0002f11..549d35f43d 100644 --- a/apps/cloud/src/server.ts +++ b/apps/cloud/src/server.ts @@ -173,51 +173,8 @@ const mcpAgentHandler = makeCloudMcpAgentHandler({ traceRequest: traceCloudMcpRequest, }); -// --------------------------------------------------------------------------- -// Start server-graph warmup -// -// TanStack Start's server entry loads the router and start instance behind a -// dynamic import on the first Start-handled request per isolate -// (start-server-core's `loadEntries`). That graph is the whole app bundle -// (React SSR + the Effect app), so the request that pays the load stalls for -// seconds on production metal. Under heavy MCP traffic the worker spreads -// across enough isolates that nearly every page/asset request IS such a first -// request — the 2026-08-16 session-reset reconnect storm took the homepage -// p50 from ~15ms to ~2.6s exactly this way. -// -// So: on each isolate's first fetch, kick the same imports off in the -// background. MCP traffic then pre-warms an isolate long before a page -// request reaches it. The specifiers are the virtual module ids the Start -// vite plugin registers for `loadEntries`' own imports, so both resolve to -// the same chunk and `loadEntries`' cache finds it already evaluated. -// -// Deliberately NOT at module scope: a full warmup there trips workerd's -// global-scope I/O restriction, and DO-only isolates should not carry the -// SSR graph in memory. -// --------------------------------------------------------------------------- -let startGraphWarm = false; -let startGraphWarmupStarted = false; -const warmStartGraph = () => { - if (startGraphWarmupStarted) return; - startGraphWarmupStarted = true; - // oxlint-disable-next-line executor/no-promise-catch -- adapter boundary; fire-and-forget warmup outside any Effect runtime - void Promise.all([import("#tanstack-router-entry"), import("#tanstack-start-entry")]) - .then(() => { - startGraphWarm = true; - }) - .catch(() => { - // Advisory only — the request path still loads the graph lazily. - startGraphWarmupStarted = false; - }); -}; - const cloudflareHandler: ExportedHandler = { fetch: async (request, env, ctx) => { - warmStartGraph(); - // Captured before any await: whether the Start graph was already - // evaluated when this request arrived (stamped on the page span below — - // a cold graph is the known multi-second page-latency mode). - const startGraphWasWarm = startGraphWarm; // Browser OTLP ingress — before the server span opens: exporter traffic // must never trace itself (the browser already excludes /v1/traces from // its own tracing for the same reason). @@ -277,7 +234,6 @@ const cloudflareHandler: ExportedHandler = { span.setAttribute(ATTR_URL_FULL, request.url); span.setAttribute(ATTR_URL_PATH, url.pathname); span.setAttribute(ATTR_URL_SCHEME, url.protocol.replace(/:$/, "")); - span.setAttribute("executor.start_graph.warm", startGraphWasWarm); // Adapter boundary: Cloudflare's fetch handler is a Promise-based // callback and the OTel span lifecycle needs to observe both the // resolved response and any thrown error before `span.end()`. Sentry's diff --git a/apps/cloud/src/start-virtual-entries.d.ts b/apps/cloud/src/start-virtual-entries.d.ts deleted file mode 100644 index 9fb43d8206..0000000000 --- a/apps/cloud/src/start-virtual-entries.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -// TanStack Start's internal virtual server-entry modules (registered by the -// Start vite plugin; the same ids `start-server-core`'s `loadEntries` -// imports). server.ts imports them for the isolate warmup — only the -// module-evaluation side effect matters there, so the value shape is left -// untyped. Kept in a standalone declaration file: shorthand ambient modules -// only register from a non-module file (env-augment.d.ts is a module). -declare module "#tanstack-router-entry"; -declare module "#tanstack-start-entry"; From f5fffae0cb1a25aed5bfbe91b4e409a0e0b40f9d Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:57:55 -0600 Subject: [PATCH 025/133] Add Google OAuth homepage (#1635) --- apps/marketing/src/pages/google-oauth.astro | 512 ++++++++++++++++++++ 1 file changed, 512 insertions(+) create mode 100644 apps/marketing/src/pages/google-oauth.astro diff --git a/apps/marketing/src/pages/google-oauth.astro b/apps/marketing/src/pages/google-oauth.astro new file mode 100644 index 0000000000..7dd5091de7 --- /dev/null +++ b/apps/marketing/src/pages/google-oauth.astro @@ -0,0 +1,512 @@ +--- +import Layout from "../layouts/Layout.astro"; + +const googleServices = [ + { + index: "01", + name: "Google Calendar", + purpose: + "Executor can read your calendars and events, then create, update, or remove events when you ask an agent to manage your schedule.", + scope: "googleapis.com/auth/calendar", + }, + { + index: "02", + name: "Gmail", + purpose: + "Executor can read, search, compose, send, label, archive, and move messages to trash when you instruct an agent to work with your email.", + scope: "googleapis.com/auth/gmail.modify", + }, + { + index: "03", + name: "Google Sheets", + purpose: + "Executor can read spreadsheet data and update cells, ranges, and worksheets when you ask an agent to work with a spreadsheet.", + scope: "googleapis.com/auth/spreadsheets", + }, +] as const; +--- + + +
+ + + + +
+
+ + Google Workspace connections +
+

Executor

+

+ Executor is an integration platform and MCP gateway for AI agents. It lets you + securely connect Google Calendar, Gmail, and Google Sheets so an agent can read + data and take the actions you request. +

+
+ One connection + Explicit permission + User-directed actions +
+
+ +
+
+

01 / Google data

+
+

What Executor accesses—and why

+

+ You choose which Google service to connect. Executor requests only the + permissions needed for that connection and uses them to carry out your + instructions. +

+
+
+ +
+ { + googleServices.map((service) => ( +
+
{service.index}
+
+

{service.name}

+

{service.purpose}

+
+ {service.scope} +
+ )) + } +
+
+ +
+
+

02 / Your control

+
+

Your Google account stays yours

+

+ Connecting Google does not give Executor permission to act on its own. An + agent can use a Google tool only when you direct it to, subject to the + policies and approvals you configure in Executor. +

+
+
+ +
+
+ +

See the action

+

Executor exposes concrete Google actions with their inputs, not an open-ended account session.

+
+
+ +

Set the policy

+

Allow an action, require approval, or block it before an agent can run it.

+
+ +
+
+ +
+

03 / Privacy

+
+
+

Clear limits on Google data

+

+ Executor does not sell Google user data. We use Google data only to provide + and improve the user-facing integration features you request, in accordance + with our Privacy Policy. Executor's use and transfer of information received + from Google APIs adheres to the + Google API Services User Data Policy, including the Limited Use requirements. +

+
+ +
+
+ +
+ +
+
+
+ + From 77f7ac3fd28e3c42ab02d832b9af6dc01ab2a327 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:00:38 -0600 Subject: [PATCH 026/133] Route Google OAuth page to marketing (#1636) --- apps/cloud/src/edge/marketing.test.ts | 1 + apps/cloud/src/edge/marketing.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/cloud/src/edge/marketing.test.ts b/apps/cloud/src/edge/marketing.test.ts index 38019ce9b9..c1e4add854 100644 --- a/apps/cloud/src/edge/marketing.test.ts +++ b/apps/cloud/src/edge/marketing.test.ts @@ -12,6 +12,7 @@ describe("isMarketingPath", () => { "/home", "/privacy", "/terms", + "/google-oauth", "/blog", "/blog/", "/blog/some-post", diff --git a/apps/cloud/src/edge/marketing.ts b/apps/cloud/src/edge/marketing.ts index ee473ce688..754fc7cf2d 100644 --- a/apps/cloud/src/edge/marketing.ts +++ b/apps/cloud/src/edge/marketing.ts @@ -18,6 +18,7 @@ const MARKETING_PATHS = [ "/setup", "/privacy", "/terms", + "/google-oauth", "/blog", "/llms.txt", "/api/detect", From ffb28cce55cd754107bfc33132c40504378a4e5d Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:07:18 -0600 Subject: [PATCH 027/133] Bypass Start for marketing requests (#1637) --- apps/cloud/src/edge/index.ts | 9 ++--- apps/cloud/src/edge/marketing.test.ts | 53 ++++++++++++++++++++++++- apps/cloud/src/edge/marketing.ts | 56 +++++++++++---------------- apps/cloud/src/server.ts | 9 +++++ apps/cloud/src/start.ts | 22 +++++------ 5 files changed, 97 insertions(+), 52 deletions(-) diff --git a/apps/cloud/src/edge/index.ts b/apps/cloud/src/edge/index.ts index 1b1cbe9ad8..6b58c2eda5 100644 --- a/apps/cloud/src/edge/index.ts +++ b/apps/cloud/src/edge/index.ts @@ -1,11 +1,10 @@ // --------------------------------------------------------------------------- -// Edge concerns — the analytics/marketing/docs request middlewares that run at -// the worker edge BEFORE the app's own mcp + api dispatch. None of these touch -// the Effect app layer; they proxy or tunnel to external services (the -// marketing worker, Sentry, PostHog, Mintlify docs). +// Edge concerns — request middleware that runs before the app's own mcp + api +// dispatch. These proxy or tunnel to external services without touching the +// Effect app layer. Marketing is dispatched even earlier, from server.ts, so a +// public page never loads the TanStack Start graph. // --------------------------------------------------------------------------- -export { marketingMiddleware } from "./marketing"; export { sentryTunnelMiddleware } from "./sentry-tunnel"; export { posthogProxyMiddleware } from "./posthog"; export { docsProxyMiddleware } from "./docs"; diff --git a/apps/cloud/src/edge/marketing.test.ts b/apps/cloud/src/edge/marketing.test.ts index c1e4add854..f3e4e9bef1 100644 --- a/apps/cloud/src/edge/marketing.test.ts +++ b/apps/cloud/src/edge/marketing.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; -import { isMarketingPath } from "./marketing"; +import { isMarketingPath, marketingProxyRequest } from "./marketing"; // On executor.sh the marketing middleware proxies an allow-list of paths to the // `executor-marketing` worker; everything else falls through to the auth-gated @@ -38,3 +38,54 @@ describe("isMarketingPath", () => { }); } }); + +describe("marketingProxyRequest", () => { + it("routes a signed-out homepage request", () => { + const request = new Request("https://executor.sh/?source=test"); + + const proxied = marketingProxyRequest(request); + + expect(proxied?.url).toBe("https://executor.sh/?source=test"); + }); + + it("leaves the signed-in homepage with the cloud application", () => { + const request = new Request("https://executor.sh/", { + headers: { cookie: "other=value; wos-session=sealed" }, + }); + + expect(marketingProxyRequest(request)).toBeNull(); + }); + + it("routes public content even when a session cookie is present", () => { + const request = new Request("https://executor.sh/blog/post", { + headers: { cookie: "wos-session=sealed" }, + }); + + expect(marketingProxyRequest(request)?.url).toBe("https://executor.sh/blog/post"); + }); + + it("rewrites the public home alias to the marketing root", () => { + const request = new Request("https://executor.sh/home?source=test"); + + expect(marketingProxyRequest(request)?.url).toBe("https://executor.sh/?source=test"); + }); + + it("preserves the request method, headers, and body", async () => { + const request = new Request("https://executor.sh/_astro/_ph/capture", { + method: "POST", + headers: { "content-type": "application/json", "x-request-id": "request-1" }, + body: JSON.stringify({ event: "test" }), + }); + + const proxied = marketingProxyRequest(request); + + expect(proxied?.method).toBe("POST"); + expect(proxied?.headers.get("x-request-id")).toBe("request-1"); + await expect(proxied?.json()).resolves.toEqual({ event: "test" }); + }); + + it("does not proxy non-production hosts or app-owned paths", () => { + expect(marketingProxyRequest(new Request("http://executor-cloud.localhost/"))).toBeNull(); + expect(marketingProxyRequest(new Request("https://executor.sh/login"))).toBeNull(); + }); +}); diff --git a/apps/cloud/src/edge/marketing.ts b/apps/cloud/src/edge/marketing.ts index 754fc7cf2d..80641e00dc 100644 --- a/apps/cloud/src/edge/marketing.ts +++ b/apps/cloud/src/edge/marketing.ts @@ -3,14 +3,10 @@ // // On the production domain (`executor.sh`), marketing paths and the // unauthenticated landing page are served by the separate `executor-marketing` -// worker (bound as `env.MARKETING`). In local dev that worker isn't running, so -// unauthenticated visits fall through to the cloud app's routes (the sign-in -// page). +// worker. This module deliberately has no TanStack Start or cloud application +// imports: the Worker entry calls it before loading the Start server graph. // --------------------------------------------------------------------------- -import { env } from "cloudflare:workers"; -import { createMiddleware } from "@tanstack/react-start"; - import { parseCookie } from "../auth/cookies"; const MARKETING_PATHS = [ @@ -28,33 +24,25 @@ const MARKETING_PATHS = [ "/pattern-graph-paper.svg", ]; -export const isMarketingPath = (pathname: string) => - MARKETING_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`)); - -const getMarketingWorker = () => env.MARKETING as { fetch: typeof fetch } | undefined; - -export const marketingMiddleware = createMiddleware({ type: "request" }).server( - async ({ pathname, request, next }) => { - // Only proxy to the marketing worker on the production domain. In local - // dev we don't run `executor-marketing`, so unauthenticated visits fall - // through to the cloud app's routes (which show the sign-in page). - const host = new URL(request.url).hostname; - if (host !== "executor.sh") return next(); +const SESSION_COOKIE = "wos-session"; - const shouldProxyToMarketing = - isMarketingPath(pathname) || - (pathname === "/" && !parseCookie(request.headers.get("cookie"), "wos-session")); - - if (!shouldProxyToMarketing) return next(); - - const marketing = getMarketingWorker(); - if (!marketing) return next(); +/** Whether an exact pathname belongs to the public marketing worker. */ +export const isMarketingPath = (pathname: string): boolean => + MARKETING_PATHS.some((p) => pathname === p || pathname.startsWith(`${p}/`)); - const url = new URL(request.url); - // Rewrite /home to / so marketing worker serves its homepage - if (pathname === "/home") { - url.pathname = "/"; - } - return marketing.fetch(new Request(url, request)); - }, -); +/** + * Project a production request onto the marketing service-binding request. + * Returns `null` when the cloud application owns the request instead. + */ +export const marketingProxyRequest = (request: Request): Request | null => { + const url = new URL(request.url); + if (url.hostname !== "executor.sh") return null; + + const shouldProxy = + isMarketingPath(url.pathname) || + (url.pathname === "/" && !parseCookie(request.headers.get("cookie"), SESSION_COOKIE)); + if (!shouldProxy) return null; + + if (url.pathname === "/home") url.pathname = "/"; + return new Request(url, request); +}; diff --git a/apps/cloud/src/server.ts b/apps/cloud/src/server.ts index 549d35f43d..93432d8a2e 100644 --- a/apps/cloud/src/server.ts +++ b/apps/cloud/src/server.ts @@ -12,6 +12,7 @@ import * as Sentry from "@sentry/cloudflare"; import handler from "@tanstack/react-start/server-entry"; import { isAppOwnedPath } from "./app-paths"; +import { marketingProxyRequest } from "./edge/marketing"; import { makeCloudMcpAgentHandler } from "./mcp/agent-handler"; import { classifyMcpPath, prepareMcpOrgScope } from "./mcp/mount"; import { parseTraceparent } from "./mcp/traceparent"; @@ -175,6 +176,14 @@ const mcpAgentHandler = makeCloudMcpAgentHandler({ const cloudflareHandler: ExportedHandler = { fetch: async (request, env, ctx) => { + // Public pages must not enter TanStack Start: its first-request dynamic + // import loads the entire React + Effect server graph and can take seconds + // on a cold isolate. Classify and service-bind marketing at the Worker + // entry, before telemetry or fetchHandler touches that graph. + const marketingRequest = marketingProxyRequest(request); + const marketing: Fetcher | undefined = env.MARKETING; + if (marketingRequest && marketing) return marketing.fetch(marketingRequest); + // Browser OTLP ingress — before the server span opens: exporter traffic // must never trace itself (the browser already excludes /v1/traces from // its own tracing for the same reason). diff --git a/apps/cloud/src/start.ts b/apps/cloud/src/start.ts index a35a327f15..be55277636 100644 --- a/apps/cloud/src/start.ts +++ b/apps/cloud/src/start.ts @@ -10,7 +10,6 @@ import { loginPath } from "./auth/return-to"; import { prepareMcpOrgScope } from "./mcp/mount"; import { docsProxyMiddleware, - marketingMiddleware, openAiAppsChallengeMiddleware, posthogProxyMiddleware, sentryTunnelMiddleware, @@ -89,20 +88,19 @@ const appRequestMiddleware = createMiddleware({ type: "request" }).server( }, ); -// The edge concerns (marketing proxy, docs proxy, sentry tunnel, posthog proxy) -// live in `./edge`; they run before the app's own dispatch. Ordering is -// load-bearing: marketing first (production landing/page proxy), then the docs -// proxy and analytics tunnels, then the unified app plane (api + mcp), and last -// the SSR auth gate — it only sees document requests nothing above claimed, so -// signed-out visitors are redirected to /login before the SPA (and its -// app-shell skeleton) is served. The docs proxy sits among the edges (not after -// the auth gate) because `/docs` is public and must skip the sign-in redirect; -// its path is disjoint from every other matcher, so its slot is not otherwise -// load-bearing. +// The remaining edge concerns (docs proxy, sentry tunnel, posthog proxy) live +// in `./edge`; they run before the app's own dispatch. Marketing is handled in +// server.ts before this module is loaded. Ordering here is load-bearing: public +// challenges and docs, then analytics tunnels, then the unified app plane (api +// + mcp), and last the SSR auth gate — it only sees document requests nothing +// above claimed, so signed-out visitors are redirected to /login before the SPA +// (and its app-shell skeleton) is served. The docs proxy sits among the edges +// (not after the auth gate) because `/docs` is public and must skip the sign-in +// redirect; its path is disjoint from every other matcher, so its slot is not +// otherwise load-bearing. export const startInstance = createStart(() => ({ requestMiddleware: [ openAiAppsChallengeMiddleware, - marketingMiddleware, docsProxyMiddleware, sentryTunnelMiddleware, posthogProxyMiddleware, From 7f5151ecc18d3d779bdd67d01d5382926b321b56 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:36:11 -0600 Subject: [PATCH 028/133] Add fresh Google Workspace homepage (#1638) --- apps/cloud/src/edge/marketing.test.ts | 1 + apps/cloud/src/edge/marketing.ts | 1 + .../src/pages/google-workspace.astro | 193 ++++++++++++++++++ 3 files changed, 195 insertions(+) create mode 100644 apps/marketing/src/pages/google-workspace.astro diff --git a/apps/cloud/src/edge/marketing.test.ts b/apps/cloud/src/edge/marketing.test.ts index f3e4e9bef1..f3e506c8f4 100644 --- a/apps/cloud/src/edge/marketing.test.ts +++ b/apps/cloud/src/edge/marketing.test.ts @@ -13,6 +13,7 @@ describe("isMarketingPath", () => { "/privacy", "/terms", "/google-oauth", + "/google-workspace", "/blog", "/blog/", "/blog/some-post", diff --git a/apps/cloud/src/edge/marketing.ts b/apps/cloud/src/edge/marketing.ts index 80641e00dc..7e9889d6f2 100644 --- a/apps/cloud/src/edge/marketing.ts +++ b/apps/cloud/src/edge/marketing.ts @@ -15,6 +15,7 @@ const MARKETING_PATHS = [ "/privacy", "/terms", "/google-oauth", + "/google-workspace", "/blog", "/llms.txt", "/api/detect", diff --git a/apps/marketing/src/pages/google-workspace.astro b/apps/marketing/src/pages/google-workspace.astro new file mode 100644 index 0000000000..e168a81605 --- /dev/null +++ b/apps/marketing/src/pages/google-workspace.astro @@ -0,0 +1,193 @@ +--- +const services = [ + { + name: "Google Calendar", + description: + "Read calendars and events, and create, update, or remove events when you ask an agent to manage your schedule.", + }, + { + name: "Gmail", + description: + "Read, search, compose, send, label, archive, or move messages to trash when you ask an agent to work with your email.", + }, + { + name: "Google Sheets", + description: + "Read spreadsheet data and update cells, ranges, or worksheets when you ask an agent to work with a spreadsheet.", + }, +] as const; +--- + + + + + + + + + + Executor + + + +
+

Useful Software Company

+

Executor

+

+ Executor is an integration platform and MCP gateway for AI agents. Executor lets you + securely connect Google Calendar, Gmail, and Google Sheets so an agent can read your + Google data and take only the actions you request. +

+
+ +
+
+

Why Executor requests access to Google data

+

+ You choose which Google service to connect. Executor requests the permissions needed + to provide that connection and uses the resulting Google data to carry out your + instructions. Connecting Google does not give Executor permission to act on its own. +

+
    + { + services.map((service) => ( +
  • +

    {service.name}

    +

    {service.description}

    +
  • + )) + } +
+
+ +
+

You control the connection

+

+ Executor exposes specific Google actions and their inputs. You can allow an action, + require approval, or block it through the policies you configure in Executor. You can + remove a connection in Executor or revoke it from your + Google Account permissions. +

+
+ +
+

Privacy and Google API data

+

+ Executor does not sell Google user data. Executor uses Google data only to provide and + improve the user-facing integration features you request, as described in the + Executor Privacy Policy. +

+

+ Executor's use and transfer of information received from Google APIs adheres to the + Google API Services User Data Policy, including the Limited Use requirements. +

+
+
+ + + + From c83cdd22b8d44071bac5d600cbca6f4e23ac91cc Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:52:06 -0600 Subject: [PATCH 029/133] Add plain Executor overview (#1639) --- apps/cloud/src/edge/marketing.test.ts | 1 + apps/cloud/src/edge/marketing.ts | 1 + apps/marketing/src/pages/about-executor.astro | 153 ++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 apps/marketing/src/pages/about-executor.astro diff --git a/apps/cloud/src/edge/marketing.test.ts b/apps/cloud/src/edge/marketing.test.ts index f3e506c8f4..f96958a0ac 100644 --- a/apps/cloud/src/edge/marketing.test.ts +++ b/apps/cloud/src/edge/marketing.test.ts @@ -12,6 +12,7 @@ describe("isMarketingPath", () => { "/home", "/privacy", "/terms", + "/about-executor", "/google-oauth", "/google-workspace", "/blog", diff --git a/apps/cloud/src/edge/marketing.ts b/apps/cloud/src/edge/marketing.ts index 7e9889d6f2..b51ef0b459 100644 --- a/apps/cloud/src/edge/marketing.ts +++ b/apps/cloud/src/edge/marketing.ts @@ -14,6 +14,7 @@ const MARKETING_PATHS = [ "/setup", "/privacy", "/terms", + "/about-executor", "/google-oauth", "/google-workspace", "/blog", diff --git a/apps/marketing/src/pages/about-executor.astro b/apps/marketing/src/pages/about-executor.astro new file mode 100644 index 0000000000..77acc55735 --- /dev/null +++ b/apps/marketing/src/pages/about-executor.astro @@ -0,0 +1,153 @@ +--- +const pageTitle = "Executor"; +const pageDescription = + "Executor is a web application that lets people connect AI assistants to Google Calendar, Gmail, Google Sheets, and other software, then control the actions those assistants can take."; +--- + + + + + + + + + + + + + + + + Executor + + + +
+

Executor

+

+ Executor is a web application that lets people connect AI assistants to their software + and data. It gives those assistants specific tools to complete tasks the person requests, + while letting the person approve or block actions. +

+
+ +
+
+

What Executor does

+

+ A person can use Executor to connect an AI assistant to services such as Google + Calendar, Gmail, and Google Sheets. The person can then ask the assistant to manage a + schedule, organize email, or update a spreadsheet through Executor. +

+

+ Executor performs only the actions the person requests and permits. It does not give an + AI assistant an unrestricted session in the person's account. +

+
+ +
+

How Executor uses Google data

+

+ When a person chooses to connect a Google service, Executor requests the permissions + needed to provide that connection and uses the resulting data to carry out that + person's instructions. +

+
    +
  • Google Calendar: read calendars and events, or create, update, and remove events.
  • +
  • Gmail: read and search messages, compose and send mail, manage labels, archive messages, or move messages to trash.
  • +
  • Google Sheets: read spreadsheet data and update cells, ranges, and worksheets.
  • +
+
+ +
+

Privacy and user control

+

+ Executor does not sell Google user data. A person can remove a connection in Executor or + revoke it from their Google Account permissions. More information is available in the + Executor Privacy Policy. +

+

+ Executor's use and transfer of information received from Google APIs adheres to the + Google API Services User Data Policy, including the Limited Use requirements. +

+
+
+ + + + From f64028a9bab692e274be29dfcda776011a4c5b76 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:01:55 -0600 Subject: [PATCH 030/133] Speed up and stabilize CI e2e (#1551) * Speed up selfhost e2e: tunable sandbox deadline + 3-way sharding * Fix the local e2e suite: toolkit MCP DB lock, Bun-only spawn, stale auth selector * Stabilize CI process lifecycle --- .github/workflows/ci.yml | 55 +++---- apps/cli/src/main.ts | 2 + apps/host-selfhost/src/config.ts | 25 ++++ apps/host-selfhost/src/execution.ts | 7 +- apps/local/src/executor.ts | 55 ++++--- apps/local/src/main.ts | 5 + apps/local/src/serve.ts | 50 +++++-- e2e/local/auth.test.ts | 8 +- e2e/local/boot-process.test.ts | 101 +++++++++++++ .../cli-mcp-daemon-attach-stress.test.ts | 114 +++++++-------- e2e/local/cli-mcp-protocol.test.ts | 21 +-- e2e/local/daemon-process.ts | 55 +++++++ e2e/local/local-server.ts | 10 +- e2e/local/vite-dev-routing.test.ts | 5 +- .../resume-after-sandbox-deadline.test.ts | 55 ++++--- e2e/selfhost/posthog-mcp-oauth.test.ts | 63 +++++--- e2e/setup/boot.ts | 135 ++++++++++++++++-- e2e/setup/cloud.boot.ts | 8 +- e2e/setup/cloud.globalsetup.ts | 4 +- e2e/setup/cloudflare.boot.ts | 9 +- e2e/setup/motel.ts | 6 +- e2e/setup/sandbox-timeout.ts | 28 ++++ e2e/setup/selfhost.boot.ts | 12 +- e2e/setup/selfhost.globalsetup.ts | 10 +- e2e/src/ports.ts | 28 +++- .../hosts/mcp/src/stdio-integration.test.ts | 60 +++++++- 26 files changed, 714 insertions(+), 217 deletions(-) create mode 100644 e2e/local/boot-process.test.ts create mode 100644 e2e/local/daemon-process.ts create mode 100644 e2e/setup/sandbox-timeout.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9eb2ca5852..dc003182b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -192,14 +192,25 @@ jobs: fail-fast: false matrix: include: - # Each cloud shard boots its own fresh dev stack. On 4 vCPU runners, - # four fatter shards keep the longest shard below selfhost while saving - # four runner boots and four warm cache restores. - - { target: cloud, shard: 1/4, shard-name: 1of4 } - - { target: cloud, shard: 2/4, shard-name: 2of4 } - - { target: cloud, shard: 3/4, shard-name: 3of4 } - - { target: cloud, shard: 4/4, shard-name: 4of4 } - - target: selfhost + # PGlite is deliberately single-connection, and under a sustained + # multi-minute shard it can stop accepting postgres sockets. Keep + # every hermetic dev stack short: eight serial shards remove that + # lifetime-dependent failure and put cloud below the selfhost lane. + - { target: cloud, shard: 1/8, shard-name: 1of8 } + - { target: cloud, shard: 2/8, shard-name: 2of8 } + - { target: cloud, shard: 3/8, shard-name: 3of8 } + - { target: cloud, shard: 4/8, shard-name: 4of8 } + - { target: cloud, shard: 5/8, shard-name: 5of8 } + - { target: cloud, shard: 6/8, shard-name: 6of8 } + - { target: cloud, shard: 7/8, shard-name: 7of8 } + - { target: cloud, shard: 8/8, shard-name: 8of8 } + # Selfhost shards the same way: each shard is its own runner booting + # its own fresh instance (own port block + data dir), so the + # project's shared-bootstrap-admin assumption stays intact per shard + # and `fileParallelism: false` still serializes within a shard. + - { target: selfhost, shard: 1/3, shard-name: 1of3 } + - { target: selfhost, shard: 2/3, shard-name: 2of3 } + - { target: selfhost, shard: 3/3, shard-name: 3of3 } runs-on: blacksmith-4vcpu-ubuntu-2404 timeout-minutes: 30 steps: @@ -241,20 +252,20 @@ jobs: # The globalsetup boots the target's own dev server (ports are claimed # per checkout, so this is hermetic) and tears it down after the run. - # --retry=2: browser scenarios can still hit isolated waitFor timeouts - # (single-test waitFor timeouts, not systemic failures); a retry on the - # same booted stack clears them. + # Do not retry scenarios: retries hide flakes and multiply slow timeout + # failures. The fixtures and process lifecycle are deterministic enough + # that the first result is the result. - name: Run cloud scenarios if: matrix.target == 'cloud' env: MCP_SESSION_TIMEOUT_MS: "3000" MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS: "6000" - run: bunx vitest run --project cloud --retry=2 ${{ matrix.shard && format('--shard={0}', matrix.shard) || '' }} + run: bunx vitest run --project cloud ${{ matrix.shard && format('--shard={0}', matrix.shard) || '' }} working-directory: e2e - name: Run selfhost scenarios if: matrix.target == 'selfhost' - run: bunx vitest run --project selfhost --retry=2 + run: bunx vitest run --project selfhost ${{ matrix.shard && format('--shard={0}', matrix.shard) || '' }} working-directory: e2e # Failed runs keep their trace.zip / session.mp4 / step screenshots in @@ -268,10 +279,7 @@ jobs: retention-days: 7 e2e-local: - name: E2E (stdio MCP) - # Skipped on pull_request: the local scenario boots a real `executor web` - # plus a browser and is currently flaky on PRs. Still runs on push to main. - if: github.event_name != 'pull_request' + name: E2E (local) runs-on: blacksmith-4vcpu-ubuntu-2404 timeout-minutes: 20 steps: @@ -314,15 +322,10 @@ jobs: run: bunx playwright install --with-deps chromium chromium-headless-shell working-directory: e2e - # The `local` project is excluded from the default `test` chain (each - # scenario boots its own `executor web`). Run just the stdio MCP scenario - # here: it is the auto-connect / env-as-secret regression guard, and - # running it alone avoids the boot-resource accumulation and the - # pre-existing browser flakiness of the rest of the local suite. Expanding - # to the full `local` project (bun run test:local) is a follow-up once - # those are stabilized. - - name: Run the stdio MCP scenario - run: bunx vitest run --project local local/stdio-mcp.test.ts + # Each scenario owns its server, browser, data directory, and descendants; + # run the complete hermetic suite on PRs without scenario retries. + - name: Run local scenarios + run: bunx vitest run --project local working-directory: e2e desktop-smoke: diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index 2b59190897..03df64876c 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -215,9 +215,11 @@ const waitForShutdownSignal = () => const shutdown = () => resume(Effect.void); process.once("SIGINT", shutdown); process.once("SIGTERM", shutdown); + process.once("SIGHUP", shutdown); return Effect.sync(() => { process.off("SIGINT", shutdown); process.off("SIGTERM", shutdown); + process.off("SIGHUP", shutdown); }); }); diff --git a/apps/host-selfhost/src/config.ts b/apps/host-selfhost/src/config.ts index a435b117eb..20728fabff 100644 --- a/apps/host-selfhost/src/config.ts +++ b/apps/host-selfhost/src/config.ts @@ -45,6 +45,14 @@ export interface SelfHostConfig { readonly organizationName: string; /** URL slug for org-prefixed console paths (`//policies`). */ readonly orgSlug: string; + /** + * Sandbox execution budget passed to the QuickJS runtime, or undefined for + * the runtime's own default (5 minutes). An operator knob in principle, but + * its real consumer is the e2e harness, which shrinks it to seconds so the + * sandbox-deadline scenario proves its race without waiting out real + * minutes (the same pattern as MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS on cloud). + */ + readonly sandboxTimeoutMs: number | undefined; } export const resolveDataDir = (): string => @@ -151,9 +159,26 @@ export const loadConfig = (): SelfHostConfig => { bootstrapAdminName: process.env.EXECUTOR_BOOTSTRAP_ADMIN_NAME ?? "Admin", organizationName: process.env.EXECUTOR_ORG_NAME ?? "Default", orgSlug: resolveOrgSlug(), + sandboxTimeoutMs: resolveSandboxTimeoutMs(), }; }; +// A malformed value is refused rather than silently ignored: an operator who +// sets the knob and typos it should find out at boot, not by watching a +// runaway execution use the 5-minute default. +const resolveSandboxTimeoutMs = (): number | undefined => { + const raw = process.env.EXECUTOR_SANDBOX_TIMEOUT_MS; + if (!raw) return undefined; + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: refuse to boot on a malformed operator knob + throw new Error( + `EXECUTOR_SANDBOX_TIMEOUT_MS ${JSON.stringify(raw)} is not a positive number of milliseconds`, + ); + } + return Math.floor(parsed); +}; + // The org slug doubles as a URL segment (`//policies`), so an // operator-set value must fit the shared grammar and avoid reserved root // segments (api, mcp, login, …) — a colliding slug would shadow real routes. diff --git a/apps/host-selfhost/src/execution.ts b/apps/host-selfhost/src/execution.ts index 270ffc4f8e..aa2ffee536 100644 --- a/apps/host-selfhost/src/execution.ts +++ b/apps/host-selfhost/src/execution.ts @@ -65,7 +65,12 @@ export const SelfHostHostConfig: Layer.Layer = Layer.sync(HostConfig export const SelfHostCodeExecutorProvider: Layer.Layer = Layer.sync( CodeExecutorProvider, - () => makeQuickJsExecutor(), + () => { + const { sandboxTimeoutMs } = loadConfig(); + return makeQuickJsExecutor( + sandboxTimeoutMs === undefined ? {} : { timeoutMs: sandboxTimeoutMs }, + ); + }, ); /** diff --git a/apps/local/src/executor.ts b/apps/local/src/executor.ts index dd91838f35..1ec7ebbf68 100644 --- a/apps/local/src/executor.ts +++ b/apps/local/src/executor.ts @@ -19,7 +19,7 @@ import type { McpPluginExtension } from "@executor-js/plugin-mcp"; import executorConfig from "../executor.config"; import { localAnalytics } from "./analytics"; import { localDataMigrations } from "./db/data-migrations"; -import { openOwnedLocalDatabase } from "./db/owned-database"; +import { openOwnedLocalDatabase, type OwnedLocalDatabase } from "./db/owned-database"; interface ResolvedStorage { readonly dataDir: string; @@ -56,6 +56,16 @@ type LocalPlugins = readonly AnyPlugin[]; export interface LocalExecutorOptions { readonly activeToolkitSlug?: string; + /** + * Reuse an already-open owned database instead of opening (and locking) the + * data dir again. A toolkit-scoped MCP session differs from the default one + * only in its plugin set, so it must ride the running server's DB handle: + * `openOwnedLocalDatabase` takes an EXCLUSIVE lock, and a second open from + * inside the same process contends with the lock this process already holds. + * The borrowed handle is NOT closed when the derived executor disposes — + * whoever opened it still owns its lifetime. + */ + readonly borrowedDb?: OwnedLocalDatabase; } const loadLocalPlugins = (options: LocalExecutorOptions = {}) => @@ -92,6 +102,10 @@ const loadLocalPlugins = (options: LocalExecutorOptions = {}) => interface LocalExecutorBundle { readonly executor: Executor; readonly plugins: LocalPlugins; + /** The owned DB this bundle opened (or borrowed). Surfaced so a + * toolkit-scoped executor can ride the SAME handle instead of contending + * with this process's own exclusive data-dir lock. */ + readonly db: OwnedLocalDatabase; /** Where this daemon's web UI is reachable, resolved once at boot. Surfaced * so callers building user-facing links (MCP artifact deep links) use the * same origin the executor itself was configured with. */ @@ -151,23 +165,27 @@ const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => { const tenantId = makeTenantId(cwd); const tables = collectTables(); - const owned = yield* Effect.acquireRelease( - Effect.tryPromise({ - try: () => - openOwnedLocalDatabase({ - dataDir: storage.dataDir, - tables, - namespace: localNamespace, - tenantId, + // A borrowed handle is owned by its opener, so it is used as-is and left + // open on release; only a handle opened here is closed here. + const owned = options.borrowedDb + ? options.borrowedDb + : yield* Effect.acquireRelease( + Effect.tryPromise({ + try: () => + openOwnedLocalDatabase({ + dataDir: storage.dataDir, + tables, + namespace: localNamespace, + tenantId, + }), + catch: (cause) => + new LocalExecutorCreateError({ + message: CREATE_SQLITE_ERROR_MESSAGE, + cause, + }), }), - catch: (cause) => - new LocalExecutorCreateError({ - message: CREATE_SQLITE_ERROR_MESSAGE, - cause, - }), - }), - (database) => Effect.promise(() => database.close()).pipe(Effect.ignore), - ); + (database) => Effect.promise(() => database.close()).pipe(Effect.ignore), + ); const sqlite = owned.db; const migration = owned.migration; @@ -243,7 +261,7 @@ const createLocalExecutorLayer = (options: LocalExecutorOptions = {}) => { ); } - return { executor, plugins, webBaseUrl }; + return { executor, plugins, webBaseUrl, db: owned }; }), ); }; @@ -257,6 +275,7 @@ export const createExecutorHandle = async (options: LocalExecutorOptions = {}) = executor: bundle.executor, plugins: bundle.plugins, webBaseUrl: bundle.webBaseUrl, + db: bundle.db, dispose: async () => { await Effect.runPromise(Effect.ignore(bundle.executor.close())); await ignorePromiseFailure("disposeRuntime", () => runtime.dispose()); diff --git a/apps/local/src/main.ts b/apps/local/src/main.ts index 14e272f37a..0e74cec5d8 100644 --- a/apps/local/src/main.ts +++ b/apps/local/src/main.ts @@ -137,8 +137,13 @@ export const createServerHandlers = async (token: string): Promise Promise; } +const viteChildSignals = ["SIGINT", "SIGTERM", "SIGHUP"] as const; + async function allocatePort(): Promise { const probe = Bun.serve({ port: 0, @@ -127,15 +129,15 @@ async function allocatePort(): Promise { async function startViteChild(): Promise { const vitePort = await allocatePort(); const cwd = resolve(import.meta.dirname, ".."); + const viteEntrypoint = resolve(cwd, "node_modules/vite/bin/vite.js"); const env = { ...process.env }; delete env.PORT; - // `bunx --bun vite` runs vite under Bun, matching the `dev:vite` script - // already in apps/local. --strictPort keeps the URL we hand back stable. + // Run Vite directly under Bun, matching the `dev:vite` script without a + // bunx wrapper that can outlive its child. --strictPort keeps the URL stable. const child: Subprocess = Bun.spawn( [ - "bunx", - "--bun", - "vite", + process.execPath, + viteEntrypoint, "dev", "--port", String(vitePort), @@ -158,20 +160,45 @@ async function startViteChild(): Promise { }, ); + let stopping = false; + const stop = async (): Promise => { + if (stopping) { + await child.exited; + return; + } + stopping = true; + for (const signal of viteChildSignals) process.off(signal, stopOnParentSignal); + if (child.exitCode === null) child.kill(); + await Promise.race([child.exited, Bun.sleep(5_000)]); + if (child.exitCode === null) child.kill("SIGKILL"); + await child.exited; + }; + const stopOnParentSignal = (): void => { + // A PTY/session teardown can signal the CLI while Vite is still optimizing + // dependencies, before the server's normal stop handle exists. Reap the + // owned child immediately; the CLI's signal waiter performs full cleanup + // once startup has completed. + void stop(); + }; + for (const signal of viteChildSignals) process.once(signal, stopOnParentSignal); + const url = `http://127.0.0.1:${vitePort}`; const deadline = Date.now() + 30_000; while (Date.now() < deadline) { // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: probing a child process that may not be listening yet try { - const r = await fetch(`${url}/`, { redirect: "manual" }); + const r = await fetch(`${url}/`, { + redirect: "manual", + // A listening socket is not proof that Vite can answer. Bound each + // probe so one accepted-but-stalled request cannot defeat the 30s boot + // deadline and wedge the entire local e2e suite. + signal: AbortSignal.timeout(5_000), + }); if (r.status < 500) { await r.body?.cancel(); return { url, - stop: async () => { - child.kill(); - await child.exited; - }, + stop, }; } await r.body?.cancel(); @@ -179,12 +206,13 @@ async function startViteChild(): Promise { // not up yet } if (child.exitCode !== null) { + await stop(); // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: child process aborted before becoming ready throw new Error(`vite dev exited with code ${child.exitCode} before becoming ready`); } await Bun.sleep(150); } - child.kill(); + await stop(); // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: vite never became reachable throw new Error(`vite dev did not become reachable on ${url} within 30s`); } diff --git a/e2e/local/auth.test.ts b/e2e/local/auth.test.ts index 68201a6843..225205b388 100644 --- a/e2e/local/auth.test.ts +++ b/e2e/local/auth.test.ts @@ -36,8 +36,10 @@ scenario( await page.goto(url, { waitUntil: "domcontentloaded" }); await page.getByRole("link", { name: "Secrets" }).first().waitFor({ timeout: 30_000 }); // Integrations actually LOAD (the built-in Executor integration) — proves - // auth + data, not just the static shell. - await page.getByText("built-in").first().waitFor({ timeout: 30_000 }); + // auth + data, not just the static shell. Matched on the row's stable + // testid: the list renders each integration's name + slug, never the + // literal "built-in" (that string is only an internal `kind`). + await page.getByTestId("integration-entry-executor").first().waitFor({ timeout: 30_000 }); // The token is moved out of the URL and persisted to localStorage. expect(new URL(page.url()).searchParams.has("_token")).toBe(false); const stored = await page.evaluate(() => localStorage.getItem("executor.authToken")); @@ -70,7 +72,7 @@ scenario( await page.getByRole("button", { name: "Connect" }).click(); await page.getByRole("link", { name: "Secrets" }).first().waitFor({ timeout: 30_000 }); // The reconnect fully restores — integrations LOAD, not a stale 401. - await page.getByText("built-in").first().waitFor({ timeout: 30_000 }); + await page.getByTestId("integration-entry-executor").first().waitFor({ timeout: 30_000 }); }); }), ); diff --git a/e2e/local/boot-process.test.ts b/e2e/local/boot-process.test.ts new file mode 100644 index 0000000000..f5c43cca20 --- /dev/null +++ b/e2e/local/boot-process.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "@effect/vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + BootProcessExitError, + BootReadinessTimeoutError, + bootProcesses, + isBootReadinessTimeout, + waitForBoot, +} from "../setup/boot"; +import { claimAndBoot, isAddrInUse } from "../src/ports"; + +describe("e2e boot process lifecycle", () => { + it("fails immediately with the boot log when a child exits", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "executor-e2e-boot-")); + const logFile = join(tempDir, "boot.log"); + let readinessProbeAborted = false; + + try { + const processes = bootProcesses( + [ + { + cmd: process.execPath, + args: [ + "-e", + 'console.error("Error: Port 44550 is already in use (EADDRINUSE)"); process.exit(17)', + ], + cwd: tempDir, + logFile, + }, + ], + { label: "lifecycle-test" }, + ); + + const startedAt = Date.now(); + let failure: unknown; + try { + await waitForBoot( + processes, + (signal) => + new Promise((_resolve, reject) => { + signal.addEventListener( + "abort", + () => { + readinessProbeAborted = true; + // oxlint-disable-next-line executor/no-promise-reject -- boundary: the fixture models an abort-aware readiness promise + reject(signal.reason); + }, + { once: true }, + ); + }), + ); + } catch (error) { + failure = error; + } + + expect(failure).toBeInstanceOf(BootProcessExitError); + expect(Date.now() - startedAt, "child exit beats the readiness timeout").toBeLessThan(5_000); + expect(readinessProbeAborted, "the losing readiness probe is cancelled").toBe(true); + expect(isAddrInUse(failure), "the port claimer can retry this boot failure").toBe(true); + const bootFailure = failure as BootProcessExitError; + expect(bootFailure.exitCode).toBe(17); + expect(bootFailure.logTail).toContain("Port 44550 is already in use"); + + await processes.teardown(); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("releases a failed claim before retrying a readiness timeout", async () => { + const envVar = "E2E_BOOT_LIFECYCLE_TEST_PORT"; + let attempts = 0; + const claimedPorts: number[] = []; + + try { + const booted = await claimAndBoot( + [{ envVar, offset: 8, label: "boot lifecycle test" }], + async (ports) => { + attempts += 1; + claimedPorts.push(ports[envVar]!); + if (attempts === 1) { + throw new BootReadinessTimeoutError("http://127.0.0.1:1", 10, "fixture timeout"); + } + return { teardown: async () => {}, value: "ready" }; + }, + { maxAttempts: 2, label: "lifecycle-test", retryWhen: isBootReadinessTimeout }, + ); + + expect(booted.value).toBe("ready"); + expect(attempts).toBe(2); + expect(claimedPorts).toHaveLength(2); + await booted.teardown(); + expect(process.env[envVar], "the successful claim is cleared at teardown").toBeUndefined(); + } finally { + delete process.env[envVar]; + } + }); +}); diff --git a/e2e/local/cli-mcp-daemon-attach-stress.test.ts b/e2e/local/cli-mcp-daemon-attach-stress.test.ts index 95daad7cfe..488c7e13fa 100644 --- a/e2e/local/cli-mcp-daemon-attach-stress.test.ts +++ b/e2e/local/cli-mcp-daemon-attach-stress.test.ts @@ -21,20 +21,24 @@ import { expect } from "@effect/vitest"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { Effect } from "effect"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { mkdtempSync, readdirSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; -import type { Subprocess } from "bun"; import { scenario } from "../src/scenario"; +import { stopAutoSpawnedDaemon } from "./daemon-process"; const repoRoot = fileURLToPath(new URL("../../", import.meta.url)); const testScope = join(repoRoot, "apps/local"); // Generous: a dev-mode daemon boots a Vite dev server, slow under machine load. const readyTimeoutMs = 150_000; -type DaemonProc = Subprocess<"ignore", "pipe", "pipe">; +// vitest runs this suite under NODE, not bun, so the daemon is spawned with +// node:child_process (the rest of the e2e harness does the same). `Bun.spawn` +// here threw `ReferenceError: Bun is not defined` on every run. +type DaemonProc = ChildProcessWithoutNullStreams; const waitForDaemonReady = ( proc: DaemonProc, @@ -44,50 +48,38 @@ const waitForDaemonReady = ( let stdoutBuffer = ""; let stderrBuffer = ""; let settled = false; - const decoder = new TextDecoder(); - const stdout = proc.stdout.getReader(); - const stderr = proc.stderr.getReader(); const deadline = setTimeout(() => { if (settled) return; settled = true; // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: captured daemon stderr rejectReady(new Error(`daemon did not announce ready: ${stderrBuffer}`)); }, readyTimeoutMs); - void (async () => { - while (true) { - const { value, done } = await stderr.read(); - if (done) return; - stderrBuffer += decoder.decode(value); - } - })(); - void (async () => { - while (true) { - const { value, done } = await stdout.read(); - if (done) { - if (!settled) { - settled = true; - clearTimeout(deadline); - // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: captured daemon stderr - rejectReady(new Error(`daemon stdout closed before ready: ${stderrBuffer}`)); - } - return; - } - stdoutBuffer += decoder.decode(value); - const match = /Daemon ready on http:\/\/(?:\[[^\]]+\]|[^:\s]+):(\d+)/.exec(stdoutBuffer); - if (match) { - settled = true; - clearTimeout(deadline); - resolveReady({ port: Number(match[1]), stderr: () => stderrBuffer }); - return; - } + proc.stderr.on("data", (chunk: Buffer) => { + stderrBuffer += chunk.toString(); + }); + proc.stdout.on("data", (chunk: Buffer) => { + if (settled) return; + stdoutBuffer += chunk.toString(); + const match = /Daemon ready on http:\/\/(?:\[[^\]]+\]|[^:\s]+):(\d+)/.exec(stdoutBuffer); + if (match) { + settled = true; + clearTimeout(deadline); + resolveReady({ port: Number(match[1]), stderr: () => stderrBuffer }); } - })(); + }); + proc.stdout.on("close", () => { + if (settled) return; + settled = true; + clearTimeout(deadline); + // oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: captured daemon stderr + rejectReady(new Error(`daemon stdout closed before ready: ${stderrBuffer}`)); + }); }); const spawnDaemon = (dataDir: string): DaemonProc => - Bun.spawn( + spawn( + "bun", [ - "bun", "run", "dev:cli", "daemon", @@ -103,17 +95,24 @@ const spawnDaemon = (dataDir: string): DaemonProc => { cwd: repoRoot, env: { ...process.env, EXECUTOR_DATA_DIR: dataDir }, - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", + stdio: ["ignore", "pipe", "pipe"], + detached: true, }, - ); + ) as DaemonProc; + +const exited = (proc: DaemonProc): Promise => + proc.exitCode !== null || proc.signalCode !== null + ? Promise.resolve() + : new Promise((resolve) => proc.once("exit", () => resolve())); const stopProc = async (proc: DaemonProc): Promise => { - if (proc.exitCode !== null) return; - proc.kill("SIGTERM"); - await Promise.race([proc.exited, Bun.sleep(3000)]); - if (proc.exitCode === null) proc.kill("SIGKILL"); + if (proc.exitCode !== null || proc.signalCode !== null) return; + if (proc.pid) process.kill(-proc.pid, "SIGTERM"); + await Promise.race([exited(proc), new Promise((resolve) => setTimeout(resolve, 3000))]); + if (proc.exitCode === null && proc.signalCode === null && proc.pid) { + process.kill(-proc.pid, "SIGKILL"); + await exited(proc); + } }; const startForegroundDaemon = (dataDir: string) => @@ -187,28 +186,14 @@ const runOneClient = async ( } }; -/** `executor mcp` now ensures a DURABLE (detached) daemon and bridges to it, so a - * cold-start scenario leaves that daemon running. Stop it before removing the - * data dir so the test never leaks an orphan daemon. */ -const stopAutoSpawnedDaemon = (dataDir: string): void => { - try { - const manifest = JSON.parse( - readFileSync(join(dataDir, "server-control", "server.json"), "utf8"), - ) as { pid?: number }; - if (manifest.pid) process.kill(manifest.pid, "SIGTERM"); - } catch { - // no manifest (no daemon spawned) — nothing to stop. - } -}; - const withTempData = Effect.acquireRelease( Effect.sync(() => { const root = mkdtempSync(join(tmpdir(), "executor-mcp-stress-")); return join(root, "data"); }), (dataDir) => - Effect.sync(() => { - stopAutoSpawnedDaemon(dataDir); + Effect.promise(async () => { + await stopAutoSpawnedDaemon(dataDir); rmSync(join(dataDir, ".."), { recursive: true, force: true }); }), ); @@ -321,8 +306,13 @@ scenario( "2", ); - daemon.proc.kill("SIGKILL"); - yield* Effect.promise(() => Promise.race([daemon.proc.exited, Bun.sleep(3000)])); + if (daemon.proc.pid) process.kill(-daemon.proc.pid, "SIGKILL"); + yield* Effect.promise(() => + Promise.race([ + exited(daemon.proc), + new Promise((resolve) => setTimeout(resolve, 3000)), + ]), + ); // The next call must settle (reject) quickly — a 10s bound well under the // scenario timeout catches a hang. @@ -332,7 +322,7 @@ scenario( .callTool({ name: "execute", arguments: { code: "return 3" } }) .then(() => "resolved" as const) .catch(() => "rejected" as const), - Bun.sleep(10_000).then(() => "timeout" as const), + new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 10_000)), ]), ); // eslint-disable-next-line no-console diff --git a/e2e/local/cli-mcp-protocol.test.ts b/e2e/local/cli-mcp-protocol.test.ts index 144883d1fc..0d002eefc7 100644 --- a/e2e/local/cli-mcp-protocol.test.ts +++ b/e2e/local/cli-mcp-protocol.test.ts @@ -11,12 +11,13 @@ import { StdioClientTransport as ModernStdioClientTransport } from "@modelcontex import { Client as LegacyClient } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport as LegacyStdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { Effect } from "effect"; -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { scenario } from "../src/scenario"; +import { stopAutoSpawnedDaemon } from "./daemon-process"; const repoRoot = fileURLToPath(new URL("../../", import.meta.url)); const testScope = join(repoRoot, "apps/local"); @@ -33,28 +34,14 @@ const bridgeCommand = (dataDir: string) => ({ stderr: "pipe" as const, }); -const stopAutoSpawnedDaemon = (dataDir: string): void => { - // The bridge is transient, while its auto-started daemon is detached. Reap - // that owner before deleting this scenario's private data directory. - // oxlint-disable-next-line executor/no-try-catch-or-throw -- cleanup tolerates a bridge that failed before writing its manifest - try { - const manifest = JSON.parse( - readFileSync(join(dataDir, "server-control", "server.json"), "utf8"), - ) as { readonly pid?: number }; - if (manifest.pid) process.kill(manifest.pid, "SIGTERM"); - } catch { - // No manifest means there is no auto-started daemon to stop. - } -}; - const withTempData = Effect.acquireRelease( Effect.sync(() => { const root = mkdtempSync(join(tmpdir(), "executor-mcp-protocol-")); return { root, dataDir: join(root, "data") }; }), ({ root, dataDir }) => - Effect.sync(() => { - stopAutoSpawnedDaemon(dataDir); + Effect.promise(async () => { + await stopAutoSpawnedDaemon(dataDir); rmSync(root, { recursive: true, force: true }); }), ); diff --git a/e2e/local/daemon-process.ts b/e2e/local/daemon-process.ts new file mode 100644 index 0000000000..d7eddb09b4 --- /dev/null +++ b/e2e/local/daemon-process.ts @@ -0,0 +1,55 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +const isAlive = (pid: number): boolean => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: process liveness probing reports false for an already-reaped test daemon + try { + process.kill(process.platform === "win32" ? pid : -pid, 0); + return true; + } catch { + return false; + } +}; + +const signal = (pid: number, name: NodeJS.Signals): void => { + // Auto-started daemons are detached process-group leaders. Signal the whole + // private group so their Vite child cannot survive a failed test. + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: Windows and pre-detach failures require a direct-pid fallback + try { + process.kill(process.platform === "win32" ? pid : -pid, name); + } catch { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- cleanup tolerates a process that exited between the liveness check and signal + try { + process.kill(pid, name); + } catch {} + } +}; + +const waitUntilStopped = async (pid: number, timeoutMs: number): Promise => { + const deadline = performance.now() + timeoutMs; + while (performance.now() < deadline) { + if (!isAlive(pid)) return true; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + return !isAlive(pid); +}; + +/** Stop the detached daemon elected by an `executor mcp` cold start. */ +export const stopAutoSpawnedDaemon = async (dataDir: string): Promise => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- cleanup tolerates a bridge that failed before writing its manifest + try { + const manifest = JSON.parse( + await readFile(join(dataDir, "server-control", "server.json"), "utf8"), + ) as { readonly pid?: unknown }; + if (!Number.isSafeInteger(manifest.pid) || (manifest.pid as number) <= 0) return; + + const pid = manifest.pid as number; + signal(pid, "SIGTERM"); + if (await waitUntilStopped(pid, 10_000)) return; + + signal(pid, "SIGKILL"); + await waitUntilStopped(pid, 2_000); + } catch { + // No manifest means there is no auto-started daemon to stop. + } +}; diff --git a/e2e/local/local-server.ts b/e2e/local/local-server.ts index 10a1fe27a2..798a62cc9f 100644 --- a/e2e/local/local-server.ts +++ b/e2e/local/local-server.ts @@ -68,7 +68,7 @@ export const withLocalServer = ( yield* Effect.all( [ cli.session( - ["bun", "run", "dev:cli", "web", "--foreground", "--port", "0"], + ["bun", "run", "apps/cli/src/main.ts", "web", "--foreground", "--port", "0"], async (term) => { markRecordingStart(runDir, "terminal"); markFocus(runDir, "terminal"); @@ -107,10 +107,16 @@ export const withLocalServer = ( // otherwise the orphaned child wedges the terminal teardown. markFocus(runDir, "terminal"); await term.keyboard.press("Control+C"); + await term.waitForExit({ timeoutMs: 15_000 }); }, { cwd: repoRoot, - env: { EXECUTOR_DATA_DIR: dataDir, EXECUTOR_SCOPE_DIR: dataDir, ...options?.env }, + env: { + EXECUTOR_DEV: "1", + EXECUTOR_DATA_DIR: dataDir, + EXECUTOR_SCOPE_DIR: dataDir, + ...options?.env, + }, record: join(runDir, options?.castName ?? "terminal.cast"), viewport: { cols: 120, rows: 40 }, }, diff --git a/e2e/local/vite-dev-routing.test.ts b/e2e/local/vite-dev-routing.test.ts index 287eb3f8c6..3e0c490311 100644 --- a/e2e/local/vite-dev-routing.test.ts +++ b/e2e/local/vite-dev-routing.test.ts @@ -80,10 +80,11 @@ const startPlainViteDev = async (): Promise => { const dataDir = mkdtempSync(join(tmpdir(), "executor-local-vite-e2e-")); const port = await freePort(); const origin = `http://127.0.0.1:${port}`; + const viteEntrypoint = join(localAppDir, "node_modules", "vite", "bin", "vite.js"); let logs = ""; const child = spawn( - "bunx", - ["--bun", "vite", "dev", "--host", "127.0.0.1", "--port", String(port), "--strictPort"], + "bun", + [viteEntrypoint, "dev", "--host", "127.0.0.1", "--port", String(port), "--strictPort"], { cwd: localAppDir, env: { ...process.env, EXECUTOR_DATA_DIR: dataDir, PORT: String(port) }, diff --git a/e2e/scenarios/resume-after-sandbox-deadline.test.ts b/e2e/scenarios/resume-after-sandbox-deadline.test.ts index 42c9154424..47bccc6235 100644 --- a/e2e/scenarios/resume-after-sandbox-deadline.test.ts +++ b/e2e/scenarios/resume-after-sandbox-deadline.test.ts @@ -8,14 +8,18 @@ // unknown execution. // // The journey drives exactly that shape: ONE execution with TWO approval -// gates. The first approval is granted late in its window (~3.5 min), so the -// second pause's window reaches well past the sandbox's 5-minute mark. The -// second approval arrives ~5.75 min after execution start — inside its OWN -// advertised window, but past the old absolute deadline. Deliberately slow -// (~6 min): the elapsed time IS the subject under test. A single-pause -// variant cannot express this cross-target — hosts that advertise a -// 4-minute window would expire it legitimately before the sandbox clock -// even matters. +// gates. The first approval is granted late (70% of the sandbox budget in), +// so the second pause's window reaches well past the budget. The second +// approval arrives at ~115% of the budget after execution start — inside its +// OWN advertised window, but past the old absolute deadline. The subject is +// that RATIO, not any absolute duration, so the delays scale off the budget +// the target was booted with: selfhost boots with a seconds-long +// EXECUTOR_SANDBOX_TIMEOUT_MS (setup/sandbox-timeout.ts) and proves the race +// in ~25s; a target on the production 5-minute budget runs the original +// ~6-minute journey (the elapsed time IS the subject — nothing is mocked). A +// single-pause variant cannot express this cross-target — hosts that +// advertise a 4-minute window would expire it legitimately before the +// sandbox clock even matters. // // The gate is `policies.create`'s own `requiresApproval` annotation // (hermetic, same device as policy-tool-approval.test.ts); both approvals @@ -29,15 +33,23 @@ import { composePluginApi } from "@executor-js/api/server"; import { scenario } from "../src/scenario"; import { Api, Mcp, Target } from "../src/services"; import { configuredMcpPausedSessionIdleTimeoutMs } from "../setup/mcp-session-timeouts"; +import { configuredSandboxTimeoutMs } from "../setup/sandbox-timeout"; const coreApi = composePluginApi([] as const); -// Grant the first approval at 3.5 min — late but inside its 4-minute window. -// The second pause then opens a fresh window reaching ~7.5 min. -const FIRST_APPROVAL_DELAY_MS = 3.5 * 60_000; -// Grant the second approval 2.25 min later: ~5.75 min after execution start, -// past the sandbox's 5-minute budget but inside the second window. -const SECOND_APPROVAL_DELAY_MS = 2.25 * 60_000; +const SANDBOX_BUDGET_MS = configuredSandboxTimeoutMs(); + +// Grant the first approval at 70% of the budget — late but inside its window +// (was 3.5 of 5 min). The second pause then opens a fresh window reaching +// past the budget. +const FIRST_APPROVAL_DELAY_MS = 0.7 * SANDBOX_BUDGET_MS; +// Grant the second approval 45% of the budget later: ~115% of the budget +// after execution start, past the sandbox clock but inside the second window +// (was 2.25 of 5 min → ~5.75 min total). +const SECOND_APPROVAL_DELAY_MS = 0.45 * SANDBOX_BUDGET_MS; +// The whole journey plus scheduling slack, for the idle-window guard and the +// vitest timeout. +const JOURNEY_MS = FIRST_APPROVAL_DELAY_MS + SECOND_APPROVAL_DELAY_MS; /** Sandbox code that creates two policies through the approval-gated core * tool. Patterns are unique-per-run and match no real tool, so the rules are @@ -56,19 +68,22 @@ const second = await tools.executor.coreTools.policies.create({ return JSON.stringify({ first: first.ok, second: second.ok }); `; -// The journey spans ~6 real minutes of paused waiting, so the host must keep -// the paused session alive that long. The suite's default e2e override shrinks +// The journey spans the whole paused waiting time, so the host must keep the +// paused session alive that long. The suite's default e2e override shrinks // the paused-session idle teardown to seconds (to keep teardown tests fast), // which would evict the session mid-scenario for reasons unrelated to the -// clock under test — require the production-like window instead. +// clock under test — require a window that outlasts the journey instead. +// With a shrunken sandbox budget the journey shrinks too, so even the short +// e2e idle window can suffice; the guard compares the two rather than +// hardcoding either. const PAUSED_IDLE_WINDOW_TOO_SHORT = - configuredMcpPausedSessionIdleTimeoutMs() < 8 * 60_000 - ? `the target's paused-session idle teardown (${configuredMcpPausedSessionIdleTimeoutMs()}ms) evicts the session before this ~6-minute journey completes; boot the target with MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS >= 480000 to run it` + configuredMcpPausedSessionIdleTimeoutMs() < JOURNEY_MS + 60_000 + ? `the target's paused-session idle teardown (${configuredMcpPausedSessionIdleTimeoutMs()}ms) evicts the session before this ${Math.round(JOURNEY_MS / 1000)}s journey completes; boot the target with MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS >= ${JOURNEY_MS + 60_000} or a smaller E2E_SANDBOX_TIMEOUT_MS to run it` : undefined; scenario( "MCP · chained approvals granted within their windows survive the sandbox clock", - { timeout: 480_000, skip: PAUSED_IDLE_WINDOW_TOO_SHORT }, + { timeout: Math.max(120_000, JOURNEY_MS + 120_000), skip: PAUSED_IDLE_WINDOW_TOO_SHORT }, Effect.gen(function* () { const target = yield* Target; const apiSurface = yield* Api; diff --git a/e2e/selfhost/posthog-mcp-oauth.test.ts b/e2e/selfhost/posthog-mcp-oauth.test.ts index 9163831649..49d20349a9 100644 --- a/e2e/selfhost/posthog-mcp-oauth.test.ts +++ b/e2e/selfhost/posthog-mcp-oauth.test.ts @@ -1,8 +1,14 @@ -// Selfhost browser regression for the reported PostHog MCP OAuth dead-end. A -// real Executor instance adds https://mcp.posthog.com/mcp, then starts the -// connection flow. The product guarantee: clicking Connect opens PostHog's -// OAuth authorization page through dynamic client registration, not the -// bring-your-own OAuth app picker with "Automatic setup unavailable". +// Hermetic selfhost browser regression for the reported PostHog MCP OAuth +// dead-end. A real Executor instance adds a wire-level OAuth-protected MCP +// server, then starts the connection flow. The product guarantee: clicking +// Connect reaches the authorization page through dynamic client registration, +// not the bring-your-own OAuth app picker with "Automatic setup unavailable". +// +// This used to call PostHog's production MCP and OAuth sites directly. That +// made Executor CI depend on a third party's availability, metadata, and popup +// response time. The local fixtures implement the same RFC 9728 discovery, +// RFC 8414 metadata, RFC 7591 registration, and authorization redirect over +// real HTTP, while keeping the assertion deterministic. import { randomBytes } from "node:crypto"; import { expect } from "@effect/vitest"; @@ -10,22 +16,28 @@ import { Effect } from "effect"; import { composePluginApi } from "@executor-js/api/server"; import { deriveMcpNamespace } from "@executor-js/plugin-mcp"; import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; +import { makeGreetingMcpServer, serveMcpServerWithOAuth } from "@executor-js/plugin-mcp/testing"; import { IntegrationSlug } from "@executor-js/sdk/shared"; +import { OAuthTestServer } from "@executor-js/sdk/testing"; import { scenario } from "../src/scenario"; import { Api, Browser, Target } from "../src/services"; -const POSTHOG_MCP_URL = "https://mcp.posthog.com/mcp"; const api = composePluginApi([mcpHttpPlugin()] as const); scenario( - "MCP OAuth · PostHog starts OAuth from Add connection", + "MCP OAuth · dynamic registration opens the discovered authorization server", { timeout: 180_000 }, Effect.scoped( Effect.gen(function* () { const target = yield* Target; const browser = yield* Browser; const { client: makeApiClient } = yield* Api; + const oauth = yield* OAuthTestServer; + const server = yield* serveMcpServerWithOAuth( + () => makeGreetingMcpServer({ name: "dcr-regression-mcp" }), + { path: "/mcp" }, + ); const identity = yield* target.newIdentity(); const client = yield* makeApiClient(api, identity); const displayName = `PostHog MCP ${randomBytes(3).toString("hex")}`; @@ -33,9 +45,9 @@ scenario( yield* Effect.gen(function* () { yield* browser.session(identity, async ({ page, step }) => { - await step("Open the add-MCP flow pointed at PostHog", async () => { + await step("Open the add-MCP flow pointed at the OAuth server", async () => { const addUrl = new URL("/integrations/add/mcp", target.baseUrl); - addUrl.searchParams.set("url", POSTHOG_MCP_URL); + addUrl.searchParams.set("url", server.endpoint); await page.goto(addUrl.toString(), { waitUntil: "networkidle" }); await page.getByText("How does this server authenticate?").waitFor({ timeout: 30_000 }); await page.getByText("Method 1 · Detected").waitFor(); @@ -59,26 +71,37 @@ scenario( const popupPromise = page.waitForEvent("popup", { timeout: 30_000 }); await page.getByRole("button", { name: "Connect", exact: true }).click(); const popup = await popupPromise; - await popup.waitForURL(/^https:\/\/oauth\.posthog\.com\/oauth\/authorize\//, { + await popup.waitForURL((url) => url.origin === new URL(oauth.issuerUrl).origin, { timeout: 30_000, }); await popup.waitForLoadState("domcontentloaded", { timeout: 30_000 }); const authorizeUrl = new URL(popup.url()); - expect(authorizeUrl.origin, "OAuth opened PostHog's authorization host").toBe( - "https://oauth.posthog.com", + expect(authorizeUrl.origin, "OAuth opened the discovered authorization host").toBe( + new URL(oauth.authorizationEndpoint).origin, ); - expect(authorizeUrl.pathname, "OAuth opened the authorize endpoint").toBe( - "/oauth/authorize/", - ); - expect( - authorizeUrl.searchParams.get("resource"), - "resource targets the MCP endpoint", - ).toBe(POSTHOG_MCP_URL); await popup.close(); }); }); + + const oauthRequests = yield* oauth.requests; + expect( + oauthRequests.some( + (request) => request.method === "POST" && request.path === "/register", + ), + "the connection flow dynamically registered its OAuth client", + ).toBe(true); + const authorizeRequest = oauthRequests.find( + (request) => request.method === "GET" && request.path === "/authorize", + ); + expect( + authorizeRequest, + "the popup reached the discovered authorize endpoint", + ).toBeDefined(); + expect(authorizeRequest?.query.resource, "resource targets the MCP endpoint").toBe( + server.endpoint, + ); }).pipe(Effect.ensuring(client.mcp.removeServer({ params: { slug } }).pipe(Effect.ignore))); }), - ), + ).pipe(Effect.provide(OAuthTestServer.layer())), ); diff --git a/e2e/setup/boot.ts b/e2e/setup/boot.ts index 2b6b137313..bec463023d 100644 --- a/e2e/setup/boot.ts +++ b/e2e/setup/boot.ts @@ -3,7 +3,46 @@ // what runs (their dev stack, their stub flags); this file only owns process // lifecycle, so it stays target-agnostic. import { spawn, type ChildProcess } from "node:child_process"; -import { openSync } from "node:fs"; +import { closeSync, openSync, readFileSync } from "node:fs"; +import { setTimeout as sleep } from "node:timers/promises"; + +const BOOT_LOG_TAIL_BYTES = 8_000; +const HTTP_PROBE_TIMEOUT_MS = 5_000; + +/** A long-lived boot process exited before its service became ready. */ +export class BootProcessExitError extends Error { + readonly _tag = "BootProcessExitError"; + + constructor( + readonly command: string, + readonly exitCode: number | null, + readonly signal: NodeJS.Signals | null, + readonly logTail: string, + ) { + const outcome = exitCode === null ? `signal ${signal ?? "unknown"}` : `code ${exitCode}`; + const diagnostic = logTail ? `\nLast boot log output:\n${logTail}` : ""; + super(`Boot process ${JSON.stringify(command)} exited with ${outcome}${diagnostic}`); + this.name = "BootProcessExitError"; + } +} + +/** A service process stayed alive but did not become HTTP-ready in time. */ +export class BootReadinessTimeoutError extends Error { + readonly _tag = "BootReadinessTimeoutError"; + + constructor( + readonly url: string, + readonly timeoutMs: number, + readonly lastError: unknown, + ) { + super(`Timed out after ${timeoutMs}ms waiting for ${url}: ${String(lastError)}`); + this.name = "BootReadinessTimeoutError"; + } +} + +/** Whether an acquisition failed because a live process never became ready. */ +export const isBootReadinessTimeout = (error: unknown): boolean => + error instanceof BootReadinessTimeoutError; export interface BootedProcesses { readonly teardown: () => Promise; @@ -11,6 +50,21 @@ export interface BootedProcesses { readonly pids: ReadonlyArray; } +/** A spawned process tree that can report an exit during startup. */ +export interface MonitoredBootedProcesses extends BootedProcesses { + /** Rejects as soon as any child exits before readiness/teardown. */ + readonly unexpectedExit: Promise; +} + +const readLogTail = (logFile: string | undefined): string => { + if (!logFile) return ""; + try { + return readFileSync(logFile, "utf8").slice(-BOOT_LOG_TAIL_BYTES).trim(); + } catch { + return ""; + } +}; + export const bootProcesses = ( procs: ReadonlyArray<{ readonly cmd: string; @@ -21,9 +75,10 @@ export const bootProcesses = ( readonly logFile?: string; }>, options: { readonly label: string }, -): BootedProcesses => { +): MonitoredBootedProcesses => { const children: ChildProcess[] = []; let tearingDown = false; + const unexpectedExits: Array> = []; for (const proc of procs) { const log = proc.logFile ? openSync(proc.logFile, "a") : undefined; const child = spawn(proc.cmd, [...proc.args], { @@ -36,11 +91,35 @@ export const bootProcesses = ( // kill and squat the port into the NEXT invocation's waitForHttp. detached: true, }); - child.on("exit", (code) => { - if (code !== 0 && code !== null && !tearingDown) { - console.error(`[e2e:${options.label}] ${proc.cmd} exited with ${code}`); - } - }); + if (log !== undefined) closeSync(log); + unexpectedExits.push( + new Promise((_resolve, reject) => { + child.once("error", (cause) => { + if (tearingDown) return; + // oxlint-disable-next-line executor/no-promise-reject -- boundary: adapt the child-process error event to the startup race + reject( + new BootProcessExitError( + [proc.cmd, ...proc.args].join(" "), + child.exitCode, + child.signalCode, + `${readLogTail(proc.logFile)}\n${String(cause)}`.trim(), + ), + ); + }); + child.once("exit", (code, signal) => { + if (tearingDown) return; + const error = new BootProcessExitError( + [proc.cmd, ...proc.args].join(" "), + code, + signal, + readLogTail(proc.logFile), + ); + console.error(`[e2e:${options.label}] ${error.message}`); + // oxlint-disable-next-line executor/no-promise-reject -- boundary: adapt the child-process exit event to the startup race + reject(error); + }); + }), + ); children.push(child); } @@ -77,28 +156,58 @@ export const bootProcesses = ( } }, pids: children.flatMap((child) => (child.pid === undefined ? [] : [child.pid])), + unexpectedExit: Promise.race(unexpectedExits), }; }; +/** + * Wait for a boot probe while also observing the spawned process tree. The + * losing readiness probe is aborted, so an early process exit neither hides + * behind the full HTTP timeout nor leaves a polling timer alive. + */ +export const waitForBoot = async ( + processes: MonitoredBootedProcesses, + ready: (signal: AbortSignal) => Promise, +): Promise => { + const controller = new AbortController(); + try { + return await Promise.race([ready(controller.signal), processes.unexpectedExit]); + } finally { + controller.abort(); + } +}; + export const waitForHttp = async ( url: string, - options: { readonly timeoutMs?: number; readonly expectRedirect?: boolean } = {}, + options: { + readonly timeoutMs?: number; + readonly expectRedirect?: boolean; + readonly signal?: AbortSignal; + } = {}, ): Promise => { - const deadline = Date.now() + (options.timeoutMs ?? 90_000); + const timeoutMs = options.timeoutMs ?? 90_000; + const deadline = performance.now() + timeoutMs; let lastError: unknown; - while (Date.now() < deadline) { + while (performance.now() < deadline) { + options.signal?.throwIfAborted(); try { - const response = await fetch(url, { redirect: "manual" }); + const remainingMs = Math.max(1, deadline - performance.now()); + const probeTimeout = AbortSignal.timeout(Math.min(HTTP_PROBE_TIMEOUT_MS, remainingMs)); + const signal = options.signal + ? AbortSignal.any([options.signal, probeTimeout]) + : probeTimeout; + const response = await fetch(url, { redirect: "manual", signal }); // During a cold vite compile /api/* falls back to the SPA's 200 HTML — // expectRedirect waits for the real handler (302) instead. if (options.expectRedirect ? response.status === 302 : response.status < 500) return; lastError = new Error(`status ${response.status}`); } catch (error) { + options.signal?.throwIfAborted(); lastError = error; } - await new Promise((resolve) => setTimeout(resolve, 400)); + await sleep(400, undefined, { signal: options.signal }); } - throw new Error(`timed out waiting for ${url}: ${String(lastError)}`); + throw new BootReadinessTimeoutError(url, timeoutMs, lastError); }; /** diff --git a/e2e/setup/cloud.boot.ts b/e2e/setup/cloud.boot.ts index 3544afabe0..191fdebde3 100644 --- a/e2e/setup/cloud.boot.ts +++ b/e2e/setup/cloud.boot.ts @@ -10,7 +10,7 @@ import { fileURLToPath } from "node:url"; // Vendored fork import (same pattern as mcporter). import { createEmulator } from "@executor-js/emulate"; -import { bootProcesses, waitForHttp } from "./boot"; +import { bootProcesses, waitForBoot, waitForHttp } from "./boot"; import { AUTUMN_PLAN_SEED } from "./autumn-plans"; import { E2E_EXECUTION_RATE_LIMIT } from "./execution-limits"; @@ -168,9 +168,11 @@ export const bootCloud = async (options: CloudBootOptions): Promise try { const local = `http://127.0.0.1:${options.cloudPort}`; - await waitForHttp(local); + await waitForBoot(procs, (signal) => waitForHttp(local, { signal })); // The API plane is ready when login actually redirects to AuthKit. - await waitForHttp(`${local}/api/auth/login`, { expectRedirect: true }); + await waitForBoot(procs, (signal) => + waitForHttp(`${local}/api/auth/login`, { expectRedirect: true, signal }), + ); } catch (error) { await teardown(); throw error; diff --git a/e2e/setup/cloud.globalsetup.ts b/e2e/setup/cloud.globalsetup.ts index 648abe568d..98433ca835 100644 --- a/e2e/setup/cloud.globalsetup.ts +++ b/e2e/setup/cloud.globalsetup.ts @@ -8,7 +8,7 @@ import { resolve } from "node:path"; import { claimAndBoot } from "../src/ports"; import { E2E_COOKIE_PASSWORD, E2E_WORKOS_CLIENT_ID } from "../targets/cloud"; -import { waitForHttp } from "./boot"; +import { isBootReadinessTimeout, waitForHttp } from "./boot"; import { bootCloud } from "./cloud.boot"; import { ensureE2eMcpSessionTimeoutEnv } from "./mcp-session-timeouts"; import { bootMotel, motelExporterEnv } from "./motel"; @@ -93,7 +93,7 @@ export default async function setup(): Promise<(() => Promise) | void> { }); return { teardown: cloud.teardown, value: cloud }; }, - { label: "cloud" }, + { label: "cloud", retryWhen: isBootReadinessTimeout }, ); // Publish the Autumn emulator URL to the test workers (they inherit this // process's env): scenarios that assert on tracked usage yield the Autumn diff --git a/e2e/setup/cloudflare.boot.ts b/e2e/setup/cloudflare.boot.ts index b883084f28..fa0eb70ce1 100644 --- a/e2e/setup/cloudflare.boot.ts +++ b/e2e/setup/cloudflare.boot.ts @@ -9,7 +9,7 @@ import { execFile } from "node:child_process"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; -import { bootProcesses, waitForHttp, type BootedProcesses } from "./boot"; +import { bootProcesses, waitForBoot, waitForHttp, type BootedProcesses } from "./boot"; export const cloudflareDir = fileURLToPath(new URL("../../apps/host-cloudflare/", import.meta.url)); const wranglerBin = fileURLToPath( @@ -63,7 +63,12 @@ export const bootCloudflare = async (options: CloudflareBootOptions): Promise + waitForHttp(`http://127.0.0.1:${options.port}/api/account/me`, { + timeoutMs: 120_000, + signal, + }), + ); } catch (error) { await procs.teardown(); throw error; diff --git a/e2e/setup/motel.ts b/e2e/setup/motel.ts index ad14e8ec4c..1c10bfa441 100644 --- a/e2e/setup/motel.ts +++ b/e2e/setup/motel.ts @@ -8,7 +8,7 @@ import { mkdirSync, rmSync } from "node:fs"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; -import { bootProcesses, waitForHttp, type BootedProcesses } from "./boot"; +import { bootProcesses, waitForBoot, waitForHttp, type MonitoredBootedProcesses } from "./boot"; export const MOTEL_PORT = 4796; export const MOTEL_URL = `http://127.0.0.1:${MOTEL_PORT}`; @@ -28,7 +28,7 @@ export const bootMotel = async (): Promise => { rmSync(dataDir, { recursive: true, force: true }); mkdirSync(dataDir, { recursive: true }); - let procs: BootedProcesses | null = null; + let procs: MonitoredBootedProcesses | null = null; // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: optional infrastructure; a motel-less host still runs the suite try { procs = bootProcesses( @@ -45,7 +45,7 @@ export const bootMotel = async (): Promise => { ], { label: "motel" }, ); - await waitForHttp(`${MOTEL_URL}/api/health`); + await waitForBoot(procs, (signal) => waitForHttp(`${MOTEL_URL}/api/health`, { signal })); console.log(`[e2e] traces → suite motel at ${MOTEL_URL}`); return { url: MOTEL_URL, teardown: procs.teardown }; } catch (error) { diff --git a/e2e/setup/sandbox-timeout.ts b/e2e/setup/sandbox-timeout.ts new file mode 100644 index 0000000000..51a85df37d --- /dev/null +++ b/e2e/setup/sandbox-timeout.ts @@ -0,0 +1,28 @@ +// The sandbox execution budget shared between a target's boot env and the +// sandbox-deadline scenario, so they cannot drift apart (same pattern as +// execution-limits.ts). The scenario proves a RATIO — approvals granted +// inside their own windows survive an execution that outlives the sandbox's +// absolute budget — so the budget's magnitude is free to shrink: on selfhost +// the boot recipe passes E2E_SANDBOX_TIMEOUT_MS through to the server as +// EXECUTOR_SANDBOX_TIMEOUT_MS and the scenario scales its approval delays to +// match, turning a ~6-minute real-time wait into seconds. Targets that cannot +// shrink the budget (cloud's dynamic-worker deadline is not env-tunable) run +// against the production default and skip via their paused-session window +// guard instead. +export const E2E_SANDBOX_TIMEOUT_MS = 20_000; + +export const SANDBOX_TIMEOUT_ENV = "E2E_SANDBOX_TIMEOUT_MS"; + +const PRODUCTION_SANDBOX_TIMEOUT_MS = 5 * 60_000; + +const positiveMilliseconds = (raw: string | undefined): number | undefined => { + if (!raw) return undefined; + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) return undefined; + return Math.floor(parsed); +}; + +/** The sandbox budget the current target enforces: the harness override when + * the target was booted with one, else the production default. */ +export const configuredSandboxTimeoutMs = (): number => + positiveMilliseconds(process.env[SANDBOX_TIMEOUT_ENV]) ?? PRODUCTION_SANDBOX_TIMEOUT_MS; diff --git a/e2e/setup/selfhost.boot.ts b/e2e/setup/selfhost.boot.ts index 6f0d743d7b..90b29bc87d 100644 --- a/e2e/setup/selfhost.boot.ts +++ b/e2e/setup/selfhost.boot.ts @@ -5,7 +5,7 @@ import { rmSync } from "node:fs"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { bootProcesses, waitForHttp, type BootedProcesses } from "./boot"; +import { bootProcesses, waitForBoot, waitForHttp, type BootedProcesses } from "./boot"; export const selfhostDir = fileURLToPath(new URL("../../apps/host-selfhost/", import.meta.url)); @@ -21,6 +21,9 @@ export interface SelfhostBootOptions { /** vite --host (e.g. "0.0.0.0" to be tailnet-reachable). */ readonly host?: string; readonly logFile?: string; + /** Shrink the sandbox execution budget (EXECUTOR_SANDBOX_TIMEOUT_MS) so + * deadline scenarios prove their race in seconds. Omit for production. */ + readonly sandboxTimeoutMs?: number; } export const bootSelfhost = async (options: SelfhostBootOptions): Promise => { @@ -51,6 +54,9 @@ export const bootSelfhost = async (options: SelfhostBootOptions): Promise + waitForHttp(`http://localhost:${options.port}`, { signal }), + ); } catch (error) { await procs.teardown(); throw error; diff --git a/e2e/setup/selfhost.globalsetup.ts b/e2e/setup/selfhost.globalsetup.ts index 01a5f4e803..39bd59081f 100644 --- a/e2e/setup/selfhost.globalsetup.ts +++ b/e2e/setup/selfhost.globalsetup.ts @@ -7,7 +7,8 @@ import { resolve } from "node:path"; import { claimAndBoot } from "../src/ports"; import { SELFHOST_ADMIN } from "../targets/selfhost"; -import { waitForHttp } from "./boot"; +import { isBootReadinessTimeout, waitForHttp } from "./boot"; +import { E2E_SANDBOX_TIMEOUT_MS, SANDBOX_TIMEOUT_ENV } from "./sandbox-timeout"; import { bootSelfhost } from "./selfhost.boot"; import { RUNS_DIR } from "../src/scenario"; @@ -37,6 +38,10 @@ export default async function setup(): Promise<(() => Promise) | void> { [{ envVar: "E2E_SELFHOST_PORT", offset: 4, label: "selfhost vite dev" }], async (ports) => { const port = ports.E2E_SELFHOST_PORT!; + // Shrink the sandbox execution budget and publish the value to the test + // workers (spawned after this globalsetup, so they inherit the env): the + // sandbox-deadline scenario reads it to scale its approval delays. + process.env[SANDBOX_TIMEOUT_ENV] = String(E2E_SANDBOX_TIMEOUT_MS); // Fresh data dir per suite run — hermetic; in-suite isolation comes from // fresh identities, not resets (bootSelfhost wipes it). const procs = await bootSelfhost({ @@ -44,10 +49,11 @@ export default async function setup(): Promise<(() => Promise) | void> { webBaseUrl: `http://localhost:${port}`, admin: SELFHOST_ADMIN, logFile: bootLogFile, + sandboxTimeoutMs: E2E_SANDBOX_TIMEOUT_MS, }); return { teardown: procs.teardown, value: procs }; }, - { label: "selfhost" }, + { label: "selfhost", retryWhen: isBootReadinessTimeout }, ); return teardown; } diff --git a/e2e/src/ports.ts b/e2e/src/ports.ts index 7176465e41..811f7f4fc1 100644 --- a/e2e/src/ports.ts +++ b/e2e/src/ports.ts @@ -207,6 +207,16 @@ export const claimPorts = async (claims: ReadonlyArray): Promise { + // Values published by this claim are not operator pins. Clear them + // when the acquisition is released so a failed `claimAndBoot` attempt + // can genuinely probe and claim again instead of treating its own + // stale E2E_* value as an explicit override on every retry. + for (const claim of unpinned) { + const published = ports[claim.envVar]; + if (published !== undefined && process.env[claim.envVar] === String(published)) { + delete process.env[claim.envVar]; + } + } const held = heldLocks.get(block); if (!held) return; heldLocks.delete(block); @@ -238,8 +248,9 @@ export const isAddrInUse = (error: unknown): boolean => { * ephemeral) an outbound socket can still grab a just-released probe port before * the service binds it. When that happens the boot throws EADDRINUSE; we release * the block (freeing its lock so `claimPorts` walks past it) and re-claim + retry - * up to `maxAttempts` times. Any non-EADDRINUSE boot failure — or exhausting the - * retries — releases and rethrows, so a genuinely broken boot still surfaces. + * up to `maxAttempts` times. Callers may also classify another idempotent + * acquisition failure with `retryWhen` (for example, a bounded Vite readiness + * timeout). Unclassified failures and exhausted retries surface unchanged. * * `boot` receives the freshly claimed ports and must return its teardown; the * returned `teardown` chains the caller's teardown then releases the block. @@ -247,7 +258,12 @@ export const isAddrInUse = (error: unknown): boolean => { export const claimAndBoot = async ( claims: ReadonlyArray, boot: (ports: Record) => Promise<{ teardown: () => Promise; value: T }>, - options: { readonly maxAttempts?: number; readonly label?: string } = {}, + options: { + readonly maxAttempts?: number; + readonly label?: string; + /** Additional acquisition failures that are safe to retry from scratch. */ + readonly retryWhen?: (error: unknown) => boolean; + } = {}, ): Promise<{ ports: Record; teardown: () => Promise; value: T }> => { const maxAttempts = options.maxAttempts ?? 3; const label = options.label ?? "boot"; @@ -267,13 +283,15 @@ export const claimAndBoot = async ( } catch (error) { await release(); lastError = error; - if (!isAddrInUse(error) || attempt === maxAttempts) throw error; + const retryable = isAddrInUse(error) || options.retryWhen?.(error) === true; + if (!retryable || attempt === maxAttempts) throw error; const collided = claims .map((claim) => ports[claim.envVar]) .filter((port): port is number => port !== undefined) .join(", "); + const reason = isAddrInUse(error) ? `hit EADDRINUSE on port(s) ${collided}` : String(error); console.warn( - `[e2e] ${label} hit EADDRINUSE on port(s) ${collided} (attempt ${attempt}/${maxAttempts}); re-claiming a fresh block and retrying`, + `[e2e] ${label} acquisition failed (${reason}, attempt ${attempt}/${maxAttempts}); re-claiming a fresh block and retrying`, ); } } diff --git a/packages/hosts/mcp/src/stdio-integration.test.ts b/packages/hosts/mcp/src/stdio-integration.test.ts index 5f6e03482f..8b831b1b4b 100644 --- a/packages/hosts/mcp/src/stdio-integration.test.ts +++ b/packages/hosts/mcp/src/stdio-integration.test.ts @@ -4,8 +4,8 @@ import { StdioClientTransport as ModernStdioClientTransport } from "@modelcontex import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; -import { Effect } from "effect"; -import { mkdtempSync } from "node:fs"; +import { Effect, Schema } from "effect"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -17,6 +17,60 @@ const stdioServer = { command: "bun", args: ["run", stdioServerEntry], }; +const decodeDaemonManifest = Schema.decodeUnknownSync( + Schema.fromJsonString(Schema.Struct({ pid: Schema.Number })), +); + +const isProcessGroupAlive = (pid: number): boolean => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: process liveness probing reports false after the test daemon is reaped + try { + process.kill(process.platform === "win32" ? pid : -pid, 0); + return true; + } catch { + return false; + } +}; + +const signalProcessGroup = (pid: number, signal: NodeJS.Signals): void => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: Windows and pre-detach failures require a direct-pid fallback + try { + process.kill(process.platform === "win32" ? pid : -pid, signal); + } catch { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- cleanup tolerates a daemon that exited between liveness check and signal + try { + process.kill(pid, signal); + } catch {} + } +}; + +const stopAutoSpawnedDaemon = async (dataDir: string): Promise => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- cleanup tolerates startup that failed before publishing a manifest + try { + const manifest = decodeDaemonManifest( + readFileSync(join(dataDir, "server-control", "server.json"), "utf8"), + ); + if (!Number.isSafeInteger(manifest.pid) || manifest.pid <= 0) return; + + const pid = manifest.pid; + signalProcessGroup(pid, "SIGTERM"); + const deadline = performance.now() + 10_000; + while (isProcessGroupAlive(pid) && performance.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 50)); + } + if (isProcessGroupAlive(pid)) signalProcessGroup(pid, "SIGKILL"); + } catch { + // No manifest means there is no auto-started daemon to stop. + } +}; + +const withTempData = Effect.acquireRelease( + Effect.sync(() => mkdtempSync(join(tmpdir(), "executor-mcp-test-"))), + (dataDir) => + Effect.promise(async () => { + await stopAutoSpawnedDaemon(dataDir); + rmSync(dataDir, { recursive: true, force: true }); + }), +); describe("MCP stdio integration", () => { it.effect( @@ -25,7 +79,7 @@ describe("MCP stdio integration", () => { Effect.gen(function* () { // Fresh temp dir so the test doesn't migrate against the developer's // real ~/.executor/data.db. - const dataDir = mkdtempSync(join(tmpdir(), "executor-mcp-test-")); + const dataDir = yield* withTempData; const transport = new StdioClientTransport({ command: "bun", args: ["run", cliEntry, "mcp", "--scope", testScope], From 63ea028bbb392dfd61bbc6ffdec79a318d4954b8 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:27:50 -0700 Subject: [PATCH 031/133] Add Slack first-party OAuth (#1640) --- apps/cloud/src/engine/execution-stack.ts | 21 ++++++++- apps/cloud/src/env-augment.d.ts | 2 + packages/core/sdk/src/oauth-client.ts | 10 +++- .../core/sdk/src/oauth-first-party.test.ts | 7 ++- .../core/sdk/src/oauth-scope-union.test.ts | 46 +++++++++++++++++++ packages/core/sdk/src/oauth-service.ts | 39 +++++++++------- .../react/src/components/oauth-app-setup.ts | 33 ++----------- packages/react/src/lib/slack-mcp-oauth.ts | 32 +++++++++++++ 8 files changed, 141 insertions(+), 49 deletions(-) create mode 100644 packages/react/src/lib/slack-mcp-oauth.ts diff --git a/apps/cloud/src/engine/execution-stack.ts b/apps/cloud/src/engine/execution-stack.ts index 9846ca84cf..5120f02b3d 100644 --- a/apps/cloud/src/engine/execution-stack.ts +++ b/apps/cloud/src/engine/execution-stack.ts @@ -43,8 +43,13 @@ import { collectTables, } from "@executor-js/api/server"; import { googleCatalogOAuthScopesForPreset } from "@executor-js/plugin-openapi/providers/google"; +import { slackMcpUserScopes } from "@executor-js/react/lib/slack-mcp-oauth"; import { makeDynamicWorkerExecutor } from "@executor-js/runtime-dynamic-worker"; -import type { AnyPlugin, FirstPartyOAuthClientConfig } from "@executor-js/sdk"; +import { + IntegrationSlug, + type AnyPlugin, + type FirstPartyOAuthClientConfig, +} from "@executor-js/sdk"; import executorConfig from "../../executor.config"; import { DbService } from "../db/db"; @@ -136,6 +141,20 @@ const cloudFirstPartyOAuthClients = (): readonly FirstPartyOAuthClientConfig[] = }, ] : []), + ...(env.FIRST_PARTY_SLACK_CLIENT_ID && env.FIRST_PARTY_SLACK_CLIENT_SECRET + ? [ + { + name: "slack", + authorizationUrl: "https://slack.com/oauth/v2_user/authorize", + tokenUrl: "https://slack.com/api/oauth.v2.user.access", + resource: "https://mcp.slack.com", + clientId: env.FIRST_PARTY_SLACK_CLIENT_ID, + clientSecret: env.FIRST_PARTY_SLACK_CLIENT_SECRET, + integrations: [IntegrationSlug.make("slack")], + allowedScopes: slackMcpUserScopes, + }, + ] + : []), ]; export const CloudHostConfig: Layer.Layer = Layer.sync(HostConfig, () => ({ diff --git a/apps/cloud/src/env-augment.d.ts b/apps/cloud/src/env-augment.d.ts index c9d76063e2..6a42130373 100644 --- a/apps/cloud/src/env-augment.d.ts +++ b/apps/cloud/src/env-augment.d.ts @@ -67,6 +67,8 @@ declare global { FIRST_PARTY_GITHUB_TOKEN_URL?: string; FIRST_PARTY_GOOGLE_CLIENT_ID?: string; FIRST_PARTY_GOOGLE_CLIENT_SECRET?: string; + FIRST_PARTY_SLACK_CLIENT_ID?: string; + FIRST_PARTY_SLACK_CLIENT_SECRET?: string; // Billing AUTUMN_SECRET_KEY?: string; diff --git a/packages/core/sdk/src/oauth-client.ts b/packages/core/sdk/src/oauth-client.ts index 613b09e5cd..3e6b034679 100644 --- a/packages/core/sdk/src/oauth-client.ts +++ b/packages/core/sdk/src/oauth-client.ts @@ -122,13 +122,19 @@ export interface FirstPartyOAuthClientConfig { readonly clientId: string; /** Literal secret from host env. Empty string for a public/PKCE client. */ readonly clientSecret: string; + /** RFC 8707 protected resource for MCP-style providers. Required when the + * integration discovers its OAuth scopes from resource metadata. */ + readonly resource?: string | null; /** Integrations this app is intended for, used by pickers to rank it as the * exact-match default for those integrations. Endpoint-host matching still * applies when omitted. */ readonly integrations?: readonly IntegrationSlug[]; /** OAuth scopes this deployment permits the app to request. Omit to allow - * every scope declared by a matching integration. When present, OAuth start - * and completion fail unless every requested scope belongs to this set. */ + * every scope declared by a matching integration. For declared scopes, + * start and completion fail unless every requested scope belongs to this + * set. For MCP-style discovery, the provider's advertised scope catalog is + * capped to this set because it may include capabilities the registered app + * was not approved for. */ readonly allowedScopes?: readonly string[]; } diff --git a/packages/core/sdk/src/oauth-first-party.test.ts b/packages/core/sdk/src/oauth-first-party.test.ts index cd081d83be..f6ab846a68 100644 --- a/packages/core/sdk/src/oauth-first-party.test.ts +++ b/packages/core/sdk/src/oauth-first-party.test.ts @@ -220,7 +220,11 @@ describe("first-party oauth clients", () => { const { executor } = yield* makeTestWorkspaceHarness({ plugins, firstPartyOAuthClients: [ - { ...firstPartyClientFor(server), allowedScopes: ["openid", "read"] }, + { + ...firstPartyClientFor(server), + resource: server.resourceUrl, + allowedScopes: ["openid", "read"], + }, ], }); yield* executor.acme.seed(); @@ -244,6 +248,7 @@ describe("first-party oauth clients", () => { allowedScopes: ["openid", "read"], }); expect(firstParty.clientId).toBe("test-client"); + expect(firstParty.resource).toBe(server.resourceUrl); expect("clientSecret" in firstParty).toBe(false); }), ), diff --git a/packages/core/sdk/src/oauth-scope-union.test.ts b/packages/core/sdk/src/oauth-scope-union.test.ts index 744e6f9412..65db26ddbd 100644 --- a/packages/core/sdk/src/oauth-scope-union.test.ts +++ b/packages/core/sdk/src/oauth-scope-union.test.ts @@ -11,6 +11,7 @@ import { ToolName, } from "./ids"; import type { AuthMethodDescriptor } from "./integration"; +import { firstPartyOAuthClientSlug } from "./oauth-client"; import { definePlugin, type IntegrationRecord } from "./plugin"; import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; import { serveTestHttpApp } from "./testing"; @@ -532,6 +533,51 @@ describe("oauth.start integration-driven scopes", () => { ), ); + it.effect( + "(i) a scope-limited first-party MCP app caps the provider's advertised scope catalog", + () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveMetadataServer({ + prm: { scopesSupported: ["read", "write", "admin"] }, + }); + const plugins = [ + memoryCredentialsPlugin(), + makeMcpScopePlugin({ scopes: null }), + ] as const; + const { executor } = yield* makeTestWorkspaceHarness({ + plugins, + firstPartyOAuthClients: [ + { + name: "acme", + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + resource: server.mcpResourceUrl, + clientId: "test-client", + clientSecret: "test-secret", + integrations: [INTEG], + allowedScopes: ["read", "write"], + }, + ], + }); + yield* executor.mcp.seed(); + + const started = yield* executor.oauth.start({ + owner: "org", + client: firstPartyOAuthClientSlug("acme"), + clientOwner: "org", + name: ConnectionName.make("main"), + integration: INTEG, + template: TEMPLATE, + }); + expect(started.status).toBe("redirect"); + if (started.status !== "redirect") return; + + expect(scopesFromAuthorizeUrl(started.authorizationUrl)).toEqual(["read", "write"]); + }), + ), + ); + it.effect("(j) caps server-advertised resource scopes so the authorize URL stays bounded", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index 186631c916..ecdf18fe75 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -513,7 +513,7 @@ export const loadedFirstPartyClient = ( readonly grant: OAuthGrant; readonly clientId: string; readonly clientSecret: string; - readonly resource: null; + readonly resource: string | null; } => ({ slug: String(firstPartyOAuthClientSlug(config.name)), authorizationUrl: config.authorizationUrl, @@ -521,7 +521,7 @@ export const loadedFirstPartyClient = ( grant: "authorization_code", clientId: config.clientId, clientSecret: config.clientSecret, - resource: null, + resource: config.resource ?? null, }); export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { @@ -1026,7 +1026,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { grant: "authorization_code", authorizationUrl: config.authorizationUrl, tokenUrl: config.tokenUrl, - resource: null, + resource: config.resource ?? null, clientId: config.clientId, origin: { kind: "first_party", @@ -1213,25 +1213,32 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { }), ), ); + const firstParty = firstPartyFlow ? firstPartyBySlug.get(String(input.client)) : undefined; const requestedScopes = scopePolicy.kind === "discover" - ? yield* discoverScopesForResource(client.resource).pipe( - Effect.mapError( - (cause) => - new OAuthStartError({ - // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: OAuthDiscoveryError carries a typed `message` field - message: `Failed to discover OAuth scopes: ${cause.message}`, - }), - ), - ) + ? yield* (() => { + const discovered = discoverScopesForResource(client.resource).pipe( + Effect.mapError( + (cause) => + new OAuthStartError({ + // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: OAuthDiscoveryError carries a typed `message` field + message: `Failed to discover OAuth scopes: ${cause.message}`, + }), + ), + ); + if (firstParty?.allowedScopes === undefined) return discovered; + const allowed = new Set(firstParty.allowedScopes); + return discovered.pipe( + Effect.map((scopes) => scopes.filter((scope) => allowed.has(scope))), + ); + })() : dedupeScopes(scopePolicy.scopes); // An explicitly scope-limited first-party app is an authorization - // boundary, not picker decoration. Endpoint matching can associate one - // Google client with every Google API, so enforce the complete requested - // set here before persisting an OAuth session or redirecting the browser. + // boundary, not picker decoration. Endpoint matching and provider + // discovery can surface capabilities outside the registered app, so + // enforce the complete requested set before persisting or redirecting. if (firstPartyFlow) { - const firstParty = firstPartyBySlug.get(String(input.client)); if ( firstParty !== undefined && !firstPartyOAuthClientAllowsScopes(firstParty, requestedScopes) diff --git a/packages/react/src/components/oauth-app-setup.ts b/packages/react/src/components/oauth-app-setup.ts index 1c54138304..6594bcf7cb 100644 --- a/packages/react/src/components/oauth-app-setup.ts +++ b/packages/react/src/components/oauth-app-setup.ts @@ -1,3 +1,7 @@ +import { slackMcpUserScopes } from "../lib/slack-mcp-oauth"; + +export { slackMcpUserScopes } from "../lib/slack-mcp-oauth"; + export interface OAuthAppSetup { readonly id: string; readonly title: string; @@ -29,35 +33,6 @@ interface SlackManifest { }; } -export const slackMcpUserScopes = [ - "search:read.public", - "search:read.private", - "search:read.mpim", - "search:read.im", - "search:read.files", - "search:read.users", - "chat:write", - "channels:history", - "groups:history", - "mpim:history", - "im:history", - "canvases:read", - "canvases:write", - "users:read", - "users:read.email", - "reactions:write", - "reactions:read", - "emoji:read", - "files:read", - "channels:write", - "groups:write", - "im:write", - "mpim:write", - "channels:read", - "groups:read", - "mpim:read", -] as const; - const slackManifest = (callbackUrl: string): SlackManifest => ({ display_information: { name: "Executor" }, oauth_config: { diff --git a/packages/react/src/lib/slack-mcp-oauth.ts b/packages/react/src/lib/slack-mcp-oauth.ts new file mode 100644 index 0000000000..99cee542a8 --- /dev/null +++ b/packages/react/src/lib/slack-mcp-oauth.ts @@ -0,0 +1,32 @@ +/** User scopes approved on Executor's Slack MCP OAuth app and embedded in the + * BYO-app creation manifest. Cloud uses the same list as its first-party + * discovery cap so Slack metadata cannot expand the requested grant beyond + * the provider-side registration. */ +export const slackMcpUserScopes = [ + "search:read.public", + "search:read.private", + "search:read.mpim", + "search:read.im", + "search:read.files", + "search:read.users", + "chat:write", + "channels:history", + "groups:history", + "mpim:history", + "im:history", + "canvases:read", + "canvases:write", + "users:read", + "users:read.email", + "reactions:write", + "reactions:read", + "emoji:read", + "files:read", + "channels:write", + "groups:write", + "im:write", + "mpim:write", + "channels:read", + "groups:read", + "mpim:read", +] as const; From 5e1875f5530b3fc76811c2386af761ca048c7883 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:47:52 -0700 Subject: [PATCH 032/133] Show first-party OAuth for MCP discovery (#1641) --- .../src/components/add-account-modal.tsx | 3 ++ .../use-effective-oauth-client.test.ts | 32 +++++++++++++++++++ .../plugins/use-effective-oauth-client.tsx | 16 ++++++++-- 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/packages/react/src/components/add-account-modal.tsx b/packages/react/src/components/add-account-modal.tsx index 2db411754c..7376a39890 100644 --- a/packages/react/src/components/add-account-modal.tsx +++ b/packages/react/src/components/add-account-modal.tsx @@ -1517,6 +1517,9 @@ function AddAccountModalView(props: AddAccountModalProps) { tokenUrl: method?.oauth?.tokenUrl ?? oauthFallbackProbe?.tokenUrl, authorizationUrl: method?.oauth?.authorizationUrl ?? oauthFallbackProbe?.authorizationUrl, scopes: method?.oauth?.scopes, + // MCP OAuth scopes are discovered by the server at connect time, where a + // first-party app's configured allow-list caps the provider's catalog. + discoversScopes: isDcr, // Recorded intent: a manual app registered from THIS integration's dialog is // a tier-1 match regardless of host. integration, diff --git a/packages/react/src/plugins/use-effective-oauth-client.test.ts b/packages/react/src/plugins/use-effective-oauth-client.test.ts index d240298a7d..a204fb2ae3 100644 --- a/packages/react/src/plugins/use-effective-oauth-client.test.ts +++ b/packages/react/src/plugins/use-effective-oauth-client.test.ts @@ -316,6 +316,38 @@ describe("selectClientsForEndpoints", () => { expect(result.unmatched).toEqual([]); }); + it("only allows unknown first-party scopes on the provider-discovery path", () => { + const integration = IntegrationSlug.make("slack_mcp"); + const firstParty = app("first-party:slack", { + owner: "org", + authorizationUrl: "https://slack.com/oauth/v2_user/authorize", + tokenUrl: "https://slack.com/api/oauth.v2.user.access", + origin: { + kind: "first_party", + allowedScopes: ["search:read.public", "chat:write"], + }, + }); + const endpoints = { + authorizationUrl: "https://slack.com/oauth/v2_user/authorize", + tokenUrl: "https://slack.com/api/oauth.v2.user.access", + integration, + requireEndpointMatch: true, + } as const; + + const staticUnknown = selectClientsForEndpoints([firstParty], endpoints); + expect(staticUnknown.endpointMatched).toBe(false); + expect(staticUnknown.matched).toEqual([]); + + const discovered = selectClientsForEndpoints([firstParty], { + ...endpoints, + discoversScopes: true, + }); + expect(discovered.endpointMatched).toBe(true); + expect(discovered.matched.map((a: OAuthClientOption) => String(a.slug))).toEqual([ + "first-party:slack", + ]); + }); + it("intent-matches a first-party app to its declared integrations even without endpoints", () => { const integration = IntegrationSlug.make("github_rest"); const firstParty = app("first-party:github", { diff --git a/packages/react/src/plugins/use-effective-oauth-client.tsx b/packages/react/src/plugins/use-effective-oauth-client.tsx index 8c8fae9013..9f898e9c61 100644 --- a/packages/react/src/plugins/use-effective-oauth-client.tsx +++ b/packages/react/src/plugins/use-effective-oauth-client.tsx @@ -59,9 +59,13 @@ export const isFirstPartyClient = (app: OAuthClientOption): boolean => const firstPartyClientAllowsScopes = ( app: OAuthClientOption, requestedScopes: readonly string[] | undefined, + discoversScopes: boolean, ): boolean => { if (app.origin.kind !== "first_party" || app.origin.allowedScopes === undefined) return true; - if (requestedScopes === undefined) return false; + // MCP providers discover their scopes at OAuth start. The server intersects + // that catalog with this same allow-list before redirecting, so an absent + // static scope list is safe only on the explicit discovery path. + if (requestedScopes === undefined) return discoversScopes; const allowed = new Set(app.origin.allowedScopes); return requestedScopes.every((scope) => allowed.has(scope)); }; @@ -178,6 +182,9 @@ export function selectClientsForEndpoints( /** Complete scope set declared by the selected OAuth auth method. Used to * hide scope-limited first-party apps that the host will reject. */ readonly scopes?: readonly string[]; + /** The OAuth service discovers provider scopes at connect time and caps a + * first-party app's result to its configured allow-list. */ + readonly discoversScopes?: boolean; /** When set, an integration that targets a SPECIFIC server (MCP, whose * endpoints are discovered at connect) must match by endpoint — absent * endpoints mean NO match (show the register CTA), never "every app @@ -192,7 +199,9 @@ export function selectClientsForEndpoints( } { // DCR clients are plumbing, never picker options. const manual = all.filter( - (app) => !isDcrClient(app) && firstPartyClientAllowsScopes(app, endpoints.scopes), + (app) => + !isDcrClient(app) && + firstPartyClientAllowsScopes(app, endpoints.scopes, endpoints.discoversScopes === true), ); const intent = endpoints.integration; @@ -281,6 +290,7 @@ export function useOAuthClientsForIntegration(opts: { readonly authorizationUrl?: string; readonly integration?: IntegrationSlug; readonly scopes?: readonly string[]; + readonly discoversScopes?: boolean; readonly requireEndpointMatch?: boolean; }): UseOAuthClientsResult { // Read the optimistic list so a just-registered/edited/removed app paints @@ -303,6 +313,7 @@ export function useOAuthClientsForIntegration(opts: { authorizationUrl: opts.authorizationUrl, integration: opts.integration, scopes: opts.scopes, + discoversScopes: opts.discoversScopes, requireEndpointMatch: opts.requireEndpointMatch, }), [ @@ -311,6 +322,7 @@ export function useOAuthClientsForIntegration(opts: { opts.authorizationUrl, opts.integration, opts.scopes, + opts.discoversScopes, opts.requireEndpointMatch, ], ); From 32206c7f78654f638bfd27c25c71c30c3d6354be Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:54:57 -0700 Subject: [PATCH 033/133] Preserve OAuth scopes on spec refresh (#1642) --- .changeset/preserve-google-refresh-scopes.md | 5 + .../google/spec-format-adapter.test.ts | 105 ++++++++++++++++++ packages/plugins/openapi/src/sdk/plugin.ts | 13 +-- 3 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 .changeset/preserve-google-refresh-scopes.md diff --git a/.changeset/preserve-google-refresh-scopes.md b/.changeset/preserve-google-refresh-scopes.md new file mode 100644 index 0000000000..1863dae042 --- /dev/null +++ b/.changeset/preserve-google-refresh-scopes.md @@ -0,0 +1,5 @@ +--- +"@executor-js/plugin-openapi": patch +--- + +Preserve an integration's selected OAuth consent scopes when refreshing converted API specifications, so Google Gmail refreshes do not restore operations that require broader scopes. diff --git a/packages/plugins/openapi/src/providers/google/spec-format-adapter.test.ts b/packages/plugins/openapi/src/providers/google/spec-format-adapter.test.ts index fddde3faa5..f06cc9cec3 100644 --- a/packages/plugins/openapi/src/providers/google/spec-format-adapter.test.ts +++ b/packages/plugins/openapi/src/providers/google/spec-format-adapter.test.ts @@ -10,6 +10,9 @@ import { deriveGoogleDiscoveryIdentity, googleDiscoveryAdapter } from "./spec-fo import { googleCatalog } from "./presets"; const TASKS_URL = "https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest"; +const GMAIL_URL = "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest"; +const GMAIL_MODIFY_SCOPE = "https://www.googleapis.com/auth/gmail.modify"; +const GMAIL_FULL_SCOPE = "https://mail.google.com/"; const tasksDiscoveryDoc = { name: "tasks", @@ -70,6 +73,66 @@ const discoveryHttpClientLayer = Layer.succeed(HttpClient.HttpClient)( ), ); +const gmailDiscoveryDoc = { + name: "gmail", + version: "v1", + title: "Gmail API", + rootUrl: "https://gmail.googleapis.com/", + servicePath: "", + auth: { + oauth2: { + scopes: { + [GMAIL_MODIFY_SCOPE]: { description: "Read and modify Gmail" }, + [GMAIL_FULL_SCOPE]: { description: "Full Gmail access" }, + }, + }, + }, + resources: { + users: { + resources: { + messages: { + methods: { + list: { + id: "gmail.users.messages.list", + httpMethod: "GET", + path: "gmail/v1/users/{userId}/messages", + scopes: [GMAIL_MODIFY_SCOPE, GMAIL_FULL_SCOPE], + parameters: { + userId: { location: "path", required: true, type: "string" }, + }, + }, + delete: { + id: "gmail.users.messages.delete", + httpMethod: "DELETE", + path: "gmail/v1/users/{userId}/messages/{id}", + scopes: [GMAIL_FULL_SCOPE], + parameters: { + userId: { location: "path", required: true, type: "string" }, + id: { location: "path", required: true, type: "string" }, + }, + }, + }, + }, + }, + }, + }, + schemas: {}, +}; + +const gmailDiscoveryHttpClientLayer = Layer.succeed(HttpClient.HttpClient)( + HttpClient.make((request: HttpClientRequest.HttpClientRequest) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(gmailDiscoveryDoc), { + status: request.url === GMAIL_URL ? 200 : 404, + headers: { "content-type": "application/json" }, + }), + ), + ), + ), +); + it.effect("fetches and converts a Google Discovery document", () => Effect.gen(function* () { const converted = yield* googleDiscoveryAdapter.fetch({ @@ -165,3 +228,45 @@ it.effect( ); }), ); + +it.effect("preserves a Google preset's consent scope boundary when refreshing", () => + Effect.gen(function* () { + const gmailPreset = googleCatalog.find((preset) => preset.id === "google-gmail")!; + const authTemplate: readonly AuthenticationInput[] = (gmailPreset.authTemplate ?? []).flatMap( + (template) => (template.kind === "oauth2" ? [template] : []), + ); + const executor = yield* createExecutor( + makeTestConfig({ + plugins: [ + openApiPlugin({ + httpClientLayer: gmailDiscoveryHttpClientLayer, + presets: [gmailPreset], + specFormats: [googleDiscoveryAdapter], + }), + memoryCredentialsPlugin(), + ], + }), + ); + + const added = yield* executor.openapi.addSpec({ + spec: { kind: "url", url: GMAIL_URL }, + slug: gmailPreset.defaultSlug, + specFormat: gmailPreset.specFormat, + family: gmailPreset.family, + authenticationTemplate: authTemplate, + }); + expect(added.toolCount).toBe(1); + + const updated = yield* executor.openapi.updateSpec("google_gmail"); + + const config = yield* executor.openapi.getConfig("google_gmail"); + const oauthTemplate = config?.authenticationTemplate?.find( + (template) => template.kind === "oauth2", + ); + expect(updated.toolCount).toBe(1); + expect(updated.addedTools).not.toContain("gmail.users.messages.delete"); + expect(oauthTemplate?.kind === "oauth2" ? oauthTemplate.scopes : undefined).toContain( + GMAIL_MODIFY_SCOPE, + ); + }), +); diff --git a/packages/plugins/openapi/src/sdk/plugin.ts b/packages/plugins/openapi/src/sdk/plugin.ts index 4b5c6a6f07..c2c4223886 100644 --- a/packages/plugins/openapi/src/sdk/plugin.ts +++ b/packages/plugins/openapi/src/sdk/plugin.ts @@ -646,14 +646,10 @@ export const openApiPlugin = definePlugin< const resolveSpecForInput = ( config: Pick< OpenApiSpecConfig, - | "spec" - | "specFormat" - | "specOverrides" - | "headers" - | "queryParams" - | "baseUrl" - | "authenticationTemplate" - >, + "spec" | "specFormat" | "specOverrides" | "headers" | "queryParams" | "baseUrl" + > & { + readonly authenticationTemplate?: readonly (Authentication | AuthenticationInput)[]; + }, httpClientLayer: Layer.Layer, ): Effect.Effect< ResolvedSpec, @@ -989,6 +985,7 @@ export const openApiPlugin = definePlugin< headers: current.headers, queryParams: current.queryParams, baseUrl: current.baseUrl, + authenticationTemplate: current.authenticationTemplate, }, httpClientLayer, ); From e2d9f238c3fba5011c92fd251f6d67bd138b45f9 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:23:14 -0700 Subject: [PATCH 034/133] Handle Slack user OAuth scopes (#1643) --- packages/core/sdk/src/oauth-helpers.test.ts | 76 +++++++++++++++++++++ packages/core/sdk/src/oauth-helpers.ts | 53 +++++++++++++- 2 files changed, 128 insertions(+), 1 deletion(-) diff --git a/packages/core/sdk/src/oauth-helpers.test.ts b/packages/core/sdk/src/oauth-helpers.test.ts index 38471bda7f..1b7cfcb55a 100644 --- a/packages/core/sdk/src/oauth-helpers.test.ts +++ b/packages/core/sdk/src/oauth-helpers.test.ts @@ -487,6 +487,82 @@ describe("exchangeAuthorizationCode", () => { ), ); + it.effect("uses nested granted scopes for Slack-style user token responses", () => + withTokenEndpoint( + tokenResponse({ + access_token: "xoxp-user-token", + token_type: "Bearer", + scope: "", + authed_user: { + id: "U12345", + scope: "channels:read,chat:write", + }, + }), + ({ tokenUrl }) => + Effect.gen(function* () { + const result = yield* exchangeAuthorizationCode({ + tokenUrl, + clientId: "cid", + clientSecret: "csecret", + redirectUrl: "https://app.example.com/cb", + codeVerifier: "verifier", + code: "abc", + }); + expect(result.access_token).toBe("xoxp-user-token"); + expect(result.scope).toBe("channels:read chat:write"); + }), + ), + ); + + it.effect("does not assign nested user scopes to a distinct top-level bot token", () => + withTokenEndpoint( + tokenResponse({ + access_token: "xoxb-bot-token", + token_type: "Bearer", + scope: "", + authed_user: { + scope: "channels:read,chat:write", + access_token: "xoxp-user-token", + }, + }), + ({ tokenUrl }) => + Effect.gen(function* () { + const result = yield* exchangeAuthorizationCode({ + tokenUrl, + clientId: "cid", + clientSecret: "csecret", + redirectUrl: "https://app.example.com/cb", + codeVerifier: "verifier", + code: "abc", + }); + expect(result.scope).toBe(""); + }), + ), + ); + + it.effect("keeps a standard top-level scope ahead of nested provider metadata", () => + withTokenEndpoint( + tokenResponse({ + access_token: "user-token", + token_type: "Bearer", + scope: "standard.scope", + authed_user: { scope: "provider.scope" }, + }), + ({ tokenUrl }) => + Effect.gen(function* () { + const result = yield* exchangeAuthorizationCode({ + tokenUrl, + clientId: "cid", + clientSecret: "csecret", + redirectUrl: "https://app.example.com/cb", + codeVerifier: "verifier", + code: "abc", + }); + expect(result.scope).toBe("standard.scope"); + }), + ), + ); + it.effect("still surfaces RFC 6749 §5.2 error envelopes after the id_token strip", () => withTokenEndpoint( () => diff --git a/packages/core/sdk/src/oauth-helpers.ts b/packages/core/sdk/src/oauth-helpers.ts index 2961debc50..bf1a6a2d34 100644 --- a/packages/core/sdk/src/oauth-helpers.ts +++ b/packages/core/sdk/src/oauth-helpers.ts @@ -604,6 +604,48 @@ type StrippedTokenResponse = { readonly idTokenIdentityLabel?: string; }; +const NestedAuthedUserScope = Schema.Struct({ + authed_user: Schema.Struct({ + scope: Schema.String, + access_token: Schema.optional(Schema.String), + }), +}); +const decodeNestedAuthedUserScope = Schema.decodeUnknownOption(NestedAuthedUserScope); + +type NestedAuthedUserGrant = { + readonly scope: string; + readonly accessToken?: string; +}; + +/** Slack's MCP-oriented `oauth.v2.user.access` endpoint returns its granted + * user scopes under `authed_user.scope` instead of the RFC 6749 top-level + * `scope`. Preserve that provider extension only when the standard field is + * absent or empty, and normalize Slack's comma separator back to RFC space-delimited + * scope syntax at this boundary. */ +const nestedAuthedUserGrant = async ( + response: Response, +): Promise => { + const body = await response + .clone() + .json() + .then( + (value: unknown) => value, + () => null, + ); + const decoded = decodeNestedAuthedUserScope(body); + if (Option.isNone(decoded)) return undefined; + const normalized = decoded.value.authed_user.scope + .split(/[\s,]+/) + .filter(Boolean) + .join(" "); + if (normalized.length === 0) return undefined; + const nestedAccessToken = decoded.value.authed_user.access_token; + return { + scope: normalized, + ...(nestedAccessToken === undefined ? {} : { accessToken: nestedAccessToken }), + }; +}; + // MCP source connections are pure OAuth 2.0. Some providers (PostHog, etc.) // front an OIDC backend and emit an `id_token` anyway; oauth4webapi then // strict-validates its claims against the AS metadata and rejects mismatches we @@ -638,9 +680,18 @@ const processTokenEndpointResponse = async ( response: Response, ): Promise => { const stripped = await stripIdToken(response); - const token = tokenResponseFrom( + const providerUserGrant = await nestedAuthedUserGrant(stripped.response); + const parsed = tokenResponseFrom( await oauth.processGenericTokenEndpointResponse(as, client, stripped.response), ); + const topLevelScopeIsEmpty = parsed.scope === undefined || parsed.scope.trim().length === 0; + const nestedGrantMatchesAccessToken = + providerUserGrant?.accessToken === undefined || + providerUserGrant.accessToken === parsed.access_token; + const token = + topLevelScopeIsEmpty && providerUserGrant !== undefined && nestedGrantMatchesAccessToken + ? { ...parsed, scope: providerUserGrant.scope } + : parsed; return stripped.idTokenIdentityLabel ? { ...token, idTokenIdentityLabel: stripped.idTokenIdentityLabel } : token; From b18e8cf57ee9368f19ba50997a6d157ba5136e14 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 17 Aug 2026 18:47:05 -0700 Subject: [PATCH 035/133] Select Slack user token grants (#1645) --- packages/core/sdk/src/oauth-helpers.test.ts | 39 +++++++++++++++++++-- packages/core/sdk/src/oauth-helpers.ts | 30 ++++++++++++---- 2 files changed, 60 insertions(+), 9 deletions(-) diff --git a/packages/core/sdk/src/oauth-helpers.test.ts b/packages/core/sdk/src/oauth-helpers.test.ts index 1b7cfcb55a..87ec8aae43 100644 --- a/packages/core/sdk/src/oauth-helpers.test.ts +++ b/packages/core/sdk/src/oauth-helpers.test.ts @@ -514,15 +514,21 @@ describe("exchangeAuthorizationCode", () => { ), ); - it.effect("does not assign nested user scopes to a distinct top-level bot token", () => + it.effect("selects the nested user grant when an empty top-level grant has a bot token", () => withTokenEndpoint( tokenResponse({ access_token: "xoxb-bot-token", token_type: "Bearer", scope: "", + refresh_token: "bot-refresh-token", + expires_in: 600, + id_token: unsignedJwt({ email: "alice@example.com" }), authed_user: { scope: "channels:read,chat:write", access_token: "xoxp-user-token", + token_type: "user", + refresh_token: "user-refresh-token", + expires_in: 3600, }, }), ({ tokenUrl }) => @@ -535,7 +541,36 @@ describe("exchangeAuthorizationCode", () => { codeVerifier: "verifier", code: "abc", }); - expect(result.scope).toBe(""); + expect(result).toMatchObject({ + access_token: "xoxp-user-token", + token_type: "user", + refresh_token: "user-refresh-token", + expires_in: 3600, + scope: "channels:read chat:write", + idTokenIdentityLabel: "alice@example.com", + }); + }), + ), + ); + + it.effect("treats an empty standard scope as omitted", () => + withTokenEndpoint( + tokenResponse({ + access_token: "user-token", + token_type: "Bearer", + scope: " ", + }), + ({ tokenUrl }) => + Effect.gen(function* () { + const result = yield* exchangeAuthorizationCode({ + tokenUrl, + clientId: "cid", + clientSecret: "csecret", + redirectUrl: "https://app.example.com/cb", + codeVerifier: "verifier", + code: "abc", + }); + expect(result.scope).toBeUndefined(); }), ), ); diff --git a/packages/core/sdk/src/oauth-helpers.ts b/packages/core/sdk/src/oauth-helpers.ts index bf1a6a2d34..44128de102 100644 --- a/packages/core/sdk/src/oauth-helpers.ts +++ b/packages/core/sdk/src/oauth-helpers.ts @@ -557,7 +557,7 @@ const tokenResponseFrom = (r: oauth.TokenEndpointResponse): OAuth2TokenResponse token_type: r.token_type, refresh_token: r.refresh_token, expires_in: typeof r.expires_in === "number" ? r.expires_in : undefined, - scope: r.scope, + scope: typeof r.scope === "string" && r.scope.trim().length > 0 ? r.scope : undefined, }); const JwtClaims = Schema.Record(Schema.String, Schema.Unknown); @@ -608,6 +608,9 @@ const NestedAuthedUserScope = Schema.Struct({ authed_user: Schema.Struct({ scope: Schema.String, access_token: Schema.optional(Schema.String), + token_type: Schema.optional(Schema.String), + refresh_token: Schema.optional(Schema.String), + expires_in: Schema.optional(Schema.Number), }), }); const decodeNestedAuthedUserScope = Schema.decodeUnknownOption(NestedAuthedUserScope); @@ -615,6 +618,9 @@ const decodeNestedAuthedUserScope = Schema.decodeUnknownOption(NestedAuthedUserS type NestedAuthedUserGrant = { readonly scope: string; readonly accessToken?: string; + readonly tokenType?: string; + readonly refreshToken?: string; + readonly expiresIn?: number; }; /** Slack's MCP-oriented `oauth.v2.user.access` endpoint returns its granted @@ -640,9 +646,15 @@ const nestedAuthedUserGrant = async ( .join(" "); if (normalized.length === 0) return undefined; const nestedAccessToken = decoded.value.authed_user.access_token; + const nestedTokenType = decoded.value.authed_user.token_type; + const nestedRefreshToken = decoded.value.authed_user.refresh_token; + const nestedExpiresIn = decoded.value.authed_user.expires_in; return { scope: normalized, ...(nestedAccessToken === undefined ? {} : { accessToken: nestedAccessToken }), + ...(nestedTokenType === undefined ? {} : { tokenType: nestedTokenType }), + ...(nestedRefreshToken === undefined ? {} : { refreshToken: nestedRefreshToken }), + ...(nestedExpiresIn === undefined ? {} : { expiresIn: nestedExpiresIn }), }; }; @@ -684,13 +696,17 @@ const processTokenEndpointResponse = async ( const parsed = tokenResponseFrom( await oauth.processGenericTokenEndpointResponse(as, client, stripped.response), ); - const topLevelScopeIsEmpty = parsed.scope === undefined || parsed.scope.trim().length === 0; - const nestedGrantMatchesAccessToken = - providerUserGrant?.accessToken === undefined || - providerUserGrant.accessToken === parsed.access_token; const token = - topLevelScopeIsEmpty && providerUserGrant !== undefined && nestedGrantMatchesAccessToken - ? { ...parsed, scope: providerUserGrant.scope } + parsed.scope === undefined && providerUserGrant !== undefined + ? providerUserGrant.accessToken === undefined + ? { ...parsed, scope: providerUserGrant.scope } + : { + access_token: providerUserGrant.accessToken, + token_type: providerUserGrant.tokenType, + refresh_token: providerUserGrant.refreshToken, + expires_in: providerUserGrant.expiresIn, + scope: providerUserGrant.scope, + } : parsed; return stripped.idTokenIdentityLabel ? { ...token, idTokenIdentityLabel: stripped.idTokenIdentityLabel } From 9ecc7cb8b30375ffa960e3fefe4d211e0254e691 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 18 Aug 2026 08:42:35 -0700 Subject: [PATCH 036/133] Opt-in modern protocol negotiation for stdio MCP servers (#1646) --- .changeset/stdio-modern-negotiation.md | 7 ++ bun.lock | 1 + e2e/local/stdio-mcp.test.ts | 30 +++++++++ packages/plugins/mcp/package.json | 1 + packages/plugins/mcp/src/api/group.ts | 4 ++ packages/plugins/mcp/src/api/handlers.ts | 2 + packages/plugins/mcp/src/sdk/connection.ts | 22 ++++++- packages/plugins/mcp/src/sdk/plugin.ts | 9 +++ .../src/sdk/stdio-negotiation-test-server.ts | 24 +++++++ .../mcp/src/sdk/stdio-negotiation.test.ts | 64 +++++++++++++++++++ packages/plugins/mcp/src/sdk/types.ts | 17 +++++ 11 files changed, 178 insertions(+), 3 deletions(-) create mode 100644 .changeset/stdio-modern-negotiation.md create mode 100644 packages/plugins/mcp/src/sdk/stdio-negotiation-test-server.ts create mode 100644 packages/plugins/mcp/src/sdk/stdio-negotiation.test.ts diff --git a/.changeset/stdio-modern-negotiation.md b/.changeset/stdio-modern-negotiation.md new file mode 100644 index 0000000000..4c5861de9a --- /dev/null +++ b/.changeset/stdio-modern-negotiation.md @@ -0,0 +1,7 @@ +--- +"@executor-js/plugin-mcp": patch +--- + +**Stdio MCP servers can negotiate the modern protocol (`versionNegotiation: "auto"`)** + +Spawned stdio MCP integrations previously always opened with the legacy 2025 `initialize` handshake, so an SDK v2 server running with its legacy compatibility lane disabled could not connect. Stdio integrations now accept `versionNegotiation: "auto"` (on `mcp.addServer` and the stored config) to probe `server/discover` per spec 2026-07-28, falling back to `initialize` on legacy servers. The default stays `legacy`: the SDK's stdio probe costs an extra short-lived child process per connect and stalls on silent legacy servers, which is the wrong trade for spawn-per-call CLI servers. The connect handshake span now records the negotiated era (`plugin.mcp.protocol_era`) so integration authors can verify which handshake a connection used. diff --git a/bun.lock b/bun.lock index a4266d315b..f0c9b7ae92 100644 --- a/bun.lock +++ b/bun.lock @@ -1011,6 +1011,7 @@ "@effect/vitest": "catalog:", "@executor-js/api": "workspace:*", "@executor-js/react": "workspace:*", + "@modelcontextprotocol/server": "2.0.0", "@types/node": "catalog:", "@types/react": "catalog:", "bun-types": "catalog:", diff --git a/e2e/local/stdio-mcp.test.ts b/e2e/local/stdio-mcp.test.ts index f890122592..a8ece4caa3 100644 --- a/e2e/local/stdio-mcp.test.ts +++ b/e2e/local/stdio-mcp.test.ts @@ -146,6 +146,36 @@ scenario( declTools.map((t) => t.name), "connecting with the secret discovers the env-gated tool", ).toContain("whoami"); + + // --- versionNegotiation "auto" survives the API → config → connector + // path and still reaches a legacy server: the probe gets the fixture's + // method-not-found for `server/discover` (a definitive legacy verdict) + // and falls back to `initialize`. Modern-era acceptance against a real + // legacy-disabled SDK v2 server lives in the plugin's + // stdio-negotiation.test.ts. --- + const autoSlug = "e2e-stdio-auto"; + yield* client.mcp.addServer({ + payload: { + transport: "stdio", + name: "E2E Stdio Auto", + command: "node", + args: [FIXTURE], + versionNegotiation: "auto", + slug: autoSlug, + }, + }); + + const autoStored = yield* client.mcp.getServer({ params: { slug: autoSlug } }); + expect( + JSON.stringify(autoStored?.config ?? {}), + "the negotiation mode is persisted on the integration config", + ).toContain('"versionNegotiation":"auto"'); + + const autoTools = yield* client.tools.list({ query: { integration: autoSlug } }); + expect( + autoTools.map((t) => t.name), + "auto negotiation falls back to legacy and still discovers tools", + ).toContain("echo_tool"); }), ); }), diff --git a/packages/plugins/mcp/package.json b/packages/plugins/mcp/package.json index 13451d29bc..af9334a4ac 100644 --- a/packages/plugins/mcp/package.json +++ b/packages/plugins/mcp/package.json @@ -75,6 +75,7 @@ "@effect/vitest": "catalog:", "@executor-js/api": "workspace:*", "@executor-js/react": "workspace:*", + "@modelcontextprotocol/server": "2.0.0", "@types/node": "catalog:", "@types/react": "catalog:", "bun-types": "catalog:", diff --git a/packages/plugins/mcp/src/api/group.ts b/packages/plugins/mcp/src/api/group.ts index 324b9d6841..2de2fb1222 100644 --- a/packages/plugins/mcp/src/api/group.ts +++ b/packages/plugins/mcp/src/api/group.ts @@ -57,6 +57,10 @@ const AddStdioServerPayload = Schema.Struct({ /** One-shot secret env values (programmatic). The UI sends `envVars`. */ env: Schema.optional(StringMap), cwd: Schema.optional(Schema.String), + /** Protocol negotiation at connect: `auto` probes `server/discover` (spec + * 2026-07-28) for modern-only servers; default is the legacy `initialize` + * handshake. */ + versionNegotiation: Schema.optional(Schema.Literals(["legacy", "auto"])), slug: Schema.optional(Schema.String), }); diff --git a/packages/plugins/mcp/src/api/handlers.ts b/packages/plugins/mcp/src/api/handlers.ts index 6ca5c3188a..2b05275ad3 100644 --- a/packages/plugins/mcp/src/api/handlers.ts +++ b/packages/plugins/mcp/src/api/handlers.ts @@ -39,6 +39,7 @@ const toServerInput = ( envVars?: readonly string[]; env?: Record; cwd?: string; + versionNegotiation?: "legacy" | "auto"; slug?: string; }; return { @@ -50,6 +51,7 @@ const toServerInput = ( envVars: p.envVars ? [...p.envVars] : undefined, env: p.env, cwd: p.cwd, + versionNegotiation: p.versionNegotiation, slug: p.slug, }; } diff --git a/packages/plugins/mcp/src/sdk/connection.ts b/packages/plugins/mcp/src/sdk/connection.ts index 22fb1647cd..ad9eabbf36 100644 --- a/packages/plugins/mcp/src/sdk/connection.ts +++ b/packages/plugins/mcp/src/sdk/connection.ts @@ -261,6 +261,15 @@ const connectClient = (input: { catch: (cause) => connectionFailure(input.transport, `Failed connecting via ${input.transport}`, cause), }).pipe( + // The negotiated era ("modern" = 2026-07-28 server/discover, "legacy" = + // 2025 initialize) is otherwise invisible: both eras list and call tools + // identically, so traces are the one place an integration author can + // verify which handshake a connection actually used. + Effect.tap(() => + Effect.annotateCurrentSpan({ + "plugin.mcp.protocol_era": client.getProtocolEra() ?? "unknown", + }), + ), Effect.withSpan("plugin.mcp.connection.handshake", { attributes: { "plugin.mcp.transport": input.transport }, }), @@ -299,6 +308,12 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { return yield* connectClient({ transport: "stdio", + // Opt-in per integration (default legacy) — see + // `McpStdioVersionNegotiation` for why stdio does not follow the + // remote transport's unconditional auto. + ...(input.versionNegotiation === "auto" + ? { versionNegotiation: { mode: "auto" as const } } + : {}), createTransport: () => createStdioTransport({ command, @@ -318,9 +333,10 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { const endpoint = buildEndpointUrl(input.endpoint, input.queryParams ?? {}); - // Auto-negotiate the 2026-07-28 era only on Streamable HTTP. SSE is a - // legacy-only transport, and stdio servers are spawned per call where the - // SDK recommends retaining its legacy-default handshake. + // Auto-negotiate the 2026-07-28 era unconditionally only on Streamable + // HTTP. SSE is a legacy-only transport; stdio negotiates per the + // integration's `versionNegotiation` (default legacy — see the stdio + // branch above). const connectStreamableHttp = connectClient({ transport: "streamable-http", versionNegotiation: { mode: "auto" }, diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index 70ec0e69e6..af82b0b278 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -60,6 +60,7 @@ import { expandMcpAuthMethodInputs, mcpAuthMethodFromShorthand, normalizeMcpAuthMethods, + McpStdioVersionNegotiation, parseMcpIntegrationConfig, type McpIntegrationConfig as McpIntegrationConfigType, type McpStdioEnvMethod, @@ -206,6 +207,11 @@ const McpStdioServerInputSchema = Schema.Struct({ * instead and leaves the values to the connect step. */ env: Schema.optional(Schema.Record(Schema.String, Schema.String)), cwd: Schema.optional(Schema.String), + /** Protocol negotiation at connect: `auto` probes `server/discover` (spec + * 2026-07-28) for modern-only servers. Defaults to the legacy `initialize` + * handshake — the right call for spawn-per-call servers, where the auto + * probe costs an extra child process per connect. */ + versionNegotiation: Schema.optional(McpStdioVersionNegotiation), slug: Schema.optional(Schema.String), }); @@ -369,6 +375,7 @@ const toIntegrationConfig = (input: McpServerInput): McpIntegrationConfigType => command: input.command, args: input.args ? [...input.args] : undefined, cwd: input.cwd, + versionNegotiation: input.versionNegotiation, authenticationTemplate: vars.length > 0 ? [{ slug: STDIO_ENV_TEMPLATE, kind: "stdio_env", vars }] @@ -587,6 +594,7 @@ const buildConnectorInput = ( args: config.args, env: Object.keys(env).length > 0 ? env : undefined, cwd: config.cwd, + versionNegotiation: config.versionNegotiation, } satisfies McpStdioIntegrationConfig); } @@ -1045,6 +1053,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { command: config.command, args: config.args, cwd: config.cwd, + versionNegotiation: config.versionNegotiation, authenticationTemplate: hasEnv ? [{ slug: STDIO_ENV_TEMPLATE, kind: "stdio_env", vars: envVars }] : [{ slug: "none", kind: "none" }], diff --git a/packages/plugins/mcp/src/sdk/stdio-negotiation-test-server.ts b/packages/plugins/mcp/src/sdk/stdio-negotiation-test-server.ts new file mode 100644 index 0000000000..c31bd2e29a --- /dev/null +++ b/packages/plugins/mcp/src/sdk/stdio-negotiation-test-server.ts @@ -0,0 +1,24 @@ +// Stdio MCP fixture for stdio-negotiation.test.ts, spawned as a child process +// with `bun run `. Serves both protocol eras by default; +// `--legacy-reject` refuses the 2025 `initialize` opening so only a client +// probing `server/discover` (spec 2026-07-28) can connect — the shape of an +// SDK v2 server running with its legacy compatibility lane disabled. +import { McpServer } from "@modelcontextprotocol/server"; +import { serveStdio } from "@modelcontextprotocol/server/stdio"; +import * as z from "zod/v4"; + +serveStdio( + () => { + const server = new McpServer( + { name: "stdio-negotiation-fixture", version: "1.0.0" }, + { capabilities: { tools: {} } }, + ); + server.registerTool( + "add", + { description: "Add two numbers", inputSchema: z.object({ a: z.number(), b: z.number() }) }, + async ({ a, b }) => ({ content: [{ type: "text", text: String(a + b) }] }), + ); + return server; + }, + { legacy: process.argv.includes("--legacy-reject") ? "reject" : "serve" }, +); diff --git a/packages/plugins/mcp/src/sdk/stdio-negotiation.test.ts b/packages/plugins/mcp/src/sdk/stdio-negotiation.test.ts new file mode 100644 index 0000000000..d3400dc8a9 --- /dev/null +++ b/packages/plugins/mcp/src/sdk/stdio-negotiation.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Predicate } from "effect"; +import { fileURLToPath } from "node:url"; + +import { createMcpConnector, type StdioConnectorInput } from "./connection"; + +const fixture = fileURLToPath(new URL("./stdio-negotiation-test-server.ts", import.meta.url)); + +const stdioInput = ( + overrides: Partial> & { + readonly args: readonly string[]; + }, +): StdioConnectorInput => ({ + transport: "stdio", + command: "bun", + ...overrides, +}); + +const withConnection = (input: StdioConnectorInput) => + Effect.acquireRelease(createMcpConnector(input).pipe(Effect.orDie), (connection) => + Effect.promise(connection.close), + ); + +describe("stdio version negotiation", () => { + it.effect("auto negotiation connects modern to a server with legacy support disabled", () => + Effect.scoped( + Effect.gen(function* () { + const connection = yield* withConnection( + stdioInput({ args: ["run", fixture, "--legacy-reject"], versionNegotiation: "auto" }), + ); + + expect(connection.client.getProtocolEra()).toBe("modern"); + const tools = yield* Effect.promise(() => connection.client.listTools()); + expect(tools.tools.map(({ name }) => name)).toContain("add"); + const result = yield* Effect.promise(() => + connection.client.callTool({ name: "add", arguments: { a: 2, b: 2 } }), + ); + expect(result.content).toEqual([{ type: "text", text: "4" }]); + }), + ), + ); + + it.effect("the default handshake surfaces a connection error on that same server", () => + Effect.gen(function* () { + const error = yield* createMcpConnector( + stdioInput({ args: ["run", fixture, "--legacy-reject"] }), + ).pipe(Effect.flip); + + expect(Predicate.isTagged(error, "McpConnectionError")).toBe(true); + }), + ); + + it.effect("absent config keeps the legacy handshake against a both-era server", () => + Effect.scoped( + Effect.gen(function* () { + const connection = yield* withConnection(stdioInput({ args: ["run", fixture] })); + + expect(connection.client.getProtocolEra()).toBe("legacy"); + const tools = yield* Effect.promise(() => connection.client.listTools()); + expect(tools.tools.map(({ name }) => name)).toContain("add"); + }), + ), + ); +}); diff --git a/packages/plugins/mcp/src/sdk/types.ts b/packages/plugins/mcp/src/sdk/types.ts index 838e81e0ac..a9ebcd427f 100644 --- a/packages/plugins/mcp/src/sdk/types.ts +++ b/packages/plugins/mcp/src/sdk/types.ts @@ -31,6 +31,20 @@ export type McpRemoteTransport = typeof McpRemoteTransport.Type; export const McpTransport = Schema.Literals(["streamable-http", "sse", "stdio", "auto"]); export type McpTransport = typeof McpTransport.Type; +/** Protocol-version negotiation for a stdio server, mirroring the client + * SDK's `versionNegotiation` modes. `legacy` (the default when absent) opens + * with the 2025 `initialize` handshake; `auto` probes `server/discover` + * (spec 2026-07-28) and falls back to `initialize` on legacy servers. + * + * Deliberately opt-in, unlike remote streamable HTTP where `auto` is + * unconditional: the SDK's stdio probe runs on a short-lived sibling + * process, and a legacy server that never answers the probe stalls connect + * for the full probe timeout — the wrong default for spawn-per-call CLI + * servers, and exactly the case the SDK's own guidance says to keep on the + * legacy handshake unless the server is known-modern. */ +export const McpStdioVersionNegotiation = Schema.Literals(["legacy", "auto"]); +export type McpStdioVersionNegotiation = typeof McpStdioVersionNegotiation.Type; + // --------------------------------------------------------------------------- // Auth methods — the shared placements vocabulary (`@executor-js/sdk/http-auth`) // plus MCP's own oauth variant. An integration declares zero or more methods, @@ -208,6 +222,9 @@ export const McpStdioIntegrationConfig = Schema.Struct({ env: Schema.optional(StringMap), /** Working directory */ cwd: Schema.optional(Schema.String), + /** Protocol negotiation at connect. Absent means `legacy` (see + * `McpStdioVersionNegotiation` for why that stays the default). */ + versionNegotiation: Schema.optional(McpStdioVersionNegotiation), /** Declared auth methods — a single `stdio_env` method naming the secret env * vars, or `none`. A connection's `template` picks one by slug, exactly as * for remote servers. Optional so pre-revamp stdio configs (which had no From 86acd383503309cd4c92bf098e2769fba562b94b Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 18 Aug 2026 08:47:08 -0700 Subject: [PATCH 037/133] Bring CI under two minutes (#1644) * Cut CI toward two minutes * Bring CI under two minutes * Make approval lease test deterministic * Stabilize E2E dependency setup * Harden CI regression coverage --- .github/workflows/ci.yml | 134 ++++------ apps/cloud/scripts/test-globalsetup.ts | 21 +- .../account/org-api-key-revoke.node.test.ts | 8 +- .../src/test-globalsetup-exit.node.test.ts | 41 ++++ .../test-globalsetup-exit/fixture.test.ts | 7 + .../test-globalsetup-exit/vitest.config.ts | 12 + e2e/package.json | 1 + e2e/scenarios/artifact-approval.test.ts | 15 +- e2e/scripts/ci-shard-durations.ts | 94 +++++++ e2e/scripts/ci-shard.test.ts | 126 ++++++++++ e2e/scripts/ci-shard.ts | 231 ++++++++++++++++++ e2e/scripts/ci-shard.vitest.config.ts | 9 + e2e/scripts/run-ci-shard.ts | 37 +++ .../core/sdk/src/pending-approval.test.ts | 17 ++ turbo.json | 1 - 15 files changed, 661 insertions(+), 93 deletions(-) create mode 100644 apps/cloud/src/test-globalsetup-exit.node.test.ts create mode 100644 apps/cloud/test-fixtures/test-globalsetup-exit/fixture.test.ts create mode 100644 apps/cloud/test-fixtures/test-globalsetup-exit/vitest.config.ts create mode 100644 e2e/scripts/ci-shard-durations.ts create mode 100644 e2e/scripts/ci-shard.test.ts create mode 100644 e2e/scripts/ci-shard.ts create mode 100644 e2e/scripts/ci-shard.vitest.config.ts create mode 100644 e2e/scripts/run-ci-shard.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc003182b3..f2ac82239d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -144,15 +144,18 @@ jobs: test: name: Test - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: blacksmith-16vcpu-ubuntu-2404 timeout-minutes: 15 - # Tuned for Blacksmith's 4 vCPU runners. + # Run eight independent packages at once and cap each Vitest process at two + # workers. This uses all 16 vCPUs without every nested runner seeing the + # whole machine and oversubscribing it. env: TURBO_API: ${{ vars.TURBO_API }} TURBO_TEAM: ${{ vars.TURBO_TEAM }} TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} - TURBO_TEST_CONCURRENCY: 4 + TURBO_TEST_CONCURRENCY: 8 + VITEST_MAX_WORKERS: 2 steps: - uses: actions/checkout@v4 @@ -184,6 +187,8 @@ jobs: # no signal. This is just a few file reads plus greps, so ~zero cost. - run: bun run check:patches + - run: bun run --cwd e2e test:ci-shard + - run: bun run test e2e: @@ -192,25 +197,41 @@ jobs: fail-fast: false matrix: include: - # PGlite is deliberately single-connection, and under a sustained - # multi-minute shard it can stop accepting postgres sockets. Keep - # every hermetic dev stack short: eight serial shards remove that - # lifetime-dependent failure and put cloud below the selfhost lane. - - { target: cloud, shard: 1/8, shard-name: 1of8 } - - { target: cloud, shard: 2/8, shard-name: 2of8 } - - { target: cloud, shard: 3/8, shard-name: 3of8 } - - { target: cloud, shard: 4/8, shard-name: 4of8 } - - { target: cloud, shard: 5/8, shard-name: 5of8 } - - { target: cloud, shard: 6/8, shard-name: 6of8 } - - { target: cloud, shard: 7/8, shard-name: 7of8 } - - { target: cloud, shard: 8/8, shard-name: 8of8 } - # Selfhost shards the same way: each shard is its own runner booting - # its own fresh instance (own port block + data dir), so the - # project's shared-bootstrap-admin assumption stays intact per shard - # and `fileParallelism: false` still serializes within a shard. - - { target: selfhost, shard: 1/3, shard-name: 1of3 } - - { target: selfhost, shard: 2/3, shard-name: 2of3 } - - { target: selfhost, shard: 3/3, shard-name: 3of3 } + # The planner assigns every file exactly once using recorded slow-file + # durations plus a conservative weight for new tests. The cloud DB's + # connection teardown and concurrent socket protocol have dedicated + # regression tests; these shards balance wall clock, not hide retries. + - { target: cloud, shard-index: 1, shard-name: 1of16 } + - { target: cloud, shard-index: 2, shard-name: 2of16 } + - { target: cloud, shard-index: 3, shard-name: 3of16 } + - { target: cloud, shard-index: 4, shard-name: 4of16 } + - { target: cloud, shard-index: 5, shard-name: 5of16 } + - { target: cloud, shard-index: 6, shard-name: 6of16 } + - { target: cloud, shard-index: 7, shard-name: 7of16 } + - { target: cloud, shard-index: 8, shard-name: 8of16 } + - { target: cloud, shard-index: 9, shard-name: 9of16 } + - { target: cloud, shard-index: 10, shard-name: 10of16 } + - { target: cloud, shard-index: 11, shard-name: 11of16 } + - { target: cloud, shard-index: 12, shard-name: 12of16 } + - { target: cloud, shard-index: 13, shard-name: 13of16 } + - { target: cloud, shard-index: 14, shard-name: 14of16 } + - { target: cloud, shard-index: 15, shard-name: 15of16 } + - { target: cloud, shard-index: 16, shard-name: 16of16 } + - { target: selfhost, shard-index: 1, shard-name: 1of10 } + - { target: selfhost, shard-index: 2, shard-name: 2of10 } + - { target: selfhost, shard-index: 3, shard-name: 3of10 } + - { target: selfhost, shard-index: 4, shard-name: 4of10 } + - { target: selfhost, shard-index: 5, shard-name: 5of10 } + - { target: selfhost, shard-index: 6, shard-name: 6of10 } + - { target: selfhost, shard-index: 7, shard-name: 7of10 } + - { target: selfhost, shard-index: 8, shard-name: 8of10 } + - { target: selfhost, shard-index: 9, shard-name: 9of10 } + - { target: selfhost, shard-index: 10, shard-name: 10of10 } + # Local files own their server, browser and data directory. Separate + # runners preserve that isolation while removing its 69-second serial + # lane from the two-minute critical path. + - { target: local, shard-index: 1, shard-name: 1of2 } + - { target: local, shard-index: 2, shard-name: 2of2 } runs-on: blacksmith-4vcpu-ubuntu-2404 timeout-minutes: 30 steps: @@ -246,26 +267,29 @@ jobs: # Install from e2e so bunx resolves ITS pinned playwright (the version # the tests run against) rather than floating to the latest. + # Blacksmith's Ubuntu image carries the official GitHub runner system + # dependencies. Restore the pinned browser binaries without apt-updating + # every matrix machine. - name: Install Playwright Chromium - run: bunx playwright install --with-deps chromium chromium-headless-shell + run: bunx playwright install chromium chromium-headless-shell working-directory: e2e - # The globalsetup boots the target's own dev server (ports are claimed - # per checkout, so this is hermetic) and tears it down after the run. + # Each target either boots its own shared dev server or lets each file own + # its server. Ports and data paths are hermetic in both cases. # Do not retry scenarios: retries hide flakes and multiply slow timeout # failures. The fixtures and process lifecycle are deterministic enough # that the first result is the result. - - name: Run cloud scenarios + - name: Run cloud shard if: matrix.target == 'cloud' env: MCP_SESSION_TIMEOUT_MS: "3000" MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS: "6000" - run: bunx vitest run --project cloud ${{ matrix.shard && format('--shard={0}', matrix.shard) || '' }} + run: bun scripts/run-ci-shard.ts cloud ${{ matrix['shard-index'] }} working-directory: e2e - - name: Run selfhost scenarios - if: matrix.target == 'selfhost' - run: bunx vitest run --project selfhost ${{ matrix.shard && format('--shard={0}', matrix.shard) || '' }} + - name: Run scenarios + if: matrix.target != 'cloud' + run: bun scripts/run-ci-shard.ts ${{ matrix.target }} ${{ matrix['shard-index'] }} working-directory: e2e # Failed runs keep their trace.zip / session.mp4 / step screenshots in @@ -278,56 +302,6 @@ jobs: path: e2e/runs/ retention-days: 7 - e2e-local: - name: E2E (local) - runs-on: blacksmith-4vcpu-ubuntu-2404 - timeout-minutes: 20 - steps: - - uses: actions/checkout@v4 - - - uses: oven-sh/setup-bun@v2 - with: - bun-version: 1.3.11 - - - name: Cache Bun package cache - uses: actions/cache@v4 - with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-bun-1.3.11-${{ hashFiles('bun.lock') }} - restore-keys: | - ${{ runner.os }}-bun-1.3.11- - - # The local scenarios boot a real `executor web` (which spawns a Node - # sidecar) and some drive a browser, so pin Node 24 and install Chromium. - - uses: actions/setup-node@v4 - with: - node-version: 24 - - - run: bun install --frozen-lockfile - - - name: Cache Playwright browsers - uses: actions/cache@v4 - with: - path: ~/.cache/ms-playwright - key: ${{ runner.os }}-playwright-1.60.0 - restore-keys: | - ${{ runner.os }}-playwright- - - # `chromium` and the new `chromium-headless-shell` ship as separate - # downloads; the browser-driven scenarios launch the headless shell. - # Install from e2e so bunx resolves ITS pinned playwright (the version the - # tests run against) rather than floating to the latest, which would fetch - # a browser build the test runtime does not look for. - - name: Install Playwright Chromium - run: bunx playwright install --with-deps chromium chromium-headless-shell - working-directory: e2e - - # Each scenario owns its server, browser, data directory, and descendants; - # run the complete hermetic suite on PRs without scenario retries. - - name: Run local scenarios - run: bunx vitest run --project local - working-directory: e2e - desktop-smoke: name: Desktop smoke build needs: changes diff --git a/apps/cloud/scripts/test-globalsetup.ts b/apps/cloud/scripts/test-globalsetup.ts index d7dd916edb..4362c3285d 100644 --- a/apps/cloud/scripts/test-globalsetup.ts +++ b/apps/cloud/scripts/test-globalsetup.ts @@ -13,12 +13,26 @@ import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); -const PORT = 5434; +const parsePort = (input: string | undefined): number => { + if (input === undefined) return 5434; + if (!/^\d+$/.test(input)) throw new Error("CLOUD_TEST_DB_PORT must be an integer"); + const port = Number(input); + if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) { + throw new Error("CLOUD_TEST_DB_PORT must be between 1 and 65535"); + } + return port; +}; + +const PORT = parsePort(process.env.CLOUD_TEST_DB_PORT); const MIGRATIONS_FOLDER = resolve(__dirname, "../drizzle"); let db: PGlite | undefined; let server: PGLiteSocketServer | undefined; +/** + * Starts the cloud unit-test database and returns teardown that releases every + * resource without allowing PGlite shutdown to erase Vitest's failure status. + */ export default async function setup() { db = await PGlite.create(); await migrate(drizzle(db), { migrationsFolder: MIGRATIONS_FOLDER }); @@ -30,7 +44,12 @@ export default async function setup() { console.log(`[test-db] PGlite socket server listening on 127.0.0.1:${PORT}`); return async () => { + // PGlite sets an internal 99 sentinel on startup and replaces it with 0 on + // close. Preserve Vitest's failure status across that close so + // teardown can turn neither a red test green nor a green test red. + const testsFailed = process.exitCode === 1; await server?.stop(); await db?.close(); + if (testsFailed) process.exitCode = 1; }; } diff --git a/apps/cloud/src/account/org-api-key-revoke.node.test.ts b/apps/cloud/src/account/org-api-key-revoke.node.test.ts index ca8d3e0522..97a3816bad 100644 --- a/apps/cloud/src/account/org-api-key-revoke.node.test.ts +++ b/apps/cloud/src/account/org-api-key-revoke.node.test.ts @@ -6,6 +6,7 @@ import { AccountError, AccountForbidden } from "@executor-js/api"; import { ApiKeyService, OrgApiKeyNotFound } from "../auth/api-keys"; import { UserStoreService } from "../auth/context"; +import { ORG_SELECTOR_HEADER } from "../auth/organization"; import { WorkOSClient, type WorkOSClientService } from "../auth/workos"; import { AutumnService } from "../extensions/billing/service"; import { AccountCaller, workosAccountProvider } from "./workos-account-service"; @@ -36,6 +37,7 @@ const MEMBER = "user_member"; const ORG_KEY = "key_org_1"; const USER_KEY = "key_user_1"; const createdAt = new Date("2026-01-01T00:00:00.000Z"); +const orgHeaders = { [ORG_SELECTOR_HEADER]: ORG }; const session = (accountId: string) => ({ accountId, @@ -157,7 +159,7 @@ describe("revokeOrgApiKey · provider boundary", () => { const { provider, revoked } = providerWith(ADMIN); const account = yield* provider; - const result = yield* account.revokeOrgApiKey({}, ORG_KEY); + const result = yield* account.revokeOrgApiKey(orgHeaders, ORG_KEY); expect(result).toEqual({ success: true }); expect(revoked, "the revoke reached the key service").toEqual([ORG_KEY]); @@ -169,7 +171,7 @@ describe("revokeOrgApiKey · provider boundary", () => { const { provider, revoked } = providerWith(MEMBER); const account = yield* provider; - const error = yield* Effect.flip(account.revokeOrgApiKey({}, ORG_KEY)); + const error = yield* Effect.flip(account.revokeOrgApiKey(orgHeaders, ORG_KEY)); expect(error, "same admin gate as the mint").toBeInstanceOf(AccountForbidden); expect(revoked, "the gate runs BEFORE the key service is touched").toEqual([]); @@ -183,7 +185,7 @@ describe("revokeOrgApiKey · provider boundary", () => { const { provider, revoked } = providerWith(ADMIN); const account = yield* provider; - const error = yield* Effect.flip(account.revokeOrgApiKey({}, USER_KEY)); + const error = yield* Effect.flip(account.revokeOrgApiKey(orgHeaders, USER_KEY)); expect(error).toBeInstanceOf(AccountError); expect(revoked).toEqual([]); diff --git a/apps/cloud/src/test-globalsetup-exit.node.test.ts b/apps/cloud/src/test-globalsetup-exit.node.test.ts new file mode 100644 index 0000000000..8eff5a4d20 --- /dev/null +++ b/apps/cloud/src/test-globalsetup-exit.node.test.ts @@ -0,0 +1,41 @@ +import { spawnSync } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "@effect/vitest"; + +const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const vitestBin = resolve(appRoot, "../../node_modules/vitest/vitest.mjs"); +const fixtureConfig = resolve(appRoot, "test-fixtures/test-globalsetup-exit/vitest.config.ts"); + +const runFixture = (port: number, shouldPass: boolean) => + spawnSync(process.execPath, [vitestBin, "run", "--config", fixtureConfig], { + cwd: appRoot, + encoding: "utf8", + timeout: 60_000, + env: { + ...process.env, + CLOUD_TEST_DB_PORT: String(port), + TEST_GLOBALSETUP_SHOULD_PASS: String(shouldPass), + }, + }); + +const diagnostic = (result: ReturnType): string => + [result.stdout, result.stderr].filter(Boolean).join("\n"); + +describe("cloud test global setup", () => { + it("does not let PGlite teardown turn a passed test red", { timeout: 60_000 }, () => { + const result = runFixture(45_435, true); + + expect(result.error).toBeUndefined(); + expect(result.signal).toBeNull(); + expect(result.status, diagnostic(result)).toBe(0); + }); + + it("does not let PGlite teardown turn a failed test green", { timeout: 60_000 }, () => { + const result = runFixture(45_436, false); + + expect(result.error).toBeUndefined(); + expect(result.signal).toBeNull(); + expect(result.status, diagnostic(result)).toBe(1); + }); +}); diff --git a/apps/cloud/test-fixtures/test-globalsetup-exit/fixture.test.ts b/apps/cloud/test-fixtures/test-globalsetup-exit/fixture.test.ts new file mode 100644 index 0000000000..453b8f8b8b --- /dev/null +++ b/apps/cloud/test-fixtures/test-globalsetup-exit/fixture.test.ts @@ -0,0 +1,7 @@ +import { expect, it } from "@effect/vitest"; + +it("exercises the global-setup exit path", () => { + if (process.env.TEST_GLOBALSETUP_SHOULD_PASS === "true") return; + + expect("deliberate failure").toBe("reported as success"); +}); diff --git a/apps/cloud/test-fixtures/test-globalsetup-exit/vitest.config.ts b/apps/cloud/test-fixtures/test-globalsetup-exit/vitest.config.ts new file mode 100644 index 0000000000..e073da19f6 --- /dev/null +++ b/apps/cloud/test-fixtures/test-globalsetup-exit/vitest.config.ts @@ -0,0 +1,12 @@ +import { resolve } from "node:path"; +import { defineConfig } from "vitest/config"; + +const appRoot = resolve(__dirname, "../.."); + +export default defineConfig({ + root: appRoot, + test: { + include: ["test-fixtures/test-globalsetup-exit/fixture.test.ts"], + globalSetup: [resolve(appRoot, "scripts/test-globalsetup.ts")], + }, +}); diff --git a/e2e/package.json b/e2e/package.json index 3a793f50fa..ffaa25b958 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -11,6 +11,7 @@ "test:selfhost-docker": "vitest run --project selfhost-docker", "test:cloudflare": "vitest run --project cloudflare", "test:local": "vitest run --project local", + "test:ci-shard": "vitest run --config scripts/ci-shard.vitest.config.ts", "test:watch": "vitest", "ports": "bun scripts/ports.ts", "summary": "bun scripts/summary.ts", diff --git a/e2e/scenarios/artifact-approval.test.ts b/e2e/scenarios/artifact-approval.test.ts index d2d4ef937b..ea1f52a34b 100644 --- a/e2e/scenarios/artifact-approval.test.ts +++ b/e2e/scenarios/artifact-approval.test.ts @@ -32,11 +32,9 @@ import { Api, Target } from "../src/services"; const coreApi = composePluginApi([] as const); /** - * How long the scenario waits before approving. - * - * Long enough to be a real human pause rather than a same-tick round trip, and - * well past the 4-minute paused-execution lease the MCP plane advertises being - * irrelevant here. Kept modest so the scenario stays inside the default timeout. + * Long enough to cover the observed human pause while staying well inside the + * 15-minute approval lease. The shard planner keeps this file isolated so the + * real elapsed-time assertion overlaps the rest of CI instead of blocking it. */ const APPROVAL_DELAY_MS = 65_000; @@ -71,7 +69,7 @@ const pausedExecutionId = (structured: unknown): string | undefined => (structured as { readonly executionId?: string } | null)?.executionId; scenario( - "Artifacts · a destructive action approved from an artifact runs, even minutes later", + "Artifacts · a destructive action approved from an artifact runs after a human-scale delay", { timeout: 240_000 }, Effect.gen(function* () { const target = yield* Target; @@ -130,8 +128,9 @@ scenario( "the action does not run while it is waiting on approval", ).toBe(false); - // The human reads the request and decides. This is the wait that makes the - // approval window a real promise rather than a same-request formality. + // Exercise the real API and persistence path across an actual delay. A + // private fake clock cannot prove that request-scoped engines and durable + // storage still agree after the originating request has been gone. yield* Effect.sleep(APPROVAL_DELAY_MS); const approved = yield* client.executions.resume({ diff --git a/e2e/scripts/ci-shard-durations.ts b/e2e/scripts/ci-shard-durations.ts new file mode 100644 index 0000000000..d1a3f6ad6d --- /dev/null +++ b/e2e/scripts/ci-shard-durations.ts @@ -0,0 +1,94 @@ +import type { CiTarget } from "./ci-shard"; + +/** + * A conservative estimate for a test file that has no recorded CI duration. + * New tests therefore receive real scheduling weight instead of accumulating + * unnoticed in one shard. + */ +export const UNKNOWN_TEST_DURATION_MS = 10_000; + +/** + * Slow-file durations observed in the last complete pre-planner CI run. + * Files below five seconds intentionally use UNKNOWN_TEST_DURATION_MS: that + * overestimate keeps the plan balanced by both known hotspots and file count. + */ +export const OBSERVED_TEST_DURATIONS_MS: Readonly< + Record>> +> = { + cloud: { + "cloud/admin-users-console.test.ts": 17_089, + "cloud/auth-hint.test.ts": 8_275, + "cloud/billing-trial-checkout-stale.test.ts": 10_173, + "cloud/connect-link-multi-org.test.ts": 9_743, + "cloud/connection-modal-oauth-abandon.test.ts": 7_468, + "cloud/logout-stale-session.test.ts": 10_136, + "cloud/mcp-browser-resume-page.test.ts": 5_729, + "cloud/mcp-client-sessions.test.ts": 16_544, + "cloud/mcp-priming-reconnect.test.ts": 15_482, + "cloud/mcp-sse-replay.test.ts": 48_617, + "cloud/member-invite-seat-limit.test.ts": 16_661, + "cloud/oauth-callback-org-scope.test.ts": 17_959, + "cloud/org-api-keys-console.test.ts": 9_520, + "cloud/org-delete.test.ts": 8_075, + "cloud/org-last-visited.test.ts": 6_696, + "cloud/org-limit.test.ts": 19_139, + "cloud/org-multitab-cookie.test.ts": 15_626, + "cloud/org-switcher.test.ts": 7_686, + "cloud/repro-transport-brick.test.ts": 5_474, + "scenarios/artifact-approval.test.ts": 65_965, + "scenarios/artifact-loading-surface.test.ts": 16_426, + "scenarios/artifact-preview-gallery.test.ts": 10_838, + "scenarios/artifacts.test.ts": 23_178, + "scenarios/connect-deep-link.test.ts": 8_836, + "scenarios/connect-handoff-session.test.ts": 10_296, + "scenarios/connect-handoff.test.ts": 7_519, + "scenarios/connection-remove-confirm.test.ts": 5_197, + "scenarios/google-health-checks.test.ts": 39_421, + "scenarios/google-photos-preset-ui.test.ts": 5_696, + "scenarios/graphql-introspection-health.test.ts": 12_617, + "scenarios/health-checks-ui.test.ts": 71_165, + "scenarios/mcp-catalog-sync-ui.test.ts": 11_813, + "scenarios/microsoft-graph-full.test.ts": 7_586, + "scenarios/oauth-client-handoff.test.ts": 9_866, + "scenarios/openapi-add-integration-action-bar.test.ts": 6_084, + "scenarios/openapi-server-selection-ui.test.ts": 5_933, + "scenarios/org-slug-routing.test.ts": 8_598, + "scenarios/policies-ui.test.ts": 9_515, + "scenarios/provider-plugins-ui.test.ts": 9_060, + "scenarios/toolkits-mcp.test.ts": 6_665, + }, + local: { + "local/auth.test.ts": 8_687, + "local/cli-mcp-daemon-attach-stress.test.ts": 12_241, + "local/stdio-mcp.test.ts": 6_886, + "local/update-notice.test.ts": 8_438, + }, + selfhost: { + "scenarios/artifact-approval.test.ts": 65_795, + "scenarios/artifact-loading-surface.test.ts": 10_961, + "scenarios/artifact-preview-gallery.test.ts": 6_923, + "scenarios/artifacts.test.ts": 17_794, + "scenarios/browser-approval.test.ts": 5_055, + "scenarios/connect-deep-link.test.ts": 7_034, + "scenarios/connect-handoff-session.test.ts": 7_279, + "scenarios/connect-handoff.test.ts": 5_695, + "scenarios/google-health-checks.test.ts": 18_428, + "scenarios/graphql-introspection-health.test.ts": 8_060, + "scenarios/health-checks-ui.test.ts": 53_684, + "scenarios/mcp-catalog-sync-ui.test.ts": 9_870, + "scenarios/microsoft-graph-full.test.ts": 7_137, + "scenarios/oauth-client-handoff.test.ts": 7_317, + "scenarios/policies-ui.test.ts": 8_609, + "scenarios/provider-plugins-ui.test.ts": 6_317, + "scenarios/resume-after-sandbox-deadline.test.ts": 23_491, + "scenarios/toolkits-mcp.test.ts": 5_571, + "selfhost/admin-users-console.test.ts": 10_751, + "selfhost/api-keys-feedback.test.ts": 7_290, + "selfhost/auth-methods-ui.test.ts": 17_925, + "selfhost/cli-device-login.test.ts": 8_718, + "selfhost/detected-auth-immutable-ui.test.ts": 5_976, + "selfhost/mcp-oauth-reconnect-health.test.ts": 30_776, + "selfhost/oauth-app-modal.test.ts": 5_160, + "selfhost/toolkits-ui.test.ts": 8_628, + }, +}; diff --git a/e2e/scripts/ci-shard.test.ts b/e2e/scripts/ci-shard.test.ts new file mode 100644 index 0000000000..ea67098434 --- /dev/null +++ b/e2e/scripts/ci-shard.test.ts @@ -0,0 +1,126 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, test } from "@effect/vitest"; +import { + CI_SHARD_COUNTS, + CI_TARGETS, + discoverTargetTestFiles, + durationForTestFile, + parseCiShardRequest, + planCiShards, + planTargetShards, +} from "./ci-shard"; +import { UNKNOWN_TEST_DURATION_MS } from "./ci-shard-durations"; + +describe("CI shard request parsing", () => { + test("parses a supported target and one-based index", () => { + expect(parseCiShardRequest(["cloud", "16"])).toEqual({ + ok: true, + value: { target: "cloud", index: 16, count: 16 }, + }); + }); + + test.each([ + { args: [] }, + { args: ["cloud"] }, + { args: ["unknown", "1"] }, + { args: ["cloud", "0"] }, + { args: ["cloud", "1.5"] }, + { args: ["cloud", "17"] }, + ])("rejects invalid external input: $args", ({ args }) => { + const result = parseCiShardRequest(args); + expect(result.ok).toBe(false); + }); +}); + +describe("CI shard planning", () => { + test("is deterministic and assigns every file exactly once", () => { + const files = Array.from({ length: 12 }, (_, index) => ({ + path: `test-${String(index).padStart(2, "0")}.test.ts`, + durationMs: (index + 1) * 1_000, + })); + + const first = planCiShards(files, 4); + const second = planCiShards([...files].reverse(), 4); + expect(second).toEqual(first); + + const assigned = first.flatMap((shard) => shard.files).sort(); + expect(assigned).toEqual(files.map((file) => file.path).sort()); + expect(new Set(assigned).size).toBe(files.length); + expect(Math.max(...first.map((shard) => shard.estimatedDurationMs))).toBe(21_000); + }); + + test("rejects duplicate paths and empty shards as planner defects", () => { + expect(() => + planCiShards( + [ + { path: "same.test.ts", durationMs: 1 }, + { path: "same.test.ts", durationMs: 1 }, + ], + 1, + ), + ).toThrow("unique"); + expect(() => planCiShards([{ path: "only.test.ts", durationMs: 1 }], 2)).toThrow( + "at least one", + ); + }); + + test("isolates irreducibly long files from unrelated work", () => { + const plan = planCiShards( + [ + { path: "slowest.test.ts", durationMs: 60_000 }, + { path: "slow.test.ts", durationMs: 50_000 }, + ...Array.from({ length: 8 }, (_, index) => ({ + path: `fast-${index}.test.ts`, + durationMs: 5_000, + })), + ], + 4, + ); + + expect(plan.find((shard) => shard.files.includes("slowest.test.ts"))?.files).toEqual([ + "slowest.test.ts", + ]); + expect(plan.find((shard) => shard.files.includes("slow.test.ts"))?.files).toEqual([ + "slow.test.ts", + ]); + }); + + test("gives unrecorded files conservative scheduling weight", () => { + expect(durationForTestFile("cloud", "cloud/new.test.ts")).toBe(UNKNOWN_TEST_DURATION_MS); + expect(durationForTestFile("cloud", "scenarios/health-checks-ui.test.ts")).toBe(71_165); + }); + + test("covers every currently discovered target file exactly once", async () => { + const e2eRoot = fileURLToPath(new URL("..", import.meta.url)); + for (const target of CI_TARGETS) { + const discovered = await discoverTargetTestFiles(target, e2eRoot); + const plan = await planTargetShards(target, e2eRoot); + const assigned = plan.flatMap((shard) => shard.files).sort(); + + expect(plan).toHaveLength(CI_SHARD_COUNTS[target]); + expect(plan.every((shard) => shard.files.length > 0)).toBe(true); + expect(assigned).toEqual([...discovered].sort()); + expect(new Set(assigned).size).toBe(discovered.length); + } + }); + + test("keeps the workflow matrix synchronized with configured shard counts", async () => { + const workflow = await readFile( + resolve(fileURLToPath(new URL("../..", import.meta.url)), ".github/workflows/ci.yml"), + "utf8", + ); + + for (const target of CI_TARGETS) { + const entries = workflow.matchAll( + new RegExp(`-\\s*\\{[^}]*target:\\s*${target},[^}]*shard-index:\\s*(\\d+)`, "g"), + ); + const indexes = [...entries].map((entry) => entry[1]); + + expect(indexes).toEqual( + Array.from({ length: CI_SHARD_COUNTS[target] }, (_, index) => String(index + 1)), + ); + } + }); +}); diff --git a/e2e/scripts/ci-shard.ts b/e2e/scripts/ci-shard.ts new file mode 100644 index 0000000000..cdfa417d5f --- /dev/null +++ b/e2e/scripts/ci-shard.ts @@ -0,0 +1,231 @@ +import { readdir } from "node:fs/promises"; +import { resolve } from "node:path"; +import { OBSERVED_TEST_DURATIONS_MS, UNKNOWN_TEST_DURATION_MS } from "./ci-shard-durations"; + +/** CI e2e targets whose files can be distributed across independent runners. */ +export type CiTarget = "cloud" | "local" | "selfhost"; + +/** The supported targets in stable display order. */ +export const CI_TARGETS: readonly CiTarget[] = ["cloud", "selfhost", "local"]; + +/** + * Shard counts sized so the recorded test workload plus runner setup stays + * close to two minutes without weakening coverage or enabling retries. + */ +export const CI_SHARD_COUNTS: Readonly> = { + cloud: 16, + selfhost: 10, + local: 2, +}; + +/** A test file and its estimated runtime used by the shard planner. */ +export interface WeightedTestFile { + readonly path: string; + readonly durationMs: number; +} + +/** A complete one-based shard assignment and its estimated total runtime. */ +export interface CiShardAssignment { + readonly index: number; + readonly estimatedDurationMs: number; + readonly files: readonly string[]; +} + +/** A parsed request for one target shard. */ +export interface CiShardRequest { + readonly target: CiTarget; + readonly index: number; + readonly count: number; +} + +/** A safe failure returned when command-line shard input cannot be parsed. */ +export class CiShardInputError extends Error { + readonly _tag = "CiShardInputError"; + + /** Creates an input error containing only safe command syntax information. */ + constructor(message: string) { + super(message); + this.name = "CiShardInputError"; + } +} + +/** The result of parsing an external shard request. */ +export type ParseCiShardRequestResult = + | { readonly ok: true; readonly value: CiShardRequest } + | { readonly ok: false; readonly error: CiShardInputError }; + +const testDirectories: Readonly> = { + cloud: ["scenarios", "cloud"], + selfhost: ["scenarios", "selfhost"], + local: ["local"], +}; + +// A file this long already consumes most of the two-minute budget after +// dependency linking, target startup, and Vitest global setup. Keep it alone +// instead of balancing unrelated work onto it. +const ISOLATED_TEST_DURATION_MS = 35_000; + +const comparePaths = (left: string, right: string): number => + left < right ? -1 : left > right ? 1 : 0; + +const parseTarget = (input: string): CiTarget | undefined => + CI_TARGETS.find((target) => target === input); + +const parseIndex = (input: string): number | undefined => { + if (!/^\d+$/.test(input)) return undefined; + const value = Number(input); + return Number.isSafeInteger(value) && value > 0 ? value : undefined; +}; + +const discoverTestFilesInDirectory = async ( + e2eRoot: string, + relativeDirectory: string, +): Promise => { + const entries = await readdir(resolve(e2eRoot, relativeDirectory), { + withFileTypes: true, + }); + const discovered = await Promise.all( + entries.map(async (entry): Promise => { + const relativePath = `${relativeDirectory}/${entry.name}`; + if (entry.isDirectory()) { + return discoverTestFilesInDirectory(e2eRoot, relativePath); + } + return entry.isFile() && entry.name.endsWith(".test.ts") ? [relativePath] : []; + }), + ); + return discovered.flat(); +}; + +/** + * Parses `[target, oneBasedIndex]`, deriving the target's fixed shard count. + * Invalid input is returned as a typed value and never reaches the planner. + */ +export const parseCiShardRequest = (args: readonly string[]): ParseCiShardRequestResult => { + if (args.length !== 2) { + return { + ok: false, + error: new CiShardInputError( + "usage: bun scripts/run-ci-shard.ts ", + ), + }; + } + + const target = parseTarget(args[0]); + if (target === undefined) { + return { + ok: false, + error: new CiShardInputError(`unsupported e2e target: ${args[0]}`), + }; + } + + const index = parseIndex(args[1]); + const count = CI_SHARD_COUNTS[target]; + if (index === undefined || index > count) { + return { + ok: false, + error: new CiShardInputError( + `shard index for ${target} must be an integer from 1 through ${count}`, + ), + }; + } + + return { ok: true, value: { target, index, count } }; +}; + +/** + * Discovers the complete set of test files owned by a target, relative to the + * supplied e2e root. Results are deduplicated and byte-order sorted. + */ +export const discoverTargetTestFiles = async ( + target: CiTarget, + e2eRoot: string, +): Promise => { + const discovered = await Promise.all( + testDirectories[target].map((directory) => discoverTestFilesInDirectory(e2eRoot, directory)), + ); + const files = new Set(discovered.flat()); + return [...files].sort(comparePaths); +}; + +/** + * Returns the observed duration for a known slow file or a conservative + * default for new and historically fast files. + */ +export const durationForTestFile = (target: CiTarget, path: string): number => + OBSERVED_TEST_DURATIONS_MS[target][path] ?? UNKNOWN_TEST_DURATION_MS; + +/** + * Plans every shard with deterministic longest-processing-time bin packing. + * + * @throws Error for internal defects such as duplicate files, invalid weights, + * or more shards than files. External CLI values must be parsed first. + */ +export const planCiShards = ( + files: readonly WeightedTestFile[], + shardCount: number, +): readonly CiShardAssignment[] => { + if (!Number.isSafeInteger(shardCount) || shardCount <= 0) { + throw new Error("shard count must be a positive safe integer"); + } + if (files.length < shardCount) { + throw new Error("every configured shard must receive at least one test file"); + } + + const paths = files.map((file) => file.path); + if (new Set(paths).size !== paths.length) { + throw new Error("test file paths must be unique"); + } + for (const file of files) { + if (file.path.length === 0 || !Number.isFinite(file.durationMs) || file.durationMs <= 0) { + throw new Error("test files must have a path and positive finite duration"); + } + } + + const bins = Array.from({ length: shardCount }, (_, index) => ({ + index: index + 1, + estimatedDurationMs: 0, + files: [] as string[], + isolated: false, + })); + const longestFirst = [...files].sort( + (left, right) => right.durationMs - left.durationMs || comparePaths(left.path, right.path), + ); + + for (const file of longestFirst) { + const mustIsolate = file.durationMs >= ISOLATED_TEST_DURATION_MS; + const candidates = bins.filter((bin) => (mustIsolate ? bin.files.length === 0 : !bin.isolated)); + const firstCandidate = candidates[0]; + if (firstCandidate === undefined) { + throw new Error("configured shard count cannot isolate every long-running test file"); + } + + let lightest = firstCandidate; + for (const candidate of candidates.slice(1)) { + if (candidate.estimatedDurationMs < lightest.estimatedDurationMs) { + lightest = candidate; + } + } + lightest.files.push(file.path); + lightest.estimatedDurationMs += file.durationMs; + lightest.isolated = mustIsolate; + } + + return bins.map((bin) => ({ + index: bin.index, + estimatedDurationMs: bin.estimatedDurationMs, + files: [...bin.files].sort(comparePaths), + })); +}; + +/** Discovers and plans all shards for one configured target. */ +export const planTargetShards = async ( + target: CiTarget, + e2eRoot: string, +): Promise => { + const paths = await discoverTargetTestFiles(target, e2eRoot); + const weighted = paths.map((path) => ({ + path, + durationMs: durationForTestFile(target, path), + })); + return planCiShards(weighted, CI_SHARD_COUNTS[target]); +}; diff --git a/e2e/scripts/ci-shard.vitest.config.ts b/e2e/scripts/ci-shard.vitest.config.ts new file mode 100644 index 0000000000..bddaeaf61e --- /dev/null +++ b/e2e/scripts/ci-shard.vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +/** Isolated unit-test config for the CI shard planner. */ +export default defineConfig({ + test: { + include: ["scripts/ci-shard.test.ts"], + maxWorkers: 1, + }, +}); diff --git a/e2e/scripts/run-ci-shard.ts b/e2e/scripts/run-ci-shard.ts new file mode 100644 index 0000000000..4a4fb3aedc --- /dev/null +++ b/e2e/scripts/run-ci-shard.ts @@ -0,0 +1,37 @@ +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { parseCiShardRequest, planTargetShards } from "./ci-shard"; + +const request = parseCiShardRequest(process.argv.slice(2)); +if (!request.ok) { + console.error(request.error.message); + process.exit(2); +} + +const e2eRoot = fileURLToPath(new URL("..", import.meta.url)); +const plan = await planTargetShards(request.value.target, e2eRoot); +const assignment = plan[request.value.index - 1]; +if (assignment === undefined || assignment.files.length === 0) { + throw new Error("configured shard has no test files"); +} + +console.log( + `Running ${request.value.target} shard ${request.value.index}/${request.value.count}: ` + + `${assignment.files.length} files, estimated ${Math.round(assignment.estimatedDurationMs / 1000)}s`, +); +for (const file of assignment.files) console.log(` ${file}`); + +const child = spawnSync( + process.execPath, + ["x", "vitest", "run", "--project", request.value.target, ...assignment.files], + { + cwd: e2eRoot, + env: process.env, + stdio: "inherit", + }, +); +if (child.error !== undefined) throw child.error; +if (child.signal !== null) { + throw new Error(`Vitest shard terminated by signal ${child.signal}`); +} +process.exit(child.status ?? 1); diff --git a/packages/core/sdk/src/pending-approval.test.ts b/packages/core/sdk/src/pending-approval.test.ts index 2614559007..1224b72bba 100644 --- a/packages/core/sdk/src/pending-approval.test.ts +++ b/packages/core/sdk/src/pending-approval.test.ts @@ -87,6 +87,23 @@ describe("makePendingApprovalStore", () => { }), ); + it.effect("keeps the full human-scale approval window without waiting on wall clock", () => + Effect.gen(function* () { + const recordedAt = 1_000_000; + let now = recordedAt + PENDING_APPROVAL_TTL_MS - 1; + const blobs = makeInMemoryBlobStore(); + const store = makePendingApprovalStore(blobs, "u:t:s", () => now); + const expiresAt = recordedAt + PENDING_APPROVAL_TTL_MS; + yield* store.put(approval({ expiresAt })); + + expect(yield* store.consume("exec_1")).not.toBeNull(); + + yield* store.put(approval({ expiresAt })); + now += 1; + expect(yield* store.consume("exec_1")).toBeNull(); + }), + ); + // A record we reject is a record nobody should be able to retry against. it.effect("drops an expired record rather than leaving it to be retried", () => Effect.gen(function* () { diff --git a/turbo.json b/turbo.json index cb596f7f42..c95bb5fc19 100644 --- a/turbo.json +++ b/turbo.json @@ -9,7 +9,6 @@ "outputs": ["dist/**"] }, "test": { - "dependsOn": ["^test"], "outputs": [] }, "typecheck": { From 039422cd217f58785a7a40c4395f479b1b7992fb Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 18 Aug 2026 08:51:52 -0700 Subject: [PATCH 038/133] Drop ignored package from mixed changeset (#1651) --- .changeset/mcp-client-span-attribution.md | 1 - 1 file changed, 1 deletion(-) diff --git a/.changeset/mcp-client-span-attribution.md b/.changeset/mcp-client-span-attribution.md index c3fc4cda24..b70476ac52 100644 --- a/.changeset/mcp-client-span-attribution.md +++ b/.changeset/mcp-client-span-attribution.md @@ -1,5 +1,4 @@ --- -"@executor-js/host-mcp": patch "@executor-js/cloudflare": patch --- From 22a0383758aef57106fda7aa05825c0607352feb Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 18 Aug 2026 08:59:15 -0700 Subject: [PATCH 039/133] Version Packages (#1653) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .../hosted-client-span-header-redaction.md | 5 -- .changeset/mcp-client-span-attribution.md | 7 -- .changeset/preserve-google-refresh-scopes.md | 5 -- .changeset/stdio-modern-negotiation.md | 7 -- apps/cli/CHANGELOG.md | 10 +++ apps/cli/package.json | 2 +- apps/cloud/CHANGELOG.md | 21 ++++++ apps/cloud/package.json | 2 +- apps/desktop/CHANGELOG.md | 2 + apps/desktop/package.json | 2 +- apps/host-selfhost/CHANGELOG.md | 21 ++++++ apps/host-selfhost/package.json | 2 +- apps/local/CHANGELOG.md | 27 ++++++++ apps/local/package.json | 2 +- bun.lock | 64 +++++++++---------- e2e/CHANGELOG.md | 12 ++++ e2e/package.json | 2 +- examples/all-plugins/CHANGELOG.md | 14 ++++ examples/all-plugins/package.json | 2 +- examples/docs-sdk-quickstart/CHANGELOG.md | 8 +++ examples/docs-sdk-quickstart/package.json | 2 +- packages/core/analytics/CHANGELOG.md | 7 ++ packages/core/analytics/package.json | 2 +- packages/core/api/CHANGELOG.md | 9 +++ packages/core/api/package.json | 2 +- packages/core/cli/CHANGELOG.md | 7 ++ packages/core/cli/package.json | 2 +- packages/core/config/CHANGELOG.md | 7 ++ packages/core/config/package.json | 2 +- packages/core/execution/CHANGELOG.md | 8 +++ packages/core/execution/package.json | 2 +- packages/core/sdk/CHANGELOG.md | 6 ++ packages/core/sdk/package.json | 2 +- packages/core/vite-plugin/CHANGELOG.md | 7 ++ packages/core/vite-plugin/package.json | 2 +- packages/hosts/cloudflare/CHANGELOG.md | 14 ++++ packages/hosts/cloudflare/package.json | 2 +- packages/hosts/mcp-apps-shell/CHANGELOG.md | 8 +++ packages/hosts/mcp-apps-shell/package.json | 2 +- packages/kernel/core/CHANGELOG.md | 2 + packages/kernel/core/package.json | 2 +- packages/kernel/runtime-quickjs/CHANGELOG.md | 7 ++ packages/kernel/runtime-quickjs/package.json | 2 +- .../runtime-workerd-subprocess/CHANGELOG.md | 7 ++ .../runtime-workerd-subprocess/package.json | 2 +- .../plugins/desktop-settings/CHANGELOG.md | 7 ++ .../plugins/desktop-settings/package.json | 2 +- .../plugins/encrypted-secrets/CHANGELOG.md | 7 ++ .../plugins/encrypted-secrets/package.json | 2 +- packages/plugins/example/CHANGELOG.md | 7 ++ packages/plugins/example/package.json | 2 +- packages/plugins/file-secrets/CHANGELOG.md | 7 ++ packages/plugins/file-secrets/package.json | 2 +- packages/plugins/graphql/CHANGELOG.md | 10 +++ packages/plugins/graphql/package.json | 2 +- packages/plugins/keychain/CHANGELOG.md | 7 ++ packages/plugins/keychain/package.json | 2 +- packages/plugins/mcp/CHANGELOG.md | 14 ++++ packages/plugins/mcp/package.json | 2 +- packages/plugins/onepassword/CHANGELOG.md | 9 +++ packages/plugins/onepassword/package.json | 2 +- packages/plugins/openapi/CHANGELOG.md | 12 ++++ packages/plugins/openapi/package.json | 2 +- .../provider-service-split/CHANGELOG.md | 8 +++ .../provider-service-split/package.json | 2 +- packages/plugins/toolkits/CHANGELOG.md | 9 +++ packages/plugins/toolkits/package.json | 2 +- packages/react/CHANGELOG.md | 8 +++ packages/react/package.json | 2 +- 69 files changed, 373 insertions(+), 88 deletions(-) delete mode 100644 .changeset/hosted-client-span-header-redaction.md delete mode 100644 .changeset/mcp-client-span-attribution.md delete mode 100644 .changeset/preserve-google-refresh-scopes.md delete mode 100644 .changeset/stdio-modern-negotiation.md diff --git a/.changeset/hosted-client-span-header-redaction.md b/.changeset/hosted-client-span-header-redaction.md deleted file mode 100644 index ac53f0f276..0000000000 --- a/.changeset/hosted-client-span-header-redaction.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@executor-js/sdk": patch ---- - -Redact every span header attribute outside a safe allowlist on the hosted HTTP client. The tracer's default four-name blocklist let provider-specific credential headers reach the trace backend verbatim; the hosted client now inverts the model and masks everything except structurally safe negotiation, caching, and tracing headers. diff --git a/.changeset/mcp-client-span-attribution.md b/.changeset/mcp-client-span-attribution.md deleted file mode 100644 index b70476ac52..0000000000 --- a/.changeset/mcp-client-span-attribution.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@executor-js/cloudflare": patch ---- - -**MCP execution spans now carry the client identity (`mcp.client.*`)** - -The `clientInfo` a client self-reports at `initialize` (or in a modern request's `_meta`) previously existed only on the initialize request itself, which has no session id yet, so execution telemetry could not be segmented by client. Execute, execute-action, and resume spans (and their descendants) now carry `mcp.client.name` / `mcp.client.version` / `mcp.client.title` alongside the existing session join keys. Cloudflare session Durable Objects persist the reported identity in session meta, so attribution survives cold restores; it feeds telemetry only, never behavior. diff --git a/.changeset/preserve-google-refresh-scopes.md b/.changeset/preserve-google-refresh-scopes.md deleted file mode 100644 index 1863dae042..0000000000 --- a/.changeset/preserve-google-refresh-scopes.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@executor-js/plugin-openapi": patch ---- - -Preserve an integration's selected OAuth consent scopes when refreshing converted API specifications, so Google Gmail refreshes do not restore operations that require broader scopes. diff --git a/.changeset/stdio-modern-negotiation.md b/.changeset/stdio-modern-negotiation.md deleted file mode 100644 index 4c5861de9a..0000000000 --- a/.changeset/stdio-modern-negotiation.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@executor-js/plugin-mcp": patch ---- - -**Stdio MCP servers can negotiate the modern protocol (`versionNegotiation: "auto"`)** - -Spawned stdio MCP integrations previously always opened with the legacy 2025 `initialize` handshake, so an SDK v2 server running with its legacy compatibility lane disabled could not connect. Stdio integrations now accept `versionNegotiation: "auto"` (on `mcp.addServer` and the stored config) to probe `server/discover` per spec 2026-07-28, falling back to `initialize` on legacy servers. The default stays `legacy`: the SDK's stdio probe costs an extra short-lived child process per connect and stalls on silent legacy servers, which is the wrong trade for spawn-per-call CLI servers. The connect handshake span now records the negotiated era (`plugin.mcp.protocol_era`) so integration authors can verify which handshake a connection used. diff --git a/apps/cli/CHANGELOG.md b/apps/cli/CHANGELOG.md index 788c9fb6dd..4144daee42 100644 --- a/apps/cli/CHANGELOG.md +++ b/apps/cli/CHANGELOG.md @@ -1,5 +1,15 @@ # executor +## 1.5.42 + +### Patch Changes + +- Updated dependencies [[`d3f0617`](https://github.com/UsefulSoftwareCo/executor/commit/d3f0617deec06c57e0d6e1479fe668f79daf977d)]: + - @executor-js/sdk@1.5.42 + - @executor-js/local@1.5.42 + - @executor-js/api@1.4.62 + - @executor-js/runtime-quickjs@1.5.42 + ## 1.5.41 ### Patch Changes diff --git a/apps/cli/package.json b/apps/cli/package.json index 17283aeb5c..760b7c6a41 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "executor", - "version": "1.5.41", + "version": "1.5.42", "private": true, "bin": { "executor": "./bin/executor.ts" diff --git a/apps/cloud/CHANGELOG.md b/apps/cloud/CHANGELOG.md index 6cbd8108b9..f7651fd1eb 100644 --- a/apps/cloud/CHANGELOG.md +++ b/apps/cloud/CHANGELOG.md @@ -1,5 +1,26 @@ # @executor-js/cloud +## 1.4.60 + +### Patch Changes + +- Updated dependencies [[`d3f0617`](https://github.com/UsefulSoftwareCo/executor/commit/d3f0617deec06c57e0d6e1479fe668f79daf977d), [`86c68af`](https://github.com/UsefulSoftwareCo/executor/commit/86c68afef9bf8b7c19ab58f59acfedca0b3c4ca7), [`32206c7`](https://github.com/UsefulSoftwareCo/executor/commit/32206c7f78654f638bfd27c25c71c30c3d6354be), [`9ecc7cb`](https://github.com/UsefulSoftwareCo/executor/commit/9ecc7cb8b30375ffa960e3fefe4d211e0254e691)]: + - @executor-js/sdk@1.5.42 + - @executor-js/cloudflare@0.0.41 + - @executor-js/plugin-openapi@1.5.42 + - @executor-js/plugin-mcp@1.5.42 + - @executor-js/api@1.4.62 + - @executor-js/execution@1.5.42 + - @executor-js/vite-plugin@0.0.59 + - @executor-js/host-mcp@1.4.4 + - @executor-js/mcp-apps-shell@1.4.10 + - @executor-js/runtime-dynamic-worker@1.4.4 + - @executor-js/plugin-graphql@1.5.42 + - @executor-js/plugin-toolkits@1.5.34 + - @executor-js/plugin-workos-vault@0.0.2 + - @executor-js/react@1.4.62 + - @executor-js/runtime-quickjs@1.5.42 + ## 1.4.59 ### Patch Changes diff --git a/apps/cloud/package.json b/apps/cloud/package.json index cd801b0723..df90fee0c0 100644 --- a/apps/cloud/package.json +++ b/apps/cloud/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/cloud", - "version": "1.4.59", + "version": "1.4.60", "private": true, "type": "module", "scripts": { diff --git a/apps/desktop/CHANGELOG.md b/apps/desktop/CHANGELOG.md index f5b8a9f0d2..69f0c90c55 100644 --- a/apps/desktop/CHANGELOG.md +++ b/apps/desktop/CHANGELOG.md @@ -1,5 +1,7 @@ # @executor-js/desktop +## 1.5.42 + ## 1.5.41 ## 1.5.40 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 41757cb8f7..366b730be8 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/desktop", - "version": "1.5.41", + "version": "1.5.42", "private": true, "homepage": "https://github.com/UsefulSoftwareCo/executor", "license": "MIT", diff --git a/apps/host-selfhost/CHANGELOG.md b/apps/host-selfhost/CHANGELOG.md index d36703c0c5..cf5b60f9a6 100644 --- a/apps/host-selfhost/CHANGELOG.md +++ b/apps/host-selfhost/CHANGELOG.md @@ -1,5 +1,26 @@ # @executor-js/host-selfhost +## 0.0.41 + +### Patch Changes + +- Updated dependencies [[`d3f0617`](https://github.com/UsefulSoftwareCo/executor/commit/d3f0617deec06c57e0d6e1479fe668f79daf977d), [`32206c7`](https://github.com/UsefulSoftwareCo/executor/commit/32206c7f78654f638bfd27c25c71c30c3d6354be), [`9ecc7cb`](https://github.com/UsefulSoftwareCo/executor/commit/9ecc7cb8b30375ffa960e3fefe4d211e0254e691)]: + - @executor-js/sdk@1.5.42 + - @executor-js/plugin-openapi@1.5.42 + - @executor-js/plugin-mcp@1.5.42 + - @executor-js/app@1.4.4 + - @executor-js/analytics@0.1.6 + - @executor-js/api@1.4.62 + - @executor-js/execution@1.5.42 + - @executor-js/host-mcp@1.4.4 + - @executor-js/mcp-apps-shell@1.4.10 + - @executor-js/plugin-encrypted-secrets@0.0.41 + - @executor-js/plugin-graphql@1.5.42 + - @executor-js/plugin-provider-service-split@0.0.13 + - @executor-js/plugin-toolkits@1.5.34 + - @executor-js/react@1.4.62 + - @executor-js/runtime-quickjs@1.5.42 + ## 0.0.40 ### Patch Changes diff --git a/apps/host-selfhost/package.json b/apps/host-selfhost/package.json index 6be5f51117..a7c66c2691 100644 --- a/apps/host-selfhost/package.json +++ b/apps/host-selfhost/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/host-selfhost", - "version": "0.0.40", + "version": "0.0.41", "private": true, "type": "module", "exports": { diff --git a/apps/local/CHANGELOG.md b/apps/local/CHANGELOG.md index f6048198c0..12d187e2fe 100644 --- a/apps/local/CHANGELOG.md +++ b/apps/local/CHANGELOG.md @@ -1,5 +1,32 @@ # @executor-js/local +## 1.5.42 + +### Patch Changes + +- Updated dependencies [[`d3f0617`](https://github.com/UsefulSoftwareCo/executor/commit/d3f0617deec06c57e0d6e1479fe668f79daf977d), [`32206c7`](https://github.com/UsefulSoftwareCo/executor/commit/32206c7f78654f638bfd27c25c71c30c3d6354be), [`9ecc7cb`](https://github.com/UsefulSoftwareCo/executor/commit/9ecc7cb8b30375ffa960e3fefe4d211e0254e691)]: + - @executor-js/sdk@1.5.42 + - @executor-js/plugin-openapi@1.5.42 + - @executor-js/plugin-mcp@1.5.42 + - @executor-js/app@1.4.4 + - @executor-js/analytics@0.1.6 + - @executor-js/api@1.4.62 + - @executor-js/config@1.5.42 + - @executor-js/execution@1.5.42 + - @executor-js/vite-plugin@0.0.59 + - @executor-js/host-mcp@1.4.4 + - @executor-js/mcp-apps-shell@1.4.10 + - @executor-js/plugin-desktop-settings@1.5.42 + - @executor-js/plugin-example@1.5.42 + - @executor-js/plugin-file-secrets@1.5.42 + - @executor-js/plugin-graphql@1.5.42 + - @executor-js/plugin-keychain@1.5.42 + - @executor-js/plugin-onepassword@1.5.42 + - @executor-js/plugin-provider-service-split@0.0.13 + - @executor-js/plugin-toolkits@1.5.34 + - @executor-js/react@1.4.62 + - @executor-js/runtime-quickjs@1.5.42 + ## 1.5.41 ### Patch Changes diff --git a/apps/local/package.json b/apps/local/package.json index cff47c8940..fae1bc4a4e 100644 --- a/apps/local/package.json +++ b/apps/local/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/local", - "version": "1.5.41", + "version": "1.5.42", "private": true, "type": "module", "exports": { diff --git a/bun.lock b/bun.lock index f0c9b7ae92..79a2e02d8a 100644 --- a/bun.lock +++ b/bun.lock @@ -31,7 +31,7 @@ }, "apps/cli": { "name": "executor", - "version": "1.5.41", + "version": "1.5.42", "bin": { "executor": "./bin/executor.ts", }, @@ -61,7 +61,7 @@ }, "apps/cloud": { "name": "@executor-js/cloud", - "version": "1.4.59", + "version": "1.4.60", "dependencies": { "@cloudflare/vite-plugin": "^1.31.1", "@effect/atom-react": "catalog:", @@ -134,7 +134,7 @@ }, "apps/desktop": { "name": "@executor-js/desktop", - "version": "1.5.41", + "version": "1.5.42", "dependencies": { "@sentry/bun": "^10.57.0", "@sentry/electron": "^7.13.0", @@ -221,7 +221,7 @@ }, "apps/host-selfhost": { "name": "@executor-js/host-selfhost", - "version": "0.0.40", + "version": "0.0.41", "dependencies": { "@better-auth/api-key": "^1.6.11", "@cloudflare/worker-bundler": "0.2.1", @@ -273,7 +273,7 @@ }, "apps/local": { "name": "@executor-js/local", - "version": "1.5.41", + "version": "1.5.42", "dependencies": { "@effect/atom-react": "catalog:", "@effect/platform-node": "catalog:", @@ -355,7 +355,7 @@ }, "e2e": { "name": "@executor-js/e2e", - "version": "0.0.38", + "version": "0.0.39", "dependencies": { "@executor-js/api": "workspace:*", "@executor-js/emulate": "^0.13.9", @@ -393,7 +393,7 @@ }, "examples/all-plugins": { "name": "@executor-js/example-all-plugins", - "version": "0.0.59", + "version": "0.0.60", "dependencies": { "@executor-js/plugin-file-secrets": "workspace:*", "@executor-js/plugin-graphql": "workspace:*", @@ -412,7 +412,7 @@ }, "examples/docs-sdk-quickstart": { "name": "@executor-js/example-docs-sdk-quickstart", - "version": "0.0.44", + "version": "0.0.45", "dependencies": { "@executor-js/plugin-openapi": "workspace:*", "@executor-js/sdk": "workspace:*", @@ -467,7 +467,7 @@ }, "packages/core/analytics": { "name": "@executor-js/analytics", - "version": "0.1.5", + "version": "0.1.6", "dependencies": { "@effect/platform-node": "catalog:", "@executor-js/execution": "workspace:*", @@ -483,7 +483,7 @@ }, "packages/core/api": { "name": "@executor-js/api", - "version": "1.4.61", + "version": "1.4.62", "dependencies": { "@executor-js/execution": "workspace:*", "@executor-js/host-mcp": "workspace:*", @@ -500,7 +500,7 @@ }, "packages/core/cli": { "name": "@executor-js/cli", - "version": "0.2.48", + "version": "0.2.49", "bin": { "executor-sdk": "./dist/index.js", }, @@ -521,7 +521,7 @@ }, "packages/core/config": { "name": "@executor-js/config", - "version": "1.5.41", + "version": "1.5.42", "dependencies": { "@executor-js/sdk": "workspace:*", "jiti": "^2.6.1", @@ -542,7 +542,7 @@ }, "packages/core/execution": { "name": "@executor-js/execution", - "version": "1.5.41", + "version": "1.5.42", "dependencies": { "@executor-js/codemode-core": "workspace:*", "@executor-js/sdk": "workspace:*", @@ -608,7 +608,7 @@ }, "packages/core/sdk": { "name": "@executor-js/sdk", - "version": "1.5.41", + "version": "1.5.42", "dependencies": { "@executor-js/fumadb": "workspace:*", "@standard-schema/spec": "^1.1.0", @@ -661,7 +661,7 @@ }, "packages/core/vite-plugin": { "name": "@executor-js/vite-plugin", - "version": "0.0.58", + "version": "0.0.59", "dependencies": { "@executor-js/sdk": "workspace:*", "jiti": "^2.6.1", @@ -681,7 +681,7 @@ }, "packages/hosts/cloudflare": { "name": "@executor-js/cloudflare", - "version": "0.0.40", + "version": "0.0.41", "dependencies": { "@executor-js/api": "workspace:*", "@executor-js/execution": "workspace:*", @@ -723,7 +723,7 @@ }, "packages/hosts/mcp-apps-shell": { "name": "@executor-js/mcp-apps-shell", - "version": "1.4.9", + "version": "1.4.10", "dependencies": { "@executor-js/react": "workspace:*", "@executor-js/runtime-quickjs": "workspace:*", @@ -761,7 +761,7 @@ }, "packages/kernel/core": { "name": "@executor-js/codemode-core", - "version": "1.5.41", + "version": "1.5.42", "dependencies": { "@babel/parser": "^7.29.2", "@standard-schema/spec": "^1.0.0", @@ -834,7 +834,7 @@ }, "packages/kernel/runtime-quickjs": { "name": "@executor-js/runtime-quickjs", - "version": "1.5.41", + "version": "1.5.42", "dependencies": { "@executor-js/codemode-core": "workspace:*", "quickjs-emscripten": "catalog:", @@ -854,7 +854,7 @@ }, "packages/kernel/runtime-workerd-subprocess": { "name": "@executor-js/runtime-workerd-subprocess", - "version": "0.0.13", + "version": "0.0.14", "dependencies": { "@executor-js/codemode-core": "workspace:*", "effect": "catalog:", @@ -869,7 +869,7 @@ }, "packages/plugins/desktop-settings": { "name": "@executor-js/plugin-desktop-settings", - "version": "1.5.41", + "version": "1.5.42", "dependencies": { "@executor-js/sdk": "workspace:*", "react": "catalog:", @@ -882,7 +882,7 @@ }, "packages/plugins/encrypted-secrets": { "name": "@executor-js/plugin-encrypted-secrets", - "version": "0.0.40", + "version": "0.0.41", "dependencies": { "@executor-js/sdk": "workspace:*", "effect": "catalog:", @@ -897,7 +897,7 @@ }, "packages/plugins/example": { "name": "@executor-js/plugin-example", - "version": "1.5.41", + "version": "1.5.42", "dependencies": { "@executor-js/sdk": "workspace:*", }, @@ -920,7 +920,7 @@ }, "packages/plugins/file-secrets": { "name": "@executor-js/plugin-file-secrets", - "version": "1.5.41", + "version": "1.5.42", "dependencies": { "@executor-js/sdk": "workspace:*", }, @@ -937,7 +937,7 @@ }, "packages/plugins/graphql": { "name": "@executor-js/plugin-graphql", - "version": "1.5.41", + "version": "1.5.42", "dependencies": { "@effect/platform-node": "catalog:", "@executor-js/config": "workspace:*", @@ -976,7 +976,7 @@ }, "packages/plugins/keychain": { "name": "@executor-js/plugin-keychain", - "version": "1.5.41", + "version": "1.5.42", "dependencies": { "@executor-js/sdk": "workspace:*", "@napi-rs/keyring": "^1.2.0", @@ -995,7 +995,7 @@ }, "packages/plugins/mcp": { "name": "@executor-js/plugin-mcp", - "version": "1.5.41", + "version": "1.5.42", "dependencies": { "@cfworker/json-schema": "^4.1.1", "@effect/platform-node": "catalog:", @@ -1038,7 +1038,7 @@ }, "packages/plugins/onepassword": { "name": "@executor-js/plugin-onepassword", - "version": "1.5.41", + "version": "1.5.42", "dependencies": { "@1password/op-js": "^0.1.13", "@1password/sdk": "^0.4.1-beta.1", @@ -1072,7 +1072,7 @@ }, "packages/plugins/openapi": { "name": "@executor-js/plugin-openapi", - "version": "1.5.41", + "version": "1.5.42", "dependencies": { "@effect/platform-node": "catalog:", "@executor-js/config": "workspace:*", @@ -1113,7 +1113,7 @@ }, "packages/plugins/provider-service-split": { "name": "@executor-js/plugin-provider-service-split", - "version": "0.0.12", + "version": "0.0.13", "dependencies": { "@executor-js/plugin-openapi": "workspace:*", "@executor-js/sdk": "workspace:*", @@ -1130,7 +1130,7 @@ }, "packages/plugins/toolkits": { "name": "@executor-js/plugin-toolkits", - "version": "1.5.33", + "version": "1.5.34", "dependencies": { "@executor-js/sdk": "workspace:*", }, @@ -1199,7 +1199,7 @@ }, "packages/react": { "name": "@executor-js/react", - "version": "1.4.61", + "version": "1.4.62", "dependencies": { "@base-ui/react": "^1.3.0", "@effect/atom-react": "catalog:", diff --git a/e2e/CHANGELOG.md b/e2e/CHANGELOG.md index fe3bf7c892..cd520d27de 100644 --- a/e2e/CHANGELOG.md +++ b/e2e/CHANGELOG.md @@ -1,5 +1,17 @@ # @executor-js/e2e +## 0.0.39 + +### Patch Changes + +- Updated dependencies [[`d3f0617`](https://github.com/UsefulSoftwareCo/executor/commit/d3f0617deec06c57e0d6e1479fe668f79daf977d), [`32206c7`](https://github.com/UsefulSoftwareCo/executor/commit/32206c7f78654f638bfd27c25c71c30c3d6354be), [`9ecc7cb`](https://github.com/UsefulSoftwareCo/executor/commit/9ecc7cb8b30375ffa960e3fefe4d211e0254e691)]: + - @executor-js/sdk@1.5.42 + - @executor-js/plugin-openapi@1.5.42 + - @executor-js/plugin-mcp@1.5.42 + - @executor-js/api@1.4.62 + - @executor-js/plugin-graphql@1.5.42 + - @executor-js/plugin-toolkits@1.5.34 + ## 0.0.38 ### Patch Changes diff --git a/e2e/package.json b/e2e/package.json index ffaa25b958..91be6b0d13 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/e2e", - "version": "0.0.38", + "version": "0.0.39", "private": true, "type": "module", "scripts": { diff --git a/examples/all-plugins/CHANGELOG.md b/examples/all-plugins/CHANGELOG.md index 5c5293d87c..e6bd92860b 100644 --- a/examples/all-plugins/CHANGELOG.md +++ b/examples/all-plugins/CHANGELOG.md @@ -1,5 +1,19 @@ # @executor-js/example-all-plugins +## 0.0.60 + +### Patch Changes + +- Updated dependencies [[`d3f0617`](https://github.com/UsefulSoftwareCo/executor/commit/d3f0617deec06c57e0d6e1479fe668f79daf977d), [`32206c7`](https://github.com/UsefulSoftwareCo/executor/commit/32206c7f78654f638bfd27c25c71c30c3d6354be), [`9ecc7cb`](https://github.com/UsefulSoftwareCo/executor/commit/9ecc7cb8b30375ffa960e3fefe4d211e0254e691)]: + - @executor-js/sdk@1.5.42 + - @executor-js/plugin-openapi@1.5.42 + - @executor-js/plugin-mcp@1.5.42 + - @executor-js/plugin-file-secrets@1.5.42 + - @executor-js/plugin-graphql@1.5.42 + - @executor-js/plugin-keychain@1.5.42 + - @executor-js/plugin-onepassword@1.5.42 + - @executor-js/plugin-workos-vault@0.0.2 + ## 0.0.59 ### Patch Changes diff --git a/examples/all-plugins/package.json b/examples/all-plugins/package.json index 67b3383ee0..da944c1166 100644 --- a/examples/all-plugins/package.json +++ b/examples/all-plugins/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/example-all-plugins", - "version": "0.0.59", + "version": "0.0.60", "private": true, "type": "module", "scripts": { diff --git a/examples/docs-sdk-quickstart/CHANGELOG.md b/examples/docs-sdk-quickstart/CHANGELOG.md index 8836e9b462..5eec50eb1f 100644 --- a/examples/docs-sdk-quickstart/CHANGELOG.md +++ b/examples/docs-sdk-quickstart/CHANGELOG.md @@ -1,5 +1,13 @@ # @executor-js/example-docs-sdk-quickstart +## 0.0.45 + +### Patch Changes + +- Updated dependencies [[`d3f0617`](https://github.com/UsefulSoftwareCo/executor/commit/d3f0617deec06c57e0d6e1479fe668f79daf977d), [`32206c7`](https://github.com/UsefulSoftwareCo/executor/commit/32206c7f78654f638bfd27c25c71c30c3d6354be)]: + - @executor-js/sdk@1.5.42 + - @executor-js/plugin-openapi@1.5.42 + ## 0.0.44 ### Patch Changes diff --git a/examples/docs-sdk-quickstart/package.json b/examples/docs-sdk-quickstart/package.json index fc9c331e94..e46fc18892 100644 --- a/examples/docs-sdk-quickstart/package.json +++ b/examples/docs-sdk-quickstart/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/example-docs-sdk-quickstart", - "version": "0.0.44", + "version": "0.0.45", "private": true, "type": "module", "scripts": { diff --git a/packages/core/analytics/CHANGELOG.md b/packages/core/analytics/CHANGELOG.md index c7f7b34bdc..b26ac6af86 100644 --- a/packages/core/analytics/CHANGELOG.md +++ b/packages/core/analytics/CHANGELOG.md @@ -1,5 +1,12 @@ # @executor-js/analytics +## 0.1.6 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/execution@1.5.42 + ## 0.1.5 ### Patch Changes diff --git a/packages/core/analytics/package.json b/packages/core/analytics/package.json index bcc42e977a..ea638f0875 100644 --- a/packages/core/analytics/package.json +++ b/packages/core/analytics/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/analytics", - "version": "0.1.5", + "version": "0.1.6", "private": true, "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/analytics", "bugs": { diff --git a/packages/core/api/CHANGELOG.md b/packages/core/api/CHANGELOG.md index 7c4e9baf87..bbe48b574a 100644 --- a/packages/core/api/CHANGELOG.md +++ b/packages/core/api/CHANGELOG.md @@ -1,5 +1,14 @@ # @executor-js/api +## 1.4.62 + +### Patch Changes + +- Updated dependencies [[`d3f0617`](https://github.com/UsefulSoftwareCo/executor/commit/d3f0617deec06c57e0d6e1479fe668f79daf977d)]: + - @executor-js/sdk@1.5.42 + - @executor-js/execution@1.5.42 + - @executor-js/host-mcp@1.4.4 + ## 1.4.61 ### Patch Changes diff --git a/packages/core/api/package.json b/packages/core/api/package.json index b162989e81..ada420e1b5 100644 --- a/packages/core/api/package.json +++ b/packages/core/api/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/api", - "version": "1.4.61", + "version": "1.4.62", "private": true, "type": "module", "exports": { diff --git a/packages/core/cli/CHANGELOG.md b/packages/core/cli/CHANGELOG.md index 56396bb0ce..e36cf1b6f5 100644 --- a/packages/core/cli/CHANGELOG.md +++ b/packages/core/cli/CHANGELOG.md @@ -1,5 +1,12 @@ # @executor-js/cli +## 0.2.49 + +### Patch Changes + +- Updated dependencies [[`d3f0617`](https://github.com/UsefulSoftwareCo/executor/commit/d3f0617deec06c57e0d6e1479fe668f79daf977d)]: + - @executor-js/sdk@1.5.42 + ## 0.2.48 ### Patch Changes diff --git a/packages/core/cli/package.json b/packages/core/cli/package.json index d22baeaf3c..682f5b7288 100644 --- a/packages/core/cli/package.json +++ b/packages/core/cli/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/cli", - "version": "0.2.48", + "version": "0.2.49", "description": "CLI for the executor SDK — schema generation, migrations", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/cli", "bugs": { diff --git a/packages/core/config/CHANGELOG.md b/packages/core/config/CHANGELOG.md index 37f3278614..3cb6115960 100644 --- a/packages/core/config/CHANGELOG.md +++ b/packages/core/config/CHANGELOG.md @@ -1,5 +1,12 @@ # @executor-js/config +## 1.5.42 + +### Patch Changes + +- Updated dependencies [[`d3f0617`](https://github.com/UsefulSoftwareCo/executor/commit/d3f0617deec06c57e0d6e1479fe668f79daf977d)]: + - @executor-js/sdk@1.5.42 + ## 1.5.41 ### Patch Changes diff --git a/packages/core/config/package.json b/packages/core/config/package.json index 64f281307c..65fb56eca2 100644 --- a/packages/core/config/package.json +++ b/packages/core/config/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/config", - "version": "1.5.41", + "version": "1.5.42", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/config", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/core/execution/CHANGELOG.md b/packages/core/execution/CHANGELOG.md index ad3dbcc04f..2b54daa441 100644 --- a/packages/core/execution/CHANGELOG.md +++ b/packages/core/execution/CHANGELOG.md @@ -1,5 +1,13 @@ # @executor-js/execution +## 1.5.42 + +### Patch Changes + +- Updated dependencies [[`d3f0617`](https://github.com/UsefulSoftwareCo/executor/commit/d3f0617deec06c57e0d6e1479fe668f79daf977d)]: + - @executor-js/sdk@1.5.42 + - @executor-js/codemode-core@1.5.42 + ## 1.5.41 ### Patch Changes diff --git a/packages/core/execution/package.json b/packages/core/execution/package.json index 6e5718eaec..b344b472d5 100644 --- a/packages/core/execution/package.json +++ b/packages/core/execution/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/execution", - "version": "1.5.41", + "version": "1.5.42", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/execution", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/core/sdk/CHANGELOG.md b/packages/core/sdk/CHANGELOG.md index d74c5642a7..4423d546a9 100644 --- a/packages/core/sdk/CHANGELOG.md +++ b/packages/core/sdk/CHANGELOG.md @@ -1,5 +1,11 @@ # @executor-js/sdk +## 1.5.42 + +### Patch Changes + +- [#1626](https://github.com/UsefulSoftwareCo/executor/pull/1626) [`d3f0617`](https://github.com/UsefulSoftwareCo/executor/commit/d3f0617deec06c57e0d6e1479fe668f79daf977d) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Redact every span header attribute outside a safe allowlist on the hosted HTTP client. The tracer's default four-name blocklist let provider-specific credential headers reach the trace backend verbatim; the hosted client now inverts the model and masks everything except structurally safe negotiation, caching, and tracing headers. + ## 1.5.41 ### Patch Changes diff --git a/packages/core/sdk/package.json b/packages/core/sdk/package.json index 974a88022b..6545c8281f 100644 --- a/packages/core/sdk/package.json +++ b/packages/core/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/sdk", - "version": "1.5.41", + "version": "1.5.42", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/sdk", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/core/vite-plugin/CHANGELOG.md b/packages/core/vite-plugin/CHANGELOG.md index 93320a0c90..277d898062 100644 --- a/packages/core/vite-plugin/CHANGELOG.md +++ b/packages/core/vite-plugin/CHANGELOG.md @@ -1,5 +1,12 @@ # @executor-js/vite-plugin +## 0.0.59 + +### Patch Changes + +- Updated dependencies [[`d3f0617`](https://github.com/UsefulSoftwareCo/executor/commit/d3f0617deec06c57e0d6e1479fe668f79daf977d)]: + - @executor-js/sdk@1.5.42 + ## 0.0.58 ### Patch Changes diff --git a/packages/core/vite-plugin/package.json b/packages/core/vite-plugin/package.json index 9e5cc55cba..aba715a07b 100644 --- a/packages/core/vite-plugin/package.json +++ b/packages/core/vite-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/vite-plugin", - "version": "0.0.58", + "version": "0.0.59", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/core/vite-plugin", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/hosts/cloudflare/CHANGELOG.md b/packages/hosts/cloudflare/CHANGELOG.md index 14c9e5870e..ea7df00ec2 100644 --- a/packages/hosts/cloudflare/CHANGELOG.md +++ b/packages/hosts/cloudflare/CHANGELOG.md @@ -1,5 +1,19 @@ # @executor-js/cloudflare +## 0.0.41 + +### Patch Changes + +- [#1621](https://github.com/UsefulSoftwareCo/executor/pull/1621) [`86c68af`](https://github.com/UsefulSoftwareCo/executor/commit/86c68afef9bf8b7c19ab58f59acfedca0b3c4ca7) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **MCP execution spans now carry the client identity (`mcp.client.*`)** + + The `clientInfo` a client self-reports at `initialize` (or in a modern request's `_meta`) previously existed only on the initialize request itself, which has no session id yet, so execution telemetry could not be segmented by client. Execute, execute-action, and resume spans (and their descendants) now carry `mcp.client.name` / `mcp.client.version` / `mcp.client.title` alongside the existing session join keys. Cloudflare session Durable Objects persist the reported identity in session meta, so attribution survives cold restores; it feeds telemetry only, never behavior. + +- Updated dependencies [[`d3f0617`](https://github.com/UsefulSoftwareCo/executor/commit/d3f0617deec06c57e0d6e1479fe668f79daf977d)]: + - @executor-js/sdk@1.5.42 + - @executor-js/api@1.4.62 + - @executor-js/execution@1.5.42 + - @executor-js/host-mcp@1.4.4 + ## 0.0.40 ### Patch Changes diff --git a/packages/hosts/cloudflare/package.json b/packages/hosts/cloudflare/package.json index 45f148a1f0..5d12172fb1 100644 --- a/packages/hosts/cloudflare/package.json +++ b/packages/hosts/cloudflare/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/cloudflare", - "version": "0.0.40", + "version": "0.0.41", "private": true, "type": "module", "exports": { diff --git a/packages/hosts/mcp-apps-shell/CHANGELOG.md b/packages/hosts/mcp-apps-shell/CHANGELOG.md index 3bf4d4f92d..0c5100cebd 100644 --- a/packages/hosts/mcp-apps-shell/CHANGELOG.md +++ b/packages/hosts/mcp-apps-shell/CHANGELOG.md @@ -1,5 +1,13 @@ # @executor-js/mcp-apps-shell +## 1.4.10 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/react@1.4.62 + - @executor-js/runtime-quickjs@1.5.42 + ## 1.4.9 ### Patch Changes diff --git a/packages/hosts/mcp-apps-shell/package.json b/packages/hosts/mcp-apps-shell/package.json index 97c1e70bf4..dccf1396c6 100644 --- a/packages/hosts/mcp-apps-shell/package.json +++ b/packages/hosts/mcp-apps-shell/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/mcp-apps-shell", - "version": "1.4.9", + "version": "1.4.10", "private": true, "type": "module", "exports": { diff --git a/packages/kernel/core/CHANGELOG.md b/packages/kernel/core/CHANGELOG.md index e78d6a09f3..fbcb33457f 100644 --- a/packages/kernel/core/CHANGELOG.md +++ b/packages/kernel/core/CHANGELOG.md @@ -1,5 +1,7 @@ # @executor-js/codemode-core +## 1.5.42 + ## 1.5.41 ## 1.5.40 diff --git a/packages/kernel/core/package.json b/packages/kernel/core/package.json index c0df8960df..be8580478e 100644 --- a/packages/kernel/core/package.json +++ b/packages/kernel/core/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/codemode-core", - "version": "1.5.41", + "version": "1.5.42", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/kernel/core", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/kernel/runtime-quickjs/CHANGELOG.md b/packages/kernel/runtime-quickjs/CHANGELOG.md index 93343b53d6..cd9579188e 100644 --- a/packages/kernel/runtime-quickjs/CHANGELOG.md +++ b/packages/kernel/runtime-quickjs/CHANGELOG.md @@ -1,5 +1,12 @@ # @executor-js/runtime-quickjs +## 1.5.42 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/codemode-core@1.5.42 + ## 1.5.41 ### Patch Changes diff --git a/packages/kernel/runtime-quickjs/package.json b/packages/kernel/runtime-quickjs/package.json index ecdea06bce..1479dfd8b3 100644 --- a/packages/kernel/runtime-quickjs/package.json +++ b/packages/kernel/runtime-quickjs/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/runtime-quickjs", - "version": "1.5.41", + "version": "1.5.42", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/kernel/runtime-quickjs", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/kernel/runtime-workerd-subprocess/CHANGELOG.md b/packages/kernel/runtime-workerd-subprocess/CHANGELOG.md index bf54459ac7..e7bca976d3 100644 --- a/packages/kernel/runtime-workerd-subprocess/CHANGELOG.md +++ b/packages/kernel/runtime-workerd-subprocess/CHANGELOG.md @@ -1,5 +1,12 @@ # @executor-js/runtime-workerd-subprocess +## 0.0.14 + +### Patch Changes + +- Updated dependencies []: + - @executor-js/codemode-core@1.5.42 + ## 0.0.13 ### Patch Changes diff --git a/packages/kernel/runtime-workerd-subprocess/package.json b/packages/kernel/runtime-workerd-subprocess/package.json index 45b0fff929..a72abff29b 100644 --- a/packages/kernel/runtime-workerd-subprocess/package.json +++ b/packages/kernel/runtime-workerd-subprocess/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/runtime-workerd-subprocess", - "version": "0.0.13", + "version": "0.0.14", "private": true, "type": "module", "exports": { diff --git a/packages/plugins/desktop-settings/CHANGELOG.md b/packages/plugins/desktop-settings/CHANGELOG.md index 3a934d8cc1..9f2be893e6 100644 --- a/packages/plugins/desktop-settings/CHANGELOG.md +++ b/packages/plugins/desktop-settings/CHANGELOG.md @@ -1,5 +1,12 @@ # @executor-js/plugin-desktop-settings +## 1.5.42 + +### Patch Changes + +- Updated dependencies [[`d3f0617`](https://github.com/UsefulSoftwareCo/executor/commit/d3f0617deec06c57e0d6e1479fe668f79daf977d)]: + - @executor-js/sdk@1.5.42 + ## 1.5.41 ### Patch Changes diff --git a/packages/plugins/desktop-settings/package.json b/packages/plugins/desktop-settings/package.json index 9e38635f71..f4fa0ae7bf 100644 --- a/packages/plugins/desktop-settings/package.json +++ b/packages/plugins/desktop-settings/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-desktop-settings", - "version": "1.5.41", + "version": "1.5.42", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/desktop-settings", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/plugins/encrypted-secrets/CHANGELOG.md b/packages/plugins/encrypted-secrets/CHANGELOG.md index 2e514b7e4a..4e55a78b43 100644 --- a/packages/plugins/encrypted-secrets/CHANGELOG.md +++ b/packages/plugins/encrypted-secrets/CHANGELOG.md @@ -1,5 +1,12 @@ # @executor-js/plugin-encrypted-secrets +## 0.0.41 + +### Patch Changes + +- Updated dependencies [[`d3f0617`](https://github.com/UsefulSoftwareCo/executor/commit/d3f0617deec06c57e0d6e1479fe668f79daf977d)]: + - @executor-js/sdk@1.5.42 + ## 0.0.40 ### Patch Changes diff --git a/packages/plugins/encrypted-secrets/package.json b/packages/plugins/encrypted-secrets/package.json index ce6c763ab9..f78aee5a1c 100644 --- a/packages/plugins/encrypted-secrets/package.json +++ b/packages/plugins/encrypted-secrets/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-encrypted-secrets", - "version": "0.0.40", + "version": "0.0.41", "private": true, "type": "module", "exports": { diff --git a/packages/plugins/example/CHANGELOG.md b/packages/plugins/example/CHANGELOG.md index 887b7e24c5..8f68c06d39 100644 --- a/packages/plugins/example/CHANGELOG.md +++ b/packages/plugins/example/CHANGELOG.md @@ -1,5 +1,12 @@ # @executor-js/plugin-example +## 1.5.42 + +### Patch Changes + +- Updated dependencies [[`d3f0617`](https://github.com/UsefulSoftwareCo/executor/commit/d3f0617deec06c57e0d6e1479fe668f79daf977d)]: + - @executor-js/sdk@1.5.42 + ## 1.5.41 ### Patch Changes diff --git a/packages/plugins/example/package.json b/packages/plugins/example/package.json index 180618b16e..b6afab9661 100644 --- a/packages/plugins/example/package.json +++ b/packages/plugins/example/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-example", - "version": "1.5.41", + "version": "1.5.42", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/example", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/plugins/file-secrets/CHANGELOG.md b/packages/plugins/file-secrets/CHANGELOG.md index 5ebb8d4a1e..2118d5a4fc 100644 --- a/packages/plugins/file-secrets/CHANGELOG.md +++ b/packages/plugins/file-secrets/CHANGELOG.md @@ -1,5 +1,12 @@ # @executor-js/plugin-file-secrets +## 1.5.42 + +### Patch Changes + +- Updated dependencies [[`d3f0617`](https://github.com/UsefulSoftwareCo/executor/commit/d3f0617deec06c57e0d6e1479fe668f79daf977d)]: + - @executor-js/sdk@1.5.42 + ## 1.5.41 ### Patch Changes diff --git a/packages/plugins/file-secrets/package.json b/packages/plugins/file-secrets/package.json index 0cdb7c7558..0808aaf07b 100644 --- a/packages/plugins/file-secrets/package.json +++ b/packages/plugins/file-secrets/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-file-secrets", - "version": "1.5.41", + "version": "1.5.42", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/file-secrets", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/plugins/graphql/CHANGELOG.md b/packages/plugins/graphql/CHANGELOG.md index a96ef8dd0f..9635c2251b 100644 --- a/packages/plugins/graphql/CHANGELOG.md +++ b/packages/plugins/graphql/CHANGELOG.md @@ -1,5 +1,15 @@ # @executor-js/plugin-graphql +## 1.5.42 + +### Patch Changes + +- Updated dependencies [[`d3f0617`](https://github.com/UsefulSoftwareCo/executor/commit/d3f0617deec06c57e0d6e1479fe668f79daf977d)]: + - @executor-js/sdk@1.5.42 + - @executor-js/api@1.4.62 + - @executor-js/config@1.5.42 + - @executor-js/react@1.4.62 + ## 1.5.41 ### Patch Changes diff --git a/packages/plugins/graphql/package.json b/packages/plugins/graphql/package.json index 34ca6bc979..327582cb51 100644 --- a/packages/plugins/graphql/package.json +++ b/packages/plugins/graphql/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-graphql", - "version": "1.5.41", + "version": "1.5.42", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/graphql", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/plugins/keychain/CHANGELOG.md b/packages/plugins/keychain/CHANGELOG.md index 7126e0b863..7e95a55057 100644 --- a/packages/plugins/keychain/CHANGELOG.md +++ b/packages/plugins/keychain/CHANGELOG.md @@ -1,5 +1,12 @@ # @executor-js/plugin-keychain +## 1.5.42 + +### Patch Changes + +- Updated dependencies [[`d3f0617`](https://github.com/UsefulSoftwareCo/executor/commit/d3f0617deec06c57e0d6e1479fe668f79daf977d)]: + - @executor-js/sdk@1.5.42 + ## 1.5.41 ### Patch Changes diff --git a/packages/plugins/keychain/package.json b/packages/plugins/keychain/package.json index fa364aeb0a..cdd4e1e8e6 100644 --- a/packages/plugins/keychain/package.json +++ b/packages/plugins/keychain/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-keychain", - "version": "1.5.41", + "version": "1.5.42", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/keychain", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/plugins/mcp/CHANGELOG.md b/packages/plugins/mcp/CHANGELOG.md index d729d6b605..6acf7ea2a7 100644 --- a/packages/plugins/mcp/CHANGELOG.md +++ b/packages/plugins/mcp/CHANGELOG.md @@ -1,5 +1,19 @@ # @executor-js/plugin-mcp +## 1.5.42 + +### Patch Changes + +- [#1646](https://github.com/UsefulSoftwareCo/executor/pull/1646) [`9ecc7cb`](https://github.com/UsefulSoftwareCo/executor/commit/9ecc7cb8b30375ffa960e3fefe4d211e0254e691) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - **Stdio MCP servers can negotiate the modern protocol (`versionNegotiation: "auto"`)** + + Spawned stdio MCP integrations previously always opened with the legacy 2025 `initialize` handshake, so an SDK v2 server running with its legacy compatibility lane disabled could not connect. Stdio integrations now accept `versionNegotiation: "auto"` (on `mcp.addServer` and the stored config) to probe `server/discover` per spec 2026-07-28, falling back to `initialize` on legacy servers. The default stays `legacy`: the SDK's stdio probe costs an extra short-lived child process per connect and stalls on silent legacy servers, which is the wrong trade for spawn-per-call CLI servers. The connect handshake span now records the negotiated era (`plugin.mcp.protocol_era`) so integration authors can verify which handshake a connection used. + +- Updated dependencies [[`d3f0617`](https://github.com/UsefulSoftwareCo/executor/commit/d3f0617deec06c57e0d6e1479fe668f79daf977d)]: + - @executor-js/sdk@1.5.42 + - @executor-js/api@1.4.62 + - @executor-js/config@1.5.42 + - @executor-js/react@1.4.62 + ## 1.5.41 ### Patch Changes diff --git a/packages/plugins/mcp/package.json b/packages/plugins/mcp/package.json index af9334a4ac..9864dd7ff8 100644 --- a/packages/plugins/mcp/package.json +++ b/packages/plugins/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-mcp", - "version": "1.5.41", + "version": "1.5.42", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/mcp", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/plugins/onepassword/CHANGELOG.md b/packages/plugins/onepassword/CHANGELOG.md index a1ea0218c2..1e216ca206 100644 --- a/packages/plugins/onepassword/CHANGELOG.md +++ b/packages/plugins/onepassword/CHANGELOG.md @@ -1,5 +1,14 @@ # @executor-js/plugin-onepassword +## 1.5.42 + +### Patch Changes + +- Updated dependencies [[`d3f0617`](https://github.com/UsefulSoftwareCo/executor/commit/d3f0617deec06c57e0d6e1479fe668f79daf977d)]: + - @executor-js/sdk@1.5.42 + - @executor-js/api@1.4.62 + - @executor-js/react@1.4.62 + ## 1.5.41 ### Patch Changes diff --git a/packages/plugins/onepassword/package.json b/packages/plugins/onepassword/package.json index f42e5a966c..ee98c9ed65 100644 --- a/packages/plugins/onepassword/package.json +++ b/packages/plugins/onepassword/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-onepassword", - "version": "1.5.41", + "version": "1.5.42", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/onepassword", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/plugins/openapi/CHANGELOG.md b/packages/plugins/openapi/CHANGELOG.md index 3369c43f11..b930d0bab7 100644 --- a/packages/plugins/openapi/CHANGELOG.md +++ b/packages/plugins/openapi/CHANGELOG.md @@ -1,5 +1,17 @@ # @executor-js/plugin-openapi +## 1.5.42 + +### Patch Changes + +- [#1642](https://github.com/UsefulSoftwareCo/executor/pull/1642) [`32206c7`](https://github.com/UsefulSoftwareCo/executor/commit/32206c7f78654f638bfd27c25c71c30c3d6354be) Thanks [@RhysSullivan](https://github.com/RhysSullivan)! - Preserve an integration's selected OAuth consent scopes when refreshing converted API specifications, so Google Gmail refreshes do not restore operations that require broader scopes. + +- Updated dependencies [[`d3f0617`](https://github.com/UsefulSoftwareCo/executor/commit/d3f0617deec06c57e0d6e1479fe668f79daf977d)]: + - @executor-js/sdk@1.5.42 + - @executor-js/api@1.4.62 + - @executor-js/config@1.5.42 + - @executor-js/react@1.4.62 + ## 1.5.41 ### Patch Changes diff --git a/packages/plugins/openapi/package.json b/packages/plugins/openapi/package.json index a4a3521889..887cb2ad3e 100644 --- a/packages/plugins/openapi/package.json +++ b/packages/plugins/openapi/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-openapi", - "version": "1.5.41", + "version": "1.5.42", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/openapi", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/plugins/provider-service-split/CHANGELOG.md b/packages/plugins/provider-service-split/CHANGELOG.md index 1380067295..4162c2a4d7 100644 --- a/packages/plugins/provider-service-split/CHANGELOG.md +++ b/packages/plugins/provider-service-split/CHANGELOG.md @@ -1,5 +1,13 @@ # @executor-js/plugin-provider-service-split +## 0.0.13 + +### Patch Changes + +- Updated dependencies [[`d3f0617`](https://github.com/UsefulSoftwareCo/executor/commit/d3f0617deec06c57e0d6e1479fe668f79daf977d), [`32206c7`](https://github.com/UsefulSoftwareCo/executor/commit/32206c7f78654f638bfd27c25c71c30c3d6354be)]: + - @executor-js/sdk@1.5.42 + - @executor-js/plugin-openapi@1.5.42 + ## 0.0.12 ### Patch Changes diff --git a/packages/plugins/provider-service-split/package.json b/packages/plugins/provider-service-split/package.json index 612ae13919..78943a9d73 100644 --- a/packages/plugins/provider-service-split/package.json +++ b/packages/plugins/provider-service-split/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-provider-service-split", - "version": "0.0.12", + "version": "0.0.13", "private": true, "type": "module", "exports": { diff --git a/packages/plugins/toolkits/CHANGELOG.md b/packages/plugins/toolkits/CHANGELOG.md index 4b172603a5..7b2286ccc3 100644 --- a/packages/plugins/toolkits/CHANGELOG.md +++ b/packages/plugins/toolkits/CHANGELOG.md @@ -1,5 +1,14 @@ # @executor-js/plugin-toolkits +## 1.5.34 + +### Patch Changes + +- Updated dependencies [[`d3f0617`](https://github.com/UsefulSoftwareCo/executor/commit/d3f0617deec06c57e0d6e1479fe668f79daf977d)]: + - @executor-js/sdk@1.5.42 + - @executor-js/api@1.4.62 + - @executor-js/react@1.4.62 + ## 1.5.33 ### Patch Changes diff --git a/packages/plugins/toolkits/package.json b/packages/plugins/toolkits/package.json index d451d3dfec..92db77f814 100644 --- a/packages/plugins/toolkits/package.json +++ b/packages/plugins/toolkits/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/plugin-toolkits", - "version": "1.5.33", + "version": "1.5.34", "homepage": "https://github.com/UsefulSoftwareCo/executor/tree/main/packages/plugins/toolkits", "bugs": { "url": "https://github.com/UsefulSoftwareCo/executor/issues" diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index 8e0cc56a2c..f85d103023 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -1,5 +1,13 @@ # @executor-js/react +## 1.4.62 + +### Patch Changes + +- Updated dependencies [[`d3f0617`](https://github.com/UsefulSoftwareCo/executor/commit/d3f0617deec06c57e0d6e1479fe668f79daf977d)]: + - @executor-js/sdk@1.5.42 + - @executor-js/api@1.4.62 + ## 1.4.61 ### Patch Changes diff --git a/packages/react/package.json b/packages/react/package.json index 8cbbd971b8..171d7032b0 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/react", - "version": "1.4.61", + "version": "1.4.62", "private": true, "type": "module", "exports": { From 12a1bb2c1ecbd2502b5147c8339d1fdb753299da Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 18 Aug 2026 08:59:59 -0700 Subject: [PATCH 040/133] Normalize Slack OAuth scopes (#1652) --- packages/core/sdk/src/oauth-helpers.test.ts | 66 +++++++++++++++++++++ packages/core/sdk/src/oauth-helpers.ts | 31 ++++++++-- 2 files changed, 93 insertions(+), 4 deletions(-) diff --git a/packages/core/sdk/src/oauth-helpers.test.ts b/packages/core/sdk/src/oauth-helpers.test.ts index 87ec8aae43..0f39c391b6 100644 --- a/packages/core/sdk/src/oauth-helpers.test.ts +++ b/packages/core/sdk/src/oauth-helpers.test.ts @@ -99,6 +99,14 @@ const tokenResponse = () => json(200, body); +const tokenResponseFetch = + (body: unknown): typeof globalThis.fetch => + async () => + new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); + // --------------------------------------------------------------------------- // PKCE // --------------------------------------------------------------------------- @@ -575,6 +583,46 @@ describe("exchangeAuthorizationCode", () => { ), ); + it.effect("normalizes Slack's comma-delimited top-level scopes", () => + Effect.gen(function* () { + const result = yield* exchangeAuthorizationCode({ + tokenUrl: "https://slack.com/api/oauth.v2.user.access", + clientId: "cid", + clientSecret: "csecret", + redirectUrl: "https://app.example.com/cb", + codeVerifier: "verifier", + code: "abc", + fetch: tokenResponseFetch({ + access_token: "xoxp-user-token", + token_type: "Bearer", + scope: "channels:read,chat:write,reactions:read", + }), + }); + + expect(result.scope).toBe("channels:read chat:write reactions:read"); + }), + ); + + it.effect("preserves commas in scope tokens from non-Slack providers", () => + Effect.gen(function* () { + const result = yield* exchangeAuthorizationCode({ + tokenUrl: "https://oauth.example.com/token", + clientId: "cid", + clientSecret: "csecret", + redirectUrl: "https://app.example.com/cb", + codeVerifier: "verifier", + code: "abc", + fetch: tokenResponseFetch({ + access_token: "provider-token", + token_type: "Bearer", + scope: "scope,with-comma other.scope", + }), + }); + + expect(result.scope).toBe("scope,with-comma other.scope"); + }), + ); + it.effect("keeps a standard top-level scope ahead of nested provider metadata", () => withTokenEndpoint( tokenResponse({ @@ -856,6 +904,24 @@ describe("exchangeClientCredentials", () => { }); describe("refreshAccessToken", () => { + it.effect("normalizes Slack's comma-delimited scopes on refresh", () => + Effect.gen(function* () { + const result = yield* refreshAccessToken({ + tokenUrl: "https://slack.com/api/oauth.v2.user.access", + clientId: "cid", + clientSecret: "csecret", + refreshToken: "refresh-token", + fetch: tokenResponseFetch({ + access_token: "xoxp-refreshed-token", + token_type: "Bearer", + scope: "channels:read,chat:write,reactions:read", + }), + }); + + expect(result.scope).toBe("channels:read chat:write reactions:read"); + }), + ); + it.effect("posts grant_type=refresh_token with the refresh token", () => withTokenEndpoint(tokenResponse(validRefreshBody), ({ tokenUrl, calls }) => Effect.gen(function* () { diff --git a/packages/core/sdk/src/oauth-helpers.ts b/packages/core/sdk/src/oauth-helpers.ts index 44128de102..f6ccc7a53b 100644 --- a/packages/core/sdk/src/oauth-helpers.ts +++ b/packages/core/sdk/src/oauth-helpers.ts @@ -552,12 +552,34 @@ const pickClientAuth = ( : oauth.ClientSecretPost(clientSecret); }; -const tokenResponseFrom = (r: oauth.TokenEndpointResponse): OAuth2TokenResponse => ({ +const normalizedTokenScope = ( + as: oauth.AuthorizationServer, + scope: string | undefined, +): string | undefined => { + if (scope === undefined || scope.trim().length === 0) return undefined; + const tokenEndpoint = typeof as.token_endpoint === "string" ? URL.parse(as.token_endpoint) : null; + const isSlackTokenEndpoint = + tokenEndpoint?.hostname.toLowerCase() === "slack.com" && + (tokenEndpoint.pathname === "/api/oauth.v2.access" || + tokenEndpoint.pathname === "/api/oauth.v2.user.access"); + if (!isSlackTokenEndpoint) return scope; + + const normalized = scope + .split(/[\s,]+/) + .filter(Boolean) + .join(" "); + return normalized.length > 0 ? normalized : undefined; +}; + +const tokenResponseFrom = ( + as: oauth.AuthorizationServer, + r: oauth.TokenEndpointResponse, +): OAuth2TokenResponse => ({ access_token: r.access_token, token_type: r.token_type, refresh_token: r.refresh_token, expires_in: typeof r.expires_in === "number" ? r.expires_in : undefined, - scope: typeof r.scope === "string" && r.scope.trim().length > 0 ? r.scope : undefined, + scope: normalizedTokenScope(as, typeof r.scope === "string" ? r.scope : undefined), }); const JwtClaims = Schema.Record(Schema.String, Schema.Unknown); @@ -694,6 +716,7 @@ const processTokenEndpointResponse = async ( const stripped = await stripIdToken(response); const providerUserGrant = await nestedAuthedUserGrant(stripped.response); const parsed = tokenResponseFrom( + as, await oauth.processGenericTokenEndpointResponse(as, client, stripped.response), ); const token = @@ -839,7 +862,7 @@ export const exchangeClientCredentials = ( ), ); const result = await oauth.processClientCredentialsResponse(as, client, response); - return tokenResponseFrom(result); + return tokenResponseFrom(as, result); }, catch: (cause) => cause, }).pipe( @@ -918,7 +941,7 @@ export const refreshAccessToken = ( client, (await stripIdToken(response)).response, ); - return tokenResponseFrom(result); + return tokenResponseFrom(as, result); }, catch: (cause) => cause, }).pipe( From 256e25e7b291b0c023bc7547d092004b66781bba Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 18 Aug 2026 09:19:59 -0700 Subject: [PATCH 041/133] Kill stdio MCP children when a dial is interrupted (#1654) --- .changeset/stdio-interrupt-child-cleanup.md | 7 ++ packages/plugins/mcp/src/sdk/connection.ts | 7 +- packages/plugins/mcp/src/sdk/discover.ts | 53 +++++---- .../src/sdk/stdio-interrupt-cleanup.test.ts | 112 ++++++++++++++++++ .../src/sdk/stdio-interrupt-test-server.ts | 65 ++++++++++ 5 files changed, 222 insertions(+), 22 deletions(-) create mode 100644 .changeset/stdio-interrupt-child-cleanup.md create mode 100644 packages/plugins/mcp/src/sdk/stdio-interrupt-cleanup.test.ts create mode 100644 packages/plugins/mcp/src/sdk/stdio-interrupt-test-server.ts diff --git a/.changeset/stdio-interrupt-child-cleanup.md b/.changeset/stdio-interrupt-child-cleanup.md new file mode 100644 index 0000000000..687598ade8 --- /dev/null +++ b/.changeset/stdio-interrupt-child-cleanup.md @@ -0,0 +1,7 @@ +--- +"@executor-js/plugin-mcp": patch +--- + +**Interrupted stdio dials no longer strand the spawned child process** + +Cancelling an in-flight health check or tool discovery (a UI refresh aborting the request, or the 15s discovery timeout) abandoned the MCP connect handshake without closing the transport, leaving the spawned stdio child running indefinitely: for `docker run -i --rm` integrations, one stranded container per interrupted dial. The connect handshake now aborts on interruption (the SDK closes the transport, ending stdin and escalating to SIGTERM/SIGKILL), and tool discovery closes the connection even when the interrupt lands between the handshake completing and discovery starting. diff --git a/packages/plugins/mcp/src/sdk/connection.ts b/packages/plugins/mcp/src/sdk/connection.ts index ad9eabbf36..3035716858 100644 --- a/packages/plugins/mcp/src/sdk/connection.ts +++ b/packages/plugins/mcp/src/sdk/connection.ts @@ -257,7 +257,12 @@ const connectClient = (input: { const transportInstance = input.createTransport(); yield* Effect.tryPromise({ - try: () => client.connect(transportInstance), + // Interruption (an HTTP 499 cancelling a health check, the discovery + // timeout) aborts this signal; the SDK then fails the in-flight + // handshake and closes the transport. Without it the abandoned connect + // kept the spawned stdio child alive forever; `docker run -i --rm` + // integrations stranded a container per interrupted dial (#1631). + try: (signal) => client.connect(transportInstance, { signal }), catch: (cause) => connectionFailure(input.transport, `Failed connecting via ${input.transport}`, cause), }).pipe( diff --git a/packages/plugins/mcp/src/sdk/discover.ts b/packages/plugins/mcp/src/sdk/discover.ts index 2734e73f54..5e965b9f44 100644 --- a/packages/plugins/mcp/src/sdk/discover.ts +++ b/packages/plugins/mcp/src/sdk/discover.ts @@ -91,34 +91,45 @@ const listAllTools = ( * forever. On timeout, any connection that DID get established is closed * before the timeout error is raised (`Effect.onExit` still fires for an * interrupted fiber). + * + * Interruption-safe: the connect phase cleans up after itself (the connector + * aborts the handshake and closes the transport, killing any spawned stdio + * child; #1631), and the mask below removes the window between the + * connector succeeding and `onExit` attaching, where an interrupt would + * leak the connection. The connector and listTools stay `restore`d so a 499 + * or the timeout above can still cancel them promptly. */ export const discoverTools = ( connector: McpConnector, timeoutMs: number = Duration.toMillis(DEFAULT_DISCOVER_TIMEOUT), ): Effect.Effect => - Effect.gen(function* () { - // Acquire connection - const connection = yield* connector.pipe( - Effect.mapError((failure) => { - // Preserve the handshake HTTP status (401/403 = auth wall) so the - // liveness health check can classify structurally. - const httpStatus = Predicate.isTagged(failure, "McpConnectionError") - ? failure.httpStatus - : undefined; - return new McpToolDiscoveryError({ - stage: "connect", - message: `Failed connecting to MCP server: ${failure.message}`, - ...(httpStatus !== undefined ? { httpStatus } : {}), - }); - }), - ); + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + // Acquire connection + const connection = yield* restore( + connector.pipe( + Effect.mapError((failure) => { + // Preserve the handshake HTTP status (401/403 = auth wall) so the + // liveness health check can classify structurally. + const httpStatus = Predicate.isTagged(failure, "McpConnectionError") + ? failure.httpStatus + : undefined; + return new McpToolDiscoveryError({ + stage: "connect", + message: `Failed connecting to MCP server: ${failure.message}`, + ...(httpStatus !== undefined ? { httpStatus } : {}), + }); + }), + ), + ); - const manifest = yield* listAllTools(connection).pipe( - Effect.onExit(() => closeConnection(connection)), - ); + const manifest = yield* restore(listAllTools(connection)).pipe( + Effect.onExit(() => closeConnection(connection)), + ); - return manifest; - }).pipe( + return manifest; + }), + ).pipe( Effect.timeoutOrElse({ duration: Duration.millis(timeoutMs), orElse: () => diff --git a/packages/plugins/mcp/src/sdk/stdio-interrupt-cleanup.test.ts b/packages/plugins/mcp/src/sdk/stdio-interrupt-cleanup.test.ts new file mode 100644 index 0000000000..2031c0caa7 --- /dev/null +++ b/packages/plugins/mcp/src/sdk/stdio-interrupt-cleanup.test.ts @@ -0,0 +1,112 @@ +// Regression coverage for #1631: interrupting a fiber mid-dial (an HTTP 499 +// cancelling a health check on app refresh, or the discovery timeout) must +// tear down the stdio child the transport spawned. Before the fix the +// abandoned `client.connect` promise kept the child alive forever; every +// interrupted health check stranded one `docker run -i --rm` container. +// +// `it.live`: these tests measure real child-process lifetime, so they need +// the wall clock; under the TestClock the fixture's delayed initialize +// reply and the discovery timeout would never fire. + +import { describe, expect, it } from "@effect/vitest"; +import { Duration, Effect, Fiber } from "effect"; +import { existsSync, mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { createMcpConnector } from "./connection"; +import { discoverTools } from "./discover"; + +const fixture = fileURLToPath(new URL("./stdio-interrupt-test-server.ts", import.meta.url)); + +const isAlive = (pid: number): boolean => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: process.kill(pid, 0) reports "process gone" only by throwing ESRCH + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +}; + +const killQuietly = (pid: number): void => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: kill throws ESRCH when the child already exited, which is the desired state + try { + process.kill(pid, "SIGKILL"); + } catch { + // already gone + } +}; + +const waitUntil = (predicate: () => boolean, timeoutMs: number) => + Effect.gen(function* () { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() > deadline) return false; + yield* Effect.sleep(Duration.millis(50)); + } + return true; + }); + +const makeFixture = (mode: "fast" | "slow" | "never") => { + const pidFile = join(mkdtempSync(join(tmpdir(), "mcp-stdio-interrupt-")), "pid"); + const connector = createMcpConnector({ + transport: "stdio", + command: "bun", + args: ["run", fixture, pidFile, mode], + }); + const spawned = waitUntil(() => existsSync(pidFile), 10_000); + const readPid = () => Number(readFileSync(pidFile, "utf8")); + return { connector, spawned, readPid }; +}; + +// The transport's teardown ends stdin first and escalates to SIGTERM only +// after 2s, so a cleaned-up child can legitimately take a moment to exit. +const exitsAfterCleanup = (pid: number) => + Effect.gen(function* () { + const exited = yield* waitUntil(() => !isAlive(pid), 5_000); + killQuietly(pid); + return exited; + }); + +describe("stdio child cleanup on interruption (#1631)", () => { + it.live("uninterrupted discovery closes the child", () => + Effect.gen(function* () { + const { connector, spawned, readPid } = makeFixture("fast"); + const manifest = yield* discoverTools(connector); + expect(manifest.tools).toEqual([]); + expect(yield* spawned).toBe(true); + expect(yield* exitsAfterCleanup(readPid())).toBe(true); + }), + ); + + it.live("interrupting mid-handshake kills the child", () => + Effect.gen(function* () { + const { connector, spawned, readPid } = makeFixture("slow"); + const fiber = yield* discoverTools(connector).pipe(Effect.forkDetach); + + expect(yield* spawned).toBe(true); + const pid = readPid(); + expect(isAlive(pid)).toBe(true); + + // The initialize reply arrives at t+3s, so the handshake is still in + // flight; cancel the fiber the way the HTTP layer does on a 499. + yield* Effect.sleep(Duration.millis(200)); + yield* Fiber.interrupt(fiber); + + expect(yield* exitsAfterCleanup(pid)).toBe(true); + }), + ); + + it.live("the discovery timeout kills the child", () => + Effect.gen(function* () { + const { connector, spawned, readPid } = makeFixture("never"); + const failure = yield* discoverTools(connector, 1_000).pipe(Effect.flip); + expect(failure.message).toContain("timed out"); + + expect(yield* spawned).toBe(true); + expect(yield* exitsAfterCleanup(readPid())).toBe(true); + }), + ); +}); diff --git a/packages/plugins/mcp/src/sdk/stdio-interrupt-test-server.ts b/packages/plugins/mcp/src/sdk/stdio-interrupt-test-server.ts new file mode 100644 index 0000000000..3258ccb19c --- /dev/null +++ b/packages/plugins/mcp/src/sdk/stdio-interrupt-test-server.ts @@ -0,0 +1,65 @@ +// Fixture for stdio-interrupt-cleanup.test.ts. A minimal legacy-handshake MCP +// server that stands in for a `docker run -i --rm` stdio integration: it +// writes its PID to the file named by argv so the test can observe process +// lifetime, and it exits only when stdin closes or it is signalled (the same +// exit contract as the docker CLI). The mode argument controls the initialize +// reply: "fast" answers immediately, "slow" answers after 3s (keeps the +// handshake in flight so the test can interrupt mid-connect), "never" withholds +// it (a wedged server, for the discovery-timeout path). + +import { writeFileSync } from "node:fs"; + +const pidFile = process.argv[2]; +const mode = process.argv[3] ?? "fast"; +if (pidFile === undefined) { + process.stderr.write("usage: stdio-interrupt-test-server.ts [fast|slow|never]\n"); + process.exit(2); +} +writeFileSync(pidFile, String(process.pid)); + +const respond = (message: object): void => { + process.stdout.write(`${JSON.stringify(message)}\n`); +}; + +const handle = (line: string): void => { + if (!line.trim()) return; + let request: { id?: number; method?: string; params?: { protocolVersion?: string } }; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: standalone non-Effect fixture process; a malformed frame is silently dropped like a real server would + try { + // oxlint-disable-next-line executor/no-json-parse -- boundary: hand-rolled JSON-RPC framing is the fixture's entire purpose (it must control handshake timing below the SDK) + request = JSON.parse(line); + } catch { + return; + } + if (request.method === "initialize") { + const reply = () => + respond({ + jsonrpc: "2.0", + id: request.id, + result: { + protocolVersion: request.params?.protocolVersion, + capabilities: { tools: {} }, + serverInfo: { name: "stdio-interrupt-test-server", version: "0.0.0" }, + }, + }); + if (mode === "slow") setTimeout(reply, 3_000); + else if (mode !== "never") reply(); + } else if (request.method === "tools/list") { + respond({ jsonrpc: "2.0", id: request.id, result: { tools: [] } }); + } else if (request.id !== undefined) { + respond({ jsonrpc: "2.0", id: request.id, result: {} }); + } +}; + +let buffer = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk: string) => { + buffer += chunk; + let newline = buffer.indexOf("\n"); + while (newline >= 0) { + handle(buffer.slice(0, newline)); + buffer = buffer.slice(newline + 1); + newline = buffer.indexOf("\n"); + } +}); +process.stdin.on("end", () => process.exit(0)); From 1c24f56e6aa6f526de15d089e9ddcf8255fd66ba Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 18 Aug 2026 09:29:24 -0700 Subject: [PATCH 042/133] Fix two CI e2e flakes: stylesheet-count race and dev-db queue wedge (#1656) * Fix two CI e2e flakes: stylesheet-count race and dev-db queue wedge * Raise sandbox-heavy discovery-shape test to the file's 10s ceiling --- apps/cloud/scripts/dev-db.ts | 16 +- .../db/dev-db-socket-concurrency.node.test.ts | 170 +++++++++++++++++- e2e/scenarios/artifacts.test.ts | 56 ++++-- .../core/execution/src/tool-invoker.test.ts | 84 +++++---- .../mcp-apps-shell/src/shell/globals.css | 10 ++ .../@electric-sql%2Fpglite-socket@0.1.4.patch | 7 +- 6 files changed, 272 insertions(+), 71 deletions(-) diff --git a/apps/cloud/scripts/dev-db.ts b/apps/cloud/scripts/dev-db.ts index 482c869007..039ff3dc76 100644 --- a/apps/cloud/scripts/dev-db.ts +++ b/apps/cloud/scripts/dev-db.ts @@ -104,8 +104,14 @@ await migrate(drizzle(db), { migrationsFolder: MIGRATIONS_FOLDER }); // but prepared statement requires M" -> random 500s on whichever request lost // the race). The patch in patches/@electric-sql%2Fpglite-socket@0.1.4.patch // batches each socket data event into one queue entry and holds handler -// affinity while a pipeline is open; -// src/db/dev-db-socket-concurrency.node.test.ts is the regression test. +// affinity while a pipeline is open. The patch also fixes the queue's failure +// path: stock 0.1.4 `return`ed out of the drain loop when a query REJECTED at +// the JS level, leaving its `processing` flag latched true — after one such +// throw nothing was ever dequeued again, so new connections' startup packets +// sat unanswered (postgres.js CONNECT_TIMEOUT) and the whole stack was bricked +// until restart: the CI e2e "cloud signIn: callback set no session (500)" +// cascade. src/db/dev-db-socket-concurrency.node.test.ts is the regression +// test for all of the above. const server = new PGLiteSocketServer({ db, port: PORT, @@ -115,7 +121,11 @@ const server = new PGLiteSocketServer({ // sent, no Sync) with its socket still OPEN would hold the queue's handler // affinity forever and starve every other connection, since affinity only // releases on detach and detach needs close/error/idle-timeout. In ms; the - // timer resets on every data event, so only a genuinely dead client trips it. + // timer resets on every data event. The patch scopes the reap to connections + // actually HOLDING affinity (open pipeline or transaction): an idle-at-rest + // connection is the normal state of a healthy postgres.js pool held by a + // long-lived scope (SSE), and reaping those raced live queries into + // sporadic `write CONNECTION_ENDED` 500s. idleTimeout: Number(process.env.DEV_DB_IDLE_TIMEOUT_MS ?? 30_000), }); diff --git a/apps/cloud/src/db/dev-db-socket-concurrency.node.test.ts b/apps/cloud/src/db/dev-db-socket-concurrency.node.test.ts index cbbec59fcf..57db00314e 100644 --- a/apps/cloud/src/db/dev-db-socket-concurrency.node.test.ts +++ b/apps/cloud/src/db/dev-db-socket-concurrency.node.test.ts @@ -21,6 +21,8 @@ // with DIFFERENT parameter counts (the exact drizzle/postgres-js shape) through // one PGLiteSocketServer and asserts zero protocol corruption. +import { setTimeout as sleep } from "node:timers/promises"; +import { connect, type Socket } from "node:net"; import { describe, expect, it } from "@effect/vitest"; import { PGlite } from "@electric-sql/pglite"; import { PGLiteSocketServer } from "@electric-sql/pglite-socket"; @@ -30,6 +32,16 @@ const PORT = 45998; const CLIENTS = 6; const QUERIES_PER_CLIENT = 40; +const makeClient = (port: number, connectTimeout = 5) => + postgres(`postgres://postgres:postgres@127.0.0.1:${port}/postgres`, { + max: 1, + idle_timeout: 0, + connect_timeout: connectTimeout, + fetch_types: false, + prepare: true, + onnotice: () => undefined, + }); + describe("dev-db PGlite socket under concurrent connections", () => { it( "serves interleaved multi-connection pipelines without protocol corruption", @@ -48,14 +60,7 @@ describe("dev-db PGlite socket under concurrent connections", () => { const errors: string[] = []; const worker = async (id: number) => { - const sql = postgres(`postgres://postgres:postgres@127.0.0.1:${PORT}/postgres`, { - max: 1, - idle_timeout: 0, - connect_timeout: 10, - fetch_types: false, - prepare: true, - onnotice: () => undefined, - }); + const sql = makeClient(PORT, 10); // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: postgres.js is promise-native and the socket must be closed on every path try { for (let q = 0; q < QUERIES_PER_CLIENT; q++) { @@ -91,4 +96,153 @@ describe("dev-db PGlite socket under concurrent connections", () => { expect(ok).toBe(CLIENTS * QUERIES_PER_CLIENT); }, ); + + // Regression for the CI e2e "cloud signIn: callback set no session (500)" + // cascade: QueryQueueManager.processQueue used to `return` out of its drain + // loop when a query REJECTED (as opposed to returning a wire-level + // ErrorResponse), leaving `processing` latched true. From then on every + // enqueue — including brand-new connections' startup packets — sat in the + // queue forever: in-flight requests hung, postgres.js reconnects died with + // CONNECT_TIMEOUT, and the whole dev stack was bricked until restart. The + // patch rejects the one entry, drops pipeline affinity, and keeps draining. + it( + "a rejected query fails one client, not the whole socket server", + { timeout: 30_000 }, + async () => { + const port = 45997; + const db = await PGlite.create(); + const server = new PGLiteSocketServer({ db, port, host: "127.0.0.1", maxConnections: 100 }); + await server.start(); + + const first = makeClient(port); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: sockets must be closed on every path + try { + expect((await first.unsafe(`select 1 as one`))[0]).toEqual({ one: 1 }); + + // Force the NEXT protocol exchange to reject at the JS level, the shape + // PGlite produces when the shared session is broken mid-run. + const real = db.execProtocolRawStream.bind(db); + let arm = true; + (db as { execProtocolRawStream: typeof real }).execProtocolRawStream = (...args) => { + if (arm) { + arm = false; + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- test boundary: simulating a PGlite internal failure requires a raw throw + throw new Error("synthetic PGlite failure"); + } + return real(...args); + }; + + await expect(first.unsafe(`select 2 as two`)).rejects.toThrow(); + + // The poisoned entry must take down only its own connection: a fresh + // client (new socket, full startup handshake) still gets served. + const second = makeClient(port); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: sockets must be closed on every path + try { + expect((await second.unsafe(`select 3 as three`))[0]).toEqual({ three: 3 }); + } finally { + // oxlint-disable-next-line executor/no-promise-catch -- test boundary: a failed teardown must not mask the assertion + await second.end({ timeout: 5 }).catch(() => {}); + } + } finally { + // oxlint-disable-next-line executor/no-promise-catch -- test boundary: a failed teardown must not mask the assertion + await first.end({ timeout: 5 }).catch(() => {}); + await server.stop(); + await db.close(); + } + }, + ); + + // Regression for the sporadic `write CONNECTION_ENDED` 500s: the server's + // idleTimeout backstop used to kill ANY connection with no traffic for the + // window, which is the resting state of every healthy postgres.js pool + // connection (idle_timeout: 0) held by a long-lived scope. The backstop now + // only fires on a connection that is actually blocking the shared session — + // an open pipeline or an open transaction. + it("an idle-at-rest connection outlives the idle backstop", { timeout: 30_000 }, async () => { + const port = 45996; + const db = await PGlite.create(); + const server = new PGLiteSocketServer({ + db, + port, + host: "127.0.0.1", + maxConnections: 100, + idleTimeout: 250, + }); + await server.start(); + + const sql = makeClient(port); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: sockets must be closed on every path + try { + expect((await sql.unsafe(`select 1 as one`))[0]).toEqual({ one: 1 }); + await sleep(900); + expect((await sql.unsafe(`select 2 as two`))[0]).toEqual({ two: 2 }); + } finally { + // oxlint-disable-next-line executor/no-promise-catch -- test boundary: a failed teardown must not mask the assertion + await sql.end({ timeout: 5 }).catch(() => {}); + await server.stop(); + await db.close(); + } + }); + + // The backstop's actual job still works: a client that opens a pipeline + // (Parse sent, never Sync) and goes silent holds queue affinity, which + // starves every other connection. The idle timer must reap exactly that + // client and hand the queue back. + it( + "a client stalled mid-pipeline is reaped and the queue recovers", + { timeout: 30_000 }, + async () => { + const port = 45995; + const db = await PGlite.create(); + const server = new PGLiteSocketServer({ + db, + port, + host: "127.0.0.1", + maxConnections: 100, + idleTimeout: 250, + }); + await server.start(); + + // Hand-rolled wire client: complete the trust-auth startup, then send a + // lone Parse. Its last frame type ('P') marks the pipeline open, so the + // handler takes affinity and every other connection queues behind it. + const staller: Socket = connect(port, "127.0.0.1"); + await new Promise((res, rej) => { + staller.once("connect", res); + staller.once("error", rej); + }); + const startupBody = Buffer.concat([ + Buffer.from([0, 3, 0, 0]), + Buffer.from("user\0postgres\0database\0postgres\0\0"), + ]); + const startup = Buffer.concat([Buffer.alloc(4), startupBody]); + startup.writeInt32BE(startup.length, 0); + staller.write(startup); + // Wait for AuthenticationOk + ReadyForQuery before opening the pipeline, + // so the Parse is its own data event (and its own queue entry). + await new Promise((res) => { + staller.on("data", (chunk: Buffer) => { + if (chunk.includes(0x5a)) res(); // 'Z' = ReadyForQuery + }); + }); + const parseBody = Buffer.from("\0select 1\0\0\0"); + const parse = Buffer.concat([Buffer.from("P"), Buffer.alloc(4), parseBody]); + parse.writeInt32BE(4 + parseBody.length, 1); + staller.write(parse); + + const bystander = makeClient(port, 10); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: sockets must be closed on every path + try { + // Connects and queries only once the staller is reaped (~250ms). + expect((await bystander.unsafe(`select 4 as four`))[0]).toEqual({ four: 4 }); + } finally { + // oxlint-disable-next-line executor/no-promise-catch -- test boundary: a failed teardown must not mask the assertion + await bystander.end({ timeout: 5 }).catch(() => {}); + staller.destroy(); + await server.stop(); + await db.close(); + } + }, + ); }); diff --git a/e2e/scenarios/artifacts.test.ts b/e2e/scenarios/artifacts.test.ts index 63b0cbba82..29f4be3e90 100644 --- a/e2e/scenarios/artifacts.test.ts +++ b/e2e/scenarios/artifacts.test.ts @@ -143,18 +143,31 @@ const recordHandshakeOrdering = async (page: Page): Promise => { const readHandshakeOrdering = (page: Page): Promise> => page.evaluate(() => globalThis.__handshakeOrder ?? []); -const readConsoleStyle = ( - page: Page, -): Promise<{ primary: string; buttonBg: string; styleSheets: number }> => +const readConsoleStyle = (page: Page): Promise<{ primary: string; buttonBg: string }> => page.evaluate(() => { const button = document.querySelector("button"); return { primary: getComputedStyle(document.documentElement).getPropertyValue("--primary").trim(), buttonBg: button ? getComputedStyle(button).backgroundColor : "", - styleSheets: document.styleSheets.length, }; }); +// The shell's compiled stylesheet declares `--mcp-apps-shell-stylesheet: 1` +// on `:root` as a provenance marker (see the shell's globals.css): the shell's +// tokens deliberately mirror the console's, so this marker is the only +// declaration that identifies the sheet. Reading it as a computed value on a +// document's root element answers "did the shell's stylesheet land in THIS +// document?" — unlike counting document.styleSheets, which moves on its own in +// dev (TanStack Start swaps its route-styles as matches settle, and a +// swapped-in link only counts once loaded), which made an equality-of-counts +// assertion flaky. +const readShellStylesheetMarker = (page: Page): Promise => + page.evaluate(() => + getComputedStyle(document.documentElement) + .getPropertyValue("--mcp-apps-shell-stylesheet") + .trim(), + ); + scenario( "Artifacts · create-artifact hands a non-Apps client a deep link that renders the live component", { timeout: 180_000 }, @@ -242,10 +255,11 @@ scenario( yield* browser.session(identity, async ({ page, step }) => { // The console's own styling, sampled BEFORE any artifact is opened. - // The shell ships its own Tailwind build and its own palette (a teal - // `--primary` against the console's near-black), so if its stylesheet - // ever reaches the top-level document again these values move. - let consoleStyleBefore: { primary: string; buttonBg: string; styleSheets: number }; + // The shell ships its own Tailwind build; if its stylesheet ever + // reaches the top-level document again, its base/utility layers move + // these computed values (and its provenance marker appears, asserted + // below). + let consoleStyleBefore: { primary: string; buttonBg: string }; await step("Open the artifact link the agent handed over", async () => { await recordHandshakeOrdering(page); @@ -338,10 +352,6 @@ scenario( expect(after.buttonBg, "a console button keeps its own background").toBe( consoleStyleBefore.buttonBg, ); - expect( - after.styleSheets, - "the shell injected no stylesheet into the console document", - ).toBe(consoleStyleBefore.styleSheets); // And positively: the shell's stylesheet IS present, one document // down. Without this the assertions above would also pass if the @@ -349,18 +359,26 @@ scenario( const shellHasOwnStyles = await page .frameLocator('[data-testid="artifact-shell-frame"]') .locator("html") - .evaluate((html) => { - const primary = getComputedStyle(html).getPropertyValue("--primary").trim(); - return { primary, sheets: html.ownerDocument.styleSheets.length }; - }); + .evaluate((html) => ({ + marker: getComputedStyle(html).getPropertyValue("--mcp-apps-shell-stylesheet").trim(), + sheets: html.ownerDocument.styleSheets.length, + })); expect( shellHasOwnStyles.sheets, "the shell document carries its own stylesheets", ).toBeGreaterThan(0); expect( - shellHasOwnStyles.primary, - "the shell keeps its own palette inside its own document", - ).not.toBe(""); + shellHasOwnStyles.marker, + "the shell document carries the shell's own compiled stylesheet", + ).toBe("1"); + + // The marker is the injection fingerprint: even a shell sheet that + // lost the cascade race (so the computed values above stayed put) + // would still surface it on the console's root element. + expect( + await readShellStylesheetMarker(page), + "the shell injected no stylesheet into the console document", + ).toBe(""); }); await step("The artifact fills the page and scrolls inside itself", async () => { diff --git a/packages/core/execution/src/tool-invoker.test.ts b/packages/core/execution/src/tool-invoker.test.ts index dd25e61689..4d7d6681aa 100644 --- a/packages/core/execution/src/tool-invoker.test.ts +++ b/packages/core/execution/src/tool-invoker.test.ts @@ -989,48 +989,54 @@ describe("tool discovery", () => { }), ); - it.effect("describes built-in discovery tool shapes that accept their runtime output", () => - Effect.gen(function* () { - const executor = yield* makeSearchExecutor(); - const engine = createExecutionEngine({ executor, codeExecutor }); + it.effect( + "describes built-in discovery tool shapes that accept their runtime output", + () => + Effect.gen(function* () { + const executor = yield* makeSearchExecutor(); + const engine = createExecutionEngine({ executor, codeExecutor }); - const execution = yield* engine.execute( - [ - "const searchDetails = await tools.describe.tool({ path: 'search' });", - "const integrationDetails = await tools.describe.tool({ path: 'executor.integrations.list' });", - "const describeDetails = await tools.describe.tool({ path: 'describe.tool' });", - "return {", - " searchDetails,", - " searchResult: await tools.search({ query: 'repo details', limit: 2 }),", - " integrationDetails,", - " integrationResult: await tools.executor.integrations.list({ limit: 2 }),", - " describeDetails,", - " describeResult: await tools.describe.tool({ path: 'github.org.main.getRepositoryDetails' }),", - "};", - ].join("\n"), - { onElicitation: acceptAll }, - ); + const execution = yield* engine.execute( + [ + "const searchDetails = await tools.describe.tool({ path: 'search' });", + "const integrationDetails = await tools.describe.tool({ path: 'executor.integrations.list' });", + "const describeDetails = await tools.describe.tool({ path: 'describe.tool' });", + "return {", + " searchDetails,", + " searchResult: await tools.search({ query: 'repo details', limit: 2 }),", + " integrationDetails,", + " integrationResult: await tools.executor.integrations.list({ limit: 2 }),", + " describeDetails,", + " describeResult: await tools.describe.tool({ path: 'github.org.main.getRepositoryDetails' }),", + "};", + ].join("\n"), + { onElicitation: acceptAll }, + ); - expect(execution.error).toBeUndefined(); - const observed = execution.result as { - readonly searchDetails: DescribedToolContract; - readonly searchResult: unknown; - readonly integrationDetails: DescribedToolContract; - readonly integrationResult: unknown; - readonly describeDetails: DescribedToolContract; - readonly describeResult: unknown; - }; + expect(execution.error).toBeUndefined(); + const observed = execution.result as { + readonly searchDetails: DescribedToolContract; + readonly searchResult: unknown; + readonly integrationDetails: DescribedToolContract; + readonly integrationResult: unknown; + readonly describeDetails: DescribedToolContract; + readonly describeResult: unknown; + }; - expect( - typeCheckDescribedInvocation(observed.searchDetails, observed.searchResult, ""), - ).toEqual([]); - expect( - typeCheckDescribedInvocation(observed.integrationDetails, observed.integrationResult, ""), - ).toEqual([]); - expect( - typeCheckDescribedInvocation(observed.describeDetails, observed.describeResult, ""), - ).toEqual([]); - }), + expect( + typeCheckDescribedInvocation(observed.searchDetails, observed.searchResult, ""), + ).toEqual([]); + expect( + typeCheckDescribedInvocation(observed.integrationDetails, observed.integrationResult, ""), + ).toEqual([]); + expect( + typeCheckDescribedInvocation(observed.describeDetails, observed.describeResult, ""), + ).toEqual([]); + }), + // Three sandboxed describe.tool round-trips plus three type-checks of the + // described contracts routinely clear vitest's 5s default on a loaded CI + // runner; the same ceiling the file's other sandbox-heavy tests use. + { timeout: 10000 }, ); it.effect("rejects malformed discover calls inside the sandbox", () => diff --git a/packages/hosts/mcp-apps-shell/src/shell/globals.css b/packages/hosts/mcp-apps-shell/src/shell/globals.css index 09d5fd90b4..797144e97f 100644 --- a/packages/hosts/mcp-apps-shell/src/shell/globals.css +++ b/packages/hosts/mcp-apps-shell/src/shell/globals.css @@ -41,3 +41,13 @@ /* Executor's tokens, variants and base layer. Shared verbatim with the inner frame's Tailwind compiler — keep anything build-resolved out of it. */ @import "./theme.css"; + +/* Provenance marker. The shell's tokens deliberately mirror the console's + (theme.css is pinned against the console's globals.css), so no token name or + value distinguishes this compiled stylesheet from the console's own. This + property is the one declaration unique to it: e2e proves style containment + by finding it computed inside the shell document and absent from the console + document — see e2e/scenarios/artifacts.test.ts. */ +:root { + --mcp-apps-shell-stylesheet: 1; +} diff --git a/patches/@electric-sql%2Fpglite-socket@0.1.4.patch b/patches/@electric-sql%2Fpglite-socket@0.1.4.patch index 1209240c3f..f84d44d139 100644 --- a/patches/@electric-sql%2Fpglite-socket@0.1.4.patch +++ b/patches/@electric-sql%2Fpglite-socket@0.1.4.patch @@ -1,13 +1,16 @@ +diff --git a/node_modules/@electric-sql/pglite-socket/.bun-tag-a8fabe72c1056a8f b/.bun-tag-a8fabe72c1056a8f +new file mode 100644 +index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/node_modules/@electric-sql/pglite-socket/.bun-tag-eaa11f63ffd98a26 b/.bun-tag-eaa11f63ffd98a26 new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/dist/chunk-NSUMFCRM.js b/dist/chunk-NSUMFCRM.js -index 37d45ebc5150c43c919fbdb1c7fffb51b18fda8c..dd6cd0fb7ab26b3911778ddc427a02d7c4d6ebd3 100644 +index 37d45ebc5150c43c919fbdb1c7fffb51b18fda8c..cc2a8f6e35c6a676f4fdecb7d580fc1202b74ace 100644 --- a/dist/chunk-NSUMFCRM.js +++ b/dist/chunk-NSUMFCRM.js @@ -1,3 +1,3 @@ -import{createServer as m}from"net";var b=6e4,c=class{constructor(s,e=!1){this.queue=[];this.processing=!1;this.lastHandlerId=null;this.db=s,this.debug=e}log(s,...e){this.debug&&console.log(`[QueryQueueManager] ${s}`,...e)}async enqueue(s,e,i){return new Promise((t,r)=>{let o={handlerId:s,message:e,resolve:t,reject:r,timestamp:Date.now(),onData:i};this.queue.push(o),this.log(`enqueued query from handler #${s}, queue size: ${this.queue.length}`),this.processing||this.processQueue()})}async processQueue(){if(!(this.processing||this.queue.length===0)){for(this.processing=!0;this.queue.length>0;){let s;if(this.db.isInTransaction()&&this.lastHandlerId){let t=this.queue.findIndex(r=>r.handlerId===this.lastHandlerId);t===-1?(this.log("transaction started, but no query from the same handler id found in queue",this.lastHandlerId),s=null):s=this.queue.splice(t,1)[0]}else s=this.queue.shift();if(!s)break;let e=Date.now()-s.timestamp;this.log(`processing query from handler #${s.handlerId} (waited ${e}ms)`);let i=0;try{await this.db.runExclusive(async()=>await this.db.execProtocolRawStream(s.message,{onRawData:t=>{i+=t.length,s.onData(t)}}))}catch(t){this.log(`query from handler #${s.handlerId} failed:`,t),s.reject(t);return}this.log(`query from handler #${s.handlerId} completed, ${i} bytes`),this.lastHandlerId=s.handlerId,s.resolve(i)}this.processing=!1,this.log("queue processing complete, queue length is",this.queue.length)}}getQueueLength(){return this.queue.length}clearQueueForHandler(s){let e=this.queue.length;this.queue=this.queue.filter(t=>t.handlerId===s?(t.reject(new Error("Handler disconnected")),!1):!0);let i=e-this.queue.length;i>0&&this.log(`cleared ${i} queries for handler #${s}`)}async clearTransactionIfNeeded(s){this.db.isInTransaction()&&this.lastHandlerId===s&&(await this.db.exec("ROLLBACK"),this.lastHandlerId=null,await this.processQueue())}},l=class l extends EventTarget{constructor(e){super();this.socket=null;this.active=!1;this.messageBuffer=Buffer.alloc(0);this.lastActivityTime=Date.now();this.queryQueue=e.queryQueue,this.closeOnDetach=e.closeOnDetach??!1,this.inspect=e.inspect??!1,this.debug=e.debug??!1,this.idleTimeout=e.idleTimeout??0,this.id=l.nextHandlerId++,this.log("constructor: created new handler")}get handlerId(){return this.id}log(e,...i){this.debug&&console.log(`[PGLiteSocketHandler#${this.id}] ${e}`,...i)}async attach(e){if(this.log(`attach: attaching socket from ${e.remoteAddress}:${e.remotePort}`),this.socket)throw new Error("Socket already attached");return this.socket=e,this.active=!0,this.lastActivityTime=Date.now(),e.setNoDelay(!0),this.idleTimeout>0&&this.resetIdleTimer(),this.log("attach: setting up socket event handlers"),e.on("data",i=>{this.lastActivityTime=Date.now(),this.resetIdleTimer(),setImmediate(async()=>{try{await this.handleData(i)}catch(t){this.log("socket on data error: ",t),this.handleError(t)}})}),e.on("error",i=>{setImmediate(()=>this.handleError(i))}),e.on("close",()=>{setImmediate(()=>this.handleClose())}),this.log("attach: socket handler ready"),this}resetIdleTimer(){this.idleTimeout<=0||(this.idleTimer&&clearTimeout(this.idleTimer),this.idleTimer=setTimeout(()=>{let e=Date.now()-this.lastActivityTime;this.log(`idle timeout after ${e}ms`),this.handleError(new Error("Idle timeout"))},this.idleTimeout))}async detach(e){if(this.log(`detach: detaching socket, close=${e??this.closeOnDetach}`),this.idleTimer&&(clearTimeout(this.idleTimer),this.idleTimer=void 0),this.queryQueue.clearQueueForHandler(this.id),await this.queryQueue.clearTransactionIfNeeded(this.id),!this.socket)return this.log("detach: no socket attached, nothing to do"),this;if(this.socket.removeAllListeners("data"),this.socket.removeAllListeners("error"),this.socket.removeAllListeners("close"),(e??this.closeOnDetach)&&this.socket.writable){this.log("detach: closing socket");try{this.socket.end(),this.socket.destroy()}catch(i){this.log("detach: error closing socket:",i)}}return this.socket=null,this.active=!1,this.messageBuffer=Buffer.alloc(0),this.log("detach: handler cleaned up"),this}get isAttached(){return this.socket!==null}async handleData(e){if(!this.socket||!this.active)return this.log("handleData: no active socket, ignoring data"),0;this.log(`handleData: received ${e.length} bytes`),this.messageBuffer=Buffer.concat([this.messageBuffer,e]),this.inspectData("incoming",e);try{let i=0;for(;this.messageBuffer.length>0;){let t=0,r=!1;if(this.messageBuffer.length>=4){let n=this.messageBuffer.readInt32BE(0);if(this.messageBuffer.length>=8){let a=this.messageBuffer.readInt32BE(4);(a===196608||a===196608)&&(t=n,r=this.messageBuffer.length>=t)}!r&&this.messageBuffer.length>=5&&(t=1+this.messageBuffer.readInt32BE(1),r=this.messageBuffer.length>=t)}if(!r||t===0){this.log(`handleData: incomplete message, buffering ${this.messageBuffer.length} bytes`);break}let o=this.messageBuffer.slice(0,t);if(this.messageBuffer=this.messageBuffer.slice(t),this.log(`handleData: processing message of ${o.length} bytes`),!this.active||!this.socket){this.log("handleData: socket no longer active, stopping processing");break}let h;if(await this.queryQueue.enqueue(this.id,new Uint8Array(o),n=>{this.log(`handleData: received ${n.length} bytes from PGlite`),this.inspectData("outgoing",n),n.length>0&&this.socket&&this.socket.writable&&this.active&&(this.log("handleData: writing response to socket"),this.socket?.writable?this.socket.write(Buffer.from(n),a=>{a?(this.log("handleData: error writing to socket:",a),h=a):this.log(`handleData: socket sent: ${n.length} bytes`)}):this.log("handleData: socket no longer writable")),i+=n.length}),h)throw h}return this.dispatchEvent(new CustomEvent("data",{detail:{incoming:e.length,outgoing:i}})),i}catch(i){throw this.log("handleData: error processing data:",i),i}}handleError(e){if(!this.active){this.log("handleError: handler not active, ignoring error");return}e.message?.includes("ECONNRESET")?this.log("handleError: client disconnected (ECONNRESET) - normal behavior"):e.message?.includes("Idle timeout")?this.log("handleError: connection idle timeout"):this.log("handleError:",e),this.active=!1,this.dispatchEvent(new CustomEvent("error",{detail:e})),this.detach(!0)}handleClose(){this.log("handleClose: socket closed"),this.active=!1,this.dispatchEvent(new CustomEvent("close")),this.detach(!1)}inspectData(e,i){if(this.inspect){console.log("-".repeat(75)),console.log(e==="incoming"?"-> incoming":"<- outgoing",i.length,"bytes");for(let t=0;t=32&&a<=126?String.fromCharCode(a):"."}console.log(`${t.toString(16).padStart(8,"0")} ${o} ${h}`)}}}};l.nextHandlerId=1;var d=l,u=class extends EventTarget{constructor(e){super();this.server=null;this.active=!1;this.handlers=new Set;this.db=e.db,e.path?this.path=e.path:(typeof e.port=="number"?this.port=e.port??e.port:this.port=5432,this.host=e.host||"127.0.0.1"),this.inspect=e.inspect??!1,this.debug=e.debug??!1,this.idleTimeout=e.idleTimeout??0,this.maxConnections=e.maxConnections??1,this.queryQueue=new c(this.db,this.debug),this.log(`constructor: created server on ${this.getServerConn()}`),this.log(`constructor: max connections: ${this.maxConnections}`),this.idleTimeout>0&&this.log(`constructor: idle timeout: ${this.idleTimeout}ms`)}log(e,...i){this.debug&&console.log(`[PGLiteSocketServer] ${e}`,...i)}async start(){if(this.log(`start: starting server on ${this.getServerConn()}`),this.server)throw new Error("Socket server already started");return await this.db.waitReady,this.active=!0,this.server=m(e=>{setImmediate(()=>this.handleConnection(e))}),this.server.maxConnections=this.maxConnections,new Promise((e,i)=>{if(!this.server)return i(new Error("Server not initialized"));if(this.server.on("error",t=>{this.log("start: server error:",t),this.dispatchEvent(new CustomEvent("error",{detail:t})),this.active||i(t)}),this.path)this.server.listen(this.path,()=>{this.log(`start: server listening on ${this.getServerConn()}`),this.dispatchEvent(new CustomEvent("listening",{detail:{path:this.path}})),e()});else{let t=this.server;t.listen(this.port,this.host,()=>{let r=t.address();if(r===null||typeof r!="object")throw Error("Expected address info");this.port=r.port,this.log(`start: server listening on ${this.getServerConn()}`),this.dispatchEvent(new CustomEvent("listening",{detail:{port:this.port,host:this.host}})),e()})}})}getServerConn(){return this.path?this.path:`${this.host}:${this.port}`}async stop(){this.log("stop: stopping server"),this.active=!1,this.log(`stop: detaching ${this.handlers.size} handlers`);for(let e of this.handlers)e.detach(!0);return this.handlers.clear(),this.server?new Promise(e=>{if(!this.server)return e();this.server.close(()=>{this.log("stop: server closed"),this.server=null,this.dispatchEvent(new CustomEvent("close")),e()})}):(this.log("stop: server not running, nothing to do"),Promise.resolve())}async handleConnection(e){let i={clientAddress:e.remoteAddress||"unknown",clientPort:e.remotePort||0};if(this.log(`handleConnection: new connection from ${i.clientAddress}:${i.clientPort}`),this.log(`handleConnection: active connections: ${this.handlers.size}, queued queries: ${this.queryQueue.getQueueLength()}`),!this.active){this.log("handleConnection: server not active, closing connection");try{e.end()}catch(r){this.log("handleConnection: error closing socket:",r)}return}if(this.handlers.size>=this.maxConnections){this.log("handleConnection: max connections reached, rejecting"),e.write(Buffer.from(`Too many connections -+import{createServer as m}from"net";var b=6e4,c=class{constructor(s,e=!1){this.queue=[];this.processing=!1;this.lastHandlerId=null;this.pipelineHandlerId=null;this.db=s,this.debug=e}log(s,...e){this.debug&&console.log(`[QueryQueueManager] ${s}`,...e)}async enqueue(s,e,i,S=!0){return new Promise((t,r)=>{let o={handlerId:s,message:e,resolve:t,reject:r,timestamp:Date.now(),onData:i,closes:S};this.queue.push(o),this.log(`enqueued query from handler #${s}, queue size: ${this.queue.length}`),this.processing||this.processQueue()})}async processQueue(){if(!(this.processing||this.queue.length===0)){for(this.processing=!0;this.queue.length>0;){let s;let __affine=this.db.isInTransaction()&&this.lastHandlerId?this.lastHandlerId:this.pipelineHandlerId;if(__affine){let t=this.queue.findIndex(r=>r.handlerId===__affine);t===-1?(this.log("affinity held, waiting for handler",__affine),s=null):s=this.queue.splice(t,1)[0]}else s=this.queue.shift();if(!s)break;let e=Date.now()-s.timestamp;this.log(`processing query from handler #${s.handlerId} (waited ${e}ms)`);let i=0;try{await this.db.runExclusive(async()=>await this.db.execProtocolRawStream(s.message,{onRawData:t=>{i+=t.length,s.onData(t)}}))}catch(t){this.log(`query from handler #${s.handlerId} failed:`,t),s.reject(t);return}this.log(`query from handler #${s.handlerId} completed, ${i} bytes`),this.lastHandlerId=s.handlerId,this.pipelineHandlerId=s.closes?null:s.handlerId,s.resolve(i)}this.processing=!1,this.log("queue processing complete, queue length is",this.queue.length)}}getQueueLength(){return this.queue.length}clearQueueForHandler(s){let e=this.queue.length;this.queue=this.queue.filter(t=>t.handlerId===s?(t.reject(new Error("Handler disconnected")),!1):!0);let i=e-this.queue.length;i>0&&this.log(`cleared ${i} queries for handler #${s}`)}async clearPipelineIfNeeded(s){this.pipelineHandlerId===s&&(this.pipelineHandlerId=null,await this.db.runExclusive(async()=>await this.db.execProtocolRawStream(new Uint8Array([83,0,0,0,4]),{onRawData:()=>{}})).catch(()=>{}),this.processQueue())}async clearTransactionIfNeeded(s){this.db.isInTransaction()&&this.lastHandlerId===s&&(await this.db.exec("ROLLBACK"),this.lastHandlerId=null,await this.processQueue())}},l=class l extends EventTarget{constructor(e){super();this.socket=null;this.active=!1;this.messageBuffer=Buffer.alloc(0);this.lastActivityTime=Date.now();this.queryQueue=e.queryQueue,this.closeOnDetach=e.closeOnDetach??!1,this.inspect=e.inspect??!1,this.debug=e.debug??!1,this.idleTimeout=e.idleTimeout??0,this.id=l.nextHandlerId++,this.log("constructor: created new handler")}get handlerId(){return this.id}log(e,...i){this.debug&&console.log(`[PGLiteSocketHandler#${this.id}] ${e}`,...i)}async attach(e){if(this.log(`attach: attaching socket from ${e.remoteAddress}:${e.remotePort}`),this.socket)throw new Error("Socket already attached");return this.socket=e,this.active=!0,this.lastActivityTime=Date.now(),e.setNoDelay(!0),this.idleTimeout>0&&this.resetIdleTimer(),this.log("attach: setting up socket event handlers"),e.on("data",i=>{this.lastActivityTime=Date.now(),this.resetIdleTimer(),setImmediate(async()=>{try{await this.handleData(i)}catch(t){this.log("socket on data error: ",t),this.handleError(t)}})}),e.on("error",i=>{setImmediate(()=>this.handleError(i))}),e.on("close",()=>{setImmediate(()=>this.handleClose())}),this.log("attach: socket handler ready"),this}resetIdleTimer(){this.idleTimeout<=0||(this.idleTimer&&clearTimeout(this.idleTimer),this.idleTimer=setTimeout(()=>{let e=Date.now()-this.lastActivityTime;this.log(`idle timeout after ${e}ms`),this.handleError(new Error("Idle timeout"))},this.idleTimeout))}async detach(e){if(this.log(`detach: detaching socket, close=${e??this.closeOnDetach}`),this.idleTimer&&(clearTimeout(this.idleTimer),this.idleTimer=void 0),this.queryQueue.clearQueueForHandler(this.id),await this.queryQueue.clearTransactionIfNeeded(this.id),await this.queryQueue.clearPipelineIfNeeded(this.id),!this.socket)return this.log("detach: no socket attached, nothing to do"),this;if(this.socket.removeAllListeners("data"),this.socket.removeAllListeners("error"),this.socket.removeAllListeners("close"),(e??this.closeOnDetach)&&this.socket.writable){this.log("detach: closing socket");try{this.socket.end(),this.socket.destroy()}catch(i){this.log("detach: error closing socket:",i)}}return this.socket=null,this.active=!1,this.messageBuffer=Buffer.alloc(0),this.log("detach: handler cleaned up"),this}get isAttached(){return this.socket!==null}async handleData(e){if(!this.socket||!this.active)return this.log("handleData: no active socket, ignoring data"),0;this.log(`handleData: received ${e.length} bytes`),this.messageBuffer=Buffer.concat([this.messageBuffer,e]),this.inspectData("incoming",e);try{let i=0;const __frames=[];for(;this.messageBuffer.length>0;){let t=0,r=!1;if(this.messageBuffer.length>=4){let n=this.messageBuffer.readInt32BE(0);if(this.messageBuffer.length>=8){let a=this.messageBuffer.readInt32BE(4);(a===196608||a===196608)&&(t=n,r=this.messageBuffer.length>=t)}!r&&this.messageBuffer.length>=5&&(t=1+this.messageBuffer.readInt32BE(1),r=this.messageBuffer.length>=t)}if(!r||t===0){this.log(`handleData: incomplete message, buffering ${this.messageBuffer.length} bytes`);break}let o=this.messageBuffer.slice(0,t);if(this.messageBuffer=this.messageBuffer.slice(t),this.log(`handleData: processing message of ${o.length} bytes`),!this.active||!this.socket){this.log("handleData: socket no longer active, stopping processing");break}__frames.push(o)}if(__frames.length===0)return this.dispatchEvent(new CustomEvent("data",{detail:{incoming:e.length,outgoing:i}})),i;{let o=__frames.length===1?__frames[0]:Buffer.concat(__frames);const __lastF=__frames[__frames.length-1];const __lt=__lastF[0]>=65?__lastF[0]:null;const __closes=__lt===null||__lt===83||__lt===81||__lt===88;let h;if(await this.queryQueue.enqueue(this.id,new Uint8Array(o),n=>{this.log(`handleData: received ${n.length} bytes from PGlite`),this.inspectData("outgoing",n),n.length>0&&this.socket&&this.socket.writable&&this.active&&(this.log("handleData: writing response to socket"),this.socket?.writable?this.socket.write(Buffer.from(n),a=>{a?(this.log("handleData: error writing to socket:",a),h=a):this.log(`handleData: socket sent: ${n.length} bytes`)}):this.log("handleData: socket no longer writable")),i+=n.length},__closes),h)throw h}return this.dispatchEvent(new CustomEvent("data",{detail:{incoming:e.length,outgoing:i}})),i}catch(i){throw this.log("handleData: error processing data:",i),i}}handleError(e){if(!this.active){this.log("handleError: handler not active, ignoring error");return}e.message?.includes("ECONNRESET")?this.log("handleError: client disconnected (ECONNRESET) - normal behavior"):e.message?.includes("Idle timeout")?this.log("handleError: connection idle timeout"):this.log("handleError:",e),this.active=!1,this.dispatchEvent(new CustomEvent("error",{detail:e})),this.detach(!0)}handleClose(){this.log("handleClose: socket closed"),this.active=!1,this.dispatchEvent(new CustomEvent("close")),this.detach(!1)}inspectData(e,i){if(this.inspect){console.log("-".repeat(75)),console.log(e==="incoming"?"-> incoming":"<- outgoing",i.length,"bytes");for(let t=0;t=32&&a<=126?String.fromCharCode(a):"."}console.log(`${t.toString(16).padStart(8,"0")} ${o} ${h}`)}}}};l.nextHandlerId=1;var d=l,u=class extends EventTarget{constructor(e){super();this.server=null;this.active=!1;this.handlers=new Set;this.db=e.db,e.path?this.path=e.path:(typeof e.port=="number"?this.port=e.port??e.port:this.port=5432,this.host=e.host||"127.0.0.1"),this.inspect=e.inspect??!1,this.debug=e.debug??!1,this.idleTimeout=e.idleTimeout??0,this.maxConnections=e.maxConnections??1,this.queryQueue=new c(this.db,this.debug),this.log(`constructor: created server on ${this.getServerConn()}`),this.log(`constructor: max connections: ${this.maxConnections}`),this.idleTimeout>0&&this.log(`constructor: idle timeout: ${this.idleTimeout}ms`)}log(e,...i){this.debug&&console.log(`[PGLiteSocketServer] ${e}`,...i)}async start(){if(this.log(`start: starting server on ${this.getServerConn()}`),this.server)throw new Error("Socket server already started");return await this.db.waitReady,this.active=!0,this.server=m(e=>{setImmediate(()=>this.handleConnection(e))}),this.server.maxConnections=this.maxConnections,new Promise((e,i)=>{if(!this.server)return i(new Error("Server not initialized"));if(this.server.on("error",t=>{this.log("start: server error:",t),this.dispatchEvent(new CustomEvent("error",{detail:t})),this.active||i(t)}),this.path)this.server.listen(this.path,()=>{this.log(`start: server listening on ${this.getServerConn()}`),this.dispatchEvent(new CustomEvent("listening",{detail:{path:this.path}})),e()});else{let t=this.server;t.listen(this.port,this.host,()=>{let r=t.address();if(r===null||typeof r!="object")throw Error("Expected address info");this.port=r.port,this.log(`start: server listening on ${this.getServerConn()}`),this.dispatchEvent(new CustomEvent("listening",{detail:{port:this.port,host:this.host}})),e()})}})}getServerConn(){return this.path?this.path:`${this.host}:${this.port}`}async stop(){this.log("stop: stopping server"),this.active=!1,this.log(`stop: detaching ${this.handlers.size} handlers`);for(let e of this.handlers)e.detach(!0);return this.handlers.clear(),this.server?new Promise(e=>{if(!this.server)return e();this.server.close(()=>{this.log("stop: server closed"),this.server=null,this.dispatchEvent(new CustomEvent("close")),e()})}):(this.log("stop: server not running, nothing to do"),Promise.resolve())}async handleConnection(e){let i={clientAddress:e.remoteAddress||"unknown",clientPort:e.remotePort||0};if(this.log(`handleConnection: new connection from ${i.clientAddress}:${i.clientPort}`),this.log(`handleConnection: active connections: ${this.handlers.size}, queued queries: ${this.queryQueue.getQueueLength()}`),!this.active){this.log("handleConnection: server not active, closing connection");try{e.end()}catch(r){this.log("handleConnection: error closing socket:",r)}return}if(this.handlers.size>=this.maxConnections){this.log("handleConnection: max connections reached, rejecting"),e.write(Buffer.from(`Too many connections ++import{createServer as m}from"net";var b=6e4,c=class{constructor(s,e=!1){this.queue=[];this.processing=!1;this.lastHandlerId=null;this.pipelineHandlerId=null;this.db=s,this.debug=e}log(s,...e){this.debug&&console.log(`[QueryQueueManager] ${s}`,...e)}async enqueue(s,e,i,S=!0){return new Promise((t,r)=>{let o={handlerId:s,message:e,resolve:t,reject:r,timestamp:Date.now(),onData:i,closes:S};this.queue.push(o),this.log(`enqueued query from handler #${s}, queue size: ${this.queue.length}`),this.processing||this.processQueue()})}async processQueue(){if(!(this.processing||this.queue.length===0)){for(this.processing=!0;this.queue.length>0;){let s;let __affine=this.db.isInTransaction()&&this.lastHandlerId?this.lastHandlerId:this.pipelineHandlerId;if(__affine){let t=this.queue.findIndex(r=>r.handlerId===__affine);t===-1?(this.log("affinity held, waiting for handler",__affine),s=null):s=this.queue.splice(t,1)[0]}else s=this.queue.shift();if(!s)break;let e=Date.now()-s.timestamp;this.log(`processing query from handler #${s.handlerId} (waited ${e}ms)`);let i=0;try{await this.db.runExclusive(async()=>await this.db.execProtocolRawStream(s.message,{onRawData:t=>{i+=t.length,s.onData(t)}}))}catch(t){this.log(`query from handler #${s.handlerId} failed:`,t),s.reject(t),this.pipelineHandlerId=null;continue}this.log(`query from handler #${s.handlerId} completed, ${i} bytes`),this.lastHandlerId=s.handlerId,this.pipelineHandlerId=s.closes?null:s.handlerId,s.resolve(i)}this.processing=!1,this.log("queue processing complete, queue length is",this.queue.length)}}getQueueLength(){return this.queue.length}holdsAffinity(s){return this.pipelineHandlerId===s||this.db.isInTransaction()&&this.lastHandlerId===s}clearQueueForHandler(s){let e=this.queue.length;this.queue=this.queue.filter(t=>t.handlerId===s?(t.reject(new Error("Handler disconnected")),!1):!0);let i=e-this.queue.length;i>0&&this.log(`cleared ${i} queries for handler #${s}`)}async clearPipelineIfNeeded(s){this.pipelineHandlerId===s&&(this.pipelineHandlerId=null,await this.db.runExclusive(async()=>await this.db.execProtocolRawStream(new Uint8Array([83,0,0,0,4]),{onRawData:()=>{}})).catch(()=>{}),this.processQueue())}async clearTransactionIfNeeded(s){this.db.isInTransaction()&&this.lastHandlerId===s&&(await this.db.exec("ROLLBACK"),this.lastHandlerId=null,await this.processQueue())}},l=class l extends EventTarget{constructor(e){super();this.socket=null;this.active=!1;this.messageBuffer=Buffer.alloc(0);this.lastActivityTime=Date.now();this.queryQueue=e.queryQueue,this.closeOnDetach=e.closeOnDetach??!1,this.inspect=e.inspect??!1,this.debug=e.debug??!1,this.idleTimeout=e.idleTimeout??0,this.id=l.nextHandlerId++,this.log("constructor: created new handler")}get handlerId(){return this.id}log(e,...i){this.debug&&console.log(`[PGLiteSocketHandler#${this.id}] ${e}`,...i)}async attach(e){if(this.log(`attach: attaching socket from ${e.remoteAddress}:${e.remotePort}`),this.socket)throw new Error("Socket already attached");return this.socket=e,this.active=!0,this.lastActivityTime=Date.now(),e.setNoDelay(!0),this.idleTimeout>0&&this.resetIdleTimer(),this.log("attach: setting up socket event handlers"),e.on("data",i=>{this.lastActivityTime=Date.now(),this.resetIdleTimer(),setImmediate(async()=>{try{await this.handleData(i)}catch(t){this.log("socket on data error: ",t),this.handleError(t)}})}),e.on("error",i=>{setImmediate(()=>this.handleError(i))}),e.on("close",()=>{setImmediate(()=>this.handleClose())}),this.log("attach: socket handler ready"),this}resetIdleTimer(){this.idleTimeout<=0||(this.idleTimer&&clearTimeout(this.idleTimer),this.idleTimer=setTimeout(()=>{if(!this.queryQueue.holdsAffinity(this.id)){this.resetIdleTimer();return}let e=Date.now()-this.lastActivityTime;this.log(`idle timeout after ${e}ms`),this.handleError(new Error("Idle timeout"))},this.idleTimeout))}async detach(e){if(this.log(`detach: detaching socket, close=${e??this.closeOnDetach}`),this.idleTimer&&(clearTimeout(this.idleTimer),this.idleTimer=void 0),this.queryQueue.clearQueueForHandler(this.id),await this.queryQueue.clearTransactionIfNeeded(this.id),await this.queryQueue.clearPipelineIfNeeded(this.id),!this.socket)return this.log("detach: no socket attached, nothing to do"),this;if(this.socket.removeAllListeners("data"),this.socket.removeAllListeners("error"),this.socket.removeAllListeners("close"),(e??this.closeOnDetach)&&this.socket.writable){this.log("detach: closing socket");try{this.socket.end(),this.socket.destroy()}catch(i){this.log("detach: error closing socket:",i)}}return this.socket=null,this.active=!1,this.messageBuffer=Buffer.alloc(0),this.log("detach: handler cleaned up"),this}get isAttached(){return this.socket!==null}async handleData(e){if(!this.socket||!this.active)return this.log("handleData: no active socket, ignoring data"),0;this.log(`handleData: received ${e.length} bytes`),this.messageBuffer=Buffer.concat([this.messageBuffer,e]),this.inspectData("incoming",e);try{let i=0;const __frames=[];for(;this.messageBuffer.length>0;){let t=0,r=!1;if(this.messageBuffer.length>=4){let n=this.messageBuffer.readInt32BE(0);if(this.messageBuffer.length>=8){let a=this.messageBuffer.readInt32BE(4);(a===196608||a===196608)&&(t=n,r=this.messageBuffer.length>=t)}!r&&this.messageBuffer.length>=5&&(t=1+this.messageBuffer.readInt32BE(1),r=this.messageBuffer.length>=t)}if(!r||t===0){this.log(`handleData: incomplete message, buffering ${this.messageBuffer.length} bytes`);break}let o=this.messageBuffer.slice(0,t);if(this.messageBuffer=this.messageBuffer.slice(t),this.log(`handleData: processing message of ${o.length} bytes`),!this.active||!this.socket){this.log("handleData: socket no longer active, stopping processing");break}__frames.push(o)}if(__frames.length===0)return this.dispatchEvent(new CustomEvent("data",{detail:{incoming:e.length,outgoing:i}})),i;{let o=__frames.length===1?__frames[0]:Buffer.concat(__frames);const __lastF=__frames[__frames.length-1];const __lt=__lastF[0]>=65?__lastF[0]:null;const __closes=__lt===null||__lt===83||__lt===81||__lt===88;let h;if(await this.queryQueue.enqueue(this.id,new Uint8Array(o),n=>{this.log(`handleData: received ${n.length} bytes from PGlite`),this.inspectData("outgoing",n),n.length>0&&this.socket&&this.socket.writable&&this.active&&(this.log("handleData: writing response to socket"),this.socket?.writable?this.socket.write(Buffer.from(n),a=>{a?(this.log("handleData: error writing to socket:",a),h=a):this.log(`handleData: socket sent: ${n.length} bytes`)}):this.log("handleData: socket no longer writable")),i+=n.length},__closes),h)throw h}return this.dispatchEvent(new CustomEvent("data",{detail:{incoming:e.length,outgoing:i}})),i}catch(i){throw this.log("handleData: error processing data:",i),i}}handleError(e){if(!this.active){this.log("handleError: handler not active, ignoring error");return}e.message?.includes("ECONNRESET")?this.log("handleError: client disconnected (ECONNRESET) - normal behavior"):e.message?.includes("Idle timeout")?this.log("handleError: connection idle timeout"):this.log("handleError:",e),this.active=!1,this.dispatchEvent(new CustomEvent("error",{detail:e})),this.detach(!0).catch(()=>{})}handleClose(){this.log("handleClose: socket closed"),this.active=!1,this.dispatchEvent(new CustomEvent("close")),this.detach(!1).catch(()=>{})}inspectData(e,i){if(this.inspect){console.log("-".repeat(75)),console.log(e==="incoming"?"-> incoming":"<- outgoing",i.length,"bytes");for(let t=0;t=32&&a<=126?String.fromCharCode(a):"."}console.log(`${t.toString(16).padStart(8,"0")} ${o} ${h}`)}}}};l.nextHandlerId=1;var d=l,u=class extends EventTarget{constructor(e){super();this.server=null;this.active=!1;this.handlers=new Set;this.db=e.db,e.path?this.path=e.path:(typeof e.port=="number"?this.port=e.port??e.port:this.port=5432,this.host=e.host||"127.0.0.1"),this.inspect=e.inspect??!1,this.debug=e.debug??!1,this.idleTimeout=e.idleTimeout??0,this.maxConnections=e.maxConnections??1,this.queryQueue=new c(this.db,this.debug),this.log(`constructor: created server on ${this.getServerConn()}`),this.log(`constructor: max connections: ${this.maxConnections}`),this.idleTimeout>0&&this.log(`constructor: idle timeout: ${this.idleTimeout}ms`)}log(e,...i){this.debug&&console.log(`[PGLiteSocketServer] ${e}`,...i)}async start(){if(this.log(`start: starting server on ${this.getServerConn()}`),this.server)throw new Error("Socket server already started");return await this.db.waitReady,this.active=!0,this.server=m(e=>{setImmediate(()=>this.handleConnection(e))}),this.server.maxConnections=this.maxConnections,new Promise((e,i)=>{if(!this.server)return i(new Error("Server not initialized"));if(this.server.on("error",t=>{this.log("start: server error:",t),this.dispatchEvent(new CustomEvent("error",{detail:t})),this.active||i(t)}),this.path)this.server.listen(this.path,()=>{this.log(`start: server listening on ${this.getServerConn()}`),this.dispatchEvent(new CustomEvent("listening",{detail:{path:this.path}})),e()});else{let t=this.server;t.listen(this.port,this.host,()=>{let r=t.address();if(r===null||typeof r!="object")throw Error("Expected address info");this.port=r.port,this.log(`start: server listening on ${this.getServerConn()}`),this.dispatchEvent(new CustomEvent("listening",{detail:{port:this.port,host:this.host}})),e()})}})}getServerConn(){return this.path?this.path:`${this.host}:${this.port}`}async stop(){this.log("stop: stopping server"),this.active=!1,this.log(`stop: detaching ${this.handlers.size} handlers`);for(let e of this.handlers)e.detach(!0).catch(()=>{});return this.handlers.clear(),this.server?new Promise(e=>{if(!this.server)return e();this.server.close(()=>{this.log("stop: server closed"),this.server=null,this.dispatchEvent(new CustomEvent("close")),e()})}):(this.log("stop: server not running, nothing to do"),Promise.resolve())}async handleConnection(e){let i={clientAddress:e.remoteAddress||"unknown",clientPort:e.remotePort||0};if(this.log(`handleConnection: new connection from ${i.clientAddress}:${i.clientPort}`),this.log(`handleConnection: active connections: ${this.handlers.size}, queued queries: ${this.queryQueue.getQueueLength()}`),!this.active){this.log("handleConnection: server not active, closing connection");try{e.end()}catch(r){this.log("handleConnection: error closing socket:",r)}return}if(this.handlers.size>=this.maxConnections){this.log("handleConnection: max connections reached, rejecting"),e.write(Buffer.from(`Too many connections `)),e.end();return}let t=new d({queryQueue:this.queryQueue,closeOnDetach:!0,inspect:this.inspect,debug:this.debug,idleTimeout:this.idleTimeout});this.handlers.add(t),t.addEventListener("error",r=>{let o=r.detail;o?.message?.includes("ECONNRESET")?this.log(`handler #${t.handlerId}: client disconnected (ECONNRESET)`):o?.message?.includes("Idle timeout")?this.log(`handler #${t.handlerId}: idle timeout`):this.log(`handler #${t.handlerId}: error:`,o)}),t.addEventListener("close",()=>{this.log(`handler #${t.handlerId}: closed`),this.handlers.delete(t),this.log(`handleConnection: active connections: ${this.handlers.size}`)});try{await t.attach(e),this.dispatchEvent(new CustomEvent("connection",{detail:i}))}catch(r){this.log("handleConnection: error attaching socket:",r),this.handlers.delete(t),this.dispatchEvent(new CustomEvent("error",{detail:r}));try{e.end()}catch(o){this.log("handleConnection: error closing socket:",o)}}}getStats(){return{activeConnections:this.handlers.size,queuedQueries:this.queryQueue.getQueueLength(),maxConnections:this.maxConnections}}};export{b as a,d as b,u as c}; //# sourceMappingURL=chunk-NSUMFCRM.js.map \ No newline at end of file From 34f3720030cc4cbb1cb321949c41016d817c7aaa Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 18 Aug 2026 09:54:32 -0700 Subject: [PATCH 043/133] Capture pageviews on the marketing site (#1655) UTM-tagged landings on executor.sh produced no $pageview, so campaigns read as zero traffic in PostHog. The params only reached $web_vitals, which no funnel or web-analytics view uses. Autocapture stays off. --- apps/marketing/src/layouts/Layout.astro | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/marketing/src/layouts/Layout.astro b/apps/marketing/src/layouts/Layout.astro index 603613436c..d791ecda8c 100644 --- a/apps/marketing/src/layouts/Layout.astro +++ b/apps/marketing/src/layouts/Layout.astro @@ -50,9 +50,16 @@ const canonical = new URL(Astro.url.pathname, Astro.site ?? Astro.url).toString( // deploy env, so it's a no-op in dev / unconfigured builds (and the SDK // isn't even shipped, thanks to the dynamic import). Events are proxied // first-party through src/middleware.ts to survive adblockers. - // autocapture and pageviews are off on purpose: we only send the explicit - // events the page fires (e.g. the "Set up with your agent" copy). Flip - // capture_pageview on if you want a denominator for conversion. + // autocapture stays off on purpose: beyond $pageview we only send the + // explicit events the page fires (e.g. the "Set up with your agent" copy). + // + // capture_pageview is ON because this is the campaign landing surface. + // With it off, a visit to executor.sh/?utm_source=... produced no + // $pageview at all, so every UTM-tagged campaign read as zero traffic in + // PostHog — the params only ever reached $web_vitals, which no funnel or + // web-analytics view is built on. Astro serves a full page load per + // navigation, so plain `true` (capture once on init) is right here; + // `history_change` is for SPAs like apps/cloud. const phKey = import.meta.env.PUBLIC_POSTHOG_KEY; if (phKey) { // api_host rides the `/_astro` prefix that the cloud edge forwards to @@ -62,7 +69,7 @@ const canonical = new URL(Astro.url.pathname, Astro.site ?? Astro.url).toString( api_host: `${window.location.origin}/_astro/_ph`, ui_host: import.meta.env.PUBLIC_POSTHOG_HOST ?? "https://us.posthog.com", autocapture: false, - capture_pageview: false, + capture_pageview: true, persistence: "localStorage", }); return posthog; From 739805fc8d22e712341b86ec65955b6d201614a3 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 18 Aug 2026 10:06:14 -0700 Subject: [PATCH 044/133] Support GitHub App OAuth permissions (#1659) --- apps/cloud/src/engine/execution-stack.ts | 4 +++ packages/core/sdk/src/oauth-client.ts | 5 +++ .../core/sdk/src/oauth-first-party.test.ts | 36 ++++++++++++++++++- packages/core/sdk/src/oauth-service.ts | 8 +++-- 4 files changed, 49 insertions(+), 4 deletions(-) diff --git a/apps/cloud/src/engine/execution-stack.ts b/apps/cloud/src/engine/execution-stack.ts index 5120f02b3d..1106b1f9fc 100644 --- a/apps/cloud/src/engine/execution-stack.ts +++ b/apps/cloud/src/engine/execution-stack.ts @@ -126,6 +126,10 @@ const cloudFirstPartyOAuthClients = (): readonly FirstPartyOAuthClientConfig[] = env.FIRST_PARTY_GITHUB_TOKEN_URL ?? "https://github.com/login/oauth/access_token", clientId: env.FIRST_PARTY_GITHUB_CLIENT_ID, clientSecret: env.FIRST_PARTY_GITHUB_CLIENT_SECRET, + integrations: [IntegrationSlug.make("github_rest")], + // GitHub App user access tokens do not use classic OAuth scopes; + // their capabilities come from the app's registered permissions. + authorizationScopes: [], }, ] : []), diff --git a/packages/core/sdk/src/oauth-client.ts b/packages/core/sdk/src/oauth-client.ts index 3e6b034679..ea2c71a388 100644 --- a/packages/core/sdk/src/oauth-client.ts +++ b/packages/core/sdk/src/oauth-client.ts @@ -129,6 +129,11 @@ export interface FirstPartyOAuthClientConfig { * exact-match default for those integrations. Endpoint-host matching still * applies when omitted. */ readonly integrations?: readonly IntegrationSlug[]; + /** Scopes sent on the provider authorization request instead of the + * integration-declared set. Use an empty array for providers such as + * GitHub Apps, whose capabilities are configured on the app and whose OAuth + * user-token flow does not use scopes. Omit for normal OAuth clients. */ + readonly authorizationScopes?: readonly string[]; /** OAuth scopes this deployment permits the app to request. Omit to allow * every scope declared by a matching integration. For declared scopes, * start and completion fail unless every requested scope belongs to this diff --git a/packages/core/sdk/src/oauth-first-party.test.ts b/packages/core/sdk/src/oauth-first-party.test.ts index f6ab846a68..d9070bceaa 100644 --- a/packages/core/sdk/src/oauth-first-party.test.ts +++ b/packages/core/sdk/src/oauth-first-party.test.ts @@ -17,7 +17,7 @@ import { } from "./oauth-client"; import { definePlugin } from "./plugin"; import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; -import { serveOAuthTestServer } from "./testing/oauth-test-server"; +import { scopesFromAuthorizeUrl, serveOAuthTestServer } from "./testing/oauth-test-server"; // First-party OAuth clients: host-operated apps declared in executor config // (`firstPartyOAuthClients`), addressed as `first-party:`. Resolved from @@ -165,6 +165,40 @@ describe("first-party oauth clients", () => { }, ); + it.effect("an authorization scope override supports scope-less provider app tokens", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["repo"] }); + const { executor } = yield* makeTestWorkspaceHarness({ + plugins, + firstPartyOAuthClients: [{ ...firstPartyClientFor(server), authorizationScopes: [] }], + }); + yield* executor.acme.seed(["repo"]); + + const started = yield* executor.oauth.start({ + owner: "org", + client: FIRST_PARTY, + clientOwner: "org", + name: ConnectionName.make("github-app"), + integration: INTEG, + template: TEMPLATE, + }); + expect(started.status).toBe("redirect"); + if (started.status !== "redirect") return; + expect(scopesFromAuthorizeUrl(started.authorizationUrl)).toEqual([]); + + const callback = yield* server.completeAuthorizationCodeFlow({ + authorizationUrl: started.authorizationUrl, + }); + const connection = yield* executor.oauth.complete({ + state: started.state, + code: callback.code, + }); + expect(connection.oauthScope).toBeNull(); + }), + ), + ); + it.effect("refresh resolves the config-declared client (no oauth_client row exists)", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index ecdf18fe75..8ec30c679a 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -1302,9 +1302,11 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { // list is already authoritative (§7.2) and must not be re-narrowed by a // divergent authorization server. const authorizationRequestedScopes = - scopePolicy.kind === "discover" - ? requestedScopes - : yield* filterAuthorizationCodeScopes(client, requestedScopes); + firstParty?.authorizationScopes !== undefined + ? dedupeScopes(firstParty.authorizationScopes) + : scopePolicy.kind === "discover" + ? requestedScopes + : yield* filterAuthorizationCodeScopes(client, requestedScopes); // authorization_code: persist a session + build the authorize URL. const verifier = createPkceCodeVerifier(); From a5f340021fd01e1ef46d06c298be5cc909aae96b Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 18 Aug 2026 10:22:01 -0700 Subject: [PATCH 045/133] Fix two more CI e2e flakes: picker hydration race and dev-db ghost affinity (#1658) * Retry reveal-style clicks that race console hydration * Repair dev-db queue affinity abandoned by mid-execution disconnects --- apps/cloud/scripts/dev-db.ts | 22 ++-- .../db/dev-db-socket-concurrency.node.test.ts | 105 ++++++++++++++---- e2e/scenarios/google-health-checks.test.ts | 8 +- e2e/scenarios/google-photos-preset-ui.test.ts | 4 +- e2e/scenarios/provider-plugins-ui.test.ts | 7 +- e2e/src/surfaces/browser.ts | 31 +++++- .../@electric-sql%2Fpglite-socket@0.1.4.patch | 7 +- 7 files changed, 140 insertions(+), 44 deletions(-) diff --git a/apps/cloud/scripts/dev-db.ts b/apps/cloud/scripts/dev-db.ts index 039ff3dc76..2bc447a138 100644 --- a/apps/cloud/scripts/dev-db.ts +++ b/apps/cloud/scripts/dev-db.ts @@ -104,14 +104,20 @@ await migrate(drizzle(db), { migrationsFolder: MIGRATIONS_FOLDER }); // but prepared statement requires M" -> random 500s on whichever request lost // the race). The patch in patches/@electric-sql%2Fpglite-socket@0.1.4.patch // batches each socket data event into one queue entry and holds handler -// affinity while a pipeline is open. The patch also fixes the queue's failure -// path: stock 0.1.4 `return`ed out of the drain loop when a query REJECTED at -// the JS level, leaving its `processing` flag latched true — after one such -// throw nothing was ever dequeued again, so new connections' startup packets -// sat unanswered (postgres.js CONNECT_TIMEOUT) and the whole stack was bricked -// until restart: the CI e2e "cloud signIn: callback set no session (500)" -// cascade. src/db/dev-db-socket-concurrency.node.test.ts is the regression -// test for all of the above. +// affinity while a pipeline is open. The patch also fixes the queue's two +// self-bricking failure paths — both surfaced in CI as the e2e "cloud signIn: +// callback set no session (500)" cascade, where new connections' startup +// packets sat unanswered (postgres.js CONNECT_TIMEOUT) until restart: +// 1. Stock 0.1.4 `return`ed out of the drain loop when a query REJECTED at +// the JS level, leaving its `processing` flag latched true; nothing was +// ever dequeued again. +// 2. A client whose socket died WHILE its pipeline-opening entry executed: +// detach() cleared affinity before the entry finished, the queue then +// took affinity for the already-dead handler, and no timer was left to +// release it. The queue now tracks detached handlers and repairs any +// transaction or pipeline affinity they can no longer release. +// src/db/dev-db-socket-concurrency.node.test.ts is the regression test for +// all of the above. const server = new PGLiteSocketServer({ db, port: PORT, diff --git a/apps/cloud/src/db/dev-db-socket-concurrency.node.test.ts b/apps/cloud/src/db/dev-db-socket-concurrency.node.test.ts index 57db00314e..b9de235fe9 100644 --- a/apps/cloud/src/db/dev-db-socket-concurrency.node.test.ts +++ b/apps/cloud/src/db/dev-db-socket-concurrency.node.test.ts @@ -42,6 +42,40 @@ const makeClient = (port: number, connectTimeout = 5) => onnotice: () => undefined, }); +// Hand-rolled wire client: connect and complete the trust-auth startup, so a +// test can then speak raw protocol frames (e.g. a lone Parse) that postgres.js +// would never emit on its own. Resolves after ReadyForQuery so the next write +// is its own data event — and its own queue entry — on the server. +const openWireClient = async (port: number): Promise => { + const socket: Socket = connect(port, "127.0.0.1"); + await new Promise((res, rej) => { + socket.once("connect", res); + socket.once("error", rej); + }); + const startupBody = Buffer.concat([ + Buffer.from([0, 3, 0, 0]), + Buffer.from("user\0postgres\0database\0postgres\0\0"), + ]); + const startup = Buffer.concat([Buffer.alloc(4), startupBody]); + startup.writeInt32BE(startup.length, 0); + socket.write(startup); + await new Promise((res) => { + socket.on("data", (chunk: Buffer) => { + if (chunk.includes(0x5a)) res(); // 'Z' = ReadyForQuery + }); + }); + return socket; +}; + +// A Parse frame for an unnamed statement: opens an extended-protocol pipeline +// that only a later Sync (or the server's recovery) closes. +const parseFrame = (query: string): Buffer => { + const body = Buffer.concat([Buffer.from(`\0${query}\0`), Buffer.from([0, 0])]); + const frame = Buffer.concat([Buffer.from("P"), Buffer.alloc(4), body]); + frame.writeInt32BE(4 + body.length, 1); + return frame; +}; + describe("dev-db PGlite socket under concurrent connections", () => { it( "serves interleaved multi-connection pipelines without protocol corruption", @@ -207,29 +241,8 @@ describe("dev-db PGlite socket under concurrent connections", () => { // Hand-rolled wire client: complete the trust-auth startup, then send a // lone Parse. Its last frame type ('P') marks the pipeline open, so the // handler takes affinity and every other connection queues behind it. - const staller: Socket = connect(port, "127.0.0.1"); - await new Promise((res, rej) => { - staller.once("connect", res); - staller.once("error", rej); - }); - const startupBody = Buffer.concat([ - Buffer.from([0, 3, 0, 0]), - Buffer.from("user\0postgres\0database\0postgres\0\0"), - ]); - const startup = Buffer.concat([Buffer.alloc(4), startupBody]); - startup.writeInt32BE(startup.length, 0); - staller.write(startup); - // Wait for AuthenticationOk + ReadyForQuery before opening the pipeline, - // so the Parse is its own data event (and its own queue entry). - await new Promise((res) => { - staller.on("data", (chunk: Buffer) => { - if (chunk.includes(0x5a)) res(); // 'Z' = ReadyForQuery - }); - }); - const parseBody = Buffer.from("\0select 1\0\0\0"); - const parse = Buffer.concat([Buffer.from("P"), Buffer.alloc(4), parseBody]); - parse.writeInt32BE(4 + parseBody.length, 1); - staller.write(parse); + const staller = await openWireClient(port); + staller.write(parseFrame("select 1")); const bystander = makeClient(port, 10); // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: sockets must be closed on every path @@ -245,4 +258,50 @@ describe("dev-db PGlite socket under concurrent connections", () => { } }, ); + + // Regression for the second wedge mode behind the same CI cascade: a client + // whose socket dies WHILE its pipeline-opening entry is executing. detach() + // clears pipeline affinity before the entry finishes, so the queue then + // assigned affinity to the already-dead handler — and nothing ever cleared + // it: the dead handler has no timers left, and every other connection + // (including fresh startups) queued behind the ghost forever. The queue now + // tracks detached handlers and repairs affinity they can no longer release. + it( + "a client that dies mid-execution does not leave the queue pinned to its ghost", + { timeout: 30_000 }, + async () => { + const port = 45994; + const db = await PGlite.create(); + + // Hold the marker query in flight long enough that the disconnect below + // reliably lands while the entry is EXECUTING (after detach's cleanup, + // before the queue takes affinity for it). + const real = db.execProtocolRawStream.bind(db); + (db as { execProtocolRawStream: typeof real }).execProtocolRawStream = async (...args) => { + if (Buffer.from(args[0]).includes("ghost_marker")) await sleep(300); + return real(...args); + }; + + const server = new PGLiteSocketServer({ db, port, host: "127.0.0.1", maxConnections: 100 }); + await server.start(); + + const ghost = await openWireClient(port); + ghost.write(parseFrame("select 'ghost_marker'")); + // Give the data event time to reach the queue and start executing, then + // die without a trace mid-flight. + await sleep(100); + ghost.destroy(); + + const bystander = makeClient(port); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: sockets must be closed on every path + try { + expect((await bystander.unsafe(`select 5 as five`))[0]).toEqual({ five: 5 }); + } finally { + // oxlint-disable-next-line executor/no-promise-catch -- test boundary: a failed teardown must not mask the assertion + await bystander.end({ timeout: 5 }).catch(() => {}); + await server.stop(); + await db.close(); + } + }, + ); }); diff --git a/e2e/scenarios/google-health-checks.test.ts b/e2e/scenarios/google-health-checks.test.ts index 8a87d4748e..6037c084c4 100644 --- a/e2e/scenarios/google-health-checks.test.ts +++ b/e2e/scenarios/google-health-checks.test.ts @@ -16,7 +16,7 @@ import { import { scenario } from "../src/scenario"; import { Api, Browser, Target } from "../src/services"; import type { Identity, Target as TargetShape } from "../src/target"; -import type { BrowserSurface } from "../src/surfaces/browser"; +import { clickToReveal, type BrowserSurface } from "../src/surfaces/browser"; const api = composePluginApi([openApiHttpPlugin()] as const); type Client = HttpApiClient.ForApi; @@ -88,12 +88,8 @@ const addGooglePresetFromCatalog = ( browser.session(identity, async ({ page, step }) => { await step(`Open ${presetName} from the connect catalog`, async () => { await page.goto("/integrations", { waitUntil: "networkidle" }); - await page - .getByRole("button", { name: /Connect/ }) - .first() - .click(); const dialog = page.getByRole("dialog", { name: "Connect an integration" }); - await dialog.waitFor(); + await clickToReveal(page.getByRole("button", { name: /Connect/ }).first(), dialog); await dialog.getByPlaceholder(/Search or paste a URL/).fill(presetName); await dialog.getByRole("link", { name: new RegExp(`^${presetName}\\b`) }).click(); }); diff --git a/e2e/scenarios/google-photos-preset-ui.test.ts b/e2e/scenarios/google-photos-preset-ui.test.ts index a4e956ced5..8b5788493b 100644 --- a/e2e/scenarios/google-photos-preset-ui.test.ts +++ b/e2e/scenarios/google-photos-preset-ui.test.ts @@ -3,6 +3,7 @@ import { Effect } from "effect"; import { scenario } from "../src/scenario"; import { Browser, Target } from "../src/services"; +import { clickToReveal } from "../src/surfaces/browser"; scenario( "Google Photos: separated catalog presets open a Photos service add flow", @@ -17,9 +18,8 @@ scenario( "Find the separated Google Photos presets from the integrations picker", async () => { await page.goto("/integrations", { waitUntil: "networkidle" }); - await page.getByRole("button", { name: "Connect" }).click(); const dialog = page.getByRole("dialog", { name: "Connect an integration" }); - await dialog.waitFor(); + await clickToReveal(page.getByRole("button", { name: "Connect" }), dialog); await dialog.getByPlaceholder(/Search or paste a URL/).fill("google photos"); await dialog.getByRole("link", { name: /^Google Photos Library\b/ }).waitFor(); await dialog.getByRole("link", { name: /^Google Photos Picker\b/ }).waitFor(); diff --git a/e2e/scenarios/provider-plugins-ui.test.ts b/e2e/scenarios/provider-plugins-ui.test.ts index 5066485969..dd59d5f4b4 100644 --- a/e2e/scenarios/provider-plugins-ui.test.ts +++ b/e2e/scenarios/provider-plugins-ui.test.ts @@ -3,6 +3,7 @@ import { Effect } from "effect"; import { scenario } from "../src/scenario"; import { Browser, Target } from "../src/services"; +import { clickToReveal } from "../src/surfaces/browser"; scenario( "Provider catalog · Google and Microsoft services are OpenAPI presets", @@ -15,8 +16,10 @@ scenario( yield* browser.session(identity, async ({ page, step }) => { await step("Open the integrations picker", async () => { await page.goto("/integrations", { waitUntil: "networkidle" }); - await page.getByRole("button", { name: "Connect" }).click(); - await page.getByRole("dialog", { name: "Connect an integration" }).waitFor(); + await clickToReveal( + page.getByRole("button", { name: "Connect" }), + page.getByRole("dialog", { name: "Connect an integration" }), + ); }); await step("The picker exposes OpenAPI plus provider service presets", async () => { diff --git a/e2e/src/surfaces/browser.ts b/e2e/src/surfaces/browser.ts index c68733310e..1ac33e305c 100644 --- a/e2e/src/surfaces/browser.ts +++ b/e2e/src/surfaces/browser.ts @@ -9,7 +9,7 @@ import { join } from "node:path"; import { promisify } from "node:util"; import { Effect } from "effect"; -import { chromium, type Page } from "playwright"; +import { chromium, type Locator, type Page } from "playwright"; import { beat, enterFocus, markNavigation, markRecordingStart } from "../timeline"; import { appendTraces, type TraceEntry } from "../trace-harvest"; @@ -36,6 +36,35 @@ const slug = (text: string): string => .replace(/^-+|-+$/g, "") .slice(0, 60); +/** + * Click `trigger` until `revealed` is visible. + * + * `waitUntil: "networkidle"` does not mean the console has hydrated: a click + * that lands between the SSR paint and React attaching the handler is + * swallowed without a trace, and whatever the click was meant to open never + * appears (the "Connect an integration" dialog no-show flake). Re-clicking a + * reveal-style trigger is idempotent, so retry until the result is actually + * on screen; the final attempt waits with the full timeout so the failure + * surfaces as the ordinary locator error. + */ +export const clickToReveal = async ( + trigger: Locator, + revealed: Locator, + { attempts = 5, revealTimeoutMs = 4_000 }: { attempts?: number; revealTimeoutMs?: number } = {}, +): Promise => { + for (let attempt = 1; attempt < attempts; attempt++) { + await trigger.click(); + const shown = await revealed + .waitFor({ timeout: revealTimeoutMs }) + .then(() => true) + // oxlint-disable-next-line executor/no-promise-catch -- retry boundary: a missed reveal is the signal to click again, not a failure + .catch(() => false); + if (shown) return; + } + await trigger.click(); + await revealed.waitFor({ timeout: revealTimeoutMs }); +}; + // acquireUseRelease so a vitest timeout (fiber interruption) still closes the // browser and flushes video + trace — a bare promise would leak Chromium. export const makeBrowserSurface = (dir: string, target: Target): BrowserSurface => ({ diff --git a/patches/@electric-sql%2Fpglite-socket@0.1.4.patch b/patches/@electric-sql%2Fpglite-socket@0.1.4.patch index f84d44d139..1098228ef0 100644 --- a/patches/@electric-sql%2Fpglite-socket@0.1.4.patch +++ b/patches/@electric-sql%2Fpglite-socket@0.1.4.patch @@ -4,13 +4,16 @@ index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2 diff --git a/node_modules/@electric-sql/pglite-socket/.bun-tag-eaa11f63ffd98a26 b/.bun-tag-eaa11f63ffd98a26 new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 +diff --git a/node_modules/@electric-sql/pglite-socket/.bun-tag-fbab1bb0bfbef953 b/.bun-tag-fbab1bb0bfbef953 +new file mode 100644 +index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/dist/chunk-NSUMFCRM.js b/dist/chunk-NSUMFCRM.js -index 37d45ebc5150c43c919fbdb1c7fffb51b18fda8c..cc2a8f6e35c6a676f4fdecb7d580fc1202b74ace 100644 +index 37d45ebc5150c43c919fbdb1c7fffb51b18fda8c..1cdfaaa69b568a0bed7618fe527ab2c10b1b9403 100644 --- a/dist/chunk-NSUMFCRM.js +++ b/dist/chunk-NSUMFCRM.js @@ -1,3 +1,3 @@ -import{createServer as m}from"net";var b=6e4,c=class{constructor(s,e=!1){this.queue=[];this.processing=!1;this.lastHandlerId=null;this.db=s,this.debug=e}log(s,...e){this.debug&&console.log(`[QueryQueueManager] ${s}`,...e)}async enqueue(s,e,i){return new Promise((t,r)=>{let o={handlerId:s,message:e,resolve:t,reject:r,timestamp:Date.now(),onData:i};this.queue.push(o),this.log(`enqueued query from handler #${s}, queue size: ${this.queue.length}`),this.processing||this.processQueue()})}async processQueue(){if(!(this.processing||this.queue.length===0)){for(this.processing=!0;this.queue.length>0;){let s;if(this.db.isInTransaction()&&this.lastHandlerId){let t=this.queue.findIndex(r=>r.handlerId===this.lastHandlerId);t===-1?(this.log("transaction started, but no query from the same handler id found in queue",this.lastHandlerId),s=null):s=this.queue.splice(t,1)[0]}else s=this.queue.shift();if(!s)break;let e=Date.now()-s.timestamp;this.log(`processing query from handler #${s.handlerId} (waited ${e}ms)`);let i=0;try{await this.db.runExclusive(async()=>await this.db.execProtocolRawStream(s.message,{onRawData:t=>{i+=t.length,s.onData(t)}}))}catch(t){this.log(`query from handler #${s.handlerId} failed:`,t),s.reject(t);return}this.log(`query from handler #${s.handlerId} completed, ${i} bytes`),this.lastHandlerId=s.handlerId,s.resolve(i)}this.processing=!1,this.log("queue processing complete, queue length is",this.queue.length)}}getQueueLength(){return this.queue.length}clearQueueForHandler(s){let e=this.queue.length;this.queue=this.queue.filter(t=>t.handlerId===s?(t.reject(new Error("Handler disconnected")),!1):!0);let i=e-this.queue.length;i>0&&this.log(`cleared ${i} queries for handler #${s}`)}async clearTransactionIfNeeded(s){this.db.isInTransaction()&&this.lastHandlerId===s&&(await this.db.exec("ROLLBACK"),this.lastHandlerId=null,await this.processQueue())}},l=class l extends EventTarget{constructor(e){super();this.socket=null;this.active=!1;this.messageBuffer=Buffer.alloc(0);this.lastActivityTime=Date.now();this.queryQueue=e.queryQueue,this.closeOnDetach=e.closeOnDetach??!1,this.inspect=e.inspect??!1,this.debug=e.debug??!1,this.idleTimeout=e.idleTimeout??0,this.id=l.nextHandlerId++,this.log("constructor: created new handler")}get handlerId(){return this.id}log(e,...i){this.debug&&console.log(`[PGLiteSocketHandler#${this.id}] ${e}`,...i)}async attach(e){if(this.log(`attach: attaching socket from ${e.remoteAddress}:${e.remotePort}`),this.socket)throw new Error("Socket already attached");return this.socket=e,this.active=!0,this.lastActivityTime=Date.now(),e.setNoDelay(!0),this.idleTimeout>0&&this.resetIdleTimer(),this.log("attach: setting up socket event handlers"),e.on("data",i=>{this.lastActivityTime=Date.now(),this.resetIdleTimer(),setImmediate(async()=>{try{await this.handleData(i)}catch(t){this.log("socket on data error: ",t),this.handleError(t)}})}),e.on("error",i=>{setImmediate(()=>this.handleError(i))}),e.on("close",()=>{setImmediate(()=>this.handleClose())}),this.log("attach: socket handler ready"),this}resetIdleTimer(){this.idleTimeout<=0||(this.idleTimer&&clearTimeout(this.idleTimer),this.idleTimer=setTimeout(()=>{let e=Date.now()-this.lastActivityTime;this.log(`idle timeout after ${e}ms`),this.handleError(new Error("Idle timeout"))},this.idleTimeout))}async detach(e){if(this.log(`detach: detaching socket, close=${e??this.closeOnDetach}`),this.idleTimer&&(clearTimeout(this.idleTimer),this.idleTimer=void 0),this.queryQueue.clearQueueForHandler(this.id),await this.queryQueue.clearTransactionIfNeeded(this.id),!this.socket)return this.log("detach: no socket attached, nothing to do"),this;if(this.socket.removeAllListeners("data"),this.socket.removeAllListeners("error"),this.socket.removeAllListeners("close"),(e??this.closeOnDetach)&&this.socket.writable){this.log("detach: closing socket");try{this.socket.end(),this.socket.destroy()}catch(i){this.log("detach: error closing socket:",i)}}return this.socket=null,this.active=!1,this.messageBuffer=Buffer.alloc(0),this.log("detach: handler cleaned up"),this}get isAttached(){return this.socket!==null}async handleData(e){if(!this.socket||!this.active)return this.log("handleData: no active socket, ignoring data"),0;this.log(`handleData: received ${e.length} bytes`),this.messageBuffer=Buffer.concat([this.messageBuffer,e]),this.inspectData("incoming",e);try{let i=0;for(;this.messageBuffer.length>0;){let t=0,r=!1;if(this.messageBuffer.length>=4){let n=this.messageBuffer.readInt32BE(0);if(this.messageBuffer.length>=8){let a=this.messageBuffer.readInt32BE(4);(a===196608||a===196608)&&(t=n,r=this.messageBuffer.length>=t)}!r&&this.messageBuffer.length>=5&&(t=1+this.messageBuffer.readInt32BE(1),r=this.messageBuffer.length>=t)}if(!r||t===0){this.log(`handleData: incomplete message, buffering ${this.messageBuffer.length} bytes`);break}let o=this.messageBuffer.slice(0,t);if(this.messageBuffer=this.messageBuffer.slice(t),this.log(`handleData: processing message of ${o.length} bytes`),!this.active||!this.socket){this.log("handleData: socket no longer active, stopping processing");break}let h;if(await this.queryQueue.enqueue(this.id,new Uint8Array(o),n=>{this.log(`handleData: received ${n.length} bytes from PGlite`),this.inspectData("outgoing",n),n.length>0&&this.socket&&this.socket.writable&&this.active&&(this.log("handleData: writing response to socket"),this.socket?.writable?this.socket.write(Buffer.from(n),a=>{a?(this.log("handleData: error writing to socket:",a),h=a):this.log(`handleData: socket sent: ${n.length} bytes`)}):this.log("handleData: socket no longer writable")),i+=n.length}),h)throw h}return this.dispatchEvent(new CustomEvent("data",{detail:{incoming:e.length,outgoing:i}})),i}catch(i){throw this.log("handleData: error processing data:",i),i}}handleError(e){if(!this.active){this.log("handleError: handler not active, ignoring error");return}e.message?.includes("ECONNRESET")?this.log("handleError: client disconnected (ECONNRESET) - normal behavior"):e.message?.includes("Idle timeout")?this.log("handleError: connection idle timeout"):this.log("handleError:",e),this.active=!1,this.dispatchEvent(new CustomEvent("error",{detail:e})),this.detach(!0)}handleClose(){this.log("handleClose: socket closed"),this.active=!1,this.dispatchEvent(new CustomEvent("close")),this.detach(!1)}inspectData(e,i){if(this.inspect){console.log("-".repeat(75)),console.log(e==="incoming"?"-> incoming":"<- outgoing",i.length,"bytes");for(let t=0;t=32&&a<=126?String.fromCharCode(a):"."}console.log(`${t.toString(16).padStart(8,"0")} ${o} ${h}`)}}}};l.nextHandlerId=1;var d=l,u=class extends EventTarget{constructor(e){super();this.server=null;this.active=!1;this.handlers=new Set;this.db=e.db,e.path?this.path=e.path:(typeof e.port=="number"?this.port=e.port??e.port:this.port=5432,this.host=e.host||"127.0.0.1"),this.inspect=e.inspect??!1,this.debug=e.debug??!1,this.idleTimeout=e.idleTimeout??0,this.maxConnections=e.maxConnections??1,this.queryQueue=new c(this.db,this.debug),this.log(`constructor: created server on ${this.getServerConn()}`),this.log(`constructor: max connections: ${this.maxConnections}`),this.idleTimeout>0&&this.log(`constructor: idle timeout: ${this.idleTimeout}ms`)}log(e,...i){this.debug&&console.log(`[PGLiteSocketServer] ${e}`,...i)}async start(){if(this.log(`start: starting server on ${this.getServerConn()}`),this.server)throw new Error("Socket server already started");return await this.db.waitReady,this.active=!0,this.server=m(e=>{setImmediate(()=>this.handleConnection(e))}),this.server.maxConnections=this.maxConnections,new Promise((e,i)=>{if(!this.server)return i(new Error("Server not initialized"));if(this.server.on("error",t=>{this.log("start: server error:",t),this.dispatchEvent(new CustomEvent("error",{detail:t})),this.active||i(t)}),this.path)this.server.listen(this.path,()=>{this.log(`start: server listening on ${this.getServerConn()}`),this.dispatchEvent(new CustomEvent("listening",{detail:{path:this.path}})),e()});else{let t=this.server;t.listen(this.port,this.host,()=>{let r=t.address();if(r===null||typeof r!="object")throw Error("Expected address info");this.port=r.port,this.log(`start: server listening on ${this.getServerConn()}`),this.dispatchEvent(new CustomEvent("listening",{detail:{port:this.port,host:this.host}})),e()})}})}getServerConn(){return this.path?this.path:`${this.host}:${this.port}`}async stop(){this.log("stop: stopping server"),this.active=!1,this.log(`stop: detaching ${this.handlers.size} handlers`);for(let e of this.handlers)e.detach(!0);return this.handlers.clear(),this.server?new Promise(e=>{if(!this.server)return e();this.server.close(()=>{this.log("stop: server closed"),this.server=null,this.dispatchEvent(new CustomEvent("close")),e()})}):(this.log("stop: server not running, nothing to do"),Promise.resolve())}async handleConnection(e){let i={clientAddress:e.remoteAddress||"unknown",clientPort:e.remotePort||0};if(this.log(`handleConnection: new connection from ${i.clientAddress}:${i.clientPort}`),this.log(`handleConnection: active connections: ${this.handlers.size}, queued queries: ${this.queryQueue.getQueueLength()}`),!this.active){this.log("handleConnection: server not active, closing connection");try{e.end()}catch(r){this.log("handleConnection: error closing socket:",r)}return}if(this.handlers.size>=this.maxConnections){this.log("handleConnection: max connections reached, rejecting"),e.write(Buffer.from(`Too many connections -+import{createServer as m}from"net";var b=6e4,c=class{constructor(s,e=!1){this.queue=[];this.processing=!1;this.lastHandlerId=null;this.pipelineHandlerId=null;this.db=s,this.debug=e}log(s,...e){this.debug&&console.log(`[QueryQueueManager] ${s}`,...e)}async enqueue(s,e,i,S=!0){return new Promise((t,r)=>{let o={handlerId:s,message:e,resolve:t,reject:r,timestamp:Date.now(),onData:i,closes:S};this.queue.push(o),this.log(`enqueued query from handler #${s}, queue size: ${this.queue.length}`),this.processing||this.processQueue()})}async processQueue(){if(!(this.processing||this.queue.length===0)){for(this.processing=!0;this.queue.length>0;){let s;let __affine=this.db.isInTransaction()&&this.lastHandlerId?this.lastHandlerId:this.pipelineHandlerId;if(__affine){let t=this.queue.findIndex(r=>r.handlerId===__affine);t===-1?(this.log("affinity held, waiting for handler",__affine),s=null):s=this.queue.splice(t,1)[0]}else s=this.queue.shift();if(!s)break;let e=Date.now()-s.timestamp;this.log(`processing query from handler #${s.handlerId} (waited ${e}ms)`);let i=0;try{await this.db.runExclusive(async()=>await this.db.execProtocolRawStream(s.message,{onRawData:t=>{i+=t.length,s.onData(t)}}))}catch(t){this.log(`query from handler #${s.handlerId} failed:`,t),s.reject(t),this.pipelineHandlerId=null;continue}this.log(`query from handler #${s.handlerId} completed, ${i} bytes`),this.lastHandlerId=s.handlerId,this.pipelineHandlerId=s.closes?null:s.handlerId,s.resolve(i)}this.processing=!1,this.log("queue processing complete, queue length is",this.queue.length)}}getQueueLength(){return this.queue.length}holdsAffinity(s){return this.pipelineHandlerId===s||this.db.isInTransaction()&&this.lastHandlerId===s}clearQueueForHandler(s){let e=this.queue.length;this.queue=this.queue.filter(t=>t.handlerId===s?(t.reject(new Error("Handler disconnected")),!1):!0);let i=e-this.queue.length;i>0&&this.log(`cleared ${i} queries for handler #${s}`)}async clearPipelineIfNeeded(s){this.pipelineHandlerId===s&&(this.pipelineHandlerId=null,await this.db.runExclusive(async()=>await this.db.execProtocolRawStream(new Uint8Array([83,0,0,0,4]),{onRawData:()=>{}})).catch(()=>{}),this.processQueue())}async clearTransactionIfNeeded(s){this.db.isInTransaction()&&this.lastHandlerId===s&&(await this.db.exec("ROLLBACK"),this.lastHandlerId=null,await this.processQueue())}},l=class l extends EventTarget{constructor(e){super();this.socket=null;this.active=!1;this.messageBuffer=Buffer.alloc(0);this.lastActivityTime=Date.now();this.queryQueue=e.queryQueue,this.closeOnDetach=e.closeOnDetach??!1,this.inspect=e.inspect??!1,this.debug=e.debug??!1,this.idleTimeout=e.idleTimeout??0,this.id=l.nextHandlerId++,this.log("constructor: created new handler")}get handlerId(){return this.id}log(e,...i){this.debug&&console.log(`[PGLiteSocketHandler#${this.id}] ${e}`,...i)}async attach(e){if(this.log(`attach: attaching socket from ${e.remoteAddress}:${e.remotePort}`),this.socket)throw new Error("Socket already attached");return this.socket=e,this.active=!0,this.lastActivityTime=Date.now(),e.setNoDelay(!0),this.idleTimeout>0&&this.resetIdleTimer(),this.log("attach: setting up socket event handlers"),e.on("data",i=>{this.lastActivityTime=Date.now(),this.resetIdleTimer(),setImmediate(async()=>{try{await this.handleData(i)}catch(t){this.log("socket on data error: ",t),this.handleError(t)}})}),e.on("error",i=>{setImmediate(()=>this.handleError(i))}),e.on("close",()=>{setImmediate(()=>this.handleClose())}),this.log("attach: socket handler ready"),this}resetIdleTimer(){this.idleTimeout<=0||(this.idleTimer&&clearTimeout(this.idleTimer),this.idleTimer=setTimeout(()=>{if(!this.queryQueue.holdsAffinity(this.id)){this.resetIdleTimer();return}let e=Date.now()-this.lastActivityTime;this.log(`idle timeout after ${e}ms`),this.handleError(new Error("Idle timeout"))},this.idleTimeout))}async detach(e){if(this.log(`detach: detaching socket, close=${e??this.closeOnDetach}`),this.idleTimer&&(clearTimeout(this.idleTimer),this.idleTimer=void 0),this.queryQueue.clearQueueForHandler(this.id),await this.queryQueue.clearTransactionIfNeeded(this.id),await this.queryQueue.clearPipelineIfNeeded(this.id),!this.socket)return this.log("detach: no socket attached, nothing to do"),this;if(this.socket.removeAllListeners("data"),this.socket.removeAllListeners("error"),this.socket.removeAllListeners("close"),(e??this.closeOnDetach)&&this.socket.writable){this.log("detach: closing socket");try{this.socket.end(),this.socket.destroy()}catch(i){this.log("detach: error closing socket:",i)}}return this.socket=null,this.active=!1,this.messageBuffer=Buffer.alloc(0),this.log("detach: handler cleaned up"),this}get isAttached(){return this.socket!==null}async handleData(e){if(!this.socket||!this.active)return this.log("handleData: no active socket, ignoring data"),0;this.log(`handleData: received ${e.length} bytes`),this.messageBuffer=Buffer.concat([this.messageBuffer,e]),this.inspectData("incoming",e);try{let i=0;const __frames=[];for(;this.messageBuffer.length>0;){let t=0,r=!1;if(this.messageBuffer.length>=4){let n=this.messageBuffer.readInt32BE(0);if(this.messageBuffer.length>=8){let a=this.messageBuffer.readInt32BE(4);(a===196608||a===196608)&&(t=n,r=this.messageBuffer.length>=t)}!r&&this.messageBuffer.length>=5&&(t=1+this.messageBuffer.readInt32BE(1),r=this.messageBuffer.length>=t)}if(!r||t===0){this.log(`handleData: incomplete message, buffering ${this.messageBuffer.length} bytes`);break}let o=this.messageBuffer.slice(0,t);if(this.messageBuffer=this.messageBuffer.slice(t),this.log(`handleData: processing message of ${o.length} bytes`),!this.active||!this.socket){this.log("handleData: socket no longer active, stopping processing");break}__frames.push(o)}if(__frames.length===0)return this.dispatchEvent(new CustomEvent("data",{detail:{incoming:e.length,outgoing:i}})),i;{let o=__frames.length===1?__frames[0]:Buffer.concat(__frames);const __lastF=__frames[__frames.length-1];const __lt=__lastF[0]>=65?__lastF[0]:null;const __closes=__lt===null||__lt===83||__lt===81||__lt===88;let h;if(await this.queryQueue.enqueue(this.id,new Uint8Array(o),n=>{this.log(`handleData: received ${n.length} bytes from PGlite`),this.inspectData("outgoing",n),n.length>0&&this.socket&&this.socket.writable&&this.active&&(this.log("handleData: writing response to socket"),this.socket?.writable?this.socket.write(Buffer.from(n),a=>{a?(this.log("handleData: error writing to socket:",a),h=a):this.log(`handleData: socket sent: ${n.length} bytes`)}):this.log("handleData: socket no longer writable")),i+=n.length},__closes),h)throw h}return this.dispatchEvent(new CustomEvent("data",{detail:{incoming:e.length,outgoing:i}})),i}catch(i){throw this.log("handleData: error processing data:",i),i}}handleError(e){if(!this.active){this.log("handleError: handler not active, ignoring error");return}e.message?.includes("ECONNRESET")?this.log("handleError: client disconnected (ECONNRESET) - normal behavior"):e.message?.includes("Idle timeout")?this.log("handleError: connection idle timeout"):this.log("handleError:",e),this.active=!1,this.dispatchEvent(new CustomEvent("error",{detail:e})),this.detach(!0).catch(()=>{})}handleClose(){this.log("handleClose: socket closed"),this.active=!1,this.dispatchEvent(new CustomEvent("close")),this.detach(!1).catch(()=>{})}inspectData(e,i){if(this.inspect){console.log("-".repeat(75)),console.log(e==="incoming"?"-> incoming":"<- outgoing",i.length,"bytes");for(let t=0;t=32&&a<=126?String.fromCharCode(a):"."}console.log(`${t.toString(16).padStart(8,"0")} ${o} ${h}`)}}}};l.nextHandlerId=1;var d=l,u=class extends EventTarget{constructor(e){super();this.server=null;this.active=!1;this.handlers=new Set;this.db=e.db,e.path?this.path=e.path:(typeof e.port=="number"?this.port=e.port??e.port:this.port=5432,this.host=e.host||"127.0.0.1"),this.inspect=e.inspect??!1,this.debug=e.debug??!1,this.idleTimeout=e.idleTimeout??0,this.maxConnections=e.maxConnections??1,this.queryQueue=new c(this.db,this.debug),this.log(`constructor: created server on ${this.getServerConn()}`),this.log(`constructor: max connections: ${this.maxConnections}`),this.idleTimeout>0&&this.log(`constructor: idle timeout: ${this.idleTimeout}ms`)}log(e,...i){this.debug&&console.log(`[PGLiteSocketServer] ${e}`,...i)}async start(){if(this.log(`start: starting server on ${this.getServerConn()}`),this.server)throw new Error("Socket server already started");return await this.db.waitReady,this.active=!0,this.server=m(e=>{setImmediate(()=>this.handleConnection(e))}),this.server.maxConnections=this.maxConnections,new Promise((e,i)=>{if(!this.server)return i(new Error("Server not initialized"));if(this.server.on("error",t=>{this.log("start: server error:",t),this.dispatchEvent(new CustomEvent("error",{detail:t})),this.active||i(t)}),this.path)this.server.listen(this.path,()=>{this.log(`start: server listening on ${this.getServerConn()}`),this.dispatchEvent(new CustomEvent("listening",{detail:{path:this.path}})),e()});else{let t=this.server;t.listen(this.port,this.host,()=>{let r=t.address();if(r===null||typeof r!="object")throw Error("Expected address info");this.port=r.port,this.log(`start: server listening on ${this.getServerConn()}`),this.dispatchEvent(new CustomEvent("listening",{detail:{port:this.port,host:this.host}})),e()})}})}getServerConn(){return this.path?this.path:`${this.host}:${this.port}`}async stop(){this.log("stop: stopping server"),this.active=!1,this.log(`stop: detaching ${this.handlers.size} handlers`);for(let e of this.handlers)e.detach(!0).catch(()=>{});return this.handlers.clear(),this.server?new Promise(e=>{if(!this.server)return e();this.server.close(()=>{this.log("stop: server closed"),this.server=null,this.dispatchEvent(new CustomEvent("close")),e()})}):(this.log("stop: server not running, nothing to do"),Promise.resolve())}async handleConnection(e){let i={clientAddress:e.remoteAddress||"unknown",clientPort:e.remotePort||0};if(this.log(`handleConnection: new connection from ${i.clientAddress}:${i.clientPort}`),this.log(`handleConnection: active connections: ${this.handlers.size}, queued queries: ${this.queryQueue.getQueueLength()}`),!this.active){this.log("handleConnection: server not active, closing connection");try{e.end()}catch(r){this.log("handleConnection: error closing socket:",r)}return}if(this.handlers.size>=this.maxConnections){this.log("handleConnection: max connections reached, rejecting"),e.write(Buffer.from(`Too many connections ++import{createServer as m}from"net";var b=6e4,c=class{constructor(s,e=!1){this.queue=[];this.processing=!1;this.lastHandlerId=null;this.pipelineHandlerId=null;this.dead=new Set();this.db=s,this.debug=e}log(s,...e){this.debug&&console.log(`[QueryQueueManager] ${s}`,...e)}async enqueue(s,e,i,S=!0){return new Promise((t,r)=>{let o={handlerId:s,message:e,resolve:t,reject:r,timestamp:Date.now(),onData:i,closes:S};this.queue.push(o),this.log(`enqueued query from handler #${s}, queue size: ${this.queue.length}`),this.processing||this.processQueue()})}async processQueue(){if(!(this.processing||this.queue.length===0)){for(this.processing=!0;this.queue.length>0;){let s;let __affine=this.db.isInTransaction()&&this.lastHandlerId?this.lastHandlerId:this.pipelineHandlerId;if(__affine&&this.dead.has(__affine)){this.log("affinity held by detached handler, recovering",__affine);if(this.db.isInTransaction()&&this.lastHandlerId===__affine){await this.db.exec("ROLLBACK").catch(()=>{});this.lastHandlerId=null}if(this.pipelineHandlerId===__affine){this.pipelineHandlerId=null;await this.db.runExclusive(async()=>await this.db.execProtocolRawStream(new Uint8Array([83,0,0,0,4]),{onRawData:()=>{}})).catch(()=>{})}continue}if(__affine){let t=this.queue.findIndex(r=>r.handlerId===__affine);t===-1?(this.log("affinity held, waiting for handler",__affine),s=null):s=this.queue.splice(t,1)[0]}else s=this.queue.shift();if(!s)break;let e=Date.now()-s.timestamp;this.log(`processing query from handler #${s.handlerId} (waited ${e}ms)`);let i=0;try{await this.db.runExclusive(async()=>await this.db.execProtocolRawStream(s.message,{onRawData:t=>{i+=t.length,s.onData(t)}}))}catch(t){this.log(`query from handler #${s.handlerId} failed:`,t),s.reject(t),this.pipelineHandlerId=null;continue}this.log(`query from handler #${s.handlerId} completed, ${i} bytes`),this.lastHandlerId=s.handlerId,this.pipelineHandlerId=s.closes?null:s.handlerId,s.resolve(i)}this.processing=!1,this.log("queue processing complete, queue length is",this.queue.length)}}getQueueLength(){return this.queue.length}holdsAffinity(s){return this.pipelineHandlerId===s||this.db.isInTransaction()&&this.lastHandlerId===s}clearQueueForHandler(s){this.dead.add(s);let e=this.queue.length;this.queue=this.queue.filter(t=>t.handlerId===s?(t.reject(new Error("Handler disconnected")),!1):!0);let i=e-this.queue.length;i>0&&this.log(`cleared ${i} queries for handler #${s}`)}async clearPipelineIfNeeded(s){this.pipelineHandlerId===s&&(this.pipelineHandlerId=null,await this.db.runExclusive(async()=>await this.db.execProtocolRawStream(new Uint8Array([83,0,0,0,4]),{onRawData:()=>{}})).catch(()=>{}),this.processQueue())}async clearTransactionIfNeeded(s){this.db.isInTransaction()&&this.lastHandlerId===s&&(await this.db.exec("ROLLBACK"),this.lastHandlerId=null,await this.processQueue())}},l=class l extends EventTarget{constructor(e){super();this.socket=null;this.active=!1;this.messageBuffer=Buffer.alloc(0);this.lastActivityTime=Date.now();this.queryQueue=e.queryQueue,this.closeOnDetach=e.closeOnDetach??!1,this.inspect=e.inspect??!1,this.debug=e.debug??!1,this.idleTimeout=e.idleTimeout??0,this.id=l.nextHandlerId++,this.log("constructor: created new handler")}get handlerId(){return this.id}log(e,...i){this.debug&&console.log(`[PGLiteSocketHandler#${this.id}] ${e}`,...i)}async attach(e){if(this.log(`attach: attaching socket from ${e.remoteAddress}:${e.remotePort}`),this.socket)throw new Error("Socket already attached");return this.socket=e,this.active=!0,this.lastActivityTime=Date.now(),e.setNoDelay(!0),this.idleTimeout>0&&this.resetIdleTimer(),this.log("attach: setting up socket event handlers"),e.on("data",i=>{this.lastActivityTime=Date.now(),this.resetIdleTimer(),setImmediate(async()=>{try{await this.handleData(i)}catch(t){this.log("socket on data error: ",t),this.handleError(t)}})}),e.on("error",i=>{setImmediate(()=>this.handleError(i))}),e.on("close",()=>{setImmediate(()=>this.handleClose())}),this.log("attach: socket handler ready"),this}resetIdleTimer(){this.idleTimeout<=0||(this.idleTimer&&clearTimeout(this.idleTimer),this.idleTimer=setTimeout(()=>{if(!this.queryQueue.holdsAffinity(this.id)){this.resetIdleTimer();return}let e=Date.now()-this.lastActivityTime;this.log(`idle timeout after ${e}ms`),this.handleError(new Error("Idle timeout"))},this.idleTimeout))}async detach(e){if(this.log(`detach: detaching socket, close=${e??this.closeOnDetach}`),this.idleTimer&&(clearTimeout(this.idleTimer),this.idleTimer=void 0),this.queryQueue.clearQueueForHandler(this.id),await this.queryQueue.clearTransactionIfNeeded(this.id),await this.queryQueue.clearPipelineIfNeeded(this.id),!this.socket)return this.log("detach: no socket attached, nothing to do"),this;if(this.socket.removeAllListeners("data"),this.socket.removeAllListeners("error"),this.socket.removeAllListeners("close"),(e??this.closeOnDetach)&&this.socket.writable){this.log("detach: closing socket");try{this.socket.end(),this.socket.destroy()}catch(i){this.log("detach: error closing socket:",i)}}return this.socket=null,this.active=!1,this.messageBuffer=Buffer.alloc(0),this.log("detach: handler cleaned up"),this}get isAttached(){return this.socket!==null}async handleData(e){if(!this.socket||!this.active)return this.log("handleData: no active socket, ignoring data"),0;this.log(`handleData: received ${e.length} bytes`),this.messageBuffer=Buffer.concat([this.messageBuffer,e]),this.inspectData("incoming",e);try{let i=0;const __frames=[];for(;this.messageBuffer.length>0;){let t=0,r=!1;if(this.messageBuffer.length>=4){let n=this.messageBuffer.readInt32BE(0);if(this.messageBuffer.length>=8){let a=this.messageBuffer.readInt32BE(4);(a===196608||a===196608)&&(t=n,r=this.messageBuffer.length>=t)}!r&&this.messageBuffer.length>=5&&(t=1+this.messageBuffer.readInt32BE(1),r=this.messageBuffer.length>=t)}if(!r||t===0){this.log(`handleData: incomplete message, buffering ${this.messageBuffer.length} bytes`);break}let o=this.messageBuffer.slice(0,t);if(this.messageBuffer=this.messageBuffer.slice(t),this.log(`handleData: processing message of ${o.length} bytes`),!this.active||!this.socket){this.log("handleData: socket no longer active, stopping processing");break}__frames.push(o)}if(__frames.length===0)return this.dispatchEvent(new CustomEvent("data",{detail:{incoming:e.length,outgoing:i}})),i;{let o=__frames.length===1?__frames[0]:Buffer.concat(__frames);const __lastF=__frames[__frames.length-1];const __lt=__lastF[0]>=65?__lastF[0]:null;const __closes=__lt===null||__lt===83||__lt===81||__lt===88;let h;if(await this.queryQueue.enqueue(this.id,new Uint8Array(o),n=>{this.log(`handleData: received ${n.length} bytes from PGlite`),this.inspectData("outgoing",n),n.length>0&&this.socket&&this.socket.writable&&this.active&&(this.log("handleData: writing response to socket"),this.socket?.writable?this.socket.write(Buffer.from(n),a=>{a?(this.log("handleData: error writing to socket:",a),h=a):this.log(`handleData: socket sent: ${n.length} bytes`)}):this.log("handleData: socket no longer writable")),i+=n.length},__closes),h)throw h}return this.dispatchEvent(new CustomEvent("data",{detail:{incoming:e.length,outgoing:i}})),i}catch(i){throw this.log("handleData: error processing data:",i),i}}handleError(e){if(!this.active){this.log("handleError: handler not active, ignoring error");return}e.message?.includes("ECONNRESET")?this.log("handleError: client disconnected (ECONNRESET) - normal behavior"):e.message?.includes("Idle timeout")?this.log("handleError: connection idle timeout"):this.log("handleError:",e),this.active=!1,this.dispatchEvent(new CustomEvent("error",{detail:e})),this.detach(!0).catch(()=>{})}handleClose(){this.log("handleClose: socket closed"),this.active=!1,this.dispatchEvent(new CustomEvent("close")),this.detach(!1).catch(()=>{})}inspectData(e,i){if(this.inspect){console.log("-".repeat(75)),console.log(e==="incoming"?"-> incoming":"<- outgoing",i.length,"bytes");for(let t=0;t=32&&a<=126?String.fromCharCode(a):"."}console.log(`${t.toString(16).padStart(8,"0")} ${o} ${h}`)}}}};l.nextHandlerId=1;var d=l,u=class extends EventTarget{constructor(e){super();this.server=null;this.active=!1;this.handlers=new Set;this.db=e.db,e.path?this.path=e.path:(typeof e.port=="number"?this.port=e.port??e.port:this.port=5432,this.host=e.host||"127.0.0.1"),this.inspect=e.inspect??!1,this.debug=e.debug??!1,this.idleTimeout=e.idleTimeout??0,this.maxConnections=e.maxConnections??1,this.queryQueue=new c(this.db,this.debug),this.log(`constructor: created server on ${this.getServerConn()}`),this.log(`constructor: max connections: ${this.maxConnections}`),this.idleTimeout>0&&this.log(`constructor: idle timeout: ${this.idleTimeout}ms`)}log(e,...i){this.debug&&console.log(`[PGLiteSocketServer] ${e}`,...i)}async start(){if(this.log(`start: starting server on ${this.getServerConn()}`),this.server)throw new Error("Socket server already started");return await this.db.waitReady,this.active=!0,this.server=m(e=>{setImmediate(()=>this.handleConnection(e))}),this.server.maxConnections=this.maxConnections,new Promise((e,i)=>{if(!this.server)return i(new Error("Server not initialized"));if(this.server.on("error",t=>{this.log("start: server error:",t),this.dispatchEvent(new CustomEvent("error",{detail:t})),this.active||i(t)}),this.path)this.server.listen(this.path,()=>{this.log(`start: server listening on ${this.getServerConn()}`),this.dispatchEvent(new CustomEvent("listening",{detail:{path:this.path}})),e()});else{let t=this.server;t.listen(this.port,this.host,()=>{let r=t.address();if(r===null||typeof r!="object")throw Error("Expected address info");this.port=r.port,this.log(`start: server listening on ${this.getServerConn()}`),this.dispatchEvent(new CustomEvent("listening",{detail:{port:this.port,host:this.host}})),e()})}})}getServerConn(){return this.path?this.path:`${this.host}:${this.port}`}async stop(){this.log("stop: stopping server"),this.active=!1,this.log(`stop: detaching ${this.handlers.size} handlers`);for(let e of this.handlers)e.detach(!0).catch(()=>{});return this.handlers.clear(),this.server?new Promise(e=>{if(!this.server)return e();this.server.close(()=>{this.log("stop: server closed"),this.server=null,this.dispatchEvent(new CustomEvent("close")),e()})}):(this.log("stop: server not running, nothing to do"),Promise.resolve())}async handleConnection(e){let i={clientAddress:e.remoteAddress||"unknown",clientPort:e.remotePort||0};if(this.log(`handleConnection: new connection from ${i.clientAddress}:${i.clientPort}`),this.log(`handleConnection: active connections: ${this.handlers.size}, queued queries: ${this.queryQueue.getQueueLength()}`),!this.active){this.log("handleConnection: server not active, closing connection");try{e.end()}catch(r){this.log("handleConnection: error closing socket:",r)}return}if(this.handlers.size>=this.maxConnections){this.log("handleConnection: max connections reached, rejecting"),e.write(Buffer.from(`Too many connections `)),e.end();return}let t=new d({queryQueue:this.queryQueue,closeOnDetach:!0,inspect:this.inspect,debug:this.debug,idleTimeout:this.idleTimeout});this.handlers.add(t),t.addEventListener("error",r=>{let o=r.detail;o?.message?.includes("ECONNRESET")?this.log(`handler #${t.handlerId}: client disconnected (ECONNRESET)`):o?.message?.includes("Idle timeout")?this.log(`handler #${t.handlerId}: idle timeout`):this.log(`handler #${t.handlerId}: error:`,o)}),t.addEventListener("close",()=>{this.log(`handler #${t.handlerId}: closed`),this.handlers.delete(t),this.log(`handleConnection: active connections: ${this.handlers.size}`)});try{await t.attach(e),this.dispatchEvent(new CustomEvent("connection",{detail:i}))}catch(r){this.log("handleConnection: error attaching socket:",r),this.handlers.delete(t),this.dispatchEvent(new CustomEvent("error",{detail:r}));try{e.end()}catch(o){this.log("handleConnection: error closing socket:",o)}}}getStats(){return{activeConnections:this.handlers.size,queuedQueries:this.queryQueue.getQueueLength(),maxConnections:this.maxConnections}}};export{b as a,d as b,u as c}; //# sourceMappingURL=chunk-NSUMFCRM.js.map \ No newline at end of file From 41ec2d6e5cc283afd8f4bd5da57d0127163b87b4 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 18 Aug 2026 10:38:00 -0700 Subject: [PATCH 046/133] Poll the emulator ledger instead of racing its write (#1661) --- e2e/scenarios/google-health-checks.test.ts | 30 ++++++++++++++++------ 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/e2e/scenarios/google-health-checks.test.ts b/e2e/scenarios/google-health-checks.test.ts index 6037c084c4..6dc532ac5b 100644 --- a/e2e/scenarios/google-health-checks.test.ts +++ b/e2e/scenarios/google-health-checks.test.ts @@ -1,7 +1,7 @@ import { randomBytes } from "node:crypto"; import { expect } from "@effect/vitest"; -import { Effect } from "effect"; +import { Effect, Schedule } from "effect"; import type { HttpApiClient } from "effect/unstable/httpapi"; import { composePluginApi } from "@executor-js/api/server"; import { connectEmulator, type EmulatorClient } from "@executor-js/emulate"; @@ -268,13 +268,27 @@ scenario( ).toBe("healthy"); } - const ledger = yield* Effect.promise(() => emulator.client.ledger.list(100)); - for (const row of rows) { - expect( - ledger.some((entry) => entry.operationId === row.expectedLedgerOperation), - `${row.presetName} health check reached the Google emulator`, - ).toBe(true); - } + // The hosted emulator acknowledges a request before its ledger entry + // is readable, so a single list right after the probe races the write + // (the health checks above already came back healthy, which only the + // emulator can answer). Poll until every expected operation is + // visible; on timeout the assertion names what never arrived. + const missing = yield* Effect.gen(function* () { + const ledger = yield* Effect.promise(() => emulator.client.ledger.list(100)); + return rows.filter( + (row) => !ledger.some((entry) => entry.operationId === row.expectedLedgerOperation), + ); + }).pipe( + Effect.repeat({ + schedule: Schedule.spaced("500 millis"), + until: (unseen) => unseen.length === 0, + times: 19, + }), + ); + expect( + missing.map((row) => row.presetName), + "every health check reached the Google emulator", + ).toEqual([]); }), Effect.gen(function* () { for (const row of rows) { From 586a9c0c7b0d8fa365d57a5bb266400df4aec49e Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 18 Aug 2026 10:56:32 -0700 Subject: [PATCH 047/133] Recognize vite strictPort exits as port collisions in claimAndBoot (#1662) --- e2e/src/ports.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/e2e/src/ports.ts b/e2e/src/ports.ts index 811f7f4fc1..5d227abf57 100644 --- a/e2e/src/ports.ts +++ b/e2e/src/ports.ts @@ -234,8 +234,14 @@ export const isAddrInUse = (error: unknown): boolean => { for (let cursor: unknown = error; cursor instanceof Error; cursor = cursor.cause) { if ((cursor as NodeJS.ErrnoException).code === "EADDRINUSE") return true; // The emulate/vite boot glue wraps the OS error in a plain Error whose - // message carries the code, so match the text too. - if (/EADDRINUSE/.test(cursor.message)) return true; + // message carries the code, so match the text too. Vite's --strictPort + // exit never says EADDRINUSE at all — the CLI catches the bind failure + // and dies with "Port N is already in use", which reaches us only as + // BootProcessExitError's log tail — so match that phrasing as well, or + // the claimAndBoot re-claim retry never engages for the most common + // collision (a Linux ephemeral outbound socket grabbing the claimed + // port between probe release and vite's bind). + if (/EADDRINUSE|is already in use/.test(cursor.message)) return true; } return false; }; From d16abec9f290097a409f34b76b33a075a26cca0b Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 18 Aug 2026 11:23:28 -0700 Subject: [PATCH 048/133] Expand first-party Google Workspace support (#1663) --- apps/cloud/src/engine/execution-stack.ts | 33 ++++- apps/marketing/src/pages/about-executor.astro | 15 +- apps/marketing/src/pages/google-oauth.astro | 50 ++++++- .../src/pages/google-workspace.astro | 31 +++- apps/marketing/src/pages/privacy.astro | 9 +- e2e/scenarios/first-party-oauth.test.ts | 135 +++++++++++++++++- .../google/__snapshots__/presets.test.ts.snap | 4 + .../src/providers/google/presets.test.ts | 48 +++++++ .../openapi/src/providers/google/presets.ts | 28 +++- .../src/planner.test.ts | 1 + .../provider-service-split/src/planner.ts | 1 + 11 files changed, 324 insertions(+), 31 deletions(-) diff --git a/apps/cloud/src/engine/execution-stack.ts b/apps/cloud/src/engine/execution-stack.ts index 1106b1f9fc..013774bc81 100644 --- a/apps/cloud/src/engine/execution-stack.ts +++ b/apps/cloud/src/engine/execution-stack.ts @@ -94,15 +94,34 @@ export const CloudPluginsProvider: Layer.Layer = Layer.succeed( */ export const CLOUD_MOUNT_PREFIX = "/api" as const; -// Initial Google launch boundary. Gmail uses gmail.modify for read, send, and -// trash operations while immediate permanent deletion remains absent until the -// broader mail.google.com scope is approved. Account-wide Drive remains absent. -// The same scope source builds the catalog auth templates, preventing drift. +// Consumer Google launch boundary. Keep this list aligned with the scopes +// submitted for the Executor-owned production app: ordinary Workspace services +// plus Photos, Meet, and Search Console. Admin, Classroom, YouTube, Apps Script, +// BigQuery, and Cloud Resource Manager have materially different audiences or +// provider requirements and remain BYO OAuth. The same scope source builds each +// catalog auth template, preventing picker/start drift. +const GOOGLE_FIRST_PARTY_PRESET_IDS = [ + "google-calendar", + "google-meet", + "google-gmail", + "google-sheets", + "google-drive", + "google-docs", + "google-slides", + "google-forms", + "google-tasks", + "google-people", + "google-photos-library", + "google-photos-picker", + "google-search-console", +] as const; + const GOOGLE_FIRST_PARTY_ALLOWED_SCOPES: readonly string[] = [ ...new Set([ - ...googleCatalogOAuthScopesForPreset("google-calendar"), - ...googleCatalogOAuthScopesForPreset("google-gmail"), - ...googleCatalogOAuthScopesForPreset("google-sheets"), + ...GOOGLE_FIRST_PARTY_PRESET_IDS.flatMap(googleCatalogOAuthScopesForPreset), + // Connections created before the full-Gmail review retain this declared + // scope on reconnect. New Gmail presets request `mail.google.com`. + "https://www.googleapis.com/auth/gmail.modify", ]), ]; diff --git a/apps/marketing/src/pages/about-executor.astro b/apps/marketing/src/pages/about-executor.astro index 77acc55735..007d27125f 100644 --- a/apps/marketing/src/pages/about-executor.astro +++ b/apps/marketing/src/pages/about-executor.astro @@ -1,7 +1,7 @@ --- const pageTitle = "Executor"; const pageDescription = - "Executor is a web application that lets people connect AI assistants to Google Calendar, Gmail, Google Sheets, and other software, then control the actions those assistants can take."; + "Executor is a web application that lets people connect AI assistants to Google Workspace, related Google services, and other software, then control the actions those assistants can take."; --- @@ -106,9 +106,9 @@ const pageDescription =

What Executor does

- A person can use Executor to connect an AI assistant to services such as Google - Calendar, Gmail, and Google Sheets. The person can then ask the assistant to manage a - schedule, organize email, or update a spreadsheet through Executor. + A person can use Executor to connect an AI assistant to Google Workspace and related + Google services. The person can then ask the assistant to manage schedules, email, + files, documents, contacts, tasks, meetings, photos, or website data through Executor.

Executor performs only the actions the person requests and permits. It does not give an @@ -125,8 +125,13 @@ const pageDescription =

  • Google Calendar: read calendars and events, or create, update, and remove events.
  • -
  • Gmail: read and search messages, compose and send mail, manage labels, archive messages, or move messages to trash.
  • +
  • Gmail: read and search messages, compose and send mail, manage labels, archive or trash messages, or permanently delete messages only when explicitly requested.
  • Google Sheets: read spreadsheet data and update cells, ranges, and worksheets.
  • +
  • Google Drive, Docs, Slides, and Forms: find and manage files and folders, edit documents and presentations, and create or read forms and responses.
  • +
  • Google Contacts and Tasks: read or update contacts and contact groups, read other contacts or an available Workspace directory, and manage task lists and tasks.
  • +
  • Google Meet: create and configure meeting spaces or read meeting records, participants, recordings, and transcripts.
  • +
  • Google Photos: upload and manage app-created media or read media explicitly selected through Google Photos Picker.
  • +
  • Google Search Console: inspect verified sites, sitemaps, indexed URLs, and search-performance data.
diff --git a/apps/marketing/src/pages/google-oauth.astro b/apps/marketing/src/pages/google-oauth.astro index 7dd5091de7..a92c8a2e9f 100644 --- a/apps/marketing/src/pages/google-oauth.astro +++ b/apps/marketing/src/pages/google-oauth.astro @@ -13,8 +13,8 @@ const googleServices = [ index: "02", name: "Gmail", purpose: - "Executor can read, search, compose, send, label, archive, and move messages to trash when you instruct an agent to work with your email.", - scope: "googleapis.com/auth/gmail.modify", + "Executor can read, search, compose, send, organize, trash, and permanently delete messages only when you explicitly instruct an agent to work with your email.", + scope: "mail.google.com", }, { index: "03", @@ -23,12 +23,54 @@ const googleServices = [ "Executor can read spreadsheet data and update cells, ranges, and worksheets when you ask an agent to work with a spreadsheet.", scope: "googleapis.com/auth/spreadsheets", }, + { + index: "04", + name: "Google Drive", + purpose: + "Executor can find, create, download, organize, share, or delete files and folders when you ask an agent to manage your Drive.", + scope: "googleapis.com/auth/drive", + }, + { + index: "05", + name: "Google Docs, Slides, and Forms", + purpose: + "Executor can read and edit documents and presentations, and create or read forms and responses, when you ask an agent to work with that content.", + scope: "documents · presentations · forms.body · forms.responses.readonly", + }, + { + index: "06", + name: "Google Contacts and Tasks", + purpose: + "Executor can read or update contacts and contact groups, read other contacts or an available Workspace directory, and manage task lists and tasks when you ask an agent to organize people or work.", + scope: "contacts · contacts.other.readonly · directory.readonly · tasks", + }, + { + index: "07", + name: "Google Meet", + purpose: + "Executor can create and configure meeting spaces or read meeting records, participants, recordings, and transcripts when you request meeting-related work.", + scope: "meetings.space.created · readonly · settings", + }, + { + index: "08", + name: "Google Photos", + purpose: + "Executor can upload and manage app-created media or read media that you explicitly select through Google Photos Picker.", + scope: "photoslibrary.app-created read/write · photospicker.readonly", + }, + { + index: "09", + name: "Google Search Console", + purpose: + "Executor can inspect verified sites, sitemaps, indexed URLs, and search-performance data when you request website analysis.", + scope: "googleapis.com/auth/webmasters", + }, ] as const; ---
@@ -57,7 +99,7 @@ const googleServices = [

Executor

Executor is an integration platform and MCP gateway for AI agents. It lets you - securely connect Google Calendar, Gmail, and Google Sheets so an agent can read + securely connect Google Workspace and related Google services so an agent can read data and take the actions you request.

diff --git a/apps/marketing/src/pages/google-workspace.astro b/apps/marketing/src/pages/google-workspace.astro index e168a81605..c6a6714949 100644 --- a/apps/marketing/src/pages/google-workspace.astro +++ b/apps/marketing/src/pages/google-workspace.astro @@ -8,13 +8,38 @@ const services = [ { name: "Gmail", description: - "Read, search, compose, send, label, archive, or move messages to trash when you ask an agent to work with your email.", + "Read, search, compose, send, label, archive, trash, or permanently delete messages when you explicitly ask an agent to work with your email.", }, { name: "Google Sheets", description: "Read spreadsheet data and update cells, ranges, or worksheets when you ask an agent to work with a spreadsheet.", }, + { + name: "Google Drive, Docs, Slides, and Forms", + description: + "Find and manage files and folders, edit documents and presentations, and create or read forms and responses when you ask an agent to work with them.", + }, + { + name: "Google Contacts and Tasks", + description: + "Read or update contacts and contact groups, read other contacts or an available Workspace directory, and manage task lists and tasks when you ask an agent to organize people or work.", + }, + { + name: "Google Meet", + description: + "Create and configure meeting spaces or read meeting records, participants, recordings, and transcripts when you request meeting-related work.", + }, + { + name: "Google Photos", + description: + "Upload and manage app-created photos or read media you explicitly select through Google Photos Picker.", + }, + { + name: "Google Search Console", + description: + "Read and manage verified sites, sitemaps, URL inspection results, and search-performance data when you request website analysis.", + }, ] as const; --- @@ -26,7 +51,7 @@ const services = [ Executor @@ -134,7 +159,7 @@ const services = [

Executor

Executor is an integration platform and MCP gateway for AI agents. Executor lets you - securely connect Google Calendar, Gmail, and Google Sheets so an agent can read your + securely connect Google Workspace and related Google services so an agent can read your Google data and take only the actions you request.

diff --git a/apps/marketing/src/pages/privacy.astro b/apps/marketing/src/pages/privacy.astro index b1a4818eb2..e57eae4ab2 100644 --- a/apps/marketing/src/pages/privacy.astro +++ b/apps/marketing/src/pages/privacy.astro @@ -57,9 +57,12 @@ import LegalLayout from "../components/LegalLayout.astro";

If you connect a Google Workspace service, Executor uses the permissions you grant to perform the actions you request through that integration. Depending on the service and permissions you choose, this may include accessing - or modifying Google Calendar events, Google Sheets spreadsheets, Gmail messages, drafts, threads, attachments, - labels, or other Google Workspace content. For Gmail, this can include reading and searching email, composing and - sending messages, and organizing or moving messages to Trash when you request those actions. + or modifying Google Calendar events; Gmail messages, drafts, threads, attachments, labels, and settings; Google + Drive files and folders; Docs documents; Sheets spreadsheets; Slides presentations; Forms and responses; Contacts, + other contacts, and an available Workspace directory; Tasks; Meet spaces, participants, recordings, and transcripts; app-created or user-selected Photos media; Search + Console sites, sitemaps, indexed URLs, and performance data; or other Google content made available by the service + you connect. For Gmail, this can include permanently deleting messages or threads only when you explicitly request + that irreversible action.

Executor stores OAuth credentials and connection metadata so the integration can continue working. It processes diff --git a/e2e/scenarios/first-party-oauth.test.ts b/e2e/scenarios/first-party-oauth.test.ts index 39fcdc2664..7b9e93a868 100644 --- a/e2e/scenarios/first-party-oauth.test.ts +++ b/e2e/scenarios/first-party-oauth.test.ts @@ -25,7 +25,8 @@ import { } from "@executor-js/sdk/shared"; import { scenario } from "../src/scenario"; -import { Api, Target } from "../src/services"; +import { Api, Browser, Target } from "../src/services"; +import { clickToReveal } from "../src/surfaces/browser"; const api = composePluginApi([openApiHttpPlugin()] as const); @@ -168,26 +169,95 @@ scenario( ); scenario( - "First-party OAuth · Google offers Gmail modify but refuses full Gmail and Drive scopes", + "First-party OAuth · Google offers the reviewed consumer bundle and refuses admin scopes", {}, Effect.scoped( Effect.gen(function* () { const target = yield* Target; if (target.name !== "cloud") return; + const browser = yield* Browser; const { client: makeApiClient } = yield* Api; const identity = yield* target.newIdentity(); const client = yield* makeApiClient(api, identity); + yield* browser.session(identity, async ({ page, step }) => { + await step("Open the Google integration catalog", async () => { + await page.goto("/integrations", { waitUntil: "networkidle" }); + const dialog = page.getByRole("dialog", { name: "Connect an integration" }); + await clickToReveal(page.getByRole("button", { name: /Connect/ }).first(), dialog); + }); + + const services = [ + "Google Calendar", + "Google Meet", + "Gmail", + "Google Sheets", + "Google Drive", + "Google Docs", + "Google Slides", + "Google Forms", + "Google Tasks", + "Google People", + "Google Photos Library", + "Google Photos Picker", + "Google Search Console", + ] as const; + const dialog = page.getByRole("dialog", { name: "Connect an integration" }); + const search = dialog.getByPlaceholder(/Search or paste a URL/); + for (const service of services) { + await step(`${service} is available to connect`, async () => { + await search.fill(service); + await dialog.getByRole("link", { name: new RegExp(`^${service}\\b`) }).waitFor(); + }); + } + }); + const clients = yield* client.oauth.listClients(); const google = clients.find((candidate) => String(candidate.slug) === "first-party:google"); expect(google, "the env-declared first-party Google app is listed").toBeDefined(); expect(google?.origin.kind).toBe("first_party"); if (google?.origin.kind !== "first_party") return; expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/calendar"); + expect(google.origin.allowedScopes).toContain( + "https://www.googleapis.com/auth/meetings.space.readonly", + ); expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/gmail.modify"); + expect(google.origin.allowedScopes).toContain("https://mail.google.com/"); expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/spreadsheets"); - expect(google.origin.allowedScopes).not.toContain("https://mail.google.com/"); - expect(google.origin.allowedScopes).not.toContain("https://www.googleapis.com/auth/drive"); + expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/drive"); + expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/documents"); + expect(google.origin.allowedScopes).toContain( + "https://www.googleapis.com/auth/presentations", + ); + expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/forms.body"); + expect(google.origin.allowedScopes).toContain( + "https://www.googleapis.com/auth/forms.responses.readonly", + ); + expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/tasks"); + expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/contacts"); + expect(google.origin.allowedScopes).toContain( + "https://www.googleapis.com/auth/contacts.other.readonly", + ); + expect(google.origin.allowedScopes).toContain( + "https://www.googleapis.com/auth/directory.readonly", + ); + expect(google.origin.allowedScopes).toContain( + "https://www.googleapis.com/auth/photoslibrary.appendonly", + ); + expect(google.origin.allowedScopes).toContain( + "https://www.googleapis.com/auth/photoslibrary.edit.appcreateddata", + ); + expect(google.origin.allowedScopes).toContain( + "https://www.googleapis.com/auth/photospicker.mediaitems.readonly", + ); + expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/webmasters"); + expect(google.origin.allowedScopes).not.toContain( + "https://www.googleapis.com/auth/admin.directory.user", + ); + expect(google.origin.allowedScopes).not.toContain("https://www.googleapis.com/auth/youtube"); + expect(google.origin.allowedScopes).not.toContain( + "https://www.googleapis.com/auth/cloud-platform", + ); const calendar = IntegrationSlug.make(unique("google_calendar")); yield* client.openapi.addSpec({ @@ -266,14 +336,67 @@ scenario( slug: fullGmail, }, }); + const fullGmailStarted = yield* client.oauth.start({ + payload: { + client: OAuthClientSlug.make("first-party:google"), + clientOwner: "org", + owner: "org", + name: ConnectionName.make("gmail-full"), + integration: fullGmail, + template: AuthTemplateSlug.make("oauth"), + }, + }); + expect(fullGmailStarted.status).toBe("redirect"); + const fullGmailAuthorizationUrl = + fullGmailStarted.status === "redirect" ? fullGmailStarted.authorizationUrl : ""; + expect( + new Set(new URL(fullGmailAuthorizationUrl).searchParams.get("scope")?.split(" ") ?? []), + ).toEqual(new Set(["openid", "email", "profile", "https://mail.google.com/"])); + + const drive = IntegrationSlug.make(unique("google_drive")); + yield* client.openapi.addSpec({ + payload: { + ...googleShapedIntegrationSpec([ + "openid", + "email", + "profile", + "https://www.googleapis.com/auth/drive", + ]), + slug: drive, + }, + }); + const driveStarted = yield* client.oauth.start({ + payload: { + client: OAuthClientSlug.make("first-party:google"), + clientOwner: "org", + owner: "org", + name: ConnectionName.make("drive"), + integration: drive, + template: AuthTemplateSlug.make("oauth"), + }, + }); + expect(driveStarted.status).toBe("redirect"); + + const admin = IntegrationSlug.make(unique("google_admin")); + yield* client.openapi.addSpec({ + payload: { + ...googleShapedIntegrationSpec([ + "openid", + "email", + "profile", + "https://www.googleapis.com/auth/admin.directory.user", + ]), + slug: admin, + }, + }); const blocked = yield* client.oauth .start({ payload: { client: OAuthClientSlug.make("first-party:google"), clientOwner: "org", owner: "org", - name: ConnectionName.make("gmail-full"), - integration: fullGmail, + name: ConnectionName.make("admin"), + integration: admin, template: AuthTemplateSlug.make("oauth"), }, }) diff --git a/packages/plugins/openapi/src/providers/google/__snapshots__/presets.test.ts.snap b/packages/plugins/openapi/src/providers/google/__snapshots__/presets.test.ts.snap index f750036024..60d14c031c 100644 --- a/packages/plugins/openapi/src/providers/google/__snapshots__/presets.test.ts.snap +++ b/packages/plugins/openapi/src/providers/google/__snapshots__/presets.test.ts.snap @@ -6,6 +6,10 @@ exports[`classifies every Google service for bundle OAuth UX 1`] = ` "id": "google-calendar", "oauthAudience": "standard-user", }, + { + "id": "google-meet", + "oauthAudience": "standard-user", + }, { "id": "google-gmail", "oauthAudience": "standard-user", diff --git a/packages/plugins/openapi/src/providers/google/presets.test.ts b/packages/plugins/openapi/src/providers/google/presets.test.ts index fcd33712b0..db9bc9971a 100644 --- a/packages/plugins/openapi/src/providers/google/presets.test.ts +++ b/packages/plugins/openapi/src/providers/google/presets.test.ts @@ -174,6 +174,7 @@ const googleHealthCheckDiscoveryFixtures = { const FROZEN_GOOGLE_SLUGS = [ "google_calendar", + "google_meet", "google_gmail", "google_sheets", "google_drive", @@ -200,6 +201,7 @@ it("keeps Select all limited to Google services that can use normal user OAuth", const standardIds = new Set(googleStandardUserOAuthPresets.map((preset) => preset.id)); expect(standardIds).toContain("google-calendar"); + expect(standardIds).toContain("google-meet"); expect(standardIds).toContain("google-gmail"); expect(standardIds).toContain("google-tasks"); expect(standardIds).toContain("google-people"); @@ -213,6 +215,52 @@ it("keeps Select all limited to Google services that can use normal user OAuth", expect(standardIds).not.toContain("google-admin-reports"); }); +it("requests full Gmail and the complete user-facing Meet surface", () => { + const gmail = googleCatalog.find((preset) => preset.id === "google-gmail"); + const meet = googleCatalog.find((preset) => preset.id === "google-meet"); + const gmailOAuth = gmail?.authTemplate?.find((template) => template.kind === "oauth2"); + const meetOAuth = meet?.authTemplate?.find((template) => template.kind === "oauth2"); + + expect(gmailOAuth?.scopes).toContain("https://mail.google.com/"); + expect(gmailOAuth?.scopes).not.toContain("https://www.googleapis.com/auth/gmail.modify"); + expect(meetOAuth?.scopes).toEqual( + expect.arrayContaining([ + "https://www.googleapis.com/auth/meetings.space.created", + "https://www.googleapis.com/auth/meetings.space.readonly", + "https://www.googleapis.com/auth/meetings.space.settings", + ]), + ); +}); + +it("requests every scope needed by Forms, People, and app-created Photos", () => { + const oauthScopes = (presetId: string) => { + const preset = googleCatalog.find((candidate) => candidate.id === presetId); + const oauth = preset?.authTemplate?.find((template) => template.kind === "oauth2"); + return oauth?.scopes ?? []; + }; + + expect(oauthScopes("google-forms")).toEqual( + expect.arrayContaining([ + "https://www.googleapis.com/auth/forms.body", + "https://www.googleapis.com/auth/forms.responses.readonly", + ]), + ); + expect(oauthScopes("google-photos-library")).toEqual( + expect.arrayContaining([ + "https://www.googleapis.com/auth/photoslibrary.appendonly", + "https://www.googleapis.com/auth/photoslibrary.edit.appcreateddata", + "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata", + ]), + ); + expect(oauthScopes("google-people")).toEqual( + expect.arrayContaining([ + "https://www.googleapis.com/auth/contacts", + "https://www.googleapis.com/auth/contacts.other.readonly", + "https://www.googleapis.com/auth/directory.readonly", + ]), + ); +}); + it("classifies every Google service for bundle OAuth UX", () => { expect( googleOpenApiPresets.map((preset) => ({ diff --git a/packages/plugins/openapi/src/providers/google/presets.ts b/packages/plugins/openapi/src/providers/google/presets.ts index 5c2131971b..140a2a2c64 100644 --- a/packages/plugins/openapi/src/providers/google/presets.ts +++ b/packages/plugins/openapi/src/providers/google/presets.ts @@ -56,6 +56,15 @@ export const googleOpenApiPresets: readonly GoogleOpenApiPreset[] = [ featured: true, oauthAudience: "standard-user", }, + { + id: "google-meet", + name: "Google Meet", + summary: "Meeting spaces, conference records, participants, recordings, and transcripts.", + url: "https://meet.googleapis.com/$discovery/rest?version=v2", + icon: "https://fonts.gstatic.com/s/i/productlogos/meet_2020q4/v8/192px.svg", + featured: true, + oauthAudience: "standard-user", + }, { id: "google-gmail", name: "Gmail", @@ -250,16 +259,29 @@ export const googlePhotosOpenApiPresets: readonly GoogleOpenApiPreset[] = export const googleOAuthConsentScopes: Readonly> = { "google-calendar": ["https://www.googleapis.com/auth/calendar"], - "google-gmail": ["https://www.googleapis.com/auth/gmail.modify"], + "google-meet": [ + "https://www.googleapis.com/auth/meetings.space.created", + "https://www.googleapis.com/auth/meetings.space.readonly", + "https://www.googleapis.com/auth/meetings.space.settings", + ], + "google-gmail": ["https://mail.google.com/"], "google-sheets": ["https://www.googleapis.com/auth/spreadsheets"], "google-drive": ["https://www.googleapis.com/auth/drive"], "google-docs": ["https://www.googleapis.com/auth/documents"], "google-slides": ["https://www.googleapis.com/auth/presentations"], - "google-forms": ["https://www.googleapis.com/auth/forms.body"], + "google-forms": [ + "https://www.googleapis.com/auth/forms.body", + "https://www.googleapis.com/auth/forms.responses.readonly", + ], "google-tasks": ["https://www.googleapis.com/auth/tasks"], - "google-people": ["https://www.googleapis.com/auth/contacts"], + "google-people": [ + "https://www.googleapis.com/auth/contacts", + "https://www.googleapis.com/auth/contacts.other.readonly", + "https://www.googleapis.com/auth/directory.readonly", + ], "google-photos-library": [ "https://www.googleapis.com/auth/photoslibrary.appendonly", + "https://www.googleapis.com/auth/photoslibrary.edit.appcreateddata", "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata", ], "google-photos-picker": ["https://www.googleapis.com/auth/photospicker.mediaitems.readonly"], diff --git a/packages/plugins/provider-service-split/src/planner.test.ts b/packages/plugins/provider-service-split/src/planner.test.ts index 0a3773fe5c..8144e83496 100644 --- a/packages/plugins/provider-service-split/src/planner.test.ts +++ b/packages/plugins/provider-service-split/src/planner.test.ts @@ -140,6 +140,7 @@ const youtubeDiscoveryUrl = "https://www.googleapis.com/discovery/v1/apis/youtub const googleCatalogMethodPrefixFixtures: ReadonlyMap = new Map([ ["google-calendar", ["calendar.events.list"]], + ["google-meet", ["meet.spaces.get", "meet.conferenceRecords.list"]], ["google-gmail", ["gmail.users.messages.list"]], ["google-sheets", ["sheets.spreadsheets.get"]], ["google-drive", ["drive.files.list"]], diff --git a/packages/plugins/provider-service-split/src/planner.ts b/packages/plugins/provider-service-split/src/planner.ts index 69f5ad1138..75a76a6dde 100644 --- a/packages/plugins/provider-service-split/src/planner.ts +++ b/packages/plugins/provider-service-split/src/planner.ts @@ -264,6 +264,7 @@ const GOOGLE_IDENTITY_DISCOVERY_URL = "https://www.googleapis.com/discovery/v1/a const GOOGLE_TOOL_PREFIX_TO_PRESET_ID: ReadonlyMap = new Map([ ["calendar", "google-calendar"], + ["meet", "google-meet"], ["gmail", "google-gmail"], ["sheets", "google-sheets"], ["drive", "google-drive"], From 06786d01ae1980d04d2cbcb71486616e42059e5d Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:26:18 -0700 Subject: [PATCH 049/133] Make latency regressions attributable: version stamps, deploy markers, JWKS and dispatch spans (#1664) --- .github/workflows/deploy.yml | 20 ++++++- apps/cloud/src/auth/jwks-cache.ts | 40 +++++++++++-- apps/cloud/src/auth/workos.ts | 28 +++++++++- apps/cloud/src/edge/docs.ts | 42 +++++++++++++- apps/cloud/src/env-augment.d.ts | 7 +++ apps/cloud/src/observability/telemetry.ts | 30 +++++++++- apps/cloud/src/server.ts | 68 +++++++++++++++++------ apps/cloud/wrangler.jsonc | 14 +++++ 8 files changed, 221 insertions(+), 28 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 80bff301a4..a946f0ae8b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -77,12 +77,30 @@ jobs: VITE_PUBLIC_SENTRY_DSN: ${{ secrets.VITE_PUBLIC_SENTRY_DSN }} - name: Deploy cloud - run: bun run wrangler deploy -c dist/server/wrangler.json + run: bun run wrangler deploy -c dist/server/wrangler.json --var GIT_COMMIT_SHA:${{ github.sha }} working-directory: apps/cloud env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + # Deploy marker: one event per deploy into the same Axiom dataset the + # worker traces land in, so a latency step-change lines up with its + # deploy in one query. Skipped (not failed) when the secret is absent, + # and never blocks the deploy. + - name: Record deploy marker in Axiom + env: + AXIOM_INGEST_TOKEN: ${{ secrets.AXIOM_INGEST_TOKEN }} + run: | + if [ -z "$AXIOM_INGEST_TOKEN" ]; then + echo "AXIOM_INGEST_TOKEN not configured; skipping deploy marker" + exit 0 + fi + curl -sf -X POST "https://api.axiom.co/v1/datasets/executor-cloud/ingest" \ + -H "Authorization: Bearer $AXIOM_INGEST_TOKEN" \ + -H "Content-Type: application/json" \ + -d "[{\"event\":\"deploy\",\"service\":\"executor-cloud\",\"commit_sha\":\"${{ github.sha }}\",\"actor\":\"${{ github.actor }}\",\"run_id\":\"${{ github.run_id }}\"}]" \ + || echo "deploy marker ingest failed (non-blocking)" + deploy-marketing: name: Deploy marketing runs-on: blacksmith-4vcpu-ubuntu-2404 diff --git a/apps/cloud/src/auth/jwks-cache.ts b/apps/cloud/src/auth/jwks-cache.ts index d04e80187c..85961e30d3 100644 --- a/apps/cloud/src/auth/jwks-cache.ts +++ b/apps/cloud/src/auth/jwks-cache.ts @@ -48,8 +48,20 @@ export interface CachedRemoteJWKSetOptions { export interface CachedRemoteJWKSet extends JWTVerifyGetKey { /** Drop the cached JWKS so the next call refetches. */ readonly forceRefresh: () => void; - /** Inspect the current cache state (testing/diagnostics). */ - readonly inspect: () => { fetchedAt: number | null; hasJwks: boolean }; + /** + * Inspect the current cache state (testing/diagnostics/span annotation). + * `fetchCount`/`fetchFailureCount` are lifetime counters for this resolver + * instance; callers snapshot them around a verify to tell a cache hit from + * a live upstream fetch (the Aug 2026 latency regression was exactly this + * cache silently missing on ~92% of verifies, invisible in traces). + */ + readonly inspect: () => { + fetchedAt: number | null; + hasJwks: boolean; + fetchCount: number; + fetchFailureCount: number; + lastFetchDurationMs: number | null; + }; } const DEFAULT_TTL_MS = 60 * 60 * 1000; @@ -125,9 +137,14 @@ export const createCachedRemoteJWKSet = ( let entry: CacheEntry | null = null; let inflight: Promise | null = null; + let fetchCount = 0; + let fetchFailureCount = 0; + let lastFetchDurationMs: number | null = null; const refresh = (): Promise => { if (inflight) return inflight; + const startedAt = Date.now(); + fetchCount += 1; inflight = (async () => { const jwks = await fetchJwksOnce(url, fetchImpl(), timeoutMs); const next: CacheEntry = { @@ -137,9 +154,19 @@ export const createCachedRemoteJWKSet = ( }; entry = next; return next; - })().finally(() => { - inflight = null; - }); + })() + .then( + (next) => next, + (error: unknown) => { + fetchFailureCount += 1; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: counting a fetch failure must preserve the original rejection for jose + throw error; + }, + ) + .finally(() => { + lastFetchDurationMs = Date.now() - startedAt; + inflight = null; + }); return inflight; }; @@ -177,6 +204,9 @@ export const createCachedRemoteJWKSet = ( value: () => ({ fetchedAt: entry?.fetchedAt ?? null, hasJwks: entry !== null, + fetchCount, + fetchFailureCount, + lastFetchDurationMs, }), }); return result; diff --git a/apps/cloud/src/auth/workos.ts b/apps/cloud/src/auth/workos.ts index 51959784a8..4e34fd2c77 100644 --- a/apps/cloud/src/auth/workos.ts +++ b/apps/cloud/src/auth/workos.ts @@ -227,7 +227,33 @@ const verifySealedSessionLocally = ( }); if (!session) return { _tag: "InvalidCookie" }; - const verified = yield* verifyJwtWithRefreshRetry(session.accessToken, jwks); + // Snapshot the JWKS cache around the verify so the `local_verify` span + // says whether THIS verify was a warm-cache signature check or paid for a + // live upstream JWKS fetch. The Aug 2026 latency regression was the cache + // silently missing on most verifies, and no span attribute distinguished + // the two paths. + const jwksBefore = jwks.inspect(); + // Entry-state annotation goes on BEFORE the verify so a failing verify + // (the case worth debugging) still records whether the cache was warm. + yield* Effect.annotateCurrentSpan({ + "jwks.cache_populated_at_start": jwksBefore.hasJwks, + ...(jwksBefore.fetchedAt === null + ? {} + : { "jwks.cache_age_ms": Date.now() - jwksBefore.fetchedAt }), + }); + const verified = yield* verifyJwtWithRefreshRetry(session.accessToken, jwks).pipe( + Effect.onExit(() => { + const jwksAfter = jwks.inspect(); + return Effect.annotateCurrentSpan({ + "jwks.fetched_during_verify": jwksAfter.fetchCount > jwksBefore.fetchCount, + "jwks.fetch_count": jwksAfter.fetchCount, + "jwks.fetch_failure_count": jwksAfter.fetchFailureCount, + ...(jwksAfter.lastFetchDurationMs === null + ? {} + : { "jwks.last_fetch_ms": jwksAfter.lastFetchDurationMs }), + }); + }), + ); if (!verified) return { _tag: "Refresh" }; const claims = Option.getOrNull(decodeJwtClaims(decodeJwt(session.accessToken))); diff --git a/apps/cloud/src/edge/docs.ts b/apps/cloud/src/edge/docs.ts index d9f18433fa..fc59f5e3c0 100644 --- a/apps/cloud/src/edge/docs.ts +++ b/apps/cloud/src/edge/docs.ts @@ -13,10 +13,18 @@ // so this never shadows an Effect-served route. // --------------------------------------------------------------------------- +import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api"; import { createMiddleware } from "@tanstack/react-start"; const DOCS_UPSTREAM_HOST = "executor.mintlify.dev"; +// The proxy fetch gets its own client span: `/docs` requests otherwise render +// as a single opaque server span, and during the Aug 2026 regression there +// was no way to tell upstream (Mintlify/Vercel) latency from worker-side +// dispatch cost. The noop tracer applies when no provider is installed +// (local dev without AXIOM_TOKEN), so this is free there. +const tracer = trace.getTracer("executor-cloud-docs-proxy"); + export const isDocsPath = (pathname: string) => pathname === "/docs" || pathname.startsWith("/docs/"); @@ -43,6 +51,38 @@ export const buildDocsUpstream = (request: Request): Request => { export const docsProxyMiddleware = createMiddleware({ type: "request" }).server( ({ pathname, request, next }) => { if (!isDocsPath(pathname)) return next(); - return fetch(buildDocsUpstream(request)); + return tracer.startActiveSpan( + `http.client ${request.method}`, + { + kind: SpanKind.CLIENT, + attributes: { + "server.address": DOCS_UPSTREAM_HOST, + "url.path": pathname, + "http.request.method": request.method, + }, + }, + async (span) => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; observe upstream response/error for span status, then pass both through unchanged + try { + const response = await fetch(buildDocsUpstream(request)); + span.setAttribute("http.response.status_code", response.status); + if (response.status >= 500) { + span.setStatus({ code: SpanStatusCode.ERROR, message: `HTTP ${response.status}` }); + } + return response; + } catch (err) { + // oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- adapter boundary: fetch rejects untyped; normalized only for the OTel span record, the original error is rethrown below + const cause = err instanceof Error ? err : String(err); + span.recordException(cause); + // oxlint-disable-next-line executor/no-unknown-error-message -- adapter boundary: same normalization as the recordException line above + const message = typeof cause === "string" ? cause : cause.message; + span.setStatus({ code: SpanStatusCode.ERROR, message }); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; preserve the original rejection for the platform handler + throw err; + } finally { + span.end(); + } + }, + ); }, ); diff --git a/apps/cloud/src/env-augment.d.ts b/apps/cloud/src/env-augment.d.ts index 6a42130373..cb8070ae63 100644 --- a/apps/cloud/src/env-augment.d.ts +++ b/apps/cloud/src/env-augment.d.ts @@ -6,6 +6,13 @@ declare global { namespace Cloudflare { interface Env { // Observability + // Worker version metadata binding (wrangler.jsonc `version_metadata`). + // Optional so test workers and local setups without the binding still + // typecheck; spans then carry the "dev" service.version default. + CF_VERSION_METADATA?: WorkerVersionMetadata; + // Commit that produced the running deploy, passed by CI as + // `wrangler deploy --var GIT_COMMIT_SHA:$GITHUB_SHA`. Absent outside CI. + GIT_COMMIT_SHA?: string; AXIOM_TOKEN?: string; AXIOM_DATASET?: string; AXIOM_TRACES_URL?: string; diff --git a/apps/cloud/src/observability/telemetry.ts b/apps/cloud/src/observability/telemetry.ts index e71701d7f1..eb35d38695 100644 --- a/apps/cloud/src/observability/telemetry.ts +++ b/apps/cloud/src/observability/telemetry.ts @@ -54,7 +54,26 @@ import { } from "./memory-metrics"; const SERVICE_NAME = "executor-cloud"; -const SERVICE_VERSION = "1.0.0"; + +// `service.version` is the Cloudflare Worker version id (from the +// `version_metadata` binding) so any span links back to the exact deploy in a +// step-change investigation; "dev" is the documented default for hosts without +// the binding (local dev, older test workers). `executor.commit_sha` rides +// along when CI passed it (`wrangler deploy --var GIT_COMMIT_SHA:...`). +const serviceVersion = (): string => env.CF_VERSION_METADATA?.id ?? "dev"; + +// One id per isolate: distinguishes "many isolates each paying a cold cost" +// from "one isolate is slow", and makes per-isolate cache behavior (JWKS, +// module caches) measurable from Axiom. The Aug 2026 latency investigation +// stalled for lack of exactly this attribute. +const ISOLATE_INSTANCE_ID = crypto.randomUUID(); +const ISOLATE_STARTED_AT = Date.now(); + +const resourceAttributes = (): Record => ({ + "service.instance.id": ISOLATE_INSTANCE_ID, + "executor.isolate_started_at": new Date(ISOLATE_STARTED_AT).toISOString(), + ...(env.GIT_COMMIT_SHA === undefined ? {} : { "executor.commit_sha": env.GIT_COMMIT_SHA }), +}); // Module-scope: one provider per isolate, never shut down. The provider holds // the SimpleSpanProcessor + OTLP exporter, so any tracer reference captured by @@ -66,7 +85,8 @@ const ensureGlobalTracerProvider = (): boolean => { provider = new WebTracerProvider({ resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: SERVICE_NAME, - [ATTR_SERVICE_VERSION]: SERVICE_VERSION, + [ATTR_SERVICE_VERSION]: serviceVersion(), + ...resourceAttributes(), }), spanProcessors: (() => { let countingProcessor: CountingSpanProcessor; @@ -135,7 +155,11 @@ const makeTelemetryLive = (): Layer.Layer => ensureGlobalTracerProvider() ? OtelTracer.layerGlobal.pipe( Layer.provide( - Resource.layer({ serviceName: SERVICE_NAME, serviceVersion: SERVICE_VERSION }), + Resource.layer({ + serviceName: SERVICE_NAME, + serviceVersion: serviceVersion(), + attributes: resourceAttributes(), + }), ), ) : Layer.empty, diff --git a/apps/cloud/src/server.ts b/apps/cloud/src/server.ts index 93432d8a2e..4d9fe33ece 100644 --- a/apps/cloud/src/server.ts +++ b/apps/cloud/src/server.ts @@ -1,5 +1,5 @@ import { DurableObject } from "cloudflare:workers"; -import { SpanKind, SpanStatusCode, context, trace } from "@opentelemetry/api"; +import { SpanKind, SpanStatusCode, context, trace, type SpanContext } from "@opentelemetry/api"; import type { ErrorEvent } from "@sentry/cloudflare"; import { ATTR_HTTP_REQUEST_METHOD, @@ -110,6 +110,15 @@ const fetchHandler = handler.fetch as ( const tracer = trace.getTracer("executor-cloud-worker"); +const traceparentValueFor = (spanContext: SpanContext): string => + `00-${spanContext.traceId}-${spanContext.spanId}-${(spanContext.traceFlags & 0xff).toString(16).padStart(2, "0")}`; + +const withTraceparent = (request: Request, spanContext: SpanContext): Request => { + const headers = new Headers(request.headers); + headers.set("traceparent", traceparentValueFor(spanContext)); + return new Request(request, { headers }); +}; + const traceCloudMcpRequest = async ( request: Request, _env: Env, @@ -138,15 +147,9 @@ const traceCloudMcpRequest = async ( span.setAttribute(ATTR_URL_FULL, request.url); span.setAttribute(ATTR_URL_PATH, url.pathname); span.setAttribute(ATTR_URL_SCHEME, url.protocol.replace(/:$/, "")); - const spanContext = span.spanContext(); - const headers = new Headers(request.headers); - headers.set( - "traceparent", - `00-${spanContext.traceId}-${spanContext.spanId}-${(spanContext.traceFlags & 0xff).toString(16).padStart(2, "0")}`, - ); // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; observe response/error for span status, keep trace export alive after the Agents bridge resolves or rejects try { - const response = await handle(new Request(request, { headers })); + const response = await handle(withTraceparent(request, span.spanContext())); span.setAttribute(ATTR_HTTP_RESPONSE_STATUS_CODE, response.status); if (response.status >= 500) { span.setStatus({ code: SpanStatusCode.ERROR, message: `HTTP ${response.status}` }); @@ -221,16 +224,47 @@ const cloudflareHandler: ExportedHandler = { return fetchHandler(request, env, ctx); } // Effect-served paths bring their own http.server span (with traceparent - // join) — opening one here too would duplicate it. See the header note. + // join) — a second SERVER span here would duplicate it (the header note). + // What they do NOT cover is the time between this invocation starting and + // the Effect router opening its span (Start dispatch, middleware, lazy + // module graph): during the Aug 2026 regression that gap was seconds of + // invisible wall time. `worker.dispatch` is an INTERNAL parent that + // brackets the whole invocation; Effect's http.server span joins under it + // via the injected traceparent, so gap = dispatch minus server span. if (isAppOwnedPath(url.pathname)) { - // The provider is installed (above) and the flush still must outlive - // the request — Effect's BatchSpanProcessor ships on a timer. - // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; mirror the traced path's finally - try { - return await fetchHandler(request, env, ctx); - } finally { - ctx.waitUntil(flushTracerProvider()); - } + return tracer.startActiveSpan( + "worker.dispatch", + { kind: SpanKind.INTERNAL }, + parentContext, + async (span) => { + span.setAttribute(ATTR_HTTP_REQUEST_METHOD, request.method); + span.setAttribute(ATTR_URL_PATH, url.pathname); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; observe response/error for span status, keep the flush alive past the response + try { + const response = await fetchHandler( + withTraceparent(request, span.spanContext()), + env, + ctx, + ); + span.setAttribute(ATTR_HTTP_RESPONSE_STATUS_CODE, response.status); + return response; + } catch (err) { + // oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- adapter boundary: Cloudflare's fetch callback throws untyped; normalized only for the OTel span record, the original error is rethrown below + const cause = err instanceof Error ? err : String(err); + span.recordException(cause); + // oxlint-disable-next-line executor/no-unknown-error-message -- adapter boundary: same normalization as the recordException line above + const message = typeof cause === "string" ? cause : cause.message; + span.setStatus({ code: SpanStatusCode.ERROR, message }); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; preserve original error to Cloudflare runtime + throw err; + } finally { + span.end(); + // The flush still must outlive the request — Effect's + // BatchSpanProcessor ships on a timer. + ctx.waitUntil(flushTracerProvider()); + } + }, + ); } return tracer.startActiveSpan( `http.server ${request.method}`, diff --git a/apps/cloud/wrangler.jsonc b/apps/cloud/wrangler.jsonc index a38224c957..4ff645629d 100644 --- a/apps/cloud/wrangler.jsonc +++ b/apps/cloud/wrangler.jsonc @@ -102,6 +102,13 @@ "binding": "LOADER", }, ], + // Stamps spans (`service.version`) and mem-metrics snapshots with the + // running Worker version id, so an Axiom step-change maps to the exact + // deploy without Cloudflare API archaeology. memory-metrics.ts already + // reads this binding; it was never declared before, so it always fell back. + "version_metadata": { + "binding": "CF_VERSION_METADATA", + }, // DEPLOYMENT PREREQUISITE: MCP 2026-07-28 requestState is shared between // stateless Worker isolates and session DOs. Configure the same 32+ byte // secret for both with `wrangler secret put MCP_REQUEST_STATE_KEY`. @@ -111,6 +118,13 @@ // inbound serving. Set the Worker var to "false" for emergency rollback; // legacy serving remains available. "VITE_PUBLIC_SITE_URL": "https://executor.sh", + // Keeps the /__sentry-otel-verify probe live in production: Sentry + // delivery failed silently for weeks (zero events after ~Jul 30 with an + // active DSN), and without this there is no way to test the pipeline + // end-to-end short of waiting for a real error. The endpoint only fires + // when its exact path is requested and the events are explicitly tagged + // synthetic. + "SENTRY_OTEL_VERIFY": "true", "VITE_PUBLIC_POSTHOG_KEY": "phc_nNLrNMALpRsfrEkZovUkfMxYbcJvHnsJHeoSPavprgLL", // Browser OTLP spans → same-origin, forwarded to Axiom by the worker // (src/observability/browser-traces.ts). Relative on purpose: the From 5897721991f81aee68952793e3d9e056f5a96d88 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:30:33 -0700 Subject: [PATCH 050/133] Generate the isolate instance id lazily, not in global scope (#1666) --- apps/cloud/src/observability/telemetry.ts | 24 ++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/apps/cloud/src/observability/telemetry.ts b/apps/cloud/src/observability/telemetry.ts index eb35d38695..3e992a2159 100644 --- a/apps/cloud/src/observability/telemetry.ts +++ b/apps/cloud/src/observability/telemetry.ts @@ -66,14 +66,24 @@ const serviceVersion = (): string => env.CF_VERSION_METADATA?.id ?? "dev"; // from "one isolate is slow", and makes per-isolate cache behavior (JWKS, // module caches) measurable from Axiom. The Aug 2026 latency investigation // stalled for lack of exactly this attribute. -const ISOLATE_INSTANCE_ID = crypto.randomUUID(); -const ISOLATE_STARTED_AT = Date.now(); +// +// Generated LAZILY on first use, not at module scope: workerd forbids random +// generation (and I/O) in global scope and Cloudflare's upload validation +// rejects the whole deploy for it (error 10021). First use is inside +// `installTracerProvider()` / the telemetry layer build, which both run in a +// request handler, so the id is still one-per-isolate. +let isolateInstanceId: string | null = null; +let isolateStartedAt: number | null = null; -const resourceAttributes = (): Record => ({ - "service.instance.id": ISOLATE_INSTANCE_ID, - "executor.isolate_started_at": new Date(ISOLATE_STARTED_AT).toISOString(), - ...(env.GIT_COMMIT_SHA === undefined ? {} : { "executor.commit_sha": env.GIT_COMMIT_SHA }), -}); +const resourceAttributes = (): Record => { + isolateInstanceId ??= crypto.randomUUID(); + isolateStartedAt ??= Date.now(); + return { + "service.instance.id": isolateInstanceId, + "executor.isolate_started_at": new Date(isolateStartedAt).toISOString(), + ...(env.GIT_COMMIT_SHA === undefined ? {} : { "executor.commit_sha": env.GIT_COMMIT_SHA }), + }; +}; // Module-scope: one provider per isolate, never shut down. The provider holds // the SimpleSpanProcessor + OTLP exporter, so any tracer reference captured by From 81d4cf9d20a4102ace27bab11c611d6590602ccb Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:16:45 -0700 Subject: [PATCH 051/133] Cache JWKS across isolates and serve stale keys on refresh failure (#1665) * Cache JWKS across isolates and serve stale keys on refresh failure The module-scope JWKS cache only helps while an isolate stays warm. When workos.session.local_verify started missing ~92% of the time, every miss paid a fresh upstream fetch (p50 3s) and the tail crossed the 5s timeout, so a slow key server surfaced as AbortError -> 500 on authenticated API routes and 503 on /mcp. Add a cross-isolate store (Workers Cache API by default, injectable, null to disable) so a cold isolate reads keys colo-locally instead of going upstream, and make the resolver stale-while-revalidate: past ttlMs a usable key set is served immediately and refreshed in the background, and a failed refresh keeps serving the last good keys until staleMaxMs rather than failing the verify. Only a fully cold path blocks on the network. Bounded by staleMaxMs (24h) and unchanged forced-refresh on unknown kid, so a retired key cannot be honoured indefinitely. * Keep JWKS latency attribution honest under stale-while-revalidate Background revalidation moves fetchCount without the caller waiting on it, so jwks.fetched_during_verify would report true for verifies that paid nothing. Track blockingFetchCount (fetches a verify awaited) and storeHitCount (cold-isolate reads served by the cross-isolate store), and attribute the span off the blocking counter. --- apps/cloud/src/auth/jwks-cache.node.test.ts | 183 +++++++++++++++- apps/cloud/src/auth/jwks-cache.ts | 227 ++++++++++++++++++-- apps/cloud/src/auth/workos.ts | 8 +- 3 files changed, 396 insertions(+), 22 deletions(-) diff --git a/apps/cloud/src/auth/jwks-cache.node.test.ts b/apps/cloud/src/auth/jwks-cache.node.test.ts index d1f2b47c4b..f4f3e08a64 100644 --- a/apps/cloud/src/auth/jwks-cache.node.test.ts +++ b/apps/cloud/src/auth/jwks-cache.node.test.ts @@ -9,7 +9,7 @@ import { type KeyLike, } from "jose"; -import { createCachedRemoteJWKSet } from "./jwks-cache"; +import { createCachedRemoteJWKSet, type JwksStore, type StoredJwks } from "./jwks-cache"; const issuer = "https://test-authkit.example.com"; const audience = "client_test_fixture"; @@ -65,6 +65,40 @@ const makeFetchHarness = (initialKeys: ReadonlyArray): FetchHarness => { }; }; +interface StoreHarness extends JwksStore { + readonly seed: (stored: StoredJwks) => void; + readonly reads: () => number; + readonly writes: () => number; +} + +/** Stands in for the Workers Cache API: shared across "isolates", in memory. */ +const makeStoreHarness = (): StoreHarness => { + const entries = new Map(); + let reads = 0; + let writes = 0; + return { + get: async (url) => { + reads++; + return entries.get(url.toString()) ?? null; + }, + put: async (url, stored) => { + writes++; + entries.set(url.toString(), stored); + }, + seed: (stored) => { + entries.set(jwksUrl.toString(), stored); + }, + reads: () => reads, + writes: () => writes, + }; +}; + +/** A fetch that always fails, standing in for a slow/down key server. */ +const failingFetch: typeof globalThis.fetch = async () => { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- test double: `fetch` signals an unreachable key server by rejecting + throw new Error("JWKS endpoint unreachable"); +}; + describe("createCachedRemoteJWKSet", () => { it("FAILING-WITHOUT-CACHE: N verifications hit JWKS endpoint only once within TTL", async () => { const kp = await generateRotatableKeypair("k1"); @@ -168,4 +202,151 @@ describe("createCachedRemoteJWKSet", () => { await jwtVerify(t1, jwks, { issuer, audience }); expect(harness.callCount()).toBe(2); }); + + // ------------------------------------------------------------------------- + // Cross-isolate store — the cold-isolate path that caused the 2026-08-18 + // 500s: no module-scope entry, so every verify paid an upstream fetch. + // ------------------------------------------------------------------------- + + it("a cold isolate serves from the store instead of going upstream", async () => { + const kp = await generateRotatableKeypair("k1"); + const store = makeStoreHarness(); + + // First isolate: cold everywhere, so it fetches and populates the store. + const warm = makeFetchHarness([kp.publicJwk]); + const first = createCachedRemoteJWKSet(jwksUrl, { fetch: warm.fetch, store }); + const t1 = await sign(kp); + await jwtVerify(t1, first, { issuer, audience }); + expect(warm.callCount()).toBe(1); + expect(store.writes()).toBe(1); + + // A brand-new isolate (fresh module scope). If it reaches upstream at all + // the fetch fails, so verifying proves the store answered. + const second = createCachedRemoteJWKSet(jwksUrl, { fetch: failingFetch, store }); + const { payload } = await jwtVerify(t1, second, { issuer, audience }); + expect(payload.sub).toBe("user_test"); + expect(store.reads()).toBeGreaterThan(0); + }); + + it("keeps serving the last good keys when the key server is down", async () => { + const kp = await generateRotatableKeypair("k1"); + const harness = makeFetchHarness([kp.publicJwk]); + let upstreamUp = true; + const flaky: typeof globalThis.fetch = (...args) => + upstreamUp ? harness.fetch(...args) : failingFetch(...args); + + // ttl short enough that the next call is past it, stale window generous. + const jwks = createCachedRemoteJWKSet(jwksUrl, { + fetch: flaky, + store: null, + ttlMs: 10, + staleMaxMs: 60_000, + }); + + const token = await sign(kp); + await jwtVerify(token, jwks, { issuer, audience }); + + upstreamUp = false; + await new Promise((r) => setTimeout(r, 20)); + + // Past the TTL with a dead upstream: the cached keys still verify. + const { payload } = await jwtVerify(token, jwks, { issuer, audience }); + expect(payload.sub).toBe("user_test"); + }); + + it("stops serving stale keys once the stale window closes", async () => { + const kp = await generateRotatableKeypair("k1"); + const harness = makeFetchHarness([kp.publicJwk]); + let upstreamUp = true; + const flaky: typeof globalThis.fetch = (...args) => + upstreamUp ? harness.fetch(...args) : failingFetch(...args); + + const jwks = createCachedRemoteJWKSet(jwksUrl, { + fetch: flaky, + store: null, + ttlMs: 5, + staleMaxMs: 10, + }); + + const token = await sign(kp); + await jwtVerify(token, jwks, { issuer, audience }); + + upstreamUp = false; + await new Promise((r) => setTimeout(r, 30)); + + // Beyond staleMaxMs the keys are no longer trustworthy — fail, don't + // silently honour a key set we can no longer confirm. + await expect(jwtVerify(token, jwks, { issuer, audience })).rejects.toThrow(); + }); + + it("attributes background revalidation to fetchCount but not blockingFetchCount", async () => { + const kp = await generateRotatableKeypair("k1"); + const harness = makeFetchHarness([kp.publicJwk]); + const jwks = createCachedRemoteJWKSet(jwksUrl, { + fetch: harness.fetch, + store: null, + ttlMs: 10, + staleMaxMs: 60_000, + }); + + const token = await sign(kp); + await jwtVerify(token, jwks, { issuer, audience }); + expect(jwks.inspect().blockingFetchCount).toBe(1); + + await new Promise((r) => setTimeout(r, 20)); + + // Past the TTL: served from the stale entry, revalidated behind it. The + // caller waited on nothing, so latency must not be attributed to it. + const before = jwks.inspect(); + await jwtVerify(token, jwks, { issuer, audience }); + const after = jwks.inspect(); + + expect(after.blockingFetchCount).toBe(before.blockingFetchCount); + expect(after.fetchCount).toBeGreaterThan(before.fetchCount); + }); + + it("records a cold-isolate store read as a store hit, not a fetch", async () => { + const kp = await generateRotatableKeypair("k1"); + const store = makeStoreHarness(); + const warm = makeFetchHarness([kp.publicJwk]); + + const first = createCachedRemoteJWKSet(jwksUrl, { fetch: warm.fetch, store }); + const token = await sign(kp); + await jwtVerify(token, first, { issuer, audience }); + + const second = createCachedRemoteJWKSet(jwksUrl, { fetch: failingFetch, store }); + await jwtVerify(token, second, { issuer, audience }); + + const stats = second.inspect(); + expect(stats.storeHitCount).toBe(1); + expect(stats.blockingFetchCount).toBe(0); + }); + + it("does not block a request on revalidation once keys are cached", async () => { + const kp = await generateRotatableKeypair("k1"); + const harness = makeFetchHarness([kp.publicJwk]); + let hang = false; + const slowAfterFirst: typeof globalThis.fetch = async (...args) => { + if (hang) await new Promise((r) => setTimeout(r, 5_000)); + return harness.fetch(...args); + }; + + const jwks = createCachedRemoteJWKSet(jwksUrl, { + fetch: slowAfterFirst, + store: null, + ttlMs: 10, + staleMaxMs: 60_000, + }); + + const token = await sign(kp); + await jwtVerify(token, jwks, { issuer, audience }); + + hang = true; + await new Promise((r) => setTimeout(r, 20)); + + // The revalidation behind this call hangs for 5s; the verify must not. + const startedAt = Date.now(); + await jwtVerify(token, jwks, { issuer, audience }); + expect(Date.now() - startedAt).toBeLessThan(1_000); + }); }); diff --git a/apps/cloud/src/auth/jwks-cache.ts b/apps/cloud/src/auth/jwks-cache.ts index 85961e30d3..92c40c1c2a 100644 --- a/apps/cloud/src/auth/jwks-cache.ts +++ b/apps/cloud/src/auth/jwks-cache.ts @@ -1,5 +1,5 @@ // --------------------------------------------------------------------------- -// In-memory JWKS cache for MCP JWT verification. +// JWKS cache for JWT verification (session + MCP auth). // --------------------------------------------------------------------------- // // Cloudflare Workers boot many short-lived isolates. `createRemoteJWKSet`'s @@ -7,14 +7,32 @@ // fetches per hour because each new isolate starts cold. Production p99 for // `mcp.auth.jwt_verify` was 1.7s — almost entirely the JWKS fetch. // -// This module offers a drop-in `createCachedRemoteJWKSet` that: +// Module-scope memory alone only helps while an isolate stays warm. When +// `workos.session.local_verify` started missing ~92% of the time (2026-08-18), +// every miss paid a fresh upstream fetch — p50 3s, and the tail crossed the 5s +// request timeout, turning a slow key-server into `AbortError` → 500 on every +// authenticated API route and 503 on `/mcp`. A transient key-server blip must +// never read as an auth failure, and a cold isolate must not have to go +// upstream at all. // -// * Caches the JSON Web Key Set in module-scope memory for a configurable -// TTL (default 1 hour). -// * Single-flights concurrent fetches so a stampede of verifies during a -// cache miss only fires one upstream request. -// * Force-refreshes once when verification fails with a cached key, so -// genuine key rotation isn't blocked by the TTL. +// So this module layers three caches, fastest first: +// +// 1. Module-scope memory — free, but dies with the isolate. +// 2. A cross-isolate store (the Workers Cache API by default) — colo-local, +// survives isolate recycling, so a cold isolate reads keys in ~1ms +// instead of paying an upstream round trip. +// 3. The upstream JWKS endpoint. +// +// On top of that it is stale-while-revalidate: once past `ttlMs` a usable key +// set is served immediately and refreshed in the background, and if a refresh +// fails we keep serving the last good keys until `staleMaxMs`. Only a fully +// cold path (no memory, no store) ever blocks on the network. +// +// Serving stale keys is safe in a way that serving a stale *token* would not +// be: key sets rotate on the order of days, tokens are still signature- and +// expiry-checked against them, and a token whose `kid` is absent forces a real +// refresh before it is rejected (see `get`). `staleMaxMs` bounds how long a +// retired key can still be honoured. // // The returned function is a `JWTVerifyGetKey` and slots directly into // `jose.jwtVerify`. It also exposes `forceRefresh()` so the verify path can @@ -32,6 +50,21 @@ import { import { Schema } from "effect"; import { JWKSNoMatchingKey } from "jose/errors"; +/** + * A cross-isolate key-set store. Defaults to the Workers Cache API; tests and + * non-Workers hosts pass their own, or `null` to stay memory-only. + */ +export interface JwksStore { + readonly get: (url: URL) => Promise; + readonly put: (url: URL, stored: StoredJwks) => Promise; +} + +export interface StoredJwks { + readonly jwks: JSONWebKeySet; + /** Epoch ms of the upstream fetch these keys came from. */ + readonly fetchedAt: number; +} + export interface CachedRemoteJWKSetOptions { /** * How long a successful fetch is considered fresh. Defaults to 1 hour — @@ -39,10 +72,21 @@ export interface CachedRemoteJWKSetOptions { * failure handles unscheduled rotations. */ readonly ttlMs?: number; + /** + * How long past `ttlMs` a key set may still be served while refreshes are + * failing. Defaults to 24h — long enough to ride out a key-server outage, + * short enough to bound how long a retired key stays honoured. + */ + readonly staleMaxMs?: number; /** Override the fetch implementation for tests. */ readonly fetch?: typeof globalThis.fetch; /** HTTP request timeout. Defaults to 5s, matching jose. */ readonly timeoutMs?: number; + /** + * Cross-isolate store. Defaults to the Workers Cache API when available, + * `null` disables the layer (memory-only). + */ + readonly store?: JwksStore | null; } export interface CachedRemoteJWKSet extends JWTVerifyGetKey { @@ -54,17 +98,26 @@ export interface CachedRemoteJWKSet extends JWTVerifyGetKey { * instance; callers snapshot them around a verify to tell a cache hit from * a live upstream fetch (the Aug 2026 latency regression was exactly this * cache silently missing on ~92% of verifies, invisible in traces). + * + * `blockingFetchCount` counts only fetches a verify actually WAITED on. + * Under stale-while-revalidate `fetchCount` also moves for background + * revalidation the caller never paid for, so latency attribution wants + * `blockingFetchCount`. `storeHitCount` counts cold-isolate reads answered + * by the cross-isolate store instead of going upstream. */ readonly inspect: () => { fetchedAt: number | null; hasJwks: boolean; fetchCount: number; fetchFailureCount: number; + blockingFetchCount: number; + storeHitCount: number; lastFetchDurationMs: number | null; }; } const DEFAULT_TTL_MS = 60 * 60 * 1000; +const DEFAULT_STALE_MAX_MS = 24 * 60 * 60 * 1000; const DEFAULT_TIMEOUT_MS = 5000; const JsonWebKey = Schema.Record(Schema.String, Schema.Unknown); @@ -85,6 +138,26 @@ interface CacheEntry { resolver: (protectedHeader: JWTHeaderParameters, token?: FlattenedJWSInput) => Promise; } +const entryFrom = (stored: StoredJwks): CacheEntry => ({ + jwks: stored.jwks, + fetchedAt: stored.fetchedAt, + resolver: createLocalJWKSet(stored.jwks), +}); + +/** + * Cache upkeep (store writes, background revalidation) is best effort: it must + * never fail or delay the verify that happened to trigger it. + */ +const ignoreFailure = async (work: Promise): Promise => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: best-effort cache upkeep must not surface as a verify failure + try { + await work; + } catch { + // Deliberately swallowed — the caller already has usable keys, or will + // take the upstream path on its next miss. + } +}; + const fetchJwksOnce = async ( url: URL, fetchImpl: typeof globalThis.fetch, @@ -119,17 +192,74 @@ const fetchJwksOnce = async ( } }; +// --------------------------------------------------------------------------- +// Workers Cache API store +// --------------------------------------------------------------------------- +// +// Keyed by the JWKS URL itself. We store our own Response rather than the +// upstream one so the body is already-validated JSON and the cache headers are +// ours: `max-age` covers the stale window, because an entry past `ttlMs` is +// still useful to us as a stale fallback. + +const STORE_HEADER_FETCHED_AT = "x-jwks-fetched-at"; + +/** `caches.default` is a Workers extension the standard lib type omits. */ +type WorkersCacheStorage = CacheStorage & { readonly default?: Cache }; + +const workersCacheStore = (staleMaxMs: number): JwksStore | null => { + if (typeof caches === "undefined") return null; + const open = (): Cache | null => (caches as WorkersCacheStorage).default ?? null; + + return { + get: async (url) => { + const cache = open(); + if (!cache) return null; + const hit = await cache.match(url.toString()); + if (!hit) return null; + const fetchedAtHeader = hit.headers.get(STORE_HEADER_FETCHED_AT); + const fetchedAt = fetchedAtHeader === null ? Number.NaN : Number(fetchedAtHeader); + if (!Number.isFinite(fetchedAt)) return null; + const body = await hit.json(); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: a corrupt cache entry must degrade to a miss, never fail the verify + try { + await decodeJsonWebKeySetPayload(body); + } catch { + return null; + } + return { jwks: body as JSONWebKeySet, fetchedAt }; + }, + put: async (url, stored) => { + const cache = open(); + if (!cache) return; + const maxAgeSeconds = Math.max(1, Math.floor(staleMaxMs / 1000)); + await cache.put( + url.toString(), + new Response(JSON.stringify(stored.jwks), { + status: 200, + headers: { + "content-type": "application/json", + "cache-control": `max-age=${maxAgeSeconds}`, + [STORE_HEADER_FETCHED_AT]: String(stored.fetchedAt), + }, + }), + ); + }, + }; +}; + /** * Creates a cached, single-flight, force-refreshable JWKS resolver compatible * with `jose.jwtVerify`. Drop-in replacement for `createRemoteJWKSet` for the - * MCP auth path — see module header for why we don't just use jose's built-in. + * auth paths — see module header for why we don't just use jose's built-in. */ export const createCachedRemoteJWKSet = ( url: URL, options: CachedRemoteJWKSetOptions = {}, ): CachedRemoteJWKSet => { const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS; + const staleMaxMs = Math.max(options.staleMaxMs ?? DEFAULT_STALE_MAX_MS, ttlMs); const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const store = options.store === undefined ? workersCacheStore(staleMaxMs) : options.store; // Capture the fetch impl lazily so consumers can swap globalThis.fetch // (tests do this) without us snapshotting a stale reference. const fetchImpl = (): typeof globalThis.fetch => @@ -139,20 +269,25 @@ export const createCachedRemoteJWKSet = ( let inflight: Promise | null = null; let fetchCount = 0; let fetchFailureCount = 0; + let blockingFetchCount = 0; + let storeHitCount = 0; let lastFetchDurationMs: number | null = null; + const isFresh = (candidate: CacheEntry): boolean => Date.now() - candidate.fetchedAt < ttlMs; + const isUsable = (candidate: CacheEntry): boolean => + Date.now() - candidate.fetchedAt < staleMaxMs; + const refresh = (): Promise => { if (inflight) return inflight; const startedAt = Date.now(); fetchCount += 1; inflight = (async () => { const jwks = await fetchJwksOnce(url, fetchImpl(), timeoutMs); - const next: CacheEntry = { - jwks, - fetchedAt: Date.now(), - resolver: createLocalJWKSet(jwks), - }; + const next = entryFrom({ jwks, fetchedAt: Date.now() }); entry = next; + if (store) { + await ignoreFailure(store.put(url, { jwks: next.jwks, fetchedAt: next.fetchedAt })); + } return next; })() .then( @@ -170,21 +305,71 @@ export const createCachedRemoteJWKSet = ( return inflight; }; - const ensureFresh = async (forceRefresh: boolean): Promise => { - if (forceRefresh) return refresh(); - if (entry && Date.now() - entry.fetchedAt < ttlMs) return entry; + /** Fire-and-forget revalidation behind a stale hit. */ + const refreshInBackground = (): void => { + void ignoreFailure(refresh()); + }; + + const loadFromStore = async (): Promise => { + if (!store) return null; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: the L2 store is an optimization; any failure degrades to an upstream fetch + try { + const stored = await store.get(url); + if (!stored) return null; + const candidate = entryFrom(stored); + if (!isUsable(candidate)) return null; + entry = candidate; + storeHitCount += 1; + return candidate; + } catch { + return null; + } + }; + + /** A refresh the caller waits on — the only kind that costs it latency. */ + const refreshBlocking = (): Promise => { + blockingFetchCount += 1; return refresh(); }; + const ensureFresh = async (forceRefresh: boolean): Promise => { + if (forceRefresh) return refreshBlocking(); + if (entry && isFresh(entry)) return entry; + + // Memory is stale but still usable: serve it now, revalidate behind it. + if (entry && isUsable(entry)) { + refreshInBackground(); + return entry; + } + + // Cold isolate — the L2 store saves us the upstream round trip. + const stored = await loadFromStore(); + if (stored) { + if (!isFresh(stored)) refreshInBackground(); + return stored; + } + + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: a failed refresh must fall back to stale keys rather than fail the verify + try { + return await refreshBlocking(); + } catch (error) { + // Upstream is slow or down. Last good keys beat failing every request. + if (entry && isUsable(entry)) return entry; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: nothing usable is cached, so the upstream failure is the real answer + throw error; + } + }; + const get: JWTVerifyGetKey = async (protectedHeader, token) => { const current = await ensureFresh(false); // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: jose JWTVerifyGetKey retry path is defined by thrown resolver failures try { return await current.resolver(protectedHeader, token); } catch (error) { - // Likely cause: keys rotated upstream after our TTL window started. - // Refetch once and try again. Anything still failing bubbles up so - // jose can classify it (we do not silently swallow real failures). + // Likely cause: keys rotated upstream after our TTL window started, or + // we answered from a stale entry. Refetch once and try again. Anything + // still failing bubbles up so jose can classify it (we do not silently + // swallow real failures). if (!isJwksNoMatchingKey(error)) { // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: jose JWTVerifyGetKey requires preserving upstream resolver rejection throw error; @@ -206,6 +391,8 @@ export const createCachedRemoteJWKSet = ( hasJwks: entry !== null, fetchCount, fetchFailureCount, + blockingFetchCount, + storeHitCount, lastFetchDurationMs, }), }); diff --git a/apps/cloud/src/auth/workos.ts b/apps/cloud/src/auth/workos.ts index 4e34fd2c77..7bbbbae633 100644 --- a/apps/cloud/src/auth/workos.ts +++ b/apps/cloud/src/auth/workos.ts @@ -245,8 +245,14 @@ const verifySealedSessionLocally = ( Effect.onExit(() => { const jwksAfter = jwks.inspect(); return Effect.annotateCurrentSpan({ - "jwks.fetched_during_verify": jwksAfter.fetchCount > jwksBefore.fetchCount, + // Blocking, not total: under stale-while-revalidate a background + // refresh moves `fetchCount` without costing this verify anything. + // Attribute latency to what the caller actually waited on. + "jwks.fetched_during_verify": + jwksAfter.blockingFetchCount > jwksBefore.blockingFetchCount, + "jwks.served_from_store": jwksAfter.storeHitCount > jwksBefore.storeHitCount, "jwks.fetch_count": jwksAfter.fetchCount, + "jwks.blocking_fetch_count": jwksAfter.blockingFetchCount, "jwks.fetch_failure_count": jwksAfter.fetchFailureCount, ...(jwksAfter.lastFetchDurationMs === null ? {} From e27931f265af7437eca279c5088931e89be0aec9 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:47:52 -0700 Subject: [PATCH 052/133] Attribute local_verify latency to a phase (#1667) local_verify is a leaf span in production, so a ~3.3s verify has nothing under it to blame. Eliminating the JWKS fetch entirely (fetch_count 0, served_from_store true) left the 3.3s intact, and it is not cold start either: isolates with >10 spans show the same p50 3262ms. Record per-phase timings (unseal, decode, jwt) plus child spans. Under workerd Date.now() only advances at I/O boundaries, which is what makes the raw span duration misleading, so the explicit per-phase numbers say which await the wall-clock actually crossed. --- apps/cloud/src/auth/workos.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/apps/cloud/src/auth/workos.ts b/apps/cloud/src/auth/workos.ts index 7bbbbae633..aeb176f272 100644 --- a/apps/cloud/src/auth/workos.ts +++ b/apps/cloud/src/auth/workos.ts @@ -213,18 +213,33 @@ const verifySealedSessionLocally = ( jwks: CachedRemoteJWKSet, ): Effect.Effect => Effect.gen(function* () { + // Phase timings, not just child spans. `local_verify` is a leaf in + // production traces, so a ~3.3s verify has nothing under it to blame — + // and it stayed 3.3s after the JWKS fetch was eliminated entirely + // (jwks.fetch_count == 0), so the cost is one of the phases below. Under + // workerd `Date.now()` only advances at I/O boundaries, which is exactly + // what makes a raw span duration misleading here: recording each phase + // explicitly says which await the wall-clock actually crossed. + const verifyStartedAt = Date.now(); + + const unsealStartedAt = Date.now(); const unsealed = yield* Effect.tryPromise({ try: () => unsealWorkOSSession(sessionData, cookiePassword), catch: (cause) => new LocalSessionCookieError({ cause }), }).pipe( Effect.catchTag("LocalSessionCookieError", () => Effect.succeed(null as unknown | null)), + Effect.withSpan("workos.session.unseal"), ); + const unsealMs = Date.now() - unsealStartedAt; + yield* Effect.annotateCurrentSpan({ "verify.unseal_ms": unsealMs }); if (!unsealed) return { _tag: "InvalidCookie" }; + const decodeStartedAt = Date.now(); const session = Option.match(decodeSealedSessionPayload(unsealed), { onNone: (): SealedSessionPayload | null => null, onSome: (payload) => payload, }); + yield* Effect.annotateCurrentSpan({ "verify.decode_ms": Date.now() - decodeStartedAt }); if (!session) return { _tag: "InvalidCookie" }; // Snapshot the JWKS cache around the verify so the `local_verify` span @@ -241,10 +256,15 @@ const verifySealedSessionLocally = ( ? {} : { "jwks.cache_age_ms": Date.now() - jwksBefore.fetchedAt }), }); + const jwtStartedAt = Date.now(); const verified = yield* verifyJwtWithRefreshRetry(session.accessToken, jwks).pipe( + Effect.withSpan("workos.session.jwt_verify"), Effect.onExit(() => { const jwksAfter = jwks.inspect(); + const finishedAt = Date.now(); return Effect.annotateCurrentSpan({ + "verify.jwt_ms": finishedAt - jwtStartedAt, + "verify.total_ms": finishedAt - verifyStartedAt, // Blocking, not total: under stale-while-revalidate a background // refresh moves `fetchCount` without costing this verify anything. // Attribute latency to what the caller actually waited on. From c0d25e4bad95ad556f5d1b299edb198fef885858 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:59:16 -0700 Subject: [PATCH 053/133] Split the jwt_verify cost into store read vs key import (#1668) jwt_verify carries the whole ~3.4s of local_verify while unseal and decode are 0ms, and it does zero upstream fetches. The two remaining candidates inside it are the cross-isolate store read (I/O) and the WebCrypto key import in the resolver. Time both. --- apps/cloud/src/auth/jwks-cache.ts | 15 ++++++++++++++- apps/cloud/src/auth/workos.ts | 9 +++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/apps/cloud/src/auth/jwks-cache.ts b/apps/cloud/src/auth/jwks-cache.ts index 92c40c1c2a..3cd1d963d1 100644 --- a/apps/cloud/src/auth/jwks-cache.ts +++ b/apps/cloud/src/auth/jwks-cache.ts @@ -113,6 +113,10 @@ export interface CachedRemoteJWKSet extends JWTVerifyGetKey { blockingFetchCount: number; storeHitCount: number; lastFetchDurationMs: number | null; + /** Wall-clock of the last cross-isolate store read (I/O). */ + lastStoreReadMs: number | null; + /** Wall-clock of the last key resolution — WebCrypto `importKey`. */ + lastResolveMs: number | null; }; } @@ -272,6 +276,8 @@ export const createCachedRemoteJWKSet = ( let blockingFetchCount = 0; let storeHitCount = 0; let lastFetchDurationMs: number | null = null; + let lastStoreReadMs: number | null = null; + let lastResolveMs: number | null = null; const isFresh = (candidate: CacheEntry): boolean => Date.now() - candidate.fetchedAt < ttlMs; const isUsable = (candidate: CacheEntry): boolean => @@ -314,7 +320,9 @@ export const createCachedRemoteJWKSet = ( if (!store) return null; // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: the L2 store is an optimization; any failure degrades to an upstream fetch try { + const storeReadStartedAt = Date.now(); const stored = await store.get(url); + lastStoreReadMs = Date.now() - storeReadStartedAt; if (!stored) return null; const candidate = entryFrom(stored); if (!isUsable(candidate)) return null; @@ -364,7 +372,10 @@ export const createCachedRemoteJWKSet = ( const current = await ensureFresh(false); // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: jose JWTVerifyGetKey retry path is defined by thrown resolver failures try { - return await current.resolver(protectedHeader, token); + const resolveStartedAt = Date.now(); + const key = await current.resolver(protectedHeader, token); + lastResolveMs = Date.now() - resolveStartedAt; + return key; } catch (error) { // Likely cause: keys rotated upstream after our TTL window started, or // we answered from a stale entry. Refetch once and try again. Anything @@ -394,6 +405,8 @@ export const createCachedRemoteJWKSet = ( blockingFetchCount, storeHitCount, lastFetchDurationMs, + lastStoreReadMs, + lastResolveMs, }), }); return result; diff --git a/apps/cloud/src/auth/workos.ts b/apps/cloud/src/auth/workos.ts index aeb176f272..bf9130e8ab 100644 --- a/apps/cloud/src/auth/workos.ts +++ b/apps/cloud/src/auth/workos.ts @@ -277,6 +277,15 @@ const verifySealedSessionLocally = ( ...(jwksAfter.lastFetchDurationMs === null ? {} : { "jwks.last_fetch_ms": jwksAfter.lastFetchDurationMs }), + // Splits the ~3.4s that sits inside jwt_verify with zero upstream + // fetches: the cross-isolate store read (I/O) vs WebCrypto key + // import vs the signature check itself. + ...(jwksAfter.lastStoreReadMs === null + ? {} + : { "jwks.store_read_ms": jwksAfter.lastStoreReadMs }), + ...(jwksAfter.lastResolveMs === null + ? {} + : { "jwks.key_resolve_ms": jwksAfter.lastResolveMs }), }); }), ); From 46cea2cbb1f414ae58ac876819a51b11967909a6 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:00:06 -0700 Subject: [PATCH 054/133] Add Gmail settings access --- .changeset/gmail-settings-access.md | 5 + apps/marketing/src/pages/google-oauth.astro | 4 +- .../src/pages/google-workspace.astro | 2 +- e2e/scenarios/first-party-oauth.test.ts | 17 ++- .../src/providers/google/discovery.test.ts | 114 +++++++++++++++++- .../openapi/src/providers/google/discovery.ts | 11 +- .../src/providers/google/oauth-scopes.test.ts | 11 +- .../src/providers/google/oauth-scopes.ts | 35 ++++-- .../src/providers/google/presets.test.ts | 6 +- .../openapi/src/providers/google/presets.ts | 5 +- .../google/spec-format-adapter.test.ts | 30 ++++- 11 files changed, 212 insertions(+), 28 deletions(-) create mode 100644 .changeset/gmail-settings-access.md diff --git a/.changeset/gmail-settings-access.md b/.changeset/gmail-settings-access.md new file mode 100644 index 0000000000..8fe9f8e43a --- /dev/null +++ b/.changeset/gmail-settings-access.md @@ -0,0 +1,5 @@ +--- +"@executor-js/plugin-openapi": patch +--- + +Request Gmail's basic-settings scope alongside full mailbox access so Google integrations can create and manage Gmail filters without including domain-admin-only sharing settings. diff --git a/apps/marketing/src/pages/google-oauth.astro b/apps/marketing/src/pages/google-oauth.astro index a92c8a2e9f..06374a1bfe 100644 --- a/apps/marketing/src/pages/google-oauth.astro +++ b/apps/marketing/src/pages/google-oauth.astro @@ -13,8 +13,8 @@ const googleServices = [ index: "02", name: "Gmail", purpose: - "Executor can read, search, compose, send, organize, trash, and permanently delete messages only when you explicitly instruct an agent to work with your email.", - scope: "mail.google.com", + "Executor can read, search, compose, send, organize, trash, and permanently delete messages, and manage filters and other basic Gmail settings, only when you explicitly instruct an agent to work with your email.", + scope: "mail.google.com · gmail.settings.basic", }, { index: "03", diff --git a/apps/marketing/src/pages/google-workspace.astro b/apps/marketing/src/pages/google-workspace.astro index c6a6714949..e784b0ed7a 100644 --- a/apps/marketing/src/pages/google-workspace.astro +++ b/apps/marketing/src/pages/google-workspace.astro @@ -8,7 +8,7 @@ const services = [ { name: "Gmail", description: - "Read, search, compose, send, label, archive, trash, or permanently delete messages when you explicitly ask an agent to work with your email.", + "Read, search, compose, send, label, archive, trash, or permanently delete messages, and manage filters and other basic Gmail settings, when you explicitly ask an agent to work with your email.", }, { name: "Google Sheets", diff --git a/e2e/scenarios/first-party-oauth.test.ts b/e2e/scenarios/first-party-oauth.test.ts index 7b9e93a868..c682c848b2 100644 --- a/e2e/scenarios/first-party-oauth.test.ts +++ b/e2e/scenarios/first-party-oauth.test.ts @@ -223,6 +223,12 @@ scenario( ); expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/gmail.modify"); expect(google.origin.allowedScopes).toContain("https://mail.google.com/"); + expect(google.origin.allowedScopes).toContain( + "https://www.googleapis.com/auth/gmail.settings.basic", + ); + expect(google.origin.allowedScopes).not.toContain( + "https://www.googleapis.com/auth/gmail.settings.sharing", + ); expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/spreadsheets"); expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/drive"); expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/documents"); @@ -332,6 +338,7 @@ scenario( "email", "profile", "https://mail.google.com/", + "https://www.googleapis.com/auth/gmail.settings.basic", ]), slug: fullGmail, }, @@ -351,7 +358,15 @@ scenario( fullGmailStarted.status === "redirect" ? fullGmailStarted.authorizationUrl : ""; expect( new Set(new URL(fullGmailAuthorizationUrl).searchParams.get("scope")?.split(" ") ?? []), - ).toEqual(new Set(["openid", "email", "profile", "https://mail.google.com/"])); + ).toEqual( + new Set([ + "openid", + "email", + "profile", + "https://mail.google.com/", + "https://www.googleapis.com/auth/gmail.settings.basic", + ]), + ); const drive = IntegrationSlug.make(unique("google_drive")); yield* client.openapi.addSpec({ diff --git a/packages/plugins/openapi/src/providers/google/discovery.test.ts b/packages/plugins/openapi/src/providers/google/discovery.test.ts index 5f4f2829a9..8e288a9bf0 100644 --- a/packages/plugins/openapi/src/providers/google/discovery.test.ts +++ b/packages/plugins/openapi/src/providers/google/discovery.test.ts @@ -1031,9 +1031,106 @@ it.effect("filters Gmail operations to the explicitly selected consent scope", ( }), ); +it.effect("keeps consumer Gmail settings tools alongside full mailbox access", () => + Effect.gen(function* () { + const fullScope = "https://mail.google.com/"; + const settingsBasicScope = "https://www.googleapis.com/auth/gmail.settings.basic"; + const settingsSharingScope = "https://www.googleapis.com/auth/gmail.settings.sharing"; + const result = yield* convertGoogleDiscoveryBundleToOpenApi({ + consentScopes: [fullScope, settingsBasicScope], + documents: [ + { + discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest", + // @effect-diagnostics-next-line preferSchemaOverJson:off + documentText: JSON.stringify({ + name: "gmail", + version: "v1", + title: "Gmail API", + rootUrl: "https://gmail.googleapis.com/", + servicePath: "", + auth: { + oauth2: { + scopes: { + [fullScope]: { description: "Full Gmail access" }, + [settingsBasicScope]: { description: "Manage Gmail settings" }, + [settingsSharingScope]: { description: "Manage Gmail sharing settings" }, + }, + }, + }, + resources: { + users: { + resources: { + messages: { + methods: { + delete: { + id: "gmail.users.messages.delete", + httpMethod: "DELETE", + path: "gmail/v1/users/{userId}/messages/{id}", + scopes: [fullScope], + parameters: { + userId: { location: "path", required: true, type: "string" }, + id: { location: "path", required: true, type: "string" }, + }, + }, + }, + }, + settings: { + resources: { + filters: { + methods: { + create: { + id: "gmail.users.settings.filters.create", + httpMethod: "POST", + path: "gmail/v1/users/{userId}/settings/filters", + scopes: [settingsBasicScope], + parameters: { + userId: { location: "path", required: true, type: "string" }, + }, + }, + }, + }, + forwardingAddresses: { + methods: { + create: { + id: "gmail.users.settings.forwardingAddresses.create", + httpMethod: "POST", + path: "gmail/v1/users/{userId}/settings/forwardingAddresses", + scopes: [settingsSharingScope], + parameters: { + userId: { location: "path", required: true, type: "string" }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + schemas: {}, + }), + }, + ], + }); + + const spec = decodeConvertedSpec(result.specText); + const operationIds = Object.values(spec.paths).flatMap((path) => + Object.values(path).map((operation) => operation.operationId), + ); + expect(operationIds).toContain("gmail.users.messages.delete"); + expect(operationIds).toContain("gmail.users.settings.filters.create"); + expect(operationIds).not.toContain("gmail.users.settings.forwardingAddresses.create"); + const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); + expect(oauthTemplate?.kind === "oauth2" ? oauthTemplate.scopes : undefined).toEqual([ + fullScope, + settingsBasicScope, + ]); + }), +); + // --------------------------------------------------------------------------- // The merged bundle scope set is the COMPACTED + FILTERED union: sub-scopes -// collapse under their broad parent (`gmail.*` → `mail.google.com/`, +// collapse under their broad parent (Gmail message scopes → `mail.google.com/`, // `calendar.*` → `calendar`, `userinfo.email` → `email`), and scopes a user // OAuth consent screen can't show (`chat.bot`, `chat.app.*`, `keep`) are // dropped. The persisted auth template, the spec `securitySchemes.googleOAuth2` @@ -1171,6 +1268,17 @@ it.effect("compacts and filters the merged bundle scope set into a clean consent // Broad parent + a sub-scope that must collapse under it. "https://mail.google.com/": { description: "Full Gmail access" }, "https://www.googleapis.com/auth/gmail.readonly": { description: "Read Gmail" }, + // Basic settings remain independent; admin-only sharing and + // contextual add-on scopes must not enter user consent. + "https://www.googleapis.com/auth/gmail.settings.basic": { + description: "Manage Gmail settings", + }, + "https://www.googleapis.com/auth/gmail.settings.sharing": { + description: "Manage Gmail sharing settings", + }, + "https://www.googleapis.com/auth/gmail.addons.current.message.readonly": { + description: "Read the current add-on message", + }, // Identity scope normalized to `email`. "https://www.googleapis.com/auth/userinfo.email": { description: "Email" }, }, @@ -1241,12 +1349,14 @@ it.effect("compacts and filters the merged bundle scope set into a clean consent const expectedConsentScopes = [ "https://mail.google.com/", + "https://www.googleapis.com/auth/gmail.settings.basic", "email", "https://www.googleapis.com/auth/chat.spaces.readonly", ]; // The derived oauth auth template carries the compacted/filtered set - // (gmail.readonly collapsed, userinfo.email → email, chat.bot/chat.app.* dropped). + // (gmail.readonly collapsed, settings.basic preserved, userinfo.email → email, + // and admin-only/contextual/chat scopes dropped). const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); expect(oauthTemplate?.kind === "oauth2" ? [...oauthTemplate.scopes].sort() : undefined).toEqual( [...expectedConsentScopes].sort(), diff --git a/packages/plugins/openapi/src/providers/google/discovery.ts b/packages/plugins/openapi/src/providers/google/discovery.ts index 00c798b035..a10e85f0aa 100644 --- a/packages/plugins/openapi/src/providers/google/discovery.ts +++ b/packages/plugins/openapi/src/providers/google/discovery.ts @@ -569,11 +569,12 @@ const discoveryScopes = (document: DiscoveryDocument): Record => // requests at connect) must match the consent the picker previews. Both run the // raw Discovery union through `compactGoogleOAuthScopes`, which drops scopes a // user OAuth consent screen can't show (`chat.bot`/`chat.app.*`/`keep`) and -// collapses sub-scopes under their broad parent (`gmail.*` → `mail.google.com`, -// `userinfo.email` → `email`). Descriptions are preserved where the raw map had -// them; compaction-introduced identity scopes (`email`/`profile`) fall back to -// the broad parent's description. Per-operation `x-google-scopes`/`security` -// stay RAW - they describe which scope each method needs, not consent. +// collapses content sub-scopes under their broad parent (Gmail message scopes → +// `mail.google.com`, `userinfo.email` → `email`) while preserving independent +// settings scopes. Descriptions are preserved where the raw map had them; +// compaction-introduced identity scopes (`email`/`profile`) fall back to the +// broad parent's description. Per-operation `x-google-scopes`/`security` stay +// RAW - they describe which scope each method needs, not consent. const compactDiscoveryScopeMap = (raw: Record): Record => { const descriptionFor = (scope: string): string => { if (raw[scope] !== undefined) return raw[scope]; diff --git a/packages/plugins/openapi/src/providers/google/oauth-scopes.test.ts b/packages/plugins/openapi/src/providers/google/oauth-scopes.test.ts index 5118eb5b60..52a89643b2 100644 --- a/packages/plugins/openapi/src/providers/google/oauth-scopes.test.ts +++ b/packages/plugins/openapi/src/providers/google/oauth-scopes.test.ts @@ -24,11 +24,20 @@ it("compacts Google OAuth scopes after filtering user-consent-incompatible scope compactGoogleOAuthScopes([ "https://mail.google.com/", "https://www.googleapis.com/auth/gmail.send", + "https://www.googleapis.com/auth/gmail.settings.basic", + "https://www.googleapis.com/auth/gmail.settings.sharing", + "https://www.googleapis.com/auth/gmail.addons.current.message.readonly", "https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile", "openid", "https://www.googleapis.com/auth/chat.app.spaces", "https://www.googleapis.com/auth/keep.readonly", ]), - ).toEqual(["https://mail.google.com/", "email", "profile", "openid"]); + ).toEqual([ + "https://mail.google.com/", + "https://www.googleapis.com/auth/gmail.settings.basic", + "email", + "profile", + "openid", + ]); }); diff --git a/packages/plugins/openapi/src/providers/google/oauth-scopes.ts b/packages/plugins/openapi/src/providers/google/oauth-scopes.ts index 3a843b1a5d..302cb7b2a6 100644 --- a/packages/plugins/openapi/src/providers/google/oauth-scopes.ts +++ b/packages/plugins/openapi/src/providers/google/oauth-scopes.ts @@ -1,27 +1,47 @@ const googleUserConsentBlockedScopes = new Set([ "https://www.googleapis.com/auth/chat.bot", "https://www.googleapis.com/auth/chat.import", + // Gmail sharing-setting writes require a service account with domain-wide + // delegation, not the authorization-code flow used by user connections. + "https://www.googleapis.com/auth/gmail.settings.sharing", "https://www.googleapis.com/auth/keep", "https://www.googleapis.com/auth/keep.readonly", ]); -const googleUserConsentBlockedScopePrefixes = ["https://www.googleapis.com/auth/chat.app."]; +const googleUserConsentBlockedScopePrefixes = [ + "https://www.googleapis.com/auth/chat.app.", + // Contextual Gmail add-on scopes are minted for add-on executions, not a + // standalone web OAuth connection. + "https://www.googleapis.com/auth/gmail.addons.", +]; + +const googleMailScopesCoveredByFullAccess = new Set([ + "https://www.googleapis.com/auth/gmail.compose", + "https://www.googleapis.com/auth/gmail.insert", + "https://www.googleapis.com/auth/gmail.labels", + "https://www.googleapis.com/auth/gmail.metadata", + "https://www.googleapis.com/auth/gmail.modify", + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/gmail.send", +]); const googleBroadScopeGroups: readonly { readonly broad: string; - readonly prefixes: readonly string[]; + readonly covers: (scope: string) => boolean; }[] = [ { broad: "https://mail.google.com/", - prefixes: ["https://www.googleapis.com/auth/gmail."], + // Full mailbox access covers message, draft, label, and send operations, + // but Google requires gmail.settings.basic separately for filter writes. + covers: (scope) => googleMailScopesCoveredByFullAccess.has(scope), }, { broad: "https://www.googleapis.com/auth/calendar", - prefixes: ["https://www.googleapis.com/auth/calendar."], + covers: (scope) => scope.startsWith("https://www.googleapis.com/auth/calendar."), }, { broad: "https://www.googleapis.com/auth/drive", - prefixes: ["https://www.googleapis.com/auth/drive."], + covers: (scope) => scope.startsWith("https://www.googleapis.com/auth/drive."), }, ]; @@ -57,10 +77,7 @@ export const compactGoogleOAuthScopes = (scopes: Iterable): string[] => return ordered.filter( (scope) => !googleBroadScopeGroups.some( - (group) => - scope !== group.broad && - present.has(group.broad) && - group.prefixes.some((prefix) => scope.startsWith(prefix)), + (group) => scope !== group.broad && present.has(group.broad) && group.covers(scope), ), ); }; diff --git a/packages/plugins/openapi/src/providers/google/presets.test.ts b/packages/plugins/openapi/src/providers/google/presets.test.ts index db9bc9971a..3bffd06e94 100644 --- a/packages/plugins/openapi/src/providers/google/presets.test.ts +++ b/packages/plugins/openapi/src/providers/google/presets.test.ts @@ -215,14 +215,18 @@ it("keeps Select all limited to Google services that can use normal user OAuth", expect(standardIds).not.toContain("google-admin-reports"); }); -it("requests full Gmail and the complete user-facing Meet surface", () => { +it("requests full consumer Gmail access and the complete user-facing Meet surface", () => { const gmail = googleCatalog.find((preset) => preset.id === "google-gmail"); const meet = googleCatalog.find((preset) => preset.id === "google-meet"); const gmailOAuth = gmail?.authTemplate?.find((template) => template.kind === "oauth2"); const meetOAuth = meet?.authTemplate?.find((template) => template.kind === "oauth2"); expect(gmailOAuth?.scopes).toContain("https://mail.google.com/"); + expect(gmailOAuth?.scopes).toContain("https://www.googleapis.com/auth/gmail.settings.basic"); expect(gmailOAuth?.scopes).not.toContain("https://www.googleapis.com/auth/gmail.modify"); + expect(gmailOAuth?.scopes).not.toContain( + "https://www.googleapis.com/auth/gmail.settings.sharing", + ); expect(meetOAuth?.scopes).toEqual( expect.arrayContaining([ "https://www.googleapis.com/auth/meetings.space.created", diff --git a/packages/plugins/openapi/src/providers/google/presets.ts b/packages/plugins/openapi/src/providers/google/presets.ts index 140a2a2c64..db2b9bf57a 100644 --- a/packages/plugins/openapi/src/providers/google/presets.ts +++ b/packages/plugins/openapi/src/providers/google/presets.ts @@ -264,7 +264,10 @@ export const googleOAuthConsentScopes: Readonly +it.effect("preserves a Google preset's full consumer consent boundary when refreshing", () => Effect.gen(function* () { const gmailPreset = googleCatalog.find((preset) => preset.id === "google-gmail")!; const authTemplate: readonly AuthenticationInput[] = (gmailPreset.authTemplate ?? []).flatMap( @@ -255,7 +274,7 @@ it.effect("preserves a Google preset's consent scope boundary when refreshing", family: gmailPreset.family, authenticationTemplate: authTemplate, }); - expect(added.toolCount).toBe(1); + expect(added.toolCount).toBe(3); const updated = yield* executor.openapi.updateSpec("google_gmail"); @@ -263,10 +282,11 @@ it.effect("preserves a Google preset's consent scope boundary when refreshing", const oauthTemplate = config?.authenticationTemplate?.find( (template) => template.kind === "oauth2", ); - expect(updated.toolCount).toBe(1); + expect(updated.toolCount).toBe(3); expect(updated.addedTools).not.toContain("gmail.users.messages.delete"); - expect(oauthTemplate?.kind === "oauth2" ? oauthTemplate.scopes : undefined).toContain( - GMAIL_MODIFY_SCOPE, + expect(updated.addedTools).not.toContain("gmail.users.settings.filters.create"); + expect(oauthTemplate?.kind === "oauth2" ? oauthTemplate.scopes : undefined).toEqual( + expect.arrayContaining([GMAIL_FULL_SCOPE, GMAIL_SETTINGS_BASIC_SCOPE]), ); }), ); From 58ebd70ff12151bc2ce3f99c050337db3fc08822 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:53:04 -0700 Subject: [PATCH 055/133] Add temporary clock-sync probe to separate pre-work time from real work (#1670) workerd freezes Date.now() until I/O completes, so any delta taken across a request's first I/O silently includes queueing, isolate start and module evaluation. That artifact made every phase before the first await read 0ms and whichever await came first read multi-second, regardless of what it actually did. Await scheduler.wait(0) at handler entry to force the clock forward, and log the delta it absorbs. That delta is the pre-work time; everything measured after it is honest. Temporary, to be reverted. --- apps/cloud/src/server.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/apps/cloud/src/server.ts b/apps/cloud/src/server.ts index 4d9fe33ece..92c432a686 100644 --- a/apps/cloud/src/server.ts +++ b/apps/cloud/src/server.ts @@ -179,6 +179,32 @@ const mcpAgentHandler = makeCloudMcpAgentHandler({ const cloudflareHandler: ExportedHandler = { fetch: async (request, env, ctx) => { + // TEMPORARY DIAGNOSTIC (Aug 2026 latency hunt). + // + // workerd freezes `Date.now()` and only advances it when I/O completes, so + // every `Date.now()` delta taken ACROSS the first I/O of a request silently + // includes all wall-clock since the request started — queueing, isolate + // start, module evaluation. That is why per-phase timings kept reporting + // 0ms for everything up to the first await and then a multi-second number + // for whichever await happened to be first: the instrument was measuring + // the clock jump, not the operation. + // + // `scheduler.wait(0)` is an I/O boundary, so awaiting it here forces the + // clock forward before any real work. The delta it absorbs IS the + // pre-work time (queue + start + module eval); every measurement after it + // is honest. + const entryPinned = Date.now(); + await scheduler.wait(0); + const preWorkMs = Date.now() - entryPinned; + const probeUrl = new URL(request.url); + console.log( + JSON.stringify({ + probe: "clock-sync", + path: probeUrl.pathname, + preWorkMs, + }), + ); + // Public pages must not enter TanStack Start: its first-request dynamic // import loads the entire React + Effect server graph and can take seconds // on a cold isolate. Classify and service-bind marketing at the Worker From 0e5cb44ae643c40957430a87fe9359291afe90b8 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:58:01 -0700 Subject: [PATCH 056/133] Probe cache and timer latency directly in the handler (#1671) With the clock synced at entry these deltas are real durations, so they separate 'the Cache API is slow' from 'every outbound subrequest is slow'. --- apps/cloud/src/server.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/cloud/src/server.ts b/apps/cloud/src/server.ts index 92c432a686..082723dfaa 100644 --- a/apps/cloud/src/server.ts +++ b/apps/cloud/src/server.ts @@ -197,11 +197,25 @@ const cloudflareHandler: ExportedHandler = { await scheduler.wait(0); const preWorkMs = Date.now() - entryPinned; const probeUrl = new URL(request.url); + + // With the clock synced above, these two deltas are real durations. They + // answer whether the multi-second cost is specific to the Cache API or + // hits every outbound subrequest from this Worker. + const cacheStartedAt = Date.now(); + await caches.default.match("https://executor.sh/__probe_never_cached"); + const cacheProbeMs = Date.now() - cacheStartedAt; + + const timerStartedAt = Date.now(); + await scheduler.wait(1); + const timerProbeMs = Date.now() - timerStartedAt; + console.log( JSON.stringify({ probe: "clock-sync", path: probeUrl.pathname, preWorkMs, + cacheProbeMs, + timerProbeMs, }), ); From 7277833d170889ff1aa9dd520447a9b9fadd3e18 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:58:53 -0700 Subject: [PATCH 057/133] Fix probe typecheck: caches.default needs the Workers CacheStorage type (#1672) --- apps/cloud/src/server.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/cloud/src/server.ts b/apps/cloud/src/server.ts index 082723dfaa..a64a0dc3f7 100644 --- a/apps/cloud/src/server.ts +++ b/apps/cloud/src/server.ts @@ -202,7 +202,8 @@ const cloudflareHandler: ExportedHandler = { // answer whether the multi-second cost is specific to the Cache API or // hits every outbound subrequest from this Worker. const cacheStartedAt = Date.now(); - await caches.default.match("https://executor.sh/__probe_never_cached"); + const probeCaches = caches as CacheStorage & { readonly default?: Cache }; + await probeCaches.default?.match("https://executor.sh/__probe_never_cached"); const cacheProbeMs = Date.now() - cacheStartedAt; const timerStartedAt = Date.now(); From fde44501582cccedaa3d2bb911e184511ac74265 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:04:24 -0700 Subject: [PATCH 058/133] Probe a real outbound fetch alongside the local probes (#1673) Cache and timer probes come back in single-digit ms while the same requests take 4-6s wall, so local I/O is not the cost. Neither probe leaves the isolate. Time a real outbound subrequest, the class the docs proxy implicates (0.098s direct vs 3-6s through the Worker). --- apps/cloud/src/server.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/apps/cloud/src/server.ts b/apps/cloud/src/server.ts index a64a0dc3f7..7b4e38bc69 100644 --- a/apps/cloud/src/server.ts +++ b/apps/cloud/src/server.ts @@ -210,6 +210,20 @@ const cloudflareHandler: ExportedHandler = { await scheduler.wait(1); const timerProbeMs = Date.now() - timerStartedAt; + // The cache and timer probes are LOCAL. Neither leaves the isolate, and + // both come back in single-digit ms while the same requests take 4-6s. + // This one is a real outbound network subrequest to a small, fast, + // unrelated endpoint — the only class of I/O not yet measured, and the + // one the docs proxy (0.098s direct, 3-6s through the Worker) implicates. + const fetchStartedAt = Date.now(); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- temporary diagnostic: a probe failure must not affect the request + try { + await fetch("https://cloudflare.com/cdn-cgi/trace", { method: "GET" }); + } catch { + // ignored — the timing is the signal, not the result + } + const fetchProbeMs = Date.now() - fetchStartedAt; + console.log( JSON.stringify({ probe: "clock-sync", @@ -217,6 +231,7 @@ const cloudflareHandler: ExportedHandler = { preWorkMs, cacheProbeMs, timerProbeMs, + fetchProbeMs, }), ); From 68d5e8bd060a4ef381b846fae6424ed469b4faf1 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:10:16 -0700 Subject: [PATCH 059/133] Split header time from body-read time on the docs upstream (#1674) fetch() resolves when headers arrive, so the earlier probe returned ~1ms without ever reading a body. Both genuinely slow operations read one: the docs proxy reads a 179KB page and the JWKS store calls hit.json(). Time headers and body separately on the exact upstream the docs proxy uses, gated to /robots.txt so normal traffic never pays for it. --- apps/cloud/src/server.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/apps/cloud/src/server.ts b/apps/cloud/src/server.ts index 7b4e38bc69..130cc3f916 100644 --- a/apps/cloud/src/server.ts +++ b/apps/cloud/src/server.ts @@ -224,6 +224,31 @@ const cloudflareHandler: ExportedHandler = { } const fetchProbeMs = Date.now() - fetchStartedAt; + // `fetch()` above resolves when HEADERS arrive — it never reads a body, + // which is why it returns in ~1ms while the request it lives in takes + // seconds. Both genuinely slow operations read a body: the docs proxy + // reads a 179KB page, and the JWKS store does `hit.json()`. Split header + // time from body time on the exact URL the docs proxy uses. + // + // Gated to one cheap path so normal traffic never pays for the probe. + let bodyHeadersMs = -1; + let bodyReadMs = -1; + let bodyBytes = -1; + if (probeUrl.pathname === "/robots.txt") { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- temporary diagnostic: a probe failure must not affect the request + try { + const h0 = Date.now(); + const probeRes = await fetch("https://executor.mintlify.dev/docs/concepts/policies"); + bodyHeadersMs = Date.now() - h0; + const b0 = Date.now(); + const text = await probeRes.text(); + bodyReadMs = Date.now() - b0; + bodyBytes = text.length; + } catch { + // ignored — the timing is the signal + } + } + console.log( JSON.stringify({ probe: "clock-sync", @@ -232,6 +257,9 @@ const cloudflareHandler: ExportedHandler = { cacheProbeMs, timerProbeMs, fetchProbeMs, + bodyHeadersMs, + bodyReadMs, + bodyBytes, }), ); From dd0bbd268e5bdf14ebc5a0fd77b78d426363ad8b Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:21:49 -0700 Subject: [PATCH 060/133] Separate Start cold-graph cost from per-request cost (#1675) The standing explanation is that Start's lazy loadEntries import of the 2.56MB server graph costs seconds on the first Start-handled request per isolate. Module evaluation is CPU work, but these requests report 45-72ms CPU against 4s wall, so that does not fit. Wrap fetchHandler to record whether this isolate had already served a Start request, plus the time inside it. Cold slow + warm fast confirms the module load; both slow refutes it. --- apps/cloud/src/server.ts | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/apps/cloud/src/server.ts b/apps/cloud/src/server.ts index 130cc3f916..ee7aba5a99 100644 --- a/apps/cloud/src/server.ts +++ b/apps/cloud/src/server.ts @@ -102,12 +102,47 @@ export { McpExecutionOwnerDirectoryDO } from "@executor-js/cloudflare/mcp/execut // until the in-flight export resolves. // --------------------------------------------------------------------------- -const fetchHandler = handler.fetch as ( +const rawFetchHandler = handler.fetch as ( request: Request, env: Env, ctx: ExecutionContext, ) => Response | Promise; +// TEMPORARY DIAGNOSTIC (Aug 2026 latency hunt). +// +// The standing explanation is that Start's lazy `loadEntries` import of the +// 2.56MB server graph costs seconds on the first Start-handled request per +// isolate. That does not fit the numbers: module evaluation is CPU work, but +// these requests report 45-72ms of CPU against 4s of wall time. +// +// So distinguish the two directly. `wasWarm` is false only for the FIRST +// Start-handled request in this isolate — the one that pays the module load. +// If cold requests are slow and warm ones are fast, the graph load is real. +// If both are slow, the cost is per-request work inside Start/Effect and the +// module-load story is wrong. +let startHandledInThisIsolate = false; + +const fetchHandler = async ( + request: Request, + env: Env, + ctx: ExecutionContext, +): Promise => { + const wasWarm = startHandledInThisIsolate; + startHandledInThisIsolate = true; + const startedAt = Date.now(); + const response = await rawFetchHandler(request, env, ctx); + const handlerMs = Date.now() - startedAt; + console.log( + JSON.stringify({ + probe: "start-graph", + path: new URL(request.url).pathname, + wasWarm, + handlerMs, + }), + ); + return response; +}; + const tracer = trace.getTracer("executor-cloud-worker"); const traceparentValueFor = (spanContext: SpanContext): string => From 2670c2b49469e804465c7474886f60a31ee674c6 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:25:58 -0700 Subject: [PATCH 061/133] Clock-sync both sides of fetchHandler so handlerMs is real (#1676) A handler that performs no I/O leaves Date.now() pinned, so the first run reported handlerMs 0 against 6002ms wall. Sync before and after. --- apps/cloud/src/server.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/cloud/src/server.ts b/apps/cloud/src/server.ts index ee7aba5a99..b8d4186a1b 100644 --- a/apps/cloud/src/server.ts +++ b/apps/cloud/src/server.ts @@ -129,8 +129,14 @@ const fetchHandler = async ( ): Promise => { const wasWarm = startHandledInThisIsolate; startHandledInThisIsolate = true; + // Sync the clock on BOTH sides. Without the trailing `scheduler.wait(0)`, + // a handler that performs no I/O leaves `Date.now()` pinned and reports + // 0ms for work that actually took seconds — which is exactly what the + // first run of this probe showed (handlerMs 0 against 6002ms wall). + await scheduler.wait(0); const startedAt = Date.now(); const response = await rawFetchHandler(request, env, ctx); + await scheduler.wait(0); const handlerMs = Date.now() - startedAt; console.log( JSON.stringify({ From 9551da932e6705d775fb722e44245ebb61acad43 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:19:10 -0700 Subject: [PATCH 062/133] Time Start module evaluation separately from first-request app init (#1677) A cold isolate pays both: evaluating the 2.56MB module graph and building the app's Effect layers for the first time. Those have different fixes. Import the same virtual ids loadEntries uses so its cache finds the module already evaluated, leaving handlerMs to cover only the rest. --- apps/cloud/src/server.ts | 17 +++++++++++++++++ apps/cloud/src/start-virtual-entries.d.ts | 9 +++++++++ 2 files changed, 26 insertions(+) create mode 100644 apps/cloud/src/start-virtual-entries.d.ts diff --git a/apps/cloud/src/server.ts b/apps/cloud/src/server.ts index b8d4186a1b..99333d0eaf 100644 --- a/apps/cloud/src/server.ts +++ b/apps/cloud/src/server.ts @@ -134,6 +134,22 @@ const fetchHandler = async ( // 0ms for work that actually took seconds — which is exactly what the // first run of this probe showed (handlerMs 0 against 6002ms wall). await scheduler.wait(0); + + // "First Start request in this isolate" does TWO things: it evaluates the + // 2.56MB module graph, and it builds the app's Effect layers (DB, WorkOS) + // for the first time. Those have different fixes, so time them apart. + // Importing the same virtual ids `loadEntries` uses means its cache finds + // the module already evaluated, so `handlerMs` below excludes the load. + const moduleStartedAt = Date.now(); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- temporary diagnostic: a probe failure must not affect the request + try { + await Promise.all([import("#tanstack-router-entry"), import("#tanstack-start-entry")]); + } catch { + // ignored — the timing is the signal + } + await scheduler.wait(0); + const moduleMs = Date.now() - moduleStartedAt; + const startedAt = Date.now(); const response = await rawFetchHandler(request, env, ctx); await scheduler.wait(0); @@ -143,6 +159,7 @@ const fetchHandler = async ( probe: "start-graph", path: new URL(request.url).pathname, wasWarm, + moduleMs, handlerMs, }), ); diff --git a/apps/cloud/src/start-virtual-entries.d.ts b/apps/cloud/src/start-virtual-entries.d.ts new file mode 100644 index 0000000000..c76881feb9 --- /dev/null +++ b/apps/cloud/src/start-virtual-entries.d.ts @@ -0,0 +1,9 @@ +// TanStack Start's internal virtual server-entry modules (registered by the +// Start vite plugin; the same ids `start-server-core`'s `loadEntries` +// imports). server.ts imports them to time module evaluation separately from +// first-request app initialization — only the evaluation side effect matters, +// so the value shape is left untyped. Kept in a standalone declaration file: +// shorthand ambient modules only register from a non-module file +// (env-augment.d.ts is a module). +declare module "#tanstack-router-entry"; +declare module "#tanstack-start-entry"; From 54ccca9222e9daa5e943e0c520a403e1095d1a17 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:32:41 -0700 Subject: [PATCH 063/133] Forward docs and PostHog proxies before loading the Start graph (#1678) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both are pure passthroughs to an external origin — they never touch the router, React, or the Effect app — but they lived in Start's request middleware, so each paid Start's lazy loadEntries import of the whole server graph before it could forward. Measured in production by timing the two costs apart on a cold isolate: the graph import is p50 3.1s while the request's own work is p50 33ms. Essentially every request is cold — worker.dispatch ran 1,666 requests across 1,608 isolates — because /mcp dispatches before fetchHandler and so never warms the graph. A /docs page took 3-6s through the Worker against 0.098s straight from the upstream. Move matching and forwarding into edge/passthrough.ts, which imports neither Start nor any app module, and dispatch it from the Worker entry next to marketing. The middleware wrappers stay registered and now source their matching from the same module, so local dev is unchanged and the two cannot drift. Also reverts the temporary latency probes from server.ts. --- apps/cloud/src/edge/docs.ts | 78 ++--------- apps/cloud/src/edge/passthrough.test.ts | 85 ++++++++++++ apps/cloud/src/edge/passthrough.ts | 135 +++++++++++++++++++ apps/cloud/src/edge/posthog.ts | 29 ++-- apps/cloud/src/server.ts | 155 ++-------------------- apps/cloud/src/start-virtual-entries.d.ts | 9 -- 6 files changed, 249 insertions(+), 242 deletions(-) create mode 100644 apps/cloud/src/edge/passthrough.test.ts create mode 100644 apps/cloud/src/edge/passthrough.ts delete mode 100644 apps/cloud/src/start-virtual-entries.d.ts diff --git a/apps/cloud/src/edge/docs.ts b/apps/cloud/src/edge/docs.ts index fc59f5e3c0..fabf3773e0 100644 --- a/apps/cloud/src/edge/docs.ts +++ b/apps/cloud/src/edge/docs.ts @@ -6,83 +6,23 @@ // base path, so the pathname is forwarded UNCHANGED — only the host/proto swap // to the upstream origin (unlike the PostHog proxy, which strips its prefix). // -// Like the PostHog/Sentry tunnels (and unlike the marketing proxy, which needs -// the prod-only `env.MARKETING` service binding), this is a plain external -// `fetch`, so it runs on every host — `/docs` previews against live Mintlify in -// local dev too. `/docs` is distinct from the app-owned `/api/docs` (Swagger), -// so this never shadows an Effect-served route. +// The matching, upstream construction, and client span live in `./passthrough`, +// which server.ts dispatches BEFORE Start loads: forwarding a docs page must +// not pay for the whole server graph. This middleware stays registered so hosts +// that reach Start by another entry (local dev) keep identical behavior; in the +// deployed Worker it is unreachable. `/docs` is distinct from the app-owned +// `/api/docs` (Swagger), so this never shadows an Effect-served route. // --------------------------------------------------------------------------- -import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api"; import { createMiddleware } from "@tanstack/react-start"; -const DOCS_UPSTREAM_HOST = "executor.mintlify.dev"; +import { docsProxyResponse, isDocsPath } from "./passthrough"; -// The proxy fetch gets its own client span: `/docs` requests otherwise render -// as a single opaque server span, and during the Aug 2026 regression there -// was no way to tell upstream (Mintlify/Vercel) latency from worker-side -// dispatch cost. The noop tracer applies when no provider is installed -// (local dev without AXIOM_TOKEN), so this is free there. -const tracer = trace.getTracer("executor-cloud-docs-proxy"); - -export const isDocsPath = (pathname: string) => - pathname === "/docs" || pathname.startsWith("/docs/"); - -// Build the upstream request for an already-classified `/docs` path. Caller -// guarantees `isDocsPath(pathname)` — we only swap the origin and fix up the -// forwarding headers, preserving method, body, path, and query. -export const buildDocsUpstream = (request: Request): Request => { - const url = new URL(request.url); - const forwardedHost = url.host; - - url.hostname = DOCS_UPSTREAM_HOST; - url.protocol = "https:"; - url.port = ""; - - const upstream = new Request(url, request); - // Mintlify keys canonical links off the public host; tell it the real one. - upstream.headers.set("X-Forwarded-Host", forwardedHost); - upstream.headers.set("X-Forwarded-Proto", "https"); - // Never leak the executor.sh session cookie to the docs origin. - upstream.headers.delete("cookie"); - return upstream; -}; +export { buildDocsUpstream, isDocsPath } from "./passthrough"; export const docsProxyMiddleware = createMiddleware({ type: "request" }).server( ({ pathname, request, next }) => { if (!isDocsPath(pathname)) return next(); - return tracer.startActiveSpan( - `http.client ${request.method}`, - { - kind: SpanKind.CLIENT, - attributes: { - "server.address": DOCS_UPSTREAM_HOST, - "url.path": pathname, - "http.request.method": request.method, - }, - }, - async (span) => { - // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; observe upstream response/error for span status, then pass both through unchanged - try { - const response = await fetch(buildDocsUpstream(request)); - span.setAttribute("http.response.status_code", response.status); - if (response.status >= 500) { - span.setStatus({ code: SpanStatusCode.ERROR, message: `HTTP ${response.status}` }); - } - return response; - } catch (err) { - // oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- adapter boundary: fetch rejects untyped; normalized only for the OTel span record, the original error is rethrown below - const cause = err instanceof Error ? err : String(err); - span.recordException(cause); - // oxlint-disable-next-line executor/no-unknown-error-message -- adapter boundary: same normalization as the recordException line above - const message = typeof cause === "string" ? cause : cause.message; - span.setStatus({ code: SpanStatusCode.ERROR, message }); - // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; preserve the original rejection for the platform handler - throw err; - } finally { - span.end(); - } - }, - ); + return docsProxyResponse(request, pathname); }, ); diff --git a/apps/cloud/src/edge/passthrough.test.ts b/apps/cloud/src/edge/passthrough.test.ts new file mode 100644 index 0000000000..2f5bbd35ad --- /dev/null +++ b/apps/cloud/src/edge/passthrough.test.ts @@ -0,0 +1,85 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "@effect/vitest"; + +import { + buildDocsUpstream, + buildPosthogUpstream, + isDocsPath, + isPosthogPath, + passthroughResponse, + POSTHOG_PROXY_PATH, +} from "./passthrough"; + +describe("passthrough matching", () => { + it("claims /docs and everything under it, but not /api/docs", () => { + expect(isDocsPath("/docs")).toBe(true); + expect(isDocsPath("/docs/concepts/policies")).toBe(true); + // The app-owned Swagger route must keep reaching the Effect app. + expect(isDocsPath("/api/docs")).toBe(false); + expect(isDocsPath("/docsearch")).toBe(false); + }); + + it("claims the PostHog proxy path and its subtree only", () => { + expect(isPosthogPath(POSTHOG_PROXY_PATH)).toBe(true); + expect(isPosthogPath(`${POSTHOG_PROXY_PATH}/i/v0/e/`)).toBe(true); + expect(isPosthogPath(`${POSTHOG_PROXY_PATH}extra`)).toBe(false); + expect(isPosthogPath("/api/connections")).toBe(false); + }); + + it("returns null for app paths so they fall through to normal dispatch", () => { + expect(passthroughResponse(new Request("https://executor.sh/"), "/")).toBeNull(); + expect( + passthroughResponse(new Request("https://executor.sh/api/connections"), "/api/connections"), + ).toBeNull(); + expect(passthroughResponse(new Request("https://executor.sh/mcp"), "/mcp")).toBeNull(); + }); +}); + +describe("upstream construction", () => { + it("forwards the docs path unchanged and strips the session cookie", () => { + const upstream = buildDocsUpstream( + new Request("https://executor.sh/docs/concepts/policies?x=1", { + headers: { cookie: "wos-session=secret" }, + }), + ); + const url = new URL(upstream.url); + expect(url.hostname).toBe("executor.mintlify.dev"); + expect(url.pathname).toBe("/docs/concepts/policies"); + expect(url.search).toBe("?x=1"); + expect(upstream.headers.get("X-Forwarded-Host")).toBe("executor.sh"); + expect(upstream.headers.get("cookie")).toBeNull(); + }); + + it("strips the proxy prefix for PostHog and splits ingest from assets", () => { + const ingest = buildPosthogUpstream( + new Request(`https://executor.sh${POSTHOG_PROXY_PATH}/i/v0/e/`), + `${POSTHOG_PROXY_PATH}/i/v0/e/`, + ); + expect(new URL(ingest.url).hostname).toBe("us.i.posthog.com"); + expect(new URL(ingest.url).pathname).toBe("/i/v0/e/"); + + const assets = buildPosthogUpstream( + new Request(`https://executor.sh${POSTHOG_PROXY_PATH}/static/array.js`), + `${POSTHOG_PROXY_PATH}/static/array.js`, + ); + expect(new URL(assets.url).hostname).toBe("us-assets.i.posthog.com"); + expect(new URL(assets.url).pathname).toBe("/static/array.js"); + }); +}); + +describe("Start-graph independence", () => { + // The entire point of this module is that server.ts can answer a proxy + // request WITHOUT importing TanStack Start. An import of the app or of + // `@tanstack/react-start` here silently reintroduces the ~3.1s cold-isolate + // `loadEntries` cost this module exists to avoid, and nothing else would + // catch it — the behavior stays correct, only slow. + it("imports neither TanStack Start nor an app module", () => { + const source = readFileSync(new URL("./passthrough.ts", import.meta.url), "utf8"); + const imports = [...source.matchAll(/^\s*import[^"']*["']([^"']+)["']/gm)].map((m) => m[1]); + expect(imports.length).toBeGreaterThan(0); + for (const specifier of imports) { + expect(specifier).not.toMatch(/@tanstack/); + expect(specifier).not.toMatch(/^\.\.\//); + } + }); +}); diff --git a/apps/cloud/src/edge/passthrough.ts b/apps/cloud/src/edge/passthrough.ts new file mode 100644 index 0000000000..12b6229d44 --- /dev/null +++ b/apps/cloud/src/edge/passthrough.ts @@ -0,0 +1,135 @@ +// --------------------------------------------------------------------------- +// Pure passthrough proxies — dispatched from the Worker entry, before Start. +// --------------------------------------------------------------------------- +// +// `/docs` and the PostHog proxy forward to an external origin and never touch +// the router, React, or the Effect app. They lived in Start's request +// middleware, which meant each one still paid Start's lazy `loadEntries` +// import of the whole server graph before it could forward a request. +// +// Measured on production (2026-08-18), splitting the two costs apart on a cold +// isolate: the graph import is p50 **3.1s** while the request's own work is +// p50 **33ms**. And essentially every request is cold — `worker.dispatch` ran +// 1,666 requests across 1,608 isolates (1.04 req/isolate), because `/mcp` +// dispatches before `fetchHandler` and so never warms the graph. A `/docs` +// page took 3-6s through the Worker against 0.098s straight from the upstream. +// +// So these move to the Worker entry, exactly as marketing did: classify and +// forward before anything imports Start. This module therefore must NOT import +// `@tanstack/react-start` or any app module — that import is the cost it +// exists to avoid. +// +// The middleware wrappers in `./docs` and `./posthog` stay registered. In the +// deployed Worker they become unreachable (server.ts answers first), but they +// keep the behavior identical on any host that reaches Start by another entry +// (local dev), and they source their matching from here so the two can't drift. +// --------------------------------------------------------------------------- + +import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api"; + +const DOCS_UPSTREAM_HOST = "executor.mintlify.dev"; +const POSTHOG_INGEST_HOST = "us.i.posthog.com"; +const POSTHOG_ASSETS_HOST = "us-assets.i.posthog.com"; + +export const POSTHOG_PROXY_PATH = `/api/${( + import.meta.env.VITE_PUBLIC_ANALYTICS_PATH ?? "a" +).replace(/^\/+|\/+$/g, "")}`; + +// The proxy fetch gets its own client span: `/docs` requests otherwise render +// as a single opaque server span, and during the Aug 2026 regression there +// was no way to tell upstream (Mintlify/Vercel) latency from worker-side +// dispatch cost. The noop tracer applies when no provider is installed +// (local dev without AXIOM_TOKEN), so this is free there. +const tracer = trace.getTracer("executor-cloud-docs-proxy"); + +export const isDocsPath = (pathname: string): boolean => + pathname === "/docs" || pathname.startsWith("/docs/"); + +export const isPosthogPath = (pathname: string): boolean => + pathname === POSTHOG_PROXY_PATH || pathname.startsWith(`${POSTHOG_PROXY_PATH}/`); + +/** + * Build the upstream request for an already-classified `/docs` path. Caller + * guarantees `isDocsPath(pathname)` — we only swap the origin and fix up the + * forwarding headers, preserving method, body, path, and query. + */ +export const buildDocsUpstream = (request: Request): Request => { + const url = new URL(request.url); + const forwardedHost = url.host; + + url.hostname = DOCS_UPSTREAM_HOST; + url.protocol = "https:"; + url.port = ""; + + const upstream = new Request(url, request); + // Mintlify keys canonical links off the public host; tell it the real one. + upstream.headers.set("X-Forwarded-Host", forwardedHost); + upstream.headers.set("X-Forwarded-Proto", "https"); + // Never leak the executor.sh session cookie to the docs origin. + upstream.headers.delete("cookie"); + return upstream; +}; + +/** Build the upstream request for an already-classified PostHog proxy path. */ +export const buildPosthogUpstream = (request: Request, pathname: string): Request => { + const url = new URL(request.url); + url.hostname = pathname.startsWith(`${POSTHOG_PROXY_PATH}/static/`) + ? POSTHOG_ASSETS_HOST + : POSTHOG_INGEST_HOST; + url.protocol = "https:"; + url.port = ""; + url.pathname = pathname.slice(POSTHOG_PROXY_PATH.length) || "/"; + + const upstream = new Request(url, request); + upstream.headers.delete("cookie"); + return upstream; +}; + +export const docsProxyResponse = (request: Request, pathname: string): Promise => + tracer.startActiveSpan( + `http.client ${request.method}`, + { + kind: SpanKind.CLIENT, + attributes: { + "server.address": DOCS_UPSTREAM_HOST, + "url.path": pathname, + "http.request.method": request.method, + }, + }, + async (span) => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; observe upstream response/error for span status, then pass both through unchanged + try { + const response = await fetch(buildDocsUpstream(request)); + span.setAttribute("http.response.status_code", response.status); + if (response.status >= 500) { + span.setStatus({ code: SpanStatusCode.ERROR, message: `HTTP ${response.status}` }); + } + return response; + } catch (err) { + // oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- adapter boundary: fetch rejects untyped; normalized only for the OTel span record, the original error is rethrown below + const cause = err instanceof Error ? err : String(err); + span.recordException(cause); + // oxlint-disable-next-line executor/no-unknown-error-message -- adapter boundary: same normalization as the recordException line above + const message = typeof cause === "string" ? cause : cause.message; + span.setStatus({ code: SpanStatusCode.ERROR, message }); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; preserve the original rejection for the platform handler + throw err; + } finally { + span.end(); + } + }, + ); + +/** + * Answer a pure passthrough request without loading the Start server graph. + * Returns `null` when the request belongs to the app, so the caller falls + * through to normal dispatch. + */ +export const passthroughResponse = ( + request: Request, + pathname: string, +): Promise | null => { + if (isDocsPath(pathname)) return docsProxyResponse(request, pathname); + if (isPosthogPath(pathname)) return fetch(buildPosthogUpstream(request, pathname)); + return null; +}; diff --git a/apps/cloud/src/edge/posthog.ts b/apps/cloud/src/edge/posthog.ts index 6badd574f9..a9a035b4d1 100644 --- a/apps/cloud/src/edge/posthog.ts +++ b/apps/cloud/src/edge/posthog.ts @@ -3,33 +3,20 @@ // first-party path and we forward to PostHog's ingest + asset hosts. Keeps // events flowing past adblockers that match *.posthog.com. See // https://posthog.com/docs/advanced/proxy/cloudflare +// +// The matching and forwarding live in `./passthrough`, which server.ts +// dispatches BEFORE Start loads (a proxy must not pay for the server graph). +// This middleware stays registered so hosts that reach Start by another entry +// keep identical behavior; in the deployed Worker it is unreachable. // --------------------------------------------------------------------------- import { createMiddleware } from "@tanstack/react-start"; -const POSTHOG_INGEST_HOST = "us.i.posthog.com"; -const POSTHOG_ASSETS_HOST = "us-assets.i.posthog.com"; -const POSTHOG_PROXY_PATH = `/api/${(import.meta.env.VITE_PUBLIC_ANALYTICS_PATH ?? "a").replace( - /^\/+|\/+$/g, - "", -)}`; +import { buildPosthogUpstream, isPosthogPath } from "./passthrough"; export const posthogProxyMiddleware = createMiddleware({ type: "request" }).server( ({ pathname, request, next }) => { - if (pathname !== POSTHOG_PROXY_PATH && !pathname.startsWith(`${POSTHOG_PROXY_PATH}/`)) { - return next(); - } - - const url = new URL(request.url); - url.hostname = pathname.startsWith(`${POSTHOG_PROXY_PATH}/static/`) - ? POSTHOG_ASSETS_HOST - : POSTHOG_INGEST_HOST; - url.protocol = "https:"; - url.port = ""; - url.pathname = pathname.slice(POSTHOG_PROXY_PATH.length) || "/"; - - const upstream = new Request(url, request); - upstream.headers.delete("cookie"); - return fetch(upstream); + if (!isPosthogPath(pathname)) return next(); + return fetch(buildPosthogUpstream(request, pathname)); }, ); diff --git a/apps/cloud/src/server.ts b/apps/cloud/src/server.ts index 99333d0eaf..0304ab0246 100644 --- a/apps/cloud/src/server.ts +++ b/apps/cloud/src/server.ts @@ -13,6 +13,7 @@ import handler from "@tanstack/react-start/server-entry"; import { isAppOwnedPath } from "./app-paths"; import { marketingProxyRequest } from "./edge/marketing"; +import { passthroughResponse } from "./edge/passthrough"; import { makeCloudMcpAgentHandler } from "./mcp/agent-handler"; import { classifyMcpPath, prepareMcpOrgScope } from "./mcp/mount"; import { parseTraceparent } from "./mcp/traceparent"; @@ -102,70 +103,12 @@ export { McpExecutionOwnerDirectoryDO } from "@executor-js/cloudflare/mcp/execut // until the in-flight export resolves. // --------------------------------------------------------------------------- -const rawFetchHandler = handler.fetch as ( +const fetchHandler = handler.fetch as ( request: Request, env: Env, ctx: ExecutionContext, ) => Response | Promise; -// TEMPORARY DIAGNOSTIC (Aug 2026 latency hunt). -// -// The standing explanation is that Start's lazy `loadEntries` import of the -// 2.56MB server graph costs seconds on the first Start-handled request per -// isolate. That does not fit the numbers: module evaluation is CPU work, but -// these requests report 45-72ms of CPU against 4s of wall time. -// -// So distinguish the two directly. `wasWarm` is false only for the FIRST -// Start-handled request in this isolate — the one that pays the module load. -// If cold requests are slow and warm ones are fast, the graph load is real. -// If both are slow, the cost is per-request work inside Start/Effect and the -// module-load story is wrong. -let startHandledInThisIsolate = false; - -const fetchHandler = async ( - request: Request, - env: Env, - ctx: ExecutionContext, -): Promise => { - const wasWarm = startHandledInThisIsolate; - startHandledInThisIsolate = true; - // Sync the clock on BOTH sides. Without the trailing `scheduler.wait(0)`, - // a handler that performs no I/O leaves `Date.now()` pinned and reports - // 0ms for work that actually took seconds — which is exactly what the - // first run of this probe showed (handlerMs 0 against 6002ms wall). - await scheduler.wait(0); - - // "First Start request in this isolate" does TWO things: it evaluates the - // 2.56MB module graph, and it builds the app's Effect layers (DB, WorkOS) - // for the first time. Those have different fixes, so time them apart. - // Importing the same virtual ids `loadEntries` uses means its cache finds - // the module already evaluated, so `handlerMs` below excludes the load. - const moduleStartedAt = Date.now(); - // oxlint-disable-next-line executor/no-try-catch-or-throw -- temporary diagnostic: a probe failure must not affect the request - try { - await Promise.all([import("#tanstack-router-entry"), import("#tanstack-start-entry")]); - } catch { - // ignored — the timing is the signal - } - await scheduler.wait(0); - const moduleMs = Date.now() - moduleStartedAt; - - const startedAt = Date.now(); - const response = await rawFetchHandler(request, env, ctx); - await scheduler.wait(0); - const handlerMs = Date.now() - startedAt; - console.log( - JSON.stringify({ - probe: "start-graph", - path: new URL(request.url).pathname, - wasWarm, - moduleMs, - handlerMs, - }), - ); - return response; -}; - const tracer = trace.getTracer("executor-cloud-worker"); const traceparentValueFor = (spanContext: SpanContext): string => @@ -237,90 +180,6 @@ const mcpAgentHandler = makeCloudMcpAgentHandler({ const cloudflareHandler: ExportedHandler = { fetch: async (request, env, ctx) => { - // TEMPORARY DIAGNOSTIC (Aug 2026 latency hunt). - // - // workerd freezes `Date.now()` and only advances it when I/O completes, so - // every `Date.now()` delta taken ACROSS the first I/O of a request silently - // includes all wall-clock since the request started — queueing, isolate - // start, module evaluation. That is why per-phase timings kept reporting - // 0ms for everything up to the first await and then a multi-second number - // for whichever await happened to be first: the instrument was measuring - // the clock jump, not the operation. - // - // `scheduler.wait(0)` is an I/O boundary, so awaiting it here forces the - // clock forward before any real work. The delta it absorbs IS the - // pre-work time (queue + start + module eval); every measurement after it - // is honest. - const entryPinned = Date.now(); - await scheduler.wait(0); - const preWorkMs = Date.now() - entryPinned; - const probeUrl = new URL(request.url); - - // With the clock synced above, these two deltas are real durations. They - // answer whether the multi-second cost is specific to the Cache API or - // hits every outbound subrequest from this Worker. - const cacheStartedAt = Date.now(); - const probeCaches = caches as CacheStorage & { readonly default?: Cache }; - await probeCaches.default?.match("https://executor.sh/__probe_never_cached"); - const cacheProbeMs = Date.now() - cacheStartedAt; - - const timerStartedAt = Date.now(); - await scheduler.wait(1); - const timerProbeMs = Date.now() - timerStartedAt; - - // The cache and timer probes are LOCAL. Neither leaves the isolate, and - // both come back in single-digit ms while the same requests take 4-6s. - // This one is a real outbound network subrequest to a small, fast, - // unrelated endpoint — the only class of I/O not yet measured, and the - // one the docs proxy (0.098s direct, 3-6s through the Worker) implicates. - const fetchStartedAt = Date.now(); - // oxlint-disable-next-line executor/no-try-catch-or-throw -- temporary diagnostic: a probe failure must not affect the request - try { - await fetch("https://cloudflare.com/cdn-cgi/trace", { method: "GET" }); - } catch { - // ignored — the timing is the signal, not the result - } - const fetchProbeMs = Date.now() - fetchStartedAt; - - // `fetch()` above resolves when HEADERS arrive — it never reads a body, - // which is why it returns in ~1ms while the request it lives in takes - // seconds. Both genuinely slow operations read a body: the docs proxy - // reads a 179KB page, and the JWKS store does `hit.json()`. Split header - // time from body time on the exact URL the docs proxy uses. - // - // Gated to one cheap path so normal traffic never pays for the probe. - let bodyHeadersMs = -1; - let bodyReadMs = -1; - let bodyBytes = -1; - if (probeUrl.pathname === "/robots.txt") { - // oxlint-disable-next-line executor/no-try-catch-or-throw -- temporary diagnostic: a probe failure must not affect the request - try { - const h0 = Date.now(); - const probeRes = await fetch("https://executor.mintlify.dev/docs/concepts/policies"); - bodyHeadersMs = Date.now() - h0; - const b0 = Date.now(); - const text = await probeRes.text(); - bodyReadMs = Date.now() - b0; - bodyBytes = text.length; - } catch { - // ignored — the timing is the signal - } - } - - console.log( - JSON.stringify({ - probe: "clock-sync", - path: probeUrl.pathname, - preWorkMs, - cacheProbeMs, - timerProbeMs, - fetchProbeMs, - bodyHeadersMs, - bodyReadMs, - bodyBytes, - }), - ); - // Public pages must not enter TanStack Start: its first-request dynamic // import loads the entire React + Effect server graph and can take seconds // on a cold isolate. Classify and service-bind marketing at the Worker @@ -329,6 +188,16 @@ const cloudflareHandler: ExportedHandler = { const marketing: Fetcher | undefined = env.MARKETING; if (marketingRequest && marketing) return marketing.fetch(marketingRequest); + // Same reasoning, same seam: `/docs` and the PostHog proxy forward to an + // external origin and never touch the router, React, or the Effect app. + // Left in Start's middleware they still paid its lazy `loadEntries` import + // first — measured at p50 3.1s on a cold isolate, against p50 33ms for the + // request's own work, on a Worker where 1,666 dispatches spread across + // 1,608 isolates (so nearly every request is cold). Forward before Start. + const passthroughPath = new URL(request.url).pathname; + const passthrough = passthroughResponse(request, passthroughPath); + if (passthrough) return passthrough; + // Browser OTLP ingress — before the server span opens: exporter traffic // must never trace itself (the browser already excludes /v1/traces from // its own tracing for the same reason). diff --git a/apps/cloud/src/start-virtual-entries.d.ts b/apps/cloud/src/start-virtual-entries.d.ts deleted file mode 100644 index c76881feb9..0000000000 --- a/apps/cloud/src/start-virtual-entries.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -// TanStack Start's internal virtual server-entry modules (registered by the -// Start vite plugin; the same ids `start-server-core`'s `loadEntries` -// imports). server.ts imports them to time module evaluation separately from -// first-request app initialization — only the evaluation side effect matters, -// so the value shape is left untyped. Kept in a standalone declaration file: -// shorthand ambient modules only register from a non-module file -// (env-augment.d.ts is a module). -declare module "#tanstack-router-entry"; -declare module "#tanstack-start-entry"; From 42d4e49ce8ee0c0885ba7826b0971c0a765f6dd9 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:39:23 -0700 Subject: [PATCH 064/133] Fix Google OAuth service policies --- apps/marketing/src/pages/about-executor.astro | 6 +- apps/marketing/src/pages/google-oauth.astro | 10 +- .../src/pages/google-workspace.astro | 6 +- apps/marketing/src/pages/privacy.astro | 4 +- e2e/scenarios/first-party-oauth.test.ts | 15 +- .../google/__snapshots__/presets.test.ts.snap | 2 +- .../src/providers/google/discovery.test.ts | 361 ++++++++++++++++-- .../openapi/src/providers/google/discovery.ts | 183 ++++++--- .../src/providers/google/oauth-scopes.test.ts | 26 ++ .../src/providers/google/oauth-scopes.ts | 83 +++- .../src/providers/google/presets.test.ts | 72 +++- .../openapi/src/providers/google/presets.ts | 109 +++--- .../providers/google/service-policy.test.ts | 32 ++ .../src/providers/google/service-policy.ts | 257 +++++++++++++ .../src/planner.test.ts | 1 - 15 files changed, 998 insertions(+), 169 deletions(-) create mode 100644 packages/plugins/openapi/src/providers/google/service-policy.test.ts create mode 100644 packages/plugins/openapi/src/providers/google/service-policy.ts diff --git a/apps/marketing/src/pages/about-executor.astro b/apps/marketing/src/pages/about-executor.astro index 007d27125f..bcc4d52282 100644 --- a/apps/marketing/src/pages/about-executor.astro +++ b/apps/marketing/src/pages/about-executor.astro @@ -124,11 +124,11 @@ const pageDescription = person's instructions.

- ) : dcrActive ? ( - // Transparent DCR: no picker. We register an app for you and run - // the OAuth flow with a single Connect click. -
-

No app to choose

-

- {dcrConnecting - ? `Connecting to ${integrationName}…` - : `${integrationName} supports automatic setup. We register an app for you and sign you in — no client ID or app to pick.`} -

-
) : oauthLoading ? (

Loading OAuth apps…

) : ( @@ -2739,9 +2785,6 @@ function AddAccountModalView(props: AddAccountModalProps) { /> )} - {isOAuth && oauthPopup.error ? ( -

{oauthPopup.error}

- ) : null} )} @@ -2909,6 +2952,15 @@ function AddAccountModalView(props: AddAccountModalProps) { {continueError}

) : null} + {/* Above the footer, not inside the method tab: the automatic + (CIMD/DCR) flows render no tab panel at all, and putting the + sign-in error in there left a blocked popup with nothing on + screen but the button returning to "Connect". */} + {isOAuth && oauthPopup.error ? ( +

+ {oauthPopup.error} +

+ ) : null}
+ + ))} + {catalog.loading && + catalogEntries.length === 0 && + Array.from({ length: 3 }).map((_, i) => ( +
+ +
+ + +
+ +
+ ))} + + )} + {catalogError &&

{catalogError}

} ); } From 4c9e75577dde0b3f8abefead0401cd00bafe680d Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:47:11 -0700 Subject: [PATCH 123/133] Fix blurry YC badge on mobile (#1770) The badge SVG wrapped everything in a Figma drop-shadow filter, and WebKit rasterizes SVG filter regions in at 1x, so the whole badge rendered low-res on iPhones. Strip the filter from the SVG and apply the shadow with CSS instead. --- apps/marketing/src/assets/yc-backed-by.svg | 16 +--------------- apps/marketing/src/pages/index.astro | 5 ++++- 2 files changed, 5 insertions(+), 16 deletions(-) diff --git a/apps/marketing/src/assets/yc-backed-by.svg b/apps/marketing/src/assets/yc-backed-by.svg index f3b4d3e3c2..6606921ff2 100755 --- a/apps/marketing/src/assets/yc-backed-by.svg +++ b/apps/marketing/src/assets/yc-backed-by.svg @@ -1,5 +1,4 @@ - - + @@ -14,17 +13,4 @@ - - - - - - - - - - - - - diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index 9edc960e84..b57258bc7e 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -203,11 +203,14 @@ Source (and the place to start if something breaks): https://github.com/UsefulSo class="rise-4 mt-10 inline-flex" aria-label="Backed by Y Combinator" > + {/* Shadow lives here, not in the SVG: WebKit rasterizes SVG + filter regions in at 1x, blurring the badge on mobile. */} Backed by Y Combinator
From cd3d0005e004cb8e3f13c21f91a2e8c8cca08d38 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:45:51 -0700 Subject: [PATCH 124/133] Classify non-JSON and HTTP-200 refresh refusals as dead grants (#1783) * Classify non-JSON and HTTP-200 refresh refusals as dead grants * Drop customer name from refresh-classification comment * Assert the 5xx control by contract, not by MCP failure shape --- .../oauth-refresh-rejected-non-json.test.ts | 503 ++++++++++++++++++ packages/core/sdk/src/executor.ts | 39 +- packages/core/sdk/src/oauth-helpers.test.ts | 24 + packages/core/sdk/src/oauth-helpers.ts | 103 +++- .../core/sdk/src/testing/oauth-test-server.ts | 21 +- 5 files changed, 680 insertions(+), 10 deletions(-) create mode 100644 e2e/scenarios/oauth-refresh-rejected-non-json.test.ts diff --git a/e2e/scenarios/oauth-refresh-rejected-non-json.test.ts b/e2e/scenarios/oauth-refresh-rejected-non-json.test.ts new file mode 100644 index 0000000000..ed12d7240a --- /dev/null +++ b/e2e/scenarios/oauth-refresh-rejected-non-json.test.ts @@ -0,0 +1,503 @@ +// Cross-target: an authorization server's refusal does not have to be a +// conform RFC 6749 §5.2 envelope to be FINAL — and a server that merely +// stumbled must not be mistaken for one that refused. +// +// Production regression: real token endpoints refuse a dead refresh grant in +// shapes the spec never describes — a `text/plain` 400 ("your session has +// expired"), a `text/plain` 404, a 200 whose body carries the error, or a 200 +// with no usable token. The RFC parser will not even read those bodies, so no +// OAuth error code was recovered, every one of them was classified as a +// retryable blip, and the dead grant went back to the authorization server on +// every single use — hundreds of identical rejections on one grant, each an +// internal error to the agent, while the connection still rendered as fine. +// +// The journey, once per refusal shape: an OpenAPI integration completes a real +// authorization-code flow against a live test AS that mints instantly-expiring +// access tokens and refuses every refresh grant with the shape under test. The +// first tool call refreshes and the AS says no. For a definitive refusal the +// connection must then ask to be reconnected and the AS must never hear that +// grant again; for a 5xx the opposite must hold — the next use tries again. +import { randomBytes } from "node:crypto"; +import { createServer } from "node:http"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + OAuthClientSlug, +} from "@executor-js/sdk/shared"; +import { serveOAuthTestServer } from "@executor-js/sdk/testing"; + +import { scenario } from "../src/scenario"; +import { Api, Mcp, Target } from "../src/services"; + +const api = composePluginApi([openApiHttpPlugin()] as const); + +const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; + +/** Upstream on 127.0.0.1: `GET /issues` is 200 for any bearer. The refresh is + * rejected before any upstream call, so this only proves the failure came + * from the token endpoint, not from here. */ +const serveUpstream = () => + Effect.acquireRelease( + Effect.callback<{ readonly url: string; readonly close: () => void }>((resume) => { + const server = createServer((request, response) => { + if (request.method === "GET" && (request.url ?? "").startsWith("/issues")) { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ issues: [] })); + return; + } + response.writeHead(404, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: "not_found" })); + }); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + resume( + Effect.succeed({ + url: `http://127.0.0.1:${port}`, + close: () => { + server.close(); + server.closeAllConnections(); + }, + }), + ); + }); + }), + (server) => Effect.sync(server.close), + ); + +const spec = ( + baseUrl: string, + oauth: { readonly authorizationEndpoint: string; readonly tokenEndpoint: string }, +): string => + JSON.stringify({ + openapi: "3.0.3", + info: { title: "Issues API", version: "1.0.0" }, + servers: [{ url: baseUrl }], + paths: { + "/issues": { + get: { + operationId: "listIssues", + summary: "List issues", + security: [{ oauth: ["issues.read"] }], + responses: { "200": { description: "issues" } }, + }, + }, + }, + components: { + securitySchemes: { + oauth: { + type: "oauth2", + flows: { + authorizationCode: { + authorizationUrl: oauth.authorizationEndpoint, + tokenUrl: oauth.tokenEndpoint, + scopes: { "issues.read": "Read issues" }, + }, + }, + }, + }, + }, + }); + +const invokeByAddressCode = (address: string, args: unknown) => ` +const segments = ${JSON.stringify(address)}.split(".").slice(1); +let node = tools; +for (const segment of segments) node = node[segment]; +const result = await node(${JSON.stringify(args)}); +return JSON.stringify(result); +`; + +type ToolEnvelope = { + readonly ok: boolean; + readonly error?: { + readonly code?: string; + readonly message?: string; + readonly details?: { + readonly category?: string; + readonly recovery?: Record; + }; + }; +}; + +/** Exactly what the token endpoint answers a refresh grant with, byte for + * byte — the whole point is that it is not a shape the RFC describes. */ +type RefreshRejection = { + readonly status: number; + readonly contentType?: string; + readonly body: string; +}; + +/** Exactly what the agent gets back from `execute`: either the tool's own JSON + * result envelope (`ok: true` at the MCP layer) or an MCP-level error, which + * is what a failure executor did not classify degrades into. Which of the two + * a refusal produces is itself part of what these scenarios pin down. */ +type McpCall = { readonly ok: boolean; readonly text: string }; + +/** What the test can observe from outside once the AS has refused: the agent's + * view of a tool call, the AS's own ledger of how many times the dead grant + * actually left the building, and what the connection says about itself. */ +type Observations = { + readonly callTool: () => Effect.Effect; + readonly refreshGrantsSent: Effect.Effect; + readonly connectionHealth: Effect.Effect< + { readonly status?: string; readonly detail?: string }, + unknown, + never + >; +}; + +/** Stand up an integration whose connection is authorized for real and whose + * every refresh is refused with `rejection`, then hand the assertions the + * outside-in observations above. */ +const withRefusedRefresh = ( + slugPrefix: string, + rejection: RefreshRejection, + assertions: (observations: Observations) => Effect.Effect, +) => + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const { client: makeClient } = yield* Api; + const mcp = yield* Mcp; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const upstream = yield* serveUpstream(); + // Instantly-expiring access tokens force the first tool call to refresh. + const oauth = yield* serveOAuthTestServer({ + scopes: ["issues.read"], + tokenExpiresInSeconds: 0, + supportRefresh: false, + refreshRejection: rejection, + }); + const slug = unique(slugPrefix); + const clientSlug = OAuthClientSlug.make(unique(`${slugPrefix}c`)); + const connectionName = ConnectionName.make("main"); + + yield* Effect.ensuring( + Effect.gen(function* () { + yield* client.openapi.addSpec({ + payload: { + spec: { kind: "blob", value: spec(upstream.url, oauth) }, + slug, + baseUrl: upstream.url, + authenticationTemplate: [ + { + slug: "oauth", + kind: "oauth2", + authorizationUrl: oauth.authorizationEndpoint, + tokenUrl: oauth.tokenEndpoint, + scopes: ["issues.read"], + }, + ], + }, + }); + yield* client.oauth.createClient({ + payload: { + owner: "org", + slug: clientSlug, + grant: "authorization_code", + authorizationUrl: oauth.authorizationEndpoint, + tokenUrl: oauth.tokenEndpoint, + clientId: "test-client", + clientSecret: "test-secret", + originIntegration: IntegrationSlug.make(slug), + }, + }); + + const started = yield* client.oauth.start({ + payload: { + client: clientSlug, + clientOwner: "org", + owner: "org", + name: connectionName, + integration: IntegrationSlug.make(slug), + template: AuthTemplateSlug.make("oauth"), + }, + }); + expect(started.status, "oauth.start redirects to the authorization server").toBe( + "redirect", + ); + if (started.status !== "redirect") return yield* Effect.die("no redirect"); + + // Drive the test IdP's consent by hand (authorize → login → code). + const code = yield* Effect.promise(async () => { + const authorize = await fetch(started.authorizationUrl, { redirect: "manual" }); + const loginUrl = authorize.headers.get("location"); + if (!loginUrl) throw new Error(`authorize did not redirect: ${authorize.status}`); + const login = await fetch(loginUrl, { + method: "POST", + headers: { + authorization: `Basic ${Buffer.from("alice:password").toString("base64")}`, + }, + redirect: "manual", + }); + const callbackUrl = login.headers.get("location"); + if (!callbackUrl) throw new Error(`login did not redirect: ${login.status}`); + const minted = new URL(callbackUrl).searchParams.get("code"); + if (!minted) throw new Error("callback carried no authorization code"); + return minted; + }); + yield* client.oauth.complete({ payload: { state: started.state, code } }); + + const tools = yield* client.tools.list({ query: {} }); + const address = tools + .filter((tool) => String(tool.integration) === slug) + .map((tool) => String(tool.address)) + .find((addr) => addr.endsWith("listIssues")); + expect(address, "the listIssues tool is in the catalog").toBeDefined(); + + // Call through the real MCP surface, the channel an agent uses. + const session = mcp.session(identity); + const callTool = () => + Effect.gen(function* () { + let called = yield* session.call("execute", { + code: invokeByAddressCode(address!, {}), + }); + // Approval-gated tools pause the execution once per gated call. + let guard = 0; + while (called.text.includes("executionId:") && guard < 10) { + called = yield* session.approvePaused(called.text); + guard += 1; + } + return { ok: called.ok, text: called.text }; + }); + + yield* assertions({ + callTool, + refreshGrantsSent: Effect.map( + oauth.requests, + (all) => + all.filter( + (request) => + request.path === "/token" && request.body.includes("grant_type=refresh_token"), + ).length, + ), + connectionHealth: Effect.map( + client.connections.get({ + params: { + owner: "org", + integration: IntegrationSlug.make(slug), + name: connectionName, + }, + }), + (connection) => ({ + status: connection.lastHealth?.status, + detail: connection.lastHealth?.detail ?? undefined, + }), + ), + }); + }), + Effect.gen(function* () { + yield* client.connections + .remove({ + params: { + owner: "org", + integration: IntegrationSlug.make(slug), + name: connectionName, + }, + }) + .pipe(Effect.ignore); + yield* client.oauth + .removeClient({ params: { slug: clientSlug }, payload: { owner: "org" } }) + .pipe(Effect.ignore); + yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore); + }), + ); + }), + ); + +/** Every definitive refusal, whatever it looks like on the wire, has to end the + * same way: one grant sent, an actionable auth failure, and silence towards + * the AS from then on. */ +const deadGrantScenario = (input: { + readonly shape: string; + readonly slugPrefix: string; + readonly rejection: RefreshRejection; + /** A fragment of the endpoint's own answer that must survive to the agent. */ + readonly reason: string; +}) => + scenario( + `Auth failures · a refresh refused with ${input.shape} is a dead grant: the connection asks to be reconnected and the grant is never re-sent`, + {}, + withRefusedRefresh( + input.slugPrefix, + input.rejection, + ({ callTool, refreshGrantsSent, connectionHealth }) => + Effect.gen(function* () { + /** A classified failure comes back as the tool's own result envelope; + * an unclassified one degrades into an MCP-level error instead. */ + const envelopeOf = (call: McpCall, label: string): ToolEnvelope => { + expect( + call.ok, + `${label}: the failure came back as a tool result the agent can read, not an MCP error (got: ${call.text.slice(0, 400)})`, + ).toBe(true); + return JSON.parse(call.text) as ToolEnvelope; + }; + + const first = envelopeOf(yield* callTool(), "first call"); + expect(first.ok, "the tool call failed").toBe(false); + expect( + first.error?.code, + "the refusal is read as a dead grant, not an internal defect", + ).toBe("oauth_reauth_required"); + expect(first.error?.details?.category, "the failure is auth-flavored").toBe( + "authentication", + ); + expect( + first.error?.message ?? "", + "the token endpoint's own words survive to the agent", + ).toContain(input.reason); + expect( + first.error?.message ?? "", + "the opaque defect message never reaches the sandbox", + ).not.toContain("Internal tool error"); + expect( + first.error?.details?.recovery?.oauthInstructions ?? "", + "the recovery tells the agent how to re-connect via OAuth", + ).toContain("startOAuthTool"); + expect(yield* refreshGrantsSent, "the grant was tried exactly once").toBe(1); + + // THE guarantee: further use of the connection keeps reporting the same + // actionable failure and the AS never hears the dead grant again. This + // is the assertion that pins the retry storm. + const second = envelopeOf(yield* callTool(), "second call"); + const third = envelopeOf(yield* callTool(), "third call"); + for (const [index, envelope] of [second, third].entries()) { + expect(envelope.ok, `follow-up call ${index + 1} still failed`).toBe(false); + expect( + envelope.error?.code, + `follow-up call ${index + 1} still asks for a reconnect`, + ).toBe("oauth_reauth_required"); + } + expect( + yield* refreshGrantsSent, + "the dead grant is never re-sent, however often the connection is used", + ).toBe(1); + + // And the connection says so on its own, with no probe: the verdict was + // recorded the moment the AS delivered it. + const health = yield* connectionHealth; + expect(health.status, "the connection reads as expired at a glance").toBe("expired"); + expect( + health.detail ?? "", + "with the upstream's reason, so the user knows why", + ).toContain(input.reason); + }), + ), + ); + +// The shape the prod outage arrived in: a plain-language 400 the RFC parser +// refuses to read at all. +deadGrantScenario({ + shape: "a text/plain 400", + slugPrefix: "refreshtxt", + rejection: { + status: 400, + contentType: "text/plain; charset=utf-8", + body: "Your session has expired. Please reconnect the integration.", + }, + reason: "Your session has expired", +}); + +// A token endpoint answering 404 does not start existing on the next attempt. +deadGrantScenario({ + shape: "a text/plain 404", + slugPrefix: "refresh404", + rejection: { + status: 404, + contentType: "text/plain; charset=utf-8", + body: "Not found", + }, + reason: "Not found", +}); + +// GitHub is the canonical emitter of this shape: HTTP 200 with the refusal in +// the body. A verdict delivered inside a response the endpoint called a SUCCESS +// is about this grant, so it is final whatever the code spells. +deadGrantScenario({ + shape: "HTTP 200 and an error body", + slugPrefix: "refresh200e", + rejection: { + status: 200, + contentType: "application/json", + body: JSON.stringify({ + error: "bad_refresh_token", + error_description: "The refresh token passed is incorrect or expired.", + }), + }, + reason: "bad_refresh_token", +}); + +// The same class without any verdict at all: a 200 the endpoint called a +// success that carries no usable access token. +deadGrantScenario({ + shape: "HTTP 200 and no usable access token", + slugPrefix: "refresh200n", + rejection: { + status: 200, + contentType: "application/json", + body: JSON.stringify({ access_token: 12_345, token_type: "Bearer" }), + }, + reason: "access_token", +}); + +// The other half of the contract, and the reason the classifier keys on status +// rather than "did anything go wrong": an AS having a bad minute has not +// refused anything, so the connection must keep trying rather than demand a +// pointless reconnect from the user. +scenario( + "Auth failures · a refresh that fails on a 5xx stays retryable: the connection keeps trying instead of demanding a reconnect", + {}, + withRefusedRefresh( + "refresh503", + { status: 503, contentType: "text/plain; charset=utf-8", body: "upstream unavailable" }, + ({ callTool, refreshGrantsSent, connectionHealth }) => + Effect.gen(function* () { + /** The call did not deliver a result — whichever shape the failure + * wore. A transient failure is deliberately left unclassified, so + * today it degrades into an MCP-level error; that shape is a separate + * contract (and a separate change), and pinning it here would make + * this scenario fail for improving it. What this scenario owns is + * that a stumble is not read as a DEAD GRANT. */ + const expectFailed = (call: McpCall, label: string) => { + if (!call.ok) return; + const envelope = JSON.parse(call.text) as ToolEnvelope; + expect(envelope.ok, label).toBe(false); + }; + const expectNoReconnectDemanded = (call: McpCall, label: string) => + expect( + call.text, + `${label}: a server that stumbled is not a grant that died — no reconnect is demanded of the user`, + ).not.toContain("oauth_reauth_required"); + + const first = yield* callTool(); + expectFailed(first, "the tool call did not succeed"); + expectNoReconnectDemanded(first, "first call"); + expect(yield* refreshGrantsSent, "the refresh was attempted once").toBe(1); + + // THE guarantee for this half: the grant is still good, so every later + // use tries again. A classifier that read 5xx as permanent would leave + // this at 1 and strand a healthy connection on a transient outage. + for (const [index, call] of [yield* callTool(), yield* callTool()].entries()) { + expectFailed(call, `follow-up call ${index + 1} still failed`); + expectNoReconnectDemanded(call, `follow-up call ${index + 1}`); + } + expect( + yield* refreshGrantsSent, + "each later use retries the refresh rather than giving up on the grant", + ).toBe(3); + + const health = yield* connectionHealth; + expect( + health.status, + "the connection is not marked expired on a transient failure", + ).not.toBe("expired"); + }), + ), +); diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 2ce9a39227..e2e806facf 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -164,6 +164,8 @@ import { collectReferencedDefinitions } from "./schema-refs"; import { refreshAccessToken, exchangeClientCredentials, + isPermanentTokenRejection, + isUnusableSuccessTokenResponse, shouldRefreshToken, type OAuth2TokenResponse, type OAuthEndpointUrlPolicy, @@ -2202,11 +2204,9 @@ export const createExecutor = { }), ), ); + + // Every refusal the classifier can be handed as an HTTP RESPONSE — a + // text/plain 400, a text/plain 404, a 200 carrying an error body, a 200 with + // no usable token, a 5xx — is covered black-box by + // `e2e/scenarios/oauth-refresh-rejected-non-json.test.ts`, where the test + // authorization server can actually emit those bytes. The one shape no + // authorization server can emit is *no answer at all*, so this case stays + // here: it is the boundary between "the server said no" (permanent) and "we + // never got an answer" (retryable), and only a dead socket expresses it. + it.effect("a transport failure stays transient and carries no status", () => + Effect.gen(function* () { + const error = yield* Effect.flip( + refreshAccessToken({ + tokenUrl: "http://127.0.0.1:1/token", + clientId: "cid", + refreshToken: "old", + timeoutMs: 100, + }), + ); + expect(error.status).toBeUndefined(); + expect(isPermanentTokenRejection(error)).toBe(false); + }), + ); }); describe("shouldRefreshToken", () => { diff --git a/packages/core/sdk/src/oauth-helpers.ts b/packages/core/sdk/src/oauth-helpers.ts index 68622f2546..207914702d 100644 --- a/packages/core/sdk/src/oauth-helpers.ts +++ b/packages/core/sdk/src/oauth-helpers.ts @@ -34,9 +34,46 @@ export class OAuth2Error extends Data.TaggedError("OAuth2Error")<{ * the AS no longer honours → re-auth required) from transient ones. */ readonly error?: string; + /** + * HTTP status the token endpoint answered with, when this failure came from + * a complete HTTP response at all. Absent for transport failures (DNS, TLS, + * timeout, reset) — which is precisely what separates "the server said no" + * from "we never got an answer", a distinction `error` cannot express + * because the majority of real refusals carry no RFC 6749 §5.2 code. + */ + readonly status?: number; readonly cause?: unknown; }> {} +/** + * The token endpoint answered 2xx and still handed back no usable access token. + * Whatever verdict such a body carries is about THIS grant rather than the app + * registration: an authorization server that reports a real error inside a + * response it called successful is naming a dead credential (GitHub answers a + * dead refresh token with HTTP 200 and `{"error":"bad_refresh_token"}`). On a + * 4xx the §5.2 code alone decides, so a fleet-wide `invalid_client` is never + * mistaken for one user's dead grant. + */ +export const isUnusableSuccessTokenResponse = (error: OAuth2Error): boolean => + error.status !== undefined && error.status < 300; + +/** + * Did the token endpoint answer in a way that re-sending the identical grant + * cannot change? + * + * Yes for a 4xx — §5.2 mandates 400 for a grant the authorization server will + * not honour, 401/403 are refusals, and a token endpoint answering 404 does not + * start existing on the next attempt — and yes for a 2xx that carried no usable + * token, because the server called it a success and still issued nothing. + * + * No for a 5xx (the AS is having a bad minute) and no when there is no response + * at all (transport). Those are exactly the failures a later attempt survives, + * so they must stay retryable. + */ +export const isPermanentTokenRejection = (error: OAuth2Error): boolean => + isUnusableSuccessTokenResponse(error) || + (error.status !== undefined && error.status >= 400 && error.status < 500); + // --------------------------------------------------------------------------- // Token response shape (RFC 6749 §5.1) // --------------------------------------------------------------------------- @@ -302,6 +339,26 @@ const responseFromOAuthErrorCause = (cause: unknown): Response | undefined => { return undefined; }; +/** oauth4webapi's OTHER failure shape: when a response it already accepted as + * successful turns out not to describe a token, it throws with the ALREADY + * PARSED body as `cause.cause.body` and attaches no `Response` at all + * (`assertString(json.access_token, …, { body: json })`). Without this probe + * that whole class is invisible — no status, no body, no verdict — which is + * how a GitHub-style `HTTP 200 {"error":"bad_refresh_token"}` reached + * classification as an unreadable parse failure. */ +const parsedBodyFromOAuthErrorCause = (cause: unknown): unknown => { + if (typeof cause !== "object" || cause === null) return undefined; + const inner = (cause as { readonly cause?: unknown }).cause; + if (typeof inner !== "object" || inner === null || inner instanceof Response) return undefined; + return (inner as { readonly body?: unknown }).body; +}; + +/** The status such a parsed-body failure came from. oauth4webapi only reaches + * the body asserts AFTER `checkOAuthBodyError` confirmed the exact expected + * status, which at the token endpoint is 200 — so the status is known even + * though the Response itself never made it into the error. */ +const PARSED_BODY_CAUSE_STATUS = 200; + const redactTokenEndpointBody = (body: string): string => body .replaceAll( @@ -356,13 +413,29 @@ const safeJsonFromResponse = async (response: Response): Promise => { return text === null ? undefined : safeJson(text); }; -const bodyPreviewFromResponse = async (response: Response): Promise => { - const text = (await safeBodyText(() => response.clone().text()))?.trim() ?? ""; +/** A bounded, secret-free rendering of an upstream body, for the failure + * message and thereby for telemetry. */ +const redactedBodyPreview = (body: string): string | undefined => { + const text = body.trim(); if (!text) return undefined; const redacted = redactTokenEndpointBody(text.replaceAll(/\s+/g, " ")); return redacted.length > 500 ? `${redacted.slice(0, 500)}...` : redacted; }; +const bodyPreviewFromResponse = async (response: Response): Promise => + redactedBodyPreview((await safeBodyText(() => response.clone().text())) ?? ""); + +/** Render an already-parsed body back to text for the preview. A value that + * cannot be serialised simply has no preview — never a thrown defect. */ +const safeStringify = (value: unknown): string => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: previewing an untrusted upstream body; an unserialisable value means "no preview" + try { + return JSON.stringify(value) ?? ""; + } catch { + return ""; + } +}; + // RFC 6749 §5.2's closed set. Only these are ever recovered from a // non-conform body: a free-text match against an open set would let an // arbitrary error message masquerade as an AS verdict. @@ -479,7 +552,27 @@ const toOAuth2ErrorWithHttpSummary = ( if (isOAuth2Error(cause)) return Effect.succeed(cause); const base = toOAuth2Error(cause); const response = responseFromOAuthErrorCause(cause); - if (!response) return Effect.succeed(base); + if (!response) { + // No Response, but possibly a body the library already parsed off one it + // had accepted as successful. A 2xx access-token response has no legitimate + // `error` field, so a string one here is the AS naming its own verdict — + // read through the CONFORM envelope decode rather than the closed free-text + // recovery, whose closed set exists only to stop prose masquerading as a + // code and has nothing to say about a discrete field. + const parsedBody = parsedBodyFromOAuthErrorCause(cause); + if (parsedBody === undefined) return Effect.succeed(base); + const envelope = Option.getOrUndefined(decodeTokenErrorEnvelope(parsedBody)); + const preview = redactedBodyPreview(safeStringify(parsedBody)); + const summary = [`HTTP ${PARSED_BODY_CAUSE_STATUS}`, ...(preview ? [`body: ${preview}`] : [])]; + return Effect.succeed( + new OAuth2Error({ + message: `${options?.fallbackMessage ?? base.message} (${summary.join("; ")})`, + error: base.error ?? envelope?.error, + status: PARSED_BODY_CAUSE_STATUS, + cause, + }), + ); + } return Effect.promise(async () => { const summary = await tokenEndpointHttpSummary(response); // A 4xx the spec parser refused may still carry the AS's verdict in its @@ -500,6 +593,10 @@ const toOAuth2ErrorWithHttpSummary = ( return new OAuth2Error({ message: `${described} (${summary})`, error: base.error ?? recovered?.code, + // Carried even when no code was recovered: the status is what tells a + // caller whether the AS refused (4xx — permanent, stop) or stumbled (5xx + // — retry). Most real refusals arrive with no code at all. + status: response.status, cause, }); }); diff --git a/packages/core/sdk/src/testing/oauth-test-server.ts b/packages/core/sdk/src/testing/oauth-test-server.ts index c6a0d73ee4..088c36f1a8 100644 --- a/packages/core/sdk/src/testing/oauth-test-server.ts +++ b/packages/core/sdk/src/testing/oauth-test-server.ts @@ -70,6 +70,19 @@ export interface OAuthTestServerOptions { * Defaults to `invalid_grant`; set to e.g. `invalid_request` to mirror * authorization servers that reject dead refresh tokens with other codes. */ readonly invalidRefreshTokenErrorCode?: string; + /** Answer a rejected refresh-token grant with this EXACT response instead of + * the conform §5.2 JSON envelope, byte for byte. Production token endpoints + * refuse dead grants in shapes the spec never describes — a `text/plain` 400 + * ("your session has expired"), a `text/plain` 404, or a GitHub-style HTTP + * 200 whose body carries the error — and executor has to read all of them as + * the same definitive refusal. Takes precedence over + * `invalidRefreshTokenErrorCode` / `invalidRefreshTokenDescription`. */ + readonly refreshRejection?: { + readonly status: number; + /** Defaults to `text/plain; charset=utf-8`. */ + readonly contentType?: string; + readonly body: string; + }; readonly idTokenClaims?: Readonly>; readonly refreshIdTokenClaims?: Readonly>; /** Gate Dynamic Client Registration on the requested redirect URIs. When set, @@ -818,7 +831,13 @@ export const serveOAuthTestServer = ( const refreshToken = params.get("refresh_token"); const record = refreshToken ? refreshTokens.get(refreshToken) : undefined; if (!supportRefresh || !refreshToken || !record || record.clientId !== clientId) { - return oauthError(400, invalidRefreshTokenErrorCode, invalidRefreshTokenDescription); + const rejection = options.refreshRejection; + return rejection + ? HttpServerResponse.text(rejection.body, { + status: rejection.status, + contentType: rejection.contentType ?? "text/plain; charset=utf-8", + }) + : oauthError(400, invalidRefreshTokenErrorCode, invalidRefreshTokenDescription); } const nextAccessToken = `at_${randomUUID()}`; const nextRefreshToken = `rt_${randomUUID()}`; From 61b3d9a02a7144f096e8efe0ab188c246e8b0f9d Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:09:17 -0700 Subject: [PATCH 125/133] Provision Autumn customers for orgs and self-heal customer_not_found (#1780) * Provision Autumn customers for orgs and self-heal customer_not_found * Pin the customer repair as bounded and attribute billing attempts per org --- .../account/org-api-key-revoke.node.test.ts | 1 + apps/cloud/src/auth/handlers.ts | 27 +- apps/cloud/src/extensions/billing/service.ts | 105 ++++++- .../billing-customer-provisioning.test.ts | 281 ++++++++++++++++++ e2e/src/surfaces/autumn.ts | 50 +++- 5 files changed, 446 insertions(+), 18 deletions(-) create mode 100644 e2e/cloud/billing-customer-provisioning.test.ts diff --git a/apps/cloud/src/account/org-api-key-revoke.node.test.ts b/apps/cloud/src/account/org-api-key-revoke.node.test.ts index 97a3816bad..0de1c64d22 100644 --- a/apps/cloud/src/account/org-api-key-revoke.node.test.ts +++ b/apps/cloud/src/account/org-api-key-revoke.node.test.ts @@ -108,6 +108,7 @@ const stubUsers = Layer.succeed(UserStoreService)({ const stubAutumn = Layer.succeed(AutumnService)({ use: () => Effect.die("revoke does not touch billing"), + ensureCustomer: () => Effect.die("revoke does not touch billing"), checkExecutionBalance: () => Effect.die("revoke does not touch billing"), trackExecution: () => Effect.void, }); diff --git a/apps/cloud/src/auth/handlers.ts b/apps/cloud/src/auth/handlers.ts index 03b44f5a05..05bc5ec178 100644 --- a/apps/cloud/src/auth/handlers.ts +++ b/apps/cloud/src/auth/handlers.ts @@ -22,6 +22,7 @@ import { env } from "cloudflare:workers"; import { WorkOSError } from "./errors"; import { WorkOSClient } from "./workos"; import { AutumnService } from "../extensions/billing/service"; +import { captureCauseEffect } from "../observability"; import { hasPaidOrganizationSubscription, isOverFreeOrganizationLimit, @@ -415,7 +416,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( ), { concurrency: 3 }, ).pipe( - Effect.catchTag("AutumnError", () => Effect.fail(new WorkOSError())), + // Any Autumn failure here (outage or missing customer) leaves the + // paid/free split unknown, and the limit must fail closed. + Effect.mapError(() => new WorkOSError()), Effect.map((ids) => new Set(ids.filter(Predicate.isNotNull))), ); @@ -431,6 +434,24 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( s.upsertOrganization({ id: org.id, name: org.name }), ); + // Provision the org's billing customer while we're the ones creating + // the org. Without this the first billing call an org ever makes is a + // non-creating one (balance check / usage track), which 404s and keeps + // 404ing — unlimited unbilled executions. Non-fatal: a billing blip + // must not block signup, and the billing seam heals a customer that + // is still missing later. + yield* autumn.ensureCustomer(org.id).pipe( + Effect.catch((error) => + Effect.gen(function* () { + yield* Effect.logWarning( + "createOrganization: could not provision the Autumn customer", + { organizationId: org.id, error }, + ); + yield* captureCauseEffect(error); + }), + ), + ); + // Try to attach the new org to the current session. This can fail // (or silently return a session still scoped to the old org) when // the caller's current session is stale — most commonly after the @@ -515,7 +536,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( yield* autumn .use((client) => client.customers.delete({ customerId: organizationId })) .pipe( - Effect.catchTag("AutumnError", (error) => + // Includes the "customer never existed" answer: nothing to cancel + // is a fine outcome for a deleted org, and it is still worth a line. + Effect.catch((error) => Effect.logWarning("deleteOrganization: failed to delete Autumn customer", { organizationId, error, diff --git a/apps/cloud/src/extensions/billing/service.ts b/apps/cloud/src/extensions/billing/service.ts index 1b33b203c6..e96c8ed84d 100644 --- a/apps/cloud/src/extensions/billing/service.ts +++ b/apps/cloud/src/extensions/billing/service.ts @@ -17,15 +17,61 @@ export class AutumnError extends Data.TaggedError("AutumnError")<{ cause?: unknown; }> {} +/** + * Autumn has no customer record for the organization. Split out from + * `AutumnError` because it is not an outage and must not be treated like one: + * an outage is transient and the right answer is to fail open and page, while + * a missing customer is a PERMANENT provisioning gap — every subsequent + * balance call 404s the same way, so the org runs unbilled and unmetered + * forever. Callers that own an organization id repair it (see + * `withProvisionedCustomer`); everything else still surfaces as `AutumnError`. + */ +export class AutumnCustomerNotFoundError extends Data.TaggedError("AutumnCustomerNotFoundError")<{ + message: string; + cause?: unknown; +}> {} + +export type AutumnFailure = AutumnError | AutumnCustomerNotFoundError; + +// Autumn's own error code for "no such customer", carried in the JSON body of +// a 404 (`{"message":"Customer not found","code":"customer_not_found"}`). +const CUSTOMER_NOT_FOUND_CODE = "customer_not_found"; + +/** + * True when `cause` is Autumn's "no such customer" answer. The autumn-js SDK + * throws an `AutumnError` carrying the raw HTTP `statusCode` and `body`; match + * on the code rather than the status alone so an unrelated 404 (a removed + * endpoint, a proxy) is still reported as a genuine failure. + */ +const isCustomerNotFoundCause = (cause: unknown): boolean => { + if (typeof cause !== "object" || cause === null) return false; + const { statusCode, body } = cause as { readonly statusCode?: unknown; readonly body?: unknown }; + if (statusCode !== 404 || typeof body !== "string") return false; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: classifying a third-party SDK's raw response body; a body that isn't JSON simply isn't this error + try { + // oxlint-disable-next-line executor/no-json-parse -- boundary: the autumn-js SDK hands back the response body as an unvalidated string + const parsed: unknown = JSON.parse(body); + if (typeof parsed !== "object" || parsed === null) return false; + return (parsed as { readonly code?: unknown }).code === CUSTOMER_NOT_FOUND_CODE; + } catch { + return false; + } +}; + // --------------------------------------------------------------------------- // Service interface // --------------------------------------------------------------------------- export type IAutumnService = Readonly<{ - use: (fn: (client: Autumn) => Promise) => Effect.Effect; + use: (fn: (client: Autumn) => Promise) => Effect.Effect; + /** + * Provision the organization's Autumn customer, creating it if Autumn has + * never seen it. Idempotent — safe to call on every org creation. + */ + ensureCustomer: (organizationId: string) => Effect.Effect; checkExecutionBalance: ( organizationId: string, - ) => Effect.Effect<{ readonly allowed: boolean }, AutumnError, never>; + ) => Effect.Effect<{ readonly allowed: boolean }, AutumnFailure, never>; /** * Fire-and-forget-safe execution usage tracker. Errors are caught and * logged; the returned Effect never fails. Callers typically @@ -48,6 +94,7 @@ const make = Effect.sync(() => { ); return { use: () => notConfigured, + ensureCustomer: () => notConfigured, checkExecutionBalance: () => notConfigured, trackExecution: () => Effect.void, } satisfies IAutumnService; @@ -62,16 +109,53 @@ const make = Effect.sync(() => { const use = (fn: (client: Autumn) => Promise) => Effect.tryPromise({ try: () => fn(client), - catch: (cause) => new AutumnError({ message: "Autumn SDK request failed", cause }), - }).pipe(Effect.withSpan(`autumn.${fn.name ?? "use"}`)); + catch: (cause): AutumnFailure => + isCustomerNotFoundCause(cause) + ? new AutumnCustomerNotFoundError({ + message: "Autumn has no customer for this organization", + cause, + }) + : new AutumnError({ message: "Autumn SDK request failed", cause }), + // An inline arrow's `name` is "" — not nullish — so `??` left every + // Autumn call tracing as the bare span `autumn.`. + }).pipe(Effect.withSpan(`autumn.${fn.name || "use"}`)); + + const ensureCustomer = (organizationId: string) => + Effect.asVoid(use((c) => c.customers.getOrCreate({ customerId: organizationId }))); + + /** + * Run `operation`; if Autumn answers "no such customer", provision the + * organization's customer and run it ONCE more. + * + * This is the seam that closes the provisioning hole. Both billing paths use + * non-creating endpoints, so an organization Autumn never learned about + * 404s here forever: the balance gate fails open (correct for an outage, + * catastrophic as a steady state) and every usage track is lost. Repairing + * the customer makes the retry land the call — and a genuine Autumn outage + * still fails with `AutumnError` and still pages, unretried. + */ + const withProvisionedCustomer = ( + organizationId: string, + operation: Effect.Effect, + ) => + operation.pipe( + Effect.catchTag("AutumnCustomerNotFoundError", () => + Effect.gen(function* () { + yield* Effect.annotateCurrentSpan({ "autumn.customer.provisioned": true }); + yield* ensureCustomer(organizationId); + return yield* operation; + }), + ), + ); const trackExecution = (organizationId: string) => Effect.gen(function* () { yield* Effect.annotateCurrentSpan({ "autumn.customer.id": organizationId }); - yield* use((c) => - c.track({ customerId: organizationId, featureId: "executions", value: 1 }), + yield* withProvisionedCustomer( + organizationId, + use((c) => c.track({ customerId: organizationId, featureId: "executions", value: 1 })), ).pipe( - Effect.catchTag("AutumnError", (error) => + Effect.catch((error) => Effect.gen(function* () { // Silent billing data loss is worth paging on: autumn.trackExecution // is fire-and-forget so the caller doesn't handle it themselves. @@ -88,13 +172,14 @@ const make = Effect.sync(() => { const checkExecutionBalance = (organizationId: string) => Effect.gen(function* () { yield* Effect.annotateCurrentSpan({ "autumn.customer.id": organizationId }); - const check = yield* use((c) => - c.check({ customerId: organizationId, featureId: "executions" }), + const check = yield* withProvisionedCustomer( + organizationId, + use((c) => c.check({ customerId: organizationId, featureId: "executions" })), ); return { allowed: check.allowed }; }).pipe(Effect.withSpan("autumn.checkExecutionBalance")); - return { use, checkExecutionBalance, trackExecution } satisfies IAutumnService; + return { use, ensureCustomer, checkExecutionBalance, trackExecution } satisfies IAutumnService; }); export class AutumnService extends Context.Service()( diff --git a/e2e/cloud/billing-customer-provisioning.test.ts b/e2e/cloud/billing-customer-provisioning.test.ts new file mode 100644 index 0000000000..ab98cfd27d --- /dev/null +++ b/e2e/cloud/billing-customer-provisioning.test.ts @@ -0,0 +1,281 @@ +// Cloud-only (billing): an organization must EXIST as a customer at the billing +// provider, or every billing call for it answers `customer_not_found` forever. +// +// That state is invisible from the product side and catastrophic underneath it: +// the balance gate fails open (by design — a billing outage must not stop +// executions), and usage tracking is fire-and-forget, so the org runs unlimited +// executions and NONE of them reach the meter. The product looks perfectly +// healthy while every execution is unbilled and unmetered. +// +// Three guarantees are pinned here, all read from Autumn's own state rather than +// from the execution's response (which can never show any of them): +// +// 1. creating an organization provisions its billing customer up front, +// 2. if a customer is missing anyway, the billing seam heals it in place and +// the execution still lands on the meter, and +// 3. any OTHER billing failure is left alone — the repair is scoped to the +// "no such customer" answer, so a real provider failure is still reported +// rather than quietly retried away. +// +// The missing customer is produced with the emulator's fault injector — one +// 404 `customer_not_found` per billing endpoint — because the emulator, like +// real Autumn's SDK flow, otherwise auto-creates customers on contact. +import { expect } from "@effect/vitest"; +import { Effect, Schedule } from "effect"; + +import { scenario } from "../src/scenario"; +import { Autumn, Billing, Mcp, Target } from "../src/services"; +import type { Identity } from "../src/target"; + +const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label; + +/** The org the bearer is scoped to — the Autumn customer id every billing call + * is made against — read from the JWT's public claims. */ +const orgIdOf = (bearer: string): string => { + const claims = JSON.parse(Buffer.from(bearer.split(".")[1] ?? "", "base64url").toString()) as { + readonly org_id?: string; + }; + if (!claims.org_id) throw new Error("orgIdOf: bearer carries no org_id claim"); + return claims.org_id; +}; + +/** A 404 `customer_not_found`, byte-for-byte the shape Autumn answers with for + * an organization it has no customer record for. */ +const CUSTOMER_NOT_FOUND = { + status: 404, + body: { message: "Customer not found", code: "customer_not_found" }, +} as const; + +/** A 404 that is NOT a missing customer — a moved route, a proxy, a gateway. + * Autumn carries a different code, and that code is the entire safety margin + * between "provision and retry" and "quietly retry away a real failure". */ +const UNRELATED_NOT_FOUND = { + status: 404, + body: { message: "Not Found", code: "not_found" }, +} as const; + +scenario( + "Billing · creating an organization provisions it as a billing customer", + { timeout: 180_000 }, + Effect.gen(function* () { + yield* Billing; + const autumn = yield* Autumn; + const target = yield* Target; + const mcp = yield* Mcp; + + // A brand-new user creating their first organization: none of the + // opportunistic billing lookups (the over-the-free-limit check, the members + // page's seat count, the rate limiter's paid-plan exemption) are reached on + // this journey, so org creation is the only chance to provision. + const identity = yield* target.newIdentity(); + const bearer = yield* mcp.mintBearer(emailOf(identity)); + const organizationId = orgIdOf(bearer); + + const customerIds = yield* autumn.customerIds(); + expect( + customerIds, + "the new organization exists as a customer at the billing provider", + ).toContain(organizationId); + }), +); + +scenario( + "Billing · a missing billing customer is healed in place and the execution is still metered", + { timeout: 180_000 }, + Effect.gen(function* () { + yield* Billing; + const autumn = yield* Autumn; + const target = yield* Target; + const mcp = yield* Mcp; + + const identity = yield* target.newIdentity(); + const bearer = yield* mcp.mintBearer(emailOf(identity)); + const customerId = orgIdOf(bearer); + + const before = yield* autumn.usageEvents({ customerId, featureId: "executions" }); + expect(before.length, "a brand-new org starts with zero metered executions").toBe(0); + + yield* Effect.gen(function* () { + // One 404 each: the next balance check and the next usage track both + // answer "this customer does not exist". A single `times: 1` fault means + // a seam that heals and retries gets through, while one that gives up + // loses the usage permanently — exactly the production failure. + yield* autumn.armFault({ + match: { operationId: "balances.check" }, + response: CUSTOMER_NOT_FOUND, + times: 1, + }); + yield* autumn.armFault({ + match: { operationId: "balances.track" }, + response: CUSTOMER_NOT_FOUND, + times: 1, + }); + + const session = mcp.session(identity); + const result = yield* session.call("execute", { code: "return 6 * 7;" }); + + // The gate still fails open: a billing problem never blocks a customer. + expect(result.ok, "the execution runs despite the missing billing customer").toBe(true); + expect(result.text, "it returns its value").toContain("42"); + + // Both billing calls really did reach Autumn and really were rejected — + // without this the scenario could pass on a fault that never armed. The + // ledger is shared by the whole run, so attribute to THIS org: another + // scenario's faulted call must not stand in for this one's. + const checks = (yield* autumn.ledgerFor("balances.check")).filter( + (entry) => entry.customerId === customerId, + ); + expect( + checks.some((entry) => entry.faulted), + "the balance check reached Autumn and was answered customer_not_found", + ).toBe(true); + const tracks = (yield* autumn.ledgerFor("balances.track")).filter( + (entry) => entry.customerId === customerId, + ); + expect( + tracks.some((entry) => entry.faulted), + "the usage track reached Autumn and was answered customer_not_found", + ).toBe(true); + + // The guarantee: the seam provisions the customer and retries, so the + // execution lands on the meter. Before the fix this ledger stays empty + // forever — the execution ran, and nobody was ever billed for it. + const metered = yield* autumn.expectUsage({ + customerId, + featureId: "executions", + count: 1, + }); + expect(metered.length, "the execution is metered exactly once").toBe(1); + expect(metered[0]?.value, "it meters a single unit").toBe(1); + + // Healing the customer is what made the retry possible, so the customer + // must exist at the provider afterwards. + const customerIds = yield* autumn.customerIds(); + expect(customerIds, "the organization has a billing customer afterwards").toContain( + customerId, + ); + }).pipe(Effect.ensuring(autumn.clearFaults().pipe(Effect.ignore))); + }), +); + +scenario( + "Billing · an unrelated billing failure is reported, not retried away", + { timeout: 180_000 }, + Effect.gen(function* () { + yield* Billing; + const autumn = yield* Autumn; + const target = yield* Target; + const mcp = yield* Mcp; + + const identity = yield* target.newIdentity(); + const bearer = yield* mcp.mintBearer(emailOf(identity)); + const customerId = orgIdOf(bearer); + + yield* Effect.gen(function* () { + // A 404 that is not "no such customer". If the seam treated every 404 as + // a provisioning gap it would create a customer and retry, turning a real + // provider failure into a silent success — and the alert never fires. + yield* autumn.armFault({ + match: { operationId: "balances.track" }, + response: UNRELATED_NOT_FOUND, + times: 1, + }); + + const session = mcp.session(identity); + const faulted = yield* session.call("execute", { code: "return 1 + 1;" }); + expect(faulted.ok, "the execution runs — billing never blocks a customer").toBe(true); + + // A second, unfaulted execution. Its usage landing is the barrier: the + // first execution's track (and any retry of it) is already done by the + // time this one is on the meter, so the counts below are settled without + // waiting on a clock. + const clean = yield* session.call("execute", { code: "return 2 + 2;" }); + expect(clean.ok, "the follow-up execution runs too").toBe(true); + yield* autumn.expectUsage({ customerId, featureId: "executions", count: 1 }); + + const tracks = (yield* autumn.ledgerFor("balances.track")).filter( + (entry) => entry.customerId === customerId, + ); + expect( + tracks.filter((entry) => entry.faulted).length, + "the first usage track was answered with the unrelated 404", + ).toBe(1); + // Two executions, two attempts: the failed one was reported and dropped, + // not repaired and replayed. + expect(tracks.length, "the rejected usage track is not retried").toBe(2); + + const metered = yield* autumn.usageEvents({ customerId, featureId: "executions" }); + expect(metered.length, "only the unfaulted execution reaches the meter").toBe(1); + }).pipe(Effect.ensuring(autumn.clearFaults().pipe(Effect.ignore))); + }), +); + +scenario( + "Billing · a customer that cannot be provisioned is given up on, not retried in a loop", + { timeout: 180_000 }, + Effect.gen(function* () { + yield* Billing; + const autumn = yield* Autumn; + const target = yield* Target; + const mcp = yield* Mcp; + + const identity = yield* target.newIdentity(); + const bearer = yield* mcp.mintBearer(emailOf(identity)); + const customerId = orgIdOf(bearer); + + // Attempts against THIS org's meter, polled until the seam has settled on + // `atLeast` of them — the ledger is the only place the retry is visible. + const trackAttempts = (atLeast: number) => + autumn.ledgerFor("balances.track").pipe( + Effect.map((entries) => entries.filter((entry) => entry.customerId === customerId)), + Effect.filterOrFail( + (entries) => entries.length >= atLeast, + (entries) => `only ${entries.length}/${atLeast} usage-track attempts for ${customerId}`, + ), + Effect.retry(Schedule.both(Schedule.spaced("250 millis"), Schedule.recurs(40))), + ); + + yield* Effect.gen(function* () { + // "No such customer" that does NOT go away when the customer is created — + // a provisioning gap the repair genuinely cannot close (a rejected id, a + // provider that never materializes the record). The seam must repair, + // retry ONCE, report, and stop: a repair loop here runs in a forked, + // untimed fibre, so an unbounded one hammers the provider forever for a + // single execution. + yield* autumn.armFault({ + match: { operationId: "balances.track" }, + response: CUSTOMER_NOT_FOUND, + times: 20, + }); + + const session = mcp.session(identity); + const stuck = yield* session.call("execute", { code: "return 7 * 6;" }); + expect(stuck.ok, "the execution runs — billing never blocks a customer").toBe(true); + expect(stuck.text, "it returns its value").toContain("42"); + + // The original attempt plus the one post-repair retry. + yield* trackAttempts(2); + + // Clearing the fault re-opens the meter, and the next execution's usage + // landing is the barrier: whatever the first execution was going to do to + // the ledger is finished by then, so the count below is settled without + // waiting on a clock. + yield* autumn.clearFaults(); + const clean = yield* session.call("execute", { code: "return 2 + 2;" }); + expect(clean.ok, "the follow-up execution runs too").toBe(true); + yield* autumn.expectUsage({ customerId, featureId: "executions", count: 1 }); + + const tracks = (yield* autumn.ledgerFor("balances.track")).filter( + (entry) => entry.customerId === customerId, + ); + expect( + tracks.filter((entry) => entry.faulted).length, + "the unrepairable customer is attempted exactly twice: once, then once after the repair", + ).toBe(2); + expect(tracks.length, "and no further attempt beyond the next execution's own").toBe(3); + + const metered = yield* autumn.usageEvents({ customerId, featureId: "executions" }); + expect(metered.length, "only the execution Autumn accepted reaches the meter").toBe(1); + }).pipe(Effect.ensuring(autumn.clearFaults().pipe(Effect.ignore))); + }), +); diff --git a/e2e/src/surfaces/autumn.ts b/e2e/src/surfaces/autumn.ts index a401cf5486..2b3c32b27b 100644 --- a/e2e/src/surfaces/autumn.ts +++ b/e2e/src/surfaces/autumn.ts @@ -51,11 +51,19 @@ export interface LedgerEntry { readonly method: string; readonly path: string; readonly faulted: boolean; + /** The customer the request was made against, read from its body. Autumn's + * ledger is shared by every scenario in the run, so attempts have to be + * attributed to one organization before they can be counted. */ + readonly customerId?: string; } export interface AutumnSurface { /** One-shot read of the matching usage events from the ledger. */ readonly usageEvents: (query: UsageQuery) => Effect.Effect; + /** Every customer id Autumn currently holds (`customers.list`). An org that + * was never provisioned as a billing customer is simply absent — the state + * in which every balance call 404s `customer_not_found` forever. */ + readonly customerIds: () => Effect.Effect; /** Poll until at least `count` matching events have landed. The track is * forked and the worker drains it on `waitUntil` shortly after the execution * returns, so arrival is eventually-consistent — polling IS the contract: @@ -129,6 +137,26 @@ export const makeAutumnSurface = (autumnUrl: string): AutumnSurface => { })); }); + const customerIds = () => + Effect.gen(function* () { + const response = yield* Effect.promise(() => + fetch(`${autumnUrl}/v1/customers.list`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }), + ); + if (!response.ok) { + return yield* Effect.fail( + `autumn customers.list responded ${response.status}: ${yield* Effect.promise(() => response.text())}`, + ); + } + const body = (yield* Effect.promise(() => response.json())) as { + readonly list?: ReadonlyArray<{ readonly id?: string }>; + }; + return (body.list ?? []).map((customer) => customer.id ?? ""); + }); + const settleCheckout = (sessionId: string) => Effect.gen(function* () { const response = yield* Effect.promise(() => @@ -232,20 +260,30 @@ export const makeAutumnSurface = (autumnUrl: string): AutumnSurface => { readonly method?: string; readonly path?: string; readonly faulted?: boolean; + readonly request?: { readonly body?: unknown }; }>; }; return (body.entries ?? []) .filter((entry) => entry.operationId === operationId) - .map((entry) => ({ - operationId: entry.operationId, - method: entry.method ?? "", - path: entry.path ?? "", - faulted: entry.faulted === true, - })); + .map((entry) => { + const requestBody = entry.request?.body; + const customerId = + typeof requestBody === "object" && requestBody !== null + ? (requestBody as { readonly customer_id?: unknown }).customer_id + : undefined; + return { + operationId: entry.operationId, + method: entry.method ?? "", + path: entry.path ?? "", + faulted: entry.faulted === true, + customerId: typeof customerId === "string" ? customerId : undefined, + }; + }); }); return { usageEvents, + customerIds, settleCheckout, exhaustExecutions, attachPlan, From e3acef7fb4449c81afed76c8cba4a5d74d34fc17 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:32:18 -0700 Subject: [PATCH 126/133] Classify Durable Object platform failures as retryable protocol errors (#1788) * Classify Durable Object platform failures as retryable protocol errors * Reproduce the session teardown race black-box in the cloud e2e suite Terminate a session while requests are already in flight instead of after they drain, so the isolate abort at the end of the teardown lands on a request the handler is holding. That reaches the unhandled 500 without the fix and passes with it. Also narrow the blockConcurrencyWhile match to the runtime's own cancellation message, so a defect thrown from inside the callback is not read as a platform reset. * Make the destroyed-session scenario reproduce the teardown race every run Stop each teardown stream on the observed post-wipe verdict instead of a fixed timer, which cuts enough wasted load to raise the crossing count and in-flight concurrency. Assert every teardown was straddled, and that a terminated session is never advertised as retryable. --- apps/cloud/src/mcp/agent-handler.ts | 81 +++- apps/cloud/src/mcp/session-durable-object.ts | 7 + apps/cloud/src/observability/index.ts | 41 +- .../src/observability/observability.test.ts | 72 ++++ .../mcp-destroyed-session-envelope.test.ts | 365 ++++++++++++++++++ packages/hosts/cloudflare/package.json | 4 + .../mcp/agent-session-durable-object.test.ts | 154 ++++++++ .../src/mcp/agent-session-durable-object.ts | 111 +++++- .../src/mcp/durable-object-errors.test.ts | 188 +++++++++ .../src/mcp/durable-object-errors.ts | 185 +++++++++ 10 files changed, 1187 insertions(+), 21 deletions(-) create mode 100644 e2e/cloud/mcp-destroyed-session-envelope.test.ts create mode 100644 packages/hosts/cloudflare/src/mcp/durable-object-errors.test.ts create mode 100644 packages/hosts/cloudflare/src/mcp/durable-object-errors.ts diff --git a/apps/cloud/src/mcp/agent-handler.ts b/apps/cloud/src/mcp/agent-handler.ts index 9d75300bfe..6d46bbd80b 100644 --- a/apps/cloud/src/mcp/agent-handler.ts +++ b/apps/cloud/src/mcp/agent-handler.ts @@ -17,6 +17,11 @@ import { withVerifiedIdentityHeaders, } from "@executor-js/cloudflare/mcp/do-headers"; import type { McpSessionProps } from "@executor-js/cloudflare/mcp/agent-durable-object"; +import { + classifyDurableObjectError, + durableObjectFailureResponse, + type DurableObjectFailure, +} from "@executor-js/cloudflare/mcp/durable-object-errors"; import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; import { wrapMcpSseResponse } from "../observability/memory-metrics"; @@ -80,6 +85,41 @@ const renderAuthError = ( }); }; +/** + * A Cloudflare *platform* Durable Object failure happened at one of this + * handler's stub touchpoints. Record what kind it was — on an exported span and + * in a structured log — so the production volume stays countable per cause + * (deploy reset vs storage timeout vs destroyed session) now that it is no + * longer a pile of 500s. + * + * Talking to a session DO means talking to a process the platform can reset out + * from under us: a deploy, a storage timeout, a backend blip, the session's own + * `ctx.abort("destroyed")`. None of those are application defects. An + * unrecognized failure never reaches here and keeps escaping as before. + */ +const recordDurableObjectFailure = ( + failure: DurableObjectFailure, + operation: string, +): Effect.Effect => + Effect.sync(() => { + console.warn( + JSON.stringify({ + event: "mcp_durable_object_platform_failure", + operation, + resetKind: failure.kind, + disposition: failure.disposition, + }), + ); + }).pipe( + Effect.withSpan("mcp.do.platform_failure", { + attributes: { + "mcp.do.reset_kind": failure.kind, + "mcp.do.reset_disposition": failure.disposition, + "mcp.do.reset_operation": operation, + }, + }), + ); + const authenticate = (request: Request) => Effect.gen(function* () { const auth = yield* McpAuthProvider; @@ -201,10 +241,27 @@ export const makeCloudMcpAgentHandler = () => { } if (sessionId) { - const owner = await mcpSessionStub(env.MCP_SESSION, sessionId).validateMcpSessionOwner({ - accountId: outcome.principal.accountId, - organizationId: outcome.principal.organizationId, - }); + let owner: "ok" | "not_found" | "forbidden" | "terminated"; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: a Durable Object stub RPC rejects with a plain platform Error, never a typed failure + try { + owner = await mcpSessionStub(env.MCP_SESSION, sessionId).validateMcpSessionOwner({ + accountId: outcome.principal.accountId, + organizationId: outcome.principal.organizationId, + }); + } catch (error) { + // The sibling stub touchpoints in this handler are both guarded — the + // `_cf_scheduleDestroy` call above with `Effect.ignore`, the + // `target.fetch` below with a catch — and this one was not, so a session + // whose DO had been destroyed or reset by the platform 500ed here before + // any of that handling could run. + const failure = classifyDurableObjectError(error); + if (!failure) { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: an unrecognized failure is a real defect and must reach the runtime unchanged + throw error; + } + await runTraced(request, recordDurableObjectFailure(failure, "validate_session_owner")); + return durableObjectFailureResponse(failure); + } if (owner === "not_found") { return jsonRpcResponse(404, -32001, "Session not found"); } @@ -244,12 +301,18 @@ export const makeCloudMcpAgentHandler = () => { // DO ever getting to answer. Map it to the old envelope's reconnect // error for a dead session (e2e/cloud/mcp-protocol.test.ts expects the // client to be told to reconnect, matching a timed-out session). - // oxlint-disable-next-line executor/no-unknown-error-message -- adapter boundary: the abort reason is a plain runtime Error whose message IS the signal - if (Predicate.isError(error) && error.message === "destroyed") { - return jsonRpcResponse(404, -32001, "Session timed out, please reconnect"); + // + // The same catch now also covers the rest of the platform's reset + // vocabulary — a deploy, a storage timeout, a cancelled + // blockConcurrencyWhile — which reaches here through the agents SDK's own + // `getServerByName` retry and used to 500 identically. + const failure = classifyDurableObjectError(error); + if (!failure) { + // 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 + throw error; } - // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: rethrow anything that isn't the condemned-DO abort to the Workers runtime unchanged - throw error; + await runTraced(request, recordDurableObjectFailure(failure, "session_fetch")); + return durableObjectFailureResponse(failure); } // The agents SDK answers a bare DELETE with 204; the old envelope's // contract (see above) was 200 — rewrite for consistency. diff --git a/apps/cloud/src/mcp/session-durable-object.ts b/apps/cloud/src/mcp/session-durable-object.ts index c272e5026c..50354810f2 100644 --- a/apps/cloud/src/mcp/session-durable-object.ts +++ b/apps/cloud/src/mcp/session-durable-object.ts @@ -71,6 +71,7 @@ import { DoTelemetryLive, flushTracerProvider } from "../observability/telemetry import { captureCause as reportCause, captureCauseEffect as reportCauseEffect, + claimCauseHandledByDurableObject, tagCurrentSentryScopeWithCurrentOtelSpan, } from "../observability"; import { parseTraceparent } from "./traceparent"; @@ -368,6 +369,12 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase): Effect.Effect { + return claimCauseHandledByDurableObject; + } + // Best-effort export the DO isolate's buffered spans after the RPC settles, // so a dying init/handleRequest can ship its own spans (and the exception + // stack recorded on them) — not just the worker-side `mcp.do.*` span. Keep it diff --git a/apps/cloud/src/observability/index.ts b/apps/cloud/src/observability/index.ts index 6d1cd7e999..a56118a6cd 100644 --- a/apps/cloud/src/observability/index.ts +++ b/apps/cloud/src/observability/index.ts @@ -30,6 +30,21 @@ export const OTEL_TRACE_ID_TAG = "otel_trace_id"; export const OTEL_SPAN_ID_TAG = "otel_span_id"; export const SENTRY_EVENT_ID_ATTRIBUTE = "sentry.event_id"; +/** + * Set by the MCP session Durable Object when it has finished deciding what to + * do about a cause — reported it, or classified it as an expected Cloudflare + * platform reset and deliberately not reported it. + * + * `instrumentDurableObjectWithSentry` wraps the DO's entry points and captures + * the same rejection again as it escapes, which is why one platform reset + * opened two issues for the same event. The DO is the better owner — it has + * the session, the org, the OTEL correlation and the classification — so its + * claim wins and the auto-instrumentation's echo is dropped in `beforeSend`. + * Nothing the DO does not claim is affected. + */ +export const DO_CAUSE_OWNER_TAG = "mcp.do.cause_owner"; +export const DO_CAUSE_OWNER_VALUE = "durable_object"; + export type OtelCorrelationContext = { readonly traceId: string; readonly spanId: string; @@ -99,10 +114,25 @@ export const tagCurrentSentryScopeWithCurrentOtelSpan: Effect.Effect { + if (event.tags?.[DO_CAUSE_OWNER_TAG] !== DO_CAUSE_OWNER_VALUE) return false; + const mechanism = event.exception?.values?.[0]?.mechanism?.type; + return typeof mechanism === "string" && mechanism.startsWith("auto."); +}; + export const beforeSendWithOtelCorrelation = ( event: ErrorEvent, options?: { readonly logPayload?: boolean }, -): ErrorEvent => { +): ErrorEvent | null => { + if (isClaimedDurableObjectEcho(event)) return null; if (options?.logPayload) { console.info( JSON.stringify({ @@ -167,6 +197,15 @@ export const captureCauseEffect = (input: unknown): Effect.Effect = Effect.sync(() => { + Sentry.getCurrentScope().setTag(DO_CAUSE_OWNER_TAG, DO_CAUSE_OWNER_VALUE); +}); + export const ErrorCaptureLive: Layer.Layer = Layer.succeed( ErrorCapture, ErrorCapture.of({ diff --git a/apps/cloud/src/observability/observability.test.ts b/apps/cloud/src/observability/observability.test.ts index b4f69a8d8e..9185cbec84 100644 --- a/apps/cloud/src/observability/observability.test.ts +++ b/apps/cloud/src/observability/observability.test.ts @@ -2,8 +2,13 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect } from "effect"; import type * as Tracer from "effect/Tracer"; +import type { ErrorEvent } from "@sentry/cloudflare"; + import { addCurrentOtelCorrelationTags, + beforeSendWithOtelCorrelation, + DO_CAUSE_OWNER_TAG, + DO_CAUSE_OWNER_VALUE, OTEL_SPAN_ID_TAG, OTEL_TRACE_ID_TAG, sentryPayloadForCause, @@ -91,3 +96,70 @@ describe("Sentry OTel correlation", () => { }).pipe(Effect.withSpan("test.sentry_capture"), Effect.withTracer(makeFixedTracer())), ); }); + +// One Durable Object failure used to open two Sentry issues: the DO's own +// `captureCause` seam reported it (mechanism `generic`), and then +// `instrumentDurableObjectWithSentry` reported the very same rejection again as +// it escaped the method (mechanism `auto.faas.cloudflare.durable_object`). The +// DO is the owner — it has the session, the classification and the OTel +// correlation — so its claim suppresses the echo and nothing else. +describe("Durable Object capture ownership", () => { + const doEcho = (overrides: Partial = {}): ErrorEvent => ({ + type: undefined, + tags: { [DO_CAUSE_OWNER_TAG]: DO_CAUSE_OWNER_VALUE }, + exception: { + values: [ + { + type: "Error", + value: "Durable Object reset because its code was updated.", + mechanism: { type: "auto.faas.cloudflare.durable_object", handled: false }, + }, + ], + }, + ...overrides, + }); + + it("drops the auto-instrumentation's copy of a cause the DO already claimed", () => { + expect(beforeSendWithOtelCorrelation(doEcho())).toBeNull(); + }); + + it("keeps the DO's own report, which carries no auto mechanism", () => { + const own = doEcho({ + exception: { + values: [ + { + type: "Error", + value: "Durable Object reset because its code was updated.", + mechanism: { type: "generic", handled: true }, + }, + ], + }, + }); + expect(beforeSendWithOtelCorrelation(own)).not.toBeNull(); + }); + + // An alarm crash or a transport fault is never claimed by the DO seam, and + // the auto-instrumentation is the ONLY thing that reports it. Dropping those + // would trade duplicate noise for silence. + it("keeps an unclaimed Durable Object failure", () => { + const unclaimed = doEcho({ tags: {} }); + expect(beforeSendWithOtelCorrelation(unclaimed)).not.toBeNull(); + }); + + it("keeps ordinary worker events untouched", () => { + const workerEvent: ErrorEvent = { + type: undefined, + tags: { [OTEL_TRACE_ID_TAG]: traceId }, + exception: { + values: [ + { + type: "TypeError", + value: "x is not a function", + mechanism: { type: "auto.http.cloudflare", handled: false }, + }, + ], + }, + }; + expect(beforeSendWithOtelCorrelation(workerEvent)).not.toBeNull(); + }); +}); diff --git a/e2e/cloud/mcp-destroyed-session-envelope.test.ts b/e2e/cloud/mcp-destroyed-session-envelope.test.ts new file mode 100644 index 0000000000..c461fc0359 --- /dev/null +++ b/e2e/cloud/mcp-destroyed-session-envelope.test.ts @@ -0,0 +1,365 @@ +// Cloud: a session id must keep answering in the MCP protocol's own vocabulary +// for the whole time its Durable Object is being torn down — not just in the +// first instant. +// +// `DELETE /mcp` condemns the session object with a durable marker and defers +// the real teardown to an immediate alarm; that alarm wipes the object's +// storage and then aborts the isolate. e2e/cloud/mcp-protocol.test.ts covers +// the calm case (terminate, then ask again, get a 404 reconnect). This scenario +// covers the violent middle of it: a client whose requests are ALREADY IN +// FLIGHT when the termination lands, which is what a real client with an open +// tool loop looks like when a session ends underneath it. +// +// Every one of those in-flight requests must come back as a well-formed +// JSON-RPC error — 404 (this id is dead, reconnect) or 503 (the object is +// restarting, retry the same id, here is how long to wait) — and never as a +// bare unhandled 500 from a platform failure escaping the request handler. +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; +import { Mcp, Target } from "../src/services"; +import type { Identity } from "../src/target"; + +const JSON_AND_SSE = "application/json, text/event-stream"; +const PROTOCOL_VERSION = "2025-03-26"; + +const INITIALIZE_REQUEST = { + jsonrpc: "2.0" as const, + id: 1, + method: "initialize", + params: { + protocolVersion: PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: "executor-e2e-destroyed-session-envelope", version: "0.0.1" }, + }, +}; + +const INITIALIZED_NOTIFICATION = { + jsonrpc: "2.0" as const, + method: "notifications/initialized", +}; + +const TOOLS_LIST_REQUEST = { + jsonrpc: "2.0" as const, + id: 2, + method: "tools/list", + params: {}, +}; + +const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label; + +const mcpPost = ( + url: string | URL, + init: { readonly bearer: string; readonly sessionId?: string; readonly body: unknown }, +): Promise => + fetch(url, { + method: "POST", + headers: { + accept: JSON_AND_SSE, + "content-type": "application/json", + authorization: `Bearer ${init.bearer}`, + ...(init.sessionId ? { "mcp-session-id": init.sessionId } : {}), + }, + body: JSON.stringify(init.body), + }); + +const openSession = async (mcpUrl: string, bearer: string): Promise => { + const initialize = await mcpPost(mcpUrl, { bearer, body: INITIALIZE_REQUEST }); + const sessionId = initialize.headers.get("mcp-session-id"); + await initialize.text(); + if (initialize.status !== 200 || !sessionId) { + throw new Error(`openSession: initialize failed (${initialize.status})`); + } + const initialized = await mcpPost(mcpUrl, { bearer, sessionId, body: INITIALIZED_NOTIFICATION }); + await initialized.text(); + if (initialized.status !== 202) { + throw new Error(`openSession: notifications/initialized failed (${initialized.status})`); + } + return sessionId; +}; + +type Probe = { + readonly atMs: number; + readonly status: number; + readonly body: string; + readonly retryAfter: string | null; +}; + +/** One JSON-RPC error shape, or null when the body is not the protocol envelope. */ +const jsonRpcError = (body: string): { readonly code: number; readonly message: string } | null => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- test helper: a non-JSON body is itself the signal we assert on + try { + const parsed = JSON.parse(body) as { + readonly jsonrpc?: string; + readonly error?: { readonly code?: number; readonly message?: string }; + }; + if (parsed.jsonrpc !== "2.0" || typeof parsed.error?.code !== "number") return null; + return { code: parsed.error.code, message: parsed.error.message ?? "" }; + } catch { + return null; + } +}; + +/** + * The verdict a request gets once the destroy alarm has wiped the session's + * storage — i.e. the teardown this scenario is timing itself against is over. + * Matched on the protocol answer, not on any server internal. + */ +const isWipedVerdict = (probe: Probe): boolean => + probe.status === 404 && jsonRpcError(probe.body)?.message === "Session not found"; + +const describeProbe = (probe: Probe): string => + `+${probe.atMs}ms ${probe.status} ${probe.body.slice(0, 200)}`; + +scenario( + "MCP protocol · in-flight requests survive their session's teardown as protocol errors", + { timeout: 180_000 }, + Effect.gen(function* () { + const target = yield* Target; + const mcp = yield* Mcp; + + // The fatal instant is the isolate abort at the end of the teardown, and a + // session only crosses it once — so cross it many times. A single crossing + // leaks the unhandled 500 only when a `validateMcpSessionOwner` RPC is + // inside the object at exactly that instant, so the scenario has to make + // that likely rather than hope for it: `sessions` crossings, each blanketed + // by `concurrency` requests. At 6 × 8 the pre-fix bug reproduced in only + // about half of runs; the numbers below are the measured point where it + // reproduces every run without pushing the shared auth path into 403s. + const sessions = 12; + // Requests are kept continuously in flight rather than polled on a timer: + // the point is to have work ALREADY inside the session object when the + // termination lands, not to sample the window from outside it. + const concurrency = 24; + // Ceiling, not the plan: the stream normally stops as soon as the teardown + // is OBSERVED to have completed (below). This only bounds a teardown whose + // alarm never lands, so a hung platform fails the scenario instead of + // hanging it. + const streamAfterDeleteMs = 3_000; + // Once the storage wipe is visible the abort has already happened, so the + // window this scenario exists to cover is behind us. Keep going briefly — + // the abort and the wipe are not the same instant — then stop, because + // every further request is pure load on the shared auth path. + const graceAfterWipeMs = 150; + // Enough in-flight requests to be mid-teardown, without racing the DELETE + // itself before the session is fully established. + const warmupMs = 250; + + const probes: Probe[] = []; + // Per teardown, so the scenario can PROVE each crossing was straddled + // rather than assume it. + const perSession: Probe[][] = []; + + // One identity per teardown, all minted BEFORE any load starts. Two + // reasons, both about keeping this scenario's traffic off the shared auth + // path: a single identity carrying every teardown's requests degrades the + // org-membership lookup into a 403, and signing new users in while the + // probe stream is running fails the sign-in itself. Neither has anything to + // do with what is being tested here. + const bearers: string[] = []; + for (let index = 0; index < sessions; index += 1) { + const identity = yield* target.newIdentity(); + bearers.push(yield* mcp.mintBearer(emailOf(identity))); + } + + for (const bearer of bearers) { + const sessionId = yield* Effect.promise(() => openSession(target.mcpUrl, bearer)); + + const thisSession: Probe[] = []; + perSession.push(thisSession); + + yield* Effect.promise(async () => { + const startedAt = Date.now(); + let hardStopAt = Number.POSITIVE_INFINITY; + // Set the first time the wiped-storage verdict is seen, which is the + // observable end of the teardown — the stream stops on this, not a + // timer. + let stopAfterWipeAt = Number.POSITIVE_INFINITY; + + const probeOnce = async (): Promise => { + const at = Date.now() - startedAt; + const response = await mcpPost(target.mcpUrl, { + bearer, + sessionId, + body: TOOLS_LIST_REQUEST, + }); + const probe: Probe = { + atMs: at, + status: response.status, + body: await response.text(), + retryAfter: response.headers.get("retry-after"), + }; + probes.push(probe); + thisSession.push(probe); + // "Session not found" is the post-wipe verdict: the destroy alarm has + // run, storage is gone, and the object has been aborted. Anything + // after this point is a request against an already-dead id. + if (isWipedVerdict(probe) && stopAfterWipeAt === Number.POSITIVE_INFINITY) { + stopAfterWipeAt = Date.now() + graceAfterWipeMs; + } + }; + + // One worker replenishes its request the moment the previous one + // settles, so the session object is never idle and the DELETE has to + // land on top of real traffic. + const worker = async (): Promise => { + while (Date.now() < hardStopAt && Date.now() < stopAfterWipeAt) await probeOnce(); + }; + const workers = Array.from({ length: concurrency }, () => worker()); + + await new Promise((resolve) => setTimeout(resolve, warmupMs)); + // Terminate WITHOUT draining the stream: this is the whole scenario. + const terminate = await fetch(target.mcpUrl, { + method: "DELETE", + headers: { authorization: `Bearer ${bearer}`, "mcp-session-id": sessionId }, + }); + await terminate.text(); + expect(terminate.status, "the client can terminate its session").toBe(200); + + hardStopAt = Date.now() + streamAfterDeleteMs; + await Promise.all(workers); + }); + } + + // What the dying session actually answered, so a reviewer can see the shape + // of the teardown window and not just the verdict. + const bucket = new Map(); + for (const probe of probes) { + const key = `${probe.status} ${jsonRpcError(probe.body)?.message ?? probe.body.slice(0, 80)}`; + bucket.set(key, (bucket.get(key) ?? 0) + 1); + } + console.info( + `[destroyed-session-envelope] ${probes.length} probes across ${sessions} teardowns: ${[ + ...bucket, + ] + .map(([key, count]) => `${count}× ${key}`) + .join(" | ")}`, + ); + + expect(probes.length, "the stream actually exercised the teardown").toBeGreaterThan(sessions); + + const unhandled = probes.filter((probe) => probe.status >= 500 && probe.status !== 503); + expect( + unhandled.map(describeProbe), + "no request on a terminating session produces an unhandled server error", + ).toEqual([]); + + // Only rejections are asserted on: a request the session still served + // answers 200 over SSE, which is not a JSON-RPC error body and not what + // this scenario is about. + const malformed = probes.filter( + (probe) => probe.status !== 200 && jsonRpcError(probe.body) === null, + ); + expect( + malformed.map(describeProbe), + "every rejection is a JSON-RPC error envelope the client can parse", + ).toEqual([]); + + // Coverage precondition, checked per teardown rather than in aggregate: a + // run in which some session's stream finished before its object was torn + // down never entered the window this scenario exists to cover, and its + // green is worth nothing. Requiring EVERY crossing to have been observed is + // what stops the assertions above from passing vacuously. + const notStraddled = perSession + .map((session, index) => ({ index, wiped: session.some(isWipedVerdict) })) + .filter((entry) => !entry.wiped) + .map((entry) => `teardown #${entry.index + 1}`); + expect( + notStraddled, + "every teardown was still under load when its session object was destroyed", + ).toEqual([]); + + // The disposition half of the contract, on the one failure this scenario + // can actually produce. Every platform failure reachable here is the + // session's own `ctx.abort` after a DELETE the client itself sent, and that + // id is dead for good — so the answer has to be "reconnect", never + // "restarting, retry". A retryable verdict for a deliberately terminated + // session is worse than the 500 this PR removed: the client is told to keep + // asking, and the loop never ends. + const deadIdCalledRetryable = probes.filter((probe) => probe.status === 503); + expect( + deadIdCalledRetryable.map(describeProbe), + "a session the client terminated is never advertised as retryable", + ).toEqual([]); + + // A 503 here means "the platform is mid-reset, come back". It is only + // actionable if the client is told how long to wait, so it must carry + // Retry-After AND a positive delay — a `retry-after: 0`, or a header the + // renderer dropped, is the same dead end as no answer at all. + const badBackoff = probes.filter((probe) => { + if (probe.status !== 503) return false; + const seconds = probe.retryAfter === null ? null : Number(probe.retryAfter); + return seconds === null || !Number.isFinite(seconds) || seconds <= 0; + }); + expect( + badBackoff.map(describeProbe), + "every retryable rejection tells the client how long to back off", + ).toEqual([]); + + // The other half of the classifier's contract, and the half a teardown + // cannot demonstrate: a DEFINITIVE refusal must stay definitive. If the + // platform-failure path ever widens far enough to swallow ordinary + // rejections, these are the two answers that would silently turn into "the + // session is restarting, retry" and send a client into a loop it can never + // exit. + const liveIdentity = yield* target.newIdentity(); + const liveBearer = yield* mcp.mintBearer(emailOf(liveIdentity)); + const liveSessionId = yield* Effect.promise(() => openSession(target.mcpUrl, liveBearer)); + + const strangerIdentity = yield* target.newIdentity(); + const strangerBearer = yield* mcp.mintBearer(emailOf(strangerIdentity)); + + yield* Effect.gen(function* () { + const hijack = yield* Effect.promise(() => + mcpPost(target.mcpUrl, { + bearer: strangerBearer, + sessionId: liveSessionId, + body: TOOLS_LIST_REQUEST, + }), + ); + const hijackBody = yield* Effect.promise(() => hijack.text()); + expect( + hijack.status, + "another account's session id is refused outright, not advertised as retryable", + ).toBe(403); + expect(hijack.headers.get("retry-after"), "a refusal carries no backoff advice").toBeNull(); + expect(jsonRpcError(hijackBody)?.code, "the refusal is the session-ownership error").toBe( + -32003, + ); + + const unauthenticated = yield* Effect.promise(() => + fetch(target.mcpUrl, { + method: "POST", + headers: { accept: JSON_AND_SSE, "content-type": "application/json" }, + body: JSON.stringify(TOOLS_LIST_REQUEST), + }), + ); + yield* Effect.promise(() => unauthenticated.text()); + expect( + unauthenticated.status, + "a missing credential is refused outright, not advertised as retryable", + ).toBe(401); + expect( + unauthenticated.headers.get("retry-after"), + "a refusal carries no backoff advice", + ).toBeNull(); + }).pipe( + // Runs even when an assertion above fails, so the extra session never + // outlives the scenario. `tryPromise` so a refused cleanup is a failure + // `ignore` can actually swallow — cleanup must not mask the real verdict. + Effect.ensuring( + Effect.tryPromise(async () => { + const closed = await fetch(target.mcpUrl, { + method: "DELETE", + headers: { + authorization: `Bearer ${liveBearer}`, + "mcp-session-id": liveSessionId, + }, + }); + await closed.text(); + }).pipe(Effect.ignore), + ), + ); + }), +); diff --git a/packages/hosts/cloudflare/package.json b/packages/hosts/cloudflare/package.json index f6a2927597..1503f31fcb 100644 --- a/packages/hosts/cloudflare/package.json +++ b/packages/hosts/cloudflare/package.json @@ -16,6 +16,10 @@ "types": "./src/mcp/agent-session-durable-object.ts", "default": "./src/mcp/agent-session-durable-object.ts" }, + "./mcp/durable-object-errors": { + "types": "./src/mcp/durable-object-errors.ts", + "default": "./src/mcp/durable-object-errors.ts" + }, "./mcp/execution-owner-directory": { "types": "./src/mcp/execution-owner-directory.ts", "default": "./src/mcp/execution-owner-directory.ts" diff --git a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts index 8072df7e54..125b3990d3 100644 --- a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts +++ b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts @@ -1,3 +1,4 @@ +// 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 import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect } from "effect"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; @@ -483,3 +484,156 @@ describe("McpAgentSessionDOBase transport restore", () => { expect(restoredEngine.calls).toEqual([{ executionId: "exec-model", response: approval }]); }); }); + +// Every Cloudflare deploy resets live Durable Objects: workerd aborts whatever +// storage operation is in flight with "Durable Object reset because its code was +// updated." That is the guaranteed consequence of shipping, not a defect — but +// it lands on whichever write `init()` happens to be doing, and the last thing +// `init()` does is `markActivity`, which writes a timestamp and arms the idle +// alarm. Nothing about a session depends on that write succeeding: the in-memory +// clock is already set, and every later touch re-arms the alarm. Losing a fully +// built, working session over it — and paging for the privilege — is the bug. +describe("McpAgentSessionDOBase init survives a platform reset of its bookkeeping write", () => { + const CODE_UPDATE_RESET = "Durable Object reset because its code was updated."; + + class ResettingStorage extends MemoryStorage { + /** Storage keys whose `put` should fail, and with what. */ + readonly putFailures = new Map Error>(); + setAlarmFailure: (() => Error) | null = null; + + override async put(key: string, value: unknown): Promise { + const failure = this.putFailures.get(key); + if (failure) { + this.putFailures.delete(key); + throw failure(); + } + await super.put(key, value); + } + + override async setAlarm(time: number | Date): Promise { + if (this.setAlarmFailure) { + const failure = this.setAlarmFailure; + this.setAlarmFailure = null; + throw failure(); + } + await super.setAlarm(time); + } + } + + type InitSession = { + ctx: ResettingStorage; + captureCause: (cause: Cause.Cause) => void; + dbHandle: { readonly end: () => void } | null; + engine: ExecutionEngine | null; + getSessionId: () => string; + init: () => Promise; + initialized: boolean; + lastActivityMs: number; + pendingApprovalLeases: Map; + props: Record; + server?: McpServer; + sessionTimeoutMs: () => number; + buildMcpServer: () => Effect.Effect<{ mcpServer: McpServer; engine: unknown }>; + openSessionDb: () => { readonly end: () => void }; + resolveSessionMeta: () => Effect.Effect; + validateMcpSessionOwner: (identity: McpApprovalOwner) => Promise; + }; + + const sessionMeta: SessionMeta = { + organizationId: "org-1", + organizationName: "Org 1", + userId: "user-1", + resource: defaultMcpResource, + }; + + const makeInitSession = (): { + session: InitSession; + storage: ResettingStorage; + captured: Cause.Cause[]; + } => { + const storage = new ResettingStorage(); + const captured: Cause.Cause[] = []; + const session = Object.create(McpAgentSessionDOBase.prototype) as InitSession; + session.ctx = storage; + session.captureCause = (cause) => { + captured.push(cause); + }; + session.dbHandle = null; + session.engine = null; + session.getSessionId = () => "session-init"; + session.initialized = false; + session.lastActivityMs = 0; + session.pendingApprovalLeases = new Map(); + session.props = { session: { organizationId: "org-1", userId: "user-1" } }; + session.sessionTimeoutMs = () => 60_000; + session.resolveSessionMeta = () => Effect.succeed(sessionMeta); + session.openSessionDb = () => ({ end: () => undefined }); + session.buildMcpServer = () => + Effect.succeed({ mcpServer: makeServer(), engine: makeEngine().engine }); + return { session, storage, captured }; + }; + + it("keeps the session when a deploy resets the last-activity write", async () => { + const { session, storage, captured } = makeInitSession(); + storage.putFailures.set("last-activity-ms", () => new Error(CODE_UPDATE_RESET)); + + await expect( + session.init(), + "a healthy session is not torn down by a lost timestamp", + ).resolves.toBeUndefined(); + + expect(session.initialized, "the runtime stays installed").toBe(true); + expect(session.engine, "the execution engine survives").not.toBeNull(); + expect(session.server, "the MCP server survives").toBeDefined(); + expect(captured, "a platform reset of bookkeeping is not paged as a defect").toEqual([]); + }); + + it("keeps the session when a deploy resets the idle-alarm write", async () => { + const { session, storage, captured } = makeInitSession(); + storage.setAlarmFailure = () => new Error(CODE_UPDATE_RESET); + + await expect(session.init()).resolves.toBeUndefined(); + + expect(session.initialized).toBe(true); + expect(captured).toEqual([]); + }); + + // The alarm is the only durable consequence of a dropped markActivity, and it + // must self-heal: the next request re-arms it. Otherwise "best effort" would + // quietly mean "this session never times out". + it("re-arms the idle alarm on the next touch after a lost bookkeeping write", async () => { + const { session, storage } = makeInitSession(); + storage.setAlarmFailure = () => new Error(CODE_UPDATE_RESET); + + await session.init(); + expect(storage.alarm, "the write that failed left no alarm").toBeUndefined(); + + await expect( + session.validateMcpSessionOwner({ accountId: "user-1", organizationId: "org-1" }), + ).resolves.toBe("ok"); + expect(storage.alarm, "the next request re-establishes the idle clock").toBeGreaterThan(0); + }); + + // Best-effort is scoped to the platform's own resets. A bookkeeping write that + // fails for any other reason is still a defect and must still be reported — + // otherwise this change trades a noisy bug for a silent one. + it("still fails and reports when the bookkeeping write breaks for an unknown reason", async () => { + const { session, storage, captured } = makeInitSession(); + storage.putFailures.set("last-activity-ms", () => new Error("quota exceeded for namespace")); + + await expect(session.init()).rejects.toThrow(/quota exceeded/); + expect(captured.length, "an unrecognized failure is still captured").toBe(1); + }); + + // Session meta is not bookkeeping — ownership validation reads it back — so a + // reset there must still fail init. What it must NOT do is escape as an + // unclassified defect: the caller renders it as a retryable error, and the DO + // stops paging for a condition every deploy guarantees. + it("fails a meta write reset without paging, so the caller can render a retry", async () => { + const { session, storage, captured } = makeInitSession(); + storage.putFailures.set("session-meta", () => new Error(CODE_UPDATE_RESET)); + + await expect(session.init()).rejects.toThrow(/code was updated/); + expect(captured, "a deploy reset is expected platform behaviour, not a defect").toEqual([]); + }); +}); diff --git a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts index 9ff71cfedd..a1dd27a3fb 100644 --- a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts +++ b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts @@ -21,6 +21,7 @@ import { import { defaultMcpResource, type McpResource } from "@executor-js/host-mcp"; import type { IncomingPropagationHeaders, McpElicitationMode } from "./do-headers"; +import { classifyDurableObjectError, type DurableObjectFailure } from "./durable-object-errors"; import type { McpExecutionOwnerDirectory, McpExecutionOwnerRecord, @@ -273,6 +274,23 @@ export abstract class McpAgentSessionDOBase< return Effect.void; } + /** + * Declare that the Durable Object has finished deciding what to do about this + * cause — either it reported it through `captureCauseEffect`, or it + * recognized it as expected platform behaviour and deliberately did not. + * + * Either way the cause is *owned here*. The host's outer error + * instrumentation wraps the DO's entry points and would otherwise report the + * same rejection a second time as it escapes, producing two issues per + * failure; this is the hook that lets the host drop its own echo. Anything the + * DO never claims (an alarm crash, a transport fault) is untouched and keeps + * being reported by that instrumentation, which is the whole point of + * claiming explicitly instead of disabling it. + */ + protected claimCauseHandled(_cause: Cause.Cause): Effect.Effect { + return Effect.void; + } + protected flushTelemetry(): Promise { return Promise.resolve(); } @@ -524,6 +542,61 @@ export abstract class McpAgentSessionDOBase< }).pipe(Effect.withSpan("mcp.session.resolve_and_store_meta")); } + /** + * A Cloudflare platform reset happened. Record what KIND it was, on the span + * and in a structured log, so the volume stays countable per cause (deploy + * reset vs storage timeout vs backend blip) instead of collapsing into one + * opaque bucket the moment it stops being an error report. + */ + private recordDurableObjectReset(input: { + readonly operation: string; + readonly failure: DurableObjectFailure; + readonly cause: Cause.Cause; + }): Effect.Effect { + const self = this; + return Effect.gen(function* () { + console.warn( + JSON.stringify({ + event: "mcp_session_durable_object_reset", + operation: input.operation, + sessionId: self.sessionId, + resetKind: input.failure.kind, + disposition: input.failure.disposition, + cause: Cause.pretty(input.cause), + }), + ); + yield* Effect.annotateCurrentSpan({ + "mcp.do.reset_kind": input.failure.kind, + "mcp.do.reset_disposition": input.failure.disposition, + "mcp.do.reset_operation": input.operation, + }); + }); + } + + /** + * Run a storage write whose only job is bookkeeping, and let the Cloudflare + * platform take it away without taking the session with it. + * + * A platform reset (a deploy, a storage timeout, a backend blip) cancels + * whatever write is in flight. For a write nothing depends on, the right + * answer is to note it and carry on — the alternative, which is what used to + * happen, is that a fully built and perfectly healthy session is torn down and + * the user's request fails because a timestamp did not land. + * + * Scoped deliberately: only failures the classifier RECOGNIZES as platform + * resets are absorbed. Anything else is still a defect and still fails. + */ + private bestEffortBookkeeping(operation: string, run: () => Promise): Effect.Effect { + const self = this; + return Effect.promise(run).pipe( + Effect.catchCause((cause) => { + const failure = classifyDurableObjectError(cause); + if (!failure) return Effect.failCause(cause); + return self.recordDurableObjectReset({ operation, failure, cause }); + }), + ); + } + private recordCauseOnSpan(cause: Cause.Cause): Effect.Effect { const errors = Cause.prettyErrors(cause); if (errors.length === 0) return Effect.void; @@ -722,15 +795,31 @@ export abstract class McpAgentSessionDOBase< self.server = mcpServer; self.engine = engine; self.initialized = true; - yield* Effect.promise(() => self.markActivity()).pipe( - Effect.withSpan("McpSessionDO.markActivity"), - ); + // Last statement, and pure bookkeeping: the runtime above is already + // installed and serving. Losing the timestamp/alarm write to a platform + // reset must not undo any of it — the in-memory clock is already set and + // the next request re-arms the alarm. + yield* self + .bestEffortBookkeeping("init.mark_activity", () => self.markActivity()) + .pipe(Effect.withSpan("McpSessionDO.markActivity")); }).pipe( Effect.tapCause((cause) => Effect.gen(function* () { - console.error("[mcp-session] init failed:", Cause.pretty(cause)); - yield* self.captureCauseEffect(cause); + // A Cloudflare platform reset of an in-flight init is not a defect — + // every deploy causes one, by design. Record the kind so the volume + // stays measurable, let the caller render it as a retry, and do not + // page for it. Everything else is reported exactly as before. + const failure = classifyDurableObjectError(cause); + if (failure) { + yield* self.recordDurableObjectReset({ operation: "init", failure, cause }); + } else { + console.error("[mcp-session] init failed:", Cause.pretty(cause)); + yield* self.captureCauseEffect(cause); + } yield* self.recordCauseOnSpan(cause); + // Claimed AFTER any capture above, so the DO's own event is not + // mistaken for the host instrumentation's duplicate of it. + yield* self.claimCauseHandled(cause); }), ), Effect.catchCause((cause) => @@ -777,9 +866,9 @@ export abstract class McpAgentSessionDOBase< const sessionMeta = yield* self.loadSessionMeta(); if (!sessionMeta) return "not_found" as const; if (self.initialized) { - yield* Effect.promise(() => self.markActivity()).pipe( - Effect.withSpan("McpSessionDO.markActivity"), - ); + yield* self + .bestEffortBookkeeping("validate_owner.mark_activity", () => self.markActivity()) + .pipe(Effect.withSpan("McpSessionDO.markActivity")); } else { yield* Effect.promise(() => self.onStart()).pipe( Effect.withSpan("McpSessionDO.restore_transport_runtime"), @@ -1178,9 +1267,9 @@ export abstract class McpAgentSessionDOBase< // which used to hold a ref across the pause and mask this by keeping // the ref transition away from 0->1.) const disposeKeepAlive = yield* Effect.promise(() => self.keepAlive()); - yield* Effect.promise(() => self.markActivity()).pipe( - Effect.withSpan("McpSessionDO.markActivity"), - ); + yield* self + .bestEffortBookkeeping("approval_lease.mark_activity", () => self.markActivity()) + .pipe(Effect.withSpan("McpSessionDO.markActivity")); const timeout = setTimeout(() => { self.queuePendingApprovalLeaseExpiration(executionId); }, PAUSED_APPROVAL_TIMEOUT_MS); diff --git a/packages/hosts/cloudflare/src/mcp/durable-object-errors.test.ts b/packages/hosts/cloudflare/src/mcp/durable-object-errors.test.ts new file mode 100644 index 0000000000..2eca5e77d3 --- /dev/null +++ b/packages/hosts/cloudflare/src/mcp/durable-object-errors.test.ts @@ -0,0 +1,188 @@ +// oxlint-disable executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: every case here is a verbatim reproduction of a plain Error the Cloudflare runtime itself throws, and the test helper throws to abort a case whose premise did not hold +import { describe, expect, it } from "@effect/vitest"; +import { Cause } from "effect"; + +import { UNAVAILABLE_RETRY_AFTER_SECONDS } from "@executor-js/host-mcp"; + +import { classifyDurableObjectError, durableObjectFailureResponse } from "./durable-object-errors"; + +// Every string here is a real Cloudflare runtime message. They arrive as plain +// `Error`s with no code, no type and no marker of any kind — the text IS the +// contract, which is exactly why it deserves one place and a test. +describe("classifyDurableObjectError", () => { + it("reads a self-abort as a session that is gone for good", () => { + // `destroy()` ends in `ctx.abort("destroyed")`; the abort reason arrives + // verbatim as the error message. + expect(classifyDurableObjectError(new Error("destroyed"))).toEqual({ + kind: "destroyed", + disposition: "session_dead", + }); + }); + + it("reads a deploy-time reset as transient", () => { + expect( + classifyDurableObjectError(new Error("Durable Object reset because its code was updated.")), + ).toEqual({ kind: "code_update", disposition: "transient" }); + }); + + it("reads a storage timeout reset as transient", () => { + expect( + classifyDurableObjectError( + new Error( + "Durable Object storage operation exceeded timeout which caused object to be reset.", + ), + ), + ).toEqual({ kind: "storage_timeout", disposition: "transient" }); + }); + + it("reads a storage backend fault as transient", () => { + expect( + classifyDurableObjectError( + new Error("Internal error in Durable Object storage caused object to be reset."), + ), + ).toEqual({ kind: "storage_internal", disposition: "transient" }); + }); + + it("reads a blockConcurrencyWhile cancellation as transient", () => { + expect( + classifyDurableObjectError( + new Error( + "A call to blockConcurrencyWhile() in a Durable Object waited for too long. The call was canceled and the Durable Object was reset.", + ), + ), + ).toEqual({ kind: "concurrency_reset", disposition: "transient" }); + }); + + it("reads a platform blip as transient, ignoring the reference id", () => { + // The reference id differs on every event; it must not defeat the match. + expect( + classifyDurableObjectError(new Error("internal error; reference = 0000aaaa1111bbbb")), + ).toEqual({ kind: "internal_error", disposition: "transient" }); + expect( + classifyDurableObjectError(new Error("internal error; reference = ffff9999eeee8888")), + ).toEqual({ kind: "internal_error", disposition: "transient" }); + }); + + it("honours the runtime's own retryable flag when the message says nothing", () => { + const error = Object.assign(new Error("Network connection lost."), { retryable: true }); + expect(classifyDurableObjectError(error)).toEqual({ + kind: "retryable", + disposition: "transient", + }); + }); + + it("looks inside an Effect cause, because that is how the DO seam sees it", () => { + const cause = Cause.die(new Error("Durable Object reset because its code was updated.")); + + expect(classifyDurableObjectError(cause)).toEqual({ + kind: "code_update", + disposition: "transient", + }); + }); + + it("unwraps a wrapped platform error", () => { + const wrapped = new Error("session restore failed", { + cause: new Error("Durable Object reset because its code was updated."), + }); + expect(classifyDurableObjectError(wrapped)).toEqual({ + kind: "code_update", + disposition: "transient", + }); + }); + + // The whole point of returning null is that unknown failures keep their + // current behaviour: rethrown, reported, paged. Silently swallowing an + // application bug as "transient" would be far worse than the noise this + // module removes. + it("refuses to classify anything it does not recognize", () => { + expect(classifyDurableObjectError(new Error("Cannot read properties of undefined"))).toBeNull(); + expect(classifyDurableObjectError(new TypeError("x is not a function"))).toBeNull(); + expect(classifyDurableObjectError(undefined)).toBeNull(); + expect(classifyDurableObjectError(null)).toBeNull(); + expect(classifyDurableObjectError({})).toBeNull(); + }); + + // A callback that throws also resets the object, so an application defect + // raised inside `blockConcurrencyWhile` can carry the method's name. Only the + // runtime's own cancellation message is a platform reset; the rest is a bug + // and must keep being rethrown and reported. + it("does not read an application defect inside blockConcurrencyWhile as a platform reset", () => { + expect( + classifyDurableObjectError( + new Error("Error in blockConcurrencyWhile(): TypeError: x is not a function"), + ), + ).toBeNull(); + }); + + // "destroyed" is the abort reason and nothing else. A message that merely + // mentions the word describes a different failure and must not be allowed to + // condemn a live session id. + it("does not condemn a session for a message that merely mentions destruction", () => { + expect( + classifyDurableObjectError(new Error("the widget was destroyed by the user")), + ).toBeNull(); + }); +}); + +// What a client actually receives when the platform takes a session Durable +// Object away mid-request. +// +// The self-abort at the end of a session teardown IS reachable black-box, and +// e2e/cloud/mcp-destroyed-session-envelope.test.ts owns that case end to end — +// it is deliberately not re-tested here. The remaining platform resets (a +// deploy replacing the script, a storage timeout, a backend blip, a cancelled +// blockConcurrencyWhile) cannot be provoked on the dev stack at all, so the +// mapping from those errors to the wire response is pinned here instead. +describe("durableObjectFailureResponse", () => { + const envelope = async ( + error: unknown, + ): Promise<{ + readonly status: number; + readonly retryAfter: string | null; + readonly body: { + readonly jsonrpc?: string; + readonly error?: { + readonly code?: number; + readonly message?: string; + readonly data?: unknown; + }; + }; + }> => { + const failure = classifyDurableObjectError(error); + if (!failure) throw new Error("expected the error to be classified as a platform failure"); + const response = durableObjectFailureResponse(failure); + return { + status: response.status, + retryAfter: response.headers.get("retry-after"), + body: await response.json(), + }; + }; + + // A deploy resets every live Durable Object. The session id is still valid, + // so the client must be told to retry it — and told how long to wait, or the + // 503 is not actionable. + it("tells the client to retry the same session after a transient platform reset", async () => { + const result = await envelope(new Error("Durable Object reset because its code was updated.")); + + expect(result.status, "HTTP status is the discriminator clients act on").toBe(503); + expect(result.body.jsonrpc).toBe("2.0"); + expect(result.body.error?.code).toBe(-32001); + // `retryAfterSeconds` renders as the standard `Retry-After` header, which + // is what a polite client (and any generic retry layer) actually reads. + expect(result.retryAfter, "the client is told how long to back off").toBe( + String(UNAVAILABLE_RETRY_AFTER_SECONDS), + ); + }); + + it("renders the same retry verdict for a storage timeout and a backend blip", async () => { + for (const message of [ + "Durable Object storage operation exceeded timeout which caused object to be reset.", + "internal error; reference = 0000aaaa1111bbbb", + "A call to blockConcurrencyWhile() in a Durable Object waited for too long. The call was canceled and the Durable Object was reset.", + ]) { + const result = await envelope(new Error(message)); + expect(result.status, message).toBe(503); + expect(result.retryAfter, message).not.toBeNull(); + } + }); +}); diff --git a/packages/hosts/cloudflare/src/mcp/durable-object-errors.ts b/packages/hosts/cloudflare/src/mcp/durable-object-errors.ts new file mode 100644 index 0000000000..4b77f15200 --- /dev/null +++ b/packages/hosts/cloudflare/src/mcp/durable-object-errors.ts @@ -0,0 +1,185 @@ +/** + * Classification of Cloudflare *platform* Durable Object failures. + * + * A Durable Object can be torn out from under its own code by the runtime, and + * when that happens the only thing the caller receives is a plain `Error` whose + * message is the entire signal. Those are not application defects: the object + * was healthy, the request was valid, and the correct answer to the client is + * "this id is dead, reconnect" or "come back in a moment" — never an unhandled + * 500 and never an error report. + * + * The codebase already classifies transient-vs-definitive failures carefully + * for the auth provider; this is the same discipline for the one other + * dependency that fails without a typed error: the DO platform itself. + * + * Everything not listed here stays UNCLASSIFIED on purpose. An unrecognized + * failure keeps its current behaviour — rethrow, report, page — because the + * cost of silently swallowing a real defect is much higher than the cost of one + * more retryable envelope. + */ +import { Cause } from "effect"; + +import { jsonRpcErrorBody, UNAVAILABLE_RETRY_AFTER_SECONDS } from "@executor-js/host-mcp"; + +/** + * The specific platform condition, kept granular so it can be recorded on a + * span (`mcp.do.reset_kind`) and the aggregate bucket split by cause in + * production instead of being one opaque pile. + */ +export type DurableObjectFailureKind = + /** The object called `ctx.abort("destroyed")` on itself — session teardown. */ + | "destroyed" + /** A deploy replaced the script and the runtime reset every live object. */ + | "code_update" + /** A storage op ran past the platform's ceiling; the object was reset. */ + | "storage_timeout" + /** The storage backend failed internally and reset the object. */ + | "storage_internal" + /** `blockConcurrencyWhile()` ran past its cap and was cancelled. */ + | "concurrency_reset" + /** A generic platform blip: `internal error; reference = `. */ + | "internal_error" + /** The runtime itself flagged the error as retryable. */ + | "retryable"; + +/** + * What the caller should do about it. + * + * - `session_dead` — the object is gone for good; the id will never work again, + * so the client must mint a new session. + * - `transient` — the object was reset but the id is still valid; the very next + * attempt is likely to succeed. + */ +export type DurableObjectFailureDisposition = "session_dead" | "transient"; + +export type DurableObjectFailure = { + readonly kind: DurableObjectFailureKind; + readonly disposition: DurableObjectFailureDisposition; +}; + +/** + * Message fragments workerd puts on the plain `Error` it throws. Matched + * case-insensitively on a substring because the runtime appends ids and + * punctuation ("… reference = 0123abcd") and has reworded these before. + */ +const MESSAGE_PATTERNS: ReadonlyArray<{ + readonly fragment: string; + readonly failure: DurableObjectFailure; +}> = [ + { + fragment: "durable object reset because its code was updated", + failure: { kind: "code_update", disposition: "transient" }, + }, + { + fragment: "storage operation exceeded timeout", + failure: { kind: "storage_timeout", disposition: "transient" }, + }, + { + fragment: "internal error in durable object storage", + failure: { kind: "storage_internal", disposition: "transient" }, + }, + { + // Deliberately the whole phrase, not the bare method name: an application + // defect thrown from inside a `blockConcurrencyWhile` callback also resets + // the object, and a message that merely names the method must not be read + // as a platform reset and quietly turned into a retry. + fragment: "blockconcurrencywhile() in a durable object waited for too long", + failure: { kind: "concurrency_reset", disposition: "transient" }, + }, + { + fragment: "internal error; reference =", + failure: { kind: "internal_error", disposition: "transient" }, + }, +]; + +/** + * `ctx.abort("destroyed")` surfaces as an `Error` whose message is exactly the + * abort reason, so this one is matched whole rather than as a substring — a + * message that merely mentions the word must not condemn a live session. + */ +const DESTROYED_MESSAGE = "destroyed"; + +/** How deep to follow `error.cause` before giving up. */ +const MAX_UNWRAP_DEPTH = 5; + +const messageOf = (error: unknown): string | null => { + if (typeof error === "string") return error; + if (typeof error !== "object" || error === null) return null; + // oxlint-disable-next-line executor/no-unknown-error-message -- platform boundary: workerd rejects with an untyped Error whose message IS the only signal; reading it here is the entire purpose of this module, and it exists so no other file has to + const message = (error as { readonly message?: unknown }).message; + return typeof message === "string" ? message : null; +}; + +const isRuntimeRetryable = (error: unknown): boolean => + typeof error === "object" && + error !== null && + (error as { readonly retryable?: unknown }).retryable === true; + +const classifyOne = (error: unknown): DurableObjectFailure | null => { + const message = messageOf(error); + if (message !== null) { + const normalized = message.trim().toLowerCase(); + if (normalized === DESTROYED_MESSAGE) { + return { kind: "destroyed", disposition: "session_dead" }; + } + for (const pattern of MESSAGE_PATTERNS) { + if (normalized.includes(pattern.fragment)) return pattern.failure; + } + } + // Checked last: the message is the more specific signal, and the runtime sets + // `retryable` on some of the same errors. + if (isRuntimeRetryable(error)) return { kind: "retryable", disposition: "transient" }; + return null; +}; + +/** + * Recognize a Cloudflare platform Durable Object failure. + * + * Accepts whatever the caller happens to be holding: the raw `Error` a stub RPC + * rejected with, an `Error` that wrapped it as its `cause`, or an Effect + * `Cause` — the DO seam only ever sees the last of those, and forcing every + * call site to unwrap first is how a classifier ends up with three subtly + * different copies. + * + * Returns `null` for anything unrecognized — the caller must then treat the + * error exactly as it did before this module existed. + */ +export const classifyDurableObjectError = (error: unknown): DurableObjectFailure | null => { + if (Cause.isCause(error)) { + for (const inner of Cause.prettyErrors(error)) { + const failure = classifyDurableObjectError(inner); + if (failure) return failure; + } + return null; + } + let current: unknown = error; + for (let depth = 0; depth < MAX_UNWRAP_DEPTH && current !== null && current !== undefined; ) { + const failure = classifyOne(current); + if (failure) return failure; + depth += 1; + if (typeof current !== "object") break; + current = (current as { readonly cause?: unknown }).cause; + } + return null; +}; + +/** + * Render a classified platform failure as the MCP protocol error the client + * should act on. + * + * Both branches reuse envelopes the MCP host already speaks, because the client + * side of this is not new: a dead session id has always been "reconnect", and a + * transient dependency failure has always been a 503 carrying `Retry-After`. + * The only thing that was missing is that a platform reset never reached + * either, and fell out of the worker as an unhandled 500 instead. + * + * The JSON-RPC code is -32001 for both; the HTTP STATUS is the discriminator + * clients act on — 404 = the id is dead, mint a new session; 503 = retry the + * SAME id after the advertised delay. + */ +export const durableObjectFailureResponse = (failure: DurableObjectFailure): Response => + failure.disposition === "session_dead" + ? jsonRpcErrorBody(404, -32001, "Session timed out, please reconnect") + : jsonRpcErrorBody(503, -32001, "MCP session is restarting, please retry", { + retryAfterSeconds: UNAVAILABLE_RETRY_AFTER_SECONDS, + }); From 55180cb1487f9a3a28ddc0ee0bedfab8464c1f72 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:56:03 -0700 Subject: [PATCH 127/133] Shape storage error messages and classify connection faults (#1784) * Shape storage error messages and classify connection faults * Classify ECONNREFUSED as a retryable storage connection fault * Add an e2e scenario for the shape of a storage failure report --- .changeset/storage-error-shaping.md | 8 + e2e/cloud/storage-error-report-shape.test.ts | 280 +++++++++++++++++++ packages/core/api/src/observability.test.ts | 26 +- packages/core/api/src/observability.ts | 15 +- packages/core/sdk/src/errors.ts | 3 +- packages/core/sdk/src/fuma-runtime.test.ts | 265 ++++++++++++++++++ packages/core/sdk/src/fuma-runtime.ts | 139 ++++++++- packages/core/sdk/src/index.ts | 7 +- 8 files changed, 728 insertions(+), 15 deletions(-) create mode 100644 .changeset/storage-error-shaping.md create mode 100644 e2e/cloud/storage-error-report-shape.test.ts create mode 100644 packages/core/sdk/src/fuma-runtime.test.ts diff --git a/.changeset/storage-error-shaping.md b/.changeset/storage-error-shaping.md new file mode 100644 index 0000000000..7a50ec85b8 --- /dev/null +++ b/.changeset/storage-error-shaping.md @@ -0,0 +1,8 @@ +--- +"@executor-js/sdk": patch +"@executor-js/api": patch +--- + +Build `StorageError.message` from the call-site label plus the driver's error code instead of the driver's raw text. The driver text is drizzle's `Failed query: \nparams: `, so error reporting grouped one storage defect by statement shape and printed bound parameters into issue titles. The full driver error stays on `cause`. + +Add `StorageConnectionError`, a `StorageFailure` variant for postgres.js connection faults (`CONNECTION_ENDED`, `CONNECTION_CLOSED`, `CONNECTION_DESTROYED`, `CONNECT_TIMEOUT`, `ECONNREFUSED`, `ECONNRESET`) and workerd's cross-request I/O rejection. It carries the fault `code` and a `retryable` flag so a lost socket can be told apart from a pool-lifetime bug. diff --git a/e2e/cloud/storage-error-report-shape.test.ts b/e2e/cloud/storage-error-report-shape.test.ts new file mode 100644 index 0000000000..ab8a848f4c --- /dev/null +++ b/e2e/cloud/storage-error-report-shape.test.ts @@ -0,0 +1,280 @@ +// Cloud-only: what an operator SEES when a write is rejected by the database. +// +// The product guarantee: a storage failure is reported under a stable headline +// built from the operation and the database's error code — never the statement +// text, never the values that were bound into it. Two consequences, both of +// them things production got wrong: +// +// - The values bound into a rejected statement are customer data (the +// organization id, the connection name, whatever the user typed into the +// description). They must not appear in the report's headline. +// - The headline is the grouping key of the error reporter, so one defect that +// hits several tables — or the same table through several WHERE shapes — +// must arrive as ONE report, not one per statement. +// +// The failure is induced through the public typed API only: PostgreSQL cannot +// store a NUL byte in a text column, so a connection whose description carries +// one is rejected by the driver with SQLSTATE 22021 while the statement and its +// bound parameters are already assembled. That is the same class of failure the +// production reports came from, reachable without touching the database. +// +// Two surfaces are asserted, both public: +// 1. What the CALLER gets — an opaque `InternalError` carrying only a trace +// id, with no driver text anywhere in the payload. +// 2. What the OPERATOR gets — the server's own error log, where the trace id +// the caller received joins to the report the server filed. Its headline — +// the captured exception's type and message — is what the error reporter +// files the report under, and groups by. +import { randomBytes } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { expect } from "@effect/vitest"; +import { Cause, Effect, Exit, Schedule } from "effect"; +import type { HttpApiClient } from "effect/unstable/httpapi"; +import { composePluginApi } from "@executor-js/api/server"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; + +import { RUNS_DIR, scenario } from "../src/scenario"; +import { Api, Target } from "../src/services"; + +const api = composePluginApi([openApiHttpPlugin()] as const); +type Client = HttpApiClient.ForApi; + +/** A text value PostgreSQL cannot store — the driver rejects it as 22021. */ +const NUL = String.fromCharCode(0); + +const SLUG = "storage-error-report-shape"; + +/** Minimal OpenAPI spec with a single GET /ping — never contacted here. */ +const pingSpec = JSON.stringify({ + openapi: "3.0.3", + info: { title: "Ping API", version: "1.0.0" }, + paths: { + "/ping": { + get: { operationId: "ping", summary: "Ping", responses: { "200": { description: "pong" } } }, + }, + }, +}); + +/** Registers a fresh apiKey-authenticated integration for connections to bind to. */ +const registerIntegration = (client: Client) => + Effect.gen(function* () { + const slug = IntegrationSlug.make(`${SLUG}-${randomBytes(4).toString("hex")}`); + yield* client.openapi.addSpec({ + payload: { + spec: { kind: "blob", value: pingSpec }, + slug, + baseUrl: "http://127.0.0.1:59999", // never contacted during registration + authenticationTemplate: [ + { + slug: "apiKey", + type: "apiKey", + headers: { authorization: ["Bearer ", { type: "variable", name: "token" }] }, + }, + ], + }, + }); + return slug; + }); + +interface RejectedWrite { + /** The trace id the caller was handed; joins to the server's report. */ + readonly traceId: string; + /** The name the caller chose for the connection — customer data. */ + readonly name: string; + /** The free text the caller typed — customer data, and the NUL carrier. */ + readonly description: string; + /** The whole client-visible failure, serialized. */ + readonly payload: string; +} + +/** + * Create a connection whose description PostgreSQL will refuse, and return what + * the caller can see about the failure. + */ +const rejectedConnectionWrite = ( + client: Client, + integration: IntegrationSlug, +): Effect.Effect => + Effect.gen(function* () { + const name = ConnectionName.make( + `${SLUG.replaceAll("-", "")}${randomBytes(4).toString("hex")}`, + ); + const description = `desc-${randomBytes(6).toString("hex")}${NUL}tail`; + + const exit = yield* Effect.exit( + client.connections.create({ + payload: { + owner: "org", + name, + integration, + template: AuthTemplateSlug.make("apiKey"), + description, + value: `sk-${randomBytes(4).toString("hex")}`, + }, + }), + ); + + const failure = Exit.isFailure(exit) + ? exit.cause.reasons.find(Cause.isFailReason)?.error + : exit.value; + const error = failure as { readonly _tag?: string; readonly traceId?: string } | undefined; + + // PostgreSQL refuses the NUL byte, so the write cannot succeed, and what + // comes back is the opaque internal failure — never the storage error. + expect(error?._tag, "the caller sees an opaque internal failure").toBe("InternalError"); + expect(error?.traceId ?? "", "the caller is handed a trace id to quote").toMatch( + /^[0-9a-f]{32}$/, + ); + + return { + traceId: error?.traceId ?? "", + name, + description, + payload: JSON.stringify(failure), + }; + }); + +/** + * The dev stack's stdout. The suite's globalsetup funnels it into the run + * artifacts; a scenario run against an already-booted instance (`cli up cloud`) + * reads that instance's log instead. + */ +const serverLogCandidates = [ + resolve(RUNS_DIR, "cloud", "server-logs", "boot.log"), + resolve(RUNS_DIR, "..", ".dev", "cloud.log"), +]; + +const readServerLog = (): string => { + const texts = serverLogCandidates.flatMap((path) => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: probing which of the two stdout sinks this run uses + try { + return [readFileSync(path, "utf8")]; + } catch { + return []; + } + }); + return texts.join("\n"); +}; + +const REPORT_PREFIX = "[api] unhandled cause: "; + +/** A stack frame in the logged cause — where the report's headline stops. */ +const STACK_FRAME = /^\s+at /; + +interface FiledReport { + /** Type + message: what the reporter names and groups the report by. */ + readonly headline: string; + /** The whole record, headline and chained cause — what a diagnosis reads. */ + readonly full: string; +} + +/** + * The report the server filed for one request. + * + * The headline is the captured cause's type and message up to the first stack + * frame — exactly what `Cause.prettyErrors` hands the reporter as the + * exception. The message is multi-line whenever the driver's text is + * (`Failed query: …\nparams: …`), so the whole headline has to be read, not + * just its first line. + * + * Found by walking back from the correlation record carrying the caller's trace + * id, so it is THIS request's report and not a neighbour's. + */ +const reportFor = (traceId: string): Effect.Effect => + Effect.sync(() => { + const lines = readServerLog().split("\n"); + const correlated = lines.findLastIndex( + (line) => + line.includes('"event":"sentry_before_send_otel_correlation"') && + line.includes(`"sentry_event_id":"${traceId}"`), + ); + if (correlated === -1) return undefined; + const reported = lines + .slice(0, correlated) + .findLastIndex((line) => line.startsWith(REPORT_PREFIX)); + if (reported === -1) return undefined; + const block = [ + lines[reported]!.slice(REPORT_PREFIX.length), + ...lines.slice(reported + 1, correlated), + ]; + const end = block.slice(1).findIndex((line) => STACK_FRAME.test(line)); + return { + headline: block + .slice(0, end === -1 ? 1 : end + 1) + .join("\n") + .trimEnd(), + full: block.join("\n"), + }; + }).pipe( + Effect.filterOrFail( + (report): report is FiledReport => report !== undefined, + () => `no error report joined to trace id ${traceId} in the server log`, + ), + // The log is a file the dev stack appends to; the write lands moments after + // the response. Poll rather than sleep (~20s ceiling). + Effect.retry(Schedule.both(Schedule.spaced("500 millis"), Schedule.recurs(40))), + ); + +scenario( + "Storage · a rejected write is reported without its SQL or the caller's data", + { timeout: 120_000 }, + Effect.gen(function* () { + const target = yield* Target; + const { client: apiClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* apiClient(api, identity); + const integration = yield* registerIntegration(client); + + const first = yield* rejectedConnectionWrite(client, integration); + const second = yield* rejectedConnectionWrite(client, integration); + + for (const write of [first, second]) { + expect(write.payload, "the client payload carries no driver text").not.toContain( + "Failed query", + ); + expect(write.payload, "the client payload carries no bound parameter").not.toContain( + write.description, + ); + } + + const report = yield* reportFor(first.traceId); + const headline = report.headline; + + // The symptom: the statement and everything bound into it used to BE the + // headline, so the report was named after the customer's data. + expect(headline, "the report headline carries no statement text").not.toContain("Failed query"); + expect(headline, "the report headline carries no statement text").not.toContain("insert into"); + expect(headline, "the report headline carries no bound parameters").not.toContain("params:"); + expect(headline, "the report headline carries no user-typed description").not.toContain( + first.description, + ); + expect(headline, "the report headline carries no user-chosen connection name").not.toContain( + first.name, + ); + expect(headline, "the report headline carries no organization id").not.toContain("org_"); + + // What it says instead: which operation failed, and how the database + // refused it — enough to act on, stable across calls. + expect(headline, "the report still names the failing operation").toContain("connection.create"); + expect(headline, "the report still names the database's error code").toContain("22021"); + + // Shaping the headline must not mean throwing the diagnosis away: the + // driver's own text is still filed with the report, one level down, where + // it informs a fix instead of naming the report. + expect(report.full, "the driver's statement is still filed under the report").toContain( + "Failed query", + ); + + // The fan-out: the two writes bound different names, descriptions and + // secrets, so their statements differ in every parameter. One defect, one + // report — not one report per set of values. + const secondReport = yield* reportFor(second.traceId); + expect( + secondReport.headline, + "a second rejected write with different values files the same report", + ).toBe(headline); + }), +); diff --git a/packages/core/api/src/observability.test.ts b/packages/core/api/src/observability.test.ts index df85881e47..ffd79e8665 100644 --- a/packages/core/api/src/observability.test.ts +++ b/packages/core/api/src/observability.test.ts @@ -7,7 +7,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, Layer, Ref, Result } from "effect"; -import { StorageError, UniqueViolationError } from "@executor-js/sdk/core"; +import { StorageConnectionError, StorageError, UniqueViolationError } from "@executor-js/sdk/core"; import { capture, ErrorCapture, InternalError } from "./observability"; @@ -47,6 +47,30 @@ describe("capture", () => { }), ); + it.effect("translates StorageConnectionError the same way as StorageError", () => + Effect.gen(function* () { + const { layer, seen } = yield* makeRecorder("trace-conn"); + const err = new StorageConnectionError({ + message: "FumaDB plugin_storage.findFirst failed: CONNECTION_ENDED", + label: "plugin_storage.findFirst", + code: "CONNECTION_ENDED", + retryable: false, + cause: "driver", + }); + + const result = yield* Effect.flip(capture(Effect.fail(err))).pipe(Effect.provide(layer)); + + expect(result).toBeInstanceOf(InternalError); + expect(result.traceId).toBe("trace-conn"); + + const causes = yield* Ref.get(seen); + expect(causes.length).toBe(1); + const squashed = Cause.squash(causes[0]!) as StorageConnectionError; + expect(squashed).toBeInstanceOf(StorageConnectionError); + expect(squashed.code).toBe("CONNECTION_ENDED"); + }), + ); + it.effect("empty traceId when no ErrorCapture is wired", () => Effect.gen(function* () { const err = new StorageError({ message: "nope", cause: undefined }); diff --git a/packages/core/api/src/observability.ts b/packages/core/api/src/observability.ts index 1bda74d5af..918c3bbd20 100644 --- a/packages/core/api/src/observability.ts +++ b/packages/core/api/src/observability.ts @@ -13,8 +13,9 @@ // the cloud Worker, console in the CLI, in-memory in tests) to // record causes and return correlation ids. Optional; absent → // empty trace ids, nothing breaks. -// 3. `capture(eff)` — the one translator. Catches `StorageError` and -// `UniqueViolationError` in the typed channel: the former is +// 3. `capture(eff)` — the one translator. Catches `StorageError`, +// `StorageConnectionError` and `UniqueViolationError` in the typed +// channel: the storage failures are // captured via `ErrorCapture` and re-failed as `InternalError({ // traceId })`; the latter dies as a defect (plugins that want to // surface it as a typed domain error should `Effect.catchTag` @@ -75,6 +76,10 @@ const resolveCapture = Effect.serviceOption(ErrorCapture).pipe( * * - `StorageError` — known backend failure. Capture the cause via * `ErrorCapture`, fail with `InternalError({ traceId })`. + * - `StorageConnectionError` — the database connection failed, so the + * statement never got a verdict. Same edge treatment as + * `StorageError` (capture, opaque 500); the tag exists so callers + * that can retry on a fresh pool are able to tell the two apart. * - `UniqueViolationError` — invariant violation at the HTTP edge: * if a plugin wanted to surface a unique-conflict as a typed * domain error (e.g. "source already exists") it should @@ -97,6 +102,12 @@ export const capture = ( Effect.flatMap((traceId) => Effect.fail(new InternalError({ traceId }))), ), ), + Effect.catchTag("StorageConnectionError", (err) => + resolveCapture.pipe( + Effect.flatMap((c) => c.captureException(Cause.fail(err))), + Effect.flatMap((traceId) => Effect.fail(new InternalError({ traceId }))), + ), + ), ) as Effect.Effect | InternalError, R>; /** diff --git a/packages/core/sdk/src/errors.ts b/packages/core/sdk/src/errors.ts index faf5e44a21..8a9e5e732a 100644 --- a/packages/core/sdk/src/errors.ts +++ b/packages/core/sdk/src/errors.ts @@ -32,7 +32,8 @@ export const isUserActionableError = (value: unknown): value is UserActionableEr /* The failure set the SDK surfaces. `execute`'s invoke failures are ported from * v1 but re-keyed by `address` (the full `tools....` * handle) instead of an opaque tool id. Storage failures reuse fuma-runtime's - * `StorageError`/`UniqueViolationError` (`StorageFailure`) — not redefined here. */ + * `StorageError`/`StorageConnectionError`/`UniqueViolationError` + * (`StorageFailure`) — not redefined here. */ // --------------------------------------------------------------------------- // Tool lifecycle diff --git a/packages/core/sdk/src/fuma-runtime.test.ts b/packages/core/sdk/src/fuma-runtime.test.ts new file mode 100644 index 0000000000..ae18f893d1 --- /dev/null +++ b/packages/core/sdk/src/fuma-runtime.test.ts @@ -0,0 +1,265 @@ +// Regression for the "StorageError: Failed query: …" reports in production. +// One root cause fanned out into a report per table and WHERE-clause because +// `fumaFailureFromCause` copied the driver's error text verbatim into +// `StorageError.message`: drizzle's `DrizzleQueryError` message is +// `Failed query: \nparams: `, so error reports grouped by +// SQL statement AND printed bound parameters (org ids, user ids, connection +// names) in their TITLES. +// +// Two contracts are pinned here: +// 1. `StorageError.message` is built from a stable label plus the driver's +// error CODE. Never the statement text, never the bound parameters. +// 2. postgres.js connection faults are classified as a distinct +// `StorageConnectionError` rather than melting into a generic +// `StorageError`, so a pool-lifetime bug is not indistinguishable from a +// malformed query. +// +// The fixtures below are synthetic reconstructions of the driver error shapes +// (verified against node_modules/postgres/src/errors.js and +// node_modules/drizzle-orm/errors.js); all identifiers are placeholders. + +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, Predicate } from "effect"; + +import { + fumaEffect, + fumaFailureFromCause, + isStorageFailure, + StorageError, + UniqueViolationError, +} from "./fuma-runtime"; + +/** Shape of `postgres.js` `Errors.connection(code, options, socket)`. */ +const postgresConnectionError = (code: string): Error => + Object.assign( + // oxlint-disable-next-line executor/no-error-constructor -- boundary: reconstructs the native driver error this module has to classify + new Error(`write ${code} db-placeholder.hyperdrive.local:5432`), + { code, errno: code, address: "db-placeholder.hyperdrive.local", port: 5432 }, + ); + +/** Shape of `postgres.js` `Errors.postgres(x)` — a server-side error report. */ +const postgresServerError = (code: string, message: string): Error => + // oxlint-disable-next-line executor/no-error-constructor -- boundary: reconstructs the native driver error this module has to classify + Object.assign(new Error(message), { code, severity: "ERROR" }); + +/** + * Shape of drizzle's `DrizzleQueryError`: the statement text and the bound + * parameters are baked into `message`, and the driver error hangs off `cause`. + */ +const drizzleQueryError = (sql: string, params: readonly unknown[], cause: unknown): Error => { + // oxlint-disable-next-line executor/no-error-constructor -- boundary: reconstructs the native driver error this module has to classify + const error = new Error(`Failed query: ${sql}\nparams: ${params.join(",")}`); + return Object.assign(error, { query: sql, params, cause }); +}; + +const SQL = 'select "key", "value" from "plugin_storage" where "scope_id" = $1 and "key" = $2'; +// Synthetic placeholders standing in for the real bound values (a WorkOS org id +// and a connection-scoped storage key) that leaked into error-report titles. +const PARAMS = ["org_placeholder_0000", "oauth:integration-placeholder:refresh"] as const; + +const expectNoDriverTextIn = (message: string): void => { + expect(message).not.toContain("Failed query"); + expect(message).not.toContain("params:"); + expect(message).not.toContain("select"); + expect(message).not.toContain('plugin_storage"'); + for (const param of PARAMS) expect(message).not.toContain(param); +}; + +describe("fumaFailureFromCause — message shaping", () => { + it("does not put the statement text or bound parameters in the message", () => { + const failure = fumaFailureFromCause( + "plugin_storage.findFirst", + drizzleQueryError(SQL, PARAMS, postgresConnectionError("CONNECTION_ENDED")), + ); + + expectNoDriverTextIn(failure.message ?? ""); + }); + + it("builds a stable message from the label and the driver error code", () => { + const failure = fumaFailureFromCause( + "plugin_storage.findFirst", + drizzleQueryError( + SQL, + PARAMS, + postgresServerError("42P01", 'relation "nope" does not exist'), + ), + ); + + expect(Predicate.isTagged(failure, "StorageError")).toBe(true); + expect(failure.message).toContain("plugin_storage.findFirst"); + expect(failure.message).toContain("42P01"); + expectNoDriverTextIn(failure.message ?? ""); + }); + + it("groups two different statements failing the same way onto one message", () => { + const a = fumaFailureFromCause( + "integration.findFirst", + drizzleQueryError( + 'select * from "integration" where "slug" = $1', + ["integration-placeholder"], + postgresServerError("57P01", "terminating connection due to administrator command"), + ), + ); + const b = fumaFailureFromCause( + "integration.findFirst", + drizzleQueryError( + 'select * from "integration" where "owner" = $1 and "slug" = $2', + ["org_placeholder_0000", "other-integration-placeholder"], + postgresServerError("57P01", "terminating connection due to administrator command"), + ), + ); + + expect(a.message).toBe(b.message); + }); + + it("still yields a stable message when the driver reports no code", () => { + const failure = fumaFailureFromCause( + "tool.findMany", + drizzleQueryError( + SQL, + PARAMS, + // oxlint-disable-next-line executor/no-error-constructor -- boundary: reconstructs a codeless native driver error + new Error("something went wrong in the driver"), + ), + ); + + expect(Predicate.isTagged(failure, "StorageError")).toBe(true); + expect(failure.message).toContain("tool.findMany"); + expectNoDriverTextIn(failure.message ?? ""); + expect(failure.message).not.toContain("something went wrong"); + }); + + it("keeps the full driver error reachable on `cause`", () => { + const driver = drizzleQueryError(SQL, PARAMS, postgresConnectionError("CONNECTION_ENDED")); + const failure = fumaFailureFromCause("plugin_storage.findFirst", driver); + + expect((failure as { readonly cause?: unknown }).cause).toBe(driver); + }); +}); + +describe("fumaFailureFromCause — classification", () => { + it.each([ + ["CONNECTION_ENDED"], + ["CONNECTION_CLOSED"], + ["CONNECTION_DESTROYED"], + ["CONNECT_TIMEOUT"], + // A backend that is simply down: postgres.js surfaces the socket errno + // verbatim, so `connect ECONNREFUSED …` is the ordinary "database + // unreachable" fault and must classify like the rest. + ["ECONNREFUSED"], + ["ECONNRESET"], + ])("classifies postgres.js %s as a storage connection fault", (code) => { + const failure = fumaFailureFromCause( + "plugin_storage.findFirst", + drizzleQueryError(SQL, PARAMS, postgresConnectionError(code)), + ); + + expect(Predicate.isTagged(failure, "StorageConnectionError")).toBe(true); + expect(failure).toEqual( + expect.objectContaining({ + _tag: "StorageConnectionError", + code, + label: "plugin_storage.findFirst", + }), + ); + expect(typeof (failure as { readonly retryable?: unknown }).retryable).toBe("boolean"); + expectNoDriverTextIn(failure.message ?? ""); + }); + + it("classifies the workerd cross-request I/O rejection as a connection fault", () => { + const failure = fumaFailureFromCause( + "integration.findFirst", + drizzleQueryError( + 'select * from "integration" where "slug" = $1', + ["integration-placeholder"], + // oxlint-disable-next-line executor/no-error-constructor -- boundary: reconstructs workerd's cross-request I/O rejection, which carries no code + new Error( + "Cannot perform I/O on behalf of a different request. I/O objects (such as streams, request/response bodies, and others) created in the context of one request handler cannot be accessed from a different request's handler.", + ), + ), + ); + + expect(Predicate.isTagged(failure, "StorageConnectionError")).toBe(true); + expectNoDriverTextIn(failure.message ?? ""); + expect(failure.message).not.toContain("Cannot perform I/O"); + }); + + it("marks transient socket loss retryable and pool-lifetime faults not retryable", () => { + const transient = fumaFailureFromCause( + "tool.findMany", + postgresConnectionError("CONNECTION_CLOSED"), + ) as { readonly retryable?: boolean }; + const lifetime = fumaFailureFromCause( + "tool.findMany", + postgresConnectionError("CONNECTION_ENDED"), + ) as { readonly retryable?: boolean }; + + expect(transient.retryable).toBe(true); + expect(lifetime.retryable).toBe(false); + }); + + it("still recognises unique violations by SQLSTATE", () => { + const failure = fumaFailureFromCause( + "connection.create", + drizzleQueryError( + 'insert into "connection" ("owner","name") values ($1,$2)', + ["org_placeholder_0000", "connection-placeholder"], + postgresServerError( + "23505", + 'duplicate key value violates unique constraint "connection_pkey"', + ), + ), + ); + + expect(failure).toBeInstanceOf(UniqueViolationError); + expect(Predicate.isTagged(failure, "UniqueViolationError")).toBe(true); + }); + + it("does not misread a unique violation as a connection fault", () => { + const failure = fumaFailureFromCause( + "connection.create", + postgresServerError("23505", "duplicate key value violates unique constraint"), + ); + + expect(Predicate.isTagged(failure, "StorageConnectionError")).toBe(false); + }); + + it("passes an already-typed storage failure through untouched", () => { + const original = new StorageError({ + message: 'FumaDB table "secret" is not available through this storage boundary.', + cause: undefined, + }); + + expect(fumaFailureFromCause("plugin_storage.findFirst", original)).toBe(original); + }); + + it("treats a connection fault as a storage failure at the boundary", () => { + const failure = fumaFailureFromCause( + "plugin_storage.findFirst", + postgresConnectionError("CONNECTION_ENDED"), + ); + + expect(isStorageFailure(failure)).toBe(true); + expect(fumaFailureFromCause("plugin_storage.findFirst", failure)).toBe(failure); + }); +}); + +describe("fumaEffect", () => { + it("maps a rejected driver promise onto the classified failure", async () => { + const driverRejection = () => + // oxlint-disable-next-line executor/no-promise-reject -- boundary: fumaEffect's whole contract is adapting a rejected driver promise + Promise.reject(drizzleQueryError(SQL, PARAMS, postgresConnectionError("CONNECTION_ENDED"))); + + const exit = await Effect.runPromiseExit( + fumaEffect("plugin_storage.findFirst", driverRejection), + ); + + expect(Exit.isFailure(exit)).toBe(true); + const failure = Exit.isFailure(exit) + ? exit.cause.reasons.find(Cause.isFailReason)?.error + : undefined; + + expect(Predicate.isTagged(failure, "StorageConnectionError")).toBe(true); + expectNoDriverTextIn(failure?.message ?? ""); + }); +}); diff --git a/packages/core/sdk/src/fuma-runtime.ts b/packages/core/sdk/src/fuma-runtime.ts index 137c0d01f2..ae5c4ebcc5 100644 --- a/packages/core/sdk/src/fuma-runtime.ts +++ b/packages/core/sdk/src/fuma-runtime.ts @@ -11,7 +11,37 @@ export class UniqueViolationError extends Data.TaggedError("UniqueViolationError readonly model?: string; }> {} -export type StorageFailure = StorageError | UniqueViolationError; +/** + * The database connection itself failed — the statement never got a verdict. + * Distinct from `StorageError` (a query the backend answered with an error) + * because the two need different responses: a connection fault is either a + * transient socket loss worth retrying on a FRESH pool, or a pool-lifetime + * bug that must stay loud. + * + * `retryable` says which. It is a property of the fault, not a policy: + * - `true` — the socket died underneath a live pool, or was never + * established because the backend was unreachable (`CONNECTION_CLOSED`, + * `CONNECT_TIMEOUT`, `ECONNREFUSED`, `ECONNRESET`). Reconnecting can + * succeed. + * - `false` — the pool was already torn down or the socket belongs to a + * different request context (`CONNECTION_ENDED`, `CONNECTION_DESTROYED`, + * the workerd cross-request I/O rejection). Retrying is futile by + * construction — postgres.js rejects every query once `end()` has been + * called — so these must surface rather than be papered over. + * + * No retry consumes this yet; classification lands first so the retry seam + * (and the request-scope fix behind these faults) can be designed against a + * typed signal instead of driver strings. + */ +export class StorageConnectionError extends Data.TaggedError("StorageConnectionError")<{ + readonly message: string; + readonly label: string; + readonly code: string; + readonly retryable: boolean; + readonly cause: unknown; +}> {} + +export type StorageFailure = StorageError | StorageConnectionError | UniqueViolationError; export type FumaTables = Record; type EmptyFumaSchema = FumaSchema<"latest", Record>; @@ -57,23 +87,112 @@ const isUniqueViolation = (cause: unknown): boolean => { return false; }; -const causeMessage = (cause: unknown): string | undefined => { - const message = - cause && typeof cause === "object" - ? // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: preserve database driver error text inside typed StorageError - (cause as Record)["message"] - : undefined; - return typeof message === "string" && message.length > 0 ? message : undefined; +/** + * postgres.js raises connection faults through `Errors.connection(code, …)`, + * which puts the code on `error.code` (see `postgres/src/errors.js`). Workerd's + * cross-request I/O rejection is a plain `Error` with no code, so it is matched + * on its fixed runtime text and given a synthetic code. + */ +const RETRYABLE_CONNECTION_CODES: ReadonlySet = new Set([ + "CONNECTION_CLOSED", + "CONNECT_TIMEOUT", + "ECONNREFUSED", + "ECONNRESET", +]); +const FATAL_CONNECTION_CODES: ReadonlySet = new Set([ + "CONNECTION_ENDED", + "CONNECTION_DESTROYED", +]); +const CROSS_REQUEST_IO_CODE = "CROSS_REQUEST_IO"; +const CROSS_REQUEST_IO_PATTERN = /cannot perform i\/o on behalf of a different request/i; + +/** Walk a cause chain, newest first, at most 5 links deep (as `isUniqueViolation` does). */ +const walkCauses = (cause: unknown, visit: (err: Record) => boolean): boolean => { + let current = cause; + for (let i = 0; i < 5; i += 1) { + const err = + current && typeof current === "object" ? (current as Record) : null; + if (!err) return false; + if (visit(err)) return true; + const innerCause = err["cause"]; + if (!innerCause || innerCause === current) return false; + current = innerCause; + } + return false; +}; + +/** First driver error code in the cause chain, if any. Never the error text. */ +const causeCode = (cause: unknown): string | undefined => { + let found: string | undefined; + walkCauses(cause, (err) => { + const code = err["code"]; + if (typeof code === "string" && code.length > 0) { + found = code; + return true; + } + return false; + }); + return found; }; +/** Connection-fault code in the cause chain, if this is a connection fault at all. */ +const connectionFaultCode = (cause: unknown): string | undefined => { + let found: string | undefined; + walkCauses(cause, (err) => { + const code = err["code"]; + if ( + typeof code === "string" && + (RETRYABLE_CONNECTION_CODES.has(code) || FATAL_CONNECTION_CODES.has(code)) + ) { + found = code; + return true; + } + // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: workerd's cross-request I/O rejection carries no code, only this fixed message + const message = err["message"]; + if (typeof message === "string" && CROSS_REQUEST_IO_PATTERN.test(message)) { + found = CROSS_REQUEST_IO_CODE; + return true; + } + return false; + }); + return found; +}; + +/** + * Build the failure message from stable inputs only — the call-site label and + * the driver's error code — never the driver's text. + * + * The driver text is `Failed query: \nparams: ` + * (`drizzle-orm/errors.js`). Copying it into the message made error reporting + * group by statement shape, splitting one defect across a report per table and + * WHERE-clause, and printed bound parameters (organization ids, user ids, + * user-chosen connection names) into report titles. The full driver error is + * preserved on `cause`, which the reporter still receives as a chained + * exception. + */ +const stableMessage = (label: string, code: string | undefined): string => + code ? `FumaDB ${label} failed: ${code}` : `FumaDB ${label} failed`; + export const isStorageFailure = (error: unknown): error is StorageFailure => - Predicate.isTagged(error, "StorageError") || Predicate.isTagged(error, "UniqueViolationError"); + Predicate.isTagged(error, "StorageError") || + Predicate.isTagged(error, "StorageConnectionError") || + Predicate.isTagged(error, "UniqueViolationError"); export const fumaFailureFromCause = (label: string, cause: unknown): StorageFailure => { if (isStorageFailure(cause)) return cause; if (isUniqueViolation(cause)) return new UniqueViolationError({ model: label }); + const connectionCode = connectionFaultCode(cause); + if (connectionCode !== undefined) { + return new StorageConnectionError({ + message: stableMessage(label, connectionCode), + label, + code: connectionCode, + retryable: RETRYABLE_CONNECTION_CODES.has(connectionCode), + cause, + }); + } return new StorageError({ - message: causeMessage(cause) ?? `FumaDB operation failed: ${label}`, + message: stableMessage(label, causeCode(cause)), cause, }); }; diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index a9b5aeb1f5..aa241f371f 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -36,7 +36,12 @@ export type { IFumaClient, StorageFailure, } from "./fuma-runtime"; -export { StorageError, UniqueViolationError, isStorageFailure } from "./fuma-runtime"; +export { + StorageError, + StorageConnectionError, + UniqueViolationError, + isStorageFailure, +} from "./fuma-runtime"; // IDs (branded) — the v2 set. export { From b885436e523e3fe07ed0a1a765b6fa72c2cc952f Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:19:59 -0700 Subject: [PATCH 128/133] Trace and type the execution rate-limit counter (#1782) * Trace and type the execution rate-limit counter Wrap the counter DO increment in a named span and a typed, classified error, cut the per-increment alarm write, and add a check-timeout env override so the fail-open path is testable. * Refer to error reporting generically in rate-limit comments * Cover the rate-limit check's blocked, exempt and override outcomes in e2e --- .../engine/execution-rate-limit.node.test.ts | 268 +++++++++++++++++- apps/cloud/src/engine/execution-rate-limit.ts | 208 ++++++++++++-- apps/cloud/src/env-augment.d.ts | 7 + e2e/cloud/mcp-execution-limits.test.ts | 132 ++++++++- e2e/setup/cloud.boot.ts | 10 +- e2e/setup/execution-limits.ts | 15 + 6 files changed, 617 insertions(+), 23 deletions(-) diff --git a/apps/cloud/src/engine/execution-rate-limit.node.test.ts b/apps/cloud/src/engine/execution-rate-limit.node.test.ts index 22d570bcec..678697edce 100644 --- a/apps/cloud/src/engine/execution-rate-limit.node.test.ts +++ b/apps/cloud/src/engine/execution-rate-limit.node.test.ts @@ -1,9 +1,16 @@ -import { describe, expect, it } from "@effect/vitest"; +import { afterEach, describe, expect, it } from "@effect/vitest"; +import { env } from "cloudflare:workers"; import { Data, Effect } from "effect"; +import type * as Tracer from "effect/Tracer"; import type { ExecutionEngine } from "@executor-js/execution"; -import { makeExecutionRateLimiter } from "./execution-rate-limit"; +import { + ExecutionRateLimiterDO, + makeCloudExecutionRateLimiter, + makeExecutionRateLimiter, + RateLimitCounterError, +} from "./execution-rate-limit"; import { RATE_LIMIT_BLOCKED_MESSAGE } from "./execution-limit-messages"; const ORG = "org_test"; @@ -150,7 +157,15 @@ describe("execution rate limiter — paid exemption", () => { it("fails open when the counter itself is unreachable", async () => { const limiter = makeExecutionRateLimiter( - () => Effect.fail(new UpstreamDownError({ which: "counter DO" })), + (organizationId) => + Effect.fail( + new RateLimitCounterError({ + organizationId, + code: "unknown", + reason: "counter DO unreachable", + cause: null, + }), + ), { limit: 10, isExempt: () => Effect.succeed(false), @@ -169,3 +184,250 @@ describe("execution rate limiter — paid exemption", () => { }); }); }); + +// --------------------------------------------------------------------------- +// Counter observability — the production DO wiring. +// +// The counter increment used to be a bare one-argument `Effect.tryPromise` +// with no span, so a Durable Object fault reached error reporting as +// `UnknownError: An error occurred in Effect.tryPromise` with no application +// frames, and the 2s check budget was invisible in traces. These tests pin +// the named spans, the typed/classified failure, and the timeout override — +// all read through the REAL `makeCloudExecutionRateLimiter` wiring against a +// fake `EXECUTION_RATE_LIMITER` binding. +// --------------------------------------------------------------------------- + +type RecordedSpan = { + readonly name: string; + readonly attributes: Map; +}; + +/** A tracer that keeps every span it is asked to open, with its attributes. */ +const recordingTracer = (recorded: Array): Tracer.Tracer => { + let nextId = 1; + return { + span: (options) => { + let status: Tracer.SpanStatus = { _tag: "Started", startTime: options.startTime }; + const attributes = new Map(); + recorded.push({ name: options.name, attributes }); + const id = String(nextId++).padStart(16, "0"); + return { + _tag: "Span", + name: options.name, + spanId: id, + traceId: "00000000000000000000000000000001", + parent: options.parent, + annotations: options.annotations, + get status() { + return status; + }, + attributes, + links: options.links, + sampled: options.sampled, + kind: options.kind, + end: (endTime, exit) => { + status = { _tag: "Ended", startTime: options.startTime, endTime, exit }; + }, + attribute: (key, value) => { + attributes.set(key, value); + }, + event: () => undefined, + addLinks: () => undefined, + }; + }, + }; +}; + +const spanNamed = (recorded: ReadonlyArray, name: string): RecordedSpan => { + const span = recorded.find((candidate) => candidate.name === name); + expect( + span, + `a span named ${name} is recorded (got: ${recorded.map((s) => s.name).join(", ") || "none"})`, + ).toBeDefined(); + return span ?? { name, attributes: new Map() }; +}; + +/** A counter-DO namespace whose single RPC method behaves as the test says. */ +const namespaceReturning = (increment: () => Promise) => ({ + idFromName: (name: string) => ({ toString: () => name }), + get: () => ({ increment }), +}); + +/** The three worker vars the production limiter reads at construction. */ +type CounterEnv = { + EXECUTION_RATE_LIMITER?: unknown; + EXECUTION_RATE_LIMIT_PER_HOUR?: string; + EXECUTION_RATE_LIMIT_CHECK_TIMEOUT_MS?: string; +}; + +const cloudEnv: CounterEnv = env; +const savedEnv: CounterEnv = { ...cloudEnv }; + +// Restore rather than leak: the limiter is built from the worker env, and a +// stale binding or budget would silently retune a later test. +afterEach(() => { + delete cloudEnv.EXECUTION_RATE_LIMITER; + delete cloudEnv.EXECUTION_RATE_LIMIT_PER_HOUR; + delete cloudEnv.EXECUTION_RATE_LIMIT_CHECK_TIMEOUT_MS; + Object.assign(cloudEnv, savedEnv); +}); + +// The exact platform fault behind the production issue: Cloudflare resets the +// object and the RPC rejects with a plain Error. The reference id is synthetic. +const DO_STORAGE_RESET = + "Internal error in Durable Object storage caused object to be reset; reference = 0000000000000000"; + +// oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: the Durable Object RPC is a promise that rejects with a platform Error; the test double has to fail the same way for the classification to mean anything +const rejectStorageReset = (): Promise => Promise.reject(new Error(DO_STORAGE_RESET)); + +/** Build the production limiter against a fake binding and worker env. */ +const cloudLimiter = (options: { + readonly namespace: ReturnType; + readonly limit: string; + readonly timeoutMs?: string; +}): ReturnType => { + cloudEnv.EXECUTION_RATE_LIMITER = options.namespace; + cloudEnv.EXECUTION_RATE_LIMIT_PER_HOUR = options.limit; + if (options.timeoutMs !== undefined) + cloudEnv.EXECUTION_RATE_LIMIT_CHECK_TIMEOUT_MS = options.timeoutMs; + return makeCloudExecutionRateLimiter(() => Effect.succeed(false)); +}; + +const runExecuteTraced = ( + limiter: ReturnType, + recorded: Array, +) => + Effect.runPromise( + limiter + .decorate(ORG, engineStub) + .execute("code", { onElicitation: () => Effect.die("elicitation is not exercised here") }) + .pipe(Effect.withTracer(recordingTracer(recorded))), + ); + +describe("execution rate limiter — counter observability", () => { + it("reports a counter-DO fault as a typed, classified error on a named span", async () => { + const recorded: Array = []; + const limiter = cloudLimiter({ + namespace: namespaceReturning(rejectStorageReset), + limit: "10", + }); + const result = await runExecuteTraced(limiter, recorded); + + // Fail-open semantics are unchanged: a broken counter never blocks. + expect(result).toMatchObject({ result: "ran" }); + + const increment = spanNamed(recorded, "rate_limit.increment"); + expect(increment.attributes.get("rate_limit.counter.error_tag")).toBe("RateLimitCounterError"); + expect(increment.attributes.get("rate_limit.counter.error_code")).toBe("storage_reset"); + + const check = spanNamed(recorded, "rate_limit.check"); + expect(check.attributes.get("rate_limit.check.failed_open")).toBe(true); + expect(check.attributes.get("rate_limit.check.timed_out")).toBe(false); + expect(check.attributes.get("rate_limit.check.error_tag")).toBe("RateLimitCounterError"); + }); + + it("honours the EXECUTION_RATE_LIMIT_CHECK_TIMEOUT_MS override", async () => { + const recorded: Array = []; + const limiter = cloudLimiter({ + // Answers well past the tiny budget, with a count far over the cap. If + // the override were ignored the default 2s budget would let that count + // through and BLOCK the execution; honouring it times the check out and + // fails open instead, so the two outcomes are distinguishable without + // measuring wall time. + namespace: namespaceReturning( + () => new Promise((resolve) => setTimeout(() => resolve(999), 200)), + ), + limit: "10", + timeoutMs: "5", + }); + const result = await runExecuteTraced(limiter, recorded); + + expect(result).toMatchObject({ result: "ran" }); + + const check = spanNamed(recorded, "rate_limit.check"); + expect(check.attributes.get("rate_limit.check.timed_out")).toBe(true); + expect(check.attributes.get("rate_limit.check.error_tag")).toBe("RateLimitCheckTimeoutError"); + }); + + // The healthy check's span (count / limit / blocked / failed_open) is NOT + // asserted here: the cloud e2e scenario "the rate-limit counter check is + // visible in the exported spans" pins it on the real workerd + Durable + // Object topology, against the spans the worker actually exports. The two + // cases above stay because neither is reachable from the e2e harness — it + // has no fault seam for a Durable Object RPC, and the check budget is a + // process-wide worker var that the shared cloud boot cannot vary per + // scenario without disabling the backstop for the whole run. +}); + +// --------------------------------------------------------------------------- +// Counter Durable Object +// --------------------------------------------------------------------------- + +/** Minimal DO storage that counts the calls the increment path makes. */ +const fakeStorage = () => { + const values = new Map(); + const calls = { put: 0, setAlarm: 0, deleteAll: 0 }; + return { + calls, + storage: { + get: (key: string) => Promise.resolve(values.get(key)), + put: (key: string, value: unknown) => { + calls.put += 1; + values.set(key, value); + return Promise.resolve(); + }, + setAlarm: () => { + calls.setAlarm += 1; + return Promise.resolve(); + }, + deleteAll: () => { + calls.deleteAll += 1; + values.clear(); + return Promise.resolve(); + }, + }, + }; +}; + +const makeCounter = (storage: ReturnType["storage"]) => + // oxlint-disable-next-line executor/no-double-cast -- test double: only the four storage methods the counter uses are implemented + new ExecutionRateLimiterDO({ storage } as unknown as DurableObjectState, {} as Env); + +describe("execution rate-limit counter DO", () => { + it("writes the purge alarm once per window instead of on every increment", async () => { + // The alarm only has to outlive the window; rewriting it on every call put + // a second durable write on the hot path with the input gate closed. + const fake = fakeStorage(); + const counter = makeCounter(fake.storage); + + expect(await counter.increment(7)).toBe(1); + expect(await counter.increment(7)).toBe(2); + expect(await counter.increment(7)).toBe(3); + + expect(fake.calls.put, "every increment still persists the count").toBe(3); + expect(fake.calls.setAlarm, "the purge alarm is written once for the window").toBe(1); + }); + + it("moves the purge alarm when the window rolls", async () => { + const fake = fakeStorage(); + const counter = makeCounter(fake.storage); + + await counter.increment(7); + await counter.increment(7); + expect(await counter.increment(8), "a new window restarts the count").toBe(1); + + expect(fake.calls.setAlarm, "one alarm write per window, not per increment").toBe(2); + }); + + it("re-arms the purge alarm after it has fired", async () => { + const fake = fakeStorage(); + const counter = makeCounter(fake.storage); + + await counter.increment(7); + await counter.alarm(); + expect(fake.calls.deleteAll, "the alarm purges the counter's storage").toBe(1); + + expect(await counter.increment(7), "the purge reset the window's count").toBe(1); + expect(fake.calls.setAlarm, "a purged counter schedules a fresh purge").toBe(2); + }); +}); diff --git a/apps/cloud/src/engine/execution-rate-limit.ts b/apps/cloud/src/engine/execution-rate-limit.ts index 3b5ea8194c..906bee2522 100644 --- a/apps/cloud/src/engine/execution-rate-limit.ts +++ b/apps/cloud/src/engine/execution-rate-limit.ts @@ -27,7 +27,7 @@ // --------------------------------------------------------------------------- import { DurableObject, env } from "cloudflare:workers"; -import { Data, Effect } from "effect"; +import { Data, Effect, Predicate } from "effect"; import type * as Cause from "effect/Cause"; import type { ExecutionEngine } from "@executor-js/execution"; @@ -76,6 +76,66 @@ class RateLimitCheckTimeoutError extends Data.TaggedError("RateLimitCheckTimeout readonly timeoutMs: number; }> {} +/** + * Why a counter DO call failed, as a small closed vocabulary. + * + * The counter's failures are overwhelmingly transient Cloudflare platform + * faults, and they used to arrive at error reporting as one untyped + * `UnknownError: An error occurred in Effect.tryPromise` with no application + * frames — a group that says nothing and would eventually swallow a real + * misconfiguration too. The code is what makes a storage reset (retryable, + * expected) distinguishable from an overload or an outright unknown fault. + */ +export type RateLimitCounterErrorCode = + | "storage_reset" + | "overloaded" + | "exceeded_memory" + | "network" + | "unknown"; + +/** A counter DO call that failed, carrying the classification and the org. */ +export class RateLimitCounterError extends Data.TaggedError("RateLimitCounterError")<{ + readonly organizationId: string; + readonly code: RateLimitCounterErrorCode; + readonly reason: string; + readonly cause: unknown; +}> {} + +// Cloudflare surfaces these as plain `Error`s with documented message text; +// there is no structured code to read, so the message is the only signal. +// (A shared `classifyDurableObjectError` would be the right home for this once +// one exists.) +const counterErrorCode = (reason: string): RateLimitCounterErrorCode => { + if (/caused object to be reset/i.test(reason)) return "storage_reset"; + if (/overloaded/i.test(reason)) return "overloaded"; + if (/exceeded (its )?memory|out of memory/i.test(reason)) return "exceeded_memory"; + if (/network connection lost|connection.*(lost|reset)/i.test(reason)) return "network"; + return "unknown"; +}; + +/** + * The fail-open landing: record the outcome on the check span, warn, and allow + * the execution. Only failures that are NOT deliberate degradation reach the + * error reporter — see the call sites in `decide`. + */ +const failOpen = ( + error: unknown, + outcome: { readonly errorTag: string; readonly timedOut: boolean }, +): Effect.Effect => + Effect.gen(function* () { + yield* Effect.annotateCurrentSpan({ + "rate_limit.blocked": false, + "rate_limit.check.failed_open": true, + "rate_limit.check.timed_out": outcome.timedOut, + "rate_limit.check.error_tag": outcome.errorTag, + }); + yield* Effect.sync(() => { + console.warn("[rate-limit] execution rate limit check failed open:", error); + }); + if (!outcome.timedOut) yield* captureCauseEffect(error); + return { blocked: false } as const satisfies GateDecision; + }); + /** Internal sentinel for an exemption lookup that exceeded its time budget. */ class ExemptionCheckTimeoutError extends Data.TaggedError("ExemptionCheckTimeoutError")<{ readonly timeoutMs: number; @@ -106,6 +166,12 @@ type WindowRecord = { export class ExecutionRateLimiterDO extends DurableObject { private readonly counterStorage: DurableObjectState["storage"]; + /** + * The window this instance has already armed the purge alarm for. In-memory + * on purpose: it costs no storage read, and a fresh instance (eviction, cold + * start) simply re-arms on its first increment. + */ + private purgeArmedForWindow: number | null = null; constructor(ctx: DurableObjectState, doEnv: Env) { super(ctx, doEnv); @@ -119,11 +185,21 @@ export class ExecutionRateLimiterDO extends DurableObject { const stored = await this.counterStorage.get(WINDOW_RECORD_KEY); const count = stored && stored.windowId === windowId ? stored.count + 1 : 1; await this.counterStorage.put(WINDOW_RECORD_KEY, { windowId, count }); - await this.counterStorage.setAlarm(Date.now() + COUNTER_PURGE_AFTER_MS); + // The alarm only has to outlive the window, and it is set two windows out, + // so once per window is enough — rewriting it on every increment put a + // second durable write and an alarm-manager update on the hot path of + // every execution, with the input gate closed across all three. `count` + // back at 1 means the window rolled (or a purge already ran), so the + // deadline moves with it. + if (count === 1 || this.purgeArmedForWindow !== windowId) { + await this.counterStorage.setAlarm(Date.now() + COUNTER_PURGE_AFTER_MS); + this.purgeArmedForWindow = windowId; + } return count; } async alarm(): Promise { + this.purgeArmedForWindow = null; await this.counterStorage.deleteAll(); } } @@ -132,11 +208,17 @@ export class ExecutionRateLimiterDO extends DurableObject { // Client // --------------------------------------------------------------------------- -/** Count one execution for (organizationId, windowId); returns the new count. */ +/** + * Count one execution for (organizationId, windowId); returns the new count. + * + * The failure channel is typed rather than `unknown` so the fail-open path can + * tell a counter fault from a blown budget by tag, and so error reporting + * groups by cause instead of by one opaque `UnknownError`. + */ export type RateLimitIncrement = ( organizationId: string, windowId: number, -) => Effect.Effect; +) => Effect.Effect; export type ExecutionRateLimiter = { readonly decorate: ( @@ -239,9 +321,25 @@ export const makeExecutionRateLimiter = ( }), Effect.flatMap((count): Effect.Effect => { // Under the cap: no exemption lookup, no extra I/O. - if (count <= limit) return Effect.succeed({ blocked: false }); + if (count <= limit) + return Effect.as( + Effect.annotateCurrentSpan({ + "rate_limit.count": count, + "rate_limit.blocked": false, + "rate_limit.check.failed_open": false, + }), + { blocked: false }, + ); return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan({ + "rate_limit.count": count, + "rate_limit.check.failed_open": false, + }); if (yield* resolveExemption(organizationId)) { + yield* Effect.annotateCurrentSpan({ + "rate_limit.blocked": false, + "rate_limit.exempt": true, + }); return { blocked: false } as const satisfies GateDecision; } // The only record that the backstop fired. A blocked execution is @@ -256,6 +354,10 @@ export const makeExecutionRateLimiter = ( `[rate-limit] blocked execution for ${organizationId}: ${count} > ${limit} in window ${windowId}`, ); }); + yield* Effect.annotateCurrentSpan({ + "rate_limit.blocked": true, + "rate_limit.exempt": false, + }); return { blocked: true, error: new ExecutionRateLimitExceededError({ @@ -267,15 +369,36 @@ export const makeExecutionRateLimiter = ( }), // FAIL OPEN: the backstop must never block executions because its // counter is unreachable or slow. + // + // A check that blew its own budget is DELIBERATE degradation, not an + // exception — the timeout exists precisely so a slow counter can't + // stall a user-facing execution. It is measured on the span + // (`rate_limit.check.timed_out`), where a step change in the rate is + // alertable, rather than paged per occurrence, which is what buried + // real counter failures under one opaque group. Everything else (RPC + // faults, a missing binding) still reports. + Effect.catchTag("RateLimitCheckTimeoutError", (error) => + failOpen(error, { errorTag: "RateLimitCheckTimeoutError", timedOut: true }), + ), + // A catch-all rather than a second `catchTag`: fail-open is a hard + // requirement and must not depend on the failure being one the types + // predicted. Effect.catch((error: unknown) => - Effect.gen(function* () { - yield* Effect.sync(() => { - console.warn("[rate-limit] execution rate limit check failed open:", error); - }); - yield* captureCauseEffect(error); - return { blocked: false } as const satisfies GateDecision; + failOpen(error, { + errorTag: Predicate.isTagged(error, "RateLimitCounterError") + ? "RateLimitCounterError" + : "unknown", + timedOut: false, }), ), + Effect.withSpan("rate_limit.check", { + attributes: { + "rate_limit.organization_id": organizationId, + "rate_limit.window_id": windowId, + "rate_limit.limit": limit, + "rate_limit.check.timeout_ms": timeoutMs, + }, + }), ); }); @@ -320,17 +443,53 @@ export const makeCloudExecutionRateLimiter = ( ); return makeExecutionRateLimiter(() => Effect.succeed(0)); } - return makeExecutionRateLimiter( - (organizationId, windowId) => - Effect.tryPromise(() => { + return makeExecutionRateLimiter(counterIncrement(namespace), { + limit, + timeoutMs: resolveCheckTimeoutMs(), + isExempt, + }); +}; + +/** + * The counter DO RPC, as a traced and typed increment. + * + * The span is the whole point: this call is a blocking, cold-startable hop on + * the execute hot path, and until it had one nothing about its duration was + * measurable — the only evidence it was slow was the fail-open warning 2s + * later. The typed error replaces Effect's generic `UnknownError`, which + * reported no org, no window, and no hint that a Durable Object was involved. + */ +const counterIncrement = + (namespace: RateLimiterNamespace): RateLimitIncrement => + (organizationId, windowId) => + Effect.tryPromise({ + try: () => { const stub = namespace.get( namespace.idFromName(organizationId), ) as ExecutionRateLimiterStub; return stub.increment(windowId); + }, + catch: (cause) => { + // oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- boundary: the Durable Object RPC rejects with a plain platform Error whose message text is the only classification signal Cloudflare gives + const reason = cause instanceof Error ? cause.message : String(cause); + return new RateLimitCounterError({ + organizationId, + code: counterErrorCode(reason), + reason, + cause, + }); + }, + }).pipe( + Effect.tapError((error) => + Effect.annotateCurrentSpan({ + "rate_limit.counter.error_tag": "RateLimitCounterError", + "rate_limit.counter.error_code": error.code, + }), + ), + Effect.withSpan("rate_limit.increment", { + attributes: { "rate_limit.window_id": windowId }, }), - { limit, isExempt }, - ); -}; + ); /** * The per-org hourly cap: the `EXECUTION_RATE_LIMIT_PER_HOUR` env override @@ -344,3 +503,18 @@ const resolveRateLimit = (): number => { const parsed = Number.parseInt(raw, 10); return Number.isInteger(parsed) && parsed > 0 ? parsed : EXECUTIONS_PER_ORG_PER_HOUR; }; + +/** + * The counter's time budget: the `EXECUTION_RATE_LIMIT_CHECK_TIMEOUT_MS` env + * override or `RATE_LIMIT_CHECK_TIMEOUT_MS` when it's unset or unparseable. + * Same precedent and same purpose as `EXECUTION_RATE_LIMIT_PER_HOUR`: the + * production 2s budget can't be blown on demand, so tests set a tiny one to + * drive the fail-open path deterministically. Production leaves it unset. + */ +const resolveCheckTimeoutMs = (): number => { + const raw = (env as { EXECUTION_RATE_LIMIT_CHECK_TIMEOUT_MS?: string }) + .EXECUTION_RATE_LIMIT_CHECK_TIMEOUT_MS; + if (raw === undefined) return RATE_LIMIT_CHECK_TIMEOUT_MS; + const parsed = Number.parseInt(raw, 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : RATE_LIMIT_CHECK_TIMEOUT_MS; +}; diff --git a/apps/cloud/src/env-augment.d.ts b/apps/cloud/src/env-augment.d.ts index ef0b54da4a..bb31aa00a6 100644 --- a/apps/cloud/src/env-augment.d.ts +++ b/apps/cloud/src/env-augment.d.ts @@ -60,6 +60,13 @@ declare global { // number to drive the backstop. Production leaves it unset. EXECUTION_RATE_LIMIT_PER_HOUR?: string; + // Optional override for the counter DO's check budget in milliseconds + // (defaults to RATE_LIMIT_CHECK_TIMEOUT_MS = 2000 when unset or + // unparseable). Same purpose as the cap override: the production budget + // can't be blown on demand, so tests set a tiny one to exercise the + // fail-open path. Production leaves it unset. + EXECUTION_RATE_LIMIT_CHECK_TIMEOUT_MS?: string; + // First-party OAuth apps (executor-owned provider registrations). Each // pair enables one-click connect through `first-party:`; an // unset pair simply ships no first-party app for that provider. The diff --git a/e2e/cloud/mcp-execution-limits.test.ts b/e2e/cloud/mcp-execution-limits.test.ts index 4c3272267c..40bbb290d2 100644 --- a/e2e/cloud/mcp-execution-limits.test.ts +++ b/e2e/cloud/mcp-execution-limits.test.ts @@ -21,8 +21,11 @@ import { RATE_LIMIT_BLOCKED_MESSAGE, } from "../../apps/cloud/src/engine/execution-limit-messages"; import { scenario } from "../src/scenario"; -import { Autumn, Billing, Mcp, Target } from "../src/services"; -import { E2E_EXECUTION_RATE_LIMIT } from "../setup/execution-limits"; +import { Autumn, Billing, Mcp, Target, Telemetry } from "../src/services"; +import { + E2E_EXECUTION_RATE_LIMIT, + E2E_EXECUTION_RATE_LIMIT_CHECK_TIMEOUT_MS, +} from "../setup/execution-limits"; import type { Identity } from "../src/target"; const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label; @@ -162,6 +165,7 @@ scenario( Effect.gen(function* () { yield* Billing; const autumn = yield* Autumn; + const telemetry = yield* Telemetry; const target = yield* Target; const mcp = yield* Mcp; @@ -196,6 +200,109 @@ scenario( expect(metered.length, "only the allowed executions are metered — the blocked one is not").toBe( RATE_LIMIT, ); + + // The block is also legible in telemetry, not just to the client. This is + // the OTHER branch of the check — the allowed path is pinned by the span + // scenario below — and it is the one an operator reaches for when a + // customer reports being cut off mid-workload. + const blockedCheck = yield* telemetry.expectSpan({ + operation: "rate_limit.check", + attributes: { "rate_limit.organization_id": customerId, "rate_limit.blocked": "true" }, + }); + expect( + blockedCheck.span.tags["rate_limit.count"], + "the span carries the count that crossed the cap", + ).toBe(String(RATE_LIMIT + 1)); + expect( + blockedCheck.span.tags["rate_limit.exempt"], + "the org was not exempt — that is why it was blocked", + ).toBe("false"); + expect( + blockedCheck.span.tags["rate_limit.check.failed_open"], + "a real block is not a degraded check wearing a block's clothes", + ).toBe("false"); + }), +); + +scenario( + "Billing · the rate-limit counter check is visible in the exported spans", + { timeout: 180_000 }, + Effect.gen(function* () { + // The backstop's counter is a blocking Durable Object hop on the execute + // hot path. It used to run untraced, so the only production evidence it + // was slow was a fail-open warning arriving 2s later — and a counter fault + // reached the error reporter as an untyped wrapper with no application + // frames. This pins the measurement seam where it is actually read: the + // spans the worker EXPORTS, over the real workerd + Durable Object + // topology, not a span object built in a unit test. + yield* Billing; + const telemetry = yield* Telemetry; + const target = yield* Target; + const mcp = yield* Mcp; + + const identity = yield* target.newIdentity(); + const bearer = yield* mcp.mintBearer(emailOf(identity)); + const customerId = orgIdOf(bearer); + + const session = mcp.session(identity); + const ok = yield* session.call("execute", { code: "return 4 + 5;" }); + expect(ok.ok, "the execution runs").toBe(true); + expect(ok.text, "it returns its value").toContain("9"); + + // A second RPC on the same session, deliberately NOT an execute (it must + // not move the counter this scenario asserts on). The session DO ships its + // spans on a bounded, best-effort `waitUntil` flush after each RPC — a + // session that makes exactly one call in its whole life is asserting on + // that one flush winning a race, which it loses often enough on a loaded + // machine to be useless. A real client keeps talking to its session; so + // does this one. + yield* session.listTools(); + + const check = yield* telemetry.expectSpan({ + operation: "rate_limit.check", + attributes: { "rate_limit.organization_id": customerId }, + }); + expect( + check.span.tags["rate_limit.count"], + "the org's count for the window is on the span", + ).toBe("1"); + expect(check.span.tags["rate_limit.limit"], "so is the cap it was measured against").toBe( + String(RATE_LIMIT), + ); + expect(check.span.tags["rate_limit.blocked"], "an allowed execution is recorded as such").toBe( + "false", + ); + // The two attributes production alerting reads. Both false on a healthy + // check; a step change in either is the signal that used to arrive only as + // a stream of untyped error reports. + expect(check.span.tags["rate_limit.check.failed_open"], "the check did not fail open").toBe( + "false", + ); + // The budget on the span is the boot's OVERRIDE, not the compiled-in 2000ms + // default — the only end-to-end proof that + // EXECUTION_RATE_LIMIT_CHECK_TIMEOUT_MS actually reaches the worker and + // retunes the check. A knob that silently falls back to the constant would + // read as healthy here and as a working knob everywhere else. + expect( + check.span.tags["rate_limit.check.timeout_ms"], + "the budget on the span is the one the worker was booted with", + ).toBe(String(E2E_EXECUTION_RATE_LIMIT_CHECK_TIMEOUT_MS)); + + // The DO round trip itself is timed, inside the same trace as the check. + // Polled, not read once: the parent and the child ride different export + // batches, so a one-shot read here races the exporter and fails ~1 run in 5. + const increment = yield* telemetry.expectSpan({ + operation: "rate_limit.increment", + traceId: check.traceId, + }); + expect( + increment.span.tags["rate_limit.window_id"], + "the counter span is for the window the check decided against", + ).toBe(check.span.tags["rate_limit.window_id"]); + expect( + increment.span.tags["rate_limit.counter.error_code"], + "a healthy counter call carries no fault classification", + ).toBeUndefined(); }), ); @@ -209,6 +316,7 @@ scenario( // blocks a free org (the scenario above) must sail past the cap here. yield* Billing; const autumn = yield* Autumn; + const telemetry = yield* Telemetry; const target = yield* Target; const mcp = yield* Mcp; @@ -245,5 +353,25 @@ scenario( count: overCap, }); expect(metered.length, "every execution past the cap is still metered").toBe(overCap); + + // Third branch of the check: over the cap AND allowed. Without the reason + // on the span, this is indistinguishable in production from a check that + // never ran — which is exactly the ambiguity that made the 2026-08-18 + // block hard to explain. + const exemptCheck = yield* telemetry.expectSpan({ + operation: "rate_limit.check", + attributes: { "rate_limit.organization_id": customerId, "rate_limit.exempt": "true" }, + }); + expect( + Number(exemptCheck.span.tags["rate_limit.count"]), + "the exemption was recorded on a check that was genuinely over the cap", + ).toBeGreaterThan(RATE_LIMIT); + expect(exemptCheck.span.tags["rate_limit.blocked"], "and it allowed the execution").toBe( + "false", + ); + expect( + exemptCheck.span.tags["rate_limit.check.failed_open"], + "the org ran because it is exempt, not because the counter fell over", + ).toBe("false"); }), ); diff --git a/e2e/setup/cloud.boot.ts b/e2e/setup/cloud.boot.ts index 851ab85cc4..4c24cd3ed9 100644 --- a/e2e/setup/cloud.boot.ts +++ b/e2e/setup/cloud.boot.ts @@ -12,7 +12,10 @@ import { createEmulator } from "@executor-js/emulate"; import { bootProcesses, waitForBoot, waitForHttp } from "./boot"; import { AUTUMN_PLAN_SEED } from "./autumn-plans"; -import { E2E_EXECUTION_RATE_LIMIT } from "./execution-limits"; +import { + E2E_EXECUTION_RATE_LIMIT, + E2E_EXECUTION_RATE_LIMIT_CHECK_TIMEOUT_MS, +} from "./execution-limits"; export const cloudDir = fileURLToPath(new URL("../../apps/cloud/", import.meta.url)); @@ -126,6 +129,11 @@ export const bootCloud = async (options: CloudBootOptions): Promise // scenario's per-org execute count. Reaches the worker via // CLOUDFLARE_INCLUDE_PROCESS_ENV, same as ALLOW_LOCAL_NETWORK. EXECUTION_RATE_LIMIT_PER_HOUR: String(E2E_EXECUTION_RATE_LIMIT), + // Set to a value that is NOT the compiled-in default, so the span the + // worker exports proves this override was actually read rather than + // silently ignored (execution-limits.ts explains why it is longer, not + // shorter, than the default). + EXECUTION_RATE_LIMIT_CHECK_TIMEOUT_MS: String(E2E_EXECUTION_RATE_LIMIT_CHECK_TIMEOUT_MS), // Throwaway PGlite on its own port + dir so it never fights `bun dev`. DEV_DB_PORT: String(options.dbPort), DEV_DB_PATH: dbPath, diff --git a/e2e/setup/execution-limits.ts b/e2e/setup/execution-limits.ts index 0294644d6e..16395523dd 100644 --- a/e2e/setup/execution-limits.ts +++ b/e2e/setup/execution-limits.ts @@ -12,3 +12,18 @@ // comfortable headroom. If a scenario ever fails with the rate-limit backstop // message, it outgrew this cap: raise it here, never in the boot env alone. export const E2E_EXECUTION_RATE_LIMIT = 20; + +// The e2e worker's counter-check budget (EXECUTION_RATE_LIMIT_CHECK_TIMEOUT_MS), +// same two consumers as the cap above. +// +// This exists to make the OVERRIDE ITSELF observable end to end. The budget is +// stamped on the `rate_limit.check` span, so a value that differs from the +// compiled-in default (RATE_LIMIT_CHECK_TIMEOUT_MS = 2000) is the difference +// between "the worker read the env var" and "the worker fell back to the +// constant and nobody noticed". A knob nothing reads is worse than no knob. +// +// It is deliberately LONGER than the default, not shorter: a shorter budget +// would time the check out and fail open on every execution, which would take +// the backstop scenario down with it. Longer only means a genuinely wedged +// counter stalls an execution 3s instead of 2s, which no scenario depends on. +export const E2E_EXECUTION_RATE_LIMIT_CHECK_TIMEOUT_MS = 3000; From 27b044d1f2a9c40dcdc491856aac81d193dc680b Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:43:32 -0700 Subject: [PATCH 129/133] MCP session cold init resilience (#1787) * MCP session cold init: carry the org identity in the session props * Promote classification tags only from the errors that define them * Cold-init e2e: prove the session works and count the org reads deterministically --- apps/cloud/src/auth/context.ts | 10 +- apps/cloud/src/auth/errors.ts | 97 +++++++- .../src/auth/user-store-error.node.test.ts | 128 +++++++++++ apps/cloud/src/mcp/agent-handler.ts | 27 +++ apps/cloud/src/mcp/auth-provider.test.ts | 6 +- apps/cloud/src/mcp/auth-provider.ts | 31 ++- apps/cloud/src/mcp/auth.ts | 21 +- apps/cloud/src/mcp/session-durable-object.ts | 41 ++-- apps/cloud/src/mcp/session-meta.node.test.ts | 167 ++++++++++++++ apps/cloud/src/mcp/session-meta.ts | 209 ++++++++++++++++++ apps/cloud/src/observability/index.ts | 63 +++++- .../src/mcp/session-durable-object.ts | 9 +- e2e/cloud/mcp-session-cold-init.test.ts | 191 ++++++++++++++++ .../mcp/agent-session-durable-object.test.ts | 115 +++++++++- .../src/mcp/agent-session-durable-object.ts | 46 +++- 15 files changed, 1106 insertions(+), 55 deletions(-) create mode 100644 apps/cloud/src/auth/user-store-error.node.test.ts create mode 100644 apps/cloud/src/mcp/session-meta.node.test.ts create mode 100644 apps/cloud/src/mcp/session-meta.ts create mode 100644 e2e/cloud/mcp-session-cold-init.test.ts diff --git a/apps/cloud/src/auth/context.ts b/apps/cloud/src/auth/context.ts index dd713b189d..ce8aa1804f 100644 --- a/apps/cloud/src/auth/context.ts +++ b/apps/cloud/src/auth/context.ts @@ -1,7 +1,7 @@ import { Context, Effect, Layer } from "effect"; import { makeUserStore } from "../auth/user-store"; import { DbService } from "../db/db"; -import { UserStoreError, tryPromiseService, withServiceLogging } from "./errors"; +import { tryPromiseService, userStoreErrorFromFailure, withServiceLogging } from "./errors"; // --------------------------------------------------------------------------- // UserStoreService — wraps the Drizzle-backed user store with Effect @@ -10,13 +10,15 @@ import { UserStoreError, tryPromiseService, withServiceLogging } from "./errors" type RawStore = ReturnType; // `op` names the store call so every span reads `user_store.` -// instead of one undifferentiated "user_store" bucket, and failures log which -// query actually failed. +// instead of one undifferentiated "user_store" bucket, failures log which +// query actually failed, and — because the same `op` is threaded onto the +// public error alongside the classified driver reason — an error report is +// diagnosable without the trace. const makeService = (store: RawStore) => ({ use: (op: string, fn: (s: RawStore) => Promise) => withServiceLogging( `user_store.${op}`, - () => new UserStoreError(), + (failure) => userStoreErrorFromFailure(op, failure), tryPromiseService(() => fn(store)), ), }); diff --git a/apps/cloud/src/auth/errors.ts b/apps/cloud/src/auth/errors.ts index 927ae59f64..debf0b0635 100644 --- a/apps/cloud/src/auth/errors.ts +++ b/apps/cloud/src/auth/errors.ts @@ -1,10 +1,103 @@ import { Data, Effect, Option, Predicate, Schema } from "effect"; +// How a user-store call failed, classified from the driver cause. Safe to put +// on the wire and on a Sentry tag: it names a failure MODE, never a query, a +// value, or a customer. +export const USER_STORE_FAILURE_REASONS = [ + "connect_timeout", + "connection_closed", + "query", + "unknown", +] as const; + +export type UserStoreFailureReason = (typeof USER_STORE_FAILURE_REASONS)[number]; + +/** + * The public failure of every cloud user-store call. + * + * It carries the two fields that make an issue diagnosable from the error + * alone: which store call failed, and how. Before those existed the error had + * an empty field set, so Sentry showed a titleless, messageless issue and the + * only cause detail (the pretty-printed Effect cause in a Sentry `extra`) is + * scrubbed server-side — the failing operation and the driver reason existed + * only in the trace store. Same shape as `WorkOSError.status`: a small, safe + * classification field threaded at the service boundary. + */ export class UserStoreError extends Schema.TaggedErrorClass()( "UserStoreError", - {}, + { + /** The store call that failed, e.g. `getOrganization`. */ + operation: Schema.String, + /** How it failed, classified from the driver cause chain. */ + reason: Schema.Literals(USER_STORE_FAILURE_REASONS), + }, { httpApiStatus: 500 }, -) {} +) { + override get message(): string { + return `user store ${this.operation} failed: ${this.reason}`; + } +} + +/** Reasons a retry can plausibly clear: the query never reached a healthy + * server. A `query` failure is deterministic and must not be retried. */ +export const isTransientUserStoreReason = (reason: UserStoreFailureReason): boolean => + reason === "connect_timeout" || reason === "connection_closed"; + +// postgres.js tags its connection failures with a string `code` +// (`CONNECT_TIMEOUT`, `CONNECTION_CLOSED`, …) and its query failures with the +// SQLSTATE. Drizzle re-throws both wrapped in its own "Failed query" error with +// the driver error in `.cause`, and the service adapter wraps that again, so +// the classification walks the chain rather than inspecting one level. +const MAX_CAUSE_DEPTH = 8; + +const REASON_BY_DRIVER_CODE: Readonly> = { + CONNECT_TIMEOUT: "connect_timeout", + ETIMEDOUT: "connect_timeout", + CONNECTION_CLOSED: "connection_closed", + CONNECTION_ENDED: "connection_closed", + CONNECTION_DESTROYED: "connection_closed", + ECONNREFUSED: "connection_closed", + ECONNRESET: "connection_closed", +}; + +const stringCodeOf = (value: unknown): string | undefined => { + if (typeof value !== "object" || value === null) return undefined; + const code = (value as { readonly code?: unknown }).code; + return typeof code === "string" ? code : undefined; +}; + +const driverCodesOf = (failure: unknown): readonly string[] => { + const codes: string[] = []; + let current: unknown = isServiceAdapterError(failure) ? failure.cause : failure; + for ( + let depth = 0; + depth < MAX_CAUSE_DEPTH && current !== undefined && current !== null; + depth++ + ) { + const code = stringCodeOf(current); + if (code !== undefined) codes.push(code); + current = + typeof current === "object" ? (current as { readonly cause?: unknown }).cause : undefined; + } + return codes; +}; + +/** Classify a raw store failure. A recognised connection code wins; any other + * driver code (a SQLSTATE) is a deterministic query failure; nothing at all is + * `unknown`. */ +export const userStoreReasonFromCause = (failure: unknown): UserStoreFailureReason => { + const codes = driverCodesOf(failure); + for (const code of codes) { + const reason = REASON_BY_DRIVER_CODE[code]; + if (reason !== undefined) return reason; + } + return codes.length > 0 ? "query" : "unknown"; +}; + +/** Build the public `UserStoreError` for a store-adapter failure, naming the + * operation the call site already knows and classifying the driver cause. */ +export const userStoreErrorFromFailure = (operation: string, failure: unknown): UserStoreError => + new UserStoreError({ operation, reason: userStoreReasonFromCause(failure) }); export class WorkOSError extends Schema.TaggedErrorClass()( "WorkOSError", diff --git a/apps/cloud/src/auth/user-store-error.node.test.ts b/apps/cloud/src/auth/user-store-error.node.test.ts new file mode 100644 index 0000000000..880c6fa37d --- /dev/null +++ b/apps/cloud/src/auth/user-store-error.node.test.ts @@ -0,0 +1,128 @@ +// `UserStoreError` is the public failure of every cloud user-store call, and it +// used to carry NOTHING: no operation, no reason, no message. Sentry grouped +// every store failure — a connect timeout, a missing table, a constraint +// violation — into one titleless issue, and the only cause detail (the +// pretty-printed Effect cause stuffed into a Sentry `extra`) is scrubbed +// server-side, so the issue was undiagnosable from Sentry alone. +// +// This pins the two safe classification fields it must carry instead: +// `operation` (already in hand at the call site) and `reason` (classified from +// the driver cause the way `statusFromWorkOSCause` classifies WorkOS causes). +import { createServer, type Server, type Socket } from "node:net"; + +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer, Result } from "effect"; +import postgres from "postgres"; + +import { UserStoreService } from "./context"; +import { ServiceAdapterError, userStoreReasonFromCause, type UserStoreError } from "./errors"; +import { DbService } from "../db/db"; + +// A socket that completes the TCP handshake and then says nothing — exactly +// what a wedged Hyperdrive/Postgres endpoint looks like to postgres.js, and the +// only way to obtain the driver's REAL connect-timeout error object rather than +// a hand-written imitation of it. +const blackHolePort = async (): Promise<{ + readonly port: number; + readonly close: () => Promise; +}> => { + const sockets = new Set(); + const server: Server = createServer((socket) => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + // oxlint-disable-next-line executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: the fixture cannot run without a bound port + if (address === null || typeof address === "string") throw new Error("no port"); + return { + port: address.port, + close: () => + new Promise((resolve) => { + // The timed-out client leaves its half-open socket attached; without + // dropping it first, `close` waits for a peer that will never speak. + for (const socket of sockets) socket.destroy(); + server.close(() => resolve()); + }), + }; +}; + +const realConnectTimeoutError = async (): Promise => { + const hole = await blackHolePort(); + const sql = postgres(`postgresql://postgres:postgres@127.0.0.1:${hole.port}/postgres`, { + max: 1, + connect_timeout: 1, + fetch_types: false, + onnotice: () => undefined, + }); + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: capturing the driver's own thrown error IS the fixture + try { + await sql`select 1`; + // oxlint-disable-next-line executor/no-error-constructor, executor/no-try-catch-or-throw -- boundary: the fixture is unusable if the socket answered + throw new Error("expected the connect to time out"); + } catch (error) { + return error; + } finally { + // oxlint-disable-next-line executor/no-promise-catch -- boundary: best-effort teardown of a connection that never opened + await sql.end({ timeout: 0 }).catch(() => undefined); + await hole.close(); + } +}; + +// Drizzle re-throws driver failures wrapped in its own error with the failing +// SQL in the message and the driver error in `.cause` — the shape production +// actually reports (`Failed query: select … -> write CONNECT_TIMEOUT …`). +const wrappedLikeDrizzle = (cause: unknown): Error => + // oxlint-disable-next-line executor/no-error-constructor -- boundary: reproducing the driver wrapper shape the store really fails with + Object.assign(new Error('Failed query: select "id" from "organizations" where "id" = $1'), { + cause, + }); + +const stubDb = Layer.succeed(DbService)({ db: {} as never }); + +const failingStoreCall = ( + operation: string, + failure: unknown, +): Effect.Effect> => + Effect.gen(function* () { + const users = yield* UserStoreService; + // oxlint-disable-next-line executor/no-promise-reject -- boundary: the store adapter lifts a REJECTING promise; rejecting is the fixture + return yield* users.use(operation, () => Promise.reject(failure)); + }).pipe( + Effect.provide(UserStoreService.Live.pipe(Layer.provide(stubDb))), + Effect.result, + ) as Effect.Effect>; + +describe("UserStoreError classification", () => { + it("classifies the driver cause chain", async () => { + const driverError = await realConnectTimeoutError(); + + expect(userStoreReasonFromCause(wrappedLikeDrizzle(driverError))).toBe("connect_timeout"); + expect( + userStoreReasonFromCause(new ServiceAdapterError({ cause: wrappedLikeDrizzle(driverError) })), + ).toBe("connect_timeout"); + expect( + userStoreReasonFromCause( + // oxlint-disable-next-line executor/no-error-constructor -- boundary: a SQLSTATE failure shape + Object.assign(new Error('relation "organizations" does not exist'), { code: "42P01" }), + ), + ).toBe("query"); + // oxlint-disable-next-line executor/no-error-constructor -- boundary: a failure with nothing to classify + expect(userStoreReasonFromCause(new Error("something else"))).toBe("unknown"); + }, 20_000); + + it("carries operation, reason and a stable message on the public error", async () => { + const driverError = await realConnectTimeoutError(); + + const result = await Effect.runPromise( + failingStoreCall("getOrganization", wrappedLikeDrizzle(driverError)), + ); + + expect(Result.isFailure(result)).toBe(true); + if (!Result.isFailure(result)) return; + expect(result.failure.operation).toBe("getOrganization"); + expect(result.failure.reason).toBe("connect_timeout"); + expect(result.failure.message).toContain("getOrganization"); + expect(result.failure.message).toContain("connect_timeout"); + }, 20_000); +}); diff --git a/apps/cloud/src/mcp/agent-handler.ts b/apps/cloud/src/mcp/agent-handler.ts index 6d46bbd80b..0ec697c911 100644 --- a/apps/cloud/src/mcp/agent-handler.ts +++ b/apps/cloud/src/mcp/agent-handler.ts @@ -27,9 +27,12 @@ import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; import { wrapMcpSseResponse } from "../observability/memory-metrics"; import { WorkerTelemetryLive } from "../observability/telemetry"; import { cloudMcpAuth } from "./auth-provider"; +import { isMcpSessionMetaUnavailable } from "./session-meta"; import { McpSessionDOSqlite } from "./session-durable-object"; import { parseTraceparent } from "./traceparent"; +const MCP_SESSION_UNAVAILABLE_MESSAGE = "Session storage temporarily unavailable - please retry"; + const corsPreflightResponse = (): Response => new Response(null, { status: 204, @@ -171,6 +174,13 @@ const propsForPrincipal = ( return { session: { organizationId: principal.organizationId, + // The org record the live membership check resolved microseconds ago, + // handed to the session DO so it never opens a connection of its own to + // re-read it. An unnamed org (no auth plane could resolve one) is + // omitted rather than sent empty, so the DO can tell "not carried" from + // "carried, and blank". + ...(principal.organizationName ? { organizationName: principal.organizationName } : {}), + ...(principal.organizationSlug ? { organizationSlug: principal.organizationSlug } : {}), userId: principal.accountId, elicitationMode: readElicitationMode(request), artifactsEnabled: readArtifactsEnabled(request), @@ -306,6 +316,23 @@ export const makeCloudMcpAgentHandler = () => { // vocabulary — a deploy, a storage timeout, a cancelled // blockConcurrencyWhile — which reaches here through the agents SDK's own // `getServerByName` retry and used to 500 identically. + // The session DO could not reach the organization directory to name the + // org (after its own bounded retry). Transient by construction, so it + // gets the same retryable envelope a WorkOS blip gets on the auth path — + // not an unclassified 500 the agents SDK then retries the whole DO + // operation over, which is what turned a 10s connect timeout into a + // half-minute client hang. + // + // Checked BEFORE the platform classifier: this is an application failure + // that merely escapes through the same seam, and it names its own cause. + // The classifier only recognizes the runtime's own reset vocabulary, so + // the two never contend — the order just keeps it that way if either + // vocabulary grows. + if (isMcpSessionMetaUnavailable(error)) { + return jsonRpcErrorBody(503, -32001, MCP_SESSION_UNAVAILABLE_MESSAGE, { + retryAfterSeconds: UNAVAILABLE_RETRY_AFTER_SECONDS, + }); + } const failure = classifyDurableObjectError(error); if (!failure) { // 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 diff --git a/apps/cloud/src/mcp/auth-provider.test.ts b/apps/cloud/src/mcp/auth-provider.test.ts index 454573ade5..bcd6009efa 100644 --- a/apps/cloud/src/mcp/auth-provider.test.ts +++ b/apps/cloud/src/mcp/auth-provider.test.ts @@ -70,9 +70,11 @@ const stubOrgAuthNoMembership = Layer.succeed(McpOrganizationAuth)({ authorize: () => Effect.succeed(null), }); -// `authorize` SUCCEEDS with an org id — active membership. +// `authorize` SUCCEEDS with the resolved org record — active membership. The +// record, not just the id: the session props carry the org's name and slug so +// the session DO never re-reads the row. const stubOrgAuthActive = Layer.succeed(McpOrganizationAuth)({ - authorize: () => Effect.succeed(ORG_ID), + authorize: () => Effect.succeed({ id: ORG_ID, name: "Stub Org", slug: "stub-org" }), }); // A failure that is not a WorkOSError at all (e.g. the per-request DB layer diff --git a/apps/cloud/src/mcp/auth-provider.ts b/apps/cloud/src/mcp/auth-provider.ts index 8a5944f052..92384da164 100644 --- a/apps/cloud/src/mcp/auth-provider.ts +++ b/apps/cloud/src/mcp/auth-provider.ts @@ -55,6 +55,7 @@ import { McpAuthLive, McpOrganizationAuth, McpOrganizationAuthLive, + type AuthorizedMcpOrganization, type McpAuthResult, type VerifiedToken, } from "./auth"; @@ -88,16 +89,24 @@ const ORGANIZATION_AUTHORIZE_UNAVAILABLE = /** * Enrich a cloud {@link VerifiedToken} (which carries only accountId + - * organizationId) into the full {@link Principal} the seam validates. The - * envelope only uses `accountId` + `organizationId` for ownership; cloud - * resolves org name/email inside the DO, so the cosmetic identity fields carry - * empty placeholders. `organizationId` is guaranteed non-null here because the - * Forbidden branch already rejected the no-org case before Authenticated. + * organizationId) into the full {@link Principal} the seam validates. + * + * The org name and slug come from the record the live membership check just + * resolved — this is the whole point of `authorize` returning the record rather + * than an id. They used to be dropped here (`organizationName: ""`), which left + * the session Durable Object to re-read the same row over a fresh database + * connection on every cold init. `email` stays a placeholder: the envelope only + * uses `accountId` + `organizationId` for ownership, and nothing downstream + * reads it. */ -const principalFromToken = (token: VerifiedToken, organizationId: string): Principal => ({ +const principalFromToken = ( + token: VerifiedToken, + organization: AuthorizedMcpOrganization, +): Principal => ({ accountId: token.accountId, - organizationId, - organizationName: "", + organizationId: organization.id, + organizationName: organization.name, + ...(organization.slug === undefined ? {} : { organizationSlug: organization.slug }), email: "", name: null, avatarUrl: null, @@ -223,9 +232,9 @@ export const cloudMcpAuthProviderLayer: Layer.Layer< // caller genuinely holds no active membership (revoked / never a member) // — a real Forbidden, which the handler may act on by condemning the // session. - const organizationId = authorizeResult.success; - if (!organizationId) return forbidden(NO_ORGANIZATION_MESSAGE, -32001); - return authenticated(principalFromToken(token, organizationId)); + const organization = authorizeResult.success; + if (!organization) return forbidden(NO_ORGANIZATION_MESSAGE, -32001); + return authenticated(principalFromToken(token, organization)); }); const toOutcome = (request: Request, result: McpAuthResult): Effect.Effect => { diff --git a/apps/cloud/src/mcp/auth.ts b/apps/cloud/src/mcp/auth.ts index 347a79faf7..ee6bb8ca17 100644 --- a/apps/cloud/src/mcp/auth.ts +++ b/apps/cloud/src/mcp/auth.ts @@ -160,19 +160,32 @@ export class McpAuth extends Context.Service< } >()("@executor-js/cloud/McpAuth") {} +/** + * The organization an MCP request was authorized against. The full record, not + * just its id: the same request later needs the org's display name and slug to + * open a session, and re-reading the row for them (from the session Durable + * Object, on a fresh database connection) is a redundant failure point on a + * request that already has the answer. + */ +export type AuthorizedMcpOrganization = { + readonly id: string; + readonly name: string; + readonly slug?: string; +}; + export class McpOrganizationAuth extends Context.Service< McpOrganizationAuth, { /** * Authorize `accountId` against an org SELECTOR — a WorkOS org id * (`org_…`, from the token or a legacy URL) or the org's URL slug (the - * form the install card prints). Returns the resolved org id when the + * form the install card prints). Returns the resolved organization when the * caller holds an active membership, `null` otherwise. */ readonly authorize: ( accountId: string, organizationSelector: string, - ) => Effect.Effect; + ) => Effect.Effect; } >()("@executor-js/cloud/McpOrganizationAuth") {} @@ -216,7 +229,9 @@ export const McpOrganizationAuthLive = Layer.succeed(McpOrganizationAuth)({ Effect.flatMap((organizationId) => organizationId ? authorizeOrganization(accountId, organizationId).pipe( - Effect.map((org) => (org ? org.id : null)), + Effect.map((org) => + org ? ({ id: org.id, name: org.name, slug: org.slug } as const) : null, + ), ) : Effect.succeed(null), ), diff --git a/apps/cloud/src/mcp/session-durable-object.ts b/apps/cloud/src/mcp/session-durable-object.ts index 50354810f2..5ff5ee2f0d 100644 --- a/apps/cloud/src/mcp/session-durable-object.ts +++ b/apps/cloud/src/mcp/session-durable-object.ts @@ -58,7 +58,7 @@ import { buildExecuteDescription, type ResumeResponse } from "@executor-js/execu // `SessionAuthLive` instead.) import { CoreSharedServices } from "../auth/workos"; import { UserStoreService } from "../auth/context"; -import { resolveOrganization } from "../auth/organization"; +import { resolveSessionMetaForToken } from "./session-meta"; import { DbService, combinedSchema, @@ -107,10 +107,6 @@ type CloudSessionDbHandle = DbServiceShape & { readonly end: () => Promise; }; -class OrganizationNotFoundError extends Data.TaggedError("OrganizationNotFoundError")<{ - readonly organizationId: string; -}> {} - class McpModelResumeForwardError extends Data.TaggedError("McpModelResumeForwardError")<{ readonly cause: unknown; }> {} @@ -209,28 +205,27 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase { + protected override resolveSessionMeta( + token: McpSessionInit, + storedMeta: SessionMeta | null, + ): Effect.Effect { + // The database handle is opened LAZILY: on the props and stored paths — the + // overwhelming majority of inits — nothing here touches Postgres at all, + // which is the whole point. postgres.js only dials on first query, so + // building the handle costs nothing; `ensuring` still closes it. const dbHandle = makeEphemeralDb(); - return Effect.gen(function* () { - const org = yield* resolveOrganization(token.organizationId); - if (!org) { - return yield* new OrganizationNotFoundError({ organizationId: token.organizationId }); - } - return { - organizationId: org.id, - organizationName: org.name, - organizationSlug: org.slug, - userId: token.userId, - resource: token.resource, - elicitationMode: token.elicitationMode, - artifactsEnabled: token.artifactsEnabled, - searchToolsEnabled: token.searchToolsEnabled, - } satisfies SessionMeta; - }).pipe( + return resolveSessionMetaForToken(token, storedMeta).pipe( Effect.withSpan("McpSessionDOSqlite.resolveSessionMeta"), Effect.provide(makeSessionServices(dbHandle)), Effect.ensuring(Effect.promise(() => dbHandle.end())), - // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: a vanished org is a defect; the worker already verified the bearer + // The base's `resolveSessionMeta` seam has no error channel, and a + // Durable Object's `init` can only reject its Promise — so a failure has + // to leave as a defect. What changed is WHAT leaves: an unreachable + // organization directory is now a bounded, classified + // `McpSessionMetaUnavailableError` whose message the worker recognises + // and renders as a retryable 503 (see `agent-handler.ts`), instead of an + // unclassified Postgres cause that produced a 500 and a client hang. + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: the DO init seam is Promise-only; the failure is classified before it dies Effect.orDie, ); } diff --git a/apps/cloud/src/mcp/session-meta.node.test.ts b/apps/cloud/src/mcp/session-meta.node.test.ts new file mode 100644 index 0000000000..d1dcdac23e --- /dev/null +++ b/apps/cloud/src/mcp/session-meta.node.test.ts @@ -0,0 +1,167 @@ +// Where an MCP session's organization identity comes from, and what happens +// when the only remaining source — the database — is unreachable. +// +// The production defect: every cold session-DO init read the `organizations` +// row over a brand-new Postgres connection, even though the worker had resolved +// that exact row microseconds earlier on the same request, and even when the DO +// already held the answer in its own storage. A connect timeout on that +// unnecessary connection became an unclassified defect and killed `initialize`. +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer, Predicate, Result } from "effect"; + +import { defaultMcpResource } from "@executor-js/host-mcp"; +import type { McpSessionInit, SessionMeta } from "@executor-js/cloudflare/mcp/agent-durable-object"; + +import { UserStoreService } from "../auth/context"; +import { UserStoreError } from "../auth/errors"; +import { WorkOSClient, type WorkOSClientService } from "../auth/workos"; +import { + isMcpSessionMetaUnavailable, + resolveSessionMetaForToken, + SESSION_META_DB_RETRIES, +} from "./session-meta"; + +const TOKEN: McpSessionInit = { + organizationId: "org_test", + userId: "user_test", + elicitationMode: "model", + resource: defaultMcpResource, + artifactsEnabled: true, +}; + +const STORED: SessionMeta = { + organizationId: "org_test", + organizationName: "Stored Org", + organizationSlug: "stored-org", + userId: "user_test", + resource: defaultMcpResource, +}; + +/** A user store that always fails the way a wedged Hyperdrive endpoint does, + * counting how many times it was asked. */ +const countingConnectTimeoutStore = (): { + readonly layer: Layer.Layer; + readonly calls: () => number; +} => { + let calls = 0; + return { + calls: () => calls, + layer: Layer.succeed(UserStoreService)({ + use: (operation: string) => + Effect.suspend(() => { + calls += 1; + return Effect.fail(new UserStoreError({ operation, reason: "connect_timeout" })); + }), + } as UserStoreService["Service"]), + }; +}; + +const namingStore = (): { + readonly layer: Layer.Layer; + readonly calls: () => number; +} => { + let calls = 0; + return { + calls: () => calls, + layer: Layer.succeed(UserStoreService)({ + use: (_operation: string, fn: (store: never) => Promise) => + Effect.suspend(() => { + calls += 1; + return Effect.promise(() => + fn({ + getOrganization: async (id: string) => ({ + id, + name: "Database Org", + slug: "database-org", + }), + } as never), + ); + }), + } as UserStoreService["Service"]), + }; +}; + +const unusedWorkOS = Layer.succeed( + WorkOSClient, + new Proxy({} as WorkOSClientService, { + get: (_target, prop) => () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`), + }), +); + +// The props source — the one the overwhelming majority of inits take — is +// covered black-box by `e2e/cloud/mcp-session-cold-init.test.ts`. What stays +// here is what that scenario cannot reach: the sources it falls back to, and +// what an unreachable database does to them. +describe("resolveSessionMetaForToken", () => { + it("falls back to the meta this session already stored, without touching the store", async () => { + const store = countingConnectTimeoutStore(); + + const meta = await Effect.runPromise( + resolveSessionMetaForToken(TOKEN, STORED).pipe( + Effect.provide(Layer.mergeAll(store.layer, unusedWorkOS)), + ), + ); + + expect(meta.organizationName).toBe("Stored Org"); + expect(meta.organizationSlug).toBe("stored-org"); + expect(store.calls(), "a restore reuses what it persisted").toBe(0); + }); + + it("reads the database only when nothing else names the org", async () => { + const store = namingStore(); + + const meta = await Effect.runPromise( + resolveSessionMetaForToken(TOKEN, null).pipe( + Effect.provide(Layer.mergeAll(store.layer, unusedWorkOS)), + ), + ); + + expect(meta.organizationName).toBe("Database Org"); + expect(store.calls()).toBe(1); + }); + + it("retries a connect timeout a bounded number of times, then fails retryably", async () => { + const store = countingConnectTimeoutStore(); + + const result = await Effect.runPromise( + resolveSessionMetaForToken(TOKEN, null).pipe( + Effect.provide(Layer.mergeAll(store.layer, unusedWorkOS)), + Effect.result, + ), + ); + + expect(Result.isFailure(result), "an unreachable directory is a failure, not a defect").toBe( + true, + ); + if (!Result.isFailure(result)) return; + expect(Predicate.isTagged(result.failure, "McpSessionMetaUnavailableError")).toBe(true); + expect( + isMcpSessionMetaUnavailable(result.failure), + "the worker can recognise it across the Durable Object boundary", + ).toBe(true); + expect(store.calls(), "bounded: the first attempt plus its retries").toBe( + SESSION_META_DB_RETRIES + 1, + ); + }); + + it("does not retry a deterministic query failure", async () => { + let calls = 0; + const store = Layer.succeed(UserStoreService)({ + use: (operation: string) => + Effect.suspend(() => { + calls += 1; + return Effect.fail(new UserStoreError({ operation, reason: "query" })); + }), + } as UserStoreService["Service"]); + + const result = await Effect.runPromise( + resolveSessionMetaForToken(TOKEN, null).pipe( + Effect.provide(Layer.mergeAll(store, unusedWorkOS)), + Effect.result, + ), + ); + + expect(Result.isFailure(result)).toBe(true); + expect(calls, "a query the server answered will answer the same way again").toBe(1); + }); +}); diff --git a/apps/cloud/src/mcp/session-meta.ts b/apps/cloud/src/mcp/session-meta.ts new file mode 100644 index 0000000000..a0bd1df81b --- /dev/null +++ b/apps/cloud/src/mcp/session-meta.ts @@ -0,0 +1,209 @@ +// --------------------------------------------------------------------------- +// Where an MCP session's organization identity comes from. +// +// Three sources, in the order they are preferred: +// +// props — the org record the worker resolved while authorizing THIS +// request, carried in the session props. Free, and always the +// freshest answer available. +// stored — the meta this session Durable Object already persisted for the +// same organization on an earlier init. Free, and correct for a +// session that already exists. +// database — an actual read of the `organizations` row. +// +// Only the third can fail, and it is the one that used to run unconditionally. +// Every cold DO init opened a brand-new Postgres connection through Hyperdrive +// purely to re-read a row the worker had loaded microseconds earlier on the +// same request and then discarded; when that connection could not be +// established the read hung for the full connect budget and the failure was +// turned into a defect, killing `initialize` on a database that was, at that +// same moment, answering the worker's own queries in milliseconds. +// +// So: prefer what the request already knows, and when the database genuinely is +// the only source, give it a bounded retry and a CLASSIFIED failure — the same +// transient-vs-definitive split the WorkOS membership check already uses in +// `auth-provider.ts` — instead of an unclassified defect. +// --------------------------------------------------------------------------- + +import { Data, Effect, Predicate, Result, Schedule } from "effect"; + +import type { McpSessionInit, SessionMeta } from "@executor-js/cloudflare/mcp/agent-durable-object"; + +import { UserStoreService } from "../auth/context"; +import { WorkOSClient } from "../auth/workos"; +import { + isDefinitiveWorkOSDenial, + isTransientUserStoreReason, + type UserStoreError, +} from "../auth/errors"; +import { resolveOrganization } from "../auth/organization"; + +export type SessionMetaSource = "props" | "stored" | "database"; + +export const SESSION_META_SOURCE_ATTRIBUTE = "mcp.session.meta_source"; + +/** + * The wire marker for "the organization directory was unreachable, try again". + * + * A Durable Object's `init` can only reject its Promise, so this travels to the + * worker as an ordinary error message across the DO boundary — the same + * mechanism the condemned-session `"destroyed"` abort already uses. The worker + * matches on it to answer a retryable 503 instead of letting an unclassified + * 500 (and the agents SDK's own DO-operation retry on top of it) turn a + * transient blip into a half-minute client hang. + */ +export const MCP_SESSION_META_UNAVAILABLE = "mcp_session_meta_unavailable"; + +export class McpSessionMetaUnavailableError extends Data.TaggedError( + "McpSessionMetaUnavailableError", +)<{ + readonly reason: string; + readonly attempts: number; +}> { + override get message(): string { + return `${MCP_SESSION_META_UNAVAILABLE}: organization directory unavailable (${this.reason}) after ${this.attempts} attempts`; + } +} + +export class OrganizationNotFoundError extends Data.TaggedError("OrganizationNotFoundError")<{ + readonly organizationId: string; +}> {} + +/** Does this failure, seen at the worker, mean "the session DO could not reach + * the organization directory"? Matches on the message because that is what + * survives the Durable Object RPC boundary. */ +export const isMcpSessionMetaUnavailable = (error: unknown): boolean => + // 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) + Predicate.isError(error) && error.message.includes(MCP_SESSION_META_UNAVAILABLE); + +/** + * Retries for the database path. Deliberately small: the point is to ride out a + * single bad connection attempt, not to sit on a client's `initialize` while a + * database stays down. Exhausting them answers the client quickly and + * retryably, which is strictly better than hanging. + */ +export const SESSION_META_DB_RETRIES = 2; + +const RETRY_SCHEDULE = Schedule.both( + Schedule.exponential("200 millis"), + Schedule.recurs(SESSION_META_DB_RETRIES), +); + +const isUserStoreError = Predicate.isTagged("UserStoreError") as ( + error: unknown, +) => error is UserStoreError; + +/** + * Is this org-lookup failure worth another attempt? A connection that never + * opened is; a query the server answered with an error, or a WorkOS denial, is + * not — retrying those only burns the client's init budget. + */ +export const isRetryableOrganizationLookupFailure = (failure: unknown): boolean => { + if (isUserStoreError(failure)) return isTransientUserStoreReason(failure.reason); + if (isDefinitiveWorkOSDenial(failure)) return false; + // A WorkOS blip (429/5xx/timeout/network) — the same class the MCP auth path + // already treats as retryable. + return Predicate.isTagged(failure, "WorkOSError"); +}; + +const failureReason = (failure: unknown): string => + isUserStoreError(failure) ? failure.reason : "upstream"; + +const metaFromIdentity = ( + token: McpSessionInit, + organization: { readonly name: string; readonly slug?: string }, +): SessionMeta => ({ + organizationId: token.organizationId, + organizationName: organization.name, + ...(organization.slug === undefined ? {} : { organizationSlug: organization.slug }), + userId: token.userId, + resource: token.resource, + elicitationMode: token.elicitationMode, + artifactsEnabled: token.artifactsEnabled, + searchToolsEnabled: token.searchToolsEnabled, +}); + +/** + * Read the organization row, retrying only failures a retry can clear, and + * surfacing an exhausted retry as a typed, retryable failure rather than a + * defect. + */ +const organizationFromDatabase = ( + organizationId: string, +): Effect.Effect< + { readonly id: string; readonly name: string; readonly slug?: string }, + McpSessionMetaUnavailableError | OrganizationNotFoundError, + UserStoreService | WorkOSClient +> => + Effect.gen(function* () { + let attempts = 0; + // `Effect.retry` retries every failure it sees, so definitive failures are + // lifted OUT of the error channel before the schedule ever runs and only + // the retryable ones are left in it. + const attempt = Effect.suspend(() => { + attempts += 1; + return resolveOrganization(organizationId).pipe( + Effect.result, + Effect.flatMap((outcome) => + Result.isFailure(outcome) && isRetryableOrganizationLookupFailure(outcome.failure) + ? Effect.fail(outcome.failure) + : Effect.succeed(outcome), + ), + ); + }); + + // Two nested Results: the outer one is "the retries ran out", the inner one + // is "the lookup failed definitively on the first look". Both mean the same + // thing to the caller. + const retried = yield* attempt.pipe(Effect.retry(RETRY_SCHEDULE), Effect.result); + const outcome = Result.isFailure(retried) ? Result.fail(retried.failure) : retried.success; + + if (Result.isFailure(outcome)) { + return yield* new McpSessionMetaUnavailableError({ + reason: failureReason(outcome.failure), + attempts, + }); + } + const organization = outcome.success; + if (!organization) return yield* new OrganizationNotFoundError({ organizationId }); + return organization; + }).pipe( + Effect.withSpan("mcp.session.resolve_organization", { + attributes: { "mcp.auth.organization_id": organizationId }, + }), + ); + +/** + * Build the session meta for an init, preferring the org identity the request + * already carries over any read of the organization directory. + * + * `storedMeta` is this DO's own persisted meta for the SAME organization (the + * base Durable Object only offers a matching one), or `null`. + */ +export const resolveSessionMetaForToken = ( + token: McpSessionInit, + storedMeta: SessionMeta | null, +): Effect.Effect< + SessionMeta, + McpSessionMetaUnavailableError | OrganizationNotFoundError, + UserStoreService | WorkOSClient +> => + Effect.gen(function* () { + const fromProps = token.organizationName; + if (fromProps) { + yield* Effect.annotateCurrentSpan(SESSION_META_SOURCE_ATTRIBUTE, "props"); + return metaFromIdentity(token, { name: fromProps, slug: token.organizationSlug }); + } + + if (storedMeta?.organizationName) { + yield* Effect.annotateCurrentSpan(SESSION_META_SOURCE_ATTRIBUTE, "stored"); + return metaFromIdentity(token, { + name: storedMeta.organizationName, + slug: storedMeta.organizationSlug, + }); + } + + yield* Effect.annotateCurrentSpan(SESSION_META_SOURCE_ATTRIBUTE, "database"); + const organization = yield* organizationFromDatabase(token.organizationId); + return metaFromIdentity(token, organization); + }); diff --git a/apps/cloud/src/observability/index.ts b/apps/cloud/src/observability/index.ts index a56118a6cd..d42f7f5ae2 100644 --- a/apps/cloud/src/observability/index.ts +++ b/apps/cloud/src/observability/index.ts @@ -12,7 +12,7 @@ import * as Sentry from "@sentry/cloudflare"; import type { ErrorEvent, Scope } from "@sentry/cloudflare"; -import { Cause, Effect, Layer } from "effect"; +import { Cause, Effect, Layer, Predicate } from "effect"; import type * as Tracer from "effect/Tracer"; import { ErrorCapture } from "@executor-js/api"; @@ -174,14 +174,75 @@ export const sentryPayloadForCause = ( return { primary: input, pretty: null }; }; +// Safe classification fields our tagged errors carry (`UserStoreError.operation` +// / `.reason`, `WorkOSError.status`). They are promoted to Sentry TAGS because +// the pretty cause is only an `extra`, and Sentry's server-side scrubber +// replaces that extra with "[Filtered]" — leaving an issue with no failing +// operation and no reason in it at all. Tags survive, group, and are +// searchable. Values are failure modes and operation names; never a query, a +// value, or anything customer-derived. +const CLASSIFICATION_TAG_FIELDS = ["operation", "reason", "status"] as const; + +/** The errors those fields are read from. An allowlist, because the fields are + * only known to be safe on the errors this app defines. */ +const CLASSIFIED_ERROR_TAGS = [ + "UserStoreError", + "WorkOSError", + "McpSessionMetaUnavailableError", +] as const; + +const MAX_CLASSIFICATION_TAG_CHARS = 120; + +const MAX_CAUSE_NESTING = 3; + +/** Every error value a cause carries, failures and defects alike. A defect can + * itself be a `Cause` (an inner `runPromise` rejecting with its own squashed + * cause), so the walk unwraps a few levels. */ +const errorValuesOf = (input: unknown, depth = 0): readonly unknown[] => { + if (depth >= MAX_CAUSE_NESTING) return []; + if (!Cause.isCause(input)) return [input]; + const values: unknown[] = []; + for (const reason of input.reasons) { + if (Cause.isFailReason(reason)) values.push(reason.error); + else if (Cause.isDieReason(reason)) values.push(...errorValuesOf(reason.defect, depth + 1)); + } + return values; +}; + +/** Read the classification fields off the tagged errors inside a cause. First + * writer wins, so the innermost reported error names the issue. */ +const classificationTagsOf = (input: unknown): Readonly> => { + const tags: Record = {}; + for (const candidate of errorValuesOf(input)) { + if (typeof candidate !== "object" || candidate === null) continue; + // Only the errors whose fields we know are safe classifications. Without + // this, any foreign object in the cause that happens to carry an + // `operation` / `reason` / `status` field would have that value promoted + // onto a tag, and a foreign field is not known to be free of a query, a + // value, or anything customer-derived. + if (!CLASSIFIED_ERROR_TAGS.some((tag) => Predicate.isTagged(candidate, tag))) continue; + const tagged = candidate as Record; + for (const field of CLASSIFICATION_TAG_FIELDS) { + const value = tagged[field]; + if (tags[field] !== undefined) continue; + if (typeof value === "string" || typeof value === "number") { + tags[field] = String(value).slice(0, MAX_CLASSIFICATION_TAG_CHARS); + } + } + } + return tags; +}; + export const captureCause = ( input: unknown, context: OtelCorrelationContext | null = null, ): string | undefined => { const { primary, pretty } = sentryPayloadForCause(input); + const classification = classificationTagsOf(input); tagCurrentSentryScopeWithOtelContext(context); return Sentry.captureException(primary, (scope) => { tagSentryScopeWithOtelContext(scope, context); + for (const [key, value] of Object.entries(classification)) scope.setTag(key, value); if (pretty !== null) scope.setExtra("cause", pretty); return scope; }); diff --git a/apps/host-cloudflare/src/mcp/session-durable-object.ts b/apps/host-cloudflare/src/mcp/session-durable-object.ts index 5e373b0ca9..4fccd6af94 100644 --- a/apps/host-cloudflare/src/mcp/session-durable-object.ts +++ b/apps/host-cloudflare/src/mcp/session-durable-object.ts @@ -102,9 +102,14 @@ export class McpSessionDO extends McpAgentSessionDOBase handle.close() }; } - protected override resolveSessionMeta(token: McpSessionInit): Effect.Effect { + protected override resolveSessionMeta( + token: McpSessionInit, + _storedMeta: SessionMeta | null, + ): Effect.Effect { // Single-tenant: every Access principal belongs to the one configured org, - // so there is nothing to resolve — stamp the configured org name. + // so there is nothing to resolve — stamp the configured org name. Nothing + // to reuse from the stored meta either; config is already the cheapest and + // freshest source there is. return Effect.succeed({ organizationId: token.organizationId, organizationName: this.cfConfig.organizationName, diff --git a/e2e/cloud/mcp-session-cold-init.test.ts b/e2e/cloud/mcp-session-cold-init.test.ts new file mode 100644 index 0000000000..c24d4368bd --- /dev/null +++ b/e2e/cloud/mcp-session-cold-init.test.ts @@ -0,0 +1,191 @@ +// Cloud: the org identity a session needs at init travels in the session props +// the worker already resolved — it is NOT re-read from Postgres inside the +// session Durable Object. +// +// The defect this pins: on EVERY cold DO init the DO opened a brand-new +// Postgres connection purely to re-read the `organizations` row the worker had +// just read microseconds earlier on the same request (the worker threw it away: +// the auth principal hardcoded an empty organization name). When that fresh +// connection could not be established the whole `initialize` died — a hard, +// client-visible failure on a healthy database, because the only unhealthy +// thing was a socket nothing needed to open. +// +// Two contracts, in the order a user meets them: +// +// 1. the session opens and WORKS — initialize mints a session id and the same +// id then serves `tools/list`, all off the identity the props carried; +// 2. the whole request performs exactly ONE organization read (the worker's +// own authorization check) and the DO opens no database connection of its +// own to name the org. +// +// (2) is asserted on the EXPORTED spans, and the two planes — worker and +// Durable Object — export independently, so the count is only taken once a +// worker-plane span for this same request has landed. Without that wait a +// still-pending worker batch would make a two-read request look like a one-read +// request, and the assertion would pass for the wrong reason. +// +// The failure half of the fix (an unreachable directory becomes a bounded retry +// and a retryable 503 rather than a bare 500) is not reachable from here: the +// harness runs one single-process PGlite shared by the worker and the DO, and +// the worker's own authorization reads that same row on the same request — so +// freezing the database fails the request before init identically on both sides +// of the fix. Those branches are pinned in +// `apps/cloud/src/mcp/session-meta.node.test.ts`. +import { randomBytes } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; +import { Mcp, Target, Telemetry } from "../src/services"; +import type { Identity } from "../src/target"; + +const JSON_AND_SSE = "application/json, text/event-stream"; + +const INITIALIZE_REQUEST = { + jsonrpc: "2.0" as const, + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "executor-e2e-session-cold-init", version: "0.0.1" }, + }, +}; + +const INITIALIZED_NOTIFICATION = { + jsonrpc: "2.0" as const, + method: "notifications/initialized", +}; + +const TOOLS_LIST_REQUEST = { + jsonrpc: "2.0" as const, + id: 2, + method: "tools/list", + params: {}, +}; + +const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label; + +/** A client-supplied W3C trace context, so every span this one request produces + * — worker plane and Durable Object alike — is addressable by one trace id. */ +const newTraceContext = (): { readonly traceId: string; readonly traceparent: string } => { + const traceId = randomBytes(16).toString("hex"); + const spanId = randomBytes(8).toString("hex"); + return { traceId, traceparent: `00-${traceId}-${spanId}-01` }; +}; + +const mcpPost = ( + url: string, + init: { + readonly bearer: string; + readonly sessionId?: string; + readonly traceparent?: string; + readonly body: unknown; + }, +): Promise => + fetch(url, { + method: "POST", + headers: { + accept: JSON_AND_SSE, + "content-type": "application/json", + authorization: `Bearer ${init.bearer}`, + ...(init.sessionId ? { "mcp-session-id": init.sessionId } : {}), + ...(init.traceparent ? { traceparent: init.traceparent } : {}), + }, + body: JSON.stringify(init.body), + }); + +scenario( + "MCP session cold init · the org identity rides in the session props instead of a second Postgres read", + { timeout: 120_000 }, + Effect.gen(function* () { + const target = yield* Target; + const mcp = yield* Mcp; + const telemetry = yield* Telemetry; + + const identity = yield* target.newIdentity(); + const bearer = yield* mcp.mintBearer(emailOf(identity)); + const trace = newTraceContext(); + + // ---- 1. the session opens, and it works ------------------------------- + const response = yield* Effect.promise(() => + mcpPost(target.mcpUrl, { + bearer, + traceparent: trace.traceparent, + body: INITIALIZE_REQUEST, + }), + ); + yield* Effect.promise(() => response.text()); + expect(response.status, "initialize opens a session").toBe(200); + const sessionId = response.headers.get("mcp-session-id"); + expect(sessionId, "the session id is minted").toBeTruthy(); + + const initialized = yield* Effect.promise(() => + mcpPost(target.mcpUrl, { + bearer, + sessionId: sessionId ?? "", + body: INITIALIZED_NOTIFICATION, + }), + ); + yield* Effect.promise(() => initialized.text()); + expect(initialized.status, "the client completes the handshake").toBe(202); + + // The session built from the props-carried identity actually serves work — + // the production symptom was an `initialize` that never got this far. + const tools = yield* Effect.promise(() => + mcpPost(target.mcpUrl, { + bearer, + sessionId: sessionId ?? "", + body: TOOLS_LIST_REQUEST, + }), + ); + const toolsBody = yield* Effect.promise(() => tools.text()); + expect(tools.status, "the session serves requests once open").toBe(200); + expect(toolsBody, "the session advertises the execute tool").toContain("execute"); + + // ---- 2. one organization read for the whole init request -------------- + // The DO really did resolve meta on this request (a cold init), and it + // resolved it from the props the worker handed over. + const resolveSpan = yield* telemetry.expectSpan({ + traceId: trace.traceId, + operation: "McpSessionDOSqlite.resolveSessionMeta", + }); + expect(resolveSpan.span.status, "the cold init resolved its meta without failing").toBe("ok"); + + // The worker plane exports on its own batch, independently of the DO's. + // Wait for a worker-plane span from this same request before counting, or + // an unflushed worker batch would hide the very read being counted. + yield* telemetry.expectSpan({ traceId: trace.traceId, operation: "mcp.request" }); + + // One org read for the whole request: the worker's own authorization + // lookup. A second one means the DO reopened a connection to re-read a row + // the request already had. + const orgReads = yield* telemetry.searchSpans({ + traceId: trace.traceId, + operation: "user_store.getOrganization", + }); + expect( + orgReads.length, + "only the worker's authorization check reads the organization row", + ).toBe(1); + + // …and the DO's own database step never ran at all. `resolveSessionMeta` + // has already landed and this span is its child, so its absence here is + // absence, not lag. + const doDatabaseReads = yield* telemetry.searchSpans({ + traceId: trace.traceId, + operation: "mcp.session.resolve_organization", + }); + expect( + doDatabaseReads.length, + "the session DO opens no connection of its own to name the organization", + ).toBe(0); + + // …because the identity it used is the one the worker handed it. + expect( + resolveSpan.span.tags["mcp.session.meta_source"], + "the session meta comes from the props the worker already resolved", + ).toBe("props"); + }), +); diff --git a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts index 125b3990d3..05c257cb28 100644 --- a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts +++ b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts @@ -1,6 +1,6 @@ // 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 import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect } from "effect"; +import { Cause, Effect, Exit } from "effect"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import type { JSONRPCMessage, MessageExtraInfo } from "@modelcontextprotocol/sdk/types.js"; @@ -308,6 +308,119 @@ describe("McpAgentSessionDOBase apps capability persistence", () => { }); }); +// A cold restore used to re-resolve the org identity through the host's backing +// store (on cloud: a brand-new Postgres connection) BEFORE it ever looked at +// the meta this DO had already persisted for the very session it is restoring. +// A transient failure of that lookup killed `init` and the restore with it — +// for a row the DO was already holding. The DO's own storage is the +// authoritative copy of the org identity of a session it already minted, so it +// is offered to the host first; the host still rebuilds everything the CONNECT +// carries (resource, elicitation mode, capability flags) from the token. +describe("McpAgentSessionDOBase cold-restore meta reuse", () => { + type RestoreSession = { + ctx: MemoryStorage; + getSessionId: () => string; + loadSessionMeta: () => Effect.Effect; + resolveSessionMeta: ( + token: unknown, + storedMeta: SessionMeta | null, + ) => Effect.Effect; + resolveAndStoreSessionMeta: (token: unknown) => Effect.Effect; + }; + + const storedMeta: SessionMeta = { + organizationId: "org-1", + organizationName: "Org One", + organizationSlug: "org-one", + userId: "user-1", + resource: defaultMcpResource, + }; + + const token = { + organizationId: "org-1", + userId: "user-1", + elicitationMode: "model" as const, + resource: defaultMcpResource, + }; + + const makeRestoreSession = async ( + stored: SessionMeta | null, + ): Promise<{ session: RestoreSession; storage: MemoryStorage }> => { + const storage = new MemoryStorage(); + if (stored) await storage.put("session-meta", stored); + const session = Object.create(McpAgentSessionDOBase.prototype) as RestoreSession; + session.ctx = storage; + session.getSessionId = () => "session-restore"; + return { session, storage }; + }; + + // The host stands in for cloud with an unreachable database: it can only + // answer when the DO hands it what it already knows. + const hostWithUnreachableStore = + (seen: { storedMeta: SessionMeta | null; calls: number }) => + (tokenIn: unknown, stored: SessionMeta | null): Effect.Effect => { + seen.calls += 1; + seen.storedMeta = stored; + if (!stored) return Effect.die("organization lookup: CONNECT_TIMEOUT"); + const t = tokenIn as { readonly userId: string; readonly organizationId: string }; + return Effect.succeed({ + organizationId: t.organizationId, + organizationName: stored.organizationName, + organizationSlug: stored.organizationSlug, + userId: t.userId, + resource: defaultMcpResource, + } satisfies SessionMeta); + }; + + it("restores from its own stored meta when the backing store is unreachable", async () => { + const { session } = await makeRestoreSession(storedMeta); + const seen = { storedMeta: null as SessionMeta | null, calls: 0 }; + session.resolveSessionMeta = hostWithUnreachableStore(seen); + + const resolved = await Effect.runPromise(session.resolveAndStoreSessionMeta(token)); + + expect(seen.calls).toBe(1); + expect(seen.storedMeta).toMatchObject({ organizationId: "org-1", organizationName: "Org One" }); + expect(resolved.organizationName).toBe("Org One"); + expect(resolved.organizationSlug).toBe("org-one"); + }); + + // Stored meta is only a shortcut for the SAME organization. A session id + // reused across orgs must never inherit the previous org's identity. + it("offers nothing when the stored meta belongs to another organization", async () => { + const { session } = await makeRestoreSession({ ...storedMeta, organizationId: "org-other" }); + const seen = { storedMeta: null as SessionMeta | null, calls: 0 }; + session.resolveSessionMeta = hostWithUnreachableStore(seen); + + const exit = await Effect.runPromiseExit(session.resolveAndStoreSessionMeta(token)); + + expect(Exit.isFailure(exit)).toBe(true); + expect(seen.storedMeta).toBeNull(); + }); + + // A brand-new session has nothing stored; the host must resolve from scratch. + it("offers nothing on a first init", async () => { + const { session } = await makeRestoreSession(null); + const seen = { storedMeta: null as SessionMeta | null, calls: 0 }; + session.resolveSessionMeta = (tokenIn, stored) => { + seen.calls += 1; + seen.storedMeta = stored; + const t = tokenIn as { readonly userId: string; readonly organizationId: string }; + return Effect.succeed({ + organizationId: t.organizationId, + organizationName: "Freshly Resolved", + userId: t.userId, + resource: defaultMcpResource, + } satisfies SessionMeta); + }; + + const resolved = await Effect.runPromise(session.resolveAndStoreSessionMeta(token)); + + expect(seen.storedMeta).toBeNull(); + expect(resolved.organizationName).toBe("Freshly Resolved"); + }); +}); + describe("McpAgentSessionDOBase transport restore", () => { it("preserves hibernated response streams when a cold isolate starts", async () => { const session = await makeHarnessSession(); diff --git a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts index a1dd27a3fb..d7dfcb0e8c 100644 --- a/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts +++ b/packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts @@ -39,6 +39,13 @@ export type IncomingTraceHeaders = IncomingPropagationHeaders; export interface McpSessionInit { readonly organizationId: string; + /** The organization's display name, as the worker resolved it while + * authorizing this very request. Carried so the session DO never has to + * re-read a row the request already loaded. Absent when the auth plane could + * not name the org, in which case the host resolves it itself. */ + readonly organizationName?: string; + /** The organization's URL slug, from the same resolved record. */ + readonly organizationSlug?: string; readonly userId: string; readonly elicitationMode: McpElicitationMode; /** Whether this session serves artifacts, read off `?artifacts=` at connect @@ -247,7 +254,20 @@ export abstract class McpAgentSessionDOBase< protected abstract openSessionDb(): TDbHandle | Promise; - protected abstract resolveSessionMeta(token: McpSessionInit): Effect.Effect; + /** + * Build the session's {@link SessionMeta} for this init. + * + * `storedMeta` is what this DO already persisted for the SAME organization on + * an earlier init, or `null`. It is offered first so a host never has to + * re-resolve an org identity it is already holding — on cloud that resolution + * is a fresh Postgres connection, and a cold restore used to die on it. Every + * field the CONNECT carries (resource, elicitation mode, capability flags) + * still comes from `token`; only the org identity may be reused. + */ + protected abstract resolveSessionMeta( + token: McpSessionInit, + storedMeta: SessionMeta | null, + ): Effect.Effect; protected abstract buildMcpServer( sessionMeta: SessionMeta, @@ -524,12 +544,21 @@ export abstract class McpAgentSessionDOBase< private resolveAndStoreSessionMeta(token: McpSessionInit) { const self = this; return Effect.gen(function* () { - const resolved = yield* self.resolveSessionMeta(token); - // `init` runs again on every cold restore, and `resolveSessionMeta` - // rebuilds meta from the bearer token — which carries no negotiated - // capabilities. Carry the stored value forward, or restoring the session - // would erase the very bit that survives the restore. + // Read what this DO already knows BEFORE asking the host to resolve + // anything. `init` runs again on every cold restore, and the stored meta + // is this session's own durable record of the organization it was minted + // for — re-deriving that identity from the host's backing store is the + // single most failure-prone step of a restore (on cloud, a brand-new + // Postgres connection) and it is redundant for a session that already + // exists. It is offered only for the SAME organization; a token naming a + // different org resolves from scratch. const stored = yield* self.loadSessionMeta(); + const reusable = stored && stored.organizationId === token.organizationId ? stored : null; + // The stored meta also carries the capabilities negotiated at + // `initialize`, which the bearer token knows nothing about. Carry them + // forward, or restoring the session would erase the very bit that + // survives the restore. + const resolved = yield* self.resolveSessionMeta(token, reusable); const sessionMeta: SessionMeta = { ...resolved, ...(token.webOrigin ? { webOrigin: token.webOrigin } : {}), @@ -803,6 +832,11 @@ export abstract class McpAgentSessionDOBase< .bestEffortBookkeeping("init.mark_activity", () => self.markActivity()) .pipe(Effect.withSpan("McpSessionDO.markActivity")); }).pipe( + // ONE capture owner for an init defect. `init` can only reject its + // Promise, and the host's DO-level error instrumentation captures that + // rejection too — so the DO claims the cause below and the host drops its + // own echo, rather than both filing the same failure as two issues with + // the same trace id and span id. Effect.tapCause((cause) => Effect.gen(function* () { // A Cloudflare platform reset of an in-flight init is not a defect — From c59e23c7ede2637c54339c012567b3b8d6ee5190 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:06:07 -0700 Subject: [PATCH 130/133] Classify CPU-limit Durable Object resets as retryable (#1792) * Classify CPU-limit Durable Object resets as retryable * Classify startup storage faults as retryable Durable Object resets --- .../src/mcp/durable-object-errors.test.ts | 87 +++++++++++++++++++ .../src/mcp/durable-object-errors.ts | 52 +++++++++++ 2 files changed, 139 insertions(+) diff --git a/packages/hosts/cloudflare/src/mcp/durable-object-errors.test.ts b/packages/hosts/cloudflare/src/mcp/durable-object-errors.test.ts index 2eca5e77d3..ab939d3a3a 100644 --- a/packages/hosts/cloudflare/src/mcp/durable-object-errors.test.ts +++ b/packages/hosts/cloudflare/src/mcp/durable-object-errors.test.ts @@ -43,6 +43,36 @@ describe("classifyDurableObjectError", () => { ).toEqual({ kind: "storage_internal", disposition: "transient" }); }); + it("reads a storage fault raised while the object starts up as transient", () => { + expect( + classifyDurableObjectError( + new Error( + "Internal error while starting up Durable Object storage caused object to be reset; reference = 0000aaaa1111bbbb", + ), + ), + ).toEqual({ kind: "startup_internal_error", disposition: "transient" }); + }); + + // This variant escaped as an unhandled 500 even with the bare-blip fragment + // in place, because the runtime interposes its own description between + // "internal error" and the reference id. Pinning the distinction keeps a + // future "just widen the blip fragment" from silently re-merging the two. + it("keeps the startup fault distinct from the bare platform blip", () => { + const startup = classifyDurableObjectError( + new Error( + "Internal error while starting up Durable Object storage caused object to be reset; reference = ffff9999eeee8888", + ), + ); + const blip = classifyDurableObjectError( + new Error("internal error; reference = ffff9999eeee8888"), + ); + + expect(startup?.kind).toBe("startup_internal_error"); + expect(blip?.kind, "the described fault must not be read as the bare blip").toBe( + "internal_error", + ); + }); + it("reads a blockConcurrencyWhile cancellation as transient", () => { expect( classifyDurableObjectError( @@ -53,6 +83,28 @@ describe("classifyDurableObjectError", () => { ).toEqual({ kind: "concurrency_reset", disposition: "transient" }); }); + it("reads a CPU-limit reset as transient", () => { + expect( + classifyDurableObjectError( + new Error("Durable Object exceeded its CPU time limit and was reset."), + ), + ).toEqual({ kind: "cpu_limit", disposition: "transient" }); + }); + + // The memory-limit reset is the CPU limit's sibling and is deliberately NOT + // classified: the runtime names the application as the cause (un-awaited + // writes, an oversized read), so a retry reproduces it. It has to keep being + // rethrown and reported rather than disappearing into a 503. + it("refuses to classify the sibling memory-limit reset as retryable", () => { + expect( + classifyDurableObjectError( + new Error( + "Durable Object's isolate exceeded its memory limit due to overflowing the storage cache. All objects in the isolate were reset.", + ), + ), + ).toBeNull(); + }); + it("reads a platform blip as transient, ignoring the reference id", () => { // The reference id differs on every event; it must not defeat the match. expect( @@ -185,4 +237,39 @@ describe("durableObjectFailureResponse", () => { expect(result.retryAfter, message).not.toBeNull(); } }); + + // A storage fault while the object is coming up reaches the handler as the + // same untyped Error as every other reset, and used to fall out of the worker + // as an unhandled 500. Nothing about the request caused it, so the client is + // told to retry the same id rather than to reconnect. + it("tells the client to retry the same session after a startup storage fault", async () => { + const result = await envelope( + new Error( + "Internal error while starting up Durable Object storage caused object to be reset; reference = 0000aaaa1111bbbb", + ), + ); + + expect(result.status, "HTTP status is the discriminator clients act on").toBe(503); + expect(result.body.jsonrpc).toBe("2.0"); + expect(result.body.error?.code).toBe(-32001); + expect(result.retryAfter, "the client is told how long to back off").toBe( + String(UNAVAILABLE_RETRY_AFTER_SECONDS), + ); + }); + + // An invocation cut off at the CPU ceiling reaches the handler as the same + // untyped Error as every other reset, and used to fall out of the worker as + // an unhandled 500. It must land on the retry-the-same-id envelope instead. + it("tells the client to retry the same session after a CPU-limit reset", async () => { + const result = await envelope( + new Error("Durable Object exceeded its CPU time limit and was reset."), + ); + + expect(result.status, "HTTP status is the discriminator clients act on").toBe(503); + expect(result.body.jsonrpc).toBe("2.0"); + expect(result.body.error?.code).toBe(-32001); + expect(result.retryAfter, "the client is told how long to back off").toBe( + String(UNAVAILABLE_RETRY_AFTER_SECONDS), + ); + }); }); diff --git a/packages/hosts/cloudflare/src/mcp/durable-object-errors.ts b/packages/hosts/cloudflare/src/mcp/durable-object-errors.ts index 4b77f15200..0eee14c2b6 100644 --- a/packages/hosts/cloudflare/src/mcp/durable-object-errors.ts +++ b/packages/hosts/cloudflare/src/mcp/durable-object-errors.ts @@ -35,8 +35,12 @@ export type DurableObjectFailureKind = | "storage_timeout" /** The storage backend failed internally and reset the object. */ | "storage_internal" + /** The storage backend failed internally *while bringing the object up*. */ + | "startup_internal_error" /** `blockConcurrencyWhile()` ran past its cap and was cancelled. */ | "concurrency_reset" + /** An invocation ran past the per-invocation CPU ceiling; the object was reset. */ + | "cpu_limit" /** A generic platform blip: `internal error; reference = `. */ | "internal_error" /** The runtime itself flagged the error as retryable. */ @@ -78,6 +82,26 @@ const MESSAGE_PATTERNS: ReadonlyArray<{ fragment: "internal error in durable object storage", failure: { kind: "storage_internal", disposition: "transient" }, }, + { + // The startup sibling of the entry above: the storage backend faults while + // the object is being brought up rather than while it is serving, and the + // runtime says so in a different clause ("… while starting up Durable + // Object storage caused object to be reset; reference = "). + // + // Deliberately stops before "storage": the two known members of this family + // agree on "Internal error … Durable Object" and disagree on everything + // that follows the verb, so the qualifier is the part most likely to move + // and is not what identifies the failure. Equally deliberately NOT + // shortened to the shared tail "caused object to be reset" — that tail is + // common to several unrelated storage faults and would stop this bucket + // from meaning anything on a span. + // + // Transient for the same reason as its sibling: nothing about the request + // caused it, the id still routes, and the object gets a fresh start on the + // next attempt. + fragment: "internal error while starting up durable object", + failure: { kind: "startup_internal_error", disposition: "transient" }, + }, { // Deliberately the whole phrase, not the bare method name: an application // defect thrown from inside a `blockConcurrencyWhile` callback also resets @@ -87,9 +111,37 @@ const MESSAGE_PATTERNS: ReadonlyArray<{ failure: { kind: "concurrency_reset", disposition: "transient" }, }, { + // Deliberately starts at the verb, not at "Durable Object": the runtime's + // resource-limit messages disagree about the subject noun (the memory + // variant says "Durable Object's isolate exceeded its memory limit"), and + // pinning a subject here would let a rewording defeat the match. From + // "exceeded its CPU time limit" onward the phrase is the runtime's alone. + // + // Transient, not a defect: the invocation was cut off but the object's + // durable storage is untouched and the session id still routes, so the next + // attempt — a smaller unit of work, or the same one under a warm isolate — + // can succeed. Retrying the same id is strictly better than the unhandled + // 500 this produced before. + fragment: "exceeded its cpu time limit and was reset", + failure: { kind: "cpu_limit", disposition: "transient" }, + }, + { + // Only the bare blip. A reference id at the end of the message is NOT the + // marker: the runtime also appends one to described faults such as the + // startup failure above, and because this fragment includes the semicolon + // that immediately follows "internal error", any interposed description + // defeats it. Those variants each need their own entry rather than a + // loosened version of this one, which would turn every referenced error + // into an opaque "internal_error" bucket. fragment: "internal error; reference =", failure: { kind: "internal_error", disposition: "transient" }, }, + // Not listed, on purpose: the sibling memory-limit reset ("Durable Object's + // isolate exceeded its memory limit due to overflowing the storage cache … + // All objects in the isolate were reset."). The runtime tags that one as a + // user error, and it names its own cause — too many un-awaited writes, or one + // oversized read. Retrying reproduces it, so calling it transient would bury + // an application defect behind a 503 instead of surfacing it. ]; /** From 36c901bcf3733ffd3ed618d05b6dae8f18b9466f Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:28:47 -0700 Subject: [PATCH 131/133] Stable Sentry error grouping across deploys (#1781) * Keep Sentry issue grouping stable across deploys Normalize build content hashes out of the grouping key in the cloud, desktop main and desktop renderer Sentry inits, and merge address-keyed Chromium soft-assert minidumps into one fingerprint. * Cover the fingerprint wiring and pin the hash threshold * Assemble desktop main Sentry options where they are tested --- apps/cloud/src/observability/index.ts | 48 ++++ .../src/observability/observability.test.ts | 137 +++++++++ apps/cloud/src/server.ts | 29 +- .../src/main/crash-fingerprint.test.ts | 143 ++++++++++ apps/desktop/src/main/crash-fingerprint.ts | 80 ++++++ apps/desktop/src/main/diagnostics.ts | 21 +- packages/app/src/crash-reporting.ts | 7 + packages/core/sdk/package.json | 9 +- packages/core/sdk/src/sentry-grouping.test.ts | 269 ++++++++++++++++++ packages/core/sdk/src/sentry-grouping.ts | 169 +++++++++++ packages/core/sdk/tsup.config.ts | 1 + 11 files changed, 874 insertions(+), 39 deletions(-) create mode 100644 apps/desktop/src/main/crash-fingerprint.test.ts create mode 100644 apps/desktop/src/main/crash-fingerprint.ts create mode 100644 packages/core/sdk/src/sentry-grouping.test.ts create mode 100644 packages/core/sdk/src/sentry-grouping.ts diff --git a/apps/cloud/src/observability/index.ts b/apps/cloud/src/observability/index.ts index d42f7f5ae2..2ad2755a9f 100644 --- a/apps/cloud/src/observability/index.ts +++ b/apps/cloud/src/observability/index.ts @@ -16,6 +16,7 @@ import { Cause, Effect, Layer, Predicate } from "effect"; import type * as Tracer from "effect/Tracer"; import { ErrorCapture } from "@executor-js/api"; +import { withStableGroupingFingerprint } from "@executor-js/sdk/sentry-grouping"; // Drizzle/postgres-js include the failing SQL (params + bound values) in // their error message. For OpenAPI source inserts that's 1MB+ of spec @@ -146,6 +147,53 @@ export const beforeSendWithOtelCorrelation = ( return event; }; +/** + * The single `beforeSend` the worker and its Durable Objects install. + * + * The worker ships as content-hashed chunks and its frames are not resolved + * back to source, so Sentry's default grouping keys on names like + * `execution-rate-limit-` and re-opens every issue on the next deploy. + * `withStableGroupingFingerprint` pins a key with the hash normalized out; + * events with no hashed grouping input are left on the default algorithm. + * + * The two stages are independent and compose in this order: the capture-owner + * pass decides WHETHER the event is reported at all (a cause the Durable + * Object already claimed is dropped, and a dropped event is never + * fingerprinted), and the grouping pass then decides HOW whatever survives is + * grouped. + */ +export const beforeSendCloudEvent = ( + event: ErrorEvent, + options?: { readonly logPayload?: boolean }, +): ErrorEvent | null => { + const reported = beforeSendWithOtelCorrelation(event, options); + return reported === null ? null : withStableGroupingFingerprint(reported); +}; + +/** + * The Sentry options the worker and every Durable Object install. It lives + * beside the `beforeSend` it wires so the composition is covered by + * observability.test.ts; `server.ts` only passes this through. + * + * NOTE: do NOT enable `instrumentPrototypeMethods`. It walks the DO prototype + * and reads every property — including accessors — to find methods to wrap, + * which invokes the `sessionId` getter with `this` bound to the prototype + * (where `ctx` is undefined) and throws during construction, 500ing every + * session create / cold restore. The DO captures its own errors via the + * `captureCause` seam (→ Sentry) instead. + */ +export const cloudSentryOptions = (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 0, + enableLogs: true, + sendDefaultPii: true, + skipOpenTelemetrySetup: true, + beforeSend: (event: ErrorEvent) => + beforeSendCloudEvent(event, { + logPayload: !env.SENTRY_DSN || env.SENTRY_OTEL_LOG_PAYLOAD === "true", + }), +}); + export const addCurrentOtelCorrelationTags = < T extends { readonly tags?: Record }, >( diff --git a/apps/cloud/src/observability/observability.test.ts b/apps/cloud/src/observability/observability.test.ts index 9185cbec84..a75a8e6174 100644 --- a/apps/cloud/src/observability/observability.test.ts +++ b/apps/cloud/src/observability/observability.test.ts @@ -6,7 +6,9 @@ import type { ErrorEvent } from "@sentry/cloudflare"; import { addCurrentOtelCorrelationTags, + beforeSendCloudEvent, beforeSendWithOtelCorrelation, + cloudSentryOptions, DO_CAUSE_OWNER_TAG, DO_CAUSE_OWNER_VALUE, OTEL_SPAN_ID_TAG, @@ -84,6 +86,83 @@ describe("sentryPayloadForCause", () => { }); }); +// Grouping keys are decided inside the Sentry SDK and never appear on any +// product surface, so the e2e harness cannot observe them. The split is: +// e2e/cloud/sentry-otel-correlation.test.ts proves the worker really installs +// `cloudSentryOptions.beforeSend` (its correlation payload only exists if that +// hook ran), and the tests here prove the hook it installs fingerprints. +describe("Sentry grouping", () => { + // The worker bundle ships as content-hashed chunks, so the only module name + // Sentry ever sees for a given frame changes on every deploy. + const workerEvent = (chunkHash: string): ErrorEvent => ({ + type: undefined, + exception: { + values: [ + { + type: "GateCheckTimeoutError", + value: "balance check timed out", + stacktrace: { + frames: [ + { + filename: `/assets/execution-rate-limit-${chunkHash}.js`, + module: `execution-rate-limit-${chunkHash}`, + function: "timeoutOrElse", + in_app: true, + }, + ], + }, + }, + ], + }, + }); + + it("pins one fingerprint across two deploys of the same chunk", () => { + const before = beforeSendCloudEvent(workerEvent("BAuwphPA"), {}); + const after = beforeSendCloudEvent(workerEvent("DkcPBbWe"), {}); + + expect(before?.fingerprint).toBeDefined(); + expect(before?.fingerprint).toEqual(after?.fingerprint); + }); + + it("leaves unhashed events on Sentry's default grouping", () => { + const event: ErrorEvent = { + type: undefined, + exception: { + values: [ + { + type: "AutumnError", + stacktrace: { + frames: [ + { filename: "/src/engine/execution-gate.ts", function: "checkExecutionBalance" }, + ], + }, + }, + ], + }, + }; + + const sent = beforeSendCloudEvent(event, {}); + + expect(sent).not.toBeNull(); + expect(sent?.fingerprint).toBeUndefined(); + }); + + // The wiring check: this is the exact object handed to `Sentry.withSentry` + // and `instrumentDurableObjectWithSentry` in server.ts. If the normalizer is + // ever dropped from the hook the worker installs, this fails. + it("the options the worker and DOs install carry the fingerprinting hook", () => { + const options = cloudSentryOptions({ SENTRY_DSN: "https://public@example.invalid/1" } as Env); + const sent = options.beforeSend(workerEvent("BAuwphPA")); + + expect(sent?.fingerprint).toEqual([ + "GateCheckTimeoutError", + "timeoutOrElse@execution-rate-limit", + ]); + // Same source, next deploy, new chunk hash — one issue, not two. + expect(options.beforeSend(workerEvent("DkcPBbWe"))?.fingerprint).toEqual(sent?.fingerprint); + }); +}); + describe("Sentry OTel correlation", () => { it.effect("adds tags from the active Effect span", () => Effect.gen(function* () { @@ -162,4 +241,62 @@ describe("Durable Object capture ownership", () => { }; expect(beforeSendWithOtelCorrelation(workerEvent)).not.toBeNull(); }); + + // The two stages of the installed `beforeSend` answer different questions and + // must both keep working: capture ownership decides WHETHER an event is + // reported, stable grouping decides HOW a reported one is grouped. A dropped + // event is never fingerprinted, and a surviving one still is. + describe("composed with stable grouping", () => { + const hashedFrames = (chunkHash: string) => ({ + stacktrace: { + frames: [ + { + filename: `/assets/session-durable-object-${chunkHash}.js`, + module: `session-durable-object-${chunkHash}`, + function: "handleSessionRequest", + in_app: true, + }, + ], + }, + }); + + it("drops a claimed echo rather than fingerprinting it", () => { + const echo = doEcho({ + exception: { + values: [ + { + type: "Error", + value: "Durable Object reset because its code was updated.", + mechanism: { type: "auto.faas.cloudflare.durable_object", handled: false }, + ...hashedFrames("BAuwphPA"), + }, + ], + }, + }); + + expect(beforeSendCloudEvent(echo, {})).toBeNull(); + }); + + it("pins a stable fingerprint on the report the DO itself owns", () => { + const ownReport = (chunkHash: string): ErrorEvent => + doEcho({ + exception: { + values: [ + { + type: "Error", + value: "Durable Object reset because its code was updated.", + mechanism: { type: "generic", handled: true }, + ...hashedFrames(chunkHash), + }, + ], + }, + }); + + const before = beforeSendCloudEvent(ownReport("BAuwphPA"), {}); + const after = beforeSendCloudEvent(ownReport("DkcPBbWe"), {}); + + expect(before?.fingerprint).toBeDefined(); + expect(before?.fingerprint).toEqual(after?.fingerprint); + }); + }); }); diff --git a/apps/cloud/src/server.ts b/apps/cloud/src/server.ts index ec4771e962..4f5bf76289 100644 --- a/apps/cloud/src/server.ts +++ b/apps/cloud/src/server.ts @@ -1,6 +1,5 @@ import { DurableObject } from "cloudflare:workers"; import { SpanKind, SpanStatusCode, context, trace, type SpanContext } from "@opentelemetry/api"; -import type { ErrorEvent } from "@sentry/cloudflare"; import { ATTR_HTTP_REQUEST_METHOD, ATTR_HTTP_RESPONSE_STATUS_CODE, @@ -19,7 +18,7 @@ import { classifyMcpPath, prepareMcpOrgScope } from "./mcp/mount"; import { parseTraceparent } from "./mcp/traceparent"; import { McpSessionDOSqlite as McpSessionDOBase } from "./mcp/session-durable-object"; import { - beforeSendWithOtelCorrelation, + cloudSentryOptions, captureCause, otelCorrelationContextFromOpenTelemetrySpan, SENTRY_EVENT_ID_ATTRIBUTE, @@ -28,28 +27,6 @@ import { import { browserTracesResponse } from "./observability/browser-traces"; import { flushTracerProvider, installTracerProvider } from "./observability/telemetry"; -// --------------------------------------------------------------------------- -// Sentry config -// --------------------------------------------------------------------------- - -const sentryOptions = (env: Env) => ({ - dsn: env.SENTRY_DSN, - tracesSampleRate: 0, - enableLogs: true, - sendDefaultPii: true, - skipOpenTelemetrySetup: true, - beforeSend: (event: ErrorEvent) => - beforeSendWithOtelCorrelation(event, { - logPayload: !env.SENTRY_DSN || env.SENTRY_OTEL_LOG_PAYLOAD === "true", - }), - // NOTE: do NOT enable `instrumentPrototypeMethods`. It walks the DO prototype - // and reads every property — including accessors — to find methods to wrap, - // which invokes the `sessionId` getter with `this` bound to the prototype - // (where `ctx` is undefined) and throws during construction, 500ing every - // session create / cold restore. The DO captures its own errors via the - // `captureCause` seam (→ Sentry) instead. -}); - // --------------------------------------------------------------------------- // Durable Object — wrapped with Sentry so DO errors land in Sentry (inits the // client inside the DO isolate, which plain `Sentry.captureException` cannot @@ -58,7 +35,7 @@ const sentryOptions = (env: Env) => ({ // --------------------------------------------------------------------------- export const McpSessionDOSqlite = Sentry.instrumentDurableObjectWithSentry( - sentryOptions, + cloudSentryOptions, McpSessionDOBase, ); @@ -458,4 +435,4 @@ const cloudflareHandler: ExportedHandler = { }, }; -export default Sentry.withSentry(sentryOptions, cloudflareHandler); +export default Sentry.withSentry(cloudSentryOptions, cloudflareHandler); diff --git a/apps/desktop/src/main/crash-fingerprint.test.ts b/apps/desktop/src/main/crash-fingerprint.test.ts new file mode 100644 index 0000000000..057498f9c7 --- /dev/null +++ b/apps/desktop/src/main/crash-fingerprint.test.ts @@ -0,0 +1,143 @@ +import { expect, test } from "@effect/vitest"; + +import { + crashReportFingerprint, + mainCrashReportingOptions, + withCrashReportFingerprint, + type CrashEvent, +} from "./crash-fingerprint"; + +/** A crash event as Sentry hands it to `beforeSend` — with the grouping key + * slot the hook is allowed to fill. */ +type SentCrashEvent = CrashEvent & { readonly fingerprint?: readonly string[] | undefined }; + +// Chromium's soft-assert path (`NOTREACHED()`/`DCHECK`) dumps and keeps +// running. Sentry titles each one with the faulting load address, so one +// condition arrives as a new issue every time the address moves. +const softAssertEvent = (address: string): SentCrashEvent => ({ + exception: { + values: [ + { + type: "Fatal Error", + value: `Simulated Exception / ${address}`, + mechanism: { type: "minidump" }, + stacktrace: { + frames: [ + { function: "logging::NotReachedLogMessage::~NotReachedLogMessage" }, + { function: "logging::HandleCheckErrorLogMessage" }, + { function: "base::debug::DumpWithoutCrashing" }, + { function: "crash_reporter::DumpWithoutCrashing" }, + ], + }, + }, + ], + }, +}); + +test("address-keyed Chromium soft asserts collapse to one fingerprint", () => { + const first = crashReportFingerprint(softAssertEvent("0x00000001a2b3c4d5")); + const second = crashReportFingerprint(softAssertEvent("0x00000007e8f9a0b1")); + + expect(first).toEqual(["chromium-dump-without-crashing"]); + expect(first).toEqual(second); +}); + +test("a real native abort keeps Sentry's own grouping", () => { + const abortEvent: CrashEvent = { + exception: { + values: [ + { + type: "EXC_CRASH", + value: "SIGABRT", + mechanism: { type: "minidump" }, + stacktrace: { + frames: [ + { function: "abort" }, + { function: "pthread_kill" }, + { function: "__pthread_kill" }, + ], + }, + }, + ], + }, + }; + + expect(crashReportFingerprint(abortEvent)).toBeUndefined(); +}); + +test("renderer chunk hashes are normalized out of the fingerprint", () => { + const rendererEvent = (chunkHash: string): CrashEvent => ({ + culprit: `loadConnections(assets/atoms-${chunkHash})`, + exception: { + values: [ + { + type: "TypeError", + value: "cannot read properties of undefined", + stacktrace: { + frames: [ + { + filename: `http://127.0.0.1:4789/assets/atoms-${chunkHash}.js`, + module: `atoms-${chunkHash}`, + function: "loadConnections", + in_app: true, + }, + ], + }, + }, + ], + }, + }); + + const release1 = crashReportFingerprint(rendererEvent("Yemn7yhP")); + const release2 = crashReportFingerprint(rendererEvent("CeCENfWa")); + + expect(release1).toEqual(["TypeError", "loadConnections@atoms"]); + expect(release1).toEqual(release2); +}); + +// `withCrashReportFingerprint` is the function object diagnostics.ts installs +// as its `beforeSend`, so these assertions cover the main-process wiring and +// not just the classifier behind it. +test("the main-process beforeSend pins the key and forwards the event", () => { + const collapsed = withCrashReportFingerprint(softAssertEvent("0x00000001a2b3c4d5")); + expect(collapsed.fingerprint).toEqual(["chromium-dump-without-crashing"]); + // Nothing else about the event is touched — this is grouping only. + expect(collapsed.exception?.values?.[0]?.value).toBe("Simulated Exception / 0x00000001a2b3c4d5"); + + const untouched: SentCrashEvent = { + exception: { values: [{ type: "EXC_CRASH", stacktrace: { frames: [{ function: "abort" }] } }] }, + }; + expect(withCrashReportFingerprint(untouched)).toBe(untouched); +}); + +// The wiring check: this is the exact object handed to `Sentry.init` in +// diagnostics.ts. If the hook is ever dropped from the main process, this +// fails. +test("the options the main process installs carry the fingerprinting hook", () => { + const options = mainCrashReportingOptions({ + dsn: "https://public@example.invalid/1", + release: "executor-desktop@0.0.0", + environment: "production", + runId: "abcdef123456", + }); + const sent = options.beforeSend(softAssertEvent("0x00000001a2b3c4d5")); + expect(sent.fingerprint).toEqual(["chromium-dump-without-crashing"]); +}); + +test("events with no volatile grouping input are left alone", () => { + expect(crashReportFingerprint({})).toBeUndefined(); + expect( + crashReportFingerprint({ + exception: { + values: [ + { + type: "Error", + stacktrace: { + frames: [{ filename: "/src/main/sidecar.ts", function: "startSidecar" }], + }, + }, + ], + }, + }), + ).toBeUndefined(); +}); diff --git a/apps/desktop/src/main/crash-fingerprint.ts b/apps/desktop/src/main/crash-fingerprint.ts new file mode 100644 index 0000000000..105f60a101 --- /dev/null +++ b/apps/desktop/src/main/crash-fingerprint.ts @@ -0,0 +1,80 @@ +/** + * Grouping keys for desktop crash reports. + * + * Two things split one desktop problem across many Sentry issues: + * + * - Chromium's soft-assert path (`NOTREACHED()`/`DCHECK`) calls + * `DumpWithoutCrashing`, which files a minidump and lets the process carry + * on. Sentry titles each one with the faulting load address, so the same + * condition arrives as a new issue every time the address moves. + * - Renderer and main bundles ship as content-hashed chunks, so unresolved + * frames name `atoms-` and re-group on every release. + * + * Both are grouping problems only: nothing here drops, downgrades or edits an + * event, and the frames keep their hashes so sourcemap resolution still works. + */ + +import { + stableGroupingFingerprint, + withStableGroupingFingerprint, + type GroupingEvent, + type GroupingFrame, +} from "@executor-js/sdk/sentry-grouping"; + +export type CrashEvent = GroupingEvent; + +export const CHROMIUM_SOFT_ASSERT_FINGERPRINT = "chromium-dump-without-crashing"; + +const isSoftAssertFrame = (frame: GroupingFrame): boolean => + (frame.function ?? "").includes("DumpWithoutCrashing"); + +/** + * The fingerprint to attach to a crash event, or `undefined` to keep Sentry's + * default grouping (which is right for a real native abort — one issue per + * distinct stack is what we want there). + */ +export const crashReportFingerprint = (event: CrashEvent): readonly string[] | undefined => { + const frames = (event.exception?.values ?? []).flatMap((value) => value.stacktrace?.frames ?? []); + if (frames.some(isSoftAssertFrame)) return [CHROMIUM_SOFT_ASSERT_FINGERPRINT]; + return stableGroupingFingerprint(event); +}; + +/** + * The `beforeSend` the Electron main process installs. Grouping only — an + * event with no volatile grouping input is forwarded unchanged. + */ +export const withCrashReportFingerprint = (event: T): T => { + const frames = (event.exception?.values ?? []).flatMap((value) => value.stacktrace?.frames ?? []); + if (frames.some(isSoftAssertFrame)) { + return { ...event, fingerprint: [CHROMIUM_SOFT_ASSERT_FINGERPRINT] }; + } + return withStableGroupingFingerprint(event); +}; + +/** + * The Sentry options the Electron main process installs, assembled here rather + * than inline at the `Sentry.init` call so the `beforeSend` wiring is covered + * by crash-fingerprint.test.ts. `diagnostics.ts` only passes this through. + * + * Typed structurally on purpose: this module stays importable without pulling + * in electron, which is what keeps it unit-testable at all. + */ +export const mainCrashReportingOptions = (config: { + readonly dsn: string; + readonly release: string; + readonly environment: string; + readonly runId: string; +}) => ({ + dsn: config.dsn, + release: config.release, + environment: config.environment, + initialScope: { + tags: { + platform: process.platform, + arch: process.arch, + runId: config.runId, + }, + }, + // Grouping only — the event is forwarded untouched otherwise. + beforeSend: withCrashReportFingerprint, +}); diff --git a/apps/desktop/src/main/diagnostics.ts b/apps/desktop/src/main/diagnostics.ts index 3d04b1cc29..c10127a478 100644 --- a/apps/desktop/src/main/diagnostics.ts +++ b/apps/desktop/src/main/diagnostics.ts @@ -23,6 +23,7 @@ import { dirname, join } from "node:path"; import { app, crashReporter, dialog, shell } from "electron"; import log from "electron-log/main.js"; import * as Sentry from "@sentry/electron/main"; +import { mainCrashReportingOptions } from "./crash-fingerprint"; import { getServerSettings } from "./settings"; const sentryDsn = __EXECUTOR_SENTRY_DSN__; @@ -78,18 +79,14 @@ export const sidecarCrashReportingEnv = (): Record => */ export const initErrorReporting = () => { if (errorReportingEnabled) { - Sentry.init({ - dsn: sentryDsn, - release: releaseTag(), - environment: environmentTag(), - initialScope: { - tags: { - platform: process.platform, - arch: process.arch, - runId, - }, - }, - }); + Sentry.init( + mainCrashReportingOptions({ + dsn: sentryDsn, + release: releaseTag(), + environment: environmentTag(), + runId, + }), + ); } else { // No DSN baked in — keep native crash dumps local so a user-reported // crash still leaves minidumps for the diagnostics zip to collect. diff --git a/packages/app/src/crash-reporting.ts b/packages/app/src/crash-reporting.ts index e06146aa7b..01c0789228 100644 --- a/packages/app/src/crash-reporting.ts +++ b/packages/app/src/crash-reporting.ts @@ -12,6 +12,8 @@ * once initialized — no reporter rewiring needed. */ +import { withStableGroupingFingerprint } from "@executor-js/sdk/sentry-grouping"; + interface CrashReportingConfig { readonly dsn: string; readonly release: string; @@ -45,6 +47,11 @@ export const initDesktopCrashReporting = (): void => { runId: config.runId, }, }, + // Route chunks are content-hashed, so an unresolved frame names + // `atoms-` and one bug re-groups on every release. Pin a + // fingerprint with the hash normalized out; the event itself keeps its + // hashed filenames so sourcemap resolution is unaffected. + beforeSend: withStableGroupingFingerprint, }); } catch { // Reporting failures stay silent — there is nowhere left to report them. diff --git a/packages/core/sdk/package.json b/packages/core/sdk/package.json index 17874f8d75..289b54834b 100644 --- a/packages/core/sdk/package.json +++ b/packages/core/sdk/package.json @@ -25,7 +25,8 @@ "./testing": "./src/testing.ts", "./migration": "./src/migration-spec.ts", "./http-auth": "./src/http-auth/index.ts", - "./public-origin": "./src/public-origin.ts" + "./public-origin": "./src/public-origin.ts", + "./sentry-grouping": "./src/sentry-grouping.ts" }, "publishConfig": { "access": "public", @@ -83,6 +84,12 @@ "types": "./dist/public-origin.d.ts", "default": "./dist/public-origin.js" } + }, + "./sentry-grouping": { + "import": { + "types": "./dist/sentry-grouping.d.ts", + "default": "./dist/sentry-grouping.js" + } } } }, diff --git a/packages/core/sdk/src/sentry-grouping.test.ts b/packages/core/sdk/src/sentry-grouping.test.ts new file mode 100644 index 0000000000..f0f8c6fb61 --- /dev/null +++ b/packages/core/sdk/src/sentry-grouping.test.ts @@ -0,0 +1,269 @@ +import { expect, test } from "@effect/vitest"; + +import { + containsContentHash, + stableGroupingFingerprint, + stripContentHashes, + withStableGroupingFingerprint, + type GroupingEvent, +} from "./sentry-grouping"; + +// Two builds of the same source: only the Vite content hash differs. +const workerFrame = (hash: string) => ({ + filename: `/assets/execution-rate-limit-${hash}.js`, + module: `execution-rate-limit-${hash}`, + function: "timeoutOrElse", + in_app: true, +}); + +const workerEvent = (hash: string): GroupingEvent => ({ + culprit: `timeoutOrElse(execution-rate-limit-${hash})`, + exception: { + values: [ + { + type: "GateCheckTimeoutError", + value: "balance check timed out", + stacktrace: { frames: [workerFrame(hash)] }, + }, + ], + }, +}); + +test("stripContentHashes rewrites chunk hashes and leaves everything else alone", () => { + expect(stripContentHashes("/assets/execution-rate-limit-BAuwphPA.js")).toBe( + "/assets/execution-rate-limit.js", + ); + expect(stripContentHashes("/assets/execution-rate-limit-DkcPBbWe.js")).toBe( + "/assets/execution-rate-limit.js", + ); + // Rollup hashes may themselves contain a dash — the whole 8-char tail goes. + expect(stripContentHashes("http://127.0.0.1:4789/assets/AddGraphqlIntegration-BCr-oWx4.js")).toBe( + "http://127.0.0.1:4789/assets/AddGraphqlIntegration.js", + ); + // Culprit strings wrap the chunk name in parentheses. + expect(stripContentHashes("U(assets/atoms-Yemn7yhP)")).toBe("U(assets/atoms)"); + expect(stripContentHashes("W(assets/atoms-CeCENfWa)")).toBe("W(assets/atoms)"); +}); + +test("stripContentHashes does not eat real name segments", () => { + // 8 lowercase letters is a word, not a hash. + expect(stripContentHashes("/src/auth/oauth-callback.ts")).toBe("/src/auth/oauth-callback.ts"); + expect(stripContentHashes("apps/cloud/src/engine/execution-gate.ts")).toBe( + "apps/cloud/src/engine/execution-gate.ts", + ); + expect(stripContentHashes("/assets/execution-rate-limit.js")).toBe( + "/assets/execution-rate-limit.js", + ); + expect(stripContentHashes("")).toBe(""); +}); + +test("stripContentHashes keeps genuinely different modules distinct", () => { + expect(stripContentHashes("/assets/atoms-Yemn7yhP.js")).not.toBe( + stripContentHashes("/assets/router-CeCENfWa.js"), + ); + expect(stripContentHashes("/assets/atoms-Yemn7yhP.js")).not.toBe( + stripContentHashes("/assets/atoms-shell-CeCENfWa.js"), + ); +}); + +// Merging two unrelated bugs into one issue is unrecoverable, so the "is this +// a hash?" threshold is pinned from both sides: a segment needs two of the +// three hash signals (uppercase, uppercase+digit, digits) before it is eaten. +// Loosening it to one signal — the tempting fix for the word-shaped hashes +// this deliberately misses — silently collapses real chunk names. +test("a name segment one signal short of a hash is left in place", () => { + // One capital, no digits. + expect(stripContentHashes("/assets/connections-Settings.js")).toBe( + "/assets/connections-Settings.js", + ); + // One digit, no capitals. + expect(stripContentHashes("/assets/storage-s3client.js")).toBe("/assets/storage-s3client.js"); + // ...and neither may be mistaken for the chunk it would collapse onto. + expect(stripContentHashes("/assets/connections-Settings.js")).not.toBe( + stripContentHashes("/assets/connections-BAuwphPA.js"), + ); +}); + +test("a segment with two hash signals is eaten", () => { + expect(stripContentHashes("/assets/chunk-AbcdefgH.js")).toBe("/assets/chunk.js"); + expect(stripContentHashes("/assets/chunk-Abcdefg1.js")).toBe("/assets/chunk.js"); + expect(stripContentHashes("/assets/chunk-abcdef12.js")).toBe("/assets/chunk.js"); +}); + +test("containsContentHash detects only hashed paths", () => { + expect(containsContentHash("/assets/atoms-Yemn7yhP.js")).toBe(true); + expect(containsContentHash("apps/cloud/src/engine/execution-gate.ts")).toBe(false); +}); + +test("the same logical frame fingerprints identically across two deploys", () => { + const first = stableGroupingFingerprint(workerEvent("BAuwphPA")); + const second = stableGroupingFingerprint(workerEvent("DkcPBbWe")); + expect(first).toBeDefined(); + expect(first).toEqual(second); + expect(first).toEqual(["GateCheckTimeoutError", "timeoutOrElse@execution-rate-limit"]); +}); + +test("different modules keep different fingerprints", () => { + const other: GroupingEvent = { + exception: { + values: [ + { + type: "GateCheckTimeoutError", + stacktrace: { + frames: [ + { + filename: "/assets/atoms-Yemn7yhP.js", + module: "atoms-Yemn7yhP", + function: "loadConnections", + in_app: true, + }, + ], + }, + }, + ], + }, + }; + expect(stableGroupingFingerprint(other)).not.toEqual( + stableGroupingFingerprint(workerEvent("BAuwphPA")), + ); +}); + +test("minified single-letter frame functions are left out of the fingerprint", () => { + // Minified identifiers rotate with every build exactly like chunk hashes, so + // keeping them would re-split the issue on the next deploy. + const atoms = (fn: string, hash: string): GroupingEvent => ({ + exception: { + values: [ + { + type: "TypeError", + stacktrace: { + frames: [ + { filename: `/assets/atoms-${hash}.js`, module: `atoms-${hash}`, function: fn }, + ], + }, + }, + ], + }, + }); + expect(stableGroupingFingerprint(atoms("U", "Yemn7yhP"))).toEqual( + stableGroupingFingerprint(atoms("W", "CeCENfWa")), + ); + expect(stableGroupingFingerprint(atoms("U", "Yemn7yhP"))).toEqual(["TypeError", "atoms"]); +}); + +// A single build emits many `dist-.js` chunks from unrelated packages, +// so the crashing frame alone is not a safe key — two different bugs would +// land in one issue. Frames are oldest-first, so the shared chunk is last. +const vendorEvent = (caller: string, hash: string): GroupingEvent => ({ + exception: { + values: [ + { + type: "TypeError", + stacktrace: { + frames: [ + { module: `${caller}-${hash}`, function: caller, in_app: true }, + { module: `dist-${hash}`, function: "throwHelper", in_app: true }, + ], + }, + }, + ], + }, +}); + +test("two vendor chunks sharing a name stay apart on their callers", () => { + const upload = stableGroupingFingerprint(vendorEvent("uploadArtifact", "BQZkXWT2")); + const render = stableGroupingFingerprint(vendorEvent("renderMarkdown", "BQZkXWT2")); + + // The crashing frame is identical in both — only the caller chain separates + // them, so a fingerprint built from the top frame alone would over-merge. + expect(upload?.slice(0, 2)).toEqual(["TypeError", "throwHelper@dist"]); + expect(render?.slice(0, 2)).toEqual(upload?.slice(0, 2)); + expect(upload).not.toEqual(render); + expect(upload).toEqual(["TypeError", "throwHelper@dist", "uploadArtifact@uploadArtifact"]); +}); + +test("the caller chain is itself deploy-stable", () => { + // Every frame in the chain must lose its hash, not just the crashing one. + expect(stableGroupingFingerprint(vendorEvent("uploadArtifact", "BQZkXWT2"))).toEqual( + stableGroupingFingerprint(vendorEvent("uploadArtifact", "Yemn7yhP")), + ); +}); + +test("events without a chunk hash keep Sentry's default grouping", () => { + const unhashed: GroupingEvent = { + culprit: "checkExecutionBalance(execution-gate.ts)", + exception: { + values: [ + { + type: "AutumnError", + stacktrace: { + frames: [ + { + filename: "/apps/cloud/src/engine/execution-gate.ts", + function: "checkExecutionBalance", + in_app: true, + }, + ], + }, + }, + ], + }, + }; + expect(stableGroupingFingerprint(unhashed)).toBeUndefined(); + expect(stableGroupingFingerprint({})).toBeUndefined(); + expect(stableGroupingFingerprint({ exception: { values: [] } })).toBeUndefined(); +}); + +test("a hashed culprit alone is enough to pin the fingerprint", () => { + const culpritOnly: GroupingEvent = { + culprit: "orElse(execution-rate-limit-BAuwphPA)", + exception: { values: [{ type: "TypeError", value: "destroyed" }] }, + }; + expect(stableGroupingFingerprint(culpritOnly)).toEqual([ + "TypeError", + "orElse(execution-rate-limit)", + ]); +}); + +// The wrapper every process installs as its `beforeSend`. `fingerprint` and +// `tags` stand in for the fields a real Sentry event carries around it. +type SentEvent = GroupingEvent & { + readonly fingerprint?: readonly string[] | undefined; + readonly tags?: Record | undefined; +}; + +test("withStableGroupingFingerprint pins the key and changes nothing else", () => { + const input: SentEvent = { ...workerEvent("BAuwphPA"), tags: { a: "b" } }; + const hashed = withStableGroupingFingerprint(input); + expect(hashed.fingerprint).toEqual([ + "GateCheckTimeoutError", + "timeoutOrElse@execution-rate-limit", + ]); + // The event still carries its hashed filename, or server-side sourcemap + // resolution would stop finding the release's artifacts. + expect(hashed.exception?.values?.[0]?.stacktrace?.frames?.[0]?.filename).toBe( + "/assets/execution-rate-limit-BAuwphPA.js", + ); + expect(hashed.tags).toEqual({ a: "b" }); +}); + +test("withStableGroupingFingerprint forwards an unhashed event untouched", () => { + const plain: SentEvent = { culprit: "checkExecutionBalance(execution-gate.ts)" }; + const out = withStableGroupingFingerprint(plain); + expect(out).toBe(plain); + expect("fingerprint" in out).toBe(false); +}); + +test("the outermost exception drives the fingerprint", () => { + // Sentry orders `values` innermost-first; the last entry is the one whose + // type the issue is titled with. + const chained: GroupingEvent = { + exception: { + values: [ + { type: "InnerError", stacktrace: { frames: [workerFrame("BAuwphPA")] } }, + { type: "OuterError", stacktrace: { frames: [workerFrame("BAuwphPA")] } }, + ], + }, + }; + expect(stableGroupingFingerprint(chained)?.[0]).toBe("OuterError"); +}); diff --git a/packages/core/sdk/src/sentry-grouping.ts b/packages/core/sdk/src/sentry-grouping.ts new file mode 100644 index 0000000000..6f07385ee0 --- /dev/null +++ b/packages/core/sdk/src/sentry-grouping.ts @@ -0,0 +1,169 @@ +// --------------------------------------------------------------------------- +// Stable Sentry grouping across deploys. +// +// Every bundle we ship names its chunks `-.js`, and the +// hash rotates on every build. When a stack frame is not resolved back to +// source (no uploaded sourcemap for that release), Sentry groups on those +// minified names, so the SAME error opens a brand-new issue after each +// deploy — one bug spread over N issues, no counts, no regression history. +// +// The fix is a `beforeSend` that computes an explicit `fingerprint` from the +// event's grouping inputs with the volatile hash segment removed. The event +// itself is left untouched: filenames still carry their hashes so server-side +// sourcemap resolution keeps working — only the grouping key is normalized. +// +// Deliberately narrow: a fingerprint is returned ONLY for events that actually +// carry a content hash. Everything else keeps Sentry's default grouping. +// --------------------------------------------------------------------------- + +/** The shape of a Sentry stack frame this module reads. Structural on purpose: + * the same helper serves @sentry/cloudflare, /electron and /browser events. */ +export type GroupingFrame = { + readonly filename?: string | undefined; + readonly abs_path?: string | undefined; + readonly module?: string | undefined; + readonly function?: string | undefined; + readonly in_app?: boolean | undefined; +}; + +export type GroupingExceptionValue = { + readonly type?: string | undefined; + readonly value?: string | undefined; + readonly stacktrace?: { readonly frames?: readonly GroupingFrame[] | undefined } | undefined; + readonly mechanism?: { readonly type?: string | undefined } | undefined; +}; + +export type GroupingEvent = { + readonly culprit?: string | undefined; + readonly exception?: { readonly values?: readonly GroupingExceptionValue[] | undefined }; +}; + +/** + * A `-XXXXXXXX` tail that ends a path segment — i.e. is followed by nothing, + * by file extensions, or by a delimiter such as the `)` in a Sentry culprit + * (`orElse(execution-rate-limit-BAuwphPA)`). Eight characters is the Vite and + * Rollup default; the hash alphabet includes `-` and `_`, so a hash can itself + * contain a dash (`BCr-oWx4`) and the whole 8-char tail must go at once. + */ +const HASH_TAIL = /-([A-Za-z0-9_-]{8})(?=(?:\.[A-Za-z0-9]+)*(?:$|[)\]}'"\s,:;?#]))/g; + +/** + * Whether an 8-character segment reads like a content hash rather than a word. + * Build hashes are random base64url, so they mix cases and digits; real name + * segments (`callback`, `grouping`, `provider`) are all lowercase. Requiring + * that mix is what keeps `oauth-callback` from collapsing to `oauth`. The cost + * is that the occasional word-shaped hash (one capital, no digits) is left + * alone and keeps re-grouping — 1 of 147 chunks in a sample desktop build. A + * missed normalization is recoverable; a wrongly merged issue is not. + */ +const looksLikeContentHash = (segment: string): boolean => { + let upper = 0; + let digits = 0; + for (const char of segment) { + if (char >= "A" && char <= "Z") upper += 1; + else if (char >= "0" && char <= "9") digits += 1; + } + return upper >= 2 || (upper >= 1 && digits >= 1) || digits >= 2; +}; + +/** + * Remove build content hashes from a path, module name or culprit string, + * leaving every other character in place. + */ +export const stripContentHashes = (value: string): string => + value.replace(HASH_TAIL, (match, segment: string) => + looksLikeContentHash(segment) ? "" : match, + ); + +/** True when the value carries at least one build content hash. */ +export const containsContentHash = (value: string): boolean => stripContentHashes(value) !== value; + +/** + * Minified identifiers rotate with every build exactly like chunk hashes, so a + * fingerprint containing one is no more stable than the hash it replaced. Names + * of three characters or fewer are treated as minified and dropped; the frame + * still contributes its module. + */ +const MINIFIED_FUNCTION = /^[A-Za-z_$][A-Za-z0-9_$]{0,2}$/; + +const usableFunction = (name: string | undefined): string | undefined => { + if (!name || name === "" || name === "?") return undefined; + return MINIFIED_FUNCTION.test(name) ? undefined : name; +}; + +/** Last path segment, without query or fragment. Chunk paths are served from + * host- and port-specific origins (`http://127.0.0.1:4789/assets/…`), which are + * volatile in their own right. */ +const basename = (path: string): string => { + const clean = path.split(/[?#]/)[0] ?? path; + const segments = clean.split(/[/\\]/); + return segments[segments.length - 1] || clean; +}; + +const frameLocation = (frame: GroupingFrame): string | undefined => { + if (frame.module) return stripContentHashes(frame.module); + const path = frame.filename ?? frame.abs_path; + return path ? stripContentHashes(basename(path)) : undefined; +}; + +const frameKey = (frame: GroupingFrame): string | undefined => { + const location = frameLocation(frame); + if (!location) return undefined; + const fn = usableFunction(frame.function); + return fn ? `${fn}@${location}` : location; +}; + +const frameHasContentHash = (frame: GroupingFrame): boolean => + containsContentHash(frame.module ?? "") || + containsContentHash(frame.filename ?? "") || + containsContentHash(frame.abs_path ?? ""); + +/** + * How many frames from the top of the stack enter the fingerprint. Bundlers + * emit several unrelated chunks under the same name (nine different + * `dist-.js` in one desktop build), so the top frame alone would merge + * unrelated vendor code; the caller chain is what keeps them apart. The + * resulting key is still strictly coarser than Sentry's default input — same + * frames, minus the volatile hash and minified identifiers — so this can only + * merge issues, never split one further. + */ +const FINGERPRINT_FRAMES = 8; + +/** + * A deploy-stable fingerprint for an event whose grouping input carries a build + * content hash, or `undefined` to leave Sentry's default grouping in place. + */ +export const stableGroupingFingerprint = (event: GroupingEvent): readonly string[] | undefined => { + const values = event.exception?.values ?? []; + // Sentry orders chained exceptions innermost-first; the last entry is the one + // the issue is titled with. + const primary = values[values.length - 1]; + if (!primary) return undefined; + + const frames = primary.stacktrace?.frames ?? []; + const culprit = event.culprit ?? ""; + if (!frames.some(frameHasContentHash) && !containsContentHash(culprit)) return undefined; + + const inApp = frames.filter((frame) => frame.in_app !== false); + const considered = (inApp.length > 0 ? inApp : frames).slice(-FINGERPRINT_FRAMES).reverse(); + const keys = considered.flatMap((frame) => { + const key = frameKey(frame); + return key ? [key] : []; + }); + + const type = primary.type ?? "Error"; + if (keys.length > 0) return [type, ...keys]; + return culprit ? [type, stripContentHashes(culprit)] : undefined; +}; + +/** + * The whole `beforeSend` contract: pin the deploy-stable fingerprint when the + * event has a volatile grouping input, and forward the event untouched when it + * does not. Every call site (cloud worker/DO, desktop main, desktop renderer) + * installs this one symbol rather than its own copy, so the wiring is covered + * by the tests below instead of being retyped per process. + */ +export const withStableGroupingFingerprint = (event: T): T => { + const fingerprint = stableGroupingFingerprint(event); + return fingerprint ? { ...event, fingerprint: [...fingerprint] } : event; +}; diff --git a/packages/core/sdk/tsup.config.ts b/packages/core/sdk/tsup.config.ts index e18e7a71b7..338335e485 100644 --- a/packages/core/sdk/tsup.config.ts +++ b/packages/core/sdk/tsup.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ "migration-spec": "src/migration-spec.ts", "http-auth": "src/http-auth/index.ts", testing: "src/testing.ts", + "sentry-grouping": "src/sentry-grouping.ts", }, format: ["esm"], dts: false, From e63a6b777ea0e50f2ca43a57069e44ac195ac4a3 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:50:45 -0700 Subject: [PATCH 132/133] Unlist the built-in Google OAuth app from connect pickers (#1795) --- apps/cloud/src/engine/execution-stack.ts | 8 +- .../engine/first-party-oauth-clients.test.ts | 87 +++++++++++++++++++ e2e/scenarios/first-party-oauth.test.ts | 82 +++-------------- packages/core/sdk/src/oauth-client.ts | 11 +++ .../core/sdk/src/oauth-first-party.test.ts | 65 ++++++++++++++ packages/core/sdk/src/oauth-service.ts | 13 ++- 6 files changed, 191 insertions(+), 75 deletions(-) create mode 100644 apps/cloud/src/engine/first-party-oauth-clients.test.ts diff --git a/apps/cloud/src/engine/execution-stack.ts b/apps/cloud/src/engine/execution-stack.ts index cc9afb38d1..a7ff4de700 100644 --- a/apps/cloud/src/engine/execution-stack.ts +++ b/apps/cloud/src/engine/execution-stack.ts @@ -135,7 +135,7 @@ const GOOGLE_FIRST_PARTY_ALLOWED_SCOPES: readonly string[] = [ // `_TOKEN_URL` overrides exist so tests and dev instances can point the app at // an emulated provider (`@executor-js/emulate`) and run the complete flow. // Production leaves them unset. -const cloudFirstPartyOAuthClients = (): readonly FirstPartyOAuthClientConfig[] => [ +export const cloudFirstPartyOAuthClients = (): readonly FirstPartyOAuthClientConfig[] => [ ...(env.FIRST_PARTY_GITHUB_CLIENT_ID && env.FIRST_PARTY_GITHUB_CLIENT_SECRET ? [ { @@ -162,6 +162,12 @@ const cloudFirstPartyOAuthClients = (): readonly FirstPartyOAuthClientConfig[] = clientId: env.FIRST_PARTY_GOOGLE_CLIENT_ID, clientSecret: env.FIRST_PARTY_GOOGLE_CLIENT_SECRET, allowedScopes: GOOGLE_FIRST_PARTY_ALLOWED_SCOPES, + // Withdrawn from the connect picker: no new connection is offered the + // Executor-owned Google app. The entry stays declared on purpose — + // every connection already minted against it keeps refreshing and + // reconnecting through it. Deleting this block, or unsetting the env + // vars, would strand those connections instead. + unlisted: true, }, ] : []), diff --git a/apps/cloud/src/engine/first-party-oauth-clients.test.ts b/apps/cloud/src/engine/first-party-oauth-clients.test.ts new file mode 100644 index 0000000000..9780a1c627 --- /dev/null +++ b/apps/cloud/src/engine/first-party-oauth-clients.test.ts @@ -0,0 +1,87 @@ +import { env } from "cloudflare:workers"; +import { beforeAll, describe, expect, it } from "@effect/vitest"; + +import { cloudFirstPartyOAuthClients } from "./execution-stack"; + +// The reviewed consumer scope boundary of the Executor-owned Google app. +// +// These assertions used to live in `e2e/scenarios/first-party-oauth.test.ts`, +// read off `listClients`. The app is now `unlisted`, so it has no read surface +// to introspect — the bundle is only observable on the config it is built from, +// which is here. The e2e still owns the BEHAVIOUR the boundary produces (which +// scopes an `oauth.start` requests, and that admin scopes are refused). +const GOOGLE_SCOPE = (suffix: string) => `https://www.googleapis.com/auth/${suffix}`; + +describe("cloud first-party oauth clients", () => { + beforeAll(() => { + env.FIRST_PARTY_GOOGLE_CLIENT_ID = "test-google-client"; + env.FIRST_PARTY_GOOGLE_CLIENT_SECRET = "test-google-secret"; + }); + + const google = () => cloudFirstPartyOAuthClients().find((client) => client.name === "google"); + + it("declares the Google app but withholds it from every listing", () => { + const client = google(); + expect(client, "the env-declared first-party Google app is configured").toBeDefined(); + // The entry MUST stay declared: `loadClient` resolves it by slug for every + // existing connection's refresh and reconnect. `unlisted` is what stops it + // being offered for new connections. + expect(client?.unlisted).toBe(true); + }); + + it("covers the reviewed consumer bundle", () => { + const allowed = google()?.allowedScopes; + expect(allowed).toBeDefined(); + for (const scope of [ + "calendar", + "meetings.space.readonly", + "spreadsheets", + "drive.file", + "drive", + "documents", + "presentations", + "forms.body", + "forms.responses.readonly", + "tasks", + "contacts", + "contacts.other.readonly", + "directory.readonly", + "user.addresses.read", + "user.birthday.read", + "user.emails.read", + "user.gender.read", + "user.organization.read", + "user.phonenumbers.read", + "photoslibrary.appendonly", + "photoslibrary.edit.appcreateddata", + "photospicker.mediaitems.readonly", + "webmasters", + "gmail.settings.basic", + ]) { + expect(allowed).toContain(GOOGLE_SCOPE(scope)); + } + // `gmail.modify` stays in the host-enforced allowlist on purpose: a + // connection created before the full-Gmail review still declares it, and + // `resolveFirstPartyScopes` filters discovered scopes through this list, so + // dropping it would break those reconnects — as the legacy-spec case in the + // e2e asserts. The invariant that new Gmail presets request + // `mail.google.com` instead lives in the preset unit tests + // (packages/plugins/openapi/.../presets.test.ts), which is where the + // request-side scope choice is actually decided. + expect(allowed).toContain("https://mail.google.com/"); + expect(allowed).toContain(GOOGLE_SCOPE("gmail.modify")); + }); + + it("excludes the scopes held back from consumer review", () => { + const allowed = google()?.allowedScopes; + expect(allowed).toBeDefined(); + for (const scope of [ + "gmail.settings.sharing", + "admin.directory.user", + "youtube", + "cloud-platform", + ]) { + expect(allowed).not.toContain(GOOGLE_SCOPE(scope)); + } + }); +}); diff --git a/e2e/scenarios/first-party-oauth.test.ts b/e2e/scenarios/first-party-oauth.test.ts index f162163a1a..e11984aa39 100644 --- a/e2e/scenarios/first-party-oauth.test.ts +++ b/e2e/scenarios/first-party-oauth.test.ts @@ -169,7 +169,7 @@ scenario( ); scenario( - "First-party OAuth · Google offers the reviewed consumer bundle and refuses admin scopes", + "First-party OAuth · unlisted Google still authorizes its bundle and refuses admin scopes", {}, Effect.scoped( Effect.gen(function* () { @@ -212,76 +212,18 @@ scenario( } }); + // The Executor-owned Google app is withheld from every listing: it is no + // longer offered for new connections, so connecting Google means bringing + // your own OAuth app. It stays fully resolvable by slug, which the + // `oauth.start` cases below exercise — that is the guarantee for everyone + // already connected through it. Its reviewed consumer scope bundle, no + // longer introspectable from here, is asserted on the config it is built + // from, in apps/cloud/src/engine/first-party-oauth-clients.test.ts. const clients = yield* client.oauth.listClients(); - const google = clients.find((candidate) => String(candidate.slug) === "first-party:google"); - expect(google, "the env-declared first-party Google app is listed").toBeDefined(); - expect(google?.origin.kind).toBe("first_party"); - if (google?.origin.kind !== "first_party") return; - expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/calendar"); - expect(google.origin.allowedScopes).toContain( - "https://www.googleapis.com/auth/meetings.space.readonly", - ); - // `gmail.modify` stays in the host-enforced allowlist on purpose: a - // connection created before the full-Gmail review still declares it, and - // `resolveFirstPartyScopes` filters discovered scopes through this list, - // so dropping it would break those reconnects — as the legacy-spec case - // further down this file asserts. The invariant that new Gmail presets - // request `mail.google.com` instead lives in the preset unit tests - // (packages/plugins/openapi/.../presets.test.ts), which is where the - // request-side scope choice is actually decided. - expect(google.origin.allowedScopes).toContain("https://mail.google.com/"); - expect(google.origin.allowedScopes).toContain( - "https://www.googleapis.com/auth/gmail.settings.basic", - ); - expect(google.origin.allowedScopes).not.toContain( - "https://www.googleapis.com/auth/gmail.settings.sharing", - ); - expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/spreadsheets"); - expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/drive.file"); - expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/drive"); - expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/documents"); - expect(google.origin.allowedScopes).toContain( - "https://www.googleapis.com/auth/presentations", - ); - expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/forms.body"); - expect(google.origin.allowedScopes).toContain( - "https://www.googleapis.com/auth/forms.responses.readonly", - ); - expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/tasks"); - expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/contacts"); - expect(google.origin.allowedScopes).toContain( - "https://www.googleapis.com/auth/contacts.other.readonly", - ); - expect(google.origin.allowedScopes).toContain( - "https://www.googleapis.com/auth/directory.readonly", - ); - for (const scope of [ - "user.addresses.read", - "user.birthday.read", - "user.emails.read", - "user.gender.read", - "user.organization.read", - "user.phonenumbers.read", - ]) { - expect(google.origin.allowedScopes).toContain(`https://www.googleapis.com/auth/${scope}`); - } - expect(google.origin.allowedScopes).toContain( - "https://www.googleapis.com/auth/photoslibrary.appendonly", - ); - expect(google.origin.allowedScopes).toContain( - "https://www.googleapis.com/auth/photoslibrary.edit.appcreateddata", - ); - expect(google.origin.allowedScopes).toContain( - "https://www.googleapis.com/auth/photospicker.mediaitems.readonly", - ); - expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/webmasters"); - expect(google.origin.allowedScopes).not.toContain( - "https://www.googleapis.com/auth/admin.directory.user", - ); - expect(google.origin.allowedScopes).not.toContain("https://www.googleapis.com/auth/youtube"); - expect(google.origin.allowedScopes).not.toContain( - "https://www.googleapis.com/auth/cloud-platform", - ); + expect( + clients.find((candidate) => String(candidate.slug) === "first-party:google"), + "the first-party Google app is not offered in listings", + ).toBeUndefined(); const calendar = IntegrationSlug.make(unique("google_calendar")); yield* client.openapi.addSpec({ diff --git a/packages/core/sdk/src/oauth-client.ts b/packages/core/sdk/src/oauth-client.ts index 2cbbbd9110..843f80da35 100644 --- a/packages/core/sdk/src/oauth-client.ts +++ b/packages/core/sdk/src/oauth-client.ts @@ -187,6 +187,17 @@ export interface FirstPartyOAuthClientConfig { * GitHub Apps, whose capabilities are configured on the app and whose OAuth * user-token flow does not use scopes. Omit for normal OAuth clients. */ readonly authorizationScopes?: readonly string[]; + /** Withdraw the app from every listing surface without retiring it. It stops + * appearing in `listClients` — so connect pickers and the agent-facing + * client list never offer it — while remaining fully resolvable by slug. + * Load, start, completion, and refresh all go through `loadClient`, which + * reads config directly, so connections already minted against the app keep + * renewing and reconnecting exactly as before. + * + * This is the safe way to stop offering a shared app. Dropping its env vars + * instead removes the config entry itself, which strands every existing + * connection on a client the host can no longer resolve. */ + readonly unlisted?: boolean; /** OAuth scopes this deployment permits the app to request. Omit to allow * every scope declared by a matching integration. For declared scopes, * start and completion fail unless every requested scope belongs to this diff --git a/packages/core/sdk/src/oauth-first-party.test.ts b/packages/core/sdk/src/oauth-first-party.test.ts index d9070bceaa..848c2fd534 100644 --- a/packages/core/sdk/src/oauth-first-party.test.ts +++ b/packages/core/sdk/src/oauth-first-party.test.ts @@ -288,6 +288,71 @@ describe("first-party oauth clients", () => { ), ); + it.effect("an unlisted first-party app is withheld from listings but still refreshes", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); + const harness = yield* makeTestWorkspaceHarness({ + plugins, + firstPartyOAuthClients: [{ ...firstPartyClientFor(server), unlisted: true }], + }); + const { executor, config } = harness; + yield* executor.acme.seed(); + + yield* executor.oauth.createClient({ + owner: "org", + slug: OAuthClientSlug.make("byo-app"), + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + grant: "authorization_code", + clientId: "byo-client", + clientSecret: "byo-secret", + }); + + // Withheld from the surface that OFFERS an app for a new connection. + const clients = yield* executor.oauth.listClients(); + expect(clients.map((c) => String(c.slug))).toEqual(["byo-app"]); + + // …yet the app itself is untouched: a connection resolves, mints, and + // renews through it exactly as a listed one would. + const started = yield* executor.oauth.start({ + owner: "org", + client: FIRST_PARTY, + clientOwner: "org", + name: ConnectionName.make("main"), + integration: INTEG, + template: TEMPLATE, + }); + expect(started.status).toBe("redirect"); + if (started.status !== "redirect") return; + const callback = yield* server.completeAuthorizationCodeFlow({ + authorizationUrl: started.authorizationUrl, + }); + yield* executor.oauth.complete({ state: started.state, code: callback.code }); + + const firstToken = (yield* executor.execute( + ToolAddress.make("tools.acme.org.main.whoami"), + {}, + )) as { token: string }; + + // Force expiry so the next resolve must refresh through the config client. + yield* Effect.promise(() => + config.db.updateMany("connection", { + where: (b) => b("name", "=", "main"), + set: { expires_at: Date.now() - 60_000 }, + }), + ); + + const refreshedToken = (yield* executor.execute( + ToolAddress.make("tools.acme.org.main.whoami"), + {}, + )) as { token: string }; + expect(refreshedToken.token).not.toBe(firstToken.token); + expect(yield* server.acceptsAccessToken(refreshedToken.token)).toBe(true); + }), + ), + ); + it.effect("a scope-limited first-party app rejects an integration outside its policy", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index 7515799794..ab964eb5da 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -1181,8 +1181,14 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { // and projected exactly like stored rows — clientId only, never the secret. // Owner is reported as "org" (the widest visibility the summary shape can // express); the flow itself ignores owner for first-party slugs. - const firstPartySummaries: readonly OAuthClientSummary[] = [...firstPartyBySlug.values()].map( - (config) => ({ + // + // `unlisted` apps are withheld here and ONLY here: listing is what offers an + // app for a NEW connection, so this is the whole of "stop offering it". + // `loadClient` still resolves them, keeping every existing connection's + // refresh and reconnect intact. + const firstPartySummaries: readonly OAuthClientSummary[] = [...firstPartyBySlug.values()] + .filter((config) => config.unlisted !== true) + .map((config) => ({ owner: "org", slug: firstPartyOAuthClientSlug(config.name), grant: "authorization_code", @@ -1195,8 +1201,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { ...(config.integrations !== undefined ? { integrations: config.integrations } : {}), ...(config.allowedScopes !== undefined ? { allowedScopes: config.allowedScopes } : {}), }, - }), - ); + })); return deps.fuma .use("oauth_client.findMany", (db) => looseDb(db).findMany("oauth_client", {})) .pipe( From 3927eba7601456d3c273693d7a25bf86f4315366 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:52:21 -0700 Subject: [PATCH 133/133] Back off vault version-check retries and persist rotated refresh tokens first (#1779) * Back off vault version-check retries and persist the rotated refresh token first * Prove the vault write contention is real and time-bounded in e2e --- e2e/cloud/credential-write-durability.test.ts | 570 ++++++++++++++++++ packages/core/sdk/src/executor.ts | 19 +- .../workos-vault/src/sdk/secret-store.test.ts | 4 +- .../workos-vault/src/sdk/secret-store.ts | 105 +++- 4 files changed, 675 insertions(+), 23 deletions(-) create mode 100644 e2e/cloud/credential-write-durability.test.ts diff --git a/e2e/cloud/credential-write-durability.test.ts b/e2e/cloud/credential-write-durability.test.ts new file mode 100644 index 0000000000..589b050db4 --- /dev/null +++ b/e2e/cloud/credential-write-durability.test.ts @@ -0,0 +1,570 @@ +// Cloud: a refreshed OAuth credential has to be PERSISTED, and persisting it is +// a pair of version-checked writes into WorkOS Vault — the rotated refresh +// token and the new access token, one after the other, not atomically. Two +// production failures live in that gap. +// +// 1. Contention. Two concurrent probes of one connection each run a refresh and +// each write the same two objects, so a version-checked write comes back 409 +// ("Current version does not match expected version"). A write that retries +// with no wait re-collides inside the peer's own round trip, drains its +// attempts in microseconds, and the refresh fails outright — the user saw a +// 500 on a connection health request. +// 2. Ordering. If the write that fails is the one carrying the rotated refresh +// token, the credential is gone for good: minting already spent the refresh +// token we sent, so nothing can mint again and every later use of the +// connection comes back `invalid_grant`. The access token, by contrast, is +// disposable — one more grant re-mints it. +// +// Both are pinned here at the product surface, black box. Failures are armed on +// the WorkOS emulator that the product's own WorkOS client talks to; no product +// code, stubs, or internals are touched. Contention is modelled twice, because +// the two halves of the write policy fail differently: by a COUNT of collisions +// (does the loop have enough attempts, and does it still land the value when it +// runs out?) and by a WINDOW of time (does it wait long enough between attempts +// to still be trying when the peer lets go?). Every scenario also reads the +// emulator's ledger back to prove the collisions it armed were really served — +// a fault whose pattern stopped matching would otherwise leave a test that +// passes without ever contending. +import { randomBytes } from "node:crypto"; +import { createServer } from "node:http"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { + type ArmedFault, + connectEmulator, + type EmulatorClient, + type FaultArmInput, +} from "@executor-js/emulate"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + OAuthClientSlug, +} from "@executor-js/sdk/shared"; +import { serveOAuthTestServer } from "@executor-js/sdk/testing"; + +import { scenario } from "../src/scenario"; +import { Api, Mcp, Target } from "../src/services"; +import { WORKOS_EMULATOR_PORT } from "../targets/cloud"; + +const api = composePluginApi([openApiHttpPlugin()] as const); + +const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; + +const VAULT_CONFLICT_RESPONSE = { + status: 409, + body: { code: "conflict", message: "Current version does not match expected version" }, +} as const; + +/** Every PUT the vault serves for a stored object — the version-checked write. */ +const VAULT_WRITE = { method: "PUT", pathPattern: "/vault/v1/kv/*" } as const; + +type UpstreamHandle = { + readonly url: string; + /** Stop honouring the bearer(s) seen so far, exactly as a revocation would — + * the deterministic way to make the very next call run a refresh. */ + readonly revokeSeenBearers: () => void; + readonly close: () => void; +}; + +/** Upstream on 127.0.0.1 that authenticates for real: `GET /issues` is 200 for + * any bearer it has not been told to reject, and 401 for one it has. */ +const serveUpstream = () => + Effect.acquireRelease( + Effect.callback((resume) => { + const seen: string[] = []; + const revoked = new Set(); + const server = createServer((request, response) => { + const bearer = (request.headers.authorization ?? "").replace(/^Bearer\s+/i, ""); + if (request.method === "GET" && (request.url ?? "").startsWith("/issues")) { + seen.push(bearer); + if (revoked.has(bearer)) { + response.writeHead(401, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: "invalid_token" })); + return; + } + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ issues: [{ id: 1, title: "first" }] })); + return; + } + response.writeHead(404, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: "not_found" })); + }); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + resume( + Effect.succeed({ + url: `http://127.0.0.1:${port}`, + revokeSeenBearers: () => { + for (const bearer of seen) revoked.add(bearer); + }, + close: () => { + server.close(); + server.closeAllConnections(); + }, + }), + ); + }); + }), + (server) => Effect.sync(server.close), + ); + +const spec = ( + baseUrl: string, + oauth: { readonly authorizationEndpoint: string; readonly tokenEndpoint: string }, +): string => + JSON.stringify({ + openapi: "3.0.3", + info: { title: "Issues API", version: "1.0.0" }, + servers: [{ url: baseUrl }], + paths: { + "/issues": { + get: { + operationId: "listIssues", + summary: "List issues", + security: [{ oauth: ["issues.read"] }], + responses: { "200": { description: "issues" } }, + }, + }, + }, + components: { + securitySchemes: { + oauth: { + type: "oauth2", + flows: { + authorizationCode: { + authorizationUrl: oauth.authorizationEndpoint, + tokenUrl: oauth.tokenEndpoint, + scopes: { "issues.read": "Read issues" }, + }, + }, + }, + }, + }, + }); + +const invokeByAddressCode = (address: string, args: unknown) => ` +const segments = ${JSON.stringify(address)}.split(".").slice(1); +let node = tools; +for (const segment of segments) node = node[segment]; +const result = await node(${JSON.stringify(args)}); +return JSON.stringify(result); +`; + +/** One vault object as the emulated WorkOS itself sees it. Read from the + * emulator control plane — upstream state, never the product's own. */ +type VaultObject = { readonly id: string; readonly name: string }; + +/** Read a vault row out of the emulator's generic store snapshot, or null when + * it is not shaped like one. */ +const readVaultObject = (row: Record): VaultObject | null => + typeof row.workos_id === "string" && typeof row.name === "string" + ? { id: row.workos_id, name: row.name } + : null; + +/** Every vault object this connection owns. The object name embeds the logical + * item id, so the connection's own objects are the ones carrying its slug. */ +const vaultObjectsFor = (workos: EmulatorClient, slug: string): Effect.Effect => + Effect.promise(async () => { + const snapshot = await workos.state(); + const items = snapshot.collections["workos.vault_objects"]?.items ?? []; + return items + .map((item) => readVaultObject({ ...item })) + .filter((object): object is VaultObject => object !== null && object.name.includes(slug)); + }); + +/** Arm a fault on the shared emulator and take it back down with the scope, by + * id — a blanket `faults.clear()` would also disarm a neighbouring scenario's. */ +const armFault = (workos: EmulatorClient, input: FaultArmInput) => + Effect.acquireRelease( + Effect.promise(() => workos.faults.arm(input)), + (armed) => Effect.promise(() => workos.faults.clear(armed.id)).pipe(Effect.ignore), + ); + +/** How many responses this armed fault actually injected, read back from the + * emulator's ledger. This is the proof that the failure under test HAPPENED: + * an armed fault whose pattern never matched leaves the product's writes + * untouched, and a scenario asserting only "the call worked" would pass + * without ever exercising the retry it claims to cover. */ +const faultsServed = (workos: EmulatorClient, armed: ArmedFault): Effect.Effect => + Effect.promise(async () => { + const entries = await workos.ledger.list(200); + return entries.filter((entry) => entry.faultId === armed.id).length; + }); + +/** Keep the vault's version-checked writes losing for a WINDOW of time that + * starts at the first collision the product actually suffers — a peer writer + * that holds the object for a while, rather than a fixed number of collisions. + * This is the shape of the production failure: attempts spaced by a wait + * outlast the peer's round trip; attempts fired back to back all land inside + * it, drain, and the refresh dies. + * + * Anchoring on the first collision (not on arming) is what makes it + * deterministic — a refresh only begins several round trips after the fault is + * armed, so a window measured from arming would already be over. */ +const holdWritesInConflict = (workos: EmulatorClient, windowMillis: number) => + Effect.gen(function* () { + // Far more conflicts than any bounded retry policy can consume, so the + // window — not a counter — is what ends the contention. + const armed = yield* armFault(workos, { + match: VAULT_WRITE, + response: VAULT_CONFLICT_RESPONSE, + times: 64, + }); + const released = (async () => { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + const live = (await workos.faults.list()).find((fault) => fault.id === armed.id); + if (!live || live.remaining < armed.times) break; + await new Promise((resolve) => setTimeout(resolve, 15)); + } + await new Promise((resolve) => setTimeout(resolve, windowMillis)); + await workos.faults.clear(armed.id); + })().catch(() => undefined); + return { armed, released }; + }); + +type CallResult = { readonly ok: boolean; readonly text: string }; + +type ToolEnvelope = { + readonly ok: boolean; + readonly data?: unknown; + readonly error?: { + readonly code?: string; + readonly message?: string; + readonly status?: number; + }; +}; + +/** An OAuth-backed integration, connected and proven working, plus the handles + * needed to break its credential writes: the upstream that can revoke a bearer + * (the deterministic refresh trigger) and the WorkOS emulator that serves the + * vault. Everything it creates is torn down by scope finalizers. */ +const connectIntegration = Effect.gen(function* () { + const target = yield* Target; + const { client: makeClient } = yield* Api; + const mcp = yield* Mcp; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const upstream = yield* serveUpstream(); + // Long-lived tokens: the proactive expiry check must never fire, so the + // upstream's 401 is the only thing that triggers the refresh whose + // persistence is under test. + const oauth = yield* serveOAuthTestServer({ + scopes: ["issues.read"], + tokenExpiresInSeconds: 3600, + }); + const workos = yield* Effect.promise(() => + connectEmulator({ baseUrl: `http://127.0.0.1:${WORKOS_EMULATOR_PORT}` }), + ); + const slug = unique("credwrite"); + const clientSlug = OAuthClientSlug.make(unique("credwriteclient")); + + yield* client.openapi.addSpec({ + payload: { + spec: { kind: "blob", value: spec(upstream.url, oauth) }, + slug, + baseUrl: upstream.url, + authenticationTemplate: [ + { + slug: "oauth", + kind: "oauth2", + authorizationUrl: oauth.authorizationEndpoint, + tokenUrl: oauth.tokenEndpoint, + scopes: ["issues.read"], + }, + ], + }, + }); + yield* Effect.addFinalizer(() => + client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore), + ); + + yield* client.oauth.createClient({ + payload: { + owner: "org", + slug: clientSlug, + grant: "authorization_code", + authorizationUrl: oauth.authorizationEndpoint, + tokenUrl: oauth.tokenEndpoint, + clientId: "test-client", + clientSecret: "test-secret", + originIntegration: IntegrationSlug.make(slug), + }, + }); + yield* Effect.addFinalizer(() => + client.oauth + .removeClient({ params: { slug: clientSlug }, payload: { owner: "org" } }) + .pipe(Effect.ignore), + ); + + const started = yield* client.oauth.start({ + payload: { + client: clientSlug, + clientOwner: "org", + owner: "org", + name: ConnectionName.make("main"), + integration: IntegrationSlug.make(slug), + template: AuthTemplateSlug.make("oauth"), + }, + }); + expect(started.status, "oauth.start redirects to the authorization server").toBe("redirect"); + if (started.status !== "redirect") return yield* Effect.die("no redirect"); + + // Drive the test IdP's consent by hand (authorize -> login -> code). + const code = yield* Effect.promise(async () => { + const authorize = await fetch(started.authorizationUrl, { redirect: "manual" }); + const loginUrl = authorize.headers.get("location"); + if (!loginUrl) throw new Error(`authorize did not redirect: ${authorize.status}`); + const login = await fetch(loginUrl, { + method: "POST", + headers: { + authorization: `Basic ${Buffer.from("alice:password").toString("base64")}`, + }, + redirect: "manual", + }); + const callbackUrl = login.headers.get("location"); + if (!callbackUrl) throw new Error(`login did not redirect: ${login.status}`); + const minted = new URL(callbackUrl).searchParams.get("code"); + if (!minted) throw new Error("callback carried no authorization code"); + return minted; + }); + yield* client.oauth.complete({ payload: { state: started.state, code } }); + yield* Effect.addFinalizer(() => + client.connections + .remove({ + params: { + owner: "org", + integration: IntegrationSlug.make(slug), + name: ConnectionName.make("main"), + }, + }) + .pipe(Effect.ignore), + ); + + const tools = yield* client.tools.list({ query: {} }); + const address = tools + .filter((tool) => String(tool.integration) === slug) + .map((tool) => String(tool.address)) + .find((addr) => addr.endsWith("listIssues")); + expect(address, "the listIssues tool is in the catalog").toBeDefined(); + + const session = mcp.session(identity); + /** Invoke the connected tool and report what came back, success or not. */ + const attempt = (): Effect.Effect => + Effect.gen(function* () { + let called = yield* session.call("execute", { + code: invokeByAddressCode(address!, {}), + }); + let guard = 0; + while (called.text.includes("executionId:") && guard < 10) { + called = yield* session.approvePaused(called.text); + guard += 1; + } + return { ok: called.ok, text: called.text }; + }); + + /** Invoke the tool and require it to have worked end to end. */ + const call = (label: string): Effect.Effect => + Effect.gen(function* () { + const result = yield* attempt(); + expect( + result.ok, + `the ${label} call reached the upstream and came back (got: ${result.text.slice(0, 400)})`, + ).toBe(true); + const envelope = JSON.parse(result.text) as ToolEnvelope; + expect(envelope.ok, `the ${label} call succeeded`).toBe(true); + return envelope; + }); + + return { attempt, call, oauth, slug, upstream, workos } as const; +}); + +/** Is this one of the authorization server's refresh grants? Counting them is + * how a "success" is shown to involve a real re-mint, not a cached token. */ +const isRefreshGrant = (request: { + readonly path: string; + readonly method: string; + readonly body: string; +}) => + request.path === "/token" && + request.method === "POST" && + request.body.includes("grant_type=refresh_token"); + +scenario( + "Credential persistence · contention on the vault write does not lose the refreshed credential", + {}, + Effect.scoped( + Effect.gen(function* () { + const { call, oauth, upstream, workos } = yield* connectIntegration; + + // Baseline: the connection works on its freshly minted token. + yield* call("baseline"); + + // Transient contention: the next few version-checked writes lose their + // race, exactly as a peer writer persisting the same connection's + // credential would make them lose it. Three is as many as the pre-fix + // policy could ever absorb. + const transient = yield* Effect.scoped( + Effect.gen(function* () { + const armed = yield* armFault(workos, { + match: VAULT_WRITE, + response: VAULT_CONFLICT_RESPONSE, + times: 3, + }); + // Revoking upstream forces the very next call to refresh, so the + // contended write happens inside a real user-visible request. + upstream.revokeSeenBearers(); + yield* call("transiently-contended-refresh"); + return yield* faultsServed(workos, armed); + }), + ); + expect(transient, "the transiently-contended refresh really did collide three times").toBe(3); + + // Sustained contention: every version-checked attempt loses. Dropping a + // credential that has just been minted is the one unrecoverable outcome, + // so the write still has to land. + const sustained = yield* Effect.scoped( + Effect.gen(function* () { + const armed = yield* armFault(workos, { + match: VAULT_WRITE, + response: VAULT_CONFLICT_RESPONSE, + times: 5, + }); + upstream.revokeSeenBearers(); + yield* call("continuously-contended-refresh"); + return yield* faultsServed(workos, armed); + }), + ); + expect( + sustained, + "the continuously-contended refresh exhausted every version-checked attempt", + ).toBe(5); + + // The durability half: the credential those contended writes were + // carrying is the rotated, single-use refresh token. If any of it had been + // dropped, the connection could never mint anything again. + upstream.revokeSeenBearers(); + yield* call("post-contention"); + + expect( + (yield* oauth.requests).filter(isRefreshGrant).length, + "each revocation was absorbed by a real refresh grant", + ).toBeGreaterThanOrEqual(3); + }), + ), +); + +// The peer writer's round trip, in milliseconds. Long enough that a retry loop +// firing its attempts back to back drains all of them (and its last-resort +// write) inside the window; short enough that a loop waiting between attempts is +// still trying when the window closes — the shipped policy's fourth and fifth +// attempts fall no earlier than 175ms and 375ms after the first collision. +const CONTENTION_WINDOW_MS = 200; + +scenario( + "Credential persistence · a refresh outlasts contention that lasts longer than a round trip", + {}, + Effect.scoped( + Effect.gen(function* () { + const { call, oauth, upstream, workos } = yield* connectIntegration; + + yield* call("baseline"); + + // Contention bounded by TIME rather than by a count of collisions: this is + // the production shape, where the peer holds the object for as long as its + // own write takes and the loser has to still be trying when it lets go. + const { armed, released } = yield* holdWritesInConflict(workos, CONTENTION_WINDOW_MS); + upstream.revokeSeenBearers(); + yield* call("refresh-under-a-contention-window"); + yield* Effect.promise(() => released); + + expect( + yield* faultsServed(workos, armed), + "the refresh really was fighting a contended vault write", + ).toBeGreaterThanOrEqual(3); + + // And the credential that survived the window is usable: the next + // revocation is absorbed by another real refresh. + upstream.revokeSeenBearers(); + yield* call("post-window"); + + expect( + (yield* oauth.requests).filter(isRefreshGrant).length, + "both revocations were absorbed by real refresh grants", + ).toBeGreaterThanOrEqual(2); + }), + ), +); + +scenario( + "Credential persistence · a write that fails mid-refresh leaves the connection able to refresh", + {}, + Effect.scoped( + Effect.gen(function* () { + const { attempt, call, oauth, slug, upstream, workos } = yield* connectIntegration; + + yield* call("baseline"); + + // A refresh persists two objects. Break the ACCESS TOKEN's object only, + // once: whichever write runs first survives and whichever runs second is + // lost, so failing this one is the direct question "which of the two does + // the product persist first?". + const objects = yield* vaultObjectsFor(workos, slug); + const refreshObject = objects.find((object) => object.name.endsWith("refresh")); + expect(refreshObject, "the connection stored a refresh token in the vault").toBeDefined(); + const accessObject = objects.find( + (object) => object !== refreshObject && refreshObject!.name.startsWith(object.name), + ); + expect(accessObject, "the connection stored an access token in the vault").toBeDefined(); + + const interrupted = yield* Effect.scoped( + Effect.gen(function* () { + const armed = yield* armFault(workos, { + match: { method: "PUT", pathPattern: `/vault/v1/kv/${accessObject!.id}` }, + response: { + status: 503, + body: { code: "unavailable", message: "vault unavailable" }, + }, + times: 1, + }); + + upstream.revokeSeenBearers(); + const result = yield* attempt(); + expect( + yield* faultsServed(workos, armed), + "the access token's write is the one that broke", + ).toBe(1); + return result; + }), + ); + expect( + interrupted.ok, + `the call whose credential write was interrupted reports the failure (got: ${interrupted.text.slice(0, 200)})`, + ).toBe(false); + + // The interrupted refresh still spent the refresh token it sent: the + // authorization server rotated it and will not honour the old one again. + // The access token in the vault is the stale, revoked one, so the very + // next call has to refresh — and can only do so if the ROTATED refresh + // token is what survived the half-finished write. + const recovered = yield* attempt(); + expect( + recovered.ok, + `the connection still refreshes after the interrupted write (got: ${recovered.text.slice(0, 400)})`, + ).toBe(true); + expect((JSON.parse(recovered.text) as ToolEnvelope).ok, "the recovered call succeeded").toBe( + true, + ); + + expect( + (yield* oauth.requests).filter(isRefreshGrant).length, + "the interrupted refresh and the recovery were both real refresh grants", + ).toBeGreaterThanOrEqual(2); + }), + ), +); diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index e2e806facf..1303072098 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -1862,11 +1862,20 @@ export const createExecutor = { }), ); - it.effect("retries value writes on 409 version conflicts", () => + // `it.live` (real clock): a conflicted write now waits before retrying, and + // the TestClock never advances that wait on its own. + it.live("retries value writes on 409 version conflicts", () => Effect.gen(function* () { const provider = makeProvider(makeFakeClient({ conflictOnNextSecretUpdate: true })); diff --git a/packages/plugins/workos-vault/src/sdk/secret-store.ts b/packages/plugins/workos-vault/src/sdk/secret-store.ts index 97341056e8..4d8c6e6b66 100644 --- a/packages/plugins/workos-vault/src/sdk/secret-store.ts +++ b/packages/plugins/workos-vault/src/sdk/secret-store.ts @@ -22,7 +22,14 @@ import { export const WORKOS_VAULT_PROVIDER_KEY = ProviderKey.make("workos-vault"); const DEFAULT_OBJECT_PREFIX = "executor"; -const MAX_WRITE_ATTEMPTS = 3; +// Version-checked write attempts before the last-resort un-versioned write. +// Contention here is a peer writer persisting the SAME credential (two +// concurrent refreshes of one connection), so retries must be spaced: a tight +// loop re-reads and re-writes inside the peer's own round trip and collides +// again deterministically. +const MAX_WRITE_ATTEMPTS = 5; +const WRITE_CONFLICT_BACKOFF_BASE_MS = 50; +const WRITE_CONFLICT_BACKOFF_CAP_MS = 800; // WorkOS creates a per-context KEK just-in-time on first write; a create // call immediately after that provisioning step can race with the KEK // becoming usable and return a transient error whose message ends in @@ -243,34 +250,98 @@ const loadSecretObject = ( }), ); +/** Half-jittered exponential backoff for attempt `n` (1-based): a wait drawn + * from [d/2, d) where d = base * 2^(n-1), capped. The jitter matters more than + * the delay — two writers that back off by the same amount simply collide + * again one beat later. */ +const writeConflictBackoffMillis = (attempt: number): number => { + const ceiling = Math.min( + WRITE_CONFLICT_BACKOFF_BASE_MS * 2 ** (attempt - 1), + WRITE_CONFLICT_BACKOFF_CAP_MS, + ); + return ceiling / 2 + Math.random() * (ceiling / 2); +}; + +/** A version-checked write either landed or lost the race to a peer writer. */ +type WriteOutcome = "written" | "contended"; + +/** Did this update failure mean "someone else wrote first"? 409 is the + * documented answer. WorkOS also answers 400 when the version we checked + * against is several generations behind, and an update carries no name and no + * context — the only inputs are the id, the value, and the version — so a 400 + * here is contention too, not a malformed request. If it really were + * malformed, the un-versioned last-resort write fails the same way and the + * error still surfaces. */ +const isWriteContention = (error: WorkOSVaultClientError): boolean => + isStatusError(error, 409) || isStatusError(error, 400); + const upsertSecretValue = ( client: WorkOSVaultClient, name: string, value: string, context: Record, ): Effect.Effect => { - const attemptWrite = ( - remainingConflictAttempts: number, - remainingKekAttempts: number, - ): Effect.Effect => + const attemptOnce = (): Effect.Effect => Effect.gen(function* () { const existing = yield* loadSecretObject(client, name); if (existing) { - yield* client.updateObject({ - id: existing.id, - value, - versionCheck: existing.metadata.versionId, - }); + return yield* client + .updateObject({ id: existing.id, value, versionCheck: existing.metadata.versionId }) + .pipe( + Effect.as("written"), + Effect.catch((error: WorkOSVaultClientError) => + isWriteContention(error) + ? Effect.succeed("contended") + : Effect.fail(error), + ), + ); + } + + return yield* client.createObject({ name, value, context }).pipe( + Effect.as("written"), + // A peer created the object between our read and our create; the next + // attempt re-reads it and takes the update path. + Effect.catch((error: WorkOSVaultClientError) => + isStatusError(error, 409) + ? Effect.succeed("contended") + : Effect.fail(error), + ), + ); + }); + + /** Last resort once every version-checked attempt lost: write without a + * version check. Both racers are persisting a credential they just minted, + * so last-writer-wins keeps a usable value; failing instead discards a + * freshly minted, often single-use credential, which is the one outcome + * that leaves a connection with nothing it can use. */ + const blindWrite = (): Effect.Effect => + Effect.gen(function* () { + const existing = yield* loadSecretObject(client, name); + if (!existing) { + yield* client.createObject({ name, value, context }); return; } + console.warn( + `[workos-vault] version-checked write for object=${name} lost ` + + `${MAX_WRITE_ATTEMPTS} races; writing without a version check`, + ); + yield* client.updateObject({ id: existing.id, value }); + }); - yield* client.createObject({ name, value, context }); - }).pipe( + const attemptWrite = ( + attempt: number, + remainingKekAttempts: number, + ): Effect.Effect => + attemptOnce().pipe( + Effect.flatMap((outcome: WriteOutcome) => { + if (outcome === "written") return Effect.void; + if (attempt >= MAX_WRITE_ATTEMPTS) return blindWrite(); + return Effect.sleep(writeConflictBackoffMillis(attempt)).pipe( + Effect.flatMap(() => attemptWrite(attempt + 1, remainingKekAttempts)), + ); + }), Effect.catch((error: WorkOSVaultClientError) => { - if (remainingConflictAttempts > 1 && isStatusError(error, 409)) { - return attemptWrite(remainingConflictAttempts - 1, remainingKekAttempts); - } if (remainingKekAttempts > 1 && isKekNotReadyError(error)) { console.warn( `[workos-vault] KEK not ready for object=${name} — ` + @@ -278,7 +349,7 @@ const upsertSecretValue = ( `(${MAX_KEK_NOT_READY_ATTEMPTS - remainingKekAttempts + 1}/${MAX_KEK_NOT_READY_ATTEMPTS})`, ); return Effect.sleep(KEK_NOT_READY_BACKOFF_MS).pipe( - Effect.flatMap(() => attemptWrite(remainingConflictAttempts, remainingKekAttempts - 1)), + Effect.flatMap(() => attemptWrite(attempt, remainingKekAttempts - 1)), ); } if (isKekNotReadyError(error)) { @@ -291,7 +362,7 @@ const upsertSecretValue = ( }), ); - return attemptWrite(MAX_WRITE_ATTEMPTS, MAX_KEK_NOT_READY_ATTEMPTS); + return attemptWrite(1, MAX_KEK_NOT_READY_ATTEMPTS); }; const deleteSecretValue = (