Skip to content

Architecture

mcgpib bridges the gap between language model tool-calling and physical test equipment. An LLM calls MCP tools; those tool calls translate into AR488 commands sent over serial or TCP; the AR488 firmware drives the GPIB bus; and instruments on the bus respond with measurement data that flows back up the stack.

graph TD
    LLM["LLM<br/>(Claude, etc.)"]
    MCP["FastMCP Server<br/>(mcgpib)"]
    MGR["BridgeManager"]
    BA["Bridge A"]
    BB["Bridge B"]
    TA["Transport A<br/>Serial / TCP"]
    TB["Transport B<br/>Serial / TCP"]
    FWA["AR488 Firmware<br/>(ESP32)"]
    FWB["AR488 Firmware<br/>(ESP32)"]
    BUSA["GPIB Bus A<br/>addr 1, 5, 22 …"]
    BUSB["GPIB Bus B<br/>addr 3, 7 …"]

    LLM -- "MCP Protocol<br/>(JSON-RPC over stdio)" --> MCP
    MCP -- "Python async<br/>function calls" --> MGR
    MGR --> BA
    MGR --> BB
    BA -- "asyncio.Lock" --> TA
    BB -- "asyncio.Lock" --> TB
    TA --> FWA
    TB --> FWB
    FWA -- "GPIB bus" --> BUSA
    FWB -- "GPIB bus" --> BUSB

    style LLM fill:#78350f,stroke:#d97706,color:#fde68a
    style MCP fill:#78350f,stroke:#d97706,color:#fde68a
    style MGR fill:#78350f,stroke:#d97706,color:#fde68a
    style BA fill:#78350f,stroke:#d97706,color:#fde68a
    style BB fill:#78350f,stroke:#d97706,color:#fde68a
    style TA fill:#334155,stroke:#94a3b8,color:#e2e8f0
    style TB fill:#334155,stroke:#94a3b8,color:#e2e8f0
    style FWA fill:#334155,stroke:#94a3b8,color:#e2e8f0
    style FWB fill:#334155,stroke:#94a3b8,color:#e2e8f0
    style BUSA fill:#334155,stroke:#94a3b8,color:#e2e8f0
    style BUSB fill:#334155,stroke:#94a3b8,color:#e2e8f0

Each layer in this stack has a single responsibility. The layers above it do not need to know the implementation details of the layers below.

The top-level entry point is server.py, which creates the FastMCP application and registers all tools, prompts, and resources. It uses a lifespan context manager to initialize the BridgeManager on startup and disconnect all bridges on shutdown.

The server exposes 22 tools organized into four groups:

  • Bridge management (5 tools) — list_bridges, bridge_status, connect_bridge, disconnect_bridge, configure_bridge
  • Bus operations (6 tools) — bus_scan, serial_poll, check_srq, bus_clear, bus_trigger, interface_clear
  • Instrument interaction (7 tools) — instrument_query, instrument_write, instrument_identify, list_instruments, instrument_reset, instrument_local, instrument_remote
  • Low-level access (4 tools) — raw_command, raw_scpi, bus_diagnostic, parallel_poll

Five prompts provide guided workflows, and a static resource at gpib://protocol/commands serves the AR488 command reference.

The BridgeManager is the orchestration layer. It holds all configured BridgeConnection instances in a dictionary keyed by bridge name. Every tool function retrieves the manager from the FastMCP lifespan context and delegates to it.

The manager’s responsibilities:

  • Bridge lookup — Resolve a bridge name string to a BridgeConnection object. Raises BridgeNotFoundError if the name does not match any configured bridge.
  • Instrument registry — Aggregate instrument lists across all bridges for the list_instruments tool. This is a read-only view of cached scan results, not a live query.
  • Server status — Report how many bridges are configured, how many are connected, and the total instrument count.
  • Lifecycledisconnect_all() is called during server shutdown to cleanly close every transport.

The manager does not hold any locks itself. It passes operations straight through to the appropriate BridgeConnection, which manages its own serialization.

Each BridgeConnection represents one physical AR488 bridge and the GPIB bus it controls. It owns three critical pieces of state:

  1. Transport — The serial or TCP connection to the AR488 hardware
  2. Lock — An asyncio.Lock that serializes all commands through the bridge
  3. Bus state — Cached scan results: which addresses have listeners, their identities, and the SRQ line state

Every public method on BridgeConnection acquires the lock before sending any data. This is the central correctness mechanism in mcgpib.

Why one lock per bridge:

  • The GPIB bus is a shared medium with a half-duplex handshake protocol. Only one device can talk at a time, and the controller can only address one listener at a time.
  • Interleaving commands to different addresses would corrupt the bus state. For example, if command A sets ++addr 5 and command B sets ++addr 22 before A sends its query, the query goes to address 22 instead of 5.
  • The AR488 firmware processes commands sequentially. Sending a second command before the first completes will merge or corrupt both.

The lock is per-bridge, not global. Operations on bench-a and bench-b can proceed concurrently because they are on different physical buses with different AR488 controllers.

sequenceDiagram
    participant A as bench-a lock
    participant B as bench-b lock

    Note over A: Serial within bridge
    activate A
    A->>A: addr 5
    A->>A: query
    A->>A: read
    deactivate A

    activate B
    B->>B: addr 3
    Note over A,B: Concurrent across bridges
    B->>B: query
    activate A
    A->>A: addr 22
    B->>B: read
    deactivate B
    A->>A: query
    A->>A: read
    deactivate A

