mcp-server-sofabaton 0.2.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.
Files changed (47) hide show
  1. mcp_server_sofabaton-0.2.0/LICENSE +21 -0
  2. mcp_server_sofabaton-0.2.0/PKG-INFO +453 -0
  3. mcp_server_sofabaton-0.2.0/README.md +414 -0
  4. mcp_server_sofabaton-0.2.0/pyproject.toml +72 -0
  5. mcp_server_sofabaton-0.2.0/setup.cfg +4 -0
  6. mcp_server_sofabaton-0.2.0/src/mcp_server_sofabaton.egg-info/PKG-INFO +453 -0
  7. mcp_server_sofabaton-0.2.0/src/mcp_server_sofabaton.egg-info/SOURCES.txt +45 -0
  8. mcp_server_sofabaton-0.2.0/src/mcp_server_sofabaton.egg-info/dependency_links.txt +1 -0
  9. mcp_server_sofabaton-0.2.0/src/mcp_server_sofabaton.egg-info/entry_points.txt +2 -0
  10. mcp_server_sofabaton-0.2.0/src/mcp_server_sofabaton.egg-info/requires.txt +17 -0
  11. mcp_server_sofabaton-0.2.0/src/mcp_server_sofabaton.egg-info/top_level.txt +1 -0
  12. mcp_server_sofabaton-0.2.0/src/sofabaton_mcp/__init__.py +5 -0
  13. mcp_server_sofabaton-0.2.0/src/sofabaton_mcp/api.py +235 -0
  14. mcp_server_sofabaton-0.2.0/src/sofabaton_mcp/assumptions.py +217 -0
  15. mcp_server_sofabaton-0.2.0/src/sofabaton_mcp/cli.py +326 -0
  16. mcp_server_sofabaton-0.2.0/src/sofabaton_mcp/client.py +790 -0
  17. mcp_server_sofabaton-0.2.0/src/sofabaton_mcp/config.py +233 -0
  18. mcp_server_sofabaton-0.2.0/src/sofabaton_mcp/doctor.py +349 -0
  19. mcp_server_sofabaton-0.2.0/src/sofabaton_mcp/limits.py +56 -0
  20. mcp_server_sofabaton-0.2.0/src/sofabaton_mcp/logsafe.py +83 -0
  21. mcp_server_sofabaton-0.2.0/src/sofabaton_mcp/mqtt.py +356 -0
  22. mcp_server_sofabaton-0.2.0/src/sofabaton_mcp/protocol.py +152 -0
  23. mcp_server_sofabaton-0.2.0/src/sofabaton_mcp/ratelimit.py +81 -0
  24. mcp_server_sofabaton-0.2.0/src/sofabaton_mcp/remote.py +364 -0
  25. mcp_server_sofabaton-0.2.0/src/sofabaton_mcp/server.py +232 -0
  26. mcp_server_sofabaton-0.2.0/src/sofabaton_mcp/sim/__init__.py +0 -0
  27. mcp_server_sofabaton-0.2.0/src/sofabaton_mcp/sim/broker.py +223 -0
  28. mcp_server_sofabaton-0.2.0/src/sofabaton_mcp/sim/fake_server.py +257 -0
  29. mcp_server_sofabaton-0.2.0/src/sofabaton_mcp/sim/fake_x2.py +213 -0
  30. mcp_server_sofabaton-0.2.0/src/sofabaton_mcp/sim/fixtures/x2_living_room.json +16 -0
  31. mcp_server_sofabaton-0.2.0/src/sofabaton_mcp/sim/hub_state.py +146 -0
  32. mcp_server_sofabaton-0.2.0/src/sofabaton_mcp/tasks.py +40 -0
  33. mcp_server_sofabaton-0.2.0/tests/test_api.py +71 -0
  34. mcp_server_sofabaton-0.2.0/tests/test_assumptions.py +54 -0
  35. mcp_server_sofabaton-0.2.0/tests/test_client.py +170 -0
  36. mcp_server_sofabaton-0.2.0/tests/test_config.py +35 -0
  37. mcp_server_sofabaton-0.2.0/tests/test_logsafe.py +46 -0
  38. mcp_server_sofabaton-0.2.0/tests/test_mqtt.py +420 -0
  39. mcp_server_sofabaton-0.2.0/tests/test_openapi_contract.py +281 -0
  40. mcp_server_sofabaton-0.2.0/tests/test_ratelimit.py +73 -0
  41. mcp_server_sofabaton-0.2.0/tests/test_recorded.py +163 -0
  42. mcp_server_sofabaton-0.2.0/tests/test_remote.py +269 -0
  43. mcp_server_sofabaton-0.2.0/tests/test_safety.py +224 -0
  44. mcp_server_sofabaton-0.2.0/tests/test_stdio.py +36 -0
  45. mcp_server_sofabaton-0.2.0/tests/test_tasks.py +139 -0
  46. mcp_server_sofabaton-0.2.0/tests/test_tooling.py +272 -0
  47. mcp_server_sofabaton-0.2.0/tests/test_tools.py +273 -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,453 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp-server-sofabaton
