mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-09-26 11:54:14 +02:00
Add MCP server page; drop stale pending notice from scripting.md (#891 merged)
+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
|
||||
|
||||
Reference in New Issue
Block a user