From db9f645c98f5043a2b32caa61240172fa1b206ff Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 10 Jun 2026 13:56:43 +0800 Subject: [PATCH] feat: Add node bulk ops, project lock, and server info MCP tools - Node bulk: start_all_nodes, stop_all_nodes, suspend_all_nodes, reload_all_nodes - Node advanced: duplicate_node, isolate_node, unisolate_node, get_node_links - Project: lock_project, unlock_project - Server: get_version, get_statistics --- gns3server/api/routes/mcp/__init__.py | 136 ++++++++++++++++++++++++++ gns3server/api/routes/mcp/nodes.py | 81 ++++++++++++++- gns3server/api/routes/mcp/projects.py | 18 ++++ gns3server/api/routes/mcp/server.py | 50 ++++++++++ 4 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 gns3server/api/routes/mcp/server.py diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index dda88f907..5ee8e26ee 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -50,6 +50,10 @@ from .projects import ( delete_project_handler, open_project_handler, close_project_handler, get_project_stats_handler, update_project_handler, duplicate_project_handler, get_project_readme_handler, update_project_readme_handler, + lock_project_handler, unlock_project_handler, +) +from .server import ( + get_version_handler, get_statistics_handler, ) from .nodes import ( get_nodes_handler, get_node_handler, start_node_handler, @@ -58,6 +62,10 @@ from .nodes import ( get_node_console_info_handler, list_node_files_handler, get_node_file_handler, write_node_file_handler, delete_node_file_handler, + start_all_nodes_handler, stop_all_nodes_handler, + suspend_all_nodes_handler, reload_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, @@ -668,6 +676,96 @@ async def delete_node_file( }) +# ── Node bulk / advanced tools ───────────────────────────────────────── + + +@mcp.tool() +async def start_all_nodes( + project_id: Annotated[str, Field(description="UUID of the project")], +) -> list[dict[str, Any]]: + """Start all nodes in a project.""" + return await asyncio.to_thread(_run_handler_sync, start_all_nodes_handler, { + "project_id": project_id, + }) + + +@mcp.tool() +async def stop_all_nodes( + project_id: Annotated[str, Field(description="UUID of the project")], +) -> list[dict[str, Any]]: + """Stop all nodes in a project.""" + return await asyncio.to_thread(_run_handler_sync, stop_all_nodes_handler, { + "project_id": project_id, + }) + + +@mcp.tool() +async def suspend_all_nodes( + project_id: Annotated[str, Field(description="UUID of the project")], +) -> list[dict[str, Any]]: + """Suspend all nodes in a project.""" + return await asyncio.to_thread(_run_handler_sync, suspend_all_nodes_handler, { + "project_id": project_id, + }) + + +@mcp.tool() +async def reload_all_nodes( + project_id: Annotated[str, Field(description="UUID of the project")], +) -> list[dict[str, Any]]: + """Reload (restart) all nodes in a project.""" + return await asyncio.to_thread(_run_handler_sync, reload_all_nodes_handler, { + "project_id": project_id, + }) + + +@mcp.tool() +async def duplicate_node( + project_id: Annotated[str, Field(description="UUID of the project")], + node_id: Annotated[str, Field(description="UUID of the node to duplicate")], + x: Annotated[int, Field(description="X coordinate for the new node")] = 0, + y: Annotated[int, Field(description="Y coordinate for the new node")] = 0, + z: Annotated[int, Field(description="Z layer for the new node")] = 0, +) -> list[dict[str, Any]]: + """Duplicate a node in a project, creating a copy at a new position.""" + return await asyncio.to_thread(_run_handler_sync, duplicate_node_handler, { + "project_id": project_id, "node_id": node_id, "x": x, "y": y, "z": z, + }) + + +@mcp.tool() +async def isolate_node( + project_id: Annotated[str, Field(description="UUID of the project")], + node_id: Annotated[str, Field(description="UUID of the node to isolate")], +) -> list[dict[str, Any]]: + """Isolate a node by suspending all its attached links (network isolation).""" + return await asyncio.to_thread(_run_handler_sync, isolate_node_handler, { + "project_id": project_id, "node_id": node_id, + }) + + +@mcp.tool() +async def unisolate_node( + project_id: Annotated[str, Field(description="UUID of the project")], + node_id: Annotated[str, Field(description="UUID of the node to unisolate")], +) -> list[dict[str, Any]]: + """Un-isolate a node by resuming all its suspended links.""" + return await asyncio.to_thread(_run_handler_sync, unisolate_node_handler, { + "project_id": project_id, "node_id": node_id, + }) + + +@mcp.tool() +async def get_node_links( + project_id: Annotated[str, Field(description="UUID of the project")], + node_id: Annotated[str, Field(description="UUID of the node")], +) -> list[dict[str, Any]]: + """List all links connected to a specific node.""" + return await asyncio.to_thread(_run_handler_sync, get_node_links_handler, { + "project_id": project_id, "node_id": node_id, + }) + + # ── Link capture / reset tools ──────────────────────────────────────── @@ -837,6 +935,44 @@ async def delete_drawing( }) +# ── Project lock tools ──────────────────────────────────────────────── + + +@mcp.tool() +async def lock_project( + project_id: Annotated[str, Field(description="UUID of the project")], +) -> list[dict[str, Any]]: + """Lock all drawings and nodes in a project to prevent accidental changes.""" + return await asyncio.to_thread(_run_handler_sync, lock_project_handler, { + "project_id": project_id, + }) + + +@mcp.tool() +async def unlock_project( + project_id: Annotated[str, Field(description="UUID of the project")], +) -> list[dict[str, Any]]: + """Unlock a project to allow editing of drawings and nodes.""" + return await asyncio.to_thread(_run_handler_sync, unlock_project_handler, { + "project_id": project_id, + }) + + +# ── Server info tools ───────────────────────────────────────────────── + + +@mcp.tool() +async def get_version() -> list[dict[str, Any]]: + """Get GNS3 server version information.""" + return await asyncio.to_thread(_run_handler_sync, get_version_handler, {}) + + +@mcp.tool() +async def get_statistics() -> list[dict[str, Any]]: + """Get GNS3 server statistics including computes, projects, nodes, and links.""" + return await asyncio.to_thread(_run_handler_sync, get_statistics_handler, {}) + + # ── Auth‑wrapped SSE app ────────────────────────────────────────────── def _make_auth_wrapper(inner_app): diff --git a/gns3server/api/routes/mcp/nodes.py b/gns3server/api/routes/mcp/nodes.py index 6c680ec3c..7c46192c4 100644 --- a/gns3server/api/routes/mcp/nodes.py +++ b/gns3server/api/routes/mcp/nodes.py @@ -257,7 +257,86 @@ def delete_node_file_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) - 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} - 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 reload_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/reload") + return {"message": "All nodes reloaded", "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 ─────────────────────────────────────────────────────── diff --git a/gns3server/api/routes/mcp/projects.py b/gns3server/api/routes/mcp/projects.py index 5d6023f64..ba1bc1484 100644 --- a/gns3server/api/routes/mcp/projects.py +++ b/gns3server/api/routes/mcp/projects.py @@ -158,6 +158,24 @@ def update_project_readme_handler(params: dict[str, Any], gns3_ctx: dict[str, An return {"message": "README.txt updated", "project_id": project_id} +def lock_project_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}/lock") + return {"message": f"Project {project_id} locked", "project_id": project_id} + + +def unlock_project_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}/unlock") + return {"message": f"Project {project_id} unlocked", "project_id": project_id} + + # ── Tool definitions (consumed by mcp/__init__.py) ───────────────────────── PROJECT_TOOLS = [ diff --git a/gns3server/api/routes/mcp/server.py b/gns3server/api/routes/mcp/server.py new file mode 100644 index 000000000..53743c900 --- /dev/null +++ b/gns3server/api/routes/mcp/server.py @@ -0,0 +1,50 @@ +# +# 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 server information. +""" + +from typing import Any + +import logging + +log = logging.getLogger(__name__) + + +# ── 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_version_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: + conn = _get_connector(gns3_ctx) + return conn.http_call("get", f"{conn.base_url}/version").json() + + +def get_statistics_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: + conn = _get_connector(gns3_ctx) + return conn.http_call("get", f"{conn.base_url}/statistics").json()