domains-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +78 -0
- package/dist/cli-smoke.js +16 -0
- package/dist/config.js +34 -0
- package/dist/index.js +89 -0
- package/dist/lib/bootstrap.js +53 -0
- package/dist/lib/cache.js +61 -0
- package/dist/lib/normalize.js +54 -0
- package/dist/providers/provider.js +1 -0
- package/dist/providers/rdap.js +86 -0
- package/dist/providers/whois.js +106 -0
- package/dist/service.js +61 -0
- package/package.json +49 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 fulldev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# domains-mcp
|
|
2
|
+
|
|
3
|
+
An MCP server that lets Claude (and other agents) check **domain availability**, look up
|
|
4
|
+
registration details, and suggest available names across TLDs.
|
|
5
|
+
|
|
6
|
+
Availability comes entirely from **free, open protocols** — [RDAP](https://www.rfc-editor.org/rfc/rfc9224)
|
|
7
|
+
with a port-43 **WHOIS** fallback. No API keys, no accounts, no paid services.
|
|
8
|
+
|
|
9
|
+
## Tools
|
|
10
|
+
|
|
11
|
+
| Tool | What it does |
|
|
12
|
+
| --- | --- |
|
|
13
|
+
| `check_domain` | Is a single domain available? Accepts bare names, URLs, and IDNs. |
|
|
14
|
+
| `check_domains` | Bulk-check a list of domains (max 50, runs concurrently). |
|
|
15
|
+
| `suggest_domains` | Given a base name, return the available options across a set of TLDs. |
|
|
16
|
+
| `domain_info` | Registrar, status, creation/expiry dates, and nameservers for a domain. |
|
|
17
|
+
|
|
18
|
+
Each result is one of `available`, `registered`, or `unknown`. `unknown` means no source could
|
|
19
|
+
answer definitively — the server never guesses an answer.
|
|
20
|
+
|
|
21
|
+
## Install & build
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install
|
|
25
|
+
npm run build
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Quick smoke test (no MCP client needed)
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
node dist/cli-smoke.js google.com some-free-name-12345.com github.io münchen.de
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Use with Claude
|
|
35
|
+
|
|
36
|
+
Add to your MCP client configuration (e.g. Claude Desktop's `claude_desktop_config.json`):
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"mcpServers": {
|
|
41
|
+
"domains": {
|
|
42
|
+
"command": "node",
|
|
43
|
+
"args": ["/absolute/path/to/domains-mcp/dist/index.js"]
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Then ask things like: *"Is acme-robotics.com available?"*, *"Check these five domains for me,"* or
|
|
50
|
+
*"Suggest available domains for the name 'lumina'."*
|
|
51
|
+
|
|
52
|
+
## Configuration (all optional)
|
|
53
|
+
|
|
54
|
+
| Variable | Purpose | Default |
|
|
55
|
+
| --- | --- | --- |
|
|
56
|
+
| `REQUEST_TIMEOUT_MS` | Per-request network timeout | `5000` |
|
|
57
|
+
| `CACHE_TTL_SECONDS` | TTL for the in-memory availability cache | `300` |
|
|
58
|
+
| `CONCURRENCY` | Max concurrent lookups during bulk operations | `8` |
|
|
59
|
+
| `DEFAULT_TLDS` | Comma-separated TLDs used by `suggest_domains` | `com,net,org,io,ai,co,dev,app,xyz,me` |
|
|
60
|
+
|
|
61
|
+
## How it works
|
|
62
|
+
|
|
63
|
+
1. The input domain is normalized (URL stripping, `www.` removal, IDN → Punycode, validation).
|
|
64
|
+
2. **RDAP**: the TLD is resolved to its RDAP server via the IANA bootstrap registry. A `404` means
|
|
65
|
+
available; a `200` returns full registration data.
|
|
66
|
+
3. **WHOIS fallback**: if the TLD has no RDAP service (or RDAP is inconclusive), the server asks
|
|
67
|
+
IANA for the TLD's WHOIS server, queries it, and pattern-matches the response.
|
|
68
|
+
4. Results are cached briefly to cut latency and respect registry rate limits.
|
|
69
|
+
|
|
70
|
+
## Known limitations
|
|
71
|
+
|
|
72
|
+
Some ccTLD WHOIS servers (e.g. DENIC for `.de`) return restricted or unusually formatted
|
|
73
|
+
responses; those may come back as `unknown` rather than a guess. RDAP/WHOIS report registration
|
|
74
|
+
status, not whether a taken domain is for sale.
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
MIT
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Quick manual smoke test: `node dist/cli-smoke.js example.com google.com xn--test.io` */
|
|
2
|
+
import { loadConfig } from "./config.js";
|
|
3
|
+
import { DomainService } from "./service.js";
|
|
4
|
+
async function main() {
|
|
5
|
+
const args = process.argv.slice(2);
|
|
6
|
+
const domains = args.length ? args : ["google.com", "this-name-is-almost-certainly-free-12345.com"];
|
|
7
|
+
const service = new DomainService(loadConfig());
|
|
8
|
+
const results = await service.checkMany(domains);
|
|
9
|
+
for (const r of results) {
|
|
10
|
+
console.log(JSON.stringify(r));
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
main().catch((e) => {
|
|
14
|
+
console.error(e);
|
|
15
|
+
process.exit(1);
|
|
16
|
+
});
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const DEFAULT_TLDS = [
|
|
2
|
+
"com",
|
|
3
|
+
"net",
|
|
4
|
+
"org",
|
|
5
|
+
"io",
|
|
6
|
+
"ai",
|
|
7
|
+
"co",
|
|
8
|
+
"dev",
|
|
9
|
+
"app",
|
|
10
|
+
"xyz",
|
|
11
|
+
"me",
|
|
12
|
+
];
|
|
13
|
+
function intFromEnv(name, fallback) {
|
|
14
|
+
const raw = process.env[name];
|
|
15
|
+
if (!raw)
|
|
16
|
+
return fallback;
|
|
17
|
+
const n = Number.parseInt(raw, 10);
|
|
18
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
19
|
+
}
|
|
20
|
+
export function loadConfig() {
|
|
21
|
+
const tldsRaw = process.env.DEFAULT_TLDS;
|
|
22
|
+
const defaultTlds = tldsRaw
|
|
23
|
+
? tldsRaw
|
|
24
|
+
.split(",")
|
|
25
|
+
.map((t) => t.trim().replace(/^\./, "").toLowerCase())
|
|
26
|
+
.filter(Boolean)
|
|
27
|
+
: DEFAULT_TLDS;
|
|
28
|
+
return {
|
|
29
|
+
requestTimeoutMs: intFromEnv("REQUEST_TIMEOUT_MS", 5000),
|
|
30
|
+
cacheTtlSeconds: intFromEnv("CACHE_TTL_SECONDS", 300),
|
|
31
|
+
concurrency: intFromEnv("CONCURRENCY", 8),
|
|
32
|
+
defaultTlds,
|
|
33
|
+
};
|
|
34
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { loadConfig } from "./config.js";
|
|
6
|
+
import { DomainService } from "./service.js";
|
|
7
|
+
const config = loadConfig();
|
|
8
|
+
const service = new DomainService(config);
|
|
9
|
+
const server = new McpServer({
|
|
10
|
+
name: "domains-mcp",
|
|
11
|
+
version: "0.1.0",
|
|
12
|
+
});
|
|
13
|
+
function statusLine(r) {
|
|
14
|
+
const name = r.domainUnicode !== r.domain ? `${r.domainUnicode} (${r.domain})` : r.domain;
|
|
15
|
+
switch (r.availability) {
|
|
16
|
+
case "available":
|
|
17
|
+
return `✅ ${name} — AVAILABLE`;
|
|
18
|
+
case "registered": {
|
|
19
|
+
const exp = r.registration?.expiryDate
|
|
20
|
+
? `, expires ${r.registration.expiryDate.slice(0, 10)}`
|
|
21
|
+
: "";
|
|
22
|
+
const reg = r.registration?.registrar ? ` via ${r.registration.registrar}` : "";
|
|
23
|
+
return `❌ ${name} — registered${reg}${exp}`;
|
|
24
|
+
}
|
|
25
|
+
default:
|
|
26
|
+
return `❓ ${name} — UNKNOWN (${r.reason ?? "no definitive answer"})`;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function asResult(text, structured) {
|
|
30
|
+
return {
|
|
31
|
+
content: [{ type: "text", text }],
|
|
32
|
+
structuredContent: { result: structured },
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
server.tool("check_domain", "Check whether a single domain is available to register. Uses RDAP (authoritative, free) with a WHOIS fallback. Returns available | registered | unknown.", { domain: z.string().describe('A domain name, e.g. "example.com". URLs and IDNs are accepted.') }, async ({ domain }) => {
|
|
36
|
+
const r = await service.checkOne(domain);
|
|
37
|
+
return asResult(statusLine(r), r);
|
|
38
|
+
});
|
|
39
|
+
server.tool("check_domains", "Check availability for a list of domains at once (bulk). Runs concurrently. Returns one result per domain.", {
|
|
40
|
+
domains: z
|
|
41
|
+
.array(z.string())
|
|
42
|
+
.min(1)
|
|
43
|
+
.max(50)
|
|
44
|
+
.describe("List of domain names to check (max 50)."),
|
|
45
|
+
}, async ({ domains }) => {
|
|
46
|
+
const results = await service.checkMany(domains);
|
|
47
|
+
const text = results.map(statusLine).join("\n");
|
|
48
|
+
return asResult(text, results);
|
|
49
|
+
});
|
|
50
|
+
server.tool("suggest_domains", "Given a base name (the part before the dot), check it across a set of TLDs and return only the available ones. Useful for brainstorming a name across .com/.io/.ai/etc.", {
|
|
51
|
+
name: z.string().describe('The label to check, e.g. "acme" (no TLD).'),
|
|
52
|
+
tlds: z
|
|
53
|
+
.array(z.string())
|
|
54
|
+
.optional()
|
|
55
|
+
.describe("TLDs to try (without dots). Defaults to a common set if omitted."),
|
|
56
|
+
}, async ({ name, tlds }) => {
|
|
57
|
+
const results = await service.suggest(name, tlds);
|
|
58
|
+
const text = results.length
|
|
59
|
+
? `Available options for "${name}":\n` + results.map(statusLine).join("\n")
|
|
60
|
+
: `No available domains found for "${name}" in the checked TLDs.`;
|
|
61
|
+
return asResult(text, results);
|
|
62
|
+
});
|
|
63
|
+
server.tool("domain_info", "Look up registration details (registrar, status, creation/expiry dates, nameservers) for a domain via RDAP. If the domain is unregistered, reports that it is available.", { domain: z.string().describe("The domain to look up.") }, async ({ domain }) => {
|
|
64
|
+
const r = await service.checkOne(domain);
|
|
65
|
+
let text = statusLine(r);
|
|
66
|
+
if (r.availability === "registered" && r.registration) {
|
|
67
|
+
const reg = r.registration;
|
|
68
|
+
const lines = [
|
|
69
|
+
reg.registrar ? `Registrar: ${reg.registrar}` : null,
|
|
70
|
+
reg.statuses?.length ? `Status: ${reg.statuses.join(", ")}` : null,
|
|
71
|
+
reg.createdDate ? `Created: ${reg.createdDate}` : null,
|
|
72
|
+
reg.updatedDate ? `Updated: ${reg.updatedDate}` : null,
|
|
73
|
+
reg.expiryDate ? `Expires: ${reg.expiryDate}` : null,
|
|
74
|
+
reg.nameservers?.length ? `Nameservers: ${reg.nameservers.join(", ")}` : null,
|
|
75
|
+
].filter(Boolean);
|
|
76
|
+
text += "\n" + lines.join("\n");
|
|
77
|
+
}
|
|
78
|
+
return asResult(text, r);
|
|
79
|
+
});
|
|
80
|
+
async function main() {
|
|
81
|
+
const transport = new StdioServerTransport();
|
|
82
|
+
await server.connect(transport);
|
|
83
|
+
// Logs must go to stderr; stdout is the MCP transport.
|
|
84
|
+
console.error("domains-mcp running on stdio");
|
|
85
|
+
}
|
|
86
|
+
main().catch((err) => {
|
|
87
|
+
console.error("Fatal:", err);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { fetchWithTimeout } from "./cache.js";
|
|
2
|
+
/**
|
|
3
|
+
* IANA RDAP bootstrap. Maps a TLD to the RDAP base URL(s) that serve it.
|
|
4
|
+
* Spec: https://datatracker.ietf.org/doc/html/rfc9224
|
|
5
|
+
* Data: https://data.iana.org/rdap/dns.json
|
|
6
|
+
*/
|
|
7
|
+
const BOOTSTRAP_URL = "https://data.iana.org/rdap/dns.json";
|
|
8
|
+
const BOOTSTRAP_TTL_MS = 24 * 60 * 60 * 1000; // 1 day
|
|
9
|
+
let cache = null;
|
|
10
|
+
let inflight = null;
|
|
11
|
+
async function loadBootstrap(timeoutMs) {
|
|
12
|
+
if (cache && cache.expires > Date.now())
|
|
13
|
+
return cache.map;
|
|
14
|
+
if (inflight)
|
|
15
|
+
return inflight;
|
|
16
|
+
inflight = (async () => {
|
|
17
|
+
const res = await fetchWithTimeout(BOOTSTRAP_URL, timeoutMs, {
|
|
18
|
+
headers: { accept: "application/json" },
|
|
19
|
+
});
|
|
20
|
+
if (!res.ok) {
|
|
21
|
+
throw new Error(`RDAP bootstrap fetch failed: HTTP ${res.status}`);
|
|
22
|
+
}
|
|
23
|
+
const data = (await res.json());
|
|
24
|
+
const map = new Map();
|
|
25
|
+
for (const [tlds, urls] of data.services) {
|
|
26
|
+
const bases = urls.map((u) => u.replace(/\/$/, ""));
|
|
27
|
+
for (const tld of tlds) {
|
|
28
|
+
map.set(tld.toLowerCase(), bases);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
cache = { map, expires: Date.now() + BOOTSTRAP_TTL_MS };
|
|
32
|
+
return map;
|
|
33
|
+
})();
|
|
34
|
+
try {
|
|
35
|
+
return await inflight;
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
inflight = null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Returns the RDAP base URLs for a TLD, or null if the TLD has no RDAP. */
|
|
42
|
+
export async function rdapBaseForTld(tld, timeoutMs) {
|
|
43
|
+
const map = await loadBootstrap(timeoutMs);
|
|
44
|
+
// For multi-label TLDs (e.g. "co.uk") fall back to the last label.
|
|
45
|
+
const direct = map.get(tld.toLowerCase());
|
|
46
|
+
if (direct)
|
|
47
|
+
return direct;
|
|
48
|
+
const lastLabel = tld.split(".").pop();
|
|
49
|
+
if (lastLabel && lastLabel !== tld) {
|
|
50
|
+
return map.get(lastLabel.toLowerCase()) ?? null;
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** A tiny in-memory TTL cache with a bounded size (simple LRU-ish eviction). */
|
|
2
|
+
export class TtlCache {
|
|
3
|
+
ttlMs;
|
|
4
|
+
maxEntries;
|
|
5
|
+
store = new Map();
|
|
6
|
+
constructor(ttlMs, maxEntries = 5000) {
|
|
7
|
+
this.ttlMs = ttlMs;
|
|
8
|
+
this.maxEntries = maxEntries;
|
|
9
|
+
}
|
|
10
|
+
get(key) {
|
|
11
|
+
const hit = this.store.get(key);
|
|
12
|
+
if (!hit)
|
|
13
|
+
return undefined;
|
|
14
|
+
if (hit.expires < Date.now()) {
|
|
15
|
+
this.store.delete(key);
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
// Refresh recency.
|
|
19
|
+
this.store.delete(key);
|
|
20
|
+
this.store.set(key, hit);
|
|
21
|
+
return hit.value;
|
|
22
|
+
}
|
|
23
|
+
set(key, value) {
|
|
24
|
+
if (this.ttlMs <= 0)
|
|
25
|
+
return;
|
|
26
|
+
if (this.store.size >= this.maxEntries) {
|
|
27
|
+
const oldest = this.store.keys().next().value;
|
|
28
|
+
if (oldest !== undefined)
|
|
29
|
+
this.store.delete(oldest);
|
|
30
|
+
}
|
|
31
|
+
this.store.set(key, { value, expires: Date.now() + this.ttlMs });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Run async tasks with bounded concurrency, preserving input order. */
|
|
35
|
+
export async function mapWithConcurrency(items, limit, fn) {
|
|
36
|
+
const results = new Array(items.length);
|
|
37
|
+
let cursor = 0;
|
|
38
|
+
const workers = new Array(Math.max(1, Math.min(limit, items.length)))
|
|
39
|
+
.fill(0)
|
|
40
|
+
.map(async () => {
|
|
41
|
+
while (true) {
|
|
42
|
+
const index = cursor++;
|
|
43
|
+
if (index >= items.length)
|
|
44
|
+
return;
|
|
45
|
+
results[index] = await fn(items[index], index);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
await Promise.all(workers);
|
|
49
|
+
return results;
|
|
50
|
+
}
|
|
51
|
+
/** fetch() with a timeout via AbortController. */
|
|
52
|
+
export async function fetchWithTimeout(url, timeoutMs, init = {}) {
|
|
53
|
+
const controller = new AbortController();
|
|
54
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
55
|
+
try {
|
|
56
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { domainToASCII, domainToUnicode } from "node:url";
|
|
2
|
+
const LABEL_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
3
|
+
/**
|
|
4
|
+
* Accepts user input that may be a bare domain, a URL, or an IDN, and returns
|
|
5
|
+
* a normalized form. Throws on input that cannot be a valid domain.
|
|
6
|
+
*/
|
|
7
|
+
export function normalizeDomain(input) {
|
|
8
|
+
if (!input || typeof input !== "string") {
|
|
9
|
+
throw new Error("Domain is required.");
|
|
10
|
+
}
|
|
11
|
+
let value = input.trim().toLowerCase();
|
|
12
|
+
// Strip a scheme + path if the user passed a URL.
|
|
13
|
+
if (value.includes("://")) {
|
|
14
|
+
try {
|
|
15
|
+
value = new URL(value).hostname;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
throw new Error(`Could not parse "${input}" as a domain or URL.`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
// Strip any stray path/query (e.g. "example.com/foo").
|
|
23
|
+
value = value.split("/")[0].split("?")[0];
|
|
24
|
+
}
|
|
25
|
+
// Drop a leading "www." for the purpose of availability checks.
|
|
26
|
+
value = value.replace(/^www\./, "");
|
|
27
|
+
// Drop a trailing dot (fully-qualified form).
|
|
28
|
+
value = value.replace(/\.$/, "");
|
|
29
|
+
if (!value.includes(".")) {
|
|
30
|
+
throw new Error(`"${input}" is missing a TLD (expected something like "example.com").`);
|
|
31
|
+
}
|
|
32
|
+
const ascii = domainToASCII(value);
|
|
33
|
+
if (!ascii) {
|
|
34
|
+
throw new Error(`"${input}" is not a valid domain name.`);
|
|
35
|
+
}
|
|
36
|
+
const unicode = domainToUnicode(ascii);
|
|
37
|
+
const firstDot = ascii.indexOf(".");
|
|
38
|
+
const label = ascii.slice(0, firstDot);
|
|
39
|
+
const tld = ascii.slice(firstDot + 1);
|
|
40
|
+
if (!label || !tld) {
|
|
41
|
+
throw new Error(`"${input}" is not a well-formed domain name.`);
|
|
42
|
+
}
|
|
43
|
+
// Validate each label looks like a hostname label.
|
|
44
|
+
for (const part of ascii.split(".")) {
|
|
45
|
+
if (!LABEL_RE.test(part)) {
|
|
46
|
+
throw new Error(`"${input}" contains an invalid label: "${part}".`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return { ascii, unicode, label, tld };
|
|
50
|
+
}
|
|
51
|
+
/** Build an ascii domain from a label + tld, normalizing IDN. */
|
|
52
|
+
export function buildDomain(label, tld) {
|
|
53
|
+
return normalizeDomain(`${label}.${tld.replace(/^\./, "")}`);
|
|
54
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { domainToUnicode } from "node:url";
|
|
2
|
+
import { fetchWithTimeout } from "../lib/cache.js";
|
|
3
|
+
import { rdapBaseForTld } from "../lib/bootstrap.js";
|
|
4
|
+
function extractRegistrar(data) {
|
|
5
|
+
const reg = data.entities?.find((e) => e.roles?.includes("registrar"));
|
|
6
|
+
if (!reg)
|
|
7
|
+
return undefined;
|
|
8
|
+
// vcardArray is ["vcard", [ ["fn", {}, "text", "Registrar Name"], ... ]]
|
|
9
|
+
const vcard = reg.vcardArray;
|
|
10
|
+
const fn = vcard?.[1]?.find((entry) => entry[0] === "fn");
|
|
11
|
+
return fn?.[3];
|
|
12
|
+
}
|
|
13
|
+
function extractEvent(data, action) {
|
|
14
|
+
return data.events?.find((e) => e.eventAction === action)?.eventDate;
|
|
15
|
+
}
|
|
16
|
+
function toRegistrationInfo(data) {
|
|
17
|
+
return {
|
|
18
|
+
registrar: extractRegistrar(data),
|
|
19
|
+
statuses: data.status,
|
|
20
|
+
createdDate: extractEvent(data, "registration"),
|
|
21
|
+
updatedDate: extractEvent(data, "last changed"),
|
|
22
|
+
expiryDate: extractEvent(data, "expiration"),
|
|
23
|
+
nameservers: data.nameservers
|
|
24
|
+
?.map((n) => n.ldhName)
|
|
25
|
+
.filter((n) => Boolean(n)),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export class RdapProvider {
|
|
29
|
+
timeoutMs;
|
|
30
|
+
name = "rdap";
|
|
31
|
+
constructor(timeoutMs) {
|
|
32
|
+
this.timeoutMs = timeoutMs;
|
|
33
|
+
}
|
|
34
|
+
async checkAvailability(ascii, tld) {
|
|
35
|
+
const unicode = domainToUnicode(ascii);
|
|
36
|
+
const base = { domain: ascii, domainUnicode: unicode, source: this.name };
|
|
37
|
+
let bases;
|
|
38
|
+
try {
|
|
39
|
+
bases = await rdapBaseForTld(tld, this.timeoutMs);
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
return {
|
|
43
|
+
...base,
|
|
44
|
+
availability: "unknown",
|
|
45
|
+
reason: `RDAP bootstrap unavailable: ${err.message}`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
if (!bases || bases.length === 0) {
|
|
49
|
+
return {
|
|
50
|
+
...base,
|
|
51
|
+
availability: "unknown",
|
|
52
|
+
reason: `No RDAP service is published for ".${tld}".`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
let lastError = "";
|
|
56
|
+
for (const baseUrl of bases) {
|
|
57
|
+
const url = `${baseUrl}/domain/${encodeURIComponent(ascii)}`;
|
|
58
|
+
try {
|
|
59
|
+
const res = await fetchWithTimeout(url, this.timeoutMs, {
|
|
60
|
+
headers: { accept: "application/rdap+json, application/json" },
|
|
61
|
+
});
|
|
62
|
+
if (res.status === 404) {
|
|
63
|
+
return { ...base, availability: "available" };
|
|
64
|
+
}
|
|
65
|
+
if (res.ok) {
|
|
66
|
+
const data = (await res.json());
|
|
67
|
+
return {
|
|
68
|
+
...base,
|
|
69
|
+
availability: "registered",
|
|
70
|
+
registration: toRegistrationInfo(data),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
// 429/5xx etc: try the next base, but remember the error.
|
|
74
|
+
lastError = `HTTP ${res.status} from ${baseUrl}`;
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
lastError = `${err.message} (${baseUrl})`;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
...base,
|
|
82
|
+
availability: "unknown",
|
|
83
|
+
reason: `RDAP lookup did not return a definitive answer: ${lastError}`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
import { domainToUnicode } from "node:url";
|
|
3
|
+
const IANA_WHOIS = "whois.iana.org";
|
|
4
|
+
const WHOIS_PORT = 43;
|
|
5
|
+
/** Phrases that indicate a domain is NOT registered (available). */
|
|
6
|
+
const FREE_PATTERNS = [
|
|
7
|
+
/\bno match\b/i,
|
|
8
|
+
/\bnot found\b/i,
|
|
9
|
+
/\bno data found\b/i,
|
|
10
|
+
/\bno entries found\b/i,
|
|
11
|
+
/\bnot registered\b/i,
|
|
12
|
+
/\bavailable for registration\b/i,
|
|
13
|
+
/\bstatus:\s*free\b/i,
|
|
14
|
+
/\bstatus:\s*available\b/i,
|
|
15
|
+
/\bdomain not found\b/i,
|
|
16
|
+
/^\s*no object found/im,
|
|
17
|
+
];
|
|
18
|
+
/** Phrases that indicate a domain IS registered. */
|
|
19
|
+
const TAKEN_PATTERNS = [
|
|
20
|
+
/^domain name:/im,
|
|
21
|
+
/^domain:\s*\S/im, // DENIC (.de) and others echo "Domain: <name>" only when taken
|
|
22
|
+
/\bregistrar:/i,
|
|
23
|
+
/\bcreation date:/i,
|
|
24
|
+
/\bcreated:/i,
|
|
25
|
+
/\bregistry domain id:/i,
|
|
26
|
+
/\bname server:/i,
|
|
27
|
+
/\bnserver:/i,
|
|
28
|
+
/\bstatus:\s*(active|connect|ok|registered)\b/i,
|
|
29
|
+
];
|
|
30
|
+
function queryWhois(host, query, timeoutMs) {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const socket = net.createConnection(WHOIS_PORT, host);
|
|
33
|
+
let data = "";
|
|
34
|
+
socket.setTimeout(timeoutMs);
|
|
35
|
+
socket.on("connect", () => socket.write(`${query}\r\n`));
|
|
36
|
+
socket.on("data", (chunk) => (data += chunk.toString("utf8")));
|
|
37
|
+
socket.on("end", () => resolve(data));
|
|
38
|
+
socket.on("timeout", () => {
|
|
39
|
+
socket.destroy();
|
|
40
|
+
reject(new Error(`WHOIS timeout querying ${host}`));
|
|
41
|
+
});
|
|
42
|
+
socket.on("error", (err) => reject(err));
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/** Find the authoritative WHOIS server for a TLD by asking IANA. */
|
|
46
|
+
async function whoisServerForTld(tld, timeoutMs) {
|
|
47
|
+
const lastLabel = tld.split(".").pop() ?? tld;
|
|
48
|
+
const response = await queryWhois(IANA_WHOIS, lastLabel, timeoutMs);
|
|
49
|
+
const match = response.match(/^\s*whois:\s*(\S+)\s*$/im);
|
|
50
|
+
return match ? match[1].trim() : null;
|
|
51
|
+
}
|
|
52
|
+
function classify(text) {
|
|
53
|
+
// Free patterns win first (a "no match" body may still contain stray keywords).
|
|
54
|
+
if (FREE_PATTERNS.some((re) => re.test(text)))
|
|
55
|
+
return "available";
|
|
56
|
+
if (TAKEN_PATTERNS.some((re) => re.test(text)))
|
|
57
|
+
return "registered";
|
|
58
|
+
return "unknown";
|
|
59
|
+
}
|
|
60
|
+
export class WhoisProvider {
|
|
61
|
+
timeoutMs;
|
|
62
|
+
name = "whois";
|
|
63
|
+
constructor(timeoutMs) {
|
|
64
|
+
this.timeoutMs = timeoutMs;
|
|
65
|
+
}
|
|
66
|
+
async checkAvailability(ascii, tld) {
|
|
67
|
+
const unicode = domainToUnicode(ascii);
|
|
68
|
+
const base = { domain: ascii, domainUnicode: unicode, source: this.name };
|
|
69
|
+
let server;
|
|
70
|
+
try {
|
|
71
|
+
server = await whoisServerForTld(tld, this.timeoutMs);
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
return {
|
|
75
|
+
...base,
|
|
76
|
+
availability: "unknown",
|
|
77
|
+
reason: `Could not resolve WHOIS server for ".${tld}": ${err.message}`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
if (!server) {
|
|
81
|
+
return {
|
|
82
|
+
...base,
|
|
83
|
+
availability: "unknown",
|
|
84
|
+
reason: `No WHOIS server is published for ".${tld}".`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const text = await queryWhois(server, ascii, this.timeoutMs);
|
|
89
|
+
const availability = classify(text);
|
|
90
|
+
return {
|
|
91
|
+
...base,
|
|
92
|
+
availability,
|
|
93
|
+
reason: availability === "unknown"
|
|
94
|
+
? `WHOIS response from ${server} was inconclusive.`
|
|
95
|
+
: undefined,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
return {
|
|
100
|
+
...base,
|
|
101
|
+
availability: "unknown",
|
|
102
|
+
reason: `WHOIS query to ${server} failed: ${err.message}`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
package/dist/service.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { TtlCache, mapWithConcurrency } from "./lib/cache.js";
|
|
2
|
+
import { normalizeDomain, buildDomain } from "./lib/normalize.js";
|
|
3
|
+
import { RdapProvider } from "./providers/rdap.js";
|
|
4
|
+
import { WhoisProvider } from "./providers/whois.js";
|
|
5
|
+
/**
|
|
6
|
+
* Orchestrates the providers: RDAP first, WHOIS fallback when RDAP returns
|
|
7
|
+
* "unknown" (e.g. a TLD without RDAP), plus a short-TTL cache.
|
|
8
|
+
*/
|
|
9
|
+
export class DomainService {
|
|
10
|
+
config;
|
|
11
|
+
rdap;
|
|
12
|
+
whois;
|
|
13
|
+
cache;
|
|
14
|
+
constructor(config) {
|
|
15
|
+
this.config = config;
|
|
16
|
+
this.rdap = new RdapProvider(config.requestTimeoutMs);
|
|
17
|
+
this.whois = new WhoisProvider(config.requestTimeoutMs);
|
|
18
|
+
this.cache = new TtlCache(config.cacheTtlSeconds * 1000);
|
|
19
|
+
}
|
|
20
|
+
async checkOne(input) {
|
|
21
|
+
const { ascii, tld } = normalizeDomain(input);
|
|
22
|
+
const cached = this.cache.get(ascii);
|
|
23
|
+
if (cached)
|
|
24
|
+
return cached;
|
|
25
|
+
let result = await this.rdap.checkAvailability(ascii, tld);
|
|
26
|
+
// Fall back to WHOIS only when RDAP couldn't give a definitive answer.
|
|
27
|
+
if (result.availability === "unknown") {
|
|
28
|
+
const whoisResult = await this.whois.checkAvailability(ascii, tld);
|
|
29
|
+
if (whoisResult.availability !== "unknown") {
|
|
30
|
+
result = whoisResult;
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
// Keep the richer RDAP reason but note both were tried.
|
|
34
|
+
result = {
|
|
35
|
+
...result,
|
|
36
|
+
source: "rdap+whois",
|
|
37
|
+
reason: `${result.reason ?? "RDAP inconclusive."} WHOIS also inconclusive: ${whoisResult.reason ?? ""}`.trim(),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
this.cache.set(ascii, result);
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
async checkMany(inputs) {
|
|
45
|
+
return mapWithConcurrency(inputs, this.config.concurrency, (d) => this.checkOne(d).catch((err) => ({
|
|
46
|
+
domain: d,
|
|
47
|
+
domainUnicode: d,
|
|
48
|
+
availability: "unknown",
|
|
49
|
+
source: "error",
|
|
50
|
+
reason: err.message,
|
|
51
|
+
})));
|
|
52
|
+
}
|
|
53
|
+
/** Expand a label across TLDs and return only the available ones. */
|
|
54
|
+
async suggest(name, tlds) {
|
|
55
|
+
const label = name.trim().toLowerCase().replace(/\.$/, "");
|
|
56
|
+
const useTlds = (tlds && tlds.length ? tlds : this.config.defaultTlds).map((t) => t.replace(/^\./, "").toLowerCase());
|
|
57
|
+
const domains = useTlds.map((tld) => buildDomain(label, tld).ascii);
|
|
58
|
+
const results = await this.checkMany(domains);
|
|
59
|
+
return results.filter((r) => r.availability === "available");
|
|
60
|
+
}
|
|
61
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "domains-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server for checking domain availability via RDAP (with WHOIS fallback). Free, no API keys.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"domains-mcp": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsc",
|
|
14
|
+
"start": "node dist/index.js",
|
|
15
|
+
"dev": "tsc --watch",
|
|
16
|
+
"check": "node dist/cli-smoke.js",
|
|
17
|
+
"prepublishOnly": "npm run build"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"mcp",
|
|
21
|
+
"model-context-protocol",
|
|
22
|
+
"domain",
|
|
23
|
+
"availability",
|
|
24
|
+
"rdap",
|
|
25
|
+
"whois",
|
|
26
|
+
"claude"
|
|
27
|
+
],
|
|
28
|
+
"author": "fulldev",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"homepage": "https://github.com/fulldev-pl1/domains-mcp#readme",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/fulldev-pl1/domains-mcp.git"
|
|
34
|
+
},
|
|
35
|
+
"bugs": {
|
|
36
|
+
"url": "https://github.com/fulldev-pl1/domains-mcp/issues"
|
|
37
|
+
},
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=20"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
43
|
+
"zod": "^3.23.8"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@types/node": "^20.14.0",
|
|
47
|
+
"typescript": "^5.5.0"
|
|
48
|
+
}
|
|
49
|
+
}
|