From 1707a7773b2d87ca67b496ff37e3f48971a3587c Mon Sep 17 00:00:00 2001 From: johnyeo Date: Thu, 4 Jun 2026 15:06:56 +0100 Subject: [PATCH] analytics --- ai | 2 +- apps/leaf/package.json | 1 + apps/leaf/src/agent/agent.ts | 55 +++++-- apps/leaf/src/agent/mcp.ts | 31 +++- apps/leaf/src/agent/messages.ts | 14 +- apps/leaf/src/approvals/flow.ts | 60 +++++-- apps/leaf/src/bot.ts | 57 ++++++- apps/leaf/src/lib/logger.ts | 60 +++++++ apps/leaf/src/main.ts | 13 +- apps/leaf/src/mcp/http.ts | 13 +- apps/leaf/src/providers/slack/routes.ts | 24 ++- apps/leaf/src/types.ts | 2 + apps/leaf/tests/unit/lib/logger.test.ts | 32 ++++ apps/leaf/tsconfig.json | 1 + bun.lock | 23 +++ docker/Dockerfile | 1 + docker/dev.dockerfile | 45 ------ package.json | 2 + packages/logging/package.json | 30 ++++ .../logging/src/context/addContextToLogs.ts | 38 +++++ packages/logging/src/context/types.ts | 35 +++++ packages/logging/src/ids/createSessionId.ts | 21 +++ packages/logging/src/ids/createTraceId.ts | 3 + packages/logging/src/index.ts | 40 +++++ packages/logging/src/logger/autumnLogger.ts | 69 ++++++++ packages/logging/src/logger/consoleLogger.ts | 28 ++++ packages/logging/src/logger/createLogger.ts | 60 +++++++ packages/logging/src/logger/loggerWrappers.ts | 71 +++++++++ .../src/logger/resolveLoggerOptions.ts | 67 ++++++++ packages/logging/src/payload/asAxiomMap.ts | 8 + .../logging/src/payload/guardLogPayload.ts | 147 ++++++++++++++++++ .../logging/src/streams/consoleJsonStream.ts | 9 ++ .../logging/src/streams/prettyLogStream.ts | 116 ++++++++++++++ packages/logging/src/types.ts | 56 +++++++ packages/logging/tsconfig.json | 34 ++++ packages/mcp/package.json | 1 + packages/mcp/src/agent/axiom.ts | 33 +++- packages/mcp/src/analytics/analyticsSink.ts | 4 +- packages/mcp/src/analytics/analyticsTypes.ts | 26 +++- packages/mcp/src/analytics/axiomSink.ts | 72 --------- packages/mcp/src/analytics/emitToolEvent.ts | 43 +++-- packages/mcp/src/analytics/index.ts | 5 +- packages/mcp/src/analytics/instrumentTools.ts | 33 +++- packages/mcp/src/analytics/loggerSink.ts | 60 +++++++ packages/mcp/src/analytics/sessionId.ts | 7 +- packages/mcp/src/server/auth/oauth.ts | 7 +- packages/mcp/src/tools/index.ts | 7 +- packages/mcp/src/tools/utils/intent.ts | 47 ++++++ .../tests/unit/mcp-server/agent/tools.test.ts | 55 +++++-- .../tests/unit/mcp-server/analytics.test.ts | 130 ++++++++++++++++ packages/mcp/tsconfig.json | 1 + scripts/axiom/createLeafDataset.ts | 74 +++++++-- scripts/mcp/addMcp.ts | 91 +++++++++++ 53 files changed, 1726 insertions(+), 238 deletions(-) create mode 100644 apps/leaf/src/lib/logger.ts create mode 100644 apps/leaf/tests/unit/lib/logger.test.ts delete mode 100644 docker/dev.dockerfile create mode 100644 packages/logging/package.json create mode 100644 packages/logging/src/context/addContextToLogs.ts create mode 100644 packages/logging/src/context/types.ts create mode 100644 packages/logging/src/ids/createSessionId.ts create mode 100644 packages/logging/src/ids/createTraceId.ts create mode 100644 packages/logging/src/index.ts create mode 100644 packages/logging/src/logger/autumnLogger.ts create mode 100644 packages/logging/src/logger/consoleLogger.ts create mode 100644 packages/logging/src/logger/createLogger.ts create mode 100644 packages/logging/src/logger/loggerWrappers.ts create mode 100644 packages/logging/src/logger/resolveLoggerOptions.ts create mode 100644 packages/logging/src/payload/asAxiomMap.ts create mode 100644 packages/logging/src/payload/guardLogPayload.ts create mode 100644 packages/logging/src/streams/consoleJsonStream.ts create mode 100644 packages/logging/src/streams/prettyLogStream.ts create mode 100644 packages/logging/src/types.ts create mode 100644 packages/logging/tsconfig.json delete mode 100644 packages/mcp/src/analytics/axiomSink.ts create mode 100644 packages/mcp/src/analytics/loggerSink.ts create mode 100644 packages/mcp/src/tools/utils/intent.ts create mode 100644 packages/mcp/tests/unit/mcp-server/analytics.test.ts create mode 100644 scripts/mcp/addMcp.ts diff --git a/ai b/ai index a84bc7418..0e52f71fb 160000 --- a/ai +++ b/ai @@ -1 +1 @@ -Subproject commit a84bc7418cd985f3b831b24a5e49f447b0eff0d4 +Subproject commit 0e52f71fbd69e7a4a58b63863a8c4929bfd9ebf8 diff --git a/apps/leaf/package.json b/apps/leaf/package.json index b1cd01d9d..8d0684771 100644 --- a/apps/leaf/package.json +++ b/apps/leaf/package.json @@ -10,6 +10,7 @@ "ts": "tsc --noEmit" }, "dependencies": { + "@autumn/logging": "workspace:*", "@autumn/mcp": "workspace:*", "@autumn/shared": "workspace:*", "@chat-adapter/slack": "^4.29.0", diff --git a/apps/leaf/src/agent/agent.ts b/apps/leaf/src/agent/agent.ts index 5143b1a58..3bf3a837d 100644 --- a/apps/leaf/src/agent/agent.ts +++ b/apps/leaf/src/agent/agent.ts @@ -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; }) => { - 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) => { 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; 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", + }); } }; diff --git a/apps/leaf/src/agent/mcp.ts b/apps/leaf/src/agent/mcp.ts index 44b2dc2ec..1b4b8113d 100644 --- a/apps/leaf/src/agent/mcp.ts +++ b/apps/leaf/src/agent/mcp.ts @@ -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; 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; + 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, diff --git a/apps/leaf/src/agent/messages.ts b/apps/leaf/src/agent/messages.ts index 395a410f1..c434a5d58 100644 --- a/apps/leaf/src/agent/messages.ts +++ b/apps/leaf/src/agent/messages.ts @@ -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 = (promise: Promise, ms: number) => new Promise((resolve, reject) => { @@ -13,6 +14,7 @@ const withTimeout = (promise: Promise, 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, diff --git a/apps/leaf/src/approvals/flow.ts b/apps/leaf/src/approvals/flow.ts index 3486125cd..f79e6930d 100644 --- a/apps/leaf/src/approvals/flow.ts +++ b/apps/leaf/src/approvals/flow.ts @@ -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; + 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, diff --git a/apps/leaf/src/bot.ts b/apps/leaf/src/bot.ts index 1e9db935c..800b9a43d 100644 --- a/apps/leaf/src/bot.ts +++ b/apps/leaf/src/bot.ts @@ -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.", diff --git a/apps/leaf/src/lib/logger.ts b/apps/leaf/src/lib/logger.ts new file mode 100644 index 000000000..f831e6d16 --- /dev/null +++ b/apps/leaf/src/lib/logger.ts @@ -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, +): AutumnLogger => + baseLogger.child({ + context: { + context, + }, + }); diff --git a/apps/leaf/src/main.ts b/apps/leaf/src/main.ts index 452cfacaa..ce9cfcc98 100644 --- a/apps/leaf/src/main.ts +++ b/apps/leaf/src/main.ts @@ -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, + }, }); }, ); diff --git a/apps/leaf/src/mcp/http.ts b/apps/leaf/src/mcp/http.ts index 9f5c38067..3186a630f 100644 --- a/apps/leaf/src/mcp/http.ts +++ b/apps/leaf/src/mcp/http.ts @@ -1,6 +1,7 @@ +import { randomUUID } from "node:crypto"; +import type { AutumnLogger } from "@autumn/logging"; import { buildAuthForRequest, - type ConsoleLogger, createAutumnOperationsMCPServer, getAuthorizationServerMetadata, getProtectedResourceMetadata, @@ -15,7 +16,7 @@ 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 }>; @@ -23,6 +24,8 @@ 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")), ); @@ -64,14 +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("/mcp", (c) => handleMcp(c, "/mcp", mcpServer)); return app; } diff --git a/apps/leaf/src/providers/slack/routes.ts b/apps/leaf/src/providers/slack/routes.ts index 748c5872b..f6e393036 100644 --- a/apps/leaf/src/providers/slack/routes.ts +++ b/apps/leaf/src/providers/slack/routes.ts @@ -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); }); diff --git a/apps/leaf/src/types.ts b/apps/leaf/src/types.ts index 92c3f937f..7a0dc1208 100644 --- a/apps/leaf/src/types.ts +++ b/apps/leaf/src/types.ts @@ -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; recentMessages?: ChatContextMessage[]; text: string; diff --git a/apps/leaf/tests/unit/lib/logger.test.ts b/apps/leaf/tests/unit/lib/logger.test.ts new file mode 100644 index 000000000..2e5bccf0d --- /dev/null +++ b/apps/leaf/tests/unit/lib/logger.test.ts @@ -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", + }); + }); +}); diff --git a/apps/leaf/tsconfig.json b/apps/leaf/tsconfig.json index 9bd1abedd..39cf3a979 100644 --- a/apps/leaf/tsconfig.json +++ b/apps/leaf/tsconfig.json @@ -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/*"], diff --git a/bun.lock b/bun.lock index 536fed665..c84d57504 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/docker/Dockerfile b/docker/Dockerfile index 74a8aaeb7..5e8c83c38 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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/ diff --git a/docker/dev.dockerfile b/docker/dev.dockerfile deleted file mode 100644 index 4fec00979..000000000 --- a/docker/dev.dockerfile +++ /dev/null @@ -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"] \ No newline at end of file diff --git a/package.json b/package.json index 31e81e561..f9c4ebdf1 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "apps/sdk-test", "packages/atmn", "packages/atmn-tests", + "packages/logging", "packages/mcp", "packages/sdk", "packages/autumn-js", @@ -115,6 +116,7 @@ "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", diff --git a/packages/logging/package.json b/packages/logging/package.json new file mode 100644 index 000000000..c4912dd34 --- /dev/null +++ b/packages/logging/package.json @@ -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" + } +} diff --git a/packages/logging/src/context/addContextToLogs.ts b/packages/logging/src/context/addContextToLogs.ts new file mode 100644 index 000000000..45a7a63e9 --- /dev/null +++ b/packages/logging/src/context/addContextToLogs.ts @@ -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; +}): AutumnLogger => logger.child({ context: { extras } }); diff --git a/packages/logging/src/context/types.ts b/packages/logging/src/context/types.ts new file mode 100644 index 000000000..830a0a861 --- /dev/null +++ b/packages/logging/src/context/types.ts @@ -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; + 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; +}; diff --git a/packages/logging/src/ids/createSessionId.ts b/packages/logging/src/ids/createSessionId.ts new file mode 100644 index 000000000..4cd3832a1 --- /dev/null +++ b/packages/logging/src/ids/createSessionId.ts @@ -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); diff --git a/packages/logging/src/ids/createTraceId.ts b/packages/logging/src/ids/createTraceId.ts new file mode 100644 index 000000000..124381e7d --- /dev/null +++ b/packages/logging/src/ids/createTraceId.ts @@ -0,0 +1,3 @@ +import { randomUUID } from "node:crypto"; + +export const createTraceId = (): string => randomUUID(); diff --git a/packages/logging/src/index.ts b/packages/logging/src/index.ts new file mode 100644 index 000000000..fbf99ad76 --- /dev/null +++ b/packages/logging/src/index.ts @@ -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"; diff --git a/packages/logging/src/logger/autumnLogger.ts b/packages/logging/src/logger/autumnLogger.ts new file mode 100644 index 000000000..ccf946ac7 --- /dev/null +++ b/packages/logging/src/logger/autumnLogger.ts @@ -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, + }; +}; + +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) }); diff --git a/packages/logging/src/logger/consoleLogger.ts b/packages/logging/src/logger/consoleLogger.ts new file mode 100644 index 000000000..e6c5795b4 --- /dev/null +++ b/packages/logging/src/logger/consoleLogger.ts @@ -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; +}; diff --git a/packages/logging/src/logger/createLogger.ts b/packages/logging/src/logger/createLogger.ts new file mode 100644 index 000000000..ff4b08c20 --- /dev/null +++ b/packages/logging/src/logger/createLogger.ts @@ -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), + ); +}; diff --git a/packages/logging/src/logger/loggerWrappers.ts b/packages/logging/src/logger/loggerWrappers.ts new file mode 100644 index 000000000..08d51e1f4 --- /dev/null +++ b/packages/logging/src/logger/loggerWrappers.ts @@ -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 }), + }; +}; diff --git a/packages/logging/src/logger/resolveLoggerOptions.ts b/packages/logging/src/logger/resolveLoggerOptions.ts new file mode 100644 index 000000000..ddd79caa4 --- /dev/null +++ b/packages/logging/src/logger/resolveLoggerOptions.ts @@ -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, + }; +}; diff --git a/packages/logging/src/payload/asAxiomMap.ts b/packages/logging/src/payload/asAxiomMap.ts new file mode 100644 index 000000000..7c73aa94c --- /dev/null +++ b/packages/logging/src/payload/asAxiomMap.ts @@ -0,0 +1,8 @@ +export const asAxiomMap = ({ + value, +}: { + value: unknown; +}): Record => + value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : { value }; diff --git a/packages/logging/src/payload/guardLogPayload.ts b/packages/logging/src/payload/guardLogPayload.ts new file mode 100644 index 000000000..64171178e --- /dev/null +++ b/packages/logging/src/payload/guardLogPayload.ts @@ -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; + +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 = {}; + 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 }; + } +}; diff --git a/packages/logging/src/streams/consoleJsonStream.ts b/packages/logging/src/streams/consoleJsonStream.ts new file mode 100644 index 000000000..a204dde79 --- /dev/null +++ b/packages/logging/src/streams/consoleJsonStream.ts @@ -0,0 +1,9 @@ +import { Writable } from "node:stream"; + +export const createConsoleJsonStream = () => + new Writable({ + write(chunk, _encoding, callback) { + console.log(chunk.toString().trimEnd()); + callback(); + }, + }); diff --git a/packages/logging/src/streams/prettyLogStream.ts b/packages/logging/src/streams/prettyLogStream.ts new file mode 100644 index 000000000..f6300014e --- /dev/null +++ b/packages/logging/src/streams/prettyLogStream.ts @@ -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 = { + 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 = { + 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, + ); + + 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(); + } + }, + }); diff --git a/packages/logging/src/types.ts b/packages/logging/src/types.ts new file mode 100644 index 000000000..0a46ee93d --- /dev/null +++ b/packages/logging/src/types.ts @@ -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; + mixin?: () => Record; + axiomToken?: string; + axiomOrgId?: string; + useConsoleLog?: boolean; +}; + +export type ResolvedLoggerOptions = Required< + Pick +> & { + 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; + onlyProd?: boolean; + }) => AutumnLogger; +}; + +export type ConsoleLoggerLevel = "debug" | "info" | "warning" | "error"; + +export type ConsoleLogger = AutumnLogger & { + level: ConsoleLoggerLevel; +}; + +export type PinoLogger = pino.Logger; diff --git a/packages/logging/tsconfig.json b/packages/logging/tsconfig.json new file mode 100644 index 000000000..c4f77bb62 --- /dev/null +++ b/packages/logging/tsconfig.json @@ -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"] +} diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 4976ab7f5..afddc2a5d 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -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", diff --git a/packages/mcp/src/agent/axiom.ts b/packages/mcp/src/agent/axiom.ts index 3f59587b3..dcfcd640a 100644 --- a/packages/mcp/src/agent/axiom.ts +++ b/packages/mcp/src/agent/axiom.ts @@ -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(); +const orgCache = new Map(); const getAxiomClient = () => { if (!process.env.AXIOM_ADMIN_TOKEN) { @@ -85,9 +87,15 @@ const assertCanUseAxiom = (auth: AutumnMcpAuth) => { } }; -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 => { const cacheKey = [ auth.serverURL ?? "https://api.useautumn.com", auth.env, @@ -96,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), { @@ -107,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 = ({ diff --git a/packages/mcp/src/analytics/analyticsSink.ts b/packages/mcp/src/analytics/analyticsSink.ts index 397325f81..928754031 100644 --- a/packages/mcp/src/analytics/analyticsSink.ts +++ b/packages/mcp/src/analytics/analyticsSink.ts @@ -1,5 +1,5 @@ import type { AnalyticsSink } from "./analyticsTypes.js"; -import { createAxiomAnalyticsSink } from "./axiomSink.js"; +import { createLoggerAnalyticsSink } from "./loggerSink.js"; const DEFAULT_DATASET = "leaf"; @@ -23,7 +23,7 @@ export const setAnalyticsSink = (sink: AnalyticsSink | null | undefined) => { export const getAnalyticsSink = (): AnalyticsSink => { if (overrideSink !== undefined) return overrideSink ?? noopSink; if (cachedSink === undefined) { - cachedSink = createAxiomAnalyticsSink({ + cachedSink = createLoggerAnalyticsSink({ token: process.env.AXIOM_TOKEN, orgId: process.env.AXIOM_ORG_ID, dataset: process.env.MCP_ANALYTICS_DATASET ?? DEFAULT_DATASET, diff --git a/packages/mcp/src/analytics/analyticsTypes.ts b/packages/mcp/src/analytics/analyticsTypes.ts index 55d3cfb22..7307a3557 100644 --- a/packages/mcp/src/analytics/analyticsTypes.ts +++ b/packages/mcp/src/analytics/analyticsTypes.ts @@ -7,21 +7,33 @@ */ 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; - env: string; - /** Resolved lazily; may be absent if org resolution fails. */ - orgId?: string | undefined; /** HTTP User-Agent of the calling MCP client. Absent for `agent` surface. */ client?: string | undefined; - /** Stateless session grouping: hash(principal + client + time window). */ + /** MCP transport session id, or fallback hash(principal + client + window). */ sessionId: string; - scopes?: string[] | undefined; + context: McpAnalyticsContext; /** Tool request payload (stored as an Axiom map field). */ input?: unknown; /** Tool result payload (stored as an Axiom map field). */ @@ -32,8 +44,8 @@ export type McpAnalyticsEvent = { /** * 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 (Axiom direct, `@axiomhq/pino`, - * an OTEL exporter, a test spy) without touching the instrumentation layer. + * 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; diff --git a/packages/mcp/src/analytics/axiomSink.ts b/packages/mcp/src/analytics/axiomSink.ts deleted file mode 100644 index 92577d8df..000000000 --- a/packages/mcp/src/analytics/axiomSink.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Axiom } from "@axiomhq/js"; -import type { AnalyticsSink, McpAnalyticsEvent } from "./analyticsTypes.js"; - -const maxPayloadBytes = - Number(process.env.MCP_ANALYTICS_MAX_PAYLOAD_BYTES) || 512_000; - -/** - * Map fields require an object value. Wrap scalars/arrays so heterogeneous - * tool outputs still land in a single Axiom map field instead of conflicting - * on type. - */ -const asMap = (value: unknown): Record => - value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : { value }; - -/** - * Keep individual events under Axiom's 1MB field cap. Oversized payloads are - * replaced with a marker rather than dropping the whole (otherwise rejected) - * event. - */ -const guardPayload = (value: unknown): unknown => { - if (value === undefined) return undefined; - try { - const json = JSON.stringify(value); - if (json && json.length > maxPayloadBytes) { - return { _truncated: true, _bytes: json.length }; - } - return value; - } catch { - return { _unserializable: true }; - } -}; - -const toAxiomRecord = (event: McpAnalyticsEvent) => ({ - _time: new Date().toISOString(), - event: event.event, - surface: event.surface, - tool: event.tool, - status: event.status, - duration_ms: event.durationMs, - org_id: event.orgId, - principal_id: event.principalId, - env: event.env, - client: event.client, - session_id: event.sessionId, - scopes: event.scopes, - // Map fields — see scripts/axiom/createLeafDataset.ts - input: asMap(guardPayload(event.input)), - output: asMap(guardPayload(event.output)), - error: event.error, -}); - -export const createAxiomAnalyticsSink = ({ - token, - orgId, - dataset, -}: { - token?: string | undefined; - orgId?: string | undefined; - dataset: string; -}): AnalyticsSink | null => { - if (!token) return null; - const client = new Axiom({ token, orgId }); - return { - emit(event) { - // Axiom batches internally; no inline await on the hot path. - client.ingest(dataset, [toAxiomRecord(event)]); - }, - flush: () => client.flush(), - }; -}; diff --git a/packages/mcp/src/analytics/emitToolEvent.ts b/packages/mcp/src/analytics/emitToolEvent.ts index ecbaadc79..86a342968 100644 --- a/packages/mcp/src/analytics/emitToolEvent.ts +++ b/packages/mcp/src/analytics/emitToolEvent.ts @@ -1,4 +1,4 @@ -import { resolveAutumnOrgId } from "../agent/axiom.js"; +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"; @@ -14,6 +14,8 @@ export const emitMcpToolEvent = ({ toolId, auth, client, + transportSessionId, + intent, status, durationMs, input, @@ -24,6 +26,8 @@ export const emitMcpToolEvent = ({ toolId: string; auth: AutumnMcpAuth; client: string | undefined; + transportSessionId?: string | undefined; + intent?: string | undefined; status: "ok" | "error"; durationMs: number; input?: unknown; @@ -32,33 +36,40 @@ export const emitMcpToolEvent = ({ }) => { const sink = getAnalyticsSink(); - // Resolve org off the hot path; resolveAutumnOrgId is cached (~5min). + // Resolve org off the hot path; resolveAutumnOrg is cached (~5min). void (async () => { let orgId = auth.orgId; - if (!orgId) { - try { - orgId = await resolveAutumnOrgId(auth); - } catch { - // Best-effort: emit without org_id rather than dropping the event. - } + 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, - orgId, principalId: auth.principalId, - env: auth.env, client, - sessionId: deriveSessionId({ - principalId: auth.principalId, - client, - now, - }), - scopes: auth.scopes, + sessionId: + transportSessionId ?? + deriveSessionId({ + principalId: auth.principalId, + client, + now, + }), + context: { + orgId, + orgSlug, + env: auth.env, + scopes: auth.scopes, + }, input, output, error, diff --git a/packages/mcp/src/analytics/index.ts b/packages/mcp/src/analytics/index.ts index 15106380b..7c2769d98 100644 --- a/packages/mcp/src/analytics/index.ts +++ b/packages/mcp/src/analytics/index.ts @@ -8,5 +8,8 @@ export type { McpAnalyticsEvent, McpAnalyticsSurface, } from "./analyticsTypes.js"; -export { createAxiomAnalyticsSink } from "./axiomSink.js"; export { instrumentToolsWithAnalytics } from "./instrumentTools.js"; +export { + createAxiomAnalyticsSink, + createLoggerAnalyticsSink, +} from "./loggerSink.js"; diff --git a/packages/mcp/src/analytics/instrumentTools.ts b/packages/mcp/src/analytics/instrumentTools.ts index 28e4c18fb..a7c989391 100644 --- a/packages/mcp/src/analytics/instrumentTools.ts +++ b/packages/mcp/src/analytics/instrumentTools.ts @@ -1,5 +1,6 @@ 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"; @@ -7,7 +8,9 @@ import { emitMcpToolEvent } from "./emitToolEvent.js"; type AnyTool = ReturnType; type ToolContext = Parameters>[1]; -const getClientFromContext = (context: ToolContext): string | undefined => { +const getHeadersFromContext = ( + context: ToolContext, +): Record | undefined => { const extra = ( context as { mcp?: { @@ -17,7 +20,19 @@ const getClientFromContext = (context: ToolContext): string | undefined => { }; } )?.mcp?.extra; - return extra?.requestInfo?.headers?.["user-agent"]; + return extra?.requestInfo?.headers; +}; + +const getHeader = ( + headers: Record | 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 => @@ -30,8 +45,9 @@ const extractRequest = (input: unknown): unknown => * 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 created fresh per request (see `createAutumnOperationsMCPServer`), - * so mutating `execute` here carries no shared-state risk. + * 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` @@ -59,7 +75,10 @@ export const instrumentToolsWithAnalytics = < } catch { return original(input as never, context as never); } - const client = getClientFromContext(context); + 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({ @@ -67,6 +86,8 @@ export const instrumentToolsWithAnalytics = < toolId, auth, client, + transportSessionId, + intent, status: "ok", durationMs: Date.now() - started, input: extractRequest(input), @@ -79,6 +100,8 @@ export const instrumentToolsWithAnalytics = < toolId, auth, client, + transportSessionId, + intent, status: "error", durationMs: Date.now() - started, input: extractRequest(input), diff --git a/packages/mcp/src/analytics/loggerSink.ts b/packages/mcp/src/analytics/loggerSink.ts new file mode 100644 index 000000000..6fb15c4fc --- /dev/null +++ b/packages/mcp/src/analytics/loggerSink.ts @@ -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((resolve) => { + const flush = logger.flush; + if (typeof flush !== "function") return resolve(); + flush.call(logger, () => resolve()); + }); + }, + }; +}; + +/** @deprecated Use createLoggerAnalyticsSink. */ +export const createAxiomAnalyticsSink = createLoggerAnalyticsSink; diff --git a/packages/mcp/src/analytics/sessionId.ts b/packages/mcp/src/analytics/sessionId.ts index f6318f3a0..717386cfd 100644 --- a/packages/mcp/src/analytics/sessionId.ts +++ b/packages/mcp/src/analytics/sessionId.ts @@ -7,10 +7,9 @@ const hash = (value: string) => createHash("sha256").update(value).digest("hex").slice(0, 32); /** - * Stateless session grouping. The serverless MCP transport issues no - * Mcp-Session-Id, so we synthesize one from the principal + client + a coarse - * time bucket — calls from the same client within the window collapse into one - * session. + * 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, diff --git a/packages/mcp/src/server/auth/oauth.ts b/packages/mcp/src/server/auth/oauth.ts index 974800201..fae23f87d 100644 --- a/packages/mcp/src/server/auth/oauth.ts +++ b/packages/mcp/src/server/auth/oauth.ts @@ -1,6 +1,5 @@ import { ms } from "@autumn/shared/unixUtils"; import { addMilliseconds, isFuture } from "date-fns"; -import type { ConsoleLogger } from "../../console-logger.js"; import { MCP_OAUTH_SCOPES } from "../../constants.js"; import type { AutumnMcpAuth } from "./auth.js"; import { OAuthHttpError } from "./utils/errors.js"; @@ -37,6 +36,10 @@ type ExchangedToken = { scopes?: string[] | undefined; }; +type AuthLogger = { + warning: (message: string, data?: Record) => void; +}; + const apiKeyCache = new Map(); const exchangeOAuthToken = async ({ @@ -132,7 +135,7 @@ export const getAuthorizationServerMetadata = (flags: MCPOAuthFlags) => { export const buildAuthForRequest = async ( headers: Headers, flags: MCPOAuthFlags, - logger: ConsoleLogger, + logger: AuthLogger, resourcePath = "/mcp", ): Promise => { const env = getEnvironment({ headers, flags }); diff --git a/packages/mcp/src/tools/index.ts b/packages/mcp/src/tools/index.ts index d7b76401f..3e723430e 100644 --- a/packages/mcp/src/tools/index.ts +++ b/packages/mcp/src/tools/index.ts @@ -18,6 +18,7 @@ import { 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"; @@ -62,14 +63,16 @@ const confirmedWrites = domains.flatMap( */ export const createRawAutumnOperationTools = () => instrumentToolsWithAnalytics({ - tools: { + // 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), - }, + }), surface: "mcp", }); diff --git a/packages/mcp/src/tools/utils/intent.ts b/packages/mcp/src/tools/utils/intent.ts new file mode 100644 index 000000000..0b72b97a5 --- /dev/null +++ b/packages/mcp/src/tools/utils/intent.ts @@ -0,0 +1,47 @@ +import type { createTool } from "@mastra/core/tools"; +import * as z from "zod/v4"; + +type AnyTool = ReturnType; + +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 = >( + 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; +}; diff --git a/packages/mcp/tests/unit/mcp-server/agent/tools.test.ts b/packages/mcp/tests/unit/mcp-server/agent/tools.test.ts index 07bf3f28e..fd874f169 100644 --- a/packages/mcp/tests/unit/mcp-server/agent/tools.test.ts +++ b/packages/mcp/tests/unit/mcp-server/agent/tools.test.ts @@ -104,7 +104,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" }); @@ -129,9 +132,12 @@ 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( + { intent: "create a plan", request: { plan_id: "pro", name: "Pro" } }, + { + mcp: { extra: { authInfo: auth } }, + } as never, + ), ).resolves.toEqual({ id: "pro" }); } finally { globalThis.fetch = originalFetch; @@ -157,6 +163,7 @@ 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" }] }], @@ -189,7 +196,7 @@ describe("Autumn operation tools", () => { throw new Error("previewCreateBalance is not executable"); await expect( - tool.execute({ request }, { + tool.execute({ intent: "preview a balance grant", request }, { mcp: { extra: { authInfo: auth } }, } as never), ).resolves.toMatchObject({ @@ -222,6 +229,7 @@ describe("Autumn operation tools", () => { await expect( tool.execute( { + intent: "grant a balance", request: { customer_id: "cus_1", entity_id: "workspace_1", @@ -259,6 +267,7 @@ 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" }] }], @@ -288,9 +297,15 @@ describe("Autumn operation tools", () => { if (!tool.execute) throw new Error("listCustomers is not executable"); await expect( - tool.execute({ request: { limit: 5000, search: "charlie" } }, { - mcp: { extra: { authInfo: auth } }, - } as never), + tool.execute( + { + intent: "list customers", + request: { limit: 5000, search: "charlie" }, + }, + { + mcp: { extra: { authInfo: auth } }, + } as never, + ), ).resolves.toEqual({ customers: [] }); } finally { globalThis.fetch = originalFetch; @@ -317,9 +332,15 @@ 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( + { + 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", @@ -346,9 +367,15 @@ describe("Autumn operation tools", () => { if (!tool.execute) throw new Error("attach is not executable"); await expect( - tool.execute({ request: { customer_id: "cus_1", plan_id: "pro" } }, { - mcp: { extra: { authInfo: auth } }, - } as never), + tool.execute( + { + intent: "attach a plan", + request: { customer_id: "cus_1", plan_id: "pro" }, + }, + { + mcp: { extra: { authInfo: auth } }, + } as never, + ), ).resolves.toEqual({ ok: true }); } finally { globalThis.fetch = originalFetch; diff --git a/packages/mcp/tests/unit/mcp-server/analytics.test.ts b/packages/mcp/tests/unit/mcp-server/analytics.test.ts new file mode 100644 index 000000000..7b9d77666 --- /dev/null +++ b/packages/mcp/tests/unit/mcp-server/analytics.test.ts @@ -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); + } + }); +}); diff --git a/packages/mcp/tsconfig.json b/packages/mcp/tsconfig.json index d9d31a185..457afcd7c 100644 --- a/packages/mcp/tsconfig.json +++ b/packages/mcp/tsconfig.json @@ -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, diff --git a/scripts/axiom/createLeafDataset.ts b/scripts/axiom/createLeafDataset.ts index f5f88ab4c..c791c9523 100644 --- a/scripts/axiom/createLeafDataset.ts +++ b/scripts/axiom/createLeafDataset.ts @@ -1,14 +1,12 @@ /** - * Idempotently provisions the Axiom `leaf` dataset used for MCP usage - * analytics (events emitted from packages/mcp `tool.execute`), and configures - * its map fields. + * 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): - * MCP tool `input`/`output` payloads have an open-ended shape — every distinct - * arg key would otherwise become its own mapped field and quickly blow Axiom's - * per-dataset field limit. Declaring `input` and `output` as map fields stores - * their nested keys inside one field each, so they never count toward the limit - * while staying queryable (e.g. `where input.customer_id == '...'`). + * 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 @@ -17,17 +15,25 @@ * 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 the map - * field list is declared via PUT (full replace), so re-running converges. + * - 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 app MCP usage analytics (per tool.execute)"; +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 = ["input", "output"]; +const MAP_FIELDS = [ + "context", + "data", + "extras", + "input", + "output", + "req", + "res", +]; const authHeaders = (token: string) => ({ Authorization: `Bearer ${token}`, @@ -59,7 +65,42 @@ const createDataset = async (token: string) => { throw new Error(`Failed to create dataset: ${res.status} ${text}`); }; -const setMapField = async (token: string, name: string) => { +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; + 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`, { @@ -69,13 +110,15 @@ const setMapField = async (token: string, name: string) => { }, ); - // Re-declaring an existing map field returns a 4xx mentioning existence. 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; } @@ -84,8 +127,9 @@ const setMapField = async (token: string, name: string) => { }; const setMapFields = async (token: string) => { + const existing = await getMapFields(token); for (const name of MAP_FIELDS) { - await setMapField(token, name); + await setMapField({ existing, name, token }); } }; diff --git a/scripts/mcp/addMcp.ts b/scripts/mcp/addMcp.ts new file mode 100644 index 000000000..3b76d3e41 --- /dev/null +++ b/scripts/mcp/addMcp.ts @@ -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 # 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 `). + */ + +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();