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 | | Tool | Description |
|------|-------------| |------|-------------|
| `node_list` | List all nodes in a project | | `node_list` | List all nodes (`fields` to filter columns, e.g. `["name","status"]`) |
| `node_get` | Get node details | | `node_get` | Get node details (`fields` to filter columns) |
| `node_create` | Create a node from template | | `node_create` | Create node(s) — single via `template_id` or batch via `nodes` array |
| `node_delete` | Delete a node | | `node_delete` | Delete a node |
| `node_update` | Update node properties | | `node_update` | Update node properties |
| `node_start` | Start a node | | `node_start` | Start node(s) — `node_id` or `node_ids` array |
| `node_stop` | Stop a node | | `node_stop` | Stop node(s) — `node_id` or `node_ids` array |
| `node_reload` | Reload a node | | `node_reload` | Reload node(s) — `node_id` or `node_ids` array |
| `node_suspend` | Suspend a node | | `node_suspend` | Suspend node(s) — `node_id` or `node_ids` array |
| `node_console` | Get WebSocket console URL | | `node_console` | Get WebSocket console URL |
| `node_file_list` | List files in node directory | | `node_file_list` | List files in node directory |
| `node_file_get` | Read a file (with offset/limit) | | `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 | | 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_get` | Get link details |
| `link_create` | Create a link between nodes | | `link_create` | Create link(s) — single via `nodes` or batch via `links` array |
| `link_delete` | Delete a link | | `link_delete` | Delete link(s) — `link_id` or `link_ids` array |
| `link_update` | Update link (suspend, filters) | | `link_update` | Update link (suspend, filters) |
| `link_reset` | Reset link (delete + recreate) | | `link_reset` | Reset link(s) — `link_id` or `link_ids` array |
| `link_capture_start` | Start packet capture | | `link_capture_start` | Start capture(s) — `link_id` or `link_ids` array |
| `link_capture_stop` | Stop packet capture | | `link_capture_stop` | Stop capture(s) — `link_id` or `link_ids` array |
| `link_capture_download` | Get PCAP download URL | | `link_capture_download` | Get PCAP download URL(s) — `link_id` or `link_ids` array |
### Template (5) ### Template (5)
@ -184,7 +184,7 @@ Both JWT tokens and API keys work for MCP and REST API endpoints interchangeably
| Tool | Description | | 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_get` | Get appliance details |
| `appliance_install` | Create template from appliance (images must exist locally) | | `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 | | Tool | Description |
|------|-------------| |------|-------------|
| `device_config_send` | Push config commands to devices via console (Nornir + Netmiko) | | `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 | | `device_command_run` | Run read-only show commands on devices. Supports Jinja2 `template` + `vars` |
| `vpcs_config_set` | Configure VPCS devices (IP, gateway, etc.) | | `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 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 ## Configuration
@ -236,20 +326,6 @@ claude mcp add --transport sse My_GNS3_Server \
-H "Authorization: Bearer $TOKEN" -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 ## Transport Security
MCP server uses FastMCP's DNS rebinding protection to prevent attackers from 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 ```mermaid
sequenceDiagram sequenceDiagram
participant Client as Claude Code / Claude Desktop participant Client as Claude Code
participant MCP as MCP Service participant MCP as MCP Service
participant Auth as JWT Auth participant Auth as Auth
participant GNS3 as GNS3 REST API 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) Client->>MCP: GET /sse (token in header or query)
MCP->>Auth: Validate Token MCP->>Auth: Validate Token
Auth-->>MCP: Token Valid Auth-->>MCP: Token Valid
@ -329,7 +405,7 @@ sequenceDiagram
Client->>MCP: POST /messages/ (tools/list) Client->>MCP: POST /messages/ (tools/list)
MCP-->>Client: event: message (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 MCP->>GNS3: Gns3Connector HTTP request
GNS3-->>MCP: Projects data GNS3-->>MCP: Projects data
MCP-->>Client: event: message (tool result) MCP-->>Client: event: message (tool result)
@ -345,7 +421,7 @@ sequenceDiagram
### Console WebSocket ### 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: 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.services import auth_service
from gns3server.utils.request_utils import extract_client_info from gns3server.utils.request_utils import extract_client_info
from gns3server.db.repositories.api_keys import ApiKeysRepository from gns3server.db.repositories.api_keys import ApiKeysRepository
from gns3server.db.repositories.users import UsersRepository
from .projects import ( from .projects import (
list_projects_handler, get_project_handler, create_project_handler, list_projects_handler, get_project_handler, create_project_handler,
delete_project_handler, open_project_handler, close_project_handler, delete_project_handler, open_project_handler, close_project_handler,
@ -118,6 +119,9 @@ from .drawings import (
log = logging.getLogger(__name__) 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. # 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 # 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. # 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( _jwt_token_var: contextvars.ContextVar[str | None] = contextvars.ContextVar(
"mcp_jwt_token", default=None "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 ────────────────────────────────────────────────── # ── Token validation ──────────────────────────────────────────────────
@ -198,12 +207,13 @@ async def _resolve_token(token: str) -> str | None:
""" """
# Try JWT first # Try JWT first
try: try:
auth_service.get_username_from_token(token) username = auth_service.get_username_from_token(token)
_jwt_username_var.set(username)
return token return token
except Exception: except Exception:
pass 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: if token.startswith("gns3_") and _app is not None:
db_engine = getattr(_app.state, "_db_engine", None) db_engine = getattr(_app.state, "_db_engine", None)
if db_engine is not 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(): for db_key in result.scalars().all():
if bcrypt.checkpw(token.encode(), db_key.key_hash.encode()): if bcrypt.checkpw(token.encode(), db_key.key_hash.encode()):
await repo.update_last_used(db_key.api_key_id) await repo.update_last_used(db_key.api_key_id)
# Return the raw API key — it will be passed as Bearer token user_repo = UsersRepository(db_session)
# and validated by the REST API auth layer user = await user_repo.get_user(db_key.user_id)
if user:
_jwt_username_var.set(user.username)
return token return token
except Exception: except Exception:
pass pass
@ -274,6 +286,7 @@ def _run_handler_sync(handler, params: dict[str, Any]) -> list[dict[str, Any]]:
ctx = { ctx = {
"server_url": _server_url(), "server_url": _server_url(),
"jwt_token": _jwt_token_var.get(), "jwt_token": _jwt_token_var.get(),
"jwt_username": _jwt_username_var.get(),
} }
result = handler(params, ctx) result = handler(params, ctx)
return [{"type": "text", "text": json.dumps(result, ensure_ascii=False, default=str)}] return [{"type": "text", "text": json.dumps(result, ensure_ascii=False, default=str)}]
@ -297,8 +310,8 @@ async def project_get(
async def project_create( async def project_create(
name: Annotated[str, Field(description="Project name")], name: Annotated[str, Field(description="Project name")],
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Create a new GNS3 project.""" """Create a new GNS3 project. auto_close is set to False so the project stays open when clients disconnect."""
params = {"name": name} params = {"name": name, "auto_close": False}
return await asyncio.to_thread(_run_handler_sync, create_project_handler, params) return await asyncio.to_thread(_run_handler_sync, create_project_handler, params)
@ -396,61 +409,100 @@ async def project_readme_update(
# ── Node tools ──────────────────────────────────────────────────────── # ── Node tools ────────────────────────────────────────────────────────
@mcp.tool() @mcp.tool()
async def node_list(project_id: str) -> list[dict[str, Any]]: async def node_list(
"""List all nodes in a project.""" project_id: Annotated[str, Field(description="UUID of the project")],
return await asyncio.to_thread(_run_handler_sync, get_nodes_handler, {"project_id": project_id}) 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() @mcp.tool()
async def node_get( async def node_get(
project_id: Annotated[str, Field(description="UUID of the project")], project_id: Annotated[str, Field(description="UUID of the project")],
node_id: Annotated[str, Field(description="UUID of the node")], 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]]: ) -> list[dict[str, Any]]:
"""Get detailed information about a specific node.""" """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}) return await asyncio.to_thread(_run_handler_sync, get_node_handler, {
"project_id": project_id, "node_id": node_id, "fields": fields,
})
@mcp.tool() @mcp.tool()
async def node_start( async def node_start(
project_id: Annotated[str, Field(description="UUID of the project")], 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]]: ) -> list[dict[str, Any]]:
"""Start a node in a project.""" """Start one or more nodes. Provide node_id for single, or node_ids for batch."""
return await asyncio.to_thread(_run_handler_sync, start_node_handler, {"project_id": project_id, "node_id": node_id}) 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() @mcp.tool()
async def node_stop( async def node_stop(
project_id: Annotated[str, Field(description="UUID of the project")], 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]]: ) -> list[dict[str, Any]]:
"""Stop a node in a project.""" """Stop one or more nodes. Provide node_id for single, or node_ids for batch."""
return await asyncio.to_thread(_run_handler_sync, stop_node_handler, {"project_id": project_id, "node_id": node_id}) 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() @mcp.tool()
async def node_reload( async def node_reload(
project_id: Annotated[str, Field(description="UUID of the project")], 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]]: ) -> list[dict[str, Any]]:
"""Reload (restart) a node in a project.""" """Reload (restart) one or more nodes. Provide node_id for single, or node_ids for batch."""
return await asyncio.to_thread(_run_handler_sync, reload_node_handler, {"project_id": project_id, "node_id": node_id}) 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() @mcp.tool()
async def node_suspend( async def node_suspend(
project_id: Annotated[str, Field(description="UUID of the project")], 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]]: ) -> list[dict[str, Any]]:
"""Suspend a node in a project.""" """Suspend one or more nodes. Provide node_id for single, or node_ids for batch."""
return await asyncio.to_thread(_run_handler_sync, suspend_node_handler, {"project_id": project_id, "node_id": node_id}) 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() @mcp.tool()
async def node_create( async def node_create(
project_id: Annotated[str, Field(description="UUID of the project")], 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")], template_id: Annotated[str | None, Field(description="Template UUID (required for single mode)")] = None,
x: Annotated[int, Field(description="X coordinate on the project canvas")] = 0, x: Annotated[int, Field(description="X coordinate")] = 0,
y: Annotated[int, Field(description="Y coordinate on the project canvas")] = 0, y: Annotated[int, Field(description="Y coordinate")] = 0,
compute_id: Annotated[str, Field(description="Compute ID (default: local)")] = "local", 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]]: ) -> 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, { return await asyncio.to_thread(_run_handler_sync, create_node_handler, {
"project_id": project_id, "template_id": template_id, "project_id": project_id, "template_id": template_id,
"x": x, "y": y, "compute_id": compute_id, "x": x, "y": y, "compute_id": compute_id,
@ -460,10 +512,16 @@ async def node_create(
@mcp.tool() @mcp.tool()
async def node_delete( async def node_delete(
project_id: Annotated[str, Field(description="UUID of the project")], 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]]: ) -> list[dict[str, Any]]:
"""Delete a node from a project.""" """Delete one or more nodes from a project. Provide node_id for single, or node_ids for batch."""
return await asyncio.to_thread(_run_handler_sync, delete_node_handler, {"project_id": project_id, "node_id": node_id}) 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() @mcp.tool()
@ -486,6 +544,7 @@ async def node_console(
Returns the WebSocket URL, console type (telnet/ssh/vnc), and other Returns the WebSocket URL, console type (telnet/ssh/vnc), and other
connection details needed to interact with a node's console via WebSocket. 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: Complete workflow:
1. Call this tool with project_id and node_id to get the WebSocket URL 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 ──────────────────────────────────────────────────────── # ── Link tools ────────────────────────────────────────────────────────
@mcp.tool() @mcp.tool()
async def link_list(project_id: str) -> list[dict[str, Any]]: async def link_list(
"""List all links in a project.""" project_id: Annotated[str, Field(description="UUID of the project")],
return await asyncio.to_thread(_run_handler_sync, get_links_handler, {"project_id": project_id}) 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() @mcp.tool()
@ -527,21 +589,20 @@ async def link_get(
@mcp.tool() @mcp.tool()
async def link_create( async def link_create(
project_id: Annotated[str, Field(description="UUID of the project")], 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", 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]]: ) -> 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: Single mode: provide nodes, link_type (optional filters)
- frequency_drop: [N] - Drop every Nth packet (N: -1 to 32767) Batch mode: provide links=[{nodes, link_type?, filters?}] — up to 10 in parallel
- 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]}}
""" """
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} params = {"project_id": project_id, "nodes": nodes, "link_type": link_type}
if filters: if filters:
params["filters"] = filters params["filters"] = filters
@ -551,10 +612,16 @@ async def link_create(
@mcp.tool() @mcp.tool()
async def link_delete( async def link_delete(
project_id: Annotated[str, Field(description="UUID of the project")], 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]]: ) -> list[dict[str, Any]]:
"""Delete a link from a project.""" """Delete one or more links from a project."""
return await asyncio.to_thread(_run_handler_sync, delete_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, delete_link_handler, params)
@mcp.tool() @mcp.tool()
@ -848,9 +915,10 @@ async def node_links(
@mcp.tool() @mcp.tool()
async def link_reset( async def link_reset(
project_id: Annotated[str, Field(description="UUID of the project")], 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]]: ) -> 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: Use cases:
- Clear accumulated packet errors/drops from the link's UDP connection - 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. Filters are preserved but their internal application state resets.
""" """
return await asyncio.to_thread(_run_handler_sync, reset_link_handler, { params = {"project_id": project_id}
"project_id": project_id, "link_id": link_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() @mcp.tool()
async def link_capture_start( async def link_capture_start(
project_id: Annotated[str, Field(description="UUID of the project")], 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", 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, capture_file_name: Annotated[str | None, Field(description="Capture file name (optional)")] = None,
wireshark: Annotated[bool, Field(description="Open Wireshark automatically (default: false)")] = False, 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]]: ) -> list[dict[str, Any]]:
"""Start packet capture on a link. The capture file can later be downloaded with download_capture_file.""" """Start packet capture on one or more links."""
return await asyncio.to_thread(_run_handler_sync, start_capture_handler, { params = {"project_id": project_id, "data_link_type": data_link_type, "capture_file_name": capture_file_name, "wireshark": wireshark}
"project_id": project_id, "link_id": link_id, if link_ids:
"data_link_type": data_link_type, "capture_file_name": capture_file_name, params["link_ids"] = link_ids
"wireshark": wireshark, else:
}) params["link_id"] = link_id
return await asyncio.to_thread(_run_handler_sync, start_capture_handler, params)
@mcp.tool() @mcp.tool()
async def link_capture_stop( async def link_capture_stop(
project_id: Annotated[str, Field(description="UUID of the project")], 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]]: ) -> list[dict[str, Any]]:
"""Stop packet capture on a link. After stopping, the capture file can be downloaded.""" """Stop packet capture on one or more links."""
return await asyncio.to_thread(_run_handler_sync, stop_capture_handler, { params = {"project_id": project_id}
"project_id": project_id, "link_id": link_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() @mcp.tool()
async def link_capture_download( async def link_capture_download(
project_id: Annotated[str, Field(description="UUID of the project")], 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]]: ) -> list[dict[str, Any]]:
"""Get the download URL and instructions for a PCAP capture file. Use curl to download.""" """Get download URL(s) for PCAP capture file(s). The URL includes a short-lived JWT (10 min). Use curl to download."""
return await asyncio.to_thread(_run_handler_sync, download_capture_file_handler, { params = {"project_id": project_id}
"project_id": project_id, "link_id": link_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 ───────────────────────────────────────────────────── # ── Snapshot tools ─────────────────────────────────────────────────────
@ -920,7 +1001,12 @@ async def snapshot_create(
project_id: Annotated[str, Field(description="UUID of the project")], project_id: Annotated[str, Field(description="UUID of the project")],
name: Annotated[str, Field(description="Name for the new snapshot")], name: Annotated[str, Field(description="Name for the new snapshot")],
) -> list[dict[str, Any]]: ) -> 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, { return await asyncio.to_thread(_run_handler_sync, create_snapshot_handler, {
"project_id": project_id, "name": name, "project_id": project_id, "name": name,
}) })
@ -1105,7 +1191,7 @@ async def symbol_list() -> list[dict[str, Any]]:
async def symbol_get( async def symbol_get(
symbol_id: Annotated[str, Field(description="Symbol ID (e.g. ':/symbols/router.svg')")], symbol_id: Annotated[str, Field(description="Symbol ID (e.g. ':/symbols/router.svg')")],
) -> list[dict[str, Any]]: ) -> 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, { return await asyncio.to_thread(_run_handler_sync, get_symbol_handler, {
"symbol_id": symbol_id, "symbol_id": symbol_id,
}) })
@ -1157,9 +1243,11 @@ async def symbol_delete(
@mcp.tool() @mcp.tool()
async def appliance_list() -> list[dict[str, Any]]: async def appliance_list(
"""List all available appliances (template library).""" 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,
return await asyncio.to_thread(_run_handler_sync, get_appliances_handler, {}) ) -> 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() @mcp.tool()
@ -1256,16 +1344,23 @@ async def device_config_send(
device_configs: Annotated[list, Field( 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\"]}" 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]]: ) -> list[dict[str, Any]]:
"""Send configuration commands to network devices via console (telnet/SSH). """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). 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. 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 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, { params = {"project_id": project_id, "device_configs": device_configs}
"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() @mcp.tool()
@ -1274,15 +1369,21 @@ async def device_command_run(
device_configs: Annotated[list, Field( device_configs: Annotated[list, Field(
description="List of device commands. Each entry: {\"device_name\": \"R1\", \"show_commands\": [\"show ip int brief\", \"show running-config\"]}" 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]]: ) -> list[dict[str, Any]]:
"""Run read-only diagnostic (show) commands on network devices via console. """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. Use this to inspect device status, view configurations, or verify changes.
Devices must be started first. Devices must be started first.
""" """
return await asyncio.to_thread(_run_handler_sync, device_command_run_handler, { params = {"project_id": project_id, "device_configs": device_configs}
"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() @mcp.tool()

View File

@ -40,9 +40,30 @@ def _get_connector(gns3_ctx: dict[str, Any]):
# ── Tool handlers ────────────────────────────────────────────────────────── # ── 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]: def get_appliances_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
conn = _get_connector(gns3_ctx) conn = _get_connector(gns3_ctx)
appliances = conn.http_call("get", f"{conn.base_url}/appliances").json() 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)} return {"appliances": appliances, "count": len(appliances)}

View File

@ -33,18 +33,61 @@ import json
import logging import logging
from typing import Any from typing import Any
from jinja2 import Template as JinjaTemplate, TemplateError as JinjaError
log = logging.getLogger(__name__) 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 ────────────────────────────────────────────────────────── # ── Tool handlers ──────────────────────────────────────────────────────────
def device_config_send_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> list[dict[str, Any]]: 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.""" """Send configuration commands to network devices via console."""
project_id = params.get("project_id") project_id = params.get("project_id")
device_configs = params.get("device_configs") device_configs = params.get("device_configs")
template = params.get("template")
if not project_id or not device_configs: if not project_id or not device_configs:
return [{"error": "project_id and device_configs are required"}] 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 from gns3server.agent.gns3_copilot.tools_v2.config_tools_nornir import ExecuteMultipleDeviceConfigCommands
tool = 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.""" """Run read-only diagnostic (show) commands on network devices."""
project_id = params.get("project_id") project_id = params.get("project_id")
device_configs = params.get("device_configs") device_configs = params.get("device_configs")
template = params.get("template")
if not project_id or not device_configs: if not project_id or not device_configs:
return [{"error": "project_id and device_configs (list of {device_name, show_commands}) are required"}] 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 from gns3server.agent.gns3_copilot.tools_v2.display_tools_nornir import ExecuteMultipleDeviceCommands
tool = ExecuteMultipleDeviceCommands() tool = ExecuteMultipleDeviceCommands()

View File

@ -23,11 +23,16 @@ via Gns3Connector (from custom_gns3fy).
""" """
from typing import Any from typing import Any
from concurrent.futures import ThreadPoolExecutor, as_completed
import logging import logging
from gns3server.services import auth_service
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
BATCH_MAX_WORKERS = 10
# ── Helper ───────────────────────────────────────────────────────────────── # ── Helper ─────────────────────────────────────────────────────────────────
@ -43,12 +48,31 @@ def _get_connector(gns3_ctx: dict[str, Any]):
# ── Tool handlers ────────────────────────────────────────────────────────── # ── 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]: def get_links_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id") project_id = params.get("project_id")
if not project_id: if not project_id:
return {"error": "project_id is required"} return {"error": "project_id is required"}
conn = _get_connector(gns3_ctx) conn = _get_connector(gns3_ctx)
links = conn.http_call("get", f"{conn.base_url}/projects/{project_id}/links").json() 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)} 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]: def create_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id") 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") nodes = params.get("nodes")
if not project_id or not nodes: if not nodes:
return {"error": "project_id and nodes are required"} return {"error": "nodes is required"}
conn = _get_connector(gns3_ctx) conn = _get_connector(gns3_ctx)
data = {"nodes": nodes} data = {"nodes": nodes}
if "link_type" in params: 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]: def delete_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id") 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") link_id = params.get("link_id")
if not project_id or not link_id: if not link_id:
return {"error": "project_id and link_id are required"} return {"error": "link_id or link_ids is required"}
conn = _get_connector(gns3_ctx) conn = _get_connector(gns3_ctx)
conn.http_call("delete", f"{conn.base_url}/projects/{project_id}/links/{link_id}") 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} 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]: def reset_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id") 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") link_id = params.get("link_id")
if not project_id or not link_id: if not link_id:
return {"error": "project_id and link_id are required"} return {"error": "link_id or link_ids is required"}
conn = _get_connector(gns3_ctx) conn = _get_connector(gns3_ctx)
url = f"{conn.base_url}/projects/{project_id}/links/{link_id}/reset" url = f"{conn.base_url}/projects/{project_id}/links/{link_id}/reset"
result = conn.http_call("post", url).json() result = conn.http_call("post", url).json()
return {"message": f"Link {link_id} reset", "link": result} 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]: def start_capture_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id") 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") link_id = params.get("link_id")
if not project_id or not link_id: if not link_id:
return {"error": "project_id and link_id are required"} return {"error": "link_id or link_ids is required"}
conn = _get_connector(gns3_ctx) conn = _get_connector(gns3_ctx)
data = { data = {
"data_link_type": params.get("data_link_type", "DLT_EN10MB"), "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]: def stop_capture_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id") 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") link_id = params.get("link_id")
if not project_id or not link_id: if not link_id:
return {"error": "project_id and link_id are required"} return {"error": "link_id or link_ids is required"}
conn = _get_connector(gns3_ctx) conn = _get_connector(gns3_ctx)
url = f"{conn.base_url}/projects/{project_id}/links/{link_id}/capture/stop" url = f"{conn.base_url}/projects/{project_id}/links/{link_id}/capture/stop"
conn.http_call("post", url) 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]: def download_capture_file_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id") 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") link_id = params.get("link_id")
if not project_id or not link_id: if not link_id:
return {"error": "project_id and link_id are required"} 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" download_url = f"{gns3_ctx['server_url']}/v3/projects/{project_id}/links/{link_id}/capture/file"
auth_token = gns3_ctx['jwt_token'] result = {
return {
"link_id": link_id, "link_id": link_id,
"download_url": download_url, "download_url": download_url,
"curl_command": f"curl -L -o capture.pcap -H 'Authorization: Bearer {auth_token}' '{download_url}'", "note": "The file is in pcap format and can be analyzed with Wireshark or tcpdump.",
"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.",
} }
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 ─────────────────────────────────────────────────────── # ── Tool definitions ───────────────────────────────────────────────────────

