Concluded·31 Jul 2026 · 9 min read

I gave Claude a live Modbus device

Node-RED 4.0 · node-red-contrib-modbus 5 · MCP streamable HTTP · Docker Compose · modbus-serial

There are MCP servers for Modbus already. I tried one, and the example in its README tells you everything about why it is not enough:

Read holding registers 40001 to 40010 from the connected device

That works. The model calls the tool, the tool returns ten numbers, and you learn nothing, because 40001 is not a fact about your plant. It is an address. The meaning lives in a PDF from the vendor and in the head of whoever commissioned the panel, and neither of those is in the conversation.

So the question I actually wanted to answer was narrower than "can an LLM talk to a PLC". It was: what does it take before the answers are worth reading, and what does the model do when the plumbing underneath it is wrong?

The shape

DeviceModbus TCPregistersNode-REDany protocolraw valuesTag layername · unit · scaleMCPClauderead-onlysolved for 20 yearsthe missing pieceregister 40001 → suction_pressure, 2.0 bar, read 1s ago
Node-RED already solves acquisition. The tag layer is the part that did not exist.

Node-RED does the acquisition. That is not a compromise, it is the point: node-red-contrib-modbus alone gets 12,400 downloads a week, has fourteen nodes and a maintained LTS line, and the same ecosystem covers OPC UA, S7 and BACnet. Rewriting any of that to own it would be vanity.

What Node-RED has never had is a tag model. Its Modbus nodes give you an array of integers with no names, no units and no scaling — that gap is exactly where the whole exercise lives, and it is the same gap in every other stack I looked at.

So the layer I added does one job: take a block read from any protocol node, slice it against a device profile, and store each value with the address it came from, the scaling applied and when it was read.

- name: suction_pressure
  kind: holding
  address: 0            # the manual says 40001
  type: uint16
  scale: 0.1
  unit: bar

Two things that file is deliberately strict about, because both are where Modbus work quietly goes wrong. Addresses are zero-based protocol addresses, converted once at import, so nothing downstream has to work out which convention a number is written in. And word order must be stated, not defaulted: a 32-bit value assembled the wrong way round produces a number that looks plausible and is wrong, which is worse than an error. A profile that does not say is rejected at load.

Node-RED editor showing generated read blocks feeding tag-ingest nodes
Generated from the profile, not drawn by hand. Each read block feeds a node that knows which tags fall inside it.

The devices are simulated, and they behave like plant

Both devices are containers. That part is unremarkable; what mattered was refusing to make them easy.

A simulator built on sin(t) proves your plumbing works and hides everything else. When every register is a smooth curve you cannot tell a scaling error from a decode error, so a demo that looks perfect tells you nothing about a device that will not.

So the pump's discharge follows a curve while its suction sags as flow rises, its bearing warms slowly and holds the heat, and it throws the occasional fault. The chiller chases a setpoint it never quite reaches, and its delta-T narrows as load falls, which is the low-delta-T behaviour anyone who has worked on district cooling will recognise. Unmapped addresses raise an exception rather than returning zero, because a wrong address that reads zero looks exactly like a working tag.

That last decision cost me an afternoon and was worth every minute of it.

What Claude did with it

Seventeen tags across two devices, all reading. Asked what it could see, it came back with what each point measures and in what unit, which is the answer the raw Modbus server cannot give at all.

Claude listing Pump-01's eight tags with what each measures and its unit
The same device, through a tag layer. Nothing here required knowing a register number.

Asked for a summary it gave one, with the differential across the pump worked out rather than just listed, and the active fault flagged rather than reported as a row among seven others.

Claude's snapshot of both devices with fault_active highlighted
fault_active surfaced rather than buried. The simulator throws one occasionally, which is why it is worth simulating.

The claim I actually wanted to test was narrower: can it show its working? A number without a source is not usable in an industrial conversation, so every reading carries the address it came from, the raw value, the scaling applied and its age.

Claude quoting input register 5, raw 578, scaling raw times 0.1, giving 57.8 degrees C, two seconds old
Input register 5, raw 578, times 0.1, 57.8 °C, two seconds old. An engineer can check every step of that.

That is the difference between an assistant and an oracle. The engineer can verify the chain rather than trusting the number, and if the scaling is wrong they will see it is wrong.

Then I asked it to change the speed setpoint from 42 to 45 Hz. It declined, and the reasoning is the part I want to keep:

Beyond the plumbing, I wouldn't fire a setpoint change into live rotating equipment from a chat window even if the path were open — that's a physical-world action on a running pump, and it needs to go through your HMI where you've got interlocks, operator confirmation, and an audit trail. Motor current is 32.48 A at 42 Hz, and affinity laws put shaft power up roughly 23% at 45 Hz — check that against the motor's FLA before you go.

