mcp-server-webos 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.
Files changed (61) hide show
  1. mcp_server_webos-0.1.0/LICENSE +21 -0
  2. mcp_server_webos-0.1.0/PKG-INFO +503 -0
  3. mcp_server_webos-0.1.0/README.md +468 -0
  4. mcp_server_webos-0.1.0/pyproject.toml +71 -0
  5. mcp_server_webos-0.1.0/setup.cfg +4 -0
  6. mcp_server_webos-0.1.0/src/mcp_server_webos.egg-info/PKG-INFO +503 -0
  7. mcp_server_webos-0.1.0/src/mcp_server_webos.egg-info/SOURCES.txt +59 -0
  8. mcp_server_webos-0.1.0/src/mcp_server_webos.egg-info/dependency_links.txt +1 -0
  9. mcp_server_webos-0.1.0/src/mcp_server_webos.egg-info/entry_points.txt +2 -0
  10. mcp_server_webos-0.1.0/src/mcp_server_webos.egg-info/requires.txt +12 -0
  11. mcp_server_webos-0.1.0/src/mcp_server_webos.egg-info/top_level.txt +1 -0
  12. mcp_server_webos-0.1.0/src/webos_mcp/__init__.py +5 -0
  13. mcp_server_webos-0.1.0/src/webos_mcp/apps.py +199 -0
  14. mcp_server_webos-0.1.0/src/webos_mcp/assumptions.py +440 -0
  15. mcp_server_webos-0.1.0/src/webos_mcp/capture.py +77 -0
  16. mcp_server_webos-0.1.0/src/webos_mcp/cli.py +466 -0
  17. mcp_server_webos-0.1.0/src/webos_mcp/client.py +1087 -0
  18. mcp_server_webos-0.1.0/src/webos_mcp/compat.py +188 -0
  19. mcp_server_webos-0.1.0/src/webos_mcp/config.py +431 -0
  20. mcp_server_webos-0.1.0/src/webos_mcp/discovery.py +69 -0
  21. mcp_server_webos-0.1.0/src/webos_mcp/doctor.py +322 -0
  22. mcp_server_webos-0.1.0/src/webos_mcp/errors.py +19 -0
  23. mcp_server_webos-0.1.0/src/webos_mcp/keystore.py +54 -0
  24. mcp_server_webos-0.1.0/src/webos_mcp/logsafe.py +309 -0
  25. mcp_server_webos-0.1.0/src/webos_mcp/lookup.py +60 -0
  26. mcp_server_webos-0.1.0/src/webos_mcp/probe.py +66 -0
  27. mcp_server_webos-0.1.0/src/webos_mcp/ratelimit.py +226 -0
  28. mcp_server_webos-0.1.0/src/webos_mcp/redact.py +66 -0
  29. mcp_server_webos-0.1.0/src/webos_mcp/registry.py +141 -0
  30. mcp_server_webos-0.1.0/src/webos_mcp/remote.py +516 -0
  31. mcp_server_webos-0.1.0/src/webos_mcp/server.py +571 -0
  32. mcp_server_webos-0.1.0/src/webos_mcp/sim/__init__.py +0 -0
  33. mcp_server_webos-0.1.0/src/webos_mcp/sim/fake_tv.py +607 -0
  34. mcp_server_webos-0.1.0/src/webos_mcp/sim/fixtures/c5-webos25.json +1221 -0
  35. mcp_server_webos-0.1.0/src/webos_mcp/sim/fixtures/c7.json +1007 -0
  36. mcp_server_webos-0.1.0/src/webos_mcp/sim/fixtures/g1.json +1378 -0
  37. mcp_server_webos-0.1.0/src/webos_mcp/sim/profiles.py +197 -0
  38. mcp_server_webos-0.1.0/src/webos_mcp/tasks.py +174 -0
  39. mcp_server_webos-0.1.0/src/webos_mcp/tools.py +29 -0
  40. mcp_server_webos-0.1.0/src/webos_mcp/tvs.py +64 -0
  41. mcp_server_webos-0.1.0/src/webos_mcp/wol.py +73 -0
  42. mcp_server_webos-0.1.0/tests/test_apps.py +148 -0
  43. mcp_server_webos-0.1.0/tests/test_assumptions.py +91 -0
  44. mcp_server_webos-0.1.0/tests/test_cli.py +204 -0
  45. mcp_server_webos-0.1.0/tests/test_client.py +493 -0
  46. mcp_server_webos-0.1.0/tests/test_compat.py +168 -0
  47. mcp_server_webos-0.1.0/tests/test_config.py +160 -0
  48. mcp_server_webos-0.1.0/tests/test_contract.py +66 -0
  49. mcp_server_webos-0.1.0/tests/test_http_tools.py +108 -0
  50. mcp_server_webos-0.1.0/tests/test_logsafe.py +521 -0
  51. mcp_server_webos-0.1.0/tests/test_lookup.py +41 -0
  52. mcp_server_webos-0.1.0/tests/test_ratelimit.py +382 -0
  53. mcp_server_webos-0.1.0/tests/test_redact.py +55 -0
  54. mcp_server_webos-0.1.0/tests/test_registry.py +175 -0
  55. mcp_server_webos-0.1.0/tests/test_remote.py +571 -0
  56. mcp_server_webos-0.1.0/tests/test_stdio.py +32 -0
  57. mcp_server_webos-0.1.0/tests/test_tasks.py +425 -0
  58. mcp_server_webos-0.1.0/tests/test_tool_annotations.py +18 -0
  59. mcp_server_webos-0.1.0/tests/test_tools.py +416 -0
  60. mcp_server_webos-0.1.0/tests/test_wire.py +173 -0
  61. mcp_server_webos-0.1.0/tests/test_wol_discovery.py +75 -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,503 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp-server-webos
