oscilloscope-mcp
MCP server that lets an AI agent operate a bench oscilloscope over LAN/SCPI to observe physical pin behavior, bridging RTL simulation and hardware testing.
README
English | 日本語
oscilloscope-mcp
An MCP server that lets an AI agent (Claude Code, Cursor, custom) operate a real bench oscilloscope over the network.
There are two ways to use it:
1. Interactive: you ask, the agent drives the scope. Instead of reaching over to the bench to turn knobs and click through the scope's menus, you say what you want in plain language ("trigger on the 2nd falling edge of CH1", "show me CH1–CH3", "what's the frequency?"), and the agent sets it up on the instrument, reads it back, and shows you an interactive waveform view.
2. As a feedback channel when an AI agent does the work. When you hand FPGA development to an AI agent, this gives it feedback on how the design behaves on real hardware: the agent captures the real signals and checks automatically whether they match what the RTL simulation predicted, so nobody has to read the scope by eye to close the "RTL sim PASS / HW FAIL" gap. It also works as a general input path: feed the state of various instruments to the AI and let it act on what it sees.
Either way, you get the results in whatever form fits the job: structured data the agent can use directly as its next input (measurements with caveats, edge / run streams), a PNG screenshot for a quick human look, or a self-contained HTML viewer that draws the measured samples in the browser.
The scope is controlled under the hood over its standard SCPI interface, and you never write it or even need to know it exists.

