From b40e69e117a052d2100313a5a5e425d76faff4f5 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 7 Nov 2026 02:39:04 +0000 Subject: [PATCH 1/2] Trending: public /api/trending and a list_trending_topics MCP tool Both answer from one function, so they cannot drift, and say when the list has stopped steering delivery. Co-Authored-By: Claude Opus 5.5 --- app/(marketing)/trending/page.tsx | 8 +++++- app/api/mcp/route.ts | 2 ++ app/api/trending/route.ts | 17 ++++++++++++ lib/ads/trendList.ts | 31 +++++++++++++++++++++ lib/mcp/trends.ts | 19 +++++++++++++ tests/ads-trend-list.test.ts | 46 +++++++++++++++++++++++++++++++ 6 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 app/api/trending/route.ts create mode 100644 lib/ads/trendList.ts create mode 100644 lib/mcp/trends.ts create mode 100644 tests/ads-trend-list.test.ts diff --git a/app/(marketing)/trending/page.tsx b/app/(marketing)/trending/page.tsx index ec4bec37..14b7513a 100644 --- a/app/(marketing)/trending/page.tsx +++ b/app/(marketing)/trending/page.tsx @@ -84,7 +84,13 @@ export default async function TrendingPage() { ))} - {newest &&

Updated {updated(newest)}.

} +

