siglent-sdg-mcp 1.0.0 → 2.0.2
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 +66 -10
- package/build/config.d.ts +12 -0
- package/build/config.js +56 -0
- package/build/connection.d.ts +6 -1
- package/build/connection.js +30 -6
- package/build/index.js +25 -27
- package/build/security/host-policy.d.ts +16 -0
- package/build/security/host-policy.js +57 -0
- package/build/security/scpi-guard.d.ts +22 -0
- package/build/security/scpi-guard.js +105 -0
- package/build/server.d.ts +5 -0
- package/build/server.js +29 -0
- package/build/tools/arbitrary.js +2 -1
- package/build/tools/burst.js +1 -0
- package/build/tools/connection.d.ts +2 -1
- package/build/tools/connection.js +8 -5
- package/build/tools/output.js +1 -0
- package/build/tools/scpi.d.ts +2 -1
- package/build/tools/scpi.js +7 -4
- package/build/tools/utility.js +1 -1
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -6,15 +6,18 @@ A [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server that l
|
|
|
6
6
|
|
|
7
7
|
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
8
|
|
|
9
|
+

|
|
10
|
+
|
|
9
11
|
**Key features:**
|
|
10
12
|
|
|
11
|
-
-
|
|
13
|
+
- 20 tools covering output control, waveform generation, modulation, sweep, burst, arbitrary waveforms, and more (21 with the opt-in raw SCPI tool)
|
|
12
14
|
- Dual-channel support (C1 and C2)
|
|
13
15
|
- 8 waveform types: sine, square, ramp, pulse, noise, arbitrary, DC, PRBS
|
|
14
16
|
- 8 modulation types: AM, DSB-AM, FM, PM, PWM, ASK, FSK, PSK
|
|
15
17
|
- Auto-connect on startup via environment variable
|
|
16
18
|
- Query queue serializes commands automatically — tools can safely run in parallel
|
|
17
|
-
-
|
|
19
|
+
- Read-only SCPI query escape hatch; raw SCPI commands are opt-in
|
|
20
|
+
- Security guardrails: command-injection protection, query-only `scpi_query`, connect-target allowlist (see [Security](#security))
|
|
18
21
|
|
|
19
22
|
## Compatibility
|
|
20
23
|
|
|
@@ -105,12 +108,14 @@ Replace `/path/to/siglent-sdg-mcp` with the actual path to your clone.
|
|
|
105
108
|
| `SIGLENT_IP` | No | — | Fallback if `SIGLENT_SDG_IP` is not set |
|
|
106
109
|
| `SIGLENT_SDG_PORT` | No | `5025` | TCP port (only change if your setup differs) |
|
|
107
110
|
| `SIGLENT_PORT` | No | `5025` | Fallback if `SIGLENT_SDG_PORT` is not set |
|
|
111
|
+
| `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) |
|
|
112
|
+
| `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
113
|
|
|
109
114
|
### Auto-Connect Behavior
|
|
110
115
|
|
|
111
116
|
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
117
|
|
|
113
|
-
If neither variable is set, the server starts without a connection. Use the `connect` tool to connect when ready.
|
|
118
|
+
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
119
|
|
|
115
120
|
## Using with Other AI Clients
|
|
116
121
|
|
|
@@ -294,7 +299,7 @@ ChatGPT Desktop only supports remote HTTPS MCP servers (called "connectors"), no
|
|
|
294
299
|
|
|
295
300
|
## Tools
|
|
296
301
|
|
|
297
|
-
|
|
302
|
+
20 tools across 9 categories by default; `scpi_command` is only registered when `SIGLENT_ALLOW_RAW_SCPI=true`.
|
|
298
303
|
|
|
299
304
|
| Category | Tool | Description |
|
|
300
305
|
|----------|------|-------------|
|
|
@@ -317,8 +322,8 @@ ChatGPT Desktop only supports remote HTTPS MCP servers (called "connectors"), no
|
|
|
317
322
|
| | `copy_channel` | Copy all parameters from one channel to another |
|
|
318
323
|
| | `configure_sync` | Configure sync output signal |
|
|
319
324
|
| | `equal_phase` | Synchronize phase of both channels |
|
|
320
|
-
| SCPI | `scpi_query` | Send
|
|
321
|
-
| | `scpi_command` | Send
|
|
325
|
+
| SCPI | `scpi_query` | Send a read-only SCPI query (single header ending in `?`) and return the response |
|
|
326
|
+
| | `scpi_command` | *Opt-in.* Send a single raw SCPI command (no response expected) |
|
|
322
327
|
|
|
323
328
|
## Example Conversations
|
|
324
329
|
|
|
@@ -358,6 +363,26 @@ ChatGPT Desktop only supports remote HTTPS MCP servers (called "connectors"), no
|
|
|
358
363
|
>
|
|
359
364
|
> Claude calls `configure_burst` with `channel: "C1"`, `state: "ON"`, `burst_mode: "NCYC"`, `cycles: "5"`, `trigger_source: "EXT"`.
|
|
360
365
|
|
|
366
|
+
## Security
|
|
367
|
+
|
|
368
|
+
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.
|
|
369
|
+
|
|
370
|
+
What the server enforces, regardless of what the model was told:
|
|
371
|
+
|
|
372
|
+
| Guardrail | What it prevents |
|
|
373
|
+
|-----------|------------------|
|
|
374
|
+
| **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"`) |
|
|
375
|
+
| **Strict parameter formats** for the free-text parameters (`load`, `cycles`, arbitrary waveform `name`) | Breaking out of a parameter or a quoted string |
|
|
376
|
+
| **`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?"`) |
|
|
377
|
+
| **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) |
|
|
378
|
+
| **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` |
|
|
379
|
+
**What it does not protect against:**
|
|
380
|
+
|
|
381
|
+
- **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).
|
|
382
|
+
- **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.
|
|
383
|
+
|
|
384
|
+
The full threat model, including residual risks, is in [docs/threat-model.md](docs/threat-model.md).
|
|
385
|
+
|
|
361
386
|
## Architecture
|
|
362
387
|
|
|
363
388
|
```
|
|
@@ -368,6 +393,7 @@ Claude Code <-- stdio/JSON-RPC --> siglent-sdg-mcp <-- TCP/SCPI --> Generator:50
|
|
|
368
393
|
- **Protocol:** SCPI commands over raw TCP sockets, newline-terminated
|
|
369
394
|
- **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
395
|
- **Auto-Connect:** If `SIGLENT_SDG_IP` is set, connects in the background on startup without blocking the MCP server.
|
|
396
|
+
- **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
397
|
|
|
372
398
|
## Development
|
|
373
399
|
|
|
@@ -376,8 +402,27 @@ npm run build # Compile TypeScript
|
|
|
376
402
|
npm run watch # Watch mode — recompile on changes
|
|
377
403
|
npm run dev # Build and run
|
|
378
404
|
npm run inspector # Launch with MCP Inspector for debugging
|
|
405
|
+
npm test # Unit tests + end-to-end tests against the SCPI simulator
|
|
379
406
|
```
|
|
380
407
|
|
|
408
|
+
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.
|
|
409
|
+
|
|
410
|
+
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):
|
|
411
|
+
|
|
412
|
+
```bash
|
|
413
|
+
npm run hardware-check -- 192.168.1.126
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
### Releasing
|
|
417
|
+
|
|
418
|
+
Publishing is triggered by **publishing a GitHub Release**, not by pushing a tag:
|
|
419
|
+
|
|
420
|
+
1. Bump `version` in `package.json` and add a section to `CHANGELOG.md`, then merge to `main`.
|
|
421
|
+
2. Create a release (draft first) with tag `vX.Y.Z` on `main`, using the changelog section as the release notes.
|
|
422
|
+
3. Publish the release. This runs the npm publish (tests first, with provenance) and the GHCR image build.
|
|
423
|
+
|
|
424
|
+
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.
|
|
425
|
+
|
|
381
426
|
### Docker (local build)
|
|
382
427
|
|
|
383
428
|
Build the image locally:
|
|
@@ -408,8 +453,13 @@ Then use the local image in your `.mcp.json`:
|
|
|
408
453
|
|
|
409
454
|
```
|
|
410
455
|
src/
|
|
411
|
-
index.ts # Entry point
|
|
412
|
-
|
|
456
|
+
index.ts # Entry point: stdio transport, auto-connect
|
|
457
|
+
server.ts # MCP server factory, tool registration
|
|
458
|
+
config.ts # Environment parsing, effective security settings
|
|
459
|
+
connection.ts # TCP socket manager with query queue (security choke point)
|
|
460
|
+
security/
|
|
461
|
+
scpi-guard.ts # Command-boundary and query-only checks
|
|
462
|
+
host-policy.ts # Connect-target allowlist
|
|
413
463
|
tools/
|
|
414
464
|
connection.ts # connect, disconnect, identify
|
|
415
465
|
output.ts # get_output, configure_output
|
|
@@ -419,7 +469,11 @@ src/
|
|
|
419
469
|
burst.ts # get_burst, configure_burst
|
|
420
470
|
arbitrary.ts # get_arbitrary_wave, set_arbitrary_wave
|
|
421
471
|
utility.ts # reset, copy_channel, configure_sync, equal_phase
|
|
422
|
-
scpi.ts # scpi_query, scpi_command
|
|
472
|
+
scpi.ts # scpi_query, scpi_command (opt-in)
|
|
473
|
+
tests/
|
|
474
|
+
unit/ # Guard, host policy and config tests
|
|
475
|
+
integration/ # End-to-end security tests via MCP client
|
|
476
|
+
simulator/ # SCPI simulator (TCP)
|
|
423
477
|
```
|
|
424
478
|
|
|
425
479
|
## Troubleshooting
|
|
@@ -433,12 +487,14 @@ The generator isn't connected yet. Either set `SIGLENT_SDG_IP` in your `.mcp.jso
|
|
|
433
487
|
- Verify the generator's IP address (check the generator's Utility > Interface menu)
|
|
434
488
|
- Ensure port 5025 is accessible (try `telnet <generator-ip> 5025` from your machine)
|
|
435
489
|
- Check that no firewall is blocking the connection
|
|
436
|
-
- The
|
|
490
|
+
- 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
491
|
|
|
438
492
|
### Query timeout
|
|
439
493
|
|
|
440
494
|
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
495
|
|
|
496
|
+
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.
|
|
497
|
+
|
|
442
498
|
### Docker: can't reach the waveform generator
|
|
443
499
|
|
|
444
500
|
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;
|
package/build/config.js
ADDED
|
@@ -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
|
package/build/connection.d.ts
CHANGED
|
@@ -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;
|
package/build/connection.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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 =
|
|
33
|
+
const autoIp = config.defaultHost;
|
|
41
34
|
if (autoIp) {
|
|
42
|
-
const port =
|
|
43
|
-
|
|
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.2";
|
|
4
|
+
/** Build the MCP server with all tools registered under the given configuration. */
|
|
5
|
+
export declare function createServer(config: ServerConfig): McpServer;
|
package/build/server.js
ADDED
|
@@ -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.2";
|
|
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
|
package/build/tools/arbitrary.js
CHANGED
|
@@ -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
|
|
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) {
|
package/build/tools/burst.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
4
|
-
|
|
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 ||
|
|
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
|
-
|
|
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}:${
|
|
35
|
+
text: `Connected to ${targetHost}:${targetPort}\nDevice: ${idn}`,
|
|
33
36
|
},
|
|
34
37
|
],
|
|
35
38
|
};
|
package/build/tools/output.js
CHANGED
|
@@ -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
|
package/build/tools/scpi.d.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
-
|
|
2
|
+
import type { ServerConfig } from "../config.js";
|
|
3
|
+
export declare function registerScpiTools(server: McpServer, config: ServerConfig): void;
|
package/build/tools/scpi.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 {
|
package/build/tools/utility.js
CHANGED
|
@@ -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": "
|
|
3
|
+
"version": "2.0.2",
|
|
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,7 +33,7 @@
|
|
|
32
33
|
"license": "MIT",
|
|
33
34
|
"repository": {
|
|
34
35
|
"type": "git",
|
|
35
|
-
"url": "https://github.com/
|
|
36
|
+
"url": "git+https://github.com/MagnusJohansson/siglent-sdg-mcp.git"
|
|
36
37
|
},
|
|
37
38
|
"dependencies": {
|
|
38
39
|
"@modelcontextprotocol/sdk": "^1.26.0",
|