View File

@ -23,11 +23,16 @@ via Gns3Connector (from custom_gns3fy).
""" """
from typing import Any from typing import Any
from concurrent.futures import ThreadPoolExecutor, as_completed
import logging import logging
from gns3server.services import auth_service
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
BATCH_MAX_WORKERS = 10
# ── Constants ────────────────────────────────────────────────────────────── # ── Constants ──────────────────────────────────────────────────────────────
# Maximum bytes to return from get_node_file (safety net). # 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"} return {"error": "project_id is required"}
conn = _get_connector(gns3_ctx) conn = _get_connector(gns3_ctx)
nodes = conn.http_call("get", f"{conn.base_url}/projects/{project_id}/nodes").json() 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)} 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]: def get_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id") project_id = params.get("project_id")
node_id = params.get("node_id") node_id = params.get("node_id")
if not project_id or not node_id: if not project_id or not node_id:
return {"error": "project_id and node_id are required"} return {"error": "project_id and node_id are required"}
conn = _get_connector(gns3_ctx) 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]: def start_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id") 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") node_id = params.get("node_id")
if not project_id or not node_id: if not node_id:
return {"error": "project_id and node_id are required"} return {"error": "node_id or node_ids is required"}
conn = _get_connector(gns3_ctx) conn = _get_connector(gns3_ctx)
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/start", json_data={}) 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} 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]: def stop_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id") 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") node_id = params.get("node_id")
if not project_id or not node_id: if not node_id:
return {"error": "project_id and node_id are required"} return {"error": "node_id or node_ids is required"}
conn = _get_connector(gns3_ctx) conn = _get_connector(gns3_ctx)
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/stop", json_data={}) 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} 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]: def reload_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id") 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") node_id = params.get("node_id")
if not project_id or not node_id: if not node_id:
return {"error": "project_id and node_id are required"} return {"error": "node_id or node_ids is required"}
conn = _get_connector(gns3_ctx) conn = _get_connector(gns3_ctx)
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/reload") 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} 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]: def suspend_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id") 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") node_id = params.get("node_id")
if not project_id or not node_id: if not node_id:
return {"error": "project_id and node_id are required"} return {"error": "node_id or node_ids is required"}
conn = _get_connector(gns3_ctx) conn = _get_connector(gns3_ctx)
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/suspend") 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} 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]: def create_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id") 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") template_id = params.get("template_id")
if not project_id or not template_id: if not template_id:
return {"error": "project_id and template_id are required"} return {"error": "template_id is required"}
conn = _get_connector(gns3_ctx) conn = _get_connector(gns3_ctx)
data = { data = {
"x": params.get("x", 0), "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]: def delete_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id") 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") node_id = params.get("node_id")
if not project_id or not node_id: if not node_id:
return {"error": "project_id and node_id are required"} return {"error": "node_id or node_ids is required"}
conn = _get_connector(gns3_ctx) conn = _get_connector(gns3_ctx)
conn.http_call("delete", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}") 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} 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() node = conn.http_call("get", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}").json()
console_type = node.get("console_type", "unknown") 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 = { result = {
"node_id": node_id, "node_id": node_id,

View File

@ -23,6 +23,8 @@ from typing import Any
import logging import logging
from gns3server.services import auth_service
log = logging.getLogger(__name__) 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: if not symbol_id:
return {"error": "symbol_id is required"} return {"error": "symbol_id is required"}
download_url = f"{gns3_ctx['server_url']}/v3/symbols/{symbol_id}/raw" download_url = f"{gns3_ctx['server_url']}/v3/symbols/{symbol_id}/raw"
auth_token = gns3_ctx['jwt_token'] username = gns3_ctx.get("jwt_username")
return { download_token = auth_service.create_access_token(username, expires_in=10) if username else None
result = {
"symbol_id": symbol_id, "symbol_id": symbol_id,
"download_url": download_url, "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.",
"note": "Symbol files are SVG images. Use curl to download.",
} }
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]: def get_symbol_dimensions_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: