siglent-sdg-mcp 1.0.0 → 2.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,20 +1,29 @@
1
1
  # siglent-sdg-mcp
2
2
 
3
+ [![CI](https://github.com/MagnusJohansson/siglent-sdg-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/MagnusJohansson/siglent-sdg-mcp/actions/workflows/ci.yml)
4
+ [![CodeQL](https://github.com/MagnusJohansson/siglent-sdg-mcp/actions/workflows/codeql.yml/badge.svg)](https://github.com/MagnusJohansson/siglent-sdg-mcp/actions/workflows/codeql.yml)
5
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/MagnusJohansson/siglent-sdg-mcp/badge)](https://scorecard.dev/viewer/?uri=github.com/MagnusJohansson/siglent-sdg-mcp)
6
+ [![npm](https://img.shields.io/npm/v/siglent-sdg-mcp)](https://www.npmjs.com/package/siglent-sdg-mcp)
7
+ [![License: MIT](https://img.shields.io/github/license/MagnusJohansson/siglent-sdg-mcp)](LICENSE)
8
+
3
9
  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.
4
10
 
5
11
  ## Overview
6
12
 
7
13
  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.
8
14
 
15
+ ![Oscilloscope screenshot captured via MCP](docs/Siglent-SDG-1032X.jpeg)
16
+
9
17
  **Key features:**
10
18
 
11
- - 21 tools covering output control, waveform generation, modulation, sweep, burst, arbitrary waveforms, and more
19
+ - 20 tools covering output control, waveform generation, modulation, sweep, burst, arbitrary waveforms, and more (21 with the opt-in raw SCPI tool)
12
20
  - Dual-channel support (C1 and C2)
13
21
  - 8 waveform types: sine, square, ramp, pulse, noise, arbitrary, DC, PRBS
14
22
  - 8 modulation types: AM, DSB-AM, FM, PM, PWM, ASK, FSK, PSK
15
23
  - Auto-connect on startup via environment variable
16
24
  - Query queue serializes commands automatically — tools can safely run in parallel
17
- - Raw SCPI escape hatch for any command not covered by the built-in tools
25
+ - Read-only SCPI query escape hatch; raw SCPI commands are opt-in
26
+ - Security guardrails: command-injection protection, query-only `scpi_query`, connect-target allowlist (see [Security](#security))
18
27
 
19
28
  ## Compatibility
20
29
 
@@ -105,12 +114,14 @@ Replace `/path/to/siglent-sdg-mcp` with the actual path to your clone.
105
114
  | `SIGLENT_IP` | No | — | Fallback if `SIGLENT_SDG_IP` is not set |
106
115
  | `SIGLENT_SDG_PORT` | No | `5025` | TCP port (only change if your setup differs) |
107
116
  | `SIGLENT_PORT` | No | `5025` | Fallback if `SIGLENT_SDG_PORT` is not set |
117
+ | `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) |
118
+ | `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) |
108
119
 
109
120
  ### Auto-Connect Behavior
110
121
 
111
122
  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.
112
123
 
113
- If neither variable is set, the server starts without a connection. Use the `connect` tool to connect when ready.
124
+ 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).
114
125
 
115
126
  ## Using with Other AI Clients
116
127
 
@@ -294,7 +305,7 @@ ChatGPT Desktop only supports remote HTTPS MCP servers (called "connectors"), no
294
305
 
295
306
  ## Tools
296
307
 
297
- 21 tools across 9 categories.
308
+ 20 tools across 9 categories by default; `scpi_command` is only registered when `SIGLENT_ALLOW_RAW_SCPI=true`.
298
309
 
299
310
  | Category | Tool | Description |
300
311
  |----------|------|-------------|
@@ -317,8 +328,8 @@ ChatGPT Desktop only supports remote HTTPS MCP servers (called "connectors"), no
317
328
  | | `copy_channel` | Copy all parameters from one channel to another |
318
329
  | | `configure_sync` | Configure sync output signal |
319
330
  | | `equal_phase` | Synchronize phase of both channels |
320
- | SCPI | `scpi_query` | Send arbitrary SCPI query and return the response |
321
- | | `scpi_command` | Send arbitrary SCPI command (no response expected) |
331
+ | SCPI | `scpi_query` | Send a read-only SCPI query (single header ending in `?`) and return the response |
332
+ | | `scpi_command` | *Opt-in.* Send a single raw SCPI command (no response expected) |
322
333
 
323
334
  ## Example Conversations
324
335
 
@@ -358,6 +369,26 @@ ChatGPT Desktop only supports remote HTTPS MCP servers (called "connectors"), no
358
369
  >
359
370
  > Claude calls `configure_burst` with `channel: "C1"`, `state: "ON"`, `burst_mode: "NCYC"`, `cycles: "5"`, `trigger_source: "EXT"`.
360
371
 
372
+ ## Security
373
+
374
+ 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.
375
+
376
+ What the server enforces, regardless of what the model was told:
377
+
378
+ | Guardrail | What it prevents |
379
+ |-----------|------------------|
380
+ | **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"`) |
381
+ | **Strict parameter formats** for the free-text parameters (`load`, `cycles`, arbitrary waveform `name`) | Breaking out of a parameter or a quoted string |
382
+ | **`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?"`) |
383
+ | **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) |
384
+ | **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` |
385
+ **What it does not protect against:**
386
+
387
+ - **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).
388
+ - **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.
389
+
390
+ The full threat model, including residual risks, is in [docs/threat-model.md](docs/threat-model.md).
391
+
361
392
  ## Architecture
362
393
 
363
394
  ```
@@ -368,6 +399,7 @@ Claude Code <-- stdio/JSON-RPC --> siglent-sdg-mcp <-- TCP/SCPI --> Generator:50
368
399
  - **Protocol:** SCPI commands over raw TCP sockets, newline-terminated
369
400
  - **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.
370
401
  - **Auto-Connect:** If `SIGLENT_SDG_IP` is set, connects in the background on startup without blocking the MCP server.
402
+ - **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.
371
403
 
372
404
  ## Development
373
405
 
@@ -376,8 +408,27 @@ npm run build # Compile TypeScript
376
408
  npm run watch # Watch mode — recompile on changes
377
409
  npm run dev # Build and run
378
410
  npm run inspector # Launch with MCP Inspector for debugging
411
+ npm test # Unit tests + end-to-end tests against the SCPI simulator
412
+ ```
413
+
414
+ 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.
415
+
416
+ 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):
417
+
418
+ ```bash
419
+ npm run hardware-check -- 192.168.1.126
379
420
  ```
380
421
 
422
+ ### Releasing
423
+
424
+ Publishing is triggered by **publishing a GitHub Release**, not by pushing a tag:
425
+
426
+ 1. Bump `version` in `package.json` and add a section to `CHANGELOG.md`, then merge to `main`.
427
+ 2. Create a release (draft first) with tag `vX.Y.Z` on `main`, using the changelog section as the release notes.
428
+ 3. Publish the release. This runs the npm publish (tests first, with provenance) and the GHCR image build.
429
+
430
+ The npm workflow fails if the tag doesn't match the `package.json` version. A pre-release publishes to the npm `next` dist-tag, and doesn't move the Docker `latest` tag. A draft release publishes nothing.
431
+
381
432
  ### Docker (local build)
382
433
 
383
434
  Build the image locally:
@@ -408,8 +459,13 @@ Then use the local image in your `.mcp.json`:
408
459
 
409
460
  ```
410
461
  src/
411
- index.ts # Entry point, MCP server setup
412
- connection.ts # TCP socket manager with query queue
462
+ index.ts # Entry point: stdio transport, auto-connect
463
+ server.ts # MCP server factory, tool registration
464
+ config.ts # Environment parsing, effective security settings
465
+ connection.ts # TCP socket manager with query queue (security choke point)
466
+ security/
467
+ scpi-guard.ts # Command-boundary and query-only checks
468
+ host-policy.ts # Connect-target allowlist
413
469
  tools/
414
470
  connection.ts # connect, disconnect, identify
415
471
  output.ts # get_output, configure_output
@@ -419,7 +475,11 @@ src/
419
475
  burst.ts # get_burst, configure_burst
420
476
  arbitrary.ts # get_arbitrary_wave, set_arbitrary_wave
421
477
  utility.ts # reset, copy_channel, configure_sync, equal_phase
422
- scpi.ts # scpi_query, scpi_command
478
+ scpi.ts # scpi_query, scpi_command (opt-in)
479
+ tests/
480
+ unit/ # Guard, host policy and config tests
481
+ integration/ # End-to-end security tests via MCP client
482
+ simulator/ # SCPI simulator (TCP)
423
483
  ```
424
484
 
425
485
  ## Troubleshooting
@@ -433,12 +493,14 @@ The generator isn't connected yet. Either set `SIGLENT_SDG_IP` in your `.mcp.jso
433
493
  - Verify the generator's IP address (check the generator's Utility > Interface menu)
434
494
  - Ensure port 5025 is accessible (try `telnet <generator-ip> 5025` from your machine)
435
495
  - Check that no firewall is blocking the connection
436
- - The generator only accepts one TCP connection at a time — close any other SCPI clients
496
+ - 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)
437
497
 
438
498
  ### Query timeout
439
499
 
440
500
  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.
441
501
 
502
+ 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.
503
+
442
504
  ### Docker: can't reach the waveform generator
443
505
 
444
506
  By default, Docker containers can reach LAN devices via the bridge network (NAT). If the container can't connect to your generator:
@@ -0,0 +1,12 @@
1
+ import type { HostPolicy } from "./security/host-policy.js";
2
+ export interface ServerConfig {
3
+ /** Instrument address used for auto-connect and as the default for `connect`. */
4
+ defaultHost?: string;
5
+ defaultPort: number;
6
+ hostPolicy: HostPolicy;
7
+ /** Whether the raw `scpi_command` tool is registered. */
8
+ rawScpiEnabled: boolean;
9
+ /** Human-readable notes about the effective configuration, logged at startup. */
10
+ notes: string[];
11
+ }
12
+ export declare function loadConfig(env?: NodeJS.ProcessEnv): ServerConfig;
@@ -0,0 +1,56 @@
1
+ const DEFAULT_PORT = 5025;
2
+ function parsePort(name, value) {
3
+ if (value === undefined || value.trim() === "")
4
+ return undefined;
5
+ const n = Number(value);
6
+ if (!Number.isInteger(n) || n < 1 || n > 65535) {
7
+ throw new Error(`${name} must be a TCP port (1-65535), got "${value}".`);
8
+ }
9
+ return n;
10
+ }
11
+ function isTrue(value) {
12
+ return value !== undefined && ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
13
+ }
14
+ export function loadConfig(env = process.env) {
15
+ const notes = [];
16
+ const defaultHost = (env.SIGLENT_SDG_IP || env.SIGLENT_IP || "").trim() || undefined;
17
+ const defaultPort = parsePort("SIGLENT_SDG_PORT", env.SIGLENT_SDG_PORT) ??
18
+ parsePort("SIGLENT_PORT", env.SIGLENT_PORT) ??
19
+ DEFAULT_PORT;
20
+ // Connect-target policy: the configured instrument plus any explicitly
21
+ // allowed hosts. If neither is set, only private-LAN IP literals are accepted.
22
+ const allowedHosts = (env.SIGLENT_ALLOWED_HOSTS ?? "")
23
+ .split(",")
24
+ .map((h) => h.trim())
25
+ .filter((h) => h.length > 0);
26
+ if (defaultHost && !allowedHosts.includes(defaultHost)) {
27
+ allowedHosts.push(defaultHost);
28
+ }
29
+ if (allowedHosts.includes("*")) {
30
+ notes.push("WARNING: SIGLENT_ALLOWED_HOSTS=* allows connecting to any host.");
31
+ }
32
+ // Raw SCPI turns `connect` into a general command channel. Without an
33
+ // explicit host list, `connect` falls back to "any private-LAN IP on the
34
+ // instrument port", which also matches bench power supplies and electronic
35
+ // loads (they commonly use 5025 too). So raw SCPI requires named hosts,
36
+ // and "*" doesn't name one.
37
+ const rawScpiEnabled = isTrue(env.SIGLENT_ALLOW_RAW_SCPI);
38
+ if (rawScpiEnabled && (allowedHosts.length === 0 || allowedHosts.includes("*"))) {
39
+ throw new Error("SIGLENT_ALLOW_RAW_SCPI requires SIGLENT_SDG_IP or SIGLENT_ALLOWED_HOSTS naming specific " +
40
+ "hosts ('*' is not accepted), so raw commands can only reach instruments you have named.");
41
+ }
42
+ if (rawScpiEnabled) {
43
+ notes.push("WARNING: raw scpi_command tool enabled; the model can send any SCPI command.");
44
+ }
45
+ return {
46
+ defaultHost,
47
+ defaultPort,
48
+ hostPolicy: {
49
+ allowedHosts,
50
+ allowedPorts: [...new Set([DEFAULT_PORT, defaultPort])],
51
+ },
52
+ rawScpiEnabled,
53
+ notes,
54
+ };
55
+ }
56
+ //# sourceMappingURL=config.js.map
@@ -11,13 +11,18 @@ export declare class SiglentConnection {
11
11
  private headerParsed;
12
12
  private queryQueue;
13
13
  private queryRunning;
14
+ /**
15
+ * Incremented on every connect(). An attempt that has been superseded by a
16
+ * newer one (e.g. auto-connect still initializing when the `connect` tool is
17
+ * called) must not touch the shared socket, and must not disconnect it.
18
+ */
19
+ private connectGeneration;
14
20
  connect(host: string, port?: number): Promise<string>;
15
21
  disconnect(): void;
16
22
  isConnected(): boolean;
17
23
  getConnectionInfo(): string;
18
24
  sendCommand(cmd: string): Promise<void>;
19
25
  query(cmd: string, timeout?: number): Promise<string>;
20
- queryBinary(cmd: string, timeout?: number): Promise<Buffer>;
21
26
  private enqueueQuery;
22
27
  private drainQueue;
23
28
  private setupSocketListeners;
@@ -1,7 +1,7 @@
1
1
  import * as net from "node:net";
2
+ import { assertIsQuery, assertWireSafe } from "./security/scpi-guard.js";
2
3
  const DEFAULT_PORT = 5025;
3
4
  const DEFAULT_TIMEOUT = 5000;
4
- const BINARY_TIMEOUT = 30000;
5
5
  export class SiglentConnection {
6
6
  socket = null;
7
7
  host = "";
@@ -15,7 +15,15 @@ export class SiglentConnection {
15
15
  headerParsed = false;
16
16
  queryQueue = [];
17
17
  queryRunning = false;
18
+ /**
19
+ * Incremented on every connect(). An attempt that has been superseded by a
20
+ * newer one (e.g. auto-connect still initializing when the `connect` tool is
21
+ * called) must not touch the shared socket, and must not disconnect it.
22
+ */
23
+ connectGeneration = 0;
18
24
  async connect(host, port = DEFAULT_PORT) {
25
+ const generation = ++this.connectGeneration;
26
+ const superseded = () => generation !== this.connectGeneration;
19
27
  if (this.socket) {
20
28
  this.disconnect();
21
29
  }
@@ -29,12 +37,19 @@ export class SiglentConnection {
29
37
  }, 5000);
30
38
  socket.on("connect", async () => {
31
39
  clearTimeout(connectTimeout);
40
+ if (superseded()) {
41
+ socket.destroy();
42
+ reject(new Error("Connection attempt superseded by a newer connect."));
43
+ return;
44
+ }
32
45
  socket.removeAllListeners("error");
33
46
  this.socket = socket;
34
47
  this.setupSocketListeners();
35
48
  try {
36
49
  // Drain any welcome banner
37
50
  await this.delay(200);
51
+ if (superseded())
52
+ throw new Error("superseded by a newer connect");
38
53
  this.dataBuffer = Buffer.alloc(0);
39
54
  // Try to set CHDR OFF for clean responses (not all SDG models support it)
40
55
  try {
@@ -44,12 +59,19 @@ export class SiglentConnection {
44
59
  catch {
45
60
  // Some models don't support CHDR — that's OK
46
61
  }
62
+ if (superseded())
63
+ throw new Error("superseded by a newer connect");
47
64
  // Query identification
48
65
  const idn = await this.query("*IDN?");
66
+ if (superseded())
67
+ throw new Error("superseded by a newer connect");
49
68
  resolve(idn);
50
69
  }
51
70
  catch (err) {
52
- this.disconnect();
71
+ // Only the current attempt may tear down the shared connection;
72
+ // a superseded one would otherwise kill the newer socket.
73
+ if (!superseded())
74
+ this.disconnect();
53
75
  reject(new Error(`Connected but failed to initialize: ${err instanceof Error ? err.message : String(err)}`));
54
76
  }
55
77
  });
@@ -86,6 +108,9 @@ export class SiglentConnection {
86
108
  return `${this.host}:${this.port}`;
87
109
  }
88
110
  async sendCommand(cmd) {
111
+ // The connection is the single choke point to the instrument, so this
112
+ // check applies regardless of which tool built the command.
113
+ assertWireSafe(cmd);
89
114
  this.ensureConnected();
90
115
  return new Promise((resolve, reject) => {
91
116
  this.socket.write(cmd + "\n", (err) => {
@@ -97,14 +122,13 @@ export class SiglentConnection {
97
122
  });
98
123
  }
99
124
  async query(cmd, timeout = DEFAULT_TIMEOUT) {
125
+ // A query must be structurally a query; otherwise "C1:OUTP ON" sent via a
126
+ // read-only tool would change state and simply time out waiting for a reply.
127
+ assertIsQuery(cmd);
100
128
  this.ensureConnected();
101
129
  const buf = await this.enqueueQuery(cmd, false, timeout);
102
130
  return buf.toString("utf-8").trim();
103
131
  }
104
- async queryBinary(cmd, timeout = BINARY_TIMEOUT) {
105
- this.ensureConnected();
106
- return this.enqueueQuery(cmd, true, timeout);
107
- }
108
132
  enqueueQuery(cmd, binary, timeout) {
109
133
  return new Promise((resolve, reject) => {
110
134
  this.queryQueue.push({ cmd, binary, timeout, resolve, reject });
package/build/index.js CHANGED
@@ -1,16 +1,9 @@
1
1
  #!/usr/bin/env node
2
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
- import { registerConnectionTools } from "./tools/connection.js";
5
- import { registerOutputTools } from "./tools/output.js";
6
- import { registerBasicWaveTools } from "./tools/basic-wave.js";
7
- import { registerModulationTools } from "./tools/modulation.js";
8
- import { registerSweepTools } from "./tools/sweep.js";
9
- import { registerBurstTools } from "./tools/burst.js";
10
- import { registerArbitraryTools } from "./tools/arbitrary.js";
11
- import { registerUtilityTools } from "./tools/utility.js";
12
- import { registerScpiTools } from "./tools/scpi.js";
3
+ import { loadConfig } from "./config.js";
13
4
  import { connection } from "./connection.js";
5
+ import { assertConnectAllowed } from "./security/host-policy.js";
6
+ import { createServer } from "./server.js";
14
7
  // Prevent uncaught errors from killing the MCP server process
15
8
  process.on("uncaughtException", (err) => {
16
9
  console.error("Uncaught exception:", err);
@@ -18,29 +11,34 @@ process.on("uncaughtException", (err) => {
18
11
  process.on("unhandledRejection", (reason) => {
19
12
  console.error("Unhandled rejection:", reason);
20
13
  });
21
- const server = new McpServer({
22
- name: "siglent-sdg-mcp",
23
- version: "1.0.0",
24
- });
25
- // Register all tool groups
26
- registerConnectionTools(server);
27
- registerOutputTools(server);
28
- registerBasicWaveTools(server);
29
- registerModulationTools(server);
30
- registerSweepTools(server);
31
- registerBurstTools(server);
32
- registerArbitraryTools(server);
33
- registerUtilityTools(server);
34
- registerScpiTools(server);
14
+ // Fail closed on invalid configuration: exit non-zero instead of starting
15
+ // with a security setting silently ignored.
16
+ let config;
17
+ try {
18
+ config = loadConfig();
19
+ }
20
+ catch (err) {
21
+ console.error(`Configuration error: ${err instanceof Error ? err.message : String(err)}`);
22
+ process.exit(1);
23
+ }
24
+ for (const note of config.notes) {
25
+ console.error(note);
26
+ }
27
+ const server = createServer(config);
35
28
  async function main() {
36
29
  const transport = new StdioServerTransport();
37
30
  await server.connect(transport);
38
31
  console.error("siglent-sdg-mcp server running on stdio");
39
32
  // Auto-connect if env var is set (fire-and-forget, don't block MCP startup)
40
- const autoIp = process.env.SIGLENT_SDG_IP || process.env.SIGLENT_IP;
33
+ const autoIp = config.defaultHost;
41
34
  if (autoIp) {
42
- const port = parseInt(process.env.SIGLENT_SDG_PORT || process.env.SIGLENT_PORT || "5025", 10);
43
- connection.connect(autoIp, port).then((idn) => console.error(`Auto-connected to ${autoIp}: ${idn}`), (err) => {
35
+ const port = config.defaultPort;
36
+ Promise.resolve()
37
+ .then(() => {
38
+ assertConnectAllowed(autoIp, port, config.hostPolicy);
39
+ return connection.connect(autoIp, port);
40
+ })
41
+ .then((idn) => console.error(`Auto-connected to ${autoIp}: ${idn}`), (err) => {
44
42
  console.error(`Auto-connect to ${autoIp} failed: ${err instanceof Error ? err.message : String(err)}`);
45
43
  console.error("Use the 'connect' tool to connect manually.");
46
44
  });
@@ -0,0 +1,16 @@
1
+ export interface HostPolicy {
2
+ /**
3
+ * Exact host names / IPs that may be connected to. "*" allows any host.
4
+ * When empty, only private-LAN IP literals are allowed.
5
+ */
6
+ allowedHosts: string[];
7
+ /** Ports that may be connected to. */
8
+ allowedPorts: number[];
9
+ }
10
+ /**
11
+ * Private-LAN ranges where bench instruments live (RFC 1918 and IPv6 ULA).
12
+ * Deliberately excluded: loopback (local services) and link-local
13
+ * 169.254.0.0/16 (includes cloud metadata endpoints such as 169.254.169.254).
14
+ */
15
+ export declare function isPrivateLanAddress(host: string): boolean;
16
+ export declare function assertConnectAllowed(host: string, port: number, policy: HostPolicy): void;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Connect-target policy.
3
+ *
4
+ * The `connect` tool lets the model choose a host and port. Without host and
5
+ * port restrictions, that plus a raw-command tool is a general-purpose TCP client: the model
6
+ * (or anyone who can steer it via prompt injection) could reach any service
7
+ * the server can reach and write arbitrary lines to it.
8
+ */
9
+ import * as net from "node:net";
10
+ import { ScpiPolicyError } from "./scpi-guard.js";
11
+ function ipv4ToInt(ip) {
12
+ return ip.split(".").reduce((acc, octet) => (acc << 8) + Number(octet), 0) >>> 0;
13
+ }
14
+ function inCidr(ip, base, bits) {
15
+ const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0;
16
+ return (ipv4ToInt(ip) & mask) === (ipv4ToInt(base) & mask);
17
+ }
18
+ /**
19
+ * Private-LAN ranges where bench instruments live (RFC 1918 and IPv6 ULA).
20
+ * Deliberately excluded: loopback (local services) and link-local
21
+ * 169.254.0.0/16 (includes cloud metadata endpoints such as 169.254.169.254).
22
+ */
23
+ export function isPrivateLanAddress(host) {
24
+ if (net.isIPv4(host)) {
25
+ return (inCidr(host, "10.0.0.0", 8) ||
26
+ inCidr(host, "172.16.0.0", 12) ||
27
+ inCidr(host, "192.168.0.0", 16));
28
+ }
29
+ if (net.isIPv6(host)) {
30
+ const first = host.toLowerCase().split(":")[0];
31
+ // fc00::/7 (unique local addresses)
32
+ return /^f[cd][0-9a-f]{2}$/.test(first);
33
+ }
34
+ return false;
35
+ }
36
+ export function assertConnectAllowed(host, port, policy) {
37
+ if (!Number.isInteger(port) || !policy.allowedPorts.includes(port)) {
38
+ throw new ScpiPolicyError(`Port ${port} is not allowed. Allowed ports: ${policy.allowedPorts.join(", ")}. ` +
39
+ "Configure SIGLENT_SDG_PORT to use a different instrument port.");
40
+ }
41
+ if (policy.allowedHosts.includes("*"))
42
+ return;
43
+ if (policy.allowedHosts.length > 0) {
44
+ const normalized = host.trim().toLowerCase();
45
+ if (!policy.allowedHosts.some((h) => h.toLowerCase() === normalized)) {
46
+ throw new ScpiPolicyError(`Host "${host}" is not in the allowed list. ` +
47
+ "Set SIGLENT_ALLOWED_HOSTS (comma-separated) to allow additional instruments.");
48
+ }
49
+ return;
50
+ }
51
+ if (!isPrivateLanAddress(host)) {
52
+ throw new ScpiPolicyError(`Host "${host}" is not allowed. Without SIGLENT_ALLOWED_HOSTS, only private-LAN IP ` +
53
+ "addresses (10/8, 172.16/12, 192.168/16, fc00::/7) are accepted; host names, loopback " +
54
+ "and link-local addresses are rejected.");
55
+ }
56
+ }
57
+ //# sourceMappingURL=host-policy.js.map
@@ -0,0 +1,22 @@
1
+ /**
2
+ * SCPI guard: checks applied to every string before it is written to the
3
+ * instrument socket.
4
+ *
5
+ * Tool arguments come from a language model and must be treated as untrusted
6
+ * input. The connection layer is the single choke point every command and
7
+ * query passes through, so these checks live there rather than in each tool.
8
+ */
9
+ /** Raised when a command or argument violates the security policy. */
10
+ export declare class ScpiPolicyError extends Error {
11
+ constructor(message: string);
12
+ }
13
+ /**
14
+ * Reject anything that could smuggle a second command onto the wire.
15
+ *
16
+ * - Control characters (incl. \n and \r): the connection terminates every
17
+ * command with "\n", so an embedded newline would start a new command.
18
+ * - ';': the SCPI compound-command separator ("C1:OUTP?;C1:OUTP ON").
19
+ * - Non-printable or non-ASCII bytes: SCPI is ASCII; nothing legitimate needs them.
20
+ */
21
+ export declare function assertWireSafe(cmd: string): void;
22
+ export declare function assertIsQuery(cmd: string): void;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * SCPI guard: checks applied to every string before it is written to the
3
+ * instrument socket.
4
+ *
5
+ * Tool arguments come from a language model and must be treated as untrusted
6
+ * input. The connection layer is the single choke point every command and
7
+ * query passes through, so these checks live there rather than in each tool.
8
+ */
9
+ /** Raised when a command or argument violates the security policy. */
10
+ export class ScpiPolicyError extends Error {
11
+ constructor(message) {
12
+ super(message);
13
+ this.name = "ScpiPolicyError";
14
+ }
15
+ }
16
+ /**
17
+ * Reject anything that could smuggle a second command onto the wire.
18
+ *
19
+ * - Control characters (incl. \n and \r): the connection terminates every
20
+ * command with "\n", so an embedded newline would start a new command.
21
+ * - ';': the SCPI compound-command separator ("C1:OUTP?;C1:OUTP ON").
22
+ * - Non-printable or non-ASCII bytes: SCPI is ASCII; nothing legitimate needs them.
23
+ */
24
+ export function assertWireSafe(cmd) {
25
+ if (cmd.length === 0) {
26
+ throw new ScpiPolicyError("Empty SCPI command.");
27
+ }
28
+ if (cmd.length > 512) {
29
+ throw new ScpiPolicyError("SCPI command too long (max 512 characters).");
30
+ }
31
+ for (let i = 0; i < cmd.length; i++) {
32
+ const code = cmd.charCodeAt(i);
33
+ if (code < 0x20 || code > 0x7e) {
34
+ throw new ScpiPolicyError(`SCPI command contains a control or non-ASCII character at position ${i} (code ${code}). ` +
35
+ "Embedded newlines would inject additional commands.");
36
+ }
37
+ }
38
+ if (cmd.includes(";")) {
39
+ throw new ScpiPolicyError("SCPI compound commands (';') are not allowed. Send one command per call.");
40
+ }
41
+ }
42
+ /**
43
+ * Query shape: an optional root colon or '*', a header, then '?' immediately
44
+ * after it, optionally followed by parameters.
45
+ *
46
+ * This is a structural check (allowlist of shape), not a denylist of setter
47
+ * keywords, so long-form headers or unusual setters cannot slip through:
48
+ * anything without '?' directly after the header is not a query.
49
+ */
50
+ const QUERY_PATTERN = /^(\*|:)?([A-Za-z][A-Za-z0-9_:]*)\?(?: ([A-Za-z0-9_,.+\-"/ ]*))?$/;
51
+ /**
52
+ * Queries that legitimately take parameters after the '?', each with the
53
+ * exact parameter shapes the SDG programming guide documents. For any other
54
+ * query, trailing parameters are rejected: whether the instrument ignores
55
+ * them or acts on them ("C1:BSWV? FRQ,1234") is firmware-dependent, so they
56
+ * are not allowed through a tool that promises to be read-only.
57
+ *
58
+ * WVDT? is deliberately absent: its reply is binary waveform data, which the
59
+ * text query path can't frame, and its parameters overlap the WVDT setter.
60
+ */
61
+ const QUOTED_PATH = '"[A-Za-z0-9_\\-./ ]{1,64}"';
62
+ const QUERY_PARAMETER_RULES = [
63
+ {
64
+ // STL?, STL? BUILDIN, STL? USER, STL? USER,"net_storage/wave"
65
+ name: "STL?",
66
+ header: /^(STL|STORELIST)$/i,
67
+ params: new RegExp(`^(BUILDIN|USER(,${QUOTED_PATH})?)$`, "i"),
68
+ },
69
+ {
70
+ // MMEMory:CATalog? "Local" (and the :DATA:ARBitrary / :STATe:XMLanguage variants)
71
+ name: "MMEMory:CATalog?",
72
+ header: /^MMEM(ORY)?:CAT(ALOG)?(:DATA:ARB(ITRARY)?|:STAT(E)?:XML(ANGUAGE)?)?$/i,
73
+ params: new RegExp(`^${QUOTED_PATH}$`),
74
+ },
75
+ ];
76
+ /**
77
+ * IEEE 488.2 common commands that are pure queries. Others that accept a '?'
78
+ * form (*RST?, *TST?, *CLS?) are actions, or would run a self-test.
79
+ */
80
+ const COMMON_QUERIES = new Set(["IDN", "OPC", "ESR", "STB", "ESE", "SRE", "OPT"]);
81
+ export function assertIsQuery(cmd) {
82
+ assertWireSafe(cmd);
83
+ const m = QUERY_PATTERN.exec(cmd.trim());
84
+ if (!m) {
85
+ throw new ScpiPolicyError(`Not a SCPI query: "${cmd}". A query must be a single header ending in '?' ` +
86
+ "(e.g. 'C1:BSWV?'). Commands that change instrument state are not allowed here.");
87
+ }
88
+ const [, prefix, header, params] = m;
89
+ const name = header.toUpperCase();
90
+ if (prefix === "*" && !COMMON_QUERIES.has(name)) {
91
+ throw new ScpiPolicyError(`"*${name}?" is not allowed: only the common queries ` +
92
+ `${[...COMMON_QUERIES].map((q) => `*${q}?`).join(", ")} are permitted.`);
93
+ }
94
+ if (params !== undefined && params.trim() !== "") {
95
+ const rule = QUERY_PARAMETER_RULES.find((r) => r.header.test(header));
96
+ if (!rule) {
97
+ throw new ScpiPolicyError(`Parameters after '?' are only allowed for ${QUERY_PARAMETER_RULES.map((r) => r.name).join(", ")}. ` +
98
+ `"${cmd}" could be interpreted as a command by the instrument.`);
99
+ }
100
+ if (!rule.params.test(params.trim())) {
101
+ throw new ScpiPolicyError(`Parameters "${params.trim()}" are not a documented form for ${rule.name}.`);
102
+ }
103
+ }
104
+ }
105
+ //# sourceMappingURL=scpi-guard.js.map
@@ -0,0 +1,5 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { ServerConfig } from "./config.js";
3
+ export declare const SERVER_VERSION = "2.0.3";
4
+ /** Build the MCP server with all tools registered under the given configuration. */
5
+ export declare function createServer(config: ServerConfig): McpServer;
@@ -0,0 +1,29 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { registerConnectionTools } from "./tools/connection.js";
3
+ import { registerOutputTools } from "./tools/output.js";
4
+ import { registerBasicWaveTools } from "./tools/basic-wave.js";
5
+ import { registerModulationTools } from "./tools/modulation.js";
6
+ import { registerSweepTools } from "./tools/sweep.js";
7
+ import { registerBurstTools } from "./tools/burst.js";
8
+ import { registerArbitraryTools } from "./tools/arbitrary.js";
9
+ import { registerUtilityTools } from "./tools/utility.js";
10
+ import { registerScpiTools } from "./tools/scpi.js";
11
+ export const SERVER_VERSION = "2.0.3";
12
+ /** Build the MCP server with all tools registered under the given configuration. */
13
+ export function createServer(config) {
14
+ const server = new McpServer({
15
+ name: "siglent-sdg-mcp",
16
+ version: SERVER_VERSION,
17
+ });
18
+ registerConnectionTools(server, config);
19
+ registerOutputTools(server);
20
+ registerBasicWaveTools(server);
21
+ registerModulationTools(server);
22
+ registerSweepTools(server);
23
+ registerBurstTools(server);
24
+ registerArbitraryTools(server);
25
+ registerUtilityTools(server);
26
+ registerScpiTools(server, config);
27
+ return server;
28
+ }
29
+ //# sourceMappingURL=server.js.map
@@ -35,8 +35,9 @@ export function registerArbitraryTools(server) {
35
35
  .describe("Index of the built-in arbitrary waveform (0-198). Examples: 0=Sine, 2=StairUp, 10=ExpFal, 18=Sinc, 19=Gaussian, 26=Cardiac"),
36
36
  name: z
37
37
  .string()
38
+ .regex(/^[A-Za-z0-9_\-./ ]{1,64}$/, "Name may contain letters, digits, space, '_', '-', '.', '/' (max 64)")
38
39
  .optional()
39
- .describe('Name of the waveform (for user-defined waves, use quotes). Can also be a path like "Local/wave1.bin"'),
40
+ .describe('Name of the waveform for user-defined waves (without quotes). Can also be a path like "Local/wave1.bin"'),
40
41
  }, { readOnlyHint: false }, async ({ channel, index, name }) => {
41
42
  try {
42
43
  if (index === undefined && !name) {
@@ -59,6 +59,7 @@ export function registerBurstTools(server) {
59
59
  .describe("Trigger edge (when EXT or MAN)"),
60
60
  cycles: z
61
61
  .string()
62
+ .regex(/^(INF|\d{1,7})$/i, "Cycles must be an integer or 'INF'")
62
63
  .optional()
63
64
  .describe("Number of burst cycles (integer, or 'INF' for infinite)"),
64
65
  delay: z
@@ -1,2 +1,3 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
- export declare function registerConnectionTools(server: McpServer): void;
2
+ import type { ServerConfig } from "../config.js";
3
+ export declare function registerConnectionTools(server: McpServer, config: ServerConfig): void;
@@ -1,7 +1,8 @@
1
1
  import { z } from "zod";
2
2
  import { connection } from "../connection.js";
3
- export function registerConnectionTools(server) {
4
- server.tool("connect", "Connect to a Siglent SDG waveform generator over TCP. Returns device identification on success.", {
3
+ import { assertConnectAllowed } from "../security/host-policy.js";
4
+ export function registerConnectionTools(server, config) {
5
+ server.tool("connect", "Connect to a Siglent SDG waveform generator over TCP. Returns device identification on success. Only hosts and ports allowed by the server configuration can be used.", {
5
6
  host: z
6
7
  .string()
7
8
  .optional()
@@ -11,7 +12,8 @@ export function registerConnectionTools(server) {
11
12
  .optional()
12
13
  .describe("TCP port (defaults to 5025)"),
13
14
  }, { readOnlyHint: false, openWorldHint: true }, async ({ host, port }) => {
14
- const targetHost = host || process.env.SIGLENT_SDG_IP || process.env.SIGLENT_IP;
15
+ const targetHost = host || config.defaultHost;
16
+ const targetPort = port ?? config.defaultPort;
15
17
  if (!targetHost) {
16
18
  return {
17
19
  content: [
@@ -24,12 +26,13 @@ export function registerConnectionTools(server) {
24
26
  };
25
27
  }
26
28
  try {
27
- const idn = await connection.connect(targetHost, port);
29
+ assertConnectAllowed(targetHost, targetPort, config.hostPolicy);
30
+ const idn = await connection.connect(targetHost, targetPort);
28
31
  return {
29
32
  content: [
30
33
  {
31
34
  type: "text",
32
- text: `Connected to ${targetHost}:${port || 5025}\nDevice: ${idn}`,
35
+ text: `Connected to ${targetHost}:${targetPort}\nDevice: ${idn}`,
33
36
  },
34
37
  ],
35
38
  };
@@ -35,6 +35,7 @@ export function registerOutputTools(server) {
35
35
  .describe("Output state: ON or OFF"),
36
36
  load: z
37
37
  .string()
38
+ .regex(/^(HZ|\d+(\.\d+)?)$/i, "Load must be 'HZ' or a number of ohms")
38
39
  .optional()
39
40
  .describe("Load impedance in ohms (50-100000) or 'HZ' for high impedance"),
40
41
  polarity: z
@@ -1,2 +1,3 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
- export declare function registerScpiTools(server: McpServer): void;
2
+ import type { ServerConfig } from "../config.js";
3
+ export declare function registerScpiTools(server: McpServer, config: ServerConfig): void;
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { connection } from "../connection.js";
3
- export function registerScpiTools(server) {
4
- server.tool("scpi_query", "Send an arbitrary SCPI query to the waveform generator and return the response. Use this as an escape hatch for commands not covered by other tools. Note: CHDR is set to OFF, so responses contain only values (no headers).", {
3
+ export function registerScpiTools(server, config) {
4
+ server.tool("scpi_query", "Send a read-only SCPI query to the waveform generator and return the response. Use this as an escape hatch for queries not covered by other tools. The command must be a single SCPI header ending in '?' (e.g. 'C1:BSWV?'); anything that could change instrument state is rejected. Note: CHDR is set to OFF, so responses contain only values (no headers).", {
5
5
  command: z
6
6
  .string()
7
7
  .describe("SCPI query command to send (e.g. '*IDN?', 'C1:BSWV?', 'C1:OUTP?')"),
@@ -33,11 +33,14 @@ export function registerScpiTools(server) {
33
33
  };
34
34
  }
35
35
  });
36
- server.tool("scpi_command", "Send an arbitrary SCPI command to the waveform generator (no response expected). Use this as an escape hatch for commands not covered by other tools.", {
36
+ // Raw commands are off by default: they bypass every per-tool constraint.
37
+ if (!config.rawScpiEnabled)
38
+ return;
39
+ server.tool("scpi_command", "Send an arbitrary SCPI command to the waveform generator (no response expected). Use this as an escape hatch for commands not covered by other tools. Only one command per call; newlines and ';' are rejected.", {
37
40
  command: z
38
41
  .string()
39
42
  .describe("SCPI command to send (e.g. '*RST', 'C1:OUTP ON', 'C1:BSWV WVTP,SINE')"),
40
- }, { readOnlyHint: false }, async ({ command }) => {
43
+ }, { readOnlyHint: false, destructiveHint: true }, async ({ command }) => {
41
44
  try {
42
45
  await connection.sendCommand(command);
43
46
  return {
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { connection } from "../connection.js";
3
3
  export function registerUtilityTools(server) {
4
- server.tool("reset", "Reset the waveform generator to factory default settings (*RST).", {}, { readOnlyHint: false }, async () => {
4
+ server.tool("reset", "Reset the waveform generator to factory default settings (*RST).", {}, { readOnlyHint: false, destructiveHint: true }, async () => {
5
5
  try {
6
6
  await connection.sendCommand("*RST");
7
7
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "siglent-sdg-mcp",
3
- "version": "1.0.0",
3
+ "version": "2.0.3",
4
4
  "description": "MCP server for Siglent SDG waveform generator control via SCPI over TCP sockets",
5
5
  "type": "module",
6
6
  "main": "build/index.js",
@@ -19,7 +19,8 @@
19
19
  "inspector": "npx @modelcontextprotocol/inspector node build/index.js",
20
20
  "prepublishOnly": "npm run build",
21
21
  "test": "vitest run",
22
- "test:watch": "vitest"
22
+ "test:watch": "vitest",
23
+ "hardware-check": "npm run build && node scripts/hardware-check.mjs"
23
24
  },
24
25
  "keywords": [
25
26
  "mcp",
@@ -32,16 +33,16 @@
32
33
  "license": "MIT",
33
34
  "repository": {
34
35
  "type": "git",
35
- "url": "https://github.com/magnusjohansson/siglent-sdg-mcp.git"
36
+ "url": "git+https://github.com/MagnusJohansson/siglent-sdg-mcp.git"
36
37
  },
37
38
  "dependencies": {
38
- "@modelcontextprotocol/sdk": "^1.26.0",
39
- "zod": "^3.25.0"
39
+ "@modelcontextprotocol/sdk": "^1.32.2",
40
+ "zod": "^4.6.7"
40
41
  },
41
42
  "devDependencies": {
42
43
  "@types/node": "^22.0.0",
43
44
  "typescript": "^5.7.0",
44
- "vitest": "^3.0.0"
45
+ "vitest": "^4.1.12"
45
46
  },
46
47
  "engines": {
47
48
  "node": ">=20"