Skip to main content
Glama
omichelbraga

PNETLab MCP Server

by omichelbraga
README.md
# 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: Apache-2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE)
[![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/)
[![MCP](https://img.shields.io/badge/MCP-compatible-6E56CF.svg)](https://modelcontextprotocol.io)
[![Status: Alpha](https://img.shields.io/badge/status-alpha-orange.svg)](#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

A3.6/5.0

Scored across 31 tools

Disambiguation5/5

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.

Naming Consistency5/5

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.

Tool Count4/5

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.

Completeness4/5

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.

Maintenance

ActivityInactive
ResponsivenessNo issues