Table of Contents
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_exportisolates its launch. SingleApplication keys its socket onapplicationFilePath(), 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 privateHOME, 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.csvis the supported form;--export-bom=out.csvis 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 reportsunstable_keysinstead 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_editneeds a build whose scripting API carries the drawing verbs. Against an older one it reports exactly which methods are missing and changes nothing.elements_diris not optional forcommon://paths. The sandboxed run has its own emptyHOME, so QElectroTech falls back to the compiled-in collection path, which on a machine that never ranmake installdoes not exist. The only symptom isadd_elementreporting that a file plainly present "does not resolve to an element". An absolute.elmtpath works without it.set_conductorchanges 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_elementstakes 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 ownisLinkable(), so a script cannot make a link the GUI would refuse.- An element must live inside a collection to be placeable. An absolute
.elmtpath works, but only if the file sits under a directory QElectroTech knows as a collection — write it under the tree passed aselements_dir. qet_element_buildchecks 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_editnever 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
- JavaScript Scripting — the
qet.*engineqet_editandqet_querydrive underneath - The project database — what
qet_queryreads - CLI Reference — the export flags
qet_exportwraps - Automating QElectroTech — the file-format and headless-export ground this builds on
- PR #969 — the server itself
- PR #970 — the scripting-API verbs it depends on
Getting Started
🌐 Languages — English · Français · Deutsch
Guides
Conductors — wire properties, what feeds which export, and cables
Wire & cable catalogue — cable types, IEC 60757 core colours, assigning a core to a conductor
Printing and exporting — paper, PDF, images, and what each path does differently
Linking elements — master, slave, terminal
PLC modules — I/O tables and linking a wire to a specific point
Using the element editor — drawing tools, saving, checks
Grid size and element size — why symbols aren't all the same scale
Preferences reference — what each settings page does
Keyboard-only control — mouseless QET, and the one real gap
Mouse modifiers — what Shift, Ctrl and Alt change while you drag
Managing collections — folders, writability, building your own shortlist
Templates — reusable multi-element blocks, and why clicking one does nothing
Search & Replace — bulk property changes
Building a nomenclature query — the BOM/summary table builder
Linking wires across pages — folio reports
Variables & formulas — %f, %{label}, sequences
Auto-numbering — schemes, sequences, freezing
Terminal strips — strips, levels, bridges
Title block templates — the .titleblock format
Importing EPLAN parts (.edz) — EPLAN Data Portal
DXF import & export — two unrelated features, one format
The project database — the in-memory SQLite cache
Development
Automating QET — CLI, XML formats, external tools
CLI Reference — command line usage
JavaScript Scripting — --run, geometry editing, undo
MCP server — let an AI assistant read, verify and edit projects
Vision — proposal, under discussion
