PNETLab MCP Server
Click on "Install 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., "@PNETLab MCP ServerBuild a two-router OSPF lab with two PCs and boot it"
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.
PNETLab MCP Server
Give Claude (or any MCP client) programmatic control of a PNETLab network-emulation lab — build topologies, wire links, push device configs, and boot the lab, all from natural language.
An MCP (Model Context Protocol) server that exposes PNETLab lab operations as tools an LLM agent can call. Ask Claude to "build me a two-router OSPF lab with a PC on each side and boot it" and it will create the lab, add the nodes, cable them, inject startup-configs, and start everything — driving the same PNETLab API the web UI uses.
It is a drop-in-compatible fork of eveng-mcp-server — same tool names and shapes — but the client speaks PNETLab's session-bound API instead of EVE-NG's path-addressed REST API. The stock EVE-NG MCP server does not work against a PNETLab box; this fork bridges the gap. Verified against a live PNETLab 5.3.x server.
Table of contents
Related MCP server: netlab-mcp
Why this exists
PNETLab is a popular fork of EVE-NG, but it quietly replaced EVE-NG's clean REST API with a session-bound model borrowed from its Laravel front-end:
You log in, then open exactly one lab (which binds it to your server-side session).
Every subsequent operation targets that open lab via
/api/labs/session/*— there is no way to address a lab by path in the URL.Lab/folder listing and teardown live in a separate Laravel "store" admin API (
/store/...,/api/folders).
The consequence: tooling written for EVE-NG's API returns 404s and empty topologies against PNETLab. This server re-implements the client to speak PNETLab's dialect, while keeping an EVE-NG-style lab_path argument on every tool so agents (and humans) don't have to think about session binding. The client tracks which lab is open and re-binds only when lab_path changes.
Features
31 tools covering the full lifecycle: labs (create/clone/rename/move/delete), folders, images, nodes, links, configs, live console (read and write), backup/restore (
.zipexport/import), and running-lab control.Auto lab-binding — every tool takes a
lab_pathand opens it for you; no explicit "open lab" dance required.IOL interface translation — pass
e0/0/e0/1and the server converts to PNETLab's0/16/32/48indices automatically.Startup-config injection — nodes are created with configs enabled, so
push_configloads at boot without a link-destroying edit.Live console typing via the Guacamole WebSocket tunnel — for the things startup-config can't do (VPCS IPs, dot1Q subinterface addressing, live
write mem).Config export — scrape a running device's live running-config back into its startup-config so
write memsurvives a wipe.Resilient sessions — transparently re-authenticates and re-opens the lab when PNETLab rotates your single-session cookie, then retries the call.
How PNETLab differs from EVE-NG
The reason a dedicated fork is necessary:
Concern | EVE-NG (upstream) | PNETLab (this fork) |
Login |
|
|
Target a lab | path in every URL: | open one lab → all ops go to |
Open a lab | implicit |
|
List labs |
|
|
Link nodes | create network + attach interfaces |
|
Push config |
|
|
Tear down lab | delete via path |
|
Delete lab file |
| path-in-URL removed ( |
Session model | stateless per request | single-session-per-account; opening a lab evicts other sessions |
Requirements
Python 3.10+
A reachable PNETLab server (tested on 5.3.x) and a login account.
Python packages (installed automatically):
mcp,httpx,pydantic,websockets,pillow.websockets+pilloware only needed for the console tools (send_console,read_console); everything else is plain HTTP.
An MCP client — Claude Code, Claude Desktop, or any MCP-compatible host.
Use a dedicated MCP account. PNETLab allows only one active session per account. If the MCP logs in as the same user you use in the browser, the two will keep evicting each other. Create a separate admin-role local account (e.g.
mcp-user) for the server.
Installation
Clone and install into a virtual environment:
git clone https://github.com/<your-username>/pnetlab-mcp-server.git
cd pnetlab-mcp-server
python -m venv .venv
# Windows (PowerShell):
.venv\Scripts\Activate.ps1
# macOS/Linux:
source .venv/bin/activate
pip install -e .Or, with the dev extras (pytest, ruff, respx) for hacking on the server:
pip install -e ".[dev]"This installs the pnetlab-mcp-server console script and the pnetlab_mcp_server module. Either of these starts the server (it speaks MCP over stdio):
python -m pnetlab_mcp_server
# or
pnetlab-mcp-serverYou normally don't run it by hand — your MCP client launches it for you (see below).
Configuration
All configuration is via environment variables. Copy .env.example and adjust:
# Base URL of the PNETLab server
PNETLAB_HOST=https://pnetlab.example
# Use a DEDICATED admin-role account for the MCP (e.g. mcp-user), separate from
# the one you log into the web UI with. PNETLab is single-session-per-account,
# so sharing 'admin' makes the MCP and your browser evict each other.
PNETLAB_USERNAME=mcp-user
PNETLAB_PASSWORD=pnet
# Verify the TLS certificate (set to 'false' for self-signed labs)
PNETLAB_VERIFY_SSL=true
# 1 = HTML5 console, 0 = native — affects the console link format
PNETLAB_HTML_CONSOLE=1Variable | Default | Description |
|
| Base URL of the PNETLab server. |
|
| Local (offline) login username. Prefer a dedicated |
|
| Local (offline) login password (sent plaintext over the offline endpoint). |
|
|
|
|
|
|
Register with an MCP client
Claude Code (CLI)
claude mcp add pnetlab \
--env PNETLAB_HOST=https://pnetlab.example \
--env PNETLAB_USERNAME=mcp-user \
--env PNETLAB_PASSWORD=pnet \
-- python -m pnetlab_mcp_serverPoint at the venv's Python if the module isn't on your global PATH — e.g. .../pnetlab-mcp-server/.venv/Scripts/python.exe on Windows or .../.venv/bin/python on macOS/Linux.
Claude Desktop / generic MCP config
Add to your client's mcpServers map (for Claude Desktop: claude_desktop_config.json):
{
"mcpServers": {
"pnetlab": {
"command": "python",
"args": ["-m", "pnetlab_mcp_server"],
"env": {
"PNETLAB_HOST": "https://pnetlab.example",
"PNETLAB_USERNAME": "mcp-user",
"PNETLAB_PASSWORD": "pnet",
"PNETLAB_VERIFY_SSL": "true"
}
}
}
}On Windows, if you installed into a venv, use the full interpreter path so the module resolves:
{
"mcpServers": {
"pnetlab": {
"command": "C:\\path\\to\\pnetlab-mcp-server\\.venv\\Scripts\\python.exe",
"args": ["-m", "pnetlab_mcp_server"],
"env": {
"PNETLAB_HOST": "https://pnetlab.example",
"PNETLAB_USERNAME": "mcp-user",
"PNETLAB_PASSWORD": "pnet"
}
}
}
}Restart the client; the pnetlab tools appear once the server connects.
👀 Watching the MCP work — join its session
Read this if you open the lab in your browser to watch what the MCP is doing.
PNETLab is multi-session: every account that opens a lab gets its own private running instance (its own pod). The MCP signs in as its own account (mcp-user), so everything it does at runtime — starting nodes, wiring links, live console activity, VPCS IPs — happens inside the mcp-user session.
The consequence: if you open the same lab in your browser as your normal account, PNETLab gives you a separate, empty pod. You will not see the nodes the MCP started or any of its live activity — you'd be looking at your own independent copy of the topology.
To see what the MCP is doing, join its session instead of starting your own:
Make the lab joinable — in the lab's settings, set Joinable to allow it (and add your email to the joinable list if the lab restricts who can join).
Find the MCP's session — ask the agent to run
list_running_labs, or open the Running Labs page in the web UI, to see themcp-usersession for that lab.Join it — open the lab and choose to join the existing (
mcp-user) session rather than start a new one.
Now you share the same running pod: the nodes the MCP starts, the links it wires, and the console output all appear in your browser in real time.
Why not just share one account? PNETLab allows only one active session per account — if the MCP and your browser used the same login, they'd constantly evict each other. That's exactly why the MCP uses a separate
mcp-user, and why joining is the right way to observe its work.
Tool reference
31 tools, grouped by concern. Every tool that operates on a lab takes a lab_path (leading slash and .unl suffix are added for you, so readme-demo, /readme-demo, and /readme-demo.unl all work).
Lab management
Tool | Parameters | Description |
|
| List labs and subfolders in a workspace folder. Returns each lab's |
|
| Full topology: lab metadata, all nodes (status + the interface index→name map you need for |
|
| Create a new lab and open it as the active session lab. |
|
| Bind an existing lab to the session. Usually unnecessary (every tool auto-opens its |
|
| Tear down a lab's running session (stop & wipe nodes, unbind). By default the |
|
| Clone a lab (topology + configs) into the same folder under a new name. Great for templating. |
|
| Rename a lab's |
|
| Move a lab into another workspace folder (must already exist — see |
| (none) | List every running lab session on the server (lab path, session id, pod, running-node count). A lab appears here whenever it has an open pod — that's what blocks deleting its |
|
| Fully stop a running lab across all its open sessions/pods (stops nodes + destroys pods) so it can be deleted — even one running under another user. |
|
| Export a lab to a |
|
| Import a lab from a local |
Images / templates
Tool | Parameters | Description |
| (none) | List installed node templates/images (uninstalled |
|
| Get a template's add-node option schema (valid ram/ethernet/image/nvram) so |
Folders
Tool | Parameters | Description |
|
| Create a workspace subfolder for organizing labs. |
|
| Delete a folder and everything in it (blocked with |
Node management
Tool | Parameters | Description |
|
| Add a node. Created with startup-config enabled so |
|
| Start a single node. |
|
| Stop a single node. |
|
| Start every node in the lab. |
|
| Stop every node in the lab. |
|
| Node status ( |
|
| Delete a single node (and its links). Delete before re-wiring neighbors — editing links after can drop bindings. |
|
| Wipe a node — clear NVRAM/runtime so it reboots fresh from the stored startup-config. Factory-reset a device, force a pushed config to reload, or free a stuck console line. |
Common templates (from list_images on the reference box):
|
| What it is | Suggested |
|
| Virtual PC (lightweight test host) |
|
|
| Cisco IOL L3 (router) |
|
|
| Cisco IOL L2 (switch) |
|
|
| Classic Dynamips IOS routers |
|
|
| Docker container node | (varies) |
Configuration
Tool | Parameters | Description |
|
| Store a startup-config (loads on next boot). For IOL L2 switches, include |
|
| Return a node's current startup-config text. |
|
| Save a running node's live running-config into its startup-config (PNETLab "Export CFG"). |
Connectivity
Tool | Parameters | Description |
|
| Point-to-point link between two node interfaces. Accepts an index or an |
|
| Remove the link on a node's interface (deletes the underlying network). For a p2p link this disconnects both ends, so you pass just one side. |
|
| Type commands on a node's live console via the Guacamole tunnel — for what |
|
| Read the live console — returns a PNG screenshot of the terminal (a vision model reads it). Optionally types |
Worked examples
These are real calls run against a live PNETLab 5.3.x box while writing this README, with real returned output.
1. List your labs
list_labs(folder="/"){
"folder": "/",
"labs": [
{ "name": "mcp-verify.unl", "path": "/mcp-verify.unl", "modified": "08 Jul 2026 00:48" },
{ "name": "bgp-vlan-sites.unl", "path": "/bgp-vlan-sites.unl", "modified": "08 Jul 2026 01:53" }
],
"subfolders": []
}2. Discover installed images
list_images(){
"c3725": "Cisco IOS 3725 (Dynamips)",
"c7200": "Cisco IOS C7200 (Dynamips)",
"docker": "Docker.io",
"i86bi_linux_l2": "Cisco IOL L2 (Switch)",
"i86bi_linux_l3": "Cisco IOL L3 (Router)",
"vpcs": "Virtual PC (VPCS)"
}3. Build a router + PC lab end-to-end
// Create and auto-open the lab
create_lab(name="ospf-lab", description="Two-node demo", author="you")
// Add a Cisco IOL router — note the interface map that comes back
add_node(lab_path="/ospf-lab.unl", template="i86bi_linux_l3", node_type="iol",
name="R1", image="i86bi_linux_l3-L3-ADVENTERPRISEK9-M-15.4-2T.bin",
left=350, top=150)
// → { "id": 1, "name": "R1", "type": "iol", "console_port": 30017,
// "interfaces": { "0": "e0/0", "16": "e0/1", "32": "e0/2", "48": "e0/3" } }
// Add a VPCS test host
add_node(lab_path="/ospf-lab.unl", template="vpcs", node_type="vpcs",
name="PC1", icon="Desktop.png", ram=128, left=100, top=150)
// → { "id": 2, "name": "PC1", "type": "vpcs", "console_port": 30018,
// "interfaces": { "0": "eth0" } }
// Cable PC1 eth0 <-> R1 e0/0 (pass the name; it's converted to index 0)
connect_nodes(lab_path="/ospf-lab.unl",
node1_id=2, node1_interface=0,
node2_id=1, node2_interface="e0/0")
// → "Connected node 2 (if 0->0) to node 1 (if e0/0->0)."
// Inject R1's startup-config (loads on boot because the node has config enabled)
push_config(lab_path="/ospf-lab.unl", node_id=1, config="""hostname R1
!
interface Ethernet0/0
ip address 10.9.9.1 255.255.255.0
no shutdown
!
end
""")
// → "Configuration pushed to node 1."
// Boot everything
start_all(lab_path="/ospf-lab.unl")4. Configure a VPCS host (startup-config is ignored — use the live console)
VPCS won't apply an injected config, so set its IP on the running console:
start_node(lab_path="/ospf-lab.unl", node_id=2)
send_console(lab_path="/ospf-lab.unl", node_id=2,
commands=["ip 10.9.9.10/24 10.9.9.1", "save"])5. Add a dot1Q subinterface (dropped from startup-config — use the console)
send_console(lab_path="/ospf-lab.unl", node_id=1, commands=[
"enable",
"configure terminal",
"interface e0/0.10",
"encapsulation dot1Q 10",
"ip address 10.1.10.1 255.255.255.0",
"end",
"write memory"
])6. Persist runtime changes, then read them back
export_config(lab_path="/ospf-lab.unl", node_id=0) // export ALL running nodes
get_node_config(lab_path="/ospf-lab.unl", node_id=1) // returns the saved startup-config text7. Inspect a full topology
get_lab returns lab metadata, every node (with status, console port, and the interface index→name map), and all networks — the one call an agent uses to understand a lab before touching it:
get_lab(lab_path="/bgp-vlan-sites.unl"){
"lab": { "name": "bgp-vlan-sites", "filename": "bgp-vlan-sites.unl", "id": "e89dbdaa-…" },
"nodes": {
"1": { "id": 1, "name": "R1", "type": "iol", "status": 0, "template": "i86bi_linux_l3",
"ethernets": { "0": {"name":"e0/0","network_id":3}, "16": {"name":"e0/1","network_id":4}, … } },
"3": { "id": 3, "name": "SW-A", "type": "iol", "status": 0, "template": "i86bi_linux_l2", … },
"6": { "id": 6, "name": "PC-A2","type": "vpcs", "status": 2, … } // status 2 = running
// …
},
"networks": { "1": {"id":1,"name":"Network 1","type":"bridge"}, … }
}8. Tear down the running lab
// Default: destroy the running session but KEEP the .unl file on disk
delete_lab(lab_path="/ospf-lab.unl")
// → { "code": 200, "message": "success" }
// Opt-in: destroy the session AND permanently delete the .unl file
delete_lab(lab_path="/ospf-lab.unl", delete_file=true)
// → { "destroyed": { "code": 200, "message": "success" },
// "file_deleted": { "code": 200, "status": "success", "message": "Lab has been deleted (60022)." } }Deleting the
.unlfile. EVE-NG's path-in-URLDELETE /api/labs/<path>was removed in PNETLab (returns60038). Deletion is instead a body-based call —DELETE /api/labswith a JSON body{"path": "/lab.unl"}(the same request PNETLab's own file manager makes). Confirmed against a live 5.3.x box:
The lab's own running session is torn down first (this tool does that for you via
factory/destroy).The path goes in the JSON body, not the URL, and the
.unlsuffix is required (without it: "Lab File is not founded").It's a per-lab delete: works for labs at the workspace root or in a subfolder, and is not blocked by other running labs.
Gotchas & hard-won lessons
These are baked into the client so you rarely hit them, but knowing them helps:
IOL interface indices are
0 / 16 / 32 / 48, not0/1/2/3. IOL groups ports in fours:e0/0→0,e0/1→16,e0/2→32,e0/3→48 (portgroup*16).connect_nodesaccepts either the index or ane0/xname and converts for you. VPCS uses index0(eth0).Never edit a node after wiring it. A partial
nodes/editsilently drops the node's interface bindings (its links). To avoid it, nodes are created with startup-config already enabled (config=1), sopush_confignever needs a follow-up edit.VPCS ignores injected startup-config. Pushing a "config" stores it but the node won't apply it at boot — set the VPCS IP on the live console via
send_console(ip 10.x.x.x/24 gw, thensave).IOL L2 defaults to VTP server mode, so VLANs live in
vlan.datand an injectedvlan Nline is ignored. Addvtp mode transparentbeforevlan Nin switch configs so VLANs persist in running/startup config.dot1Q subinterface IPs get dropped from startup-config on IOL. Add them live with
send_console, thenexport_configto persist.The console is single-connection. A node's telnet console allows only one client. Close any open browser console tab for a node before
send_console, or guacd will refuse the tunnel (the client retries a few times with backoff to ride out transient contention).delete_labkeeps the file by default. It destroys the running session/instance and leaves the.unlon disk. Passdelete_file=trueto also permanently remove the file (works at the root or in a subfolder, and isn't blocked by other running labs). The legacy EVE-NG path-in-URLDELETE /api/labs/<path>route is gone (returns60038); deletion usesDELETE /api/labswith{path}in the JSON body instead.Single session per account. Opening a lab (any tool call) evicts other sessions for that user. Use a dedicated
mcp-userso the MCP and your browser don't fight. If the cookie is rotated mid-run, the client auto-re-logins, re-opens the lab, and retries once.
Troubleshooting
Symptom | Likely cause & fix |
| Wrong |
Calls work, then start failing with | Your session cookie was rotated (someone else logged in as the same user). Use a dedicated |
TLS / certificate errors | Self-signed lab cert → set |
| You wired an IOL port with the wrong index. Use |
A pushed VLAN or subinterface IP vanished | IOL L2 VTP (add |
| Node isn't running, or its console is held by an open browser tab (single-connection). Close the tab and retry; ensure |
A VPCS host has no IP after boot | Expected — VPCS ignores startup-config. Set it with |
| The |
| The lab path must include the |
Run the server with INFO logging (it logs to stderr by default) to watch auth, lab-open, and retry events while debugging.
Architecture
src/pnetlab_mcp_server/
├── __main__.py # entry point: configures logging, runs the MCP server over stdio
├── server.py # FastMCP tool definitions (the 31 tools) — thin wrappers over the client
├── guac_screen.py# Guacamole draw-instruction compositor (read_console screenshots)
├── client.py # PNetLabClient: async httpx client that speaks PNETLab's session API
└── models.py # Pydantic models (NodeType, AddNodeInput) for tool inputsserver.pybuilds aFastMCP("pnetlab-mcp-server")instance and registers each tool with@mcp.tool. Tools do input shaping, call the client, and format the result as JSON text. A single lazily-constructedPNetLabClientis shared across calls.client.pyhides PNETLab's session model behind an EVE-NG-shaped interface. Key pieces:login()→ offline login at/store/public/auth/login/login._ensure_lab()→ opens the requested lab viafactory/createonly when it differs from the currently bound lab._request()→ wraps every call with transparent re-auth + lab re-open + single retry on session expiry (90001).iol_interface_index()→ thee0/x⇄0/16/32/48translation.send_console()→ opens a Guacamole WebSocket tunnel, keeps it alive by echoingsyncframes, and types keystrokes; retries on single-connection contention.
The MCP layer speaks stdio; the client layer speaks HTTPS + WSS to PNETLab.
Development & testing
Install dev extras and run the linter:
pip install -e ".[dev]"
ruff check .The tests/ directory contains live integration probes (they hit a real PNETLab box using your PNETLAB_* env vars — not offline unit tests). The most useful is a full lifecycle drive:
# Requires PNETLAB_HOST / PNETLAB_USERNAME / PNETLAB_PASSWORD in the environment.
python tests/live_phase3.pyIt runs: login → list templates → create lab → add VPCS + IOL nodes → connect → push & read config → start → stop → delete → list, printing a pass/fail summary. Other probe_*.py / guac_*.py scripts were used to reverse-engineer individual endpoints and the Guacamole console tunnel.
These scripts create and destroy a throwaway lab. Point them at a lab server you control.
Project status
Alpha. The full lifecycle — login → create/clone/rename/move/delete labs & folders → list templates → add/connect/disconnect/wipe/delete nodes → push/get/export config → start/stop → read topology → console read + write → backup/restore (.zip) → list/stop running labs — is implemented and verified against a live PNETLab 5.3.x server. read_console returns a rendered screenshot of the terminal (Guacamole draws text as image tiles, so there's no text on the wire — the framebuffer is reconstructed and a vision model reads it). API endpoint shapes were reverse-engineered against one build; other PNETLab versions may differ.
Not yet supported: live packet capture (wireshark/capture). It's a browser Guacamole session running Wireshark in a container (console_guac_link?node_id=N&type=wireshark returns the guac link, and the framebuffer can be screenshotted the same way read_console works). The blocker is the POST /api/labs/session/wireshark/capture trigger, which returns "Please capture again" to a bare API call regardless of node state/ordering — the web UI performs an additional handshake step that hasn't been fully reverse-engineered yet.
Contributions, bug reports, and version-compatibility notes are welcome.
License
Apache-2.0. Fork of axiom-works-ai/eveng-mcp-server (Apache-2.0).
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Latest Blog Posts
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/omichelbraga/pnetlab-mcp-server'
If you have feedback or need assistance with the MCP directory API, please join our Discord server