rankcontrol 0.2.0 → 0.7.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/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,230 @@ 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 — a DIAGNOSTIC, not a traffic metric: whether the site's edge (e.g. Cloudflare Bot Fight Mode) or robots.txt blocks GPTBot/ClaudeBot. 'shielded' means browsers and bots are both challenged (site-wide shield, ambiguous for real, signed crawlers).",
110
+ {},
111
+ run(() => api.crawlerAccess())
112
+ );
113
+
114
+ server.tool(
115
+ "get_analytics_sources",
116
+ "Per-workspace analytics source setup: the selected writer for human traffic and AI-crawler data, which sources the setup qualifies for, and install state (Cloudflare zone/sync, WordPress plugin version, embed liveness). Both Analytics and Reports screens ship disabled until activated.",
117
+ {},
118
+ run(() => api.analyticsSources())
119
+ );
120
+
121
+ server.tool(
122
+ "set_analytics_source",
123
+ "Select the active writer for a data type. Validated server-side against what the customer's setup supports. Switching aiCrawlers to cloudflare starts a backfill sync.",
124
+ {
125
+ dataType: z.enum(["humanTraffic", "aiCrawlers"]),
126
+ source: z
127
+ .string()
128
+ .describe("embed, wordpress_plugin, cloudflare or none"),
129
+ },
130
+ run(({ dataType, source }) => api.setAnalyticsSource(dataType, source))
131
+ );
132
+
133
+ server.tool(
134
+ "activate_analytics_screen",
135
+ "Turn on the Analytics or Reports screen for this workspace (they ship disabled; activation is one-time per screen).",
136
+ { screen: z.enum(["analytics", "reports"]) },
137
+ run(({ screen }) => api.activateScreen(screen))
138
+ );
139
+
140
+ server.tool(
141
+ "get_cloudflare_connect_url",
142
+ "OAuth URL to connect read-only Cloudflare analytics (AI-crawler data source). Open it in a browser; the grant is scoped to Analytics:Read.",
143
+ {},
144
+ run(() => api.cloudflareConnectUrl())
145
+ );
146
+
147
+ server.tool(
148
+ "list_cloudflare_zones",
149
+ "Cloudflare zones visible to the connected grant, for picking which zone to poll.",
150
+ {},
151
+ run(() => api.cloudflareZones())
152
+ );
153
+
154
+ server.tool(
155
+ "select_cloudflare_zone",
156
+ "Pick the Cloudflare zone to poll. Also selects Cloudflare as the AI-crawler source and starts the first sync (backfills up to 7 days).",
157
+ {
158
+ zoneId: z.string(),
159
+ zoneName: z.string().describe("Zone name, e.g. example.com"),
160
+ },
161
+ run(({ zoneId, zoneName }) => api.cloudflareSelectZone(zoneId, zoneName))
162
+ );
163
+
164
+ server.tool(
165
+ "install_framer_embed",
166
+ "Install site-wide visit tracking on the connected Framer project via its custom-code API. Dry run unless confirm is true. Confirming ALSO PUBLISHES the customer's Framer site.",
167
+ {
168
+ integrationId: z.string().optional(),
169
+ confirm: z.boolean().optional().describe("Actually install (default: dry run)"),
170
+ },
171
+ run(({ integrationId, confirm }) =>
172
+ api.framerInstallEmbed({
173
+ ...(integrationId ? { integrationId } : {}),
174
+ confirm: confirm === true,
175
+ })
176
+ )
177
+ );
178
+
179
+ server.tool(
180
+ "install_webflow_custom_code",
181
+ "Register and apply the tracking loader via Webflow's Custom Code API. Dry run unless confirm is true. Needs a site token with CMS + Custom code scopes (pass apiToken to upgrade the stored one). Goes live on the customer's next Webflow site publish.",
182
+ {
183
+ integrationId: z.string().optional(),
184
+ apiToken: z.string().optional().describe("New site token with CMS + Custom code scopes"),
185
+ confirm: z.boolean().optional().describe("Actually install (default: dry run)"),
186
+ },
187
+ run(({ integrationId, apiToken, confirm }) =>
188
+ api.webflowInstallCustomCode({
189
+ ...(integrationId ? { integrationId } : {}),
190
+ ...(apiToken ? { apiToken } : {}),
191
+ confirm: confirm === true,
192
+ })
193
+ )
194
+ );
195
+
196
+ server.tool(
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))
201
+ );
202
+
203
+ server.tool(
204
+ "remove_tracked_query",
205
+ "Delete a query from the tracking pool. Frees its plan slot if it was tracking.",
206
+ { queryId: z.string().describe("Query id from get_tracked_queries") },
207
+ run(({ queryId }) => api.removeQuery(queryId))
208
+ );
209
+
210
+ server.tool(
211
+ "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.",
213
+ {
214
+ queryId: z.string().describe("Query id from get_tracked_queries"),
215
+ tracked: z.boolean().describe("true to track weekly, false to pause"),
216
+ },
217
+ run(({ queryId, tracked }) => api.setQueryTracking(queryId, tracked))
218
+ );
219
+
220
+ server.tool(
221
+ "update_tracked_query",
222
+ "Rewrite a tracked query's text. Future weekly checks use the new text.",
223
+ {
224
+ queryId: z.string().describe("Query id from get_tracked_queries"),
225
+ queryText: z.string().describe("New query text (min 5 characters)"),
226
+ },
227
+ run(({ queryId, queryText }) => api.updateQuery(queryId, queryText))
228
+ );
229
+
230
+ server.tool(
231
+ "get_competitors",
232
+ "Tracked competitors used in share-of-voice and visibility comparisons.",
233
+ {},
234
+ run(() => api.competitors())
235
+ );
236
+
237
+ server.tool(
238
+ "add_competitor",
239
+ "Track a competitor (max 10). They enter weekly share-of-voice comparisons against the org's own citation rate.",
240
+ {
241
+ name: z.string().describe("Competitor name"),
242
+ websiteUrl: z.string().describe("Competitor website URL"),
243
+ },
244
+ run(({ name, websiteUrl }) => api.addCompetitor(name, websiteUrl))
245
+ );
246
+
247
+ server.tool(
248
+ "remove_competitor",
249
+ "Stop tracking a competitor.",
250
+ { competitorId: z.string().describe("Competitor id from get_competitors") },
251
+ run(({ competitorId }) => api.removeCompetitor(competitorId))
252
+ );
253
+
254
+ server.tool(
255
+ "get_topics",
256
+ "The org's pillar list (topic clusters). Tracked queries, content ideas and planned articles all group under these.",
257
+ {},
258
+ run(() => api.topics())
259
+ );
260
+
261
+ server.tool(
262
+ "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.",
264
+ {
265
+ topics: z.array(z.string()).describe("The complete list of topic names"),
266
+ },
267
+ run(({ topics }) => api.setTopics(topics))
268
+ );
269
+
270
+ server.tool(
271
+ "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.",
273
+ { contentId: z.string().describe("Planned contentPage id") },
274
+ run(({ contentId }) => api.deletePlanned(contentId))
275
+ );
276
+
277
+ server.tool(
278
+ "get_traffic_overview",
279
+ "Traffic overview from RankControl's own page analytics: total page views, unique visitors, sessions, bounce rate, and average time on page.",
280
+ { days: z.enum(["7", "30", "90"]).optional().describe("Window in days, default 30") },
281
+ run(({ days }) => api.trafficOverview(days ? Number(days) : 30))
282
+ );
283
+
65
284
  server.tool(
66
285
  "get_planning_capacity",
67
286
  "How many article titles can still be planned onto the content calendar within the scheduling horizon. Check before plan_content.",
@@ -69,6 +288,88 @@ export async function startMcpServer() {
69
288
  run(() => api.planningCapacity())
70
289
  );
71
290
 
291
+ const permLevel = z.enum(["none", "read", "write"]);
292
+ server.tool(
293
+ "get_team",
294
+ "Workspace members and pending invites, with each member's per-screen permission matrix and the seat cap.",
295
+ {},
296
+ run(() => api.team())
297
+ );
298
+
299
+ server.tool(
300
+ "invite_team_member",
301
+ "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.",
302
+ {
303
+ email: z.string().describe("Invitee email address"),
304
+ screenPermissions: z
305
+ .object({
306
+ overview: permLevel.optional(),
307
+ content: permLevel.optional(),
308
+ visibility: permLevel.optional(),
309
+ brandControl: permLevel.optional(),
310
+ linkControl: permLevel.optional(),
311
+ analytics: permLevel.optional(),
312
+ reports: permLevel.optional(),
313
+ })
314
+ .optional(),
315
+ confirm: z
316
+ .boolean()
317
+ .optional()
318
+ .describe("Set true to actually send the invite (default dry-run)"),
319
+ },
320
+ run((args) => api.teamInvite(args))
321
+ );
322
+
323
+ server.tool(
324
+ "revoke_team_invite",
325
+ "Revoke a pending team invite by invitation id (from get_team).",
326
+ { invitationId: z.string() },
327
+ run(({ invitationId }) => api.teamRevoke(invitationId))
328
+ );
329
+
330
+ server.tool(
331
+ "remove_team_member",
332
+ "Remove a member from the workspace (access revoked immediately). Dry-run unless confirm is true.",
333
+ {
334
+ userId: z.string().describe("Member user id from get_team"),
335
+ confirm: z.boolean().optional(),
336
+ },
337
+ run((args) => api.teamRemove(args))
338
+ );
339
+
340
+ server.tool(
341
+ "list_outreach_prospects",
342
+ "Link outreach pipeline: prospect sites per published article with status (identified → contacted → replied → link placed) and any contact email already found.",
343
+ {},
344
+ run(() => api.outreachProspects())
345
+ );
346
+
347
+ server.tool(
348
+ "find_outreach_contact",
349
+ "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.",
350
+ { backlinkId: z.string().describe("Prospect id from list_outreach_prospects") },
351
+ run(({ backlinkId }) => api.outreachFindContact(backlinkId))
352
+ );
353
+
354
+ server.tool(
355
+ "draft_outreach_reply",
356
+ "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.",
357
+ { backlinkId: z.string().describe("Prospect id from list_outreach_prospects") },
358
+ run(({ backlinkId }) => api.outreachDraftReply(backlinkId))
359
+ );
360
+
361
+ server.tool(
362
+ "queue_outreach_email",
363
+ "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.",
364
+ {
365
+ backlinkId: z.string().describe("Prospect id from list_outreach_prospects"),
366
+ subject: z.string().optional().describe("Override the drafted subject"),
367
+ body: z.string().optional().describe("Override the drafted body"),
368
+ confirm: z.boolean().optional().describe("Set true to actually queue the send"),
369
+ },
370
+ run((args) => api.outreachQueue({ ...args, confirm: args.confirm === true }))
371
+ );
372
+
72
373
  server.tool(
73
374
  "list_jobs",
74
375
  "Recent agent runs (job status feed). Async actions like content planning show up here with their outputs.",
@@ -117,6 +418,437 @@ export async function startMcpServer() {
117
418
  run(({ contentId, confirm }) => api.publishContent(contentId, confirm === true))
118
419
  );
119
420
 
421
+ server.tool(
422
+ "generate_article",
423
+ "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.",
424
+ {
425
+ contentId: z.string().describe("The planned content page id"),
426
+ confirm: z.boolean().optional().describe("Set true to actually start generation (default: dry run)"),
427
+ },
428
+ run(({ contentId, confirm }) => api.generateContent(contentId, confirm === true))
429
+ );
430
+
431
+ server.tool(
432
+ "shopify_install_url",
433
+ "Mint an install link for the RankControl Shopify app. A human opens it in a browser and approves the install in Shopify; the Shopify destination is then added to the workspace automatically. The link expires in 10 minutes and minting it has no side effect on its own.",
434
+ {
435
+ shop: z.string().describe("The store's .myshopify.com domain"),
436
+ },
437
+ run(({ shop }) => api.shopifyInstallUrl(shop))
438
+ );
439
+
440
+ server.tool(
441
+ "get_article_settings",
442
+ "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.",
443
+ {},
444
+ run(() => api.articleSettings())
445
+ );
446
+
447
+ const articleSettingsShape = {
448
+ 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"),
450
+ includeInfographics: z.boolean().optional().describe("AI hero + section images"),
451
+ titleInHeroImage: z.boolean().optional().describe("Write the post title into the hero image"),
452
+ includeSectionInfographics: z.boolean().optional().describe("Informational panels in 2,000+ word articles"),
453
+ includeRelatedReading: z.boolean().optional().describe("Related Reading link block at the end"),
454
+ includeYouTube: z.boolean().optional(),
455
+ useEmojis: z.boolean().optional(),
456
+ internalLinksPerArticle: z.number().min(1).max(20).optional(),
457
+ externalLinksPerArticle: z.number().min(0).max(15).optional(),
458
+ globalInstructions: z.string().optional(),
459
+ };
460
+
461
+ server.tool(
462
+ "update_article_settings",
463
+ "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).",
464
+ {
465
+ articleSettings: z.object(articleSettingsShape).optional(),
466
+ flexibleScheduling: z.boolean().optional(),
467
+ styleSet: z
468
+ .enum([
469
+ "classic-editorial",
470
+ "print-craft",
471
+ "storybook-painterly",
472
+ "modern-saas",
473
+ "dark-premium",
474
+ "bold-poster",
475
+ ])
476
+ .optional()
477
+ .describe("Image style set for all generated images"),
478
+ },
479
+ run((args) => api.updateArticleSettings(args))
480
+ );
481
+
482
+ server.tool(
483
+ "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.",
485
+ {
486
+ contentId: z.string(),
487
+ targetDayStartMs: z.number().describe("Epoch ms of the target day's local midnight"),
488
+ },
489
+ run(({ contentId, targetDayStartMs }) => api.reschedule(contentId, targetDayStartMs))
490
+ );
491
+
492
+ server.tool(
493
+ "get_internal_links",
494
+ "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.",
495
+ { contentId: z.string().describe("The content page id") },
496
+ run(({ contentId }) => api.internalLinks(contentId))
497
+ );
498
+
499
+ server.tool(
500
+ "list_site_pages",
501
+ "Site pages RankControl links to from inside articles and the Related Reading block (with linkable/alive flags).",
502
+ {},
503
+ run(() => api.sitePages())
504
+ );
505
+
506
+ server.tool(
507
+ "detect_site_links",
508
+ "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.",
509
+ {
510
+ source: z.enum(["sitemap", "blogroot"]),
511
+ url: z.string().describe("Sitemap XML URL or a page URL to crawl"),
512
+ },
513
+ run(({ source, url }) => api.detectSiteLinks(source, url))
514
+ );
515
+
516
+ server.tool(
517
+ "add_site_pages",
518
+ "Add site page URLs for internal linking. Duplicates are skipped; page titles fill in automatically in the background.",
519
+ { urls: z.array(z.string()) },
520
+ run(({ urls }) => api.addSitePages(urls))
521
+ );
522
+
523
+ server.tool(
524
+ "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.",
526
+ {},
527
+ run(() => api.repurposeQueue())
528
+ );
529
+
530
+ server.tool(
531
+ "get_repurpose_drafts",
532
+ "Full social drafts (title, body, images, status) for one published article across the 8 platforms (LinkedIn, X/Twitter, Pinterest, Instagram, Facebook, Threads, YouTube, TikTok).",
533
+ { contentId: z.string().describe("The content page id") },
534
+ run(({ contentId }) => api.repurposeDrafts(contentId))
535
+ );
536
+
537
+ server.tool(
538
+ "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 (spends LLM budget).",
540
+ {
541
+ contentId: z.string(),
542
+ platforms: z
543
+ .array(
544
+ z.enum([
545
+ "linkedin",
546
+ "twitter",
547
+ "pinterest",
548
+ "instagram",
549
+ "facebook",
550
+ "threads",
551
+ "youtube",
552
+ "tiktok",
553
+ ])
554
+ )
555
+ .optional()
556
+ .describe("Subset of platforms; omit for the workspace default"),
557
+ confirm: z.boolean().optional(),
558
+ },
559
+ run((args) => api.repurposeGenerate(args))
560
+ );
561
+
562
+ server.tool(
563
+ "update_repurpose_draft",
564
+ "Edit a repurpose draft's body and/or title before pushing or posting.",
565
+ {
566
+ draftId: z.string(),
567
+ body: z.string().optional(),
568
+ title: z.string().optional(),
569
+ },
570
+ run((args) => api.repurposeEditDraft(args))
571
+ );
572
+
573
+ server.tool(
574
+ "mark_repurpose_posted",
575
+ "Mark a repurpose draft as posted — use after publishing its text manually (outside Postiz).",
576
+ { draftId: z.string() },
577
+ run(({ draftId }) => api.repurposeMarkPosted(draftId))
578
+ );
579
+
580
+ server.tool(
581
+ "list_postiz_channels",
582
+ "Connected Postiz social channels (id, name, platform). Channel ids are required by push_repurpose_draft.",
583
+ {},
584
+ run(() => api.repurposeChannels())
585
+ );
586
+
587
+ server.tool(
588
+ "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.",
590
+ {
591
+ draftId: z.string(),
592
+ integrationIds: z
593
+ .array(z.string())
594
+ .optional()
595
+ .describe(
596
+ "Postiz channel ids from list_postiz_channels (required with confirm)"
597
+ ),
598
+ scheduleType: z
599
+ .enum(["now", "schedule", "draft"])
600
+ .optional()
601
+ .describe("Default now"),
602
+ date: z.string().optional().describe("ISO time for scheduleType=schedule"),
603
+ confirm: z.boolean().optional(),
604
+ },
605
+ run((args) => api.repurposePush(args))
606
+ );
607
+
608
+ server.tool(
609
+ "get_content_ideas",
610
+ "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.",
611
+ {},
612
+ run(() => api.contentIdeas())
613
+ );
614
+
615
+ server.tool(
616
+ "plan_content_idea",
617
+ "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).",
618
+ {
619
+ queryText: z.string().describe("The idea's keyword/query text"),
620
+ title: z.string().optional().describe("Exact headline to plan"),
621
+ },
622
+ run((args) => api.planIdea(args))
623
+ );
624
+
625
+ server.tool(
626
+ "archive_content",
627
+ "Archive an article, removing it from the working set. Reversible in the dashboard.",
628
+ { contentId: z.string() },
629
+ run(({ contentId }) => api.archiveContent(contentId))
630
+ );
631
+
632
+ server.tool(
633
+ "get_page_engagement",
634
+ "Per-page engagement for the last 30 days: views, AI citations, and a view sparkline per published page.",
635
+ {},
636
+ run(() => api.pageEngagement())
637
+ );
638
+
639
+ server.tool(
640
+ "get_exec_summary",
641
+ "Executive report: last 30 days vs the 30 before — cite rate, citations, page views, AI-referred views, crawler hits, and articles published.",
642
+ {},
643
+ run(() => api.reportSummary())
644
+ );
645
+
646
+ server.tool(
647
+ "get_top_wins",
648
+ "Biggest wins of the last 30 days: most-cited page, best query cite-rate jump, biggest Google ranking climb, best new backlink.",
649
+ {},
650
+ run(() => api.reportWins())
651
+ );
652
+
653
+ server.tool(
654
+ "get_agent_activity",
655
+ "Recent pipeline runs per agent lane (brand control, radar, forge, recon, deploy, outreach, sentinel, social).",
656
+ {
657
+ perAgent: z.number().max(50).optional().describe("Runs per lane, default 15"),
658
+ sinceDays: z.number().optional().describe("Window in days, default 30"),
659
+ },
660
+ run((args) => api.agentActivity(args))
661
+ );
662
+
663
+ server.tool(
664
+ "list_backlinks",
665
+ "The workspace's backlink table, newest first.",
666
+ {
667
+ status: z
668
+ .string()
669
+ .optional()
670
+ .describe(
671
+ "Filter: discovered, verified, lost, identified, contacted, replied, link_placed, rejected"
672
+ ),
673
+ },
674
+ run(({ status }) => api.backlinks(status))
675
+ );
676
+
677
+ server.tool(
678
+ "get_backlink_stats",
679
+ "Backlink totals (live, lost, avg DA, unique domains) plus outreach pipeline counts.",
680
+ {},
681
+ run(() => api.backlinkStats())
682
+ );
683
+
684
+ server.tool(
685
+ "update_outreach_status",
686
+ "Move an outreach prospect through the pipeline.",
687
+ {
688
+ backlinkId: z.string(),
689
+ status: z.enum(["identified", "contacted", "replied", "link_placed", "rejected"]),
690
+ },
691
+ run(({ backlinkId, status }) => api.outreachStatus(backlinkId, status))
692
+ );
693
+
694
+ server.tool(
695
+ "get_link_network",
696
+ "Link Network state: credit balance/earn/spend, membership (optedIn), and hosted/received placements with partners and anchors.",
697
+ {},
698
+ run(() => api.linkNetwork())
699
+ );
700
+
701
+ server.tool(
702
+ "set_link_network_opt_in",
703
+ "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.",
704
+ { optIn: z.boolean(), confirm: z.boolean().optional() },
705
+ run(({ optIn, confirm }) => api.linkNetworkOptIn(optIn, confirm))
706
+ );
707
+
708
+ server.tool(
709
+ "remove_network_placement",
710
+ "Retire one Link Network placement. VISIBLE on the partner site: defaults to a DRY RUN; call again with confirm=true after human approval.",
711
+ { placementId: z.string(), confirm: z.boolean().optional() },
712
+ run(({ placementId, confirm }) => api.linkNetworkRemovePlacement(placementId, confirm))
713
+ );
714
+
715
+ server.tool(
716
+ "list_social_threads",
717
+ "Social thread prospects (Reddit/X) where the brand's articles could earn a mention, with subreddit rules context, sorted by intent.",
718
+ {
719
+ platform: z.enum(["reddit", "twitter"]).optional(),
720
+ status: z.string().optional(),
721
+ age: z.enum(["ranked", "new"]).optional(),
722
+ },
723
+ run((args) => api.socialThreads(args))
724
+ );
725
+
726
+ server.tool(
727
+ "get_social_stats",
728
+ "Social thread pipeline counts.",
729
+ {},
730
+ run(() => api.socialStats())
731
+ );
732
+
733
+ server.tool(
734
+ "update_social_thread_status",
735
+ "Move a social thread through the pipeline (e.g. dismissed, replied).",
736
+ { threadId: z.string(), status: z.string() },
737
+ run(({ threadId, status }) => api.socialStatus(threadId, status))
738
+ );
739
+
740
+ server.tool(
741
+ "draft_social_reply",
742
+ "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.",
743
+ {
744
+ threadId: z.string(),
745
+ mentionMode: z
746
+ .enum(["none", "natural", "founderOpen"])
747
+ .optional()
748
+ .describe("Brand-mention mode; warm-up accounts are clamped to none"),
749
+ },
750
+ run(({ threadId, mentionMode }) => api.socialDraftReply(threadId, mentionMode))
751
+ );
752
+
753
+ server.tool(
754
+ "contact_support",
755
+ "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.",
756
+ {
757
+ subject: z.string(),
758
+ message: z.string().describe("Details: what happened, errors, ids, steps to reproduce"),
759
+ pageUrl: z.string().optional().describe("Related dashboard or API URL"),
760
+ },
761
+ run((args) => api.support(args))
762
+ );
763
+
764
+ server.tool(
765
+ "get_brand",
766
+ "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.",
767
+ {},
768
+ run(() => api.brand())
769
+ );
770
+
771
+ server.tool(
772
+ "update_brand_profile",
773
+ "Patch brand voice/style fields: tone, colors, fontFamily, metaTitle/Description/Keywords, defaultLocale, flexibleScheduling. Only provided fields change; they steer all future article generation.",
774
+ {
775
+ tone: z.string().optional(),
776
+ primaryColor: z.string().optional(),
777
+ secondaryColor: z.string().optional(),
778
+ fontFamily: z.string().optional(),
779
+ metaTitle: z.string().optional(),
780
+ metaDescription: z.string().optional(),
781
+ metaKeywords: z.string().optional(),
782
+ defaultLocale: z.string().optional().describe("BCP-47 tag from the supported registry"),
783
+ flexibleScheduling: z.boolean().optional(),
784
+ },
785
+ run((args) => api.brandProfileSet(args))
786
+ );
787
+
788
+ server.tool(
789
+ "update_brand_identity",
790
+ "Update workspace identity: name, industry, productDescription, authors (EEAT bylines used in article JSON-LD), styleReferenceUrls (articles whose writing style the writer imitates). authors and styleReferenceUrls REPLACE the stored list — call get_brand first and send the full updated list. websiteUrl is immutable and cannot be changed.",
791
+ {
792
+ name: z.string().optional(),
793
+ industry: z.string().optional(),
794
+ productDescription: z.string().optional(),
795
+ authors: z
796
+ .array(
797
+ z.object({
798
+ name: z.string(),
799
+ bio: z.string().optional(),
800
+ linkedinUrl: z
801
+ .string()
802
+ .optional()
803
+ .describe("Person sameAs URL for article JSON-LD"),
804
+ })
805
+ )
806
+ .optional(),
807
+ styleReferenceUrls: z.array(z.string()).optional(),
808
+ },
809
+ run((args) => api.brandIdentitySet(args))
810
+ );
811
+
812
+ server.tool(
813
+ "write_brand_product",
814
+ "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.",
815
+ {
816
+ productId: z.string().optional(),
817
+ del: z.boolean().optional(),
818
+ name: z.string().optional(),
819
+ description: z.string().optional(),
820
+ url: z.string().optional(),
821
+ features: z.array(z.string()).optional(),
822
+ pricing: z.string().optional(),
823
+ category: z.string().optional(),
824
+ usps: z.array(z.string()).optional(),
825
+ isActive: z.boolean().optional(),
826
+ },
827
+ run((args) => api.brandProductWrite(args))
828
+ );
829
+
830
+ server.tool(
831
+ "write_brand_icp",
832
+ "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.",
833
+ {
834
+ profileId: z.string().optional(),
835
+ del: z.boolean().optional(),
836
+ title: z.string().optional(),
837
+ industry: z.string().optional(),
838
+ companySize: z.string().optional(),
839
+ painPoints: z.array(z.string()).optional(),
840
+ buyingTriggers: z.array(z.string()).optional(),
841
+ objections: z.array(z.string()).optional(),
842
+ preferredChannels: z.array(z.string()).optional(),
843
+ demographics: z
844
+ .record(z.string(), z.unknown())
845
+ .optional()
846
+ .describe("Demographics object; required on create (same shape the dashboard saves)"),
847
+ isActive: z.boolean().optional(),
848
+ },
849
+ run((args) => api.brandIcpWrite(args))
850
+ );
851
+
120
852
  await server.connect(new StdioServerTransport());
121
853
  // Keep the process alive; the transport owns stdin/stdout from here
122
854
  console.error("rankcontrol MCP server running on stdio");