Skip to main content
Glama
README.md
# juniper-junos-mcp

An [MCP](https://modelcontextprotocol.io) server that exposes **Juniper Junos**
devices — SRX firewalls in particular — as tools an LLM client can call. Named
operational reads, plus a configuration-change path that previews the device's
own diff, commits behind a rollback timer, verifies the box still answers, and
only then confirms.

It speaks MCP over Streamable HTTP, so it runs as its own service on the
network rather than as a local subprocess of one client. Transport to the device
is NETCONF over SSH, via [`junos-eznc`](https://github.com/Juniper/py-junos-eznc).

Built for [`universal-network-director`](https://github.com/anderson-jason573/universal-network-director),
a chat-driven multi-vendor network manager with a human approval gate on every
write — but it is a standalone MCP server and works with any MCP client.

> **Not affiliated with, endorsed by, or supported by Juniper Networks.**
> "Juniper", "Junos" and "SRX" are trademarks of their respective owners and are
> used here only to describe what this software talks to.

---

## The safety model

A router's configuration is not a set of narrow API calls, so this doesn't
pretend it is. **Two things make config changes survivable, and neither is the
model:**

### 1. The diff comes from the device, not from the model

`preview_config_change` loads your `set` lines into a **candidate**
configuration and returns the device's own `show | compare` output. That's the
router telling you what would actually change, given its current state — not the
model's account of what it intended. You approve *that*.

`write_apply_previewed_config` then takes **only a preview id**. It cannot
commit anything that was not previewed, and the preview is re-validated against
the device before it commits: if the running config moved underneath you, the
apply fails closed rather than committing a stale diff. Previews expire
(`JUNOSGW_PREVIEW_TTL`, default 30 minutes), and an expired one fails with
"unknown or expired preview" so you re-preview against current state.

### 2. A change that severs management reverts itself

The apply is `commit confirmed <n>`: live immediately, but Junos rolls it back
automatically unless a confirming commit arrives within `n` minutes (1–60,
default 5). This server then **reconnects on a new session** to check the device
still answers, and sends the confirming commit only if it does. Lock yourself
out and the box undoes the change on its own.

### What this does *not* protect

Reads are bounded by construction — every operational read runs a command this
server chose, so the model picks a *tool*, never a *command*. **Config changes
are not bounded that way: the model writes the `set` lines.** What protects that
side is the diff coming from the device and the auto-revert, not the tool
surface. Read the diff.

---

## Read this before you point it at production

**Two of the fifteen tools change state, and this server does not ask before
executing them.** There is no confirmation step here — the `write_` prefix is a
convention so *your client* can gate them.

| Tool | What it does | Risk |
|---|---|---|
| `write_clear_ipsec_sa` | Tears down one IPsec SA by index, forcing renegotiation | **Traffic over that tunnel stops** until it re-establishes. Changes no configuration. |
| `write_apply_previewed_config` | Commits a previously previewed change | **Whatever the diff said.** Behind `commit confirmed` with an auto-revert, but a bad policy is live for the length of the timer. |

Note the asymmetry the prefix encodes: **`write_` means "a human must approve
this", not "this edits the configuration".** `write_clear_ipsec_sa` edits nothing
and is gated because traffic stops when it runs. Junos itself draws the line
between configuration and operational state; the approval gate draws it around
consequences.

**There is deliberately no free-form command tool.** Every operational read has
its command fixed here, so there is no guessed syntax and no approval spent on a
command that was never going to work. An unnamed operation gets "I have no tool
for that" — which is the signal for what to build next, not a gap to paper over
with a shell.

**Scope the device account read-only** unless you specifically intend the writes
to work. That control lives on the device, not in this code, and it is the one
that holds regardless of what any model or client decides.

## The MCP endpoint has no authentication

This server exposes its tools to anyone who can reach its port. There is no
token, no client auth, no TLS on the MCP side.

`MCP_HOST` defaults to `127.0.0.1` for that reason. The container image sets
`0.0.0.0` because it has to, which means **publishing the container's port puts
an unauthenticated path to your firewalls on that interface.** Keep it on an
internal network with the client, or terminate TLS and authentication in front
of it.

---

## Device inventory

Device credentials live in a JSON file mounted read-only into the container —
never baked into the image, never committed. It holds **plaintext SSH
credentials**, so `chmod 600` it and own it as the uid the container runs as.

```json
{
  "srx-edge": {
    "ip": "192.0.2.10",
    "port": 22,
    "username": "automation",
    "auth": { "type": "password", "password": "..." }
  },
  "srx-branch": {
    "ip": "192.0.2.11",
    "username": "automation",
    "auth": { "type": "ssh_key", "private_key_path": "/app/config/id_ed25519" }
  }
}
```

`port` defaults to 22. `auth.type` is `password` or `ssh_key`; a flat
`"password": "..."` at the top level is also accepted for compatibility with the
format this inherited. The keys of the object are the names `list_devices`
returns and every other tool takes as `device` — no credential is ever exposed
through a tool result.

> **Container uid.** The image creates a uid-1000 passwd entry deliberately.
> OpenSSH refuses to run when the calling uid has no `/etc/passwd` entry, and
> the symptom is a misleading "EOF reading from transport" rather than anything
> naming the real cause.

## Running it

```bash
docker build -t juniper-junos-mcp .
docker run --rm \
  -v "$PWD/secrets/devices.json:/app/config/devices.json:ro" \
  --user 1000:1000 \
  -p 127.0.0.1:8003:8003 \
  juniper-junos-mcp
```

| Variable | Default | Meaning |
|---|---|---|
| `JUNOS_DEVICES_FILE` | `/app/config/devices.json` | Device inventory path |
| `JUNOSGW_PREVIEW_TTL` | `1800` | Seconds a preview stays applicable |
| `MCP_HOST` | `127.0.0.1` | Bind address (the image sets `0.0.0.0`) |
| `MCP_PORT` | `8003` | Bind port |

## Tests

```bash
docker run --rm juniper-junos-mcp python test_arg_validation.py
```

Offline argument-validation checks — no device contact, no credentials needed.

## Tool reference

[`TOOLS.md`](TOOLS.md) has the tool-by-tool reference: arguments, what each one
returns, and which of them need approval.

## License

Apache-2.0. See [LICENSE](LICENSE).