industriagents-modbus-mcp 0.3.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.
Files changed (58) hide show
  1. industriagents_modbus_mcp-0.3.0/.env.example +48 -0
  2. industriagents_modbus_mcp-0.3.0/.gitignore +13 -0
  3. industriagents_modbus_mcp-0.3.0/.python-version +1 -0
  4. industriagents_modbus_mcp-0.3.0/LICENSE-kukapay +21 -0
  5. industriagents_modbus_mcp-0.3.0/PKG-INFO +337 -0
  6. industriagents_modbus_mcp-0.3.0/README.md +322 -0
  7. industriagents_modbus_mcp-0.3.0/pyproject.toml +60 -0
  8. industriagents_modbus_mcp-0.3.0/src/modbus_mcp/__init__.py +8 -0
  9. industriagents_modbus_mcp-0.3.0/src/modbus_mcp/__main__.py +6 -0
  10. industriagents_modbus_mcp-0.3.0/src/modbus_mcp/_contract/tools.json +1834 -0
  11. industriagents_modbus_mcp-0.3.0/src/modbus_mcp/adapters/__init__.py +1 -0
  12. industriagents_modbus_mcp-0.3.0/src/modbus_mcp/adapters/transport.py +307 -0
  13. industriagents_modbus_mcp-0.3.0/src/modbus_mcp/application/__init__.py +1 -0
  14. industriagents_modbus_mcp-0.3.0/src/modbus_mcp/application/diagnostics.py +112 -0
  15. industriagents_modbus_mcp-0.3.0/src/modbus_mcp/application/execution.py +85 -0
  16. industriagents_modbus_mcp-0.3.0/src/modbus_mcp/application/read.py +336 -0
  17. industriagents_modbus_mcp-0.3.0/src/modbus_mcp/application/request.py +64 -0
  18. industriagents_modbus_mcp-0.3.0/src/modbus_mcp/application/write.py +240 -0
  19. industriagents_modbus_mcp-0.3.0/src/modbus_mcp/cli.py +7 -0
  20. industriagents_modbus_mcp-0.3.0/src/modbus_mcp/codec.py +153 -0
  21. industriagents_modbus_mcp-0.3.0/src/modbus_mcp/config.py +322 -0
  22. industriagents_modbus_mcp-0.3.0/src/modbus_mcp/domain/__init__.py +1 -0
  23. industriagents_modbus_mcp-0.3.0/src/modbus_mcp/domain/model.py +276 -0
  24. industriagents_modbus_mcp-0.3.0/src/modbus_mcp/domain/policy.py +38 -0
  25. industriagents_modbus_mcp-0.3.0/src/modbus_mcp/domain/ports.py +20 -0
  26. industriagents_modbus_mcp-0.3.0/src/modbus_mcp/infrastructure/__init__.py +1 -0
  27. industriagents_modbus_mcp-0.3.0/src/modbus_mcp/infrastructure/audit.py +45 -0
  28. industriagents_modbus_mcp-0.3.0/src/modbus_mcp/infrastructure/control_context.py +104 -0
  29. industriagents_modbus_mcp-0.3.0/src/modbus_mcp/protocol/__init__.py +1 -0
  30. industriagents_modbus_mcp-0.3.0/src/modbus_mcp/protocol/contract.py +164 -0
  31. industriagents_modbus_mcp-0.3.0/src/modbus_mcp/protocol/hints.py +4 -0
  32. industriagents_modbus_mcp-0.3.0/src/modbus_mcp/protocol/server.py +202 -0
  33. industriagents_modbus_mcp-0.3.0/src/modbus_mcp/protocol/tools.py +340 -0
  34. industriagents_modbus_mcp-0.3.0/src/modbus_mcp/server.py +367 -0
  35. industriagents_modbus_mcp-0.3.0/src/modbus_mcp/state.py +27 -0
  36. industriagents_modbus_mcp-0.3.0/tests/conftest.py +375 -0
  37. industriagents_modbus_mcp-0.3.0/tests/test_architecture_contract.py +205 -0
  38. industriagents_modbus_mcp-0.3.0/tests/test_cancel_race.py +111 -0
  39. industriagents_modbus_mcp-0.3.0/tests/test_cancellation.py +148 -0
  40. industriagents_modbus_mcp-0.3.0/tests/test_cancellation_stdio.py +296 -0
  41. industriagents_modbus_mcp-0.3.0/tests/test_codec.py +136 -0
  42. industriagents_modbus_mcp-0.3.0/tests/test_config.py +419 -0
  43. industriagents_modbus_mcp-0.3.0/tests/test_deadline.py +193 -0
  44. industriagents_modbus_mcp-0.3.0/tests/test_metadata.py +44 -0
  45. industriagents_modbus_mcp-0.3.0/tests/test_mock_device.py +127 -0
  46. industriagents_modbus_mcp-0.3.0/tests/test_mock_lifetime.py +67 -0
  47. industriagents_modbus_mcp-0.3.0/tests/test_pymodbus_compat.py +59 -0
  48. industriagents_modbus_mcp-0.3.0/tests/test_readme_examples.py +53 -0
  49. industriagents_modbus_mcp-0.3.0/tests/test_reconnect_and_ping.py +147 -0
  50. industriagents_modbus_mcp-0.3.0/tests/test_reply_checks.py +95 -0
  51. industriagents_modbus_mcp-0.3.0/tests/test_rtu.py +241 -0
  52. industriagents_modbus_mcp-0.3.0/tests/test_stdio_integration.py +440 -0
  53. industriagents_modbus_mcp-0.3.0/tests/test_typed_tools.py +127 -0
  54. industriagents_modbus_mcp-0.3.0/tests/test_validation.py +249 -0
  55. industriagents_modbus_mcp-0.3.0/tests/test_write_reaches_device_once.py +286 -0
  56. industriagents_modbus_mcp-0.3.0/tests/test_writes_at_most_once.py +256 -0
  57. industriagents_modbus_mcp-0.3.0/tests/test_writes_disabled.py +59 -0
  58. industriagents_modbus_mcp-0.3.0/uv.lock +1183 -0
