@tikoci/centrs 0.1.8-next.0 → 0.1.8
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/CHANGELOG.md +21 -0
- package/commands/mcp/README.md +5 -1
- package/commands/mcp/examples.md +18 -1
- package/package.json +1 -1
- package/src/cli/retrieve.ts +7 -0
- package/src/mcp/safety.ts +43 -18
- package/src/mcp/tools.ts +5 -3
- package/src/retrieve.ts +9 -4
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,27 @@ documenting cross-cutting shifts that affect contributors and consumers.
|
|
|
8
8
|
|
|
9
9
|
## Unreleased
|
|
10
10
|
|
|
11
|
+
## 0.1.8 — 2026-10-12
|
|
12
|
+
|
|
13
|
+
Release (npm tag `latest`; `next` moves forward to 0.1.8). Everything in
|
|
14
|
+
0.1.8-next.0 below, plus two fixes from field-testing it.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- `retrieve --wait` with no deadline value names `--wait` (#445). Written as a
|
|
19
|
+
bare switch, `--wait --until 'expr'` read `--until` as the deadline and
|
|
20
|
+
shifted the predicate into a second target, so the CLI reported
|
|
21
|
+
`usage/fanout-not-supported`. It now fails `settings/invalid-timeout`
|
|
22
|
+
("Received the flag: --until"), and every deadline without a unit gets the
|
|
23
|
+
`--wait`-specific message. A real multi-target `--wait` still gets the
|
|
24
|
+
fanout refusal.
|
|
25
|
+
- MCP accepts a registered device by its `identity=`, `mac=` or `ip=` comment
|
|
26
|
+
key, as the CLI does (#444). The allowlist gate now matches with the same
|
|
27
|
+
resolver the call dials with, so the `mcp=` write policy checked is the
|
|
28
|
+
policy of the record the call runs against. A name on more than one record
|
|
29
|
+
fails `identity/ambiguous` with the candidates in `context.matches` and an
|
|
30
|
+
MCP remediation, instead of resolving to the first.
|
|
31
|
+
|
|
11
32
|
## 0.1.8-next.0 — 2026-10-12
|
|
12
33
|
|
|
13
34
|
Pre-release (npm tag `next`; `latest` stays on 0.1.7). Install with
|
package/commands/mcp/README.md
CHANGED
|
@@ -149,7 +149,11 @@ The authorization boundary is the WinBox CDB (constitution: identity and CDB),
|
|
|
149
149
|
which is already centrs's device registry **and** native credential store. The
|
|
150
150
|
MCP server applies it as a hard allowlist:
|
|
151
151
|
|
|
152
|
-
- **Targets resolve through the CDB only** — by
|
|
152
|
+
- **Targets resolve through the CDB only** — by record target, by the
|
|
153
|
+
`identity=`/`mac=`/`ip=` comment keys (the same matcher the CLI dials with, so
|
|
154
|
+
the write policy checked belongs to the record the call runs against), or by
|
|
155
|
+
`group`. A name on
|
|
156
|
+
more than one record fails `identity/ambiguous` rather than picking one. Inline
|
|
153
157
|
ad-hoc `host` + `username` + `password` are **rejected by default** with
|
|
154
158
|
`cdb/target-not-registered`, whose `fix` points at `centrs_devices add`. An
|
|
155
159
|
agent therefore cannot reach an arbitrary router with arbitrary credentials.
|
package/commands/mcp/examples.md
CHANGED
|
@@ -37,10 +37,27 @@ Envelope: `ok: true`, `data.path = "/ip/route"`, `data.verb = "add"`,
|
|
|
37
37
|
|
|
38
38
|
Envelope: `ok: false`, `error.code = "cdb/target-not-registered"`, and
|
|
39
39
|
`error.fix`/`remediation` names `centrs_devices add`. No connection is attempted.
|
|
40
|
-
RouterOS-facing MCP tools accept only a CDB `target`
|
|
40
|
+
RouterOS-facing MCP tools accept only a CDB-registered `target` — the record's
|
|
41
|
+
address (IP/URL/MAC) or its `identity=`/`mac=`/`ip=` comment key — and there is
|
|
41
42
|
no inline `host`/`username`/`password` executor parameter, so RouterOS actions
|
|
42
43
|
use credentials already stored in the CDB.
|
|
43
44
|
|
|
45
|
+
### 2b. A record's `identity=` name passes the allowlist
|
|
46
|
+
|
|
47
|
+
The harness registers `$NAME` with the comment `identity=mcp-lab mcp=rw
|
|
48
|
+
via=rest-api`.
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{ "name": "centrs_retrieve",
|
|
52
|
+
"arguments": { "target": "mcp-lab", "path": "/system/resource" } }
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Envelope: `ok: true`, and `meta.target` names the record the call dialed:
|
|
56
|
+
`identity = "mcp-lab"`, `recordIndex = 0`, and `baseUrl` is `$NAME`'s REST URL.
|
|
57
|
+
The allowlist gate and the dialing resolver use the same matcher, so a name on
|
|
58
|
+
more than one record fails `identity/ambiguous` instead of picking one (asserted
|
|
59
|
+
offline in `test/unit/mcp-safety.test.ts`; the CHR test registers one record).
|
|
60
|
+
|
|
44
61
|
### 3. `centrs_validate` accepts the bare `blackhole` flag
|
|
45
62
|
|
|
46
63
|
```json
|
package/package.json
CHANGED
package/src/cli/retrieve.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
retrieveSample,
|
|
29
29
|
retrieveWait,
|
|
30
30
|
type ValidationTrace,
|
|
31
|
+
validateRetrieveRequestShape,
|
|
31
32
|
} from "../index.ts";
|
|
32
33
|
import { assertNoQuickchrOverrideConflict } from "../resolver/index.ts";
|
|
33
34
|
import {
|
|
@@ -245,6 +246,12 @@ export async function runRetrieveCli(args: readonly string[]): Promise<number> {
|
|
|
245
246
|
? "--follow"
|
|
246
247
|
: "--sample";
|
|
247
248
|
if (isFanoutMode(selectionFlags, targetPositionals.length)) {
|
|
249
|
+
// A value-less `--wait` swallows the next flag and shifts a
|
|
250
|
+
// positional into a second target, so report a flag-value error
|
|
251
|
+
// before the fanout refusal names the wrong mistake (#445). Only
|
|
252
|
+
// here: single-target streams report shape errors as their own
|
|
253
|
+
// stdout envelope.
|
|
254
|
+
validateRetrieveRequestShape(request);
|
|
248
255
|
throw new CentrsError({
|
|
249
256
|
code: "usage/fanout-not-supported",
|
|
250
257
|
summary: `\`retrieve ${mode}\` watches one router and cannot fan out across multiple targets.`,
|
package/src/mcp/safety.ts
CHANGED
|
@@ -10,7 +10,12 @@
|
|
|
10
10
|
* `mcp=rw`, else `cdb/write-not-permitted`.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
type DevicesShowItem,
|
|
15
|
+
type LoadedCdb,
|
|
16
|
+
loadCdb,
|
|
17
|
+
showDevice,
|
|
18
|
+
} from "../devices.ts";
|
|
14
19
|
import { CentrsError } from "../errors.ts";
|
|
15
20
|
import type { McpWritePolicy } from "../mcp.ts";
|
|
16
21
|
import { parseCommentKv } from "../resolver/comment-kv.ts";
|
|
@@ -52,30 +57,50 @@ export async function loadAllowlistCdb(
|
|
|
52
57
|
* `cdb/target-not-registered` when no record matches — the friendly,
|
|
53
58
|
* MCP-specific framing of "this device is not on the allowlist", pointing at
|
|
54
59
|
* `centrs_devices add` rather than leaking the CLI's `cdb/not-found-target`.
|
|
60
|
+
*
|
|
61
|
+
* Matches with {@link showDevice}, the matcher the core resolver dials with
|
|
62
|
+
* (target, `identity=`, `mac=`, `ip=`), so the record whose write policy is
|
|
63
|
+
* checked here is the record the call then runs against (#444). A name that
|
|
64
|
+
* matches more than one record fails `identity/ambiguous` rather than taking
|
|
65
|
+
* the first.
|
|
55
66
|
*/
|
|
56
67
|
export function resolveRegisteredTarget(
|
|
57
68
|
cdb: LoadedCdb,
|
|
58
69
|
target: string,
|
|
59
70
|
): RegisteredTarget {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
71
|
+
let entry: DevicesShowItem;
|
|
72
|
+
try {
|
|
73
|
+
entry = showDevice({ cdb, target }).data.entry;
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (error instanceof CentrsError && error.code === "cdb/not-found-target") {
|
|
76
|
+
throw new CentrsError({
|
|
77
|
+
code: "cdb/target-not-registered",
|
|
78
|
+
summary: `Target "${target}" is not registered in the CDB allowlist.`,
|
|
79
|
+
remediation:
|
|
80
|
+
"Register the device first with the `centrs_devices` tool (op `add`) or `centrs devices add`, then retry. The MCP server only acts on CDB-registered targets, by target or by its `identity=`, `mac=` or `ip=` comment key.",
|
|
81
|
+
context: { target, cdbFile: cdb.settings.cdbFile.value },
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
// The CLI remediation names `--match`, which no MCP tool takes.
|
|
85
|
+
if (error instanceof CentrsError && error.code === "identity/ambiguous") {
|
|
86
|
+
throw new CentrsError({
|
|
87
|
+
code: error.code,
|
|
88
|
+
summary: error.summary,
|
|
89
|
+
remediation:
|
|
90
|
+
"Retry with one candidate's address from `context.matches[].target` as `target`. If the candidates share that address too, the CDB holds duplicate records for it: remove or edit the extra one with the `centrs_devices` tool (op `remove` or `set`).",
|
|
91
|
+
context: error.context,
|
|
92
|
+
cause: error,
|
|
93
|
+
});
|
|
70
94
|
}
|
|
95
|
+
throw error;
|
|
71
96
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
97
|
+
return {
|
|
98
|
+
target: entry.target,
|
|
99
|
+
user: entry.user,
|
|
100
|
+
group: entry.group,
|
|
101
|
+
recordIndex: entry.cdbRecordIndex,
|
|
102
|
+
writePolicy: readWritePolicy(entry.comment),
|
|
103
|
+
};
|
|
79
104
|
}
|
|
80
105
|
|
|
81
106
|
/**
|
package/src/mcp/tools.ts
CHANGED
|
@@ -193,7 +193,7 @@ export const validateInputShape = {
|
|
|
193
193
|
target: z
|
|
194
194
|
.string()
|
|
195
195
|
.describe(
|
|
196
|
-
"CDB-registered target
|
|
196
|
+
"CDB-registered target: its address (IP / URL / MAC) or its identity=, mac= or ip= comment key. Must be on the allowlist.",
|
|
197
197
|
),
|
|
198
198
|
command: z.string().describe("RouterOS CLI command to dry-run."),
|
|
199
199
|
via: z
|
|
@@ -248,7 +248,9 @@ export const retrieveInputShape = {
|
|
|
248
248
|
target: z
|
|
249
249
|
.string()
|
|
250
250
|
.optional()
|
|
251
|
-
.describe(
|
|
251
|
+
.describe(
|
|
252
|
+
"CDB-registered single target: its address (IP / URL / MAC) or its identity=, mac= or ip= comment key. Use either target or group.",
|
|
253
|
+
),
|
|
252
254
|
group: z
|
|
253
255
|
.string()
|
|
254
256
|
.optional()
|
|
@@ -340,7 +342,7 @@ export const executeInputShape = {
|
|
|
340
342
|
target: z
|
|
341
343
|
.string()
|
|
342
344
|
.describe(
|
|
343
|
-
"CDB-registered target. Inline host+credentials are not accepted.",
|
|
345
|
+
"CDB-registered target: its address (IP / URL / MAC) or its identity=, mac= or ip= comment key. Inline host+credentials are not accepted.",
|
|
344
346
|
),
|
|
345
347
|
command: z.string().describe("RouterOS CLI command to run."),
|
|
346
348
|
confirm: z
|
package/src/retrieve.ts
CHANGED
|
@@ -874,6 +874,9 @@ export function applyMaxResultsBudget(
|
|
|
874
874
|
export function validateRetrieveRequestShape(
|
|
875
875
|
request: RetrieveRequest,
|
|
876
876
|
): readonly string[] {
|
|
877
|
+
// Flag values before the path: a value-less flag shifts the positionals,
|
|
878
|
+
// so the path check would blame the wrong token (#445).
|
|
879
|
+
resolveWaitSettings(request);
|
|
877
880
|
if (!request.path.startsWith("/")) {
|
|
878
881
|
throw new CentrsError({
|
|
879
882
|
code: "input/invalid-routeros-path",
|
|
@@ -885,7 +888,6 @@ export function validateRetrieveRequestShape(
|
|
|
885
888
|
}
|
|
886
889
|
|
|
887
890
|
const query = resolveRetrieveQuery(request);
|
|
888
|
-
resolveWaitSettings(request);
|
|
889
891
|
if (query && request.listAttributes) {
|
|
890
892
|
throw new CentrsError({
|
|
891
893
|
code: "usage/conflicting-flags",
|
|
@@ -1027,13 +1029,16 @@ function resolveWaitSettings(
|
|
|
1027
1029
|
// A unit is required for the same reason as `--sample`: a bare `30` would
|
|
1028
1030
|
// be a 30 ms deadline, which expires before validation completes.
|
|
1029
1031
|
const text = String(request.wait).trim();
|
|
1030
|
-
const deadlineMs = /^\d
|
|
1032
|
+
const deadlineMs = /^\d+(ms|s|m)$/i.test(text) ? parseDuration(text) : -1;
|
|
1031
1033
|
if (deadlineMs <= 0)
|
|
1032
1034
|
throw new CentrsError({
|
|
1033
1035
|
code: "settings/invalid-timeout",
|
|
1034
|
-
|
|
1036
|
+
// `--wait --until …` reads `--until` as the deadline (#445).
|
|
1037
|
+
summary: text.startsWith("-")
|
|
1038
|
+
? `\`--wait\` needs a deadline value before the next flag. Received the flag: ${text}`
|
|
1039
|
+
: `\`--wait\` needs a positive deadline with a unit. Received: ${text}`,
|
|
1035
1040
|
remediation:
|
|
1036
|
-
"Pass an overall duration such as `--wait 30s` or `--wait 5m`.",
|
|
1041
|
+
"Pass an overall duration such as `--wait 30s` or `--wait 5m`, e.g. `--wait 20s --until 'running'`.",
|
|
1037
1042
|
context: { flag: "--wait", value: text },
|
|
1038
1043
|
});
|
|
1039
1044
|
return {
|