# MCP tools reference

**URL:** <https://forum.hal0.dev/t/mcp-tools-reference/54>\
**Category:** Reference\
**Created:** [August 22, 2026, 6:50am UTC](https://forum.hal0.dev/t/mcp-tools-reference/54 "2026-08-22T06:50:25Z")\
**Posts on this page:** 1\
**Page:** 1

<div class="post-metadata">

**Author:** ![system](https://forum.hal0.dev/user_avatar/forum.hal0.dev/system/32/37_2.png) [@system](https://forum.hal0.dev/u/system)\
**Post date:** [August 22, 2026, 6:50am UTC](https://forum.hal0.dev/t/mcp-tools-reference/54/1 "2026-08-22T06:50:25Z")

</div>

hal0 mounts three [FastMCP](https://github.com/jlowin/fastmcp) (Streamable-HTTP) servers  
on the main FastAPI app: `hal0-admin` at `/mcp/admin`, `hal0-memory` at  
`/mcp/memory` and `hal0-agents` at `/mcp/agents`. `hal0-agents` is for remote coding agents  
admitted with [`hal0 bridge`](https://forum.hal0.dev/t/hal0-cli-reference-memory-runner-images-profiles-mcp-auth-more/116#hal0-bridge--admit-remote-coding-agents);  
it is mounted whenever `[bridge].enabled` is on (the default), and is 404 while it is off.

Every Hermes profile is wired to both servers. hal0 writes `hal0-admin` and `hal0-memory` into each named profile’s `config.yaml` as `hermes__<profile>`, with shared memory by default (`hal0: {memory_private: true}` in a profile’s `config.yaml` makes its memory facts private; mental models and directives are bank-level in the unified bank and are not covered) and the box bearer, at provisioning and on every hal0-api reconcile sweep (120 s). A profile opts out with `hal0: {wire_mcp: false}`. The default profile is not converged periodically: after adding the opt-in to its `config.yaml` by hand, run `sudo hal0 agent reprovision hermes --repair` (or the next `hal0 update` does it). `hal0 doctor` reports the state on the `hermes_profiles_mcp` row, and on `hermes_default_memory` and `hermes_brain_memory` for the default and `hal0-brain` profiles.

## Per-slot tune through MCP

`slot_edit` accepts a sparse `tune` object: `ctx_size`, `enable_thinking`,  
`reasoning_effort`, `reasoning_budget`, `vision`, `speculation`. Null removes  
an override; null for the whole table clears it. REST guards also apply here.  
`slot_list` retains `tune_effective` (including `tune_unavailable`) in its compact roster. Profile create/edit  
tools also accept reasoning effort and integer budget.

## `hal0-admin` (`/mcp/admin`)

Every admin tool is registered through one generic factory, so every tool’s call  
signature is `{"args": {...per-tool schema...}}`. The advertised JSON schema per tool  
merges path args (required strings, derived from the REST route template) with a  
hand-authored set of named body/query field hints, and always allows additional  
properties to pass through undeclared.

Tools are autogenerated from the live FastAPI route table and re-keyed onto stable  
names — the tool-name-to-route mapping in source (`TOOL_NAME_ALIASES`) is the ground  
truth if this table ever drifts.

Return shape for every admin tool is `dict[str, Any]`:

- **success (autonomous)**: the raw REST JSON response — `slot_list`, `provider_list`,  
`upstream_list`, `runner_image_pulls`, and `backend_list` wrap their bare-list REST  
response as `{"<key>": [...], "count": N}` (`_LIST_TOOL_WRAP_KEY` in source is the  
authoritative list).
- **pending approval (gated)**: `{"status": "pending_approval", "approval_id": "...", "detail": "..."}`.
- **error** : `{"status": "error", "error": {"code": ..., ...}}` — only this full envelope  
(an `error` dict with a string `code`) is reported as `isError: true`. A successful  
read of a slot whose own lifecycle `status` is `"error"` returns normally with its  
payload.

Path arguments (the `{placeholders}` in a tool’s REST route) are checked before a gated  
call is queued and before an autonomous call runs, so you are never shown an approval card  
for an argument that would be refused. A path argument must be a string, or an integer  
below 2^53 (JSON and the URL spell those the same way). Anything else (`null`, a boolean, a  
float, a list, an object), an empty value, `.`, `..`, a value containing `/`, and a value  
containing a control character or an invisible formatting character (zero-width space, a  
bidi override, a byte-order mark) comes back as  
`{"status": "error", "error": {"code": "mcp.invalid_arg", ...}}`. A missing one is  
`mcp.missing_arg`. Every other value is percent-encoded as one URL segment, so `?`, `#`,  
`%`, spaces, `:` and non-ASCII reach the server exactly as the card shows them. The check  
runs again when an approved call executes. A tool that needs a Hugging Face `org/name`  
(such as `model_pull`) takes it as a body field (`hf_repo`), not in the path.

The one exception to the `/` rule is a path argument whose route declares it `{name:path}`:  
today only `comfyui_file_delete`’s `relpath`. It may contain `/` between valid segments  
(`styles/a.safetensors`); each segment is held to the rules above on its own, so a leading,  
trailing or doubled `/` and a `.` or `..` segment are refused, and each segment is  
percent-encoded separately.

Tools fall into three gating tiers. Treat the frozensets in source  
(`AUTONOMOUS_READ_TOOLS`, `AUTONOMOUS_WRITE_TOOLS`, `GATED_TOOLS`) as ground truth if  
this table drifts.

Full argument schemas are not reproduced per tool here — the REST route each  
tool passes through documents its own body/query shape; hand-authored  
overrides for the handful that don’t map 1:1 onto their route live in  
`TOOL_PARAM_HINTS` in source.

### Slot, model and profile tools

The profile supplies the base tune. `slot_create` and `slot_edit` take a profile,  
model and placement, plus sparse `tune` overrides. Legacy root-level tuning  
keys remain refused with `slot.tuning_key_denied`. `slot_edit` can set context  
through `tune.ctx_size`; free flags stay on the profile. `model_edit`,  
`model_register` and `model_validate` take file facts as top-level fields:  
`chat_template`, `tokenizer_repo`, `mtp` (MTP heads) and `jinja`. A `defaults`  
object is `422 model.defaults_removed`; model tuning is `422 model.tune_key_removed`.  
`profile_create`, `profile_update` and `profile_generate` carry flags, context,  
thinking, reasoning effort/budget, vision, speculation and runner. A model’s  
drafter is the file fact `draft` on `model_edit` (path or null to unpair);  
`model_pull` takes `draft_filename` and `draft_repo`, and `model_inspect` returns `drafts`.

`model_list` and `capability_list` return the whole payload when called bare, which is  
large enough on a populated box that clients truncate it. Narrow them instead.  
`model_list` takes `installed` (true keeps models on this host, false the  
upstream-advertised ones), `type`, `capability` (a model must have every one listed),  
`device` (`gpu-rocm`, `gpu-vulkan`, `cpu`, `npu`; any match), `q` (substring of the id or  
name), `fields` (the row keys to return; `id` always stays), and `limit` (1-500) with  
`offset`. The list parameters take a comma-separated string, and the reply gains `total`,  
the number of matches before paging. `capability_list` takes `section` (any of `backends`,  
`catalogs`, `selections`; an unknown name is `400 capability.unknown_section`) and  
`summary=true`, which cuts each catalog entry to its `id`, `display_name` and `downloaded`.  
With no parameters both replies are unchanged.

`slot_status`, `slot_by_name` and `slot_by_id` return the same enriched payload  
for the same local slot, including config and container fields and effective  
context and tuning where applicable. `slot_by_id` resolves the stable id to  
the slot’s current name; an unknown id returns `404 slot.not_found`.

### Autonomous — read (no approval)

| Tool | REST route |
| --- | --- |
| `slot_list` | `GET /api/slots` |
| `slot_status` | `GET /api/slots/{name}` |
| `slot_metrics` | `GET /api/slots/metrics` |
| `slot_capacity` | `GET /api/slots/capacity` |
| `slot_by_name` | `GET /api/slots/by-name/{name}` |
| `slot_by_id` | `GET /api/slots/by-id/{slot_id}` |
| `slot_resolved` | `GET /api/slots/{name}/resolved` |
| `slot_state` | `GET /api/slots/{name}/state` |
| `model_list` | `GET /api/models` |
| `model_show` | `GET /api/models/{model_id}` |
| `model_scan_preview` | `POST /api/models/scan/preview` |
| `model_catalogue` | `GET /api/models/catalogue` |
| `model_update_check` | `GET /api/models/updates/check` |
| `model_pulls_list` | `GET /api/models/pulls` |
| `model_pull_status` | `GET /api/models/{model_id}/pull/status` |
| `model_inspect` | `POST /api/models/inspect` |
| `model_inspect_draft` | `POST /api/models/inspect-draft` |
| `model_store` | `GET /api/settings/models/store` |
| `hardware_probe` | `GET /api/stats/hardware` |
| `system_info` | `GET /api/system-info` |
| `port_list` | `GET /api/ports` |
| `capability_list` | `GET /api/capabilities` |
| `provider_list` | `GET /api/providers` |
| `version_info` | `GET /api/status` |
| `upstream_list` | `GET /api/upstreams` |
| `stack_list` | `GET /api/stacks` |
| `stack_status` | `GET /api/stacks/{slug}` |
| `profile_list` | `GET /api/profiles` |
| `profile_status` | `GET /api/profiles/{name}` |
| `profile_export` | `POST /api/profiles/{name}/export` |
| `settings_get` | `GET /api/settings` |
| `settings_schema` | `GET /api/settings/schema` |
| `settings_apply_plan` | `GET /api/settings/apply-plan` |
| `bench_runs` | `GET /api/benchmarks/runs` |
| `bench_run_status` | `GET /api/benchmarks/runs/{run_id}` |
| `bench_queue` | `GET /api/benchmarks/queue` |
| `gpu_target_version` | in-process host probe, no REST hop |
| `npu_status` | in-process host probe |
| `env_report` | in-process host probe |
| `model_store_probe` | in-process host probe — arg: `path` (required) |
| `upstream_get` | `GET /api/upstreams/{name}` |
| `upstream_test` | `POST /api/upstreams/{name}/test` |
| `service_list` | `GET /api/services` |
| `service_health` | `GET /api/services/health` |
| `comfyui_status` | `GET /api/comfyui/status` |
| `comfyui_workflows` | `GET /api/comfyui/workflows` |
| `comfyui_models` | `GET /api/comfyui/models` — the image-model catalog with install state and each model’s latest install job |
| `comfyui_jobs` | `GET /api/comfyui/jobs` |
| `comfyui_files` | `GET /api/comfyui/files` |
| `updater_state` | `GET /api/updates/state` |
| `updater_check` | `GET /api/updates/check` |
| `updater_channel_get` | `GET /api/updates/channel` |
| `updater_slot_drift` | `GET /api/updates/slot-drift` |
| `updater_job_status` | `GET /api/updates/status/{job_id}` |
| `component_status` | `GET /api/updates/components` |
| `update_check` | `GET /api/updates/check` |
| `doctor_report` | `GET /api/doctor` |
| `health_system` | `GET /api/health/system` |
| `feature_list` | `GET /api/features` |
| `metrics_snapshot` | `GET /api/metrics` |
| `hardware_snapshot` | `GET /api/hardware` |
| `slot_stats` | `GET /api/stats/slots` |
| `stats_overview` | `GET /api/stats` |
| `request_metrics` | `GET /api/stats/requests` |
| `system_stats` | `GET /api/system-stats` |
| `power_stats` | `GET /api/stats/power` |
| `throughput_history` | `GET /api/stats/throughput/history` |
| `npu_occupancy` | `GET /api/npu/occupancy` |
| `npu_swap_status` | `GET /api/npu/swap-status` |
| `model_health_check` | `GET /api/models/health` |
| `slot_config` | `GET /api/slots/{name}/config` |
| `slot_voices` | `GET /api/slots/{name}/voices` |
| `flm_model_list` | `GET /api/slots/flm/models` |
| `slot_pull_status` | `GET /api/slots/{name}/pull/status` |
| `model_validate` | `POST /api/models/validate` |
| `hf_search` | `GET /api/hf/search` |
| `model_config_read` | `GET /api/config/models` |
| `bench_roster` | `GET /api/benchmarks/roster` |
| `bench_plan` | `GET /api/benchmarks/plan` |
| `bench_cells` | `GET /api/benchmarks/cells` |
| `bench_history` | `GET /api/benchmarks/history` |
| `bench_evals` | `GET /api/benchmarks/evals` |
| `journal_snapshot` | `GET /api/journal` |
| `activity_list` | `GET /api/activity` |
| `activity_export` | `GET /api/activity/export` |
| `approval_list` | `GET /api/agent/approvals` |
| `runner_image_list` | `GET /api/runner-images` |
| `runner_image_downloaded` | `GET /api/runner-images/downloaded` |
| `runner_image_pulls` | `GET /api/runner-images/pulls/list` |
| `backend_list` | `GET /api/backends` |
| `backend_detail` | `GET /api/backends/{backend_id}` |
| `mcp_server_list` | `GET /api/mcp/servers` |
| `mcp_client_list` | `GET /api/mcp/clients` |
| `mcp_catalog` | `GET /api/mcp/catalog` |
| `profile_generate` | `POST /api/profiles/generate` |
| `memory_search`, `memory_list`, `memory_recall`, `memory_reflect`, `memory_history`, `memory_mental_model_list`, `memory_mental_model_get`, `memory_directive_list`, `memory_directive_get`, `memory_operation_list`, `memory_operation_get`, `memory_tags_list`, `memory_bank_stats` | delegate to `hal0-memory` — see below |

### Autonomous — write (no approval; reversible, low blast radius)

| Tool | REST route |
| --- | --- |
| `model_swap` | `POST /api/slots/{name}/swap` |
| `model_assign` | `PUT /api/slots/{name}/config` |
| `model_edit` | `PUT /api/models/{model_id}` |
| `model_scan` | `POST /api/models/scan` |
| `model_pull_cancel` | `POST /api/models/{model_id}/pull/cancel` |
| `model_pull_delete` | `DELETE /api/models/pulls/{model_id}` |
| `model_set_default` | `POST /api/models/{model_id}/default` |
| `slot_load` | `POST /api/slots/{name}/load` |
| `slot_unload` | `POST /api/slots/{name}/unload` |
| `slot_edit` | `PUT /api/slots/{name}/config` |
| `settings_reload` | `POST /api/settings/reload` |
| `hardware_reprobe` | `POST /api/hardware/probe` |
| `comfyui_job_cancel` | `POST /api/comfyui/jobs/{model_id}/cancel` |
| `memory_add`, `memory_curate`, `memory_mental_model_create`, `memory_mental_model_update`, `memory_mental_model_refresh`, `memory_directive_create`, `memory_directive_update`, `memory_operation_cancel`, `memory_operation_retry`, `memory_bank_consolidate` | delegate to `hal0-memory` — see below |
| `memory_delete` | delegates to `hal0-memory` — gates itself when deleting more than one id, or a foreign/list dataset |

### Gated (destructive — require operator approval)

| Tool | REST route |
| --- | --- |
| `model_pull` | `POST /api/models/{model_id}/pull` |
| `model_delete` | `DELETE /api/models/{model_id}` |
| `model_register` | `POST /api/models` |
| `model_add` | `POST /api/models/add-from-path` |
| `model_store_set` | `POST /api/settings/models/store` |
| `model_store_migrate` | `POST /api/settings/models/store/migrate` |
| `model_update` | `POST /api/models/{model_id}/update` |
| `slot_create` | `POST /api/slots` |
| `slot_delete` | `DELETE /api/slots/{name}` |
| `slot_restart` | `POST /api/slots/{name}/restart` |
| `slot_rename` | `POST /api/slots/{name}/rename` |
| `component_converge` | `POST /api/updates/components/{component_id}/converge` |
| `capability_set` | `POST /api/capabilities/{slot}/{child}` |
| `config_write` | `PUT /api/settings` |
| `provider_credential_write` | `POST /api/providers/{name}/credentials` |
| `stack_create` | `POST /api/stacks` |
| `stack_update` | `PUT /api/stacks/{slug}` |
| `stack_apply` | `POST /api/stacks/{slug}/apply` |
| `stack_import` | `POST /api/stacks/import` |
| `stack_export` | `POST /api/stacks/{slug}/export` |
| `stack_snapshot` | `POST /api/stacks/snapshot` |
| `stack_delete` | `DELETE /api/stacks/{slug}` |
| `profile_create` | `POST /api/profiles` |
| `profile_update` | `PUT /api/profiles/{name}` |
| `profile_import` | `POST /api/profiles/import` |
| `profile_delete` | `DELETE /api/profiles/{name}` |
| `bench_enqueue` | `POST /api/benchmarks/queue` |
| `bench_control` | `POST /api/benchmarks/control` |
| `bench_queue_delete` | `DELETE /api/benchmarks/queue/{item_id}` |
| `logs_tail` | `GET /api/logs` — output is secret-redacted |
| `slot_logs` | `GET /api/slots/{name}/logs` — output is secret-redacted |
| `upstream_create` | `POST /api/upstreams` |
| `upstream_update` | `PATCH /api/upstreams/{name}` |
| `upstream_delete` | `DELETE /api/upstreams/{name}` |
| `service_action` | `POST /api/services/{service_id}/action` |
| `comfyui_switchover` | `POST /api/comfyui/switchover` |
| `comfyui_pin` | `POST /api/comfyui/pin` |
| `comfyui_model_install` | `POST /api/comfyui/models/{model_id}/install` |
| `comfyui_model_remove` | `DELETE /api/comfyui/models/{model_id}` — deletes the model’s weight files and workflow; files other installed models load stay unless `include_shared=true`, which deletes them too and reports those models as affected |
| `comfyui_file_delete` | `DELETE /api/comfyui/files/{subdir}/{relpath}` — deletes one weight file no catalog model loads; `relpath` is the row’s path as listed, with a plain `/` between folders (`styles/a.safetensors`); an empty, `.` or `..` part is refused before approval, and the route refuses catalog-owned files, symlinks, hidden or `..` path parts and anything that is not a weight file |
| `comfyui_render_cancel` | `POST /api/comfyui/render/cancel` |
| `comfyui_restart` | `POST /api/comfyui/restart` |
| `comfyui_workflow_launch` | `POST /api/comfyui/workflows/{name}/launch` |
| `comfyui_logs` | `GET /api/comfyui/logs` — output is secret-redacted |
| `slot_pull_image` | `POST /api/slots/{name}/pull` |
| `model_config_write` | `PUT /api/config/models` |
| `bench_run` | `POST /api/benchmarks/run` |
| `runner_image_sync` | `POST /api/runner-images/sync` |
| `npu_backend_load` | `POST /api/backends/npu/load` |
| `npu_backend_unload` | `POST /api/backends/npu/unload` |
| `mcp_server_install` | `POST /api/mcp/install` |
| `mcp_server_uninstall` | `DELETE /api/mcp/{server_id}` |
| `mcp_server_config_write` | `PATCH /api/mcp/{server_id}/config` |
| `mcp_server_action` | `POST /api/mcp/{server_id}/{action}` |
| `mcp_server_logs` | `GET /api/mcp/{server_id}/logs` |
| `memory_mental_model_delete`, `memory_directive_delete` | delegate to `hal0-memory` — destructive, always gated |

### Deliberately excluded

`board_crud`, `brain_chat`, `updater_apply`, `auth_rotate`, `auth_me`,  
`provider_credential_read`, `agent_sessions`, `hermes_command_approvals` are explicitly  
excluded from the MCP surface (each with a documented reason in source) — not gaps,  
policy.

## `hal0-memory` (`/mcp/memory`)

Unlike `hal0-admin`, each tool here registers with a real typed signature, so  
`tools/list` returns real per-field schemas, not an opaque `args` blob.

hal0-memory registers 26 tools total. Gating tier is classified the same way as  
the admin tools above — via `AUTONOMOUS_READ_TOOLS` / `AUTONOMOUS_WRITE_TOOLS`  
/ `GATED_TOOLS` in `hal0.mcp.admin` (the standalone mount additionally  
hard-gates its two delete tools through its own local list, checked first —  
same verdict, belt and braces).

**Autonomous — read**

| Tool | Required | Returns |
| --- | --- | --- |
| `memory_search` | `query` | `{results: [{id, text, score, timestamp, dataset, tags, source, metadata}]}` |
| `memory_list` | none | `{items: [...], next_cursor}` |
| `memory_recall` | `query` | token-budgeted, consolidated memory — `{results: [...]}` |
| `memory_reflect` | `query` | LLM-backed synthesis grounded in recalled facts; `{status: "unsupported"}` on engines without this capability |
| `memory_history` | `id` | curation history for one memory unit |
| `memory_mental_model_list` | none | a bank’s mental models (stored `memory_reflect` answers kept refreshed). Metadata only (id, name, tags, staleness) unless `detail` is `content` or `full`; read one model’s content with `memory_mental_model_get` |
| `memory_mental_model_get` | `id` | one mental model |
| `memory_directive_list` | none | a bank’s directives (standing instructions injected into prompts) |
| `memory_directive_get` | `id` | one directive |
| `memory_operation_list` | none | async operations (retain, consolidation, refresh, …) |
| `memory_operation_get` | `id` | one async operation’s status — the poll target for `memory_add`’s `operation_id`, `memory_mental_model_refresh`, `memory_bank_consolidate`. A completed retain reports its extraction yield: `facts_extracted`, plus `nothing_learned` and a `notice` when zero facts made it into memory. A zero-yield retain of an already-extracted document instead carries `document_fact_count` and a “content was already known” `notice` |
| `memory_tags_list` | none | tags in use in a bank, with usage counts |
| `memory_bank_stats` | none | node/link/observation/operation counts for a bank |

**Autonomous — write** (reversible, low blast radius)

If `memory_add` cannot connect to the memory engine (including a connect  
timeout), it returns `status: "error"` with code `memory.engine_unavailable`,  
a message asking the caller to retry shortly, and `error.details.retry_after_s: 5`.  
An error response from the engine is not reclassified as engine unavailability.

| Tool | Required | Returns |
| --- | --- | --- |
| `memory_add` | `text` (non-empty) | `{id, timestamp, operation_id?}` |
| `memory_delete` | `ids` (non-empty list) | `{deleted: int}`; bulk deletes (\>1 id) or a foreign/list dataset instead return `{status: "pending_approval", approval_id, detail}` |
| `memory_curate` | `id`, plus at least one field to change (`text`, `context`, `occurred_start`, `occurred_end`, `fact_type`, `entities`, or `state`) | edits a memory unit’s text/context/occurred-range/fact\_type/entities, or soft-invalidates it via `state` — the non-destructive correction path; `id` alone returns `mcp.memory_schema` |
| `memory_mental_model_create` | `name`, `source_query` | `{mental_model_id, operation_id}` — initial content generation runs as an async refresh |
| `memory_mental_model_update` | `id` | updates name/source\_query/max\_tokens/tags/trigger |
| `memory_mental_model_refresh` | `id` | triggers a mental model refresh (async) — `{operation_id, ...}` |
| `memory_directive_create` | `name`, `content` | creates a directive |
| `memory_directive_update` | `id` | updates name/content/priority/is\_active/tags |
| `memory_operation_cancel` | `id` | aborts pending/processing work |
| `memory_operation_retry` | `id` | re-queues a failed operation |
| `memory_bank_consolidate` | none | triggers the engine’s world/experience → observation consolidation pass early — `{operation_id, deduplicated}` |

**Gated** (destructive — require operator approval)

| Tool | Required | Returns |
| --- | --- | --- |
| `memory_mental_model_delete` | `id` | deletes a mental model |
| `memory_directive_delete` | `id` | deletes a directive |

Validation errors return `{"status": "error", "error": {"code": "mcp.memory_schema", "detail": ...}}`.

`source` on `memory_add` is server-injected from the caller’s identity — a client  
cannot set it directly; attempting to pass it raises.

Namespace rule: `dataset` defaults to `"shared"`; the `X-hal0-Private` header promotes  
it to `private:<client_id>`.

> [@](#):
>
> **Note**
> 
> `hal0-admin`’s `memory_*` tools and the standalone `/mcp/memory` mount share the exact  
> same handlers — the admin server calls them in-process via a `memory_dispatcher`  
> closure, with no HTTP hop.

## `hal0-agents` (`/mcp/agents`)

The agent bridge’s sixteen tools (nine to run and follow a Hermes run, seven to teach a Hermes profile) are on their own page: [MCP tools: hal0-agents](https://forum.hal0.dev/t/mcp-tools-hal0-agents-agent-bridge/129).

## MCP server management REST API

Router: `src/hal0/api/routes/mcp.py`, mounted at `/api/mcp`. This manages _installed  
3rd-party MCP server registrations_ for the dashboard — distinct from the tool-calling  
protocol surface above. Tool invocation only happens over the actual MCP protocol at  
`/mcp/admin`, `/mcp/memory` and `/mcp/agents`; this REST surface cannot invoke a tool.

| Method | Path | Request | Response |
| --- | --- | --- | --- |
| GET | `/api/mcp/servers` | — | `{servers: [...], count}` — bundled servers introspected live via `list_tools()`/`list_resources()`/`list_prompts()`; installed servers come from the registry; `state` is a 1.5 s TCP connect to the record’s `url` for `streamable-http`/`sse` (`reachable`, `unreachable`, or `disabled` when reachable but `enabled = false`) and always `stopped` for `stdio`. |
| GET | `/api/mcp/clients` | — | `{clients: [{id, name, role, host, since, connected_to}], count}` — derived from the audit log. |
| GET | `/api/mcp/catalog` | — | `{items: [...], categories}` — static installable-MCP catalog (puppeteer, sqlite, gdrive, slack, …). |
| GET | `/api/mcp/stream` | — | SSE stream of `mcp.tool.*` events (audit-log tail, polled every 2s). |
| GET | `/api/mcp/{server_id}/logs` | query `limit` (1-500, default 100) | `{server, events: [...], count}`. |
| GET | `/api/mcp/resolve` | query `url` (1-2048 chars) | A `ResolvedManifest`: `{id, name, description, spec, transport, tools, resources, prompts, env_required, source_kind, source_url?, author, verified}`. Supports `oci://`, `npm:`/`npx:`, `uvx:`/`uv:`, `git+https://`, and generic `https://.../manifest.json` specs, with an SSRF guard blocking loopback/private/link-local/CGNAT targets. |
| POST | `/api/mcp/install` | `{"url": "..."}` or `{"manifest": {...}}` | 201 `{installed: <InstalledServer>, hermes_sync}`; 409 on a bundled-id collision or already-installed. |
| DELETE | `/api/mcp/{server_id}` | — | `{uninstalled: server_id, hermes_sync}`; 409 `mcp.bundled` if `server_id` is a bundled server. |
| PATCH | `/api/mcp/{server_id}/config` | `{"env"?: {str: str}, "enabled"?: bool}` | `{server: <InstalledServer>}`, plus `hermes_sync` when the body set `enabled`; 409 if bundled. |
| POST | `/api/mcp/{server_id}/test` | — | `{server_id, probe, verdicts, agent_exposure}` — MCP `tools/list` handshake against the record’s own `url`, each tool classified against the record’s own `[tools]` policy (`blocked` / `gated` / `allow`, else `unknown_tool`) while `hermes`/`brain` exposure is refused (#2415), and against the Hermes seed once it is available (`unknown_server` when the server isn’t exposed or no seed exists). `agent_exposure` is `{available: false, code, reason}` while `hermes`/`brain` exposure is refused (#2358, until #2303). 404 `mcp.not_found`; 501 `mcp.supervisor_unavailable` for `stdio`. |
| PATCH | `/api/mcp/{server_id}/tools` | `{"allow": [...], "gated": [...], "blocked": [...]}` (replaces the whole policy) | `{server, hermes_sync}`; 400 `mcp.tools_invalid` (e.g. a tool on two lists); 409 `mcp.bundled`. |
| PATCH | `/api/mcp/{server_id}/exposure` | any of `{"hermes", "brain", "openwebui", "opencode"}: bool` (merged into the current flags) | `{server, hermes_sync}`; 501 `mcp.exposure_unsupported` for `openwebui`/`opencode` set `true`; 409 `mcp.exposure_needs_supervisor` for `hermes`/`brain` on a `stdio` record; 409 `mcp.exposure_policy_unenforced` for turning `hermes`/`brain` on for any installed server until #2303 (turning them off still works); 400 `mcp.exposure_invalid`; 409 `mcp.bundled`. |
| POST | `/api/mcp/{server_id}/{action}` | — | **501 stub** (`McpNotImplemented`, code `mcp.supervisor_unavailable`) — start/stop/restart are not implemented yet. |

`hermes_sync` is the join report `{hermes, brain, errors, probe}`; the join never fails  
the request — failures land in `errors`.

`InstalledServer` fields: `id, name, description, spec, transport, command, args, url, tools, resources, prompts, env, secrets, tool_policy, exposure, enabled, source_url, author, verified, installed_at`. On disk (`/etc/hal0/mcp-servers/<id>.toml`),  
`tool_policy` is serialised as the `[tools]` table and the integer `tools` count as  
`tools_count`.

## hal0 as an MCP client

hal0 acts as an MCP client in two limited ways — it does not run a general-purpose MCP  
client session itself:

1. **Policy layer for bundled agents** (`hal0.agents.mcp_client.AgentMCPClient`) —  
transport-agnostic. It loads `/etc/hal0/agents/<name>.toml`, resolves bearer tokens  
from env or a systemd credential, and classifies a tool call as `allow`, `gated`,  
`blocked`, `unknown_server`, or `unknown_tool`, with workspace-escape path  
sandboxing for filesystem-style MCP args. The actual wire-level MCP client that  
connects to _other_ MCP servers is owned by the separate Hermes agent runtime, not  
this repo.
2. **Manifest resolver** (`hal0.mcp.manifest`) — fetches and parses manifest JSON from  
arbitrary URLs so the dashboard can preview/install third-party MCP servers  
(backs `GET /api/mcp/resolve` and `POST /api/mcp/install`). This is manifest  
fetching only, with an SSRF guard blocking loopback/private/link-local/CGNAT  
targets — not live MCP protocol tool-calling.
