Install the MCP server with QElectroTech

The MCP server (misc/qet-mcp) was only reachable from a source checkout.
It now ships with every package, next to the program, and finds that
QElectroTech and its element collection from where it sits:

- make install (Linux distributions, snap, flatpak):
  <prefix>/share/qelectrotech/mcp/qet_mcp.py, executable, with its README.
- Windows installer: a new "AI assistant (MCP)" component (on by default,
  ~200 KB), installed to <folder>\mcp. The workflow stages files/mcp, so
  the portable folder and the MSI, which packs all of files/, carry it too.

The server learns the Windows layout (<root>/mcp beside <root>/bin, whose
program is QElectroTech.exe, and <root>/elements), in addition to
<prefix>/share/qelectrotech/mcp. A copy saved anywhere else, even beside
some bin/ folder, is not taken for an installation.

The installer strings are given in all 29 installer languages: French
translated, the others in English until translated, which is what NSIS
would fall back to anyway but without its warning 6040 per language.

Checked: make install into a scratch prefix, then from the installed
script with nothing configured and no qelectrotech on PATH, an SVG export
and a qet_edit placing a common:// element both succeeded. makensis 3.10
compiles the installer with 0 warnings before and after (removing the
French description gives warning 6040), and the installer contains
mcp/qet_mcp.py. Suite 274/274 none skipped; each layout check was
removed in turn and a test failed. Snap/flatpak: installed by the same
CMake rule; launching their QElectroTech from outside is untested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ispyisail
2026-09-29 12:55:10 +13:00
parent 124b7e4f6e
commit ac952d3df6
9 changed files with 159 additions and 15 deletions
+22
View File
@@ -82,6 +82,28 @@ Register it with an MCP client, for example:
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, for example on Windows:
```json
"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
+26 -14
View File
@@ -3189,12 +3189,24 @@ def _env_paths(name: str) -> list:
return out
def _installed_prefix() -> Path | None:
"""The install prefix when this script is <prefix>/share/qelectrotech/mcp/."""
def _installation() -> tuple | None:
"""(program directory, element collection) of the QElectroTech this
script was installed with, or None when it runs from anywhere else.
Two layouts, both put there by QElectroTech's own packaging:
<prefix>/share/qelectrotech/mcp/ -> <prefix>/bin, <prefix>/share/qelectrotech/elements
(make install: Linux, snap, flatpak, macOS)
<root>/mcp/ -> <root>/bin, <root>/elements
(the Windows installers and portable folder)
"""
here = Path(__file__).resolve().parent
if here.name == "mcp" and here.parent.name == "qelectrotech" \
and here.parent.parent.name == "share":
return here.parent.parent.parent
if here.name != "mcp":
return None
if here.parent.name == "qelectrotech" and here.parent.parent.name == "share":
prefix = here.parent.parent.parent
return prefix / "bin", here.parent / "elements"
if (here.parent / "bin").is_dir():
return here.parent / "bin", here.parent / "elements"
return None
@@ -3208,10 +3220,12 @@ def resolve_binary() -> Path | None:
env = os.environ.get("QET_BINARY", "").strip()
if env:
return Path(env).expanduser().resolve()
prefix = _installed_prefix()
if prefix is not None:
for name in ("qelectrotech", "qelectrotech.exe"):
cand = prefix / "bin" / name
install = _installation()
if install is not None:
# The Windows build names it QElectroTech.exe; only a case-sensitive
# file system tells the spellings apart.
for name in ("qelectrotech", "qelectrotech.exe", "QElectroTech.exe"):
cand = install[0] / name
if cand.is_file():
return cand.resolve()
found = shutil.which("qelectrotech")
@@ -3220,11 +3234,9 @@ def resolve_binary() -> Path | None:
def default_elements_dir() -> Path | None:
"""The element collection of the install this script ships in, if any."""
prefix = _installed_prefix()
if prefix is not None:
coll = prefix / "share" / "qelectrotech" / "elements"
if coll.is_dir():
return coll.resolve()
install = _installation()
if install is not None and install[1].is_dir():
return install[1].resolve()
return None
+37
View File
@@ -2021,6 +2021,43 @@ class BinaryPolicy(unittest.TestCase):
self.assertEqual(args["elements_dir"],
str((prefix / "share" / "qelectrotech" / "elements").resolve()))
def installed_at(self, root: Path, mcp: Path):
"""Import a copy of the server placed at mcp/qet_mcp.py."""
import importlib.util
mcp.mkdir(parents=True)
shutil.copy2(HERE / "qet_mcp.py", mcp / "qet_mcp.py")
spec = importlib.util.spec_from_file_location(f"qet_mcp_{root.name}", mcp / "qet_mcp.py")
inst = importlib.util.module_from_spec(spec)
spec.loader.exec_module(inst)
return inst
def test_the_windows_layout_is_found_too(self):
"""The Windows installers and the portable folder put the server in
<root>/mcp, the program in <root>/bin as QElectroTech.exe, and the
collection in <root>/elements."""
root = Path(self.tmp.name) / "QElectroTech"
(root / "elements").mkdir(parents=True)
exe = fake_qet(root / "bin", "QElectroTech.exe")
inst = self.installed_at(root, root / "mcp")
del os.environ["QET_BINARY"]
args = {"project": str(self.root / "ok.qet")}
inst.enforce_path_policy("qet_query", args)
self.assertEqual(args["binary"], str(exe.resolve()))
self.assertEqual(args["elements_dir"], str((root / "elements").resolve()))
def test_a_copy_saved_anywhere_is_not_an_install(self):
"""A downloaded copy sitting beside some bin/ folder must not go
looking for QElectroTech there."""
home = Path(self.tmp.name) / "home"
fake_qet(home / "bin")
inst = self.installed_at(home, home / "Downloads")
self.assertIsNone(inst._installation())
def test_a_folder_named_mcp_is_not_an_install(self):
"""Without a bin/ beside it, an mcp/ folder is just a folder."""
inst = self.installed_at(Path(self.tmp.name) / "x", Path(self.tmp.name) / "x" / "mcp")
self.assertIsNone(inst._installation())
class ScriptingDisabledHint(unittest.TestCase):
"""QElectroTech may refuse to run scripts at all, and says so in French.