dsh-ensp 0.7.0 → 0.8.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.
@@ -19,7 +19,7 @@
19
19
  import { readFile, writeFile, readdir } from 'node:fs/promises'
20
20
  import path from 'node:path'
21
21
 
22
- import { buildTopology, twoPcLab, twoPcLabConfig, autoLayout, MODEL_SPECS } from '../core/build.mjs'
22
+ import { buildTopology, twoPcLab, twoPcLabConfig, autoLayout } from '../core/build.mjs'
23
23
  import { readTopology, writeTopology, summarise } from '../core/topology.mjs'
24
24
  import {
25
25
  LAB_IDS,
@@ -28,395 +28,252 @@ import {
28
28
  labAddressing,
29
29
  labDevices,
30
30
  } from '../core/labs.mjs'
31
- import { checkTopologySupport } from '../core/compat.mjs'
31
+ import { countLabConfigs, hasLabConfig, labConfigScript } from '../core/labconfig.mjs'
32
+ import { checkTopologySupport, pluginMediaPlan, pluginModels, installPluginMedia } from '../core/compat.mjs'
32
33
  import {
33
34
  enspPid,
34
35
  locateWindow,
35
36
  launchEnsp,
37
+ showEnsp,
36
38
  startAllDevices,
37
39
  stopAllDevices,
38
40
  waitForConsole,
39
41
  } from '../core/launcher.mjs'
40
42
  import { calibrateAnchors, inspectAnchors, DEFAULT_ANCHORS } from '../core/calibrate.mjs'
41
43
  import { doctor, modelsOf } from '../core/doctor.mjs'
42
- import { runAcceptance, hasChecks, EXPECTATIONS } from '../core/acceptance.mjs'
43
- import { knowledgeFor, searchKnowledge, knowledgeTopics } from '../core/knowledge.mjs'
44
+ import { runAcceptance, hasChecks, EXPECTATIONS, formatAcceptance } from '../core/acceptance.mjs'
44
45
  import { curriculumFor, renderCurriculum, searchCurriculum } from '../core/curriculum.mjs'
45
46
  import { pushConfig, pushConfigMany, queryDevice } from '../core/provision.mjs'
47
+ import { writeReport } from '../core/report.mjs'
46
48
  import { SCENARIOS, SCENARIO_IDS, byLevel, renderScenario, searchScenarios } from '../core/presets.mjs'
47
49
  import { PACKAGE_NAME, VERSION } from '../core/version.mjs'
50
+ import { TOOL_SPECS, toInputSchema } from '../tools.mjs'
48
51
 
49
52
  const SERVER_INFO = { name: PACKAGE_NAME, version: VERSION }
50
53
 
51
- /** JSON-schema helpers to keep the tool table readable. */
52
- const obj = (properties, required = []) => ({ type: 'object', properties, required })
53
-
54
- /** Every tool this server advertises. */
55
- const TOOLS = [
56
- {
57
- name: 'ensp_status',
58
- description:
59
- 'Report whether eNSP is running, where its window is, the calibrated toolbar anchors, ' +
60
- 'and — when a topology path is given — which device consoles answer.',
61
- inputSchema: obj({ topology: { type: 'string', description: 'optional .topo path' } }),
62
- async run({ topology }) {
63
- const pid = enspPid()
64
- if (!pid) return { running: false }
65
- const hwnd = locateWindow(pid)
66
- const anchors = hwnd ? calibrateAnchors(hwnd) : null
67
- const out = {
68
- running: true,
69
- pid,
70
- hwnd,
71
- anchors: anchors ? { start: anchors.start, stop: anchors.stop, method: anchors.method } : null,
72
- }
73
- if (topology) {
74
- const topo = await readTopology(topology)
75
- out.devices = await Promise.all(
76
- topo.devices
77
- .filter((d) => d.comPort)
78
- .map(async (d) => {
79
- const r = await waitForConsole({ port: d.comPort, timeoutMs: 4000 })
80
- return { name: d.name, port: d.comPort, ready: r.ready, prompt: r.prompt }
81
- }),
54
+ /**
55
+ * Tool handlers, keyed by name.
56
+ *
57
+ * The names, descriptions and parameter schemas live in `../tools.mjs` so the MCP
58
+ * table and the DSH plugin table cannot drift apart. Only the implementations stay
59
+ * here, because the two front ends return different shapes.
60
+ */
61
+ const HANDLERS = {
62
+ async ensp_status({ topology }) {
63
+ const pid = enspPid()
64
+ if (!pid) return { running: false }
65
+ const hwnd = locateWindow(pid)
66
+ const anchors = hwnd ? calibrateAnchors(hwnd) : null
67
+ const out = {
68
+ running: true,
69
+ pid,
70
+ hwnd,
71
+ anchors: anchors ? { start: anchors.start, stop: anchors.stop, method: anchors.method } : null,
72
+ }
73
+ if (topology) {
74
+ const topo = await readTopology(topology)
75
+ out.devices = await Promise.all(
76
+ topo.devices
77
+ .filter((d) => d.comPort)
78
+ .map(async (d) => {
79
+ const r = await waitForConsole({ port: d.comPort, timeoutMs: 4000 })
80
+ return { name: d.name, port: d.comPort, ready: r.ready, prompt: r.prompt }
81
+ }),
82
+ )
83
+ }
84
+ return out
85
+ },
86
+ async ensp_build_topology(args) {
87
+ let topo
88
+ if (args.scenario) {
89
+ if (!hasLab(args.scenario)) {
90
+ throw new Error(
91
+ `scenario "${args.scenario}" has no topology blueprint; ${LAB_IDS.length} of ${SCENARIO_IDS.length} have one: ${LAB_IDS.join(', ')}`,
82
92
  )
83
93
  }
84
- return out
85
- },
94
+ topo = buildScenarioTopology(args.scenario)
95
+ } else if (args.preset === 'two-pc' || (!args.devices && !args.links)) {
96
+ topo = twoPcLab({ vlan: args.vlan ?? 10, subnet: args.subnet ?? '192.168.10' })
97
+ } else {
98
+ const b = buildTopology()
99
+ for (const d of args.devices ?? []) b.add(d.name, d.model, { x: d.x, y: d.y, pc: d.pc })
100
+ for (const l of args.links ?? []) b.link(l.a, l.aPort, l.b, l.bPort)
101
+ topo = autoLayout(b.finish())
102
+ }
103
+ const target = path.resolve(args.path)
104
+ await writeTopology(target, topo)
105
+ const support = checkTopologySupport(topo)
106
+ return {
107
+ path: target,
108
+ ...summarise(topo),
109
+ ...(args.scenario
110
+ ? { scenario: args.scenario, devices: labDevices(args.scenario), addressing: labAddressing(args.scenario) }
111
+ : {}),
112
+ ...(support.ok ? {} : { unsupportedDevices: support.blocked }),
113
+ }
86
114
  },
87
- {
88
- name: 'ensp_build_topology',
89
- description:
90
- 'Generate a .topo file. Use `preset: "two-pc"` for the built-in two-PC/one-switch lab, ' +
91
- '`scenario` to build the topology behind a scenario id, or pass `devices` and `links` ' +
92
- 'explicitly. Returns the written path and a summary.',
93
- inputSchema: obj({
94
- path: { type: 'string', description: 'output .topo path' },
95
- preset: { type: 'string', enum: ['two-pc'], description: 'built-in lab' },
96
- scenario: { type: 'string', description: 'scenario id with a topology blueprint' },
97
- vlan: { type: 'number', description: 'for preset two-pc (default 10)' },
98
- subnet: { type: 'string', description: 'for preset two-pc (default 192.168.10)' },
99
- devices: {
100
- type: 'array',
101
- description: 'explicit device list',
102
- items: obj({
103
- name: { type: 'string' },
104
- model: { type: 'string', enum: Object.keys(MODEL_SPECS) },
105
- x: { type: 'number' },
106
- y: { type: 'number' },
107
- pc: obj({ ip: { type: 'string' }, gateway: { type: 'string' } }),
108
- }),
109
- },
110
- links: {
111
- type: 'array',
112
- description: 'explicit links using VRP interface names',
113
- items: obj({
114
- a: { type: 'string' },
115
- aPort: { type: 'string' },
116
- b: { type: 'string' },
117
- bPort: { type: 'string' },
118
- }),
119
- },
120
- }),
121
- async run(args) {
122
- let topo
123
- if (args.scenario) {
124
- if (!hasLab(args.scenario)) {
125
- throw new Error(
126
- `scenario "${args.scenario}" has no topology blueprint; ${LAB_IDS.length} of ${SCENARIO_IDS.length} have one: ${LAB_IDS.join(', ')}`,
127
- )
128
- }
129
- topo = buildScenarioTopology(args.scenario)
130
- } else if (args.preset === 'two-pc' || (!args.devices && !args.links)) {
131
- topo = twoPcLab({ vlan: args.vlan ?? 10, subnet: args.subnet ?? '192.168.10' })
132
- } else {
133
- const b = buildTopology()
134
- for (const d of args.devices ?? []) b.add(d.name, d.model, { x: d.x, y: d.y, pc: d.pc })
135
- for (const l of args.links ?? []) b.link(l.a, l.aPort, l.b, l.bPort)
136
- topo = autoLayout(b.finish())
137
- }
138
- const target = path.resolve(args.path)
139
- await writeTopology(target, topo)
140
- const support = checkTopologySupport(topo)
141
- return {
142
- path: target,
143
- ...summarise(topo),
144
- ...(args.scenario
145
- ? { scenario: args.scenario, devices: labDevices(args.scenario), addressing: labAddressing(args.scenario) }
146
- : {}),
147
- ...(support.ok ? {} : { unsupportedDevices: support.blocked }),
148
- }
149
- },
115
+ async ensp_open({ topology }) {
116
+ const launched = await launchEnsp({ topology: path.resolve(topology) })
117
+ return { pid: launched.pid, hwnd: launched.hwnd, reused: Boolean(launched.reused) }
150
118
  },
151
- {
152
- name: 'ensp_open',
153
- description:
154
- 'Launch eNSP (or reuse the running instance) and open a topology file. ' +
155
- 'Returns the process id and window handle needed by the other tools.',
156
- inputSchema: obj({ topology: { type: 'string' } }, ['topology']),
157
- async run({ topology }) {
158
- const launched = await launchEnsp({ topology: path.resolve(topology) })
159
- return { pid: launched.pid, hwnd: launched.hwnd, reused: Boolean(launched.reused) }
160
- },
119
+ async ensp_start(args) {
120
+ const topo = await readTopology(path.resolve(args.topology))
121
+ const hwnd = args.hwnd ?? locateWindow(enspPid())
122
+ if (!hwnd) throw new Error('eNSP window not found; call ensp_open first')
123
+ const result = await startAllDevices({
124
+ devices: topo.devices,
125
+ hwnd,
126
+ perDeviceTimeoutMs: args.perDeviceTimeoutMs ?? 240000,
127
+ minimise: !args.keepWindow,
128
+ })
129
+ return {
130
+ allReady: result.allReady,
131
+ ready: result.ready,
132
+ total: result.total,
133
+ anchors: result.anchors,
134
+ devices: result.devices.map((d) => ({ name: d.name, port: d.port, ready: d.ready, prompt: d.prompt, waitedMs: d.waitedMs })),
135
+ }
161
136
  },
162
- {
163
- name: 'ensp_start',
164
- description:
165
- 'Boot every device in a topology. Locates the toolbar button by colour (no hard-coded ' +
166
- 'coordinates), clicks it, then polls each console until VRP answers — boot time is ' +
167
- 'measured, never assumed. Minimises the window afterwards unless keepWindow is set.',
168
- inputSchema: obj(
169
- {
170
- topology: { type: 'string' },
171
- hwnd: { type: 'string', description: 'optional; located automatically' },
172
- perDeviceTimeoutMs: { type: 'number', description: 'default 240000' },
173
- keepWindow: { type: 'boolean', description: 'leave the window visible' },
174
- },
175
- ['topology'],
176
- ),
177
- async run(args) {
178
- const topo = await readTopology(path.resolve(args.topology))
179
- const hwnd = args.hwnd ?? locateWindow(enspPid())
180
- if (!hwnd) throw new Error('eNSP window not found; call ensp_open first')
181
- const result = await startAllDevices({
182
- devices: topo.devices,
183
- hwnd,
184
- perDeviceTimeoutMs: args.perDeviceTimeoutMs ?? 240000,
185
- minimise: !args.keepWindow,
186
- })
187
- return {
188
- allReady: result.allReady,
189
- ready: result.ready,
190
- total: result.total,
191
- anchors: result.anchors,
192
- devices: result.devices.map((d) => ({ name: d.name, port: d.port, ready: d.ready, prompt: d.prompt, waitedMs: d.waitedMs })),
193
- }
194
- },
137
+ async ensp_stop({ hwnd }) {
138
+ const target = hwnd ?? locateWindow(enspPid())
139
+ if (!target) throw new Error('eNSP window not found')
140
+ const r = stopAllDevices(target)
141
+ return { clicked: r.ok, anchors: r.anchors, detail: r.detail }
195
142
  },
196
- {
197
- name: 'ensp_stop',
198
- description: 'Shut down every device in the running topology.',
199
- inputSchema: obj({ hwnd: { type: 'string' } }),
200
- async run({ hwnd }) {
201
- const target = hwnd ?? locateWindow(enspPid())
202
- if (!target) throw new Error('eNSP window not found')
203
- const r = stopAllDevices(target)
204
- return { clicked: r.ok, anchors: r.anchors, detail: r.detail }
205
- },
143
+ async ensp_apply({ device, port, script, save }) {
144
+ const r = await pushConfig({ device, port, script, save: Boolean(save) })
145
+ return {
146
+ ok: r.ok,
147
+ applied: r.applied,
148
+ total: r.total,
149
+ saved: r.saved,
150
+ durationMs: r.durationMs,
151
+ failedAt: r.failedAt,
152
+ }
206
153
  },
207
- {
208
- name: 'ensp_apply',
209
- description:
210
- 'Push a configuration script to ONE device. Commands are sent one at a time, each one ' +
211
- 'confirmed by its prompt; the first error aborts the batch and reports the line number, ' +
212
- 'the offending command and its raw output.',
213
- inputSchema: obj(
214
- {
215
- device: { type: 'string', description: 'device name, for reporting' },
216
- port: { type: 'number', description: 'console TCP port from the .topo' },
217
- script: { type: 'string', description: 'the configuration text' },
218
- save: { type: 'boolean', description: 'run save at the end' },
219
- },
220
- ['device', 'port', 'script'],
221
- ),
222
- async run({ device, port, script, save }) {
223
- const r = await pushConfig({ device, port, script, save: Boolean(save) })
224
- return {
225
- ok: r.ok,
226
- applied: r.applied,
227
- total: r.total,
228
- saved: r.saved,
229
- durationMs: r.durationMs,
230
- failedAt: r.failedAt,
231
- }
232
- },
154
+ async ensp_apply_dir(args) {
155
+ const topo = await readTopology(path.resolve(args.topology))
156
+ const consoles = new Map(topo.devices.filter((d) => d.comPort).map((d) => [d.name, d.comPort]))
157
+ const files = (await readdir(args.directory)).filter((f) => /\.(cfg|txt)$/i.test(f))
158
+ const targets = []
159
+ for (const file of files) {
160
+ const device = file.replace(/\.(cfg|txt)$/i, '')
161
+ const port = consoles.get(device)
162
+ if (!port) continue
163
+ targets.push({ device, port, script: await readFile(path.join(args.directory, file), 'utf8') })
164
+ }
165
+ const results = await pushConfigMany({
166
+ targets,
167
+ concurrency: args.concurrency ?? 3,
168
+ save: Boolean(args.save),
169
+ })
170
+ return {
171
+ configured: results.filter((r) => r.ok).length,
172
+ total: results.length,
173
+ results: results.map((r) => ({ device: r.device, ok: r.ok, applied: r.applied, total: r.total, failedAt: r.failedAt })),
174
+ }
233
175
  },
234
- {
235
- name: 'ensp_apply_dir',
236
- description:
237
- 'Push one .cfg per device from a directory, matching file names to device names. ' +
238
- 'Devices are configured concurrently; a failure on one does not stop the others.',
239
- inputSchema: obj(
240
- {
241
- directory: { type: 'string' },
242
- topology: { type: 'string' },
243
- save: { type: 'boolean' },
244
- concurrency: { type: 'number', description: 'default 3' },
245
- },
246
- ['directory', 'topology'],
247
- ),
248
- async run(args) {
249
- const topo = await readTopology(path.resolve(args.topology))
250
- const consoles = new Map(topo.devices.filter((d) => d.comPort).map((d) => [d.name, d.comPort]))
251
- const files = (await readdir(args.directory)).filter((f) => /\.(cfg|txt)$/i.test(f))
252
- const targets = []
253
- for (const file of files) {
254
- const device = file.replace(/\.(cfg|txt)$/i, '')
255
- const port = consoles.get(device)
256
- if (!port) continue
257
- targets.push({ device, port, script: await readFile(path.join(args.directory, file), 'utf8') })
258
- }
259
- const results = await pushConfigMany({
260
- targets,
261
- concurrency: args.concurrency ?? 3,
262
- save: Boolean(args.save),
263
- })
264
- return {
265
- configured: results.filter((r) => r.ok).length,
266
- total: results.length,
267
- results: results.map((r) => ({ device: r.device, ok: r.ok, applied: r.applied, total: r.total, failedAt: r.failedAt })),
268
- }
269
- },
176
+ async ensp_query({ port, command }) {
177
+ const r = await queryDevice({ port, command })
178
+ return { ok: r.ok, output: r.output, error: r.error }
270
179
  },
271
- {
272
- name: 'ensp_query',
273
- description: 'Run one read-only command on a device console and return its output.',
274
- inputSchema: obj({ port: { type: 'number' }, command: { type: 'string' } }, ['port', 'command']),
275
- async run({ port, command }) {
276
- const r = await queryDevice({ port, command })
277
- return { ok: r.ok, output: r.output, error: r.error }
278
- },
180
+ async ensp_probe({ hwnd }) {
181
+ const target = hwnd ?? locateWindow(enspPid())
182
+ if (!target) throw new Error('eNSP window not found')
183
+ const r = inspectAnchors(target)
184
+ return { window: r.window, green: r.green, red: r.red, resolved: r.resolved, defaults: DEFAULT_ANCHORS }
279
185
  },
280
- {
281
- name: 'ensp_probe',
282
- description:
283
- 'Re-measure the toolbar button positions. Run this after upgrading eNSP or changing ' +
284
- 'display scaling; the coordinates are cached per window size otherwise.',
285
- inputSchema: obj({ hwnd: { type: 'string' } }),
286
- async run({ hwnd }) {
287
- const target = hwnd ?? locateWindow(enspPid())
288
- if (!target) throw new Error('eNSP window not found')
289
- const r = inspectAnchors(target)
290
- return { window: r.window, green: r.green, red: r.red, resolved: r.resolved, defaults: DEFAULT_ANCHORS }
291
- },
186
+ async ensp_show({ hwnd }) {
187
+ const target = hwnd ?? locateWindow(enspPid())
188
+ if (!target) throw new Error('eNSP window not found')
189
+ return showEnsp(target)
292
190
  },
293
- {
294
- name: 'ensp_scenarios',
295
- description:
296
- 'List or search the built-in configuration scenarios (' + SCENARIO_IDS.length + ' of them, mirroring the labs ' +
297
- 'shipped with eNSP plus verified project scenarios). Each entry is a skeleton: what to ' +
298
- 'create, in what order, and the command that proves it worked — not a command dump.',
299
- inputSchema: obj({
300
- search: { type: 'string', description: 'keyword filter, e.g. "ospf" or "vlan"' },
301
- level: { type: 'string', enum: ['basic', 'intermediate', 'advanced'] },
302
- }),
303
- async run({ search, level }) {
304
- const groups = byLevel()
305
- let ids = level ? groups[level] ?? [] : SCENARIO_IDS
306
- if (search) {
307
- const matched = new Set(searchScenarios(search).map((h) => h.id))
308
- ids = ids.filter((id) => matched.has(id))
309
- }
310
- return ids.map((id) => ({
311
- id,
312
- title: SCENARIOS[id].title,
313
- level: SCENARIOS[id].level,
314
- goal: SCENARIOS[id].goal,
315
- devices: SCENARIOS[id].devices,
316
- source: SCENARIOS[id].source,
317
- }))
318
- },
191
+ async ensp_scenarios({ search, level, syllabus }) {
192
+ if (syllabus) return searchCurriculum(syllabus)
193
+ const groups = byLevel()
194
+ let ids = level ? groups[level] ?? [] : SCENARIO_IDS
195
+ if (search) {
196
+ const matched = new Set(searchScenarios(search).map((h) => h.id))
197
+ ids = ids.filter((id) => matched.has(id))
198
+ }
199
+ return ids.map((id) => ({
200
+ id,
201
+ title: SCENARIOS[id].title,
202
+ level: SCENARIOS[id].level,
203
+ goal: SCENARIOS[id].goal,
204
+ devices: SCENARIOS[id].devices,
205
+ source: SCENARIOS[id].source,
206
+ }))
319
207
  },
320
- {
321
- name: 'ensp_scenario',
322
- description:
323
- 'Return one scenario as a step-by-step brief: ordered direction, the verification command ' +
324
- 'for each step, and the known pitfalls. Use this to plan a configuration before touching ' +
325
- 'any device.',
326
- inputSchema: obj({ id: { type: 'string' } }, ['id']),
327
- async run({ id }) {
328
- if (!SCENARIOS[id]) throw new Error(`unknown scenario "${id}"`)
329
- return {
330
- id,
331
- brief: renderScenario(id) + renderCurriculum(id),
332
- data: SCENARIOS[id],
333
- hasTopology: hasLab(id),
334
- hasCuratedChecks: hasChecks(id),
335
- ...(hasLab(id) ? { devices: labDevices(id), addressing: labAddressing(id) } : {}),
336
- knowledge: knowledgeFor(id),
337
- curriculum: curriculumFor(id),
338
- }
339
- },
208
+ async ensp_scenario({ id }) {
209
+ if (!SCENARIOS[id]) throw new Error(`unknown scenario "${id}"`)
210
+ return {
211
+ id,
212
+ brief: renderScenario(id) + renderCurriculum(id),
213
+ data: SCENARIOS[id],
214
+ hasTopology: hasLab(id),
215
+ hasCuratedChecks: hasChecks(id),
216
+ ...(hasLab(id) ? { devices: labDevices(id), addressing: labAddressing(id) } : {}),
217
+ curriculum: curriculumFor(id),
218
+ }
340
219
  },
341
- {
342
- name: 'ensp_doctor',
343
- description:
344
- 'Check whether this machine can actually run a lab, before starting anything. ' +
345
- 'Reports eNSP and VirtualBox presence, missing device images, console ports already in ' +
346
- 'use, free memory, and — most importantly — whether synthetic mouse input reaches this ' +
347
- 'session at all. Every failing check comes with the fix. Never starts eNSP.',
348
- inputSchema: obj({
349
- topology: { type: 'string', description: 'optional .topo path, so device images can be checked' },
350
- deep: { type: 'boolean', description: 'run the slow input-injection probe (default true)' },
351
- }),
352
- async run({ topology, deep }) {
353
- let models
354
- if (topology) models = modelsOf(await readTopology(topology))
355
- return doctor({ models, deep: deep !== false })
356
- },
220
+ async ensp_images({ model, source, overwrite }) {
221
+ if (source) {
222
+ if (!model) throw new Error('model is required when installing an image')
223
+ return installPluginMedia(model, source, { overwrite: overwrite === true })
224
+ }
225
+ const models = model ? [model] : pluginModels()
226
+ const plans = models.map((name) => pluginMediaPlan(name))
227
+ return { models: plans, ready: plans.every((plan) => plan.ready) }
357
228
  },
358
- {
359
- name: 'ensp_verify',
360
- description:
361
- 'Run a scenario\'s acceptance checks against a lab that is already running. Curated ' +
362
- 'scenarios assert on what each device reports (OSPF Full, BGP Established, VRRP Master); ' +
363
- 'the rest smoke-test every verification command from the scenario. Use this to decide ' +
364
- 'whether the lab works, not just whether configuration was sent.',
365
- inputSchema: obj({
366
- scenario: { type: 'string', description: 'scenario id, e.g. "ospf-single"' },
367
- topology: { type: 'string', description: 'the .topo file that is running (for console ports)' },
368
- timeout: { type: 'number', description: 'per-command timeout in ms (default 25000)' },
369
- wait: {
370
- type: 'number',
371
- description: 'how long a failing assertion may keep being retried, in ms (default 60000)',
372
- },
373
- }),
374
- async run({ scenario, topology, timeout, wait }) {
375
- if (!scenario) throw new Error('scenario is required')
376
- if (!topology) throw new Error('topology is required: pass the .topo file that is running')
377
- return runAcceptance({ id: scenario, topology, timeoutMs: timeout, waitMs: wait })
378
- },
229
+ async ensp_doctor({ topology, deep }) {
230
+ let models
231
+ if (topology) models = modelsOf(await readTopology(topology))
232
+ return doctor({ models, deep: deep !== false })
379
233
  },
380
- {
381
- name: 'ensp_knowledge',
382
- description:
383
- 'Search the curriculum knowledge index: short factual statements about the terms each '
384
- + 'scenario exercises, grouped by topic. ' +
385
- 'Use it to look up the vocabulary a scenario teaches (which LSA belongs to which area, ' +
386
- 'which BGP attribute wins, which port role is MSTP only) without leaving the lab. ' +
387
- 'Returns short question/answer pairs plus the scenarios that teach each topic.',
388
- inputSchema: obj({
389
- query: { type: 'string', description: 'keyword, e.g. "LSA" or "MED"' },
390
- scenario: { type: 'string', description: 'scenario id, to list what that lab teaches' },
391
- syllabus: { type: 'string', description: 'keyword to search the manual chapter index instead' },
392
- }),
393
- async run({ query, scenario, syllabus }) {
394
- if (syllabus) {
395
- return { syllabus, chapters: searchCurriculum(syllabus) }
396
- }
397
- if (scenario) {
398
- if (!SCENARIOS[scenario]) throw new Error(`unknown scenario "${scenario}"`)
399
- return {
400
- scenario,
401
- topics: knowledgeTopics(),
402
- points: knowledgeFor(scenario),
403
- curriculum: curriculumFor(scenario),
404
- }
405
- }
406
- if (!query) throw new Error('pass query, scenario or syllabus')
407
- return { query, points: searchKnowledge(query) }
408
- },
234
+ async ensp_verify({ scenario, topology, timeout, wait }) {
235
+ if (!scenario) throw new Error('scenario is required')
236
+ if (!topology) throw new Error('topology is required: pass the .topo file that is running')
237
+ const report = await runAcceptance({ id: scenario, topology, timeoutMs: timeout, waitMs: wait })
238
+ return {
239
+ ...report,
240
+ verified: report.ok !== null,
241
+ summary: formatAcceptance(report),
242
+ }
409
243
  },
410
-
411
- {
412
- name: 'ensp_preset_config',
413
- description: 'Return the configuration that makes the built-in two-PC lab pass traffic.',
414
- inputSchema: obj({ vlan: { type: 'number' } }),
415
- async run({ vlan }) {
416
- return { script: twoPcLabConfig({ vlan: vlan ?? 10 }) }
417
- },
244
+ async ensp_preset_config({ vlan, scenario }) {
245
+ if (!scenario) return { script: twoPcLabConfig({ vlan: vlan ?? 10 }) }
246
+ if (!hasLabConfig(scenario)) {
247
+ throw new Error(`scenario "${scenario}" has no addressing plan; ${countLabConfigs()} of them have one`)
248
+ }
249
+ return { script: labConfigScript(scenario) }
418
250
  },
419
- ]
251
+ async ensp_report({ scenario, topology, out, timeout, wait }) {
252
+ if (!scenario) throw new Error('scenario is required')
253
+ const result = await writeReport({ id: scenario, topology, out, timeoutMs: timeout, waitMs: wait })
254
+ return {
255
+ path: result.path,
256
+ bytes: result.bytes,
257
+ scenario: result.evidence.id,
258
+ title: result.evidence.title,
259
+ verdict: result.evidence.verdict,
260
+ devices: result.evidence.devices.length,
261
+ checks: result.evidence.checks.length,
262
+ }
263
+ },
264
+ }
265
+
266
+ /**
267
+ * The published tool table: shared specs plus the local handlers.
268
+ *
269
+ * Exported so the tests can compare it with what the DSH plugin registers.
270
+ */
271
+ export const TOOLS = TOOL_SPECS.map((spec) => ({
272
+ name: spec.name,
273
+ description: spec.description,
274
+ inputSchema: toInputSchema(spec.params),
275
+ run: HANDLERS[spec.name],
276
+ }))
420
277
 
421
278
  // ---------------------------------------------------------------------------
422
279
  // JSON-RPC plumbing