Merge pull request #2785 from yueguobin/mcp/batch-operations

MCP batch operations, field filtering, Jinja2 templates, download security, and WebSocket fixes
This commit is contained in:
Jeremy Grossmann 2026-06-15 00:47:17 +02:00 committed by GitHub
commit b605d71a8a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 694 additions and 152 deletions

View File

@ -95,15 +95,15 @@ Both JWT tokens and API keys work for MCP and REST API endpoints interchangeably
| Tool | Description |
|------|-------------|
| `node_list` | List all nodes in a project |
| `node_get` | Get node details |
| `node_create` | Create a node from template |
| `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 a node |
| `node_stop` | Stop a node |
| `node_reload` | Reload a node |
| `node_suspend` | Suspend a node |
| `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) |
@ -122,15 +122,15 @@ Both JWT tokens and API keys work for MCP and REST API endpoints interchangeably
| Tool | Description |
|------|-------------|
| `link_list` | List all links in a project |
| `link_list` | List all links (`fields` to filter columns) |
| `link_get` | Get link details |
| `link_create` | Create a link between nodes |
| `link_delete` | Delete a link |
| `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 (delete + recreate) |
| `link_capture_start` | Start packet capture |
| `link_capture_stop` | Stop packet capture |
| `link_capture_download` | Get PCAP download URL |
| `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)
@ -184,7 +184,7 @@ Both JWT tokens and API keys work for MCP and REST API endpoints interchangeably
| Tool | Description |
|------|-------------|
| `appliance_list` | List appliances from template library |
| `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) |
@ -209,11 +209,101 @@ Both JWT tokens and API keys work for MCP and REST API endpoints interchangeably
| Tool | Description |
|------|-------------|
| `device_config_send` | Push config commands to devices via console (Nornir + Netmiko) |
| `device_command_run` | Run read-only show commands on devices |
| `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.) |
Requires nodes to be started first. Device type is auto-detected from the node's `device_type:<type>` tag.
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:<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 12 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
@ -236,20 +326,6 @@ claude mcp add --transport sse My_GNS3_Server \
-H "Authorization: Bearer $TOKEN"
```
### Claude Desktop
Add to `claude_desktop_config.json`:
```json
{
"mcpServers": {
"My_GNS3_Server": {
"url": "http://localhost:3080/v3/mcp/transport/sse?token=your_jwt_or_api_key"
}
}
}
```
## Transport Security
MCP server uses FastMCP's DNS rebinding protection to prevent attackers from
@ -310,12 +386,12 @@ For public-facing MCP servers, set `allowed_hosts` to your server's domain name.
```mermaid
sequenceDiagram
participant Client as Claude Code / Claude Desktop
participant Client as Claude Code
participant MCP as MCP Service
participant Auth as JWT Auth
participant Auth as Auth
participant GNS3 as GNS3 REST API
Note over Client: 1. Connect with JWT
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
@ -329,7 +405,7 @@ sequenceDiagram
Client->>MCP: POST /messages/ (tools/list)
MCP-->>Client: event: message (tools list)
Client->>MCP: POST /messages/ (tools/call list_projects)
Client->>MCP: POST /messages/ (tools/call project_list)
MCP->>GNS3: Gns3Connector HTTP request
GNS3-->>MCP: Projects data
MCP-->>Client: event: message (tool result)
@ -345,7 +421,7 @@ sequenceDiagram
### Console WebSocket
The `get_node_console_info` tool returns a WebSocket URL for connecting to a node's console. 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 `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:

View File

