Add MCP server page; drop stale pending notice from scripting.md (#891 merged)

ispyisail
2026-09-24 12:52:01 +12:00
parent 361ba73aa6
commit 8e0fbcbe88
3 changed files with 336 additions and 6 deletions
+2
@@ -137,6 +137,8 @@
**[JavaScript Scripting](scripting)** — `--run`, geometry editing, undo
**[MCP server](mcp_server)** — let an AI assistant read, verify and edit projects
**[Development Roadmap](development_roadmap)**
**[Vision](vision)** — _proposal, under discussion_
+333
@@ -0,0 +1,333 @@
# 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](https://modelcontextprotocol.io/) server, Python
3.9+ and the standard library only, no MCP SDK dependency. Added in
[PR #969](https://github.com/qelectrotech/qelectrotech-source-mirror/pull/969);
its scripting-API foundation landed alongside in
[PR #970](https://github.com/qelectrotech/qelectrotech-source-mirror/pull/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](scripting)** for the engine `qet_edit` and
`qet_query` drive underneath.
---
## Installation
```bash
# 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:
```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"
}
}
}
}
```
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](https://github.com/qelectrotech/qelectrotech-source-mirror/pull/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?
```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"`.
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:
```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. See
**[The project database](project_database)** for what `element_nomenclature_view`,
`project_summary_view` and `wiring_list_view` cover.
### 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.
---
## 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
```bash
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](scripting)** — the `qet.*` engine `qet_edit` and
`qet_query` drive underneath
- **[The project database](project_database)** — what `qet_query` reads
- **[CLI Reference](cli_reference)** — the export flags `qet_export` wraps
- **[Automating QElectroTech](api_reference)** — the file-format and
headless-export ground this builds on
- [PR #969](https://github.com/qelectrotech/qelectrotech-source-mirror/pull/969) — the server itself
- [PR #970](https://github.com/qelectrotech/qelectrotech-source-mirror/pull/970) — the scripting-API verbs it depends on
+1 -6
@@ -1,11 +1,5 @@
# JavaScript Scripting
> **Status: pending.** Everything on this page describes
> [PR #891](https://github.com/qelectrotech/qelectrotech-source-mirror/pull/891),
> not yet merged. Nothing here works until that lands — check the PR before
> trying any of this against your own build. This page will drop this notice
> once it does.
Read a project's model, export it, and edit its geometry from a script —
headless for CI, or interactively against the diagram you have open.
@@ -275,5 +269,6 @@ change of mind shows up in one obvious place:
- **[CLI Reference](cli_reference)** — the export flags this feature wraps
- **[Automating QElectroTech](api_reference)** — the file-format and headless
export ground this builds on
- **[MCP server](mcp_server)** — an AI assistant driving this engine through `qet_edit`/`qet_query`
- [PR #891](https://github.com/qelectrotech/qelectrotech-source-mirror/pull/891) — implementation, with the exact tests run against it
- [Issue #162](https://github.com/qelectrotech/qelectrotech-source-mirror/issues/162) — the original request and design discussion