0.3.0 package and tool migration: Install
industriagents-modbus-mcpfrom npm (requires uv) or PyPI; the command remainsmodbus-mcp. The advertised catalog now contains 6 tools. Older tool names in the examples below remain compatibility calls. See the architecture and canonical tool guide and release guide.
Modbus MCP Server (Python)
The MCP server of MODBUS-MCP. It is a uv project with the modbus-mcp entry point; the mock device to test it against lives in ../modbus-mock-server.
Features
- Read-only by default: the write tools (
write_register,write_registers,write_coil,write_coils_bulk,mask_write_register,write_tag) send nothing unlessMODBUS_WRITES_ENABLED=true. - Core tools:
- Read/write holding registers:
read_register,write_register,read_multiple_holding_registers,write_registers. - Read/write coils:
read_coils,write_coil,write_coils_bulk. - Read input registers:
read_input_registers. - Read discrete inputs:
read_discrete_inputs. - Bitwise masked write:
mask_write_register. - Device information (MEI 0x2B/0x0E):
read_device_information.
- Read/write holding registers:
- Typed reads:
read_holding_typed,read_input_typedforint16/uint16/int32/uint32/int64/uint64/float32/float64with byte/word endianness, scale, and offset.- Encoding and decoding are done in
src/modbus_mcp/codec.pywith the same register layout pymodbus' formerBinaryPayloadBuilder/Decoderused (a test checks this against them on pymodbus 3.9).
- Tag map (optional):
- Load a JSON register map via
REGISTER_MAP_FILEand uselist_tags,read_tag,write_tag. Typed tags are decoded and scaled on read;write_tagtakes the same engineering units and reverses the scale and offset.
- Load a JSON register map via
- Reliability:
- 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). - 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 returnssuccess: false,"no reply; the write may have been applied; check before retrying". - Starts without the device (waiting at most 1 s for it):
initializeandtools/listwork, device tools return"cannot reach device at …"until it answers, and every call tries to connect (also after a dropped connection). - 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 ofMODBUS_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 twiceMODBUS_TIMEOUTcan still be mistaken for the next request's when it fits that request (an exception with the same function code, for example); setMODBUS_TIMEOUTabove the device's worst-case response time.
- Reads are retried with exponential backoff (
- Validation:
- 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.0ortrueis not an address, and1or"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.
- 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:
- Structured results:
- 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).
- Every tool returns
- Health:
pingreads 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 returnsuccess: false. It also reports the configuration.
- Prompt:
- Analyze register values with a simple prompt:
analyze_register.
- Analyze register values with a simple prompt:
Requirements
- Python: 3.10+ (uv uses 3.10 from
.python-version) - uv for dependency and virtual environment management.
Installation
-
Install
uv:curl -LsSf https://astral.sh/uv/install.sh | sh
-
Clone the Repository:
git clone https://github.com/IndustriAgents/MODBUS-MCP.git cd MODBUS-MCP/modbus-python
-
Install Dependencies:
uv syncThis installs the versions pinned in
uv.lock, plus pytest and Ruff. The server supportsmcp>=1.22.0,<2,pymodbus[serial]>=3.9.2,<3.16,python-dotenv>=1.1.0,anyio>=4.5andpydantic>=2.8.0,<2.13, subject to their transitive requirements. MCP 1.22 is the tested floor for directCallToolResulterror responses. CI checks both locked and lowest resolvable dependencies. pymodbus 3.10 renamed the unit-id keyword fromslavetodevice_id; the adapter uses whichever the installed version accepts.
Configuration
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:
- the process environment (for an MCP client, the
envblock of its server configuration); - a file passed explicitly with
modbus-mcp --env-file PATH; modbus-python/.envof 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.
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 lists every setting.
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.
modbus-mcp is a stdio server, meant to be launched by an MCP client (see Installing for Claude Desktop, or the MCP Inspector command in CONTRIBUTING.md). 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.
Environment Variables
| Variable | Description | Default | Required |
|---|---|---|---|
MODBUS_TYPE |
tcp, udp, or serial |
tcp |
— |
MODBUS_HOST |
Host for TCP/UDP | 127.0.0.1 |
For TCP/UDP |
MODBUS_PORT |
Port for TCP/UDP (1-65535) | 502 |
For TCP/UDP |
MODBUS_DEFAULT_SLAVE_ID |
Slave/Unit ID used when a tool call omits slave_id (0-255) |
1 |
— |
MODBUS_SERIAL_PORT |
Serial port (e.g., /dev/ttyUSB0, COM1) |
/dev/ttyUSB0 |
For serial |
MODBUS_BAUDRATE |
Serial baud rate | 9600 |
For serial |
MODBUS_PARITY |
Serial parity: N, E, O |
N |
For serial |
MODBUS_STOPBITS |
Serial stop bits: 1, 2 |
1 |
For serial |
MODBUS_BYTESIZE |
Serial byte size: 5-8 |
8 |
For serial |
MODBUS_TIMEOUT |
Serial timeout (seconds, > 0); TCP/UDP wait up to 3 s for a reply (pymodbus' default) | 1 |
For serial |
MODBUS_MAX_RETRIES |
Retries after a failed read or a failed connection attempt (0-10); a write is never sent twice | 2 |
— |
MODBUS_RETRY_BACKOFF_BASE |
Backoff base in seconds (doubled per retry) | 0.2 |
— |
MODBUS_TOOL_TIMEOUT |
Per-request timeout (seconds; unset or 0 = none) |
unset | — |
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 |
— |
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 |
— |
REGISTER_MAP_FILE |
Path to JSON tag map file | unset | — |
Example .env File
For TCP:
MODBUS_TYPE=tcp
MODBUS_HOST=192.168.1.100
MODBUS_PORT=502
MODBUS_DEFAULT_SLAVE_ID=1
For Serial:
MODBUS_TYPE=serial
MODBUS_SERIAL_PORT=/dev/ttyUSB0
MODBUS_BAUDRATE=9600
MODBUS_PARITY=N
MODBUS_STOPBITS=1
MODBUS_BYTESIZE=8
MODBUS_TIMEOUT=1
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:
{
"command": "uv",
"args": [ "--directory", "/absolute/path/to/MODBUS-MCP/modbus-python", "run", "modbus-mcp", "--env-file", "/absolute/path/to/plant.env" ]
}
Usage
Installing for Claude Desktop
The configuration file:
{
"mcpServers": {
"Modbus MCP Server": {
"command": "uv",
"args": [ "--directory", "/absolute/path/to/MODBUS-MCP/modbus-python", "run", "modbus-mcp" ],
"env": { "MODBUS_TYPE": "tcp", "MODBUS_HOST": "127.0.0.1", "MODBUS_PORT": "1502" }
}
}
}
Using Tools
All tools return a structured object: { "success": bool, "data": any, "error": string|null, "meta": object }. Addresses are zero-based.
The write tools add meta.outcome and meta.request_sent:
written: the device acknowledged the write with a reply that echoes it (function code, address, and value, count or masks);rejected: the device refused it with a Modbus exception that comes before execution by the protocol's definition: 1, 2, 3, 6 or 10;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_packethook, 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);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.
Nothing is sent twice, so read the value back before writing again after an unknown.
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").
The examples use addresses that exist on the mock device (every table holds addresses 0-99; 10-99 are unused, free for experiments); the writes need MODBUS_WRITES_ENABLED=true.
-
Read one holding register (Valve Position on the mock)
{ "tool": "read_register", "parameters": { "address": 0, "slave_id": 1 } }
-
Write one holding register (an unused register on the mock)
{ "tool": "write_register", "parameters": { "address": 10, "value": 100, "slave_id": 1 } }
-
Read coils (chunked automatically when large)
{ "tool": "read_coils", "parameters": { "address": 0, "count": 5, "slave_id": 1 } }
-
Write multiple coils
{ "tool": "write_coils_bulk", "parameters": { "address": 10, "values": [true, false, true], "slave_id": 1 } }
-
Read input registers (Flow Rate, Tank Level, Vibration on the mock)
{ "tool": "read_input_registers", "parameters": { "address": 2, "count": 3, "slave_id": 1 } }
-
Read discrete inputs
{ "tool": "read_discrete_inputs", "parameters": { "address": 0, "count": 8, "slave_id": 1 } }
-
Read multiple holding registers
{ "tool": "read_multiple_holding_registers", "parameters": { "address": 0, "count": 3, "slave_id": 1 } }
-
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
{ "tool": "mask_write_register", "parameters": { "address": 20, "and_mask": 65532, "or_mask": 3, "slave_id": 1 } }
-
Device information
{ "tool": "read_device_information", "parameters": { "slave_id": 1, "read_code": 3 } }
-
Typed reads (with endianness and scaling): two float32 values in registers 40-43, low word first
{ "tool": "read_holding_typed", "parameters": { "address": 40, "dtype": "float32", "count": 2, "byteorder": "big", "wordorder": "little", "scale": 1.0, "offset": 0.0, "slave_id": 1 } }
and the mock's pH (input register 5, stored x100) as a number:
{ "tool": "read_input_typed", "parameters": { "address": 5, "dtype": "uint16", "scale": 0.01, "slave_id": 1 } }
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.
- Write multiple holding registers (1-123 per call)
{ "tool": "write_registers", "parameters": { "address": 50, "values": [1,2,3], "slave_id": 1 } }
- Health
{ "tool": "ping", "parameters": {} }
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.
Tag Map (optional)
Point REGISTER_MAP_FILE to a JSON file that defines named tags. Example file (it matches the mock device):
{
"ValvePosition": { "table": "holding", "address": 0, "unit": "%" },
"Temperature": { "table": "input", "address": 0, "dtype": "uint16", "scale": 0.1, "unit": "°C" },
"PumpSpeed": { "table": "holding", "address": 30, "dtype": "float32", "count": 1, "byteorder": "big", "wordorder": "big", "slave_id": 1 },
"PumpEnabled": { "table": "coil", "address": 0, "slave_id": 1 },
"Status": { "table": "discrete", "address": 0, "count": 2, "slave_id": 1 }
}
Keys of a tag:
| Key | Meaning | Default |
|---|---|---|
table |
holding, input, coil or discrete (aliases such as hr, ir, coils, di work) |
holding |
address |
Zero-based start address (required) | — |
count |
Number of values (typed tags), registers (raw register tags) or bits | 1 |
slave_id |
Unit id | MODBUS_DEFAULT_SLAVE_ID |
dtype |
Holding/input only: one of the typed dtypes; without it a register tag is raw uint16 |
none |
byteorder, wordorder |
Holding/input only: big or little |
big |
scale, offset |
Typed tags only: read returns raw * scale + offset (scale must not be 0) |
1, 0 |
description, unit |
Free text, shown by list_tags |
none |
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.
Tools:
-
List tags
{ "tool": "list_tags", "parameters": {} }
-
Read a tag (typed and scaled when the tag has a
dtype; raw registers or bits otherwise){ "tool": "read_tag", "parameters": { "name": "Temperature" } }
-
Write a tag
- Coils:
trueorfalse(a coil tag withcount> 1 is refused; write those coils withwrite_coils_bulk){ "tool": "write_tag", "parameters": { "name": "PumpEnabled", "value": true } }
- Holding registers: a value, or a list of
countvalues, in the unitsread_tagreturns. The tag'sscaleandoffsetare reversed and the result is encoded bydtype(defaultuint16) and endianness. For an integer dtype the raw value must come out whole: on a tag with scale 0.1,25.3is written as 253, while25.35is refused.{ "tool": "write_tag", "parameters": { "name": "PumpSpeed", "value": 42.5 } }
- Input and discrete-input tags are read-only.
- Coils:
Testing
uv sync
uv run pytest # unit + integration tests
uv run pytest -m "not integration" # unit tests only, no subprocesses
uv run ruff check && uv run ruff format --check
To check the lowest versions pyproject.toml allows, as CI does:
uv venv --python 3.10 .venv
uv pip install --resolution lowest-direct -e . "pytest>=8"
uv run --no-sync pytest
uv sync --locked # back to the locked versions
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.
Known issues
None at the moment. Behaviour worth knowing:
- On a serial line, a request that gets no reply within
MODBUS_TIMEOUTmakes the server close the port and reopen it after anotherMODBUS_TIMEOUT, so the next call takes up to twice that longer. That keeps a reply up to twiceMODBUS_TIMEOUTlate from being taken for the next request's; a later reply can still be mistaken, so setMODBUS_TIMEOUTabove the device's worst-case response time. - The cancel check reads mcp's private
session._in_flight/RequestResponder._cancel_scope(pinned bymcp<2and bytests/test_cancel_race.py).
Testing, Security and Contributing
- Run it against the mock device:
../modbus-mock-server. - Write tools are disabled unless
MODBUS_WRITES_ENABLED=true; read SECURITY.md before pointing the server at real equipment. - Development setup, the CI checks and pull request conventions: CONTRIBUTING.md.
Acknowledgements
This server is derived from 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
MIT — see LICENSE. It names both copyright holders: IndustriAgents, and kukapay for the portions derived from kukapay/modbus-mcp (see Acknowledgements).
Metadata
Release files for industriagents-modbus-mcp 0.3.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| industriagents_modbus_mcp-0.3.0.tar.gz | 159.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| industriagents_modbus_mcp-0.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 217.8 kB
Release files / industriagents_modbus_mcp-0.3.0.tar.gz
| Download URL | industriagents_modbus_mcp-0.3.0.tar.gz |
|---|---|
| Size | 159.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
8516defd94d47b1c369179f670f5a5017bc77e7c15491920e7f16cd8a8d83416
|
|
BLAKE2b-256 checksum How to use checksums |
f5db066e818ccd35fa955535d51da90ecb25e91d02d710c9d44d221a48d7ca22
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 15, 2026.
Transparency logRelease files / industriagents_modbus_mcp-0.3.0-py3-none-any.whl
| Download URL | industriagents_modbus_mcp-0.3.0-py3-none-any.whl |
|---|---|
| Size | 57.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
e9f5073bf480bfe7db7d3d43e752bba45842d2096a49e5e2742b9f73a03ab8ff
|
|
BLAKE2b-256 checksum How to use checksums |
a98ce9ea1a0ca4f389578795f7232a1c785f5f6d7903a9a7068a226ef0625535
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 15, 2026.
Transparency log