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) <noreply@anthropic.com>
This commit is contained in:
ispyisail
2026-10-02 13:02:26 +13:00
parent cd51f39d7b
commit 15c114edd5
3 changed files with 182 additions and 3 deletions
+16
View File
@@ -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
+107 -2
View File
@@ -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"):
+59 -1
View File
@@ -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.