3
+ Version: 0.2.0
4
+ Summary: MCP server for Sofabaton universal remote hubs: an X2 over its own MQTT link, or any model via sofabaton-x-server
5
+ Author: Nicholas E. Andrade
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/SDNick484/mcp-server-sofabaton
8
+ Project-URL: Issues, https://github.com/SDNick484/mcp-server-sofabaton/issues
9
+ Project-URL: Changelog, https://github.com/SDNick484/mcp-server-sofabaton/blob/main/CHANGELOG.md
10
+ Keywords: mcp,mcp-server,sofabaton,universal-remote,home-automation,mqtt
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: httpx>=0.27
25
+ Requires-Dist: pyjwt[crypto]>=2.10
26
+ Requires-Dist: uvicorn>=0.31
27
+ Requires-Dist: typing_extensions>=4.7
28
+ Provides-Extra: mqtt
29
+ Requires-Dist: aiomqtt<3,>=2.3; extra == "mqtt"
30
+ Provides-Extra: dev
31
+ Requires-Dist: aiomqtt<3,>=2.3; extra == "dev"
32
+ Requires-Dist: pytest>=8; extra == "dev"
33
+ Requires-Dist: anyio>=4; extra == "dev"
34
+ Requires-Dist: ruff>=0.6; extra == "dev"
35
+ Requires-Dist: mypy>=1.11; extra == "dev"
36
+ Requires-Dist: sofabaton-x<0.3,>=0.2.3; extra == "dev"
37
+ Requires-Dist: httpx2; extra == "dev"
38
+ Dynamic: license-file
39
+
40
+ # mcp-server-sofabaton
41
+
42
+ [![CI](https://github.com/SDNick484/mcp-server-sofabaton/actions/workflows/ci.yml/badge.svg)](https://github.com/SDNick484/mcp-server-sofabaton/actions/workflows/ci.yml)
43
+
44
+ An [MCP](https://modelcontextprotocol.io) server for **Sofabaton X1, X1S and X2** universal
45
+ remote hubs. It lets an MCP client such as Claude start and stop activities, press remote
46
+ buttons, send macros, favorites and device commands, find the remote, and read the presses of
47
+ buttons you've bound to it.
48
+
49
+ All three models work through [sofabaton-x-server](https://pypi.org/project/sofabaton-x-server/).
50
+ An **X2** can also be reached over **MQTT**, either on its own or alongside the server. That adds
51
+ live activity state, and keeps the hub controllable while the Sofabaton app is open. Every
52
+ response tells the model which model it's talking to and what that hub can and can't do.
53
+
54
+ > **Status: alpha, looking for testers.** Tested on one X2 over MQTT (see [Tested on](#tested-on)).
55
+ > The sofabaton-x-server path, and so every X1 and X1S, has run only against the simulator. If you
56
+ > have a Sofabaton hub, a report of what does and doesn't work on yours is the most useful thing
57
+ > you can send: [open a hardware report](https://github.com/SDNick484/mcp-server-sofabaton/issues/new?template=hardware_report.yml).
58
+
59
+ Not affiliated with Sofabaton.
60
+
61
+ ## Tested on
62
+
63
+ | | |
64
+ | --- | --- |
65
+ | Hub | One Sofabaton **X2**, over **MQTT only** (no sofabaton-x-server). Firmware unknown: MQTT doesn't report it |
66
+ | Broker | Home Assistant's Mosquitto add-on |
67
+ | Server | Alpine Linux in a Proxmox LXC, Python 3.14, as the OpenRC service in `deploy/alpine/` |
68
+ | Client | Claude, over Streamable HTTP through a Cloudflare Tunnel with Cloudflare Access |
69
+ | Setup | 5 activities, 6 devices, 1 macro, 7 favorites (on the TV and on a Home Assistant Wifi Device) |
70
+
71
+ **Seen working on that X2 (2026-10-16):** listing activities, devices, buttons, macros and
72
+ favorites; starting an activity and powering it off, with the hub's confirmation; hard buttons
73
+ (`VOL_UP`); a macro; favorites on two different devices; holding presses until the power macro
74
+ has settled (about 5 s here, inside the 6 s window); and presses of a button bound to a Wifi
75
+ Device arriving on `<MAC>/up`.
76
+
77
+ **Seen not working, and handled:** the X2 never answers a request for a device's own command list
78
+ over MQTT, so device commands over MQTT are refused up front with what would enable them
79
+ (sofabaton-x-server). And a burst of a dozen or so unanswered requests left that X2 still
80
+ publishing state but answering nothing until it was power-cycled; nothing in the server sends
81
+ requests like that.
82
+
83
+ **Not tested on hardware yet:** the X1 and X1S, sofabaton-x-server with any hub, an X2 on both
84
+ paths at once, more than one hub, brokers other than Mosquitto, and other firmware. Each detail
85
+ the code relies on is a named assumption with its source and status in
86
+ [PROTOCOL.md](https://github.com/SDNick484/mcp-server-sofabaton/blob/main/PROTOCOL.md); [HARDWARE_VALIDATION.md](https://github.com/SDNick484/mcp-server-sofabaton/blob/main/HARDWARE_VALIDATION.md) is the
87
+ checklist for confirming them on your hub.
88
+
89
+ ## What works with which hub
90
+
91
+ | | X1 / X1S | X2 + server | X2 + server + MQTT | X2, MQTT only |
92
+ | ------------------------------------------------ | ---------------- | --------------- | --------------------------- | ---------------- |
93
+ | Activities, buttons, macros, favorites | yes | yes | yes | yes |
94
+ | A device's own commands | yes | yes | yes (not while the app is open) | no: the X2 doesn't list them over MQTT |
95
+ | Find the remote; model and firmware | yes | yes | yes | no |
96
+ | Presses of buttons bound to a Wifi Device | yes (HTTP) | yes | yes | yes (MQTT) |
97
+ | Live activity state (pushed by the hub) | no | no | yes | yes |
98
+ | Control while the Sofabaton app is open | no | no | yes: falls back to MQTT | yes |
99
+ | Needs | server | server | server + broker | broker + the MAC |
100
+
101
+ **MQTT is X2-only.** The X1 and X1S have no MQTT; on those models, the server is the only way in
102
+ and everything in the first three rows works through it. `get_status` reports this as
103
+ `model`, `capabilities` and `limitations`, so the model can plan around it. For example, on an
104
+ X1S it says *"This is an X1S: live activity state, control while the Sofabaton app is open,
105
+ and MQTT presses need an X2."*
106
+
107
+ ## How it reaches the hub
108
+
109
+ ```
110
+ Claude ──► mcp-server-sofabaton ──┬──► sofabaton-x-server ──► hub (X1, X1S, X2)
111
+ (picks a path per call) └──► MQTT broker ◄────────► hub (X2 only)
112
+ ```
113
+
114
+ - **Through sofabaton-x-server (any model).** Sofabaton publishes no API. The
115
+ [sofabaton-x](https://pypi.org/project/sofabaton-x/) library reverse-engineers it by
116
+ sitting between the hub and the official app as a proxy, so one process owns the hub at a
117
+ time. sofabaton-x-server is that process, with a REST API. Going through it means many
118
+ clients can share the hub. It also means a model can only reach the control routes: editing,
119
+ erasing and restoring the hub need a token this project never holds.
120
+ - **Over MQTT (X2).** The X2 keeps its own connection to the broker you set in the Sofabaton
121
+ app (Me → Connect to Home Assistant). It answers list requests and takes commands on topics
122
+ under its MAC, and it announces every activity change. This doesn't go through the proxy, so
123
+ it keeps working while the app holds it.
124
+
125
+ With both configured, each call uses the server when it's in control mode and MQTT otherwise.
126
+ Names are resolved on whichever path is used, so ids never cross from one to the other.
127
+
128
+ ## Install
129
+
130
+ ```sh
131
+ python -m venv ~/.venvs/sofabaton && . ~/.venvs/sofabaton/bin/activate
132
+ pip install "mcp-server-sofabaton[mqtt]" # or without [mqtt] for server-only (X1/X1S, or an X2 without MQTT)
133
+ ```
134
+
135
+ From source instead (for the newest code, or to run the tests):
136
+
137
+ ```sh
138
+ git clone https://github.com/SDNick484/mcp-server-sofabaton.git && cd mcp-server-sofabaton
139
+ python -m venv .venv && . .venv/bin/activate
140
+ pip install -e ".[mqtt,dev]" && pytest
141
+ ```
142
+
143
+ Requires Python 3.11+. The `[mqtt]` extra adds one dependency, `aiomqtt` (which brings
144
+ `paho-mqtt`).
145
+
146
+ **Claim sofabaton-x-server.** Open its control panel and set up an admin account (Server
147
+ settings > Access). Until you do, it lets anyone on the LAN edit and erase hubs. `doctor` warns
148
+ while it's unclaimed.
149
+
150
+ ## Configuration
151
+
152
+ Environment variables (an MCP client may not pass your shell's environment, so set them in the
153
+ client's config):
154
+
155
+ | Setting | Default | What it does |
156
+ | ------------------------------- | ----------------------- | ------------------------------------------------------------------------------- |
157
+ | `SOFABATON_URL` | `http://localhost:8480` | sofabaton-x-server's address. `none` for MQTT only |
158
+ | `SOFABATON_HUB` | *(the only hub)* | Hub id or name, when the server manages more than one |
159
+ | `SOFABATON_MQTT_URL` | *(off)* | `mqtt://[user:pass@]host[:1883]` or `mqtts://...` (X2 only) |
160
+ | `SOFABATON_MQTT_USERNAME` | | If not in the URL |
161
+ | `SOFABATON_MQTT_PASSWORD` | | If not in the URL |
162
+ | `SOFABATON_MQTT_PASSWORD_FILE` | | Read the password from a file (wins over the others) |
163
+ | `SOFABATON_MQTT_MAC` | *(from the server)* | The X2's MAC. Needed for MQTT only; with a server it's learned |
164
+ | `SOFABATON_MQTT_SETTLE_S` | `6` | After an activity change over MQTT, hold presses off this long |
165
+ | `SOFABATON_DRY_RUN` | off | Read the hub, send nothing that changes anything (`serve --dry-run` too) |
166
+ | `SOFABATON_LOG_UNREDACTED` | off | Don't mask IPs and MACs in logs (`--no-redact`); passwords and other secrets stay masked |
167
+
168
+ There is deliberately no sofabaton-x-server token setting. Bad values are reported by name at
169
+ startup and by `doctor`, rather than failing later.
170
+
171
+ Typical setups:
172
+
173
+ ```sh
174
+ # X1 / X1S, or an X2 without MQTT
175
+ SOFABATON_URL=http://192.168.1.70:8480
176
+
177
+ # X2, both paths (recommended)
178
+ SOFABATON_URL=http://192.168.1.70:8480
179
+ SOFABATON_MQTT_URL=mqtt://sofabaton@192.168.1.10:1883
180
+ SOFABATON_MQTT_PASSWORD_FILE=/etc/mcp/sofabaton-mqtt.pw
181
+
182
+ # X2, MQTT only (no sofabaton-x-server)
183
+ SOFABATON_URL=none
184
+ SOFABATON_MQTT_URL=mqtt://sofabaton:secret@192.168.1.10:1883
185
+ SOFABATON_MQTT_MAC=02:AB:34:CD:56:EF
186
+ ```
187
+
188
+ ## First contact
189
+
190
+ ```sh
191
+ mcp-server-sofabaton doctor # each layer, with a hint for whatever fails
192
+ mcp-server-sofabaton check # the model, the path, capabilities, and the names the model will use
193
+ mcp-server-sofabaton call start_activity activity="Watch Shield" # one tool, as the model calls it
194
+ ```
195
+
196
+ `doctor` checks the server (reachable, version, claimed, hub session, mode, identity,
197
+ catalog) and MQTT (login, the X2 answering on its topics). If the X2 doesn't answer on the
198
+ uppercase-MAC topics, it tries lowercase and tells you. `doctor --listen 30` prints everything
199
+ the X2 publishes while you press keys on the remote. `doctor --dump DIR` saves a redacted
200
+ capture for `tests/fixtures/recorded/`.
201
+
202
+ ## Use it with an MCP client
203
+
204
+ Local (stdio): Claude Code and Claude Desktop launch it themselves.
205
+
206
+ ```json
207
+ {
208
+ "mcpServers": {
209
+ "sofabaton": {
210
+ "command": "/path/to/mcp-server-sofabaton/.venv/bin/mcp-server-sofabaton",
211
+ "env": {
212
+ "SOFABATON_URL": "http://192.168.1.70:8480",
213
+ "SOFABATON_MQTT_URL": "mqtt://sofabaton:secret@192.168.1.10:1883"
214
+ }
215
+ }
216
+ }
217
+ }
218
+ ```
219
+
220
+ For Claude Code: `claude mcp add sofabaton -e SOFABATON_URL=http://192.168.1.70:8480 -- /path/to/.venv/bin/mcp-server-sofabaton`.
221
+
222
+ > [!WARNING]
223
+ > **This server turns your devices on and off. Don't put it on a network you don't trust without
224
+ > authentication in front of it.** Without Cloudflare Access configured, the HTTP server serves
225
+ > only requests that start on this machine: it won't listen on anything but `127.0.0.1`, won't
226
+ > start with a `--public-host`, and answers 403 to any request a tunnel or proxy forwarded
227
+ > (`Cf-Connecting-IP`, `X-Forwarded-For`, ...). `--insecure-no-auth` turns those checks off for a
228
+ > trusted-LAN test. With a tunnel, set `CF_ACCESS_TEAM_DOMAIN` and `CF_ACCESS_AUD` so the server
229
+ > checks Access's signed assertion on every request itself.
230
+
231
+ As a service (Streamable HTTP), to share one instance between Claude Code, Claude Desktop and
232
+ the mobile app through a Cloudflare Tunnel with Cloudflare Access in front:
233
+
234
+ ```sh
235
+ CF_ACCESS_TEAM_DOMAIN=<team>.cloudflareaccess.com CF_ACCESS_AUD=<aud tag> \
236
+ mcp-server-sofabaton serve --http --public-host mcp.example.com # 127.0.0.1:8714/sofabaton/mcp
237
+ ```
238
+
239
+ Every request must carry a valid Access JWT (`Cf-Access-Jwt-Assertion`). The checks and the
240
+ flags are documented in `src/sofabaton_mcp/remote.py`. The Host-header (DNS-rebinding) checks stay on
241
+ even with `--bind 0.0.0.0`: for LAN clients, also pass `--public-host <this machine's LAN IP>`.
242
+
243
+ ### As an OpenRC service on Alpine (e.g. a Proxmox LXC)
244
+
245
+ `deploy/alpine/` installs it the same way as mcp-server-webos and -harmony, so all three can share
246
+ an LXC: a venv in `/opt/mcp-server-sofabaton` (with the `[mqtt]` extra), a `mcp-sofabaton` system
247
+ user, every setting in `/etc/mcp-server-sofabaton/env`, and `supervise-daemon` restarting it if
248
+ it exits. Keep the broker password out of the env file: put it in
249
+ `/etc/mcp-server-sofabaton/mqtt-password` (`0640 root:mcp-sofabaton`) and set
250
+ `SOFABATON_MQTT_PASSWORD_FILE` to that path.
251
+
252
+ ```sh
253
+ git clone https://github.com/SDNick484/mcp-server-sofabaton.git && cd mcp-server-sofabaton
254
+ sh deploy/alpine/install.sh # as root; re-run to upgrade
255
+ vim /etc/mcp-server-sofabaton/env # SOFABATON_URL and/or SOFABATON_MQTT_*, Access
256
+ su -s /bin/sh mcp-sofabaton -c '/opt/mcp-server-sofabaton/run.sh doctor'
257
+ rc-update add mcp-server-sofabaton default && rc-service mcp-server-sofabaton start
258
+ ```
259
+
260
+ Because every setting is an environment variable, `run.sh` takes a subcommand and runs it with
261
+ exactly the service's environment: `run.sh doctor`, `run.sh check`, `run.sh call get_status`.
262
+ Logs are in `/var/log/mcp-server-sofabaton/server.log`. CI runs `deploy/alpine/smoke-test.sh` in a
263
+ stock `python:3.12-alpine` container: install, the test suite on musl, `run.sh doctor`, the
264
+ service under OpenRC, and an MCP call over HTTP reaching a simulated X2 on both paths.
265
+
266
+ ## Tools
267
+
268
+ | Tool | What it does |
269
+ | -------------------- | ----------------------------------------------------------------------------------------------- |
270
+ | `get_status` | Start here: model, running activity, `via` (server or MQTT), `capabilities`, `limitations`, `transition` |
271
+ | `list_activities` | Activities, marking the running one |
272
+ | `list_devices` | Devices (brand and class over the server; names only over MQTT) |
273
+ | `list_commands` | Named commands for an activity (macros, favorites) or a device (default: the running activity) |
274
+ | `start_activity` | Start an activity and wait for the hub to confirm; `unchanged` if it's already running |
275
+ | `power_off` | Power off the running activity and confirm |
276
+ | `press_button` | A remote hard button (`VOL_UP`, `PAUSE`, `OK`, ...) 1-10 times, through the running activity |
277
+ | `send_command` | A macro, favorite or device command by name, 1-10 times |
278
+ | `find_remote` | Make the remote beep (needs the server) |
279
+ | `get_recent_presses` | Presses of remote buttons bound to a Wifi Device: the remote as an input to the agent |
280
+
281
+ Actions return `{outcome: done | unchanged | dry_run, detail, via, sent}`. `sent` lists the
282
+ exact requests or publishes, with the MAC shown as `<MAC>`. When something can't work on this
283
+ hub, the error says why and what would enable it.
284
+
285
+ ### A session against the simulator
286
+
287
+ An X2 with both paths. Someone opens the Sofabaton app partway through, and the server
288
+ switches to MQTT. (Output from `simulate`, trimmed.)
289
+
290
+ ```
291
+ > get_status
292
+ {"model": "X2", "via": "server", "running_activity": null, "transition": null,
293
+ "capabilities": ["catalog", "activities", "buttons", "commands", "presses", "find_remote",
294
+ "hub_info", "live_activity_state", "control_while_app_open"],
295
+ "limitations": [], ...}
296
+
297
+ > start_activity {"activity": "Watch Shield"}
298
+ {"outcome": "done", "detail": "Start Watch Shield", "via": "server",
299
+ "sent": ["POST /api/v1/hubs/a1b2c3/activities/101/start"]}
300
+
301
+ > press_button {"button": "VOL_UP", "repeat": 3}
302
+ {"outcome": "done", "detail": "Press VOL_UP x3 on activity Watch Shield", "via": "server", ...}
303
+
304
+ # (someone opens the Sofabaton app: sofabaton-x-server goes to observe mode)
305
+
306
+ > start_activity {"activity": "Listen to Music"}
307
+ {"outcome": "done", "via": "mqtt",
308
+ "detail": "Start Listen to Music. The X2 announced it early; its power macro may run for a
309
+ few more seconds (presses wait 6s).",
310
+ "sent": ["publish activity/<MAC>/activity_control_down {\"data\": {\"activity_id\": 102, \"state\": \"on\"}}"]}
311
+
312
+ > send_command {"command": "Input HDMI 2", "target": "Onkyo Receiver"}
313
+ ERROR: The X2 just changed activity and its power macro may still be running; a press now
314
+ could interrupt it. Try again in 5s (SOFABATON_MQTT_SETTLE_S sets this window).
315
+
316
+ > get_status
317
+ {"model": "X2", "via": "mqtt", "running_activity": "Listen to Music",
318
+ "transition": "power macro may still be running (presses wait 5s more)",
319
+ "capabilities": ["catalog", "activities", "buttons", "commands", "presses", "hub_info",
320
+ "live_activity_state", "control_while_app_open"],
321
+ "limitations": ["The Sofabaton app holds sofabaton-x-server's proxy (observe mode): commands
322
+ go over MQTT meanwhile, and find_remote waits until the app is closed."], ...}
323
+ ```
324
+
325
+ The same calls on an X1S go through the server, and `limitations` says the MQTT features need
326
+ an X2.
327
+
328
+ ## The remote as an input
329
+
330
+ A "Wifi Device" on the hub is a virtual device whose commands do nothing but report the press.
331
+ On the X2 it can report to `<MAC>/up` on your broker, and on any model it can report over HTTP
332
+ to sofabaton-x-server. Bind one to a key (say, a long press of a color button) and
333
+ `get_recent_presses` sees it. A remote button can then mean "ask the assistant" or "run my
334
+ bedtime routine" to an agent that watches for it. Wifi Devices are set up in the Sofabaton app
335
+ or the server's control panel: that's editing the hub, which this server doesn't do.
336
+
337
+ ## Safety design
338
+
339
+ - **No token, so no edits.** sofabaton-x-server lets anyone read and control, and requires a
340
+ token for editing, deleting, erasing and restoring. This project never holds one.
341
+ - **A local route allow-list too.** The REST client can only make GETs and the four control
342
+ POSTs (`send`, activity `start`/`stop`, `find-remote`). Anything else raises before a request
343
+ leaves the process. `test_openapi_contract.py` checks that the allow-list matches exactly
344
+ those routes in the server's published API.
345
+ - **MQTT topics are fixed.** Only the topics in `protocol.py` are ever published to, with
346
+ payloads built from names resolved against the hub's own lists.
347
+ - **Names, not ids.** The model never supplies an id. Buttons are an enum. `POWER_ON` and
348
+ `POWER_OFF` are left out, so starting and stopping always go through the tools that confirm.
349
+ - **Confirmation, not trust.** `accepted: true` means the hub took the frame. Starts and stops
350
+ wait for the hub to report the change.
351
+ - **Don't interrupt a macro.** Over MQTT the X2 announces a change at the *start* of its power
352
+ macro and never says when it's done, so presses wait out a settle window.
353
+ - **Rate limits and caps.** Per call: at most 10 repeats, 100-2000 ms apart. Across calls (a
354
+ model in a loop): 20 presses in a burst, then 4 a second; 4 activity changes in a burst, then
355
+ one per 15 s. A call over the limit is refused whole, never half-sent.
356
+ - **Dry run.** `serve --dry-run` (or `call --dry-run`) reads the hub and reports what it would
357
+ send.
358
+ - **Logs are redacted**: IP addresses and MACs (unless `--no-redact`), and always secrets (passwords
359
+ in URLs, JWTs, bearer tokens, keys). They go to stderr; stdout is the
360
+ MCP transport.
361
+ - Every tool has a title and explicit MCP annotations. Nothing is destructive.
362
+
363
+ ## Troubleshooting
364
+
365
+ | Message | Meaning |
366
+ | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
367
+ | `Can't reach sofabaton-x-server at ...` | It isn't running, or `SOFABATON_URL` is wrong for the environment the client launched us in |
368
+ | `... close the app on every phone and tablet` | The app holds the proxy (observe mode). On an X2, MQTT would keep it controllable |
369
+ | `has no session with the hub right now` | The hub is off the network, or its IP changed. Check the server's control panel |
370
+ | `didn't report it finished within 30s` | A long power macro, or a device didn't respond. Check `get_status` before retrying |
371
+ | `The X2 didn't answer the ... request over MQTT` | The X2 isn't linked to this broker, or the MAC is wrong. Run `doctor` |
372
+ | `the MQTT broker at ... rejected the login` | Wrong user or password in `SOFABATON_MQTT_URL` |
373
+ | `power macro may still be running` | Expected for a few seconds after a change over MQTT |
374
+ | `Rate limit: refusing to ...` | Many presses in a short time. Wait and retry if it was deliberate |
375
+
376
+ ## Development
377
+
378
+ ```sh
379
+ pip install -e ".[dev]"
380
+ pytest -q # everything, no hardware: fakes for the server, the broker and the X2
381
+ ruff check . && ruff format --check . && mypy
382
+ ```
383
+
384
+ The simulator runs the same fakes as real services, so you can point a client (or `doctor`)
385
+ at them:
386
+
387
+ ```sh
388
+ mcp-server-sofabaton simulate # X2: fake server + broker + X2; type `help` at sim>
389
+ mcp-server-sofabaton simulate --model X1S # server only
390
+ mcp-server-sofabaton simulate --no-server # X2 over MQTT only
391
+ ```
392
+
393
+ It prints the `export` lines for another terminal. At its `sim>` prompt you act as the
394
+ physical remote (`off`, `start <activity>`, `key <device> <key>`) or change the scene
395
+ (`app on` puts the server in observe mode).
396
+
397
+ The MQTT tests use an in-package broker by default. To run them against mosquitto, as CI does:
398
+
399
+ ```sh
400
+ docker compose -f dev/compose.yaml up -d
401
+ MQTT_TEST_BROKER=mqtt://127.0.0.1:1883 pytest -q tests/test_mqtt.py tests/test_tools.py tests/test_tooling.py -rs
402
+ ```
403
+
404
+ What the tests cover:
405
+
406
+ | File | Covers |
407
+ | -------------------------- | ------------------------------------------------------------------------------------------------------ |
408
+ | `test_openapi_contract.py` | Our routes, types and the fake's payloads against the OpenAPI document recorded from sofabaton-x-server 0.2.4 |
409
+ | `test_client.py` | The server path for every model, capabilities, confirmation |
410
+ | `test_mqtt.py` | The X2 over MQTT, alone and with the server, against a real broker over sockets |
411
+ | `test_tools.py` | The MCP contract the model sees (schemas, annotations, structured results) |
412
+ | `test_safety.py` | Dry run, rate limits, caps, config validation, redaction |
413
+ | `test_tooling.py` | `doctor`, `check`, `call` and `simulate`, including as real processes |
414
+ | `test_recorded.py` | Replays captures from a real hub (`doctor --dump`) through the real parsers |
415
+ | `test_assumptions.py` | Every assumption appears here and in HARDWARE_VALIDATION.md |
416
+
417
+ ## Verification status
418
+
419
+ These are the protocol assumptions the code depends on. `hardware-verified` means seen on a real
420
+ hub; the rest are verified against the simulator only (details and sources in [PROTOCOL.md](https://github.com/SDNick484/mcp-server-sofabaton/blob/main/PROTOCOL.md)):
421
+
422
+ | Assumption | Confidence | Status |
423
+ | ---------------------- | ---------- | -------------- |
424
+ | `S-REST-API` | high | simulator-only |
425
+ | `S-REST-MODES` | medium | simulator-only |
426
+ | `S-REST-START` | low | simulator-only |
427
+ | `S-X1-NO-MQTT` | high | simulator-only |
428
+ | `S-MQTT-UP` | high | hardware-verified |
429
+ | `S-MQTT-STATE` | high | hardware-verified |
430
+ | `S-MQTT-MAC-CASE` | medium | hardware-verified |
431
+ | `S-MQTT-CONTROL` | medium | hardware-verified |
432
+ | `S-MQTT-KEYS` | medium | hardware-verified |
433
+ | `S-MQTT-MACRO` | medium | hardware-verified |
434
+ | `S-MQTT-FAVORITE` | low | hardware-verified |
435
+ | `S-MQTT-DEVICE` | high | simulator-only |
436
+ | `S-MQTT-LISTS` | medium | hardware-verified |
437
+ | `S-MQTT-DEVICE-KEYS` | high | hardware-verified |
438
+ | `S-MQTT-IDS` | low | simulator-only |
439
+ | `S-MQTT-SERIAL` | medium | simulator-only |
440
+ | `S-MQTT-REPLY-TIMEOUT` | low | hardware-verified |
441
+ | `S-MQTT-SETTLE` | low | hardware-verified |
442
+
443
+ `test_assumptions.py` fails if this table and `assumptions.py` disagree.
444
+
445
+ ## Roadmap
446
+
447
+ - Validate on hardware ([HARDWARE_VALIDATION.md](https://github.com/SDNick484/mcp-server-sofabaton/blob/main/HARDWARE_VALIDATION.md))
448
+ - Push instead of poll: surface activity changes and presses as MCP resource updates
449
+ - Publish to PyPI and the MCP registry
450
+
451
+ ## License
452
+
453
+ MIT. See [LICENSE](https://github.com/SDNick484/mcp-server-sofabaton/blob/main/LICENSE).