Merge pull request #2861 from yueguobin/refactor/gns3-client-and-mcp-fixes

refactor: sink shared REST handlers into gns3_client + MCP regression fixes
This commit is contained in:
Jeremy Grossmann 2026-08-25 19:28:43 +02:00 committed by GitHub
commit ae317608e8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
58 changed files with 3098 additions and 5482 deletions

View File

@ -49,7 +49,7 @@ Tools are separated by domain into individual files under `gns3server/api/routes
- Synchronous functions receiving `(params: dict, gns3_ctx: dict)`
- Run via `asyncio.to_thread()` to avoid blocking the event loop
- `gns3_ctx` contains `server_url` and `jwt_token`
- `Gns3Connector` is created per-handler from `custom_gns3fy`
- `Gns3Connector` is created per-handler from `gns3_client.connector` (per-handler instantiation keeps each tool call isolated)
### Token Lifetime
- Default: 1440 minutes (24 hours)
@ -61,14 +61,11 @@ Tools are separated by domain into individual files under `gns3server/api/routes
- **Why not stdio**: stdio is local-only; SSE supports both local and remote deployments
## Related Files
- `gns3server/api/routes/mcp/__init__.py` — FastMCP server, tool decorators, auth wrapper
- `gns3server/api/routes/mcp/projects.py` — Project tool handlers
- `gns3server/api/routes/mcp/nodes.py` — Node tool handlers
- `gns3server/api/routes/mcp/links.py` — Link tool handlers
- `gns3server/api/routes/mcp/templates.py` — Template tool handlers
- `gns3server/api/routes/mcp/computes.py` — Compute tool handlers
- `gns3server/agent/gns3_copilot/gns3_client/custom_gns3fy.py` — Gns3Connector client
- `gns3server/api/server.py:87` — MCP route registration
- `gns3server/agent/mcp/__init__.py` — FastMCP server, `@mcp.tool()` decorators, auth wrapper (`_resolve_token` exchanges API keys for JWTs)
- `gns3server/agent/mcp/*.py` — tool handlers for projects/templates/computes/snapshots/drawings/symbols/appliances/images
- `gns3server/agent/gns3_copilot/gns3_client/api_handlers.py` — shared node/link handler layer (single implementation, consumed by both MCP tools and copilot `tools_v2`; tests must patch `_get_connector` HERE, not in mcp modules)
- `gns3server/agent/gns3_copilot/gns3_client/connector.py` — Gns3Connector (JWT auth + http_call only; the old `custom_gns3fy.py` Node/Link/Project wrappers were removed)
- `gns3server/agent/gns3_copilot/gns3_client/project_inventory.py` — nodes/links aggregation feeding the topology context and Nornir inventory
## Configuration

View File

@ -94,9 +94,9 @@ All subsequent tool handler REST API calls use this JWT → zero extra bcrypt
## Available Tools
**82 tools** across 12 categories:
**76 tools** across 11 categories:
### Project (15)
### Project (14)
| Tool | Description |
|------|-------------|
@ -113,7 +113,6 @@ All subsequent tool handler REST API calls use this JWT → zero extra bcrypt
| `project_readme_update` | Update project README |
| `project_lock` | Lock project (prevent edits) |
| `project_unlock` | Unlock project |
| `project_load` | Load project from path |
| `project_locked` | Check if project is locked |
### Node (22)
@ -192,16 +191,11 @@ All subsequent tool handler REST API calls use this JWT → zero extra bcrypt
| `drawing_update` | Update drawing (position, rotation, SVG) |
| `drawing_delete` | Delete a drawing |
### Symbol (6)
| Tool | Description |
|------|-------------|
| `symbol_list` | List all symbols |
| `symbol_get` | Get symbol download URL |
| `symbol_dimensions` | Get symbol dimensions |
| `symbol_defaults` | Get default symbol mapping |
| `symbol_upload` | Upload a custom symbol (SVG content) |
| `symbol_delete` | Delete a custom symbol (built-in: 403) |
<!--
Symbol tools (symbol_list / symbol_get / symbol_dimensions /
symbol_defaults / symbol_upload / symbol_delete) are disabled for now:
they require a vision-capable model to be genuinely useful. Revisit later.
-->
### Appliance (3)
@ -440,7 +434,7 @@ sequenceDiagram
- **FastMCP** (Anthropic MCP SDK) is used for tool registration and SSE transport
- The SSE app is mounted as a Starlette sub-application under `/v3/mcp/transport`
- **Auth:** JWT validation via `auth_service`. API key (`gns3_<uuid>_<secret>`) extracts UUID for O(1) DB lookup, runs bcrypt in thread pool, returns a fresh JWT — subsequent calls use the JWT with zero extra bcrypt.
- Tool handlers use `Gns3Connector` (from `custom_gns3fy`) to call GNS3's own REST API, keeping the MCP layer decoupled
- Tool handlers use `Gns3Connector` (from `gns3_copilot.gns3_client.connector`) via the shared handler layer (`gns3_copilot.gns3_client.api_handlers`), keeping the MCP layer decoupled
- The JWT token is stored in a `contextvars.ContextVar` so it is available within tool handler threads (Python ≥ 3.9 propagates contextvars through `asyncio.to_thread`)
### Console WebSocket

View File

