Skip to content

pymeasure Integration

mcpyvisa’s raw SCPI tools give an LLM complete freedom to send any command string to any instrument. That freedom is dangerous. A typo in a voltage command, an out-of-range current value, or a misconfigured compliance limit could damage equipment worth tens of thousands of dollars. The pymeasure integration exists to constrain that freedom where it matters most — at the boundary between software and hardware.

The core problem is straightforward: raw SCPI is untyped. The string SOUR:VOLT 999 is syntactically valid, but sending it to a Keithley 2400 (whose source voltage range tops out at 210 V) would either damage the instrument or, if the instrument is robust enough to reject it, waste a round-trip to discover the error.

pymeasure solves this by providing validated instrument drivers maintained by the scientific Python community. Each driver encodes the specific capabilities and limits of its instrument:

  • Range-checked properties — A source_voltage setter on the Keithley 2400 driver knows the valid range is [-210, 210]. Values outside that range are rejected in Python before any SCPI command is constructed.
  • Type conversion and unit handling — Reading voltage on an HP 34401A parses the instrument’s exponential notation response (+3.29847E+00) into a Python float.
  • High-level methods — Operations like apply_voltage() encode multi-step SCPI sequences that configure source mode, set the range, set compliance, and enable output in a single call.
  • Community-tested drivers — The pymeasure project has hundreds of contributors and CI-tested driver implementations.

The 8 supported instruments cover the most common categories of bench equipment: multimeters, source-measure units, function generators, signal generators, LCR meters, and electrometers. Instruments without pymeasure drivers still work through the raw instrument_query and instrument_write tools — the pymeasure layer is additive, not exclusive.

pymeasure is an optional dependency. Install it with:

Terminal window
uv add mcpyvisa[pymeasure]

When pymeasure is installed, the server conditionally registers four additional MCP tools on top of the existing raw SCPI tools:

  • instrument_inspect — Discover validated properties and methods for an instrument’s pymeasure driver
  • instrument_get — Read a property (measurement or setting) with type conversion
  • instrument_set — Set a property with range validation
  • instrument_call — Call high-level methods like apply_voltage() or shutdown()

If pymeasure is not installed, these tools simply do not appear. The server detects availability at import time and skips registration cleanly.

graph TD
    MCPYVISA["mcpyvisa<br/>FastMCP server"]
    PM_TOOLS["pymeasure_tools.py<br/>4 MCP tools"]
    PM_MGR["PyMeasureManager<br/>instrument lifecycle"]
    PM_REG["pymeasure_registry<br/>IDN matching"]
    PM_INTRO["pymeasure_introspect<br/>property extraction"]
    PYMEASURE["pymeasure<br/>instrument drivers"]
    PYVISA["pyvisa<br/>ResourceManager"]
    BACKEND["Backend<br/>(@ar488 / @py / @ni)"]
    INSTR["Instrument"]

    MCPYVISA --> PM_TOOLS
    PM_TOOLS --> PM_MGR
    PM_TOOLS --> PM_INTRO
    PM_MGR --> PM_REG
    PM_MGR --> PYVISA
    PYMEASURE --> PYVISA
    PYVISA --> BACKEND
    BACKEND --> INSTR

    style MCPYVISA fill:#78350f,stroke:#d97706,color:#fde68a
    style PM_TOOLS fill:#78350f,stroke:#d97706,color:#fde68a
    style PM_MGR fill:#78350f,stroke:#d97706,color:#fde68a
    style PM_REG fill:#334155,stroke:#94a3b8,color:#e2e8f0
    style PM_INTRO fill:#334155,stroke:#94a3b8,color:#e2e8f0
    style PYMEASURE fill:#1e293b,stroke:#64748b,color:#cbd5e1
    style PYVISA fill:#1e293b,stroke:#64748b,color:#cbd5e1
    style BACKEND fill:#334155,stroke:#94a3b8,color:#e2e8f0
    style INSTR fill:#1e293b,stroke:#64748b,color:#cbd5e1

