scapy-mcp
scapy-mcp
MCP server wrapping scapy for packet crafting, dissection,
pcap staging, and live BPF capture. Tools operate on a worker that holds a
scapy_mcp.config.settings.ScapySettings instance and respect a four-stage
transmit policy before any frame leaves the host.
Version: 0.1.1 — BSD-3-Clause — Python ≥ 3.14 — alpha status
Install
uv pip install scapy-mcp
scapy-mcpThe console script scapy-mcp resolves to scapy_mcp.__main__:main, which
builds the FastMCP ASGI app and serves it on uvicorn (so the custom
/readyz route is mounted). Set SCAPY_MCP_HTTP_PORT to override the
default port 3056.
For local development:
uv sync --group dev
uv run scapy-mcpConfigure
Settings layer: defaults → settings/scapy-mcp.yaml → settings/local.yaml
→ SCAPY_MCP_* environment variables (pydantic-settings nested delimiter
is __). Nested config (e.g. auth) uses SCAPY_MCP_AUTH__ENABLED=true.
Setting | Default | Purpose |
|
| MCP HTTP listener port |
|
|
|
|
| oneiric LoggingConfig level |
|
| capture iface when caller omits one |
|
| per-session packet ceiling |
|
| per-session wall-clock ceiling |
|
| global BPF filter applied when caller omits one |
|
| ThreadPoolExecutor size for the sync sniff fallback |
|
| write-side staging root (path-containment enforced) |
|
| read-side ceiling; |
|
| master kill-switch — must be |
|
| L3 CIDR allow-list |
|
| allow L2 (ARP / ND / RAW) emission |
|
| allow broadcast destination |
|
| per-session packets-per-second cap |
|
| per-session total packet cap |
|
| per-call probe-target count cap |
Tools
The server registers 9 domain tools + the mcp-common baseline tools.
Group | Tool | Purpose |
craft |
| Build a |
dissect |
| Parse raw bytes into layers + summary text |
pcap |
| Read packet summaries from a pcap at |
pcap |
| Stage a pcap into |
capture |
| Start a BPF-filtered capture (optional feed) |
capture |
| Stop an in-progress capture session |
capture |
| Read summaries from an active capture session |
transmit |
| Send a single packet after every transmit control passes |
transmit |
| One-shot emit at a small BPF, used to confirm the surface works |
health |
| Per-feed aggregate status (counts + last error) |
Plus the four mcp-common baseline tools — discover_tools,
get_liveness, get_readiness, health_check_all — registered
unconditionally via bootstrap_baseline_tools.
read_pcap contract
read_pcap accepts an arbitrary path (a file produced by
write_pcap, a pre-existing capture, or a fixture) and returns
summaries. Before parsing, the file size is checked against
pcap_read_max_bytes; oversize files are refused with
ConfigurationError so multi-GB captures cannot exhaust memory by
being loaded whole. Path containment under pcap_write_dir is the
write path's concern, not read's.
Health
Two routes, answering different questions:
/health— always HTTP 200. Reports per-feed detail incomponents. For orchestrators andcurlsmoke probes./readyz— HTTP 503 when a required feed has not yet returned data, 200 otherwise. For readiness probes.
The capture feed is optional: its absence never flips /readyz
to 503. All other registered feeds are required.
Four transmit controls
Every transmit_packet call is checked against:
Master kill-switch (
transmit_enabled: bool, defaultfalse). The default refuses every frame — setSCAPY_MCP_TRANSMIT_ENABLED=trueto permit emission at all.L3 CIDR allow-list (
transmit_allow_l3_cidrs: list[str]). Set to["0.0.0.0/0"]for unrestricted L3; production deployments pin the specific CIDRs the worker is allowed to reach.L2 destination allow-flag (
transmit_allow_l2: bool, defaultfalse). Pure ARP / ND / RAW frames require this flag; L3 packets do not.Broadcast opt-in (
transmit_allow_broadcast: bool, defaultfalse). Even after the L2/L3 allow-list, broadcasts refuse unless this istrue.
A refusal emits an EmissionRefusedError with the failing control name and a
human-readable reason. The wrapper logs a scapy-write-would-refuse /
scapy-transmit-refused warning so the refusal is visible without polluting
the caller's error stream.
BPF probe (capture)
Capture is OPTIONAL. When /dev/bpf* is missing capture_start refuses
with CapabilityUnavailableError and the capture feed is marked
unavailable. /readyz stays 200 because capture is not a required feed.
Backend selection
The capture worker picks one of two backends at session start:
AsyncSniffer— preferred when scapy exposes it (≥ 2.5). Each session owns a sniffer;capture_stopcalls.stop()andcapture_readreads.results.sniff(stop_filter=...)— synchronous fallback executed in the module-level worker pool.stop_filterchecks a per-session cancel event socapture_stopworks;packet_capandduration_capare enforced viacountandtimeouton the underlying sniff call.
If scapy exposes neither backend, capture_start refuses with
CapabilityUnavailableError (reason = "no sniffer backend").
Deterministic fixtures
Tests do not open raw sockets. All packet construction is exercised
against in-memory fixtures under tests/fixtures/. Each fixture includes a
hand-crafted bytes() body that round-trips through craft_packet →
rdpcap/wrpcap → dissect_bytes. No real network frames in the suite.
Regenerate via:
python -m scripts.gen_pcap_fixturesDefault = closed-by-default transmit
The shipped defaults cannot emit a frame:
transmit_enabled = false
transmit_allow_l3_cidrs = []
transmit_allow_l2 = false
transmit_allow_broadcast = falseA worker installs only what its operator explicitly approves. See
scapy_mcp/config/settings.py for the full settings surface.
License
BSD-3-Clause.