An MCP (Model Context Protocol) server that exposes network scanning, HTTP and
SSH as tools to a model, with every request bounded by authentication, quotas
and target policy. It ships its own scan engine (zscan), so the fastest scan
path needs no external binary.
Repository root: /root/pentest-mcp. Workspace resolver 3, Rust edition 2024.
| Component | Path | What it is |
|---|---|---|
mcp-netscan |
src/, Cargo.toml |
The MCP server binary: Streamable HTTP transport, auth, quota chain, tool registry. |
zscan |
crates/zscan/ |
The scan engine library: AF_PACKET TPACKET_V3 rings, probe modules, permutation walk, rate control. |
zscan-cli |
crates/zscan-cli/ |
The zscan command line binary, plus migrate / user / quota operator subcommands. |
quota |
crates/quota/ |
Multi-dimensional quota engine: concurrency, cooldown, sliding windows, rate shaping, CPU share. |
store |
crates/store/ |
Two backends: MongoDB users/secrets/quota profiles plus Redis sessions and the cross-node invalidation bus, or a single JSON file (admin-store.json) for a standalone node. Legacy migration. |
admin |
crates/admin/ |
axum + askama web panel on its own listener: users, secrets, quota profiles, node status. Served by default; needs no external services. |
Tool list. Every built-in tool runs in-process in this binary; only ssh and
[[tools.custom]] entries start another program. The per-tool arguments live
in docs/TOOLS.md.
| Suite | Tools | Notes |
|---|---|---|
net_scan |
net_scan |
Built-in zscan engine and the only scan tool. TCP SYN/ACK/FIN/NULL/XMAS/Maimon/Window, UDP (DNS/mDNS/SNMP/NTP/SSDP/memcached) and ICMP probes, banners, version and OS hints. No external process, so it streams progress and findings. |
| dns | dns_query, dns_bruteforce, dns_zone_transfer, subdomain_permute |
Resolver queries, wildcard-aware bulk resolution, AXFR, permutation generation. |
| service | redis_check, smtp_enum, ftp_probe, ssh_audit, tls_scan, ike_scan |
Service probes. redis_check is read-only; smtp_enum and ftp_probe are bounded probes, not crackers. |
| web_recon | http_probe, web_crawl, web_tech, waf_detect, security_headers, cors_check |
HTTP probing, crawling, fingerprinting, WAF and security-header checks. |
| web_audit | web_fuzz, param_discover, js_endpoints, xss_scan, sqli_scan, ssti_scan, open_redirect, jwt_inspect, cms_detect, graphql_probe |
Actively probe the target, so each is marked destructive and capped per call. |
| discovery | ping_sweep, nbtscan, snmp_check, snmp_walk, packet_send |
Host and service discovery. packet_send is the raw AF_PACKET sender. |
| secrets | secret_scan, cewl |
Credential/high-entropy scanning and site wordlists. |
| data | hash_identify, dedup_lines, json_flatten |
Local pipeline helpers; no network. |
| standalone | http_request, ssh |
In-process HTTP client with SSRF guards; ssh runs the system ssh client. |
[[tools.custom]] |
operator-declared binary | Config-only plugin tools using the same quota/timeout machinery. |
cd /root/pentest-mcp
cargo build --releaseProducts: target/release/mcp-netscan, target/release/zscan.
The server reads config.toml from the working directory, or the path in the
MCP_CONFIG environment variable (src/config.rs:833-838).
Change at least the credential before starting. In config.toml:
[[auth.secrets]]
name = "default"
token = "CHANGE-ME-default-token"
enabled = true
[node]
iface = "eth0"
# router_mac = "d8:b1:22:68:e1:32"[node] holds node-local NIC settings. They stay in TOML even when users and
quotas live in MongoDB.
MCP_CONFIG=/root/pentest-mcp/config.toml ./target/release/mcp-netscanThe MCP endpoint is served at server.path (default /mcp).
Health and readiness are GET /healthz and GET /readyz; both are open and
report status, version, the enabled tool names, and the configured job limits
(src/main.rs:407-424).
Point a client at it:
curl -s http://127.0.0.1:8080/mcp \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-H 'mcp-protocol-version: 2025-06-18' \
-H 'authorization: Bearer CHANGE-ME-default-token' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'zscan needs raw sockets:
sudo target/release/zscan 10.0.0.0/24 -p 22,80,443 -i eth0 \
--router-mac <gw-mac> --adapter-ip <our-ip> \
--max-rate 10000 --wait 4 --tx-workers 4 \
--output-format json --output-file hits.jsonlEngine probe modules come from --probe-module:
tcp-synscan (default), tcp-ackscan, tcp-finscan, icmp-echoscan.
Native scanner; IPv4 only. Key arguments (src/tools/net_scan.rs:29-231):
| Argument | Meaning |
|---|---|
targets |
IPs, CIDR blocks, ranges (10.0.0.1-20) or hostnames. |
ports |
80, 22,80,443, 1-1024, U:53, T:80,U:53. |
probe |
tcp_syn (default), tcp_ack, tcp_fin, tcp_null, tcp_xmas, tcp_maimon, tcp_window; udp_dns, udp_mdns, udp_snmp, udp_ntp, udp_ssdp, udp_memcached, udp_empty; icmp_echo, icmp_timestamp, icmp_address_mask; ip_proto. |
dnsName |
UDP payload name for udp_dns. |
numProbes |
Probe copies per host/port. |
ratePps |
Requested packet rate; clamped by every quota layer. |
shard |
"n/N" slice for distributed scanning. |
exclude |
CIDR list of addresses to skip. |
banners |
Complete TCP handshakes and read service banners. |
bannerHello |
Bytes pushed after connect, e.g. GET / HTTP/1.0\r\n\r\n. |
seed |
Traversal-order seed, deterministic across shards. |
The effective packet rate is the minimum of the tool ceiling
([tools.net_scan].max_rate_pps), [node].max_pps, the caller's TOML quota,
the cluster profile and ratePps (src/tools/net_scan.rs:528-553); the
byte-rate chain adds [node].max_bytes_per_sec
(src/tools/net_scan.rs:554-563). Findings are capped by
[tools.net_scan].max_findings (src/tools/net_scan.rs:899).
The argument struct is larger than this table: the engine also accepts the
nmap/zmap/masscan-compatible port-selection, pacing, service/version, output and
file-input flags (src/tools/net_scan.rs:64-231). randomizeHosts is accepted
for compatibility only — the engine already walks targets in seed-derived
pseudo-random order (src/tools/net_scan.rs:208-214).
Any method from allowed_methods, arbitrary headers, body, query parameters,
redirect control. Guards: deny_metadata_endpoints blocks 169.254.169.254,
100.100.100.200 and fd00:ec2::254; validate_resolved_addresses checks
every resolved A/AAAA record before connecting
(src/tools/http.rs:341-395, src/targets.rs:395).
Host or argv command, stdin, key/agent/password auth, allowed_hosts globs
and allowed_cidrs checks, host_key_checking policy. Passwords go through an
ephemeral SSH_ASKPASS helper and never appear in argv
(src/tools/ssh.rs:1-8).
All three share --mongo-uri (env ZSCAN_MONGO_URI), --mongo-db
(default zscan) and --redis-uri (env ZSCAN_REDIS_URI).
Imports legacy config.toml [[auth.secrets]] entries into MongoDB.
Idempotent; already-migrated names are skipped.
zscan migrate --config config.toml \
--mongo-uri mongodb://127.0.0.1:27017 \
--redis-uri redis://127.0.0.1:6379Output: imported secret '<name>' (token + quota profile) per secret, then
<N> secret(s) imported; nothing to import (already migrated) when there is
nothing to do (crates/zscan-cli/src/main.rs:705-712).
zscan user add alice --password-stdin --role admin \
--mongo-uri mongodb://127.0.0.1:27017 --redis-uri redis://127.0.0.1:6379
zscan user list --mongo-uri mongodb://127.0.0.1:27017 --redis-uri redis://127.0.0.1:6379--role is one of admin, operator, viewer (default viewer). This
subcommand requires a password of at least 10 characters
(crates/zscan-cli/src/main.rs:729-730); the hash is argon2id everywhere
(crates/store/src/users.rs:47-61). add fails when the name is taken
(Mongo: crates/store/src/users.rs:100-105; file backend:
crates/store/src/local.rs:204-206).
zscan quota set alice --concurrency 4 --rpm 60 --rpd 500 --max-pps 10000 \
--mongo-uri mongodb://127.0.0.1:27017 --redis-uri redis://127.0.0.1:6379
zscan quota list --mongo-uri mongodb://127.0.0.1:27017 --redis-uri redis://127.0.0.1:6379Flags: --concurrency (default 4), --cooldown-secs, --priority, --rpm,
--rph, --rpd, --rpw, --max-pps, --cpu-pct. Zero means "dimension
unset". A profile with subject alice covers every tool; subject
alice:net_scan overrides it for one tool (src/server.rs:152-158).
The panel is served by default ([store].admin_enabled = true,
src/config.rs:88) on its own listener, so its cookies never mix with MCP
transport auth. It listens on [store].admin_bind (default 127.0.0.1:9100,
src/config.rs:89). Keep it on loopback and terminate TLS in front of it.
It needs no external services. Without [store].mongo_uri the server opens
a file-backed store at [store].local_path (default admin-store.json,
src/config.rs:66, src/config.rs:90). That file holds users (argon2id
password hashes), secrets (argon2 hashes) and quota profiles; it is written
atomically with 0600 permissions and is gitignored
(crates/store/src/local.rs:1-17, opened by connect_shared_state at
src/main.rs:225-243). Sessions live in memory only and the invalidation bus
is a no-op, because there is no other node to tell. Point [store].mongo_uri
and [store].redis_uri at real services for the multi-node backend
(crates/store/src/lib.rs:36-57).
- With no users,
GET /admin/loginshows the first-run form (crates/admin/templates/login.html). POST /admin/bootstrapwithusernameandpasswordcreates the first account with roleadminand redirects to/admin/login.- Once any user exists, the same POST returns
403 bootstrap closed: users already exist(crates/admin/src/handlers.rs:38-44). There is no re-open path in code.
curl -i -X POST http://127.0.0.1:9100/admin/bootstrap \
-d 'username=admin&password=<strong-password>'Roles: admin may manage users and quota profiles; any signed-in user may see
the dashboard and their own secrets and mint new ones
(crates/admin/src/handlers.rs:27-33, crates/admin/src/handlers.rs:123-133).
config.toml is parsed into Config with deny_unknown_fields, so a typo is a
startup error. Config::validate rejects nonsensical combinations and the
process exits with code 2 on a configuration error (src/main.rs:37-43).
| Key | Default | Effect |
|---|---|---|
host, port |
0.0.0.0, 8080 |
MCP listener. |
path |
/mcp |
Mount path, normalized to a leading slash and no trailing slash. |
public_base_url |
unset | Informational, for logs. |
allowed_hosts |
localhost set | Host header allow-list (DNS-rebinding guard). Empty disables the check. |
allowed_origins |
[] |
Origin allow-list. Empty disables the check. |
session_mode |
false |
false is stateless, best behind a load balancer. |
json_response |
true |
JSON instead of SSE for simple calls. |
max_request_body_bytes |
1 MiB | Body ceiling, checked on every route. |
sse_keep_alive_secs, tcp_keepalive_secs |
15, 60 | Keepalive tuning. |
graceful_shutdown_secs |
20 | Configured but not read by the runtime (see "Known gaps"). |
| Key | Default | Effect |
|---|---|---|
enabled |
true |
When false every caller is anonymous. |
header |
authorization |
Header inspected. authorization strips a Bearer prefix. |
require_secret |
true |
Reject unknown credentials. |
max_args_bytes |
65536 | Configured but not enforced anywhere (see "Known gaps"). |
[[auth.secrets]] |
one example | Credential list, each with name, token, allow_tools, deny_tools, enabled, [auth.secrets.quota]. |
Global ceilings applied to every job regardless of credential:
max_concurrent_jobs (8), max_job_runtime_secs (900), max_output_bytes
(2 MiB), max_stream_bytes (2 MiB), default_requests_per_minute (60,
configured but unused), rate_limit_penalty_secs (60), max_tool_call_secs
(900), job_retention_secs (3600, configured but unused).
Node-local scan settings: iface, router_mac, adapter_mac, adapter_ip,
max_pps, max_bytes_per_sec. net_scan and the discovery suite clamp their
packet rate to max_pps and net_scan clamps its byte rate to
max_bytes_per_sec (src/tools/net_scan.rs:535-563,
src/tools/discovery_suite.rs:465-484). adapter_mac is still declared but
not read by any tool (see "Known gaps").
mongo_uri, mongo_db (default zscan), redis_uri, cookie_key
(128 hex characters), session_ttl_secs (86400), admin_bind,
cluster_queue_wait_secs (30), admin_enabled (true) and local_path
(default admin-store.json) (src/config.rs:51-93). Leaving the two URIs
unset keeps the node standalone: TOML credentials for MCP calls, and a
file-backed store for the admin panel and cluster quota profiles (no cross-node
sync; src/main.rs:225-243, src/main.rs:112-125).
One section per suite or standalone tool: [tools.net_scan],
[tools.http_request], [tools.ssh], [tools.data], [tools.dns],
[tools.secrets], [tools.service], [tools.web_recon], [tools.web_audit]
and [tools.discovery], plus [[tools.custom]] entries. net_scan,
http_request and ssh each have their own schema; the other suites carry a
[tools.<suite>.limits] envelope with enabled, timeout_secs,
max_output_bytes, max_concurrency, max_targets and
max_requests_per_sec (0 = no cap) (src/tools/native.rs:27-44). A limit can
only tighten a call: an oversized target list is refused before any traffic,
concurrency is clamped, and max_requests_per_sec only paces
(src/tools/native.rs:59-89). Each suite may add keys under
[tools.<suite>], for example [tools.service.smtp] or
[tools.web_audit].max_requests_per_call.
Add a config-only tool without recompiling:
[[tools.custom]]
enabled = true
name = "dns_lookup"
description = "Resolve a hostname with dig."
binary = "/usr/bin/dig"
args = ["+short", "{{name}}"]
timeout_secs = 10
max_output_bytes = 65536
allowed_values = ["name:example.com", "name:example.org"]| File | Contents |
|---|---|
docs/README.md |
This file: what the project is, quickstart, tools, config. |
docs/TOOLS.md |
Per-tool reference: arguments, limits and examples. |
docs/ARCHITECTURE.md |
Module map, MCP request flow, engine internals, quota layers. |
docs/MIGRATION.md |
Moving from shell-backed scanners to net_scan; TOML secrets to MongoDB. |
docs/THREAT_MODEL.md |
Assets, actors, trust boundaries, abuse paths, known gaps. |
docs/PERFORMANCE.md |
Measured zscan vs zmap vs masscan numbers and method. |
These are real and visible in code; see docs/THREAT_MODEL.md for the security
consequences.
auth.max_args_bytes(src/config.rs:241),limits.default_requests_per_minute(src/config.rs:202),limits.job_retention_secs(src/config.rs:208),server.graceful_shutdown_secs(src/config.rs:115),tools.ssh.max_concurrent_sessions(src/config.rs:674) andtools.ssh.strict_host_key_checking_known_hosts(src/config.rs:666) are parsed but never read outsidesrc/config.rs.[node].adapter_macis declared but not read by any tool (src/config.rs:40).[node].max_ppsis read now:net_scanand the discovery suite clamp their packet rate to it (src/tools/net_scan.rs:537,src/tools/discovery_suite.rs:467), andnet_scanclamps its byte rate to[node].max_bytes_per_sec(src/tools/net_scan.rs:555).tools.net_scan.max_port_counthas no effect: the tool passes0(src/tools/net_scan.rs:435).- The MCP authenticator only compares against
[[auth.secrets]]in TOML. MongoDB-backed tokens authenticate throughStore::authenticate_tokenbut the MCP data path never calls it (src/auth.rs:123-171).