rankcontrol 0.8.3 → 0.13.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
@@ -39,11 +39,12 @@ npx rankcontrol citations --model chatgpt
39
39
  npx rankcontrol crawler-access # can AI crawlers reach your site (daily probe)
40
40
  npx rankcontrol queries # tracked queries checked weekly across AI engines
41
41
  npx rankcontrol query-add "best crm for agencies"
42
+ npx rankcontrol query-add "Do luggage trackers work abroad?" --covered-by <contentId> # an article already answers it
42
43
  npx rankcontrol query-edit <queryId> "new text"
43
44
  npx rankcontrol query-track <queryId> off # pause weekly checks (frees a slot)
44
45
  npx rankcontrol query-remove <queryId>
45
46
  npx rankcontrol competitors
46
- npx rankcontrol competitor-add "Acme" https://acme.com
47
+ npx rankcontrol competitor-add "Acme" # site resolved from the name; pass a URL to override
47
48
  npx rankcontrol competitor-remove <competitorId>
48
49
 
49
50
  # Content pipeline
@@ -52,8 +53,9 @@ npx rankcontrol content # pages with status
52
53
  npx rankcontrol plan-content --topics "bus travel" --max-difficulty 40
53
54
  npx rankcontrol commit-titles --file titles.json
54
55
  npx rankcontrol ideas # scored content-idea backlog
56
+ npx rankcontrol idea-hide <queryId> # hide an idea; a tracked query keeps its checks
55
57
  npx rankcontrol plan-idea "query text" # put an idea on the calendar
56
- npx rankcontrol delete-planned <contentId> # title returns to the idea pool
58
+ npx rankcontrol delete-planned <contentId> # its keyword returns to Content Ideas
57
59
  npx rankcontrol generate <contentId> --confirm # write a planned article now
58
60
  npx rankcontrol publish <contentId> --confirm # dry run without --confirm
59
61
  npx rankcontrol archive <contentId>
@@ -61,14 +63,15 @@ npx rankcontrol reschedule <contentId> 2026-08-04
61
63
  npx rankcontrol settings # article policy (auto-write, auto-publish, images, ...)
62
64
  npx rankcontrol settings-set --auto-generate off --auto-publish off
63
65
  npx rankcontrol topics # pillar list (topic clusters)
64
- npx rankcontrol topics-set "Topic A" "Topic B" # replaces the FULL list
66
+ npx rankcontrol topics-set "Topic A" "Topic B" # replaces the FULL list; up to 20 topics
65
67
  npx rankcontrol internal-links <contentId> # who links here / links out
66
68
  npx rankcontrol site-pages # pages used for internal links
67
69
  npx rankcontrol detect-links https://example.com/sitemap.xml
68
70
  npx rankcontrol add-pages https://example.com/pricing
69
71
 
70
- # Leads and analytics
72
+ # Analytics
71
73
  npx rankcontrol traffic --days 30 # page views, visitors, bounce rate, time on page
74
+ npx rankcontrol rankings # Verified Google + Bing positions, GSC clicks/impressions/average, AI Overview rate per keyword (--scope ours|site)
72
75
  npx rankcontrol engagement # per-page views, citations, sparkline
73
76
  npx rankcontrol analytics-sources # which source writes traffic/crawler data
74
77
  npx rankcontrol analytics-set-source <dataType> <source>
@@ -96,13 +99,15 @@ npx rankcontrol network-remove-placement <placementId> --confirm
96
99
  npx rankcontrol repurpose [contentId] # published articles + social drafts (8 platforms)
97
100
  npx rankcontrol repurpose-generate <contentId> --platforms twitter --confirm
98
101
  npx rankcontrol repurpose-edit <draftId> --title "New hook"
99
- npx rankcontrol repurpose-channels # Postiz channel ids
100
- npx rankcontrol repurpose-push <draftId> --channels <id> --confirm
102
+ npx rankcontrol repurpose-channels # Postiz and Buffer channel ids
103
+ npx rankcontrol repurpose-push <draftId> --channels <id> --confirm # --scheduler postiz|buffer when both are connected
101
104
  npx rankcontrol repurpose-mark-posted <draftId>
102
105
  npx rankcontrol social # Reddit/X threads where your articles fit
103
106
  npx rankcontrol social-stats
104
- npx rankcontrol social-status <threadId> <status>
107
+ npx rankcontrol social-status <threadId> skipped
105
108
  npx rankcontrol social-draft-reply <threadId>
109
+ npx rankcontrol social-open <threadId> [--text "<edited reply>"]
110
+ npx rankcontrol social-x-handle <handle|clear>
106
111
 
107
112
  # Brand, team, workspace
108
113
  npx rankcontrol brand # brand profile, products, ICPs in one read
@@ -110,6 +115,8 @@ npx rankcontrol brand-set --industry "SaaS" # name, industry, description, au
110
115
  npx rankcontrol brand-profile-set --json '{"tone":"confident"}'
111
116
  npx rankcontrol brand-product --json @product.json
112
117
  npx rankcontrol brand-icp --json @icp.json
118
+ npx rankcontrol icp-suggest # up to 8 audiences for this site; saves nothing
119
+ npx rankcontrol icp-draft --describe "Ops leads at 50-500 person logistics firms" # one profile to review; save with brand-icp
113
120
  npx rankcontrol team