@ -53,6 +53,7 @@ import gns3server.db.models as models
from gns3server.services import auth_service
from gns3server.utils.request_utils import extract_client_info
from gns3server.db.repositories.api_keys import ApiKeysRepository
from gns3server.db.repositories.users import UsersRepository
from .projects import (
list_projects_handler, get_project_handler, create_project_handler,
delete_project_handler, open_project_handler, close_project_handler,
@ -118,6 +119,9 @@ from .drawings import (
log = logging.getLogger(__name__)
# Suppress noisy telnet connection logs from device config tools.
logging.getLogger("telnetlib3").setLevel(logging.WARNING)
# FastAPI app reference — used to lazily access app.state._db_engine for API key validation.
# The db engine is initialized during the lifespan startup, which runs AFTER
# register_starlette_routes() is called, so we cannot capture it at registration time.
@ -184,6 +188,11 @@ async def wait_for_mcp_ready() -> bool:
_jwt_token_var: contextvars.ContextVar[str | None] = contextvars.ContextVar(
"mcp_jwt_token", default=None
)
# Username extracted during token validation — used by handlers to generate
# short-lived JWTs for download/console URLs without exposing the raw key.
_jwt_username_var: contextvars.ContextVar[str | None] = contextvars.ContextVar(
"mcp_jwt_username", default=None
)
# ── Token validation ──────────────────────────────────────────────────
@ -198,12 +207,13 @@ async def _resolve_token(token: str) -> str | None:
"""
# Try JWT first
try:
auth_service.get_username_from_token(token)
username = auth_service.get_username_from_token(token)
_jwt_username_var.set(username)
return token
except Exception:
pass
# Try API key — pass through directly; REST API auth already supports gns3_ keys
# Try API key
if token.startswith("gns3_") and _app is not None:
db_engine = getattr(_app.state, "_db_engine", None)
if db_engine is not None:
@ -215,8 +225,10 @@ async def _resolve_token(token: str) -> str | None:
for db_key in result.scalars().all():
if bcrypt.checkpw(token.encode(), db_key.key_hash.encode()):
await repo.update_last_used(db_key.api_key_id)
# Return the raw API key — it will be passed as Bearer token
# and validated by the REST API auth layer
user_repo = UsersRepository(db_session)
user = await user_repo.get_user(db_key.user_id)
if user:
_jwt_username_var.set(user.username)
return token
except Exception:
pass
@ -274,6 +286,7 @@ def _run_handler_sync(handler, params: dict[str, Any]) -> list[dict[str, Any]]:
ctx = {
"server_url": _server_url(),
"jwt_token": _jwt_token_var.get(),
"jwt_username": _jwt_username_var.get(),
}
result = handler(params, ctx)
return [{"type": "text", "text": json.dumps(result, ensure_ascii=False, default=str)}]
@ -297,8 +310,8 @@ async def project_get(
async def project_create(
name: Annotated[str, Field(description="Project name")],
) -> list[dict[str, Any]]:
"""Create a new GNS3 project."""
params = {"name": name}
"""Create a new GNS3 project. auto_close is set to False so the project stays open when clients disconnect."""
params = {"name": name, "auto_close": False}
return await asyncio.to_thread(_run_handler_sync, create_project_handler, params)
@ -396,61 +409,100 @@ async def project_readme_update(
# ── Node tools ────────────────────────────────────────────────────────
@mcp.tool()
async def node_list(project_id: str) -> list[dict[str, Any]]:
"""List all nodes in a project."""
return await asyncio.to_thread(_run_handler_sync, get_nodes_handler, {"project_id": project_id})
async def node_list(
project_id: Annotated[str, Field(description="UUID of the project")],
fields: Annotated[list[str] | None, Field(description="Optional: return only these fields per node. e.g. [\"name\",\"status\"]. Available: name, status, node_type, console, console_type, console_host, node_id, project_id, compute_id, symbol, x, y, z, locked, ports, properties, command_line, node_directory, label, tags, template_id, width, height, aux, aux_type")] = None,
) -> list[dict[str, Any]]:
"""List all nodes in a project. Use fields=[] to return only what you need."""
return await asyncio.to_thread(_run_handler_sync, get_nodes_handler, {"project_id": project_id, "fields": fields})
@mcp.tool()
async def node_get(
project_id: Annotated[str, Field(description="UUID of the project")],
node_id: Annotated[str, Field(description="UUID of the node")],
fields: Annotated[list[str] | None, Field(description="Optional: return only these fields. e.g. [\"name\",\"status\"]. Available: name, status, node_type, console, console_type, console_host, node_id, project_id, compute_id, symbol, x, y, z, locked, ports, properties, command_line, node_directory, label, tags, template_id, width, height, aux, aux_type")] = None,
) -> list[dict[str, Any]]:
"""Get detailed information about a specific node."""
return await asyncio.to_thread(_run_handler_sync, get_node_handler, {"project_id": project_id, "node_id": node_id})
"""Get detailed information about a specific node. Use fields=[] to return only what you need."""
return await asyncio.to_thread(_run_handler_sync, get_node_handler, {
"project_id": project_id, "node_id": node_id, "fields": fields,
})
@mcp.tool()
async def node_start(
project_id: Annotated[str, Field(description="UUID of the project")],
node_id: Annotated[str, Field(description="UUID of the node to start")],
node_id: Annotated[str | None, Field(description="Node UUID (single mode)")] = None,
node_ids: Annotated[list[str] | None, Field(description="Batch mode: [\"uuid1\",\"uuid2\"] — start multiple nodes in parallel")] = None,
) -> list[dict[str, Any]]:
"""Start a node in a project."""
return await asyncio.to_thread(_run_handler_sync, start_node_handler, {"project_id": project_id, "node_id": node_id})
"""Start one or more nodes. Provide node_id for single, or node_ids for batch."""
params = {"project_id": project_id}
if node_ids:
params["node_ids"] = node_ids
else:
params["node_id"] = node_id
return await asyncio.to_thread(_run_handler_sync, start_node_handler, params)
@mcp.tool()
async def node_stop(
project_id: Annotated[str, Field(description="UUID of the project")],
node_id: Annotated[str, Field(description="UUID of the node to stop")],
node_id: Annotated[str | None, Field(description="Node UUID (single mode)")] = None,
node_ids: Annotated[list[str] | None, Field(description="Batch mode: [\"uuid1\",\"uuid2\"] — stop multiple nodes in parallel")] = None,
) -> list[dict[str, Any]]:
"""Stop a node in a project."""
return await asyncio.to_thread(_run_handler_sync, stop_node_handler, {"project_id": project_id, "node_id": node_id})
"""Stop one or more nodes. Provide node_id for single, or node_ids for batch."""
params = {"project_id": project_id}
if node_ids:
params["node_ids"] = node_ids
else:
params["node_id"] = node_id
return await asyncio.to_thread(_run_handler_sync, stop_node_handler, params)
@mcp.tool()
async def node_reload(
project_id: Annotated[str, Field(description="UUID of the project")],
node_id: Annotated[str, Field(description="UUID of the node to reload")],
node_id: Annotated[str | None, Field(description="Node UUID (single mode)")] = None,
node_ids: Annotated[list[str] | None, Field(description="Batch mode: [\"uuid1\",\"uuid2\"] — reload multiple nodes in parallel")] = None,
) -> list[dict[str, Any]]:
"""Reload (restart) a node in a project."""
return await asyncio.to_thread(_run_handler_sync, reload_node_handler, {"project_id": project_id, "node_id": node_id})
"""Reload (restart) one or more nodes. Provide node_id for single, or node_ids for batch."""
params = {"project_id": project_id}
if node_ids:
params["node_ids"] = node_ids
else:
params["node_id"] = node_id
return await asyncio.to_thread(_run_handler_sync, reload_node_handler, params)
@mcp.tool()
async def node_suspend(
project_id: Annotated[str, Field(description="UUID of the project")],
node_id: Annotated[str, Field(description="UUID of the node to suspend")],
node_id: Annotated[str | None, Field(description="Node UUID (single mode)")] = None,
node_ids: Annotated[list[str] | None, Field(description="Batch mode: [\"uuid1\",\"uuid2\"] — suspend multiple nodes in parallel")] = None,
) -> list[dict[str, Any]]:
"""Suspend a node in a project."""
return await asyncio.to_thread(_run_handler_sync, suspend_node_handler, {"project_id": project_id, "node_id": node_id})
"""Suspend one or more nodes. Provide node_id for single, or node_ids for batch."""
params = {"project_id": project_id}
if node_ids:
params["node_ids"] = node_ids
else:
params["node_id"] = node_id
return await asyncio.to_thread(_run_handler_sync, suspend_node_handler, params)
@mcp.tool()
async def node_create(
project_id: Annotated[str, Field(description="UUID of the project")],
template_id: Annotated[str, Field(description="UUID of the template to create the node from")],
x: Annotated[int, Field(description="X coordinate on the project canvas")] = 0,
y: Annotated[int, Field(description="Y coordinate on the project canvas")] = 0,
template_id: Annotated[str | None, Field(description="Template UUID (required for single mode)")] = None,
x: Annotated[int, Field(description="X coordinate")] = 0,
y: Annotated[int, Field(description="Y coordinate")] = 0,
compute_id: Annotated[str, Field(description="Compute ID (default: local)")] = "local",
nodes: Annotated[list | None, Field(description="Batch mode: [{template_id, x?, y?, name?, compute_id?}] — creates multiple nodes in parallel")] = None,
) -> list[dict[str, Any]]:
"""Create a new node from a template in a project."""
"""Create one or more nodes from templates.
Single mode: provide template_id, x, y (optional compute_id)
Batch mode: provide nodes=[{template_id, x, y, name?, compute_id?}] creates up to 10 in parallel
"""
if nodes is not None:
return await asyncio.to_thread(_run_handler_sync, create_node_handler, {
"project_id": project_id, "nodes": nodes,
})
return await asyncio.to_thread(_run_handler_sync, create_node_handler, {
"project_id": project_id, "template_id": template_id,
"x": x, "y": y, "compute_id": compute_id,
@ -460,10 +512,16 @@ async def node_create(
@mcp.tool()
async def node_delete(
project_id: Annotated[str, Field(description="UUID of the project")],
node_id: Annotated[str, Field(description="UUID of the node to delete")],
node_id: Annotated[str | None, Field(description="Node UUID (single mode)")] = None,
node_ids: Annotated[list[str] | None, Field(description="Batch mode: [\"uuid1\",\"uuid2\"] — delete multiple nodes in parallel")] = None,
) -> list[dict[str, Any]]:
"""Delete a node from a project."""
return await asyncio.to_thread(_run_handler_sync, delete_node_handler, {"project_id": project_id, "node_id": node_id})
"""Delete one or more nodes from a project. Provide node_id for single, or node_ids for batch."""
params = {"project_id": project_id}
if node_ids:
params["node_ids"] = node_ids
else:
params["node_id"] = node_id
return await asyncio.to_thread(_run_handler_sync, delete_node_handler, params)
@mcp.tool()
@ -486,6 +544,7 @@ async def node_console(
Returns the WebSocket URL, console type (telnet/ssh/vnc), and other
connection details needed to interact with a node's console via WebSocket.
The URL includes a short-lived JWT (10 min) reconnect if it expires.
Complete workflow:
1. Call this tool with project_id and node_id to get the WebSocket URL
@ -510,9 +569,12 @@ async def node_console(
# ── Link tools ────────────────────────────────────────────────────────
@mcp.tool()
async def link_list(project_id: str) -> list[dict[str, Any]]:
"""List all links in a project."""
return await asyncio.to_thread(_run_handler_sync, get_links_handler, {"project_id": project_id})
async def link_list(
project_id: Annotated[str, Field(description="UUID of the project")],
fields: Annotated[list[str] | None, Field(description="Optional: return only these fields. e.g. [\"link_id\",\"nodes\"]. Available: link_id, project_id, link_type, nodes, suspend, filters, capturing, capture_file_name, link_style")] = None,
) -> list[dict[str, Any]]:
"""List all links in a project. Use fields=[] to return only what you need."""
return await asyncio.to_thread(_run_handler_sync, get_links_handler, {"project_id": project_id, "fields": fields})
@mcp.tool()
@ -527,21 +589,20 @@ async def link_get(
@mcp.tool()
async def link_create(
project_id: Annotated[str, Field(description="UUID of the project")],
nodes: Annotated[list, Field(description="List of node connections, e.g. [{\"node_id\": \"...\", \"adapter_number\": 0, \"port_number\": 0}]")],
nodes: Annotated[list | None, Field(description="Single mode: [{node_id, adapter_number, port_number}]")] = None,
link_type: Annotated[str, Field(description="Link type - ethernet or serial")] = "ethernet",
filters: Annotated[dict, Field(description="Optional packet filters (must use array format): frequency_drop: [N], packet_loss: [rate], delay: [ms, jitter], corrupt: [rate], bpf: [expression]")] = None,
filters: Annotated[dict | None, Field(description="Optional packet filters")] = None,
links: Annotated[list | None, Field(description="Batch mode: [{nodes, link_type?, filters?}] — creates multiple links in parallel")] = None,
) -> list[dict[str, Any]]:
"""Create a link between two nodes in a project.
"""Create one or more links between nodes.
Filters must use array format:
- frequency_drop: [N] - Drop every Nth packet (N: -1 to 32767)
- packet_loss: [rate] - Packet loss percentage (rate: 0 to 100)
- delay: [ms, jitter] - Latency and jitter in milliseconds
- corrupt: [rate] - Packet corruption percentage (rate: 0 to 100)
- bpf: [expression] - Berkeley Packet Filter expression
Example: {"filters": {"delay": [100, 10], "packet_loss": [5]}}
Single mode: provide nodes, link_type (optional filters)
Batch mode: provide links=[{nodes, link_type?, filters?}] up to 10 in parallel
"""
if links:
return await asyncio.to_thread(_run_handler_sync, create_link_handler, {
"project_id": project_id, "links": links,
})
params = {"project_id": project_id, "nodes": nodes, "link_type": link_type}
if filters:
params["filters"] = filters
@ -551,10 +612,16 @@ async def link_create(
@mcp.tool()
async def link_delete(
project_id: Annotated[str, Field(description="UUID of the project")],
link_id: Annotated[str, Field(description="UUID of the link to delete")],
link_id: Annotated[str | None, Field(description="Link UUID (single mode)")] = None,
link_ids: Annotated[list[str] | None, Field(description="Batch mode: [\"uuid1\",\"uuid2\"] — delete multiple links in parallel")] = None,
) -> list[dict[str, Any]]:
"""Delete a link from a project."""
return await asyncio.to_thread(_run_handler_sync, delete_link_handler, {"project_id": project_id, "link_id": link_id})
"""Delete one or more links from a project."""
params = {"project_id": project_id}
if link_ids:
params["link_ids"] = link_ids
else:
params["link_id"] = link_id
return await asyncio.to_thread(_run_handler_sync, delete_link_handler, params)
@mcp.tool()
@ -848,9 +915,10 @@ async def node_links(
@mcp.tool()
async def link_reset(
project_id: Annotated[str, Field(description="UUID of the project")],
link_id: Annotated[str, Field(description="UUID of the link")],
link_id: Annotated[str | None, Field(description="Link UUID (single mode)")] = None,
link_ids: Annotated[list[str] | None, Field(description="Batch mode: [\"uuid1\",\"uuid2\"] — reset multiple links in parallel")] = None,
) -> list[dict[str, Any]]:
"""Reset a link by tearing down the underlying UDP connection and recreating it.
"""Reset one or more links by tearing down and recreating the UDP connection.
Use cases:
- Clear accumulated packet errors/drops from the link's UDP connection
@ -859,47 +927,60 @@ async def link_reset(
Filters are preserved but their internal application state resets.
"""
return await asyncio.to_thread(_run_handler_sync, reset_link_handler, {
"project_id": project_id, "link_id": link_id,
})
params = {"project_id": project_id}
if link_ids:
params["link_ids"] = link_ids
else:
params["link_id"] = link_id
return await asyncio.to_thread(_run_handler_sync, reset_link_handler, params)
@mcp.tool()
async def link_capture_start(
project_id: Annotated[str, Field(description="UUID of the project")],
link_id: Annotated[str, Field(description="UUID of the link")],
link_id: Annotated[str | None, Field(description="Link UUID (single mode)")] = None,
data_link_type: Annotated[str, Field(description="Data link type (default: DLT_EN10MB)")] = "DLT_EN10MB",
capture_file_name: Annotated[str | None, Field(description="Capture file name (optional)")] = None,
wireshark: Annotated[bool, Field(description="Open Wireshark automatically (default: false)")] = False,
link_ids: Annotated[list[str] | None, Field(description="Batch mode: [\"uuid1\",\"uuid2\"] — start capture on multiple links in parallel")] = None,
) -> list[dict[str, Any]]:
"""Start packet capture on a link. The capture file can later be downloaded with download_capture_file."""
return await asyncio.to_thread(_run_handler_sync, start_capture_handler, {
"project_id": project_id, "link_id": link_id,
"data_link_type": data_link_type, "capture_file_name": capture_file_name,
"wireshark": wireshark,
})
"""Start packet capture on one or more links."""
params = {"project_id": project_id, "data_link_type": data_link_type, "capture_file_name": capture_file_name, "wireshark": wireshark}
if link_ids:
params["link_ids"] = link_ids
else:
params["link_id"] = link_id
return await asyncio.to_thread(_run_handler_sync, start_capture_handler, params)
@mcp.tool()
async def link_capture_stop(
project_id: Annotated[str, Field(description="UUID of the project")],
link_id: Annotated[str, Field(description="UUID of the link")],
link_id: Annotated[str | None, Field(description="Link UUID (single mode)")] = None,
link_ids: Annotated[list[str] | None, Field(description="Batch mode: [\"uuid1\",\"uuid2\"] — stop capture on multiple links in parallel")] = None,
) -> list[dict[str, Any]]:
"""Stop packet capture on a link. After stopping, the capture file can be downloaded."""
return await asyncio.to_thread(_run_handler_sync, stop_capture_handler, {
"project_id": project_id, "link_id": link_id,
})
"""Stop packet capture on one or more links."""
params = {"project_id": project_id}
if link_ids:
params["link_ids"] = link_ids
else:
params["link_id"] = link_id
return await asyncio.to_thread(_run_handler_sync, stop_capture_handler, params)
@mcp.tool()
async def link_capture_download(
project_id: Annotated[str, Field(description="UUID of the project")],
link_id: Annotated[str, Field(description="UUID of the link")],
link_id: Annotated[str | None, Field(description="Link UUID (single mode)")] = None,
link_ids: Annotated[list[str] | None, Field(description="Batch mode: [\"uuid1\",\"uuid2\"] — get download URLs for multiple captures")] = None,
) -> list[dict[str, Any]]:
"""Get the download URL and instructions for a PCAP capture file. Use curl to download."""
return await asyncio.to_thread(_run_handler_sync, download_capture_file_handler, {
"project_id": project_id, "link_id": link_id,
})
"""Get download URL(s) for PCAP capture file(s). The URL includes a short-lived JWT (10 min). Use curl to download."""
params = {"project_id": project_id}
if link_ids:
params["link_ids"] = link_ids
else:
params["link_id"] = link_id
return await asyncio.to_thread(_run_handler_sync, download_capture_file_handler, params)
# ── Snapshot tools ─────────────────────────────────────────────────────
@ -920,7 +1001,12 @@ async def snapshot_create(
project_id: Annotated[str, Field(description="UUID of the project")],
name: Annotated[str, Field(description="Name for the new snapshot")],
) -> list[dict[str, Any]]:
"""Create a new snapshot of a project."""
"""Create a new snapshot of a project.
Prerequisite: All stoppable nodes (qemu, docker, dynamips, vpcs, iou, etc.)
must be stopped first. Use node_stop_all before creating a snapshot.
Cloud, NAT, and switch nodes are always-running and can be ignored.
"""
return await asyncio.to_thread(_run_handler_sync, create_snapshot_handler, {
"project_id": project_id, "name": name,
})
@ -1105,7 +1191,7 @@ async def symbol_list() -> list[dict[str, Any]]:
async def symbol_get(
symbol_id: Annotated[str, Field(description="Symbol ID (e.g. ':/symbols/router.svg')")],
) -> list[dict[str, Any]]:
"""Get a download URL for a symbol file (SVG). Use curl to download."""
"""Get a download URL for a symbol file (SVG). The URL includes a short-lived JWT (10 min). Use curl to download."""
return await asyncio.to_thread(_run_handler_sync, get_symbol_handler, {
"symbol_id": symbol_id,
})
@ -1157,9 +1243,11 @@ async def symbol_delete(
@mcp.tool()
async def appliance_list() -> list[dict[str, Any]]:
"""List all available appliances (template library)."""
return await asyncio.to_thread(_run_handler_sync, get_appliances_handler, {})
async def appliance_list(
fields: Annotated[list[str] | None, Field(description="Optional: return only these fields. e.g. [\"name\",\"category\"]. Available: name, category, description, vendor_name, product_name, status, availability, images, versions, tags, symbol, usage, builtin")] = None,
) -> list[dict[str, Any]]:
"""List all available appliances (template library). Use fields=[] to return only what you need."""
return await asyncio.to_thread(_run_handler_sync, get_appliances_handler, {"fields": fields} if fields else {})
@mcp.tool()
@ -1256,16 +1344,23 @@ async def device_config_send(
device_configs: Annotated[list, Field(
description="List of device configs. Each entry: {\"device_name\": \"R1\", \"config_commands\": [\"int lo0\", \"ip add 1.1.1.1 255.255.255.255\"]}"
)],
template: Annotated[str | None, Field(description="Optional Jinja2 template. Use with vars in each device to reduce token usage for batch config. Example: \"interface lo{{ n }}\\nip address {{ ip }} 255.255.255.255\"")] = None,
) -> list[dict[str, Any]]:
"""Send configuration commands to network devices via console (telnet/SSH).
Two modes:
1. Direct commands: each device has config_commands=[...]
2. Jinja2 template: provide template + vars per device template is rendered for each
Example: device_configs=[{\"device_name\": \"R1\", \"vars\": {\"n\": 0, \"ip\": \"1.1.1.1\"}}]
Devices must be started first (use node_start or node_start_all).
Device type is auto-detected from the 'device_type:<type>' tag on each node.
Common device types: cisco_ios_telnet, cisco_xr_telnet, huawei_telnet, gns3_huawei_telnet_ce
"""
return await asyncio.to_thread(_run_handler_sync, device_config_send_handler, {
"project_id": project_id, "device_configs": device_configs,
})
params = {"project_id": project_id, "device_configs": device_configs}
if template is not None:
params["template"] = template
return await asyncio.to_thread(_run_handler_sync, device_config_send_handler, params)
@mcp.tool()
@ -1274,15 +1369,21 @@ async def device_command_run(
device_configs: Annotated[list, Field(
description="List of device commands. Each entry: {\"device_name\": \"R1\", \"show_commands\": [\"show ip int brief\", \"show running-config\"]}"
)],
template: Annotated[str | None, Field(description="Optional Jinja2 template. Use with vars per device. Example: \"show ip route {{ protocol }}\"")] = None,
) -> list[dict[str, Any]]:
"""Run read-only diagnostic (show) commands on network devices via console.
Two modes:
1. Direct commands: each device has show_commands=[...]
2. Jinja2 template: provide template + vars per device
Use this to inspect device status, view configurations, or verify changes.
Devices must be started first.
"""
return await asyncio.to_thread(_run_handler_sync, device_command_run_handler, {
"project_id": project_id, "device_configs": device_configs,
})
params = {"project_id": project_id, "device_configs": device_configs}
if template is not None:
params["template"] = template
return await asyncio.to_thread(_run_handler_sync, device_command_run_handler, params)
@mcp.tool()

View File

@ -40,9 +40,30 @@ def _get_connector(gns3_ctx: dict[str, Any]):
# ── Tool handlers ──────────────────────────────────────────────────────────
VALID_APPLIANCE_FIELDS = {
"appliance_id", "name", "category", "description", "vendor_name",
"vendor_url", "product_name", "product_url", "documentation_url",
"status", "availability", "maintainer", "usage", "symbol",
"images", "versions", "tags", "builtin",
"first_port_name", "port_name_format", "port_segment_size",
"linked_clone", "docker", "iou", "dynamips", "qemu",
}
def get_appliances_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
conn = _get_connector(gns3_ctx)
appliances = conn.http_call("get", f"{conn.base_url}/appliances").json()
fields = params.get("fields")
if fields:
if not isinstance(fields, list):
return {"error": "fields must be a list, e.g. [\"name\", \"category\"]"}
invalid = [f for f in fields if f not in VALID_APPLIANCE_FIELDS]
if invalid:
return {
"error": f"Unknown fields: {invalid}",
"available_fields": sorted(VALID_APPLIANCE_FIELDS),
}
appliances = [{k: a[k] for k in fields if k in a} for a in appliances]
return {"appliances": appliances, "count": len(appliances)}

View File

@ -33,18 +33,61 @@ import json
import logging
from typing import Any
from jinja2 import Template as JinjaTemplate, TemplateError as JinjaError
log = logging.getLogger(__name__)
def _render_template(template: str, device_configs: list[dict], commands_field: str = "config_commands") -> list[dict]:
"""Render a Jinja2 template for each device's vars into the specified commands field.
Entries with the same device_name are merged into a single entry
so they share one Nornir session and avoid output fragmentation.
Each device in device_configs can have:
- "vars": dict of template variables (rendered into commands_field)
- commands_field: existing commands merged after rendering if present
Args:
commands_field: field name for the rendered commands, e.g. "config_commands", "commands"
"""
jinja = JinjaTemplate(template)
merged: dict[str, dict] = {}
for dev in device_configs:
name = dev.get("device_name")
if not name:
continue
vars_data = dev.get("vars", {})
if name not in merged:
merged[name] = {"device_name": name, commands_field: list(dev.get(commands_field, []))}
entry = merged[name]
if vars_data:
try:
output = jinja.render(**vars_data)
lines = [l for l in output.splitlines() if l.strip()]
entry[commands_field].extend(lines)
except JinjaError as e:
error_msg = f"Template rendering failed for '{name}': {e}"
log.error(error_msg)
return [{"error": error_msg}]
return list(merged.values())
# ── Tool handlers ──────────────────────────────────────────────────────────
def device_config_send_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> list[dict[str, Any]]:
"""Send configuration commands to network devices via console."""
project_id = params.get("project_id")
device_configs = params.get("device_configs")
template = params.get("template")
if not project_id or not device_configs:
return [{"error": "project_id and device_configs are required"}]
if template:
device_configs = _render_template(template, device_configs, commands_field="config_commands")
if len(device_configs) == 1 and "error" in device_configs[0]:
return device_configs
from gns3server.agent.gns3_copilot.tools_v2.config_tools_nornir import ExecuteMultipleDeviceConfigCommands
tool = ExecuteMultipleDeviceConfigCommands()
@ -63,9 +106,15 @@ def device_command_run_handler(params: dict[str, Any], gns3_ctx: dict[str, Any])
"""Run read-only diagnostic (show) commands on network devices."""
project_id = params.get("project_id")
device_configs = params.get("device_configs")
template = params.get("template")
if not project_id or not device_configs:
return [{"error": "project_id and device_configs (list of {device_name, show_commands}) are required"}]
if template:
device_configs = _render_template(template, device_configs, commands_field="commands")
if len(device_configs) == 1 and "error" in device_configs[0]:
return device_configs
from gns3server.agent.gns3_copilot.tools_v2.display_tools_nornir import ExecuteMultipleDeviceCommands
tool = ExecuteMultipleDeviceCommands()

View File

@ -23,11 +23,16 @@ via Gns3Connector (from custom_gns3fy).
"""
from typing import Any
from concurrent.futures import ThreadPoolExecutor, as_completed
import logging
from gns3server.services import auth_service
log = logging.getLogger(__name__)
BATCH_MAX_WORKERS = 10
# ── Helper ─────────────────────────────────────────────────────────────────
@ -43,12 +48,31 @@ def _get_connector(gns3_ctx: dict[str, Any]):
# ── Tool handlers ──────────────────────────────────────────────────────────
VALID_LINK_FIELDS = {
"link_id", "project_id", "link_type", "nodes", "suspend",
"link_style", "filters", "show_filters_icon",
"capturing", "capture_file_name", "capture_file_path",
"capture_compute_id", "wireshark",
}
def get_links_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id")
if not project_id:
return {"error": "project_id is required"}
conn = _get_connector(gns3_ctx)
links = conn.http_call("get", f"{conn.base_url}/projects/{project_id}/links").json()
fields = params.get("fields")
if fields:
if not isinstance(fields, list):
return {"error": "fields must be a list, e.g. [\"link_id\", \"nodes\"]"}
invalid = [f for f in fields if f not in VALID_LINK_FIELDS]
if invalid:
return {
"error": f"Unknown fields: {invalid}",
"available_fields": sorted(VALID_LINK_FIELDS),
}
links = [{k: l[k] for k in fields if k in l} for l in links]
return {"links": links, "count": len(links)}
@ -63,9 +87,42 @@ def get_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[s
def create_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id")
if not project_id:
return {"error": "project_id is required"}
links = params.get("links")
# Batch mode: links=[{nodes, link_type?, filters?, suspend?}]
if links is not None:
if not isinstance(links, list) or not links:
return {"error": "links must be a non-empty array"}
results = []
conn = _get_connector(gns3_ctx)
def _create_one(link_data):
if not link_data.get("nodes"):
return {"status": "error", "error": "nodes is required for each link"}
try:
body = {"nodes": link_data["nodes"]}
if link_data.get("link_type"):
body["link_type"] = link_data["link_type"]
if link_data.get("filters"):
body["filters"] = link_data["filters"]
if link_data.get("suspend"):
body["suspend"] = link_data["suspend"]
url = f"{conn.base_url}/projects/{project_id}/links"
resp = conn.http_call("post", url, json_data=body).json()
return {"status": "success", "link": resp}
except Exception as e:
return {"status": "error", "error": str(e)}
with ThreadPoolExecutor(max_workers=min(len(links), BATCH_MAX_WORKERS)) as pool:
futures = {pool.submit(_create_one, l): l for l in links}
for future in as_completed(futures):
results.append(future.result())
return results
# Single mode
nodes = params.get("nodes")
if not project_id or not nodes:
return {"error": "project_id and nodes are required"}
if not nodes:
return {"error": "nodes is required"}
conn = _get_connector(gns3_ctx)
data = {"nodes": nodes}
if "link_type" in params:
@ -80,9 +137,24 @@ def create_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
def delete_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id")
if not project_id:
return {"error": "project_id is required"}
link_ids = params.get("link_ids")
if link_ids:
if not isinstance(link_ids, list):
return {"error": "link_ids must be a list"}
conn = _get_connector(gns3_ctx)
def _del(lid):
try:
conn.http_call("delete", f"{conn.base_url}/projects/{project_id}/links/{lid}")
return {"link_id": lid, "status": "deleted"}
except Exception as e:
return {"link_id": lid, "status": "error", "error": str(e)}
with ThreadPoolExecutor(max_workers=min(len(link_ids), BATCH_MAX_WORKERS)) as pool:
return list(pool.map(_del, link_ids))
link_id = params.get("link_id")
if not project_id or not link_id:
return {"error": "project_id and link_id are required"}
if not link_id:
return {"error": "link_id or link_ids is required"}
conn = _get_connector(gns3_ctx)
conn.http_call("delete", f"{conn.base_url}/projects/{project_id}/links/{link_id}")
return {"message": f"Link {link_id} deleted", "link_id": link_id}
@ -110,20 +182,66 @@ def update_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
def reset_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id")
if not project_id:
return {"error": "project_id is required"}
link_ids = params.get("link_ids")
if link_ids:
if not isinstance(link_ids, list):
return {"error": "link_ids must be a list"}
conn = _get_connector(gns3_ctx)
def _rst(lid):
try:
url = f"{conn.base_url}/projects/{project_id}/links/{lid}/reset"
r = conn.http_call("post", url).json()
return {"link_id": lid, "status": "reset", "link": r}
except Exception as e:
return {"link_id": lid, "status": "error", "error": str(e)}
with ThreadPoolExecutor(max_workers=min(len(link_ids), BATCH_MAX_WORKERS)) as pool:
return list(pool.map(_rst, link_ids))
link_id = params.get("link_id")
if not project_id or not link_id:
return {"error": "project_id and link_id are required"}
if not link_id:
return {"error": "link_id or link_ids is required"}
conn = _get_connector(gns3_ctx)
url = f"{conn.base_url}/projects/{project_id}/links/{link_id}/reset"
result = conn.http_call("post", url).json()
return {"message": f"Link {link_id} reset", "link": result}
def _batch_capture(project_id, link_ids, action, data_builder, conn):
"""Helper for batch capture start/stop."""
def _act(lid):
try:
url = f"{conn.base_url}/projects/{project_id}/links/{lid}/capture/{action}"
kwargs = data_builder(lid) if data_builder else {}
conn.http_call("post", url, **kwargs)
return {"link_id": lid, "status": "success"}
except Exception as e:
return {"link_id": lid, "status": "error", "error": str(e)}
with ThreadPoolExecutor(max_workers=min(len(link_ids), BATCH_MAX_WORKERS)) as pool:
return list(pool.map(_act, link_ids))
def start_capture_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id")
if not project_id:
return {"error": "project_id is required"}
link_ids = params.get("link_ids")
if link_ids:
if not isinstance(link_ids, list):
return {"error": "link_ids must be a list"}
conn = _get_connector(gns3_ctx)
dlt = params.get("data_link_type", "DLT_EN10MB")
ws = params.get("wireshark", False)
fname = params.get("capture_file_name")
def _build(lid):
data = {"data_link_type": dlt, "wireshark": ws}
if fname:
data["capture_file_name"] = fname
return {"json_data": data}
return _batch_capture(project_id, link_ids, "start", _build, conn)
link_id = params.get("link_id")
if not project_id or not link_id:
return {"error": "project_id and link_id are required"}
if not link_id:
return {"error": "link_id or link_ids is required"}
conn = _get_connector(gns3_ctx)
data = {
"data_link_type": params.get("data_link_type", "DLT_EN10MB"),
@ -138,9 +256,17 @@ def start_capture_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> d
def stop_capture_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id")
if not project_id:
return {"error": "project_id is required"}
link_ids = params.get("link_ids")
if link_ids:
if not isinstance(link_ids, list):
return {"error": "link_ids must be a list"}
conn = _get_connector(gns3_ctx)
return _batch_capture(project_id, link_ids, "stop", None, conn)
link_id = params.get("link_id")
if not project_id or not link_id:
return {"error": "project_id and link_id are required"}
if not link_id:
return {"error": "link_id or link_ids is required"}
conn = _get_connector(gns3_ctx)
url = f"{conn.base_url}/projects/{project_id}/links/{link_id}/capture/stop"
conn.http_call("post", url)
@ -149,18 +275,38 @@ def stop_capture_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> di
def download_capture_file_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id")
if not project_id:
return {"error": "project_id is required"}
username = gns3_ctx.get("jwt_username")
download_token = auth_service.create_access_token(username, expires_in=10) if username else None
link_ids = params.get("link_ids")
if link_ids:
if not isinstance(link_ids, list):
return {"error": "link_ids must be a list"}
results = []
for lid in link_ids:
url = f"{gns3_ctx['server_url']}/v3/projects/{project_id}/links/{lid}/capture/file"
entry = {"link_id": lid, "download_url": url}
if download_token:
cmd = f"curl -L -o capture_{lid}.pcap -H 'Authorization: Bearer {download_token}' '{url}'"
entry["curl_command"] = cmd
results.append(entry)
return {"downloads": results, "count": len(results), "note": "Files are in pcap format. Links include a 10-minute token."}
link_id = params.get("link_id")
if not project_id or not link_id:
return {"error": "project_id and link_id are required"}
if not link_id:
return {"error": "link_id or link_ids is required"}
download_url = f"{gns3_ctx['server_url']}/v3/projects/{project_id}/links/{link_id}/capture/file"
auth_token = gns3_ctx['jwt_token']
return {
result = {
"link_id": link_id,
"download_url": download_url,
"curl_command": f"curl -L -o capture.pcap -H 'Authorization: Bearer {auth_token}' '{download_url}'",
"note": "Use the curl command to download the PCAP capture file. "
"The file is in pcap format and can be analyzed with Wireshark or tcpdump.",
"note": "The file is in pcap format and can be analyzed with Wireshark or tcpdump.",
}
if download_token:
result["curl_command"] = f"curl -L -o capture.pcap -H 'Authorization: Bearer {download_token}' '{download_url}'"
result["note"] += " The download link includes a 10-minute token."
return result
# ── Tool definitions ───────────────────────────────────────────────────────

View File

@ -23,11 +23,16 @@ via Gns3Connector (from custom_gns3fy).
"""
from typing import Any
from concurrent.futures import ThreadPoolExecutor, as_completed
import logging
from gns3server.services import auth_service
log = logging.getLogger(__name__)
BATCH_MAX_WORKERS = 10
# ── Constants ──────────────────────────────────────────────────────────────
# Maximum bytes to return from get_node_file (safety net).
@ -55,23 +60,82 @@ def get_nodes_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[
return {"error": "project_id is required"}
conn = _get_connector(gns3_ctx)
nodes = conn.http_call("get", f"{conn.base_url}/projects/{project_id}/nodes").json()
fields = params.get("fields")
if fields:
if not isinstance(fields, list):
return {"error": "fields must be a list of field names, e.g. [\"name\", \"status\"]"}
invalid = [f for f in fields if f not in VALID_NODE_FIELDS]
if invalid:
return {
"error": f"Unknown fields: {invalid}",
"available_fields": sorted(VALID_NODE_FIELDS),
}
nodes = [{k: n[k] for k in fields if k in n} for n in nodes]
return {"nodes": nodes, "count": len(nodes)}
VALID_NODE_FIELDS = {
# NodeBase
"compute_id", "name", "node_type", "node_id",
"console", "console_type", "console_auto_start",
"aux", "aux_type", "properties", "label", "symbol",
"x", "y", "z", "locked",
"port_name_format", "port_segment_size", "first_port_name",
"custom_adapters", "tags",
# Node
"template_id", "project_id", "node_directory", "status",
"command_line", "width", "height", "ports", "console_host",
}
def get_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id")
node_id = params.get("node_id")
if not project_id or not node_id:
return {"error": "project_id and node_id are required"}
conn = _get_connector(gns3_ctx)
return conn.http_call("get", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}").json()
node = conn.http_call("get", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}").json()
fields = params.get("fields")
if fields:
if not isinstance(fields, list):
return {"error": "fields must be a list of field names, e.g. [\"name\", \"status\"]"}
invalid = [f for f in fields if f not in VALID_NODE_FIELDS]
if invalid:
return {
"error": f"Unknown fields: {invalid}",
"available_fields": sorted(VALID_NODE_FIELDS),
}
return {k: node[k] for k in fields if k in node}
return node
def _batch_lifecycle(project_id, node_ids, action, conn, action_label):
"""Helper to run a lifecycle action on multiple nodes in parallel."""
def _act(nid):
try:
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{nid}/{action}")
return {"node_id": nid, "status": "success", "message": f"Node {nid} {action_label}"}
except Exception as e:
return {"node_id": nid, "status": "error", "error": str(e)}
with ThreadPoolExecutor(max_workers=min(len(node_ids), BATCH_MAX_WORKERS)) as pool:
return list(pool.map(_act, node_ids))
def start_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id")
if not project_id:
return {"error": "project_id is required"}
node_ids = params.get("node_ids")
if node_ids:
if not isinstance(node_ids, list):
return {"error": "node_ids must be a list"}
conn = _get_connector(gns3_ctx)
return _batch_lifecycle(project_id, node_ids, "start", conn, "started")
node_id = params.get("node_id")
if not project_id or not node_id:
return {"error": "project_id and node_id are required"}
if not node_id:
return {"error": "node_id or node_ids is required"}
conn = _get_connector(gns3_ctx)
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/start", json_data={})
return {"message": f"Node {node_id} started", "node_id": node_id}
@ -79,9 +143,17 @@ def start_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict
def stop_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id")
if not project_id:
return {"error": "project_id is required"}
node_ids = params.get("node_ids")
if node_ids:
if not isinstance(node_ids, list):
return {"error": "node_ids must be a list"}
conn = _get_connector(gns3_ctx)
return _batch_lifecycle(project_id, node_ids, "stop", conn, "stopped")
node_id = params.get("node_id")
if not project_id or not node_id:
return {"error": "project_id and node_id are required"}
if not node_id:
return {"error": "node_id or node_ids is required"}
conn = _get_connector(gns3_ctx)
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/stop", json_data={})
return {"message": f"Node {node_id} stopped", "node_id": node_id}
@ -89,9 +161,17 @@ def stop_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[
def reload_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id")
if not project_id:
return {"error": "project_id is required"}
node_ids = params.get("node_ids")
if node_ids:
if not isinstance(node_ids, list):
return {"error": "node_ids must be a list"}
conn = _get_connector(gns3_ctx)
return _batch_lifecycle(project_id, node_ids, "reload", conn, "reloaded")
node_id = params.get("node_id")
if not project_id or not node_id:
return {"error": "project_id and node_id are required"}
if not node_id:
return {"error": "node_id or node_ids is required"}
conn = _get_connector(gns3_ctx)
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/reload")
return {"message": f"Node {node_id} reloaded", "node_id": node_id}
@ -99,9 +179,17 @@ def reload_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
def suspend_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id")
if not project_id:
return {"error": "project_id is required"}
node_ids = params.get("node_ids")
if node_ids:
if not isinstance(node_ids, list):
return {"error": "node_ids must be a list"}
conn = _get_connector(gns3_ctx)
return _batch_lifecycle(project_id, node_ids, "suspend", conn, "suspended")
node_id = params.get("node_id")
if not project_id or not node_id:
return {"error": "project_id and node_id are required"}
if not node_id:
return {"error": "node_id or node_ids is required"}
conn = _get_connector(gns3_ctx)
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/suspend")
return {"message": f"Node {node_id} suspended", "node_id": node_id}
@ -109,9 +197,41 @@ def suspend_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> di
def create_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id")
if not project_id:
return {"error": "project_id is required"}
nodes = params.get("nodes")
# Batch mode: nodes=[{template_id, x, y, name?, compute_id?}]
if nodes is not None:
if not isinstance(nodes, list) or not nodes:
return {"error": "nodes must be a non-empty array"}
results = []
conn = _get_connector(gns3_ctx)
def _create_one(node_data):
tid = node_data.get("template_id")
if not tid:
return {"template_id": tid, "status": "error", "error": "template_id is required"}
try:
url = f"{conn.base_url}/projects/{project_id}/templates/{tid}"
body = {
"x": node_data.get("x", 0),
"y": node_data.get("y", 0),
"compute_id": node_data.get("compute_id", "local"),
}
resp = conn.http_call("post", url, json_data=body).json()
return {"template_id": tid, "status": "success", "node": resp}
except Exception as e:
return {"template_id": tid, "status": "error", "error": str(e)}
with ThreadPoolExecutor(max_workers=min(len(nodes), BATCH_MAX_WORKERS)) as pool:
futures = {pool.submit(_create_one, n): n for n in nodes}
for future in as_completed(futures):
results.append(future.result())
return results
# Single mode
template_id = params.get("template_id")
if not project_id or not template_id:
return {"error": "project_id and template_id are required"}
if not template_id:
return {"error": "template_id is required"}
conn = _get_connector(gns3_ctx)
data = {
"x": params.get("x", 0),
@ -124,9 +244,24 @@ def create_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic
def delete_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id")
if not project_id:
return {"error": "project_id is required"}
node_ids = params.get("node_ids")
if node_ids:
if not isinstance(node_ids, list):
return {"error": "node_ids must be a list"}
conn = _get_connector(gns3_ctx)
def _del(nid):
try:
conn.http_call("delete", f"{conn.base_url}/projects/{project_id}/nodes/{nid}")
return {"node_id": nid, "status": "deleted"}
except Exception as e:
return {"node_id": nid, "status": "error", "error": str(e)}
with ThreadPoolExecutor(max_workers=min(len(node_ids), BATCH_MAX_WORKERS)) as pool:
return list(pool.map(_del, node_ids))
node_id = params.get("node_id")
if not project_id or not node_id:
return {"error": "project_id and node_id are required"}
if not node_id:
return {"error": "node_id or node_ids is required"}
conn = _get_connector(gns3_ctx)
conn.http_call("delete", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}")
return {"message": f"Node {node_id} deleted", "node_id": node_id}
@ -158,7 +293,14 @@ def get_node_console_info_handler(params: dict[str, Any], gns3_ctx: dict[str, An
node = conn.http_call("get", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}").json()
console_type = node.get("console_type", "unknown")
ws_url = f"{gns3_ctx['server_url']}/v3/projects/{project_id}/nodes/{node_id}/console/ws?token={gns3_ctx['jwt_token']}"
# Short-lived JWT for the WebSocket URL (10 min)
username = gns3_ctx.get("jwt_username")
ws_token = auth_service.create_access_token(username, expires_in=10) if username else None
raw_url = f"{gns3_ctx['server_url']}/v3/projects/{project_id}/nodes/{node_id}/console/ws"
if ws_token:
raw_url += f"?token={ws_token}"
# Convert http scheme to ws for direct websocat usage
ws_url = raw_url.replace("https://", "wss://").replace("http://", "ws://")
result = {
"node_id": node_id,

View File

@ -23,6 +23,8 @@ from typing import Any
import logging
from gns3server.services import auth_service
log = logging.getLogger(__name__)
@ -51,13 +53,18 @@ def get_symbol_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict
if not symbol_id:
return {"error": "symbol_id is required"}
download_url = f"{gns3_ctx['server_url']}/v3/symbols/{symbol_id}/raw"
auth_token = gns3_ctx['jwt_token']
return {
username = gns3_ctx.get("jwt_username")
download_token = auth_service.create_access_token(username, expires_in=10) if username else None
result = {
"symbol_id": symbol_id,
"download_url": download_url,
"curl_command": f"curl -L -o '{symbol_id.replace(':', '').replace('/', '_')}.svg' -H 'Authorization: Bearer {auth_token}' '{download_url}'",
"note": "Symbol files are SVG images. Use curl to download.",
"note": "Symbol files are SVG images.",
}
if download_token:
safe_name = symbol_id.replace(':', '').replace('/', '_')
result["curl_command"] = f"curl -L -o '{safe_name}.svg' -H 'Authorization: Bearer {download_token}' '{download_url}'"
result["note"] += " Download link includes a 10-minute token."
return result
def get_symbol_dimensions_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: