A few weeks ago the folding roof on my 2010 Ford Focus CC stopped working.
The obvious suspect was the roof module, but FORScan showed nothing there: zero stored fault codes. The useful clues were somewhere else, in the passenger door module, and they didn't look related at first glance.
I wanted to see whether Claude could work with that kind of diagnostic state directly instead of me copy-pasting screenshots from FORScan. So I built a small MCP server around an OBD-II adapter.
This is not a story about Claude magically fixing a car. It's a story about giving an LLM a narrow, read-only set of tools, fighting a BLE adapter for a few hours, and seeing whether the model could produce a useful explanation from real diagnostic state.
The setup
┌──────────┐ Bluetooth ┌───────────────┐ Python ┌──────────────┐ MCP ┌─────────┐
│ Car │ ◄─────────────► │ vLinker FD │ ◄──────────► │ MCP Server │ ◄───────► │ Claude │
│ OBD-II │ │ ($15 dongle) │ │ (250 lines) │ │ │
└──────────┘ └───────────────┘ └──────────────┘ └─────────┘
For this experiment I used only read-only tools:
| Tool | What it does |
|---|---|
read_dtc |
Read stored fault codes from all or specific modules |
get_live_data |
Real-time sensor readings (RPM, coolant temp, speed, etc.) |
get_vehicle_info |
VIN, calibration IDs, ECU name |
list_modules |
Enumerate all ECUs on the CAN bus |
get_freeze_frame |
Sensor snapshot from when a DTC was set |
explain_dtc |
Look up a code in the local DTC database |
There is a guarded clear_dtc command in the repo (requires explicit confirmation), but I didn't use it for this experiment and wouldn't include it in a truly read-only profile.
The server is about 250 lines of Python and intentionally boring. Claude helped with the Python boilerplate, but the useful work was figuring out the adapter behavior and the diagnostic boundaries.
The BLE problem (where most of the time went)
The car side was straightforward. Plug the Vgate vLinker FD into the OBD port, turn the ignition on, pair via Bluetooth (PIN is 1234, not the 0000 macOS suggests).
macOS created a serial port:
$ ls /dev/tty.*vLinker*
/dev/tty.vLinkerFD-Android
Looks good. Let's talk to it:
import serial
conn = serial.Serial('/dev/tty.vLinkerFD-Android', baudrate=115200, timeout=2)
conn.write(b'ATZ\r') # Reset the ELM327 chip
response = conn.read(100)
print(response) # b''
Nothing. Empty bytes. I tried every common baud rate (9600, 38400, 115200, 500000). All of them opened the port without error, accepted writes, and returned nothing.
I spent a couple of hours on this. Tried different AT commands, reconnected the adapter, verified the ignition was on. The port was there, it opened cleanly, and nobody was home.
The actual problem: the vLinker FD is a BLE device, not classic Bluetooth. These are two completely different protocols. Classic Bluetooth creates a serial port (SPP/RFCOMM) and you talk to it like a USB cable. BLE uses GATT characteristics instead, small read/write endpoints organized into services.
When macOS paired with the vLinker, it created a serial port entry because that's what macOS does with Bluetooth devices. But the adapter never actually serves data on RFCOMM. It only listens on its BLE GATT characteristics. The serial port was a phantom.
This is why FORScan works fine on iOS. It uses Core Bluetooth to talk BLE directly. It never tries the serial port.
The fix was the bleak library (Python BLE client). The adapter exposes a GATT service with specific characteristics for communication:
Service: 0000fff0-0000-1000-8000-00805f9b34fb
Write: 0000fff2-0000-1000-8000-00805f9b34fb
Notify: 0000fff1-0000-1000-8000-00805f9b34fb
You send ELM327 commands to the write characteristic and listen for responses on the notify characteristic:
async with BleakClient(address) as client:
await client.start_notify(NOTIFY_UUID, on_response)
await client.write_gatt_char(WRITE_UUID, b'ATZ\r')
First successful adapter interaction after hours of debugging:
> ATZ
ELM327 v1.5
> AT SP 6
OK
> 0100
41 00 BE 3E B8 13
The lesson I keep coming back to: the hard part of connecting an LLM to hardware was not the LLM. It was getting bytes from point A to point B through the correct abstraction layer.
Why MCP instead of just pasting logs?
The useful part of MCP here is not sophistication. It's the boundary.
Claude doesn't get arbitrary access to my laptop or the car. It gets a small set of named tools with typed inputs and outputs: list modules, read DTCs, explain one code, fetch freeze-frame data. That's it.
That made the interaction auditable. I could see exactly what Claude asked for, what the adapter returned, and where the final explanation came from. If Claude produced a bad answer, I could trace it back to which tool call returned unexpected data versus where the model's reasoning went wrong.
For a system that talks to actual hardware, that boundary matters more than convenience.
The DTC database
The car returns codes, not rich explanations. OBD-II gives you hex strings like B1310, but the standard doesn't include descriptions. Manufacturer-specific codes aren't in any universal reference.
For the prototype, I built a local lookup database from publicly available DTC references using an Apify crawler. It covers over 20 brands (Ford's coverage is the deepest, with codes across powertrain, body, and chassis categories). The database is not authoritative, and I wouldn't treat it as a replacement for OEM service data. The point was to avoid asking Claude to invent meanings for manufacturer-specific codes from memory.
What Claude actually did
Here's the context I hadn't explained yet. The passenger window regulator on the car is broken: the motor spins, but the glass doesn't move. On a convertible, that matters because the roof sequence depends on the side windows dropping before the roof panels can fold over them.
The interesting diagnostic puzzle was that the folding top module had no stored DTCs. The only relevant faults were in the passenger door module.
Important caveat: the Focus CC module scan shown below is reproduced in the mock adapter from my actual FORScan session. The MCP server can talk to the BLE adapter and read standard ELM327 responses, but full Ford MS-CAN module enumeration (which is where body modules like the roof controller live) needs extended commands beyond what generic OBD-II provides. The mock exists so the MCP workflow can be tested end-to-end without pretending that generic OBD-II gives you all body-module data for free.
Simplified scan output from mock mode (module addresses and fault codes match my real FORScan session):
Module Address Protocol DTCs
──────────────────────────────────────────────────────────────────
PCM (Powertrain Control) 0x7E0 HS-CAN 0
ABS Module 0x760 HS-CAN 0
Instrument Cluster 0x720 MS-CAN 0
Driver Door Control Unit 0x740 MS-CAN 0
Passenger Door Control Unit 0x741 MS-CAN 2
→ B1310: Power door unlock circuit failure
→ B166A: Heated mirror circuit open
Folding Top Control Module 0x750 MS-CAN 0
HVAC Module 0x733 MS-CAN 0
Reconstructed tool-call sequence from the mock-mode run:
1. list_modules() → found 7 ECUs
2. read_dtc() → scanned all modules, found 2 codes on passenger door
3. explain_dtc("B1310") → "Power door unlock circuit failure"
4. explain_dtc("B166A") → "Heated mirror circuit open"
5. get_freeze_frame("B1310") → sensor snapshot from when the code was set
The codes themselves don't say "broken window regulator." B1310 and B166A point to passenger-door electrical faults. The missing piece was the symptom history: the window motor spins but the glass doesn't move, and the roof stopped working around the same time.
I asked Claude: "The roof won't operate but the roof module has no fault codes. Why?"
Claude's hypothesis: the roof controller performs a live precondition check before the folding sequence. It asks the windows to move. If the passenger side doesn't respond correctly (because the regulator is broken), the sequence aborts. Since the roof module itself isn't failing, it doesn't store a DTC. The relevant faults are on the door module, not the roof module.
The important connection was not B1310 specifically. It was that the only module with faults was in the same subsystem the roof controller depends on for its pre-flight sequence.
That matched what I later found in Ford service documentation. I haven't replaced the regulator yet, so this is still a high-confidence hypothesis rather than a confirmed repair. But it was useful novice-level triage: connecting live module state, symptom history, and reference data into a coherent next thing to check. That's more than I had before building this.
What this did not prove
This didn't prove that Claude can diagnose cars. It proved something narrower:
- MCP is a clean interface for exposing diagnostic tools to an LLM.
- The model is more useful when it can query real state instead of working from pasted screenshots.
- The hard part is still protocol handling, adapter quirks, and incomplete documentation.
- The result was a plausible diagnostic hypothesis, not a certified repair instruction.
What I intentionally didn't build
The server is read-only. I didn't expose actuator tests, module coding, service resets, or anything that can change vehicle state.
The DTC database includes diagnostic procedures for each brand: how to run self-tests, cycle actuators, reset service intervals. In theory, you could wrap those as MCP tools. I chose not to.
I'm a developer, not a car mechanic. Reading fault codes is passive, you're just asking the car what it already knows. But sending active commands to modules is a different thing entirely. A wrong routine ID, a command sent at the wrong time, or an actuator test while someone's hand is near a moving part. That's real risk. The gap between "technically possible via UDS Service $31" and "safe to let an LLM trigger autonomously" is enormous.
Honest limitations
Standard OBD-II is limited. The OBD-II standard only covers emissions-related PIDs. Body modules (roof controllers, door modules) live on manufacturer-specific CAN buses. The python-obd library doesn't natively scan Ford's MS-CAN bus. The mock adapter simulates this based on real FORScan data, but real MS-CAN scanning from the MCP server needs FORScan-level extended commands that aren't implemented yet.
The DTC database is scraped, not authoritative. It's built from publicly available references, not Ford's official FRIDA/DRIS database. Some descriptions may be incomplete or outdated.
Claude can hallucinate. It produced a plausible explanation for my roof problem, but LLMs can also produce confident-sounding nonsense. This is an explanation layer, not a repair manual. Verify anything it says against actual service documentation before acting on it.
The repo
The code is here: github.com/petrpatek/obd2-mcp-server
git clone https://github.com/petrpatek/obd2-mcp-server.git
cd obd2-mcp-server
./setup.sh
source .venv/bin/activate
obd2-mcp --mock # runs with simulated car data, no hardware needed
The mock simulates the Focus CC scenario with the same module addresses and fault codes I saw in FORScan. If you have an ELM327-compatible adapter, swap --mock for --ble (or --port for USB) and point it at a real car.
It passes 28 tests and works for the narrow path I built it for, but it's a prototype. I wouldn't rely on it for real diagnostics without verifying the output against proper service documentation.
What's next
I'm working on dynamic PID discovery (asking each ECU what standard PIDs it supports), live data streaming, and better brand coverage for the DTC database. All read-only.
The pattern I care about is narrower than "AI for every industry": take a system that already exposes diagnostic state, wrap a few safe read-only queries as tools, and let the model explain what it sees without letting it push buttons.
That seems useful beyond cars, but only if the boring parts are done well: protocol quirks, incomplete documentation, adapter weirdness, safety boundaries, and knowing when not to let the model act.
If you've tried similar MCP wrappers for CAN, Modbus, or other diagnostic protocols, I'd be curious to compare notes.
Top comments (0)