Skip to main content
Glama

cml-mcp-server

An MCP server for Cisco Modeling Labs (CML 2.x): explore, build, run and troubleshoot network labs from an AI assistant. It's built on stutters-mcp-core and tested against CML 2.10.

Not affiliated with or endorsed by Cisco Systems, Inc. "Cisco" and "Cisco Modeling Labs" are trademarks of Cisco Systems, Inc.

  • 72 tools in three tiers: 39 read, 25 write, 8 dangerous (incl. cml_api_call for non-GET), and the prompt cml_build_test_lab. Write and dangerous tools (and the prompt) are off by default.

  • Names or ids everywhere. Labs take a title or a UUID, nodes take a label, and links take r1<->r2 or r1:Ethernet0/0<->r2:Ethernet0/0.

  • Preview-first writes. A write call without confirm changes nothing. It returns a preview (diffs, chosen interfaces, blockers CML would reject) and a single-use confirm token.

  • Task tools. cml_build_lab builds a whole lab from a compact spec (day-0 configs, extra config files, console logins, external connectors), cml_wait_for_converged reports boot progress (and can wait for a console message), cml_check_reachability proves what a lab can ping, and cml_diff_lab compares a lab with another lab or a saved export.

  • Real platforms on the console. IOS/IOS-XE, NX-OS (POAP skipped), FTD CLISH (no enable, mode='exec' for configure manager add ...), Linux. Questions ([confirm], [yes/no]) are answered on request, runaway commands get Ctrl-C.

  • Lab recipes for the other suite servers. FTDv for FMC, an unregistered FTDv HA pair, IOS-XE routers for Catalyst Center, a Cat9kv 802.1X switch (IBNS 2.0) with MAB and EAP-TLS clients for ISE, an AD-user 802.1X client behind an FMC-managed FTD for user-based firewalling: cml_list_recipes, cml_build_recipe, cml_verify_recipe (pass/fail contract, incl. ISE live sessions), cml_run_recipe_action (e.g. switch the logged-in user) and the prompt cml_build_test_lab; cml_plan_licences says which labs to stop. See src/cml_mcp/recipes/README.md.

  • Test oracles. cml_diff_node_config shows what another server changed on a device; cml_capture / cml_summarize_capture decode RADIUS, EAPOL, OSPF, DHCP and more from a link capture.

  • Degraded networks and diagrams. cml_apply_link_scenario puts named impairments on a link (wan-good, wan-satellite, lossy-wifi, brownout, or a timed flap); cml_export_topology draws a lab as Mermaid or draw.io with addresses, and cml_annotate_lab makes a lab self-describing.

  • Spec escape hatch. cml_search_spec and cml_get_definition read the controller's own OpenAPI document. cml_api_call reaches any endpoint: GET in the read tier, other methods in the dangerous tier.

Quickstart (5 minutes)

You need: a CML account for the server, a regular (non-admin) user is enough (read-only to start: labs shared with it as lab_view + lab_exec and a resource pool with 0 node licences, see docs/least-privilege.md). Never the built-in admin.

A. On your laptop (stdio), from a release wheel. Every GitHub release has a wheel with stutters-mcp-core bundled in; it needs only PyPI dependencies.

WHEEL=https://github.com/dr-stutters/cml-mcp-server/releases/download/v0.11.0/cml_mcp_server-0.11.0-py3-none-any.whl
export CML_URL=https://cml.example.com CML_USERNAME=mcp-cml-viewer CML_PASSWORD='...'
uvx --from "$WHEEL" cml-mcp-server check            # preflight: reachability, TLS, login, version, admin flag
claude mcp add cml -e CML_URL=$CML_URL -e CML_USERNAME=$CML_USERNAME -e CML_PASSWORD="$CML_PASSWORD" \
  -- uvx --from "$WHEEL" cml-mcp-server              # stdio is the default transport

Claude Desktop (claude_desktop_config.json), same idea:

{
  "mcpServers": {
    "cml": {
      "command": "uvx",
      "args": ["--from", "https://github.com/dr-stutters/cml-mcp-server/releases/download/v0.11.0/cml_mcp_server-0.11.0-py3-none-any.whl", "cml-mcp-server"],
      "env": {"CML_URL": "https://cml.example.com", "CML_USERNAME": "mcp-cml-viewer", "CML_PASSWORD": "..."}
    }
  }
}

B. On a shared host (HTTP), from the image. Multi-arch (amd64/arm64), signed, with an SBOM: ghcr.io/dr-stutters/cml-mcp-server:<version> (or :latest).

