rust-junosmcp

io.github.mechubsecv0.27.4Updated Oct 7, 2026

Juniper Junos/SRX MCP server: scoped, audited, two-person change control. Rust.

Overview

AI-generated overview

Lets an assistant run audited, scope-limited operational commands and configuration changes on Juniper Junos and SRX network devices.

What it does
Exposes tools to list routers, gather device facts, run operational and PFE commands (single or batched across routers), read and diff configurations, render and apply Jinja2 templates, load and commit configuration, run commit checks, discard candidates, roll back, transfer and fetch files, upgrade Junos, and query SRX policies, address books, applications, and NAT rules. Access is governed by per-token router and tool scopes, command allowlists or blocklists, and audit records; write tools are excluded from wildcard scopes. A change-set flow requires a second principal to approve high-blast-radius operations.
When to use it
Use it when an assistant should inspect or operate Juniper Junos/SRX estate through MCP with least-privilege scoping and an audit trail, for example routine operational queries, configuration review, staged template rollout, or SRX policy inspection. It is aimed at controlled network operations rather than ad-hoc device access.
Requirements
Runs locally as a stdio process (or streamable-http with optional TLS); a Rust build from source or the published container image. Needs a devices.json inventory with device addresses, usernames, and SSH credentials or key paths, plus a tokens.json token store at mode 0600. Docker deployments need read-only inventory/key mounts and a writable state directory owned by UID/GID 65532. Network access to the managed devices is required.
Before you install
This server lets a model run commands and push configuration changes to production network devices, including commits, rollbacks, file transfers, and software upgrades with reboots. devices.json holds SSH credentials and private-key paths and must be mode 0600; tokens.json must also be 0600. Config and command output is redacted best-effort only, and enabling show configuration can expose the full running configuration including reversibly encrypted secret data. Prefer SSH keys over passwords…

Installation

In SourceWeft

  1. Open rust-junosmcp in the dashboard and add it to a workspace.
  2. Enable the server for the chats that should use its tools.

Desktop only via STDIO. STDIO servers start a local process, so they need the SourceWeft desktop host.

Other MCP clients

Follow the launch instructions in the repository.

README

[mechub mark]

rust-junosmcp

One MCP server for Juniper Junos and SRX devices, in Rust
a mechub project — sovereign network-security automation

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 and SRX devices, written in Rust. The single rust-junosmcp process exposes the core Junos tools and, by default, the SRX security workflows through one tool registry and endpoint. It is drop-in compatible with Juniper/junos-mcp-server on the inventory format and core tool surface, but built on async Rust (rustEZ + rustnetconf) instead of PyEZ.

Beyond Juniper/junos-mcp-server

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, and discard_candidate to unstick a dirty candidate.
  • Device lifecycle — staged upgrade_junos (image → install → reboot → verify), SCP transfer_file/fetch_file, PFE commands.
  • Scale & UX — parallel session-pooled batch (~1.7× faster), | last N/| count + max_lines/max_bytes output caps, router/router_name aliases, Jinja2 templates.
  • Transport & auth — streamable-HTTP with per-token router/tool scopes, TLS, and a Host allowlist; upstream is stdio-only.
  • SRX tools (enabled by the default srx feature) — IDP & Application-ID signature-package updates (check/download/install/rollback), chassis-cluster health, license & security-services status, JTAC bundle with secret redaction, and read-only security-policy / address-book / application / NAT-rule inspection.

Performance

Benchmarked on 2026-10-05 against Juniper/junos-mcp-server 1.1.1 and shigechika/junos-mcp 0.18.0 / 0.22.0 with the same read-only workload on a vSRX lab device (Junos 26.2R1.7), 3 runs × 30 calls per operation, 0 failed calls. Medians across runs:

rust-junosmcp 0.27.2Juniper 1.1.1shigechika 0.22.0
Cold start6 ms264 ms392 ms
Peak memory (RSS)20 MiB129 MiB98 MiB
SSH connections per run11211
show version, p50216 ms878 ms228 ms
config RPC (access-denied reply¹), p5011 ms776 ms21 ms

¹ the read-only bench login has no configuration view, so this measures a round trip with a ~400-byte reply, not a full config fetch. See docs/BENCHMARKS.md.

Full results, the mock-target overhead numbers, method and reproduction steps: docs/BENCHMARKS.md.

v0.10.0 released — read before upgrading

Two breaking authorization changes, both requiring operator action: a wildcard tool scope ("tools": ["*"]) no longer confers the 15 write-capable tools, and tokens.json must be mode 0600 or the server refuses to start. The new token set-scope command changes a token's scopes without reissuing its secret, so you can narrow scopes on the running 0.9.x server before swapping the binary. See Upgrading to v0.10 for the procedure.

Security: the auth stack is now unsafe-free — rust-junosmcp-auth consumes the shared mecmcp-auth crate, which replaces hand-rolled secret zeroing with zeroize and libc::getuid with rustix. A malformed token entry also no longer takes the whole store offline at load.

Tool surface is unchanged at 27 tools (18 Junos-only with --no-default-features). See the v0.10.0 release notes.

Feature scope

v0.1 (released)

  • 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.json drop-in compatible (auth.type ∈ {password, ssh_key}).
  • Docker image (distroless) and LXC release tarball with systemd unit.

v0.2 (released)

  • streamable-http transport (with optional rustls TLS).
  • bearer-token auth with per-token router/tool scopes.
  • SIGHUP hot-reload of the token store.

v0.2 follow-up: PFE + batch (released)

  • 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_commands rule list under _blocklist_defaults and per-device blocklist. Independent from commands.

v0.2 follow-up: Templates (released)

  • render_and_apply_j2_template — render a Jinja2 template (inline template_content) with a JSON vars_content object. Supports single (router_name) or multiple routers (router_names), dry-run, and full commit. Reuses the same blocklist + format gating as load_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 / libyml advisory chain (RUSTSEC-2025-0067/-0068) was reachable from MCP input, so the YAML branch was removed.
  • Size caps: template_content and vars_content are each bounded at 64 KiB.
  • Strict-undefined: missing variables fail with the variable name rather than rendering empty.
  • Auto-format detection: leading < → xml, any set / delete line → set, otherwise text. Override via config_format.
  • Result shape: one row per router with rendered_template, config_format, and either diff (dry-run), commit_comment (apply-mode echo of the supplied comment — rustez does not return a server-issued commit id), or error.

v0.2 follow-up: Inventory mutation (released)

  • add_device — add a Junos device to the in-memory inventory and persist to devices.json. Atomic write (tempfile + rename), preserves _blocklist_defaults, per-device blocklist, 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 (permits auth.type=password in add_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.

v0.3 (released)

  • NETCONF session pooling — PooledDevice RAII 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_config and junos_config_diff, corrected show configuration | compare rollback N command, timeout now covers SSH connect + NETCONF handshake (not just CLI execution).
  • Batch partial results — execute_junos_command_batch returns inline error rows for unknown or unreachable routers instead of aborting the entire batch. Blocklist violations remain strict.
  • Batch scope violations are not partial — if any router in the request is outside the token's scope, the call is refused with HTTP 403 insufficient_scope and no router executes. This differs from the unreachable case above on purpose: a device being down is a runtime failure, whereas naming a device the caller may not touch is an authorization failure, and no part of an unauthorized request is honoured. Split the request or widen the token.
  • Confirmed commits — load_and_commit_config gains confirm_timeout_mins parameter for commit confirmed N with auto-rollback safety net.
  • crates.io dependency — rustez switched from path dep to crates.io 0.10.1; CI no longer requires sibling repo checkout.

v0.4 (released)

  • 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 MiB headroom), 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 when router_name is 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, host_key_mismatch, host_key_revoked, 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.sh provisions the new on-disk surface owned by jmcp:jmcp. See the File transfers section below for details.
  • Tool count: 11 → 13.

