From acce79d2431c88c17fdbd1db2608def227530e3b Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 10 Jun 2026 13:47:10 +0800 Subject: [PATCH 01/41] refactor: unify MCP handlers to use http_call directly, relocate node file ops to Node class - Move node file operations (list_files, delete_file) from Gns3Connector to Node class - Add link capture/reset operations (reset, start_capture, stop_capture) to Link class - Convert all MCP handlers to use conn.http_call() directly instead of Gns3Connector/Node/Link abstraction methods - Register 4 new MCP tools: reset_link, start_capture, stop_capture, download_capture_file --- .../gns3_copilot/gns3_client/custom_gns3fy.py | 223 ++++++++++++------ gns3server/api/routes/mcp/__init__.py | 54 +++++ gns3server/api/routes/mcp/computes.py | 6 +- gns3server/api/routes/mcp/links.py | 119 +++++++++- gns3server/api/routes/mcp/nodes.py | 29 ++- gns3server/api/routes/mcp/projects.py | 18 +- gns3server/api/routes/mcp/templates.py | 57 +++-- 7 files changed, 387 insertions(+), 119 deletions(-) diff --git a/gns3server/agent/gns3_copilot/gns3_client/custom_gns3fy.py b/gns3server/agent/gns3_copilot/gns3_client/custom_gns3fy.py index 13c17436c..3f714fd36 100644 --- a/gns3server/agent/gns3_copilot/gns3_client/custom_gns3fy.py +++ b/gns3server/agent/gns3_copilot/gns3_client/custom_gns3fy.py @@ -782,81 +782,6 @@ class Gns3Connector: _url = f"{self.base_url}/projects/{project_id}/files/{encoded_path}" self.http_call("post", _url, data=content, headers={"Content-Type": "text/plain"}) - def list_node_files( - self, project_id: str, node_id: str, - path: str = "", recursive: bool = False - ) -> list[dict[str, Any]]: - """ - List files in a node directory with metadata. - - **Required Attributes:** - - - `project_id` - - `node_id` - - `path` Subdirectory path within node directory (optional) - - `recursive` Whether to recursively list all files (optional) - - **Returns:** - - List of file objects with name, path, size, modified time, type, etc. - """ - _url = f"{self.base_url}/projects/{project_id}/nodes/{node_id}/files" - _params = {} - if path: - _params["path"] = path - if recursive: - _params["recursive"] = "true" - _response_data = self.http_call("get", _url, params=_params if _params else None) - return cast(list[dict[str, Any]], _response_data.json()) - - def get_node_file(self, project_id: str, node_id: str, file_path: str) -> str: - """ - Get the content of a file in a node directory. - - **Required Attributes:** - - - `project_id` - - `node_id` - - `file_path` - - **Returns** - - File content as text string - """ - encoded_path = quote(file_path, safe="/") - _url = f"{self.base_url}/projects/{project_id}/nodes/{node_id}/files/{encoded_path}" - _response = self.http_call("get", _url) - return _response.text - - def write_node_file(self, project_id: str, node_id: str, file_path: str, content: str) -> None: - """ - Write content to a file in a node directory. Creates the file if it doesn't exist. - - **Required Attributes:** - - - `project_id` - - `node_id` - - `file_path` - - `content` - """ - encoded_path = quote(file_path, safe="/") - _url = f"{self.base_url}/projects/{project_id}/nodes/{node_id}/files/{encoded_path}" - self.http_call("post", _url, data=content, headers={"Content-Type": "text/plain"}) - - def delete_node_file(self, project_id: str, node_id: str, file_path: str) -> None: - """ - Delete a file from the node directory. - - **Required Attributes:** - - - `project_id` - - `node_id` - - `file_path` - """ - encoded_path = quote(file_path, safe="/") - _url = f"{self.base_url}/projects/{project_id}/nodes/{node_id}/files/{encoded_path}" - self.http_call("delete", _url) - def get_computes(self) -> list[dict[str, Any]]: """ Returns a list of computes. @@ -1251,6 +1176,96 @@ class Link: 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: @@ -1763,6 +1778,64 @@ class Node: 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: """ diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index 7af5029b7..51bdc2f30 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -62,6 +62,8 @@ from .nodes import ( from .links import ( get_links_handler, get_link_handler, create_link_handler, delete_link_handler, update_link_handler, + reset_link_handler, start_capture_handler, stop_capture_handler, + download_capture_file_handler, ) from .templates import ( list_templates_handler, get_template_handler, create_template_handler, @@ -658,6 +660,58 @@ async def delete_node_file( }) +# ── Link capture / reset tools ──────────────────────────────────────── + + +@mcp.tool() +async def reset_link( + project_id: Annotated[str, Field(description="UUID of the project")], + link_id: Annotated[str, Field(description="UUID of the link")], +) -> list[dict[str, Any]]: + """Reset a link, clearing its state (counters, filters, etc.).""" + return await asyncio.to_thread(_run_handler_sync, reset_link_handler, { + "project_id": project_id, "link_id": link_id, + }) + + +@mcp.tool() +async def start_capture( + project_id: Annotated[str, Field(description="UUID of the project")], + link_id: Annotated[str, Field(description="UUID of the link")], + data_link_type: Annotated[str, Field(description="Data link type (default: DLT_EN10MB)")] = "DLT_EN10MB", + capture_file_name: Annotated[str | None, Field(description="Capture file name (optional)")] = None, + wireshark: Annotated[bool, Field(description="Open Wireshark automatically (default: false)")] = False, +) -> list[dict[str, Any]]: + """Start packet capture on a link. The capture file can later be downloaded with download_capture_file.""" + return await asyncio.to_thread(_run_handler_sync, start_capture_handler, { + "project_id": project_id, "link_id": link_id, + "data_link_type": data_link_type, "capture_file_name": capture_file_name, + "wireshark": wireshark, + }) + + +@mcp.tool() +async def stop_capture( + project_id: Annotated[str, Field(description="UUID of the project")], + link_id: Annotated[str, Field(description="UUID of the link")], +) -> list[dict[str, Any]]: + """Stop packet capture on a link. After stopping, the capture file can be downloaded.""" + return await asyncio.to_thread(_run_handler_sync, stop_capture_handler, { + "project_id": project_id, "link_id": link_id, + }) + + +@mcp.tool() +async def download_capture_file( + project_id: Annotated[str, Field(description="UUID of the project")], + link_id: Annotated[str, Field(description="UUID of the link")], +) -> list[dict[str, Any]]: + """Get the download URL and instructions for a PCAP capture file. Use curl to download.""" + return await asyncio.to_thread(_run_handler_sync, download_capture_file_handler, { + "project_id": project_id, "link_id": link_id, + }) + + # ── Auth‑wrapped SSE app ────────────────────────────────────────────── def _make_auth_wrapper(inner_app): diff --git a/gns3server/api/routes/mcp/computes.py b/gns3server/api/routes/mcp/computes.py index fa6c70674..e95fbd45c 100644 --- a/gns3server/api/routes/mcp/computes.py +++ b/gns3server/api/routes/mcp/computes.py @@ -37,14 +37,14 @@ def _get_connector(gns3_ctx: dict[str, Any]): def list_computes_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: conn = _get_connector(gns3_ctx) - computes = conn.get_computes() + computes = conn.http_call("get", f"{conn.base_url}/computes").json() return {"computes": computes, "count": len(computes)} def get_compute_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: compute_id = params.get("compute_id", "local") conn = _get_connector(gns3_ctx) - return conn.get_compute(compute_id=compute_id) + 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]: @@ -53,7 +53,7 @@ def get_compute_images_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) if not emulator: return {"error": "emulator is required (e.g. qemu, iou, docker)"} conn = _get_connector(gns3_ctx) - images = conn.get_compute_images(emulator=emulator, compute_id=compute_id) + 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/api/routes/mcp/links.py b/gns3server/api/routes/mcp/links.py index 1d5a87da6..3ae7924bf 100644 --- a/gns3server/api/routes/mcp/links.py +++ b/gns3server/api/routes/mcp/links.py @@ -48,7 +48,7 @@ def get_links_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[ if not project_id: return {"error": "project_id is required"} conn = _get_connector(gns3_ctx) - links = conn.get_links(project_id=project_id) + links = conn.http_call("get", f"{conn.base_url}/projects/{project_id}/links").json() return {"links": links, "count": len(links)} @@ -58,7 +58,7 @@ def get_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[s if not project_id or not link_id: return {"error": "project_id and link_id are required"} conn = _get_connector(gns3_ctx) - return conn.get_link(project_id=project_id, link_id=link_id) + 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]: @@ -105,6 +105,64 @@ def update_link_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic 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") + 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}/reset" + result = conn.http_call("post", url).json() + return {"message": f"Link {link_id} reset", "link": result} + + +def start_capture_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) + 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") + 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}/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") + link_id = params.get("link_id") + if not project_id or not link_id: + return {"error": "project_id and link_id are required"} + download_url = f"{gns3_ctx['server_url']}/v3/projects/{project_id}/links/{link_id}/capture/file" + auth_token = gns3_ctx['jwt_token'] + return { + "link_id": link_id, + "download_url": download_url, + "curl_command": f"curl -L -o capture.pcap -H 'Authorization: Bearer {auth_token}' '{download_url}'", + "note": "Use the curl command to download the PCAP capture file. " + "The file is in pcap format and can be analyzed with Wireshark or tcpdump.", + } + + # ── Tool definitions ─────────────────────────────────────────────────────── LINK_TOOLS = [ @@ -193,4 +251,61 @@ LINK_TOOLS = [ }, "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/api/routes/mcp/nodes.py b/gns3server/api/routes/mcp/nodes.py index f74213e7f..6c680ec3c 100644 --- a/gns3server/api/routes/mcp/nodes.py +++ b/gns3server/api/routes/mcp/nodes.py @@ -54,7 +54,7 @@ def get_nodes_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[ if not project_id: return {"error": "project_id is required"} conn = _get_connector(gns3_ctx) - nodes = conn.get_nodes(project_id=project_id) + nodes = conn.http_call("get", f"{conn.base_url}/projects/{project_id}/nodes").json() return {"nodes": nodes, "count": len(nodes)} @@ -64,7 +64,7 @@ def get_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[s if not project_id or not node_id: return {"error": "project_id and node_id are required"} conn = _get_connector(gns3_ctx) - return conn.get_node(project_id=project_id, node_id=node_id) + return conn.http_call("get", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}").json() def start_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: @@ -155,7 +155,7 @@ def get_node_console_info_handler(params: dict[str, Any], gns3_ctx: dict[str, An if not project_id or not node_id: return {"error": "project_id and node_id are required"} conn = _get_connector(gns3_ctx) - node = conn.get_node(project_id=project_id, node_id=node_id) + node = conn.http_call("get", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}").json() console_type = node.get("console_type", "unknown") ws_url = f"{gns3_ctx['server_url']}/v3/projects/{project_id}/nodes/{node_id}/console/ws?token={gns3_ctx['jwt_token']}" @@ -181,12 +181,13 @@ def list_node_files_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> if not project_id or not node_id: return {"error": "project_id and node_id are required"} conn = _get_connector(gns3_ctx) - files = conn.list_node_files( - project_id=project_id, - node_id=node_id, - path=params.get("path", ""), - recursive=params.get("recursive", False), - ) + 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)} @@ -201,7 +202,8 @@ def get_node_file_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> d limit = params.get("limit", 200) conn = _get_connector(gns3_ctx) - raw = conn.get_node_file(project_id=project_id, node_id=node_id, file_path=file_path) + 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 @@ -240,7 +242,8 @@ def write_node_file_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> 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) - conn.write_node_file(project_id=project_id, node_id=node_id, file_path=file_path, content=content) + 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} @@ -251,7 +254,9 @@ def delete_node_file_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) - 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) - conn.delete_node_file(project_id=project_id, node_id=node_id, file_path=file_path) + 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} diff --git a/gns3server/api/routes/mcp/projects.py b/gns3server/api/routes/mcp/projects.py index bcf30d947..5d6023f64 100644 --- a/gns3server/api/routes/mcp/projects.py +++ b/gns3server/api/routes/mcp/projects.py @@ -46,7 +46,7 @@ def _get_connector(gns3_ctx: dict[str, Any]): def list_projects_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: conn = _get_connector(gns3_ctx) - projects = conn.get_projects() + projects = conn.http_call("get", f"{conn.base_url}/projects").json() return {"projects": projects, "count": len(projects)} @@ -55,7 +55,7 @@ def get_project_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic if not project_id: return {"error": "project_id is required"} conn = _get_connector(gns3_ctx) - project = conn.get_project(project_id=project_id) + project = conn.http_call("get", f"{conn.base_url}/projects/{project_id}").json() if project is None: return {"error": f"Project '{project_id}' not found"} return project @@ -69,7 +69,7 @@ def create_project_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> project_data = {"name": name} if "description" in params: project_data["description"] = params["description"] - return conn.create_project(**project_data) + return conn.http_call("post", f"{conn.base_url}/projects", json_data=project_data).json() def delete_project_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: @@ -77,7 +77,7 @@ def delete_project_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> if not project_id: return {"error": "project_id is required"} conn = _get_connector(gns3_ctx) - conn.delete_project(project_id=project_id) + conn.http_call("delete", f"{conn.base_url}/projects/{project_id}") return {"message": f"Project '{project_id}' deleted", "project_id": project_id} @@ -115,7 +115,7 @@ def update_project_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> return {"error": "project_id is required"} conn = _get_connector(gns3_ctx) kwargs = {k: v for k, v in params.items() if k != "project_id" and v is not None} - return conn.update_project(project_id=project_id, **kwargs) + return conn.http_call("put", f"{conn.base_url}/projects/{project_id}", json_data=kwargs).json() def duplicate_project_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: @@ -127,7 +127,7 @@ def duplicate_project_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) return {"error": "name is required"} conn = _get_connector(gns3_ctx) kwargs = {k: v for k, v in params.items() if k not in ("project_id",) and v is not None} - return conn.duplicate_project(project_id=project_id, **kwargs) + return conn.http_call("post", f"{conn.base_url}/projects/{project_id}/duplicate", json_data=kwargs).json() def get_project_readme_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: @@ -136,7 +136,8 @@ def get_project_readme_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) return {"error": "project_id is required"} conn = _get_connector(gns3_ctx) try: - content = conn.get_project_file(project_id=project_id, file_path="README.txt") + url = f"{conn.base_url}/projects/{project_id}/files/README.txt" + content = conn.http_call("get", url).text return {"project_id": project_id, "file": "README.txt", "content": content} except Exception as e: if "404" in str(e): @@ -152,7 +153,8 @@ def update_project_readme_handler(params: dict[str, Any], gns3_ctx: dict[str, An if content is None: return {"error": "content is required"} conn = _get_connector(gns3_ctx) - conn.write_project_file(project_id=project_id, file_path="README.txt", content=content) + url = f"{conn.base_url}/projects/{project_id}/files/README.txt" + conn.http_call("post", url, data=content, headers={"Content-Type": "text/plain"}) return {"message": "README.txt updated", "project_id": project_id} diff --git a/gns3server/api/routes/mcp/templates.py b/gns3server/api/routes/mcp/templates.py index e2cc71e76..6ed9eaf30 100644 --- a/gns3server/api/routes/mcp/templates.py +++ b/gns3server/api/routes/mcp/templates.py @@ -45,7 +45,7 @@ def _get_connector(gns3_ctx: dict[str, Any]): def list_templates_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: conn = _get_connector(gns3_ctx) - templates = conn.get_templates() + templates = conn.http_call("get", f"{conn.base_url}/templates").json() return {"templates": templates, "count": len(templates)} @@ -57,10 +57,16 @@ def get_template_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> di return {"error": "template_id or name is required"} conn = _get_connector(gns3_ctx) - template = conn.get_template(name=name, template_id=template_id) - if template is None: - return {"error": "Template not found"} + if template_id: + template = conn.http_call("get", f"{conn.base_url}/templates/{template_id}").json() + else: + # Find template by name + all_templates = conn.http_call("get", f"{conn.base_url}/templates").json() + matches = [t for t in all_templates if t.get("name") == name] + if not matches: + return {"error": f"Template '{name}' not found"} + template = matches[0] return template @@ -72,14 +78,12 @@ def create_template_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> return {"error": "name and template_type are required"} conn = _get_connector(gns3_ctx) - - # Handle nested kwargs structure from MCP clients - if "kwargs" in params and isinstance(params["kwargs"], dict): - create_params = params["kwargs"] - else: - create_params = params - - return conn.create_template(**create_params) + data = { + "name": name, + "template_type": template_type, + "compute_id": params.get("compute_id", "local"), + } + return conn.http_call("post", f"{conn.base_url}/templates", json_data=data).json() def update_template_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: @@ -91,13 +95,20 @@ def update_template_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> 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_params = params["kwargs"] - else: - update_params = {k: v for k, v in params.items() if k not in ("template_id", "name", "kwargs")} + # Resolve name to ID if needed + if not template_id and name: + all_templates = conn.http_call("get", f"{conn.base_url}/templates").json() + matches = [t for t in all_templates if t.get("name") == name] + if not matches: + return {"error": f"Template '{name}' not found"} + template_id = matches[0]["template_id"] - return conn.update_template(name=name, template_id=template_id, **update_params) + update_data = {k: v for k, v in params.items() if k not in ("template_id", "name", "kwargs")} + # Support nested kwargs from MCP clients + if "kwargs" in params and isinstance(params["kwargs"], dict): + update_data = params["kwargs"] + + return conn.http_call("put", f"{conn.base_url}/templates/{template_id}", json_data=update_data).json() def delete_template_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: @@ -108,7 +119,15 @@ def delete_template_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> return {"error": "template_id or name is required"} conn = _get_connector(gns3_ctx) - conn.delete_template(name=name, template_id=template_id) + + if not template_id and name: + all_templates = conn.http_call("get", f"{conn.base_url}/templates").json() + matches = [t for t in all_templates if t.get("name") == name] + if not matches: + return {"error": f"Template '{name}' not found"} + template_id = matches[0]["template_id"] + + conn.http_call("delete", f"{conn.base_url}/templates/{template_id}") return {"message": f"Template deleted"} From e7fbb3ec10ffe91e85cfc2ba8b45c3f1c9d7946e Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 10 Jun 2026 13:55:13 +0800 Subject: [PATCH 02/41] feat: Add snapshot and drawing MCP tools - Add snapshot tools: get_snapshots, create_snapshot, delete_snapshot, restore_snapshot - Add drawing tools: get_drawings, create_drawing, get_drawing, update_drawing, delete_drawing --- gns3server/api/routes/mcp/__init__.py | 125 +++++++++++++++++++++++++ gns3server/api/routes/mcp/drawings.py | 96 +++++++++++++++++++ gns3server/api/routes/mcp/snapshots.py | 79 ++++++++++++++++ 3 files changed, 300 insertions(+) create mode 100644 gns3server/api/routes/mcp/drawings.py create mode 100644 gns3server/api/routes/mcp/snapshots.py diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index 51bdc2f30..dda88f907 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -72,6 +72,14 @@ from .templates import ( from .computes import ( list_computes_handler, get_compute_handler, get_compute_images_handler, ) +from .snapshots import ( + get_snapshots_handler, create_snapshot_handler, + delete_snapshot_handler, restore_snapshot_handler, +) +from .drawings import ( + get_drawings_handler, create_drawing_handler, + get_drawing_handler, update_drawing_handler, delete_drawing_handler, +) log = logging.getLogger(__name__) @@ -712,6 +720,123 @@ async def download_capture_file( }) +# ── Snapshot tools ───────────────────────────────────────────────────── + + +@mcp.tool() +async def get_snapshots( + project_id: Annotated[str, Field(description="UUID of the project")], +) -> list[dict[str, Any]]: + """List all snapshots of a project.""" + return await asyncio.to_thread(_run_handler_sync, get_snapshots_handler, { + "project_id": project_id, + }) + + +@mcp.tool() +async def create_snapshot( + project_id: Annotated[str, Field(description="UUID of the project")], + name: Annotated[str, Field(description="Name for the new snapshot")], +) -> list[dict[str, Any]]: + """Create a new snapshot of a project.""" + return await asyncio.to_thread(_run_handler_sync, create_snapshot_handler, { + "project_id": project_id, "name": name, + }) + + +@mcp.tool() +async def delete_snapshot( + project_id: Annotated[str, Field(description="UUID of the project")], + snapshot_id: Annotated[str, Field(description="UUID of the snapshot to delete")], +) -> list[dict[str, Any]]: + """Delete a snapshot from a project. Cannot be undone.""" + return await asyncio.to_thread(_run_handler_sync, delete_snapshot_handler, { + "project_id": project_id, "snapshot_id": snapshot_id, + }) + + +@mcp.tool() +async def restore_snapshot( + project_id: Annotated[str, Field(description="UUID of the project")], + snapshot_id: Annotated[str, Field(description="UUID of the snapshot to restore")], +) -> list[dict[str, Any]]: + """Restore a project to a previous snapshot state. The project may be closed and reopened.""" + return await asyncio.to_thread(_run_handler_sync, restore_snapshot_handler, { + "project_id": project_id, "snapshot_id": snapshot_id, + }) + + +# ── Drawing tools ────────────────────────────────────────────────────── + + +@mcp.tool() +async def get_drawings( + project_id: Annotated[str, Field(description="UUID of the project")], +) -> list[dict[str, Any]]: + """List all drawings (labels, shapes, images) on a project canvas.""" + return await asyncio.to_thread(_run_handler_sync, get_drawings_handler, { + "project_id": project_id, + }) + + +@mcp.tool() +async def create_drawing( + project_id: Annotated[str, Field(description="UUID of the project")], + svg: Annotated[str, Field(description="SVG content for the drawing")], + x: Annotated[int, Field(description="X coordinate (default: 0)")] = 0, + y: Annotated[int, Field(description="Y coordinate (default: 0)")] = 0, + z: Annotated[int, Field(description="Z layer (default: 0)")] = 0, + locked: Annotated[bool, Field(description="Lock the drawing (default: false)")] = False, + rotation: Annotated[int, Field(description="Rotation angle in degrees, -359 to 359 (default: 0)")] = 0, +) -> list[dict[str, Any]]: + """Create a new drawing (label, shape, or image) on a project canvas.""" + return await asyncio.to_thread(_run_handler_sync, create_drawing_handler, { + "project_id": project_id, "svg": svg, "x": x, "y": y, "z": z, + "locked": locked, "rotation": rotation, + }) + + +@mcp.tool() +async def get_drawing( + project_id: Annotated[str, Field(description="UUID of the project")], + drawing_id: Annotated[str, Field(description="UUID of the drawing")], +) -> list[dict[str, Any]]: + """Get detailed information about a specific drawing.""" + return await asyncio.to_thread(_run_handler_sync, get_drawing_handler, { + "project_id": project_id, "drawing_id": drawing_id, + }) + + +@mcp.tool() +async def update_drawing( + project_id: Annotated[str, Field(description="UUID of the project")], + drawing_id: Annotated[str, Field(description="UUID of the drawing")], + svg: Annotated[str | None, Field(description="New SVG content")] = None, + locked: Annotated[bool | None, Field(description="Lock or unlock the drawing")] = None, + x: Annotated[int | None, Field(description="New X coordinate")] = None, + y: Annotated[int | None, Field(description="New Y coordinate")] = None, + z: Annotated[int | None, Field(description="New Z layer")] = None, +) -> list[dict[str, Any]]: + """Update a drawing's properties (svg, position, lock state, etc.).""" + params = {"project_id": project_id, "drawing_id": drawing_id} + local_vars = {"svg": svg, "locked": locked, "x": x, "y": y, "z": z} + for key, val in local_vars.items(): + if val is not None: + params[key] = val + return await asyncio.to_thread(_run_handler_sync, update_drawing_handler, params) + + +@mcp.tool() +async def delete_drawing( + project_id: Annotated[str, Field(description="UUID of the project")], + drawing_id: Annotated[str, Field(description="UUID of the drawing to delete")], +) -> list[dict[str, Any]]: + """Delete a drawing from a project canvas. Cannot be undone.""" + return await asyncio.to_thread(_run_handler_sync, delete_drawing_handler, { + "project_id": project_id, "drawing_id": drawing_id, + }) + + # ── Auth‑wrapped SSE app ────────────────────────────────────────────── def _make_auth_wrapper(inner_app): diff --git a/gns3server/api/routes/mcp/drawings.py b/gns3server/api/routes/mcp/drawings.py new file mode 100644 index 000000000..6e47a2e02 --- /dev/null +++ b/gns3server/api/routes/mcp/drawings.py @@ -0,0 +1,96 @@ +# +# 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 drawing management. +""" + +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_drawings_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) + drawings = conn.http_call("get", f"{conn.base_url}/projects/{project_id}/drawings").json() + return {"drawings": drawings, "count": len(drawings)} + + +def create_drawing_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: + project_id = params.get("project_id") + svg = params.get("svg") + if not project_id or not svg: + return {"error": "project_id and svg are required"} + conn = _get_connector(gns3_ctx) + data = { + "svg": svg, + "x": params.get("x", 0), + "y": params.get("y", 0), + "z": params.get("z", 0), + "locked": params.get("locked", False), + "rotation": params.get("rotation", 0), + } + result = conn.http_call("post", f"{conn.base_url}/projects/{project_id}/drawings", json_data=data).json() + return {"message": "Drawing created", "drawing": result} + + +def get_drawing_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: + project_id = params.get("project_id") + drawing_id = params.get("drawing_id") + if not project_id or not drawing_id: + return {"error": "project_id and drawing_id are required"} + conn = _get_connector(gns3_ctx) + return conn.http_call("get", f"{conn.base_url}/projects/{project_id}/drawings/{drawing_id}").json() + + +def update_drawing_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: + project_id = params.get("project_id") + drawing_id = params.get("drawing_id") + if not project_id or not drawing_id: + return {"error": "project_id and drawing_id are required"} + conn = _get_connector(gns3_ctx) + data = {k: v for k, v in params.items() if k not in ("project_id", "drawing_id") and v is not None} + return conn.http_call("put", f"{conn.base_url}/projects/{project_id}/drawings/{drawing_id}", json_data=data).json() + + +def delete_drawing_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: + project_id = params.get("project_id") + drawing_id = params.get("drawing_id") + if not project_id or not drawing_id: + return {"error": "project_id and drawing_id are required"} + conn = _get_connector(gns3_ctx) + conn.http_call("delete", f"{conn.base_url}/projects/{project_id}/drawings/{drawing_id}") + return {"message": f"Drawing {drawing_id} deleted", "drawing_id": drawing_id} diff --git a/gns3server/api/routes/mcp/snapshots.py b/gns3server/api/routes/mcp/snapshots.py new file mode 100644 index 000000000..e5b1685db --- /dev/null +++ b/gns3server/api/routes/mcp/snapshots.py @@ -0,0 +1,79 @@ +# +# 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 snapshot management. +""" + +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_snapshots_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) + snapshots = conn.http_call("get", f"{conn.base_url}/projects/{project_id}/snapshots").json() + return {"snapshots": snapshots, "count": len(snapshots)} + + +def create_snapshot_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: + project_id = params.get("project_id") + name = params.get("name") + if not project_id or not name: + return {"error": "project_id and name are required"} + conn = _get_connector(gns3_ctx) + result = conn.http_call("post", f"{conn.base_url}/projects/{project_id}/snapshots", json_data={"name": name}).json() + return {"message": f"Snapshot '{name}' created", "snapshot": result} + + +def delete_snapshot_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: + project_id = params.get("project_id") + snapshot_id = params.get("snapshot_id") + if not project_id or not snapshot_id: + return {"error": "project_id and snapshot_id are required"} + conn = _get_connector(gns3_ctx) + conn.http_call("delete", f"{conn.base_url}/projects/{project_id}/snapshots/{snapshot_id}") + return {"message": f"Snapshot {snapshot_id} deleted", "snapshot_id": snapshot_id} + + +def restore_snapshot_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: + project_id = params.get("project_id") + snapshot_id = params.get("snapshot_id") + if not project_id or not snapshot_id: + return {"error": "project_id and snapshot_id are required"} + conn = _get_connector(gns3_ctx) + result = conn.http_call("post", f"{conn.base_url}/projects/{project_id}/snapshots/{snapshot_id}/restore").json() + return {"message": f"Snapshot {snapshot_id} restored", "project": result} From db9f645c98f5043a2b32caa61240172fa1b206ff Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 10 Jun 2026 13:56:43 +0800 Subject: [PATCH 03/41] 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() From e4d2faec3711f36439f5afd607f4b6020edc6d0b Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 10 Jun 2026 13:57:52 +0800 Subject: [PATCH 04/41] feat: Add symbol and appliance MCP tools - Symbol tools: get_symbols, get_symbol, get_symbol_dimensions, get_default_symbols - Appliance tools: get_appliances, get_appliance, install_appliance --- gns3server/api/routes/mcp/__init__.py | 72 +++++++++++++++++++++++++ gns3server/api/routes/mcp/appliances.py | 63 ++++++++++++++++++++++ gns3server/api/routes/mcp/symbols.py | 68 +++++++++++++++++++++++ 3 files changed, 203 insertions(+) create mode 100644 gns3server/api/routes/mcp/appliances.py create mode 100644 gns3server/api/routes/mcp/symbols.py diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index 5ee8e26ee..a24d10dac 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -55,6 +55,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, +) +from .appliances import ( + get_appliances_handler, get_appliance_handler, + install_appliance_handler, +) from .nodes import ( get_nodes_handler, get_node_handler, start_node_handler, stop_node_handler, reload_node_handler, suspend_node_handler, @@ -973,6 +981,70 @@ async def get_statistics() -> list[dict[str, Any]]: return await asyncio.to_thread(_run_handler_sync, get_statistics_handler, {}) +# ── Symbol tools ────────────────────────────────────────────────────── + + +@mcp.tool() +async def get_symbols() -> 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 get_symbol( + symbol_id: Annotated[str, Field(description="Symbol ID (e.g. ':/symbols/router.svg')")], +) -> list[dict[str, Any]]: + """Get details about a specific symbol.""" + return await asyncio.to_thread(_run_handler_sync, get_symbol_handler, { + "symbol_id": symbol_id, + }) + + +@mcp.tool() +async def get_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 get_default_symbols() -> 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, {}) + + +# ── Appliance tools ─────────────────────────────────────────────────── + + +@mcp.tool() +async def get_appliances() -> list[dict[str, Any]]: + """List all available appliances (template library).""" + return await asyncio.to_thread(_run_handler_sync, get_appliances_handler, {}) + + +@mcp.tool() +async def get_appliance( + appliance_id: Annotated[str, Field(description="UUID of the appliance")], +) -> list[dict[str, Any]]: + """Get detailed information about a specific appliance.""" + return await asyncio.to_thread(_run_handler_sync, get_appliance_handler, { + "appliance_id": appliance_id, + }) + + +@mcp.tool() +async def install_appliance( + appliance_id: Annotated[str, Field(description="UUID of the appliance to install")], +) -> list[dict[str, Any]]: + """Install (download and set up) an appliance from the template library.""" + return await asyncio.to_thread(_run_handler_sync, install_appliance_handler, { + "appliance_id": appliance_id, + }) + + # ── Auth‑wrapped SSE app ────────────────────────────────────────────── def _make_auth_wrapper(inner_app): diff --git a/gns3server/api/routes/mcp/appliances.py b/gns3server/api/routes/mcp/appliances.py new file mode 100644 index 000000000..426538560 --- /dev/null +++ b/gns3server/api/routes/mcp/appliances.py @@ -0,0 +1,63 @@ +# +# 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 appliance management. +""" + +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_appliances_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: + conn = _get_connector(gns3_ctx) + appliances = conn.http_call("get", f"{conn.base_url}/appliances").json() + return {"appliances": appliances, "count": len(appliances)} + + +def get_appliance_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: + appliance_id = params.get("appliance_id") + if not appliance_id: + return {"error": "appliance_id is required"} + conn = _get_connector(gns3_ctx) + return conn.http_call("get", f"{conn.base_url}/appliances/{appliance_id}").json() + + +def install_appliance_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: + appliance_id = params.get("appliance_id") + if not appliance_id: + return {"error": "appliance_id is required"} + conn = _get_connector(gns3_ctx) + result = conn.http_call("post", f"{conn.base_url}/appliances/{appliance_id}/install").json() + return {"message": f"Appliance {appliance_id} installation requested", "result": result} diff --git a/gns3server/api/routes/mcp/symbols.py b/gns3server/api/routes/mcp/symbols.py new file mode 100644 index 000000000..ca40a6feb --- /dev/null +++ b/gns3server/api/routes/mcp/symbols.py @@ -0,0 +1,68 @@ +# +# 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 symbol management. +""" + +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_symbols_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: + conn = _get_connector(gns3_ctx) + symbols = conn.http_call("get", f"{conn.base_url}/symbols").json() + return {"symbols": symbols, "count": len(symbols)} + + +def get_symbol_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: + symbol_id = params.get("symbol_id") + if not symbol_id: + return {"error": "symbol_id is required"} + conn = _get_connector(gns3_ctx) + return conn.http_call("get", f"{conn.base_url}/symbols/{symbol_id}").json() + + +def get_symbol_dimensions_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: + symbol_id = params.get("symbol_id") + if not symbol_id: + return {"error": "symbol_id is required"} + conn = _get_connector(gns3_ctx) + return conn.http_call("get", f"{conn.base_url}/symbols/{symbol_id}/dimensions").json() + + +def get_default_symbols_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: + conn = _get_connector(gns3_ctx) + symbols = conn.http_call("get", f"{conn.base_url}/symbols/default_symbols").json() + return {"default_symbols": symbols} From b786f0b7ebaa068f8e0e8ae82604f42167666fee Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 10 Jun 2026 13:58:42 +0800 Subject: [PATCH 05/41] feat: Add image management MCP tools - Image tools: get_images, get_image, delete_image, prune_images, install_images --- gns3server/api/routes/mcp/__init__.py | 46 ++++++++++++++++ gns3server/api/routes/mcp/images.py | 75 +++++++++++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 gns3server/api/routes/mcp/images.py diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index a24d10dac..907c0195c 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -63,6 +63,11 @@ from .appliances import ( get_appliances_handler, get_appliance_handler, install_appliance_handler, ) +from .images import ( + get_images_handler, get_image_handler, + delete_image_handler, prune_images_handler, + install_images_handler, +) from .nodes import ( get_nodes_handler, get_node_handler, start_node_handler, stop_node_handler, reload_node_handler, suspend_node_handler, @@ -1045,6 +1050,47 @@ async def install_appliance( }) +# ── Image tools ─────────────────────────────────────────────────────── + + +@mcp.tool() +async def get_images() -> list[dict[str, Any]]: + """List all images available on the server across all emulators.""" + return await asyncio.to_thread(_run_handler_sync, get_images_handler, {}) + + +@mcp.tool() +async def get_image( + image_id: Annotated[str, Field(description="ID or filename of the image")], +) -> list[dict[str, Any]]: + """Get detailed information about a specific image.""" + return await asyncio.to_thread(_run_handler_sync, get_image_handler, { + "image_id": image_id, + }) + + +@mcp.tool() +async def delete_image( + image_id: Annotated[str, Field(description="ID or filename of the image to delete")], +) -> list[dict[str, Any]]: + """Delete an image from the server. Cannot be undone.""" + return await asyncio.to_thread(_run_handler_sync, delete_image_handler, { + "image_id": image_id, + }) + + +@mcp.tool() +async def prune_images() -> list[dict[str, Any]]: + """Remove all unused images from the server to free up disk space.""" + return await asyncio.to_thread(_run_handler_sync, prune_images_handler, {}) + + +@mcp.tool() +async def install_images() -> list[dict[str, Any]]: + """Request the server to install pending images (download from registry).""" + return await asyncio.to_thread(_run_handler_sync, install_images_handler, {}) + + # ── Auth‑wrapped SSE app ────────────────────────────────────────────── def _make_auth_wrapper(inner_app): diff --git a/gns3server/api/routes/mcp/images.py b/gns3server/api/routes/mcp/images.py new file mode 100644 index 000000000..4b00c1a22 --- /dev/null +++ b/gns3server/api/routes/mcp/images.py @@ -0,0 +1,75 @@ +# +# 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 image management. +""" + +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_images_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: + conn = _get_connector(gns3_ctx) + images = conn.http_call("get", f"{conn.base_url}/images").json() + return {"images": images, "count": len(images)} + + +def get_image_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: + image_id = params.get("image_id") + if not image_id: + return {"error": "image_id is required"} + conn = _get_connector(gns3_ctx) + return conn.http_call("get", f"{conn.base_url}/images/{image_id}").json() + + +def delete_image_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: + image_id = params.get("image_id") + if not image_id: + return {"error": "image_id is required"} + conn = _get_connector(gns3_ctx) + conn.http_call("delete", f"{conn.base_url}/images/{image_id}") + return {"message": f"Image {image_id} deleted", "image_id": image_id} + + +def prune_images_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: + conn = _get_connector(gns3_ctx) + result = conn.http_call("delete", f"{conn.base_url}/images/prune").json() + return {"message": "Unused images pruned", "result": result} + + +def install_images_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: + conn = _get_connector(gns3_ctx) + result = conn.http_call("post", f"{conn.base_url}/images/install").json() + return {"message": "Image installation requested", "result": result} From 41594faf43d6d8a8dca353374ed57babdc729fea Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 10 Jun 2026 13:59:39 +0800 Subject: [PATCH 06/41] feat: Add symbol upload/delete, project load, and locked check MCP tools --- gns3server/api/routes/mcp/__init__.py | 42 +++++++++++++++++++++++++++ gns3server/api/routes/mcp/projects.py | 18 ++++++++++++ gns3server/api/routes/mcp/symbols.py | 18 ++++++++++++ 3 files changed, 78 insertions(+) diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index 907c0195c..dad350e5c 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -51,6 +51,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, ) from .server import ( get_version_handler, get_statistics_handler, @@ -58,6 +59,7 @@ from .server import ( 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, @@ -971,6 +973,26 @@ async def unlock_project( }) +@mcp.tool() +async def get_locked_project( + project_id: Annotated[str, Field(description="UUID of the project")], +) -> list[dict[str, Any]]: + """Check whether a project is locked (preventing edits to drawings and nodes).""" + return await asyncio.to_thread(_run_handler_sync, get_locked_project_handler, { + "project_id": project_id, + }) + + +@mcp.tool() +async def load_project( + 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 ───────────────────────────────────────────────── @@ -1021,6 +1043,26 @@ async def get_default_symbols() -> list[dict[str, Any]]: return await asyncio.to_thread(_run_handler_sync, get_default_symbols_handler, {}) +@mcp.tool() +async def upload_symbol( + symbol_id: Annotated[str, Field(description="Symbol ID to upload (e.g. ':/symbols/my_symbol.svg')")], +) -> list[dict[str, Any]]: + """Upload or update a custom symbol on the server.""" + return await asyncio.to_thread(_run_handler_sync, upload_symbol_handler, { + "symbol_id": symbol_id, + }) + + +@mcp.tool() +async def delete_symbol( + symbol_id: Annotated[str, Field(description="Symbol ID to delete")], +) -> list[dict[str, Any]]: + """Delete a custom symbol from the server.""" + return await asyncio.to_thread(_run_handler_sync, delete_symbol_handler, { + "symbol_id": symbol_id, + }) + + # ── Appliance tools ─────────────────────────────────────────────────── diff --git a/gns3server/api/routes/mcp/projects.py b/gns3server/api/routes/mcp/projects.py index ba1bc1484..bcb348380 100644 --- a/gns3server/api/routes/mcp/projects.py +++ b/gns3server/api/routes/mcp/projects.py @@ -176,6 +176,24 @@ 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: + return {"error": "project_id is required"} + conn = _get_connector(gns3_ctx) + locked = conn.http_call("get", f"{conn.base_url}/projects/{project_id}/locked").json() + return {"project_id": project_id, "locked": locked} + + # ── Tool definitions (consumed by mcp/__init__.py) ───────────────────────── PROJECT_TOOLS = [ diff --git a/gns3server/api/routes/mcp/symbols.py b/gns3server/api/routes/mcp/symbols.py index ca40a6feb..5ff2d56d8 100644 --- a/gns3server/api/routes/mcp/symbols.py +++ b/gns3server/api/routes/mcp/symbols.py @@ -66,3 +66,21 @@ def get_default_symbols_handler(params: dict[str, Any], gns3_ctx: dict[str, Any] conn = _get_connector(gns3_ctx) symbols = conn.http_call("get", f"{conn.base_url}/symbols/default_symbols").json() return {"default_symbols": symbols} + + +def upload_symbol_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: + symbol_id = params.get("symbol_id") + if not symbol_id: + return {"error": "symbol_id is required"} + conn = _get_connector(gns3_ctx) + result = conn.http_call("post", f"{conn.base_url}/symbols/{symbol_id}").json() + return {"message": f"Symbol {symbol_id} uploaded", "symbol": result} + + +def delete_symbol_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: + symbol_id = params.get("symbol_id") + if not symbol_id: + return {"error": "symbol_id is required"} + conn = _get_connector(gns3_ctx) + conn.http_call("delete", f"{conn.base_url}/symbols/{symbol_id}") + return {"message": f"Symbol {symbol_id} deleted", "symbol_id": symbol_id} From 87e14cc49e53d23b0634bd111b920159e49d9cdf Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 10 Jun 2026 14:07:34 +0800 Subject: [PATCH 07/41] refactor: unify MCP tool naming to _ convention All 79 tools renamed for consistent alphabetical grouping: - project_list, project_get, project_create, project_delete ... - node_list, node_get, node_start, node_stop, node_file_list ... - link_list, link_create, link_capture_start, link_capture_stop ... - template_list, template_get ... - snapshot_list, snapshot_create, snapshot_delete ... - drawing_list, drawing_create, drawing_update ... - symbol_list, symbol_get, symbol_upload ... - appliance_list, appliance_get ... - image_list, image_get, image_delete ... - server_version, server_statistics ... - compute_list, compute_get, compute_images ... --- gns3server/api/routes/mcp/__init__.py | 158 +++++++++++++------------- 1 file changed, 79 insertions(+), 79 deletions(-) diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index dad350e5c..19235e69e 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -236,13 +236,13 @@ def _run_handler_sync(handler, params: dict[str, Any]) -> list[dict[str, Any]]: @mcp.tool() -async def list_projects() -> list[dict[str, Any]]: +async def project_list() -> list[dict[str, Any]]: """List all GNS3 projects accessible to the current user.""" return await asyncio.to_thread(_run_handler_sync, list_projects_handler, {}) @mcp.tool() -async def get_project( +async def project_get( project_id: Annotated[str, Field(description="UUID of the project")], ) -> list[dict[str, Any]]: """Get detailed information about a specific project.""" @@ -250,7 +250,7 @@ async def get_project( @mcp.tool() -async def create_project( +async def project_create( name: Annotated[str, Field(description="Project name")], description: Annotated[str, Field(description="Optional project description")] = "", ) -> list[dict[str, Any]]: @@ -262,7 +262,7 @@ async def create_project( @mcp.tool() -async def delete_project( +async def project_delete( project_id: Annotated[str, Field(description="UUID of the project to delete")], ) -> list[dict[str, Any]]: """Delete a GNS3 project permanently.""" @@ -270,21 +270,21 @@ async def delete_project( @mcp.tool() -async def open_project( +async def project_open( project_id: Annotated[str, Field(description="UUID of the project to open")], ) -> list[dict[str, Any]]: """Open a closed GNS3 project.""" return await asyncio.to_thread(_run_handler_sync, open_project_handler, {"project_id": project_id}) @mcp.tool() -async def close_project( +async def project_close( project_id: Annotated[str, Field(description="UUID of the project to close")], ) -> list[dict[str, Any]]: """Close an open GNS3 project.""" return await asyncio.to_thread(_run_handler_sync, close_project_handler, {"project_id": project_id}) @mcp.tool() -async def get_project_stats( +async def project_stats( project_id: Annotated[str, Field(description="UUID of the project to get statistics for")], ) -> list[dict[str, Any]]: """Get statistics (nodes, links, snapshots, drawings) for a project.""" @@ -292,7 +292,7 @@ async def get_project_stats( @mcp.tool() -async def update_project( +async def project_update( project_id: Annotated[str, Field(description="UUID of the project to update")], name: Annotated[str, Field(description="New project name")] = None, auto_close: Annotated[bool, Field(description="Close project when last client leaves")] = None, @@ -323,7 +323,7 @@ async def update_project( @mcp.tool() -async def duplicate_project( +async def project_duplicate( project_id: Annotated[str, Field(description="UUID of the project to duplicate")], name: Annotated[str, Field(description="New project name")], reset_mac_addresses: Annotated[bool, Field(description="Reset MAC addresses for this project")] = False, @@ -336,7 +336,7 @@ async def duplicate_project( @mcp.tool() -async def get_project_readme( +async def project_readme_get( project_id: Annotated[str, Field(description="UUID of the project")], ) -> list[dict[str, Any]]: """Get the content of a project's README.md file — the project documentation (Markdown format).""" @@ -344,7 +344,7 @@ async def get_project_readme( @mcp.tool() -async def update_project_readme( +async def project_readme_update( project_id: Annotated[str, Field(description="UUID of the project")], content: Annotated[str, Field(description="Content to write to README.md (Markdown format)")], ) -> list[dict[str, Any]]: @@ -355,13 +355,13 @@ async def update_project_readme( # ── Node tools ──────────────────────────────────────────────────────── @mcp.tool() -async def get_nodes(project_id: str) -> list[dict[str, Any]]: +async def node_list(project_id: str) -> list[dict[str, Any]]: """List all nodes in a project.""" return await asyncio.to_thread(_run_handler_sync, get_nodes_handler, {"project_id": project_id}) @mcp.tool() -async def get_node( +async def node_get( project_id: Annotated[str, Field(description="UUID of the project")], node_id: Annotated[str, Field(description="UUID of the node")], ) -> list[dict[str, Any]]: @@ -369,7 +369,7 @@ async def get_node( return await asyncio.to_thread(_run_handler_sync, get_node_handler, {"project_id": project_id, "node_id": node_id}) @mcp.tool() -async def start_node( +async def node_start( project_id: Annotated[str, Field(description="UUID of the project")], node_id: Annotated[str, Field(description="UUID of the node to start")], ) -> list[dict[str, Any]]: @@ -377,7 +377,7 @@ async def start_node( return await asyncio.to_thread(_run_handler_sync, start_node_handler, {"project_id": project_id, "node_id": node_id}) @mcp.tool() -async def stop_node( +async def node_stop( project_id: Annotated[str, Field(description="UUID of the project")], node_id: Annotated[str, Field(description="UUID of the node to stop")], ) -> list[dict[str, Any]]: @@ -385,7 +385,7 @@ async def stop_node( return await asyncio.to_thread(_run_handler_sync, stop_node_handler, {"project_id": project_id, "node_id": node_id}) @mcp.tool() -async def reload_node( +async def node_reload( project_id: Annotated[str, Field(description="UUID of the project")], node_id: Annotated[str, Field(description="UUID of the node to reload")], ) -> list[dict[str, Any]]: @@ -393,7 +393,7 @@ async def reload_node( return await asyncio.to_thread(_run_handler_sync, reload_node_handler, {"project_id": project_id, "node_id": node_id}) @mcp.tool() -async def suspend_node( +async def node_suspend( project_id: Annotated[str, Field(description="UUID of the project")], node_id: Annotated[str, Field(description="UUID of the node to suspend")], ) -> list[dict[str, Any]]: @@ -402,7 +402,7 @@ async def suspend_node( @mcp.tool() -async def create_node( +async def node_create( project_id: Annotated[str, Field(description="UUID of the project")], template_id: Annotated[str, Field(description="UUID of the template to create the node from")], x: Annotated[int, Field(description="X coordinate on the project canvas")] = 0, @@ -417,7 +417,7 @@ async def create_node( @mcp.tool() -async def delete_node( +async def node_delete( project_id: Annotated[str, Field(description="UUID of the project")], node_id: Annotated[str, Field(description="UUID of the node to delete")], ) -> list[dict[str, Any]]: @@ -426,7 +426,7 @@ async def delete_node( @mcp.tool() -async def update_node( +async def node_update( project_id: Annotated[str, Field(description="UUID of the project")], node_id: Annotated[str, Field(description="UUID of the node to update")], **kwargs: Any, @@ -437,7 +437,7 @@ async def update_node( @mcp.tool() -async def get_node_console_info( +async def node_console( project_id: Annotated[str, Field(description="UUID of the project containing the node")], node_id: Annotated[str, Field(description="UUID of the node to get console info for")], ) -> list[dict[str, Any]]: @@ -469,13 +469,13 @@ async def get_node_console_info( # ── Link tools ──────────────────────────────────────────────────────── @mcp.tool() -async def get_links(project_id: str) -> list[dict[str, Any]]: +async def link_list(project_id: str) -> list[dict[str, Any]]: """List all links in a project.""" return await asyncio.to_thread(_run_handler_sync, get_links_handler, {"project_id": project_id}) @mcp.tool() -async def get_link( +async def link_get( project_id: Annotated[str, Field(description="UUID of the project")], link_id: Annotated[str, Field(description="UUID of the link")], ) -> list[dict[str, Any]]: @@ -484,7 +484,7 @@ async def get_link( @mcp.tool() -async def create_link( +async def link_create( project_id: Annotated[str, Field(description="UUID of the project")], nodes: Annotated[list, Field(description="List of node connections, e.g. [{\"node_id\": \"...\", \"adapter_number\": 0, \"port_number\": 0}]")], link_type: Annotated[str, Field(description="Link type - ethernet or serial")] = "ethernet", @@ -508,7 +508,7 @@ async def create_link( @mcp.tool() -async def delete_link( +async def link_delete( project_id: Annotated[str, Field(description="UUID of the project")], link_id: Annotated[str, Field(description="UUID of the link to delete")], ) -> list[dict[str, Any]]: @@ -517,7 +517,7 @@ async def delete_link( @mcp.tool() -async def update_link( +async def link_update( project_id: Annotated[str, Field(description="UUID of the project")], link_id: Annotated[str, Field(description="UUID of the link to update")], **kwargs: Any, @@ -546,13 +546,13 @@ async def update_link( # ── Template tools ──────────────────────────────────────────────────── @mcp.tool() -async def list_templates() -> list[dict[str, Any]]: +async def template_list() -> list[dict[str, Any]]: """List all available templates on the server.""" return await asyncio.to_thread(_run_handler_sync, list_templates_handler, {}) @mcp.tool() -async def get_template( +async def template_get( template_id: Annotated[str | None, Field(description="Template UUID (optional if name is provided)")] = None, name: Annotated[str | None, Field(description="Template name (optional if template_id is provided)")] = None, ) -> list[dict[str, Any]]: @@ -563,7 +563,7 @@ async def get_template( @mcp.tool() -async def create_template( +async def template_create( name: Annotated[str, Field(description="Template name")], template_type: Annotated[str, Field(description="Template type (e.g. qemu, docker, dynamips)")], compute_id: Annotated[str, Field(description="Compute ID (default: local)")] = "local", @@ -575,7 +575,7 @@ async def create_template( @mcp.tool() -async def update_template( +async def template_update( template_id: Annotated[str | None, Field(description="Template UUID (optional if name is provided)")] = None, name: Annotated[str | None, Field(description="Template name (optional if template_id is provided)")] = None, **kwargs: Any, @@ -586,7 +586,7 @@ async def update_template( @mcp.tool() -async def delete_template( +async def template_delete( template_id: Annotated[str | None, Field(description="Template UUID (optional if name is provided)")] = None, name: Annotated[str | None, Field(description="Template name (optional if template_id is provided)")] = None, ) -> list[dict[str, Any]]: @@ -599,13 +599,13 @@ async def delete_template( # ── Compute tools ───────────────────────────────────────────────────── @mcp.tool() -async def list_computes() -> list[dict[str, Any]]: +async def compute_list() -> list[dict[str, Any]]: """List all compute nodes available to the server.""" return await asyncio.to_thread(_run_handler_sync, list_computes_handler, {}) @mcp.tool() -async def get_compute( +async def compute_get( compute_id: Annotated[str, Field(description="Compute ID (default: local)")] = "local", ) -> list[dict[str, Any]]: """Get detailed information about a compute node.""" @@ -613,7 +613,7 @@ async def get_compute( @mcp.tool() -async def get_compute_images( +async def compute_images( emulator: Annotated[str, Field(description="Emulator type (e.g. qemu, iou, docker)")], compute_id: Annotated[str, Field(description="Compute ID (default: local)")] = "local", ) -> list[dict[str, Any]]: @@ -627,7 +627,7 @@ async def get_compute_images( @mcp.tool() -async def list_node_files( +async def node_file_list( project_id: Annotated[str, Field(description="UUID of the project")], node_id: Annotated[str, Field(description="UUID of the node")], path: Annotated[str, Field(description="Subdirectory path within node directory (optional)")] = "", @@ -644,7 +644,7 @@ async def list_node_files( @mcp.tool() -async def get_node_file( +async def node_file_get( project_id: Annotated[str, Field(description="UUID of the project")], node_id: Annotated[str, Field(description="UUID of the node")], file_path: Annotated[str, Field(description="Path to the file within the node directory")], @@ -667,7 +667,7 @@ async def get_node_file( @mcp.tool() -async def write_node_file( +async def node_file_write( project_id: Annotated[str, Field(description="UUID of the project")], node_id: Annotated[str, Field(description="UUID of the node")], file_path: Annotated[str, Field(description="Path to the file within the node directory")], @@ -680,7 +680,7 @@ async def write_node_file( @mcp.tool() -async def delete_node_file( +async def node_file_delete( project_id: Annotated[str, Field(description="UUID of the project")], node_id: Annotated[str, Field(description="UUID of the node")], file_path: Annotated[str, Field(description="Path to the file within the node directory")], @@ -695,7 +695,7 @@ async def delete_node_file( @mcp.tool() -async def start_all_nodes( +async def node_start_all( project_id: Annotated[str, Field(description="UUID of the project")], ) -> list[dict[str, Any]]: """Start all nodes in a project.""" @@ -705,7 +705,7 @@ async def start_all_nodes( @mcp.tool() -async def stop_all_nodes( +async def node_stop_all( project_id: Annotated[str, Field(description="UUID of the project")], ) -> list[dict[str, Any]]: """Stop all nodes in a project.""" @@ -715,7 +715,7 @@ async def stop_all_nodes( @mcp.tool() -async def suspend_all_nodes( +async def node_suspend_all( project_id: Annotated[str, Field(description="UUID of the project")], ) -> list[dict[str, Any]]: """Suspend all nodes in a project.""" @@ -725,7 +725,7 @@ async def suspend_all_nodes( @mcp.tool() -async def reload_all_nodes( +async def node_reload_all( project_id: Annotated[str, Field(description="UUID of the project")], ) -> list[dict[str, Any]]: """Reload (restart) all nodes in a project.""" @@ -735,7 +735,7 @@ async def reload_all_nodes( @mcp.tool() -async def duplicate_node( +async def node_duplicate( 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, @@ -749,7 +749,7 @@ async def duplicate_node( @mcp.tool() -async def isolate_node( +async def node_isolate( 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]]: @@ -760,7 +760,7 @@ async def isolate_node( @mcp.tool() -async def unisolate_node( +async def node_unisolate( 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]]: @@ -771,7 +771,7 @@ async def unisolate_node( @mcp.tool() -async def get_node_links( +async def 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]]: @@ -785,7 +785,7 @@ async def get_node_links( @mcp.tool() -async def reset_link( +async def link_reset( project_id: Annotated[str, Field(description="UUID of the project")], link_id: Annotated[str, Field(description="UUID of the link")], ) -> list[dict[str, Any]]: @@ -796,7 +796,7 @@ async def reset_link( @mcp.tool() -async def start_capture( +async def link_capture_start( project_id: Annotated[str, Field(description="UUID of the project")], link_id: Annotated[str, Field(description="UUID of the link")], data_link_type: Annotated[str, Field(description="Data link type (default: DLT_EN10MB)")] = "DLT_EN10MB", @@ -812,7 +812,7 @@ async def start_capture( @mcp.tool() -async def stop_capture( +async def link_capture_stop( project_id: Annotated[str, Field(description="UUID of the project")], link_id: Annotated[str, Field(description="UUID of the link")], ) -> list[dict[str, Any]]: @@ -823,7 +823,7 @@ async def stop_capture( @mcp.tool() -async def download_capture_file( +async def link_capture_download( project_id: Annotated[str, Field(description="UUID of the project")], link_id: Annotated[str, Field(description="UUID of the link")], ) -> list[dict[str, Any]]: @@ -837,7 +837,7 @@ async def download_capture_file( @mcp.tool() -async def get_snapshots( +async def snapshot_list( project_id: Annotated[str, Field(description="UUID of the project")], ) -> list[dict[str, Any]]: """List all snapshots of a project.""" @@ -847,7 +847,7 @@ async def get_snapshots( @mcp.tool() -async def create_snapshot( +async def snapshot_create( project_id: Annotated[str, Field(description="UUID of the project")], name: Annotated[str, Field(description="Name for the new snapshot")], ) -> list[dict[str, Any]]: @@ -858,7 +858,7 @@ async def create_snapshot( @mcp.tool() -async def delete_snapshot( +async def snapshot_delete( project_id: Annotated[str, Field(description="UUID of the project")], snapshot_id: Annotated[str, Field(description="UUID of the snapshot to delete")], ) -> list[dict[str, Any]]: @@ -869,7 +869,7 @@ async def delete_snapshot( @mcp.tool() -async def restore_snapshot( +async def snapshot_restore( project_id: Annotated[str, Field(description="UUID of the project")], snapshot_id: Annotated[str, Field(description="UUID of the snapshot to restore")], ) -> list[dict[str, Any]]: @@ -883,7 +883,7 @@ async def restore_snapshot( @mcp.tool() -async def get_drawings( +async def drawing_list( project_id: Annotated[str, Field(description="UUID of the project")], ) -> list[dict[str, Any]]: """List all drawings (labels, shapes, images) on a project canvas.""" @@ -893,7 +893,7 @@ async def get_drawings( @mcp.tool() -async def create_drawing( +async def drawing_create( project_id: Annotated[str, Field(description="UUID of the project")], svg: Annotated[str, Field(description="SVG content for the drawing")], x: Annotated[int, Field(description="X coordinate (default: 0)")] = 0, @@ -910,7 +910,7 @@ async def create_drawing( @mcp.tool() -async def get_drawing( +async def drawing_get( project_id: Annotated[str, Field(description="UUID of the project")], drawing_id: Annotated[str, Field(description="UUID of the drawing")], ) -> list[dict[str, Any]]: @@ -921,7 +921,7 @@ async def get_drawing( @mcp.tool() -async def update_drawing( +async def drawing_update( project_id: Annotated[str, Field(description="UUID of the project")], drawing_id: Annotated[str, Field(description="UUID of the drawing")], svg: Annotated[str | None, Field(description="New SVG content")] = None, @@ -940,7 +940,7 @@ async def update_drawing( @mcp.tool() -async def delete_drawing( +async def drawing_delete( project_id: Annotated[str, Field(description="UUID of the project")], drawing_id: Annotated[str, Field(description="UUID of the drawing to delete")], ) -> list[dict[str, Any]]: @@ -954,7 +954,7 @@ async def delete_drawing( @mcp.tool() -async def lock_project( +async def project_lock( 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.""" @@ -964,7 +964,7 @@ async def lock_project( @mcp.tool() -async def unlock_project( +async def project_unlock( project_id: Annotated[str, Field(description="UUID of the project")], ) -> list[dict[str, Any]]: """Unlock a project to allow editing of drawings and nodes.""" @@ -974,7 +974,7 @@ async def unlock_project( @mcp.tool() -async def get_locked_project( +async def project_locked( project_id: Annotated[str, Field(description="UUID of the project")], ) -> list[dict[str, Any]]: """Check whether a project is locked (preventing edits to drawings and nodes).""" @@ -984,7 +984,7 @@ async def get_locked_project( @mcp.tool() -async def load_project( +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.""" @@ -997,13 +997,13 @@ async def load_project( @mcp.tool() -async def get_version() -> list[dict[str, Any]]: +async def server_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]]: +async def server_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, {}) @@ -1012,13 +1012,13 @@ async def get_statistics() -> list[dict[str, Any]]: @mcp.tool() -async def get_symbols() -> list[dict[str, Any]]: +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 get_symbol( +async def symbol_get( symbol_id: Annotated[str, Field(description="Symbol ID (e.g. ':/symbols/router.svg')")], ) -> list[dict[str, Any]]: """Get details about a specific symbol.""" @@ -1028,7 +1028,7 @@ async def get_symbol( @mcp.tool() -async def get_symbol_dimensions( +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.""" @@ -1038,13 +1038,13 @@ async def get_symbol_dimensions( @mcp.tool() -async def get_default_symbols() -> list[dict[str, Any]]: +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 upload_symbol( +async def symbol_upload( symbol_id: Annotated[str, Field(description="Symbol ID to upload (e.g. ':/symbols/my_symbol.svg')")], ) -> list[dict[str, Any]]: """Upload or update a custom symbol on the server.""" @@ -1054,7 +1054,7 @@ async def upload_symbol( @mcp.tool() -async def delete_symbol( +async def symbol_delete( symbol_id: Annotated[str, Field(description="Symbol ID to delete")], ) -> list[dict[str, Any]]: """Delete a custom symbol from the server.""" @@ -1067,13 +1067,13 @@ async def delete_symbol( @mcp.tool() -async def get_appliances() -> list[dict[str, Any]]: +async def appliance_list() -> list[dict[str, Any]]: """List all available appliances (template library).""" return await asyncio.to_thread(_run_handler_sync, get_appliances_handler, {}) @mcp.tool() -async def get_appliance( +async def appliance_get( appliance_id: Annotated[str, Field(description="UUID of the appliance")], ) -> list[dict[str, Any]]: """Get detailed information about a specific appliance.""" @@ -1083,7 +1083,7 @@ async def get_appliance( @mcp.tool() -async def install_appliance( +async def appliance_install( appliance_id: Annotated[str, Field(description="UUID of the appliance to install")], ) -> list[dict[str, Any]]: """Install (download and set up) an appliance from the template library.""" @@ -1096,13 +1096,13 @@ async def install_appliance( @mcp.tool() -async def get_images() -> list[dict[str, Any]]: +async def image_list() -> list[dict[str, Any]]: """List all images available on the server across all emulators.""" return await asyncio.to_thread(_run_handler_sync, get_images_handler, {}) @mcp.tool() -async def get_image( +async def image_get( image_id: Annotated[str, Field(description="ID or filename of the image")], ) -> list[dict[str, Any]]: """Get detailed information about a specific image.""" @@ -1112,7 +1112,7 @@ async def get_image( @mcp.tool() -async def delete_image( +async def image_delete( image_id: Annotated[str, Field(description="ID or filename of the image to delete")], ) -> list[dict[str, Any]]: """Delete an image from the server. Cannot be undone.""" @@ -1122,13 +1122,13 @@ async def delete_image( @mcp.tool() -async def prune_images() -> list[dict[str, Any]]: +async def image_prune() -> list[dict[str, Any]]: """Remove all unused images from the server to free up disk space.""" return await asyncio.to_thread(_run_handler_sync, prune_images_handler, {}) @mcp.tool() -async def install_images() -> list[dict[str, Any]]: +async def image_install() -> list[dict[str, Any]]: """Request the server to install pending images (download from registry).""" return await asyncio.to_thread(_run_handler_sync, install_images_handler, {}) From e7038f1ae3e0110116e8758051a9a3b0f547fa0a Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 10 Jun 2026 14:34:35 +0800 Subject: [PATCH 08/41] feat: Add device configuration MCP tools (config_send, command_run, vpcs_config_set) - Add jwt_token/url parameters to get_device_ports_from_topology() and GNS3TopologyTool for MCP handler compatibility (backward compatible, auto-detection fallback) - Add jwt_token/url pass-through to ExecuteMultipleDeviceConfigCommands, ExecuteMultipleDeviceCommands, and VPCSCommands _run() methods - Create MCP handler device_config.py wrapping the 3 device config tools - Register as device_config_send, device_command_run, vpcs_config_set --- .../gns3_client/gns3_topology_reader.py | 8 +- .../tools_v2/config_tools_nornir.py | 12 ++- .../tools_v2/display_tools_nornir.py | 12 ++- .../tools_v2/vpcs_tools_netmiko.py | 16 ++- .../utils/get_gns3_device_port.py | 6 +- gns3server/api/routes/mcp/__init__.py | 69 ++++++++++++ gns3server/api/routes/mcp/device_config.py | 101 ++++++++++++++++++ 7 files changed, 215 insertions(+), 9 deletions(-) create mode 100644 gns3server/api/routes/mcp/device_config.py 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 6365e6be4..5ec0f6935 100644 --- a/gns3server/agent/gns3_copilot/gns3_client/gns3_topology_reader.py +++ b/gns3server/agent/gns3_copilot/gns3_client/gns3_topology_reader.py @@ -70,6 +70,8 @@ class GNS3TopologyTool(BaseTool): tool_input: Any = None, run_manager: Any = None, project_id: str | None = None, + jwt_token: str | None = None, + url: str | None = None, ) -> dict: """ Synchronous method to retrieve the topology of a specific GNS3 project. @@ -80,6 +82,8 @@ class GNS3TopologyTool(BaseTool): run_manager: Callback manager for tool run. project_id: The UUID of the specific GNS3 project to retrieve topology from. + jwt_token: JWT token for authentication (used by MCP handlers). + url: GNS3 server URL (used by MCP handlers). Returns: dict: A dictionary containing the project ID, name, status, nodes, @@ -102,8 +106,10 @@ class GNS3TopologyTool(BaseTool): } # Initialize Gns3Connector using factory function + # 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() + server = get_gns3_connector(jwt_token=jwt_token, url=url) if server is None: logger.error("Failed to create GNS3 connector") 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 44af6a703..7bce576ee 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/config_tools_nornir.py +++ b/gns3server/agent/gns3_copilot/tools_v2/config_tools_nornir.py @@ -169,6 +169,8 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): self, tool_input: str, # or Union[str, List[Any], Dict[str, Any]] run_manager: CallbackManagerForToolRun | None = None, + jwt_token: str | None = None, + url: str | None = None, **kwargs: Any, ) -> list[dict[str, Any]]: """ @@ -177,6 +179,8 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): Args: tool_input (str): A JSON string containing project_id and device configuration commands to execute. + jwt_token: JWT token for GNS3 API auth (MCP handlers). + url: GNS3 server URL (MCP handlers). Returns: List[Dict[str, Any]]: A list of dicts containing device names and @@ -214,7 +218,7 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): # Prepare device hosts data try: hosts_data = self._prepare_device_hosts_data( - device_configs_list, project_id + device_configs_list, project_id, jwt_token=jwt_token, url=url ) except ValueError as e: logger.error("Failed to prepare device hosts data: %s", e) @@ -547,6 +551,8 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): self, device_config_list: list[dict[str, Any]], project_id: str | None = None, + jwt_token: str | None = None, + url: str | None = None, ) -> dict[str, dict[str, Any]]: """Prepare device hosts data from topology information.""" # Extract device names list @@ -556,7 +562,9 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool): ] # Get device port information with project_id - hosts_data = get_device_ports_from_topology(device_names, project_id) + hosts_data = get_device_ports_from_topology( + device_names, project_id, jwt_token=jwt_token, url=url + ) if not hosts_data: error_msg = ( 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 abe7eae47..7e0061ceb 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/display_tools_nornir.py +++ b/gns3server/agent/gns3_copilot/tools_v2/display_tools_nornir.py @@ -171,6 +171,8 @@ class ExecuteMultipleDeviceCommands(BaseTool): self, tool_input: str | bytes | list[Any] | dict[str, Any], run_manager: CallbackManagerForToolRun | None = None, + jwt_token: str | None = None, + url: str | None = None, **kwargs: Any, ) -> list[dict[str, Any]]: """ @@ -181,6 +183,8 @@ class ExecuteMultipleDeviceCommands(BaseTool): Args: tool_input: JSON string with project_id and diagnostic commands. + jwt_token: JWT token for GNS3 API auth (MCP handlers). + url: GNS3 server URL (MCP handlers). Returns: List[Dict]: A list of dicts with device names and outputs. @@ -210,7 +214,7 @@ class ExecuteMultipleDeviceCommands(BaseTool): # Prepare device hosts data try: hosts_data = self._prepare_device_hosts_data( - device_configs_list, project_id + device_configs_list, project_id, jwt_token=jwt_token, url=url ) except ValueError as e: logger.error("Failed to prepare device hosts data: %s", e) @@ -490,6 +494,8 @@ class ExecuteMultipleDeviceCommands(BaseTool): self, device_config_list: list[dict[str, Any]], project_id: str | None = None, + jwt_token: str | None = None, + url: str | None = None, ) -> dict[str, dict[str, Any]]: """Prepare device hosts data from topology information.""" # Extract device names list @@ -499,7 +505,9 @@ class ExecuteMultipleDeviceCommands(BaseTool): ] # Get device port information with project_id - hosts_data = get_device_ports_from_topology(device_names, project_id) + hosts_data = get_device_ports_from_topology( + device_names, project_id, jwt_token=jwt_token, url=url + ) if not hosts_data: error_msg = ( 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 e143b700e..b7bb3d661 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/vpcs_tools_netmiko.py +++ b/gns3server/agent/gns3_copilot/tools_v2/vpcs_tools_netmiko.py @@ -154,6 +154,8 @@ class VPCSCommands(BaseTool): self, tool_input: str | bytes | list[Any] | dict[str, Any], run_manager: CallbackManagerForToolRun | None = None, + jwt_token: str | None = None, + url: str | None = None, **kwargs: Any, ) -> list[dict[str, Any]]: """ @@ -161,6 +163,8 @@ class VPCSCommands(BaseTool): Args: tool_input: JSON string with project_id and VPCS commands. + jwt_token: JWT token for GNS3 API auth (MCP handlers). + url: GNS3 server URL (MCP handlers). Returns: List of dicts with device names and command outputs. @@ -183,7 +187,7 @@ class VPCSCommands(BaseTool): # Prepare device hosts data try: hosts_data = self._prepare_device_hosts_data( - device_configs_list, project_id + device_configs_list, project_id, jwt_token=jwt_token, url=url ) except ValueError as e: logger.error("Failed to prepare device hosts data: %s", e) @@ -408,7 +412,11 @@ class VPCSCommands(BaseTool): } def _prepare_device_hosts_data( - self, device_configs_list: list[dict[str, Any]], project_id: str + self, + device_configs_list: list[dict[str, Any]], + project_id: str, + jwt_token: str | None = None, + url: str | None = None, ) -> dict[str, dict[str, Any]]: """ Prepare Nornir inventory hosts data for VPCS devices. @@ -416,6 +424,8 @@ class VPCSCommands(BaseTool): Args: device_configs_list: List of device configurations project_id: GNS3 project ID + jwt_token: JWT token for GNS3 API auth (MCP handlers). + url: GNS3 server URL (MCP handlers). Returns: Dictionary mapping device names to their host data @@ -431,7 +441,7 @@ class VPCSCommands(BaseTool): # Get device port mappings from topology device_ports = get_device_ports_from_topology( - device_names, project_id=project_id + device_names, project_id=project_id, jwt_token=jwt_token, url=url ) # Build Nornir inventory hosts data 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 3ac1bc992..a5c950150 100644 --- a/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py +++ b/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py @@ -36,6 +36,8 @@ logger = logging.getLogger(__name__) def get_device_ports_from_topology( device_names: list[str], project_id: str | None = None, + jwt_token: str | None = None, + url: str | None = None, ) -> dict[str, dict[str, Any]]: """ Get device connection information from GNS3 topology @@ -43,6 +45,8 @@ def get_device_ports_from_topology( Args: device_names: List of device names to look up project_id: UUID of the specific GNS3 project to retrieve topology from + jwt_token: JWT token for authentication (used by MCP handlers). + url: GNS3 server URL (used by MCP handlers). Returns: Dictionary mapping device names to their connection data: @@ -71,7 +75,7 @@ def get_device_ports_from_topology( # Get topology information topo = GNS3TopologyTool() - topology = topo._run(project_id=project_id) + topology = topo._run(project_id=project_id, jwt_token=jwt_token, url=url) # Dynamically build hosts_data from topology hosts_data: dict[str, dict[str, Any]] = {} diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index 19235e69e..c6b3aee57 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -70,6 +70,10 @@ from .images import ( delete_image_handler, prune_images_handler, install_images_handler, ) +from .device_config import ( + device_config_send_handler, device_command_run_handler, + vpcs_config_set_handler, +) from .nodes import ( get_nodes_handler, get_node_handler, start_node_handler, stop_node_handler, reload_node_handler, suspend_node_handler, @@ -1133,6 +1137,71 @@ async def image_install() -> list[dict[str, Any]]: return await asyncio.to_thread(_run_handler_sync, install_images_handler, {}) +# ── Device config tools ─────────────────────────────────────────────── +# These tools connect to network device consoles via telnet/SSH using +# Nornir + Netmiko. Devices must be started and have a device_type tag. +# +# Workflow: +# 1. node_list(project_id) → identify device names +# 2. node_start_all(project_id) → ensure devices are running +# 3. device_config_send(project_id, device_configs=[...]) → push config +# 4. device_command_run(project_id, device_commands=[...]) → verify + + +@mcp.tool() +async def device_config_send( + project_id: Annotated[str, Field(description="UUID of the project")], + device_configs: Annotated[list, Field( + description="List of device configs. Each entry: {\"device_name\": \"R1\", \"config_commands\": [\"int lo0\", \"ip add 1.1.1.1 255.255.255.255\"]}" + )], +) -> list[dict[str, Any]]: + """Send configuration commands to network devices via console (telnet/SSH). + + 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 + """ + return await asyncio.to_thread(_run_handler_sync, device_config_send_handler, { + "project_id": project_id, "device_configs": device_configs, + }) + + +@mcp.tool() +async def device_command_run( + project_id: Annotated[str, Field(description="UUID of the project")], + device_commands: Annotated[list, Field( + description="List of device show commands. Each entry: {\"device_name\": \"R1\", \"show_commands\": [\"show ip int brief\", \"show running-config\"]}" + )], +) -> list[dict[str, Any]]: + """Run read-only diagnostic (show) commands on network devices via console. + + Use this to inspect device status, view configurations, or verify changes. + Devices must be started first. + """ + return await asyncio.to_thread(_run_handler_sync, device_command_run_handler, { + "project_id": project_id, "device_commands": device_commands, + }) + + +@mcp.tool() +async def vpcs_config_set( + project_id: Annotated[str, Field(description="UUID of the project")], + device_configs: Annotated[list, Field( + description="List of VPCS configs. Each entry: {\"device_name\": \"PC1\", \"commands\": [\"ip 10.0.0.1/24 10.0.0.254\", \"save\"]}" + )], +) -> list[dict[str, Any]]: + """Configure VPCS devices (set IP addresses, gateway, etc.). + + VPCS-specific configuration commands: + - ip
/ Set IP and gateway + - save Save config to startup.vpc + - ping Test connectivity + """ + return await asyncio.to_thread(_run_handler_sync, vpcs_config_set_handler, { + "project_id": project_id, "device_configs": device_configs, + }) + + # ── Auth‑wrapped SSE app ────────────────────────────────────────────── def _make_auth_wrapper(inner_app): diff --git a/gns3server/api/routes/mcp/device_config.py b/gns3server/api/routes/mcp/device_config.py new file mode 100644 index 000000000..790d4b89f --- /dev/null +++ b/gns3server/api/routes/mcp/device_config.py @@ -0,0 +1,101 @@ +# +# 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 device configuration via Nornir + Netmiko. + +These tools connect to network device consoles via telnet/SSH and execute +configuration or diagnostic commands. Device connection info is automatically +discovered from the project topology using the device's tags for device_type. + +Prerequisites: + - Device must be started (use node_start / node_start_all) + - Device must have a 'device_type:' tag set in GNS3 + (right-click → Configure → Tags → add 'device_type:cisco_ios_telnet') + - Device must have a console port assigned +""" + +import json +import logging +from typing import Any + +log = logging.getLogger(__name__) + + +# ── Tool handlers ────────────────────────────────────────────────────────── + +def device_config_send_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> list[dict[str, Any]]: + """Send configuration commands to network devices via console.""" + 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"}] + + from gns3server.agent.gns3_copilot.tools_v2.config_tools_nornir import ExecuteMultipleDeviceConfigCommands + + tool = ExecuteMultipleDeviceConfigCommands() + input_data = json.dumps({ + "project_id": project_id, + "device_configs": device_configs, + }) + return tool._run( + input_data, + jwt_token=gns3_ctx["jwt_token"], + url=gns3_ctx["server_url"], + ) + + +def device_command_run_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> list[dict[str, Any]]: + """Run read-only diagnostic (show) commands on network devices.""" + project_id = params.get("project_id") + device_commands = params.get("device_commands") + if not project_id or not device_commands: + return [{"error": "project_id and device_commands are required"}] + + from gns3server.agent.gns3_copilot.tools_v2.display_tools_nornir import ExecuteMultipleDeviceCommands + + tool = ExecuteMultipleDeviceCommands() + input_data = json.dumps({ + "project_id": project_id, + "device_commands": device_commands, + }) + return tool._run( + input_data, + jwt_token=gns3_ctx["jwt_token"], + url=gns3_ctx["server_url"], + ) + + +def vpcs_config_set_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> list[dict[str, Any]]: + """Configure VPCS devices (set IP, gateway, etc.).""" + 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"}] + + from gns3server.agent.gns3_copilot.tools_v2.vpcs_tools_netmiko import VPCSCommands + + tool = VPCSCommands() + input_data = json.dumps({ + "project_id": project_id, + "device_configs": device_configs, + }) + return tool._run( + input_data, + jwt_token=gns3_ctx["jwt_token"], + url=gns3_ctx["server_url"], + ) From 67fa65a9e0284e890e4219a03775ea23bab361d2 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 10 Jun 2026 22:31:17 +0800 Subject: [PATCH 09/41] fix: Require UUID for compute_get/images, remove 'local' string default The end /v3/computes/{compute_id} expects the compute_id to be a valid UUID. Previously the MCP tool defaulted to the string 'local', which caused a ValueError in the database layer. Now compute_id is required and callers must use compute_list first to resolve names to UUIDs. --- gns3server/api/routes/mcp/__init__.py | 6 +++--- gns3server/api/routes/mcp/computes.py | 8 ++++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index c6b3aee57..0a90b745e 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -610,16 +610,16 @@ async def compute_list() -> list[dict[str, Any]]: @mcp.tool() async def compute_get( - compute_id: Annotated[str, Field(description="Compute ID (default: local)")] = "local", + compute_id: Annotated[str, Field(description="Compute UUID from compute_list output")], ) -> list[dict[str, Any]]: - """Get detailed information about a compute node.""" + """Get detailed information about a compute node. Use compute_list first to get the UUID.""" return await asyncio.to_thread(_run_handler_sync, get_compute_handler, {"compute_id": compute_id}) @mcp.tool() async def compute_images( emulator: Annotated[str, Field(description="Emulator type (e.g. qemu, iou, docker)")], - compute_id: Annotated[str, Field(description="Compute ID (default: local)")] = "local", + compute_id: Annotated[str, Field(description="Compute UUID from compute_list output")], ) -> list[dict[str, Any]]: """List available images for an emulator on a compute node.""" return await asyncio.to_thread(_run_handler_sync, get_compute_images_handler, { diff --git a/gns3server/api/routes/mcp/computes.py b/gns3server/api/routes/mcp/computes.py index e95fbd45c..29072ea04 100644 --- a/gns3server/api/routes/mcp/computes.py +++ b/gns3server/api/routes/mcp/computes.py @@ -42,16 +42,20 @@ 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", "local") + compute_id = params.get("compute_id") + if not compute_id: + return {"error": "compute_id is required (use compute_list to get the UUID)"} 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", "local") + compute_id = params.get("compute_id") 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)} From 8b014693a85f39cf3d33b2fc4f629dda07d88a03 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 10 Jun 2026 22:38:38 +0800 Subject: [PATCH 10/41] fix: Type compute_id as uuid.UUID to reject non-UUID values at MCP input layer Previously compute_id was typed as str, so 'local' would pass MCP validation and reach the controller API where it crashed. Now uuid.UUID type ensures Pydantic rejects any non-UUID string before the handler runs. --- gns3server/api/routes/mcp/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index 0a90b745e..a5c905b07 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -31,6 +31,7 @@ import json import asyncio import logging import socket +import uuid from typing import Any, Annotated from urllib.parse import parse_qs @@ -610,7 +611,7 @@ async def compute_list() -> list[dict[str, Any]]: @mcp.tool() async def compute_get( - compute_id: Annotated[str, Field(description="Compute UUID from compute_list output")], + compute_id: Annotated[uuid.UUID, Field(description="Compute UUID from compute_list output")], ) -> list[dict[str, Any]]: """Get detailed information about a compute node. Use compute_list first to get the UUID.""" return await asyncio.to_thread(_run_handler_sync, get_compute_handler, {"compute_id": compute_id}) @@ -619,7 +620,7 @@ 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[str, Field(description="Compute UUID from compute_list output")], + compute_id: Annotated[uuid.UUID, Field(description="Compute UUID from compute_list output")], ) -> list[dict[str, Any]]: """List available images for an emulator on a compute node.""" return await asyncio.to_thread(_run_handler_sync, get_compute_images_handler, { From 658a8d112b1062c4b51de63d6edda05a9a39e949 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 10 Jun 2026 22:44:54 +0800 Subject: [PATCH 11/41] docs: Clarify compute tools descriptions about local compute vs database computes compute_list and compute_get only return database-registered computes. The built-in local compute is returned by server_statistics instead. --- gns3server/api/routes/mcp/__init__.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index a5c905b07..f18815589 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -605,7 +605,10 @@ async def template_delete( @mcp.tool() async def compute_list() -> list[dict[str, Any]]: - """List all compute nodes available to the server.""" + """List all remotely registered compute nodes (returns only database entries, does NOT include the built-in local compute). + + For the local compute info, use server_statistics instead. + """ return await asyncio.to_thread(_run_handler_sync, list_computes_handler, {}) @@ -613,7 +616,11 @@ async def compute_list() -> list[dict[str, Any]]: async def compute_get( compute_id: Annotated[uuid.UUID, Field(description="Compute UUID from compute_list output")], ) -> list[dict[str, Any]]: - """Get detailed information about a compute node. Use compute_list first to get the UUID.""" + """Get detailed information about a registered remote 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. + """ return await asyncio.to_thread(_run_handler_sync, get_compute_handler, {"compute_id": compute_id}) @@ -622,7 +629,11 @@ 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")], ) -> list[dict[str, Any]]: - """List available images for an emulator on a compute node.""" + """List available images for an emulator on a registered 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. + """ return await asyncio.to_thread(_run_handler_sync, get_compute_images_handler, { "emulator": emulator, "compute_id": compute_id, }) From d431ff6eaa8442990f04bb7a2faff1e10edb68fe Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 10 Jun 2026 22:53:51 +0800 Subject: [PATCH 12/41] feat: Log registered MCP tools at startup --- gns3server/api/routes/mcp/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index f18815589..9f873352a 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -1287,3 +1287,7 @@ def register_starlette_routes(app): sse_app = _make_auth_wrapper(mcp.sse_app(mount_path="")) app.mount("/v3/mcp/transport", sse_app, name="mcp-sse") log.info("MCP SSE server mounted at /v3/mcp/transport") + + # Log registered MCP tools for verification + tool_names = list(mcp._tool_manager._tools.keys()) + log.info("MCP tools registered (%d): %s", len(tool_names), ", ".join(sorted(tool_names))) From 80e64ebbae868980a492fb175463bd1de067d81e Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 10 Jun 2026 23:01:27 +0800 Subject: [PATCH 13/41] fix: Fix symbol_get/upload/delete handlers for correct API paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - get_symbol: URL was missing '/raw' suffix + tried .json() on binary SVG → now returns download URL + curl command (like download_capture_file) - upload_symbol: URL was missing '/raw' suffix → now accepts SVG content string and POSTs to correct path - delete_symbol: returns 204 No Content, .json() would fail → removed .json() call (just returns message) --- gns3server/api/routes/mcp/__init__.py | 7 ++++--- gns3server/api/routes/mcp/symbols.py | 20 ++++++++++++++------ 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index 9f873352a..1523be18e 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -1037,7 +1037,7 @@ async def symbol_list() -> list[dict[str, Any]]: async def symbol_get( symbol_id: Annotated[str, Field(description="Symbol ID (e.g. ':/symbols/router.svg')")], ) -> list[dict[str, Any]]: - """Get details about a specific symbol.""" + """Get a download URL for a symbol file (SVG). Use curl to download.""" return await asyncio.to_thread(_run_handler_sync, get_symbol_handler, { "symbol_id": symbol_id, }) @@ -1062,10 +1062,11 @@ async def symbol_defaults() -> list[dict[str, Any]]: @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.""" + """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, + "symbol_id": symbol_id, "content": content, }) diff --git a/gns3server/api/routes/mcp/symbols.py b/gns3server/api/routes/mcp/symbols.py index 5ff2d56d8..a49e417d5 100644 --- a/gns3server/api/routes/mcp/symbols.py +++ b/gns3server/api/routes/mcp/symbols.py @@ -50,8 +50,14 @@ def get_symbol_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict symbol_id = params.get("symbol_id") if not symbol_id: return {"error": "symbol_id is required"} - conn = _get_connector(gns3_ctx) - return conn.http_call("get", f"{conn.base_url}/symbols/{symbol_id}").json() + download_url = f"{gns3_ctx['server_url']}/v3/symbols/{symbol_id}/raw" + auth_token = gns3_ctx['jwt_token'] + return { + "symbol_id": symbol_id, + "download_url": download_url, + "curl_command": f"curl -L -o '{symbol_id.replace(':', '').replace('/', '_')}.svg' -H 'Authorization: Bearer {auth_token}' '{download_url}'", + "note": "Symbol files are SVG images. Use curl to download.", + } def get_symbol_dimensions_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: @@ -70,11 +76,13 @@ def get_default_symbols_handler(params: dict[str, Any], gns3_ctx: dict[str, Any] def upload_symbol_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: symbol_id = params.get("symbol_id") - if not symbol_id: - return {"error": "symbol_id is required"} + content = params.get("content") + if not symbol_id or content is None: + return {"error": "symbol_id and content (SVG data) are required"} conn = _get_connector(gns3_ctx) - result = conn.http_call("post", f"{conn.base_url}/symbols/{symbol_id}").json() - return {"message": f"Symbol {symbol_id} uploaded", "symbol": result} + url = f"{conn.base_url}/symbols/{symbol_id}/raw" + conn.http_call("post", url, data=content, headers={"Content-Type": "image/svg+xml"}) + return {"message": f"Symbol {symbol_id} uploaded", "symbol_id": symbol_id} def delete_symbol_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: From 4ec80f898936d2c5457b7821c0381febf1c704e8 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 10 Jun 2026 23:32:27 +0800 Subject: [PATCH 14/41] fix: Remove unsupported description param from project_create GNS3 REST API ProjectCreate schema does not have a description field. The parameter was silently ignored; now removed to avoid confusion. --- gns3server/api/routes/mcp/__init__.py | 3 --- gns3server/api/routes/mcp/projects.py | 5 +---- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index 1523be18e..f2a23247a 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -257,12 +257,9 @@ async def project_get( @mcp.tool() async def project_create( name: Annotated[str, Field(description="Project name")], - description: Annotated[str, Field(description="Optional project description")] = "", ) -> list[dict[str, Any]]: """Create a new GNS3 project.""" params = {"name": name} - if description: - params["description"] = description return await asyncio.to_thread(_run_handler_sync, create_project_handler, params) diff --git a/gns3server/api/routes/mcp/projects.py b/gns3server/api/routes/mcp/projects.py index bcb348380..76478bf81 100644 --- a/gns3server/api/routes/mcp/projects.py +++ b/gns3server/api/routes/mcp/projects.py @@ -66,10 +66,7 @@ def create_project_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> if not name: return {"error": "name is required"} conn = _get_connector(gns3_ctx) - project_data = {"name": name} - if "description" in params: - project_data["description"] = params["description"] - return conn.http_call("post", f"{conn.base_url}/projects", json_data=project_data).json() + return conn.http_call("post", f"{conn.base_url}/projects", json_data={"name": name}).json() def delete_project_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: From e7aa228ead8e451b3c8e73385118c60463a4f627 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 10 Jun 2026 23:56:30 +0800 Subject: [PATCH 15/41] fix: Update link_reset description to match actual behavior (delete + recreate) --- gns3server/api/routes/mcp/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index f2a23247a..91cba3cc7 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -802,7 +802,7 @@ async def link_reset( project_id: Annotated[str, Field(description="UUID of the project")], link_id: Annotated[str, Field(description="UUID of the link")], ) -> list[dict[str, Any]]: - """Reset a link, clearing its state (counters, filters, etc.).""" + """Reset a link by deleting and recreating it. All filters, suspend state, and packet counters are cleared. The link will briefly go down then come back up.""" return await asyncio.to_thread(_run_handler_sync, reset_link_handler, { "project_id": project_id, "link_id": link_id, }) From 571853599fac9582492efd84e40c7ef4a05bdb84 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 10 Jun 2026 23:59:45 +0800 Subject: [PATCH 16/41] docs: Update link_reset description with accurate UDP connection behavior --- gns3server/api/routes/mcp/__init__.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index 91cba3cc7..9a770924b 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -802,7 +802,15 @@ async def link_reset( project_id: Annotated[str, Field(description="UUID of the project")], link_id: Annotated[str, Field(description="UUID of the link")], ) -> list[dict[str, Any]]: - """Reset a link by deleting and recreating it. All filters, suspend state, and packet counters are cleared. The link will briefly go down then come back up.""" + """Reset a link by tearing down the underlying UDP connection and recreating it. + + Use cases: + - Clear accumulated packet errors/drops from the link's UDP connection + - Force filter state (delay, packet loss, etc.) to restart fresh + - Recover a stuck or abnormal link state + + Filters are preserved but their internal application state resets. + """ return await asyncio.to_thread(_run_handler_sync, reset_link_handler, { "project_id": project_id, "link_id": link_id, }) From 66975f54f7d7d20cc182dc83e65a395a95d0c847 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 11 Jun 2026 00:14:00 +0800 Subject: [PATCH 17/41] fix: Map MCP device_command_run parameter to tool's expected field name MCP sent 'device_commands' but the internal display_tools_nornir expects 'device_configs'. Renamed parameter for consistency. --- gns3server/api/routes/mcp/__init__.py | 6 +++--- gns3server/api/routes/mcp/device_config.py | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index 9a770924b..70f430894 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -1187,8 +1187,8 @@ async def device_config_send( @mcp.tool() async def device_command_run( project_id: Annotated[str, Field(description="UUID of the project")], - device_commands: Annotated[list, Field( - description="List of device show commands. Each entry: {\"device_name\": \"R1\", \"show_commands\": [\"show ip int brief\", \"show running-config\"]}" + device_configs: Annotated[list, Field( + description="List of device commands. Each entry: {\"device_name\": \"R1\", \"show_commands\": [\"show ip int brief\", \"show running-config\"]}" )], ) -> list[dict[str, Any]]: """Run read-only diagnostic (show) commands on network devices via console. @@ -1197,7 +1197,7 @@ async def device_command_run( Devices must be started first. """ return await asyncio.to_thread(_run_handler_sync, device_command_run_handler, { - "project_id": project_id, "device_commands": device_commands, + "project_id": project_id, "device_configs": device_configs, }) diff --git a/gns3server/api/routes/mcp/device_config.py b/gns3server/api/routes/mcp/device_config.py index 790d4b89f..6d886a81c 100644 --- a/gns3server/api/routes/mcp/device_config.py +++ b/gns3server/api/routes/mcp/device_config.py @@ -62,16 +62,16 @@ def device_config_send_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) def device_command_run_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> list[dict[str, Any]]: """Run read-only diagnostic (show) commands on network devices.""" project_id = params.get("project_id") - device_commands = params.get("device_commands") - if not project_id or not device_commands: - return [{"error": "project_id and device_commands are required"}] + device_configs = params.get("device_configs") + if not project_id or not device_configs: + return [{"error": "project_id and device_configs (list of {device_name, show_commands}) are required"}] from gns3server.agent.gns3_copilot.tools_v2.display_tools_nornir import ExecuteMultipleDeviceCommands tool = ExecuteMultipleDeviceCommands() input_data = json.dumps({ "project_id": project_id, - "device_commands": device_commands, + "device_configs": device_configs, }) return tool._run( input_data, From 42665839223029e939f9a01669d0c7bc9b7e1b47 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 11 Jun 2026 00:36:37 +0800 Subject: [PATCH 18/41] fix: Add missing rotation parameter to drawing_update MCP tool Parameter was defined in create_drawing but omitted from update_drawing, causing rotation changes to be silently ignored. --- gns3server/api/routes/mcp/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index 70f430894..51f4c614a 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -950,10 +950,11 @@ async def drawing_update( x: Annotated[int | None, Field(description="New X coordinate")] = None, y: Annotated[int | None, Field(description="New Y coordinate")] = None, z: Annotated[int | None, Field(description="New Z layer")] = None, + rotation: Annotated[int | None, Field(description="Rotation angle in degrees, -359 to 359")] = None, ) -> list[dict[str, Any]]: - """Update a drawing's properties (svg, position, lock state, etc.).""" + """Update a drawing's properties (svg, position, lock state, rotation, etc.).""" params = {"project_id": project_id, "drawing_id": drawing_id} - local_vars = {"svg": svg, "locked": locked, "x": x, "y": y, "z": z} + local_vars = {"svg": svg, "locked": locked, "x": x, "y": y, "z": z, "rotation": rotation} for key, val in local_vars.items(): if val is not None: params[key] = val From 4b9572d565f94fb84756e3a2ef5c9479f8b0420f Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 11 Jun 2026 00:39:37 +0800 Subject: [PATCH 19/41] docs: Add SVG shape examples to drawing_create tool description --- gns3server/api/routes/mcp/__init__.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index 51f4c614a..447aac02e 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -923,7 +923,15 @@ async def drawing_create( locked: Annotated[bool, Field(description="Lock the drawing (default: false)")] = False, rotation: Annotated[int, Field(description="Rotation angle in degrees, -359 to 359 (default: 0)")] = 0, ) -> list[dict[str, Any]]: - """Create a new drawing (label, shape, or image) on a project canvas.""" + """Create a new drawing (label, shape, or image) on a project canvas. + + SVG examples: + Text label: R1 + Rectangle: + Ellipse: + Line: + Dashed line: + """ return await asyncio.to_thread(_run_handler_sync, create_drawing_handler, { "project_id": project_id, "svg": svg, "x": x, "y": y, "z": z, "locked": locked, "rotation": rotation, From 3db275b1990fdd551eb3fff5bd7236d79cb93c60 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 11 Jun 2026 00:48:54 +0800 Subject: [PATCH 20/41] docs: Add GNS3 SVG rendering quirks to drawing_create description --- gns3server/api/routes/mcp/__init__.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index 447aac02e..91c16288a 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -925,9 +925,15 @@ async def drawing_create( ) -> list[dict[str, Any]]: """Create a new drawing (label, shape, or image) on a project canvas. + GNS3 SVG rendering notes: + - MUST have a solid fill color (e.g. fill=\"#FF0000\") to render. + fill=\"none\" or fill=\"transparent\" will be invisible in the GUI. + - works correctly with or without fill. + - and work normally. + SVG examples: Text label: R1 - Rectangle: + Rectangle: Ellipse: Line: Dashed line: From 9d7c482288e1e33b8731a5e92d0825c27efc2e65 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 11 Jun 2026 01:26:43 +0800 Subject: [PATCH 21/41] fix: Skip always-running nodes in start_all/stop_all Always-running node types (Ethernet switch, Cloud, NAT, etc.) return 405 when start/stop is called. is_always_running() already tracks these types; start_all/stop_all now skip them. --- gns3server/controller/project.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index 0526df838..463ff5345 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -1532,21 +1532,23 @@ class Project: @open_required async def start_all(self): """ - Start all nodes + Start all nodes (except always-running types like Ethernet switch, Cloud, NAT, etc.) """ pool = Pool(concurrency=3) for node in self.nodes.values(): - pool.append(node.start) + if not node.is_always_running(): + pool.append(node.start) await pool.join() @open_required async def stop_all(self): """ - Stop all nodes + Stop all nodes (except always-running types like Ethernet switch, Cloud, NAT, etc.) """ pool = Pool(concurrency=3) for node in self.nodes.values(): - pool.append(node.stop) + if not node.is_always_running(): + pool.append(node.stop) await pool.join() @open_required From 828a77048955ad88eb4770097cd85fe26a5bd63a Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 11 Jun 2026 01:28:19 +0800 Subject: [PATCH 22/41] fix: Remove .json() calls on 204 responses for prune/install images --- gns3server/api/routes/mcp/images.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/gns3server/api/routes/mcp/images.py b/gns3server/api/routes/mcp/images.py index 4b00c1a22..0022e1554 100644 --- a/gns3server/api/routes/mcp/images.py +++ b/gns3server/api/routes/mcp/images.py @@ -65,11 +65,13 @@ def delete_image_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> di def prune_images_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: conn = _get_connector(gns3_ctx) - result = conn.http_call("delete", f"{conn.base_url}/images/prune").json() - return {"message": "Unused images pruned", "result": result} + # Returns 204 No Content on success (empty body, no .json()) + conn.http_call("delete", f"{conn.base_url}/images/prune") + return {"message": "Unused images pruned"} def install_images_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: conn = _get_connector(gns3_ctx) - result = conn.http_call("post", f"{conn.base_url}/images/install").json() - return {"message": "Image installation requested", "result": result} + # Returns 204 No Content on success (empty body, no .json()) + conn.http_call("post", f"{conn.base_url}/images/install") + return {"message": "Image installation completed"} From c2aae9529b5c5f87117fa8db15d915739fe2fd3e Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 11 Jun 2026 01:34:34 +0800 Subject: [PATCH 23/41] fix: Add image field to template_create, document type-specific params in description --- gns3server/api/routes/mcp/__init__.py | 17 +++++++++++++---- gns3server/api/routes/mcp/templates.py | 4 ++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index 91c16288a..bb226c304 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -569,11 +569,20 @@ async def template_create( name: Annotated[str, Field(description="Template name")], template_type: Annotated[str, Field(description="Template type (e.g. qemu, docker, dynamips)")], compute_id: Annotated[str, Field(description="Compute ID (default: local)")] = "local", + image: Annotated[str | None, Field(description="Docker image name or Dynamips IOS image path (required for docker/dynamips)")] = None, ) -> list[dict[str, Any]]: - """Create a new template.""" - return await asyncio.to_thread(_run_handler_sync, create_template_handler, { - "name": name, "template_type": template_type, "compute_id": compute_id, - }) + """Create a new template. + + Template-type-specific required parameters: + docker: image is required (e.g. "ubuntu:latest") + dynamips: image is required (path to .image file) + iou: needs 'path' (IOL image path) — set via template_update after creation + qemu: needs 'hda_disk_image' or 'qemu_path' — set via template_update after creation + """ + params = {"name": name, "template_type": template_type, "compute_id": compute_id} + if image: + params["image"] = image + return await asyncio.to_thread(_run_handler_sync, create_template_handler, params) @mcp.tool() diff --git a/gns3server/api/routes/mcp/templates.py b/gns3server/api/routes/mcp/templates.py index 6ed9eaf30..01006ee94 100644 --- a/gns3server/api/routes/mcp/templates.py +++ b/gns3server/api/routes/mcp/templates.py @@ -83,6 +83,10 @@ def create_template_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> "template_type": template_type, "compute_id": params.get("compute_id", "local"), } + # Pass through optional template-type-specific fields (image, qemu_path, etc.) + for key in ("image",): + if key in params: + data[key] = params[key] return conn.http_call("post", f"{conn.base_url}/templates", json_data=data).json() From d0960812aa34a511a0117e2fa3acb6c7bda7ffe6 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 11 Jun 2026 01:35:14 +0800 Subject: [PATCH 24/41] docs: Clarify symbol_delete limitations (built-in vs custom symbols) --- gns3server/api/routes/mcp/__init__.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index bb226c304..34b889483 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -1101,9 +1101,14 @@ async def symbol_upload( @mcp.tool() async def symbol_delete( - symbol_id: Annotated[str, Field(description="Symbol ID to 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.""" + """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, }) From 05f12e1f15d0acfe3d914f5d60a9489950e7eb1d Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 11 Jun 2026 01:42:27 +0800 Subject: [PATCH 25/41] docs: Clarify image_prune description - only removes unreferenced images --- gns3server/api/routes/mcp/__init__.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index 34b889483..0bf40c131 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -1174,7 +1174,12 @@ async def image_delete( @mcp.tool() async def image_prune() -> list[dict[str, Any]]: - """Remove all unused images from the server to free up disk space.""" + """Remove images not referenced by any template. + + NOTE: Only images that are not used by any template will be removed. + If all images are still referenced by templates, no images are deleted. + Use image_list to see which images exist and check if they are in use. + """ return await asyncio.to_thread(_run_handler_sync, prune_images_handler, {}) From b69ff1785063356963e37e1a55a654aef09968b4 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 11 Jun 2026 12:00:36 +0800 Subject: [PATCH 26/41] docs: Fix image_install description - it auto-creates templates from uploaded images, not downloads --- gns3server/api/routes/mcp/__init__.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index 0bf40c131..3f679a581 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -1185,7 +1185,12 @@ async def image_prune() -> list[dict[str, Any]]: @mcp.tool() async def image_install() -> list[dict[str, Any]]: - """Request the server to install pending images (download from registry).""" + """Scan uploaded images and auto-create templates by matching image checksums against known appliance definitions. + + 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. + """ return await asyncio.to_thread(_run_handler_sync, install_images_handler, {}) From f0b0de2a9118285997cb6a140d4f2409fb058cdb Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 11 Jun 2026 12:11:13 +0800 Subject: [PATCH 27/41] docs: Add MCP sharing warnings to shared gns3_copilot modules --- .../agent/gns3_copilot/gns3_client/connector_factory.py | 4 ++++ gns3server/agent/gns3_copilot/gns3_client/custom_gns3fy.py | 4 ++++ .../agent/gns3_copilot/gns3_client/gns3_topology_reader.py | 4 ++++ .../agent/gns3_copilot/tools_v2/config_tools_nornir.py | 5 +++++ .../agent/gns3_copilot/tools_v2/display_tools_nornir.py | 5 +++++ gns3server/agent/gns3_copilot/tools_v2/vpcs_tools_netmiko.py | 5 +++++ gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py | 5 +++++ 7 files changed, 32 insertions(+) diff --git a/gns3server/agent/gns3_copilot/gns3_client/connector_factory.py b/gns3server/agent/gns3_copilot/gns3_client/connector_factory.py index a2144b2b9..e878ae911 100644 --- a/gns3server/agent/gns3_copilot/gns3_client/connector_factory.py +++ b/gns3server/agent/gns3_copilot/gns3_client/connector_factory.py @@ -29,6 +29,10 @@ GNS3 Connector Factory Module This module provides factory functions for creating Gns3Connector instances with JWT token authentication and context-aware configuration management. +⚠️ WARNING: This module is shared with the MCP (Model Context Protocol) service. +The get_gns3_connector() function is used by both gns3-copilot and MCP. +Modifications must be tested with BOTH. + Features: - Context variable based request-scoped data management (JWT tokens, LLM config) diff --git a/gns3server/agent/gns3_copilot/gns3_client/custom_gns3fy.py b/gns3server/agent/gns3_copilot/gns3_client/custom_gns3fy.py index 3f714fd36..6e30af017 100644 --- a/gns3server/agent/gns3_copilot/gns3_client/custom_gns3fy.py +++ b/gns3server/agent/gns3_copilot/gns3_client/custom_gns3fy.py @@ -29,6 +29,10 @@ 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) 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 5ec0f6935..8ecee9409 100644 --- a/gns3server/agent/gns3_copilot/gns3_client/gns3_topology_reader.py +++ b/gns3server/agent/gns3_copilot/gns3_client/gns3_topology_reader.py @@ -30,6 +30,10 @@ This module provides a LangChain BaseTool to retrieve the topology of a specific GNS3 project by project ID. Returns nodes, links, and project metadata. +⚠️ WARNING: This module is shared with the MCP (Model Context Protocol) service. +GNS3TopologyTool._run() is called by MCP device config handlers. +The jwt_token/url parameters were added for MCP compatibility. +Modifications must be tested with BOTH gns3-copilot AND MCP. """ import copy 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 7bce576ee..41ed58f9d 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/config_tools_nornir.py +++ b/gns3server/agent/gns3_copilot/tools_v2/config_tools_nornir.py @@ -26,6 +26,11 @@ This module provides a tool to execute configuration commands on multiple devices in a GNS3 topology using Nornir. + +⚠️ WARNING: This module is shared with the MCP (Model Context Protocol) service. +ExecuteMultipleDeviceConfigCommands._run() is called by the MCP device_config_send handler. +The jwt_token/url parameters were added for MCP compatibility. +Modifications must be tested with BOTH gns3-copilot AND MCP. """ import json 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 7e0061ceb..8fa056650 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/display_tools_nornir.py +++ b/gns3server/agent/gns3_copilot/tools_v2/display_tools_nornir.py @@ -26,6 +26,11 @@ This module provides a tool to execute display commands on multiple devices in a GNS3 topology using Nornir. + +⚠️ WARNING: This module is shared with the MCP (Model Context Protocol) service. +ExecuteMultipleDeviceCommands._run() is called by the MCP device_command_run handler. +The jwt_token/url parameters were added for MCP compatibility. +Modifications must be tested with BOTH gns3-copilot AND MCP. """ import json 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 b7bb3d661..221bf9bed 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/vpcs_tools_netmiko.py +++ b/gns3server/agent/gns3_copilot/tools_v2/vpcs_tools_netmiko.py @@ -26,6 +26,11 @@ """ This module provides a tool to execute commands on VPCS devices in a GNS3 topology using Nornir with Netmiko. + +⚠️ WARNING: This module is shared with the MCP (Model Context Protocol) service. +VPCSCommands._run() is called by the MCP vpcs_config_set handler. +The jwt_token/url parameters were added for MCP compatibility. +Modifications must be tested with BOTH gns3-copilot AND MCP. """ import json 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 a5c950150..597fd9f64 100644 --- a/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py +++ b/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py @@ -25,6 +25,11 @@ """ Public module for getting device port information from GNS3 topology + +⚠️ WARNING: This module is shared with the MCP (Model Context Protocol) service. +get_device_ports_from_topology() is called by MCP device config handlers. +The jwt_token/url parameters were added for MCP compatibility. +Modifications must be tested with BOTH gns3-copilot AND MCP. """ import logging From 0b2c784b8197a3020498a865f1fdef5ef5d4d7ee Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 11 Jun 2026 12:13:31 +0800 Subject: [PATCH 28/41] docs: Fix appliance_install description - it does NOT download images --- gns3server/api/routes/mcp/__init__.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index 3f679a581..cff1ae774 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -1137,7 +1137,13 @@ async def appliance_get( async def appliance_install( appliance_id: Annotated[str, Field(description="UUID of the appliance to install")], ) -> list[dict[str, Any]]: - """Install (download and set up) an appliance from the template library.""" + """Create a template from a GNS3 appliance definition. + + NOTE: This does NOT download images. Images must be placed in the + GNS3 images directory (e.g. ~/GNS3/images/) beforehand. + The appliance definition is read from local .gns3a files bundled with the server. + Use get_appliance first to see what images are required. + """ return await asyncio.to_thread(_run_handler_sync, install_appliance_handler, { "appliance_id": appliance_id, }) From 8e9afbcf9288f46d61bfb0c665d7fb9c238bc6b9 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 11 Jun 2026 22:32:12 +0800 Subject: [PATCH 29/41] =?UTF-8?q?fix:=20Validate=20JWT=20token=20exp=20cla?= =?UTF-8?q?im=20=E2=80=94=20was=20silently=20ignored=20after=20migration?= =?UTF-8?q?=20to=20joserfc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit joserfc.jwt.decode() does not validate the exp claim by default, so expired tokens were accepted indefinitely. Added explicit check after decoding. See issue #2781. --- gns3server/services/authentication.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/gns3server/services/authentication.py b/gns3server/services/authentication.py index 9b9c6ffa7..42c980415 100644 --- a/gns3server/services/authentication.py +++ b/gns3server/services/authentication.py @@ -17,6 +17,7 @@ from joserfc import jwt from joserfc.jwk import OctKey from joserfc.errors import JoseError +import time from datetime import datetime, timedelta, timezone import bcrypt @@ -80,6 +81,10 @@ class AuthService: username: str = payload.claims.get("sub") if username is None: raise credentials_exception + # Validate the exp claim — joserfc does not validate time-based claims by default + token_exp: int = payload.claims.get("exp", 0) + if token_exp and time.time() > token_exp: + raise credentials_exception token_version: int = payload.claims.get("ver", 0) token_data = TokenData(username=username, token_version=token_version) except (JoseError, ValidationError, ValueError): From a79bc7dd2033edbe69a26a10b815868e20e4acde Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 11 Jun 2026 22:54:03 +0800 Subject: [PATCH 30/41] feat: Add API Key support for MCP authentication - New db model: api_keys table with bcrypt-hashed keys - New API: POST/GET/DELETE /v3/access/api-keys endpoints - MCP _resolve_token: validates API keys, resolves to 5-min JWT - API keys inherit the creating user's RBAC permissions - MCP auth supports both JWT (24h) and API key (permanent) tokens --- gns3server/api/routes/controller/__init__.py | 7 + gns3server/api/routes/controller/api_keys.py | 127 ++++++++++++++++++ gns3server/api/routes/mcp/__init__.py | 57 +++++++- gns3server/db/models/__init__.py | 1 + gns3server/db/models/api_keys.py | 33 +++++ gns3server/db/repositories/api_keys.py | 94 +++++++++++++ .../versions/f0b0de2a9_add_api_keys_table.py | 42 ++++++ gns3server/schemas/controller/tokens.py | 6 + 8 files changed, 361 insertions(+), 6 deletions(-) create mode 100644 gns3server/api/routes/controller/api_keys.py create mode 100644 gns3server/db/models/api_keys.py create mode 100644 gns3server/db/repositories/api_keys.py create mode 100644 gns3server/db_migrations/versions/f0b0de2a9_add_api_keys_table.py diff --git a/gns3server/api/routes/controller/__init__.py b/gns3server/api/routes/controller/__init__.py index f34d342c3..2891642f9 100644 --- a/gns3server/api/routes/controller/__init__.py +++ b/gns3server/api/routes/controller/__init__.py @@ -60,6 +60,7 @@ from . import roles from . import acl from . import pools from . import privileges +from . import api_keys from .dependencies.authentication import get_current_active_user @@ -192,3 +193,9 @@ router.include_router( dependencies=[Depends(get_current_active_user)], tags=["GNS3 Copilot"] ) + +router.include_router( + api_keys.router, + dependencies=[Depends(get_current_active_user)], + tags=["API Keys"] +) diff --git a/gns3server/api/routes/controller/api_keys.py b/gns3server/api/routes/controller/api_keys.py new file mode 100644 index 000000000..4a6260761 --- /dev/null +++ b/gns3server/api/routes/controller/api_keys.py @@ -0,0 +1,127 @@ +# +# Copyright (C) 2026 GNS3 Technologies Inc. +# +# 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 . + +""" +API routes for API key management. +""" + +import secrets +import bcrypt +from uuid import uuid4, UUID + +from fastapi import APIRouter, Depends, status + +from gns3server import schemas +from gns3server.schemas.controller.tokens import TokenData +from gns3server.db.repositories.api_keys import ApiKeysRepository +from gns3server.db.repositories.users import UsersRepository +from .dependencies.database import get_repository +from .dependencies.authentication import get_current_active_user + +import logging + +log = logging.getLogger(__name__) + +router = APIRouter(prefix="/access/api-keys", tags=["API Keys"]) + +API_KEY_PREFIX = "gns3_" +API_KEY_BYTES = 32 # 256-bit key, results in 64 hex chars + + +def _generate_api_key() -> tuple[str, str, str]: + """Generate a new API key. + + Returns: + Tuple of (full_key, key_hash, key_prefix) + """ + random_bytes = secrets.token_hex(API_KEY_BYTES) + raw_key = API_KEY_PREFIX + random_bytes + key_hash = bcrypt.hashpw(raw_key.encode(), bcrypt.gensalt()).decode() + key_prefix = raw_key[: len(API_KEY_PREFIX) + 8] # gns3_ + first 8 hex chars + return raw_key, key_hash, key_prefix + + +@router.post( + "", + status_code=status.HTTP_201_CREATED, +) +async def create_api_key( + api_key_data: schemas.ApiKeyCreate, + current_user: schemas.User = Depends(get_current_active_user), + api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)), +) -> dict: + """Create a new API key. The full key is returned only once.""" + + raw_key, key_hash, key_prefix = _generate_api_key() + db_key = await api_keys_repo.create_api_key( + api_key_id=uuid4(), + user_id=current_user.user_id, + name=api_key_data.name, + key_hash=key_hash, + key_prefix=key_prefix, + ) + return { + "api_key_id": str(db_key.api_key_id), + "api_key": raw_key, + "name": db_key.name, + "key_prefix": db_key.key_prefix, + "created_at": db_key.created_at.isoformat() if db_key.created_at else None, + } + + +@router.get("") +async def list_api_keys( + current_user: schemas.User = Depends(get_current_active_user), + api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)), +) -> list[dict]: + """List all API keys for the current user.""" + + keys = await api_keys_repo.get_api_keys_by_user(current_user.user_id) + return [ + { + "api_key_id": str(k.api_key_id), + "name": k.name, + "key_prefix": k.key_prefix, + "created_at": k.created_at.isoformat() if k.created_at else None, + "last_used_at": k.last_used_at.isoformat() if k.last_used_at else None, + "revoked": k.revoked, + } + for k in keys + ] + + +@router.delete( + "/{api_key_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +async def revoke_api_key( + api_key_id: UUID, + current_user: schemas.User = Depends(get_current_active_user), + api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)), +) -> None: + """Revoke an API key (soft delete — sets revoked=True).""" + + key = await api_keys_repo.get_api_key(api_key_id) + if not key: + from fastapi import HTTPException + raise HTTPException(status_code=404, detail="API key not found") + + # Only the key owner can revoke it + if key.user_id != current_user.user_id: + from fastapi import HTTPException + raise HTTPException(status_code=403, detail="Cannot revoke another user's API key") + + await api_keys_repo.revoke_api_key(api_key_id) diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index cff1ae774..01066209c 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -32,6 +32,7 @@ import asyncio import logging import socket import uuid +import bcrypt from typing import Any, Annotated from urllib.parse import parse_qs @@ -43,9 +44,16 @@ from pydantic import Field from mcp.server.fastmcp import FastMCP from mcp.server.transport_security import TransportSecuritySettings +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + from gns3server.config import Config +from gns3server.services.authentication import AuthService +import gns3server.db.models as models from gns3server.services import auth_service from gns3server.utils.request_utils import extract_client_info +from gns3server.db.repositories.api_keys import ApiKeysRepository +from gns3server.db.repositories.users import UsersRepository from .projects import ( list_projects_handler, get_project_handler, create_project_handler, delete_project_handler, open_project_handler, close_project_handler, @@ -111,6 +119,9 @@ from .drawings import ( log = logging.getLogger(__name__) +# Database engine reference — set during register_starlette_routes +_db_engine = None + # ── Server ready state ──────────────────────────────────────────────── # Tracks whether GNS3 server has completed initialization. @@ -176,13 +187,40 @@ _jwt_token_var: contextvars.ContextVar[str | None] = contextvars.ContextVar( # ── Token validation ────────────────────────────────────────────────── -async def _validate_token(token: str) -> bool: - """Return True if token is a valid GNS3 JWT.""" +async def _resolve_token(token: str) -> str | None: + """Validate a token (JWT or API key) and return the effective JWT to use. + + For JWT tokens, returns the token as-is. + For API keys, validates against the database and returns a fresh short-lived JWT. + + Returns None if the token is invalid. + """ + # Try JWT first try: auth_service.get_username_from_token(token) - return True + return token except Exception: - return False + pass + + # Try API key + if token.startswith("gns3_") and _db_engine is not None: + try: + async with AsyncSession(_db_engine, expire_on_commit=False) as db_session: + repo = ApiKeysRepository(db_session) + query = select(models.ApiKey).where(models.ApiKey.revoked == False) + result = await db_session.execute(query) + for db_key in result.scalars().all(): + if bcrypt.checkpw(token.encode(), db_key.key_hash.encode()): + await repo.update_last_used(db_key.api_key_id) + user_repo = UsersRepository(db_session) + user = await user_repo.get_user(db_key.user_id) + if user: + svc = AuthService() + return svc.create_access_token(user.username, expires_in=5) + except Exception: + pass + + return None # ── Server URL helper ───────────────────────────────────────────────── @@ -1305,11 +1343,16 @@ def _make_auth_wrapper(inner_app): tokens = params.get("token", []) if tokens: token = tokens[0] - if not token or not await _validate_token(token): + if not token: response = Response("Missing or invalid token", status_code=401) await response(scope, receive, send) return - _jwt_token_var.set(token) + resolved = await _resolve_token(token) + if not resolved: + response = Response("Missing or invalid token", status_code=401) + await response(scope, receive, send) + return + _jwt_token_var.set(resolved) await inner_app(scope, receive, send) return auth_wrapper @@ -1335,6 +1378,8 @@ async def mcp_root(): def register_starlette_routes(app): """Mount MCP transports on the FastAPI app.""" + global _db_engine + _db_engine = getattr(app.state, "_db_engine", None) sse_app = _make_auth_wrapper(mcp.sse_app(mount_path="")) app.mount("/v3/mcp/transport", sse_app, name="mcp-sse") log.info("MCP SSE server mounted at /v3/mcp/transport") diff --git a/gns3server/db/models/__init__.py b/gns3server/db/models/__init__.py index 5e7afb07b..cd538b80d 100644 --- a/gns3server/db/models/__init__.py +++ b/gns3server/db/models/__init__.py @@ -24,6 +24,7 @@ from .computes import Compute from .images import Image from .pools import Resource, ResourcePool from .llm_model_configs import LLMModelConfig +from .api_keys import ApiKey from .templates import ( Template, CloudTemplate, diff --git a/gns3server/db/models/api_keys.py b/gns3server/db/models/api_keys.py new file mode 100644 index 000000000..b43ecf5f2 --- /dev/null +++ b/gns3server/db/models/api_keys.py @@ -0,0 +1,33 @@ +# +# Copyright (C) 2026 GNS3 Technologies Inc. +# +# 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 . + +from sqlalchemy import Column, String, Boolean, DateTime, ForeignKey, func + +from .base import BaseTable, GUID + + +class ApiKey(BaseTable): + + __tablename__ = "api_keys" + + api_key_id = Column(GUID, primary_key=True) + user_id = Column(GUID, ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False, index=True) + name = Column(String(128), nullable=False) + key_hash = Column(String(128), nullable=False) + key_prefix = Column(String(8), nullable=False) + last_used_at = Column(DateTime, nullable=True) + created_at = Column(DateTime, server_default=func.current_timestamp(), nullable=False) + revoked = Column(Boolean, default=False, nullable=False) diff --git a/gns3server/db/repositories/api_keys.py b/gns3server/db/repositories/api_keys.py new file mode 100644 index 000000000..8b635565a --- /dev/null +++ b/gns3server/db/repositories/api_keys.py @@ -0,0 +1,94 @@ +# +# Copyright (C) 2026 GNS3 Technologies Inc. +# +# 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 . + +from uuid import UUID +from typing import Optional, List +from datetime import datetime, timezone +from sqlalchemy import select, update, delete, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from .base import BaseRepository +import gns3server.db.models as models + +import logging + +log = logging.getLogger(__name__) + + +class ApiKeysRepository(BaseRepository): + + def __init__(self, db_session: AsyncSession) -> None: + super().__init__(db_session) + + async def create_api_key( + self, api_key_id: UUID, user_id: UUID, name: str, key_hash: str, key_prefix: str + ) -> models.ApiKey: + db_api_key = models.ApiKey( + api_key_id=api_key_id, + user_id=user_id, + name=name, + key_hash=key_hash, + key_prefix=key_prefix, + ) + self._db_session.add(db_api_key) + await self._db_session.commit() + await self._db_session.refresh(db_api_key) + return db_api_key + + async def get_api_key(self, api_key_id: UUID) -> Optional[models.ApiKey]: + query = select(models.ApiKey).where(models.ApiKey.api_key_id == api_key_id) + result = await self._db_session.execute(query) + return result.scalars().first() + + async def get_api_keys_by_user(self, user_id: UUID) -> List[models.ApiKey]: + query = ( + select(models.ApiKey) + .where(models.ApiKey.user_id == user_id) + .order_by(models.ApiKey.created_at.desc()) + ) + result = await self._db_session.execute(query) + return list(result.scalars().all()) + + async def get_api_key_by_hash(self, key_hash: str) -> Optional[models.ApiKey]: + query = select(models.ApiKey).where(models.ApiKey.key_hash == key_hash) + result = await self._db_session.execute(query) + return result.scalars().first() + + async def update_last_used(self, api_key_id: UUID) -> None: + query = ( + update(models.ApiKey) + .where(models.ApiKey.api_key_id == api_key_id) + .values(last_used_at=func.now()) + ) + await self._db_session.execute(query) + await self._db_session.commit() + + async def revoke_api_key(self, api_key_id: UUID) -> bool: + query = ( + update(models.ApiKey) + .where(models.ApiKey.api_key_id == api_key_id) + .values(revoked=True) + ) + result = await self._db_session.execute(query) + await self._db_session.commit() + return result.rowcount > 0 + + async def delete_api_key(self, api_key_id: UUID) -> bool: + query = delete(models.ApiKey).where(models.ApiKey.api_key_id == api_key_id) + result = await self._db_session.execute(query) + await self._db_session.commit() + return result.rowcount > 0 diff --git a/gns3server/db_migrations/versions/f0b0de2a9_add_api_keys_table.py b/gns3server/db_migrations/versions/f0b0de2a9_add_api_keys_table.py new file mode 100644 index 000000000..5c438678e --- /dev/null +++ b/gns3server/db_migrations/versions/f0b0de2a9_add_api_keys_table.py @@ -0,0 +1,42 @@ +"""add api_keys table + +Revision ID: f0b0de2a9 +Revises: a8829e6c069b +Create Date: 2026-06-11 10:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +import gns3server.db.models.base as models + +# revision identifiers, used by Alembic. +revision = 'f0b0de2a9' +down_revision = 'a8829e6c069b' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + + op.create_table( + 'api_keys', + sa.Column('api_key_id', models.GUID(), nullable=False), + sa.Column('user_id', models.GUID(), nullable=False), + sa.Column('name', sa.String(128), nullable=False), + sa.Column('key_hash', sa.String(128), nullable=False), + sa.Column('key_prefix', sa.String(8), nullable=False), + sa.Column('last_used_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False), + sa.Column('revoked', sa.Boolean(), default=False, nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.user_id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('api_key_id'), + ) + op.create_index('ix_api_keys_user_id', 'api_keys', ['user_id']) + op.create_index('ix_api_keys_key_hash', 'api_keys', ['key_hash']) + + +def downgrade() -> None: + + op.drop_index('ix_api_keys_key_hash', table_name='api_keys') + op.drop_index('ix_api_keys_user_id', table_name='api_keys') + op.drop_table('api_keys') diff --git a/gns3server/schemas/controller/tokens.py b/gns3server/schemas/controller/tokens.py index 86c1a9377..e36a1d35c 100644 --- a/gns3server/schemas/controller/tokens.py +++ b/gns3server/schemas/controller/tokens.py @@ -28,3 +28,9 @@ class TokenData(BaseModel): username: Optional[str] = None token_version: int = 0 + + +class ApiKeyCreate(BaseModel): + """Schema for creating a new API key.""" + + name: str From f8340cb35531b386e49798d79bb49b7c6fdd024b Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 11 Jun 2026 22:56:44 +0800 Subject: [PATCH 31/41] fix: Export ApiKeyCreate from schemas package --- gns3server/schemas/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gns3server/schemas/__init__.py b/gns3server/schemas/__init__.py index 0c58eee28..59f9b6cef 100644 --- a/gns3server/schemas/__init__.py +++ b/gns3server/schemas/__init__.py @@ -57,7 +57,7 @@ except ImportError: from .controller.rbac import RoleCreate, RoleUpdate, Role, Privilege, ACECreate, ACEUpdate, ACE from .controller.pools import Resource, ResourceCreate, ResourcePoolCreate, ResourcePoolUpdate, ResourcePool -from .controller.tokens import Token +from .controller.tokens import Token, ApiKeyCreate from .controller.snapshots import SnapshotCreate, Snapshot from .controller.iou_license import IOULicense from .controller.capabilities import Capabilities From d75746e029c3f618fe87a8229c5351407323d35d Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 11 Jun 2026 23:13:50 +0800 Subject: [PATCH 32/41] fix: Add missing updated_at column to api_keys table BaseTable base class includes updated_at, but the initial migration didn't create the column. Added fixup migration. --- .../versions/f0b0de2a9_add_api_keys_table.py | 1 + .../f0b0de2a9b_add_updated_at_to_api_keys.py | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 gns3server/db_migrations/versions/f0b0de2a9b_add_updated_at_to_api_keys.py diff --git a/gns3server/db_migrations/versions/f0b0de2a9_add_api_keys_table.py b/gns3server/db_migrations/versions/f0b0de2a9_add_api_keys_table.py index 5c438678e..9c8b4fdb6 100644 --- a/gns3server/db_migrations/versions/f0b0de2a9_add_api_keys_table.py +++ b/gns3server/db_migrations/versions/f0b0de2a9_add_api_keys_table.py @@ -27,6 +27,7 @@ def upgrade() -> None: sa.Column('key_prefix', sa.String(8), nullable=False), sa.Column('last_used_at', sa.DateTime(), nullable=True), sa.Column('created_at', sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False), sa.Column('revoked', sa.Boolean(), default=False, nullable=False), sa.ForeignKeyConstraint(['user_id'], ['users.user_id'], ondelete='CASCADE'), sa.PrimaryKeyConstraint('api_key_id'), diff --git a/gns3server/db_migrations/versions/f0b0de2a9b_add_updated_at_to_api_keys.py b/gns3server/db_migrations/versions/f0b0de2a9b_add_updated_at_to_api_keys.py new file mode 100644 index 000000000..159ac910d --- /dev/null +++ b/gns3server/db_migrations/versions/f0b0de2a9b_add_updated_at_to_api_keys.py @@ -0,0 +1,23 @@ +"""add updated_at column to api_keys table + +Revision ID: f0b0de2a9b +Revises: f0b0de2a9 +Create Date: 2026-06-11 23:15:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = 'f0b0de2a9b' +down_revision = 'f0b0de2a9' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column('api_keys', sa.Column('updated_at', sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False)) + + +def downgrade() -> None: + op.drop_column('api_keys', 'updated_at') From aed883005269ad2ea0736f90d7e5223bb0ed7e3e Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 11 Jun 2026 23:18:05 +0800 Subject: [PATCH 33/41] fix: Lazily access db engine for API key validation _db_engine is not available when register_starlette_routes() is called (it's set later during lifespan startup). Store the app reference instead and access app.state._db_engine lazily. --- gns3server/api/routes/mcp/__init__.py | 44 +++++++++++++++------------ 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index 01066209c..b52ce1cd0 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -119,8 +119,10 @@ from .drawings import ( log = logging.getLogger(__name__) -# Database engine reference — set during register_starlette_routes -_db_engine = None +# FastAPI app reference — used to lazily access app.state._db_engine for API key validation. +# The db engine is initialized during the lifespan startup, which runs AFTER +# register_starlette_routes() is called, so we cannot capture it at registration time. +_app = None # ── Server ready state ──────────────────────────────────────────────── @@ -203,22 +205,24 @@ async def _resolve_token(token: str) -> str | None: pass # Try API key - if token.startswith("gns3_") and _db_engine is not None: - try: - async with AsyncSession(_db_engine, expire_on_commit=False) as db_session: - repo = ApiKeysRepository(db_session) - query = select(models.ApiKey).where(models.ApiKey.revoked == False) - result = await db_session.execute(query) - for db_key in result.scalars().all(): - if bcrypt.checkpw(token.encode(), db_key.key_hash.encode()): - await repo.update_last_used(db_key.api_key_id) - user_repo = UsersRepository(db_session) - user = await user_repo.get_user(db_key.user_id) - if user: - svc = AuthService() - return svc.create_access_token(user.username, expires_in=5) - except Exception: - pass + if token.startswith("gns3_") and _app is not None: + db_engine = getattr(_app.state, "_db_engine", None) + if db_engine is not None: + try: + async with AsyncSession(db_engine, expire_on_commit=False) as db_session: + repo = ApiKeysRepository(db_session) + query = select(models.ApiKey).where(models.ApiKey.revoked == False) + result = await db_session.execute(query) + for db_key in result.scalars().all(): + if bcrypt.checkpw(token.encode(), db_key.key_hash.encode()): + await repo.update_last_used(db_key.api_key_id) + user_repo = UsersRepository(db_session) + user = await user_repo.get_user(db_key.user_id) + if user: + svc = AuthService() + return svc.create_access_token(user.username, expires_in=5) + except Exception: + pass return None @@ -1378,8 +1382,8 @@ async def mcp_root(): def register_starlette_routes(app): """Mount MCP transports on the FastAPI app.""" - global _db_engine - _db_engine = getattr(app.state, "_db_engine", None) + global _app + _app = app sse_app = _make_auth_wrapper(mcp.sse_app(mount_path="")) app.mount("/v3/mcp/transport", sse_app, name="mcp-sse") log.info("MCP SSE server mounted at /v3/mcp/transport") From bfef0a36e3710e161af1ed320bb62fbef774d5b8 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 11 Jun 2026 23:21:49 +0800 Subject: [PATCH 34/41] feat: Support API keys in REST API authentication (reuse gns3_ prefix keys) API keys can now be used in Authorization: Bearer header for all REST API endpoints, not just MCP. --- .../controller/dependencies/authentication.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/gns3server/api/routes/controller/dependencies/authentication.py b/gns3server/api/routes/controller/dependencies/authentication.py index 3e06345d1..d8b947a9b 100644 --- a/gns3server/api/routes/controller/dependencies/authentication.py +++ b/gns3server/api/routes/controller/dependencies/authentication.py @@ -15,12 +15,16 @@ # along with this program. If not, see . import logging +import bcrypt from fastapi import Request, Query, Depends, HTTPException, WebSocket, status from fastapi.security import OAuth2PasswordBearer from typing import Optional +from sqlalchemy import select from gns3server import schemas +import gns3server.db.models as models +from gns3server.db.repositories.api_keys import ApiKeysRepository from gns3server.db.repositories.users import UsersRepository from gns3server.db.repositories.rbac import RbacRepository from gns3server.services import auth_service @@ -33,6 +37,7 @@ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/v3/access/users/login", auto_err async def get_user_from_token( bearer_token: str = Depends(oauth2_scheme), user_repo: UsersRepository = Depends(get_repository(UsersRepository)), + api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)), token: Optional[str] = Query(None, include_in_schema=False) ) -> schemas.User: @@ -47,6 +52,29 @@ async def get_user_from_token( headers={"WWW-Authenticate": "Bearer"}, ) + # API Key authentication + if token.startswith("gns3_"): + query = select(models.ApiKey).where(models.ApiKey.revoked == False) + result = await api_keys_repo._db_session.execute(query) + for db_key in result.scalars().all(): + if bcrypt.checkpw(token.encode(), db_key.key_hash.encode()): + await api_keys_repo.update_last_used(db_key.api_key_id) + user = await user_repo.get_user(db_key.user_id) + if user: + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not an active user", + headers={"WWW-Authenticate": "Bearer"}, + ) + return user + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid API key", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # JWT authentication token_data = auth_service.get_token_data(token) user = await user_repo.get_user_by_username(token_data.username) if user is None: From 9d441517fd0c70ff8e8bc4a2a22f4e05a17b6cc2 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 11 Jun 2026 23:27:43 +0800 Subject: [PATCH 35/41] fix: Hard-delete API keys instead of soft delete (revoked flag) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removing the soft-delete approach — revoked keys are now deleted from the database entirely via DELETE endpoint. This prevents the api_keys table from accumulating stale records. --- gns3server/api/routes/controller/api_keys.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gns3server/api/routes/controller/api_keys.py b/gns3server/api/routes/controller/api_keys.py index 4a6260761..b63091c87 100644 --- a/gns3server/api/routes/controller/api_keys.py +++ b/gns3server/api/routes/controller/api_keys.py @@ -119,9 +119,9 @@ async def revoke_api_key( from fastapi import HTTPException raise HTTPException(status_code=404, detail="API key not found") - # Only the key owner can revoke it + # Only the key owner can delete it if key.user_id != current_user.user_id: from fastapi import HTTPException - raise HTTPException(status_code=403, detail="Cannot revoke another user's API key") + raise HTTPException(status_code=403, detail="Cannot delete another user's API key") - await api_keys_repo.revoke_api_key(api_key_id) + await api_keys_repo.delete_api_key(api_key_id) From 700d71d675b7eafe2942426d8af2be4805d9cca5 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 11 Jun 2026 23:28:05 +0800 Subject: [PATCH 36/41] =?UTF-8?q?fix:=20Rename=20revoke=5Fapi=5Fkey=20?= =?UTF-8?q?=E2=86=92=20delete=5Fapi=5Fkey?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gns3server/api/routes/controller/api_keys.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gns3server/api/routes/controller/api_keys.py b/gns3server/api/routes/controller/api_keys.py index b63091c87..3785a316f 100644 --- a/gns3server/api/routes/controller/api_keys.py +++ b/gns3server/api/routes/controller/api_keys.py @@ -107,7 +107,7 @@ async def list_api_keys( "/{api_key_id}", status_code=status.HTTP_204_NO_CONTENT, ) -async def revoke_api_key( +async def delete_api_key( api_key_id: UUID, current_user: schemas.User = Depends(get_current_active_user), api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)), From 062e8dfbc46b2ca40a3542930d7079387b1b19b1 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 11 Jun 2026 23:28:43 +0800 Subject: [PATCH 37/41] chore: Remove unused revoke_api_key from repository --- gns3server/db/repositories/api_keys.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/gns3server/db/repositories/api_keys.py b/gns3server/db/repositories/api_keys.py index 8b635565a..28da531ff 100644 --- a/gns3server/db/repositories/api_keys.py +++ b/gns3server/db/repositories/api_keys.py @@ -77,16 +77,6 @@ class ApiKeysRepository(BaseRepository): await self._db_session.execute(query) await self._db_session.commit() - async def revoke_api_key(self, api_key_id: UUID) -> bool: - query = ( - update(models.ApiKey) - .where(models.ApiKey.api_key_id == api_key_id) - .values(revoked=True) - ) - result = await self._db_session.execute(query) - await self._db_session.commit() - return result.rowcount > 0 - async def delete_api_key(self, api_key_id: UUID) -> bool: query = delete(models.ApiKey).where(models.ApiKey.api_key_id == api_key_id) result = await self._db_session.execute(query) From 8ecc35193de143483572593eea83e87169b88b7b Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 11 Jun 2026 23:32:19 +0800 Subject: [PATCH 38/41] =?UTF-8?q?feat:=20API=20key=20lifecycle=20=E2=80=94?= =?UTF-8?q?=20revoke/restore/delete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /{id}/revoke: 吊销, revoked=True, 立即失效 - POST /{id}/restore: 恢复, revoked=False, 重新生效 - DELETE /{id}: 永久删除, 不可逆 - 列表接口显示所有 key 包括已吊销的 - 认证时过滤 revoked=False --- gns3server/api/routes/controller/api_keys.py | 65 +++++++++++++------- gns3server/db/repositories/api_keys.py | 20 ++++++ 2 files changed, 62 insertions(+), 23 deletions(-) diff --git a/gns3server/api/routes/controller/api_keys.py b/gns3server/api/routes/controller/api_keys.py index 3785a316f..4d6e456d6 100644 --- a/gns3server/api/routes/controller/api_keys.py +++ b/gns3server/api/routes/controller/api_keys.py @@ -22,12 +22,10 @@ import secrets import bcrypt from uuid import uuid4, UUID -from fastapi import APIRouter, Depends, status +from fastapi import APIRouter, Depends, status, HTTPException from gns3server import schemas -from gns3server.schemas.controller.tokens import TokenData from gns3server.db.repositories.api_keys import ApiKeysRepository -from gns3server.db.repositories.users import UsersRepository from .dependencies.database import get_repository from .dependencies.authentication import get_current_active_user @@ -38,26 +36,18 @@ log = logging.getLogger(__name__) router = APIRouter(prefix="/access/api-keys", tags=["API Keys"]) API_KEY_PREFIX = "gns3_" -API_KEY_BYTES = 32 # 256-bit key, results in 64 hex chars +API_KEY_BYTES = 32 def _generate_api_key() -> tuple[str, str, str]: - """Generate a new API key. - - Returns: - Tuple of (full_key, key_hash, key_prefix) - """ random_bytes = secrets.token_hex(API_KEY_BYTES) raw_key = API_KEY_PREFIX + random_bytes key_hash = bcrypt.hashpw(raw_key.encode(), bcrypt.gensalt()).decode() - key_prefix = raw_key[: len(API_KEY_PREFIX) + 8] # gns3_ + first 8 hex chars + key_prefix = raw_key[: len(API_KEY_PREFIX) + 8] return raw_key, key_hash, key_prefix -@router.post( - "", - status_code=status.HTTP_201_CREATED, -) +@router.post("", status_code=status.HTTP_201_CREATED) async def create_api_key( api_key_data: schemas.ApiKeyCreate, current_user: schemas.User = Depends(get_current_active_user), @@ -103,25 +93,54 @@ async def list_api_keys( ] -@router.delete( - "/{api_key_id}", - status_code=status.HTTP_204_NO_CONTENT, -) +@router.post("/{api_key_id}/revoke", status_code=status.HTTP_200_OK) +async def revoke_api_key( + api_key_id: UUID, + current_user: schemas.User = Depends(get_current_active_user), + api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)), +) -> dict: + """Revoke an API key. It will immediately stop working, but can be restored.""" + + key = await api_keys_repo.get_api_key(api_key_id) + if not key: + raise HTTPException(status_code=404, detail="API key not found") + if key.user_id != current_user.user_id: + raise HTTPException(status_code=403, detail="Cannot modify another user's API key") + + await api_keys_repo.revoke_api_key(api_key_id) + return {"message": f"API key '{key.name}' revoked"} + + +@router.post("/{api_key_id}/restore", status_code=status.HTTP_200_OK) +async def restore_api_key( + api_key_id: UUID, + current_user: schemas.User = Depends(get_current_active_user), + api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)), +) -> dict: + """Restore a previously revoked API key.""" + + key = await api_keys_repo.get_api_key(api_key_id) + if not key: + raise HTTPException(status_code=404, detail="API key not found") + if key.user_id != current_user.user_id: + raise HTTPException(status_code=403, detail="Cannot modify another user's API key") + + await api_keys_repo.restore_api_key(api_key_id) + return {"message": f"API key '{key.name}' restored"} + + +@router.delete("/{api_key_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_api_key( api_key_id: UUID, current_user: schemas.User = Depends(get_current_active_user), api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)), ) -> None: - """Revoke an API key (soft delete — sets revoked=True).""" + """Permanently delete an API key. Cannot be undone.""" key = await api_keys_repo.get_api_key(api_key_id) if not key: - from fastapi import HTTPException raise HTTPException(status_code=404, detail="API key not found") - - # Only the key owner can delete it if key.user_id != current_user.user_id: - from fastapi import HTTPException raise HTTPException(status_code=403, detail="Cannot delete another user's API key") await api_keys_repo.delete_api_key(api_key_id) diff --git a/gns3server/db/repositories/api_keys.py b/gns3server/db/repositories/api_keys.py index 28da531ff..ae56dd37e 100644 --- a/gns3server/db/repositories/api_keys.py +++ b/gns3server/db/repositories/api_keys.py @@ -68,6 +68,26 @@ class ApiKeysRepository(BaseRepository): result = await self._db_session.execute(query) return result.scalars().first() + async def revoke_api_key(self, api_key_id: UUID) -> bool: + query = ( + update(models.ApiKey) + .where(models.ApiKey.api_key_id == api_key_id) + .values(revoked=True) + ) + result = await self._db_session.execute(query) + await self._db_session.commit() + return result.rowcount > 0 + + async def restore_api_key(self, api_key_id: UUID) -> bool: + query = ( + update(models.ApiKey) + .where(models.ApiKey.api_key_id == api_key_id) + .values(revoked=False) + ) + result = await self._db_session.execute(query) + await self._db_session.commit() + return result.rowcount > 0 + async def update_last_used(self, api_key_id: UUID) -> None: query = ( update(models.ApiKey) From a98707e91de8ccaa880c68a27a3184ff3046d384 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Thu, 11 Jun 2026 23:51:07 +0800 Subject: [PATCH 39/41] chore: Remove redundant migration fixup f0b0de2a9_add_api_keys_table already includes updated_at column, so the fixup migration f0b0de2a9b is unnecessary. --- .../f0b0de2a9b_add_updated_at_to_api_keys.py | 23 ------------------- 1 file changed, 23 deletions(-) delete mode 100644 gns3server/db_migrations/versions/f0b0de2a9b_add_updated_at_to_api_keys.py diff --git a/gns3server/db_migrations/versions/f0b0de2a9b_add_updated_at_to_api_keys.py b/gns3server/db_migrations/versions/f0b0de2a9b_add_updated_at_to_api_keys.py deleted file mode 100644 index 159ac910d..000000000 --- a/gns3server/db_migrations/versions/f0b0de2a9b_add_updated_at_to_api_keys.py +++ /dev/null @@ -1,23 +0,0 @@ -"""add updated_at column to api_keys table - -Revision ID: f0b0de2a9b -Revises: f0b0de2a9 -Create Date: 2026-06-11 23:15:00.000000 - -""" -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = 'f0b0de2a9b' -down_revision = 'f0b0de2a9' -branch_labels = None -depends_on = None - - -def upgrade() -> None: - op.add_column('api_keys', sa.Column('updated_at', sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False)) - - -def downgrade() -> None: - op.drop_column('api_keys', 'updated_at') From f2e5d69a2cfa216b58e3ab7858c89776b0fdeebf Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Fri, 12 Jun 2026 00:24:48 +0800 Subject: [PATCH 40/41] fix: Pass API key directly instead of generating short-lived JWT REST API auth already supports gns3_ keys, so there's no need to create a temporary 5-min JWT. The raw API key is passed through as the Bearer token, eliminating token expiry issues. --- gns3server/api/routes/mcp/__init__.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index b52ce1cd0..9cf569044 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -53,7 +53,6 @@ import gns3server.db.models as models from gns3server.services import auth_service from gns3server.utils.request_utils import extract_client_info from gns3server.db.repositories.api_keys import ApiKeysRepository -from gns3server.db.repositories.users import UsersRepository from .projects import ( list_projects_handler, get_project_handler, create_project_handler, delete_project_handler, open_project_handler, close_project_handler, @@ -204,7 +203,7 @@ async def _resolve_token(token: str) -> str | None: except Exception: pass - # Try API key + # Try API key — pass through directly; REST API auth already supports gns3_ keys if token.startswith("gns3_") and _app is not None: db_engine = getattr(_app.state, "_db_engine", None) if db_engine is not None: @@ -216,11 +215,9 @@ async def _resolve_token(token: str) -> str | None: for db_key in result.scalars().all(): if bcrypt.checkpw(token.encode(), db_key.key_hash.encode()): await repo.update_last_used(db_key.api_key_id) - user_repo = UsersRepository(db_session) - user = await user_repo.get_user(db_key.user_id) - if user: - svc = AuthService() - return svc.create_access_token(user.username, expires_in=5) + # Return the raw API key — it will be passed as Bearer token + # and validated by the REST API auth layer + return token except Exception: pass From a69485befa43e2faa6406e68b399a6efe014618d Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Fri, 12 Jun 2026 00:26:56 +0800 Subject: [PATCH 41/41] docs: Update MCP service documentation with 82 tools and API Key setup --- docs/features/mcp-service.md | 230 ++++++++++++++++++++++++++--------- 1 file changed, 173 insertions(+), 57 deletions(-) diff --git a/docs/features/mcp-service.md b/docs/features/mcp-service.md index 524750fa9..61784f8ea 100644 --- a/docs/features/mcp-service.md +++ b/docs/features/mcp-service.md @@ -16,19 +16,19 @@ The MCP service exposes GNS3 project management operations as MCP tools that can ## Authentication -The SSE endpoint requires a valid GNS3 JWT token. It supports two ways to pass the token: +The SSE endpoint supports two types of credentials, passed the same way. -1. **Authorization header** (recommended for Claude Code): +1. **Authorization header** (recommended): ``` - Authorization: Bearer + Authorization: Bearer ``` -2. **Query parameter** (required for Claude Desktop, since EventSource does not support custom headers): +2. **Query parameter** (for clients that don't support custom headers): ``` - GET /v3/mcp/transport/sse?token= + GET /v3/mcp/transport/sse?token= ``` -### Getting a Token +### Option 1: JWT Token (24h expiry) ```bash curl -X POST http://localhost:3080/v3/access/users/authenticate \ @@ -36,85 +36,201 @@ curl -X POST http://localhost:3080/v3/access/users/authenticate \ -d '{"username": "admin", "password": "admin"}' ``` -### Token Expiry - -Default JWT token lifetime is **1440 minutes (24 hours)**. This can be configured in `gns3_server.conf`: - +Default lifetime is **1440 minutes (24 hours)**. Configurable in `gns3_server.conf`: ```ini jwt_access_token_expire_minutes = 1440 ; 24 hours ``` +### Option 2: API Key (permanent, revocable) — Recommended for MCP + +API keys never expire and can be revoked individually. Create one via the REST API: + +```bash +# Create an API key (requires a JWT to authenticate) +curl -X POST http://localhost:3080/v3/access/api-keys \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"name": "MCP Production"}' +# Response: {"api_key": "gns3_a1b2c3d4...", "api_key_id": "...", ...} +# ⚠️ The key is only shown once — save it immediately. +``` + +API key management endpoints: + +| Endpoint | Description | +|----------|-------------| +| `POST /v3/access/api-keys` | Create a new key (returns plaintext once) | +| `GET /v3/access/api-keys` | List all your keys | +| `POST /v3/access/api-keys/{id}/revoke` | Revoke a key (can be restored) | +| `POST /v3/access/api-keys/{id}/restore` | Restore a revoked key | +| `DELETE /v3/access/api-keys/{id}` | Permanently delete a key | + +Both JWT tokens and API keys work for MCP and REST API endpoints interchangeably. + ## Available Tools -**30 tools** across 5 categories: +**82 tools** across 12 categories: -### Project (7) +### Project (15) -| Tool | Description | Required Parameters | -|------|-------------|-------------------| -| `list_projects` | List all projects | none | -| `get_project` | Get project details | `project_id` | -| `create_project` | Create a project | `name` | -| `delete_project` | Delete a project | `project_id` | -| `open_project` | Open a project | `project_id` | -| `close_project` | Close a project | `project_id` | -| `get_project_stats` | Get project statistics | `project_id` | +| Tool | Description | +|------|-------------| +| `project_list` | List all projects | +| `project_get` | Get project details | +| `project_create` | Create a project | +| `project_delete` | Delete a project | +| `project_open` | Open a closed project | +| `project_close` | Close an open project | +| `project_stats` | Get project statistics | +| `project_update` | Update project properties | +| `project_duplicate` | Duplicate a project | +| `project_readme_get` | Get project README content | +| `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 (10) +### Node (22) -| Tool | Description | Required Parameters | -|------|-------------|-------------------| -| `get_nodes` | List all nodes in a project | `project_id` | -| `get_node` | Get node details | `project_id`, `node_id` | -| `start_node` | Start a node | `project_id`, `node_id` | -| `stop_node` | Stop a node | `project_id`, `node_id` | -| `reload_node` | Reload a node | `project_id`, `node_id` | -| `suspend_node` | Suspend a node | `project_id`, `node_id` | -| `create_node` | Create a node from template | `project_id`, `template_id` | -| `delete_node` | Delete a node | `project_id`, `node_id` | -| `update_node` | Update node properties | `project_id`, `node_id` | -| `get_node_console_info` | Get WebSocket console URL | `project_id`, `node_id` | +| Tool | Description | +|------|-------------| +| `node_list` | List all nodes in a project | +| `node_get` | Get node details | +| `node_create` | Create a node from template | +| `node_delete` | Delete a node | +| `node_update` | Update node properties | +| `node_start` | Start a node | +| `node_stop` | Stop a node | +| `node_reload` | Reload a node | +| `node_suspend` | Suspend a node | +| `node_console` | Get WebSocket console URL | +| `node_file_list` | List files in node directory | +| `node_file_get` | Read a file (with offset/limit) | +| `node_file_write` | Write a file | +| `node_file_delete` | Delete a file | +| `node_start_all` | Start all nodes | +| `node_stop_all` | Stop all nodes | +| `node_suspend_all` | Suspend all nodes | +| `node_reload_all` | Reload all nodes | +| `node_duplicate` | Duplicate a node | +| `node_isolate` | Isolate a node (suspend links) | +| `node_unisolate` | Un-isolate a node (resume links) | +| `node_links` | List links connected to a node | -### Link (5) +### Link (9) -| Tool | Description | Required Parameters | -|------|-------------|-------------------| -| `get_links` | List all links in a project | `project_id` | -| `get_link` | Get link details | `project_id`, `link_id` | -| `create_link` | Create a link between nodes | `project_id`, `nodes` | -| `delete_link` | Delete a link | `project_id`, `link_id` | -| `update_link` | Update link properties | `project_id`, `link_id` | +| Tool | Description | +|------|-------------| +| `link_list` | List all links in a project | +| `link_get` | Get link details | +| `link_create` | Create a link between nodes | +| `link_delete` | Delete a link | +| `link_update` | Update link (suspend, filters) | +| `link_reset` | Reset link (delete + recreate) | +| `link_capture_start` | Start packet capture | +| `link_capture_stop` | Stop packet capture | +| `link_capture_download` | Get PCAP download URL | ### Template (5) -| Tool | Description | Required Parameters | -|------|-------------|-------------------| -| `list_templates` | List all templates | none | -| `get_template` | Get template details | `template_id` or `name` | -| `create_template` | Create a template | `name`, `template_type` | -| `update_template` | Update a template | `template_id` or `name` | -| `delete_template` | Delete a template | `template_id` or `name` | +| Tool | Description | +|------|-------------| +| `template_list` | List all templates | +| `template_get` | Get template details | +| `template_create` | Create a template (Docker needs `image`) | +| `template_update` | Update a template | +| `template_delete` | Delete a template | ### Compute (3) -| Tool | Description | Required Parameters | -|------|-------------|-------------------| -| `list_computes` | List all compute nodes | none | -| `get_compute` | Get compute details | `compute_id` | -| `get_compute_images` | List available images | `emulator` | +| Tool | Description | +|------|-------------| +| `compute_list` | List registered remote computes | +| `compute_get` | Get compute details (requires UUID) | +| `compute_images` | List emulator images on a compute | + +### Snapshot (4) + +| Tool | Description | +|------|-------------| +| `snapshot_list` | List snapshots | +| `snapshot_create` | Create a snapshot | +| `snapshot_delete` | Delete a snapshot | +| `snapshot_restore` | Restore a snapshot | + +### Drawing (5) + +| Tool | Description | +|------|-------------| +| `drawing_list` | List drawings on canvas | +| `drawing_get` | Get drawing details | +| `drawing_create` | Create drawing (SVG label/shape/image) | +| `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) + +| Tool | Description | +|------|-------------| +| `appliance_list` | List appliances from template library | +| `appliance_get` | Get appliance details | +| `appliance_install` | Create template from appliance (images must exist locally) | + +### Image (5) + +| Tool | Description | +|------|-------------| +| `image_list` | List all images | +| `image_get` | Get image details | +| `image_delete` | Delete an image | +| `image_prune` | Remove images not referenced by any template | +| `image_install` | Auto-create templates from uploaded images by checksum | + +### Server (2) + +| Tool | Description | +|------|-------------| +| `server_version` | Get GNS3 server version | +| `server_statistics` | Get server statistics (computes, projects, nodes) | + +### Device Config (3) + +| Tool | Description | +|------|-------------| +| `device_config_send` | Push config commands to devices via console (Nornir + Netmiko) | +| `device_command_run` | Run read-only show commands on devices | +| `vpcs_config_set` | Configure VPCS devices (IP, gateway, etc.) | + +Requires nodes to be started first. Device type is auto-detected from the node's `device_type:` tag. ## Configuration ### Claude Code (CLI) ```bash -# Get a JWT token +# Option A: Using API key (recommended — never expires) +claude mcp add --transport sse My_GNS3_Server \ + http://localhost:3080/v3/mcp/transport/sse \ + -H "Authorization: Bearer gns3_a1b2c3d4..." + +# Option B: Using JWT token (expires after 24h) TOKEN=$(curl -s -X POST http://localhost:3080/v3/access/users/authenticate \ -H "Content-Type: application/json" \ -d '{"username": "admin", "password": "admin"}' | python3 -c \ "import sys,json; print(json.load(sys.stdin)['access_token'])") -# Add MCP server claude mcp add --transport sse My_GNS3_Server \ http://localhost:3080/v3/mcp/transport/sse \ -H "Authorization: Bearer $TOKEN" @@ -128,7 +244,7 @@ Add to `claude_desktop_config.json`: { "mcpServers": { "My_GNS3_Server": { - "url": "http://localhost:3080/v3/mcp/transport/sse?token=your_jwt_token" + "url": "http://localhost:3080/v3/mcp/transport/sse?token=your_jwt_or_api_key" } } }