getdr-mcp 0.0.0-stage → 0.2.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/CHANGELOG.md ADDED
@@ -0,0 +1,59 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
+ and the project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.2.0] - 2026-10-29
10
+
11
+ ### Added
12
+
13
+ - Records of invalid targets carry `"error":"invalid target"` (also in
14
+ `outputSchema`). Through a getdr server, a target its Ahrefs refuses is no
15
+ longer reported as "no response".
16
+ - The release workflow publishes `server.json` to the MCP Registry.
17
+
18
+ ### Fixed
19
+
20
+ - Through the binary, a target starting with `#` was skipped as a comment of
21
+ the `-f -` list; such lists now go as arguments.
22
+
23
+ ### Changed
24
+
25
+ - The binary backend needs getdr 0.2.0 or newer (for the `error` field).
26
+ - Without `version`, the getdr server's or binary's config decides; before,
27
+ `dr3` was always sent.
28
+ - The text result is only the JSON array: the note after it about targets
29
+ without a response is gone (their `checked_at` is `null`). An error for
30
+ "no response for any target" quotes getdr instead of guessing a cause.
31
+
32
+ ### Removed
33
+
34
+ - The Ahrefs attribution line at the end of the `get_dr` text result and the
35
+ `attribution` field of `structuredContent` (and of `outputSchema`).
36
+
37
+ ## [0.1.0] - 2026-10-28
38
+
39
+ ### Added
40
+
41
+ - `getdr-mcp`: MCP server over stdio, run with `npx -y getdr-mcp`; no runtime
42
+ dependencies.
43
+ - Tool `get_dr`: Ahrefs Domain Rating for domains or URLs (`targets`, optional
44
+ `version` `dr3`/`dr4` and `fresh`).
45
+ - Backends in order: a getdr server from `GETDR_API` (bearer token from
46
+ `GETDR_SERVE_TOKEN`), else the `getdr` binary on `PATH`; a tool error with
47
+ setup instructions when neither is there.
48
+ - Result: the getdr JSON records as text, followed by the Ahrefs attribution;
49
+ `structuredContent` with an `outputSchema` for protocol 2025-06-18 and later.
50
+ - MCP protocol versions 2026-07-28 (stateless, with `server/discover`) and the
51
+ `initialize`-based 2025-11-25, 2025-06-18, 2025-03-26 and 2024-11-05.
52
+ - A request cancelled with `notifications/cancelled` gets no response.
53
+ - `server.json` for the MCP Registry.
54
+ - Release workflow with npm trusted publishing, and `npm run release` with a
55
+ version and tarball check for manual publishes.
56
+
57
+ [Unreleased]: https://github.com/index-pages/getdr-mcp/compare/v0.2.0...HEAD
58
+ [0.2.0]: https://github.com/index-pages/getdr-mcp/compare/v0.1.0...v0.2.0
59
+ [0.1.0]: https://github.com/index-pages/getdr-mcp/releases/tag/v0.1.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Index Pages
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 CHANGED
@@ -1,3 +1,158 @@
1
- # Temporary Holding Version
1
+ # getdr-mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ `getdr-mcp` is a small open-source [MCP](https://modelcontextprotocol.io/) server by
4
+ [Index Pages](https://index-pages.com) that gives AI agents one tool, `get_dr`: Ahrefs Domain Rating (DR) for
5
+ domains or URLs. It is a thin front for [getdr](https://index-pages.com/getdr) – it asks a getdr server or the
6
+ `getdr` binary and makes no Ahrefs requests of its own. Stdio transport, no runtime dependencies. Docs:
7
+ [index-pages.com/getdr](https://index-pages.com/getdr).
8
+
9
+ ## Setup
10
+
11
+ Add it to your MCP client (Claude Desktop, Claude Code, Cursor and others use this shape):
12
+
13
+ ```json
14
+ {
15
+ "mcpServers": {
16
+ "getdr": {
17
+ "command": "npx",
18
+ "args": ["-y", "getdr-mcp"]
19
+ }
20
+ }
21
+ }
22
+ ```
23
+
24
+ That uses the `getdr` binary on your `PATH`. To use a getdr server on your network instead:
25
+
26
+ ```json
27
+ {
28
+ "mcpServers": {
29
+ "getdr": {
30
+ "command": "npx",
31
+ "args": ["-y", "getdr-mcp"],
32
+ "env": {
33
+ "GETDR_API": "http://lan-host:8080",
34
+ "GETDR_SERVE_TOKEN": "s3cret"
35
+ }
36
+ }
37
+ }
38
+ }
39
+ ```
40
+
41
+ Requires Node.js 18 or later.
42
+
43
+ ## Backends
44
+
45
+ Checked on every call, in this order:
46
+
47
+ 1. `GETDR_API` is set – one `POST $GETDR_API/dr` to that getdr server (`getdr serve`), with
48
+ `Authorization: Bearer $GETDR_SERVE_TOKEN` when the token is set. The server holds the Ahrefs key and the
49
+ shared cache, so this machine needs neither.
50
+ 2. `getdr` (0.2.0 or newer) is on `PATH` – runs `getdr --format json -q [--version V] [--fresh] -f -` with
51
+ the targets on stdin, no shell (as arguments after `--` when a target starts with `#`, which `-f` would
52
+ skip as a comment). The binary uses its own config, cache and key (`AHREFS_KEY`), or its own `api`
53
+ setting.
54
+ 3. Neither – the tool returns an error with setup instructions.
55
+
56
+ Install the binary with `go install github.com/index-pages/getdr@latest` or from
57
+ [Releases](https://github.com/index-pages/getdr/releases), then get a free Ahrefs API key:
58
+ [index-pages.com/getdr#key](https://index-pages.com/getdr#key).
59
+
60
+ Both backends return the same records. Calls time out after 120 seconds.
61
+
62
+ Proxies: the HTTP backend uses Node's built-in `fetch`, which ignores `HTTP_PROXY`/`HTTPS_PROXY` unless
63
+ `NODE_USE_ENV_PROXY=1` is set (Node 22.21 or later, or 24 and later; older versions have no built-in proxy
64
+ support). The binary backend honours `HTTP_PROXY`, `HTTPS_PROXY` and `NO_PROXY` as usual.
65
+
66
+ ## Tool
67
+
68
+ `get_dr` – "Ahrefs Domain Rating (0–100) for domains or URLs, via getdr by Index Pages".
69
+
70
+ | argument | type | |
71
+ |---|---|---|
72
+ | `targets` | string[] | required, non-empty; domains or URLs |
73
+ | `version` | `"dr3"` or `"dr4"` | DR formula; when left out, the backend's config decides: the getdr server's with `GETDR_API`, otherwise the binary's (also when that binary uses a server), `dr3` unless set; values are not comparable between versions |
74
+ | `fresh` | boolean | ignore the cache and fetch again |
75
+
76
+ Targets are normalized by getdr: a URL becomes its host, `www.` is dropped, everything is lower-cased and
77
+ duplicates are removed. Results follow the input order.
78
+
79
+ ## Output
80
+
81
+ The text result is a JSON array with one record per target, and nothing else:
82
+
83
+ ```
84
+ [
85
+ {"target":"ahrefs.com","dr":91,"version":"dr3","checked_at":"2026-10-28T12:00:00Z","cached":true},
86
+ {"target":"nodata.example","dr":null,"version":"dr3","checked_at":"2026-10-28T12:00:00Z","cached":false}
87
+ ]
88
+ ```
89
+
90
+ The record is the getdr JSON contract, the same as `getdr --format json` and `getdr serve`. `dr` is `null`
91
+ when Ahrefs has no data; `checked_at` is `null` too when there was no response or the target is invalid. An
92
+ invalid target (not a domain or URL, or refused by Ahrefs, such as `localhost`) also has
93
+ `"error":"invalid target"`; no other record has an `error` key. Clients on MCP protocol 2025-06-18 or later
94
+ also get `structuredContent` – `{"results": […]}` – and an `outputSchema` for it.
95
+
96
+ Invalid targets and targets without a response are part of a normal result. Failures come back as a tool
97
+ error (`isError: true`) with getdr's message:
98
+
99
+ - no target got a response at all;
100
+ - getdr server answered 401 – the token is missing or wrong, set `GETDR_SERVE_TOKEN`;
101
+ - getdr server answered 503, or the binary exited with code 3 – the Ahrefs key is missing or rejected;
102
+ - the server is unreachable, the binary failed, or a call timed out.
103
+
104
+ Supported MCP protocol versions: 2026-07-28 (stateless: `server/discover` and per-request `_meta`, no
105
+ handshake) and the `initialize`-based 2025-11-25, 2025-06-18, 2025-03-26 and 2024-11-05, served side by side.
106
+
107
+ ## Attribution
108
+
109
+ Domain Rating data comes from [Ahrefs](https://ahrefs.com/) and is subject to the
110
+ [Domain Rating License](https://ahrefs.com/legal/domain-rating-license), which requires the attribution
111
+ "Domain Rating by Ahrefs" wherever you publish the numbers. The tool result carries only the data, with
112
+ no attribution in it.
113
+
114
+ getdr is not affiliated with Ahrefs.
115
+
116
+ ## No telemetry
117
+
118
+ getdr-mcp connects to exactly one place: the getdr server in `GETDR_API`, when you set it. Otherwise it only
119
+ starts the `getdr` binary. No telemetry, no update checks, no analytics, no pings. Requests carry the
120
+ User-Agent `getdr-mcp/<version> (+https://index-pages.com/getdr)`. You can verify this in the source: the only
121
+ outgoing request and the only child process are in `src/backends.ts`.
122
+
123
+ ## Development
124
+
125
+ ```sh
126
+ npm ci
127
+ npm run build
128
+ npm test
129
+ ```
130
+
131
+ The backend tests build `getdr` and its mock-backed server from the getdr source tree (`../bin`, or
132
+ `GETDR_SRC`) and need Go; without it they are skipped. The live check runs only when `AHREFS_KEY` is set.
133
+
134
+ ## Releasing
135
+
136
+ Bump the version in `package.json`, `server.json` (twice) and `src/version.ts`, update `CHANGELOG.md`, then
137
+ push a tag `vX.Y.Z`. `.github/workflows/release.yml` builds, runs the tests against the getdr source at the
138
+ same tag, checks that every version matches the tag and that the packed tarball holds `dist/index.js` with its
139
+ shebang, and publishes with npm trusted publishing (OIDC, no token, provenance included).
140
+
141
+ Trusted publishing can only be configured for a package that already exists on npm, so the first release is
142
+ published by hand once: `npm login`, then `npm run release`. Right after that, add the trusted publisher on
143
+ npmjs.com (package Settings → Trusted publishing: GitHub Actions, `index-pages` / `getdr-mcp`, workflow
144
+ `release.yml`); the workflow has to complete its first publish within 2 days or the configuration expires.
145
+
146
+ For any manual publish use `npm run release` (build, tests, `check-release`, `npm publish`), never a bare
147
+ `npm publish`: with install scripts disabled (`ignore-scripts`) `prepack` does not run and the package would
148
+ ship without `dist/`. `prepack` stays as a fallback for setups that run scripts.
149
+
150
+ The same workflow then publishes `server.json` to the [MCP Registry](https://registry.modelcontextprotocol.io)
151
+ as `io.github.index-pages/getdr-mcp`: it waits until npm shows the new version (the registry checks `mcpName`
152
+ in the npm package), proves the `index-pages` namespace with the workflow's OIDC token
153
+ (`mcp-publisher login github-oidc`, no secret) and runs `mcp-publisher publish` with a pinned, checksummed
154
+ `mcp-publisher`. A version already in the registry is skipped.
155
+
156
+ ## License
157
+
158
+ MIT, see [LICENSE](LICENSE). Made by [Index Pages](https://index-pages.com).
@@ -0,0 +1,216 @@
1
+ // The two ways to get Domain Rating: a getdr server (GETDR_API) or the getdr
2
+ // binary on PATH. Both return the same record shape. Nothing here talks to
3
+ // Ahrefs directly.
4
+ import { spawn } from "node:child_process";
5
+ import { accessSync, constants, statSync } from "node:fs";
6
+ import { delimiter, isAbsolute, join } from "node:path";
7
+ import { HOMEPAGE, USER_AGENT } from "./version.js";
8
+ // A failure to report to the model as a tool error.
9
+ export class BackendError extends Error {
10
+ }
11
+ // Both the HTTP request and the binary run are bounded by this. A getdr
12
+ // server may need several rate-limited Ahrefs batches for a long list.
13
+ export const TIMEOUT_MS = 120_000;
14
+ const MAX_DETAIL = 2000;
15
+ export function isNoResponse(r) {
16
+ return r.dr === null && r.checked_at === null && r.error === undefined;
17
+ }
18
+ export function toRecord(v) {
19
+ const o = v;
20
+ if (typeof o !== "object" ||
21
+ o === null ||
22
+ typeof o.target !== "string" ||
23
+ !(o.dr === null || typeof o.dr === "number") ||
24
+ typeof o.version !== "string" ||
25
+ !(o.checked_at === null || typeof o.checked_at === "string") ||
26
+ typeof o.cached !== "boolean" ||
27
+ !(o.error === undefined || typeof o.error === "string")) {
28
+ throw new Error(`unexpected record ${JSON.stringify(v)?.slice(0, 200)}`);
29
+ }
30
+ const r = { target: o.target, dr: o.dr, version: o.version, checked_at: o.checked_at, cached: o.cached };
31
+ if (o.error !== undefined)
32
+ r.error = o.error;
33
+ return r;
34
+ }
35
+ function clip(s) {
36
+ s = s.trim();
37
+ return s.length > MAX_DETAIL ? s.slice(0, MAX_DETAIL) + "…" : s;
38
+ }
39
+ // ---- getdr server -------------------------------------------------------
40
+ function serverError(body) {
41
+ try {
42
+ const e = JSON.parse(body);
43
+ if (typeof e.error === "string" && e.error)
44
+ return e.error;
45
+ }
46
+ catch {
47
+ // not JSON, fall through
48
+ }
49
+ return clip(body) || "(empty body)";
50
+ }
51
+ export async function viaApi(base, token, q) {
52
+ const url = base.replace(/\/+$/, "") + "/dr";
53
+ const where = `getdr server ${base} (GETDR_API)`;
54
+ // Without a version the server's own default applies.
55
+ const body = { targets: q.targets };
56
+ if (q.version)
57
+ body.version = q.version;
58
+ if (q.fresh)
59
+ body.fresh = true;
60
+ const headers = {
61
+ "Content-Type": "application/json",
62
+ Accept: "application/json",
63
+ "User-Agent": USER_AGENT,
64
+ };
65
+ if (token)
66
+ headers.Authorization = `Bearer ${token}`;
67
+ let status;
68
+ let text;
69
+ try {
70
+ const res = await fetch(url, {
71
+ method: "POST",
72
+ headers,
73
+ body: JSON.stringify(body),
74
+ signal: AbortSignal.timeout(TIMEOUT_MS),
75
+ });
76
+ status = res.status;
77
+ text = await res.text();
78
+ }
79
+ catch (err) {
80
+ const e = err;
81
+ if (e.name === "TimeoutError" || e.name === "AbortError") {
82
+ throw new BackendError(`${where} did not answer within ${TIMEOUT_MS / 1000} s. Try again later or with fewer targets.`);
83
+ }
84
+ const why = e.cause?.code ?? e.cause?.message ?? e.message;
85
+ throw new BackendError(`${where} is unreachable: ${why}. Check that GETDR_API points to a running getdr serve.`);
86
+ }
87
+ switch (status) {
88
+ case 200:
89
+ break;
90
+ case 401:
91
+ throw new BackendError(`${where} rejected the request: ${serverError(text)}. Set GETDR_SERVE_TOKEN to the server's token.`);
92
+ case 503:
93
+ throw new BackendError(`${where}: ${serverError(text)}`);
94
+ default:
95
+ throw new BackendError(`${where}: HTTP ${status}: ${serverError(text)}`);
96
+ }
97
+ try {
98
+ const parsed = JSON.parse(text);
99
+ if (!Array.isArray(parsed.results))
100
+ throw new Error('no "results" array');
101
+ const records = parsed.results.map(toRecord);
102
+ return { records, detail: records.some(isNoResponse) ? "the getdr server got no answer from Ahrefs" : "" };
103
+ }
104
+ catch (err) {
105
+ throw new BackendError(`${where} sent an unexpected response: ${err.message}`);
106
+ }
107
+ }
108
+ // ---- getdr binary -------------------------------------------------------
109
+ // Looks up getdr in PATH the way a shell would, without running a shell.
110
+ // Empty and relative PATH entries are skipped so the working directory never
111
+ // supplies the binary.
112
+ export function findBinary(env) {
113
+ const win = process.platform === "win32";
114
+ const pathVar = (win ? (env.Path ?? env.PATH) : env.PATH) ?? "";
115
+ const names = win
116
+ ? (env.PATHEXT ?? ".EXE;.COM").split(";").filter(Boolean).map((ext) => "getdr" + ext.toLowerCase())
117
+ : ["getdr"];
118
+ for (const dir of pathVar.split(delimiter)) {
119
+ if (!dir || !isAbsolute(dir))
120
+ continue;
121
+ for (const name of names) {
122
+ const p = join(dir, name);
123
+ try {
124
+ if (!statSync(p).isFile())
125
+ continue;
126
+ if (!win)
127
+ accessSync(p, constants.X_OK);
128
+ return p;
129
+ }
130
+ catch {
131
+ // not here
132
+ }
133
+ }
134
+ }
135
+ return null;
136
+ }
137
+ export function viaBinary(bin, env, q) {
138
+ // Targets go through stdin (-f -): no shell, no argv length limits, and a
139
+ // target that starts with "-" is never read as a flag. -f skips lines
140
+ // starting with "#", so such a list goes as arguments after "--" instead.
141
+ const argv = ["--format", "json", "-q"];
142
+ if (q.version)
143
+ argv.push("--version", q.version);
144
+ if (q.fresh)
145
+ argv.push("--fresh");
146
+ const asArgs = q.targets.some((t) => t.trim().startsWith("#"));
147
+ if (asArgs)
148
+ argv.push("--", ...q.targets);
149
+ else
150
+ argv.push("-f", "-");
151
+ return new Promise((resolve, reject) => {
152
+ const fail = (msg) => reject(new BackendError(msg));
153
+ let child;
154
+ try {
155
+ child = spawn(bin, argv, { env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
156
+ }
157
+ catch (err) {
158
+ fail(`cannot run ${bin}: ${err.message}`);
159
+ return;
160
+ }
161
+ const out = [];
162
+ const errOut = [];
163
+ let timedOut = false;
164
+ const timer = setTimeout(() => {
165
+ timedOut = true;
166
+ child.kill();
167
+ }, TIMEOUT_MS);
168
+ child.stdout.on("data", (b) => out.push(b));
169
+ child.stderr.on("data", (b) => errOut.push(b));
170
+ child.stdin.on("error", () => {
171
+ // the binary exited before reading everything; its exit code tells why
172
+ });
173
+ child.on("error", (err) => {
174
+ clearTimeout(timer);
175
+ fail(`cannot run ${bin}: ${err.message}`);
176
+ });
177
+ child.on("close", (code, signal) => {
178
+ clearTimeout(timer);
179
+ const stdout = Buffer.concat(out).toString("utf8");
180
+ const stderr = clip(Buffer.concat(errOut).toString("utf8"));
181
+ if (timedOut) {
182
+ fail(`getdr did not finish within ${TIMEOUT_MS / 1000} s. Try again later or with fewer targets.`);
183
+ return;
184
+ }
185
+ if (code !== 0 && code !== 2) {
186
+ if (code === 3) {
187
+ fail(stderr || `getdr: Ahrefs API key missing or rejected; see ${HOMEPAGE}#key`);
188
+ }
189
+ else {
190
+ fail(`getdr exited with ${code ?? signal}${stderr ? `: ${stderr}` : ""}`);
191
+ }
192
+ return;
193
+ }
194
+ let records;
195
+ try {
196
+ records = stdout
197
+ .split("\n")
198
+ .filter((l) => l.trim() !== "")
199
+ .map((l) => toRecord(JSON.parse(l)));
200
+ }
201
+ catch (err) {
202
+ fail(`getdr printed unexpected output: ${err.message}`);
203
+ return;
204
+ }
205
+ // Exit 2 without records is an error (bad flag, cache, no valid
206
+ // targets); with records it means some targets were invalid or got no
207
+ // response.
208
+ if (code === 2 && records.length === 0) {
209
+ fail(stderr || "getdr failed (exit 2)");
210
+ return;
211
+ }
212
+ resolve({ records, detail: code === 2 ? stderr : "" });
213
+ });
214
+ child.stdin.end(asArgs ? "" : q.targets.join("\n") + "\n");
215
+ });
216
+ }
package/dist/index.js ADDED
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env node
2
+ // getdr-mcp: an MCP server over stdio with one tool, get_dr. stdout carries
3
+ // only protocol messages, one JSON object per line; diagnostics go to stderr.
4
+ import { cancelledId, errorResponse, PARSE_ERROR, Server } from "./protocol.js";
5
+ const server = new Server(process.env);
6
+ const pending = new Set();
7
+ // Requests in progress, and those the client cancelled: a cancelled request
8
+ // gets no response (2026-07-28 basic/transports/stdio#cancellation).
9
+ const inflight = new Set();
10
+ const cancelled = new Set();
11
+ let buffer = "";
12
+ function send(msg) {
13
+ process.stdout.write(JSON.stringify(msg) + "\n");
14
+ }
15
+ function onLine(line) {
16
+ if (line.trim() === "")
17
+ return;
18
+ let msg;
19
+ try {
20
+ msg = JSON.parse(line);
21
+ }
22
+ catch {
23
+ send(errorResponse(null, PARSE_ERROR, "parse error: each line must be one JSON-RPC message"));
24
+ return;
25
+ }
26
+ const cancel = cancelledId(msg);
27
+ if (cancel !== undefined && inflight.has(cancel))
28
+ cancelled.add(cancel);
29
+ const id = msg?.id;
30
+ const tracked = typeof id === "string" || typeof id === "number" ? id : undefined;
31
+ if (tracked !== undefined)
32
+ inflight.add(tracked);
33
+ const p = server
34
+ .handle(msg)
35
+ .then((res) => {
36
+ if (tracked !== undefined) {
37
+ inflight.delete(tracked);
38
+ if (cancelled.delete(tracked))
39
+ return;
40
+ }
41
+ if (res)
42
+ send(res);
43
+ })
44
+ .catch((err) => {
45
+ process.stderr.write(`getdr-mcp: ${err?.stack ?? String(err)}\n`);
46
+ })
47
+ .finally(() => pending.delete(p));
48
+ pending.add(p);
49
+ }
50
+ process.stdin.setEncoding("utf8");
51
+ process.stdin.on("data", (chunk) => {
52
+ buffer += chunk;
53
+ let nl;
54
+ while ((nl = buffer.indexOf("\n")) >= 0) {
55
+ onLine(buffer.slice(0, nl));
56
+ buffer = buffer.slice(nl + 1);
57
+ }
58
+ });
59
+ // The client closed stdin: answer what is in flight, then exit.
60
+ process.stdin.on("end", () => {
61
+ onLine(buffer);
62
+ buffer = "";
63
+ void Promise.allSettled([...pending]).then(() => process.exit(0));
64
+ });
65
+ // The client went away; nothing left to write to.
66
+ process.stdout.on("error", () => process.exit(0));
@@ -0,0 +1,195 @@
1
+ // MCP over stdio is newline-delimited JSON-RPC 2.0. This module answers one
2
+ // parsed message at a time; index.ts does the line framing.
3
+ //
4
+ // Two protocol eras are served side by side ("dual-era", 2026-07-28
5
+ // basic/versioning#backward-compatibility-with-initialization-based-versions):
6
+ // - legacy (2024-11-05 … 2025-11-25): an initialize handshake fixes the
7
+ // version for the process;
8
+ // - modern (2026-07-28): no handshake, every request names its version and
9
+ // client capabilities in _meta, results carry resultType.
10
+ import { callGetDr, toolDefinition } from "./tool.js";
11
+ import { VERSION } from "./version.js";
12
+ // Newest first. initialize answers with the requested legacy version when we
13
+ // support it, else with the newest legacy one.
14
+ export const LEGACY_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
15
+ export const MODERN_VERSIONS = ["2026-07-28"];
16
+ export const SUPPORTED_VERSIONS = [...MODERN_VERSIONS, ...LEGACY_VERSIONS];
17
+ // structuredContent and outputSchema exist since 2025-06-18.
18
+ const STRUCTURED_SINCE = "2025-06-18";
19
+ // Per-request _meta keys (2026-07-28 basic/index#meta).
20
+ const META_VERSION = "io.modelcontextprotocol/protocolVersion";
21
+ const META_CLIENT_CAPS = "io.modelcontextprotocol/clientCapabilities";
22
+ const META_SERVER_INFO = "io.modelcontextprotocol/serverInfo";
23
+ // The tool list only changes with a new release, which means a new process.
24
+ // Cache hints are required on server/discover and tools/list
25
+ // (2026-07-28 server/utilities/caching#cacheable-results).
26
+ const CACHE_TTL_MS = 3_600_000;
27
+ export const PARSE_ERROR = -32700;
28
+ export const INVALID_REQUEST = -32600;
29
+ export const METHOD_NOT_FOUND = -32601;
30
+ export const INVALID_PARAMS = -32602;
31
+ export const INTERNAL_ERROR = -32603;
32
+ export const UNSUPPORTED_PROTOCOL_VERSION = -32022;
33
+ const SERVER_INFO = { name: "getdr-mcp", title: "getdr", version: VERSION };
34
+ const CAPABILITIES = { tools: { listChanged: false } };
35
+ class RpcError extends Error {
36
+ code;
37
+ data;
38
+ constructor(code, message, data) {
39
+ super(message);
40
+ this.code = code;
41
+ this.data = data;
42
+ }
43
+ }
44
+ export function errorResponse(id, code, message, data) {
45
+ const error = { code, message };
46
+ if (data !== undefined)
47
+ error.data = data;
48
+ return { jsonrpc: "2.0", id, error };
49
+ }
50
+ function isObject(v) {
51
+ return typeof v === "object" && v !== null && !Array.isArray(v);
52
+ }
53
+ // The request id a notifications/cancelled message refers to, if it is one.
54
+ export function cancelledId(msg) {
55
+ if (!isObject(msg) || msg.method !== "notifications/cancelled" || "id" in msg || !isObject(msg.params)) {
56
+ return undefined;
57
+ }
58
+ const id = msg.params.requestId;
59
+ return typeof id === "string" || typeof id === "number" ? id : undefined;
60
+ }
61
+ export class Server {
62
+ env;
63
+ // Legacy version agreed in initialize; null until then.
64
+ protocolVersion = null;
65
+ constructor(env = process.env) {
66
+ this.env = env;
67
+ }
68
+ // Returns the response to send, or null for notifications and for
69
+ // responses the client sends us (we never send requests, so we ignore them).
70
+ async handle(msg) {
71
+ if (Array.isArray(msg)) {
72
+ return errorResponse(null, INVALID_REQUEST, "JSON-RPC batches are not supported");
73
+ }
74
+ if (!isObject(msg) || msg.jsonrpc !== "2.0") {
75
+ const id = isObject(msg) ? validId(msg.id) : null;
76
+ return errorResponse(id, INVALID_REQUEST, 'invalid request: expected a JSON-RPC 2.0 object with "jsonrpc": "2.0"');
77
+ }
78
+ const hasId = "id" in msg;
79
+ if (typeof msg.method !== "string") {
80
+ if (!("result" in msg) && !("error" in msg)) {
81
+ return errorResponse(validId(msg.id), INVALID_REQUEST, "invalid request: missing method");
82
+ }
83
+ return null;
84
+ }
85
+ if (!hasId)
86
+ return null; // notification: notifications/initialized, notifications/cancelled, …
87
+ if (typeof msg.id !== "string" && typeof msg.id !== "number") {
88
+ return errorResponse(null, INVALID_REQUEST, "invalid request: id must be a string or a number");
89
+ }
90
+ const id = msg.id;
91
+ if (msg.params !== undefined && !isObject(msg.params)) {
92
+ return errorResponse(id, INVALID_PARAMS, "params must be an object");
93
+ }
94
+ const params = msg.params ?? {};
95
+ try {
96
+ const ctx = this.context(params);
97
+ const result = (await this.dispatch(msg.method, params, ctx));
98
+ if (ctx.modern) {
99
+ // 2026-07-28 basic/index#result-responses: resultType is required;
100
+ // serverInfo in every result's _meta is a SHOULD.
101
+ result.resultType = "complete";
102
+ result._meta = { [META_SERVER_INFO]: SERVER_INFO };
103
+ }
104
+ return { jsonrpc: "2.0", id, result };
105
+ }
106
+ catch (err) {
107
+ if (err instanceof RpcError)
108
+ return errorResponse(id, err.code, err.message, err.data);
109
+ return errorResponse(id, INTERNAL_ERROR, `internal error: ${err?.message ?? String(err)}`);
110
+ }
111
+ }
112
+ // A request that names its protocol version in _meta is served by that
113
+ // version alone (2026-07-28 basic/index#statelessness); one without it
114
+ // belongs to the legacy session.
115
+ context(params) {
116
+ const meta = isObject(params._meta) ? params._meta : undefined;
117
+ const requested = meta?.[META_VERSION];
118
+ if (requested === undefined) {
119
+ return { version: this.protocolVersion ?? LEGACY_VERSIONS[0], modern: false };
120
+ }
121
+ if (typeof requested !== "string") {
122
+ throw new RpcError(INVALID_PARAMS, `_meta["${META_VERSION}"] must be a string`);
123
+ }
124
+ if (!SUPPORTED_VERSIONS.includes(requested)) {
125
+ throw new RpcError(UNSUPPORTED_PROTOCOL_VERSION, "Unsupported protocol version", {
126
+ supported: SUPPORTED_VERSIONS,
127
+ requested,
128
+ });
129
+ }
130
+ const modern = MODERN_VERSIONS.includes(requested);
131
+ if (modern && !isObject(meta?.[META_CLIENT_CAPS])) {
132
+ throw new RpcError(INVALID_PARAMS, `missing _meta["${META_CLIENT_CAPS}"]`);
133
+ }
134
+ return { version: requested, modern };
135
+ }
136
+ async dispatch(method, params, ctx) {
137
+ const structured = ctx.version >= STRUCTURED_SINCE; // ISO dates compare as strings
138
+ switch (method) {
139
+ case "initialize":
140
+ return this.initialize(params);
141
+ case "server/discover":
142
+ if (!ctx.modern) {
143
+ throw new RpcError(INVALID_PARAMS, `server/discover needs _meta["${META_VERSION}"]: "${MODERN_VERSIONS[0]}"`);
144
+ }
145
+ return {
146
+ supportedVersions: SUPPORTED_VERSIONS,
147
+ capabilities: CAPABILITIES,
148
+ ttlMs: CACHE_TTL_MS,
149
+ cacheScope: "public",
150
+ };
151
+ case "ping":
152
+ if (ctx.modern)
153
+ break; // removed in 2026-07-28
154
+ return {};
155
+ case "tools/list": {
156
+ const result = { tools: [toolDefinition(structured)] };
157
+ if (ctx.modern)
158
+ Object.assign(result, { ttlMs: CACHE_TTL_MS, cacheScope: "public" });
159
+ return result;
160
+ }
161
+ case "tools/call":
162
+ return this.callTool(params, structured);
163
+ }
164
+ throw new RpcError(METHOD_NOT_FOUND, `method not found: ${method}`);
165
+ }
166
+ initialize(params) {
167
+ const requested = params.protocolVersion;
168
+ if (typeof requested !== "string") {
169
+ throw new RpcError(INVALID_PARAMS, "initialize: protocolVersion must be a string", {
170
+ supported: LEGACY_VERSIONS,
171
+ });
172
+ }
173
+ this.protocolVersion = LEGACY_VERSIONS.includes(requested) ? requested : LEGACY_VERSIONS[0];
174
+ return {
175
+ protocolVersion: this.protocolVersion,
176
+ capabilities: CAPABILITIES,
177
+ serverInfo: SERVER_INFO,
178
+ };
179
+ }
180
+ async callTool(params, structured) {
181
+ if (typeof params.name !== "string") {
182
+ throw new RpcError(INVALID_PARAMS, "tools/call: name must be a string");
183
+ }
184
+ if (params.name !== "get_dr") {
185
+ throw new RpcError(INVALID_PARAMS, `unknown tool: ${params.name}`);
186
+ }
187
+ if (params.arguments !== undefined && !isObject(params.arguments)) {
188
+ throw new RpcError(INVALID_PARAMS, "tools/call: arguments must be an object");
189
+ }
190
+ return callGetDr(params.arguments ?? {}, { structured, env: this.env });
191
+ }
192
+ }
193
+ function validId(id) {
194
+ return typeof id === "string" || typeof id === "number" ? id : null;
195
+ }
package/dist/tool.js ADDED
@@ -0,0 +1,125 @@
1
+ // The get_dr tool: argument checks, backend choice and the result shape.
2
+ import { BackendError, findBinary, isNoResponse, viaApi, viaBinary } from "./backends.js";
3
+ import { HOMEPAGE } from "./version.js";
4
+ const DESCRIPTION = "Ahrefs Domain Rating (0–100) for domains or URLs, via getdr by Index Pages";
5
+ export function toolDefinition(structured) {
6
+ const tool = {
7
+ name: "get_dr",
8
+ title: "Ahrefs Domain Rating",
9
+ description: DESCRIPTION,
10
+ inputSchema: {
11
+ type: "object",
12
+ properties: {
13
+ targets: {
14
+ type: "array",
15
+ items: { type: "string" },
16
+ minItems: 1,
17
+ description: "Domains or URLs. URLs are reduced to the host, www. is dropped, duplicates are removed; results follow the input order.",
18
+ },
19
+ version: {
20
+ type: "string",
21
+ enum: ["dr3", "dr4"],
22
+ description: "DR formula: dr3 or dr4 (the new one); default: the getdr server's or binary's config, dr3 unless set there. Values are not comparable between versions.",
23
+ },
24
+ fresh: {
25
+ type: "boolean",
26
+ description: "Ignore the cache and fetch again.",
27
+ },
28
+ },
29
+ required: ["targets"],
30
+ additionalProperties: false,
31
+ },
32
+ annotations: { readOnlyHint: true, openWorldHint: true },
33
+ };
34
+ if (structured) {
35
+ tool.outputSchema = {
36
+ type: "object",
37
+ properties: {
38
+ results: {
39
+ type: "array",
40
+ items: {
41
+ type: "object",
42
+ properties: {
43
+ target: { type: "string" },
44
+ dr: { type: ["number", "null"], description: "null: no data, or no response or an invalid target when checked_at is null" },
45
+ version: { type: "string", enum: ["dr3", "dr4"] },
46
+ checked_at: { type: ["string", "null"], description: "RFC 3339 UTC; null for no response or an invalid target" },
47
+ cached: { type: "boolean" },
48
+ error: { type: "string", enum: ["invalid target"], description: "only on a target that is not a domain or URL" },
49
+ },
50
+ required: ["target", "dr", "version", "checked_at", "cached"],
51
+ },
52
+ },
53
+ },
54
+ required: ["results"],
55
+ };
56
+ }
57
+ return tool;
58
+ }
59
+ function toolError(text) {
60
+ return { content: [{ type: "text", text }], isError: true };
61
+ }
62
+ export const NO_BACKEND = "getdr is not available. Either set GETDR_API to the URL of a getdr server (getdr serve), " +
63
+ "or install the getdr binary and put it on PATH: go install github.com/index-pages/getdr@latest, " +
64
+ "or download it from https://github.com/index-pages/getdr/releases. " +
65
+ `The binary needs a free Ahrefs API key in AHREFS_KEY; a getdr server brings its own. Setup: ${HOMEPAGE}`;
66
+ // Returns the checked arguments or an error message for the model.
67
+ function checkArgs(a) {
68
+ for (const k of Object.keys(a)) {
69
+ if (k !== "targets" && k !== "version" && k !== "fresh") {
70
+ return `unknown argument "${k}"; get_dr takes targets, version and fresh`;
71
+ }
72
+ }
73
+ const { targets, version, fresh } = a;
74
+ if (!Array.isArray(targets) || targets.length === 0) {
75
+ return "targets must be a non-empty array of domains or URLs, e.g. [\"example.com\"]";
76
+ }
77
+ for (const t of targets) {
78
+ if (typeof t !== "string")
79
+ return "every target must be a string";
80
+ if (/[\r\n]/.test(t))
81
+ return "a target must not contain line breaks";
82
+ }
83
+ if (version !== undefined && version !== "dr3" && version !== "dr4") {
84
+ return 'version must be "dr3" or "dr4"';
85
+ }
86
+ if (fresh !== undefined && typeof fresh !== "boolean") {
87
+ return "fresh must be true or false";
88
+ }
89
+ return { targets, version, fresh };
90
+ }
91
+ export async function callGetDr(raw, opts) {
92
+ const args = checkArgs(raw);
93
+ if (typeof args === "string")
94
+ return toolError(args);
95
+ let backend;
96
+ const api = opts.env.GETDR_API?.trim();
97
+ if (api) {
98
+ backend = (q) => viaApi(api, opts.env.GETDR_SERVE_TOKEN || undefined, q);
99
+ }
100
+ else {
101
+ const bin = findBinary(opts.env);
102
+ if (!bin)
103
+ return toolError(NO_BACKEND);
104
+ backend = (q) => viaBinary(bin, opts.env, q);
105
+ }
106
+ let records;
107
+ let detail = "";
108
+ try {
109
+ ({ records, detail } = await backend(args));
110
+ }
111
+ catch (err) {
112
+ if (err instanceof BackendError)
113
+ return toolError(err.message);
114
+ throw err;
115
+ }
116
+ // Nothing answered at all is a failure of the backend, not a result.
117
+ if (records.length > 0 && records.every(isNoResponse)) {
118
+ return toolError(detail ? `No response for any target: ${detail}` : "No response for any target.");
119
+ }
120
+ const text = records.length ? "[\n" + records.map((r) => JSON.stringify(r)).join(",\n") + "\n]\n" : "[]\n";
121
+ const result = { content: [{ type: "text", text }] };
122
+ if (opts.structured)
123
+ result.structuredContent = { results: records };
124
+ return result;
125
+ }
@@ -0,0 +1,4 @@
1
+ // Kept in sync with package.json (a test checks it).
2
+ export const VERSION = "0.2.0";
3
+ export const HOMEPAGE = "https://index-pages.com/getdr";
4
+ export const USER_AGENT = `getdr-mcp/${VERSION} (+${HOMEPAGE})`;
package/humans.txt ADDED
@@ -0,0 +1,14 @@
1
+ /* TEAM */
2
+ Team: Index Pages
3
+ Site: https://index-pages.com
4
+ Project: https://index-pages.com/getdr
5
+
6
+ /* THANKS */
7
+ Domain Rating data: Ahrefs – https://ahrefs.com/
8
+
9
+ /* SITE */
10
+ Last update: 2026/10/28
11
+ Language: English
12
+ Standards: Model Context Protocol, JSON-RPC 2.0
13
+ Components: none (no runtime dependencies)
14
+ Software: Node.js, TypeScript
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
2
  "name": "getdr-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "MCP server for Ahrefs Domain Rating via getdr by Index Pages",
5
+ "keywords": [
6
+ "mcp",
7
+ "model-context-protocol",
8
+ "ahrefs",
9
+ "domain-rating",
10
+ "seo",
11
+ "getdr"
12
+ ],
13
+ "author": "Index Pages",
14
+ "homepage": "https://index-pages.com/getdr",
15
+ "license": "MIT",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/index-pages/getdr-mcp.git"
19
+ },
20
+ "bugs": {
21
+ "url": "https://github.com/index-pages/getdr-mcp/issues"
22
+ },
23
+ "mcpName": "io.github.index-pages/getdr-mcp",
24
+ "type": "module",
25
+ "bin": {
26
+ "getdr-mcp": "dist/index.js"
27
+ },
28
+ "files": [
29
+ "dist",
30
+ "CHANGELOG.md",
31
+ "humans.txt"
32
+ ],
33
+ "engines": {
34
+ "node": ">=18"
35
+ },
36
+ "scripts": {
37
+ "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
38
+ "prepack": "npm run build",
39
+ "test": "node --test test/*.test.mjs",
40
+ "check-release": "node scripts/check-release.mjs",
41
+ "release": "npm run build && npm test && npm run check-release && npm publish --access public"
42
+ },
43
+ "devDependencies": {
44
+ "@types/node": "18.19.130",
45
+ "typescript": "6.0.3"
46
+ }
47
+ }