v0.5 (released)

  • upgrade_junos — two-call (stage then confirm) Junos software upgrade. Uploads the package via transfer_file semantics, runs request system software add, and reboots. Standalone-only; rejected if a session pool entry exists for the target router.
  • Tool count: 13 → 14.

v0.6 (released)

  • 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 of transfer_file.
  • Tool count: 14 → 15.

v0.7 (released)

  • 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.

v0.8 (released)

  • Unified Junos/SRX server — one binary, endpoint, process, inventory, device manager, lease manager, auth surface, and SIGHUP reload path. Default builds expose 26 tools; --no-default-features retains the 17-tool Junos-only surface.
  • Operational controls — per-router concurrency, per-token session and RPS limits, strict global session admission, bounded Prometheus metrics, and optional native journald audit fan-out.
  • Security and packaging — scoped router-list results, cross-process destructive-operation leases, container SCP support, non-root image runtime, process healthchecks, and package upgrade coverage.
  • Split-service migration — upgrades remove rust-srxmcp, its systemd unit, and port 30032 while preserving support bundles. Deprecated JMCP_SRX_* aliases are accepted for v0.8.0 only.

v0.9 (released)

  • rollback_config tool — load a Junos rollback archive (rollback N, 0–49) into the candidate and preview (default) or commit it, with confirmed-commit support. Tool count: 26 → 27 (17 → 18 Junos-only).
  • Correct pipe filtering — | match / | except are applied server-side; the <command> RPC silently dropped them, so filtered config queries had returned the full config (a silent audit false negative).
  • Safer config verbs — junos_config_diff accepts rollback 0 (candidate vs running); commit_check_config reports a three-way outcome (valid / invalid / check_failed) so an inconclusive multi-RE cluster check is never read as an invalid config; discard_candidate recovers a dirty candidate lock-free.
  • Clearer diagnostics — SRX services-status reports a failed health-check RPC as error, not not_configured; router-resolution failures log whether a name is unknown or out-of-scope (client response unchanged).
  • Security — SSH transport off prerelease RustCrypto (russh 0.62; -rc crypto crates 13 → 3).

v0.10 (released)

  • Wildcard tool scopes exclude write tools (breaking) — "tools": ["*"] reaches every read-only tool but none of the 15 write-capable tools; granting write authority is now always an explicit, named decision. See Tool scopes and write tools.
  • tokens.json must be mode 0600 (breaking) — the server refuses to start on a group- or world-accessible token file and names the owner uid, mode, caller uid, and the chmod to run.
  • token set-scope — rescope a token without reissuing its secret, so scopes can be narrowed ahead of a binary upgrade without a client outage.
  • Resilient token load — scope names are validated on write rather than on load, so one stale entry no longer takes the whole store offline.
  • Security — rust-junosmcp-auth is now a thin re-export of the shared mecmcp-auth crate and contains no unsafe; zeroize replaces hand-rolled secret zeroing and rustix replaces libc::getuid. Tool count unchanged (27 / 18).

v0.11 (released)

  • Four new read-only SRX tools — srx_list_policies (security policies by from-zone/to-zone context, including global policies and optional per-policy hit counts), srx_resolve_address and srx_resolve_application (address-book and application/application-set resolution, including junos-* predefined defaults, with recursive nested-set resolution and explicit reference-cycle rejection — never a loop or a silent partial answer), and srx_list_nat_rules (source, destination, and static NAT rules, each independently). Address-book and application resolution are configuration-sourced via a hand-built subtree-filtered get-configuration RPC, since rustez's call() only supports flat key/value args. Names on policies and NAT rules (addresses, applications) are returned unresolved on purpose — srx_resolve_address / srx_resolve_application are a separate, explicit step. All four are paginated/capped with an explicit truncated flag rather than a silent cutoff. Tool count: 37 → 41 (9 → 13 SRX tools; Junos-only build unchanged at 27 / 18, since these are gated by the default srx feature).

v0.12 (unreleased)

  • Commit-confirmed is on by default (breaking) — load_and_commit_config, rollback_config (commit=true), render_and_apply_j2_template, and apply_junos_change_set now issue commit confirmed unless the caller explicitly opts out with confirm_timeout_mins: 0. Previously a plain, unconditional commit was the default; a model-drafted change that cut management access had nothing bringing the device back. See Confirmed commits.
  • --commit-confirm-default-mins (default 10) sets the server-wide window used when a call omits confirm_timeout_mins; a per-call value still overrides it.
  • confirm_commit — new write tool that sends the confirming commit for any of the four paths above, cancelling the pending auto-rollback.
  • Opting out is recorded in the audit event as commit_confirmed=false.
  • Tool count: 43 → 44 (27 → 28 Junos-only).

Blocklist guardrails (v0.2)

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_defaults or per-device blocklist are not cross-compatible with Juniper/junos-mcp-server's inventory format. Files without these fields remain drop-in compatible.

execute_junos_command authorization mode: allowlist (default) vs. blocklist

_blocklist_defaults (and, going forward, this key only — see the per-device restriction below) may carry a mode of "allowlist" or "blocklist". This governs execute_junos_command, execute_junos_command_batch, execute_junos_pfe_command, and also get_junos_config (it runs show configuration [path] [| display <format>] through the same commands allowlist/blocklist — see "Config output format and load mode" below); the config domain used by load_and_commit_config stays a fail-open blocklist regardless of mode.

  • allowlist (fail-closed, the default for new configs) — a command is denied unless it matches a literal, whitespace-tokenized prefix in allow (globs are rejected at load time, not just ignored). Each stage after a | in the command must independently match a prefix in allowed_pipes, or the whole command is refused; forbidden shell metacharacters (;, redirects, backticks, newlines) are refused outright, before any prefix match. allow/allowed_pipes merge the same way commands deny rules do today: _blocklist_defaults ∪ the device's own list. A per-device allow addition never leaks to other devices — each device gets its own compiled allowlist policy.

    execute_junos_pfe_command is gated by its own, independent pair of keys: pfe_allow/pfe_allowed_pipes. They merge the same way as allow/allowed_pipes (defaults ∪ device), but an entry in allow does not allowlist anything for PFE commands and vice versa — the two domains never share state. A config that sets allow but not pfe_allow still refuses every PFE command under allowlist mode — that is fail-closed by default for an unconfigured domain, not a bug; add a pfe_allow list if you need execute_junos_pfe_command.

  • blocklist (fail-open, legacy) — the pre-MEC-93 behavior: a command is denied only if it matches a commands (or pfe_commands) deny glob; everything else is allowed.