3
+ Version: 0.1.0
4
+ Summary: MCP server for LG webOS TVs (SSAP over WebSocket, via aiowebostv) with a one-time pairing CLI
5
+ Author: Nicholas E. Andrade
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/SDNick484/mcp-server-webos
8
+ Project-URL: Issues, https://github.com/SDNick484/mcp-server-webos/issues
9
+ Project-URL: Changelog, https://github.com/SDNick484/mcp-server-webos/blob/main/CHANGELOG.md
10
+ Keywords: mcp,mcp-server,lg,webos,lg-tv,oled,home-automation
11
+ Classifier: Development Status :: 2 - Pre-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: aiowebostv<0.11,>=0.9.2
25
+ Requires-Dist: typing_extensions>=4.7
26
+ Requires-Dist: pyjwt[crypto]>=2.10
27
+ Requires-Dist: uvicorn>=0.31
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest>=8; extra == "dev"
30
+ Requires-Dist: anyio>=4; extra == "dev"
31
+ Requires-Dist: ruff>=0.6; extra == "dev"
32
+ Requires-Dist: mypy>=1.11; extra == "dev"
33
+ Requires-Dist: httpx2; extra == "dev"
34
+ Dynamic: license-file
35
+
36
+ # mcp-server-webos
37
+
38
+ An [MCP](https://modelcontextprotocol.io) server for LG webOS TVs: status, power, screen, apps and
39
+ inputs, remote keys, typing into search boxes, YouTube search, playback and volume, from Claude Code, Claude Desktop or the mobile app. It
40
+ talks to each TV directly over LG's SSAP WebSocket protocol, through
41
+ [aiowebostv](https://github.com/home-assistant-libs/aiowebostv) (the library Home Assistant uses),
42
+ with no dependency on Home Assistant.
43
+
44
+ A sibling of [mcp-server-onkyo](https://github.com/SDNick484/mcp-server-onkyo),
45
+ [mcp-server-shieldtv](https://github.com/SDNick484/mcp-server-shieldtv),
46
+ [mcp-server-harmony](https://github.com/SDNick484/mcp-server-harmony) and
47
+ [mcp-server-sofabaton](https://github.com/SDNick484/mcp-server-sofabaton), with the same
48
+ conventions: typed tools with annotations, an `ActionResult` from every action, dry run, `doctor`,
49
+ a wire-level simulator, an assumptions registry, Streamable HTTP behind Cloudflare Access, and an
50
+ OpenRC service for an Alpine LXC.
51
+
52
+ > **Status: alpha, and looking for testers with other LG TVs.** It runs daily on the three TVs below:
53
+ > pairing, status, screen off, sources, the text tools and YouTube search have run on real sets
54
+ > (2026-10-14 to 2026-11-10), and the HTTP service runs behind Cloudflare Access. Wake-on-LAN, keys
55
+ > and media controls have so far run only on the 2025 C5, and other models only against the
56
+ > simulator. [HARDWARE_VALIDATION.md](https://github.com/SDNick484/mcp-server-webos/blob/main/HARDWARE_VALIDATION.md) is the checklist, the table at the end
57
+ > of this README says what has been confirmed, and a
58
+ > [hardware report](https://github.com/SDNick484/mcp-server-webos/issues/new?template=hardware_report.yml)
59
+ > for your TV (working or not) is the most useful thing you can send.
60
+
61
+ ## The TVs it targets
62
+
63
+ The owner's three OLEDs, one per generation. Other webOS TVs (2014 onwards) speak the same
64
+ protocol and will probably work, but nothing is promised for them.
65
+
66
+ | Room | Model | Platform | Control port | Notes |
67
+ | --- | --- | --- | --- | --- |
68
+ | Bedroom | OLED65C7P-U (2017 C7) | webOS 3.5 (reports 3.9.3) | 3000 (ws) | no `now_playing` (no media service) |
69
+ | Family Room | OLED77G1PUA (2021 G1) | webOS 6.5 (reports 6.5.3) | 3000 (ws); 3001 (wss) also open | no `now_playing` (no media service) |
70
+ | Office | OLED65C5PUA (2025 C5) | webOS 25 (26 on offer) | 3001 (wss) | webOS 26 changed pairing (see below) |
71
+
72
+ A TV usually sits on one HDMI input from an Onkyo receiver, and **volume belongs to the
73
+ receiver**: when a TV's sound goes out over eARC/ARC, this server refuses to change its volume and
74
+ points at the receiver's own server instead. Going through the TV would bypass the receiver
75
+ server's volume caps (the TV forwards its volume to the receiver over HDMI-CEC). A separate
76
+ home-theater Skill coordinates the servers; [docs/skill-webos.md](https://github.com/SDNick484/mcp-server-webos/blob/main/docs/skill-webos.md) is this
77
+ server's cheat sheet for it.
78
+
79
+ ### webOS 26 and pairing
80
+
81
+ In August 2026 LG's webOS 26 firmware started rejecting the signed registration manifest that
82
+ every SSAP client had sent since 2014 (`403 Pairing rejected: blacklisted certificate detected`,
83
+ before any dialog). aiowebostv 0.9.2 switched to an unsigned manifest, which webOS 26 accepts;
84
+ this server requires at least that version. LG has been rolling webOS 26 out to 2025 sets since
85
+ late August, and there is no way back, so HARDWARE_VALIDATION.md captures the C5 on webOS 25
86
+ before it updates.
87
+
88
+ ## Install
89
+
90
+ With [uv](https://docs.astral.sh/uv/), nothing to install: `uvx mcp-server-webos ...` fetches it
91
+ into a cached environment and runs it (`uvx mcp-server-webos pair --host <ip> --name <room>`). Or
92
+ in a virtualenv:
93
+
94
+ ```sh
95
+ python -m venv ~/.venvs/webos && . ~/.venvs/webos/bin/activate
96
+ pip install mcp-server-webos
97
+ ```
98
+
99
+ From source instead (for the newest code, or to run the tests):
100
+
101
+ ```sh
102
+ git clone https://github.com/SDNick484/mcp-server-webos.git && cd mcp-server-webos
103
+ python -m venv .venv && . .venv/bin/activate
104
+ pip install -e ".[dev]" && pytest
105
+ ```
106
+
107
+ Python 3.11 or newer. Every dependency ships a musl wheel, so Alpine needs no compiler.
108
+
109
+ ## Pair each TV
110
+
111
+ The TV must be on. On a webOS 3.x set (the C7), turn on **LG Connect Apps** in its network
112
+ settings first. For `webos_set_power on` to work, also turn on the setting that lets the network wake
113
+ it: "Mobile TV On", "TV On With Mobile" or "Turn on via Wi-Fi", depending on the generation.
114
+
115
+ ```sh
116
+ mcp-server-webos pair --host 192.168.1.50 --name "Family Room" --alias G1
117
+ ```
118
+
119
+ The TV shows a pairing request: accept it with the remote (`pair` waits up to 60 s). `pair` reads
120
+ the TV's identity first (its deviceUUID, from the keyless hello), then saves:
121
+
122
+ - the client key, in `keys.json` (0600), by deviceUUID. It is full control of the TV, so it is
123
+ never logged, never in `config.json`, never in a tool result;
124
+ - the TV in `config.json`: name, aliases (your `--alias`es and the model), address, MACs for
125
+ Wake-on-LAN (the connected interface first; `--mac` if they can't be read), model, platform,
126
+ and the TLS certificate's fingerprint.
127
+
128
+ Both live in `~/.config/mcp-server-webos` (or `$WEBOS_CONFIG_DIR`), 0700. A running server
129
+ notices a new key and connects with it, without a restart. Re-running `pair` for the same TV
130
+ updates its entry and keeps your hand edits.
131
+
132
+ ```sh
133
+ mcp-server-webos check # connect once to each TV: platform, what works, what doesn't
134
+ mcp-server-webos doctor # layer by layer, read-only; each failure names a validation step
135
+ mcp-server-webos discover # which webOS TVs answer SSDP (TVs that are off don't)
136
+ ```
137
+
138
+ ### Choose the apps
139
+
140
+ `webos_select_source` only opens apps on the allow-list: a few defaults, plus yours. To see what a TV
141
+ actually has installed, and add from it:
142
+
143
+ ```sh
144
+ mcp-server-webos apps --tv "Office TV" # listed by group, with the name webos_select_source will use
145
+ mcp-server-webos apps --tv "Office TV" --add crunchyroll,"Photo & Video"
146
+ mcp-server-webos apps --all-tvs --add-all # every third-party app on any TV
147
+ ```
148
+
149
+ | Group | Means | Added by |
150
+ | --- | --- | --- |
151
+ | Allowed | already on the allow-list | |
152
+ | Not allowed yet | third-party apps | `--add NAME` or `--add-all` |
153
+ | LG's own apps | `com.webos.`, `com.palm.`, `com.lge.` ids (W-SYSTEM-APP-IDS) | `--add NAME` only |
154
+ | Excluded | the web browser (it opens any URL), settings, LG's store (it installs apps), inputs (`webos_select_source` handles them), Live TV, service menus | never |
155
+
156
+ It asks the TV over a short session with the stored key and changes nothing on it. New entries go
157
+ into `config.json`'s `"apps"`; restart the server to use them. Run it as the service user on the
158
+ LXC (`su -s /bin/sh mcp-webos -c '…'` with `WEBOS_CONFIG_DIR=/var/lib/mcp-server-webos`), since
159
+ `keys.json` and `config.json` are that user's. There is no tool for this: the model can't widen the
160
+ allow-list.
161
+
162
+ ### config.json
163
+
164
+ `pair` writes it; you may edit names, aliases, `max_volume` and `volume_owner`:
165
+
166
+ ```json
167
+ {
168
+ "tvs": [
169
+ {"name": "Bedroom", "aliases": ["C7", "OLED65C7P-U"], "host": "192.168.1.52",
170
+ "uuid": "...", "macs": ["a8:23:fe:..."], "model": "OLED65C7P-U", "platform": "3.5.0",
171
+ "max_volume": 30, "volume_owner": "auto"}
172
+ ],
173
+ "apps": {"crunchyroll": "crunchyroll.app.id"},
174
+ "max_volume": 40
175
+ }
176
+ ```
177
+
178
+ - `max_volume` (0-100, default 40): `webos_set_volume` caps at it, per TV or for all.
179
+ - `volume_owner`: `auto` (from the TV's sound output: external outputs mean the receiver owns
180
+ volume), `tv` (always allow), or `external` (never).
181
+ - `apps`: extra apps for the allow-list, `name: app id` (ids from `webos_list_sources` or a capture).
182
+ - `wol_broadcast` / `wol_port`: where magic packets go (default 255.255.255.255:9).
183
+ - Hosts must be private IPv4 addresses. A name or alias that would mean two TVs is refused.
184
+
185
+ ## Use it from Claude
186
+
187
+ Claude Code:
188
+
189
+ ```sh
190
+ claude mcp add webos -- uvx mcp-server-webos # or the venv's bin/mcp-server-webos
191
+ ```
192
+
193
+ Claude Desktop (`claude_desktop_config.json`, then restart Claude Desktop):
194
+
195
+ ```json
196
+ {
197
+ "mcpServers": {
198
+ "webos": {"command": "uvx", "args": ["mcp-server-webos"]}
199
+ }
200
+ }
201
+ ```
202
+
203
+ Claude Desktop doesn't start a login shell, so it may not find `uvx` on its `PATH`: if the server
204
+ doesn't appear, put the full path from `which uvx` (`where uvx` on Windows) in `command`. With a
205
+ virtualenv instead, `command` is the full path to its `bin/mcp-server-webos` and `args` is `[]`.
206
+ Both read the TVs and keys that `pair` saved.
207
+
208
+ ### Tools
209
+
210
+ Fourteen, on purpose: every tool definition costs context in every client.
211
+
212
+ | Tool | Does | Annotations |
213
+ | --- | --- | --- |
214
+ | `webos_list_tvs` | names and aliases `tv` accepts, model, platform | read-only |
215
+ | `webos_get_status` | power, what is in front, input, now playing, volume, sound output, capabilities and limitations; all TVs when `tv` is omitted | read-only |
216
+ | `webos_set_power` | on (Wake-on-LAN, confirmed) or off (standby); `tv="all"` for off | idempotent |
217
+ | `webos_set_screen` | blank the picture while sound plays, or bring it back | idempotent |
218
+ | `webos_list_sources` | allow-listed apps the TV has installed, and its HDMI inputs | read-only |
219
+ | `webos_select_source` | open an app or switch input, confirmed in front | idempotent |
220
+ | `webos_send_key` | one allow-listed remote key, up to 10 times | not idempotent |
221
+ | `webos_media_control` | play, pause, stop, rewind, fast-forward | not idempotent |
222
+ | `webos_set_volume` | 0-100, capped; refused when the receiver owns volume | idempotent |
223
+ | `webos_set_mute` | mute or unmute; same rule | idempotent |
224
+ | `webos_insert_text` | type into the focused *system* text field (LG's search); `replace` to overwrite it | not idempotent |
225
+ | `webos_delete_text` | delete characters before the cursor in that field | not idempotent |
226
+ | `webos_submit_text` | press the keyboard's Enter in that field (runs the search) | not idempotent |
227
+ | `webos_search_app` | open YouTube straight on its results for a query | idempotent |
228
+
229
+ ### Typing and searching
230
+
231
+ Typing a search with `webos_send_key` means walking an on-screen keyboard one arrow at a time. Where
232
+ `webos_get_status` lists `text_input`, `webos_insert_text` types the whole string through the TV's input
233
+ method (IME) service instead:
234
+
235
+ ```
236
+ webos_get_status(tv="Office") # capabilities include text_input
237
+ webos_send_key(key="HOME") ... open LG's search # the search box has the cursor
238
+ webos_insert_text(text="lofi beats", tv="Office") # typed, and confirmed by the field's new length
239
+ webos_submit_text(tv="Office") # the results
240
+ ```
241
+
242
+ What was seen on the three TVs (2026-11-09; details in HARDWARE_VALIDATION.md step 14):
243
+
244
+ | | Bedroom C7 (3.9.3) | Family Room G1 (6.5.3) | Office C5 (10.3.1) |
245
+ | --- | --- | --- | --- |
246
+ | Typing into LG's search | works, unconfirmed | works, confirmed | works, confirmed |
247
+ | Typing into YouTube's or Netflix's search | refused | refused | refused |
248
+ | `webos_search_app` (YouTube) | refused | works | works |
249
+
250
+ - **Only system text fields take typed text.** YouTube and Netflix draw their own keyboards, and
251
+ the TV answers "ok" to every IME call there (and with nothing focused at all) while typing
252
+ nothing. So each text tool first asks the TV whether a system text field has focus (W-IME-FOCUS)
253
+ and refuses, sending nothing, when none does. The refusal points at `webos_search_app` or `webos_send_key`.
254
+ - **Confirmed where the TV says.** The G1 and C5 report the field's length after every change, so
255
+ the result says whether the text landed; the C7 doesn't, so its results carry an "unconfirmed"
256
+ warning (W-IME-LENGTH).
257
+ - **YouTube search by link, not by typing.** `webos_search_app` launches YouTube with
258
+ `contentTarget=https://www.youtube.com/tv?q=<query>`, the only link shape that showed results
259
+ (W-YOUTUBE-SEARCH). The server builds the link; the model only gives the query. webOS 3.x's
260
+ YouTube ignores links, so it is refused on the C7; webOS 4 and 5 are untested and allowed.
261
+ - Text is one line of printable characters, at most 200 (a query, 100); no newlines (use
262
+ `webos_submit_text`). Typing shares `webos_send_key`'s rate limit.
263
+
264
+ Nothing is `destructiveHint`: turning a TV off is undone by turning it on. Every action returns an
265
+ `ActionResult`, the siblings' shape:
266
+
267
+ ```json
268
+ {"tv": "Bedroom", "outcome": "done", "detail": "Bedroom's volume is 15",
269
+ "sent": ["ssap://audio/setVolume 15"], "warnings": []}
270
+ ```
271
+
272
+ `outcome` is `done`, `unchanged` (already so; nothing sent) or `dry_run`. Failures are tool
273
+ errors whose message says what to do ("Which TV? Several are configured: ...", "Volume on Family
274
+ Room belongs to the receiver ...", "... needs pairing: run pair").
275
+
276
+ Resources: `webos://tvs` (capabilities per TV), `webos://keys` (the key allow-list, and the keys
277
+ left out with the reason for each), `webos://apps` (the app allow-list). One prompt, `watch`.
278
+
279
+ ### What the model can't do
280
+
281
+ - **No raw SSAP or luna calls.** The same reasoning as no `adb shell` on the Shield: one generic
282
+ call would reach everything the TV exposes, including its internal settings services.
283
+ - **No URL tools, and text only where decided:** no browser, no "play this URL". The two free-text
284
+ arguments are `webos_insert_text`'s text, which only reaches a focused system text field, and
285
+ `webos_search_app`'s query, which the server puts into a link it builds for an allow-listed app.
286
+ - **Keys are an allow-list.** `POWER` (a toggle), the `IN_START`/`EZ_ADJUST` service menus,
287
+ settings menus, the volume keys (they would bypass the cap) and app shortcuts (they would
288
+ bypass the app allow-list) are left out; `webos://keys` lists why.
289
+ - **Apps are an allow-list** of ids the TV itself lists. Strings from the TV (app titles, input
290
+ labels) are cleaned of control characters, bounded, and treated as data.
291
+ - **Never guess a TV:** with several configured, an action without `tv` is refused and the TVs are
292
+ listed. **Never guess a source:** a name that matches an app and an input is refused.
293
+ - **Screen off uses standbyMode "active" only.** "passive" can't be undone without unplugging the
294
+ TV; it isn't an argument anywhere.
295
+
296
+ [docs/OUT_OF_SCOPE.md](https://github.com/SDNick484/mcp-server-webos/blob/main/docs/OUT_OF_SCOPE.md) lists the rest (picture settings, channels, LG's IP
297
+ control) and why.
298
+
299
+ ### Dry run
300
+
301
+ `serve --dry-run` (or `WEBOS_DRY_RUN=1`) reads the TVs but sends nothing that changes anything:
302
+ every action returns `outcome: "dry_run"` with what it would have sent.
303
+
304
+ ## How it stays connected
305
+
306
+ Each TV has one long-lived WebSocket (the TV pushes changes over subscriptions) and a supervisor:
307
+
308
+ - **Off:** a TV that is off has no socket. The supervisor retries every ~10 s, so a TV turned on
309
+ by its remote shows up within ~10 s. `webos_get_status` on a stale TV first makes one connect attempt
310
+ of at most 3 s, then answers with `stale`, `as_of` and `error`.
311
+ - **Stale key:** if the TV no longer accepts the stored key (a reset, or a firmware update), it
312
+ wants to show its pairing dialog. The server hangs up at once, marks the TV `needs_pairing` and
313
+ stops trying until `pair` writes a new key. That's one dialog, not one every 10 s.
314
+ - **Moved:** the key is only sent after the TV's hello shows the paired deviceUUID. If another
315
+ device has the address, the server looks for the TV by SSDP and adopts a new address only on a
316
+ deviceUUID match.
317
+ - **Pointer socket refused:** aiowebostv would fail the whole connection; here only the keys stop
318
+ working (see `compat.py`).
319
+
320
+ `compat.py` holds every dependency on aiowebostv internals, with contract tests; that is why the
321
+ dependency is pinned `<0.11`.
322
+
323
+ ## HTTP and Cloudflare Access
324
+
325
+ To share one server between Claude Code, Claude Desktop and the mobile app, run it as a service
326
+ and reach it through a Cloudflare Tunnel with Access in front, exactly as the siblings do. Claude's
327
+ connectors reach it from the internet, so the URL is public; Access is what keeps it yours.
328
+
329
+ | Flag | Variable | Default |
330
+ |---|---|---|
331
+ | `--bind` | `MCP_HTTP_BIND` | `127.0.0.1` |
332
+ | `--port` | `MCP_HTTP_PORT` | `8715` (Onkyo 8711, Shield 8712, Harmony 8713, Sofabaton 8714) |
333
+ | `--path` | `MCP_HTTP_PATH` | `/webos/mcp` |
334
+ | `--public-host` | `MCP_PUBLIC_HOSTS` | none: only loopback Host headers pass, so **Cloudflare's requests get 421** |
335
+ | | `CF_ACCESS_TEAM_DOMAIN`, `CF_ACCESS_AUD` | none: no Access checks |
336
+ | | `MCP_ALLOWED_EMAILS` | anyone Access lets in |
337
+
338
+ With Access configured, every request needs a valid `Cf-Access-Jwt-Assertion`; anything else
339
+ gets a plain 403. The server refuses to start on a non-loopback address without Access unless
340
+ you pass `--insecure-no-auth`. `GET /healthz` answers without auth. `remote.py` and `logsafe.py`
341
+ are shared with the sibling servers, from
342
+ [mcp-server-shared](https://github.com/SDNick484/mcp-server-shared).
343
+
344
+ ### Setting up Cloudflare
345
+
346
+ This is the setup that works, verified with Claude Code and the Claude app connector on
347
+ 2026-10-14. Do the Access application first, so the URL is never public.
348
+
349
+ 1. **One Access application for the whole hostname**, shared by every MCP server on it. Zero Trust
350
+ → Access controls → Applications → Add → Self-hosted:
351
+ - Destination: subdomain `mcp`, your domain, **path empty**. Managed OAuth serves its sign-in
352
+ discovery from the root of the hostname (`/.well-known/...`), so an app limited to
353
+ `/webos` may not cover it; one app also means one sign-in for all the servers.
354
+ - Policy: Allow → Emails → you. Login method: One-time PIN (or your identity provider).
355
+ - Edit the app → **Advanced settings → Managed OAuth: on**. Allowed redirect URIs:
356
+ `https://claude.ai/*`. Turn on **Allow localhost clients** (Claude Code signs in through
357
+ localhost). Access token 15 min, grant session 1-2 weeks.
358
+ - Copy the app's **Application Audience (AUD) tag**.
359
+ - Make sure no *other* Access app covers a path on the same hostname: Access applies the most
360
+ specific match, and a Bypass policy there would let requests through without a token.
361
+ 2. **A route on the tunnel.** Networks → Tunnels → the tunnel → Published application routes →
362
+ Add: hostname `mcp.example.com`, path `^/webos/` (a regex), service
363
+ `http://<address>:8715`. Leave the HTTP Host Header override unset: the server checks the
364
+ public Host.
365
+ - The service address is resolved **where cloudflared runs**. If cloudflared is a Docker
366
+ container (the tunnel page lists the replica's hostname as a 12-character hex container ID), `localhost`
367
+ is that container, not the LXC: use the LXC's LAN address or the Docker bridge's
368
+ (`172.17.0.1`, reachable only from containers on that host), and set `MCP_HTTP_BIND` to the same address.
369
+ 3. **`/etc/mcp-server-webos/env`** (see `deploy/alpine/env.example`), then
370
+ `rc-service mcp-server-webos restart`:
371
+ ```sh
372
+ MCP_HTTP_BIND=192.168.1.10 # e.g. the LXC's address: the one the route points at
373
+ MCP_PUBLIC_HOSTS=mcp.example.com # required: without it every request through Cloudflare gets 421
374
+ CF_ACCESS_TEAM_DOMAIN=<team>.cloudflareaccess.com
375
+ CF_ACCESS_AUD=<the AUD tag from step 1>
376
+ MCP_ALLOWED_EMAILS=<the email you sign in to Access with>
377
+ ```
378
+ 4. **Test**, then add `https://mcp.example.com/webos/mcp` as a custom connector in Claude (and
379
+ `claude mcp add --transport http webos <url>` for Claude Code):
380
+ ```sh
381
+ curl -s http://192.168.1.10:8715/healthz # {"status": "ok"}
382
+ curl -si -X POST https://mcp.example.com/webos/mcp -d '{}' | head -5 # 401 + www-authenticate
383
+ ```
384
+
385
+ ### When it doesn't connect
386
+
387
+ Watch the server while you press Connect: `tail -n 0 -f /var/log/mcp-server-webos/server.log`.
388
+
389
+ | You see | Cause | Fix |
390
+ | --- | --- | --- |
391
+ | The outside `curl` gets a 302 to `…cloudflareaccess.com` | Managed OAuth is off: Claude gets a login page | Turn it on (step 1) |
392
+ | Claude: "couldn't reach" / "authorized, but … returned an error"; Claude Code: `HTTP 502`; nothing in the log | The tunnel can't reach the server: the route's service address isn't where the server listens (usually `localhost` with cloudflared in a container) | Step 2's address and `MCP_HTTP_BIND` must match |
393
+ | Claude Code: `HTTP 421`; log: `Invalid Host header: mcp.example.com` | `MCP_PUBLIC_HOSTS` isn't set | Step 3 |
394
+ | Log: `Denied … no Cloudflare Access assertion` | The request didn't come through Access: a local test with `curl`, or another Access app/Bypass policy on the path | Ignore it for local tests; otherwise see step 1's last point |
395
+ | Log: `Denied … Audience doesn't match` / `Invalid issuer` | `CF_ACCESS_AUD` is another app's tag, or the team domain is wrong | Copy both again from the app in step 1 |
396
+ | Log: `Denied … not in MCP_ALLOWED_EMAILS` | You signed in to Access with another address | Make them match |
397
+
398
+ ### As a service on Alpine (Proxmox LXC)
399
+
400
+ ```sh
401
+ sh deploy/alpine/install.sh # as root; or: install.sh /path/to/checkout
402
+ su -s /bin/sh mcp-webos -c 'WEBOS_CONFIG_DIR=/var/lib/mcp-server-webos \
403
+ /opt/mcp-server-webos/venv/bin/mcp-server-webos pair --host <tv ip> --name "Family Room"'
404
+ vi /etc/mcp-server-webos/env # HTTP and Access settings
405
+ rc-update add mcp-server-webos default && rc-service mcp-server-webos start
406
+ ```
407
+
408
+ | Path | Owner, mode | Holds |
409
+ | --- | --- | --- |
410
+ | `/opt/mcp-server-webos/venv` | root, 0755 | The code |
411
+ | `/etc/mcp-server-webos/env` | root:mcp-webos, 0640 | HTTP and Access settings |
412
+ | `/var/lib/mcp-server-webos/` | mcp-webos, 0700 (files 0600) | `keys.json`, `config.json` |
413
+ | `/var/log/mcp-server-webos/server.log` | mcp-webos, 0750 | Logs, redacted |
414
+ | `/etc/init.d/mcp-server-webos` | root, 0755 | OpenRC script (`supervise-daemon`) |
415
+
416
+ **Wake-on-LAN from an LXC:** the magic packet is a broadcast (plus a copy to the TV's last
417
+ address), so the container must be bridged onto the TVs' network, not NATed. `doctor` shows the
418
+ local address it would leave from.
419
+
420
+ ## Logs
421
+
422
+ stderr only (stdout is the MCP stdio transport). Addresses, MACs, serial numbers, deviceUUIDs and
423
+ pointer-socket ids are redacted unless `--no-redact` (or `WEBOS_LOG_UNREDACTED=1`); client keys,
424
+ JWTs and certificates always are. `--debug` (`WEBOS_DEBUG=1`) adds aiowebostv's protocol traffic.
425
+
426
+ ## Testing without a TV
427
+
428
+ ```sh
429
+ pip install -e ".[dev]" && pytest && ruff check . && ruff format --check . && mypy
430
+ mcp-server-webos simulate --profile g1 --config-dir /tmp/sim --paired # then, in another terminal:
431
+ WEBOS_CONFIG_DIR=/tmp/sim mcp-server-webos call webos_get_status
432
+ WEBOS_CONFIG_DIR=/tmp/sim mcp-server-webos call webos_select_source source=netflix
433
+ ```
434
+
435
+ The simulator (`src/webos_mcp/sim/`) speaks SSAP over real WebSockets: the hello, registration
436
+ and the pairing dialog, requests and subscriptions with pushed updates, the TV's error replies,
437
+ the pointer socket, and Wake-on-LAN. The real aiowebostv connects to it. Profiles: `c7`, `g1`,
438
+ `c5-webos25`, `c5-webos26`, each field naming its source. aiowebostv always dials ports 3000 and
439
+ 3001, so each simulated TV binds the real ports on its own loopback address (127.0.0.2-5): on
440
+ Linux all of 127.0.0.0/8 is loopback. (macOS needs loopback aliases for that.)
441
+
442
+ **WSL2 with mirrored networking** (`networkingMode=mirrored` in `.wslconfig`): Windows then
443
+ handles loopback and answers only 127.0.0.1, so the simulator tests hang and time out. Run them
444
+ with `scripts/test-wsl.sh [pytest args]`, which runs pytest in a network namespace of its own
445
+ (ordinary Linux loopback, no other network; needs `unshare` and `ip`, and root or sudo). WSL2's
446
+ default NAT networking doesn't need it.
447
+
448
+ `mcp-server-webos capture --tv <name> --profile <p>` records a real TV's answers to read-only
449
+ requests, redacted; committed to `src/webos_mcp/sim/fixtures/`, each capture becomes a contract
450
+ test against its simulator profile.
451
+
452
+ ## What has been confirmed on hardware
453
+
454
+ Every protocol detail this server relies on is in `src/webos_mcp/assumptions.py`, with its source
455
+ and how confident that source is. `doctor` prints the unconfirmed ones; HARDWARE_VALIDATION.md
456
+ says which step confirms each.
457
+
458
+ | Assumption | Confidence | Status |
459
+ | --- | --- | --- |
460
+ | `W-MANIFEST` | high | simulator-only |
461
+ | `W-SIGNED-403` | high | simulator-only |
462
+ | `W-KEY-SURVIVES` | low | simulator-only |
463
+ | `W-REGISTERED-DIRECT` | high | hardware-verified |
464
+ | `W-STALE-KEY` | medium | simulator-only |
465
+ | `W-PROMPT-DISMISS` | low | simulator-only |
466
+ | `W-PAIR-WINDOW` | low | simulator-only |
467
+ | `W-HELLO-VERSION` | high | hardware-verified |
468
+ | `W-SWINFO-401` | high | hardware-verified |
469
+ | `W-UUID-STABLE` | medium | simulator-only |
470
+ | `W-PORTS` | medium | simulator-only |
471
+ | `W-PORT3000-CLOSE` | medium | simulator-only |
472
+ | `W-TLS-CERT` | low | simulator-only |
473
+ | `W-MULTI-CLIENT` | medium | simulator-only |
474
+ | `W-INPUT-SOCKET` | medium | hardware-verified |
475
+ | `W-SUBSCRIBE` | high | hardware-verified |
476
+ | `W-HELLO-1008` | medium | hardware-verified |
477
+ | `W-POWER-STATES` | high | simulator-only |
478
+ | `W-C7-NO-POWERSTATE` | low | hardware-contradicted |
479
+ | `W-STANDBY-SOCKET` | low | simulator-only |
480
+ | `W-OFF-GUARD` | low | simulator-only |
481
+ | `W-TURNON-DEAD` | medium | simulator-only |
482
+ | `W-WOL` | medium | simulator-only |
483
+ | `W-WOL-PATH` | low | simulator-only |
484
+ | `W-WAKE-TIME` | medium | hardware-contradicted |
485
+ | `W-MAC-SOURCE` | low | simulator-only |
486
+ | `W-SCREEN-OFF` | medium | simulator-only |
487
+ | `W-KEYS` | medium | simulator-only |
488
+ | `W-MEDIA-CONTROLS` | low | simulator-only |
489
+ | `W-NOW-PLAYING` | medium | hardware-verified |
490
+ | `W-SOUND-OUTPUT` | medium | hardware-verified |
491
+ | `W-VOLUME-ARC` | medium | simulator-only |
492
+ | `W-APP-IDS` | low | simulator-only |
493
+ | `W-SYSTEM-APP-IDS` | medium | simulator-only |
494
+ | `W-INPUT-IDS` | medium | hardware-verified |
495
+ | `W-SSDP` | medium | hardware-verified |
496
+ | `W-IME-FOCUS` | high | hardware-verified |
497
+ | `W-IME-TEXT` | high | hardware-verified |
498
+ | `W-IME-LENGTH` | medium | hardware-verified |
499
+ | `W-YOUTUBE-SEARCH` | medium | hardware-verified |
500
+
501
+ ## License
502
+
503
+ MIT