PNETLab MCP Server
# PNETLab MCP Server
> Give Claude (or any MCP client) programmatic control of a [PNETLab](https://pnetlab.com) network-emulation lab — build topologies, wire links, push device configs, and boot the lab, all from natural language.
[](LICENSE)
[](https://www.python.org/downloads/)
[](https://modelcontextprotocol.io)
[](#project-status)
An [MCP](https://modelcontextprotocol.io) (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`](https://github.com/axiom-works-ai/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
- [Why this exists](#why-this-exists)
- [Features](#features)
- [How PNETLab differs from EVE-NG](#how-pnetlab-differs-from-eve-ng)
- [Requirements](#requirements)
- [Installation](#installation)
- [Configuration](#configuration)
- [Register with an MCP client](#register-with-an-mcp-client)
- [Tool reference](#tool-reference)
- [Worked examples](#worked-examples)
- [Gotchas & hard-won lessons](#gotchas--hard-won-lessons)
- [Troubleshooting](#troubleshooting)
- [Architecture](#architecture)
- [Development & testing](#development--testing)
- [Project status](#project-status)
- [License](#license)
---
## 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 (`.zip` export/import), and running-lab control.
- **Auto lab-binding** — every tool takes a `lab_path` and opens it for you; no explicit "open lab" dance required.
- **IOL interface translation** — pass `e0/0`/`e0/1` and the server converts to PNETLab's `0/16/32/48` indices automatically.
- **Startup-config injection** — nodes are created with configs enabled, so `push_config` loads 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 mem` survives 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** | `POST /api/auth/login` | `POST /store/public/auth/login/login` (offline, plaintext, empty captcha) |
| **Target a lab** | path in every URL: `/api/labs/{path}/…` | open one lab → all ops go to `/api/labs/session/*` |
| **Open a lab** | implicit | `POST /api/labs/session/factory/create {path}` |
| **List labs** | `GET /api/folders/{path}` | `GET /api/folders` / `/api/folders/<path>` (**no** trailing slash) |
| **Link nodes** | create network + attach interfaces | `POST /api/labs/session/networks/p2p` (auto-creates the bridge) |
| **Push config** | `PUT …/config` | `POST /api/labs/session/configs/edit {id,data}` |
| **Tear down lab** | delete via path | `POST /api/labs/session/factory/destroy {lab_session}` |
| **Delete lab file**| `DELETE /api/labs/{path}` (path in URL) | path-in-URL removed (`60038`) → `DELETE /api/labs` with `{path}` in body |
| **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` + `pillow` are only needed for the console tools (`send_console`, `read_console`); everything else is plain HTTP.
- An MCP client — [Claude Code](https://docs.claude.com/en/docs/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:
```bash
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:
```bash
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**):
```bash
python -m pnetlab_mcp_server
# or
pnetlab-mcp-server
```
You 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:
```dotenv
# 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=1
```
| Variable | Default | Description |
| ---------------------- | ----------------------------- | --------------------------------------------------------------------------- |
| `PNETLAB_HOST` | `https://pnetlab.example` | Base URL of the PNETLab server. |
| `PNETLAB_USERNAME` | `admin` | Local (offline) login username. Prefer a dedicated `mcp-user`. |
| `PNETLAB_PASSWORD` | `pnet` | Local (offline) login password (sent plaintext over the offline endpoint). |
| `PNETLAB_VERIFY_SSL` | `true` | `false` disables TLS verification (self-signed certs). |
| `PNETLAB_HTML_CONSOLE` | `1` | `1` HTML5 console, `0` native console; changes the console link scheme. |
---
## Register with an MCP client
### Claude Code (CLI)
```bash
claude mcp add pnetlab \
--env PNETLAB_HOST=https://pnetlab.example \
--env PNETLAB_USERNAME=mcp-user \
--env PNETLAB_PASSWORD=pnet \
-- python -m pnetlab_mcp_server
```
Point 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`):
```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:
```json
{
"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:**
1. **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).
2. **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 the `mcp-user` session for that lab.
3. **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` | `folder="/"` | List labs and subfolders in a workspace folder. Returns each lab's `name`, `path`, and `modified` time. |
| `get_lab` | `lab_path` | Full topology: lab metadata, all nodes (status + the interface index→name map you need for `connect_nodes`), and networks. |
| `create_lab` | `name`, `description=""`, `path="/"`, `author=""`, `version="1"`, `body=""` | Create a new lab and open it as the active session lab. |
| `open_lab` | `lab_path` | Bind an existing lab to the session. Usually unnecessary (every tool auto-opens its `lab_path`); handy to switch the active lab. |
| `delete_lab` | `lab_path`, `delete_file=false` | Tear down a lab's **running session** (stop & wipe nodes, unbind). By default the `.unl` file is kept; pass `delete_file=true` to also **permanently delete** the `.unl` from disk. See the [note below](#deleting-the-unl-file). |
| `clone_lab` | `source_lab_path`, `name` | Clone a lab (topology + configs) into the same folder under a new name. Great for templating. |
| `rename_lab` | `lab_path`, `new_name` | Rename a lab's `.unl` file (PNETLab sanitizes special characters out of the new name). |
| `move_lab` | `lab_path`, `folder` | Move a lab into another workspace folder (must already exist — see `create_folder`). |
| `list_running_labs` | *(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 `.unl`. |
| `stop_lab` | `lab_path` | 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. |
| `download_lab`| `lab_path`, `save_path=""` | Export a lab to a `.zip` backup and save it locally (defaults to the current directory). Round-trips with `upload_lab`. |
| `upload_lab` | `zip_file_path`, `folder="/"` | Import a lab from a local `.zip` (from `download_lab`) into a workspace folder (must already exist). |
### Images / templates
| Tool | Parameters | Description |
| --------------------- | ---------- | --------------------------------------------------------------------------------------- |
| `list_images` | *(none)* | List installed node templates/images (uninstalled `.missing` entries filtered out). |
| `get_template_options`| `template` | Get a template's add-node option schema (valid ram/ethernet/image/nvram) so `add_node` params are right. Template keys come from `list_images`. |
### Folders
| Tool | Parameters | Description |
| --------------- | ------------------- | --------------------------------------------------------------------------------- |
| `create_folder` | `name`, `path="/"` | Create a workspace subfolder for organizing labs. |
| `delete_folder` | `folder_path` | Delete a folder **and everything in it** (blocked with `error_folder_running` if any lab under it is running). |
### Node management
| Tool | Parameters | Description |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| `add_node` | `lab_path`, `template`, `name=""`, `node_type="iol"`, `ram=1024`, `cpu=1`, `ethernet=1`, `serial=0`, `image=""`, `icon="Router.png"`, `console="telnet"`, `left=100`, `top=200`, `config="1"`, `delay=0` | Add a node. Created with startup-config **enabled** so `push_config` loads on boot. |
| `start_node` | `lab_path`, `node_id` | Start a single node. |
| `stop_node` | `lab_path`, `node_id` | Stop a single node. |
| `start_all` | `lab_path` | Start every node in the lab. |
| `stop_all` | `lab_path` | Stop every node in the lab. |
| `get_node_status` | `lab_path`, `node_id` | Node status (`0`=stopped, `1`=building, `2`=running), console info, and interfaces. |
| `delete_node` | `lab_path`, `node_id` | Delete a single node (and its links). Delete *before* re-wiring neighbors — editing links after can drop bindings. |
| `wipe_node` | `lab_path`, `node_id` | 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):
| `template` | `node_type` | What it is | Suggested `icon` |
| ------------------ | ----------- | --------------------------------- | ----------------- |
| `vpcs` | `vpcs` | Virtual PC (lightweight test host) | `Desktop.png` |
| `i86bi_linux_l3` | `iol` | Cisco IOL L3 (router) | `Router.png` |
| `i86bi_linux_l2` | `iol` | Cisco IOL L2 (switch) | `Switch.png` |
| `c3725`, `c7200`… | `dynamips` | Classic Dynamips IOS routers | `Router.png` |
| `docker` | `docker` | Docker container node | *(varies)* |
### Configuration
| Tool | Parameters | Description |
| ----------------- | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `push_config` | `lab_path`, `node_id`, `config` | Store a startup-config (loads on next boot). For IOL **L2** switches, include `vtp mode transparent` before `vlan N` or the VLAN won't persist. |
| `get_node_config` | `lab_path`, `node_id` | Return a node's current startup-config text. |
| `export_config` | `lab_path`, `node_id=0` | Save a **running** node's live running-config into its startup-config (PNETLab "Export CFG"). `node_id=0` exports **all** running nodes. |
### Connectivity
| Tool | Parameters | Description |
| --------------- | ------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `connect_nodes` | `lab_path`, `node1_id`, `node1_interface`, `node2_id`, `node2_interface`, `network_name=""`, `network_type="bridge"` | Point-to-point link between two node interfaces. Accepts an index **or** an `e0/1`-style name (auto-converted). Wire the topology *before* starting nodes. |
| `disconnect_nodes` | `lab_path`, `node_id`, `node_interface` | 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. |
| `send_console` | `lab_path`, `node_id`, `commands` (list of lines) | Type commands on a node's **live** console via the Guacamole tunnel — for what `push_config` can't do. Node must be running. Write-only; verify with `export_config` or `read_console`. |
| `read_console` | `lab_path`, `node_id`, `commands=[]` (optional) | **Read** the live console — returns a PNG screenshot of the terminal (a vision model reads it). Optionally types `commands` first, then captures the output — do type+read in **one** call to avoid console contention. The read side `send_console` lacks. |
---
## 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
```jsonc
list_labs(folder="/")
```
```json
{
"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
```jsonc
list_images()
```
```json
{
"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
```jsonc
// 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:
```jsonc
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)
```jsonc
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
```jsonc
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 text
```
### 7. 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:
```jsonc
get_lab(lab_path="/bgp-vlan-sites.unl")
```
```jsonc
{
"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
```jsonc
// 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)." } }
```
<a id="deleting-the-unl-file"></a>
> **Deleting the `.unl` file.** EVE-NG's path-in-URL `DELETE /api/labs/<path>` was removed in PNETLab (returns `60038`). Deletion is instead a **body-based** call — `DELETE /api/labs` with a JSON body `{"path": "/lab.unl"}` (the same request PNETLab's own file manager makes). Confirmed against a live 5.3.x box:
> 1. The lab's own running session is torn down first (this tool does that for you via `factory/destroy`).
> 2. The path goes in the **JSON body**, not the URL, and the **`.unl` suffix is required** (without it: "Lab File is not founded").
> 3. 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`**, not `0/1/2/3`. IOL groups ports in fours: `e0/0`→0, `e0/1`→16, `e0/2`→32, `e0/3`→48 (`portgroup*16`). `connect_nodes` accepts either the index or an `e0/x` name and converts for you. VPCS uses index `0` (`eth0`).
- **Never edit a node after wiring it.** A partial `nodes/edit` silently drops the node's interface bindings (its links). To avoid it, nodes are created with startup-config **already enabled** (`config=1`), so `push_config` never 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`, then `save`).
- **IOL L2 defaults to VTP server mode**, so VLANs live in `vlan.dat` and an injected `vlan N` line is ignored. Add `vtp mode transparent` before `vlan N` in 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`, then `export_config` to 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_lab` keeps the file by default.** It destroys the running session/instance and leaves the `.unl` on disk. Pass `delete_file=true` to 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-URL `DELETE /api/labs/<path>` route is gone (returns `60038`); deletion uses `DELETE /api/labs` with `{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-user` so 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 |
| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Login failed` | Wrong `PNETLAB_USERNAME`/`PASSWORD`, or the account is a cloud (pnetlab.com) login — this server uses the **offline/local** login path. |
| Calls work, then start failing with `90001` | Your session cookie was rotated (someone else logged in as the same user). Use a dedicated `mcp-user`. The client auto-retries once regardless. |
| TLS / certificate errors | Self-signed lab cert → set `PNETLAB_VERIFY_SSL=false`. |
| `connect_nodes` "succeeds" but there's no link | You wired an IOL port with the wrong index. Use `e0/x` names (auto-converted) or the `0/16/32/48` indices; check the map from `get_lab`/`add_node`. |
| A pushed VLAN or subinterface IP vanished | IOL L2 VTP (add `vtp mode transparent`) or dot1Q-in-startup-config (add it live via `send_console`). See gotchas. |
| `send_console` fails / times out | Node isn't running, or its console is held by an open browser tab (single-connection). Close the tab and retry; ensure `websockets` is installed. |
| A VPCS host has no IP after boot | Expected — VPCS ignores startup-config. Set it with `send_console` (`ip …/24 gw`, `save`). |
| `list_labs` returns nothing for a subfolder | The `/api/folders/<path>` endpoint is path-sensitive and takes **no** trailing slash; pass e.g. `folder="/My Labs"`. |
| `delete_lab(delete_file=true)` → "Lab File is not founded" | The lab path must include the `.unl` suffix and be correct (`/folder/name.unl`). The tool normalizes this for you; check the `lab_path` you passed exists (`list_labs`). |
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 inputs
```
- **`server.py`** builds a `FastMCP("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-constructed `PNetLabClient` is shared across calls.
- **`client.py`** hides 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 via `factory/create` only 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()` → the `e0/x` ⇄ `0/16/32/48` translation.
- `send_console()` → opens a Guacamole WebSocket tunnel, keeps it alive by echoing `sync` frames, 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:
```bash
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:
```bash
# Requires PNETLAB_HOST / PNETLAB_USERNAME / PNETLAB_PASSWORD in the environment.
python tests/live_phase3.py
```
It 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](LICENSE). Fork of [`axiom-works-ai/eveng-mcp-server`](https://github.com/axiom-works-ai/eveng-mcp-server) (Apache-2.0).
TDQS
Scored across 31 tools
Each tool has a clearly distinct purpose. For example, add_node/clone_lab/create_lab cover different operations, connect_nodes/disconnect_nodes are opposites, and send_console/read_console handle input/output separately. Even similar verbs like start_all vs start_node are distinguished by scope.
All 31 tools use a consistent verb_noun pattern (e.g., add_node, delete_lab, get_lab, start_all). No mixing of camelCase, snake_case, or other conventions; the naming is uniform and predictable.
At 31 tools, the server covers a wide domain (lab lifecycle, node management, console interaction, configuration). While above the typical 3-15 range, each tool serves a specific function needed for network simulation, making the count reasonable for the scope.
The tool set covers CRUD for labs and nodes, console interaction, config management, and import/export. Minor gaps exist: no direct network management (beyond connections) and no tool to retrieve a node's running config (only startup-config). However, core workflows are well-supported.