Skip to main content
Glama
README.md
# Loxone MCP Proxy

A tiny, **zero-dependency** local proxy that gives Claude Desktop (or any
stdio-based MCP client) a **stable** connection to a **Loxone Miniserver's
native MCP Server** β€” with a fully **headless, credential-based login** (no
browser, no manual OAuth click).

> Loxone Gen 2 Miniservers (firmware **17.1+**) can run a built-in **MCP Server**
> plugin. Connecting a *local* MCP client to it normally has two pain points that
> this proxy solves for you.

## Why this exists

1. **The Miniserver's MCP URL is not stable.** Loxone publishes the endpoint
   through a Cloud "connect" service that **redirects to a relay address that
   rotates over time** (it changes on Miniserver restarts and relay reshuffles).
   If you hard-code the redirected `…dyndns.loxonecloud.com:PORT/mcp` URL into a
   client, it breaks a while later.
2. **Auth is interactive by default.** The MCP endpoint speaks OAuth 2.1 and the
   official flow pops a browser login. That's awkward for an always-on client and
   impossible for a headless setup.

**This proxy** exposes a normal local **stdio** MCP server to Claude, and on the
other side it:

- resolves the **stable** `connect.loxonecloud.com/<serial>/mcp` entry point to
  the **current** relay, and **re-resolves automatically** whenever the relay
  moves or dies (so your Claude connection just keeps working);
