feat: MCP server (add-on 0.6.4 -> 0.7.0) #1

Merged
marcello merged 5 commits from feat/mcp-server into main 2026-08-24 14:12:45 +00:00
Owner

Porta il server MCP: rotte routes/mcp.py, suite di test pytest, docs/mcp.md; alza la versione add-on da 0.6.4 a 0.7.0.

Nota post-deploy: impostare mcp_enabled: true nelle opzioni add-on e aggiungere il blocco location /mcp nel proxy NGINX (vedi docs/mcp.md).

Migrato da GitHub (marcelloobertisolte-lab/skynet-ha) a Soltea-org. Commit di testa: 0dea5a6.

Porta il server MCP: rotte `routes/mcp.py`, suite di test pytest, `docs/mcp.md`; alza la versione add-on da 0.6.4 a 0.7.0. Nota post-deploy: impostare `mcp_enabled: true` nelle opzioni add-on e aggiungere il blocco `location /mcp` nel proxy NGINX (vedi docs/mcp.md). Migrato da GitHub (marcelloobertisolte-lab/skynet-ha) a Soltea-org. Commit di testa: 0dea5a6.
Groundwork for the in-process MCP server. A dedicated JWT scope keeps the
new surface firewalled the same way `google_access` is: an app `access`
token cannot drive MCP, and an MCP token cannot call /api/v1 or the
Google fulfillment webhook. get_mcp_user mirrors get_google_user and also
rejects pending users.

mcp_enabled defaults to false so nothing changes for existing installs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One install point: the FastAPI app that already serves the Android app and
the Google Home fulfillment now also speaks MCP, so an MCP consumer
(ChatGPT Realtime) gets the same per-user filtered slice of HA.

Three tools — list_entities, get_entity_state, call_entity_service — all
enforced exactly like google_fulfillment.py: the entity_id is intersected
with the caller's EntityPermission allowlist BEFORE any HA hop, and verbs
go through the same _SERVICE_MAP whitelist the app uses. A denied entity
reads the same as a missing one, so there is no existence oracle.

Transport is Streamable HTTP reduced to its stateless core: one JSON-RPC
2.0 message per POST, no session, no SSE (GET answers 405). The dispatcher
is hand-written because the add-on installs from vendored aarch64 wheels
with pip --no-index — the MCP SDK would mean re-vendoring its whole
dependency tree for ~50 lines of protocol. No new dependency.

Every path out of the dispatcher is guarded: an exception escaping into
main.py's catch-all would become an opaque HTTP 500 that no MCP client can
parse, so faults come back as well-formed JSON-RPC errors instead, and
permission refusals as isError tool results the model can act on.

The MCP principal is a synthetic service user (google_subject "svc:<slug>")
minted from the Ingress-only POST /api/admin/service-tokens. A real Google
`sub` is all digits, so that row can never be signed into — and being an
ordinary users row, the existing admin UI already assigns entities to it.
No OAuth dance, no new table, no migration (this codebase has none).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Panel appears only when GET /api/admin/service-tokens answers, i.e. when
mcp_enabled is true — a 404 is the normal "feature off" reply, not an
error worth a toast. The token is displayed once; nothing stores it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
First Python tests in the repo (50 of them), covering what the MCP surface
must never get wrong:

- scope firewall: an app access token, an expired token, an inactive or
  pending user all bounce off /mcp;
- JSON-RPC envelope: parse / invalid-request / method-not-found /
  bad-params / internal errors all come back as JSON-RPC with HTTP 200,
  never as the catch-all 500;
- permissions: every deny path also asserts Home Assistant was never
  called, so the allowlist intersection provably happens before the hop;
- service tokens: Ingress-gated, idempotent on the row, revocable by
  deactivating the user.

Run with `cd addon/homeassistantshare && python -m pytest`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs/mcp.md walks through enabling the option, minting the principal,
assigning entities, the tool/error contract and the security model.

The proxy section is the part that matters operationally: /api/admin/* is
gated on nothing but the presence of X-Ingress-Path, so every public
location that reaches port 8099 MUST blank it — including the existing
/api/v1/ block, which was missing the line in the README.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Soltea-org/skynet-ha!1
No description provided.