Offer Python with the Windows installer, for the MCP server
qet-mcp — a Model Context Protocol server for QElectroTech projects
A small stdio MCP server that lets an AI assistant read and verify QElectroTech projects: what is in a project, what an edit actually changed, and what a whole corpus of projects contains.
It has no third-party dependencies — Python 3.9+ and the standard library only. The MCP SDK is not required.
Why
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.
Tools
| Tool | What it answers |
|---|---|
qet_project_info |
title, format version, folios with their uuids, 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 |
qet_export |
run a headless export (pdf, png, svg, dxf, bom, cables, wires, wiring, nets, links, info) |
qet_edit |
change a project — place, move, rotate, label, wire, number, cross-reference, add text, shapes and images, restyle a symbol's text fields, delete; then diff the result |
qet_element_build |
author a .elmt — draw a new symbol, with terminals to wire it by |
qet_project_new |
start from nothing — an empty project with a title and folios |
qet_element_search |
find a symbol in a collection by name (any language), type or terminal count |
qet_check |
design-rule checks — duplicate labels, unlabelled masters, unnumbered conductors, empty folios |
qet_query |
ask the project database — read-only SQL over the views and tables |
qet_export and qet_edit launch QElectroTech. Everything else parses the
file directly, which is faster, needs no display, and cannot be confused by
a dialog.
Running it
# list the tools and exit
misc/qet-mcp/qet_mcp.py --list
# speak MCP on stdin/stdout
misc/qet-mcp/qet_mcp.py
# run one tool and exit, no MCP client needed
misc/qet-mcp/qet_mcp.py --call qet_project_info '{"path": "drawing.qet"}'
Register it with an MCP client, for example:
{
"mcpServers": {
"qet": {
"command": "python3",
"args": ["/path/to/qelectrotech/misc/qet-mcp/qet_mcp.py"],
"env": {
"QET_MCP_WORKSPACE": "/home/you/drawings",
"QET_BINARY": "/usr/bin/qelectrotech",
"QET_ENABLE_SCRIPTING": "1"
}
}
}
}
QET_BINARY is the QElectroTech the tools launch. Leave it out when
qelectrotech is on your PATH, or when the server is installed with
QElectroTech (as <prefix>/share/qelectrotech/mcp/qet_mcp.py, which also
finds the installed element collection).
Installed with QElectroTech
QElectroTech's packages install the server next to the program, and from there it finds that QElectroTech and its element collection by itself:
| Package | Server | Finds |
|---|---|---|
make install, Linux distributions |
<prefix>/share/qelectrotech/mcp/qet_mcp.py |
<prefix>/bin/qelectrotech, <prefix>/share/qelectrotech/elements |
| Windows installer, MSI, portable folder | <folder>\mcp\qet_mcp.py (the "AI assistant (MCP)" component) |
<folder>\bin\QElectroTech.exe, <folder>\elements |
So a client configuration needs only the path to the server and the
workspace. On Windows, the installer also offers Python for the AI
assistant (unticked by default; always in the portable folder and the
MSI): Python from python.org in <folder>\mcp\python, for anyone without
a Python of their own. Then:
"command": "C:\\Program Files\\QElectroTech\\mcp\\python\\python.exe",
"args": ["C:\\Program Files\\QElectroTech\\mcp\\qet_mcp.py"]
Snap and flatpak install it too. Their QElectroTech is built to run inside
the package's sandbox, and starting it from the server has not been tested:
the tools that read a file work, exports and edits may not. If they fail,
point QET_BINARY at a QElectroTech installed another way.
Using it from the Claude app
A web chat in a browser cannot start a program on your computer, so it cannot run this server. The Claude desktop app for Windows and macOS can, and it uses the same account as the website.
-
Install Python 3.9 or later. Nothing else is needed.
-
In the desktop app, open Settings → Developer → Edit Config. This opens
claude_desktop_config.json. -
Add the server, with your own paths:
{ "mcpServers": { "qet": { "command": "python", "args": ["C:\\path\\to\\qelectrotech\\misc\\qet-mcp\\qet_mcp.py"], "env": { "QET_MCP_WORKSPACE": "C:\\Users\\you\\Documents\\drawings", "QET_ENABLE_SCRIPTING": "1" } } } }On macOS use
python3and ordinary/paths. In JSON every\in a Windows path is written\\. -
Quit the app completely and start it again. The tools appear under the chat box's tools menu.
-
If
qelectrotechis not on yourPATH, add"QET_BINARY"to theenvblock with the full path to the executable (C:\\Program Files\\...\\qelectrotech.exe, written with\\).
Only files under QET_MCP_WORKSPACE can be read or written (see
What the server is allowed to touch).
Leave out QET_ENABLE_SCRIPTING if you do not want the assistant to edit
projects; see the next section for what that switches off.
The test suite runs on Linux. The server uses nothing platform-specific, but it has not yet been tested on Windows or macOS.
With only a browser
If your web chat can run Python (on claude.ai, code execution), upload
qet_mcp.py together with your project and ask the assistant to use
--call:
python3 qet_mcp.py --call qet_elements '{"path": "drawing.qet"}'
echo '{"path": "drawing.qet"}' | python3 qet_mcp.py --call qet_check -
It prints the tool's JSON result and exits 0, or 1 if the tool reported an
error, or 2 if the call itself was malformed. The workspace rule applies as
it does in a server. The sandbox has no QElectroTech in it, so only the
tools that read files work there: qet_project_info, qet_elements,
qet_conductors, qet_items, qet_diff, qet_scan,
qet_element_info, qet_element_search and qet_element_build.
Five tools need scripting switched on
A QElectroTech with JavaScript scripting switched off refuses --run, and
off is the default from
#984
onwards. Five tools here drive it that way and stop working until it is
turned on:
need QET_ENABLE_SCRIPTING=1 |
qet_query, qet_continuity, qet_check, qet_project_new, qet_edit |
| unaffected | everything else — they read the .qet directly, or, in qet_export's case, use a plain CLI flag |
The variable goes in the environment this server is started in, which for an
MCP client is the env block above; the server passes its environment
straight through to QElectroTech. It does not set the variable itself, on
purpose — a switch a program turns on for itself is not a switch. Whoever
configured this server and pointed it at a QElectroTech binary made that
choice, and their interactive QElectroTech keeps whatever its own setting
says.
Without it, those five come back "ok": false with a hint naming the
variable. Older builds, from before the setting existed, need nothing.
What the server is allowed to touch
Every path in a tool call is chosen by the model, so without a policy this server would be a read/write primitive for anything the operating system lets the process reach: read any project on the disk, export one somewhere else, overwrite an unrelated file, embed an arbitrary local image or PDF.
So data paths are confined to a workspace:
QET_MCP_WORKSPACE |
the directories tool calls may read and write, separated by : (; on Windows) |
| unset | the directory the server was started in |
QET_MCP_ALLOW_ANY_PATH=1 |
turns the check off entirely |
Set the workspace to the folder your drawings live in. 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.
The client does not choose what program runs. The tools that launch
QElectroTech use the one the server found (QET_BINARY, the install it
ships in, or PATH). A call may still name binary, but only as that same
file or one listed by whoever configured the server:
QET_BINARY |
the QElectroTech to launch |
QET_MCP_BINARIES |
other executables a call may name, separated like QET_MCP_WORKSPACE (for comparing two builds) |
QET_MCP_ALLOW_ANY_BINARY=1 |
turns the check off: a call can then run any program |
QET_MCP_ELEMENTS |
element collections a call may name as elements_dir besides the workspace and the installed one |
Anything else is refused, even a file inside the workspace: being there makes it readable, not runnable. Before this rule any executable a call named was run, with the call's own paths as arguments, so text inside a project could steer an assistant into starting another program.
QET_MCP_ALLOW_ANY_PATH=1 is equivalent to granting the client local
filesystem access with this process's privileges. It exists so that is a
deliberate choice rather than the default.
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. Replacing a file is the
one step this server cannot undo, so it is the one step it will not take on
its own.
The confinement is applied where tool arguments enter the server, not inside
each tool. Importing qet_mcp and calling tool_export() from your own
Python is not confined and is not meant to be — that is your code calling a
library, and you already chose the paths.
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": {
"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".
A "folio" given as a number counts from 0, while qet_elements and
qet_project_info number folios from 1 as the application does: the folio
they call 1 is "folio": 0 here. An op that fails because of this says which
index to use. 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 answer carries a per-operation result
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": {
"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.
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.
Testing
python3 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 test_qet_mcp.py # everything
QET_ENABLE_SCRIPTING=1 matters from #984 onwards: 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.
176 tests in three layers: unit (validation, script generation, the terminal
order rule, the diff, the part schema), the real stdio transport, and
integration against a built QElectroTech. Several exist because the
behaviour they pin was once wrong and looked right, and say so in their
docstrings. To check the suite itself rather than trust it, each of those
bugs was reintroduced in turn and the suite confirmed to fail: ten in the
Python, plus the hang guard on addConductor and the database refresh in
ConductorCreator in the C++.
Notes and limits
- Two ways of numbering folios. Tools that read the file —
qet_project_info,qet_elements,qet_conductors,qet_diff— number folios from 1, as the application does. Tools that pass a folio to QElectroTech's scripting API —qet_editandqet_continuity— take an index counted from 0, so the folioqet_elementscalls 1 is0there.qet_continuityrefuses an index with no folio instead of reporting it clean, and each of its findings carries bothfolio(the index) andfolio_number(counted from 1). - The project database is reachable now, through
qet_query. It was not when this server was written, which is why every other structural tool here re-derives its answer from the XML. Prefer the views —element_nomenclature_view,project_summary_view,wiring_list_view— which exist to be queried; the underlying tables are how the cache is arranged today and a column may move. Callqet_querywith nosqlto list both. OnlySELECTandWITHare accepted, which is the rule QElectroTech applies to its own custom-query box, not one invented here. An empty result and a failed query are told apart:row_count0 with noerrormeans nothing matched, and a typo'd column name says so. - Texts, shapes and images are in the database too, one row each in
drawing_item_view(uuid,kind,folio, position and size, and adescription: the shape type or the text). The uuid is the one saved in the file, and everyqet_editop that takes a text, shape or imageindexalso takes that uuid, which does not shift the way an index does.qet_element_buildgives every part of a symbol a uuid as well, returned inpart_uuids;qet_element_infolists them inpart_list. - A folio can be named by its uuid wherever an op takes
folioorto_folio;qet_project_infolists each folio's. It still names the same folio after an earlier op in the run adds, inserts or removes one, where an index would shift. A folio saved without a uuid shows it empty: QElectroTech gives it one on load and writes it on the next save, so it appears after a firstqet_edit. Needsqet.folioIndex()in the build. The"$id"of anadd_folioorinsert_folioworks the same way: it keeps naming that folio after a laterinsert_folioorremove_folioin the same run (on a build withoutqet.folioUuid(), it is the index the folio had when it was made, as before). - A conductor can be named by its uuid (
qet_conductorsreports it):set_conductor,move_conductor_segmentanddelete_conductortake"conductor": "{uuid}"in place ofelement+terminal, which works where two conductors meet at a terminal. It is turned at run time into an end whose terminal carries only that conductor; where both of its ends are shared the op fails and itsnotesays why. Needsqet.conductorEnds()in the build. A uuid names one wire, butset_conductorstill changes the whole potential, the same as by terminal. A project saved before conductors carried a uuid has none in the file until it is saved once: since #1107 QElectroTech works one out from the wire's two ends on load and writes it on the next save, so it appears after a firstqet_edit. - A terminal can be named by its uuid:
terminal,from_terminalandto_terminaltake the terminal's uuid (asqet_element_infolists it) in place of its index, on the op's own element (foradd_conductor, on that end's element). It is turned at run time into the index the call takes; if the element has no terminal with it the op fails and itsnotesays so. Unlike the index, which is a sort by position, it is defined between two terminals at the same point. Needsqet.terminalIndex()in the build. A symbol file saved without terminal uuids lists them empty; QElectroTech gives the terminals of every project's copy of it a uuid on opening (#1118), written on the next save. 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. A conductor names its ends withterminal1/terminal2, and the project format has two schemes: folio-scoped integer ids in older files, terminal-definition uuids pluselement1/element2in newer ones. The integer ids are renumbered on every save, so keying on them — which this tool did at first — made all 47 conductors of an untouched folio read as removed and re-added the moment the other side had been through QElectroTech, which is exactly whatqet_editproduces. They are now keyed by owning element uuid plus terminal, which is stable across a save: measured at 0 colliding keys over 3190 conductors in the 24 shipped examples, and 0 churn on a no-op edit. Where an element predates persisted uuids the end cannot be resolved and keeps a#-marked unstable key; the diff then reportsunstable_keysand says so rather than pretending to be comparable. - Texts, shapes and images are keyed by uuid when every one on both sides
has one, so an edit or a move reads as a change to that item. A file saved
before they carried a uuid has none; for such a pair (including a legacy
file against its first re-save) that kind falls back to position, where an
edited text reads as removed plus added and a move as a removal plus an
addition. Each section says which it used in
keyed_by. The folioversionattribute is left out of the comparison on purpose: QElectroTech rewrites it on every save, and including it made every folio of any re-saved project look edited. - Elements written before persisted uuids fall back to a positional key, which makes a move in such a file read as a remove plus an add.
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.addElementand the move/delete verbs shipped with the scripting API;addConductor,rotateElement,setElementLabel,setElementInfoandsetFolioTitleare newer.elements_diris not optional forcommon://paths unless the server is installed with QElectroTech, which fills it in. The sandboxed run has its own empty HOME, so QElectroTech falls back to the compiled-in collection path, which on a machine that never ranmake installdoes not exist. The only symptom isaddElementreporting 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 — a wire number describes a potential — so name a terminal carrying exactly one conductor and the change reaches every conductor electrically joined to it. A terminal several conductors meet at names none of them and is refused, so address a potential from one of its leaves. Property names are the file's own, soqet_conductorsreads back exactly what was set.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. This is
not about the path syntax: an absolute
.elmtpath works, but only if the file sits under a directory QElectroTech knows as a collection. Write it under the tree you pass aselements_dirandqet_editcan place it, by absolute path or ascommon://…; write it anywhere else andadd_elementreports only "does not resolve to an element". qet_element_buildcomputes the.elmtsize header, and checks it.width/height/hotspot_x/hotspot_yrelate to the drawing by 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. The shipped collection shows authors picking their own margins (one element pads 2 units left and 3 right, another 8 and 2), so there is no convention to copy, only an invariant to satisfy. A drawing that escaped its box is the classic way a hand-written element renders clipped in the collection panel while looking fine in XML.- QElectroTech interrupts a script at 30 s of its own accord, separately
from this tool's
timeout. A very long operation list will hit 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.