hal0 talks Model Context Protocol in
both directions: it can install and run MCP servers for its bundled
agent to use, and it exposes its own agents, memory and admin surfaces as
MCP servers for any external client.
Install an MCP server
hal0 mcp install oci://ghcr.io/example/some-mcp-server:latest
hal0 mcp install npm:@example/some-mcp-server
hal0 mcp install uvx:some-mcp-server
hal0 mcp install git+https://github.com/example/some-mcp-server
hal0 mcp install https://some-mcp-server.example.com/mcp
hal0 mcp install (alias: hal0 mcp add) accepts an OCI image ref, an
npm/uvx package specifier, a git URL, or a streamable-http/sse manifest
URL. Each installed server persists as its own file,
/etc/hal0/mcp-servers/<id>.toml, mode 0600. A freshly installed
server has zero callable tools and zero exposure — see the next two
sections to actually let an agent use it.
hal0 mcp list # bundled + installed
hal0 mcp list --json
hal0 mcp status <id> # tools, env, connected clients
hal0 mcp uninstall <id> # alias: hal0 mcp remove <id>
hal0 mcp uninstall <id> --force
hal0 mcp catalog list # curated, known-good server catalogue
hal0 mcp catalog refresh
Credentials
Non-secret settings (a base URL, a workspace path) go in a server’s
plain env block. Anything credential-shaped is a reference, not a
literal — point it at a name already in hal0’s own secrets store
(the dashboard’s Settings → Secrets panel, or PUT /api/secrets/{name}),
never at a value typed straight into the server’s TOML file:
# /etc/hal0/mcp-servers/github.toml
[env]
GITHUB_HOST = "github.com"
[secrets]
GITHUB_TOKEN = "GITHUB_MCP_TOKEN" # name of a key already in /etc/hal0/api.env
env edits go through PATCH /api/mcp/{id}/config. Nothing writes the
[secrets] table yet — edit it in the record file directly. Each [env] and
[secrets] entry is sent to the server as a request header of that name, with
a secret resolved from hal0-api’s live environment, so set the secret through
Settings → Secrets (which updates the running process) rather than by editing
api.env by hand. A hand edit is picked up by the next registry mutation —
re-run hal0 mcp expose <id> --hermes to push it into Hermes’s config now.
hal0 mcp restart <id> does not help: start/stop/restart answer
501 mcp.supervisor_unavailable until the process supervisor ships.
Probe an installed server
hal0 mcp test github
Connects to the server directly (its own url + resolved env/secrets
headers — never hal0’s own credentials), lists its advertised tools, and
shows each one’s verdict: allow, gated, blocked, unknown_tool, or
unknown_server. While exposure to Hermes is unavailable (see below), the
verdicts come from the server’s own [tools] policy (blocked, then
gated, then allow, else unknown_tool), so you can check a policy edit
before any agent can reach the server, and the output ends with the reason
exposure is refused. Once exposure is available again, verdicts are read
from the Hermes seed at /etc/hal0/agents/hermes.toml, which only carries
servers that are enabled and exposed to Hermes (unknown_server otherwise). A stdio server
answers 501 mcp.supervisor_unavailable.
Set the tool policy
A newly installed server can connect, but every one of its tools is
unreachable until you say otherwise — the same default-deny posture
hal0’s bundled agents use:
hal0 mcp allow github search_repositories # autonomous calls
hal0 mcp gate github create_pull_request # each call enqueues an approval
hal0 mcp block github delete_repository # hard-rejected at the client
A tool moves between tiers, never sits on two at once — hal0 mcp allow
on a currently-gated tool promotes it and drops the gated entry
automatically. Once the server can be exposed to Hermes again, re-run
hal0 mcp test <id> to confirm a verdict changed.
Expose to an agent
Installing and setting a tool policy is not enough on its own — a server
only reaches an agent once it is exposed:
hal0 mcp expose github --hermes # join into Hermes's own config
hal0 mcp expose github --no-hermes # withdraw it
hal0 mcp expose github --hermes --brain # also join the hal0-brain profile
Exposing re-runs the join immediately (an idempotent recompute, not a
one-shot patch) and re-probes the server; the probe result is in the REST
response’s hermes_sync.probe (the CLI prints only the new flags), so run
hal0 mcp test <id> to see a bad URL at expose time rather than at the
agent’s first turn. openwebui/opencode are
reserved flags with no join yet — setting either returns
501 mcp.exposure_unsupported. A stdio-transport server (most
npm/uvx packages) has no supervisor yet either — --hermes/--brain on
one of those returns 409 mcp.exposure_needs_supervisor until that
follow-up ships. Until the proxy mount (#2303), every installed server
returns 409 mcp.exposure_policy_unenforced (see above); withdrawing
exposure always works.
hal0’s own bundled servers
hal0 ships three MCP servers, mounted on the API as Streamable-HTTP
sub-apps, for any external client (an IDE, Hermes, another agent) to
connect to directly:
/mcp/agents—hal0-agents, the agent bridge: delegate work to the
Hermes agent on the box, and teach it memory and skills. Reachable only with
a bridge key; see Delegate work to the Hermes agent./mcp/memory— long-term memory tools (search, add, recall, list,
delete). Only mounted when the memory subsystem is initialised./mcp/admin— operate hal0: slots, models, capabilities, profiles,
stacks, config, hardware probes. Itsmemory_*tools route in-process
to the memory server.
Connect an external MCP client
Give each remote agent (Claude Code on a workstation, Codex, a CI job) a
bridge key of its own, instead of a copy of the box’s admin or client key.
One command on the hal0 box mints the key and prints what to paste on the
other machine; the key opens only the servers you name, is bound to the
agent’s name, and can be revoked on its own.
-
On the hal0 box, add the client. Its name is lower-case letters, digits,
_and-, up to 32 characters:hal0 bridge add workstation-claude --note "Claude Code on my workstation"The default scopes are
agentsandmemory. Add--scope adminfor the
admin tools (see the caution below), or repeat--scopeto choose the set. -
hal0 prints the key once and one
claude mcp addline for each
server the key opens. It keeps only the key’s sha256, so a key you did not
save is replaced withhal0 bridge rotate. The lines name
[network].public_urlwhen it is set, else the box’s LAN address. -
On the remote machine, paste the lines:
claude mcp add --transport http hal0-agents http://<host>:8080/mcp/agents/mcp --header "Authorization: Bearer <key>" claude mcp add --transport http hal0-memory http://<host>:8080/mcp/memory/mcp --header "Authorization: Bearer <key>" -
Check the key from there.
/api/bridge/meis the one REST route a bridge
key can call:curl -s http://<host>:8080/api/bridge/me -H "Authorization: Bearer <key>"
hal0’s MCP host allowlist already holds this box’s own LAN IP, hostname and
.local name. If the first request answers 421 Invalid Host (a client that
reaches the box by a proxy domain, say), see
Widen the host and origin allowlists.
What the key opens depends on its scopes. agents is always held.
| Scope | Server | What the key does there |
|---|---|---|
agents |
/mcp/agents (hal0-agents) |
Delegate work to the Hermes agent and read its results. A client sees only its own runs. |
memory |
/mcp/memory (hal0-memory) |
Search and write memory. The writer is the client named in the key: X-hal0-Agent is ignored, so X-hal0-Private: 1 lands in private:<client> and nowhere else. |
admin |
/mcp/admin (hal0-admin) |
The admin tools. Calls reach hal0 as hal0’s own service identity and are audited as bridge:<client>; gated tools still wait in the approvals inbox for a person. |
A bridge key is refused on every other non-OPEN route of /api/* and
/v1/*, /api/bridge/me apart; OPEN routes such as GET /api/health and
GET /v1/models answer it as they answer anyone. Manage clients with:
hal0 bridge list # clients, scopes, last seen, revoked
hal0 bridge scopes workstation-claude agents memory admin # replace the scope set
hal0 bridge rotate workstation-claude # new key; the old one stops at once
hal0 bridge revoke workstation-claude # ends the key; the name stays reserved
See hal0 bridge
for every flag and ADR-0043 for
the design.
Connect with the box keys (fallback)
The bridge above is the recommended route: a key per client, bound to its name
and revocable on its own. The box’s own keys still work on /mcp/memory and
/mcp/admin. They are one key shared by every client that holds it, cannot be
revoked for one client without rotating them for all, and carry whatever
identity the client’s X-hal0-Agent header claims. This section and the ones
after it, through the workstation example, cover that route.
Point your MCP client at the Streamable-HTTP endpoint on the hal0 host — the
mount root itself 405s (no redirect); the working path always ends in /mcp:
http://localhost:8080/mcp/admin/mcp
http://localhost:8080/mcp/memory/mcp
Authenticate
Both mounts are token-gated whenever auth is armed on the box:
| Mount | Tier | Satisfied by |
|---|---|---|
/mcp/admin |
ADMIN | HAL0_ADMIN_KEY only |
/mcp/memory |
CLIENT | HAL0_ADMIN_KEY or HAL0_CLIENT_KEY |
Send the key as a bearer header on every request:
Authorization: Bearer <key>
Both keys live in /etc/hal0/api.env on the host (owner-only, mode
0600) — read them out-of-band; hal0 auth status reports only whether a
key is configured, never its value.
TOKEN=$(sudo grep '^HAL0_ADMIN_KEY=' /etc/hal0/api.env | cut -d= -f2)
curl -s -X POST http://localhost:8080/mcp/admin/mcp \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-H "X-hal0-Agent: my-agent" \
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}' | jq '.result.tools | length'
A bare 401 with no body means the box requires auth and the request
above needs a valid Authorization header.
Rotate a key:
hal0 auth rotate admin # or: hal0 auth rotate client
A rotation is not live-propagated into an already-saved client config —
update each client’s stored Authorization header by hand. hal0’s own
bundled Hermes is the one exception: it re-reads the current key on the
next hal0 agent reprovision hermes --repair.
A rotation does end connections that were using the old key: open WebSockets
close with code 4401 and open SSE streams end with an event: revoked frame,
so a long-lived MCP or event client must reconnect with the new key. See
Revoked streams.
Identify with X-hal0-Agent
Caller identity flows on the X-hal0-Agent request header. The value
must match ^[a-zA-Z0-9_-]{1,64}$. It stamps the audit trail and powers
the private:<agent> memory namespace.
X-hal0-Agent: my-agent
An absent or malformed header falls back to anonymous. Add
X-hal0-Private: 1 to opt a memory client into its private namespace —
writes then land in private:<agent> instead of the default shared
dataset (or the unified bank, if [memory].unified_bank is on — see
Enable memory).
Widen the host and origin allowlists
Each mounted server applies DNS-rebinding protection. The allowlist starts
with the names this box answers to: loopback, every detected LAN IP, the
hostname, its .local name and the [network].public_url host, each bare and
with the API port, so a client on another machine reaches the box by any of
them with no setup. Any other Host gets a bare 421 Invalid Host. The
set is read when hal0-api starts; a changed LAN IP or hostname needs a restart.
Two environment knobs on hal0-api add to it:
HAL0_MCP_ALLOWED_HOSTS— comma-separatedhost/host:port/
host:*values added to that default. The single value*
disables DNS-rebinding protection entirely.HAL0_MCP_ALLOWED_ORIGINS— comma-separated browser origins, derived
automatically from each added host when unset.
HAL0_MCP_ALLOWED_HOSTS=hal0.local:8080,hal0.local:*
See Edit configuration for where these env keys
live.
Remote client (workstation Hermes/Claude)
Connecting from a workstation to a hal0 box on the LAN — rather than a
client running on the box itself — needs three things: the box’s
hostname/IP widened into the allowlist (see Widen the host and origin
allowlists, above), the mount URL pointed at the box instead of
127.0.0.1, and — if auth is armed (see Authenticate, above) — the
bearer header.
A workstation Hermes’ config.yaml (the same mcp_servers.* shape hal0 agent reprovision hermes renders for the box’s own bundled agent):
mcp_servers:
hal0-admin:
type: http
url: http://<host>:8080/mcp/admin/mcp
timeout: 60
headers:
X-hal0-Agent: my-workstation-hermes
Authorization: Bearer <key> # omit entirely when auth is off
hal0-memory:
type: http
url: http://<host>:8080/mcp/memory/mcp
timeout: 30
headers:
X-hal0-Agent: my-workstation-hermes
X-hal0-Private: "1"
Authorization: Bearer <key> # omit entirely when auth is off
A workstation Claude Code (~/.claude/settings.json) uses the same
headers shape — see the hal0-admin / hal0-memory example in
/etc/hal0/MCP-CLIENTS.md on the box (swap 127.0.0.1 for <host>).
Replace <host> with the hal0 box’s LAN hostname or IP, and <key> with
the value read from /etc/hal0/api.env on the hal0 box — never commit
it into a workstation dotfile checked into git. HAL0_ADMIN_KEY satisfies
both mounts; a client-tier HAL0_CLIENT_KEY only satisfies /mcp/memory
(see the tier table in Authenticate, above).
Memory tools (/mcp/memory)
Dashboard tab showing available MCP tools and their approval status.
| Tool | Effect | Notes |
|---|---|---|
memory_add |
write | text (required), dataset, tags, metadata, document_id. Reuse document_id to upsert one logical document. |
memory_search |
read | query (required), limit (1–200), dataset, tags, before, after. |
memory_recall |
read | Token-budgeted, consolidated recall — preferred over search. query, max_tokens (1–32768), types. |
memory_list |
read | Paginate: dataset, cursor, limit. |
memory_delete |
delete | ids (non-empty list), optional dataset. |
Admin tools (/mcp/admin)
Admin tools are classified into three tiers. Read-only and low-blast-radius
writes run autonomously; destructive or wide-reaching tools are
gated — they enqueue for owner approval and return
{"status": "pending_approval", "approval_id": "..."} instead of
executing.
Autonomous — read: slot_list, slot_status, model_list,
hardware_probe, capability_list, provider_list, version_info,
stack_list, stack_status, profile_list, profile_status,
profile_export, gpu_target_version, npu_status, env_report,
model_store_probe.
Autonomous — write: model_swap, memory_add, memory_search,
memory_list, and memory_delete for a single id. A bulk memory_delete
routes to the gated tier at call time.
Gated — always require approval: model_pull, model_delete,
slot_create, slot_delete, slot_restart, capability_set,
config_write, provider_credential_write, stack_apply,
stack_import, stack_delete, profile_create, profile_update,
profile_import, profile_delete, and
logs_tail. slot_create and config_write go through the same slot-write checks as every other path: a tuning key on the slot is refused with slot.tuning_key_denied, and a LLAMA_ARG_* variable in [server].env with slot.llama_env_denied. Tunes belong on a profile; see Edit configuration.
The approval flow
-
The server enqueues the call and returns
{"status": "pending_approval", "approval_id": "..."}. -
The owner reviews and approves (or denies) the request from the
dashboard’s inbox, or on the box withhal0 agent approvals listand
sudo hal0 agent approvals approve/deny— the queued call carries
the tool name and arguments so the approver sees exactly what will run.
An API key cannot decide. -
On approval, the tool executes with the approved arguments and the
real result is recorded; every gated call is written to the audit log.
See also
- Enable memory — what the memory subsystem powers.
- Run agents — the approvals queue in context.
- Edit configuration — where per-server env secrets and the profile flag screen live.
- Security — the LAN-open posture, MCP allowlist, and origin gate.
