dirigera-mcp
# dirigera-mcp
An MCP server that lets an MCP client (Claude Code) control IKEA TRÅDFRI smart plugs
("strömbrytare") paired to a DIRIGERA hub.
It talks to the hub over its **local** REST API (HTTPS on port 8443, bearer-token auth) using the
[`dirigera`](https://pypi.org/project/dirigera/) library. Nothing goes through IKEA's cloud.
| | |
|---|---|
| Transports | stdio, or streamable-http for sharing one hub connection across devcontainers |
| SDK | official Python MCP SDK (`mcp` 2.0.0, `MCPServer`) |
| Hub client | `dirigera` 1.2.7 |
| Server | [src/dirigera_mcp/server.py](src/dirigera_mcp/server.py) |
| Pairing helper | [scripts/get_token.py](scripts/get_token.py) |
---
## Setup
The hub issues a long-lived access token only after someone physically presses its action button.
That token then has to reach the container, and it does so from your **host** environment via
devcontainer `remoteEnv` — it is never committed, never written to a file in this repo, and never
logged.
Do these three steps in order.
### 1. Generate the token
In a terminal **inside the container**:
```bash
python scripts/get_token.py
```
The script prints the hub address, then tells you to press the action button (the small recessed
button on the underside of the hub). Press it, come back, hit ENTER, and the token is printed to
stdout. You have about 60 seconds.
If you are driving this non-interactively, use the countdown mode instead — no ENTER needed:
```bash
python scripts/get_token.py --wait 30
```
The address comes from `$DIRIGERA_HUB_IP`; pass `--ip <address>` if it is not set yet.
> **The token is printed once and is not saved anywhere.** Copy it now. If you lose it, just run
> the script again — pairing again is harmless and does not invalidate other tokens.
>
> Prefer running this in your own VS Code terminal rather than having Claude run it, so the
> credential does not end up in a chat transcript.
### 2. Put it in your HOST user environment
Not in the container, and **not in a `.env` file** — the whole point of the setup is that the
credential lives on the host and is injected at runtime.
On Windows, in a **PowerShell** window on the host:
```powershell
[Environment]::SetEnvironmentVariable("DIRIGERA_TOKEN", "<paste-the-token>", "User")
[Environment]::SetEnvironmentVariable("DIRIGERA_HUB_IP", "<your-hub-ip>", "User")
```
On macOS/Linux hosts, put the two `export` lines in your shell profile (`~/.zshrc`, `~/.bashrc`)
and make sure VS Code is launched from a shell that has sourced it.
`DIRIGERA_HUB_IP` is required. It has no default in the source: a baked-in fallback would be one
specific person's LAN address. It is also what the sandbox firewall opens its single exception for,
so the same value drives both.
### 3. Restart VS Code and reopen the container
First confirm the values really persisted. In PowerShell — this reads the stored User-scope value
directly, so it works even in the window that just set it:
```powershell
[Environment]::GetEnvironmentVariable("DIRIGERA_TOKEN", "User").Length # expect ~400, not 0
[Environment]::GetEnvironmentVariable("DIRIGERA_HUB_IP", "User") # expect your hub address
```
Then restart VS Code. A Windows process inherits its environment at launch and never sees User
variables created afterwards, so a window reload — and even a container rebuild — is not enough:
the value comes from the running VS Code process, not from the container.
1. **Quit every VS Code instance**, including windows holding unrelated projects. VS Code runs one
shared main process on Windows and new windows inherit its environment, so a single surviving
window is enough to keep the stale environment alive. `code .` from a fresh shell does not help
either — it just signals the existing instance.
2. Confirm nothing survived: `Get-Process code -ErrorAction SilentlyContinue` should print nothing.
If processes linger without visible windows, `... | Stop-Process` (save your work first).
3. Start VS Code again, open this folder, and **Reopen in Container**.
No rebuild is needed. Closing the last window stops the container (`shutdownAction` defaults to
`stopContainer`), and the next attach re-reads `remoteEnv`.
Verify **inside the container**, in a bash terminal (this is bash syntax; it silently prints empty
values if you run it in PowerShell):
```bash
echo "hub=$DIRIGERA_HUB_IP token_length=${#DIRIGERA_TOKEN}"
```
A non-zero `token_length` means the token arrived. Then restart Claude Code so it picks up the
`dirigera` server from `.mcp.json`, and ask it to list your outlets.
### Troubleshooting
**The variables exist in the container but are empty** (`env | grep DIRIGERA` shows both names with
no values). `remoteEnv` is working; `${localEnv:...}` resolved to nothing. That means VS Code was
started before the host variables were created — quit it fully and relaunch, as above.
**Still empty after a full restart.** Check whether VS Code is reading the Windows environment at
all: if `/home/vscode/.gitconfig-host` exists and is non-empty, `${localEnv:USERPROFILE}` resolved,
so the mechanism works and only the DIRIGERA values are missing. If that file is missing, VS Code is
resolving `localEnv` somewhere else — typically because the window was opened through Remote-WSL,
where Windows User variables are not visible unless forwarded via `WSLENV`. Set the two variables
inside WSL instead, or open the folder as a Windows path.
**The tools work but the plug does not switch.** Check `is_reachable` in the result. The hub accepts
writes for offline devices, so the server flags this with a `warning` field rather than reporting a
state it cannot confirm.
---
## MCP tools
| Tool | Arguments | Returns |
|---|---|---|
| `list_outlets` | — | every outlet: `id`, `name`, `room`, `is_on`, `is_reachable` |
| `get_outlet` | `outlet_id` | current state of one outlet, read fresh from the hub |
| `set_outlet` | `outlet_id`, `on` (bool) | new state, plus `previous_is_on` |
| `toggle_outlet` | `outlet_id` | new state, plus `previous_is_on` |
| `power_cycle` | `outlet_id`, `off_seconds` (0.5–300, default 5) | state after power is restored |
`power_cycle` switches the outlet off, waits, and switches it back on — for rebooting hardware such
as a development board. Power is restored on every path out of the wait, and the call refuses
outright on an unreachable outlet rather than risk cutting power it cannot restore. The call blocks
for `off_seconds`, so keep it well inside the client's tool-call timeout.
`set_outlet` and `toggle_outlet` re-read the outlet from the hub after writing, so the state they
report is the hub's, not an optimistic guess. If the hub reports the plug as unreachable, the result
carries a `warning` field — the hub accepts writes for offline devices, so a successful call is not
by itself proof that the plug switched.
Every failure comes back as a plain sentence, not a stack trace:
| Situation | What the client sees |
|---|---|
| `DIRIGERA_TOKEN` empty | "DIRIGERA_TOKEN is not set in this container's environment…" + how to fix |
| Token wrong/expired (401/403) | "The hub rejected the token…" + how to regenerate |
| Hub off or wrong IP | "Cannot reach the DIRIGERA hub at `<ip>`:8443…" |
| Unknown outlet id | "Device id not found. Call list_outlets to get the ids…" |
| Id belongs to a lamp, not a plug | "Device is not an outlet. Call list_outlets…" |
---
## Registration
The server is registered in the repo's [.mcp.json](.mcp.json) alongside the other project servers:
```json
"dirigera": {
"type": "stdio",
"command": "/workspaces/ikea-mcp/.venv/bin/python",
"args": ["/workspaces/ikea-mcp/src/dirigera_mcp/server.py"],
"env": {
"DIRIGERA_HUB_IP": "${DIRIGERA_HUB_IP:-}",
"DIRIGERA_TOKEN": "${DIRIGERA_TOKEN:-}"
}
}
```
Absolute paths, because the client chooses the working directory. The `:-` defaults let the server
start even with the variables unset, so an unconfigured setup produces a helpful tool error instead
of a server that fails to launch.
The server is a single module run directly by the venv interpreter — the project is not installed
as a package, so there is no build step and `uv sync` is all that is needed.
---
## Sharing one server with other devcontainers
An MCP stdio server is spawned as a child process by its client, so Claude Code running inside
devcontainer X always starts the server *inside* X. Installing it on the host does nothing for a
containerised client. To use one hub connection from several projects, run the server over HTTP.
The payoff: the hub token and the firewall's LAN exception exist in exactly one place.
Project containers get neither — they only reach `host.docker.internal`.
### Run it on the host
`docker compose` from [deploy/](deploy/), with both secrets in the shell's environment:
```powershell
$env:DIRIGERA_MCP_KEY = python -c "import secrets; print(secrets.token_urlsafe(32))"
[Environment]::SetEnvironmentVariable("DIRIGERA_MCP_KEY", $env:DIRIGERA_MCP_KEY, "User")
cd deploy
docker compose up -d --build
```
`DIRIGERA_MCP_KEY` is the shared secret between the server and every client. Without it the server
refuses to start over HTTP — an unauthenticated endpoint here can cut power to whatever is plugged
in.
To run the published image instead of building locally — no clone needed on the host:
```powershell
docker run -d --name dirigera-mcp --restart unless-stopped `
-p 127.0.0.1:8765:8765 `
-e DIRIGERA_HUB_IP -e DIRIGERA_TOKEN -e DIRIGERA_MCP_KEY `
ghcr.io/david-s-svedberg/ikea-mcp:latest
```
[.github/workflows/publish-image.yml](.github/workflows/publish-image.yml) builds and publishes that
image on every push to `main`, using the workflow's built-in `GITHUB_TOKEN`. The image holds no
credentials; both secrets arrive as runtime environment.
### Point a project at it
In that project's `.mcp.json`:
```json
"dirigera": {
"type": "http",
"url": "http://host.docker.internal:8765/mcp",
"headers": { "Authorization": "Bearer ${DIRIGERA_MCP_KEY}" }
}
```
The project's devcontainer needs `DIRIGERA_MCP_KEY` in `remoteEnv` (same host-variable flow as the
token above) and must allow egress to `host.docker.internal`. Under the firewall pattern in
[.devcontainer/init-firewall.sh](.devcontainer/init-firewall.sh) that is already covered by the
`192.168.65.0/24` carve-out for Docker Desktop's internal services. No LAN exception is needed.
### Security properties
| Check | Behaviour |
|---|---|
| Missing or wrong `Authorization` | `401`, constant-time comparison so the secret does not leak byte by byte |
| Forged `Host` header | `421`, DNS-rebinding protection with an explicit allow-list |
| Published port | host loopback only, so the endpoint is not on the LAN |
| Image contents | no credentials; both secrets arrive as runtime environment |
Verified locally over HTTP: 401 without and with a wrong secret, 421 on a forged Host header, and a
working session against the real hub with the correct one.
**Loopback publishing is enough** — verified on Docker Desktop for Windows. With the port published
as `127.0.0.1:8765:8765`, a devcontainer resolves `host.docker.internal` to the Docker Desktop
gateway (`192.168.65.254`), which proxies through to the host's loopback and reaches the server. The
port stays off the LAN while remaining reachable from containers.
If a different Docker setup cannot do that, publish on all interfaces (`"8765:8765"`) and block the
port from outside in the host firewall, or put the server and the project container on a shared
user-defined Docker network and address it by container name.
## Sandbox notes
This devcontainer is hardened (see [.devcontainer/init-firewall.sh](.devcontainer/init-firewall.sh)):
egress to RFC1918 space is rejected, with exactly one exception for `$DIRIGERA_HUB_IP:8443`.
No LAN address is committed anywhere in this repo. The firewall script takes the hub address as an
argument, which `devcontainer.json` fills from `remoteEnv`, and the Python side reads the same
variable. Renumbering the hub therefore means changing one value in your host environment. If
`DIRIGERA_HUB_IP` is unset the script falls back to RFC 5737 documentation space, so it fails closed:
the exception points at an address nothing answers on, and the real hub stays blocked.
Interactive `sudo` is disabled. System packages belong in
[.devcontainer/Dockerfile](.devcontainer/Dockerfile), followed by a container rebuild.
The hub serves a self-signed certificate, so the `dirigera` library disables TLS verification. That
is the library's own behaviour and acceptable here: the connection is to a single pinned LAN IP that
the firewall restricts to one port.
## Development
```bash
uv sync # install locked deps into .venv
ruff check src scripts # lint
```
TDQS
Scored across 5 tools
Each tool has a clearly distinct purpose: listing all outlets, reading one outlet's state, setting on/off, toggling, and power-cycling. No two tools overlap in function; the descriptions make the boundaries obvious.
All tool names follow the verb_noun pattern with imperative verbs (list, get, set, toggle, power_cycle) and consistent use of underscores. The noun is 'outlet' where relevant, maintaining a predictable style.
Five tools is well-scoped for a smart-plug controller. Each tool addresses a real need (discovery, state read, state write, state flip, and a power-cycle sequence) without unnecessary bloat or missing essentials.
For the domain of outlet control, the surface is complete: you can enumerate, inspect, set, toggle, and power-cycle outlets. There are no obvious gaps, as outlets are paired through the hub rather than created or deleted via API.