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.

Version 2026-09-09 (0.2)Wire format JSON-RPC 2.0Transports stdio · HTTP + SSEReference impl openmhp (Python, zero deps)
  1. 1Why MHP
  2. 2Architecture
  3. 3Scale: one device among thousands
  4. 4The device package
  5. 5Primitives
  6. 6Lifecycle
  7. 7Safety model
  8. 8Notifications
  9. 9Transports and discovery
  10. 10Three control surfaces
  11. 11Authoring descriptors
  12. 12Error codes
  13. 13Device classes
  14. 14Bridging existing ecosystems
  15. 15Conformance
  16. 16Not in 0.1
  17. 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:

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 exposesMHP exposes
toolsactions, which return jobs the device runs on its own
resourcessignals to read and settings to write
promptsa 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

  1. Safety is enforced by the driver, never by the agent. A wrong value from an agent is refused, not obeyed.
  2. The device package is the whole manual. Tacit knowledge from paper manuals and people's heads is written down where the agent reads it.
  3. Five primitives, no more. Describe, signals, settings, actions, safety.
  4. Bridge, don't replace. SiLA 2, PyLabRobot, MADSci, OPC UA and ROS 2 devices join through thin adapters.
  5. The agent's context is protected. Two thousand devices cost the same handful of tokens as two (§3).

2Architecture

Host · any agent harness Claude Code, Codex, OpenClaw, Hermes, Claude Science, Open Science, your own SDK agent MHP client MHP client one per device JSON-RPC · stdio / HTTP JSON-RPC · stdio / HTTP MHP server · driver thermocycler-01, serial link gates, jobs, descriptor MHP server · adapter arm-01, wraps a SiLA 2 feature same gates, same descriptor instrument instrument
One server per device. The host is any agent harness; it sees no difference between a native driver and an adapter over another control layer.
RoleResponsibility
HostAny 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.
ClientOne 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.
DeviceThe physical thing. A single instrument, or a coordinated cell exposed as one logical device.
DirectoryA 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 mechanismReported effectMHP equivalent
Tool search, deferred loading85% fewer tokens; Opus 4 accuracy 49% → 74%Descriptor detail tiers + directory/search (§3.1, §3.2)
Programmatic tool calling37% fewer tokens on research tasksmhp_run scripts against the Lab client (§3.4)
Tool use examples72% → 90% on complex parametersexamples 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.

TierCostContents
card~40 tokensLevel 1: the DEVICE.md frontmatter, live state, and the names of every signal, setting and action
summaryunder 1k tokensLevel 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
fullunboundedLevel 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.

