Skip to content

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.

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.

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)

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.

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, @ni with 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 @ar488 backends. 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.

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.

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 like GPIB0::22::INSTR and 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 ResourceManager instances 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_instruments tool. 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.

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.

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.

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

mcpyvisa supports four backend types, configured in mcpyvisa.toml:

Backendpyvisa stringTransportUse case
ar488@ar488Serial or TCP to AR488/Prologix adapterGPIB instruments via affordable adapters
pyvisa-py@pyUSB-TMC, LAN/VXI-11, serialModern instruments without NI-VISA
system(default)System VISA library (NI-VISA, Keysight IO)Full VISA stack with proprietary drivers
sim{path}@simYAML definition fileDevelopment, 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.

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?”.

mcpyvisa reads its configuration from TOML files, searched in order:

  1. $MCPYVISA_CONFIG environment variable
  2. ./mcpyvisa.toml in the current directory
  3. ~/.config/mcpyvisa/config.toml

The config defines backends, their connection parameters, and instrument aliases. See the configuration reference for the full schema.