@cyanheads/whois-mcp-server 0.1.5 → 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.
Files changed (52) hide show
  1. package/AGENTS.md +27 -14
  2. package/CLAUDE.md +27 -14
  3. package/Dockerfile +48 -34
  4. package/README.md +30 -35
  5. package/changelog/0.1.x/0.1.6.md +41 -0
  6. package/changelog/0.2.x/0.2.0.md +40 -0
  7. package/dist/app.d.ts +67 -26
  8. package/dist/app.d.ts.map +1 -1
  9. package/dist/app.js +7 -5
  10. package/dist/app.js.map +1 -1
  11. package/dist/mcp-server/tools/definitions/_fqdn.d.ts +9 -3
  12. package/dist/mcp-server/tools/definitions/_fqdn.d.ts.map +1 -1
  13. package/dist/mcp-server/tools/definitions/_fqdn.js +10 -14
  14. package/dist/mcp-server/tools/definitions/_fqdn.js.map +1 -1
  15. package/dist/mcp-server/tools/definitions/whois-check-availability.tool.d.ts +11 -5
  16. package/dist/mcp-server/tools/definitions/whois-check-availability.tool.d.ts.map +1 -1
  17. package/dist/mcp-server/tools/definitions/whois-check-availability.tool.js +25 -40
  18. package/dist/mcp-server/tools/definitions/whois-check-availability.tool.js.map +1 -1
  19. package/dist/mcp-server/tools/definitions/whois-get-dns.tool.d.ts +23 -4
  20. package/dist/mcp-server/tools/definitions/whois-get-dns.tool.d.ts.map +1 -1
  21. package/dist/mcp-server/tools/definitions/whois-get-dns.tool.js +44 -28
  22. package/dist/mcp-server/tools/definitions/whois-get-dns.tool.js.map +1 -1
  23. package/dist/mcp-server/tools/definitions/whois-get-dossier.tool.d.ts +0 -5
  24. package/dist/mcp-server/tools/definitions/whois-get-dossier.tool.d.ts.map +1 -1
  25. package/dist/mcp-server/tools/definitions/whois-get-dossier.tool.js +58 -53
  26. package/dist/mcp-server/tools/definitions/whois-get-dossier.tool.js.map +1 -1
  27. package/dist/mcp-server/tools/definitions/whois-lookup-asn.tool.d.ts +2 -2
  28. package/dist/mcp-server/tools/definitions/whois-lookup-asn.tool.d.ts.map +1 -1
  29. package/dist/mcp-server/tools/definitions/whois-lookup-asn.tool.js +9 -5
  30. package/dist/mcp-server/tools/definitions/whois-lookup-asn.tool.js.map +1 -1
  31. package/dist/mcp-server/tools/definitions/whois-lookup-domain.tool.d.ts +16 -9
  32. package/dist/mcp-server/tools/definitions/whois-lookup-domain.tool.d.ts.map +1 -1
  33. package/dist/mcp-server/tools/definitions/whois-lookup-domain.tool.js +60 -24
  34. package/dist/mcp-server/tools/definitions/whois-lookup-domain.tool.js.map +1 -1
  35. package/dist/mcp-server/tools/definitions/whois-lookup-ip.tool.d.ts +16 -2
  36. package/dist/mcp-server/tools/definitions/whois-lookup-ip.tool.d.ts.map +1 -1
  37. package/dist/mcp-server/tools/definitions/whois-lookup-ip.tool.js +44 -17
  38. package/dist/mcp-server/tools/definitions/whois-lookup-ip.tool.js.map +1 -1
  39. package/dist/services/doh/doh-service.d.ts +3 -3
  40. package/dist/services/doh/doh-service.d.ts.map +1 -1
  41. package/dist/services/doh/doh-service.js +71 -24
  42. package/dist/services/doh/doh-service.js.map +1 -1
  43. package/dist/services/doh/types.d.ts +16 -0
  44. package/dist/services/doh/types.d.ts.map +1 -1
  45. package/dist/services/rdap/rdap-service.d.ts +20 -8
  46. package/dist/services/rdap/rdap-service.d.ts.map +1 -1
  47. package/dist/services/rdap/rdap-service.js +200 -145
  48. package/dist/services/rdap/rdap-service.js.map +1 -1
  49. package/dist/services/rdap/types.d.ts +32 -2
  50. package/dist/services/rdap/types.d.ts.map +1 -1
  51. package/package.json +14 -12
  52. package/server.json +11 -25
package/AGENTS.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # Developer Protocol
2
2
 
3
3
  **Server:** whois-mcp-server
4
- **Version:** 0.1.5
5
- **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.6`
4
+ **Version:** 0.2.0
5
+ **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.15`
6
6
  **Engines:** Bun ≥1.4.0, Node ≥24.0.0
7
- **MCP SDK:** `@modelcontextprotocol/server` ^2.0.0
7
+ **MCP SDK:** `@modelcontextprotocol/server` ^2.3.1
8
8
  **Zod:** ^4.6.5
9
9
 
10
10
  > **Read the framework docs first:** `node_modules/@cyanheads/mcp-ts-core/CLAUDE.md` contains the full API reference — builders, Context, error codes, exports, patterns. This file covers server-specific conventions only.
@@ -52,7 +52,7 @@ import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
52
52
  import { getRdapService } from '@/services/rdap/rdap-service.js';
53
53
 
