# Deploying teslamate-mcp on Unraid behind a Cloudflare MCP Server Portal This runbook deploys the teslamate-mcp Docker container on an Unraid server and exposes it to Claude (claude.ai / Claude Desktop / mobile / Claude Code) through **Cloudflare Tunnel** + a **Cloudflare Zero Trust MCP Server Portal** — no ports opened on the home network. ## Architecture ``` claude.ai / Claude Desktop / mobile / Claude Code │ OAuth 2.1 (Cloudflare Access Managed OAuth; policy: allow you@example.com) ▼ https://mcp.your-domain.com/mcp ← MCP Server Portal (Cloudflare edge) │ Authorization: Bearer (portal "Custom Headers" upstream auth) ▼ https://teslamate-mcp.your-domain.com/mcp ← Tunnel public hostname │ Cloudflare Tunnel (outbound-only cloudflared on Unraid) ▼ [Unraid] cloudflared ──► http://192.168.1.100:8888 (teslamate-mcp container) └──► TeslaMate PostgreSQL (DATABASE_URL) ``` Why this shape: - **claude.ai / Claude Desktop custom connectors are OAuth-only** — they cannot send a static bearer token. The portal's *Managed OAuth* handles the client-side login (via your Cloudflare Access policy), while the portal authenticates *upstream* to teslamate-mcp with its built-in `AUTH_TOKEN` as a custom header. - **Cloudflare Tunnel** makes the container reachable from Cloudflare's edge without any inbound firewall/NAT rules. - Defense in depth: the direct hostname stays bearer-protected; `run_sql` is additionally sandboxed server-side (Postgres `READ ONLY` transaction + forced rollback); use a SELECT-only DB role for a final layer. ## Example values used throughout this guide Substitute your own values wherever these appear: | Placeholder | Meaning | |---|---| | `your-domain.com` | Domain active on Cloudflare | | `192.168.1.100` | LAN IP of the Unraid server | | `` | Bearer token generated in Phase 1 (never written to disk/docs) | | `you@example.com` | Email allowed by the Access policy | ## Prerequisites - TeslaMate + PostgreSQL running on Unraid (Docker UI). - A domain added and active in your Cloudflare account. - A Cloudflare **Zero Trust** organization (free plan is fine — first-time setup asks you to pick a team name at ). The default **One-time PIN** identity provider (email code) is sufficient. --- ## Phase 1 — Deploy teslamate-mcp on Unraid ### 1.1 Generate the auth token (on any machine) ```bash openssl rand -base64 32 # or: uv run teslamate-mcp gen-token ``` Save the value — it is used in the container env **and** in the Cloudflare portal config. ### 1.2 (Recommended) Create a SELECT-only Postgres role In the TeslaMate PostgreSQL container console (Unraid Docker UI → PostgreSQL container → Console): `psql -U teslamate teslamate`, then: ```sql CREATE ROLE teslamate_ro LOGIN PASSWORD ''; GRANT CONNECT ON DATABASE teslamate TO teslamate_ro; GRANT USAGE ON SCHEMA public TO teslamate_ro; GRANT SELECT ON ALL TABLES IN SCHEMA public TO teslamate_ro; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO teslamate_ro; ``` ### 1.3 Add the container Option A — template: copy `deploy/unraid/teslamate-mcp.xml` to `/boot/config/plugins/dockerMan/templates-user/` on the Unraid flash share, then **Docker → Add Container → select template `teslamate-mcp`**. Option B — manual (**Docker → Add Container**): | Field | Value | |---|---| | Name | `teslamate-mcp` | | Repository | `ghcr.io/cobanov/teslamate-mcp:latest` (this repo's release image, v0.4.0+) | | Network type | `bridge` | | Port | host `8888` → container `8888` (TCP) | | Env `DATABASE_URL` | `postgresql://teslamate_ro:@192.168.1.100:5432/teslamate` (not the main `teslamate` user — it is a superuser; see [SECURITY.md](../../SECURITY.md)) | | Env `AUTH_TOKEN` | `` from step 1.1 | | Env `REPORT_TIMEZONE` | `Europe/Istanbul` (IANA name; daily/monthly buckets follow local midnight) | | Env `LOG_LEVEL` | `INFO` | > The image's default command already runs the HTTP transport > (`teslamate-mcp http --host 0.0.0.0 --port 8888 --json-response --stateless`, as of 0.6.0), > runs as a non-root user, and has a built-in Docker `HEALTHCHECK` on `/health`. Leave > **Post Arguments** empty. `--stateless` (new in 0.6.0, SDK v2 / MCP 2026-07-28) serves > legacy-era clients like the portal without per-session server state; drop the flag with > **Post Arguments** if a client ever misbehaves without sessions. > **Historical (only if running the upstream `cobanov/…:0.3.1` image):** that image's > `--json-response` flag crashes at startup. Workaround: set **Post Arguments** to > `teslamate-mcp http --host 0.0.0.0 --port 8888`. Fixed in 0.4.0 — the workaround must be > removed when upgrading, or you'll silently keep SSE-response mode. > **Postgres reachability:** `192.168.1.100:5432` works when the TeslaMate PostgreSQL > container publishes port 5432 (the standard Unraid TeslaMate setup does). If it doesn't, > either publish it or put both containers on the same custom Docker network and use the > container name as host. ### 1.4 Verify on the LAN ```bash curl http://192.168.1.100:8888/health # → {"status":"ok","version":"0.6.0"} (or newer) curl -i http://192.168.1.100:8888/mcp # → HTTP 401 (bearer auth active) ``` > **Important:** `/health` and the 401 check do **not** touch the database. A *missing* > `DATABASE_URL` fails at startup, but a *wrong* one (bad host/password) only surfaces on > the **first real MCP request** — the session hangs (SSE pings only), times out, and all > subsequent requests return `500` until the container is restarted. Never use `localhost` > as the DB host: inside the container it points at the container itself, not the Unraid > host. Use `192.168.1.100`. Test connectivity from inside the container with: > `docker exec teslamate-mcp python -c "import os,psycopg; psycopg.connect(os.environ['DATABASE_URL'], connect_timeout=5); print('DB OK')"` --- ## Phase 2 — Cloudflare Tunnel ### 2.1 Create the tunnel 1. → **Networks → Tunnels → Create a tunnel** → connector type **Cloudflared** → name it `unraid`. 2. Copy the **tunnel token** (long `eyJ…` string) from the install command shown. ### 2.2 Run cloudflared on Unraid Install **Cloudflared** from Unraid Community Apps (image `cloudflare/cloudflared`), with post arguments / command: ``` tunnel --no-autoupdate run --token ``` The dashboard should show the tunnel status **HEALTHY** within a minute. ### 2.3 Add the public hostname In the tunnel's **Public hostnames** tab → **Add a public hostname**: | Field | Value | |---|---| | Subdomain | `teslamate-mcp` | | Domain | `your-domain.com` | | Type | `HTTP` | | URL | `192.168.1.100:8888` | Cloudflare creates the DNS CNAME automatically. ### 2.4 Verify from the internet ```bash curl https://teslamate-mcp.your-domain.com/health # → 200 {"status":"ok",...} curl -i https://teslamate-mcp.your-domain.com/mcp # → 401 without token curl -s -X POST https://teslamate-mcp.your-domain.com/mcp \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}' # → MCP initialize result (serverInfo "teslamate") ``` > Since 0.6.0 the server runs SDK v2 and speaks both protocol eras: the legacy > `initialize` handshake above (what the Cloudflare portal uses) and the stateless > MCP **2026-07-28** revision (`server/discover`, no sessions). The 2025-06-18 curl > stays valid as the portal-path check. --- ## Phase 3 — Zero Trust MCP server + portal ### 3.1 Register the MCP server **Zero Trust → Access controls → AI controls → MCP servers tab → Add an MCP server**: | Field | Value | |---|---| | Name | `teslamate-mcp` | | HTTP URL | `https://teslamate-mcp.your-domain.com/mcp` | | Authentication | **Custom Headers** → header `Authorization`, value `Bearer ` | | Access policy | Allow → Emails → `you@example.com` | teslamate-mcp does not implement OAuth/dynamic client registration, so **Custom Headers** is the correct (and documented) auth method for it. Save — Cloudflare connects, syncs capabilities, and the server status becomes **Ready** (27 tools with writes enabled as of 0.7.0 — incl. the `show_charging_curve` MCP Apps chart — 7 prompts, 3 resources). After deploying a new server version with added/changed tools, force **⋯ → Sync capabilities** on the MCP server entry (auto-resync is ~2h). > **MCP Apps charts do not render through the portal.** The portal namespaces every > resource URI (`ui://teslamate/drive-route.html` is only readable as > `teslamate-mcp_ui://teslamate/drive-route.html`) and leaves the `ui://` resources out of > `resources/list`, so the host cannot load the chart a `show_*` tool points at and falls > back to the plain data rows. Cloudflare has not shipped MCP Apps support for portals > (checked 2026-09-30). For charts, use the direct connection in **Phase 3b**. ### 3.2 Create the portal **Zero Trust → Access controls → AI controls → Add MCP server portal**: | Field | Value | |---|---| | Name | `home-mcp` (any) | | Custom domain | subdomain `mcp`, domain `your-domain.com` (CNAME to `gateway.agents.cloudflare.com` is auto-created) | | MCP servers | add `teslamate-mcp` | | Access policy | Allow → Emails → `you@example.com` | Then edit the portal → **Advanced settings → enable Managed OAuth** (this is what lets MCP clients like claude.ai authenticate without a browser-cookie flow). ### 3.3 Allowlist client OAuth callbacks (REQUIRED for Claude clients) Without this, Claude fails at Dynamic Client Registration with *"Couldn't register with …'s sign-in service"* — Access rejects the client's `redirect_uri` (`invalid_client_metadata: redirect_uri is not allowed by the account configuration`). **Zero Trust → Access controls → Applications** → the portal's auto-created application → **Edit → Advanced settings** → Managed OAuth: | Setting | Value | |---|---| | Allowed redirect URIs | `https://claude.ai/api/mcp/auth_callback` | | *(second entry)* | `https://claude.com/api/mcp/auth_callback` | | Allow localhost clients | On (CLI clients like Claude Code) | | Allow loopback clients | On | Optional: under the portal's server settings you can disable individual tools (e.g. hide `run_sql` if you only want the predefined analytics). The portal endpoint is now: **`https://mcp.your-domain.com/mcp`** > Capabilities re-sync automatically ~every 2 hours; after adding new queries to the > server, use **⋯ → Sync capabilities** to refresh immediately. --- ## Phase 3b — Direct connection (for interactive charts) Optional, and it can run alongside the portal. Claude signs in through a Cloudflare Access application on a second tunnel hostname and talks to the container directly, so `ui://` URIs arrive unchanged and the `show_*` charts render. Requires **0.11.0+**. ``` claude.ai ──OAuth (Access Managed OAuth)──► https://teslamate-direct.your-domain.com/mcp Access adds a signed Cf-Access-Jwt-Assertion header ──► tunnel ──► container (the container verifies it: CF_ACCESS_TEAM_DOMAIN + CF_ACCESS_AUD) ``` 1. **Second public hostname**: Networks → Tunnels → `unraid` → Public hostnames → Add. Subdomain `teslamate-direct`, domain `your-domain.com`, type `HTTP`, URL `192.168.1.100:8888` (the same service as `teslamate-mcp`, which the portal keeps using). 2. **Access application**: Zero Trust → Access controls → Applications → Add → **Self-hosted**. Domain `teslamate-direct.your-domain.com`, policy Allow → Emails → `you@example.com`. After saving, copy the **Application Audience (AUD) tag** from the application's overview. 3. **Managed OAuth**: edit the application → **Advanced settings → Managed OAuth** on, with the same redirect URIs and localhost/loopback settings as Phase 3.3. 4. **Check the OAuth challenge.** claude.ai needs the `WWW-Authenticate` header to start the login: ```bash curl -si -X POST https://teslamate-direct.your-domain.com/mcp | grep -i www-authenticate # → Bearer realm="OAuth", … resource_metadata="https://teslamate-direct…/.well-known/…" ``` The `team_domain` in the `resource_metadata` document is the value for step 5. 5. **Container env**: add these, then Apply. | Variable | Value | |---|---| | `CF_ACCESS_TEAM_DOMAIN` | `.cloudflareaccess.com` | | `CF_ACCESS_AUD` | the AUD tag from step 2 | Keep `AUTH_TOKEN` set; the portal path still uses it. The container log should show `Cloudflare Access authentication enabled`. 6. **Connect**: claude.ai → Settings → Connectors → Add custom connector → `https://teslamate-direct.your-domain.com/mcp` → Connect → Access login. Then disconnect the portal connector so Claude doesn't pick the chart-less copy of each tool. Try *"Show me the route of my last drive."* A request to `/mcp` needs either the static token or an assertion signed by your Access team for this application's audience; anything else gets `401`. --- ## Phase 4 — Connect clients ### claude.ai / Claude Desktop / mobile (custom connector) **Settings → Connectors → Add custom connector** → URL `https://mcp.your-domain.com/mcp` → **Connect** → complete the Cloudflare Access login (email one-time PIN). Tools appear under the connector; try *"What's my car's current status?"*. > Note: connectors run from Anthropic's cloud, which is why the portal must be publicly > reachable (it is — on Cloudflare's edge). ### Claude Code ```bash claude mcp add --transport http teslamate https://mcp.your-domain.com/mcp # then inside a session: /mcp → Authenticate (opens browser OAuth) ``` ### Cursor / other clients without OAuth support — `mcp-remote` wrapper ```json { "mcpServers": { "teslamate": { "command": "npx", "args": ["-y", "mcp-remote@latest", "https://mcp.your-domain.com/mcp"] } } } ``` ### OAuth failure modes (claude.ai / Claude Desktop) Two distinct failures, in the order you're likely to meet them: 1. **"Couldn't register with …'s sign-in service" at Connect** → Dynamic Client Registration rejected. Fix: allowlist Claude's callback URIs (Phase 3.3). Diagnose by POSTing a fake registration to `https://.cloudflareaccess.com/cdn-cgi/access/oauth/registration` — a `redirect_uri is not allowed by the account configuration` body confirms it. 2. **"Authorization with the MCP server failed" before any login screen** (historical, reported mid-2026, since fixed on Cloudflare's side in this deployment's testing) — caused by a missing `WWW-Authenticate: … resource_metadata=…` header on the portal's 401. Verify with `curl -si -X POST https://mcp./mcp | grep -i www-authenticate`; the header should reference `/.well-known/oauth-protected-resource/mcp`. If it's absent: use **Claude Desktop with `mcp-remote`** (config above) as the workaround, or front the tunnel origin with a Workers OAuth proxy (`workers-oauth-provider`) as a last resort. --- ## Phase 5 — Verification checklist - [ ] `curl http://192.168.1.100:8888/health` → 200 (LAN) - [ ] `curl https://teslamate-mcp.your-domain.com/health` → 200 (tunnel) - [ ] `curl https://teslamate-mcp.your-domain.com/mcp` without token → **401** - [ ] MCP initialize with bearer token → success (Phase 2.4) - [ ] Portal dashboard: server **Ready**, 25 tools synced (26 with `ENABLE_CHARGING_WRITES=true`, 0.5.0+) - [ ] Client connected via portal URL; `get_basic_car_information` returns your car - [ ] Negative: `run_sql` with `DELETE FROM cars` → rejected (validator + READ ONLY txn) ## Enabling charging-cost writes (0.5.0+, opt-in) Lets Claude write charging prices into the database — e.g. paste receipts and have it match them to sessions (`search_charging_sessions`) and set each cost (`set_charging_cost`). 1. **Grant the single writable column** — TeslaMate Postgres container console (`psql -U teslamate teslamate`): ```sql GRANT UPDATE (cost) ON charging_processes TO teslamate_ro; ``` This column-scoped grant is the security boundary: no other table or column can ever be written, regardless of any bug. `run_sql` additionally stays READ ONLY at the transaction level. 2. **Enable the flag** — teslamate-mcp container → add env `ENABLE_CHARGING_WRITES=true` → Apply. 3. **Re-sync the portal** — AI controls → MCP servers → teslamate-mcp → ⋯ → Sync capabilities → 26 tools. 4. claude.ai will ask for approval on each write call (or choose "always allow" for the connector). The portal's per-tool toggle can hide `set_charging_cost` again at any time without touching the server. Receipt workflow: use the `backfill_costs_from_receipts` prompt, or just tell Claude *"Here are my charging receipts — match them to my sessions and set the costs."* ## Troubleshooting | Symptom | Likely cause / fix | |---|---| | Crash at startup: `no field "streamable_http_json_response"` | Only on the old upstream `0.3.1` image — upgrade to `ghcr.io/cobanov/teslamate-mcp:0.4.0+` (or see the historical workaround in Phase 1.3) | | Portal shows old tool count after upgrading the container | Sync ran while the container was restarting — re-run **⋯ → Sync capabilities** once `/health` responds | | `set_charging_cost` fails with `InsufficientPrivilege` | The column grant is missing — run `GRANT UPDATE (cost) ON charging_processes TO teslamate_ro;` in the Postgres console | | GHCR push from release workflow fails `403 Forbidden` | An existing `teslamate-mcp` package isn't linked to the repo (e.g. orphaned from a deleted repo) — delete the stale package or grant the repo **Write** under the package's *Manage Actions access* | | First MCP call hangs (SSE pings only), then every call `500`s | Container can't reach Postgres (e.g. `localhost` in `DATABASE_URL`) — fix the URL host to `192.168.1.100`, then restart the container (0.5.1+ contains the failure per-call instead of poisoning the transport) | | `500` on every request, log shows `Task group is not initialized` + Postgres says `remaining connection slots are reserved` | Pre-0.5.1 per-session pool leak — restart the container to free the connections and upgrade to `0.5.1+` (shared pool) | | `522` on `teslamate-mcp.your-domain.com` | cloudflared container down or wrong service URL in public hostname | | `522` on `mcp.your-domain.com` | Portal DNS CNAME missing (should point to `gateway.agents.cloudflare.com`) | | Container restarts at boot | Bad `DATABASE_URL` (fail-fast) — check container log | | `401` even with token | Token mismatch: compare container `AUTH_TOKEN` vs portal custom header (`Bearer ` prefix included?) | | Portal server stuck "Sync Required" | Re-save the custom header credentials; **⋯ → Sync capabilities** | | Sync fails `HTTP 403` / `Error POSTing to endpoint: error code: 1003` | Trailing-slash mismatch: the portal's saved hostname ends in `/mcp/` (SDK v1's canonical form, and the hostname field is immutable) while SDK v2 serves `/mcp` and 307-redirects `/mcp/`; uvicorn's redirect behind the tunnel has a malformed `Location`, which Cloudflare rejects as 1003. Fixed in **0.6.1** — the server normalizes `/mcp/` to `/mcp`, so both forms answer directly. Upgrade the container, then **Sync capabilities** | | claude.ai "Authorization failed" at Connect | Known Managed-OAuth/connector issue — see Phase 4 fallbacks | | `show_*` tools return rows but no chart | Connected through the portal, which rewrites `ui://` URIs; use the direct connection (Phase 3b) | | Direct hostname: `401 Authorization required` after a successful Access login | `CF_ACCESS_TEAM_DOMAIN` / `CF_ACCESS_AUD` not set on the container, or the AUD tag belongs to another application; the container log shows `Rejected Cloudflare Access assertion: …` for a wrong AUD | | Tools list stale after adding a query | Portal syncs ~2h; force with **Sync capabilities** | ## Security notes - `AUTH_TOKEN` is shared only between the container and the portal config. Anyone hitting the direct hostname without it gets 401. - The MCP server enforces read-only execution for `run_sql` at the Postgres level (`READ ONLY` transaction, forced rollback, statement timeout, row cap). - The `teslamate_ro` SELECT-only role guarantees even the predefined-query path can't write. - Access policy restricts the portal (and OAuth) to your email; add MFA/IdP in Zero Trust if you later share access.