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.
The full stack
Section titled “The full 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.
FastMCP server
Section titled “FastMCP server”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.
BridgeManager
Section titled “BridgeManager”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
BridgeConnectionobject. RaisesBridgeNotFoundErrorif the name does not match any configured bridge. - Instrument registry — Aggregate instrument lists across all bridges for the
list_instrumentstool. 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.
- Lifecycle —
disconnect_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.
Bridge
Section titled “Bridge”Each BridgeConnection represents one physical AR488 bridge and the GPIB bus it controls. It owns three critical pieces of state:
- Transport — The serial or TCP connection to the AR488 hardware
- Lock — An
asyncio.Lockthat serializes all commands through the bridge - Bus state — Cached scan results: which addresses have listeners, their identities, and the SRQ line state
The lock
Section titled “The lock”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 5and command B sets++addr 22before 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
Connection lifecycle
Section titled “Connection lifecycle”When connect() is called, the bridge runs this sequence:
- Open the transport (serial port or TCP socket)
- Drain any buffered output (macro 0 may have auto-executed at ESP32 boot)
- Run the initialization sequence (see below)
- Read the firmware version with
++verto verify communication
On disconnect, the transport is closed and all cached state is reset.
Init sequence
Section titled “Init sequence”The initialization sequence configures the AR488 bridge for machine-parseable output. Each command serves a specific purpose:
| Command | Value | Purpose |
|---|---|---|
++verbose 0 | Off | Return numeric values instead of human-readable strings |
++prompt 0 | Off | Suppress the command prompt character |
++auto 0 | Off | Manual read mode — mcgpib controls when to read with ++read eoi |
++mode 1 | Controller | Set controller mode (vs. device mode) |
++eoi 1 | On | Assert EOI on the last byte of each GPIB write |
++eos 0 | CR+LF | Append CR+LF to data sent on the GPIB bus |
++read_tmo_ms N | Config value | Set 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.
Transport layer
Section titled “Transport layer”The transport layer abstracts the physical connection. Both SerialTransport and TcpTransport implement the same interface:
connect()— Open the serial port or TCP socketdisconnect()— Close the connectionsend_line(line)— Send a CR-terminated ASCII stringread_line(timeout_ms)— Read one line with a timeoutdrain_buffer(drain_ms)— Read and discard buffered datais_connected()— Check connection state
SerialTransport
Section titled “SerialTransport”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.
TcpTransport
Section titled “TcpTransport”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.
Why auto=0
Section titled “Why auto=0”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.
Inter-command delay
Section titled “Inter-command delay”The inter_command_delay_ms setting (default 10 ms) inserts a small pause between consecutive commands sent to the AR488 bridge. This exists because:
- 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.
- Bus settling time — The GPIB three-wire handshake needs time to complete, especially with long cable runs or electrically slow instruments.
- 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.
Error hierarchy
Section titled “Error hierarchy”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?”.