@tikoci/centrs 0.1.0
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 +207 -0
- package/GLOSSARY.txt +420 -0
- package/README.md +196 -0
- package/SECURITY.md +30 -0
- package/commands/AGENTS.md +50 -0
- package/commands/btest/README.md +257 -0
- package/commands/btest/examples.md +190 -0
- package/commands/check/README.md +59 -0
- package/commands/devices/README.md +405 -0
- package/commands/devices/examples.md +488 -0
- package/commands/discover/README.md +170 -0
- package/commands/discover/examples.md +98 -0
- package/commands/execute/README.md +208 -0
- package/commands/execute/examples.md +353 -0
- package/commands/mcp/README.md +260 -0
- package/commands/mcp/examples.md +158 -0
- package/commands/retrieve/README.md +209 -0
- package/commands/retrieve/examples.md +306 -0
- package/commands/settings/README.md +69 -0
- package/commands/stream/README.md +70 -0
- package/commands/terminal/README.md +177 -0
- package/commands/terminal/examples.md +144 -0
- package/commands/transfer/README.md +315 -0
- package/commands/transfer/examples.md +368 -0
- package/docs/CONSTITUTION.md +417 -0
- package/docs/MATRIX.md +224 -0
- package/docs/errors/README.md +74 -0
- package/docs/errors/auth/failed.md +9 -0
- package/docs/errors/cdb/already-exists.md +9 -0
- package/docs/errors/cdb/created.md +9 -0
- package/docs/errors/cdb/decrypt-failed.md +9 -0
- package/docs/errors/cdb/empty-group.md +9 -0
- package/docs/errors/cdb/invalid-option.md +9 -0
- package/docs/errors/cdb/invalid-record.md +9 -0
- package/docs/errors/cdb/not-found-target.md +9 -0
- package/docs/errors/cdb/not-found.md +9 -0
- package/docs/errors/cdb/override-applied.md +9 -0
- package/docs/errors/cdb/parse-failed.md +9 -0
- package/docs/errors/cdb/password-not-needed.md +9 -0
- package/docs/errors/cdb/password-required.md +9 -0
- package/docs/errors/cdb/reserved-key.md +9 -0
- package/docs/errors/cdb/reserved-option.md +9 -0
- package/docs/errors/cdb/target-not-registered.md +9 -0
- package/docs/errors/cdb/unknown-field.md +9 -0
- package/docs/errors/cdb/unknown-option.md +9 -0
- package/docs/errors/cdb/write-not-permitted.md +9 -0
- package/docs/errors/discover/broadcast-unavailable.md +9 -0
- package/docs/errors/discover/failed.md +9 -0
- package/docs/errors/identity/ambiguous.md +9 -0
- package/docs/errors/identity/no-match.md +9 -0
- package/docs/errors/input/invalid-command.md +9 -0
- package/docs/errors/input/invalid-match.md +9 -0
- package/docs/errors/input/invalid-path.md +9 -0
- package/docs/errors/input/invalid-routeros-path.md +9 -0
- package/docs/errors/input/invalid-target.md +9 -0
- package/docs/errors/input/local-file-not-found.md +9 -0
- package/docs/errors/input/mac-address.md +9 -0
- package/docs/errors/input/max-results-exceeded.md +9 -0
- package/docs/errors/internal/devices-failed.md +9 -0
- package/docs/errors/internal/mcp-start.md +9 -0
- package/docs/errors/internal/mcp-tool.md +9 -0
- package/docs/errors/internal/native-api-length.md +9 -0
- package/docs/errors/internal/unhandled.md +9 -0
- package/docs/errors/internal/unreachable.md +9 -0
- package/docs/errors/mndp/encode-failed.md +9 -0
- package/docs/errors/mndp/listen-failed.md +9 -0
- package/docs/errors/mndp/malformed.md +9 -0
- package/docs/errors/routeros/api-fatal.md +9 -0
- package/docs/errors/routeros/api-protocol.md +9 -0
- package/docs/errors/routeros/api-trap.md +9 -0
- package/docs/errors/routeros/btest-protocol.md +12 -0
- package/docs/errors/routeros/btest-too-many-sessions.md +9 -0
- package/docs/errors/routeros/command-failed.md +14 -0
- package/docs/errors/routeros/ec-srp5-protocol.md +11 -0
- package/docs/errors/routeros/error.md +9 -0
- package/docs/errors/routeros/invalid-value.md +14 -0
- package/docs/errors/routeros/mac-telnet-error.md +9 -0
- package/docs/errors/routeros/mac-telnet-not-ready.md +9 -0
- package/docs/errors/routeros/mac-telnet-protocol.md +9 -0
- package/docs/errors/routeros/mac-telnet-unsupported-auth.md +9 -0
- package/docs/errors/routeros/protocol-not-implemented.md +9 -0
- package/docs/errors/routeros/request-failed.md +9 -0
- package/docs/errors/routeros/session-closed.md +13 -0
- package/docs/errors/routeros/unknown-attribute.md +13 -0
- package/docs/errors/routeros/unknown-path.md +15 -0
- package/docs/errors/routeros/unsupported-capability.md +9 -0
- package/docs/errors/settings/invalid-boolean.md +9 -0
- package/docs/errors/settings/invalid-format.md +9 -0
- package/docs/errors/settings/invalid-integer.md +9 -0
- package/docs/errors/settings/invalid-timeout.md +9 -0
- package/docs/errors/settings/invalid-via.md +9 -0
- package/docs/errors/settings/unsafe-protocol-blocked.md +9 -0
- package/docs/errors/target/mac-not-in-arp.md +14 -0
- package/docs/errors/target/mac-required.md +23 -0
- package/docs/errors/target/mac-unresolved.md +17 -0
- package/docs/errors/target/unresolved.md +9 -0
- package/docs/errors/transport/auth-failed.md +9 -0
- package/docs/errors/transport/auto-method.md +9 -0
- package/docs/errors/transport/capability-unsupported.md +22 -0
- package/docs/errors/transport/checksum-unavailable.md +9 -0
- package/docs/errors/transport/connection-closed.md +9 -0
- package/docs/errors/transport/connection-refused.md +9 -0
- package/docs/errors/transport/dns.md +9 -0
- package/docs/errors/transport/host-key-mismatch.md +9 -0
- package/docs/errors/transport/incomplete-transfer.md +9 -0
- package/docs/errors/transport/insecure-trust.md +9 -0
- package/docs/errors/transport/local-tool-missing.md +11 -0
- package/docs/errors/transport/network.md +9 -0
- package/docs/errors/transport/timeout.md +9 -0
- package/docs/errors/transport/tls-certificate.md +9 -0
- package/docs/errors/transport/unreachable.md +9 -0
- package/docs/errors/transport/unsupported-operation.md +9 -0
- package/docs/errors/usage/confirmation-required.md +9 -0
- package/docs/errors/usage/conflicting-flags.md +9 -0
- package/docs/errors/usage/invalid-concurrency.md +9 -0
- package/docs/errors/usage/missing-group.md +9 -0
- package/docs/errors/usage/not-implemented.md +9 -0
- package/docs/errors/usage/target-exists.md +9 -0
- package/docs/errors/usage/timeout-out-of-range.md +9 -0
- package/docs/errors/validation/not-implemented.md +9 -0
- package/docs/errors/validation/option.md +9 -0
- package/docs/errors/validation/syntax.md +9 -0
- package/docs/errors/validation/unknown-attribute.md +9 -0
- package/docs/errors/validation/unknown-path.md +9 -0
- package/package.json +96 -0
- package/src/AGENTS.md +7 -0
- package/src/btest.ts +906 -0
- package/src/cli/btest.ts +455 -0
- package/src/cli/common.ts +53 -0
- package/src/cli/devices.ts +527 -0
- package/src/cli/discover.ts +186 -0
- package/src/cli/execute.ts +333 -0
- package/src/cli/mcp.ts +109 -0
- package/src/cli/retrieve.ts +383 -0
- package/src/cli/terminal.ts +303 -0
- package/src/cli/transfer.ts +471 -0
- package/src/cli.ts +115 -0
- package/src/core/envelope.ts +221 -0
- package/src/core/error-catalog.ts +469 -0
- package/src/core/routeros-errors.ts +268 -0
- package/src/data/AGENTS.md +11 -0
- package/src/data/dude-db.ts +0 -0
- package/src/data/mndp-cache.ts +132 -0
- package/src/data/mndp.ts +405 -0
- package/src/data/winbox-cdb-write.ts +200 -0
- package/src/data/winbox-cdb.ts +913 -0
- package/src/devices-schema.ts +107 -0
- package/src/devices.ts +1717 -0
- package/src/discover.ts +802 -0
- package/src/errors.ts +189 -0
- package/src/execute.ts +1350 -0
- package/src/index.ts +353 -0
- package/src/mcp/config.ts +66 -0
- package/src/mcp/resources.ts +98 -0
- package/src/mcp/safety.ts +119 -0
- package/src/mcp/server.ts +225 -0
- package/src/mcp/tools.ts +657 -0
- package/src/mcp.ts +33 -0
- package/src/protocols/AGENTS.md +14 -0
- package/src/protocols/adapter.ts +866 -0
- package/src/protocols/btest-session.ts +1493 -0
- package/src/protocols/btest.ts +447 -0
- package/src/protocols/ec-srp5.ts +466 -0
- package/src/protocols/index.ts +118 -0
- package/src/protocols/mac-telnet-console.ts +655 -0
- package/src/protocols/mac-telnet.ts +1299 -0
- package/src/protocols/mtwei.ts +47 -0
- package/src/protocols/native-api.ts +796 -0
- package/src/protocols/sftp.ts +427 -0
- package/src/protocols/ssh.ts +297 -0
- package/src/resolver/cdb.ts +580 -0
- package/src/resolver/comment-kv.ts +360 -0
- package/src/resolver/index.ts +74 -0
- package/src/resolver/mac.ts +227 -0
- package/src/resolver/settings.ts +228 -0
- package/src/resolver/target.ts +442 -0
- package/src/retrieve-fanout.ts +533 -0
- package/src/retrieve.ts +1215 -0
- package/src/terminal.ts +444 -0
- package/src/transfer.ts +1554 -0
- package/src/tui.ts +22 -0
- package/src/webproxy.ts +16 -0
package/src/mcp/tools.ts
ADDED
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tool handlers for centrs.
|
|
3
|
+
*
|
|
4
|
+
* Each handler is a plain async function that returns the standard centrs
|
|
5
|
+
* envelope (`{ ok, data?|error, warnings, meta }`) — the same shape every other
|
|
6
|
+
* surface emits. The server module (`./server.ts`) wraps these as MCP tools and
|
|
7
|
+
* serializes the returned envelope as JSON text content. Keeping the handlers
|
|
8
|
+
* transport-free makes them directly unit-testable without an MCP client.
|
|
9
|
+
*
|
|
10
|
+
* The tool surface is a small set of scoped verbs (never one tool per RouterOS
|
|
11
|
+
* command), grounded in the `bench-routeros-tools` study: an explain → validate
|
|
12
|
+
* → run split where `:parse` is the real-parser validation gate.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
import type { CentrsEnvelope } from "../core/envelope.ts";
|
|
17
|
+
import {
|
|
18
|
+
addDevice,
|
|
19
|
+
editInteractiveOnlyError,
|
|
20
|
+
type LoadedCdb,
|
|
21
|
+
listDevices,
|
|
22
|
+
listGroups,
|
|
23
|
+
recordTypeFromName,
|
|
24
|
+
removeDevice,
|
|
25
|
+
setDevice,
|
|
26
|
+
showDevice,
|
|
27
|
+
} from "../devices.ts";
|
|
28
|
+
import { discover } from "../discover.ts";
|
|
29
|
+
import { CentrsError, serializeCentrsError } from "../errors.ts";
|
|
30
|
+
import {
|
|
31
|
+
canonicalizeExecuteCommand,
|
|
32
|
+
executeEnvelope,
|
|
33
|
+
isWriteShaped,
|
|
34
|
+
validateExecuteEnvelope,
|
|
35
|
+
} from "../execute.ts";
|
|
36
|
+
import { parseDuration } from "../resolver/settings.ts";
|
|
37
|
+
import { buildRetrieveErrorEnvelope, retrieve } from "../retrieve.ts";
|
|
38
|
+
import {
|
|
39
|
+
buildRetrieveFanoutErrorEnvelope,
|
|
40
|
+
retrieveGroup,
|
|
41
|
+
} from "../retrieve-fanout.ts";
|
|
42
|
+
import type { CentrsMcpConfig } from "./config.ts";
|
|
43
|
+
import {
|
|
44
|
+
assertWritePermitted,
|
|
45
|
+
loadAllowlistCdb,
|
|
46
|
+
resolveRegisteredGroup,
|
|
47
|
+
resolveRegisteredTarget,
|
|
48
|
+
} from "./safety.ts";
|
|
49
|
+
|
|
50
|
+
export interface McpOperationMeta {
|
|
51
|
+
kind: "mcp";
|
|
52
|
+
tool: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The MCP tools return whatever envelope their core call produced (each carries
|
|
57
|
+
* its own command-specific operation meta), plus the synthetic adapter-error
|
|
58
|
+
* envelope below. The shared shape is the standard centrs envelope, so the type
|
|
59
|
+
* stays open on the operation slot rather than forcing a re-stamp.
|
|
60
|
+
*/
|
|
61
|
+
type McpEnvelope = CentrsEnvelope;
|
|
62
|
+
|
|
63
|
+
type EnvelopeWithData = CentrsEnvelope<unknown> & { data?: unknown };
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Build a minimal centrs error envelope for failures that happen in the MCP
|
|
67
|
+
* adapter itself (allowlist rejections, policy denials) — before or instead of
|
|
68
|
+
* a core call that would have produced its own envelope.
|
|
69
|
+
*/
|
|
70
|
+
function mcpErrorEnvelope(
|
|
71
|
+
tool: string,
|
|
72
|
+
error: unknown,
|
|
73
|
+
context: { target?: string } = {},
|
|
74
|
+
): McpEnvelope {
|
|
75
|
+
const centrsError =
|
|
76
|
+
error instanceof CentrsError
|
|
77
|
+
? error
|
|
78
|
+
: new CentrsError({
|
|
79
|
+
code: "internal/mcp-tool",
|
|
80
|
+
summary: error instanceof Error ? error.message : String(error),
|
|
81
|
+
remediation:
|
|
82
|
+
"This is an unexpected MCP adapter failure; retry, and report it if it persists.",
|
|
83
|
+
});
|
|
84
|
+
return {
|
|
85
|
+
ok: false,
|
|
86
|
+
error: serializeCentrsError(centrsError),
|
|
87
|
+
warnings: [],
|
|
88
|
+
tips: [],
|
|
89
|
+
meta: {
|
|
90
|
+
target: context.target ? { input: context.target } : {},
|
|
91
|
+
via: null,
|
|
92
|
+
settings: {},
|
|
93
|
+
operation: { kind: "mcp", tool },
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function requireConfirm(tool: string, confirmed: boolean | undefined): void {
|
|
99
|
+
if (confirmed !== true) {
|
|
100
|
+
throw new CentrsError({
|
|
101
|
+
code: "usage/confirmation-required",
|
|
102
|
+
summary: `${tool} mutates the CDB and requires \`confirm: true\`.`,
|
|
103
|
+
remediation:
|
|
104
|
+
"Re-submit the MCP tool call with confirm=true after verifying the target and requested CDB change.",
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function redactEntry(entry: unknown): unknown {
|
|
110
|
+
if (typeof entry !== "object" || entry === null) {
|
|
111
|
+
return entry;
|
|
112
|
+
}
|
|
113
|
+
const { password, ...rest } = entry as Record<string, unknown>;
|
|
114
|
+
return {
|
|
115
|
+
...rest,
|
|
116
|
+
passwordSet: typeof password === "string" ? password.length > 0 : undefined,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function redactDeviceSecrets(envelope: McpEnvelope): McpEnvelope {
|
|
121
|
+
if (!envelope.ok) {
|
|
122
|
+
return envelope;
|
|
123
|
+
}
|
|
124
|
+
const withData = envelope as EnvelopeWithData;
|
|
125
|
+
const data = withData.data;
|
|
126
|
+
if (typeof data !== "object" || data === null) {
|
|
127
|
+
return envelope;
|
|
128
|
+
}
|
|
129
|
+
const deviceData = data as { entry?: unknown };
|
|
130
|
+
if (deviceData.entry === undefined) {
|
|
131
|
+
return envelope;
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
...envelope,
|
|
135
|
+
data: {
|
|
136
|
+
...data,
|
|
137
|
+
entry: redactEntry(deviceData.entry),
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// --- centrs_explain ---------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
export const explainInputShape = {
|
|
145
|
+
command: z
|
|
146
|
+
.string()
|
|
147
|
+
.describe(
|
|
148
|
+
"A RouterOS CLI command string to canonicalize, e.g. '/ip/address/add address=1.2.3.4/24 interface=ether1'.",
|
|
149
|
+
),
|
|
150
|
+
} as const;
|
|
151
|
+
|
|
152
|
+
const explainSchema = z.object(explainInputShape);
|
|
153
|
+
export type ExplainArgs = z.infer<typeof explainSchema>;
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Offline canonicalization: turn a RouterOS CLI string into
|
|
157
|
+
* `{ path, verb, attributes, queries, mode, writeShaped }` without touching a
|
|
158
|
+
* device. No CDB or target needed — this is the cheap knowledge tier.
|
|
159
|
+
*/
|
|
160
|
+
export function handleExplain(args: ExplainArgs): McpEnvelope {
|
|
161
|
+
try {
|
|
162
|
+
const canonical = canonicalizeExecuteCommand(args.command);
|
|
163
|
+
return {
|
|
164
|
+
ok: true,
|
|
165
|
+
data: {
|
|
166
|
+
input: canonical.input,
|
|
167
|
+
mode: canonical.mode,
|
|
168
|
+
path: canonical.path,
|
|
169
|
+
verb: canonical.verb,
|
|
170
|
+
attributes: canonical.attributes,
|
|
171
|
+
queries: canonical.queries,
|
|
172
|
+
writeShaped: isWriteShaped(canonical),
|
|
173
|
+
},
|
|
174
|
+
warnings: [],
|
|
175
|
+
tips: [],
|
|
176
|
+
meta: {
|
|
177
|
+
target: {},
|
|
178
|
+
via: null,
|
|
179
|
+
settings: {},
|
|
180
|
+
operation: { kind: "mcp", tool: "centrs_explain" },
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
} catch (error) {
|
|
184
|
+
return mcpErrorEnvelope("centrs_explain", error);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// --- centrs_validate --------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
export const validateInputShape = {
|
|
191
|
+
target: z
|
|
192
|
+
.string()
|
|
193
|
+
.describe(
|
|
194
|
+
"CDB-registered target (name / IP / URL / MAC). Must be on the allowlist.",
|
|
195
|
+
),
|
|
196
|
+
command: z.string().describe("RouterOS CLI command to dry-run."),
|
|
197
|
+
via: z
|
|
198
|
+
.string()
|
|
199
|
+
.optional()
|
|
200
|
+
.describe(
|
|
201
|
+
"Override transport (rest-api, native-api, ssh). Defaults to the CDB/record preference.",
|
|
202
|
+
),
|
|
203
|
+
timeout: z
|
|
204
|
+
.union([z.string(), z.number()])
|
|
205
|
+
.optional()
|
|
206
|
+
.describe("Request timeout (ms or duration string)."),
|
|
207
|
+
} as const;
|
|
208
|
+
|
|
209
|
+
const validateSchema = z.object(validateInputShape);
|
|
210
|
+
export type ValidateArgs = z.infer<typeof validateSchema>;
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Dry-run a command through `:parse` + `/console/inspect` against a registered
|
|
214
|
+
* device, never running it. This is the bench's gold-bug catcher: schema
|
|
215
|
+
* inspection alone accepts `blackhole=yes`, but the real parser rejects it.
|
|
216
|
+
*/
|
|
217
|
+
export async function handleValidate(
|
|
218
|
+
args: ValidateArgs,
|
|
219
|
+
config: CentrsMcpConfig,
|
|
220
|
+
): Promise<McpEnvelope> {
|
|
221
|
+
let cdb: LoadedCdb;
|
|
222
|
+
try {
|
|
223
|
+
cdb = await loadAllowlistCdb(config);
|
|
224
|
+
resolveRegisteredTarget(cdb, args.target);
|
|
225
|
+
} catch (error) {
|
|
226
|
+
return mcpErrorEnvelope("centrs_validate", error, { target: args.target });
|
|
227
|
+
}
|
|
228
|
+
return await validateExecuteEnvelope(
|
|
229
|
+
{
|
|
230
|
+
targetInput: args.target,
|
|
231
|
+
command: args.command,
|
|
232
|
+
via: args.via,
|
|
233
|
+
timeout: args.timeout,
|
|
234
|
+
cdbFile: config.cdbFile,
|
|
235
|
+
cdbPassword: config.cdbPassword,
|
|
236
|
+
validate: true,
|
|
237
|
+
stdinIsTty: false,
|
|
238
|
+
},
|
|
239
|
+
config.env,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// --- centrs_retrieve --------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
export const retrieveInputShape = {
|
|
246
|
+
target: z
|
|
247
|
+
.string()
|
|
248
|
+
.optional()
|
|
249
|
+
.describe("CDB-registered single target. Use either target or group."),
|
|
250
|
+
group: z
|
|
251
|
+
.string()
|
|
252
|
+
.optional()
|
|
253
|
+
.describe("CDB group selector — fans out over all registered members."),
|
|
254
|
+
path: z
|
|
255
|
+
.string()
|
|
256
|
+
.describe("RouterOS menu path to read, e.g. '/system/resource'."),
|
|
257
|
+
attributes: z
|
|
258
|
+
.array(z.string())
|
|
259
|
+
.optional()
|
|
260
|
+
.describe("Project only these attributes."),
|
|
261
|
+
allAttributes: z
|
|
262
|
+
.boolean()
|
|
263
|
+
.optional()
|
|
264
|
+
.describe("Return every attribute instead of the default projection."),
|
|
265
|
+
filter: z.string().optional().describe("RouterOS-style filter expression."),
|
|
266
|
+
query: z
|
|
267
|
+
.string()
|
|
268
|
+
.optional()
|
|
269
|
+
.describe("Raw query word(s) appended to the read."),
|
|
270
|
+
via: z.string().optional().describe("Override transport."),
|
|
271
|
+
timeout: z
|
|
272
|
+
.union([z.string(), z.number()])
|
|
273
|
+
.optional()
|
|
274
|
+
.describe("Request timeout."),
|
|
275
|
+
} as const;
|
|
276
|
+
|
|
277
|
+
const retrieveSchema = z.object(retrieveInputShape);
|
|
278
|
+
export type RetrieveArgs = z.infer<typeof retrieveSchema>;
|
|
279
|
+
|
|
280
|
+
/** Read RouterOS state over the API for a registered target or group. */
|
|
281
|
+
export async function handleRetrieve(
|
|
282
|
+
args: RetrieveArgs,
|
|
283
|
+
config: CentrsMcpConfig,
|
|
284
|
+
): Promise<McpEnvelope> {
|
|
285
|
+
if ((args.target === undefined) === (args.group === undefined)) {
|
|
286
|
+
return mcpErrorEnvelope(
|
|
287
|
+
"centrs_retrieve",
|
|
288
|
+
new CentrsError({
|
|
289
|
+
code: "input/invalid-command",
|
|
290
|
+
summary: "Provide exactly one of `target` or `group`.",
|
|
291
|
+
remediation:
|
|
292
|
+
"Pass a single CDB target, or a group selector — not both, not neither.",
|
|
293
|
+
}),
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
let cdb: LoadedCdb;
|
|
298
|
+
try {
|
|
299
|
+
cdb = await loadAllowlistCdb(config);
|
|
300
|
+
if (args.group !== undefined) {
|
|
301
|
+
resolveRegisteredGroup(cdb, args.group);
|
|
302
|
+
} else if (args.target !== undefined) {
|
|
303
|
+
resolveRegisteredTarget(cdb, args.target);
|
|
304
|
+
}
|
|
305
|
+
} catch (error) {
|
|
306
|
+
return mcpErrorEnvelope("centrs_retrieve", error, {
|
|
307
|
+
target: args.target ?? args.group,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const request = {
|
|
312
|
+
targetInput: args.target,
|
|
313
|
+
group: args.group,
|
|
314
|
+
path: args.path,
|
|
315
|
+
attributes: args.attributes,
|
|
316
|
+
allAttributes: args.allAttributes,
|
|
317
|
+
filter: args.filter,
|
|
318
|
+
query: args.query,
|
|
319
|
+
via: args.via,
|
|
320
|
+
timeout: args.timeout,
|
|
321
|
+
cdbFile: config.cdbFile,
|
|
322
|
+
cdbPassword: config.cdbPassword,
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
try {
|
|
326
|
+
const envelope =
|
|
327
|
+
args.group !== undefined
|
|
328
|
+
? await retrieveGroup(request)
|
|
329
|
+
: await retrieve(request);
|
|
330
|
+
return envelope;
|
|
331
|
+
} catch (error) {
|
|
332
|
+
const envelope =
|
|
333
|
+
args.group !== undefined
|
|
334
|
+
? buildRetrieveFanoutErrorEnvelope(request, error)
|
|
335
|
+
: buildRetrieveErrorEnvelope(request, error);
|
|
336
|
+
return envelope;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// --- centrs_execute ---------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
export const executeInputShape = {
|
|
343
|
+
target: z
|
|
344
|
+
.string()
|
|
345
|
+
.describe(
|
|
346
|
+
"CDB-registered target. Inline host+credentials are not accepted.",
|
|
347
|
+
),
|
|
348
|
+
command: z.string().describe("RouterOS CLI command to run."),
|
|
349
|
+
confirm: z
|
|
350
|
+
.boolean()
|
|
351
|
+
.optional()
|
|
352
|
+
.describe(
|
|
353
|
+
"Required true for write-shaped commands (add/set/remove). Reads ignore it.",
|
|
354
|
+
),
|
|
355
|
+
via: z.string().optional().describe("Override transport."),
|
|
356
|
+
timeout: z
|
|
357
|
+
.union([z.string(), z.number()])
|
|
358
|
+
.optional()
|
|
359
|
+
.describe("Request timeout."),
|
|
360
|
+
} as const;
|
|
361
|
+
|
|
362
|
+
const executeSchema = z.object(executeInputShape);
|
|
363
|
+
export type ExecuteArgs = z.infer<typeof executeSchema>;
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Run a read- or write-shaped command against a registered device. Writes are
|
|
367
|
+
* double-gated: the CDB record must be `mcp=rw` (else `cdb/write-not-permitted`)
|
|
368
|
+
* and the call must pass `confirm:true` (else `usage/confirmation-required`).
|
|
369
|
+
*/
|
|
370
|
+
export async function handleExecute(
|
|
371
|
+
args: ExecuteArgs,
|
|
372
|
+
config: CentrsMcpConfig,
|
|
373
|
+
): Promise<McpEnvelope> {
|
|
374
|
+
try {
|
|
375
|
+
const cdb = await loadAllowlistCdb(config);
|
|
376
|
+
const registered = resolveRegisteredTarget(cdb, args.target);
|
|
377
|
+
const canonical = canonicalizeExecuteCommand(args.command);
|
|
378
|
+
if (isWriteShaped(canonical)) {
|
|
379
|
+
assertWritePermitted(registered);
|
|
380
|
+
}
|
|
381
|
+
} catch (error) {
|
|
382
|
+
return mcpErrorEnvelope("centrs_execute", error, { target: args.target });
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return await executeEnvelope(
|
|
386
|
+
{
|
|
387
|
+
targetInput: args.target,
|
|
388
|
+
command: args.command,
|
|
389
|
+
via: args.via,
|
|
390
|
+
timeout: args.timeout,
|
|
391
|
+
cdbFile: config.cdbFile,
|
|
392
|
+
cdbPassword: config.cdbPassword,
|
|
393
|
+
validate: true,
|
|
394
|
+
yes: args.confirm === true,
|
|
395
|
+
stdinIsTty: false,
|
|
396
|
+
},
|
|
397
|
+
config.env,
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// --- centrs_devices ----------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
export const devicesInputShape = {
|
|
404
|
+
op: z
|
|
405
|
+
.enum(["list", "show", "groups", "add", "edit", "set", "remove"])
|
|
406
|
+
.describe(
|
|
407
|
+
"Registry operation. Reads: list/show/groups. Writes (require confirm=true): add/set/remove. edit is reserved for the future interactive editor and returns usage/not-implemented.",
|
|
408
|
+
),
|
|
409
|
+
target: z
|
|
410
|
+
.string()
|
|
411
|
+
.optional()
|
|
412
|
+
.describe("Required for op=show/add/set/remove."),
|
|
413
|
+
group: z
|
|
414
|
+
.string()
|
|
415
|
+
.optional()
|
|
416
|
+
.describe(
|
|
417
|
+
"Group filter for op=list; first-class group field for op=add/set.",
|
|
418
|
+
),
|
|
419
|
+
user: z.string().optional().describe("CDB username for op=add/set."),
|
|
420
|
+
password: z
|
|
421
|
+
.string()
|
|
422
|
+
.optional()
|
|
423
|
+
.describe("CDB password for op=add/set. It is never returned by MCP."),
|
|
424
|
+
profile: z.string().optional().describe("CDB profile for op=add/set."),
|
|
425
|
+
session: z.string().optional().describe("CDB session for op=add/set."),
|
|
426
|
+
comment: z
|
|
427
|
+
.string()
|
|
428
|
+
.optional()
|
|
429
|
+
.describe(
|
|
430
|
+
"CDB base comment for op=add; op=set edits the comment via updates.",
|
|
431
|
+
),
|
|
432
|
+
recordType: z
|
|
433
|
+
.string()
|
|
434
|
+
.optional()
|
|
435
|
+
.describe("CDB record type for op=add, e.g. ipAdmin or macTarget."),
|
|
436
|
+
savedPassword: z
|
|
437
|
+
.boolean()
|
|
438
|
+
.optional()
|
|
439
|
+
.describe("Set the CDB saved-password flag for op=add/set."),
|
|
440
|
+
force: z
|
|
441
|
+
.boolean()
|
|
442
|
+
.optional()
|
|
443
|
+
.describe("Allow op=add to replace an existing target."),
|
|
444
|
+
updates: z
|
|
445
|
+
.array(
|
|
446
|
+
z.object({
|
|
447
|
+
key: z.string(),
|
|
448
|
+
value: z.string().nullable(),
|
|
449
|
+
}),
|
|
450
|
+
)
|
|
451
|
+
.optional()
|
|
452
|
+
.describe("Comment kv-soup updates for op=set. Use value=null to remove."),
|
|
453
|
+
strict: z
|
|
454
|
+
.boolean()
|
|
455
|
+
.optional()
|
|
456
|
+
.describe("For op=set, reject comment keys outside the allowlist."),
|
|
457
|
+
confirm: z
|
|
458
|
+
.boolean()
|
|
459
|
+
.optional()
|
|
460
|
+
.describe("Required true for CDB-mutating ops add/set/remove."),
|
|
461
|
+
} as const;
|
|
462
|
+
|
|
463
|
+
const devicesSchema = z.object(devicesInputShape);
|
|
464
|
+
export type DevicesArgs = z.infer<typeof devicesSchema>;
|
|
465
|
+
|
|
466
|
+
function requireDevicesTarget(
|
|
467
|
+
args: DevicesArgs,
|
|
468
|
+
operation: "show" | "add" | "edit" | "set" | "remove",
|
|
469
|
+
): string {
|
|
470
|
+
if (args.target !== undefined) {
|
|
471
|
+
return args.target;
|
|
472
|
+
}
|
|
473
|
+
throw new CentrsError({
|
|
474
|
+
code: "input/invalid-command",
|
|
475
|
+
summary: `op=${operation} requires a \`target\`.`,
|
|
476
|
+
remediation:
|
|
477
|
+
"Pass the CDB target to mutate, or use op=list to enumerate registered targets.",
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function resolveRecordType(recordType: string | undefined): number | undefined {
|
|
482
|
+
if (recordType === undefined) {
|
|
483
|
+
return undefined;
|
|
484
|
+
}
|
|
485
|
+
const resolved = recordTypeFromName(recordType);
|
|
486
|
+
if (resolved !== undefined) {
|
|
487
|
+
return resolved;
|
|
488
|
+
}
|
|
489
|
+
throw new CentrsError({
|
|
490
|
+
code: "input/invalid-command",
|
|
491
|
+
summary: `Unknown CDB recordType "${recordType}".`,
|
|
492
|
+
remediation:
|
|
493
|
+
"Use a WinBox CDB record type name such as ipAdmin, ipUser, or macTarget.",
|
|
494
|
+
context: { recordType },
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** Inspect or mutate the CDB device registry — the allowlist itself. */
|
|
499
|
+
export async function handleDevices(
|
|
500
|
+
args: DevicesArgs,
|
|
501
|
+
config: CentrsMcpConfig,
|
|
502
|
+
): Promise<McpEnvelope> {
|
|
503
|
+
try {
|
|
504
|
+
const cdb = await loadAllowlistCdb(config);
|
|
505
|
+
switch (args.op) {
|
|
506
|
+
case "list":
|
|
507
|
+
return listDevices({ cdb, group: args.group });
|
|
508
|
+
case "groups":
|
|
509
|
+
return listGroups({ cdb, withMembers: true });
|
|
510
|
+
case "show": {
|
|
511
|
+
const target = requireDevicesTarget(args, "show");
|
|
512
|
+
return redactDeviceSecrets(
|
|
513
|
+
showDevice({ cdb, target, env: config.env }),
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
case "add": {
|
|
517
|
+
requireConfirm("centrs_devices op=add", args.confirm);
|
|
518
|
+
const target = requireDevicesTarget(args, "add");
|
|
519
|
+
return redactDeviceSecrets(
|
|
520
|
+
await addDevice({
|
|
521
|
+
cdb,
|
|
522
|
+
target,
|
|
523
|
+
recordType: resolveRecordType(args.recordType),
|
|
524
|
+
user: args.user,
|
|
525
|
+
password: args.password,
|
|
526
|
+
group: args.group,
|
|
527
|
+
profile: args.profile,
|
|
528
|
+
session: args.session,
|
|
529
|
+
comment: args.comment,
|
|
530
|
+
savedPassword: args.savedPassword,
|
|
531
|
+
force: args.force,
|
|
532
|
+
}),
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
case "edit":
|
|
536
|
+
// The interactive editor has no MCP analogue; modify via op=set.
|
|
537
|
+
throw editInteractiveOnlyError();
|
|
538
|
+
case "set": {
|
|
539
|
+
requireConfirm("centrs_devices op=set", args.confirm);
|
|
540
|
+
const target = requireDevicesTarget(args, "set");
|
|
541
|
+
return redactDeviceSecrets(
|
|
542
|
+
await setDevice({
|
|
543
|
+
cdb,
|
|
544
|
+
target,
|
|
545
|
+
updates: args.updates,
|
|
546
|
+
user: args.user,
|
|
547
|
+
password: args.password,
|
|
548
|
+
group: args.group,
|
|
549
|
+
profile: args.profile,
|
|
550
|
+
session: args.session,
|
|
551
|
+
savedPassword: args.savedPassword,
|
|
552
|
+
strict: args.strict,
|
|
553
|
+
}),
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
case "remove": {
|
|
557
|
+
requireConfirm("centrs_devices op=remove", args.confirm);
|
|
558
|
+
const target = requireDevicesTarget(args, "remove");
|
|
559
|
+
return await removeDevice({ cdb, target });
|
|
560
|
+
}
|
|
561
|
+
default:
|
|
562
|
+
throw new CentrsError({
|
|
563
|
+
code: "input/invalid-command",
|
|
564
|
+
summary: `Unknown devices op "${String(args.op)}".`,
|
|
565
|
+
remediation:
|
|
566
|
+
"Use one of: list, show, groups, add, edit, set, remove.",
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
} catch (error) {
|
|
570
|
+
return mcpErrorEnvelope("centrs_devices", error, { target: args.target });
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// --- centrs_discover ---------------------------------------------------------
|
|
575
|
+
|
|
576
|
+
export const discoverInputShape = {
|
|
577
|
+
timeout: z
|
|
578
|
+
.union([z.string(), z.number()])
|
|
579
|
+
.optional()
|
|
580
|
+
.describe("MNDP listen window (ms or duration string). Defaults to 15s."),
|
|
581
|
+
port: z
|
|
582
|
+
.number()
|
|
583
|
+
.int()
|
|
584
|
+
.min(0)
|
|
585
|
+
.max(65535)
|
|
586
|
+
.optional()
|
|
587
|
+
.describe(
|
|
588
|
+
"UDP port to bind. Defaults to 5678; use 0 for an ephemeral port.",
|
|
589
|
+
),
|
|
590
|
+
host: z
|
|
591
|
+
.string()
|
|
592
|
+
.optional()
|
|
593
|
+
.describe("Bind address for MNDP listen. Defaults to 0.0.0.0."),
|
|
594
|
+
ttlMs: z
|
|
595
|
+
.number()
|
|
596
|
+
.int()
|
|
597
|
+
.positive()
|
|
598
|
+
.optional()
|
|
599
|
+
.describe("MNDP cache TTL in ms."),
|
|
600
|
+
save: z
|
|
601
|
+
.boolean()
|
|
602
|
+
.optional()
|
|
603
|
+
.describe("Persist discovered neighbors into the active CDB."),
|
|
604
|
+
group: z
|
|
605
|
+
.string()
|
|
606
|
+
.optional()
|
|
607
|
+
.describe("CDB group for saved neighbors. Defaults to discovered."),
|
|
608
|
+
confirm: z
|
|
609
|
+
.boolean()
|
|
610
|
+
.optional()
|
|
611
|
+
.describe("Required true when save=true because saving mutates the CDB."),
|
|
612
|
+
sendRefresh: z
|
|
613
|
+
.boolean()
|
|
614
|
+
.optional()
|
|
615
|
+
.describe(
|
|
616
|
+
"Send MNDP broadcast refresh. Defaults true; tests may set false.",
|
|
617
|
+
),
|
|
618
|
+
} as const;
|
|
619
|
+
|
|
620
|
+
const discoverSchema = z.object(discoverInputShape);
|
|
621
|
+
export type DiscoverArgs = z.infer<typeof discoverSchema>;
|
|
622
|
+
|
|
623
|
+
function mcpTimeoutMs(timeout: DiscoverArgs["timeout"]): number | undefined {
|
|
624
|
+
if (timeout === undefined) {
|
|
625
|
+
return undefined;
|
|
626
|
+
}
|
|
627
|
+
if (typeof timeout === "number") {
|
|
628
|
+
return timeout;
|
|
629
|
+
}
|
|
630
|
+
return parseDuration(timeout);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** Listen for MNDP neighbors and optionally save them into the active CDB. */
|
|
634
|
+
export async function handleDiscover(
|
|
635
|
+
args: DiscoverArgs,
|
|
636
|
+
config: CentrsMcpConfig,
|
|
637
|
+
): Promise<McpEnvelope> {
|
|
638
|
+
try {
|
|
639
|
+
if (args.save === true) {
|
|
640
|
+
requireConfirm("centrs_discover save", args.confirm);
|
|
641
|
+
}
|
|
642
|
+
return await discover({
|
|
643
|
+
timeoutMs: mcpTimeoutMs(args.timeout),
|
|
644
|
+
port: args.port,
|
|
645
|
+
host: args.host,
|
|
646
|
+
ttlMs: args.ttlMs,
|
|
647
|
+
save: args.save,
|
|
648
|
+
group: args.group,
|
|
649
|
+
cdbFile: config.cdbFile,
|
|
650
|
+
cdbPassword: config.cdbPassword,
|
|
651
|
+
env: config.env,
|
|
652
|
+
sendRefresh: args.sendRefresh,
|
|
653
|
+
});
|
|
654
|
+
} catch (error) {
|
|
655
|
+
return mcpErrorEnvelope("centrs_discover", error);
|
|
656
|
+
}
|
|
657
|
+
}
|
package/src/mcp.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The centrs MCP frontend barrel (`@tikoci/centrs/mcp`).
|
|
3
|
+
*
|
|
4
|
+
* This module re-exports the runnable, CHR-tested stdio server and its config,
|
|
5
|
+
* plus the one shared type the safety layer needs (`McpWritePolicy`). The tool
|
|
6
|
+
* surface, safety model, and CHR-gated examples are described in
|
|
7
|
+
* `commands/mcp/` and `docs/MATRIX.md` (status: `CHR-passed`, Phase 1); the
|
|
8
|
+
* registered tools in `./mcp/tools.ts` + `./mcp/server.ts` are the single source
|
|
9
|
+
* of truth for the tool list — this file no longer restates them as a plan.
|
|
10
|
+
*
|
|
11
|
+
* Design grounding: `~/GitHub/bench-routeros-tools` (REPORT.md,
|
|
12
|
+
* docs/REPORT_LIVE_CHR.md) — a scoped verb surface over canonical
|
|
13
|
+
* `{ path, verb, args }`, the explain → validate → run split, and `:parse` as the
|
|
14
|
+
* real-parser validation gate that schema inspection alone misses.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Per-device write policy carried in CDB comment-kv `mcp=<value>`. */
|
|
18
|
+
export type McpWritePolicy = "ro" | "rw";
|
|
19
|
+
|
|
20
|
+
export { type CentrsMcpConfig, resolveMcpConfig } from "./mcp/config.ts";
|
|
21
|
+
export { createCentrsMcpServer, runMcpStdio } from "./mcp/server.ts";
|
|
22
|
+
|
|
23
|
+
/** Entry point for `centrs mcp` / `bunx @tikoci/centrs mcp`. */
|
|
24
|
+
export async function runMcp(
|
|
25
|
+
args: readonly string[] = Bun.argv.slice(3),
|
|
26
|
+
): Promise<number> {
|
|
27
|
+
const { runMcpCli } = await import("./cli/mcp.ts");
|
|
28
|
+
return runMcpCli(args);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (import.meta.main) {
|
|
32
|
+
process.exitCode = await runMcp();
|
|
33
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Protocol Rules
|
|
2
|
+
|
|
3
|
+
Follow `docs/CONSTITUTION.md` (protocol selection section) and the
|
|
4
|
+
appropriate `commands/<name>/README.md`.
|
|
5
|
+
|
|
6
|
+
- Model protocols by capabilities, not by frontend.
|
|
7
|
+
- Ground protocol facts before implementation: RouterOS service/API/CLI path,
|
|
8
|
+
auth model, default port, local tooling, validation source, security warnings,
|
|
9
|
+
failure modes, and CHR test shape.
|
|
10
|
+
- Prefer rosetta MCP lookups and related tikoci project evidence over general
|
|
11
|
+
web search for RouterOS-specific facts.
|
|
12
|
+
- Keep validation and execution separate so callers can explain, validate, and run in distinct phases.
|
|
13
|
+
- Do not silently fall back to another protocol when the user requested `via`.
|
|
14
|
+
- Surface missing local tools with platform-aware install or remediation advice.
|