Skip to content

Commit e8500cd

Browse files
committed
Meter MCP executions to Autumn
The cloud MCP session Durable Object built its execution engine with the no-op decorator, so only the HTTP /api executor plane reported executions to Autumn. The MCP server is the primary execution surface, so the bulk of real usage never reached the meter (the dashboard undercounted badly). Build the DO's engine with the metered stack, providing AutumnService locally so the DO bundle stays free of the HTTP API assembly, and degrading to a no-op tracker when AUTUMN_SECRET_KEY is unset. Both cloud planes now meter, so the unused neutral CloudExecutionStackLayer is removed. Add a cloud e2e scenario that drives a real MCP client over StreamableHTTP and asserts the Autumn ledger records exactly one executions event per run, with a new Autumn surface that reads the emulator's usage ledger.
1 parent ae87f71 commit e8500cd

10 files changed

Lines changed: 260 additions & 56 deletions

File tree

apps/cloud/src/api/protected.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,11 @@ export {
5050
// - `cloudIdentityFailureStrategy` -> renders the shared identity errors as
5151
// cloud's exact `{ error, code }` JSON at
5252
// status 401/403/503 (byte-identical).
53-
// - `cloudPlugins` + `CloudMeteredExecutionStackLayer` — the executor plane is
54-
// the ONLY path that meters, so billing lives
55-
// here (not in the neutral stack the DO shares).
53+
// - `cloudPlugins` + `CloudMeteredExecutionStackLayer` — the HTTP executor
54+
// plane meters here; the MCP session DO uses
55+
// the same metered stack (with its own
56+
// `AutumnService.Default`), so both planes
57+
// bill executions.
5658
//
5759
// Only `AutumnService` is captured at boot; `IdentityProvider` + `DbService` +
5860
// `UserStoreService` stay residual and are supplied per request by the combined

apps/cloud/src/engine/execution-stack-metered.ts

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
// ---------------------------------------------------------------------------
2-
// Metered execution stack — the HTTP executor plane's billing overlay.
2+
// Metered execution stack: cloud's billing overlay over the execution seams.
33
//
4-
// Cloud is the only host that meters executions, and only the HTTP `/api/*`
5-
// executor plane does so (the MCP session DO never bills). This module is where
6-
// the billing decorator binds to the neutral `CloudExecutionStackLayer`: it
7-
// overrides the base stack's no-op `EngineDecorator` with one that calls
8-
// `AutumnService.trackExecution` after each execution.
4+
// Cloud is the only host that meters executions, and BOTH of its execution
5+
// planes do so: the HTTP `/api/*` executor plane (api/protected.ts) and the MCP
6+
// session Durable Object (mcp/session-durable-object.ts). This module composes
7+
// the four billing-free `CloudExecutionSeamsLayer` seams with an `EngineDecorator`
8+
// that calls `AutumnService.trackExecution` after each execution.
99
//
1010
// Keeping this in the cloud APP layer (not the neutral `engine/execution-stack.ts`)
11-
// is the billing-boundary line: the neutral stack the DO shares names no billing
12-
// service; the metered overlay — provided ONLY here — does.
11+
// is the billing-boundary line: the seams module names no billing service; the
12+
// metered overlay, provided ONLY here, does. Both planes import THIS layer and
13+
// supply `AutumnService` from their own context (boot for the HTTP plane,
14+
// `AutumnService.Default` locally for the DO).
1315
// ---------------------------------------------------------------------------
1416

1517
import { Effect, Layer } from "effect";
@@ -42,10 +44,10 @@ export const CloudMeteringEngineDecorator: Layer.Layer<EngineDecorator, never, A
4244
);
4345

4446
/**
45-
* The execution-stack seams for the metered HTTP executor plane: the four
46-
* billing-free `CloudExecutionSeamsLayer` seams plus the billing decorator.
47-
* Requires `DbService` (per-request Hyperdrive db) and `AutumnService` (usage
48-
* metering) from the surrounding context.
47+
* The metered execution stack used by BOTH cloud planes (HTTP executor plane and
48+
* MCP session DO): the four billing-free `CloudExecutionSeamsLayer` seams plus
49+
* the billing decorator. Requires `DbService` (per-request Hyperdrive db) and
50+
* `AutumnService` (usage metering) from the surrounding context.
4951
*/
5052
export const CloudMeteredExecutionStackLayer: Layer.Layer<
5153
DbProvider | PluginsProvider | HostConfig | CodeExecutorProvider | EngineDecorator,

