mcp-server-sofabaton
An MCP server for Sofabaton X1, X1S and X2 universal remote hubs. It lets an MCP client such as Claude start and stop activities, press remote buttons, send macros, favorites and device commands, find the remote, and read the presses of buttons you've bound to it.
All three models work through sofabaton-x-server. An X2 can also be reached over MQTT, either on its own or alongside the server. That adds live activity state, and keeps the hub controllable while the Sofabaton app is open. Every response tells the model which model it's talking to and what that hub can and can't do.
Status: alpha, looking for testers. Tested on one X2 over MQTT (see Tested on). The sofabaton-x-server path, and so every X1 and X1S, has run only against the simulator. If you have a Sofabaton hub, a report of what does and doesn't work on yours is the most useful thing you can send: open a hardware report.
Not affiliated with Sofabaton.
Tested on
| Hub | One Sofabaton X2, over MQTT only (no sofabaton-x-server). Firmware unknown: MQTT doesn't report it |
| Broker | Home Assistant's Mosquitto add-on |
| Server | Alpine Linux in a Proxmox LXC, Python 3.14, as the OpenRC service in deploy/alpine/ |
| Client | Claude, over Streamable HTTP through a Cloudflare Tunnel with Cloudflare Access |
| Setup | 5 activities, 6 devices, 1 macro, 7 favorites (on the TV and on a Home Assistant Wifi Device) |
Seen working on that X2 (2026-10-16): listing activities, devices, buttons, macros and
favorites; starting an activity and powering it off, with the hub's confirmation; hard buttons
(VOL_UP); a macro; favorites on two different devices; holding presses until the power macro
has settled (about 5 s here, inside the 6 s window); and presses of a button bound to a Wifi
Device arriving on <MAC>/up.
Seen not working, and handled: the X2 never answers a request for a device's own command list over MQTT, so device commands over MQTT are refused up front with what would enable them (sofabaton-x-server). And a burst of a dozen or so unanswered requests left that X2 still publishing state but answering nothing until it was power-cycled; nothing in the server sends requests like that.
Not tested on hardware yet: the X1 and X1S, sofabaton-x-server with any hub, an X2 on both paths at once, more than one hub, brokers other than Mosquitto, and other firmware. Each detail the code relies on is a named assumption with its source and status in PROTOCOL.md; HARDWARE_VALIDATION.md is the checklist for confirming them on your hub.
What works with which hub
| X1 / X1S | X2 + server | X2 + server + MQTT | X2, MQTT only | |
|---|---|---|---|---|
| Activities, buttons, macros, favorites | yes | yes | yes | yes |
| A device's own commands | yes | yes | yes (not while the app is open) | no: the X2 doesn't list them over MQTT |
| Find the remote; model and firmware | yes | yes | yes | no |
| Presses of buttons bound to a Wifi Device | yes (HTTP) | yes | yes | yes (MQTT) |
| Live activity state (pushed by the hub) | no | no | yes | yes |
| Control while the Sofabaton app is open | no | no | yes: falls back to MQTT | yes |
| Needs | server | server | server + broker | broker + the MAC |
MQTT is X2-only. The X1 and X1S have no MQTT; on those models, the server is the only way in
and everything in the first three rows works through it. get_status reports this as
model, capabilities and limitations, so the model can plan around it. For example, on an
X1S it says "This is an X1S: live activity state, control while the Sofabaton app is open,
and MQTT presses need an X2."
How it reaches the hub
Claude ──► mcp-server-sofabaton ──┬──► sofabaton-x-server ──► hub (X1, X1S, X2)
(picks a path per call) └──► MQTT broker ◄────────► hub (X2 only)
- Through sofabaton-x-server (any model). Sofabaton publishes no API. The sofabaton-x library reverse-engineers it by sitting between the hub and the official app as a proxy, so one process owns the hub at a time. sofabaton-x-server is that process, with a REST API. Going through it means many clients can share the hub. It also means a model can only reach the control routes: editing, erasing and restoring the hub need a token this project never holds.
- Over MQTT (X2). The X2 keeps its own connection to the broker you set in the Sofabaton app (Me → Connect to Home Assistant). It answers list requests and takes commands on topics under its MAC, and it announces every activity change. This doesn't go through the proxy, so it keeps working while the app holds it.
With both configured, each call uses the server when it's in control mode and MQTT otherwise. Names are resolved on whichever path is used, so ids never cross from one to the other.
Install
python -m venv ~/.venvs/sofabaton && . ~/.venvs/sofabaton/bin/activate
pip install "mcp-server-sofabaton[mqtt]" # or without [mqtt] for server-only (X1/X1S, or an X2 without MQTT)
From source instead (for the newest code, or to run the tests):
git clone https://github.com/SDNick484/mcp-server-sofabaton.git && cd mcp-server-sofabaton
python -m venv .venv && . .venv/bin/activate
pip install -e ".[mqtt,dev]" && pytest
Requires Python 3.11+. The [mqtt] extra adds one dependency, aiomqtt (which brings
paho-mqtt).
Claim sofabaton-x-server. Open its control panel and set up an admin account (Server
settings > Access). Until you do, it lets anyone on the LAN edit and erase hubs. doctor warns
while it's unclaimed.
Configuration
Environment variables (an MCP client may not pass your shell's environment, so set them in the client's config):
| Setting | Default | What it does |
|---|---|---|
SOFABATON_URL |
http://localhost:8480 |
sofabaton-x-server's address. none for MQTT only |
SOFABATON_HUB |
(the only hub) | Hub id or name, when the server manages more than one |
SOFABATON_MQTT_URL |
(off) | mqtt://[user:pass@]host[:1883] or mqtts://... (X2 only) |
SOFABATON_MQTT_USERNAME |
If not in the URL | |
SOFABATON_MQTT_PASSWORD |
If not in the URL | |
SOFABATON_MQTT_PASSWORD_FILE |
Read the password from a file (wins over the others) | |
SOFABATON_MQTT_MAC |
(from the server) | The X2's MAC. Needed for MQTT only; with a server it's learned |
SOFABATON_MQTT_SETTLE_S |
6 |
After an activity change over MQTT, hold presses off this long |
SOFABATON_DRY_RUN |
off | Read the hub, send nothing that changes anything (serve --dry-run too) |
SOFABATON_LOG_UNREDACTED |
off | Don't mask IPs and MACs in logs (--no-redact); passwords and other secrets stay masked |
There is deliberately no sofabaton-x-server token setting. Bad values are reported by name at
startup and by doctor, rather than failing later.
Typical setups:
# X1 / X1S, or an X2 without MQTT
SOFABATON_URL=http://192.168.1.70:8480
# X2, both paths (recommended)
SOFABATON_URL=http://192.168.1.70:8480
SOFABATON_MQTT_URL=mqtt://sofabaton@192.168.1.10:1883
SOFABATON_MQTT_PASSWORD_FILE=/etc/mcp/sofabaton-mqtt.pw
# X2, MQTT only (no sofabaton-x-server)
SOFABATON_URL=none
SOFABATON_MQTT_URL=mqtt://sofabaton:secret@192.168.1.10:1883
SOFABATON_MQTT_MAC=02:AB:34:CD:56:EF
First contact
mcp-server-sofabaton doctor # each layer, with a hint for whatever fails
mcp-server-sofabaton check # the model, the path, capabilities, and the names the model will use
mcp-server-sofabaton call start_activity activity="Watch Shield" # one tool, as the model calls it
doctor checks the server (reachable, version, claimed, hub session, mode, identity,
catalog) and MQTT (login, the X2 answering on its topics). If the X2 doesn't answer on the
uppercase-MAC topics, it tries lowercase and tells you. doctor --listen 30 prints everything
the X2 publishes while you press keys on the remote. doctor --dump DIR saves a redacted
capture for tests/fixtures/recorded/.
Use it with an MCP client
Local (stdio): Claude Code and Claude Desktop launch it themselves.
{
"mcpServers": {
"sofabaton": {
"command": "/path/to/mcp-server-sofabaton/.venv/bin/mcp-server-sofabaton",
"env": {
"SOFABATON_URL": "http://192.168.1.70:8480",
"SOFABATON_MQTT_URL": "mqtt://sofabaton:secret@192.168.1.10:1883"
}
}
}
}
For Claude Code: claude mcp add sofabaton -e SOFABATON_URL=http://192.168.1.70:8480 -- /path/to/.venv/bin/mcp-server-sofabaton.
As a service (Streamable HTTP), to share one instance between Claude Code, Claude Desktop and the mobile app through a Cloudflare Tunnel with Cloudflare Access in front:
CF_ACCESS_TEAM_DOMAIN=<team>.cloudflareaccess.com CF_ACCESS_AUD=<aud tag> \
mcp-server-sofabaton serve --http --public-host mcp.example.com # 127.0.0.1:8714/sofabaton/mcp
Every request must carry a valid Access JWT (Cf-Access-Jwt-Assertion). The checks and the
flags are documented in src/sofabaton_mcp/remote.py. The Host-header (DNS-rebinding) checks stay on
even with --bind 0.0.0.0: for LAN clients, also pass --public-host <this machine's LAN IP>.
As an OpenRC service on Alpine (e.g. a Proxmox LXC)
deploy/alpine/ installs it the same way as mcp-server-webos and -harmony, so all three can share
an LXC: a venv in /opt/mcp-server-sofabaton (with the [mqtt] extra), a mcp-sofabaton system
user, every setting in /etc/mcp-server-sofabaton/env, and supervise-daemon restarting it if
it exits. Keep the broker password out of the env file: put it in
/etc/mcp-server-sofabaton/mqtt-password (0640 root:mcp-sofabaton) and set
SOFABATON_MQTT_PASSWORD_FILE to that path.
git clone https://github.com/SDNick484/mcp-server-sofabaton.git && cd mcp-server-sofabaton
sh deploy/alpine/install.sh # as root; re-run to upgrade
vim /etc/mcp-server-sofabaton/env # SOFABATON_URL and/or SOFABATON_MQTT_*, Access
su -s /bin/sh mcp-sofabaton -c '/opt/mcp-server-sofabaton/run.sh doctor'
rc-update add mcp-server-sofabaton default && rc-service mcp-server-sofabaton start
Because every setting is an environment variable, run.sh takes a subcommand and runs it with
exactly the service's environment: run.sh doctor, run.sh check, run.sh call get_status.
Logs are in /var/log/mcp-server-sofabaton/server.log. CI runs deploy/alpine/smoke-test.sh in a
stock python:3.12-alpine container: install, the test suite on musl, run.sh doctor, the
service under OpenRC, and an MCP call over HTTP reaching a simulated X2 on both paths.
Tools
| Tool | What it does |
|---|---|
get_status |
Start here: model, running activity, via (server or MQTT), capabilities, limitations, transition |
list_activities |
Activities, marking the running one |
list_devices |
Devices (brand and class over the server; names only over MQTT) |
list_commands |
Named commands for an activity (macros, favorites) or a device (default: the running activity) |
start_activity |
Start an activity and wait for the hub to confirm; unchanged if it's already running |
power_off |
Power off the running activity and confirm |
press_button |
A remote hard button (VOL_UP, PAUSE, OK, ...) 1-10 times, through the running activity |
send_command |
A macro, favorite or device command by name, 1-10 times |
find_remote |
Make the remote beep (needs the server) |
get_recent_presses |
Presses of remote buttons bound to a Wifi Device: the remote as an input to the agent |
Actions return {outcome: done | unchanged | dry_run, detail, via, sent}. sent lists the
exact requests or publishes, with the MAC shown as <MAC>. When something can't work on this
hub, the error says why and what would enable it.
A session against the simulator
An X2 with both paths. Someone opens the Sofabaton app partway through, and the server
switches to MQTT. (Output from simulate, trimmed.)
> get_status
{"model": "X2", "via": "server", "running_activity": null, "transition": null,
"capabilities": ["catalog", "activities", "buttons", "commands", "presses", "find_remote",
"hub_info", "live_activity_state", "control_while_app_open"],
"limitations": [], ...}
> start_activity {"activity": "Watch Shield"}
{"outcome": "done", "detail": "Start Watch Shield", "via": "server",
"sent": ["POST /api/v1/hubs/a1b2c3/activities/101/start"]}
> press_button {"button": "VOL_UP", "repeat": 3}
{"outcome": "done", "detail": "Press VOL_UP x3 on activity Watch Shield", "via": "server", ...}
# (someone opens the Sofabaton app: sofabaton-x-server goes to observe mode)
> start_activity {"activity": "Listen to Music"}
{"outcome": "done", "via": "mqtt",
"detail": "Start Listen to Music. The X2 announced it early; its power macro may run for a
few more seconds (presses wait 6s).",
"sent": ["publish activity/<MAC>/activity_control_down {\"data\": {\"activity_id\": 102, \"state\": \"on\"}}"]}
> send_command {"command": "Input HDMI 2", "target": "Onkyo Receiver"}
ERROR: The X2 just changed activity and its power macro may still be running; a press now
could interrupt it. Try again in 5s (SOFABATON_MQTT_SETTLE_S sets this window).
> get_status
{"model": "X2", "via": "mqtt", "running_activity": "Listen to Music",
"transition": "power macro may still be running (presses wait 5s more)",
"capabilities": ["catalog", "activities", "buttons", "commands", "presses", "hub_info",
"live_activity_state", "control_while_app_open"],
"limitations": ["The Sofabaton app holds sofabaton-x-server's proxy (observe mode): commands
go over MQTT meanwhile, and find_remote waits until the app is closed."], ...}
The same calls on an X1S go through the server, and limitations says the MQTT features need
an X2.
The remote as an input
A "Wifi Device" on the hub is a virtual device whose commands do nothing but report the press.
On the X2 it can report to <MAC>/up on your broker, and on any model it can report over HTTP
to sofabaton-x-server. Bind one to a key (say, a long press of a color button) and
get_recent_presses sees it. A remote button can then mean "ask the assistant" or "run my
bedtime routine" to an agent that watches for it. Wifi Devices are set up in the Sofabaton app
or the server's control panel: that's editing the hub, which this server doesn't do.
Safety design
- No token, so no edits. sofabaton-x-server lets anyone read and control, and requires a token for editing, deleting, erasing and restoring. This project never holds one.
- A local route allow-list too. The REST client can only make GETs and the four control
POSTs (
send, activitystart/stop,find-remote). Anything else raises before a request leaves the process.test_openapi_contract.pychecks that the allow-list matches exactly those routes in the server's published API. - MQTT topics are fixed. Only the topics in
protocol.pyare ever published to, with payloads built from names resolved against the hub's own lists. - Names, not ids. The model never supplies an id. Buttons are an enum.
POWER_ONandPOWER_OFFare left out, so starting and stopping always go through the tools that confirm. - Confirmation, not trust.
accepted: truemeans the hub took the frame. Starts and stops wait for the hub to report the change. - Don't interrupt a macro. Over MQTT the X2 announces a change at the start of its power macro and never says when it's done, so presses wait out a settle window.
- Rate limits and caps. Per call: at most 10 repeats, 100-2000 ms apart. Across calls (a model in a loop): 20 presses in a burst, then 4 a second; 4 activity changes in a burst, then one per 15 s. A call over the limit is refused whole, never half-sent.
- Dry run.
serve --dry-run(orcall --dry-run) reads the hub and reports what it would send. - Logs are redacted: IP addresses and MACs (unless
--no-redact), and always secrets (passwords in URLs, JWTs, bearer tokens, keys). They go to stderr; stdout is the MCP transport. - Every tool has a title and explicit MCP annotations. Nothing is destructive.
Troubleshooting
| Message | Meaning |
|---|---|
Can't reach sofabaton-x-server at ... |
It isn't running, or SOFABATON_URL is wrong for the environment the client launched us in |
... close the app on every phone and tablet |
The app holds the proxy (observe mode). On an X2, MQTT would keep it controllable |
has no session with the hub right now |
The hub is off the network, or its IP changed. Check the server's control panel |
didn't report it finished within 30s |
A long power macro, or a device didn't respond. Check get_status before retrying |
The X2 didn't answer the ... request over MQTT |
The X2 isn't linked to this broker, or the MAC is wrong. Run doctor |
the MQTT broker at ... rejected the login |
Wrong user or password in SOFABATON_MQTT_URL |
power macro may still be running |
Expected for a few seconds after a change over MQTT |
Rate limit: refusing to ... |
Many presses in a short time. Wait and retry if it was deliberate |
Development
pip install -e ".[dev]"
pytest -q # everything, no hardware: fakes for the server, the broker and the X2
ruff check . && ruff format --check . && mypy
The simulator runs the same fakes as real services, so you can point a client (or doctor)
at them:
mcp-server-sofabaton simulate # X2: fake server + broker + X2; type `help` at sim>
mcp-server-sofabaton simulate --model X1S # server only
mcp-server-sofabaton simulate --no-server # X2 over MQTT only
It prints the export lines for another terminal. At its sim> prompt you act as the
physical remote (off, start <activity>, key <device> <key>) or change the scene
(app on puts the server in observe mode).
The MQTT tests use an in-package broker by default. To run them against mosquitto, as CI does:
docker compose -f dev/compose.yaml up -d
MQTT_TEST_BROKER=mqtt://127.0.0.1:1883 pytest -q tests/test_mqtt.py tests/test_tools.py tests/test_tooling.py -rs
What the tests cover:
| File | Covers |
|---|---|
test_openapi_contract.py |
Our routes, types and the fake's payloads against the OpenAPI document recorded from sofabaton-x-server 0.2.4 |
test_client.py |
The server path for every model, capabilities, confirmation |
test_mqtt.py |
The X2 over MQTT, alone and with the server, against a real broker over sockets |
test_tools.py |
The MCP contract the model sees (schemas, annotations, structured results) |
test_safety.py |
Dry run, rate limits, caps, config validation, redaction |
test_tooling.py |
doctor, check, call and simulate, including as real processes |
test_recorded.py |
Replays captures from a real hub (doctor --dump) through the real parsers |
test_assumptions.py |
Every assumption appears here and in HARDWARE_VALIDATION.md |
Verification status
These are the protocol assumptions the code depends on. hardware-verified means seen on a real
hub; the rest are verified against the simulator only (details and sources in PROTOCOL.md):
| Assumption | Confidence | Status |
|---|---|---|
S-REST-API |
high | simulator-only |
S-REST-MODES |
medium | simulator-only |
S-REST-START |
low | simulator-only |
S-X1-NO-MQTT |
high | simulator-only |
S-MQTT-UP |
high | hardware-verified |
S-MQTT-STATE |
high | hardware-verified |
S-MQTT-MAC-CASE |
medium | hardware-verified |
S-MQTT-CONTROL |
medium | hardware-verified |
S-MQTT-KEYS |
medium | hardware-verified |
S-MQTT-MACRO |
medium | hardware-verified |
S-MQTT-FAVORITE |
low | hardware-verified |
S-MQTT-DEVICE |
high | simulator-only |
S-MQTT-LISTS |
medium | hardware-verified |
S-MQTT-DEVICE-KEYS |
high | hardware-verified |
S-MQTT-IDS |
low | simulator-only |
S-MQTT-SERIAL |
medium | simulator-only |
S-MQTT-REPLY-TIMEOUT |
low | hardware-verified |
S-MQTT-SETTLE |
low | hardware-verified |
test_assumptions.py fails if this table and assumptions.py disagree.
Roadmap
- Validate on hardware (HARDWARE_VALIDATION.md)
- Push instead of poll: surface activity changes and presses as MCP resource updates
- Publish to PyPI and the MCP registry
License
MIT. See LICENSE.
Metadata
Release files for mcp-server-sofabaton 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| mcp_server_sofabaton-0.2.0.tar.gz | 105.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| mcp_server_sofabaton-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 183.3 kB
Release files / mcp_server_sofabaton-0.2.0.tar.gz
| Download URL | mcp_server_sofabaton-0.2.0.tar.gz |
|---|---|
| Size | 105.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
d6ffe2125e495a0ea8b1fd04316e80a3bb86716728ae0586f57b7fe6e4dc99d2
|
|
BLAKE2b-256 checksum How to use checksums |
f9d4caa238ed0142641e6a0b507f9be6fda6eb16804f1db2b9bc074d783f14d7
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 17, 2026.
Transparency logRelease files / mcp_server_sofabaton-0.2.0-py3-none-any.whl
| Download URL | mcp_server_sofabaton-0.2.0-py3-none-any.whl |
|---|---|
| Size | 77.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
05f96918e7d8bb946c0d86e6205a3e9b3c499f6aacc9db28223b1a54932c7555
|
|
BLAKE2b-256 checksum How to use checksums |
5299c6134f8a7e11158968f6b017be92405c9fa802a8c4c8ff4a917e1d2fd80d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 17, 2026.
Transparency log