diff --git a/gns3server/agent/mcp/__init__.py b/gns3server/agent/mcp/__init__.py index 27cce38b9..05d8603c7 100644 --- a/gns3server/agent/mcp/__init__.py +++ b/gns3server/agent/mcp/__init__.py @@ -110,6 +110,7 @@ from .device_config import ( ) from .drawings import ( create_drawing_handler, + create_text_drawing_handler, delete_drawing_handler, get_drawing_handler, get_drawings_handler, @@ -1548,20 +1549,24 @@ 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 from raw SVG. - 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. + Web UI compatibility rules (it re-parses the SVG instead of rendering it raw): + - Root must be ; W/H set the selection box size and are required. + - Exactly ONE child element, from: text, image, rect, line, ellipse, path. + is not supported; extra children are dropped (first recognized child wins). + - is re-drawn as a freehand curve from the points in d — not a faithful render. + - : omit x/y (ignored, the rect sits at the drawing origin) and give a solid fill. + - : cx/cy are offsets inside the drawing box, not canvas coordinates. + - font-size is displayed in pt units by the Web UI (1pt = 4/3 px). + + For text labels use drawing_create_text instead — it handles escaping and sizing. SVG examples: - Text label: R1 - Rectangle: - Ellipse: - Line: - Dashed line: + Rectangle: + Ellipse: + Line: + Dashed line: """ return await asyncio.to_thread( _run_handler_sync, @@ -1578,6 +1583,50 @@ async def drawing_create( ) +@mcp.tool() +async def drawing_create_text( + project_id: Annotated[str, Field(description="Project name or UUID")], + x: Annotated[int, Field(description="X coordinate of the text top-left corner on the canvas")], + y: Annotated[int, Field(description="Y coordinate of the text top-left corner on the canvas")], + text: Annotated[str, Field(description="Label text; newlines (\\n) start additional lines")], + font_size: Annotated[int, Field(description="Font size, 1-200 (default: 13)")] = 13, + color: Annotated[str, Field(description="Text color, hex like #RRGGBB (default: #000000)")] = "#000000", + bold: Annotated[bool, Field(description="Bold text (default: false)")] = False, + font_family: Annotated[str, Field(description="Font family name (default: monospace)")] = "monospace", + z: Annotated[int, Field(description="Z layer; 1 keeps text above links (default: 1)")] = 1, +) -> list[dict[str, Any]]: + """Add a text label 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. + + - XML characters (& < >) are escaped automatically. + - Multi-line: embed newlines (\\n) in text; each line renders below the previous one. + - The drawing width/height are computed from the text and returned, so labels + can be laid out without guessing the selection box size. + - Constraints: color must be hex like #RRGGBB; font_family accepts plain names + (letters, digits, spaces, . ' -); text capped at 500 characters. + + Example: bold red label at (100, 80): + drawing_create_text(project_id="demo", x=100, y=80, text="R1 management", color="#CC0000", bold=True) + """ + return await asyncio.to_thread( + _run_handler_sync, + create_text_drawing_handler, + { + "project_id": project_id, + "text": text, + "x": x, + "y": y, + "z": z, + "font_size": font_size, + "color": color, + "bold": bold, + "font_family": font_family, + }, + ) + + @mcp.tool() async def drawing_get( project_id: Annotated[str, Field(description="Project name or UUID")], diff --git a/gns3server/agent/mcp/drawings.py b/gns3server/agent/mcp/drawings.py index 7ca6daeda..4cd88c4fe 100644 --- a/gns3server/agent/mcp/drawings.py +++ b/gns3server/agent/mcp/drawings.py @@ -20,7 +20,11 @@ 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__) @@ -69,6 +73,101 @@ 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 SVG for a canvas label. + + The output mirrors what the Web UI itself serializes for text drawings: + root with one 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'' + f'{escape(text)}' + ) + # 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") diff --git a/pyproject.toml b/pyproject.toml index 5ff415217..9defd027d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -165,6 +165,8 @@ extend-immutable-calls = [ "gns3server/agent/gns3_copilot/utils/parse_tool_content.py" = ["C901", "DTZ003"] "gns3server/agent/mcp/__init__.py" = ["RUF003"] "gns3server/agent/mcp/device_config.py" = ["E741"] +# Parses self-generated SVG (validated inputs) like controller/drawing.py does. +"gns3server/agent/mcp/drawings.py" = ["S314"] "gns3server/agent/web_wireshark/manager.py" = ["C901"] "gns3server/agent/web_wireshark/setup_wireshark_image.py" = ["PLW1510", "S603", "S607"] "gns3server/agent/web_wireshark/stats.py" = ["ASYNC221", "PLW1510", "S603", "S607"] @@ -234,6 +236,7 @@ extend-immutable-calls = [ "scripts/extract_mermaid.py" = ["C901", "PLW1510", "S603", "S607"] "scripts/tag.py" = ["S603", "S607"] "scripts/welcome.py" = ["C901", "E722", "PLW1510", "S108", "S112", "S603", "S605", "S606", "S607"] +"tests/agent/mcp/test_drawings.py" = ["S314"] "tests/agent/mcp/test_handlers.py" = ["B905"] "tests/agent/mcp/test_tool_params.py" = ["C901"] "tests/api/test_openapi_spec.py" = ["S603"] diff --git a/tests/agent/mcp/test_drawings.py b/tests/agent/mcp/test_drawings.py new file mode 100644 index 000000000..0979ecb04 --- /dev/null +++ b/tests/agent/mcp/test_drawings.py @@ -0,0 +1,149 @@ +""" +MCP text drawing tests: SVG builder units and handler behavior. +""" + +from unittest.mock import MagicMock, patch +from xml.etree import ElementTree as ET + +import pytest + +from gns3server.agent.mcp.drawings import build_text_svg, create_text_drawing_handler + +BASE = "gns3server.agent.mcp" + + +def _mock_conn(json_result=None): + """Create a mocked Gns3Connector with base_url and http_call.""" + conn = MagicMock() + conn.base_url = "http://192.168.1.3:3080/v3" + conn.http_call.return_value.json.return_value = json_result or {"status": "ok"} + return conn + + +@pytest.fixture +def ctx(): + return {"server_url": "http://192.168.1.3:3080", "jwt_token": "token", "jwt_username": "admin"} + + +class TestBuildTextSvg: + def test_single_line(self): + svg, width, height, line_height = build_text_svg("R1", font_size=13) + assert svg == ( + '' + 'R1' + ) + # 2 chars x 13 x 0.8 = 20.8 -> 21; one line of 13 x 1.9 = 24.7 -> 25 + assert width == 21 + assert height == 25 + assert line_height == 25 + + def test_bold_and_color(self): + svg, _, _, _ = build_text_svg("R1", font_size=13, color="#CC0000", bold=True) + assert 'fill="#CC0000"' in svg + assert 'font-weight="bold"' in svg + + def test_escapes_xml_characters(self): + svg, _, _, _ = build_text_svg("a mono_w + + def test_empty_line_kept(self): + # a blank line still occupies a line box + _, _, height, line_height = build_text_svg("a\n\nb", font_size=10) + assert height == 3 * line_height + + def test_matches_web_ui_serializer_shape(self): + # byte-for-byte structural parity with the Web UI's own text output + svg, width, height, _ = build_text_svg("demo", font_size=11) + assert svg.startswith(f'", "a&b", ""]) + def test_bad_font_family(self, ctx, font_family): + result = create_text_drawing_handler({"project_id": "p1", "text": "R1", "font_family": font_family}, ctx) + assert "error" in result + + def test_font_family_with_spaces_accepted(self, ctx): + with patch(f"{BASE}.drawings._get_connector") as m: + conn = _mock_conn({"drawing_id": "d1"}) + m.return_value = conn + result = create_text_drawing_handler( + {"project_id": "p1", "text": "R1", "font_family": "DejaVu Sans Mono"}, ctx + ) + assert "error" not in result + + def test_xml_entities_survive_round_trip(self, ctx): + with patch(f"{BASE}.drawings._get_connector") as m: + conn = _mock_conn({"drawing_id": "d1"}) + m.return_value = conn + create_text_drawing_handler({"project_id": "p1", "text": "R1 & "}, ctx) + svg = conn.http_call.call_args.kwargs["json_data"]["svg"] + assert "R1 & <backup>" in svg + ET.fromstring(svg) diff --git a/tests/agent/mcp/test_tool_params.py b/tests/agent/mcp/test_tool_params.py index e2287f8e1..29d266191 100644 --- a/tests/agent/mcp/test_tool_params.py +++ b/tests/agent/mcp/test_tool_params.py @@ -81,6 +81,7 @@ HANDLER_FILES = { "restore_snapshot_handler": "snapshots.py", "get_drawings_handler": "drawings.py", "create_drawing_handler": "drawings.py", + "create_text_drawing_handler": "drawings.py", "get_drawing_handler": "drawings.py", "update_drawing_handler": "drawings.py", "delete_drawing_handler": "drawings.py",