@ -114,8 +114,8 @@ if await asyncio.to_thread(bcrypt.checkpw, secret.encode(), db_key.key_hash.enco
| Node creation Pool | 5 | 100 | `controller/project.py` |
| Link creation Pool | 5 | 100 | `controller/project.py` |
| MCP BATCH_MAX_WORKERS | 10 | 100 | `agent/mcp/nodes.py` |
| MCP HTTP timeout | 10s | 30s | `agent/gns3_copilot/gns3_client/custom_gns3fy.py` |
| HTTP connection pool | 10 (default) | 500/1000 | `agent/gns3_copilot/gns3_client/custom_gns3fy.py` |
| MCP HTTP timeout | 10s | 30s | `agent/gns3_copilot/gns3_client/connector.py` |
| HTTP connection pool | 10 (default) | 500/1000 | `agent/gns3_copilot/gns3_client/connector.py` |
| Start nodes Pool | 3 | 3 (unchanged) | `controller/project.py` |
### 8. MCP Auth Returns JWT
@ -144,5 +144,5 @@ if user:
| `gns3server/agent/mcp/__init__.py` | Auth returns JWT, tool enhancements |
| `gns3server/agent/mcp/nodes.py` | fields filter, inherited template_id, name passthrough |
| `gns3server/agent/mcp/links.py` | fields filter, compact array format |
| `gns3server/agent/gns3_copilot/gns3_client/custom_gns3fy.py` | Timeout 30s, connection pool 500/1000 |
| `gns3server/agent/gns3_copilot/gns3_client/connector.py` | Timeout 30s, connection pool 500/1000 |
| `gns3server/utils/images.py` | md5sum cache error → warning |

View File

@ -516,29 +516,39 @@ gns3server/agent/gns3_copilot/tools_v2/
### API Integration
The tools use the `Node` and `Link` classes from `custom_gns3fy`:
The tools call the shared REST handler layer (`gns3_copilot.gns3_client.api_handlers`), the same functions the MCP service exposes as MCP tools:
```python
from gns3server.agent.gns3_copilot.gns3_client import Node, Link, get_gns3_connector
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
build_gns3_ctx, create_node_handler, create_link_handler,
get_nodes_handler, start_node_handler, stop_node_handler,
suspend_node_handler, update_node_handler,
)
# Get templates
templates = get_gns3_connector().get_templates()
gns3_ctx = build_gns3_ctx() # JWT + server URL from the request context
# Create node
node = Node(project_id=project_id, template_id=template_id, x=x, y=y, connector=gns3_server)
node.create()
# Create node (single POST, batch mode is parallel)
created = create_node_handler(
{"project_id": project_id, "template_id": template_id, "x": x, "y": y, "name": name},
gns3_ctx,
)
# Create link
link = Link(project_id=project_id, connector=gns3_server, nodes=[...])
link.create()
link = create_link_handler(
{"project_id": project_id, "nodes": [{"node_id": nid, "adapter_number": 0, "port_number": 0}, ...]},
gns3_ctx,
)
# Update node name
node = Node(project_id=project_id, node_id=node_id, connector=gns3_server)
node.update(name=new_name)
# Update node name — the PUT response is the updated node
updated = update_node_handler({"project_id": project_id, "node_id": node_id, "name": new_name}, gns3_ctx)
# Start/stop/suspend node
node = Node(project_id=project_id, node_id=node_id, connector=gns3_server)
node.start() # or node.stop() / node.suspend()
# Start/stop/suspend nodes (node_ids batch runs in parallel)
start_node_handler({"project_id": project_id, "node_ids": [nid1, nid2]}, gns3_ctx)
stop_node_handler({"project_id": project_id, "node_ids": [nid1, nid2]}, gns3_ctx)
suspend_node_handler({"project_id": project_id, "node_ids": [nid1, nid2]}, gns3_ctx)
# Node listing/status (single call for the whole project)
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
```
### Progress Tracking

View File

@ -26,27 +26,27 @@
"""
GNS3 Client Package
This package provides a Python interface for interacting with GNS3 servers.
Adapted from the upstream gns3fy project with modifications for compatibility
with langchain and reduced dependency conflicts.
This package provides the shared GNS3 REST client layer:
Main classes:
- Gns3Connector: Connector for GNS3 server API interaction
- Project: GNS3 Project management
- Node: GNS3 Node management
- Link: GNS3 Link management
- GNS3TopologyTool: GNS3 topology reading tool
- GNS3ProjectInfoTool: GNS3 project info tool
- Gns3Connector (connector.py): authenticated HTTP session for the
controller API v2 basic / v3 JWT auth, token refresh, error extraction
- api_handlers.py: endpoint handlers taking ``(params, gns3_ctx)`` dicts,
shared by the copilot tools and the MCP service
- project_inventory.py: nodes/links aggregation for the topology context
- GNS3TopologyTool / GNS3ProjectInfoTool: LangChain reader tools
Main functions:
- get_gns3_connector: Factory function to create Gns3Connector
- get_gns3_connector_with_llm_config: Create connector AND retrieve LLM config
- get_gns3_server_host: Get GNS3 server hostname from Controller or Config
- get_llm_config: Get LLM model configuration for a user
- get_llm_config: Get user's default LLM config with API key
Upstream gns3fy: https://github.com/davidban77/gns3fy
The connector is adapted from the upstream gns3fy project
(https://github.com/davidban77/gns3fy).
"""
from .api_handlers import build_gns3_ctx
from .connector import Gns3Connector
from .connector_factory import get_gns3_connector
from .connector_factory import get_gns3_connector_with_llm_config
from .connector_factory import get_gns3_server_host
@ -55,13 +55,6 @@ from .context_helpers import get_current_jwt_token
from .context_helpers import get_current_llm_config
from .context_helpers import set_current_jwt_token
from .context_helpers import set_current_llm_config
from .custom_gns3fy import CONSOLE_TYPES
from .custom_gns3fy import LINK_TYPES
from .custom_gns3fy import NODE_TYPES
from .custom_gns3fy import Gns3Connector
from .custom_gns3fy import Link
from .custom_gns3fy import Node
from .custom_gns3fy import Project
from .gns3_project_info import GNS3ProjectInfoTool
from .gns3_topology_reader import GNS3TopologyTool
@ -79,12 +72,7 @@ __url__ = "https://github.com/yueguobin/gns3-copilot"
__all__ = [
"Gns3Connector",
"Project",
"Node",
"Link",
"NODE_TYPES",
"CONSOLE_TYPES",
"LINK_TYPES",
"build_gns3_ctx",
"GNS3TopologyTool",
"GNS3ProjectInfoTool",
"get_gns3_connector",

View File

@ -0,0 +1,997 @@
# SPDX-License-Identifier: GPL-3.0-or-later
#
# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3
#
# This file is part of GNS3-Copilot project.
#
# GNS3-Copilot is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the
# Free Software Foundation, either version 3 of the License, or (at your
# option) any later version.
#
# GNS3-Copilot is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
# for more details.
#
# You should have received a copy of the GNU General Public License
# along with GNS3-Copilot. If not, see <https://www.gnu.org/licenses/>.
#
# Copyright (C) 2025 Yue Guobin (岳国宾)
# Author: Yue Guobin (岳国宾)
#
# Project Home: https://github.com/yueguobin/gns3-copilot
#
"""
Shared GNS3 REST API handler layer.
Handlers receive ``(params: dict, gns3_ctx: dict)`` and call the GNS3 REST
API directly via ``Gns3Connector.http_call`` no ORM-style wrapper objects.
This module is the single implementation shared by two consumers:
- the MCP service (``gns3server.agent.mcp``) re-exports these handlers as
MCP tools, and
- gns3-copilot tools (``tools_v2``) call them directly.
``gns3_ctx`` carries the per-request connection info:
- ``server_url`` (str): GNS3 server base URL
- ``jwt_token`` (str): a JWT API keys must be exchanged for a JWT by the
entry point before calling handlers (see ``mcp._resolve_token``)
- ``jwt_username`` / ``jwt_token_version`` (optional): only needed by
handlers that mint short-lived tokens for console/download URLs
Copilot-side callers build the context with :func:`build_gns3_ctx`, which
pulls the request-scoped user JWT from the context variables.
"""
from typing import Any
from concurrent.futures import ThreadPoolExecutor
import hashlib
import logging
from gns3server.services import auth_service
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
log = logging.getLogger(__name__)
BATCH_MAX_WORKERS = 100
# ── Constants ──────────────────────────────────────────────────────────────
# Maximum bytes to return from get_node_file (safety net).
# Larger files are truncated with a truncated=True flag.
MAX_NODE_FILE_BYTES = 50 * 1024 # 50 KiB
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",
}
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",
}
LINK_DEFAULT_FIELDS = ["link_id", "link_type", "nodes"]
# ── Helpers ────────────────────────────────────────────────────────────────
def _get_connector(gns3_ctx: dict[str, Any]):
return Gns3Connector(
url=gns3_ctx["server_url"],
jwt_token=gns3_ctx["jwt_token"],
api_version=3,
verify=False,
)
def build_gns3_ctx(
jwt_token: str | None = None, url: str | None = None
) -> dict[str, Any] | None:
"""
Build a handler ``gns3_ctx`` for in-process copilot callers.
The JWT is taken from the request-scoped context variable when not
passed explicitly (mirroring ``get_gns3_connector``); the URL uses the
same Controller Config fallback detection order.
Returns None when no JWT token is available.
"""
from gns3server.agent.gns3_copilot.gns3_client.connector_factory import (
_detect_url_for_api,
)
from gns3server.agent.gns3_copilot.gns3_client.context_helpers import (
get_current_jwt_token,
)
token = jwt_token or get_current_jwt_token()
if not token:
return None
return {
"server_url": url or _detect_url_for_api(),
"jwt_token": token,
"jwt_username": None,
"jwt_token_version": 0,
}
def _filter_node_response(node: dict, fields: list[str] = None) -> dict:
"""Filter node response to only include requested fields."""
if not fields:
fields = ["node_id", "name", "node_type", "status", "console"]
return {k: node[k] for k in fields if k in node}
def _filter_link_response(link: dict, fields: list[str] = None) -> dict:
"""Filter link response to only include requested fields."""
if not fields:
fields = LINK_DEFAULT_FIELDS
return {k: link[k] for k in fields if k in link}
def _normalize_link_nodes(nodes) -> list[dict[str, Any]]:
"""
Normalize link node entries, accepting both standard object format and
compact array format to reduce token usage.
Standard: [{"node_id": "uuid", "adapter_number": 0, "port_number": 0}]
Compact: ["uuid", 0, 0, "uuid", 0, 0]
Returns the normalized list, or raises ValueError with a clear message
on format errors so the AI can self-correct.
"""
if not nodes:
return nodes
if not isinstance(nodes, list):
raise ValueError(f"nodes must be a list, got {type(nodes).__name__}: {nodes}")
# Standard object format: [{"node_id": "...", ...}]
if isinstance(nodes[0], dict):
return nodes
# Compact array format: ["uuid", ad, pt, "uuid", ad, pt"]
if all(not isinstance(n, dict) for n in nodes):
if len(nodes) != 6:
raise ValueError(
f"Compact link format requires exactly 6 elements "
f"[node_id, adapter, port, node_id, adapter, port], "
f"but got {len(nodes)} elements: {nodes}"
)
if not isinstance(nodes[0], str) or not isinstance(nodes[3], str):
raise ValueError(
f"Compact link format expects node_id (string) at positions 0 and 3, "
f"got types {type(nodes[0]).__name__} and {type(nodes[3]).__name__}: {nodes}"
)
return [
{"node_id": nodes[0], "adapter_number": nodes[1], "port_number": nodes[2]},
{"node_id": nodes[3], "adapter_number": nodes[4], "port_number": nodes[5]},
]
raise ValueError(
f"Unrecognized link nodes format. "
f"Use standard [{{\"node_id\":\"..\",\"adapter_number\":0,\"port_number\":0}},...] "
f"or compact [\"id\",0,0,\"id\",0,0], got: {nodes}"
)
# ── Node handlers ──────────────────────────────────────────────────────────
def get_nodes_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)
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)}
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)
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 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}
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 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}
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 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}
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"}
fields = params.get("fields")
if fields is not None and not isinstance(fields, list):
return {"error": "fields must be a list, e.g. [\"node_id\", \"name\"]"}
nodes = params.get("nodes")
# Batch mode: nodes=[{template_id?, x, y, name?, compute_id?}]
# When top-level template_id is set, it applies to all nodes as a default
if nodes is not None:
if not isinstance(nodes, list) or not nodes:
return {"error": "nodes must be a non-empty array"}
default_tid = params.get("template_id")
conn = _get_connector(gns3_ctx)
def _create_one(node_data):
tid = node_data.get("template_id", default_tid)
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"),
}
node_name = node_data.get("name")
if node_name:
body["name"] = node_name
resp = conn.http_call("post", url, json_data=body).json()
return {"template_id": tid, "status": "success", "node": _filter_node_response(resp, fields)}
except Exception as e:
return {"template_id": tid, "status": "error", "error": str(e)}
if any(not node.get("name") for node in nodes):
# The controller assigns default names (R-1, R-2, ...) and console
# ports in request arrival order, and a parallel fan-out makes the
# arrival order depend on thread scheduling. Batches that rely on
# default naming are therefore created sequentially so those
# server-side assignments follow the submission order; batches
# where every node has an explicit name stay parallel.
return [_create_one(node) for node in nodes]
with ThreadPoolExecutor(max_workers=min(len(nodes), BATCH_MAX_WORKERS)) as pool:
# pool.map keeps the submission order, so callers can correlate
# results with the nodes they sent regardless of completion order
return list(pool.map(_create_one, nodes))
# Single mode
template_id = params.get("template_id")
if not template_id:
return {"error": "template_id is required"}
conn = _get_connector(gns3_ctx)
data = {
"x": params.get("x", 0),
"y": params.get("y", 0),
"compute_id": params.get("compute_id", "local"),
}
node_name = params.get("name")
if node_name:
data["name"] = node_name
url = f"{conn.base_url}/projects/{project_id}/templates/{template_id}"
resp = conn.http_call("post", url, json_data=data).json()
return _filter_node_response(resp, fields)
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": "success", "message": f"Node {nid} 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 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}
def update_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)
# Extract update parameters - handle nested kwargs structure from MCP clients
if "kwargs" in params and isinstance(params["kwargs"], dict):
update_data = params["kwargs"]
else:
update_data = {k: v for k, v in params.items() if k not in ("project_id", "node_id", "kwargs")}
url = f"{conn.base_url}/projects/{project_id}/nodes/{node_id}"
return conn.http_call("put", url, json_data=update_data).json()
def get_node_console_info_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)
node = conn.http_call("get", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}").json()
console_type = node.get("console_type", "unknown")
# Short-lived JWT for the WebSocket URL (10 min)
username = gns3_ctx.get("jwt_username")
ws_token = auth_service.create_access_token(username, token_version=gns3_ctx.get("jwt_token_version", 0), 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,
"node_name": node.get("name"),
"console_type": console_type,
"ws_url": ws_url,
"command": f"websocat -t --no-close {ws_url}",
}
if ws_token:
# Fingerprint of the minted token: compare it against what actually reached the
# server (logged on WebSocket auth rejection) to detect copy corruption, and
# re-request the URL once token_ttl_seconds has elapsed.
result["token_sha256_prefix"] = hashlib.sha256(ws_token.encode()).hexdigest()[:8]
result["token_ttl_seconds"] = 600
if console_type in ("vnc",):
result["vnc_url"] = f"/v3/projects/{project_id}/nodes/{node_id}/console/vnc?token={gns3_ctx['jwt_token']}"
return result
# ── Node file handlers ────────────────────────────────────────────────────
def list_node_files_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)
url = f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/files"
query = {}
if params.get("path"):
query["path"] = params["path"]
if params.get("recursive"):
query["recursive"] = "true"
files = conn.http_call("get", url, params=query if query else None).json()
return {"files": files, "count": len(files)}
def get_node_file_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")
file_path = params.get("file_path")
if not project_id or not node_id or not file_path:
return {"error": "project_id, node_id and file_path are required"}
offset = params.get("offset", 0)
limit = params.get("limit", 200)
conn = _get_connector(gns3_ctx)
url = f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/files/{file_path}"
raw = conn.http_call("get", url).text
total_bytes = len(raw.encode("utf-8"))
truncated = False
if total_bytes > MAX_NODE_FILE_BYTES:
raw = raw[:MAX_NODE_FILE_BYTES]
truncated = True
# keepends keeps the content byte-faithful: the trailing newline of the
# last line and any \r\n endings survive the round trip
lines = raw.splitlines(keepends=True)
total_lines = len(lines)
# Apply offset/limit
selected = lines[offset: offset + limit] if offset < total_lines else []
has_more = (offset + limit) < total_lines or truncated
content = "".join(selected)
return {
"file_path": file_path,
"content": content,
"metadata": {
"total_lines": total_lines,
"total_bytes": total_bytes,
"offset": offset,
"limit": limit,
"returned_lines": len(selected),
"returned_bytes": len(content.encode("utf-8")),
"truncated": truncated or has_more,
"has_more": has_more,
},
}
def write_node_file_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")
file_path = params.get("file_path")
content = params.get("content")
if not project_id or not node_id or not file_path or content is None:
return {"error": "project_id, node_id, file_path and content are required"}
conn = _get_connector(gns3_ctx)
url = f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/files/{file_path}"
conn.http_call("post", url, data=content, headers={"Content-Type": "text/plain"})
return {"message": f"File {file_path} written to node {node_id}", "file_path": file_path, "node_id": node_id}
def delete_node_file_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")
file_path = params.get("file_path")
if not project_id or not node_id or not file_path:
return {"error": "project_id, node_id and file_path are required"}
conn = _get_connector(gns3_ctx)
url = f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/files/{file_path}"
conn.http_call("delete", url)
return {"message": f"File {file_path} deleted from node {node_id}", "file_path": file_path, "node_id": node_id}
# ── Node bulk / advanced handlers ────────────────────────────────────────
def start_all_nodes_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)
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/start")
return {"message": "All nodes started", "project_id": project_id}
def stop_all_nodes_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)
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/stop")
return {"message": "All nodes stopped", "project_id": project_id}
def suspend_all_nodes_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)
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/suspend")
return {"message": "All nodes suspended", "project_id": project_id}
def duplicate_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)
data = {k: v for k, v in params.items() if k not in ("project_id", "node_id") and v is not None}
result = conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/duplicate", json_data=data).json()
return {"message": f"Node {node_id} duplicated", "node": result}
def isolate_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)
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/isolate")
return {"message": f"Node {node_id} isolated (all links suspended)", "node_id": node_id}
def unisolate_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)
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/unisolate")
return {"message": f"Node {node_id} unisolated (links resumed)", "node_id": node_id}
def get_node_links_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)
links = conn.http_call("get", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/links").json()
return {"links": links, "count": len(links)}
# ── Link handlers ──────────────────────────────────────────────────────────
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: link[k] for k in fields if k in link} for link in links]
return {"links": links, "count": len(links)}
def get_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id")
link_id = params.get("link_id")
if not project_id or not link_id:
return {"error": "project_id and link_id are required"}
conn = _get_connector(gns3_ctx)
return conn.http_call("get", f"{conn.base_url}/projects/{project_id}/links/{link_id}").json()
def available_filters_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
"""
List the packet filter types available for a link (GNS3 API v3 only).
Returns a list of filter descriptors (frequency_drop, packet_loss,
delay, corrupt, bpf) with their parameters.
"""
project_id = params.get("project_id")
link_id = params.get("link_id")
if not project_id or not link_id:
return {"error": "project_id and link_id are required"}
conn = _get_connector(gns3_ctx)
url = f"{conn.base_url}/projects/{project_id}/links/{link_id}/available_filters"
return conn.http_call("get", url).json()
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"}
fields = params.get("fields")
if fields is not None and not isinstance(fields, list):
return {"error": "fields must be a list, e.g. [\"link_id\", \"nodes\"]"}
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"}
conn = _get_connector(gns3_ctx)
def _create_one(link_data):
raw_nodes = link_data.get("nodes")
if not raw_nodes:
return {"status": "error", "error": "nodes is required for each link"}
try:
body = {"nodes": _normalize_link_nodes(raw_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": _filter_link_response(resp, fields)}
except Exception as e:
return {"status": "error", "error": str(e)}
with ThreadPoolExecutor(max_workers=min(len(links), BATCH_MAX_WORKERS)) as pool:
# pool.map keeps the submission order, so callers can correlate
# results with the links they sent regardless of completion order
return list(pool.map(_create_one, links))
# Single mode
nodes = params.get("nodes")
if not nodes:
return {"error": "nodes is required"}
conn = _get_connector(gns3_ctx)
data = {"nodes": _normalize_link_nodes(nodes)}
if "link_type" in params:
data["link_type"] = params["link_type"]
if "filters" in params:
data["filters"] = params["filters"]
if "suspend" in params:
data["suspend"] = params["suspend"]
url = f"{conn.base_url}/projects/{project_id}/links"
resp = conn.http_call("post", url, json_data=data).json()
return _filter_link_response(resp, fields)
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": "success", "message": f"Link {lid} 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 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}
def update_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id")
link_id = params.get("link_id")
if not project_id or not link_id:
return {"error": "project_id and link_id are required"}
conn = _get_connector(gns3_ctx)
# Extract update parameters - handle nested kwargs structure from MCP clients
if "kwargs" in params and isinstance(params["kwargs"], dict):
update_data = params["kwargs"]
else:
update_data = {k: v for k, v in params.items() if k not in ("project_id", "link_id", "kwargs")}
url = f"{conn.base_url}/projects/{project_id}/links/{link_id}"
return conn.http_call("put", url, json_data=update_data).json()
# ── Link capture / reset handlers ──────────────────────────────────────
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 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 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"),
"wireshark": params.get("wireshark", False),
}
if params.get("capture_file_name"):
data["capture_file_name"] = params["capture_file_name"]
url = f"{conn.base_url}/projects/{project_id}/links/{link_id}/capture/start"
result = conn.http_call("post", url, json_data=data).json()
return {"message": f"Capture started on link {link_id}", "link": result}
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 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)
return {"message": f"Capture stopped on link {link_id}", "link_id": link_id}
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, token_version=gns3_ctx.get("jwt_token_version", 0), 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 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"
result = {
"link_id": link_id,
"download_url": download_url,
"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
# ── Marker (traffic-insight) handlers ──────────────────────────────────
def link_marker_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
"""
Manage traffic-insight markers on a specific link.
Actions:
- create: POST /projects/{pid}/links/{lid}/markers
- update: PUT /projects/{pid}/links/{lid}/markers/{name}
- delete: DELETE /projects/{pid}/links/{lid}/markers/{name}
"""
project_id = params.get("project_id")
link_id = params.get("link_id")
action = params.get("action")
if not all([project_id, link_id, action]):
return {"error": "project_id, link_id and action are required"}
if action not in ("create", "update", "delete"):
return {"error": f"Unknown action: {action}. Supported: create, update, delete"}
conn = _get_connector(gns3_ctx)
base = f"{conn.base_url}/projects/{project_id}/links/{link_id}/markers"
if action == "create":
bpf = params.get("bpf")
if not bpf:
return {"error": "bpf is required for create action"}
body: dict[str, Any] = {"bpf": bpf}
for opt in ("name", "tag", "capture_node_id", "color", "highlight_duration", "data_link_type"):
if params.get(opt) is not None:
body[opt] = params[opt]
# direction: "tx"/"rx" set a one-way filter; "both"/omitted = no filter.
if params.get("direction") in ("tx", "rx"):
body["direction"] = params["direction"]
return conn.http_call("post", base, json_data=body).json()
marker_name = params.get("marker_name")
if not marker_name:
return {"error": "marker_name is required for update/delete actions"}
url = f"{base}/{marker_name}"
if action == "update":
body = {}
for opt in ("bpf", "tag", "enabled", "color", "highlight_duration"):
if params.get(opt) is not None:
body[opt] = params[opt]
# direction tri-state: omitted=preserve, "tx"/"rx"=set, "both"=clear (→ null).
direction = params.get("direction")
if direction == "both":
body["direction"] = None
elif direction in ("tx", "rx"):
body["direction"] = direction
if not body:
return {"error": "At least one update field is required (bpf, tag, enabled, direction, color, highlight_duration)"}
return conn.http_call("put", url, json_data=body).json()
# action == "delete"
conn.http_call("delete", url)
return {"message": f"Marker '{marker_name}' deleted from link {link_id}", "link_id": link_id, "marker_name": marker_name}
def marker_definition_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
"""
Manage project-level marker definitions (auto-fanout to all links).
Actions:
- create: POST /projects/{pid}/marker-definitions fans out global-{name} to every link
- update: PUT /projects/{pid}/marker-definitions/{name}
- delete: DELETE /projects/{pid}/marker-definitions/{name}
- list: GET /projects/{pid}/marker-definitions
"""
project_id = params.get("project_id")
action = params.get("action")
if not all([project_id, action]):
return {"error": "project_id and action are required"}
if action not in ("create", "update", "delete", "list"):
return {"error": f"Unknown action: {action}. Supported: create, update, delete, list"}
conn = _get_connector(gns3_ctx)
base = f"{conn.base_url}/projects/{project_id}/marker-definitions"
if action == "list":
return conn.http_call("get", base).json()
if action == "create":
bpf = params.get("bpf")
if not bpf:
return {"error": "bpf is required for create action"}
body: dict[str, Any] = {"bpf": bpf}
for opt in ("name", "tag", "color", "highlight_duration", "data_link_type"):
if params.get(opt) is not None:
body[opt] = params[opt]
# No direction: a definition fans out to every link and auto-selects its
# capture node on each, so tx/rx (which is relative to that node) has no
# consistent meaning. Encode direction in the BPF instead.
return conn.http_call("post", base, json_data=body).json()
def_name = params.get("def_name")
if not def_name:
return {"error": "def_name is required for update/delete actions"}
url = f"{base}/{def_name}"
if action == "update":
body = {}
for opt in ("bpf", "tag", "color", "highlight_duration", "data_link_type"):
if params.get(opt) is not None:
body[opt] = params[opt]
if not body:
return {"error": "At least one update field is required (bpf, tag, color, highlight_duration, data_link_type)"}
return conn.http_call("put", url, json_data=body).json()
# action == "delete"
conn.http_call("delete", url)
return {"message": f"Marker definition '{def_name}' deleted", "project_id": project_id, "def_name": def_name}

View File

@ -0,0 +1,309 @@
# SPDX-License-Identifier: GPL-3.0-or-later
#
# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3
#
# This file is part of GNS3-Copilot project.
#
# GNS3-Copilot is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the
# Free Software Foundation, either version 3 of the License, or (at your
# option) any later version.
#
# GNS3-Copilot is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
# for more details.
#
# You should have received a copy of the GNU General Public License
# along with GNS3-Copilot. If not, see <https://www.gnu.org/licenses/>.
#
# Copyright (C) 2025 Yue Guobin (岳国宾)
# Author: Yue Guobin (岳国宾)
#
# Project Home: https://github.com/yueguobin/gns3-copilot
#
"""
GNS3 REST API connector.
A minimal authenticated HTTP session for the GNS3 controller API: URL/base
URL handling, v2 basic / v3 JWT authentication and token refresh, plus GNS3
error extraction. Callers make requests through ``http_call`` the
endpoint-specific logic lives in ``api_handlers``.
The class is adapted from the upstream gns3fy project
(https://github.com/davidban77/gns3fy) Gns3Connector.
WARNING: This module is shared with the MCP (Model Context Protocol)
service. Modifications must be tested with BOTH gns3-copilot AND MCP.
"""
import time
from typing import Any
import jwt
import requests
import urllib3
from requests import HTTPError
class Gns3Connector:
"""
Connector to be used for interaction against the GNS3 server controller API.
**Attributes:**
- `url` (str): URL of the GNS3 server (**required**)
- `user` (str): User used for authentication
- `cred` (str): Password used for authentication
- `jwt_token` (str): JWT token for direct authentication (API v3)
- `verify` (bool): Whether or not to verify SSL
- `api_version` (int): GNS3 server REST API version
- `api_calls`: Counter of amount of `http_calls` has been performed
- `base_url`: url passed + api_version
- `session`: Requests Session object
**Returns:**
`Gns3Connector` instance
**Example:**
```python
>>> # API v2 with basic auth
>>> server = Gns3Connector(
... url="http://<address>:3080", user="admin", cred="password",
... api_version=2
... )
>>> # API v3 with username/password (auto-fetches JWT token)
>>> server = Gns3Connector(
... url="http://<address>:3080", user="admin", cred="password",
... api_version=3
... )
>>> # API v3 with direct JWT token
>>> server = Gns3Connector(
... url="http://<address>:3080",
... jwt_token="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
... api_version=3
... )
>>> print(server.http_call("get", f"{server.base_url}/version").json())
{'local': False, 'version': '2.2.0b4'}
```
"""
access_token: str | None
token_expiry: float | None
def __init__(
self,
url: str | None = None,
user: str | None = None,
cred: str | None = None,
jwt_token: str | None = None,
verify: bool = False,
api_version: int = 2,
) -> None:
# Disable SSL warnings
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
if url is None:
raise ValueError("URL is required for Gns3Connector")
self.url = url.strip("/") # Store original URL for reference
self.base_url = f"{self.url}/v{api_version}"
self.user = user
self.cred = cred
self.headers = {"Content-Type": "application/json"}
self.verify = verify
self.api_calls = 0
# v3 authentication attributes
# If jwt_token is provided directly, use it; otherwise will be
# fetched via username/password
self.access_token = jwt_token
self.token_expiry = None
self.auth_type = "basic" if api_version == 2 else "jwt"
self.api_version = api_version
# Create session object
self._create_session()
def _create_session(self) -> None:
"""
Creates the requests.Session object and applies the necessary parameters
"""
self.session = requests.Session() # pragma: no cover
# Increase connection pool size to support concurrent MCP batch operations
adapter = requests.adapters.HTTPAdapter(pool_connections=500, pool_maxsize=1000)
self.session.mount("http://", adapter)
self.session.mount("https://", adapter)
self.session.headers["Accept"] = "application/json" # pragma: no cover
# Set authentication based on API version
if (
self.auth_type == "basic"
and self.user is not None
and self.cred is not None
):
self.session.auth = (self.user, self.cred) # pragma: no cover
elif self.auth_type == "jwt" and self.access_token:
self.session.headers["Authorization"] = (
f"Bearer {self.access_token}"
)
def _authenticate_v3(self) -> None:
"""
Performs v3 API authentication using username and password to get JWT token.
Skips authentication if a JWT token is already provided.
"""
# If token is already provided, skip authentication
if self.access_token:
return
if not self.user or not self.cred:
raise ValueError(
"Username and password are required for v3 authentication "
"when no JWT token is provided"
)
# Construct authentication URL (v3 API uses different base URL)
auth_url = (
f"{self.base_url.replace('/v3', '')}/v3/access/users/authenticate"
)
auth_data = {"username": self.user, "password": self.cred}
# Use temporary session for authentication
temp_session = requests.Session()
temp_session.headers["Content-Type"] = "application/json"
try:
response = temp_session.post(
auth_url, json=auth_data, verify=self.verify, timeout=10.0
)
if response.status_code == 200:
auth_result = response.json()
self.access_token = auth_result["access_token"]
# Update session with new token
self.session.headers["Authorization"] = (
f"Bearer {self.access_token}"
)
else:
raise HTTPError(
f"v3 API authentication failed: {response.status_code} - "
f"{response.text}"
)
except Exception as e:
raise HTTPError(f"v3 API authentication error: {str(e)}") from e
def _is_token_expired(self) -> bool:
"""
Check if the JWT token is expired (basic implementation)
"""
token = self.access_token
if not token:
return True
try:
# Decode token without verification to check expiry
decoded: dict[str, Any] = jwt.decode(
token, options={"verify_signature": False}
)
exp = decoded.get("exp")
if exp is not None:
return time.time() > float(exp)
return False
except (jwt.PyJWTError, ValueError, TypeError):
return True
def _refresh_token(self) -> None:
"""
Refresh the JWT token (for now, just re-authenticate)
"""
print("Refreshing v3 API token...")
self._authenticate_v3()
def http_call(
self,
method: str,
url: str,
data: Any | None = None,
json_data: dict[str, Any] | list[Any] | None = None,
headers: dict[str, str] | None = None,
verify: bool = False,
params: dict[str, Any] | None = None,
) -> requests.Response:
"""
Executes HTTP operations and handles GNS3-specific error logic.
"""
# Handle JWT authentication
if (
self.auth_type == "jwt"
and not self.access_token
and self.user
and self.cred
):
self._authenticate_v3()
# Get request function (e.g., session.get, session.post)
caller = getattr(self.session, method.lower())
# Prepare request parameters, avoiding multiple repeated calls to caller
kwargs: dict[str, Any] = {
"headers": headers,
"params": params,
"verify": verify,
"timeout": 30.0, # Main request timeout (auth call uses 10s)
}
if data is not None:
kwargs["data"] = data
elif json_data is not None:
kwargs["json"] = json_data
# Execute request
_response: requests.Response = caller(url, **kwargs)
self.api_calls += 1
try:
_response.raise_for_status()
except HTTPError as e:
# Throw enhanced error
raise self._extract_gns3_error(e) from e
return _response
def _extract_gns3_error(self, e: HTTPError) -> HTTPError:
"""
Extract GNS3-specific JSON error information from HTTPError.
If parsing fails, return the original error.
"""
# e.response might be None, need explicit check
response = e.response
if response is None:
return e
try:
# Only attempt parsing when Content-Type is JSON
if (
"application/json"
in response.headers.get("Content-Type", "").lower()
):
error_json = response.json()
status = error_json.get("status", "Unknown Status")
message = error_json.get(
"message", "No message provided in JSON."
)
# Construct a more descriptive new error
new_err = HTTPError(
f"{status}: {message} (Original {response.status_code} Error)",
response=response,
)
return new_err
except Exception:
# If JSON parsing fails, return error with original text
return HTTPError(
f"Original Error: {str(e)}. GNS3 response text: {response.text}",
response=response,
)
return e

View File

@ -54,7 +54,7 @@ from gns3server.agent.gns3_copilot.gns3_client.context_helpers import (
)
# Local imports
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import (
from gns3server.agent.gns3_copilot.gns3_client.connector import (
Gns3Connector,
)

File diff suppressed because it is too large Load Diff

View File

@ -38,8 +38,12 @@ from typing import Any
from langchain.tools import BaseTool
from gns3server.agent.gns3_copilot.gns3_client import Project
from gns3server.agent.gns3_copilot.gns3_client import get_gns3_connector
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
build_gns3_ctx,
)
from gns3server.agent.gns3_copilot.gns3_client.project_inventory import (
fetch_project_inventory,
)
# Configure logging
logger = logging.getLogger(__name__)
@ -101,11 +105,11 @@ class GNS3ProjectInfoTool(BaseTool):
)
}
# Initialize Gns3Connector using factory function
# Build handler context (JWT + server URL from request context)
logger.debug("Connecting to GNS3 server...")
server = get_gns3_connector()
gns3_ctx = build_gns3_ctx()
if server is None:
if gns3_ctx is None:
logger.error("Failed to create GNS3 connector")
return {
"error": (
@ -118,37 +122,33 @@ class GNS3ProjectInfoTool(BaseTool):
logger.info(
f"Retrieving project info for project_id: {project_id}"
)
project = Project(project_id=project_id, connector=server)
project.get() # Load project details
inventory = fetch_project_inventory(gns3_ctx, project_id)
# Get node and link counts
nodes_inventory = project.nodes_inventory()
links_summary = project.links_summary(is_print=False)
node_count = len(nodes_inventory) if nodes_inventory else 0
link_count = len(links_summary) if links_summary else 0
node_count = len(inventory["nodes_inventory"])
link_count = len(inventory["links_summary"])
# Build result in tuple format consistent with GNS3ProjectList
result = {
"project_id": project.project_id,
"name": project.name,
"status": project.status,
"project_id": inventory["project_id"],
"name": inventory["name"],
"status": inventory["status"],
"node_count": node_count,
"link_count": link_count,
"tuple": (
project.name,
project.project_id,
inventory["name"],
inventory["project_id"],
node_count,
link_count,
project.status,
inventory["status"],
),
}
# Log result
logger.info(
"Project info retrieved: name=%s, status=%s, nodes=%d, links=%d",
project.name,
project.status,
inventory["name"],
inventory["status"],
node_count,
link_count,
)

View File

@ -43,8 +43,12 @@ from typing import Any
from langchain.tools import BaseTool
from gns3server.agent.gns3_copilot.gns3_client import Project
from gns3server.agent.gns3_copilot.gns3_client import get_gns3_connector
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
build_gns3_ctx,
)
from gns3server.agent.gns3_copilot.gns3_client.project_inventory import (
fetch_project_inventory,
)
# Configure logging
logger = logging.getLogger(__name__)
@ -109,13 +113,13 @@ class GNS3TopologyTool(BaseTool):
"Please provide a valid project UUID."
}
# Initialize Gns3Connector using factory function
# Build handler context (JWT + server URL)
# jwt_token/url can be passed explicitly (e.g. from MCP handlers)
# or auto-detected (e.g. from gns3-copilot agent)
logger.debug("Connecting to GNS3 server...")
server = get_gns3_connector(jwt_token=jwt_token, url=url)
gns3_ctx = build_gns3_ctx(jwt_token=jwt_token, url=url)
if server is None:
if gns3_ctx is None:
logger.error("Failed to create GNS3 connector")
return {
"error": "Failed to connect to GNS3 server. Please check "
@ -124,18 +128,17 @@ class GNS3TopologyTool(BaseTool):
# Use the provided project_id directly
logger.info(f"Retrieving topology for project_id: {project_id}")
project = Project(project_id=project_id, connector=server)
project.get() # Load project details
inventory = fetch_project_inventory(gns3_ctx, project_id)
# Get topology JSON: includes nodes (devices), links, etc.
topology = {
"project_id": project.project_id,
"name": project.name,
"status": project.status,
"project_id": inventory["project_id"],
"name": inventory["name"],
"status": inventory["status"],
"nodes": self._clean_nodes_ports(
copy.deepcopy(project.nodes_inventory())
copy.deepcopy(inventory["nodes_inventory"])
),
"links": project.links_summary(is_print=False),
"links": inventory["links_summary"],
}
# Log topology result

View File

@ -0,0 +1,148 @@
# SPDX-License-Identifier: GPL-3.0-or-later
#
# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3
#
# This file is part of GNS3-Copilot project.
#
# GNS3-Copilot is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the
# Free Software Foundation, either version 3 of the License, or (at your
# option) any later version.
#
# GNS3-Copilot is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
# for more details.
#
# You should have received a copy of the GNU General Public License
# along with GNS3-Copilot. If not, see <https://www.gnu.org/licenses/>.
#
# Copyright (C) 2025 Yue Guobin (岳国宾)
# Author: Yue Guobin (岳国宾)
#
# Project Home: https://github.com/yueguobin/gns3-copilot
#
"""
Project inventory aggregation over the raw GNS3 REST listings.
Replaces the aggregation previously living on the ``Project`` dataclass
(``nodes_inventory`` / ``links_summary``). The output shapes are kept
field-for-field: they feed the LLM topology context and the Nornir
inventory, so any change here is consumer-visible.
"""
from typing import Any
from urllib.parse import urlparse
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import _get_connector
def build_nodes_inventory(
nodes: list[dict[str, Any]], server_host: str | None
) -> dict[str, Any]:
"""
Build an inventory-style dict keyed by node name.
Shape (per node name):
{server, name, node_id, console_port, console_type, type, ports,
status, x, y, tags, netmiko_device_type, default_username,
default_password}
"""
inventory: dict[str, Any] = {}
for n in nodes:
inventory[n.get("name")] = {
"server": server_host,
"name": n.get("name"),
"node_id": n.get("node_id"),
"console_port": n.get("console"),
"console_type": n.get("console_type"),
"type": n.get("node_type"),
"ports": n.get("ports"),
"status": n.get("status"),
"x": n.get("x"),
"y": n.get("y"),
"tags": n.get("tags") if n.get("tags") else [],
"netmiko_device_type": n.get("netmiko_device_type"),
"default_username": n.get("default_username"),
"default_password": n.get("default_password"),
}
return inventory
def build_links_summary(
nodes: list[dict[str, Any]], links: list[dict[str, Any]]
) -> list[dict[str, str]]:
"""
Build a human/LLM-friendly link list resolving node and port names.
Shape (per link): {link_id, node_a, port_a, node_b, port_b}.
Links whose endpoints cannot be resolved are skipped, mirroring the
original Project.links_summary behavior.
"""
summary: list[dict[str, str]] = []
for link in links:
if not link.get("nodes"):
continue
side_a = link["nodes"][0]
side_b = link["nodes"][1]
try:
node_a = next(
x for x in nodes if x.get("node_id") == side_a["node_id"]
)
port_a = str(
next(
p["name"]
for p in (node_a.get("ports") or [])
if p["port_number"] == side_a["port_number"]
and p["adapter_number"] == side_a["adapter_number"]
)
)
node_b = next(
x for x in nodes if x.get("node_id") == side_b["node_id"]
)
port_b = str(
next(
p["name"]
for p in (node_b.get("ports") or [])
if p["port_number"] == side_b["port_number"]
and p["adapter_number"] == side_b["adapter_number"]
)
)
name_a = str(node_a["name"]) if node_a.get("name") else "Unknown"
name_b = str(node_b["name"]) if node_b.get("name") else "Unknown"
summary.append({
"link_id": link.get("link_id"),
"node_a": name_a,
"port_a": port_a,
"node_b": name_b,
"port_b": port_b,
})
except (StopIteration, KeyError, AttributeError):
# Prevent errors when lookups can't match data
continue
return summary
def fetch_project_inventory(
gns3_ctx: dict[str, Any], project_id: str
) -> dict[str, Any]:
"""
Fetch a project's metadata, nodes and links and return the aggregated
inventory the equivalent of the old ``Project.get()`` +
``nodes_inventory()`` + ``links_summary()`` sequence (minus the
stats/snapshots/drawings calls no consumer ever read).
"""
conn = _get_connector(gns3_ctx)
base = conn.base_url
project = conn.http_call("get", f"{base}/projects/{project_id}").json()
nodes = conn.http_call("get", f"{base}/projects/{project_id}/nodes").json()
links = conn.http_call("get", f"{base}/projects/{project_id}/links").json()
server_host = urlparse(gns3_ctx["server_url"]).hostname
return {
"project_id": project.get("project_id", project_id),
"name": project.get("name"),
"status": project.get("status"),
"nodes_inventory": build_nodes_inventory(nodes, server_host),
"links_summary": build_links_summary(nodes, links),
}

View File

@ -227,7 +227,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
)
except ValueError as e:
logger.error("Failed to prepare device hosts data: %s", e)
return [{"error": str(e)}]
return [{"status": "failed", "error": str(e)}]
# Check if any devices have errors (e.g., missing device_type tag)
error_devices = {
@ -254,7 +254,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
dynamic_nr = self._initialize_nornir(hosts_data)
except ValueError as e:
logger.error("Failed to initialize Nornir: %s", e)
return [{"error": str(e)}]
return [{"status": "failed", "error": str(e)}]
results = []
@ -278,7 +278,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
logger.error(
"Error executing configurations on all devices: %s", e
)
return [{"error": f"Execution error: {str(e)}"}]
return [{"status": "failed", "error": f"Execution error: {str(e)}"}]
logger.info(
"Multiple device configuration execution completed. Results: %s",
@ -368,7 +368,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
"Invalid JSON string received as tool input: %s", e
)
return (
[{"error": f"Invalid JSON string input from model: {e}"}],
[{"status": "failed", "error": f"Invalid JSON string input from model: {e}"}],
None,
)
else:
@ -389,7 +389,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
if not project_id:
error_msg = "Missing required 'project_id' field in input"
logger.error(error_msg)
return ([{"error": error_msg}], None)
return ([{"status": "failed", "error": error_msg}], None)
if not self._validate_project_id(project_id):
error_msg = (
@ -397,13 +397,13 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
"Expected UUID format."
)
logger.error(error_msg)
return ([{"error": error_msg}], None)
return ([{"status": "failed", "error": error_msg}], None)
# Validate device_configs
if not isinstance(device_configs, list):
error_msg = "'device_configs' must be an array"
logger.error(error_msg)
return ([{"error": error_msg}], None)
return ([{"status": "failed", "error": error_msg}], None)
if not device_configs:
logger.warning("Device configs list is empty.")
@ -426,7 +426,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
f"{type(parsed_input).__name__}"
)
logger.error(error_msg)
return ([{"error": error_msg}], None)
return ([{"status": "failed", "error": error_msg}], None)
def _validate_project_id(self, project_id: str) -> bool:
"""

View File

@ -223,7 +223,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
)
except ValueError as e:
logger.error("Failed to prepare device hosts data: %s", e)
return [{"error": str(e)}]
return [{"status": "failed", "error": str(e)}]
# Check if any devices have errors (e.g., missing device_type tag)
error_devices = {
@ -250,7 +250,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
dynamic_nr = self._initialize_nornir(hosts_data)
except ValueError as e:
logger.error("Failed to initialize Nornir: %s", e)
return [{"error": str(e)}]
return [{"status": "failed", "error": str(e)}]
results = []
@ -272,7 +272,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
except Exception as e:
# Overall execution failed
logger.error("Error executing display on all devices: %s", e)
return [{"error": f"Execution error: {str(e)}"}]
return [{"status": "failed", "error": f"Execution error: {str(e)}"}]
logger.debug(
"Multiple device display execution completed. Results: %s",
@ -370,7 +370,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
"Invalid JSON string received as tool input: %s", e
)
return (
[{"error": f"Invalid JSON string input from model: {e}"}],
[{"status": "failed", "error": f"Invalid JSON string input from model: {e}"}],
None,
)
else:
@ -391,18 +391,18 @@ class ExecuteMultipleDeviceCommands(BaseTool):
if not project_id:
error_msg = "Missing required 'project_id' field in input"
logger.error(error_msg)
return ([{"error": error_msg}], None)
return ([{"status": "failed", "error": error_msg}], None)
if not self._validate_project_id(project_id):
error_msg = f"Invalid project_id: {project_id}. Expected UUID."
logger.error(error_msg)
return ([{"error": error_msg}], None)
return ([{"status": "failed", "error": error_msg}], None)
# Validate device_configs
if not isinstance(device_configs, list):
error_msg = "'device_configs' must be an array"
logger.error(error_msg)
return ([{"error": error_msg}], None)
return ([{"status": "failed", "error": error_msg}], None)
if not device_configs:
logger.warning("Device configs list is empty.")
@ -423,7 +423,7 @@ class ExecuteMultipleDeviceCommands(BaseTool):
f"or legacy JSON array, got {type(parsed_input).__name__}"
)
logger.error(error_msg)
return ([{"error": error_msg}], None)
return ([{"status": "failed", "error": error_msg}], None)
def _validate_project_id(self, project_id: str) -> bool:
"""

