Skip to content

MCP server

openhal mcp is a stdio MCP server. An agent's MCP client starts the server as a subprocess, and the server forwards every tool call to the hub's local API with an agent token. The server makes no safety decisions, because the hub's kernel checks every write and action, and the tool returns the kernel's result unchanged.

Before you start

  1. Run openhal init once to create ~/.openhal, the human token and a simulated sample device.
  2. Start the hub with openhald and check it with openhal status.
  3. Create an agent token for the client.
openhal token create --agent --scope control_low --name claude

The token is printed once. Scopes are read (reads only), control_low (low hazard devices) and control_medium (low and medium hazard devices). Every write and action on a high hazard device waits for a person, whatever the agent token's scope.

Give each client its own token, so the audit log shows which client did what and you can revoke one client's token without the others.

Token

The server reads its token from the OPENHAL_TOKEN environment variable, or from a file given with --token-file. Because the hub treats a human's command as already confirmed, the server never reads ~/.openhal/human.token and refuses to run with a human token, so an agent always needs its own agent token.

A token file keeps the token out of client config files that may be synced or shared.

openhal token create --agent --scope control_low --name claude | tail -1 > ~/.openhal/claude.token
chmod 600 ~/.openhal/claude.token

Claude Desktop

Edit claude_desktop_config.json, which is in ~/Library/Application Support/Claude/ on macOS and in %APPDATA%\Claude\ on Windows.

{
  "mcpServers": {
    "openhal": {
      "command": "openhal",
      "args": ["mcp"],
      "env": { "OPENHAL_TOKEN": "ohal_..." }
    }
  }
}

Because Claude Desktop does not use your shell's PATH, put the full path from which openhal in command when Claude Desktop can't find openhal, and restart Claude Desktop after editing the file.

Claude Code

claude mcp add openhal --scope user -e OPENHAL_TOKEN=ohal_... -- openhal mcp

You can also pass a token file.

claude mcp add openhal --scope user -- openhal mcp --token-file ~/.openhal/claude.token

Run claude mcp list to check that the OpenHAL server connects.

Cursor

Add the server to ~/.cursor/mcp.json for every project, or .cursor/mcp.json for one.

{
  "mcpServers": {
    "openhal": {
      "command": "openhal",
      "args": ["mcp", "--token-file", "/Users/you/.openhal/cursor.token"]
    }
  }
}

Any other MCP client

SettingValue
Transportstdio
Commandopenhal mcp (or the full path to openhal)
TokenOPENHAL_TOKEN environment variable, or --token-file <path>
Hub addressOPENHAL_URL or --url, default from api.host and api.port in config.yaml
Options--no-start to never start openhald

When the hub is not running

On start, openhal mcp checks the hub. If the hub doesn't respond, openhal mcp starts openhald in the background once (output goes to ~/.openhal/openhald.log) and waits up to 8 seconds. With --no-start, or if the start fails, the server still runs, and every tool returns an error that says the hub is not running and how to start the hub. Because stdout carries the MCP protocol, messages for people go to stderr and never to stdout.

Tools

The list and input schemas live in openhal/mcp/tools.py. Every tool has a description written for models, typed inputs and typed outputs. Values carry their unit, and blocked decisions carry a stable code, a message and the limit that blocked them.

Numbers go in with their unit apart (ADR 0016), so write takes value 60 and unit %, and action arguments and session limits take {"value": 2, "unit": "s"}. A number sent as one string such as "60 %" comes back blocked with UNIT_INVALID and the shape to use.

ToolWhat it does
hub_statusHub state, protocol version, stop lock, connected devices, pending confirmations
list_devicesEvery device with hazard class, writable and connected
describe_deviceChannels with units and limits, actions, interlocks, watchdog, safe state, latest values, notes
readRead one channel now. A camera frame comes back as an image
read_manyRead up to 100 channels. Images come without their pixels
writeRequest a channel change, which the kernel checks. Returns done, blocked or pending
actionRequest an action, which the kernel checks
dry_runCheck one write or action without running it
plan_checkCheck a list of steps in order without running any
stop_allEmergency stop. Every device goes to its safe state
tighten_limitsNarrow min, max or rate for this session. Never widens
get_confirmation_statusStatus of requests that wait for a person
list_routinesRoutines with state, devices, hazard and last run
get_routineOne routine with source and history
draft_routineStore a routine. Every new routine starts disabled
run_routine_dryDry run a routine step by step
enable_routineRequest to enable a routine. Refused unless the owner allowed agents to enable low hazard routines
disable_routineDisable a routine
get_audit_logAudit entries, newest first
list_driversInstalled drivers and bridges
discover_devicesLook for devices with read-only identify commands
list_unknown_devicesDevices the last discovery could not match
start_driver_writerStart a driver writer session for an unknown device
get_writer_sessionSession state, sources, draft and problems
propose_device_fileSend a draft device file. Every limit is stored as suggested
request_limits_interviewThe questions a person must answer about each limit
get_test_planThe staged write test plan for a new device
device_file_schemaThe device file JSON Schema, for drafting

Prompts

PromptArgumentsWhat it guides
set_up_devicedescription, unknown_id (both optional)Discovery, the driver writer, a draft device file and the limits interview
write_routinegoalReading the devices, drafting a YAML routine, plan checks and a dry run, with enabling left to a person

What needs a person

Only a person with the human token can do the following, in the CLI or the dashboard.

  • Approve or reject a request that waits for confirmation (openhal confirm approve)
  • Unlock after a stop (openhal unlock)
  • Loosen a limit or confirm a device file (openhal file confirm, the limits interview)
  • Enable a routine, unless routines.agent_enable_low_hazard is on and every device the routine touches is low hazard
  • Sign a routine approval for a high hazard device

Device text is data

Text that comes from devices, device files, sketches, manuals and routine sources reaches the model inside <device-data source="..."> blocks with a line saying the text is data. The hub wraps device notes and routine sources, and the MCP server also wraps string readings and driver error messages before returning them. Nothing inside these blocks can change a limit or approve anything, whatever the text says.

A camera frame from read is an MCP image block. Because a frame can show text too, a short <device-data> block that says the image came from a device and is data comes before the image. The structured result carries the reading with mimeType, bytes, width and height, without the pixels. A frame over 2 MB is refused with DRIVER_ERROR and a reason that names the camera settings to lower.