Skip to main content

0.3.0 package and tool migration: Install industriagents-modbus-mcp from npm (requires uv) or PyPI; the command remains modbus-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 unless MODBUS_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.
  • Typed reads:
    • read_holding_typed, read_input_typed for int16/uint16/int32/uint32/int64/uint64/float32/float64 with byte/word endianness, scale, and offset.
    • 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).
  • Tag map (optional):
    • 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.
  • 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 returns success: false, "no reply; the write may have been applied; check before retrying".
    • 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).
    • 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.
  • 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.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.
  • 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).
  • Health:
    • 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.
  • Prompt:
    • Analyze register values with a simple prompt: analyze_register.

Requirements

  • Python: 3.10+ (uv uses 3.10 from .python-version)
  • uv for dependency and virtual environment management.

Installation

  1. Install uv:

    curl -LsSf https://astral.sh/uv/install.sh | sh
    
  2. Clone the Repository:

    git clone https://github.com/IndustriAgents/MODBUS-MCP.git
    cd MODBUS-MCP/modbus-python
    
  3. Install Dependencies:

    uv sync
    

    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.

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:

  1. the process environment (for an MCP client, the env block of its server configuration);
  2. a file passed explicitly with modbus-mcp --env-file PATH;
  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.

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_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);
  • 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.

  1. Read one holding register (Valve Position on the mock)

    { "tool": "read_register", "parameters": { "address": 0, "slave_id": 1 } }
    
  2. Write one holding register (an unused register on the mock)

    { "tool": "write_register", "parameters": { "address": 10, "value": 100, "slave_id": 1 } }
    
  3. Read coils (chunked automatically when large)

    { "tool": "read_coils", "parameters": { "address": 0, "count": 5, "slave_id": 1 } }
    
  4. Write multiple coils

    { "tool": "write_coils_bulk", "parameters": { "address": 10, "values": [true, false, true], "slave_id": 1 } }
    
  5. Read input registers (Flow Rate, Tank Level, Vibration on the mock)

    { "tool": "read_input_registers", "parameters": { "address": 2, "count": 3, "slave_id": 1 } }
    
  6. Read discrete inputs

    { "tool": "read_discrete_inputs", "parameters": { "address": 0, "count": 8, "slave_id": 1 } }
    
  7. Read multiple holding registers

    { "tool": "read_multiple_holding_registers", "parameters": { "address": 0, "count": 3, "slave_id": 1 } }
    
  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

    { "tool": "mask_write_register", "parameters": { "address": 20, "and_mask": 65532, "or_mask": 3, "slave_id": 1 } }
    
  9. Device information

    { "tool": "read_device_information", "parameters": { "slave_id": 1, "read_code": 3 } }
    
  10. 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.

  1. Write multiple holding registers (1-123 per call)
{ "tool": "write_registers", "parameters": { "address": 50, "values": [1,2,3], "slave_id": 1 } }
  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: true or false (a coil tag with count > 1 is refused; write those coils with write_coils_bulk)
      { "tool": "write_tag", "parameters": { "name": "PumpEnabled", "value": true } }
      
    • 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.
      { "tool": "write_tag", "parameters": { "name": "PumpSpeed", "value": 42.5 } }
      
    • Input and discrete-input tags are read-only.

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_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.
  • The cancel check reads mcp's private session._in_flight / RequestResponder._cancel_scope (pinned by mcp<2 and by tests/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)

Source distribution for industriagents-modbus-mcp 0.3.0
File Size Uploaded
industriagents_modbus_mcp-0.3.0.tar.gz 159.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for industriagents-modbus-mcp 0.3.0
File Interpreter ABI Platform
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 log

Release 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

Release history Release notifications | RSS feed

This release

0.3.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page