mcp-server-harmony 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mcp_server_harmony-0.1.0/LICENSE +21 -0
- mcp_server_harmony-0.1.0/PKG-INFO +356 -0
- mcp_server_harmony-0.1.0/README.md +320 -0
- mcp_server_harmony-0.1.0/pyproject.toml +77 -0
- mcp_server_harmony-0.1.0/setup.cfg +4 -0
- mcp_server_harmony-0.1.0/src/harmony_mcp/__init__.py +3 -0
- mcp_server_harmony-0.1.0/src/harmony_mcp/assumptions.py +157 -0
- mcp_server_harmony-0.1.0/src/harmony_mcp/catalog.py +181 -0
- mcp_server_harmony-0.1.0/src/harmony_mcp/cli.py +302 -0
- mcp_server_harmony-0.1.0/src/harmony_mcp/client.py +428 -0
- mcp_server_harmony-0.1.0/src/harmony_mcp/config.py +211 -0
- mcp_server_harmony-0.1.0/src/harmony_mcp/discovery.py +103 -0
- mcp_server_harmony-0.1.0/src/harmony_mcp/doctor.py +278 -0
- mcp_server_harmony-0.1.0/src/harmony_mcp/hubs.py +243 -0
- mcp_server_harmony-0.1.0/src/harmony_mcp/limits.py +25 -0
- mcp_server_harmony-0.1.0/src/harmony_mcp/logsafe.py +156 -0
- mcp_server_harmony-0.1.0/src/harmony_mcp/ratelimit.py +213 -0
- mcp_server_harmony-0.1.0/src/harmony_mcp/registry.py +137 -0
- mcp_server_harmony-0.1.0/src/harmony_mcp/remote.py +417 -0
- mcp_server_harmony-0.1.0/src/harmony_mcp/server.py +348 -0
- mcp_server_harmony-0.1.0/src/harmony_mcp/sim/__init__.py +1 -0
- mcp_server_harmony-0.1.0/src/harmony_mcp/sim/fake_hub.py +346 -0
- mcp_server_harmony-0.1.0/src/harmony_mcp/sim/fixtures/den.json +62 -0
- mcp_server_harmony-0.1.0/src/harmony_mcp/sim/fixtures/living_room.json +133 -0
- mcp_server_harmony-0.1.0/src/harmony_mcp/tasks.py +131 -0
- mcp_server_harmony-0.1.0/src/mcp_server_harmony.egg-info/PKG-INFO +356 -0
- mcp_server_harmony-0.1.0/src/mcp_server_harmony.egg-info/SOURCES.txt +46 -0
- mcp_server_harmony-0.1.0/src/mcp_server_harmony.egg-info/dependency_links.txt +1 -0
- mcp_server_harmony-0.1.0/src/mcp_server_harmony.egg-info/entry_points.txt +2 -0
- mcp_server_harmony-0.1.0/src/mcp_server_harmony.egg-info/requires.txt +13 -0
- mcp_server_harmony-0.1.0/src/mcp_server_harmony.egg-info/top_level.txt +1 -0
- mcp_server_harmony-0.1.0/tests/test_assumptions.py +54 -0
- mcp_server_harmony-0.1.0/tests/test_catalog.py +75 -0
- mcp_server_harmony-0.1.0/tests/test_cli.py +54 -0
- mcp_server_harmony-0.1.0/tests/test_client.py +235 -0
- mcp_server_harmony-0.1.0/tests/test_config.py +56 -0
- mcp_server_harmony-0.1.0/tests/test_contract.py +303 -0
- mcp_server_harmony-0.1.0/tests/test_logsafe.py +237 -0
- mcp_server_harmony-0.1.0/tests/test_multi_hub.py +124 -0
- mcp_server_harmony-0.1.0/tests/test_ratelimit.py +344 -0
- mcp_server_harmony-0.1.0/tests/test_recorded.py +73 -0
- mcp_server_harmony-0.1.0/tests/test_registry.py +152 -0
- mcp_server_harmony-0.1.0/tests/test_remote.py +359 -0
- mcp_server_harmony-0.1.0/tests/test_safety.py +220 -0
- mcp_server_harmony-0.1.0/tests/test_stdio.py +31 -0
- mcp_server_harmony-0.1.0/tests/test_tasks.py +304 -0
- mcp_server_harmony-0.1.0/tests/test_tooling.py +238 -0
- mcp_server_harmony-0.1.0/tests/test_tools.py +226 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nicholas E. Andrade
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcp-server-harmony
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for the Logitech Harmony Hub (local websocket API), activity-first and allow-listed by the hub's own config
|
|
5
|
+
Author: Nicholas E. Andrade
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/SDNick484/mcp-server-harmony
|
|
8
|
+
Project-URL: Issues, https://github.com/SDNick484/mcp-server-harmony/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/SDNick484/mcp-server-harmony/blob/main/CHANGELOG.md
|
|
10
|
+
Keywords: mcp,mcp-server,harmony,logitech-harmony,harmony-hub,home-automation
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Framework :: AsyncIO
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Home Automation
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: mcp<3,>=2.3
|
|
24
|
+
Requires-Dist: aioharmony<2,>=1.0.10
|
|
25
|
+
Requires-Dist: aiohttp>=3.9
|
|
26
|
+
Requires-Dist: pyjwt[crypto]>=2.10
|
|
27
|
+
Requires-Dist: uvicorn>=0.31
|
|
28
|
+
Requires-Dist: typing_extensions>=4.7
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
31
|
+
Requires-Dist: anyio>=4; extra == "dev"
|
|
32
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
33
|
+
Requires-Dist: mypy>=1.11; extra == "dev"
|
|
34
|
+
Requires-Dist: httpx2; extra == "dev"
|
|
35
|
+
Dynamic: license-file
|
|
36
|
+
|
|
37
|
+
# mcp-server-harmony
|
|
38
|
+
|
|
39
|
+
[](https://github.com/SDNick484/mcp-server-harmony/actions/workflows/ci.yml)
|
|
40
|
+
|
|
41
|
+
An [MCP](https://modelcontextprotocol.io) server for **Logitech Harmony Hubs**, one or many
|
|
42
|
+
(say, one per TV). It talks to each hub directly over its local websocket API, with no Home
|
|
43
|
+
Assistant and no Logitech cloud. It lets an MCP client such as Claude start and stop
|
|
44
|
+
activities, press buttons, and read what's on. It works with any remote paired to a hub (Elite,
|
|
45
|
+
Companion, 950), since the server talks to the hub, not the remote.
|
|
46
|
+
|
|
47
|
+
> **Status: first contact with real hubs done (2026-10-14).** Two hubs connected, reported their
|
|
48
|
+
> state and names, and powered off on request. Starting activities, button presses, reconnects
|
|
49
|
+
> and config pushes are still verified against the simulator only: the tests run the real
|
|
50
|
+
> aioharmony library against a wire-level fake hub. Every protocol detail that hasn't been
|
|
51
|
+
> confirmed on hardware is a named assumption (see
|
|
52
|
+
> [Verification status](#verification-status)), and
|
|
53
|
+
> [HARDWARE_VALIDATION.md](HARDWARE_VALIDATION.md) is the checklist for confirming them.
|
|
54
|
+
|
|
55
|
+
Not affiliated with Logitech. Built on [aioharmony](https://github.com/Harmony-Libs/aioharmony),
|
|
56
|
+
the library behind Home Assistant's Harmony integration.
|
|
57
|
+
|
|
58
|
+
## Design: activity-first
|
|
59
|
+
|
|
60
|
+
A Harmony is built around *activities* ("Watch Shield", "Listen to Music"): starting one
|
|
61
|
+
powers the right devices and sets their inputs. That's also the right level for a model:
|
|
62
|
+
|
|
63
|
+
- `start_activity("Watch Shield")` is one call; the hub handles the device choreography.
|
|
64
|
+
- `send_command("VolumeUp")` with no device goes **through the running activity**, so volume
|
|
65
|
+
reaches the receiver and Pause reaches the Shield without the model knowing your wiring.
|
|
66
|
+
- `send_command(device="Onkyo AV Receiver", ...)` is there for the cases activities don't cover.
|
|
67
|
+
|
|
68
|
+
## Several hubs
|
|
69
|
+
|
|
70
|
+
One server drives all your hubs, each by its own name (the one set in the Harmony app). Tools
|
|
71
|
+
take an optional `hub`, and the model only needs it when a name is ambiguous:
|
|
72
|
+
|
|
73
|
+
- **Unique names just work.** `start_activity("Watch TV")` finds the one hub that has it.
|
|
74
|
+
- **A repeated name asks.** If both hubs have "Listen to Music", the tool returns an error
|
|
75
|
+
naming both hubs, and the model asks you or retries with `hub`.
|
|
76
|
+
- **Hub-qualified refs.** The list tools return a `ref` such as `"Den/Listen to Music"`, which
|
|
77
|
+
works anywhere a name does, so the model can copy one string.
|
|
78
|
+
- **Buttons follow the running activity**, when exactly one hub has one running. With both
|
|
79
|
+
TVs on, `send_command("VolumeUp")` asks which.
|
|
80
|
+
- **`power_off`** turns off the one hub that's on, asks if several are, and takes `hub="all"`
|
|
81
|
+
for every one. With `all`, each hub is reported separately, so one unreachable hub doesn't
|
|
82
|
+
stop the others.
|
|
83
|
+
|
|
84
|
+
The server never guesses between hubs: each one is a different TV, and a guess turns off the
|
|
85
|
+
wrong room.
|
|
86
|
+
|
|
87
|
+
## Install
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
python -m venv ~/.venvs/harmony && . ~/.venvs/harmony/bin/activate
|
|
91
|
+
pip install mcp-server-harmony
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
From source instead (for the newest code, or to run the tests):
|
|
95
|
+
|
|
96
|
+
```sh
|
|
97
|
+
git clone https://github.com/SDNick484/mcp-server-harmony.git && cd mcp-server-harmony
|
|
98
|
+
python -m venv .venv && . .venv/bin/activate
|
|
99
|
+
pip install -e ".[dev]" && pytest
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Requires Python 3.11+.
|
|
103
|
+
|
|
104
|
+
## First contact
|
|
105
|
+
|
|
106
|
+
There's no pairing: a hub's local API answers anything on the LAN at **TCP 8088**.
|
|
107
|
+
|
|
108
|
+
```sh
|
|
109
|
+
mcp-server-harmony discover # SSDP: lists hubs that answer
|
|
110
|
+
mcp-server-harmony check --discover # ...and adds them all
|
|
111
|
+
mcp-server-harmony check --host 192.168.1.60 # or one at a time, by IP
|
|
112
|
+
mcp-server-harmony doctor # each hub, layer by layer, with hints
|
|
113
|
+
mcp-server-harmony call start_activity activity="Den/Watch TV" # one tool, as the model calls it
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`check` connects, prints the hub's name, activities and devices (the exact names the model
|
|
117
|
+
will use), and adds the hub to `~/.config/mcp-server-harmony/config.json` with its name. Plain
|
|
118
|
+
`check` rechecks every configured hub. SSDP is multicast, so it doesn't cross VLANs or guest
|
|
119
|
+
networks; `--host` always works.
|
|
120
|
+
|
|
121
|
+
`doctor` checks TCP, provisioning, the websocket and the catalog for each hub, and warns about
|
|
122
|
+
activity names that more than one hub has. `doctor --dump DIR` saves a redacted copy of each
|
|
123
|
+
hub's config for `tests/fixtures/recorded/`.
|
|
124
|
+
|
|
125
|
+
## Use it with an MCP client
|
|
126
|
+
|
|
127
|
+
Local (stdio): Claude Code and Claude Desktop launch it themselves.
|
|
128
|
+
|
|
129
|
+
```json
|
|
130
|
+
{
|
|
131
|
+
"mcpServers": {
|
|
132
|
+
"harmony": { "command": "/path/to/mcp-server-harmony/.venv/bin/mcp-server-harmony" }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
For Claude Code: `claude mcp add harmony -- /path/to/.venv/bin/mcp-server-harmony`.
|
|
138
|
+
|
|
139
|
+
As a service (Streamable HTTP), to share one instance between Claude Code, Claude Desktop and
|
|
140
|
+
the mobile app through a Cloudflare Tunnel with Cloudflare Access in front:
|
|
141
|
+
|
|
142
|
+
```sh
|
|
143
|
+
CF_ACCESS_TEAM_DOMAIN=<team>.cloudflareaccess.com CF_ACCESS_AUD=<aud tag> \
|
|
144
|
+
mcp-server-harmony serve --http --public-host mcp.example.com # 127.0.0.1:8713/harmony/mcp
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Every request must carry a valid Access JWT (`Cf-Access-Jwt-Assertion`). The checks and the
|
|
148
|
+
flags are documented in `src/harmony_mcp/remote.py`. The Host-header (DNS-rebinding) checks stay on
|
|
149
|
+
even with `--bind 0.0.0.0`: for LAN clients, also pass `--public-host <this machine's LAN IP>`.
|
|
150
|
+
|
|
151
|
+
### As an OpenRC service on Alpine (e.g. a Proxmox LXC)
|
|
152
|
+
|
|
153
|
+
`deploy/alpine/` installs it the same way as mcp-server-webos, so the two can share an LXC: a venv
|
|
154
|
+
in `/opt/mcp-server-harmony`, a `mcp-harmony` system user, settings in
|
|
155
|
+
`/etc/mcp-server-harmony/env`, the hub list in `/var/lib/mcp-server-harmony/config.json`, and
|
|
156
|
+
`supervise-daemon` restarting it if it exits. The LXC needs TCP 8088 to each hub.
|
|
157
|
+
|
|
158
|
+
```sh
|
|
159
|
+
git clone https://github.com/SDNick484/mcp-server-harmony.git && cd mcp-server-harmony
|
|
160
|
+
sh deploy/alpine/install.sh # as root; re-run to upgrade
|
|
161
|
+
su -s /bin/sh mcp-harmony -c 'HARMONY_CONFIG_DIR=/var/lib/mcp-server-harmony \
|
|
162
|
+
/opt/mcp-server-harmony/venv/bin/mcp-server-harmony check --host <hub ip>' # once per hub
|
|
163
|
+
vi /etc/mcp-server-harmony/env # public host and Access settings
|
|
164
|
+
rc-update add mcp-server-harmony default && rc-service mcp-server-harmony start
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Logs are in `/var/log/mcp-server-harmony/server.log`. CI runs `deploy/alpine/smoke-test.sh` in a
|
|
168
|
+
stock `python:3.12-alpine` container: install, the test suite on musl, the service under OpenRC,
|
|
169
|
+
and an MCP call over HTTP reaching a simulated hub.
|
|
170
|
+
|
|
171
|
+
## Configuration
|
|
172
|
+
|
|
173
|
+
| Setting | Default | What it does |
|
|
174
|
+
| ------------------------ | ------------------------------------- | --------------------------------------------------------------- |
|
|
175
|
+
| `HARMONY_HOSTS` | *(from `config.json`)* | Comma-separated hub addresses; replaces the file's list |
|
|
176
|
+
| `HARMONY_CONFIG_DIR` | `$XDG_CONFIG_HOME/mcp-server-harmony` | Where `config.json` lives |
|
|
177
|
+
| `HARMONY_DRY_RUN` | off | Read the hubs, send nothing that changes anything (`serve --dry-run` too) |
|
|
178
|
+
| `HARMONY_LOG_UNREDACTED` | off | Don't mask IPs and MACs in logs (`--no-redact`); secrets stay masked |
|
|
179
|
+
|
|
180
|
+
`config.json` (written by `check`, safe to edit by hand):
|
|
181
|
+
|
|
182
|
+
```json
|
|
183
|
+
{
|
|
184
|
+
"hubs": [
|
|
185
|
+
{ "host": "192.168.1.60", "name": "Living Room" },
|
|
186
|
+
{ "host": "192.168.1.61", "name": "Den" }
|
|
187
|
+
]
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`name` is optional: without it, the hub's own name is used once it connects. Write your own to
|
|
192
|
+
call a hub something else; `check` fills in missing names but never replaces one. A hub also
|
|
193
|
+
answers to its address. Optionally add `"protocol": "WEBSOCKETS"` (or `"XMPP"`) to skip the
|
|
194
|
+
transport race. `HARMONY_HOST` (one hub) and the older `{"host": ...}` form still work.
|
|
195
|
+
Problems (a host with a port, duplicate hosts or names, invalid JSON) are reported by name at
|
|
196
|
+
startup and by `doctor`.
|
|
197
|
+
|
|
198
|
+
## Tools
|
|
199
|
+
|
|
200
|
+
| Tool | What it does |
|
|
201
|
+
| ----------------- | ------------------------------------------------------------------------------------------------ |
|
|
202
|
+
| `get_status` | Start here: every hub's name, reachable, firmware, on/off, running activity, and any `transition` |
|
|
203
|
+
| `list_activities` | Activities on each hub, with `ref`, marking the running ones |
|
|
204
|
+
| `list_devices` | Devices on each hub, with `ref`, manufacturer, model and command count |
|
|
205
|
+
| `list_commands` | Commands for an activity or device (default: the running activity), and which device gets each |
|
|
206
|
+
| `start_activity` | Start an activity and wait until its hub finishes; `unchanged` if it's already running |
|
|
207
|
+
| `power_off` | Run a hub's power-off (or `hub="all"`); `unchanged` if it's already off |
|
|
208
|
+
| `send_command` | Press a command 1-10 times (`repeat`), optionally held (`hold_ms`) or spaced (`delay_ms`) |
|
|
209
|
+
|
|
210
|
+
Every tool except `get_status` takes an optional `hub`. Actions return
|
|
211
|
+
`{hub, outcome: done | unchanged | dry_run | error, detail, sent}`, where `sent` lists the frames
|
|
212
|
+
that went to the hub. The server also offers resources (`harmony://hubs`,
|
|
213
|
+
`harmony://hubs/{hub}`) with each hub's catalog, and a `fix_stuck_remote` prompt for the
|
|
214
|
+
Shield's Bluetooth keyboard getting stuck.
|
|
215
|
+
|
|
216
|
+
### A session against the simulator
|
|
217
|
+
|
|
218
|
+
Two hubs that both have "Listen to Music". (Output from `simulate`, trimmed.)
|
|
219
|
+
|
|
220
|
+
```
|
|
221
|
+
> get_status
|
|
222
|
+
{"dry_run": false, "hubs": [
|
|
223
|
+
{"hub": "Living Room", "reachable": true, "power": "off", "current_activity": null, "transition": null, ...},
|
|
224
|
+
{"hub": "Den", "reachable": true, "power": "off", "current_activity": null, "transition": null, ...}]}
|
|
225
|
+
|
|
226
|
+
> start_activity {"activity": "Listen to Music"}
|
|
227
|
+
ERROR: Activity 'Listen to Music' exists on Living Room, Den; pass hub to choose.
|
|
228
|
+
|
|
229
|
+
> list_activities {"hub": "Den"}
|
|
230
|
+
[{"hub": "Den", "name": "Watch TV", "ref": "Den/Watch TV", "running": false},
|
|
231
|
+
{"hub": "Den", "name": "Listen to Music", "ref": "Den/Listen to Music", "running": false}]
|
|
232
|
+
|
|
233
|
+
> start_activity {"activity": "Den/Listen to Music"}
|
|
234
|
+
{"hub": "Den", "outcome": "done", "detail": "Start Listen to Music on Den",
|
|
235
|
+
"sent": ["runactivity activityId=39000002 (Listen to Music)"]}
|
|
236
|
+
|
|
237
|
+
> start_activity {"activity": "Watch Shield"}
|
|
238
|
+
{"hub": "Living Room", "outcome": "done", "detail": "Start Watch Shield on Living Room", ...}
|
|
239
|
+
|
|
240
|
+
> send_command {"command": "VolumeUp", "repeat": 3}
|
|
241
|
+
ERROR: Activities are running on Living Room (Watch Shield), Den (Listen to Music); pass hub to choose.
|
|
242
|
+
|
|
243
|
+
> send_command {"command": "VolumeUp", "hub": "Living Room", "repeat": 3}
|
|
244
|
+
{"hub": "Living Room", "outcome": "done", "detail": "Send VolumeUp x3 to Onkyo AV Receiver on Living Room",
|
|
245
|
+
"sent": ["holdAction press+release Onkyo AV Receiver/VolumeUp", ...]}
|
|
246
|
+
|
|
247
|
+
> power_off
|
|
248
|
+
ERROR: Several hubs are on (Living Room, Den); pass hub, or hub='all' for every one.
|
|
249
|
+
|
|
250
|
+
> power_off {"hub": "all"}
|
|
251
|
+
{"results": [{"hub": "Living Room", "outcome": "done", "detail": "Power off Living Room", ...},
|
|
252
|
+
{"hub": "Den", "outcome": "done", "detail": "Power off Den", ...}]}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
## Safety design
|
|
256
|
+
|
|
257
|
+
- **The hub's own config is the allow-list.** Activity, device and command names are resolved
|
|
258
|
+
against what the hub already knows. There's no way to send a raw IR code, an unknown device
|
|
259
|
+
id, or a command from one device to another.
|
|
260
|
+
- **Left out on purpose:** `sync` (talks to Logitech's cloud), `change_channel`, and anything
|
|
261
|
+
that edits the hub's configuration.
|
|
262
|
+
- **No interleaving with activity sequences.** While a hub is starting an activity or powering
|
|
263
|
+
off, its commands are refused with "busy, try again", since a press mid-sequence can land on
|
|
264
|
+
a device that's still warming up.
|
|
265
|
+
- **Rate limits and caps, per hub.** Per call: at most 10 repeats, held up to 3 s, 100-2000 ms
|
|
266
|
+
apart, 15 s in total. Across calls (a model in a loop): 20 presses in a burst, then 4 a
|
|
267
|
+
second; 4 activity changes in a burst, then one per 15 s. A call over the limit is refused
|
|
268
|
+
whole, never half-sent.
|
|
269
|
+
- **Dry run.** `serve --dry-run` (or `call --dry-run`) reads the hubs and reports what it
|
|
270
|
+
would send.
|
|
271
|
+
- **Resilient.** Each hub reconnects with backoff, and re-reads its state after a reconnect.
|
|
272
|
+
An offline hub doesn't block the others, and shutdown is bounded per hub.
|
|
273
|
+
- **Logs are redacted**: IP addresses and MACs (unless `--no-redact`), and always secrets (JWTs,
|
|
274
|
+
bearer tokens, keys, URL passwords). They go to stderr; stdout is the MCP transport.
|
|
275
|
+
- Every tool carries a title and MCP annotations. Nothing is marked destructive (activities and
|
|
276
|
+
button presses change what's on, not data). `send_command` is the one non-idempotent action,
|
|
277
|
+
since VolumeUp twice is +2.
|
|
278
|
+
|
|
279
|
+
## Troubleshooting
|
|
280
|
+
|
|
281
|
+
| Message | Meaning |
|
|
282
|
+
| --------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
|
|
283
|
+
| `No Harmony hub configured.` | Run `check --host <ip>` once per hub, or set `HARMONY_HOSTS` (in the client's config) |
|
|
284
|
+
| `Can't reach the Harmony hub 'Den' at ...` | Offline, its IP changed, or TCP 8088 is blocked (a VLAN in between). Other hubs keep working |
|
|
285
|
+
| `... exists on Living Room, Den; pass hub to choose.` | The name is on more than one hub: the model should ask which TV, or use a ref |
|
|
286
|
+
| `Den is busy starting ...` | An activity is mid-sequence. Wait until `get_status` shows `transition: null` for that hub |
|
|
287
|
+
| `Rate limit: refusing to ...` | Many presses in a short time. Wait and retry if it was deliberate |
|
|
288
|
+
|
|
289
|
+
`doctor` says which layer failed for each hub and what to check.
|
|
290
|
+
|
|
291
|
+
## Development
|
|
292
|
+
|
|
293
|
+
```sh
|
|
294
|
+
pip install -e ".[dev]"
|
|
295
|
+
pytest -q # everything, no hardware: real aioharmony against fake hubs over real sockets
|
|
296
|
+
ruff check . && ruff format --check . && mypy
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
The simulator runs the same fake hubs as real services on 127.0.0.1 and 127.0.0.2:
|
|
300
|
+
|
|
301
|
+
```sh
|
|
302
|
+
mcp-server-harmony simulate --write-config /tmp/hcfg # two hubs; --hubs 1 for one, --flaky N to drop connections
|
|
303
|
+
HARMONY_CONFIG_DIR=/tmp/hcfg mcp-server-harmony doctor
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
(On macOS, add the second loopback address first: `sudo ifconfig lo0 alias 127.0.0.2`.)
|
|
307
|
+
|
|
308
|
+
What the tests cover:
|
|
309
|
+
|
|
310
|
+
| File | Covers |
|
|
311
|
+
| --------------------- | ------------------------------------------------------------------------------------------------ |
|
|
312
|
+
| `test_contract.py` | The real aioharmony against the fake hub over a socket: the wire protocol, faults, reconnects |
|
|
313
|
+
| `test_catalog.py` | Parsing a hub's config into the allow-list |
|
|
314
|
+
| `test_client.py` | One hub: start, power off, presses, the busy guard |
|
|
315
|
+
| `test_multi_hub.py` | Several hubs: ambiguity, refs, an offline hub, partial failures |
|
|
316
|
+
| `test_tools.py` | The MCP contract the model sees (schemas, annotations, structured results) |
|
|
317
|
+
| `test_safety.py` | Dry run, rate limits, caps, config validation, shutdown |
|
|
318
|
+
| `test_tooling.py` | `doctor`, `discover`, `call`, `simulate`, log redaction |
|
|
319
|
+
| `test_recorded.py` | Replays configs captured from real hubs (`doctor --dump`) |
|
|
320
|
+
| `test_assumptions.py` | Every assumption appears here and in HARDWARE_VALIDATION.md |
|
|
321
|
+
|
|
322
|
+
## Verification status
|
|
323
|
+
|
|
324
|
+
These are the protocol assumptions the code depends on (claim, source, and for verified ones the
|
|
325
|
+
evidence, in `src/harmony_mcp/assumptions.py`). `hardware-verified` means seen working on a real hub;
|
|
326
|
+
the rest are verified against the simulator only:
|
|
327
|
+
|
|
328
|
+
| Assumption | Confidence | Status |
|
|
329
|
+
| ------------------ | ---------- | -------------- |
|
|
330
|
+
| `H-PORT` | high | hardware-verified |
|
|
331
|
+
| `H-PROVISION` | high | hardware-verified |
|
|
332
|
+
| `H-FRAMES` | high | hardware-verified |
|
|
333
|
+
| `H-CONFIG-SHAPE` | high | simulator-only |
|
|
334
|
+
| `H-POWEROFF` | high | hardware-verified |
|
|
335
|
+
| `H-NAME` | high | hardware-verified |
|
|
336
|
+
| `H-START-SEQUENCE` | medium | simulator-only |
|
|
337
|
+
| `H-PRESS-SILENT` | medium | simulator-only |
|
|
338
|
+
| `H-PRESS-ERROR` | low | simulator-only |
|
|
339
|
+
| `H-HOLD` | low | simulator-only |
|
|
340
|
+
| `H-CONFIG-PUSH` | medium | simulator-only |
|
|
341
|
+
| `H-RECONNECT` | high | simulator-only |
|
|
342
|
+
| `H-SSDP` | medium | simulator-only |
|
|
343
|
+
| `H-TIMING` | low | simulator-only |
|
|
344
|
+
|
|
345
|
+
"High" means aioharmony itself depends on it, and Home Assistant's Harmony integration runs on
|
|
346
|
+
aioharmony. `test_assumptions.py` fails if this table and `assumptions.py` disagree.
|
|
347
|
+
|
|
348
|
+
## Roadmap
|
|
349
|
+
|
|
350
|
+
- Validate on hardware ([HARDWARE_VALIDATION.md](HARDWARE_VALIDATION.md))
|
|
351
|
+
- Surface activity changes as MCP resource updates, once the Claude apps support subscriptions
|
|
352
|
+
- Publish to the MCP registry
|
|
353
|
+
|
|
354
|
+
## License
|
|
355
|
+
|
|
356
|
+
MIT. See [LICENSE](LICENSE).
|