View File

@ -38,8 +38,11 @@ from typing import Any
from langchain.tools import BaseTool
from langchain_core.callbacks import CallbackManagerForToolRun
from gns3server.agent.gns3_copilot.gns3_client import Link
from gns3server.agent.gns3_copilot.gns3_client import get_gns3_connector
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
build_gns3_ctx,
create_link_handler,
get_nodes_handler,
)
# Configure logging
logger = logging.getLogger(__name__)
@ -133,11 +136,11 @@ class GNS3LinkTool(BaseTool):
{"error": "Invalid links data: must be a non-empty array"}
]
# Initialize Gns3Connector using factory function
# Build handler context (JWT + server URL from request context)
logger.info("Connecting to GNS3 server...")
gns3_server = get_gns3_connector()
gns3_ctx = build_gns3_ctx()
if gns3_server is None:
if gns3_ctx is None:
logger.error("Failed to create GNS3 connector")
return [
{
@ -148,6 +151,12 @@ class GNS3LinkTool(BaseTool):
}
]
# Fetch all nodes once for port resolution
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
if "error" in listing:
return [{"error": listing["error"]}]
nodes_by_id = {n["node_id"]: n for n in listing["nodes"]}
created_links = []
# Process each link definition
@ -170,13 +179,9 @@ class GNS3LinkTool(BaseTool):
created_links.append({"error": error_msg})
continue
# Get node details
node1 = gns3_server.get_node(
project_id=project_id, node_id=node_id1
)
node2 = gns3_server.get_node(
project_id=project_id, node_id=node_id2
)
# Get node details from the pre-fetched map
node1 = nodes_by_id.get(node_id1)
node2 = nodes_by_id.get(node_id2)
if not node1 or not node2:
error_msg = f"Node not found in link {i}"
logger.error(error_msg)
@ -206,39 +211,48 @@ class GNS3LinkTool(BaseTool):
created_links.append({"error": error_msg})
continue
# Create the link
link = Link(
project_id=project_id,
connector=gns3_server,
nodes=[
{
"node_id": node_id1,
"adapter_number": port1_info.get(
"adapter_number", 0
),
"port_number": port1_info.get(
"port_number", 0
),
"label": {"text": port1_info.get("short_name") or port1},
},
{
"node_id": node_id2,
"adapter_number": port2_info.get(
"adapter_number", 0
),
"port_number": port2_info.get(
"port_number", 0
),
"label": {"text": port2_info.get("short_name") or port2},
},
],
# Create the link via the shared REST handler
link_resp = create_link_handler(
{
"project_id": project_id,
"nodes": [
{
"node_id": node_id1,
"adapter_number": port1_info.get(
"adapter_number", 0
),
"port_number": port1_info.get(
"port_number", 0
),
"label": {
"text": port1_info.get("short_name")
or port1
},
},
{
"node_id": node_id2,
"adapter_number": port2_info.get(
"adapter_number", 0
),
"port_number": port2_info.get(
"port_number", 0
),
"label": {
"text": port2_info.get("short_name")
or port2
},
},
],
"fields": ["link_id"],
},
gns3_ctx,
)
link.create()
link.get()
if "error" in link_resp:
raise RuntimeError(link_resp["error"])
# Collect link details
link_info = {
"link_id": link.link_id,
"link_id": link_resp.get("link_id"),
"node_id1": node_id1,
"port1": port1,
"node_id2": node_id2,

View File

@ -38,8 +38,10 @@ from typing import Any
from langchain.tools import BaseTool
from langchain_core.callbacks import CallbackManagerForToolRun
from gns3server.agent.gns3_copilot.gns3_client import Node
from gns3server.agent.gns3_copilot.gns3_client import get_gns3_connector
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
build_gns3_ctx,
create_node_handler,
)
# Configure logging
logger = logging.getLogger(__name__)
@ -194,11 +196,11 @@ class GNS3CreateNodeTool(BaseTool):
f"template_id, x, or y."
}
# Initialize Gns3Connector using factory function
# Build handler context (JWT + server URL from request context)
logger.info("Connecting to GNS3 server...")
gns3_server = get_gns3_connector()
gns3_ctx = build_gns3_ctx()
if gns3_server is None:
if gns3_ctx is None:
logger.error("Failed to create GNS3 connector")
return {
"error": "Failed to connect to GNS3 server. "
@ -228,22 +230,22 @@ class GNS3CreateNodeTool(BaseTool):
name,
)
# Create node
node = Node(
project_id=project_id,
template_id=template_id,
x=x,
y=y,
name=name,
connector=gns3_server,
# Create node via the shared REST handler
created = create_node_handler(
{
"project_id": project_id,
"template_id": template_id,
"x": x,
"y": y,
"name": name,
},
gns3_ctx,
)
node.create()
# Retrieve node details
node.get()
if "error" in created:
raise RuntimeError(created["error"])
node_info = {
"node_id": node.node_id,
"name": node.name,
"node_id": created.get("node_id"),
"name": created.get("name"),
"status": "success",
}

View File

@ -139,7 +139,9 @@ class GNS3TemplateTool(BaseTool):
}
# Retrieve all available templates
templates = gns3_server.get_templates()
templates = gns3_server.http_call(
"get", f"{gns3_server.base_url}/templates"
).json()
# Filter out utility templates and extract relevant info
template_info = []

View File