54
54
  export const whoisLookupDomain = tool('whois_lookup_domain', {
55
- description: 'Look up a domain\'s registration record — registrar, created/expiry dates, nameservers, EPP status codes, DNSSEC flag, and registrant org (where not privacy-redacted). RDAP-first via IANA auto-bootstrap.',
55
+ description: 'Look up registry registration data with provider DNSSEC and privacy evidence. Public registrant org is independent of contact redaction.',
56
56
  annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
57
57
  input: z.object({
58
58
  domain: z.string().describe('Domain name to look up (e.g. "github.com")'),
@@ -67,8 +67,8 @@ export const whoisLookupDomain = tool('whois_lookup_domain', {
67
67
  when: 'TLD has no RDAP server in IANA bootstrap',
68
68
  recovery: 'Use whois_check_availability instead or try a different TLD.' },
69
69
  { reason: 'domain_not_found', code: JsonRpcErrorCode.NotFound,
70
- when: 'RDAP returned 404 — domain not registered',
71
- recovery: 'Use whois_check_availability to confirm the domain is available.' },
70
+ when: 'RDAP returned 404 — no registry object found',
71
+ recovery: 'Use whois_check_availability for record status; no eligibility guarantee is implied.' },
72
72
  ],
73
73
  async handler(input, ctx) {
74
74
  const rdap = getRdapService();
@@ -110,6 +110,8 @@ export function getServerConfig() {
110
110
 
111
111
  For env booleans use `z.stringbool()`, never `z.coerce.boolean()` — `Boolean("false")` is `true`, so a coerced flag can't be disabled through the environment. `z.stringbool()` parses `true/false/1/0/yes/no/on/off` and rejects anything else, so `=false` actually disables.
112
112
 
113
+ Call `getServerConfig()` in `createApp()`'s `setup()` before initializing services so invalid environment values fail startup before the transport binds.
114
+
113
115
  ### Server identity and instructions
114
116
 
115
117
  `createApp()` accepts optional identity fields forwarded to the SDK's `initialize` response and the server manifest (`/.well-known/mcp.json`):
@@ -151,13 +153,14 @@ Handlers receive a unified `ctx` object. Key properties:
151
153
  | Property | Description |
152
154
  |:---------|:------------|
153
155
  | `ctx.log` | Request-scoped logger — `.debug()`, `.info()`, `.notice()`, `.warning()`, `.error()`. Auto-correlates requestId, traceId, tenantId. Dual-sink: Pino **and** `notifications/message` to the client, so treat it as client-visible. |
154
- | `ctx.state` | Tenant-scoped KV — `.get(key)`, `.set(key, value, { ttl? })`, `.delete(key)`, `.getMany(keys)`, `.list(prefix, { cursor, limit })`. Accepts any serializable value. |
156
+ | `ctx.state` | Tenant-scoped KV — `.get(key)`, `.set(key, value, { ttl? })`, `.delete(key)`, `.getMany(keys)`, `.list(prefix, { cursor, limit })`. Accepts any JSON-serializable value; reads return its JSON form (a `Date` comes back as an ISO string). |
155
157
  | `ctx.requestInput` | Suspend and ask the caller for more input — `return ctx.requestInput({ inputRequests: { key: inputRequired.elicit({ message, requestedSchema }) } })`. Never returns; the handler is re-entered with the answers. Always present. |
156
- | `ctx.inputs` | Reader over a retried request's responses — `.accepted(key, schema)`, `.view(key)`, `.state()`, `.dropped`. Empty on the first round. |
158
+ | `ctx.inputs` | The request's responses — `.accepted(key, schema)`, `.view(key)`, `.state()`, `.dropped` — limited to what the client declared (`elicitation` and its form/url modes, `sampling`, `roots`). Client-supplied: a consent gate trusts only a `ctx.state` record it stored when it asked, bound to the operation, caller, and target (see the `api-context` skill). |
159
+ | `ctx.clientCapabilities` | What the client declared for this request, `undefined` when no view exists. Decides whether to ask for optional context (e.g. roots); never a reason to skip a consent prompt. |
157
160
  | `ctx.enrich` | Success-path agent context (empty-result notices, query echo, pagination totals) — `ctx.enrich(...)` or `.notice()` / `.total()` / `.echo()` / `.truncated()`. Reaches `structuredContent` and `content[]`; lands only when the definition declares an `enrichment` block (no-op otherwise). |
158
161
  | `ctx.content` | Non-text content blocks — `.image(data, mimeType)`, `.audio(data, mimeType)`, or `ctx.content(block)` for a raw block. Prepended to `content[]` after `format()`; never enters `structuredContent`. |
159
162
  | `ctx.signal` | `AbortSignal` for cancellation. |
160
- | `ctx.requestId` | Unique request ID. |
163
+ | `ctx.requestId` | Request ID — the one every log record of the call carries and its error envelope returns as `data.requestId`. |
161
164
  | `ctx.tenantId` | Tenant ID from JWT; `'default'` for stdio or HTTP with auth off. |
162
165
 
163
166
  ---
@@ -166,7 +169,7 @@ Handlers receive a unified `ctx` object. Key properties:
166
169
 
167
170
  Handlers throw — the framework catches, classifies, and formats.
168
171
 
169
- **Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable?, severity?, thrownBy? }]` on `tool()` / `resource()` to receive `ctx.fail(reason, …)` typed against the reason union. TypeScript catches typos at compile time, `data.reason` is auto-populated for observability, linter enforces conformance against the handler body. `recovery` is required (≥ 5 words, lint-validated) — the single source of truth for the agent's next move. Pass `ctx.recoveryFor('reason')` as the throw's data to put it on the wire (`data.recovery.hint`, mirrored into `content[]` text unless the message already contains it verbatim); override with an explicit `{ recovery: { hint: '...' } }` when dynamic runtime context matters. Forwarding it is lint-enforced per throw site (`error-contract-recovery-unforwarded`). Mark an entry the service layer throws with `thrownBy: 'service'` so `error-contract-unthrown` skips it — lint-only metadata, nothing at runtime reads it. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) bubble freely and don't need declaring.
172
+ **Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable?, severity?, thrownBy? }]` on `tool()` / `resource()` to receive `ctx.fail(reason, …)` typed against the reason union. TypeScript catches typos at compile time, `data.reason` is auto-populated for observability, linter enforces conformance against the handler body. `recovery` is required (≥ 5 words, lint-validated) — the single source of truth for the agent's next move. The framework puts it on the wire whenever a failure carrying that `reason` arrives without a hint — a bare `ctx.fail('reason')` or a service throw with `data: { reason }` — as `data.recovery.hint`, mirrored into `content[]` text unless the message already contains it verbatim; override with an explicit `{ recovery: { hint: '...' } }` when dynamic runtime context matters. Every error envelope also carries `data.requestId`, the id the server's log records for that call carry, and `content[]` closes with `(reason … · request <id>)`. Mark an entry the service layer throws with `thrownBy: 'service'` so `error-contract-unthrown` skips it — lint-only metadata, nothing at runtime reads it. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) bubble freely and don't need declaring.
170
173
 
171
174
  ```ts
172
175
  import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
@@ -178,7 +181,7 @@ errors: [
178
181
  ],
179
182
  async handler(input, ctx) {
180
183
  const item = await db.find(input.id);
181
- if (!item) throw ctx.fail('no_match', `No item ${input.id}`, ctx.recoveryFor('no_match'));
184
+ if (!item) throw ctx.fail('no_match', `No item ${input.id}`);
182
185
  return item;
183
186
  }
184
187
  ```
@@ -224,16 +227,26 @@ src/
224
227
  mcp-server/
225
228
  tools/definitions/
226
229
  whois-lookup-domain.tool.ts # Domain registration record via RDAP
227
- whois-check-availability.tool.ts # Domain availability check (RDAP 404 = available)
230
+ whois-check-availability.tool.ts # Registry record status; no eligibility guarantee
228
231
  whois-get-dns.tool.ts # DNS records via DoH (A, AAAA, MX, TXT, NS, CNAME, SOA, CAA, PTR)
229
232
  whois-lookup-ip.tool.ts # IP/CIDR netblock, org, abuse contact via RIR RDAP
230
233
  whois-lookup-asn.tool.ts # ASN to org/RIR resolution via RIR RDAP
231
234
  whois-get-dossier.tool.ts # One-call domain triage — registration + DNS in parallel
232
- _fqdn.ts # Shared FQDN validation helper
235
+ _fqdn.ts # Shared registration/DNS/ASN/IP syntax patterns
233
236
  ```
234
237
 
235
238
  ---
236
239
 
240
+ ## Lookup Contracts
241
+
242
+ Registry object absence never establishes registration eligibility. `whois_check_availability` reports `registration_status: registered | not_found | unknown`, with `available: false` only for a found record and null otherwise. ICANN-only suffix recognition rejects known subdomains without silently querying their parent; private suffixes do not create registry boundaries.
243
+
244
+ Registrant org comes from jCard `org`; personal `fn` is not organization data. Explicit registrant redaction and missing evidence yield true and null respectively; public org survives independently. DNSSEC preserves provider `delegationSigned`, with missing/no-coverage evidence null. Domain and dossier formatters render unknown states explicitly. Optional `include_registrar` detail stays nested, never overriding registry authority; one referral, no retry/recursion, five guarded redirects and one total RDAP deadline. Destination checks are best-effort against DNS rebinding.
245
+
246
+ DNS owner syntax separately permits underscores and one normalized root dot. Failure RCODEs and truncated replies never become empty successes; CAA does not repeat NextDNS as fallback, and cancellation starts no further requests. TXT uses complete literal fences, not an injection boundary. PTR selects only reachable owner-matched records, allows five aliases under one DNS deadline, and discloses found/not_found/failed plus failure detail. IP output retains all ordered `cidrs`, with scalar `cidr` only for one component. A missing IANA route is non-retryable `ip_no_rdap_route`; bootstrap fetch failure stays operational.
247
+
248
+ Serialized patterns are the syntax gate (`InvalidParams`/`invalid_arguments`); IP/ASN semantic parsing and exclusions retain typed reasons. Preserve existing ASN whitespace/plus/zero-padding and CIDR padding without arbitrary input ceilings. See `docs/design.md` for the domain decisions.
249
+
237
250
  ## Naming
238
251
 
239
252
  | What | Convention | Example |
@@ -363,7 +376,7 @@ security: false # optional — true ONLY for a source
363
376
 
364
377
  ## Publishing
365
378
 
366
- **Every release goes through a release PR, straight-through** — `git-wrapup`'s "Release PR mode", mode `straight-through`. One run: `git-wrapup` lands the commit stack on `release/<version>`, pushes it, and opens the PR (title = the release commit subject, body = the changelog entry plus a gates section); `release-and-publish` then fast-forwards `main` locally with `git merge --ff-only`, creates the tag on `main`'s tip, pushes `main` and the tag, deletes the branch, and publishes. A caller's brief may run a given release as `gated` instead — a `release-pr-review` pass on the open PR before `release-and-publish`. **Never merge through the GitHub UI or `gh pr merge`**: squash and rebase-merge are disabled in the repo settings because both rewrite the stack (rebase-merge also strips the SSH signatures), and a merge commit breaks the linear history.
379
+ **Every release goes through a release PR, straight-through** — `git-wrapup`'s "Release PR mode", mode `straight-through`. One run: `git-wrapup` lands the commit stack on `release/<version>`, pushes it, and opens the PR (title = the release commit subject, body = the release digest: theme line, `## Changes`, `## Gates`, changelog link last); `release-and-publish` then fast-forwards `main` locally with `git merge --ff-only`, creates the tag on `main`'s tip, pushes `main` and the tag, deletes the branch, and publishes. A caller's brief may run a given release as `gated` instead — a `release-pr-review` pass on the open PR before `release-and-publish`. **Never merge through the GitHub UI or `gh pr merge`**: squash and rebase-merge are disabled in the repo settings because both rewrite the stack (rebase-merge also strips the SSH signatures), and a merge commit breaks the linear history.
367
380
 
368
381
  ---
369
382
 
package/CLAUDE.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # Developer Protocol
2
2
 
3
3
  **Server:** whois-mcp-server
4
- **Version:** 0.1.5
5
- **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.6`
4
+ **Version:** 0.2.0
5
+ **Framework:** [@cyanheads/mcp-ts-core](https://www.npmjs.com/package/@cyanheads/mcp-ts-core) `^0.13.15`
6
6
  **Engines:** Bun ≥1.4.0, Node ≥24.0.0
7
- **MCP SDK:** `@modelcontextprotocol/server` ^2.0.0
7
+ **MCP SDK:** `@modelcontextprotocol/server` ^2.3.1
8
8
  **Zod:** ^4.6.5
9
9
 
10
10
  > **Read the framework docs first:** `node_modules/@cyanheads/mcp-ts-core/CLAUDE.md` contains the full API reference — builders, Context, error codes, exports, patterns. This file covers server-specific conventions only.
@@ -52,7 +52,7 @@ import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
52
52
  import { getRdapService } from '@/services/rdap/rdap-service.js';
53
53
 
54
54
  export const whoisLookupDomain = tool('whois_lookup_domain', {
55
- description: 'Look up a domain\'s registration record — registrar, created/expiry dates, nameservers, EPP status codes, DNSSEC flag, and registrant org (where not privacy-redacted). RDAP-first via IANA auto-bootstrap.',
55
+ description: 'Look up registry registration data with provider DNSSEC and privacy evidence. Public registrant org is independent of contact redaction.',
56
56
  annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
57
57
  input: z.object({
58
58
  domain: z.string().describe('Domain name to look up (e.g. "github.com")'),
@@ -67,8 +67,8 @@ export const whoisLookupDomain = tool('whois_lookup_domain', {
67
67
  when: 'TLD has no RDAP server in IANA bootstrap',
68
68
  recovery: 'Use whois_check_availability instead or try a different TLD.' },
69
69
  { reason: 'domain_not_found', code: JsonRpcErrorCode.NotFound,
70
- when: 'RDAP returned 404 — domain not registered',
71
- recovery: 'Use whois_check_availability to confirm the domain is available.' },
70
+ when: 'RDAP returned 404 — no registry object found',
71
+ recovery: 'Use whois_check_availability for record status; no eligibility guarantee is implied.' },
72
72
  ],
73
73
  async handler(input, ctx) {
74
74
  const rdap = getRdapService();
@@ -110,6 +110,8 @@ export function getServerConfig() {
110
110
 
111
111
  For env booleans use `z.stringbool()`, never `z.coerce.boolean()` — `Boolean("false")` is `true`, so a coerced flag can't be disabled through the environment. `z.stringbool()` parses `true/false/1/0/yes/no/on/off` and rejects anything else, so `=false` actually disables.
112
112
 
113
+ Call `getServerConfig()` in `createApp()`'s `setup()` before initializing services so invalid environment values fail startup before the transport binds.
114
+
113
115
  ### Server identity and instructions
114
116
 
115
117
  `createApp()` accepts optional identity fields forwarded to the SDK's `initialize` response and the server manifest (`/.well-known/mcp.json`):
@@ -151,13 +153,14 @@ Handlers receive a unified `ctx` object. Key properties:
151
153
  | Property | Description |
152
154
  |:---------|:------------|
153
155
  | `ctx.log` | Request-scoped logger — `.debug()`, `.info()`, `.notice()`, `.warning()`, `.error()`. Auto-correlates requestId, traceId, tenantId. Dual-sink: Pino **and** `notifications/message` to the client, so treat it as client-visible. |
154
- | `ctx.state` | Tenant-scoped KV — `.get(key)`, `.set(key, value, { ttl? })`, `.delete(key)`, `.getMany(keys)`, `.list(prefix, { cursor, limit })`. Accepts any serializable value. |
156
+ | `ctx.state` | Tenant-scoped KV — `.get(key)`, `.set(key, value, { ttl? })`, `.delete(key)`, `.getMany(keys)`, `.list(prefix, { cursor, limit })`. Accepts any JSON-serializable value; reads return its JSON form (a `Date` comes back as an ISO string). |
155
157
  | `ctx.requestInput` | Suspend and ask the caller for more input — `return ctx.requestInput({ inputRequests: { key: inputRequired.elicit({ message, requestedSchema }) } })`. Never returns; the handler is re-entered with the answers. Always present. |
156
- | `ctx.inputs` | Reader over a retried request's responses — `.accepted(key, schema)`, `.view(key)`, `.state()`, `.dropped`. Empty on the first round. |
158
+ | `ctx.inputs` | The request's responses — `.accepted(key, schema)`, `.view(key)`, `.state()`, `.dropped` — limited to what the client declared (`elicitation` and its form/url modes, `sampling`, `roots`). Client-supplied: a consent gate trusts only a `ctx.state` record it stored when it asked, bound to the operation, caller, and target (see the `api-context` skill). |
159
+ | `ctx.clientCapabilities` | What the client declared for this request, `undefined` when no view exists. Decides whether to ask for optional context (e.g. roots); never a reason to skip a consent prompt. |
157
160
  | `ctx.enrich` | Success-path agent context (empty-result notices, query echo, pagination totals) — `ctx.enrich(...)` or `.notice()` / `.total()` / `.echo()` / `.truncated()`. Reaches `structuredContent` and `content[]`; lands only when the definition declares an `enrichment` block (no-op otherwise). |
158
161
  | `ctx.content` | Non-text content blocks — `.image(data, mimeType)`, `.audio(data, mimeType)`, or `ctx.content(block)` for a raw block. Prepended to `content[]` after `format()`; never enters `structuredContent`. |
159
162
  | `ctx.signal` | `AbortSignal` for cancellation. |
160
- | `ctx.requestId` | Unique request ID. |
163
+ | `ctx.requestId` | Request ID — the one every log record of the call carries and its error envelope returns as `data.requestId`. |
161
164
  | `ctx.tenantId` | Tenant ID from JWT; `'default'` for stdio or HTTP with auth off. |
162
165
 
163
166
  ---
@@ -166,7 +169,7 @@ Handlers receive a unified `ctx` object. Key properties:
166
169
 
167
170
  Handlers throw — the framework catches, classifies, and formats.
168
171
 
169
- **Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable?, severity?, thrownBy? }]` on `tool()` / `resource()` to receive `ctx.fail(reason, …)` typed against the reason union. TypeScript catches typos at compile time, `data.reason` is auto-populated for observability, linter enforces conformance against the handler body. `recovery` is required (≥ 5 words, lint-validated) — the single source of truth for the agent's next move. Pass `ctx.recoveryFor('reason')` as the throw's data to put it on the wire (`data.recovery.hint`, mirrored into `content[]` text unless the message already contains it verbatim); override with an explicit `{ recovery: { hint: '...' } }` when dynamic runtime context matters. Forwarding it is lint-enforced per throw site (`error-contract-recovery-unforwarded`). Mark an entry the service layer throws with `thrownBy: 'service'` so `error-contract-unthrown` skips it — lint-only metadata, nothing at runtime reads it. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) bubble freely and don't need declaring.
172
+ **Recommended: typed error contract.** Declare `errors: [{ reason, code, when, recovery, retryable?, severity?, thrownBy? }]` on `tool()` / `resource()` to receive `ctx.fail(reason, …)` typed against the reason union. TypeScript catches typos at compile time, `data.reason` is auto-populated for observability, linter enforces conformance against the handler body. `recovery` is required (≥ 5 words, lint-validated) — the single source of truth for the agent's next move. The framework puts it on the wire whenever a failure carrying that `reason` arrives without a hint — a bare `ctx.fail('reason')` or a service throw with `data: { reason }` — as `data.recovery.hint`, mirrored into `content[]` text unless the message already contains it verbatim; override with an explicit `{ recovery: { hint: '...' } }` when dynamic runtime context matters. Every error envelope also carries `data.requestId`, the id the server's log records for that call carry, and `content[]` closes with `(reason … · request <id>)`. Mark an entry the service layer throws with `thrownBy: 'service'` so `error-contract-unthrown` skips it — lint-only metadata, nothing at runtime reads it. Baseline codes (`InternalError`, `ServiceUnavailable`, `Timeout`, `ValidationError`, `SerializationError`, `RequestCancelled`) bubble freely and don't need declaring.
170
173
 
171
174
  ```ts
172
175
  import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
@@ -178,7 +181,7 @@ errors: [
178
181
  ],
179
182
  async handler(input, ctx) {
180
183
  const item = await db.find(input.id);
181
- if (!item) throw ctx.fail('no_match', `No item ${input.id}`, ctx.recoveryFor('no_match'));
184
+ if (!item) throw ctx.fail('no_match', `No item ${input.id}`);
182
185
  return item;
183
186
  }
184
187
  ```
@@ -224,16 +227,26 @@ src/
224
227
  mcp-server/
225
228
  tools/definitions/
226
229
  whois-lookup-domain.tool.ts # Domain registration record via RDAP
227
- whois-check-availability.tool.ts # Domain availability check (RDAP 404 = available)
230
+ whois-check-availability.tool.ts # Registry record status; no eligibility guarantee
228
231
  whois-get-dns.tool.ts # DNS records via DoH (A, AAAA, MX, TXT, NS, CNAME, SOA, CAA, PTR)
229
232
  whois-lookup-ip.tool.ts # IP/CIDR netblock, org, abuse contact via RIR RDAP
230
233
  whois-lookup-asn.tool.ts # ASN to org/RIR resolution via RIR RDAP
231
234
  whois-get-dossier.tool.ts # One-call domain triage — registration + DNS in parallel
232
- _fqdn.ts # Shared FQDN validation helper
235
+ _fqdn.ts # Shared registration/DNS/ASN/IP syntax patterns
233
236
  ```
234
237
 
235
238
  ---
236
239
 
240
+ ## Lookup Contracts
241
+
242
+ Registry object absence never establishes registration eligibility. `whois_check_availability` reports `registration_status: registered | not_found | unknown`, with `available: false` only for a found record and null otherwise. ICANN-only suffix recognition rejects known subdomains without silently querying their parent; private suffixes do not create registry boundaries.
243
+
244
+ Registrant org comes from jCard `org`; personal `fn` is not organization data. Explicit registrant redaction and missing evidence yield true and null respectively; public org survives independently. DNSSEC preserves provider `delegationSigned`, with missing/no-coverage evidence null. Domain and dossier formatters render unknown states explicitly. Optional `include_registrar` detail stays nested, never overriding registry authority; one referral, no retry/recursion, five guarded redirects and one total RDAP deadline. Destination checks are best-effort against DNS rebinding.
245
+
246
+ DNS owner syntax separately permits underscores and one normalized root dot. Failure RCODEs and truncated replies never become empty successes; CAA does not repeat NextDNS as fallback, and cancellation starts no further requests. TXT uses complete literal fences, not an injection boundary. PTR selects only reachable owner-matched records, allows five aliases under one DNS deadline, and discloses found/not_found/failed plus failure detail. IP output retains all ordered `cidrs`, with scalar `cidr` only for one component. A missing IANA route is non-retryable `ip_no_rdap_route`; bootstrap fetch failure stays operational.
247
+
248
+ Serialized patterns are the syntax gate (`InvalidParams`/`invalid_arguments`); IP/ASN semantic parsing and exclusions retain typed reasons. Preserve existing ASN whitespace/plus/zero-padding and CIDR padding without arbitrary input ceilings. See `docs/design.md` for the domain decisions.
249
+
237
250
  ## Naming
238
251
 
239
252
  | What | Convention | Example |
@@ -363,7 +376,7 @@ security: false # optional — true ONLY for a source
363
376
 
364
377
  ## Publishing
365
378
 
366
- **Every release goes through a release PR, straight-through** — `git-wrapup`'s "Release PR mode", mode `straight-through`. One run: `git-wrapup` lands the commit stack on `release/<version>`, pushes it, and opens the PR (title = the release commit subject, body = the changelog entry plus a gates section); `release-and-publish` then fast-forwards `main` locally with `git merge --ff-only`, creates the tag on `main`'s tip, pushes `main` and the tag, deletes the branch, and publishes. A caller's brief may run a given release as `gated` instead — a `release-pr-review` pass on the open PR before `release-and-publish`. **Never merge through the GitHub UI or `gh pr merge`**: squash and rebase-merge are disabled in the repo settings because both rewrite the stack (rebase-merge also strips the SSH signatures), and a merge commit breaks the linear history.
379
+ **Every release goes through a release PR, straight-through** — `git-wrapup`'s "Release PR mode", mode `straight-through`. One run: `git-wrapup` lands the commit stack on `release/<version>`, pushes it, and opens the PR (title = the release commit subject, body = the release digest: theme line, `## Changes`, `## Gates`, changelog link last); `release-and-publish` then fast-forwards `main` locally with `git merge --ff-only`, creates the tag on `main`'s tip, pushes `main` and the tag, deletes the branch, and publishes. A caller's brief may run a given release as `gated` instead — a `release-pr-review` pass on the open PR before `release-and-publish`. **Never merge through the GitHub UI or `gh pr merge`**: squash and rebase-merge are disabled in the repo settings because both rewrite the stack (rebase-merge also strips the SSH signatures), and a merge commit breaks the linear history.
367
380
 
368
381
  ---
369
382
 
package/Dockerfile CHANGED
@@ -4,7 +4,7 @@
4
4
  # This stage installs all dependencies (including dev), builds the TypeScript
5
5
  # source code into JavaScript, and prepares the production assets.
6
6
  # ==============================================================================
7
- FROM --platform=$BUILDPLATFORM oven/bun:1.4.0 AS build
7
+ FROM --platform=$BUILDPLATFORM oven/bun:1.4.3 AS build
8
8
 
9
9
  WORKDIR /usr/src/app
10
10
 
@@ -23,6 +23,48 @@ COPY . .
23
23
  RUN bun run build
24
24
 
25
25
 
26
+ # ==============================================================================
27
+ # Production Dependencies Stage
28
+ #
29
+ # Run the scanner and OTel installer natively, cross-installing dependencies for
30
+ # the target architecture. Only node_modules enters the production image.
31
+ # ==============================================================================
32
+ FROM --platform=$BUILDPLATFORM oven/bun:1.4.3 AS deps
33
+
34
+ WORKDIR /usr/src/app
35
+
36
+ COPY package.json bun.lock bunfig.toml ./
37
+
38
+ # Seed the scanner omitted by the production install; bunfig.toml retains both
39
+ # the scanner and release-age protections for all dependency resolution.
40
+ COPY --from=build /usr/src/app/node_modules/@socketsecurity/bun-security-scanner ./node_modules/@socketsecurity/bun-security-scanner
41
+
42
+ ARG TARGETOS
43
+ ARG TARGETARCH
44
+ RUN case "$TARGETARCH" in \
45
+ amd64) echo x64 ;; \
46
+ arm64) echo arm64 ;; \
47
+ *) echo "Unsupported TARGETARCH '$TARGETARCH': expected amd64 or arm64" >&2; exit 1 ;; \
48
+ esac > .bun-cpu
49
+
50
+ RUN --mount=type=cache,target=/root/.bun/install/cache \
51
+ bun install --production --omit=peer --frozen-lockfile --ignore-scripts \
52
+ --os="$TARGETOS" --cpu="$(cat .bun-cpu)"
53
+
54
+ # Read the OTel package list and ranges from the installed framework.
55
+ COPY scripts/install-otel.ts ./scripts/
56
+ ARG OTEL_ENABLED=true
57
+ RUN --mount=type=cache,target=/root/.bun/install/cache \
58
+ if [ "$OTEL_ENABLED" = "true" ]; then \
59
+ bun scripts/install-otel.ts --os="$TARGETOS" --cpu="$(cat .bun-cpu)"; \
60
+ fi
61
+
62
+ COPY scripts/prune-musl-packages.ts ./scripts/
63
+ RUN bun scripts/prune-musl-packages.ts
64
+
65
+ RUN rm -rf node_modules/@socketsecurity/bun-security-scanner
66
+
67
+
26
68
  # ==============================================================================
