Clone
1
mcp_server
ispyisail edited this page 2026-09-24 12:52:01 +12:00

MCP server

Let an AI assistant (Claude, or any other MCP client) read and verify QElectroTech projects directly, instead of reasoning from a screenshot: what a project contains, what an edit actually changed, and what a whole corpus of projects contains.

It lives at misc/qet-mcp/qet_mcp.py in the source tree — a small stdio Model Context Protocol server, Python 3.9+ and the standard library only, no MCP SDK dependency. Added in PR #969; its scripting-API foundation landed alongside in PR #970. Both merged 2026-09-21.


Why this exists

Verifying a change by screenshot is unreliable, and this tool exists because that unreliability produced two wrong conclusions in one review session:

  • A drag of a multi-element selection looked like it had left the symbols behind and detached their labels. Diffing the saved file showed all four elements had moved by an identical (0, -80) and no label had moved at all. A bug report was one step away from being filed.
  • An "Apply" button looked like it did nothing. It was disabled, because a required field was empty.

Both times the pixels misled and the model told the truth. So the tools here read the model — the project XML and, where one exists, the project database — instead of the rendered scene.

Most tools parse the .qet/.elmt file directly: fast, no display needed, immune to a stray dialog. Two (qet_export, qet_edit) launch QElectroTech itself, in an isolated sandbox, because exporting and editing through the real application is the only way to get the real behaviour — see JavaScript Scripting for the engine qet_edit and qet_query drive underneath.


Installation

# from the QElectroTech source tree
python3 misc/qet-mcp/qet_mcp.py --list   # list the tools and exit
python3 misc/qet-mcp/qet_mcp.py          # speak MCP on stdin/stdout

Register it with an MCP client — for Claude Code or Claude Desktop, a mcpServers block:

{
  "mcpServers": {
    "qet": {
      "command": "python3",
      "args": ["/path/to/qelectrotech/misc/qet-mcp/qet_mcp.py"],
      "env": {
        "QET_MCP_WORKSPACE": "/home/you/drawings",
        "QET_ENABLE_SCRIPTING": "1"
      }
    }
  }
}

Nothing to build, nothing to pip install — the two environment variables above are the only setup that matters, and both are covered below.


Environment variables

Variable Effect
QET_MCP_WORKSPACE Directories tool calls may read and write, :-separated (; on Windows). Unset: the directory the server was started in.
QET_MCP_ALLOW_ANY_PATH=1 Turns the workspace check off entirely — equivalent to giving the client local filesystem access with this process's privileges.
QET_ENABLE_SCRIPTING=1 Required by five tools (see below); QElectroTech refuses --run without it, default off since PR #984.

Workspace confinement

Every path in a tool call is chosen by the model. Without a policy, that makes the server a read/write primitive for anything the OS lets the process reach — read any project on disk, export somewhere unrelated, overwrite a file, embed an arbitrary local image or PDF. So data paths are confined to QET_MCP_WORKSPACE, checked at the point arguments enter the server. A path outside it is refused with an error naming what was allowed; symlinks are resolved first, so a link planted inside the workspace is judged by where it points.

Two arguments are deliberately not confined: binary (the qelectrotech executable) and elements_dir (the element collection). Those are configuration, chosen once by whoever runs the server, and both normally live in /usr or a build tree — outside any sensible workspace. Confining them would reject the ordinary case while stopping nothing.

Nothing is overwritten unasked. qet_export, qet_edit, qet_project_new and qet_element_build refuse an output that already exists unless the call passes "overwrite": true — the one step this server cannot undo is the one step it will not take on its own.

Scripting gate

Needs QET_ENABLE_SCRIPTING=1 qet_query, qet_continuity, qet_check, qet_project_new, qet_edit
Unaffected Everything else — they read the .qet/.elmt directly, or, for qet_export, use a plain CLI flag

The variable goes in the environment the server is started in (the env block above), and the server passes it straight through to QElectroTech — it does not set the variable itself. A switch a program turns on for itself is not a switch: whoever configured the server and pointed it at a QElectroTech binary made that choice, and their own interactive QElectroTech keeps whatever its own setting says. Without it, the five tools above return "ok": false with a hint naming the variable. Builds from before the setting existed need nothing.


Tools

