feat: implement Cloudflare Worker for MCP endpoint with runtime configuration and Redis abstraction

This commit is contained in:
2026-07-14 01:24:17 -07:00
parent c9d30ac198
commit 488ad6b3fd
9 changed files with 703 additions and 77 deletions

View File

@@ -0,0 +1,523 @@
# MCP Cloudflare Worker Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Deploy the Autumn MCP endpoint currently served through `apps/leaf` as a dedicated Cloudflare Worker while preserving the existing `/mcp` and OAuth protected-resource contract.
**Architecture:** Add a new `apps/mcp-worker` Worker that imports `@autumn/mcp`, exposes `GET /.well-known/oauth-protected-resource/mcp`, `ALL /mcp`, and `GET /health`, and calls the Autumn API through fetch. Keep `server` as the public proxy/control plane initially, then switch `MCP_SERVER_URL` / `CHAT_SERVER_URL` to the Worker once live smoke tests pass.
**Tech Stack:** Cloudflare Workers, Wrangler JSONC, Hono, Mastra MCP, `@modelcontextprotocol/sdk` Streamable HTTP, Bun workspace scripts, Autumn OAuth issuer from `server`.
## Global Constraints
- Do not migrate all of `apps/leaf`; the Worker scope is only hosted MCP.
- Keep `packages/mcp` usable by `apps/leaf` tests and evals.
- Do not hardcode secrets in `wrangler.jsonc`; use `wrangler secret put`.
- Generate Worker env types with Wrangler instead of hand-writing binding types.
- Use Cloudflare-compatible crypto and schema validation; avoid `node:fs`, `node:http`, `ioredis`, and `process.env` in Worker request paths.
- Keep `server/src/routers/chatProxyRouter.ts` behavior intact until the new Worker endpoint is live.
- Validate locally, dry-run bundle, then deploy and smoke test the real `/mcp` endpoint.
---
## File Structure
- Create `apps/mcp-worker/package.json`: Worker package scripts and dependencies.
- Create `apps/mcp-worker/wrangler.jsonc`: Worker name, entrypoint, compatibility date, `nodejs_compat`, non-secret vars, observability, optional custom route.
- Create `apps/mcp-worker/src/index.ts`: Worker `fetch` entry, Hono app, route registration.
- Create `apps/mcp-worker/src/env.ts`: parse Cloudflare bindings and vars from `env`, not `process.env`.
- Create `apps/mcp-worker/src/auth.ts`: Worker-native copy/extraction of the current MCP auth flow from `apps/leaf/src/mcp/auth`.
- Create `apps/mcp-worker/src/protectedResourceMetadata.ts`: Worker route for OAuth protected resource metadata.
- Create `apps/mcp-worker/src/mcpHandler.ts`: Worker-native HTTP handler for `/mcp`.
- Create `apps/mcp-worker/tests/mcp-worker.test.ts`: Worker route/auth smoke tests using `app.request` or `unstable_dev`.
- Modify `package.json`: add workspace `apps/mcp-worker`; add root scripts such as `mcp:cf:dev`, `mcp:cf:check`, `mcp:cf:deploy`.
- Modify `packages/mcp/src/server/server.ts`: accept optional Cloudflare JSON schema validator and resource/instruction inputs.
- Modify `packages/mcp/src/resources-v2/index.ts` and `packages/mcp/src/resources-v2/mcpInstructions.ts`: replace Worker runtime file reads with injectable/generated static text.
- Modify `packages/mcp/src/agent/pending-actions.ts`: abstract pending-action storage so Worker can use KV or Durable Object instead of Redis.
- Modify `packages/mcp/src/analytics/analyticsSink.ts`, `packages/mcp/src/agent/axiom.ts`, and related utilities: replace direct `process.env` reads with injectable config.
- Modify `server/wrangler.jsonc` only after Worker deploy: set `CHAT_SERVER_URL` and `MCP_SERVER_URL` to the deployed Worker origin, or route `/mcp` directly to the Worker custom domain.
## Task 1: Worker Runtime Audit Gate
**Files:**
- Read: `packages/mcp/src/**`
- Read: `apps/leaf/src/mcp/**`
- Create: `apps/mcp-worker/tests/runtime-compatibility.test.ts`
**Interfaces:**
- Produces: a test that fails while Worker-incompatible imports remain in the Worker bundle.
- [ ] **Step 1: Add a static compatibility test**
```ts
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
const workerReachableFiles = [
"packages/mcp/src/server/server.ts",
"packages/mcp/src/resources-v2/index.ts",
"packages/mcp/src/resources-v2/mcpInstructions.ts",
"packages/mcp/src/agent/pending-actions.ts",
"packages/mcp/src/analytics/analyticsSink.ts",
"packages/mcp/src/agent/axiom.ts",
];
describe("mcp Worker runtime compatibility", () => {
test("Worker bundle does not depend on Node-only runtime APIs", () => {
const forbidden = [/from "node:fs"/, /from "node:http"/, /from "ioredis"/, /process\.env/];
const offenders = workerReachableFiles.flatMap((file) => {
const text = readFileSync(join(process.cwd(), file), "utf8");
return forbidden
.filter((pattern) => pattern.test(text))
.map((pattern) => `${file}: ${pattern}`);
});
expect(offenders).toEqual([]);
});
});
```
- [ ] **Step 2: Run the failing gate**
Run: `cd /Volumes/sker/resources/coding/comi-logic/autumn && bun test apps/mcp-worker/tests/runtime-compatibility.test.ts`
Expected: FAIL before compatibility refactors, naming the current `node:fs`, `ioredis`, and `process.env` offenders.
- [ ] **Step 3: Commit after the test exists**
```bash
git add apps/mcp-worker/tests/runtime-compatibility.test.ts
git commit -m "test: add mcp worker runtime compatibility gate"
```
## Task 2: Make `@autumn/mcp` Configurable For Workers
**Files:**
- Modify: `packages/mcp/src/server/server.ts`
- Modify: `packages/mcp/src/resources-v2/index.ts`
- Modify: `packages/mcp/src/resources-v2/mcpInstructions.ts`
- Modify: `packages/mcp/src/analytics/analyticsSink.ts`
- Modify: `packages/mcp/src/agent/axiom.ts`
- Test: `packages/mcp/tests/unit/mcp-server/agent/server.test.ts`
**Interfaces:**
- Produces: `createAutumnOperationsMCPServer(options?: AutumnMcpServerOptions)`.
- Produces: `AutumnMcpRuntimeConfig` with `axiomToken`, `axiomOrgId`, `analyticsDataset`, `nodeEnv`, and `pendingActionStore`.
- [ ] **Step 1: Introduce options**
Add an exported type:
```ts
export type AutumnMcpServerOptions = {
resources?: MCPServerResources;
instructions?: string;
jsonSchemaValidator?: ConstructorParameters<typeof MCPServer>[0]["jsonSchemaValidator"];
runtime?: AutumnMcpRuntimeConfig;
};
```
Update `createAutumnOperationsMCPServer` to pass:
```ts
jsonSchemaValidator: options?.jsonSchemaValidator,
instructions: options?.instructions ?? autumnMcpInstructions,
resources: options?.resources ?? autumnMcpResources,
```
- [ ] **Step 2: Replace direct runtime env reads**
Move `process.env.*` reads behind a runtime config getter. For Node callers, default from `process.env`; for Worker callers, pass the config from `apps/mcp-worker/src/env.ts`.
- [ ] **Step 3: Keep Node resource behavior**
Keep the current disk-based development behavior for `apps/leaf`, but add a second export:
```ts
export const createStaticAutumnMcpResources = (docs: AutumnMcpResourceDoc[]): MCPServerResources => ({
listResources: async () => docs.map((doc) => ({
uri: doc.uri,
name: doc.name,
title: doc.title,
description: doc.description,
mimeType: "text/markdown",
size: doc.text.length,
annotations: { audience: doc.audience, priority: doc.priority },
})),
getResourceContent: async ({ uri }) => {
const doc = docs.find((entry) => entry.uri === uri);
if (!doc) throw new Error(`Unknown Autumn MCP resource: ${uri}`);
return { text: doc.text };
},
});
```
- [ ] **Step 4: Run package tests**
Run: `cd /Volumes/sker/resources/coding/comi-logic/autumn && bun -F @autumn/mcp test`
Expected: PASS.
- [ ] **Step 5: Re-run compatibility gate**
Run: `cd /Volumes/sker/resources/coding/comi-logic/autumn && bun test apps/mcp-worker/tests/runtime-compatibility.test.ts`
Expected: PASS after Worker-incompatible imports are isolated from the Worker path.
## Task 3: Generate Static MCP Resource Bundle
**Files:**
- Create: `packages/mcp/scripts/build-worker-resources.ts`
- Create: `packages/mcp/src/resources-v2/generated/workerResources.ts`
- Modify: `packages/mcp/package.json`
**Interfaces:**
- Produces: `workerAutumnMcpInstructions: string`.
- Produces: `workerAutumnMcpResourceDocs: AutumnMcpResourceDoc[]`.
- [ ] **Step 1: Add generator**
The generator imports existing compile helpers in Bun/Node, reads markdown, and writes a TypeScript module that exports plain strings/objects.
- [ ] **Step 2: Add script**
In `packages/mcp/package.json`:
```json
"build:worker-resources": "bun scripts/build-worker-resources.ts"
```
- [ ] **Step 3: Validate generated file**
Run: `cd /Volumes/sker/resources/coding/comi-logic/autumn && bun -F @autumn/mcp build:worker-resources && bun -F @autumn/mcp ts`
Expected: generated file compiles and contains no `node:fs` import.
## Task 4: Add Dedicated Cloudflare Worker App
**Files:**
- Create: `apps/mcp-worker/package.json`
- Create: `apps/mcp-worker/wrangler.jsonc`
- Create: `apps/mcp-worker/src/index.ts`
- Create: `apps/mcp-worker/src/env.ts`
- Modify: `package.json`
**Interfaces:**
- Produces: Worker routes `GET /health`, `GET /.well-known/oauth-protected-resource/mcp`, and `ALL /mcp`.
- Consumes: `createAutumnOperationsMCPServer`, `createStaticAutumnMcpResources`, `workerAutumnMcpResourceDocs`, `workerAutumnMcpInstructions`.
- [ ] **Step 1: Add package scripts**
Use scripts:
```json
{
"name": "@autumn/mcp-worker",
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"types": "wrangler types",
"check": "bun run types && tsc --noEmit",
"test": "bun test tests"
}
}
```
- [ ] **Step 2: Add Worker config**
`wrangler.jsonc` should include:
```jsonc
{
"name": "autumn-mcp",
"main": "src/index.ts",
"compatibility_date": "2026-07-14",
"compatibility_flags": ["nodejs_compat"],
"observability": { "enabled": true, "head_sampling_rate": 1 },
"vars": {
"BETTER_AUTH_URL": "https://api.useautumn.com",
"MCP_SERVER_URL": "https://mcp.useautumn.com",
"MCP_OAUTH_ENVIRONMENT": "sandbox",
"AUTUMN_API_URL": "https://api.useautumn.com"
}
}
```
Secrets to set with Wrangler: `AXIOM_TOKEN`, `AXIOM_ORG_ID`, and any future secret-only MCP config.
- [ ] **Step 3: Add Worker entry**
`src/index.ts` creates a Hono app and exports:
```ts
export default {
fetch: app.fetch,
} satisfies ExportedHandler<Env>;
```
- [ ] **Step 4: Register workspace**
Add `apps/mcp-worker` to root `package.json` workspaces and root scripts:
```json
"mcp:cf:dev": "bun -F @autumn/mcp-worker dev",
"mcp:cf:check": "bun -F @autumn/mcp-worker check",
"mcp:cf:deploy": "bun -F @autumn/mcp-worker deploy"
```
## Task 5: Port Auth And Protected Resource Metadata
**Files:**
- Create: `apps/mcp-worker/src/auth.ts`
- Create: `apps/mcp-worker/src/protectedResourceMetadata.ts`
- Test: `apps/mcp-worker/tests/auth.test.ts`
**Interfaces:**
- Produces: `buildAuthForWorkerRequest(request, env): Promise<AutumnMcpAuth>`.
- Produces: `getWorkerProtectedResourceMetadata(env): object`.
- [ ] **Step 1: Port from leaf without Node types**
Copy the behavior from `apps/leaf/src/mcp/auth/resolveRequestAuth.ts`, but use Web `Request`, `Headers`, and `crypto.subtle.digest` or `crypto.randomUUID` where needed.
- [ ] **Step 2: Preserve responses**
Unauthenticated `/mcp` must return `401` with `WWW-Authenticate` pointing to `/.well-known/oauth-protected-resource/mcp`.
- [ ] **Step 3: Test static key and OAuth token paths**
Run: `cd /Volumes/sker/resources/coding/comi-logic/autumn && bun -F @autumn/mcp-worker test`
Expected: PASS for `secret-key`, OAuth bearer prefix, missing bearer, invalid env, and protected-resource metadata.
## Task 6: Implement Worker MCP HTTP Handler
**Files:**
- Create: `apps/mcp-worker/src/mcpHandler.ts`
- Modify: `apps/mcp-worker/src/index.ts`
- Test: `apps/mcp-worker/tests/mcp-worker.test.ts`
**Interfaces:**
- Produces: `handleMcpRequest(request: Request, env: Env, ctx: ExecutionContext): Promise<Response>`.
- [ ] **Step 1: Build per-request auth**
Call `buildAuthForWorkerRequest`, then pass the auth to Mastra via `extra.authInfo` / `RequestContext` in the same shape expected by `packages/mcp/src/server/auth/auth.ts`.
- [ ] **Step 2: Use Cloudflare validator**
Create the server with:
```ts
import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker";
const server = createAutumnOperationsMCPServer({
jsonSchemaValidator: new CfWorkerJsonSchemaValidator(),
instructions: workerAutumnMcpInstructions,
resources: createStaticAutumnMcpResources(workerAutumnMcpResourceDocs),
runtime,
});
```
- [ ] **Step 3: Use stateless/serverless HTTP**
Prefer Mastra `startHTTP` serverless mode if it accepts Web `Request`/`Response` in practice. If the current type/runtime still requires Node `IncomingMessage`/`ServerResponse`, replace the transport layer with the underlying `@modelcontextprotocol/sdk/server/streamableHttp` Web-compatible transport, keeping existing tools/resources unchanged.
- [ ] **Step 4: Test MCP initialize**
Send JSON-RPC:
```json
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"smoke","version":"0.0.1"}}}
```
Expected: HTTP 200 and MCP server info for `Autumn MCP`.
## Task 7: Replace Redis Pending Actions
**Files:**
- Modify: `packages/mcp/src/agent/pending-actions.ts`
- Create: `apps/mcp-worker/src/pendingActionStore.ts`
- Modify: `apps/mcp-worker/wrangler.jsonc`
- Test: `packages/mcp/tests/unit/mcp-server/agent/pending-actions.test.ts`
**Interfaces:**
- Produces: `PendingActionStore` with `create`, `claimLatest`, `getLatest`, `clear`.
- [ ] **Step 1: Abstract storage**
Keep Redis as the Node default for leaf. Add a Worker store implementation backed by KV for the 15-minute pending action TTL.
- [ ] **Step 2: Add KV binding**
Add to `wrangler.jsonc`:
```jsonc
"kv_namespaces": [
{ "binding": "MCP_PENDING_ACTIONS", "id": "<created-with-wrangler>" }
]
```
- [ ] **Step 3: Validate claim semantics**
Because KV is eventually consistent, confirm whether double-claim is acceptable for the current billing confirmation flow. If not acceptable, switch this task to a Durable Object store before production deploy.
## Task 8: Verification And Deploy
**Files:**
- Modify: `server/wrangler.jsonc` after deploy only.
- Read: `apps/mcp-worker/wrangler.jsonc`
**Interfaces:**
- Produces: live Worker endpoint.
- [ ] **Step 1: Generate types and check**
Run:
```bash
cd /Volumes/sker/resources/coding/comi-logic/autumn
bun -F @autumn/mcp-worker check
bun -F @autumn/mcp test
bun -F @autumn/leaf ts
```
Expected: all pass.
- [ ] **Step 2: Dry-run bundle**
Run:
```bash
cd /Volumes/sker/resources/coding/comi-logic/autumn/apps/mcp-worker
bunx wrangler deploy --dry-run --outdir dist-dry-run
```
Expected: bundle succeeds and does not include `node:fs`, `node:http`, or `ioredis` in the Worker path.
- [ ] **Step 3: Set secrets**
Run:
```bash
cd /Volumes/sker/resources/coding/comi-logic/autumn/apps/mcp-worker
bunx wrangler secret put AXIOM_TOKEN
bunx wrangler secret put AXIOM_ORG_ID
```
Expected: Wrangler confirms each secret was uploaded.
- [ ] **Step 4: Deploy**
Run:
```bash
cd /Volumes/sker/resources/coding/comi-logic/autumn
bun run mcp:cf:deploy
```
Expected: Wrangler returns a deployed Worker version and route.
- [ ] **Step 5: Live smoke**
Run:
```bash
curl -i https://<mcp-worker-host>/health
curl -i https://<mcp-worker-host>/.well-known/oauth-protected-resource/mcp
curl -i https://<mcp-worker-host>/mcp
```
Expected: `/health` returns 200, metadata returns 200 JSON, unauthenticated `/mcp` returns 401 with `WWW-Authenticate`.
## Task 9: Cut Over Existing Autumn Server Proxy
**Files:**
- Modify: `server/wrangler.jsonc`
- Optionally modify: deployment environment vars for `server`
**Interfaces:**
- Consumes: deployed Worker origin.
- Produces: existing `server` `/mcp` proxy routes target the Cloudflare Worker instead of localhost/leaf.
- [ ] **Step 1: Change target vars**
Set:
```jsonc
"CHAT_SERVER_URL": "https://<mcp-worker-host>",
"MCP_SERVER_URL": "https://<mcp-worker-host>"
```
If Slack remains on leaf, do not reuse `CHAT_SERVER_URL`; split `server/src/routers/chatProxyRouter.ts` into `CHAT_SERVER_URL` for Slack and `MCP_UPSTREAM_URL` for MCP before changing production config.
- [ ] **Step 2: Deploy server**
Run:
```bash
cd /Volumes/sker/resources/coding/comi-logic/autumn/server
bun run cf:deploy
```
Expected: deploy succeeds.
- [ ] **Step 3: Smoke through existing public origin**
Run:
```bash
curl -i https://autumn-api.bowong.cc/.well-known/oauth-protected-resource/mcp
curl -i https://autumn-api.bowong.cc/mcp
```
Expected: metadata reflects the intended MCP resource URL, and unauthenticated `/mcp` returns the correct OAuth challenge.
## Task 10: Production Acceptance
**Files:**
- Test only unless smoke reveals defects.
**Interfaces:**
- Produces: a deployed, externally reachable MCP endpoint for Claude/Codex-style MCP clients.
- [ ] **Step 1: Run JSON-RPC initialize against live endpoint**
Use a bearer test credential or OAuth token and post an `initialize` request to `/mcp`.
Expected: server identifies as `Autumn MCP`.
- [ ] **Step 2: List tools**
Post `tools/list`.
Expected: core Autumn tools are present: customer, plan, billing, balance, logs, and organization tools.
- [ ] **Step 3: Run a read-only tool**
Call `organizationMe` or equivalent read-only org tool.
Expected: returns data scoped to the authenticated Autumn org and environment.
- [ ] **Step 4: Confirm observability**
Check Cloudflare Worker logs and Axiom MCP analytics.
Expected: one structured request log and one MCP tool analytics event for the smoke request.
## Rollback
- Keep `apps/leaf` deployment unchanged until Task 10 passes.
- If Worker deploy fails, keep `server` vars pointing to current leaf/local target.
- If cutover fails after Task 9, restore previous `CHAT_SERVER_URL` / `MCP_SERVER_URL` values in `server/wrangler.jsonc` and redeploy `server`.
- If billing confirmation double-claim appears during smoke, disable billing write tools in Worker until Pending Actions moves from KV to Durable Object.
## Notes From Current Audit
- `packages/mcp/src/resources-v2/index.ts` and `mcpInstructions.ts` use `readFileSync`; this must not run in the Worker bundle.
- `packages/mcp/src/agent/pending-actions.ts` uses `ioredis`; Worker needs KV or Durable Object.
- `apps/leaf/src/mcp/handlers/handleMcp.ts` depends on `@hono/node-server` request/response bindings; it is not Worker-native.
- `@mastra/mcp` documents `CfWorkerJsonSchemaValidator` for Cloudflare Workers.
- `server` already exposes `/mcp` and protected-resource metadata as a proxy, so cutover can be config-first after the new Worker is live.

