Reference · draft 0.2 for early partners
Specification
An open protocol for AI agents to discover, understand and safely operate physical devices: microscopes, liquid handlers, robot arms, lasers, furnaces. Five primitives, one descriptor, enforced safety, and a context cost that stays flat from two devices to two thousand.
- 1Why MHP
- 2Architecture
- 3Scale: one device among thousands
- 4The device package
- 5Primitives
- 6Lifecycle
- 7Safety model
- 8Notifications
- 9Transports and discovery
- 10Three control surfaces
- 11Authoring descriptors
- 12Error codes
- 13Device classes
- 14Bridging existing ecosystems
- 15Conformance
- 16Not in 0.1
- AReference implementation
1Why MHP
Every instrument on a bench or factory floor has its own programming interface. Connecting them to each other is hard. Connecting them to an AI agent is harder, because the agent needs three things no vendor API provides:
- a uniform way to read and write the device;
- a machine-readable account of what the device is and how to use it safely;
- a way to hand off long-running work so the device runs without the agent reasoning at every step.
MHP answers all three with one small protocol that borrows the shape of MCP: JSON-RPC 2.0 messages, a short list of primitives, capability negotiation at connect time, and a host, client, server split.
| MCP exposes | MHP exposes |
|---|---|
| tools | actions, which return jobs the device runs on its own |
| resources | signals to read and settings to write |
| prompts | a device package: card, operating instructions, references |
| (nothing) | a safety envelope: limits, interlocks, approval levels, e-stop, enforced by the driver |
Design rules, in priority order
- Safety is enforced by the driver, never by the agent. A wrong value from an agent is refused, not obeyed.
- The device package is the whole manual. Tacit knowledge from paper manuals and people's heads is written down where the agent reads it.
- Five primitives, no more. Describe, signals, settings, actions, safety.
- Bridge, don't replace. SiLA 2, PyLabRobot, MADSci, OPC UA and ROS 2 devices join through thin adapters.
- The agent's context is protected. Two thousand devices cost the same handful of tokens as two (§3).
2Architecture
| Role | Responsibility |
|---|---|
| Host | Any agent harness: Claude Code, Codex, OpenClaw, Hermes, Claude Science, Open Science, a MADSci workcell manager, or your own program on any SDK. It owns the conversation with the model, decides what to do, holds one client per device, and presents human confirmation prompts. Most hosts reach MHP through the mhp-mcp bridge (§10.1) rather than speaking MHP directly. |
| Client | One connection to one server. Sends requests, receives responses and notifications, tracks the lease. |
| Server (driver) | Owns exactly one device. Serves the descriptor, enforces the safety envelope, translates primitives into vendor commands, runs jobs. |
| Device | The physical thing. A single instrument, or a coordinated cell exposed as one logical device. |
| Directory | A server whose role is finding devices rather than being one. Indexes cards, answers searches, holds no device connections (§3.2). |
3Scale: one device among thousands
MCP's first year taught a specific lesson. Loading every tool definition up front cost about 55,000 tokens for five ordinary servers, and selection accuracy fell as the list grew. Anthropic's fixes, described in Advanced tool use, were three: a tool search tool with deferred loading, programmatic tool calling so intermediate results never enter the model's context, and usage examples inside tool definitions. MHP builds all three in from the start, because a lab has more devices than a workspace has tools, and every device descriptor is longer than a tool schema.
| MCP mechanism | Reported effect | MHP equivalent |
|---|---|---|
| Tool search, deferred loading | 85% fewer tokens; Opus 4 accuracy 49% → 74% | Descriptor detail tiers + directory/search (§3.1, §3.2) |
| Programmatic tool calling | 37% fewer tokens on research tasks | mhp_run scripts against the Lab client (§3.4) |
| Tool use examples | 72% → 90% on complex parameters | examples on actions; input_examples on every bridge tool (§3.5) |
3.1 Descriptor detail tiers
device/describe takes a detail parameter. A host never has to load the whole descriptor to decide whether it wants the device.
| Tier | Cost | Contents |
|---|---|---|
card | ~40 tokens | Level 1: the DEVICE.md frontmatter, live state, and the names of every signal, setting and action |
summary | under 1k tokens | Level 2: card + the DEVICE.md operating instructions + every signal, setting and action with type, unit, limits, approval level and interlocks + the list of bundled resources |
full | unbounded | Level 3: the whole descriptor, including notes, params and examples |
select fetches full specs for named items only: {"select": {"actions": ["run_protocol"], "settings": ["target_temperature"]}}. The normal path is card, then summary, then select the two things you will use. A host SHOULD default to summary; full is for authoring and debugging.
3.2 The directory
A directory is an MHP server whose role is finding devices rather than being one. It indexes cards, not descriptors, and keeps no open connection to the devices it lists.
| Method | Params | Returns |
|---|---|---|
directory/search | query (free text), class, tags, location, state, limit, live (default true) | ranked cards with live state, each with a target the client can connect to |
directory/get | id | one card + target, state pinged now |
directory/stats | counts by class and state |
→ {"method":"directory/search","params":{"query":"heat a 96-well plate to 95 C for PCR in bay 12",
"class":"thermocycler","state":"idle","limit":3}}
← {"result":{"results":[
{"id":"thermocycler-0411","class":"thermocycler","make":"Bio-Rad","model":"C1000","location":"bay 12",
"tags":["pcr","heating"],"notes":"96-well block.","state":"idle",
"signals":["block_temperature","lid_temperature","lid_closed","cycle"],
"settings":["target_temperature","lid_heater"],"actions":["run_protocol","open_lid"],
"target":"http://bay12-bench3:18921","score":10.08}, ...]}}
Ranking is BM25 over id, class, make, model, location, tags, notes and the names of signals, settings and actions, so the query above finds thermocyclers in bay 12 without the agent knowing any ids. Filters are exact. state lets an agent ask for an idle instrument.
A directory is built by asking each device for its card once, or from a manifest file the lab maintains. State is never served from the index. The index holds only what is static about a device: identity, location, tags, capability names. At query time the directory pings the top candidates in parallel with a short timeout, fills in each card's state from the answer, and only then applies the filter. directory/get always pings. A device that does not answer is reported as unreachable and never matches state: "idle".
| Reference directory | Value |
|---|---|
| Ping timeout per device | 500 ms |
| Candidates pinged when a state filter is given | 4 × limit |
| A ping is reused for | 2 s |
A client MAY pass live: false to skip pinging when it only wants identity, and MUST still treat the device's own describe or ping as authoritative before acting. Small labs need no directory: a client with an explicit name-to-target map scans its own devices' cards.
3.3 Constant tool surface
An MCP host connected to an MHP lab sees eight operating tools regardless of device count: mhp_find, mhp_describe, mhp_read, mhp_write, mhp_invoke, mhp_job, mhp_estop, mhp_run, plus one lab-management tool, mhp_lab (§9.1). There are never per-device tools. Bridges MUST NOT enumerate devices into the tool list or the resource list at startup; resources list only the devices the session has opened.
3.4 Programmatic runs
mhp_run executes an orchestration script against the lab and returns only what it prints, capped. A script that takes five hundred temperature readings and reports a mean puts one line in the model's context, not five hundred numbers. The script sees the same Lab client as §10.3, and every call it makes passes the same safety gates as a direct tool call. Hosts SHOULD sandbox script execution; the reference implementation runs scripts in-process so simulated device state is shared, and says so.
# mhp_run: 500 readings in, one line out
t = lab["thermocycler-0411"]
with t:
j = t.wait(t.invoke("run_protocol", steps=[{"temp": 95, "hold_s": 30}], cycles=5))
rs = [t.read("block_temperature") for _ in range(500)]
print("cycles", j["result"]["cycles_completed"], "mean", sum(rs) / len(rs))
3.5 Examples in the descriptor
Actions MAY carry examples, an array of realistic parameter objects. Bridges MUST forward them, and the reference bridge's own tools ship input_examples. A schema says what is valid; an example says what is normal.
3.6 Measured
The reference scale demo builds 2,000 simulated devices across 8 classes and 40 bays, then runs the query above.
| Approach | Tokens in agent context |
|---|---|
| Every descriptor loaded up front | 937,845 |
mhp_find (5 cards) + summary of the chosen device + full spec of the 2 items used | 1,087 |
That is 0.11% of the naive cost, with search taking well under a millisecond. The device is then operated through exactly the same primitives as in a two-device lab.
4The device package
A device is described the way an Agent Skill is: a folder whose contents load in three levels, each only when the agent needs it. This is the same progressive disclosure that lets a harness hold hundreds of skills at about a hundred tokens each, applied to hardware.
thermocycler-01/
├── DEVICE.md Level 1: YAML frontmatter, the card (always cheap)
│ Level 2: Markdown body, operating instructions (when the device is chosen)
├── descriptor.yaml Level 3: full machine-readable spec: limits, params, examples
├── driver.py Level 3: code, a Driver subclass or an adapter's DEVICE object
├── references/ Level 3: manual excerpts, SOPs, calibration tables
└── scripts/ Level 3: ready-made orchestration scripts for mhp_run
| Level | Content | Served by | Loaded when |
|---|---|---|---|
| 1 | frontmatter: id, class, make, model, location, tags, description | device/describe {detail: "card"}, directory/search | search results; about 40 tokens per device |
| 2 | DEVICE.md body: how to operate it, what to check, what never to do | device/describe {detail: "summary"} with a slim capability table | the agent picks this device; under 1k tokens |
| 3 | descriptor items, references, scripts, driver | device/describe {select}, resources/list, resources/read {path} | the agent needs that item; none until asked |
4.1 DEVICE.md
The frontmatter is the card. description is what a directory ranks and what an agent matches its task against, so it must say both what the device is and when to pick it, in under 1,024 characters. The body is written for the agent that has just chosen the device: an operating procedure, what to watch, and pointers to the resources it may need.
---
mhp: "2026-09-09"
id: thermocycler-01
class: thermocycler
make: SimBio
model: TC-96
location: bay 3, bench 3
tags: [pcr, heating, 96-well]
description: 96-well PCR thermocycler with heated lid, 4 to 105 degC block. Use for PCR,
denaturation, ligation holds and any timed temperature program on a 96-well SBS plate.
Not for tubes, not for cooling below 4 degC.
driver: driver.py:SimThermocycler
---
# Thermocycler-01
Sits on bench 3 in bay 3, left of the liquid handler; the plate arm reaches it at `thermocycler`.
The block ramps about 4 °C/s. Turn `lid_heater` on ~40 s before loading to avoid condensation.
## Operating procedure
1. Confirm `lid_closed` is true; `run_protocol` refuses otherwise.
2. Set `lid_heater` true; wait for `lid_temperature` above 100 °C.
3. Invoke `run_protocol`; poll the job. 30 cycles of three steps take ~75 minutes.
4. Write `target_temperature` 4 to hold. Open the lid only below 60 °C (needs a human).
## Resources
- `references/protocols.md`: standard programs per polymerase.
- `scripts/pcr.py`: complete run through `mhp_run`, including the 4 °C hold.
4.2 descriptor.yaml
The machine-readable part. Identity fields live in the frontmatter and are merged in; everything below is enforced or served by the driver.
device:
notes: >
96-well block on bench 3, left of the liquid handler. Lid must be closed
before any run. Block ramps ~4 °C/s; lid heater takes ~40 s to reach 105 °C.
physical: # anything the agent cannot infer from code
mass_kg: 12.5
footprint_mm: [331, 461, 251]
power_w: 851
notes: Bench-mounted, do not relocate while running. Hot lid surface up to 110 °C.
signals: # READ
- name: block_temperature
type: number
unit: degC
notes: Measured at block centre; edge wells lag by ~0.5 °C.
- name: lid_closed
type: boolean
notes: True when the lid latch is engaged.
settings: # WRITE
- name: target_temperature
type: number
unit: degC
limits: {min: 5, max: 106} # enforced by the driver
approval: auto # auto | confirm | forbid
notes: Below 4 °C condensation forms; above 105 °C the seal fails.
actions: # INVOKE -> job
- name: run_protocol
duration: long # short | long
approval: auto
interlocks: [lid_closed] # signals that must be truthy
params:
steps: array of {temp: degC, hold_s: number}
cycles: integer
notes: Runs a cycling program; steps are repeated `cycles` times.
examples: # what normal usage looks like, §3.5
- {steps: [{temp: 95, hold_s: 30}, {temp: 58, hold_s: 30}, {temp: 72, hold_s: 45}], cycles: 30}
- name: open_lid
duration: short
approval: confirm # a human must confirm each invocation
notes: Lid may be hot; a human should be present.
safety:
estop: true # driver implements safety/estop
interlocks: [lid_closed]
watchdog_s: 11 # driver fails safe if no ping within this window
notes: E-stop cuts block and lid heaters; the block cools passively.
4.3 Resources
resources/list returns the relative paths of every file in the package except DEVICE.md. resources/read {path} returns a text file's contents, capped, and MUST refuse paths outside the package. Scripts under scripts/ are written for mhp_run and see lab: an agent reads one, adapts the constants, and runs it, so the device owner's tested procedure is what executes.
4.4 Field rules
| Field | Rule |
|---|---|
device.id | MUST be unique within a host's set of devices. class is an open string; §13 lists the initial vocabulary. |
device.description | Frontmatter. SHOULD state what the device is and when to pick it, at most 1,024 characters. It is the ranking text for directories and the card text for agents. |
notes | Any object may carry one. Notes are natural language for the agent, the mechanism by which tacit knowledge enters the protocol. Drivers MUST pass them through unchanged. |
type, unit | number | integer | boolean | string | object | array. Units SHOULD be UCUM codes or common spellings (degC, mm, percent, N, rpm). |
limits (setting) | {min, max} for numbers, {enum: [...]} for strings. The driver MUST refuse writes outside limits with LimitViolation. |
limits (action) | A map from parameter name to [min, max]. Drivers SHOULD enforce these inside the action. |
approval | auto: the agent may act. confirm: each call needs approved: true, which the host may set only after human confirmation. forbid: never agent-operable; listed so the agent knows the capability exists and why it is off-limits. |
interlocks | Boolean signals that MUST read truthy at the moment of the write or invoke; otherwise InterlockOpen. |
duration | short actions finish within seconds and hosts MAY block on them. long actions run as jobs; the host SHOULD poll or subscribe. |
location, tags, examples | Optional but SHOULD be set. Location and tags are what a directory filters on; examples on an action is an array of parameter objects. |
| unknown keys | Permitted at top level (for example locations on a robot arm) and MUST be preserved by adapters and bridges. |
5Primitives
All requests are JSON-RPC 2.0. Method names are namespaced with /, as in MCP.
| Primitive | Methods | Direction |
|---|---|---|
| Lifecycle | initialize, ping | client → server |
| Describe | device/describe {detail, select} | client → server |
| Resources | resources/list, resources/read {path} | client → server |
| Directory | directory/search, directory/get, directory/stats | client → directory server |
| Signals | signals/list, signals/read, signals/subscribe | client → server |
| Settings | settings/list, settings/write | client → server |
| Actions | actions/list, actions/invoke, jobs/status, jobs/list, jobs/cancel | client → server |
| Safety | safety/limits, safety/estop, safety/reset | client → server |
| Session | session/acquire, session/release | client → server |
| Notifications | notifications/signals/update, notifications/settings/changed, notifications/jobs/*, notifications/safety/* | server → client |
| Elicitation | elicitation/confirm (optional, §7.2) | server → client |
5.1 initialize
→ {"jsonrpc":"2.0","id":1,"method":"initialize",
"params":{"protocolVersion":"2026-09-09","clientInfo":{"name":"claude-lab","version":"1.0"}}}
← {"jsonrpc":"2.0","id":1,"result":{
"protocolVersion":"2026-09-09",
"device":{"id":"thermocycler-01","class":"thermocycler","make":"SimBio","model":"TC-96"},
"capabilities":{"signals":true,"settings":true,"actions":true,
"subscribe":true,"lease":true,"estop":true}}}
Servers MUST respond to initialize before any other method. Capabilities are booleans; a client MUST NOT call a method whose capability is false.
5.2 device/describe
Returns the descriptor (§4) at the requested detail tier (card, summary or full, §3.1) or the named items in select, plus state, one of idle, busy, fault, estop. Hosts SHOULD call this with summary before operating a device, then select the items they will use.
5.3 Signals: read
→ {"method":"signals/read","params":{"names":["block_temperature","lid_closed"]}}
← {"result":{"values":{"block_temperature":22.0,"lid_closed":true},"ts":1788991266.5}}
Omitting names reads every signal. signals/subscribe asks the server to push notifications/signals/update for the named signals; over HTTP these arrive on the /events stream, over stdio as interleaved notification lines.
5.4 Settings: write
→ {"method":"settings/write","params":{"name":"target_temperature","value":95}}
← {"result":{"ok":true,"name":"target_temperature","value":95}}
→ {"method":"settings/write","params":{"name":"target_temperature","value":200}}
← {"error":{"code":-32010,"message":"target_temperature=200 above max 105","data":{"min":4,"max":105}}}
Optional params: approved: true for confirm settings, and dryRun: true to run every gate, touch nothing, and return what would have been written.
5.5 Actions and jobs
actions/invoke returns immediately with a job. The device does the work; the agent polls or subscribes.
→ {"method":"actions/invoke","params":{"name":"run_protocol",
"params":{"steps":[{"temp":95,"hold_s":30},{"temp":58,"hold_s":30},{"temp":72,"hold_s":45}],"cycles":30}}}
← {"result":{"job":{"id":"job_04324008","action":"run_protocol","state":"queued","progress":0.0}}}
→ {"method":"jobs/status","params":{"id":"job_04324008"}}
← {"result":{"job":{"id":"job_04324008","state":"running","progress":0.43}}}
... later, unprompted ...
← {"method":"notifications/jobs/finished","params":{"job":{"id":"job_04324008","state":"done",
"progress":1.0,"result":{"cycles_completed":30,"final_block_temperature":72}}}}
Job states run queued → running → done | failed | cancelled. A failed job puts the device in fault until safety/reset. A busy device refuses a second non-concurrent invoke with DeviceBusy.
5.6 Safety
safety/limits returns the effective limits, interlocks and approval levels in one call, for hosts that want to show the envelope to a human.
safety/estop MUST stop all motion and energy output as fast as the hardware allows, cancel every running job, and put the device in estop. It MUST succeed regardless of lease, approval level or interlock state. It is the one call an agent may always make.
safety/reset returns the device to idle after estop or fault. It MUST fail with DeviceBusy while any job is still running.
5.7 Session leases
session/acquire {ttl} gives the calling client exclusive write and invoke rights until ttl seconds elapse or session/release. Reads, ping, describe and estop are never blocked by a lease. Leases let an orchestration script own several devices for the duration of a protocol without a second agent interleaving commands.
6Lifecycle
client server
│── initialize ───────────────────────▶│ version + capabilities
│── device/describe ──────────────────▶│ descriptor into agent context
│── session/acquire {ttl:600} ────────▶│ optional exclusive control
│── signals/subscribe ────────────────▶│ optional streaming
│── settings/write / actions/invoke ──▶│ operate, gated by §7
│◀─ notifications/... ─────────────────│
│── ping (within watchdog_s) ─────────▶│ keeps the watchdog fed
│── session/release ──────────────────▶│
Watchdog. If the descriptor declares safety.watchdog_s, the server MUST fail safe (the device-specific equivalent of e-stop, or at minimum refusing further writes) when it has received no request from the lease holder within that window. This is what protects the lab when the agent process dies mid-protocol.
Versioning. protocolVersion is a date string, as in MCP. A server MUST answer with the highest version it supports that is not newer than the client's; a client that receives an older version MUST either speak it or disconnect.
7Safety model
The safety model is the reason MHP exists as a separate protocol rather than a set of MCP tools. Every rule below is enforced in the server, on the device side of the network, so it holds even when the agent is wrong, compromised, or has lost context.
7.1 Gate order
On every settings/write and actions/invoke the server evaluates, in this order, and stops at the first failure:
- State
EStopActiveif in estop,DeviceFaultif in fault. - Approval
Forbiddenfor forbid items;ApprovalRequiredfor confirm items sent withoutapproved: true. - InterlocksEach named signal is read live at that moment. Any falsy value:
InterlockOpen. - LimitsNumeric range or enum:
LimitViolation, with the limits indata. - LeaseAnother client holds an unexpired lease:
NotLeased. - BusyA non-concurrent job is running:
DeviceBusy.
Only after all six pass does vendor code run. dryRun: true stops after the sixth gate and reports what would have happened.
7.2 Approval levels and elicitation
confirm is the bridge between agent autonomy and human oversight. The intended flow: the agent invokes open_lid and receives ApprovalRequired. The host shows the human a confirmation using the action's own notes ("Lid may be hot; a human should be present."). The human confirms, and the host re-sends the call with approved: true.
A host MUST NOT set approved: true on the model's say-so alone. Servers that can reach a human directly, through a touchscreen on the instrument or a physical key switch, MAY instead implement elicitation/confirm as a server-initiated request and wait for the answer in-band.
7.3 Fail closed
Any exception inside vendor code during a job moves the device to fault. Both fault and estop block all writes and invokes until an explicit safety/reset. There is no automatic recovery: a human or the agent must decide the device is safe to resume.
7.4 Defence in depth
Descriptor limits protect setpoints. Action parameters (a PCR step at 200 °C inside an otherwise valid protocol) are the driver's responsibility. Drivers SHOULD validate parameters against the same physical limits and MAY publish them under actions[].limits. The reference thermocycler does both.
7.5 What MHP does not do
MHP is not a functional-safety system in the IEC 61508 sense. Hard-wired e-stops, light curtains and PLC safety logic remain the primary layer. MHP is the layer that keeps an agent from asking for something unsafe, and gives it a standard way to stop everything when it sees something wrong.
8Notifications
Servers push notifications (JSON-RPC requests without id) for anything a supervising agent needs to react to.
| Method | Params | When |
|---|---|---|
notifications/signals/update | {name, value, ts} | a subscribed or driver-chosen signal changes |
notifications/settings/changed | {name, value, by} | any client writes a setting |
notifications/jobs/started | {job} | job leaves the queue |
notifications/jobs/progress | {job, ...extra} | driver reports progress; extra keys are device-specific |
notifications/jobs/finished | {job} | job reaches done, failed or cancelled |
notifications/safety/estop | {by, reason} | e-stop engaged by any client |
notifications/safety/reset | {by} | device returned to idle |
9Transports and discovery
MHP defines two transports, mirroring MCP.
stdio. Newline-delimited JSON-RPC on the driver process's stdin and stdout. For a driver running on the bench PC, launched by the host.
HTTP. POST /rpc for requests. GET /events is a Server-Sent Events stream of notifications. GET /mhp.json returns the descriptor with no handshake, so an agent, or a person with curl, can learn what a device is from its URL alone. Clients SHOULD send an X-MHP-Client header so leases and audit logs can name them.
Network discovery. Servers SHOULD advertise via mDNS / DNS-SD as _mhp._tcp with TXT records id, class and path=/mhp.json; the reference HTTP transport does so when the optional zeroconf package is installed. Clients discover devices two ways, and SHOULD use both: browse _mhp._tcp, and probe GET /mhp.json on candidate hosts across the conventional port range (18900 to 18939), which works on networks that block multicast. Labs MAY additionally run a directory (§3.2).
9.1 The fleet and mhp_lab
A host keeps a fleet: the list of devices it knows, persisted as ~/.openmhp/fleet.json, each entry an id, a target and the device's card. Device packages the host owns are hosted in-process from ~/.openmhp/devices/<id>/, so a scientist's laptop needs no separate device servers for them. The bridge exposes the fleet through one tool, mhp_lab:
| op | Effect |
|---|---|
scan | mDNS browse plus HTTP probe of localhost and any hosts given; returns devices not yet in the fleet |
add | register a target (http://host:port, a package folder, or a package id under ~/.openmhp/devices); fetches the card; immediately searchable |
new | create a package skeleton for an instrument that does not speak MHP yet |
write | write files into that package; paths may not escape it |
validate | run the package validator |
list, remove, home | membership and paths |
This is how a scientist adds an instrument without leaving the conversation: "find the instruments on my network" runs scan; "add the thermocycler" runs add. For an instrument with no MHP server, the openmhp-onboard-device skill interviews the owner and the agent writes the package with new, write and validate, then adds it. The npx openmhp launcher installs the runtime, the skills and the harness registration in one command.
Authentication is transport-level and out of scope for 0.1. HTTP deployments SHOULD sit behind TLS with bearer tokens or mutual TLS. The protocol reserves params.auth for a future in-band scheme.
10Three control surfaces
The same five primitives are reachable three ways. They compose: an agent uses MCP to explore and decide, writes a code file for the parts that must run fast or long, and uses the CLI to check on it.
MCP bridge
Eight generic tools, constant in the number of devices. The descriptor is the tool documentation, loaded on demand, so no per-device tool code is written.
Command line
The debugging and shell-scripting surface, and what an agent reaches for inside a Bash tool.
Code files
For work that must run for hours or faster than the model's reasoning loop. The devices execute; the model reads the result.
10.1 MCP bridge
| MCP tool | MHP call |
|---|---|
mhp_find | directory/search, or a scan of cards in a small lab |
mhp_describe | device/describe {detail, select}, default summary; with resource= it calls resources/read |
mhp_read | signals/read |
mhp_write | settings/write |
mhp_invoke | actions/invoke, optionally waiting for the job |
mhp_job | jobs/status or jobs/cancel |
mhp_estop | safety/estop on one device, or every device this session touched |
mhp_run | runs a script against the Lab client; returns stdout only (§3.4) |
mhp_lab | fleet management: scan, add, new, write, validate, list, remove (§9.1) |
resources mhp://<id>/descriptor, mhp://<id>/<path> | descriptor and package files of devices this session has opened |
Every tool ships input_examples. MHP errors surface as MCP tool results with isError: true and the structured mhpError body, so the model sees why a write was refused and can adjust. A refused call inside an mhp_run script returns the same body.
# any harness, scientist's laptop: one command
npx openmhp setup
# Claude Desktop / Claude Code, big lab: nothing loaded until searched
{"mcpServers": {"lab": {"command": "mhp-mcp", "args": ["--directory", "http://directory:18900"]}}}
# small lab: cards indexed at start, connections still lazy
{"mcpServers": {"lab": {"command": "mhp-mcp",
"args": ["thermo=http://bench-pc:18921", "arm=http://arm-pc:18921"]}}}
# remote harnesses: the same bridge over MCP Streamable HTTP
mhp-mcp --directory http://directory:18900 --http 18800 # POST http://host:18800/mcp
The bridge is the lab's MCP server. Any harness that speaks MCP over stdio or HTTP (Claude Code, Codex, OpenClaw, Hermes, Claude Science, Open Science, or a custom agent) connects to it and sees the same eight tools.
10.2 Command line
mhp http://bench:18921 describe
mhp http://bench:18921 read block_temperature lid_closed
mhp http://bench:18921 write target_temperature 95
mhp http://bench:18921 invoke run_protocol '{"steps":[...],"cycles":30}' --wait
mhp http://bench:18921 invoke open_lid --approved # after a human said yes
mhp http://bench:18921 estop "smoke from lid"
10.3 Code files
from openmhp.client import Lab
lab = Lab({"arm": "http://arm-pc:18921", "thermo": "http://bench:18921"})
arm, thermo = lab["arm"], lab["thermo"]
with arm, thermo: # leases on both
arm.write("speed", 31)
arm.wait(arm.invoke("pick_plate", location="deck_A1"))
arm.wait(arm.invoke("place_plate", location="thermocycler"))
result = thermo.wait(thermo.invoke("run_protocol", steps=PCR, cycles=31))
arm.wait(arm.invoke("pick_plate", location="thermocycler"))
11Authoring descriptors
Two paths, same output: a device package (§4). By hand: copy the package template, fill in the frontmatter and limits, write the operating procedure in DEVICE.md. By interview: the agent asks the owner a short set of questions and writes the package.
What is it and when should an agent pick it? Where does it sit? What is heavy, hot, sharp or fragile about it? How do you run it, step by step? What must be true before it runs? What values would you never set? What should always need a human?
The first answer becomes description, the procedure becomes the DEVICE.md body, and the rest become notes, interlocks, limits and approval levels in descriptor.yaml. Existing SOPs and manual excerpts go under references/. The owner reviews the package before it is served.
Packages are versioned folders checked into the lab's repository. Changing a limit is a code review, not a runtime action.
12Error codes
| Code | Name | Meaning |
|---|---|---|
| -32601 | MethodNotFound | unknown method |
| -32602 | InvalidParams | missing or invalid params |
| -32010 | LimitViolation | value outside descriptor limits; data carries the limits |
| -32011 | InterlockOpen | a required interlock signal is falsy; data.interlock names it |
| -32012 | ApprovalRequired | confirm item called without approved: true |
| -32013 | Forbidden | forbid item |
| -32020 | DeviceFault | device in fault; reset required |
| -32021 | DeviceBusy | non-concurrent job running, or reset attempted mid-job |
| -32022 | EStopActive | device in estop; reset required |
| -32030 | NotLeased | another client holds the lease; data.holder names it |
| -32040 | UnknownName | no such signal, setting or action |
| -32041 | JobNotFound | no such job id |
13Device classes
Initial vocabulary. A class is a hint for the agent and a namespace for conventions: a robot_arm SHOULD have a locations map, a microscope SHOULD expose an acquire action returning an image URI. Classes are not enforced by the protocol.
thermocyclerliquid_handlerrobot_armmicroscopeplate_readercentrifugeincubatorhotplatebalancepumpvalvespectrometerlaserstagecnc3d_printerovenpower_supplycamerageneric14Bridging existing ecosystems
MHP is deliberately thin enough to sit on top of the control layers labs already run. The reference implementation ships an adapter for each, built on one mechanism: bindings. A binding pairs an MHP name with a callable into the foreign layer, and BoundDriver turns three lists of bindings into a complete MHP driver with every gate, tier, job and notification inherited.
from openmhp.adapters import BoundDriver, Signal, Setting, Action
dev = BoundDriver(
device={"id": "hotplate-01", "class": "hotplate", "notes": "Fume hood 2."},
signals=[Signal("plate_temperature", read=lambda: plc.read(0x10), unit="degC")],
settings=[Setting("target_temperature", write=lambda v: plc.write(0x20, v), limits={"min": 20, "max": 300})],
actions=[Action("shutdown", run=lambda job, p: plc.write(0x21, 0), approval="confirm")],
estop=lambda: plc.write(0x21, 0))
| Layer | Adapter | Mapping |
|---|---|---|
| SiLA 2 | sila_device(host, port, …) | Property → signal. One-parameter unobservable command → setting. Command → action; observable commands stream progress and accept cancel. Any stop command → estop. Leases replace LockController. |
| PyLabRobot | plr_device(machine, …) | Every public coroutine on a Machine → action with params from its signature. setup() on start, stop() on estop. Coroutines run on a private event loop. |
| MADSci | madsci_node(url, …) | Self-describing: /info actions → actions with params and notes; /state keys → signals; /status busy → ping state; /action plus polling → jobs; /admin/safety_stop → estop. No binding map needed. |
| OPC UA / Modbus | opcua_device(url, …) | Variable node → signal or setting. Method node → action with positional args. Abort method → estop. Modbus uses BoundDriver with two register callables. |
| ROS 2 | ros2_device(node, …) | Topic subscription → signal. Topic publication → setting. Action server → action with feedback → progress and cancel. Trigger service → estop. |
Adapters MUST implement the gates in §7 themselves. Wrapping a layer that lacks limits does not exempt the MHP server from enforcing them. The reference adapters get this for free from BoundDriver, and are tested against injected fakes so the mapping logic is verified without vendor libraries or hardware.
14.1 Agent Skills
MHP ships three Agent Skills so that any skills-capable harness can bring hardware under the protocol and operate it without bespoke prompting. Skills follow the same progressive-disclosure design as the protocol: a harness holds only their names and descriptions until a task matches.
| Skill | Activates when | Does |
|---|---|---|
openmhp-onboard-device | one instrument to connect | interviews the owner, writes descriptor and driver, validates, serves, registers |
openmhp-adapt-fleet | a SiLA 2, PyLabRobot, MADSci, OPC UA or ROS 2 fleet | one adapter file per device, manifest, directory, mhp-mcp config, proof checklist |
openmhp-operate | any task on connected hardware | the find, describe, check, dry-run, act, verify loop; refusal handling; e-stop rules |
15Conformance
| Level | Requirements |
|---|---|
| MHP Core | initialize, ping, device/describe (all three tiers and select), resources/*, signals/read, settings/write with limit enforcement, the error codes in §12. |
| MHP Actions | Core plus actions/invoke, jobs/*, notifications/jobs/*, interlock and approval gates. |
| MHP Safe | Actions plus safety/estop, safety/reset, watchdog, fail-closed state machine, session/* leases. |
| MHP Directory | Server role: initialize with role: "directory", directory/search, directory/get, directory/stats. Indexes cards only. |
Devices that carry stored energy or can move SHOULD NOT be exposed below MHP Safe. All device levels MUST support detail and select on device/describe.
16Not in 0.1
Bulk data transfer (images, spectra) beyond a URI in a job result; in-band authentication; multi-device transactions; descriptor signing; a hosted registry. Each has a natural home in the protocol (a data/ primitive, params.auth, a transaction/ namespace) and will be specified with partner input.
AReference implementation
openmhp is a Python implementation of everything above, with PyYAML as its only dependency: the Driver base class with all six gates and the detail tiers, the device package loader, the live-pinging Directory, stdio and HTTP + SSE transports, the lazy Lab client SDK, the mhp CLI, the eight-tool mhp-mcp bridge over stdio or HTTP, BoundDriver and the five ecosystem adapters, three Agent Skills, two simulated devices, adapter tests against injected fakes, and the 2,000-device scale demo. Writing a new driver is a descriptor plus three hooks.
class MyHotplate(Driver):
descriptor = {...} # §4
def on_read(self, name): return float(self.dev.query("IN_PV_1"))
def on_write(self, name, value): self.dev.write(f"OUT_SP_1 {value}")
def on_invoke(self, job): self.dev.write("STOP")
def on_estop(self): self.dev.write("STOP")
pip install -e .
python examples/pcr_run.py # arm + thermocycler, in-process
python examples/scale_demo.py # 2,000 devices, 1,087 tokens
python tests/test_adapters.py # adapters + live directory, no hardware
mhp serve local:openmhp.devices.sim_thermocycler:SimThermocycler --http 18921
curl localhost:18921/mhp.json
mhp-mcp thermo=http://localhost:18921 arm=local:openmhp.devices.sim_arm:SimArm