v1.0
This commit is contained in:
1 parent
0246cbb6ba
commit
0ca4b4bf7a
89 files changed
+7042
-2480
No files matched your search
+25
-16
@@ -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.).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 ""
|
||||
@@ -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
@@ -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__":
|
||||
|
||||
Reference in new issue
Block a user