rankcontrol 0.2.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,13 +29,38 @@ The env var wins over the stored config when both exist. Scopes are set per key;
29
29
 
30
30
  ```bash
31
31
  npx rankcontrol funnel # AI crawls / AI visits / AI leads (30d)
32
+ npx rankcontrol score # composite visibility score + subscores (AI/Google/Bing)
33
+ npx rankcontrol leads --limit 100 # captured leads with AI source attribution
34
+ npx rankcontrol traffic --days 30 # page views, visitors, bounce rate, time on page
32
35
  npx rankcontrol visibility --days 60 # daily AI visibility score trend
36
+ npx rankcontrol sov --days 30 # share of voice vs competitors
37
+ npx rankcontrol sources --days 30 # cited domains typed brand/competitive/ugc/editorial
38
+ npx rankcontrol sentiment --days 30 # how AI answers frame your brand
39
+ npx rankcontrol optimizer --limit 25 # pages ranked by citability, worst first
33
40
  npx rankcontrol citations --model chatgpt
41
+ npx rankcontrol queries # tracked queries checked weekly across AI engines
34
42
  npx rankcontrol capacity # remaining content-plan slots
35
43
  npx rankcontrol plan-content --topics "bus travel" --max-difficulty 40
36
44
  npx rankcontrol commit-titles --file titles.json
37
45
  npx rankcontrol publish <contentId> # dry run
38
46
  npx rankcontrol publish <contentId> --confirm # actually publish
47
+ npx rankcontrol generate <contentId> # dry run
48
+ npx rankcontrol generate <contentId> --confirm # write a planned article now (slot unchanged)
49
+ npx rankcontrol settings # article policy (auto-write, auto-publish, images, ...)
50
+ npx rankcontrol settings-set --auto-generate off --auto-publish off
51
+ npx rankcontrol reschedule <contentId> 2026-08-04
52
+ npx rankcontrol internal-links <contentId> # who links here / links out
53
+ npx rankcontrol site-pages # pages used for internal links
54
+ npx rankcontrol detect-links https://example.com/sitemap.xml
55
+ npx rankcontrol add-pages https://example.com/pricing
56
+ npx rankcontrol repurpose # published articles + social draft chips
57
+ npx rankcontrol repurpose <contentId> # full drafts for one article
58
+ npx rankcontrol repurpose-generate <contentId> --platforms twitter --confirm
59
+ npx rankcontrol repurpose-edit <draftId> --title "New hook"
60
+ npx rankcontrol repurpose-channels # Postiz channel ids (for repurpose-push)
61
+ npx rankcontrol repurpose-push <draftId> --channels <id> # dry run
62
+ npx rankcontrol repurpose-push <draftId> --channels <id> --confirm
63
+ npx rankcontrol repurpose-mark-posted <draftId> # posted it yourself
39
64
  npx rankcontrol jobs # async job status (agent runs)
40
65
  ```
41
66
 
@@ -66,14 +91,34 @@ Or in `.mcp.json` / Cursor `mcp.json`:
66
91
  | Tool | What it does |
67
92
  |---|---|
68
93
  | `get_overview_funnel` | AI crawls / AI visits / AI leads, last 30 days |
94
+ | `list_leads` | Captured leads with AI model / query / page attribution |
95
+ | `get_traffic_overview` | Page views, visitors, sessions, bounce rate, time on page |
69
96
  | `get_visibility_trend` | Daily visibility score from stored weekly citation checks |
70
97
  | `list_citations` | Recent citation checks per AI model |
71
- | `get_visibility_score` | Current overall AI visibility score |
98
+ | `list_tracked_queries` | The queries checked weekly across the AI engines |
99
+ | `get_visibility_score` | Composite score (50% AI + 30% Google + 20% Bing) with subscores |
100
+ | `get_share_of_voice` | Your citation rate vs competitor mention rates + your share |
101
+ | `get_citation_sources` | Cited domains typed brand / competitive / UGC / editorial |
102
+ | `get_citation_sentiment` | Positive / neutral / negative framing with recent receipts |
103
+ | `get_citability_report` | Published pages by citability score, worst first, with fixes |
72
104
  | `get_planning_capacity` | Remaining calendar slots (check before planning) |
73
105
  | `plan_content` | Generate candidate titles (capacity-gated, nothing scheduled) |
74
106
  | `commit_planned_titles` | Approve reviewed titles onto the calendar |
75
107
  | `publish_content` | Dry run by default; `confirm: true` publishes |
108
+ | `generate_article` | Write a planned article's body now, slot unchanged; dry run by default |
109
+ | `get_article_settings` / `update_article_settings` | Read / merge-patch the article policy (auto-write, auto-publish, images, links, ...) |
110
+ | `reschedule_article` | Move a planned article to another day (flexible schedule) |
111
+ | `get_internal_links` | Who links to this article internally, and its outbound count |
112
+ | `list_site_pages` / `add_site_pages` | Pages used for in-article internal links |
113
+ | `detect_site_links` | Scan a sitemap or blog root for site pages |
114
+ | `list_repurpose_queue` | Published articles + social draft chips per platform |
115
+ | `get_repurpose_drafts` | Full social drafts for one article (8 platforms) |
116
+ | `generate_repurpose_drafts` | Draft social posts for an article; dry run by default |
117
+ | `update_repurpose_draft` | Edit a draft's body/title before pushing |
118
+ | `mark_repurpose_posted` | Record a draft as posted manually |
119
+ | `list_postiz_channels` | Connected Postiz channels (ids for pushing) |
120
+ | `push_repurpose_draft` | Send a draft to Postiz; dry run by default |
76
121
  | `list_content` | Content pages with status |
77
122
  | `list_jobs` | Async job status (agent runs) |
78
123
 
79
- The plan→commit split is intentional: `plan_content` proposes, a human (or a supervising agent) reviews, `commit_planned_titles` approves. `publish_content` is dry-run-first for the same reason.
124
+ The plan→commit split is intentional: `plan_content` proposes, a human (or a supervising agent) reviews, `commit_planned_titles` approves. `publish_content` and `generate_article` are dry-run-first for the same reason.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rankcontrol",
3
- "version": "0.2.0",
3
+ "version": "0.6.0",
4
4
  "description": "RankControl CLI + MCP server: drive your SEO/AI-visibility workspace from the terminal or any AI agent",
5
5
  "license": "MIT",
6
6
  "homepage": "https://rctrl.com",