apps/cloud/src/engine/execution-stack.ts

Lines changed: 13 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,14 @@
2222
// a test flag. `webBaseUrl` is `VITE_PUBLIC_SITE_URL ??
2323
// executor.sh`.
2424
// - CodeExecutorProvider -> `makeDynamicWorkerExecutor({ loader: env.LOADER })`.
25-
// - EngineDecorator -> the BASE stack uses the no-op decorator (the MCP
26-
// session DO never meters); the METERED stack (HTTP
27-
// executor plane only) overrides it with the billing
28-
// decorator (`CloudMeteredExecutionStackLayer`,
29-
// ../engine/execution-stack-metered.ts). Billing lives in
30-
// the cloud app, not this neutral stack.
25+
// - EngineDecorator -> the billing decorator that meters each execution
26+
// to Autumn. BOTH cloud execution planes (the HTTP
27+
// `/api/*` executor plane AND the MCP session DO) use
28+
// the metered stack (`CloudMeteredExecutionStackLayer`,
29+
// ../engine/execution-stack-metered.ts), since the MCP
30+
// server is the primary execution surface. Billing
31+
// still lives in the cloud app, not this neutral
32+
// seams module; the decorator is composed on top.
3133
// ---------------------------------------------------------------------------
3234

3335
import { env } from "cloudflare:workers";
@@ -36,8 +38,6 @@ import { Layer } from "effect";
3638
import {
3739
CodeExecutorProvider,
3840
DbProvider,
39-
EngineDecorator,
40-
EngineDecoratorNoop,
4141
HostConfig,
4242
PluginsProvider,
4343
collectTables,
@@ -98,11 +98,11 @@ export const CloudCodeExecutorProvider: Layer.Layer<CodeExecutorProvider> = Laye
9898

9999
/**
100100
* The four billing-free execution-stack seams (db / plugins / host-config /
101-
* code-executor) everything `makeExecutionStack` reads EXCEPT the
102-
* `EngineDecorator`. The metered HTTP plane composes this with the billing
103-
* decorator (../engine/execution-stack-metered.ts); the neutral stack below adds
104-
* the no-op decorator. Exported so the metered overlay builds over the SAME four
105-
* seams rather than relying on a layer override.
101+
* code-executor): everything `makeExecutionStack` reads EXCEPT the
102+
* `EngineDecorator`. Both cloud planes compose this with the billing decorator
103+
* via `CloudMeteredExecutionStackLayer` (../engine/execution-stack-metered.ts);
104+
* exported so that overlay builds over the SAME four seams. There is no neutral
105+
* no-op-decorator variant anymore: every cloud execution meters.
106106
*/
107107
export const CloudExecutionSeamsLayer: Layer.Layer<
108108
DbProvider | PluginsProvider | HostConfig | CodeExecutorProvider,
@@ -114,20 +114,3 @@ export const CloudExecutionSeamsLayer: Layer.Layer<
114114
CloudHostConfig,
115115
CloudCodeExecutorProvider,
116116
);
117-
118-
/**
119-
* The five execution-stack seams the shared `makeExecutionStack` reads from,
120-
* with the NO-OP engine decorator. This is the neutral stack: it requires only
121-
* `DbService` (per-request Hyperdrive db) and carries NO billing dependency, so
122-
* the MCP session DO — which never meters — can build an engine without dragging
123-
* in any billing service.
124-
*
125-
* The HTTP executor plane (the only path that meters) uses
126-
* `CloudMeteredExecutionStackLayer` (../engine/execution-stack-metered.ts), which
127-
* swaps the no-op decorator for the billing one.
128-
*/
129-
export const CloudExecutionStackLayer: Layer.Layer<
130-
DbProvider | PluginsProvider | HostConfig | CodeExecutorProvider | EngineDecorator,
131-
never,
132-
DbService
133-
> = Layer.merge(CloudExecutionSeamsLayer, EngineDecoratorNoop);

