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 +59 -0
- package/LICENSE +21 -0
- package/README.md +157 -2
- package/dist/backends.js +216 -0
- package/dist/index.js +66 -0
- package/dist/protocol.js +195 -0
- package/dist/tool.js +125 -0
- package/dist/version.js +4 -0
- package/humans.txt +14 -0
- package/package.json +45 -4
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
|
-
#
|
|
1
|
+
# getdr-mcp
|
|
2
2
|
|
|
3
|
-
|
|
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).
|
package/dist/backends.js
ADDED
|
@@ -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));
|
package/dist/protocol.js
ADDED
|
@@ -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
|
+
}
|
package/dist/version.js
ADDED
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|