@rankcli/mcp-server 0.0.8 → 0.0.9

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.
@@ -5,15 +5,122 @@ import {
5
5
  ListToolsRequestSchema
6
6
  } from "@modelcontextprotocol/sdk/types.js";
7
7
  import { analyzers } from "@rankcli/agent-runtime";
8
- async function fetchHtml(url) {
9
- const res = await fetch(url, {
10
- headers: { "User-Agent": "RankCLI/1.0 (+https://rankcli.dev)" }
11
- });
8
+
9
+ // src/fetch-guard.ts
10
+ import { lookup } from "dns/promises";
11
+ import { isIP } from "net";
12
+ var USER_AGENT = "RankCLI/1.0 (+https://rankcli.dev)";
13
+ var MAX_REDIRECTS = 5;
14
+ var TIMEOUT_MS = 15e3;
15
+ function ipv4ToInt(ip) {
16
+ return ip.split(".").reduce((acc, part) => (acc << 8) + Number(part), 0) >>> 0;
17
+ }
18
+ function inV4(ip, cidr) {
19
+ const [base, bits] = cidr.split("/");
20
+ const mask = Number(bits) === 0 ? 0 : ~0 << 32 - Number(bits) >>> 0;
21
+ return (ipv4ToInt(ip) & mask) === (ipv4ToInt(base) & mask);
22
+ }
23
+ var BLOCKED_V4 = [
24
+ "0.0.0.0/8",
25
+ "10.0.0.0/8",
26
+ "100.64.0.0/10",
27
+ "127.0.0.0/8",
28
+ "169.254.0.0/16",
29
+ "172.16.0.0/12",
30
+ "192.0.0.0/24",
31
+ "192.168.0.0/16",
32
+ "198.18.0.0/15",
33
+ "224.0.0.0/4",
34
+ "240.0.0.0/4"
35
+ ];
36
+ function isPrivateAddress(ip) {
37
+ const version = isIP(ip);
38
+ if (version === 4) return BLOCKED_V4.some((cidr) => inV4(ip, cidr));
39
+ if (version === 6) {
40
+ const v6 = ip.toLowerCase();
41
+ const mapped = v6.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
42
+ if (mapped) return isPrivateAddress(mapped[1]);
43
+ if (v6 === "::" || v6 === "::1") return true;
44
+ const first = parseInt(v6.split(":")[0] || "0", 16);
45
+ if ((first & 65024) === 64512) return true;
46
+ if ((first & 65472) === 65152) return true;
47
+ if ((first & 65280) === 65280) return true;
48
+ return false;
49
+ }
50
+ return true;
51
+ }
52
+ async function assertPublicUrl(url) {
53
+ const host = url.hostname.replace(/^\[|\]$/g, "");
54
+ if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".internal") || host.endsWith(".local")) {
55
+ throw new Error(`Refusing to fetch ${url.origin}: private network address`);
56
+ }
57
+ const addresses = isIP(host) ? [{ address: host }] : await lookup(host, { all: true, verbatim: true });
58
+ if (addresses.length === 0 || addresses.some((a) => isPrivateAddress(a.address))) {
59
+ throw new Error(`Refusing to fetch ${url.origin}: private network address`);
60
+ }
61
+ }
62
+ async function guardedFetch(rawUrl, options = {}) {
63
+ let url;
64
+ try {
65
+ url = new URL(rawUrl);
66
+ } catch {
67
+ throw new Error(`Invalid URL: ${rawUrl}`);
68
+ }
69
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
70
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
71
+ throw new Error(`Refusing to fetch ${url.protocol} URL - only http(s) is supported`);
72
+ }
73
+ if (options.blockPrivateNetworks) await assertPublicUrl(url);
74
+ const response = await fetch(url, {
75
+ headers: { "User-Agent": USER_AGENT },
76
+ redirect: options.blockPrivateNetworks ? "manual" : "follow",
77
+ signal: AbortSignal.timeout(TIMEOUT_MS)
78
+ });
79
+ const location = response.headers.get("location");
80
+ if (options.blockPrivateNetworks && response.status >= 300 && response.status < 400 && location) {
81
+ url = new URL(location, url);
82
+ continue;
83
+ }
84
+ return response;
85
+ }
86
+ throw new Error(`Too many redirects fetching ${rawUrl}`);
87
+ }
88
+
89
+ // src/server.ts
90
+ async function fetchHtml(url, options = {}) {
91
+ const res = await guardedFetch(url, options);
12
92
  if (!res.ok) {
13
93
  throw new Error(`Fetching ${url} returned ${res.status} ${res.statusText}`);
14
94
  }
15
95
  return res.text();
16
96
  }
