Agent access to infrastructure services — scope¶
Scoping for the six work items that came out of the pre-alpha readiness
review (September 2026). Each section says what the item is, the design it
should follow given the locked decisions in architecture.md, which files it
touches, how it is tested, and a rough effort at focused hobby pace. The
sequencing table at the end is the recommendation; nothing here is built yet
except where a checkbox says so.
Two blockers from the same review landed ahead of this doc and are not
scoped here: the packaged Alembic config (db/migrate.py; helper db init
works from a wheel) and the scoped MCP probe_host tool
(HOMELAB_HELPER_MCP_PROBE_ALLOW).
Current agent surface, for reference — the MCP server over stdio:
| Service | Adapter | MCP reach today | Writes |
|---|---|---|---|
| Proxmox, K8s, OMV, UniFi, Cloudflare, Argo CD | read-only | run_discovery(<source>) |
none |
| Talos | read-only, talosctl |
none (CLI only) | none |
| SSH hosts | KernelSSHAdapter |
probe_host (scoped) |
none |
| NetBox | read + custom-field/InventoryItem/VM sync | none (CLI only) | CLI netbox sync-* |
| Phase 5 planners | n/a | none (CLI only) | none |
| Home Assistant | none | none | none |
1. Planners as MCP tools (+ Talos in run_discovery)¶
- [x] Landed.
list_workloads,recommend_placement,plan_rebalance,analyze_bottlenecks,analyze_surplus,network_path, andprobe_talos. One deviation from the table below: Talos is its ownprobe_talostool rather than arun_discoverytarget, because it names a host and so gets the same allow-list scoping asprobe_host(thediscover talosCLI verb now sharesengine/talos_probe.pywith it).
Why. Placement, rebalance, bottlenecks, surplus, and network path exist
as helper plan ... / helper bottlenecks verbs but not as tools, so the
recommendation engine — the most useful thing an agent could reach — is
agent-invisible.
Design. One tool per planner, each a thin wrapper over the existing
engine entry point, returning the deterministic report as a dict. The CLI's
--narrate path (Planner agent via LLMRouter) is not mirrored: the MCP
client's own model narrates, and the harness never spends LLM calls on a
tool's behalf.
| Tool | Engine call | Notes |
|---|---|---|
list_workloads(category=None) |
load_workload_library() |
profile summaries |
recommend_placement(workload) |
recommend_placement(session, profile, topology) |
unknown workload → {"error"} |
plan_rebalance() |
plan_rebalance(session) |
three candidate plans + tradeoffs |
analyze_bottlenecks(persist=False) |
analyze_bottlenecks / persist_bottlenecks |
persist writes findings (harness DB only) |
analyze_surplus() |
analyze_surplus(session) |
|
network_path(host_a, host_b, workload=None) |
Topology.path + network_verdict |
topology from HOMELAB_HELPER_NETWORK_TOPOLOGY; missing → {"error"} |
run_discovery("talos", node=...) |
the discover talos sequence |
needs a node name; extend the tool signature with an optional target |
Every report dataclass needs an as_dict() mirroring HostProbeResult.as_dict
(JSON-safe, enums as values). Keep the lookup-miss-returns-error convention.
Files. mcp_server.py; engine/{placement,rebalance,bottlenecks,reconfigure,network_path}.py
(as_dict methods); tests/test_mcp_server.py (roster + one seeded call
per tool, reusing test_cli_plan.py's fixtures); README roster.
Effort. 1–2 days. No new dependencies, no schema change.
2. Home Assistant adapter (read-only)¶
- [x] v1 landed.
adapters/homeassistant.py,engine/hass_import.py,helper discover hass [--persist],run_discovery("hass"), thehasssource inhelper config.DiscoverySource.HOME_ASSISTANTshipped without a migration, following the repo precedent for enum members (SQLite'sSAEnumhas no CHECK constraint); a Postgres deployment needs theALTER TYPEwhen that backend is first exercised. v2 (device registry,device_trackeridentity) is still open.
Why. HA is the most common service in the target audience's labs and the only one on the list with no adapter. It also carries cross-source signal the harness wants: which integrations HA has configured (a second opinion on what exists), and device-tracker entities that map hostnames to IPs.
Design. Same pattern as UniFi/OMV: httpx client, injected for tests with
MockTransport, read-only, health_check(), _owns_client semantics.
- Config:
HOMELAB_HELPER_HASS_URL,HOMELAB_HELPER_HASS_TOKEN(a long-lived access token, secret),HOMELAB_HELPER_HASS_VERIFY_SSL. Register inconfig.SOURCES. - Reads, v1 (REST only):
GET /api/(liveness),GET /api/config(version,location_name,components),GET /api/states(entity states; summarize by domain),GET /api/services(available service domains). - Persisted, v1: a
Service("home-assistant")withattributes= version + component list + entity counts by domain, and an internalServiceEndpointfor the HA URL. Integrations that name another management plane the harness knows (proxmoxve,unifi,mqtt,esphome,zha, …) land in the service's attributes for the stray-config / cross-resolver pass to compare against.DiscoverySourcegainsHOME_ASSISTANT. - v2 (deferred, separate PR): the device registry over the websocket API
(
config/device_registry/list) anddevice_tracker.*entities withipattributes as a client-identity source alongside UniFi clients. - Writes: none. When Phase 6 reaches HA, service calls are a new trust domain
(
automation) rather thancontainers; out of scope here.
DiscoverySource is a SQLAlchemy Enum column: on SQLite adding a member is
code-only, on Postgres it is an ALTER TYPE ... ADD VALUE migration. Ship the
migration either way so the two backends stay aligned.
Files. adapters/homeassistant.py; engine/hass_import.py (persist);
cli/discover.py (discover hass); config.py (SOURCES);
mcp_server.py (run_discovery("hass")); db/enums.py + migration;
tests/test_homeassistant_adapter.py, tests/test_cli_hass.py; README
credential-scope note (a token from a non-admin HA user is sufficient).
Effort. 2–3 days for v1; 2 days for v2.
3. MCP transport stays stdio — document, don't build¶
- [x] Landed. README "Transport and trust" paragraph under the MCP
section;
architecture.mdtrust-boundaries row for the MCP client.
Why. Stdio inherits the operator's shell and .env, which is the right
trust boundary for the test audience: the MCP client is the same person as
the operator. A network transport would put the harness's SSH key and every
source token behind whatever auth the transport has.
Design. No code. A short "Transport and trust" subsection in the README's
MCP section and a line in architecture.md under "Trust boundaries" stating:
stdio only; the server process runs as the operator; client config blocks
must not carry secrets (the server reads .env); remote MCP (streamable
HTTP + bearer auth + TLS) is gated on the HTTP API, which is a stub, and is
not planned before Phase 6 ships.
What remote MCP would need, so the decision is recorded: the FastAPI layer
with token-scoped auth from the architecture doc; the MCP SDK's streamable
HTTP transport mounted on it; per-token tool allowlists (a remote client
should never see probe_host); and the secrets store from item 6.
Effort. One hour of docs.
4. Adapter write surfaces designed against trust cells¶
- [x] Re-baselined and landed in reduced form. Between the original
scoping and this work, Phase 6 PRs B–F merged:
engine/executor.py(Proxmox guest power viavm_power, snapshot capture + rollback, receipts), auto-escalation, elevation windows, the kill switch, and the per-action override. What was still missing and now exists:engine/manifest.py(the pydantic authoring schema; the executor's ownparse_manifeststays as the untrusted-input validator, and a test holds the two in agreement) andtests/test_write_isolation.py(no module outside the executor/rollback pair may name a Proxmox write method — the block-comment contract is now mechanical). - Deliberately not built: the
(domain, action_kind) → callableregistry (one write adapter does not justify it; add it with the second), and the SSHcontainers/restart/single-hostcell (it needs a rollback strategy the orchestrator doesn't have yet, and every new write path is behind the live-fleet validation gate). NetBox custom-field/VM sync keeps its own diff/confirm path, as the backlog already records.
Why. No adapter had a write method when this was scoped; the executor now exists, so the work is keeping every future write path on the same rails: a typed manifest at the authoring boundary, one enforcement point, and a mechanical guard.
Files (landed). engine/manifest.py, engine/executor.py (imports the
shared VM_KIND_DOMAIN/ManifestError), tests/test_manifest.py,
tests/test_write_isolation.py.
Effort (remaining). Registry + SSH restart cell with a rollback strategy: 1–2 weeks, after live-fleet validation.
5. propose_action for agents; never exec¶
- [x] Landed. MCP
propose_action(action_kind, node, vmid, vm_kind, title, description?, blast_radius="single-host", hostnames?)validates throughengine/manifest.py, writes a PENDINGProposalLogwithproposed_by="agent:mcp", and returns the pessimisticdecide()preview plus thehelper exec run <id>next step.list_proposals(outcome?, limit?)andget_proposal(id | prefix)are read-only.trust_status,list_receipts, andpending_actionshad already landed with PR F. The roster test pins the tool set and the forbidden-substring test keepsexec/grant/window/override/rollbackout of the MCP surface. - Dropped: the provenance clamp. The original proposal would have
capped agent-drafted actions at CONFIRM. The executor's tests, landed
with PR B, deliberately run
agent:plannerproposals at AUTONOMOUS once a cell has earned it through clean confirmed runs — the gate is cell trust, not who drafted the manifest, and auto-escalation is the mechanism that makes an agent-drafted action trustworthy over time. A string-prefix clamp would contradict that design, soprovenancestays informational (recorded on every receipt) anddecide()is unchanged.
6. Secrets store¶
- [x] Landed as secret references (
homelab_helper/secrets.py). Every secret-valued variable accepts a literal (unchanged behaviour) or a reference:env:VAR,file:<path>#<key>(plain YAML/JSON,.agevia the operator'sagebinary andHOMELAB_HELPER_AGE_IDENTITY,*.sops.*or asopsmarker viasops -d), orkeyring:<service>/<user>(optional[keyring]extra). Adapters and LLM backends read throughsecret_from_env;helper configshowsset via file|keyring|env;helper config initdocuments the forms.redact()scrubs every resolved value from the MCP surface's error strings. - Not built:
Host.credentials_refis still the literalssh:<user>:<key-path>string (nothing reads it); moving SSH key paths onto references is a separate, small change.op://andvault://stay future schemes. Redaction covers MCP error returns; CLI output prints adapter errors unredacted (they rarely echo a header, but it is a gap).
Why. Every token lived in a plaintext .env. With execution now
possible, write-capable tokens exist, and a plaintext file must be a choice.
Files (landed). secrets.py, config.py (reference in
variable_status), cli/config.py, seven adapters, llm/backends.py,
mcp_server.py (redaction), tests/test_secrets.py, pyproject.toml
(keyring extra).
Sequencing¶
| Order | Item | Blocks | Effort |
|---|---|---|---|
| 1 | 3. stdio-only note — done | nothing | 1 h |
| 2 | 1. planner tools + Talos — done | nothing | 1–2 d |
| 3 | 2. Home Assistant v1 — done | nothing | 2–3 d |
| 4 | 5. read-only half (list_proposals, get_proposal, trust_status) — done |
nothing | 1 d |
| 5 | 6. secrets store — done (references; credentials_ref migration open) |
item 4 merging | 3–5 d |
| 6 | 4. executor + first cell — done via Phase 6 PRs B–F; manifest schema + write-isolation test added; registry + SSH cell open | item 6 | 1–2 w |
| 7 | 5. propose_action — done; provenance clamp dropped (see §5) |
item 4's manifest model | 2 d |
| 8 | 2. Home Assistant v2 | v1 | 2 d |
Items 1–4 in this order are all read-only and can ship to the test audience
as they land. Items 5–7 are the Phase 6 track and stay behind the
live-fleet validation gate in backlog.md.