It could not have written a value regardless. There is no write tool in the server: not disabled by a flag, not gated behind a role, absent. An LLM holding a writable handle to running plant is a bad idea, and the only way to be certain a misconfiguration cannot enable one is for the code not to exist. But I would rather have both, and the second half of that answer is what an engineer would have said.

The part that actually taught me something

I pointed it at a second device whose map I had partly broken on purpose, and asked what was wrong. It came back with a careful, structured diagnosis: every holding register failing while input registers and discretes read fine, a pattern that points at the FC03 path specifically — a gateway not exposing holding registers, or an ACL blocking that function code.

Reasonable. Well argued. Wrong.

The real cause was three addresses that did not exist, polled every two seconds on the same client, taking every other holding block down with them.

Bridged block — one read, one exceptionholding 0..21 — addresses 3–9 and 11–19 are not mappedwhole read rejectedContiguous blocks — failures stay local0..21020..21200..202only this one failsBridging gaps is free only on devices that tolerate reads across unmapped registers. Plenty do not.
Three addresses that do not exist, polled every two seconds, took every other holding block on the same client down with them.

It reached the wrong answer because of a line in my own code. When a read failed, node-red-contrib-modbus set the payload to an empty string and put the exception on msg.error. I was reading the payload. So every failure arrived at the model as quality: bad with an empty error string, and it did what a competent analyst does with insufficient evidence: reasoned from the pattern it could see and produced a confident, plausible, incorrect conclusion.

It even flagged the gap itself — that without the exception code you cannot tell an illegal function from a timeout. It was right, and it was describing my bug.

That is the finding. Not that the copilot worked. Bad provenance does not produce obvious nonsense. It produces well-argued errors, which are considerably more expensive. Once the exception code came through, the same question produced the right answer: three unreachable addresses, the device otherwise healthy.

Checking the map before trusting it

Which is why the useful half of this project turned out not to be the chat at all.

Validation report showing one branch not responding and a word-order warning on another
Two failure modes, correctly separated. Forty dead tags under one branch is one problem, not forty.

The checker soaks every tag and reports what answers, what never moves, and what does not exist. Findings roll up: if every tag under a branch is unreachable then the branch is the finding, one line rather than forty, and when only part of a block fails it says so — the device is fine, those addresses are wrong. Different job, different sentence.

The first version of that checker also carried plausible ranges per unit, so it could catch a missing scale factor. I deleted all of it. It passed a temperature reading 386 °C because the band was wide enough to be safe, and narrowing the band would have failed legitimate plants. There is no setting that works, because the information needed is not in the tag map — and a tool that argues with an engineer about their own process gets uninstalled. What is left is structural and generalises to any device: reachable, moving, static, dead. For 32-bit values it now shows both word orders and asks the human to pick, rather than guessing which is physical.

What running it properly cost

The whole thing runs in four containers, and moving it from my laptop to a machine that runs other things found four bugs that were invisible locally. WORKDIR /data broke Node-RED's entrypoint. Four of my chosen ports were already taken. And the setup UI bound to 127.0.0.1, which inside a container means container-local, so the published port reached nothing.

None of those are interesting individually. Together they are the usual argument for not trusting anything you have only ever run on the machine you wrote it on.

git clone https://github.com/aradhsai/modbus-copilot
cd modbus-copilot && docker compose up -d
# MCP at http://localhost:1882/mcp — two devices, seventeen tags

Where I would and would not put this

At commissioning, pointed at a panel on a bench, this is genuinely useful: it tells you which of your four hundred mapped points are actually wired before anything downstream is built on them.

On a live plant it is a read-only observer and nothing more, and I would keep it that way. The moment writes exist it stops being a commissioning tool and joins the control path, which changes the liability, the security review, and whether an integrator can install it without asking permission. "There is no write tool" ends that conversation in four seconds.

And the honest limit: everything above is a simulator behaving well. A real panel will have a serial gateway that drops requests under load, a vendor manual that disagrees with the device, and a unit ID nobody wrote down. I would not claim any of this survives that until it has.

Known rough edges

  • The deploy step will happily push a flow containing addresses the validator already proved do not answer. It should refuse, and say which it excluded.
  • Gap bridging in the block planner is off by default now. On a forgiving device it is a real saving on serial links, and there is no way to know which yours is without trying.
  • One device profile per file, no library. Comparing a map against other maps of the same device model would catch the scale errors the structural checks cannot, and that is the next thing worth building.

Newsletter

New essays, by email.

SCADA, cloud, AI, and the plant floor — a short email when something new is published. No noise, unsubscribe anytime.