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.
- netdiag_mcp-0.2.0/LICENSE +21 -0
- netdiag_mcp-0.2.0/PKG-INFO +136 -0
- netdiag_mcp-0.2.0/README.md +106 -0
- netdiag_mcp-0.2.0/netdiag_mcp/__init__.py +3 -0
- netdiag_mcp-0.2.0/netdiag_mcp/__main__.py +48 -0
- netdiag_mcp-0.2.0/netdiag_mcp/server.py +133 -0
- netdiag_mcp-0.2.0/netdiag_mcp/tools.py +253 -0
- netdiag_mcp-0.2.0/netdiag_mcp/validate.py +47 -0
- netdiag_mcp-0.2.0/netdiag_mcp.egg-info/PKG-INFO +136 -0
- netdiag_mcp-0.2.0/netdiag_mcp.egg-info/SOURCES.txt +17 -0
- netdiag_mcp-0.2.0/netdiag_mcp.egg-info/dependency_links.txt +1 -0
- netdiag_mcp-0.2.0/netdiag_mcp.egg-info/entry_points.txt +2 -0
- netdiag_mcp-0.2.0/netdiag_mcp.egg-info/requires.txt +7 -0
- netdiag_mcp-0.2.0/netdiag_mcp.egg-info/top_level.txt +1 -0
- netdiag_mcp-0.2.0/pyproject.toml +61 -0
- netdiag_mcp-0.2.0/setup.cfg +4 -0
- netdiag_mcp-0.2.0/tests/test_server.py +57 -0
- netdiag_mcp-0.2.0/tests/test_tools.py +231 -0
- netdiag_mcp-0.2.0/tests/test_validate.py +41 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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,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
|