apps/cloud/src/mcp/session-durable-object.ts

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,17 @@ import {
3232
} from "@executor-js/cloudflare/mcp/durable-object";
3333
import { buildExecuteDescription } from "@executor-js/execution";
3434

35-
// The DO only needs the neutral boot-scoped service (WorkOSClient). It never
36-
// bills, so it does NOT depend on any billing service — `CloudExecutionStackLayer`
37-
// here is the no-op-decorator (Autumn-free) stack. It imports the focused
38-
// `CoreSharedServices` root (beside `WorkOSClient`), NOT `../api/layers`, so the
39-
// DO bundle stays small and free of the whole HTTP API assembly. (This used to
40-
// require a dedicated `core-shared-services.ts` leaf to keep `auth/handlers.ts` →
41-
// `@tanstack/react-start` out of the DO bundle; that coupling is gone now that
42-
// `handlers.ts` queues cookies through `SessionAuthLive` instead.)
35+
// The DO meters executions just like the HTTP `/api/*` plane: it builds its
36+
// engine with `CloudMeteredExecutionStackLayer`, so every MCP execution is
37+
// tracked to Autumn (the MCP server is the primary execution surface, so leaving
38+
// it unmetered silently dropped the bulk of real usage). The billing service
39+
// (`AutumnService.Default`) is provided LOCALLY to the metered stack below, so
40+
// the DO still imports the focused `CoreSharedServices` root (beside
41+
// `WorkOSClient`), NOT `../api/layers`, and its bundle stays free of the whole
42+
// HTTP API assembly. (This used to require a dedicated `core-shared-services.ts`
43+
// leaf to keep `auth/handlers.ts` -> `@tanstack/react-start` out of the DO
44+
// bundle; that coupling is gone now that `handlers.ts` queues cookies through
45+
// `SessionAuthLive` instead.)
4346
import { CoreSharedServices } from "../auth/workos";
4447
import { UserStoreService } from "../auth/context";
4548
import { resolveOrganization } from "../auth/organization";
@@ -50,7 +53,9 @@ import {
5053
type DrizzleDb,
5154
type DbServiceShape,
5255
} from "../db/db";
53-
import { CloudExecutionStackLayer, makeExecutionStack } from "../engine/execution-stack";
56+
import { makeExecutionStack } from "../engine/execution-stack";
57+
import { CloudMeteredExecutionStackLayer } from "../engine/execution-stack-metered";
58+
import { AutumnService } from "../extensions/billing/service";
5459
import { DoTelemetryLive, flushTracerProvider } from "../observability/telemetry";
5560
import { captureCause as reportCause } from "../observability";
5661

