-
Notifications
You must be signed in to change notification settings - Fork 2
Architecture
src/mcp_can/
bus.py python-can helpers (make_bus, read_frames, shutdown_bus)
dbc.py DBC loading/decoding (load_dbc, decode_frame, signal_int)
obd.py OBD-II (SAE J1979) request/response + DTC encode/decode
diagnostics.py UDS-style diagnostic service/response-code logic
config.py env settings (MCP_CAN_*) + logging setup
models.py internal bus-layer dataclass (Frame)
cli.py Typer CLI, mirrors the MCP tool surface
simulator/
runner.py SimThread, OBDResponderThread, DiagnosticResponderThread, run_simulator()
state.py correlated driving-dynamics state (VehicleState, tick())
faults.py named fault-injection presets + activation protocol
profiles.py DEFAULT_PROFILE: which messages SimThread sends, how often
server/
fastmcp_server.py MCP tools/resources + dashboard routes
schemas.py Pydantic models for MCP tool structured output
live_state.py background bus listener backing the dashboard + passive tools
templates/dashboard.html
vehicle.dbc sample CAN database (incl. a UDS-like diagnostic schema)
There is no shared Python state between the simulator and the server/CLI — they only communicate by sending and receiving actual CAN frames on a bus (virtual by default via python-can's virtual backend, or real hardware/SocketCAN). This is a deliberate simplicity choice: it means the simulator, server, and CLI can each be swapped for a different implementation without the others knowing.
The corollary: python-can's virtual backend does not reliably share bus state across separate OS processes (this is especially true on Windows). Every command that needs to see live traffic — frames, monitor, snapshot, obd-request, diag-request, fault, and the MCP server's tools — only works against a simulator sharing the same process. mcp-can demo runs the simulator in a background thread of the same process as the server specifically to route around this. See Troubleshooting.
A single python-can Bus instance's recv() queue is consumed once per message: if two threads call .recv() on the same instance, incoming frames are silently split between them rather than each thread seeing every frame. This was a real, previously-shipped bug (the diagnostic responder and OBD responder would work or not depending on which thread happened to dequeue a given frame first). The fix — and now a hard rule in this codebase — is that every listener thread gets its own make_bus(...) instance. Look at run_simulator() in runner.py for the pattern: SimThreads share a bus instance for sending (sending is unaffected — only concurrent recv() calls collide), but OBDResponderThread, DiagnosticResponderThread, and FaultListenerThread each get a dedicated instance.
Four kinds of background thread, all started from run_simulator():
-
SimThread— one per DBC message inprofiles.py::DEFAULT_PROFILE(ENGINE_STATUS,ABS_STATUS,AIRBAG_STATUS,BODY_STATUS), each on its own period. For every signal in its message, in priority order:- an active fault-injection override, if any
- a correlated value, if the signal has one
- otherwise, an independent random draw within the signal's declared range
Every value — from any of the three sources — passes through
_clamp_to_encodable()beforeMessage.encode(), because a signal's declared DBC range and what its bit width can actually encode aren't always the same thing (see the callout below). Random draws were always safe here; correlated/override values weren't, until this clamp was added. -
OBDResponderThread— answers OBD-II (SAE J1979) requests onOBD_BROADCAST_ID(0x7DF). See Diagnostics and OBD-II. -
DiagnosticResponderThread— answers the UDS-styleDIAGNOSTIC_REQUESTframe vehicle.dbc defines, from all four simulated ECUs. See Diagnostics and OBD-II. -
FaultListenerThread— listens for scenario-activation control frames and updates the sharedFaultState. See Fault Injection.
ENGINE_TEMP is defined in vehicle.dbc as 8 bits, scale 0.5, offset -40, with a declared range of -40..127.5. But 8 bits only has 256 raw values (0–255), so the actual encodable ceiling is 255*0.5-40 = 87.5 — the declared 127.5 maximum is not reachable at all. cantools' Message.encode() raises rather than clamping, and this bit the correlated-state feature directly: its warm-engine target used to be 90.0, which is above the real ceiling, so ENGINE_STATUS frames silently failed to encode (caught by a broad except Exception, so the simulator kept running — but that message effectively stopped being sent). state.py::ENGINE_TEMP_MAX_C documents the real ceiling, and SimThread._clamp_to_encodable() now guards every correlated/override value generically, so this class of bug can't recur for a future signal.
Without this, every signal is an independent random draw each tick — ENGINE_SPEED and THROTTLE_POSITION have no relationship, which doesn't read as a moving vehicle. VehicleState runs a small background loop (tick_s=0.2 by default) advancing a DrivingState dataclass (throttle_pct, rpm, speed_kph, engine_temp_c, fuel_pct) with simple first-order-lag dynamics — tick() is a pure function (state in, state out), so it's fully unit-testable without any threading.
CORRELATED_SIGNALS maps DBC signal names to functions of that state:
-
ENGINE_SPEED/ENGINE_LOADtrack throttle -
WHEEL_SPEED_FL/FR/RL/RRtrack a common vehicle speed with small independent jitter per wheel -
FUEL_LEVELonly decreases -
ENGINE_TEMPwarms toward an operating-temperature target over ~60s
Signals with no entry in CORRELATED_SIGNALS (doors, seatbelts, crash/fault flags, wipers, etc.) keep using SimThread's independent random draws — they're discrete/situational, not driving-dynamics signals with an obvious relationship to throttle/speed/rpm.
See the dedicated Fault Injection page.
-
live_state.py::LiveState— one continuously-running background listener per server process (started once increate_app()), maintaining:- a time-windowed deque of raw frames (
frames_since()) backingread_can_frames/filter_frames/monitor_signal - a "last known value per signal" map (
snapshot()) backingget_vehicle_snapshotand the dashboard's SSE stream
This replaced an earlier design where each of those tools opened a fresh bus listener per call — which meant frames sent between calls were simply lost, and (per the rule above) a listener could have frames stolen by a competing one on the same bus instance. Passive tools are now instant (served from the buffer) rather than blocking for
duration_s. - a time-windowed deque of raw frames (
-
fastmcp_server.py::create_app()— registers all MCP tools/resources (see MCP Tools Reference), the/dashboardand/dashboard/streamroutes,/healthz, and OAuth-discovery stub routes (so MCP Inspector's auth probing doesn't block). Also patchesFastMCP.sse_appto add CORS middleware — see Configuration for how origins/credentials are controlled. -
server/schemas.py— every tool's return type is a PydanticBaseModelhere, not an ad-hoc dict; FastMCP derives each tool'soutputSchema/structuredContentfrom these.
A Typer app that mirrors most of the MCP tool surface as direct commands (frames, decode, monitor, snapshot, dbc-info, obd-request, diag-request, fault), plus process-management commands (simulate, server, demo). Each data command opens its own short-lived bus connection — same cross-process caveat as everything else (see above).
Two shapes recur throughout the codebase, and matter if you're adding a new tool (see Development and Testing):
-
Passive-listening (
read_can_frames,filter_frames,monitor_signal,get_vehicle_snapshot): read fromLiveState's buffer, return near-instantly, never open a bus connection themselves. -
Request/response (
send_obd_request,send_diagnostic_request,activate_fault_scenario): genuinely need to send a frame and wait for a specific reply, so they open their own short-livedmake_bus()instance and alwaysshutdown_bus()in afinally.duration_s/timeout_son these is capped bySettings.max_duration_seither way.
Getting started
Reference
Simulation features
Operating it
Contributing