97
+ async function fetchRobotsTxt(pageUrl, options = {}) {
98
+ let robotsUrl;
99
+ try {
100
+ robotsUrl = new URL("/robots.txt", pageUrl).href;
101
+ } catch {
102
+ return { note: "robots.txt not checked: invalid URL." };
103
+ }
104
+ try {
105
+ const res = await guardedFetch(robotsUrl, options);
106
+ if (res.ok) {
107
+ return { content: await res.text(), note: `robots.txt fetched from ${robotsUrl}.` };
108
+ }
109
+ if (res.status >= 500 || res.status === 429) {
110
+ return {
111
+ unknown: true,
112
+ note: `robots.txt returned HTTP ${res.status}, so crawler access is unknown - Google treats a 5xx robots.txt as "disallow everything" until it answers.`
113
+ };
114
+ }
115
+ return { note: `No robots.txt (HTTP ${res.status}) - every crawler is allowed by default.` };
116
+ } catch (error) {
117
+ return { unknown: true, note: `robots.txt could not be fetched (${error instanceof Error ? error.message : String(error)}), so crawler access is unknown.` };
118
+ }
119
+ }
120
+ async function resolveRobotsTxt(url, provided, options) {
121
+ if (provided !== void 0) return { content: provided, note: "robots.txt as provided." };
122
+ return fetchRobotsTxt(url, options);
123
+ }
17
124
  var ANALYSIS_TOOLS = [
18
125
  {
19
126
  name: "seo_analyze",
@@ -40,7 +147,7 @@ Use this for a complete SEO audit.`,
40
147
  },
41
148
  robotsTxt: {
42
149
  type: "string",
43
- description: "robots.txt content for AI crawler analysis (optional)"
150
+ description: "robots.txt content (optional - fetched from the site when omitted)"
44
151
  }
45
152
  },
46
153
  required: ["url"]
@@ -68,11 +175,11 @@ Critical for visibility in ChatGPT, Perplexity, Claude, and Gemini responses.`,
68
175
  },
69
176
  html: {
70
177
  type: "string",
71
- description: "HTML content of the page"
178
+ description: "HTML content of the page (optional - fetched from the URL when omitted)"
72
179
  },
73
180
  robotsTxt: {
74
181
  type: "string",
75
- description: "robots.txt content"
182
+ description: "robots.txt content (optional - fetched from the site when omitted)"
76
183
  }
77
184
  },
78
185
  required: ["url"]
@@ -318,7 +425,9 @@ ${result.prioritizedRecommendations.map((r, i) => `${i + 1}. ${r}`).join("\n")}
318
425
  ${BRIDGE_FOOTER}
319
426
  `;
320
427
  }
321
- function formatGEOResult(result) {
428
+ function formatGEOResult(result, robots) {
429
+ const access = robots.unknown ? `| \u2754 Unknown | ${robots.note} |` : `| \u2705 Allowed | ${result.aiCrawlerAccess.allowedCrawlers.join(", ") || "None"} |
430
+ | \u274C Blocked | ${result.aiCrawlerAccess.blockedCrawlers.join(", ") || "None"} |`;
322
431
  return `# GEO Analysis (AI Search Optimization)
323
432
 
324
433
  **Score:** ${result.score}/100