MethodParamsReturns
directory/searchquery (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/getidone card + target, state pinged now
directory/statscounts 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 directoryValue
Ping timeout per device500 ms
Candidates pinged when a state filter is given4 × limit
A ping is reused for2 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.

ApproachTokens in agent context
Every descriptor loaded up front937,845
mhp_find (5 cards) + summary of the chosen device + full spec of the 2 items used1,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
LevelContentServed byLoaded when
1frontmatter: id, class, make, model, location, tags, descriptiondevice/describe {detail: "card"}, directory/searchsearch results; about 40 tokens per device
2DEVICE.md body: how to operate it, what to check, what never to dodevice/describe {detail: "summary"} with a slim capability tablethe agent picks this device; under 1k tokens
3descriptor items, references, scripts, driverdevice/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

FieldRule
device.idMUST be unique within a host's set of devices. class is an open string; §13 lists the initial vocabulary.
device.descriptionFrontmatter. 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.
notesAny 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, unitnumber | 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.
approvalauto: 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.
interlocksBoolean signals that MUST read truthy at the moment of the write or invoke; otherwise InterlockOpen.
durationshort actions finish within seconds and hosts MAY block on them. long actions run as jobs; the host SHOULD poll or subscribe.
location, tags, examplesOptional but SHOULD be set. Location and tags are what a directory filters on; examples on an action is an array of parameter objects.
unknown keysPermitted 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.

PrimitiveMethodsDirection
Lifecycleinitialize, pingclient → server
Describedevice/describe {detail, select}client → server
Resourcesresources/list, resources/read {path}client → server
Directorydirectory/search, directory/get, directory/statsclient → directory server
Signalssignals/list, signals/read, signals/subscribeclient → server
Settingssettings/list, settings/writeclient → server
Actionsactions/list, actions/invoke, jobs/status, jobs/list, jobs/cancelclient → server
Safetysafety/limits, safety/estop, safety/resetclient → server
Sessionsession/acquire, session/releaseclient → server
Notificationsnotifications/signals/update, notifications/settings/changed, notifications/jobs/*, notifications/safety/*server → client
Elicitationelicitation/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:

  1. StateEStopActive if in estop, DeviceFault if in fault.
  2. ApprovalForbidden for forbid items; ApprovalRequired for confirm items sent without approved: true.
  3. InterlocksEach named signal is read live at that moment. Any falsy value: InterlockOpen.
  4. LimitsNumeric range or enum: LimitViolation, with the limits in data.
  5. LeaseAnother client holds an unexpired lease: NotLeased.
  6. 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.

idlebusyfaultestop

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.

MethodParamsWhen
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:

opEffect
scanmDNS browse plus HTTP probe of localhost and any hosts given; returns devices not yet in the fleet
addregister a target (http://host:port, a package folder, or a package id under ~/.openmhp/devices); fetches the card; immediately searchable
newcreate a package skeleton for an instrument that does not speak MHP yet
writewrite files into that package; paths may not escape it
validaterun the package validator
list, remove, homemembership 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 toolMHP call
mhp_finddirectory/search, or a scan of cards in a small lab
mhp_describedevice/describe {detail, select}, default summary; with resource= it calls resources/read
mhp_readsignals/read
mhp_writesettings/write
mhp_invokeactions/invoke, optionally waiting for the job
mhp_jobjobs/status or jobs/cancel
mhp_estopsafety/estop on one device, or every device this session touched
mhp_runruns a script against the Lab client; returns stdout only (§3.4)
mhp_labfleet 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

CodeNameMeaning
-32601MethodNotFoundunknown method
-32602InvalidParamsmissing or invalid params
-32010LimitViolationvalue outside descriptor limits; data carries the limits
-32011InterlockOpena required interlock signal is falsy; data.interlock names it
-32012ApprovalRequiredconfirm item called without approved: true
-32013Forbiddenforbid item
-32020DeviceFaultdevice in fault; reset required
-32021DeviceBusynon-concurrent job running, or reset attempted mid-job
-32022EStopActivedevice in estop; reset required
-32030NotLeasedanother client holds the lease; data.holder names it
-32040UnknownNameno such signal, setting or action
-32041JobNotFoundno 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_supplycamerageneric

14Bridging 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))
LayerAdapterMapping
SiLA 2sila_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.
PyLabRobotplr_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.
MADScimadsci_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 / Modbusopcua_device(url, …)Variable node → signal or setting. Method node → action with positional args. Abort method → estop. Modbus uses BoundDriver with two register callables.
ROS 2ros2_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.

SkillActivates whenDoes
openmhp-onboard-deviceone instrument to connectinterviews the owner, writes descriptor and driver, validates, serves, registers
openmhp-adapt-fleeta SiLA 2, PyLabRobot, MADSci, OPC UA or ROS 2 fleetone adapter file per device, manifest, directory, mhp-mcp config, proof checklist
openmhp-operateany task on connected hardwarethe find, describe, check, dry-run, act, verify loop; refusal handling; e-stop rules

15Conformance

LevelRequirements
MHP Coreinitialize, ping, device/describe (all three tiers and select), resources/*, signals/read, settings/write with limit enforcement, the error codes in §12.
MHP ActionsCore plus actions/invoke, jobs/*, notifications/jobs/*, interlock and approval gates.
MHP SafeActions plus safety/estop, safety/reset, watchdog, fail-closed state machine, session/* leases.
MHP DirectoryServer 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