netdiag-mcp 0.2.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AIKAWA Shigechika
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.4
2
+ Name: netdiag-mcp
3
+ Version: 0.2.0
4
+ Summary: MCP server wrapping dig, ping, mtr, whois and TLS/HTTP checks for on-demand network diagnostics
5
+ Author: AIKAWA Shigechika
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/shigechika/netdiag-mcp
8
+ Project-URL: Repository, https://github.com/shigechika/netdiag-mcp
9
+ Project-URL: Issues, https://github.com/shigechika/netdiag-mcp/issues
10
+ Keywords: dns,dig,ping,mtr,whois,tls,network,mcp,model-context-protocol,diagnostics
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: System Administrators
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: System :: Networking
19
+ Classifier: Topic :: Internet
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: httpx>=0.27
24
+ Requires-Dist: mcp<2,>=1.2
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest; extra == "dev"
27
+ Requires-Dist: pytest-cov; extra == "dev"
28
+ Requires-Dist: ruff; extra == "dev"
29
+ Dynamic: license-file
30
+
31
+ <!-- mcp-name: io.github.shigechika/netdiag-mcp -->
32
+
33
+ # netdiag-mcp
34
+
35
+ English | [日本語](README.ja.md)
36
+
37
+ MCP server for on-demand network diagnostics — DNS lookups (with a DNSSEC AD-bit check), ping, an `mtr`-based path report, TCP port checks, HTTP status/redirect checks, TLS certificate inspection, and WHOIS, all from one server.
38
+
39
+ Built for triaging "can't reach X" / "is DNS propagated yet" reports without shelling into a jump host for each one-off `dig`/`ping`/`curl`.
40
+
41
+ ## Tools
42
+
43
+ | Tool | Purpose |
44
+ |---|---|
45
+ | `dns_lookup` | Resolve a DNS record via `dig` (A/AAAA/MX/TXT/NS/CNAME/SOA/PTR/CAA), optionally against a specific resolver and over plain DNS/DoT/DoH |
46
+ | `dnssec_check` | Query a known-validating resolver and report whether the AD bit is set (plain/DoT/DoH) — the only reliable way to confirm DNSSEC validation, since an RRSIG being present in a plain `dig` reply does not by itself prove anything validated it |
47
+ | `ping_host` | ICMP ping (count clamped to 1-10) |
48
+ | `traceroute_path` | Hop-by-hop path/loss report via `mtr --report` (fixed cycles, not a live/continuous run) |
49
+ | `tcp_port_check` | Is a TCP port open — a plain socket connect, not a port scan |
50
+ | `http_check` | HEAD/GET a URL and report status, redirect chain, and latency |
51
+ | `tls_cert_check` | Fetch the certificate a host presents and report subject/issuer/validity/SANs |
52
+ | `whois_lookup` | WHOIS lookup for a domain |
53
+ | `asn_lookup` | ASN + country-code lookup for an IP, or org info for an AS number, via Team Cymru's whois service — no API key or GeoIP database needed |
54
+ | `health_check` | Version and which wrapped binaries (`dig`/`ping`/`mtr`/`whois`) are present on PATH |
55
+
56
+ All tools are read-only and single-target (no batch/sweep mode) — this is a
57
+ convenience wrapper around checks an operator would run by hand, not a
58
+ scanning tool. `nmap`-style multi-host/multi-port scanning is intentionally
59
+ out of scope; deliberately probing many hosts or ports is a different,
60
+ higher-blast-radius action that deserves its own tooling and approval flow.
61
+
62
+ `tcp_port_check`, `http_check` and `tls_cert_check` use Python's own
63
+ socket/ssl/httpx stack rather than shelling out to `nc`/`curl`/`openssl`, so
64
+ those three tools work even on a host with only the `dig`/`ping`/`mtr`/`whois`
65
+ binaries installed (or none of them — `health_check` reports which are
66
+ missing without failing the whole server).
67
+
68
+ `dns_lookup`/`dnssec_check` support DNS-over-TLS and DNS-over-HTTPS via
69
+ `transport="dot"`/`"doh"` (dig's `+tls`/`+https`). This needs `dig` from
70
+ BIND 9.18+ — an older `dig` rejects the flag outright rather than silently
71
+ falling back to plain DNS, so a stale binary fails loudly instead of giving
72
+ a false sense of having checked over an encrypted transport.
73
+
74
+ `tls_cert_check`/`http_check` against a bare IP address can fail TLS
75
+ handshake with a "handshake failure" or similar error on SNI-hosted /
76
+ CDN-fronted origins (e.g. behind Cloudflare) — TLS's SNI extension only
77
+ carries hostnames, so an IP literal can't route to the right certificate on
78
+ a shared edge. This is normal TLS behavior, not a tool bug; check by
79
+ hostname when the target is CDN-fronted.
80
+
81
+ ## Setup
82
+
83
+ ### 1. System dependencies
84
+
85
+ `dns_lookup`, `dnssec_check`, `ping_host`, `traceroute_path` and
86
+ `whois_lookup` shell out to `dig`, `ping`, `mtr` and `whois` respectively.
87
+ Install whichever of these you want available:
88
+
89
+ ```bash
90
+ # Debian/Ubuntu
91
+ sudo apt install dnsutils iputils-ping mtr-tiny whois
92
+ ```
93
+
94
+ `mtr` needs raw-socket access. Debian/Ubuntu's `mtr-tiny` package grants
95
+ `cap_net_raw` to the `mtr-packet` helper at install time, so it normally
96
+ works for an unprivileged service user without further setup — verify with
97
+ `getcap "$(command -v mtr-packet)"` if `traceroute_path` reports a socket
98
+ permission error. Without that capability, `traceroute_path` fails cleanly
99
+ with a `ToolError` rather than crashing the server.
100
+
101
+ ### 2. Install
102
+
103
+ ```bash
104
+ pip install netdiag-mcp
105
+ # or
106
+ uv tool install netdiag-mcp
107
+ ```
108
+
109
+ ### 3. Claude Code (manual)
110
+
111
+ ```bash
112
+ claude mcp add netdiag -- netdiag-mcp
113
+ ```
114
+
115
+ No environment variables are required.
116
+
117
+ ## CLI
118
+
119
+ ```bash
120
+ netdiag-mcp --version # print version
121
+ netdiag-mcp --check # report which wrapped binaries are present (exit 0 when all are)
122
+ ```
123
+
124
+ ## Security notes
125
+
126
+ - Every external-binary call passes an argv list (never a shell string), so
127
+ no tool argument can break out into shell syntax.
128
+ - Hostname/IP and port arguments are validated and size/range-clamped before
129
+ use — tool input is model-driven and treated as untrusted, the same as any
130
+ other tool-calling surface.
131
+ - `tcp_port_check` connects to exactly one host:port per call; there is no
132
+ loop or range argument, by design.
133
+
134
+ ## License
135
+
136
+ MIT
@@ -0,0 +1,106 @@
1
+ <!-- mcp-name: io.github.shigechika/netdiag-mcp -->
2
+
3
+ # netdiag-mcp
4
+
5
+ English | [日本語](README.ja.md)
6
+
7
+ MCP server for on-demand network diagnostics — DNS lookups (with a DNSSEC AD-bit check), ping, an `mtr`-based path report, TCP port checks, HTTP status/redirect checks, TLS certificate inspection, and WHOIS, all from one server.
8
+
9
+ Built for triaging "can't reach X" / "is DNS propagated yet" reports without shelling into a jump host for each one-off `dig`/`ping`/`curl`.
10
+
11
+ ## Tools
12
+
13
+ | Tool | Purpose |
14
+ |---|---|
15
+ | `dns_lookup` | Resolve a DNS record via `dig` (A/AAAA/MX/TXT/NS/CNAME/SOA/PTR/CAA), optionally against a specific resolver and over plain DNS/DoT/DoH |
16
+ | `dnssec_check` | Query a known-validating resolver and report whether the AD bit is set (plain/DoT/DoH) — the only reliable way to confirm DNSSEC validation, since an RRSIG being present in a plain `dig` reply does not by itself prove anything validated it |
17
+ | `ping_host` | ICMP ping (count clamped to 1-10) |
18
+ | `traceroute_path` | Hop-by-hop path/loss report via `mtr --report` (fixed cycles, not a live/continuous run) |
19
+ | `tcp_port_check` | Is a TCP port open — a plain socket connect, not a port scan |
20
+ | `http_check` | HEAD/GET a URL and report status, redirect chain, and latency |
21
+ | `tls_cert_check` | Fetch the certificate a host presents and report subject/issuer/validity/SANs |
22
+ | `whois_lookup` | WHOIS lookup for a domain |
23
+ | `asn_lookup` | ASN + country-code lookup for an IP, or org info for an AS number, via Team Cymru's whois service — no API key or GeoIP database needed |
24
+ | `health_check` | Version and which wrapped binaries (`dig`/`ping`/`mtr`/`whois`) are present on PATH |
25
+
26
+ All tools are read-only and single-target (no batch/sweep mode) — this is a
27
+ convenience wrapper around checks an operator would run by hand, not a
28
+ scanning tool. `nmap`-style multi-host/multi-port scanning is intentionally
29
+ out of scope; deliberately probing many hosts or ports is a different,
30
+ higher-blast-radius action that deserves its own tooling and approval flow.
31
+
32
+ `tcp_port_check`, `http_check` and `tls_cert_check` use Python's own
33
+ socket/ssl/httpx stack rather than shelling out to `nc`/`curl`/`openssl`, so
34
+ those three tools work even on a host with only the `dig`/`ping`/`mtr`/`whois`
35
+ binaries installed (or none of them — `health_check` reports which are
36
+ missing without failing the whole server).
37
+
38
+ `dns_lookup`/`dnssec_check` support DNS-over-TLS and DNS-over-HTTPS via
39
+ `transport="dot"`/`"doh"` (dig's `+tls`/`+https`). This needs `dig` from
40
+ BIND 9.18+ — an older `dig` rejects the flag outright rather than silently
41
+ falling back to plain DNS, so a stale binary fails loudly instead of giving
42
+ a false sense of having checked over an encrypted transport.
43
+
44
+ `tls_cert_check`/`http_check` against a bare IP address can fail TLS
45
+ handshake with a "handshake failure" or similar error on SNI-hosted /
46
+ CDN-fronted origins (e.g. behind Cloudflare) — TLS's SNI extension only
47
+ carries hostnames, so an IP literal can't route to the right certificate on
48
+ a shared edge. This is normal TLS behavior, not a tool bug; check by
49
+ hostname when the target is CDN-fronted.
50
+
51
+ ## Setup
52
+
53
+ ### 1. System dependencies
54
+
55
+ `dns_lookup`, `dnssec_check`, `ping_host`, `traceroute_path` and
56
+ `whois_lookup` shell out to `dig`, `ping`, `mtr` and `whois` respectively.
57
+ Install whichever of these you want available:
58
+
59
+ ```bash
60
+ # Debian/Ubuntu
61
+ sudo apt install dnsutils iputils-ping mtr-tiny whois
62
+ ```
63
+
64
+ `mtr` needs raw-socket access. Debian/Ubuntu's `mtr-tiny` package grants
65
+ `cap_net_raw` to the `mtr-packet` helper at install time, so it normally
66
+ works for an unprivileged service user without further setup — verify with
67
+ `getcap "$(command -v mtr-packet)"` if `traceroute_path` reports a socket
68
+ permission error. Without that capability, `traceroute_path` fails cleanly
69
+ with a `ToolError` rather than crashing the server.
70
+
71
+ ### 2. Install
72
+
73
+ ```bash
74
+ pip install netdiag-mcp
75
+ # or
76
+ uv tool install netdiag-mcp
77
+ ```
78
+
79
+ ### 3. Claude Code (manual)
80
+
81
+ ```bash
82
+ claude mcp add netdiag -- netdiag-mcp
83
+ ```
84
+
85
+ No environment variables are required.
86
+
87
+ ## CLI
88
+
89
+ ```bash
90
+ netdiag-mcp --version # print version
91
+ netdiag-mcp --check # report which wrapped binaries are present (exit 0 when all are)
92
+ ```
93
+
94
+ ## Security notes
95
+
96
+ - Every external-binary call passes an argv list (never a shell string), so
97
+ no tool argument can break out into shell syntax.
98
+ - Hostname/IP and port arguments are validated and size/range-clamped before
99
+ use — tool input is model-driven and treated as untrusted, the same as any
100
+ other tool-calling surface.
101
+ - `tcp_port_check` connects to exactly one host:port per call; there is no
102
+ loop or range argument, by design.
103
+
104
+ ## License
105
+
106
+ MIT
@@ -0,0 +1,3 @@
1
+ """netdiag-mcp — on-demand network diagnostics MCP Server (dig, ping, mtr, whois, TLS/HTTP checks)."""
2
+
3
+ __version__ = "0.2.0" # x-release-please-version
@@ -0,0 +1,48 @@
1
+ """Entry point for netdiag-mcp."""
2
+
3
+ import argparse
4
+ import asyncio
5
+ import os
6
+ import sys
7
+
8
+ from netdiag_mcp import __version__
9
+
10
+
11
+ def main():
12
+ parser = argparse.ArgumentParser(
13
+ description="On-demand network diagnostics MCP Server (dig, ping, mtr, whois, TLS/HTTP checks)",
14
+ formatter_class=argparse.RawDescriptionHelpFormatter,
15
+ epilog="""
16
+ No required environment variables. Wrapped binaries (dig, ping, mtr, whois)
17
+ must be on PATH; missing ones degrade health_check but do not stop the
18
+ server, since the other tools (tcp_port_check, http_check, tls_cert_check)
19
+ use Python's own socket/ssl/httpx stack and need no external binary.
20
+ """,
21
+ )
22
+ parser.add_argument("--version", action="store_true", help="Print version and exit")
23
+ parser.add_argument("--check", action="store_true", help="Verify wrapped binaries are present, then exit")
24
+ args = parser.parse_args()
25
+
26
+ if args.version:
27
+ print(f"netdiag-mcp {__version__}")
28
+ sys.exit(0)
29
+
30
+ if args.check:
31
+ from netdiag_mcp.server import health_check
32
+
33
+ result = health_check()
34
+ print(f"{result['status']} — {result['service']} {result['version']}")
35
+ if result["missing"]:
36
+ print(f"missing binaries: {', '.join(result['missing'])}", file=sys.stderr)
37
+ sys.exit(0 if result["status"] == "healthy" else 2)
38
+
39
+ from netdiag_mcp.server import mcp
40
+
41
+ try:
42
+ mcp.run()
43
+ except (KeyboardInterrupt, asyncio.CancelledError):
44
+ os._exit(0)
45
+
46
+
47
+ if __name__ == "__main__":
48
+ main()
@@ -0,0 +1,133 @@
1
+ """netdiag-mcp — MCP Server tools."""
2
+
3
+ import shutil
4
+
5
+ from mcp.server.fastmcp import FastMCP
6
+
7
+ from netdiag_mcp import tools
8
+ from netdiag_mcp.tools import ToolError
9
+
10
+ mcp = FastMCP("netdiag-mcp")
11
+
12
+ # health_check probes these; missing ones degrade rather than fail outright
13
+ # so the server stays usable for whichever tools still have their binary.
14
+ _REQUIRED_BINARIES = ("dig", "ping", "mtr", "whois")
15
+
16
+
17
+ @mcp.tool()
18
+ def health_check() -> dict:
19
+ """Service health: version and which wrapped binaries are present on PATH.
20
+
21
+ Returns a fixed shape (status/service/version + backend fields) so a
22
+ monitoring caller never has to branch on missing keys. status is
23
+ "healthy" when every wrapped binary is found, "degraded" when at least
24
+ one is missing (the corresponding tools will fail at call time).
25
+ """
26
+ from netdiag_mcp import __version__
27
+
28
+ binaries = {name: shutil.which(name) is not None for name in _REQUIRED_BINARIES}
29
+ missing = [name for name, present in binaries.items() if not present]
30
+ return {
31
+ "status": "healthy" if not missing else "degraded",
32
+ "service": "netdiag-mcp",
33
+ "version": __version__,
34
+ "binaries": binaries,
35
+ "missing": missing,
36
+ }
37
+
38
+
39
+ @mcp.tool()
40
+ def dns_lookup(hostname: str, record_type: str = "A", resolver: str | None = None, transport: str = "plain") -> str:
41
+ """Resolve a DNS record via `dig`. record_type: A/AAAA/MX/TXT/NS/CNAME/SOA/PTR/CAA.
42
+
43
+ Pass resolver to query a specific nameserver instead of the host default
44
+ (e.g. to check whether a change has propagated to a given resolver).
45
+ transport: "plain" (UDP/TCP 53, default), "dot" (DNS-over-TLS, 853) or
46
+ "doh" (DNS-over-HTTPS, 443). Requires dig from BIND 9.18+; an older dig
47
+ rejects dot/doh outright instead of silently querying over plain DNS.
48
+ """
49
+ try:
50
+ return tools.dns_lookup(hostname, record_type, resolver, transport)
51
+ except (ValueError, ToolError) as e:
52
+ return f"error: {e}"
53
+
54
+
55
+ @mcp.tool()
56
+ def dnssec_check(hostname: str, resolver: str = "1.1.1.1", transport: str = "plain") -> str:
57
+ """Check whether a name validates DNSSEC against a known-validating resolver (AD bit).
58
+
59
+ transport: "plain" (default), "dot" or "doh" — compare validation over
60
+ plain DNS vs. an encrypted transport when port 53 may be intercepted.
61
+ """
62
+ try:
63
+ return tools.dnssec_check(hostname, resolver, transport)
64
+ except (ValueError, ToolError) as e:
65
+ return f"error: {e}"
66
+
67
+
68
+ @mcp.tool()
69
+ def ping_host(host: str, count: int = 4) -> str:
70
+ """ICMP ping a host or IP. count is clamped to 1-10."""
71
+ try:
72
+ return tools.ping_host(host, count)
73
+ except (ValueError, ToolError) as e:
74
+ return f"error: {e}"
75
+
76
+
77
+ @mcp.tool()
78
+ def traceroute_path(host: str, cycles: int = 3) -> str:
79
+ """Path/MTU-style hop report via `mtr --report` (fixed cycles, not a live run). cycles clamped 1-10."""
80
+ try:
81
+ return tools.traceroute_path(host, cycles)
82
+ except (ValueError, ToolError) as e:
83
+ return f"error: {e}"
84
+
85
+
86
+ @mcp.tool()
87
+ def tcp_port_check(host: str, port: int, timeout: float = 5.0) -> str:
88
+ """Check whether a TCP port is open (plain socket connect, no port scanning)."""
89
+ try:
90
+ return tools.tcp_port_check(host, port, timeout)
91
+ except (ValueError, ToolError) as e:
92
+ return f"error: {e}"
93
+
94
+
95
+ @mcp.tool()
96
+ def http_check(url: str, timeout: float = 5.0) -> str:
97
+ """HEAD/GET a URL and report status, redirect chain and latency."""
98
+ try:
99
+ return tools.http_check(url, timeout)
100
+ except (ValueError, ToolError) as e:
101
+ return f"error: {e}"
102
+
103
+
104
+ @mcp.tool()
105
+ def tls_cert_check(host: str, port: int = 443) -> str:
106
+ """Fetch the TLS certificate presented on host:port and report subject/issuer/validity/SANs."""
107
+ try:
108
+ return tools.tls_cert_check(host, port)
109
+ except (ValueError, ToolError) as e:
110
+ return f"error: {e}"
111
+
112
+
113
+ @mcp.tool()
114
+ def whois_lookup(domain: str) -> str:
115
+ """WHOIS lookup for a domain."""
116
+ try:
117
+ return tools.whois_lookup(domain)
118
+ except (ValueError, ToolError) as e:
119
+ return f"error: {e}"
120
+
121
+
122
+ @mcp.tool()
123
+ def asn_lookup(target: str) -> str:
124
+ """ASN + country-code lookup for an IP, or org info for an AS number (e.g. AS15169 or 15169).
125
+
126
+ Via Team Cymru's whois service — no API key or GeoIP database needed.
127
+ Takes an IP literal or AS number, not a hostname; resolve first with
128
+ dns_lookup if you only have a name.
129
+ """
130
+ try:
131
+ return tools.asn_lookup(target)
132
+ except (ValueError, ToolError) as e:
133
+ return f"error: {e}"
@@ -0,0 +1,253 @@
1
+ """Wrappers around external diagnostic binaries and Python's own socket/ssl/httpx stack.
2
+
3
+ Every external-binary call uses an argv list (never shell=True), so no
4
+ argument can break out into shell syntax regardless of what the caller
5
+ passes — validate.py's job is bounding *size*, not escaping.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ipaddress
11
+ import platform
12
+ import re
13
+ import shutil
14
+ import socket
15
+ import ssl
16
+ import subprocess
17
+ import time
18
+
19
+ import httpx
20
+
21
+ from netdiag_mcp.validate import clamp, validate_port, validate_target
22
+
23
+ DEFAULT_TIMEOUT = 5.0
24
+ SUBPROCESS_TIMEOUT = 15.0
25
+
26
+ DNS_RECORD_TYPES = {"A", "AAAA", "MX", "TXT", "NS", "CNAME", "SOA", "PTR", "CAA"}
27
+
28
+ # dig flag for each transport. "plain" adds nothing (classic UDP/TCP port 53).
29
+ # +tls/+https require dig from BIND 9.18+; an older dig rejects the flag
30
+ # outright ("Invalid option", exit 1) rather than silently falling back to
31
+ # plain DNS, so a stale `dig` fails loudly here instead of giving a false
32
+ # sense of having checked over an encrypted transport.
33
+ _DNS_TRANSPORT_FLAGS = {"plain": None, "dot": "+tls", "doh": "+https"}
34
+
35
+
36
+ class ToolError(Exception):
37
+ """A tool could not run at all (missing binary, timeout, bad input)."""
38
+
39
+
40
+ def _run(binary: str, args: list[str]) -> str:
41
+ if shutil.which(binary) is None:
42
+ raise ToolError(f"{binary} is not installed on this host")
43
+ try:
44
+ proc = subprocess.run(
45
+ [binary, *args],
46
+ capture_output=True,
47
+ text=True,
48
+ timeout=SUBPROCESS_TIMEOUT,
49
+ )
50
+ except subprocess.TimeoutExpired as e:
51
+ raise ToolError(f"{binary} timed out after {SUBPROCESS_TIMEOUT:g}s") from e
52
+ out = proc.stdout.strip()
53
+ err = proc.stderr.strip()
54
+ if proc.returncode != 0 and not out:
55
+ raise ToolError(err or f"{binary} exited {proc.returncode} with no output")
56
+ return out if not err else f"{out}\n[stderr] {err}" if out else err
57
+
58
+
59
+ def _transport_flag(transport: str) -> str | None:
60
+ key = transport.strip().lower()
61
+ if key not in _DNS_TRANSPORT_FLAGS:
62
+ raise ToolError(f"unsupported transport {transport!r}; use one of {sorted(_DNS_TRANSPORT_FLAGS)}")
63
+ return _DNS_TRANSPORT_FLAGS[key]
64
+
65
+
66
+ def dns_lookup(hostname: str, record_type: str = "A", resolver: str | None = None, transport: str = "plain") -> str:
67
+ """transport: "plain" (UDP/TCP 53, default), "dot" (DNS-over-TLS, 853) or "doh" (DNS-over-HTTPS, 443)."""
68
+ target = validate_target(hostname)
69
+ rtype = record_type.strip().upper()
70
+ if rtype not in DNS_RECORD_TYPES:
71
+ raise ToolError(f"unsupported record type {record_type!r}; use one of {sorted(DNS_RECORD_TYPES)}")
72
+ flag = _transport_flag(transport)
73
+ args = []
74
+ if resolver:
75
+ args.append(f"@{validate_target(resolver)}")
76
+ if flag:
77
+ args.append(flag)
78
+ args += [target, rtype, "+noall", "+answer", "+stats"]
79
+ return _run("dig", args)
80
+
81
+
82
+ def dnssec_check(hostname: str, resolver: str = "1.1.1.1", transport: str = "plain") -> str:
83
+ """Query a validating resolver and report whether the AD (Authenticated Data) bit is set.
84
+
85
+ A bare `dig` reply with an RRSIG present does NOT mean DNSSEC validated
86
+ — only a resolver that itself validates and sets the AD flag proves
87
+ that. This deliberately targets a known-validating public resolver
88
+ rather than trusting whatever the host's default resolver is.
89
+
90
+ transport lets you compare validation over plain DNS vs. DoT/DoH — useful
91
+ when a network intercepts/spoofs port 53 but leaves 443/853 alone.
92
+ """
93
+ target = validate_target(hostname)
94
+ res = validate_target(resolver)
95
+ flag = _transport_flag(transport)
96
+ args = [f"@{res}"]
97
+ if flag:
98
+ args.append(flag)
99
+ args += [target, "+dnssec", "+noall", "+comment", "+answer"]
100
+ out = _run("dig", args)
101
+ ad_set = "flags:" in out and " ad" in out.split("flags:", 1)[1].split(";", 1)[0]
102
+ verdict = "DNSSEC validated (AD bit set)" if ad_set else "DNSSEC NOT validated (AD bit absent)"
103
+ return f"{verdict}\n\n{out}"
104
+
105
+
106
+ def _is_ipv6_literal(target: str) -> bool:
107
+ try:
108
+ return ipaddress.ip_address(target).version == 6
109
+ except ValueError:
110
+ return False # hostname — let ping's own resolver pick a family
111
+
112
+
113
+ def ping_host(host: str, count: int = 4) -> str:
114
+ """ping with an explicit overall deadline where the binary supports one.
115
+
116
+ Without a deadline, an unreachable target makes `ping` wait its own
117
+ per-packet default for every probe (observed ~12s for count=2 against a
118
+ black-holed address), which can exceed SUBPROCESS_TIMEOUT and get
119
+ hard-killed into a generic "timed out" ToolError instead of ping's own
120
+ clean 100%-loss report. iputils (Linux, the deploy target) takes an
121
+ overall deadline via `-w`; BSD/macOS `ping` takes one via `-t`.
122
+
123
+ On Linux a single `ping` binary handles both families. BSD/macOS's
124
+ `ping` is IPv4-only and rejects an IPv6 literal outright ("cannot
125
+ resolve ...: Unknown host") — this dispatches to `ping6` there instead,
126
+ which has no overall-deadline flag at all (its `-t` means something
127
+ unrelated: an ICMPv6 Node Information query type), so an unreachable
128
+ IPv6 target on macOS falls back to the SUBPROCESS_TIMEOUT backstop and
129
+ a generic timeout error rather than a clean loss report — a narrow gap
130
+ limited to local macOS testing, since Linux never takes this branch.
131
+ An IPv6-only *hostname* on macOS still isn't handled (that would need a
132
+ pre-resolve this tool deliberately doesn't do), but a bare IPv6 literal
133
+ now works on both platforms.
134
+ """
135
+ target = validate_target(host)
136
+ n = clamp(count, 1, 10)
137
+ deadline = clamp(n * 2, 4, 10)
138
+ is_linux = platform.system() == "Linux"
139
+ binary = "ping" if is_linux or not _is_ipv6_literal(target) else "ping6"
140
+ args = ["-c", str(n)]
141
+ if binary != "ping6":
142
+ args += ["-w" if is_linux else "-t", str(deadline)]
143
+ args.append(target)
144
+ return _run(binary, args)
145
+
146
+
147
+ def traceroute_path(host: str, cycles: int = 3) -> str:
148
+ """mtr in report mode — a fixed number of cycles, not a live/continuous run."""
149
+ target = validate_target(host)
150
+ n = clamp(cycles, 1, 10)
151
+ return _run("mtr", ["--report", "--report-cycles", str(n), "--no-dns", target])
152
+
153
+
154
+ def whois_lookup(domain: str) -> str:
155
+ target = validate_target(domain)
156
+ return _run("whois", [target])
157
+
158
+
159
+ _ASN_RE = re.compile(r"^(?:AS)?(\d{1,10})$", re.IGNORECASE)
160
+
161
+
162
+ def asn_lookup(target: str) -> str:
163
+ """ASN + country-code lookup for an IP, or org info for an AS number, via
164
+ Team Cymru's whois service (whois.cymru.com) — no API key or GeoIP
165
+ database needed, reuses the `whois` binary already required by
166
+ whois_lookup. Accepts an IP literal or an AS number (`AS15169` or
167
+ `15169`), not a hostname — Cymru's service does prefix/ASN lookups, not
168
+ DNS resolution, so resolve a hostname with dns_lookup first.
169
+ """
170
+ stripped = target.strip()
171
+ m = _ASN_RE.match(stripped)
172
+ if m:
173
+ query = f"AS{m.group(1)}"
174
+ else:
175
+ try:
176
+ ipaddress.ip_address(stripped)
177
+ except ValueError as e:
178
+ raise ToolError(
179
+ f"asn_lookup takes an IP address or AS number (e.g. AS15169 or 15169), "
180
+ f"not a hostname: {target!r}. Resolve it first with dns_lookup."
181
+ ) from e
182
+ query = validate_target(stripped)
183
+ # The leading space before "-v" is a documented whois.cymru.com quirk:
184
+ # their server parses flags out of the raw query string itself (the
185
+ # standard whois protocol has no argv-style options), and drops the
186
+ # -v verbose header line without it.
187
+ return _run("whois", ["-h", "whois.cymru.com", f" -v {query}"])
188
+
189
+
190
+ def tcp_port_check(host: str, port: int, timeout: float = DEFAULT_TIMEOUT) -> str:
191
+ """Native TCP connect probe — no `nc` dependency, no shell involved at all."""
192
+ target = validate_target(host)
193
+ p = validate_port(port)
194
+ t = clamp(timeout, 1, 15)
195
+ start = time.monotonic()
196
+ try:
197
+ with socket.create_connection((target, p), timeout=t):
198
+ elapsed_ms = (time.monotonic() - start) * 1000
199
+ return f"{target}:{p} open ({elapsed_ms:.1f}ms)"
200
+ except TimeoutError:
201
+ return f"{target}:{p} timed out after {t:g}s"
202
+ except (ConnectionRefusedError, OSError) as e:
203
+ return f"{target}:{p} closed/unreachable: {e}"
204
+
205
+
206
+ def http_check(url: str, timeout: float = DEFAULT_TIMEOUT) -> str:
207
+ """HEAD (falling back to GET) via httpx — reports status, redirect chain and latency."""
208
+ t = clamp(timeout, 1, 15)
209
+ try:
210
+ with httpx.Client(follow_redirects=True, timeout=t) as client:
211
+ start = time.monotonic()
212
+ resp = client.head(url)
213
+ if resp.status_code == 405:
214
+ resp = client.get(url)
215
+ elapsed_ms = (time.monotonic() - start) * 1000
216
+ except httpx.HTTPError as e:
217
+ raise ToolError(f"HTTP request failed: {e}") from e
218
+ lines = [f"{resp.status_code} {resp.reason_phrase} {elapsed_ms:.0f}ms final_url={resp.url}"]
219
+ if resp.history:
220
+ chain = " -> ".join(str(r.url) for r in [*resp.history, resp])
221
+ lines.append(f"redirects: {chain}")
222
+ for header in ("server", "content-type", "content-length"):
223
+ if header in resp.headers:
224
+ lines.append(f"{header}: {resp.headers[header]}")
225
+ return "\n".join(lines)
226
+
227
+
228
+ def tls_cert_check(host: str, port: int = 443) -> str:
229
+ """Native ssl/socket TLS handshake — reports the peer certificate, not raw openssl s_client text."""
230
+ target = validate_target(host)
231
+ p = validate_port(port)
232
+ ctx = ssl.create_default_context()
233
+ try:
234
+ with socket.create_connection((target, p), timeout=DEFAULT_TIMEOUT) as sock:
235
+ with ctx.wrap_socket(sock, server_hostname=target) as tls:
236
+ cert = tls.getpeercert()
237
+ cipher = tls.cipher()
238
+ except ssl.SSLCertVerificationError as e:
239
+ return f"TLS handshake failed certificate verification: {e}"
240
+ except (TimeoutError, OSError) as e:
241
+ raise ToolError(f"could not connect to {target}:{p}: {e}") from e
242
+ subject = dict(x[0] for x in cert.get("subject", []))
243
+ issuer = dict(x[0] for x in cert.get("issuer", []))
244
+ sans = [v for k, v in cert.get("subjectAltName", []) if k == "DNS"]
245
+ lines = [
246
+ f"subject: {subject.get('commonName', '?')}",
247
+ f"issuer: {issuer.get('commonName', '?')}",
248
+ f"validity: {cert.get('notBefore')} -> {cert.get('notAfter')}",
249
+ f"cipher: {cipher[0]} {cipher[1]}" if cipher else "cipher: ?",
250
+ ]
251
+ if sans:
252
+ lines.append(f"subjectAltName: {', '.join(sans)}")
253
+ return "\n".join(lines)
@@ -0,0 +1,47 @@
1
+ """Input validation shared by every tool.
2
+
3
+ Tool arguments are LLM-driven and must be treated as adversarial: nothing
4
+ here ever builds a shell string (subprocess calls always pass argv lists),
5
+ but sizes/counts/hostnames are still bounds-checked so a single tool call
6
+ cannot become an amplification vector (e.g. an unbounded ping count) or a
7
+ malformed argv0 for the wrapped binary.
8
+ """
9
+
10
+ import ipaddress
11
+ import re
12
+
13
+ _HOSTNAME_RE = re.compile(r"^(?=.{1,253}$)(?!-)[A-Za-z0-9-]{1,63}(?<!-)(\.(?!-)[A-Za-z0-9-]{1,63}(?<!-))*\.?$")
14
+
15
+
16
+ def validate_target(value: str) -> str:
17
+ """Return value unchanged if it is a plausible hostname or IP literal.
18
+
19
+ Raises ValueError otherwise. Deliberately permissive about *what* the
20
+ name resolves to (that is the point of the tool) — this only rejects
21
+ input that could not be a real target at all (empty, whitespace,
22
+ shell metacharacters, embedded newlines).
23
+ """
24
+ target = value.strip()
25
+ if not target or len(target) > 253:
26
+ raise ValueError("target must be a non-empty hostname or IP address")
27
+ if any(c.isspace() for c in target):
28
+ raise ValueError("target must not contain whitespace")
29
+ try:
30
+ ipaddress.ip_address(target)
31
+ return target
32
+ except ValueError:
33
+ pass
34
+ if not _HOSTNAME_RE.match(target):
35
+ raise ValueError(f"not a valid hostname or IP address: {value!r}")
36
+ return target
37
+
38
+
39
+ def validate_port(value: int) -> int:
40
+ port = int(value)
41
+ if not 1 <= port <= 65535:
42
+ raise ValueError("port must be between 1 and 65535")
43
+ return port
44
+
45
+
46
+ def clamp(value: int, low: int, high: int) -> int:
47
+ return max(low, min(high, int(value)))
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.4
2
+ Name: netdiag-mcp
3
+ Version: 0.2.0
4
+ Summary: MCP server wrapping dig, ping, mtr, whois and TLS/HTTP checks for on-demand network diagnostics
5
+ Author: AIKAWA Shigechika
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/shigechika/netdiag-mcp
8
+ Project-URL: Repository, https://github.com/shigechika/netdiag-mcp
9
+ Project-URL: Issues, https://github.com/shigechika/netdiag-mcp/issues
10
+ Keywords: dns,dig,ping,mtr,whois,tls,network,mcp,model-context-protocol,diagnostics
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: System Administrators
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: System :: Networking
19
+ Classifier: Topic :: Internet
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: httpx>=0.27
24
+ Requires-Dist: mcp<2,>=1.2
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest; extra == "dev"
27
+ Requires-Dist: pytest-cov; extra == "dev"
28
+ Requires-Dist: ruff; extra == "dev"
29
+ Dynamic: license-file
30
+
31
+ <!-- mcp-name: io.github.shigechika/netdiag-mcp -->
32
+
33
+ # netdiag-mcp
34
+
35
+ English | [日本語](README.ja.md)
36
+
37
+ MCP server for on-demand network diagnostics — DNS lookups (with a DNSSEC AD-bit check), ping, an `mtr`-based path report, TCP port checks, HTTP status/redirect checks, TLS certificate inspection, and WHOIS, all from one server.
38
+
39
+ Built for triaging "can't reach X" / "is DNS propagated yet" reports without shelling into a jump host for each one-off `dig`/`ping`/`curl`.
40
+
41
+ ## Tools
42
+
43
+ | Tool | Purpose |
44
+ |---|---|
45
+ | `dns_lookup` | Resolve a DNS record via `dig` (A/AAAA/MX/TXT/NS/CNAME/SOA/PTR/CAA), optionally against a specific resolver and over plain DNS/DoT/DoH |
46
+ | `dnssec_check` | Query a known-validating resolver and report whether the AD bit is set (plain/DoT/DoH) — the only reliable way to confirm DNSSEC validation, since an RRSIG being present in a plain `dig` reply does not by itself prove anything validated it |
47
+ | `ping_host` | ICMP ping (count clamped to 1-10) |
48
+ | `traceroute_path` | Hop-by-hop path/loss report via `mtr --report` (fixed cycles, not a live/continuous run) |
49
+ | `tcp_port_check` | Is a TCP port open — a plain socket connect, not a port scan |
50
+ | `http_check` | HEAD/GET a URL and report status, redirect chain, and latency |
51
+ | `tls_cert_check` | Fetch the certificate a host presents and report subject/issuer/validity/SANs |
52
+ | `whois_lookup` | WHOIS lookup for a domain |
53
+ | `asn_lookup` | ASN + country-code lookup for an IP, or org info for an AS number, via Team Cymru's whois service — no API key or GeoIP database needed |
54
+ | `health_check` | Version and which wrapped binaries (`dig`/`ping`/`mtr`/`whois`) are present on PATH |
55
+
56
+ All tools are read-only and single-target (no batch/sweep mode) — this is a
57
+ convenience wrapper around checks an operator would run by hand, not a
58
+ scanning tool. `nmap`-style multi-host/multi-port scanning is intentionally
59
+ out of scope; deliberately probing many hosts or ports is a different,
60
+ higher-blast-radius action that deserves its own tooling and approval flow.
61
+
62
+ `tcp_port_check`, `http_check` and `tls_cert_check` use Python's own
63
+ socket/ssl/httpx stack rather than shelling out to `nc`/`curl`/`openssl`, so
64
+ those three tools work even on a host with only the `dig`/`ping`/`mtr`/`whois`
65
+ binaries installed (or none of them — `health_check` reports which are
66
+ missing without failing the whole server).
67
+
68
+ `dns_lookup`/`dnssec_check` support DNS-over-TLS and DNS-over-HTTPS via
69
+ `transport="dot"`/`"doh"` (dig's `+tls`/`+https`). This needs `dig` from
70
+ BIND 9.18+ — an older `dig` rejects the flag outright rather than silently
71
+ falling back to plain DNS, so a stale binary fails loudly instead of giving
72
+ a false sense of having checked over an encrypted transport.
73
+
74
+ `tls_cert_check`/`http_check` against a bare IP address can fail TLS
75
+ handshake with a "handshake failure" or similar error on SNI-hosted /
76
+ CDN-fronted origins (e.g. behind Cloudflare) — TLS's SNI extension only
77
+ carries hostnames, so an IP literal can't route to the right certificate on
78
+ a shared edge. This is normal TLS behavior, not a tool bug; check by
79
+ hostname when the target is CDN-fronted.
80
+
81
+ ## Setup
82
+
83
+ ### 1. System dependencies
84
+
85
+ `dns_lookup`, `dnssec_check`, `ping_host`, `traceroute_path` and
86
+ `whois_lookup` shell out to `dig`, `ping`, `mtr` and `whois` respectively.
87
+ Install whichever of these you want available:
88
+
89
+ ```bash
90
+ # Debian/Ubuntu
91
+ sudo apt install dnsutils iputils-ping mtr-tiny whois
92
+ ```
93
+
94
+ `mtr` needs raw-socket access. Debian/Ubuntu's `mtr-tiny` package grants
95
+ `cap_net_raw` to the `mtr-packet` helper at install time, so it normally
96
+ works for an unprivileged service user without further setup — verify with
97
+ `getcap "$(command -v mtr-packet)"` if `traceroute_path` reports a socket
98
+ permission error. Without that capability, `traceroute_path` fails cleanly
99
+ with a `ToolError` rather than crashing the server.
100
+
101
+ ### 2. Install
102
+
103
+ ```bash
104
+ pip install netdiag-mcp
105
+ # or
106
+ uv tool install netdiag-mcp
107
+ ```
108
+
109
+ ### 3. Claude Code (manual)
110
+
111
+ ```bash
112
+ claude mcp add netdiag -- netdiag-mcp
113
+ ```
114
+
115
+ No environment variables are required.
116
+
117
+ ## CLI
118
+
119
+ ```bash
120
+ netdiag-mcp --version # print version
121
+ netdiag-mcp --check # report which wrapped binaries are present (exit 0 when all are)
122
+ ```
123
+
124
+ ## Security notes
125
+
126
+ - Every external-binary call passes an argv list (never a shell string), so
127
+ no tool argument can break out into shell syntax.
128
+ - Hostname/IP and port arguments are validated and size/range-clamped before
129
+ use — tool input is model-driven and treated as untrusted, the same as any
130
+ other tool-calling surface.
131
+ - `tcp_port_check` connects to exactly one host:port per call; there is no
132
+ loop or range argument, by design.
133
+
134
+ ## License
135
+
136
+ MIT
@@ -0,0 +1,17 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ netdiag_mcp/__init__.py
5
+ netdiag_mcp/__main__.py
6
+ netdiag_mcp/server.py
7
+ netdiag_mcp/tools.py
8
+ netdiag_mcp/validate.py
9
+ netdiag_mcp.egg-info/PKG-INFO
10
+ netdiag_mcp.egg-info/SOURCES.txt
11
+ netdiag_mcp.egg-info/dependency_links.txt
12
+ netdiag_mcp.egg-info/entry_points.txt
13
+ netdiag_mcp.egg-info/requires.txt
14
+ netdiag_mcp.egg-info/top_level.txt
15
+ tests/test_server.py
16
+ tests/test_tools.py
17
+ tests/test_validate.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ netdiag-mcp = netdiag_mcp.__main__:main
@@ -0,0 +1,7 @@
1
+ httpx>=0.27
2
+ mcp<2,>=1.2
3
+
4
+ [dev]
5
+ pytest
6
+ pytest-cov
7
+ ruff
@@ -0,0 +1 @@
1
+ netdiag_mcp
@@ -0,0 +1,61 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "netdiag-mcp"
7
+ dynamic = ["version"]
8
+ description = "MCP server wrapping dig, ping, mtr, whois and TLS/HTTP checks for on-demand network diagnostics"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ {name = "AIKAWA Shigechika"},
14
+ ]
15
+ keywords = ["dns", "dig", "ping", "mtr", "whois", "tls", "network", "mcp", "model-context-protocol", "diagnostics"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: System Administrators",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Topic :: System :: Networking",
25
+ "Topic :: Internet",
26
+ ]
27
+ dependencies = [
28
+ "httpx>=0.27",
29
+ "mcp>=1.2,<2",
30
+ ]
31
+
32
+ [project.optional-dependencies]
33
+ dev = [
34
+ "pytest",
35
+ "pytest-cov",
36
+ "ruff",
37
+ ]
38
+
39
+ [project.scripts]
40
+ netdiag-mcp = "netdiag_mcp.__main__:main"
41
+
42
+ [project.urls]
43
+ Homepage = "https://github.com/shigechika/netdiag-mcp"
44
+ Repository = "https://github.com/shigechika/netdiag-mcp"
45
+ Issues = "https://github.com/shigechika/netdiag-mcp/issues"
46
+
47
+ [tool.setuptools]
48
+ packages = ["netdiag_mcp"]
49
+
50
+ [tool.setuptools.dynamic]
51
+ version = {attr = "netdiag_mcp.__version__"}
52
+
53
+ [tool.ruff]
54
+ target-version = "py310"
55
+ line-length = 120
56
+
57
+ [tool.ruff.lint]
58
+ select = ["E", "F", "I", "W", "UP"]
59
+
60
+ [tool.pytest.ini_options]
61
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,57 @@
1
+ import netdiag_mcp.server as server
2
+
3
+
4
+ def test_health_check_shape_all_present(monkeypatch):
5
+ monkeypatch.setattr(server.shutil, "which", lambda _: "/usr/bin/x")
6
+ result = server.health_check()
7
+ assert result["status"] == "healthy"
8
+ assert result["service"] == "netdiag-mcp"
9
+ assert result["missing"] == []
10
+ assert set(result["binaries"]) == set(server._REQUIRED_BINARIES)
11
+
12
+
13
+ def test_health_check_shape_degraded_when_missing(monkeypatch):
14
+ def fake_which(name):
15
+ return None if name == "mtr" else "/usr/bin/x"
16
+
17
+ monkeypatch.setattr(server.shutil, "which", fake_which)
18
+ result = server.health_check()
19
+ assert result["status"] == "degraded"
20
+ assert result["missing"] == ["mtr"]
21
+
22
+
23
+ def test_dns_lookup_wraps_validation_error_as_string():
24
+ out = server.dns_lookup("bad host name", "A")
25
+ assert out.startswith("error:")
26
+
27
+
28
+ def test_dns_lookup_delegates_to_tools(monkeypatch):
29
+ monkeypatch.setattr(server.tools, "dns_lookup", lambda h, r, s, t: f"{h}/{r}/{s}/{t}")
30
+ assert server.dns_lookup("example.com", "MX", None, "doh") == "example.com/MX/None/doh"
31
+
32
+
33
+ def test_ping_host_wraps_tool_error(monkeypatch):
34
+ def raise_tool_error(*a, **k):
35
+ raise server.ToolError("ping not installed")
36
+
37
+ monkeypatch.setattr(server.tools, "ping_host", raise_tool_error)
38
+ out = server.ping_host("example.com")
39
+ assert out == "error: ping not installed"
40
+
41
+
42
+ def test_tcp_port_check_rejects_bad_port():
43
+ out = server.tcp_port_check("example.com", 99999)
44
+ assert out.startswith("error:")
45
+
46
+
47
+ def test_asn_lookup_wraps_tool_error(monkeypatch):
48
+ def raise_tool_error(*a, **k):
49
+ raise server.ToolError("not a hostname")
50
+
51
+ monkeypatch.setattr(server.tools, "asn_lookup", raise_tool_error)
52
+ assert server.asn_lookup("example.com") == "error: not a hostname"
53
+
54
+
55
+ def test_asn_lookup_delegates_to_tools(monkeypatch):
56
+ monkeypatch.setattr(server.tools, "asn_lookup", lambda t: f"asn-result for {t}")
57
+ assert server.asn_lookup("8.8.8.8") == "asn-result for 8.8.8.8"
@@ -0,0 +1,231 @@
1
+ import socket
2
+ import subprocess
3
+ import threading
4
+
5
+ import httpx
6
+ import pytest
7
+
8
+ from netdiag_mcp import tools
9
+ from netdiag_mcp.tools import ToolError
10
+
11
+
12
+ def test_run_raises_when_binary_missing(monkeypatch):
13
+ monkeypatch.setattr(tools.shutil, "which", lambda _: None)
14
+ with pytest.raises(ToolError, match="not installed"):
15
+ tools._run("dig", ["example.com"])
16
+
17
+
18
+ def test_run_raises_on_timeout(monkeypatch):
19
+ monkeypatch.setattr(tools.shutil, "which", lambda _: "/usr/bin/dig")
20
+
21
+ def fake_run(*a, **k):
22
+ raise subprocess.TimeoutExpired(cmd="dig", timeout=15)
23
+
24
+ monkeypatch.setattr(subprocess, "run", fake_run)
25
+ with pytest.raises(ToolError, match="timed out"):
26
+ tools._run("dig", ["example.com"])
27
+
28
+
29
+ def test_run_returns_stdout(monkeypatch):
30
+ monkeypatch.setattr(tools.shutil, "which", lambda _: "/usr/bin/dig")
31
+ proc = subprocess.CompletedProcess(args=[], returncode=0, stdout="192.0.2.1\n", stderr="")
32
+ monkeypatch.setattr(subprocess, "run", lambda *a, **k: proc)
33
+ assert tools._run("dig", ["example.com"]) == "192.0.2.1"
34
+
35
+
36
+ def test_dns_lookup_rejects_unknown_record_type():
37
+ with pytest.raises(ToolError, match="unsupported record type"):
38
+ tools.dns_lookup("example.com", "BOGUS")
39
+
40
+
41
+ def test_dns_lookup_rejects_bad_hostname():
42
+ with pytest.raises(ValueError):
43
+ tools.dns_lookup("not a host", "A")
44
+
45
+
46
+ def test_dns_lookup_rejects_unknown_transport():
47
+ with pytest.raises(ToolError, match="unsupported transport"):
48
+ tools.dns_lookup("example.com", "A", transport="quic")
49
+
50
+
51
+ def test_dns_lookup_plain_transport_adds_no_flag(monkeypatch):
52
+ captured = {}
53
+ monkeypatch.setattr(tools, "_run", lambda binary, args: captured.setdefault("args", args) or "ok")
54
+ tools.dns_lookup("example.com", "A", transport="plain")
55
+ assert "+tls" not in captured["args"]
56
+ assert "+https" not in captured["args"]
57
+
58
+
59
+ @pytest.mark.parametrize("transport,flag", [("dot", "+tls"), ("doh", "+https")])
60
+ def test_dns_lookup_encrypted_transport_adds_flag(monkeypatch, transport, flag):
61
+ captured = {}
62
+ monkeypatch.setattr(tools, "_run", lambda binary, args: captured.setdefault("args", args) or "ok")
63
+ tools.dns_lookup("example.com", "A", resolver="1.1.1.1", transport=transport)
64
+ assert flag in captured["args"]
65
+ assert "@1.1.1.1" in captured["args"]
66
+
67
+
68
+ def test_dnssec_check_rejects_unknown_transport():
69
+ with pytest.raises(ToolError, match="unsupported transport"):
70
+ tools.dnssec_check("example.com", transport="quic")
71
+
72
+
73
+ @pytest.mark.parametrize("transport,flag", [("dot", "+tls"), ("doh", "+https")])
74
+ def test_dnssec_check_encrypted_transport_adds_flag(monkeypatch, transport, flag):
75
+ captured = {}
76
+
77
+ def fake_run(binary, args):
78
+ captured["args"] = args
79
+ return ";; flags: qr rd ra ad; QUERY: 1, ANSWER: 1"
80
+
81
+ monkeypatch.setattr(tools, "_run", fake_run)
82
+ tools.dnssec_check("example.com", transport=transport)
83
+ assert flag in captured["args"]
84
+
85
+
86
+ def test_dnssec_check_detects_ad_flag(monkeypatch):
87
+ fake_output = ";; flags: qr rd ra ad; QUERY: 1, ANSWER: 1\nexample.com. 300 IN A 192.0.2.1"
88
+ monkeypatch.setattr(tools, "_run", lambda binary, args: fake_output)
89
+ result = tools.dnssec_check("example.com")
90
+ assert "DNSSEC validated" in result
91
+
92
+
93
+ def test_dnssec_check_reports_missing_ad_flag(monkeypatch):
94
+ fake_output = ";; flags: qr rd ra; QUERY: 1, ANSWER: 1\nexample.com. 300 IN A 192.0.2.1"
95
+ monkeypatch.setattr(tools, "_run", lambda binary, args: fake_output)
96
+ result = tools.dnssec_check("example.com")
97
+ assert "NOT validated" in result
98
+
99
+
100
+ def test_ping_host_clamps_count(monkeypatch):
101
+ captured = {}
102
+
103
+ def fake_run(binary, args):
104
+ captured["args"] = args
105
+ return "ok"
106
+
107
+ monkeypatch.setattr(tools, "_run", fake_run)
108
+ tools.ping_host("example.com", count=999)
109
+ assert captured["args"][1] == "10" # clamped to the max
110
+
111
+
112
+ def test_ping_host_sets_an_overall_deadline(monkeypatch):
113
+ """Regression guard: without a deadline flag an unreachable target makes
114
+ ping wait its own per-packet default for every probe (observed ~12s for
115
+ count=2 against a black-holed address), which can exceed
116
+ SUBPROCESS_TIMEOUT and turn a clean 100%-loss report into a hard-killed
117
+ generic timeout error instead."""
118
+ captured = {}
119
+ monkeypatch.setattr(tools, "_run", lambda binary, args: captured.setdefault("args", args) or "ok")
120
+ tools.ping_host("example.com", count=4)
121
+ assert "-w" in captured["args"] or "-t" in captured["args"]
122
+
123
+
124
+ def test_ping_host_uses_ping6_for_ipv6_literal_on_macos(monkeypatch):
125
+ """Regression guard: BSD/macOS `ping` is IPv4-only and rejects an IPv6
126
+ literal outright ("cannot resolve ...: Unknown host") — a bare IPv6
127
+ literal must dispatch to `ping6` there. Linux's iputils `ping` handles
128
+ both, so this only applies off-Linux."""
129
+ captured = {}
130
+ monkeypatch.setattr(tools.platform, "system", lambda: "Darwin")
131
+ monkeypatch.setattr(tools, "_run", lambda binary, args: captured.setdefault("binary", binary) or "ok")
132
+ tools.ping_host("2001:db8::1")
133
+ assert captured["binary"] == "ping6"
134
+
135
+
136
+ def test_ping_host_uses_ping_for_ipv4_on_macos(monkeypatch):
137
+ captured = {}
138
+ monkeypatch.setattr(tools.platform, "system", lambda: "Darwin")
139
+ monkeypatch.setattr(tools, "_run", lambda binary, args: captured.setdefault("binary", binary) or "ok")
140
+ tools.ping_host("192.0.2.1")
141
+ assert captured["binary"] == "ping"
142
+
143
+
144
+ def test_ping_host_uses_ping_for_ipv6_on_linux(monkeypatch):
145
+ """Linux's iputils ping is dual-stack — no ping6 dispatch needed there."""
146
+ captured = {}
147
+ monkeypatch.setattr(tools.platform, "system", lambda: "Linux")
148
+ monkeypatch.setattr(tools, "_run", lambda binary, args: captured.setdefault("binary", binary) or "ok")
149
+ tools.ping_host("2001:db8::1")
150
+ assert captured["binary"] == "ping"
151
+
152
+
153
+ def test_tcp_port_check_open_port():
154
+ server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
155
+ server.bind(("127.0.0.1", 0))
156
+ server.listen(1)
157
+ port = server.getsockname()[1]
158
+ t = threading.Thread(target=lambda: server.accept(), daemon=True)
159
+ t.start()
160
+ try:
161
+ result = tools.tcp_port_check("127.0.0.1", port, timeout=2)
162
+ assert "open" in result
163
+ finally:
164
+ server.close()
165
+
166
+
167
+ def test_tcp_port_check_closed_port():
168
+ # Bind and immediately close to get a port nothing is listening on.
169
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
170
+ s.bind(("127.0.0.1", 0))
171
+ port = s.getsockname()[1]
172
+ s.close()
173
+ result = tools.tcp_port_check("127.0.0.1", port, timeout=2)
174
+ assert "closed" in result or "unreachable" in result
175
+
176
+
177
+ def test_http_check_reports_status_and_headers(monkeypatch):
178
+ real_client = httpx.Client
179
+
180
+ def handler(request):
181
+ return httpx.Response(200, headers={"server": "test-server"}, request=request)
182
+
183
+ def fake_client(*args, **kwargs):
184
+ kwargs["transport"] = httpx.MockTransport(handler)
185
+ return real_client(*args, **kwargs)
186
+
187
+ monkeypatch.setattr(tools.httpx, "Client", fake_client)
188
+ result = tools.http_check("https://example.com")
189
+ assert "200" in result
190
+ assert "server: test-server" in result
191
+
192
+
193
+ def test_http_check_wraps_transport_errors(monkeypatch):
194
+ real_client = httpx.Client
195
+
196
+ def handler(request):
197
+ raise httpx.ConnectError("boom", request=request)
198
+
199
+ def fake_client(*args, **kwargs):
200
+ kwargs["transport"] = httpx.MockTransport(handler)
201
+ return real_client(*args, **kwargs)
202
+
203
+ monkeypatch.setattr(tools.httpx, "Client", fake_client)
204
+ with pytest.raises(ToolError, match="HTTP request failed"):
205
+ tools.http_check("https://example.com")
206
+
207
+
208
+ def test_tls_cert_check_raises_on_connection_failure():
209
+ # Port 1 is a reserved system port with nothing listening in CI/dev.
210
+ with pytest.raises(ToolError):
211
+ tools.tls_cert_check("127.0.0.1", 1)
212
+
213
+
214
+ @pytest.mark.parametrize("value", ["AS15169", "as15169", "15169"])
215
+ def test_asn_lookup_normalizes_as_number(monkeypatch, value):
216
+ captured = {}
217
+ monkeypatch.setattr(tools, "_run", lambda binary, args: captured.setdefault("args", args) or "ok")
218
+ tools.asn_lookup(value)
219
+ assert captured["args"] == ["-h", "whois.cymru.com", " -v AS15169"]
220
+
221
+
222
+ def test_asn_lookup_passes_through_ip(monkeypatch):
223
+ captured = {}
224
+ monkeypatch.setattr(tools, "_run", lambda binary, args: captured.setdefault("args", args) or "ok")
225
+ tools.asn_lookup("8.8.8.8")
226
+ assert captured["args"] == ["-h", "whois.cymru.com", " -v 8.8.8.8"]
227
+
228
+
229
+ def test_asn_lookup_rejects_hostname():
230
+ with pytest.raises(ToolError, match="not a hostname"):
231
+ tools.asn_lookup("example.com")
@@ -0,0 +1,41 @@
1
+ import pytest
2
+
3
+ from netdiag_mcp.validate import clamp, validate_port, validate_target
4
+
5
+
6
+ @pytest.mark.parametrize(
7
+ "value",
8
+ ["example.com", "sub.example.co.jp", "192.0.2.1", "2001:db8::1", "a.b", "localhost"],
9
+ )
10
+ def test_validate_target_accepts_valid_input(value):
11
+ assert validate_target(value) == value
12
+
13
+
14
+ @pytest.mark.parametrize(
15
+ "value",
16
+ ["", " ", "has space.com", "evil.com; rm -rf /", "a" * 254, "bad\nname.com"],
17
+ )
18
+ def test_validate_target_rejects_invalid_input(value):
19
+ with pytest.raises(ValueError):
20
+ validate_target(value)
21
+
22
+
23
+ def test_validate_target_strips_surrounding_whitespace():
24
+ assert validate_target(" example.com ") == "example.com"
25
+
26
+
27
+ @pytest.mark.parametrize("value", [1, 80, 443, 65535])
28
+ def test_validate_port_accepts_valid_range(value):
29
+ assert validate_port(value) == value
30
+
31
+
32
+ @pytest.mark.parametrize("value", [0, -1, 65536, 100000])
33
+ def test_validate_port_rejects_out_of_range(value):
34
+ with pytest.raises(ValueError):
35
+ validate_port(value)
36
+
37
+
38
+ def test_clamp_bounds_both_directions():
39
+ assert clamp(-5, 1, 10) == 1
40
+ assert clamp(50, 1, 10) == 10
41
+ assert clamp(5, 1, 10) == 5