# MCP (Model Context Protocol) Service ## Overview GNS3 Server provides a standard [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) interface, allowing AI assistants like Claude to interact with GNS3 network simulations through SSE (Server-Sent Events) transport. The MCP service exposes GNS3 project management operations as MCP tools that can be discovered and called by MCP clients. ## Endpoints | Path | Method | Description | |------|--------|-------------| | `/v3/mcp/` | GET | MCP service metadata | | `/v3/mcp/transport/sse` | GET | SSE stream (MCP connection) | | `/v3/mcp/transport/messages/` | POST | JSON-RPC messages | ## Authentication The SSE endpoint supports two types of credentials, passed the same way. 1. **Authorization header** (recommended): ``` Authorization: Bearer ``` 2. **Query parameter** (for clients that don't support custom headers): ``` GET /v3/mcp/transport/sse?token= ``` ### Option 1: JWT Token (24h expiry) ```bash curl -X POST http://localhost:3080/v3/access/users/authenticate \ -H "Content-Type: application/json" \ -d '{"username": "admin", "password": "admin"}' ``` Default lifetime is **1440 minutes (24 hours)**. Configurable in `gns3_server.conf`: ```ini jwt_access_token_expire_minutes = 1440 ; 24 hours ``` ### Option 2: API Key (permanent, revocable) — Recommended for MCP API keys never expire and can be revoked individually. Create one via the REST API: ```bash # Create an API key (requires a JWT to authenticate) curl -X POST http://localhost:3080/v3/access/api-keys \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"name": "MCP Production"}' # Response: {"api_key": "gns3_a1b2c3d4...", "api_key_id": "...", ...} # ⚠️ The key is only shown once — save it immediately. ``` API key management endpoints: | Endpoint | Description | |----------|-------------| | `POST /v3/access/api-keys` | Create a new key (returns plaintext once) | | `GET /v3/access/api-keys` | List all your keys | | `POST /v3/access/api-keys/{id}/revoke` | Revoke a key (can be restored) | | `POST /v3/access/api-keys/{id}/restore` | Restore a revoked key | | `DELETE /v3/access/api-keys/{id}` | Permanently delete a key | Both JWT tokens and API keys work for MCP and REST API endpoints interchangeably. ## Available Tools **82 tools** across 12 categories: ### Project (15) | Tool | Description | |------|-------------| | `project_list` | List all projects | | `project_get` | Get project details | | `project_create` | Create a project | | `project_delete` | Delete a project | | `project_open` | Open a closed project | | `project_close` | Close an open project | | `project_stats` | Get project statistics | | `project_update` | Update project properties | | `project_duplicate` | Duplicate a project | | `project_readme_get` | Get project README content | | `project_readme_update` | Update project README | | `project_lock` | Lock project (prevent edits) | | `project_unlock` | Unlock project | | `project_load` | Load project from path | | `project_locked` | Check if project is locked | ### Node (22) | Tool | Description | |------|-------------| | `node_list` | List all nodes (`fields` to filter columns, e.g. `["name","status"]`) | | `node_get` | Get node details (`fields` to filter columns) | | `node_create` | Create node(s) — single via `template_id` or batch via `nodes` array | | `node_delete` | Delete a node | | `node_update` | Update node properties | | `node_start` | Start node(s) — `node_id` or `node_ids` array | | `node_stop` | Stop node(s) — `node_id` or `node_ids` array | | `node_reload` | Reload node(s) — `node_id` or `node_ids` array | | `node_suspend` | Suspend node(s) — `node_id` or `node_ids` array | | `node_console` | Get WebSocket console URL | | `node_file_list` | List files in node directory | | `node_file_get` | Read a file (with offset/limit) | | `node_file_write` | Write a file | | `node_file_delete` | Delete a file | | `node_start_all` | Start all nodes | | `node_stop_all` | Stop all nodes | | `node_suspend_all` | Suspend all nodes | | `node_reload_all` | Reload all nodes | | `node_duplicate` | Duplicate a node | | `node_isolate` | Isolate a node (suspend links) | | `node_unisolate` | Un-isolate a node (resume links) | | `node_links` | List links connected to a node | ### Link (9) | Tool | Description | |------|-------------| | `link_list` | List all links (`fields` to filter columns) | | `link_get` | Get link details | | `link_create` | Create link(s) — single via `nodes` or batch via `links` array | | `link_delete` | Delete link(s) — `link_id` or `link_ids` array | | `link_update` | Update link (suspend, filters) | | `link_reset` | Reset link(s) — `link_id` or `link_ids` array | | `link_capture_start` | Start capture(s) — `link_id` or `link_ids` array | | `link_capture_stop` | Stop capture(s) — `link_id` or `link_ids` array | | `link_capture_download` | Get PCAP download URL(s) — `link_id` or `link_ids` array | ### Template (5) | Tool | Description | |------|-------------| | `template_list` | List all templates | | `template_get` | Get template details | | `template_create` | Create a template (Docker needs `image`) | | `template_update` | Update a template | | `template_delete` | Delete a template | ### Compute (3) | Tool | Description | |------|-------------| | `compute_list` | List registered remote computes | | `compute_get` | Get compute details (requires UUID) | | `compute_images` | List emulator images on a compute | ### Snapshot (4) | Tool | Description | |------|-------------| | `snapshot_list` | List snapshots | | `snapshot_create` | Create a snapshot | | `snapshot_delete` | Delete a snapshot | | `snapshot_restore` | Restore a snapshot | ### Drawing (5) | Tool | Description | |------|-------------| | `drawing_list` | List drawings on canvas | | `drawing_get` | Get drawing details | | `drawing_create` | Create drawing (SVG label/shape/image) | | `drawing_update` | Update drawing (position, rotation, SVG) | | `drawing_delete` | Delete a drawing | ### Symbol (6) | Tool | Description | |------|-------------| | `symbol_list` | List all symbols | | `symbol_get` | Get symbol download URL | | `symbol_dimensions` | Get symbol dimensions | | `symbol_defaults` | Get default symbol mapping | | `symbol_upload` | Upload a custom symbol (SVG content) | | `symbol_delete` | Delete a custom symbol (built-in: 403) | ### Appliance (3) | Tool | Description | |------|-------------| | `appliance_list` | List appliances (`fields` to filter, e.g. `["name","category"]`) | | `appliance_get` | Get appliance details | | `appliance_install` | Create template from appliance (images must exist locally) | ### Image (5) | Tool | Description | |------|-------------| | `image_list` | List all images | | `image_get` | Get image details | | `image_delete` | Delete an image | | `image_prune` | Remove images not referenced by any template | | `image_install` | Auto-create templates from uploaded images by checksum | ### Server (2) | Tool | Description | |------|-------------| | `server_version` | Get GNS3 server version | | `server_statistics` | Get server statistics (computes, projects, nodes) | ### Device Config (3) | Tool | Description | |------|-------------| | `device_config_send` | Push config commands to devices via console (Nornir + Netmiko). Supports Jinja2 `template` + `vars` | | `device_command_run` | Run read-only show commands on devices. Supports Jinja2 `template` + `vars` | | `vpcs_config_set` | Configure VPCS devices (IP, gateway, etc.) | The tool connects to each device's console via telnet/SSH. Nodes must be in the `started` state (use `node_start` or `node_start_all`). Device type is auto-detected from the node's `device_type:` tag in GNS3. #### Jinja2 Template Mode Both `device_config_send` and `device_command_run` support an optional `template` parameter. When provided, each device's `vars` dict is rendered against the template to produce commands. Entries with the same `device_name` are merged into a single device session. ```python # Direct commands (single/batch) device_config_send(project_id, device_configs=[ {"device_name": "R1", "config_commands": ["int lo0", "ip add 1.1.1.1 255.255.255.255"]}, ]) # Jinja2 template (reduces token usage for batch) device_config_send(project_id, template="interface lo{{ n }}\nip address {{ ip }} 255.255.255.255", device_configs=[ {"device_name": "R1", "vars": {"n": 0, "ip": "1.1.1.1"}}, {"device_name": "R2", "vars": {"n": 0, "ip": "2.2.2.2"}}, ]) # Show commands with template device_command_run(project_id, template="show ip route {{ protocol }}", device_configs=[ {"device_name": "R1", "vars": {"protocol": "ospf"}}, {"device_name": "R2", "vars": {"protocol": "bgp"}}, ]) ``` ### Best Practices **Prefer template over direct commands for batch.** When ≥2 nodes share the same config structure with different values, use `template`+`vars` instead of writing `config_commands` per node. This reduces token usage and transcription errors. **Batch merging.** Multiple entries with the same `device_name` are merged into a single Nornir session. The output contains all commands' results in one block. Match results by `device_name`, not list index. **Don't rely on `status: success` alone.** It only means commands entered config mode. IOS errors (`% Invalid input`, `% overlaps`, `% Incomplete command`) appear inside `output` text — always scan for `%` lines. **Pilot before full rollout.** Test template + vars on 1–2 devices first to verify rendering and syntax, then expand to all nodes. **Config backup via file operations.** IOU and Dynamips nodes save startup config as a plain text file (`startup-config.cfg`) in the node directory after `write memory`. These can be backed up and restored via `node_file_get`/`node_file_write`. ```python # Save config on device device_command_run(project_id, device_configs=[ {"device_name": "R1", "commands": ["write memory"]}, ]) # Backup config = node_file_get(project_id, node_id, "startup-config.cfg") # Restore if config breaks node_file_write(project_id, node_id, "startup-config.cfg", config) node_reload(project_id, node_id) ``` ### Device Config Workflow ```mermaid sequenceDiagram participant AI as AI Agent participant MCP as MCP Handler participant TM as Template Renderer participant DP as Device Discovery participant NR as Nornir participant NM as Netmiko participant D as Device Console Note over AI: Decide: template or direct commands? alt Direct commands AI->>MCP: device_config_send(config_commands=[...]) else Jinja2 template AI->>MCP: device_config_send(template + vars) MCP->>TM: Render template per device TM->>TM: Jinja2.render(**vars) TM-->>MCP: device_configs with rendered commands end MCP->>DP: get_device_ports_from_topology() DP-->>MCP: hosts_data (console port, device_type) Note over MCP: Prepare Nornir inventory MCP->>NR: InitNornir(hosts, threaded runner) par Device 1 to N (parallel, max 10) NR->>NM: netmiko_send_config(commands) NM->>D: telnet/SSH console session D-->>NM: command output NM-->>NR: execution result end NR-->>MCP: aggregated results MCP-->>AI: per-device results with output ``` ## Configuration ### Claude Code (CLI) ```bash # Option A: Using API key (recommended — never expires) claude mcp add --transport sse My_GNS3_Server \ http://localhost:3080/v3/mcp/transport/sse \ -H "Authorization: Bearer gns3_a1b2c3d4..." # Option B: Using JWT token (expires after 24h) TOKEN=$(curl -s -X POST http://localhost:3080/v3/access/users/authenticate \ -H "Content-Type: application/json" \ -d '{"username": "admin", "password": "admin"}' | python3 -c \ "import sys,json; print(json.load(sys.stdin)['access_token'])") claude mcp add --transport sse My_GNS3_Server \ http://localhost:3080/v3/mcp/transport/sse \ -H "Authorization: Bearer $TOKEN" ``` ## Transport Security MCP server uses FastMCP's DNS rebinding protection to prevent attackers from exploiting DNS resolution to access the MCP endpoint through unauthorized domains. ### Default Behaviour DNS rebinding protection is **disabled by default**, allowing connections from any host. This aligns with GNS3 server's default `host = 0.0.0.0` binding policy, which is designed for VM distribution scenarios where users access the server from various network locations. ### Enabling Protection Add to `gns3_server.conf` under the `[Server]` section: ```ini ; Enable DNS rebinding protection for MCP server mcp_enable_dns_rebinding_protection = True ; Allowed hosts (comma-separated, "host:*" port wildcard patterns only) mcp_allowed_hosts = 127.0.0.1:*,localhost:*,192.168.1.3:* ; Allowed origins (comma-separated) mcp_allowed_origins = http://127.0.0.1:*,http://localhost:*,http://192.168.1.3:* ``` > **Note**: The MCP library only supports `"host:*"` port wildcard patterns > (e.g., `"192.168.1.3:*"`). Standalone `"*"` wildcards are not supported. ### Protection Mechanism When protection is enabled, the MCP server validates the `Host` header of incoming SSE connection requests: ```python # Verify the request's Host header matches allowed patterns validate_request → check Host header → 421 Misdirected Request if invalid ``` This prevents DNS rebinding attacks: 1. Attacker registers `evil.com` pointing to your server's IP 2. User's browser makes requests to `evil.com:3080` 3. MCP server checks Host header = `"evil.com:3080"` 4. `"evil.com:3080"` is not in `allowed_hosts` → connection rejected ### Behaviour Summary | `mcp_enable_dns_rebinding_protection` | Result | |:---|:---| | `False` (default) | All hosts allowed | | `True` + correct hosts configured | Only configured hosts allowed | | `True` + missing/wrong hosts | Connections rejected with 421 | For public-facing MCP servers, set `allowed_hosts` to your server's domain name. ## Architecture ```mermaid sequenceDiagram participant Client as Claude Code participant MCP as MCP Service participant Auth as Auth participant GNS3 as GNS3 REST API Note over Client: 1. Connect with credential (JWT or API Key) Client->>MCP: GET /sse (token in header or query) MCP->>Auth: Validate Token Auth-->>MCP: Token Valid MCP-->>Client: event: endpoint /messages/?session_id=xxx Note over Client: 2. Initialize Client->>MCP: POST /messages/ (initialize) MCP-->>Client: event: message (protocolVersion, capabilities) Note over Client: 3. List & Call Tools Client->>MCP: POST /messages/ (tools/list) MCP-->>Client: event: message (tools list) Client->>MCP: POST /messages/ (tools/call project_list) MCP->>GNS3: Gns3Connector HTTP request GNS3-->>MCP: Projects data MCP-->>Client: event: message (tool result) ``` ## Internal Implementation - **FastMCP** (Anthropic MCP SDK) is used for tool registration and SSE transport - The SSE app is mounted as a Starlette sub-application under `/v3/mcp/transport` - JWT tokens are validated using GNS3's existing `auth_service` - Tool handlers use `Gns3Connector` (from `custom_gns3fy`) to call GNS3's own REST API, keeping the MCP layer decoupled - The JWT token is stored in a `contextvars.ContextVar` so it is available within tool handler threads (Python ≥ 3.9 propagates contextvars through `asyncio.to_thread`) ### Console WebSocket The `node_console` tool returns a WebSocket URL for connecting to a node's console. The URL includes a short-lived JWT (10 min) — reconnect if it expires. This endpoint is protocol-agnostic — it works for **telnet**, **ssh**, and **vnc** console types alike. The WebSocket simply proxies raw byte streams between the client and the compute node; protocol negotiation (e.g. SSH key exchange) happens on the compute side. The WebSocket URL is constructed using the server's `_server_url()`, which resolves the host as follows: | `Server.host` value | Resolved host in URL | |:---|:---| | Specific IP or hostname (e.g. `192.168.1.3`) | Used as-is | | `0.0.0.0` (IPv4 any, default) | Detected via **default route interface IP** | | `::` (IPv6 any) | Detected via default route interface IP | | Detection failure | Fallback to `127.0.0.1` | When `Server.host` is `0.0.0.0` (listen on all interfaces), the MCP server discovers the default route interface IP using a UDP socket connect to `8.8.8.8:80` — no network data is sent, the operating system simply selects the interface that would be used for the default route. This ensures the returned WebSocket URL uses a reachable address (e.g. `192.168.1.3` instead of `127.0.0.1`). If the configured host is already a specific IP or hostname (not `0.0.0.0`), it is used directly in the URL without modification. Use `websocat` to connect from the command line: ```bash # The host in the URL is automatically resolved to a reachable address websocat ws://192.168.1.3:3080/v3/projects/{project_id}/nodes/{node_id}/console/ws?token= ``` ### Source Files | File | Purpose | |------|---------| | `gns3server/api/routes/mcp/__init__.py` | FastMCP server, tool decorators, SSE transport, JWT auth wrapper | | `gns3server/api/routes/mcp/projects.py` | Project tool handlers | | `gns3server/api/routes/mcp/nodes.py` | Node tool handlers | | `gns3server/api/routes/mcp/links.py` | Link tool handlers | | `gns3server/api/routes/mcp/templates.py` | Template tool handlers | | `gns3server/api/routes/mcp/computes.py` | Compute tool handlers | | `gns3server/api/server.py` | Mounts MCP routes via `register_starlette_routes()` |