Architecture
mcpyvisa gives language models direct access to physical test equipment. An LLM calls MCP tools; those tool calls translate into pyvisa operations; pyvisa dispatches through the appropriate backend (AR488, pyvisa-py, NI-VISA, or sim); and instruments respond with measurement data that flows back up the stack.
The key architectural decision is using pyvisa as the abstraction layer. Rather than coding against a single protocol, mcpyvisa delegates all instrument I/O to pyvisa ResourceManagers. This means any instrument reachable by any pyvisa backend — GPIB, USB-TMC, LAN/VXI-11, serial, or simulation — is accessible through the same set of MCP tools.
The full stack
Section titled “The full stack”graph TD
LLM["LLM<br/>(Claude, etc.)"]
MCP["FastMCP Server<br/>(mcpyvisa)"]
MGR["InstrumentManager"]
RMA["ResourceManager A<br/>(@ar488)"]
RMB["ResourceManager B<br/>(@py)"]
RMC["ResourceManager C<br/>(@ni)"]
RMD["ResourceManager D<br/>(@sim)"]
AR["AR488 Adapter<br/>(Serial / TCP)"]
PY["pyvisa-py<br/>(USB-TMC / LAN)"]
NI["NI-VISA<br/>(GPIB / USB / LAN)"]
SIM["pyvisa-sim<br/>(YAML definitions)"]
BUSA["GPIB Bus<br/>addr 1, 5, 22"]
BUSB["USB-TMC / LAN<br/>instruments"]
BUSC["NI-VISA<br/>instruments"]
BUSD["Simulated<br/>Instruments"]
LLM -- "MCP Protocol<br/>(JSON-RPC over stdio)" --> MCP
MCP -- "Python async<br/>function calls" --> MGR
MGR --> RMA
MGR --> RMB
MGR --> RMC
MGR --> RMD
RMA -- "asyncio.to_thread<br/>+ Lock" --> AR
RMB -- "asyncio.to_thread<br/>+ Lock" --> PY
RMC -- "asyncio.to_thread<br/>+ Lock" --> NI
RMD -- "asyncio.to_thread<br/>+ Lock" --> SIM
AR --> BUSA
PY --> BUSB
NI --> BUSC
SIM --> BUSD
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 RMA fill:#334155,stroke:#94a3b8,color:#e2e8f0
style RMB fill:#334155,stroke:#94a3b8,color:#e2e8f0
style RMC fill:#334155,stroke:#94a3b8,color:#e2e8f0
style RMD fill:#334155,stroke:#94a3b8,color:#e2e8f0
style AR fill:#334155,stroke:#94a3b8,color:#e2e8f0
style PY fill:#334155,stroke:#94a3b8,color:#e2e8f0
style NI fill:#334155,stroke:#94a3b8,color:#e2e8f0
style SIM fill:#334155,stroke:#94a3b8,color:#e2e8f0
style BUSA fill:#1e293b,stroke:#64748b,color:#cbd5e1
style BUSB fill:#1e293b,stroke:#64748b,color:#cbd5e1
style BUSC fill:#1e293b,stroke:#64748b,color:#cbd5e1
style BUSD fill:#1e293b,stroke:#64748b,color:#cbd5e1
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 sim backend demonstrates the power of the pyvisa abstraction. When mcpyvisa creates a sim ResourceManager, it passes a YAML file path to pyvisa.ResourceManager("{path}@sim"). From that point, pyvisa-sim handles all instrument I/O by matching queries against YAML-defined dialogues and returning configured responses (including randomized values). No code branching exists in mcpyvisa for the sim backend — the same instrument_query, instrument_write, and discover_instruments tools work identically across all four backend types.
Source layout
Section titled “Source layout”src/mcpyvisa/ server.py FastMCP app, lifespan, tool/prompt/resource registration config.py TOML config: backends + instrument aliases instrument_manager.py Core: wraps pyvisa ResourceManager(s), alias resolution models.py Pydantic data models (transport-agnostic) errors.py Exception hierarchy (McpyVisaError base) pymeasure_registry.py IDN -> pymeasure driver class mapping pymeasure_introspect.py Property/method extraction from pymeasure classes pymeasure_manager.py Instrument lifecycle resources.py MCP resources (SCPI/AR488 command reference) prompts.py Guided workflows tools/ instrument_tools.py query, write, identify, discover, reset (universal) gpib_tools.py serial_poll, SRQ, IFC, clear, trigger, local/remote ar488_tools.py raw ++ commands, xdiag, configure pymeasure_tools.py inspect, get, set, call (conditional on pymeasure)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 InstrumentManager on startup and disconnect all backends on shutdown.
The server exposes 22 tools organized into four groups:
- Universal tools (8 tools) —
server_status,connect_backend,disconnect_backend,discover_instruments,instrument_query,instrument_write,instrument_identify,instrument_reset - GPIB tools (7 tools) —
instrument_clear,serial_poll,check_srq,bus_trigger,interface_clear,instrument_local,instrument_remote - AR488 tools (3 tools) —
ar488_command,ar488_diagnostic,configure_ar488 - pymeasure tools (4 tools, optional) —
instrument_inspect,instrument_get,instrument_set,instrument_call
Six prompts provide guided workflows, and two static resources serve the AR488 command reference and SCPI common commands.
Tool activation
Section titled “Tool activation”Not all tools apply to all backends. The four groups are always registered, but they behave differently depending on the backend type:
- Universal tools work with any pyvisa backend. These are the tools an LLM uses most of the time.
- GPIB tools send GPIB-specific bus commands (serial poll, IFC, etc.). They work on any GPIB-capable backend (
@ar488,@niwith a GPIB controller). On non-GPIB backends, they return a descriptive error rather than failing silently. - AR488 tools send raw
++commands to AR488/Prologix adapters. They only function on@ar488backends. On other backends, they explain what they need. - pymeasure tools are conditionally registered at import time. If pymeasure is not installed, these tools do not appear in the tool list at all.
pymeasure layer
Section titled “pymeasure layer”When pymeasure is installed, a parallel path exists for 8 supported instruments. Instead of constructing raw SCPI strings, the LLM calls instrument_get / instrument_set / instrument_call which route through pymeasure’s validated drivers:
graph TD
LLM["LLM"]
RAW["Raw SCPI Tools<br/>instrument_query / instrument_write"]
PM["pymeasure Tools<br/>instrument_get / instrument_set / instrument_call"]
DRIVER["pymeasure Driver<br/>(Keithley2400, HP34401A, ...)"]
PYVISA["pyvisa ResourceManager"]
LOCK["Backend Lock<br/>(asyncio.to_thread)"]
INSTR["Instrument"]
LLM --> RAW
LLM --> PM
RAW --> LOCK
PM --> DRIVER
DRIVER --> PYVISA
PYVISA --> LOCK
LOCK --> INSTR
style LLM fill:#78350f,stroke:#d97706,color:#fde68a
style RAW fill:#334155,stroke:#94a3b8,color:#e2e8f0
style PM fill:#78350f,stroke:#d97706,color:#fde68a
style DRIVER fill:#78350f,stroke:#d97706,color:#fde68a
style PYVISA fill:#334155,stroke:#94a3b8,color:#e2e8f0
style LOCK fill:#334155,stroke:#94a3b8,color:#e2e8f0
style INSTR fill:#1e293b,stroke:#64748b,color:#cbd5e1
Both paths converge at the per-backend lock — the same serialization mechanism protects all instrument access regardless of whether the command came from raw SCPI or pymeasure. See the pymeasure integration explanation for the full details.
InstrumentManager
Section titled “InstrumentManager”The InstrumentManager is the orchestration layer. It holds all configured pyvisa ResourceManager instances, keyed by backend name. Every tool function retrieves the manager from the FastMCP lifespan context and delegates to it.
The manager’s responsibilities:
- Alias resolution — Map a friendly name like
"dmm"to a VISA resource string likeGPIB0::22::INSTRand its associated backend. If the input contains::, it is treated as a raw VISA resource string. Otherwise, the manager looks it up in the configured aliases. Unresolved names produce an error listing the known instruments and aliases. - Backend lifecycle — Create and destroy pyvisa
ResourceManagerinstances for each configured backend. On shutdown,disconnect_all()closes every ResourceManager and its open sessions. - Instrument discovery — Aggregate instrument lists across all backends for the
discover_instrumentstool. This queries each backend’s ResourceManager for available resources. - Server status — Report how many backends are configured, how many are connected, and the total instrument count.
The manager does not hold any locks itself. Serialization is handled at the per-backend level through asyncio.to_thread() with backend-scoped locks.
Alias resolution
Section titled “Alias resolution”Aliases are defined in mcpyvisa.toml and map human-readable names to VISA resource strings plus their backend:
[aliases]dmm = { resource = "GPIB0::22::INSTR", backend = "bench-a" }smu = { resource = "GPIB0::24::INSTR", backend = "bench-a" }scope = { resource = "TCPIP::192.168.1.5::INSTR", backend = "lan" }When a tool receives instrument="dmm", the InstrumentManager resolves it to the resource string and routes the operation to the correct backend’s ResourceManager. This keeps tool calls short and readable in LLM conversations.
The async bridge
Section titled “The async bridge”All pyvisa calls are synchronous. pyvisa’s write(), read(), and query() block until the instrument responds. mcpyvisa’s MCP tools are async — they run in the FastMCP event loop. These two worlds need to coexist without blocking.
The solution is asyncio.to_thread(). Every pyvisa call is dispatched to a thread pool worker:
sequenceDiagram
participant LLM as LLM
participant MCP as MCP Tool<br/>(async, event loop)
participant Thread as Thread Pool<br/>(sync)
participant PV as pyvisa<br/>ResourceManager
participant I as Instrument
LLM->>MCP: instrument_query("dmm", "MEAS:VOLT:DC?")
MCP->>MCP: resolve alias "dmm"<br/>-> GPIB0::22::INSTR @ bench-a
MCP->>Thread: asyncio.to_thread(query)
Thread->>PV: resource.query("MEAS:VOLT:DC?")
PV->>I: SCPI command
I-->>PV: +3.29847E+00
PV-->>Thread: "+3.29847E+00"
Thread-->>MCP: "+3.29847E+00"
MCP-->>LLM: "+3.29847E+00"
This pattern keeps the event loop free to handle other MCP requests while a slow instrument measurement runs in a background thread. The thread pool is sized by Python’s default ThreadPoolExecutor.
Lock-per-backend serialization
Section titled “Lock-per-backend serialization”Each backend has its own lock. When asyncio.to_thread() dispatches a pyvisa call, the lock for that backend is acquired first. This serializes all operations on a shared bus (or shared connection) while allowing operations on different backends to proceed concurrently.
Why one lock per backend:
- GPIB buses are shared. Only one device can talk at a time, and the controller can only address one listener at a time. Interleaving commands to different addresses on the same bus corrupts the bus state.
- Serial connections are single-channel. AR488 adapters process commands sequentially. Sending a second command before the first completes risks buffer overflows or response mismatches.
- Different backends are independent. Operations on a GPIB bus through an AR488 adapter have no interaction with operations on a USB-TMC instrument through pyvisa-py. They can run in parallel safely.
sequenceDiagram
participant A as bench-a lock
participant B as lan lock
Note over A: Serial within backend
activate A
A->>A: query dmm
A->>A: read response
deactivate A
activate B
B->>B: query scope
Note over A,B: Concurrent across backends
activate A
A->>A: query smu
B->>B: read response
deactivate B
A->>A: read response
deactivate A
Backend types
Section titled “Backend types”mcpyvisa supports four backend types, configured in mcpyvisa.toml:
| Backend | pyvisa string | Transport | Use case |
|---|---|---|---|
ar488 | @ar488 | Serial or TCP to AR488/Prologix adapter | GPIB instruments via affordable adapters |
pyvisa-py | @py | USB-TMC, LAN/VXI-11, serial | Modern instruments without NI-VISA |
system | (default) | System VISA library (NI-VISA, Keysight IO) | Full VISA stack with proprietary drivers |
sim | {path}@sim | YAML definition file | Development, testing, and learning without hardware |
Each backend creates its own pyvisa ResourceManager with the appropriate backend string. The InstrumentManager treats them uniformly — the same instrument_query tool works regardless of whether the instrument is on a GPIB bus behind an AR488 adapter or connected via USB-TMC through pyvisa-py.
Error hierarchy
Section titled “Error hierarchy”mcpyvisa uses a layered exception hierarchy rooted at McpyVisaError:
graph TD
ROOT["McpyVisaError"]
CFG["ConfigError"]
BACKEND["BackendError"]
CONN["ConnectionError"]
TMO["TimeoutError"]
INSTR["InstrumentError"]
NOTF["InstrumentNotFoundError"]
BUS["BusError"]
PME["PyMeasureError"]
ROOT --> CFG
ROOT --> BACKEND
ROOT --> INSTR
ROOT --> PME
ROOT --> BUS
BACKEND --> CONN
BACKEND --> TMO
INSTR --> NOTF
style ROOT fill:#78350f,stroke:#d97706,color:#fde68a
style CFG fill:#334155,stroke:#94a3b8,color:#e2e8f0
style BACKEND fill:#334155,stroke:#94a3b8,color:#e2e8f0
style CONN fill:#1e293b,stroke:#64748b,color:#cbd5e1
style TMO fill:#1e293b,stroke:#64748b,color:#cbd5e1
style INSTR fill:#334155,stroke:#94a3b8,color:#e2e8f0
style NOTF fill:#1e293b,stroke:#64748b,color:#cbd5e1
style PME fill:#334155,stroke:#94a3b8,color:#e2e8f0
style BUS fill:#334155,stroke:#94a3b8,color:#e2e8f0
Each layer catches errors from the layer below and wraps them with context. A TimeoutError from the backend becomes an InstrumentError at the tool level with a message like “Instrument ‘dmm’ (GPIB0::22::INSTR) did not respond to MEAS:VOLT:DC?”.
Configuration
Section titled “Configuration”mcpyvisa reads its configuration from TOML files, searched in order:
$MCPYVISA_CONFIGenvironment variable./mcpyvisa.tomlin the current directory~/.config/mcpyvisa/config.toml
The config defines backends, their connection parameters, and instrument aliases. See the configuration reference for the full schema.