Merge pull request #1136 from ispyisail/feature/qet-mcp-install

Install the MCP server with QElectroTech
This commit is contained in:
ispyisail
2026-09-29 18:52:36 +13:00
committed by GitHub
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
@@ -3240,12 +3240,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
@@ -3259,10 +3271,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")
@@ -3271,11 +3285,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
@@ -2562,6 +2562,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 LaunchExecutable(unittest.TestCase):
"""Windows cannot run a lone copy of QElectroTech (F065): its DLLs sit