From 15c114edd539845b00c50edcc633bdcf878b0d2b Mon Sep 17 00:00:00 2001 From: ispyisail Date: Fri, 2 Oct 2026 13:02:26 +1300 Subject: [PATCH] qet-mcp: read qet-assistant.json; qet_about; a note at first contact QElectroTech now writes qet-assistant.json with its folders, features, script calls and stored scripts. The server reads it to find the scripts folder -- right even when QElectroTech runs with --data-dir, where the per-platform guess was wrong -- and qet_about shows it, never the live token. The initialize reply now carries instructions: what QElectroTech is, headless and live, the usual order of tools, start with qet_about. Co-Authored-By: Claude Opus 5.5 (1M context) --- misc/qet-mcp/README.md | 16 +++++ misc/qet-mcp/qet_mcp.py | 109 ++++++++++++++++++++++++++++++++++- misc/qet-mcp/test_qet_mcp.py | 60 ++++++++++++++++++- 3 files changed, 182 insertions(+), 3 deletions(-) diff --git a/misc/qet-mcp/README.md b/misc/qet-mcp/README.md index 546014f1e..a0c442407 100644 --- a/misc/qet-mcp/README.md +++ b/misc/qet-mcp/README.md @@ -41,6 +41,7 @@ here read the model. | `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_about` | **start here** — where QElectroTech keeps things, what is switched on, the stored scripts, the calls a script can make (from `qet-assistant.json`) | | `qet_script_api` | **what a script can call** — every `qet.*` call of this build, and the header that makes a script a button | | `qet_script_test` | **try a script** on a copy of a project: what it would change, what it logged, its errors | | `qet_script_install` | **make a button** — store a script (and an SVG icon) where QElectroTech shows it in Projet > Scripts and the Scripts toolbar | @@ -251,6 +252,21 @@ 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. +## What QElectroTech tells the server: `qet-assistant.json` + +Each time an editor window opens, and whenever its stored scripts, +settings or live channel change, QElectroTech writes `qet-assistant.json` +in its standard data folder (`~/.local/share/QElectroTech/QElectroTech/` +on Linux, `%APPDATA%\QElectroTech\QElectroTech\` on Windows). It names +every folder actually in use, even when QElectroTech was started with +`--data-dir`, which features are on, every call a script can make, and the +stored scripts. The server reads it instead of guessing; `qet_about` shows +it. Set `QET_MCP_INFO_FILE` to read it from somewhere else. + +The server also sends the assistant a short note at first contact: the two +ways of working (files, or live), the usual order of tools, and to start +with `qet_about`. + ## Script buttons QElectroTech turns every `.js` file in its scripts folder that starts with a diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index 0e994560a..965edaf2a 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -2611,18 +2611,110 @@ def default_scripts_dir(os_name: str, platform: str, env, home) -> PurePath: return base / "QElectroTech" / "QElectroTech" / "scripts" +def assistant_info_file() -> Path: + """Where QElectroTech writes qet-assistant.json. + + QET_MCP_INFO_FILE if set; next to a QET_MCP_SCRIPTS_DIR if that is set + (one folder up, as in QElectroTech's own layout); else the platform's + standard data folder, where QElectroTech always writes it, even when + --data-dir moves the rest. + """ + explicit = os.environ.get("QET_MCP_INFO_FILE", "").strip() + if explicit: + return Path(explicit).expanduser() + scripts = os.environ.get("QET_MCP_SCRIPTS_DIR", "").strip() + if scripts: + return Path(scripts).expanduser().parent / "qet-assistant.json" + default = default_scripts_dir(os.name, sys.platform, os.environ, str(Path.home())) + return Path(str(default.parent)) / "qet-assistant.json" + + +def assistant_info() -> dict | None: + """qet-assistant.json as QElectroTech last wrote it, or None. + + QElectroTech rewrites it whenever an editor opens and whenever its + stored scripts, settings or live channel change: its folders, features, + the calls a script can make, the stored scripts, and the live channel + while one is open. + """ + path = assistant_info_file() + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + + def scripts_dir() -> Path: """The folder QElectroTech reads stored scripts from. - QET_MCP_SCRIPTS_DIR overrides it, for a QElectroTech started with - --data-dir or a test. + QET_MCP_SCRIPTS_DIR first (a test, or a deliberate choice); then the + folder qet-assistant.json names; else the platform's default. """ env = os.environ.get("QET_MCP_SCRIPTS_DIR", "").strip() if env: return Path(env).expanduser() + info = assistant_info() or {} + named = (info.get("folders") or {}).get("scripts") + if isinstance(named, str) and named: + return Path(named) return Path(str(default_scripts_dir(os.name, sys.platform, os.environ, str(Path.home())))) +SERVER_INSTRUCTIONS = ( + "This server works with QElectroTech, a free editor for electrical " + "diagrams. A project (.qet) holds folios (sheets) of symbols " + "(elements) joined by wires (conductors). Folio indexes count from 0.\n" + "Start with qet_about: where QElectroTech keeps things, what is " + "switched on, and the stored scripts.\n" + "Two ways of working. HEADLESS (qet_* and qet_script_*): read, check " + "and edit .qet files and store script buttons; nothing the user has " + "open is touched. To make a button: qet_script_api for the calls, " + "qet_script_test on a copy until the diff is right, then " + "qet_script_install with test_project set. LIVE (qet_live_*): act on " + "the project open in the user's QElectroTech while they watch; only " + "when they switched live mode on and accepted its warning at this " + "start, each action one undo step, scripts written on the spot shown " + "to them first.\n" + "Verify edits by reading the result (qet_diff, qet_elements), not by " + "assuming them.") + + +def tool_about() -> dict: + """What this server and the QElectroTech it works with look like now.""" + info = assistant_info() + binary = resolve_binary() + out = { + "server": { + "version": SERVER_VERSION, + "qelectrotech_binary": str(binary) if binary else None, + "workspace": [str(r) for r in workspace_roots()] or "any path (QET_MCP_ALLOW_ANY_PATH=1)", + "scripting_allowed_here": os.environ.get("QET_ENABLE_SCRIPTING") == "1", + }, + "info_file": str(assistant_info_file()), + "scripts_folder": str(scripts_dir()), + } + if info is None: + out["found"] = False + out["note"] = ("QElectroTech writes this file when an editor window opens; " + "start it once (a version with script buttons) to fill it in. " + "Until then folders are this server's own guess.") + return out + live = info.get("live") + out.update({ + "found": True, + "qelectrotech": {k: info.get(k) for k in + ("qelectrotech_version", "program", "running", "written")}, + "folders": info.get("folders"), + "features": info.get("features"), + "stored_scripts": info.get("stored_scripts"), + "refused_scripts": info.get("refused_scripts"), + "script_api": info.get("script_api"), + # Never the token: it is for the live tools, not the conversation. + "live": {"open": bool(live), "pid": (live or {}).get("pid")}, + }) + return out + + def parse_script_header(text: str, script_id: str) -> dict: """The same rules as QElectroTech's ScriptHeader::parse(). @@ -3597,6 +3689,18 @@ TOOLS = [ }, "handler": lambda a: tool_script_remove(a["id"]), }, + { + "name": "qet_about", + "description": "Start here. What QElectroTech last wrote about itself in " + "qet-assistant.json: version, every folder (data, settings, " + "scripts, element and title block collections), which " + "features are on (scripting, live mode), every call a script " + "can make, the stored scripts and the ones refused with why, " + "and whether a live session is open; plus this server's own " + "setup. Reads one file; changes nothing.", + "inputSchema": {"type": "object", "properties": {}}, + "handler": lambda a: tool_about(), + }, ] _BY_NAME = {t["name"]: t for t in TOOLS} @@ -3898,6 +4002,7 @@ def handle(msg: dict) -> dict | None: "protocolVersion": want or DEFAULT_PROTOCOL, "capabilities": {"tools": {}}, "serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION}, + "instructions": SERVER_INSTRUCTIONS, }) if method in ("notifications/initialized", "initialized"): diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index ebbe49362..882fdf60c 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -166,7 +166,7 @@ class ToolRegistry(unittest.TestCase): "qet_project_new", "qet_element_search", "qet_check", "qet_element_build", "qet_continuity", "qet_items", "qet_script_api", "qet_script_test", "qet_script_install", "qet_script_list", "qet_script_read", - "qet_script_remove"}) + "qet_script_remove", "qet_about"}) class EditValidation(unittest.TestCase): @@ -2996,6 +2996,64 @@ class ScriptToolsIntegration(unittest.TestCase): self.assertTrue((self.dir / "good.js").is_file()) +class AssistantInfoFile(unittest.TestCase): + """qet-assistant.json: QElectroTech says where things are; the server + believes it over its own per-platform guess.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + self.info = self.root / "info" / "qet-assistant.json" + self.info.parent.mkdir() + self.env = mock.patch.dict(os.environ, {"QET_MCP_INFO_FILE": str(self.info), + "QET_MCP_SCRIPTS_DIR": ""}) + self.env.start() + + def tearDown(self): + self.env.stop() + self.tmp.cleanup() + + def write(self, **extra): + data = {"format": 1, "qelectrotech_version": "0.200.1", "running": True, + "folders": {"scripts": str(self.root / "moved" / "scripts")}, + "features": {"scripting_enabled": True, "live_mode_setting": False}, + "script_api": ["int currentFolio()"], "stored_scripts": [], + "refused_scripts": [], "live": None} + data.update(extra) + self.info.write_text(json.dumps(data)) + + def test_scripts_folder_comes_from_the_file(self): + """QElectroTech started with --data-dir keeps its scripts elsewhere; + only the file knows.""" + self.write() + self.assertEqual(m.scripts_dir(), self.root / "moved" / "scripts") + with mock.patch.dict(os.environ, {"QET_MCP_SCRIPTS_DIR": "/explicit"}): + self.assertEqual(m.scripts_dir(), Path("/explicit")) + + def test_without_the_file_the_guess_stands(self): + about = m.tool_about() + self.assertFalse(about["found"]) + self.assertIn("start it once", about["note"]) + + def test_about_never_shows_the_live_token(self): + self.write(live={"socket": "s", "token": "SECRET", "pid": 7}) + about = m.tool_about() + self.assertTrue(about["found"]) + self.assertEqual(about["live"], {"open": True, "pid": 7}) + self.assertNotIn("SECRET", json.dumps(about)) + self.assertEqual(about["script_api"], ["int currentFolio()"]) + + def test_first_contact_carries_the_instructions(self): + reply = m.handle({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}) + self.assertIn("qet_about", reply["result"]["instructions"]) + + def test_default_location_is_next_to_the_default_scripts_folder(self): + with mock.patch.dict(os.environ, {"QET_MCP_INFO_FILE": "", "XDG_DATA_HOME": "/x"}), \ + mock.patch.object(m.os, "name", "posix"), mock.patch.object(m.sys, "platform", "linux"): + self.assertEqual(m.assistant_info_file(), + Path("/x/QElectroTech/QElectroTech/qet-assistant.json")) + + class PathPolicyOverStdio(unittest.TestCase): """Proves the policy is actually wired into the dispatcher.