One MCP-driven capture: an AI agent set the timebase, V/div, per-channel
offset, channel labels, and trigger on a RIGOL DS1104Z, then pulled the
screenshot back to the user. The four traces are GPIO 26 / 19 / 13 / 17 on a
Raspberry Pi 4 driven at 50 / 100 / 200 / 500 Hz via pigpio DMA waveform
output, and the scope reads each one back exactly, confirming the whole loop
end to end. Every setting was made by the agent through this MCP server's
tools, with no hands on the front panel.
What makes it different
-
The agent is given everything the instrument can do, as data, not a thin wrapper around a few commands. The full set of capabilities (for example, all 15 DS1000Z trigger types and ~95 parameters) is declared in a machine-readable profile, taken from the official programming guide and verified on real hardware. The agent can ask "what can I set here?" and get back the parameters it can actually set, so it isn't limited to the few operations the tool's author happened to think of.
-
Natural language in, validated settings out. Every request is checked against what the instrument actually supports; an impossible setting is rejected before it reaches the hardware. A bad request fails loudly instead of leaving the scope in a surprising state.
-
Every operation is also a reproducible, typed command. The same thing you asked for interactively can be scripted, shared, and run in CI.
-
Structured facts, plus screenshots you can check. The agent pulls out the numbers and caveats, and PNG screenshots come back for review (with optional cursor / channel-label annotation). Never a black box.
-
Self-contained HTML viewer. One file, no dependencies, opens in any browser. It reads the measured RAW samples from the scope and draws them in the browser, up to the scope's full memory. The controls match the scope itself: per-channel V/div, drag the GND offset, a cursor that snaps to samples, channel on/off.
-
Sim-vs-hardware diff. Give it a reference edge sequence (for example, from your RTL simulation) and it compares that against what the scope actually measured, returning structured added / missing / shifted edges.
-
Plug-in architecture. Add a new vendor / model with two files: a SCPI-dialect driver and a capability profile YAML. Capability is declared data, not code.
-
Tested. ~560 unit tests plus live conformance against real hardware.
Drivers ship for the RIGOL DS1000Z series (DS1054Z / DS1074Z / DS1104Z); only DS1104Z is verified on real hardware so far. The others use the same code path and profile, but are not conformance-tested yet.
Install
git clone https://github.com/masahiro-999/oscilloscope-mcp.git
cd oscilloscope-mcp
pip install -e .
Run as MCP server
# Set the scope's network address.
export SCOPE_MCP_HOST=<your-scope-ip> # e.g. 192.168.1.42
# Start the server (stdio transport, default).
python -m oscilloscope_mcp
# Or use the console script.
oscilloscope-mcp
Register with Claude Code
claude mcp add oscilloscope -- python -m oscilloscope_mcp
Then the scope_query, scope_screenshot, scope_trigger,
scope_measure, scope_measure_stat, scope_channel, scope_timebase,
scope_waveform, scope_acquire, scope_capture, scope_compare,
and scope_viewer tools become available to any session that connects
to this MCP server.
Tools exposed
| MCP tool | Purpose |
|---|---|
scope_query |
Raw SCPI command passthrough. Returns the response plus a caveats[] warning that observation limits are not auto-checked on this path. |
scope_screenshot |
Save a PNG of the current scope screen for user review. Optionally place manual cursors (Δt / ΔV) and channel labels (DIR / STP / NXT / CLK) to make the image easy to read. |
scope_trigger |
Read or configure the trigger; it covers every trigger type the instrument supports. Call with no arguments to read the current state (returns mode, globals, the mode's params, and accepted_params describing what's settable). To set, pass mode and/or the global sweep / coupling / holdoff_s, and put all type-specific parameters in a params object (e.g. {"source":"CHAN1","slope":"NEG","nth_edge":2,"idle_s":2e-3} for an Nth-edge trigger). mode is validated against the profile's full type list (6 standard + 9 option-licensed DS1000Z types), and each params key/value is validated against that mode's schema (enum membership, numeric range, channel source). Unsupported types/params are rejected with the valid list; option-licensed types emit a caveats[] warning. Pass action to control acquisition: RUN / STOP / SINGLE (arm one shot) / FORCE (force a trigger; NORMAL/SINGLE sweep only). status (TD/WAIT/RUN/AUTO/STOP) reflects the result. |
scope_measure |
Read scope-side automatic measurements (:MEAS:ITEM?) for a list of items on a source channel. Single-source items: VMAX VMIN VPP VTOP VBASE VAMP VAVG VRMS OVERSHOOT PRESHOOT PERIOD FREQUENCY RTIME FTIME PWIDTH NWIDTH PDUTY NDUTY; two-source (require source2): RDELAY FDELAY RPHASE FPHASE. Un-measurable values come back as null with a caveat. |
scope_measure_stat |
Read measurement statistics (:MEASure:STATistic:ITEM) for one or more items on a source: the accumulated current/max/min/average/deviation/count, rather than a single instantaneous value. stat_types defaults to all six (CURRent MAXimum MINimum AVERages DEViation COUNt); pass a subset to limit the query. mode optionally sets the statistics mode (DIFFerence / EXTRemum). reset=True clears accumulated data before querying. Returns {source, statistics: [{item, current, max, min, avg, dev, count}], caveats}. |
scope_channel |
Read or configure an analog channel's vertical setup. channel (1-based) is required. Pass any subset of scale_v_per_div, offset_v, coupling (AC/DC/GND), display, probe, bw_limit (20M/OFF), invert, units. Validated against the profile; 20 MHz BW limit emits a caveat. |
scope_timebase |
Read or configure the horizontal (timebase) setup. Pass any subset of s_per_div, offset_s, mode (MAIN/XY/ROLL). Validated against the profile; long timebase emits a memory-downgrade caveat. |
scope_waveform |
Capture one channel and return a compact threshold-quantized event stream, not raw ADC samples. Raw volts pass through a Schmitt-trigger comparator (threshold_v ± hysteresis_v/2) into a 0/1 stream, then run-length-encoded into runs [(t_us, level, dur_us)] and edges [(t_us, channel, RISE/FALL)] (times in µs, trigger at t=0). Hysteresis is mandatory: a single threshold on a noisy edge fragments into useless micro-pulses. A hysteresis_v over 20 % of the signal peak-to-peak emits a detection-miss caveats[] warning; an over-budget stream is truncated with a caveat. Args: source (default CHAN1), mode (NORMal or RAW; RAW reads the full acquisition memory up to 24M points instead of the ~1200-point screen decimation, and the scope must be stopped first), threshold_v (1.5), hysteresis_v (0.1). |
scope_acquire |
Read or configure the acquisition mode: type (NORMal/AVERages/PEAK/HRESolution), averages (2..1024 powers of 2), and memory_depth (AUTO or numeric, validated per active channel count). Call with no args to read (returns type, averages, memory_depth, sample_rate). Selecting AVERages emits a caveats[] warning that the update rate is reduced. |
scope_capture |
Declarative single-shot capture: configure, arm, wait, and read in one call. Composes trigger/channel/timebase/acquire setup, arms :SINGle, polls until triggered (or timeout_s), then reads per-channel waveforms (quantized to runs/edges/bus_runs), scope-side measurements, and an optional screenshot. Use sweep='AUTO' or 'NORMAL' to skip arm+poll and read the current screen memory immediately. Set waveform_mode='RAW' to read the full acquisition memory after the trigger fires (scope is already stopped after SINGLE). Returns {triggered, channels, waveform, bus_runs, measurements, screenshot_path, caveats}. |
scope_compare |
Sim vs HW reference diff: the project's core goal. Takes a golden edge/run list from your RTL simulator and compares it against hardware (live capture or provided data). Returns {matched, shifted[{ref_t_us, hw_t_us, delta_us, kind}], missing[{t_us, kind}], added[{t_us, kind}], first_divergence_us, summary, caveats}. Two modes: live (captures from scope if hw_runs/hw_edges not provided) or offline (pass pre-captured data directly, no scope connection needed). tolerance_us (default 0.05 = 50 ns) sets how close two same-kind edges must be to count as a match. Signal-agnostic: works on SPI, I2C, UART, ULPI, or any digital signal. |
scope_viewer |
Generate a self-contained interactive HTML waveform viewer. Reads RAW waveform data (scope must be stopped) and embeds the measured analog voltage samples into an HTML file. Features: per-channel ON/OFF toggle, per-channel V/div dropdown (10mV–100V), draggable GND offset markers (▶), T/div dropdown with 1-2-5 auto-stepping on scroll-zoom, drag-to-pan, vertical cursor snapped to sample points with fixed voltage readout, trigger position marker (▼T at t=0, derived from :TRIG:POS?). depth controls observation window centered on the trigger: "low" (30k pts, ~120µs, ~1s transfer), "mid" (300k, ~1.2ms, ~3s), "high" (3M+, ~12ms, ~17s). A bigger depth transfers more slowly, so a window around the trigger is the usual choice, with the scope's full memory as the upper limit. Browser renders millions of points via per-pixel min/max envelope when zoomed out, individual samples + dots when zoomed in. |
Layout
oscilloscope-mcp/ # repo (kebab-case)
├── README.md
├── LICENSE
├── pyproject.toml
├── src/oscilloscope_mcp/ # Python package (snake_case)
│ ├── __init__.py
│ ├── __main__.py # `python -m oscilloscope_mcp` entry
│ ├── server.py # FastMCP server: tool registration + dispatch
│ ├── transport/
│ │ └── scpi_lan.py # raw TCP SCPI client (zero third-party deps)
│ ├── instruments/
│ │ ├── _base.py # Scope ABC, vendor-agnostic interface
│ │ ├── __init__.py # MODEL_REGISTRY + open_scope() dispatch
│ │ ├── rigol_ds1000z.py # RIGOL DS1054Z / DS1104Z driver
│ │ └── profiles/
│ │ ├── _ds1000z_family.yaml # shared trigger + acquisition schema
│ │ ├── rigol_ds1104z.yaml
│ │ └── rigol_ds1054z.yaml
│ └── helpers/
│ ├── caveat_calc.py # capability + current setting → caveats[]
│ ├── trigger.py # trigger profile: validate / normalize / caveats
│ ├── measure.py # measurement profile: validate / normalize / parse
│ ├── acquisition.py # channel + timebase profile validation
│ ├── quantize.py # raw volts → 0/1 stream (Schmitt hysteresis)
│ ├── rle.py # digital stream → runs[(t_us, level, dur_us)]
│ ├── edges.py # runs → edges[(t_us, ch, RISE/FALL)]
│ ├── bus.py # multi-channel runs → bus_runs[(t_us, value, dur_us)]
│ ├── reference_diff.py # sim vs HW edge diff (the whole point of the project)
│ ├── viewer.py # self-contained HTML waveform viewer generator
│ ├── glitch_list.py # runs < min_width (runt/glitch detection)
│ ├── edge_interval_stats.py # jitter / clock stability stats
│ ├── pattern_search.py # RLE bit-pattern template matching
│ ├── voltage_histogram.py # voltage distribution / bimodal detection
│ ├── fft_peaks.py # top-N frequency peaks (EMI / switching noise)
│ ├── causality_check.py # cross-channel "B follows A within N µs"
│ └── envelope_downsample.py # min/max decimation for visualization
└── tests/ # unit + live conformance
Environment
| Env var | Required | Meaning |
|---|---|---|
SCOPE_MCP_HOST |
yes | scope IP or hostname |
SCOPE_MCP_PORT |
no (default 5555) | SCPI TCP port |
SCOPE_MCP_MODEL |
no | model key (e.g. RIGOL_DS1104Z). If unset, *IDN? is parsed and matched against each profile's idn_match regex |
Env-var prefix SCOPE_MCP_* is project-specific so it does not collide
with other shells driving unrelated tools.
Adding a new instrument
Two new files plus one registry line:
-
profile (
src/oscilloscope_mcp/instruments/profiles/<model>.yaml): capability (analog BW, sample rate per channel count, memory depth,idn_matchregex). -
driver (
src/oscilloscope_mcp/instruments/<vendor>_<series>.py): subclassoscilloscope_mcp.instruments._base.Scope, implement each abstract method using the vendor's SCPI dialect. -
registry line: add an entry to
MODEL_REGISTRYinsrc/oscilloscope_mcp/instruments/__init__.py. -
conformance test: run
SCOPE_MCP_HOST=<ip> \ SCOPE_MCP_CONFORMANCE_MODEL=<MODEL_KEY> \ pytest tests/test_instrument_conformance.py -v
No other module changes are required.
Limits (tool warnings)
All structured outputs include a caveats[] field. Going outside a limit the
profile declares (for example, 4-channel mode forces 250 MSa/s on a DS1000Z,
which drops practical observation to ~25 MHz) automatically adds a caveat. The
agent must read caveats[] before trusting any number.
Verified on real hardware
Everything below was tested against a live RIGOL DS1104Z (the
tests/test_instrument_conformance.py suite): connection / IDN, raw SCPI,
screenshot, channel / timebase / acquire read+set, measurements and statistics,
waveform capture in NORMal and RAW (full memory), declarative scope_capture,
run control, trigger mode switching across all 15 types, and the validation
(rejection) paths.
Built from the official programming guide and unit-tested, but not yet confirmed on real hardware. Treat these as based on the spec until they are verified:
- Per-trigger-type parameters beyond EDGE (pulse width, slope time, the RS232 / IIC / SPI fields, …). Every type's parameter ranges / enums come from the guide and are validated before anything is sent, and the mode switch is hardware-checked for all 15 types, but the individual parameters of the non-EDGE types are not each tested on hardware.
- Acquire types other than
NORMal(AVERages / PEAK / HRESolution) and explicitmemory_depthvalues. scope_compareagainst a real simulation. The diff engine is unit-tested and verified on real captured edges shifted by a known amount, but a genuine RTL-simulation-output vs hardware-capture run has not been done yet.- Models other than DS1104Z (DS1054Z / DS1074Z): same code path + profile, not conformance-tested.
Security / trust model
This is a local-only stdio MCP server. It speaks to an MCP client over its parent process's stdin/stdout, and it does not open any network port. The client (Claude Code, Cursor, your own agent) launches it as a subprocess, and the trust boundary is the local user account.
Within that boundary:
scope_queryis a raw SCPI passthrough with no command validation. Any SCPI the client sends is forwarded to the scope. That is intentional: SCPI on a bench scope is mostly read operations, and even changes that alter the setup (timebase, channel scale, trigger) are easy to undo. But it does mean an agent with this MCP server enabled can fully reconfigure your scope and read everything on its screen. Use it accordingly.- The SCPI connection to the scope itself is unauthenticated TCP on the scope's LAN port (RIGOL DS1000Z uses 5555). Anything on the same network can already talk to the scope. Installing this MCP server does not change that; it just gives the AI agent the same access you already have.
If you want to lock down the scope further, do it on the scope's network (VLAN, firewall) rather than at this layer. The MCP server is not the right place to police what an agent on the same machine can ask the scope to do.
Tests
# Unit tests (no hardware).
pytest tests/ -v
# Live conformance against a real scope.
SCOPE_MCP_HOST=<your-scope-ip> \
pytest tests/test_instrument_conformance.py -v
Adding support for your instrument
Any oscilloscope with a publicly documented control interface (SCPI or otherwise) can be supported. Thanks to the plug-in architecture above, a new vendor / model is just a driver subclass plus a capability profile, with no changes to the core. See Adding a new instrument.
Forks that add instrument support are welcome. Requests are too: open an issue for an instrument you'd like supported (a link to its programming guide helps) and we'll consider it.
推荐服务器
Baidu Map
百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。
Playwright MCP Server
一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。
Magic Component Platform (MCP)
一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。
Audiense Insights MCP Server
通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。
VeyraX
一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。
graphlit-mcp-server
模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。
Kagi MCP Server
一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。
e2b-mcp-server
使用 MCP 通过 e2b 运行代码。
Neon MCP Server
用于与 Neon 管理 API 和数据库交互的 MCP 服务器
Exa MCP Server
模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。