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 <tool> '<json>'` (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 <noreply@anthropic.com>
This commit is contained in:
ispyisail
2026-09-29 11:15:26 +13:00
parent b411a08340
commit 03fc51d0fe
3 changed files with 146 additions and 0 deletions
+63
View File
@@ -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
+42
View File
@@ -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 <tool> [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 <tool> [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 <tool> ['<json arguments>' | -]",
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
+41
View File
@@ -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('<project title="Via --call"><diagram title="D"/></project>')
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('<project title="not yours"><diagram title="D"/></project>')
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