When connect() is called, the bridge runs this sequence:

  1. Open the transport (serial port or TCP socket)
  2. Drain any buffered output (macro 0 may have auto-executed at ESP32 boot)
  3. Run the initialization sequence (see below)
  4. Read the firmware version with ++ver to verify communication

On disconnect, the transport is closed and all cached state is reset.

The initialization sequence configures the AR488 bridge for machine-parseable output. Each command serves a specific purpose:

CommandValuePurpose
++verbose 0OffReturn numeric values instead of human-readable strings
++prompt 0OffSuppress the command prompt character
++auto 0OffManual read mode — mcgpib controls when to read with ++read eoi
++mode 1ControllerSet controller mode (vs. device mode)
++eoi 1OnAssert EOI on the last byte of each GPIB write
++eos 0CR+LFAppend CR+LF to data sent on the GPIB bus
++read_tmo_ms NConfig valueSet the read timeout for ++read commands

After setting these, mcgpib sends ++ver and reads the response. If it gets a valid firmware version string, the bridge is confirmed operational. If ++ver times out, the init fails with BridgeInitError.

The transport layer abstracts the physical connection. Both SerialTransport and TcpTransport implement the same interface:

  • connect() — Open the serial port or TCP socket
  • disconnect() — Close the connection
  • send_line(line) — Send a CR-terminated ASCII string
  • read_line(timeout_ms) — Read one line with a timeout
  • drain_buffer(drain_ms) — Read and discard buffered data
  • is_connected() — Check connection state

Uses pyserial-asyncio to create an async StreamReader/StreamWriter pair from a serial port. The default baud rate is 115200, matching the AR488 firmware default.

Uses asyncio.open_connection to establish a TCP socket. The ESP32 AR488 WiFi firmware listens on port 23 (telnet-style), accepting one client at a time. A 10-second connection timeout prevents hangs when the ESP32 is unreachable.

Both transports encode outgoing data as ASCII with a \r terminator and decode incoming data as ASCII, stripping all line terminators. The AR488 protocol is entirely 7-bit ASCII.

mcgpib uses ++auto 0 (manual read mode) rather than Prologix-compatible auto-read modes. In auto mode, the bridge attempts to read from the GPIB bus after every command, which causes problems:

  • Non-query commands (like CONF:VOLT:DC 10) produce no response, so auto-read results in a timeout on every write
  • The timing of auto-reads is unpredictable, which makes it harder to reliably match responses to queries
  • Auto mode interferes with multi-step operations like “set address, send command, read response”

With auto=0, mcgpib explicitly sends ++read eoi when it expects data, giving it full control over the read timing.

The inter_command_delay_ms setting (default 10 ms) inserts a small pause between consecutive commands sent to the AR488 bridge. This exists because:

  1. ESP32 brownout prevention — Rapid GPIB bus activity draws current through the SN7516x transceiver ICs. If commands arrive faster than the ESP32’s voltage regulator can recover, the voltage dips below the brownout threshold and the ESP32 resets.
  2. Bus settling time — The GPIB three-wire handshake needs time to complete, especially with long cable runs or electrically slow instruments.
  3. Firmware processing — The AR488 firmware processes commands in a single-threaded loop. Flooding it with commands faster than it can process them risks buffer overflows.

The delay is applied after every command sent, whether it is a ++ command to the bridge or data destined for the GPIB bus. The default of 10 ms is conservative and works well for USB connections. WiFi connections may benefit from 20—30 ms to account for network jitter.

mcgpib uses a layered exception hierarchy that mirrors the protocol stack:

graph TD
    ROOT["GpibMcpError"]
    CFG["ConfigError"]
    TRANS["TransportError"]
    CONN["ConnectionError"]
    TMO["TimeoutError"]
    BRG["BridgeError"]
    INIT["BridgeInitError"]
    NOTF["BridgeNotFoundError"]
    BUS["BusError"]
    NOLSN["NoListenersError"]
    INST["InstrumentError"]

    ROOT --> CFG
    ROOT --> TRANS
    ROOT --> BRG
    ROOT --> BUS
    TRANS --> CONN
    TRANS --> TMO
    BRG --> INIT
    BRG --> NOTF
    BUS --> NOLSN
    BUS --> INST

    style ROOT fill:#78350f,stroke:#d97706,color:#fde68a
    style CFG fill:#334155,stroke:#94a3b8,color:#e2e8f0
    style TRANS fill:#334155,stroke:#94a3b8,color:#e2e8f0
    style CONN fill:#1e293b,stroke:#64748b,color:#cbd5e1
    style TMO fill:#1e293b,stroke:#64748b,color:#cbd5e1
    style BRG fill:#334155,stroke:#94a3b8,color:#e2e8f0
    style INIT fill:#1e293b,stroke:#64748b,color:#cbd5e1
    style NOTF fill:#1e293b,stroke:#64748b,color:#cbd5e1
    style BUS fill:#334155,stroke:#94a3b8,color:#e2e8f0
    style NOLSN fill:#1e293b,stroke:#64748b,color:#cbd5e1
    style INST fill:#1e293b,stroke:#64748b,color:#cbd5e1

Each layer catches errors from the layer below and wraps them with context. A TimeoutError from the transport becomes an InstrumentError at the bus level with a message like “Instrument at address 5 did not respond to MEAS:VOLT:DC?”.