27
69
  # Production Stage
28
70
  #
@@ -30,7 +72,7 @@ RUN bun run build
30
72
  # application. It uses a slim base image and only includes production
31
73
  # dependencies and build artifacts.
32
74
  # ==============================================================================
33
- FROM oven/bun:1.4.0-slim AS production
75
+ FROM oven/bun:1.4.3-slim AS production
34
76
 
35
77
  WORKDIR /usr/src/app
36
78
 
@@ -41,41 +83,14 @@ ENV NODE_ENV=production
41
83
  # OCI image metadata (https://github.com/opencontainers/image-spec/blob/main/annotations.md)
42
84
  ARG APP_VERSION
43
85
  LABEL org.opencontainers.image.title="whois-mcp-server"
44
- LABEL org.opencontainers.image.description="Look up domain registration, check availability, fetch DNS records, and resolve IPs and ASNs via RDAP and DNS-over-HTTPS via MCP. STDIO or Streamable HTTP."
86
+ LABEL org.opencontainers.image.description="Look up domain registration, check registry record status, fetch DNS records, and resolve IPs and ASNs over RDAP and DNS-over-HTTPS via MCP. STDIO or Streamable HTTP."
45
87
  LABEL org.opencontainers.image.source="https://github.com/cyanheads/whois-mcp-server"