+ {newest && `Updated ${updated(newest)}. `}Also as{" "} + + JSON + {" "} + and the list_trending_topics MCP tool. +

)} diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts index af673ea3..76715379 100644 --- a/app/api/mcp/route.ts +++ b/app/api/mcp/route.ts @@ -17,6 +17,7 @@ import { registerAffiliateTools } from "@/lib/mcp/affiliate"; import { registerActorTools } from "@/lib/mcp/actors"; import { registerCreditTools } from "@/lib/mcp/credits"; import { registerWebhookTools } from "@/lib/mcp/webhooks"; +import { registerTrendTools } from "@/lib/mcp/trends"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -32,6 +33,7 @@ const handler = createMcpHandler( registerActorTools(server); registerCreditTools(server); registerWebhookTools(server); + registerTrendTools(server); }, {}, // The route is mounted at /api/mcp, so mcp-handler must derive its endpoint diff --git a/app/api/trending/route.ts b/app/api/trending/route.ts new file mode 100644 index 00000000..7e4b9557 --- /dev/null +++ b/app/api/trending/route.ts @@ -0,0 +1,17 @@ +// GET /api/trending?limit=50 — the list behind /trending, open and with no key. +// The token-authed /api/ads/v1/trends adds what advertisers need on top. + +import { NextResponse, type NextRequest } from "next/server"; +import { serviceClient } from "@/lib/supabase/service"; +import { publicTrendList } from "@/lib/ads/trendList"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(req: NextRequest) { + const limit = Number(new URL(req.url).searchParams.get("limit")) || 50; + const list = await publicTrendList(serviceClient(), { limit }); + return NextResponse.json(list, { + headers: { "cache-control": "public, max-age=3600", "access-control-allow-origin": "*" }, + }); +} diff --git a/lib/ads/trendList.ts b/lib/ads/trendList.ts new file mode 100644 index 00000000..9dbbaec9 --- /dev/null +++ b/lib/ads/trendList.ts @@ -0,0 +1,31 @@ +// The trend list as outsiders see it: the public API and the MCP tool answer with +// this, so the two cannot drift apart. + +import type { SupabaseClient } from "@supabase/supabase-js"; +import { currentTrends } from "./trends"; +import { TREND_MAX_AGE_HOURS, TREND_SOURCE, TREND_WINDOW_DAYS, trendsAreStale } from "./trending"; + +export async function publicTrendList(sb: SupabaseClient, options: { limit?: number; now?: number } = {}) { + const now = options.now ?? Date.now(); + const limit = Math.min(200, Math.max(1, Math.round(Number(options.limit)) || 50)); + const signals = await currentTrends(sb, { limit, includeStale: true, now }); + const newest = signals.reduce( + (latest, signal) => (!latest || (signal.ingestedAt ?? "") > latest ? (signal.ingestedAt ?? latest) : latest), + null, + ); + return { + source: TREND_SOURCE, + window_days: TREND_WINDOW_DAYS, + ingested_at: newest, + // Past this the list no longer steers delivery, and an agent should know. + stale: signals.length > 0 && trendsAreStale(newest, now), + max_age_hours: TREND_MAX_AGE_HOURS, + topics: signals.map((signal) => ({ + topic: signal.label ?? signal.topic, + match_key: signal.topic, + score: signal.score, + feeds: signal.mentions, + prior_feeds: signal.priorMentions, + })), + }; +} diff --git a/lib/mcp/trends.ts b/lib/mcp/trends.ts new file mode 100644 index 00000000..35fea455 --- /dev/null +++ b/lib/mcp/trends.ts @@ -0,0 +1,19 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { serviceClient } from "@/lib/supabase/service"; +import { publicTrendList } from "@/lib/ads/trendList"; + +export function registerTrendTools(server: McpServer): void { + server.registerTool( + "list_trending_topics", + { + description: + "Subjects blogs and news sites are writing about more than last week, the list trending ad targeting matches on. Feeds are counted in a sample, so read them as movement. `stale: true` means the list no longer steers delivery.", + inputSchema: { limit: z.number().int().min(1).max(200).optional().describe("Default 50.") }, + }, + async (args) => { + const list = await publicTrendList(serviceClient(), { limit: args.limit }); + return { content: [{ type: "text" as const, text: JSON.stringify(list, null, 2) }] }; + }, + ); +} diff --git a/tests/ads-trend-list.test.ts b/tests/ads-trend-list.test.ts new file mode 100644 index 00000000..f766f173 --- /dev/null +++ b/tests/ads-trend-list.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { publicTrendList } from "@/lib/ads/trendList"; + +const NOW = Date.parse("2026-11-07T12:00:00Z"); +const row = (topic: string, label: string, ingested_at: string) => ({ + source: "rssamp", + topic, + label, + mentions: 60, + prior_mentions: 2, + score: 40, + window_days: 7, + generated_at: null, + ingested_at, +}); + +function client(rows: unknown[]) { + const chain = { + select: () => chain, + eq: () => chain, + order: () => chain, + limit: async () => ({ data: rows, error: null }), + }; + return { from: () => chain } as never; +} + +describe("publicTrendList", () => { + it("shows the subject as written and keeps the match key beside it", async () => { + const list = await publicTrendList(client([row("midterm result", "midterm results", "2026-11-07T11:00:00Z")]), { now: NOW }); + expect(list.stale).toBe(false); + expect(list.topics).toEqual([ + { topic: "midterm results", match_key: "midterm result", score: 40, feeds: 60, prior_feeds: 2 }, + ]); + }); + + it("says when the list has stopped steering delivery", async () => { + const list = await publicTrendList(client([row("a", "a", "2026-11-01T11:00:00Z")]), { now: NOW }); + expect(list.stale).toBe(true); + expect(list.topics).toHaveLength(1); + }); + + it("is not stale when there is simply nothing yet", async () => { + const list = await publicTrendList(client([]), { now: NOW }); + expect(list).toMatchObject({ stale: false, ingested_at: null, topics: [] }); + }); +}); From ab1201eb714f781a5d26eac884b0a033411c57d8 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 7 Nov 2026 02:40:36 +0000 Subject: [PATCH 2/2] Trending: keep unsafe subjects off the page and out of targeting Co-Authored-By: Claude Opus 5.5 --- lib/ads/trends.ts | 5 ++++- tests/ads-trending.test.ts | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/ads/trends.ts b/lib/ads/trends.ts index 343c0b5e..08c5c7d8 100644 --- a/lib/ads/trends.ts +++ b/lib/ads/trends.ts @@ -18,6 +18,9 @@ export type IngestResult = | { ok: true; source: string; windowDays: number; stored: number; removed: number; generatedAt: string | null } | { ok: false; status: number; error: string }; +// Real news, but nothing to publish on a sales page or sell an ad against. +const UNSAFE_TOPIC = /\b(abuse|assault|rape\w*|sexual\w*|sex|porn\w*|nude\w*|child|children|minor|minors|murder\w*|suicide|massacre|shooting|stabbing|beheaded|terror\w*)\b/i; + const clampNumber = (value: unknown, min: number, max: number, fallback: number): number => { const n = Number(value); if (!Number.isFinite(n)) return fallback; @@ -56,7 +59,7 @@ export function parseTrendPayload( for (const item of rawTopics) { const row = (item ?? {}) as Record; const topic = normalizeTopic(String(row.topic ?? "")); - if (!topic || seen.has(topic)) continue; + if (!topic || seen.has(topic) || UNSAFE_TOPIC.test(String(row.topic))) continue; seen.add(topic); signals.push({ source, diff --git a/tests/ads-trending.test.ts b/tests/ads-trending.test.ts index 2bcf5f88..b657f8ca 100644 --- a/tests/ads-trending.test.ts +++ b/tests/ads-trending.test.ts @@ -287,3 +287,10 @@ describe("asking for trending targeting", () => { expect(promoLine(null)).toBe(""); }); }); + +describe("unsafe topics", () => { + it("are dropped before they can be shown or targeted", () => { + const parsed = parseTrendPayload({ topics: [{ topic: "abuse material", score: 9 }, { topic: "midterm results", score: 5 }] }); + expect(parsed.ok && parsed.signals.map((s) => s.label)).toEqual(["midterm results"]); + }); +});