feat: implement Cloudflare Worker for MCP endpoint with runtime configuration and Redis abstraction
This commit is contained in:
523
docs/superpowers/plans/2026-07-14-mcp-cloudflare-worker.md
Normal file
523
docs/superpowers/plans/2026-07-14-mcp-cloudflare-worker.md
Normal 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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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({
|
||||
|
||||
61
packages/mcp/src/server/runtime.ts
Normal file
61
packages/mcp/src/server/runtime.ts
Normal 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,
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user