mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-10-05 10:34:13 +02:00
a73e5c462d
qet.checkContinuity() answers a folio index it has no folio for with an empty list, so qet_continuity returned "0 findings" for it -- the same answer as a clean folio. The index counts from 0 while qet_elements numbers folios from 1, so passing the last folio's number checked nothing and said so cleanly; any other folio's number checked the next folio instead. An index with no folio is now refused before QElectroTech is launched, with the valid range and the counting rule. Each finding also carries folio_number (counted from 1) beside the existing folio index. The qet_continuity and qet_conductors descriptions and the README say how each tool counts. Nothing changes for a valid index. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
369 lines
18 KiB
Markdown
369 lines
18 KiB
Markdown
# 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, 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_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
|
|
|
|
```bash
|
|
# list the tools and exit
|
|
misc/qet-mcp/qet_mcp.py --list
|
|
|
|
# speak MCP on stdin/stdout
|
|
misc/qet-mcp/qet_mcp.py
|
|
```
|
|
|
|
Register it with an MCP client, for example:
|
|
|
|
```json
|
|
{
|
|
"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"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Five tools need scripting switched on
|
|
|
|
A QElectroTech with JavaScript scripting switched off refuses `--run`, and
|
|
off is the default from
|
|
[#984](https://github.com/qelectrotech/qelectrotech-source-mirror/pull/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.
|
|
|
|
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.
|
|
|
|
`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?**
|
|
|
|
```json
|
|
{"name": "qet_diff", "arguments": {"before": "a.qet", "after": "b.qet"}}
|
|
```
|
|
|
|
```json
|
|
"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**
|
|
|
|
```json
|
|
{"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"`.
|
|
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:
|
|
|
|
```json
|
|
"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**
|
|
|
|
```json
|
|
{"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**
|
|
|
|
```json
|
|
{"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"}}
|
|
```
|
|
|
|
```json
|
|
"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?**
|
|
|
|
```json
|
|
{"name": "qet_scan",
|
|
"arguments": {"directory": "examples", "tag": "conductor", "attribute": "cable"}}
|
|
```
|
|
|
|
```json
|
|
{ "files": 24, "total": 3190, "non_empty": 0, "distinct_values": [] }
|
|
```
|
|
|
|
Across the shipped examples: 3190 conductors, not one with a cable value.
|
|
|
|
## Testing
|
|
|
|
```bash
|
|
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_edit` and `qet_continuity` — take an
|
|
index counted from 0, so the folio `qet_elements` calls 1 is `0` there.
|
|
`qet_continuity` refuses an index with no folio instead of reporting it
|
|
clean, and each of its findings carries both `folio` (the index) and
|
|
`folio_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. Call `qet_query` with no `sql` to
|
|
list both. Only `SELECT` and `WITH` are 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_count` 0 with no
|
|
`error` means 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 a
|
|
`description`: the shape type or the text). The uuid is the one saved in
|
|
the file, and every `qet_edit` op that takes a text, shape or image
|
|
`index` also takes that uuid, which does not shift the way an index
|
|
does. `qet_element_build` gives every part of a symbol a uuid as well,
|
|
returned in `part_uuids`; `qet_element_info` lists them in `part_list`.
|
|
- **`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`.** A conductor names
|
|
its ends with `terminal1`/`terminal2`, and the project format has two
|
|
schemes: folio-scoped integer ids in older files, terminal-definition
|
|
uuids plus `element1`/`element2` in 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 what `qet_edit` produces. 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 reports
|
|
`unstable_keys` and 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 folio `version` attribute 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_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. `addElement` and the move/delete verbs shipped with the
|
|
scripting API; `addConductor`, `rotateElement`, `setElementLabel`,
|
|
`setElementInfo` and `setFolioTitle` are newer.
|
|
- **`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 `addElement` 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 — 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, so `qet_conductors` reads
|
|
back exactly what was set.
|
|
- **`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.** This is
|
|
not about the path syntax: an absolute `.elmt` path works, but only if
|
|
the file sits under a directory QElectroTech knows as a collection.
|
|
Write it under the tree you pass as `elements_dir` and `qet_edit` can
|
|
place it, by absolute path or as `common://…`; write it anywhere else
|
|
and `add_element` reports only "does not resolve to an element".
|
|
- **`qet_element_build` computes the `.elmt` size header, and checks it.**
|
|
`width`/`height`/`hotspot_x`/`hotspot_y` relate 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_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.
|