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(); + }); +});