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 +72 -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 +7 -6
package/README.md
CHANGED
|
@@ -1,20 +1,29 @@
|
|
|
1
1
|
# siglent-sdg-mcp
|
|
2
2
|
|
|
3
|
+
[](https://github.com/MagnusJohansson/siglent-sdg-mcp/actions/workflows/ci.yml)
|
|
4
|
+
[](https://github.com/MagnusJohansson/siglent-sdg-mcp/actions/workflows/codeql.yml)
|
|
5
|
+
[](https://scorecard.dev/viewer/?uri=github.com/MagnusJohansson/siglent-sdg-mcp)
|
|
6
|
+
[](https://www.npmjs.com/package/siglent-sdg-mcp)
|
|
7
|
+
[](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
|
+

|
|
16
|
+
|
|
9
17
|
**Key features:**
|
|
10
18
|
|
|
11
|
-
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
|
321
|
-
| | `scpi_command` | Send
|
|
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
|
|
412
|
-
|
|
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
|
|
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;
|
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.3";
|
|
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.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
|
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.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/
|
|
36
|
+
"url": "git+https://github.com/MagnusJohansson/siglent-sdg-mcp.git"
|
|
36
37
|
},
|
|
37
38
|
"dependencies": {
|
|
38
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
39
|
-
"zod": "^
|
|
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": "^
|
|
45
|
+
"vitest": "^4.1.12"
|
|
45
46
|
},
|
|
46
47
|
"engines": {
|
|
47
48
|
"node": ">=20"
|