View File

@@ -21,6 +21,7 @@ import {
createAutumnClient,
getAutumnAuth,
} from "../server/auth/auth.js";
import { getAutumnMcpRuntimeConfig } from "../server/runtime.js";
const axiomDataset = "express";
const defaultStartTime = "now-30m";
@@ -34,13 +35,14 @@ let axiomClient: Axiom | null = null;
const orgCache = new Map<string, { org: AutumnOrg; expiresAt: Date }>();
const getAxiomClient = () => {
if (!process.env.AXIOM_ADMIN_TOKEN) {
const runtime = getAutumnMcpRuntimeConfig();
if (!runtime.axiomAdminToken) {
throw new Error("Axiom is not configured (AXIOM_ADMIN_TOKEN missing).");
}
axiomClient ??= new Axiom({
token: process.env.AXIOM_ADMIN_TOKEN,
orgId: process.env.AXIOM_ORG_ID,
token: runtime.axiomAdminToken,
orgId: runtime.axiomOrgId,
});
return axiomClient;

View File

@@ -1,8 +1,16 @@
import { createHash } from "node:crypto";
import { ms } from "@autumn/shared/unixUtils";
import { addMilliseconds, isPast } from "date-fns";
import { Redis } from "ioredis";
import type { AutumnMcpAuth } from "../server/auth/auth.js";
import {
getAutumnMcpRuntimeConfig,
type PendingActionRedis,
} from "../server/runtime.js";
export type {
PendingActionRedis,
PendingActionRedisMulti,
} from "../server/runtime.js";
export type BillingToolName =
| "attach"
@@ -25,23 +33,7 @@ export type PendingBillingAction = {
const ttlMs = ms.minutes(15);
const namespace = "autumn:mcp:pending-action";
let redis: Redis | undefined;
export type PendingActionRedisMulti = {
set: (
key: string,
value: string,
expiryMode: "EX",
ttlSeconds: number,
) => PendingActionRedisMulti;
exec: () => Promise<unknown>;
};
export type PendingActionRedis = {
multi: () => PendingActionRedisMulti;
get: (key: string) => Promise<string | null>;
getdel: (key: string) => Promise<string | null>;
del: (...keys: string[]) => Promise<unknown>;
keys: (pattern: string) => Promise<string[]>;
};
let redis: PendingActionRedis | undefined;
const createToken = () => `act_${crypto.randomUUID()}`;
const isExpired = (action: PendingBillingAction) =>
@@ -49,7 +41,6 @@ const isExpired = (action: PendingBillingAction) =>
const hash = (value: string) =>
createHash("sha256").update(value).digest("hex").slice(0, 32);
const shortHash = (value: string) => hash(value).slice(0, 8);
const redisUrl = () => process.env.REDIS_URL || "";
const actionScope = (auth: AutumnMcpAuth) =>
hash([auth.principalId, auth.resource, auth.env].join(":"));
@@ -64,26 +55,32 @@ const actionDebug = (auth: AutumnMcpAuth) => ({
scope: actionScope(auth),
});
const logPendingAction = (event: string, data: Record<string, unknown>) => {
if (process.env.MCP_DEBUG_PENDING_ACTIONS !== "1") return;
if (!getAutumnMcpRuntimeConfig().mcpDebugPendingActions) return;
console.log(`[mcp:pending-actions] ${event} ${JSON.stringify(data)}`);
};
export const setPendingActionsRedis = (client: PendingActionRedis) => {
redis = client as unknown as Redis;
redis = client;
};
const getRedis = (): PendingActionRedis => {
const getRedis = async (): Promise<PendingActionRedis> => {
const configuredStore = getAutumnMcpRuntimeConfig().pendingActionStore;
if (configuredStore) return configuredStore;
if (redis) return redis;
const url = redisUrl().trim();
const url = getAutumnMcpRuntimeConfig().redisUrl?.trim() ?? "";
if (!url) {
throw new Error("REDIS_URL is required for MCP pending billing actions.");
}
redis = new Redis(url, {
const { Redis } = await import("ioredis");
const client = new Redis(url, {
maxRetriesPerRequest: 1,
commandTimeout: 5_000,
});
redis.on("error", () => undefined);
}) as unknown as PendingActionRedis & {
on?: (event: "error", handler: () => void) => void;
};
client.on?.("error", () => undefined);
redis = client;
logPendingAction("store", { backend: "redis", redisUrl: true });
return redis;
};
@@ -121,7 +118,7 @@ export const createPendingAction = async (input: {
preview: string;
}) => {
const action = createAction(input);
const client = getRedis();
const client = await getRedis();
const ttlSeconds = Math.ceil(ttlMs / 1000);
await client
.multi()
@@ -143,7 +140,7 @@ export const createPendingAction = async (input: {
};
export const claimLatestPendingAction = async (auth: AutumnMcpAuth) => {
const client = getRedis();
const client = await getRedis();
const token = await client.getdel(latestKey(auth));
const key = token ? actionKey(auth, token) : null;
const action = key ? parseStoredAction(await client.get(key)) : null;
@@ -171,7 +168,7 @@ export const claimLatestPendingAction = async (auth: AutumnMcpAuth) => {
};
export const getLatestPendingAction = async (auth: AutumnMcpAuth) => {
const client = getRedis();
const client = await getRedis();
const token = await client.get(latestKey(auth));
const action = token
? parseStoredAction(await client.get(actionKey(auth, token)))
@@ -181,7 +178,7 @@ export const getLatestPendingAction = async (auth: AutumnMcpAuth) => {
};
export const clearPendingActions = async () => {
const client = getRedis();
const client = await getRedis();
const keys = await client.keys(`${namespace}:*`);
if (keys.length) await client.del(...keys);
};

View File

@@ -1,5 +1,6 @@
import type { AnalyticsSink } from "./analyticsTypes.js";
import { createLoggerAnalyticsSink } from "./loggerSink.js";
import { getAutumnMcpRuntimeConfig } from "../server/runtime.js";
const DEFAULT_DATASET = "leaf";
@@ -23,10 +24,11 @@ export const setAnalyticsSink = (sink: AnalyticsSink | null | undefined) => {
export const getAnalyticsSink = (): AnalyticsSink => {
if (overrideSink !== undefined) return overrideSink ?? noopSink;
if (cachedSink === undefined) {
const runtime = getAutumnMcpRuntimeConfig();
cachedSink = createLoggerAnalyticsSink({
token: process.env.AXIOM_TOKEN,
orgId: process.env.AXIOM_ORG_ID,
dataset: process.env.MCP_ANALYTICS_DATASET ?? DEFAULT_DATASET,
token: runtime.axiomToken,
orgId: runtime.axiomOrgId,
dataset: runtime.mcpAnalyticsDataset ?? DEFAULT_DATASET,
});
}
return cachedSink ?? noopSink;

View File

@@ -25,4 +25,15 @@ export {
type OAuthEnvironment,
} from "./server/auth/auth.js";
export type { MCPServerFlags } from "./server/flags.js";
export { createAutumnOperationsMCPServer } from "./server/server.js";
export {
type AutumnMcpServerOptions,
createAutumnOperationsMCPServer,
} from "./server/server.js";
export {
type AutumnMcpRuntimeConfig,
type PendingActionRedis,
type PendingActionRedisMulti,
getAutumnMcpRuntimeConfig,
setAutumnMcpRuntimeConfig,
} from "./server/runtime.js";
export { createStaticAutumnMcpResources } from "./resources-v2/index.js";

View File

@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
import type { MCPServerResources } from "@mastra/mcp";
import { parseResourceMarkdown } from "../resources/compileResources.js";
import type { AutumnMcpResourceDoc } from "../resources/types.js";
import { getAutumnMcpRuntimeConfig } from "../server/runtime.js";
const billingResource = {
name: "billing",
@@ -156,6 +157,28 @@ const compileResources = ({
]);
};
export const createStaticAutumnMcpResources = (
docs: AutumnMcpResourceDoc[],
): MCPServerResources => ({
listResources: async () =>
docs.map((doc) => ({
uri: doc.uri,
name: doc.name,
title: doc.title,
description: doc.description,
mimeType: "text/markdown",
size: doc.text.length,
annotations: { audience: doc.audience, priority: doc.priority },
})),
getResourceContent: async ({ uri }) => {
const doc = docs.find((entry) => entry.uri === uri);
if (!doc) {
throw new Error(`Unknown Autumn MCP resource: ${uri}`);
}
return { text: doc.text };
},
});
export const createAutumnMcpResources = ({
baseUrl,
}: {
@@ -165,32 +188,14 @@ export const createAutumnMcpResources = ({
// Re-read resource markdown from disk every call in dev so prompt edits take
// effect without a restart; memoize in prod.
const getDocs = () => {
if (process.env.NODE_ENV !== "production") {
if (getAutumnMcpRuntimeConfig().nodeEnv !== "production") {
return compileResources({ baseUrl });
}
docs ??= compileResources({ baseUrl });
return docs;
};
return {
listResources: async () =>
getDocs().map((doc) => ({
uri: doc.uri,
name: doc.name,
title: doc.title,
description: doc.description,
mimeType: "text/markdown",
size: doc.text.length,
annotations: { audience: doc.audience, priority: doc.priority },
})),
getResourceContent: async ({ uri }) => {
const doc = getDocs().find((entry) => entry.uri === uri);
if (!doc) {
throw new Error(`Unknown Autumn MCP resource: ${uri}`);
}
return { text: doc.text };
},
};
return createStaticAutumnMcpResources(getDocs());
};
export const autumnMcpResources = createAutumnMcpResources({

View File

@@ -0,0 +1,61 @@
export type PendingActionRedisMulti = {
set: (
key: string,
value: string,
expiryMode: "EX",
ttlSeconds: number,
) => PendingActionRedisMulti;
exec: () => Promise<unknown>;
};
export type PendingActionRedis = {
multi: () => PendingActionRedisMulti;
get: (key: string) => Promise<string | null>;
getdel: (key: string) => Promise<string | null>;
del: (...keys: string[]) => Promise<unknown>;
keys: (pattern: string) => Promise<string[]>;
};
export type AutumnMcpRuntimeConfig = {
axiomAdminToken?: string | undefined;
axiomOrgId?: string | undefined;
axiomToken?: string | undefined;
mcpAnalyticsDataset?: string | undefined;
mcpDebugPendingActions?: boolean | undefined;
nodeEnv?: string | undefined;
pendingActionStore?: PendingActionRedis | undefined;
redisUrl?: string | undefined;
};
let runtimeConfig: AutumnMcpRuntimeConfig | undefined;
const readProcessEnv = (): Record<string, string | undefined> =>
(
globalThis as {
process?: { env?: Record<string, string | undefined> };
}
).process?.env ?? {};
const getAmbientRuntimeConfig = (): AutumnMcpRuntimeConfig => {
const env = readProcessEnv();
return {
axiomAdminToken: env.AXIOM_ADMIN_TOKEN,
axiomOrgId: env.AXIOM_ORG_ID,
axiomToken: env.AXIOM_TOKEN,
mcpAnalyticsDataset: env.MCP_ANALYTICS_DATASET,
mcpDebugPendingActions: env.MCP_DEBUG_PENDING_ACTIONS === "1",
nodeEnv: env.NODE_ENV,
redisUrl: env.REDIS_URL,
};
};
export const setAutumnMcpRuntimeConfig = (
config: AutumnMcpRuntimeConfig | undefined,
) => {
runtimeConfig = config;
};
export const getAutumnMcpRuntimeConfig = (): AutumnMcpRuntimeConfig => ({
...getAmbientRuntimeConfig(),
...runtimeConfig,
});

View File

@@ -2,14 +2,35 @@ import { MCPServer } from "@mastra/mcp";
import { autumnMcpResources } from "../resources/index.js";
import { autumnMcpInstructions } from "../resources-v2/mcpInstructions.js";
import { createRawAutumnOperationTools } from "../tools/index.js";
import {
type AutumnMcpRuntimeConfig,
setAutumnMcpRuntimeConfig,
} from "./runtime.js";
export const createAutumnOperationsMCPServer = () =>
new MCPServer({
export type AutumnMcpServerOptions = {
instructions?: string | undefined;
jsonSchemaValidator?: ConstructorParameters<
typeof MCPServer
>[0]["jsonSchemaValidator"];
resources?: ConstructorParameters<typeof MCPServer>[0]["resources"];
runtime?: AutumnMcpRuntimeConfig | undefined;
};
export const createAutumnOperationsMCPServer = (
options: AutumnMcpServerOptions = {},
) => {
if (options.runtime) {
setAutumnMcpRuntimeConfig(options.runtime);
}
return new MCPServer({
id: "autumn-mcp",
name: "Autumn MCP",
version: "0.0.1",
description: "Operate on Autumn customers, plans, and billing.",
instructions: autumnMcpInstructions,
instructions: options.instructions ?? autumnMcpInstructions,
tools: createRawAutumnOperationTools(),
resources: autumnMcpResources,
resources: options.resources ?? autumnMcpResources,
jsonSchemaValidator: options.jsonSchemaValidator,
});
};

View File

@@ -85,6 +85,8 @@ describe("Autumn MCP server", () => {
"listPlans",
"createPlan",
"getPlan",
"hasCustomers",
"updatePlan",
"createBalance",
"searchRequestLogs",
"queryRequestLogs",
@@ -153,12 +155,12 @@ describe("Autumn MCP server", () => {
expect(conceptsText).toContain("Never use `auto_enable: true`");
expect(conceptsText).toContain('no concept of "variants"');
expect(conceptsText).toContain("`pro_monthly` or `pro_annual`");
expect(conceptsText).toContain("Do not create duplicate features");
expect(conceptsText).toContain("`monthly_tokens` and `one_time_tokens`");
expect(conceptsText).toContain(
"Boolean/unlimited feature grants use `unlimited: true`",
);
expect(conceptsText).toContain("Boolean plan items cannot be paid today");
expect(conceptsText).toContain("Do not create duplicate features");
expect(conceptsText).toContain("`monthly_tokens` and `one_time_tokens`");
expect(conceptsText).toContain(
"Pass only `feature_id`; set neither `included` nor `unlimited`",
);
expect(conceptsText).toContain("Boolean plan items cannot be paid today");
expect(conceptsText).toContain("concurrency limit of 10");
const planManagement = await server.readResource(
@@ -222,23 +224,25 @@ describe("Autumn MCP server", () => {
expect(billingText).toContain(
"Resolve invoice, checkout, and proration behavior with <billing-behavior>",
);
expect(billingText).toContain(
"Gather all missing questions from the checklist and ask them together",
);
expect(billingText).toContain(
"Gather all remaining missing questions from the checklist and ask them together",
);
expect(billingText).toContain(
"If there are no missing questions, call the preview tool",
);
expect(billingText).toContain(
"If the user approves the preview, execute the exact previewed billing action",
);
expect(billingText).toContain(
"Once approved, apply the exact previewed billing action",
);
expect(billingText).toContain("<param-checklist>");
expect(billingText).toContain("Do not use `update_items`");
expect(billingText).toContain(
"Never use `customize.items` (PUT-style full replacement) or `update_items`",
);
expect(billingText).toContain("Change prepaid to usage-based");
expect(billingText).toContain('plan_schedule: "immediate"');
expect(billingText).toContain("<attach-timing>");
expect(billingText).toContain("dateToEpochMilliseconds");
expect(billingText).toContain("pass the literal `now`");
expect(billingText).toContain("addInterval");
expect(billingText).toContain('use `starts_at: "now"` on phase 1');
expect(billingText).toContain("`starting_after` on later phases");
expect(billingText).toContain("Future first-phase `starts_at`");
expect(billingText).toContain("<billing-behavior>");
expect(billingText).toContain(
@@ -255,7 +259,7 @@ describe("Autumn MCP server", () => {
);
expect(billingText).toContain("<preview-and-approval>");
expect(billingText).toContain(
"APPROVAL MUST BE GRANTED BEFORE PERFORMING ANY MUTATING BILLING ACTION",
"A mutating billing action requires approval before it takes effect",
);
expect(billingText).toContain("Monetary amounts are major currency units");
expect(billingText).toContain(