import { waitUntil } from "cloudflare:workers"; import { accountDb } from "../scoped-db"; import { randomId } from "../crypto"; import { HttpError } from "../errors"; import { enforceRateLimit, limitHeavy } from "../limits"; import { RPC, RpcError } from "./protocol"; import type { McpCaller } from "./protocol"; import { recordToolUsage } from "./usage"; const MAX_AUDIT_STRING = 200; const MAX_AUDIT_JSON = 4000; const MAX_AUDIT_DEPTH = 4; /** * Argument keys whose values are user content or credentials: logged as a length and hash, never verbatim. * Covers file bodies, commit messages, CI `inputs` (often secrets), scratch-site `files` and claim notes. */ const REDACTED_KEYS = new Set([ "content", "token", "resolutions", "body", "inputs", "files", "message", "note", "title", "description", "text", "secret", "value", "env", ]); function redact(value: unknown, depth = 0): unknown { if (depth > MAX_AUDIT_DEPTH) return "…"; if (typeof value === "string") return value.length > MAX_AUDIT_STRING ? `<${value.length} chars>` : value; if (Array.isArray(value)) return value.slice(0, 20).map((item) => redact(item, depth + 1)); if (typeof value !== "object" || value === null) return value; const out: Record = {}; for (const [key, item] of Object.entries(value)) { out[key] = REDACTED_KEYS.has(key) ? summarize(item) : redact(item, depth + 1); } return out; } /** Shape and size of a redacted value, never its contents: `<12 chars>`, `<3 keys, 240 chars>`, `<5 items>`. */ function summarize(value: unknown): string { if (typeof value === "string") return `<${value.length} chars>`; if (Array.isArray(value)) return `<${value.length} items>`; if (typeof value === "object" && value !== null) { return `<${Object.keys(value).length} keys, ${JSON.stringify(value).length} chars>`; } return ""; } /** One row per write tool call, successful or not, naming the agent and the human behind it. */ export async function auditToolCall(input: { caller: McpCaller; tool: string; args: unknown; repoId: string | null; outcome: "ok" | "denied" | "error"; detail: string | null; }): Promise { try { await accountDb(input.caller.viewer.id).run( `INSERT INTO mcp_audit (id, user_id, token_id, agent_name, repo_id, tool, arguments, outcome, detail, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, randomId(), input.caller.viewer.id, input.caller.tokenId, input.caller.agent.claimedName ? `${input.caller.agent.name} [claims ${input.caller.agent.claimedName}]` : input.caller.agent.name, input.repoId, input.tool, JSON.stringify(redact(input.args)).slice(0, MAX_AUDIT_JSON), input.outcome, input.detail?.slice(0, 500) ?? null, Date.now(), ); } catch (error) { console.error("MCP audit write failed", error); } } /** * Per-token and per-session budgets on top of the per-user limits the shared server modules enforce, * so one runaway agent cannot exhaust its human's whole allowance. */ export async function limitToolCall(caller: McpCaller, write: boolean): Promise { await limitMcpRequest(caller.tokenId ?? caller.viewer.id, caller.sessionId, write); } /** Same budgets keyed directly, for methods handled before a caller exists (`initialize`, `subscriptions/listen`). */ export async function limitMcpRequest(tokenKey: string, sessionId: string | null, write: boolean): Promise { await enforceRateLimit("GIT_LIMITER", `mcp:token:${tokenKey}`); if (sessionId) await enforceRateLimit("GIT_LIMITER", `mcp:session:${sessionId}`); if (!write) return; await enforceRateLimit("WRITE_LIMITER", `mcp:token:${tokenKey}`); if (sessionId) await enforceRateLimit("WRITE_LIMITER", `mcp:session:${sessionId}`); } /** Non-tool methods that read user data or create server state count against the same limits and usage rows. */ export async function meterMethod(caller: McpCaller, method: string, heavy = false): Promise { try { await limitToolCall(caller, false); if (heavy) await limitHeavy(caller.viewer); } catch (error) { if (error instanceof HttpError) throw new RpcError(RPC.invalidRequest, error.message, undefined, error.status); throw error; } waitUntil(recordToolUsage(caller.viewer.id, `$${method}`, false)); }