docker run --rm -e CML_URL=https://cml.example.com -e CML_USERNAME=mcp-cml-viewer -e CML_PASSWORD='...' \
  ghcr.io/dr-stutters/cml-mcp-server cml-mcp-server check
docker run --rm ghcr.io/dr-stutters/cml-mcp-server cml-mcp-server token alice   # one token per person
cp .env.example .env && chmod 600 .env    # CML_*, MCP_AUTH_TOKENS=alice:sha256:...
docker compose up -d                      # pulls the image; http://localhost:8032/mcp

For a team, use docs/deploying.md: read-only by default, per-user tokens in the audit log, several controllers, the shared node-licence budget, TLS with Caddy, upgrades.

check runs without an MCP client and exits 1 on a failure (--json for scripts, --strict to fail on warnings, --instance training for one controller). It reports reachability and TLS trust of the controller API, the login, the CML version against the tested matrix (2.10; 2.7 minimum), the API user's admin flag against the enabled tiers (an admin behind a read-only server warns; a regular user with the dangerous tier on warns that admin-only tools will fail) and, with writes on, the node licences the account may start (its resource pool, else the controller licence):

  OK    config                 https://cml.example.com; tiers enabled: read, write
  OK    reachability[api]      CML API answered; logged in as mcp-cml-operator (41 ms)
  OK    tls                    certificate verified against the system trust store
  OK    auth                   credentials accepted
  OK    role                   role 'mcp-cml-operator (regular user)' allows the enabled tier(s): read, write
  OK    version                Cisco Modeling Labs 2.10.0+build.13: tested
  WARN  licences               resource pool 'mcp-cml-operator-pool' leaves 0 node licences free (12 of 12 in use): ...
                               -> Raise the pool's licences (Tools > Resource Pools; ...) or stop labs (cml_plan_licences says which).

cml-mcp-server serve --http serves HTTP (needs MCP_AUTH_TOKENS); cml-mcp-server config shows the effective settings and where each came from, without contacting CML.

From source

git clone https://github.com/dr-stutters/cml-mcp-server && cd cml-mcp-server
python3 scripts/bundle_core.py vendor       # bundles the pinned stutters-mcp-core as cml_mcp._core (needs git)
cp .env.example .env && chmod 600 .env      # set CML_URL, CML_USERNAME, CML_PASSWORD
uv sync
uv run cml-mcp-server check

The shared stutters-mcp-core library (with its tasks extra) is bundled into the package as cml_mcp._core (core ADR-005): the wheel, the sdist and the Docker image need nothing but their PyPI dependencies, with no git dependency and no token. uv build writes the wheel to dist/. The pinned core tag is [tool.bundle-core] in pyproject.toml and cml_status reports it as core_version. The vendored copy is generated, not committed: scripts/bundle_core.py vendor writes it, check reports drift, and update --tag vX.Y.Z moves the pin (then uv lock). A plain git checkout has no cml_mcp/_core, so install from a release wheel rather than git+https://....

Claude Code: stdio (local)

claude mcp add cml -- uv --directory /path/to/cml-mcp-server run cml-mcp-server
# to allow changes:
claude mcp add cml -e MCP_ENABLE_WRITES=true -- uv --directory /path/to/cml-mcp-server run cml-mcp-server

Claude Code: HTTP (shared container)

uv run cml-mcp-server token alice            # prints a bearer token + the MCP_AUTH_TOKENS entry
claude mcp add --transport http cml http://cortex:8032/mcp --header "Authorization: Bearer <token>"

or in .mcp.json:

{
  "mcpServers": {
    "cml": {
      "type": "http",
      "url": "http://cortex:8032/mcp",
      "headers": { "Authorization": "Bearer ${CML_MCP_TOKEN}" }
    }
  }
}

Related MCP server: gns3-mcp

Tools

Read (always on)

Tool

What it does

cml_status

Version, API user, compute/controller health problems, licence summary, server config

cml_get_system_health

Controller and compute health flags, with a problem list

cml_get_resource_usage

CPU, RAM and disk per compute host; running nodes and allocated resources; licence capacity

cml_get_licensing

Smart Licensing registration and authorization, features, expiry

cml_list_labs / cml_get_lab

Labs with state and counts; one lab with convergence, node states and resources in use

cml_get_lab_topology

Compact wiring summary (node interface -> peer, links, conditioning); detail='full' for everything

cml_export_lab

Topology YAML (pyATS passwords masked unless include_credentials)

cml_list_lab_events

