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/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/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: [] });
+ });
+});
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"]);
+ });
+});