From 1a61f7d5fb1919bd6b64e4df936141bdc6a1ab98 Mon Sep 17 00:00:00 2001
From: Anthony Ettinger
Date: Fri, 17 Jul 2026 14:13:47 +0000
Subject: [PATCH] mcp: add async "audits" tools module (start_audit +
get_audit)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Third MCP capability — AEO audits. Audits run asynchronously (the worker runs
each engine and fills in the score later), so this is a start + poll pair,
mirroring the in-app runAudit flow:
- start_audit({ url, engines? }) — validate URL, charge credits (engines default
to the free rule+dns; AI engines cost credits), insert queued audit rows with
a shared scan_run_id, notify the worker /enqueue. Returns the run id.
- get_audit({ run_id }) — per-engine status + scores (+ public report links when
complete) for that run.
Owner-scoped throughout (service-role client). Registered alongside promote +
stats in app/api/mcp/route.ts. Docs + API-tokens UI updated. Contract test
asserts both tools register + are discoverable over the real SDK.
Built in an isolated worktree off master to stay clear of the concurrent
@profullstack/stack refactor. tsc + next build clean; 3 MCP contract tests pass.
Co-Authored-By: Claude Opus 4.8
---
.../projects/[id]/social/api-tokens/page.tsx | 3 +-
app/api/mcp/route.ts | 2 +
docs/mcp.md | 9 +
lib/mcp/audits.ts | 161 ++++++++++++++++++
tests/contract/mcp-audits.test.ts | 35 ++++
5 files changed, 209 insertions(+), 1 deletion(-)
create mode 100644 lib/mcp/audits.ts
create mode 100644 tests/contract/mcp-audits.test.ts
diff --git a/app/(app)/projects/[id]/social/api-tokens/page.tsx b/app/(app)/projects/[id]/social/api-tokens/page.tsx
index ec0fa042..2646cfe5 100644
--- a/app/(app)/projects/[id]/social/api-tokens/page.tsx
+++ b/app/(app)/projects/[id]/social/api-tokens/page.tsx
@@ -135,7 +135,8 @@ function McpServerSection() {
promote: list_accounts, generate_promo_post,{" "}
post_to_socials, promote_url; stats (read-only):{" "}
list_projects, recent_audits, ad_earnings,{" "}
- promote_status. The Accept: application/json, text/event-stream{" "}
+ promote_status; audits: start_audit, get_audit.
+ The Accept: application/json, text/event-stream{" "}
header is required. Cookie-auth platforms report queued — the post lands shortly after.
diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts
index c9253b2c..ee9d2b76 100644
--- a/app/api/mcp/route.ts
+++ b/app/api/mcp/route.ts
@@ -10,6 +10,7 @@ import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
import { authenticateToken } from "@/lib/sp/apiAuth";
import { registerPromoteTools } from "@/lib/mcp/promote";
import { registerStatsTools } from "@/lib/mcp/stats";
+import { registerAuditTools } from "@/lib/mcp/audits";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -18,6 +19,7 @@ const handler = createMcpHandler(
(server) => {
registerPromoteTools(server);
registerStatsTools(server);
+ registerAuditTools(server);
},
{},
// The route is mounted at /api/mcp, so mcp-handler must derive its endpoint
diff --git a/docs/mcp.md b/docs/mcp.md
index 1bb0de24..6da0eb97 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -80,6 +80,15 @@ Responses come back as SSE frames (`event: message` / `data: {…}`).
| `ad_earnings` | Ad-network money summary — earned (publisher), spent (advertiser), net. |
| `promote_status` | Recent Promote posts and their status (posted / queued / failed). `{ limit? }` |
+## Tools (module: `audits`)
+
+AEO audits run asynchronously, so this is a start + poll pair.
+
+| Tool | What it does |
+|------|--------------|
+| `start_audit` | Kick off an AEO audit of a URL; returns a run id. Engines default to rule+dns (free); AI engines cost credits. `{ url, engines? }` |
+| `get_audit` | Per-engine status + scores for a run. `{ run_id }` |
+
Cookie-auth platforms (reddit/facebook/…) publish asynchronously, so their
result reads **`queued`** — the post lands shortly after and the View-post link
appears in the app's Promote history.
diff --git a/lib/mcp/audits.ts b/lib/mcp/audits.ts
new file mode 100644
index 00000000..6e422401
--- /dev/null
+++ b/lib/mcp/audits.ts
@@ -0,0 +1,161 @@
+// Audits capability for the CrawlProof MCP server. AEO audits are ASYNCHRONOUS
+// (the worker runs each engine and fills in the score later), so this is a
+// start + poll pair: start_audit kicks off a run and returns a run id;
+// get_audit reports the per-engine status/scores for that run. Mirrors the
+// in-app runAudit flow (validate → credits → insert queued rows → notify the
+// worker /enqueue). Scoped to the authenticated user throughout.
+
+import crypto from "node:crypto";
+import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { z } from "zod";
+import { env } from "@/lib/env";
+import { serviceClient } from "@/lib/supabase/service";
+import { isAllowedTargetUrl, consumeCredit, refundCredit, checkPerTargetLimit } from "@/lib/rateLimit";
+import {
+ ENGINES,
+ selectionCost,
+ engineAvailable,
+ dedupeEngines,
+ DEFAULT_PROJECT_ENGINES,
+ type Engine,
+} from "@/lib/credits";
+import { newShareToken } from "@/lib/shareToken";
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+function getUserId(extra: any): string {
+ const info = extra?.authInfo;
+ const uid = info?.extra?.userId ?? info?.clientId;
+ if (!uid || typeof uid !== "string") throw new Error("Unauthenticated.");
+ return uid;
+}
+function textResult(s: string) {
+ return { content: [{ type: "text" as const, text: s }] };
+}
+function errorResult(s: string) {
+ return { content: [{ type: "text" as const, text: s }], isError: true };
+}
+
+async function notifyWorker(auditId: string): Promise {
+ if (!env.workerUrl) return;
+ try {
+ await fetch(`${env.workerUrl}/enqueue`, {
+ method: "POST",
+ headers: { "content-type": "application/json", "x-worker-secret": env.workerSecret },
+ body: JSON.stringify({ auditId }),
+ });
+ } catch {
+ // Fall back to the worker's periodic sweep, which also picks up queued rows.
+ }
+}
+
+const VALID_ENGINES = new Set(Object.keys(ENGINES));
+
+export function registerAuditTools(server: McpServer): void {
+ server.registerTool(
+ "start_audit",
+ {
+ description:
+ "Start an AEO (Answer Engine Optimization) audit of a URL. Async — returns a run id; poll get_audit for scores. Engines default to rule+dns (free); AI engines (claude, openai, gemini, perplexity, …) cost credits. Available engines: " +
+ Object.keys(ENGINES).join(", ") +
+ ".",
+ inputSchema: {
+ url: z.string().describe("The page/site URL to audit."),
+ engines: z
+ .array(z.string())
+ .optional()
+ .describe("Engines to run. Omit for the free default (rule, dns)."),
+ },
+ },
+ async (args, extra) => {
+ const userId = getUserId(extra);
+ const check = isAllowedTargetUrl(args.url);
+ if (!check.ok) return errorResult(check.reason);
+ const target = check.url;
+
+ let engines: Engine[] =
+ args.engines && args.engines.length
+ ? dedupeEngines(args.engines.filter((e): e is Engine => VALID_ENGINES.has(e)))
+ : DEFAULT_PROJECT_ENGINES;
+ if (!engines.length) engines = DEFAULT_PROJECT_ENGINES;
+ const unavailable = engines.find((e) => !engineAvailable(e));
+ if (unavailable) return errorResult(`Engine "${ENGINES[unavailable].label}" isn't available.`);
+
+ const cost = selectionCost(engines);
+ if (cost > 0) {
+ const c = await consumeCredit(userId, cost);
+ if (!c.ok)
+ return errorResult(
+ `Need ${cost} credit${cost === 1 ? "" : "s"} for ${engines.length} engine${
+ engines.length === 1 ? "" : "s"
+ } — not enough balance. Buy credits in Billing.`,
+ );
+ }
+ if (!(await checkPerTargetLimit(target, userId))) {
+ if (cost > 0) await refundCredit(userId, cost);
+ return errorResult("This URL was just audited — try again in ~30 seconds.");
+ }
+
+ const sb = serviceClient();
+ const scanRunId = crypto.randomUUID();
+ const inserts = engines.map((e) => ({
+ target_url: target,
+ project_id: null,
+ owner_id: userId,
+ status: "queued",
+ share_token: newShareToken(),
+ triggered_by: "manual", // CHECK constraint allows only manual|scheduled
+ engine: e,
+ scan_run_id: scanRunId,
+ }));
+ const { data: rows, error } = await sb.from("audits").insert(inserts).select("id");
+ if (error || !rows) {
+ if (cost > 0) await refundCredit(userId, cost);
+ return errorResult(error?.message ?? "Failed to create the audit.");
+ }
+ for (const r of rows as { id: string }[]) await notifyWorker(r.id);
+
+ return textResult(
+ `Started AEO audit of ${target} — engines: ${engines.join(", ")} ${
+ cost > 0 ? `(spent ${cost} credit${cost === 1 ? "" : "s"})` : "(free)"
+ }.\nRun id: ${scanRunId}\nPoll get_audit({ run_id: "${scanRunId}" }) — engines finish in ~10–60s each.`,
+ );
+ },
+ );
+
+ server.registerTool(
+ "get_audit",
+ {
+ description: "Get the status + scores of an audit run started by start_audit.",
+ inputSchema: { run_id: z.string().describe("The run id returned by start_audit.") },
+ },
+ async (args, extra) => {
+ const userId = getUserId(extra);
+ const { data } = await serviceClient()
+ .from("audits")
+ .select("engine, status, score, failed_reason, share_token")
+ .eq("scan_run_id", args.run_id)
+ .eq("owner_id", userId);
+ const rows =
+ (data as {
+ engine: string;
+ status: string;
+ score: number | null;
+ failed_reason: string | null;
+ share_token: string | null;
+ }[]) ?? [];
+ if (!rows.length) return errorResult("No audit found for that run id.");
+
+ const site = env.siteUrl.replace(/\/$/, "");
+ const lines = rows.map((a) => {
+ if (a.status === "complete")
+ return `✓ ${a.engine}: ${a.score ?? "—"}/100${a.share_token ? ` → ${site}/r/${a.share_token}` : ""}`;
+ if (a.status === "failed")
+ return `✗ ${a.engine}: failed${a.failed_reason ? ` (${a.failed_reason})` : ""}`;
+ return `… ${a.engine}: ${a.status}`;
+ });
+ const done = rows.filter((a) => a.status === "complete" || a.status === "failed").length;
+ const header = done === rows.length ? "Done." : `${done}/${rows.length} engines finished (still running)…`;
+ return textResult(`${header}\n${lines.join("\n")}`);
+ },
+ );
+}
diff --git a/tests/contract/mcp-audits.test.ts b/tests/contract/mcp-audits.test.ts
new file mode 100644
index 00000000..f2ca6c64
--- /dev/null
+++ b/tests/contract/mcp-audits.test.ts
@@ -0,0 +1,35 @@
+import { describe, it, expect } from "vitest";
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { Client } from "@modelcontextprotocol/sdk/client/index.js";
+import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
+import { registerAuditTools } from "@/lib/mcp/audits";
+
+// The async audit tools register against the real SDK and are discoverable
+// over the MCP protocol (handlers aren't invoked by tools/list, so no DB/worker).
+describe("crawlproof MCP · audits module", () => {
+ it("exposes start_audit + get_audit", async () => {
+ const server = new McpServer({ name: "crawlproof-test", version: "0.0.0" });
+ registerAuditTools(server);
+
+ const [ct, st] = InMemoryTransport.createLinkedPair();
+ await server.connect(st);
+ const client = new Client({ name: "test-client", version: "0.0.0" });
+ await client.connect(ct);
+
+ const { tools } = await client.listTools();
+ const names = tools.map((t) => t.name).sort();
+ expect(names).toEqual(["get_audit", "start_audit"]);
+
+ const start = tools.find((t) => t.name === "start_audit");
+ const props = (start?.inputSchema as { properties?: Record })?.properties;
+ expect(props?.url).toBeDefined();
+ expect(props?.engines).toBeDefined();
+
+ const get = tools.find((t) => t.name === "get_audit");
+ const gp = (get?.inputSchema as { properties?: Record })?.properties;
+ expect(gp?.run_id).toBeDefined();
+
+ await client.close();
+ await server.close();
+ });
+});