46
88
  LABEL org.opencontainers.image.licenses="Apache-2.0"
47
89
  LABEL org.opencontainers.image.version="${APP_VERSION}"
48
90
 
49
- # Copy dependency manifests
50
- COPY package.json bun.lock ./
51
-
52
- # Install only production dependencies, ignoring any lifecycle scripts (like 'prepare')
53
- # that are not needed in the final production image.
54
- # `--omit=peer` drops the framework's optional peer tiers (test runner, service
55
- # SDKs, parsers) that Bun would otherwise auto-install. Anything this server
56
- # actually imports belongs in its own `dependencies`, so nothing needed at
57
- # runtime is lost. The OTEL step below carries the same flag — without it, that
58
- # install re-resolves the graph and pulls every optional peer back in.
59
- RUN --mount=type=cache,target=/root/.bun/install/cache \
60
- bun install --production --omit=peer --frozen-lockfile --ignore-scripts
61
-
62
- # Conditionally install OpenTelemetry optional peer dependencies (Tier 3).
63
- # These are not bundled by default to keep the base image lean. Enable at build time
64
- # with: docker build --build-arg OTEL_ENABLED=true
65
- ARG OTEL_ENABLED=true
66
- RUN --mount=type=cache,target=/root/.bun/install/cache \
67
- if [ "$OTEL_ENABLED" = "true" ]; then \
68
- bun add --omit=dev --omit=peer --ignore-scripts @hono/otel \
69
- @opentelemetry/instrumentation-http \
70
- @opentelemetry/exporter-metrics-otlp-http \
71
- @opentelemetry/exporter-trace-otlp-http \
72
- @opentelemetry/instrumentation-pino \
73
- @opentelemetry/resources \
74
- @opentelemetry/sdk-metrics \
75
- @opentelemetry/sdk-node \
76
- @opentelemetry/sdk-trace-node \
77
- @opentelemetry/semantic-conventions; \
78
- fi
91
+ # Keep the original manifest; the deps-stage OTel install rewrites its copy.
92
+ COPY package.json ./
93
+ COPY --from=deps /usr/src/app/node_modules ./node_modules
79
94
 
