Modbus TCP Server · In-memory store · Web UI · REST · Swagger · MCP over HTTP
Web UI v2.2.0: Modbus register matrix, Address map, Simulation rules, auto-refresh, Swagger & MCP.
A Modbus TCP simulator with a single in-memory store: web UI, REST API, Swagger, and MCP (Model Context Protocol) over HTTP for clients such as Cursor. Built with Rocket, tokio-modbus, and rmcp.
- Modbus TCP — holding/input registers, coils, and discrete inputs.
- JavaScript simulation scripts — server-side QuickJS scripts in
script/react to register writes and run on timers (see below). - REST — the same data as Modbus and MCP.
- Web UI (
/ui/) — register matrix, UInt16/Int32/float/double formats, bitmask, auto-refresh with configurable interval, MCP hint withmcp.jsonexample and download. English (default) and Russian via EN / RU in the title bar; choice is stored in the browser. - MCP — Streamable HTTP at
/mcp, tools such asmodbus_read_holding_registers,modbus_write_holding_registers, and more. - Installable PWA — open the UI in Chrome or Edge at
http://127.0.0.1:9090/ui/(or your host); after the page loads, use Install in the address bar (same idea as YouTube / Spotify web: standalone window, no tabs). Usesmanifest.json+ a minimal service worker under/ui/.
- From source: Rust toolchain.
- Container: Docker (or pull-only from Docker Hub).
Current tag: 2.2.0.
docker pull allexd2010/modbus-server-sim:2.2.0
docker run -d --name modbus-sim \
-p 9090:9090 \
-p 502:502 \
-e RUST_LOG=info \
allexd2010/modbus-server-sim:2.2.0| Host | Container | Purpose |
|---|---|---|
| 9090 | 9090 | HTTP: REST, Swagger, /ui/, MCP /mcp |
| 502 | 502 | Modbus TCP |
Build the image locally:
docker build -t allexd2010/modbus-server-sim:2.2.0 .docker compose up -dPorts and image are defined in docker-compose.yml.
cargo run --releaseDefaults: web 9090 (UI + API + MCP at /mcp), Modbus 502. UI: http://127.0.0.1:9090/ui/.
- Start the server (
cargo run --releaseor Docker with port 9090 published). - Open
http://127.0.0.1:9090/ui/in Chrome or Microsoft Edge (secure context:localhost/127.0.0.1works). - When the browser shows Install in the address bar (or menu → Install this site as an app), confirm — the UI opens in its own window without the normal browser toolbar.
The installed app still talks to the same origin as the page; keep the backend running. For a remote server, use that host in the URL before installing.
| Service | URL / address |
|---|---|
| Web UI | http://<host>:9090/ui/ |
| Swagger | http://<host>:9090/api/v1/swagger/ |
| REST | base prefix /api/v1/ |
| Modbus TCP | <host>:502 (or the port from MB_SERVER_PORT and your Docker -p mapping) |
| MCP | http://<host>:9090/mcp (same port as UI/API) |
- Tabs: holding / input / coils / discrete inputs.
- Offset and Count set the read window. Limits come from the server (
MB_MAX_ADDRESS,MB_MAX_READ_COUNT; defaults 65535 each). The UI loads them fromGET /api/v1/ui-configso inputs stay in sync. The register matrix scrolls inside the grid area. - Auto — periodic reads; interval in seconds; while a cell is focused, auto-refresh does not overwrite your input.
- Scripts — top bar Modbus / Scripts: edit server-side JavaScript (
*.js), save with engine reload. Hidden when simulation scripts are disabled. - AI — MCP help text, current URL for Cursor,
mcp.jsondownload (host as on the page, port 18081 by default, or?mcpPort=in the page URL).
The protocol and API use zero-based offsets (the first holding register is address 0). Modicon-style docs: holding 40001 → offset 0, 40021 → 20.
All routes are under /api/v1/; holding examples:
GET /api/v1/ui-config— JSONmax_modbus_address,max_read_count(same limits as env vars below; used by the web UI)GET /api/v1/holding-registers/{addr}/{cnt}— readPOST /api/v1/holding-register/{addr}/{data}— single wordPOST /api/v1/holding-registers/{addr}— JSON body{"data":[…]}
Read/write requests that exceed the configured limits return 400. Same URL patterns for input, coils, and discrete — see Swagger.
- Transport: Streamable HTTP, endpoint
/mcp. - Same store as REST.
- In tools,
addris the protocol offset, not a 40001-style number.
Modbus: modbus_read_holding_registers, modbus_write_holding_register, … (all read/write tools from before).
Address map: var_map_get, var_map_save, var_map_import — same data as /api/v1/var-map (named variables for map.* in scripts; supports type: "bit" with bit: 0…15).
Simulation rules: sim_scripts_list, sim_scripts_read, sim_scripts_write, sim_scripts_delete, sim_scripts_reload, sim_scripts_export, sim_scripts_import (require script engine; disabled when SIM_SCRIPTS_DISABLE).
Combined: simulation_export, simulation_import — scripts + address map in one JSON bundle (mode: merge or replace).
Example mcp.json (global %USERPROFILE%\.cursor\mcp.json or project .cursor/mcp.json):
{
"mcpServers": {
"modbus-tcp-sim": {
"url": "http://127.0.0.1:9090/mcp"
}
}
}After changing the config, fully restart Cursor.
Disable MCP: MCP_SERVER_PORT=0.
The backend runs JavaScript scripts from a directory (default ./script) using an embedded QuickJS runtime. Scripts load at startup and share the same in-memory Modbus store as TCP, REST, and MCP.
Triggers:
modbus.onWrite(kind, fn)— after any write to a register/coil (holding,input,coil,discreteInput): from Modbus TCP, REST, MCP, or another script. Callback:(addr, values).modbus.setInterval(ms, fn)— periodic callback (minimum interval 1 ms).
API from scripts:
| Method | Description |
|---|---|
modbus.holdingRead(addr, count?) |
Read holding register(s) |
modbus.inputRead(addr, count?) |
Read input register(s) |
modbus.coilRead(addr, count?) |
Read coil(s) |
modbus.discreteInputRead(addr, count?) |
Read discrete input(s) |
modbus.holdingWrite(addr, value) |
Write one word or array |
modbus.inputWrite(addr, value) |
Same for input registers |
modbus.coilWrite(addr, value) |
Write bool or array of bools |
modbus.discreteInputWrite(addr, value) |
Same for discrete inputs |
Typed registers (same layout as the web UI: Int32/Float32 use 2 words with optional word order 'HL' default or 'LH'; Double/Int64 use 4 words big-endian):
| Method | Description |
|---|---|
modbus.holdingReadInt32(addr, order?) / holdingWriteInt32 |
Signed 32-bit |
modbus.holdingReadFloat(addr, order?) / holdingWriteFloat |
IEEE754 float32 |
modbus.holdingReadDouble(addr) / holdingWriteDouble |
IEEE754 float64 |
modbus.holdingReadInt64(addr) / holdingWriteInt64 |
Signed 64-bit (number or {hi, lo} parts) |
modbus.holdingReadInt64Parts(addr) |
{ hi, lo } without precision loss |
modbus.inputReadInt32 … inputWriteInt64 |
Same for input registers |
Bits (16-bit Modbus word, bit 0 = LSB):
| Method | Description |
|---|---|
modbus.getBit(word, n) |
Read bit n (0…15) |
modbus.testBit(word, n) |
true if bit set |
modbus.setBit(word, n, on) |
Set/clear bit, returns new word |
modbus.getBits(word, start, len) |
Extract bit field |
modbus.setBits(word, start, len, value) |
Insert bit field, returns new word |
modbus.int64FromNumber(n) / int64ToNumber(hi, lo) |
Convert int64 parts ↔ number (safe range) |
Address map (map) — read/write named points from the Address map tab (stored in conf/var-map.json). Same typed layout as above:
| Method | Description |
|---|---|
map.read(name) / map.get(name) |
Read by variable name |
map.write(name, value) / map.set(name, value) |
Write by name |
map.has(name) |
true if the name exists in the map |
map.list() |
Array of all variable names |
map.def(name) |
{ name, kind, addr, type, order } or null |
map.def(name).bit |
Bit index 0…15 when type is "bit" (LSB = b0) |
map.onChange(name, fn) |
After any write overlapping the variable: fn(value, name) |
map.onChange(fn) |
Watch all map variables: fn(name, value) |
onChange fires after writes from Modbus TCP, REST, MCP, or scripts (including map.write). The callback receives the decoded typed value (same as map.read).
Example:
map.onChange("Temperature", function (value) {
map.write("Alarm", value > 80);
});
map.onChange(function (name, value) {
if (name === "HR0_counter") {
/* … */
}
});Avoid feedback loops in onWrite handlers (e.g. writing the same address you react to).
Example (script/example.js):
modbus.setInterval(1000, function () {
var v = modbus.holdingRead(100);
modbus.holdingWrite(100, (v + 1) % 65536);
});
modbus.onWrite('holding', function (addr, values) {
if (addr !== 0) return;
var v = Array.isArray(values) ? values[0] : values;
modbus.inputWrite(0, v);
});Mount custom scripts in Docker:
docker run -d -p 9090:9090 -p 502:502 \
-v /path/to/my-scripts:/app/script \
-v /path/to/my-conf:/app/conf \
allexd2010/modbus-server-sim:2.2.0Web UI: Address map tab — named Modbus points; Export / Import for the map. Scripts tab — edit *.js files; Export / Import scripts only, or Export all / Import all for scripts + map together. Save writes to disk and reloads the QuickJS engine; Reload engine applies on-disk files without editing.
REST (same as the UI):
GET /api/v1/sim-scripts— listGET /api/v1/sim-scripts/{name}— readPUT /api/v1/sim-scripts/{name}— save body{ "content": "…" }POST /api/v1/sim-scripts— create{ "name": "foo.js", "content": "…" }DELETE /api/v1/sim-scripts/{name}POST /api/v1/sim-scripts/reload— reload engineGET /api/v1/sim-scripts/export— scripts JSON bundlePOST /api/v1/sim-scripts/import— body{ "version": 1, "scripts": […], "mode": "merge"|"replace" }GET /api/v1/var-map/PUT /api/v1/var-map— read/save address mapGET /api/v1/var-map/export— map JSON{ "version": 1, "variables": […] }POST /api/v1/var-map/import— body{ "version": 1, "variables": […], "mode": "merge"|"replace" }GET /api/v1/simulation/export— combined{ "version": 1, "scripts": […], "varMap": {…} }POST /api/v1/simulation/import— import scripts and/orvarMap/ top-levelvariables
| Variable | Default | Description |
|---|---|---|
SIM_SCRIPTS_DIR |
script (or /app/script in Docker) |
Only directory for *.js simulation scripts (read/save). Legacy scripts/ is merged into script/ on startup and removed. |
SIM_SCRIPTS_DISABLE |
(unset) | Set to 1 or true to disable the script engine |
VAR_MAP_PATH |
conf/var-map.json (or /app/conf/var-map.json in Docker) |
Named Modbus points for the Address map tab and map.* in scripts |
| Variable | Default | Description |
|---|---|---|
WEB_SERVER_PORT |
9090 |
HTTP (REST, Swagger, static /ui) |
MB_SERVER_PORT |
502 |
Modbus TCP |
MCP_SERVER_PORT |
any non-zero | 0 disables MCP; when enabled, MCP is at /mcp on WEB_SERVER_PORT |
MB_MAX_ADDRESS |
65535 |
Maximum protocol address (inclusive). The window addr … addr + cnt - 1 must not pass this bound. |
MB_MAX_READ_COUNT |
65535 |
Maximum words or bits in one HTTP read, and maximum elements in one batch write body. |
RUST_LOG |
info |
Log level via tracing. Examples: info, debug, modbus_tcp_server_rust=debug,info. HTTP static assets under /ui/ are omitted at info to reduce noise. |
See CHANGELOG.md for release history.
Modbus clients: use the host port mapped from MB_SERVER_PORT (default 502). The MB_MAX_* variables apply to the HTTP REST API (and what the web UI calls), not to raw Modbus TCP frame limits inside tokio-modbus.
The backend uses structured logging (tracing). On startup you will see Modbus TCP bind, web/MCP status, script engine, and var-map path.
RUST_LOG=info cargo run --release # default
RUST_LOG=debug cargo run --release # HTTP requests + script load
RUST_LOG=modbus_tcp_server_rust=debug,info cargo run --releaseIn Docker / Compose, RUST_LOG=info is set by default. Static files under /ui/ are not logged at info to reduce noise.
- Port in use — check
netstat/ Task Manager; change-pmappings or environment variables. - MCP not responding in Cursor — URL must be
http://<host>:9090/mcp(same port as the web UI). - Empty or wrong data in the UI — the API must return a JSON array; HTML from a proxy will not fill the table.
- Docker Hub:
allexd2010/modbus-server-sim - Tags: e.g.
2.2.0
MIT
Documentation · latest tag 2.2.0