mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-09-29 05:44:14 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user