Skip to content

Commit ed0ebd9

Browse files
ralyodioclaude
andauthored
mcp: autoblog tools — post, guest-post, link-exchange, articles, traffic (#250)
Extends the existing CrawlProof MCP server (/api/mcp) with the autoblog and link-exchange engine that the dashboard drives by hand, so an agent holding a crp_ token can drive it too. New lib/mcp/autoblog.ts, registered alongside the promote/stats/audits/leads modules. Tools: - autoblog_sites — the caller's autoblog sites, ids and status. - autoblog_post — queue an article on one of them (topic from its own plan). - autoblog_guest_post — request a guest post from the caller's site to another, mirroring the dashboard route's dedupe (generated reported, failed retried, live returned, else queued) and enqueueing the worker. - autoblog_link_exchange — read-only: reciprocal backlink candidates for a site via the existing matcher. - autoblog_articles — a site's recent articles and status. - traffic — a site's visitors/pageviews and top sources/pages, the tracker data the dashboard shows, filterable by humans/bots/all. Every tool scopes to the authenticated user and verifies site ownership before any side effect: the MCP route uses the service-role client, and the lx HTTP routes authenticate by session cookie an MCP caller lacks, so these call the lib functions directly with owner checks rather than proxying routes. A contract test pins the tool surface and schemas over the real SDK, mirroring the stats/promote/audits/leads tests. Claude-Session: https://claude.ai/code/session_01MxNif5tsYq4LczgG7aE8Jp Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent eb03f14 commit ed0ebd9

3 files changed

Lines changed: 303 additions & 0 deletions

File tree

‎app/api/mcp/route.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { registerPromoteTools } from "@/lib/mcp/promote";
1212
import { registerStatsTools } from "@/lib/mcp/stats";
1313
import { registerAuditTools } from "@/lib/mcp/audits";
1414
import { registerLeadTools } from "@/lib/mcp/leads";
15+
import { registerAutoblogTools } from "@/lib/mcp/autoblog";
1516

1617
export const runtime = "nodejs";
1718
export const dynamic = "force-dynamic";
@@ -22,6 +23,7 @@ const handler = createMcpHandler(
2223
registerStatsTools(server);
2324
registerAuditTools(server);
2425
registerLeadTools(server);
26+
registerAutoblogTools(server);
2527
},
2628
{},
2729
// The route is mounted at /api/mcp, so mcp-handler must derive its endpoint

‎lib/mcp/autoblog.ts‎

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
// Autoblog capability for the CrawlProof MCP server. Lets an agent drive the
2+
// link-exchange / autoblog engine that the dashboard drives by hand: list the
3+
// caller's autoblog sites, queue an article on one, request a guest post from
4+
// one site to another, find backlink-exchange candidates, and read a site's
5+
// recent articles and its traffic.
6+
//
7+
// Every query is scoped EXPLICITLY to the authenticated user. The MCP route
8+
// uses the service-role client (no RLS), so each tool filters by user_id /
9+
// owner_id itself and verifies site ownership before any side effect. The lx
10+
// HTTP routes authenticate with a Supabase session cookie, which an MCP caller
11+
// does not have, so these tools call the lib functions directly rather than
12+
// proxying the routes.
13+
14+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
15+
import { z } from "zod";
16+
import { serviceClient } from "@/lib/supabase/service";
17+
import { enqueueArticleGenerate, enqueueGuestPostGenerate } from "@/lib/lx/workerClient";
18+
import { findExchangeCandidates } from "@/lib/lx/exchangeMatcher";
19+
import { projectStats, resolveProject } from "@/lib/tracker/apiStats";
20+
import { trackerRange } from "@/lib/tracker/ranges";
21+
import { DEFAULT_WHO, parseWho, whoToKind } from "@/lib/tracker/who";
22+
23+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
24+
function getUserId(extra: any): string {
25+
const info = extra?.authInfo;
26+
const uid = info?.extra?.userId ?? info?.clientId;
27+
if (!uid || typeof uid !== "string") throw new Error("Unauthenticated.");
28+
return uid;
29+
}
30+
function textResult(s: string) {
31+
return { content: [{ type: "text" as const, text: s }] };
32+
}
33+
function errorResult(s: string) {
34+
return { content: [{ type: "text" as const, text: s }], isError: true };
35+
}
36+
37+
type SiteRow = {
38+
id: string;
39+
domain: string | null;
40+
url: string | null;
41+
niche: string | null;
42+
status: string;
43+
backlinks_enabled: boolean;
44+
};
45+
46+
/** The caller's autoblog site by id, or null if it is not theirs. */
47+
async function ownedSite(userId: string, siteId: string): Promise<SiteRow | null> {
48+
const { data } = await serviceClient()
49+
.from("lx_site")
50+
.select("id, domain, url, niche, status, backlinks_enabled")
51+
.eq("id", siteId)
52+
.eq("user_id", userId)
53+
.maybeSingle();
54+
return (data as SiteRow | null) ?? null;
55+
}
56+
57+
export function registerAutoblogTools(server: McpServer): void {
58+
server.registerTool(
59+
"autoblog_sites",
60+
{
61+
description:
62+
"List the caller's autoblog sites (id, domain, status, whether backlink exchange is on, and when the next article is due). Use a site id with autoblog_post, autoblog_guest_post, autoblog_link_exchange or autoblog_articles.",
63+
inputSchema: {},
64+
},
65+
async (_args, extra) => {
66+
const userId = getUserId(extra);
67+
const { data } = await serviceClient()
68+
.from("lx_site")
69+
.select("id, domain, url, status, backlinks_enabled, next_publish_at, daily_article_count")
70+
.eq("user_id", userId)
71+
.order("created_at", { ascending: false })
72+
.limit(100);
73+
const rows =
74+
(data as {
75+
id: string;
76+
domain: string | null;
77+
url: string | null;
78+
status: string;
79+
backlinks_enabled: boolean;
80+
next_publish_at: string | null;
81+
daily_article_count: number | null;
82+
}[]) ?? [];
83+
if (!rows.length) return textResult("No autoblog sites. Set one up in the CrawlProof dashboard under Autoblog.");
84+
return textResult(
85+
rows
86+
.map(
87+
(s) =>
88+
`- ${s.domain ?? s.url ?? "(no domain)"} — ${s.status}, ${s.daily_article_count ?? 0}/day, exchange ${s.backlinks_enabled ? "on" : "off"}${s.next_publish_at ? `, next ${s.next_publish_at}` : ""} (id: ${s.id})`,
89+
)
90+
.join("\n"),
91+
);
92+
},
93+
);
94+
95+
server.registerTool(
96+
"autoblog_post",
97+
{
98+
description:
99+
"Queue a new article for one of the caller's autoblog sites. The topic is chosen from the site's own keyword plan, exactly as a scheduled article. Returns once queued; the worker generates and delivers it. Credits are consumed and re-checked by the worker.",
100+
inputSchema: {
101+
siteId: z.string().describe("An autoblog site id from autoblog_sites."),
102+
preview: z.boolean().optional().describe("Generate a preview draft rather than a live post (default false)."),
103+
},
104+
},
105+
async (args, extra) => {
106+
const userId = getUserId(extra);
107+
const site = await ownedSite(userId, args.siteId);
108+
if (!site) return errorResult("No such autoblog site for this account.");
109+
if (site.status !== "active") return errorResult(`That site is ${site.status}; only an active site can post.`);
110+
await enqueueArticleGenerate(site.id, { manual: true, ...(args.preview ? { preview: true } : {}) });
111+
return textResult(`Queued an article for ${site.domain ?? site.url ?? site.id}. It will appear on the site's dashboard when generation lands.`);
112+
},
113+
);
114+
115+
server.registerTool(
116+
"autoblog_guest_post",
117+
{
118+
description:
119+
"Request a guest post: one of the caller's sites (the author) writes an article for another site (the target), with a backlink to the author. The author site must be the caller's; the target is any site id in the network. Returns the request; the worker generates it.",
120+
inputSchema: {
121+
authorSiteId: z.string().describe("The caller's site that will author the post (from autoblog_sites)."),
122+
targetSiteId: z.string().describe("The site the guest post is written for. Must differ from the author."),
123+
topic: z.string().min(3).describe("What the guest post should be about."),
124+
},
125+
},
126+
async (args, extra) => {
127+
const userId = getUserId(extra);
128+
const author = await ownedSite(userId, args.authorSiteId);
129+
if (!author) return errorResult("No such author site for this account.");
130+
if (author.status !== "active") return errorResult(`The author site is ${author.status}; only an active site can author a guest post.`);
131+
if (args.targetSiteId === author.id) return errorResult("The target must differ from the author site.");
132+
const sb = serviceClient();
133+
const { data: target } = await sb.from("lx_site").select("id, status").eq("id", args.targetSiteId).maybeSingle();
134+
if (!target) return errorResult("No such target site.");
135+
136+
// Dedupe on (author, target, topic), mirroring the dashboard route: a
137+
// generated one is reported, a failed one is retried, a live one is
138+
// returned rather than double-queued.
139+
const { data: existing } = await sb
140+
.from("lx_guest_post_request")
141+
.select("id, status")
142+
.eq("author_site_id", author.id)
143+
.eq("target_site_id", args.targetSiteId)
144+
.eq("topic", args.topic)
145+
.maybeSingle();
146+
if (existing) {
147+
if (existing.status === "generated") return textResult(`A guest post for this topic is already generated (request ${existing.id}).`);
148+
if (existing.status === "failed") {
149+
await sb.from("lx_guest_post_request").update({ status: "queued", error_text: null }).eq("id", existing.id);
150+
await enqueueGuestPostGenerate(author.id, args.targetSiteId, args.topic, { requestId: existing.id });
151+
return textResult(`Retried guest-post request ${existing.id}.`);
152+
}
153+
if (existing.status === "queued") await enqueueGuestPostGenerate(author.id, args.targetSiteId, args.topic, { requestId: existing.id });
154+
return textResult(`Guest-post request ${existing.id} is ${existing.status}.`);
155+
}
156+
const { data: inserted, error } = await sb
157+
.from("lx_guest_post_request")
158+
.insert({ author_site_id: author.id, target_site_id: args.targetSiteId, topic: args.topic, status: "queued" })
159+
.select("id")
160+
.single();
161+
if (error || !inserted) return errorResult(error?.message ?? "Could not record the guest-post request.");
162+
await enqueueGuestPostGenerate(author.id, args.targetSiteId, args.topic, { requestId: inserted.id });
163+
return textResult(`Queued a guest post from ${author.domain ?? author.id} to ${args.targetSiteId} (request ${inserted.id}).`);
164+
},
165+
);
166+
167+
server.registerTool(
168+
"autoblog_link_exchange",
169+
{
170+
description:
171+
"Find reciprocal backlink-exchange candidates for one of the caller's sites: articles on other network sites, in a related niche, that could exchange links. Read-only; it proposes matches, it does not create links.",
172+
inputSchema: {
173+
siteId: z.string().describe("The caller's site to find exchange partners for."),
174+
keyword: z.string().optional().describe("A topic to match on; defaults to the site's niche."),
175+
slots: z.number().int().min(1).max(10).optional().describe("How many candidates to return (default 3)."),
176+
},
177+
},
178+
async (args, extra) => {
179+
const userId = getUserId(extra);
180+
const site = await ownedSite(userId, args.siteId);
181+
if (!site) return errorResult("No such autoblog site for this account.");
182+
const result = await findExchangeCandidates(serviceClient(), {
183+
selfSiteId: site.id,
184+
selfNiche: site.niche,
185+
keyword: (args.keyword ?? site.niche ?? "").trim(),
186+
slots: args.slots ?? 3,
187+
});
188+
if (!result.candidates.length) {
189+
return textResult(`No exchange candidates${result.networkSize ? ` (network has ${result.networkSize} eligible articles)` : ""}. Backlink exchange must be enabled on both sites.`);
190+
}
191+
return textResult(
192+
`${result.candidates.length} candidate${result.candidates.length === 1 ? "" : "s"}${result.relaxed ? " (relaxed niche match)" : ""}:\n` +
193+
result.candidates
194+
.map((c) => `- "${c.title}" — ${c.url}`)
195+
.join("\n"),
196+
);
197+
},
198+
);
199+
200+
server.registerTool(
201+
"autoblog_articles",
202+
{
203+
description: "List recent articles on one of the caller's autoblog sites, with their status and slug.",
204+
inputSchema: {
205+
siteId: z.string().describe("An autoblog site id from autoblog_sites."),
206+
limit: z.number().int().min(1).max(50).optional().describe("Max rows (default 10)."),
207+
},
208+
},
209+
async (args, extra) => {
210+
const userId = getUserId(extra);
211+
const site = await ownedSite(userId, args.siteId);
212+
if (!site) return errorResult("No such autoblog site for this account.");
213+
const { data } = await serviceClient()
214+
.from("lx_article")
215+
.select("title, slug, status, published_at")
216+
.eq("site_id", site.id)
217+
.order("created_at", { ascending: false })
218+
.limit(args.limit ?? 10);
219+
const rows = (data as { title: string; slug: string; status: string; published_at: string | null }[]) ?? [];
220+
if (!rows.length) return textResult("No articles yet on that site.");
221+
return textResult(rows.map((a) => `- [${a.status}] ${a.title} (${a.slug})${a.published_at ? ` — ${a.published_at}` : ""}`).join("\n"));
222+
},
223+
);
224+
225+
server.registerTool(
226+
"traffic",
227+
{
228+
description:
229+
"A site's traffic over a range: visitors, pageviews, and top sources and pages. The same data the dashboard shows, scoped to the caller. `who` filters humans, bots or all.",
230+
inputSchema: {
231+
site: z.string().optional().describe("Site hostname, id or name. Omit to use the caller's only/first site."),
232+
range: z.string().optional().describe("A window like 1h, 24h, 7d, 30d (default 24h)."),
233+
who: z.enum(["humans", "bots", "all"]).optional().describe("Which visitors to count (default humans)."),
234+
},
235+
},
236+
async (args, extra) => {
237+
const userId = getUserId(extra);
238+
const who = args.who ? parseWho(args.who) : DEFAULT_WHO;
239+
if (!who) return errorResult("Unknown who. Expected humans, bots or all.");
240+
const sb = serviceClient();
241+
const resolved = await resolveProject(sb, userId, args.site ?? null);
242+
if (!resolved.ok) return errorResult(resolved.error);
243+
const range = trackerRange(args.range ?? null);
244+
const stats = await projectStats(sb, resolved.project, range, whoToKind(who), who, false);
245+
const topSources = stats.sources.slice(0, 5).map((s) => ` ${s.label}: ${s.value}`).join("\n");
246+
const topPages = stats.pages.slice(0, 5).map((p) => ` ${p.label}: ${p.value}`).join("\n");
247+
return textResult(
248+
`${stats.project.name} — ${stats.range}, ${who}\n` +
249+
`visitors ${stats.totals.visitors}, pageviews ${stats.totals.pageviews}\n` +
250+
(topSources ? `top sources:\n${topSources}\n` : "") +
251+
(topPages ? `top pages:\n${topPages}` : ""),
252+
);
253+
},
254+
);
255+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { describe, it, expect } from "vitest";
2+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
5+
import { registerAutoblogTools } from "@/lib/mcp/autoblog";
6+
7+
// The autoblog tools register against the real SDK and are discoverable over
8+
// the MCP protocol. Handlers are not invoked by tools/list, so no DB or worker
9+
// is touched here; this pins the tool surface and each tool's input schema.
10+
describe("crawlproof MCP · autoblog module", () => {
11+
it("exposes the autoblog toolset", async () => {
12+
const server = new McpServer({ name: "crawlproof-test", version: "0.0.0" });
13+
registerAutoblogTools(server);
14+
15+
const [ct, st] = InMemoryTransport.createLinkedPair();
16+
await server.connect(st);
17+
const client = new Client({ name: "test-client", version: "0.0.0" });
18+
await client.connect(ct);
19+
20+
const { tools } = await client.listTools();
21+
const names = tools.map((t) => t.name).sort();
22+
expect(names).toEqual([
23+
"autoblog_articles",
24+
"autoblog_guest_post",
25+
"autoblog_link_exchange",
26+
"autoblog_post",
27+
"autoblog_sites",
28+
"traffic",
29+
]);
30+
31+
const guest = tools.find((t) => t.name === "autoblog_guest_post");
32+
const props = (guest?.inputSchema as { properties?: Record<string, unknown> })?.properties;
33+
expect(props?.authorSiteId).toBeDefined();
34+
expect(props?.targetSiteId).toBeDefined();
35+
expect(props?.topic).toBeDefined();
36+
37+
const traffic = tools.find((t) => t.name === "traffic");
38+
const tprops = (traffic?.inputSchema as { properties?: Record<string, unknown> })?.properties;
39+
expect(tprops?.site).toBeDefined();
40+
expect(tprops?.range).toBeDefined();
41+
expect(tprops?.who).toBeDefined();
42+
43+
await client.close();
44+
await server.close();
45+
});
46+
});

0 commit comments

Comments
 (0)