Tool What it answers Launches QET?
qet_project_info Title, format version, folios, element/conductor counts per folio No
qet_elements Placed elements: uuid, type, position, label, information bag; filter by folio or name No
qet_conductors Conductors and their documentation fields (num, formula, cable, bus, function, colour, section); filter by attribute No
qet_diff What an edit actually changed — element moves/adds/removes/relabels, conductor field changes, folio fields/texts/shapes/images/symbol text fields/terminal strips No
qet_scan Sweep a directory of projects, counting nodes carrying an attribute, with distinct values found No
qet_element_info Introspect a .elmt: translated names, terminals, dynamic-text info fields, part counts No
qet_export Headless export: pdf, png, svg, bom, cables, wires, wiring, nets, links, info Yes
qet_edit Change a project — place, move, rotate, label, wire, number, cross-reference, add text/shapes/images, restyle a symbol's text fields, delete; returns a qet_diff of the result Yes
qet_query Read-only SQL SELECT/WITH against the project's SQLite database; omit sql to list queryable views/tables Yes*
qet_continuity ERC-style checks against the live Terminal/Conductor graph: unconnected terminals, potential mismatches, folio-report link mismatches Yes*
qet_project_new Start from nothing: an empty project with a title and folios, written and read back by QElectroTech itself Yes*
qet_element_search Find a symbol in a collection by name (any language), link type, kind or terminal count; results carry the common:// path and terminal index order qet_edit needs No
qet_check Design-rule checks: duplicate labels, unlabelled masters, unnumbered conductors, empty folios, masters missing a manufacturer reference Yes*
qet_element_build Author a new .elmt: draw from lines/rects/ellipses/circles/arcs/polygons/text, with terminals to wire it by; computes and checks the size header No

* Needs QET_ENABLE_SCRIPTING=1.


Worked examples

What did that edit change?

{"name": "qet_diff", "arguments": {"before": "a.qet", "after": "b.qet"}}
"elements": { "moved_count": 4,
              "distinct_move_deltas": [[0.0, -80.0]],
              "relabelled": [], "info_changed": [] }

Four elements moved by one uniform delta; nothing was relabelled. That is the answer a screenshot gave wrongly.

Draw something, and check it landed

{"name": "qet_edit", "arguments": {
  "binary": "/path/to/qelectrotech",
  "project": "in.qet", "output": "out.qet",
  "elements_dir": "/path/to/qelectrotech/elements",
  "operations": [
    {"op": "add_folio", "id": "f"},
    {"op": "set_folio_title", "folio": "$f", "title": "Starter"},
    {"op": "add_element", "id": "k1", "folio": "$f", "path": "common://.../coil.elmt", "x": 100, "y": 100},
    {"op": "add_element", "id": "k2", "folio": "$f", "path": "common://.../coil.elmt", "x": 320, "y": 100},
    {"op": "add_conductor", "folio": "$f", "from": "$k1", "from_terminal": 0, "to": "$k2", "to_terminal": 0},
    {"op": "set_conductor", "folio": "$f", "element": "$k1", "terminal": 0, "property": "num", "value": "W7"},
    {"op": "set_label", "folio": "$f", "element": "$k1", "label": "KM1"}
  ]}}

An op that creates something takes an "id"; later ops name it as "$id". Terminals are addressed by index — top to bottom, then left to right, not the order the .elmt lists them; qet_element_info and qet_element_search both report that index order. The result carries a per-operation outcome and a qet_diff, because "addConductor → true" says the call was accepted, not that the file came out right:

"diff": {"elements":   {"before": 11, "after": 13, "added": ["{0aa3…}", "{6f63…}"]},
         "conductors": {"before": 47, "after": 48, "added": ["4:{0aa3…}/{2904…}--{6f63…}/{2904…}"],
                        "removed": []}}

Draw a symbol that does not exist yet

{"name": "qet_element_build", "arguments": {
  "output": "/path/to/collection/99_custom/my_resistor.elmt",
  "names": {"en": "Test resistor", "fr": "Résistance de test"},
  "parts": [
    {"type": "rect", "x": -10, "y": -20, "width": 20, "height": 40},
    {"type": "line", "x1": 0, "y1": -30, "x2": 0, "y2": -20},
    {"type": "line", "x1": 0, "y1": 20,  "x2": 0, "y2": 30},
    {"type": "text", "x": 14, "y": -4, "text": "R"}
  ],
  "terminals": [{"x": 0, "y": -30, "orientation": "n", "name": "1"},
                {"x": 0, "y": 30,  "orientation": "s", "name": "2"}]}}