@ -40,8 +40,12 @@ from typing import Any
from langchain.tools import BaseTool
from langchain_core.callbacks import CallbackManagerForToolRun
from gns3server.agent.gns3_copilot.gns3_client import Link
from gns3server.agent.gns3_copilot.gns3_client import get_gns3_connector
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
available_filters_handler,
build_gns3_ctx,
get_link_handler,
update_link_handler,
)
# Configure logging
logger = logging.getLogger(__name__)
@ -207,35 +211,34 @@ class GNS3PacketFilterTool(BaseTool):
"error": "'set' action requires 'filters' dict with filter configuration."
}
# Initialize Gns3Connector using factory function
# Build handler context (JWT + server URL from request context)
logger.info("Connecting to GNS3 server...")
gns3_server = get_gns3_connector()
gns3_ctx = build_gns3_ctx()
if gns3_server is None:
if gns3_ctx is None:
logger.error("Failed to create GNS3 connector")
return {
"error": "Failed to connect to GNS3 server. "
"Please check your configuration."
}
# Create Link object
# Execute action
logger.info(
"Processing packet filter action '%s' for link %s...", action, link_id
)
link = Link(
project_id=project_id, link_id=link_id, connector=gns3_server
)
# Execute action
if action == "get_available":
result = self._get_available_filters(link)
result = self._get_available_filters(gns3_ctx, project_id, link_id)
elif action == "set":
filters = input_data.get("filters", {})
result = self._set_filters(link, filters, show_filters_icon)
result = self._set_filters(
gns3_ctx, project_id, link_id, filters, show_filters_icon
)
elif action == "get":
result = self._get_filters(link)
result = self._get_filters(gns3_ctx, project_id, link_id)
elif action == "clear":
result = self._clear_filters(link, show_filters_icon)
result = self._clear_filters(
gns3_ctx, project_id, link_id, show_filters_icon
)
else:
result = {"error": f"Unknown action: {action}"}
@ -253,14 +256,20 @@ class GNS3PacketFilterTool(BaseTool):
"error": f"Failed to process packet filter request: {str(e)}"
}
def _get_available_filters(self, link: Link) -> dict[str, Any]:
def _get_available_filters(
self, gns3_ctx: dict, project_id: str, link_id: str
) -> dict[str, Any]:
"""Get available filter types for the link."""
try:
filters = link.available_filters()
filters = available_filters_handler(
{"project_id": project_id, "link_id": link_id}, gns3_ctx
)
if "error" in filters:
raise RuntimeError(filters["error"])
logger.info("Retrieved %d available filter types.", len(filters))
return {
"action": "get_available",
"link_id": link.link_id,
"link_id": link_id,
"available_filters": filters,
"count": len(filters),
"status": "success",
@ -269,7 +278,7 @@ class GNS3PacketFilterTool(BaseTool):
logger.error("Failed to get available filters: %s", e)
return {
"action": "get_available",
"link_id": link.link_id,
"link_id": link_id,
"error": f"Failed to get available filters: {str(e)}",
"status": "failed",
}
@ -338,7 +347,12 @@ class GNS3PacketFilterTool(BaseTool):
return {"valid": False, "error": f"BPF validation error: {str(e)}"}
def _set_filters(
self, link: Link, filters: dict[str, Any], show_filters_icon: bool = False
self,
gns3_ctx: dict,
project_id: str,
link_id: str,
filters: dict[str, Any],
show_filters_icon: bool = False,
) -> dict[str, Any]:
"""Set packet filters on the link."""
try:
@ -353,7 +367,7 @@ class GNS3PacketFilterTool(BaseTool):
if not validation["valid"]:
return {
"action": "set",
"link_id": link.link_id,
"link_id": link_id,
"error": f"BPF syntax error at index {idx}: {validation['error']}",
"status": "failed",
}
@ -363,22 +377,31 @@ class GNS3PacketFilterTool(BaseTool):
if not validation["valid"]:
return {
"action": "set",
"link_id": link.link_id,
"link_id": link_id,
"error": f"BPF syntax error: {validation['error']}",
"status": "failed",
}
# Update filters
link.update(filters=filters, show_filters_icon=show_filters_icon)
# Update filters — the PUT response is the updated link
updated = update_link_handler(
{
"project_id": project_id,
"link_id": link_id,
"kwargs": {
"filters": filters,
"show_filters_icon": show_filters_icon,
},
},
gns3_ctx,
)
if "error" in updated:
raise RuntimeError(updated["error"])
# Get updated link info
link.get()
logger.info("Successfully set filters on link %s", link.link_id)
logger.info("Successfully set filters on link %s", link_id)
return {
"action": "set",
"link_id": link.link_id,
"filters": link.filters,
"link_id": link_id,
"filters": updated.get("filters"),
"status": "success",
"message": "Filters applied successfully",
}
@ -386,49 +409,68 @@ class GNS3PacketFilterTool(BaseTool):
logger.error("Failed to set filters: %s", e)
return {
"action": "set",
"link_id": link.link_id,
"link_id": link_id,
"error": f"Failed to set filters: {str(e)}",
"status": "failed",
}
def _get_filters(self, link: Link) -> dict[str, Any]:
def _get_filters(
self, gns3_ctx: dict, project_id: str, link_id: str
) -> dict[str, Any]:
"""Get current filters configured on the link."""
try:
# Get link information
link.get()
link = get_link_handler(
{"project_id": project_id, "link_id": link_id}, gns3_ctx
)
if "error" in link:
raise RuntimeError(link["error"])
logger.info("Retrieved current filters for link %s", link.link_id)
logger.info("Retrieved current filters for link %s", link_id)
return {
"action": "get",
"link_id": link.link_id,
"filters": link.filters,
"link_id": link_id,
"filters": link.get("filters"),
"status": "success",
}
except Exception as e:
logger.error("Failed to get filters: %s", e)
return {
"action": "get",
"link_id": link.link_id,
"link_id": link_id,
"error": f"Failed to get filters: {str(e)}",
"status": "failed",
}
def _clear_filters(
self, link: Link, show_filters_icon: bool = False
self,
gns3_ctx: dict,
project_id: str,
link_id: str,
show_filters_icon: bool = False,
) -> dict[str, Any]:
"""Clear all filters from the link."""
try:
# Clear filters by setting empty dict
link.update(filters={}, show_filters_icon=show_filters_icon)
# Clear filters by setting an empty dict — the PUT response
# is the updated link
updated = update_link_handler(
{
"project_id": project_id,
"link_id": link_id,
"kwargs": {
"filters": {},
"show_filters_icon": show_filters_icon,
},
},
gns3_ctx,
)
if "error" in updated:
raise RuntimeError(updated["error"])
# Get updated link info to confirm
link.get()
logger.info("Successfully cleared filters on link %s", link.link_id)
logger.info("Successfully cleared filters on link %s", link_id)
return {
"action": "clear",
"link_id": link.link_id,
"filters": link.filters,
"link_id": link_id,
"filters": updated.get("filters"),
"status": "success",
"message": "Filters cleared successfully",
}
@ -436,7 +478,7 @@ class GNS3PacketFilterTool(BaseTool):
logger.error("Failed to clear filters: %s", e)
return {
"action": "clear",
"link_id": link.link_id,
"link_id": link_id,
"error": f"Failed to clear filters: {str(e)}",
"status": "failed",
}

View File

@ -39,8 +39,11 @@ from typing import Any
from langchain.tools import BaseTool
from langchain_core.callbacks import CallbackManagerForToolRun
from gns3server.agent.gns3_copilot.gns3_client import Node
from gns3server.agent.gns3_copilot.gns3_client import get_gns3_connector
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
build_gns3_ctx,
get_nodes_handler,
start_node_handler,
)
# Configure logging
logger = logging.getLogger(__name__)
@ -64,7 +67,7 @@ def calculate_startup_time(nodes: list) -> int:
- If any node is a slow device: use conservative startup time
Args:
nodes: List of node objects with node_type attribute
nodes: List of node dicts with a "node_type" key
Returns:
Calculated wait time in seconds
@ -73,7 +76,7 @@ def calculate_startup_time(nodes: list) -> int:
return 60 # Default: 60 seconds for empty list
# Get all node types
node_types = [getattr(node, "node_type", "default") for node in nodes]
node_types = [node.get("node_type") or "default" for node in nodes]
# Check if all nodes are fast startup devices (VPCS or IOU)
fast_types = {"vpcs", "iou"}
@ -198,106 +201,88 @@ class GNS3StartNodeTool(BaseTool):
logger.error("node_ids must be a list.")
return {"error": "node_ids must be a list."}
# Initialize Gns3Connector using factory function
# Build handler context (JWT + server URL from request context)
logger.info("Connecting to GNS3 server...")
gns3_server = get_gns3_connector()
gns3_ctx = build_gns3_ctx()
if gns3_server is None:
if gns3_ctx is None:
logger.error("Failed to create GNS3 connector")
return {
"error": "Failed to connect to GNS3 server. "
"Please check your configuration."
}
# First loop: Get node info and send start commands for all nodes
# Phase 1: fetch node info (including node_type) in one call
logger.info(
"Retrieving node info for %d nodes in project %s...",
len(node_ids),
project_id,
)
nodes = []
for node_id in node_ids:
try:
node = Node(
project_id=project_id,
node_id=node_id,
connector=gns3_server,
)
# Get node info (including node_type)
node.get()
if node.node_id:
nodes.append(node)
logger.info(
"Node %s (%s) type: %s",
node_id,
node.name,
node.node_type,
)
else:
logger.error(
"Node %s not found in project %s",
node_id,
project_id,
)
except Exception as e:
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
if "error" in listing:
return {"error": listing["error"]}
nodes_by_id = {n["node_id"]: n for n in listing["nodes"]}
nodes = [nodes_by_id[nid] for nid in node_ids if nid in nodes_by_id]
for node in nodes:
logger.info(
"Node %s (%s) type: %s",
node["node_id"],
node.get("name"),
node.get("node_type"),
)
for nid in node_ids:
if nid not in nodes_by_id:
logger.error(
"Failed to get node info for %s: %s",
node_id,
e,
"Node %s not found in project %s", nid, project_id
)
# Calculate startup time based on node types
wait_time = calculate_startup_time(nodes)
# Send start commands for all nodes
# Phase 2: send start commands for all nodes (parallel batch)
logger.info(
"Sending start commands for %d nodes in project %s...",
len(nodes),
project_id,
)
for node in nodes:
try:
node.start()
logger.info("Start command sent for node %s", node.node_id)
except Exception as e:
start_results = start_node_handler(
{"project_id": project_id, "node_ids": [n["node_id"] for n in nodes]},
gns3_ctx,
)
for r in start_results:
if r.get("status") == "error":
logger.error(
"Failed to send start command for node %s: %s",
node.node_id,
e,
r.get("node_id"),
r.get("error"),
)
else:
logger.info("Start command sent for node %s", r.get("node_id"))
# Show progress bar with calculated wait time
show_progress_bar(
duration=wait_time, interval=1, node_count=len(nodes)
)
# Second loop: Get status for all nodes
# Phase 3: get final status for all nodes (one call)
results = []
logger.info("Retrieving status for %d nodes...", len(nodes))
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
if "error" in listing:
return {"error": listing["error"]}
final_by_id = {n["node_id"]: n for n in listing["nodes"]}
for node in nodes:
try:
node.get() # Get latest status
node_info = {
"node_id": node.node_id,
"name": node.name or "N/A",
"status": node.status or "unknown",
node_info = final_by_id.get(node["node_id"], node)
results.append(
{
"node_id": node["node_id"],
"name": node_info.get("name") or "N/A",
"status": node_info.get("status") or "unknown",
}
results.append(node_info)
except Exception as e:
logger.error(
"Failed to get status for node %s: %s", node.node_id, e
)
results.append(
{
"node_id": node.node_id,
"name": getattr(node, "name", "N/A"),
"status": "error",
"error": str(e),
}
)
)
# Handle nodes that failed to be retrieved initially
retrieved_node_ids = {node.node_id for node in nodes}
retrieved_node_ids = {node["node_id"] for node in nodes}
for node_id in node_ids:
if node_id not in retrieved_node_ids:
results.append(
@ -405,77 +390,90 @@ class GNS3StartNodeQuickTool(BaseTool):
logger.error("node_ids must be a list.")
return {"error": "node_ids must be a list."}
# Initialize Gns3Connector using factory function
# Build handler context (JWT + server URL from request context)
logger.info("Connecting to GNS3 server...")
gns3_server = get_gns3_connector()
gns3_ctx = build_gns3_ctx()
if gns3_server is None:
if gns3_ctx is None:
logger.error("Failed to create GNS3 connector")
return {
"error": "Failed to connect to GNS3 server. "
"Please check your configuration."
}
# Send start commands for all nodes and collect initial status
# Verify nodes exist and capture pre-start info (one call)
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
if "error" in listing:
return {"error": listing["error"]}
nodes_by_id = {n["node_id"]: n for n in listing["nodes"]}
# Send start commands for all nodes (parallel batch)
logger.info(
"Sending start commands for %d nodes in project %s...",
len(node_ids),
project_id,
)
results = []
known_ids = [nid for nid in node_ids if nid in nodes_by_id]
start_results = start_node_handler(
{"project_id": project_id, "node_ids": known_ids}, gns3_ctx
)
start_errors = {
r["node_id"]: r.get("error")
for r in start_results
if r.get("status") == "error"
}
# Get immediate status (likely 'starting' or 'stopped') — one call
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
if "error" in listing:
return {"error": listing["error"]}
after_by_id = {n["node_id"]: n for n in listing["nodes"]}
for node_id in node_ids:
try:
node = Node(
project_id=project_id,
node_id=node_id,
connector=gns3_server,
if node_id not in nodes_by_id:
logger.error(
"Node %s not found in project %s", node_id, project_id
)
# Verify node exists and get current info
node.get()
if not node.node_id:
logger.error(
"Node %s not found in project %s",
node_id,
project_id,
)
results.append(
{
"node_id": node_id,
"name": "N/A",
"status": "error",
"error": "Node not found",
}
)
continue
# Send start command
node.start()
logger.info(
"Start command sent for node %s (%s)",
node_id,
node.name,
)
# Get immediate status (likely 'starting' or 'stopped')
node.get()
node_info = {
"node_id": node.node_id,
"name": node.name or "N/A",
"status": node.status or "unknown",
}
results.append(node_info)
except Exception as e:
logger.error("Failed to start node %s: %s", node_id, e)
results.append(
{
"node_id": node_id,
"name": "N/A",
"status": "error",
"error": str(e),
"error": "Node not found",
}
)
continue
if node_id in start_errors:
logger.error(
"Failed to start node %s: %s",
node_id,
start_errors[node_id],
)
results.append(
{
"node_id": node_id,
"name": nodes_by_id[node_id].get("name") or "N/A",
"status": "error",
"error": start_errors[node_id],
}
)
continue
logger.info(
"Start command sent for node %s (%s)",
node_id,
nodes_by_id[node_id].get("name"),
)
current = after_by_id.get(node_id, nodes_by_id[node_id])
results.append(
{
"node_id": node_id,
"name": current.get("name") or "N/A",
"status": current.get("status") or "unknown",
}
)
# Analyze results (count based on successful command sending)
successful_nodes = [

View File

@ -38,8 +38,11 @@ from typing import Any
from langchain.tools import BaseTool
from langchain_core.callbacks import CallbackManagerForToolRun
from gns3server.agent.gns3_copilot.gns3_client import Node
from gns3server.agent.gns3_copilot.gns3_client import get_gns3_connector
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
build_gns3_ctx,
get_nodes_handler,
stop_node_handler,
)
# Configure logging
logger = logging.getLogger(__name__)
@ -103,75 +106,83 @@ class GNS3StopNodeTool(BaseTool):
logger.error("node_ids must be a list.")
return {"error": "node_ids must be a list."}
# Initialize Gns3Connector using factory function
# Build handler context (JWT + server URL from request context)
logger.info("Connecting to GNS3 server...")
gns3_server = get_gns3_connector()
gns3_ctx = build_gns3_ctx()
if gns3_server is None:
if gns3_ctx is None:
logger.error("Failed to create GNS3 connector")
return {
"error": "Failed to connect to GNS3 server. "
"Please check your configuration."
}
# Stop all nodes and collect results
# Verify nodes exist and capture names (one call)
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
if "error" in listing:
return {"error": listing["error"]}
nodes_by_id = {n["node_id"]: n for n in listing["nodes"]}
# Stop all nodes (parallel batch) and collect results
logger.info(
"Stopping %d nodes in project %s...",
len(node_ids),
project_id,
)
results = []
known_ids = [nid for nid in node_ids if nid in nodes_by_id]
stop_results = stop_node_handler(
{"project_id": project_id, "node_ids": known_ids}, gns3_ctx
)
stop_errors = {
r["node_id"]: r.get("error")
for r in stop_results
if r.get("status") == "error"
}
# Get updated status — one call
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
if "error" in listing:
return {"error": listing["error"]}
after_by_id = {n["node_id"]: n for n in listing["nodes"]}
for node_id in node_ids:
try:
node = Node(
project_id=project_id,
node_id=node_id,
connector=gns3_server,
if node_id not in nodes_by_id:
logger.error(
"Node %s not found in project %s", node_id, project_id
)
# Verify node exists and get current info
node.get()
if not node.node_id:
logger.error(
"Node %s not found in project %s",
node_id,
project_id,
)
results.append(
{
"node_id": node_id,
"name": "N/A",
"status": "error",
"error": "Node not found",
}
)
continue
# Send stop command
node.stop()
logger.info(
"Stop command sent for node %s (%s)",
node_id,
node.name,
)
# Get updated status
node.get()
node_info = {
"node_id": node.node_id,
"name": node.name or "N/A",
"status": node.status or "unknown",
}
results.append(node_info)
except Exception as e:
logger.error("Failed to stop node %s: %s", node_id, e)
results.append(
{
"node_id": node_id,
"name": "N/A",
"status": "error",
"error": str(e),
"error": "Node not found",
}
)
elif node_id in stop_errors:
logger.error(
"Failed to stop node %s: %s", node_id, stop_errors[node_id]
)
results.append(
{
"node_id": node_id,
"name": nodes_by_id[node_id].get("name") or "N/A",
"status": "error",
"error": stop_errors[node_id],
}
)
else:
logger.info(
"Stop command sent for node %s (%s)",
node_id,
nodes_by_id[node_id].get("name"),
)
current = after_by_id.get(node_id, nodes_by_id[node_id])
results.append(
{
"node_id": node_id,
"name": current.get("name") or "N/A",
"status": current.get("status") or "unknown",
}
)

View File

@ -39,8 +39,11 @@ from typing import Any
from langchain.tools import BaseTool
from langchain_core.callbacks import CallbackManagerForToolRun
from gns3server.agent.gns3_copilot.gns3_client import Node
from gns3server.agent.gns3_copilot.gns3_client import get_gns3_connector
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
build_gns3_ctx,
get_nodes_handler,
suspend_node_handler,
)
# Configure logging
logger = logging.getLogger(__name__)
@ -109,75 +112,85 @@ class GNS3SuspendNodeTool(BaseTool):
logger.error("node_ids must be a list.")
return {"error": "node_ids must be a list."}
# Initialize Gns3Connector using factory function
# Build handler context (JWT + server URL from request context)
logger.info("Connecting to GNS3 server...")
gns3_server = get_gns3_connector()
gns3_ctx = build_gns3_ctx()
if gns3_server is None:
if gns3_ctx is None:
logger.error("Failed to create GNS3 connector")
return {
"error": "Failed to connect to GNS3 server. "
"Please check your configuration."
}
# Suspend all nodes and collect results
# Verify nodes exist and capture names (one call)
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
if "error" in listing:
return {"error": listing["error"]}
nodes_by_id = {n["node_id"]: n for n in listing["nodes"]}
# Suspend all nodes (parallel batch) and collect results
logger.info(
"Suspending %d nodes in project %s...",
len(node_ids),
project_id,
)
results = []
known_ids = [nid for nid in node_ids if nid in nodes_by_id]
suspend_results = suspend_node_handler(
{"project_id": project_id, "node_ids": known_ids}, gns3_ctx
)
suspend_errors = {
r["node_id"]: r.get("error")
for r in suspend_results
if r.get("status") == "error"
}
# Get updated status — one call
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
if "error" in listing:
return {"error": listing["error"]}
after_by_id = {n["node_id"]: n for n in listing["nodes"]}
for node_id in node_ids:
try:
node = Node(
project_id=project_id,
node_id=node_id,
connector=gns3_server,
if node_id not in nodes_by_id:
logger.error(
"Node %s not found in project %s", node_id, project_id
)
# Verify node exists and get current info
node.get()
if not node.node_id:
logger.error(
"Node %s not found in project %s",
node_id,
project_id,
)
results.append(
{
"node_id": node_id,
"name": "N/A",
"status": "error",
"error": "Node not found",
}
)
continue
# Send suspend command
node.suspend()
logger.info(
"Suspend command sent for node %s (%s)",
node_id,
node.name,
)
# Get updated status
node.get()
node_info = {
"node_id": node.node_id,
"name": node.name or "N/A",
"status": node.status or "unknown",
}
results.append(node_info)
except Exception as e:
logger.error("Failed to suspend node %s: %s", node_id, e)
results.append(
{
"node_id": node_id,
"name": "N/A",
"status": "error",
"error": str(e),
"error": "Node not found",
}
)
elif node_id in suspend_errors:
logger.error(
"Failed to suspend node %s: %s",
node_id,
suspend_errors[node_id],
)
results.append(
{
"node_id": node_id,
"name": nodes_by_id[node_id].get("name") or "N/A",
"status": "error",
"error": suspend_errors[node_id],
}
)
else:
logger.info(
"Suspend command sent for node %s (%s)",
node_id,
nodes_by_id[node_id].get("name"),
)
current = after_by_id.get(node_id, nodes_by_id[node_id])
results.append(
{
"node_id": node_id,
"name": current.get("name") or "N/A",
"status": current.get("status") or "unknown",
}
)

View File

@ -38,8 +38,11 @@ from typing import Any
from langchain.tools import BaseTool
from langchain_core.callbacks import CallbackManagerForToolRun
from gns3server.agent.gns3_copilot.gns3_client import Node
from gns3server.agent.gns3_copilot.gns3_client import get_gns3_connector
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
build_gns3_ctx,
get_nodes_handler,
update_node_handler,
)
# Configure logging
logger = logging.getLogger(__name__)
@ -126,17 +129,23 @@ class GNS3UpdateNodeNameTool(BaseTool):
"error": f"Node {i + 1} missing node_id or new_name."
}
# Initialize Gns3Connector
# Build handler context (JWT + server URL from request context)
logger.info("Connecting to GNS3 server...")
gns3_server = get_gns3_connector()
gns3_ctx = build_gns3_ctx()
if gns3_server is None:
if gns3_ctx is None:
logger.error("Failed to create GNS3 connector")
return {
"error": "Failed to connect to GNS3 server. "
"Please check your configuration."
}
# Fetch current node names in one call (old names + existence)
listing = get_nodes_handler({"project_id": project_id}, gns3_ctx)
if "error" in listing:
return {"error": listing["error"]}
nodes_by_id = {n["node_id"]: n for n in listing["nodes"]}
# Update node names
logger.info(
"Updating names for %d nodes in project %s...",
@ -158,21 +167,25 @@ class GNS3UpdateNodeNameTool(BaseTool):
new_name,
)
# Get node to retrieve current name
node = Node(
project_id=project_id,
node_id=node_id,
connector=gns3_server,
node = nodes_by_id.get(node_id)
if node is None:
raise ValueError("Node not found")
old_name = node.get("name")
# Update node name — the PUT response is the updated node
updated = update_node_handler(
{
"project_id": project_id,
"node_id": node_id,
"name": new_name,
},
gns3_ctx,
)
node.get()
old_name = node.name
if "error" in updated:
raise RuntimeError(updated["error"])
current_name = updated.get("name")
# Update node name
node.update(name=new_name)
# Verify update
node.get()
if node.name == new_name:
if current_name == new_name:
node_info = {
"node_id": node_id,
"old_name": old_name,
@ -190,7 +203,7 @@ class GNS3UpdateNodeNameTool(BaseTool):
"node_id": node_id,
"old_name": old_name,
"new_name": new_name,
"current_name": node.name,
"current_name": current_name,
"status": "failed",
"error": "Name verification failed",
}

View File

@ -196,7 +196,7 @@ class VPCSCommands(BaseTool):
)
except ValueError as e:
logger.error("Failed to prepare device hosts data: %s", e)
return [{"error": str(e)}]
return [{"status": "failed", "error": str(e)}]
# Check if any devices have errors (e.g., missing device)
error_devices = {
@ -223,7 +223,7 @@ class VPCSCommands(BaseTool):
dynamic_nr = self._initialize_nornir(hosts_data)
except ValueError as e:
logger.error("Failed to initialize Nornir: %s", e)
return [{"error": str(e)}]
return [{"status": "failed", "error": str(e)}]
results = []
@ -244,7 +244,7 @@ class VPCSCommands(BaseTool):
except Exception as e:
# Overall execution failed
logger.error("Error executing commands on all VPCS devices: %s", e)
return [{"error": f"Execution error: {str(e)}"}]
return [{"status": "failed", "error": f"Execution error: {str(e)}"}]
logger.debug(
"VPCS command execution completed. Results: %s",
@ -338,7 +338,7 @@ class VPCSCommands(BaseTool):
"Invalid JSON string received as tool input: %s", e
)
return (
[{"error": f"Invalid JSON string input from model: {e}"}],
[{"status": "failed", "error": f"Invalid JSON string input from model: {e}"}],
None,
)
else:
@ -357,18 +357,18 @@ class VPCSCommands(BaseTool):
if not project_id:
error_msg = "Missing required 'project_id' field in input"
logger.error(error_msg)
return ([{"error": error_msg}], None)
return ([{"status": "failed", "error": error_msg}], None)
if not self._validate_project_id(project_id):
error_msg = f"Invalid project_id: {project_id}. Expected UUID."
logger.error(error_msg)
return ([{"error": error_msg}], None)
return ([{"status": "failed", "error": error_msg}], None)
# Validate device_configs
if not isinstance(device_configs, list):
error_msg = "'device_configs' must be an array"
logger.error(error_msg)
return ([{"error": error_msg}], None)
return ([{"status": "failed", "error": error_msg}], None)
if not device_configs:
logger.warning("Device configs list is empty.")
@ -382,7 +382,7 @@ class VPCSCommands(BaseTool):
f"got {type(parsed_input).__name__}"
)
logger.error(error_msg)
return ([{"error": error_msg}], None)
return ([{"status": "failed", "error": error_msg}], None)
def _validate_project_id(self, project_id: str) -> bool:
"""
@ -461,6 +461,25 @@ class VPCSCommands(BaseTool):
port = device_ports[device_name]["port"]
node_type = device_ports[device_name].get("node_type")
if node_type != "vpcs":
# VPCS syntax typed into another node's CLI is silently
# discarded (e.g. IOS answers "% Invalid input"), so reject
# mismatched devices before a console session is opened
logger.error(
"Device '%s' is a %s node, not a VPCS node",
device_name,
node_type or "unknown-type",
)
hosts_data[device_name] = {
"error": (
f"Device '{device_name}' is a {node_type or 'unknown-type'} node, "
"not a VPCS node; use device_config_send / device_show_run "
"for network devices"
)
}
continue
# VPCS devices use gns3_vpcs_telnet device type
hosts_data[device_name] = {
"port": port,
@ -559,8 +578,8 @@ class VPCSCommands(BaseTool):
if device_name in hosts_data and "error" in hosts_data[device_name]:
results.append({
"device_name": device_name,
"status": "error",
"output": hosts_data[device_name]["error"],
"status": "failed",
"error": hosts_data[device_name]["error"],
"commands": device_config["commands"],
})
continue
@ -574,8 +593,8 @@ class VPCSCommands(BaseTool):
error_msg = str(host_result.result) if host_result.result else "Unknown error"
results.append({
"device_name": device_name,
"status": "error",
"output": error_msg,
"status": "failed",
"error": error_msg,
"commands": device_config["commands"],
})
else:
@ -590,8 +609,8 @@ class VPCSCommands(BaseTool):
# Device not in task result (shouldn't happen)
results.append({
"device_name": device_name,
"status": "error",
"output": f"Device '{device_name}' not in task results",
"status": "failed",
"error": f"Device '{device_name}' not in task results",
"commands": device_config["commands"],
})

View File

@ -59,6 +59,7 @@ def get_device_ports_from_topology(
"device_name": {
"port": console_port,
"platform": "huawei", # Extracted from tags
"node_type": "vpcs", # GNS3 node type from the topology
"groups": ["network_devices"], # For inheriting shared settings
"connection_options": {
"netmiko": {
@ -160,9 +161,14 @@ def get_device_ports_from_topology(
# This is the Nornir best practice - each host has its own
# connection configuration (device_type), while sharing common
# settings (hostname, timeout) via group inheritance.
# node_type (the GNS3 node type, e.g. vpcs/iou/docker) lets callers
# reject mismatched devices before opening a console connection;
# DictInventory ignores keys it does not know, so carrying it here
# is safe for entries fed straight into Nornir.
host_entry = {
"port": node_info["console_port"],
"platform": platform,
"node_type": node_info.get("type"),
"groups": ["network_devices"], # For inheriting hostname, timeout, etc.
"connection_options": {
"netmiko": {

View File

@ -31,7 +31,6 @@ import json
import asyncio
import logging
import socket
import uuid
from uuid import UUID
import bcrypt
from typing import Any, Annotated
@ -61,16 +60,19 @@ from .projects import (
get_project_stats_handler, update_project_handler, duplicate_project_handler,
get_project_readme_handler, update_project_readme_handler,
lock_project_handler, unlock_project_handler,
load_project_handler, get_locked_project_handler,
get_locked_project_handler,
)
from .server import (
get_version_handler, get_statistics_handler,
)
from .symbols import (
get_symbols_handler, get_symbol_handler,
get_symbol_dimensions_handler, get_default_symbols_handler,
upload_symbol_handler, delete_symbol_handler,
)
# Symbol tools are disabled for now: they require a vision-capable model to
# be genuinely useful (the tools shuttle SVG content, which a text-only LLM
# cannot inspect or produce). Revisit later.
# from .symbols import (
# get_symbols_handler, get_symbol_handler,
# get_symbol_dimensions_handler, get_default_symbols_handler,
# upload_symbol_handler, delete_symbol_handler,
# )
from .appliances import (
get_appliances_handler, get_appliance_handler,
install_appliance_handler,
@ -84,7 +86,7 @@ from .device_config import (
device_config_send_handler, device_show_run_handler,
vpcs_config_set_handler,
)
from .nodes import (
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
get_nodes_handler, get_node_handler, start_node_handler,
stop_node_handler, suspend_node_handler,
create_node_handler, delete_node_handler, update_node_handler,
@ -95,9 +97,8 @@ from .nodes import (
suspend_all_nodes_handler,
duplicate_node_handler, isolate_node_handler,
unisolate_node_handler, get_node_links_handler,
)
from .links import (
get_links_handler, get_link_handler, create_link_handler,
get_links_handler, get_link_handler, available_filters_handler,
create_link_handler,
delete_link_handler, update_link_handler,
reset_link_handler, start_capture_handler, stop_capture_handler,
download_capture_file_handler,
@ -508,6 +509,9 @@ async def node_create(
Single mode: provide template_id, x, y (optional compute_id)
Batch mode: provide nodes=[{name, template_id?, x?, y?, compute_id?}] creates up to 100 in parallel.
Top-level template_id applies to all nodes; individual nodes can override.
Results are always returned in submission order; correlate nodes by node_id, not name.
When a node omits `name`, the server assigns a default name (R-1, R-2, ...) and console
port such batches are created sequentially so those assignments follow submission order.
"""
if nodes is not None:
return await asyncio.to_thread(_run_handler_sync, create_node_handler, {
@ -676,7 +680,19 @@ async def link_update(
return await asyncio.to_thread(_run_handler_sync, update_link_handler, params)
# ── Template tools ────────────────────────────────────────────────────
@mcp.tool()
async def link_available_filters(
project_id: Annotated[str, Field(description="UUID of the project")],
link_id: Annotated[str, Field(description="UUID of the link")],
) -> list[dict[str, Any]]:
"""List the packet filter types available for a link (frequency_drop, packet_loss, delay, corrupt, bpf)
with their parameters. Use before setting filters with link_update."""
return await asyncio.to_thread(_run_handler_sync, available_filters_handler, {
"project_id": project_id, "link_id": link_id,
})
# ── Template tools ────────────────────────────────────────────
@mcp.tool()
async def template_list(
@ -755,12 +771,12 @@ async def compute_list() -> list[dict[str, Any]]:
@mcp.tool()
async def compute_get(
compute_id: Annotated[uuid.UUID, Field(description="Compute UUID from compute_list output")],
compute_id: Annotated[str, Field(description="Compute ID: 'local' (default) for the built-in local compute, or a compute UUID from compute_list")] = "local",
) -> list[dict[str, Any]]:
"""Get detailed information about a registered remote compute node.
"""Get detailed information about a compute node.
NOTE: Only works for computes registered in the database (returned by compute_list).
For the built-in local compute info, use server_statistics instead.
Accepts 'local' for the built-in local compute or a UUID from compute_list
for a registered remote compute.
"""
return await asyncio.to_thread(_run_handler_sync, get_compute_handler, {"compute_id": compute_id})
@ -768,12 +784,12 @@ async def compute_get(
@mcp.tool()
async def compute_images(
emulator: Annotated[str, Field(description="Emulator type (e.g. qemu, iou, docker)")],
compute_id: Annotated[uuid.UUID, Field(description="Compute UUID from compute_list output")],
compute_id: Annotated[str, Field(description="Compute ID: 'local' (default) for the built-in local compute, or a compute UUID from compute_list")] = "local",
) -> list[dict[str, Any]]:
"""List available images for an emulator on a registered compute node.
"""List available images for an emulator on a compute node.
NOTE: Only works for computes registered in the database.
For the local compute, the default compute_id is typically found via server_statistics.
Accepts 'local' for the built-in local compute or a UUID from compute_list
for a registered remote compute.
"""
return await asyncio.to_thread(_run_handler_sync, get_compute_images_handler, {
"emulator": emulator, "compute_id": compute_id,
@ -1021,6 +1037,7 @@ async def link_marker(
capture_node_id: Annotated[str | None, Field(description="UUID of the endpoint whose uBridge hosts the marker (the observer; tx/rx are from its perspective). Must be a link endpoint and marker-capable. Omit to auto-pick.")] = None,
color: Annotated[str | None, Field(description="Hex color for UI highlight, e.g. '#ff5722'")] = None,
highlight_duration: Annotated[int | None, Field(description="UI highlight duration in milliseconds")] = None,
data_link_type: Annotated[str | None, Field(description="pcap link-layer type for serial links (create-only): DLT_C_HDLC / DLT_PPP_SERIAL / DLT_FRELAY / DLT_ATM_RFC1483, matching the encapsulation on the serial link. Omit = DLT_EN10MB (Ethernet). Ignored on update — changing it would invalidate the capture file.")] = None,
) -> list[dict[str, Any]]:
"""Manage traffic-insight markers on a link.
@ -1037,7 +1054,7 @@ async def link_marker(
and cannot be modified or deleted via this tool.
"""
params = {"project_id": project_id, "link_id": link_id, "action": action}
for opt in ("bpf", "marker_name", "name", "tag", "enabled", "direction", "capture_node_id", "color", "highlight_duration"):
for opt in ("bpf", "marker_name", "name", "tag", "enabled", "direction", "capture_node_id", "color", "highlight_duration", "data_link_type"):
val = locals().get(opt)
if val is not None:
params[opt] = val
@ -1253,16 +1270,6 @@ async def project_locked(
})
@mcp.tool()
async def project_load(
path: Annotated[str, Field(description="Filesystem path to the .gns3 project file")],
) -> list[dict[str, Any]]:
"""Load a project from a file path on the server's filesystem."""
return await asyncio.to_thread(_run_handler_sync, load_project_handler, {
"path": path,
})
# ── Server info tools ─────────────────────────────────────────────────
@ -1279,64 +1286,67 @@ async def server_statistics() -> list[dict[str, Any]]:
# ── Symbol tools ──────────────────────────────────────────────────────
@mcp.tool()
async def symbol_list() -> list[dict[str, Any]]:
"""List all available symbols on the server."""
return await asyncio.to_thread(_run_handler_sync, get_symbols_handler, {})
@mcp.tool()
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). 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,
})
@mcp.tool()
async def symbol_dimensions(
symbol_id: Annotated[str, Field(description="Symbol ID to get dimensions for")],
) -> list[dict[str, Any]]:
"""Get the dimensions (width, height) of a symbol."""
return await asyncio.to_thread(_run_handler_sync, get_symbol_dimensions_handler, {
"symbol_id": symbol_id,
})
@mcp.tool()
async def symbol_defaults() -> list[dict[str, Any]]:
"""Get the default symbol mapping for each node type."""
return await asyncio.to_thread(_run_handler_sync, get_default_symbols_handler, {})
@mcp.tool()
async def symbol_upload(
symbol_id: Annotated[str, Field(description="Symbol ID to upload (e.g. ':/symbols/my_symbol.svg')")],
content: Annotated[str, Field(description="SVG content of the symbol")],
) -> list[dict[str, Any]]:
"""Upload or update a custom symbol on the server. Provide the SVG content as a string."""
return await asyncio.to_thread(_run_handler_sync, upload_symbol_handler, {
"symbol_id": symbol_id, "content": content,
})
@mcp.tool()
async def symbol_delete(
symbol_id: Annotated[str, Field(description="Symbol ID to delete (e.g. ':/symbols/my_custom_symbol.svg'). Use symbol_list to get existing IDs.")],
) -> list[dict[str, Any]]:
"""Delete a custom symbol from the server.
NOTE: Only custom (user-uploaded) symbols can be deleted.
Built-in symbols (starting with ':/symbols/') will be rejected with 403.
Use symbol_list to see which symbols are available and their IDs.
"""
return await asyncio.to_thread(_run_handler_sync, delete_symbol_handler, {
"symbol_id": symbol_id,
})
#
# Disabled for now: symbol handling requires a vision-capable model (the
# tools shuttle SVG content, which a text-only LLM cannot inspect or
# produce). Revisit later.
#
# @mcp.tool()
# async def symbol_list() -> list[dict[str, Any]]:
# """List all available symbols on the server."""
# return await asyncio.to_thread(_run_handler_sync, get_symbols_handler, {})
#
#
# @mcp.tool()
# 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). 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,
# })
#
#
# @mcp.tool()
# async def symbol_dimensions(
# symbol_id: Annotated[str, Field(description="Symbol ID to get dimensions for")],
# ) -> list[dict[str, Any]]:
# """Get the dimensions (width, height) of a symbol."""
# return await asyncio.to_thread(_run_handler_sync, get_symbol_dimensions_handler, {
# "symbol_id": symbol_id,
# })
#
#
# @mcp.tool()
# async def symbol_defaults() -> list[dict[str, Any]]:
# """Get the default symbol mapping for each node type."""
# return await asyncio.to_thread(_run_handler_sync, get_default_symbols_handler, {})
#
#
# @mcp.tool()
# async def symbol_upload(
# symbol_id: Annotated[str, Field(description="Symbol ID to upload (e.g. ':/symbols/my_symbol.svg')")],
# content: Annotated[str, Field(description="SVG content of the symbol")],
# ) -> list[dict[str, Any]]:
# """Upload or update a custom symbol on the server. Provide the SVG content as a string."""
# return await asyncio.to_thread(_run_handler_sync, upload_symbol_handler, {
# "symbol_id": symbol_id, "content": content,
# })
#
#
# @mcp.tool()
# async def symbol_delete(
# symbol_id: Annotated[str, Field(description="Symbol ID to delete (e.g. ':/symbols/my_custom_symbol.svg'). Use symbol_list to get existing IDs.")],
# ) -> list[dict[str, Any]]:
# """Delete a custom symbol from the server.
#
# NOTE: Only custom (user-uploaded) symbols can be deleted.
# Built-in symbols (starting with ':/symbols/') will be rejected with 403.
# Use symbol_list to see which symbols are available and their IDs.
# """
# return await asyncio.to_thread(_run_handler_sync, delete_symbol_handler, {
# "symbol_id": symbol_id,
# })
# ── Appliance tools ───────────────────────────────────────────────────
@ -1365,7 +1375,7 @@ async def appliance_install(
appliance_id: Annotated[str, Field(description="UUID of the appliance to install")],
version: Annotated[str | None, Field(description="Version to install (e.g. '2.7.0.356'). Required if the appliance has multiple versions. Use appliance_get to see available versions.")] = None,
) -> list[dict[str, Any]]:
"""Create a template from a GNS3 appliance definition.
"""Create a template from a GNS3 appliance definition and return the created template.
NOTE: This does NOT download images. Images must be placed in the
GNS3 images directory (e.g. ~/GNS3/images/) beforehand.
@ -1424,7 +1434,9 @@ async def image_install() -> list[dict[str, Any]]:
This is NOT for downloading images. Images must be uploaded first (via the GNS3 Web UI).
If an uploaded image matches a known appliance, a template is automatically created.
Images already referenced by existing templates are skipped.
Returns {"created": [...], "skipped": [...]}: images already referenced by existing
templates are skipped, and no template is auto-created when one with the same name
already exists (regardless of version).
"""
return await asyncio.to_thread(_run_handler_sync, install_images_handler, {})
@ -1458,6 +1470,10 @@ async def device_config_send(
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
Error contract: every failure is reported in-band as an entry with
status "failed" and an "error" message (per-device entries also carry
device_name and commands).
"""
params = {"project_id": project_id, "device_configs": device_configs}
if template is not None:
@ -1488,6 +1504,10 @@ async def device_show_run(
(e.g. device_type:cisco_ios_telnet, device_type:gns3_huawei_telnet_ce).
Nodes without this tag will fail with "device_type tag not found".
Docker/Linux nodes are not supported (use node_console instead).
Error contract: every failure is reported in-band as an entry with
status "failed" and an "error" message (per-device entries also carry
device_name and commands).
"""
params = {"project_id": project_id, "device_configs": device_configs}
if template is not None:
@ -1504,6 +1524,11 @@ async def vpcs_config_set(
) -> list[dict[str, Any]]:
"""Configure VPCS devices (set IP addresses, gateway, etc.).
Only VPCS nodes are accepted: any other node type in device_configs fails
with a per-device error instead of typing VPCS syntax into its CLI.
Every failure is reported in-band as an entry with status "failed"
and an "error" message.
VPCS-specific configuration commands:
- ip <address>/<mask> <gateway> Set IP and gateway
- save Save config to startup.vpc

View File

@ -29,7 +29,7 @@ log = logging.getLogger(__name__)
# ── Helper ─────────────────────────────────────────────────────────────────
def _get_connector(gns3_ctx: dict[str, Any]):
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Gns3Connector
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
return Gns3Connector(
url=gns3_ctx["server_url"],
jwt_token=gns3_ctx["jwt_token"],
@ -85,5 +85,13 @@ def install_appliance_handler(params: dict[str, Any], gns3_ctx: dict[str, Any])
version = params.get("version")
if version:
request_params["version"] = version
result = conn.http_call("post", url, params=request_params).json()
return {"message": f"Appliance {appliance_id} installation requested", "result": result}
response = conn.http_call("post", url, params=request_params)
result = {"message": f"Appliance {appliance_id} installed"}
if response.content:
# the install endpoint returns the created template (201); tolerate an
# empty body in case an older server still replies with 204
template = response.json()
result["template"] = {
k: template[k] for k in ("template_id", "name", "version", "template_type") if k in template
}
return result

View File

@ -26,7 +26,7 @@ log = logging.getLogger(__name__)
def _get_connector(gns3_ctx: dict[str, Any]):
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Gns3Connector
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
return Gns3Connector(
url=gns3_ctx["server_url"],
jwt_token=gns3_ctx["jwt_token"],
@ -42,20 +42,16 @@ def list_computes_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> d
def get_compute_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
compute_id = params.get("compute_id")
if not compute_id:
return {"error": "compute_id is required (use compute_list to get the UUID)"}
compute_id = params.get("compute_id") or "local"
conn = _get_connector(gns3_ctx)
return conn.http_call("get", f"{conn.base_url}/computes/{compute_id}").json()
def get_compute_images_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
emulator = params.get("emulator")
compute_id = params.get("compute_id")
compute_id = params.get("compute_id") or "local"
if not emulator:
return {"error": "emulator is required (e.g. qemu, iou, docker)"}
if not compute_id:
return {"error": "compute_id is required (use compute_list to get the UUID)"}
conn = _get_connector(gns3_ctx)
images = conn.http_call("get", f"{conn.base_url}/computes/{compute_id}/{emulator}/images").json()
return {"images": images, "count": len(images)}

View File

@ -51,7 +51,13 @@ def _render_template(template: str, device_configs: list[dict], commands_field:
Args:
commands_field: field name for the rendered commands, e.g. "config_commands", "commands"
"""
jinja = JinjaTemplate(template)
try:
jinja = JinjaTemplate(template)
except JinjaError as e:
# a syntactically invalid template must not escape as a raw exception
error_msg = f"Template rendering failed: {e}"
log.error(error_msg)
return [{"status": "failed", "error": error_msg}]
merged: dict[str, dict] = {}
for dev in device_configs:
name = dev.get("device_name")
@ -69,7 +75,7 @@ def _render_template(template: str, device_configs: list[dict], commands_field:
except JinjaError as e:
error_msg = f"Template rendering failed for '{name}': {e}"
log.error(error_msg)
return [{"error": error_msg}]
return [{"status": "failed", "error": error_msg}]
return list(merged.values())
@ -81,7 +87,7 @@ def device_config_send_handler(params: dict[str, Any], gns3_ctx: dict[str, Any])
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"}]
return [{"status": "failed", "error": "project_id and device_configs are required"}]
if template:
device_configs = _render_template(template, device_configs, commands_field="config_commands")
@ -108,7 +114,7 @@ def device_show_run_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) ->
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, commands}) are required"}]
return [{"status": "failed", "error": "project_id and device_configs (list of {device_name, commands}) are required"}]
if template:
device_configs = _render_template(template, device_configs, commands_field="commands")
@ -134,7 +140,7 @@ def vpcs_config_set_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) ->
project_id = params.get("project_id")
device_configs = params.get("device_configs")
if not project_id or not device_configs:
return [{"error": "project_id and device_configs are required"}]
return [{"status": "failed", "error": "project_id and device_configs are required"}]
from gns3server.agent.gns3_copilot.tools_v2.vpcs_tools_netmiko import VPCSCommands

View File

@ -29,7 +29,7 @@ log = logging.getLogger(__name__)
# ── Helper ─────────────────────────────────────────────────────────────────
def _get_connector(gns3_ctx: dict[str, Any]):
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Gns3Connector
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
return Gns3Connector(
url=gns3_ctx["server_url"],
jwt_token=gns3_ctx["jwt_token"],

View File

@ -29,7 +29,7 @@ log = logging.getLogger(__name__)
# ── Helper ─────────────────────────────────────────────────────────────────
def _get_connector(gns3_ctx: dict[str, Any]):
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Gns3Connector
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
return Gns3Connector(
url=gns3_ctx["server_url"],
jwt_token=gns3_ctx["jwt_token"],
@ -72,6 +72,9 @@ def prune_images_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> di
def install_images_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
conn = _get_connector(gns3_ctx)
# Returns 204 No Content on success (empty body, no .json())
conn.http_call("post", f"{conn.base_url}/images/install")
response = conn.http_call("post", f"{conn.base_url}/images/install")
if response.content:
# the install endpoint reports which templates were created or skipped
return response.json()
# tolerate an empty body in case an older server still replies with 204
return {"message": "Image installation completed"}

View File

@ -1,633 +0,0 @@
#
# Copyright (C) 2026 GNS3 Technologies Inc.
# Author: Yue Guobin
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
MCP tool handlers for GNS3 link management.
Handlers receive (params, gns3_ctx) and call GNS3's REST API
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 = 100
# ── Helper ─────────────────────────────────────────────────────────────────
def _get_connector(gns3_ctx: dict[str, Any]):
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Gns3Connector
return Gns3Connector(
url=gns3_ctx["server_url"],
jwt_token=gns3_ctx["jwt_token"],
api_version=3,
verify=False,
)
def _normalize_link_nodes(nodes) -> list[dict[str, Any]]:
"""
Normalize link node entries, accepting both standard object format and
compact array format to reduce token usage.
Standard: [{"node_id": "uuid", "adapter_number": 0, "port_number": 0}]
Compact: ["uuid", 0, 0, "uuid", 0, 0]
Returns the normalized list, or raises ValueError with a clear message
on format errors so the AI can self-correct.
"""
if not nodes:
return nodes
if not isinstance(nodes, list):
raise ValueError(f"nodes must be a list, got {type(nodes).__name__}: {nodes}")
# Standard object format: [{"node_id": "...", ...}]
if isinstance(nodes[0], dict):
return nodes
# Compact array format: ["uuid", ad, pt, "uuid", ad, pt]
if all(not isinstance(n, dict) for n in nodes):
if len(nodes) != 6:
raise ValueError(
f"Compact link format requires exactly 6 elements "
f"[node_id, adapter, port, node_id, adapter, port], "
f"but got {len(nodes)} elements: {nodes}"
)
if not isinstance(nodes[0], str) or not isinstance(nodes[3], str):
raise ValueError(
f"Compact link format expects node_id (string) at positions 0 and 3, "
f"got types {type(nodes[0]).__name__} and {type(nodes[3]).__name__}: {nodes}"
)
return [
{"node_id": nodes[0], "adapter_number": nodes[1], "port_number": nodes[2]},
{"node_id": nodes[3], "adapter_number": nodes[4], "port_number": nodes[5]},
]
raise ValueError(
f"Unrecognized link nodes format. "
f"Use standard [{{\"node_id\":\"..\",\"adapter_number\":0,\"port_number\":0}},...] "
f"or compact [\"id\",0,0,\"id\",0,0], got: {nodes}"
)
# ── 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",
}
LINK_DEFAULT_FIELDS = ["link_id", "link_type", "nodes"]
def _filter_link_response(link: dict, fields: list[str] = None) -> dict:
"""Filter link response to only include requested fields."""
if not fields:
fields = LINK_DEFAULT_FIELDS
return {k: link[k] for k in fields if k in link}
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)}
def get_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id")
link_id = params.get("link_id")
if not project_id or not link_id:
return {"error": "project_id and link_id are required"}
conn = _get_connector(gns3_ctx)
return conn.http_call("get", f"{conn.base_url}/projects/{project_id}/links/{link_id}").json()
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"}
fields = params.get("fields")
if fields is not None and not isinstance(fields, list):
return {"error": "fields must be a list, e.g. [\"link_id\", \"nodes\"]"}
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):
raw_nodes = link_data.get("nodes")
if not raw_nodes:
return {"status": "error", "error": "nodes is required for each link"}
try:
body = {"nodes": _normalize_link_nodes(raw_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": _filter_link_response(resp, fields)}
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 nodes:
return {"error": "nodes is required"}
conn = _get_connector(gns3_ctx)
data = {"nodes": _normalize_link_nodes(nodes)}
if "link_type" in params:
data["link_type"] = params["link_type"]
if "filters" in params:
data["filters"] = params["filters"]
if "suspend" in params:
data["suspend"] = params["suspend"]
url = f"{conn.base_url}/projects/{project_id}/links"
resp = conn.http_call("post", url, json_data=data).json()
return _filter_link_response(resp, fields)
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 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}
def update_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id")
link_id = params.get("link_id")
if not project_id or not link_id:
return {"error": "project_id and link_id are required"}
conn = _get_connector(gns3_ctx)
# Extract update parameters - handle nested kwargs structure from MCP clients
if "kwargs" in params and isinstance(params["kwargs"], dict):
update_data = params["kwargs"]
else:
update_data = {k: v for k, v in params.items() if k not in ("project_id", "link_id", "kwargs")}
url = f"{conn.base_url}/projects/{project_id}/links/{link_id}"
return conn.http_call("put", url, json_data=update_data).json()
# ── Link capture / reset handlers ──────────────────────────────────────
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 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 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"),
"wireshark": params.get("wireshark", False),
}
if params.get("capture_file_name"):
data["capture_file_name"] = params["capture_file_name"]
url = f"{conn.base_url}/projects/{project_id}/links/{link_id}/capture/start"
result = conn.http_call("post", url, json_data=data).json()
return {"message": f"Capture started on link {link_id}", "link": result}
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 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)
return {"message": f"Capture stopped on link {link_id}", "link_id": link_id}
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, token_version=gns3_ctx.get("jwt_token_version", 0), 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 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"
result = {
"link_id": link_id,
"download_url": download_url,
"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
# ── Marker (traffic-insight) handlers ──────────────────────────────────
def link_marker_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
"""
Manage traffic-insight markers on a specific link.
Actions:
- create: POST /projects/{pid}/links/{lid}/markers
- update: PUT /projects/{pid}/links/{lid}/markers/{name}
- delete: DELETE /projects/{pid}/links/{lid}/markers/{name}
"""
project_id = params.get("project_id")
link_id = params.get("link_id")
action = params.get("action")
if not all([project_id, link_id, action]):
return {"error": "project_id, link_id and action are required"}
if action not in ("create", "update", "delete"):
return {"error": f"Unknown action: {action}. Supported: create, update, delete"}
conn = _get_connector(gns3_ctx)
base = f"{conn.base_url}/projects/{project_id}/links/{link_id}/markers"
if action == "create":
bpf = params.get("bpf")
if not bpf:
return {"error": "bpf is required for create action"}
body: dict[str, Any] = {"bpf": bpf}
for opt in ("name", "tag", "capture_node_id", "color", "highlight_duration"):
if params.get(opt) is not None:
body[opt] = params[opt]
# direction: "tx"/"rx" set a one-way filter; "both"/omitted = no filter.
if params.get("direction") in ("tx", "rx"):
body["direction"] = params["direction"]
return conn.http_call("post", base, json_data=body).json()
marker_name = params.get("marker_name")
if not marker_name:
return {"error": "marker_name is required for update/delete actions"}
url = f"{base}/{marker_name}"
if action == "update":
body = {}
for opt in ("bpf", "tag", "enabled", "color", "highlight_duration"):
if params.get(opt) is not None:
body[opt] = params[opt]
# direction tri-state: omitted=preserve, "tx"/"rx"=set, "both"=clear (→ null).
direction = params.get("direction")
if direction == "both":
body["direction"] = None
elif direction in ("tx", "rx"):
body["direction"] = direction
if not body:
return {"error": "At least one update field is required (bpf, tag, enabled, direction, color, highlight_duration)"}
return conn.http_call("put", url, json_data=body).json()
# action == "delete"
conn.http_call("delete", url)
return {"message": f"Marker '{marker_name}' deleted from link {link_id}", "link_id": link_id, "marker_name": marker_name}
def marker_definition_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
"""
Manage project-level marker definitions (auto-fanout to all links).
Actions:
- create: POST /projects/{pid}/marker-definitions fans out global-{name} to every link
- update: PUT /projects/{pid}/marker-definitions/{name}
- delete: DELETE /projects/{pid}/marker-definitions/{name}
- list: GET /projects/{pid}/marker-definitions
"""
project_id = params.get("project_id")
action = params.get("action")
if not all([project_id, action]):
return {"error": "project_id and action are required"}
if action not in ("create", "update", "delete", "list"):
return {"error": f"Unknown action: {action}. Supported: create, update, delete, list"}
conn = _get_connector(gns3_ctx)
base = f"{conn.base_url}/projects/{project_id}/marker-definitions"
if action == "list":
return conn.http_call("get", base).json()
if action == "create":
bpf = params.get("bpf")
if not bpf:
return {"error": "bpf is required for create action"}
body: dict[str, Any] = {"bpf": bpf}
for opt in ("name", "tag", "color", "highlight_duration", "data_link_type"):
if params.get(opt) is not None:
body[opt] = params[opt]
# No direction: a definition fans out to every link and auto-selects its
# capture node on each, so tx/rx (which is relative to that node) has no
# consistent meaning. Encode direction in the BPF instead.
return conn.http_call("post", base, json_data=body).json()
def_name = params.get("def_name")
if not def_name:
return {"error": "def_name is required for update/delete actions"}
url = f"{base}/{def_name}"
if action == "update":
body = {}
for opt in ("bpf", "tag", "color", "highlight_duration", "data_link_type"):
if params.get(opt) is not None:
body[opt] = params[opt]
if not body:
return {"error": "At least one update field is required (bpf, tag, color, highlight_duration, data_link_type)"}
return conn.http_call("put", url, json_data=body).json()
# action == "delete"
conn.http_call("delete", url)
return {"message": f"Marker definition '{def_name}' deleted", "project_id": project_id, "def_name": def_name}
# ── Tool definitions ───────────────────────────────────────────────────────
LINK_TOOLS = [
{
"name": "get_links",
"description": "List all links in a project",
"parameters": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project UUID"},
},
"required": ["project_id"],
},
"handler": get_links_handler,
},
{
"name": "get_link",
"description": "Get detailed information about a specific link",
"parameters": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project UUID"},
"link_id": {"type": "string", "description": "Link UUID"},
},
"required": ["project_id", "link_id"],
},
"handler": get_link_handler,
},
{
"name": "create_link",
"description": "Create a link between two nodes in a project",
"parameters": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project UUID"},
"nodes": {
"type": "array",
"description": "List of node connections, each with node_id, adapter_number, port_number",
"items": {
"type": "object",
"properties": {
"node_id": {"type": "string"},
"adapter_number": {"type": "integer"},
"port_number": {"type": "integer"},
},
},
},
"link_type": {"type": "string", "description": "Link type: ethernet or serial (optional)"},
"filters": {
"type": "object",
"description": "Packet filters (optional). Must use array format: frequency_drop: [N], packet_loss: [rate], delay: [ms, jitter], corrupt: [rate], bpf: [expression]"
},
},
"required": ["project_id", "nodes"],
},
"handler": create_link_handler,
},
{
"name": "delete_link",
"description": "Delete a link from a project",
"parameters": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project UUID"},
"link_id": {"type": "string", "description": "Link UUID"},
},
"required": ["project_id", "link_id"],
},
"handler": delete_link_handler,
},
{
"name": "update_link",
"description": "Update a link's properties (suspend, filters, etc.)",
"parameters": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project UUID"},
"link_id": {"type": "string", "description": "Link UUID"},
"suspend": {"type": "boolean", "description": "Suspend the link (optional)"},
"filters": {
"type": "object",
"description": "Packet filters (optional). Must use array format: frequency_drop: [N], packet_loss: [rate], delay: [ms, jitter], corrupt: [rate], bpf: [expression]. Example: {\"frequency_drop\": [10], \"packet_loss\": [5]}"
},
},
"required": ["project_id", "link_id"],
},
"handler": update_link_handler,
},
{
"name": "reset_link",
"description": "Reset a link, clearing its state (counters, filters, etc.)",
"parameters": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project UUID"},
"link_id": {"type": "string", "description": "Link UUID"},
},
"required": ["project_id", "link_id"],
},
"handler": reset_link_handler,
},
{
"name": "start_capture",
"description": "Start packet capture on a link. The capture file can later be downloaded with download_capture_file.",
"parameters": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project UUID"},
"link_id": {"type": "string", "description": "Link UUID"},
"data_link_type": {"type": "string", "description": "Data link type (optional, default: DLT_EN10MB)"},
"capture_file_name": {"type": "string", "description": "Capture file name (optional)"},
"wireshark": {"type": "boolean", "description": "Open Wireshark automatically (optional, default: false)"},
},
"required": ["project_id", "link_id"],
},
"handler": start_capture_handler,
},
{
"name": "stop_capture",
"description": "Stop packet capture on a link. After stopping, the capture file can be downloaded.",
"parameters": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project UUID"},
"link_id": {"type": "string", "description": "Link UUID"},
},
"required": ["project_id", "link_id"],
},
"handler": stop_capture_handler,
},
{
"name": "download_capture_file",
"description": "Get the download URL and instructions for a PCAP capture file from a link. "
"Use the returned curl command to download the file. "
"The PCAP file can be analyzed with Wireshark or tcpdump.",
"parameters": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project UUID"},
"link_id": {"type": "string", "description": "Link UUID"},
},
"required": ["project_id", "link_id"],
},
"handler": download_capture_file_handler,
},
]

View File

@ -1,678 +0,0 @@
#
# Copyright (C) 2026 GNS3 Technologies Inc.
# Author: Yue Guobin
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
MCP tool handlers for GNS3 node management.
Handlers receive (params, gns3_ctx) and call GNS3's REST API
via Gns3Connector (from custom_gns3fy).
"""
from typing import Any
from concurrent.futures import ThreadPoolExecutor, as_completed
import hashlib
import logging
from gns3server.services import auth_service
log = logging.getLogger(__name__)
BATCH_MAX_WORKERS = 100
# ── Constants ──────────────────────────────────────────────────────────────
# Maximum bytes to return from get_node_file (safety net).
# Larger files are truncated with a truncated=True flag.
MAX_NODE_FILE_BYTES = 50 * 1024 # 50 KiB
# ── Helper ─────────────────────────────────────────────────────────────────
def _get_connector(gns3_ctx: dict[str, Any]):
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Gns3Connector
return Gns3Connector(
url=gns3_ctx["server_url"],
jwt_token=gns3_ctx["jwt_token"],
api_version=3,
verify=False,
)
# ── Tool handlers ──────────────────────────────────────────────────────────
def get_nodes_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)
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)
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 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}
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 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}
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 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}
def _filter_node_response(node: dict, fields: list[str] = None) -> dict:
"""Filter node response to only include requested fields."""
if not fields:
fields = ["node_id", "name", "node_type", "status", "console"]
return {k: node[k] for k in fields if k in node}
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"}
fields = params.get("fields")
if fields is not None and not isinstance(fields, list):
return {"error": "fields must be a list, e.g. [\"node_id\", \"name\"]"}
nodes = params.get("nodes")
# Batch mode: nodes=[{template_id?, x, y, name?, compute_id?}]
# When top-level template_id is set, it applies to all nodes as a default
if nodes is not None:
if not isinstance(nodes, list) or not nodes:
return {"error": "nodes must be a non-empty array"}
default_tid = params.get("template_id")
results = []
conn = _get_connector(gns3_ctx)
def _create_one(node_data):
tid = node_data.get("template_id", default_tid)
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"),
}
node_name = node_data.get("name")
if node_name:
body["name"] = node_name
resp = conn.http_call("post", url, json_data=body).json()
return {"template_id": tid, "status": "success", "node": _filter_node_response(resp, fields)}
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 template_id:
return {"error": "template_id is required"}
conn = _get_connector(gns3_ctx)
data = {
"x": params.get("x", 0),
"y": params.get("y", 0),
"compute_id": params.get("compute_id", "local"),
}
node_name = params.get("name")
if node_name:
data["name"] = node_name
url = f"{conn.base_url}/projects/{project_id}/templates/{template_id}"
resp = conn.http_call("post", url, json_data=data).json()
return _filter_node_response(resp, fields)
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 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}
def update_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)
# Extract update parameters - handle nested kwargs structure from MCP clients
if "kwargs" in params and isinstance(params["kwargs"], dict):
update_data = params["kwargs"]
else:
update_data = {k: v for k, v in params.items() if k not in ("project_id", "node_id", "kwargs")}
url = f"{conn.base_url}/projects/{project_id}/nodes/{node_id}"
return conn.http_call("put", url, json_data=update_data).json()
def get_node_console_info_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)
node = conn.http_call("get", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}").json()
console_type = node.get("console_type", "unknown")
# Short-lived JWT for the WebSocket URL (10 min)
username = gns3_ctx.get("jwt_username")
ws_token = auth_service.create_access_token(username, token_version=gns3_ctx.get("jwt_token_version", 0), 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,
"node_name": node.get("name"),
"console_type": console_type,
"ws_url": ws_url,
"command": f"websocat -t --no-close {ws_url}",
}
if ws_token:
# Fingerprint of the minted token: compare it against what actually reached the
# server (logged on WebSocket auth rejection) to detect copy corruption, and
# re-request the URL once token_ttl_seconds has elapsed.
result["token_sha256_prefix"] = hashlib.sha256(ws_token.encode()).hexdigest()[:8]
result["token_ttl_seconds"] = 600
if console_type in ("vnc",):
result["vnc_url"] = f"/v3/projects/{project_id}/nodes/{node_id}/console/vnc?token={gns3_ctx['jwt_token']}"
return result
# ── Node file handlers ────────────────────────────────────────────────────
def list_node_files_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)
url = f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/files"
query = {}
if params.get("path"):
query["path"] = params["path"]
if params.get("recursive"):
query["recursive"] = "true"
files = conn.http_call("get", url, params=query if query else None).json()
return {"files": files, "count": len(files)}
def get_node_file_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")
file_path = params.get("file_path")
if not project_id or not node_id or not file_path:
return {"error": "project_id, node_id and file_path are required"}
offset = params.get("offset", 0)
limit = params.get("limit", 200)
conn = _get_connector(gns3_ctx)
url = f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/files/{file_path}"
raw = conn.http_call("get", url).text
total_bytes = len(raw.encode("utf-8"))
truncated = False
if total_bytes > MAX_NODE_FILE_BYTES:
raw = raw[:MAX_NODE_FILE_BYTES]
truncated = True
lines = raw.splitlines(keepends=False)
total_lines = len(lines)
# Apply offset/limit
selected = lines[offset: offset + limit] if offset < total_lines else []
has_more = (offset + limit) < total_lines or truncated
return {
"file_path": file_path,
"content": "\n".join(selected),
"metadata": {
"total_lines": total_lines,
"total_bytes": total_bytes,
"offset": offset,
"limit": limit,
"returned_lines": len(selected),
"returned_bytes": len("\n".join(selected).encode("utf-8")),
"truncated": truncated or has_more,
"has_more": has_more,
},
}
def write_node_file_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")
file_path = params.get("file_path")
content = params.get("content")
if not project_id or not node_id or not file_path or content is None:
return {"error": "project_id, node_id, file_path and content are required"}
conn = _get_connector(gns3_ctx)
url = f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/files/{file_path}"
conn.http_call("post", url, data=content, headers={"Content-Type": "text/plain"})
return {"message": f"File {file_path} written to node {node_id}", "file_path": file_path, "node_id": node_id}
def delete_node_file_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")
file_path = params.get("file_path")
if not project_id or not node_id or not file_path:
return {"error": "project_id, node_id and file_path are required"}
conn = _get_connector(gns3_ctx)
url = f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/files/{file_path}"
conn.http_call("delete", url)
return {"message": f"File {file_path} deleted from node {node_id}", "file_path": file_path, "node_id": node_id}
# ── Node bulk / advanced handlers ────────────────────────────────────
def start_all_nodes_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)
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/start")
return {"message": "All nodes started", "project_id": project_id}
def stop_all_nodes_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)
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/stop")
return {"message": "All nodes stopped", "project_id": project_id}
def suspend_all_nodes_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)
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/suspend")
return {"message": "All nodes suspended", "project_id": project_id}
def duplicate_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)
data = {k: v for k, v in params.items() if k not in ("project_id", "node_id") and v is not None}
result = conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/duplicate", json_data=data).json()
return {"message": f"Node {node_id} duplicated", "node": result}
def isolate_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)
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/isolate")
return {"message": f"Node {node_id} isolated (all links suspended)", "node_id": node_id}
def unisolate_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)
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/unisolate")
return {"message": f"Node {node_id} unisolated (links resumed)", "node_id": node_id}
def get_node_links_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)
links = conn.http_call("get", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/links").json()
return {"links": links, "count": len(links)}
# ── Tool definitions ───────────────────────────────────────────────────────
NODE_TOOLS = [
{
"name": "get_nodes",
"description": "List all nodes in a project",
"parameters": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project UUID"},
},
"required": ["project_id"],
},
"handler": get_nodes_handler,
},
{
"name": "get_node",
"description": "Get detailed information about a specific node",
"parameters": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project UUID"},
"node_id": {"type": "string", "description": "Node UUID"},
},
"required": ["project_id", "node_id"],
},
"handler": get_node_handler,
},
{
"name": "start_node",
"description": "Start a node in a project",
"parameters": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project UUID"},
"node_id": {"type": "string", "description": "Node UUID"},
},
"required": ["project_id", "node_id"],
},
"handler": start_node_handler,
},
{
"name": "stop_node",
"description": "Stop a node in a project",
"parameters": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project UUID"},
"node_id": {"type": "string", "description": "Node UUID"},
},
"required": ["project_id", "node_id"],
},
"handler": stop_node_handler,
},
{
"name": "suspend_node",
"description": "Suspend a node in a project",
"parameters": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project UUID"},
"node_id": {"type": "string", "description": "Node UUID"},
},
"required": ["project_id", "node_id"],
},
"handler": suspend_node_handler,
},
{
"name": "create_node",
"description": "Create a new node from a template in a project",
"parameters": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project UUID"},
"template_id": {"type": "string", "description": "Template UUID"},
"x": {"type": "integer", "description": "X coordinate (optional)"},
"y": {"type": "integer", "description": "Y coordinate (optional)"},
"compute_id": {"type": "string", "description": "Compute ID (optional, default: local)"},
},
"required": ["project_id", "template_id"],
},
"handler": create_node_handler,
},
{
"name": "delete_node",
"description": "Delete a node from a project",
"parameters": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project UUID"},
"node_id": {"type": "string", "description": "Node UUID"},
},
"required": ["project_id", "node_id"],
},
"handler": delete_node_handler,
},
{
"name": "update_node",
"description": "Update a node's properties (name, position, etc.)",
"parameters": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project UUID"},
"node_id": {"type": "string", "description": "Node UUID"},
"name": {"type": "string", "description": "New node name (optional)"},
"x": {"type": "integer", "description": "New X position (optional)"},
"y": {"type": "integer", "description": "New Y position (optional)"},
"compute_id": {"type": "string", "description": "Compute ID (optional)"},
},
"required": ["project_id", "node_id"],
},
"handler": update_node_handler,
},
{
"name": "get_node_console_info",
"description": "Get console WebSocket URL for a node (use websocat to connect)",
"parameters": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project UUID"},
"node_id": {"type": "string", "description": "Node UUID"},
},
"required": ["project_id", "node_id"],
},
"handler": get_node_console_info_handler,
},
{
"name": "list_node_files",
"description": "List files in a node directory with metadata (name, size, type, modified time). "
"Use recursive=true for a full recursive listing. "
"Check file sizes before reading large files with get_node_file.",
"parameters": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project UUID"},
"node_id": {"type": "string", "description": "Node UUID"},
"path": {"type": "string", "description": "Subdirectory path within node directory (optional)"},
"recursive": {"type": "boolean", "description": "Recursively list all files (optional, default: false)"},
},
"required": ["project_id", "node_id"],
},
"handler": list_node_files_handler,
},
{
"name": "get_node_file",
"description": "Read a text file from a node directory. Returns file content line-by-line with offset/limit support. "
"Best practice: start with offset=0, limit=200 to preview, then increase offset to read more. "
"Large files (>50KB) are auto-truncated; check the metadata.truncated flag. "
"For binary files, check file type via list_node_files first.",
"parameters": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project UUID"},
"node_id": {"type": "string", "description": "Node UUID"},
"file_path": {"type": "string", "description": "Path to the file within the node directory"},
"offset": {"type": "integer", "description": "Line offset to start reading from (optional, default: 0)"},
"limit": {"type": "integer", "description": "Maximum number of lines to return (optional, default: 200)"},
},
"required": ["project_id", "node_id", "file_path"],
},
"handler": get_node_file_handler,
},
{
"name": "write_node_file",
"description": "Write content to a file in a node directory. Creates the file if it doesn't exist. "
"Overwrites existing content. Useful for updating configuration files on nodes.",
"parameters": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project UUID"},
"node_id": {"type": "string", "description": "Node UUID"},
"file_path": {"type": "string", "description": "Path to the file within the node directory"},
"content": {"type": "string", "description": "Content to write to the file"},
},
"required": ["project_id", "node_id", "file_path", "content"],
},
"handler": write_node_file_handler,
},
{
"name": "delete_node_file",
"description": "Delete a file from a node directory. Cannot be undone. "
"Use list_node_files to confirm the file path before deleting.",
"parameters": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project UUID"},
"node_id": {"type": "string", "description": "Node UUID"},
"file_path": {"type": "string", "description": "Path to the file within the node directory"},
},
"required": ["project_id", "node_id", "file_path"],
},
"handler": delete_node_file_handler,
},
]

