mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-11-03 05:51:15 +02:00
feat(agent): share Web UI compatible drawing builders between MCP and copilot
The text drawing tool was MCP-only; the copilot needs the same capability and its area-annotation helper carried the same Web UI compat bugs. Split into three layers with a single implementation: - gns3server/utils/drawings.py: pure SVG builders (text, rectangle, ellipse) with full input validation (ValueError) — the Web UI format knowledge lives in core utils next to picture.py - api_handlers.py: shared REST handlers (create_text_drawing_handler moved here, create_rectangle/ellipse_drawing_handler added) — the layer both consumers already share - thin shells: MCP tools (drawing_create_text unchanged, plus new drawing_create_rectangle/_ellipse) and three LangChain tools (tools_v2/gns3_create_drawing.py) registered in both copilot mode lists Shape builder details: - rectangle: solid fill or "none", stroke block emitted only when a stroke is given, rx corner radius, dasharray validated against the Web UI's Qt remap table (QtDasharrayFixer rewrites those patterns) - ellipse: bounding-box API; cx/cy derived as offsets inside the drawing box (the Web UI treats them as box-relative, not canvas coordinates); odd sizes round down and the actual size is returned gns3_drawing_utils now delegates to the shared builders, fixing the px-blind sizing, the ignored text-anchor/x/y centering and the missing XML escaping in the area-annotation path.
This commit is contained in:
13 files changed
+1003
-265
No files matched your search
@@ -77,7 +77,10 @@ from gns3server.agent.gns3_copilot.skills.registry import get_prompt
|
||||
from gns3server.agent.gns3_copilot.tools_v2 import (
|
||||
ExecuteMultipleDeviceCommands,
|
||||
ExecuteMultipleDeviceConfigCommands,
|
||||
GNS3CreateEllipseDrawingTool,
|
||||
GNS3CreateNodeTool,
|
||||
GNS3CreateRectangleDrawingTool,
|
||||
GNS3CreateTextDrawingTool,
|
||||
GNS3LinkTool,
|
||||
GNS3PacketFilterTool,
|
||||
GNS3StartNodeTool,
|
||||
@@ -103,6 +106,9 @@ TEACHING_ASSISTANT_MODE_TOOLS = [
|
||||
GNS3TemplateTool(), # Get GNS3 node templates
|
||||
GNS3CreateNodeTool(), # Create new nodes in GNS3
|
||||
GNS3LinkTool(), # Create links between nodes
|
||||
GNS3CreateTextDrawingTool(), # Add text labels to the canvas
|
||||
GNS3CreateRectangleDrawingTool(), # Add rectangle annotations to the canvas
|
||||
GNS3CreateEllipseDrawingTool(), # Add ellipse annotations to the canvas
|
||||
GNS3StartNodeTool(), # Start GNS3 nodes (returns immediately)
|
||||
GNS3WaitTool(), # Wait for nodes to boot (pair with start_gns3_node)
|
||||
GNS3UpdateNodeNameTool(), # Update node name
|
||||
@@ -118,6 +124,9 @@ LAB_AUTOMATION_ASSISTANT_MODE_TOOLS = [
|
||||
GNS3TemplateTool(), # Get GNS3 node templates
|
||||
GNS3CreateNodeTool(), # Create new nodes in GNS3
|
||||
GNS3LinkTool(), # Create links between nodes
|
||||
GNS3CreateTextDrawingTool(), # Add text labels to the canvas
|
||||
GNS3CreateRectangleDrawingTool(), # Add rectangle annotations to the canvas
|
||||
GNS3CreateEllipseDrawingTool(), # Add ellipse annotations to the canvas
|
||||
GNS3StartNodeTool(), # Start GNS3 nodes (returns immediately)
|
||||
GNS3WaitTool(), # Wait for nodes to boot (pair with start_gns3_node)
|
||||
GNS3StopNodeTool(), # Stop GNS3 nodes
|
||||
|
||||
@@ -55,6 +55,7 @@ from typing import Any
|
||||
from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector
|
||||
from gns3server.services import access_ticket_service
|
||||
from gns3server.services.access_tickets import DEFAULT_TICKET_TTL
|
||||
from gns3server.utils.drawings import build_ellipse_svg, build_rectangle_svg, build_text_svg
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -1076,3 +1077,95 @@ def marker_definition_handler(params: dict[str, Any], gns3_ctx: dict[str, Any])
|
||||
# action == "delete"
|
||||
conn.http_call("delete", url)
|
||||
return {"message": f"Marker definition '{def_name}' deleted", "project_id": project_id, "def_name": def_name}
|
||||
|
||||
|
||||
# ── Drawing handlers ──────────────────────────────────────────────────────
|
||||
# SVG assembly and validation live in gns3server.utils.drawings (Web UI
|
||||
# compatible format); these handlers only carry params and do the REST call.
|
||||
|
||||
|
||||
def create_text_drawing_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Create a canvas text label; the SVG is assembled from validated params."""
|
||||
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
try:
|
||||
svg, width, height, line_height = build_text_svg(
|
||||
params.get("text"),
|
||||
font_size=params.get("font_size", 13),
|
||||
color=params.get("color", "#000000"),
|
||||
bold=params.get("bold", False),
|
||||
font_family=params.get("font_family", "monospace"),
|
||||
)
|
||||
except ValueError as e:
|
||||
return {"error": str(e)}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
data = {"svg": svg, "x": params.get("x", 0), "y": params.get("y", 0), "z": params.get("z", 1)}
|
||||
result = conn.http_call("post", f"{conn.base_url}/projects/{project_id}/drawings", json_data=data).json()
|
||||
return {
|
||||
"message": "Text drawing created",
|
||||
"drawing": result,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"line_height": line_height,
|
||||
}
|
||||
|
||||
|
||||
def create_rectangle_drawing_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Create a canvas rectangle; the SVG is assembled from validated params."""
|
||||
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
width = params.get("width")
|
||||
height = params.get("height")
|
||||
if width is None or height is None:
|
||||
return {"error": "width and height are required"}
|
||||
try:
|
||||
svg, width, height = build_rectangle_svg(
|
||||
width,
|
||||
height,
|
||||
fill=params.get("fill", "#FFFFFF"),
|
||||
fill_opacity=params.get("fill_opacity", 1.0),
|
||||
stroke=params.get("stroke"),
|
||||
stroke_width=params.get("stroke_width", 1),
|
||||
dashed=params.get("dashed", False),
|
||||
dasharray=params.get("dasharray"),
|
||||
rx=params.get("rx", 0),
|
||||
)
|
||||
except ValueError as e:
|
||||
return {"error": str(e)}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
data = {"svg": svg, "x": params.get("x", 0), "y": params.get("y", 0), "z": params.get("z", 0)}
|
||||
result = conn.http_call("post", f"{conn.base_url}/projects/{project_id}/drawings", json_data=data).json()
|
||||
return {"message": "Rectangle drawing created", "drawing": result, "width": width, "height": height}
|
||||
|
||||
|
||||
def create_ellipse_drawing_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Create a canvas ellipse from a bounding box; the SVG is assembled from validated params."""
|
||||
|
||||
project_id = params.get("project_id")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
width = params.get("width")
|
||||
height = params.get("height")
|
||||
if width is None or height is None:
|
||||
return {"error": "width and height are required"}
|
||||
try:
|
||||
svg, width, height = build_ellipse_svg(
|
||||
width,
|
||||
height,
|
||||
fill=params.get("fill", "#FFFFFF"),
|
||||
fill_opacity=params.get("fill_opacity", 1.0),
|
||||
stroke=params.get("stroke"),
|
||||
stroke_width=params.get("stroke_width", 1),
|
||||
dashed=params.get("dashed", False),
|
||||
dasharray=params.get("dasharray"),
|
||||
)
|
||||
except ValueError as e:
|
||||
return {"error": str(e)}
|
||||
conn = _get_connector(gns3_ctx)
|
||||
data = {"svg": svg, "x": params.get("x", 0), "y": params.get("y", 0), "z": params.get("z", 0)}
|
||||
result = conn.http_call("post", f"{conn.base_url}/projects/{project_id}/drawings", json_data=data).json()
|
||||
return {"message": "Ellipse drawing created", "drawing": result, "width": width, "height": height}
|
||||
@@ -40,6 +40,7 @@ Main modules:
|
||||
- vpcs_tools_netmiko: VPCS device configuration tool using Netmiko
|
||||
- gns3_create_node: GNS3 node creation tool
|
||||
- gns3_create_link: GNS3 link creation tool
|
||||
- gns3_create_drawing: GNS3 canvas drawing tools (text, rectangle, ellipse)
|
||||
- gns3_start_node: GNS3 node startup tool (immediate return, no boot wait)
|
||||
- gns3_wait: Wait timer tool (pair with node start to let devices boot)
|
||||
- gns3_get_node_temp: GNS3 template retrieval tool
|
||||
@@ -54,6 +55,11 @@ Author: Yue Guobin (岳国宾)
|
||||
# Import main tool classes
|
||||
from .config_tools_nornir import ExecuteMultipleDeviceConfigCommands
|
||||
from .display_tools_nornir import ExecuteMultipleDeviceCommands
|
||||
from .gns3_create_drawing import (
|
||||
GNS3CreateEllipseDrawingTool,
|
||||
GNS3CreateRectangleDrawingTool,
|
||||
GNS3CreateTextDrawingTool,
|
||||
)
|
||||
from .gns3_create_link import GNS3LinkTool
|
||||
from .gns3_create_node import GNS3CreateNodeTool
|
||||
from .gns3_get_node_temp import GNS3TemplateTool
|
||||
@@ -81,7 +87,10 @@ __url__ = "https://github.com/yueguobin/gns3-copilot"
|
||||
__all__ = [
|
||||
"ExecuteMultipleDeviceCommands",
|
||||
"ExecuteMultipleDeviceConfigCommands",
|
||||
"GNS3CreateEllipseDrawingTool",
|
||||
"GNS3CreateNodeTool",
|
||||
"GNS3CreateRectangleDrawingTool",
|
||||
"GNS3CreateTextDrawingTool",
|
||||
"GNS3LinkTool",
|
||||
"GNS3PacketFilterTool",
|
||||
"GNS3StartNodeQuickTool",
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3
|
||||
#
|
||||
# This file is part of GNS3-Copilot project.
|
||||
#
|
||||
# GNS3-Copilot is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU General Public License as published by the
|
||||
# Free Software Foundation, either version 3 of the License, or (at your
|
||||
# option) any later version.
|
||||
#
|
||||
# GNS3-Copilot is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
# for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with GNS3-Copilot. If not, see <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
# Copyright (C) 2025 Yue Guobin (岳国宾)
|
||||
# Author: Yue Guobin (岳国宾)
|
||||
#
|
||||
# Project Home: https://github.com/yueguobin/gns3-copilot
|
||||
#
|
||||
|
||||
"""
|
||||
GNS3 canvas drawing tools for text, rectangle and ellipse annotations.
|
||||
|
||||
Thin LangChain wrappers over the shared REST handlers in
|
||||
``gns3_client.api_handlers``; the Web UI compatible SVG is assembled and
|
||||
validated by ``gns3server.utils.drawings`` (single implementation shared
|
||||
with the MCP drawing tools).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from langchain.tools import BaseTool
|
||||
from langchain_core.callbacks import CallbackManagerForToolRun
|
||||
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
|
||||
build_gns3_ctx,
|
||||
create_ellipse_drawing_handler,
|
||||
create_rectangle_drawing_handler,
|
||||
create_text_drawing_handler,
|
||||
)
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _GNS3CreateDrawingToolBase(BaseTool):
|
||||
"""Shared plumbing: parse JSON input, build ctx, dispatch to a handler."""
|
||||
|
||||
def _dispatch(self, input_data: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
def _run(
|
||||
self,
|
||||
tool_input: str,
|
||||
run_manager: CallbackManagerForToolRun | None = None,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
input_data = json.loads(tool_input)
|
||||
|
||||
gns3_ctx = build_gns3_ctx()
|
||||
if gns3_ctx is None:
|
||||
logger.error("Failed to create GNS3 connector")
|
||||
return {"error": "Failed to connect to GNS3 server. Please check your configuration."}
|
||||
|
||||
return self._dispatch(input_data, gns3_ctx)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error("Invalid JSON input: %s", e)
|
||||
return {"error": f"Invalid JSON input: {e}"}
|
||||
except Exception as e:
|
||||
logger.exception("Drawing creation failed")
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
class GNS3CreateTextDrawingTool(_GNS3CreateDrawingToolBase):
|
||||
"""
|
||||
A LangChain tool to add a text label to the GNS3 project canvas.
|
||||
|
||||
**Input**:
|
||||
A JSON object with project_id, x, y (top-left corner on the canvas)
|
||||
and text; optional font_size (1-200, default 13), color (#RRGGBB,
|
||||
default #000000), bold (default false), font_family (default
|
||||
monospace) and z (default 1, keeps text above links).
|
||||
Newlines in text start additional lines; XML characters are escaped
|
||||
automatically; the computed width/height are returned.
|
||||
|
||||
**Output**:
|
||||
A dictionary with the created drawing, its drawing_id and the
|
||||
computed width/height/line_height, or an "error" key on failure.
|
||||
"""
|
||||
|
||||
name: str = "create_gns3_text_drawing"
|
||||
description: str = """
|
||||
Adds a text label to the GNS3 project canvas.
|
||||
Input: JSON with project_id, x, y (top-left corner), text and optional
|
||||
font_size, color, bold, font_family, z.
|
||||
Returns: the created drawing with its computed width and height.
|
||||
"""
|
||||
|
||||
def _dispatch(self, input_data: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
return create_text_drawing_handler(input_data, gns3_ctx)
|
||||
|
||||
|
||||
class GNS3CreateRectangleDrawingTool(_GNS3CreateDrawingToolBase):
|
||||
"""
|
||||
A LangChain tool to add a rectangle to the GNS3 project canvas.
|
||||
|
||||
**Input**:
|
||||
A JSON object with project_id, x, y (top-left corner), width and
|
||||
height (1-2000); optional fill (#RRGGBB or "none", default #FFFFFF),
|
||||
fill_opacity (0-1, default 1.0), stroke (#RRGGBB, no border by
|
||||
default), stroke_width (1-20), dashed (bool), dasharray (like "4,2",
|
||||
requires stroke) and rx (corner radius, default 0). z defaults to 0
|
||||
so shapes stay behind nodes and text.
|
||||
|
||||
**Output**:
|
||||
A dictionary with the created drawing, or an "error" key on failure.
|
||||
"""
|
||||
|
||||
name: str = "create_gns3_rectangle_drawing"
|
||||
description: str = """
|
||||
Adds a rectangle to the GNS3 project canvas.
|
||||
Input: JSON with project_id, x, y, width, height and optional fill,
|
||||
fill_opacity, stroke, stroke_width, dashed, dasharray, rx, z.
|
||||
Returns: the created drawing with its width and height.
|
||||
"""
|
||||
|
||||
def _dispatch(self, input_data: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
return create_rectangle_drawing_handler(input_data, gns3_ctx)
|
||||
|
||||
|
||||
class GNS3CreateEllipseDrawingTool(_GNS3CreateDrawingToolBase):
|
||||
"""
|
||||
A LangChain tool to add an ellipse (or circle) to the GNS3 canvas.
|
||||
|
||||
**Input**:
|
||||
A JSON object with project_id, x, y (bounding box top-left corner),
|
||||
width and height (2-2000, equal values give a circle); the same
|
||||
optional style fields as the rectangle tool. cx/cy are derived from
|
||||
the bounding box; for odd width/height the drawing is 1 px smaller
|
||||
than requested and the actual size is returned.
|
||||
|
||||
**Output**:
|
||||
A dictionary with the created drawing, or an "error" key on failure.
|
||||
"""
|
||||
|
||||
name: str = "create_gns3_ellipse_drawing"
|
||||
description: str = """
|
||||
Adds an ellipse or circle to the GNS3 project canvas.
|
||||
Input: JSON with project_id, x, y, width, height (bounding box) and
|
||||
optional fill, fill_opacity, stroke, stroke_width, dashed, dasharray, z.
|
||||
Returns: the created drawing with its actual width and height.
|
||||
"""
|
||||
|
||||
def _dispatch(self, input_data: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
return create_ellipse_drawing_handler(input_data, gns3_ctx)
|
||||
@@ -33,6 +33,8 @@ Supports ellipse and rectangle shapes for two-node annotations.
|
||||
import math
|
||||
from typing import Any, Literal
|
||||
|
||||
from gns3server.utils.drawings import build_ellipse_svg, build_rectangle_svg, build_text_svg
|
||||
|
||||
# Default parameters
|
||||
DEFAULT_DEVICE_WIDTH = 50
|
||||
DEFAULT_DEVICE_HEIGHT = 50
|
||||
@@ -144,10 +146,16 @@ def calculate_two_node_shape(
|
||||
center_y = (node1_center_y + node2_center_y) / 2
|
||||
|
||||
color_scheme = _get_color_scheme(area_name)
|
||||
text_svg = generate_text_svg(area_name, color_scheme)
|
||||
|
||||
text_svg_width = len(area_name) * 8 + 20
|
||||
text_svg_height = DEFAULT_FONT_SIZE + 16
|
||||
# Shared builder: Web UI compatible SVG plus the computed label size
|
||||
# (the old len * 8 + 20 estimate ignored the Web UI's pt-based rendering).
|
||||
text_svg, text_svg_width, text_svg_height, _ = build_text_svg(
|
||||
area_name,
|
||||
font_size=DEFAULT_FONT_SIZE,
|
||||
color=color_scheme["stroke"],
|
||||
bold=True,
|
||||
font_family="TypeWriter",
|
||||
)
|
||||
|
||||
if shape_type == "ellipse":
|
||||
rx = distance / 2
|
||||
@@ -236,8 +244,11 @@ def generate_ellipse_svg(
|
||||
svg_width: int,
|
||||
svg_height: int,
|
||||
) -> str:
|
||||
"""Generate SVG for ellipse."""
|
||||
return f"""<svg width="{svg_width}" height="{svg_height}"><ellipse cx="{rx}" cy="{ry}" rx="{rx}" ry="{ry}" fill="{color_scheme["fill"]}" fill-opacity="{color_scheme["fill_opacity"]}"/></svg>"""
|
||||
"""Generate SVG for ellipse (delegates to the shared Web UI compatible builder)."""
|
||||
svg, _, _ = build_ellipse_svg(
|
||||
rx * 2, ry * 2, fill=color_scheme["fill"], fill_opacity=color_scheme["fill_opacity"]
|
||||
)
|
||||
return svg
|
||||
|
||||
|
||||
def generate_rectangle_svg(
|
||||
@@ -245,16 +256,23 @@ def generate_rectangle_svg(
|
||||
height: int,
|
||||
color_scheme: dict[str, Any],
|
||||
) -> str:
|
||||
"""Generate SVG for rectangle."""
|
||||
return f"""<svg width="{width}" height="{height}"><rect x="0" y="0" width="{width}" height="{height}" fill="{color_scheme["fill"]}" fill-opacity="{color_scheme["fill_opacity"]}"/></svg>"""
|
||||
"""Generate SVG for rectangle (delegates to the shared Web UI compatible builder)."""
|
||||
svg, _, _ = build_rectangle_svg(
|
||||
width, height, fill=color_scheme["fill"], fill_opacity=color_scheme["fill_opacity"]
|
||||
)
|
||||
return svg
|
||||
|
||||
|
||||
def generate_text_svg(text: str, color_scheme: dict[str, Any]) -> str:
|
||||
"""Generate SVG for text label."""
|
||||
text_width = len(text) * 8 + 20
|
||||
text_height = DEFAULT_FONT_SIZE + 16
|
||||
|
||||
return f"""<svg width="{text_width}" height="{text_height}"><text font-family="TypeWriter" font-size="{DEFAULT_FONT_SIZE}.0" font-weight="bold" fill="{color_scheme["stroke"]}" text-anchor="middle" x="{text_width / 2}" y="{text_height / 2 + 4}">{text}</text></svg>"""
|
||||
"""Generate SVG for text label (delegates to the shared Web UI compatible builder)."""
|
||||
svg, _, _, _ = build_text_svg(
|
||||
text,
|
||||
font_size=DEFAULT_FONT_SIZE,
|
||||
color=color_scheme["stroke"],
|
||||
bold=True,
|
||||
font_family="TypeWriter",
|
||||
)
|
||||
return svg
|
||||
|
||||
|
||||
def _hsv_to_hex(h: int, s: int, v: int) -> str:
|
||||
|
||||
@@ -45,8 +45,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from gns3server.agent.gns3_copilot.gns3_client.api_handlers import (
|
||||
available_filters_handler,
|
||||
create_ellipse_drawing_handler,
|
||||
create_link_handler,
|
||||
create_node_handler,
|
||||
create_rectangle_drawing_handler,
|
||||
create_text_drawing_handler,
|
||||
delete_link_handler,
|
||||
delete_node_file_handler,
|
||||
delete_node_handler,
|
||||
@@ -110,7 +113,6 @@ from .device_config import (
|
||||
)
|
||||
from .drawings import (
|
||||
create_drawing_handler,
|
||||
create_text_drawing_handler,
|
||||
delete_drawing_handler,
|
||||
get_drawing_handler,
|
||||
get_drawings_handler,
|
||||
@@ -1627,6 +1629,103 @@ async def drawing_create_text(
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def drawing_create_rectangle(
|
||||
project_id: Annotated[str, Field(description="Project name or UUID")],
|
||||
x: Annotated[int, Field(description="X coordinate of the rectangle top-left corner on the canvas")],
|
||||
y: Annotated[int, Field(description="Y coordinate of the rectangle top-left corner on the canvas")],
|
||||
width: Annotated[int, Field(description="Rectangle width in px, 1-2000")],
|
||||
height: Annotated[int, Field(description="Rectangle height in px, 1-2000")],
|
||||
fill: Annotated[str, Field(description='Fill color, hex like #RRGGBB or "none" (default: #FFFFFF)')] = "#FFFFFF",
|
||||
fill_opacity: Annotated[float, Field(description="Fill opacity, 0-1 (default: 1.0)")] = 1.0,
|
||||
stroke: Annotated[str | None, Field(description="Border color, hex like #RRGGBB; omit for no border")] = None,
|
||||
stroke_width: Annotated[int, Field(description="Border width, 1-20 (default: 1)")] = 1,
|
||||
dashed: Annotated[bool, Field(description="Dashed border, uses the default 10,6 pattern (default: false)")] = False,
|
||||
dasharray: Annotated[str | None, Field(description='Custom dash pattern like "4,2"; requires stroke')] = None,
|
||||
rx: Annotated[int, Field(description="Corner radius, 0 to min(width, height)/2 (default: 0)")] = 0,
|
||||
z: Annotated[int, Field(description="Z layer; 0 keeps shapes behind nodes and text (default: 0)")] = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Add a rectangle to a project canvas.
|
||||
|
||||
The SVG is assembled server-side in the Web UI compatible format; prefer this
|
||||
over hand-writing SVG for drawing_create.
|
||||
|
||||
- fill "none" renders an outline-only box (stroke recommended then).
|
||||
- Border attributes are only emitted when stroke is given.
|
||||
- dasharray must be plain "on,off" numbers; the Qt-remapped patterns
|
||||
("25, 25", "5, 25", ...) are rejected because the Web UI rewrites them.
|
||||
|
||||
Example: semi-transparent outline box at (200, 150):
|
||||
drawing_create_rectangle(project_id="demo", x=200, y=150, width=400, height=200,
|
||||
fill="none", stroke="#333333", dashed=True)
|
||||
"""
|
||||
return await asyncio.to_thread(
|
||||
_run_handler_sync,
|
||||
create_rectangle_drawing_handler,
|
||||
{
|
||||
"project_id": project_id,
|
||||
"x": x,
|
||||
"y": y,
|
||||
"z": z,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"fill": fill,
|
||||
"fill_opacity": fill_opacity,
|
||||
"stroke": stroke,
|
||||
"stroke_width": stroke_width,
|
||||
"dashed": dashed,
|
||||
"dasharray": dasharray,
|
||||
"rx": rx,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def drawing_create_ellipse(
|
||||
project_id: Annotated[str, Field(description="Project name or UUID")],
|
||||
x: Annotated[int, Field(description="X coordinate of the bounding box top-left corner on the canvas")],
|
||||
y: Annotated[int, Field(description="Y coordinate of the bounding box top-left corner on the canvas")],
|
||||
width: Annotated[int, Field(description="Bounding box width in px, 2-2000; equal to height gives a circle")],
|
||||
height: Annotated[int, Field(description="Bounding box height in px, 2-2000")],
|
||||
fill: Annotated[str, Field(description='Fill color, hex like #RRGGBB or "none" (default: #FFFFFF)')] = "#FFFFFF",
|
||||
fill_opacity: Annotated[float, Field(description="Fill opacity, 0-1 (default: 1.0)")] = 1.0,
|
||||
stroke: Annotated[str | None, Field(description="Border color, hex like #RRGGBB; omit for no border")] = None,
|
||||
stroke_width: Annotated[int, Field(description="Border width, 1-20 (default: 1)")] = 1,
|
||||
dashed: Annotated[bool, Field(description="Dashed border, uses the default 10,6 pattern (default: false)")] = False,
|
||||
dasharray: Annotated[str | None, Field(description='Custom dash pattern like "4,2"; requires stroke')] = None,
|
||||
z: Annotated[int, Field(description="Z layer; 0 keeps shapes behind nodes and text (default: 0)")] = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Add an ellipse (or circle) to a project canvas.
|
||||
|
||||
The SVG is assembled server-side in the Web UI compatible format. cx/cy are
|
||||
derived from the bounding box (the Web UI treats them as offsets inside the
|
||||
drawing, not canvas coordinates); for odd width/height the drawing is 1 px
|
||||
smaller than requested and the actual size is returned.
|
||||
|
||||
Example: circle of diameter 160 at (300, 300):
|
||||
drawing_create_ellipse(project_id="demo", x=300, y=300, width=160, height=160,
|
||||
fill="#5AA9DD", fill_opacity=0.8)
|
||||
"""
|
||||
return await asyncio.to_thread(
|
||||
_run_handler_sync,
|
||||
create_ellipse_drawing_handler,
|
||||
{
|
||||
"project_id": project_id,
|
||||
"x": x,
|
||||
"y": y,
|
||||
"z": z,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"fill": fill,
|
||||
"fill_opacity": fill_opacity,
|
||||
"stroke": stroke,
|
||||
"stroke_width": stroke_width,
|
||||
"dashed": dashed,
|
||||
"dasharray": dasharray,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def drawing_get(
|
||||
project_id: Annotated[str, Field(description="Project name or UUID")],
|
||||
|
||||
@@ -20,11 +20,7 @@ MCP tool handlers for GNS3 drawing management.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from math import ceil
|
||||
from typing import Any
|
||||
from xml.etree import ElementTree as ET
|
||||
from xml.sax.saxutils import escape
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -73,101 +69,6 @@ def create_drawing_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) ->
|
||||
return {"message": "Drawing created", "drawing": result}
|
||||
|
||||
|
||||
# ── Text drawing ───────────────────────────────────────────────────────────
|
||||
|
||||
# The Web UI renders font-size as pt (1pt = 4/3 px) and monospace glyphs advance
|
||||
# ~0.6em, so one character is ~0.8 x font_size in canvas pixels. Proportional
|
||||
# fonts get a wider worst-case factor so the selection box does not clip the text.
|
||||
_MONOSPACE_CHAR_FACTOR = 0.8
|
||||
_PROPORTIONAL_CHAR_FACTOR = 1.0
|
||||
# The Web UI stacks lines at dy=1.4em, i.e. 1.4 x 4/3 ~ 1.87 px per pt of font size.
|
||||
_LINE_HEIGHT_FACTOR = 1.9
|
||||
_MAX_TEXT_LENGTH = 500
|
||||
|
||||
_COLOR_RE = re.compile(r"^#(?:[0-9A-Fa-f]{3,4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$")
|
||||
_FONT_FAMILY_RE = re.compile(r"^[A-Za-z0-9 ,.'-]+$")
|
||||
|
||||
|
||||
def build_text_svg(
|
||||
text: str, font_size: int = 13, color: str = "#000000", bold: bool = False, font_family: str = "monospace"
|
||||
) -> tuple[str, int, int, int]:
|
||||
"""
|
||||
Build a Web UI compatible single <text> SVG for a canvas label.
|
||||
|
||||
The output mirrors what the Web UI itself serializes for text drawings:
|
||||
root <svg height width> with one <text> child and no x/y (the Web UI parser
|
||||
ignores them and positions the text itself).
|
||||
|
||||
:returns: (svg, width, height, line_height) with the computed drawing size
|
||||
"""
|
||||
|
||||
lines = text.split("\n")
|
||||
factor = _MONOSPACE_CHAR_FACTOR if "monospace" in font_family.lower() else _PROPORTIONAL_CHAR_FACTOR
|
||||
width = max(1, ceil(max(len(line) for line in lines) * font_size * factor))
|
||||
line_height = ceil(font_size * _LINE_HEIGHT_FACTOR)
|
||||
height = len(lines) * line_height
|
||||
weight = "bold" if bold else "normal"
|
||||
svg = (
|
||||
f'<svg height="{height}" width="{width}">'
|
||||
f'<text fill="{color}" fill-opacity="1.0" '
|
||||
f'font-family="{font_family}" font-size="{font_size}" '
|
||||
f'font-weight="{weight}">{escape(text)}</text></svg>'
|
||||
)
|
||||
# Fail loudly here: the controller silently keeps the previous (empty) SVG
|
||||
# when handed an unparseable string of 500+ characters.
|
||||
ET.fromstring(svg)
|
||||
return svg, width, height, line_height
|
||||
|
||||
|
||||
def create_text_drawing_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
project_id = params.get("project_id")
|
||||
text = params.get("text")
|
||||
if not project_id:
|
||||
return {"error": "project_id is required"}
|
||||
if not text:
|
||||
return {"error": "text is required"}
|
||||
text = text.replace("\r\n", "\n").replace("\r", "\n").strip("\n")
|
||||
if not text:
|
||||
return {"error": "text is required"}
|
||||
if len(text) > _MAX_TEXT_LENGTH:
|
||||
return {"error": f"text is too long (max {_MAX_TEXT_LENGTH} characters)"}
|
||||
if any(ord(ch) < 32 and ch != "\n" for ch in text) or "\x7f" in text:
|
||||
return {"error": "text contains control characters (only newlines are allowed)"}
|
||||
|
||||
font_size = params.get("font_size", 13)
|
||||
if isinstance(font_size, bool) or not isinstance(font_size, int) or not 1 <= font_size <= 200:
|
||||
return {"error": "font_size must be an integer between 1 and 200"}
|
||||
color = params.get("color", "#000000")
|
||||
if not _COLOR_RE.match(color):
|
||||
return {"error": "color must be a hex value like #RRGGBB"}
|
||||
font_family = params.get("font_family", "monospace")
|
||||
if not _FONT_FAMILY_RE.match(font_family):
|
||||
return {"error": "font_family contains invalid characters (allowed: letters, digits, spaces, . ' -)"}
|
||||
|
||||
try:
|
||||
svg, width, height, line_height = build_text_svg(
|
||||
text, font_size=font_size, color=color, bold=params.get("bold", False), font_family=font_family
|
||||
)
|
||||
except ET.ParseError as e:
|
||||
return {"error": f"generated SVG is not valid XML: {e}"}
|
||||
|
||||
conn = _get_connector(gns3_ctx)
|
||||
data = {
|
||||
"svg": svg,
|
||||
"x": params.get("x", 0),
|
||||
"y": params.get("y", 0),
|
||||
"z": params.get("z", 1),
|
||||
}
|
||||
result = conn.http_call("post", f"{conn.base_url}/projects/{project_id}/drawings", json_data=data).json()
|
||||
return {
|
||||
"message": "Text drawing created",
|
||||
"drawing": result,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"line_height": line_height,
|
||||
}
|
||||
|
||||
|
||||
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")
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# 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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
Web UI compatible drawing SVG assembly.
|
||||
|
||||
The GNS3 Web UI does not render drawing SVG as-is: it re-parses it into its own
|
||||
element model with ONE recognized child element per drawing (text, image, rect,
|
||||
line, ellipse or path) and reads the root width/height for the selection box.
|
||||
These helpers emit exactly that format, mirroring the attribute sets the Web UI
|
||||
itself serializes, and validate inputs so callers fail loudly instead of hitting
|
||||
the controller's silent drop of unparseable SVG strings of 500+ characters.
|
||||
|
||||
Shared by the MCP tools (agent/mcp) and the GNS3 copilot tools (tools_v2).
|
||||
"""
|
||||
|
||||
import re
|
||||
from math import ceil
|
||||
from typing import Any
|
||||
from xml.etree import ElementTree as ET
|
||||
from xml.sax.saxutils import escape
|
||||
|
||||
# The Web UI renders font-size as pt (1pt = 4/3 px) and monospace glyphs advance
|
||||
# ~0.6em, so one character is ~0.8 x font_size in canvas pixels. Proportional
|
||||
# fonts get a wider worst-case factor so the selection box does not clip the text.
|
||||
_MONOSPACE_CHAR_FACTOR = 0.8
|
||||
_PROPORTIONAL_CHAR_FACTOR = 1.0
|
||||
# The Web UI stacks lines at dy=1.4em, i.e. 1.4 x 4/3 ~ 1.87 px per pt of font size.
|
||||
_LINE_HEIGHT_FACTOR = 1.9
|
||||
_MAX_TEXT_LENGTH = 500
|
||||
|
||||
_COLOR_RE = re.compile(r"^#(?:[0-9A-Fa-f]{3,4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$")
|
||||
_FONT_FAMILY_RE = re.compile(r"^[A-Za-z0-9 ,.'-]+$")
|
||||
_DASHARRAY_RE = re.compile(r"^[0-9][0-9, ]*$")
|
||||
# The Web UI remaps these Qt-style dash patterns to different values
|
||||
# (QtDasharrayFixer); reject them so what you send is what renders.
|
||||
_QT_REMAPPED_DASHARRAYS = {"25, 25", "5, 25", "5, 25, 25", "25, 25, 5, 25, 5"}
|
||||
_DEFAULT_DASHARRAY = "10,6"
|
||||
|
||||
|
||||
def _checked_text(text: Any) -> str:
|
||||
if text is None:
|
||||
raise ValueError("text is required")
|
||||
if not isinstance(text, str):
|
||||
raise ValueError("text must be a string")
|
||||
text = text.replace("\r\n", "\n").replace("\r", "\n").strip("\n")
|
||||
if not text:
|
||||
raise ValueError("text is required")
|
||||
if len(text) > _MAX_TEXT_LENGTH:
|
||||
raise ValueError(f"text is too long (max {_MAX_TEXT_LENGTH} characters)")
|
||||
if any(ord(ch) < 32 and ch != "\n" for ch in text) or "\x7f" in text:
|
||||
raise ValueError("text contains control characters (only newlines are allowed)")
|
||||
return text
|
||||
|
||||
|
||||
def _checked_font_size(font_size: Any) -> int:
|
||||
if isinstance(font_size, bool) or not isinstance(font_size, int) or not 1 <= font_size <= 200:
|
||||
raise ValueError("font_size must be an integer between 1 and 200")
|
||||
return font_size
|
||||
|
||||
|
||||
def _checked_font_family(font_family: Any) -> str:
|
||||
if not isinstance(font_family, str) or not _FONT_FAMILY_RE.match(font_family):
|
||||
raise ValueError("font_family contains invalid characters (allowed: letters, digits, spaces, . ' -)")
|
||||
return font_family
|
||||
|
||||
|
||||
def _checked_color(value: Any, name: str) -> str:
|
||||
if not isinstance(value, str) or not _COLOR_RE.match(value):
|
||||
raise ValueError(f"{name} must be a hex value like #RRGGBB")
|
||||
return value
|
||||
|
||||
|
||||
def _checked_dimension(value: Any, name: str) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= 2000:
|
||||
raise ValueError(f"{name} must be an integer between 1 and 2000")
|
||||
return value
|
||||
|
||||
|
||||
def _checked_radius(value: Any, max_radius: int) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= max_radius:
|
||||
raise ValueError(f"rx must be an integer between 0 and {max_radius}")
|
||||
return value
|
||||
|
||||
|
||||
def _checked_style(
|
||||
fill: Any, fill_opacity: Any, stroke: Any, stroke_width: Any, dashed: Any, dasharray: Any
|
||||
) -> tuple[str, Any, str | None, int, str | None]:
|
||||
"""
|
||||
Validate the style block shared by rectangle and ellipse drawings.
|
||||
|
||||
:returns: (fill, fill_opacity, stroke, stroke_width, dasharray-or-None)
|
||||
"""
|
||||
|
||||
if fill != "none" and (not isinstance(fill, str) or not _COLOR_RE.match(fill)):
|
||||
raise ValueError('fill must be a hex value like #RRGGBB or "none"')
|
||||
if isinstance(fill_opacity, bool) or not isinstance(fill_opacity, (int, float)) or not 0 <= fill_opacity <= 1:
|
||||
raise ValueError("fill_opacity must be a number between 0 and 1")
|
||||
|
||||
dash = dasharray if dasharray is not None else (_DEFAULT_DASHARRAY if dashed else None)
|
||||
if dash is not None:
|
||||
if not isinstance(dash, str) or not _DASHARRAY_RE.match(dash) or dash in _QT_REMAPPED_DASHARRAYS:
|
||||
raise ValueError(
|
||||
'dasharray must be plain "on,off" number pairs and must not be one of '
|
||||
f"the Qt-remapped patterns {sorted(_QT_REMAPPED_DASHARRAYS)}"
|
||||
)
|
||||
if stroke is None:
|
||||
raise ValueError("stroke is required when dasharray is set")
|
||||
|
||||
if stroke is not None:
|
||||
stroke = _checked_color(stroke, "stroke")
|
||||
if isinstance(stroke_width, bool) or not isinstance(stroke_width, int) or not 1 <= stroke_width <= 20:
|
||||
raise ValueError("stroke_width must be an integer between 1 and 20")
|
||||
|
||||
return fill, fill_opacity, stroke, stroke_width, dash
|
||||
|
||||
|
||||
def _style_attrs(fill: str, fill_opacity: Any, stroke: str | None, stroke_width: int, dash: str | None) -> str:
|
||||
"""Render the validated style block as SVG attributes (Web UI attribute order)."""
|
||||
|
||||
attrs = f'fill="{fill}" fill-opacity="{fill_opacity}"'
|
||||
if stroke is not None:
|
||||
attrs += f' stroke="{stroke}" stroke-width="{stroke_width}"'
|
||||
if dash is not None:
|
||||
attrs += f' stroke-dasharray="{dash}"'
|
||||
return attrs
|
||||
|
||||
|
||||
def build_text_svg(
|
||||
text: Any, font_size: int = 13, color: str = "#000000", bold: bool = False, font_family: str = "monospace"
|
||||
) -> tuple[str, int, int, int]:
|
||||
"""
|
||||
Build a Web UI compatible single <text> SVG for a canvas label.
|
||||
|
||||
The output mirrors what the Web UI itself serializes for text drawings:
|
||||
root <svg height width> with one <text> child and no x/y (the Web UI parser
|
||||
ignores them and positions the text itself).
|
||||
|
||||
:returns: (svg, width, height, line_height) with the computed drawing size
|
||||
"""
|
||||
|
||||
text = _checked_text(text)
|
||||
font_size = _checked_font_size(font_size)
|
||||
color = _checked_color(color, "color")
|
||||
font_family = _checked_font_family(font_family)
|
||||
|
||||
lines = text.split("\n")
|
||||
factor = _MONOSPACE_CHAR_FACTOR if "monospace" in font_family.lower() else _PROPORTIONAL_CHAR_FACTOR
|
||||
width = max(1, ceil(max(len(line) for line in lines) * font_size * factor))
|
||||
line_height = ceil(font_size * _LINE_HEIGHT_FACTOR)
|
||||
height = len(lines) * line_height
|
||||
weight = "bold" if bold else "normal"
|
||||
svg = (
|
||||
f'<svg height="{height}" width="{width}">'
|
||||
f'<text fill="{color}" fill-opacity="1.0" '
|
||||
f'font-family="{font_family}" font-size="{font_size}" '
|
||||
f'font-weight="{weight}">{escape(text)}</text></svg>'
|
||||
)
|
||||
ET.fromstring(svg)
|
||||
return svg, width, height, line_height
|
||||
|
||||
|
||||
def build_rectangle_svg(
|
||||
width: int,
|
||||
height: int,
|
||||
fill: str = "#FFFFFF",
|
||||
fill_opacity: float = 1.0,
|
||||
stroke: str | None = None,
|
||||
stroke_width: int = 1,
|
||||
dashed: bool = False,
|
||||
dasharray: str | None = None,
|
||||
rx: int = 0,
|
||||
) -> tuple[str, int, int]:
|
||||
"""
|
||||
Build a Web UI compatible single <rect> SVG.
|
||||
|
||||
The rect sits at the drawing origin (the Web UI ignores rect x/y), so the
|
||||
caller positions the drawing itself and the root size equals the rect size.
|
||||
|
||||
:returns: (svg, width, height)
|
||||
"""
|
||||
|
||||
width = _checked_dimension(width, "width")
|
||||
height = _checked_dimension(height, "height")
|
||||
rx = _checked_radius(rx, min(width, height) // 2)
|
||||
fill, fill_opacity, stroke, stroke_width, dash = _checked_style(
|
||||
fill, fill_opacity, stroke, stroke_width, dashed, dasharray
|
||||
)
|
||||
|
||||
svg = (
|
||||
f'<svg height="{height}" width="{width}">'
|
||||
f'<rect {_style_attrs(fill, fill_opacity, stroke, stroke_width, dash)} '
|
||||
f'height="{height}" width="{width}" rx="{rx}" ry="{rx}"/></svg>'
|
||||
)
|
||||
ET.fromstring(svg)
|
||||
return svg, width, height
|
||||
|
||||
|
||||
def build_ellipse_svg(
|
||||
width: int,
|
||||
height: int,
|
||||
fill: str = "#FFFFFF",
|
||||
fill_opacity: float = 1.0,
|
||||
stroke: str | None = None,
|
||||
stroke_width: int = 1,
|
||||
dashed: bool = False,
|
||||
dasharray: str | None = None,
|
||||
) -> tuple[str, int, int]:
|
||||
"""
|
||||
Build a Web UI compatible single <ellipse> SVG from a bounding box.
|
||||
|
||||
The radii and center are derived (rx = width // 2, cx = rx) because the Web UI
|
||||
treats cx/cy as offsets inside the drawing box, not canvas coordinates. For
|
||||
odd width/height the drawing is 1 px smaller than requested.
|
||||
|
||||
:returns: (svg, width, height) with the actual (possibly rounded-down) size
|
||||
"""
|
||||
|
||||
width = _checked_dimension(width, "width")
|
||||
height = _checked_dimension(height, "height")
|
||||
rx, ry = width // 2, height // 2
|
||||
if rx < 1 or ry < 1:
|
||||
raise ValueError("width and height must be at least 2")
|
||||
fill, fill_opacity, stroke, stroke_width, dash = _checked_style(
|
||||
fill, fill_opacity, stroke, stroke_width, dashed, dasharray
|
||||
)
|
||||
|
||||
svg = (
|
||||
f'<svg height="{2 * ry}" width="{2 * rx}">'
|
||||
f'<ellipse {_style_attrs(fill, fill_opacity, stroke, stroke_width, dash)} '
|
||||
f'cx="{rx}" cy="{ry}" rx="{rx}" ry="{ry}"/></svg>'
|
||||
)
|
||||
ET.fromstring(svg)
|
||||
return svg, 2 * rx, 2 * ry
|
||||
Reference in new issue
Block a user