Then place it with qet_edit like any catalogue element. Unlike a project, a .elmt is not rewritten by QElectroTech on a round trip, so generating one here is safe in a way that generating a .qet would not be — there is no toXml() waiting to drop what this writer did not know to emit.

Ask a question the XML cannot answer

{"name": "qet_query", "arguments": {
  "binary": "/path/to/qelectrotech", "project": "industrial.qet",
  "sql": "SELECT label, COUNT(*) AS n FROM element_nomenclature_view WHERE label <> '' GROUP BY label HAVING n > 1 ORDER BY n DESC"}}
"rows": [{"label": "V6", "n": 7}, {"label": "V5", "n": 6}, {"label": "V4", "n": 6}]

Duplicate element labels in a shipped example — a design-rule question, answered by the database that already knew it. See The project database for what element_nomenclature_view, project_summary_view and wiring_list_view cover.

How much of a corpus uses a field?

{"name": "qet_scan",
 "arguments": {"directory": "examples", "tag": "conductor", "attribute": "cable"}}
{ "files": 24, "total": 3190, "non_empty": 0, "distinct_values": [] }

Across the shipped examples: 3190 conductors, not one with a cable value.


Notes and limits

  • qet_export isolates its launch. SingleApplication keys its socket on applicationFilePath(), so a second launch of the same binary path forwards its request to an already-running instance and returns that process's answer, with no error. The tool copies the binary to a unique temporary path, gives it a private HOME, and runs it on the offscreen platform. A symlink would not work — applicationFilePath() resolves it back to the real path.
  • The CLI matches its flags exactly. --export-bom out.csv is the supported form; --export-bom=out.csv is not recognised as an export at all, so the application starts its interface instead and a headless run hangs. The tool uses the positional form.
  • Conductor identity is the hard part of qet_diff. Conductors are keyed by owning element uuid plus terminal, which is stable across a save — the file's own folio-scoped integer ids are renumbered on every save and would make every conductor of an untouched folio read as removed and re-added. Where an element predates persisted uuids the end cannot be resolved and keeps a #-marked unstable key; the diff then reports unstable_keys instead of pretending to be comparable.
  • Texts, shapes and images have no uuid, so an edited text reads as the old one removed and a new one added, both shown. Shapes and images are keyed by position, so a restyle or rescale is reported as a change to that item, but a move reads as removal plus addition.
  • qet_edit needs a build whose scripting API carries the drawing verbs. Against an older one it reports exactly which methods are missing and changes nothing.
  • elements_dir is not optional for common:// paths. The sandboxed run has its own empty HOME, so QElectroTech falls back to the compiled-in collection path, which on a machine that never ran make install does not exist. The only symptom is add_element reporting that a file plainly present "does not resolve to an element". An absolute .elmt path works without it.
  • set_conductor changes the whole potential, not one segment — that is what the application does, since a wire number describes a potential. Name a terminal carrying exactly one conductor; a terminal several conductors meet at names none of them and is refused.
  • link_elements takes a folio for each end, because a master and its slave are normally on different folios. Whether a pair may be linked is decided by QElectroTech's own isLinkable(), so a script cannot make a link the GUI would refuse.
  • An element must live inside a collection to be placeable. An absolute .elmt path works, but only if the file sits under a directory QElectroTech knows as a collection — write it under the tree passed as elements_dir.
  • qet_element_build checks its size header against a containment constraint, not a formula: the declared box runs from (-hotspot_x, -hotspot_y) to (width - hotspot_x, height - hotspot_y) and the drawing must fit inside it. A drawing that escaped its box is the classic way a hand-written element renders clipped in the collection panel while looking fine in the XML.
  • QElectroTech interrupts a script at 30 s of its own accord, separate from the tool's own timeout. A very long operation list hits that first.
  • qet_edit never writes the input. It saves to a separate file and diffs the two, so the original is always the thing the diff is against.

Testing

python3 misc/qet-mcp/test_qet_mcp.py                      # unit + protocol, no QElectroTech needed

QET_BINARY=/path/to/qelectrotech \
QET_ELEMENTS=/path/to/qelectrotech/elements \
QET_EXAMPLES=/path/to/qelectrotech/examples \
QET_ENABLE_SCRIPTING=1 \
    python3 misc/qet-mcp/test_qet_mcp.py                  # everything, integration included

QET_ENABLE_SCRIPTING=1 matters here too: without it the integration tests that drive QElectroTech through a script all fail, and they fail as "the edit did nothing" rather than as "scripting is off" — which reads like a regression in the thing under test, not a missing switch.


See also