80
95
  # Copy the compiled application code from the build stage
81
96
  COPY --from=build /usr/src/app/dist ./dist
@@ -101,7 +116,6 @@ ENV MCP_TRANSPORT_TYPE="http"
101
116
  ENV MCP_SESSION_MODE="stateless"
102
117
  ENV MCP_LOG_LEVEL="info"
103
118
  ENV LOGS_DIR="/var/log/whois-mcp-server"
104
- ENV MCP_FORCE_CONSOLE_LOGGING="true"
105
119
 
106
120
  # Expose the port the server listens on
107
121
  EXPOSE ${MCP_HTTP_PORT}
package/README.md CHANGED
@@ -1,13 +1,13 @@
1
1
  <div align="center">
2
2
  <h1>@cyanheads/whois-mcp-server</h1>
3
- <p><b>Look up domain registration, check availability, fetch DNS records, and resolve IPs and ASNs via RDAP and DNS-over-HTTPS via MCP. STDIO or Streamable HTTP.</b>
3
+ <p><b>Look up domain registration, check registry record status, fetch DNS records, and resolve IPs and ASNs over RDAP and DNS-over-HTTPS via MCP. STDIO or Streamable HTTP.</b>
4
4
  <div>6 Tools</div>
5
5
  </p>
6
6
  </div>
7
7
 
8
8
  <div align="center">
9
9
 
