chore: merge with dev

This commit is contained in:
Charlie Lamb
2026-06-05 11:25:46 +01:00
166 changed files with 21414 additions and 2464 deletions

2
ai

Submodule ai updated: a84bc7418c...0e52f71fbd

View File

@@ -10,6 +10,7 @@
"ts": "tsc --noEmit"
},
"dependencies": {
"@autumn/logging": "workspace:*",
"@autumn/mcp": "workspace:*",
"@autumn/shared": "workspace:*",
"@chat-adapter/slack": "^4.29.0",

View File

@@ -1,13 +1,12 @@
import type { AutumnLogger } from "@autumn/logging";
import { AppEnv } from "@autumn/shared";
import { Agent } from "@mastra/core/agent";
import { z } from "zod";
import {
createAutumnMcpClient,
getAutumnMcpTools,
} from "./mcp.js";
import { createFirecrawlTools } from "./firecrawl.js";
import { env as chatEnv } from "../lib/env.js";
import { logger as rootLogger } from "../lib/logger.js";
import type { ChatContextMessage } from "../types.js";
import { createFirecrawlTools } from "./firecrawl.js";
import { createAutumnMcpClient, getAutumnMcpTools } from "./mcp.js";
const docs = [
"autumn://docs/tool-composition",
@@ -37,15 +36,25 @@ const recentMessageContext = (messages: ChatContextMessage[] = []) =>
}));
export const selectChatEnv = async ({
logger = rootLogger,
message,
recentMessages,
select,
}: {
logger?: AutumnLogger;
message: string;
recentMessages?: ChatContextMessage[];
select?: () => Promise<unknown> | unknown;
}) => {
if (select) return envSelectionSchema.parse(await select()).env;
if (select) {
const env = envSelectionSchema.parse(await select()).env;
logger.debug("Selected chat environment from override", {
event: "leaf.chat_env_selected",
context: { env },
data: { source: "override" },
});
return env;
}
const agent = new Agent({
id: "autumn-chat-env",
@@ -61,9 +70,12 @@ export const selectChatEnv = async ({
instructions:
"Return live unless the latest user request clearly asks to use sandbox or test mode.",
},
context: [
...recentMessageContext(recentMessages),
],
context: [...recentMessageContext(recentMessages)],
});
logger.debug("Selected chat environment from model", {
event: "leaf.chat_env_selected",
context: { env: output.object.env },
data: { source: "model" },
});
return output.object.env;
};
@@ -86,6 +98,7 @@ const readDocs = async (mcp: ReturnType<typeof createAutumnMcpClient>) => {
export const runChatAgent = async ({
apiKey,
env,
logger = rootLogger,
message,
threadId,
resourceId,
@@ -95,6 +108,7 @@ export const runChatAgent = async ({
}: {
apiKey: string;
env: AppEnv;
logger?: AutumnLogger;
message: string;
onAction?: (message: string) => Promise<void> | void;
threadId: string;
@@ -111,10 +125,22 @@ export const runChatAgent = async ({
}
| undefined;
try {
logger.info("Starting chat agent", {
event: "leaf.agent_started",
context: {
env,
org_id: resourceId,
provider,
},
data: {
thread_id: threadId,
},
});
await onAction?.("Loading Autumn tools and guidance");
const [tools, docsText] = await Promise.all([
getAutumnMcpTools(mcp, {
applyApprovalPolicy: true,
logger,
onToolCall: onAction,
onPreview: (approval) => {
previewApproval = approval;
@@ -150,8 +176,19 @@ export const runChatAgent = async ({
...recentMessageContext(recentMessages),
],
});
logger.info("Completed chat agent", {
event: "leaf.agent_completed",
context: { env },
data: {
finish_reason: output.finishReason,
run_id: output.runId,
},
});
return { ...output, env, previewApproval };
} finally {
await mcp.disconnect();
logger.debug("Disconnected Autumn MCP client", {
event: "leaf.mcp_client_disconnected",
});
}
};

View File

@@ -1,6 +1,8 @@
import type { AutumnLogger } from "@autumn/logging";
import { MCPClient } from "@mastra/mcp";
import { getWriteToolForPreview, toolLabel } from "./toolPolicy.js";
import { env } from "../lib/env.js";
import { logger as rootLogger } from "../lib/logger.js";
import { getWriteToolForPreview, toolLabel } from "./toolPolicy.js";
type AutumnTool = {
execute?: (
@@ -14,6 +16,7 @@ type AutumnTool = {
type ToolOptions = {
applyApprovalPolicy?: boolean;
logger?: AutumnLogger;
onToolCall?: (message: string) => Promise<void> | void;
onPreview?: (approval: {
toolName: string;
@@ -77,12 +80,25 @@ export const getAutumnMcpTools = async (
mcp: MCPClient,
options: ToolOptions = {},
) => {
const logger = options.logger ?? rootLogger;
const { toolsets, errors } = await mcp.listToolsetsWithErrors();
if (Object.keys(errors).length) {
throw new Error(`Could not load Autumn MCP tools: ${JSON.stringify(errors)}`);
logger.error("Could not load Autumn MCP tools", {
event: "leaf.mcp_tools_load_failed",
data: { errors },
});
throw new Error(
`Could not load Autumn MCP tools: ${JSON.stringify(errors)}`,
);
}
const tools = (toolsets.autumn ?? {}) as Record<string, AutumnTool>;
logger.info("Loaded Autumn MCP tools", {
event: "leaf.mcp_tools_loaded",
data: {
tool_count: Object.keys(tools).length,
},
});
for (const [toolName, tool] of Object.entries(tools)) {
if (options.applyApprovalPolicy) {
tool.requireApproval = tool.mcp?.annotations?.destructiveHint === true;
@@ -91,10 +107,21 @@ export const getAutumnMcpTools = async (
if (tool.execute && (options.onToolCall || options.onPreview)) {
const execute = tool.execute.bind(tool);
tool.execute = async (args, ...rest) => {
logger.info("Calling Autumn MCP tool", {
event: "leaf.mcp_tool_called",
tool: toolName,
});
await options.onToolCall?.(formatToolAction(toolName, args));
const result = await execute(args, ...rest);
const writeTool = getWriteToolForPreview(toolName);
if (writeTool) {
logger.info("Captured Autumn MCP preview", {
event: "leaf.mcp_preview_captured",
tool: writeTool,
data: {
preview_tool: toolName,
},
});
options.onPreview?.({
toolName: writeTool,
toolArgs: args,

View File

@@ -1,6 +1,7 @@
import { runChatAgent, selectChatEnv } from "./agent.js";
import { logger as rootLogger } from "../lib/logger.js";
import { getInstallationKey } from "../providers/slack/installations.js";
import { agentOutputSchema, type BotMessage } from "../types.js";
import { runChatAgent, selectChatEnv } from "./agent.js";
const withTimeout = <T>(promise: Promise<T>, ms: number) =>
new Promise<T>((resolve, reject) => {
@@ -13,6 +14,7 @@ const withTimeout = <T>(promise: Promise<T>, ms: number) =>
export const runMessage = async ({
installation,
logger = rootLogger,
onAction,
recentMessages,
text,
@@ -23,11 +25,21 @@ export const runMessage = async ({
const env = await selectChatEnv({
message: text,
recentMessages,
logger,
});
logger.info("Selected chat environment", {
event: "leaf.chat_env_selected",
context: {
env,
org_id: installation.org_id,
provider: installation.provider,
},
});
return agentOutputSchema.parse(
await runChatAgent({
apiKey: getInstallationKey(installation, env),
env,
logger,
message: text,
onAction,
threadId,

View File

@@ -1,5 +1,16 @@
import type { AutumnLogger } from "@autumn/logging";
import type { ChatApproval, ChatInstallation } from "@autumn/shared";
import type { ActionEvent } from "chat";
import { toolLabel } from "../agent/toolPolicy.js";
import { logger as rootLogger } from "../lib/logger.js";
import type { AgentOutput } from "../types.js";
import { approvalCard, approvalStatusCard } from "../ui/blocks.js";
import {
finishLoading,
type LoadingState,
type ReplyTarget,
} from "../ui/progress.js";
import { approvalRequestFromOutput } from "./request.js";
import {
approveAndRun,
cancelApproval,
@@ -7,21 +18,13 @@ import {
getApproval,
isErrorResult,
} from "./store.js";
import { approvalRequestFromOutput } from "./request.js";
import { approvalCard, approvalStatusCard } from "../ui/blocks.js";
import {
finishLoading,
type LoadingState,
type ReplyTarget,
} from "../ui/progress.js";
import { toolLabel } from "../agent/toolPolicy.js";
import type { AgentOutput } from "../types.js";
export const postApprovalRequest = async ({
channelId,
installation,
loading,
logAction,
logger = rootLogger,
output,
providerUserId,
target,
@@ -30,6 +33,7 @@ export const postApprovalRequest = async ({
installation: ChatInstallation;
loading: LoadingState;
logAction: (message: string) => Promise<void> | void;
logger?: AutumnLogger;
output: AgentOutput;
providerUserId: string;
target: ReplyTarget;
@@ -47,6 +51,15 @@ export const postApprovalRequest = async ({
});
await logAction(`Waiting for approval: ${toolLabel(approval.toolName)}`);
logger.info("Created approval request", {
event: "leaf.approval_created",
context: {
env: approval.env,
org_id: installation.org_id,
},
approval_id: approvalId,
tool: approval.toolName,
});
await finishLoading(target, loading, "Preview ready.");
await target.post(
approvalCard({
@@ -92,10 +105,22 @@ export const handleApprovalAction = async (event: ActionEvent) => {
if (!event.value) return;
try {
rootLogger.info("Received approval action", {
event: "leaf.approval_action_received",
approval_id: event.value,
action: event.actionId,
data: {
provider_user_id: event.user.userId,
},
});
const details = await approvalDetails(event.value);
if (event.actionId === "cancel_billing_action") {
const cancelled = await cancelApproval(event.value, event.user.userId);
if (!cancelled) {
rootLogger.warn("Approval cancellation ignored", {
event: "leaf.approval_cancel_ignored",
approval_id: event.value,
});
const current = await getApproval(event.value);
await editActionMessage(
event,
@@ -110,6 +135,11 @@ export const handleApprovalAction = async (event: ActionEvent) => {
event,
approvalStatusCard({ status: "cancelled", ...details }),
);
rootLogger.info("Cancelled approval", {
event: "leaf.approval_cancelled",
approval_id: event.value,
tool: details.toolName,
});
return;
}
@@ -118,6 +148,12 @@ export const handleApprovalAction = async (event: ActionEvent) => {
approvalStatusCard({ status: "running", ...details }),
);
const result = await approveAndRun(event.value, event.user.userId);
rootLogger.info("Completed approval action", {
event: "leaf.approval_completed",
approval_id: event.value,
status: isErrorResult(result) ? "failed" : "approved",
tool: details.toolName,
});
await editActionMessage(
event,
approvalStatusCard({
@@ -127,7 +163,11 @@ export const handleApprovalAction = async (event: ActionEvent) => {
}),
);
} catch (error) {
console.error("[chat] Approval action failed", error);
rootLogger.error("[chat] Approval action failed", error, {
event: "leaf.approval_failed",
approval_id: event.value,
action: event.actionId,
});
const current = await getApproval(event.value);
await editActionMessage(
event,

View File

@@ -2,12 +2,19 @@ import { createSlackAdapter } from "@chat-adapter/slack";
import { createPostgresState } from "@chat-adapter/state-pg";
import type { Message, Thread } from "chat";
import { Chat } from "chat";
import { runMessage } from "./agent/messages.js";
import { handleApprovalAction, postApprovalRequest } from "./approvals/flow.js";
import { getSlackWorkspaceId } from "./providers/slack/context.js";
import { decrypt } from "./lib/crypto.js";
import { env } from "./lib/env.js";
import {
addLeafContext,
createLeafSessionContext,
logger as rootLogger,
} from "./lib/logger.js";
import { getSlackWorkspaceId } from "./providers/slack/context.js";
import { findInstallation } from "./providers/slack/installations.js";
import { runMessage } from "./agent/messages.js";
import { getRecentMessages } from "./providers/slack/threadContext.js";
import type { ChatContextMessage } from "./types.js";
import {
createActionLogger,
finishLoading,
@@ -15,8 +22,6 @@ import {
type ReplyTarget,
startLoading,
} from "./ui/progress.js";
import { getRecentMessages } from "./providers/slack/threadContext.js";
import type { ChatContextMessage } from "./types.js";
export const chatAdapterNames = ["slack"];
@@ -66,15 +71,46 @@ const runAndReply = async ({
threadId: string;
}) => {
let loading: LoadingState = null;
let logger = rootLogger;
try {
const workspaceId = getSlackWorkspaceId(raw);
const session = createLeafSessionContext({
channelId,
provider: "slack",
providerUserId,
threadId,
workspaceId,
});
logger = addLeafContext(rootLogger, {
...session.context,
agent_run_id: session.agentRunId,
});
logger.info("Received Slack message", {
event: "leaf.slack_message_received",
data: {
text_length: text.length,
},
});
const installation = await findInstallation("slack", workspaceId);
if (!installation || !text.trim()) return;
if (!installation) {
logger.warn("Slack installation not found", {
event: "leaf.slack_installation_missing",
});
return;
}
if (!text.trim()) {
logger.info("Skipping empty Slack message", {
event: "leaf.slack_message_skipped",
data: { reason: "empty" },
});
return;
}
loading = await startLoading(target);
const logAction = createActionLogger(loading);
const output = await runMessage({
installation,
logger,
onAction: logAction,
recentMessages,
text,
@@ -86,6 +122,7 @@ const runAndReply = async ({
installation,
loading,
logAction,
logger,
output,
providerUserId,
target,
@@ -94,8 +131,16 @@ const runAndReply = async ({
await finishLoading(target, loading, "Done.");
await target.post({ markdown: output.text || "Done." });
logger.info("Posted Slack response", {
event: "leaf.slack_response_posted",
data: {
has_text: Boolean(output.text),
},
});
} catch (error) {
console.error("[chat] Message failed", error);
logger.error("[chat] Message failed", error, {
event: "leaf.slack_message_failed",
});
await finishLoading(target, loading, "Request failed.");
await target.post({
markdown: "I could not complete that request. Please try again.",

View File

@@ -0,0 +1,60 @@
import {
type AutumnLogger,
createAppLogger,
createSessionId,
createTraceId,
} from "@autumn/logging";
export const logger = createAppLogger({
service: "leaf",
dataset: process.env.LEAF_LOG_DATASET ?? "leaf",
preset: "default",
});
export const createLeafSessionContext = ({
channelId,
provider,
providerUserId,
threadId,
workspaceId,
}: {
channelId: string;
provider: string;
providerUserId: string;
threadId: string;
workspaceId: string;
}) => {
const traceId = createTraceId();
const sessionId = createSessionId({
parts: {
channelId,
provider,
threadId,
workspaceId,
},
});
return {
agentRunId: createTraceId(),
sessionId,
traceId,
context: {
provider,
provider_user_id: providerUserId,
session_id: sessionId,
trace_id: traceId,
slack_channel_id: channelId,
slack_thread_id: threadId,
slack_workspace_id: workspaceId,
},
};
};
export const addLeafContext = (
baseLogger: AutumnLogger,
context: Record<string, unknown>,
): AutumnLogger =>
baseLogger.child({
context: {
context,
},
});

View File

@@ -1,9 +1,9 @@
import { createConsoleLogger } from "@autumn/mcp";
import type { HttpBindings } from "@hono/node-server";
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { chatAdapterNames } from "./bot.js";
import { env } from "./lib/env.js";
import { logger } from "./lib/logger.js";
import { registerMcpRoutes } from "./mcp/http.js";
import { slackRoutes } from "./providers/slack/routes.js";
@@ -22,7 +22,7 @@ registerMcpRoutes(app, {
"oauth-enabled": true,
"oauth-environment": env.MCP_OAUTH_ENVIRONMENT,
"server-url": env.BETTER_AUTH_URL,
logger: createConsoleLogger("info"),
logger,
});
app.route("/slack", slackRoutes);
@@ -34,9 +34,12 @@ serve(
port: env.PORT,
},
({ address, port }) => {
console.log("Chat listening", {
host: `${address}:${port}`,
adapters: chatAdapterNames,
logger.info("Chat listening", {
event: "leaf.server_started",
data: {
host: `${address}:${port}`,
adapters: chatAdapterNames,
},
});
},
);

View File

@@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import type { AutumnLogger } from "@autumn/logging";
import {
buildAuthForRequest,
type ConsoleLogger,
createAskAutumnMCPServer,
createAutumnOperationsMCPServer,
getAuthorizationServerMetadata,
getProtectedResourceMetadata,
@@ -16,24 +16,20 @@ import type { Context, Hono } from "hono";
export interface McpRouteOptions extends MCPServerFlags {
readonly "oauth-enabled": boolean;
readonly "oauth-environment": OAuthEnvironment;
readonly logger: ConsoleLogger;
readonly logger: AutumnLogger;
}
type AppContext = Context<{ Bindings: HttpBindings }>;
type McpPath = "/mcp" | "/internal/mcp";
type McpPath = "/mcp";
type McpApp = Hono<{ Bindings: HttpBindings }>;
export function registerMcpRoutes(app: McpApp, options: McpRouteOptions) {
const mcpServer = createAutumnOperationsMCPServer();
app.get("/.well-known/oauth-protected-resource/mcp", (c) =>
c.json(getProtectedResourceMetadata(c.req.raw.headers, options, "/mcp")),
);
app.get("/.well-known/oauth-protected-resource/internal/mcp", (c) =>
c.json(
getProtectedResourceMetadata(c.req.raw.headers, options, "/internal/mcp"),
),
);
app.get("/.well-known/oauth-authorization-server", (c) =>
c.json(getAuthorizationServerMetadata(options)),
);
@@ -41,7 +37,7 @@ export function registerMcpRoutes(app: McpApp, options: McpRouteOptions) {
const handleMcp = async (
c: AppContext,
path: McpPath,
server: ReturnType<typeof createAskAutumnMCPServer>,
server: ReturnType<typeof createAutumnOperationsMCPServer>,
) => {
let auth: Awaited<ReturnType<typeof buildAuthForRequest>>;
try {
@@ -71,17 +67,12 @@ export function registerMcpRoutes(app: McpApp, options: McpRouteOptions) {
httpPath: path,
req: c.env.incoming,
res: c.env.outgoing,
options: { serverless: true },
options: { sessionIdGenerator: randomUUID },
});
return RESPONSE_ALREADY_SENT;
};
app.all("/mcp", (c) =>
handleMcp(c, "/mcp", createAutumnOperationsMCPServer()),
);
app.all("/internal/mcp", (c) =>
handleMcp(c, "/internal/mcp", createAskAutumnMCPServer()),
);
app.all("/mcp", (c) => handleMcp(c, "/mcp", mcpServer));
return app;
}

View File

@@ -2,6 +2,7 @@ import { verifyChatInstallState } from "@autumn/shared/utils/chatState";
import { Hono } from "hono";
import { z } from "zod";
import { bot } from "../../bot.js";
import { logger } from "../../lib/logger.js";
import { getStateSecret, replaceInstallation } from "./installations.js";
import { exchangeSlackCode, slackErrorUrl, slackSuccessUrl } from "./oauth.js";
@@ -36,25 +37,38 @@ slackRoutes.get("/oauth/callback", async (c) => {
.filter(Boolean),
installedByProviderUserId: oauth.authed_user?.id,
});
console.info("[chat:slack] Installed", {
orgId: parsedState.orgId,
workspaceId: oauth.team.id,
workspaceName: oauth.team.name,
logger.info("[chat:slack] Installed", {
event: "leaf.slack_installed",
context: {
org_id: parsedState.orgId,
slack_workspace_id: oauth.team.id,
},
data: {
workspace_name: oauth.team.name,
},
});
return c.redirect(slackSuccessUrl());
} catch (error) {
console.error("[chat:slack] OAuth callback failed", error);
logger.error("[chat:slack] OAuth callback failed", error, {
event: "leaf.slack_oauth_failed",
});
return c.redirect(slackErrorUrl("Slack install failed"));
}
});
slackRoutes.post("/events", (c) => {
logger.debug("Received Slack events request", {
event: "leaf.slack_events_request_received",
});
if (!bot.webhooks.slack) return c.text("Slack is not configured", 503);
return bot.webhooks.slack(c.req.raw);
});
slackRoutes.post("/interactions", (c) => {
logger.debug("Received Slack interactions request", {
event: "leaf.slack_interactions_request_received",
});
if (!bot.webhooks.slack) return c.text("Slack is not configured", 503);
return bot.webhooks.slack(c.req.raw);
});

View File

@@ -1,4 +1,5 @@
import { AppEnv, type ChatInstallation } from "@autumn/shared";
import type { AutumnLogger } from "@autumn/logging";
import { z } from "zod";
export const agentOutputSchema = z.preprocess(
@@ -62,6 +63,7 @@ export type SignatureArgs = {
export type BotMessage = {
installation: ChatInstallation;
logger?: AutumnLogger;
onAction?: (message: string) => Promise<void> | void;
recentMessages?: ChatContextMessage[];
text: string;

View File

@@ -0,0 +1,32 @@
import { describe, expect, test } from "bun:test";
import { createLeafSessionContext } from "../../../src/lib/logger.js";
describe("Leaf logger context", () => {
test("creates stable session ids and distinct trace ids", () => {
const first = createLeafSessionContext({
channelId: "C1",
provider: "slack",
providerUserId: "U1",
threadId: "T1",
workspaceId: "W1",
});
const second = createLeafSessionContext({
channelId: "C1",
provider: "slack",
providerUserId: "U2",
threadId: "T1",
workspaceId: "W1",
});
expect(first.sessionId).toBe(second.sessionId);
expect(first.traceId).not.toBe(second.traceId);
expect(first.context).toMatchObject({
provider: "slack",
session_id: first.sessionId,
trace_id: first.traceId,
slack_channel_id: "C1",
slack_thread_id: "T1",
slack_workspace_id: "W1",
});
});
});

View File

@@ -12,6 +12,7 @@
"paths": {
"@autumn/shared": ["../../shared/index.ts"],
"@autumn/shared/*": ["../../shared/*"],
"@autumn/logging": ["../../packages/logging/src/index.ts"],
"@autumn/mcp/*": ["../../packages/mcp/*"],
"@api/*": ["../../shared/api/*"],
"@models/*": ["../../shared/models/*"],

View File

@@ -88,6 +88,7 @@
"name": "@autumn/leaf",
"version": "0.0.1",
"dependencies": {
"@autumn/logging": "workspace:*",
"@autumn/mcp": "workspace:*",
"@autumn/shared": "workspace:*",
"@chat-adapter/slack": "^4.29.0",
@@ -284,10 +285,24 @@
"name": "@autumn/ksuid",
"version": "1.0.0",
},
"packages/logging": {
"name": "@autumn/logging",
"version": "0.0.1",
"dependencies": {
"@axiomhq/pino": "^1.3.1",
"pino": "^9.6.0",
},
"devDependencies": {
"@types/bun": "^1.2.13",
"@types/node": "^18.19.3",
"typescript": "~5.8.3",
},
},
"packages/mcp": {
"name": "@autumn/mcp",
"version": "0.0.1",
"dependencies": {
"@autumn/logging": "workspace:*",
"@autumn/shared": "workspace:*",
"@axiomhq/js": "^1.6.1",
"@mastra/core": "^1.36.0",
@@ -722,6 +737,8 @@
"@autumn/leaf": ["@autumn/leaf@workspace:apps/leaf"],
"@autumn/logging": ["@autumn/logging@workspace:packages/logging"],
"@autumn/mcp": ["@autumn/mcp@workspace:packages/mcp"],
"@autumn/openapi": ["@autumn/openapi@workspace:packages/openapi"],
@@ -6000,6 +6017,10 @@
"@autumn/leaf/@types/node": ["@types/node@25.8.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ=="],
"@autumn/logging/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"@autumn/logging/typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"@autumn/mcp/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"@autumn/mcp/typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
@@ -7770,6 +7791,8 @@
"@autumn/leaf/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"@autumn/logging/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"@autumn/mcp/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"@autumn/server/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],

View File

@@ -29,6 +29,7 @@ COPY packages/atmn/package.json packages/atmn/
COPY packages/atmn-tests/package.json packages/atmn-tests/
COPY packages/autumn-js/package.json packages/autumn-js/
COPY packages/ksuid/package.json packages/ksuid/
COPY packages/logging/package.json packages/logging/
COPY packages/mcp/package.json packages/mcp/
COPY packages/openapi/package.json packages/openapi/
COPY packages/sdk/package.json packages/sdk/

View File

@@ -1,45 +0,0 @@
# Multi-stage Dockerfile for Autumn development
FROM oven/bun:latest AS base
WORKDIR /app
# Skip Puppeteer Chromium download to speed up install
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
ENV PUPPETEER_SKIP_DOWNLOAD=true
COPY package.json ./
COPY bun.lock ./
COPY shared/package*.json ./shared/
COPY server/package*.json ./server/
COPY vite/package*.json ./vite/
RUN bun install
# Stage 1: /localtunnel
FROM base AS localtunnel
WORKDIR /app
COPY localtunnel-start.sh ./
CMD ["sh", "localtunnel-start.sh"]
# Stage 2: /vite
FROM base AS vite
COPY shared/ ./shared/
WORKDIR /app/vite
COPY vite/ ./
EXPOSE 3000
CMD ["bun", "dev"]
# Stage 3: /server
FROM base AS server
COPY shared/ ./shared/
COPY server/ ./server/
WORKDIR /app/server
EXPOSE 8080
CMD ["bun", "dev"]
# Stage 4: Workers
FROM base AS workers
COPY shared/ ./shared/
COPY server/ ./server/
WORKDIR /app/server
CMD ["bun", "workers:dev"]

View File

@@ -15,6 +15,7 @@
"apps/sdk-test",
"packages/atmn",
"packages/atmn-tests",
"packages/logging",
"packages/mcp",
"packages/sdk",
"packages/autumn-js",
@@ -113,6 +114,9 @@
"tb": "bun scripts/tinybird/index.ts",
"tb:prod": "bun scripts/tinybird/index.ts prod",
"tb:prod-legacy": "bun scripts/tinybird/index.ts prod-legacy",
"axiom": "ENV_FILE=.env infisical run --env=dev --recursive -- bun scripts/axiom/cli.ts",
"axiom:prod": "ENV_FILE=.env.prod infisical run --env=prod --recursive -- bun scripts/axiom/cli.ts",
"add-mcp": "bun scripts/mcp/addMcp.ts",
"trigger:deploy": "bunx trigger.dev deploy",
"setupci": "node scripts/setup/setupci.js",
"replicate": "bun scripts/db/replicate.ts",

View File

@@ -1,9 +1,10 @@
// OAuth constants for CLI authentication
/** The OAuth client ID for the CLI (public client) */
// export const CLI_CLIENT_ID = "khicXGthBbGMIWmpgodOTDcCCJHJMDpN"; (local i think)
// export const CLI_CLIENT_ID = "NiKwaSyAfaeEEKEvFaUYihTXdTPtIRCk" (dev i think)
export const CLI_CLIENT_ID = "hAWUopQqLnsSwuRgeRzIBzKslwXmQUSr"; // (prod i think)
// Historical Better Auth OAuth clients for atmn CLI environments.
// Server auth should identify atmn from oauth_client metadata/name instead.
export const LOCAL_CLI_CLIENT_ID = "khicXGthBbGMIWmpgodOTDcCCJHJMDpN";
export const DEV_CLI_CLIENT_ID = "NiKwaSyAfaeEEKEvFaUYihTXdTPtIRCk";
export const CLI_CLIENT_ID = "hAWUopQqLnsSwuRgeRzIBzKslwXmQUSr";
/** Base port for the local OAuth callback server */
export const OAUTH_PORT_BASE = 31448;

View File

@@ -0,0 +1,30 @@
{
"name": "@autumn/logging",
"version": "0.0.1",
"author": "Autumn",
"type": "module",
"sideEffects": false,
"exports": {
".": "./src/index.ts"
},
"files": [
"README.md",
"src"
],
"scripts": {
"build": "tsc",
"ts": "tsc --noEmit",
"test": "bun test tests/unit",
"prepack": "bun run build",
"prepublishOnly": "bun run build"
},
"dependencies": {
"@axiomhq/pino": "^1.3.1",
"pino": "^9.6.0"
},
"devDependencies": {
"@types/bun": "^1.2.13",
"@types/node": "^18.19.3",
"typescript": "~5.8.3"
}
}

View File

@@ -0,0 +1,38 @@
import type { AutumnLogger } from "../types.js";
import type {
LogAppContext,
LogRequestContext,
LogTriggerContext,
} from "./types.js";
export const addRequestToLogs = ({
logger,
requestContext,
}: {
logger: AutumnLogger;
requestContext: LogRequestContext;
}): AutumnLogger => logger.child({ context: { req: requestContext } });
export const addAppContextToLogs = ({
logger,
appContext,
}: {
logger: AutumnLogger;
appContext: LogAppContext;
}): AutumnLogger => logger.child({ context: { context: appContext } });
export const addTriggerToLogs = ({
logger,
triggerContext,
}: {
logger: AutumnLogger;
triggerContext: LogTriggerContext;
}): AutumnLogger => logger.child({ context: { trigger: triggerContext } });
export const addExtrasToLogs = ({
logger,
extras,
}: {
logger: AutumnLogger;
extras: Record<string, unknown>;
}): AutumnLogger => logger.child({ context: { extras } });

View File

@@ -0,0 +1,35 @@
export type LogRequestContext = {
id: string;
method: string;
url: string;
timestamp: number;
customer_id?: string;
entity_id?: string;
user_agent?: string;
ip_address?: string;
region?: string;
query: Record<string, string>;
body: unknown;
name: string;
};
export type LogAppContext = {
org_id?: string;
org_slug?: string;
env?: string;
auth_type?: string;
customer_id?: string;
entity_id?: string;
user_id?: string;
user_email?: string;
api_version?: string;
scopes?: string[];
full_subject_bucket?: number;
full_subject_rollout_enabled?: boolean;
};
export type LogTriggerContext = {
run_id: string;
task_id: string;
attempt_number?: number;
};

View File

@@ -0,0 +1,21 @@
import { createHash } from "node:crypto";
const stableStringify = ({ value }: { value: unknown }): string => {
if (!value || typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value))
return `[${value.map((item) => stableStringify({ value: item })).join(",")}]`;
return `{${Object.entries(value)
.sort(([a], [b]) => a.localeCompare(b))
.map(
([key, item]) =>
`${JSON.stringify(key)}:${stableStringify({ value: item })}`,
)
.join(",")}}`;
};
export const createSessionId = ({ parts }: { parts: unknown }): string =>
createHash("sha256")
.update(stableStringify({ value: parts }))
.digest("hex")
.slice(0, 24);

View File

@@ -0,0 +1,3 @@
import { randomUUID } from "node:crypto";
export const createTraceId = (): string => randomUUID();

View File

@@ -0,0 +1,40 @@
export {
addAppContextToLogs,
addExtrasToLogs,
addRequestToLogs,
addTriggerToLogs,
} from "./context/addContextToLogs.js";
export type {
LogAppContext,
LogRequestContext,
LogTriggerContext,
} from "./context/types.js";
export { createSessionId } from "./ids/createSessionId.js";
export { createTraceId } from "./ids/createTraceId.js";
export {
createAppLogger,
createAutumnLogger,
} from "./logger/autumnLogger.js";
export { createConsoleLogger } from "./logger/consoleLogger.js";
export { createLogger } from "./logger/createLogger.js";
export {
mirrorLogger,
withLogPrefix,
} from "./logger/loggerWrappers.js";
export { resolveLoggerOptions } from "./logger/resolveLoggerOptions.js";
export { asAxiomMap } from "./payload/asAxiomMap.js";
export {
type GuardLogPayloadOptions,
guardLogPayload,
} from "./payload/guardLogPayload.js";
export type {
AutumnLogger,
ConsoleLogger,
ConsoleLoggerLevel,
CreateLoggerParams,
LoggerLevel,
LoggerOutput,
LoggerPreset,
PinoLogger,
ResolvedLoggerOptions,
} from "./types.js";

View File

@@ -0,0 +1,69 @@
import type pino from "pino";
import type {
AutumnLogger,
ConsoleLoggerLevel,
CreateLoggerParams,
LogArgs,
} from "../types.js";
import { createLogger } from "./createLogger.js";
const rewriteAppPath = (value: string): string =>
value.replace("file:///app/", "./").replace(/\/app\//g, "./");
const errorToObject = (error: Error) => ({
name: error.name,
message: error.message,
stack: error.stack ? rewriteAppPath(error.stack) : undefined,
});
const normalizeLogArgs = ({ args }: { args: LogArgs }) => {
const strings = args
.filter((arg): arg is string => typeof arg === "string")
.map(rewriteAppPath);
const objects = args
.filter(
(arg) => typeof arg !== "string" && arg !== null && arg !== undefined,
)
.map((arg) => (arg instanceof Error ? { error: errorToObject(arg) } : arg));
const error = args.find((arg): arg is Error => arg instanceof Error);
const message =
strings.at(-1) ??
(error
? rewriteAppPath(error.stack || error.message || "Error occurred")
: "");
return {
message,
merged: Object.assign({}, ...objects) as Record<string, unknown>,
};
};
const createLogMethod =
({ method }: { method: pino.LogFn }) =>
(...args: LogArgs) => {
const { message, merged } = normalizeLogArgs({ args });
if (Object.keys(merged).length > 0) method(merged, message);
else method(message);
};
export const createAutumnLogger = ({
logger,
}: {
logger: pino.Logger;
}): AutumnLogger => ({
level: logger.level as ConsoleLoggerLevel,
debug: createLogMethod({ method: logger.debug.bind(logger) }),
info: createLogMethod({ method: logger.info.bind(logger) }),
warn: createLogMethod({ method: logger.warn.bind(logger) }),
warning: createLogMethod({ method: logger.warn.bind(logger) }),
error: createLogMethod({ method: logger.error.bind(logger) }),
child: ({ context, onlyProd = false }) => {
if (onlyProd && process.env.NODE_ENV !== "production") {
return createAutumnLogger({ logger });
}
return createAutumnLogger({ logger: logger.child(context) });
},
});
export const createAppLogger = (params: CreateLoggerParams): AutumnLogger =>
createAutumnLogger({ logger: createLogger(params) });

View File

@@ -0,0 +1,28 @@
import type { ConsoleLogger, ConsoleLoggerLevel, LogArgs } from "../types.js";
export const createConsoleLogger = ({
level,
}: {
level: ConsoleLoggerLevel;
}): ConsoleLogger => {
const levels: ConsoleLoggerLevel[] = ["debug", "info", "warning", "error"];
const min = levels.indexOf(level);
const noop = () => {};
const log =
({ method }: { method: "debug" | "info" | "warn" | "error" }) =>
(...args: LogArgs) => {
console[method](...args);
};
const logger: ConsoleLogger = {
level,
debug: min <= 0 ? log({ method: "debug" }) : noop,
info: min <= 1 ? log({ method: "info" }) : noop,
warn: min <= 2 ? log({ method: "warn" }) : noop,
warning: min <= 2 ? log({ method: "warn" }) : noop,
error: min <= 3 ? log({ method: "error" }) : noop,
child: () => logger,
};
return logger;
};

View File

@@ -0,0 +1,60 @@
import pino from "pino";
import { createConsoleJsonStream } from "../streams/consoleJsonStream.js";
import { createPrettyLogStream } from "../streams/prettyLogStream.js";
import type { CreateLoggerParams } from "../types.js";
import { resolveLoggerOptions } from "./resolveLoggerOptions.js";
export const createLogger = (params: CreateLoggerParams): pino.Logger => {
const resolved = resolveLoggerOptions({ options: params });
const axiomToken = params.axiomToken ?? process.env.AXIOM_TOKEN;
const axiomOrgId = params.axiomOrgId ?? process.env.AXIOM_ORG_ID;
const streams: pino.StreamEntry[] = [];
for (const output of resolved.outputs) {
if (output === "console-pretty") {
streams.push({
level: resolved.level,
stream: createPrettyLogStream({
trailingNewline: resolved.preset !== "dual",
useConsoleLog: params.useConsoleLog ?? resolved.preset === "dual",
}),
});
}
if (output === "console-json") {
streams.push({
level: resolved.level,
stream: createConsoleJsonStream(),
});
}
if (output === "axiom" && axiomToken) {
streams.push({
level: resolved.level,
stream: pino.transport({
target: "@axiomhq/pino",
options: {
dataset: resolved.dataset,
token: axiomToken,
orgId: axiomOrgId,
},
}),
});
}
}
return pino(
{
level: resolved.level,
base: {
service: resolved.service,
...(params.context ?? {}),
},
mixin: params.mixin,
formatters: {
level: (label: string) => ({ level: label.toUpperCase() }),
},
},
pino.multistream(streams),
);
};

View File

@@ -0,0 +1,71 @@
import type { AutumnLogger, LogArgs } from "../types.js";
const logToStdout = ({
level,
args,
}: {
level: "debug" | "info" | "warn" | "error";
args: LogArgs;
}) => {
const method =
level === "debug"
? console.debug
: level === "info"
? console.info
: level === "warn"
? console.warn
: console.error;
method(...args);
};
export const mirrorLogger = ({
logger,
}: {
logger: AutumnLogger;
}): AutumnLogger => ({
debug: (...args) => {
logger.debug(...args);
logToStdout({ level: "debug", args });
},
info: (...args) => {
logger.info(...args);
logToStdout({ level: "info", args });
},
warn: (...args) => {
logger.warn(...args);
logToStdout({ level: "warn", args });
},
warning: (...args) => {
logger.warn(...args);
logToStdout({ level: "warn", args });
},
error: (...args) => {
logger.error(...args);
logToStdout({ level: "error", args });
},
child: (params) => mirrorLogger({ logger: logger.child(params) }),
});
const prefixArgs = ({ prefix, args }: { prefix: string; args: LogArgs }) => {
if (typeof args[0] !== "string") return [prefix, ...args];
if (args[0].startsWith(prefix)) return args;
return [`${prefix} ${args[0]}`, ...args.slice(1)];
};
export const withLogPrefix = ({
logger,
label,
}: {
logger: AutumnLogger;
label: string;
}): AutumnLogger => {
const prefix = `[${label}]`;
return {
debug: (...args) => logger.debug(...prefixArgs({ prefix, args })),
info: (...args) => logger.info(...prefixArgs({ prefix, args })),
warn: (...args) => logger.warn(...prefixArgs({ prefix, args })),
warning: (...args) => logger.warn(...prefixArgs({ prefix, args })),
error: (...args) => logger.error(...prefixArgs({ prefix, args })),
child: (params) => withLogPrefix({ logger: logger.child(params), label }),
};
};

View File

@@ -0,0 +1,67 @@
import type {
CreateLoggerParams,
LoggerLevel,
LoggerOutput,
ResolvedLoggerOptions,
} from "../types.js";
const parseOutputs = (
value: string | undefined,
): LoggerOutput[] | undefined => {
if (!value) return undefined;
const outputs = value
.split(",")
.map((part) => part.trim())
.filter(Boolean);
if (
outputs.every(
(output): output is LoggerOutput =>
output === "console-pretty" ||
output === "console-json" ||
output === "axiom",
)
) {
return outputs;
}
return undefined;
};
export const resolveLoggerOptions = ({
options,
env = process.env,
}: {
options: CreateLoggerParams;
env?: NodeJS.ProcessEnv;
}): ResolvedLoggerOptions => {
const preset = options.preset ?? "default";
const isDevOrTest = env.NODE_ENV === "development" || env.NODE_ENV === "test";
const hasAxiomToken = Boolean(options.axiomToken ?? env.AXIOM_TOKEN);
let outputs = options.outputs ?? parseOutputs(env.LOG_OUTPUTS);
if (!outputs) {
if (preset === "console-only") outputs = ["console-pretty"];
else if (preset === "axiom-only") outputs = ["axiom"];
else if (preset === "dual")
outputs = [isDevOrTest ? "console-pretty" : "console-json", "axiom"];
else if (isDevOrTest) outputs = ["console-pretty", "axiom"];
else outputs = ["axiom"];
}
const filteredOutputs = outputs.filter(
(output) => output !== "axiom" || hasAxiomToken,
);
return {
service: options.service,
dataset: options.dataset ?? options.service,
preset,
level:
options.level ??
((env.LOG_LEVEL as LoggerLevel | undefined) ||
(isDevOrTest || preset === "dual" ? "debug" : "info")),
outputs: filteredOutputs.length > 0 ? filteredOutputs : ["console-pretty"],
hasAxiomToken,
};
};

View File

@@ -0,0 +1,8 @@
export const asAxiomMap = ({
value,
}: {
value: unknown;
}): Record<string, unknown> =>
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: { value };

View File

@@ -0,0 +1,147 @@
const defaultMaxPayloadBytes = 512_000;
const defaultTruncateAboveBytes = 4_000;
const defaultMaxArrayItems = 5;
const defaultMaxStringLength = 500;
const defaultMaxDepth = 6;
export type GuardLogPayloadOptions = {
maxPayloadBytes?: number;
truncateAboveBytes?: number;
maxArrayItems?: number;
maxStringLength?: number;
maxDepth?: number;
};
const envNumber = ({
value,
fallback,
}: {
value?: string;
fallback: number;
}) => {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
};
const resolveOptions = ({
options = {},
}: {
options?: GuardLogPayloadOptions;
}) => ({
maxPayloadBytes:
options.maxPayloadBytes ??
envNumber({
value: process.env.LOG_MAX_PAYLOAD_BYTES,
fallback: defaultMaxPayloadBytes,
}),
truncateAboveBytes:
options.truncateAboveBytes ??
envNumber({
value: process.env.LOG_TRUNCATE_ABOVE_BYTES,
fallback: defaultTruncateAboveBytes,
}),
maxArrayItems:
options.maxArrayItems ??
envNumber({
value: process.env.LOG_MAX_ARRAY_ITEMS,
fallback: defaultMaxArrayItems,
}),
maxStringLength:
options.maxStringLength ??
envNumber({
value: process.env.LOG_MAX_STRING_LENGTH,
fallback: defaultMaxStringLength,
}),
maxDepth: options.maxDepth ?? defaultMaxDepth,
});
type ResolvedGuardOptions = ReturnType<typeof resolveOptions>;
const truncateString = ({
value,
maxStringLength,
}: {
value: string;
maxStringLength: number;
}): string =>
value.length > maxStringLength
? `${value.slice(0, maxStringLength)}...[+${value.length - maxStringLength} chars]`
: value;
const truncateValue = ({
value,
options,
depth = 0,
}: {
value: unknown;
options: ResolvedGuardOptions;
depth?: number;
}): unknown => {
if (typeof value === "string")
return truncateString({
value,
maxStringLength: options.maxStringLength,
});
if (!value || typeof value !== "object") return value;
if (depth >= options.maxDepth) {
if (Array.isArray(value)) return `...[${value.length} items]`;
return "...[object]";
}
if (Array.isArray(value)) {
const kept = value.slice(0, options.maxArrayItems).map((item) =>
truncateValue({
value: item,
options,
depth: depth + 1,
}),
);
if (value.length > options.maxArrayItems) {
kept.push(`...[+${value.length - options.maxArrayItems} more items]`);
}
return kept;
}
if (value instanceof Error) {
return {
name: value.name,
message: value.message,
stack: value.stack,
};
}
const out: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value)) {
out[key] = truncateValue({
value: item,
options,
depth: depth + 1,
});
}
return out;
};
export const guardLogPayload = ({
value,
options: guardOptions,
}: {
value: unknown;
options?: GuardLogPayloadOptions;
}): unknown => {
if (value === undefined) return undefined;
const options = resolveOptions({ options: guardOptions });
try {
const json = JSON.stringify(value);
if (!json || json.length <= options.truncateAboveBytes) return value;
const truncated = truncateValue({ value, options });
const truncatedJson = JSON.stringify(truncated);
if (truncatedJson && truncatedJson.length > options.maxPayloadBytes) {
return { _truncated: true, _bytes: truncatedJson.length };
}
return truncated;
} catch {
return { _unserializable: true };
}
};

View File

@@ -0,0 +1,9 @@
import { Writable } from "node:stream";
export const createConsoleJsonStream = () =>
new Writable({
write(chunk, _encoding, callback) {
console.log(chunk.toString().trimEnd());
callback();
},
});

View File

@@ -0,0 +1,116 @@
import { Writable } from "node:stream";
const FORMATTED_LOG_EXCLUDE_FIELDS = new Set([
"time",
"level",
"msg",
"pid",
"hostname",
"req",
"res",
"statusCode",
"body",
"query",
"durationMs",
"duration_ms",
"context",
"workflow",
"trigger",
"stripe_event",
"vercel_event",
"worker",
"extras",
"type",
"data",
"aws",
"service",
]);
const colors = {
reset: "\x1b[0m",
bright: "\x1b[1m",
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
white: "\x1b[37m",
gray: "\x1b[90m",
bgRed: "\x1b[41m",
};
const levelColors: Record<number | string, string> = {
10: colors.gray,
20: colors.blue,
30: colors.green,
40: colors.yellow,
50: colors.red,
60: colors.bgRed,
TRACE: colors.gray,
DEBUG: colors.blue,
INFO: colors.green,
WARN: colors.yellow,
ERROR: colors.red,
FATAL: colors.bgRed,
};
const levelNames: Record<number | string, string> = {
10: "TRACE",
20: "DEBUG",
30: "INFO",
40: "WARN",
50: "ERROR",
60: "FATAL",
TRACE: "TRACE",
DEBUG: "DEBUG",
INFO: "INFO",
WARN: "WARN",
ERROR: "ERROR",
FATAL: "FATAL",
};
export const createPrettyLogStream = ({
trailingNewline = true,
useConsoleLog = false,
}: {
trailingNewline?: boolean;
useConsoleLog?: boolean;
} = {}) =>
new Writable({
write(chunk, _encoding, callback) {
try {
const log = JSON.parse(chunk.toString());
const timestamp = new Date(log.time)
.toISOString()
.replace("T", " ")
.replace("Z", "");
const level = log.level;
const levelColor = levelColors[level] || colors.white;
const levelName =
levelNames[level] || (typeof level === "string" ? level : "UNKNOWN");
let message = log.msg || "";
const additionalFields = Object.keys(log)
.filter((key) => !FORMATTED_LOG_EXCLUDE_FIELDS.has(key))
.reduce(
(acc, key) => {
acc[key] = log[key];
return acc;
},
{} as Record<string, unknown>,
);
if (Object.keys(additionalFields).length > 0) {
message += ` ${JSON.stringify(additionalFields, null, 2)}`;
}
const formattedLog = `${colors.gray}${timestamp}${colors.reset} ${levelColor}${colors.bright}${levelName}${colors.reset} ${message}${trailingNewline ? "\n" : ""}`;
if (useConsoleLog) console.log(formattedLog);
else process.stdout.write(formattedLog);
callback();
} catch {
if (useConsoleLog) console.log(chunk.toString());
else process.stdout.write(chunk);
callback();
}
},
});

View File

@@ -0,0 +1,56 @@
import type pino from "pino";
export type LoggerOutput = "console-pretty" | "console-json" | "axiom";
export type LoggerPreset = "default" | "dual" | "console-only" | "axiom-only";
export type LoggerLevel =
| "trace"
| "debug"
| "info"
| "warn"
| "error"
| "fatal";
export type CreateLoggerParams = {
service: string;
dataset?: string;
level?: LoggerLevel;
preset?: LoggerPreset;
outputs?: LoggerOutput[];
context?: Record<string, unknown>;
mixin?: () => Record<string, unknown>;
axiomToken?: string;
axiomOrgId?: string;
useConsoleLog?: boolean;
};
export type ResolvedLoggerOptions = Required<
Pick<CreateLoggerParams, "service" | "preset">
> & {
dataset: string;
level: LoggerLevel;
outputs: LoggerOutput[];
hasAxiomToken: boolean;
};
export type LogArgs = unknown[];
export type AutumnLogger = {
level?: string;
debug: (...args: LogArgs) => void;
info: (...args: LogArgs) => void;
warn: (...args: LogArgs) => void;
warning: (...args: LogArgs) => void;
error: (...args: LogArgs) => void;
child: (params: {
context: Record<string, unknown>;
onlyProd?: boolean;
}) => AutumnLogger;
};
export type ConsoleLoggerLevel = "debug" | "info" | "warning" | "error";
export type ConsoleLogger = AutumnLogger & {
level: ConsoleLoggerLevel;
};
export type PinoLogger = pino.Logger;

View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"allowJs": true,
"allowUnreachableCode": false,
"allowUnusedLabels": false,
"checkJs": true,
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"exactOptionalPropertyTypes": false,
"forceConsistentCasingInFileNames": true,
"incremental": false,
"isolatedModules": true,
"lib": ["es2024"],
"module": "Preserve",
"moduleResolution": "bundler",
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": false,
"noImplicitReturns": false,
"noPropertyAccessFromIndexSignature": false,
"noUncheckedIndexedAccess": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noEmit": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"target": "es2022",
"types": ["bun", "node"],
"useUnknownInCatchVariables": true
},
"exclude": ["node_modules"],
"include": ["src/**/*.ts", "tests/**/*.ts"]
}

View File

@@ -2,11 +2,10 @@
Mastra-backed MCP library for Autumn operations.
The hosted runtime lives in `apps/leaf` (see `src/mcp/http.ts`) and exposes two
Streamable HTTP MCP routes:
The hosted runtime lives in `apps/leaf` (see `src/mcp/http.ts`) and exposes a
Streamable HTTP MCP route:
- `/mcp` - public, API-shaped operational tools.
- `/internal/mcp` - internal Autumn agent tool.
## `/mcp`
@@ -31,19 +30,6 @@ The write tools are marked destructive. Clients should call the matching preview
tool first where one exists and only call a write tool after explicit user
confirmation.
## `/internal/mcp`
Use this for Autumn-controlled agent flows.
Tools:
- `ask_autumn({ message, context? })`
`ask_autumn` can look up customers/plans, inspect scoped Axiom logs when
available, preview billing changes, and apply confirmed billing writes. Billing
writes are preview-first: the server stores the pending action internally and
executes it only after a follow-up confirmation.
## Local
The routes are served by the `@autumn/leaf` app. From the repo root:
@@ -52,15 +38,13 @@ The routes are served by the `@autumn/leaf` app. From the repo root:
bun run leaf
```
This starts both MCP routes (on the leaf port, `3099` by default):
This starts the MCP route (on the leaf port, `3099` by default):
- `http://localhost:3099/mcp`
- `http://localhost:3099/internal/mcp`
OAuth metadata is route-aware:
- `http://localhost:3099/.well-known/oauth-protected-resource/mcp`
- `http://localhost:3099/.well-known/oauth-protected-resource/internal/mcp`
OAuth uses the Autumn Better Auth issuer from `--server-url`:
OAuth uses the Autumn Better Auth issuer from `MCP_SERVER_URL`:

View File

@@ -20,6 +20,7 @@
"prepublishOnly": "bun run build"
},
"dependencies": {
"@autumn/logging": "workspace:*",
"@autumn/shared": "workspace:*",
"@axiomhq/js": "^1.6.1",
"@mastra/core": "^1.36.0",

View File

@@ -1,4 +1,10 @@
import { createHash } from "node:crypto";
import {
makeScopeChecker,
type ScopeString,
Scopes,
} from "@autumn/shared/scopeDefinitions";
import { ms } from "@autumn/shared/unixUtils";
import { Axiom } from "@axiomhq/js";
import { createTool } from "@mastra/core/tools";
import {
@@ -9,18 +15,12 @@ import {
isValid,
parseISO,
} from "date-fns";
import {
makeScopeChecker,
Scopes,
type ScopeString,
} from "@autumn/shared/scopeDefinitions";
import { ms } from "@autumn/shared/unixUtils";
import * as z from "zod/v4";
import {
type AutumnMcpAuth,
createAutumnClient,
getAutumnAuth,
type AutumnMcpAuth,
} from "./auth.js";
} from "../server/auth/auth.js";
const axiomDataset = "express";
const defaultStartTime = "now-30m";
@@ -28,8 +28,10 @@ const defaultEndTime = "now";
const maxRangeMs = ms.days(7);
const searchMaxRangeMs = ms.hours(1);
type AutumnOrg = { id: string; slug?: string | undefined };
let axiomClient: Axiom | null = null;
const orgCache = new Map<string, { orgId: string; expiresAt: Date }>();
const orgCache = new Map<string, { org: AutumnOrg; expiresAt: Date }>();
const getAxiomClient = () => {
if (!process.env.AXIOM_ADMIN_TOKEN) {
@@ -78,14 +80,22 @@ const getRangeMs = (startTime: string, endTime: string) => {
};
const assertCanUseAxiom = (auth: AutumnMcpAuth) => {
if (!makeScopeChecker(auth.scopes).has(Scopes.Analytics.Read as ScopeString)) {
if (
!makeScopeChecker(auth.scopes).has(Scopes.Analytics.Read as ScopeString)
) {
throw new Error("analytics:read scope is required to query Axiom logs.");
}
};
export const resolveAutumnOrgId = async (auth: AutumnMcpAuth) => {
if (auth.orgId) return auth.orgId;
/**
* Resolves the Autumn org (id + slug) for an authenticated request. Cached
* (~5min) per credential. Unlike `resolveAutumnOrgId`, this always hits
* `/v1/organization` when uncached so the slug is available the id alone may
* already be on `auth`, but the slug never is.
*/
export const resolveAutumnOrg = async (
auth: AutumnMcpAuth,
): Promise<AutumnOrg> => {
const cacheKey = [
auth.serverURL ?? "https://api.useautumn.com",
auth.env,
@@ -94,7 +104,7 @@ export const resolveAutumnOrgId = async (auth: AutumnMcpAuth) => {
String(auth.failOpen),
].join(":");
const cached = orgCache.get(cacheKey);
if (cached && isFuture(cached.expiresAt)) return cached.orgId;
if (cached && isFuture(cached.expiresAt)) return cached.org;
const client = createAutumnClient(auth);
const response = await fetch(new URL("/v1/organization", client.baseUrl), {
@@ -105,17 +115,26 @@ export const resolveAutumnOrgId = async (auth: AutumnMcpAuth) => {
throw new Error("Could not resolve Autumn organization for MCP request.");
}
const body = (await response.json()) as { id?: unknown };
const body = (await response.json()) as { id?: unknown; slug?: unknown };
if (typeof body.id !== "string" || !body.id) {
throw new Error("Autumn organization response did not include an id.");
}
const org: AutumnOrg = {
id: body.id,
slug: typeof body.slug === "string" ? body.slug : undefined,
};
orgCache.set(cacheKey, {
orgId: body.id,
org,
expiresAt: addMilliseconds(new Date(), ms.minutes(5)),
});
return body.id;
return org;
};
export const resolveAutumnOrgId = async (auth: AutumnMcpAuth) => {
if (auth.orgId) return auth.orgId;
return (await resolveAutumnOrg(auth)).id;
};
export const prepareAxiomQuery = ({
@@ -133,7 +152,9 @@ export const prepareAxiomQuery = ({
const rangeMs = getRangeMs(startTime, endTime);
if (rangeMs === null || rangeMs <= 0 || rangeMs > maxRangeMs) {
throw new Error("Axiom queries must use a bounded time range of at most 7 days.");
throw new Error(
"Axiom queries must use a bounded time range of at most 7 days.",
);
}
const trimmed = apl.trim();
@@ -152,7 +173,9 @@ export const prepareAxiomQuery = ({
}
if (/\|\s*\[\s*['"][^'"]+['"]\s*\](?=\s*(?:\||$))/i.test(rest)) {
throw new Error("Axiom queries may only use the express dataset source once.");
throw new Error(
"Axiom queries may only use the express dataset source once.",
);
}
if (/\bsearch\b/i.test(rest) && rangeMs > searchMaxRangeMs) {
@@ -165,7 +188,9 @@ export const prepareAxiomQuery = ({
`| where ['context.org_id'] == '${escapeAplString(auth.orgId)}'`,
`| where ['context.env'] == '${escapeAplString(auth.env)}'`,
rest,
].filter(Boolean).join("\n"),
]
.filter(Boolean)
.join("\n"),
startTime,
endTime,
};
@@ -180,11 +205,13 @@ export const createAxiomTools = () => ({
id: "queryAxiomLogs",
description:
"Run a read-only Axiom APL query against Autumn logs. The query is always constrained to the authenticated Autumn org and environment.",
inputSchema: z.object({
apl: z.string().min(1),
startTime: z.string().optional(),
endTime: z.string().optional(),
}).strict(),
inputSchema: z
.object({
apl: z.string().min(1),
startTime: z.string().optional(),
endTime: z.string().optional(),
})
.strict(),
execute: async ({ apl, startTime, endTime }, context) => {
const auth = await withAxiomOrg(getAutumnAuth(context));
const query = prepareAxiomQuery({ auth, apl, startTime, endTime });
@@ -198,9 +225,11 @@ export const createAxiomTools = () => ({
id: "getAxiomDatasetFields",
description:
"List available Axiom field metadata for the express dataset, scoped to the authenticated Autumn org and environment.",
inputSchema: z.object({
dataset: z.literal(axiomDataset),
}).strict(),
inputSchema: z
.object({
dataset: z.literal(axiomDataset),
})
.strict(),
execute: async ({ dataset }, context) => {
const auth = await withAxiomOrg(getAutumnAuth(context));
const query = prepareAxiomQuery({

View File

@@ -2,7 +2,7 @@ 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 "./auth.js";
import type { AutumnMcpAuth } from "../server/auth/auth.js";
export type BillingToolName =
| "attach"
@@ -89,7 +89,7 @@ const getRedis = (): PendingActionRedis => {
};
const parseStoredAction = (value: string | null) =>
(value ? (JSON.parse(value) as PendingBillingAction) : null);
value ? (JSON.parse(value) as PendingBillingAction) : null;
const createAction = ({
auth,
@@ -150,7 +150,11 @@ export const claimLatestPendingAction = async (auth: AutumnMcpAuth) => {
if (!token || !action || isExpired(action)) {
logPendingAction("claim-miss", {
backend: "redis",
reason: !token ? "missing_latest" : !action ? "missing_action" : "expired",
reason: !token
? "missing_latest"
: !action
? "missing_action"
: "expired",
token: token ? shortHash(token) : null,
...actionDebug(auth),
});

View File

@@ -0,0 +1,37 @@
import type { AnalyticsSink } from "./analyticsTypes.js";
import { createLoggerAnalyticsSink } from "./loggerSink.js";
const DEFAULT_DATASET = "leaf";
const noopSink: AnalyticsSink = {
emit() {},
flush: async () => {},
};
let cachedSink: AnalyticsSink | null | undefined;
let overrideSink: AnalyticsSink | null | undefined;
/**
* Override the analytics sink (tests, or wiring a pino/OTEL sink from the host
* app). Pass `null` to disable. Pass `undefined` to fall back to env defaults.
*/
export const setAnalyticsSink = (sink: AnalyticsSink | null | undefined) => {
overrideSink = sink;
if (sink !== undefined) cachedSink = undefined;
};
export const getAnalyticsSink = (): AnalyticsSink => {
if (overrideSink !== undefined) return overrideSink ?? noopSink;
if (cachedSink === undefined) {
cachedSink = createLoggerAnalyticsSink({
token: process.env.AXIOM_TOKEN,
orgId: process.env.AXIOM_ORG_ID,
dataset: process.env.MCP_ANALYTICS_DATASET ?? DEFAULT_DATASET,
});
}
return cachedSink ?? noopSink;
};
/** True when a real sink is configured — lets callers skip hot-path work. */
export const isAnalyticsEnabled = (): boolean =>
getAnalyticsSink() !== noopSink;

View File

@@ -0,0 +1,54 @@
/**
* Where a tool call originated:
* - `mcp` — an external MCP client hitting our hosted server (e.g. Claude
* Code, Cursor). The #1 usage-analytics target.
* - `agent` — our own Autumn Ops agent (e.g. Slack) invoking tools
* internally. Drives agent reliability / failure detection.
*/
export type McpAnalyticsSurface = "mcp" | "agent";
/**
* Org/auth context for a tool call. Mirrors the server's `context.*` log shape
* (see server/src/utils/logging) so MCP analytics and agent logs unify cleanly.
*/
export type McpAnalyticsContext = {
/** Autumn org id. Resolved lazily; may be absent if resolution fails. */
orgId?: string | undefined;
/** Autumn org slug. Resolved lazily; may be absent if resolution fails. */
orgSlug?: string | undefined;
env: string;
scopes?: string[] | undefined;
};
export type McpAnalyticsEvent = {
event: "mcp.tool_call";
surface: McpAnalyticsSurface;
tool: string;
/** One-sentence statement of what the caller is trying to do. */
intent?: string | undefined;
status: "ok" | "error";
durationMs: number;
principalId: string;
/** HTTP User-Agent of the calling MCP client. Absent for `agent` surface. */
client?: string | undefined;
/** MCP transport session id, or fallback hash(principal + client + window). */
sessionId: string;
context: McpAnalyticsContext;
/** Tool request payload (stored as an Axiom map field). */
input?: unknown;
/** Tool result payload (stored as an Axiom map field). */
output?: unknown;
error?: string | undefined;
};
/**
* Pluggable destination for analytics events. Implementations must be
* non-blocking: `emit` runs on the hot path of every tool call and must never
* throw or await network I/O inline. Swap this (pino/Axiom, an OTEL exporter,
* a test spy) without touching the instrumentation layer.
*/
export interface AnalyticsSink {
emit(event: McpAnalyticsEvent): void;
/** Drain any buffered events. Call on graceful shutdown. */
flush(): Promise<void>;
}

View File

@@ -0,0 +1,78 @@
import { resolveAutumnOrg } from "../agent/axiom.js";
import type { AutumnMcpAuth } from "../server/auth/auth.js";
import { getAnalyticsSink } from "./analyticsSink.js";
import type { McpAnalyticsSurface } from "./analyticsTypes.js";
import { deriveSessionId } from "./sessionId.js";
/**
* Builds and dispatches a single tool-call analytics event. Org resolution and
* the actual sink write run off the hot path so the tool response is never
* delayed by analytics.
*/
export const emitMcpToolEvent = ({
surface,
toolId,
auth,
client,
transportSessionId,
intent,
status,
durationMs,
input,
output,
error,
}: {
surface: McpAnalyticsSurface;
toolId: string;
auth: AutumnMcpAuth;
client: string | undefined;
transportSessionId?: string | undefined;
intent?: string | undefined;
status: "ok" | "error";
durationMs: number;
input?: unknown;
output?: unknown;
error?: string | undefined;
}) => {
const sink = getAnalyticsSink();
// Resolve org off the hot path; resolveAutumnOrg is cached (~5min).
void (async () => {
let orgId = auth.orgId;
let orgSlug: string | undefined;
try {
const org = await resolveAutumnOrg(auth);
orgId = org.id;
orgSlug = org.slug;
} catch {
// Best-effort: emit without org context rather than dropping the event.
}
const now = Date.now();
sink.emit({
event: "mcp.tool_call",
surface,
tool: toolId,
intent,
status,
durationMs,
principalId: auth.principalId,
client,
sessionId:
transportSessionId ??
deriveSessionId({
principalId: auth.principalId,
client,
now,
}),
context: {
orgId,
orgSlug,
env: auth.env,
scopes: auth.scopes,
},
input,
output,
error,
});
})();
};

View File

@@ -0,0 +1,15 @@
export {
getAnalyticsSink,
isAnalyticsEnabled,
setAnalyticsSink,
} from "./analyticsSink.js";
export type {
AnalyticsSink,
McpAnalyticsEvent,
McpAnalyticsSurface,
} from "./analyticsTypes.js";
export { instrumentToolsWithAnalytics } from "./instrumentTools.js";
export {
createAxiomAnalyticsSink,
createLoggerAnalyticsSink,
} from "./loggerSink.js";

View File

@@ -0,0 +1,115 @@
import type { createTool } from "@mastra/core/tools";
import { type AutumnMcpAuth, getAutumnAuth } from "../server/auth/auth.js";
import { getIntent } from "../tools/utils/intent.js";
import { isAnalyticsEnabled } from "./analyticsSink.js";
import type { McpAnalyticsSurface } from "./analyticsTypes.js";
import { emitMcpToolEvent } from "./emitToolEvent.js";
type AnyTool = ReturnType<typeof createTool>;
type ToolContext = Parameters<NonNullable<AnyTool["execute"]>>[1];
const getHeadersFromContext = (
context: ToolContext,
): Record<string, string | undefined> | undefined => {
const extra = (
context as {
mcp?: {
extra?: {
requestInfo?: { headers?: Record<string, string | undefined> };
};
};
}
)?.mcp?.extra;
return extra?.requestInfo?.headers;
};
const getHeader = (
headers: Record<string, string | undefined> | undefined,
name: string,
): string | undefined => {
const direct = headers?.[name] ?? headers?.[name.toLowerCase()];
if (direct) return direct;
const entry = Object.entries(headers ?? {}).find(
([key]) => key.toLowerCase() === name.toLowerCase(),
);
return entry?.[1];
};
const extractRequest = (input: unknown): unknown =>
input && typeof input === "object" && "request" in input
? (input as { request: unknown }).request
: input;
/**
* Wraps each tool's `execute` to emit a usage event per call. Auth/identity is
* read from the same MCP context the tools already use, so an unauthenticated
* call simply skips analytics (it would have failed in the tool anyway).
*
* Tools are wrapped once when the MCP server is created. The wrapper keeps no
* per-request mutable state; auth/session data is read from the execution
* context for each tool call.
*
* @param tools The toolset to instrument (mutated in place and returned).
* @param surface Origin of the calls — `mcp` (external clients) or `agent`
* (our own Autumn Ops agent).
*/
export const instrumentToolsWithAnalytics = <
T extends Record<string, AnyTool>,
>({
tools,
surface,
}: {
tools: T;
surface: McpAnalyticsSurface;
}): T => {
if (!isAnalyticsEnabled()) return tools;
for (const [toolId, tool] of Object.entries(tools)) {
const original = tool.execute;
if (!original) continue;
tool.execute = (async (input: unknown, context: ToolContext) => {
const started = Date.now();
let auth: AutumnMcpAuth | undefined;
try {
auth = getAutumnAuth(context);
} catch {
return original(input as never, context as never);
}
const headers = getHeadersFromContext(context);
const client = getHeader(headers, "user-agent");
const transportSessionId = getHeader(headers, "mcp-session-id");
const intent = getIntent(input);
try {
const output = await original(input as never, context as never);
emitMcpToolEvent({
surface,
toolId,
auth,
client,
transportSessionId,
intent,
status: "ok",
durationMs: Date.now() - started,
input: extractRequest(input),
output,
});
return output;
} catch (error) {
emitMcpToolEvent({
surface,
toolId,
auth,
client,
transportSessionId,
intent,
status: "error",
durationMs: Date.now() - started,
input: extractRequest(input),
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
}) as AnyTool["execute"];
}
return tools;
};

View File

@@ -0,0 +1,60 @@
import { asAxiomMap, createLogger, guardLogPayload } from "@autumn/logging";
import type { AnalyticsSink, McpAnalyticsEvent } from "./analyticsTypes.js";
const toLoggerRecord = (event: McpAnalyticsEvent) => ({
_time: new Date().toISOString(),
event: event.event,
surface: event.surface,
tool: event.tool,
intent: event.intent,
status: event.status,
duration_ms: event.durationMs,
principal_id: event.principalId,
client: event.client,
session_id: event.sessionId,
context: {
org_id: event.context.orgId,
org_slug: event.context.orgSlug,
env: event.context.env,
scopes: event.context.scopes,
},
input: asAxiomMap({ value: guardLogPayload({ value: event.input }) }),
output: asAxiomMap({ value: guardLogPayload({ value: event.output }) }),
error: event.error,
});
export const createLoggerAnalyticsSink = ({
token,
orgId,
dataset,
}: {
token?: string | undefined;
orgId?: string | undefined;
dataset: string;
}): AnalyticsSink | null => {
if (!token) return null;
const logger = createLogger({
service: "mcp",
dataset,
preset: "axiom-only",
outputs: ["axiom"],
axiomToken: token,
axiomOrgId: orgId,
});
return {
emit(event) {
logger.info(toLoggerRecord(event), "MCP tool call");
},
flush: async () => {
await new Promise<void>((resolve) => {
const flush = logger.flush;
if (typeof flush !== "function") return resolve();
flush.call(logger, () => resolve());
});
},
};
};
/** @deprecated Use createLoggerAnalyticsSink. */
export const createAxiomAnalyticsSink = createLoggerAnalyticsSink;

View File

@@ -0,0 +1,23 @@
import { createHash } from "node:crypto";
import { ms } from "@autumn/shared/unixUtils";
const sessionWindowMs = ms.minutes(30);
const hash = (value: string) =>
createHash("sha256").update(value).digest("hex").slice(0, 32);
/**
* Fallback session grouping. Stateful MCP clients send Mcp-Session-Id; when it
* is absent, synthesize a coarse principal/client bucket so calls from the same
* client within the window still collapse into one session.
*/
export const deriveSessionId = ({
principalId,
client,
now,
}: {
principalId: string;
client: string | undefined;
now: number;
}) =>
hash(`${principalId}|${client ?? ""}|${Math.floor(now / sessionWindowMs)}`);

View File

@@ -16,7 +16,8 @@ export type ConsoleLogger = Record<ConsoleLoggerLevel, LogMethod> & {
export function createConsoleLogger(level: ConsoleLoggerLevel): ConsoleLogger {
const min = consoleLoggerLevels.indexOf(level);
const noop = () => {};
const log = (method: "debug" | "info" | "warn" | "error"): LogMethod =>
const log =
(method: "debug" | "info" | "warn" | "error"): LogMethod =>
(message, data) => {
if (data) console[method](message, data);
else console[method](message);

View File

@@ -0,0 +1,18 @@
import type { ScopeString } from "@autumn/shared/scopeDefinitions";
import { Scopes } from "@autumn/shared/scopeDefinitions";
/** Shared defaults for talking to the Autumn API from the MCP server. */
export const DEFAULT_AUTUMN_API_URL = "https://api.useautumn.com";
export const DEFAULT_API_VERSION = "2.3.0";
/** Scopes requested when exchanging an OAuth token for an Autumn API key. */
export const MCP_OAUTH_SCOPES = [
Scopes.Customers.Read,
Scopes.Customers.Write,
Scopes.Plans.Read,
Scopes.Plans.Write,
Scopes.Billing.Read,
Scopes.Billing.Write,
Scopes.Balances.Write,
Scopes.Analytics.Read,
] as const satisfies readonly ScopeString[];

View File

@@ -1,19 +1,24 @@
export {
createAskAutumnMCPServer,
createAutumnOperationsMCPServer,
createMCPServer,
} from "./mcp-server/agent/server.js";
type AnalyticsSink,
createAxiomAnalyticsSink,
getAnalyticsSink,
isAnalyticsEnabled,
type McpAnalyticsEvent,
type McpAnalyticsSurface,
setAnalyticsSink,
} from "./analytics/index.js";
export {
type ConsoleLogger,
type ConsoleLoggerLevel,
consoleLoggerLevels,
createConsoleLogger,
} from "./mcp-server/console-logger.js";
export type { MCPServerFlags } from "./mcp-server/flags.js";
} from "./console-logger.js";
export {
buildAuthForRequest,
getAuthorizationServerMetadata,
getProtectedResourceMetadata,
type OAuthEnvironment,
OAuthHttpError,
} from "./mcp-server/oauth.js";
} from "./server/auth/oauth.js";
export type { MCPServerFlags } from "./server/flags.js";
export { createAutumnOperationsMCPServer } from "./server/server.js";

View File

@@ -1,124 +0,0 @@
import { Agent } from "@mastra/core/agent";
import { createTool } from "@mastra/core/tools";
import * as z from "zod/v4";
import {
type AutumnMcpAuth,
createRequestContext,
getAutumnAuth,
} from "./auth.js";
import { getLatestPendingAction } from "./pending-actions.js";
import { createAgentAutumnOperationTools } from "./tools.js";
const model = "anthropic/claude-sonnet-4-6";
const instructions = `You are Autumn's operational billing assistant.
Use Autumn tools for customer, plan, and billing work.
Use Axiom tools only for read-only investigation of Autumn logs.
Rules:
- Read requests can be answered directly.
- For plan-attribute queries, call listPlans first and filter returned plans locally.
- For customer-heavy queries, push filters into listCustomers and paginate for complete results.
- For customer lookup, use listCustomers first when the id/email/name is ambiguous.
- For plan lookup, use listPlans first when the plan is ambiguous.
- Avoid getCustomer fan-out unless listCustomers is missing details required by the user.
- For customer creation, use createCustomer only when the user explicitly asks to create or pre-create a customer.
- For plan creation, gather plan id, name, price, items/features, trials, and add-on/default behavior before calling createPlan.
- For standalone credit or balance grants, use previewCreateBalance before createBalance. Use entity_id for entity-scoped grants, included_grant for the granted amount, expires_at in milliseconds for expiring grants, and omit reset when using expires_at.
- For multi-phase billing schedules, gather customer, optional entity, ordered phase start times, and phase plans before calling previewCreateSchedule.
- Use dateToEpochMilliseconds to convert user-facing dates into epoch milliseconds before calling tools with starts_at or expires_at fields; if a named timezone matters, ask for or use an explicit offset.
- If a fee schedule says year 1 is already paid or has no billing changes, do not add an immediate/year-1 phase; start the schedule at the first future billing change.
- For custom consumable grants, map "per month/year" to customize.items[].reset.interval. Omit reset only for unlimited, non-consumable, or clearly one-time grants.
- For billing changes, call previewAttach or previewUpdateSubscription first. These preview tools automatically create the pending billing action.
- previewCreateSchedule stores the pending createSchedule write; after it returns pending, ask the user to confirm the exact schedule before applying it.
- previewCreateBalance stores the pending createBalance write; after it returns pending, ask the user to confirm the exact balance grant before applying it.
- createPlan stores a pending write; after it returns pending, ask the user to confirm the exact plan configuration before applying it.
- Never expose internal ids or server bookkeeping details.
- After a billing preview, tell the user to explicitly apply or approve the exact previewed change.
- If the user semantically confirms, applies, or approves a billing preview, call confirmBillingAction even if the preview is not visible in the current message. The tool validates whether a pending action exists.
- Never claim a billing write has been applied unless confirmBillingAction succeeds.
- If customer, plan, entity, subscription, or environment is ambiguous, ask a short clarifying question.
- Keep responses concise. Use JSON only when it materially helps debugging.`;
// To be added when we add axiom:
// - For log investigations, start with narrow structured fields such as context.customer_id, context.org_slug, req.url, req.id, stripe_event.id, stripe_event.type, workflow.id, or workflow.name.
// - For wide log windows, use a cheap aggregate query first, then focused <= 1 hour queries. Prefer ERROR/WARN levels first.
// - Axiom queries are already scoped to the authenticated org and environment; do not add or mention separate org filters unless useful to explain the investigation.
// - Axiom tools are read-only and must never be used as part of a billing confirmation or write flow.
const createAgent = () =>
new Agent({
id: "autumn-ops",
name: "Autumn Ops",
description:
"Answers Autumn customer, plan, and billing questions using controlled Autumn operations.",
instructions,
model,
tools: createAgentAutumnOperationTools(),
});
const getAuth = (
toolContext: Parameters<
NonNullable<ReturnType<typeof createTool>["execute"]>
>[1],
defaultAuth?: AutumnMcpAuth,
) => {
try {
return getAutumnAuth(toolContext);
} catch (error) {
if (defaultAuth) return defaultAuth;
throw error;
}
};
const getPendingAction = async (auth: AutumnMcpAuth) => {
try {
return await getLatestPendingAction(auth);
} catch {
return null;
}
};
export const createAskAutumnTool = (defaultAuth?: AutumnMcpAuth) =>
createTool({
id: "ask_autumn",
description:
"Ask Autumn to look up customers/plans or safely preview and confirm billing changes.",
inputSchema: z.object({
message: z.string().min(1),
context: z.record(z.string(), z.unknown()).optional(),
}),
mcp: {
annotations: {
title: "Ask Autumn",
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
openWorldHint: false,
},
},
execute: async ({ message, context }, toolContext) => {
const auth = getAuth(toolContext, defaultAuth);
const pendingAction = await getPendingAction(auth);
const contextText = context
? `\n\nCaller context:\n${JSON.stringify(context, null, 2)}`
: "";
const pendingText = pendingAction
? `\n\nPending billing action:\nTool: ${pendingAction.toolName}\nPreview: ${pendingAction.preview}\nIf the user confirms this preview, call confirmBillingAction.`
: "";
const output = await createAgent().generate(message, {
maxSteps: 8,
requestContext: createRequestContext(auth),
context: [
{
role: "system",
content: `Current Autumn environment: ${auth.env}.${pendingText}${contextText}`,
},
],
});
return output.text;
},
});
export const askAutumnTool = createAskAutumnTool();

View File

@@ -1,53 +0,0 @@
import { createHash } from "node:crypto";
import { RequestContext } from "@mastra/core/request-context";
import type { ToolExecutionContext } from "@mastra/core/tools";
import type { OAuthEnvironment } from "../oauth.js";
export type AutumnMcpAuth = {
apiKey: string;
env: OAuthEnvironment;
principalId: string;
resource: string;
scopes: string[];
orgId?: string | undefined;
serverURL?: string | undefined;
xApiVersion?: string | undefined;
failOpen?: boolean | undefined;
};
type MaybeToolContext = Pick<ToolExecutionContext, "mcp" | "requestContext">;
const hash = (value: string) =>
createHash("sha256").update(value).digest("hex").slice(0, 32);
export const principalFromSecret = (kind: string, value: string) =>
`${kind}:${hash(value)}`;
export const createAutumnClient = (auth: AutumnMcpAuth) => ({
baseUrl: auth.serverURL ?? "https://api.useautumn.com",
headers: {
Authorization: `Bearer ${auth.apiKey}`,
"Content-Type": "application/json",
Accept: "application/json",
"x-api-version": auth.xApiVersion ?? "2.3.0",
...(auth.failOpen === undefined
? {}
: { "fail-open": String(auth.failOpen) }),
},
});
export const getAutumnAuth = (context?: MaybeToolContext): AutumnMcpAuth => {
const direct = context?.mcp?.extra?.authInfo as AutumnMcpAuth | undefined;
const nested = context?.requestContext?.get?.("mcp.extra") as
| { authInfo?: AutumnMcpAuth }
| undefined;
const auth = direct ?? nested?.authInfo;
if (!auth?.apiKey) throw new Error("Autumn MCP authentication is required.");
return auth;
};
export const createRequestContext = (auth: AutumnMcpAuth) => {
const requestContext = new RequestContext();
requestContext.set("mcp.extra", { authInfo: auth });
return requestContext;
};

View File

@@ -1,36 +0,0 @@
import { MCPServer } from "@mastra/mcp";
import { createAskAutumnTool } from "./ask-autumn.js";
import type { AutumnMcpAuth } from "./auth.js";
import { autumnMcpResources } from "./resources.js";
import { createRawAutumnOperationTools } from "./tools.js";
export const createAskAutumnMCPServer = (_opts?: {
defaultAuth?: AutumnMcpAuth;
}) =>
new MCPServer({
id: "autumn-internal-mcp",
name: "Autumn Internal MCP",
version: "0.0.1",
description:
"Ask Autumn to safely operate on customers, plans, and billing.",
instructions:
"Use ask_autumn for all Autumn work. Billing writes require preview and explicit user confirmation.",
tools: {
ask_autumn: createAskAutumnTool(_opts?.defaultAuth),
},
resources: autumnMcpResources,
});
export const createAutumnOperationsMCPServer = () =>
new MCPServer({
id: "autumn-mcp",
name: "Autumn MCP",
version: "0.0.1",
description: "Operate on Autumn customers, plans, and billing.",
instructions:
"Use preview tools before billing writes. Write tools are destructive and should only be called after explicit user confirmation.",
tools: createRawAutumnOperationTools(),
resources: autumnMcpResources,
});
export const createMCPServer = createAskAutumnMCPServer;

View File

@@ -1,554 +0,0 @@
import {
AttachParamsV1Schema,
CreateBalanceParamsV0Schema,
CreateCustomerParamsV1Schema,
CreatePlanParamsV2Schema,
CreateSchedulePhaseSchema,
CreateScheduleParamsV0Schema,
GetCustomerParamsV1Schema,
GetPlanParamsV0Schema,
ListCustomersV2_3ParamsSchema,
ListPlanParamsSchema,
UpdateSubscriptionV1ParamsSchema,
} from "@autumn/shared/publicApiSchemas";
import { createTool } from "@mastra/core/tools";
import { isValid, parseISO } from "date-fns";
import * as z from "zod/v4";
import { createAutumnClient, getAutumnAuth } from "./auth.js";
import {
claimLatestPendingAction,
createPendingAction,
} from "./pending-actions.js";
type ToolContext = Parameters<
NonNullable<ReturnType<typeof createTool>["execute"]>
>[1];
type ConfirmedWriteToolName =
| "attach"
| "updateSubscription"
| "createPlan"
| "createSchedule"
| "createBalance";
type OperationToolConfig = {
id: string;
description: string;
schema: z.ZodType;
endpoint: string;
destructive?: boolean;
idempotent?: boolean;
};
type BillingPreviewToolConfig = {
id: string;
description: string;
schema: z.ZodType;
previewEndpoint: string;
writeToolName: ConfirmedWriteToolName;
};
type LocalPreviewToolConfig = {
id: string;
description: string;
schema: z.ZodType;
writeToolName: ConfirmedWriteToolName;
preview: (request: unknown) => unknown;
};
export const endpointByTool = {
listCustomers: "/v1/customers.list",
createCustomer: "/v1/customers.get_or_create",
getCustomer: "/v1/customers.get",
listPlans: "/v1/plans.list",
createPlan: "/v1/plans.create",
getPlan: "/v1/plans.get",
previewAttach: "/v1/billing.preview_attach",
attach: "/v1/billing.attach",
previewUpdateSubscription: "/v1/billing.preview_update",
updateSubscription: "/v1/billing.update",
previewCreateSchedule: "/v1/billing.preview_create_schedule",
createSchedule: "/v1/billing.create_schedule",
createBalance: "/v1/balances.create",
} as const;
const epochMillisecondsSchema = z
.union([z.number(), z.string()])
.transform((value, context) => {
if (typeof value === "number") {
if (Number.isFinite(value)) return value;
} else {
const normalized = /^\d{4}-\d{2}-\d{2}$/.test(value)
? `${value}T00:00:00.000Z`
: value;
const hasExplicitZone = /(?:z|[+-]\d{2}:?\d{2})$/i.test(normalized);
const parsed = parseISO(hasExplicitZone ? normalized : `${normalized}Z`);
if (isValid(parsed)) return parsed.getTime();
}
context.addIssue({
code: "custom",
message:
"Expected epoch milliseconds or an ISO date/timestamp string.",
});
return z.NEVER;
});
const createSchedulePhaseMcpSchema = CreateSchedulePhaseSchema.extend({
starts_at: epochMillisecondsSchema.meta({
description:
"Phase start time as epoch milliseconds or an ISO date string. Date-only values use midnight UTC.",
}),
});
const createScheduleMcpSchema = CreateScheduleParamsV0Schema.extend({
phases: z.tuple([createSchedulePhaseMcpSchema]).rest(
createSchedulePhaseMcpSchema,
),
});
const createBalanceMcpSchema = CreateBalanceParamsV0Schema.extend({
expires_at: epochMillisecondsSchema.optional().meta({
description:
"Expiry time as epoch milliseconds or an ISO date string. Date-only values use midnight UTC.",
}),
});
const listCustomersMcpSchema = ListCustomersV2_3ParamsSchema.extend({
limit: z
.preprocess(
(value) => (typeof value === "number" && value > 1000 ? 1000 : value),
z.number().int().positive().max(1000).optional(),
)
.meta({ description: "Maximum customers per page. Max 1000." }),
});
const writeSchemaByTool = {
attach: AttachParamsV1Schema,
updateSubscription: UpdateSubscriptionV1ParamsSchema,
createPlan: CreatePlanParamsV2Schema,
createSchedule: createScheduleMcpSchema,
createBalance: createBalanceMcpSchema,
} as const satisfies Record<ConfirmedWriteToolName, z.ZodType>;
export const schemaByTool = {
listCustomers: listCustomersMcpSchema,
createCustomer: CreateCustomerParamsV1Schema,
getCustomer: GetCustomerParamsV1Schema,
listPlans: ListPlanParamsSchema,
createPlan: CreatePlanParamsV2Schema,
getPlan: GetPlanParamsV0Schema,
previewAttach: AttachParamsV1Schema,
attach: AttachParamsV1Schema,
previewUpdateSubscription: UpdateSubscriptionV1ParamsSchema,
updateSubscription: UpdateSubscriptionV1ParamsSchema,
previewCreateSchedule: createScheduleMcpSchema,
createSchedule: createScheduleMcpSchema,
previewCreateBalance: createBalanceMcpSchema,
createBalance: createBalanceMcpSchema,
} as const satisfies Record<
keyof typeof endpointByTool | "previewCreateBalance",
z.ZodType
>;
const toolConfigs: OperationToolConfig[] = [
{
id: "listCustomers",
description:
"List Autumn customers. Use search, plans, subscription_status, and processors filters for customer-heavy queries. limit max is 1000. For queued/upcoming plan version queries, use subscription_status scheduled and omit the earliest matching version unless the user asks for all historical versions (versions 1,2,3 -> filter 2,3). 'live', 'paying', and active subscribers usually mean subscription_status active. When a plan is named, include the plans filter instead of listing broad customer sets. If listPlans returned matching versions, pass only relevant versions in plans[].versions, never guessed versions. For every/all/complete requests, paginate by calling again with start_cursor set to the previous response's next_cursor until next_cursor is empty.",
schema: listCustomersMcpSchema,
endpoint: endpointByTool.listCustomers,
},
{
id: "createCustomer",
description:
"Create an Autumn customer, or return the existing customer with the same id. Use when the user explicitly wants a customer record created.",
schema: CreateCustomerParamsV1Schema,
endpoint: endpointByTool.createCustomer,
idempotent: true,
},
{
id: "getCustomer",
description: "Fetch one Autumn customer by id.",
schema: GetCustomerParamsV1Schema,
endpoint: endpointByTool.getCustomer,
},
{
id: "listPlans",
description:
"List Autumn plans. This is usually a cheap full scan; filter returned plans locally and use matching id/version pairs before customer queries based on plan attributes.",
schema: ListPlanParamsSchema,
endpoint: endpointByTool.listPlans,
},
{
id: "createPlan",
description:
"Create an Autumn plan. Destructive configuration write: gather plan_id, name, price, features/items, trials, and confirmation before running.",
schema: CreatePlanParamsV2Schema,
endpoint: endpointByTool.createPlan,
destructive: true,
},
{
id: "createBalance",
description:
"Create a standalone customer balance grant. Use when a user asks to give, add, grant, or provision credits/balance to a customer or entity. Destructive: preview first; use entity_id for entity-scoped credits, included_grant for the grant amount, expires_at for expiring grants, and omit reset when using expires_at. For relative expiries like '2 months', use calendar months, not a 30-day approximation. expires_at accepts epoch milliseconds or ISO/date strings.",
schema: createBalanceMcpSchema,
endpoint: endpointByTool.createBalance,
destructive: true,
},
{
id: "getPlan",
description: "Fetch one Autumn plan by id and optional version.",
schema: GetPlanParamsV0Schema,
endpoint: endpointByTool.getPlan,
},
];
const localPreviewConfigs: LocalPreviewToolConfig[] = [
{
id: "previewCreateBalance",
description:
"Preview a standalone balance grant before createBalance. Use when a user asks to give, add, grant, or provision credits/balance to a customer or entity. Use for one-time credit grants, referral/promotional credits, and entity-scoped credits. Does not mutate Autumn. For relative expiries like '2 months', use calendar months. expires_at accepts epoch milliseconds or ISO/date strings.",
schema: createBalanceMcpSchema,
writeToolName: "createBalance",
preview: (request) => ({
action: "createBalance",
request,
impact:
"Creates a standalone balance grant. If entity_id is present, the balance is scoped to that entity. If expires_at is present, the grant expires at that timestamp.",
}),
},
];
const billingPreviewConfigs: BillingPreviewToolConfig[] = [
{
id: "previewAttach",
description:
"Preview attaching a plan before attach. Include feature_quantities and custom items/prices; map recurring custom grants like 'per month/year' to reset.interval.",
schema: AttachParamsV1Schema,
previewEndpoint: endpointByTool.previewAttach,
writeToolName: "attach",
},
{
id: "previewUpdateSubscription",
description:
"Preview updating a subscription before updateSubscription. Include quantity/custom item changes; recurring custom grants need reset.interval.",
schema: UpdateSubscriptionV1ParamsSchema,
previewEndpoint: endpointByTool.previewUpdateSubscription,
writeToolName: "updateSubscription",
},
{
id: "previewCreateSchedule",
description:
"Preview billing impact of a multi-phase schedule before createSchedule. starts_at accepts epoch milliseconds or ISO/date strings; preserve exact calendar dates from the user or contract. Use redirect_mode if_required unless the user explicitly asks to disable checkout/redirects. If changing an existing/customer contract schedule, inspect the customer first. For schedules, put phase-specific feature quantities and contract feature limits/overrides in plan.customize.items, not feature_quantities; map 'per month/year' to reset.interval month/year. If the user says year 1 is already paid or should have no billing changes, do not add a year-1 phase; start phases at the first future billing change.",
schema: createScheduleMcpSchema,
previewEndpoint: endpointByTool.previewCreateSchedule,
writeToolName: "createSchedule",
},
];
const confirmedWriteConfigs: OperationToolConfig[] = [
{
id: "attach",
description:
"Attach a plan to a customer. Destructive: preview first; preserve feature_quantities, custom prices/items, reset intervals, discounts, and checkout behavior.",
schema: AttachParamsV1Schema,
endpoint: endpointByTool.attach,
destructive: true,
},
{
id: "updateSubscription",
description:
"Update a subscription. Destructive: preview first; preserve quantity/custom item changes and reset intervals from the previewed request.",
schema: UpdateSubscriptionV1ParamsSchema,
endpoint: endpointByTool.updateSubscription,
destructive: true,
},
{
id: "createSchedule",
description:
"Create a multi-phase billing schedule. Destructive: preview first; preserve phase starts_at and redirect_mode values from the previewed request. Use redirect_mode if_required unless the user explicitly asks to disable checkout/redirects. If changing an existing/customer contract schedule, inspect the customer first. For schedules, put phase-specific feature quantities and contract feature limits/overrides in plan.customize.items, not feature_quantities. If year 1 is already paid/no billing changes, do not add a year-1 phase; start at the first future billing change.",
schema: createScheduleMcpSchema,
endpoint: endpointByTool.createSchedule,
destructive: true,
},
];
export const dateToEpochMillisecondsTool = createTool({
id: "dateToEpochMilliseconds",
description:
"Convert a calendar date or ISO timestamp to UTC epoch milliseconds for API timestamp fields. Date-only values default to midnight UTC; include an explicit offset in the date string when timezone matters.",
inputSchema: z
.object({
date: z.string(),
})
.strict(),
execute: async ({ date }) => toEpochMilliseconds(date),
});
const toEpochMilliseconds = (date: string) => {
const normalized = /^\d{4}-\d{2}-\d{2}$/.test(date)
? `${date}T00:00:00.000`
: date;
const hasExplicitZone = /(?:z|[+-]\d{2}:?\d{2})$/i.test(normalized);
const parsed = parseISO(hasExplicitZone ? normalized : `${normalized}Z`);
if (!isValid(parsed)) throw new Error(`Invalid date: ${date}`);
return parsed.getTime();
};
const callAutumn = async ({
context,
endpoint,
request,
}: {
context?: ToolContext;
endpoint: string;
request: unknown;
}) => {
const auth = getAutumnAuth(context);
const client = createAutumnClient(auth);
const init: RequestInit = {
method: "POST",
headers: client.headers,
body: JSON.stringify(request),
};
if (context?.mcp?.extra?.signal) init.signal = context.mcp.extra.signal;
const response = await fetch(new URL(endpoint, client.baseUrl), init);
const text = await response.text();
const body = text ? parseBody(text) : null;
if (!response.ok) {
throw new Error(
`Autumn API request failed (${response.status}): ${typeof body === "string" ? body : JSON.stringify(body)}`,
);
}
return body;
};
const parseBody = (text: string): unknown => {
try {
return JSON.parse(text);
} catch {
return text;
}
};
const logTool = (event: string, data: Record<string, unknown>) => {
if (process.env.MCP_DEBUG_PENDING_ACTIONS !== "1") return;
console.log(`[mcp:agent-tools] ${event} ${JSON.stringify(data)}`);
};
const mcpAnnotations = (destructive = false, idempotent = false) => ({
readOnlyHint: !destructive && !idempotent,
destructiveHint: destructive,
idempotentHint: idempotent,
openWorldHint: false,
});
const toTools = <Config extends { id: string }>(
configs: Config[],
create: (config: Config) => ReturnType<typeof createTool>,
) => Object.fromEntries(configs.map((config) => [config.id, create(config)]));
const operationTool = ({
id,
description,
schema,
endpoint,
destructive = false,
idempotent = false,
}: OperationToolConfig) =>
createTool({
id,
description,
inputSchema: z.object({ request: schema }).strict(),
mcp: {
annotations: mcpAnnotations(destructive, idempotent),
},
execute: (input, context) =>
callAutumn({
context,
endpoint,
request: schema.parse((input as { request: unknown }).request),
}),
});
const agentBillingPreviewTool = ({
id,
description,
schema,
previewEndpoint,
writeToolName,
}: {
id: string;
description: string;
schema: z.ZodType;
previewEndpoint: string;
writeToolName: ConfirmedWriteToolName;
}) =>
createTool({
id,
description: `${description} Store the exact pending billing action for later confirmation.`,
inputSchema: z.object({ request: schema }).strict(),
mcp: {
annotations: mcpAnnotations(),
},
execute: async (input, context) => {
const request = (input as { request: unknown }).request;
const parsedRequest = schema.parse(request);
const auth = getAutumnAuth(context);
logTool("preview-start", { previewTool: id, writeToolName });
const preview = await callAutumn({
context,
endpoint: previewEndpoint,
request: parsedRequest,
});
await createPendingAction({
auth,
toolName: writeToolName,
request: parsedRequest,
preview: JSON.stringify(preview),
});
logTool("preview-stored", { previewTool: id, writeToolName });
return {
preview,
pending: true,
message:
"Preview ready. Ask the user to explicitly apply or approve this exact change.",
};
},
});
const rawLocalPreviewTool = ({
id,
description,
schema,
preview,
}: LocalPreviewToolConfig) =>
createTool({
id,
description,
inputSchema: z.object({ request: schema }).strict(),
mcp: {
annotations: mcpAnnotations(),
},
execute: async (input) =>
preview(schema.parse((input as { request: unknown }).request)),
});
const agentLocalPreviewTool = ({
id,
description,
schema,
writeToolName,
preview,
}: LocalPreviewToolConfig) =>
createTool({
id,
description: `${description} Store the exact pending billing action for later confirmation.`,
inputSchema: z.object({ request: schema }).strict(),
mcp: {
annotations: mcpAnnotations(),
},
execute: async (input, context) => {
const request = (input as { request: unknown }).request;
const parsedRequest = schema.parse(request);
const previewResult = preview(parsedRequest);
await createPendingAction({
auth: getAutumnAuth(context),
toolName: writeToolName,
request: parsedRequest,
preview: JSON.stringify(previewResult),
});
return {
preview: previewResult,
pending: true,
message:
"Preview ready. Ask the user to explicitly apply or approve this exact change.",
};
},
});
const agentPendingWriteTool = ({
id,
description,
schema,
}: OperationToolConfig) =>
createTool({
id,
description: `${description} This internal agent tool stores the exact request for later confirmation instead of applying it immediately.`,
inputSchema: z.object({ request: schema }).strict(),
mcp: {
annotations: mcpAnnotations(),
},
execute: async (input, context) => {
const request = (input as { request: unknown }).request;
const parsedRequest = schema.parse(request);
await createPendingAction({
auth: getAutumnAuth(context),
toolName: id as ConfirmedWriteToolName,
request: parsedRequest,
preview: JSON.stringify(parsedRequest),
});
return {
pending: true,
request: parsedRequest,
message:
"Request ready. Ask the user to explicitly apply or approve this exact change.",
};
},
});
export const createRawAutumnOperationTools = () => ({
...toTools(toolConfigs, operationTool),
...toTools(billingPreviewConfigs, (config) =>
operationTool({ ...config, endpoint: config.previewEndpoint }),
),
...toTools(localPreviewConfigs, rawLocalPreviewTool),
...toTools(confirmedWriteConfigs, operationTool),
});
export const createAgentAutumnOperationTools = () => ({
...toTools(
toolConfigs.filter(({ destructive }) => !destructive),
operationTool,
),
...toTools(
toolConfigs.filter(({ destructive }) => destructive),
agentPendingWriteTool,
),
...toTools(billingPreviewConfigs, agentBillingPreviewTool),
...toTools(localPreviewConfigs, agentLocalPreviewTool),
dateToEpochMilliseconds: dateToEpochMillisecondsTool,
confirmBillingAction: createTool({
id: "confirmBillingAction",
description:
"Apply the latest pending billing action after the user semantically confirms the preview.",
inputSchema: z.object({}).strict(),
execute: async (_input, context) => {
const auth = getAutumnAuth(context);
logTool("confirm-start", { env: auth.env });
const action = await claimLatestPendingAction(auth);
logTool("confirm-claimed", { toolName: action.toolName });
const result = await executeConfirmedBillingAction({
auth,
toolName: action.toolName,
request: action.request,
});
return {
message: `Confirmed and applied ${action.toolName}.`,
result,
};
},
}),
});
export const executeConfirmedBillingAction = async ({
auth,
toolName,
request,
}: {
auth: ReturnType<typeof getAutumnAuth>;
toolName: ConfirmedWriteToolName;
request: unknown;
}) =>
callAutumn({
context: { mcp: { extra: { authInfo: auth } } } as never,
endpoint: endpointByTool[toolName],
request: writeSchemaByTool[toolName].parse(request),
});

View File

@@ -1,333 +0,0 @@
import { type ScopeString, Scopes } from "@autumn/shared/scopeDefinitions";
import { ms } from "@autumn/shared/unixUtils";
import { addMilliseconds, isFuture } from "date-fns";
import * as z from "zod/v4";
import type { AutumnMcpAuth } from "./agent/auth.js";
import { principalFromSecret } from "./agent/auth.js";
import type { ConsoleLogger } from "./console-logger.js";
import type { MCPServerFlags } from "./flags.js";
export const MCP_OAUTH_SCOPES = [
Scopes.Customers.Read,
Scopes.Customers.Write,
Scopes.Plans.Read,
Scopes.Plans.Write,
Scopes.Billing.Read,
Scopes.Billing.Write,
Scopes.Balances.Write,
Scopes.Analytics.Read,
] as const satisfies readonly ScopeString[];
const environmentSchema = z.enum(["sandbox", "live"]);
const xApiVersionSchema = z.string().default("2.3.0");
const failOpenSchema = z
.union([
z.boolean(),
z.enum(["true", "false"]).transform((v) => v === "true"),
])
.default(true);
const secretKeySchema = z.string().min(1).optional();
const tokenExchangeSchema = z.object({
sandbox_key: z.string().optional(),
prod_key: z.string().optional(),
org_id: z.string().optional(),
user_id: z.string().optional(),
client_id: z.string().optional(),
scopes: z.array(z.string()).optional(),
});
export type OAuthEnvironment = z.infer<typeof environmentSchema>;
export interface MCPOAuthFlags extends MCPServerFlags {
readonly "oauth-enabled"?: boolean | undefined;
readonly "oauth-environment"?: OAuthEnvironment | undefined;
}
export class OAuthHttpError extends Error {
constructor(
readonly status: number,
message: string,
readonly error = "invalid_token",
readonly wwwAuthenticate?: string,
) {
super(message);
}
}
const apiKeyCache = new Map<
string,
{
key: string;
orgId?: string | undefined;
userId?: string | undefined;
clientId?: string | undefined;
scopes?: string[] | undefined;
expiresAt: Date;
}
>();
function trimTrailingSlash(url: string): string {
return url.endsWith("/") ? url.slice(0, -1) : url;
}
export function getResourceUrl(
headers: Headers,
_flags: MCPOAuthFlags,
resourcePath = "/mcp",
): string {
const host =
headers.get("x-autumn-forwarded-host") ??
headers.get("x-forwarded-host") ??
headers.get("host");
if (!host) {
throw new OAuthHttpError(400, "Missing Host header", "invalid_request");
}
const proto =
headers.get("x-autumn-forwarded-proto") ??
headers.get("x-forwarded-proto") ??
"http";
return new URL(resourcePath, `${proto}://${host}`).href;
}
export function getProtectedResourceMetadataUrl(resourceUrl: string): string {
const url = new URL(resourceUrl);
const path = url.pathname === "/" ? "" : url.pathname;
return new URL(`/.well-known/oauth-protected-resource${path}`, url).href;
}
function getIssuerUrl(flags: MCPOAuthFlags): string {
return trimTrailingSlash(
new URL("/api/auth", flags["server-url"] ?? "https://api.useautumn.com")
.href,
);
}
function getApiKeyUrl(flags: MCPOAuthFlags): string {
return new URL("/cli/api-keys", getIssuerUrl(flags)).href;
}
function getWWWAuthenticate(resourceUrl: string, error?: string): string {
const params = [
`resource_metadata="${getProtectedResourceMetadataUrl(resourceUrl)}"`,
];
if (error) params.push(`error="${error}"`);
return `Bearer ${params.join(", ")}`;
}
export function getProtectedResourceMetadata(
headers: Headers,
flags: MCPOAuthFlags,
resourcePath = "/mcp",
) {
const resource = getResourceUrl(headers, flags, resourcePath);
return {
resource,
authorization_servers: [getIssuerUrl(flags)],
scopes_supported: [...MCP_OAUTH_SCOPES],
bearer_methods_supported: ["header"],
resource_name: "Autumn MCP",
};
}
export function getAuthorizationServerMetadata(flags: MCPOAuthFlags) {
const issuer = getIssuerUrl(flags);
return {
issuer,
authorization_endpoint: `${issuer}/oauth2/authorize`,
token_endpoint: `${issuer}/oauth2/token`,
registration_endpoint: `${issuer}/oauth2/register`,
revocation_endpoint: `${issuer}/oauth2/revoke`,
introspection_endpoint: `${issuer}/oauth2/introspect`,
response_types_supported: ["code"],
grant_types_supported: ["authorization_code", "refresh_token"],
token_endpoint_auth_methods_supported: [
"client_secret_post",
"client_secret_basic",
"none",
],
code_challenge_methods_supported: ["S256"],
scopes_supported: [...MCP_OAUTH_SCOPES],
};
}
function getEnvironment(
headers: Headers,
flags: MCPOAuthFlags,
): OAuthEnvironment {
const value =
headers.get("x-autumn-environment") ??
flags["oauth-environment"] ??
"sandbox";
const parsed = environmentSchema.safeParse(value);
if (parsed.success) return parsed.data;
throw new OAuthHttpError(
400,
"Invalid x-autumn-environment",
"invalid_request",
);
}
function parseRequestOption<T>(
value: unknown,
schema: z.ZodType<T>,
message: string,
): T {
const parsed = schema.safeParse(value);
if (parsed.success) return parsed.data;
throw new OAuthHttpError(400, message, "invalid_request");
}
async function exchangeOAuthToken(
headers: Headers,
flags: MCPOAuthFlags,
resource: string,
token: string,
): Promise<{
key: string;
orgId?: string | undefined;
userId?: string | undefined;
clientId?: string | undefined;
scopes?: string[];
}> {
const env = getEnvironment(headers, flags);
const cacheKey = `${token}:${resource}:${env}`;
const cached = apiKeyCache.get(cacheKey);
if (cached && isFuture(cached.expiresAt)) return cached;
const response = await fetch(getApiKeyUrl(flags), {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ resource, scopes: MCP_OAUTH_SCOPES }),
});
if (!response.ok) {
throw new OAuthHttpError(
response.status === 403 ? 403 : 401,
await response.text(),
response.status === 403 ? "insufficient_scope" : "invalid_token",
response.status === 403
? undefined
: getWWWAuthenticate(resource, "invalid_token"),
);
}
const data = tokenExchangeSchema.parse(await response.json());
const key = env === "live" ? data.prod_key : data.sandbox_key;
if (!key) {
throw new OAuthHttpError(
502,
"OAuth key exchange did not return an API key",
);
}
const exchanged = {
key,
orgId: data.org_id,
userId: data.user_id,
clientId: data.client_id,
scopes: data.scopes,
expiresAt: addMilliseconds(new Date(), ms.minutes(1)),
};
apiKeyCache.set(cacheKey, exchanged);
return exchanged;
}
function getOAuthPrincipalId(
token: string,
exchanged: Awaited<ReturnType<typeof exchangeOAuthToken>>,
) {
if (!exchanged.orgId) return principalFromSecret("oauth", token);
return [
"oauth",
exchanged.orgId,
exchanged.userId ?? "unknown-user",
exchanged.clientId ?? "unknown-client",
].join(":");
}
function getStaticApiKey(headers: Headers, flags: MCPOAuthFlags) {
const secretKey = headers.get("secret-key");
if (secretKey) return secretKey;
const authorization = headers.get("authorization");
const bearer = authorization?.startsWith("Bearer ")
? authorization.slice("Bearer ".length)
: undefined;
if (bearer?.startsWith("am_")) return bearer;
return flags["oauth-enabled"] ? undefined : flags["secret-key"];
}
export async function buildAuthForRequest(
headers: Headers,
flags: MCPOAuthFlags,
logger: ConsoleLogger,
resourcePath = "/mcp",
): Promise<AutumnMcpAuth> {
const env = getEnvironment(headers, flags);
const resource = getResourceUrl(headers, flags, resourcePath);
const xApiVersion = parseRequestOption(
headers.get("x-api-version") ?? flags["x-api-version"],
xApiVersionSchema,
"Invalid x-api-version",
);
const failOpen = parseRequestOption(
headers.get("fail-open") ?? flags["fail-open"],
failOpenSchema,
"Invalid fail-open",
);
const apiKey = parseRequestOption(
getStaticApiKey(headers, flags),
secretKeySchema,
"Invalid secret-key",
);
if (apiKey) {
return {
apiKey,
env,
resource,
principalId: principalFromSecret("secret-key", apiKey),
scopes: [...MCP_OAUTH_SCOPES],
serverURL: flags["server-url"],
xApiVersion,
failOpen,
};
}
if (flags["oauth-enabled"]) {
const authHeader = headers.get("authorization");
if (!authHeader?.startsWith("Bearer ")) {
throw new OAuthHttpError(
401,
"Missing Authorization bearer token",
"invalid_token",
getWWWAuthenticate(resource),
);
}
const token = authHeader.slice("Bearer ".length);
const exchanged = await exchangeOAuthToken(headers, flags, resource, token);
return {
apiKey: exchanged.key,
env,
resource,
principalId: getOAuthPrincipalId(token, exchanged),
scopes: exchanged.scopes ?? [...MCP_OAUTH_SCOPES],
orgId: exchanged.orgId,
serverURL: flags["server-url"],
xApiVersion,
failOpen,
};
}
logger.warning("Missing secret-key for MCP request");
throw new OAuthHttpError(401, "Missing secret-key", "invalid_token");
}

View File

@@ -1,7 +1,28 @@
import type { MCPServerResources } from "@mastra/mcp";
const docs = {
"autumn://docs/tool-composition": {
type DocInput = {
name: string;
title: string;
description: string;
text: string;
};
/**
* Builds a single Autumn docs resource. The `autumn://docs/<name>` URI is
* derived from `name` so each doc is declared once, with no duplicated key.
*/
const defineDoc = ({ name, title, description, text }: DocInput) => ({
uri: `autumn://docs/${name}`,
name,
title,
description,
text,
});
type Doc = ReturnType<typeof defineDoc>;
const docs: Doc[] = [
defineDoc({
name: "tool-composition",
title: "Tool Composition",
description: "How to compose Autumn MCP tools for operational questions.",
@@ -21,8 +42,8 @@ Use Autumn tools as composable primitives.
- For billing writes, always preview first and wait for explicit user confirmation before applying.
Docs index: https://docs.useautumn.com/llms.txt`,
},
"autumn://docs/querying-plans": {
}),
defineDoc({
name: "querying-plans",
title: "Querying Plans",
description: "How to answer plan-filtering questions with listPlans.",
@@ -39,8 +60,8 @@ Use listPlans for questions about:
- plan features and included quantities
Filter the returned plans locally. If the user asks for customers on matching plans, first resolve the matching plans, then call listCustomers with those plan ids. For upcoming, queued, or scheduled version queries, pass only the relevant target versions to listCustomers; with numeric versions, exclude the earliest historical version unless the user asks for all historical versions.`,
},
"autumn://docs/creating-plans": {
}),
defineDoc({
name: "creating-plans",
title: "Creating Plans",
description: "How to gather plan details before using createPlan.",
@@ -59,8 +80,8 @@ Before creating a plan, resolve:
For consumable features, recurring grants need reset intervals. "500 credits per month" means included 500 with reset.interval "month"; one-time grants use "one_off".
If any required pricing or feature detail is ambiguous, ask a concise clarification question before creating the plan.`,
},
"autumn://docs/querying-customers": {
}),
defineDoc({
name: "querying-customers",
title: "Querying Customers",
description: "How to answer customer-heavy questions with listCustomers.",
@@ -76,8 +97,8 @@ Prefer server-side filters before local filtering:
Use limit 1000 for broad scans; that is the maximum page size.
Always paginate until next_cursor is empty when the user asks for complete results. Use getCustomer only for details not returned by listCustomers.`,
},
"autumn://docs/schedules": {
}),
defineDoc({
name: "schedules",
title: "Billing Schedules",
description: "How to create multi-phase billing schedules safely.",
@@ -101,8 +122,8 @@ Custom feature mapping:
- Omit reset only for non-consumable, unlimited, or clearly one-time grants.
There is no separate public update-schedule tool. For existing subscription changes, use previewUpdateSubscription and updateSubscription when the requested change fits that endpoint. For a new multi-phase transition, call previewCreateSchedule first, show the immediate billing impact and ordered phases, then call createSchedule only after explicit confirmation.`,
},
"autumn://docs/balances": {
}),
defineDoc({
name: "balances",
title: "Standalone Balances",
description:
@@ -133,8 +154,8 @@ Useful docs:
- https://docs.useautumn.com/documentation/customers/balances
- https://docs.useautumn.com/documentation/modelling-pricing/sub-entity-balances
- https://docs.useautumn.com/api-reference/balances/createBalance`,
},
"autumn://docs/billing-safety": {
}),
defineDoc({
name: "billing-safety",
title: "Billing Safety",
description: "Preview-first rules for Autumn billing changes.",
@@ -157,13 +178,15 @@ Useful docs:
- https://docs.useautumn.com/api-reference/billing/attach
- https://docs.useautumn.com/documentation/concepts/plan-items
- https://docs.useautumn.com/documentation/customers/balances`,
},
} as const;
}),
];
const docByUri = new Map(docs.map((doc) => [doc.uri, doc]));
export const autumnMcpResources: MCPServerResources = {
listResources: async () =>
Object.entries(docs).map(([uri, doc]) => ({
uri,
docs.map((doc) => ({
uri: doc.uri,
name: doc.name,
title: doc.title,
description: doc.description,
@@ -175,13 +198,12 @@ export const autumnMcpResources: MCPServerResources = {
},
})),
getResourceContent: async ({ uri }) => {
if (!Object.hasOwn(docs, uri)) {
const doc = docByUri.get(uri);
if (!doc) {
throw new Error(`Unknown Autumn MCP resource: ${uri}`);
}
const doc = docs[uri as keyof typeof docs];
return { text: doc.text };
},
};
export const autumnMcpResourceUris = Object.keys(docs);
export const autumnMcpResourceUris = docs.map((doc) => doc.uri);

View File

@@ -0,0 +1,78 @@
import { RequestContext } from "@mastra/core/request-context";
import * as z from "zod/v4";
import {
DEFAULT_API_VERSION,
DEFAULT_AUTUMN_API_URL,
} from "../../constants.js";
import { environmentSchema } from "./utils/schemas.js";
/**
* Authenticated Autumn identity attached to every MCP request. Defined as a zod
* schema so the same definition both types the value and validates it when read
* back from the (loosely-typed) MCP execution context — no casts required.
*/
export const autumnMcpAuthSchema = z.object({
apiKey: z.string().min(1),
env: environmentSchema,
principalId: z.string(),
resource: z.string(),
scopes: z.array(z.string()),
orgId: z.string().optional(),
serverURL: z.string().optional(),
xApiVersion: z.string().optional(),
failOpen: z.boolean().optional(),
});
export type AutumnMcpAuth = z.infer<typeof autumnMcpAuthSchema>;
/**
* Minimal structural view of the MCP tool execution context we read auth from.
* Kept intentionally loose so any Mastra `ToolExecutionContext` satisfies it
* without callers having to cast.
*/
type AuthContext = {
mcp?: { extra?: { authInfo?: unknown } | undefined } | undefined;
requestContext?: { get?: (key: string) => unknown } | undefined;
};
/** Reads `mcp.extra.authInfo` back out of a serialized request context. */
const readNestedAuthInfo = (
requestContext: AuthContext["requestContext"],
): unknown => {
const extra = requestContext?.get?.("mcp.extra");
if (typeof extra === "object" && extra !== null && "authInfo" in extra) {
return extra.authInfo;
}
return undefined;
};
export const getAutumnAuth = (context?: AuthContext): AutumnMcpAuth => {
const candidate =
context?.mcp?.extra?.authInfo ??
readNestedAuthInfo(context?.requestContext);
const parsed = autumnMcpAuthSchema.safeParse(candidate);
if (!parsed.success) {
throw new Error("Autumn MCP authentication is required.");
}
return parsed.data;
};
export const createRequestContext = (auth: AutumnMcpAuth) => {
const requestContext = new RequestContext();
requestContext.set("mcp.extra", { authInfo: auth });
return requestContext;
};
export const createAutumnClient = (auth: AutumnMcpAuth) => ({
baseUrl: auth.serverURL ?? DEFAULT_AUTUMN_API_URL,
headers: {
Authorization: `Bearer ${auth.apiKey}`,
"Content-Type": "application/json",
Accept: "application/json",
"x-api-version": auth.xApiVersion ?? DEFAULT_API_VERSION,
...(auth.failOpen === undefined
? {}
: { "fail-open": String(auth.failOpen) }),
},
});

View File

@@ -0,0 +1,112 @@
import { MCP_OAUTH_SCOPES } from "../../constants.js";
import type { AutumnMcpAuth } from "./auth.js";
import { OAuthHttpError } from "./utils/errors.js";
import { principalFromSecret } from "./utils/principal.js";
import {
getEnvironment,
getStaticApiKey,
parseRequestOption,
} from "./utils/request.js";
import {
failOpenSchema,
type MCPOAuthFlags,
secretKeySchema,
xApiVersionSchema,
} from "./utils/schemas.js";
import {
getIssuerUrl,
getResourceUrl,
getWWWAuthenticate,
} from "./utils/urls.js";
// Public surface consumed via `./oauth.js` (index.ts, leaf, tests).
export { MCP_OAUTH_SCOPES } from "../../constants.js";
export { OAuthHttpError } from "./utils/errors.js";
export type { MCPOAuthFlags, OAuthEnvironment } from "./utils/schemas.js";
type AuthLogger = {
warning: (message: string, data?: Record<string, unknown>) => void;
};
export const getProtectedResourceMetadata = (
headers: Headers,
flags: MCPOAuthFlags,
resourcePath = "/mcp",
) => ({
resource: getResourceUrl({ headers, resourcePath }),
authorization_servers: [getIssuerUrl(flags)],
scopes_supported: [...MCP_OAUTH_SCOPES],
bearer_methods_supported: ["header"],
resource_name: "Autumn MCP",
});
export const getAuthorizationServerMetadata = (flags: MCPOAuthFlags) => {
const issuer = getIssuerUrl(flags);
return {
issuer,
authorization_endpoint: `${issuer}/oauth2/authorize`,
token_endpoint: `${issuer}/oauth2/token`,
registration_endpoint: `${issuer}/oauth2/register`,
revocation_endpoint: `${issuer}/oauth2/revoke`,
introspection_endpoint: `${issuer}/oauth2/introspect`,
response_types_supported: ["code"],
grant_types_supported: ["authorization_code", "refresh_token"],
token_endpoint_auth_methods_supported: [
"client_secret_post",
"client_secret_basic",
"none",
],
code_challenge_methods_supported: ["S256"],
scopes_supported: [...MCP_OAUTH_SCOPES],
};
};
export const buildAuthForRequest = async (
headers: Headers,
flags: MCPOAuthFlags,
logger: AuthLogger,
resourcePath = "/mcp",
): Promise<AutumnMcpAuth> => {
const env = getEnvironment({ headers, flags });
const resource = getResourceUrl({ headers, resourcePath });
const xApiVersion = parseRequestOption({
value: headers.get("x-api-version") ?? flags["x-api-version"],
schema: xApiVersionSchema,
message: "Invalid x-api-version",
});
const failOpen = parseRequestOption({
value: headers.get("fail-open") ?? flags["fail-open"],
schema: failOpenSchema,
message: "Invalid fail-open",
});
const apiKey = parseRequestOption({
value: getStaticApiKey({ headers, flags }),
schema: secretKeySchema,
message: "Invalid secret-key",
});
if (apiKey) {
return {
apiKey,
env,
resource,
principalId: principalFromSecret({ kind: "secret-key", value: apiKey }),
scopes: [...MCP_OAUTH_SCOPES],
serverURL: flags["server-url"],
xApiVersion,
failOpen,
};
}
if (flags["oauth-enabled"]) {
throw new OAuthHttpError(
401,
"Missing Autumn API key bearer token",
"invalid_token",
getWWWAuthenticate({ resourceUrl: resource, error: "invalid_token" }),
);
}
logger.warning("Missing secret-key for MCP request");
throw new OAuthHttpError(401, "Missing secret-key", "invalid_token");
};

View File

@@ -0,0 +1,15 @@
/**
* Error carrying the HTTP status and OAuth metadata the MCP HTTP layer needs to
* build a spec-compliant `WWW-Authenticate` response. Lives in its own module so
* both the request helpers and the OAuth flow can throw it without import cycles.
*/
export class OAuthHttpError extends Error {
constructor(
readonly status: number,
message: string,
readonly error = "invalid_token",
readonly wwwAuthenticate?: string,
) {
super(message);
}
}

View File

@@ -0,0 +1,17 @@
import { createHash } from "node:crypto";
/** Short, stable digest used to anonymise secrets inside principal ids. */
const hash = (value: string) =>
createHash("sha256").update(value).digest("hex").slice(0, 32);
/**
* Builds a principal id from a secret without leaking it, e.g.
* `secret-key:<digest>`.
*/
export const principalFromSecret = ({
kind,
value,
}: {
kind: string;
value: string;
}) => `${kind}:${hash(value)}`;

View File

@@ -0,0 +1,67 @@
import type * as z from "zod/v4";
import { OAuthHttpError } from "./errors.js";
import {
environmentSchema,
type MCPOAuthFlags,
type OAuthEnvironment,
} from "./schemas.js";
/**
* Validates a request-derived value against a schema, surfacing a 400 with a
* caller-supplied message instead of zod's default error shape.
*/
export const parseRequestOption = <T>({
value,
schema,
message,
}: {
value: unknown;
schema: z.ZodType<T>;
message: string;
}): T => {
const parsed = schema.safeParse(value);
if (parsed.success) return parsed.data;
throw new OAuthHttpError(400, message, "invalid_request");
};
/** Resolves the Autumn environment from the request header, then the flag. */
export const getEnvironment = ({
headers,
flags,
}: {
headers: Headers;
flags: MCPOAuthFlags;
}): OAuthEnvironment =>
parseRequestOption({
value:
headers.get("x-autumn-environment") ??
flags["oauth-environment"] ??
"sandbox",
schema: environmentSchema,
message: "Invalid x-autumn-environment",
});
/**
* Extracts a directly-supplied Autumn secret key (no OAuth exchange): a
* `secret-key` header, an `am_`-prefixed bearer token, or the configured flag
* when OAuth is disabled.
*/
export const getStaticApiKey = ({
headers,
flags,
}: {
headers: Headers;
flags: MCPOAuthFlags;
}): string | undefined => {
const secretKey = headers.get("secret-key");
if (secretKey) return secretKey;
const authorization = headers.get("authorization");
const bearer = authorization?.startsWith("Bearer ")
? authorization.slice("Bearer ".length)
: undefined;
if (bearer?.startsWith("am_")) return bearer;
return flags["oauth-enabled"] ? undefined : flags["secret-key"];
};

View File

@@ -0,0 +1,22 @@
import * as z from "zod/v4";
import { DEFAULT_API_VERSION } from "../../../constants.js";
import type { MCPServerFlags } from "../../flags.js";
export const environmentSchema = z.enum(["sandbox", "live"]);
export type OAuthEnvironment = z.infer<typeof environmentSchema>;
export const xApiVersionSchema = z.string().default(DEFAULT_API_VERSION);
export const failOpenSchema = z
.union([
z.boolean(),
z.enum(["true", "false"]).transform((v) => v === "true"),
])
.default(true);
export const secretKeySchema = z.string().min(1).optional();
export interface MCPOAuthFlags extends MCPServerFlags {
readonly "oauth-enabled"?: boolean | undefined;
readonly "oauth-environment"?: OAuthEnvironment | undefined;
}

View File

@@ -0,0 +1,60 @@
import { DEFAULT_AUTUMN_API_URL } from "../../../constants.js";
import { OAuthHttpError } from "./errors.js";
import type { MCPOAuthFlags } from "./schemas.js";
const trimTrailingSlash = (url: string) =>
url.endsWith("/") ? url.slice(0, -1) : url;
/** Host the client reached us on, honouring Autumn's proxy forwarding headers. */
const getForwardedHost = (headers: Headers) =>
headers.get("x-autumn-forwarded-host") ??
headers.get("x-forwarded-host") ??
headers.get("host");
const getForwardedProto = (headers: Headers) =>
headers.get("x-autumn-forwarded-proto") ??
headers.get("x-forwarded-proto") ??
"http";
/** Absolute URL of the MCP resource the current request is targeting. */
export const getResourceUrl = ({
headers,
resourcePath = "/mcp",
}: {
headers: Headers;
resourcePath?: string;
}): string => {
const host = getForwardedHost(headers);
if (!host) {
throw new OAuthHttpError(400, "Missing Host header", "invalid_request");
}
return new URL(resourcePath, `${getForwardedProto(headers)}://${host}`).href;
};
export const getProtectedResourceMetadataUrl = (
resourceUrl: string,
): string => {
const url = new URL(resourceUrl);
const path = url.pathname === "/" ? "" : url.pathname;
return new URL(`/.well-known/oauth-protected-resource${path}`, url).href;
};
export const getIssuerUrl = (flags: MCPOAuthFlags): string =>
trimTrailingSlash(
new URL("/api/auth", flags["server-url"] ?? DEFAULT_AUTUMN_API_URL).href,
);
export const getWWWAuthenticate = ({
resourceUrl,
error,
}: {
resourceUrl: string;
error?: string;
}): string => {
const params = [
`resource_metadata="${getProtectedResourceMetadataUrl(resourceUrl)}"`,
];
if (error) params.push(`error="${error}"`);
return `Bearer ${params.join(", ")}`;
};

View File

@@ -0,0 +1,15 @@
import { MCPServer } from "@mastra/mcp";
import { autumnMcpResources } from "../resources/index.js";
import { createRawAutumnOperationTools } from "../tools/index.js";
export const createAutumnOperationsMCPServer = () =>
new MCPServer({
id: "autumn-mcp",
name: "Autumn MCP",
version: "0.0.1",
description: "Operate on Autumn customers, plans, and billing.",
instructions:
"Use preview tools before billing writes. Write tools are destructive and should only be called after explicit user confirmation.",
tools: createRawAutumnOperationTools(),
resources: autumnMcpResources,
});

View File

@@ -0,0 +1,49 @@
import { CreateBalanceParamsV0Schema } from "@autumn/shared/publicApiSchemas";
import { createDomainTools } from "./utils/builders.js";
import { epochMillisecondsSchema } from "./utils/dates.js";
import type { ToolDomain } from "./utils/types.js";
const createBalanceMcpSchema = CreateBalanceParamsV0Schema.extend({
expires_at: epochMillisecondsSchema.optional().meta({
description:
"Expiry time as epoch milliseconds or an ISO date string. Date-only values use midnight UTC.",
}),
});
const endpoints = {
createBalance: "/v1/balances.create",
} as const;
const schemas = {
previewCreateBalance: createBalanceMcpSchema,
createBalance: createBalanceMcpSchema,
} as const;
const { operation, localPreview } = createDomainTools({ endpoints, schemas });
const domain = {
operations: [
operation({
id: "createBalance",
description:
"Create a standalone customer balance grant. Use when a user asks to give, add, grant, or provision credits/balance to a customer or entity. Destructive: preview first; use entity_id for entity-scoped credits, included_grant for the grant amount, expires_at for expiring grants, and omit reset when using expires_at. For relative expiries like '2 months', use calendar months, not a 30-day approximation. expires_at accepts epoch milliseconds or ISO/date strings.",
destructive: true,
}),
],
localPreviews: [
localPreview({
id: "previewCreateBalance",
description:
"Preview a standalone balance grant before createBalance. Use when a user asks to give, add, grant, or provision credits/balance to a customer or entity. Use for one-time credit grants, referral/promotional credits, and entity-scoped credits. Does not mutate Autumn. For relative expiries like '2 months', use calendar months. expires_at accepts epoch milliseconds or ISO/date strings.",
writeToolName: "createBalance",
preview: (request) => ({
action: "createBalance",
request,
impact:
"Creates a standalone balance grant. If entity_id is present, the balance is scoped to that entity. If expires_at is present, the grant expires at that timestamp.",
}),
}),
],
} satisfies ToolDomain;
export const balances = { endpoints, schemas, domain };

View File

@@ -0,0 +1,88 @@
import {
AttachParamsV1Schema,
CreateScheduleParamsV0Schema,
CreateSchedulePhaseSchema,
UpdateSubscriptionV1ParamsSchema,
} from "@autumn/shared/publicApiSchemas";
import * as z from "zod/v4";
import { createDomainTools } from "./utils/builders.js";
import { epochMillisecondsSchema } from "./utils/dates.js";
import type { ToolDomain } from "./utils/types.js";
const createSchedulePhaseMcpSchema = CreateSchedulePhaseSchema.extend({
starts_at: epochMillisecondsSchema.meta({
description:
"Phase start time as epoch milliseconds or an ISO date string. Date-only values use midnight UTC.",
}),
});
const createScheduleMcpSchema = CreateScheduleParamsV0Schema.extend({
phases: z
.tuple([createSchedulePhaseMcpSchema])
.rest(createSchedulePhaseMcpSchema),
});
const endpoints = {
previewAttach: "/v1/billing.preview_attach",
attach: "/v1/billing.attach",
previewUpdateSubscription: "/v1/billing.preview_update",
updateSubscription: "/v1/billing.update",
previewCreateSchedule: "/v1/billing.preview_create_schedule",
createSchedule: "/v1/billing.create_schedule",
} as const;
const schemas = {
previewAttach: AttachParamsV1Schema,
attach: AttachParamsV1Schema,
previewUpdateSubscription: UpdateSubscriptionV1ParamsSchema,
updateSubscription: UpdateSubscriptionV1ParamsSchema,
previewCreateSchedule: createScheduleMcpSchema,
createSchedule: createScheduleMcpSchema,
} as const;
const { billingPreview, confirmedWrite } = createDomainTools({
endpoints,
schemas,
});
const domain = {
billingPreviews: [
billingPreview({
id: "previewAttach",
description:
"Preview attaching a plan before attach. Include feature_quantities and custom items/prices; map recurring custom grants like 'per month/year' to reset.interval.",
writeToolName: "attach",
}),
billingPreview({
id: "previewUpdateSubscription",
description:
"Preview updating a subscription before updateSubscription. Include quantity/custom item changes; recurring custom grants need reset.interval.",
writeToolName: "updateSubscription",
}),
billingPreview({
id: "previewCreateSchedule",
description:
"Preview billing impact of a multi-phase schedule before createSchedule. starts_at accepts epoch milliseconds or ISO/date strings; preserve exact calendar dates from the user or contract. Use redirect_mode if_required unless the user explicitly asks to disable checkout/redirects. If changing an existing/customer contract schedule, inspect the customer first. For schedules, put phase-specific feature quantities and contract feature limits/overrides in plan.customize.items, not feature_quantities; map 'per month/year' to reset.interval month/year. If the user says year 1 is already paid or should have no billing changes, do not add a year-1 phase; start phases at the first future billing change.",
writeToolName: "createSchedule",
}),
],
confirmedWrites: [
confirmedWrite({
id: "attach",
description:
"Attach a plan to a customer. Destructive: preview first; preserve feature_quantities, custom prices/items, reset intervals, discounts, and checkout behavior.",
}),
confirmedWrite({
id: "updateSubscription",
description:
"Update a subscription. Destructive: preview first; preserve quantity/custom item changes and reset intervals from the previewed request.",
}),
confirmedWrite({
id: "createSchedule",
description:
"Create a multi-phase billing schedule. Destructive: preview first; preserve phase starts_at and redirect_mode values from the previewed request. Use redirect_mode if_required unless the user explicitly asks to disable checkout/redirects. If changing an existing/customer contract schedule, inspect the customer first. For schedules, put phase-specific feature quantities and contract feature limits/overrides in plan.customize.items, not feature_quantities. If year 1 is already paid/no billing changes, do not add a year-1 phase; start at the first future billing change.",
}),
],
} satisfies ToolDomain;
export const billing = { endpoints, schemas, domain };

View File

@@ -0,0 +1,53 @@
import {
CreateCustomerParamsV1Schema,
GetCustomerParamsV1Schema,
ListCustomersV2_3ParamsSchema,
} from "@autumn/shared/publicApiSchemas";
import * as z from "zod/v4";
import { createDomainTools } from "./utils/builders.js";
import type { ToolDomain } from "./utils/types.js";
const listCustomersSchema = ListCustomersV2_3ParamsSchema.extend({
limit: z
.preprocess(
(value) => (typeof value === "number" && value > 1000 ? 1000 : value),
z.number().int().positive().max(1000).optional(),
)
.meta({ description: "Maximum customers per page. Max 1000." }),
});
const endpoints = {
listCustomers: "/v1/customers.list",
createCustomer: "/v1/customers.get_or_create",
getCustomer: "/v1/customers.get",
} as const;
const schemas = {
listCustomers: listCustomersSchema,
createCustomer: CreateCustomerParamsV1Schema,
getCustomer: GetCustomerParamsV1Schema,
} as const;
const { operation } = createDomainTools({ endpoints, schemas });
const domain = {
operations: [
operation({
id: "listCustomers",
description:
"List Autumn customers. Use search, plans, subscription_status, and processors filters for customer-heavy queries. limit max is 1000. For queued/upcoming plan version queries, use subscription_status scheduled and omit the earliest matching version unless the user asks for all historical versions (versions 1,2,3 -> filter 2,3). 'live', 'paying', and active subscribers usually mean subscription_status active. When a plan is named, include the plans filter instead of listing broad customer sets. If listPlans returned matching versions, pass only relevant versions in plans[].versions, never guessed versions. For every/all/complete requests, paginate by calling again with start_cursor set to the previous response's next_cursor until next_cursor is empty.",
}),
operation({
id: "createCustomer",
description:
"Create an Autumn customer, or return the existing customer with the same id. Use when the user explicitly wants a customer record created.",
idempotent: true,
}),
operation({
id: "getCustomer",
description: "Fetch one Autumn customer by id.",
}),
],
} satisfies ToolDomain;
export const customers = { endpoints, schemas, domain };

View File

@@ -0,0 +1,140 @@
import { createTool } from "@mastra/core/tools";
import * as z from "zod/v4";
import { claimLatestPendingAction } from "../agent/pending-actions.js";
import { instrumentToolsWithAnalytics } from "../analytics/index.js";
import { type AutumnMcpAuth, getAutumnAuth } from "../server/auth/auth.js";
import { balances } from "./balances.js";
import { billing } from "./billing.js";
import { customers } from "./customers.js";
import { orgTools } from "./org.js";
import { plans } from "./plans.js";
import { callAutumn } from "./utils/client.js";
import { dateToEpochMillisecondsTool } from "./utils/dates.js";
import { logTool } from "./utils/debug.js";
import {
agentBillingPreviewTool,
agentLocalPreviewTool,
agentPendingWriteTool,
operationTool,
rawLocalPreviewTool,
toTools,
} from "./utils/factories.js";
import { requireIntentOnTools } from "./utils/intent.js";
import type { ConfirmedWriteToolName, ToolDomain } from "./utils/types.js";
export { dateToEpochMillisecondsTool } from "./utils/dates.js";
/** Endpoint each tool calls, keyed by tool id (preview tools use their preview path). */
export const endpointByTool = {
...customers.endpoints,
...plans.endpoints,
...billing.endpoints,
...balances.endpoints,
} as const;
/** Request schema each tool validates against, keyed by tool id. */
export const schemaByTool = {
...customers.schemas,
...plans.schemas,
...billing.schemas,
...balances.schemas,
} as const satisfies Record<
keyof typeof endpointByTool | "previewCreateBalance",
z.ZodType
>;
const domains: ToolDomain[] = [
customers.domain,
plans.domain,
billing.domain,
balances.domain,
];
const operations = domains.flatMap((domain) => domain.operations ?? []);
const billingPreviews = domains.flatMap(
(domain) => domain.billingPreviews ?? [],
);
const localPreviews = domains.flatMap((domain) => domain.localPreviews ?? []);
const confirmedWrites = domains.flatMap(
(domain) => domain.confirmedWrites ?? [],
);
/**
* Public MCP toolset: previews call Autumn's preview endpoints directly and
* writes apply immediately (external clients gate destructive calls themselves).
*/
export const createRawAutumnOperationTools = () =>
instrumentToolsWithAnalytics({
// Require a one-sentence `intent` on every external tool call so we can
// see what clients are actually trying to do (captured in analytics).
tools: requireIntentOnTools({
...toTools(operations, operationTool),
...toTools(billingPreviews, (config) =>
operationTool({ ...config, endpoint: config.previewEndpoint }),
),
...toTools(localPreviews, rawLocalPreviewTool),
...toTools(confirmedWrites, operationTool),
...orgTools,
} as Record<string, ReturnType<typeof createTool>>),
surface: "mcp",
});
/** Applies a previously-staged billing write after the user confirms it. */
export const executeConfirmedBillingAction = ({
auth,
toolName,
request,
}: {
auth: AutumnMcpAuth;
toolName: ConfirmedWriteToolName;
request: unknown;
}) =>
callAutumn({
auth,
endpoint: endpointByTool[toolName],
request: schemaByTool[toolName].parse(request),
});
/**
* Agent toolset: destructive operations and billing writes are staged as pending
* actions (preview-first), then applied via `confirmBillingAction` once approved.
*/
const createAgentAutumnOperationToolset = () => ({
...toTools(
operations.filter(({ destructive }) => !destructive),
operationTool,
),
...toTools(
operations.filter(({ destructive }) => destructive),
agentPendingWriteTool,
),
...toTools(billingPreviews, agentBillingPreviewTool),
...toTools(localPreviews, agentLocalPreviewTool),
dateToEpochMilliseconds: dateToEpochMillisecondsTool,
confirmBillingAction: createTool({
id: "confirmBillingAction",
description:
"Apply the latest pending billing action after the user semantically confirms the preview.",
inputSchema: z.object({}).strict(),
execute: async (_input, context) => {
const auth = getAutumnAuth(context);
logTool("confirm-start", { env: auth.env });
const action = await claimLatestPendingAction(auth);
logTool("confirm-claimed", { toolName: action.toolName });
const result = await executeConfirmedBillingAction({
auth,
toolName: action.toolName,
request: action.request,
});
return {
message: `Confirmed and applied ${action.toolName}.`,
result,
};
},
}),
});
export const createAgentAutumnOperationTools = () =>
instrumentToolsWithAnalytics({
tools: createAgentAutumnOperationToolset(),
surface: "agent",
});

View File

@@ -0,0 +1,34 @@
import { createTool } from "@mastra/core/tools";
import * as z from "zod/v4";
import { getAutumnAuth } from "../server/auth/auth.js";
import { mcpAnnotations } from "./utils/annotations.js";
import { callAutumnGet } from "./utils/client.js";
const organizationMeSchema = z
.object({
name: z.string(),
slug: z.string(),
env: z.string(),
})
.strict();
const signalOf = (context: { mcp?: { extra?: { signal?: AbortSignal } } }) =>
context?.mcp?.extra?.signal;
export const orgTools = {
getCurrentOrganization: createTool({
id: "getCurrentOrganization",
description:
"Fetch the current Autumn organization name, slug, and environment.",
inputSchema: z.object({}).strict(),
mcp: { annotations: mcpAnnotations() },
execute: async (_input, context) =>
organizationMeSchema.parse(
await callAutumnGet({
auth: getAutumnAuth(context),
endpoint: "/v1/organization/me",
signal: signalOf(context),
}),
),
}),
} as const;

View File

@@ -0,0 +1,43 @@
import {
CreatePlanParamsV2Schema,
GetPlanParamsV0Schema,
ListPlanParamsSchema,
} from "@autumn/shared/publicApiSchemas";
import { createDomainTools } from "./utils/builders.js";
import type { ToolDomain } from "./utils/types.js";
const endpoints = {
listPlans: "/v1/plans.list",
createPlan: "/v1/plans.create",
getPlan: "/v1/plans.get",
} as const;
const schemas = {
listPlans: ListPlanParamsSchema,
createPlan: CreatePlanParamsV2Schema,
getPlan: GetPlanParamsV0Schema,
} as const;
const { operation } = createDomainTools({ endpoints, schemas });
const domain = {
operations: [
operation({
id: "listPlans",
description:
"List Autumn plans. This is usually a cheap full scan; filter returned plans locally and use matching id/version pairs before customer queries based on plan attributes.",
}),
operation({
id: "createPlan",
description:
"Create an Autumn plan. Destructive configuration write: gather plan_id, name, price, features/items, trials, and confirmation before running.",
destructive: true,
}),
operation({
id: "getPlan",
description: "Fetch one Autumn plan by id and optional version.",
}),
],
} satisfies ToolDomain;
export const plans = { endpoints, schemas, domain };

View File

@@ -0,0 +1,13 @@
/** MCP tool hints describing the side effects of a tool call. */
export const mcpAnnotations = ({
destructive = false,
idempotent = false,
}: {
destructive?: boolean;
idempotent?: boolean;
} = {}) => ({
readOnlyHint: !destructive && !idempotent,
destructiveHint: destructive,
idempotentHint: idempotent,
openWorldHint: false,
});

View File

@@ -0,0 +1,100 @@
import type * as z from "zod/v4";
import type {
BillingPreviewToolConfig,
ConfirmedWriteToolName,
LocalPreviewToolConfig,
OperationToolConfig,
} from "./types.js";
/**
* Domain-scoped config composers bound to a domain's `endpoints` and `schemas`
* maps. A tool's `id` keys into both maps, so each tool declares its id,
* description, and semantics once — the schema and endpoint are looked up rather
* than repeated. The `id` is type-checked against the relevant map keys.
*/
export const createDomainTools = <
E extends Record<string, string>,
S extends Record<string, z.ZodType>,
>({
endpoints,
schemas,
}: {
endpoints: E;
schemas: S;
}) => {
type EndpointId = Extract<keyof E & keyof S, string>;
type SchemaId = Extract<keyof S, string>;
/** A tool that calls its endpoint directly with the parsed request. */
const operation = ({
id,
description,
destructive = false,
idempotent = false,
}: {
id: EndpointId;
description: string;
destructive?: boolean;
idempotent?: boolean;
}): OperationToolConfig => ({
id,
description,
schema: schemas[id],
endpoint: endpoints[id],
destructive,
idempotent,
});
/** A preview tool that stages a pending billing write via its preview endpoint. */
const billingPreview = ({
id,
description,
writeToolName,
}: {
id: EndpointId;
description: string;
writeToolName: ConfirmedWriteToolName;
}): BillingPreviewToolConfig => ({
id,
description,
schema: schemas[id],
previewEndpoint: endpoints[id],
writeToolName,
});
/** A destructive write applied only after the user confirms a preview. */
const confirmedWrite = ({
id,
description,
}: {
id: EndpointId;
description: string;
}): OperationToolConfig => ({
id,
description,
schema: schemas[id],
endpoint: endpoints[id],
destructive: true,
});
/** A preview computed locally (no Autumn call) before a billing write. */
const localPreview = ({
id,
description,
writeToolName,
preview,
}: {
id: SchemaId;
description: string;
writeToolName: ConfirmedWriteToolName;
preview: (request: unknown) => unknown;
}): LocalPreviewToolConfig => ({
id,
description,
schema: schemas[id],
writeToolName,
preview,
});
return { operation, billingPreview, confirmedWrite, localPreview };
};

View File

@@ -0,0 +1,74 @@
import {
type AutumnMcpAuth,
createAutumnClient,
} from "../../server/auth/auth.js";
const parseBody = (text: string): unknown => {
try {
return JSON.parse(text);
} catch {
return text;
}
};
/** POSTs a request to an Autumn endpoint using the caller's resolved auth. */
export const callAutumn = async ({
auth,
endpoint,
request,
signal,
}: {
auth: AutumnMcpAuth;
endpoint: string;
request: unknown;
signal?: AbortSignal | undefined;
}) => {
const client = createAutumnClient(auth);
const init: RequestInit = {
method: "POST",
headers: client.headers,
body: JSON.stringify(request),
};
if (signal) init.signal = signal;
const response = await fetch(new URL(endpoint, client.baseUrl), init);
const text = await response.text();
const body = text ? parseBody(text) : null;
if (!response.ok) {
throw new Error(
`Autumn API request failed (${response.status}): ${
typeof body === "string" ? body : JSON.stringify(body)
}`,
);
}
return body;
};
export const callAutumnGet = async ({
auth,
endpoint,
signal,
}: {
auth: AutumnMcpAuth;
endpoint: string;
signal?: AbortSignal | undefined;
}) => {
const client = createAutumnClient(auth);
const init: RequestInit = {
method: "GET",
headers: client.headers,
};
if (signal) init.signal = signal;
const response = await fetch(new URL(endpoint, client.baseUrl), init);
const text = await response.text();
const body = text ? parseBody(text) : null;
if (!response.ok) {
throw new Error(
`Autumn API request failed (${response.status}): ${
typeof body === "string" ? body : JSON.stringify(body)
}`,
);
}
return body;
};

View File

@@ -0,0 +1,53 @@
import { createTool } from "@mastra/core/tools";
import { isValid, parseISO } from "date-fns";
import * as z from "zod/v4";
/**
* Parses an ISO date/timestamp string to UTC epoch milliseconds. Date-only
* values (`YYYY-MM-DD`) and zone-less timestamps are treated as UTC. Returns
* `null` when the input is not a valid date.
*/
const parseToEpochMilliseconds = (value: string): number | null => {
const normalized = /^\d{4}-\d{2}-\d{2}$/.test(value)
? `${value}T00:00:00.000`
: value;
const hasExplicitZone = /(?:z|[+-]\d{2}:?\d{2})$/i.test(normalized);
const parsed = parseISO(hasExplicitZone ? normalized : `${normalized}Z`);
return isValid(parsed) ? parsed.getTime() : null;
};
/** Accepts epoch milliseconds or an ISO date/timestamp string; outputs epoch ms. */
export const epochMillisecondsSchema = z
.union([z.number(), z.string()])
.transform((value, context) => {
if (typeof value === "number") {
if (Number.isFinite(value)) return value;
} else {
const epoch = parseToEpochMilliseconds(value);
if (epoch !== null) return epoch;
}
context.addIssue({
code: "custom",
message: "Expected epoch milliseconds or an ISO date/timestamp string.",
});
return z.NEVER;
});
const toEpochMilliseconds = (date: string): number => {
const epoch = parseToEpochMilliseconds(date);
if (epoch === null) throw new Error(`Invalid date: ${date}`);
return epoch;
};
export const dateToEpochMillisecondsTool = createTool({
id: "dateToEpochMilliseconds",
description:
"Convert a calendar date or ISO timestamp to UTC epoch milliseconds for API timestamp fields. Date-only values default to midnight UTC; include an explicit offset in the date string when timezone matters.",
inputSchema: z
.object({
date: z.string(),
})
.strict(),
execute: async ({ date }) => toEpochMilliseconds(date),
});

View File

@@ -0,0 +1,5 @@
/** Opt-in tracing for the pending-action flow (set MCP_DEBUG_PENDING_ACTIONS=1). */
export const logTool = (event: string, data: Record<string, unknown>) => {
if (process.env.MCP_DEBUG_PENDING_ACTIONS !== "1") return;
console.log(`[mcp:agent-tools] ${event} ${JSON.stringify(data)}`);
};

View File

@@ -0,0 +1,164 @@
import { createTool } from "@mastra/core/tools";
import * as z from "zod/v4";
import { createPendingAction } from "../../agent/pending-actions.js";
import { getAutumnAuth } from "../../server/auth/auth.js";
import { mcpAnnotations } from "./annotations.js";
import { callAutumn } from "./client.js";
import { logTool } from "./debug.js";
import {
type BillingPreviewToolConfig,
isConfirmedWriteToolName,
type LocalPreviewToolConfig,
type OperationToolConfig,
} from "./types.js";
const PENDING_MESSAGE =
"Preview ready. Ask the user to explicitly apply or approve this exact change.";
/** Reads the `request` payload out of a tool input without casting. */
const getRequest = (input: unknown): unknown =>
input && typeof input === "object" && "request" in input
? input.request
: undefined;
const signalOf = (context: { mcp?: { extra?: { signal?: AbortSignal } } }) =>
context?.mcp?.extra?.signal;
/** Builds a `{ id: tool }` record from a list of configs. */
export const toTools = <Config extends { id: string }>(
configs: Config[],
create: (config: Config) => ReturnType<typeof createTool>,
) => Object.fromEntries(configs.map((config) => [config.id, create(config)]));
/** Calls an Autumn endpoint directly with the parsed request. */
export const operationTool = ({
id,
description,
schema,
endpoint,
destructive = false,
idempotent = false,
}: OperationToolConfig) =>
createTool({
id,
description,
inputSchema: z.object({ request: schema }).strict(),
mcp: { annotations: mcpAnnotations({ destructive, idempotent }) },
execute: (input, context) =>
callAutumn({
auth: getAutumnAuth(context),
endpoint,
request: schema.parse(getRequest(input)),
signal: signalOf(context),
}),
});
/** Agent variant: previews via Autumn, then stages a pending billing write. */
export const agentBillingPreviewTool = ({
id,
description,
schema,
previewEndpoint,
writeToolName,
}: BillingPreviewToolConfig) =>
createTool({
id,
description: `${description} Store the exact pending billing action for later confirmation.`,
inputSchema: z.object({ request: schema }).strict(),
mcp: { annotations: mcpAnnotations() },
execute: async (input, context) => {
const parsedRequest = schema.parse(getRequest(input));
const auth = getAutumnAuth(context);
logTool("preview-start", { previewTool: id, writeToolName });
const preview = await callAutumn({
auth,
endpoint: previewEndpoint,
request: parsedRequest,
signal: signalOf(context),
});
await createPendingAction({
auth,
toolName: writeToolName,
request: parsedRequest,
preview: JSON.stringify(preview),
});
logTool("preview-stored", { previewTool: id, writeToolName });
return { preview, pending: true, message: PENDING_MESSAGE };
},
});
/** Raw variant of a local preview: just returns the computed preview. */
export const rawLocalPreviewTool = ({
id,
description,
schema,
preview,
}: LocalPreviewToolConfig) =>
createTool({
id,
description,
inputSchema: z.object({ request: schema }).strict(),
mcp: { annotations: mcpAnnotations() },
execute: async (input) => preview(schema.parse(getRequest(input))),
});
/** Agent variant of a local preview: stages a pending billing write. */
export const agentLocalPreviewTool = ({
id,
description,
schema,
writeToolName,
preview,
}: LocalPreviewToolConfig) =>
createTool({
id,
description: `${description} Store the exact pending billing action for later confirmation.`,
inputSchema: z.object({ request: schema }).strict(),
mcp: { annotations: mcpAnnotations() },
execute: async (input, context) => {
const parsedRequest = schema.parse(getRequest(input));
const previewResult = preview(parsedRequest);
await createPendingAction({
auth: getAutumnAuth(context),
toolName: writeToolName,
request: parsedRequest,
preview: JSON.stringify(previewResult),
});
return {
preview: previewResult,
pending: true,
message: PENDING_MESSAGE,
};
},
});
/** Agent variant of a destructive operation: stages the request instead of applying it. */
export const agentPendingWriteTool = ({
id,
description,
schema,
}: OperationToolConfig) =>
createTool({
id,
description: `${description} This internal agent tool stores the exact request for later confirmation instead of applying it immediately.`,
inputSchema: z.object({ request: schema }).strict(),
mcp: { annotations: mcpAnnotations() },
execute: async (input, context) => {
if (!isConfirmedWriteToolName(id)) {
throw new Error(`Cannot stage a pending write for tool: ${id}`);
}
const parsedRequest = schema.parse(getRequest(input));
await createPendingAction({
auth: getAutumnAuth(context),
toolName: id,
request: parsedRequest,
preview: JSON.stringify(parsedRequest),
});
return {
pending: true,
request: parsedRequest,
message:
"Request ready. Ask the user to explicitly apply or approve this exact change.",
};
},
});

View File

@@ -0,0 +1,47 @@
import type { createTool } from "@mastra/core/tools";
import * as z from "zod/v4";
type AnyTool = ReturnType<typeof createTool>;
export const INTENT_DESCRIPTION =
"Required. One concise sentence, in plain language, describing what the user " +
"asked you (the agent) to do — their original request in their own terms, " +
"not a restatement of the arguments or the tool name. If this call is one " +
'step toward a larger ask, state that larger ask. Example: "Find customers ' +
'on the Pro plan so we can email them about the new add-on."';
/** Required single-sentence statement of what the caller is trying to do. */
export const intentSchema = z.string().min(1).describe(INTENT_DESCRIPTION);
/** Reads the `intent` string out of a tool input without casting. */
export const getIntent = (input: unknown): string | undefined =>
input &&
typeof input === "object" &&
"intent" in input &&
typeof input.intent === "string"
? input.intent
: undefined;
/**
* Adds a required `intent` field to every tool's input schema, in place, so
* external MCP clients must declare their goal on every call. Call this once on
* a fully-built toolset (the intent is captured by the analytics layer).
*
* Tools whose input isn't a plain object are left untouched.
*/
export const requireIntentOnTools = <T extends Record<string, AnyTool>>(
tools: T,
): T => {
for (const tool of Object.values(tools)) {
const schema = tool.inputSchema;
if (schema instanceof z.ZodObject) {
// Runtime value is a plain zod object, but Mastra types the field as its
// JSON-schema-augmented schema (incompatible at the type level only), so
// route the reassignment through `unknown`.
tool.inputSchema = schema.extend({
intent: intentSchema,
}) as unknown as typeof tool.inputSchema;
}
}
return tools;
};

View File

@@ -0,0 +1,61 @@
import type * as z from "zod/v4";
/**
* Tool names that mutate billing state. These are the only tools that can be
* staged as a pending action and later applied via `confirmBillingAction`.
* Declared as a tuple so the union type and runtime guard stay in sync.
*/
export const CONFIRMED_WRITE_TOOL_NAMES = [
"attach",
"updateSubscription",
"createPlan",
"createSchedule",
"createBalance",
] as const;
export type ConfirmedWriteToolName =
(typeof CONFIRMED_WRITE_TOOL_NAMES)[number];
export const isConfirmedWriteToolName = (
id: string,
): id is ConfirmedWriteToolName =>
CONFIRMED_WRITE_TOOL_NAMES.some((name) => name === id);
/** A tool that calls a single Autumn endpoint with the parsed request. */
export type OperationToolConfig = {
id: string;
description: string;
schema: z.ZodType;
endpoint: string;
destructive?: boolean;
idempotent?: boolean;
};
/** A preview tool whose result is staged as a pending billing write. */
export type BillingPreviewToolConfig = {
id: string;
description: string;
schema: z.ZodType;
previewEndpoint: string;
writeToolName: ConfirmedWriteToolName;
};
/** A preview tool computed locally (no Autumn call) before a billing write. */
export type LocalPreviewToolConfig = {
id: string;
description: string;
schema: z.ZodType;
writeToolName: ConfirmedWriteToolName;
preview: (request: unknown) => unknown;
};
/**
* One business domain's tool declarations, grouped by behaviour. The top-level
* `index.ts` composes these into the raw (MCP) and agent toolsets.
*/
export type ToolDomain = {
operations?: OperationToolConfig[];
billingPreviews?: BillingPreviewToolConfig[];
localPreviews?: LocalPreviewToolConfig[];
confirmedWrites?: OperationToolConfig[];
};

View File

@@ -45,10 +45,13 @@ test("previews and creates an entity-scoped expiring credit grant", async () =>
},
});
await generate([
"Looking to give entity ent_689d243e2c03da31e0ac90d0 on customer cus_687672c4c0d36fa5679f8c7a 50k credits on the credits feature that expire in 2 months. Can you set that up in Autumn?",
"These should not be permanent credits.",
], 6);
await generate(
[
"Looking to give entity ent_689d243e2c03da31e0ac90d0 on customer cus_687672c4c0d36fa5679f8c7a 50k credits on the credits feature that expire in 2 months. Can you set that up in Autumn?",
"These should not be permanent credits.",
],
6,
);
expectToolCall(toolCalls, "previewCreateBalance", expectedGrant);
expectNoApiCall(api, "createBalance");

View File

@@ -35,14 +35,18 @@ test("lists all matching customers with compound filters and cursor pagination",
name: "Acme US",
email: "billing@acme.example",
processors: { stripe: { id: "cus_stripe_us" } },
subscriptions: [{ planId: "pro", version: 3, status: "active" }],
subscriptions: [
{ planId: "pro", version: 3, status: "active" },
],
},
{
id: "cus_acme_eu",
name: "Acme EU",
email: "finance@acme.example",
processors: { stripe: { id: "cus_stripe_eu" } },
subscriptions: [{ planId: "pro", version: 2, status: "active" }],
subscriptions: [
{ planId: "pro", version: 2, status: "active" },
],
},
],
next_cursor: "cursor_acme_2",
@@ -175,7 +179,9 @@ test("resolves plan attributes before listing scheduled Vercel customers", async
return (
call.body.subscription_status === "scheduled" &&
call.body.processors?.includes("vercel") &&
Array.from(new Set(versions ?? [])).sort().join(",") === "4,5"
Array.from(new Set(versions ?? []))
.sort()
.join(",") === "4,5"
);
});
expect(

View File

@@ -1,244 +0,0 @@
import { describe, expect, mock, test } from "bun:test";
import type { AutumnMcpAuth } from "../../../../src/mcp-server/agent/auth.js";
import { setPendingActionsRedis } from "../../../../src/mcp-server/agent/pending-actions.js";
import { createTestRedis } from "../../../utils/test-redis.js";
const systemPrompts: string[] = [];
let agentConfirms = true;
let agentCalls = 0;
mock.module("@mastra/core/agent", () => ({
Agent: class {
private readonly tools: Record<string, { execute?: Function }>;
constructor(config: { tools: Record<string, { execute?: Function }> }) {
this.tools = config.tools;
}
async generate(
message: string,
options: {
requestContext: unknown;
context: { content: string }[];
},
) {
agentCalls += 1;
const systemPrompt = options.context[0]?.content ?? "";
systemPrompts.push(systemPrompt);
const context = { requestContext: options.requestContext };
if (message.toLowerCase().includes("customers")) {
const result = await this.tools.listCustomers.execute?.(
{ request: {} },
context,
);
return { text: JSON.stringify(result) };
}
if (agentConfirms && systemPrompt.includes("Pending billing action")) {
const result = await this.tools.confirmBillingAction.execute?.(
{},
context,
);
return { text: JSON.stringify(result) };
}
if (systemPrompt.includes("Pending billing action")) {
return { text: "There is no pending billing action to confirm." };
}
const result = await this.tools.previewAttach.execute?.(
{ request: { customer_id: "cus_1", plan_id: "pro" } },
context,
);
return { text: JSON.stringify(result) };
}
},
}));
const { createAskAutumnTool } = await import(
"../../../../src/mcp-server/agent/ask-autumn.js"
);
const auth: AutumnMcpAuth = {
apiKey: "sk_test",
env: "sandbox",
principalId: "user_1",
resource: "http://localhost:2718/mcp",
scopes: ["billing:read", "billing:write"],
serverURL: "http://localhost:8080",
};
const mockFetch = (calls: { url: string; body: unknown }[]) => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (url, init) => {
const body = JSON.parse(init?.body as string);
calls.push({ url: String(url), body });
if (String(url).endsWith("/v1/billing.preview_attach")) {
return Response.json({ total: 50 });
}
if (String(url).endsWith("/v1/billing.attach")) {
return Response.json({ applied: true });
}
if (String(url).endsWith("/v1/customers.list")) {
return Response.json({ customers: [] });
}
return Response.json({ error: "unexpected" }, { status: 500 });
}) as typeof fetch;
return () => {
globalThis.fetch = originalFetch;
};
};
describe("ask_autumn billing confirmation flow", () => {
test("confirms a pending attach across separate ask_autumn calls", async () => {
setPendingActionsRedis(createTestRedis());
systemPrompts.length = 0;
agentConfirms = true;
agentCalls = 0;
const calls: { url: string; body: unknown }[] = [];
const restoreFetch = mockFetch(calls);
try {
const tool = createAskAutumnTool();
if (!tool.execute) throw new Error("ask_autumn is not executable");
const context = { mcp: { extra: { authInfo: auth } } } as never;
const preview = await tool.execute(
{ message: "attach pro to cus_1" },
context,
);
expect(String(preview)).toContain("Preview ready");
expect(systemPrompts.at(-1)).not.toContain("Pending billing action");
expect(calls.map((call) => call.url)).toEqual([
"http://localhost:8080/v1/billing.preview_attach",
]);
const confirm = await tool.execute({ message: "confirm" }, context);
expect(String(confirm)).toContain("Confirmed and applied attach.");
expect(calls).toEqual([
{
url: "http://localhost:8080/v1/billing.preview_attach",
body: {
customer_id: "cus_1",
plan_id: "pro",
redirect_mode: "if_required",
},
},
{
url: "http://localhost:8080/v1/billing.attach",
body: {
customer_id: "cus_1",
plan_id: "pro",
redirect_mode: "if_required",
},
},
]);
} finally {
restoreFetch();
}
});
test("semantic confirmation gets the pending preview context", async () => {
setPendingActionsRedis(createTestRedis());
systemPrompts.length = 0;
agentConfirms = true;
agentCalls = 0;
const calls: { url: string; body: unknown }[] = [];
const restoreFetch = mockFetch(calls);
try {
const tool = createAskAutumnTool();
if (!tool.execute) throw new Error("ask_autumn is not executable");
const context = { mcp: { extra: { authInfo: auth } } } as never;
await tool.execute({ message: "attach pro to cus_1" }, context);
expect(agentCalls).toBe(1);
const confirm = await tool.execute(
{ message: "that looks good, go ahead" },
context,
);
expect(String(confirm)).toContain("Confirmed and applied attach.");
expect(agentCalls).toBe(2);
expect(systemPrompts.at(-1)).toContain("Pending billing action:");
expect(systemPrompts.at(-1)).toContain("Preview:");
expect(systemPrompts.at(-1)).toContain('"total":50');
expect(calls.map((call) => call.url)).toEqual([
"http://localhost:8080/v1/billing.preview_attach",
"http://localhost:8080/v1/billing.attach",
]);
} finally {
restoreFetch();
}
});
test("question-like confirmation text does not bypass the agent", async () => {
setPendingActionsRedis(createTestRedis());
systemPrompts.length = 0;
agentConfirms = false;
agentCalls = 0;
const calls: { url: string; body: unknown }[] = [];
const restoreFetch = mockFetch(calls);
try {
const tool = createAskAutumnTool();
if (!tool.execute) throw new Error("ask_autumn is not executable");
const context = { mcp: { extra: { authInfo: auth } } } as never;
await tool.execute({ message: "attach pro to cus_1" }, context);
const response = await tool.execute(
{ message: "can you confirm what this changes?" },
context,
);
expect(String(response)).toContain("no pending billing action");
expect(agentCalls).toBe(2);
expect(systemPrompts.at(-1)).toContain("Pending billing action:");
expect(calls.map((call) => call.url)).toEqual([
"http://localhost:8080/v1/billing.preview_attach",
]);
} finally {
restoreFetch();
}
});
test("read requests continue when pending lookup fails", async () => {
setPendingActionsRedis({
multi: () => {
throw new Error("unavailable");
},
get: async () => {
throw new Error("unavailable");
},
getdel: async () => {
throw new Error("unavailable");
},
del: async () => undefined,
keys: async () => [],
});
systemPrompts.length = 0;
agentConfirms = true;
agentCalls = 0;
const calls: { url: string; body: unknown }[] = [];
const restoreFetch = mockFetch(calls);
try {
const tool = createAskAutumnTool();
if (!tool.execute) throw new Error("ask_autumn is not executable");
const context = { mcp: { extra: { authInfo: auth } } } as never;
const response = await tool.execute({ message: "list customers" }, context);
expect(String(response)).toContain("customers");
expect(agentCalls).toBe(1);
expect(calls.map((call) => call.url)).toEqual([
"http://localhost:8080/v1/customers.list",
]);
} finally {
restoreFetch();
}
});
});

View File

@@ -1,7 +1,10 @@
import { describe, expect, test } from "bun:test";
import { Scopes } from "@autumn/shared/scopeDefinitions";
import type { AutumnMcpAuth } from "../../../../src/mcp-server/agent/auth.js";
import { prepareAxiomQuery, resolveAutumnOrgId } from "../../../../src/mcp-server/agent/axiom.js";
import {
prepareAxiomQuery,
resolveAutumnOrgId,
} from "../../../../src/agent/axiom.js";
import type { AutumnMcpAuth } from "../../../../src/server/auth/auth.js";
const auth: AutumnMcpAuth & { orgId: string } = {
apiKey: "sk_test",

View File

@@ -1,11 +1,11 @@
import { describe, expect, test } from "bun:test";
import type { AutumnMcpAuth } from "../../../../src/mcp-server/agent/auth.js";
import {
claimLatestPendingAction,
clearPendingActions,
createPendingAction,
setPendingActionsRedis,
} from "../../../../src/mcp-server/agent/pending-actions.js";
} from "../../../../src/agent/pending-actions.js";
import type { AutumnMcpAuth } from "../../../../src/server/auth/auth.js";
import { createTestRedis } from "../../../utils/test-redis.js";
setPendingActionsRedis(createTestRedis());
@@ -38,7 +38,9 @@ describe("pending billing actions", () => {
plan_id: "pro",
},
});
await expect(claimLatestPendingAction(auth())).rejects.toThrow("No pending");
await expect(claimLatestPendingAction(auth())).rejects.toThrow(
"No pending",
);
});
test("claims the latest matching action without exposing tokens", async () => {
@@ -75,11 +77,11 @@ describe("pending billing actions", () => {
claimLatestPendingAction(auth()),
]);
expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(
1,
);
expect(results.filter((result) => result.status === "rejected")).toHaveLength(
1,
);
expect(
results.filter((result) => result.status === "fulfilled"),
).toHaveLength(1);
expect(
results.filter((result) => result.status === "rejected"),
).toHaveLength(1);
});
});

View File

@@ -1,9 +1,6 @@
import { describe, expect, test } from "bun:test";
import {
createAskAutumnMCPServer,
createAutumnOperationsMCPServer,
} from "../../../../src/mcp-server/agent/server.js";
import { autumnMcpResourceUris } from "../../../../src/mcp-server/agent/resources.js";
import { autumnMcpResourceUris } from "../../../../src/resources/index.js";
import { createAutumnOperationsMCPServer } from "../../../../src/server/server.js";
describe("Autumn MCP server", () => {
test("public server advertises raw operation tools", async () => {
@@ -15,8 +12,8 @@ describe("Autumn MCP server", () => {
"getCustomer",
"listPlans",
"createPlan",
"createBalance",
"getPlan",
"createBalance",
"previewAttach",
"previewUpdateSubscription",
"previewCreateSchedule",
@@ -24,6 +21,7 @@ describe("Autumn MCP server", () => {
"attach",
"updateSubscription",
"createSchedule",
"getCurrentOrganization",
]);
expect(tools.tools.map((tool) => tool.name)).not.toContain("ask_autumn");
expect(tools.tools.map((tool) => tool.name)).not.toContain(
@@ -31,14 +29,6 @@ describe("Autumn MCP server", () => {
);
});
test("internal server advertises only ask_autumn", async () => {
const tools = await createAskAutumnMCPServer().getToolListInfo();
expect(tools.tools.map((tool) => tool.name)).toEqual(["ask_autumn"]);
expect(tools.tools.map((tool) => tool.name)).not.toContain("attach");
expect(tools.tools.map((tool) => tool.name)).not.toContain("listCustomers");
});
test("billing tool schemas avoid legacy JSON Schema ids", async () => {
const tools = await createAutumnOperationsMCPServer().getToolListInfo();
@@ -53,11 +43,8 @@ describe("Autumn MCP server", () => {
}
});
test.each([
["public", createAutumnOperationsMCPServer],
["internal", createAskAutumnMCPServer],
])("%s server exposes Autumn composition docs", async (_name, createServer) => {
const server = createServer();
test("public server exposes Autumn composition docs", async () => {
const server = createAutumnOperationsMCPServer();
const resources = await server.listResources();
expect(resources.resources.map((resource) => resource.uri)).toEqual(

View File

@@ -1,17 +1,17 @@
import { describe, expect, test } from "bun:test";
import type { AutumnMcpAuth } from "../../../../src/mcp-server/agent/auth.js";
import {
clearPendingActions,
claimLatestPendingAction,
clearPendingActions,
createPendingAction,
setPendingActionsRedis,
} from "../../../../src/mcp-server/agent/pending-actions.js";
import { createTestRedis } from "../../../utils/test-redis.js";
} from "../../../../src/agent/pending-actions.js";
import type { AutumnMcpAuth } from "../../../../src/server/auth/auth.js";
import {
createAgentAutumnOperationTools,
createRawAutumnOperationTools,
dateToEpochMillisecondsTool,
} from "../../../../src/mcp-server/agent/tools.js";
} from "../../../../src/tools/index.js";
import { createTestRedis } from "../../../utils/test-redis.js";
setPendingActionsRedis(createTestRedis());
@@ -33,7 +33,9 @@ describe("Autumn operation tools", () => {
const tools = createRawAutumnOperationTools();
expect(tools.listPlans.description).toContain("cheap full scan");
expect(tools.listPlans.description).toContain("filter returned plans locally");
expect(tools.listPlans.description).toContain(
"filter returned plans locally",
);
expect(tools.listCustomers.description).toContain("plans");
expect(tools.listCustomers.description).toContain("paginate");
expect(tools.createPlan.description).toContain("confirmation");
@@ -41,6 +43,7 @@ describe("Autumn operation tools", () => {
expect(tools.previewCreateBalance.description).toContain("Does not mutate");
expect(tools.createSchedule.description).toContain("starts_at");
expect(tools.previewCreateSchedule.description).toContain("billing impact");
expect(tools.getCurrentOrganization.description).toContain("organization");
});
test("write tools are annotated as destructive", () => {
@@ -65,6 +68,7 @@ describe("Autumn operation tools", () => {
"previewUpdateSubscription",
"previewCreateSchedule",
"previewCreateBalance",
"getCurrentOrganization",
] as const) {
expect(tools[name].mcp?.annotations?.destructiveHint).toBe(false);
}
@@ -72,7 +76,8 @@ describe("Autumn operation tools", () => {
test("dateToEpochMilliseconds converts UTC dates and offsets", async () => {
const tool = dateToEpochMillisecondsTool as ExecutableTool;
if (!tool.execute) throw new Error("dateToEpochMilliseconds is not executable");
if (!tool.execute)
throw new Error("dateToEpochMilliseconds is not executable");
await expect(tool.execute({ date: "2027-01-01" }, {})).resolves.toBe(
Date.UTC(2027, 0, 1),
@@ -85,7 +90,9 @@ describe("Autumn operation tools", () => {
test("raw createCustomer calls the get-or-create endpoint", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (url, init) => {
expect(String(url)).toBe("http://localhost:8080/v1/customers.get_or_create");
expect(String(url)).toBe(
"http://localhost:8080/v1/customers.get_or_create",
);
expect(JSON.parse(init?.body as string)).toMatchObject({
customer_id: "cus_1",
email: "charlie@example.com",
@@ -99,7 +106,10 @@ describe("Autumn operation tools", () => {
await expect(
tool.execute(
{ request: { customer_id: "cus_1", email: "charlie@example.com" } },
{
intent: "create a customer",
request: { customer_id: "cus_1", email: "charlie@example.com" },
},
{ mcp: { extra: { authInfo: auth } } } as never,
),
).resolves.toEqual({ id: "cus_1" });
@@ -125,8 +135,10 @@ describe("Autumn operation tools", () => {
await expect(
tool.execute(
{ request: { plan_id: "pro", name: "Pro" } },
{ mcp: { extra: { authInfo: auth } } } as never,
{ intent: "create a plan", request: { plan_id: "pro", name: "Pro" } },
{
mcp: { extra: { authInfo: auth } },
} as never,
),
).resolves.toEqual({ id: "pro" });
} finally {
@@ -137,7 +149,9 @@ describe("Autumn operation tools", () => {
test("raw createSchedule calls the create schedule endpoint", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (url, init) => {
expect(String(url)).toBe("http://localhost:8080/v1/billing.create_schedule");
expect(String(url)).toBe(
"http://localhost:8080/v1/billing.create_schedule",
);
expect(JSON.parse(init?.body as string)).toMatchObject({
customer_id: "cus_1",
});
@@ -151,11 +165,10 @@ describe("Autumn operation tools", () => {
await expect(
tool.execute(
{
intent: "create a schedule",
request: {
customer_id: "cus_1",
phases: [
{ starts_at: Date.now(), plans: [{ plan_id: "pro" }] },
],
phases: [{ starts_at: Date.now(), plans: [{ plan_id: "pro" }] }],
},
},
{ mcp: { extra: { authInfo: auth } } } as never,
@@ -181,13 +194,13 @@ describe("Autumn operation tools", () => {
try {
const tool = createRawAutumnOperationTools().previewCreateBalance;
if (!tool.execute) throw new Error("previewCreateBalance is not executable");
if (!tool.execute)
throw new Error("previewCreateBalance is not executable");
await expect(
tool.execute(
{ request },
{ mcp: { extra: { authInfo: auth } } } as never,
),
tool.execute({ intent: "preview a balance grant", request }, {
mcp: { extra: { authInfo: auth } },
} as never),
).resolves.toMatchObject({
action: "createBalance",
request,
@@ -218,6 +231,7 @@ describe("Autumn operation tools", () => {
await expect(
tool.execute(
{
intent: "grant a balance",
request: {
customer_id: "cus_1",
entity_id: "workspace_1",
@@ -255,11 +269,10 @@ describe("Autumn operation tools", () => {
await expect(
tool.execute(
{
intent: "preview a schedule",
request: {
customer_id: "cus_1",
phases: [
{ starts_at: Date.now(), plans: [{ plan_id: "pro" }] },
],
phases: [{ starts_at: Date.now(), plans: [{ plan_id: "pro" }] }],
},
},
{ mcp: { extra: { authInfo: auth } } } as never,
@@ -287,8 +300,13 @@ describe("Autumn operation tools", () => {
await expect(
tool.execute(
{ request: { limit: 5000, search: "charlie" } },
{ mcp: { extra: { authInfo: auth } } } as never,
{
intent: "list customers",
request: { limit: 5000, search: "charlie" },
},
{
mcp: { extra: { authInfo: auth } },
} as never,
),
).resolves.toEqual({ customers: [] });
} finally {
@@ -296,11 +314,47 @@ describe("Autumn operation tools", () => {
}
});
test("raw getCurrentOrganization calls the organization me endpoint", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (url, init) => {
expect(String(url)).toBe("http://localhost:8080/v1/organization/me");
expect(init?.method).toBe("GET");
expect(init?.body).toBeUndefined();
return Response.json({
name: "Unit Tests",
slug: "unit-tests",
env: "sandbox",
});
}) as typeof fetch;
try {
const tool = createRawAutumnOperationTools().getCurrentOrganization;
if (!tool.execute) {
throw new Error("getCurrentOrganization is not executable");
}
await expect(
tool.execute(
{ intent: "check which Autumn organization is connected" },
{ mcp: { extra: { authInfo: auth } } } as never,
),
).resolves.toEqual({
name: "Unit Tests",
slug: "unit-tests",
env: "sandbox",
});
} finally {
globalThis.fetch = originalFetch;
}
});
test("raw previewAttach does not create a pending action", async () => {
await clearPendingActions();
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (url, init) => {
expect(String(url)).toBe("http://localhost:8080/v1/billing.preview_attach");
expect(String(url)).toBe(
"http://localhost:8080/v1/billing.preview_attach",
);
expect(JSON.parse(init?.body as string)).toEqual({
customer_id: "cus_1",
plan_id: "pro",
@@ -315,11 +369,18 @@ describe("Autumn operation tools", () => {
await expect(
tool.execute(
{ request: { customer_id: "cus_1", plan_id: "pro" } },
{ mcp: { extra: { authInfo: auth } } } as never,
{
intent: "preview an attach",
request: { customer_id: "cus_1", plan_id: "pro" },
},
{
mcp: { extra: { authInfo: auth } },
} as never,
),
).resolves.toEqual({ total: 50 });
await expect(claimLatestPendingAction(auth)).rejects.toThrow("No pending");
await expect(claimLatestPendingAction(auth)).rejects.toThrow(
"No pending",
);
} finally {
globalThis.fetch = originalFetch;
}
@@ -343,8 +404,13 @@ describe("Autumn operation tools", () => {
await expect(
tool.execute(
{ request: { customer_id: "cus_1", plan_id: "pro" } },
{ mcp: { extra: { authInfo: auth } } } as never,
{
intent: "attach a plan",
request: { customer_id: "cus_1", plan_id: "pro" },
},
{
mcp: { extra: { authInfo: auth } },
} as never,
),
).resolves.toEqual({ ok: true });
} finally {
@@ -356,7 +422,9 @@ describe("Autumn operation tools", () => {
await clearPendingActions();
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (url, init) => {
expect(String(url)).toBe("http://localhost:8080/v1/billing.preview_attach");
expect(String(url)).toBe(
"http://localhost:8080/v1/billing.preview_attach",
);
expect(JSON.parse(init?.body as string)).toEqual({
customer_id: "cus_1",
plan_id: "pro",
@@ -376,10 +444,9 @@ describe("Autumn operation tools", () => {
if (!tool.execute) throw new Error("previewAttach is not executable");
await expect(
tool.execute(
{ request: { customer_id: "cus_1", plan_id: "pro" } },
{ mcp: { extra: { authInfo: auth } } } as never,
),
tool.execute({ request: { customer_id: "cus_1", plan_id: "pro" } }, {
mcp: { extra: { authInfo: auth } },
} as never),
).resolves.toMatchObject({ pending: true, preview: { total: 50 } });
await expect(claimLatestPendingAction(auth)).resolves.toMatchObject({
@@ -411,10 +478,9 @@ describe("Autumn operation tools", () => {
if (!tool.execute) throw new Error("createPlan is not executable");
await expect(
tool.execute(
{ request: { plan_id: "pro", name: "Pro" } },
{ mcp: { extra: { authInfo: auth } } } as never,
),
tool.execute({ request: { plan_id: "pro", name: "Pro" } }, {
mcp: { extra: { authInfo: auth } },
} as never),
).resolves.toMatchObject({ pending: true });
await expect(claimLatestPendingAction(auth)).resolves.toMatchObject({
@@ -451,10 +517,9 @@ describe("Autumn operation tools", () => {
}
await expect(
tool.execute(
{ request },
{ mcp: { extra: { authInfo: auth } } } as never,
),
tool.execute({ request }, {
mcp: { extra: { authInfo: auth } },
} as never),
).resolves.toMatchObject({ pending: true });
await expect(claimLatestPendingAction(auth)).resolves.toMatchObject({
@@ -491,10 +556,9 @@ describe("Autumn operation tools", () => {
}
await expect(
tool.execute(
{ request },
{ mcp: { extra: { authInfo: auth } } } as never,
),
tool.execute({ request }, {
mcp: { extra: { authInfo: auth } },
} as never),
).resolves.toMatchObject({ pending: true });
await expect(claimLatestPendingAction(auth)).resolves.toMatchObject({
@@ -527,7 +591,8 @@ describe("Autumn operation tools", () => {
try {
const tool = createAgentAutumnOperationTools().confirmBillingAction;
if (!tool.execute) throw new Error("confirmBillingAction is not executable");
if (!tool.execute)
throw new Error("confirmBillingAction is not executable");
await expect(
tool.execute({}, { mcp: { extra: { authInfo: auth } } } as never),
@@ -535,7 +600,9 @@ describe("Autumn operation tools", () => {
message: "Confirmed and applied attach.",
result: { ok: true },
});
await expect(claimLatestPendingAction(auth)).rejects.toThrow("No pending");
await expect(claimLatestPendingAction(auth)).rejects.toThrow(
"No pending",
);
} finally {
globalThis.fetch = originalFetch;
}
@@ -565,7 +632,8 @@ describe("Autumn operation tools", () => {
try {
const tool = createAgentAutumnOperationTools().confirmBillingAction;
if (!tool.execute) throw new Error("confirmBillingAction is not executable");
if (!tool.execute)
throw new Error("confirmBillingAction is not executable");
await expect(
tool.execute({}, { mcp: { extra: { authInfo: auth } } } as never),

View File

@@ -0,0 +1,130 @@
import { describe, expect, test } from "bun:test";
import { createTool } from "@mastra/core/tools";
import * as z from "zod/v4";
import {
instrumentToolsWithAnalytics,
type McpAnalyticsEvent,
setAnalyticsSink,
} from "../../../src/analytics/index.js";
import type { AutumnMcpAuth } from "../../../src/server/auth/auth.js";
const auth: AutumnMcpAuth = {
apiKey: "sk_test",
env: "sandbox",
principalId: "user_1",
resource: "http://localhost:2718/mcp",
scopes: ["billing:read"],
serverURL: "http://localhost:8080",
};
const waitForEvent = async (events: McpAnalyticsEvent[]) => {
for (let i = 0; i < 20; i++) {
if (events.length > 0) return events[0];
await new Promise((resolve) => setTimeout(resolve, 5));
}
throw new Error("Timed out waiting for analytics event");
};
describe("MCP analytics instrumentation", () => {
test("emits successful tool calls", async () => {
const events: McpAnalyticsEvent[] = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
Response.json({ id: "org_1", slug: "acme" })) as unknown as typeof fetch;
setAnalyticsSink({
emit: (event: McpAnalyticsEvent) => events.push(event),
flush: async () => {},
});
try {
const tools = instrumentToolsWithAnalytics({
surface: "mcp",
tools: {
echo: createTool({
id: "echo",
description: "Echo input",
inputSchema: z.object({ intent: z.string(), request: z.unknown() }),
execute: async ({ request }) => ({ request }),
}),
},
});
await expect(
tools.echo.execute?.({ intent: "echo input", request: { ok: true } }, {
mcp: {
extra: {
authInfo: auth,
requestInfo: {
headers: {
"mcp-session-id": "mcp_session_1",
"user-agent": "Claude Code",
},
},
},
},
} as never),
).resolves.toEqual({ request: { ok: true } });
await expect(waitForEvent(events)).resolves.toMatchObject({
event: "mcp.tool_call",
surface: "mcp",
tool: "echo",
intent: "echo input",
status: "ok",
principalId: "user_1",
client: "Claude Code",
sessionId: "mcp_session_1",
context: {
orgId: "org_1",
orgSlug: "acme",
env: "sandbox",
},
input: { ok: true },
output: { request: { ok: true } },
});
} finally {
setAnalyticsSink(undefined);
globalThis.fetch = originalFetch;
}
});
test("emits errors and rethrows", async () => {
const events: McpAnalyticsEvent[] = [];
setAnalyticsSink({
emit: (event: McpAnalyticsEvent) => events.push(event),
flush: async () => {},
});
try {
const tools = instrumentToolsWithAnalytics({
surface: "agent",
tools: {
fail: createTool({
id: "fail",
description: "Fail input",
inputSchema: z.object({ intent: z.string() }),
execute: async () => {
throw new Error("nope");
},
}),
},
});
await expect(
tools.fail.execute?.({ intent: "fail intentionally" }, {
mcp: { extra: { authInfo: auth } },
} as never),
).rejects.toThrow("nope");
await expect(waitForEvent(events)).resolves.toMatchObject({
surface: "agent",
tool: "fail",
intent: "fail intentionally",
status: "error",
error: "nope",
});
} finally {
setAnalyticsSink(undefined);
}
});
});

View File

@@ -1,12 +1,12 @@
import { Scopes } from "@autumn/shared/scopeDefinitions";
import { describe, expect, test } from "bun:test";
import { Scopes } from "@autumn/shared/scopeDefinitions";
import {
buildAuthForRequest,
getProtectedResourceMetadata,
MCP_OAUTH_SCOPES,
OAuthHttpError,
type MCPOAuthFlags,
} from "../../../src/mcp-server/oauth.js";
type OAuthHttpError,
} from "../../../src/server/auth/oauth.js";
const flags = {
"oauth-enabled": true,
@@ -41,7 +41,7 @@ describe("MCP OAuth auth resolution", () => {
status: 401,
error: "invalid_token",
wwwAuthenticate:
'Bearer resource_metadata="http://localhost:2718/.well-known/oauth-protected-resource/mcp"',
'Bearer resource_metadata="http://localhost:2718/.well-known/oauth-protected-resource/mcp", error="invalid_token"',
} satisfies Partial<OAuthHttpError>);
});
@@ -57,47 +57,34 @@ describe("MCP OAuth auth resolution", () => {
status: 401,
error: "invalid_token",
wwwAuthenticate:
'Bearer resource_metadata="http://localhost:2718/.well-known/oauth-protected-resource/internal/mcp"',
'Bearer resource_metadata="http://localhost:2718/.well-known/oauth-protected-resource/internal/mcp", error="invalid_token"',
} satisfies Partial<OAuthHttpError>);
});
test("exchanges a bearer token for Autumn API credentials", async () => {
test("rejects opaque bearer tokens without exchanging them", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (_url, init) => {
expect(init?.headers).toEqual({
Authorization: "Bearer oauth_token",
"Content-Type": "application/json",
});
expect(JSON.parse(init?.body as string)).toEqual({
resource: "http://localhost:2718/mcp",
scopes: MCP_OAUTH_SCOPES,
});
return Response.json({
sandbox_key: "sk_sandbox",
prod_key: "sk_live",
org_id: "org_123",
user_id: "user_123",
client_id: "client_123",
scopes: MCP_OAUTH_SCOPES,
});
}) as typeof fetch;
let fetchCalled = false;
const mockFetch = (async () => {
fetchCalled = true;
return Response.json({});
}) as unknown as typeof fetch;
globalThis.fetch = mockFetch;
try {
const auth = await buildAuthForRequest(
new Headers({
authorization: "Bearer oauth_token",
host: "localhost:2718",
}),
flags as MCPOAuthFlags,
logger,
);
expect(auth.apiKey).toBe("sk_sandbox");
expect(auth.env).toBe("sandbox");
expect(auth.resource).toBe("http://localhost:2718/mcp");
expect(auth.principalId).toBe("oauth:org_123:user_123:client_123");
expect(auth.scopes).toEqual([...MCP_OAUTH_SCOPES]);
expect(auth.orgId).toBe("org_123");
await expect(
buildAuthForRequest(
new Headers({
authorization: "Bearer oauth_token",
host: "localhost:2718",
}),
flags as MCPOAuthFlags,
logger,
),
).rejects.toMatchObject({
status: 401,
error: "invalid_token",
} satisfies Partial<OAuthHttpError>);
expect(fetchCalled).toBe(false);
} finally {
globalThis.fetch = originalFetch;
}
@@ -133,40 +120,24 @@ describe("MCP OAuth auth resolution", () => {
});
test("uses route-specific resource URLs", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (_url, init) => {
expect(JSON.parse(init?.body as string)).toMatchObject({
resource: "http://localhost:2718/internal/mcp",
});
return Response.json({
sandbox_key: "sk_sandbox",
org_id: "org_123",
scopes: MCP_OAUTH_SCOPES,
});
}) as typeof fetch;
const auth = await buildAuthForRequest(
new Headers({
authorization: "Bearer am_sk_test_chat",
host: "localhost:2718",
}),
flags as MCPOAuthFlags,
logger,
"/internal/mcp",
);
try {
const auth = await buildAuthForRequest(
new Headers({
authorization: "Bearer internal_oauth_token",
host: "localhost:2718",
}),
expect(auth.resource).toBe("http://localhost:2718/internal/mcp");
expect(
getProtectedResourceMetadata(
new Headers({ host: "localhost:2718" }),
flags as MCPOAuthFlags,
logger,
"/internal/mcp",
);
expect(auth.resource).toBe("http://localhost:2718/internal/mcp");
expect(
getProtectedResourceMetadata(
new Headers({ host: "localhost:2718" }),
flags as MCPOAuthFlags,
"/internal/mcp",
).resource,
).toBe("http://localhost:2718/internal/mcp");
} finally {
globalThis.fetch = originalFetch;
}
).resource,
).toBe("http://localhost:2718/internal/mcp");
});
test("missing static secret-key returns the auth error path", async () => {

View File

@@ -1,5 +1,5 @@
import { createServer, type IncomingMessage, type Server } from "node:http";
import { afterEach, expect } from "bun:test";
import { createServer, type IncomingMessage, type Server } from "node:http";
import { Agent } from "@mastra/core/agent";
import type { MessageListItem } from "@mastra/core/agent/message-list";
import { Mastra } from "@mastra/core/mastra";
@@ -9,12 +9,9 @@ import type * as z from "zod/v4";
import {
type AutumnMcpAuth,
createRequestContext,
} from "../../src/mcp-server/agent/auth.js";
import { createAutumnOperationsMCPServer } from "../../src/mcp-server/agent/server.js";
import {
endpointByTool,
schemaByTool,
} from "../../src/mcp-server/agent/tools.js";
} from "../../src/server/auth/auth.js";
import { createAutumnOperationsMCPServer } from "../../src/server/server.js";
import { endpointByTool, schemaByTool } from "../../src/tools/index.js";
type ToolName = keyof typeof schemaByTool;
type EndpointToolName = keyof typeof endpointByTool;

View File

@@ -1,7 +1,7 @@
import type {
PendingActionRedis,
PendingActionRedisMulti,
} from "../../src/mcp-server/agent/pending-actions.js";
} from "../../src/agent/pending-actions.js";
export const createTestRedis = (): PendingActionRedis => {
const store = new Map<string, string>();

View File

@@ -33,6 +33,7 @@
"@api/*": ["../../shared/api/*"],
"@models/*": ["../../shared/models/*"],
"@utils/*": ["../../shared/utils/*"],
"@autumn/logging": ["../logging/src/index.ts"],
"@autumn/ksuid": ["../ksuid/src/index.ts"]
},
"useUnknownInCatchVariables": true,

45
scripts/axiom/cli.ts Normal file
View File

@@ -0,0 +1,45 @@
/**
* Axiom provisioning CLI. Secrets (AXIOM_ADMIN_TOKEN) are injected by infisical
* via the package.json scripts:
*
* bun axiom <action> # dev (infisical --env=dev)
* bun axiom:prod <action> # prod (infisical --env=prod)
*
* Add a new action by registering it in the `actions` map below.
*/
import "dotenv/config";
import { createLeafDataset } from "./createLeafDataset.js";
const actions = {
"create-leaf": createLeafDataset,
} satisfies Record<string, () => Promise<void>>;
type Action = keyof typeof actions;
const isAction = (value: string | undefined): value is Action =>
value !== undefined && Object.hasOwn(actions, value);
const usage = () =>
[
"Usage: bun axiom <action> (or bun axiom:prod <action>)",
"",
"Actions:",
...Object.keys(actions).map((action) => ` - ${action}`),
].join("\n");
const main = async () => {
const action = process.argv[2];
if (!isAction(action)) {
console.error(usage());
process.exit(1);
}
try {
await actions[action]();
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}
};
await main();

View File

@@ -0,0 +1,149 @@
/**
* Idempotently provisions the Axiom `leaf` dataset used for Leaf runtime logs
* and MCP usage analytics, and configures its map fields.
*
* Map fields ("vacuum" the unpredictable nested payloads into a single column):
* Tool payloads, req/res bodies, and per-log details have open-ended shape.
* Every distinct top-level key would otherwise become its own mapped field and
* quickly blow Axiom's per-dataset field limit. These map fields keep nested
* keys inside one field each while staying queryable.
*
* Run via the Axiom CLI (resolves AXIOM_ADMIN_TOKEN from infisical):
* bun axiom create-leaf # dev
* bun axiom:prod create-leaf # prod
*
* Notes:
* - AXIOM_ADMIN_TOKEN must be a personal API token with dataset create/update
* scope, NOT the `xaat-` ingest token used at runtime.
* - Safe to re-run: dataset creation tolerates "already exists", and existing
* map fields are read before missing fields are created.
*/
const AXIOM_BASE = "https://api.axiom.co/v2";
const DATASET = "leaf";
const DATASET_DESCRIPTION = "Leaf runtime logs and MCP usage analytics";
// Nested, open-ended payloads stored as map fields to stay under the field
// limit. Keep this list minimal — only genuinely high-cardinality objects.
const MAP_FIELDS = [
"context",
"data",
"extras",
"input",
"output",
"req",
"res",
];
const authHeaders = (token: string) => ({
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
});
const createDataset = async (token: string) => {
const res = await fetch(`${AXIOM_BASE}/datasets`, {
method: "POST",
headers: authHeaders(token),
body: JSON.stringify({
name: DATASET,
description: DATASET_DESCRIPTION,
}),
});
if (res.ok) {
console.log(` + created dataset \`${DATASET}\``);
return;
}
// 409 (or a 400 mentioning existence) means it's already there — fine.
const text = await res.text();
if (res.status === 409 || /exist/i.test(text)) {
console.log(` = dataset \`${DATASET}\` already exists`);
return;
}
throw new Error(`Failed to create dataset: ${res.status} ${text}`);
};
const getMapFields = async (token: string) => {
const res = await fetch(
`${AXIOM_BASE}/datasets/${encodeURIComponent(DATASET)}/mapfields`,
{
method: "GET",
headers: authHeaders(token),
},
);
const text = await res.text();
if (!res.ok) {
throw new Error(`Failed to list map fields: ${res.status} ${text}`);
}
const parsed = JSON.parse(text) as unknown;
if (!Array.isArray(parsed) || parsed.some((name) => typeof name !== "string")) {
throw new Error(`Unexpected map fields response: ${text}`);
}
return new Set(parsed);
};
const setMapField = async ({
existing,
name,
token,
}: {
existing: Set<string>;
name: string;
token: string;
}) => {
if (existing.has(name)) {
console.log(` = map field: ${name} (already set)`);
return;
}
const res = await fetch(
`${AXIOM_BASE}/datasets/${encodeURIComponent(DATASET)}/mapfields`,
{
method: "POST",
headers: authHeaders(token),
body: JSON.stringify({ name }),
},
);
const text = await res.text();
if (res.ok) {
existing.add(name);
console.log(` + map field: ${name}`);
return;
}
if (/exist/i.test(text)) {
existing.add(name);
console.log(` = map field: ${name} (already set)`);
return;
}
throw new Error(`Failed to set map field "${name}": ${res.status} ${text}`);
};
const setMapFields = async (token: string) => {
const existing = await getMapFields(token);
for (const name of MAP_FIELDS) {
await setMapField({ existing, name, token });
}
};
/** Provisions the `leaf` dataset and its map fields. */
export const createLeafDataset = async () => {
const token = process.env.AXIOM_ADMIN_TOKEN;
if (!token) {
throw new Error(
"AXIOM_ADMIN_TOKEN env var is required (personal API token, not xaat-* ingest token)",
);
}
console.log(`Provisioning Axiom dataset \`${DATASET}\`...`);
await createDataset(token);
await setMapFields(token);
console.log("\nDone.");
};

View File

@@ -4,10 +4,10 @@
* (`req.url`, `context.org_slug`, `statusCode`, etc.).
*
* Usage:
* AXIOM_API_TOKEN=<personal-token> bun scripts/axiom/setOtelVirtualFields.ts
* AXIOM_ADMIN_TOKEN=<personal-token> bun scripts/axiom/setOtelVirtualFields.ts
*
* Notes:
* - AXIOM_API_TOKEN must be a personal API token with dataset-write scope,
* - AXIOM_ADMIN_TOKEN must be a personal API token with dataset-write scope,
* NOT the `xaat-` ingest token used by the server.
* - Safe to re-run; existing fields with matching names are updated in place.
*/
@@ -149,10 +149,10 @@ type ExistingVField = {
dataset: string;
};
const token = process.env.AXIOM_API_TOKEN;
const token = process.env.AXIOM_ADMIN_TOKEN;
if (!token) {
console.error(
"AXIOM_API_TOKEN env var is required (personal API token, not xaat-* ingest token)",
"AXIOM_ADMIN_TOKEN env var is required (personal API token, not xaat-* ingest token)",
);
process.exit(1);
}

91
scripts/mcp/addMcp.ts Normal file
View File

@@ -0,0 +1,91 @@
/**
* Registers the Autumn MCP server with local AI CLIs (Claude Code + Codex).
*
* Usage:
* bun add-mcp # autumn-dev -> http://localhost:3099/mcp
* bun add-mcp <name> <url> # custom name / url
*
* Only CLIs that are actually installed are touched; the rest are skipped.
* The server uses OAuth, so you authenticate on first connect (Claude prompts
* automatically; for Codex run `codex mcp login <name>`).
*/
const DEFAULT_NAME = "autumn-dev";
const DEFAULT_URL = "http://localhost:3099/mcp";
type Client = {
label: string;
bin: string;
/** Args to remove an existing server of this name (best-effort, ignored). */
removeArgs: (name: string) => string[];
/** Args to add the streamable-HTTP server. */
addArgs: (name: string, url: string) => string[];
/** Follow-up the user must run/do (e.g. OAuth login). */
next: (name: string) => string;
};
const clients: Client[] = [
{
label: "Claude Code",
bin: "claude",
removeArgs: (name) => ["mcp", "remove", name],
addArgs: (name, url) => ["mcp", "add", "--transport", "http", name, url],
next: () => "Claude prompts for OAuth automatically on first use.",
},
{
label: "Codex",
bin: "codex",
removeArgs: (name) => ["mcp", "remove", name],
addArgs: (name, url) => ["mcp", "add", name, "--url", url],
next: (name) =>
`Run \`codex mcp login ${name}\` to authenticate (OAuth). If the handshake fails, retry with \`-c experimental_use_rmcp_client=true\`.`,
},
];
const run = (bin: string, args: string[]) => {
const proc = Bun.spawnSync([bin, ...args], {
stdout: "pipe",
stderr: "pipe",
});
const output = `${proc.stdout.toString()}${proc.stderr.toString()}`.trim();
return { ok: proc.exitCode === 0, output };
};
const addToClient = (client: Client, name: string, url: string) => {
if (!Bun.which(client.bin)) {
console.log(`- ${client.label}: skipped (\`${client.bin}\` not found)`);
return;
}
// Remove any existing entry first so re-running converges cleanly.
run(client.bin, client.removeArgs(name));
const { ok, output } = run(client.bin, client.addArgs(name, url));
if (ok) {
console.log(`+ ${client.label}: added \`${name}\` -> ${url}`);
console.log(` next: ${client.next(name)}`);
return;
}
console.log(`! ${client.label}: failed to add \`${name}\``);
if (output) console.log(` ${output.replaceAll("\n", "\n ")}`);
};
const main = () => {
const [, , nameArg, urlArg] = process.argv;
if (nameArg === "--help" || nameArg === "-h") {
console.log("Usage: bun add-mcp [name] [url]");
console.log(`Defaults: ${DEFAULT_NAME} ${DEFAULT_URL}`);
return;
}
const name = nameArg ?? DEFAULT_NAME;
const url = urlArg ?? DEFAULT_URL;
console.log(`Registering MCP server \`${name}\` (${url})\n`);
for (const client of clients) {
addToClient(client, name, url);
}
};
main();

View File

@@ -79,7 +79,7 @@ export const handlePrepaidPrices = async ({
const rolloverUpdate = getRolloverUpdates({
cusEnt,
nextResetAt: end * 1000,
nextResetAt: start * 1000,
});
if (notNullish(options?.upcoming_quantity)) {

View File

@@ -149,7 +149,7 @@ export const handleUsagePrices = async ({
allowance: ent.interval === EntInterval.Lifetime ? 0 : ent.allowance!,
});
const { end } = subToPeriodStartEnd({ sub: usageSub });
const { start, end } = subToPeriodStartEnd({ sub: usageSub });
await CusEntService.update({
ctx,
id: relatedCusEnt.id,
@@ -162,7 +162,7 @@ export const handleUsagePrices = async ({
const rolloverUpdate = getRolloverUpdates({
cusEnt: relatedCusEnt,
nextResetAt: end * 1000,
nextResetAt: start * 1000,
});
if (rolloverUpdate?.toInsert && rolloverUpdate.toInsert.length > 0) {

View File

@@ -36,13 +36,17 @@ export const handleStripeInvoiceCreated = async ({
await processPrepaidPricesForInvoiceCreated({ ctx, eventContext });
await processAllocatedPricesForInvoiceCreated({ ctx, eventContext });
const shouldStoreScheduleProrationInvoice =
eventContext.stripeInvoice.billing_reason === "subscription_update" &&
!!eventContext.stripeSubscription.schedule;
// Upsert Autumn invoice record
const autumnInvoice = await upsertAutumnInvoice({
ctx,
stripeInvoice: eventContext.stripeInvoice,
stripeSubscription: eventContext.stripeSubscription,
customerProducts: eventContext.customerProducts,
options: { skipNonCycleInvoices: true },
options: { skipNonCycleInvoices: !shouldStoreScheduleProrationInvoice },
});
// Store invoice line items (async via SQS workflow)

View File

@@ -9,9 +9,9 @@ import { eventContextToArrearLineItems } from "@/external/stripe/webhookHandlers
import { lineItemsToCreateInvoiceItemsParams } from "@/internal/billing/v2/providers/stripe/utils/invoiceLines/lineItemsToCreateInvoiceItemsParams";
import { createStripeInvoiceItems } from "@/internal/billing/v2/providers/stripe/utils/invoices/stripeInvoiceOps";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService";
import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer";
import { RolloverService } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/RolloverService";
import { getRolloverUpdates } from "@/internal/customers/cusProducts/cusEnts/cusRollovers/rolloverUtils";
import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer";
import { parseSkipOverageSubmissionFlag } from "@/internal/misc/featureFlags/parseSkipOverageSubmission";
import type { StripeWebhookContext } from "../../../webhookMiddlewares/stripeWebhookContext";
import type { InvoiceCreatedContext } from "../setupInvoiceCreatedContext";
@@ -116,7 +116,7 @@ export const processConsumablePricesForInvoiceCreated = async ({
updateCustomerEntitlements.forEach(async (update) => {
const rolloverUpdates = getRolloverUpdates({
cusEnt: update.customerEntitlement,
nextResetAt: Date.now(),
nextResetAt: invoicePeriodEndMs,
});
const fullCusEnt: FullCusEntWithProduct = {

View File

@@ -41,8 +41,7 @@ const processPrepaidPrice = async ({
const customerProduct = customerEntitlement.customer_product;
const { stripeSubscription, fullCustomer } = eventContext;
const { db } = ctx;
const { stripeSubscription } = eventContext;
if (!options) return;
const previousQuantity = options?.quantity ?? 0;
@@ -60,11 +59,11 @@ const processPrepaidPrice = async ({
const ent = customerEntitlement.entitlement;
const { end } = subToPeriodStartEnd({ sub: stripeSubscription });
const { start, end } = subToPeriodStartEnd({ sub: stripeSubscription });
const rolloverUpdate = getRolloverUpdates({
cusEnt: customerEntitlement,
nextResetAt: end * 1000,
nextResetAt: start * 1000,
});
if (notNullish(options?.upcoming_quantity) && customerProduct) {

Some files were not shown because too many files have changed in this diff Show More