All pymeasure tools accept the same instrument parameter as every other mcpyvisa tool. The InstrumentManager resolves it through the standard alias resolution:

  1. If the string contains ::, treat it as a VISA resource string (e.g., GPIB0::22::INSTR)
  2. Otherwise, look it up as a configured alias (e.g., dmm, smu)
  3. If unresolved, return an error listing known instruments and aliases

This means pymeasure tool calls use the same addressing as raw SCPI tools:

instrument_get("dmm", "voltage") # alias
instrument_get("GPIB0::22::INSTR", "voltage") # VISA resource string

pymeasure and pyvisa are entirely synchronous libraries. They expect write() and read() to block until the instrument responds. mcpyvisa’s MCP tools are async — they run in the FastMCP event loop.

All pyvisa calls are wrapped in asyncio.to_thread():

sequenceDiagram
    participant LLM as LLM
    participant MCP as MCP Tool<br/>(async)
    participant Thread as Worker Thread<br/>(sync)
    participant PM as pymeasure<br/>Driver
    participant PV as pyvisa<br/>ResourceManager
    participant I as Instrument

    LLM->>MCP: instrument_get("dmm", "voltage")
    MCP->>MCP: resolve "dmm"<br/>-> GPIB0::22::INSTR
    MCP->>Thread: asyncio.to_thread()
    Thread->>PM: instrument.voltage
    PM->>PV: resource.query("MEAS:VOLT:DC?")
    PV->>I: SCPI command via backend
    I-->>PV: +3.29847E+00
    PV-->>PM: "+3.29847E+00"
    PM-->>Thread: 3.29847
    Thread-->>MCP: 3.29847
    MCP-->>LLM: "3.29847"

Each hop in this chain exists for a reason:

  1. MCP tool (async) — The tool function receives the call from the LLM via FastMCP. It runs in the event loop.

  2. asyncio.to_thread() — pymeasure is synchronous, so it cannot run in the event loop without blocking everything. to_thread() dispatches the pymeasure call to a thread pool worker.

  3. pymeasure driver — The driver’s voltage property getter calls self.adapter.query("MEAS:VOLT:DC?"). This is standard pyvisa usage — the driver does not know or care which backend is involved.

  4. pyvisa ResourceManager — Routes the call through the configured backend (@ar488, @py, or system VISA). For AR488 backends, this means translating the VISA operation into ++ command sequences. For pyvisa-py, it might be a USB-TMC transfer. The pymeasure driver is unaware of these details.

  5. The backend communicates with the instrument, the response propagates back through each layer, and pymeasure parses the result into a typed Python value.

The per-backend lock serializes all I/O on shared buses, preventing concurrent tool calls from interleaving commands on the same backend.

The safety benefit of pymeasure comes from validation that happens entirely in Python, before any SCPI command is constructed. Consider an LLM attempting to set a dangerously high source voltage on a Keithley 2400:

graph TD
    SET["instrument_set('smu', 'source_voltage', '999')"]
    THREAD["Run in thread"]
    DRIVER["pymeasure Keithley2400"]
    VALIDATOR["strict_range validator<br/>min=-210, max=210"]
    REJECT["ValueError: 999 is not in range [-210, 210]"]
    SAFE["Instrument untouched"]

    SET --> THREAD
    THREAD --> DRIVER
    DRIVER --> VALIDATOR
    VALIDATOR --> REJECT
    REJECT --> SAFE

    style SET fill:#78350f,stroke:#d97706,color:#fde68a
    style THREAD fill:#334155,stroke:#94a3b8,color:#e2e8f0
    style DRIVER fill:#334155,stroke:#94a3b8,color:#e2e8f0
    style VALIDATOR fill:#78350f,stroke:#d97706,color:#fde68a
    style REJECT fill:#7f1d1d,stroke:#dc2626,color:#fecaca
    style SAFE fill:#14532d,stroke:#22c55e,color:#bbf7d0

The key insight: the ValueError is raised by pymeasure’s strict_range validator inside the property setter, before the driver even constructs a SOUR:VOLT 999 string. The SCPI command is never built. The instrument never receives anything. The bus is never touched.