114
121
  npx rankcontrol team-invite user@company.com --perms content=write,analytics=read --confirm
115
122
  npx rankcontrol team-revoke <invitationId>
@@ -147,7 +154,7 @@ Or in `.mcp.json` / Cursor `mcp.json`:
147
154
  }
148
155
  ```
149
156
 
150
- ### Tools (83)
157
+ ### Tools (80+)
151
158
 
152
159
  **Visibility and citations**
153
160
 
@@ -164,6 +171,7 @@ Or in `.mcp.json` / Cursor `mcp.json`:
164
171
  | `get_crawler_access` | Daily AI-crawler reachability probe (edge blocks, robots.txt) |
165
172
  | `list_tracked_queries` | The queries checked weekly across the AI engines |
166
173
  | `add_tracked_query` / `update_tracked_query` / `set_query_tracking` / `remove_tracked_query` | Manage the query pool and its plan slots |
174
+ | `hide_content_idea` | Hide an idea from Content Ideas; a tracked query keeps its weekly checks and its slot |
167
175
  | `get_competitors` / `add_competitor` / `remove_competitor` | Manage share-of-voice competitors (max 10) |
168
176
 
169
177
  **Content pipeline**
@@ -180,16 +188,17 @@ Or in `.mcp.json` / Cursor `mcp.json`:
180
188
  | `archive_content` | Archive an article (reversible in the dashboard) |
181
189
  | `reschedule_article` | Move a planned article to another day (flexible schedule) |
182
190
  | `get_article_settings` / `update_article_settings` | Read / merge-patch the article policy |
183
- | `get_topics` / `set_topics` | Read / replace the pillar list (full-list replace) |
191
+ | `get_topics` / `set_topics` | Read / replace the pillar list (full-list replace, up to 20 topics) |
184
192
  | `get_internal_links` | Who links to this article internally, and its outbound count |
185
193
  | `list_site_pages` / `add_site_pages` / `detect_site_links` | Pages used for in-article internal links |
186
194
  | `list_content` | Content pages with status |
187
195
 
188
- **Leads and analytics**
196
+ **Analytics**
189
197
 
190
198
  | Tool | What it does |
191
199
  |---|---|
192
200
  | `get_traffic_overview` | Page views, visitors, sessions, bounce rate, time on page |
201
+ | `get_rankings` | Verified Google and Bing positions per keyword, Search Console clicks, impressions and average position (never shown as a rank), which page earned each query and whether it is yours, how often Google's AI Overview shows and cites you; `scope` ours (default) or site |
193
202
  | `get_page_engagement` | Per-page views, citations, and a view sparkline |
194
203
  | `get_analytics_sources` / `set_analytics_source` | Which source writes traffic and crawler data |
195
204
  | `activate_analytics_screen` | One-time activation of the Analytics or Reports screen |
@@ -222,10 +231,10 @@ Or in `.mcp.json` / Cursor `mcp.json`:
222
231
  |---|---|
223
232
  | `list_repurpose_queue` / `get_repurpose_drafts` | Published articles + social drafts (8 platforms) |
224
233
  | `generate_repurpose_drafts` / `update_repurpose_draft` | Draft and edit platform-native posts |
225
- | `list_postiz_channels` / `push_repurpose_draft` | Push to Postiz; dry run by default |
234
+ | `list_postiz_channels` / `push_repurpose_draft` | Push to Buffer or Postiz; dry run by default |
226
235
  | `mark_repurpose_posted` | Record a draft as posted manually |
227
236
  | `list_social_threads` / `get_social_stats` | Reddit/X threads where your articles fit |
228
- | `update_social_thread_status` / `draft_social_reply` | Work a thread; drafts never auto-post |
237
+ | `update_social_thread_status` / `draft_social_reply` / `open_social_thread` / `set_social_x_handle` | Work a thread: draft, then open it to post yourself (X gets a pre-filled composer link); the thread marks itself Posted once your reply is found. Drafts never auto-post; Posted is never set by hand |
229
238
 
230
239
  **Brand, team, workspace**
231
240
 
@@ -235,6 +244,7 @@ Or in `.mcp.json` / Cursor `mcp.json`:
235
244
  | `update_brand_identity` | Name, industry, description, authors (EEAT bylines), style refs |
236
245
  | `update_brand_profile` | Voice, colors, meta fields, locale |
237
246
  | `write_brand_product` / `write_brand_icp` | Create/update/delete products and buyer profiles |
247
+ | `suggest_brand_icps` / `draft_brand_icp` | Suggest audiences for this site and draft one buyer profile to review; nothing is saved until `write_brand_icp` |
238
248
  | `get_team` / `invite_team_member` / `revoke_team_invite` / `remove_team_member` | Seats, invites, per-screen permissions |
239
249
  | `contact_support` | Message the RankControl team with the workspace identified |
240
250
 
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "rankcontrol",
3
- "version": "0.8.3",
3
+ "version": "0.13.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
+ "scripts": {
7
+ "prepublishOnly": "node ../../scripts/check-package-parity.mjs"
8
+ },
6
9
  "homepage": "https://rctrl.com",
7
10
  "type": "module",
8
11
  "bin": {
package/src/cli.mjs CHANGED
@@ -58,6 +58,12 @@ export function runCli(argv) {
58
58
  .option("--days <n>", "7, 30 or 90", "30")
59
59
  .action((opts) => api.trafficOverview(Number(opts.days)).then(out).catch(fail));
60
60
 
61
+ program
62
+ .command("rankings")
63
+ .description("Verified Google and Bing positions per keyword, Search Console clicks, impressions and average position when connected (an average, never a rank), which page earned each query, and how often Google's AI Overview shows above it and cites you")
64
+ .option("--scope <scope>", '"ours" (default): queries your published articles appear for; "site": everything on the domain', "ours")
65
+ .action((opts) => api.rankings(opts.scope).then(out).catch(fail));
66
+
61
67
  program
62
68
  .command("score")
63
69
  .description("Composite visibility score blending AI citation rate with Google and Bing rank share, with subscores")
@@ -198,19 +204,26 @@ export function runCli(argv) {
198
204
 
199
205
  program
200
206
  .command("query-add")
201
- .description("Add a query to the tracking pool (tracks immediately when a plan slot is free)")
207
+ .description("Add a query (tracks immediately when a tracking slot is free)")
202
208
  .argument("<queryText>", "The search/AI query to track")
203
- .action((queryText) => api.addQuery(queryText).then(out).catch(fail));
209
+ .option("--covered-by <contentId>", "Article that already answers this query, so Content Ideas skips it")
210
+ .action((queryText, opts) => api.addQuery(queryText, opts.coveredBy).then(out).catch(fail));
211
+
212
+ program
213
+ .command("idea-hide")
214
+ .description("Hide an idea from Content Ideas. A tracked query keeps its weekly checks")
215
+ .argument("<queryId>", "Idea id (from the ideas command)")
216
+ .action((queryId) => api.hideIdea(queryId).then(out).catch(fail));
204
217
 
205
218
  program
206
219
  .command("query-remove")
207
- .description("Delete a query from the tracking pool")
220
+ .description("Delete a query")
208
221
  .argument("<queryId>", "Query id (from the queries command)")
209
222
  .action((queryId) => api.removeQuery(queryId).then(out).catch(fail));
210
223
 
211
224
  program
212
225
  .command("query-track")
213
- .description("Pause or resume weekly checks on a query. Tracking uses a plan slot; pausing frees it")
226
+ .description("Pause or resume weekly checks on a query. Tracking uses one of your tracking slots; pausing frees it")
214
227
  .argument("<queryId>", "Query id (from the queries command)")
215
228
  .argument("<state>", "on | off")
216
229
  .action((queryId, state) =>
@@ -228,14 +241,14 @@ export function runCli(argv) {
228
241
 
229
242
  program
230
243
  .command("competitors")
231
- .description("Tracked competitors used in share-of-voice and visibility comparisons")
244
+ .description("Tracked competitors used in share-of-voice and visibility comparisons, with each one's AI visibility score, citations of checks over the last 30 days, domain metrics and positioning analysis")
232
245
  .action(() => api.competitors().then(out).catch(fail));
233
246
 
234
247
  program
235
248
  .command("competitor-add")
236
- .description("Track a competitor (max 10)")
249
+ .description("Track a competitor (max 10); the site is resolved from the name when omitted")
237
250
  .argument("<name>", "Competitor name")
238
- .argument("<websiteUrl>", "Competitor website URL")
251
+ .argument("[websiteUrl]", "Competitor website URL (optional)")
239
252
  .action((name, websiteUrl) =>
240
253
  api.addCompetitor(name, websiteUrl).then(out).catch(fail)
241
254
  );
@@ -255,7 +268,7 @@ export function runCli(argv) {
255
268
 
256
269
  program
257
270
  .command("topics-set")
258
- .description("Replace the full pillar list (create/dedupe/safe-delete reconcile)")
271
+ .description("Replace the full pillar list (create/dedupe/safe-delete reconcile); up to 20 topics")
259
272
  .argument("<topics...>", "Topic names, space-separated (quote multi-word topics)")
260
273
  .action((topics) => api.setTopics(topics).then(out).catch(fail));
261
274
 
@@ -492,11 +505,10 @@ export function runCli(argv) {
492
505
 
493
506
  program
494
507
  .command("reschedule <contentId> <date>")
495
- .description("Move a planned article to a day (YYYY-MM-DD, local time; needs the flexible schedule)")
508
+ .description("Move a planned article to a day (YYYY-MM-DD in the workspace's timezone; needs the flexible schedule)")
496
509
  .action((contentId, date) => {
497
- const day = new Date(`${date}T00:00:00`);
498
- if (Number.isNaN(day.getTime())) return fail(new Error("Date must be YYYY-MM-DD"));
499
- return api.reschedule(contentId, day.getTime()).then(out).catch(fail);
510
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return fail(new Error("Date must be YYYY-MM-DD"));
511
+ return api.reschedule(contentId, date).then(out).catch(fail);
500
512
  });
501
513
 
502
514
  program
@@ -536,7 +548,7 @@ export function runCli(argv) {
536
548
 
537
549
  program
538
550
  .command("repurpose-generate <contentId>")
539
- .description("Draft social posts for a published article (dry run unless --confirm; costly)")
551
+ .description("Draft social posts for a published article that has none yet (dry run unless --confirm; costly)")
540
552
  .option(
541
553
  "--platforms <a,b>",
542
554
  "Subset: linkedin,twitter,pinterest,instagram,facebook,threads,youtube,tiktok"
@@ -572,20 +584,22 @@ export function runCli(argv) {
572
584
 
573
585
  program
574
586
  .command("repurpose-channels")
575
- .description("Connected Postiz channels (ids needed for repurpose-push)")
587
+ .description("Connected Postiz and Buffer channels (ids needed for repurpose-push)")
576
588
  .action(() => api.repurposeChannels().then(out).catch(fail));
577
589
 
578
590
  program
579
591
  .command("repurpose-push <draftId>")
580
- .description("Send a draft to Postiz (dry run unless --confirm)")
581
- .option("--channels <id1,id2>", "Postiz channel ids (see repurpose-channels)")
582
- .option("--when <now|schedule|draft>", "Postiz mode", "now")
592
+ .description("Send a draft to Postiz or Buffer (dry run unless --confirm)")
593
+ .option("--scheduler <postiz|buffer>", "Which scheduler (required when both are connected)")
594
+ .option("--channels <id1,id2>", "Channel ids (see repurpose-channels)")
595
+ .option("--when <now|schedule|draft|queue>", "Mode; queue is Buffer only", "now")
583
596
  .option("--date <iso>", "ISO time for --when schedule")
584
597
  .option("--confirm", "Actually push (publicly visible)")
585
598
  .action((draftId, opts) =>
586
599
  api
587
600
  .repurposePush({
588
601
  draftId,
602
+ scheduler: opts.scheduler,
589
603
  integrationIds: opts.channels ? list(opts.channels) : undefined,
590
604
  scheduleType: opts.when,
591
605
  date: opts.date,
@@ -707,11 +721,28 @@ export function runCli(argv) {
707
721
 
708
722
  program
709
723
  .command("social-status <threadId> <status>")
710
- .description("Move a social thread through the pipeline")
724
+ .description("Skip a thread (the only manual status; Posted is detected automatically)")
711
725
  .action((threadId, status) =>
712
726
  api.socialStatus(threadId, status).then(out).catch(fail)
713
727
  );
714
728
 
729
+ program
730
+ .command("social-open <threadId>")
731
+ .description(
732
+ "Take a drafted reply to the platform yourself: prints the thread URL (for X, a composer link with the reply filled in) and starts the checks that mark it Posted"
733
+ )
734
+ .option("--text <reply>", "Use an edited reply (X: 280 characters or fewer)")
735
+ .action((threadId, opts) =>
736
+ api.socialOpen(threadId, opts.text).then(out).catch(fail)
737
+ );
738
+
739
+ program
740
+ .command("social-x-handle <handle>")
741
+ .description("Save the X handle used to recognise your posted replies (\"clear\" removes it)")
742
+ .action((handle) =>
743
+ api.socialXHandle(handle === "clear" ? "" : handle).then(out).catch(fail)
744
+ );
745
+
715
746
  program
716
747
  .command("social-draft-reply <threadId>")
717
748
  .description("AI-draft a reply for a thread (nothing posts; the draft lands for review)")
@@ -773,5 +804,29 @@ export function runCli(argv) {
773
804
  api.brandIcpWrite(parseJson(opts.json, "brand-icp")).then(out).catch(fail)
774
805
  );
775
806
 
807
+ program
808
+ .command("icp-suggest")
809
+ .description("Suggest up to 8 audiences for this site from its brand, products, competitors and topics (saves nothing)")
810
+ .option("--exclude <titles...>", "Audience titles already shown, to get different ones")
811
+ .action((opts) => api.suggestIcps(opts.exclude ?? []).then(out).catch(fail));
812
+
813
+ program
814
+ .command("icp-draft")
815
+ .description("Draft one buyer profile from a sentence or a suggestion (saves nothing; save it with brand-icp)")
816
+ .option("--describe <sentence>", "Who the audience is, in one sentence")
817
+ .option("--title <title>", "Title of a suggestion from icp-suggest")
818
+ .option("--who <sentence>", "The suggestion's one-line description")
819
+ .option("--json <json>", "Fields to keep as written, JSON (or @file.json); a key you pass is kept, a key you leave out is filled")
820
+ .action((opts) =>
821
+ api
822
+ .draftIcp({
823
+ describe: opts.describe,
824
+ suggestion: opts.title ? { title: opts.title, who: opts.who } : undefined,
825
+ given: opts.json ? parseJson(opts.json, "icp-draft") : undefined,
826
+ })
827
+ .then(out)
828
+ .catch(fail)
829
+ );
830
+
776
831
  program.parseAsync(argv);
777
832
  }
package/src/client.mjs CHANGED
@@ -124,8 +124,8 @@ export const api = {
124
124
  articleSettings: () => request("GET", "/api/v1/settings/articles"),
125
125
  updateArticleSettings: (opts) =>
126
126
  request("POST", "/api/v1/settings/articles", opts),
127
- reschedule: (contentId, targetDayStartMs) =>
128
- request("POST", "/api/v1/content/reschedule", { contentId, targetDayStartMs }),
127
+ reschedule: (contentId, date) =>
128
+ request("POST", "/api/v1/content/reschedule", { contentId, date }),
129
129
  sitePages: () => request("GET", "/api/v1/links/pages"),
130
130
  detectSiteLinks: (source, url) =>
131
131
  request("POST", "/api/v1/links/detect", { source, url }),
@@ -144,9 +144,12 @@ export const api = {
144
144
  request("POST", "/api/v1/repurpose/mark-posted", { draftId }),
145
145
  repurposePush: (opts) => request("POST", "/api/v1/repurpose/push", opts),
146
146
  trackedQueries: () => request("GET", "/api/v1/queries"),
147
- addQuery: (queryText) => request("POST", "/api/v1/queries", { queryText }),
147
+ addQuery: (queryText, coveredByContentId) =>
148
+ request("POST", "/api/v1/queries", coveredByContentId ? { queryText, coveredByContentId } : { queryText }),
148
149
  removeQuery: (queryId) =>
149
150
  request("POST", "/api/v1/queries/remove", { queryId }),
151
+ hideIdea: (queryId) =>
152
+ request("POST", "/api/v1/content/ideas/hide", { queryId }),
150
153
  setQueryTracking: (queryId, tracked) =>
151
154
  request("POST", "/api/v1/queries/set-tracking", { queryId, tracked }),
152
155
  updateQuery: (queryId, queryText) =>
@@ -163,6 +166,8 @@ export const api = {
163
166
  crawlerAccess: () => request("GET", "/api/v1/crawler-access"),
164
167
  trafficOverview: (days = 30) =>
165
168
  request("GET", `/api/v1/analytics/traffic?days=${days}`),
169
+ rankings: (scope = "ours") =>
170
+ request("GET", `/api/v1/analytics/rankings?scope=${scope === "site" ? "site" : "ours"}`),
166
171
  analyticsSources: () => request("GET", "/api/v1/analytics/sources"),
167
172
  setAnalyticsSource: (dataType, source) =>
168
173
  request("POST", "/api/v1/analytics/sources", { dataType, source }),
@@ -232,10 +237,17 @@ export const api = {
232
237
  request("POST", "/api/v1/social/status", { threadId, status }),
233
238
  socialDraftReply: (threadId, mentionMode) =>
234
239
  request("POST", "/api/v1/social/draft-reply", { threadId, mentionMode }),
240
+ socialOpen: (threadId, text) =>
241
+ request("POST", "/api/v1/social/open", { threadId, text }),
242
+ socialXHandle: (handle) =>
243
+ request("POST", "/api/v1/social/x-handle", { handle }),
235
244
  support: (opts) => request("POST", "/api/v1/support", opts),
236
245
  brand: () => request("GET", "/api/v1/brand"),
237
246
  brandIdentitySet: (opts) => request("POST", "/api/v1/brand", opts),
238
247
  brandProfileSet: (opts) => request("POST", "/api/v1/brand/profile", opts),
239
248
  brandProductWrite: (opts) => request("POST", "/api/v1/brand/product", opts),
240
249
  brandIcpWrite: (opts) => request("POST", "/api/v1/brand/icp", opts),
250
+ suggestIcps: (exclude = []) =>
251
+ request("POST", "/api/v1/brand/icp/suggest", { exclude }),
252
+ draftIcp: (opts) => request("POST", "/api/v1/brand/icp/draft", opts),
241
253
  };
package/src/login.mjs CHANGED
@@ -13,13 +13,10 @@ const DEFAULT_SCOPES = [
13
13
  "read:content",
14
14
  "read:analytics",
15
15
  "write:content",
16
- "write:queries",
17
16
  "write:backlinks",
18
17
  "write:social",
19
18
  "write:publish",
20
- "manage:integrations",
21
19
  "manage:org",
22
- "trigger:agents",
23
20
  ];
24
21
 
25
22
  function openBrowser(url) {
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.8.3" });
32
+ const server = new McpServer({ name: "rankcontrol", version: "0.13.0" });
33
33
 
34
34
  server.tool(
35
35
  "get_overview_funnel",
@@ -195,21 +195,34 @@ export async function startMcpServer() {
195
195
 
196
196
  server.tool(
197
197
  "add_tracked_query",
198
- "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.",
199
- { queryText: z.string().describe("The search/AI query to track") },
200
- run(({ queryText }) => api.addQuery(queryText))
198
+ "Add a query. Weekly AI citation checks start immediately when a tracking slot is free; otherwise it is saved untracked. Pass coveredByContentId when the query is a question an existing article already answers, so Content Ideas does not offer it again.",
199
+ {
200
+ queryText: z.string().describe("The search/AI query to track"),
201
+ coveredByContentId: z
202
+ .string()
203
+ .optional()
204
+ .describe("contentPage id of the article that already answers this query"),
205
+ },
206
+ run(({ queryText, coveredByContentId }) => api.addQuery(queryText, coveredByContentId))
207
+ );
208
+
209
+ server.tool(
210
+ "hide_content_idea",
211
+ "Hide an idea from Content Ideas. A tracked query keeps its weekly checks and its slot; use remove_tracked_query to delete a query outright.",
212
+ { queryId: z.string().describe("Idea id from get_content_ideas") },
213
+ run(({ queryId }) => api.hideIdea(queryId))
201
214
  );
202
215
 
203
216
  server.tool(
204
217
  "remove_tracked_query",
205
- "Delete a query from the tracking pool. Frees its plan slot if it was tracking.",
218
+ "Delete a query. Frees its tracking slot if it was tracked.",
206
219
  { queryId: z.string().describe("Query id from get_tracked_queries") },
207
220
  run(({ queryId }) => api.removeQuery(queryId))
208
221
  );
209
222
 
210
223
  server.tool(
211
224
  "set_query_tracking",
212
- "Pause or resume weekly AI citation checks on a pooled query. Tracking consumes one plan slot; pausing frees it. The query stays in the pool either way.",
225
+ "Pause or resume weekly AI citation checks on a saved query. Tracking uses one of the plan's tracking slots; pausing frees it. The query is kept either way.",
213
226
  {
214
227
  queryId: z.string().describe("Query id from get_tracked_queries"),
215
228
  tracked: z.boolean().describe("true to track weekly, false to pause"),
@@ -229,17 +242,20 @@ export async function startMcpServer() {
229
242
 
230
243
  server.tool(
231
244
  "get_competitors",
232
- "Tracked competitors used in share-of-voice and visibility comparisons.",
245
+ "Tracked competitors used in share-of-voice and visibility comparisons, with each one's AI visibility score, citations of checks over the last 30 days, domain metrics and positioning analysis.",
233
246
  {},
234
247
  run(() => api.competitors())
235
248
  );
236
249
 
237
250
  server.tool(
238
251
  "add_competitor",
239
- "Track a competitor (max 10). They enter weekly share-of-voice comparisons against the org's own citation rate.",
252
+ "Track a competitor (max 10). They enter weekly share-of-voice comparisons against the org's own citation rate. Omit websiteUrl to have the site resolved from the name.",
240
253
  {
241
254
  name: z.string().describe("Competitor name"),
242
- websiteUrl: z.string().describe("Competitor website URL"),
255
+ websiteUrl: z
256
+ .string()
257
+ .optional()
258
+ .describe("Competitor website URL (optional; resolved from the name when omitted)"),
243
259
  },
244
260
  run(({ name, websiteUrl }) => api.addCompetitor(name, websiteUrl))
245
261
  );
@@ -260,7 +276,7 @@ export async function startMcpServer() {
260
276
 
261
277
  server.tool(
262
278
  "set_topics",
263
- "Replace the FULL pillar list. Send every topic that should exist; missing ones are safe-deleted, new ones created, duplicates merged. Read get_topics first and modify that list.",
279
+ "Replace the FULL pillar list. Send every topic that should exist; missing ones are safe-deleted, new ones created, duplicates merged. Read get_topics first and modify that list. Up to 20 topics.",
264
280
  {
265
281
  topics: z.array(z.string()).describe("The complete list of topic names"),
266
282
  },
@@ -269,7 +285,7 @@ export async function startMcpServer() {
269
285
 
270
286
  server.tool(
271
287
  "delete_planned_title",
272
- "Remove a planned title from the content calendar. The title returns to Content Ideas as a pool query (deduped), so the topic is not lost. Only planned rows qualify; use archive for generated articles.",
288
+ "Remove a planned title from the content calendar. The title's keyword returns to Content Ideas as a saved query, so the topic is not lost. Only planned rows qualify; use archive for generated articles.",
273
289
  { contentId: z.string().describe("Planned contentPage id") },
274
290
  run(({ contentId }) => api.deletePlanned(contentId))
275
291
  );
@@ -281,6 +297,18 @@ export async function startMcpServer() {
281
297
  run(({ days }) => api.trafficOverview(days ? Number(days) : 30))
282
298
  );
283
299
 
300
+ server.tool(
301
+ "get_rankings",
302
+ "Verified Google and Bing positions per keyword (top 30), with Search Console clicks, impressions and average position when connected (gscPosition is an average over the searches you appeared in, never a rank), which page earned each query and whether it is one of your articles (isOurs), and how often Google's AI Overview shows above each keyword and cites one of your pages (a rate over the last 8 weekly checks). Defaults to queries your published articles appear for.",
303
+ {
304
+ scope: z
305
+ .enum(["ours", "site"])
306
+ .optional()
307
+ .describe('"ours" (default): queries your published articles appear for. "site": everything on the domain.'),
308
+ },
309
+ run(({ scope }) => api.rankings(scope ?? "ours"))
310
+ );
311
+
284
312
  server.tool(
285
313
  "get_planning_capacity",
286
314
  "How many article titles can still be planned onto the content calendar within the scheduling horizon. Check before plan_content.",
@@ -360,7 +388,7 @@ export async function startMcpServer() {
360
388
 
361
389
  server.tool(
362
390
  "queue_outreach_email",
363
- "Queue an outreach email to a prospect. It sends from the workspace's own connected mailbox, paced automatically with a 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.",
391
+ "Queue an outreach email to a prospect. It sends from the workspace's own connected mailbox, paced automatically with a daily cap. Uses the stored draft unless subject/body are given; a \"[Your name]\" sign-off in the draft is replaced with the owner's profile name at send time. Dry-run unless confirm is true — a confirmed queue leads to a REAL email being sent.",
364
392
  {
365
393
  backlinkId: z.string().describe("Prospect id from list_outreach_prospects"),
366
394
  subject: z.string().optional().describe("Override the drafted subject"),
@@ -400,7 +428,7 @@ export async function startMcpServer() {
400
428
 
401
429
  server.tool(
402
430
  "commit_planned_titles",
403
- "Approve reviewed titles onto the content calendar (the approval half of the plan→commit gate). Pass rejected candidates in `discarded` so their keywords recycle into the idea pool.",
431
+ "Approve reviewed titles onto the content calendar (the approval half of the plan→commit gate). Pass rejected candidates in `discarded` so their keywords recycle into Content Ideas.",
404
432
  {
405
433
  titles: z.array(plannedTitle).describe("Titles to schedule"),
406
434
  discarded: z.array(plannedTitle).optional().describe("Rejected candidates"),
@@ -410,7 +438,7 @@ export async function startMcpServer() {
410
438
 
411
439
  server.tool(
412
440
  "publish_content",
413
- "Publish an article to the customer's connected CMS. Defaults to a DRY RUN describing what would happen; a human should approve before calling again with confirm=true.",
441
+ "Publish an article to the customer's connected CMS. Defaults to a DRY RUN describing what would happen; a human should approve before calling again with confirm=true. Delivery runs after this call returns. If the article does not go live, list_content shows its lastPublishError. Webhook receivers: https://rctrl.com/docs/webhook",
414
442
  {
415
443
  contentId: z.string().describe("The content page id"),
416
444
  confirm: z.boolean().optional().describe("Set true to actually publish (default: dry run)"),
@@ -446,7 +474,7 @@ export async function startMcpServer() {
446
474
 
447
475
  const articleSettingsShape = {
448
476
  autoPublish: z.boolean().optional(),
449
- autoGenerate: z.boolean().optional().describe("Off = fully manual mode: articles are written only on demand (generate_article) and publish only from the editor"),
477
+ autoGenerate: z.boolean().optional().describe("Off = fully manual mode: articles are written only on demand (generate_article) and publish only from the editor. Turning it on needs a publishing destination that has already put at least one article live on the site."),
450
478
  includeInfographics: z.boolean().optional().describe("AI hero + section images"),
451
479
  titleInHeroImage: z.boolean().optional().describe("Write the post title into the hero image"),
452
480
  includeSectionInfographics: z.boolean().optional().describe("Informational panels in 2,000+ word articles"),
@@ -481,12 +509,12 @@ export async function startMcpServer() {
481
509
 
482
510
  server.tool(
483
511
  "reschedule_article",
484
- "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.",
512
+ "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 YYYY-MM-DD in the workspace's timezone.",
485
513
  {
486
514
  contentId: z.string(),
487
- targetDayStartMs: z.number().describe("Epoch ms of the target day's local midnight"),
515
+ date: z.string().describe("Target day as YYYY-MM-DD in the workspace's timezone"),
488
516
  },
489
- run(({ contentId, targetDayStartMs }) => api.reschedule(contentId, targetDayStartMs))
517
+ run(({ contentId, date }) => api.reschedule(contentId, date))
490
518
  );
491
519
 
492
520
  server.tool(
@@ -522,7 +550,7 @@ export async function startMcpServer() {
522
550
 
523
551
  server.tool(
524
552
  "list_repurpose_queue",
525
- "Published articles with their social repurpose drafts per platform (platform + status chips). Shows which articles still need drafts and whether Postiz is connected.",
553
+ "Published articles with their social repurpose drafts per platform (platform + status chips). Shows which articles still need drafts and which scheduler is connected (Buffer or Postiz).",
526
554
  {},
527
555
  run(() => api.repurposeQueue())
528
556
  );
@@ -536,7 +564,7 @@ export async function startMcpServer() {
536
564
 
537
565
  server.tool(
538
566
  "generate_repurpose_drafts",
539
- "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 (costly).",
567
+ "Draft platform-native social posts for a published article. Only for an article with no drafts yet; edit or dismiss existing drafts in the dashboard. Defaults to a DRY RUN; confirm=true generates (costly).",
540
568
  {
541
569
  contentId: z.string(),
542
570
  platforms: z
@@ -572,33 +600,37 @@ export async function startMcpServer() {
572
600
 
573
601
  server.tool(
574
602
  "mark_repurpose_posted",
575
- "Mark a repurpose draft as posted — use after publishing its text manually (outside Postiz).",
603
+ "Mark a repurpose draft as posted — use after publishing its text manually (outside Buffer or Postiz).",
576
604
  { draftId: z.string() },
577
605
  run(({ draftId }) => api.repurposeMarkPosted(draftId))
578
606
  );
579
607
 
580
608
  server.tool(
581
609
  "list_postiz_channels",
582
- "Connected Postiz social channels (id, name, platform). Channel ids are required by push_repurpose_draft.",
610
+ "Connected social channels across Postiz and Buffer (scheduler, id, name, platform). Channel ids are required by push_repurpose_draft.",
583
611
  {},
584
612
  run(() => api.repurposeChannels())
585
613
  );
586
614
 
587
615
  server.tool(
588
616
  "push_repurpose_draft",
589
- "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.",
617
+ "Send a repurpose draft to a connected scheduler's channels: Postiz or Buffer (post now, schedule, save as a draft, or add to Buffer's queue). PUBLICLY VISIBLE side effect: defaults to a DRY RUN; a human should approve before calling again with confirm=true.",
590
618
  {
591
619
  draftId: z.string(),
620
+ scheduler: z
621
+ .enum(["postiz", "buffer"])
622
+ .optional()
623
+ .describe("Which scheduler; required when both are connected"),
592
624
  integrationIds: z
593
625
  .array(z.string())
594
626
  .optional()
595
627
  .describe(
596
- "Postiz channel ids from list_postiz_channels (required with confirm)"
628
+ "Channel ids from list_postiz_channels (required with confirm)"
597
629
  ),
598
630
  scheduleType: z
599
- .enum(["now", "schedule", "draft"])
631
+ .enum(["now", "schedule", "draft", "queue"])
600
632
  .optional()
601
- .describe("Default now"),
633
+ .describe("Default now; queue is Buffer only"),
602
634
  date: z.string().optional().describe("ISO time for scheduleType=schedule"),
603
635
  confirm: z.boolean().optional(),
604
636
  },
@@ -662,13 +694,13 @@ export async function startMcpServer() {
662
694
 
663
695
  server.tool(
664
696
  "list_backlinks",
665
- "The workspace's backlink table, newest first.",
697
+ "The workspace's backlink table, newest first. Each row carries its source (index, outreach, managed, network).",
666
698
  {
667
699
  status: z
668
700
  .string()
669
701
  .optional()
670
702
  .describe(
671
- "Filter: discovered, verified, lost, identified, contacted, replied, link_placed, rejected"
703
+ "Filter: discovered, verified, lost, placed, identified, contacted, replied, link_placed, rejected"
672
704
  ),
673
705
  },
674
706
  run(({ status }) => api.backlinks(status))
@@ -732,11 +764,25 @@ export async function startMcpServer() {
732
764
 
733
765
  server.tool(
734
766
  "update_social_thread_status",
735
- "Move a social thread through the pipeline (e.g. dismissed, replied).",
767
+ "Skip a social thread so it leaves the queue (the only manual status; Posted is set automatically once your reply is found in the thread).",
736
768
  { threadId: z.string(), status: z.string() },
737
769
  run(({ threadId, status }) => api.socialStatus(threadId, status))
738
770
  );
739
771
 
772
+ server.tool(
773
+ "open_social_thread",
774
+ "Open a drafted social thread to post the reply yourself: returns the thread URL (for X, a link that opens X's composer with the reply filled in) and starts four checks over 72 hours that mark the thread Posted once your reply is found there. Pass text to use an edited reply; X replies must be 280 characters or fewer.",
775
+ { threadId: z.string(), text: z.string().optional() },
776
+ run(({ threadId, text }) => api.socialOpen(threadId, text))
777
+ );
778
+
779
+ server.tool(
780
+ "set_social_x_handle",
781
+ "Save the X handle used to recognise your posted replies (optional; text matching covers the rest). Pass an empty handle to clear it.",
782
+ { handle: z.string() },
783
+ run(({ handle }) => api.socialXHandle(handle))
784
+ );
785
+
740
786
  server.tool(
741
787
  "draft_social_reply",
742
788
  "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.",
@@ -849,6 +895,30 @@ export async function startMcpServer() {
849
895
  run((args) => api.brandIcpWrite(args))
850
896
  );
851
897
 
898
+ server.tool(
899
+ "suggest_brand_icps",
900
+ "Suggest up to 8 audiences for this site from its brand, products, competitors and topics. Saves nothing. Pick one and pass it to draft_brand_icp, or write your own with write_brand_icp.",
901
+ { exclude: z.array(z.string()).optional().describe("Audience titles already shown, to get different ones") },
902
+ run(({ exclude }) => api.suggestIcps(exclude ?? []))
903
+ );
904
+
905
+ server.tool(
906
+ "draft_brand_icp",
907
+ "Draft one buyer profile from a one-sentence description or a suggestion from suggest_brand_icps. Returns the fields to review; nothing is saved until write_brand_icp. Fields passed in given are kept as written.",
908
+ {
909
+ describe: z.string().optional().describe("Who the audience is, in one sentence"),
910
+ title: z.string().optional().describe("Title of a suggestion from suggest_brand_icps"),
911
+ who: z.string().optional().describe("The suggestion's one-line description"),
912
+ given: z
913
+ .record(z.string(), z.unknown())
914
+ .optional()
915
+ .describe("Fields to keep as written (same keys as write_brand_icp, plus ageRange, seniority, department as strings). A key you pass is kept, empty included; a key you leave out is filled."),
916
+ },
917
+ run(({ describe, title, who, given }) =>
918
+ api.draftIcp({ describe, suggestion: title ? { title, who } : undefined, given })
919
+ )
920
+ );
921
+
852
922
  await server.connect(new StdioServerTransport());
853
923
  // Keep the process alive; the transport owns stdin/stdout from here
854
924
  console.error("rankcontrol MCP server running on stdio");