Skip to main content
Glama
README.md
# pyvisa-mcp

MCP server that puts [PyVISA](https://github.com/pyvisa/pyvisa) in front of AI tools so they can discover, **learn**, and drive oscilloscopes (and other SCPI instruments) over USB, GPIB, serial, and Ethernet.

This project is not affiliated with the PyVISA maintainers. It uses the public PyVISA API.

## How learn works

SCPI has no “dump every menu” command. Instead the server builds a **local brain** per instrument (keyed by `*IDN?`) and reuses it next session.

Saved under `~/.local/share/pyvisa-mcp/` (override with `PYVISA_MCP_MEMORY`):

| What is stored | Where it comes from |
|----------------|---------------------|
| Identity, dialect, class | `*IDN?` |
| Options / HELP headers / setup dump | `*OPT?`, `SYST:HELP:HEAD?`, `*LRN?` (`deep=true`) |
| Programming manual (PDF + extracted text) | Web search, or `manual_url=` / a local file |
| SCPI that already worked or failed on this firmware | Every `query` / `write` |

```
open_instrument  192.168.1.10
learn_instrument  deep=true  fetch_manual=true
search_scope_knowledge  query="FFT"
search_scope_knowledge  query="I2C decode"
search_scope_knowledge  query=":WAV:DATA?"
```

Next chat, `learn_instrument` reloads that brain. Pass a PDF you already have:

```
learn_instrument  manual_url="/path/to/DS1054Z_ProgrammingGuide.pdf"
```

`search_scope_knowledge` searches the manual text, HELP headers, and remembered commands. That is how the model reuses what it learned instead of guessing vendor SCPI.

`list_learned_instruments` / `forget_instrument` manage the store. Resources: `visa://learned`, `visa://learned/{key}`.

## Talk to a network oscilloscope

LAN scopes often **do not** appear in `list_instruments`. Open by IP (Utility → I/O → LAN on the scope):

```
open_instrument resource="192.168.1.10"
```

that becomes `TCPIP0::192.168.1.10::inst0::INSTR` (VXI-11 / LXI).

If it times out, try raw SCPI (Keysight/Siglent often **5025**, Rigol DS1000Z often **5555**):

```
open_instrument resource="192.168.1.10:5025"
```

Then measure / capture:

```
learn_instrument
scope_measure kinds=["vpp","freq"]
scope_capture_waveform channel=1
export_waveform path="/tmp/ch1.csv"
```

## Tools

| Tool | Purpose |
|------|---------|
| `list_instruments` | Local VISA resources (USB/GPIB/serial; LAN often missing) |
| `discover_network_instruments` | mDNS browse for LXI / VXI-11 / HiSLIP / SCPI-raw |
| `open_instrument` | Open by VISA name **or IP** (`192.168.1.10`, `host:5025`, IPv6) |
| `learn_instrument` | Identify, save/reload the local brain; `deep` / `fetch_manual` |
| `fetch_manual` | Download or ingest a programming guide into that brain |
| `search_scope_knowledge` | Search the saved manual + enumerated SCPI + remembered commands |
| `list_learned_instruments` / `forget_instrument` | List or delete saved brains |
| `query` / `write` / `query_binary` | Raw SCPI (successes/failures are remembered) |
| `read_errors` | Drain `SYST:ERR?` |
| `scope_capture_waveform` | Scaled volts-vs-time, downsampled for MCP |
| `export_waveform` | Full trace to CSV / NPY / BIN (no downsample) |
| `scope_measure` | Vpp, frequency, RMS, … |
| `scope_configure` | V/div, s/div, coupling, edge trigger |
| `scope_acquire` | run / stop / single / autoset (`confirm=true` for autoset) |
| `scope_screenshot` | PNG/BMP/JPEG when the vendor returns a real image |
| `suggest_scpi` | Map a known intent to SCPI; says “no mapping” instead of guessing |

Resources: `visa://howto`, `visa://scpi`, `visa://resource-names`, `visa://oscilloscope/{vendor}`, `visa://learned`, `visa://learned/{key}`, `visa://open`, `visa://backend`.

High-level `scope_*` tools refuse DMMs / spectrum analyzers / PSUs unless you pass `dialect=`.

## Install

```bash
pip install -e ".[dev]"
# or
uv sync --extra dev
```

Runtime dependencies include **pyvisa-py**. You do **not** need NI-VISA or Keysight IO Libraries for LAN/SOCKET instruments.

USB-TMC without a vendor VISA: `pip install pyvisa-mcp[usb]` (needs libusb).

## Run

stdio (what Grok, Claude Desktop, Cursor, etc. spawn):

```bash
pyvisa-mcp
# python -m pyvisa_mcp
```

HTTP (optional):

```bash
pyvisa-mcp --transport streamable-http --port 8000
# endpoint: http://127.0.0.1:8000/mcp
```

Inspector:

```bash
uv run mcp dev src/pyvisa_mcp/server.py
```

## Connect an AI host

### Grok (`~/.grok/config.toml`)

```toml
[mcp_servers.pyvisa]
command = "uv"
args = ["run", "--directory", "/path/to/pyvisa-mcp", "pyvisa-mcp"]
env = { PYVISA_LIBRARY = "@py" }
startup_timeout_sec = 45
tool_timeout_sec = 90
```

After install onto PATH:

```toml
[mcp_servers.pyvisa]
command = "pyvisa-mcp"
env = { PYVISA_LIBRARY = "@py" }
```

### Claude Desktop / Cursor (`.mcp.json`)

```json
{
  "mcpServers": {
    "pyvisa": {
      "command": "pyvisa-mcp",
      "env": { "PYVISA_LIBRARY": "@py" }
    }
  }
}
```

## Environment

| Variable | Meaning |
|----------|---------|
| `PYVISA_LIBRARY` / `PYVISA_MCP_BACKEND` | VISA backend. `@py` = pyvisa-py. Path to `visa.so` / `visa32.dll` for NI/Keysight. `sim.yaml@sim` for [pyvisa-sim](https://pyvisa.readthedocs.io/projects/pyvisa-sim/). |
| `PYVISA_MCP_TIMEOUT_MS` | Default I/O timeout (ms). Default `10000`. |
| `PYVISA_MCP_ALLOW` | Comma-separated globs of allowed resources, e.g. `TCPIP::*`. |
| `PYVISA_MCP_READONLY` | `1` to allow queries only. |
| `PYVISA_MCP_MEMORY` | Path to `instruments.json`. Default `~/.local/share/pyvisa-mcp/instruments.json`. `off` disables saving. |

`*RST` and autoset need `confirm=true`.

## Vendor dialects

`learn_instrument` maps `*IDN?` **manufacturer + model class** to a dialect (not a substring of HANTEK/INSTEK):

- **keysight** (Agilent / HP InfiniiVision-style scopes)
- **tektronix** (`CURV?`, `MEASU:IMM:*`)
- **rigol**
- **siglent** (`C1`, `C1:PAVA?`)
- **lecroy**
- **generic** — unknown vendors, and Keysight/Tek boxes that are not scopes (DMM, SA, PSU, AWG)

Firmware forks still happen. If a helper fails: `search_scope_knowledge`, then `query` / `write`, then `read_errors`.

`scope_capture_waveform` min/max-downsamples (default 400 points) so MCP payloads stay small. Use `export_waveform` for the full record.

## Tests

```bash
uv run pytest
```

Tests use an in-memory fake oscilloscope (no hardware). Optional [pyvisa-sim](https://github.com/pyvisa/pyvisa-sim) YAML lives in `tests/sim/scope.yaml`.

## Safety

This process can change instrument state: timebase, trigger, acquisition, autoset, `*RST`. Point `PYVISA_MCP_ALLOW` at the instruments you mean to touch, use `PYVISA_MCP_READONLY=1` when you only want to look, and do not expose the HTTP transport on an untrusted network.

TDQS

A3.5/5.0

Scored across 22 tools

Disambiguation4/5

Tools have largely distinct purposes, with clear boundaries between generic VISA operations and oscilloscope-specific ones. Minor potential overlap exists between identify and learn_instrument, and between query and suggest_scpi (when executing), but descriptions clarify usage.

Naming Consistency5/5

All tool names use consistent snake_case, with a clear 'scope_' prefix for oscilloscope-specific tools. This predictable pattern aids discoverability and grouping.

Tool Count3/5

22 tools is on the heavy side for the stated domain, slightly exceeding the typical 3-15 range. While many tools earn their place, some could potentially be consolidated (e.g., identify and learn_instrument), making the set borderline.

Completeness5/5

The surface covers the full lifecycle of instrument control: discovery, connection, identification, learning, SCPI communication, error handling, configuration, and scope-specific operations like waveform capture, measurement, acquisition, and screenshot. No major gaps are apparent for the domain.

Maintenance

ActivityMaintained
ResponsivenessNo issues