siglent-sdg-mcp 2.0.4 → 2.0.6

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
@@ -379,7 +379,7 @@ What the server enforces, regardless of what the model was told:
379
379
  |-----------|------------------|
380
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
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?"`) |
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?`, ...); action commands the programming guide documents without a query form are rejected in `?` form | Using the "read-only" tool to change state (`scpi_query("C1:OUTP ON")`, `"C1:BSWV? AMP,10"`, `"*RST?"`, `"C1:TRIGger?"`) |
383
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
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
385
  **What it does not protect against:**
@@ -41,13 +41,14 @@ export function assertWireSafe(cmd) {
41
41
  }
42
42
  /**
43
43
  * Query shape: an optional root colon or '*', a header, then '?' immediately
44
- * after it, optionally followed by parameters.
44
+ * after it, optionally followed by parameters. Every node of the header is
45
+ * non-empty: "C1::TRIG?" and "C1:TRIG:?" are not queries.
45
46
  *
46
47
  * This is a structural check (allowlist of shape), not a denylist of setter
47
48
  * keywords, so long-form headers or unusual setters cannot slip through:
48
49
  * anything without '?' directly after the header is not a query.
49
50
  */
50
- const QUERY_PATTERN = /^(\*|:)?([A-Za-z][A-Za-z0-9_:]*)\?(?: ([A-Za-z0-9_,.+\-"/ ]*))?$/;
51
+ const QUERY_PATTERN = /^(\*|:)?([A-Za-z][A-Za-z0-9_]*(?::[A-Za-z][A-Za-z0-9_]*)*)\?(?: ([A-Za-z0-9_,.+\-"/ ]*))?$/;
51
52
  /**
52
53
  * Queries that legitimately take parameters after the '?', each with the
53
54
  * exact parameter shapes the SDG programming guide documents. For any other
@@ -80,6 +81,69 @@ const QUERY_PARAMETER_RULES = [
80
81
  * marked read-only must not do.
81
82
  */
82
83
  const COMMON_QUERIES = new Set(["IDN", "OPC", "STB", "ESE", "SRE", "OPT"]);
84
+ /**
85
+ * A SCPI mnemonic as a regex source: the short form (the upper-case part)
86
+ * followed by any leading part of the rest, so "MTRigger" matches MTR, MTRI,
87
+ * ... MTRIGGER. SCPI only defines the short and long forms; accepting the
88
+ * lengths in between is deliberate, because this list blocks, and blocking a
89
+ * spelling the firmware might also accept is the safe direction.
90
+ */
91
+ function mnemonic(m) {
92
+ const short = /^[A-Z0-9]+/.exec(m)?.[0] ?? "";
93
+ const tail = m.slice(short.length).toUpperCase();
94
+ return short + [...tail].reduceRight((rest, ch) => `(?:${ch}${rest})?`, "");
95
+ }
96
+ /**
97
+ * A header pattern written in the programming guide's notation: "<ch>:" a
98
+ * channel prefix (C1:, C2:), "A|B" alternatives and "[:NODE]" an optional
99
+ * node. Blocking more spellings than the guide documents is the safe
100
+ * direction, so every pattern also matches a channel prefix where the guide
101
+ * has none ("C1:MMEM:DEL") and a numeric suffix on any node ("SENS1",
102
+ * "SEGMent3"), which SCPI parsers commonly accept.
103
+ */
104
+ function headerPattern(spec) {
105
+ const rest = spec.startsWith("<ch>:") ? spec.slice("<ch>:".length) : spec;
106
+ let source = "(?:C\\d+:)?";
107
+ const nodes = rest.match(/\[:[^\]]+\]|[^:[\]]+/g) ?? [];
108
+ nodes.forEach((node, i) => {
109
+ const optional = node.startsWith("[");
110
+ const body = optional ? node.slice(2, -1) : node;
111
+ const pattern = `(?:${body.split("|").map(mnemonic).join("|")})\\d*`;
112
+ source += optional ? `(?::${pattern})?` : `${i === 0 ? "" : ":"}${pattern}`;
113
+ });
114
+ return new RegExp(`^${source}$`, "i");
115
+ }
116
+ /**
117
+ * Commands the SDG programming guide documents with no query form: actions
118
+ * (trigger, clear, delete, copy, save, load) and setters whose bare form
119
+ * creates or deletes something, plus EQPHASE: the guide lists a response
120
+ * format for it, but documents it only as a command that aligns the channel
121
+ * phases, which steps the phase of a running output. The shape check can't tell "C1:TRIGger?" from
122
+ * a real query, and whether the firmware ignores the '?' or runs the action
123
+ * isn't documented, so a tool marked read-only must not send them.
124
+ *
125
+ * Matched against the whole header, so queries that share a node with an
126
+ * action (C1:TRIGger:SOURce?, C1:TRIGger:TIMer?) are unaffected.
127
+ * Defence in depth on top of the shape check; undocumented headers remain a
128
+ * residual risk (see docs/threat-model.md).
129
+ */
130
+ const ACTION_HEADERS = [
131
+ "<ch>:SWEep:MTRigger",
132
+ "<ch>:TRIGger[:SEQuence][:IMMediate]",
133
+ "<ch>:SEQuence:DEFAult|NEW|RECall|SAVe",
134
+ "<ch>:SEQuence:SEGMent:DELEte|INSErt",
135
+ "<ch>:FHOP:AFLIst|DFLIst|CFLIst|MFLIst|LFLIst|SFLIst",
136
+ "<ch>:FHOP:AOLIst|DOLIst|COLIst|MOLIst|LOLIst|SOLIst",
137
+ "<ch>:FHOP:AALIst|DALIst|CALIst|LALIst|SALIst",
138
+ "SENSe:COUNTer:CLEar",
139
+ "PACP|PARACOPY",
140
+ "VKEY|VIRTUALKEY",
141
+ "MMEMory:DELete|RDIRectory|MDIRectory|COPY|MOVE|TRANsfer",
142
+ "MMEMory:SAVE|LOAD:XML",
143
+ "EQPHASE",
144
+ // Switches both outputs on or off; documented in 3.3 without a query form.
145
+ "OUT_BOTHCH",
146
+ ].map((spec) => ({ spec, header: headerPattern(spec) }));
83
147
  export function assertIsQuery(cmd) {
84
148
  assertWireSafe(cmd);
85
149
  const m = QUERY_PATTERN.exec(cmd.trim());
@@ -93,6 +157,13 @@ export function assertIsQuery(cmd) {
93
157
  throw new ScpiPolicyError(`"*${name}?" is not allowed: only the common queries ` +
94
158
  `${[...COMMON_QUERIES].map((q) => `*${q}?`).join(", ")} are permitted.`);
95
159
  }
160
+ if (prefix !== "*") {
161
+ const action = ACTION_HEADERS.find((a) => a.header.test(header));
162
+ if (action) {
163
+ throw new ScpiPolicyError(`"${header}?" is not allowed: ${action.spec} is an action command with no documented ` +
164
+ "query form, and the instrument might run it.");
165
+ }
166
+ }
96
167
  if (params !== undefined && params.trim() !== "") {
97
168
  const rule = QUERY_PARAMETER_RULES.find((r) => r.header.test(header));
98
169
  if (!rule) {
package/build/server.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import type { ServerConfig } from "./config.js";
3
- export declare const SERVER_VERSION = "2.0.4";
3
+ export declare const SERVER_VERSION = "2.0.6";
4
4
  /** Build the MCP server with all tools registered under the given configuration. */
5
5
  export declare function createServer(config: ServerConfig): McpServer;
package/build/server.js CHANGED
@@ -8,7 +8,7 @@ import { registerBurstTools } from "./tools/burst.js";
8
8
  import { registerArbitraryTools } from "./tools/arbitrary.js";
9
9
  import { registerUtilityTools } from "./tools/utility.js";
10
10
  import { registerScpiTools } from "./tools/scpi.js";
11
- export const SERVER_VERSION = "2.0.4";
11
+ export const SERVER_VERSION = "2.0.6";
12
12
  /** Build the MCP server with all tools registered under the given configuration. */
13
13
  export function createServer(config) {
14
14
  const server = new McpServer({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "siglent-sdg-mcp",
3
- "version": "2.0.4",
3
+ "version": "2.0.6",
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",