@@ -327,8 +436,9 @@ function formatGEOResult(result) {
327
436
 
328
437
  | Status | Crawlers |
329
438
  |--------|----------|
330
- | \u2705 Allowed | ${result.aiCrawlerAccess.allowedCrawlers.join(", ") || "None"} |
331
- | \u274C Blocked | ${result.aiCrawlerAccess.blockedCrawlers.join(", ") || "None"} |
439
+ ${access}
440
+
441
+ _${robots.note}_
332
442
 
333
443
  **Server-Side Rendered:** ${result.aiCrawlerAccess.serverSideRendered ? "\u2705 Yes" : "\u274C No"}
334
444
  **JS Rendering Required:** ${result.aiCrawlerAccess.jsRenderingRequired ? "\u26A0\uFE0F Yes (AI crawlers may not see content)" : "\u2705 No"}
@@ -539,19 +649,27 @@ Allow: /
539
649
  \`\`\`
540
650
  `;
541
651
  }
542
- async function handleAnalysisTool(name, args) {
652
+ async function handleAnalysisTool(name, args, options = {}) {
543
653
  switch (name) {
544
654
  case "seo_analyze": {
545
655
  const { url, html: providedHtml, robotsTxt } = args;
546
- const html = providedHtml ?? await fetchHtml(url);
547
- const result = await analyzers.analyzeComprehensive(html, url, { robotsTxt });
548
- return { content: [{ type: "text", text: formatComprehensiveResult(result) }] };
656
+ const [html, robots] = await Promise.all([
657
+ providedHtml ?? fetchHtml(url, options),
658
+ resolveRobotsTxt(url, robotsTxt, options)
659
+ ]);
660
+ const result = await analyzers.analyzeComprehensive(html, url, { robotsTxt: robots.content });
661
+ return { content: [{ type: "text", text: `${formatComprehensiveResult(result)}
662
+ _${robots.note}_
663
+ ` }] };
549
664
  }
550
665
  case "seo_geo_check": {
551
666
  const { url, html: providedHtml, robotsTxt } = args;
552
- const html = providedHtml ?? await fetchHtml(url);
553
- const result = await analyzers.analyzeGEO(html, url, robotsTxt);
554
- return { content: [{ type: "text", text: formatGEOResult(result) }] };
667
+ const [html, robots] = await Promise.all([
668
+ providedHtml ?? fetchHtml(url, options),
669
+ resolveRobotsTxt(url, robotsTxt, options)
670
+ ]);
671
+ const result = await analyzers.analyzeGEO(html, url, robots.content);
672
+ return { content: [{ type: "text", text: formatGEOResult(result, robots) }] };
555
673
  }
556
674
  case "seo_robots_ai": {
557
675
  const { robotsTxt } = args;
@@ -652,7 +770,7 @@ ${Object.entries(headers).map(([k, v]) => `**${k}:**
652
770
  return void 0;
653
771
  }
654
772
  }
655
- function createAnalysisServer() {
773
+ function createAnalysisServer(options = {}) {
656
774
  const server = new Server(
657
775
  { name: "rankcli", version: "0.0.1" },
658
776
  { capabilities: { tools: {} } }
@@ -663,7 +781,7 @@ function createAnalysisServer() {
663
781
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
664
782
  const { name, arguments: args } = request.params;
665
783
  try {
666
- const result = await handleAnalysisTool(name, args);
784
+ const result = await handleAnalysisTool(name, args, options);
667
785
  if (result) return result;
668
786
  return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
669
787
  } catch (error) {
@@ -677,7 +795,9 @@ function createAnalysisServer() {
677
795
  }
678
796
 
679
797
  export {
798
+ isPrivateAddress,
680
799
  fetchHtml,
800
+ fetchRobotsTxt,
681
801
  ANALYSIS_TOOLS,
682
802
  handleAnalysisTool,
683
803
  createAnalysisServer
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  ANALYSIS_TOOLS,
4
4
  handleAnalysisTool
5
- } from "./chunk-JSSECRCS.js";
5
+ } from "./chunk-FJ2K7KRV.js";
6
6
 
7
7
  // src/index.ts
8
8
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
@@ -118,6 +118,12 @@ function clearPendingConnect() {
118
118
  localConfig.delete("pendingConnectId");
119
119
  localConfig.delete("pendingConnectUrl");
120
120
  localConfig.delete("pendingConnectExpiresAt");
121
+ localConfig.delete("pendingConnectCode");
122
+ }
123
+ function codeLine(userCode) {
124
+ return userCode ? `
125
+
126
+ Approve only if the page shows this code: ${userCode}` : "";
121
127
  }
122
128
  async function connectToRankCLI() {
123
129
  const existingKey = localConfig.get("apiKey");
@@ -144,7 +150,7 @@ async function connectToRankCLI() {
144
150
  return {
145
151
  text: `Still waiting for approval. Open this link if you haven't yet, then run rankcli_connect again:
146
152
 
147
- ${pendingUrl}
153
+ ${pendingUrl}${codeLine(localConfig.get("pendingConnectCode"))}
148
154
 
149
155
  Expires in ${minutesLeft} minute${minutesLeft === 1 ? "" : "s"}.`
150
156
  };
@@ -161,17 +167,18 @@ Expires in ${minutesLeft} minute${minutesLeft === 1 ? "" : "s"}.`
161
167
  isError: true
162
168
  };
163
169
  }
164
- const { connectId, url, expiresInSeconds } = startData;
170
+ const { connectId, userCode, url, expiresInSeconds } = startData;
165
171
  const expiresMinutes = Math.round(expiresInSeconds / 60);
166
172
  localConfig.set("pendingConnectId", connectId);
167
173
  localConfig.set("pendingConnectUrl", url);
168
174
  localConfig.set("pendingConnectExpiresAt", Date.now() + expiresInSeconds * 1e3);
175
+ if (userCode) localConfig.set("pendingConnectCode", userCode);
169
176
  const supportsUrlElicitation = !!server.getClientCapabilities()?.elicitation?.url;
170
177
  if (supportsUrlElicitation) {
171
178
  try {
172
179
  const result = await server.elicitInput({
173
180
  mode: "url",
174
- message: "Connect your RankCLI account to enable GitHub auto-fix PRs, scheduled monitoring, and a dashboard with audit history.",
181
+ message: `Connect your RankCLI account to enable GitHub auto-fix PRs, scheduled monitoring, and a dashboard with audit history.${codeLine(userCode)}`,
175
182
  url,
176
183
  elicitationId: connectId
177
184
  });
@@ -195,7 +202,7 @@ Expires in ${minutesLeft} minute${minutesLeft === 1 ? "" : "s"}.`
195
202
  return {
196
203
  text: `Open this link to connect your RankCLI account:
197
204
 
198
- ${url}
205
+ ${url}${codeLine(userCode)}
199
206
 
200
207
  Then run rankcli_connect again to finish (link expires in ${expiresMinutes} minutes).`
201
208
  };
package/dist/server.d.ts CHANGED
@@ -1,6 +1,13 @@
1
1
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
2
  import { Tool, CallToolResult } from '@modelcontextprotocol/sdk/types.js';
3
3
 
4
+ interface FetchGuardOptions {
5
+ /** Refuse loopback, private, link-local, CGNAT and metadata addresses. */
6
+ blockPrivateNetworks?: boolean;
7
+ }
8
+ /** Loopback, private, link-local, CGNAT, multicast/reserved - anything not on the public internet. */
9
+ declare function isPrivateAddress(ip: string): boolean;
10
+
4
11
  /**
5
12
  * Shared, stateless SEO/GEO analysis tools - the part of the RankCLI MCP
6
13
  * server that's safe to run anywhere, local stdio process or a shared
@@ -11,19 +18,39 @@ import { Tool, CallToolResult } from '@modelcontextprotocol/sdk/types.js';
11
18
  * via local file storage.
12
19
  */
13
20
 
14
- declare function fetchHtml(url: string): Promise<string>;
21
+ /** Options for the analysis tools. Everything defaults off, for local stdio use. */
22
+ type AnalysisServerOptions = FetchGuardOptions;
23
+ declare function fetchHtml(url: string, options?: AnalysisServerOptions): Promise<string>;
24
+ interface RobotsTxtResult {
25
+ /** robots.txt body when the site served one (2xx). */
26
+ content?: string;
27
+ /** One line for the report: what was found, or why crawler access is unknown. */
28
+ note: string;
29
+ /** robots.txt couldn't be read (5xx, 429, network) - access is not known. */
30
+ unknown?: boolean;
31
+ }
32
+ /**
33
+ * The site's own robots.txt. seo_geo_check / seo_analyze used to skip this
34
+ * unless the caller pasted robots.txt in, and then reported every AI
35
+ * crawler as "allowed" - even on sites that block them all.
36
+ */
37
+ declare function fetchRobotsTxt(pageUrl: string, options?: AnalysisServerOptions): Promise<RobotsTxtResult>;
15
38
  declare const ANALYSIS_TOOLS: Tool[];
16
39
  /**
17
40
  * Handles a single analysis tool call. Returns undefined if `name` isn't
18
41
  * one of ANALYSIS_TOOLS, so callers (index.ts's stdio server) can fall
19
42
  * through to their own additional tools.
20
43
  */
21
- declare function handleAnalysisTool(name: string, args: Record<string, unknown> | undefined): Promise<CallToolResult | undefined>;
44
+ declare function handleAnalysisTool(name: string, args: Record<string, unknown> | undefined, options?: AnalysisServerOptions): Promise<CallToolResult | undefined>;
22
45
  /**
23
46
  * A fresh Server exposing only the stateless analysis tools - used by the
24
47
  * remote HTTP endpoint, one instance per request (see packages/audit-worker),
25
48
  * matching the MCP SDK's own stateless-server example.
49
+ *
50
+ * A shared deployment should pass { blockPrivateNetworks: true } so callers
51
+ * can't point the fetching tools at internal addresses. Off by default: the
52
+ * local stdio server auditing http://localhost:3000 is a feature.
26
53
  */
27
- declare function createAnalysisServer(): Server;
54
+ declare function createAnalysisServer(options?: AnalysisServerOptions): Server;
28
55
 
29
- export { ANALYSIS_TOOLS, createAnalysisServer, fetchHtml, handleAnalysisTool };
56
+ export { ANALYSIS_TOOLS, type AnalysisServerOptions, type FetchGuardOptions, type RobotsTxtResult, createAnalysisServer, fetchHtml, fetchRobotsTxt, handleAnalysisTool, isPrivateAddress };
package/dist/server.js CHANGED
@@ -2,11 +2,15 @@ import {
2
2
  ANALYSIS_TOOLS,
3
3
  createAnalysisServer,
4
4
  fetchHtml,
5
- handleAnalysisTool
6
- } from "./chunk-JSSECRCS.js";
5
+ fetchRobotsTxt,
6
+ handleAnalysisTool,
7
+ isPrivateAddress
8
+ } from "./chunk-FJ2K7KRV.js";
7
9
  export {
8
10
  ANALYSIS_TOOLS,
9
11
  createAnalysisServer,
10
12
  fetchHtml,
11
- handleAnalysisTool
13
+ fetchRobotsTxt,
14
+ handleAnalysisTool,
15
+ isPrivateAddress
12
16
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rankcli/mcp-server",
3
- "version": "0.0.8",
3
+ "version": "0.0.9",
4
4
  "mcpName": "io.github.integrallis/rankcli-mcp-server",
5
5
  "description": "Free, local SEO + GEO (AI-search-citation) analysis for AI assistants - audits GEO/AI-crawler access, Core Web Vitals, structured data, security headers, mobile, and images with no signup or API key.",
6
6
  "type": "module",
@@ -23,7 +23,7 @@
23
23
  "build": "tsup src/index.ts src/server.ts --format esm --dts --clean",
24
24
  "dev": "tsup src/index.ts src/server.ts --format esm --watch",
25
25
  "typecheck": "tsc --noEmit",
26
- "test": "echo 'No tests yet'",
26
+ "test": "vitest run",
27
27
  "prepublishOnly": "npm run build"
28
28
  },
29
29
  "keywords": [
@@ -51,12 +51,13 @@
51
51
  },
52
52
  "dependencies": {
53
53
  "@modelcontextprotocol/sdk": "^1.30.0",
54
- "@rankcli/agent-runtime": "^0.0.20",
54
+ "@rankcli/agent-runtime": "^0.0.21",
55
55
  "conf": "^12.0.0"
56
56
  },
57
57
  "devDependencies": {
58
58
  "tsup": "^8.0.0",
59
- "typescript": "^5.4.0"
59
+ "typescript": "^5.4.0",
60
+ "vitest": "^1.6.0"
60
61
  },
61
62
  "files": [
62
63
  "dist"