Skip to content

Architecture

farzad edited this page Aug 19, 2026 · 1 revision

Architecture

Layout

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)

Everything talks over one CAN bus

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.

One bus listener instance per thread — a real bug, now a rule

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.

Simulator (simulator/runner.py)

Four kinds of background thread, all started from run_simulator():

  • SimThread — one per DBC message in profiles.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:

    1. an active fault-injection override, if any
    2. a correlated value, if the signal has one
    3. otherwise, an independent random draw within the signal's declared range

    Every value — from any of the three sources — passes through _clamp_to_encodable() before Message.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 on OBD_BROADCAST_ID (0x7DF). See Diagnostics and OBD-II.

  • DiagnosticResponderThread — answers the UDS-style DIAGNOSTIC_REQUEST frame vehicle.dbc defines, from all four simulated ECUs. See Diagnostics and OBD-II.

  • FaultListenerThread — listens for scenario-activation control frames and updates the shared FaultState. See Fault Injection.

A declared range isn't always an encodable range

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.

Correlated driving state (simulator/state.py)

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_LOAD track throttle
  • WHEEL_SPEED_FL/FR/RL/RR track a common vehicle speed with small independent jitter per wheel
  • FUEL_LEVEL only decreases
  • ENGINE_TEMP warms 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.

Fault injection (simulator/faults.py)

See the dedicated Fault Injection page.

Server (server/)

  • live_state.py::LiveState — one continuously-running background listener per server process (started once in create_app()), maintaining:

    • a time-windowed deque of raw frames (frames_since()) backing read_can_frames/filter_frames/monitor_signal
    • a "last known value per signal" map (snapshot()) backing get_vehicle_snapshot and 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.

  • fastmcp_server.py::create_app() — registers all MCP tools/resources (see MCP Tools Reference), the /dashboard and /dashboard/stream routes, /healthz, and OAuth-discovery stub routes (so MCP Inspector's auth probing doesn't block). Also patches FastMCP.sse_app to add CORS middleware — see Configuration for how origins/credentials are controlled.

  • server/schemas.py — every tool's return type is a Pydantic BaseModel here, not an ad-hoc dict; FastMCP derives each tool's outputSchema/structuredContent from these.

CLI (cli.py)

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).

Request/response vs. passive-listening tools

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 from LiveState'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-lived make_bus() instance and always shutdown_bus() in a finally. duration_s/timeout_s on these is capped by Settings.max_duration_s either way.

Clone this wiki locally