hal0 mounts three 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;
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, andbackend_listwrap their bare-list REST
response as{"<key>": [...], "count": N}(_LIST_TOOL_WRAP_KEYin source is the
authoritative list). - pending approval (gated):
{"status": "pending_approval", "approval_id": "...", "detail": "..."}. - error:
{"status": "error", "error": {"code": ..., ...}}— only this full envelope
(anerrordict with a stringcode) is reported asisError: true. A successful
read of a slot whose own lifecyclestatusis"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>.
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.
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:
- 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 asallow,gated,
blocked,unknown_server, orunknown_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. - Manifest resolver (
hal0.mcp.manifest) — fetches and parses manifest JSON from
arbitrary URLs so the dashboard can preview/install third-party MCP servers
(backsGET /api/mcp/resolveandPOST /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.