Change and state history, newest first, with the fields that changed

cml_get_pyats_testbed

The pyATS testbed CML generates (passwords masked)

cml_list_nodes / cml_get_node

Nodes with state, image and resources (optionally live CPU/RAM); one node with interfaces, peers and config

cml_get_node_console_log

Recent serial console output, escape codes stripped, optional grep

cml_run_commands

Run show, ping and traceroute (or ip addr etc. on Linux nodes) on one or more booted nodes through the CML console server, in parallel. Only read-only commands are allowed

cml_list_interfaces

Interfaces with MAC, state and peer; filter free or connected

cml_get_layer3_addresses

IPs per interface: snooped by CML on external segments, or read from every node's CLI (source='cli')

cml_check_reachability

Ping targets from nodes (per-OS syntax, VRF, FTD management) and return a pass/fail matrix with average RTT

cml_list_external_connectors

Networks an external_connector node can join (NAT, System Bridge...)

cml_list_sample_labs

Sample labs on the controller (filter by text or node type)

cml_list_links / cml_get_link

Links as node:interface pairs; one link with conditioning and capture status

cml_list_capture_packets

Decoded packets from a link capture (filter by protocol or text)

cml_list_node_definitions / cml_get_node_definition

What can be built: interfaces, resources, boot markers, default configs

cml_list_image_definitions

Installed images

cml_get_image_files

Disk images in CML_IMAGE_DIR on the server host, and the controller's drop folder

cml_wait_for_converged

Wait until a lab or node has booted, with progress notifications; console_pattern waits for a later console message

cml_diff_lab

Diff a lab with another lab or a saved YAML: nodes, interfaces, links, config diff (running_configs=true for live configs)

cml_diff_node_config

Running config (from the console) vs day-0, startup, another node or a baseline; secrets masked

cml_summarize_capture

Decoded summary of a link capture: protocol mix, conversations, RADIUS/EAPOL/OSPF/DHCP/ARP/ICMP highlights

cml_plan_licences

Licences in use per lab, what a lab or recipe needs, which labs to stop

cml_plan_replica

Map a real site's devices (Catalyst Center / ISE / FMC inventories) to CML node definitions within a node/RAM budget: ready cml_build_lab spec, gaps, dropped devices

cml_list_recipes / cml_verify_recipe

Test-lab recipes (no secrets returned); pass/fail verify contract of a running recipe lab (or a copy: lab, substitute)

cml_get_link_scenarios

Named link scenarios with netem values and expected round-trip ping deltas; with a lab, its conditioned links and flap jobs

cml_export_topology

Mermaid, draw.io or JSON diagram of a lab with interface addresses (day-0 config, snooped or CLI)

cml_list_annotations

A lab's drawn annotations (text, shapes), marking the ones this server wrote

cml_search_spec / cml_get_definition / cml_api_call

OpenAPI search, operation and schema details, raw GET

Write (MCP_ENABLE_WRITES=true, all preview-first)

Tool

What it does

cml_build_lab

Build a lab from nodes=[{label, definition, config?, files?, credentials?, ...}] and links=["r1 r2", "r1:Ethernet0/1 sw1"]. Interfaces are picked automatically, external connectors and node licences are checked. Optionally start it (and wait)

cml_load_sample_lab

Create a lab from a sample lab

cml_create_lab / cml_import_lab

Create an empty lab, or create one from topology YAML (node definitions are checked)

cml_start_lab / cml_stop_lab

Start (waits for convergence) or stop a lab

cml_stop_labs

Stop several labs (a list, or every running lab except keep) with one preview: licences and RAM freed per lab, protected labs refused

cml_add_node / cml_update_node

Add a node; change its label, tags, position, resources or console login

cml_set_node_config

Replace a node's day-0 config; the preview shows a unified diff

cml_set_node_state

Start or stop one node

cml_extract_node_configs

Save running configs into the stored node configs

cml_configure_node

Push config lines to a running device over its console (configure terminal ... end, optionally write memory), or exec-mode lines (mode='exec', e.g. FTD configure manager add). Rejected lines are reported

cml_connect_nodes

Link two nodes (first free interfaces, or the ones you name)

cml_set_link_condition

Add latency, jitter, loss, bandwidth limits, duplication or corruption to a link, or clear it

cml_set_link_state

Take a link down or bring it up (cable pull)

cml_start_capture / cml_stop_capture

Packet capture on a link

cml_capture

Capture for N seconds and return the decoded summary in one call

cml_capture_wireless