@@ -196,7 +201,15 @@ export class McpSessionDO extends McpSessionDOBase<CloudSessionDbHandle> {
196201
sessionMeta.organizationName,
197202
{ mcpResource: sessionMeta.resource },
198203
).pipe(
199-
Effect.provide(CloudExecutionStackLayer),
204+
// The metered stack tracks each execution to Autumn. It requires
205+
// `AutumnService | DbService`; `AutumnService.Default` is provided here
206+
// (it only reads `env`, no further deps), and `DbService` flows from the
207+
// outer `makeSessionServices`. When `AUTUMN_SECRET_KEY` is unset the
208+
// billing service degrades to a no-op tracker, so this stays inert in
209+
// cloud dev/preview environments that run without a billing backend.
210+
Effect.provide(
211+
CloudMeteredExecutionStackLayer.pipe(Layer.provide(AutumnService.Default)),
212+
),
200213
Effect.withSpan("McpSessionDO.makeExecutionStack"),
201214
);
202215
// Build the description here so `executor.connections.list()` stays under
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Cloud-only (billing): every code execution run through the MCP session
2+
// Durable Object is metered to Autumn, exactly like the HTTP executor plane.
3+
// The MCP server is the PRIMARY execution surface (Claude/Cursor run code here,
4+
// not over /api), so if the DO doesn't bill, the bulk of real usage silently
5+
// never reaches the meter — the regression this pins.
6+
//
7+
// Black-box and end-to-end: drive a real @modelcontextprotocol/sdk client over
8+
// StreamableHTTP (the exact transport an MCP client uses) against the production
9+
// workerd + McpSessionDO topology, then read the usage the server ACTUALLY
10+
// tracked from the Autumn ledger. The execution's own response can't prove it
11+
// was billed — metering is fire-and-forget, decoupled from the user-facing
12+
// result — so only the meter is the source of truth.
13+
import { expect } from "@effect/vitest";
14+
import { Effect } from "effect";
15+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
16+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
17+
18+
import { scenario } from "../src/scenario";
19+
import { Autumn, Billing, Mcp, Target } from "../src/services";
20+
import type { Identity } from "../src/target";
21+
22+
const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label;
23+
24+
/** The org the bearer is scoped to — the Autumn customer id `trackExecution`
25+
* meters against — read from the JWT's public claims. */
26+
const orgIdOf = (bearer: string): string => {
27+
const claims = JSON.parse(Buffer.from(bearer.split(".")[1] ?? "", "base64url").toString()) as {
28+
readonly org_id?: string;
29+
};
30+
if (!claims.org_id) throw new Error("orgIdOf: bearer carries no org_id claim");
31+
return claims.org_id;
32+
};
33+
34+
const textOf = (result: { content?: unknown; toolResult?: unknown }): string =>
35+
((result.content ?? []) as Array<{ type: string; text?: string }>)
36+
.filter((part) => part.type === "text")
37+
.map((part) => part.text)
38+
.join("\n");
39+
40+
const RUNS = 3;
41+
42+
scenario(
43+
"Billing · every MCP execution is metered to Autumn, one unit per run",
44+
{ timeout: 180_000 },
45+
Effect.gen(function* () {
46+
// Gates: billing is enforced here AND the Autumn ledger is observable
47+
// (the suite booted the emulator). Yield before any work so a target
48+
// missing either capability skips cleanly.
49+
yield* Billing;
50+
const autumn = yield* Autumn;
51+
const target = yield* Target;
52+
const mcp = yield* Mcp;
53+
54+
const identity = yield* target.newIdentity();
55+
const bearer = yield* mcp.mintBearer(emailOf(identity));
56+
const customerId = orgIdOf(bearer);
57+
58+
// A fresh org has metered nothing yet — the baseline the run is measured
59+
// against, and a guard that we're reading this customer's ledger in
60+
// isolation from every other scenario's executions.
61+
const before = yield* autumn.usageEvents({ customerId, featureId: "executions" });
62+
expect(before.length, "a brand-new org starts with zero metered executions").toBe(0);
63+
64+
// A real MCP client over StreamableHTTP — the production code path.
65+
const client = new Client(
66+
{ name: "executor-e2e-metering", version: "0.0.1" },
67+
{ capabilities: {} },
68+
);
69+
const transport = new StreamableHTTPClientTransport(new URL(target.mcpUrl), {
70+
requestInit: { headers: { authorization: `Bearer ${bearer}` } },
71+
});
72+
const connected = yield* Effect.promise(() => client.connect(transport).then(() => client));
73+
74+
yield* Effect.gen(function* () {
75+
// Run a handful of executions; each is one billable unit.
76+
for (let i = 1; i <= RUNS; i++) {
77+
const result = yield* Effect.promise(() =>
78+
connected.callTool({ name: "execute", arguments: { code: `return ${i} * 2;` } }),
79+
);
80+
expect(result.isError, `execution ${i} succeeds`).not.toBe(true);
81+
expect(textOf(result), `execution ${i} returns its value`).toContain(String(i * 2));
82+
}
83+
84+
// The meter is the source of truth. Tracking is fire-and-forget, so poll
85+
// the ledger until all runs have landed.
86+
const after = yield* autumn.expectUsage({
87+
customerId,
88+
featureId: "executions",
89+
count: RUNS,
90+
});
91+
92+
expect(
93+
after.length,
94+
"exactly one 'executions' usage event per run — no over- or under-counting",
95+
).toBe(RUNS);
96+
expect(
97+
after.every((event) => event.value === 1),
98+
"each run meters a single unit",
99+
).toBe(true);
100+
}).pipe(Effect.ensuring(Effect.promise(() => connected.close().catch(() => undefined))));
101+
}),
102+
);

