diff --git a/app/(app)/projects/[id]/social/api-tokens/page.tsx b/app/(app)/projects/[id]/social/api-tokens/page.tsx
index 1fab2733..ec0fa042 100644
--- a/app/(app)/projects/[id]/social/api-tokens/page.tsx
+++ b/app/(app)/projects/[id]/social/api-tokens/page.tsx
@@ -131,10 +131,12 @@ function McpServerSection() {
- Set CRAWLPROOF_MCP_TOKEN to a token above. Tools:{" "}
- list_accounts, generate_promo_post, post_to_socials,{" "}
- promote_url. The Accept: application/json, text/event-stream header
- is required. Cookie-auth platforms report queued — the post lands shortly after.
+ Set CRAWLPROOF_MCP_TOKEN to a token above. Tools —{" "}
+ 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{" "}
+ 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 ef4bb8e6..c9253b2c 100644
--- a/app/api/mcp/route.ts
+++ b/app/api/mcp/route.ts
@@ -9,6 +9,7 @@ import { createMcpHandler, withMcpAuth } from "mcp-handler";
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";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -16,6 +17,7 @@ export const dynamic = "force-dynamic";
const handler = createMcpHandler(
(server) => {
registerPromoteTools(server);
+ registerStatsTools(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 69110b93..1bb0de24 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -71,6 +71,15 @@ Responses come back as SSE frames (`event: message` / `data: {…}`).
| `post_to_socials` | Publish given text to accounts (all active, or `account_ids`). `{ text, account_ids? }` |
| `promote_url` | One shot: write a per-platform promo post for a URL and publish it. `{ url, account_ids?, angle?, brand_voice? }` |
+## Tools (module: `stats`, read-only)
+
+| Tool | What it does |
+|------|--------------|
+| `list_projects` | The caller's sites/projects (name, url, id). |
+| `recent_audits` | Recent AEO audits with scores + status. `{ url?, limit? }` |
+| `ad_earnings` | Ad-network money summary — earned (publisher), spent (advertiser), net. |
+| `promote_status` | Recent Promote posts and their status (posted / queued / failed). `{ limit? }` |
+
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/stats.ts b/lib/mcp/stats.ts
new file mode 100644
index 00000000..e43303c7
--- /dev/null
+++ b/lib/mcp/stats.ts
@@ -0,0 +1,153 @@
+// Read-only "stats" capability for the CrawlProof MCP server. Lets an agent ask
+// "what sites do I have / how did my last audits score / what am I earning /
+// did my promo posts land" without any side effects. Everything is scoped
+// EXPLICITLY to the authenticated user — the MCP route uses the service-role
+// client (no RLS), so every query filters by owner_id/user_id itself.
+
+import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { z } from "zod";
+import { serviceClient } from "@/lib/supabase/service";
+
+// 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 dollars(cents: number): string {
+ const neg = cents < 0;
+ return `${neg ? "-" : ""}$${(Math.abs(cents) / 100).toFixed(2)}`;
+}
+
+export function registerStatsTools(server: McpServer): void {
+ server.registerTool(
+ "list_projects",
+ {
+ description: "List the caller's sites/projects (name, url, id). Use an id/url with recent_audits.",
+ inputSchema: {},
+ },
+ async (_args, extra) => {
+ const userId = getUserId(extra);
+ const { data } = await serviceClient()
+ .from("projects")
+ .select("id, name, url")
+ .eq("owner_id", userId)
+ .order("created_at", { ascending: false })
+ .limit(100);
+ const rows = (data as { id: string; name: string; url: string }[]) ?? [];
+ if (!rows.length) return textResult("No projects yet.");
+ return textResult(rows.map((p) => `- ${p.name} — ${p.url} (id: ${p.id})`).join("\n"));
+ },
+ );
+
+ server.registerTool(
+ "recent_audits",
+ {
+ description:
+ "The caller's recent AEO audits with their scores (0–100) and status. Optionally filter by a URL substring.",
+ inputSchema: {
+ url: z.string().optional().describe("Filter to audits whose target URL contains this."),
+ limit: z.number().int().min(1).max(50).optional().describe("Max rows (default 10)."),
+ },
+ },
+ async (args, extra) => {
+ const userId = getUserId(extra);
+ let q = serviceClient()
+ .from("audits")
+ .select("target_url, engine, score, status, created_at")
+ .eq("owner_id", userId)
+ .order("created_at", { ascending: false })
+ .limit(args.limit ?? 10);
+ if (args.url) q = q.ilike("target_url", `%${args.url}%`);
+ const { data } = await q;
+ const rows =
+ (data as { target_url: string; engine: string | null; score: number | null; status: string; created_at: string }[]) ??
+ [];
+ if (!rows.length) return textResult("No audits found.");
+ return textResult(
+ rows
+ .map(
+ (a) =>
+ `- ${a.target_url} [${a.engine ?? "?"}] ${a.score ?? "—"}/100 (${a.status}) — ${new Date(
+ a.created_at,
+ )
+ .toISOString()
+ .slice(0, 10)}`,
+ )
+ .join("\n"),
+ );
+ },
+ );
+
+ server.registerTool(
+ "ad_earnings",
+ {
+ description:
+ "The caller's ad-network money summary: earned as a publisher, spent as an advertiser, and the net.",
+ inputSchema: {},
+ },
+ async (_args, extra) => {
+ const userId = getUserId(extra);
+ const sb = serviceClient();
+ const [{ data: ledger }, { data: campaigns }] = await Promise.all([
+ sb.from("ad_ledger").select("amount_cents").eq("owner_id", userId).eq("kind", "publisher_accrual"),
+ sb.from("ad_campaigns").select("total_spent_cents").eq("owner_id", userId),
+ ]);
+ const earned = ((ledger as { amount_cents: number | null }[]) ?? []).reduce(
+ (a, r) => a + (r.amount_cents ?? 0),
+ 0,
+ );
+ const spent = ((campaigns as { total_spent_cents: number | null }[]) ?? []).reduce(
+ (a, r) => a + (r.total_spent_cents ?? 0),
+ 0,
+ );
+ return textResult(
+ `Earned (publisher): ${dollars(earned)}\nSpent (advertiser): ${dollars(spent)}\nNet: ${dollars(
+ earned - spent,
+ )}`,
+ );
+ },
+ );
+
+ server.registerTool(
+ "promote_status",
+ {
+ description:
+ "The caller's recent Promote posts and their status (posted / queued / failed) across connected socials.",
+ inputSchema: { limit: z.number().int().min(1).max(50).optional().describe("Max rows (default 15).") },
+ },
+ async (args, extra) => {
+ const userId = getUserId(extra);
+ const sb = serviceClient();
+ const { data: lists } = await sb.from("promo_list").select("id").eq("user_id", userId);
+ const ids = ((lists as { id: string }[]) ?? []).map((l) => l.id);
+ if (!ids.length) return textResult("No Promote lists yet.");
+ const { data } = await sb
+ .from("promo_post")
+ .select("platform, status, post_url, created_at")
+ .in("list_id", ids)
+ .order("created_at", { ascending: false })
+ .limit(args.limit ?? 15);
+ const rows =
+ (data as { platform: string; status: string; post_url: string | null; created_at: string }[]) ?? [];
+ if (!rows.length) return textResult("No posts yet.");
+ const counts: Record = {};
+ for (const r of rows) {
+ const s = r.status === "pending" ? "queued" : r.status;
+ counts[s] = (counts[s] ?? 0) + 1;
+ }
+ const summary = Object.entries(counts)
+ .map(([k, v]) => `${k}: ${v}`)
+ .join(", ");
+ const lines = rows.map((r) => {
+ const status = r.status === "pending" ? "queued" : r.status;
+ return `- ${r.platform}: ${status}${r.post_url ? ` → ${r.post_url}` : ""}`;
+ });
+ return textResult(`Last ${rows.length} (${summary}):\n${lines.join("\n")}`);
+ },
+ );
+}
diff --git a/tests/contract/mcp-stats.test.ts b/tests/contract/mcp-stats.test.ts
new file mode 100644
index 00000000..0d7576b4
--- /dev/null
+++ b/tests/contract/mcp-stats.test.ts
@@ -0,0 +1,31 @@
+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 { registerStatsTools } from "@/lib/mcp/stats";
+
+// The read-only stats tools register against the real SDK and are discoverable
+// over the MCP protocol (handlers aren't invoked by tools/list, so no DB).
+describe("crawlproof MCP · stats module", () => {
+ it("exposes the read-only stats toolset", async () => {
+ const server = new McpServer({ name: "crawlproof-test", version: "0.0.0" });
+ registerStatsTools(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(["ad_earnings", "list_projects", "promote_status", "recent_audits"]);
+
+ const audits = tools.find((t) => t.name === "recent_audits");
+ const props = (audits?.inputSchema as { properties?: Record })?.properties;
+ expect(props?.url).toBeDefined();
+ expect(props?.limit).toBeDefined();
+
+ await client.close();
+ await server.close();
+ });
+});