mcp-eveng
MCP server for controlling EVE-NG network emulator instances via REST API, enabling lab/node/network management, topology editing, and device operations through natural language.
README
mcp-eveng
A Model Context Protocol server that lets LLM clients (Claude Desktop, Claude Code, or any other MCP host) drive an EVENG network emulator instance: create and edit labs, add/wire nodes and networks, start/stop/wipe devices, and browse templates, folders and users — all through the EVENG REST API.
Features
- Full coverage of the documented EVENG REST API: auth, system status, node templates, network types, folders, users, labs, lab networks, lab nodes (including start/stop/wipe/export), topology, links and pictures.
- Recursive lab search (
list_all_labs) with explicit loop protection, since EVE-NG's own API has no recursive-listing endpoint. - Every destructive tool (delete folder/user/network/node/lab) requires
in-band user confirmation via MCP elicitation before it does anything.
delete_labin particular resolves a lab by name across the whole tree, disambiguates if more than one matches, and never deletes more than one. - All three MCP transports:
stdio(default),sse, andstreamable-http(recommended for networked deployments), selected with a CLI flag. - DNS-rebinding Host-header protection and optional stateless streamable-http, both configurable.
- Async, typed, cookie-session-aware EVENG client with automatic re-login on session expiry.
- Configuration lives entirely in environment variables / a
.envfile — nothing is hardcoded. - Ships as an installable, PyPI-packagable Python distribution with a full unit test suite.
Installation
git clone https://github.com/madmickstar/mcp_eveng.git
cd mcp_eveng
pip install -e .
Or, for local development:
git clone https://github.com/madmickstar/mcp_eveng.git
cd mcp_eveng
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
Choosing a transport
Transport is a CLI flag, not an environment variable. --sse and
--http are mutually exclusive; passing neither runs stdio.
mcp_eveng # stdio (default)
mcp_eveng --sse # legacy SSE transport, network-exposed
mcp_eveng --http # Streamable HTTP transport, network-exposed (recommended)
- stdio (no flag): the MCP host (Claude Desktop, Claude Code, ...) launches
mcp_evengas a subprocess and supplies configuration directly in its ownenvblock — see the example below. No.envfile orMCP_*variable is needed in this mode at all. --sse/--http: you run the process yourself as a standalone network service, so you'll typically want a.envfile (or exported env vars) for both theEVENG_*connection settings and theMCP_*network settings described below.
Configuration
Copy .env.example to .env and fill in your EVENG server details:
cp .env.example .env
EVENG connection (always used, regardless of transport)
| Variable | Default | Description |
|---|---|---|
EVENG_HOST |
127.0.0.1 |
EVENG server IP or hostname — no scheme or port, those are separate variables below |
EVENG_PORT |
80 |
EVENG server port |
EVENG_PROTOCOL |
http |
http or https |
EVENG_USERNAME |
admin |
Login username |
EVENG_PASSWORD |
eve |
Login password |
EVENG_HTML5 |
-1 |
EVENG html5 login flag (-1 auto, 0 Pro/HTML5-only, 1 native console) |
EVENG_VERIFY_SSL |
true |
Verify TLS certs. Default is true; if your EVE-NG server uses HTTPS with a self-signed cert (common for EVE-NG Pro), you'll most likely need to set this to false |
EVENG_TIMEOUT_SECONDS |
30 |
HTTP request timeout |
EVENG_HOST rejects a value containing :// at startup (e.g.
https://172.16.130.14) with a clear error rather than a cryptic
getaddrinfo failed — put the scheme in EVENG_PROTOCOL instead.
Since EVENG_HOST is an IP/hostname only, always set EVENG_PORT and
EVENG_PROTOCOL explicitly to match your deployment rather than relying on
the http/80 defaults.
MCP network settings (only used with --sse or --http)
| Variable | Default | Description |
|---|---|---|
MCP_HOST |
127.0.0.1 |
Bind host |
MCP_PORT |
8000 |
Bind port |
MCP_HTTP_PATH |
/mcp |
Mount path for the Streamable HTTP app (--http) |
MCP_SSE_PATH |
/sse |
Mount path for the legacy SSE app (--sse) |
MCP_LOG_LEVEL |
INFO |
DEBUG, INFO, WARNING, ERROR, or CRITICAL |
MCP_ALLOWED_HOSTS |
(empty) | Comma-separated Host-header allowlist. Required when MCP_HOST is not a loopback address |
MCP_STATEFUL |
true |
false disables streamable-http session persistence |
All variables can also be set as real environment variables, which take
precedence over .env.
MCP_LOG_LEVEL: options and where logs go
Options are the standard Python logging levels — DEBUG, INFO, WARNING,
ERROR, CRITICAL (case-insensitive; an invalid value fails fast at
startup). Logs always go to stderr, never stdout, in every transport —
not just stdio — because stdout is reserved for the stdio JSON-RPC stream
and nothing else should ever print to it. There's no file logging built in;
redirect stderr yourself if you want persistent logs, e.g.
mcp_eveng --http 2>> mcp_eveng.log.
MCP_ALLOWED_HOSTS: DNS-rebinding protection
The mcp SDK validates the HTTP Host header on --sse/--http requests to
guard against DNS-rebinding attacks (TransportSecuritySettings).
When MCP_HOST is a loopback address (127.0.0.1, localhost, ::1), the
SDK auto-enables a loopback-only allowlist and you don't need to do anything.
When MCP_HOST is anything else (e.g. 0.0.0.0 to bind all interfaces), the
SDK does not auto-protect — mcp_eveng will refuse to start with a clear
error unless you set MCP_ALLOWED_HOSTS explicitly.
Format is a comma-separated list of host:port or host:* (any port)
entries, matching the SDK's native allowed_hosts syntax:
MCP_ALLOWED_HOSTS="192.168.1.100:8000,192.168.1.150:*"
MCP_STATEFUL: session persistence across restarts
Streamable HTTP is stateful by default (stateless_http=False in the SDK):
each client gets a session id tied to server-side state. If you restart the
server, clients that already negotiated a session can be left holding a
session id the server no longer recognizes. Set MCP_STATEFUL=false to run
with stateless_http=True instead, which drops session persistence so a
restart never confuses connected clients — useful for --http deployments
that get redeployed/restarted regularly. This is a real SDK feature
(FastMCP(..., stateless_http=...)), not a workaround.
Running
# stdio (default) — for MCP hosts that launch the server as a subprocess
mcp_eveng
# Streamable HTTP — recommended for networked / remote deployments
mcp_eveng --http
# Legacy SSE transport
mcp_eveng --sse
# Bound to all interfaces, requires MCP_ALLOWED_HOSTS (see above)
MCP_HOST="0.0.0.0" MCP_ALLOWED_HOSTS="192.168.1.100:8000,192.168.1.150:*" mcp_eveng --http
You can also run it as a module: python -m mcp_eveng --http.
Press Ctrl+C to stop a foreground server (--sse/--http, or stdio run
directly in a terminal for testing) — it's caught and exits cleanly with a
"Goodbye!" message on stderr instead of a raw traceback.
Using it with Claude Desktop / Claude Code (stdio)
In stdio mode the host supplies EVENG_* directly in its own config — no
.env file needed:
{
"mcpServers": {
"eveng_stdio": {
"command": "/path/to/venv/python.exe",
"args": [
"-m",
"mcp_eveng"
],
"env": {
"EVENG_HOST": "192.168.1.50",
"EVENG_USERNAME": "admin",
"EVENG_PASSWORD": "eve",
"EVENG_VERIFY_SSL": "false"
}
}
}
}
(EVENG_VERIFY_SSL: "false" is shown here because this example EVE-NG host is
assumed to be running HTTPS with a self-signed cert — see the config table
above. Drop it, or set it to "true", for a plain-HTTP or properly-certed
target.)
Using it with Claude Desktop / Claude Code (streamable-http)
Claude Desktop/Code don't speak Streamable HTTP directly, so bridge through
mcp-remote to a mcp_eveng --http
instance running elsewhere (e.g. mcp_eveng --http on 192.168.1.100:8000):
{
"mcpServers": {
"eveng_http": {
"command": "npx",
"args": [
"-y",
"mcp-remote@latest",
"http://192.168.1.100:8000/mcp",
"--allow-http"
]
}
}
}
Available tools
Tool names have no prefix (get_status, not eveng_get_status) — be aware
this means a name could collide with another MCP server's tool if you ever
connect more than one server with overlapping names to the same client.
| Area | Tools |
|---|---|
| System | get_status, list_node_templates, get_node_template, list_network_types, list_user_roles |
| Folders | list_folder, add_folder, move_folder, delete_folder* |
| Users | list_users, get_user, add_user, edit_user, delete_user* |
| Labs | get_lab, create_lab, edit_lab, move_lab, delete_lab*, get_lab_topology, get_lab_links, list_lab_pictures, list_labs, list_all_labs |
| Networks | list_lab_networks, add_lab_network, delete_lab_network* |
| Nodes | list_lab_nodes, add_lab_node, delete_lab_node*, get_node_interfaces, start_node, stop_node, wipe_node, export_node |
* Requires user confirmation before it does anything — see below.
list_labs vs list_all_labs: list_labs(path) lists only the labs
(not subfolders) directly inside one folder. list_all_labs(start_path)
recursively walks the whole tree from start_path (default: the entire
server) and returns every lab found. EVE-NG's API has no recursive-listing
endpoint (confirmed against the actual server source, api.php's
apiGetFolders() route — it only ever returns one folder's immediate
children), so list_all_labs walks the tree itself, one request per
folder, with loop protection: every folder's ".." entry is skipped, each
folder is only ever visited once even if referenced more than once, and
hard max_depth/max_folders ceilings guard against any unexpected API
response shape. Labs are deduplicated by path, since EVE-NG's virtual
/Running folder can otherwise list the same lab twice.
Deleting things requires confirmation
Every delete tool (delete_folder, delete_user, delete_lab_network,
delete_lab_node, delete_lab) works the same way:
- A search string is required. Every delete tool's identifying parameter has no meaningful default — call it with an empty/missing string and it fails immediately with a message explaining what's needed, before anything is searched or shown to the user.
- Matching is exact, not fuzzy, and always case-insensitive. "test" matches "Test" or "TEST" but never "testing".
- Every match is shown as a numbered list, with a trailing "All"
entry (except for
delete_lab— see below) — even when there's only one match, it's still shown as choice "1" so confirmation always goes through the same explicit picker rather than a plain yes/no prompt. - The user must pick a number. This uses
MCP elicitation (
ctx.elicit(...), part of the MCP spec since the 2025-06-18 revision and supported by our pinned SDK). Picking a listed number deletes that item (or every item, for "All"). Anything else — a decline, a cancel, invalid input, or a connected MCP host that doesn't support elicitation at all — cancels the whole operation. Nothing is ever deleted without an explicit, valid number choice.
What each tool matches against:
| Tool | Matches on | Notes |
|---|---|---|
delete_folder |
Folder path only (never a bare name) | Refuses to delete a non-empty folder — see below |
delete_user |
Username | |
delete_lab_network |
Network name or id, within one lab_path |
|
delete_lab_node |
Node name or id, within one lab_path |
|
delete_lab |
Lab path or name, across the whole tree (or search_path) |
No "All" option — see below |
delete_folder refuses to delete a non-empty folder. If the chosen
folder still contains any subfolders or labs, nothing is deleted for it and
the response explains that it's not empty, listing its contents as bullets
(so you can see exactly what's in the way).
delete_lab never offers "All" and never deletes more than one lab in a
single call, even when several labs share the same name in different
folders — you always have to pick exactly one specific match by number.
This is deliberately stricter than the other delete tools.
⚠️ This has not been exercised against a live EVE-NG server or a real elicitation-capable MCP host yet — the logic is covered by unit tests with a faked confirmation flow, but please verify the actual UX (does your MCP host render the numbered-choice prompt the way you'd expect?) before relying on it for anything you can't afford to lose.
Project layout
mcp_eveng/
├── src/mcp_eveng/
│ ├── client.py # async EVENG REST API client (incl. list_all_labs)
│ ├── config.py # pydantic-settings, reads .env
│ ├── confirmation.py # shared numbered-choice deletion confirmation (MCP elicitation)
│ ├── dependencies.py # shared client singleton
│ ├── exceptions.py
│ ├── search.py # case-insensitive record search (used by delete tools)
│ ├── server.py # FastMCP assembly + transport security/statefulness
│ ├── __main__.py # CLI: --sse / --http flags
│ └── tools/ # one module per API area
├── tests/
│ ├── conftest.py
│ ├── helpers.py # FakeContext/FakeElicitResult for testing confirmations
│ ├── test_cli.py
│ ├── test_client.py
│ ├── test_config.py
│ ├── test_confirmation.py
│ ├── test_dependencies.py
│ ├── test_search.py
│ ├── test_server.py
│ └── tools/
└── .github/workflows/ # CI + PyPI publish
Troubleshooting
IncompleteFieldDefinitionWarning: Field 'lifespan' has an incomplete definition... — this comes from inside the mcp SDK itself, not from
mcp_eveng. The SDK's internal FastMCP Settings model has a
self-referential lifespan field type that it never calls
model_rebuild() on, so pydantic-settings warns about it on every
FastMCP construction. It has no functional effect (nothing reads that
field from the environment) and mcp_eveng suppresses it by default — if
you still see it, you're likely on an mcp version where the warning text
changed slightly; it's safe to ignore either way.
Development
pip install -e ".[dev]"
# run tests with coverage
pytest
# lint / type-check
ruff check .
mypy src
Why mcp is pinned below 2.0
The official MCP Python SDK shipped a 2.0.0 release on 2026-07-28 alongside
the 2026-07-28 protocol revision. It is a deliberate breaking rework
(FastMCP renamed to MCPServer, new import paths, stateless transports)
and the SDK maintainers themselves recommend the 1.x line for production
use while 2.x stabilizes. This project pins mcp[cli]>=1.23.0,<2.0.0
intentionally — see pyproject.toml. Revisit this pin (and re-verify all
three transports, transport_security, and stateless_http) when migrating
to 2.x.
Publishing to PyPI
This repo is set up for Trusted Publishing:
tagging a release (vX.Y.Z) triggers .github/workflows/publish.yml, which
builds and uploads to PyPI with no stored API tokens. Configure the trusted
publisher on PyPI's project settings page pointing at this repository and the
publish.yml workflow before tagging your first release.
License
MIT — see LICENSE.
Recommended Servers
playwright-mcp
A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.
Audiense Insights MCP Server
Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.
Magic Component Platform (MCP)
An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.
VeyraX MCP
Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.
graphlit-mcp-server
The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.
Kagi MCP Server
An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.
E2B
Using MCP to run code via e2b.
Neon Database
MCP server for interacting with Neon Management API and databases
Exa Search
A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.
Qdrant Server
This repository is an example of how to create a MCP server for Qdrant, a vector search engine.