rankcontrol 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Infowick
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # rankcontrol
2
+
3
+ RankControl from the terminal or any AI agent. One package, two faces:
4
+
5
+ - **CLI** — `npx rankcontrol <command>` (or the short alias `rctrl` once installed)
6
+ - **MCP server** — `npx rankcontrol mcp` (stdio, for Claude Code, Claude Desktop, Cursor, Codex)
7
+
8
+ Every command calls the same authed, rate-limited, org-scoped API the RankControl dashboard uses. Quotas and approval gates apply server-side no matter which client calls.
9
+
10
+ ## Auth
11
+
12
+ Create an API key in **RankControl → Settings → API**, then:
13
+
14
+ ```bash
15
+ export RANKCONTROL_API_KEY=rctrl_pk_...
16
+ ```
17
+
18
+ Scopes are set per key. Read-only keys work for all `get_*`/`list_*` tools.
19
+
20
+ ## CLI
21
+
22
+ ```bash
23
+ npx rankcontrol funnel # AI crawls / AI visits / AI leads (30d)
24
+ npx rankcontrol visibility --days 60 # daily AI visibility score trend
25
+ npx rankcontrol citations --model chatgpt
26
+ npx rankcontrol capacity # remaining content-plan slots
27
+ npx rankcontrol plan-content --topics "bus travel" --max-difficulty 40
28
+ npx rankcontrol commit-titles --file titles.json
29
+ npx rankcontrol publish <contentId> # dry run
30
+ npx rankcontrol publish <contentId> --confirm # actually publish
31
+ npx rankcontrol jobs # async job status (agent runs)
32
+ ```
33
+
34
+ ## MCP
35
+
36
+ Claude Code:
37
+
38
+ ```bash
39
+ claude mcp add rankcontrol --env RANKCONTROL_API_KEY=rctrl_pk_... -- npx rankcontrol mcp
40
+ ```
41
+
42
+ Or in `.mcp.json` / Cursor `mcp.json`:
43
+
44
+ ```json
45
+ {
46
+ "mcpServers": {
47
+ "rankcontrol": {
48
+ "command": "npx",
49
+ "args": ["rankcontrol", "mcp"],
50
+ "env": { "RANKCONTROL_API_KEY": "rctrl_pk_..." }
51
+ }
52
+ }
53
+ }
54
+ ```
55
+
56
+ ### Tools
57
+
58
+ | Tool | What it does |
59
+ |---|---|
60
+ | `get_overview_funnel` | AI crawls / AI visits / AI leads, last 30 days |
61
+ | `get_visibility_trend` | Daily visibility score from stored weekly citation checks |
62
+ | `list_citations` | Recent citation checks per AI model |
63
+ | `get_visibility_score` | Current overall AI visibility score |
64
+ | `get_planning_capacity` | Remaining calendar slots (check before planning) |
65
+ | `plan_content` | Generate candidate titles (capacity-gated, nothing scheduled) |
66
+ | `commit_planned_titles` | Approve reviewed titles onto the calendar |
67
+ | `publish_content` | Dry run by default; `confirm: true` publishes |
68
+ | `list_content` | Content pages with status |
69
+ | `list_jobs` | Async job status (agent runs) |
70
+
71
+ 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.
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { runCli } from "../src/cli.mjs";
3
+
4
+ runCli(process.argv);
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "rankcontrol",
3
+ "version": "0.1.0",
4
+ "description": "RankControl CLI + MCP server: drive your SEO/AI-visibility workspace from the terminal or any AI agent",
5
+ "license": "MIT",
6
+ "homepage": "https://rctrl.com",
7
+ "type": "module",
8
+ "bin": {
9
+ "rankcontrol": "bin/rankcontrol.mjs",
10
+ "rctrl": "bin/rankcontrol.mjs"
11
+ },
12
+ "files": [
13
+ "bin",
14
+ "src",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "engines": {
19
+ "node": ">=18"
20
+ },
21
+ "dependencies": {
22
+ "@modelcontextprotocol/sdk": "^1.12.0",
23
+ "commander": "^12.1.0",
24
+ "zod": "^3.24.0"
25
+ },
26
+ "keywords": [
27
+ "seo",
28
+ "aeo",
29
+ "ai-visibility",
30
+ "mcp",
31
+ "model-context-protocol",
32
+ "rankcontrol"
33
+ ]
34
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,118 @@
1
+ import { Command } from "commander";
2
+ import { readFileSync } from "node:fs";
3
+ import { api } from "./client.mjs";
4
+
5
+ const out = (data) => console.log(JSON.stringify(data, null, 2));
6
+
7
+ const fail = (err) => {
8
+ console.error(`Error: ${err?.message ?? String(err)}`);
9
+ process.exit(1);
10
+ };
11
+
12
+ const list = (val) => val.split(",").map((s) => s.trim()).filter(Boolean);
13
+
14
+ export function runCli(argv) {
15
+ const program = new Command();
16
+ program
17
+ .name("rankcontrol")
18
+ .description(
19
+ "RankControl from the terminal. Auth: export RANKCONTROL_API_KEY=rctrl_pk_... (Settings → API)"
20
+ )
21
+ .version("0.1.0");
22
+
23
+ program
24
+ .command("mcp")
25
+ .description("Start the MCP server on stdio (for Claude, Cursor, and other agents)")
26
+ .action(async () => {
27
+ const { startMcpServer } = await import("./mcp.mjs");
28
+ await startMcpServer().catch(fail);
29
+ });
30
+
31
+ program
32
+ .command("funnel")
33
+ .description("AI pipeline last 30 days: crawls, AI visits, AI leads")
34
+ .action(() => api.overviewFunnel().then(out).catch(fail));
35
+
36
+ program
37
+ .command("visibility")
38
+ .description("Daily AI visibility score trend from the stored weekly citation checks")
39
+ .option("--days <n>", "30, 60 or 90", "30")
40
+ .action((opts) => api.visibilityTrend(Number(opts.days)).then(out).catch(fail));
41
+
42
+ program
43
+ .command("citations")
44
+ .description("Recent AI citation checks")
45
+ .option("--model <model>", "Filter by AI model (chatgpt, perplexity, ...)")
46
+ .option("--limit <n>", "Max rows", "50")
47
+ .action((opts) =>
48
+ api
49
+ .citations({ model: opts.model, limit: Number(opts.limit) })
50
+ .then(out)
51
+ .catch(fail)
52
+ );
53
+
54
+ program
55
+ .command("capacity")
56
+ .description("Remaining plan slots on the content calendar")
57
+ .action(() => api.planningCapacity().then(out).catch(fail));
58
+
59
+ program
60
+ .command("jobs")
61
+ .description("Recent agent runs (async job status)")
62
+ .option("--limit <n>", "Max rows", "20")
63
+ .action((opts) => api.jobs(Number(opts.limit)).then(out).catch(fail));
64
+
65
+ program
66
+ .command("content")
67
+ .description("List content pages with status")
68
+ .action(() => api.content().then(out).catch(fail));
69
+
70
+ program
71
+ .command("plan-content")
72
+ .description("Generate candidate titles (nothing is scheduled until commit-titles)")
73
+ .option("--topics <a,b,c>", "Focus topics")
74
+ .option("--content-types <a,b>", "Content types")
75
+ .option("--max-difficulty <n>", "Keyword-difficulty ceiling 0-100")
76
+ .option("--intents <a,b>", "informational,commercial,transactional")
77
+ .option("--citation-gaps", "Target queries where AI cites competitors but not us")
78
+ .option("--ranking-gaps", "Target keywords ranking page 2-3 or slipping")
79
+ .action((opts) =>
80
+ api
81
+ .planContent({
82
+ topics: opts.topics ? list(opts.topics) : undefined,
83
+ contentTypes: opts.contentTypes ? list(opts.contentTypes) : undefined,
84
+ maxDifficulty: opts.maxDifficulty ? Number(opts.maxDifficulty) : undefined,
85
+ intents: opts.intents ? list(opts.intents) : undefined,
86
+ useCitationGaps: opts.citationGaps || undefined,
87
+ useRankingGaps: opts.rankingGaps || undefined,
88
+ })
89
+ .then(out)
90
+ .catch(fail)
91
+ );
92
+
93
+ program
94
+ .command("commit-titles")
95
+ .description("Approve reviewed titles onto the calendar. Reads {titles:[...],discarded?:[...]} JSON")
96
+ .option("--file <path>", "JSON file (defaults to stdin)")
97
+ .action(async (opts) => {
98
+ try {
99
+ const raw = opts.file
100
+ ? readFileSync(opts.file, "utf8")
101
+ : readFileSync(0, "utf8");
102
+ const body = JSON.parse(raw);
103
+ out(await api.commitPlannedTitles(body));
104
+ } catch (err) {
105
+ fail(err);
106
+ }
107
+ });
108
+
109
+ program
110
+ .command("publish <contentId>")
111
+ .description("Publish an article to the connected CMS (dry run unless --confirm)")
112
+ .option("--confirm", "Actually publish (default is a dry run)")
113
+ .action((contentId, opts) =>
114
+ api.publishContent(contentId, !!opts.confirm).then(out).catch(fail)
115
+ );
116
+
117
+ program.parseAsync(argv);
118
+ }
package/src/client.mjs ADDED
@@ -0,0 +1,58 @@
1
+ const DEFAULT_BASE = "https://api.rctrl.com";
2
+
3
+ export function getConfig() {
4
+ const apiKey = process.env.RANKCONTROL_API_KEY;
5
+ if (!apiKey) {
6
+ throw new Error(
7
+ "RANKCONTROL_API_KEY is not set. Create a key in RankControl → Settings → API, then export RANKCONTROL_API_KEY=rctrl_pk_..."
8
+ );
9
+ }
10
+ return {
11
+ apiKey,
12
+ baseUrl: (process.env.RANKCONTROL_API_URL || DEFAULT_BASE).replace(/\/$/, ""),
13
+ };
14
+ }
15
+
16
+ async function request(method, path, body) {
17
+ const { apiKey, baseUrl } = getConfig();
18
+ const res = await fetch(`${baseUrl}${path}`, {
19
+ method,
20
+ headers: {
21
+ Authorization: `Bearer ${apiKey}`,
22
+ ...(body !== undefined ? { "Content-Type": "application/json" } : {}),
23
+ },
24
+ body: body !== undefined ? JSON.stringify(body) : undefined,
25
+ });
26
+ let json;
27
+ try {
28
+ json = await res.json();
29
+ } catch {
30
+ throw new Error(`RankControl API returned a non-JSON response (HTTP ${res.status})`);
31
+ }
32
+ if (!res.ok) {
33
+ throw new Error(json?.error || `RankControl API error (HTTP ${res.status})`);
34
+ }
35
+ return json.data ?? json;
36
+ }
37
+
38
+ export const api = {
39
+ overviewFunnel: () => request("GET", "/api/v1/overview/funnel"),
40
+ visibilityTrend: (days = 30) =>
41
+ request("GET", `/api/v1/visibility/trend?days=${days}`),
42
+ citations: (params = {}) => {
43
+ const q = new URLSearchParams();
44
+ if (params.model) q.set("model", params.model);
45
+ if (params.limit) q.set("limit", String(params.limit));
46
+ const qs = q.toString();
47
+ return request("GET", `/api/v1/citations${qs ? `?${qs}` : ""}`);
48
+ },
49
+ visibilityScore: () => request("GET", "/api/v1/visibility/score"),
50
+ planningCapacity: () => request("GET", "/api/v1/content/planning-capacity"),
51
+ jobs: (limit = 20) => request("GET", `/api/v1/jobs?limit=${limit}`),
52
+ content: () => request("GET", "/api/v1/content"),
53
+ planContent: (opts) => request("POST", "/api/v1/content/plan", opts),
54
+ commitPlannedTitles: (opts) =>
55
+ request("POST", "/api/v1/content/plan/commit", opts),
56
+ publishContent: (contentId, confirm = false) =>
57
+ request("POST", "/api/v1/content/publish", { contentId, confirm }),
58
+ };
package/src/mcp.mjs ADDED
@@ -0,0 +1,123 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { z } from "zod";
4
+ import { api } from "./client.mjs";
5
+
6
+ const asText = (data) => ({
7
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
8
+ });
9
+
10
+ const asError = (err) => ({
11
+ content: [{ type: "text", text: `Error: ${err?.message ?? String(err)}` }],
12
+ isError: true,
13
+ });
14
+
15
+ const run = (fn) => async (args) => {
16
+ try {
17
+ return asText(await fn(args));
18
+ } catch (err) {
19
+ return asError(err);
20
+ }
21
+ };
22
+
23
+ const plannedTitle = z.object({
24
+ title: z.string(),
25
+ targetKeyword: z.string().optional(),
26
+ searchVolume: z.number().optional(),
27
+ keywordDifficulty: z.number().optional(),
28
+ searchIntent: z.string().optional(),
29
+ });
30
+
31
+ export async function startMcpServer() {
32
+ const server = new McpServer({ name: "rankcontrol", version: "0.1.0" });
33
+
34
+ server.tool(
35
+ "get_overview_funnel",
36
+ "AI pipeline overview for the last 30 days: AI crawler hits, visits referred by AI engines, and leads that came from AI search.",
37
+ {},
38
+ run(() => api.overviewFunnel())
39
+ );
40
+
41
+ server.tool(
42
+ "get_visibility_trend",
43
+ "Daily AI visibility score series (share of weekly citation checks where the brand was cited across the 6 tracked AI engines). Data comes from RankControl's stored weekly checks.",
44
+ { days: z.enum(["30", "60", "90"]).optional().describe("Window size in days, default 30") },
45
+ run(({ days }) => api.visibilityTrend(days ? Number(days) : 30))
46
+ );
47
+
48
+ server.tool(
49
+ "list_citations",
50
+ "Recent AI citation checks: which tracked queries were checked on which AI model and whether the brand was cited.",
51
+ {
52
+ model: z.string().optional().describe("Filter by AI model, e.g. chatgpt, perplexity"),
53
+ limit: z.number().max(200).optional(),
54
+ },
55
+ run((args) => api.citations(args))
56
+ );
57
+
58
+ server.tool(
59
+ "get_visibility_score",
60
+ "Current overall AI visibility score for the workspace.",
61
+ {},
62
+ run(() => api.visibilityScore())
63
+ );
64
+
65
+ server.tool(
66
+ "get_planning_capacity",
67
+ "How many article titles can still be planned onto the content calendar within the scheduling horizon. Check before plan_content.",
68
+ {},
69
+ run(() => api.planningCapacity())
70
+ );
71
+
72
+ server.tool(
73
+ "list_jobs",
74
+ "Recent agent runs (job status feed). Async actions like content planning show up here with their outputs.",
75
+ { limit: z.number().max(100).optional() },
76
+ run(({ limit }) => api.jobs(limit ?? 20))
77
+ );
78
+
79
+ server.tool(
80
+ "list_content",
81
+ "List content pages in the workspace with their status (planned, draft, published).",
82
+ {},
83
+ run(() => api.content())
84
+ );
85
+
86
+ server.tool(
87
+ "plan_content",
88
+ "Generate candidate article titles (capacity-gated). This DOES NOT put anything on the calendar: review the returned titles, then call commit_planned_titles with the keepers. Costs an LLM call; capped at 5/min.",
89
+ {
90
+ topics: z.array(z.string()).optional().describe("Brand topics to focus on; omit for auto"),
91
+ contentTypes: z.array(z.string()).optional(),
92
+ maxDifficulty: z.number().min(0).max(100).optional().describe("Keyword-difficulty ceiling"),
93
+ intents: z.array(z.string()).optional().describe("informational / commercial / transactional"),
94
+ useCitationGaps: z.boolean().optional().describe("Target queries where AI cites competitors but not us"),
95
+ useRankingGaps: z.boolean().optional().describe("Target keywords ranking page 2-3 or slipping"),
96
+ },
97
+ run((args) => api.planContent(args))
98
+ );
99
+
100
+ server.tool(
101
+ "commit_planned_titles",
102
+ "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.",
103
+ {
104
+ titles: z.array(plannedTitle).describe("Titles to schedule"),
105
+ discarded: z.array(plannedTitle).optional().describe("Rejected candidates"),
106
+ },
107
+ run((args) => api.commitPlannedTitles(args))
108
+ );
109
+
110
+ server.tool(
111
+ "publish_content",
112
+ "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.",
113
+ {
114
+ contentId: z.string().describe("The content page id"),
115
+ confirm: z.boolean().optional().describe("Set true to actually publish (default: dry run)"),
116
+ },
117
+ run(({ contentId, confirm }) => api.publishContent(contentId, confirm === true))
118
+ );
119
+
120
+ await server.connect(new StdioServerTransport());
121
+ // Keep the process alive; the transport owns stdin/stdout from here
122
+ console.error("rankcontrol MCP server running on stdio");
123
+ }