- performs the **OAuth login headlessly** using a Loxone username/password from a
  local `.env` (it drives the Miniserver's HTML login form directly β€” no browser),
  then caches and refreshes the token;
- proxies the MCP protocol (streamable HTTP ⇄ stdio) transparently.

## Features

- πŸ”Œ **Stable** β€” hides the rotating relay address behind automatic re-resolution.
- πŸ€– **Headless** β€” logs in from `.env` credentials, no browser, no clicking.
- ♻️ **Self-healing** β€” re-resolves the relay and refreshes/re-issues tokens on
  failure, and transparently re-initializes a dropped session.
- πŸͺΆ **Zero runtime dependencies** β€” plain Node.js (β‰₯ 20). Nothing to build.
- πŸ”’ **Least-privilege friendly** β€” log in as a dedicated, restricted Loxone user;
  tokens are cached with `0600` permissions.

## How it works

```
Claude Desktop  ──stdio(JSON-RPC)──▢  loxone-mcp-proxy  ──HTTPS(streamable MCP)──▢  Loxone relay ──▢ Miniserver
                                          β”‚
                    1) GET connect.loxonecloud.com/<serial>/mcp β†’ 307 β†’ current relay "entry"
                    2) headless OAuth on the entry:
                         register client β†’ GET authorize (HTML login form)
                         β†’ POST username+password (+hidden fields) β†’ ?code=…
                         β†’ exchange code (PKCE) β†’ access + refresh token
                    3) speak MCP to the entry with the Bearer token
                    4) on relay death / 401 / lost session β†’ re-resolve / refresh / re-init
```

A subtle but important detail: the OAuth **`resource`** advertised by the server
is only the token **audience identifier** β€” it frequently points at a *different,
sometimes unreachable* relay. MCP traffic is therefore sent to the **reachable
connect-resolved entry**, not to `resource`. The proxy handles this for you.

## Requirements

- **Node.js β‰₯ 20** (uses the built-in `fetch`, no packages to install).
- A **Loxone Gen 2 Miniserver, firmware 17.1+**, with the **MCP Server** plugin
  added and running (Loxone Config β†’ Network Periphery β†’ *MCP Server*).
- A **Loxone user account** for the assistant. Create a **dedicated** one with the
  minimum rights it needs (ideally read-only to start).

## Setup

### 1. Get the code

```bash
git clone https://github.com/ivantichy/loxone-mcp-proxy.git
cd loxone-mcp-proxy
# no "npm install" needed β€” there are no dependencies
```

### 2. Configure credentials

```bash
cp .env.example .env
```

Edit `.env`:

```ini
LOXONE_SERIAL=504F94A1AE7A     # your Miniserver serial (MAC without colons)
LOXONE_USER=mcp                # a DEDICATED, minimal-rights Loxone user
LOXONE_PASSWORD=your-password
```

**Finding your serial:** open `http://<miniserver-ip>/jdev/cfg/api` in a browser
and read the `snr` field (e.g. `50:4F:94:A1:AE:7A` β†’ `504F94A1AE7A`), or read it
in Loxone Config.

### 3. Verify it works

```bash
npm run login-check
```

Expected:

```
OK  Authenticated & connected.  Server: MCP Server 1.0.0
OK  11 tools available: control_find, control_command, system_status, ...
All good - this configuration works in Claude Desktop.
```

If this fails, see [Troubleshooting](#troubleshooting).

### 4. Add it to Claude Desktop

Open **Settings β†’ Developer β†’ Edit Config** and add a server that runs this proxy
over stdio. Point it at `src/index.js` with an absolute path:

```jsonc
{
  "mcpServers": {
    "loxone": {
      "command": "node",
      "args": ["/absolute/path/to/loxone-mcp-proxy/src/index.js"]
    }
  }
}
```

On **Windows**, escape the backslashes:

```jsonc
{
  "mcpServers": {
    "loxone": {
      "command": "node",
      "args": ["C:\\path\\to\\loxone-mcp-proxy\\src\\index.js"]
    }
  }
}
```

Credentials are read from the project's `.env`, so you don't put secrets in the
Claude config. Fully **quit and reopen** Claude Desktop, then ask it something
like *"what's on in Loxone?"*.

## What Claude can do

The tools come from your Miniserver's MCP plugin and act with **your Loxone
user's permissions**. Typical set:

| Tool | Type | Purpose |
|------|------|---------|
| `control_find`, `control_describe` | read | discover controls, rooms, categories |
| `control_state`, `system_status` | read | read current values / installation status |
| `control_history`, `control_statistics` | read | historical data & aggregates |
| `weather` | read | Loxone weather data |
| `control_command`, `control_secured_command` | write | operate controls (lights, blinds, scenes…) |
| `code_run`, `sdk_describe` | advanced | scripted access (use with care) |

To keep the assistant **read-only**, either restrict the Loxone user's rights or
disable the write tools in your MCP client's connector settings.

## State warm-up (optional)

The Miniserver's MCP plugin (tech preview) fills its `control_state` table only
from **state-change deltas**, and it only starts collecting them once the first
state query of a server-side session epoch arrives β€” there is no initial
snapshot. Practical symptom: in a fresh session, *"what's the temperature at
home?"* returns no data until each value physically changes.

Set `LOXONE_STATE_WARMUP=1` and the proxy issues one tiny `control_state` nudge
right after every (re-)initialize, so the Miniserver starts accumulating values
immediately at connect time instead of at your first question. By the time you
ask, slow-moving states (room temperatures etc.) have usually re-broadcast and
are present. This cannot conjure a value that has not changed or re-broadcast
yet β€” that part only Loxone can fix server-side β€” but it removes the
"assistant starts blind" effect for a long-running Claude Desktop.

`LOXONE_WARMUP_SWEEP=1` additionally touches every control individually after
the nudge (one `control_state` per control, spaced out). Under currently
observed firmware semantics the delta subscription is global, so the sweep is
redundant β€” it exists as belt-and-braces for future firmware.

## Security

- **Use a dedicated Loxone user** with the least privilege necessary. The
  assistant can only see and do what that user can.
- Credentials live only in your local `.env` (git-ignored). Tokens are cached at
  `~/.loxone-mcp-proxy/cache.json` with `0600` permissions. **Windows note:**
  POSIX file modes are effectively ignored on Windows β€” the cache is only as
  private as your user profile directory (NTFS ACLs), so keep the machine
  account personal.
- Nothing is sent to any third-party cloud β€” the proxy talks only to Loxone's own
  connect/relay infrastructure and your Miniserver.

## Troubleshooting

Run with verbose logging (to stderr):

```bash
LOXONE_DEBUG=1 npm run login-check              # macOS / Linux
```

```powershell
$env:LOXONE_DEBUG='1'; npm run login-check      # Windows PowerShell
```

- **`login was rejected (form re-rendered)`** β€” wrong `LOXONE_USER` /
  `LOXONE_PASSWORD`, or that user may not access this Miniserver.
- **`connect is rate-limited (HTTP 429)`** β€” the connect endpoint allows only a
  handful of requests per window; wait a minute. Normal operation caches the
  endpoint and rarely hits it.
- **`could not authenticate after N attempts`** β€” check the MCP Server plugin is
  added and running in Loxone Config, and that the Miniserver has external access
  / Remote Connect enabled.
- **Claude shows the server but no tools** β€” fully quit Claude Desktop (from the
  tray) and relaunch; confirm `npm run login-check` passes.

## Configuration reference

All optional, via `.env` or environment:

| Variable | Default | Meaning |
|----------|---------|---------|
| `LOXONE_SERIAL` | β€” | **required** β€” Miniserver serial (MAC, no colons) |
| `LOXONE_USER` | β€” | **required** β€” Loxone username |
| `LOXONE_PASSWORD` | β€” | **required** β€” Loxone password |
| `LOXONE_CONNECT_BASE` | `https://connect.loxonecloud.com` | Loxone Cloud connect base |
| `LOXONE_CACHE_PATH` | `~/.loxone-mcp-proxy/cache.json` | token cache location |
| `LOXONE_REDIRECT_PORT` | `41678` | loopback port used only as the OAuth redirect target |
| `LOXONE_REQUEST_TIMEOUT_MS` | `120000` | per-request deadline for MCP calls to the relay |
| `LOXONE_STATE_WARMUP` | `0` | set `1` to nudge state-delta collection after each (re-)initialize |
| `LOXONE_WARMUP_SWEEP` | `0` | with warm-up: also touch every control individually |
| `LOXONE_WARMUP_DELAY_MS` | `1000` | delay between session establishment and the warm-up |
| `LOXONE_WARMUP_SPACING_MS` | `150` | spacing between per-control sweep calls |
| `LOXONE_WARMUP_MAX_CONTROLS` | `500` | sweep enumeration cap |
| `LOXONE_MAX_ATTEMPTS` | `6` | max authentication attempts before giving up |
| `LOXONE_CONNECT_MIN_INTERVAL_MS` | `2000` | min. spacing between connect-endpoint calls |
| `LOXONE_ENV_FILE` | β€” | explicit path to the `.env` file to load |
| `LOXONE_NO_DOTENV` | `0` | set `1` to skip `.env` loading entirely |
| `LOXONE_DEBUG` | `0` | set `1` for verbose stderr logging |
| `LOXONE_LOG_LEVEL` | `info` | `error` / `warn` / `info` / `debug` (ignored if `LOXONE_DEBUG=1`) |

## Development

```bash
npm test                                        # unit tests (node:test, no deps)

# live integration test (needs .env + a reachable Miniserver) β€” opt in via env:
LOXONE_INTEGRATION=1 npm run test:integration   # macOS / Linux
```

```powershell
$env:LOXONE_INTEGRATION='1'; npm run test:integration   # Windows PowerShell
```

Layout:

```
src/
  index.js       entry point (loads config, starts the stdio proxy)
  proxy.js       stdio <-> Connection bridge (newline-delimited JSON-RPC)
  connection.js  endpoint resolution, token lifecycle, session, recovery
  loxoneAuth.js  headless OAuth (register, HTML-form login, token, refresh)
  resolver.js    connect.loxonecloud.com -> current relay, cached
  cache.js       on-disk token cache
  config.js      .env + environment config
  logger.js      stderr logging (stdout is reserved for MCP)
scripts/
  login-check.js standalone connectivity/credentials check
```

## License

[MIT](./LICENSE) Β© Ivan Tichy

## Acknowledgements

Built for connecting Claude to Loxone's native MCP Server. Not affiliated with or
endorsed by Loxone.