View File

@ -19,7 +19,7 @@
MCP tools for GNS3 project management.
Tool handlers receive (params, gns3_ctx) and call GNS3's REST API
via Gns3Connector (from custom_gns3fy).
via Gns3Connector (from gns3_copilot.gns3_client.connector).
"""
from typing import Any
@ -33,7 +33,7 @@ log = logging.getLogger(__name__)
def _get_connector(gns3_ctx: dict[str, Any]):
"""Create a Gns3Connector from the GNS3 context dict."""
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Gns3Connector
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
return Gns3Connector(
url=gns3_ctx["server_url"],
jwt_token=gns3_ctx["jwt_token"],
@ -176,15 +176,6 @@ def unlock_project_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) ->
return {"message": f"Project {project_id} unlocked", "project_id": project_id}
def load_project_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
path = params.get("path")
if not path:
return {"error": "path is required"}
conn = _get_connector(gns3_ctx)
result = conn.http_call("post", f"{conn.base_url}/projects/load", json_data={"path": path}).json()
return {"message": f"Project loaded from {path}", "project": result}
def get_locked_project_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id")
if not project_id:

View File

@ -29,7 +29,7 @@ log = logging.getLogger(__name__)
# ── Helper ─────────────────────────────────────────────────────────────────
def _get_connector(gns3_ctx: dict[str, Any]):
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Gns3Connector
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
return Gns3Connector(
url=gns3_ctx["server_url"],
jwt_token=gns3_ctx["jwt_token"],

View File

@ -29,7 +29,7 @@ log = logging.getLogger(__name__)
# ── Helper ─────────────────────────────────────────────────────────────────
def _get_connector(gns3_ctx: dict[str, Any]):
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Gns3Connector
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
return Gns3Connector(
url=gns3_ctx["server_url"],
jwt_token=gns3_ctx["jwt_token"],

View File

@ -31,7 +31,7 @@ log = logging.getLogger(__name__)
# ── Helper ─────────────────────────────────────────────────────────────────
def _get_connector(gns3_ctx: dict[str, Any]):
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Gns3Connector
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
return Gns3Connector(
url=gns3_ctx["server_url"],
jwt_token=gns3_ctx["jwt_token"],

View File

@ -19,7 +19,7 @@
MCP tool handlers for GNS3 template management.
Handlers receive (params, gns3_ctx) and call GNS3's REST API
via Gns3Connector (from custom_gns3fy).
via Gns3Connector (from gns3_copilot.gns3_client.connector).
"""
from typing import Any
@ -32,7 +32,7 @@ log = logging.getLogger(__name__)
# ── Helper ─────────────────────────────────────────────────────────────────
def _get_connector(gns3_ctx: dict[str, Any]):
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Gns3Connector
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
return Gns3Connector(
url=gns3_ctx["server_url"],
jwt_token=gns3_ctx["jwt_token"],

View File

@ -122,7 +122,8 @@ def add_appliance_version(appliance_id: UUID, appliance_version: Union[schemas.A
@router.post(
"/{appliance_id}/install",
status_code=status.HTTP_204_NO_CONTENT,
response_model=schemas.Template,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(has_privilege("Appliance.Allocate"))]
)
async def install_appliance(
@ -132,15 +133,15 @@ async def install_appliance(
templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)),
current_user: schemas.User = Depends(get_current_active_user),
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
) -> None:
) -> schemas.Template:
"""
Install an appliance.
Install an appliance and return the created template.
Required privilege: Appliance.Allocate
"""
controller = Controller.instance()
await controller.appliance_manager.install_appliance(
return await controller.appliance_manager.install_appliance(
appliance_id,
version,
images_repo,

View File

@ -206,19 +206,24 @@ async def prune_images(
@router.post(
"/install",
status_code=status.HTTP_204_NO_CONTENT,
status_code=status.HTTP_200_OK,
dependencies=[Depends(has_privilege("Image.Allocate"))]
)
async def install_images(
images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository))
) -> None:
) -> dict:
"""
Attempt to automatically create templates based on image checksums.
Returns the list of created templates and the list of skipped
candidates (with the reason why they were skipped).
Required privilege: Image.Allocate
"""
created = []
skipped = []
skip_images = get_builtin_disks()
images = await images_repo.get_images()
for image in images:
@ -229,8 +234,12 @@ async def install_images(
if templates:
# the image is already used by a template
log.warning(f"Image '{image.path}' is used by one or more templates")
skipped.append({
"name": image.filename,
"reason": "image is already used by one or more templates",
})
continue
await Controller.instance().appliance_manager.install_appliances_from_image(
results = await Controller.instance().appliance_manager.install_appliances_from_image(
image.path,
image.checksum,
images_repo,
@ -239,6 +248,12 @@ async def install_images(
None,
os.path.dirname(image.path)
)
for result in results:
if result.get("status") == "created":
created.append({k: v for k, v in result.items() if k != "status"})
else:
skipped.append({k: v for k, v in result.items() if k != "status"})
return {"created": created, "skipped": skipped}
@router.get(

View File

@ -372,14 +372,13 @@ async def suspend_node(node: Node = Depends(dep_node)) -> None:
"""
Suspend a node.
Node types without suspend support return a 405 error instead of a
silent no-op, so the caller cannot mistake it for a suspended node.
Required privilege: Node.PowerMgmt
"""
try:
await node.suspend()
except HTTPException as e:
if not e.status_code == status.HTTP_405_METHOD_NOT_ALLOWED:
raise
await node.suspend()
@router.post(

View File

@ -745,6 +745,17 @@ class Controller:
if not os.path.exists(path):
raise ControllerError(f"'{path}' does not exist on the controller")
# A .gns3 file must live in its own directory: the project path is
# the file's parent directory. A file placed directly in the
# projects directory would register the shared projects root as the
# project directory, and deleting that project would wipe every
# project on the controller.
projects_path = os.path.realpath(self.projects_directory())
if os.path.realpath(os.path.dirname(path)) == projects_path:
raise ControllerError(
f"'{path}' cannot be loaded: the .gns3 file must be in its own subdirectory of '{projects_path}'"
)
topo_data = load_topology(path)
topo_data.pop("topology")
topo_data.pop("version")

View File

@ -204,9 +204,9 @@ class ApplianceManager:
else:
raise ControllerError(f"Could not find '{appliance_file}'")
async def _create_template(self, template_data, templates_repo, rbac_repo, current_user):
async def _create_template(self, template_data, templates_repo, rbac_repo, current_user) -> dict:
"""
Create a new template
Create a new template and return it as a dict.
"""
try:
@ -217,6 +217,7 @@ class ApplianceManager:
#template_id = template.get("template_id")
#await rbac_repo.add_permission_to_user_with_path(current_user.user_id, f"/templates/{template_id}/*")
log.info(f"Template '{template.get('name')}' has been created")
return template
async def _appliance_to_template(self, appliance: Appliance, version: str = None) -> dict:
"""
@ -244,11 +245,16 @@ class ApplianceManager:
rbac_repo: RbacRepository,
current_user: schemas.User,
image_dir: str
) -> None:
) -> List[dict]:
"""
Install appliances using an image checksum
Install appliances using an image checksum.
Returns a manifest of what happened: one entry per attempted template,
either {"status": "created", ...template fields} or
{"status": "skipped", "name", "reason"}.
"""
results: List[dict] = []
appliances_info = self._find_appliances_from_image_checksum(image_checksum)
for appliance, image_version in appliances_info:
try:
@ -256,15 +262,48 @@ class ApplianceManager:
ApplianceModel.model_validate(appliance.asdict())
except ValidationError as e:
log.warning(f"Could not validate appliance '{appliance.id}': {e}")
results.append({
"status": "skipped",
"name": appliance.name,
"reason": f"could not validate appliance '{appliance.id}': {e}",
})
continue
if appliance.versions:
for version in appliance.versions:
if version.get("name") == image_version:
try:
await self._find_appliance_version_images(appliance, version, images_repo, image_dir)
template_data = await self._appliance_to_template(appliance, version)
await self._create_template(template_data, templates_repo, rbac_repo, current_user)
name = template_data.get("name")
existing = await templates_repo.get_template_by_name(name) if name else None
if existing is not None:
# never automatically create a second template with the same
# name: the name+version check in TemplatesService would allow
# duplicates when the appliance version differs, but two
# templates sharing a name is never what the user asked for here
log.warning(f"Template '{name}' already exists, skipping automatic template creation")
results.append({
"status": "skipped",
"name": name,
"reason": f"a template named '{name}' already exists",
})
continue
template = await self._create_template(template_data, templates_repo, rbac_repo, current_user)
results.append({
"status": "created",
"template_id": str(template.get("template_id")),
"name": template.get("name"),
"version": template.get("version"),
"template_type": template.get("template_type"),
})
except (ControllerError, InvalidImageError) as e:
log.warning(f"Could not automatically create template using image '{image_path}': {e}")
results.append({
"status": "skipped",
"name": appliance.name,
"reason": str(e),
})
return results
async def install_appliance(
self,

View File

@ -471,6 +471,18 @@ class Project:
@path.setter
def path(self, path):
check_path_allowed(path)
# The projects directory itself (or one of its ancestors) must
# never become a project directory: deleting such a "project"
# would wipe every project on the controller.
real_path = os.path.realpath(path)
real_projects_path = os.path.realpath(get_default_project_directory())
if os.path.commonpath([real_path, real_projects_path]) == real_path:
raise ControllerForbiddenError(
f"The project directory cannot be '{path}': it must be a subdirectory "
f"of '{real_projects_path}', not the projects directory itself or one of its parents"
)
try:
os.makedirs(path, exist_ok=True)
except OSError as e:
@ -1608,11 +1620,19 @@ class Project:
await self._cleanup_web_wireshark_container()
try:
project_directory = get_default_project_directory()
if not os.path.commonprefix([project_directory, self.path]) == project_directory:
project_directory = os.path.realpath(get_default_project_directory())
path = os.path.realpath(self.path)
if os.path.commonpath([path, project_directory]) != project_directory:
raise ControllerError(
f"Project '{self._name}' cannot be deleted because it is not in the default project directory: '{project_directory}'"
)
if path == project_directory:
# A poisoned or hand-crafted entry whose path is the
# projects root itself must never be deletable: rmtree
# would wipe every project on the controller.
raise ControllerError(
f"Project '{self._name}' cannot be deleted because its directory is the projects directory itself: '{path}'"
)
shutil.rmtree(self.path)
except OSError as e:
raise ControllerError(f"Cannot delete project directory {self.path}: {str(e)}")
@ -2121,6 +2141,10 @@ class Project:
Check if all items in a project are locked and not
"""
if not self._drawings and not self._nodes:
# a project without drawings or nodes has nothing to lock and would
# otherwise always report as locked, even after unlocking it
return False
for drawing in self._drawings.values():
if not drawing.locked:
return False

View File

@ -71,6 +71,17 @@ class TemplatesRepository(BaseRepository):
result = await self._db_session.execute(query)
return result.scalars().first()
async def get_template_by_name(self, name: str) -> Union[None, models.Template]:
"""
Return the first template with this name, regardless of version.
"""
query = select(models.Template).\
options(selectinload(models.Template.images)).\
where(models.Template.name == name)
result = await self._db_session.execute(query)
return result.scalars().first()
async def get_templates(self) -> List[models.Template]:
query = select(models.Template).options(selectinload(models.Template.images))

View File

@ -54,8 +54,12 @@ class ComputesService:
self._controller.notification.controller_emit("compute.created", compute.asdict())
return db_compute
async def get_compute(self, compute_id: Union[str, UUID]) -> models.Compute:
async def get_compute(self, compute_id: Union[str, UUID]) -> Union[models.Compute, dict]:
if str(compute_id) == "local":
# the built-in local compute only lives in the controller, not in the database;
# drop unset fields (e.g. user) as the response schema types them as str
return {k: v for k, v in self._controller.get_compute("local").asdict().items() if v is not None}
db_compute = await self._computes_repo.get_compute(compute_id)
if not db_compute:
raise ControllerNotFoundError(f"Compute '{compute_id}' not found")

View File

@ -0,0 +1,139 @@
"""
Device config tool tests with mocked topology and Nornir layers.
Covers the VPCS node-type guard and the error contract shared by
device_config_send / device_show_run / vpcs_config_set.
"""
import json
import pytest
from unittest.mock import MagicMock, patch
VPCS_MOD = "gns3server.agent.gns3_copilot.tools_v2.vpcs_tools_netmiko"
def _topology_ports(node_type):
"""Mocked get_device_ports_from_topology return value for one device."""
return {"PC1": {"port": 5000, "node_type": node_type}}
class TestVPCSNodeTypeGuard:
def test_non_vpcs_node_is_rejected(self):
from gns3server.agent.gns3_copilot.tools_v2.vpcs_tools_netmiko import VPCSCommands
with patch(f"{VPCS_MOD}.get_device_ports_from_topology",
return_value=_topology_ports("iou")) as topo:
result = VPCSCommands()._run(json.dumps({
"project_id": "0c0fde25-6ead-4413-a283-ea8fd2324291",
"device_configs": [{"device_name": "PC1", "commands": ["ip 10.0.0.1/24"]}],
}))
assert topo.called
assert len(result) == 1
assert result[0]["device_name"] == "PC1"
assert result[0]["status"] == "failed"
assert "not a VPCS node" in result[0]["error"]
def test_missing_node_type_is_rejected(self):
from gns3server.agent.gns3_copilot.tools_v2.vpcs_tools_netmiko import VPCSCommands
with patch(f"{VPCS_MOD}.get_device_ports_from_topology",
return_value={"PC1": {"port": 5000}}):
result = VPCSCommands()._run(json.dumps({
"project_id": "0c0fde25-6ead-4413-a283-ea8fd2324291",
"device_configs": [{"device_name": "PC1", "commands": ["ip 10.0.0.1/24"]}],
}))
assert result[0]["status"] == "failed"
assert "unknown-type" in result[0]["error"]
def test_vpcs_node_passes_the_guard(self):
from gns3server.agent.gns3_copilot.tools_v2.vpcs_tools_netmiko import VPCSCommands
tool = VPCSCommands()
nornir = MagicMock()
host_result = MagicMock(failed=False)
host_result.result = "OK"
nornir.run.return_value = {"PC1": host_result}
with patch(f"{VPCS_MOD}.get_device_ports_from_topology",
return_value=_topology_ports("vpcs")), \
patch.object(VPCSCommands, "_initialize_nornir", return_value=nornir):
result = tool._run(json.dumps({
"project_id": "0c0fde25-6ead-4413-a283-ea8fd2324291",
"device_configs": [{"device_name": "PC1", "commands": ["ip 10.0.0.1/24"]}],
}))
assert result[0]["status"] == "success"
assert result[0]["output"] == "OK"
def test_execution_failure_reports_failed_with_error(self):
from gns3server.agent.gns3_copilot.tools_v2.vpcs_tools_netmiko import VPCSCommands
tool = VPCSCommands()
nornir = MagicMock()
host_result = MagicMock(failed=True)
host_result.result = "Command failed (ReadTimeout)"
nornir.run.return_value = {"PC1": host_result}
with patch(f"{VPCS_MOD}.get_device_ports_from_topology",
return_value=_topology_ports("vpcs")), \
patch.object(VPCSCommands, "_initialize_nornir", return_value=nornir):
result = tool._run(json.dumps({
"project_id": "0c0fde25-6ead-4413-a283-ea8fd2324291",
"device_configs": [{"device_name": "PC1", "commands": ["ip 10.0.0.1/24"]}],
}))
assert result[0]["status"] == "failed"
assert result[0]["error"] == "Command failed (ReadTimeout)"
assert "output" not in result[0]
class TestDeviceToolErrorContract:
"""
Every in-band error entry carries status "failed" plus an "error"
message, whether it is topology-level (no device) or per-device.
"""
def test_topology_level_error_has_status(self):
from gns3server.agent.gns3_copilot.tools_v2.vpcs_tools_netmiko import VPCSCommands
with patch(f"{VPCS_MOD}.get_device_ports_from_topology",
side_effect=ValueError("topology unreachable")):
result = VPCSCommands()._run(json.dumps({
"project_id": "0c0fde25-6ead-4413-a283-ea8fd2324291",
"device_configs": [{"device_name": "PC1", "commands": ["ip 10.0.0.1/24"]}],
}))
assert result == [{"status": "failed", "error": "topology unreachable"}]
def test_config_tool_topology_level_error_has_status(self):
from gns3server.agent.gns3_copilot.tools_v2.config_tools_nornir import (
ExecuteMultipleDeviceConfigCommands,
)
with patch("gns3server.agent.gns3_copilot.tools_v2.config_tools_nornir"
".get_device_ports_from_topology",
side_effect=ValueError("no valid devices")):
result = ExecuteMultipleDeviceConfigCommands()._run(json.dumps({
"project_id": "0c0fde25-6ead-4413-a283-ea8fd2324291",
"device_configs": [{"device_name": "R1", "config_commands": ["int lo0"]}],
}))
assert result == [{"status": "failed", "error": "no valid devices"}]
def test_mcp_handler_param_error_has_status(self, ctx=None):
from gns3server.agent.mcp.device_config import (
device_config_send_handler,
device_show_run_handler,
vpcs_config_set_handler,
)
for handler in (device_config_send_handler, device_show_run_handler, vpcs_config_set_handler):
result = handler({}, {"server_url": "http://x", "jwt_token": "t"})
assert result == [{
"status": "failed",
"error": result[0]["error"], # message text may differ per handler
}]
assert "required" in result[0]["error"]
def test_template_render_error_has_status(self):
from gns3server.agent.mcp.device_config import _render_template
result = _render_template("{{ unclosed", [{"device_name": "R1", "vars": {"n": 1}}])
assert len(result) == 1
assert result[0]["status"] == "failed"
assert "Template rendering failed" in result[0]["error"]

View File

@ -3,6 +3,8 @@ MCP handler unit tests with mocked Gns3Connector.
Tests that handlers correctly transform tool parameters into HTTP calls.
"""
import json
import pytest
from unittest.mock import MagicMock, patch
@ -12,10 +14,12 @@ def _mock_conn(json_result=None):
conn = MagicMock()
conn.base_url = "http://192.168.1.3:3080/v3"
conn.http_call.return_value.json.return_value = json_result or {"status": "ok"}
conn.http_call.return_value.content = b"{}" # non-empty body by default
return conn
BASE = "gns3server.agent.mcp"
AH = "gns3server.agent.gns3_copilot.gns3_client.api_handlers" # node/link handlers sunk here
@pytest.fixture
@ -110,11 +114,10 @@ class TestProject:
class TestNode:
mod = "nodes"
def test_list_fields(self, ctx):
from gns3server.agent.mcp.nodes import get_nodes_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_nodes_handler
with patch(f"{AH}._get_connector") as m:
m.return_value = _mock_conn([
{"node_id": "n1", "name": "R1", "status": "started", "node_type": "qemu", "console": 5000},
])
@ -122,22 +125,22 @@ class TestNode:
assert result == {"nodes": [{"name": "R1", "status": "started"}], "count": 1}
def test_list_invalid_fields(self, ctx):
from gns3server.agent.mcp.nodes import get_nodes_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_nodes_handler
with patch(f"{AH}._get_connector") as m:
m.return_value = _mock_conn([])
result = get_nodes_handler({"project_id": "p1", "fields": "not-a-list"}, ctx)
assert "error" in result
def test_get(self, ctx):
from gns3server.agent.mcp.nodes import get_node_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_node_handler
with patch(f"{AH}._get_connector") as m:
m.return_value = _mock_conn({"node_id": "n1", "name": "R1"})
result = get_node_handler({"project_id": "p1", "node_id": "n1"}, ctx)
assert result["name"] == "R1"
def test_create_single_passes_name(self, ctx):
from gns3server.agent.mcp.nodes import create_node_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import create_node_handler
with patch(f"{AH}._get_connector") as m:
conn = _mock_conn({"node_id": "n1", "name": "MyRouter"})
m.return_value = conn
result = create_node_handler({
@ -151,8 +154,8 @@ class TestNode:
assert result == {"node_id": "n1", "name": "MyRouter"}
def test_create_fields_filter(self, ctx):
from gns3server.agent.mcp.nodes import create_node_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import create_node_handler
with patch(f"{AH}._get_connector") as m:
m.return_value = _mock_conn({"node_id": "n1", "name": "R1", "status": "started"})
result = create_node_handler({
"project_id": "p1", "template_id": "t1",
@ -161,8 +164,8 @@ class TestNode:
assert result == {"node_id": "n1", "name": "R1"}
def test_create_fields_validation(self, ctx):
from gns3server.agent.mcp.nodes import create_node_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import create_node_handler
with patch(f"{AH}._get_connector") as m:
conn = _mock_conn()
m.return_value = conn
result = create_node_handler({
@ -173,8 +176,8 @@ class TestNode:
conn.http_call.assert_not_called()
def test_create_batch_inherits_template_id(self, ctx):
from gns3server.agent.mcp.nodes import create_node_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import create_node_handler
with patch(f"{AH}._get_connector") as m:
m.return_value = _mock_conn({"node_id": "n1", "name": "R1"})
result = create_node_handler({
"project_id": "p1", "template_id": "default-tpl",
@ -182,70 +185,173 @@ class TestNode:
}, ctx)
assert result[0]["status"] == "success"
def test_create_batch_preserves_submission_order(self, ctx):
import time
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import create_node_handler
with patch(f"{AH}._get_connector") as m:
conn = _mock_conn()
def _http_call(method, url, json_data=None, **kwargs):
# first submissions sleep longest so completion order is reversed
time.sleep({"slow": 0.25, "mid": 0.1}.get(json_data.get("name"), 0.0))
resp = MagicMock()
resp.json.return_value = {"node_id": "n1", "name": json_data["name"]}
return resp
conn.http_call.side_effect = _http_call
m.return_value = conn
result = create_node_handler({
"project_id": "p1", "template_id": "t1",
"nodes": [{"name": "slow"}, {"name": "mid"}, {"name": "fast"}],
}, ctx)
assert [r["node"]["name"] for r in result] == ["slow", "mid", "fast"]
def test_create_batch_default_names_created_sequentially(self, ctx):
import threading
import time
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import create_node_handler
def _run(nodes_param):
with patch(f"{AH}._get_connector") as m:
conn = _mock_conn()
lock = threading.Lock()
active = [0, 0] # in-flight requests, high-water mark
counter = [0]
def _http_call(method, url, json_data=None, **kwargs):
with lock:
active[0] += 1
active[1] = max(active[1], active[0])
counter[0] += 1
seq = counter[0]
time.sleep(0.05) # wide enough that parallel calls would overlap
with lock:
active[0] -= 1
resp = MagicMock()
resp.json.return_value = {"node_id": f"n{seq}", "name": json_data.get("name", f"R-{seq}")}
return resp
conn.http_call.side_effect = _http_call
m.return_value = conn
result = create_node_handler({"project_id": "p1", "template_id": "t1", "nodes": nodes_param}, ctx)
return result, active[1]
# nodes relying on default naming are created one at a time so the
# server assigns default names/console ports in submission order
result, max_active = _run([{}, {}, {}])
assert [r["node"]["name"] for r in result] == ["R-1", "R-2", "R-3"]
assert max_active == 1
# one nameless node is enough to serialize the whole batch
result, max_active = _run([{"name": "explicit"}, {}])
assert [r["node"]["name"] for r in result] == ["explicit", "R-2"]
assert max_active == 1
def test_create_missing_project_id(self, ctx):
from gns3server.agent.mcp.nodes import create_node_handler
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import create_node_handler
assert create_node_handler({}, ctx) == {"error": "project_id is required"}
def test_delete_batch(self, ctx):
from gns3server.agent.mcp.nodes import delete_node_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import delete_node_handler
with patch(f"{AH}._get_connector") as m:
m.return_value = _mock_conn({})
result = delete_node_handler({"project_id": "p1", "node_ids": ["n1", "n2"]}, ctx)
assert len(result) == 2
# same status vocabulary as create/start/stop batches
assert all(r["status"] == "success" for r in result)
def test_start_batch(self, ctx):
from gns3server.agent.mcp.nodes import start_node_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import start_node_handler
with patch(f"{AH}._get_connector") as m:
m.return_value = _mock_conn({"status": "started"})
result = start_node_handler({"project_id": "p1", "node_ids": ["n1"]}, ctx)
assert result[0]["status"] == "success"
def test_stop_batch(self, ctx):
from gns3server.agent.mcp.nodes import stop_node_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import stop_node_handler
with patch(f"{AH}._get_connector") as m:
m.return_value = _mock_conn({"status": "stopped"})
result = stop_node_handler({"project_id": "p1", "node_ids": ["n1"]}, ctx)
assert result[0]["status"] == "success"
def test_suspend_batch(self, ctx):
from gns3server.agent.mcp.nodes import suspend_node_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import suspend_node_handler
with patch(f"{AH}._get_connector") as m:
m.return_value = _mock_conn({"status": "suspended"})
result = suspend_node_handler({"project_id": "p1", "node_ids": ["n1"]}, ctx)
assert result[0]["status"] == "success"
def test_console(self, ctx):
from gns3server.agent.mcp.nodes import get_node_console_info_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_node_console_info_handler
with patch(f"{AH}._get_connector") as m:
m.return_value = _mock_conn({"console_url": "ws://host/console"})
result = get_node_console_info_handler({"project_id": "p1", "node_id": "n1"}, ctx)
assert "command" in result
@staticmethod
def _file_conn(text):
conn = _mock_conn()
conn.http_call.return_value.text = text
return conn
def test_file_get_keeps_trailing_newline(self, ctx):
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_node_file_handler
with patch(f"{AH}._get_connector") as m:
m.return_value = self._file_conn("line1\nline2\n")
result = get_node_file_handler({"project_id": "p1", "node_id": "n1", "file_path": "startup.cfg"}, ctx)
assert result["content"] == "line1\nline2\n"
assert result["metadata"]["total_bytes"] == 12
assert result["metadata"]["returned_bytes"] == 12
assert result["metadata"]["has_more"] is False
def test_file_get_keeps_crlf_endings(self, ctx):
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_node_file_handler
with patch(f"{AH}._get_connector") as m:
m.return_value = self._file_conn("line1\r\nline2\r\n")
result = get_node_file_handler({"project_id": "p1", "node_id": "n1", "file_path": "startup.cfg"}, ctx)
assert result["content"] == "line1\r\nline2\r\n"
assert result["metadata"]["returned_bytes"] == 14
def test_file_get_without_trailing_newline(self, ctx):
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_node_file_handler
with patch(f"{AH}._get_connector") as m:
m.return_value = self._file_conn("line1\nline2")
result = get_node_file_handler({"project_id": "p1", "node_id": "n1", "file_path": "startup.cfg"}, ctx)
assert result["content"] == "line1\nline2"
def test_file_get_pagination(self, ctx):
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_node_file_handler
with patch(f"{AH}._get_connector") as m:
m.return_value = self._file_conn("line1\nline2\nline3\n")
result = get_node_file_handler(
{"project_id": "p1", "node_id": "n1", "file_path": "startup.cfg", "offset": 1, "limit": 1}, ctx
)
assert result["content"] == "line2\n"
assert result["metadata"]["total_lines"] == 3
assert result["metadata"]["returned_lines"] == 1
assert result["metadata"]["has_more"] is True
# ── Link ────────────────────────────────────────────────────────────────
class TestLink:
mod = "links"
def test_list(self, ctx):
from gns3server.agent.mcp.links import get_links_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_links_handler
with patch(f"{AH}._get_connector") as m:
m.return_value = _mock_conn([{"link_id": "l1", "link_type": "ethernet"}])
result = get_links_handler({"project_id": "p1", "fields": ["link_id"]}, ctx)
assert result["links"] == [{"link_id": "l1"}]
def test_get(self, ctx):
from gns3server.agent.mcp.links import get_link_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_link_handler
with patch(f"{AH}._get_connector") as m:
m.return_value = _mock_conn({"link_id": "l1", "link_type": "ethernet"})
result = get_link_handler({"project_id": "p1", "link_id": "l1"}, ctx)
assert result["link_id"] == "l1"
def test_create_compact_format(self, ctx):
from gns3server.agent.mcp.links import create_link_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import create_link_handler
with patch(f"{AH}._get_connector") as m:
conn = _mock_conn({"link_id": "l1", "link_type": "ethernet", "nodes": []})
m.return_value = conn
result = create_link_handler({
@ -261,8 +367,8 @@ class TestLink:
)
def test_create_standard_format(self, ctx):
from gns3server.agent.mcp.links import create_link_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import create_link_handler
with patch(f"{AH}._get_connector") as m:
m.return_value = _mock_conn({"link_id": "l1"})
result = create_link_handler({
"project_id": "p1",
@ -274,8 +380,8 @@ class TestLink:
assert result["link_id"] == "l1"
def test_create_fields_validation(self, ctx):
from gns3server.agent.mcp.links import create_link_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import create_link_handler
with patch(f"{AH}._get_connector") as m:
conn = _mock_conn()
m.return_value = conn
result = create_link_handler({
@ -287,15 +393,41 @@ class TestLink:
conn.http_call.assert_not_called()
def test_delete_batch(self, ctx):
from gns3server.agent.mcp.links import delete_link_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import delete_link_handler
with patch(f"{AH}._get_connector") as m:
m.return_value = _mock_conn({})
result = delete_link_handler({"project_id": "p1", "link_ids": ["l1", "l2"]}, ctx)
assert len(result) == 2
# same status vocabulary as create batches
assert all(r["status"] == "success" for r in result)
def test_create_batch_preserves_submission_order(self, ctx):
import time
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import create_link_handler
with patch(f"{AH}._get_connector") as m:
conn = _mock_conn()
def _http_call(method, url, json_data=None, **kwargs):
# first submission sleeps longest so completion order is reversed
first_node = json_data["nodes"][0]["node_id"]
time.sleep(0.25 if first_node == "n1" else 0.0)
resp = MagicMock()
resp.json.return_value = {"link_id": f"link-{first_node}"}
return resp
conn.http_call.side_effect = _http_call
m.return_value = conn
result = create_link_handler({
"project_id": "p1",
"links": [
{"nodes": ["n1", 0, 0, "n2", 0, 0]},
{"nodes": ["n3", 0, 0, "n4", 0, 0]},
],
"fields": ["link_id"],
}, ctx)
assert [r["link"]["link_id"] for r in result] == ["link-n1", "link-n3"]
def test_update(self, ctx):
from gns3server.agent.mcp.links import update_link_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import update_link_handler
with patch(f"{AH}._get_connector") as m:
m.return_value = _mock_conn({"link_id": "l1", "suspend": True})
result = update_link_handler({
"project_id": "p1", "link_id": "l1", "suspend": True,
@ -320,7 +452,7 @@ class TestAppliance:
def test_install_with_version(self, ctx):
from gns3server.agent.mcp.appliances import install_appliance_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
conn = _mock_conn({"status": "installed"})
conn = _mock_conn({"template_id": "t1", "name": "FRR", "version": "8.2.2", "template_type": "docker"})
m.return_value = conn
result = install_appliance_handler({
"appliance_id": "a1", "version": "2.7.0.356",
@ -329,6 +461,22 @@ class TestAppliance:
"post", "http://192.168.1.3:3080/v3/appliances/a1/install",
params={"version": "2.7.0.356"},
)
assert result["template"] == {
"template_id": "t1", "name": "FRR", "version": "8.2.2", "template_type": "docker",
}
def test_install_empty_body(self, ctx):
# a 204-style empty response must not blow up with a JSON decode error
# (the template is still created server-side)
from gns3server.agent.mcp.appliances import install_appliance_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
conn = _mock_conn()
conn.http_call.return_value.content = b""
conn.http_call.return_value.json.side_effect = json.JSONDecodeError("Expecting value", "", 0)
m.return_value = conn
result = install_appliance_handler({"appliance_id": "a1"}, ctx)
assert "template" not in result
assert result["message"] == "Appliance a1 installed"
def test_install_missing_id(self, ctx):
from gns3server.agent.mcp.appliances import install_appliance_handler
@ -375,17 +523,84 @@ class TestTemplate:
assert "deleted" in str(result).lower()
# ── Image ───────────────────────────────────────────────────────────────
class TestImage:
mod = "images"
def test_install_manifest(self, ctx):
from gns3server.agent.mcp.images import install_images_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
conn = _mock_conn({
"created": [{"template_id": "t1", "name": "Empty VM", "version": "100G", "template_type": "qemu"}],
"skipped": [{"name": "csr1000v.qcow2", "reason": "image is already used by one or more templates"}],
})
m.return_value = conn
result = install_images_handler({}, ctx)
assert result["created"][0]["name"] == "Empty VM"
assert result["skipped"][0]["name"] == "csr1000v.qcow2"
def test_install_empty_body(self, ctx):
from gns3server.agent.mcp.images import install_images_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
conn = _mock_conn()
conn.http_call.return_value.content = b""
conn.http_call.return_value.json.side_effect = json.JSONDecodeError("Expecting value", "", 0)
m.return_value = conn
result = install_images_handler({}, ctx)
assert result == {"message": "Image installation completed"}
class TestCompute:
mod = "computes"
def test_get_local_by_default(self, ctx):
from gns3server.agent.mcp.computes import get_compute_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
conn = _mock_conn({"compute_id": "local", "name": "local"})
m.return_value = conn
result = get_compute_handler({}, ctx)
assert result["compute_id"] == "local"
url = conn.http_call.call_args[0][1]
assert url.endswith("/computes/local")
def test_get_explicit_compute_id(self, ctx):
from gns3server.agent.mcp.computes import get_compute_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
conn = _mock_conn({"compute_id": "4fcfb6b5-5b0b-4f43-bd5e-e8ae2a69c8e6"})
m.return_value = conn
get_compute_handler({"compute_id": "4fcfb6b5-5b0b-4f43-bd5e-e8ae2a69c8e6"}, ctx)
url = conn.http_call.call_args[0][1]
assert url.endswith("/computes/4fcfb6b5-5b0b-4f43-bd5e-e8ae2a69c8e6")
def test_images_local_by_default(self, ctx):
from gns3server.agent.mcp.computes import get_compute_images_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
conn = _mock_conn(["img1.qcow2"])
m.return_value = conn
result = get_compute_images_handler({"emulator": "qemu"}, ctx)
assert result["count"] == 1
url = conn.http_call.call_args[0][1]
assert url.endswith("/computes/local/qemu/images")
def test_images_requires_emulator(self, ctx):
from gns3server.agent.mcp.computes import get_compute_images_handler
assert "error" in get_compute_images_handler({}, ctx)
# ── Marker (traffic-insight) ────────────────────────────────────────────
class TestLinkMarker:
"""link_marker_handler direction tri-state: omit=preserve, tx/rx=set, both=clear (→ null)."""
mod = "links"
def test_update_direction_both_clears(self, ctx):
from gns3server.agent.mcp.links import link_marker_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import link_marker_handler
with patch(f"{AH}._get_connector") as m:
conn = _mock_conn({"name": "icmp"})
m.return_value = conn
link_marker_handler(
@ -398,8 +613,8 @@ class TestLinkMarker:
)
def test_update_direction_tx_sets(self, ctx):
from gns3server.agent.mcp.links import link_marker_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import link_marker_handler
with patch(f"{AH}._get_connector") as m:
conn = _mock_conn({"name": "icmp"})
m.return_value = conn
link_marker_handler(
@ -412,8 +627,8 @@ class TestLinkMarker:
)
def test_update_direction_omitted_preserved(self, ctx):
from gns3server.agent.mcp.links import link_marker_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import link_marker_handler
with patch(f"{AH}._get_connector") as m:
conn = _mock_conn({"name": "icmp"})
m.return_value = conn
link_marker_handler(
@ -426,8 +641,8 @@ class TestLinkMarker:
)
def test_create_direction_both_omitted(self, ctx):
from gns3server.agent.mcp.links import link_marker_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import link_marker_handler
with patch(f"{AH}._get_connector") as m:
conn = _mock_conn({"name": "icmp"})
m.return_value = conn
link_marker_handler(
@ -439,9 +654,36 @@ class TestLinkMarker:
json_data={"bpf": "icmp"},
)
def test_create_data_link_type_passthrough(self, ctx):
"""create passes a serial WAN encapsulation through; update ignores it."""
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import link_marker_handler
with patch(f"{AH}._get_connector") as m:
conn = _mock_conn({"name": "icmp"})
m.return_value = conn
link_marker_handler(
{"project_id": "p", "link_id": "l", "action": "create",
"bpf": "icmp", "data_link_type": "DLT_C_HDLC"}, ctx,
)
conn.http_call.assert_called_with(
"post", "http://192.168.1.3:3080/v3/projects/p/links/l/markers",
json_data={"bpf": "icmp", "data_link_type": "DLT_C_HDLC"},
)
conn = _mock_conn({"name": "icmp"})
m.return_value = conn
link_marker_handler(
{"project_id": "p", "link_id": "l", "action": "update",
"marker_name": "icmp", "tag": 1, "data_link_type": "DLT_PPP_SERIAL"}, ctx,
)
# create-only: dropped from the update body
conn.http_call.assert_called_with(
"put", "http://192.168.1.3:3080/v3/projects/p/links/l/markers/icmp",
json_data={"tag": 1},
)
def test_create_direction_tx(self, ctx):
from gns3server.agent.mcp.links import link_marker_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import link_marker_handler
with patch(f"{AH}._get_connector") as m:
conn = _mock_conn({"name": "icmp"})
m.return_value = conn
link_marker_handler(
@ -462,11 +704,10 @@ class TestMarkerDefinition:
meaning any direction passed is ignored, never reaching the request body.
"""
mod = "links"
def test_create_builds_body(self, ctx):
from gns3server.agent.mcp.links import marker_definition_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import marker_definition_handler
with patch(f"{AH}._get_connector") as m:
conn = _mock_conn({"name": "arp"})
m.return_value = conn
marker_definition_handler(
@ -479,8 +720,8 @@ class TestMarkerDefinition:
)
def test_create_ignores_direction(self, ctx):
from gns3server.agent.mcp.links import marker_definition_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import marker_definition_handler
with patch(f"{AH}._get_connector") as m:
conn = _mock_conn({"name": "arp"})
m.return_value = conn
marker_definition_handler(
@ -493,8 +734,8 @@ class TestMarkerDefinition:
)
def test_update_builds_body(self, ctx):
from gns3server.agent.mcp.links import marker_definition_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import marker_definition_handler
with patch(f"{AH}._get_connector") as m:
conn = _mock_conn({"name": "arp"})
m.return_value = conn
marker_definition_handler(
@ -507,8 +748,8 @@ class TestMarkerDefinition:
)
def test_update_ignores_direction(self, ctx):
from gns3server.agent.mcp.links import marker_definition_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import marker_definition_handler
with patch(f"{AH}._get_connector") as m:
conn = _mock_conn({"name": "arp"})
m.return_value = conn
marker_definition_handler(
@ -521,8 +762,8 @@ class TestMarkerDefinition:
)
def test_update_requires_a_field(self, ctx):
from gns3server.agent.mcp.links import marker_definition_handler
with patch(f"{BASE}.{self.mod}._get_connector"):
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import marker_definition_handler
with patch(f"{AH}._get_connector"):
result = marker_definition_handler(
{"project_id": "p", "action": "update", "def_name": "arp"}, ctx,
)

View File

@ -15,7 +15,10 @@ from pathlib import Path
import pytest
MCP_DIR = Path(__file__).resolve().parents[3] / "gns3server" / "agent" / "mcp"
REPO_ROOT = Path(__file__).resolve().parents[3]
MCP_DIR = REPO_ROOT / "gns3server" / "agent" / "mcp"
# Node/link handlers live in the shared REST client layer, not MCP_DIR.
API_HANDLERS_FILE = "gns3server/agent/gns3_copilot/gns3_client/api_handlers.py"
TOOL_FILE = MCP_DIR / "__init__.py"
HANDLER_FILES = {
@ -33,38 +36,38 @@ HANDLER_FILES = {
"lock_project_handler": "projects.py",
"unlock_project_handler": "projects.py",
"get_locked_project_handler": "projects.py",
"load_project_handler": "projects.py",
"get_nodes_handler": "nodes.py",
"get_node_handler": "nodes.py",
"start_node_handler": "nodes.py",
"stop_node_handler": "nodes.py",
"suspend_node_handler": "nodes.py",
"create_node_handler": "nodes.py",
"delete_node_handler": "nodes.py",
"update_node_handler": "nodes.py",
"get_node_console_info_handler": "nodes.py",
"list_node_files_handler": "nodes.py",
"get_node_file_handler": "nodes.py",
"write_node_file_handler": "nodes.py",
"delete_node_file_handler": "nodes.py",
"start_all_nodes_handler": "nodes.py",
"stop_all_nodes_handler": "nodes.py",
"suspend_all_nodes_handler": "nodes.py",
"duplicate_node_handler": "nodes.py",
"isolate_node_handler": "nodes.py",
"unisolate_node_handler": "nodes.py",
"get_node_links_handler": "nodes.py",
"get_links_handler": "links.py",
"get_link_handler": "links.py",
"create_link_handler": "links.py",
"delete_link_handler": "links.py",
"update_link_handler": "links.py",
"reset_link_handler": "links.py",
"start_capture_handler": "links.py",
"stop_capture_handler": "links.py",
"download_capture_file_handler": "links.py",
"link_marker_handler": "links.py",
"marker_definition_handler": "links.py",
"get_nodes_handler": API_HANDLERS_FILE,
"get_node_handler": API_HANDLERS_FILE,
"start_node_handler": API_HANDLERS_FILE,
"stop_node_handler": API_HANDLERS_FILE,
"suspend_node_handler": API_HANDLERS_FILE,
"create_node_handler": API_HANDLERS_FILE,
"delete_node_handler": API_HANDLERS_FILE,
"update_node_handler": API_HANDLERS_FILE,
"get_node_console_info_handler": API_HANDLERS_FILE,
"list_node_files_handler": API_HANDLERS_FILE,
"get_node_file_handler": API_HANDLERS_FILE,
"write_node_file_handler": API_HANDLERS_FILE,
"delete_node_file_handler": API_HANDLERS_FILE,
"start_all_nodes_handler": API_HANDLERS_FILE,
"stop_all_nodes_handler": API_HANDLERS_FILE,
"suspend_all_nodes_handler": API_HANDLERS_FILE,
"duplicate_node_handler": API_HANDLERS_FILE,
"isolate_node_handler": API_HANDLERS_FILE,
"unisolate_node_handler": API_HANDLERS_FILE,
"get_node_links_handler": API_HANDLERS_FILE,
"available_filters_handler": API_HANDLERS_FILE,
"get_links_handler": API_HANDLERS_FILE,
"get_link_handler": API_HANDLERS_FILE,
"create_link_handler": API_HANDLERS_FILE,
"delete_link_handler": API_HANDLERS_FILE,
"update_link_handler": API_HANDLERS_FILE,
"reset_link_handler": API_HANDLERS_FILE,
"start_capture_handler": API_HANDLERS_FILE,
"stop_capture_handler": API_HANDLERS_FILE,
"download_capture_file_handler": API_HANDLERS_FILE,
"link_marker_handler": API_HANDLERS_FILE,
"marker_definition_handler": API_HANDLERS_FILE,
"list_templates_handler": "templates.py",
"get_template_handler": "templates.py",
"create_template_handler": "templates.py",
@ -109,7 +112,7 @@ def _get_handler_params(handler_name):
filename = HANDLER_FILES.get(handler_name)
if not filename:
return None
filepath = MCP_DIR / filename
filepath = (MCP_DIR if not filename.startswith('gns3server/') else REPO_ROOT) / filename
if not filepath.exists():
return None

View File

@ -16,79 +16,113 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
The vendored gns3fy copy keeps its node/console type lists as literals
(it is shared with the standalone MCP service and cannot import server
enums). These tests fail when the server enums grow a value the vendored
lists have not picked up exactly what happened with "docker_exec": one
vendor node failing validation made the copilot's topology reader drop
the whole project.
Tests for the shared GNS3 REST client layer (gns3_copilot.gns3_client):
- project_inventory: the nodes/links aggregation feeding the topology
context and the Nornir inventory its output shape is consumer-visible
and must stay field-for-field stable
- get_gns3_device_port: netmiko device_type resolution and per-node
credentials over the topology inventory
"""
import pytest
def test_console_types_cover_server_enum():
# ── project_inventory ────────────────────────────────────────────────────
def test_nodes_inventory_emits_default_credentials():
"""
Every server ConsoleType value must be accepted by the vendored Node model.
The inventory dict consumed by get_device_ports_from_topology must
carry the per-node default credentials.
"""
pytest.importorskip("jwt", reason="ai-features extras not installed")
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import CONSOLE_TYPES
from gns3server.schemas.common import ConsoleType
missing = {e.value for e in ConsoleType} - set(CONSOLE_TYPES)
assert not missing, f"CONSOLE_TYPES drifted from ConsoleType, missing: {missing}"
def test_node_types_cover_server_enum():
"""
Every server NodeType value must be accepted by the vendored Node model.
"""
pytest.importorskip("jwt", reason="ai-features extras not installed")
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import NODE_TYPES
from gns3server.schemas.controller.nodes import NodeType
missing = {e.value for e in NodeType} - set(NODE_TYPES)
assert not missing, f"NODE_TYPES drifted from NodeType, missing: {missing}"
def test_node_accepts_docker_exec_console():
"""
Vendor NOS nodes use console_type "docker_exec"; the topology reader
validates the whole node list in one pass, so rejecting it poisoned
every copilot device tool for the project.
"""
pytest.importorskip("jwt", reason="ai-features extras not installed")
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Node
node = Node(
name="R1",
project_id="5f517ce3-1bc6-4245-b866-1a2fbd0ee5a7",
node_id="0d15c2e6-8f83-4b79-8875-9dbc3e5f2f1e",
node_type="docker",
console_type="docker_exec",
status="started",
from gns3server.agent.gns3_copilot.gns3_client.project_inventory import (
build_nodes_inventory,
)
assert node.console_type == "docker_exec"
nodes = [
{
"name": "R1",
"node_id": "0d15c2e6-8f83-4b79-8875-9dbc3e5f2f1e",
"node_type": "dynamips",
"console": 5000,
"console_type": "telnet",
"status": "started",
"x": 0,
"y": 0,
"default_username": "admin",
"default_password": "admin123",
},
]
inventory = build_nodes_inventory(nodes, "127.0.0.1")
assert inventory["R1"]["default_username"] == "admin"
assert inventory["R1"]["default_password"] == "admin123"
assert inventory["R1"]["console_port"] == 5000
assert inventory["R1"]["type"] == "dynamips"
assert inventory["R1"]["server"] == "127.0.0.1"
assert inventory["R1"]["tags"] == []
def test_node_accepts_netmiko_device_type():
def test_links_summary_resolves_names_and_ports():
"""
The vendored Node model must keep the netmiko_device_type field so the
device-port tools can prefer it over the device_type:<type> tag.
links_summary maps raw link endpoint lists to
{link_id, node_a, port_a, node_b, port_b} using port/adapter numbers.
"""
pytest.importorskip("jwt", reason="ai-features extras not installed")
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Node
node = Node(
name="SR1",
project_id="5f517ce3-1bc6-4245-b866-1a2fbd0ee5a7",
node_id="0d15c2e6-8f83-4b79-8875-9dbc3e5f2f1e",
node_type="docker",
console_type="docker_exec",
status="started",
netmiko_device_type="nokia_srl",
from gns3server.agent.gns3_copilot.gns3_client.project_inventory import (
build_links_summary,
)
assert node.netmiko_device_type == "nokia_srl"
nodes = [
{
"name": "R1",
"node_id": "n1",
"ports": [
{"name": "GigabitEthernet0/0", "port_number": 0, "adapter_number": 0},
{"name": "GigabitEthernet0/1", "port_number": 1, "adapter_number": 0},
],
},
{
"name": "R2",
"node_id": "n2",
"ports": [
{"name": "Ethernet0", "port_number": 0, "adapter_number": 0},
],
},
]
links = [
{
"link_id": "l1",
"nodes": [
{"node_id": "n1", "port_number": 0, "adapter_number": 0},
{"node_id": "n2", "port_number": 0, "adapter_number": 0},
],
},
# endpoint not resolvable → skipped, not an error
{
"link_id": "l2",
"nodes": [
{"node_id": "missing", "port_number": 0, "adapter_number": 0},
{"node_id": "n2", "port_number": 0, "adapter_number": 0},
],
},
]
summary = build_links_summary(nodes, links)
assert summary == [
{
"link_id": "l1",
"node_a": "R1",
"port_a": "GigabitEthernet0/0",
"node_b": "R2",
"port_b": "Ethernet0",
}
]
# ── get_gns3_device_port (over the topology inventory) ──────────────────
def test_device_ports_prefer_netmiko_field_over_tag(monkeypatch):
@ -152,58 +186,6 @@ def test_device_ports_error_without_any_device_type(monkeypatch):
assert "netmiko_device_type" in hosts["R2"]["error"]
def test_node_accepts_default_credentials():
"""
The vendored Node model must keep the default credentials so the
device-port tools can log into devices that require authentication.
"""
pytest.importorskip("jwt", reason="ai-features extras not installed")
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Node
node = Node(
name="R1",
project_id="5f517ce3-1bc6-4245-b866-1a2fbd0ee5a7",
node_id="0d15c2e6-8f83-4b79-8875-9dbc3e5f2f1e",
node_type="docker",
console_type="telnet",
status="started",
default_username="admin",
default_password="admin123",
)
assert node.default_username == "admin"
assert node.default_password == "admin123"
def test_nodes_inventory_emits_default_credentials():
"""
The inventory dict consumed by get_device_ports_from_topology must
carry the per-node default credentials.
"""
pytest.importorskip("jwt", reason="ai-features extras not installed")
from types import SimpleNamespace
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Node, Project
project = Project(
project_id="5f517ce3-1bc6-4245-b866-1a2fbd0ee5a7",
connector=SimpleNamespace(base_url="http://127.0.0.1:3080"),
)
project.nodes = [
Node(
name="R1",
project_id=project.project_id,
node_id="0d15c2e6-8f83-4b79-8875-9dbc3e5f2f1e",
node_type="dynamips",
console=5000,
default_username="admin",
default_password="admin123",
),
]
inventory = project.nodes_inventory()
assert inventory["R1"]["default_username"] == "admin"
assert inventory["R1"]["default_password"] == "admin123"
def test_device_ports_inject_default_credentials(monkeypatch):
"""
Per-node default credentials become host-level nornir values (which

View File

@ -53,7 +53,8 @@ class TestApplianceRoutes:
appliance_id = "fc520ae2-a4e5-48c3-9a13-516bb2e94668" # Alpine Linux appliance
response = await client.post(app.url_path_for("install_appliance", appliance_id=appliance_id))
assert response.status_code == status.HTTP_204_NO_CONTENT
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["name"] == "Alpine Linux"
async def test_docker_appliance_install_with_version(self, app: FastAPI, client: AsyncClient) -> None:
@ -68,7 +69,9 @@ class TestApplianceRoutes:
appliance_id = "1cfdf900-7c30-4cb7-8f03-3f61d2581633" # Empty VM appliance
params = {"version": "8G"}
response = await client.post(app.url_path_for("install_appliance", appliance_id=appliance_id), params=params)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["name"] == "Empty VM"
assert response.json()["version"] == "8G"
async def test_qemu_appliance_install_without_version(self, app: FastAPI, client: AsyncClient, images_dir: str) -> None:

View File

@ -79,6 +79,16 @@ class TestComputeRoutes:
assert response.status_code == status.HTTP_200_OK
assert response.json()["compute_id"] == str(test_compute.compute_id)
async def test_compute_get_local(self, app: FastAPI, client: AsyncClient, controller) -> None:
await controller.add_compute(
compute_id="local", name="local", host="127.0.0.1", port=3080, force=True, connect=False)
response = await client.get(app.url_path_for("get_compute", compute_id="local"))
assert response.status_code == status.HTTP_200_OK
assert response.json()["compute_id"] == "local"
assert response.json()["name"] == "local"
async def test_compute_update(self, app: FastAPI, client: AsyncClient, test_compute: Compute) -> None:
params = {

View File

@ -328,10 +328,54 @@ class TestImageRoutes:
with asyncio_patch("gns3server.api.routes.controller.images.get_builtin_disks", return_value=[]) as mock:
response = await client.post(app.url_path_for("install_images"))
assert mock.called
assert response.status_code == status.HTTP_204_NO_CONTENT
assert response.status_code == status.HTTP_200_OK
created = response.json()["created"]
assert len(created) == 1
assert created[0]["name"] == "Empty VM"
assert created[0]["version"] == "100G"
templates_repo = TemplatesRepository(db_session)
templates = await templates_repo.get_templates()
assert len(templates) == 1
assert templates[0].name == "Empty VM"
assert templates[0].version == "100G"
assert templates[0].version == "100G"
await templates_repo.delete_template(templates[0].template_id)
async def test_install_all_skips_existing_template_name(
self, app: FastAPI,
client: AsyncClient,
db_session: AsyncSession,
controller: Controller
) -> None:
# two images matching two versions of the same appliance must not
# produce two templates with the same name
#
# earlier tests in this class uploaded the same filenames from different
# (function-scoped) images directories; drop those stale rows so the
# install route only sees this test's uploads
images_repo = ImagesRepository(db_session)
for image_name in ("empty30G.qcow2", "empty100G.qcow2"):
await images_repo.delete_image(image_name)
for image_path in ("tests/resources/empty30G.qcow2", "tests/resources/empty100G.qcow2"):
with open(image_path, "rb") as f:
image_data = f.read()
response = await client.post(
app.url_path_for("upload_image", image_path=os.path.basename(image_path)),
content=image_data)
assert response.status_code == status.HTTP_201_CREATED
controller.appliance_manager.load_appliances() # make sure appliances are loaded
with asyncio_patch("gns3server.api.routes.controller.images.get_builtin_disks", return_value=[]):
response = await client.post(app.url_path_for("install_images"))
assert response.status_code == status.HTTP_200_OK
manifest = response.json()
assert len(manifest["created"]) == 1
assert manifest["created"][0]["name"] == "Empty VM"
assert any("already exists" in skipped["reason"] for skipped in manifest["skipped"])
templates_repo = TemplatesRepository(db_session)
templates = await templates_repo.get_templates()
assert len(templates) == 1
assert templates[0].name == "Empty VM"
await templates_repo.delete_template(templates[0].template_id)

View File

@ -18,7 +18,7 @@
import pytest
from fastapi import FastAPI, status
from fastapi import FastAPI, HTTPException, status
from httpx import AsyncClient
from unittest.mock import MagicMock
@ -295,10 +295,44 @@ class TestNodeRoutes:
compute: Compute,
node: Node
) -> None:
compute.post = AsyncioMagicMock()
response = await client.post(app.url_path_for("suspend_node", project_id=project.id, node_id=node.id))
assert response.status_code == status.HTTP_204_NO_CONTENT
async def test_suspend_node_unsupported(
self,
app: FastAPI,
client: AsyncClient,
project: Project,
compute: Compute,
node: Node
) -> None:
# node types without suspend support (e.g. VPCS, IOU) must surface the
# compute 405 instead of reporting a fake success
compute.post = AsyncioMagicMock(
side_effect=HTTPException(status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Suspend is not supported")
)
response = await client.post(app.url_path_for("suspend_node", project_id=project.id, node_id=node.id))
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
async def test_suspend_all_nodes_tolerates_unsupported(
self,
app: FastAPI,
client: AsyncClient,
project: Project,
compute: Compute,
node: Node
) -> None:
# suspending all nodes of a mixed project stays best-effort: nodes
# without suspend support are skipped without failing the request
compute.post = AsyncioMagicMock(
side_effect=HTTPException(status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Suspend is not supported")
)
response = await client.post(app.url_path_for("suspend_all_nodes", project_id=project.id))
assert response.status_code == status.HTTP_204_NO_CONTENT
async def test_reload_node(

View File

@ -55,6 +55,11 @@ class TestControllerProjectRoutes:
params = {"name": "test", "path": str(config.settings.Server.projects_path), "project_id": "00010203-0405-0607-0809-0a0b0c0d0e0f"}
response = await client.post(app.url_path_for("create_project"), json=params)
# The projects directory itself must never become a project directory
assert response.status_code == status.HTTP_403_FORBIDDEN
params = {"name": "test", "path": os.path.join(str(config.settings.Server.projects_path), "custom"), "project_id": "00010203-0405-0607-0809-0a0b0c0d0e0f"}
response = await client.post(app.url_path_for("create_project"), json=params)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["name"] == "test"
assert response.json()["project_id"] == "00010203-0405-0607-0809-0a0b0c0d0e0f"
@ -608,3 +613,29 @@ class TestControllerProjectRoutes:
assert drawing.locked is False
for node in project.nodes.values():
assert node.locked is False
response = await client.get(app.url_path_for("locked_project", project_id=project.id))
assert response.status_code == status.HTTP_200_OK
assert response.json() is False
async def test_lock_unlock_empty_project(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
# a project without drawings or nodes has nothing to lock and must
# never report as locked, otherwise it could not be unlocked
response = await client.get(app.url_path_for("locked_project", project_id=project.id))
assert response.status_code == status.HTTP_200_OK
assert response.json() is False
response = await client.post(app.url_path_for("lock_project", project_id=project.id))
assert response.status_code == status.HTTP_204_NO_CONTENT
response = await client.get(app.url_path_for("locked_project", project_id=project.id))
assert response.status_code == status.HTTP_200_OK
assert response.json() is False
response = await client.post(app.url_path_for("unlock_project", project_id=project.id))
assert response.status_code == status.HTTP_204_NO_CONTENT
response = await client.get(app.url_path_for("locked_project", project_id=project.id))
assert response.status_code == status.HTTP_200_OK
assert response.json() is False

View File

@ -125,6 +125,54 @@ async def test_load_projects_skip_unexpected_errors(controller, projects_dir):
mock_load_project.assert_called_with(os.path.join(projects_dir, "broken_project", "broken.gns3"), load=False)
def _write_topology_file(path, project_id, name):
with open(path, "w+") as f:
json.dump(
{
"name": name,
"project_id": project_id,
"version": __version__,
"revision": 10,
"type": "topology",
"topology": {"computes": [], "drawings": [], "links": [], "nodes": []},
},
f,
)
@pytest.mark.asyncio
async def test_load_project_refuses_gns3_in_projects_directory(controller, projects_dir):
"""
A .gns3 placed directly in the projects directory must not be
loadable: its parent directory (the shared projects root) would become
the project directory, and deleting that project would wipe every
project on the controller.
"""
topology_file = os.path.join(projects_dir, "root-level.gns3")
_write_topology_file(topology_file, str(uuid.uuid4()), "root-level")
with pytest.raises(ControllerError):
await controller.load_project(topology_file)
assert not controller._projects
@pytest.mark.asyncio
async def test_load_project_from_own_subdirectory(controller, projects_dir):
"""
The normal layout a .gns3 inside its own subdirectory keeps
loading, with the subdirectory as the project directory.
"""
project_dir = os.path.join(projects_dir, "sub-project")
os.makedirs(project_dir)
topology_file = os.path.join(project_dir, "sub-project.gns3")
_write_topology_file(topology_file, str(uuid.uuid4()), "sub-project")
project = await controller.load_project(topology_file, load=False)
assert project.path == project_dir
def test_projects_directory_event_handler_filters_events(controller):
controller._notify_projects_directory_event = MagicMock()

View File

@ -87,7 +87,8 @@ async def test_import_project_override(projects_dir, controller):
override the previous keeping the same project id & location
"""
tmpdir = Path(projects_dir)
tmpdir = Path(projects_dir) / "override-location"
tmpdir.mkdir(parents=True, exist_ok=True)
project_id = str(uuid.uuid4())
topology = {
"project_id": project_id,

View File

@ -741,6 +741,37 @@ async def test_delete(project):
assert not os.path.exists(project.path)
@pytest.mark.asyncio
async def test_delete_refuses_to_delete_projects_directory(project, projects_dir):
"""
A poisoned entry whose path is the projects directory itself (a .gns3
loaded directly from the projects root before the guard existed) must
not be deletable: rmtree would wipe every project on the controller.
"""
other_project = os.path.join(projects_dir, "another-project")
os.makedirs(other_project, exist_ok=True)
# Simulate the poisoned in-memory state directly: the path setter now
# rejects such an assignment, but a long-running server can still hold
# an entry created before the fix.
project._path = projects_dir
with pytest.raises(ControllerError):
await project.delete()
assert os.path.exists(other_project)
def test_path_setter_rejects_projects_directory(project, projects_dir):
"""
The projects directory itself must never become a project directory.
"""
with pytest.raises(ControllerForbiddenError):
project.path = projects_dir
assert project.path == os.path.join(projects_dir, project.id)
@pytest.mark.asyncio
async def test_delete_does_not_start_nodes(project):
"""

View File

@ -192,12 +192,14 @@ async def test_open(controller, projects_dir):
"version": "2.0.0"
}
with open(os.path.join(projects_dir, "demo.gns3"), "w+") as f:
project_dir = os.path.join(projects_dir, "demo")
os.makedirs(project_dir)
with open(os.path.join(project_dir, "demo.gns3"), "w+") as f:
json.dump(simple_topology, f)
project = Project(name="demo",
project_id="64ba8408-afbf-4b66-9cdd-1fd854427478",
path=str(projects_dir),
path=project_dir,
controller=controller,
filename="demo.gns3",
status="closed")