diff --git a/misc/qet-mcp/README.md b/misc/qet-mcp/README.md
index 6d29fe6dc..dd555a59a 100644
--- a/misc/qet-mcp/README.md
+++ b/misc/qet-mcp/README.md
@@ -30,6 +30,7 @@ here read the model.
| `qet_project_info` | title, format version, folios, element and conductor counts |
| `qet_elements` | placed elements: uuid, type, position, label, information bag |
| `qet_conductors` | conductors and their documentation fields; filter by attribute |
+| `qet_items` | free texts, shapes, pictures, tables and symbol text fields, each with its uuid |
| `qet_diff` | **what an edit actually changed** — element moves, adds, removes, relabels; conductor changes; and folio fields, texts, shapes, images, symbol text fields and terminal strips |
| `qet_scan` | sweep a directory of projects, counting nodes carrying an attribute |
| `qet_element_info` | a `.elmt`: translated names, terminals, info fields, part counts |
diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py
index 9cf5d64e2..a33630123 100755
--- a/misc/qet-mcp/qet_mcp.py
+++ b/misc/qet-mcp/qet_mcp.py
@@ -272,6 +272,36 @@ def tool_elements(path: str, folio: int | None = None,
"elements": rows[:limit]}
+ITEM_KINDS = ["text", "shape", "image", "table", "element_text"]
+
+
+def tool_items(path: str, folio: int | None = None, kind: str | None = None,
+ limit: int = 500) -> dict:
+ """Every drawn item that is not a symbol or a wire, with its uuid.
+
+ Free texts, shapes, pictures, tables and the text fields of symbols --
+ the items a qet_edit op or a qet_diff entry names by uuid. Folios are
+ numbered from 1, as in qet_elements. An item saved before these items
+ carried a uuid has "" here; QElectroTech gives it one on the next save.
+ """
+ if kind is not None and kind not in ITEM_KINDS:
+ raise ValueError(f"kind must be one of {ITEM_KINDS}, not {kind!r}")
+ ex = _extras(_root(path))
+ rows = []
+ for name, records in (("text", ex["texts"]), ("shape", ex["shapes"]),
+ ("image", ex["images"]), ("table", ex["tables"])):
+ for r in records:
+ rows.append({"kind": name, "uuid": r["uuid"], **r["label"], **r["value"]})
+ for k, v in ex["element_texts"].items():
+ rows.append({"kind": "element_text", "uuid": ex["element_text_uuids"][k],
+ "folio": ex["element_text_folios"][k], "element": k[0],
+ "source": k[1], "bound_to": k[2], "n": k[3], **v})
+ rows = [r for r in rows if (folio is None or r["folio"] == folio)
+ and (kind is None or r["kind"] == kind)]
+ rows.sort(key=lambda r: (r["folio"], ITEM_KINDS.index(r["kind"])))
+ return {"count": len(rows), "truncated": len(rows) > limit, "items": rows[:limit]}
+
+
def tool_conductors(path: str, folio: int | None = None,
attribute: str | None = None,
non_empty: bool = False, limit: int = 200) -> dict:
@@ -373,7 +403,7 @@ def _extras(root: ET.Element) -> dict:
im, {"folio": n, "x": im.get("x", ""), "y": im.get("y", "")},
{"scale": im.get("size", ""), "rotation": _angle(im.get("rotation", ""))}))
- element_texts, element_text_uuids = {}, {}
+ element_texts, element_text_uuids, element_text_folios = {}, {}, {}
for n, d in _folios(root):
for el in d.iter("element"):
uuid = el.get("uuid", "")
@@ -387,6 +417,7 @@ def _extras(root: ET.Element) -> dict:
seen[base] = seen.get(base, 0) + 1
fs = (t.get("font", "").split(",") + ["", ""])[1]
element_text_uuids[base + (seen[base],)] = t.get("uuid", "")
+ element_text_folios[base + (seen[base],)] = n
element_texts[base + (seen[base],)] = {
"x": t.get("x", ""), "y": t.get("y", ""), "size": fs,
"frame": t.get("frame", ""), "rotation": _angle(t.get("rotation", "")),
@@ -406,7 +437,8 @@ def _extras(root: ET.Element) -> dict:
"terminals": sum(1 for _ in st.iter("real_terminal"))}
return {"folios": folios, "folio_uuids": folio_uuids, "texts": texts, "shapes": shapes,
"images": images, "tables": tables, "strips": strips,
- "element_texts": element_texts, "element_text_uuids": element_text_uuids}
+ "element_texts": element_texts, "element_text_uuids": element_text_uuids,
+ "element_text_folios": element_text_folios}
def _diff_keyed(a: dict, b: dict, label) -> dict:
@@ -2225,6 +2257,26 @@ TOOLS = [
},
"handler": lambda a: tool_project_info(a["path"]),
},
+ {
+ "name": "qet_items",
+ "description": "List the drawn items that are not symbols or wires -- free texts, "
+ "shapes, pictures, tables and the text fields of symbols -- with "
+ "each one's uuid, folio (counted from 1) and main fields. Use the "
+ "uuid to address an item in qet_edit or to find it in qet_diff. "
+ "Reads the file directly; does not launch QElectroTech.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "path": {"type": "string"},
+ "folio": {"type": "integer", "description": "folio number counted from 1"},
+ "kind": {"type": "string", "enum": ITEM_KINDS},
+ "limit": {"type": "integer", "default": 500},
+ },
+ "required": ["path"],
+ },
+ "handler": lambda a: tool_items(a["path"], a.get("folio"), a.get("kind"),
+ a.get("limit", 500)),
+ },
{
"name": "qet_elements",
"description": "List placed elements with uuid, type, position, label and "
@@ -2798,6 +2850,7 @@ _BY_NAME = {t["name"]: t for t in TOOLS}
_DATA_PATHS = {
"qet_project_info": {"read": ("path",)},
"qet_elements": {"read": ("path",)},
+ "qet_items": {"read": ("path",)},
"qet_conductors": {"read": ("path",)},
"qet_diff": {"read": ("before", "after")},
"qet_scan": {"read": ("directory",)},
diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py
index a549581d4..3d90e9d13 100644
--- a/misc/qet-mcp/test_qet_mcp.py
+++ b/misc/qet-mcp/test_qet_mcp.py
@@ -153,7 +153,7 @@ class ToolRegistry(unittest.TestCase):
"qet_project_info", "qet_elements", "qet_conductors", "qet_diff",
"qet_scan", "qet_element_info", "qet_export", "qet_edit", "qet_query",
"qet_project_new", "qet_element_search", "qet_check", "qet_element_build",
- "qet_continuity"})
+ "qet_continuity", "qet_items"})
class EditValidation(unittest.TestCase):
@@ -838,6 +838,69 @@ class ReadToolContracts(unittest.TestCase):
self.assertEqual(m._plain_text(None), "")
+class ItemsTool(unittest.TestCase):
+ """qet_items: every drawn item that is not a symbol or a wire, with its uuid."""
+
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.qet = str(Path(self.tmp.name) / "p.qet")
+ Path(self.qet).write_text(
+ ''
+ ''
+ 'K1label'
+ ''
+ ''
+ ''
+ ''
+ ''
+ ''
+ ''
+ '')
+
+ def tearDown(self):
+ self.tmp.cleanup()
+
+ def test_every_kind_with_its_uuid(self):
+ r = m.tool_items(self.qet)
+ self.assertEqual((r["count"], r["truncated"]), (5, False))
+ self.assertEqual(r["items"], [
+ {"kind": "text", "uuid": "{tx}", "folio": 1, "x": "1", "y": "2", "text": "note",
+ "rotation": "0", "font": "f", "color": "c"},
+ {"kind": "shape", "uuid": "{sh}", "folio": 1, "type": "Line", "from": ["0", "0"],
+ "to": ["9", "9"], "line_color": "c", "line_style": "s", "line_width": "1",
+ "fill": "none", "rotation": "0"},
+ {"kind": "element_text", "uuid": "{et}", "folio": 1, "element": "{el}",
+ "source": "ElementInfo", "bound_to": "label", "n": 1, "x": "5", "y": "6",
+ "size": "9", "frame": "false", "rotation": "0", "width": "-1", "shows": "K1"},
+ # saved before pictures carried a uuid: "" until the next save
+ {"kind": "image", "uuid": "", "folio": 2, "x": "3", "y": "4", "scale": "1",
+ "rotation": "0"},
+ {"kind": "table", "uuid": "{tb}", "folio": 2, "name": "Parts", "x": "0", "y": "0",
+ "width": "100", "height": "50", "rows_shown": "10"}])
+
+ def test_filters_and_limit(self):
+ kinds = lambda r: [i["kind"] for i in r["items"]]
+ self.assertEqual(kinds(m.tool_items(self.qet, folio=2)), ["image", "table"])
+ self.assertEqual(kinds(m.tool_items(self.qet, kind="shape")), ["shape"])
+ self.assertEqual(kinds(m.tool_items(self.qet, folio=2, kind="shape")), [])
+ r = m.tool_items(self.qet, limit=2)
+ self.assertEqual((r["count"], r["truncated"], len(r["items"])), (5, True, 2))
+ r = m.tool_items(self.qet, limit=5)
+ self.assertEqual((r["count"], r["truncated"]), (5, False))
+ with self.assertRaisesRegex(ValueError, "kind must be one of"):
+ m.tool_items(self.qet, kind="wire")
+
+ def test_default_limit_is_500(self):
+ many = "".join(f'' for i in range(501))
+ big = Path(self.tmp.name) / "big.qet"
+ big.write_text(f'{many}')
+ r = m.tool_items(str(big))
+ self.assertEqual((r["count"], r["truncated"], len(r["items"])), (501, True, 500))
+
+
class DiffContracts(unittest.TestCase):
"""Exact qet_diff output, section by section, on hand-made pairs.
@@ -1572,6 +1635,20 @@ class PathPolicy(unittest.TestCase):
if "overwrite" in t["inputSchema"].get("properties", {})}
self.assertEqual(guarded, advertised)
+ def test_every_data_path_argument_is_guarded(self):
+ """The other direction: a tool whose schema takes a data path must be
+ in the policy, or that path is read or written with no workspace
+ check at all -- and nothing fails. binary and elements_dir are
+ configuration, deliberately not confined (see the README)."""
+ pathish = {"path", "project", "before", "after", "output", "directory"}
+ for t in m.TOOLS:
+ with self.subTest(tool=t["name"]):
+ props = t["inputSchema"].get("properties", {})
+ spec = m._DATA_PATHS.get(t["name"], {})
+ guarded = set(spec.get("read", ())) | set(spec.get("write", ()))
+ self.assertEqual(pathish & set(props), guarded & pathish,
+ f"{t['name']}: data path arguments not in _DATA_PATHS")
+
def test_the_policy_names_only_real_tools_and_arguments(self):
by_name = {t["name"]: t for t in m.TOOLS}
for name, spec in m._DATA_PATHS.items():