refactor: sink shared REST handlers into gns3_client, drop gns3fy wrappers

- Slim custom_gns3fy.py to connector-only Gns3Connector and rename to
  connector.py; delete the unused Node/Link/Project dataclasses and
  endpoint wrapper methods (~3000 lines)
- Move the MCP node/link handler implementations into
  gns3_client/api_handlers.py as the shared REST client layer consumed
  by both the MCP service and copilot tools; add available_filters
  handler (exposed as link_available_filters MCP tool) and
  build_gns3_ctx() for copilot callers
- Rewrite the tools_v2 node/link tools on top of the handlers: batch
  lifecycle actions now run in parallel, node creation is a single
  POST, project-wide status reads replace per-node GETs
- Port Project.nodes_inventory/links_summary aggregation into
  project_inventory.py (output shape preserved) and rewrite the
  topology reader / project info tools on it, dropping the unused
  stats/snapshots/drawings calls
- Delete the dead mcp/nodes.py and mcp/links.py (NODE_TOOLS/LINK_TOOLS
  had no consumers; __init__ imports handlers from api_handlers)
- Retarget mcp handler tests to patch api_handlers._get_connector and
  replace test_custom_gns3fy.py with inventory contract tests
This commit is contained in:
YueGuobin 2026-08-25 09:15:32 +08:00
parent 1649fb7b7a
commit 629bb9194f
No known key found for this signature in database
35 changed files with 2180 additions and 5298 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

@ -440,7 +440,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,990 @@
# 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, as_completed
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")
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)}
# ── 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"}
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, link): link for link 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}

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

@ -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

@ -84,7 +84,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 +95,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,
@ -676,7 +675,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(

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

@ -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"],

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

@ -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"],

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

@ -16,6 +16,7 @@ def _mock_conn(json_result=None):
BASE = "gns3server.agent.mcp"
AH = "gns3server.agent.gns3_copilot.gns3_client.api_handlers" # node/link handlers sunk here
@pytest.fixture
@ -110,11 +111,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 +122,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 +151,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 +161,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 +173,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",
@ -183,40 +183,40 @@ class TestNode:
assert result[0]["status"] == "success"
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
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
@ -227,25 +227,24 @@ class TestNode:
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 +260,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 +273,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 +286,15 @@ 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
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,
@ -381,11 +380,10 @@ class TestTemplate:
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 +396,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 +410,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 +424,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(
@ -440,8 +438,8 @@ class TestLinkMarker:
)
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 +460,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 +476,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 +490,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 +504,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 +518,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 = {
@ -34,37 +37,38 @@ HANDLER_FILES = {
"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 +113,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