siglent-sdg-mcp
# siglent-sdg-mcp
[](https://github.com/MagnusJohansson/siglent-sdg-mcp/actions/workflows/ci.yml)
[](https://github.com/MagnusJohansson/siglent-sdg-mcp/actions/workflows/codeql.yml)
[](https://scorecard.dev/viewer/?uri=github.com/MagnusJohansson/siglent-sdg-mcp)
[](https://www.npmjs.com/package/siglent-sdg-mcp)
[](LICENSE)
A [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server that lets AI assistants control Siglent SDG waveform generators over your local network. Connect Claude to your bench and generate, modulate, and configure signals through natural language.
## Overview
This MCP server communicates with Siglent SDG waveform generators via SCPI commands over TCP sockets (port 5025). No VISA drivers or NI-MAX installation required — just a network connection to your generator.

**Key features:**
- 20 tools covering output control, waveform generation, modulation, sweep, burst, arbitrary waveforms, and more (21 with the opt-in raw SCPI tool)
- Dual-channel support (C1 and C2)
- 8 waveform types: sine, square, ramp, pulse, noise, arbitrary, DC, PRBS
- 8 modulation types: AM, DSB-AM, FM, PM, PWM, ASK, FSK, PSK
- Auto-connect on startup via environment variable
- Query queue serializes commands automatically — tools can safely run in parallel
- Read-only SCPI query escape hatch; raw SCPI commands are opt-in
- Security guardrails: command-injection protection, query-only `scpi_query`, connect-target allowlist (see [Security](#security))
## Compatibility
| Status | Model |
|--------|-------|
| Tested | SDG1032X |
| Expected to work | SDG1000X series (SDG1062X, etc.) |
| May work | Other Siglent SDG models with SCPI over TCP support |
The server uses standard SCPI commands from the SDG series programming guide. Other Siglent models that support the same command set over port 5025 should work with little or no modification.
## Quick Start
You need a Siglent waveform generator accessible on your network (TCP port 5025). Pick one of the three options below and add the config to your `.mcp.json` (in your project directory, or `~/.claude/.mcp.json` for global access).
Replace `192.168.1.126` with your generator's IP address.
### Option A: Docker (recommended)
No Node.js installation required. Works on Linux, macOS, and Windows (via WSL2 or Docker Desktop).
```json
{
"mcpServers": {
"siglent-sdg": {
"type": "stdio",
"command": "docker",
"args": [
"run", "--rm", "-i",
"-e", "SIGLENT_SDG_IP=192.168.1.126",
"ghcr.io/magnusjohansson/siglent-sdg-mcp:latest"
]
}
}
}
```
### Option B: npx
Requires Node.js 20+. Downloads and runs the package automatically.
```json
{
"mcpServers": {
"siglent-sdg": {
"type": "stdio",
"command": "npx",
"args": ["-y", "siglent-sdg-mcp"],
"env": {
"SIGLENT_SDG_IP": "192.168.1.126"
}
}
}
}
```
### Option C: Clone and build
```bash
git clone https://github.com/magnusjohansson/siglent-sdg-mcp.git
cd siglent-sdg-mcp
npm install
npm run build
```
```json
{
"mcpServers": {
"siglent-sdg": {
"type": "stdio",
"command": "node",
"args": ["/path/to/siglent-sdg-mcp/build/index.js"],
"env": {
"SIGLENT_SDG_IP": "192.168.1.126"
}
}
}
}
```
Replace `/path/to/siglent-sdg-mcp` with the actual path to your clone.
### Environment Variables
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `SIGLENT_SDG_IP` | No | — | Generator IP address for auto-connect on startup |
| `SIGLENT_IP` | No | — | Fallback if `SIGLENT_SDG_IP` is not set |
| `SIGLENT_SDG_PORT` | No | `5025` | TCP port (only change if your setup differs) |
| `SIGLENT_PORT` | No | `5025` | Fallback if `SIGLENT_SDG_PORT` is not set |
| `SIGLENT_ALLOWED_HOSTS` | No | — | Comma-separated hosts the `connect` tool may use, in addition to `SIGLENT_SDG_IP`. `*` allows any host (not recommended). See [Security](#security) |
| `SIGLENT_ALLOW_RAW_SCPI` | No | `false` | Register the raw `scpi_command` tool. Requires `SIGLENT_SDG_IP` or `SIGLENT_ALLOWED_HOSTS` naming specific hosts (`*` is not accepted) |
### Auto-Connect Behavior
If `SIGLENT_SDG_IP` (or `SIGLENT_IP`) is set, the server attempts to connect to the generator immediately after starting. This runs in the background and does **not** block the MCP server — Claude can start using other tools right away. If the generator is offline or unreachable, the server logs a warning and you can connect manually later using the `connect` tool.
If neither variable is set, the server starts without a connection. Use the `connect` tool to connect when ready. In that case `connect` only accepts private-LAN IP addresses; see [Security](#security).
## Using with Other AI Clients
The Quick Start examples above use Claude Code's `.mcp.json` format, which includes a `"type": "stdio"` field. Other AI clients use the same JSON structure but **without** the `"type"` field and with different config file locations.
### Claude Desktop
Edit `claude_desktop_config.json`:
- **Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
- **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
#### Docker
```json
{
"mcpServers": {
"siglent-sdg": {
"command": "docker",
"args": [
"run", "--rm", "-i",
"-e", "SIGLENT_SDG_IP=192.168.1.126",
"ghcr.io/magnusjohansson/siglent-sdg-mcp:latest"
]
}
}
}
```
#### npx
```json
{
"mcpServers": {
"siglent-sdg": {
"command": "npx",
"args": ["-y", "siglent-sdg-mcp"],
"env": {
"SIGLENT_SDG_IP": "192.168.1.126"
}
}
}
}
```
> **Note:** You must fully restart Claude Desktop after changing the config file.
### Cursor
Edit one of:
- **User-level:** `~/.cursor/mcp.json` (available across all projects)
- **Project-level:** `.cursor/mcp.json` (shared with your team via version control)
You can also add servers through the UI: **Settings > Cursor Settings > MCP > Add new global MCP server**.
#### Docker
```json
{
"mcpServers": {
"siglent-sdg": {
"command": "docker",
"args": [
"run", "--rm", "-i",
"-e", "SIGLENT_SDG_IP=192.168.1.126",
"ghcr.io/magnusjohansson/siglent-sdg-mcp:latest"
]
}
}
}
```
#### npx
```json
{
"mcpServers": {
"siglent-sdg": {
"command": "npx",
"args": ["-y", "siglent-sdg-mcp"],
"env": {
"SIGLENT_SDG_IP": "192.168.1.126"
}
}
}
}
```
### Windsurf
Edit `mcp_config.json`:
- **Windows:** `%USERPROFILE%\.codeium\windsurf\mcp_config.json`
- **macOS/Linux:** `~/.codeium/windsurf/mcp_config.json`
You can also configure servers through the UI: **Cascade panel > MCP icon > Manage MCP Servers > View raw config**.
#### Docker
```json
{
"mcpServers": {
"siglent-sdg": {
"command": "docker",
"args": [
"run", "--rm", "-i",
"-e", "SIGLENT_SDG_IP=192.168.1.126",
"ghcr.io/magnusjohansson/siglent-sdg-mcp:latest"
]
}
}
}
```
#### npx
```json
{
"mcpServers": {
"siglent-sdg": {
"command": "npx",
"args": ["-y", "siglent-sdg-mcp"],
"env": {
"SIGLENT_SDG_IP": "192.168.1.126"
}
}
}
}
```
### Google Antigravity
Configuration is managed through the IDE's UI:
1. Open the **Agent pane** on the right side of the workspace
2. Click the **`...`** button at the top
3. Select **MCP Servers**
4. Click **Manage MCP Servers**
5. Click **View raw config**
6. Add the configuration below and save
#### Docker
```json
{
"mcpServers": {
"siglent-sdg": {
"command": "docker",
"args": [
"run", "--rm", "-i",
"-e", "SIGLENT_SDG_IP=192.168.1.126",
"ghcr.io/magnusjohansson/siglent-sdg-mcp:latest"
]
}
}
}
```
#### npx
```json
{
"mcpServers": {
"siglent-sdg": {
"command": "npx",
"args": ["-y", "siglent-sdg-mcp"],
"env": {
"SIGLENT_SDG_IP": "192.168.1.126"
}
}
}
}
```
Replace `192.168.1.126` with your generator's IP address in all examples above.
### ChatGPT Desktop
ChatGPT Desktop only supports remote HTTPS MCP servers (called "connectors"), not local stdio servers. Since this MCP server uses stdio transport, it is not directly compatible with ChatGPT Desktop.
## Tools
20 tools across 9 categories by default; `scpi_command` is only registered when `SIGLENT_ALLOW_RAW_SCPI=true`.
| Category | Tool | Description |
|----------|------|-------------|
| Connection | `connect` | Connect to waveform generator over TCP |
| | `disconnect` | Close the connection |
| | `identify` | Query device ID (manufacturer, model, serial, firmware) |
| Output | `get_output` | Read output state, load impedance, and polarity |
| | `configure_output` | Turn output on/off, set load impedance and polarity |
| Basic Waveform | `get_basic_wave` | Read waveform parameters (type, frequency, amplitude, etc.) |
| | `configure_basic_wave` | Set waveform type, frequency, amplitude, offset, phase, duty cycle, and more |
| Modulation | `get_modulation` | Read modulation settings (type, source, depth/deviation) |
| | `configure_modulation` | Set modulation type, source, frequency, depth, deviation, and carrier parameters |
| Sweep | `get_sweep` | Read sweep parameters (time, frequency range, mode, direction) |
| | `configure_sweep` | Set sweep time, start/stop frequencies, mode, direction, trigger, and marker |
| Burst | `get_burst` | Read burst parameters (mode, period, trigger, cycle count) |
| | `configure_burst` | Set burst mode, period, trigger source, cycle count, delay, and gate polarity |
| Arbitrary Waveform | `get_arbitrary_wave` | Read the current arbitrary waveform selection |
| | `set_arbitrary_wave` | Set arbitrary waveform by index (built-in) or name (user-defined) |
| Utility | `reset` | Reset generator to factory defaults (*RST) |
| | `copy_channel` | Copy all parameters from one channel to another |
| | `configure_sync` | Configure sync output signal |
| | `equal_phase` | Synchronize phase of both channels |
| SCPI | `scpi_query` | Send a read-only SCPI query (single header ending in `?`) and return the response |
| | `scpi_command` | *Opt-in.* Send a single raw SCPI command (no response expected) |
## Example Conversations
### Generate a basic waveform
> **You:** Output a 1 kHz sine wave at 2 Vpp on channel 1.
>
> Claude calls `configure_basic_wave` with `channel: "C1"`, `waveform_type: "SINE"`, `frequency: 1000`, `amplitude: 2`, then `configure_output` with `channel: "C1"`, `state: "ON"`.
### Set up modulation
> **You:** Add AM modulation to channel 1 with 80% depth at 100 Hz.
>
> Claude calls `configure_modulation` with `channel: "C1"`, `state: "ON"`, `type: "AM"`, `depth: 80`, `frequency: 100`.
### Configure a frequency sweep
> **You:** Sweep channel 2 from 100 Hz to 10 kHz over 5 seconds.
>
> Claude calls `configure_sweep` with `channel: "C2"`, `state: "ON"`, `start: 100`, `stop: 10000`, `time: 5`, then `configure_output` with `channel: "C2"`, `state: "ON"`.
### Check current settings
> **You:** What's the current waveform setup on both channels?
>
> Claude calls `get_basic_wave` on both C1 and C2 in parallel and reports the waveform type, frequency, amplitude, and other settings for each channel.
### Copy channel configuration
> **You:** Make channel 2 match channel 1's settings.
>
> Claude calls `copy_channel` with `source: "C1"`, `destination: "C2"`.
### Set up burst mode
> **You:** Configure channel 1 for 5-cycle bursts triggered externally.
>
> Claude calls `configure_burst` with `channel: "C1"`, `state: "ON"`, `burst_mode: "NCYC"`, `cycles: "5"`, `trigger_source: "EXT"`.
## Security
An MCP server turns a language model's output into actions. For this server those actions are physical: a wrong command can put 20 Vpp into a 3.3 V circuit. Tool arguments are therefore treated as **untrusted input**, whether they came from your request, from a document the model read, or from a prompt injection.
What the server enforces, regardless of what the model was told:
| Guardrail | What it prevents |
|-----------|------------------|
| **Command-boundary check** on every string written to the socket: no control characters (incl. newlines), no `;`, ASCII only, max 512 chars | Smuggling extra SCPI commands through any parameter (`load: "50\nC1:OUTP ON"`) |
| **Strict parameter formats** for the free-text parameters (`load`, `cycles`, arbitrary waveform `name`) | Breaking out of a parameter or a quoted string |
| **`scpi_query` is query-only**: a single header ending in `?`; parameters after `?` only for queries documented to take them (`STL?`, `MMEMory:CATalog?`), and only in their documented forms; common commands only as pure queries (`*IDN?`, `*OPC?`, ...) | Using the "read-only" tool to change state (`scpi_query("C1:OUTP ON")`, `"C1:BSWV? AMP,10"`, `"*RST?"`) |
| **Raw `scpi_command` is off by default**, and can only be enabled together with named instrument hosts (`*` is not accepted) | An unrestricted command channel to the instrument, or to other SCPI devices on the LAN (power supplies often use port 5025 too) |
| **Connect allowlist**: `connect` only reaches `SIGLENT_SDG_IP`, `SIGLENT_ALLOWED_HOSTS`, or (if neither is set) private-LAN IP addresses, and only the instrument port | Using the server as a TCP client to reach other services, e.g. `169.254.169.254` |
**What it does not protect against:**
- **Output voltage.** This server does **not** limit amplitude or offset. The model can set anything the generator allows, up to 20 Vpp. To protect a circuit, set the generator's own limit on the front panel or with `C1:BSWV MAX_OUTPUT_AMP,<Vpp>` (on models that support it). **This backstop only holds while raw SCPI is off**: with `SIGLENT_ALLOW_RAW_SCPI=true`, the model can send `MAX_OUTPUT_AMP` itself and raise the limit. Whether `reset` (`*RST`) restores the generator's default limit isn't documented; check it on your model before relying on the backstop. Server-side limits are being designed separately: a first version was reviewed and found to be bypassable, so it isn't shipped (see the threat model).
- **Other clients on port 5025.** Anyone with network access to the instrument can control it directly, without going through this server. The instrument has no authentication. Network segmentation is the control for that.
The full threat model, including residual risks, is in [docs/threat-model.md](docs/threat-model.md).
## Architecture
```
Claude Code <-- stdio/JSON-RPC --> siglent-sdg-mcp <-- TCP/SCPI --> Generator:5025
```
- **Transport:** MCP over stdio (JSON-RPC 2.0)
- **Protocol:** SCPI commands over raw TCP sockets, newline-terminated
- **Query Queue:** All SCPI queries are serialized through an internal queue. The generator processes one command at a time, so even when tools issue parallel requests, the queue ensures they're sent sequentially.
- **Auto-Connect:** If `SIGLENT_SDG_IP` is set, connects in the background on startup without blocking the MCP server.
- **Security choke point:** every command and query passes through `connection.ts`, which applies the checks in `src/security/` before anything is written to the socket.
## Development
```bash
npm run build # Compile TypeScript
npm run watch # Watch mode — recompile on changes
npm run dev # Build and run
npm run inspector # Launch with MCP Inspector for debugging
npm test # Unit tests + end-to-end tests against the SCPI simulator
```
The end-to-end tests run the real server and connection code over a real TCP socket against a small SCPI simulator (`tests/simulator/`), so no hardware is needed. Security tests assert on what actually reached the simulator, not only on the error returned.
To check against real hardware (never turns an output on, aborts unless both outputs are off, because channel coupling can carry C1 changes to C2, uses 1 Vpp on C1, and changes C1's waveform settings):
```bash
npm run hardware-check -- 192.168.1.126
```
### Releasing
Publishing is triggered by **publishing a GitHub Release**, not by pushing a tag:
1. Bump `version` in `package.json` and `SERVER_VERSION` in `src/server.ts`, and add a `CHANGELOG.md` section. Merge to `main`.
2. Create a release (draft first) with tag `vX.Y.Z` on `main`, using the changelog section as the release notes. Tick "pre-release" exactly when the version has a suffix such as `-rc.1`.
3. Publish the release. The `Release` workflow runs three jobs in order: **verify**, then **npm**, then **docker**.
The verify job fails, and nothing is published, if the tag doesn't match the `package.json` version, `SERVER_VERSION` differs from it, `repository.url` doesn't match the repository exactly, the pre-release checkbox doesn't match the version, the tagged commit isn't on `main`, or the build or tests fail. The Docker image is only pushed after the npm publish succeeds. A pre-release publishes to the npm `next` dist-tag and doesn't move the Docker `latest` tag. A draft release publishes nothing.
### Docker (local build)
Build the image locally:
```bash
docker build -t siglent-sdg-mcp .
```
Then use the local image in your `.mcp.json`:
```json
{
"mcpServers": {
"siglent-sdg": {
"type": "stdio",
"command": "docker",
"args": [
"run", "--rm", "-i",
"-e", "SIGLENT_SDG_IP=192.168.1.126",
"siglent-sdg-mcp"
]
}
}
}
```
### Project Structure
```
src/
index.ts # Entry point: stdio transport, auto-connect
server.ts # MCP server factory, tool registration
config.ts # Environment parsing, effective security settings
connection.ts # TCP socket manager with query queue (security choke point)
security/
scpi-guard.ts # Command-boundary and query-only checks
host-policy.ts # Connect-target allowlist
tools/
connection.ts # connect, disconnect, identify
output.ts # get_output, configure_output
basic-wave.ts # get_basic_wave, configure_basic_wave
modulation.ts # get_modulation, configure_modulation
sweep.ts # get_sweep, configure_sweep
burst.ts # get_burst, configure_burst
arbitrary.ts # get_arbitrary_wave, set_arbitrary_wave
utility.ts # reset, copy_channel, configure_sync, equal_phase
scpi.ts # scpi_query, scpi_command (opt-in)
tests/
unit/ # Guard, host policy and config tests
integration/ # End-to-end security tests via MCP client
simulator/ # SCPI simulator (TCP)
```
## Troubleshooting
### "Not connected to waveform generator"
The generator isn't connected yet. Either set `SIGLENT_SDG_IP` in your `.mcp.json` env for auto-connect, or use the `connect` tool manually.
### Connection timeout
- Verify the generator's IP address (check the generator's Utility > Interface menu)
- Ensure port 5025 is accessible (try `telnet <generator-ip> 5025` from your machine)
- Check that no firewall is blocking the connection
- Close other SCPI clients (another MCP server instance, a second Claude session, NI-MAX, scripts). The SDG1032X accepts more than one TCP connection, so a second client does not fail to connect. It interferes instead (see below)
### Query timeout
Some SCPI queries can take a few seconds. The default timeout is 5 seconds. For `scpi_query`, you can increase the timeout with the `timeout_ms` parameter.
Timeouts on queries that normally answer instantly (`identify`, `get_output`) usually mean **another client is talking to the generator at the same time**. A common cause is a second Claude Code session whose MCP config also starts this server with `SIGLENT_SDG_IP` set, so both auto-connect. The server serializes its own queries, but it can't coordinate with other processes, and their commands and replies interleave on the instrument. Close the other client and reconnect.
### Docker: can't reach the waveform generator
By default, Docker containers can reach LAN devices via the bridge network (NAT). If the container can't connect to your generator:
- Verify the generator is reachable from your host: `telnet 192.168.1.126 5025`
- On Linux, try adding `--network host` to the Docker args:
```json
"args": ["run", "--rm", "-i", "--network", "host", "-e", "SIGLENT_SDG_IP=192.168.1.126", "ghcr.io/magnusjohansson/siglent-sdg-mcp:latest"]
```
Note: `--network host` does not work on macOS or Windows Docker Desktop.
### Docker: wrong architecture / exec format error
The published image supports `linux/amd64` and `linux/arm64`. Docker should pull the correct one automatically. If you see an exec format error, pull explicitly:
```bash
docker pull --platform linux/amd64 ghcr.io/magnusjohansson/siglent-sdg-mcp:latest
```
### "CHDR" appears in responses
This shouldn't happen — the server sets `CHDR OFF` on connect. If you see command headers in responses, try disconnecting and reconnecting.
## License
MIT — see [LICENSE](LICENSE) for details.
TDQS
Scored across 20 tools
Each tool targets a distinct subsystem-plus-action pairing (get/configure per output, basic wave, modulation, sweep, burst, arbitrary wave), and the one-off operations (copy_channel, equal_phase, identify, reset) are clearly unique. The get_/configure_ symmetry makes boundaries obvious, and scpi_query is explicitly framed as an escape hatch rather than a rival to the dedicated tools.
A strong verb_noun convention dominates (get_basic_wave/configure_basic_wave, get_sweep/configure_sweep, get_burst/configure_burst). Minor deviations: set_arbitrary_wave breaks the configure_ pattern used everywhere else, and stand-alone verbs like copy_channel, equal_phase, and identify sit outside the scheme.
Twenty tools is on the heavy side, but the device genuinely has six or so independently configurable subsystems, each warranting a get/configure pair. Every tool maps to a real instrument command, with little obvious redundancy beyond the scpi_query catch-all.
Most subsystems have symmetric read/write coverage plus connection lifecycle, copy_channel, equal_phase, reset, and identify. Minor gaps: configure_sync has no get_sync counterpart, and functions like harmonic/combine or counter modes are only reachable via scpi_query, but that escape hatch keeps agents from dead-ending.