Unofficial / community project. This repository is an independent, community-driven project. It is not affiliated with, endorsed by, sponsored by, or supported by Hewlett Packard Enterprise or Juniper Networks. "HPE", "Juniper", "SRX", "JUNOS", "Security Director" and "Juniper Mist" are trademarks of their respective owners and are used here only to describe what this software interoperates with. Please direct support and licensing questions about those products to the respective vendors
A Model Context Protocol server for Juniper Junos devices, written in Rust. Drop-in compatible with Juniper/junos-mcp-server on the inventory format and tool surface, but built on async Rust (rustEZ + rustnetconf) instead of PyEZ.
Drop-in on devices.json and the core tools — plus a lot the Python/PyEZ server doesn't have:
- Safer config —
commit_check_config(validate, never commit), confirmed commits with auto-rollback, anddiscard_candidateto unstick a dirty candidate. - Device lifecycle — staged
upgrade_junos(image → install → reboot → verify), SCPtransfer_file/fetch_file, PFE commands. - Scale & UX — parallel session-pooled batch (~1.7× faster),
| last N/| count+max_lines/max_bytesoutput caps,router/router_namealiases, Jinja2 templates. - Transport & auth — streamable-HTTP with per-token router/tool scopes, TLS, and a
Hostallowlist; upstream is stdio-only. - SRX tools (
rust-srxmcp) — IDP & Application-ID signature-package updates (check/download/install/rollback), chassis-cluster health, license & security-services status, JTAC bundle with secret redaction.
Benchmarked against Juniper/junos-mcp-server (Python/PyEZ) on the same vSRX lab devices, same network path.
| Test | rust-junosmcp (v0.3.0) | junos-mcp (Python) | Speedup |
|---|---|---|---|
| 5 sequential commands | 30.4s (6.1s/cmd) | 52.2s (10.4s/cmd) | 1.7x |
| 5 parallel commands | 8.1s (1.6s/cmd) | 11.1s (2.2s/cmd) | 1.4x |
| 4 routers x 3 commands (batch) | 16.1s (1.3s/cmd) | N/A | Rust-only |
Session pooling (PooledDevice) eliminates SSH/NETCONF handshake overhead
on sequential commands to the same router. The batch tool runs routers in
parallel with a configurable concurrency cap.
Two new non-destructive candidate-safety tools and a hardened HTTP transport.
commit_check_configvalidates a candidate (commit check) and discards it without ever activating config;discard_candidaterecovers a candidate left dirty viarollback 0.junos_config_diffnow returns an actionable hint when the on-box config won't parse for the current mode. Security:rmcp0.8.5 → 2.0.0 (closes RUSTSEC-2026-0189 DNS-rebinding; adds aHostallowlist — off-loopback deployments must pass--allowed-host) andquick-xml0.36 → 0.41 (closes RUSTSEC-2026-0194/-0195 DoS). Tool surface 15 → 17.See the v0.7.0 release notes.
- 6 tools:
get_router_list,gather_device_facts,execute_junos_command,get_junos_config,junos_config_diff,load_and_commit_config. - stdio transport only.
devices.jsondrop-in compatible (auth.type∈ {password,ssh_key}).- Docker image (distroless) and LXC release tarball with systemd unit.
- streamable-http transport (with optional rustls TLS).
- bearer-token auth with per-token router/tool scopes.
- SIGHUP hot-reload of the token store.
execute_junos_pfe_command— single PFE-shell call against an explicit FPC target.execute_junos_command_batch— N routers x M operational CLI commands, parallel across routers, per-command and optional whole-batch timeouts. Pre-flight blocklist + unknown-router checks; continue-on-error after pre-flight.- New
pfe_commandsrule list under_blocklist_defaultsand per-deviceblocklist. Independent fromcommands.
render_and_apply_j2_template— render a Jinja2 template (inlinetemplate_content) with a JSONvars_contentobject. Supports single (router_name) or multiple routers (router_names), dry-run, and full commit. Reuses the same blocklist + format gating asload_and_commit_config.- Vars must be a top-level JSON object. YAML is no longer accepted as of v0.5.2 (RJMCP-SEC-002): the
serde_yml/libymladvisory chain (RUSTSEC-2025-0067/-0068) was reachable from MCP input, so the YAML branch was removed. - Size caps:
template_contentandvars_contentare each bounded at 64 KiB. - Strict-undefined: missing variables fail with the variable name rather than rendering empty.
- Auto-format detection: leading
<→xml, anyset/deleteline →set, otherwisetext. Override viaconfig_format. - Result shape: one row per router with
rendered_template,config_format, and eitherdiff(dry-run),commit_comment(apply-mode echo of the supplied comment — rustez does not return a server-issued commit id), orerror.
add_device— add a Junos device to the in-memory inventory and persist todevices.json. Atomic write (tempfile + rename), preserves_blocklist_defaults, per-deviceblocklist, and other top-level fields. SHA-256-based TOCTOU guard rejects calls that race with external edits.reload_devices— re-read the current--device-mapping(no args) or swap to a new inventory file (file_name). Reports added / removed / changed device names.- New CLI flags:
--inventory-readonly(rejects both tools unconditionally),--allow-password-auth-add(permitsauth.type=passwordinadd_device; mutually exclusive with--inventory-readonly). - SIGHUP now also re-reads the inventory in addition to the token store.
Documented sharp edge: add_device does not modify the token store. If a token has --routers 'edge-*' and you add_device for core-3, the existing token will not see the new router. Mint a new token or rotate scopes after add_device.
- NETCONF session pooling —
PooledDeviceRAII guard with per-router single-slot pool (300s idle timeout, 30s SSH keepalive, background reaper). Eliminates SSH handshake overhead on sequential commands. - Tool reliability fixes — XML wrapper stripping for
get_junos_configandjunos_config_diff, correctedshow configuration | compare rollback Ncommand, timeout now covers SSH connect + NETCONF handshake (not just CLI execution). - Batch partial results —
execute_junos_command_batchreturns inline error rows for unknown routers instead of aborting the entire batch. Blocklist violations remain strict. - Confirmed commits —
load_and_commit_configgainsconfirm_timeout_minsparameter forcommit confirmed Nwith auto-rollback safety net. - crates.io dependency —
rustezswitched from path dep to crates.io 0.10.1; CI no longer requires sibling repo checkout.
transfer_file— idempotent SCP push (scp -O, since Junos disables OpenSSH SFTP) of a host-staged file to/var/tmp/<basename>on a Junos device. Pre-flight free-space check on/var(local_size + 32 MiBheadroom), SHA-256 verify, post-transfer checksum re-validation with delete-on-mismatch. SSH-key auth only — password-auth devices rejected with[code=unsupported_auth].list_staged_files— lists host staging dir always, plus device/var/tmp/listing whenrouter_nameis supplied.- Stable error codes — every transfer failure carries an LLM-readable
[code=...]Display tag (bad_source_path,insufficient_disk,unsupported_auth,dest_exists_differs,scp_failed,connect_timeout,verify_mismatch,outer_timeout,device_probe_failed). - New CLI flags —
--staging-dir(default/var/lib/jmcp/staging) and--known-hosts-file(default/etc/jmcp/known_hosts). - Packaging —
install.shprovisions the new on-disk surface owned byjmcp:jmcp. See the File transfers section below for details. - Tool count: 11 → 13.
upgrade_junos— two-call (stage then confirm) Junos software upgrade. Uploads the package viatransfer_filesemantics, runsrequest system software add, and reboots. Standalone-only; rejected if a session pool entry exists for the target router.- Tool count: 13 → 14.
fetch_file— downloads a file from<device>:/var/tmp/<basename>to the host staging dir. SHA-256-verified, idempotent skip if the local copy already matches, per-router serialization. Mirror oftransfer_file.- Tool count: 14 → 15.
commit_check_config— validate a candidate config (commit check) without committing — loads, diffs, checks, then discards. Never activates config. Own token scope (least-privilege).discard_candidate— discard uncommitted candidate changes (rollback 0) to recover a candidate left dirty ("configuration database modified"). Never changes the running config. Own token scope (least-privilege).- Tool count: 15 → 17.
devices.json may carry an optional _blocklist_defaults block plus an
optional blocklist field on each device entry. Rules use simple globs
(*, ?) and an action of "deny" or "allow". Most-specific match
wins; per-device rules tiebreak top-level defaults. See
devices-template.json for an example, and
docs/superpowers/specs/2026-05-04-blocklist-guardrails-design.md
for the full design.
The pfe_commands rule list is independent: a deny on commands does not gate execute_junos_pfe_command and vice versa. Use it to restrict PFE inputs (e.g. set *) without affecting the operational CLI.
The blocklist applies to execute_junos_command and load_and_commit_config.
For load_and_commit_config, config_format must be set whenever the
device has any effective config rules; text and xml payloads are
rejected pre-flight in that case.
Compat note: files using
_blocklist_defaultsor per-deviceblocklistare not cross-compatible with Juniper/junos-mcp-server's inventory format. Files without these fields remain drop-in compatible.
load_and_commit_config supports Junos commit confirmed via the
confirm_timeout_mins parameter. The router auto-rolls back after N
minutes unless a follow-up commit confirms the change — a critical safety
net for remote config pushes that might break management connectivity.
{
"router_name": "core-1",
"config_text": "set interfaces ge-0/0/0 description test",
"confirm_timeout_mins": 10,
"commit_comment": "safe change with rollback window"
}Response:
{
"success": true,
"diff": "[edit interfaces ge-0/0/0]\n+ description test;",
"confirmed": true,
"rollback_in_minutes": 10,
"message": "Commit confirmed: auto-rollback in 10 minutes unless confirmed. Send another commit to confirm."
}To confirm (prevent rollback), send another load_and_commit_config with
the same config (or any valid config) without confirm_timeout_mins.
transfer_file pushes a host-staged file to /var/tmp/<basename> on a Junos
device using legacy SCP (scp -O, since Junos disables the OpenSSH SFTP
subsystem). It is idempotent on SHA-256: if the remote file already exists
with a matching digest the call returns status: "skipped". Pass force: true
to overwrite when digests differ.
fetch_file is the mirror operation: it downloads /var/tmp/<basename> from a
Junos device to the host staging dir using the same legacy SCP path. It is
idempotent on SHA-256 — if the local file already exists with a matching
digest the call returns status: "skipped". Per-router serialization and
post-transfer SHA-256 re-verification apply identically to transfer_file.
Auth: SSH key only. Devices with auth.type = "password" are rejected with
[code=unsupported_auth]. Add an SSH key to the device and reference its path
via auth.private_key_path in devices.json.
On-disk surface:
| Path | Purpose | Default mode | Owner |
|---|---|---|---|
/var/lib/jmcp/staging/ |
Host-side stage for files awaiting transfer | 0755 |
jmcp:jmcp |
/etc/jmcp/known_hosts |
SSH known_hosts consulted for every push |
0644 |
jmcp:jmcp |
Override at startup with --staging-dir <path> and --known-hosts-file <path>.
Host-key policy (v0.5.2+): scp runs with StrictHostKeyChecking=yes by
default — unknown device host keys are refused. The known_hosts file must
exist before the first transfer_file / upgrade_junos call, otherwise the
tool errors with [code=known_hosts_missing]. Pre-populate it with the
bundled helper:
scripts/scan-known-hosts.sh --inventory /etc/jmcp/devices.json \
--known-hosts /etc/jmcp/known_hostsFor lab / first-contact use, pass --ssh-accept-new-host-keys to fall back
to OpenSSH's accept-new (TOFU) mode.
list_staged_files returns the contents of the host staging dir. If
router_name is supplied it also runs file list /var/tmp/ detail on the
device and includes those entries under device_files.
Source path safety: source_path must be a basename only (no /, no \,
no .., no leading dot, ≤ 255 bytes); it is resolved relative to
--staging-dir and never escapes it.
Pre-flight checks: before scp, transfer_file runs
show system storage no-forwarding and refuses to push when free space on
/var is below local_size + 32 MiB.
Post-verify: unless verify: false is passed, the device-side checksum is
re-computed via file checksum sha-256 /var/tmp/<basename> and the file is
deleted on mismatch.
Each MCP tool exposes a per-call timeout parameter (default 360 s). This is
the sole user-visible bound on operation duration; the underlying
rustez::Device is configured with a 1-hour internal RPC timeout at
connection time, so commands that legitimately take many minutes
(request system software add, request support information,
request system snapshot, etc.) will not be silently truncated.
If you need to run an operation that exceeds 1 hour, split it into phases or invoke the work fire-and-forget on the device and poll for completion separately.
Caveat: when a long-running RPC is followed by a device reboot, the NETCONF session will of course die. The session pool reconnects cleanly on the next call.
This server lets an LLM run commands and push configuration changes against your Junos devices. Read Juniper/junos-mcp-server's security notice before deploying. The same warnings apply.
- Prefer SSH key authentication over passwords.
- Review configurations before allowing commit tools to run.
- Restrict network access to the MCP server.
- Don't deploy to untrusted networks.
- Set
devices.jsonpermissions to0600— it contains SSH credentials. get_junos_configreturns the full config including## SECRET-DATAhashed password lines. Restrict this tool's scope to trusted tokens.reload_devicesrequiresfile_nameto be a relative path resolving inside the original--device-mappingdirectory (since v0.5.2). Absolute paths,..traversal, and symlinks pointing outside the inventory directory are all rejected.- Text input fields (
command,config_text,template_content,pfe_command) are capped at 1 MB. Batch lists are capped at 100 routers and 50 commands.
git clone https://github.com/fastrevmd-lab/RustJunosMCP.git
cd RustJunosMCP
# Build (rustez pulled from crates.io automatically).
cargo build --release
# Configure devices.
cp devices-template.json devices.json
$EDITOR devices.json # set ip / username / auth
# Run as MCP stdio server.
./target/release/rust-junosmcp -f devices.json{
"mcpServers": {
"junos": {
"command": "/path/to/rust-junosmcp",
"args": ["-f", "/path/to/devices.json"]
}
}
}docker build -t rust-junosmcp:0.7 .
# Run.
docker run --rm -i \
-v $PWD/devices.json:/etc/jmcp/devices.json:ro \
-v $PWD/keys:/etc/jmcp/keys:ro \
rust-junosmcp:0.7# Build the tarball.
./scripts/package-lxc.sh
# Push and install on VM 115 (Debian 12 / Ubuntu 24.04 LXC).
pct push 115 dist/rust-junosmcp_0.7.0_amd64.tar.gz /tmp/jmcp.tar.gz
pct exec 115 -- bash -c "tar xzf /tmp/jmcp.tar.gz -C /tmp && /tmp/rust-junosmcp_0.7.0_amd64/install.sh"
# Edit /etc/jmcp/devices.json on the LXC, then:
pct exec 115 -- systemctl enable --now rust-junosmcpv0.1 caveat on the systemd unit: stdio doesn't suit a long-running daemon. The unit is shipped for forward-compat with v0.2's HTTP transport. For v0.1, the practical pattern is invoking the binary on demand from an MCP client running outside the LXC.
cargo run -- token add \
--tokens-file tokens.json \
--name ops \
--routers '*' \
--tools execute_junos_command,gather_device_factsNote: See
tokens-template.jsonfor the file shape. Usetoken addrather than editing the file by hand — the hash field must be a SHA-256 of the secret, not the plaintext.
Run token subcommands as the service user. When the systemd unit runs the server as a dedicated user (e.g.
User=jmcpin the packaged unit), the filetoken add/revoke/rotatewrites inherits the calling user's ownership. If you run them asroot, the resultingtokens.jsonwill beroot:root 0600and the service user cannot read it — the server then crash-loops on startup withPermission denied. Either:# Preferred: run subcommands as the service user. sudo -u jmcp rust-junosmcp token add --tokens-file /etc/jmcp/tokens.json ... # Or fix ownership after running as root. rust-junosmcp token add --tokens-file /etc/jmcp/tokens.json ... chown jmcp:jmcp /etc/jmcp/tokens.jsonIf the server hits this case on startup, the error message now reports the file's uid/mode and the caller's uid so the fix is obvious without trawling journald.
cargo run -- \
--device-mapping devices.json \
--transport streamable-http \
-H 127.0.0.1 \
-p 8765 \
--tokens-file tokens.jsoncargo run -- --device-mapping devices.json --transport streamable-http \
-H 127.0.0.1 -p 8765 --allow-no-auth--allow-no-auth is refused if the bind address is not loopback.
cargo run -- \
--device-mapping devices.json \
--transport streamable-http \
-H 0.0.0.0 \
-p 8765 \
--tokens-file tokens.json \
--tls-cert cert.pem \
--tls-key key.pemTo bind off-loopback over plain HTTP (e.g., behind a TLS-terminating proxy on
the same host), add --allow-insecure-bind. This flag overrides the TLS
requirement and should be used with care — only when you have an external
guarantee of transport security.
The streamable-http transport validates the incoming Host header against an
allowlist (default: loopback only — localhost, 127.0.0.1, ::1). This
closes RUSTSEC-2026-0189 (DNS rebinding). Off-loopback clients must be
allowlisted with --allowed-host <HOST> (repeatable) or they are rejected
with HTTP 403, regardless of auth state:
cargo run -- \
--device-mapping devices.json \
--transport streamable-http \
-H 0.0.0.0 \
-p 8765 \
--tokens-file tokens.json \
--tls-cert cert.pem \
--tls-key key.pem \
--allowed-host jmcp.lab.internal--disable-host-check turns the allowlist off entirely (accept any Host),
reintroducing the DNS-rebinding exposure; bearer auth still applies. Off by
default — only set this if you understand the tradeoff.
After revoking or rotating a token, the server reloads the token store without
restarting. Pass --server-pid <pid> to any write subcommand and the SIGHUP
is sent automatically after the file is written:
# Revoke — writes file, then signals the server.
cargo run -- token revoke --tokens-file tokens.json --name ops --server-pid <pid>
# Rotate (mints a new secret, preserves scopes) — same pattern.
cargo run -- token rotate --tokens-file tokens.json --name ops --server-pid <pid>
# Add a new token and signal in one step.
cargo run -- token add \
--tokens-file tokens.json \
--name ops2 \
--routers '*' \
--tools execute_junos_command,gather_device_facts \
--server-pid <pid>If you need to trigger a reload without a token change (e.g., after editing the file by hand), send SIGHUP directly:
kill -HUP <pid>| Flags | Bind address | Result |
|---|---|---|
| (none) | any | Refused — --tokens-file or --allow-no-auth required for streamable-http |
--allow-no-auth only |
non-loopback | Refused — --allow-no-auth is loopback-only |
--allow-no-auth only |
loopback | OK — but note: if you also supply --tls-cert/--tls-key, auth is still disabled; TLS gives confidentiality but any client that can reach the port has full tool access (foot-gun) |
--tokens-file only |
non-loopback, no TLS | Refused — add --tls-cert/--tls-key or --allow-insecure-bind |
--tokens-file --allow-insecure-bind |
non-loopback, no TLS | OK — tokens are checked; you are asserting external transport security |
--tokens-file --tls-cert cert.pem --tls-key key.pem |
any | OK |
Junos MCP server (Rust)
Usage: rust-junosmcp [OPTIONS] [COMMAND]
Commands:
token Manage the bearer-token store
help Print this message or the help of the given subcommand(s)
Options:
-f, --device-mapping <DEVICE_MAPPING>
JSON file with device mapping (Juniper junos-mcp-server compatible) [default: devices.json]
-t, --transport <TRANSPORT>
Transport [default: stdio] [possible values: stdio, streamable-http]
-H, --host <HOST>
Bind host (streamable-http only) [default: 127.0.0.1]
-p, --port <PORT>
Bind port (streamable-http only) [default: 30030]
--tokens-file <TOKENS_FILE>
Bearer-token file. Required for streamable-http unless --allow-no-auth
--tls-cert <TLS_CERT>
PEM-encoded TLS cert (streamable-http only). Pair with --tls-key
--tls-key <TLS_KEY>
PEM-encoded TLS key (streamable-http only). Pair with --tls-cert
--allow-no-auth
Disable bearer-token auth. Refuses to bind off-loopback
--allow-insecure-bind
Bind off-loopback over plain HTTP. Required for non-127.0.0.1 hosts when TLS is not configured
--inventory-readonly
Reject add_device and reload_devices unconditionally
--allow-password-auth-add
Permit add_device to accept auth.type=password (mutually exclusive
with --inventory-readonly)
--allowed-host <HOST>
Additional Host authorities to accept on the streamable-http
endpoint, beyond the loopback defaults (localhost, 127.0.0.1, ::1).
Repeatable
--disable-host-check
Disable the streamable-http Host allowlist entirely (accept any
Host). Off by default
-h, --help
Print help
-V, --version
Print version
JMCP_TEST_HOST=10.0.0.1 \
JMCP_TEST_USER=admin \
JMCP_TEST_PASS=secret \
cargo test -p rust-junosmcp-core --test integration_real_device -- --ignored --nocaptureDual-licensed under MIT or Apache-2.0.