@@ -0,0 +1,48 @@
1
+ # Example settings for modbus-mcp. Copy to modbus-python/.env and edit.
2
+ #
3
+ # Where settings come from, highest precedence first:
4
+ # 1. the process environment (for example the "env" block of your MCP
5
+ # client's server configuration);
6
+ # 2. a file passed explicitly with `modbus-mcp --env-file PATH` (read in full);
7
+ # 3. modbus-python/.env of a source checkout, loaded automatically. It is
8
+ # found relative to the package source; a .env in the working directory
9
+ # or in any parent directory is never read, and an installed package
10
+ # (site-packages) has no automatic .env at all.
11
+ #
12
+ # The automatically loaded .env (3) is limited:
13
+ # - it may set only modbus-mcp's own settings: MODBUS_* and REGISTER_MAP_FILE.
14
+ # Anything else (PATH, LD_PRELOAD, DYLD_*, PYTHON*, ...) is ignored with a
15
+ # warning on stderr;
16
+ # - it may NOT set the write switch MODBUS_WRITES_ENABLED, in any spelling
17
+ # (keys are compared case-insensitively, `export` and quotes included). It
18
+ # is only honoured from (1) or (2); here it is ignored with a warning.
19
+ # The reason: whoever controls the files around the server should not be able
20
+ # to switch writes on, or redirect them to another device.
21
+
22
+ MODBUS_TYPE=tcp
23
+ MODBUS_HOST=127.0.0.1
24
+ MODBUS_PORT=1502
25
+ MODBUS_DEFAULT_SLAVE_ID=1
26
+
27
+ # Serial (MODBUS_TYPE=serial)
28
+ # MODBUS_SERIAL_PORT=/dev/ttyUSB0
29
+ # MODBUS_BAUDRATE=9600
30
+ # MODBUS_PARITY=N
31
+ # MODBUS_STOPBITS=1
32
+ # MODBUS_BYTESIZE=8
33
+ # MODBUS_TIMEOUT=1
34
+
35
+ # Reliability: retries apply to reads and to (re)connecting; a write is sent at most once.
36
+ # MODBUS_MAX_RETRIES=2
37
+ # MODBUS_RETRY_BACKOFF_BASE=0.2
38
+ # MODBUS_TOOL_TIMEOUT=
39
+ # Overall deadline for one tool call, in seconds (reads end with an error, a
40
+ # write in flight is reported as "unknown"):
41
+ # MODBUS_CALL_TIMEOUT=30
42
+
43
+ # Tag map
44
+ # REGISTER_MAP_FILE=/absolute/path/to/tags.json
45
+
46
+ # Write switch: ignored in the automatically loaded .env (see above). Set it in
47
+ # the process environment, or in a file passed with --env-file.
48
+ # MODBUS_WRITES_ENABLED=true
@@ -0,0 +1,13 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ .DS_Store
10
+
11
+ # Virtual environments
12
+ .venv
13
+ .env
@@ -0,0 +1 @@
1
+ 3.10
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 kukapay
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,337 @@
1
+ Metadata-Version: 2.5
2
+ Name: industriagents-modbus-mcp
3
+ Version: 0.3.0
4
+ Summary: An MCP server that standardizes and contextualizes Modbus data, enabling seamless integration of AI agents with industrial IoT systems.
5
+ License-File: LICENSE-kukapay
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: anyio>=4.5
8
+ Requires-Dist: exceptiongroup>=1.0.2; python_version < '3.11'
9
+ Requires-Dist: jsonschema<5,>=4.23
10
+ Requires-Dist: mcp[cli]<2,>=1.22.0
11
+ Requires-Dist: pydantic<2.13,>=2.8.0
12
+ Requires-Dist: pymodbus[serial]<3.16,>=3.9.2
13
+ Requires-Dist: python-dotenv>=1.1.0
14
+ Description-Content-Type: text/markdown
15
+
16
+ > **0.3.0 package and tool migration:** Install `industriagents-modbus-mcp`
17
+ > from npm (requires uv) or PyPI; the command remains `modbus-mcp`.
18
+ > The advertised catalog now contains **6 tools**. Older tool names in
19
+ > the examples below remain compatibility calls. See the
20
+ > [architecture and canonical tool guide](../docs/architecture.md)
21
+ > and [release guide](../docs/release.md).
22
+
23
+ # Modbus MCP Server (Python)
24
+
25
+ The MCP server of [MODBUS-MCP](../README.md). It is a [uv](https://docs.astral.sh/uv/) project with the `modbus-mcp` entry point; the mock device to test it against lives in [`../modbus-mock-server`](../modbus-mock-server/README.md).
26
+
27
+ ## Features
28
+
29
+ - **Read-only by default**: the write tools (`write_register`, `write_registers`, `write_coil`, `write_coils_bulk`, `mask_write_register`, `write_tag`) send nothing unless `MODBUS_WRITES_ENABLED=true`.
30
+ - **Core tools**:
31
+ - Read/write holding registers: `read_register`, `write_register`, `read_multiple_holding_registers`, `write_registers`.
32
+ - Read/write coils: `read_coils`, `write_coil`, `write_coils_bulk`.
33
+ - Read input registers: `read_input_registers`.
34
+ - Read discrete inputs: `read_discrete_inputs`.
35
+ - Bitwise masked write: `mask_write_register`.
36
+ - Device information (MEI 0x2B/0x0E): `read_device_information`.
37
+ - **Typed reads**:
38
+ - `read_holding_typed`, `read_input_typed` for `int16/uint16/int32/uint32/int64/uint64/float32/float64` with byte/word endianness, scale, and offset.
39
+ - Encoding and decoding are done in `src/modbus_mcp/codec.py` with the same register layout pymodbus' former `BinaryPayloadBuilder`/`Decoder` used (a test checks this against them on pymodbus 3.9).
40
+ - **Tag map (optional)**:
41
+ - Load a JSON register map via `REGISTER_MAP_FILE` and use `list_tags`, `read_tag`, `write_tag`. Typed tags are decoded and scaled on read; `write_tag` takes the same engineering units and reverses the scale and offset.
42
+ - **Reliability**:
43
+ - Reads are retried with exponential backoff (`MODBUS_MAX_RETRIES`); per-tool timeouts; automatic chunking of reads to protocol limits (125 registers, 2000 bits per request).
44
+ - Writes are sent at most once. pymodbus' own re-sending is switched off (`retries=0`), and a write is only retried when nothing was sent (the connection could not be opened). If a write went out and no reply came back, the tool returns `success: false`, `"no reply; the write may have been applied; check before retrying"`.
45
+ - Starts without the device (waiting at most 1 s for it): `initialize` and `tools/list` work, device tools return `"cannot reach device at …"` until it answers, and every call tries to connect (also after a dropped connection).
46
+ - Every reply is checked against its request: an exception reply must carry the request's function code, a read reply the function code and exact count, a write reply the echoed address and value/count/masks (a coil write's reply byte for byte, since pymodbus decodes any non-zero value as ON). A reply that does not belong is not used (a read is retried, a write is `unknown`). After a request goes unanswered, the connection is closed in the same step, before another request can use it, and the next call reconnects (on a serial line after a quiet period of `MODBUS_TIMEOUT`), so a late reply is not taken for the next request's. Modbus RTU replies carry no transaction id, and pymodbus before 3.12 does not check TCP's. On a serial line, a reply later than twice `MODBUS_TIMEOUT` can still be mistaken for the next request's when it fits that request (an exception with the same function code, for example); set `MODBUS_TIMEOUT` above the device's worst-case response time.
47
+ - **Validation**:
48
+ - Arguments are checked against Modbus limits before anything is sent (addresses and register values 0-65535, unit ids 0-255, at most 123 registers or 1968 coils per write, known dtypes and byte/word orders). Integers and booleans are strict: `"5"`, `5.0` or `true` is not an address, and `1` or `"on"` is not a coil state. Typed values the dtype cannot hold exactly (out of range, a fraction for an integer type) are refused rather than truncated.
49
+ - **Structured results**:
50
+ - Every tool returns `{ success, data, error, meta }` for predictable parsing, including invalid arguments (`"error": "Invalid arguments: …"`) and device exceptions (`"error": "Modbus exception 2 (Illegal Data Address) for function 0x03"`, `meta.exception_code`).
51
+ - **Health**:
52
+ - `ping` reads one holding register (address 0 by default) to check that the device answers. Any Modbus reply counts as reachable, including an exception reply; only no reply makes it return `success: false`. It also reports the configuration.
53
+ - **Prompt**:
54
+ - Analyze register values with a simple prompt: `analyze_register`.
55
+
56
+ ## Requirements
57
+
58
+ - **Python**: 3.10+ (uv uses 3.10 from `.python-version`)
59
+ - **uv** for dependency and virtual environment management.
60
+
61
+ ## Installation
62
+
63
+ 1. **Install `uv`**:
64
+ ```bash
65
+ curl -LsSf https://astral.sh/uv/install.sh | sh
66
+ ```
67
+
68
+ 2. **Clone the Repository**:
69
+ ```bash
70
+ git clone https://github.com/IndustriAgents/MODBUS-MCP.git
71
+ cd MODBUS-MCP/modbus-python
72
+ ```
73
+
74
+ 3. **Install Dependencies**:
75
+ ```bash
76
+ uv sync
77
+ ```
78
+ This installs the versions pinned in `uv.lock`, plus pytest and Ruff. The server supports `mcp>=1.22.0,<2`, `pymodbus[serial]>=3.9.2,<3.16`, `python-dotenv>=1.1.0`, `anyio>=4.5` and `pydantic>=2.8.0,<2.13`, subject to their transitive requirements. MCP 1.22 is the tested floor for direct `CallToolResult` error responses. CI checks both locked and lowest resolvable dependencies. pymodbus 3.10 renamed the unit-id keyword from `slave` to `device_id`; the adapter uses whichever the installed version accepts.
79
+
80
+ ## Configuration
81
+
82
+ The server connects to a Modbus device using parameters specified via environment variables. They are read once, when the server starts, from these sources, highest precedence first:
83
+
84
+ 1. the process environment (for an MCP client, the `env` block of its server configuration);
85
+ 2. a file passed explicitly with `modbus-mcp --env-file PATH`;
86
+ 3. `modbus-python/.env` of this source checkout, loaded automatically. It is found relative to the package source, never in the working directory (wherever the MCP client launched the server) or a parent directory; an installed package has no automatic `.env`.
87
+
88
+ **The automatically loaded `.env` cannot enable writes, and may only set this server's settings.** Settings such as the host and the tag map decide where a write goes, so it may set `MODBUS_*` and `REGISTER_MAP_FILE` but not `MODBUS_WRITES_ENABLED` (only honoured from (1) or (2)) and nothing else (`PATH`, `LD_PRELOAD`, `PYTHON*`, …). Keys are compared case-insensitively, as Windows does, with `export` prefixes and quotes removed, so `modbus_writes_enabled=true` is caught too. Each ignored key gets `modbus-mcp: warning: ignoring <KEY> from <file>` on stderr. [`.env.example`](.env.example) lists every setting.
89
+
90
+ The configuration is checked when it is read. An invalid value (a port that is not a number, an unknown `MODBUS_TYPE`, `MODBUS_WRITES_ENABLED=maybe`, a tag map that does not exist or does not validate) stops the server with exit status 2 and a `modbus-mcp: configuration error: …` message on stderr. An empty value means the default.
91
+
92
+ `modbus-mcp` is a stdio server, meant to be launched by an MCP client (see [Installing for Claude Desktop](#installing-for-claude-desktop), or the MCP Inspector command in [CONTRIBUTING.md](../CONTRIBUTING.md#testing-a-change)). Run by hand in a terminal, it prints nothing and waits for JSON-RPC on stdin; nothing but JSON-RPC is ever written to stdout. It tries to connect to the device at startup, for at most 1 s, but starts either way: if the device is unreachable it logs `Cannot reach Modbus <type> device at <address> yet` on stderr, and the device tools return `success: false`, `"cannot reach device at <address>"` until the device answers. Every tool call tries to connect first, which also re-establishes a dropped connection. `modbus-mcp --help` lists the command-line options.
93
+
94
+ ### Environment Variables
95
+
96
+ | Variable | Description | Default | Required |
97
+ |----------------------------|--------------------------------------------------|----------------------|----------|
98
+ | `MODBUS_TYPE` | `tcp`, `udp`, or `serial` | `tcp` | — |
99
+ | `MODBUS_HOST` | Host for TCP/UDP | `127.0.0.1` | For TCP/UDP |
100
+ | `MODBUS_PORT` | Port for TCP/UDP (1-65535) | `502` | For TCP/UDP |
101
+ | `MODBUS_DEFAULT_SLAVE_ID` | Slave/Unit ID used when a tool call omits `slave_id` (0-255) | `1` | — |
102
+ | `MODBUS_SERIAL_PORT` | Serial port (e.g., `/dev/ttyUSB0`, `COM1`) | `/dev/ttyUSB0` | For serial |
103
+ | `MODBUS_BAUDRATE` | Serial baud rate | `9600` | For serial |
104
+ | `MODBUS_PARITY` | Serial parity: `N`, `E`, `O` | `N` | For serial |
105
+ | `MODBUS_STOPBITS` | Serial stop bits: `1`, `2` | `1` | For serial |
106
+ | `MODBUS_BYTESIZE` | Serial byte size: `5`-`8` | `8` | For serial |
107
+ | `MODBUS_TIMEOUT` | Serial timeout (seconds, > 0); TCP/UDP wait up to 3 s for a reply (pymodbus' default) | `1` | For serial |
108
+ | `MODBUS_MAX_RETRIES` | Retries after a failed read or a failed connection attempt (0-10); a write is never sent twice | `2` | — |
109
+ | `MODBUS_RETRY_BACKOFF_BASE`| Backoff base in seconds (doubled per retry) | `0.2` | — |
110
+ | `MODBUS_TOOL_TIMEOUT` | Per-request timeout (seconds; unset or `0` = none) | unset | — |
111
+ | `MODBUS_CALL_TIMEOUT` | Overall deadline for one tool call, in seconds, from the moment the call starts: it covers waiting behind other calls, reconnecting and every request of the call (one deadline, never restarted per request). On expiry a read fails cleanly, a write that went out is `unknown`, a write still waiting is `not_sent` and never sent | `30` | — |
112
+ | `MODBUS_WRITES_ENABLED` | Allow the write tools (`true`/`false`, also `1`/`0`, `yes`/`no`, `on`/`off`). Only from the process environment or `--env-file`; ignored in the automatically loaded `.env` | `false` | — |
113
+ | `REGISTER_MAP_FILE` | Path to JSON tag map file | unset | — |
114
+
115
+ ### Example `.env` File
116
+
117
+ For TCP:
118
+ ```
119
+ MODBUS_TYPE=tcp
120
+ MODBUS_HOST=192.168.1.100
121
+ MODBUS_PORT=502
122
+ MODBUS_DEFAULT_SLAVE_ID=1
123
+ ```
124
+
125
+ For Serial:
126
+ ```
127
+ MODBUS_TYPE=serial
128
+ MODBUS_SERIAL_PORT=/dev/ttyUSB0
129
+ MODBUS_BAUDRATE=9600
130
+ MODBUS_PARITY=N
131
+ MODBUS_STOPBITS=1
132
+ MODBUS_BYTESIZE=8
133
+ MODBUS_TIMEOUT=1
134
+ ```
135
+
136
+ Neither enables writes, and the automatically loaded `.env` could not: to allow writes, set `MODBUS_WRITES_ENABLED=true` in the MCP client's `env` block, or put it in a file you pass with `--env-file`:
137
+
138
+ ```json
139
+ {
140
+ "command": "uv",
141
+ "args": [ "--directory", "/absolute/path/to/MODBUS-MCP/modbus-python", "run", "modbus-mcp", "--env-file", "/absolute/path/to/plant.env" ]
142
+ }
143
+ ```
144
+
145
+ ## Usage
146
+
147
+ ### Installing for Claude Desktop
148
+
149
+ The configuration file:
150
+
151
+ ```json
152
+ {
153
+ "mcpServers": {
154
+ "Modbus MCP Server": {
155
+ "command": "uv",
156
+ "args": [ "--directory", "/absolute/path/to/MODBUS-MCP/modbus-python", "run", "modbus-mcp" ],
157
+ "env": { "MODBUS_TYPE": "tcp", "MODBUS_HOST": "127.0.0.1", "MODBUS_PORT": "1502" }
158
+ }
159
+ }
160
+ }
161
+ ```
162
+
163
+ ### Using Tools
164
+
165
+ All tools return a structured object: `{ "success": bool, "data": any, "error": string|null, "meta": object }`. Addresses are zero-based.
166
+
167
+ The write tools add `meta.outcome` and `meta.request_sent`:
168
+
169
+ - `written`: the device acknowledged the write with a reply that echoes it (function code, address, and value, count or masks);
170
+ - `rejected`: the device refused it with a Modbus exception that comes before execution by the protocol's definition: 1, 2, 3, 6 or 10;
171
+ - `not_sent` (`request_sent: false`): refused before sending, because writes are disabled, the arguments are invalid, the device is unreachable, or encoding failed locally; whether a frame went out is known exactly, from pymodbus' `trace_packet` hook, which sees each frame right before it is handed to the transport (and refuses it when the connection is gone, since pymodbus would drop it silently);
172
+ - `unknown` (`request_sent: true`): it went out, and then there was no reply (`"no reply; the write may have been applied; check before retrying"`), a reply that does not belong to it (another request's, or one that does not echo it: `meta.reply_mismatch`), another exception (4 Server Device Failure, 5 Acknowledge, 8, 11), or the call's deadline passed.
173
+
174
+ Nothing is sent twice, so read the value back before writing again after an `unknown`.
175
+
176
+ A write that has not gone out is never sent after the call was abandoned: the client cancelled it (`notifications/cancelled`), the client disconnected (stdin closed; pending calls are stopped and the server exits with status 0), or the call's deadline passed. `SendGate`, the last check right before pymodbus hands a request to the transport, refuses it; the result is `not_sent`. A cancellation that lands in the same instant the write gets its turn is caught too: the gate reads mcp's cancel scope for the request, which the SDK sets before the call can notice. A call the client cancelled ends without a second reply (the SDK has already answered "Request cancelled").
177
+
178
+ The examples use addresses that exist on the [mock device](../modbus-mock-server/README.md#register-map-unitslave-id-any) (every table holds addresses 0-99; 10-99 are unused, free for experiments); the writes need `MODBUS_WRITES_ENABLED=true`.
179
+
180
+ 1. Read one holding register (Valve Position on the mock)
181
+ ```json
182
+ { "tool": "read_register", "parameters": { "address": 0, "slave_id": 1 } }
183
+ ```
184
+
185
+ 2. Write one holding register (an unused register on the mock)
186
+ ```json
187
+ { "tool": "write_register", "parameters": { "address": 10, "value": 100, "slave_id": 1 } }
188
+ ```
189
+
190
+ 3. Read coils (chunked automatically when large)
191
+ ```json
192
+ { "tool": "read_coils", "parameters": { "address": 0, "count": 5, "slave_id": 1 } }
193
+ ```
194
+
195
+ 4. Write multiple coils
196
+ ```json
197
+ { "tool": "write_coils_bulk", "parameters": { "address": 10, "values": [true, false, true], "slave_id": 1 } }
198
+ ```
199
+
200
+ 5. Read input registers (Flow Rate, Tank Level, Vibration on the mock)
201
+ ```json
202
+ { "tool": "read_input_registers", "parameters": { "address": 2, "count": 3, "slave_id": 1 } }
203
+ ```
204
+
205
+ 6. Read discrete inputs
206
+ ```json
207
+ { "tool": "read_discrete_inputs", "parameters": { "address": 0, "count": 8, "slave_id": 1 } }
208
+ ```
209
+
210
+ 7. Read multiple holding registers
211
+ ```json
212
+ { "tool": "read_multiple_holding_registers", "parameters": { "address": 0, "count": 3, "slave_id": 1 } }
213
+ ```
214
+
215
+ 8. Mask write a register: register = (register AND and_mask) OR (or_mask AND NOT and_mask); this sets the two low bits of register 20
216
+ ```json
217
+ { "tool": "mask_write_register", "parameters": { "address": 20, "and_mask": 65532, "or_mask": 3, "slave_id": 1 } }
218
+ ```
219
+
220
+ 9. Device information
221
+ ```json
222
+ { "tool": "read_device_information", "parameters": { "slave_id": 1, "read_code": 3 } }
223
+ ```
224
+
225
+ 10. Typed reads (with endianness and scaling): two float32 values in registers 40-43, low word first
226
+ ```json
227
+ { "tool": "read_holding_typed", "parameters": { "address": 40, "dtype": "float32", "count": 2, "byteorder": "big", "wordorder": "little", "scale": 1.0, "offset": 0.0, "slave_id": 1 } }
228
+ ```
229
+ and the mock's pH (input register 5, stored x100) as a number:
230
+ ```json
231
+ { "tool": "read_input_typed", "parameters": { "address": 5, "dtype": "uint16", "scale": 0.01, "slave_id": 1 } }
232
+ ```
233
+ `data.values` holds `value * scale + offset`; with the default scale 1 and offset 0 the decoded values are returned unchanged (so 64-bit integers keep full precision). NaN and infinities come back as the strings `"NaN"`, `"Infinity"` and `"-Infinity"`, since JSON has no such numbers.
234
+
235
+ 11. Write multiple holding registers (1-123 per call)
236
+ ```json
237
+ { "tool": "write_registers", "parameters": { "address": 50, "values": [1,2,3], "slave_id": 1 } }
238
+ ```
239
+
240
+ 12. Health
241
+ ```json
242
+ { "tool": "ping", "parameters": {} }
243
+ ```
244
+ `data` has `connected`, `device_reachable`, `probe_address`, `probe_slave_id`, `probe_response` (`"ok"` or the exception the device returned), `probe_latency_ms`, `probe_error`, and the configuration (`type`, `host`, `port`, `serial_port`, `writes_enabled`, `max_retries`, `tool_timeout`, `tag_count`). Pass `address` to probe a register other than 0.
245
+
246
+ ### Tag Map (optional)
247
+
248
+ Point `REGISTER_MAP_FILE` to a JSON file that defines named tags. Example file (it matches the mock device):
249
+
250
+ ```json
251
+ {
252
+ "ValvePosition": { "table": "holding", "address": 0, "unit": "%" },
253
+ "Temperature": { "table": "input", "address": 0, "dtype": "uint16", "scale": 0.1, "unit": "°C" },
254
+ "PumpSpeed": { "table": "holding", "address": 30, "dtype": "float32", "count": 1, "byteorder": "big", "wordorder": "big", "slave_id": 1 },
255
+ "PumpEnabled": { "table": "coil", "address": 0, "slave_id": 1 },
256
+ "Status": { "table": "discrete", "address": 0, "count": 2, "slave_id": 1 }
257
+ }
258
+ ```
259
+
260
+ Keys of a tag:
261
+
262
+ | Key | Meaning | Default |
263
+ |-----|---------|---------|
264
+ | `table` | `holding`, `input`, `coil` or `discrete` (aliases such as `hr`, `ir`, `coils`, `di` work) | `holding` |
265
+ | `address` | Zero-based start address (required) | — |
266
+ | `count` | Number of values (typed tags), registers (raw register tags) or bits | `1` |
267
+ | `slave_id` | Unit id | `MODBUS_DEFAULT_SLAVE_ID` |
268
+ | `dtype` | Holding/input only: one of the typed dtypes; without it a register tag is raw `uint16` | none |
269
+ | `byteorder`, `wordorder` | Holding/input only: `big` or `little` | `big` |
270
+ | `scale`, `offset` | Typed tags only: read returns `raw * scale + offset` (`scale` must not be 0) | `1`, `0` |
271
+ | `description`, `unit` | Free text, shown by `list_tags` | none |
272
+
273
+ The map is validated when the server starts: unknown keys or tables, a missing or out-of-range address, an unsupported dtype or order, typed keys on a coil/discrete tag, `scale`/`offset` without a `dtype`, or a tag that runs past address 65535 stop the server with a message naming the tag.
274
+
275
+ Tools:
276
+
277
+ - List tags
278
+ ```json
279
+ { "tool": "list_tags", "parameters": {} }
280
+ ```
281
+
282
+ - Read a tag (typed and scaled when the tag has a `dtype`; raw registers or bits otherwise)
283
+ ```json
284
+ { "tool": "read_tag", "parameters": { "name": "Temperature" } }
285
+ ```
286
+
287
+ - Write a tag
288
+ - Coils: `true` or `false` (a coil tag with `count` > 1 is refused; write those coils with `write_coils_bulk`)
289
+ ```json
290
+ { "tool": "write_tag", "parameters": { "name": "PumpEnabled", "value": true } }
291
+ ```
292
+ - Holding registers: a value, or a list of `count` values, in the units `read_tag` returns. The tag's `scale` and `offset` are reversed and the result is encoded by `dtype` (default `uint16`) and endianness. For an integer dtype the raw value must come out whole: on a tag with scale 0.1, `25.3` is written as 253, while `25.35` is refused.
293
+ ```json
294
+ { "tool": "write_tag", "parameters": { "name": "PumpSpeed", "value": 42.5 } }
295
+ ```
296
+ - Input and discrete-input tags are read-only.
297
+
298
+ ## Testing
299
+
300
+ ```bash
301
+ uv sync
302
+ uv run pytest # unit + integration tests
303
+ uv run pytest -m "not integration" # unit tests only, no subprocesses
304
+ uv run ruff check && uv run ruff format --check
305
+ ```
306
+
307
+ To check the lowest versions `pyproject.toml` allows, as CI does:
308
+
309
+ ```bash
310
+ uv venv --python 3.10 .venv
311
+ uv pip install --resolution lowest-direct -e . "pytest>=8"
312
+ uv run --no-sync pytest
313
+ uv sync --locked # back to the locked versions
314
+ ```
315
+
316
+ The integration tests (`-m integration`) start the mock device from `../modbus-mock-server` with `uv run --locked` on a free port, and drive `python -m modbus_mcp` over stdio with the MCP client SDK. They need `uv` on `PATH` and are skipped without it.
317
+
318
+ ## Known issues
319
+
320
+ None at the moment. Behaviour worth knowing:
321
+
322
+ - On a serial line, a request that gets no reply within `MODBUS_TIMEOUT` makes the server close the port and reopen it after another `MODBUS_TIMEOUT`, so the next call takes up to twice that longer. That keeps a reply up to twice `MODBUS_TIMEOUT` late from being taken for the next request's; a later reply can still be mistaken, so set `MODBUS_TIMEOUT` above the device's worst-case response time.
323
+ - The cancel check reads mcp's private `session._in_flight` / `RequestResponder._cancel_scope` (pinned by `mcp<2` and by `tests/test_cancel_race.py`).
324
+
325
+ ## Testing, Security and Contributing
326
+
327
+ - Run it against the mock device: [`../modbus-mock-server`](../modbus-mock-server/README.md).
328
+ - Write tools are disabled unless `MODBUS_WRITES_ENABLED=true`; read [SECURITY.md](../SECURITY.md) before pointing the server at real equipment.
329
+ - Development setup, the CI checks and pull request conventions: [CONTRIBUTING.md](../CONTRIBUTING.md).
330
+
331
+ ## Acknowledgements
332
+
333
+ This server is derived from [kukapay/modbus-mcp](https://github.com/kukapay/modbus-mcp) and still contains portions of it, notably in `src/modbus_mcp/cli.py`. Those portions are Copyright (c) 2025 kukapay and used under the MIT License. kukapay's license text is kept unchanged in [LICENSE-kukapay](LICENSE-kukapay).
334
+
335
+ ## License
336
+
337
+ MIT — see [LICENSE](../LICENSE). It names both copyright holders: IndustriAgents, and kukapay for the portions derived from kukapay/modbus-mcp (see [Acknowledgements](#acknowledgements)).