MCP
The gateway speaks MCP in both directions: agents can read and drive the plant, and an automation rule can call a tool somewhere else.
The design rule is that an agent is a caller like any other. The tools read
through the same cache the UI reads and write through the same gate the UI
writes through. There is no privileged path, no bypass, and no separate policy.
A write an operator would be refused is refused; a write that succeeds lands in
the same audit table with mcp: in front of the actor.
Serving
Section titled “Serving”Over HTTP
Section titled “Over HTTP”Enabled by default at /mcp on the app’s port, protected by a bearer token the
chart generates into the release Secret.
kubectl -n hotloop get secret <release> \ -o jsonpath='{.data.mcp-token}' | base64 -dIn-cluster, the endpoint is:
http://<release>.<namespace>.svc.cluster.local:8080/mcpThe token check sits in front of the handler, so an unauthenticated caller
cannot even enumerate what exists. Leaving mcp.token empty makes the chart
generate one, the default deployment is not accidentally the open one. Combine
it with the NetworkPolicy; a token is a second line of defense, not the only one.
Over stdio
Section titled “Over stdio”The same binary serves MCP on stdin and stdout with --mcp-stdio, for a client
that spawns it directly. Everything else starts too, so the agent sees a live
plant rather than a hollow server.
{ "mcpServers": { "hotloop": { "command": "/usr/local/bin/hotloop", "args": ["--mcp-stdio"], "env": { "HOTLOOP_DB_HOST": "timescale.plant.local", "HOTLOOP_DB_PASSWORD": "…", "HOTLOOP_ALLOW_MCP_WRITES": "false" } } }}In stdio mode all logging goes to stderr. Anything written to stdout would corrupt the protocol stream.
Twenty-five of them.
| Tool | Does |
|---|---|
get_plant_status |
devices online, alarms outstanding, whether writes are enabled |
list_devices |
every device with protocol, address, state, tag count |
describe_device |
one device in detail, with its tags and poll statistics |
list_tags |
search the catalog by device, name or description |
read_tag |
current values, with quality |
query_history |
historical readings, raw or bucketed |
write_tag |
command a tag |
list_alarms |
alarms and their ISA-18.2 states |
ack_alarm |
acknowledge on an operator’s behalf |
list_automations |
rules, run counts, last outcome |
trigger_automation |
run a rule now |
describe_protocol |
what this build actually speaks |
recent_events |
the system log, to work out what happened in what order |
list_entities |
find entities by domain, equipment node or label, with the services each takes |
get_states |
current state of one or more entities, with quality and attributes |
call_service |
command an entity: switch.turn_on, number.set_value |
get_logbook |
one timeline of state changes, writes, alarms, runs and config changes, by entity, equipment node, actor or kind |
list_scripts |
the plant’s scripts, the fields each takes, whether it is running, its last run |
run_script |
run a script, or ask with dryRun what it would do right now |
list_recipes |
the plant’s recipes: targets in order, parameters, precondition, version, last application |
validate_recipe |
would a recipe apply right now? every reason it would be refused; nothing written |
apply_recipe |
apply a recipe: checked whole, written in order, read back |
list_blueprints |
the blueprints people fill in to make rules, their inputs and versions |
preview_blueprint |
the rule a blueprint would make with given inputs, and what it would do now; makes nothing |
find_network_client |
where a device is on the plant network: its switch port, whether the port has a link, when the UniFi console last saw it |
get_network_topology |
the plant network as the UniFi console draws it: what is plugged into what, each device’s state, and the HotLoop devices that lose their network if it goes down |
get_network_events |
the newest events a UniFi console has reported, and whether its events websocket is live |
Entities
Section titled “Entities”An entity is what people call a thing on the plant, sensor.zone_1_temperature
or switch.conveyor_run, wrapping one or more tags. See
ENTITIES.md. For an agent they are usually the better handle:
the name means something, and the entity knows what you can do to it.
list_entities filters by domain, by equipment (a node of the equipment
tree by id, name or path of names like Plant A/Packing, matching everything
at or under it, an entity’s own place winning over its device’s), by label
(label_id or name; an entity matches if it carries the label or its device
does) and by query, a substring of the entity_id or name. Each result carries
its current state and quality and the services it takes, as domain.service.
An entity with no services is read-only. The services come from the entity
layer, so a domain that gains one there shows up here with no change.
get_states takes entity_ids and answers each with its state, quality,
attributes and last_changed. It holds the same line read_tag does: an
unknown or unavailable state is answered with a note saying it is not a
value, a disabled entity says it is disabled rather than missing, and bad
quality is spelled out.
call_service takes domain, service, entity_id, data and a required
reason:
> call_service domain=number service=set_value entity_id=number.press_01_sp data={"value": 212.5} reason="operator asked for 212.5 on zone 1"Called number.set_value on number.press_01_sp (was 210). Writes: 212.5 to press-01/Setpoint: writtenIt goes through entity.Services, the same runner POST /api/services/...
uses, which turns the call into writes through the write gate. See
Writing.
The network
Section titled “The network”find_network_client answers the first question about a PLC that went
quiet: is it the PLC, or its switch port? Give it deviceId and it follows
that device’s confirmed link to its UniFi client; give it query (a MAC, an
IP or part of a name) and it searches every console HotLoop reads. It reads
the UniFi driver’s last read, never the console, so an agent asking in a loop
costs the console nothing. That also means an --mcp-stdio helper answers
that no console is read in its process: it never logs in to one, because the
Gateway beside it already holds the session. Ask the Gateway’s endpoint. See
UNIFI.md.
get_network_topology is the map: each switch, access point and gateway under
the one it is plugged into, with the port on both ends, the link, how many
clients are on it, and the HotLoop devices linked to a client below it. That
last part is the blast radius: it names the PLCs that lose their network if
that switch goes. A switch the console lists as plugged into nothing says so
and is not hung off the gateway. get_network_events is the console’s newest
events, newest first, with its own sentence, a warning flag where the console
calls it negative, and whether the events websocket is live. Both take the
console’s deviceId, which can be left out when HotLoop reads only one, and
both read the driver’s last read like find_network_client, so they cost the
console nothing and say the same thing in an --mcp-stdio helper: no console
is read in that process. Older events are in get_logbook, filed under the
switch or access point they were about.
The logbook
Section titled “The logbook”get_logbook is the timeline GET /api/logbook serves (see
LOGBOOK.md): state changes, writes, alarm transitions,
automation runs and configuration changes, newest first. It narrows by
entity, by equipment (id, name or path, and everything under it; a name
that matches two nodes is an error listing both), by actor (part of it:
mcp: finds everything any agent did), by kinds, and by minutes back
(default a day). A page with more after it ends by saying what to pass as
before for the next. For “what happened before this went wrong” it is the
better recent_events: the equipment’s own state changes are in it, and the
write gate’s lines are not in it twice.
Scripts
Section titled “Scripts”A script is a named sequence of steps somebody wrote once, the CIP cycle or
the line purge, so an agent runs the plant’s own procedure instead of making
one up. See SCRIPTS.md. list_scripts says what each takes.
run_script takes scriptId, fields, a required reason, and two
switches:
dryRun: truewalks the script against the plant as it is and says, step by step, what would be written and the first thing the gate would refuse, in the gate’s words. Nothing is written or audited. Ask this first.wait: trueanswers once the run has ended, with its outcome and trace. Without it the answer is the run id, andlist_scriptsorget_logbookwithkinds: ["script"]says how it ended.
The run is the script’s, as script:<id>, with the source mcp, so every
write it makes meets the MCP switch in the gate exactly like write_tag. With
HOTLOOP_ALLOW_MCP_WRITES off, run_script refuses before anything runs,
the way trigger_automation does; a dry run is still answered, and says the
switch is why.
Recipes
Section titled “Recipes”A recipe is a set of setpoints the plant applies together, a product grade or
a changeover, so an agent loads the plant’s own numbers instead of writing
them one at a time. See RECIPES.md. list_recipes shows each
one’s targets in order and the parameters it takes. validate_recipe asks
whether one would apply right now and gives every reason it would not,
writing nothing. apply_recipe takes recipeId, params, an optional
version and a required reason.
It is checked whole first and refused whole, with nothing written, if any
value would be refused; values out of range are refused, never adjusted. Then
the targets go out in order under one call id, the first that fails stops the
rest, and nothing is undone. The answer says, target by target, what went out
and whether the device read the value back. The writes are the agent’s, with
the source mcp, so the MCP switch applies to each in the gate; with
HOTLOOP_ALLOW_MCP_WRITES off, apply_recipe refuses before anything is
checked. A recipe that names its own apply permission is for the people
granted it, and an agent is not one of them.
Blueprints
Section titled “Blueprints”A blueprint is a rule written once with typed blanks in it: which motor, how
long, what limit. See BLUEPRINTS.md. list_blueprints says
what each one takes. preview_blueprint takes blueprintId, automationId
and inputs, and answers the rule it would make and what that rule would do
if it fired right now, every write put to the gate’s checks. The inputs are
checked exactly as the screen checks them: out of range is refused, not
adjusted.
An agent cannot make the rule. There is no tool for it, on purpose. A rule is
a standing order to command equipment every time its trigger fires, for
months after the conversation that suggested it is over, and whether a plant
has one is a person’s call, made on the Blueprints screen with their name on
it. So an agent can do the useful part (find the blueprint, work out the
inputs, show what the rule would do) and hand the decision over. The preview
stores nothing and writes nothing, so it is answered whatever
HOTLOOP_ALLOW_MCP_WRITES says.
Resources
Section titled “Resources”| URI | Contents |
|---|---|
iiot://devices |
the device tree with tags, as JSON |
iiot://tags |
the tag catalog with current values |
iiot://alarms |
alarm definitions, live state, and a state reference |
iiot://protocols |
supported protocols, from the compiled-in drivers |
Resources exist so a client can pull the whole namespace in one read rather than discovering it a tool call at a time.
What the tools will not do
Section titled “What the tools will not do”The tools are written so that a model cannot round off uncertainty by accident.
A tag with no reading is not a tag reading zero. read_tag says
no reading has been taken for this tag yet, or this tag is not configured if
it does not exist. Neither returns a value.
Bad quality is stated, not implied. Readings come back as
[quality: bad (the value is NOT known; do not treat it as a measurement)]. The
server instructions repeat it. Do not average across bad readings and do not
report one as a measurement.
describe_protocol cannot lie. Availability comes from the drivers compiled
into the binary, so the tool cannot claim a protocol the gateway does not have.
A refusal is an answer, not a transport failure. A refused write comes back
as a tool result with isError, meaning the call was well-formed and
deliberately declined. It is not a protocol error to be retried.
Writing
Section titled “Writing”write_tag moves real equipment. Four things stand between a tool call and a
machine:
HOTLOOP_ALLOW_WRITESmust be on.HOTLOOP_ALLOW_MCP_WRITESmust be on. This is a narrowing of the master switch, not a way around it, with writes on and this off, an operator can command a tag and an agent cannot.- The tag must be marked writable. Tags are read-only until somebody says otherwise, one at a time.
- The value must be within the tag’s configured range. It is rejected, not clamped, sending 400 when someone asked for 500 is quietly doing something different from what was asked.
A reason is required and is recorded next to the write. An audit row nobody can interpret six months later is barely better than none.
call_service gets exactly the same four, because it is nothing but writes
through the same gate, and then the entity’s own checks on top (a number’s
step and narrower range, a text’s pattern, the entity not being disabled). A
refusal at any of them is audited against the entity as well as the tag:
source mcp, the client by name, the entity_id, the service and the call id,
the same row a refused write_tag leaves plus the entity. A call without a
reason is refused before anything is attempted, like write_tag.
trigger_automation is gated identically, because a rule’s actions can command
equipment; otherwise it would be a way around write_tag being switched off.
> write_tag tag=press-01/Zone1_Temp value=99 reason="testing"write refused: press-01/Zone1_Temp: this tag is not marked writableTo run a read-only agent, set allowMcpWrites: false. The server instructions
then tell clients up front that writes are disabled, so an agent does not waste
a turn discovering it.
Calling out
Section titled “Calling out”An automation’s mcp_call action invokes a tool on another MCP server. Configure
the servers in the chart:
mcp: outboundServers: - name: maintenance url: http://maintenance-mcp.default.svc.cluster.local:8080/mcp existingSecret: maintenance-mcp-token secretKey: tokenSessions are pooled and reused; connecting per call would mean a handshake, an initialize and a tool listing every time a rule fires. A session that has died is reconnected on the next call rather than on a timer, because the next call is the first moment anybody cares. A configured server that is down does not stop the plant being monitored, nothing is dialed until a rule needs it.
The MCP screen shows each configured server, whether it is connected, and the tools it advertises.
Federating sites
Section titled “Federating sites”One gateway per plant is how this is meant to run. Each site keeps its own database, its own board and its own name, and none of them depend on a link to anywhere else staying up, a plant does not stop being monitored because the WAN dropped.
Seeing all of them at once uses the protocol the sites already speak. Configure
a gateway at the cloud with the sites as outbound MCP servers, exactly as for the
mcp_call action:
HOTLOOP_MCP_SERVER_1_NAME=macedoniaHOTLOOP_MCP_SERVER_1_URL=https://macedonia.example/mcpHOTLOOP_MCP_SERVER_1_TOKEN=…HOTLOOP_MCP_SERVER_2_NAME=ashtabulaHOTLOOP_MCP_SERVER_2_URL=https://ashtabula.example/mcpHOTLOOP_MCP_SERVER_2_TOKEN=…The Sites screen then calls get_plant_status on each of them in parallel and
rolls the answers up. There is nothing to deploy at a site to make it appear, no
site has to know it is federated, and nothing connects inward to a plant network.
Two behaviors are deliberate and worth relying on:
- A site that does not answer is shown as unreachable, with the reason, and is left out of the roll-up. Counting a plant you cannot reach as zero alarms is the worst thing a screen like this could do.
- The numbers a site last reported are never shown. They look current and are not.
Each site gets its own short deadline, so one plant on a dead link cannot hold up the ones that are up.
Worth telling an agent
Section titled “Worth telling an agent”The server’s own instructions cover this, but for anyone writing a prompt around it:
- Alarm states follow ISA-18.2.
rtn-unackmeans an alarm cleared on its own before anybody saw it, it is still worth reporting, and it is the state that proves acknowledgement and return-to-normal are independent. query_historywith buckets, not raw, for anything longer than a few minutes. A day of one-second data is 86,400 points per tag. Buckets follow the quality rule: bad and uncertain readings are counted inbadReadings, never averaged in, and a bucket with nothing good has a null average.recent_eventsis the way to reconstruct an incident: device connections, writes, alarm transitions, automation runs and configuration changes, in order, each with the actor that caused it.