kef-mcp
by nqrwhal
README.md
# kef-mcp
An **MCP server** for controlling **KEF wireless speakers** (LSX II / LS50 Wireless II
/ LS60) and playing **Spotify** on them. Exposes tools any MCP client can call to
power the speaker on/off, switch inputs, set volume, control transport, and start
Spotify playback — with one combined tool that does the whole "wake → switch to
wifi → play" flow.
Built to be driven by [Poke](https://poke.com) (see [`poke/`](./poke)), but it's a
standard MCP server (streamable HTTP) usable by any MCP client.
```
MCP client ──► kef-mcp ──┬─ HTTP ─► KEF speaker (LAN)
└─ HTTPS ► Spotify Web API
```
## Tools
| Tool | What it does |
|------|--------------|
| `play_on_kef(query, volume?)` | **Main one.** Wake KEF → source=wifi → find on Spotify → play on KEF. |
| `kef_power_on` / `kef_standby` | Power on / off. |
| `kef_set_source(source)` | wifi, bluetooth, tv, optic, coaxial, analog. |
| `kef_set_volume(level)` / `kef_get_volume` | Hardware volume 0-100. |
| `kef_play_pause` / `kef_next` / `kef_previous` | Transport control. |
| `kef_status` | Power, source, volume, now playing. |
| `spotify_pause` / `spotify_skip` | Spotify-side control. |
| `spotify_set_volume(level)` | Volume on the active Spotify device. |
| `spotify_now_playing` | Track / artist / device. |
| `cast_url(url, volume?)` | Cast a direct audio/radio stream URL (non-Spotify). |
| `play_radio(query_or_url, volume?)` | Internet radio via a direct stream URL. |
| `play_from_youtube(query, volume?)` | Play YouTube/SoundCloud audio (search or URL). |
| `cast_stop` / `cast_pause` / `cast_resume` | Cast transport control. |
| `cast_status` | What's playing via Cast. |
### Playing non-Spotify audio (Google Cast)
The KEF has Chromecast built-in. The `cast_*` / `play_radio` / `play_from_youtube`
tools cast an HTTP(S) audio URL to the speaker (it pulls the stream itself and
auto-wakes to wifi, like Spotify Connect). `play_from_youtube` uses `yt-dlp` to
turn a query or page URL into a stream URL first.
- **Same-LAN requirement:** the server reaches the speaker's Cast channel on
**TCP 8009** by IP (no mDNS). In Docker this works over the same bridge the
KEF HTTP API already uses — but confirm 8009 is reachable from the container.
- **YouTube can break:** site changes occasionally break extraction. Fix =
bump `yt-dlp` in `requirements.txt` and rebuild the image.
- **UPnP/DLNA does NOT work** on this hardware — the speaker firmware blocks
`SetAVTransportURI`, so generic DLNA casting fails. Google Cast is the
supported non-Spotify path.
## Prerequisites
- **Spotify Premium** (the Web API blocks playback control on Free accounts).
- A KEF LSX II / LS50 Wireless II / LS60 on your LAN, ideally with a **DHCP
reservation** so its IP is stable.
- Docker (to run the server), or just Python 3.12+ to run it directly.
## Setup
### 1. Configure
```bash
cp .env.example .env
```
Fill in `.env`:
- `KEF_HOST` — the speaker's LAN IP (from the KEF Connect app or your router).
- `KEF_DEVICE_NAME` — substring of its Spotify Connect name (e.g. `KEF`).
- `SPOTIFY_CLIENT_ID` / `SPOTIFY_CLIENT_SECRET` — from a
[Spotify app](https://developer.spotify.com/dashboard) (Redirect URI:
`http://127.0.0.1:8888/callback`).
- `MCP_AUTH_TOKEN` — a random string clients must send as a Bearer token.
Generate: `python -c "import secrets; print(secrets.token_urlsafe(32))"`
### 2. Get a Spotify refresh token (once)
```bash
pip install requests
export SPOTIFY_CLIENT_ID=... # PowerShell: $env:SPOTIFY_CLIENT_ID="..."
export SPOTIFY_CLIENT_SECRET=...
python auth_spotify.py
```
Log in, approve, and copy the printed `SPOTIFY_REFRESH_TOKEN=...` line into `.env`.
### 3. Run
**Docker (recommended):**
```bash
docker compose up -d --build
docker compose logs -f kef-mcp
```
> `docker-compose.yml` also runs a `poke-tunnel` sidecar that forwards the MCP
> server up to your Poke agent (see [`poke/README.md`](./poke/README.md)). Set
> `POKE_TUNNEL_TOKEN` in `.env` to enable it (run `npx poke@latest login` once to
> get it); it's only needed for Poke.
**Or directly:**
```bash
pip install -r requirements.txt
python server.py # serves streamable HTTP at http://0.0.0.0:8000/mcp
```
### 4. Connect a client
- **Poke:** set `POKE_TUNNEL_TOKEN` in `.env` (from `npx poke@latest login`) and
run `docker compose up -d` — the `poke-tunnel` container connects it
automatically. Full guide + the recipe: [`poke/README.md`](./poke/README.md).
- **Other MCP clients:** point them at `http://<host>:8000/mcp`. (If you expose the
server beyond the tunnel, set `MCP_AUTH_TOKEN` and send
`Authorization: Bearer <token>`.)
## Sanity checks
KEF reachable from the container:
```bash
docker compose exec kef-mcp python -c \
"import asyncio, os; from kef_client import KefClient; \
print(asyncio.run(KefClient(os.environ['KEF_HOST']).get_status()))"
```
Should print the speaker status (e.g. `standby` / `powerOn`).
## Notes & troubleshooting
- **"KEF Spotify Connect device not found"** — the KEF only advertises as a Connect
target once it's awake and has played Spotify since boot. `play_on_kef` wakes it
first; on a cold boot, cast to it once from the Spotify app, then retry.
- **Spotify 404 / "No active device"** — same cause; ensure `KEF_DEVICE_NAME`
matches the name Spotify shows.
- **Container can't reach the KEF** — verify `KEF_HOST` and that the speaker has a
DHCP reservation.
- **KEF API** — writes are `POST /api/setData` (JSON body), reads are
`GET /api/getData`. Verified against LSX II / LS50 W II / LS60 firmware. Very old
firmware used GET-only; update firmware if writes fail.
## Project layout
```
kef-mcp/
├── server.py # MCP server: defines the tools
├── kef_client.py # KEF local HTTP API client
├── spotify_client.py # Spotify Web API client
├── cast_client.py # Google Cast (Chromecast built-in) client
├── youtube_resolver.py # yt-dlp: query/URL -> direct stream URL
├── auth_spotify.py # one-time Spotify OAuth helper
├── Dockerfile
├── docker-compose.yml # server + poke-tunnel sidecar
├── .env.example
├── tunnel/ # headless poke-tunnel container
│ ├── Dockerfile
│ └── entrypoint.sh
└── poke/ # Poke-specific: connection guide + recipe
├── README.md
└── RECIPE.md
```
## Security
- `.env` holds your Spotify secret/refresh token and auth token — gitignored, never
commit it.
- Anyone with `MCP_AUTH_TOKEN` + the URL can control your speakers and Spotify. Keep
it secret; consider a Cloudflare Access policy for remote exposure.
## Credits
KEF local-API payload formats verified against
[pykefcontrol](https://github.com/N0ciple/pykefcontrol) (MIT).
This server cannot be deployed
Maintenance
ActivityInactive
ResponsivenessNo issues