feat(mcp): add drawing_create_text tool with Web UI compatible SVG assembly

The Web UI re-parses drawing SVG into its own element model instead of
rendering it raw, which makes hand-assembled SVG error-prone for LLM
callers. The new tool assembles the SVG server-side, mirroring what the
Web UI itself serializes for text drawings:

- XML-escaped text content, validated color (#RGB/#RGBA/#RRGGBB/
  #RRGGBBAA) and font family (attribute-safe charset)
- multi-line support: literal newlines render as stacked lines in the
  Web UI (tspan split), width/height computed from the longest line
- pt-aware sizing: the Web UI renders font-size in pt (1pt = 4/3 px),
  so width uses a 0.8/char factor for monospace (1.0 proportional) and
  line height 1.9 x font_size, instead of the px-based 0.62 estimate
- ET.fromstring pre-check so bad input fails loudly instead of hitting
  the controller's silent drop of unparseable 500+ char SVG
- computed width/height/line_height returned for label layout

Also correct the raw drawing_create docstring: root width/height are
required (the parser reads them for the selection box), x/y on rect are
ignored, <g> is unsupported, <path> is re-interpreted as a freehand
curve, and the old examples lacked the mandatory root dimensions.
This commit is contained in:
YueGuobin 2026-10-08 20:07:57 +08:00
parent 1e0d123f4b
commit 73cbb0387c
No known key found for this signature in database
5 changed files with 312 additions and 11 deletions

View File

@ -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:
- <rect> MUST have a solid fill color (e.g. fill=\"#FF0000\") to render.
fill=\"none\" or fill=\"transparent\" will be invisible in the GUI.
- <ellipse> works correctly with or without fill.
- <line> and <text> work normally.
Web UI compatibility rules (it re-parses the SVG instead of rendering it raw):
- Root must be <svg width="W" height="H">; W/H set the selection box size and are required.
- Exactly ONE child element, from: text, image, rect, line, ellipse, path.
<g> is not supported; extra children are dropped (first recognized child wins).
- <path> is re-drawn as a freehand curve from the points in d — not a faithful render.
- <rect>: omit x/y (ignored, the rect sits at the drawing origin) and give a solid fill.
- <ellipse>: 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: <svg><text x=\"10\" y=\"20\" font-size=\"14\">R1</text></svg>
Rectangle: <svg><rect x=\"10\" y=\"10\" width=\"80\" height=\"50\" fill=\"#4A90D9\" stroke=\"black\"/></svg>
Ellipse: <svg><ellipse cx=\"50\" cy=\"50\" rx=\"40\" ry=\"20\" fill=\"red\" stroke=\"black\"/></svg>
Line: <svg><line x1=\"0\" y1=\"0\" x2=\"100\" y2=\"100\" stroke=\"black\" stroke-width=\"2\"/></svg>
Dashed line: <svg><line x1=\"0\" y1=\"0\" x2=\"100\" y2=\"100\" stroke=\"black\" stroke-dasharray=\"5,5\"/></svg>
Rectangle: <svg width="80" height="50"><rect width="80" height="50" fill="#4A90D9"/></svg>
Ellipse: <svg width="80" height="40"><ellipse cx="40" cy="20" rx="40" ry="20" fill="red"/></svg>
Line: <svg width="100" height="100"><line x2="100" y2="100" stroke="black" stroke-width="2"/></svg>
Dashed line: <svg width="100" height="100"><line x2="100" y2="100" stroke="black" stroke-dasharray="5,5"/></svg>
"""
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")],

View File

@ -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 <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")

View File

@ -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"]

View File

@ -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 == (
'<svg height="25" width="21">'
'<text fill="#000000" fill-opacity="1.0" font-family="monospace" '
'font-size="13" font-weight="normal">R1</text></svg>'
)
# 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<b&c", font_size=10)
root = ET.fromstring(svg)
# round-trips through the parser back to the original text
assert root.find("text").text == "a<b&c"
assert "a&lt;b&amp;c" in svg
def test_multi_line(self):
svg, width, height, line_height = build_text_svg("hello\nworld!", font_size=10)
# longest line is 6 chars x 10 x 0.8 = 48; two lines of ceil(19)
assert width == 48
assert height == 38
assert line_height == 19
assert "\n" in ET.fromstring(svg).find("text").text
def test_proportional_font_wider(self):
mono_w, _, _, _ = build_text_svg("same", font_size=10, font_family="monospace")
prop_w, _, _, _ = build_text_svg("same", font_size=10, font_family="Noto Sans")
assert prop_w > 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'<svg height="{height}" width="{width}"><text fill=')
assert svg.endswith("</text></svg>")
class TestCreateTextDrawingHandler:
def test_success(self, ctx):
with patch(f"{BASE}.drawings._get_connector") as m:
conn = _mock_conn({"drawing_id": "d1", "x": 100, "y": 80})
m.return_value = conn
result = create_text_drawing_handler(
{"project_id": "p1", "text": "R1", "x": 100, "y": 80, "bold": True}, ctx
)
assert result["drawing"]["drawing_id"] == "d1"
assert result["width"] == 21
assert result["height"] == 25
# z defaults to 1 (above links)
body = conn.http_call.call_args.kwargs["json_data"]
assert body["z"] == 1
assert body["x"] == 100
assert 'font-weight="bold"' in body["svg"]
def test_missing_project_id(self, ctx):
assert "error" in create_text_drawing_handler({"text": "R1"}, ctx)
def test_missing_text(self, ctx):
assert "error" in create_text_drawing_handler({"project_id": "p1"}, ctx)
def test_blank_text(self, ctx):
assert "error" in create_text_drawing_handler({"project_id": "p1", "text": "\n\n"}, ctx)
def test_newline_normalization(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": "a\r\nb"}, ctx)
svg = conn.http_call.call_args.kwargs["json_data"]["svg"]
assert "\r" not in svg
def test_text_too_long(self, ctx):
assert "error" in create_text_drawing_handler({"project_id": "p1", "text": "x" * 501}, ctx)
def test_control_characters_rejected(self, ctx):
assert "error" in create_text_drawing_handler({"project_id": "p1", "text": "a\x00b"}, ctx)
@pytest.mark.parametrize("font_size", [0, 201, "13", 13.5, True])
def test_bad_font_size(self, ctx, font_size):
result = create_text_drawing_handler({"project_id": "p1", "text": "R1", "font_size": font_size}, ctx)
assert "error" in result
@pytest.mark.parametrize("color", ["red", "123456", "#GGGGGG", "#12345", "black\" onload=\"x"])
def test_bad_color(self, ctx, color):
result = create_text_drawing_handler({"project_id": "p1", "text": "R1", "color": color}, ctx)
assert "error" in result
@pytest.mark.parametrize("font_family", ['mono" onload="x', "sans<serif>", "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 & <backup>"}, ctx)
svg = conn.http_call.call_args.kwargs["json_data"]["svg"]
assert "R1 &amp; &lt;backup&gt;" in svg
ET.fromstring(svg)

View File

@ -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",