Capture the simulated 802.11 medium at a wireless-ap / wireless-client node and decode it (SSIDs, auth/assoc, deauth reasons, EAPOL 4-way handshake, EAP identity/method)

cml_build_recipe

Build a packaged test-lab recipe (optionally start it), with annotations; title + substitute build a copy on other addresses

cml_run_recipe_action

Run one of a recipe's packaged actions on its lab, e.g. ise-fmc-identity login-alice / login-bob / logoff (802.1X as another AD user); the command comes from the recipe, never the caller

cml_apply_link_scenario

Apply a named scenario to a link: wan-good, wan-satellite, lossy-wifi, brownout, or flap (timed down/up cycles in the background, link always restored)

cml_clear_link_scenarios

Stop flap jobs and remove conditioning from a lab's links

cml_annotate_lab

Add notes, per-node address labels and the recipe's purpose/credential references as text annotations (re-runnable)

cml_create_image_definition

Make an uploaded disk image usable by a node definition (admin)

Prompts (write tier): cml_replicate_site(site, fidelity, max_nodes) - Catalyst Center site (+ FMC firewalls, ISE NADs) -> cml_plan_replica -> licence check -> cml_build_lab -> report gaps. cml_build_test_lab(recipe, reuse) - recipe -> licence plan -> build/start -> converge -> verify -> hand-off block for the FMC / Catalyst Center / ISE server.

Dangerous (MCP_ENABLE_DANGEROUS=true as well)

Tool

What it does

cml_wipe_lab / cml_wipe_node

Erase node disks (force=true stops first)

cml_delete_lab / cml_delete_node / cml_delete_link

Delete (force=true stops and wipes first where CML requires it)

cml_upload_image

Stream a disk image from CML_IMAGE_DIR to the controller (never through MCP), SHA-256 and disk-space checked (admin)

cml_delete_image

Delete an image definition (refused while nodes use it) and/or a drop-folder file (admin)

cml_api_call (non-GET)

Raw POST, PUT, PATCH or DELETE, preview-first

Device CLI uses the controller's console server: SSH to the controller with the CML credentials, then open /<lab>/<node>/0. This is the same path pyATS testbeds use. Device logins come from the lab's pyATS testbed (node pyATS credentials, or the definition defaults).

CML only accepts config, RAM/CPU and new-interface changes while a node is wiped (DEFINED_ON_CORE). It only deletes wiped nodes and labs. The previews say so and point to the tool that fixes it.

Configuration

Variable

Default

Meaning

CML_URL

(required)

Controller URL, e.g. https://cml.example.com

CML_USERNAME / CML_PASSWORD

API user (JWT login via /api/v0/auth_extended)

CML_TOKEN

A pre-issued JWT instead of username/password

CML_INSTANCES

More controllers, each with CML_<NAME>_URL, _USERNAME, _PASSWORD...; tools take an instance argument (docs/deploying.md)

CML_VERIFY_SSL

true

false for self-signed lab controllers (logged as a warning)

CML_CA_BUNDLE

CA file to verify the controller certificate

CML_TIMEOUT / CML_RETRIES

30 / 2

Per-request timeout (s) and retries for idempotent calls

CML_RATE_LIMIT / CML_MAX_CONCURRENCY

none / 8

Client-side throttling

CML_WAIT_TIMEOUT

600

Default wait for start/stop/convergence (s)

CML_NODE_LICENSE_LIMIT

detected

Running licensed-node cap. Detected from /licensing when unset (base licence nodes + CML_NODE_COUNT expansion; 5 in evaluation mode). Start/build previews refuse to exceed it; cml_plan_licences says which labs to stop

CML_CONSOLE_PORT

22

SSH port of the CML console server (used for device CLI)

CML_CONSOLE_LOCK_WAIT

60

Seconds a console call waits for another call on the same node before reporting it busy

CML_LAB_SECRETS_FILE

dotenv file with the LAB_* values that recipes reference as ${LAB_*} (device passwords, keys, communities). LAB_* environment variables override it. Recipe builds name any missing variable

CML_IMAGE_DIR

The only directory cml_upload_image reads (images are streamed from it; uploads are off without it)

CML_PROTECTED_LABS / CML_MANAGED_LABS

Safety rails (docs/safety.md): labs no change may touch; labs the dangerous tools may wipe/delete (title globs or ids)

CML_CONFIG_DENY_DEFAULTS / CML_CONFIG_DENY

true /

cml_configure_node refuses destructive device commands (write erase, reload, format, delete, ...); one extra regex

CML_TLS_CHECK_HOSTNAME

