From 03fc51d0fea8617f439377c06f265a66c7a9eeb9 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 11:15:26 +1300 Subject: [PATCH] qet-mcp: add --call, and document using it from the Claude desktop app A web chat in a browser cannot start a local program, so it cannot run this server. Two ways round that, documented in the README: - the Claude desktop app runs stdio servers; a step-by-step setup; - a chat that can execute Python can upload qet_mcp.py and run `--call ''` (or `-` to read the arguments from stdin). --call goes through the same dispatcher as the stdio server, so the workspace policy applies unchanged. Exit status 0 success, 1 the tool reported an error, 2 the call was malformed. Co-Authored-By: Claude Opus 5.5 --- misc/qet-mcp/README.md | 63 ++++++++++++++++++++++++++++++++++++ misc/qet-mcp/qet_mcp.py | 42 ++++++++++++++++++++++++ misc/qet-mcp/test_qet_mcp.py | 41 +++++++++++++++++++++++ 3 files changed, 146 insertions(+) diff --git a/misc/qet-mcp/README.md b/misc/qet-mcp/README.md index c8e994d9b..999d8c0ae 100644 --- a/misc/qet-mcp/README.md +++ b/misc/qet-mcp/README.md @@ -54,6 +54,9 @@ misc/qet-mcp/qet_mcp.py --list # speak MCP on stdin/stdout misc/qet-mcp/qet_mcp.py + +# run one tool and exit, no MCP client needed +misc/qet-mcp/qet_mcp.py --call qet_project_info '{"path": "drawing.qet"}' ``` Register it with an MCP client, for example: @@ -73,6 +76,66 @@ Register it with an MCP client, for example: } ``` +## Using it from the Claude app + +A web chat in a browser cannot start a program on your computer, so it +cannot run this server. The Claude desktop app for Windows and macOS can, +and it uses the same account as the website. + +1. Install Python 3.9 or later. Nothing else is needed. +2. In the desktop app, open **Settings → Developer → Edit Config**. This + opens `claude_desktop_config.json`. +3. Add the server, with your own paths: + + ```json + { + "mcpServers": { + "qet": { + "command": "python", + "args": ["C:\\path\\to\\qelectrotech\\misc\\qet-mcp\\qet_mcp.py"], + "env": { + "QET_MCP_WORKSPACE": "C:\\Users\\you\\Documents\\drawings", + "QET_ENABLE_SCRIPTING": "1" + } + } + } + } + ``` + + On macOS use `python3` and ordinary `/` paths. In JSON every `\` in a + Windows path is written `\\`. +4. Quit the app completely and start it again. The tools appear under the + chat box's tools menu. +5. In the chat, say where your `qelectrotech` executable is. `qet_edit` and + `qet_export` take it as an argument on every call; the other tools do not + need it. + +Only files under `QET_MCP_WORKSPACE` can be read or written (see +[What the server is allowed to touch](#what-the-server-is-allowed-to-touch)). +Leave out `QET_ENABLE_SCRIPTING` if you do not want the assistant to edit +projects; see the next section for what that switches off. + +The test suite runs on Linux. The server uses nothing platform-specific, +but it has not yet been tested on Windows or macOS. + +### With only a browser + +If your web chat can run Python (on claude.ai, code execution), upload +`qet_mcp.py` together with your project and ask the assistant to use +`--call`: + +```bash +python3 qet_mcp.py --call qet_elements '{"path": "drawing.qet"}' +echo '{"path": "drawing.qet"}' | python3 qet_mcp.py --call qet_check - +``` + +It prints the tool's JSON result and exits 0, or 1 if the tool reported an +error, or 2 if the call itself was malformed. The workspace rule applies as +it does in a server. The sandbox has no QElectroTech in it, so only the +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 A QElectroTech with JavaScript scripting switched off refuses `--run`, and diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index 09d84c384..f40090d96 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -47,6 +47,11 @@ PROTOCOL Line-delimited JSON-RPC 2.0 on stdin/stdout, per MCP's stdio transport. Nothing but protocol goes to stdout; diagnostics go to stderr. No third-party dependencies — the MCP SDK is not assumed to be present. + +`--call [arguments]` runs one tool without an MCP client, for an +assistant that can execute Python but cannot launch a server (a web chat +with code execution). It goes through the same dispatcher, so the +workspace policy applies exactly as it does over stdio. """ from __future__ import annotations @@ -3262,11 +3267,48 @@ def serve(stdin=sys.stdin, stdout=sys.stdout) -> None: print(json.dumps(reply, ensure_ascii=False), file=stdout, flush=True) +def call_once(argv: list[str], stdin=sys.stdin, stdout=sys.stdout, + stderr=sys.stderr) -> int: + """--call [arguments]: one tools/call, the result's text on stdout. + + arguments is a JSON object, or "-" to read it from stdin (which spares + the caller from quoting JSON for a shell). Exit status: 0 the tool + succeeded, 1 the tool reported an error, 2 the call itself was malformed. + """ + if not argv or len(argv) > 2: + print("usage: qet_mcp.py --call ['' | -]", + file=stderr) + return 2 + name, raw = argv[0], (argv[1] if len(argv) == 2 else "{}") + if raw == "-": + raw = stdin.read() + try: + arguments = json.loads(raw) if raw.strip() else {} + except json.JSONDecodeError as exc: + print(f"arguments are not valid JSON: {exc}", file=stderr) + return 2 + if not isinstance(arguments, dict): + print("arguments must be a JSON object", file=stderr) + return 2 + + reply = handle({"jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": {"name": name, "arguments": arguments}}) + if "error" in reply: + print(reply["error"]["message"], file=stderr) + return 2 + result = reply["result"] + for part in result["content"]: + print(part["text"], file=stdout) + return 1 if result.get("isError") else 0 + + def main() -> int: if len(sys.argv) > 1 and sys.argv[1] in ("--list", "-l"): for t in TOOLS: print(f"{t['name']}\n {t['description']}\n") return 0 + if len(sys.argv) > 1 and sys.argv[1] == "--call": + return call_once(sys.argv[2:]) serve() return 0 diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index fdac11f55..51aaa89d0 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -2159,6 +2159,47 @@ class Protocol(unittest.TestCase): for t in m.TOOLS: self.assertIn(t["name"], out) + def call(self, *args, stdin="", workspace=None): + env = dict(os.environ) + if workspace is not None: + env["QET_MCP_WORKSPACE"] = str(workspace) + return subprocess.run([sys.executable, str(HERE / "qet_mcp.py"), "--call", *args], + input=stdin, capture_output=True, text=True, + timeout=30, env=env) + + def test_call_flag_runs_one_tool(self): + with tempfile.TemporaryDirectory() as tmp: + p = Path(tmp) / "a.qet" + p.write_text('') + by_argv = self.call("qet_project_info", json.dumps({"path": str(p)}), + workspace=tmp) + by_stdin = self.call("qet_project_info", "-", + stdin=json.dumps({"path": str(p)}), workspace=tmp) + for proc in (by_argv, by_stdin): + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertEqual(json.loads(proc.stdout)["title"], "Via --call") + + def test_call_flag_keeps_the_workspace_policy(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) / "ws" + root.mkdir() + victim = Path(tmp) / "elsewhere.qet" + victim.write_text('') + proc = self.call("qet_project_info", json.dumps({"path": str(victim)}), + workspace=root) + self.assertEqual(proc.returncode, 1) + self.assertIn("outside the workspace", proc.stdout) + self.assertNotIn("not yours", proc.stdout) + + def test_call_flag_exit_codes(self): + tool_error = self.call("qet_project_info", '{"path": "/no/such.qet"}') + self.assertEqual(tool_error.returncode, 1) + for args in (("nope",), ("qet_project_info", "{not json"), + ("qet_project_info", "[1]"), ()): + proc = self.call(*args) + self.assertEqual(proc.returncode, 2, args) + self.assertEqual(proc.stdout, "", args) + # ========================================================================== # integration