multitool-mcp 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- multitool_mcp-0.1.0/LICENSE +14 -0
- multitool_mcp-0.1.0/PKG-INFO +97 -0
- multitool_mcp-0.1.0/README.md +74 -0
- multitool_mcp-0.1.0/pyproject.toml +45 -0
- multitool_mcp-0.1.0/setup.cfg +4 -0
- multitool_mcp-0.1.0/src/dut_mcp/__init__.py +5 -0
- multitool_mcp-0.1.0/src/dut_mcp/__main__.py +5 -0
- multitool_mcp-0.1.0/src/dut_mcp/device.py +114 -0
- multitool_mcp-0.1.0/src/dut_mcp/server.py +269 -0
- multitool_mcp-0.1.0/src/dut_mcp/websocket.py +160 -0
- multitool_mcp-0.1.0/src/multitool_mcp.egg-info/PKG-INFO +97 -0
- multitool_mcp-0.1.0/src/multitool_mcp.egg-info/SOURCES.txt +15 -0
- multitool_mcp-0.1.0/src/multitool_mcp.egg-info/dependency_links.txt +1 -0
- multitool_mcp-0.1.0/src/multitool_mcp.egg-info/entry_points.txt +2 -0
- multitool_mcp-0.1.0/src/multitool_mcp.egg-info/requires.txt +3 -0
- multitool_mcp-0.1.0/src/multitool_mcp.egg-info/top_level.txt +1 -0
- multitool_mcp-0.1.0/tests/test_dut_mcp.py +134 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
WE Multitool DUT MCP
|
|
2
|
+
Copyright (C) 2026 Wise Electronics
|
|
3
|
+
|
|
4
|
+
This program is free software: you can redistribute it and/or modify it under
|
|
5
|
+
the terms of the GNU General Public License as published by the Free Software
|
|
6
|
+
Foundation, either version 3 of the License, or (at your option) any later
|
|
7
|
+
version.
|
|
8
|
+
|
|
9
|
+
This program is distributed in the hope that it will be useful, but WITHOUT ANY
|
|
10
|
+
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
|
|
11
|
+
PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
|
12
|
+
|
|
13
|
+
You should have received a copy of the GNU General Public License along with
|
|
14
|
+
this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: multitool-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for debugging devices under test through WE Multitool
|
|
5
|
+
Author: Wise Electronics
|
|
6
|
+
License-Expression: GPL-3.0-or-later
|
|
7
|
+
Project-URL: Homepage, https://github.com/we-devices/multitool-mcp
|
|
8
|
+
Project-URL: Repository, https://github.com/we-devices/multitool-mcp.git
|
|
9
|
+
Project-URL: Issues, https://github.com/we-devices/multitool-mcp/issues
|
|
10
|
+
Keywords: mcp,embedded,hardware,debugging,we-multitool
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Topic :: Software Development :: Debuggers
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Provides-Extra: test
|
|
21
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# WE Multitool DUT MCP
|
|
25
|
+
|
|
26
|
+
`multitool-mcp` is a dependency-free [Model Context Protocol](https://modelcontextprotocol.io/) server for inspecting and debugging a device under test (DUT) through a Wise Electronics WE Multitool.
|
|
27
|
+
|
|
28
|
+
It runs over standard input/output and bridges the Multitool's JSON-over-WebSocket API. It exposes bounded MCP tools for GPIO, I2C, SPI, UART, PWM, and GPIO ownership.
|
|
29
|
+
|
|
30
|
+
## Requirements
|
|
31
|
+
|
|
32
|
+
- Python 3.11 or later
|
|
33
|
+
- A WE Multitool reachable over WebSocket
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
Install from PyPI once published:
|
|
38
|
+
|
|
39
|
+
```shell
|
|
40
|
+
python -m pip install multitool-mcp
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
For development from a clone:
|
|
44
|
+
|
|
45
|
+
```shell
|
|
46
|
+
python -m pip install -e .
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Configure an MCP client
|
|
50
|
+
|
|
51
|
+
Run the installed command:
|
|
52
|
+
|
|
53
|
+
```toml
|
|
54
|
+
[mcp_servers.we_multitool_dut]
|
|
55
|
+
command = "multitool-mcp"
|
|
56
|
+
env_vars = ["WE_MULTITOOL_URL", "WE_MULTITOOL_TIMEOUT_SECONDS"]
|
|
57
|
+
startup_timeout_sec = 10
|
|
58
|
+
tool_timeout_sec = 35
|
|
59
|
+
default_tools_approval_mode = "prompt"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
By default, the server connects to `ws://we-multitool.local/ws`. Set these environment variables before starting the MCP client to change that:
|
|
63
|
+
|
|
64
|
+
```shell
|
|
65
|
+
WE_MULTITOOL_URL=ws://10.0.0.1/ws
|
|
66
|
+
WE_MULTITOOL_TIMEOUT_SECONDS=8
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
On PowerShell:
|
|
70
|
+
|
|
71
|
+
```powershell
|
|
72
|
+
$env:WE_MULTITOOL_URL = "ws://10.0.0.1/ws"
|
|
73
|
+
$env:WE_MULTITOOL_TIMEOUT_SECONDS = "8"
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Safety and firmware behavior
|
|
77
|
+
|
|
78
|
+
Read `pins_get` before configuring pins, and verify wiring, voltage, bus role, and DUT limits before driving signals.
|
|
79
|
+
|
|
80
|
+
- Commands are serialized because firmware responses do not carry request IDs.
|
|
81
|
+
- UART data received while another command awaits a response is buffered.
|
|
82
|
+
- The `gpio_set` firmware command has no acknowledgement; `gpio_write` reports it as dispatched but unverified.
|
|
83
|
+
- The firmware tracks one active WebSocket response socket. Do not use the browser UI and MCP bridge concurrently.
|
|
84
|
+
|
|
85
|
+
## Development
|
|
86
|
+
|
|
87
|
+
```shell
|
|
88
|
+
python -m pip install -e ".[test]"
|
|
89
|
+
python -m pytest
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The runtime has no third-party dependencies. The optional `test` extra installs pytest.
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
Licensed under the GNU General Public License, version 3 or later
|
|
97
|
+
(GPL-3.0-or-later). See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# WE Multitool DUT MCP
|
|
2
|
+
|
|
3
|
+
`multitool-mcp` is a dependency-free [Model Context Protocol](https://modelcontextprotocol.io/) server for inspecting and debugging a device under test (DUT) through a Wise Electronics WE Multitool.
|
|
4
|
+
|
|
5
|
+
It runs over standard input/output and bridges the Multitool's JSON-over-WebSocket API. It exposes bounded MCP tools for GPIO, I2C, SPI, UART, PWM, and GPIO ownership.
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
- Python 3.11 or later
|
|
10
|
+
- A WE Multitool reachable over WebSocket
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
Install from PyPI once published:
|
|
15
|
+
|
|
16
|
+
```shell
|
|
17
|
+
python -m pip install multitool-mcp
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
For development from a clone:
|
|
21
|
+
|
|
22
|
+
```shell
|
|
23
|
+
python -m pip install -e .
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Configure an MCP client
|
|
27
|
+
|
|
28
|
+
Run the installed command:
|
|
29
|
+
|
|
30
|
+
```toml
|
|
31
|
+
[mcp_servers.we_multitool_dut]
|
|
32
|
+
command = "multitool-mcp"
|
|
33
|
+
env_vars = ["WE_MULTITOOL_URL", "WE_MULTITOOL_TIMEOUT_SECONDS"]
|
|
34
|
+
startup_timeout_sec = 10
|
|
35
|
+
tool_timeout_sec = 35
|
|
36
|
+
default_tools_approval_mode = "prompt"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
By default, the server connects to `ws://we-multitool.local/ws`. Set these environment variables before starting the MCP client to change that:
|
|
40
|
+
|
|
41
|
+
```shell
|
|
42
|
+
WE_MULTITOOL_URL=ws://10.0.0.1/ws
|
|
43
|
+
WE_MULTITOOL_TIMEOUT_SECONDS=8
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
On PowerShell:
|
|
47
|
+
|
|
48
|
+
```powershell
|
|
49
|
+
$env:WE_MULTITOOL_URL = "ws://10.0.0.1/ws"
|
|
50
|
+
$env:WE_MULTITOOL_TIMEOUT_SECONDS = "8"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Safety and firmware behavior
|
|
54
|
+
|
|
55
|
+
Read `pins_get` before configuring pins, and verify wiring, voltage, bus role, and DUT limits before driving signals.
|
|
56
|
+
|
|
57
|
+
- Commands are serialized because firmware responses do not carry request IDs.
|
|
58
|
+
- UART data received while another command awaits a response is buffered.
|
|
59
|
+
- The `gpio_set` firmware command has no acknowledgement; `gpio_write` reports it as dispatched but unverified.
|
|
60
|
+
- The firmware tracks one active WebSocket response socket. Do not use the browser UI and MCP bridge concurrently.
|
|
61
|
+
|
|
62
|
+
## Development
|
|
63
|
+
|
|
64
|
+
```shell
|
|
65
|
+
python -m pip install -e ".[test]"
|
|
66
|
+
python -m pytest
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The runtime has no third-party dependencies. The optional `test` extra installs pytest.
|
|
70
|
+
|
|
71
|
+
## License
|
|
72
|
+
|
|
73
|
+
Licensed under the GNU General Public License, version 3 or later
|
|
74
|
+
(GPL-3.0-or-later). See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77.0.3"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "multitool-mcp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "MCP server for debugging devices under test through WE Multitool"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "GPL-3.0-or-later"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Wise Electronics" }]
|
|
14
|
+
keywords = ["mcp", "embedded", "hardware", "debugging", "we-multitool"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Topic :: Software Development :: Debuggers",
|
|
22
|
+
]
|
|
23
|
+
dependencies = []
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
test = ["pytest>=8"]
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
multitool-mcp = "dut_mcp.server:main"
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/we-devices/multitool-mcp"
|
|
33
|
+
Repository = "https://github.com/we-devices/multitool-mcp.git"
|
|
34
|
+
Issues = "https://github.com/we-devices/multitool-mcp/issues"
|
|
35
|
+
|
|
36
|
+
[tool.setuptools]
|
|
37
|
+
package-dir = { "" = "src" }
|
|
38
|
+
|
|
39
|
+
[tool.setuptools.packages.find]
|
|
40
|
+
where = ["src"]
|
|
41
|
+
|
|
42
|
+
[tool.pytest.ini_options]
|
|
43
|
+
testpaths = ["tests"]
|
|
44
|
+
pythonpath = ["src"]
|
|
45
|
+
addopts = "-ra"
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Client for the JSON-over-WebSocket protocol implemented by WE Multitool."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import socket
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from collections import deque
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .websocket import WebSocket, WebSocketError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DeviceError(RuntimeError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
Message = dict[str, Any]
|
|
22
|
+
Matcher = Callable[[Message], bool]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class DeviceClient:
|
|
26
|
+
"""Serializes commands because the firmware protocol has no request IDs."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, url: str, timeout: float = 5.0, transport: WebSocket | None = None) -> None:
|
|
29
|
+
self.url, self.timeout = url, timeout
|
|
30
|
+
self._ws = transport or WebSocket(url, timeout)
|
|
31
|
+
self._lock = threading.RLock()
|
|
32
|
+
self._pending: deque[Message] = deque()
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def connected(self) -> bool:
|
|
36
|
+
return self._ws.connected
|
|
37
|
+
|
|
38
|
+
def close(self) -> None:
|
|
39
|
+
with self._lock:
|
|
40
|
+
self._ws.close()
|
|
41
|
+
self._pending.clear()
|
|
42
|
+
|
|
43
|
+
def request(self, command: Message, matcher: Matcher, timeout: float | None = None) -> Message:
|
|
44
|
+
with self._lock:
|
|
45
|
+
try:
|
|
46
|
+
self._ws.send_text(json.dumps(command, separators=(",", ":")))
|
|
47
|
+
deadline = time.monotonic() + (self.timeout if timeout is None else timeout)
|
|
48
|
+
while True:
|
|
49
|
+
remaining = deadline - time.monotonic()
|
|
50
|
+
if remaining <= 0:
|
|
51
|
+
raise DeviceError(f"Timed out waiting for response to {command['type']}")
|
|
52
|
+
message = self._receive(remaining)
|
|
53
|
+
if matcher(message):
|
|
54
|
+
return message
|
|
55
|
+
self._pending.append(message)
|
|
56
|
+
except (OSError, socket.timeout, WebSocketError) as exc:
|
|
57
|
+
self._ws.close()
|
|
58
|
+
raise DeviceError(f"Device communication failed: {exc}") from exc
|
|
59
|
+
|
|
60
|
+
def send(self, command: Message) -> None:
|
|
61
|
+
with self._lock:
|
|
62
|
+
try:
|
|
63
|
+
self._ws.send_text(json.dumps(command, separators=(",", ":")))
|
|
64
|
+
except (OSError, socket.timeout, WebSocketError) as exc:
|
|
65
|
+
self._ws.close()
|
|
66
|
+
raise DeviceError(f"Device communication failed: {exc}") from exc
|
|
67
|
+
|
|
68
|
+
def read_uart(self, timeout: float, max_bytes: int) -> bytes:
|
|
69
|
+
with self._lock:
|
|
70
|
+
output, deadline = bytearray(), time.monotonic() + timeout
|
|
71
|
+
while len(output) < max_bytes:
|
|
72
|
+
message = self._take_pending(self._is_uart_data)
|
|
73
|
+
if message is None:
|
|
74
|
+
remaining = deadline - time.monotonic()
|
|
75
|
+
if remaining <= 0:
|
|
76
|
+
break
|
|
77
|
+
try:
|
|
78
|
+
if not self._ws.connected:
|
|
79
|
+
self._ws.connect()
|
|
80
|
+
message = self._receive(remaining)
|
|
81
|
+
except socket.timeout:
|
|
82
|
+
break
|
|
83
|
+
except (OSError, WebSocketError) as exc:
|
|
84
|
+
self._ws.close()
|
|
85
|
+
raise DeviceError(f"Device communication failed: {exc}") from exc
|
|
86
|
+
if not self._is_uart_data(message):
|
|
87
|
+
self._pending.append(message)
|
|
88
|
+
continue
|
|
89
|
+
try:
|
|
90
|
+
output.extend(base64.b64decode(message["data"], validate=True))
|
|
91
|
+
except (ValueError, TypeError) as exc:
|
|
92
|
+
raise DeviceError("Device returned invalid base64 UART data") from exc
|
|
93
|
+
return bytes(output[:max_bytes])
|
|
94
|
+
|
|
95
|
+
def _receive(self, timeout: float) -> Message:
|
|
96
|
+
raw = self._ws.receive_text(timeout)
|
|
97
|
+
try:
|
|
98
|
+
message = json.loads(raw)
|
|
99
|
+
except json.JSONDecodeError as exc:
|
|
100
|
+
raise DeviceError(f"Device returned invalid JSON: {raw[:200]}") from exc
|
|
101
|
+
if not isinstance(message, dict):
|
|
102
|
+
raise DeviceError("Device response is not a JSON object")
|
|
103
|
+
return message
|
|
104
|
+
|
|
105
|
+
def _take_pending(self, matcher: Matcher) -> Message | None:
|
|
106
|
+
for index, message in enumerate(self._pending):
|
|
107
|
+
if matcher(message):
|
|
108
|
+
del self._pending[index]
|
|
109
|
+
return message
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def _is_uart_data(message: Message) -> bool:
|
|
114
|
+
return message.get("type") == "uart_response" and "error" not in message
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Dependency-free STDIO MCP server for debugging DUTs through WE Multitool."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import traceback
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any, Callable
|
|
12
|
+
|
|
13
|
+
from .device import DeviceClient, DeviceError
|
|
14
|
+
|
|
15
|
+
JsonObject = dict[str, Any]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _object_schema(properties: JsonObject, required: list[str] | None = None) -> JsonObject:
|
|
19
|
+
schema: JsonObject = {"type": "object", "properties": properties, "additionalProperties": False}
|
|
20
|
+
if required:
|
|
21
|
+
schema["required"] = required
|
|
22
|
+
return schema
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
PIN = {"type": "integer", "minimum": 0, "maximum": 63, "description": "ESP32 GPIO number."}
|
|
26
|
+
OPTIONAL_PIN = {"type": "integer", "minimum": -1, "maximum": 63, "description": "ESP32 GPIO number, or -1 when unused."}
|
|
27
|
+
BYTE = {"type": "integer", "minimum": 0, "maximum": 255}
|
|
28
|
+
BYTES = {"type": "array", "items": BYTE, "maxItems": 1024}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Tool:
|
|
33
|
+
name: str
|
|
34
|
+
description: str
|
|
35
|
+
schema: JsonObject
|
|
36
|
+
handler: Callable[[JsonObject], Any]
|
|
37
|
+
read_only: bool = False
|
|
38
|
+
idempotent: bool = False
|
|
39
|
+
|
|
40
|
+
def definition(self) -> JsonObject:
|
|
41
|
+
annotations: JsonObject = {"readOnlyHint": self.read_only, "openWorldHint": False}
|
|
42
|
+
if not self.read_only:
|
|
43
|
+
annotations.update({"destructiveHint": False, "idempotentHint": self.idempotent})
|
|
44
|
+
return {"name": self.name, "description": self.description, "inputSchema": self.schema, "annotations": annotations}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class DutTools:
|
|
48
|
+
def __init__(self, client: DeviceClient) -> None:
|
|
49
|
+
self.client = client
|
|
50
|
+
self.tools = {tool.name: tool for tool in self._build_tools()}
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def _type(name: str) -> Callable[[JsonObject], bool]:
|
|
54
|
+
return lambda message: message.get("type") == name
|
|
55
|
+
|
|
56
|
+
@staticmethod
|
|
57
|
+
def _check_error(message: JsonObject) -> JsonObject:
|
|
58
|
+
if message.get("error") is True or message.get("result_type") == 0:
|
|
59
|
+
raise DeviceError(str(message.get("data", "Device command failed")))
|
|
60
|
+
return message
|
|
61
|
+
|
|
62
|
+
def _command(self, command: JsonObject, response_type: str) -> JsonObject:
|
|
63
|
+
return self._check_error(self.client.request(command, self._type(response_type)))
|
|
64
|
+
|
|
65
|
+
def _build_tools(self) -> list[Tool]:
|
|
66
|
+
return [
|
|
67
|
+
Tool("device_status", "Connect to WE Multitool and return its URL and GPIO ownership map.", _object_schema({}), self.device_status, True),
|
|
68
|
+
Tool("pins_get", "Read the current GPIO ownership map before assigning pins.", _object_schema({}), self.pins_get, True),
|
|
69
|
+
Tool("gpio_configure", "Configure a free GPIO as input or output.", _object_schema({"pin": PIN, "direction": {"type": "string", "enum": ["input", "output"]}}, ["pin", "direction"]), self.gpio_configure, False, True),
|
|
70
|
+
Tool("gpio_read", "Read a GPIO that is configured as input.", _object_schema({"pin": PIN}, ["pin"]), self.gpio_read, True),
|
|
71
|
+
Tool("gpio_write", "Drive a GPIO configured as output. Firmware dispatches this command without an acknowledgement.", _object_schema({"pin": PIN, "level": {"type": "integer", "enum": [0, 1]}}, ["pin", "level"]), self.gpio_write, False, True),
|
|
72
|
+
Tool("i2c_configure", "Configure WE Multitool as an I2C master.", _object_schema({"sda_pin": PIN, "scl_pin": PIN, "frequency_hz": {"type": "integer", "minimum": 1000, "maximum": 1000000}}, ["sda_pin", "scl_pin", "frequency_hz"]), self.i2c_configure, False, True),
|
|
73
|
+
Tool("i2c_scan", "Scan the configured I2C bus and return responding 7-bit addresses.", _object_schema({}), self.i2c_scan, True),
|
|
74
|
+
Tool("i2c_read", "Read bytes from a register on the configured I2C bus.", _object_schema({"address": {"type": "integer", "minimum": 1, "maximum": 127}, "register": BYTE, "length": {"type": "integer", "minimum": 1, "maximum": 128}}, ["address", "register", "length"]), self.i2c_read, True),
|
|
75
|
+
Tool("i2c_write", "Write bytes to a register on the configured I2C bus.", _object_schema({"address": {"type": "integer", "minimum": 1, "maximum": 127}, "register": BYTE, "data": BYTES}, ["address", "register", "data"]), self.i2c_write),
|
|
76
|
+
Tool("spi_configure", "Configure WE Multitool as an SPI master.", _object_schema({"cs_pin": PIN, "sck_pin": PIN, "mosi_pin": PIN, "miso_pin": PIN, "frequency_hz": {"type": "integer", "minimum": 1000}, "mode": {"type": "integer", "minimum": 0, "maximum": 3}}, ["cs_pin", "sck_pin", "mosi_pin", "miso_pin", "frequency_hz", "mode"]), self.spi_configure, False, True),
|
|
77
|
+
Tool("spi_transfer", "Write bytes and optionally read bytes in one SPI master transaction.", _object_schema({"write_data": BYTES, "read_length": {"type": "integer", "minimum": 0, "maximum": 1024}}, ["write_data", "read_length"]), self.spi_transfer),
|
|
78
|
+
Tool("uart_configure", "Configure UART2 pins and framing. Use -1 for unused CTS/RTS pins.", _object_schema({"tx_pin": PIN, "rx_pin": PIN, "baudrate": {"type": "integer", "minimum": 300, "maximum": 5000000}, "data_bits": {"type": "integer", "enum": [5, 6, 7, 8]}, "parity": {"type": "string", "enum": ["none", "even", "odd"]}, "stop_bits": {"type": "number", "enum": [1, 1.5, 2]}, "flow_control": {"type": "string", "enum": ["none", "rts", "cts", "cts_rts"]}, "cts_pin": OPTIONAL_PIN, "rts_pin": OPTIONAL_PIN}, ["tx_pin", "rx_pin"]), self.uart_configure, False, True),
|
|
79
|
+
Tool("uart_write", "Send text, base64, or byte-array data to the configured DUT UART.", _object_schema({"text": {"type": "string"}, "base64_data": {"type": "string"}, "bytes": BYTES}), self.uart_write),
|
|
80
|
+
Tool("uart_read", "Collect buffered UART bytes from the DUT for a bounded time.", _object_schema({"timeout_ms": {"type": "integer", "minimum": 0, "maximum": 30000}, "max_bytes": {"type": "integer", "minimum": 1, "maximum": 10240}}), self.uart_read, True),
|
|
81
|
+
Tool("pwm_configure", "Configure PWM on a free pin and channel.", _object_schema({"pin": PIN, "frequency_hz": {"type": "integer", "minimum": 1}, "channel": {"type": "integer", "minimum": 0, "maximum": 7}}, ["pin", "frequency_hz", "channel"]), self.pwm_configure, False, True),
|
|
82
|
+
Tool("pwm_set", "Set PWM duty cycle in percent on a configured pin.", _object_schema({"pin": PIN, "duty_percent": {"type": "number", "minimum": 0, "maximum": 100}}, ["pin", "duty_percent"]), self.pwm_set, False, True),
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
def device_status(self, _: JsonObject) -> JsonObject:
|
|
86
|
+
return {"url": self.client.url, "connected": True, **self.pins_get({})}
|
|
87
|
+
|
|
88
|
+
def pins_get(self, _: JsonObject) -> JsonObject:
|
|
89
|
+
return {"pins": self._command({"type": "pins_get"}, "pins_get").get("pins", [])}
|
|
90
|
+
|
|
91
|
+
def gpio_configure(self, args: JsonObject) -> JsonObject:
|
|
92
|
+
return self._command({"type": "gpio_setup", "pin": args["pin"], "dir": args["direction"]}, "status")
|
|
93
|
+
|
|
94
|
+
def gpio_read(self, args: JsonObject) -> JsonObject:
|
|
95
|
+
return self._command({"type": "gpio_get", "pin": args["pin"]}, "gpio_state")
|
|
96
|
+
|
|
97
|
+
def gpio_write(self, args: JsonObject) -> JsonObject:
|
|
98
|
+
self.client.send({"type": "gpio_set", "pin": args["pin"], "state": args["level"]})
|
|
99
|
+
return {"dispatched": True, "verified": False, "note": "Current firmware does not acknowledge gpio_set."}
|
|
100
|
+
|
|
101
|
+
def i2c_configure(self, args: JsonObject) -> JsonObject:
|
|
102
|
+
return self._command({"type": "i2c_setup", "mode": "master", "sda": args["sda_pin"], "scl": args["scl_pin"], "freq": args["frequency_hz"]}, "i2c_response")
|
|
103
|
+
|
|
104
|
+
def i2c_scan(self, _: JsonObject) -> JsonObject:
|
|
105
|
+
return {"addresses": self._command({"type": "i2c_detect"}, "i2c_response").get("data", [])}
|
|
106
|
+
|
|
107
|
+
def i2c_read(self, args: JsonObject) -> JsonObject:
|
|
108
|
+
response = self._command({"type": "i2c_read", "addr": args["address"], "reg": args["register"], "len": args["length"]}, "i2c_response")
|
|
109
|
+
return {"address": args["address"], "register": args["register"], "data": response.get("data", [])}
|
|
110
|
+
|
|
111
|
+
def i2c_write(self, args: JsonObject) -> JsonObject:
|
|
112
|
+
return self._command({"type": "i2c_write", "addr": args["address"], "reg": args["register"], "data": args["data"]}, "i2c_response")
|
|
113
|
+
|
|
114
|
+
def spi_configure(self, args: JsonObject) -> JsonObject:
|
|
115
|
+
return self._command({"type": "spi_setup", "mode": "Master", "conn_mode": args["mode"] + 1, "cs": args["cs_pin"], "sck": args["sck_pin"], "mosi": args["mosi_pin"], "miso": args["miso_pin"], "freq": args["frequency_hz"]}, "spi_response")
|
|
116
|
+
|
|
117
|
+
def spi_transfer(self, args: JsonObject) -> JsonObject:
|
|
118
|
+
return self._command({"type": "spi_write", "word_length": 8, "data": args["write_data"], "data_read": args["read_length"]}, "spi_response")
|
|
119
|
+
|
|
120
|
+
def uart_configure(self, args: JsonObject) -> JsonObject:
|
|
121
|
+
command = {
|
|
122
|
+
"type": "uart_setup", "tx": args["tx_pin"], "rx": args["rx_pin"],
|
|
123
|
+
"cts": args.get("cts_pin", -1), "rts": args.get("rts_pin", -1),
|
|
124
|
+
"baudrate": args.get("baudrate", 115200), "databits": {5: 0, 6: 1, 7: 2, 8: 3}[args.get("data_bits", 8)],
|
|
125
|
+
"parity": {"none": 0, "even": 2, "odd": 3}[args.get("parity", "none")],
|
|
126
|
+
"stopbits": {1: 1, 1.5: 2, 2: 3}[args.get("stop_bits", 1)],
|
|
127
|
+
"flow": {"none": 0, "rts": 1, "cts": 2, "cts_rts": 3}[args.get("flow_control", "none")],
|
|
128
|
+
}
|
|
129
|
+
response = self.client.request(command, lambda message: message.get("type") == "uart_response" and "error" in message)
|
|
130
|
+
return self._check_error(response)
|
|
131
|
+
|
|
132
|
+
def uart_write(self, args: JsonObject) -> JsonObject:
|
|
133
|
+
choices = [key for key in ("text", "base64_data", "bytes") if key in args]
|
|
134
|
+
if len(choices) != 1:
|
|
135
|
+
raise DeviceError("Provide exactly one of text, base64_data, or bytes")
|
|
136
|
+
if choices[0] == "text":
|
|
137
|
+
data: Any = args["text"]
|
|
138
|
+
elif choices[0] == "base64_data":
|
|
139
|
+
try:
|
|
140
|
+
data = list(base64.b64decode(args["base64_data"], validate=True))
|
|
141
|
+
except (ValueError, TypeError) as exc:
|
|
142
|
+
raise DeviceError("base64_data is not valid base64") from exc
|
|
143
|
+
else:
|
|
144
|
+
data = args["bytes"]
|
|
145
|
+
response = self.client.request({"type": "uart_write", "data": data}, lambda message: message.get("type") == "uart_response" and "error" in message)
|
|
146
|
+
return self._check_error(response)
|
|
147
|
+
|
|
148
|
+
def uart_read(self, args: JsonObject) -> JsonObject:
|
|
149
|
+
data = self.client.read_uart(args.get("timeout_ms", 1000) / 1000, args.get("max_bytes", 4096))
|
|
150
|
+
return {"length": len(data), "base64_data": base64.b64encode(data).decode("ascii"), "text_utf8": data.decode("utf-8", errors="replace")}
|
|
151
|
+
|
|
152
|
+
def pwm_configure(self, args: JsonObject) -> JsonObject:
|
|
153
|
+
return self._command({"type": "pwm_setup", "pin": args["pin"], "freq": args["frequency_hz"], "channel": args["channel"]}, "pwm_response")
|
|
154
|
+
|
|
155
|
+
def pwm_set(self, args: JsonObject) -> JsonObject:
|
|
156
|
+
return self._command({"type": "pwm_set", "pin": args["pin"], "dutyCycle": args["duty_percent"]}, "pwm_response")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class McpServer:
|
|
160
|
+
PROTOCOL_VERSION = "2025-06-18"
|
|
161
|
+
|
|
162
|
+
def __init__(self, tools: DutTools) -> None:
|
|
163
|
+
self.tools = tools
|
|
164
|
+
|
|
165
|
+
def handle(self, request: JsonObject) -> JsonObject | None:
|
|
166
|
+
if "id" not in request:
|
|
167
|
+
return None
|
|
168
|
+
request_id, method = request["id"], request.get("method")
|
|
169
|
+
try:
|
|
170
|
+
if method == "initialize":
|
|
171
|
+
requested = request.get("params", {}).get("protocolVersion")
|
|
172
|
+
return self._result(request_id, {
|
|
173
|
+
"protocolVersion": requested or self.PROTOCOL_VERSION,
|
|
174
|
+
"capabilities": {"tools": {"listChanged": False}},
|
|
175
|
+
"serverInfo": {"name": "multitool-mcp", "version": "0.1.0"},
|
|
176
|
+
"instructions": "Debug DUTs through WE Multitool. Read pins_get before configuring pins. Prefer read-only observations first. Confirm wiring, voltage, bus role, and target limits before changing GPIO/PWM or writing UART/I2C/SPI. Commands are serialized because firmware responses have no request IDs.",
|
|
177
|
+
})
|
|
178
|
+
if method == "ping":
|
|
179
|
+
return self._result(request_id, {})
|
|
180
|
+
if method == "tools/list":
|
|
181
|
+
return self._result(request_id, {"tools": [tool.definition() for tool in self.tools.tools.values()]})
|
|
182
|
+
if method == "tools/call":
|
|
183
|
+
params = request.get("params", {})
|
|
184
|
+
name = params.get("name")
|
|
185
|
+
tool = self.tools.tools.get(name)
|
|
186
|
+
if tool is None:
|
|
187
|
+
return self._error(request_id, -32602, f"Unknown tool: {name}")
|
|
188
|
+
arguments = params.get("arguments", {})
|
|
189
|
+
if not isinstance(arguments, dict):
|
|
190
|
+
return self._error(request_id, -32602, "Tool arguments must be an object")
|
|
191
|
+
try:
|
|
192
|
+
self._validate(arguments, tool.schema)
|
|
193
|
+
value = tool.handler(arguments)
|
|
194
|
+
text = json.dumps(value, separators=(",", ":"), ensure_ascii=False)
|
|
195
|
+
return self._result(request_id, {"content": [{"type": "text", "text": text}], "structuredContent": value})
|
|
196
|
+
except (DeviceError, KeyError, ValueError, TypeError) as exc:
|
|
197
|
+
return self._result(request_id, {"content": [{"type": "text", "text": str(exc)}], "isError": True})
|
|
198
|
+
return self._error(request_id, -32601, f"Method not found: {method}")
|
|
199
|
+
except Exception as exc:
|
|
200
|
+
traceback.print_exc(file=sys.stderr)
|
|
201
|
+
return self._error(request_id, -32603, f"Internal error: {exc}")
|
|
202
|
+
|
|
203
|
+
@staticmethod
|
|
204
|
+
def _result(request_id: Any, result: JsonObject) -> JsonObject:
|
|
205
|
+
return {"jsonrpc": "2.0", "id": request_id, "result": result}
|
|
206
|
+
|
|
207
|
+
@staticmethod
|
|
208
|
+
def _error(request_id: Any, code: int, message: str) -> JsonObject:
|
|
209
|
+
return {"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}}
|
|
210
|
+
|
|
211
|
+
@classmethod
|
|
212
|
+
def _validate(cls, value: Any, schema: JsonObject, path: str = "arguments") -> None:
|
|
213
|
+
expected = schema.get("type")
|
|
214
|
+
if expected == "object":
|
|
215
|
+
if not isinstance(value, dict):
|
|
216
|
+
raise ValueError(f"{path} must be an object")
|
|
217
|
+
missing = [name for name in schema.get("required", []) if name not in value]
|
|
218
|
+
if missing:
|
|
219
|
+
raise ValueError(f"{path} is missing required field(s): {', '.join(missing)}")
|
|
220
|
+
properties = schema.get("properties", {})
|
|
221
|
+
if schema.get("additionalProperties") is False:
|
|
222
|
+
extras = [name for name in value if name not in properties]
|
|
223
|
+
if extras:
|
|
224
|
+
raise ValueError(f"{path} has unknown field(s): {', '.join(extras)}")
|
|
225
|
+
for name, item in value.items():
|
|
226
|
+
if name in properties:
|
|
227
|
+
cls._validate(item, properties[name], f"{path}.{name}")
|
|
228
|
+
return
|
|
229
|
+
if expected == "array":
|
|
230
|
+
if not isinstance(value, list):
|
|
231
|
+
raise ValueError(f"{path} must be an array")
|
|
232
|
+
if len(value) > schema.get("maxItems", len(value)):
|
|
233
|
+
raise ValueError(f"{path} has too many items")
|
|
234
|
+
for index, item in enumerate(value):
|
|
235
|
+
cls._validate(item, schema.get("items", {}), f"{path}[{index}]")
|
|
236
|
+
elif expected == "integer":
|
|
237
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
238
|
+
raise ValueError(f"{path} must be an integer")
|
|
239
|
+
elif expected == "number":
|
|
240
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
241
|
+
raise ValueError(f"{path} must be a number")
|
|
242
|
+
elif expected == "string" and not isinstance(value, str):
|
|
243
|
+
raise ValueError(f"{path} must be a string")
|
|
244
|
+
if "enum" in schema and value not in schema["enum"]:
|
|
245
|
+
raise ValueError(f"{path} must be one of {schema['enum']}")
|
|
246
|
+
if "minimum" in schema and value < schema["minimum"]:
|
|
247
|
+
raise ValueError(f"{path} must be at least {schema['minimum']}")
|
|
248
|
+
if "maximum" in schema and value > schema["maximum"]:
|
|
249
|
+
raise ValueError(f"{path} must be at most {schema['maximum']}")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def main() -> None:
|
|
253
|
+
url = os.environ.get("WE_MULTITOOL_URL", "ws://we-multitool.local/ws")
|
|
254
|
+
timeout = float(os.environ.get("WE_MULTITOOL_TIMEOUT_SECONDS", "5"))
|
|
255
|
+
client = DeviceClient(url, timeout)
|
|
256
|
+
server = McpServer(DutTools(client))
|
|
257
|
+
try:
|
|
258
|
+
for line in sys.stdin:
|
|
259
|
+
try:
|
|
260
|
+
request = json.loads(line)
|
|
261
|
+
if not isinstance(request, dict):
|
|
262
|
+
raise ValueError("Request must be a JSON object")
|
|
263
|
+
response = server.handle(request)
|
|
264
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
265
|
+
response = McpServer._error(None, -32700, str(exc))
|
|
266
|
+
if response is not None:
|
|
267
|
+
print(json.dumps(response, separators=(",", ":"), ensure_ascii=False), flush=True)
|
|
268
|
+
finally:
|
|
269
|
+
client.close()
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Small RFC 6455 client used to keep the MCP bridge dependency-free."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import os
|
|
8
|
+
import socket
|
|
9
|
+
import ssl
|
|
10
|
+
import struct
|
|
11
|
+
from urllib.parse import urlsplit
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class WebSocketError(RuntimeError):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class WebSocket:
|
|
19
|
+
def __init__(self, url: str, timeout: float = 5.0) -> None:
|
|
20
|
+
self.url = url
|
|
21
|
+
self.timeout = timeout
|
|
22
|
+
self._socket: socket.socket | None = None
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def connected(self) -> bool:
|
|
26
|
+
return self._socket is not None
|
|
27
|
+
|
|
28
|
+
def connect(self) -> None:
|
|
29
|
+
if self._socket is not None:
|
|
30
|
+
return
|
|
31
|
+
uri = urlsplit(self.url)
|
|
32
|
+
if uri.scheme not in {"ws", "wss"} or not uri.hostname:
|
|
33
|
+
raise WebSocketError("Device URL must use ws:// or wss://")
|
|
34
|
+
port = uri.port or (443 if uri.scheme == "wss" else 80)
|
|
35
|
+
raw = socket.create_connection((uri.hostname, port), timeout=self.timeout)
|
|
36
|
+
if uri.scheme == "wss":
|
|
37
|
+
raw = ssl.create_default_context().wrap_socket(raw, server_hostname=uri.hostname)
|
|
38
|
+
raw.settimeout(self.timeout)
|
|
39
|
+
key = base64.b64encode(os.urandom(16)).decode("ascii")
|
|
40
|
+
path = uri.path or "/"
|
|
41
|
+
if uri.query:
|
|
42
|
+
path += "?" + uri.query
|
|
43
|
+
host = uri.hostname if uri.port is None else f"{uri.hostname}:{uri.port}"
|
|
44
|
+
request = (
|
|
45
|
+
f"GET {path} HTTP/1.1\r\nHost: {host}\r\nUpgrade: websocket\r\n"
|
|
46
|
+
f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n"
|
|
47
|
+
)
|
|
48
|
+
try:
|
|
49
|
+
raw.sendall(request.encode("ascii"))
|
|
50
|
+
response = self._receive_headers(raw)
|
|
51
|
+
status = response.split("\r\n", 1)[0]
|
|
52
|
+
if " 101 " not in status:
|
|
53
|
+
raise WebSocketError(f"WebSocket upgrade failed: {status}")
|
|
54
|
+
headers = {}
|
|
55
|
+
for line in response.split("\r\n")[1:]:
|
|
56
|
+
if ":" in line:
|
|
57
|
+
name, value = line.split(":", 1)
|
|
58
|
+
headers[name.strip().lower()] = value.strip()
|
|
59
|
+
expected = base64.b64encode(hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode("ascii")).digest()).decode("ascii")
|
|
60
|
+
if headers.get("sec-websocket-accept") != expected:
|
|
61
|
+
raise WebSocketError("WebSocket server returned an invalid accept key")
|
|
62
|
+
except Exception:
|
|
63
|
+
raw.close()
|
|
64
|
+
raise
|
|
65
|
+
self._socket = raw
|
|
66
|
+
|
|
67
|
+
def close(self) -> None:
|
|
68
|
+
sock, self._socket = self._socket, None
|
|
69
|
+
if sock is None:
|
|
70
|
+
return
|
|
71
|
+
try:
|
|
72
|
+
self._send_frame(0x8, b"", sock=sock)
|
|
73
|
+
except OSError:
|
|
74
|
+
pass
|
|
75
|
+
finally:
|
|
76
|
+
sock.close()
|
|
77
|
+
|
|
78
|
+
def send_text(self, text: str) -> None:
|
|
79
|
+
self.connect()
|
|
80
|
+
self._send_frame(0x1, text.encode("utf-8"))
|
|
81
|
+
|
|
82
|
+
def receive_text(self, timeout: float | None = None) -> str:
|
|
83
|
+
if self._socket is None:
|
|
84
|
+
raise WebSocketError("WebSocket is not connected")
|
|
85
|
+
previous_timeout = self._socket.gettimeout()
|
|
86
|
+
if timeout is not None:
|
|
87
|
+
self._socket.settimeout(timeout)
|
|
88
|
+
try:
|
|
89
|
+
fragments = bytearray()
|
|
90
|
+
while True:
|
|
91
|
+
final, opcode, payload = self._receive_frame()
|
|
92
|
+
if opcode == 0x8:
|
|
93
|
+
self.close()
|
|
94
|
+
raise WebSocketError("Device closed the WebSocket connection")
|
|
95
|
+
if opcode == 0x9:
|
|
96
|
+
self._send_frame(0xA, payload)
|
|
97
|
+
continue
|
|
98
|
+
if opcode == 0xA:
|
|
99
|
+
continue
|
|
100
|
+
if opcode not in {0x0, 0x1}:
|
|
101
|
+
raise WebSocketError(f"Unsupported WebSocket opcode: {opcode}")
|
|
102
|
+
fragments.extend(payload)
|
|
103
|
+
if final:
|
|
104
|
+
return fragments.decode("utf-8")
|
|
105
|
+
finally:
|
|
106
|
+
if self._socket is not None:
|
|
107
|
+
self._socket.settimeout(previous_timeout)
|
|
108
|
+
|
|
109
|
+
@staticmethod
|
|
110
|
+
def _receive_headers(sock: socket.socket) -> str:
|
|
111
|
+
data = bytearray()
|
|
112
|
+
while b"\r\n\r\n" not in data:
|
|
113
|
+
chunk = sock.recv(4096)
|
|
114
|
+
if not chunk:
|
|
115
|
+
raise WebSocketError("Connection closed during WebSocket upgrade")
|
|
116
|
+
data.extend(chunk)
|
|
117
|
+
if len(data) > 65536:
|
|
118
|
+
raise WebSocketError("WebSocket upgrade headers are too large")
|
|
119
|
+
return bytes(data).split(b"\r\n\r\n", 1)[0].decode("iso-8859-1")
|
|
120
|
+
|
|
121
|
+
def _receive_frame(self) -> tuple[bool, int, bytes]:
|
|
122
|
+
first, second = self._read_exact(2)
|
|
123
|
+
final, opcode, masked = bool(first & 0x80), first & 0x0F, bool(second & 0x80)
|
|
124
|
+
length = second & 0x7F
|
|
125
|
+
if length == 126:
|
|
126
|
+
length = struct.unpack("!H", self._read_exact(2))[0]
|
|
127
|
+
elif length == 127:
|
|
128
|
+
length = struct.unpack("!Q", self._read_exact(8))[0]
|
|
129
|
+
mask = self._read_exact(4) if masked else b""
|
|
130
|
+
payload = self._read_exact(length)
|
|
131
|
+
if masked:
|
|
132
|
+
payload = bytes(value ^ mask[index % 4] for index, value in enumerate(payload))
|
|
133
|
+
return final, opcode, payload
|
|
134
|
+
|
|
135
|
+
def _read_exact(self, length: int) -> bytes:
|
|
136
|
+
assert self._socket is not None
|
|
137
|
+
data = bytearray()
|
|
138
|
+
while len(data) < length:
|
|
139
|
+
chunk = self._socket.recv(length - len(data))
|
|
140
|
+
if not chunk:
|
|
141
|
+
raise WebSocketError("Device closed the WebSocket connection")
|
|
142
|
+
data.extend(chunk)
|
|
143
|
+
return bytes(data)
|
|
144
|
+
|
|
145
|
+
def _send_frame(self, opcode: int, payload: bytes, sock: socket.socket | None = None) -> None:
|
|
146
|
+
target = sock or self._socket
|
|
147
|
+
if target is None:
|
|
148
|
+
raise WebSocketError("WebSocket is not connected")
|
|
149
|
+
mask, length = os.urandom(4), len(payload)
|
|
150
|
+
header = bytearray([0x80 | opcode])
|
|
151
|
+
if length < 126:
|
|
152
|
+
header.append(0x80 | length)
|
|
153
|
+
elif length <= 0xFFFF:
|
|
154
|
+
header.append(0x80 | 126)
|
|
155
|
+
header.extend(struct.pack("!H", length))
|
|
156
|
+
else:
|
|
157
|
+
header.append(0x80 | 127)
|
|
158
|
+
header.extend(struct.pack("!Q", length))
|
|
159
|
+
masked = bytes(value ^ mask[index % 4] for index, value in enumerate(payload))
|
|
160
|
+
target.sendall(bytes(header) + mask + masked)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: multitool-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for debugging devices under test through WE Multitool
|
|
5
|
+
Author: Wise Electronics
|
|
6
|
+
License-Expression: GPL-3.0-or-later
|
|
7
|
+
Project-URL: Homepage, https://github.com/we-devices/multitool-mcp
|
|
8
|
+
Project-URL: Repository, https://github.com/we-devices/multitool-mcp.git
|
|
9
|
+
Project-URL: Issues, https://github.com/we-devices/multitool-mcp/issues
|
|
10
|
+
Keywords: mcp,embedded,hardware,debugging,we-multitool
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Topic :: Software Development :: Debuggers
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Provides-Extra: test
|
|
21
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# WE Multitool DUT MCP
|
|
25
|
+
|
|
26
|
+
`multitool-mcp` is a dependency-free [Model Context Protocol](https://modelcontextprotocol.io/) server for inspecting and debugging a device under test (DUT) through a Wise Electronics WE Multitool.
|
|
27
|
+
|
|
28
|
+
It runs over standard input/output and bridges the Multitool's JSON-over-WebSocket API. It exposes bounded MCP tools for GPIO, I2C, SPI, UART, PWM, and GPIO ownership.
|
|
29
|
+
|
|
30
|
+
## Requirements
|
|
31
|
+
|
|
32
|
+
- Python 3.11 or later
|
|
33
|
+
- A WE Multitool reachable over WebSocket
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
Install from PyPI once published:
|
|
38
|
+
|
|
39
|
+
```shell
|
|
40
|
+
python -m pip install multitool-mcp
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
For development from a clone:
|
|
44
|
+
|
|
45
|
+
```shell
|
|
46
|
+
python -m pip install -e .
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Configure an MCP client
|
|
50
|
+
|
|
51
|
+
Run the installed command:
|
|
52
|
+
|
|
53
|
+
```toml
|
|
54
|
+
[mcp_servers.we_multitool_dut]
|
|
55
|
+
command = "multitool-mcp"
|
|
56
|
+
env_vars = ["WE_MULTITOOL_URL", "WE_MULTITOOL_TIMEOUT_SECONDS"]
|
|
57
|
+
startup_timeout_sec = 10
|
|
58
|
+
tool_timeout_sec = 35
|
|
59
|
+
default_tools_approval_mode = "prompt"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
By default, the server connects to `ws://we-multitool.local/ws`. Set these environment variables before starting the MCP client to change that:
|
|
63
|
+
|
|
64
|
+
```shell
|
|
65
|
+
WE_MULTITOOL_URL=ws://10.0.0.1/ws
|
|
66
|
+
WE_MULTITOOL_TIMEOUT_SECONDS=8
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
On PowerShell:
|
|
70
|
+
|
|
71
|
+
```powershell
|
|
72
|
+
$env:WE_MULTITOOL_URL = "ws://10.0.0.1/ws"
|
|
73
|
+
$env:WE_MULTITOOL_TIMEOUT_SECONDS = "8"
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Safety and firmware behavior
|
|
77
|
+
|
|
78
|
+
Read `pins_get` before configuring pins, and verify wiring, voltage, bus role, and DUT limits before driving signals.
|
|
79
|
+
|
|
80
|
+
- Commands are serialized because firmware responses do not carry request IDs.
|
|
81
|
+
- UART data received while another command awaits a response is buffered.
|
|
82
|
+
- The `gpio_set` firmware command has no acknowledgement; `gpio_write` reports it as dispatched but unverified.
|
|
83
|
+
- The firmware tracks one active WebSocket response socket. Do not use the browser UI and MCP bridge concurrently.
|
|
84
|
+
|
|
85
|
+
## Development
|
|
86
|
+
|
|
87
|
+
```shell
|
|
88
|
+
python -m pip install -e ".[test]"
|
|
89
|
+
python -m pytest
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The runtime has no third-party dependencies. The optional `test` extra installs pytest.
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
Licensed under the GNU General Public License, version 3 or later
|
|
97
|
+
(GPL-3.0-or-later). See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/dut_mcp/__init__.py
|
|
5
|
+
src/dut_mcp/__main__.py
|
|
6
|
+
src/dut_mcp/device.py
|
|
7
|
+
src/dut_mcp/server.py
|
|
8
|
+
src/dut_mcp/websocket.py
|
|
9
|
+
src/multitool_mcp.egg-info/PKG-INFO
|
|
10
|
+
src/multitool_mcp.egg-info/SOURCES.txt
|
|
11
|
+
src/multitool_mcp.egg-info/dependency_links.txt
|
|
12
|
+
src/multitool_mcp.egg-info/entry_points.txt
|
|
13
|
+
src/multitool_mcp.egg-info/requires.txt
|
|
14
|
+
src/multitool_mcp.egg-info/top_level.txt
|
|
15
|
+
tests/test_dut_mcp.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
dut_mcp
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import json
|
|
3
|
+
import socket
|
|
4
|
+
|
|
5
|
+
from dut_mcp.device import DeviceClient
|
|
6
|
+
from dut_mcp.server import DutTools, McpServer
|
|
7
|
+
from dut_mcp.websocket import WebSocket
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FakeTransport:
|
|
11
|
+
def __init__(self, responses=()):
|
|
12
|
+
self.responses = list(responses)
|
|
13
|
+
self.sent = []
|
|
14
|
+
self.connected = False
|
|
15
|
+
|
|
16
|
+
def connect(self):
|
|
17
|
+
self.connected = True
|
|
18
|
+
|
|
19
|
+
def close(self):
|
|
20
|
+
self.connected = False
|
|
21
|
+
|
|
22
|
+
def send_text(self, text):
|
|
23
|
+
self.connected = True
|
|
24
|
+
self.sent.append(json.loads(text))
|
|
25
|
+
|
|
26
|
+
def receive_text(self, timeout=None):
|
|
27
|
+
if not self.responses:
|
|
28
|
+
raise socket.timeout()
|
|
29
|
+
return json.dumps(self.responses.pop(0))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class FakeSocket:
|
|
33
|
+
def __init__(self, incoming=b""):
|
|
34
|
+
self.incoming = bytearray(incoming)
|
|
35
|
+
self.outgoing = b""
|
|
36
|
+
self.timeout = None
|
|
37
|
+
|
|
38
|
+
def recv(self, length):
|
|
39
|
+
result = bytes(self.incoming[:length])
|
|
40
|
+
del self.incoming[:length]
|
|
41
|
+
return result
|
|
42
|
+
|
|
43
|
+
def sendall(self, data):
|
|
44
|
+
self.outgoing += data
|
|
45
|
+
|
|
46
|
+
def gettimeout(self):
|
|
47
|
+
return self.timeout
|
|
48
|
+
|
|
49
|
+
def settimeout(self, timeout):
|
|
50
|
+
self.timeout = timeout
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def make_server(responses=()):
|
|
54
|
+
transport = FakeTransport(responses)
|
|
55
|
+
client = DeviceClient("ws://test/ws", timeout=0.01, transport=transport)
|
|
56
|
+
return McpServer(DutTools(client)), transport
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_initialize_and_tool_annotations():
|
|
60
|
+
server, _ = make_server()
|
|
61
|
+
initialized = server.handle({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18"}})
|
|
62
|
+
assert initialized["result"]["protocolVersion"] == "2025-06-18"
|
|
63
|
+
listed = server.handle({"jsonrpc": "2.0", "id": 2, "method": "tools/list"})
|
|
64
|
+
tools = {tool["name"]: tool for tool in listed["result"]["tools"]}
|
|
65
|
+
assert tools["gpio_read"]["annotations"]["readOnlyHint"] is True
|
|
66
|
+
assert tools["gpio_write"]["annotations"]["readOnlyHint"] is False
|
|
67
|
+
assert tools["i2c_read"]["inputSchema"]["additionalProperties"] is False
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_i2c_read_translates_wire_contract():
|
|
71
|
+
server, transport = make_server([
|
|
72
|
+
{"type": "uart_response", "data": base64.b64encode(b"boot").decode()},
|
|
73
|
+
{"type": "i2c_response", "result_type": 2, "addr": 0x48, "data": [1, 2]},
|
|
74
|
+
])
|
|
75
|
+
response = server.handle({"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "i2c_read", "arguments": {"address": 0x48, "register": 1, "length": 2}}})
|
|
76
|
+
assert transport.sent == [{"type": "i2c_read", "addr": 0x48, "reg": 1, "len": 2}]
|
|
77
|
+
assert response["result"]["structuredContent"]["data"] == [1, 2]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_uart_rx_is_buffered_while_waiting_for_ack():
|
|
81
|
+
server, _ = make_server([
|
|
82
|
+
{"type": "uart_response", "data": base64.b64encode(b"hello").decode()},
|
|
83
|
+
{"type": "uart_response", "data": "Data has been sent", "error": False},
|
|
84
|
+
])
|
|
85
|
+
write = server.handle({"jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": {"name": "uart_write", "arguments": {"text": "AT\r\n"}}})
|
|
86
|
+
assert write["result"].get("isError") is not True
|
|
87
|
+
read = server.handle({"jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": {"name": "uart_read", "arguments": {"timeout_ms": 0, "max_bytes": 20}}})
|
|
88
|
+
assert read["result"]["structuredContent"]["text_utf8"] == "hello"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_gpio_write_reports_unverified_dispatch():
|
|
92
|
+
server, transport = make_server()
|
|
93
|
+
response = server.handle({"jsonrpc": "2.0", "id": 6, "method": "tools/call", "params": {"name": "gpio_write", "arguments": {"pin": 4, "level": 1}}})
|
|
94
|
+
assert transport.sent == [{"type": "gpio_set", "pin": 4, "state": 1}]
|
|
95
|
+
assert response["result"]["structuredContent"]["verified"] is False
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_device_error_becomes_mcp_tool_error():
|
|
99
|
+
server, _ = make_server([{"type": "i2c_response", "result_type": 0, "data": "not connected"}])
|
|
100
|
+
response = server.handle({"jsonrpc": "2.0", "id": 7, "method": "tools/call", "params": {"name": "i2c_scan", "arguments": {}}})
|
|
101
|
+
assert response["result"]["isError"] is True
|
|
102
|
+
assert "not connected" in response["result"]["content"][0]["text"]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_uart_write_requires_exactly_one_encoding():
|
|
106
|
+
server, _ = make_server()
|
|
107
|
+
response = server.handle({"jsonrpc": "2.0", "id": 8, "method": "tools/call", "params": {"name": "uart_write", "arguments": {"text": "x", "bytes": [1]}}})
|
|
108
|
+
assert response["result"]["isError"] is True
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_arguments_are_validated_before_device_access():
|
|
112
|
+
server, transport = make_server()
|
|
113
|
+
response = server.handle({"jsonrpc": "2.0", "id": 9, "method": "tools/call", "params": {"name": "gpio_write", "arguments": {"pin": -1, "level": 2}}})
|
|
114
|
+
assert response["result"]["isError"] is True
|
|
115
|
+
assert transport.sent == []
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_websocket_decodes_server_text_frame():
|
|
119
|
+
websocket = WebSocket("ws://test/ws")
|
|
120
|
+
websocket._socket = FakeSocket(b"\x81\x05hello")
|
|
121
|
+
assert websocket.receive_text() == "hello"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_websocket_masks_client_text_frame():
|
|
125
|
+
websocket = WebSocket("ws://test/ws")
|
|
126
|
+
sock = FakeSocket()
|
|
127
|
+
websocket._socket = sock
|
|
128
|
+
websocket.send_text("hello")
|
|
129
|
+
assert sock.outgoing[0] == 0x81
|
|
130
|
+
assert sock.outgoing[1] & 0x80
|
|
131
|
+
length = sock.outgoing[1] & 0x7F
|
|
132
|
+
mask = sock.outgoing[2:6]
|
|
133
|
+
encoded = sock.outgoing[6:6 + length]
|
|
134
|
+
assert bytes(value ^ mask[index % 4] for index, value in enumerate(encoded)) == b"hello"
|