pyvisa-mcp
# 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
Scored across 22 tools
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.
All tool names use consistent snake_case, with a clear 'scope_' prefix for oscilloscope-specific tools. This predictable pattern aids discoverability and grouping.
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.
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.