From 55eac848df66f3e05730358c7cc4afae1ce9d58e Mon Sep 17 00:00:00 2001 From: ispyisail Date: Fri, 2 Oct 2026 09:13:05 +1300 Subject: [PATCH 1/3] qet-mcp: tools to write, test and store scripts that QElectroTech shows as buttons qet_script_api lists every qet.* call, asked of the QElectroTech that will run it (qet.apiSignatures() where the build has it, names otherwise), with the header format that makes a script a button. qet_script_test runs a script's text on a copy of a project and returns the qet_diff of what it changed, its log and its errors with their line. qet_script_install stores a script, and optionally an SVG icon, in the scripts folder QElectroTech reads; with test_project it tests first and stores nothing if the test fails. qet_script_list, qet_script_read and qet_script_remove complete the set. The folder is the server's choice, never the call's: ids are checked as file names, and storing or removing needs QET_ENABLE_SCRIPTING=1 like an edit, because a stored script runs when the user clicks it. A header is checked with the same rules as QElectroTech's own, so nothing is stored that would get no button. Co-Authored-By: Claude Opus 5.5 --- misc/qet-mcp/README.md | 48 +++- misc/qet-mcp/qet_mcp.py | 456 ++++++++++++++++++++++++++++++++++- misc/qet-mcp/test_qet_mcp.py | 219 ++++++++++++++++- 3 files changed, 711 insertions(+), 12 deletions(-) diff --git a/misc/qet-mcp/README.md b/misc/qet-mcp/README.md index 37a3a2cb8..546014f1e 100644 --- a/misc/qet-mcp/README.md +++ b/misc/qet-mcp/README.md @@ -41,6 +41,10 @@ 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_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 | +| `qet_script_list`, `qet_script_read`, `qet_script_remove` | the stored scripts: list, read one to change it, delete one | `qet_export` and `qet_edit` launch QElectroTech. Everything else parses the file directly, which is faster, needs no display, and cannot be confused by @@ -172,17 +176,17 @@ tools that read files work there: `qet_project_info`, `qet_elements`, `qet_conductors`, `qet_items`, `qet_diff`, `qet_scan`, `qet_element_info`, `qet_element_search` and `qet_element_build`. -## Five tools need scripting switched on +## Some tools need scripting switched on A QElectroTech with JavaScript scripting switched off refuses `--run`, and off is the default from [#984](https://github.com/qelectrotech/qelectrotech-source-mirror/pull/984) -onwards. Five tools here drive it that way and stop working until it is -turned on: +onwards. These tools drive it that way, or store a script that runs when +clicked, and stop working until it is turned on: | | | |---|---| -| need `QET_ENABLE_SCRIPTING=1` | `qet_query`, `qet_continuity`, `qet_check`, `qet_project_new`, `qet_edit` | +| need `QET_ENABLE_SCRIPTING=1` | `qet_query`, `qet_continuity`, `qet_check`, `qet_project_new`, `qet_edit`, `qet_script_api`, `qet_script_test`, `qet_script_install`, `qet_script_remove` | | unaffected | everything else — they read the `.qet` directly, or, in `qet_export`'s case, use a plain CLI flag | The variable goes in the environment this server is started in, which for an @@ -193,7 +197,7 @@ configured this server and pointed it at a QElectroTech binary made that choice, and their interactive QElectroTech keeps whatever its own setting says. -Without it, those five come back `"ok": false` with a `hint` naming the +Without it, those come back `"ok": false` with a `hint` naming the variable. Older builds, from before the setting existed, need nothing. ## What the server is allowed to touch @@ -247,6 +251,40 @@ 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. +## Script buttons + +QElectroTech turns every `.js` file in its scripts folder that starts with a +`// ==QETScript==` header into a command with an icon: in Projet > Scripts, +on the Scripts toolbar, in command search and in the shortcut bar. A person +can write that file by hand; an assistant uses the tools above. Both end +with the same file, and an open QElectroTech picks it up without a restart. + +```js +// ==QETScript== +// @name Add revision note +// @icon add-revision-note.svg +// @tooltip Puts a "Rev A" note on the folio on screen +// @shortcut Ctrl+Alt+R +// @context canvas +// ==/QETScript== +qet.addText(qet.currentFolio(), "Rev A", 40, 40); +``` + +The usual round: `qet_script_api` for the calls, `qet_script_test` on a +project until the diff is what was wanted, then `qet_script_install` with +`test_project` set, so a script that fails is not stored. The assistant +never presses the button: the user does, and one Ctrl+Z undoes the run. + +| | | +|---|---| +| folder | QElectroTech's data folder + `/scripts`: `~/.local/share/QElectroTech/QElectroTech/scripts` on Linux, `%APPDATA%\QElectroTech\QElectroTech\scripts` on Windows, `~/Library/Application Support/QElectroTech/QElectroTech/scripts` on macOS | +| `QET_MCP_SCRIPTS_DIR` | another folder, for a QElectroTech started with `--data-dir` | + +The folder is chosen by the server, never by a call, and a script's id +becomes its file name only if it is `a-z`, `0-9`, `-` and `_`. Storing or +removing a script needs `QET_ENABLE_SCRIPTING=1` like an edit does: a +stored script runs with the user's rights when they click it. + ## Worked examples **What did that edit change?** diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index 20485964d..72222da06 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -1023,8 +1023,10 @@ def _run_qet(binary: str, args: list[str], timeout: int = 180, "QET_ENABLE_SCRIPTING=1 to the environment this server is " "started in -- in an MCP client that is the \"env\" block of " "its entry in the client configuration. Only qet_query, " - "qet_continuity, qet_check, qet_project_new and qet_edit " - "need it; every other tool either reads the file directly " + "qet_continuity, qet_check, qet_project_new, qet_edit, " + "qet_script_api, qet_script_test, qet_script_install and " + "qet_script_remove need it; every other tool either reads " + "the file directly " "or uses a plain CLI flag.") return result @@ -2551,6 +2553,338 @@ def tool_edit(binary: str, project: str, operations: list, output: str, return result +# -------------------------------------------------------------------------- +# Stored scripts: the buttons in Projet > Scripts and on the Scripts toolbar +# -------------------------------------------------------------------------- +# +# QElectroTech turns every .js file in one folder into a command with an +# icon, read from a // ==QETScript== header at the top of the file. The +# folder is the whole contract: a person writing a script by hand and an +# assistant using these tools both end with a file there, and QElectroTech +# notices it without a restart. So installing is writing a file, and these +# tools never talk to a running QElectroTech. +# +# The folder is not the client's to choose -- scripts_dir() finds it the +# way QElectroTech does -- and writing to it needs the same consent as +# editing a project: QET_ENABLE_SCRIPTING=1 in this server's environment. +# A stored script runs, with the user's rights, when they click its button. + +_SCRIPT_CONTEXTS = ("canvas", "selection", "conductor") +_SCRIPT_ID = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") +_SCRIPT_MAX_BYTES = 256 * 1024 +_ICON_MAX_BYTES = 64 * 1024 + +SCRIPT_HEADER_HELP = ( + "A stored script is one .js file; its first lines say how its button " + "looks:\n" + "// ==QETScript==\n" + "// @name Add revision note (required)\n" + "// @icon .svg (optional: a file next to the " + "script, or builtin:; a tile with the initials otherwise)\n" + "// @tooltip Puts a note on the folio on screen\n" + "// @shortcut Ctrl+Alt+R (optional default shortcut)\n" + "// @context canvas (canvas: always enabled; " + "selection: something selected; conductor: a conductor selected)\n" + "// @api 1\n" + "// ==/QETScript==\n" + "The script sees one global, qet. qet.currentFolio() is the folio on " + "screen; folio indexes count from 0. A click is one undo step, so do " + "not call qet.undo() in a stored script. Report with qet.log(); " + "qet.showMessage() opens a dialog the user has to close.") + + +def scripts_dir() -> Path: + """The folder QElectroTech reads stored scripts from. + + QETApp::dataDir() + "/scripts", where dataDir() is Qt's + AppDataLocation for organisation and application "QElectroTech". + QET_MCP_SCRIPTS_DIR overrides it, for a QElectroTech started with + --data-dir or a test. + """ + env = os.environ.get("QET_MCP_SCRIPTS_DIR", "").strip() + if env: + return Path(env).expanduser() + if os.name == "nt": + base = Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming") + elif sys.platform == "darwin": + base = Path.home() / "Library" / "Application Support" + else: + base = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share") + return base / "QElectroTech" / "QElectroTech" / "scripts" + + +def parse_script_header(text: str, script_id: str) -> dict: + """The same rules as QElectroTech's ScriptHeader::parse(). + + Returns the header's fields, with "error" set when QElectroTech would + refuse it (and so show no button for it). + """ + h = {"id": script_id, "name": "", "icon": "", "tooltip": "", "shortcut": "", + "context": "canvas", "api": 1, + "action_id": "diagrameditor.script." + script_id} + m = re.search(r"//\s*==QETScript==\s*\n(.*?)//\s*==/QETScript==", text, re.S) + if not m: + h["error"] = "no // ==QETScript== header" + return h + for line in m.group(1).split("\n"): + lm = re.match(r"^\s*//\s*@(\w+)\s+(.*?)\s*$", line) + if not lm: + continue + key, value = lm.groups() + if key == "api": + try: + h["api"] = int(value) + except ValueError: + h["api"] = 0 + elif key in ("name", "icon", "tooltip", "shortcut", "context"): + h[key] = value + else: + h["error"] = f"unknown header key @{key}" + return h + if not h["name"]: + h["error"] = "@name is required" + elif h["context"] not in _SCRIPT_CONTEXTS: + h["error"] = "@context must be one of: " + ", ".join(_SCRIPT_CONTEXTS) + elif h["api"] != 1: + h["error"] = f"@api {h['api']} is not supported by this version (1 is)" + return h + + +def _script_id(script_id) -> str: + if not isinstance(script_id, str) or not _SCRIPT_ID.match(script_id): + raise ValueError("'id' must be 1-64 characters of a-z, 0-9, '-' and '_', " + "starting with a letter or digit: it is the file name") + return script_id + + +def _require_script_consent() -> None: + if os.environ.get("QET_ENABLE_SCRIPTING") != "1": + raise ValueError( + "storing a script needs the same consent as editing a project: " + "QET_ENABLE_SCRIPTING=1 in the environment this server is started " + "in. A stored script runs with the user's rights when they click it.") + + +def _check_icon_svg(svg: str) -> None: + if not isinstance(svg, str) or len(svg.encode("utf-8")) > _ICON_MAX_BYTES: + raise ValueError(f"'icon_svg' must be SVG text under {_ICON_MAX_BYTES // 1024} KB") + try: + root = ET.fromstring(svg) + except ET.ParseError as exc: + raise ValueError(f"'icon_svg' is not well-formed XML: {exc}") from None + if root.tag.rsplit("}", 1)[-1] != "svg": + raise ValueError("'icon_svg' must have as its root element") + + +def _write_atomic(path: Path, data: str) -> None: + """Write, then rename into place, so the watcher never reads half a file.""" + tmp = path.with_name("." + path.name + ".tmp") + tmp.write_text(data, encoding="utf-8") + os.replace(tmp, path) + + +def tool_script_api(binary: str, timeout: int = 120) -> dict: + """The calls a script can make, asked of the QElectroTech that will run it.""" + script = ("var sigs = typeof qet.apiSignatures === 'function' ? qet.apiSignatures()" + " : null;\n" + "var names = []; for (var k in qet) if (typeof qet[k] === 'function') " + "names.push(k);\n" + "qet.log(%s + JSON.stringify({kind: 'api', signatures: sigs, names: names}));\n" + % json.dumps(_MARKER)) + with tempfile.TemporaryDirectory(prefix="qet-mcp-api-") as tmp: + proj = Path(tmp) / "api.qet" + proj.write_text('\n\n', + encoding="utf-8") + result = _run_qet(binary, [str(proj)], timeout=timeout, script=script, + tail=400_000) + rec = None + for line in (result.get("stdout", "") + "\n" + result.get("stderr", "")).splitlines(): + idx = line.find(_MARKER) + if idx >= 0: + try: + rec = json.loads(line[idx + len(_MARKER):]) + except json.JSONDecodeError: + pass + if rec is None: + result["ok"] = False + result["stdout"] = result.get("stdout", "")[-4000:] + result["stderr"] = result.get("stderr", "")[-4000:] + return result + out = {"ok": True, "header_format": SCRIPT_HEADER_HELP} + if rec.get("signatures"): + out["calls"] = rec["signatures"] + else: + out["calls"] = sorted(n for n in rec.get("names", []) + if not n.endswith("Changed") and n != "deleteLater") + out["note"] = ("this QElectroTech predates qet.apiSignatures(): names only, " + "see the JavaScript Scripting wiki page for parameters") + out["call_count"] = len(out["calls"]) + return out + + +def tool_script_test(binary: str, project: str, source: str, + elements_dir: str | None = None, timeout: int = 180) -> dict: + """Run a script on a copy of a project and say what it would change. + + What clicking its button would do, without touching the project: the + script runs headless on a copy, the copy is saved, and qet_diff compares + it with the original. Headless there is no folio on screen, so + qet.currentFolio() is the first folio. + """ + proj = Path(project).expanduser() + if not proj.is_file(): + raise ValueError(f"no such project: {proj}") + if not isinstance(source, str) or not source.strip(): + raise ValueError("'source' must be the script's text") + header = parse_script_header(source, "test") + with tempfile.TemporaryDirectory(prefix="qet-mcp-script-") as tmp: + copy = Path(tmp) / proj.name + shutil.copy2(proj, copy) + # Older builds have no currentFolio(); the first folio stands in, as + # it does headless in builds that have it. On the script's own first + # line, so the line numbers in its errors are its own. + script = ("if (typeof qet.currentFolio !== 'function') " + "qet.currentFolio = function () { return qet.folioCount() ? 0 : -1; }; " + + source + "\n" + "qet.log(%s + JSON.stringify({kind: 'save', result: qet.save(%s)}));\n" + % (json.dumps(_MARKER), json.dumps(str(copy)))) + result = _run_qet(binary, [str(copy)], timeout=timeout, + elements_dir=elements_dir, script=script, tail=400_000) + streams = result.get("stdout", "") + "\n" + result.get("stderr", "") + saved = None + for line in streams.splitlines(): + idx = line.find(_MARKER) + if idx >= 0: + try: + saved = json.loads(line[idx + len(_MARKER):]).get("result") + except json.JSONDecodeError: + pass + errors = [ln.strip() for ln in streams.splitlines() if "Script error:" in ln] + log = [ln for ln in streams.splitlines() + if ln.strip() and _MARKER not in ln and "Script error:" not in ln] + out = {"ok": bool(result.get("ok")) and saved is True and not errors, + "header": header, "errors": errors, "log": log[-60:]} + if result.get("hint"): + out["hint"] = result["hint"] + if result.get("timed_out"): + out["timed_out"] = True + if saved is True: + out["diff"] = tool_diff(str(proj), str(copy)) + elif not errors: + out["errors"] = ["the script did not finish: it threw before the " + "copy could be saved, or never ran"] + if header.get("error"): + out["header_warning"] = (f"QElectroTech would show no button for this " + f"script: {header['error']}") + return out + + +def tool_script_install(script_id: str, source: str, icon_svg: str | None = None, + overwrite: bool = False, test_project: str | None = None, + binary: str | None = None, elements_dir: str | None = None, + timeout: int = 180) -> dict: + """Store a script so QElectroTech shows it as a button.""" + _require_script_consent() + sid = _script_id(script_id) + if not isinstance(source, str) or not source.strip(): + raise ValueError("'source' must be the script's text") + if len(source.encode("utf-8")) > _SCRIPT_MAX_BYTES: + raise ValueError(f"'source' is over {_SCRIPT_MAX_BYTES // 1024} KB") + header = parse_script_header(source, sid) + if header.get("error"): + raise ValueError(f"QElectroTech would refuse this header: {header['error']}. " + + SCRIPT_HEADER_HELP) + folder = scripts_dir() + icon = header["icon"] + if icon_svg is not None: + _check_icon_svg(icon_svg) + if icon != f"{sid}.svg": + raise ValueError(f"with 'icon_svg', the header must say '// @icon {sid}.svg'") + elif icon and not icon.startswith("builtin:") and not (folder / icon).is_file(): + raise ValueError(f"the header names icon file {icon!r}, which is not in " + f"{folder}: pass its SVG as 'icon_svg', use builtin:, " + "or leave @icon out for an initials tile") + + target = folder / f"{sid}.js" + if target.exists() and not overwrite: + raise ValueError(f"a script with id {sid!r} is already stored: {target}. " + "Pass \"overwrite\": true to replace it.") + + test = None + if test_project: + test = tool_script_test(binary, test_project, source, elements_dir, timeout) + if not test.get("ok"): + return {"ok": False, "installed": None, + "reason": "the test run failed, so nothing was stored", + "test": test} + + folder.mkdir(parents=True, exist_ok=True) + if icon_svg is not None: + _write_atomic(folder / f"{sid}.svg", icon_svg) + _write_atomic(target, source) + out = {"ok": True, "installed": str(target), "header": header, + "where": "Projet > Scripts, the Scripts toolbar, command search " + "(Ctrl+Shift+M) and the shortcut bar's Customise list; an " + "open QElectroTech picks it up without a restart"} + if test is not None: + out["test"] = {"ok": True, "diff": test.get("diff")} + return out + + +def tool_script_list() -> dict: + folder = scripts_dir() + scripts, refused = [], [] + if folder.is_dir(): + for path in sorted(folder.glob("*.js")): + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as exc: + refused.append({"file": path.name, "error": str(exc)}) + continue + h = parse_script_header(text, path.stem) + if h.get("error"): + refused.append({"file": path.name, "error": h["error"]}) + else: + scripts.append(h) + return {"folder": str(folder), "exists": folder.is_dir(), + "scripts": scripts, "refused": refused} + + +def tool_script_read(script_id: str) -> dict: + sid = _script_id(script_id) + folder = scripts_dir() + path = folder / f"{sid}.js" + if not path.is_file(): + raise ValueError(f"no stored script {sid!r} in {folder}") + source = path.read_text(encoding="utf-8") + out = {"id": sid, "path": str(path), "source": source, + "header": parse_script_header(source, sid)} + icon = folder / f"{sid}.svg" + if icon.is_file(): + out["icon_svg"] = icon.read_text(encoding="utf-8") + return out + + +def tool_script_remove(script_id: str) -> dict: + _require_script_consent() + sid = _script_id(script_id) + folder = scripts_dir() + path = folder / f"{sid}.js" + if not path.is_file(): + raise ValueError(f"no stored script {sid!r} in {folder}") + icon = parse_script_header(path.read_text(encoding="utf-8"), sid).get("icon", "") + path.unlink() + removed = [str(path)] + # The icon goes too unless another script still names it. + if icon and not icon.startswith("builtin:") and "/" not in icon and "\\" not in icon: + others = {parse_script_header(p.read_text(encoding="utf-8"), p.stem).get("icon") + for p in folder.glob("*.js")} + if icon not in others and (folder / icon).is_file(): + (folder / icon).unlink() + removed.append(str(folder / icon)) + return {"removed": removed} + + TOOLS = [ { "name": "qet_project_info", @@ -3151,6 +3485,108 @@ TOOLS = [ a["output"], a["names"], a["parts"], a.get("terminals"), a.get("link_type", "simple"), a.get("informations"), a.get("uuid")), }, + { + "name": "qet_script_api", + "description": "List every call a QElectroTech script can make (the global " + "'qet'), asked of the QElectroTech that will run it, plus the " + "header format that turns a script into a button. Read this " + "before writing a script for qet_script_install. One launch; " + "needs QET_ENABLE_SCRIPTING=1.", + "inputSchema": { + "type": "object", + "properties": { + "binary": {"type": "string", "description": "the qelectrotech executable; leave it out to use the one this server is configured with. Any other is refused unless its configuration allows it"}, + "timeout": {"type": "integer", "default": 120}, + }, + }, + "handler": lambda a: tool_script_api(a["binary"], a.get("timeout", 120)), + }, + { + "name": "qet_script_test", + "description": "Run a script's text on a COPY of a project and return what it " + "would change (a qet_diff), what it logged, and any error with " + "its line. The project is never modified. Headless there is no " + "folio on screen: qet.currentFolio() is the first folio. Also " + "says if the header would get no button. Needs " + "QET_ENABLE_SCRIPTING=1.", + "inputSchema": { + "type": "object", + "properties": { + "binary": {"type": "string", "description": "the qelectrotech executable; leave it out to use the one this server is configured with. Any other is refused unless its configuration allows it"}, + "project": {"type": "string", "description": "the .qet to try it on; never modified"}, + "source": {"type": "string", "description": "the script's full text"}, + "elements_dir": {"type": "string"}, + "timeout": {"type": "integer", "default": 180}, + }, + "required": ["project", "source"], + }, + "handler": lambda a: tool_script_test(a["binary"], a["project"], a["source"], + a.get("elements_dir"), a.get("timeout", 180)), + }, + { + "name": "qet_script_install", + "description": "Store a script so QElectroTech shows it as a button with an " + "icon: Projet > Scripts, the Scripts toolbar, command search " + "and the shortcut bar. A running QElectroTech picks it up " + "without a restart. The text must start with a " + "// ==QETScript== header (see qet_script_api); the file is " + ".js in the user's scripts folder, which this server " + "chooses. Give 'icon_svg' to store an icon as .svg (the " + "header must then say '// @icon .svg'). Give " + "'test_project' to run qet_script_test first and store " + "nothing if it fails -- recommended. Does not run the " + "script: the user clicks it. Needs QET_ENABLE_SCRIPTING=1.", + "inputSchema": { + "type": "object", + "properties": { + "id": {"type": "string", "description": "file name without .js: a-z, 0-9, '-', '_'"}, + "source": {"type": "string", "description": "the script's full text, header first"}, + "icon_svg": {"type": "string", "description": "optional SVG for the button, stored as .svg"}, + "overwrite": {"type": "boolean", "default": False}, + "test_project": {"type": "string", "description": "optional .qet to test on first; never modified"}, + "binary": {"type": "string", "description": "the qelectrotech executable for the test; leave it out to use the one this server is configured with"}, + "elements_dir": {"type": "string"}, + "timeout": {"type": "integer", "default": 180}, + }, + "required": ["id", "source"], + }, + "handler": lambda a: tool_script_install( + a["id"], a["source"], a.get("icon_svg"), bool(a.get("overwrite")), + a.get("test_project"), a.get("binary"), a.get("elements_dir"), + a.get("timeout", 180)), + }, + { + "name": "qet_script_list", + "description": "List the stored scripts QElectroTech shows as buttons: each " + "one's id, name, icon, tooltip, shortcut and context, and the " + "files it ignores with the reason. Reads files only.", + "inputSchema": {"type": "object", "properties": {}}, + "handler": lambda a: tool_script_list(), + }, + { + "name": "qet_script_read", + "description": "The text (and stored SVG icon, if any) of one stored script, " + "to change it and store it again with qet_script_install " + "overwrite=true.", + "inputSchema": { + "type": "object", + "properties": {"id": {"type": "string"}}, + "required": ["id"], + }, + "handler": lambda a: tool_script_read(a["id"]), + }, + { + "name": "qet_script_remove", + "description": "Delete a stored script, and its icon if no other script uses " + "it; its button goes from a running QElectroTech. Needs " + "QET_ENABLE_SCRIPTING=1.", + "inputSchema": { + "type": "object", + "properties": {"id": {"type": "string"}}, + "required": ["id"], + }, + "handler": lambda a: tool_script_remove(a["id"]), + }, ] _BY_NAME = {t["name"]: t for t in TOOLS} @@ -3203,11 +3639,23 @@ _DATA_PATHS = { "qet_check": {"read": ("project",)}, "qet_project_new": {"write": ("output",)}, "qet_element_build": {"write": ("output",)}, + # The scripts folder is chosen by scripts_dir(), never by the client, + # so only the project a script is tried on is a data path here. + "qet_script_api": {}, + "qet_script_test": {"read": ("project",)}, + "qet_script_install": {"read": ("test_project",)}, } # Tools that launch QElectroTech, and so take "binary" and "elements_dir". _LAUNCHES_QET = {"qet_export", "qet_edit", "qet_query", "qet_continuity", - "qet_check", "qet_project_new"} + "qet_check", "qet_project_new", "qet_script_api", "qet_script_test"} + +# Tools that launch QElectroTech only when given this argument. +_LAUNCHES_QET_WITH = {"qet_script_install": "test_project"} + +# Tools whose "overwrite" guards a file the server names itself (the +# stored script, in scripts_dir()), not a client-chosen output path. +_OVERWRITE_OWN_FILE = {"qet_script_install"} # qet_edit operations that name a file of their own. _DATA_PATH_OPS = {"add_image": "file", "add_pdf_page": "file"} @@ -3392,7 +3840,7 @@ def enforce_path_policy(tool_name: str, arguments: dict) -> None: return roots = workspace_roots() - if tool_name in _LAUNCHES_QET: + if tool_name in _LAUNCHES_QET or arguments.get(_LAUNCHES_QET_WITH.get(tool_name, "")): _check_binary(arguments) _check_elements_dir(arguments, roots) diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index 422c4a085..d65e76626 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -164,7 +164,9 @@ class ToolRegistry(unittest.TestCase): "qet_project_info", "qet_elements", "qet_conductors", "qet_diff", "qet_scan", "qet_element_info", "qet_export", "qet_edit", "qet_query", "qet_project_new", "qet_element_search", "qet_check", "qet_element_build", - "qet_continuity", "qet_items"}) + "qet_continuity", "qet_items", "qet_script_api", "qet_script_test", + "qet_script_install", "qet_script_list", "qet_script_read", + "qet_script_remove"}) class EditValidation(unittest.TestCase): @@ -2417,7 +2419,7 @@ class PathPolicy(unittest.TestCase): guarded = {name for name, spec in m._DATA_PATHS.items() if spec.get("write")} advertised = {t["name"] for t in m.TOOLS if "overwrite" in t["inputSchema"].get("properties", {})} - self.assertEqual(guarded, advertised) + self.assertEqual(guarded, advertised - m._OVERWRITE_OWN_FILE) def test_every_data_path_argument_is_guarded(self): """The other direction: a tool whose schema takes a data path must be @@ -2527,7 +2529,7 @@ class BinaryPolicy(unittest.TestCase): would run whatever it was given.""" takes = {t["name"] for t in m.TOOLS if "binary" in t["inputSchema"].get("properties", {})} - self.assertEqual(takes, m._LAUNCHES_QET) + self.assertEqual(takes, m._LAUNCHES_QET | set(m._LAUNCHES_QET_WITH)) for name in takes: self.assertNotIn("binary", m._BY_NAME[name]["inputSchema"].get("required", []), f"{name} still requires the client to name a binary") @@ -2773,6 +2775,217 @@ class ScriptingDisabledHint(unittest.TestCase): self.assertIn(tool, hint) +NOTE_SCRIPT = ( + "// ==QETScript==\n" + "// @name Add a note\n" + "// @tooltip Puts a note on the folio on screen\n" + "// @shortcut Ctrl+Alt+N\n" + "// ==/QETScript==\n" + "qet.addText(qet.currentFolio(), 'SCRIPT-NOTE', 40, 40);\n" + "qet.log('added');\n") +ICON = '' + + +class ScriptHeader(unittest.TestCase): + """The same cases as QElectroTech's tst_scriptheader: a header this + accepts and QElectroTech refuses would install a script that never gets + a button.""" + + def h(self, lines): + return m.parse_script_header( + "// ==QETScript==\n" + lines + "// ==/QETScript==\nqet.log('x');\n", "a") + + def test_full(self): + h = self.h("// @name Add revision note\n// @icon note.svg\n" + "// @tooltip Puts a note\n// @shortcut Ctrl+Alt+R\n" + "// @context selection\n// @api 1\n") + self.assertNotIn("error", h) + self.assertEqual((h["name"], h["icon"], h["shortcut"], h["context"]), + ("Add revision note", "note.svg", "Ctrl+Alt+R", "selection")) + self.assertEqual(h["action_id"], "diagrameditor.script.a") + + def test_refused(self): + cases = { + "qet.log('x');\n": "no // ==QETScript== header", + } + for text, error in cases.items(): + self.assertEqual(m.parse_script_header(text, "a")["error"], error) + for lines, error in [ + ("// @icon x.svg\n", "@name is required"), + ("// @name A\n// @shortcutt Ctrl+K\n", "unknown header key @shortcutt"), + ("// @name A\n// @context wires\n", + "@context must be one of: canvas, selection, conductor"), + ("// @name A\n// @api 2\n", + "@api 2 is not supported by this version (1 is)")]: + with self.subTest(lines=lines): + self.assertEqual(self.h(lines)["error"], error) + + def test_only_the_block(self): + h = m.parse_script_header( + "// ==QETScript==\n// @name Real\n// ==/QETScript==\n" + "// @name Not this one\n// @bogus key\n", "a") + self.assertNotIn("error", h) + self.assertEqual(h["name"], "Real") + + +class ScriptStore(unittest.TestCase): + """install / list / read / remove against a scripts folder of its own.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.dir = Path(self.tmp.name) / "scripts" + self.env = mock.patch.dict(os.environ, {"QET_MCP_SCRIPTS_DIR": str(self.dir), + "QET_ENABLE_SCRIPTING": "1"}) + self.env.start() + + def tearDown(self): + self.env.stop() + self.tmp.cleanup() + + def test_round_trip(self): + r = m.tool_script_install("add-note", NOTE_SCRIPT) + self.assertTrue(r["ok"]) + self.assertEqual((self.dir / "add-note.js").read_text(), NOTE_SCRIPT) + listed = m.tool_script_list() + self.assertEqual([s["id"] for s in listed["scripts"]], ["add-note"]) + self.assertEqual(listed["scripts"][0]["shortcut"], "Ctrl+Alt+N") + self.assertEqual(m.tool_script_read("add-note")["source"], NOTE_SCRIPT) + m.tool_script_remove("add-note") + self.assertFalse((self.dir / "add-note.js").exists()) + self.assertEqual(m.tool_script_list()["scripts"], []) + + def test_icon_stored_and_removed_with_it(self): + src = NOTE_SCRIPT.replace("// @tooltip", "// @icon iconic.svg\n// @tooltip") + m.tool_script_install("iconic", src, icon_svg=ICON) + self.assertEqual((self.dir / "iconic.svg").read_text(), ICON) + self.assertEqual(m.tool_script_read("iconic")["icon_svg"], ICON) + m.tool_script_remove("iconic") + self.assertEqual(list(self.dir.iterdir()), []) + + def test_shared_icon_kept_while_used(self): + src = NOTE_SCRIPT.replace("// @tooltip", "// @icon one.svg\n// @tooltip") + m.tool_script_install("one", src, icon_svg=ICON) + m.tool_script_install("two", src) # names one.svg, which exists + m.tool_script_remove("one") + self.assertTrue((self.dir / "one.svg").exists()) + m.tool_script_remove("two") + self.assertFalse((self.dir / "one.svg").exists()) + + def test_refusals(self): + for sid in ("../evil", "Upper", "", "a/b", "-x", "x" * 65, None): + with self.subTest(id=sid), self.assertRaisesRegex(ValueError, "'id' must be"): + m.tool_script_install(sid, NOTE_SCRIPT) + with self.assertRaisesRegex(ValueError, "would refuse this header: @name"): + m.tool_script_install("a", "// ==QETScript==\n// ==/QETScript==\n") + with self.assertRaisesRegex(ValueError, "must say '// @icon a.svg'"): + m.tool_script_install("a", NOTE_SCRIPT, icon_svg=ICON) + with self.assertRaisesRegex(ValueError, "not in"): + m.tool_script_install("a", NOTE_SCRIPT.replace( + "// @tooltip", "// @icon missing.svg\n// @tooltip")) + src = NOTE_SCRIPT.replace("// @tooltip", "// @icon a.svg\n// @tooltip") + for bad in ("", " Date: Fri, 2 Oct 2026 10:05:24 +1300 Subject: [PATCH 2/3] qet-mcp: the scripts folder rule as a pure function, tested for Windows and macOS Checked against a real Windows package under Wine: QElectroTech keeps its data in %APPDATA%\QElectroTech\QElectroTech, next to which the scripts folder goes. Co-Authored-By: Claude Opus 5.5 --- misc/qet-mcp/qet_mcp.py | 30 ++++++++++++++++++++---------- misc/qet-mcp/test_qet_mcp.py | 22 ++++++++++++++++------ 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index 72222da06..0e994560a 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -64,7 +64,7 @@ import subprocess import sys import tempfile import xml.etree.ElementTree as ET -from pathlib import Path, PurePath +from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath SERVER_NAME = "qet-mcp" SERVER_VERSION = "0.1.0" @@ -2593,24 +2593,34 @@ SCRIPT_HEADER_HELP = ( "qet.showMessage() opens a dialog the user has to close.") +def default_scripts_dir(os_name: str, platform: str, env, home) -> PurePath: + """QETApp::dataDir() + "/scripts" for a given platform. + + dataDir() is Qt's AppDataLocation with organisation and application + both "QElectroTech" (main.cpp). Pure, so the Windows and macOS answers + are tested on any machine. + """ + if os_name == "nt": + appdata = env.get("APPDATA") + base = (PureWindowsPath(appdata) if appdata + else PureWindowsPath(home, "AppData", "Roaming")) + elif platform == "darwin": + base = PurePosixPath(home, "Library", "Application Support") + else: + base = PurePosixPath(env.get("XDG_DATA_HOME") or PurePosixPath(home, ".local", "share")) + return base / "QElectroTech" / "QElectroTech" / "scripts" + + def scripts_dir() -> Path: """The folder QElectroTech reads stored scripts from. - QETApp::dataDir() + "/scripts", where dataDir() is Qt's - AppDataLocation for organisation and application "QElectroTech". QET_MCP_SCRIPTS_DIR overrides it, for a QElectroTech started with --data-dir or a test. """ env = os.environ.get("QET_MCP_SCRIPTS_DIR", "").strip() if env: return Path(env).expanduser() - if os.name == "nt": - base = Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming") - elif sys.platform == "darwin": - base = Path.home() / "Library" / "Application Support" - else: - base = Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share") - return base / "QElectroTech" / "QElectroTech" / "scripts" + return Path(str(default_scripts_dir(os.name, sys.platform, os.environ, str(Path.home())))) def parse_script_header(text: str, script_id: str) -> dict: diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index d65e76626..ebbe49362 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -2917,12 +2917,22 @@ class ScriptStore(unittest.TestCase): "error": "no // ==QETScript== header"}]) def test_default_folder_is_qets_data_dir(self): - with mock.patch.dict(os.environ, {"QET_MCP_SCRIPTS_DIR": "", - "XDG_DATA_HOME": "/x/share"}), \ - mock.patch.object(m.os, "name", "posix"), \ - mock.patch.object(m.sys, "platform", "linux"): - self.assertEqual(m.scripts_dir(), - Path("/x/share/QElectroTech/QElectroTech/scripts")) + """QStandardPaths::AppDataLocation plus the organisation and + application names main.cpp sets, on each platform.""" + from pathlib import PurePosixPath, PureWindowsPath + f = m.default_scripts_dir + self.assertEqual(f("posix", "linux", {"XDG_DATA_HOME": "/x/share"}, "/home/u"), + PurePosixPath("/x/share/QElectroTech/QElectroTech/scripts")) + self.assertEqual(f("posix", "linux", {}, "/home/u"), + PurePosixPath("/home/u/.local/share/QElectroTech/QElectroTech/scripts")) + self.assertEqual(f("nt", "win32", {"APPDATA": r"C:\Users\u\AppData\Roaming"}, r"C:\Users\u"), + PureWindowsPath(r"C:\Users\u\AppData\Roaming\QElectroTech\QElectroTech\scripts")) + self.assertEqual(f("nt", "win32", {}, r"C:\Users\u"), + PureWindowsPath(r"C:\Users\u\AppData\Roaming\QElectroTech\QElectroTech\scripts")) + self.assertEqual(f("posix", "darwin", {}, "/Users/u"), + PurePosixPath("/Users/u/Library/Application Support/QElectroTech/QElectroTech/scripts")) + with mock.patch.dict(os.environ, {"QET_MCP_SCRIPTS_DIR": "/elsewhere"}): + self.assertEqual(m.scripts_dir(), Path("/elsewhere")) def test_install_does_not_need_a_binary_without_a_test(self): """Only a test run launches QElectroTech; storing a file must not be From 15c114edd539845b00c50edcc633bdcf878b0d2b Mon Sep 17 00:00:00 2001 From: ispyisail Date: Fri, 2 Oct 2026 13:02:26 +1300 Subject: [PATCH 3/3] 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.