From 959cccc7639f98cd16b4eb1ebc424a210a7556fa Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 17 Jul 2026 13:49:06 +0000 Subject: [PATCH] mcp: add read-only "stats" tools module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second capability module for the MCP server — read-only, no side effects, all explicitly scoped to the authenticated user (the route uses the service-role client, so each query filters by owner_id/user_id itself): - list_projects — the caller's sites/projects - recent_audits — recent AEO audits + scores (optional url filter) - ad_earnings — earned (publisher) / spent (advertiser) / net - promote_status — recent Promote posts + posted/queued/failed status Registered alongside promote in app/api/mcp/route.ts. Docs + the API-tokens UI tool list updated. Contract test asserts the 4 tools register + are discoverable over the real SDK (in-memory transport). Co-Authored-By: Claude Opus 4.8 --- .../projects/[id]/social/api-tokens/page.tsx | 10 +- app/api/mcp/route.ts | 2 + docs/mcp.md | 9 ++ lib/mcp/stats.ts | 153 ++++++++++++++++++ tests/contract/mcp-stats.test.ts | 31 ++++ 5 files changed, 201 insertions(+), 4 deletions(-) create mode 100644 lib/mcp/stats.ts create mode 100644 tests/contract/mcp-stats.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 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(); + }); +});