I built a read-only MCP server for my car's OBD-II port

A weekend hack connecting a 2010 Ford Focus CC to Claude via OBD-II, BLE, and MCP. The hard part was not the AI. It was Bluetooth, protocols, and knowing when not to let the model act.

Petr PátekAuthor
May 19, 202610 min read
OBD-II to Claude MCP architecture — car diagnostic port connected through BLE adapter and Python MCP server to Claude AI

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 actual diagnostic data 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 actual diagnostic data.

The setup

The architecture is four boxes: the car's OBD-II port talks to a $15 Bluetooth adapter (Vgate vLinker FD), which talks to a 250-line Python MCP server, which talks to Claude.

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`

List ECUs exposed by the current backend

`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: `/dev/tty.vLinkerFD-Android`. Looks good. Let's talk to it.

I opened the port with `serial.Serial('/dev/tty.vLinkerFD-Android', baudrate=115200, timeout=2)`, sent `ATZ\r` to reset the ELM327 chip, and read the response. 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: BLE vs Classic Bluetooth

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:

  • Service: `0000fff0-0000-1000-8000-00805f9b34fb`
  • Write characteristic: `0000fff2-0000-1000-8000-00805f9b34fb`
  • Notify characteristic: `0000fff1-0000-1000-8000-00805f9b34fb`

You send ELM327 commands to the write characteristic and listen for responses on the notify characteristic. First successful adapter interaction after hours of debugging: `ATZ` returned `ELM327 v1.5`, then `AT SP 6` returned `OK`, and `0100` returned `41 00 BE 3E B8 13`. Finally.

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. Diagnostic tools give you codes 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

Folding Top Control Module

0x750

MS-CAN

0

HVAC Module

0x733

MS-CAN

0

The passenger door module had two codes: B1310 (power door unlock circuit failure) and B166A (heated mirror circuit open).

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

Clone it, run `./setup.sh`, activate the virtualenv, and `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.

*For the non-technical perspective on what this pattern means beyond cars, read: The machines already talk. We just stopped listening.*

TagsMCPOBD-IIPythonBluetoothOpen SourceIoTClaude
Share

Continue reading

Have a project in mind?

Tell us about your business challenge. We'll figure out the right solution together.