From 629bb9194f9fba014d8313d7ad0f98d1cf524165 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Tue, 25 Aug 2026 09:15:32 +0800 Subject: [PATCH 01/14] 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 --- .claude/memory/mcp-service-design.md | 15 +- docs/features/mcp-service.md | 2 +- docs/features/project-open-performance.md | 6 +- .../implemented/node-control-tools.md | 40 +- .../gns3_copilot/gns3_client/__init__.py | 38 +- .../gns3_copilot/gns3_client/api_handlers.py | 990 +++++ .../gns3_copilot/gns3_client/connector.py | 309 ++ .../gns3_client/connector_factory.py | 2 +- .../gns3_copilot/gns3_client/custom_gns3fy.py | 3341 ----------------- .../gns3_client/gns3_project_info.py | 40 +- .../gns3_client/gns3_topology_reader.py | 27 +- .../gns3_client/project_inventory.py | 148 + .../gns3_copilot/tools_v2/gns3_create_link.py | 96 +- .../gns3_copilot/tools_v2/gns3_create_node.py | 40 +- .../tools_v2/gns3_get_node_temp.py | 4 +- .../tools_v2/gns3_packet_filter.py | 138 +- .../gns3_copilot/tools_v2/gns3_start_node.py | 222 +- .../gns3_copilot/tools_v2/gns3_stop_node.py | 109 +- .../tools_v2/gns3_suspend_node.py | 111 +- .../tools_v2/gns3_update_node_name.py | 51 +- gns3server/agent/mcp/__init__.py | 21 +- gns3server/agent/mcp/appliances.py | 2 +- gns3server/agent/mcp/computes.py | 2 +- gns3server/agent/mcp/drawings.py | 2 +- gns3server/agent/mcp/images.py | 2 +- gns3server/agent/mcp/links.py | 633 ---- gns3server/agent/mcp/nodes.py | 678 ---- gns3server/agent/mcp/projects.py | 4 +- gns3server/agent/mcp/server.py | 2 +- gns3server/agent/mcp/snapshots.py | 2 +- gns3server/agent/mcp/symbols.py | 2 +- gns3server/agent/mcp/templates.py | 4 +- tests/agent/mcp/test_handlers.py | 123 +- tests/agent/mcp/test_tool_params.py | 70 +- ...t_custom_gns3fy.py => test_gns3_client.py} | 202 +- 35 files changed, 2180 insertions(+), 5298 deletions(-) create mode 100644 gns3server/agent/gns3_copilot/gns3_client/api_handlers.py create mode 100644 gns3server/agent/gns3_copilot/gns3_client/connector.py delete mode 100644 gns3server/agent/gns3_copilot/gns3_client/custom_gns3fy.py create mode 100644 gns3server/agent/gns3_copilot/gns3_client/project_inventory.py delete mode 100644 gns3server/agent/mcp/links.py delete mode 100644 gns3server/agent/mcp/nodes.py rename tests/agent/{test_custom_gns3fy.py => test_gns3_client.py} (58%) diff --git a/.claude/memory/mcp-service-design.md b/.claude/memory/mcp-service-design.md index d6bbcc9de..6c3d80ff9 100644 --- a/.claude/memory/mcp-service-design.md +++ b/.claude/memory/mcp-service-design.md @@ -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 diff --git a/docs/features/mcp-service.md b/docs/features/mcp-service.md index bfcb43271..e1f45fc3e 100644 --- a/docs/features/mcp-service.md +++ b/docs/features/mcp-service.md @@ -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__`) 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 diff --git a/docs/features/project-open-performance.md b/docs/features/project-open-performance.md index 3abd49a60..38cd4334c 100644 --- a/docs/features/project-open-performance.md +++ b/docs/features/project-open-performance.md @@ -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 | diff --git a/docs/gns3-copilot/implemented/node-control-tools.md b/docs/gns3-copilot/implemented/node-control-tools.md index 6d7d105f0..b09083f08 100644 --- a/docs/gns3-copilot/implemented/node-control-tools.md +++ b/docs/gns3-copilot/implemented/node-control-tools.md @@ -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 diff --git a/gns3server/agent/gns3_copilot/gns3_client/__init__.py b/gns3server/agent/gns3_copilot/gns3_client/__init__.py index 57b618f61..100ed500c 100644 --- a/gns3server/agent/gns3_copilot/gns3_client/__init__.py +++ b/gns3server/agent/gns3_copilot/gns3_client/__init__.py @@ -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", diff --git a/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py b/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py new file mode 100644 index 000000000..dc674ccb8 --- /dev/null +++ b/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py @@ -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 . +# +# 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} diff --git a/gns3server/agent/gns3_copilot/gns3_client/connector.py b/gns3server/agent/gns3_copilot/gns3_client/connector.py new file mode 100644 index 000000000..bd5fbcf9c --- /dev/null +++ b/gns3server/agent/gns3_copilot/gns3_client/connector.py @@ -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 . +# +# 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://
:3080", user="admin", cred="password", + ... api_version=2 + ... ) + >>> # API v3 with username/password (auto-fetches JWT token) + >>> server = Gns3Connector( + ... url="http://
:3080", user="admin", cred="password", + ... api_version=3 + ... ) + >>> # API v3 with direct JWT token + >>> server = Gns3Connector( + ... url="http://
: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 diff --git a/gns3server/agent/gns3_copilot/gns3_client/connector_factory.py b/gns3server/agent/gns3_copilot/gns3_client/connector_factory.py index e878ae911..724291782 100644 --- a/gns3server/agent/gns3_copilot/gns3_client/connector_factory.py +++ b/gns3server/agent/gns3_copilot/gns3_client/connector_factory.py @@ -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, ) diff --git a/gns3server/agent/gns3_copilot/gns3_client/custom_gns3fy.py b/gns3server/agent/gns3_copilot/gns3_client/custom_gns3fy.py deleted file mode 100644 index 170e8757b..000000000 --- a/gns3server/agent/gns3_copilot/gns3_client/custom_gns3fy.py +++ /dev/null @@ -1,3341 +0,0 @@ -# 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 . -# -# Copyright (C) 2025 Yue Guobin (岳国宾) -# Author: Yue Guobin (岳国宾) -# -# Project Home: https://github.com/yueguobin/gns3-copilot -# - -""" -Adapted gns3fy module for GNS3-Copilot - -This module is based on the upstream gns3fy project -(https://github.com/davidban77/gns3fy). - -⚠️ WARNING: This module is shared with the MCP (Model Context Protocol) service. -The Gns3Connector class is used by MCP handlers to make HTTP calls. -Modifications to this file must be tested with BOTH gns3-copilot AND MCP. - -Modifications made for GNS3-Copilot: -- Adjusted pydantic usages and dataclass configuration to reduce dependency - conflicts with langchain (pydantic version/api differences) -- Kept the original API surface where possible but simplified - validators/config -- Added JWT token authentication support -- Integrated with context-aware connector factory - -Note: This file is adapted from upstream gns3fy for compatibility with -GNS3-Copilot's architecture. - -Upstream: https://github.com/davidban77/gns3fy -""" - -import os -import time -from collections.abc import Callable -from dataclasses import field -from functools import wraps -from math import cos -from math import pi -from math import sin -from typing import Any -from typing import ParamSpec -from typing import TypeVar -from typing import cast -from urllib.parse import urlparse, quote - -import jwt -import requests -import urllib3 -from pydantic import ConfigDict -from pydantic import field_validator -from pydantic.dataclasses import dataclass -from requests import HTTPError - -P = ParamSpec("P") -R = TypeVar("R") -F = TypeVar("F", bound=Callable[..., Any]) - -config = ConfigDict(validate_assignment=True, extra="ignore") - - -NODE_TYPES = [ - "cloud", - "nat", - "ethernet_hub", - "ethernet_switch", - "frame_relay_switch", - "atm_switch", - "docker", - "dynamips", - "vpcs", - "traceng", - "virtualbox", - "vmware", - "iou", - "qemu", -] - -# Keep in sync with gns3server.schemas.common.ConsoleType. The values are -# duplicated as literals because this module is shared with the standalone -# MCP service and cannot import the enum. "null" is gns3fy legacy. -CONSOLE_TYPES = [ - "vnc", - "telnet", - "ssh", - "http", - "https", - "spice", - "spice+agent", - "none", - "docker_exec", - "null", -] - -LINK_TYPES = ["ethernet", "serial"] - - -class Gns3Connector: - """ - Connector to be use for interaction against 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://
:3080", user="admin", cred="password", - ... api_version=2 - ... ) - >>> # API v3 with username/password (auto-fetches JWT token) - >>> server = Gns3Connector( - ... url="http://
:3080", user="admin", cred="password", - ... api_version=3 - ... ) - >>> # API v3 with direct JWT token - >>> server = Gns3Connector( - ... url="http://
:3080", - ... jwt_token="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", - ... api_version=3 - ... ) - >>> print(server.get_version()) - {'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}" - ) - # print(f"Successfully authenticated to v3 API, token obtained") - 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 - - def get_version(self) -> dict[str, Any]: - """ - Returns the version information of GNS3 server - """ - response = self.http_call("get", url=f"{self.base_url}/version") - return cast(dict[str, Any], response.json()) - - def projects_summary( - self, is_print: bool = True - ) -> list[tuple[str, str, int, int, str]] | None: - """ - Returns a summary of the projects in the server. If `is_print` is `False`, it - will return a list of tuples like: - - `[(name, project_id, total_nodes, total_links, status) ...]` - """ - _projects_summary = [] - for _p in self.get_projects(): - # Retrieve the project stats - _stats = self.http_call( - "get", f"{self.base_url}/projects/{_p['project_id']}/stats" - ).json() - if is_print: - print( - f"{_p['name']}: {_p['project_id']} -- Nodes: {_stats['nodes']} -- " - f"Links: {_stats['links']} -- Status: {_p['status']}" - ) - _projects_summary.append( - ( - _p["name"], - _p["project_id"], - _stats["nodes"], - _stats["links"], - _p["status"], - ) - ) - - return _projects_summary if not is_print else None - - def get_projects(self) -> list[dict[str, Any]]: - """ - Returns the list of the projects on the server - """ - response = self.http_call( - "get", url=f"{self.base_url}/projects" - ).json() - return cast(list[dict[str, Any]], response) - - def get_project( - self, name: str | None = None, project_id: str | None = None - ) -> dict[str, Any] | None: - """ - Retrieves a project from either a name or ID - - **Required Attributes:** - - - `name` or `project_id` - """ - if project_id: - _response = self.http_call( - "get", url=f"{self.base_url}/projects/{project_id}" - ) - return cast(dict[str, Any], _response.json()) - elif name: - try: - return next( - p for p in self.get_projects() if p["name"] == name - ) - except StopIteration: - # Project not found - return None - else: - raise ValueError("Must provide either a name or project_id") - - def templates_summary( - self, is_print: bool = True - ) -> list[tuple[str, str, str, bool, str, str]] | None: - """ - Returns a summary of the templates in the server. If `is_print` is `False`, it - will return a list of tuples like: - - `[(name, template_id, template_type, builtin, console_type, category) ...]` - """ - _templates_summary = [] - for _t in self.get_templates(): - if "console_type" not in _t: - _t["console_type"] = "N/A" - if is_print: - print( - f"{_t['name']}: {_t['template_id']} -- Type: {_t['template_type']}" - f" -- Builtin: {_t['builtin']} -- Console: {_t['console_type']} -- " - f"Category: {_t['category']}" - ) - _templates_summary.append( - ( - _t["name"], - _t["template_id"], - _t["template_type"], - _t["builtin"], - _t["console_type"], - _t["category"], - ) - ) - - return _templates_summary if not is_print else None - - def get_templates(self) -> list[dict[str, Any]]: - """ - Returns the templates defined on the server. - """ - _response_data = self.http_call( - "get", url=f"{self.base_url}/templates" - ).json() - return cast(list[dict[str, Any]], _response_data) - - def get_template( - self, name: str | None = None, template_id: str | None = None - ) -> dict[str, Any] | None: - """ - Retrieves a template from either a name or ID - - **Required Attributes:** - - - `name` or `template_id` - """ - if template_id: - _response_json = self.http_call( - "get", url=f"{self.base_url}/templates/{template_id}" - ).json() - return cast(dict[str, Any], _response_json) - elif name: - try: - return next( - t for t in self.get_templates() if t["name"] == name - ) - except StopIteration: - # Template name not found - return None - else: - raise ValueError("Must provide either a name or template_id") - - def update_template( - self, - name: str | None = None, - template_id: str | None = None, - **kwargs: Any, - ) -> dict[str, Any]: - """ - Updates a template by giving its name or UUID. For more information [API INFO] - (http://api.gns3.net/en/2.2/api/v2/controller/template/ - templatestemplateid.html#put-v2-templates-template-id) - - **Required Attributes:** - - - `name` or `template_id` - - **Optional Attributes (can be passed via kwargs):** - - - `tags` (list): List of tags for the template (e.g., - ["device_type:cisco_ios_telnet", "platform:cisco_ios"]) - - `netmiko_device_type` (str): Netmiko device type (e.g. "cisco_ios_telnet"); - preferred over the device_type: tag - - Any other template attributes supported by GNS3 API - """ - # Get existing template - _template = self.get_template(name=name, template_id=template_id) - # Type check: handle case where get_template might return None - if _template is None: - raise ValueError( - f"Template not found (name={name}, id={template_id})" - ) - # Update local dictionary and send request - _template.update(**kwargs) - - response = self.http_call( - "put", - url=f"{self.base_url}/templates/{_template['template_id']}", - json_data=_template, - ) - # Return JSON and handle Any type errors - return cast(dict[str, Any], response.json()) - - def create_template(self, **kwargs: Any) -> dict[str, Any]: - """ - Creates a template by giving its attributes. For more information [API INFO] - (http://api.gns3.net/en/2.2/api/v2/controller/template/ - templates.html#post-v2-templates) - - **Required Attributes:** - - - `name` - - `compute_id` by default is 'local' - - `template_type` - - **Optional Attributes (can be passed via kwargs):** - - - `tags` (list): List of tags for the template (e.g., - ["device_type:cisco_ios_telnet", "platform:cisco_ios"]) - - `netmiko_device_type` (str): Netmiko device type (e.g. "cisco_ios_telnet"); - preferred over the device_type: tag - - Any other template attributes supported by GNS3 API - - **Example:** - - ```python - >>> connector.create_template( - ... name="cisco_router", - ... template_type="dynamips", - ... tags=["device_type:cisco_ios_telnet", "platform:cisco_ios"], - ... netmiko_device_type="cisco_ios_telnet" - ... ) - ``` - """ - # kwargs["name"] might raise KeyError at runtime, for more robust - # code we can use get first - template_name = kwargs.get("name") - if not template_name: - raise ValueError( - "Attribute 'name' is required to create a template" - ) - - # Check if template already exists - _template = self.get_template(name=kwargs["name"]) - if _template: - raise ValueError(f"Template already used: {kwargs['name']}") - - # Set default values - if "compute_id" not in kwargs: - kwargs["compute_id"] = "local" - - # Send request - response = self.http_call( - "post", url=f"{self.base_url}/templates", json_data=kwargs - ) - # Return and convert type - return cast(dict[str, Any], response.json()) - - def delete_template( - self, name: str | None = None, template_id: str | None = None - ) -> None: - """ - Deletes a template by giving its attributes. For more information [API INFO] - (http://api.gns3.net/en/2.2/api/v2/controller/template/ - templatestemplateid.html#id16) - - **Required Attributes:** - - - `name` or `template_id` - """ - # Logic handling: if only name is given, need to first get template_id - if name and not template_id: - _template = self.get_template(name=name) - # Type narrowing: check if _template is None - if _template is None: - raise ValueError(f"Template with name '{name}' not found.") - - template_id = _template["template_id"] - - # Final check: ensure template_id has a value at this point - if not template_id: - raise ValueError( - "Must provide either a 'name' or 'template_id' to delete a template." - ) - - self.http_call( - "delete", url=f"{self.base_url}/templates/{template_id}" - ) - - def get_nodes(self, project_id: str) -> list[dict[str, Any]]: - """ - Retieves the nodes defined on the project - - **Required Attributes:** - - - `project_id` - """ - _response_data = self.http_call( - "get", url=f"{self.base_url}/projects/{project_id}/nodes" - ).json() - - return cast(list[dict[str, Any]], _response_data) - - def get_node(self, project_id: str, node_id: str) -> dict[str, Any]: - """ - Returns the node by locating its ID. - - **Required Attributes:** - - - `project_id` - - `node_id` - """ - _url = f"{self.base_url}/projects/{project_id}/nodes/{node_id}" - _response_data = self.http_call("get", _url).json() - return cast(dict[str, Any], _response_data) - - def get_links(self, project_id: str) -> list[dict[str, Any]]: - """ - Retrieves the links defined in the project. - - **Required Attributes:** - - - `project_id` - """ - _response_data = self.http_call( - "get", url=f"{self.base_url}/projects/{project_id}/links" - ).json() - - return cast(list[dict[str, Any]], _response_data) - - def get_link(self, project_id: str, link_id: str) -> dict[str, Any]: - """ - Returns the link by locating its ID. - - **Required Attributes:** - - - `project_id` - - `link_id` - """ - _url = f"{self.base_url}/projects/{project_id}/links/{link_id}" - _response_data = self.http_call("get", _url).json() - - return cast(dict[str, Any], _response_data) - - def create_project(self, **kwargs: Any) -> dict[str, Any]: - """ - Pass a dictionary type object with the project parameters to be created. - - **Required Attributes:** - - - `name` - - **Returns** - - JSON project information - """ - _url = f"{self.base_url}/projects" - if "name" not in kwargs: - raise ValueError("Parameter 'name' is mandatory") - _response = self.http_call("post", _url, json_data=kwargs) - - return cast(dict[str, Any], _response.json()) - - def delete_project(self, project_id: str) -> None: - """ - Deletes a project from server. - - **Required Attributes:** - - - `project_id` - """ - _url = f"{self.base_url}/projects/{project_id}" - self.http_call("delete", _url) - return None - - def update_project(self, project_id: str, **kwargs: Any) -> dict[str, Any]: - """ - Update a project's properties. - - **Required Attributes:** - - - `project_id` - - **Optional Attributes:** - - - `name`, `auto_close`, `auto_open`, `auto_start` - - `scene_height`, `scene_width`, `zoom` - - `show_layers`, `snap_to_grid`, `show_grid`, `grid_size`, `drawing_grid_size` - - `show_interface_labels`, `supplier`, `variables` - - **Returns** - - JSON project information - """ - _url = f"{self.base_url}/projects/{project_id}" - _response = self.http_call("put", _url, json_data=kwargs) - return cast(dict[str, Any], _response.json()) - - def duplicate_project(self, project_id: str, **kwargs: Any) -> dict[str, Any]: - """ - Duplicate a project from a given project_id. - - **Required Attributes:** - - - `project_id` - - `name` (in kwargs) - - **Returns** - - JSON project information - """ - _url = f"{self.base_url}/projects/{project_id}/duplicate" - if "name" not in kwargs: - raise ValueError("Parameter 'name' is mandatory") - _response = self.http_call("post", _url, json_data=kwargs) - return cast(dict[str, Any], _response.json()) - - def get_project_file(self, project_id: str, file_path: str) -> str: - """ - Get the content of a file in a project. - - **Required Attributes:** - - - `project_id` - - `file_path` - - **Returns** - - File content as text string - """ - encoded_path = quote(file_path, safe="/") - _url = f"{self.base_url}/projects/{project_id}/files/{encoded_path}" - _response = self.http_call("get", _url) - return _response.text - - def write_project_file(self, project_id: str, file_path: str, content: str) -> None: - """ - Write content to a file in a project. Creates the file if it doesn't exist. - - **Required Attributes:** - - - `project_id` - - `file_path` - - `content` - """ - encoded_path = quote(file_path, safe="/") - _url = f"{self.base_url}/projects/{project_id}/files/{encoded_path}" - self.http_call("post", _url, data=content, headers={"Content-Type": "text/plain"}) - - def get_computes(self) -> list[dict[str, Any]]: - """ - Returns a list of computes. - - **Returns:** - - List of dictionaries of the computes attributes like cpu/memory usage - """ - _url = f"{self.base_url}/computes" - _response_data = self.http_call("get", _url).json() - - return cast(list[dict[str, Any]], _response_data) - - def get_compute(self, compute_id: str = "local") -> dict[str, Any]: - """ - Returns a compute. - - **Returns:** - - Dictionary of the compute attributes like cpu/memory usage - """ - _url = f"{self.base_url}/computes/{compute_id}" - _response_data = self.http_call("get", _url).json() - - return cast(dict[str, Any], _response_data) - - def get_compute_images( - self, emulator: str, compute_id: str = "local" - ) -> list[dict[str, Any]]: - """ - Returns a list of images available for a compute. - - **Required Attributes:** - - - `emulator`: the likes of 'qemu', 'iou', 'docker' ... - - `compute_id` By default is 'local' - - **Returns:** - - List of dictionaries with images available for the compute for the specified - emulator - """ - _url = f"{self.base_url}/computes/{compute_id}/{emulator}/images" - _response_data = self.http_call("get", _url).json() - - return cast(list[dict[str, Any]], _response_data) - - def upload_compute_image( - self, emulator: str, file_path: str, compute_id: str = "local" - ) -> None: - """ - uploads an image for use by a compute. - - **Required Attributes:** - - - `emulator`: the likes of 'qemu', 'iou', 'docker' ... - - `file_path`: path of file to be uploaded - - `compute_id` By default is 'local' - """ - if not os.path.exists(file_path): - raise FileNotFoundError(f"Could not find file: {file_path}") - - _filename = os.path.basename(file_path) - _url = f"{self.base_url}/computes/{compute_id}/{emulator}/images/{_filename}" - with open(file_path, "rb") as f: - self.http_call("post", _url, data=f) - - return None - - def get_compute_ports(self, compute_id: str = "local") -> dict[str, Any]: - """ - Returns ports used and configured by a compute. - - **Required Attributes:** - - - `compute_id` By default is 'local' - - **Returns:** - - Dictionary of `console_ports` used and range, as well as the `udp_ports` - """ - _url = f"{self.base_url}/computes/{compute_id}/ports" - _response_data = self.http_call("get", _url).json() - - return cast(dict[str, Any], _response_data) - - -def verify_connector_and_id(f: F) -> F: - """ - Main checker for connector object and respective object's ID for their retrieval - or actions methods. - """ - - @wraps(f) - def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any: - _conn = self.connector - _project_id = self.project_id - - if _conn is None: - raise ValueError("Gns3Connector not assigned under 'connector'") - if _project_id is None: - raise ValueError("Need to submit project_id") - - # Checks for Node - if self.__class__.__name__ == "Node": - if not self.node_id: - if not self.name: - raise ValueError("Need to either submit node_id or name") - - # Try to retrieve the node_id - _url = f"{_conn.base_url}/projects/{_project_id}/nodes" - _response = _conn.http_call("get", _url) - - extracted = [ - node - for node in _response.json() - if node["name"] == self.name - ] - if len(extracted) > 1: # pragma: no cover - raise ValueError( - "Multiple nodes found with same name. Need to submit node_id" - ) - self.node_id = extracted[0]["node_id"] - # Checks for Link - if self.__class__.__name__ == "Link": - if not self.link_id: - raise ValueError("Need to submit link_id") - return f(self, *args, **kwargs) - - return cast(F, wrapper) - - -@dataclass(config=config) -class Link: - """ - GNS3 Link API object. For more information visit: [Links Endpoint API information]( - http://api.gns3.net/en/2.2/api/v2/controller/link/projectsprojectidlinks.html) - - **Attributes:** - - - `link_id` (str): Link UUID (**required** to be set when using `get` method) - - `link_type` (enum): Possible values: ethernet, serial - - `link_style` (dict): Describes the visual style of the link - - `project_id` (str): Project UUID (**required**) - - `connector` (object): `Gns3Connector` instance used for interaction (**required**) - - `suspend` (bool): Suspend the link - - `nodes` (list): List of the Nodes and ports (**required** when using `create` - method, see Features/Link creation on the docs) - - `filters` (dict): Packet filter. This allow to simulate latency and errors - - `capturing` (bool): Read only property. True if a capture running on the link - - `capture_file_path` (str): Read only property. The full path of the capture file - if capture is running - - `capture_file_name` (str): Read only property. The name of the capture file if - capture is running - - **Returns:** - - `Link` instance - - **Example:** - - ```python - >>> link = Link(project_id=, link_id= connector=) - >>> link.get() - >>> print(link.link_type) - 'ethernet' - ``` - """ - - link_id: str | None = None - link_type: str | None = None - link_style: Any | None = None - project_id: str | None = None - suspend: bool | None = None - nodes: list[Any] | None = None - filters: dict | None = None - capturing: bool | None = None - capture_file_path: str | None = None - capture_file_name: str | None = None - capture_compute_id: str | None = None - - connector: Any | None = field(default=None, repr=False) - - @field_validator("link_type") - @classmethod - def _valid_link_type(cls, value: str | None) -> str | None: - if value not in LINK_TYPES and value is not None: - raise ValueError(f"Not a valid link_type - {value}") - return value - - @field_validator("suspend") - @classmethod - def _valid_suspend(cls, value: bool | None) -> bool | None: - if type(value) is not bool and value is not None: - raise ValueError(f"Not a valid suspend - {value}") - return value - - @field_validator("filters") - @classmethod - def _valid_filters( - cls, value: dict[str, Any] | None - ) -> dict[str, Any] | None: - if type(value) is not dict and value is not None: - raise ValueError(f"Not a valid filters - {value}") - return value - - def _update(self, data_dict: dict[str, Any]) -> None: - for k, v in data_dict.items(): - if k in self.__dict__.keys(): - self.__setattr__(k, v) - - @verify_connector_and_id - def get(self) -> None: - """ - Retrieves the information from the link endpoint. - - **Required Attributes:** - - - `project_id` - - `connector` - - `link_id` - """ - _conn = self.connector - _project_id = self.project_id - - if _conn is None: - raise ValueError("Gns3Connector not assigned under 'connector'") - if _project_id is None: - raise ValueError("Need to submit project_id") - - _url = f"{_conn.base_url}/projects/{_project_id}/links/{self.link_id}" - _response = _conn.http_call("get", _url) - - # Update object - self._update(_response.json()) - - @verify_connector_and_id - def delete(self) -> None: - """ - Deletes a link endpoint from the project. It sets to `None` the attributes - `link_id` when executed sucessfully - - **Required Attributes:** - - - `project_id` - - `connector` - - `link_id` - """ - _conn = self.connector - _project_id = self.project_id - _link_id = self.link_id - - if _conn is None: - raise ValueError("Gns3Connector not assigned under 'connector'") - if _project_id is None: - raise ValueError("Need to submit project_id") - if _link_id is None: - raise ValueError( - "Link ID is missing. The link might have already been deleted." - ) - - _url = f"{_conn.base_url}/projects/{_project_id}/links/{self.link_id}" - - _conn.http_call("delete", _url) - - self.project_id = None - self.link_id = None - - def create(self) -> None: - """ - Creates a link endpoint - - **Required Attributes:** - - - `project_id` - - `connector` - - `nodes` - """ - if not self.connector: - raise ValueError("Gns3Connector not assigned under 'connector'") - if not self.project_id: - raise ValueError("Need to submit project_id") - - _url = f"{self.connector.base_url}/projects/{self.project_id}/links" - - data = { - k: v - for k, v in self.__dict__.items() - if k not in ("connector", "__initialised__") - if v is not None - } - - _response = self.connector.http_call("post", _url, json_data=data) - - # Now update it - self._update(_response.json()) - - @verify_connector_and_id - def update(self, **kwargs: Any) -> None: - """ - Updates the link instance by passing the keyword arguments of the attributes - you want updated - - Example: - - ```python - link1.update(suspend=True) - ``` - - This will update the link `suspend` attribute to `True` - - **Required Attributes:** - - - `project_id` - - `connector` - - `link_id` - """ - if not self.connector: - raise ValueError("Gns3Connector not assigned under 'connector'") - if not self.project_id: - raise ValueError("Need to submit project_id") - - _url = ( - f"{self.connector.base_url}/projects/{self.project_id}/links/" - f"{self.link_id}" - ) - - # TODO: Verify that the passed kwargs are supported ones - _response = self.connector.http_call("put", _url, json_data=kwargs) - - # Update object - self._update(_response.json()) - - @verify_connector_and_id - def available_filters(self) -> list[dict[str, Any]]: - """ - Gets the list of available packet filters for this link. - - **NOTE:** This endpoint is only available in GNS3 API v3 or later. - Attempting to call this method with a v2 connector will raise an error. - - **Required Attributes:** - - - `project_id` - - `connector` (must be API v3 or later) - - `link_id` - - **Returns:** - - List of available filter types with their parameters (e.g., frequency_drop, - packet_loss, delay, corrupt, bpf). - - **Example:** - - ```python - >>> link = Link(project_id=, link_id=, connector=) - >>> filters = link.available_filters() - >>> print(filters) - [ - { - "type": "frequency_drop", - "name": "Frequency drop", - "description": "It will drop everything with a -1 frequency...", - "parameters": [...] - }, - ... - ] - ``` - """ - _conn = self.connector - _project_id = self.project_id - - if _conn is None: - raise ValueError("Gns3Connector not assigned under 'connector'") - if _project_id is None: - raise ValueError("Need to submit project_id") - - # Check API version - available_filters endpoint is only available in v3+ - if not hasattr(_conn, "api_version") or _conn.api_version < 3: - raise ValueError( - "The available_filters() method requires GNS3 API v3 or later. " - f"Current connector version: v{getattr(_conn, 'api_version', 2)}. " - "Please use api_version=3 when creating the Gns3Connector." - ) - - _url = ( - f"{_conn.base_url}/projects/{_project_id}/links/{self.link_id}/" - "available_filters" - ) - _response = _conn.http_call("get", _url) - - return cast(list[dict[str, Any]], _response.json()) - - def reset(self) -> None: - """ - Reset the link, clearing its state (counters, filters, etc.). - - **Required Attributes:** - - - `project_id` - - `connector` - - `link_id` - """ - _conn = self.connector - _project_id = self.project_id - - if _conn is None: - raise ValueError("Gns3Connector not assigned under 'connector'") - if _project_id is None: - raise ValueError("Need to submit project_id") - - _url = f"{_conn.base_url}/projects/{_project_id}/links/{self.link_id}/reset" - _response = _conn.http_call("post", _url) - self._update(_response.json()) - - def start_capture( - self, - data_link_type: str = "DLT_EN10MB", - capture_file_name: str | None = None, - wireshark: bool = False, - ) -> None: - """ - Start packet capture on the link. - - **Required Attributes:** - - - `project_id` - - `connector` - - `link_id` - - **Optional Attributes:** - - - `data_link_type` Data link type (default: DLT_EN10MB) - - `capture_file_name` Name of the capture file (optional) - - `wireshark` Open Wireshark automatically (default: False) - """ - _conn = self.connector - _project_id = self.project_id - - if _conn is None: - raise ValueError("Gns3Connector not assigned under 'connector'") - if _project_id is None: - raise ValueError("Need to submit project_id") - if not self.link_id: - raise ValueError("Need to submit link_id") - - _url = ( - f"{_conn.base_url}/projects/{_project_id}/links/" - f"{self.link_id}/capture/start" - ) - _data: dict[str, Any] = { - "data_link_type": data_link_type, - "wireshark": wireshark, - } - if capture_file_name: - _data["capture_file_name"] = capture_file_name - _response = _conn.http_call("post", _url, json_data=_data) - self._update(_response.json()) - - def stop_capture(self) -> None: - """ - Stop packet capture on the link. - - **Required Attributes:** - - - `project_id` - - `connector` - - `link_id` - """ - _conn = self.connector - _project_id = self.project_id - - if _conn is None: - raise ValueError("Gns3Connector not assigned under 'connector'") - if _project_id is None: - raise ValueError("Need to submit project_id") - - _url = ( - f"{_conn.base_url}/projects/{_project_id}/links/" - f"{self.link_id}/capture/stop" - ) - _conn.http_call("post", _url) - - -@dataclass(config=config) -class Node: - """ - GNS3 Node API object. For more information visit: [Node Endpoint API information]( - http://api.gns3.net/en/2.2/api/v2/controller/node/projectsprojectidnodes.html) - - **Attributes:** - - - `name` (str): Node name (**required** when using `create` method) - - `project_id` (str): Project UUID (**required**) - - `node_id` (str): Node UUID (**required** when using `get` method) - - `compute_id` (str): Compute identifier (**required**, default=local) - - `node_type` (enum): frame_relay_switch, atm_switch, docker, dynamips, vpcs, - traceng, virtualbox, vmware, iou, qemu (**required** when using `create` method) - - `connector` (object): `Gns3Connector` instance used for interaction (**required**) - - `template_id`: Template UUID from the which the node is from. - - `template`: Template name from the which the node is from. - - `node_directory` (str): Working directory of the node. Read only - - `status` (enum): Possible values: stopped, started, suspended - - `ports` (list): List of node ports, READ only - - `port_name_format` (str): Formating for port name {0} will be replace by port - number - - `port_segment_size` (int): Size of the port segment - - `first_port_name` (str): Name of the first port - - `properties` (dict): Properties specific to an emulator - - `locked` (bool): Whether the element locked or not - - `label` (dict): TBC - - `console` (int): Console TCP port - - `console_host` (str): Console host - - `console_auto_start` (bool): Automatically start the console when the node has - started - - `command_line` (str): Command line use to start the node - - `custom_adapters` (list): TBC - - `height` (int): Height of the node, READ only - - `width` (int): Width of the node, READ only - - `symbol` (str): Symbol of the node - - `x` (int): X position of the node - - `y` (int): Y position of the node - - `z (int): Z position of the node - - **Returns:** - - `Node` instance - - **Example:** - - ```python - >>> alpine = Node(name="alpine1", node_type="docker", template="alpine", - project_id=, connector=) - >>> alpine.create() - >>> print(alpine.node_id) - 'SOME-UUID-GENERATED' - ``` - """ - - name: str | None = None - project_id: str | None = None - node_id: str | None = None - compute_id: str = "local" - node_type: str | None = None - node_directory: str | None = None - status: str | None = None - ports: list | None = None - port_name_format: str | None = None - port_segment_size: int | None = None - first_port_name: str | None = None - locked: bool | None = None - label: Any | None = None - console: int | None = None - console_host: str | None = None - console_type: str | None = None - console_auto_start: bool | None = None - command_line: str | None = None - custom_adapters: list[Any] | None = None - height: int | None = None - width: int | None = None - symbol: str | None = None - x: int | None = None - y: int | None = None - z: int | None = None - template_id: str | None = None - properties: Any | None = None - tags: list[str] | None = None - netmiko_device_type: str | None = None - default_username: str | None = None - default_password: str | None = None - - template: str | None = None - links: list[Link] = field(default_factory=list, repr=False) - connector: Any | None = field(default=None, repr=False) - - @field_validator("node_type") - @classmethod - def _valid_node_type(cls, value: Any) -> Any: - if value not in NODE_TYPES and value is not None: - raise ValueError(f"Not a valid node_type - {value}") - return value - - @field_validator("console_type") - @classmethod - def _valid_console_type(cls, value: Any) -> Any: - if value not in CONSOLE_TYPES and value is not None: - raise ValueError(f"Not a valid console_type - {value}") - return value - - @field_validator("status") - @classmethod - def _valid_status(cls, value: Any) -> Any: - if ( - value not in ("stopped", "started", "suspended") - and value is not None - ): - raise ValueError(f"Not a valid status - {value}") - return value - - def _update(self, data_dict: dict[str, Any]) -> None: - for k, v in data_dict.items(): - if k in self.__dict__: - setattr(self, k, v) - - @verify_connector_and_id - def get(self, get_links: bool = True) -> None: - """ - Retrieves the node information. When `get_links` is `True` it also retrieves the - links respective to the node. - - **Required Attributes:** - - - `project_id` - - `connector` - - `node_id` - """ - if not self.connector: - raise ValueError("Gns3Connector not assigned under 'connector'") - if not self.project_id: - raise ValueError("Need to submit project_id") - - _url = ( - f"{self.connector.base_url}/projects/{self.project_id}/nodes/" - f"{self.node_id}" - ) - _response = self.connector.http_call("get", _url) - - # Update object - self._update(_response.json()) - - if get_links: - self.get_links() - - @verify_connector_and_id - def get_links(self) -> None: - """ - Retrieves the links of the respective node. They will be saved at the `links` - attribute - - **Required Attributes:** - - - `project_id` - - `connector` - - `node_id` - """ - if not self.connector: - raise ValueError("Gns3Connector not assigned under 'connector'") - if not self.project_id: - raise ValueError("Need to submit project_id") - - _url = ( - f"{self.connector.base_url}/projects/{self.project_id}/nodes" - f"/{self.node_id}/links" - ) - _response = self.connector.http_call("get", _url) - - # Create the Link array but cleanup cache if there is one - if self.links: - self.links = [] - for _link in _response.json(): - self.links.append(Link(connector=self.connector, **_link)) - - @verify_connector_and_id - def start(self) -> bool | None: - """ - Starts the node. - - **Required Attributes:** - - - `project_id` - - `connector` - - `node_id` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/nodes/{self.node_id}/start" - if "v2" in _url.lower(): # api_version 2 - _response = _conn.http_call( - "post", - _url, - ) - - # Update object or perform get if change was not reflected - if _response.json().get("status") == "started": - self._update(_response.json()) - else: - self.get() # pragma: no cover - - return True - - else: - # api_version 3 - _response = _conn.http_call( - "post", _url, json_data={"additionalProp1": {}} - ) - # successful response code 204 - if _response.status_code in (204,): - self.get() - return True - else: - try: - error_detail = _response.json() - except Exception: - error_detail = getattr( - _response, "text", "No response body" - ) - - _msg = ( - "Failed to start node: " - f"{getattr(_response, 'status_code', 'Unknown Status')}, " - f"Detail: {error_detail}" - ) - raise RuntimeError(_msg) from None - - @verify_connector_and_id - def stop(self) -> bool | None: - """ - Stops the node. - - **Required Attributes:** - - - `project_id` - - `connector` - - `node_id` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/nodes/{self.node_id}/stop" - if "v2" in _url.lower(): # api_version 2 - _response = _conn.http_call( - "post", - _url, - ) - - # Update object or perform get if change was not reflected - if _response.json().get("status") == "stopped": - self._update(_response.json()) - else: - self.get() # pragma: no cover - - return True - else: - # api_version 3 - _response = _conn.http_call( - "post", _url, json_data={"additionalProp1": {}} - ) - # successful response code 204 - if _response.status_code in (204,): - self.get() - return True - else: - try: - error_detail = _response.json() - except Exception: - error_detail = _response.text - _msg = ( - f"Failed to stop node: {_response.status_code}, " - f"Detail: {error_detail}" - ) - raise RuntimeError(_msg) from None - - @verify_connector_and_id - def reload(self) -> bool | None: - """ - Reloads the node. - - **Required Attributes:** - - - `project_id` - - `connector` - - `node_id` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - _node_id = self.node_id - assert _node_id is not None - - _url = ( - f"{_conn.base_url}/projects/{_project_id}/nodes/{_node_id}/reload" - ) - _response = _conn.http_call("post", _url) - - if "v2" in _url.lower(): # api_version 2 - _response = _conn.http_call( - "post", - _url, - ) - - # Update object or perform get if change was not reflected - if _response.json().get("status") == "started": - self._update(_response.json()) - else: - self.get() # pragma: no cover - return True - - else: - # api_version 3 - _response = _conn.http_call( - "post", _url, json_data={"additionalProp1": {}} - ) - # successful response code 204 - if _response.status_code in (204,): - self.get() - return True - else: - try: - error_detail = _response.json() - except Exception: - error_detail = _response.text - _msg = ( - f"Failed to reload node: {_response.status_code}, " - f"Detail: {error_detail}" - ) - raise RuntimeError(_msg) from None - - @verify_connector_and_id - def suspend(self) -> None: - """ - Suspends the node. - - **Required Attributes:** - - - `project_id` - - `connector` - - `node_id` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - _node_id = self.node_id - assert _node_id is not None - - _url = ( - f"{_conn.base_url}/projects/{_project_id}/nodes/{_node_id}/suspend" - ) - _response = _conn.http_call("post", _url) - - # Update object or perform get if change was not reflected - if _response.json().get("status") == "suspended": - self._update(_response.json()) - else: - self.get() # pragma: no cover - - @verify_connector_and_id - def update(self, **kwargs: Any) -> None: - """ - Updates the node instance by passing the keyword arguments of the attributes - you want updated - - Example: - - ```python - router01.update(name="router01-CSX") - ``` - - This will update the project `auto_close` attribute to `True` - - **Required Attributes:** - - - `project_id` - - `connector` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - _node_id = self.node_id - assert _node_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/nodes/{_node_id}" - - # TODO: Verify that the passed kwargs are supported ones - _response = _conn.http_call("put", _url, json_data=kwargs) - - # Update object - self._update(_response.json()) - - def create(self) -> None: - """ - Creates a node. - - By default it will fetch the nodes properties for creation based on the - `template` or `template_id` attribute supplied. This can be overriden/updated - by sending a dictionary of the properties under `extra_properties`. - - **Required Node instance attributes:** - - - `project_id` - - `connector` - - `compute_id`: Defaults to "local" - - `template` or `template_id` - if not passed as arguments - """ - if self.node_id: - raise ValueError("Node already created") - if not self.connector: - raise ValueError("Gns3Connector not assigned under 'connector'") - if not self.project_id: - raise ValueError("Node object needs to have project_id attribute") - if not self.template_id: - if self.template: - _template = self.connector.get_template(name=self.template) - if _template is None: - raise ValueError(f"Template {self.template} not found") - self.template_id = self.connector.get_template( - name=self.template - ).get("template_id") - else: - raise ValueError("Need either 'template' of 'template_id'") - - cached_data = { - k: v - for k, v in self.__dict__.items() - if k - not in ( - "project_id", - "template", - "template_id", - "links", - "connector", - "__initialised__", - ) - if v is not None - } - - _url = ( - f"{self.connector.base_url}/projects/{self.project_id}/" - f"templates/{self.template_id}" - ) - - _response = self.connector.http_call( - "post", - _url, - json_data={"x": 0, "y": 0, "compute_id": self.compute_id}, - ) - - self._update(_response.json()) - - # Update the node attributes based on cached data - self.update(**cached_data) - - @verify_connector_and_id - def delete(self) -> None: - """ - Deletes the node from the project. It sets to `None` the attributes `node_id` - and `name` when executed successfully - - **Required Attributes:** - - - `project_id` - - `connector` - - `node_id` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - _node_id = self.node_id - assert _node_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/nodes/{_node_id}" - - _conn.http_call("delete", _url) - - self.project_id = None - self.node_id = None - self.name = None - - @verify_connector_and_id - def get_file(self, path: str) -> str: - """ - Retrieve a file in the node directory. - - **Required Attributes:** - - - `project_id` - - `connector` - - `path`: Node's relative path of the file - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - _node_id = self.node_id - assert _node_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/nodes/{_node_id}/files/{path}" - - return cast(str, _conn.http_call("get", _url).text) - - @verify_connector_and_id - def list_files(self, path: str = "", recursive: bool = False) -> list[dict[str, Any]]: - """ - List files in the node directory with metadata (name, size, type, modified time). - - **Required Attributes:** - - - `project_id` - - `connector` - - `node_id` - - **Optional Attributes:** - - - `path`: Subdirectory path within node directory (default: "") - - `recursive`: Recursively list all files (default: False) - - **Returns:** - - List of file objects with metadata. - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - _node_id = self.node_id - assert _node_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/nodes/{_node_id}/files" - _params: dict[str, str] = {} - if path: - _params["path"] = path - if recursive: - _params["recursive"] = "true" - _response = _conn.http_call("get", _url, params=_params if _params else None) - return cast(list[dict[str, Any]], _response.json()) - - @verify_connector_and_id - def delete_file(self, path: str) -> None: - """ - Delete a file from the node directory. - - **Required Attributes:** - - - `project_id` - - `connector` - - `node_id` - - `path`: Node's relative path of the file to delete - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - _node_id = self.node_id - assert _node_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/nodes/{_node_id}/files/{path}" - _conn.http_call("delete", _url) - - @verify_connector_and_id - def write_file(self, path: str, data: Any) -> None: - """ - Places a file content on a specified node file path. Used mainly for docker - images. - - Example to update an alpine docker network interfaces: - - ```python - >>> data = ''' - auto eth0 - iface eth0 inet dhcp - ''' - - >>> alpine_node.write_file(path='/etc/network/interfaces', data=data) - ``` - - **Required Attributes:** - - - `project_id` - - `connector` - - `path`: Node's relative path of the file - - `data`: Data to be included in the file - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - _node_id = self.node_id - assert _node_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/nodes/{_node_id}/files/{path}" - - _conn.http_call("post", _url, data=data) - - -@dataclass(config=config) -class Project: - """ - GNS3 Project API object. For more information visit: [Project Endpoint API - information](http://api.gns3.net/en/2.2/api/v2/controller/project/projects.html) - - **Attributes:** - - - `name`: Project name (**required** when using `create` method) - - `project_id` (str): Project UUID (**required**) - - `connector` (object): `Gns3Connector` instance used for interaction (**required**) - - `status` (enum): Possible values: opened, closed - - `path` (str): Path of the project on the server - - `filename` (str): Project filename - - `auto_start` (bool): Project start when opened - - `auto_close` (bool): Project auto close when client cut off the notifications feed - - `auto_open` (bool): Project open when GNS3 start - - `drawing_grid_size` (int): Grid size for the drawing area for drawings - - `grid_size` (int): Grid size for the drawing area for nodes - - `scene_height` (int): Height of the drawing area - - `scene_width` (int): Width of the drawing area - - `show_grid` (bool): Show the grid on the drawing area - - `show_interface_labels` (bool): Show interface labels on the drawing area - - `show_layers` (bool): Show layers on the drawing area - - `snap_to_grid` (bool): Snap to grid on the drawing area - - `supplier` (dict): Supplier of the project - - `variables` (list): Variables required to run the project - - `zoom` (int): Zoom of the drawing area - - `stats` (dict): Project stats - -.`drawings` (list): List of drawings present on the project - - `nodes` (list): List of `Node` instances present on the project - - `links` (list): List of `Link` instances present on the project - - **Returns:** - - `Project` instance - - **Example:** - - ```python - >>> lab = Project(name="lab", connector=) - >>> lab.create() - >>> print(lab.status) - 'opened' - ``` - """ - - name: str | None = None - project_id: str | None = None - status: str | None = None - locked: bool | None = None - path: str | None = None - filename: str | None = None - auto_start: bool | None = None - auto_close: bool | None = None - auto_open: bool | None = None - drawing_grid_size: int | None = None - grid_size: int | None = None - scene_height: int | None = None - scene_width: int | None = None - show_grid: bool | None = None - show_interface_labels: bool | None = None - show_layers: bool | None = None - snap_to_grid: bool | None = None - supplier: Any | None = None - variables: list | None = None - zoom: int | None = None - - stats: dict[str, Any] | None = None - snapshots: list[dict] | None = None - drawings: list[dict] | None = None - nodes: list[Node] = field(default_factory=list, repr=False) - links: list[Link] = field(default_factory=list, repr=False) - connector: Any | None = field(default=None, repr=False) - - @field_validator("status") - @classmethod - def _valid_status(cls, value: Any) -> Any: - if value != "opened" and value != "closed" and value is not None: - raise ValueError("status must be opened or closed") - return value - - def _update(self, data_dict: dict[str, Any]) -> None: - for k, v in data_dict.items(): - if k in self.__dict__: - setattr(self, k, v) - - def get( - self, - get_links: bool = True, - get_nodes: bool = True, - get_stats: bool = True, - ) -> None: - """ - Retrieves the projects information. - - - `get_links`: When true it also queries for the links inside the project - - `get_nodes`: When true it also queries for the nodes inside the project - - `get_stats`: When true it also queries for the stats inside the project - - It `get_stats` is set to `True`, it also verifies if snapshots and drawings are - inside the project and stores them in their respective attributes - (`snapshots` and `drawings`) - - **Required Attributes:** - - - `connector` - - `project_id` or `name` - """ - if not self.connector: - raise ValueError("Gns3Connector not assigned under 'connector'") - - # Get projects if no ID was provided by the name - if not self.project_id: - if not self.name: - raise ValueError("Need to submit either project_id or name") - _url = f"{self.connector.base_url}/projects" - # Get all projects and filter the respective project - _response = self.connector.http_call("get", _url) - - # Filter the respective project - for _project in _response.json(): - if _project.get("name") == self.name: - self.project_id = _project.get("project_id") - - # Get project - _url = f"{self.connector.base_url}/projects/{self.project_id}" - _response = self.connector.http_call("get", _url) - - # Update object - self._update(_response.json()) - - if get_stats: - self.get_stats() - if self.stats is not None: - if self.stats.get("snapshots", 0) > 0: - self.get_snapshots() - if self.stats.get("drawings", 0) > 0: - self.get_drawings() - if get_nodes: - self.get_nodes() - if get_links: - self.get_links() - - def create(self) -> None: - """ - Creates the project. - - **Required Attributes:** - - - `name` - - `connector` - """ - if not self.name: - raise ValueError("Need to submit project name") - if not self.connector: - raise ValueError("Gns3Connector not assigned under 'connector'") - - _url = f"{self.connector.base_url}/projects" - - data = { - k: v - for k, v in self.__dict__.items() - if k - not in ( - "stats", - "nodes", - "links", - "connector", - "__initialised__", - ) - if v is not None - } - - _response = self.connector.http_call("post", _url, json_data=data) - - # Now update it - self._update(_response.json()) - - @verify_connector_and_id - def update(self, **kwargs: Any) -> None: - """ - Updates the project instance by passing the keyword arguments of the attributes - you want updated - - Example: - - ```python - lab.update(auto_close=True) - ``` - - This will update the project `auto_close` attribute to `True` - - **Required Attributes:** - - - `project_id` - - `connector` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}" - - # TODO: Verify that the passed kwargs are supported ones - _response = _conn.http_call("put", _url, json_data=kwargs) - - # Update object - self._update(_response.json()) - - @verify_connector_and_id - def delete(self) -> None: - """ - Deletes the project from the server. It sets to `None` the attributes - `project_id` and `name` when executed successfully - - **Required Attributes:** - - - `project_id` - - `connector` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}" - - _conn.http_call("delete", _url) - - self.project_id = None - self.name = None - - @verify_connector_and_id - def close(self) -> None: - """ - Closes the project on the server. - - **Required Attributes:** - - - `project_id` - - `connector` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/close" - - _response = _conn.http_call("post", _url) - - # Update object - if _response.status_code == 204: - self.status = "closed" - - @verify_connector_and_id - def open(self) -> None: - """ - Opens the project on the server. - - **Required Attributes:** - - - `project_id` - - `connector` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/open" - - _response = _conn.http_call("post", _url) - - # Update object - self._update(_response.json()) - - @verify_connector_and_id - def get_stats(self) -> None: - """ - Retrieve the stats of the project. - - **Required Attributes:** - - - `project_id` - - `connector` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/stats" - - _response = _conn.http_call("get", _url) - - # Update object - self.stats = _response.json() - - @verify_connector_and_id - def get_file(self, path: str) -> str: - """ - Retrieve a file in the project directory. Beware you have warranty to be able to - access only to file global to the project. - - **Required Attributes:** - - - `project_id` - - `connector` - - `path`: Project's relative path of the file - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/files/{path}" - - return cast(str, _conn.http_call("get", _url).text) - - @verify_connector_and_id - def write_file(self, path: str, data: Any) -> None: - """ - Places a file content on a specified project file path. Beware you have warranty - to be able to access only to file global to the project. - - Example to create a README.txt for the project: - - ```python - >>> data = ''' - This is a README description! - ''' - - >>> project.write_file(path='README.txt', data=data) - ``` - - **Required Attributes:** - - - `project_id` - - `connector` - - `path`: Project's relative path of the file - - `data`: Data to be included in the file - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/files/{path}" - - _conn.http_call("post", _url, data=data) - - @verify_connector_and_id - def get_nodes(self) -> None: - """ - Retrieve the nodes of the project. - - **Required Attributes:** - - - `project_id` - - `connector` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/nodes" - - _response = _conn.http_call("get", _url) - - # Create the Nodes array but cleanup cache if there is one - if self.nodes: - self.nodes = [] - for _node in _response.json(): - _n = Node(connector=self.connector, **_node) - _n.project_id = self.project_id - self.nodes.append(_n) - - @verify_connector_and_id - def get_links(self) -> None: - """ - Retrieve the links of the project. - - **Required Attributes:** - - - `project_id` - - `connector` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/links" - - _response = _conn.http_call("get", _url) - - # Create the Nodes array but cleanup cache if there is one - if self.links: - self.links = [] - for _link in _response.json(): - _l = Link(connector=self.connector, **_link) - _l.project_id = self.project_id - self.links.append(_l) - - @verify_connector_and_id - def start_nodes(self, poll_wait_time: int = 5) -> None: - """ - Starts all the nodes inside the project. - - - `poll_wait_time` is used as a delay when performing the next query of the - nodes status. - - **Required Attributes:** - - - `project_id` - - `connector` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/nodes/start" - - _conn.http_call("post", _url) - - # Update object - time.sleep(poll_wait_time) - self.get_nodes() - - @verify_connector_and_id - def stop_nodes(self, poll_wait_time: int = 5) -> None: - """ - Stops all the nodes inside the project. - - - `poll_wait_time` is used as a delay when performing the next query of the - nodes status. - - **Required Attributes:** - - - `project_id` - - `connector` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/nodes/stop" - - _conn.http_call("post", _url) - - # Update object - time.sleep(poll_wait_time) - self.get_nodes() - - @verify_connector_and_id - def reload_nodes(self, poll_wait_time: int = 5) -> None: - """ - Reloads all the nodes inside the project. - - - `poll_wait_time` is used as a delay when performing the next query of the - nodes status. - - **Required Attributes:** - - - `project_id` - - `connector` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/nodes/reload" - - _conn.http_call("post", _url) - - # Update object - time.sleep(poll_wait_time) - self.get_nodes() - - @verify_connector_and_id - def suspend_nodes(self, poll_wait_time: int = 5) -> None: - """ - Suspends all the nodes inside the project. - - - `poll_wait_time` is used as a delay when performing the next query of the - nodes status. - - **Required Attributes:** - - - `project_id` - - `connector` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/nodes/suspend" - - _conn.http_call("post", _url) - - # Update object - time.sleep(poll_wait_time) - self.get_nodes() - - def nodes_summary( - self, is_print: bool = True - ) -> list[tuple[Any, ...]] | None: - """ - Returns a summary of the nodes insode the project. If `is_print` is `False`, it - will return a list of tuples like: - - `[(node_name, node_status, node_console, node_id) ...]` - - **Required Attributes:** - - - `project_id` - - `connector` - """ - - if not self.nodes: - self.get_nodes() - - _nodes_summary = [] - for _n in self.nodes: - if is_print: - print( - f"{_n.name}: {_n.status} -- Console: {_n.console} -- " - f"ID: {_n.node_id}" - ) - _nodes_summary.append((_n.name, _n.status, _n.console, _n.node_id)) - - return _nodes_summary if not is_print else None - - def nodes_inventory(self) -> dict[str | None, Any]: - """ - Returns an inventory-style dictionary of the nodes - - Example: - - `{ - "router01": { - "server": "127.0.0.1", - "name": "router01", - "node_id": uuid, - "console_port": 5077, - "type": "vEOS", - "ports": "[port detila]", - "x": 100, - "y": 200 - } - }` - - **Required Attributes:** - - - `project_id` - - `connector` - """ - - if not self.nodes: - self.get_nodes() - - _nodes_inventory = {} - conn = self.connector - if not conn: - raise ValueError( - "Gns3Connector not assigned. Please set the connector first." - ) - - _server = urlparse(conn.base_url).hostname - - for _n in self.nodes: - _nodes_inventory.update( - { - _n.name: { - "server": _server, - "name": _n.name, - "node_id": _n.node_id, - "console_port": _n.console, - "console_type": _n.console_type, - "type": _n.node_type, - "ports": _n.ports, - "status": _n.status, - # "template": _n.template, - "x": _n.x, - "y": _n.y, - "tags": _n.tags if _n.tags else [], - "netmiko_device_type": _n.netmiko_device_type, - "default_username": _n.default_username, - "default_password": _n.default_password, - } - } - ) - - return _nodes_inventory - - def links_summary( - self, is_print: bool = True - ) -> list[dict[str, str]] | None: - """ - Returns a summary of the links inside the project. If `is_print` is False, - it will return a list of dicts like: - - `[{"link_id": "xxx", "node_a": "R1", "port_a": "Eth0/0", "node_b": "R2", "port_b": "Eth0/0"}, ...]` - - **Required Attributes:** - - - `project_id` - - `connector` - """ - # Ensure data is loaded - if not self.nodes: - self.get_nodes() - if not self.links: - self.get_links() - # If None, program errors here instead of continuing - assert self.links is not None, "Links must be loaded" - assert self.nodes is not None, "Nodes must be loaded" - - _links_summary: list[dict[str, str]] = [] - - for _l in self.links: - if not _l.nodes: - continue - _side_a = _l.nodes[0] - _side_b = _l.nodes[1] - - try: - # Add type-safe lookup logic - _node_a = next( - x for x in self.nodes if x.node_id == _side_a["node_id"] - ) - # Ensure getting str to resolve [return-value] error - _port_a = str( - next( - x["name"] - for x in (_node_a.ports or []) - if x["port_number"] == _side_a["port_number"] - and x["adapter_number"] == _side_a["adapter_number"] - ) - ) - - _node_b = next( - x for x in self.nodes if x.node_id == _side_b["node_id"] - ) - _port_b = str( - next( - x["name"] - for x in (_node_b.ports or []) - if x["port_number"] == _side_b["port_number"] - and x["adapter_number"] == _side_b["adapter_number"] - ) - ) - - # Ensure name is not None - name_a = str(_node_a.name) if _node_a.name else "Unknown" - name_b = str(_node_b.name) if _node_b.name else "Unknown" - - endpoint_a = f"{name_a}: {_port_a}" - endpoint_b = f"{name_b}: {_port_b}" - - if is_print: - print(f"{endpoint_a} ---- {endpoint_b}") - - _links_summary.append({ - "link_id": _l.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 list comprehension can't match data - continue - return _links_summary if not is_print else None - - def _search_node(self, key: str, value: Any) -> Any | None: - "Performs a search based on a key and value" - # Retrive nodes if neccesary - if not self.nodes: - self.get_nodes() - - try: - return [_p for _p in self.nodes if getattr(_p, key) == value][0] - except IndexError: - return None - - def get_node( - self, name: str | None = None, node_id: str | None = None - ) -> Any | None: - """ - Returns the Node object by searching for the `name` or the `node_id`. - - **Required Attributes:** - - - `project_id` - - `connector` - - **Required keyword arguments:** - - `name` or `node_id` - - **NOTE:** Run method `get_nodes()` manually to refresh list of nodes if - necessary - """ - if node_id: - return self._search_node(key="node_id", value=node_id) - elif name: - return self._search_node(key="name", value=name) - else: - raise ValueError("name or node_ide must be provided") - - def _search_link(self, key: str, value: Any) -> Any | None: - "Performs a search based on a key and value" - # Retrive links if neccesary - if not self.links: - self.get_links() - - try: - return next(_p for _p in self.links if getattr(_p, key) == value) - except StopIteration: - return None - - def get_link(self, link_id: str) -> Any | None: - """ - Returns the Link object by locating its ID - - **Required Attributes:** - - - `project_id` - - `connector` - - `link_id` - - **NOTE:** Run method `get_links()` manually to refresh list of links if - necessary - """ - return self._search_link(key="link_id", value=link_id) - - def create_node(self, **kwargs: Any) -> None: - """ - Creates a node. To know available parameters see `Node` object, specifically - the `create` method. The most basic example would be: - - ```python - project.create_node(name='test-switch01', template='Ethernet switch') - ``` - - **Required Project instance attributes:** - - - `project_id` - - `connector` - - **Required keyword aguments:** - - - `template` or `template_id` - """ - if not self.nodes: - self.get_nodes() - - _node = Node( - project_id=self.project_id, connector=self.connector, **kwargs - ) - - _node.create() - self.nodes.append(_node) - print( - f"Created: {_node.name} -- Type: {_node.node_type} -- " - f"Console: {_node.console}" - ) - - def create_link( - self, node_a: str, port_a: str, node_b: str, port_b: str - ) -> None: - """ - Creates a link. - - **Required Attributes:** - - - `project_id` - - `connector` - - `node_a`: Node name of the A side - - `port_a`: Port name of the A side (must match the `name` attribute of the - port) - - `node_b`: Node name of the B side - - `port_b`: Port name of the B side (must match the `name` attribute of the - port) - """ - if not self.nodes: - self.get_nodes() - if not self.links: - self.get_links() - - _node_a = self.get_node(name=node_a) - if not _node_a: - raise ValueError(f"node_a: {node_a} not found") - try: - _port_a = [_p for _p in _node_a.ports if _p["name"] == port_a][0] - except IndexError: - raise ValueError(f"port_a: {port_a} not found") from None - - _node_b = self.get_node(name=node_b) - if not _node_b: - raise ValueError(f"node_b: {node_b} not found") - try: - _port_b = [_p for _p in _node_b.ports if _p["name"] == port_b][0] - except IndexError: - raise ValueError(f"port_b: {port_b} not found") from None - - _matches = [] - for _l in self.links: - if not _l.nodes: - continue - if ( - _l.nodes[0]["node_id"] == _node_a.node_id - and _l.nodes[0]["adapter_number"] == _port_a["adapter_number"] - and _l.nodes[0]["port_number"] == _port_a["port_number"] - ): - _matches.append(_l) - elif ( - _l.nodes[1]["node_id"] == _node_b.node_id - and _l.nodes[1]["adapter_number"] == _port_b["adapter_number"] - and _l.nodes[1]["port_number"] == _port_b["port_number"] - ): - _matches.append(_l) # pragma: no cover - if _matches: - raise ValueError( - f"At least one port is used, ID: {_matches[0].link_id}" - ) - - # Now create the link! - _link = Link( - project_id=self.project_id, - connector=self.connector, - nodes=[ - { - "node_id": _node_a.node_id, - "adapter_number": _port_a["adapter_number"], - "port_number": _port_a["port_number"], - "label": {"text": _port_a.get("short_name") or _port_a["name"]}, - }, - { - "node_id": _node_b.node_id, - "adapter_number": _port_b["adapter_number"], - "port_number": _port_b["port_number"], - "label": {"text": _port_b.get("short_name") or _port_b["name"]}, - }, - ], - ) - - _link.create() - self.links.append(_link) - print(f"Created Link-ID: {_link.link_id} -- Type: {_link.link_type}") - - def delete_link( - self, node_a: str, port_a: str, node_b: str, port_b: str - ) -> None: - """ - Deletes a link. - - **Required Attributes:** - - - `project_id` - - `connector` - - `node_a`: Node name of the A side - - `port_a`: Port name of the A side (must match the `name` attribute of the - port) - - `node_b`: Node name of the B side - - `port_b`: Port name of the B side (must match the `name` attribute of the - port) - """ - if not self.nodes: - self.get_nodes() # pragma: no cover - if not self.links: - self.get_links() # pragma: no cover - - # checking link info - _node_a = self.get_node(name=node_a) - if not _node_a: - raise ValueError(f"node_a: {node_a} not found") - try: - _port_a = [_p for _p in _node_a.ports if _p["name"] == port_a][0] - except IndexError: - raise ValueError(f"port_a: {port_a} not found") from None - - _node_b = self.get_node(name=node_b) - if not _node_b: - raise ValueError(f"node_b: {node_b} not found") - try: - _port_b = [_p for _p in _node_b.ports if _p["name"] == port_b][0] - except IndexError: - raise ValueError(f"port_b: {port_b} not found") from None - - _matches = [] - for _l in self.links: - if not _l.nodes: - continue - if ( - _l.nodes[0]["node_id"] == _node_a.node_id - and _l.nodes[0]["adapter_number"] == _port_a["adapter_number"] - and _l.nodes[0]["port_number"] == _port_a["port_number"] - ): - _matches.append(_l) - elif ( - _l.nodes[1]["node_id"] == _node_b.node_id - and _l.nodes[1]["adapter_number"] == _port_b["adapter_number"] - and _l.nodes[1]["port_number"] == _port_b["port_number"] - ): - _matches.append(_l) - if not _matches: - raise ValueError( - f"Link not found: {node_a, port_a, node_b, port_b}" - ) # pragma: no cover - - # now to delete the link via GNS3_api - _link = _matches[0] - self.links.remove(_link) - _link_id = _link.link_id - _link.delete() - print( - f"Deleted Link-ID: {_link_id} From node {node_a}, port: {port_a} <--> " - f"to node {node_b}, port: {port_b}" - ) - - @verify_connector_and_id - def get_snapshots(self) -> None: - """ - Retrieves list of snapshots of the project - - **Required Project instance attributes:** - - - `project_id` - - `connector` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/snapshots" - - _response = _conn.http_call("get", _url) - self.snapshots = _response.json() - - def _search_snapshot(self, key: str, value: Any) -> dict[str, Any] | None: - "Performs a search based on a key and value" - if not self.snapshots: - self.get_snapshots() - - try: - return next( - _p for _p in (self.snapshots or []) if _p[key] == value - ) - except StopIteration: - return None - - def get_snapshot( - self, name: str | None = None, snapshot_id: str | None = None - ) -> dict[str, Any] | None: - """ - Returns the Snapshot by searching for the `name` or the `snapshot_id`. - - **Required Attributes:** - - - `project_id` - - `connector` - - **Required keyword arguments:** - - `name` or `snapshot_id` - """ - if snapshot_id: - return self._search_snapshot(key="snapshot_id", value=snapshot_id) - elif name: - return self._search_snapshot(key="name", value=name) - else: - raise ValueError("name or snapshot_id must be provided") - - @verify_connector_and_id - def create_snapshot(self, name: str) -> None: - """ - Creates a snapshot of the project - - **Required Project instance attributes:** - - - `project_id` - - `connector` - - **Required keyword aguments:** - - - `name` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - self.get_snapshots() - - _snapshot = self.get_snapshot(name=name) - if _snapshot: - raise ValueError("Snapshot already created") - - _url = f"{_conn.nector.base_url}/projects/{_project_id}/snapshots" - - _response = _conn.http_call("post", _url, json_data={"name": name}) - - _snapshot = _response.json() - - if self.snapshots is None: - self.snapshots = [] - - self.snapshots.append(_snapshot) - print(f"Created snapshot: {_snapshot['name']}") - - @verify_connector_and_id - def delete_snapshot( - self, name: str | None = None, snapshot_id: str | None = None - ) -> None: - """ - Deletes a snapshot of the project - - **Required Project instance attributes:** - - - `project_id` - - `connector` - - **Required keyword aguments:** - - - `name` or `snapshot_id` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - self.get_snapshots() - - _snapshot = self.get_snapshot(name=name, snapshot_id=snapshot_id) - if not _snapshot: - raise ValueError("Snapshot not found") - - _url = ( - f"{_conn.base_url}/projects/{_project_id}/snapshots/" - f"{_snapshot['snapshot_id']}" - ) - - _conn.http_call("delete", _url) - - self.get_snapshots() - - @verify_connector_and_id - def restore_snapshot( - self, name: str | None = None, snapshot_id: str | None = None - ) -> None: - """ - Restore a snapshot from disk - - **Required Project instance attributes:** - - - `project_id` - - `connector` - - **Required keyword aguments:** - - - `name` or `snapshot_id` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - self.get_snapshots() - - _snapshot = self.get_snapshot(name=name, snapshot_id=snapshot_id) - if not _snapshot: - raise ValueError("Snapshot not found") - - _url = ( - f"{_conn.base_url}/projects/{_project_id}/snapshots/" - f"{_snapshot['snapshot_id']}/restore" - ) - - _conn.http_call("post", _url) - - # Update the whole project - self.get() - - def arrange_nodes_circular(self, radius: int = 120) -> None: - """ - Re-arrgange the existing nodes - in a circular fashion - - **Attributes:** - - - project instance created - - **Example** - - ```python - >>> proj = Project(name='project_name', connector=Gns3connector) - >>> proj.arrange_nodes() - ``` - """ - - self.get() - if self.status != "opened": - self.open() # pragma: no cover - - _angle = (2 * pi) / len(self.nodes) - # The Y Axis is inverted in GNS3, so the -Y is UP - for index, n in enumerate(self.nodes): - _x = int(radius * (sin(_angle * index))) - _y = int(radius * (-cos(_angle * index))) - n.update(x=_x, y=_y) - - def get_drawing( - self, drawing_id: str | None = None - ) -> dict[str, Any] | None: - """ - Returns the drawing by searching for the `svg` or the `drawing_id`. - - **Required Attributes:** - - - `project_id` - - `connector` - - **Required keyword arguments:** - - `svg` or `drawing_id` - """ - if not self.drawings: - self.get_drawings() - - try: - return next( - _drawing - for _drawing in (self.drawings or []) - if _drawing["drawing_id"] == drawing_id - ) - except (StopIteration, KeyError, TypeError): - return None - - @verify_connector_and_id - def get_drawings(self) -> None: - """ - Retrieves list of drawings of the project - - **Required Project instance attributes:** - - - `project_id` - - `connector` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/drawings" - - _response = _conn.http_call("get", _url) - self.drawings = _response.json() - - @verify_connector_and_id - def create_drawing( - self, - svg: str, - x: int = 0, - y: int = 0, - z: int = 0, - locked: bool = False, - rotation: int = 0, - ) -> dict[str, Any]: - """ - Creates a new drawing in the project - - API: POST /v2/projects/{project_id}/drawings - - Required Project instance attributes: - - - `project_id` - - `connector` - - Required parameters: - - - `svg`: SVG content string - - Optional parameters: - - - `x`: X coordinate (default: 0) - - `y`: Y coordinate (default: 0) - - `z`: Z layer (default: 0) - - `locked`: Whether to lock the drawing (default: False) - - `rotation`: Rotation angle in degrees, range -359 to 359 (default: 0) - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/drawings" - - # Prepare request body - request_body = { - "svg": svg, - "x": x, - "y": y, - "z": z, - "locked": locked, - "rotation": rotation, - } - - # Send POST request to create drawing - _response = _conn.http_call("post", _url, json_data=request_body) - - # Refresh drawings list - self.get_drawings() - - return cast(dict[str, Any], _response.json()) - - @verify_connector_and_id - def update_drawing( - self, - drawing_id: str, - svg: str | None = None, - locked: bool | None = None, - x: int | None = None, - y: int | None = None, - z: int | None = None, - ) -> dict[str, Any]: - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - _url = f"{_conn.base_url}/projects/{_project_id}/drawings/{drawing_id}" - - # Ensure data exists - if not self.drawings: - self.get_drawings() - - # Type guard: inform Mypy that self.drawings is now an iterable list - # Use or [] with next to find target object - current_drawing = next( - ( - d - for d in (self.drawings or []) - if d.get("drawing_id") == drawing_id - ), - None, - ) - - if current_drawing is None: - raise ValueError( - f"Drawing with ID {drawing_id} not found in project." - ) - - # If parameter is None, get original value from current object - # This way, Mypy won't report errors for list comprehensions of each field - final_svg = svg if svg is not None else current_drawing.get("svg") - final_locked = ( - locked if locked is not None else current_drawing.get("locked") - ) - final_x = x if x is not None else current_drawing.get("x") - final_y = y if y is not None else current_drawing.get("y") - final_z = z if z is not None else current_drawing.get("z") - - # Execute update - response = _conn.http_call( - "put", - _url, - json_data={ - "svg": final_svg, - "locked": final_locked, - "x": final_x, - "y": final_y, - "z": final_z, - }, - ) - - # Update local cache - self.get_drawings() - - return cast(dict[str, Any], response.json()) - - @verify_connector_and_id - def delete_drawing(self, drawing_id: str | None = None) -> None: - """ - Deletes a drawing of the project - - **Required Project instance attributes:** - - - `project_id` - - `connector` - - **Required keyword aguments:** - - - `drawing_id` - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - self.get_drawings() - - _drawing = self.get_drawing(drawing_id=drawing_id) - if not _drawing: - raise ValueError("drawing not found") - - _url = ( - f"{_conn.base_url}/projects/{_project_id}/drawings/" - f"{_drawing['drawing_id']}" - ) - - _conn.http_call("delete", _url) - - self.get_drawings() - - @verify_connector_and_id - def get_locked(self) -> bool: - """ - Retrieve locked status of the project. - - Returns whether the project is locked or not. - - API: GET /v3/projects/{project_id}/locked - - Required Attributes: - - - `project_id` - - `connector` - - Returns: - bool: True if project is locked, False otherwise - - Raises: - ValueError: If called with GNS3 API v2 (not supported) - - Note: - This method is only available in GNS3 v3 API - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - # Check API version - only v3 supports lock operations - if _conn.api_version != 3: - raise ValueError( - "Project lock/unlock operations are only supported in GNS3 API v3. " - f"Current API version: v{_conn.api_version}" - ) - - _url = f"{_conn.base_url}/projects/{_project_id}/locked" - - _response = _conn.http_call("get", _url) - locked_status = cast(bool, _response.json()) - - # Update the locked attribute - self.locked = locked_status - - return locked_status - - @verify_connector_and_id - def lock_project(self) -> None: - """ - Lock all drawings and nodes in the project. - - API: POST /v3/projects/{project_id}/lock - - Required Attributes: - - - `project_id` - - `connector` - - Raises: - ValueError: If called with GNS3 API v2 (not supported) - - Note: - This method is only available in GNS3 v3 API - Returns 204 on success (no content) - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - # Check API version - only v3 supports lock operations - if _conn.api_version != 3: - raise ValueError( - "Project lock/unlock operations are only supported in GNS3 API v3. " - f"Current API version: v{_conn.api_version}" - ) - - _url = f"{_conn.base_url}/projects/{_project_id}/lock" - - _conn.http_call("post", _url) - - # Update the locked attribute - self.locked = True - - @verify_connector_and_id - def unlock_project(self) -> None: - """ - Unlock all drawings and nodes in the project. - - API: POST /v3/projects/{project_id}/unlock - - Required Attributes: - - - `project_id` - - `connector` - - Raises: - ValueError: If called with GNS3 API v2 (not supported) - - Note: - This method is only available in GNS3 v3 API - Returns 204 on success (no content) - """ - _conn = self.connector - assert _conn is not None - _project_id = self.project_id - assert _project_id is not None - - # Check API version - only v3 supports lock operations - if _conn.api_version != 3: - raise ValueError( - "Project lock/unlock operations are only supported in GNS3 API v3. " - f"Current API version: v{_conn.api_version}" - ) - - _url = f"{_conn.base_url}/projects/{_project_id}/unlock" - - _conn.http_call("post", _url) - - # Update the locked attribute - self.locked = False diff --git a/gns3server/agent/gns3_copilot/gns3_client/gns3_project_info.py b/gns3server/agent/gns3_copilot/gns3_client/gns3_project_info.py index f9029489a..eedb72a00 100644 --- a/gns3server/agent/gns3_copilot/gns3_client/gns3_project_info.py +++ b/gns3server/agent/gns3_copilot/gns3_client/gns3_project_info.py @@ -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, ) diff --git a/gns3server/agent/gns3_copilot/gns3_client/gns3_topology_reader.py b/gns3server/agent/gns3_copilot/gns3_client/gns3_topology_reader.py index 8ecee9409..59bd33507 100644 --- a/gns3server/agent/gns3_copilot/gns3_client/gns3_topology_reader.py +++ b/gns3server/agent/gns3_copilot/gns3_client/gns3_topology_reader.py @@ -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 diff --git a/gns3server/agent/gns3_copilot/gns3_client/project_inventory.py b/gns3server/agent/gns3_copilot/gns3_client/project_inventory.py new file mode 100644 index 000000000..a4b6782da --- /dev/null +++ b/gns3server/agent/gns3_copilot/gns3_client/project_inventory.py @@ -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 . +# +# 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), + } diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_create_link.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_create_link.py index 677f7d642..86d28769f 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/gns3_create_link.py +++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_create_link.py @@ -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, diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_create_node.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_create_node.py index 49f7188fe..e03e42ec4 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/gns3_create_node.py +++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_create_node.py @@ -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", } diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_get_node_temp.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_get_node_temp.py index fabd78f5a..e10e6390f 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/gns3_get_node_temp.py +++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_get_node_temp.py @@ -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 = [] diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_packet_filter.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_packet_filter.py index d4afb4b60..562a80f1b 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/gns3_packet_filter.py +++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_packet_filter.py @@ -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", } diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_start_node.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_start_node.py index dc9a5fa10..8e749d35a 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/gns3_start_node.py +++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_start_node.py @@ -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 = [ diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_stop_node.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_stop_node.py index 66d6de515..a6cdfc2a2 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/gns3_stop_node.py +++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_stop_node.py @@ -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", } ) diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_suspend_node.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_suspend_node.py index 0bef3190c..c7d28025e 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/gns3_suspend_node.py +++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_suspend_node.py @@ -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", } ) diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_update_node_name.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_update_node_name.py index eca74795f..f6b00171b 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/gns3_update_node_name.py +++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_update_node_name.py @@ -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", } diff --git a/gns3server/agent/mcp/__init__.py b/gns3server/agent/mcp/__init__.py index 47438b9c8..538f0d6e7 100644 --- a/gns3server/agent/mcp/__init__.py +++ b/gns3server/agent/mcp/__init__.py @@ -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( diff --git a/gns3server/agent/mcp/appliances.py b/gns3server/agent/mcp/appliances.py index a2bc9dd0f..dd2be1735 100644 --- a/gns3server/agent/mcp/appliances.py +++ b/gns3server/agent/mcp/appliances.py @@ -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"], diff --git a/gns3server/agent/mcp/computes.py b/gns3server/agent/mcp/computes.py index 29072ea04..e3abb65bb 100644 --- a/gns3server/agent/mcp/computes.py +++ b/gns3server/agent/mcp/computes.py @@ -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"], diff --git a/gns3server/agent/mcp/drawings.py b/gns3server/agent/mcp/drawings.py index 6e47a2e02..e9135b0f9 100644 --- a/gns3server/agent/mcp/drawings.py +++ b/gns3server/agent/mcp/drawings.py @@ -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"], diff --git a/gns3server/agent/mcp/images.py b/gns3server/agent/mcp/images.py index 0022e1554..3b8169a71 100644 --- a/gns3server/agent/mcp/images.py +++ b/gns3server/agent/mcp/images.py @@ -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"], diff --git a/gns3server/agent/mcp/links.py b/gns3server/agent/mcp/links.py deleted file mode 100644 index 314f8b8c9..000000000 --- a/gns3server/agent/mcp/links.py +++ /dev/null @@ -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 . - -""" -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, - }, -] diff --git a/gns3server/agent/mcp/nodes.py b/gns3server/agent/mcp/nodes.py deleted file mode 100644 index 3d3a7133a..000000000 --- a/gns3server/agent/mcp/nodes.py +++ /dev/null @@ -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 . - -""" -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, - }, -] diff --git a/gns3server/agent/mcp/projects.py b/gns3server/agent/mcp/projects.py index ebcdc65c0..24221db5c 100644 --- a/gns3server/agent/mcp/projects.py +++ b/gns3server/agent/mcp/projects.py @@ -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"], diff --git a/gns3server/agent/mcp/server.py b/gns3server/agent/mcp/server.py index 53743c900..0f8ac4abb 100644 --- a/gns3server/agent/mcp/server.py +++ b/gns3server/agent/mcp/server.py @@ -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"], diff --git a/gns3server/agent/mcp/snapshots.py b/gns3server/agent/mcp/snapshots.py index e5b1685db..f428292b9 100644 --- a/gns3server/agent/mcp/snapshots.py +++ b/gns3server/agent/mcp/snapshots.py @@ -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"], diff --git a/gns3server/agent/mcp/symbols.py b/gns3server/agent/mcp/symbols.py index 6026ee51f..8625d3e0c 100644 --- a/gns3server/agent/mcp/symbols.py +++ b/gns3server/agent/mcp/symbols.py @@ -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"], diff --git a/gns3server/agent/mcp/templates.py b/gns3server/agent/mcp/templates.py index af8eeaf40..7c0f678bb 100644 --- a/gns3server/agent/mcp/templates.py +++ b/gns3server/agent/mcp/templates.py @@ -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"], diff --git a/tests/agent/mcp/test_handlers.py b/tests/agent/mcp/test_handlers.py index d9c3490c0..42738dc37 100644 --- a/tests/agent/mcp/test_handlers.py +++ b/tests/agent/mcp/test_handlers.py @@ -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, ) diff --git a/tests/agent/mcp/test_tool_params.py b/tests/agent/mcp/test_tool_params.py index fe46ea178..281293c56 100644 --- a/tests/agent/mcp/test_tool_params.py +++ b/tests/agent/mcp/test_tool_params.py @@ -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 diff --git a/tests/agent/test_custom_gns3fy.py b/tests/agent/test_gns3_client.py similarity index 58% rename from tests/agent/test_custom_gns3fy.py rename to tests/agent/test_gns3_client.py index 783ec59ed..36e57fd72 100644 --- a/tests/agent/test_custom_gns3fy.py +++ b/tests/agent/test_gns3_client.py @@ -16,79 +16,113 @@ # along with this program. If not, see . """ -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: 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 From 702fc1f6d9aa4fd19bb8d540ec955d866ff5e885 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Tue, 25 Aug 2026 13:17:37 +0800 Subject: [PATCH 02/14] fix: never allow the projects directory to become a project directory Loading a .gns3 placed directly in the projects root registered the shared projects directory as the project path (load_project derives the path from the file's parent directory). Deleting such an entry ran rmtree on the projects directory itself, wiping every project until a root-owned file stopped it, and left a zombie entry in the controller. Three layers of protection: - Controller.load_project() refuses a .gns3 whose parent directory is the projects directory; the normal subdirectory layout is unaffected - the Project.path setter rejects the projects directory itself and its ancestors, closing the same hole for POST/PUT with an explicit path - Project.delete() uses realpath + commonpath instead of commonprefix: entries whose path is the projects root are refused, and sibling directories sharing a string prefix (/srv/projects-evil vs /srv/projects) are no longer treated as inside the projects dir Also removes the project_load MCP tool: loading by raw server filesystem path is a footgun for automated clients; projects can still be opened by project_id via the remaining tools. --- docs/features/mcp-service.md | 3 +- gns3server/agent/mcp/__init__.py | 12 +---- gns3server/agent/mcp/projects.py | 9 ---- gns3server/controller/__init__.py | 11 +++++ gns3server/controller/project.py | 24 +++++++++- tests/agent/mcp/test_tool_params.py | 1 - tests/api/routes/controller/test_projects.py | 5 ++ tests/controller/test_controller.py | 48 ++++++++++++++++++++ tests/controller/test_import_project.py | 3 +- tests/controller/test_project.py | 31 +++++++++++++ tests/controller/test_project_open.py | 6 ++- 11 files changed, 125 insertions(+), 28 deletions(-) diff --git a/docs/features/mcp-service.md b/docs/features/mcp-service.md index e1f45fc3e..3115f1bab 100644 --- a/docs/features/mcp-service.md +++ b/docs/features/mcp-service.md @@ -96,7 +96,7 @@ All subsequent tool handler REST API calls use this JWT → zero extra bcrypt **82 tools** across 12 categories: -### Project (15) +### Project (14) | Tool | Description | |------|-------------| @@ -113,7 +113,6 @@ All subsequent tool handler REST API calls use this JWT → zero extra bcrypt | `project_readme_update` | Update project README | | `project_lock` | Lock project (prevent edits) | | `project_unlock` | Unlock project | -| `project_load` | Load project from path | | `project_locked` | Check if project is locked | ### Node (22) diff --git a/gns3server/agent/mcp/__init__.py b/gns3server/agent/mcp/__init__.py index 538f0d6e7..608070f0f 100644 --- a/gns3server/agent/mcp/__init__.py +++ b/gns3server/agent/mcp/__init__.py @@ -61,7 +61,7 @@ from .projects import ( get_project_stats_handler, update_project_handler, duplicate_project_handler, get_project_readme_handler, update_project_readme_handler, lock_project_handler, unlock_project_handler, - load_project_handler, get_locked_project_handler, + get_locked_project_handler, ) from .server import ( get_version_handler, get_statistics_handler, @@ -1264,16 +1264,6 @@ async def project_locked( }) -@mcp.tool() -async def project_load( - path: Annotated[str, Field(description="Filesystem path to the .gns3 project file")], -) -> list[dict[str, Any]]: - """Load a project from a file path on the server's filesystem.""" - return await asyncio.to_thread(_run_handler_sync, load_project_handler, { - "path": path, - }) - - # ── Server info tools ───────────────────────────────────────────────── diff --git a/gns3server/agent/mcp/projects.py b/gns3server/agent/mcp/projects.py index 24221db5c..e095354a1 100644 --- a/gns3server/agent/mcp/projects.py +++ b/gns3server/agent/mcp/projects.py @@ -176,15 +176,6 @@ def unlock_project_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> return {"message": f"Project {project_id} unlocked", "project_id": project_id} -def load_project_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: - path = params.get("path") - if not path: - return {"error": "path is required"} - conn = _get_connector(gns3_ctx) - result = conn.http_call("post", f"{conn.base_url}/projects/load", json_data={"path": path}).json() - return {"message": f"Project loaded from {path}", "project": result} - - def get_locked_project_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: project_id = params.get("project_id") if not project_id: diff --git a/gns3server/controller/__init__.py b/gns3server/controller/__init__.py index 9a519a616..9593ee1cc 100644 --- a/gns3server/controller/__init__.py +++ b/gns3server/controller/__init__.py @@ -745,6 +745,17 @@ class Controller: if not os.path.exists(path): raise ControllerError(f"'{path}' does not exist on the controller") + # A .gns3 file must live in its own directory: the project path is + # the file's parent directory. A file placed directly in the + # projects directory would register the shared projects root as the + # project directory, and deleting that project would wipe every + # project on the controller. + projects_path = os.path.realpath(self.projects_directory()) + if os.path.realpath(os.path.dirname(path)) == projects_path: + raise ControllerError( + f"'{path}' cannot be loaded: the .gns3 file must be in its own subdirectory of '{projects_path}'" + ) + topo_data = load_topology(path) topo_data.pop("topology") topo_data.pop("version") diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index ab39b9496..d6563ead0 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -471,6 +471,18 @@ class Project: @path.setter def path(self, path): check_path_allowed(path) + + # The projects directory itself (or one of its ancestors) must + # never become a project directory: deleting such a "project" + # would wipe every project on the controller. + real_path = os.path.realpath(path) + real_projects_path = os.path.realpath(get_default_project_directory()) + if os.path.commonpath([real_path, real_projects_path]) == real_path: + raise ControllerForbiddenError( + f"The project directory cannot be '{path}': it must be a subdirectory " + f"of '{real_projects_path}', not the projects directory itself or one of its parents" + ) + try: os.makedirs(path, exist_ok=True) except OSError as e: @@ -1608,11 +1620,19 @@ class Project: await self._cleanup_web_wireshark_container() try: - project_directory = get_default_project_directory() - if not os.path.commonprefix([project_directory, self.path]) == project_directory: + project_directory = os.path.realpath(get_default_project_directory()) + path = os.path.realpath(self.path) + if os.path.commonpath([path, project_directory]) != project_directory: raise ControllerError( f"Project '{self._name}' cannot be deleted because it is not in the default project directory: '{project_directory}'" ) + if path == project_directory: + # A poisoned or hand-crafted entry whose path is the + # projects root itself must never be deletable: rmtree + # would wipe every project on the controller. + raise ControllerError( + f"Project '{self._name}' cannot be deleted because its directory is the projects directory itself: '{path}'" + ) shutil.rmtree(self.path) except OSError as e: raise ControllerError(f"Cannot delete project directory {self.path}: {str(e)}") diff --git a/tests/agent/mcp/test_tool_params.py b/tests/agent/mcp/test_tool_params.py index 281293c56..06f4ae173 100644 --- a/tests/agent/mcp/test_tool_params.py +++ b/tests/agent/mcp/test_tool_params.py @@ -36,7 +36,6 @@ HANDLER_FILES = { "lock_project_handler": "projects.py", "unlock_project_handler": "projects.py", "get_locked_project_handler": "projects.py", - "load_project_handler": "projects.py", "get_nodes_handler": API_HANDLERS_FILE, "get_node_handler": API_HANDLERS_FILE, "start_node_handler": API_HANDLERS_FILE, diff --git a/tests/api/routes/controller/test_projects.py b/tests/api/routes/controller/test_projects.py index 3f45d0ba6..aa7ae8d36 100644 --- a/tests/api/routes/controller/test_projects.py +++ b/tests/api/routes/controller/test_projects.py @@ -55,6 +55,11 @@ class TestControllerProjectRoutes: params = {"name": "test", "path": str(config.settings.Server.projects_path), "project_id": "00010203-0405-0607-0809-0a0b0c0d0e0f"} response = await client.post(app.url_path_for("create_project"), json=params) + # The projects directory itself must never become a project directory + assert response.status_code == status.HTTP_403_FORBIDDEN + + params = {"name": "test", "path": os.path.join(str(config.settings.Server.projects_path), "custom"), "project_id": "00010203-0405-0607-0809-0a0b0c0d0e0f"} + response = await client.post(app.url_path_for("create_project"), json=params) assert response.status_code == status.HTTP_201_CREATED assert response.json()["name"] == "test" assert response.json()["project_id"] == "00010203-0405-0607-0809-0a0b0c0d0e0f" diff --git a/tests/controller/test_controller.py b/tests/controller/test_controller.py index 741b2cbce..4f158c9b3 100644 --- a/tests/controller/test_controller.py +++ b/tests/controller/test_controller.py @@ -125,6 +125,54 @@ async def test_load_projects_skip_unexpected_errors(controller, projects_dir): mock_load_project.assert_called_with(os.path.join(projects_dir, "broken_project", "broken.gns3"), load=False) +def _write_topology_file(path, project_id, name): + with open(path, "w+") as f: + json.dump( + { + "name": name, + "project_id": project_id, + "version": __version__, + "revision": 10, + "type": "topology", + "topology": {"computes": [], "drawings": [], "links": [], "nodes": []}, + }, + f, + ) + + +@pytest.mark.asyncio +async def test_load_project_refuses_gns3_in_projects_directory(controller, projects_dir): + """ + A .gns3 placed directly in the projects directory must not be + loadable: its parent directory (the shared projects root) would become + the project directory, and deleting that project would wipe every + project on the controller. + """ + + topology_file = os.path.join(projects_dir, "root-level.gns3") + _write_topology_file(topology_file, str(uuid.uuid4()), "root-level") + + with pytest.raises(ControllerError): + await controller.load_project(topology_file) + assert not controller._projects + + +@pytest.mark.asyncio +async def test_load_project_from_own_subdirectory(controller, projects_dir): + """ + The normal layout — a .gns3 inside its own subdirectory — keeps + loading, with the subdirectory as the project directory. + """ + + project_dir = os.path.join(projects_dir, "sub-project") + os.makedirs(project_dir) + topology_file = os.path.join(project_dir, "sub-project.gns3") + _write_topology_file(topology_file, str(uuid.uuid4()), "sub-project") + + project = await controller.load_project(topology_file, load=False) + assert project.path == project_dir + + def test_projects_directory_event_handler_filters_events(controller): controller._notify_projects_directory_event = MagicMock() diff --git a/tests/controller/test_import_project.py b/tests/controller/test_import_project.py index e0cbfa1b1..0d5cff67f 100644 --- a/tests/controller/test_import_project.py +++ b/tests/controller/test_import_project.py @@ -87,7 +87,8 @@ async def test_import_project_override(projects_dir, controller): override the previous keeping the same project id & location """ - tmpdir = Path(projects_dir) + tmpdir = Path(projects_dir) / "override-location" + tmpdir.mkdir(parents=True, exist_ok=True) project_id = str(uuid.uuid4()) topology = { "project_id": project_id, diff --git a/tests/controller/test_project.py b/tests/controller/test_project.py index 5b5b823e9..31a4a2e7a 100644 --- a/tests/controller/test_project.py +++ b/tests/controller/test_project.py @@ -741,6 +741,37 @@ async def test_delete(project): assert not os.path.exists(project.path) +@pytest.mark.asyncio +async def test_delete_refuses_to_delete_projects_directory(project, projects_dir): + """ + A poisoned entry whose path is the projects directory itself (a .gns3 + loaded directly from the projects root before the guard existed) must + not be deletable: rmtree would wipe every project on the controller. + """ + + other_project = os.path.join(projects_dir, "another-project") + os.makedirs(other_project, exist_ok=True) + + # Simulate the poisoned in-memory state directly: the path setter now + # rejects such an assignment, but a long-running server can still hold + # an entry created before the fix. + project._path = projects_dir + + with pytest.raises(ControllerError): + await project.delete() + assert os.path.exists(other_project) + + +def test_path_setter_rejects_projects_directory(project, projects_dir): + """ + The projects directory itself must never become a project directory. + """ + + with pytest.raises(ControllerForbiddenError): + project.path = projects_dir + assert project.path == os.path.join(projects_dir, project.id) + + @pytest.mark.asyncio async def test_delete_does_not_start_nodes(project): """ diff --git a/tests/controller/test_project_open.py b/tests/controller/test_project_open.py index 40ddd9ca1..073fa3877 100644 --- a/tests/controller/test_project_open.py +++ b/tests/controller/test_project_open.py @@ -192,12 +192,14 @@ async def test_open(controller, projects_dir): "version": "2.0.0" } - with open(os.path.join(projects_dir, "demo.gns3"), "w+") as f: + project_dir = os.path.join(projects_dir, "demo") + os.makedirs(project_dir) + with open(os.path.join(project_dir, "demo.gns3"), "w+") as f: json.dump(simple_topology, f) project = Project(name="demo", project_id="64ba8408-afbf-4b66-9cdd-1fd854427478", - path=str(projects_dir), + path=project_dir, controller=controller, filename="demo.gns3", status="closed") From 9e4edc8a8ac46eac022a5862799f77347377c229 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Tue, 25 Aug 2026 13:22:59 +0800 Subject: [PATCH 03/14] chore: disable symbol MCP tools for now MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symbol tools (symbol_list/get/dimensions/defaults/upload/delete) require a vision-capable model to be genuinely useful — they shuttle SVG content, which a text-only LLM cannot inspect or produce. The tool registrations and imports are commented out (handlers stay in symbols.py); revisit when multimodal support is worked out. --- docs/features/mcp-service.md | 17 ++-- gns3server/agent/mcp/__init__.py | 132 ++++++++++++++++--------------- 2 files changed, 75 insertions(+), 74 deletions(-) diff --git a/docs/features/mcp-service.md b/docs/features/mcp-service.md index 3115f1bab..3dbfdbbd4 100644 --- a/docs/features/mcp-service.md +++ b/docs/features/mcp-service.md @@ -94,7 +94,7 @@ All subsequent tool handler REST API calls use this JWT → zero extra bcrypt ## Available Tools -**82 tools** across 12 categories: +**76 tools** across 11 categories: ### Project (14) @@ -191,16 +191,11 @@ All subsequent tool handler REST API calls use this JWT → zero extra bcrypt | `drawing_update` | Update drawing (position, rotation, SVG) | | `drawing_delete` | Delete a drawing | -### Symbol (6) - -| Tool | Description | -|------|-------------| -| `symbol_list` | List all symbols | -| `symbol_get` | Get symbol download URL | -| `symbol_dimensions` | Get symbol dimensions | -| `symbol_defaults` | Get default symbol mapping | -| `symbol_upload` | Upload a custom symbol (SVG content) | -| `symbol_delete` | Delete a custom symbol (built-in: 403) | + ### Appliance (3) diff --git a/gns3server/agent/mcp/__init__.py b/gns3server/agent/mcp/__init__.py index 608070f0f..191d819cf 100644 --- a/gns3server/agent/mcp/__init__.py +++ b/gns3server/agent/mcp/__init__.py @@ -66,11 +66,14 @@ from .projects import ( from .server import ( get_version_handler, get_statistics_handler, ) -from .symbols import ( - get_symbols_handler, get_symbol_handler, - get_symbol_dimensions_handler, get_default_symbols_handler, - upload_symbol_handler, delete_symbol_handler, -) +# Symbol tools are disabled for now: they require a vision-capable model to +# be genuinely useful (the tools shuttle SVG content, which a text-only LLM +# cannot inspect or produce). Revisit later. +# from .symbols import ( +# get_symbols_handler, get_symbol_handler, +# get_symbol_dimensions_handler, get_default_symbols_handler, +# upload_symbol_handler, delete_symbol_handler, +# ) from .appliances import ( get_appliances_handler, get_appliance_handler, install_appliance_handler, @@ -1280,64 +1283,67 @@ async def server_statistics() -> list[dict[str, Any]]: # ── Symbol tools ────────────────────────────────────────────────────── - - -@mcp.tool() -async def symbol_list() -> list[dict[str, Any]]: - """List all available symbols on the server.""" - return await asyncio.to_thread(_run_handler_sync, get_symbols_handler, {}) - - -@mcp.tool() -async def symbol_get( - symbol_id: Annotated[str, Field(description="Symbol ID (e.g. ':/symbols/router.svg')")], -) -> list[dict[str, Any]]: - """Get a download URL for a symbol file (SVG). The URL includes a short-lived JWT (10 min). Use curl to download.""" - return await asyncio.to_thread(_run_handler_sync, get_symbol_handler, { - "symbol_id": symbol_id, - }) - - -@mcp.tool() -async def symbol_dimensions( - symbol_id: Annotated[str, Field(description="Symbol ID to get dimensions for")], -) -> list[dict[str, Any]]: - """Get the dimensions (width, height) of a symbol.""" - return await asyncio.to_thread(_run_handler_sync, get_symbol_dimensions_handler, { - "symbol_id": symbol_id, - }) - - -@mcp.tool() -async def symbol_defaults() -> list[dict[str, Any]]: - """Get the default symbol mapping for each node type.""" - return await asyncio.to_thread(_run_handler_sync, get_default_symbols_handler, {}) - - -@mcp.tool() -async def symbol_upload( - symbol_id: Annotated[str, Field(description="Symbol ID to upload (e.g. ':/symbols/my_symbol.svg')")], - content: Annotated[str, Field(description="SVG content of the symbol")], -) -> list[dict[str, Any]]: - """Upload or update a custom symbol on the server. Provide the SVG content as a string.""" - return await asyncio.to_thread(_run_handler_sync, upload_symbol_handler, { - "symbol_id": symbol_id, "content": content, - }) - - -@mcp.tool() -async def symbol_delete( - symbol_id: Annotated[str, Field(description="Symbol ID to delete (e.g. ':/symbols/my_custom_symbol.svg'). Use symbol_list to get existing IDs.")], -) -> list[dict[str, Any]]: - """Delete a custom symbol from the server. - - NOTE: Only custom (user-uploaded) symbols can be deleted. - Built-in symbols (starting with ':/symbols/') will be rejected with 403. - Use symbol_list to see which symbols are available and their IDs. - """ - return await asyncio.to_thread(_run_handler_sync, delete_symbol_handler, { - "symbol_id": symbol_id, - }) +# +# Disabled for now: symbol handling requires a vision-capable model (the +# tools shuttle SVG content, which a text-only LLM cannot inspect or +# produce). Revisit later. +# +# @mcp.tool() +# async def symbol_list() -> list[dict[str, Any]]: +# """List all available symbols on the server.""" +# return await asyncio.to_thread(_run_handler_sync, get_symbols_handler, {}) +# +# +# @mcp.tool() +# async def symbol_get( +# symbol_id: Annotated[str, Field(description="Symbol ID (e.g. ':/symbols/router.svg')")], +# ) -> list[dict[str, Any]]: +# """Get a download URL for a symbol file (SVG). The URL includes a short-lived JWT (10 min). Use curl to download.""" +# return await asyncio.to_thread(_run_handler_sync, get_symbol_handler, { +# "symbol_id": symbol_id, +# }) +# +# +# @mcp.tool() +# async def symbol_dimensions( +# symbol_id: Annotated[str, Field(description="Symbol ID to get dimensions for")], +# ) -> list[dict[str, Any]]: +# """Get the dimensions (width, height) of a symbol.""" +# return await asyncio.to_thread(_run_handler_sync, get_symbol_dimensions_handler, { +# "symbol_id": symbol_id, +# }) +# +# +# @mcp.tool() +# async def symbol_defaults() -> list[dict[str, Any]]: +# """Get the default symbol mapping for each node type.""" +# return await asyncio.to_thread(_run_handler_sync, get_default_symbols_handler, {}) +# +# +# @mcp.tool() +# async def symbol_upload( +# symbol_id: Annotated[str, Field(description="Symbol ID to upload (e.g. ':/symbols/my_symbol.svg')")], +# content: Annotated[str, Field(description="SVG content of the symbol")], +# ) -> list[dict[str, Any]]: +# """Upload or update a custom symbol on the server. Provide the SVG content as a string.""" +# return await asyncio.to_thread(_run_handler_sync, upload_symbol_handler, { +# "symbol_id": symbol_id, "content": content, +# }) +# +# +# @mcp.tool() +# async def symbol_delete( +# symbol_id: Annotated[str, Field(description="Symbol ID to delete (e.g. ':/symbols/my_custom_symbol.svg'). Use symbol_list to get existing IDs.")], +# ) -> list[dict[str, Any]]: +# """Delete a custom symbol from the server. +# +# NOTE: Only custom (user-uploaded) symbols can be deleted. +# Built-in symbols (starting with ':/symbols/') will be rejected with 403. +# Use symbol_list to see which symbols are available and their IDs. +# """ +# return await asyncio.to_thread(_run_handler_sync, delete_symbol_handler, { +# "symbol_id": symbol_id, +# }) # ── Appliance tools ─────────────────────────────────────────────────── From f31bfffefc0e0640df4bec549b8d167f61b91c88 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Tue, 25 Aug 2026 13:29:39 +0800 Subject: [PATCH 04/14] feat: expose data_link_type on the link_marker MCP tool (create-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-link markers on serial links need the WAN encapsulation (e.g. DLT_C_HDLC) so the BPF compiles against the right link layer — the REST API already accepts it, but the MCP tool never forwarded it. Create passes it through; update ignores it (changing it would invalidate the capture file), matching the REST schema semantics. --- .../gns3_copilot/gns3_client/api_handlers.py | 2 +- gns3server/agent/mcp/__init__.py | 3 ++- tests/agent/mcp/test_handlers.py | 27 +++++++++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py b/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py index dc674ccb8..9c31d07d0 100644 --- a/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py +++ b/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py @@ -900,7 +900,7 @@ def link_marker_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic 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"): + for opt in ("name", "tag", "capture_node_id", "color", "highlight_duration", "data_link_type"): if params.get(opt) is not None: body[opt] = params[opt] # direction: "tx"/"rx" set a one-way filter; "both"/omitted = no filter. diff --git a/gns3server/agent/mcp/__init__.py b/gns3server/agent/mcp/__init__.py index 191d819cf..b04a764ff 100644 --- a/gns3server/agent/mcp/__init__.py +++ b/gns3server/agent/mcp/__init__.py @@ -1035,6 +1035,7 @@ async def link_marker( capture_node_id: Annotated[str | None, Field(description="UUID of the endpoint whose uBridge hosts the marker (the observer; tx/rx are from its perspective). Must be a link endpoint and marker-capable. Omit to auto-pick.")] = None, color: Annotated[str | None, Field(description="Hex color for UI highlight, e.g. '#ff5722'")] = None, highlight_duration: Annotated[int | None, Field(description="UI highlight duration in milliseconds")] = None, + data_link_type: Annotated[str | None, Field(description="pcap link-layer type for serial links (create-only): DLT_C_HDLC / DLT_PPP_SERIAL / DLT_FRELAY / DLT_ATM_RFC1483, matching the encapsulation on the serial link. Omit = DLT_EN10MB (Ethernet). Ignored on update — changing it would invalidate the capture file.")] = None, ) -> list[dict[str, Any]]: """Manage traffic-insight markers on a link. @@ -1051,7 +1052,7 @@ async def link_marker( and cannot be modified or deleted via this tool. """ params = {"project_id": project_id, "link_id": link_id, "action": action} - for opt in ("bpf", "marker_name", "name", "tag", "enabled", "direction", "capture_node_id", "color", "highlight_duration"): + for opt in ("bpf", "marker_name", "name", "tag", "enabled", "direction", "capture_node_id", "color", "highlight_duration", "data_link_type"): val = locals().get(opt) if val is not None: params[opt] = val diff --git a/tests/agent/mcp/test_handlers.py b/tests/agent/mcp/test_handlers.py index 42738dc37..0a85e0256 100644 --- a/tests/agent/mcp/test_handlers.py +++ b/tests/agent/mcp/test_handlers.py @@ -437,6 +437,33 @@ class TestLinkMarker: json_data={"bpf": "icmp"}, ) + def test_create_data_link_type_passthrough(self, ctx): + """create passes a serial WAN encapsulation through; update ignores it.""" + from gns3server.agent.gns3_copilot.gns3_client.api_handlers import link_marker_handler + with patch(f"{AH}._get_connector") as m: + conn = _mock_conn({"name": "icmp"}) + m.return_value = conn + link_marker_handler( + {"project_id": "p", "link_id": "l", "action": "create", + "bpf": "icmp", "data_link_type": "DLT_C_HDLC"}, ctx, + ) + conn.http_call.assert_called_with( + "post", "http://192.168.1.3:3080/v3/projects/p/links/l/markers", + json_data={"bpf": "icmp", "data_link_type": "DLT_C_HDLC"}, + ) + + conn = _mock_conn({"name": "icmp"}) + m.return_value = conn + link_marker_handler( + {"project_id": "p", "link_id": "l", "action": "update", + "marker_name": "icmp", "tag": 1, "data_link_type": "DLT_PPP_SERIAL"}, ctx, + ) + # create-only: dropped from the update body + conn.http_call.assert_called_with( + "put", "http://192.168.1.3:3080/v3/projects/p/links/l/markers/icmp", + json_data={"tag": 1}, + ) + def test_create_direction_tx(self, ctx): from gns3server.agent.gns3_copilot.gns3_client.api_handlers import link_marker_handler with patch(f"{AH}._get_connector") as m: From 97e7a791171e7f3d0d7a882f3fcee96e398994df Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Tue, 25 Aug 2026 13:45:36 +0800 Subject: [PATCH 05/14] fix: report empty projects as not locked GET /projects/{id}/locked returned True for a project with no drawings or nodes: both loops ran zero times and the fallback return won. Locking and unlocking such a project always succeeded while the state stayed "locked", so it could never be unlocked. Report a project with nothing to lock as not locked, and re-check the state after unlock in the route tests. --- gns3server/controller/project.py | 4 +++ tests/api/routes/controller/test_projects.py | 26 ++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index d6563ead0..01506a734 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -2141,6 +2141,10 @@ class Project: Check if all items in a project are locked and not """ + if not self._drawings and not self._nodes: + # a project without drawings or nodes has nothing to lock and would + # otherwise always report as locked, even after unlocking it + return False for drawing in self._drawings.values(): if not drawing.locked: return False diff --git a/tests/api/routes/controller/test_projects.py b/tests/api/routes/controller/test_projects.py index aa7ae8d36..c5d32922a 100644 --- a/tests/api/routes/controller/test_projects.py +++ b/tests/api/routes/controller/test_projects.py @@ -613,3 +613,29 @@ class TestControllerProjectRoutes: assert drawing.locked is False for node in project.nodes.values(): assert node.locked is False + + response = await client.get(app.url_path_for("locked_project", project_id=project.id)) + assert response.status_code == status.HTTP_200_OK + assert response.json() is False + + async def test_lock_unlock_empty_project(self, app: FastAPI, client: AsyncClient, project: Project) -> None: + + # a project without drawings or nodes has nothing to lock and must + # never report as locked, otherwise it could not be unlocked + response = await client.get(app.url_path_for("locked_project", project_id=project.id)) + assert response.status_code == status.HTTP_200_OK + assert response.json() is False + + response = await client.post(app.url_path_for("lock_project", project_id=project.id)) + assert response.status_code == status.HTTP_204_NO_CONTENT + + response = await client.get(app.url_path_for("locked_project", project_id=project.id)) + assert response.status_code == status.HTTP_200_OK + assert response.json() is False + + response = await client.post(app.url_path_for("unlock_project", project_id=project.id)) + assert response.status_code == status.HTTP_204_NO_CONTENT + + response = await client.get(app.url_path_for("locked_project", project_id=project.id)) + assert response.status_code == status.HTTP_200_OK + assert response.json() is False From c9bc6359960b612dee9829e688bd0633cb61bb76 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Tue, 25 Aug 2026 21:29:53 +0800 Subject: [PATCH 06/14] fix: propagate 405 when suspending a node without suspend support Suspending a single VPCS/IOU node returned a fake 204: the controller route swallowed the compute 405 that the node types honestly raise, so callers saw success while the node stayed started. Surface the 405 instead. The best-effort swallow on suspend_all is kept (and now covered by a test) since mixed projects legitimately contain always-running node types. --- gns3server/api/routes/controller/nodes.py | 9 +++--- tests/api/routes/controller/test_nodes.py | 38 +++++++++++++++++++++-- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/gns3server/api/routes/controller/nodes.py b/gns3server/api/routes/controller/nodes.py index de29db5dc..47f9e57aa 100644 --- a/gns3server/api/routes/controller/nodes.py +++ b/gns3server/api/routes/controller/nodes.py @@ -372,14 +372,13 @@ async def suspend_node(node: Node = Depends(dep_node)) -> None: """ Suspend a node. + Node types without suspend support return a 405 error instead of a + silent no-op, so the caller cannot mistake it for a suspended node. + Required privilege: Node.PowerMgmt """ - try: - await node.suspend() - except HTTPException as e: - if not e.status_code == status.HTTP_405_METHOD_NOT_ALLOWED: - raise + await node.suspend() @router.post( diff --git a/tests/api/routes/controller/test_nodes.py b/tests/api/routes/controller/test_nodes.py index 4fd84d0ad..bded58728 100644 --- a/tests/api/routes/controller/test_nodes.py +++ b/tests/api/routes/controller/test_nodes.py @@ -18,7 +18,7 @@ import pytest -from fastapi import FastAPI, status +from fastapi import FastAPI, HTTPException, status from httpx import AsyncClient from unittest.mock import MagicMock @@ -295,10 +295,44 @@ class TestNodeRoutes: compute: Compute, node: Node ) -> None: - + compute.post = AsyncioMagicMock() response = await client.post(app.url_path_for("suspend_node", project_id=project.id, node_id=node.id)) assert response.status_code == status.HTTP_204_NO_CONTENT + + async def test_suspend_node_unsupported( + self, + app: FastAPI, + client: AsyncClient, + project: Project, + compute: Compute, + node: Node + ) -> None: + + # node types without suspend support (e.g. VPCS, IOU) must surface the + # compute 405 instead of reporting a fake success + compute.post = AsyncioMagicMock( + side_effect=HTTPException(status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Suspend is not supported") + ) + response = await client.post(app.url_path_for("suspend_node", project_id=project.id, node_id=node.id)) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED + + async def test_suspend_all_nodes_tolerates_unsupported( + self, + app: FastAPI, + client: AsyncClient, + project: Project, + compute: Compute, + node: Node + ) -> None: + + # suspending all nodes of a mixed project stays best-effort: nodes + # without suspend support are skipped without failing the request + compute.post = AsyncioMagicMock( + side_effect=HTTPException(status_code=status.HTTP_405_METHOD_NOT_ALLOWED, detail="Suspend is not supported") + ) + response = await client.post(app.url_path_for("suspend_all_nodes", project_id=project.id)) + assert response.status_code == status.HTTP_204_NO_CONTENT async def test_reload_node( From 57b5baed7f99c9d1d7139f92e7ba28eba64bbf9e Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Tue, 25 Aug 2026 21:32:09 +0800 Subject: [PATCH 07/14] fix: keep submission order and unify status in MCP batch handlers Batch node/link creation collected results with as_completed, so the response order followed completion rather than the submitted array and callers could not correlate entries. Collect in submission order via pool.map, and report batch deletes as status=success like every other batch action (the message still says what was deleted). --- .../gns3_copilot/gns3_client/api_handlers.py | 22 ++++----- tests/agent/mcp/test_handlers.py | 47 +++++++++++++++++++ 2 files changed, 56 insertions(+), 13 deletions(-) diff --git a/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py b/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py index 9c31d07d0..8c5a794e6 100644 --- a/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py +++ b/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py @@ -48,7 +48,7 @@ pulls the request-scoped user JWT from the context variables. """ from typing import Any -from concurrent.futures import ThreadPoolExecutor, as_completed +from concurrent.futures import ThreadPoolExecutor import hashlib import logging @@ -315,7 +315,6 @@ def create_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic 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) @@ -336,10 +335,9 @@ def create_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic 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 + # pool.map keeps the submission order, so callers can correlate + # results with the nodes they sent regardless of completion order + return list(pool.map(_create_one, nodes)) # Single mode template_id = params.get("template_id") @@ -371,7 +369,7 @@ def delete_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic def _del(nid): try: conn.http_call("delete", f"{conn.base_url}/projects/{project_id}/nodes/{nid}") - return {"node_id": nid, "status": "deleted"} + return {"node_id": nid, "status": "success", "message": f"Node {nid} deleted"} except Exception as e: return {"node_id": nid, "status": "error", "error": str(e)} with ThreadPoolExecutor(max_workers=min(len(node_ids), BATCH_MAX_WORKERS)) as pool: @@ -656,7 +654,6 @@ def create_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic 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") @@ -676,10 +673,9 @@ def create_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic 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 + # pool.map keeps the submission order, so callers can correlate + # results with the links they sent regardless of completion order + return list(pool.map(_create_one, links)) # Single mode nodes = params.get("nodes") @@ -710,7 +706,7 @@ def delete_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic def _del(lid): try: conn.http_call("delete", f"{conn.base_url}/projects/{project_id}/links/{lid}") - return {"link_id": lid, "status": "deleted"} + return {"link_id": lid, "status": "success", "message": f"Link {lid} deleted"} except Exception as e: return {"link_id": lid, "status": "error", "error": str(e)} with ThreadPoolExecutor(max_workers=min(len(link_ids), BATCH_MAX_WORKERS)) as pool: diff --git a/tests/agent/mcp/test_handlers.py b/tests/agent/mcp/test_handlers.py index 0a85e0256..68ca430f9 100644 --- a/tests/agent/mcp/test_handlers.py +++ b/tests/agent/mcp/test_handlers.py @@ -182,6 +182,25 @@ class TestNode: }, ctx) assert result[0]["status"] == "success" + def test_create_batch_preserves_submission_order(self, ctx): + import time + from gns3server.agent.gns3_copilot.gns3_client.api_handlers import create_node_handler + with patch(f"{AH}._get_connector") as m: + conn = _mock_conn() + def _http_call(method, url, json_data=None, **kwargs): + # first submissions sleep longest so completion order is reversed + time.sleep({"slow": 0.25, "mid": 0.1}.get(json_data.get("name"), 0.0)) + resp = MagicMock() + resp.json.return_value = {"node_id": "n1", "name": json_data["name"]} + return resp + conn.http_call.side_effect = _http_call + m.return_value = conn + result = create_node_handler({ + "project_id": "p1", "template_id": "t1", + "nodes": [{"name": "slow"}, {"name": "mid"}, {"name": "fast"}], + }, ctx) + assert [r["node"]["name"] for r in result] == ["slow", "mid", "fast"] + def test_create_missing_project_id(self, ctx): from gns3server.agent.gns3_copilot.gns3_client.api_handlers import create_node_handler assert create_node_handler({}, ctx) == {"error": "project_id is required"} @@ -192,6 +211,8 @@ class TestNode: m.return_value = _mock_conn({}) result = delete_node_handler({"project_id": "p1", "node_ids": ["n1", "n2"]}, ctx) assert len(result) == 2 + # same status vocabulary as create/start/stop batches + assert all(r["status"] == "success" for r in result) def test_start_batch(self, ctx): from gns3server.agent.gns3_copilot.gns3_client.api_handlers import start_node_handler @@ -291,6 +312,32 @@ class TestLink: m.return_value = _mock_conn({}) result = delete_link_handler({"project_id": "p1", "link_ids": ["l1", "l2"]}, ctx) assert len(result) == 2 + # same status vocabulary as create batches + assert all(r["status"] == "success" for r in result) + + def test_create_batch_preserves_submission_order(self, ctx): + import time + from gns3server.agent.gns3_copilot.gns3_client.api_handlers import create_link_handler + with patch(f"{AH}._get_connector") as m: + conn = _mock_conn() + def _http_call(method, url, json_data=None, **kwargs): + # first submission sleeps longest so completion order is reversed + first_node = json_data["nodes"][0]["node_id"] + time.sleep(0.25 if first_node == "n1" else 0.0) + resp = MagicMock() + resp.json.return_value = {"link_id": f"link-{first_node}"} + return resp + conn.http_call.side_effect = _http_call + m.return_value = conn + result = create_link_handler({ + "project_id": "p1", + "links": [ + {"nodes": ["n1", 0, 0, "n2", 0, 0]}, + {"nodes": ["n3", 0, 0, "n4", 0, 0]}, + ], + "fields": ["link_id"], + }, ctx) + assert [r["link"]["link_id"] for r in result] == ["link-n1", "link-n3"] def test_update(self, ctx): from gns3server.agent.gns3_copilot.gns3_client.api_handlers import update_link_handler From 636abde16c27377c490cb1087d214dafaf4e3586 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Tue, 25 Aug 2026 21:33:21 +0800 Subject: [PATCH 08/14] fix: keep node file content byte-faithful in node_file_get Splitting the file with keepends=False and rejoining with newlines dropped the trailing newline of the last line (and every \r of CRLF files), so returned content was shorter than the file on disk and did not round-trip. Split with keepends=True and join the selected lines verbatim; pagination semantics are unchanged. --- .../gns3_copilot/gns3_client/api_handlers.py | 9 ++-- tests/agent/mcp/test_handlers.py | 43 +++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py b/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py index 8c5a794e6..994406132 100644 --- a/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py +++ b/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py @@ -474,23 +474,26 @@ def get_node_file_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> d raw = raw[:MAX_NODE_FILE_BYTES] truncated = True - lines = raw.splitlines(keepends=False) + # keepends keeps the content byte-faithful: the trailing newline of the + # last line and any \r\n endings survive the round trip + lines = raw.splitlines(keepends=True) total_lines = len(lines) # Apply offset/limit selected = lines[offset: offset + limit] if offset < total_lines else [] has_more = (offset + limit) < total_lines or truncated + content = "".join(selected) return { "file_path": file_path, - "content": "\n".join(selected), + "content": content, "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")), + "returned_bytes": len(content.encode("utf-8")), "truncated": truncated or has_more, "has_more": has_more, }, diff --git a/tests/agent/mcp/test_handlers.py b/tests/agent/mcp/test_handlers.py index 68ca430f9..c95846870 100644 --- a/tests/agent/mcp/test_handlers.py +++ b/tests/agent/mcp/test_handlers.py @@ -242,6 +242,49 @@ class TestNode: result = get_node_console_info_handler({"project_id": "p1", "node_id": "n1"}, ctx) assert "command" in result + @staticmethod + def _file_conn(text): + conn = _mock_conn() + conn.http_call.return_value.text = text + return conn + + def test_file_get_keeps_trailing_newline(self, ctx): + from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_node_file_handler + with patch(f"{AH}._get_connector") as m: + m.return_value = self._file_conn("line1\nline2\n") + result = get_node_file_handler({"project_id": "p1", "node_id": "n1", "file_path": "startup.cfg"}, ctx) + assert result["content"] == "line1\nline2\n" + assert result["metadata"]["total_bytes"] == 12 + assert result["metadata"]["returned_bytes"] == 12 + assert result["metadata"]["has_more"] is False + + def test_file_get_keeps_crlf_endings(self, ctx): + from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_node_file_handler + with patch(f"{AH}._get_connector") as m: + m.return_value = self._file_conn("line1\r\nline2\r\n") + result = get_node_file_handler({"project_id": "p1", "node_id": "n1", "file_path": "startup.cfg"}, ctx) + assert result["content"] == "line1\r\nline2\r\n" + assert result["metadata"]["returned_bytes"] == 14 + + def test_file_get_without_trailing_newline(self, ctx): + from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_node_file_handler + with patch(f"{AH}._get_connector") as m: + m.return_value = self._file_conn("line1\nline2") + result = get_node_file_handler({"project_id": "p1", "node_id": "n1", "file_path": "startup.cfg"}, ctx) + assert result["content"] == "line1\nline2" + + def test_file_get_pagination(self, ctx): + from gns3server.agent.gns3_copilot.gns3_client.api_handlers import get_node_file_handler + with patch(f"{AH}._get_connector") as m: + m.return_value = self._file_conn("line1\nline2\nline3\n") + result = get_node_file_handler( + {"project_id": "p1", "node_id": "n1", "file_path": "startup.cfg", "offset": 1, "limit": 1}, ctx + ) + assert result["content"] == "line2\n" + assert result["metadata"]["total_lines"] == 3 + assert result["metadata"]["returned_lines"] == 1 + assert result["metadata"]["has_more"] is True + # ── Link ──────────────────────────────────────────────────────────────── From 73e5e27c7b3eb514caf8b3b3a5b0a7172c623523 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Tue, 25 Aug 2026 22:22:08 +0800 Subject: [PATCH 09/14] fix: keep default node naming aligned with batch submission order The controller assigns default names (R-1, R-2, ...) and console ports in request arrival order. A parallel batch fan-out lets thread scheduling decide that order, so the first submitted node could end up as R-2. Batches that rely on default naming (any node without a name) are now created sequentially; batches with explicit names stay parallel. The node_create tool description documents the ordering semantics and tells callers to correlate nodes by node_id. --- .../gns3_copilot/gns3_client/api_handlers.py | 8 ++++ gns3server/agent/mcp/__init__.py | 3 ++ tests/agent/mcp/test_handlers.py | 40 +++++++++++++++++++ 3 files changed, 51 insertions(+) diff --git a/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py b/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py index 994406132..2dcec5afa 100644 --- a/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py +++ b/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py @@ -334,6 +334,14 @@ def create_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic return {"template_id": tid, "status": "success", "node": _filter_node_response(resp, fields)} except Exception as e: return {"template_id": tid, "status": "error", "error": str(e)} + if any(not node.get("name") for node in nodes): + # The controller assigns default names (R-1, R-2, ...) and console + # ports in request arrival order, and a parallel fan-out makes the + # arrival order depend on thread scheduling. Batches that rely on + # default naming are therefore created sequentially so those + # server-side assignments follow the submission order; batches + # where every node has an explicit name stay parallel. + return [_create_one(node) for node in nodes] with ThreadPoolExecutor(max_workers=min(len(nodes), BATCH_MAX_WORKERS)) as pool: # pool.map keeps the submission order, so callers can correlate # results with the nodes they sent regardless of completion order diff --git a/gns3server/agent/mcp/__init__.py b/gns3server/agent/mcp/__init__.py index b04a764ff..6d65ff11c 100644 --- a/gns3server/agent/mcp/__init__.py +++ b/gns3server/agent/mcp/__init__.py @@ -510,6 +510,9 @@ async def node_create( Single mode: provide template_id, x, y (optional compute_id) Batch mode: provide nodes=[{name, template_id?, x?, y?, compute_id?}] — creates up to 100 in parallel. Top-level template_id applies to all nodes; individual nodes can override. + Results are always returned in submission order; correlate nodes by node_id, not name. + When a node omits `name`, the server assigns a default name (R-1, R-2, ...) and console + port — such batches are created sequentially so those assignments follow submission order. """ if nodes is not None: return await asyncio.to_thread(_run_handler_sync, create_node_handler, { diff --git a/tests/agent/mcp/test_handlers.py b/tests/agent/mcp/test_handlers.py index c95846870..c5cbbbfd5 100644 --- a/tests/agent/mcp/test_handlers.py +++ b/tests/agent/mcp/test_handlers.py @@ -201,6 +201,46 @@ class TestNode: }, ctx) assert [r["node"]["name"] for r in result] == ["slow", "mid", "fast"] + def test_create_batch_default_names_created_sequentially(self, ctx): + import threading + import time + from gns3server.agent.gns3_copilot.gns3_client.api_handlers import create_node_handler + + def _run(nodes_param): + with patch(f"{AH}._get_connector") as m: + conn = _mock_conn() + lock = threading.Lock() + active = [0, 0] # in-flight requests, high-water mark + counter = [0] + + def _http_call(method, url, json_data=None, **kwargs): + with lock: + active[0] += 1 + active[1] = max(active[1], active[0]) + counter[0] += 1 + seq = counter[0] + time.sleep(0.05) # wide enough that parallel calls would overlap + with lock: + active[0] -= 1 + resp = MagicMock() + resp.json.return_value = {"node_id": f"n{seq}", "name": json_data.get("name", f"R-{seq}")} + return resp + + conn.http_call.side_effect = _http_call + m.return_value = conn + result = create_node_handler({"project_id": "p1", "template_id": "t1", "nodes": nodes_param}, ctx) + return result, active[1] + + # nodes relying on default naming are created one at a time so the + # server assigns default names/console ports in submission order + result, max_active = _run([{}, {}, {}]) + assert [r["node"]["name"] for r in result] == ["R-1", "R-2", "R-3"] + assert max_active == 1 + # one nameless node is enough to serialize the whole batch + result, max_active = _run([{"name": "explicit"}, {}]) + assert [r["node"]["name"] for r in result] == ["explicit", "R-2"] + assert max_active == 1 + def test_create_missing_project_id(self, ctx): from gns3server.agent.gns3_copilot.gns3_client.api_handlers import create_node_handler assert create_node_handler({}, ctx) == {"error": "project_id is required"} From 888afdccbdac119355a28d0d7de7c41eccfd8f25 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Tue, 25 Aug 2026 23:39:38 +0800 Subject: [PATCH 10/14] fix: return the created template from appliance install POST /appliances/{id}/install replied 204 with an empty body, so the MCP appliance_install tool crashed with 'Expecting value: line 1 column 1' while the template had actually been created. The route now returns the created template (201, response_model=schemas.Template), _create_template propagates it, and the MCP handler parses the body defensively so an empty reply degrades to a plain success message. --- gns3server/agent/mcp/__init__.py | 2 +- gns3server/agent/mcp/appliances.py | 12 +++++++++-- .../api/routes/controller/appliances.py | 9 ++++---- gns3server/controller/appliance_manager.py | 5 +++-- tests/agent/mcp/test_handlers.py | 21 ++++++++++++++++++- .../api/routes/controller/test_appliances.py | 7 +++++-- 6 files changed, 44 insertions(+), 12 deletions(-) diff --git a/gns3server/agent/mcp/__init__.py b/gns3server/agent/mcp/__init__.py index 6d65ff11c..dac6031a4 100644 --- a/gns3server/agent/mcp/__init__.py +++ b/gns3server/agent/mcp/__init__.py @@ -1376,7 +1376,7 @@ async def appliance_install( appliance_id: Annotated[str, Field(description="UUID of the appliance to install")], version: Annotated[str | None, Field(description="Version to install (e.g. '2.7.0.356'). Required if the appliance has multiple versions. Use appliance_get to see available versions.")] = None, ) -> list[dict[str, Any]]: - """Create a template from a GNS3 appliance definition. + """Create a template from a GNS3 appliance definition and return the created template. NOTE: This does NOT download images. Images must be placed in the GNS3 images directory (e.g. ~/GNS3/images/) beforehand. diff --git a/gns3server/agent/mcp/appliances.py b/gns3server/agent/mcp/appliances.py index dd2be1735..ab867fe57 100644 --- a/gns3server/agent/mcp/appliances.py +++ b/gns3server/agent/mcp/appliances.py @@ -85,5 +85,13 @@ def install_appliance_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) version = params.get("version") if version: request_params["version"] = version - result = conn.http_call("post", url, params=request_params).json() - return {"message": f"Appliance {appliance_id} installation requested", "result": result} + response = conn.http_call("post", url, params=request_params) + result = {"message": f"Appliance {appliance_id} installed"} + if response.content: + # the install endpoint returns the created template (201); tolerate an + # empty body in case an older server still replies with 204 + template = response.json() + result["template"] = { + k: template[k] for k in ("template_id", "name", "version", "template_type") if k in template + } + return result diff --git a/gns3server/api/routes/controller/appliances.py b/gns3server/api/routes/controller/appliances.py index 1ca32d8e9..142818f66 100644 --- a/gns3server/api/routes/controller/appliances.py +++ b/gns3server/api/routes/controller/appliances.py @@ -122,7 +122,8 @@ def add_appliance_version(appliance_id: UUID, appliance_version: Union[schemas.A @router.post( "/{appliance_id}/install", - status_code=status.HTTP_204_NO_CONTENT, + response_model=schemas.Template, + status_code=status.HTTP_201_CREATED, dependencies=[Depends(has_privilege("Appliance.Allocate"))] ) async def install_appliance( @@ -132,15 +133,15 @@ async def install_appliance( templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)), current_user: schemas.User = Depends(get_current_active_user), rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)) -) -> None: +) -> schemas.Template: """ - Install an appliance. + Install an appliance and return the created template. Required privilege: Appliance.Allocate """ controller = Controller.instance() - await controller.appliance_manager.install_appliance( + return await controller.appliance_manager.install_appliance( appliance_id, version, images_repo, diff --git a/gns3server/controller/appliance_manager.py b/gns3server/controller/appliance_manager.py index 25b0c366e..7ee36a4d4 100644 --- a/gns3server/controller/appliance_manager.py +++ b/gns3server/controller/appliance_manager.py @@ -204,9 +204,9 @@ class ApplianceManager: else: raise ControllerError(f"Could not find '{appliance_file}'") - async def _create_template(self, template_data, templates_repo, rbac_repo, current_user): + async def _create_template(self, template_data, templates_repo, rbac_repo, current_user) -> dict: """ - Create a new template + Create a new template and return it as a dict. """ try: @@ -217,6 +217,7 @@ class ApplianceManager: #template_id = template.get("template_id") #await rbac_repo.add_permission_to_user_with_path(current_user.user_id, f"/templates/{template_id}/*") log.info(f"Template '{template.get('name')}' has been created") + return template async def _appliance_to_template(self, appliance: Appliance, version: str = None) -> dict: """ diff --git a/tests/agent/mcp/test_handlers.py b/tests/agent/mcp/test_handlers.py index c5cbbbfd5..91f1b9f72 100644 --- a/tests/agent/mcp/test_handlers.py +++ b/tests/agent/mcp/test_handlers.py @@ -3,6 +3,8 @@ MCP handler unit tests with mocked Gns3Connector. Tests that handlers correctly transform tool parameters into HTTP calls. """ +import json + import pytest from unittest.mock import MagicMock, patch @@ -12,6 +14,7 @@ def _mock_conn(json_result=None): conn = MagicMock() conn.base_url = "http://192.168.1.3:3080/v3" conn.http_call.return_value.json.return_value = json_result or {"status": "ok"} + conn.http_call.return_value.content = b"{}" # non-empty body by default return conn @@ -449,7 +452,7 @@ class TestAppliance: def test_install_with_version(self, ctx): from gns3server.agent.mcp.appliances import install_appliance_handler with patch(f"{BASE}.{self.mod}._get_connector") as m: - conn = _mock_conn({"status": "installed"}) + conn = _mock_conn({"template_id": "t1", "name": "FRR", "version": "8.2.2", "template_type": "docker"}) m.return_value = conn result = install_appliance_handler({ "appliance_id": "a1", "version": "2.7.0.356", @@ -458,6 +461,22 @@ class TestAppliance: "post", "http://192.168.1.3:3080/v3/appliances/a1/install", params={"version": "2.7.0.356"}, ) + assert result["template"] == { + "template_id": "t1", "name": "FRR", "version": "8.2.2", "template_type": "docker", + } + + def test_install_empty_body(self, ctx): + # a 204-style empty response must not blow up with a JSON decode error + # (the template is still created server-side) + from gns3server.agent.mcp.appliances import install_appliance_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: + conn = _mock_conn() + conn.http_call.return_value.content = b"" + conn.http_call.return_value.json.side_effect = json.JSONDecodeError("Expecting value", "", 0) + m.return_value = conn + result = install_appliance_handler({"appliance_id": "a1"}, ctx) + assert "template" not in result + assert result["message"] == "Appliance a1 installed" def test_install_missing_id(self, ctx): from gns3server.agent.mcp.appliances import install_appliance_handler diff --git a/tests/api/routes/controller/test_appliances.py b/tests/api/routes/controller/test_appliances.py index d3c1cc632..356fe2b08 100644 --- a/tests/api/routes/controller/test_appliances.py +++ b/tests/api/routes/controller/test_appliances.py @@ -53,7 +53,8 @@ class TestApplianceRoutes: appliance_id = "fc520ae2-a4e5-48c3-9a13-516bb2e94668" # Alpine Linux appliance response = await client.post(app.url_path_for("install_appliance", appliance_id=appliance_id)) - assert response.status_code == status.HTTP_204_NO_CONTENT + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["name"] == "Alpine Linux" async def test_docker_appliance_install_with_version(self, app: FastAPI, client: AsyncClient) -> None: @@ -68,7 +69,9 @@ class TestApplianceRoutes: appliance_id = "1cfdf900-7c30-4cb7-8f03-3f61d2581633" # Empty VM appliance params = {"version": "8G"} response = await client.post(app.url_path_for("install_appliance", appliance_id=appliance_id), params=params) - assert response.status_code == status.HTTP_204_NO_CONTENT + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["name"] == "Empty VM" + assert response.json()["version"] == "8G" async def test_qemu_appliance_install_without_version(self, app: FastAPI, client: AsyncClient, images_dir: str) -> None: From 8a8314ab297a46f67fd47f71d5cea3f6b87b272c Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 26 Aug 2026 00:04:57 +0800 Subject: [PATCH 11/14] fix: dedupe and report automatic template creation from images install_appliances_from_image relied on the name+version pair check in TemplatesService, so the same appliance reached through a second image (the CSR1000v case) created a second template sharing the name. The auto path now skips when any template with the same name exists, whatever the version, and returns a manifest of created and skipped candidates; POST /images/install replies 200 with that manifest instead of an empty 204, and the image_install MCP tool surfaces it. --- gns3server/agent/mcp/__init__.py | 4 +- gns3server/agent/mcp/images.py | 7 +++- gns3server/api/routes/controller/images.py | 21 ++++++++-- gns3server/controller/appliance_manager.py | 44 ++++++++++++++++++-- gns3server/db/repositories/templates.py | 11 +++++ tests/agent/mcp/test_handlers.py | 30 ++++++++++++++ tests/api/routes/controller/test_images.py | 48 +++++++++++++++++++++- 7 files changed, 154 insertions(+), 11 deletions(-) diff --git a/gns3server/agent/mcp/__init__.py b/gns3server/agent/mcp/__init__.py index dac6031a4..2d9e39a89 100644 --- a/gns3server/agent/mcp/__init__.py +++ b/gns3server/agent/mcp/__init__.py @@ -1435,7 +1435,9 @@ async def image_install() -> list[dict[str, Any]]: This is NOT for downloading images. Images must be uploaded first (via the GNS3 Web UI). If an uploaded image matches a known appliance, a template is automatically created. - Images already referenced by existing templates are skipped. + Returns {"created": [...], "skipped": [...]}: images already referenced by existing + templates are skipped, and no template is auto-created when one with the same name + already exists (regardless of version). """ return await asyncio.to_thread(_run_handler_sync, install_images_handler, {}) diff --git a/gns3server/agent/mcp/images.py b/gns3server/agent/mcp/images.py index 3b8169a71..3fcbfd17d 100644 --- a/gns3server/agent/mcp/images.py +++ b/gns3server/agent/mcp/images.py @@ -72,6 +72,9 @@ def prune_images_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> di def install_images_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: conn = _get_connector(gns3_ctx) - # Returns 204 No Content on success (empty body, no .json()) - conn.http_call("post", f"{conn.base_url}/images/install") + response = conn.http_call("post", f"{conn.base_url}/images/install") + if response.content: + # the install endpoint reports which templates were created or skipped + return response.json() + # tolerate an empty body in case an older server still replies with 204 return {"message": "Image installation completed"} diff --git a/gns3server/api/routes/controller/images.py b/gns3server/api/routes/controller/images.py index 26b9a2ed2..70c49b098 100644 --- a/gns3server/api/routes/controller/images.py +++ b/gns3server/api/routes/controller/images.py @@ -206,19 +206,24 @@ async def prune_images( @router.post( "/install", - status_code=status.HTTP_204_NO_CONTENT, + status_code=status.HTTP_200_OK, dependencies=[Depends(has_privilege("Image.Allocate"))] ) async def install_images( images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)), templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)) -) -> None: +) -> dict: """ Attempt to automatically create templates based on image checksums. + Returns the list of created templates and the list of skipped + candidates (with the reason why they were skipped). + Required privilege: Image.Allocate """ + created = [] + skipped = [] skip_images = get_builtin_disks() images = await images_repo.get_images() for image in images: @@ -229,8 +234,12 @@ async def install_images( if templates: # the image is already used by a template log.warning(f"Image '{image.path}' is used by one or more templates") + skipped.append({ + "name": image.filename, + "reason": "image is already used by one or more templates", + }) continue - await Controller.instance().appliance_manager.install_appliances_from_image( + results = await Controller.instance().appliance_manager.install_appliances_from_image( image.path, image.checksum, images_repo, @@ -239,6 +248,12 @@ async def install_images( None, os.path.dirname(image.path) ) + for result in results: + if result.get("status") == "created": + created.append({k: v for k, v in result.items() if k != "status"}) + else: + skipped.append({k: v for k, v in result.items() if k != "status"}) + return {"created": created, "skipped": skipped} @router.get( diff --git a/gns3server/controller/appliance_manager.py b/gns3server/controller/appliance_manager.py index 7ee36a4d4..e411b9852 100644 --- a/gns3server/controller/appliance_manager.py +++ b/gns3server/controller/appliance_manager.py @@ -245,11 +245,16 @@ class ApplianceManager: rbac_repo: RbacRepository, current_user: schemas.User, image_dir: str - ) -> None: + ) -> List[dict]: """ - Install appliances using an image checksum + Install appliances using an image checksum. + + Returns a manifest of what happened: one entry per attempted template, + either {"status": "created", ...template fields} or + {"status": "skipped", "name", "reason"}. """ + results: List[dict] = [] appliances_info = self._find_appliances_from_image_checksum(image_checksum) for appliance, image_version in appliances_info: try: @@ -257,15 +262,48 @@ class ApplianceManager: ApplianceModel.model_validate(appliance.asdict()) except ValidationError as e: log.warning(f"Could not validate appliance '{appliance.id}': {e}") + results.append({ + "status": "skipped", + "name": appliance.name, + "reason": f"could not validate appliance '{appliance.id}': {e}", + }) + continue if appliance.versions: for version in appliance.versions: if version.get("name") == image_version: try: await self._find_appliance_version_images(appliance, version, images_repo, image_dir) template_data = await self._appliance_to_template(appliance, version) - await self._create_template(template_data, templates_repo, rbac_repo, current_user) + name = template_data.get("name") + existing = await templates_repo.get_template_by_name(name) if name else None + if existing is not None: + # never automatically create a second template with the same + # name: the name+version check in TemplatesService would allow + # duplicates when the appliance version differs, but two + # templates sharing a name is never what the user asked for here + log.warning(f"Template '{name}' already exists, skipping automatic template creation") + results.append({ + "status": "skipped", + "name": name, + "reason": f"a template named '{name}' already exists", + }) + continue + template = await self._create_template(template_data, templates_repo, rbac_repo, current_user) + results.append({ + "status": "created", + "template_id": str(template.get("template_id")), + "name": template.get("name"), + "version": template.get("version"), + "template_type": template.get("template_type"), + }) except (ControllerError, InvalidImageError) as e: log.warning(f"Could not automatically create template using image '{image_path}': {e}") + results.append({ + "status": "skipped", + "name": appliance.name, + "reason": str(e), + }) + return results async def install_appliance( self, diff --git a/gns3server/db/repositories/templates.py b/gns3server/db/repositories/templates.py index 0f03c74a3..a08b58053 100644 --- a/gns3server/db/repositories/templates.py +++ b/gns3server/db/repositories/templates.py @@ -71,6 +71,17 @@ class TemplatesRepository(BaseRepository): result = await self._db_session.execute(query) return result.scalars().first() + async def get_template_by_name(self, name: str) -> Union[None, models.Template]: + """ + Return the first template with this name, regardless of version. + """ + + query = select(models.Template).\ + options(selectinload(models.Template.images)).\ + where(models.Template.name == name) + result = await self._db_session.execute(query) + return result.scalars().first() + async def get_templates(self) -> List[models.Template]: query = select(models.Template).options(selectinload(models.Template.images)) diff --git a/tests/agent/mcp/test_handlers.py b/tests/agent/mcp/test_handlers.py index 91f1b9f72..5b84dae64 100644 --- a/tests/agent/mcp/test_handlers.py +++ b/tests/agent/mcp/test_handlers.py @@ -523,6 +523,36 @@ class TestTemplate: assert "deleted" in str(result).lower() +# ── Image ─────────────────────────────────────────────────────────────── + + +class TestImage: + + mod = "images" + + def test_install_manifest(self, ctx): + from gns3server.agent.mcp.images import install_images_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: + conn = _mock_conn({ + "created": [{"template_id": "t1", "name": "Empty VM", "version": "100G", "template_type": "qemu"}], + "skipped": [{"name": "csr1000v.qcow2", "reason": "image is already used by one or more templates"}], + }) + m.return_value = conn + result = install_images_handler({}, ctx) + assert result["created"][0]["name"] == "Empty VM" + assert result["skipped"][0]["name"] == "csr1000v.qcow2" + + def test_install_empty_body(self, ctx): + from gns3server.agent.mcp.images import install_images_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: + conn = _mock_conn() + conn.http_call.return_value.content = b"" + conn.http_call.return_value.json.side_effect = json.JSONDecodeError("Expecting value", "", 0) + m.return_value = conn + result = install_images_handler({}, ctx) + assert result == {"message": "Image installation completed"} + + # ── Marker (traffic-insight) ──────────────────────────────────────────── diff --git a/tests/api/routes/controller/test_images.py b/tests/api/routes/controller/test_images.py index 8760c9075..8a7a4d68c 100644 --- a/tests/api/routes/controller/test_images.py +++ b/tests/api/routes/controller/test_images.py @@ -328,10 +328,54 @@ class TestImageRoutes: with asyncio_patch("gns3server.api.routes.controller.images.get_builtin_disks", return_value=[]) as mock: response = await client.post(app.url_path_for("install_images")) assert mock.called - assert response.status_code == status.HTTP_204_NO_CONTENT + assert response.status_code == status.HTTP_200_OK + created = response.json()["created"] + assert len(created) == 1 + assert created[0]["name"] == "Empty VM" + assert created[0]["version"] == "100G" templates_repo = TemplatesRepository(db_session) templates = await templates_repo.get_templates() assert len(templates) == 1 assert templates[0].name == "Empty VM" - assert templates[0].version == "100G" \ No newline at end of file + assert templates[0].version == "100G" + await templates_repo.delete_template(templates[0].template_id) + + async def test_install_all_skips_existing_template_name( + self, app: FastAPI, + client: AsyncClient, + db_session: AsyncSession, + controller: Controller + ) -> None: + # two images matching two versions of the same appliance must not + # produce two templates with the same name + # + # earlier tests in this class uploaded the same filenames from different + # (function-scoped) images directories; drop those stale rows so the + # install route only sees this test's uploads + images_repo = ImagesRepository(db_session) + for image_name in ("empty30G.qcow2", "empty100G.qcow2"): + await images_repo.delete_image(image_name) + for image_path in ("tests/resources/empty30G.qcow2", "tests/resources/empty100G.qcow2"): + with open(image_path, "rb") as f: + image_data = f.read() + response = await client.post( + app.url_path_for("upload_image", image_path=os.path.basename(image_path)), + content=image_data) + assert response.status_code == status.HTTP_201_CREATED + + controller.appliance_manager.load_appliances() # make sure appliances are loaded + with asyncio_patch("gns3server.api.routes.controller.images.get_builtin_disks", return_value=[]): + response = await client.post(app.url_path_for("install_images")) + assert response.status_code == status.HTTP_200_OK + + manifest = response.json() + assert len(manifest["created"]) == 1 + assert manifest["created"][0]["name"] == "Empty VM" + assert any("already exists" in skipped["reason"] for skipped in manifest["skipped"]) + + templates_repo = TemplatesRepository(db_session) + templates = await templates_repo.get_templates() + assert len(templates) == 1 + assert templates[0].name == "Empty VM" + await templates_repo.delete_template(templates[0].template_id) \ No newline at end of file From 2951af6eabf90bf891ff1ac52349adba77e77f3e Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 26 Aug 2026 00:36:47 +0800 Subject: [PATCH 12/14] fix: reject non-VPCS nodes in the VPCS config tool VPCS syntax typed into another node's console is silently discarded (IOS answers % Invalid input) while the tool still reports success. get_device_ports_from_topology now carries the GNS3 node type through to callers, and VPCSCommands fails device preparation with a per-device error unless the node type is vpcs. --- .../tools_v2/vpcs_tools_netmiko.py | 19 ++++++ .../utils/get_gns3_device_port.py | 6 ++ gns3server/agent/mcp/__init__.py | 3 + tests/agent/mcp/test_device_tools.py | 65 +++++++++++++++++++ 4 files changed, 93 insertions(+) create mode 100644 tests/agent/mcp/test_device_tools.py diff --git a/gns3server/agent/gns3_copilot/tools_v2/vpcs_tools_netmiko.py b/gns3server/agent/gns3_copilot/tools_v2/vpcs_tools_netmiko.py index 221bf9bed..4eda2d3fd 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/vpcs_tools_netmiko.py +++ b/gns3server/agent/gns3_copilot/tools_v2/vpcs_tools_netmiko.py @@ -461,6 +461,25 @@ class VPCSCommands(BaseTool): port = device_ports[device_name]["port"] + node_type = device_ports[device_name].get("node_type") + if node_type != "vpcs": + # VPCS syntax typed into another node's CLI is silently + # discarded (e.g. IOS answers "% Invalid input"), so reject + # mismatched devices before a console session is opened + logger.error( + "Device '%s' is a %s node, not a VPCS node", + device_name, + node_type or "unknown-type", + ) + hosts_data[device_name] = { + "error": ( + f"Device '{device_name}' is a {node_type or 'unknown-type'} node, " + "not a VPCS node; use device_config_send / device_show_run " + "for network devices" + ) + } + continue + # VPCS devices use gns3_vpcs_telnet device type hosts_data[device_name] = { "port": port, diff --git a/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py b/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py index 3ef331157..b3127574e 100644 --- a/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py +++ b/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py @@ -59,6 +59,7 @@ def get_device_ports_from_topology( "device_name": { "port": console_port, "platform": "huawei", # Extracted from tags + "node_type": "vpcs", # GNS3 node type from the topology "groups": ["network_devices"], # For inheriting shared settings "connection_options": { "netmiko": { @@ -160,9 +161,14 @@ def get_device_ports_from_topology( # This is the Nornir best practice - each host has its own # connection configuration (device_type), while sharing common # settings (hostname, timeout) via group inheritance. + # node_type (the GNS3 node type, e.g. vpcs/iou/docker) lets callers + # reject mismatched devices before opening a console connection; + # DictInventory ignores keys it does not know, so carrying it here + # is safe for entries fed straight into Nornir. host_entry = { "port": node_info["console_port"], "platform": platform, + "node_type": node_info.get("type"), "groups": ["network_devices"], # For inheriting hostname, timeout, etc. "connection_options": { "netmiko": { diff --git a/gns3server/agent/mcp/__init__.py b/gns3server/agent/mcp/__init__.py index 2d9e39a89..780c05e62 100644 --- a/gns3server/agent/mcp/__init__.py +++ b/gns3server/agent/mcp/__init__.py @@ -1517,6 +1517,9 @@ async def vpcs_config_set( ) -> list[dict[str, Any]]: """Configure VPCS devices (set IP addresses, gateway, etc.). + Only VPCS nodes are accepted: any other node type in device_configs fails + with a per-device error instead of typing VPCS syntax into its CLI. + VPCS-specific configuration commands: - ip
/ Set IP and gateway - save Save config to startup.vpc diff --git a/tests/agent/mcp/test_device_tools.py b/tests/agent/mcp/test_device_tools.py new file mode 100644 index 000000000..fc996332e --- /dev/null +++ b/tests/agent/mcp/test_device_tools.py @@ -0,0 +1,65 @@ +""" +Device config tool tests with mocked topology and Nornir layers. + +Covers the VPCS node-type guard and the error contract shared by +device_config_send / device_show_run / vpcs_config_set. +""" +import json + +import pytest +from unittest.mock import MagicMock, patch + +VPCS_MOD = "gns3server.agent.gns3_copilot.tools_v2.vpcs_tools_netmiko" + + +def _topology_ports(node_type): + """Mocked get_device_ports_from_topology return value for one device.""" + return {"PC1": {"port": 5000, "node_type": node_type}} + + +class TestVPCSNodeTypeGuard: + + def test_non_vpcs_node_is_rejected(self): + from gns3server.agent.gns3_copilot.tools_v2.vpcs_tools_netmiko import VPCSCommands + + with patch(f"{VPCS_MOD}.get_device_ports_from_topology", + return_value=_topology_ports("iou")) as topo: + result = VPCSCommands()._run(json.dumps({ + "project_id": "0c0fde25-6ead-4413-a283-ea8fd2324291", + "device_configs": [{"device_name": "PC1", "commands": ["ip 10.0.0.1/24"]}], + })) + assert topo.called + assert len(result) == 1 + assert result[0]["device_name"] == "PC1" + assert result[0]["status"] == "failed" + assert "not a VPCS node" in result[0]["error"] + + def test_missing_node_type_is_rejected(self): + from gns3server.agent.gns3_copilot.tools_v2.vpcs_tools_netmiko import VPCSCommands + + with patch(f"{VPCS_MOD}.get_device_ports_from_topology", + return_value={"PC1": {"port": 5000}}): + result = VPCSCommands()._run(json.dumps({ + "project_id": "0c0fde25-6ead-4413-a283-ea8fd2324291", + "device_configs": [{"device_name": "PC1", "commands": ["ip 10.0.0.1/24"]}], + })) + assert result[0]["status"] == "failed" + assert "unknown-type" in result[0]["error"] + + def test_vpcs_node_passes_the_guard(self): + from gns3server.agent.gns3_copilot.tools_v2.vpcs_tools_netmiko import VPCSCommands + + tool = VPCSCommands() + nornir = MagicMock() + host_result = MagicMock(failed=False) + host_result.result = "OK" + nornir.run.return_value = {"PC1": host_result} + with patch(f"{VPCS_MOD}.get_device_ports_from_topology", + return_value=_topology_ports("vpcs")), \ + patch.object(VPCSCommands, "_initialize_nornir", return_value=nornir): + result = tool._run(json.dumps({ + "project_id": "0c0fde25-6ead-4413-a283-ea8fd2324291", + "device_configs": [{"device_name": "PC1", "commands": ["ip 10.0.0.1/24"]}], + })) + assert result[0]["status"] == "success" + assert result[0]["output"] == "OK" From 6703e504873c1865587721b4d66e308b41fe655c Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 26 Aug 2026 00:39:38 +0800 Subject: [PATCH 13/14] fix: unify the device tool error contract The device tools reported failures in three shapes: topology-level entries with only an error key, per-device entries with status 'error' plus the reason under output (VPCS tool only), and raw exceptions leaking out of template rendering. Every in-band error entry now carries status 'failed' and an error message, and invalid Jinja2 templates are reported in-band instead of escaping the handler. --- .../tools_v2/config_tools_nornir.py | 16 ++-- .../tools_v2/display_tools_nornir.py | 16 ++-- .../tools_v2/vpcs_tools_netmiko.py | 28 +++---- gns3server/agent/mcp/__init__.py | 10 +++ gns3server/agent/mcp/device_config.py | 16 ++-- tests/agent/mcp/test_device_tools.py | 74 +++++++++++++++++++ 6 files changed, 125 insertions(+), 35 deletions(-) diff --git a/gns3server/agent/gns3_copilot/tools_v2/config_tools_nornir.py b/gns3server/agent/gns3_copilot/tools_v2/config_tools_nornir.py index 41ed58f9d..285e221dc 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/config_tools_nornir.py +++ b/gns3server/agent/gns3_copilot/tools_v2/config_tools_nornir.py @@ -227,7 +227,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): ) except ValueError as e: logger.error("Failed to prepare device hosts data: %s", e) - return [{"error": str(e)}] + return [{"status": "failed", "error": str(e)}] # Check if any devices have errors (e.g., missing device_type tag) error_devices = { @@ -254,7 +254,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): dynamic_nr = self._initialize_nornir(hosts_data) except ValueError as e: logger.error("Failed to initialize Nornir: %s", e) - return [{"error": str(e)}] + return [{"status": "failed", "error": str(e)}] results = [] @@ -278,7 +278,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): logger.error( "Error executing configurations on all devices: %s", e ) - return [{"error": f"Execution error: {str(e)}"}] + return [{"status": "failed", "error": f"Execution error: {str(e)}"}] logger.info( "Multiple device configuration execution completed. Results: %s", @@ -368,7 +368,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): "Invalid JSON string received as tool input: %s", e ) return ( - [{"error": f"Invalid JSON string input from model: {e}"}], + [{"status": "failed", "error": f"Invalid JSON string input from model: {e}"}], None, ) else: @@ -389,7 +389,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): if not project_id: error_msg = "Missing required 'project_id' field in input" logger.error(error_msg) - return ([{"error": error_msg}], None) + return ([{"status": "failed", "error": error_msg}], None) if not self._validate_project_id(project_id): error_msg = ( @@ -397,13 +397,13 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): "Expected UUID format." ) logger.error(error_msg) - return ([{"error": error_msg}], None) + return ([{"status": "failed", "error": error_msg}], None) # Validate device_configs if not isinstance(device_configs, list): error_msg = "'device_configs' must be an array" logger.error(error_msg) - return ([{"error": error_msg}], None) + return ([{"status": "failed", "error": error_msg}], None) if not device_configs: logger.warning("Device configs list is empty.") @@ -426,7 +426,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): f"{type(parsed_input).__name__}" ) logger.error(error_msg) - return ([{"error": error_msg}], None) + return ([{"status": "failed", "error": error_msg}], None) def _validate_project_id(self, project_id: str) -> bool: """ diff --git a/gns3server/agent/gns3_copilot/tools_v2/display_tools_nornir.py b/gns3server/agent/gns3_copilot/tools_v2/display_tools_nornir.py index 8fa056650..7c4f45622 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/display_tools_nornir.py +++ b/gns3server/agent/gns3_copilot/tools_v2/display_tools_nornir.py @@ -223,7 +223,7 @@ class ExecuteMultipleDeviceCommands(BaseTool): ) except ValueError as e: logger.error("Failed to prepare device hosts data: %s", e) - return [{"error": str(e)}] + return [{"status": "failed", "error": str(e)}] # Check if any devices have errors (e.g., missing device_type tag) error_devices = { @@ -250,7 +250,7 @@ class ExecuteMultipleDeviceCommands(BaseTool): dynamic_nr = self._initialize_nornir(hosts_data) except ValueError as e: logger.error("Failed to initialize Nornir: %s", e) - return [{"error": str(e)}] + return [{"status": "failed", "error": str(e)}] results = [] @@ -272,7 +272,7 @@ class ExecuteMultipleDeviceCommands(BaseTool): except Exception as e: # Overall execution failed logger.error("Error executing display on all devices: %s", e) - return [{"error": f"Execution error: {str(e)}"}] + return [{"status": "failed", "error": f"Execution error: {str(e)}"}] logger.debug( "Multiple device display execution completed. Results: %s", @@ -370,7 +370,7 @@ class ExecuteMultipleDeviceCommands(BaseTool): "Invalid JSON string received as tool input: %s", e ) return ( - [{"error": f"Invalid JSON string input from model: {e}"}], + [{"status": "failed", "error": f"Invalid JSON string input from model: {e}"}], None, ) else: @@ -391,18 +391,18 @@ class ExecuteMultipleDeviceCommands(BaseTool): if not project_id: error_msg = "Missing required 'project_id' field in input" logger.error(error_msg) - return ([{"error": error_msg}], None) + return ([{"status": "failed", "error": error_msg}], None) if not self._validate_project_id(project_id): error_msg = f"Invalid project_id: {project_id}. Expected UUID." logger.error(error_msg) - return ([{"error": error_msg}], None) + return ([{"status": "failed", "error": error_msg}], None) # Validate device_configs if not isinstance(device_configs, list): error_msg = "'device_configs' must be an array" logger.error(error_msg) - return ([{"error": error_msg}], None) + return ([{"status": "failed", "error": error_msg}], None) if not device_configs: logger.warning("Device configs list is empty.") @@ -423,7 +423,7 @@ class ExecuteMultipleDeviceCommands(BaseTool): f"or legacy JSON array, got {type(parsed_input).__name__}" ) logger.error(error_msg) - return ([{"error": error_msg}], None) + return ([{"status": "failed", "error": error_msg}], None) def _validate_project_id(self, project_id: str) -> bool: """ diff --git a/gns3server/agent/gns3_copilot/tools_v2/vpcs_tools_netmiko.py b/gns3server/agent/gns3_copilot/tools_v2/vpcs_tools_netmiko.py index 4eda2d3fd..8339baefc 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/vpcs_tools_netmiko.py +++ b/gns3server/agent/gns3_copilot/tools_v2/vpcs_tools_netmiko.py @@ -196,7 +196,7 @@ class VPCSCommands(BaseTool): ) except ValueError as e: logger.error("Failed to prepare device hosts data: %s", e) - return [{"error": str(e)}] + return [{"status": "failed", "error": str(e)}] # Check if any devices have errors (e.g., missing device) error_devices = { @@ -223,7 +223,7 @@ class VPCSCommands(BaseTool): dynamic_nr = self._initialize_nornir(hosts_data) except ValueError as e: logger.error("Failed to initialize Nornir: %s", e) - return [{"error": str(e)}] + return [{"status": "failed", "error": str(e)}] results = [] @@ -244,7 +244,7 @@ class VPCSCommands(BaseTool): except Exception as e: # Overall execution failed logger.error("Error executing commands on all VPCS devices: %s", e) - return [{"error": f"Execution error: {str(e)}"}] + return [{"status": "failed", "error": f"Execution error: {str(e)}"}] logger.debug( "VPCS command execution completed. Results: %s", @@ -338,7 +338,7 @@ class VPCSCommands(BaseTool): "Invalid JSON string received as tool input: %s", e ) return ( - [{"error": f"Invalid JSON string input from model: {e}"}], + [{"status": "failed", "error": f"Invalid JSON string input from model: {e}"}], None, ) else: @@ -357,18 +357,18 @@ class VPCSCommands(BaseTool): if not project_id: error_msg = "Missing required 'project_id' field in input" logger.error(error_msg) - return ([{"error": error_msg}], None) + return ([{"status": "failed", "error": error_msg}], None) if not self._validate_project_id(project_id): error_msg = f"Invalid project_id: {project_id}. Expected UUID." logger.error(error_msg) - return ([{"error": error_msg}], None) + return ([{"status": "failed", "error": error_msg}], None) # Validate device_configs if not isinstance(device_configs, list): error_msg = "'device_configs' must be an array" logger.error(error_msg) - return ([{"error": error_msg}], None) + return ([{"status": "failed", "error": error_msg}], None) if not device_configs: logger.warning("Device configs list is empty.") @@ -382,7 +382,7 @@ class VPCSCommands(BaseTool): f"got {type(parsed_input).__name__}" ) logger.error(error_msg) - return ([{"error": error_msg}], None) + return ([{"status": "failed", "error": error_msg}], None) def _validate_project_id(self, project_id: str) -> bool: """ @@ -578,8 +578,8 @@ class VPCSCommands(BaseTool): if device_name in hosts_data and "error" in hosts_data[device_name]: results.append({ "device_name": device_name, - "status": "error", - "output": hosts_data[device_name]["error"], + "status": "failed", + "error": hosts_data[device_name]["error"], "commands": device_config["commands"], }) continue @@ -593,8 +593,8 @@ class VPCSCommands(BaseTool): error_msg = str(host_result.result) if host_result.result else "Unknown error" results.append({ "device_name": device_name, - "status": "error", - "output": error_msg, + "status": "failed", + "error": error_msg, "commands": device_config["commands"], }) else: @@ -609,8 +609,8 @@ class VPCSCommands(BaseTool): # Device not in task result (shouldn't happen) results.append({ "device_name": device_name, - "status": "error", - "output": f"Device '{device_name}' not in task results", + "status": "failed", + "error": f"Device '{device_name}' not in task results", "commands": device_config["commands"], }) diff --git a/gns3server/agent/mcp/__init__.py b/gns3server/agent/mcp/__init__.py index 780c05e62..3b28b3cea 100644 --- a/gns3server/agent/mcp/__init__.py +++ b/gns3server/agent/mcp/__init__.py @@ -1471,6 +1471,10 @@ async def device_config_send( Devices must be started first (use node_start or node_start_all). Device type is auto-detected from the 'device_type:' tag on each node. Common device types: cisco_ios_telnet, cisco_xr_telnet, huawei_telnet, gns3_huawei_telnet_ce + + Error contract: every failure is reported in-band as an entry with + status "failed" and an "error" message (per-device entries also carry + device_name and commands). """ params = {"project_id": project_id, "device_configs": device_configs} if template is not None: @@ -1501,6 +1505,10 @@ async def device_show_run( (e.g. device_type:cisco_ios_telnet, device_type:gns3_huawei_telnet_ce). Nodes without this tag will fail with "device_type tag not found". Docker/Linux nodes are not supported (use node_console instead). + + Error contract: every failure is reported in-band as an entry with + status "failed" and an "error" message (per-device entries also carry + device_name and commands). """ params = {"project_id": project_id, "device_configs": device_configs} if template is not None: @@ -1519,6 +1527,8 @@ async def vpcs_config_set( Only VPCS nodes are accepted: any other node type in device_configs fails with a per-device error instead of typing VPCS syntax into its CLI. + Every failure is reported in-band as an entry with status "failed" + and an "error" message. VPCS-specific configuration commands: - ip
/ Set IP and gateway diff --git a/gns3server/agent/mcp/device_config.py b/gns3server/agent/mcp/device_config.py index 10d4b7634..990f4b097 100644 --- a/gns3server/agent/mcp/device_config.py +++ b/gns3server/agent/mcp/device_config.py @@ -51,7 +51,13 @@ def _render_template(template: str, device_configs: list[dict], commands_field: Args: commands_field: field name for the rendered commands, e.g. "config_commands", "commands" """ - jinja = JinjaTemplate(template) + try: + jinja = JinjaTemplate(template) + except JinjaError as e: + # a syntactically invalid template must not escape as a raw exception + error_msg = f"Template rendering failed: {e}" + log.error(error_msg) + return [{"status": "failed", "error": error_msg}] merged: dict[str, dict] = {} for dev in device_configs: name = dev.get("device_name") @@ -69,7 +75,7 @@ def _render_template(template: str, device_configs: list[dict], commands_field: except JinjaError as e: error_msg = f"Template rendering failed for '{name}': {e}" log.error(error_msg) - return [{"error": error_msg}] + return [{"status": "failed", "error": error_msg}] return list(merged.values()) @@ -81,7 +87,7 @@ def device_config_send_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) device_configs = params.get("device_configs") template = params.get("template") if not project_id or not device_configs: - return [{"error": "project_id and device_configs are required"}] + return [{"status": "failed", "error": "project_id and device_configs are required"}] if template: device_configs = _render_template(template, device_configs, commands_field="config_commands") @@ -108,7 +114,7 @@ def device_show_run_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> device_configs = params.get("device_configs") template = params.get("template") if not project_id or not device_configs: - return [{"error": "project_id and device_configs (list of {device_name, commands}) are required"}] + return [{"status": "failed", "error": "project_id and device_configs (list of {device_name, commands}) are required"}] if template: device_configs = _render_template(template, device_configs, commands_field="commands") @@ -134,7 +140,7 @@ def vpcs_config_set_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> project_id = params.get("project_id") device_configs = params.get("device_configs") if not project_id or not device_configs: - return [{"error": "project_id and device_configs are required"}] + return [{"status": "failed", "error": "project_id and device_configs are required"}] from gns3server.agent.gns3_copilot.tools_v2.vpcs_tools_netmiko import VPCSCommands diff --git a/tests/agent/mcp/test_device_tools.py b/tests/agent/mcp/test_device_tools.py index fc996332e..5c5cd8c63 100644 --- a/tests/agent/mcp/test_device_tools.py +++ b/tests/agent/mcp/test_device_tools.py @@ -63,3 +63,77 @@ class TestVPCSNodeTypeGuard: })) assert result[0]["status"] == "success" assert result[0]["output"] == "OK" + + def test_execution_failure_reports_failed_with_error(self): + from gns3server.agent.gns3_copilot.tools_v2.vpcs_tools_netmiko import VPCSCommands + + tool = VPCSCommands() + nornir = MagicMock() + host_result = MagicMock(failed=True) + host_result.result = "Command failed (ReadTimeout)" + nornir.run.return_value = {"PC1": host_result} + with patch(f"{VPCS_MOD}.get_device_ports_from_topology", + return_value=_topology_ports("vpcs")), \ + patch.object(VPCSCommands, "_initialize_nornir", return_value=nornir): + result = tool._run(json.dumps({ + "project_id": "0c0fde25-6ead-4413-a283-ea8fd2324291", + "device_configs": [{"device_name": "PC1", "commands": ["ip 10.0.0.1/24"]}], + })) + assert result[0]["status"] == "failed" + assert result[0]["error"] == "Command failed (ReadTimeout)" + assert "output" not in result[0] + + +class TestDeviceToolErrorContract: + """ + Every in-band error entry carries status "failed" plus an "error" + message, whether it is topology-level (no device) or per-device. + """ + + def test_topology_level_error_has_status(self): + from gns3server.agent.gns3_copilot.tools_v2.vpcs_tools_netmiko import VPCSCommands + + with patch(f"{VPCS_MOD}.get_device_ports_from_topology", + side_effect=ValueError("topology unreachable")): + result = VPCSCommands()._run(json.dumps({ + "project_id": "0c0fde25-6ead-4413-a283-ea8fd2324291", + "device_configs": [{"device_name": "PC1", "commands": ["ip 10.0.0.1/24"]}], + })) + assert result == [{"status": "failed", "error": "topology unreachable"}] + + def test_config_tool_topology_level_error_has_status(self): + from gns3server.agent.gns3_copilot.tools_v2.config_tools_nornir import ( + ExecuteMultipleDeviceConfigCommands, + ) + + with patch("gns3server.agent.gns3_copilot.tools_v2.config_tools_nornir" + ".get_device_ports_from_topology", + side_effect=ValueError("no valid devices")): + result = ExecuteMultipleDeviceConfigCommands()._run(json.dumps({ + "project_id": "0c0fde25-6ead-4413-a283-ea8fd2324291", + "device_configs": [{"device_name": "R1", "config_commands": ["int lo0"]}], + })) + assert result == [{"status": "failed", "error": "no valid devices"}] + + def test_mcp_handler_param_error_has_status(self, ctx=None): + from gns3server.agent.mcp.device_config import ( + device_config_send_handler, + device_show_run_handler, + vpcs_config_set_handler, + ) + + for handler in (device_config_send_handler, device_show_run_handler, vpcs_config_set_handler): + result = handler({}, {"server_url": "http://x", "jwt_token": "t"}) + assert result == [{ + "status": "failed", + "error": result[0]["error"], # message text may differ per handler + }] + assert "required" in result[0]["error"] + + def test_template_render_error_has_status(self): + from gns3server.agent.mcp.device_config import _render_template + + result = _render_template("{{ unclosed", [{"device_name": "R1", "vars": {"n": 1}}]) + assert len(result) == 1 + assert result[0]["status"] == "failed" + assert "Template rendering failed" in result[0]["error"] From df25e037ea627bdf86e311ca607c2a71cf65132e Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 26 Aug 2026 00:44:57 +0800 Subject: [PATCH 14/14] fix: accept the 'local' compute id in compute tools The compute_get/compute_images MCP tools typed compute_id as a UUID, so passing 'local' (the actual id of the built-in compute, which the compute_images description itself pointed to) was rejected by schema validation. Both tools now take a string defaulting to 'local', and the compute_get REST route resolves 'local' through the controller since the local compute has no database entry. --- gns3server/agent/mcp/__init__.py | 17 +++++---- gns3server/agent/mcp/computes.py | 8 ++--- gns3server/services/computes.py | 6 +++- tests/agent/mcp/test_handlers.py | 38 ++++++++++++++++++++ tests/api/routes/controller/test_computes.py | 10 ++++++ 5 files changed, 63 insertions(+), 16 deletions(-) diff --git a/gns3server/agent/mcp/__init__.py b/gns3server/agent/mcp/__init__.py index 3b28b3cea..a74031f5d 100644 --- a/gns3server/agent/mcp/__init__.py +++ b/gns3server/agent/mcp/__init__.py @@ -31,7 +31,6 @@ import json import asyncio import logging import socket -import uuid from uuid import UUID import bcrypt from typing import Any, Annotated @@ -772,12 +771,12 @@ async def compute_list() -> list[dict[str, Any]]: @mcp.tool() async def compute_get( - compute_id: Annotated[uuid.UUID, Field(description="Compute UUID from compute_list output")], + compute_id: Annotated[str, Field(description="Compute ID: 'local' (default) for the built-in local compute, or a compute UUID from compute_list")] = "local", ) -> list[dict[str, Any]]: - """Get detailed information about a registered remote compute node. + """Get detailed information about a compute node. - NOTE: Only works for computes registered in the database (returned by compute_list). - For the built-in local compute info, use server_statistics instead. + Accepts 'local' for the built-in local compute or a UUID from compute_list + for a registered remote compute. """ return await asyncio.to_thread(_run_handler_sync, get_compute_handler, {"compute_id": compute_id}) @@ -785,12 +784,12 @@ async def compute_get( @mcp.tool() async def compute_images( emulator: Annotated[str, Field(description="Emulator type (e.g. qemu, iou, docker)")], - compute_id: Annotated[uuid.UUID, Field(description="Compute UUID from compute_list output")], + compute_id: Annotated[str, Field(description="Compute ID: 'local' (default) for the built-in local compute, or a compute UUID from compute_list")] = "local", ) -> list[dict[str, Any]]: - """List available images for an emulator on a registered compute node. + """List available images for an emulator on a compute node. - NOTE: Only works for computes registered in the database. - For the local compute, the default compute_id is typically found via server_statistics. + Accepts 'local' for the built-in local compute or a UUID from compute_list + for a registered remote compute. """ return await asyncio.to_thread(_run_handler_sync, get_compute_images_handler, { "emulator": emulator, "compute_id": compute_id, diff --git a/gns3server/agent/mcp/computes.py b/gns3server/agent/mcp/computes.py index e3abb65bb..254dc53f1 100644 --- a/gns3server/agent/mcp/computes.py +++ b/gns3server/agent/mcp/computes.py @@ -42,20 +42,16 @@ def list_computes_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> d def get_compute_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: - compute_id = params.get("compute_id") - if not compute_id: - return {"error": "compute_id is required (use compute_list to get the UUID)"} + compute_id = params.get("compute_id") or "local" conn = _get_connector(gns3_ctx) return conn.http_call("get", f"{conn.base_url}/computes/{compute_id}").json() def get_compute_images_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: emulator = params.get("emulator") - compute_id = params.get("compute_id") + compute_id = params.get("compute_id") or "local" if not emulator: return {"error": "emulator is required (e.g. qemu, iou, docker)"} - if not compute_id: - return {"error": "compute_id is required (use compute_list to get the UUID)"} conn = _get_connector(gns3_ctx) images = conn.http_call("get", f"{conn.base_url}/computes/{compute_id}/{emulator}/images").json() return {"images": images, "count": len(images)} diff --git a/gns3server/services/computes.py b/gns3server/services/computes.py index 995cb7698..a97c8fcf4 100644 --- a/gns3server/services/computes.py +++ b/gns3server/services/computes.py @@ -54,8 +54,12 @@ class ComputesService: self._controller.notification.controller_emit("compute.created", compute.asdict()) return db_compute - async def get_compute(self, compute_id: Union[str, UUID]) -> models.Compute: + async def get_compute(self, compute_id: Union[str, UUID]) -> Union[models.Compute, dict]: + if str(compute_id) == "local": + # the built-in local compute only lives in the controller, not in the database; + # drop unset fields (e.g. user) as the response schema types them as str + return {k: v for k, v in self._controller.get_compute("local").asdict().items() if v is not None} db_compute = await self._computes_repo.get_compute(compute_id) if not db_compute: raise ControllerNotFoundError(f"Compute '{compute_id}' not found") diff --git a/tests/agent/mcp/test_handlers.py b/tests/agent/mcp/test_handlers.py index 5b84dae64..b71b33ee0 100644 --- a/tests/agent/mcp/test_handlers.py +++ b/tests/agent/mcp/test_handlers.py @@ -553,6 +553,44 @@ class TestImage: assert result == {"message": "Image installation completed"} +class TestCompute: + + mod = "computes" + + def test_get_local_by_default(self, ctx): + from gns3server.agent.mcp.computes import get_compute_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: + conn = _mock_conn({"compute_id": "local", "name": "local"}) + m.return_value = conn + result = get_compute_handler({}, ctx) + assert result["compute_id"] == "local" + url = conn.http_call.call_args[0][1] + assert url.endswith("/computes/local") + + def test_get_explicit_compute_id(self, ctx): + from gns3server.agent.mcp.computes import get_compute_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: + conn = _mock_conn({"compute_id": "4fcfb6b5-5b0b-4f43-bd5e-e8ae2a69c8e6"}) + m.return_value = conn + get_compute_handler({"compute_id": "4fcfb6b5-5b0b-4f43-bd5e-e8ae2a69c8e6"}, ctx) + url = conn.http_call.call_args[0][1] + assert url.endswith("/computes/4fcfb6b5-5b0b-4f43-bd5e-e8ae2a69c8e6") + + def test_images_local_by_default(self, ctx): + from gns3server.agent.mcp.computes import get_compute_images_handler + with patch(f"{BASE}.{self.mod}._get_connector") as m: + conn = _mock_conn(["img1.qcow2"]) + m.return_value = conn + result = get_compute_images_handler({"emulator": "qemu"}, ctx) + assert result["count"] == 1 + url = conn.http_call.call_args[0][1] + assert url.endswith("/computes/local/qemu/images") + + def test_images_requires_emulator(self, ctx): + from gns3server.agent.mcp.computes import get_compute_images_handler + assert "error" in get_compute_images_handler({}, ctx) + + # ── Marker (traffic-insight) ──────────────────────────────────────────── diff --git a/tests/api/routes/controller/test_computes.py b/tests/api/routes/controller/test_computes.py index c92bc1a14..5b864f02f 100644 --- a/tests/api/routes/controller/test_computes.py +++ b/tests/api/routes/controller/test_computes.py @@ -79,6 +79,16 @@ class TestComputeRoutes: assert response.status_code == status.HTTP_200_OK assert response.json()["compute_id"] == str(test_compute.compute_id) + async def test_compute_get_local(self, app: FastAPI, client: AsyncClient, controller) -> None: + + await controller.add_compute( + compute_id="local", name="local", host="127.0.0.1", port=3080, force=True, connect=False) + + response = await client.get(app.url_path_for("get_compute", compute_id="local")) + assert response.status_code == status.HTTP_200_OK + assert response.json()["compute_id"] == "local" + assert response.json()["name"] == "local" + async def test_compute_update(self, app: FastAPI, client: AsyncClient, test_compute: Compute) -> None: params = {