package/src/cli.mjs CHANGED
@@ -12,13 +12,16 @@ const fail = (err) => {
12
12
  const list = (val) => val.split(",").map((s) => s.trim()).filter(Boolean);
13
13
 
14
14
  export function runCli(argv) {
15
+ const pkg = JSON.parse(
16
+ readFileSync(new URL("../package.json", import.meta.url), "utf8")
17
+ );
15
18
  const program = new Command();
16
19
  program
17
20
  .name("rankcontrol")
18
21
  .description(
19
22
  "RankControl from the terminal. Auth: `rankcontrol login` (browser approval) or export RANKCONTROL_API_KEY=rctrl_pk_..."
20
23
  )
21
- .version("0.2.0");
24
+ .version(pkg.version);
22
25
 
23
26
  program
24
27
  .command("mcp")
@@ -50,12 +53,53 @@ export function runCli(argv) {
50
53
  .description("AI pipeline last 30 days: crawls, AI visits, AI leads")
51
54
  .action(() => api.overviewFunnel().then(out).catch(fail));
52
55
 
56
+ program
57
+ .command("leads")
58
+ .description("Captured leads with source attribution (AI model, query, page)")
59
+ .option("--limit <n>", "Max rows, up to 500", "100")
60
+ .action((opts) => api.leads(Number(opts.limit)).then(out).catch(fail));
61
+
62
+ program
63
+ .command("traffic")
64
+ .description("Traffic overview: page views, visitors, sessions, bounce rate, time on page")
65
+ .option("--days <n>", "7, 30 or 90", "30")
66
+ .action((opts) => api.trafficOverview(Number(opts.days)).then(out).catch(fail));
67
+
68
+ program
69
+ .command("score")
70
+ .description("Composite visibility score: 50% AI citation rate + 30% Google + 20% Bing rank share, with subscores")
71
+ .action(() => api.visibilityScore().then(out).catch(fail));
72
+
53
73
  program
54
74
  .command("visibility")
55
75
  .description("Daily AI visibility score trend from the stored weekly citation checks")
56
76
  .option("--days <n>", "30, 60 or 90", "30")
57
77
  .action((opts) => api.visibilityTrend(Number(opts.days)).then(out).catch(fail));
58
78
 
79
+ program
80
+ .command("sov")
81
+ .description("Share of voice: your citation rate vs top competitors, plus your share of all brand appearances")
82
+ .option("--days <n>", "Window in days, 7-90", "30")
83
+ .action((opts) => api.shareOfVoice(Number(opts.days)).then(out).catch(fail));
84
+
85
+ program
86
+ .command("sources")
87
+ .description("Domains AI answers cite for your tracked queries, typed brand/competitive/ugc/editorial")
88
+ .option("--days <n>", "Window in days, 7-90", "30")
89
+ .action((opts) => api.citationSources(Number(opts.days)).then(out).catch(fail));
90
+
91
+ program
92
+ .command("sentiment")
93
+ .description("How AI answers frame your brand when cited: positive/neutral/negative with recent receipts")
94
+ .option("--days <n>", "Window in days, 7-90", "30")
95
+ .action((opts) => api.citationSentiment(Number(opts.days)).then(out).catch(fail));
96
+
97
+ program
98
+ .command("optimizer")
99
+ .description("Published pages ranked by citability score, worst first, with their open fixes")
100
+ .option("--limit <n>", "Max rows", "25")
101
+ .action((opts) => api.optimizer(Number(opts.limit)).then(out).catch(fail));
102
+
59
103
  program
60
104
  .command("citations")
61
105
  .description("Recent AI citation checks")
@@ -68,11 +112,115 @@ export function runCli(argv) {
68
112
  .catch(fail)
69
113
  );
70
114
 
115
+ program
116
+ .command("queries")
117
+ .description("List the tracked queries checked weekly across the AI engines")
118
+ .action(() => api.trackedQueries().then(out).catch(fail));
119
+
120
+ program
121
+ .command("crawler-access")
122
+ .description("Daily AI-crawler reachability probe: is the site's edge or robots.txt blocking GPTBot/ClaudeBot")
123
+ .action(() => api.crawlerAccess().then(out).catch(fail));
124
+
125
+ program
126
+ .command("query-add")
127
+ .description("Add a query to the tracking pool (tracks immediately when a plan slot is free)")
128
+ .argument("<queryText>", "The search/AI query to track")
129
+ .action((queryText) => api.addQuery(queryText).then(out).catch(fail));
130
+
71
131
  program
72
132
  .command("capacity")
73
133
  .description("Remaining plan slots on the content calendar")
74
134
  .action(() => api.planningCapacity().then(out).catch(fail));
75
135
 
136
+ program
137
+ .command("team")
138
+ .description("Workspace members and pending invites (owner surface)")
139
+ .action(() => api.team().then(out).catch(fail));
140
+
141
+ program
142
+ .command("team-invite")
143
+ .description("Invite a member (dry-run by default; --confirm sends the email)")
144
+ .argument("<email>", "Invitee email")
145
+ .option(
146
+ "--perms <area=level,...>",
147
+ "Per-screen levels, e.g. content=write,analytics=read (unspecified screens default to none)"
148
+ )
149
+ .option("--confirm", "Actually send the invite")
150
+ .action((email, opts) => {
151
+ const screenPermissions = {};
152
+ for (const pair of (opts.perms ?? "").split(",").filter(Boolean)) {
153
+ const [area, level] = pair.split("=");
154
+ screenPermissions[area?.trim()] = level?.trim();
155
+ }
156
+ return api
157
+ .teamInvite({
158
+ email,
159
+ ...(Object.keys(screenPermissions).length ? { screenPermissions } : {}),
160
+ confirm: !!opts.confirm,
161
+ })
162
+ .then(out)
163
+ .catch(fail);
164
+ });
165
+
166
+ program
167
+ .command("team-revoke")
168
+ .description("Revoke a pending invite")
169
+ .argument("<invitationId>", "Invitation id from `team`")
170
+ .action((invitationId) => api.teamRevoke(invitationId).then(out).catch(fail));
171
+
172
+ program
173
+ .command("team-remove")
174
+ .description("Remove a member (requires --confirm)")
175
+ .argument("<userId>", "Member user id from `team`")
176
+ .option("--confirm", "Actually remove the member")
177
+ .action((userId, opts) =>
178
+ api
179
+ .teamRemove({ userId, confirm: !!opts.confirm })
180
+ .then(out)
181
+ .catch(fail)
182
+ );
183
+
184
+ program
185
+ .command("outreach-prospects")
186
+ .description("Link outreach pipeline with any found contact emails")
187
+ .action(() => api.outreachProspects().then(out).catch(fail));
188
+
189
+ program
190
+ .command("outreach-find-contact")
191
+ .description("Find an outreach email for a prospect (scrapes their site)")
192
+ .argument("<backlinkId>", "Prospect id from `outreach-prospects`")
193
+ .action((backlinkId) =>
194
+ api.outreachFindContact(backlinkId).then(out).catch(fail)
195
+ );
196
+
197
+ program
198
+ .command("outreach-draft-reply")
199
+ .description("AI-draft a reply to a prospect's inbound response (nothing sends)")
200
+ .argument("<backlinkId>", "Prospect id from `outreach-prospects`")
201
+ .action((backlinkId) =>
202
+ api.outreachDraftReply(backlinkId).then(out).catch(fail)
203
+ );
204
+
205
+ program
206
+ .command("outreach-queue")
207
+ .description("Queue an outreach email to send from the connected mailbox (dry-run by default)")
208
+ .argument("<backlinkId>", "Prospect id from `outreach-prospects`")
209
+ .option("--subject <subject>", "Override the drafted subject")
210
+ .option("--body <body>", "Override the drafted body")
211
+ .option("--confirm", "Actually queue the send")
212
+ .action((backlinkId, opts) =>
213
+ api
214
+ .outreachQueue({
215
+ backlinkId,
216
+ ...(opts.subject ? { subject: opts.subject } : {}),
217
+ ...(opts.body ? { body: opts.body } : {}),
218
+ confirm: !!opts.confirm,
219
+ })
220
+ .then(out)
221
+ .catch(fail)
222
+ );
223
+
76
224
  program
77
225
  .command("jobs")
78
226
  .description("Recent agent runs (async job status)")
@@ -131,5 +279,342 @@ export function runCli(argv) {
131
279
  api.publishContent(contentId, !!opts.confirm).then(out).catch(fail)
132
280
  );
133
281
 
282
+ program
283
+ .command("generate <contentId>")
284
+ .description("Write a planned article's body now, keeping its publish slot (past-due or ≤3 days ahead; dry run unless --confirm)")
285
+ .option("--confirm", "Actually start generation (spends LLM budget)")
286
+ .action((contentId, opts) =>
287
+ api.generateContent(contentId, !!opts.confirm).then(out).catch(fail)
288
+ );
289
+
290
+ program
291
+ .command("settings")
292
+ .description("Show the article policy: per-article defaults + flexible scheduling")
293
+ .action(() => api.articleSettings().then(out).catch(fail));
294
+
295
+ const onOff = (val) => {
296
+ if (val === "on" || val === "true") return true;
297
+ if (val === "off" || val === "false") return false;
298
+ throw new Error(`Expected on|off, got "${val}"`);
299
+ };
300
+
301
+ program
302
+ .command("settings-set")
303
+ .description("Merge-patch the article policy (only flags you pass change)")
304
+ .option("--auto-publish <on|off>")
305
+ .option("--auto-generate <on|off>", "Off = fully manual: write articles only on demand (generate command / Write now)")
306
+ .option("--images <on|off>", "AI hero + section images")
307
+ .option("--title-in-hero <on|off>", "Write the post title into the hero image")
308
+ .option("--section-infographics <on|off>", "Informational panels in 2,000+ word articles")
309
+ .option("--related-reading <on|off>", "Related Reading link block at the end")
310
+ .option("--youtube <on|off>", "Embedded video suggestions")
311
+ .option("--emojis <on|off>")
312
+ .option("--internal-links <n>", "Internal links per article (1-20)")
313
+ .option("--external-links <n>", "Outbound authority links per article (0-15)")
314
+ .option("--instructions <text>", "Global editorial instructions")
315
+ .option("--flexible-schedule <on|off>", "1-5 articles/day + calendar drag-and-drop")
316
+ .option(
317
+ "--image-style <set>",
318
+ "Image style set for all generated images: classic-editorial | print-craft | storybook-painterly | modern-saas | dark-premium | bold-poster"
319
+ )
320
+ .action((opts) => {
321
+ try {
322
+ const s = {};
323
+ if (opts.autoPublish !== undefined) s.autoPublish = onOff(opts.autoPublish);
324
+ if (opts.autoGenerate !== undefined) s.autoGenerate = onOff(opts.autoGenerate);
325
+ if (opts.images !== undefined) s.includeInfographics = onOff(opts.images);
326
+ if (opts.titleInHero !== undefined) s.titleInHeroImage = onOff(opts.titleInHero);
327
+ if (opts.sectionInfographics !== undefined)
328
+ s.includeSectionInfographics = onOff(opts.sectionInfographics);
329
+ if (opts.relatedReading !== undefined)
330
+ s.includeRelatedReading = onOff(opts.relatedReading);
331
+ if (opts.youtube !== undefined) s.includeYouTube = onOff(opts.youtube);
332
+ if (opts.emojis !== undefined) s.useEmojis = onOff(opts.emojis);
333
+ if (opts.internalLinks !== undefined)
334
+ s.internalLinksPerArticle = Number(opts.internalLinks);
335
+ if (opts.externalLinks !== undefined)
336
+ s.externalLinksPerArticle = Number(opts.externalLinks);
337
+ if (opts.instructions !== undefined) s.globalInstructions = opts.instructions;
338
+ const body = {};
339
+ if (Object.keys(s).length > 0) body.articleSettings = s;
340
+ if (opts.flexibleSchedule !== undefined)
341
+ body.flexibleScheduling = onOff(opts.flexibleSchedule);
342
+ if (opts.imageStyle !== undefined) body.styleSet = opts.imageStyle;
343
+ if (Object.keys(body).length === 0)
344
+ throw new Error("Pass at least one flag to change (see --help)");
345
+ return api.updateArticleSettings(body).then(out).catch(fail);
346
+ } catch (err) {
347
+ fail(err);
348
+ }
349
+ });
350
+
351
+ program
352
+ .command("reschedule <contentId> <date>")
353
+ .description("Move a planned article to a day (YYYY-MM-DD, local time; needs the flexible schedule)")
354
+ .action((contentId, date) => {
355
+ const day = new Date(`${date}T00:00:00`);
356
+ if (Number.isNaN(day.getTime())) return fail(new Error("Date must be YYYY-MM-DD"));
357
+ return api.reschedule(contentId, day.getTime()).then(out).catch(fail);
358
+ });
359
+
360
+ program
361
+ .command("internal-links <contentId>")
362
+ .description("Inbound and outbound internal links recorded for an article (from the publish-time link graph)")
363
+ .action((contentId) => api.internalLinks(contentId).then(out).catch(fail));
364
+
365
+ program
366
+ .command("site-pages")
367
+ .description("List the site pages used for in-article links and Related Reading")
368
+ .action(() => api.sitePages().then(out).catch(fail));
369
+
370
+ program
371
+ .command("detect-links <url>")
372
+ .description("Scan a sitemap (nested indexes too) or a blog root page for site pages")
373
+ .option("--blog-root", "Treat the URL as a page to crawl instead of a sitemap")
374
+ .action((url, opts) =>
375
+ api
376
+ .detectSiteLinks(opts.blogRoot ? "blogroot" : "sitemap", url)
377
+ .then(out)
378
+ .catch(fail)
379
+ );
380
+
381
+ program
382
+ .command("add-pages <urls...>")
383
+ .description("Add site page URLs for internal linking (dedupes; titles auto-fill)")
384
+ .action((urls) => api.addSitePages(urls).then(out).catch(fail));
385
+
386
+ program
387
+ .command("repurpose [contentId]")
388
+ .description("Repurpose queue (published articles + draft chips), or full drafts for one article")
389
+ .action((contentId) =>
390
+ (contentId ? api.repurposeDrafts(contentId) : api.repurposeQueue())
391
+ .then(out)
392
+ .catch(fail)
393
+ );
394
+
395
+ program
396
+ .command("repurpose-generate <contentId>")
397
+ .description("Draft social posts for a published article (dry run unless --confirm; spends LLM budget)")
398
+ .option(
399
+ "--platforms <a,b>",
400
+ "Subset: linkedin,twitter,pinterest,instagram,facebook,threads,youtube,tiktok"
401
+ )
402
+ .option("--confirm", "Actually generate")
403
+ .action((contentId, opts) =>
404
+ api
405
+ .repurposeGenerate({
406
+ contentId,
407
+ platforms: opts.platforms ? list(opts.platforms) : undefined,
408
+ confirm: !!opts.confirm,
409
+ })
410
+ .then(out)
411
+ .catch(fail)
412
+ );
413
+
414
+ program
415
+ .command("repurpose-edit <draftId>")
416
+ .description("Edit a repurpose draft's text/title before pushing")
417
+ .option("--body <text>")
418
+ .option("--title <text>")
419
+ .action((draftId, opts) =>
420
+ api
421
+ .repurposeEditDraft({ draftId, body: opts.body, title: opts.title })
422
+ .then(out)
423
+ .catch(fail)
424
+ );
425
+
426
+ program
427
+ .command("repurpose-mark-posted <draftId>")
428
+ .description("Mark a draft as posted (you published it yourself)")
429
+ .action((draftId) => api.repurposeMarkPosted(draftId).then(out).catch(fail));
430
+
431
+ program
432
+ .command("repurpose-channels")
433
+ .description("Connected Postiz channels (ids needed for repurpose-push)")
434
+ .action(() => api.repurposeChannels().then(out).catch(fail));
435
+
436
+ program
437
+ .command("repurpose-push <draftId>")
438
+ .description("Send a draft to Postiz (dry run unless --confirm)")
439
+ .option("--channels <id1,id2>", "Postiz channel ids (see repurpose-channels)")
440
+ .option("--when <now|schedule|draft>", "Postiz mode", "now")
441
+ .option("--date <iso>", "ISO time for --when schedule")
442
+ .option("--confirm", "Actually push (publicly visible)")
443
+ .action((draftId, opts) =>
444
+ api
445
+ .repurposePush({
446
+ draftId,
447
+ integrationIds: opts.channels ? list(opts.channels) : undefined,
448
+ scheduleType: opts.when,
449
+ date: opts.date,
450
+ confirm: !!opts.confirm,
451
+ })
452
+ .then(out)
453
+ .catch(fail)
454
+ );
455
+
456
+ const parseJson = (raw, label) => {
457
+ try {
458
+ return JSON.parse(raw.startsWith("@") ? readFileSync(raw.slice(1), "utf8") : raw);
459
+ } catch {
460
+ fail(new Error(`--json for ${label} must be valid JSON (or @file.json)`));
461
+ }
462
+ };
463
+
464
+ program
465
+ .command("ideas")
466
+ .description("Scored content-idea backlog: uncovered queries, citation gaps, quick wins")
467
+ .action(() => api.contentIdeas().then(out).catch(fail));
468
+
469
+ program
470
+ .command("plan-idea <queryText>")
471
+ .description("Put an idea on the content calendar (uses its stored title, or generates one)")
472
+ .option("--title <title>", "Exact headline to plan")
473
+ .action((queryText, opts) =>
474
+ api.planIdea({ queryText, title: opts.title }).then(out).catch(fail)
475
+ );
476
+
477
+ program
478
+ .command("archive <contentId>")
479
+ .description("Archive an article (removes it from the working set)")
480
+ .action((contentId) => api.archiveContent(contentId).then(out).catch(fail));
481
+
482
+ program
483
+ .command("engagement")
484
+ .description("Per-page views + citations for the last 30 days, with view sparklines")
485
+ .action(() => api.pageEngagement().then(out).catch(fail));
486
+
487
+ program
488
+ .command("report")
489
+ .description("Executive summary: last 30 days vs the 30 before (citations, views, crawls, publishing)")
490
+ .action(() => api.reportSummary().then(out).catch(fail));
491
+
492
+ program
493
+ .command("wins")
494
+ .description("Biggest wins of the last 30 days: top cited page, cite-rate jump, ranking climb, best backlink")
495
+ .action(() => api.reportWins().then(out).catch(fail));
496
+
497
+ program
498
+ .command("agent-activity")
499
+ .description("Recent runs per agent lane (brand control, radar, forge, deploy, ...)")
500
+ .option("--per-agent <n>", "Runs per lane, up to 50", "15")
501
+ .option("--since-days <n>", "Window in days", "30")
502
+ .action((opts) =>
503
+ api
504
+ .agentActivity({ perAgent: Number(opts.perAgent), sinceDays: Number(opts.sinceDays) })
505
+ .then(out)
506
+ .catch(fail)
507
+ );
508
+
509
+ program
510
+ .command("backlinks")
511
+ .description("The backlink table, newest first")
512
+ .option("--status <status>", "Filter: discovered, verified, lost, identified, contacted, replied, link_placed, rejected")
513
+ .action((opts) => api.backlinks(opts.status).then(out).catch(fail));
514
+
515
+ program
516
+ .command("backlink-stats")
517
+ .description("Backlink totals + the outreach pipeline counts")
518
+ .action(() => api.backlinkStats().then(out).catch(fail));
519
+
520
+ program
521
+ .command("outreach-status <backlinkId> <status>")
522
+ .description("Move an outreach prospect through the pipeline (identified|contacted|replied|link_placed|rejected)")
523
+ .action((backlinkId, status) =>
524
+ api.outreachStatus(backlinkId, status).then(out).catch(fail)
525
+ );
526
+
527
+ program
528
+ .command("network")
529
+ .description("Link Network credit stats, membership state, and hosted/received placements")
530
+ .action(() => api.linkNetwork().then(out).catch(fail));
531
+
532
+ program
533
+ .command("network-opt-in <on|off>")
534
+ .description("Join or leave the Link Network (dry run unless --confirm; leaving retires live links)")
535
+ .option("--confirm", "Actually change membership")
536
+ .action((state, opts) =>
537
+ api.linkNetworkOptIn(state === "on", !!opts.confirm).then(out).catch(fail)
538
+ );
539
+
540
+ program
541
+ .command("network-remove-placement <placementId>")
542
+ .description("Retire one network placement (dry run unless --confirm; visible on the partner site)")
543
+ .option("--confirm", "Actually remove it")
544
+ .action((placementId, opts) =>
545
+ api.linkNetworkRemovePlacement(placementId, !!opts.confirm).then(out).catch(fail)
546
+ );
547
+
548
+ program
549
+ .command("social")
550
+ .description("Social thread prospects (Reddit/X) with rules context and intent sort")
551
+ .option("--platform <reddit|twitter>")
552
+ .option("--status <status>")
553
+ .option("--age <ranked|new>")
554
+ .action((opts) =>
555
+ api
556
+ .socialThreads({ platform: opts.platform, status: opts.status, age: opts.age })
557
+ .then(out)
558
+ .catch(fail)
559
+ );
560
+
561
+ program
562
+ .command("social-stats")
563
+ .description("Social thread pipeline counts")
564
+ .action(() => api.socialStats().then(out).catch(fail));
565
+
566
+ program
567
+ .command("social-status <threadId> <status>")
568
+ .description("Move a social thread through the pipeline")
569
+ .action((threadId, status) =>
570
+ api.socialStatus(threadId, status).then(out).catch(fail)
571
+ );
572
+
573
+ program
574
+ .command("social-draft-reply <threadId>")
575
+ .description("AI-draft a reply for a thread (nothing posts; the draft lands for review)")
576
+ .option("--mention <none|natural|founderOpen>", "Brand-mention mode")
577
+ .action((threadId, opts) =>
578
+ api.socialDraftReply(threadId, opts.mention).then(out).catch(fail)
579
+ );
580
+
581
+ program
582
+ .command("support <subject>")
583
+ .description("Send a message or bug report to RankControl support (emails the team, capped 3/min)")
584
+ .requiredOption("--message <text>", "What happened — include errors, ids, and steps")
585
+ .option("--page <url>", "Related dashboard or API URL")
586
+ .action((subject, opts) =>
587
+ api.support({ subject, message: opts.message, pageUrl: opts.page }).then(out).catch(fail)
588
+ );
589
+
590
+ program
591
+ .command("brand")
592
+ .description("Brand profile, products, and buyer profiles (ICPs) in one read")
593
+ .action(() => api.brand().then(out).catch(fail));
594
+
595
+ program
596
+ .command("brand-profile-set")
597
+ .description("Patch brand voice/style fields. --json '{\"tone\":\"...\"}' or --json @file.json")
598
+ .requiredOption("--json <json>", "Fields: tone, primaryColor, secondaryColor, fontFamily, metaTitle, metaDescription, metaKeywords, defaultLocale, flexibleScheduling")
599
+ .action((opts) =>
600
+ api.brandProfileSet(parseJson(opts.json, "brand-profile-set")).then(out).catch(fail)
601
+ );
602
+
603
+ program
604
+ .command("brand-product")
605
+ .description("Create, update, or delete a product. Create: name+description+category; update: productId + fields; delete: productId + del:true")
606
+ .requiredOption("--json <json>", "Product JSON (or @file.json)")
607
+ .action((opts) =>
608
+ api.brandProductWrite(parseJson(opts.json, "brand-product")).then(out).catch(fail)
609
+ );
610
+
611
+ program
612
+ .command("brand-icp")
613
+ .description("Create, update, or delete a buyer profile. Create: title+industry+demographics; update: profileId + fields; delete: profileId + del:true")
614
+ .requiredOption("--json <json>", "Buyer profile JSON (or @file.json)")
615
+ .action((opts) =>
616
+ api.brandIcpWrite(parseJson(opts.json, "brand-icp")).then(out).catch(fail)
617
+ );
618
+
134
619
  program.parseAsync(argv);
135
620
  }
package/src/client.mjs CHANGED
@@ -87,6 +87,14 @@ export const api = {
87
87
  overviewFunnel: () => request("GET", "/api/v1/overview/funnel"),
88
88
  visibilityTrend: (days = 30) =>
89
89
  request("GET", `/api/v1/visibility/trend?days=${days}`),
90
+ shareOfVoice: (days = 30) =>
91
+ request("GET", `/api/v1/visibility/share-of-voice?days=${days}`),
92
+ citationSources: (days = 30) =>
93
+ request("GET", `/api/v1/visibility/sources?days=${days}`),
94
+ citationSentiment: (days = 30) =>
95
+ request("GET", `/api/v1/visibility/sentiment?days=${days}`),
96
+ optimizer: (limit = 25) =>
97
+ request("GET", `/api/v1/content/optimizer?limit=${limit}`),
90
98
  citations: (params = {}) => {
91
99
  const q = new URLSearchParams();
92
100
  if (params.model) q.set("model", params.model);
@@ -103,4 +111,98 @@ export const api = {
103
111
  request("POST", "/api/v1/content/plan/commit", opts),
104
112
  publishContent: (contentId, confirm = false) =>
105
113
  request("POST", "/api/v1/content/publish", { contentId, confirm }),
114
+ generateContent: (contentId, confirm = false) =>
115
+ request("POST", "/api/v1/content/generate", { contentId, confirm }),
116
+ internalLinks: (contentId) =>
117
+ request(
118
+ "GET",
119
+ `/api/v1/content/internal-links?contentId=${encodeURIComponent(contentId)}`
120
+ ),
121
+ articleSettings: () => request("GET", "/api/v1/settings/articles"),
122
+ updateArticleSettings: (opts) =>
123
+ request("POST", "/api/v1/settings/articles", opts),
124
+ reschedule: (contentId, targetDayStartMs) =>
125
+ request("POST", "/api/v1/content/reschedule", { contentId, targetDayStartMs }),
126
+ sitePages: () => request("GET", "/api/v1/links/pages"),
127
+ detectSiteLinks: (source, url) =>
128
+ request("POST", "/api/v1/links/detect", { source, url }),
129
+ addSitePages: (urls) => request("POST", "/api/v1/links/pages", { urls }),
130
+ leads: (limit = 100) => request("GET", `/api/v1/leads?limit=${limit}`),
131
+ repurposeQueue: () => request("GET", "/api/v1/repurpose"),
132
+ repurposeDrafts: (contentId) =>
133
+ request(
134
+ "GET",
135
+ `/api/v1/repurpose?contentId=${encodeURIComponent(contentId)}`
136
+ ),
137
+ repurposeChannels: () => request("GET", "/api/v1/repurpose/channels"),
138
+ repurposeGenerate: (opts) =>
139
+ request("POST", "/api/v1/repurpose/generate", opts),
140
+ repurposeEditDraft: (opts) => request("POST", "/api/v1/repurpose/draft", opts),
141
+ repurposeMarkPosted: (draftId) =>
142
+ request("POST", "/api/v1/repurpose/mark-posted", { draftId }),
143
+ repurposePush: (opts) => request("POST", "/api/v1/repurpose/push", opts),
144
+ trackedQueries: () => request("GET", "/api/v1/queries"),
145
+ addQuery: (queryText) => request("POST", "/api/v1/queries", { queryText }),
146
+ crawlerAccess: () => request("GET", "/api/v1/crawler-access"),
147
+ trafficOverview: (days = 30) =>
148
+ request("GET", `/api/v1/analytics/traffic?days=${days}`),
149
+ team: () => request("GET", "/api/v1/team"),
150
+ teamInvite: (opts) => request("POST", "/api/v1/team/invite", opts),
151
+ teamRevoke: (invitationId) =>
152
+ request("POST", "/api/v1/team/revoke", { invitationId }),
153
+ teamRemove: (opts) => request("POST", "/api/v1/team/remove", opts),
154
+ outreachProspects: () => request("GET", "/api/v1/outreach/prospects"),
155
+ outreachFindContact: (backlinkId) =>
156
+ request("POST", "/api/v1/outreach/find-contact", { backlinkId }),
157
+ outreachQueue: (opts) => request("POST", "/api/v1/outreach/queue", opts),
158
+ outreachDraftReply: (backlinkId) =>
159
+ request("POST", "/api/v1/outreach/draft-reply", { backlinkId }),
160
+ contentIdeas: () => request("GET", "/api/v1/content/ideas"),
161
+ planIdea: (opts) => request("POST", "/api/v1/content/ideas/plan", opts),
162
+ archiveContent: (contentId) =>
163
+ request("POST", "/api/v1/content/archive", { contentId }),
164
+ pageEngagement: () => request("GET", "/api/v1/analytics/engagement"),
165
+ reportSummary: () => request("GET", "/api/v1/reports/summary"),
166
+ reportWins: () => request("GET", "/api/v1/reports/wins"),
167
+ agentActivity: (params = {}) => {
168
+ const q = new URLSearchParams();
169
+ if (params.perAgent) q.set("perAgent", String(params.perAgent));
170
+ if (params.sinceDays) q.set("sinceDays", String(params.sinceDays));
171
+ const qs = q.toString();
172
+ return request("GET", `/api/v1/reports/agent-activity${qs ? `?${qs}` : ""}`);
173
+ },
174
+ backlinks: (status) =>
175
+ request(
176
+ "GET",
177
+ `/api/v1/backlinks${status ? `?status=${encodeURIComponent(status)}` : ""}`
178
+ ),
179
+ backlinkStats: () => request("GET", "/api/v1/backlinks/stats"),
180
+ outreachStatus: (backlinkId, status) =>
181
+ request("POST", "/api/v1/outreach/status", { backlinkId, status }),
182
+ linkNetwork: () => request("GET", "/api/v1/link-network"),
183
+ linkNetworkOptIn: (optIn, confirm = false) =>
184
+ request("POST", "/api/v1/link-network/opt-in", { optIn, confirm }),
185
+ linkNetworkRemovePlacement: (placementId, confirm = false) =>
186
+ request("POST", "/api/v1/link-network/remove-placement", {
187
+ placementId,
188
+ confirm,
189
+ }),
190
+ socialThreads: (params = {}) => {
191
+ const q = new URLSearchParams();
192
+ if (params.platform) q.set("platform", params.platform);
193
+ if (params.status) q.set("status", params.status);
194
+ if (params.age) q.set("age", params.age);
195
+ const qs = q.toString();
196
+ return request("GET", `/api/v1/social${qs ? `?${qs}` : ""}`);
197
+ },
198
+ socialStats: () => request("GET", "/api/v1/social/stats"),
199
+ socialStatus: (threadId, status) =>
200
+ request("POST", "/api/v1/social/status", { threadId, status }),
201
+ socialDraftReply: (threadId, mentionMode) =>
202
+ request("POST", "/api/v1/social/draft-reply", { threadId, mentionMode }),
203
+ support: (opts) => request("POST", "/api/v1/support", opts),
204
+ brand: () => request("GET", "/api/v1/brand"),
205
+ brandProfileSet: (opts) => request("POST", "/api/v1/brand/profile", opts),
206
+ brandProductWrite: (opts) => request("POST", "/api/v1/brand/product", opts),
207
+ brandIcpWrite: (opts) => request("POST", "/api/v1/brand/icp", opts),
106
208
  };
package/src/mcp.mjs CHANGED
@@ -29,7 +29,7 @@ const plannedTitle = z.object({
29
29
  });
30
30
 
31
31
  export async function startMcpServer() {
32
- const server = new McpServer({ name: "rankcontrol", version: "0.2.0" });
32
+ const server = new McpServer({ name: "rankcontrol", version: "0.6.0" });
33
33
 
34
34
  server.tool(
35
35
  "get_overview_funnel",
@@ -57,11 +57,74 @@ export async function startMcpServer() {
57
57
 
58
58
  server.tool(
59
59
  "get_visibility_score",
60
- "Current overall AI visibility score for the workspace.",
60
+ "Composite visibility score for the workspace: 50% AI citation rate + 30% Google + 20% Bing position-weighted top-10 rank share (top 3 = full credit, 4-10 = half). Returns the composite, weights, and per-pillar subscores (the AI pillar includes a per-model breakdown).",
61
61
  {},
62
62
  run(() => api.visibilityScore())
63
63
  );
64
64
 
65
+ server.tool(
66
+ "get_citability_report",
67
+ "Published pages ranked by citability score (how extractable each page is for AI answers), worst first, each with its top open fixes. Use to decide which articles to improve.",
68
+ { limit: z.number().max(100).optional().describe("Max rows, default 25") },
69
+ run(({ limit }) => api.optimizer(limit ?? 25))
70
+ );
71
+
72
+ server.tool(
73
+ "get_citation_sentiment",
74
+ "How AI answers frame the brand when it's cited: positive / neutral / negative counts, daily trend, and recent answer snippets as receipts.",
75
+ { days: z.number().min(7).max(90).optional().describe("Window in days, default 30") },
76
+ run(({ days }) => api.citationSentiment(days ?? 30))
77
+ );
78
+
79
+ server.tool(
80
+ "get_citation_sources",
81
+ "Which domains AI engines cite when answering the brand's tracked queries, typed brand / competitive / UGC / editorial with counts and share. Shows where citation presence must be earned.",
82
+ { days: z.number().min(7).max(90).optional().describe("Window in days, default 30") },
83
+ run(({ days }) => api.citationSources(days ?? 30))
84
+ );
85
+
86
+ server.tool(
87
+ "get_share_of_voice",
88
+ "Share of voice vs competitors: the brand's citation rate, top competitor mention rates, and the brand's share of all brand appearances in AI answers over the window.",
89
+ { days: z.number().min(7).max(90).optional().describe("Window in days, default 30") },
90
+ run(({ days }) => api.shareOfVoice(days ?? 30))
91
+ );
92
+
93
+ server.tool(
94
+ "list_leads",
95
+ "Captured leads with source attribution: email, name, company, status, lead score, and the AI model / query / page that produced each lead.",
96
+ { limit: z.number().max(500).optional().describe("Max rows, default 100") },
97
+ run(({ limit }) => api.leads(limit ?? 100))
98
+ );
99
+
100
+ server.tool(
101
+ "list_tracked_queries",
102
+ "The workspace's tracked queries — the questions RankControl checks weekly across the 6 AI engines for brand citations — with their status and metadata.",
103
+ {},
104
+ run(() => api.trackedQueries())
105
+ );
106
+
107
+ server.tool(
108
+ "get_crawler_access",
109
+ "Daily AI-crawler reachability probe result: whether the site's edge (e.g. Cloudflare Bot Fight Mode) or robots.txt blocks GPTBot/ClaudeBot — a blocked site cannot be read or cited by AI engines.",
110
+ {},
111
+ run(() => api.crawlerAccess())
112
+ );
113
+
114
+ server.tool(
115
+ "add_tracked_query",
116
+ "Add a query to the tracking pool. It starts weekly AI citation checks immediately when a plan slot is free; otherwise it joins the pool untracked.",
117
+ { queryText: z.string().describe("The search/AI query to track") },
118
+ run(({ queryText }) => api.addQuery(queryText))
119
+ );
120
+
121
+ server.tool(
122
+ "get_traffic_overview",
123
+ "Traffic overview from RankControl's own page analytics: total page views, unique visitors, sessions, bounce rate, and average time on page.",
124
+ { days: z.enum(["7", "30", "90"]).optional().describe("Window in days, default 30") },
125
+ run(({ days }) => api.trafficOverview(days ? Number(days) : 30))
126
+ );
127
+
65
128
  server.tool(
66
129
  "get_planning_capacity",
67
130
  "How many article titles can still be planned onto the content calendar within the scheduling horizon. Check before plan_content.",
@@ -69,6 +132,88 @@ export async function startMcpServer() {
69
132
  run(() => api.planningCapacity())
70
133
  );
71
134
 
135
+ const permLevel = z.enum(["none", "read", "write"]);
136
+ server.tool(
137
+ "get_team",
138
+ "Workspace members and pending invites, with each member's per-screen permission matrix and the seat cap.",
139
+ {},
140
+ run(() => api.team())
141
+ );
142
+
143
+ server.tool(
144
+ "invite_team_member",
145
+ "Invite a member to the workspace with per-screen none/read/write access (unspecified screens default to none — grant at least one). Dry-run unless confirm is true; confirming sends the invite email.",
146
+ {
147
+ email: z.string().describe("Invitee email address"),
148
+ screenPermissions: z
149
+ .object({
150
+ overview: permLevel.optional(),
151
+ content: permLevel.optional(),
152
+ visibility: permLevel.optional(),
153
+ brandControl: permLevel.optional(),
154
+ linkControl: permLevel.optional(),
155
+ analytics: permLevel.optional(),
156
+ reports: permLevel.optional(),
157
+ })
158
+ .optional(),
159
+ confirm: z
160
+ .boolean()
161
+ .optional()
162
+ .describe("Set true to actually send the invite (default dry-run)"),
163
+ },
164
+ run((args) => api.teamInvite(args))
165
+ );
166
+
167
+ server.tool(
168
+ "revoke_team_invite",
169
+ "Revoke a pending team invite by invitation id (from get_team).",
170
+ { invitationId: z.string() },
171
+ run(({ invitationId }) => api.teamRevoke(invitationId))
172
+ );
173
+
174
+ server.tool(
175
+ "remove_team_member",
176
+ "Remove a member from the workspace (access revoked immediately). Dry-run unless confirm is true.",
177
+ {
178
+ userId: z.string().describe("Member user id from get_team"),
179
+ confirm: z.boolean().optional(),
180
+ },
181
+ run((args) => api.teamRemove(args))
182
+ );
183
+
184
+ server.tool(
185
+ "list_outreach_prospects",
186
+ "Link outreach pipeline: prospect sites per published article with status (identified → contacted → replied → link placed) and any contact email already found.",
187
+ {},
188
+ run(() => api.outreachProspects())
189
+ );
190
+
191
+ server.tool(
192
+ "find_outreach_contact",
193
+ "Find an outreach email for one prospect by scraping their site (mailto links, contact page, obfuscated text; Prospeo fallback when configured). Returns the stored contact or found:false. Capped at 5/min.",
194
+ { backlinkId: z.string().describe("Prospect id from list_outreach_prospects") },
195
+ run(({ backlinkId }) => api.outreachFindContact(backlinkId))
196
+ );
197
+
198
+ server.tool(
199
+ "draft_outreach_reply",
200
+ "AI-draft the next reply in an outreach conversation where the prospect has responded (replySnippet present on the prospect). Returns subject/body only — nothing sends; follow with queue_outreach_email to send it.",
201
+ { backlinkId: z.string().describe("Prospect id from list_outreach_prospects") },
202
+ run(({ backlinkId }) => api.outreachDraftReply(backlinkId))
203
+ );
204
+
205
+ server.tool(
206
+ "queue_outreach_email",
207
+ "Queue an outreach email to a prospect. It sends from the workspace's own connected mailbox, paced ~1 per 10 minutes up to the daily cap. Uses the stored draft unless subject/body are given. Dry-run unless confirm is true — a confirmed queue leads to a REAL email being sent.",
208
+ {
209
+ backlinkId: z.string().describe("Prospect id from list_outreach_prospects"),
210
+ subject: z.string().optional().describe("Override the drafted subject"),
211
+ body: z.string().optional().describe("Override the drafted body"),
212
+ confirm: z.boolean().optional().describe("Set true to actually queue the send"),
213
+ },
214
+ run((args) => api.outreachQueue({ ...args, confirm: args.confirm === true }))
215
+ );
216
+
72
217
  server.tool(
73
218
  "list_jobs",
74
219
  "Recent agent runs (job status feed). Async actions like content planning show up here with their outputs.",
@@ -117,6 +262,404 @@ export async function startMcpServer() {
117
262
  run(({ contentId, confirm }) => api.publishContent(contentId, confirm === true))
118
263
  );
119
264
 
265
+ server.tool(
266
+ "generate_article",
267
+ "Write a PLANNED article's body immediately instead of waiting for the scheduled pipeline (generate-only: its publish slot is unchanged). Allowed for past-due slots and up to 3 days ahead. Defaults to a DRY RUN; a human should approve before calling again with confirm=true since generation spends LLM budget.",
268
+ {
269
+ contentId: z.string().describe("The planned content page id"),
270
+ confirm: z.boolean().optional().describe("Set true to actually start generation (default: dry run)"),
271
+ },
272
+ run(({ contentId, confirm }) => api.generateContent(contentId, confirm === true))
273
+ );
274
+
275
+ server.tool(
276
+ "get_article_settings",
277
+ "The workspace article policy: per-article defaults (auto-write, auto-publish, images, title-in-hero, section infographics, Related Reading, YouTube, emojis, link counts, global instructions) plus the flexible-scheduling flag.",
278
+ {},
279
+ run(() => api.articleSettings())
280
+ );
281
+
282
+ const articleSettingsShape = {
283
+ autoPublish: z.boolean().optional(),
284
+ autoGenerate: z.boolean().optional().describe("Off = fully manual mode: articles are written only on demand (generate_article) and publish only from the editor"),
285
+ includeInfographics: z.boolean().optional().describe("AI hero + section images"),
286
+ titleInHeroImage: z.boolean().optional().describe("Write the post title into the hero image"),
287
+ includeSectionInfographics: z.boolean().optional().describe("Informational panels in 2,000+ word articles"),
288
+ includeRelatedReading: z.boolean().optional().describe("Related Reading link block at the end"),
289
+ includeYouTube: z.boolean().optional(),
290
+ useEmojis: z.boolean().optional(),
291
+ internalLinksPerArticle: z.number().min(1).max(20).optional(),
292
+ externalLinksPerArticle: z.number().min(0).max(15).optional(),
293
+ globalInstructions: z.string().optional(),
294
+ };
295
+
296
+ server.tool(
297
+ "update_article_settings",
298
+ "Merge-patch the article policy: only the fields you pass change. Set flexibleScheduling true to allow 1-5 articles/day and manual rescheduling. styleSet picks the image style set applied to all generated images (heroes, infographic panels, social cards).",
299
+ {
300
+ articleSettings: z.object(articleSettingsShape).optional(),
301
+ flexibleScheduling: z.boolean().optional(),
302
+ styleSet: z
303
+ .enum([
304
+ "classic-editorial",
305
+ "print-craft",
306
+ "storybook-painterly",
307
+ "modern-saas",
308
+ "dark-premium",
309
+ "bold-poster",
310
+ ])
311
+ .optional()
312
+ .describe("Image style set for all generated images"),
313
+ },
314
+ run((args) => api.updateArticleSettings(args))
315
+ );
316
+
317
+ server.tool(
318
+ "reschedule_article",
319
+ "Move a PLANNED article to another day (requires the flexible schedule; max 5/day; the exact hour is placed automatically). Pass the target day as epoch ms of local midnight.",
320
+ {
321
+ contentId: z.string(),
322
+ targetDayStartMs: z.number().describe("Epoch ms of the target day's local midnight"),
323
+ },
324
+ run(({ contentId, targetDayStartMs }) => api.reschedule(contentId, targetDayStartMs))
325
+ );
326
+
327
+ server.tool(
328
+ "get_internal_links",
329
+ "How the workspace's articles interlink: how many published articles link TO this page (with the source list) and how many internal links it sends out. Recorded from the real injected links at publish render time.",
330
+ { contentId: z.string().describe("The content page id") },
331
+ run(({ contentId }) => api.internalLinks(contentId))
332
+ );
333
+
334
+ server.tool(
335
+ "list_site_pages",
336
+ "Site pages RankControl links to from inside articles and the Related Reading block (with linkable/alive flags).",
337
+ {},
338
+ run(() => api.sitePages())
339
+ );
340
+
341
+ server.tool(
342
+ "detect_site_links",
343
+ "Scan the customer's sitemap (nested indexes handled) or crawl a blog root page and return same-site page URLs. Costly (3/min); review the URLs, then call add_site_pages with the keepers.",
344
+ {
345
+ source: z.enum(["sitemap", "blogroot"]),
346
+ url: z.string().describe("Sitemap XML URL or a page URL to crawl"),
347
+ },
348
+ run(({ source, url }) => api.detectSiteLinks(source, url))
349
+ );
350
+
351
+ server.tool(
352
+ "add_site_pages",
353
+ "Add site page URLs for internal linking. Duplicates are skipped; page titles fill in automatically in the background.",
354
+ { urls: z.array(z.string()) },
355
+ run(({ urls }) => api.addSitePages(urls))
356
+ );
357
+
358
+ server.tool(
359
+ "list_repurpose_queue",
360
+ "Published articles with their social repurpose drafts per platform (platform + status chips). Shows which articles still need drafts and whether Postiz is connected.",
361
+ {},
362
+ run(() => api.repurposeQueue())
363
+ );
364
+
365
+ server.tool(
366
+ "get_repurpose_drafts",
367
+ "Full social drafts (title, body, images, status) for one published article across the 8 platforms (LinkedIn, X/Twitter, Pinterest, Instagram, Facebook, Threads, YouTube, TikTok).",
368
+ { contentId: z.string().describe("The content page id") },
369
+ run(({ contentId }) => api.repurposeDrafts(contentId))
370
+ );
371
+
372
+ server.tool(
373
+ "generate_repurpose_drafts",
374
+ "Draft platform-native social posts for a published article. Regeneration replaces unposted drafts for the chosen platforms. Defaults to a DRY RUN; confirm=true generates (spends LLM budget).",
375
+ {
376
+ contentId: z.string(),
377
+ platforms: z
378
+ .array(
379
+ z.enum([
380
+ "linkedin",
381
+ "twitter",
382
+ "pinterest",
383
+ "instagram",
384
+ "facebook",
385
+ "threads",
386
+ "youtube",
387
+ "tiktok",
388
+ ])
389
+ )
390
+ .optional()
391
+ .describe("Subset of platforms; omit for the workspace default"),
392
+ confirm: z.boolean().optional(),
393
+ },
394
+ run((args) => api.repurposeGenerate(args))
395
+ );
396
+
397
+ server.tool(
398
+ "update_repurpose_draft",
399
+ "Edit a repurpose draft's body and/or title before pushing or posting.",
400
+ {
401
+ draftId: z.string(),
402
+ body: z.string().optional(),
403
+ title: z.string().optional(),
404
+ },
405
+ run((args) => api.repurposeEditDraft(args))
406
+ );
407
+
408
+ server.tool(
409
+ "mark_repurpose_posted",
410
+ "Mark a repurpose draft as posted — use after publishing its text manually (outside Postiz).",
411
+ { draftId: z.string() },
412
+ run(({ draftId }) => api.repurposeMarkPosted(draftId))
413
+ );
414
+
415
+ server.tool(
416
+ "list_postiz_channels",
417
+ "Connected Postiz social channels (id, name, platform). Channel ids are required by push_repurpose_draft.",
418
+ {},
419
+ run(() => api.repurposeChannels())
420
+ );
421
+
422
+ server.tool(
423
+ "push_repurpose_draft",
424
+ "Send a repurpose draft to connected Postiz channels (post now, schedule, or save as a Postiz draft). PUBLICLY VISIBLE side effect: defaults to a DRY RUN; a human should approve before calling again with confirm=true.",
425
+ {
426
+ draftId: z.string(),
427
+ integrationIds: z
428
+ .array(z.string())
429
+ .optional()
430
+ .describe(
431
+ "Postiz channel ids from list_postiz_channels (required with confirm)"
432
+ ),
433
+ scheduleType: z
434
+ .enum(["now", "schedule", "draft"])
435
+ .optional()
436
+ .describe("Default now"),
437
+ date: z.string().optional().describe("ISO time for scheduleType=schedule"),
438
+ confirm: z.boolean().optional(),
439
+ },
440
+ run((args) => api.repurposePush(args))
441
+ );
442
+
443
+ server.tool(
444
+ "get_content_ideas",
445
+ "Scored content-idea backlog: uncovered tracked queries ranked by citation gaps, competitor citations, trending topics, and quick-win rankings. Each idea carries a suggested title and winnability metrics.",
446
+ {},
447
+ run(() => api.contentIdeas())
448
+ );
449
+
450
+ server.tool(
451
+ "plan_content_idea",
452
+ "Put a content idea on the calendar as a planned article. Pass the idea's queryText (and its suggested title if you have it — otherwise a title is generated).",
453
+ {
454
+ queryText: z.string().describe("The idea's keyword/query text"),
455
+ title: z.string().optional().describe("Exact headline to plan"),
456
+ },
457
+ run((args) => api.planIdea(args))
458
+ );
459
+
460
+ server.tool(
461
+ "archive_content",
462
+ "Archive an article, removing it from the working set. Reversible in the dashboard.",
463
+ { contentId: z.string() },
464
+ run(({ contentId }) => api.archiveContent(contentId))
465
+ );
466
+
467
+ server.tool(
468
+ "get_page_engagement",
469
+ "Per-page engagement for the last 30 days: views, AI citations, and a view sparkline per published page.",
470
+ {},
471
+ run(() => api.pageEngagement())
472
+ );
473
+
474
+ server.tool(
475
+ "get_exec_summary",
476
+ "Executive report: last 30 days vs the 30 before — cite rate, citations, page views, AI-referred views, crawler hits, and articles published.",
477
+ {},
478
+ run(() => api.reportSummary())
479
+ );
480
+
481
+ server.tool(
482
+ "get_top_wins",
483
+ "Biggest wins of the last 30 days: most-cited page, best query cite-rate jump, biggest Google ranking climb, best new backlink.",
484
+ {},
485
+ run(() => api.reportWins())
486
+ );
487
+
488
+ server.tool(
489
+ "get_agent_activity",
490
+ "Recent pipeline runs per agent lane (brand control, radar, forge, recon, deploy, outreach, sentinel, social).",
491
+ {
492
+ perAgent: z.number().max(50).optional().describe("Runs per lane, default 15"),
493
+ sinceDays: z.number().optional().describe("Window in days, default 30"),
494
+ },
495
+ run((args) => api.agentActivity(args))
496
+ );
497
+
498
+ server.tool(
499
+ "list_backlinks",
500
+ "The workspace's backlink table, newest first.",
501
+ {
502
+ status: z
503
+ .string()
504
+ .optional()
505
+ .describe(
506
+ "Filter: discovered, verified, lost, identified, contacted, replied, link_placed, rejected"
507
+ ),
508
+ },
509
+ run(({ status }) => api.backlinks(status))
510
+ );
511
+
512
+ server.tool(
513
+ "get_backlink_stats",
514
+ "Backlink totals (live, lost, avg DA, unique domains) plus outreach pipeline counts.",
515
+ {},
516
+ run(() => api.backlinkStats())
517
+ );
518
+
519
+ server.tool(
520
+ "update_outreach_status",
521
+ "Move an outreach prospect through the pipeline.",
522
+ {
523
+ backlinkId: z.string(),
524
+ status: z.enum(["identified", "contacted", "replied", "link_placed", "rejected"]),
525
+ },
526
+ run(({ backlinkId, status }) => api.outreachStatus(backlinkId, status))
527
+ );
528
+
529
+ server.tool(
530
+ "get_link_network",
531
+ "Link Network state: credit balance/earn/spend, membership (optedIn), and hosted/received placements with partners and anchors.",
532
+ {},
533
+ run(() => api.linkNetwork())
534
+ );
535
+
536
+ server.tool(
537
+ "set_link_network_opt_in",
538
+ "Join or leave the Link Network. SIDE EFFECT on partner sites (leaving retires live links): defaults to a DRY RUN; a human should approve before calling again with confirm=true.",
539
+ { optIn: z.boolean(), confirm: z.boolean().optional() },
540
+ run(({ optIn, confirm }) => api.linkNetworkOptIn(optIn, confirm))
541
+ );
542
+
543
+ server.tool(
544
+ "remove_network_placement",
545
+ "Retire one Link Network placement. VISIBLE on the partner site: defaults to a DRY RUN; call again with confirm=true after human approval.",
546
+ { placementId: z.string(), confirm: z.boolean().optional() },
547
+ run(({ placementId, confirm }) => api.linkNetworkRemovePlacement(placementId, confirm))
548
+ );
549
+
550
+ server.tool(
551
+ "list_social_threads",
552
+ "Social thread prospects (Reddit/X) where the brand's articles could earn a mention, with subreddit rules context, sorted by intent.",
553
+ {
554
+ platform: z.enum(["reddit", "twitter"]).optional(),
555
+ status: z.string().optional(),
556
+ age: z.enum(["ranked", "new"]).optional(),
557
+ },
558
+ run((args) => api.socialThreads(args))
559
+ );
560
+
561
+ server.tool(
562
+ "get_social_stats",
563
+ "Social thread pipeline counts.",
564
+ {},
565
+ run(() => api.socialStats())
566
+ );
567
+
568
+ server.tool(
569
+ "update_social_thread_status",
570
+ "Move a social thread through the pipeline (e.g. dismissed, replied).",
571
+ { threadId: z.string(), status: z.string() },
572
+ run(({ threadId, status }) => api.socialStatus(threadId, status))
573
+ );
574
+
575
+ server.tool(
576
+ "draft_social_reply",
577
+ "AI-draft a reply for a social thread. Nothing posts — the draft lands on the thread for human review. Subject to the same warm-up/pacing guards as the dashboard.",
578
+ {
579
+ threadId: z.string(),
580
+ mentionMode: z
581
+ .enum(["none", "natural", "founderOpen"])
582
+ .optional()
583
+ .describe("Brand-mention mode; warm-up accounts are clamped to none"),
584
+ },
585
+ run(({ threadId, mentionMode }) => api.socialDraftReply(threadId, mentionMode))
586
+ );
587
+
588
+ server.tool(
589
+ "contact_support",
590
+ "Send a message or bug report to the RankControl team (emails support with the workspace identified; the org owner is set as reply-to). Use when an API call errors unexpectedly or data looks wrong — include the failing request, ids, and what you expected. Capped at 3/min.",
591
+ {
592
+ subject: z.string(),
593
+ message: z.string().describe("Details: what happened, errors, ids, steps to reproduce"),
594
+ pageUrl: z.string().optional().describe("Related dashboard or API URL"),
595
+ },
596
+ run((args) => api.support(args))
597
+ );
598
+
599
+ server.tool(
600
+ "get_brand",
601
+ "The workspace's brand data in one read: brand profile (voice, colors, meta), links/CTAs, products, and buyer profiles (ICPs). This is the context RankControl's writers use.",
602
+ {},
603
+ run(() => api.brand())
604
+ );
605
+
606
+ server.tool(
607
+ "update_brand_profile",
608
+ "Patch brand voice/style fields: tone, colors, fontFamily, metaTitle/Description/Keywords, defaultLocale, flexibleScheduling. Only provided fields change; they steer all future article generation.",
609
+ {
610
+ tone: z.string().optional(),
611
+ primaryColor: z.string().optional(),
612
+ secondaryColor: z.string().optional(),
613
+ fontFamily: z.string().optional(),
614
+ metaTitle: z.string().optional(),
615
+ metaDescription: z.string().optional(),
616
+ metaKeywords: z.string().optional(),
617
+ defaultLocale: z.string().optional().describe("BCP-47 tag from the supported registry"),
618
+ flexibleScheduling: z.boolean().optional(),
619
+ },
620
+ run((args) => api.brandProfileSet(args))
621
+ );
622
+
623
+ server.tool(
624
+ "write_brand_product",
625
+ "Create, update, or delete a product in brand memory (feeds article context + entity triples). Create: name+description+category. Update: productId + changed fields. Delete: productId + del=true.",
626
+ {
627
+ productId: z.string().optional(),
628
+ del: z.boolean().optional(),
629
+ name: z.string().optional(),
630
+ description: z.string().optional(),
631
+ url: z.string().optional(),
632
+ features: z.array(z.string()).optional(),
633
+ pricing: z.string().optional(),
634
+ category: z.string().optional(),
635
+ usps: z.array(z.string()).optional(),
636
+ isActive: z.boolean().optional(),
637
+ },
638
+ run((args) => api.brandProductWrite(args))
639
+ );
640
+
641
+ server.tool(
642
+ "write_brand_icp",
643
+ "Create, update, or delete a buyer profile / ICP (feeds article targeting + entity triples). Create: title+industry+demographics. Update: profileId + changed fields. Delete: profileId + del=true.",
644
+ {
645
+ profileId: z.string().optional(),
646
+ del: z.boolean().optional(),
647
+ title: z.string().optional(),
648
+ industry: z.string().optional(),
649
+ companySize: z.string().optional(),
650
+ painPoints: z.array(z.string()).optional(),
651
+ buyingTriggers: z.array(z.string()).optional(),
652
+ objections: z.array(z.string()).optional(),
653
+ preferredChannels: z.array(z.string()).optional(),
654
+ demographics: z
655
+ .record(z.string(), z.unknown())
656
+ .optional()
657
+ .describe("Demographics object; required on create (same shape the dashboard saves)"),
658
+ isActive: z.boolean().optional(),
659
+ },
660
+ run((args) => api.brandIcpWrite(args))
661
+ );
662
+
120
663
  await server.connect(new StdioServerTransport());
121
664
  // Keep the process alive; the transport owns stdin/stdout from here
122
665
  console.error("rankcontrol MCP server running on stdio");