№ 018 · 2026-09-10 · shipped · docker-prod-01

The cars can answer questions now

claudemcpteslamatemqttfastapidockerteslatelemetrymosquitto

I’ve run TeslaMate for a while — a self-hosted logger that records every drive, charge and sleep cycle of a Tesla into Postgres, with a pile of Grafana dashboards on top. It’s excellent software. I had also, it turns out, not looked at it in months.

So this started as a maintenance job and turned into something better.

First: it was dead

I went looking for the container, found it, opened the UI, and the car’s last recorded position was mid-June. Three months of nothing.

The logs were unambiguous about why. Every poll, every thirty seconds, for three months:

GET https://owner-api.teslamotors.com/api/1/vehicles/... -> 403 {"error": "forbidden, see https://developer.tesla.com/docs/fleet-api"}

Tesla had switched off the legacy Owner API for third-party apps. My install was two major versions behind and still calling the dead endpoint. The upstream release notes for the version I was missing said it plainly: “This release resolves the issue of 403 Forbidden errors with Owner API tokens.”

The fix was the upgrade. Database backed up first — two majors is a lot of migrations to run against a database with years of history in it — then pull, recreate, and watch eight migrations go by. Clean.

And then a nice surprise. A second car appeared.

We have two. Only one had ever been in TeslaMate, and I’d assumed the other needed setting up. It didn’t. It had been sitting on the same account the whole time — the dead API simply couldn’t enumerate vehicles, so the logger had never seen it. The moment authentication worked it registered itself. Two cars, no configuration.

They’re called Bear and Big Bear, which tells you everything about how naming works in our house.

Now the actual idea

Working dashboards are nice. But the questions I actually have are not dashboard-shaped:

Where’s my wife? Is she on her way home yet? How long until she gets here? Did I leave the car unlocked?

Those are conversation-shaped. So: an MCP server over TeslaMate, letting me ask in English.

MQTT, not the database

The obvious approach is to point it at the Postgres database. There are existing MCP servers that do exactly that, and the most popular one has a few hundred stars. I nearly used it.

Then I measured how often TeslaMate actually writes a row, and changed my mind:

| Car state | A new position every | |—|—| | Driving | 0.5–3.5 seconds | | Parked, awake | ~5 minutes | | Asleep | never |

Sub-second while driving is genuinely real-time — that’s Tesla’s streaming API, and it’s plenty to answer “where is she right now”. But five minutes while parked is not, and the sleeping case is worse: TeslaMate deliberately stops polling a sleeping car so that it can sleep. Ask the database whether the car is moving and it will answer confidently from data that may be hours old.

TeslaMate also publishes everything to an MQTT broker as it happens — 145 topics per car, and crucially the broker retains the last value of every one. A client connecting at three in the morning gets a complete, timestamped picture immediately, without waking anything up.

So: MQTT for “what is true now”, Postgres for “what happened”. A background client holds a live in-memory cache; the historical tools query the database. Two sources, each used for what it’s actually good at.

The bit I’m pleased with: teaching it to say “I don’t know”

Here’s the failure mode I wanted to design out.

You ask where a car is. The answer comes back: at home. That sentence reads identically whether the data is four seconds old or nine hours old — and those are wildly different claims. One is an observation. The other is an assumption.

So every live answer carries a freshness block, and it distinguishes two things people constantly conflate:

The thresholds scale with what the car is doing: 30 seconds while driving, 12 minutes while awake. A real answer looks like this:

json "freshness": { "state": "offline", "data_age_seconds": 13.9, "frozen": true, "stale": false, "note": "Car is offline; TeslaMate stops polling so the car can sleep. These values are the last known before it slept and remain accurate unless the car was moved without waking." }

That note is written for a model to read. It’s the difference between “the car is at home” and “the car is at home, and has been asleep there since 10:37pm” — and the second one is the sentence I actually want.

What it can answer

Ten tools, all taking a person, a car name or a model — “my wife”, “my car”, “the Y” — or nothing at all, meaning both.

| | | |—|—| | where_is | position, and the named place — “Home”, not coordinates | | is_moving | in motion, speed, heading | | get_eta | destination, minutes and miles to arrival, traffic delay | | get_car_status | battery, charging, climate, locks, sentry, tyre pressures | | get_recent_drives | completed trips with start and end places | | get_charging_summary | sessions, kWh and cost, broken down by location | | get_data_gaps | holes in the history — call before quoting any total | | get_live_raw | every raw topic, as an escape hatch |

The ETA one is the most fun. If a route is set in the car’s navigation, MQTT carries the destination, minutes to arrival, miles remaining, expected battery on arrival, and current traffic delay. So “when’s she getting home?” has a real answer, updating every second while the car moves.

Geofences make the answers human. TeslaMate resolves known locations to names, so it says Home, not a latitude.

Two things I made it refuse to forget

It knows where the car is, not where the person is. If my wife is a passenger, took the other car, or walked somewhere, the answer is confidently wrong. Every location response carries that caveat, and the skill instructs the model to phrase it as “Bear is at…” rather than “she is at…” when the difference could matter. There’s an is_user_present flag, but occupancy isn’t identity.

There’s a three-month hole in the history. The outage is a real gap, and it left two database rows stuck open — a state and a drive that both begin in June and never end. Rather than delete them, there’s a tool that surfaces them, and the instructions say to call it before quoting mileage or efficiency across that window. Silently cleaning up would have made the data look complete when it isn’t.

One security note worth stating

The obvious way to reach MQTT from another container is to publish port 1883 on the network. I didn’t, because TeslaMate’s bundled broker runs with authentication disabled — it’s designed to sit on a private compose network. Exposing it would let anything on the LAN read both cars’ live location continuously, forever.

Instead the MCP container joins TeslaMate’s own Docker network and talks to the broker and database by service name. Only the MCP port is published. It’s a one-line difference in a compose file and it’s the difference between a private service and a location beacon.

Where it landed

A small FastAPI app on the same box as everything else, one background MQTT subscriber, read-only Postgres access, and a skill file that teaches the model the household vocabulary — which car is whose, that ranges come back in kilometres and I think in miles, and what the freshness block means.

Deliberately read-only. It cannot unlock a door, honk a horn or start the climate, and it isn’t going to. Watching is a different risk profile from acting, and I only wanted the first one.

Total build: an evening. Most of which was the upgrade, and a mildly annoying detour when the MCP Python SDK turned out to have shipped a major version that renamed the main class out from under me.

Worth it for being able to type “is she on her way?” and get a real answer.