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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ """MCP bridge for the WE Multitool device-under-test interfaces."""
2
+
3
+ from .device import DeviceClient, DeviceError
4
+
5
+ __all__ = ["DeviceClient", "DeviceError"]
@@ -0,0 +1,5 @@
1
+ from .server import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ main()
@@ -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,2 @@
1
+ [console_scripts]
2
+ multitool-mcp = dut_mcp.server:main
@@ -0,0 +1,3 @@
1
+
2
+ [test]
3
+ pytest>=8
@@ -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"