This commit is contained in:
Javis committed 2026-10-04 15:31:44 +02:00
1 parent 0246cbb6ba
commit 0ca4b4bf7a
89 files changed
+7042 -2480

No files matched your search

+25 -16
View File
@@ -6,16 +6,21 @@ It talks to Homey's local HTTP Web API (`/api/manager/...`) using a Personal Acc
## Features
Tools:
Tools (read-only ones carry `readOnlyHint`; zone/home-wide and flow tools are marked `destructiveHint`):
- `ping` — verify connectivity / return Homey system info
- `list_devices(zone_id?)` — devices with current capability values
- `get_device(device_id)` — full device object
- `list_zones` — rooms / areas
- `set_capability(device_id, capability, value)` — set `onoff`, `dim`, `target_temperature`, …
- `list_flows` — all flows
- `trigger_flow(flow_id)` — start a flow
- `list_recent_events(limit, kind?)` — recent realtime events from the in-memory buffer
- Connectivity: `ping`
- Devices: `list_devices(zone_id?)`, `find_devices(query, zone?)`, `get_device(device_id)`
- Zones: `list_zones`
- Status views: `get_zone_status(zone)`, `get_house_status(class_filter?)`, `get_temperatures`
- Capabilities: `get_capability`, `set_capability`, `set_light`, `set_capabilities_bulk`
- Zone / home control: `control_zone(zone, capability, value)`, `control_all(capability, value, device_class?)`
- Flows: `list_flows`, `find_flows(query)`, `trigger_flow(flow_id)`
- Logic variables: `list_variables`, `set_variable`
- Notifications: `send_notification(title, excerpt?)`
- Events: `list_recent_events(limit, kind?)` — realtime events from the in-memory buffer
Zone names resolve exact match → prefix → substring, so `Wohnzimmer` never picks
`Wohnzimmer Ecke`. IDs are URL-encoded before they reach Homey's API.
Resources:
@@ -104,11 +109,13 @@ The remote client connects to `http://<this-machine-ip>:8080/mcp`
URL may use `http://<this-machine-ip>:8080/`; MCP traffic at `/` is forwarded
to `/mcp`, while an ordinary browser GET returns service discovery.
**Important for MCP transport over LAN:** The MCP SDK validates that clients
send the correct `Host` header. If you bind to `0.0.0.0`, you **must** set
`HOMEY_MCP_ADVERTISED_HOST` to your actual server IP (e.g., `10.10.10.50`).
If you instead bind to a specific IP, omit `HOMEY_MCP_ADVERTISED_HOST` and it
will default to the bind address.
**Important for MCP transport over LAN:** requests are only accepted when their
`Host` header is `HOMEY_MCP_ADVERTISED_HOST`, `localhost` or `127.0.0.1`
(otherwise `421 Misdirected Request`). This blocks DNS-rebinding attacks, where a
web page in any browser on your LAN rebinds its domain to this server. If you
bind to `0.0.0.0`, set `HOMEY_MCP_ADVERTISED_HOST` to the IP clients use; add
further names (e.g. a DNS name) to `HOMEY_MCP_ALLOWED_HOSTS` (comma-separated,
`*` disables the check). Without either, the check is off and a warning is logged.
`HOMEY_MCP_AUTH_MODE=none` is the compatible mode for OAuth-negotiating MCP
clients such as Odysseus, because this server is not an OAuth authorization
@@ -119,7 +126,9 @@ dynamic registration are not provided. Generate a token with
`python -c "import secrets; print(secrets.token_urlsafe(32))"`.
When `HOMEY_MCP_AUTH_TOKEN` is set, the OpenAPI REST routes always require it,
regardless of the MCP auth mode.
regardless of the MCP auth mode. In `bearer` mode every MCP route requires it
too (`/mcp`, the root alias, and `/sse` + `/messages/` for the SSE transport);
only `GET /` (discovery) and `/openapi.json` stay public.
This is independent of Javis's own connection: Javis keeps talking to
homey-mcp over stdio via `mcp_servers.json` as before — run the LAN instance
@@ -179,7 +188,7 @@ If `homey-mcp` is not on PATH, use the full venv path, e.g. `C:\\local\\Code\\PR
- Local API only (no cloud OAuth).
- LAN MCP transport authentication is controlled by `HOMEY_MCP_AUTH_MODE`.
Never expose `none` mode beyond a trusted, firewall-restricted LAN.
- Realtime events are best-effort — if the Socket.IO connection fails the REST tools still work. Set `HOMEY_REALTIME=false` to disable.
- Realtime events are best-effort — if the Socket.IO connection fails the REST tools still work. Set `HOMEY_REALTIME=false` to disable. The server performs Homey's `handshakeClient` and subscribes to `homey:manager:devices` plus each `homey:device:<id>` (capability changes).
- No admin operations (zone/user/app management) are exposed.
- Capability values are typed as `bool | int | float | str`; the model picks based on the capability (`onoff` → bool, `dim` → 0..1 float, etc.).
+5 -4
View File
@@ -23,7 +23,8 @@ Required fields:
Optional for LAN mode:
- `HOMEY_MCP_AUTH_MODE` - `none` for trusted-LAN compatibility, or `bearer`
- `HOMEY_MCP_AUTH_TOKEN` - Protects REST and, in `bearer` mode, MCP requests
- `HOMEY_MCP_ADVERTISED_HOST` - Set this to your server's LAN IP (e.g., `10.10.10.50`). Required if using `HOMEY_MCP_HOST=0.0.0.0`.
- `HOMEY_MCP_ADVERTISED_HOST` - Set this to your server's LAN IP (e.g., `10.10.10.50`). Required if using `HOMEY_MCP_HOST=0.0.0.0`: only requests whose `Host` header is this name, `localhost` or `127.0.0.1` are accepted (DNS-rebinding protection).
- `HOMEY_MCP_ALLOWED_HOSTS` - Extra comma-separated host names clients may use (e.g. a DNS name); `*` disables the check.
### 3. Start the server
@@ -69,8 +70,8 @@ HOMEY_MCP_TRANSPORT=streamable-http HOMEY_MCP_HOST=0.0.0.0 HOMEY_MCP_PORT=8080 \
Install the service file for automatic startup:
```bash
# Copy service file
sudo cp homey-mcp.service /etc/systemd/system/
# Copy service file (it is a template: one instance per user)
sudo cp homey-mcp.service /etc/systemd/system/homey-mcp@.service
# Reload systemd
sudo systemctl daemon-reload
@@ -95,7 +96,7 @@ To connect Odysseus as an MCP client, use the streamable-http transport:
Authentication: none
```
If you see a "421 Misdirected Request" error, your `HOMEY_MCP_ADVERTISED_HOST` is not set correctly; it must match the IP that Odysseus uses to connect.
If you see a "421 Misdirected Request" error, the `Host` the client uses is not allowed: set `HOMEY_MCP_ADVERTISED_HOST` to the IP Odysseus connects to, or add the name to `HOMEY_MCP_ALLOWED_HOSTS`.
Use `HOMEY_MCP_AUTH_MODE=none` only on a trusted, firewall-restricted LAN.
This avoids an OAuth discovery flow, which homey-mcp does not implement. If
+6 -4
View File
@@ -1,3 +1,5 @@
# Template unit: install as /etc/systemd/system/homey-mcp@.service and enable
# homey-mcp@<user>.service. (%h would be /root here: specifiers ignore User=.)
[Unit]
Description=Homey MCP Server (LAN mode for Odysseus)
After=network.target
@@ -6,12 +8,12 @@ Wants=network.target
[Service]
Type=simple
User=%i
WorkingDirectory=%h/homey-mcp
ExecStart=%h/homey-mcp/run-lan.sh
WorkingDirectory=/home/%i/homey-mcp
ExecStart=/home/%i/homey-mcp/run-lan.sh
Environment=HOMEY_MCP_TRANSPORT=streamable-http
Environment=HOMEY_MCP_HOST=0.0.0.0
Environment=HOMEY_MCP_PORT=8080
EnvironmentFile=%h/homey-mcp/.env
EnvironmentFile=/home/%i/homey-mcp/.env
Restart=on-failure
RestartSec=5
StandardOutput=journal
@@ -20,7 +22,7 @@ StandardError=journal
# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=%h/homey-mcp
ReadWritePaths=/home/%i/homey-mcp
ProtectHome=read-only
[Install]
+2 -1
View File
@@ -20,7 +20,8 @@ classifiers = [
"Operating System :: OS Independent",
]
dependencies = [
"mcp[cli]>=1.2",
# >=1.13: ToolAnnotations / transport security; 2.x removed mcp.server.fastmcp
"mcp[cli]>=1.13,<2",
"httpx>=0.27",
"python-socketio[asyncio_client]>=5.11",
"pydantic>=2.7",
+1 -1
View File
@@ -79,7 +79,7 @@ echo "2. For LAN mode (recommended for Odysseus): ./run-lan.sh"
echo "3. For local stdio mode: ./run.sh"
echo ""
echo "For persistent operation, install the systemd service:"
echo " sudo cp homey-mcp.service /etc/systemd/system/"
echo " sudo cp homey-mcp.service /etc/systemd/system/homey-mcp@.service"
echo " sudo systemctl daemon-reload"
echo " sudo systemctl enable --now homey-mcp@$(whoami).service"
echo ""
+95 -21
View File
@@ -2,10 +2,12 @@
from __future__ import annotations
import asyncio
import logging
import time
from collections import deque
from typing import Any
from urllib.parse import quote
import httpx
@@ -18,6 +20,24 @@ from .config import Settings
log = logging.getLogger(__name__)
# Cap per-device realtime subscriptions on very large installations.
_MAX_DEVICE_SUBSCRIPTIONS = 500
def _seg(value: Any) -> str:
"""One URL path segment: IDs come from the LLM and must not add path parts."""
return quote(str(value), safe="")
def event_entry(uri: str, event: str, data: Any) -> dict[str, Any]:
"""Compact buffer entry for one realtime event (device objects are large)."""
entry: dict[str, Any] = {"ts": time.time(), "kind": event, "data": data}
if uri.startswith("homey:device:"):
entry["device_id"] = uri.split(":", 2)[2]
if event.startswith("device.") and isinstance(data, dict):
entry["data"] = {k: data.get(k) for k in ("id", "name", "zone", "available")}
return entry
class HomeyClient:
"""Thin async wrapper around Homey's local HTTP + Socket.IO APIs."""
@@ -35,10 +55,14 @@ class HomeyClient:
self._events: deque[dict[str, Any]] = deque(maxlen=settings.event_buffer)
self._sio: Any | None = None
self._sio_connected: bool = False
self._namespace: str | None = None
self._session_task: asyncio.Task | None = None
# ---- lifecycle --------------------------------------------------------
async def aclose(self) -> None:
if self._session_task is not None:
self._session_task.cancel()
try:
if self._sio is not None and self._sio_connected:
await self._sio.disconnect()
@@ -76,13 +100,13 @@ class HomeyClient:
# ---- REST: devices / zones / flows -----------------------------------
async def get_system_info(self) -> dict[str, Any]:
return await self._get("/api/manager/system/info")
return await self._get("/api/manager/system/")
async def list_devices(self) -> dict[str, Any]:
return await self._get("/api/manager/devices/device")
async def get_device(self, device_id: str) -> dict[str, Any]:
return await self._get(f"/api/manager/devices/device/{device_id}")
return await self._get(f"/api/manager/devices/device/{_seg(device_id)}")
async def list_zones(self) -> dict[str, Any]:
return await self._get("/api/manager/zones/zone")
@@ -91,7 +115,7 @@ class HomeyClient:
self, device_id: str, capability_id: str, value: Any
) -> Any:
return await self._put(
f"/api/manager/devices/device/{device_id}/capability/{capability_id}",
f"/api/manager/devices/device/{_seg(device_id)}/capability/{_seg(capability_id)}",
json={"value": value},
)
@@ -99,7 +123,7 @@ class HomeyClient:
return await self._get("/api/manager/flow/flow")
async def trigger_flow(self, flow_id: str) -> Any:
return await self._post(f"/api/manager/flow/flow/{flow_id}/trigger")
return await self._post(f"/api/manager/flow/flow/{_seg(flow_id)}/trigger")
# ---- REST: logic variables -------------------------------------------
@@ -110,7 +134,7 @@ class HomeyClient:
async def set_variable(self, variable_id: str, value: Any) -> Any:
"""Set a Homey Logic Variable to *value*."""
return await self._put(
f"/api/manager/logic/variable/{variable_id}",
f"/api/manager/logic/variable/{_seg(variable_id)}",
json={"value": value},
)
@@ -138,7 +162,14 @@ class HomeyClient:
return items[-limit:]
async def start_realtime(self) -> None:
"""Best-effort Socket.IO connection. Failures are logged, not raised."""
"""Best-effort Socket.IO connection. Failures are logged, not raised.
Homey's protocol (as implemented by Athom's ``homey-api``): connect the
root socket, ``handshakeClient`` with the token to obtain a namespace
(``/api``), connect that namespace and ``subscribe`` to URIs such as
``homey:manager:devices`` / ``homey:device:<id>``. Events then arrive
with the URI as event name and ``(event, data)`` as arguments.
"""
if not self.settings.realtime:
return
if socketio is None:
@@ -150,31 +181,74 @@ class HomeyClient:
sio = socketio.AsyncClient(reconnection=True, logger=False, engineio_logger=False)
self._sio = sio
def _record(kind: str) -> Any:
async def _handler(data: Any = None) -> None:
self._events.append(
{"ts": time.time(), "kind": kind, "data": data}
)
async def _on_connect() -> None:
# Fires on the first connect and after every reconnect: the Homey
# session (handshake + subscriptions) must be rebuilt each time.
self._sio_connected = True
if self._session_task is None or self._session_task.done():
self._session_task = asyncio.create_task(self._open_session())
return _handler
async def _on_disconnect(*_args: Any) -> None:
self._namespace = None
for kind in (
"device.create",
"device.update",
"device.delete",
"capability",
):
sio.on(kind, _record(kind))
sio.on("connect", _on_connect)
sio.on("disconnect", _on_disconnect)
try:
await sio.connect(
self.settings.base_url,
auth={"token": self.settings.token},
transports=["websocket"],
wait_timeout=self.settings.timeout,
)
self._sio_connected = True
log.info("Socket.IO connected to %s", self.settings.base_url)
except Exception as exc: # pragma: no cover
log.warning("Socket.IO connection failed: %s", exc)
self._sio_connected = False
async def _open_session(self) -> None:
sio = self._sio
if sio is None:
return
try:
info = await self.get_system_info()
homey_id = info.get("cloudId") or info.get("id")
ack = await sio.call(
"handshakeClient",
{"token": self.settings.token, "homeyId": homey_id},
timeout=self.settings.timeout,
)
err, result = ack if isinstance(ack, (list, tuple)) and len(ack) == 2 else (None, ack)
if err or not isinstance(result, dict) or not result.get("namespace"):
raise RuntimeError(f"handshake rejected: {err or result!r}")
namespace = str(result["namespace"])
async def _on_event(uri: str, *args: Any) -> None:
event = str(args[0]) if args else uri
data = args[1] if len(args) > 1 else None
self._events.append(event_entry(uri, event, data))
sio.on("*", _on_event, namespace=namespace)
# python-socketio has no public API to join a namespace after
# connect(); send the CONNECT packet the way connect() does.
from socketio import packet as sio_packet
await sio._send_packet(sio.packet_class(sio_packet.CONNECT, namespace=namespace))
for _ in range(int(self.settings.timeout * 10)):
if namespace in sio.namespaces:
break
await asyncio.sleep(0.1)
else:
raise RuntimeError(f"namespace {namespace} did not connect")
self._namespace = namespace
uris = ["homey:manager:devices"]
devices = await self.list_devices()
ids = list(devices)[:_MAX_DEVICE_SUBSCRIPTIONS] if isinstance(devices, dict) else []
uris += [f"homey:device:{device_id}" for device_id in ids]
for uri in uris:
await sio.call("subscribe", uri, namespace=namespace, timeout=self.settings.timeout)
log.info("Homey realtime: subscribed to %d URIs on %s", len(uris), namespace)
except asyncio.CancelledError:
raise
except Exception as exc:
log.warning("Homey realtime session failed: %s", exc)
+197 -136
View File
@@ -15,6 +15,7 @@ from typing import Any
import httpx
from mcp.server.fastmcp import Context, FastMCP
from mcp.server.transport_security import TransportSecuritySettings
from mcp.types import ToolAnnotations
from pydantic import BaseModel, Field
from .config import load_settings
@@ -22,6 +23,17 @@ from .homey_client import HomeyClient
log = logging.getLogger("homey_mcp")
# MCP tool annotations: hints for clients deciding what needs confirmation.
_READ = ToolAnnotations(readOnlyHint=True, openWorldHint=False)
_SET = ToolAnnotations(readOnlyHint=False, destructiveHint=False, idempotentHint=True,
openWorldHint=False)
_BULK = ToolAnnotations(readOnlyHint=False, destructiveHint=True, idempotentHint=True,
openWorldHint=False)
_ACTION = ToolAnnotations(readOnlyHint=False, destructiveHint=True, idempotentHint=False,
openWorldHint=True)
_NOTIFY = ToolAnnotations(readOnlyHint=False, destructiveHint=False, idempotentHint=False,
openWorldHint=True)
# ---- models --------------------------------------------------------------
@@ -143,8 +155,8 @@ async def _rest_devices(_request):
async def _rest_set_capability(request):
from starlette.responses import JSONResponse
body = await request.json()
if "value" not in body:
body = await _json_body(request)
if not isinstance(body, dict) or "value" not in body:
return JSONResponse({"error": "JSON body must contain 'value'"}, status_code=400)
device_id = request.path_params["device_id"]
capability = request.path_params["capability"]
@@ -162,8 +174,10 @@ async def _rest_set_capability(request):
async def _rest_control_zone(request):
from starlette.responses import JSONResponse
body = await request.json()
zone_query = str(body.get("zone", "")).strip().lower()
body = await _json_body(request)
if not isinstance(body, dict):
return JSONResponse({"error": "JSON object body required"}, status_code=400)
zone_query = str(body.get("zone", "")).strip()
capability = str(body.get("capability", "")).strip()
if not zone_query or not capability or "value" not in body:
return JSONResponse(
@@ -171,15 +185,10 @@ async def _rest_control_zone(request):
async with _resource_homey() as homey:
zones = await homey.list_zones()
zone_id = None
zone_name = None
for candidate_id, zone in zones.items():
if zone_query in str(zone.get("name", "")).lower():
zone_id = str(candidate_id)
zone_name = str(zone.get("name", zone_query))
break
if zone_id is None:
match = _resolve_zone(zones, zone_query)
if match is None:
return JSONResponse({"error": f"Zone '{body['zone']}' not found"}, status_code=404)
zone_id, zone_name = match
devices = await homey.list_devices()
affected: list[str] = []
@@ -187,11 +196,8 @@ async def _rest_control_zone(request):
for device in devices.values():
if not isinstance(device, dict):
continue
device_zone = device.get("zone", "")
if isinstance(device_zone, dict):
device_zone = device_zone.get("id", "")
capabilities = device.get("capabilities") or []
if str(device_zone) != zone_id or capability not in capabilities:
if _device_zone_id(device) != zone_id or capability not in capabilities:
continue
try:
dev_value = _coerce_capability_value(
@@ -323,6 +329,38 @@ def _homey(ctx: Context) -> HomeyClient:
return app.homey
async def _json_body(request) -> Any:
try:
return await request.json()
except ValueError:
return None
def _device_zone_id(device: dict[str, Any]) -> str:
zone = device.get("zone", "")
if isinstance(zone, dict): # some Homey versions embed {"id", "name"}
zone = zone.get("id", "")
return str(zone)
def _resolve_zone(raw_zones: Any, query: str) -> tuple[str, str] | None:
"""Best zone for *query*: exact name, then prefix, then substring match.
The first substring hit used to win, so 'Wohnzimmer' could resolve to
'Wohnzimmer Ecke' depending on dict order.
"""
q = query.strip().lower()
if not q or not isinstance(raw_zones, dict):
return None
candidates = [(str(zid), str(z.get("name", ""))) for zid, z in raw_zones.items()
if isinstance(z, dict)]
for test in (lambda n: n == q, lambda n: n.startswith(q), lambda n: q in n):
hits = sorted((c for c in candidates if test(c[1].lower())), key=lambda c: len(c[1]))
if hits:
return hits[0]
return None
def _capability_type(device: dict[str, Any], capability: str) -> str | None:
"""Look up a capability's declared type (e.g. 'boolean', 'number') from
a device object's capabilitiesObj, if present."""
@@ -388,7 +426,7 @@ def _device_to_summary(d: dict[str, Any]) -> DeviceSummary:
# ---- tools ---------------------------------------------------------------
@mcp.tool()
@mcp.tool(annotations=_READ)
async def ping(ctx: Context) -> PingResult:
"""Verify connectivity to the Homey Pro and return inventory counts."""
homey = _homey(ctx)
@@ -403,7 +441,7 @@ async def ping(ctx: Context) -> PingResult:
return PingResult(ok=False, host=homey.settings.host, error=str(exc))
@mcp.tool()
@mcp.tool(annotations=_READ)
async def list_devices(
ctx: Context, zone_id: str | None = None
) -> list[DeviceSummary]:
@@ -420,7 +458,7 @@ async def list_devices(
return out
@mcp.tool()
@mcp.tool(annotations=_READ)
async def find_devices(
ctx: Context, query: str, zone: str | None = None
) -> list[DeviceSummary]:
@@ -430,6 +468,10 @@ async def find_devices(
refers to a device by a human name like 'Wohnzimmerlicht' or 'kitchen
lamp'.
Every query word must match the device name or its zone name (so
'esszimmer licht' finds the lights in the Esszimmer). If nothing matches
all words, devices matching any word are returned instead.
Args:
query: substring to match against device name (and zone name).
zone: optional substring to match against zone name to narrow down.
@@ -460,28 +502,30 @@ async def find_devices(
return zones_by_id.get(str(zone_field), "").lower()
matches: list[DeviceSummary] = []
partial: list[DeviceSummary] = []
for d in items:
if not isinstance(d, dict):
continue
name = str(d.get("name", "")).lower()
zone_name = _zone_name_for(d)
# Match if ANY query word appears in the device name or zone name.
if q_words and not any(w in name or w in zone_name for w in q_words):
continue
if zq and zq not in zone_name:
continue
matches.append(_device_to_summary(d))
return matches
hits = [w in name or w in zone_name for w in q_words]
if all(hits):
matches.append(_device_to_summary(d))
elif any(hits):
partial.append(_device_to_summary(d))
return matches or partial
@mcp.tool()
@mcp.tool(annotations=_READ)
async def get_device(ctx: Context, device_id: str) -> dict[str, Any]:
"""Get the full device object (including all capability values)."""
homey = _homey(ctx)
return await homey.get_device(device_id)
@mcp.tool()
@mcp.tool(annotations=_READ)
async def list_zones(ctx: Context) -> list[ZoneSummary]:
"""List all zones (rooms / areas)."""
homey = _homey(ctx)
@@ -498,7 +542,7 @@ async def list_zones(ctx: Context) -> list[ZoneSummary]:
]
@mcp.tool()
@mcp.tool(annotations=_SET)
async def set_capability(
ctx: Context,
device_id: str,
@@ -539,7 +583,7 @@ async def set_capability(
return CapabilityResult(device_id=device_id, capability=capability, value=value)
@mcp.tool()
@mcp.tool(annotations=_BULK)
async def control_zone(
ctx: Context,
zone: str,
@@ -552,9 +596,8 @@ async def control_zone(
when the user wants to control an entire room (e.g. 'turn off Esszimmer',
'dim Wohnzimmer to 50%', 'Küche Licht an').
Zone matching is case-insensitive substring: 'wohn' matches 'Wohnzimmer'.
If multiple zones match, the FIRST match is used — use a more specific
substring (e.g. 'Wohnzimmer' not 'zimmer') to avoid ambiguity.
Zone matching is case-insensitive: an exact zone name wins, then a
prefix, then a substring ('wohn' matches 'Wohnzimmer').
Devices that don't support the capability are silently skipped (reported
in the `skipped` list). Check `affected` > 0 to confirm something changed.
@@ -580,20 +623,12 @@ async def control_zone(
raise RuntimeError(
f"Could not reach Homey (list_zones): {type(exc).__name__}"
) from None
zone_id: str | None = None
zone_display = zone
zq = zone.strip().lower()
if isinstance(raw_zones, dict):
for zid, z in raw_zones.items():
if isinstance(z, dict) and zq in str(z.get("name", "")).lower():
zone_id = str(zid)
zone_display = str(z.get("name", zone))
break
if zone_id is None:
match = _resolve_zone(raw_zones, zone)
if match is None:
raise ValueError(
f"Zone '{zone}' not found. Call list_zones to see available zones."
)
zone_id, zone_display = match
# --- Step 2: collect devices whose zone field matches zone_id ---
try:
@@ -604,19 +639,7 @@ async def control_zone(
) from None
items = raw_devices.values() if isinstance(raw_devices, dict) else raw_devices
zone_devices: list[dict] = []
for d in items:
if not isinstance(d, dict):
continue
zone_field = d.get("zone", "")
if isinstance(zone_field, dict):
# Homey sometimes embeds zone as {"id": "...", "name": "..."}
match = (str(zone_field.get("id", "")) == zone_id
or zq in str(zone_field.get("name", "")).lower())
else:
match = str(zone_field) == zone_id
if match:
zone_devices.append(d)
zone_devices = [d for d in items if isinstance(d, dict) and _device_zone_id(d) == zone_id]
if not zone_devices:
raise ValueError(
@@ -656,7 +679,7 @@ async def control_zone(
}
@mcp.tool()
@mcp.tool(annotations=_READ)
async def list_flows(ctx: Context) -> list[FlowSummary]:
"""List all Homey flows."""
homey = _homey(ctx)
@@ -674,7 +697,7 @@ async def list_flows(ctx: Context) -> list[FlowSummary]:
]
@mcp.tool()
@mcp.tool(annotations=_ACTION)
async def trigger_flow(ctx: Context, flow_id: str) -> dict[str, Any]:
"""Trigger (start) a Homey flow by id."""
homey = _homey(ctx)
@@ -682,7 +705,7 @@ async def trigger_flow(ctx: Context, flow_id: str) -> dict[str, Any]:
return {"flow_id": flow_id, "result": result, "ok": True}
@mcp.tool()
@mcp.tool(annotations=_READ)
async def list_recent_events(
ctx: Context, limit: int = 50, kind: str | None = None
) -> list[EventEntry]:
@@ -692,13 +715,14 @@ async def list_recent_events(
``device.delete``, ``capability``.
"""
homey = _homey(ctx)
limit = max(1, min(limit, homey.settings.event_buffer))
return [EventEntry(**e) for e in homey.recent_events(limit=limit, kind=kind)]
# ---- extended tools ------------------------------------------------------
@mcp.tool()
@mcp.tool(annotations=_READ)
async def get_zone_status(ctx: Context, zone: str) -> dict[str, Any]:
"""Get the current state of ALL devices inside a named zone.
@@ -717,17 +741,10 @@ async def get_zone_status(ctx: Context, zone: str) -> dict[str, Any]:
raise RuntimeError(
f"Could not reach Homey (list_zones): {type(exc).__name__}"
) from None
zone_id: str | None = None
zone_display = zone
zq = zone.strip().lower()
if isinstance(raw_zones, dict):
for zid, z in raw_zones.items():
if isinstance(z, dict) and zq in str(z.get("name", "")).lower():
zone_id = str(zid)
zone_display = str(z.get("name", zone))
break
if zone_id is None:
match = _resolve_zone(raw_zones, zone)
if match is None:
raise ValueError(f"Zone '{zone}' not found. Call list_zones for available zones.")
zone_id, zone_display = match
try:
raw_devices = await homey.list_devices()
@@ -738,14 +755,7 @@ async def get_zone_status(ctx: Context, zone: str) -> dict[str, Any]:
items = raw_devices.values() if isinstance(raw_devices, dict) else raw_devices
devices: list[dict[str, Any]] = []
for d in items:
if not isinstance(d, dict):
continue
zone_field = d.get("zone", "")
if isinstance(zone_field, dict):
match = str(zone_field.get("id", "")) == zone_id
else:
match = str(zone_field) == zone_id
if not match:
if not isinstance(d, dict) or _device_zone_id(d) != zone_id:
continue
s = _device_to_summary(d)
devices.append({
@@ -764,7 +774,7 @@ async def get_zone_status(ctx: Context, zone: str) -> dict[str, Any]:
}
@mcp.tool()
@mcp.tool(annotations=_READ)
async def get_house_status(
ctx: Context, class_filter: str | None = None
) -> dict[str, Any]:
@@ -827,7 +837,7 @@ async def get_house_status(
}
@mcp.tool()
@mcp.tool(annotations=_READ)
async def get_temperatures(ctx: Context) -> list[dict[str, Any]]:
"""Get every temperature-related reading across the entire home.
@@ -839,12 +849,9 @@ async def get_temperatures(ctx: Context) -> list[dict[str, Any]]:
homey = _homey(ctx)
try:
raw_devices = await homey.list_devices()
except Exception as exc:
return [{"error": f"Could not reach Homey (list_devices): {exc or type(exc).__name__}"}]
try:
raw_zones = await homey.list_zones()
except Exception as exc:
return [{"error": f"Could not reach Homey (list_zones): {exc or type(exc).__name__}"}]
raise RuntimeError(f"Could not reach Homey: {exc or type(exc).__name__}") from exc
zones_by_id: dict[str, str] = {}
if isinstance(raw_zones, dict):
for zid, z in raw_zones.items():
@@ -879,7 +886,7 @@ async def get_temperatures(ctx: Context) -> list[dict[str, Any]]:
return results
@mcp.tool()
@mcp.tool(annotations=_READ)
async def find_flows(ctx: Context, query: str) -> list[FlowSummary]:
"""Search flows by name (case-insensitive substring match).
@@ -905,7 +912,7 @@ async def find_flows(ctx: Context, query: str) -> list[FlowSummary]:
]
@mcp.tool()
@mcp.tool(annotations=_READ)
async def get_capability(
ctx: Context, device_id: str, capability: str
) -> dict[str, Any]:
@@ -940,7 +947,7 @@ async def get_capability(
}
@mcp.tool()
@mcp.tool(annotations=_SET)
async def set_light(
ctx: Context,
device_id: str,
@@ -1003,7 +1010,7 @@ async def set_light(
}
@mcp.tool()
@mcp.tool(annotations=_SET)
async def set_capabilities_bulk(
ctx: Context,
device_id: str,
@@ -1034,7 +1041,7 @@ async def set_capabilities_bulk(
return {"device_id": device_id, "applied": applied, "errors": errors}
@mcp.tool()
@mcp.tool(annotations=_BULK)
async def control_all(
ctx: Context,
capability: str,
@@ -1063,12 +1070,10 @@ async def control_all(
Omit only for truly all-device commands like 'turn off everything'.
"""
if not device_class and capability not in ("onoff", "dim"):
return {
"error": (
f"Refusing to set '{capability}' on ALL devices without a class filter. "
"Specify device_class (e.g. 'light', 'socket') to narrow the scope."
)
}
raise ValueError(
f"Refusing to set '{capability}' on ALL devices without a class filter. "
"Specify device_class (e.g. 'light', 'socket') to narrow the scope."
)
homey = _homey(ctx)
raw_devices = await homey.list_devices()
items = raw_devices.values() if isinstance(raw_devices, dict) else raw_devices
@@ -1105,7 +1110,7 @@ async def control_all(
}
@mcp.tool()
@mcp.tool(annotations=_READ)
async def list_variables(ctx: Context) -> list[VariableSummary]:
"""List all Homey Logic Variables (boolean, number, string, enum).
@@ -1128,7 +1133,7 @@ async def list_variables(ctx: Context) -> list[VariableSummary]:
]
@mcp.tool()
@mcp.tool(annotations=_SET)
async def set_variable(
ctx: Context,
variable_id: str,
@@ -1150,7 +1155,7 @@ async def set_variable(
return {"variable_id": variable_id, "value": value, "ok": True}
@mcp.tool()
@mcp.tool(annotations=_NOTIFY)
async def send_notification(
ctx: Context, title: str, excerpt: str = ""
) -> dict[str, Any]:
@@ -1164,11 +1169,8 @@ async def send_notification(
excerpt: Optional longer message body shown below the title.
"""
homey = _homey(ctx)
try:
result = await homey.send_notification(title, excerpt)
return {"ok": True, "title": title, "result": result}
except Exception as exc:
return {"ok": False, "title": title, "error": str(exc)}
result = await homey.send_notification(title, excerpt)
return {"ok": True, "title": title, "result": result}
@@ -1228,11 +1230,16 @@ async def _resource_homey() -> AsyncIterator[HomeyClient]:
class _BearerAuthMiddleware:
"""Protect REST routes and optionally MCP traffic with a static token."""
"""Protect REST routes and optionally all MCP traffic with a static token.
Only service discovery (``GET /``) and ``/openapi.json`` stay public.
"""
_PUBLIC = {("GET", "/"), ("HEAD", "/"), ("GET", "/openapi.json")}
def __init__(self, app, token: str, protect_mcp: bool):
self.app = app
self.token = token
self.token = token.encode()
self.protect_mcp = protect_mcp
async def __call__(self, scope, receive, send):
@@ -1243,19 +1250,16 @@ class _BearerAuthMiddleware:
method = scope.get("method", "")
headers = dict(scope.get("headers", []))
accepts_events = b"text/event-stream" in headers.get(b"accept", b"")
is_root_mcp = path == "/" and (method == "POST" or accepts_events)
requires_auth = path.startswith("/api/") or (
self.protect_mcp and (path == "/mcp" or is_root_mcp)
)
is_public = (method, path) in self._PUBLIC and not (path == "/" and accepts_events)
# Every MCP route (streamable /mcp, SSE /sse + /messages/, root alias).
requires_auth = path.startswith("/api/") or (self.protect_mcp and not is_public)
if not requires_auth:
await self.app(scope, receive, send)
return
from starlette.requests import Request
from starlette.responses import JSONResponse
request = Request(scope)
auth = request.headers.get("authorization", "")
presented = auth.removeprefix("Bearer ").strip() if auth.startswith("Bearer ") else ""
auth = headers.get(b"authorization", b"")
presented = auth[7:].strip() if auth[:7].lower() == b"bearer " else b""
if not presented or not hmac.compare_digest(presented, self.token):
response = JSONResponse(
{"error": "unauthorized"},
@@ -1267,6 +1271,47 @@ class _BearerAuthMiddleware:
await self.app(scope, receive, send)
class _HostGuardMiddleware:
"""Reject requests whose Host header isn't one of ours (DNS-rebinding defence).
A web page can rebind its own domain to this server's LAN IP and then
talk to it "same-origin"; the Host header still carries the attacker's
domain, so an allow-list stops it. Covers MCP *and* the REST API.
"""
def __init__(self, app, allowed_hosts: set[str]):
self.app = app
self.allowed = {h.lower() for h in allowed_hosts}
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
host = dict(scope.get("headers", [])).get(b"host", b"").decode("latin-1").lower()
hostname = host.rsplit(":", 1)[0] if not host.endswith("]") else host
if hostname not in self.allowed:
from starlette.responses import JSONResponse
response = JSONResponse({"error": "misdirected request (Host not allowed)"},
status_code=421)
await response(scope, receive, send)
return
await self.app(scope, receive, send)
def _allowed_hosts(advertised_host: str, extra: str) -> set[str] | None:
"""Host names clients may use, or None when the guard must stay off."""
extra_hosts = {h.strip() for h in extra.split(",") if h.strip()}
if "*" in extra_hosts:
return None
hosts = {"localhost", "127.0.0.1", "[::1]"} | extra_hosts
if advertised_host and advertised_host not in ("0.0.0.0", "::", "[::]"):
hosts.add(advertised_host)
elif not extra_hosts:
return None # wildcard bind and nothing configured: can't know our own name
return hosts
class _RootMcpAliasMiddleware:
"""Map MCP traffic sent to `/` onto FastMCP's `/mcp` endpoint."""
@@ -1315,11 +1360,46 @@ def main() -> None:
auth_mode = "none"
if auth_mode == "bearer" and not token:
raise RuntimeError("HOMEY_MCP_AUTH_MODE=bearer requires HOMEY_MCP_AUTH_TOKEN")
allowed_hosts = _allowed_hosts(advertised_host,
os.environ.get("HOMEY_MCP_ALLOWED_HOSTS", ""))
# Use advertised_host for MCP transport validation (Host header check).
# Bind uvicorn to host (which can be 0.0.0.0 for all interfaces).
mcp.settings.host = advertised_host
mcp.settings.port = port
app = build_lan_app(transport, token=token, auth_mode=auth_mode,
allowed_hosts=allowed_hosts)
if allowed_hosts is None:
log.warning(
"Host-header check disabled — DNS rebinding protection is off. Set "
"HOMEY_MCP_ADVERTISED_HOST (or HOMEY_MCP_ALLOWED_HOSTS) to the name/IP clients use."
)
else:
log.info(f"Accepted Host headers: {sorted(allowed_hosts)}")
if auth_mode == "bearer":
log.info(
f"homey-mcp ({transport}) bound to http://{host}:{port} "
f"(advertised as http://{advertised_host}:{port}) — "
"MCP + REST Bearer auth enabled"
)
elif token:
log.warning(
f"homey-mcp ({transport}) bound to http://{host}:{port} "
f"(advertised as http://{advertised_host}:{port}) — "
"MCP is unauthenticated for client compatibility; REST API requires Bearer auth"
)
else:
log.warning(
f"homey-mcp ({transport}) bound to http://{host}:{port} "
f"(advertised as http://{advertised_host}:{port}) WITHOUT authentication — "
"anyone on the LAN can control your smart home. Set HOMEY_MCP_AUTH_TOKEN to secure it."
)
import uvicorn
uvicorn.run(app, host=host, port=port, log_level="info")
def build_lan_app(transport: str, *, token: str, auth_mode: str,
allowed_hosts: set[str] | None):
"""ASGI app for the sse / streamable-http transports plus the REST API."""
app = mcp.streamable_http_app() if transport == "streamable-http" else mcp.sse_app()
from starlette.responses import JSONResponse
@@ -1352,30 +1432,11 @@ def main() -> None:
]
if transport == "streamable-http":
app = _RootMcpAliasMiddleware(app)
if token:
app = _BearerAuthMiddleware(app, token, protect_mcp=auth_mode == "bearer")
if auth_mode == "bearer":
log.info(
f"homey-mcp ({transport}) bound to http://{host}:{port} "
f"(advertised as http://{advertised_host}:{port}) — "
"MCP + REST Bearer auth enabled"
)
elif token:
log.warning(
f"homey-mcp ({transport}) bound to http://{host}:{port} "
f"(advertised as http://{advertised_host}:{port}) — "
"MCP is unauthenticated for client compatibility; REST API requires Bearer auth"
)
else:
log.warning(
f"homey-mcp ({transport}) bound to http://{host}:{port} "
f"(advertised as http://{advertised_host}:{port}) WITHOUT authentication — "
"anyone on the LAN can control your smart home. Set HOMEY_MCP_AUTH_TOKEN to secure it."
)
import uvicorn
uvicorn.run(app, host=host, port=port, log_level="info")
if allowed_hosts is not None:
app = _HostGuardMiddleware(app, allowed_hosts)
return app
if __name__ == "__main__":