@dnsdoctor/mcp 1.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/LICENSE ADDED
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
package/README.md ADDED
@@ -0,0 +1,169 @@
1
+ # DNS Doctor — Claude Code plugin
2
+
3
+ Diagnose and fix a domain's email authentication (SPF, DMARC, DKIM, MX, blacklist,
4
+ domain/SSL expiry) from inside Claude. This plugin bundles the DNS Doctor **skill**
5
+ (the scan → diagnose → fix workflow) and an **MCP server config** pointing at the
6
+ hosted DNS Doctor tools.
7
+
8
+ The moat: every fix record you get back is generated and validated by a
9
+ deterministic engine — RFC grammar plus the SPF 10-lookup counter — **never an LLM
10
+ guess**. Your agent hands the human a record that already parses correctly, not a
11
+ plausible-looking string that silently fails.
12
+
13
+ ## What's inside
14
+
15
+ ```
16
+ claude-plugin/
17
+ ├── .claude-plugin/plugin.json # plugin manifest
18
+ ├── .mcp.json # MCP server: https://dnsdoctor.dev/mcp (HTTP)
19
+ ├── skills/dns-doctor/SKILL.md # the scan → diagnose → fix workflow
20
+ ├── src/ # @dnsdoctor/mcp — the local stdio MCP server
21
+ ├── tools.json # the 11 tool definitions (generated, never hand-edited)
22
+ ├── instructions.txt # the server's own `initialize` guidance (generated)
23
+ ├── tests/ # vitest suite for the stdio server
24
+ ├── package.json tsconfig.json # npm package + build
25
+ ├── LICENSE # Apache-2.0
26
+ └── README.md
27
+ ```
28
+
29
+ ## Tools it adds
30
+
31
+ | Tool | Does |
32
+ |---|---|
33
+ | `scan_domain` | Fresh scan of a domain; full report. |
34
+ | `get_report` | Persisted report (scans once if none exists). |
35
+ | `build_dmarc_upgrade` | A validated DMARC enforcement record — `p=reject` only when the server-derived alignment gate passes. |
36
+ | `count_spf_lookups` | The SPF DNS-lookup count against the RFC limit of 10. |
37
+ | `validate_dmarc_record` | Parse and validate a DMARC record, tag by tag. |
38
+ | `generate_dmarc_record` | Build a DMARC record from a policy + reporting address. |
39
+ | `check_dkim_selector` | Look up one DKIM selector and check the key. |
40
+ | `parse_dmarc_report` | Parse an aggregate (RUA) report file into rows. |
41
+ | `check_record` | Read any DNS record type for a name. |
42
+ | `check_reverse_dns` | PTR / forward-confirmed reverse DNS for an IP. |
43
+ | `start_monitoring_signup` | A sign-up link to hand to the human who owns the domain. Sends no email and creates nothing — they open it, sign in on our page themselves (a social provider or an emailed link, whichever that deployment offers), and the domain is carried over to their dashboard already filled in; monitoring starts once they verify it with a TXT record. |
44
+
45
+ Over the hosted HTTP transport the `dnsdoctor://domains` resource (your
46
+ monitored domains) is always listed; reading it needs an API token and is
47
+ refused without one. The local stdio server registers the tools only — no
48
+ resource. Anonymous access is enough for a one-off diagnosis either way.
49
+
50
+ ## Install
51
+
52
+ ### Claude Code
53
+
54
+ Add the marketplace/repo and enable the plugin:
55
+
56
+ ```bash
57
+ /plugin marketplace add dnsdoctor/claude-plugin
58
+ /plugin install dns-doctor
59
+ ```
60
+
61
+ > Public home: [github.com/dnsdoctor/claude-plugin](https://github.com/dnsdoctor/claude-plugin)
62
+ > (org `dnsdoctor`, domain-verified). The plugin is developed in the DNS Doctor
63
+ > monorepo and published here as clean release snapshots.
64
+
65
+ Or point Claude Code at a local checkout of this directory during development.
66
+ Once enabled, the skill auto-loads and the `dns-doctor` MCP server connects to
67
+ `https://dnsdoctor.dev/mcp`.
68
+
69
+ ### claude.ai (MCP connector)
70
+
71
+ Add a custom connector with:
72
+
73
+ - **URL:** `https://dnsdoctor.dev/mcp`
74
+ - **Transport:** Streamable HTTP
75
+ - **Auth:** none (anonymous) — or a Bearer token (below)
76
+
77
+ ## Optional: API token for monitored domains
78
+
79
+ Anonymous access covers scanning and fixes. A per-account API token unlocks the
80
+ `dnsdoctor://domains` resource (your continuously-monitored domains and their
81
+ latest per-check statuses).
82
+
83
+ 1. Sign in at <https://dnsdoctor.dev> → **Settings → API tokens** → create a token.
84
+ The plaintext (`dnsd_…`) is shown once; copy it.
85
+ 2. Add the `Authorization` header to the server in `.mcp.json`:
86
+
87
+ ```json
88
+ {
89
+ "mcpServers": {
90
+ "dns-doctor": {
91
+ "type": "http",
92
+ "url": "https://dnsdoctor.dev/mcp",
93
+ "headers": { "Authorization": "Bearer ${DNSDOCTOR_API_TOKEN}" }
94
+ }
95
+ }
96
+ }
97
+ ```
98
+
99
+ Then export `DNSDOCTOR_API_TOKEN=dnsd_YOUR_TOKEN` in your environment. Never
100
+ commit the token.
101
+
102
+ ### Transport
103
+
104
+ Two supported public transports, same 11 tools:
105
+
106
+ - **Hosted streamable HTTP** — `https://dnsdoctor.dev/mcp`, wired in this
107
+ plugin's `.mcp.json`. No install, no keys.
108
+ - **Local stdio** — `npx -y @dnsdoctor/mcp`. A thin server that runs on your
109
+ machine and calls the public DNS Doctor REST API; it holds no diagnosis logic
110
+ of its own and relays every record string verbatim.
111
+
112
+ ```json
113
+ {
114
+ "mcpServers": {
115
+ "dns-doctor": {
116
+ "command": "npx",
117
+ "args": ["-y", "@dnsdoctor/mcp"],
118
+ "env": { "DNSDOCTOR_API_TOKEN": "dnsd_YOUR_TOKEN" }
119
+ }
120
+ }
121
+ }
122
+ ```
123
+
124
+ `DNSDOCTOR_API_TOKEN` is optional (anonymous access covers scanning and fixes).
125
+ `DNSDOCTOR_API_BASE` overrides the origin — it must be an origin that serves the
126
+ `/api/…` paths, i.e. the public site rather than a bare backend port.
127
+
128
+ ### Build the stdio server from source
129
+
130
+ ```bash
131
+ npm ci
132
+ npm test
133
+ npm run build # -> dist/index.js, the package's bin entrypoint
134
+ ```
135
+
136
+ `tools.json` and `instructions.txt` are **generated from the hosted server** and
137
+ pinned by a backend test — never hand-edit them, and never add a tool
138
+ description to `src/`. Both files ship in the npm tarball; without them the
139
+ client has no tools and none of its safety guidance.
140
+
141
+ ## Worked example
142
+
143
+ > **You:** Our invoices keep landing in customers' spam. Domain is `example.com`.
144
+ >
145
+ > **Claude** (`scan_domain` → `example.com`): DMARC is at `p=none`, so nothing
146
+ > enforces alignment — receivers can't tell your mail from a spoof. SPF passes with
147
+ > 7 of 10 lookups used. DKIM: a selector was found and is valid. Not blacklisted.
148
+ > Domain expires in 240 days.
149
+ >
150
+ > **Claude** (`build_dmarc_upgrade` → `example.com`): SPF is aligned and DKIM is
151
+ > present, so enforcement to `p=reject` is safe. Publish this exact TXT record at
152
+ > `_dmarc.example.com` — **paste it verbatim, don't edit it**:
153
+ >
154
+ > ```
155
+ > v=DMARC1; p=reject; rua=mailto:dmarc@example.com; adkim=r; aspf=r
156
+ > ```
157
+ >
158
+ > Apply it in your DNS host once you've confirmed it's approved, then ask me to
159
+ > re-scan to verify. Want the domain watched continuously with aggregate-report
160
+ > alerts? I can send a setup link to your email.
161
+
162
+ ## Learn more
163
+
164
+ - **Methodology** (how the verdicts are computed): <https://dnsdoctor.dev/methodology>
165
+ - **REST API / OpenAPI schema:** <https://dnsdoctor.dev/api/v1/openapi.json>
166
+
167
+ ## License
168
+
169
+ Apache-2.0 — see [LICENSE](./LICENSE).
package/dist/api.d.ts ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The ONE HTTP layer of the thin client.
3
+ *
4
+ * Every tool handler goes through here, and nothing else in `src/` may hold a
5
+ * base URL or invent an error message. Two rules are load-bearing:
6
+ *
7
+ * 1. **Relay, never compose.** Response bodies are returned untouched — the
8
+ * records and signup URLs in them are authored server-side by the fixengine
9
+ * and are the product's trust moat. This file never edits a payload.
10
+ * 2. **A transport failure is never a verdict.** 429/5xx/network faults map to
11
+ * explicitly transient errors that tell the agent to retry rather than
12
+ * report a diagnosis, mirroring the backend's `temperror` discipline.
13
+ *
14
+ * A 422 relays the API's `detail` **verbatim** — those strings are a wire
15
+ * contract shared with the hosted MCP server; rewording them here would let the
16
+ * two surfaces disagree about the same malformed input.
17
+ */
18
+ export declare const DEFAULT_API_BASE = "https://dnsdoctor.dev";
19
+ /** Shared tail of every transient message — one string, so all of them agree. */
20
+ export declare const TRANSIENT_SUFFIX = "retry; never report this as a verdict";
21
+ export declare const RATE_LIMITED_MESSAGE = "rate limited \u2014 slow down and retry";
22
+ /** Error raised for any non-2xx response or transport fault. */
23
+ export declare class ApiError extends Error {
24
+ readonly status: number | null;
25
+ readonly transient: boolean;
26
+ constructor(message: string, status: number | null, transient: boolean);
27
+ }
28
+ /** Base URL for the DNS Doctor API; `DNSDOCTOR_API_BASE` overrides for local runs. */
29
+ export declare function apiBase(): string;
30
+ /** This package's version — also what the server reports in `initialize`. */
31
+ export declare function packageVersion(): string;
32
+ /**
33
+ * The UA every call carries — this client's only attribution.
34
+ *
35
+ * `api/agents.py::agent_family` is an ALLOWLIST: an unrecognized UA is not
36
+ * counted at all, so without this marker the npm channel would be invisible in
37
+ * `dnsdoctor_agent_requests_total`, which is the number the agent channel's
38
+ * kill criterion reads. The matching entry lives in that module's `_FAMILIES`.
39
+ */
40
+ export declare function userAgent(): string;
41
+ /** GET a JSON resource. `path` is API-root-relative and already encoded. */
42
+ export declare function getJson(path: string): Promise<unknown>;
43
+ /** POST a JSON body and return the parsed response, untouched. */
44
+ export declare function postJson(path: string, body: unknown): Promise<unknown>;
45
+ /**
46
+ * Decode base64, rejecting input Node would silently mangle.
47
+ *
48
+ * Not transient: the caller sent bad bytes, so retrying the same call changes
49
+ * nothing — the agent must re-encode instead of reporting a verdict.
50
+ */
51
+ export declare function decodeBase64(value: string): Buffer;
52
+ /**
53
+ * POST one file as `multipart/form-data` — the shape `/api/tools/dmarc-report-parse`
54
+ * expects. The tool takes the report as base64, so the bytes are decoded here and
55
+ * uploaded as a file part rather than re-encoded into JSON.
56
+ */
57
+ export declare function postFile(path: string, field: string, filename: string, contentBase64: string): Promise<unknown>;
package/dist/api.js ADDED
@@ -0,0 +1,178 @@
1
+ /**
2
+ * The ONE HTTP layer of the thin client.
3
+ *
4
+ * Every tool handler goes through here, and nothing else in `src/` may hold a
5
+ * base URL or invent an error message. Two rules are load-bearing:
6
+ *
7
+ * 1. **Relay, never compose.** Response bodies are returned untouched — the
8
+ * records and signup URLs in them are authored server-side by the fixengine
9
+ * and are the product's trust moat. This file never edits a payload.
10
+ * 2. **A transport failure is never a verdict.** 429/5xx/network faults map to
11
+ * explicitly transient errors that tell the agent to retry rather than
12
+ * report a diagnosis, mirroring the backend's `temperror` discipline.
13
+ *
14
+ * A 422 relays the API's `detail` **verbatim** — those strings are a wire
15
+ * contract shared with the hosted MCP server; rewording them here would let the
16
+ * two surfaces disagree about the same malformed input.
17
+ */
18
+ import { readFileSync } from "node:fs";
19
+ export const DEFAULT_API_BASE = "https://dnsdoctor.dev";
20
+ /** Shared tail of every transient message — one string, so all of them agree. */
21
+ export const TRANSIENT_SUFFIX = "retry; never report this as a verdict";
22
+ export const RATE_LIMITED_MESSAGE = "rate limited — slow down and retry";
23
+ /** Error raised for any non-2xx response or transport fault. */
24
+ export class ApiError extends Error {
25
+ status;
26
+ transient;
27
+ constructor(message, status, transient) {
28
+ super(message);
29
+ this.name = "ApiError";
30
+ this.status = status;
31
+ this.transient = transient;
32
+ }
33
+ }
34
+ /** Base URL for the DNS Doctor API; `DNSDOCTOR_API_BASE` overrides for local runs. */
35
+ export function apiBase() {
36
+ const raw = process.env.DNSDOCTOR_API_BASE?.trim();
37
+ const base = raw ? raw : DEFAULT_API_BASE;
38
+ return base.replace(/\/+$/, "");
39
+ }
40
+ /** Optional bearer token — anonymous access works, a token only raises the budget. */
41
+ function authHeaders() {
42
+ const token = process.env.DNSDOCTOR_API_TOKEN?.trim();
43
+ return token ? { Authorization: `Bearer ${token}` } : {};
44
+ }
45
+ /** This package's version — also what the server reports in `initialize`. */
46
+ export function packageVersion() {
47
+ try {
48
+ const raw = readFileSync(new URL("../package.json", import.meta.url), "utf8");
49
+ const version = JSON.parse(raw).version;
50
+ return typeof version === "string" ? version : "0.0.0";
51
+ }
52
+ catch {
53
+ return "0.0.0";
54
+ }
55
+ }
56
+ /**
57
+ * The UA every call carries — this client's only attribution.
58
+ *
59
+ * `api/agents.py::agent_family` is an ALLOWLIST: an unrecognized UA is not
60
+ * counted at all, so without this marker the npm channel would be invisible in
61
+ * `dnsdoctor_agent_requests_total`, which is the number the agent channel's
62
+ * kill criterion reads. The matching entry lives in that module's `_FAMILIES`.
63
+ */
64
+ export function userAgent() {
65
+ return `dnsdoctor-mcp/${packageVersion()} (+https://dnsdoctor.dev)`;
66
+ }
67
+ /** Pull the API's `detail` out of an error body, preserving its exact text. */
68
+ function detailOf(body) {
69
+ if (typeof body !== "object" || body === null)
70
+ return null;
71
+ const detail = body.detail;
72
+ if (typeof detail === "string")
73
+ return detail;
74
+ if (detail === undefined || detail === null)
75
+ return null;
76
+ return JSON.stringify(detail);
77
+ }
78
+ async function readBody(response) {
79
+ try {
80
+ return await response.json();
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ }
86
+ async function toApiError(response) {
87
+ const detail = detailOf(await readBody(response));
88
+ const status = response.status;
89
+ if (status === 429) {
90
+ // The constant leads, so the agent always reads the same instruction; the
91
+ // API's own detail (which domain, which budget) rides along verbatim.
92
+ const message = detail ? `${RATE_LIMITED_MESSAGE} (${detail})` : RATE_LIMITED_MESSAGE;
93
+ return new ApiError(message, status, true);
94
+ }
95
+ if (status === 503) {
96
+ return new ApiError(`DNS Doctor could not complete the lookup (transient) — ${TRANSIENT_SUFFIX}`, status, true);
97
+ }
98
+ if (status >= 500) {
99
+ return new ApiError(`DNS Doctor returned a server error (HTTP ${status}) — ${TRANSIENT_SUFFIX}`, status, true);
100
+ }
101
+ // 4xx — the API's own `detail` is the answer (422 malformed input, opaque 404,
102
+ // 413 oversize report). Verbatim, always.
103
+ return new ApiError(detail ?? `DNS Doctor request failed (HTTP ${status})`, status, false);
104
+ }
105
+ async function send(path, init) {
106
+ const url = `${apiBase()}${path}`;
107
+ let response;
108
+ try {
109
+ response = await fetch(url, {
110
+ ...init,
111
+ headers: {
112
+ Accept: "application/json",
113
+ "User-Agent": userAgent(),
114
+ ...authHeaders(),
115
+ ...(init.headers ?? {}),
116
+ },
117
+ });
118
+ }
119
+ catch (error) {
120
+ const cause = error instanceof Error ? error.message : String(error);
121
+ throw new ApiError(`Could not reach the DNS Doctor API (${cause}) — ${TRANSIENT_SUFFIX}`, null, true);
122
+ }
123
+ if (!response.ok)
124
+ throw await toApiError(response);
125
+ const body = await readBody(response);
126
+ if (body === null) {
127
+ throw new ApiError(`DNS Doctor returned an unreadable response (HTTP ${response.status}) — ${TRANSIENT_SUFFIX}`, response.status, true);
128
+ }
129
+ return body;
130
+ }
131
+ /** GET a JSON resource. `path` is API-root-relative and already encoded. */
132
+ export function getJson(path) {
133
+ return send(path, { method: "GET" });
134
+ }
135
+ /** POST a JSON body and return the parsed response, untouched. */
136
+ export function postJson(path, body) {
137
+ return send(path, {
138
+ method: "POST",
139
+ headers: { "Content-Type": "application/json" },
140
+ body: JSON.stringify(body),
141
+ });
142
+ }
143
+ /**
144
+ * Decode base64, rejecting input Node would silently mangle.
145
+ *
146
+ * Not transient: the caller sent bad bytes, so retrying the same call changes
147
+ * nothing — the agent must re-encode instead of reporting a verdict.
148
+ */
149
+ export function decodeBase64(value) {
150
+ // Whitespace (MIME line breaks) and the base64url alphabet are both accepted
151
+ // — Node decodes them and so should we; the round-trip below is what rejects
152
+ // input that is not base64 at all.
153
+ const compact = value.replace(/\s+/g, "").replace(/-/g, "+").replace(/_/g, "/");
154
+ const bytes = Buffer.from(compact, "base64");
155
+ if (bytes.toString("base64").replace(/=+$/, "") !== compact.replace(/=+$/, "")) {
156
+ throw new ApiError("'content_base64' is not valid base64 — re-encode the report file and try again", null, false);
157
+ }
158
+ return bytes;
159
+ }
160
+ /**
161
+ * POST one file as `multipart/form-data` — the shape `/api/tools/dmarc-report-parse`
162
+ * expects. The tool takes the report as base64, so the bytes are decoded here and
163
+ * uploaded as a file part rather than re-encoded into JSON.
164
+ */
165
+ // `async`, so a decode rejection reaches callers the same way a transport one
166
+ // does — a synchronous throw from a Promise-returning function is a trap.
167
+ export async function postFile(path, field, filename, contentBase64) {
168
+ // Validated, not try/caught: `Buffer.from(s, "base64")` never throws — it
169
+ // silently DISCARDS characters outside the alphabet. Uploading those bytes
170
+ // would earn a 422 "not a DMARC report file", telling the agent its report is
171
+ // invalid when the real fault is the encoding it sent us.
172
+ const bytes = decodeBase64(contentBase64);
173
+ const form = new FormData();
174
+ form.append(field, new Blob([bytes], { type: "application/octet-stream" }), filename);
175
+ // No Content-Type header: fetch sets it with the multipart boundary.
176
+ return send(path, { method: "POST", body: form });
177
+ }
178
+ //# sourceMappingURL=api.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"api.js","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAEvC,MAAM,CAAC,MAAM,gBAAgB,GAAG,uBAAuB,CAAC;AAExD,iFAAiF;AACjF,MAAM,CAAC,MAAM,gBAAgB,GAAG,uCAAuC,CAAC;AAExE,MAAM,CAAC,MAAM,oBAAoB,GAAG,oCAAoC,CAAC;AAEzE,gEAAgE;AAChE,MAAM,OAAO,QAAS,SAAQ,KAAK;IACxB,MAAM,CAAgB;IACtB,SAAS,CAAU;IAE5B,YAAY,OAAe,EAAE,MAAqB,EAAE,SAAkB;QACpE,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,UAAU,CAAC;QACvB,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QACrB,IAAI,CAAC,SAAS,GAAG,SAAS,CAAC;IAC7B,CAAC;CACF;AAED,sFAAsF;AACtF,MAAM,UAAU,OAAO;IACrB,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,kBAAkB,EAAE,IAAI,EAAE,CAAC;IACnD,MAAM,IAAI,GAAG,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,gBAAgB,CAAC;IAC1C,OAAO,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;AAClC,CAAC;AAED,sFAAsF;AACtF,SAAS,WAAW;IAClB,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC,mBAAmB,EAAE,IAAI,EAAE,CAAC;IACtD,OAAO,KAAK,CAAC,CAAC,CAAC,EAAE,aAAa,EAAE,UAAU,KAAK,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;AAC3D,CAAC;AAED,6EAA6E;AAC7E,MAAM,UAAU,cAAc;IAC5B,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,YAAY,CAAC,IAAI,GAAG,CAAC,iBAAiB,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,CAAC;QAC9E,MAAM,OAAO,GAAI,IAAI,CAAC,KAAK,CAAC,GAAG,CAA2B,CAAC,OAAO,CAAC;QACnE,OAAO,OAAO,OAAO,KAAK,QAAQ,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC;IACzD,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,OAAO,CAAC;IACjB,CAAC;AACH,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,SAAS;IACvB,OAAO,iBAAiB,cAAc,EAAE,2BAA2B,CAAC;AACtE,CAAC;AAED,+EAA+E;AAC/E,SAAS,QAAQ,CAAC,IAAa;IAC7B,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,IAAI,KAAK,IAAI;QAAE,OAAO,IAAI,CAAC;IAC3D,MAAM,MAAM,GAAI,IAA6B,CAAC,MAAM,CAAC;IACrD,IAAI,OAAO,MAAM,KAAK,QAAQ;QAAE,OAAO,MAAM,CAAC;IAC9C,IAAI,MAAM,KAAK,SAAS,IAAI,MAAM,KAAK,IAAI;QAAE,OAAO,IAAI,CAAC;IACzD,OAAO,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC;AAChC,CAAC;AAED,KAAK,UAAU,QAAQ,CAAC,QAAkB;IACxC,IAAI,CAAC;QACH,OAAO,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAC;IAC/B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED,KAAK,UAAU,UAAU,CAAC,QAAkB;IAC1C,MAAM,MAAM,GAAG,QAAQ,CAAC,MAAM,QAAQ,CAAC,QAAQ,CAAC,CAAC,CAAC;IAClD,MAAM,MAAM,GAAG,QAAQ,CAAC,MAAM,CAAC;IAC/B,IAAI,MAAM,KAAK,GAAG,EAAE,CAAC;QACnB,0EAA0E;QAC1E,sEAAsE;QACtE,MAAM,OAAO,GAAG,MAAM,CAAC,CAAC,CAAC,GAAG,oBAAoB,KAAK,MAAM,GAAG,CAAC,CAAC,CAAC,oBAAoB,CAAC;QACtF,OAAO,IAAI,QAAQ,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,CAAC;IAC7C,CAAC;IACD,IAAI,MAAM,KAAK,GAAG,EAAE,CAAC;QACnB,OAAO,IAAI,QAAQ,CACjB,0DAA0D,gBAAgB,EAAE,EAC5E,MAAM,EACN,IAAI,CACL,CAAC;IACJ,CAAC;IACD,IAAI,MAAM,IAAI,GAAG,EAAE,CAAC;QAClB,OAAO,IAAI,QAAQ,CACjB,4CAA4C,MAAM,OAAO,gBAAgB,EAAE,EAC3E,MAAM,EACN,IAAI,CACL,CAAC;IACJ,CAAC;IACD,+EAA+E;IAC/E,0CAA0C;IAC1C,OAAO,IAAI,QAAQ,CAAC,MAAM,IAAI,mCAAmC,MAAM,GAAG,EAAE,MAAM,EAAE,KAAK,CAAC,CAAC;AAC7F,CAAC;AAED,KAAK,UAAU,IAAI,CAAC,IAAY,EAAE,IAAiB;IACjD,MAAM,GAAG,GAAG,GAAG,OAAO,EAAE,GAAG,IAAI,EAAE,CAAC;IAClC,IAAI,QAAkB,CAAC;IACvB,IAAI,CAAC;QACH,QAAQ,GAAG,MAAM,KAAK,CAAC,GAAG,EAAE;YAC1B,GAAG,IAAI;YACP,OAAO,EAAE;gBACP,MAAM,EAAE,kBAAkB;gBAC1B,YAAY,EAAE,SAAS,EAAE;gBACzB,GAAG,WAAW,EAAE;gBAChB,GAAG,CAAC,IAAI,CAAC,OAAO,IAAI,EAAE,CAAC;aACxB;SACF,CAAC,CAAC;IACL,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,KAAK,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;QACrE,MAAM,IAAI,QAAQ,CAChB,uCAAuC,KAAK,OAAO,gBAAgB,EAAE,EACrE,IAAI,EACJ,IAAI,CACL,CAAC;IACJ,CAAC;IACD,IAAI,CAAC,QAAQ,CAAC,EAAE;QAAE,MAAM,MAAM,UAAU,CAAC,QAAQ,CAAC,CAAC;IACnD,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,QAAQ,CAAC,CAAC;IACtC,IAAI,IAAI,KAAK,IAAI,EAAE,CAAC;QAClB,MAAM,IAAI,QAAQ,CAChB,oDAAoD,QAAQ,CAAC,MAAM,OAAO,gBAAgB,EAAE,EAC5F,QAAQ,CAAC,MAAM,EACf,IAAI,CACL,CAAC;IACJ,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,4EAA4E;AAC5E,MAAM,UAAU,OAAO,CAAC,IAAY;IAClC,OAAO,IAAI,CAAC,IAAI,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,CAAC;AACvC,CAAC;AAED,kEAAkE;AAClE,MAAM,UAAU,QAAQ,CAAC,IAAY,EAAE,IAAa;IAClD,OAAO,IAAI,CAAC,IAAI,EAAE;QAChB,MAAM,EAAE,MAAM;QACd,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;QAC/C,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC;KAC3B,CAAC,CAAC;AACL,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,YAAY,CAAC,KAAa;IACxC,6EAA6E;IAC7E,6EAA6E;IAC7E,mCAAmC;IACnC,MAAM,OAAO,GAAG,KAAK,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;IAChF,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC;IAC7C,IAAI,KAAK,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,OAAO,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,EAAE,CAAC;QAC/E,MAAM,IAAI,QAAQ,CAChB,gFAAgF,EAChF,IAAI,EACJ,KAAK,CACN,CAAC;IACJ,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;;;GAIG;AACH,8EAA8E;AAC9E,0EAA0E;AAC1E,MAAM,CAAC,KAAK,UAAU,QAAQ,CAC5B,IAAY,EACZ,KAAa,EACb,QAAgB,EAChB,aAAqB;IAErB,0EAA0E;IAC1E,2EAA2E;IAC3E,8EAA8E;IAC9E,0DAA0D;IAC1D,MAAM,KAAK,GAAG,YAAY,CAAC,aAAa,CAAC,CAAC;IAC1C,MAAM,IAAI,GAAG,IAAI,QAAQ,EAAE,CAAC;IAC5B,IAAI,CAAC,MAAM,CAAC,KAAK,EAAE,IAAI,IAAI,CAAC,CAAC,KAAK,CAAC,EAAE,EAAE,IAAI,EAAE,0BAA0B,EAAE,CAAC,EAAE,QAAQ,CAAC,CAAC;IACtF,qEAAqE;IACrE,OAAO,IAAI,CAAC,IAAI,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;AACpD,CAAC"}
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `@dnsdoctor/mcp` — a thin stdio MCP server in front of the public DNS Doctor
4
+ * REST API.
5
+ *
6
+ * It holds no diagnosis logic of its own. Every tool is one HTTP call whose
7
+ * response is relayed **verbatim**: the record strings, signup URLs and verdicts
8
+ * in those payloads are authored server-side by the fixengine, and rewriting one
9
+ * here would break the product's central invariant (an LLM — or a client — never
10
+ * composes an authoritative record). The tool definitions come from `tools.json`
11
+ * (see `tools.ts`), so the only thing this file decides is which endpoint each
12
+ * tool calls.
13
+ */
14
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
15
+ type ToolArgs = Record<string, unknown>;
16
+ /** Run one tool and return the API's parsed body, untouched. */
17
+ export declare function callTool(name: string, args?: ToolArgs): Promise<unknown>;
18
+ /** Build the MCP server with the fixture's tools and instructions registered. */
19
+ export declare function createServer(): Server;
20
+ /** Every fixture tool has a route — a missing one would 'unknown tool' at runtime. */
21
+ export declare function unroutedTools(): string[];
22
+ export declare function main(): Promise<void>;
23
+ /**
24
+ * True when this module is the process entrypoint — false when a test imports it.
25
+ *
26
+ * Both sides are resolved through `realpathSync` because npm installs `bin`
27
+ * entries as **symlinks**: launched as `dnsdoctor-mcp` (or via `npx`),
28
+ * `process.argv[1]` is `node_modules/.bin/dnsdoctor-mcp` while
29
+ * `import.meta.url` is already the symlink's target, so a raw string compare is
30
+ * false for every installed user and the server silently never starts. That is
31
+ * invisible to `node dist/index.js` and shows up only in the `npm pack` smoke.
32
+ */
33
+ export declare function isEntrypoint(): boolean;
34
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `@dnsdoctor/mcp` — a thin stdio MCP server in front of the public DNS Doctor
4
+ * REST API.
5
+ *
6
+ * It holds no diagnosis logic of its own. Every tool is one HTTP call whose
7
+ * response is relayed **verbatim**: the record strings, signup URLs and verdicts
8
+ * in those payloads are authored server-side by the fixengine, and rewriting one
9
+ * here would break the product's central invariant (an LLM — or a client — never
10
+ * composes an authoritative record). The tool definitions come from `tools.json`
11
+ * (see `tools.ts`), so the only thing this file decides is which endpoint each
12
+ * tool calls.
13
+ */
14
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
15
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
16
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
17
+ import { realpathSync } from "node:fs";
18
+ import { fileURLToPath } from "node:url";
19
+ import { ApiError, getJson, packageVersion, postFile, postJson } from "./api.js";
20
+ import { instructions, listTools, toolNames } from "./tools.js";
21
+ const ROUTES = {
22
+ scan_domain: { kind: "json", path: "/api/v1/scan" },
23
+ get_report: { kind: "report" },
24
+ build_dmarc_upgrade: { kind: "json", path: "/api/v1/dmarc-upgrade" },
25
+ start_monitoring_signup: { kind: "json", path: "/api/v1/signup-url" },
26
+ count_spf_lookups: { kind: "json", path: "/api/tools/spf-count" },
27
+ validate_dmarc_record: { kind: "json", path: "/api/tools/dmarc-validate" },
28
+ generate_dmarc_record: { kind: "json", path: "/api/tools/dmarc-generate" },
29
+ check_dkim_selector: { kind: "json", path: "/api/tools/dkim-check" },
30
+ parse_dmarc_report: {
31
+ kind: "upload",
32
+ path: "/api/tools/dmarc-report-parse",
33
+ field: "file",
34
+ },
35
+ check_record: { kind: "json", path: "/api/tools/check-record" },
36
+ check_reverse_dns: { kind: "json", path: "/api/tools/reverse-dns-check" },
37
+ };
38
+ /** Default upload name when the caller supplies none — the API only reads bytes. */
39
+ const DEFAULT_REPORT_FILENAME = "report.xml";
40
+ function requireString(args, key) {
41
+ const value = args[key];
42
+ if (typeof value !== "string" || value.trim() === "") {
43
+ throw new ApiError(`'${key}' is required and must be a non-empty string`, null, false);
44
+ }
45
+ return value;
46
+ }
47
+ /** Run one tool and return the API's parsed body, untouched. */
48
+ export async function callTool(name, args = {}) {
49
+ const route = ROUTES[name];
50
+ if (route === undefined) {
51
+ throw new ApiError(`unknown tool '${name}'`, null, false);
52
+ }
53
+ if (route.kind === "report") {
54
+ const domain = requireString(args, "domain");
55
+ return getJson(`/api/v1/report/${encodeURIComponent(domain)}`);
56
+ }
57
+ if (route.kind === "upload") {
58
+ const content = requireString(args, "content_base64");
59
+ const filename = args["filename"];
60
+ return postFile(route.path, route.field, typeof filename === "string" && filename.trim() !== "" ? filename : DEFAULT_REPORT_FILENAME, content);
61
+ }
62
+ return postJson(route.path, args);
63
+ }
64
+ function okResult(body) {
65
+ return { content: [{ type: "text", text: JSON.stringify(body, null, 2) }] };
66
+ }
67
+ function errorResult(error) {
68
+ const message = error instanceof Error ? error.message : String(error);
69
+ return { content: [{ type: "text", text: message }], isError: true };
70
+ }
71
+ /** Build the MCP server with the fixture's tools and instructions registered. */
72
+ export function createServer() {
73
+ const server = new Server({ name: "dns-doctor", version: packageVersion() },
74
+ // `instructions` is the hosted server's own preamble, shipped in the pinned
75
+ // fixture: without it a host other than Claude Code (which reads the bundled
76
+ // SKILL.md) gets the tools with none of the safety contract around them.
77
+ { capabilities: { tools: {} }, instructions: instructions() });
78
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: listTools() }));
79
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
80
+ const { name, arguments: args } = request.params;
81
+ try {
82
+ return okResult(await callTool(name, args ?? {}));
83
+ }
84
+ catch (error) {
85
+ return errorResult(error);
86
+ }
87
+ });
88
+ return server;
89
+ }
90
+ /** Every fixture tool has a route — a missing one would 'unknown tool' at runtime. */
91
+ export function unroutedTools() {
92
+ return toolNames().filter((name) => ROUTES[name] === undefined);
93
+ }
94
+ export async function main() {
95
+ await createServer().connect(new StdioServerTransport());
96
+ }
97
+ /**
98
+ * True when this module is the process entrypoint — false when a test imports it.
99
+ *
100
+ * Both sides are resolved through `realpathSync` because npm installs `bin`
101
+ * entries as **symlinks**: launched as `dnsdoctor-mcp` (or via `npx`),
102
+ * `process.argv[1]` is `node_modules/.bin/dnsdoctor-mcp` while
103
+ * `import.meta.url` is already the symlink's target, so a raw string compare is
104
+ * false for every installed user and the server silently never starts. That is
105
+ * invisible to `node dist/index.js` and shows up only in the `npm pack` smoke.
106
+ */
107
+ export function isEntrypoint() {
108
+ const argv1 = process.argv[1];
109
+ if (argv1 === undefined)
110
+ return false;
111
+ const resolve = (path) => {
112
+ try {
113
+ return realpathSync(path);
114
+ }
115
+ catch {
116
+ return path;
117
+ }
118
+ };
119
+ return resolve(argv1) === resolve(fileURLToPath(import.meta.url));
120
+ }
121
+ if (isEntrypoint()) {
122
+ main().catch((error) => {
123
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
124
+ process.exit(1);
125
+ });
126
+ }
127
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The tool definitions and the `initialize` preamble — read from the fixtures,
3
+ * never authored here.
4
+ *
5
+ * `tools.json` and `instructions.txt` are generated from the hosted server's own
6
+ * `list_tools()` and instructions, and pinned byte-for-byte by
7
+ * `backend/tests/mcp_server/test_tools_fixture.py`. That is the whole point: a
8
+ * name, description, schema or instruction sentence edited on the server fails a
9
+ * backend test until the fixtures are regenerated, so this client cannot drift
10
+ * from the surface it claims to mirror. Consequently **no description string,
11
+ * schema, tool name or guidance sentence is written in `src/`** — everything
12
+ * comes from the files.
13
+ */
14
+ /** One entry of `tools.json`; the shape the MCP `tools/list` result expects. */
15
+ export interface ToolDefinition {
16
+ name: string;
17
+ description: string;
18
+ inputSchema: Record<string, unknown>;
19
+ annotations?: Record<string, unknown>;
20
+ }
21
+ /** Every tool the hosted server exposes, in the fixture's own (sorted) order. */
22
+ export declare function listTools(): ToolDefinition[];
23
+ /** The tool names, for the dispatch table's coverage check. */
24
+ export declare function toolNames(): string[];
25
+ export declare function instructions(): string;
package/dist/tools.js ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The tool definitions and the `initialize` preamble — read from the fixtures,
3
+ * never authored here.
4
+ *
5
+ * `tools.json` and `instructions.txt` are generated from the hosted server's own
6
+ * `list_tools()` and instructions, and pinned byte-for-byte by
7
+ * `backend/tests/mcp_server/test_tools_fixture.py`. That is the whole point: a
8
+ * name, description, schema or instruction sentence edited on the server fails a
9
+ * backend test until the fixtures are regenerated, so this client cannot drift
10
+ * from the surface it claims to mirror. Consequently **no description string,
11
+ * schema, tool name or guidance sentence is written in `src/`** — everything
12
+ * comes from the files.
13
+ */
14
+ import { readFileSync } from "node:fs";
15
+ /**
16
+ * `tools.json` sits at the package root — one level up from both `src/` (tests,
17
+ * run from source) and `dist/` (the published `bin`), so the same relative URL
18
+ * resolves in either. It ships in `package.json#files`; without it the client
19
+ * has no tools at all, which is why a missing file throws rather than degrading
20
+ * to an empty list.
21
+ */
22
+ const FIXTURE_URL = new URL("../tools.json", import.meta.url);
23
+ function readFixture() {
24
+ const parsed = JSON.parse(readFileSync(FIXTURE_URL, "utf8"));
25
+ if (!Array.isArray(parsed) || parsed.length === 0) {
26
+ throw new Error("tools.json is not a non-empty array of tool definitions");
27
+ }
28
+ return parsed;
29
+ }
30
+ const TOOLS = readFixture();
31
+ /** Every tool the hosted server exposes, in the fixture's own (sorted) order. */
32
+ export function listTools() {
33
+ return TOOLS;
34
+ }
35
+ /** The tool names, for the dispatch table's coverage check. */
36
+ export function toolNames() {
37
+ return TOOLS.map((tool) => tool.name);
38
+ }
39
+ /**
40
+ * The in-band guidance the hosted server sends in `initialize`, shipped verbatim.
41
+ *
42
+ * Claude Code users read the same rules from the bundled SKILL.md; every OTHER
43
+ * host running `npx -y @dnsdoctor/mcp` reads them only here. Dropping them would
44
+ * leave an agent with no `temperror`-is-not-a-failure rule, no
45
+ * `not_registered`-is-not-health rule, no records-verbatim rule and no
46
+ * SPF-is-diagnose-only rule — the product's safety contract, not decoration.
47
+ */
48
+ const INSTRUCTIONS = readFileSync(new URL("../instructions.txt", import.meta.url), "utf8").trim();
49
+ export function instructions() {
50
+ return INSTRUCTIONS;
51
+ }
52
+ //# sourceMappingURL=tools.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tools.js","sourceRoot":"","sources":["../src/tools.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAUvC;;;;;;GAMG;AACH,MAAM,WAAW,GAAG,IAAI,GAAG,CAAC,eAAe,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AAE9D,SAAS,WAAW;IAClB,MAAM,MAAM,GAAY,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,WAAW,EAAE,MAAM,CAAC,CAAC,CAAC;IACtE,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAClD,MAAM,IAAI,KAAK,CAAC,yDAAyD,CAAC,CAAC;IAC7E,CAAC;IACD,OAAO,MAA0B,CAAC;AACpC,CAAC;AAED,MAAM,KAAK,GAAqB,WAAW,EAAE,CAAC;AAE9C,iFAAiF;AACjF,MAAM,UAAU,SAAS;IACvB,OAAO,KAAK,CAAC;AACf,CAAC;AAED,+DAA+D;AAC/D,MAAM,UAAU,SAAS;IACvB,OAAO,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACxC,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,YAAY,GAAW,YAAY,CACvC,IAAI,GAAG,CAAC,qBAAqB,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,EAC/C,MAAM,CACP,CAAC,IAAI,EAAE,CAAC;AAET,MAAM,UAAU,YAAY;IAC1B,OAAO,YAAY,CAAC;AACtB,CAAC"}
@@ -0,0 +1 @@
1
+ DNS Doctor scans a domain's email-authentication posture (SPF, DMARC, DKIM, MX, DNS, blacklist, expiry) and returns deterministic verdicts plus copy-paste fix records generated by a validating engine — never a guessed record. Workflow: call scan_domain (or get_report), read the verdicts failing-first (a 'temperror' status is transient, NOT a failure), then call build_dmarc_upgrade for a DMARC enforcement record. ONE EXCEPTION TO READING FAILING-FIRST: when a report carries not_registered: true the domain has no DNS records at all, so no check ran and every status is an 'info' placeholder — zero failing checks there does NOT mean the domain is healthy. Report that the domain does not resolve (usually a typo), propose no records for it, and do not offer monitoring until it resolves. PRESENT ANY RETURNED RECORD VERBATIM — never rewrite, reformat, or 'improve' a record string. build_dmarc_upgrade may return record: null (the domain does not exist; the DMARC lookup itself hit NXDOMAIN while the existence probe did not resolve; the DMARC lookup temp-failed; or the domain already applies a policy at least as strong as the one this scan justifies) — relay its 'rationale' as the answer and NEVER compose a record yourself to fill the gap. 'policy' describes the returned record and is null whenever record is; the domain's observed policy is in 'current_policy'. A human must approve every DNS change; nothing is applied automatically. SPF is diagnose-only: relay the report's SPF findings, but never propose SPF record edits of your own (e.g. tightening ~all to -all) — an SPF change can silently de-authorize a real sender, which is why the engine emits no SPF fix record. Scan responses end with a next_steps block: relay it — safe DMARC enforcement needs ~30 days of aggregate-report (RUA) evidence that no chat session can watch, so when a domain lacks reporting, call start_monitoring_signup and give the human the signup_url it returns. That tool sends no email and creates nothing: the human opens the link, signs in themselves, and adds the domain themselves. Never ask a human for their email address to pass to us, and never invent one — hand over the link and let them sign in on our page. Also share the report_url and monitor_url links from next_steps.
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@dnsdoctor/mcp",
3
+ "version": "1.2.0",
4
+ "description": "Thin stdio MCP server for DNS Doctor — diagnoses SPF, DMARC, DKIM, MX, DNS, blacklist and expiry over the public DNS Doctor REST API.",
5
+ "license": "Apache-2.0",
6
+ "author": {
7
+ "name": "DNS Doctor",
8
+ "url": "https://dnsdoctor.dev"
9
+ },
10
+ "homepage": "https://dnsdoctor.dev/methodology",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/dnsdoctor/claude-plugin.git"
14
+ },
15
+ "keywords": [
16
+ "mcp",
17
+ "dns",
18
+ "email",
19
+ "spf",
20
+ "dmarc",
21
+ "dkim",
22
+ "deliverability"
23
+ ],
24
+ "type": "module",
25
+ "bin": {
26
+ "dnsdoctor-mcp": "dist/index.js"
27
+ },
28
+ "files": [
29
+ "dist",
30
+ "tools.json",
31
+ "instructions.txt"
32
+ ],
33
+ "engines": {
34
+ "node": ">=20"
35
+ },
36
+ "scripts": {
37
+ "build": "tsc",
38
+ "prepack": "npm run build",
39
+ "test": "vitest run",
40
+ "typecheck": "tsc --noEmit"
41
+ },
42
+ "dependencies": {
43
+ "@modelcontextprotocol/sdk": "^1.21.0"
44
+ },
45
+ "devDependencies": {
46
+ "@types/node": "^22.15.0",
47
+ "typescript": "^5.9.2",
48
+ "vitest": "^3.2.4"
49
+ }
50
+ }
package/tools.json ADDED
@@ -0,0 +1,354 @@
1
+ [
2
+ {
3
+ "annotations": {
4
+ "destructiveHint": false,
5
+ "idempotentHint": false,
6
+ "openWorldHint": true,
7
+ "readOnlyHint": false
8
+ },
9
+ "description": "Return a validated DMARC enforcement-upgrade record for a domain. The alignment gate (whether p=reject is safe) is derived server-side from a scan \u2014 a caller can never assert alignment. `record` is null when there is no honest upgrade to offer (the domain does not exist; the DMARC lookup itself hit NXDOMAIN while the existence probe did not resolve; the DMARC lookup temp-failed; or the domain already applies a policy at least as strong as this scan justifies): relay `rationale` and never compose a record to fill the gap. Present a returned record verbatim; a human must approve before publishing.",
10
+ "inputSchema": {
11
+ "properties": {
12
+ "domain": {
13
+ "title": "Domain",
14
+ "type": "string"
15
+ }
16
+ },
17
+ "required": [
18
+ "domain"
19
+ ],
20
+ "title": "build_dmarc_upgradeArguments",
21
+ "type": "object"
22
+ },
23
+ "name": "build_dmarc_upgrade"
24
+ },
25
+ {
26
+ "annotations": {
27
+ "destructiveHint": false,
28
+ "idempotentHint": true,
29
+ "openWorldHint": true,
30
+ "readOnlyHint": true
31
+ },
32
+ "description": "Check ONE specific DKIM selector on a domain \u2014 the exact selector the sending platform uses (e.g. `google`, `s1`), which a full scan's common-selector sweep may miss. Returns the verdict, its explanation, and the published key record when one resolves. No fix record is returned: a DKIM key is generated by the sending platform, so the fix is always to publish what that platform gives the owner.",
33
+ "inputSchema": {
34
+ "properties": {
35
+ "domain": {
36
+ "title": "Domain",
37
+ "type": "string"
38
+ },
39
+ "selector": {
40
+ "title": "Selector",
41
+ "type": "string"
42
+ }
43
+ },
44
+ "required": [
45
+ "domain",
46
+ "selector"
47
+ ],
48
+ "title": "check_dkim_selectorArguments",
49
+ "type": "object"
50
+ },
51
+ "name": "check_dkim_selector"
52
+ },
53
+ {
54
+ "annotations": {
55
+ "destructiveHint": false,
56
+ "idempotentHint": true,
57
+ "openWorldHint": true,
58
+ "readOnlyHint": true
59
+ },
60
+ "description": "Check whether a DNS change has landed: reads the record from the domain's OWN nameservers (cache-free) and from two public caching resolvers, and reports whether they agree. `kind` is one of spf|dmarc|txt|mx|cname|a|aaaa \u2014 pass the kind, not a query name: `dmarc` reads TXT at _dmarc.<domain> and `spf` reads apex TXT, each filtered to the matching record. `host` prepends a label (txt, cname, a and aaaa only). Empty values mean the record is genuinely absent. When in_sync is false, max_wait_seconds is the largest remaining cached TTL \u2014 the wait before those resolvers refresh. This samples two resolvers, so never describe it as worldwide or as propagation coverage.",
61
+ "inputSchema": {
62
+ "properties": {
63
+ "domain": {
64
+ "title": "Domain",
65
+ "type": "string"
66
+ },
67
+ "host": {
68
+ "anyOf": [
69
+ {
70
+ "type": "string"
71
+ },
72
+ {
73
+ "type": "null"
74
+ }
75
+ ],
76
+ "default": null,
77
+ "title": "Host"
78
+ },
79
+ "kind": {
80
+ "enum": [
81
+ "spf",
82
+ "dmarc",
83
+ "txt",
84
+ "mx",
85
+ "cname",
86
+ "a",
87
+ "aaaa"
88
+ ],
89
+ "title": "Kind",
90
+ "type": "string"
91
+ }
92
+ },
93
+ "required": [
94
+ "domain",
95
+ "kind"
96
+ ],
97
+ "title": "check_recordArguments",
98
+ "type": "object"
99
+ },
100
+ "name": "check_record"
101
+ },
102
+ {
103
+ "annotations": {
104
+ "destructiveHint": false,
105
+ "idempotentHint": true,
106
+ "openWorldHint": true,
107
+ "readOnlyHint": true
108
+ },
109
+ "description": "Check one sending IP's forward-confirmed reverse DNS (FCrDNS): reads the IP's PTR record, then resolves that hostname back and reports whether it returns to the same IP. `verdict` is confirmed (the pair agrees \u2014 what receivers want to see), ptr_missing (the IP publishes no reverse record), or mismatch (a PTR that does not resolve back). A PTR on its own proves nothing, because the IP's operator writes its own reverse zone \u2014 only the forward confirmation is evidence, so never report a bare PTR as verified. The fix is always made by whoever controls the IP (the hosting or mail provider), never in the sending domain's own DNS. Pass a public IPv4 or IPv6 address.",
110
+ "inputSchema": {
111
+ "properties": {
112
+ "ip": {
113
+ "title": "Ip",
114
+ "type": "string"
115
+ }
116
+ },
117
+ "required": [
118
+ "ip"
119
+ ],
120
+ "title": "check_reverse_dnsArguments",
121
+ "type": "object"
122
+ },
123
+ "name": "check_reverse_dns"
124
+ },
125
+ {
126
+ "annotations": {
127
+ "destructiveHint": false,
128
+ "idempotentHint": true,
129
+ "openWorldHint": true,
130
+ "readOnlyHint": true
131
+ },
132
+ "description": "Validate an SPF record and count what it costs. Returns `record_valid` (the record parses as RFC 7208 SPF), `findings` (per-term diagnostics), `has_pass_all` (a `+all` that authorizes the whole internet to send as this domain), `multiple_all` (more than one `all`, which makes everything after the first unreachable), the parsed `terms`, and the lookup count against the limit of 10 with `over_limit`/`near_limit` and the `offending_mechanisms` that push it over. Pass EXACTLY ONE of `domain` (resolves the published record and counts recursively through nested includes) or `record` (parses a pasted record, its own terms only). This is the SPF validator \u2014 there is no separate one. Diagnose-only: no SPF fix record is ever returned, because removing a mechanism can silently de-authorize a real sender \u2014 relay the findings and let the domain's owner decide what to drop.",
133
+ "inputSchema": {
134
+ "properties": {
135
+ "domain": {
136
+ "anyOf": [
137
+ {
138
+ "type": "string"
139
+ },
140
+ {
141
+ "type": "null"
142
+ }
143
+ ],
144
+ "default": null,
145
+ "title": "Domain"
146
+ },
147
+ "record": {
148
+ "anyOf": [
149
+ {
150
+ "type": "string"
151
+ },
152
+ {
153
+ "type": "null"
154
+ }
155
+ ],
156
+ "default": null,
157
+ "title": "Record"
158
+ }
159
+ },
160
+ "title": "count_spf_lookupsArguments",
161
+ "type": "object"
162
+ },
163
+ "name": "count_spf_lookups"
164
+ },
165
+ {
166
+ "annotations": {
167
+ "destructiveHint": false,
168
+ "idempotentHint": true,
169
+ "openWorldHint": false,
170
+ "readOnlyHint": true
171
+ },
172
+ "description": "Build a DMARC record from scratch for a domain that has none, using a validating engine \u2014 never compose one yourself. `policy` is none|quarantine|reject; optional `rua_email` (aggregate-report destination), `subdomain_policy`, and `strict_alignment`. The generated record is re-validated before it is returned. Present it verbatim; a human must approve before publishing.",
173
+ "inputSchema": {
174
+ "properties": {
175
+ "policy": {
176
+ "enum": [
177
+ "none",
178
+ "quarantine",
179
+ "reject"
180
+ ],
181
+ "title": "Policy",
182
+ "type": "string"
183
+ },
184
+ "rua_email": {
185
+ "anyOf": [
186
+ {
187
+ "type": "string"
188
+ },
189
+ {
190
+ "type": "null"
191
+ }
192
+ ],
193
+ "default": null,
194
+ "title": "Rua Email"
195
+ },
196
+ "strict_alignment": {
197
+ "default": false,
198
+ "title": "Strict Alignment",
199
+ "type": "boolean"
200
+ },
201
+ "subdomain_policy": {
202
+ "anyOf": [
203
+ {
204
+ "enum": [
205
+ "none",
206
+ "quarantine",
207
+ "reject"
208
+ ],
209
+ "type": "string"
210
+ },
211
+ {
212
+ "type": "null"
213
+ }
214
+ ],
215
+ "default": null,
216
+ "title": "Subdomain Policy"
217
+ }
218
+ },
219
+ "required": [
220
+ "policy"
221
+ ],
222
+ "title": "generate_dmarc_recordArguments",
223
+ "type": "object"
224
+ },
225
+ "name": "generate_dmarc_record"
226
+ },
227
+ {
228
+ "annotations": {
229
+ "destructiveHint": false,
230
+ "idempotentHint": true,
231
+ "openWorldHint": true,
232
+ "readOnlyHint": false
233
+ },
234
+ "description": "Return the stored report for a domain, scanning once only if none exists yet \u2014 the cheap read, and the right default for a first look. Returns the same seven-check report as scan_domain (SPF, DKIM, DMARC, MX, DNS hardening, domain/TLS expiry, blacklist; each with a status, the observed record and any fixengine fix_record), including `scanned_at` so you can judge staleness yourself. Prefer scan_domain when you specifically need state re-read right now \u2014 for example after a DNS change.",
235
+ "inputSchema": {
236
+ "properties": {
237
+ "domain": {
238
+ "title": "Domain",
239
+ "type": "string"
240
+ }
241
+ },
242
+ "required": [
243
+ "domain"
244
+ ],
245
+ "title": "get_reportArguments",
246
+ "type": "object"
247
+ },
248
+ "name": "get_report"
249
+ },
250
+ {
251
+ "annotations": {
252
+ "destructiveHint": false,
253
+ "idempotentHint": true,
254
+ "openWorldHint": false,
255
+ "readOnlyHint": true
256
+ },
257
+ "description": "Parse ONE DMARC aggregate (RUA) report into readable per-source aggregates: who sent mail as the domain, how much, and what share was SPF/DKIM aligned. Pass the file's bytes base64-encoded in `content_base64` (XML, .gz or .zip; up to 2 MiB decoded) with an optional `filename`. Nothing is stored \u2014 the report is parsed and discarded.",
258
+ "inputSchema": {
259
+ "properties": {
260
+ "content_base64": {
261
+ "title": "Content Base64",
262
+ "type": "string"
263
+ },
264
+ "filename": {
265
+ "anyOf": [
266
+ {
267
+ "type": "string"
268
+ },
269
+ {
270
+ "type": "null"
271
+ }
272
+ ],
273
+ "default": null,
274
+ "title": "Filename"
275
+ }
276
+ },
277
+ "required": [
278
+ "content_base64"
279
+ ],
280
+ "title": "parse_dmarc_reportArguments",
281
+ "type": "object"
282
+ },
283
+ "name": "parse_dmarc_report"
284
+ },
285
+ {
286
+ "annotations": {
287
+ "destructiveHint": false,
288
+ "idempotentHint": false,
289
+ "openWorldHint": true,
290
+ "readOnlyHint": false
291
+ },
292
+ "description": "Force a fresh scan of a domain and return its full report: seven deterministic checks \u2014 SPF, DKIM, DMARC, MX, DNS hardening, domain/TLS expiry and blacklist listing \u2014 each with a status (pass/warn/fail/info/temperror), the observed record, and a fixengine-generated fix_record where one exists. Explanations are cache-first. A repeat scan of the same domain within a minute reuses the stored report. Use this when you need current state; use get_report when a recent cached answer will do.",
293
+ "inputSchema": {
294
+ "properties": {
295
+ "domain": {
296
+ "title": "Domain",
297
+ "type": "string"
298
+ }
299
+ },
300
+ "required": [
301
+ "domain"
302
+ ],
303
+ "title": "scan_domainArguments",
304
+ "type": "object"
305
+ },
306
+ "name": "scan_domain"
307
+ },
308
+ {
309
+ "annotations": {
310
+ "destructiveHint": false,
311
+ "idempotentHint": true,
312
+ "openWorldHint": false,
313
+ "readOnlyHint": true
314
+ },
315
+ "description": "Return a sign-up link to give to the HUMAN who owns a domain, so they can start monitoring it themselves. Nothing is created and no email is sent by this call: the returned signup_url opens a page that explains monitoring and asks them to sign in with their own email address; the domain is carried over to their dashboard, already filled in, only after that, and monitoring itself starts once they prove ownership with a TXT record. Hand them the URL and relay `message` \u2014 never sign in on their behalf.",
316
+ "inputSchema": {
317
+ "properties": {
318
+ "domain": {
319
+ "title": "Domain",
320
+ "type": "string"
321
+ }
322
+ },
323
+ "required": [
324
+ "domain"
325
+ ],
326
+ "title": "start_monitoring_signupArguments",
327
+ "type": "object"
328
+ },
329
+ "name": "start_monitoring_signup"
330
+ },
331
+ {
332
+ "annotations": {
333
+ "destructiveHint": false,
334
+ "idempotentHint": true,
335
+ "openWorldHint": false,
336
+ "readOnlyHint": true
337
+ },
338
+ "description": "Validate a pasted DMARC record: parsed tags, level'd findings, and whether it is valid. No DNS lookup \u2014 pass the record string itself. `upgrade_record` previews a stronger policy and is capped at p=quarantine: a pasted record carries no alignment evidence, so p=reject needs a full scan (use build_dmarc_upgrade). Present any returned record verbatim.",
339
+ "inputSchema": {
340
+ "properties": {
341
+ "record": {
342
+ "title": "Record",
343
+ "type": "string"
344
+ }
345
+ },
346
+ "required": [
347
+ "record"
348
+ ],
349
+ "title": "validate_dmarc_recordArguments",
350
+ "type": "object"
351
+ },
352
+ "name": "validate_dmarc_record"
353
+ }
354
+ ]