From 488ad6b3fd610db1d3d15993e8cfa69b4150e6be Mon Sep 17 00:00:00 2001 From: imeepos Date: Tue, 14 Jul 2026 01:24:17 -0700 Subject: [PATCH] feat: implement Cloudflare Worker for MCP endpoint with runtime configuration and Redis abstraction --- .../plans/2026-07-14-mcp-cloudflare-worker.md | 523 ++++++++++++++++++ packages/mcp/src/agent/axiom.ts | 8 +- packages/mcp/src/agent/pending-actions.ts | 57 +- packages/mcp/src/analytics/analyticsSink.ts | 8 +- packages/mcp/src/index.ts | 13 +- packages/mcp/src/resources-v2/index.ts | 45 +- packages/mcp/src/server/runtime.ts | 61 ++ packages/mcp/src/server/server.ts | 29 +- .../unit/mcp-server/agent/server.test.ts | 36 +- 9 files changed, 703 insertions(+), 77 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-14-mcp-cloudflare-worker.md create mode 100644 packages/mcp/src/server/runtime.ts diff --git a/docs/superpowers/plans/2026-07-14-mcp-cloudflare-worker.md b/docs/superpowers/plans/2026-07-14-mcp-cloudflare-worker.md new file mode 100644 index 000000000..aade17858 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-mcp-cloudflare-worker.md @@ -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[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; +``` + +- [ ] **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`. +- 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`. + +- [ ] **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": "" } +] +``` + +- [ ] **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:///health +curl -i https:///.well-known/oauth-protected-resource/mcp +curl -i https:///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_SERVER_URL": "https://" +``` + +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. diff --git a/packages/mcp/src/agent/axiom.ts b/packages/mcp/src/agent/axiom.ts index dcfcd640a..75a04f5ac 100644 --- a/packages/mcp/src/agent/axiom.ts +++ b/packages/mcp/src/agent/axiom.ts @@ -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(); 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; diff --git a/packages/mcp/src/agent/pending-actions.ts b/packages/mcp/src/agent/pending-actions.ts index 5cfd9512d..a1c62a87b 100644 --- a/packages/mcp/src/agent/pending-actions.ts +++ b/packages/mcp/src/agent/pending-actions.ts @@ -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; -}; -export type PendingActionRedis = { - multi: () => PendingActionRedisMulti; - get: (key: string) => Promise; - getdel: (key: string) => Promise; - del: (...keys: string[]) => Promise; - keys: (pattern: string) => Promise; -}; +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) => { - 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 => { + 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); }; diff --git a/packages/mcp/src/analytics/analyticsSink.ts b/packages/mcp/src/analytics/analyticsSink.ts index 928754031..23cabd348 100644 --- a/packages/mcp/src/analytics/analyticsSink.ts +++ b/packages/mcp/src/analytics/analyticsSink.ts @@ -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; diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 50afd12df..98971a6b6 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -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"; diff --git a/packages/mcp/src/resources-v2/index.ts b/packages/mcp/src/resources-v2/index.ts index ff3222a2a..b2670a214 100644 --- a/packages/mcp/src/resources-v2/index.ts +++ b/packages/mcp/src/resources-v2/index.ts @@ -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({ diff --git a/packages/mcp/src/server/runtime.ts b/packages/mcp/src/server/runtime.ts new file mode 100644 index 000000000..53cf4710e --- /dev/null +++ b/packages/mcp/src/server/runtime.ts @@ -0,0 +1,61 @@ +export type PendingActionRedisMulti = { + set: ( + key: string, + value: string, + expiryMode: "EX", + ttlSeconds: number, + ) => PendingActionRedisMulti; + exec: () => Promise; +}; + +export type PendingActionRedis = { + multi: () => PendingActionRedisMulti; + get: (key: string) => Promise; + getdel: (key: string) => Promise; + del: (...keys: string[]) => Promise; + keys: (pattern: string) => Promise; +}; + +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 => + ( + globalThis as { + process?: { env?: Record }; + } + ).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, +}); diff --git a/packages/mcp/src/server/server.ts b/packages/mcp/src/server/server.ts index 390d72acd..49b66e633 100644 --- a/packages/mcp/src/server/server.ts +++ b/packages/mcp/src/server/server.ts @@ -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[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, }); +}; diff --git a/packages/mcp/tests/unit/mcp-server/agent/server.test.ts b/packages/mcp/tests/unit/mcp-server/agent/server.test.ts index 0a20a450c..c49ad0b6a 100644 --- a/packages/mcp/tests/unit/mcp-server/agent/server.test.ts +++ b/packages/mcp/tests/unit/mcp-server/agent/server.test.ts @@ -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 ", ); - 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(""); - 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(""); 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(""); expect(billingText).toContain( @@ -255,7 +259,7 @@ describe("Autumn MCP server", () => { ); expect(billingText).toContain(""); 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(