Allowed characters (MEC-1337). In allowlist mode, a command may only use printable ASCII characters with the literal ASCII space (U+0020) as the token separator. Any other character is refused outright (forbidden_metachar), the same as the existing ;/redirect/backtick/ newline check.

Migration: a devices.json with commands/pfe_commands deny rules but no mode key loads as blocklist and logs one startup WARN that blocklist mode is fail-open, with a pointer back to this section. A file with no _blocklist_defaults at all, or a freshly generated sample config, loads as allowlist. mode is only valid on _blocklist_defaults — setting it on a per-device blocklist is a load-time error, since the underlying policy engine picks one command mode for the whole file and a per-device override would silently do nothing.

Every refusal — allowlist or blocklist — writes an audit record via the existing audit path, tagged with a stable reason code (not_allowlisted, pipe_not_allowlisted, forbidden_metachar, or the legacy blocked).

A minimal read-only starter allowlist:

json
"_blocklist_defaults": {    "mode": "allowlist",    "allow": [        "show version",        "show interfaces",        "show route",        "show security policies",        "show chassis"    ],    "pfe_allow": [        "show cos"    ]}

Config output format and load mode

get_junos_config takes an optional format: text (default, unchanged), set, xml, or json. Each is rendered device-side via the matching Junos | display <format> CLI modifier, so output goes through the same policy check, blocklist, and output-cap pipeline as text today — there is no separate code path for the new formats.

json
{ "router_name": "core-1", "config_path": "system services", "format": "set" }

Under mode: allowlist, get_junos_config is governed by the commands allowlist, not a separate rule. The rendered command is show configuration [config_path] [| display set|xml|json], checked the same way as any other execute_junos_command input: show configuration must be a prefix in allow, and — if a format other than the default text is requested — display set/display xml/display json must be a prefix in allowed_pipes. The starter allowlist in devices-template.json does not include show configuration, so copying it as-is refuses get_junos_config entirely (fails closed, so it is safe, but easy to miss). Add "show configuration" to allow (and the display variants you need to allowed_pipes) to enable it — and note that doing so exposes the full running configuration, including hashed secrets, to the model.

load_and_commit_config, render_and_apply_j2_template, and create_junos_change_set's per-action payload all take an optional mode: merge (default, unchanged), replace, or override.

override replaces the entire candidate configuration — the highest blast-radius operation this server exposes. load_and_commit_config and render_and_apply_j2_template commit directly in the same call with no second-principal review, so they refuse override outright. It is only available through the change-set flow (create_junos_change_set → approve_junos_change_set → apply_junos_change_set), which requires a second principal to approve the plan before anything commits.

config_format=set (a configuration-set, i.e. a list of set/delete commands) has no wire-level override action in Junos — that combination is rejected before any RPC is sent, on every path, including change sets.

Confirmed commits (v0.3, default-on since v0.12)

load_and_commit_config, rollback_config (with commit=true), render_and_apply_j2_template, and apply_junos_change_set all commit via Junos commit confirmed by default — the router auto-rolls back if nothing confirms the change within the window, so a bad push that cuts management access reverts itself instead of requiring a truck roll.

The default window is the server's --commit-confirm-default-mins flag (default 10, same validation as the per-call parameter). A per-call confirm_timeout_mins overrides it for that one commit:

json
{  "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:

json
{  "success": true,  "diff": "[edit interfaces ge-0/0/0]\n+   description test;",  "confirmed": true,  "rollback_in_minutes": 10,  "rollback_deadline_unix": 1780000600,  "message": "Commit confirmed: auto-rollback in 10 minutes unless confirmed. Send another commit to confirm."}

apply_junos_change_set's status response reports the same rollback_deadline_unix.

Opt out of commit-confirmed for a single call by passing confirm_timeout_mins: 0; this issues a plain, unconditional commit and is recorded in the audit event as commit_confirmed=false so the exception is traceable after the fact.

To confirm a pending window (cancel the scheduled auto-rollback), call the confirm_commit tool with the device name — it sends the confirming commit the same way confirm_junos_change_set does for the change-set path. Sending another load_and_commit_config (or rollback_config / render_and_apply_j2_template) also confirms, since Junos treats any commit against the candidate as confirmation regardless of which tool issued it. While a commit-confirmed window is open, upgrade_junos refuses to proceed (commit_confirmed_active) rather than reboot a device that might still roll back its configuration underneath the new image.

File transfers (transfer_file / fetch_file / list_staged_files)

transfer_file pushes a host-staged file to /var/tmp/<basename> on a Junos device using mecmcp-scp's native SCP1 client over the SSH exec channel — the same wire protocol OpenSSH's scp -O forces, since Junos disables SFTP-over-SSH. No external scp process is spawned (#212). 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 native SCP1 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:

PathPurposeDefault modeOwner
/var/lib/jmcp/staging/Host-side stage for files awaiting transfer0750jmcp:jmcp
/etc/jmcp/known_hostsSSH known_hosts consulted for every push0644jmcp:jmcp
/var/lib/jmcp/device-leasesShared Junos/SRX destructive-operation locks0700jmcp:jmcp

Override at startup with --staging-dir <path>, --known-hosts-file <path>, and --device-lease-dir <path>. Junos and SRX services must use the same device lease directory.

Host-key policy (v0.5.2+): host-key checking is strict 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:

bash
scripts/scan-known-hosts.sh --inventory /etc/jmcp/devices.json \                            --known-hosts /etc/jmcp/known_hosts

For lab / first-contact use, pass --ssh-accept-new-host-keys to fall back to OpenSSH's accept-new (TOFU) mode: unknown hosts are pinned to known_hosts on first contact, and a host presenting a different key afterward is still refused. This applies identically to transfer_file / upgrade_junos (scp) and NETCONF SSH.

--ssh-insecure-accept-any-host-key (lab-only): skips host-key verification entirely for both NETCONF SSH and scp (transfer_file / upgrade_junos) — no known_hosts persistence, no mismatch detection, no protection against a man-in-the-middle. Mutually exclusive with --ssh-accept-new-host-keys. Logged loudly at startup and recorded as an audit event. Never use this against production devices; use --ssh-accept-new-host-keys instead, which gives TOFU semantics safely.

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 transferring, 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.

Long-running operational commands

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.

Security warning

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.json permissions to 0600 — it contains SSH credentials.
  • get_junos_config, junos_config_diff, and other tools returning device config or command output are redacted before the response reaches the caller: values matching known secret patterns — including Junos ## SECRET-DATA values ($9$...-style strings, which are reversibly encrypted with Juniper's proprietary symmetric cipher, not hashed — anyone holding the device's master key/passphrase can recover the plaintext), IKE pre-shared-keys, RADIUS/TACACS secrets, and SNMP communities — are replaced with a marker while structure, hostnames, and non-secret values are preserved. This is a best-effort net (a denylist plus a value-shape catch-all), not a guarantee; still restrict this tool's scope to trusted tokens.
  • reload_devices requires file_name to be a relative path resolving inside the original --device-mapping directory (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.

--lab-mode

--lab-mode waives the second principal requirement for change sets, intended for single-operator lab environments where two-person control is impractical. It is off by default and must not be enabled on servers managing production devices.

What it does and does not change:

  • Change sets are approved automatically at creation. There is no separate approval step, and the flow stays plan → apply, identical to production.
  • Planning, the plan digest, drift detection, and apply-time revalidation all still run. Lab mode removes the second reviewer, not the change record.
  • No approver is fabricated. A waived change set records approver: null alongside approval_waiver: "lab-mode". It is cryptographically distinguishable from a genuine two-person approval and cannot be relabelled afterwards — which matters if anyone later has to prove which changes had real separation of duties.
  • The server warns loudly at startup whenever it is enabled.

If you want solo write-testing without waiving the control, minting two tokens with different names and using one to plan and the other to approve is weaker than it looks: both tokens belong to the same human, so "two-person" review is really the same operator clicking approve on their own plan under a different name. The principal is the token name and self-approval is refused, so it exercises the plan→approve→apply API shape honestly — but it is not a real second reviewer.

mecmcp 0.27.0 adds a verified human-approver flow (MEC-994/MEC-995) that closes that gap by binding the approver's identity to a fresh IdP login rather than a token name:

  • Bind the owner token to an IdP identity at creation time: token add --oidc-issuer https://idp.example.com --oidc-subject [email protected] ....
  • Start the server with --oidc-issuer, --oidc-audience, and --require-verified-approver (plus --approval-digest-key-file, which strict mode requires so the verified-approver fields are tamper-evident).
  • Approve with mecmcp-approve, which drives a real OIDC login (PKCE by default) and attaches the resulting assertion as a Mecmcp-Approver-Assertion header: mecmcp-approve --server-url https://junos01.example:8443/mcp --approve-tool approve_junos_change_set --oidc-issuer https://idp.example.com --oidc-client-id mecmcp-approve --arg change_set_id=... --arg device=... --arg expected_digest=....
  • In strict mode, the coordinator refuses an approval whose verified subject matches the owner's bound subject — the same human cannot satisfy both sides of the two-person rule, no matter how many token names they hold.

The two-token workaround above still has a place for pure functional testing of the plan→approve→apply flow when no IdP is available, but treat it as what it is: one operator exercising the API shape, not an approval.

Enabling it

Add --lab-mode to the service unit. On a package install, use a systemd drop-in rather than editing the shipped unit, so an upgrade does not silently drop it:

console
sudo systemctl edit rust-junosmcp

Replacing ExecStart means restating it in full, so copy the shipped command and append the flag rather than writing a shorter one. Dropping other arguments would turn off structured auditing or HMAC redaction as a side effect of enabling lab mode:

ini
[Service]# Clear the shipped ExecStart before replacing it; systemd appends otherwise.ExecStart=ExecStart=/usr/local/bin/rust-junosmcp \    --device-mapping /etc/jmcp/devices.json \    --transport streamable-http \    --host 127.0.0.1 \    --port 30030 \    --tokens-file /etc/jmcp/tokens.json \    --audit-format json \    --audit-journald \    --audit-redact devices=hmac \    --audit-hmac-key-file /etc/jmcp/audit-hmac.key \    --lab-mode

Check it against packaging/systemd/rust-junosmcp.service before applying it — the shipped arguments are the authority, and this snippet is a copy that can age.

console
sudo systemctl daemon-reload && sudo systemctl restart rust-junosmcp

Confirm it took effect. The startup warning uses target: "audit", so grep the journal for the lab-mode text with enough privilege to see a system unit:

console
sudo journalctl -u rust-junosmcp -b | grep -i "lab mode"

The expected output includes:

lab mode enabled: change sets are approved on creation with no second principal. Records carry approval_waiver=lab-mode. Do not run this against production devices.

Silence means it is off. An unprivileged journalctl can also print nothing here for lack of access rather than because the flag is unset, which is why the command uses sudo.

--allow-direct-commit

Four tools never create a change set at all: load_and_commit_config unconditionally, and render_and_apply_j2_template (when it would actually apply), rollback_config (with commit=true), and upgrade_junos (with confirm=true) once past their read-only preview/pre-flight step. Each stages, validates, and commits a device change in one call, so there is no second-principal review by construction — there is no change set for a second principal to approve. Off by default: without this flag, all four are refused before the device is ever touched, over stdio exactly as over HTTP (stdio carries no caller context at all, so it cannot be treated any more leniently than an authenticated session).

Residual risk. --allow-direct-commit is an escape hatch, not a fix. An operator who sets it has decided that, for these specific tools, running with no independent review is an acceptable risk for this deployment. That decision is:

  • Logged loudly at startup. A SECURITY: warning names the risk every time the process starts with the flag on.
  • Audited on every call. Each direct-commit call carries direct_commit_allowed=true in its audit record — a refusal is audited too, as authorization=denied reason=direct_commit_disabled.

It does not add a second-principal review; it only makes running without one visible. Prefer the change-set flow (create_junos_change_set → approve_junos_change_set → apply_junos_change_set) wherever your workflow can use it, and reserve this flag for the specific tools that cannot.

Enabling it

Same pattern as --lab-mode above: add --allow-direct-commit to the service unit via a systemd drop-in, copying the shipped ExecStart in full rather than writing a shorter one.

Confirm it took effect. Unlike the --lab-mode banner, this one is deliberately not tagged target: "audit" — that stream has a fixed per-call schema, and a startup banner has none of those fields — so grep the plain message text instead:

console
sudo journalctl -u rust-junosmcp -b | grep -i "allow-direct-commit is enabled"

Silence means it is off.

Audit logging

rust-junosmcp emits structured audit events for every Junos and SRX tool invocation. Each event records the caller, tool, target routers, authorization decision, outcome, and duration. See docs/AUDIT.md for the full schema and forwarding guidance.

FlagEnvironment VariableDefaultDescription
--audit-formatJMCP_AUDIT_FORMATtextOutput format: text or json.
--audit-log-fileJMCP_AUDIT_LOG_FILE(none)Optional file path to append JSON events to (in addition to stderr).
--audit-journaldJMCP_AUDIT_JOURNALDfalseOptional native journald fan-out for structured audit fields; fails startup when explicitly enabled but unavailable.

Quick start (local)

bash
git clone https://github.com/mechubsec/rustjunosmcp.gitcd RustJunosMCP
# Build the default 41-tool Junos/SRX server with TLS.cargo build --release
# Optional: build the 28-tool Junos-only server without TLS.cargo build --release --no-default-features
# Optional: build Junos-only with TLS.cargo build --release --no-default-features --features tls
# 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

Claude Desktop config

One registration exposes every tool enabled in the built binary (41 with the default srx feature, or 28 in a Junos-only build):

json
{  "mcpServers": {    "junos": {      "command": "/path/to/rust-junosmcp",      "args": ["-f", "/path/to/devices.json"]    }  }}

Docker

Run with Docker

The catalog-friendly stdio invocation replaces the image's HTTP CMD with --transport stdio. The ENTRYPOINT already supplies the inventory, state, known-hosts, lease, and token-file paths, so do not repeat those flags here. The state mount is a bind mount, so it starts out host-user-owned; it and tokens.json inside it must be owned by UID/GID 65532:65532 before the first start, with tokens.json at mode 0600:

bash
mkdir -p jmcp-statecat > jmcp-state/tokens.json <<'EOF'{  "version": 1,  "tokens": [    {      "name": "catalog",      "digest": "sha256:REPLACE_WITH_TOKEN_ADD_OUTPUT",      "devices": ["r1"],      "tools": ["gather_device_facts"],      "created_at": "2026-01-01T00:00:00Z"    }  ]}EOFsudo chown -R 65532:65532 jmcp-statesudo chmod 0700 jmcp-statesudo chmod 0600 jmcp-state/tokens.jsonsudo chown 65532:65532 devices.jsonsudo chmod 0600 devices.json
docker run --rm -i \  -v "$PWD/devices.json:/etc/jmcp/devices.json:ro" \  -v "$PWD/keys:/etc/jmcp/keys:ro" \  -v "$PWD/jmcp-state:/var/lib/jmcp" \  ghcr.io/mechubsec/rustjunosmcp:latest \  --transport stdio

On rootless Docker or a userns-remapped host, UID 65532 inside the container maps to a different host UID; chown the mounts to whatever that mapped UID is instead of 65532 directly.

For example, devices.json uses the server's inventory shape (replace the placeholder secret before use):

json
{  "r1": {    "ip": "192.0.2.10",    "port": 22,    "username": "netconf-user",    "auth": {      "type": "password",      "password": "replace-with-device-password"    }  }}

This invocation leaves HTTP and TLS off because replacing the image CMD also removes its HTTP bind and TLS-related flags.

Running the two-person and lab-mode pair as containers, including the published-vs-internal port trap that makes the allow-lists reject everything with 421, is written up in docs/HOW-TO-SETUP-DOCKER.md.

Prebuilt images are published to GHCR on every version tag. The package is public — no docker login required. The runtime is distroless — no shell, apt, or OpenSSH client (#201) — and runs as numeric UID/GID 65532:65532. transfer_file/fetch_file no longer spawn scp; they speak the SCP1 wire protocol natively via mecmcp-scp over the SSH exec channel (#212).

Prepare read-only configuration/key mounts and one persistent writable state directory. Private-key paths in devices.json must use their in-container locations under /etc/jmcp/keys.

bash
# Pull the prebuilt image (tags: latest, 0.25, 0.26, 0.27).docker pull ghcr.io/mechubsec/rustjunosmcp:latest
# Prepare host paths. Review scanned host-key fingerprints against a trusted# source before starting the server in strict mode.mkdir -p keys jmcp-state/staging jmcp-state/device-leasestouch jmcp-state/known_hosts./scripts/scan-known-hosts.sh \  --inventory "$PWD/devices.json" \  --known-hosts "$PWD/jmcp-state/known_hosts" \  --replace
# The image's non-root process must own its writable state and be able to read# the inventory and private keys. Keep all three private from other host users.sudo chown -R 65532:65532 devices.json keys jmcp-statesudo chmod 0600 devices.json keys/* jmcp-state/known_hostssudo chmod 0700 keys jmcp-state jmcp-state/device-leasessudo chmod 0750 jmcp-state/staging
# Run with configuration/keys read-only and state persistent + writable.docker run --rm -i \  -v "$PWD/devices.json:/etc/jmcp/devices.json:ro" \  -v "$PWD/keys:/etc/jmcp/keys:ro" \  -v "$PWD/jmcp-state:/var/lib/jmcp" \  ghcr.io/mechubsec/rustjunosmcp:latest

Verifying the image signature: every image pushed by the Release image workflow is signed keylessly with cosign via GitHub Actions OIDC — there is no key pair anywhere. Verification pins the signing identity to that exact workflow, so a signature from anywhere else (a fork, a different repo, a local build) fails:

bash
cosign verify \  --certificate-identity-regexp '^https://github\.com/mechubsec/rustjunosmcp/\.github/workflows/release-image\.yml@refs/(tags/v[0-9]+\.[0-9]+\.[0-9]+|heads/main)$' \  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \  ghcr.io/mechubsec/rustjunosmcp:latest

This is a regexp, not an exact --certificate-identity, because GitHub embeds the ref that triggered the run into the certificate, and this workflow has two legitimate triggers with two different refs: a normal tag push carries @refs/tags/vX.Y.Z for that release's own tag — different for every version, including for the :latest tag, since it is repushed and re-signed on every release — and a workflow_dispatch backfill (see the ref input above) typically carries @refs/heads/main, the branch the run was dispatched from. Neither a pull request nor a push to any other branch can trigger this workflow at all, so no other identity is possible. Pin the exact tag instead of the version range if you are verifying one specific release rather than "some release build of this workflow." Each signature also creates a public entry in the Rekor transparency log — this is expected and does not disclose anything beyond what the image push itself already made public.

Images published before 2026-09-29 were signed by the workflow under github.com/fastrevmd-lab/RustJunosMCP, so verifying an older tag needs that identity instead.

Verifying the SBOM attestation: on release, a CycloneDX SBOM of the Rust dependency graph (not the image's distroless runtime base) is attached to the GitHub release and also pushed as an in-toto attestation on the image, signed keylessly the same way as above. This attestation is signed by the release-sbom.yml workflow, a different identity from the image signature's release-image.yml identity above, because it is a separate job that runs after the image is already pushed:

bash
cosign verify-attestation --type cyclonedx \  --certificate-identity-regexp '^https://github\.com/mechubsec/rustjunosmcp/\.github/workflows/release-sbom\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$' \  --certificate-oidc-issuer https://token.actions.githubusercontent.com \  ghcr.io/mechubsec/rustjunosmcp:<version>

The state mount holds staged upload/download files, the shared destructive operation leases, and known_hosts. Do not delete its lease files while a server is running. Strict host-key checking is the default. For an isolated lab only, append --ssh-accept-new-host-keys to the docker run command; this lets the server add first-seen keys to the writable state file, but does not authenticate that first connection out of band.

Apple Silicon (M-series): images are built for linux/amd64 only, so they run under emulation on Apple Silicon. This works, but if you hit a platform-mismatch warning add --platform linux/amd64 to both the pull and run commands.

Prefer to build locally instead:

bash
docker build -t rust-junosmcp:0.26 .
docker run --rm -i \  -v "$PWD/devices.json:/etc/jmcp/devices.json:ro" \  -v "$PWD/keys:/etc/jmcp/keys:ro" \  -v "$PWD/jmcp-state:/var/lib/jmcp" \  rust-junosmcp:0.26

LXC (Proxmox)

Building a container from nothing — including the two-person and lab-mode pair, the credential modes that must be right before first start, and how to verify the seccomp posture came from the shipped unit — is written up in docs/HOW-TO-SETUP-LXC.md.

bash
# Build the tarball and checksum../scripts/package-lxc.sh
# Verify the checksum.sha256sum -c dist/rust-junosmcp_0.27.2_amd64.tar.gz.sha256
# Push and install on VM 115. The installer copies the unified binary and unit# from its extracted package root.## The container MUST be Debian 13 (trixie). This is not a style preference:# `package-lxc.sh` builds against the glibc of whatever host runs it, and the# current published binary requires GLIBC_2.39. Debian 12 ships 2.36, so the# service dies at start with a "GLIBC_2.39 not found" symbol error — after a# clean build and a clean install, which is the worst place to discover it.# Debian 13 ships 2.41. Check your own tarball with:#   objdump -T dist/rust-junosmcp_*/usr/local/bin/rust-junosmcp \#     | grep -oE 'GLIBC_[0-9]+\.[0-9]+' | sort -Vu | tail -1## Debian 13 also matches docs/PACKAGING.md §2, the container runtime base, and# rustpanosmcp — one distro generation to track CVEs against, not three.pct push 115 dist/rust-junosmcp_0.27.2_amd64.tar.gz /tmp/jmcp.tar.gzpct exec 115 -- bash -c "tar xzf /tmp/jmcp.tar.gz -C /tmp && /tmp/rust-junosmcp_0.27.2_amd64/install.sh"

Downloading a prebuilt release tarball instead: each GitHub release also publishes the tarball, its .sha256 checksum, and a keyless cosign signature bundle (.cosign.bundle) for it, signed by the Sign release tarball workflow the same way the container image is signed above — no key pair, GitHub Actions OIDC only. Check the checksum and the signature; the checksum alone only proves the download was not corrupted in transit, not that it came from this repository's release workflow:

bash
version=0.27.2base="https://github.com/mechubsec/rustjunosmcp/releases/download/v${version}"curl -LO "${base}/rust-junosmcp_${version}_amd64.tar.gz"curl -LO "${base}/rust-junosmcp_${version}_amd64.tar.gz.sha256"curl -LO "${base}/rust-junosmcp_${version}_amd64.tar.gz.cosign.bundle"
sha256sum -c "rust-junosmcp_${version}_amd64.tar.gz.sha256"
cosign verify-blob \  --certificate-identity "https://github.com/mechubsec/mecmcp/.github/workflows/reusable-sign-release-tarball.yml@f927c820f39369b2601e11e31334cc5b504b1fd1" \  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \  --certificate-github-workflow-repository "mechubsec/rustjunosmcp" \  --certificate-github-workflow-trigger "release" \  --bundle "rust-junosmcp_${version}_amd64.tar.gz.cosign.bundle" \  "rust-junosmcp_${version}_amd64.tar.gz"

Unlike the image workflow above, the sign job in this repo's own release-sign-tarball.yml delegates the actual signing to mecmcp's reusable workflow, so the OIDC certificate subject is that workflow's path, pinned to the exact commit SHA release-sign-tarball.yml's sign: job currently references via its uses: line — not this repo's own workflow file, and not a branch ref. That pin moves whenever the sign: job is repinned to a newer mecmcp SHA or tag, so don't trust this README's SHA to stay accurate forever; check the uses: line in .github/workflows/release-sign-tarball.yml for the current pin.

Because that reusable workflow lives in a public repo, any GitHub repository can call it and get a certificate with the same identity, so the identity alone does not prove the tarball came from this repo's release. --certificate-github-workflow-repository and --certificate-github-workflow-trigger close that gap: they check the certificate's calling-repository and triggering-event fields, which must be mechubsec/rustjunosmcp and release. Do not drop them.

cosign verify-blob exits non-zero on any mismatch — wrong identity, wrong issuer, wrong calling repository or trigger, or a tarball that does not match the bundle — so a failure here means do not install, not "probably fine."

Edit the inventory:

bash
# Copy the example and configure your devices.pct exec 115 -- cp /etc/jmcp/devices.json.example /etc/jmcp/devices.jsonpct exec 115 -- $EDITOR /etc/jmcp/devices.json  # or edit via another methodpct exec 115 -- chown jmcp:jmcp /etc/jmcp/devices.jsonpct exec 115 -- chmod 0600 /etc/jmcp/devices.json

Mint a bearer token:

bash
# A wildcard tool scope grants read-only tools only; write tools must be named# explicitly. See "Upgrading to v0.10" and "Tool scopes and write tools" below.pct exec 115 -- runuser -u jmcp -- /usr/local/bin/rust-junosmcp token add \  --tokens-file /etc/jmcp/tokens.json \  --name ops \  --routers '*' \  --tools get_router_list,gather_device_facts,execute_junos_command,get_junos_config,commit_check_config,load_and_commit_config

Start the service:

bash
pct exec 115 -- systemctl enable --now rust-junosmcp

The installer is idempotent: rerunning it upgrades binaries and units without overwriting devices.json, tokens.json, or known_hosts. It validates the complete archive before changing system state. The packaged server exposes all enabled Junos and SRX tools at 127.0.0.1:30030/mcp and requires bearer authentication. Use an SSH tunnel or a TLS reverse proxy for remote clients.

Clearing a stuck operation: state resolve settles an operation that is stuck in a non-terminal state after a failed apply. One such record blocks every later change on its device, and no tool can clear it — cancel_junos_ change_set refuses a change set that is already terminal. Stop the service first, or the running server will overwrite the file from memory:

bash
systemctl stop rust-junosmcprunuser -u jmcp -- /usr/local/bin/rust-junosmcp state resolve \  --state-file /var/lib/jmcp/changeset-state.json \  --operation-id <64-hex-operation-id> \  --disposition discarded \  --confirmation "RESOLVED <64-hex-operation-id> AS DISCARDED"systemctl start rust-junosmcp

The confirmation string must match exactly: it is the operator asserting they looked at the device and this is what is true. Use committed only when the device's own commit log proves the change landed.

Narrowing an existing token: token set-scope changes a token's router or tool scopes without reissuing its secret, so clients keep working with the same bearer token. Useful for removing access without reconfiguring every client. See the token management section for examples.

Upgrading from a split-server release removes the retired rust-srxmcp executable, unit, and enabled-service link. It deliberately preserves existing support bundles under /var/lib/jmcp/srx-staging/bundles.

Remote transport + auth

Mint a token

bash
cargo run -- token add \  --tokens-file tokens.json \  --name ops \  --routers '*' \  --tools execute_junos_command,gather_device_facts

get_router_list applies the same router scope as device tools. Authenticated allowlist tokens receive only the current inventory names in their scope; stale scope entries and excluded routers are omitted without counts or errors. An empty allowlist or empty intersection returns []. Wildcard tokens, local stdio, and explicitly unauthenticated loopback mode retain the full inventory.

Note: See tokens-template.json for the file shape. Use token add rather than editing the file by hand — the digest field must be a versioned SHA-256 of the secret, not the plaintext. Fields are canonically digest and devices; the older hash and routers spellings are still accepted as aliases, so existing files load unchanged. The CLI flag remains --routers.

tokens.json must be mode 0600 (v0.10.0+). The server refuses to start on a group- or world-accessible token file, and the error names the file's owner uid, its mode, the calling process's uid, and the chmod to run. Every token subcommand writes 0600, and the LXC installer sets it, so this only bites files created or copied by hand.

Tool scopes and write tools

A tool scope is either the literal * or an explicit list of tool names. The two cannot be mixed — --tools '*',transfer_file is rejected.

A wildcard tool scope does not confer write tools (v0.10.0+). "tools": ["*"] reaches every read-only tool but none of these 15 write-capable tools:

Write tool
add_deviceapply_junos_change_set
approve_junos_change_setconfirm_junos_change_set
create_junos_change_setdiscard_candidate
executeload_and_commit_config
manage_appid_signature_packagemanage_idp_security_package
reload_devicesrender_and_apply_j2_template
rollback_configtransfer_file
upgrade_junos

Granting write authority is always an explicit, named decision: a token that needs load_and_commit_config must list it, alongside every other tool it calls. An explicit allowlist behaves exactly as before.

execute is a strict facade over the 40 concrete operations. A token using it must explicitly name both execute and the selected concrete operation; wildcard scope does not grant execute. The facade passes the selected operation's arguments unchanged, so use the concrete operation's exact argument names:

json
{"operation":"gather_device_facts","arguments":{"device":"vsrx-ci"}}

Direct calls to all 40 concrete tools remain supported.

tools/list advertises only what the caller's token can invoke, so the list an agent sees matches what it can actually call. A wildcard token is shown the read-only tools and not the 15 write-capable tools; a token scoped to nothing is shown an empty list.

Cached lists go stale. A client that fetched tools/list before you re-scoped its token with token set-scope keeps the old view until it reconnects — the server does not currently emit notifications/tools/list_changed on SIGHUP reload. Authorization is unaffected: a call to a tool the token no longer has is refused regardless of what the client believes it can see.

Scope checks apply only to authenticated HTTP callers. Local stdio and the --allow-no-auth loopback escape hatch carry no caller context and are not scope-restricted.

Run token subcommands as the service user. When the systemd unit runs the server as a dedicated user (e.g. User=jmcp in the packaged unit), the file token add/revoke/rotate/set-scope writes inherits the calling user's ownership. If you run them as root, the resulting tokens.json will be root:root 0600 and the service user cannot read it — the server then crash-loops on startup with Permission denied. Either:

bash
# 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.json

If 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.

Run with auth (streamable-http)

bash
cargo run -- \  --device-mapping devices.json \  --transport streamable-http \  -H 127.0.0.1 \  -p 8765 \  --tokens-file tokens.json

Loopback escape hatch (no auth, local only)

bash
cargo 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.

Non-loopback requires TLS

bash
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

To 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.

Host allowlist (DNS-rebinding guard)

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:

bash
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.example.net

There is no way to turn the allowlist off. --disable-host-check was removed in 0.15.3 and is now rejected at startup. The allowlist is the DNS-rebinding guard (RUSTSEC-2026-0189), and rebinding targets loopback-bound services specifically — a browser resolves an attacker-controlled name to 127.0.0.1 and reaches the server with a foreign Host. "Off" was therefore most dangerous exactly where it looked safest. If a client sends an authority the server does not know, name it with --allowed-host; the flag is repeatable.

Hot reload

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:

bash
# 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>

Rescope a token without reissuing its secret

token set-scope changes an existing token's scopes in place. The digest, created_at, and envelope version are all preserved, so clients holding the secret keep working — this is the difference from rotate, which mints a new secret and forces every consumer to be updated at once.

bash
# Narrow tools; leave the router scope alone.cargo run -- token set-scope \  --tokens-file tokens.json \  --name ops \  --tools gather_device_facts,get_junos_config,load_and_commit_config \  --server-pid <pid>
# Narrow routers; leave the tool scope alone.cargo run -- token set-scope \  --tokens-file tokens.json \  --name ops \  --routers edge-1,edge-2 \  --server-pid <pid>

--routers and --tools are each optional and independent; whichever you omit is left unchanged. Supplying neither is an error. The command prints the resulting scopes to stderr, and --server-pid sends the usual SIGHUP once the file is written. Unknown tool names are rejected; unknown device names warn but are accepted, since token operations run before the inventory is loaded.

If you need to trigger a reload without a token change (e.g., after editing the file by hand), send SIGHUP directly:

bash
kill -HUP <pid>

Refusal matrix

FlagsBind addressResult
(none)anyRefused — --tokens-file or --allow-no-auth required for streamable-http
--allow-no-auth onlynon-loopbackRefused — --allow-no-auth is loopback-only
--allow-no-auth onlyloopbackOK — 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 onlynon-loopback, no TLSRefused — add --tls-cert/--tls-key or --allow-insecure-bind
--tokens-file --allow-insecure-bindnon-loopback, no TLSOK — tokens are checked; you are asserting external transport security
--tokens-file --tls-cert cert.pem --tls-key key.pemanyOK

Canonical environment variables

The unified server uses one JMCP_* namespace. Command-line values take precedence over environment values.

PurposeCanonical environment variable
ListenerJMCP_HTTP_HOST, JMCP_HTTP_PORT
Inventory and tokensJMCP_DEVICES_PATH, JMCP_TOKENS_PATH
TLSJMCP_TLS_CERT, JMCP_TLS_KEY
Destructive-operation leasesJMCP_DEVICE_LEASE_DIR
Support-bundle stagingJMCP_SUPPORT_BUNDLE_STAGING_DIR, JMCP_SUPPORT_BUNDLE_STAGING_MAX_BYTES
AuditJMCP_AUDIT_FORMAT, JMCP_AUDIT_LOG_FILE, JMCP_AUDIT_JOURNALD, JMCP_AUDIT_REDACT, JMCP_AUDIT_HMAC_KEY_FILE
MetricsJMCP_ENABLE_METRICS

For migration, legacy JMCP_SRX_* aliases are accepted with a warning in 0.8.0 only when neither the corresponding command-line option nor canonical variable is set. JMCP_SRX_HTTP_PORT is always ignored: the retired second listener no longer exists. Move deployments to the canonical names now.

Upgrading to v0.10

Two breaking authorization changes land in v0.10.0. Both are checked at runtime by the new binary, so do the preparation while 0.9.x is still running — otherwise the upgrade either fails to start or silently breaks write-capable clients.

1. Fix token-file permissions

The new binary refuses to start on a group- or world-accessible tokens.json. Check and fix before swapping the binary:

bash
# On the server host, as the service user.stat -c '%a %U:%G %n' /etc/jmcp/tokens.jsonchmod 0600 /etc/jmcp/tokens.json

The LXC installer already sets 0600, so packaged installs are almost certainly fine. Files created by hand, copied between hosts, restored from a backup, or written by configuration management are the ones to check. If the server does hit this, the startup error names the file's owner uid, its mode, the calling process's uid, and the exact chmod — it is not a silent failure.

2. Re-scope wildcard tokens that need write tools

A wildcard tool scope no longer confers the 15 write-capable tools. Any token with "tools": ["*"] that calls one of them will start getting ToolNotInScope refusals after the upgrade.

Find the affected tokens:

bash
sudo -u jmcp rust-junosmcp token list --tokens-file /etc/jmcp/tokens.json

Every row showing * in the TOOLS column is a candidate. For each one, decide whether it genuinely needs write access:

  • Read-only in practice — leave it. A wildcard scope still reaches every read-only tool, and it is now safer than it was.
  • Needs write tools — replace the wildcard with an explicit list naming every tool it calls, including the read-only ones. Scopes cannot mix * with names.

token set-scope does this without reissuing the secret, so clients keep working across the change:

bash
sudo -u jmcp rust-junosmcp token set-scope \  --tokens-file /etc/jmcp/tokens.json \  --name ops \  --tools get_router_list,gather_device_facts,get_junos_config,junos_config_diff,commit_check_config,load_and_commit_config \  --server-pid "$(systemctl show -p MainPID --value rust-junosmcp)"

set-scope exists in 0.10.0 and later. On 0.9.x, the equivalent is to edit tokens.json by hand — change only the tools array, leave hash/digest and created_at untouched — then systemctl kill -s HUP rust-junosmcp. Do not use rotate for this: it mints a new secret and locks out every consumer of that token at once.

3. Upgrade, then verify

After installing the new release, confirm the store loaded and the scopes are what you expect:

bash
systemctl status rust-junosmcpsudo -u jmcp rust-junosmcp token list --tokens-file /etc/jmcp/tokens.json

Then exercise one write tool through an affected client. A refusal at this point means that token's allowlist is missing the tool name.

Back up tokens.json before upgrading — rollback is not symmetric. v0.10 reads the old hash/routers spellings, but any v0.10 token write (add, rotate, revoke, set-scope) rewrites the whole file in the canonical digest/devices spelling, and 0.9.x cannot parse that — it requires hash and fails to load the store, so the rolled-back server will not start. Keep a copy:

bash
sudo -u jmcp cp -a /etc/jmcp/tokens.json /etc/jmcp/tokens.json.0.9-backup

If you have not run a v0.10 token write, the file is byte-identical and rollback is clean. Otherwise restore the backup (secrets and scopes are unchanged by the upgrade itself), or rename the two fields back by hand. On Proxmox, an LXC snapshot of the container before install covers this along with everything else.

Resource limits (streamable-HTTP)

The unified endpoint enforces configurable DoS guardrails. Most limits are enabled by default with generous values; the optional per-token request-rate limiter is disabled until both of its knobs are positive. A zero value disables an individual limit, subject to the rate/burst pair rule below.

FlagEnvironment variableDefaultEffect
--max-request-body-bytesJMCP_MAX_REQUEST_BODY_BYTES10 MiBReject larger bodies with 413 before buffering
--max-inflight-requestsJMCP_MAX_INFLIGHT_REQUESTS64Global concurrency cap; over-limit → 503
--max-inflight-requests-per-tokenJMCP_MAX_INFLIGHT_REQUESTS_PER_TOKEN16Per-token concurrency cap → 503
--max-requests-per-second-per-tokenJMCP_MAX_REQUESTS_PER_SECOND_PER_TOKEN0Per-token refill rate; pair with burst (0/0 disables)
--max-request-burst-per-tokenJMCP_MAX_REQUEST_BURST_PER_TOKEN0Per-token immediate burst; pair with rate (0/0 disables)
--max-inflight-requests-per-routerJMCP_MAX_INFLIGHT_REQUESTS_PER_ROUTER4Per-router concurrency cap → 503
--max-sessionsJMCP_MAX_SESSIONS128Session count cap → 503
--max-sessions-per-tokenJMCP_MAX_SESSIONS_PER_TOKEN16Per-bearer-token session cap → 503
--session-idle-timeout-secsJMCP_SESSION_IDLE_TIMEOUT_SECS300Idle sessions reaped
--session-max-lifetime-secsJMCP_SESSION_MAX_LIFETIME_SECS3600Old sessions reaped

The global session cap is enforced atomically during session creation. The middleware rejects obvious saturation early, while the shared session manager closes any concurrently created session that loses the final slot race before returning the same session_cap 503 contract. Rejected initialization never returns an Mcp-Session-Id, and closing or reaping an admitted session returns its slot.

Per-token session accounting uses the exact authenticated token name. Successful initialization binds the returned Mcp-Session-Id; explicit close and idle/lifetime reaping return the slot. Saturation returns {"error":"overloaded","limit":"token_session_cap"}. The cap is skipped in explicit no-auth mode because no token identity exists.

Over-limit responses carry Retry-After: 1. Concurrency permits are released when the response stream ends. A multi-router call holds one router slot for each unique top-level router, router_name, routers, or router_names target. Per-router saturation is identified by the response body {"error":"overloaded","limit":"router_concurrency"}.

The per-router HTTP permit is acquired before a destructive workflow waits for its cross-process device lease. A destructive call counts once while waiting for or holding that lease; the HTTP cap bounds both reads and destructive waiters, while the lease remains the authority that serializes destructive operations across processes.

Per-token request-rate limiting is an opt-in token bucket keyed by the exact authenticated token name. Set both the requests-per-second rate and burst to positive values to enable it; leave both at 0 to disable it. Supplying only one positive value fails startup. Each authenticated /mcp HTTP request costs one token. An exhausted bucket returns 429, Retry-After: 1, and {"error":"rate_limited","limit":"token_rate"} before concurrency or session capacity is acquired. Explicit no-auth mode skips this per-token control.

Use the RPS limiter to absorb bursts of many cheap, short calls. Use concurrency limits to bound simultaneous expensive NETCONF/SSH work and slow response streams. They are independent and can be enabled together; rate checks run first, while concurrency/session exhaustion retains the existing 503 contract.

Prometheus export is opt-in with --enable-metrics (JMCP_ENABLE_METRICS). It mounts an unauthenticated GET /metrics beside /mcp; protect it with network controls. See Prometheus metrics for scrape configuration, metric names, labels, and PromQL examples.

CLI

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)      --device-lease-dir <DEVICE_LEASE_DIR>          Shared directory for cross-process destructive-operation leases          [default: /var/lib/jmcp/device-leases]      --allowed-host <HOST>          Additional Host authorities to accept on the streamable-http          endpoint, beyond the loopback defaults (localhost, 127.0.0.1, ::1).          Repeatable  -h, --help          Print help  -V, --version          Print version

Testing against a real device

bash
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 --nocapture

Audit forwarding to the event store

The audit trail does not stay on this host. This server follows the family standard — AUDIT-FORWARDING-STANDARD.md.

An audit record that only exists on the machine that produced it is not an audit trail: it is a log file on a box whose operator is the party the record is about.

Emission (in effect now)

--audit-format json \--audit-log-file /var/lib/jmcp/audit.jsonl

JSON is mandatory. The text format is for reading in a terminal and is not a parse target. The file is the operator-facing artifact and must be rotated — the server never truncates it.

Transport (specified, not yet implemented)

Records are written directly into SSDF's ssdf.audit as hash-chained rows, per SSDF's merged evidence contract, so that deleting or editing a row is detectable. Tracked in mecmcp#292.

A cheaper syslog path was designed and rejected: it works, but the records are unchained, and every other link here is tamper-evident by construction — plan digests bind approvals, approvals name a distinct principal, and token_verified_fields separates vouched-for provenance from asserted. An unchained final hop would discard that guarantee exactly where an auditor needs it. The reasoning is recorded in the standard.

Reading the result

token_verified_fields names the provenance fields the token vouched for. The rest of that group — client_name, model_id, session_id — is client-asserted and authenticated by nothing. Do not read them as equivalent.

request_id correlates the transport event, the handler event, and (on Junos) the device commit comment.

License

Licensed under MIT.


[Image]
a mechub project · deterministic decides · the model explains · a human approves
github.com/fastrevmd-lab

Source: README.md at commit a93bc95

Tools

0
Tool metadata has not been indexed yet.

Version history

1
  1. v0.27.4LatestOct 7, 2026