e2e/setup/cloud.globalsetup.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ export default async function setup(): Promise<(() => Promise<void>) | void> {
5050
// slow" gets a span waterfall, not a guess.
5151
extraEnv: motelExporterEnv(motel, publicUrl),
5252
});
53+
// Publish the Autumn emulator URL to the test workers (they inherit this
54+
// process's env): scenarios that assert on tracked usage yield the Autumn
55+
// service, which exists only when this is set. No emulator → those scenarios
56+
// skip, never fail. (Cloud-only; selfhost never boots Autumn.)
57+
process.env.E2E_AUTUMN_URL = booted.autumnUrl;
5358
} catch (error) {
5459
await motel?.teardown();
5560
await release();

e2e/src/scenario.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,15 @@ import { FetchHttpClient, type HttpClient } from "effect/unstable/http";
2121
import type { Target as TargetShape } from "./target";
2222
import { resolveTarget } from "../targets/registry";
2323
import { makeApiSurface } from "./surfaces/api";
24+
import { makeAutumnSurface } from "./surfaces/autumn";
2425
import { makeBrowserSurface } from "./surfaces/browser";
2526
import { makeCliSurface } from "./surfaces/cli";
2627
import { makeMcpSurface } from "./surfaces/mcp";
2728
import { makeTelemetrySurface } from "./surfaces/telemetry";
2829
import { completeOAuthConsent, hasOpenCode, makeOpenCodeHome, warmUp } from "./clients/opencode";
2930
import {
3031
Api,
32+
Autumn,
3133
Billing,
3234
Browser,
3335
Cli,
@@ -62,6 +64,7 @@ type AllServices =
6264
| Browser
6365
| Mcp
6466
| Billing
67+
| Autumn
6568
| OpenCode
6669
| TtlControl
6770
| Restart
@@ -100,6 +103,9 @@ const contextFor = (target: TargetShape, dir: string): Context.Context<AllServic
100103
if (process.env.E2E_MOTEL_URL) {
101104
context = Context.add(context, Telemetry, makeTelemetrySurface(process.env.E2E_MOTEL_URL));
102105
}
106+
if (process.env.E2E_AUTUMN_URL) {
107+
context = Context.add(context, Autumn, makeAutumnSurface(process.env.E2E_AUTUMN_URL));
108+
}
103109
return context;
104110
};
105111

e2e/src/services.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Context, type Effect } from "effect";
88

99
import type { Target as TargetShape } from "./target";
1010
import type { ApiSurface } from "./surfaces/api";
11+
import type { AutumnSurface } from "./surfaces/autumn";
1112
import type { BrowserSurface } from "./surfaces/browser";
1213
import type { CliSurface } from "./surfaces/cli";
1314
import type { McpSurface } from "./surfaces/mcp";
@@ -35,6 +36,10 @@ export class Mcp extends Context.Service<Mcp, McpSurface>()("e2e/mcp-oauth") {}
3536
/** Marker: billing limits are enforced on this target. */
3637
export class Billing extends Context.Service<Billing, true>()("e2e/billing") {}
3738

39+
/** Read the usage events the target tracked to its billing backend (present
40+
* when the suite booted the Autumn emulator — E2E_AUTUMN_URL; cloud-only). */
41+
export class Autumn extends Context.Service<Autumn, AutumnSurface>()("e2e/autumn") {}
42+
3843
/** Query the suite's OTLP trace store for spans the target actually exported
3944
* (present when the suite booted motel — E2E_MOTEL_URL). */
4045
export class Telemetry extends Context.Service<Telemetry, TelemetrySurface>()("e2e/telemetry") {}

0 commit comments

Comments
 (0)