This matters especially for source-measure units (SMUs) like the Keithley 2400, which can source up to 210 V and 1 A. An unconstrained LLM sending raw SCPI could potentially damage a device under test or the SMU itself. With pymeasure in the path, the driver’s validated properties act as guardrails.

When a pymeasure tool is first called for a given instrument, the PyMeasureManager needs to find the right pymeasure driver class. It does this by matching the instrument’s *IDN? response against patterns in the registry.

The matching process:

  1. Query the instrument’s identity via instrument_identify (or use a cached result from a previous discover_instruments call)
  2. Parse the *IDN? response: manufacturer, model, serial, firmware
  3. Match against regex patterns in pymeasure_registry.py that account for manufacturer name variations (Hewlett-Packard, HP, and Agilent all map to the same instruments for historical reasons — HP instruments were rebranded when Agilent split off from HP in 1999, and some instruments report either name depending on firmware version)
  4. Lazily import the matched pymeasure driver class
  5. Create a pymeasure instrument object connected through the existing pyvisa ResourceManager
  6. Cache the result keyed by instrument identifier (alias or resource string)

Subsequent calls reuse the cached instance. When a backend disconnects, the manager invalidates all cached instruments for that backend.

The instrument_inspect tool does not just list property names. It digs into pymeasure’s property factory closures to extract the SCPI commands, valid value ranges, and validator functions that each property uses. This gives the LLM enough context to understand what a property does, what values it accepts, and what SCPI commands it maps to.

pymeasure builds properties using three factory methods on the Instrument base class:

  • measurement — Read-only. Triggers a real measurement (e.g., voltage sends MEAS:VOLT:DC?).
  • control — Read/write. Gets or sets a value with validation (e.g., source_voltage has a strict_range validator).
  • setting — Write-only. Configures the instrument without readback (e.g., trigger_mode).

These factories store metadata — the SCPI get/set commands, the validator function, the valid values list — in the closure variables of the generated property. The introspection module walks the class hierarchy, inspects each property’s fget/fset closures with inspect.getclosurevars(), and extracts this metadata into a structured format the LLM can reason about.

Instrumentpymeasure ClassTypeKey Validated Properties
HP 33120AHP33120AFunction Generatorfrequency, amplitude, offset, shape
HP 34401AHP34401AMultimetervoltage_range, current_range, nplc
HP 3478AHP3478AMultimeterrange, trigger_mode
HP 8657BHP8657BSignal Generatorfrequency, power
Agilent 4284AAgilent4284ALCR Meterfrequency, voltage, mode
Keithley 2000Keithley2000Multimetervoltage_range, current_range, resistance_range
Keithley 2400Keithley2400Source-Measure Unitsource_voltage, source_current, compliance_voltage, compliance_current
Keithley 6517BKeithley6517BElectrometervoltage_range, current_range

pyvisa-ar488 is a standard pyvisa backend. This means any Python code that uses pyvisa can talk to AR488 adapters — not just mcpyvisa. Jupyter notebooks, standalone measurement scripts, other pymeasure programs, pytest-based hardware test suites — anything that opens a ResourceManager can use it.

import pyvisa
rm = pyvisa.ResourceManager("@ar488")
instr = rm.open_resource("GPIB0::22::INSTR")
print(instr.query("*IDN?"))

The @ar488 backend string tells pyvisa to load pyvisa-ar488 instead of the default NI-VISA backend. From that point on, all standard pyvisa operations work: write(), read(), query(), read_stb(), clear(), and so on. The backend translates each into the appropriate AR488 ++ command sequences.

This was a deliberate architectural choice. By implementing the full VisaLibraryBase interface, pyvisa-ar488 becomes a drop-in replacement for NI-VISA in any codebase that talks to GPIB instruments. Existing measurement code does not need to be rewritten — only the ResourceManager constructor changes. The broader scientific Python community benefits from the backend even if they never use mcpyvisa or MCP.