10
- [![Version](https://img.shields.io/badge/Version-0.1.5-blue.svg?style=flat-square)](./CHANGELOG.md) [![License](https://img.shields.io/badge/License-Apache%202.0-orange.svg?style=flat-square)](./LICENSE) [![Docker](https://img.shields.io/badge/Docker-ghcr.io-2496ED?style=flat-square&logo=docker&logoColor=white)](https://github.com/users/cyanheads/packages/container/package/whois-mcp-server) [![MCP SDK](https://img.shields.io/badge/MCP%20SDK-^2.0.0-green.svg?style=flat-square)](https://modelcontextprotocol.io/) [![npm](https://img.shields.io/npm/v/@cyanheads/whois-mcp-server?style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/@cyanheads/whois-mcp-server) [![TypeScript](https://img.shields.io/badge/TypeScript-^7.0.2-3178C6.svg?style=flat-square)](https://www.typescriptlang.org/) [![Bun](https://img.shields.io/badge/Bun-v1.4.0-blueviolet.svg?style=flat-square)](https://bun.sh/)
10
+ [![Version](https://img.shields.io/badge/Version-0.2.0-blue.svg?style=flat-square)](./CHANGELOG.md) [![License](https://img.shields.io/badge/License-Apache%202.0-orange.svg?style=flat-square)](./LICENSE) [![Docker](https://img.shields.io/badge/Docker-ghcr.io-2496ED?style=flat-square&logo=docker&logoColor=white)](https://github.com/users/cyanheads/packages/container/package/whois-mcp-server) [![MCP SDK](https://img.shields.io/badge/MCP%20SDK-^2.3.1-green.svg?style=flat-square)](https://modelcontextprotocol.io/) [![npm](https://img.shields.io/npm/v/@cyanheads/whois-mcp-server?style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/@cyanheads/whois-mcp-server) [![TypeScript](https://img.shields.io/badge/TypeScript-^7.0.2-3178C6.svg?style=flat-square)](https://www.typescriptlang.org/) [![Bun](https://img.shields.io/badge/Bun-v1.4.3-blueviolet.svg?style=flat-square)](https://bun.sh/)
11
11
 
12
12
  </div>
13
13
 
@@ -23,15 +23,15 @@
23
23
 
24
24
  ## Overview
25
25
 
26
- Domain and network intelligence via RDAP and DNS-over-HTTPS. Look up domain registrations, check availability, fetch DNS records, and resolve IPs and ASNs to their registries — all via public, keyless data sources. Runs as a stdio process or a local Streamable HTTP server.
26
+ Domain and network intelligence via RDAP and DNS-over-HTTPS. Look up domain registrations, check registry record status, fetch DNS records, and resolve IPs and ASNs to their registries — all via public, keyless data sources. Runs as a stdio process or a local Streamable HTTP server.
27
27
 
28
28
  ### Tools
29
29
 
30
30
  | Tool | Description |
31
31
  |:-----|:------------|
32
32
  | `whois_lookup_domain` | Full domain registration record — registrar, created/expiry dates, nameservers, EPP status, DNSSEC, registrant org |
33
- | `whois_check_availability` | Check whether a domain is registered or available for registration |
34
- | `whois_get_dns` | DNS records for any hostname via DNS-over-HTTPS (A, AAAA, MX, TXT, NS, CNAME, SOA, CAA, PTR) |
33
+ | `whois_check_availability` | Check registry record presence; missing records do not establish registration eligibility |
34
+ | `whois_get_dns` | DNS owner records via DNS-over-HTTPS (A, AAAA, MX, TXT, NS, CNAME, SOA, CAA, PTR) |
35
35
  | `whois_lookup_ip` | IP or CIDR netblock, org, country, abuse contact, and reverse DNS via RIR RDAP |
36
36
  | `whois_lookup_asn` | Resolve an ASN to its org name, country, and RIR source |
37
37
  | `whois_get_dossier` | One-call domain triage — registration + DNS in parallel, normalized into a single record with factual signals |
@@ -42,58 +42,53 @@ Domain and network intelligence via RDAP and DNS-over-HTTPS. Look up domain regi
42
42
 
43
43
  ### `whois_lookup_domain` <sub>tool</sub>
44
44
 
45
- - RDAP-first via IANA auto-bootstrap — automatically selects the correct registry RDAP server per TLD
46
- - Returns registrar, creation/expiry dates, nameservers, EPP status codes, and DNSSEC delegation flag
47
- - Surfaces `registrant_redacted: true` when privacy redaction is in effect (standard post-GDPR for gTLDs)
48
- - Throws `rdap_no_coverage` when the TLD has no RDAP server in the IANA bootstrap — use `whois_check_availability` instead
49
- - Throws `domain_not_found` on an RDAP 404 (domain not registered) — use `whois_check_availability` to confirm
45
+ - Accepts a fully qualified domain name and selects the registry RDAP server through IANA bootstrap.
46
+ - Returns registrar, registration dates, nameservers, EPP status, DNSSEC, and `registrant_redacted`; throws `rdap_no_coverage` or `domain_not_found` when no record can be returned.
47
+ - Absent DNSSEC and privacy evidence stay `null`. Public jCard `org` is independent of redacted contact fields; a person's formatted name is not an organization.
48
+ - `include_registrar: true` follows one registrar RDAP referral and returns separate `registrar_detail` with `found`, `no_referral`, or `failed` status and source URL. Registry dates, nameservers, status, and DNSSEC remain authoritative. Default lookups make no registrar request. The optional exchange has no retries, at most five guarded redirects, and one `RDAP_TIMEOUT_MS` deadline; failure retains the registry result, cancellation propagates. Destination checks are best-effort against DNS rebinding.
50
49
 
51
50
  ---
52
51
 
53
52
  ### `whois_check_availability` <sub>tool</sub>
54
53
 
55
- - RDAP 404 response maps to `available: true`
56
- - Registered domains return `available: false` with `registrar` and `expiry_date`
57
- - `available: null` with `rdap_coverage: false` when the TLD has no RDAP coverage — availability cannot be determined
58
- - Thin response optimized for bulk name sweeps — no extra fields
54
+ - Accepts a fully qualified domain name for a registration check.
55
+ - Returns `registration_status: registered | not_found | unknown`. `available` is `false` for a found registration and `null` otherwise; it never returns `true`. RDAP 404 establishes record absence, not eligibility to register.
56
+ - Known ICANN subdomains return `non_registrable_domain` with their registrable parent; the query is never silently rewritten. Private suffixes do not create independent registration boundaries, and unlisted suffixes retain IANA coverage handling.
59
57
 
60
58
  ---
61
59
 
62
60
  ### `whois_get_dns` <sub>tool</sub>
63
61
 
64
- - Supports A, AAAA, MX, TXT, NS, CNAME, SOA, CAA, PTR; multiple types fetched in parallel; defaults to A, AAAA, MX, TXT, NS when `types` is omitted
65
- - Cloudflare primary, NextDNS fallback per type (CAA always uses NextDNS — Cloudflare returns raw hex wire format for it)
66
- - Returns records with TTLs and the resolving `source` (`cloudflare` or `nextdns`)
67
- - `nxdomain: true` in the result (not an error) when the domain doesn't exist in DNS
62
+ - Accepts an ASCII DNS owner name, including underscore labels (`_dmarc.example.com`) and one terminal root dot. Names normalize to at most 253 characters, with nonempty 1–63-character labels; spaces, URLs, consecutive dots, and multiple terminal dots are rejected. Optional nonempty `types`: A, AAAA, MX, TXT, NS, CNAME, SOA, CAA, PTR; omission defaults to A, AAAA, MX, TXT, NS. Duplicate types are queried once.
63
+ - Returns records with TTLs and ordered `query_sources: [{type, source}]`, including empty answers and NXDOMAIN. Legacy `source` is `cloudflare` if any query consumed a successful Cloudflare response, otherwise `nextdns`; a nonexistent domain returns `nxdomain: true` as data.
64
+ - NOERROR/NODATA stays empty data; resolver failure codes and truncated responses return errors after retries/fallback. CAA queries use NextDNS once per retry sequence. TXT values, including duplicates, empty strings and newlines, remain complete on both surfaces and use content-sized Markdown fences for literal presentation.
68
65
 
69
66
  ---
70
67
 
71
68
  ### `whois_lookup_ip` <sub>tool</sub>
72
69
 
73
- - Accepts an IPv4/IPv6 address or CIDR block; auto-routes to the correct RIR (ARIN, RIPE, APNIC, LACNIC, AFRINIC) via IANA IP bootstrap
74
- - Returns netblock CIDR, org name, country, abuse contact email
75
- - Fetches PTR (reverse DNS) via DoH as a best-effort step — `ptr: null` on failure, not an error
76
- - Throws `private_range` for RFC 1918, loopback, and link-local addresses — no RIR RDAP record exists for them
77
- - Throws `ip_not_found` on an RIR RDAP 404
70
+ - Accepts a complete IPv4/IPv6 address with at most one decimal CIDR prefix (0–32 / 0–128); no whitespace, zone IDs, or brackets. Queries the base address and echoes the original `ip`. IPv4-mapped IPv6 uses the embedded IPv4 for policy, RDAP, and PTR; other IPv6 uses 32 reversed PTR nibbles.
71
+ - Returns every provider CIDR in ordered `cidrs`; `cidr` appears only for exactly one component. Start/end addresses remain authoritative; missing CIDR extensions produce an empty list rather than an inferred prefix.
72
+ - Best-effort PTR uses `ptr_status: found | not_found | failed`; failures include `ptr_source_error` and retain RDAP data. Only reachable owner-matched PTR records are selected, through at most five alias transitions under one `DOH_TIMEOUT_MS` deadline. Cancellation propagates.
73
+ - Semantic input failures return `invalid_ip`; RIR 404 returns `ip_not_found`. A successfully loaded bootstrap with no covering route returns non-retryable `ip_no_rdap_route`, distinct from bootstrap failure.
74
+ - `private_range` enforces an explicit policy: IPv4 `0.0.0.0/8`, `10.0.0.0/8`, `100.64.0.0/10`, `127.0.0.0/8`, `169.254.0.0/16`, `172.16.0.0/12`, `192.168.0.0/16`, `255.0.0.0/8`; IPv6 `::1/128`, `fc00::/7`, `fe80::/10`. Other special-use addresses remain eligible for registry records or normal no-coverage/not-found outcomes.
78
75
 
79
76
  ---
80
77
 
81
78
  ### `whois_lookup_asn` <sub>tool</sub>
82
79
 
83
- - Accepts `AS15169` or bare integer `15169` format
84
- - Routes to the correct RIR RDAP endpoint via IANA ASN bootstrap
85
- - Returns `name`, `org_name`, `country`, `rir`, `start_autnum`, `end_autnum`
86
- - Throws `asn_not_found` on an RIR RDAP 404
80
+ - Accepts a decimal ASN from 1 to 4294967295 with an optional case-insensitive `AS` prefix and leading `+` (e.g., `AS15169`, `AS 15169`, `+15169`). Outer whitespace, whitespace after `AS`, and arbitrary zero-padding are allowed; whitespace inside digits or after `+`, suffixes, and decimals fail syntax validation. Out-of-range numbers retain `invalid_asn`.
81
+ - Returns `name`, `org_name`, `country`, `rir`, `start_autnum`, and `end_autnum`; throws `asn_not_found` when no ASN record exists.
87
82
 
88
83
  ---
89
84
 
90
85
  ### `whois_get_dossier` <sub>tool</sub>
91
86
 
92
- - Runs RDAP domain lookup and DoH (A, MX, NS, TXT) in parallel via `Promise.allSettled`
93
- - Inferred signals: `age_days`, `privacy_redacted`, `registrar`, `ns_provider` (from NS records), `mx_provider` (from MX records)
94
- - No synthesized risk scores — factual signals only; the agent decides the verdict
95
- - Partial results surfaced when one leg fails (`rdap_source_error` / `dns_source_error` on the failed leg)
96
- - Throws `both_legs_failed` only when both RDAP and DNS fail; individual leg failures are data, not errors
87
+ - Accepts a fully qualified domain name for parallel registration and A/MX/NS/TXT lookups.
88
+ - Returns registration, DNS, domain age, privacy status, and inferred NS/MX providers. Individual failures remain partial data in `rdap_source_error` or `dns_source_error`; `both_legs_failed` means neither source succeeded.
89
+ - Missing RDAP evidence, no coverage, and RDAP failure keep DNSSEC/privacy unknown. A registry miss is record absence without an eligibility guarantee; DNS failure codes and truncation populate `dns_source_error`.
90
+
91
+ Identifier syntax is advertised in JSON Schema and rejected with `InvalidParams` / `invalid_arguments` before network calls. Complete IP parsing, prefix ranges, numeric ASN bounds, and exclusion policy retain typed semantic errors. Registration names use hostname grammar separately from DNS underscore/root-dot names; ASN and CIDR inputs have no arbitrary length ceiling.
97
92
 
98
93
  ---
99
94
 
@@ -111,7 +106,7 @@ RDAP / DNS-specific:
111
106
  Agent-friendly output:
112
107
 
113
108
  - Coverage signaled two ways — `rdap_coverage: false` returned as data by `whois_check_availability` and `whois_get_dossier`, while `whois_lookup_domain` throws `rdap_no_coverage` for the same case
114
- - Privacy redaction surfaced as a field — `registrant_redacted: true` rather than silently absent contact data
109
+ - Privacy redaction surfaced from explicit registrant evidence — `true` for redaction and `null` when evidence is absent; organization data is independent
115
110
  - Partial failure model — `whois_get_dossier` marks individual legs with a `source_error` field and continues; only both-legs-fail escalates to an error
116
111
  - Factual signals, not scores — `age_days`, `privacy_redacted`, `ns_provider`, `mx_provider` are real data, not synthesized risk scores
117
112
 
@@ -177,12 +172,12 @@ For Streamable HTTP, set the transport and start the server:
177
172
 
178
173
  ```sh
179
174
  MCP_TRANSPORT_TYPE=http MCP_HTTP_PORT=3010 bun run start:http
180
- # Server listens at http://localhost:3010/mcp
175
+ # Server listens at http://127.0.0.1:3010/mcp
181
176
  ```
182
177
 
183
178
  ### Prerequisites
184
179
 
185
- - [Bun v1.3.0](https://bun.sh/) or higher (or Node.js v24+).
180
+ - [Bun v1.4.0](https://bun.sh/) or higher (or Node.js v24+).
186
181
  - No API keys required — all data sources are public.
187
182
 
188
183
  ### Installation
@@ -0,0 +1,41 @@
1
+ ---
2
+ summary: "Lookup inputs are validated whole before any request, DNS results attribute each query to its resolver, and the dossier renders every TXT record and labels providers by domain."
3
+ breaking: false
4
+ security: false
5
+ ---
6
+
7
+ # 0.1.6 — 2026-09-30
8
+
9
+ ## Added
10
+
11
+ - **`whois_get_dns` `query_sources`** ([#10](https://github.com/cyanheads/whois-mcp-server/issues/10)) — one `{type, source}` entry per requested type, in request order, naming the resolver whose response was consumed, empty answers and NXDOMAIN included. Duplicate types are queried once; `source` stays the aggregate (`cloudflare` when any query used Cloudflare, otherwise `nextdns`).
12
+ - **Contract tests over the real services** — `tests/helpers/upstream.ts` replaces only outbound HTTP, and `tests/fixtures/` holds captured IANA, ARIN, and APNIC responses that the IP suite replays.
13
+
14
+ ## Changed
15
+
16
+ - **Framework `@cyanheads/mcp-ts-core` `^0.13.6` → `^0.13.10`** — tool errors carry `data.requestId` and their text closes with `request <id>`, an integer sent for a string argument is repaired to its digits, and framework-built errors keep stack traces and request context out of `data`.
17
+ - **Recovery hints reach the wire through the framework's contract fill** — throws whose `errors[]` entry supplies the hint no longer forward `ctx.recoveryFor`.
18
+ - **Docker image** — production dependencies and the OpenTelemetry peers install in a `deps` stage on the build platform through `scripts/install-otel.ts`, at the framework's declared peer ranges, so no target-platform stage runs Bun while building. The unread `MCP_FORCE_CONSOLE_LOGGING` is gone.
19
+ - **Packaging** — `package.json` `files` carries `!dist/*.mcpb`, keeping a built bundle out of the npm tarball, and `.mcpbignore` excludes `docs/idea.md` and `logs/` from the bundle.
20
+ - **Tooling synced from the framework** — `lint:packaging` checks the bundle exclusion, `manifest.json` version parity, and the Dockerfile build platform; `lint:mcp` fails on a definition it cannot import; `release:github -- --check` validates the tag annotation.
21
+ - **Docs** — README tool sections state each tool's input and output contract, `docs/design.md` records the IP exclusion policy, and `CLAUDE.md` / `AGENTS.md` plus 31 framework skills follow the 0.13.10 templates.
22
+
23
+ ## Fixed
24
+
25
+ - **`whois_lookup_asn` validates the whole token** ([#5](https://github.com/cyanheads/whois-mcp-server/issues/5)) — a decimal ASN from 1 to 4294967295 with an optional `AS` prefix and leading `+`. `AS15169abc`, `1.5`, and out-of-range values fail as `invalid_asn` before any request instead of resolving a truncated number.
26
+ - **`whois_lookup_ip` parses IP and CIDR input completely** ([#6](https://github.com/cyanheads/whois-mcp-server/issues/6)) — extra slashes, trailing text, whitespace, zone IDs, brackets, and malformed IPv6 fail as `invalid_ip` before any request. A CIDR prefix is one decimal within 0–32 or 0–128.
27
+ - **`private_range` follows the numeric address** — equivalent IPv6 spellings classify alike (`0:0:0:0:0:0:0:1`, `fe90::1`), `::10` is no longer excluded, and the tool description lists the excluded ranges.
28
+ - **IPv6 with a dotted IPv4 tail is accepted** — `2001:db8::192.0.2.1` resolves with a 32-nibble PTR name, and IPv4-mapped addresses (`::ffff:8.8.8.8`) are classified, routed, and PTR-resolved as their embedded IPv4.
29
+ - **`whois_get_dossier` renders every TXT value** ([#8](https://github.com/cyanheads/whois-mcp-server/issues/8)) — `content[]` dropped values past the third. Each value now sits in its own fence, longer than any backtick run inside it.
30
+ - **`whois_get_dns` rejects `types: []`** ([#9](https://github.com/cyanheads/whois-mcp-server/issues/9)) — it fails as `InvalidParams` (`-32602`) before any query instead of reporting a NextDNS lookup that never ran. Omitting `types` still fetches A, AAAA, MX, TXT, NS.
31
+ - **Provider and registry labels match whole domains** ([#12](https://github.com/cyanheads/whois-mcp-server/issues/12)) — `ns_provider` and `mx_provider` compare hostnames at label boundaries with the MX priority removed, and `rdap_source` / `rir` read only the self link's hostname. `ns1.hostname.com` no longer reads as Name.com; Fastmail's `messagingengine.com` is recognized.
32
+ - **`whois_lookup_domain` description** — a TLD without RDAP coverage throws `rdap_no_coverage`; the description previously promised `rdap_coverage: false`.
33
+
34
+ ## Dependencies
35
+
36
+ - `@cyanheads/mcp-ts-core` `^0.13.6` → `^0.13.10`
37
+ - `@socketsecurity/bun-security-scanner` (dev) `^1.1.2` → `^1.1.3`
38
+ - `@types/node` (dev) `^26.6.1` → `^26.6.3`
39
+ - `ignore` (dev) `^7.0.9` → `^7.0.10`
40
+ - `vitest` (dev) `^5.0.1` → `^5.0.2`
41
+ - Bun `1.4.0` → `1.4.2` (`packageManager`, Docker base images)
@@ -0,0 +1,40 @@
1
+ ---
2
+ summary: "DNS and RDAP results preserve uncertainty, with optional registrar contact detail."
3
+ breaking: true
4
+ security: false
5
+ ---
6
+
7
+ # 0.2.0 — 2026-10-13
8
+
9
+ ## Added
10
+
11
+ - **`include_registrar`** opts `whois_lookup_domain` into one bounded registrar referral, returning separate `registrar_detail` without replacing registry fields; failed detail retains the registry record. ([#25](https://github.com/cyanheads/whois-mcp-server/issues/25))
12
+ - **DNS owner names** accept underscore labels and one terminal root dot, normalized before lookup. ([#17](https://github.com/cyanheads/whois-mcp-server/issues/17))
13
+
14
+ ## Changed
15
+
16
+ - **Registration status** is `registered`, `not_found`, or `unknown`; `available` is now `false` or `null`, never `true`. Migrate eligibility checks to registrar confirmation. Known ICANN subdomains return `non_registrable_domain` with their parent. ([#18](https://github.com/cyanheads/whois-mcp-server/issues/18))
17
+ - **Registrant evidence** reads public jCard `org` independently of contact redaction; personal `fn` is no longer organization data. Missing privacy evidence is `null`, so consumers must handle unknown separately from false. ([#19](https://github.com/cyanheads/whois-mcp-server/issues/19))
18
+ - **DNSSEC evidence** preserves explicit provider booleans and returns `null` when absent, including dossier no-coverage results; handle unknown separately from unsigned. ([#20](https://github.com/cyanheads/whois-mcp-server/issues/20))
19
+ - **IP allocations** expose all ordered `cidrs`; legacy `cidr` is populated only for one component. Read `cidrs` for multi-component allocations. ([#21](https://github.com/cyanheads/whois-mcp-server/issues/21))
20
+ - **Identifier validation** is advertised in JSON Schema on all six tools; syntax failures now return `InvalidParams` / `invalid_arguments`, while semantic IP/ASN errors retain typed reasons. ([#26](https://github.com/cyanheads/whois-mcp-server/issues/26))
21
+ - **Framework tooling** syncs skill mirrors, packaging launch checks, CodeQL categories and configuration examples; Docker uses Bun 1.4.2 → 1.4.3 and prunes unused musl dependencies from glibc images.
22
+
23
+ ## Fixed
24
+
25
+ - **IP routing** returns non-retryable `ip_no_rdap_route` for a successfully loaded bootstrap without a covering route. ([#15](https://github.com/cyanheads/whois-mcp-server/issues/15))
26
+ - **DNS failures** and truncation return `dns_response_failed` after retries/fallback; CAA avoids duplicate fallback, cancellation stops further requests, and dossiers retain partial data. ([#16](https://github.com/cyanheads/whois-mcp-server/issues/16))
27
+ - **PTR lookup** selects only reachable owner-matched records, follows at most five aliases under one deadline, and reports `ptr_status` as `found`, `not_found`, or `failed` with failure detail. ([#22](https://github.com/cyanheads/whois-mcp-server/issues/22))
28
+ - **TXT presentation** preserves complete, duplicate, empty and multiline values in literal Markdown fences sized for embedded backticks. ([#23](https://github.com/cyanheads/whois-mcp-server/issues/23))
29
+ - **Startup configuration** validates server environment values before opening the transport; MCP Registry entries launch the package bin through `npx` and select HTTP through a fixed environment value.
30
+
31
+ ## Dependencies
32
+
33
+ - `@cyanheads/mcp-ts-core` ^0.13.10 → ^0.13.15
34
+ - `pino-pretty` ^13.1.3 → ^13.2.0
35
+ - `tldts` added at ^7.4.18
36
+ - `@biomejs/biome` ^2.5.14 → ^2.5.15
37
+ - `@types/node` ^26.6.3 → ^26.6.5
38
+ - `ignore` ^7.0.10 → ^7.0.12
39
+ - `tsc-alias` ^1.9.5 → ^1.9.7
40
+ - `vitest` ^5.0.2 → ^5.0.3