true

false only with CML_CA_BUNDLE = the controller's own certificate (pinned; scripts/pin_controller.py)

CML_CONSOLE_KNOWN_HOSTS

known_hosts file for the console server's host key. When unset, the key is checked against ~/.ssh/known_hosts if CML_VERIFY_SSL=true, and not checked otherwise

MCP_TRANSPORT

stdio

http for a network server

MCP_HOST / MCP_PORT / MCP_PATH

0.0.0.0 / 8000 / /mcp

HTTP listener

MCP_AUTH_TOKENS / MCP_AUTH_TOKENS_FILE

name:sha256:<hex> entries; HTTP refuses to start without one

MCP_ENABLE_WRITES / MCP_ENABLE_DANGEROUS

false

Register the write and dangerous tiers

MCP_TASKS / MCP_TASKS_URL

false / memory://

Run the long tools (image upload, builds/starts with wait, convergence waits, recipe verify, captures) as MCP background tasks for clients that support them; others still call them synchronously

MCP_CONFIRM_TTL

600

Confirm-token lifetime (s)

MCP_CACHE_DIR

~/.cache/cml-mcp-server

OpenAPI index cache ("" disables it)

MCP_MAX_RESPONSE_CHARS

40000

Response size budget

MCP_AUDIT_LOG / LOG_LEVEL

stderr / INFO

Audit trail of previews and applied writes

Least-privilege account

Run the server as a regular (non-admin) CML user, never admin. Tested on CML 2.10 with two accounts, both created by scripts/seed_lab.py --least-privilege. Full guide: docs/least-privilege.md.

  • Read tier: mcp-cml-viewer, labs shared with its group as lab_view + lab_exec (consoles need lab_exec), and a resource pool with 0 node licences. Every read tool works except cml_get_licensing (admin-only in CML).

  • Write tier: mcp-cml-operator owns the labs it builds; its resource pool caps licences, CPU and RAM, and the start previews respect the pool.

  • As a non-admin, licence planning counts only the labs the account can see (scope says so), and consoles of shared labs work.

Docker (HTTP)

docker compose up -d                   # pulls ghcr.io/dr-stutters/cml-mcp-server (MCP_IMAGE_TAG, default latest)
docker compose up -d --build           # or build from this checkout (no token: core is bundled)
curl -s http://localhost:8032/health   # {"status":"ok"}

The container runs as non-root on a read-only root filesystem with all capabilities dropped. The spec cache lives in a named volume. Shared hosts (TLS, several controllers, per-user tokens, the shared licence budget, upgrades): docs/deploying.md.

Development

uv run ruff check && uv run ruff format --check && uv run mypy && uv run pytest
uv run pytest tests/live -m live           # against the lab in .env; creates and deletes mcp-test-* labs
uv run python scripts/capture_fixtures.py mcp-test-probe   # refresh redacted fixtures

scripts/seed_lab.py [--start] [--recreate] creates a reusable lab, mcp-test-seed. It has two IOL-XE routers running OSPF, an IOL-L2 switch and two Alpine hosts (h1 10.0.10.10 and h2 10.0.20.10, which can ping each other). Device logins are cisco/cisco.

Note: CML caps running licensed nodes across all labs (an unregistered controller in evaluation mode allows 5; the test controller is now Enterprise with 20 + 50 expansion = 70). Unmanaged switches and external connectors don't count. cml_plan_licences shows the cap and which labs to stop.

Live tests

Live tests build throwaway labs named mcp-test-* from fast-booting IOL nodes, run every tool, and always delete what they created. They read the controller from .env (CML_URL plus a login) and skip without one. Optional inputs (commented in .env.example); the tests that need them skip when they are unset:

Variable

Example

Used by

CML_IMAGE_DIR

/srv/cml-images

test_live_images.py: local directory the image is uploaded from

CML_LIVE_IMAGE_FILE

cat8000v-universalk9_16G_serial.17.16.01a.qcow2

test_live_images.py: a disk image file in CML_IMAGE_DIR

CML_LIVE_IMAGE_NODE_DEFINITION

cat8000v (the default)

test_live_images.py: node definition for that image

CML_RO_USERNAME / CML_RO_PASSWORD, CML_RW_USERNAME / CML_RW_PASSWORD

mcp-cml-viewer, mcp-cml-operator

test_least_privilege.py (scripts/seed_lab.py --least-privilege)

-m live_recipes verifies only recipe labs that are already running on the controller.

Licence

MIT

Related MCP Connectors

Related MCP Servers