mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-19 23:03:33 +08:00
Netx tools: load from netx remote /mcp, add per-tool toggles
- Drop the TypeScript netx tool implementations and the hard-coded TOOLS_BY_GROUP list; register netx__* tools from the server's tools/list (group from _meta.netx.group) and forward tools/call. - Thin Streamable HTTP client on standard fetch (fetchImpl injectable); cached catalog when netx is unreachable, refresh to reconnect. - disabledTools (deny list; new server tools follow their group) and readOnlyOnly (only readOnlyHint=true tools), re-registered live. - Settings card: per-group expandable tool rows with switches and a write marker; skills append a disabled-tools note. - Toggles only control model visibility; netx enforces security. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
2def53e7c7
commit
2ef3a43c63
38 changed files with 5049 additions and 17029 deletions
|
|
@ -12,8 +12,6 @@ import {
|
|||
groupsForced,
|
||||
kbEnabledForPlane,
|
||||
SKILL_DIR_BY_GROUP,
|
||||
toolNamesForGroups,
|
||||
TOOLS_BY_GROUP,
|
||||
} from '../src/netx/capability-groups.ts'
|
||||
|
||||
test('defaults: ops + bizMonitor in preset; topology and public off', () => {
|
||||
|
|
@ -74,14 +72,12 @@ test('preset and host planes are symmetric; only defaults differ', () => {
|
|||
})
|
||||
|
||||
test('null boolean flags do not resurrect defaults incorrectly', () => {
|
||||
// Settings projection may emit null for volatile booleans.
|
||||
const groups = capabilityGroupsFromSettings({
|
||||
groupOpsInPreset: /** @type {any} */ (null),
|
||||
groupOpsPublic: /** @type {any} */ (null),
|
||||
groupBizMonitorInPreset: /** @type {any} */ (null),
|
||||
groupBizMonitorPublic: /** @type {any} */ (null),
|
||||
})
|
||||
// null is not a boolean → fall back to defaults (ops+bizMonitor inPreset, public off)
|
||||
assert.deepEqual(groupsForPlane(groups, 'preset'), ['ops', 'bizMonitor'])
|
||||
assert.deepEqual(groupsForPlane(groups, 'host'), [])
|
||||
})
|
||||
|
|
@ -95,24 +91,12 @@ test('kbEnabledForPlane mirrors inPreset/public symmetry', () => {
|
|||
assert.equal(kbEnabledForPlane({}, 'host'), false)
|
||||
})
|
||||
|
||||
test('one group one skill dir; ops owns NMS + managed CLI; topology owns canvas; bizMonitor owns cutover read', () => {
|
||||
test('one group one skill dir', () => {
|
||||
assert.deepEqual(SKILL_DIR_BY_GROUP, {
|
||||
ops: 'ops',
|
||||
topology: 'topology',
|
||||
bizMonitor: 'biz-monitor',
|
||||
})
|
||||
assert.ok(TOOLS_BY_GROUP.ops.includes('netx__findTopologyPaths'))
|
||||
assert.ok(TOOLS_BY_GROUP.ops.includes('netx__execManagedNe'))
|
||||
assert.ok(TOOLS_BY_GROUP.ops.includes('netx__getNeExecJob'))
|
||||
assert.ok(TOOLS_BY_GROUP.ops.includes('netx__queryNmsAlarms'))
|
||||
assert.ok(TOOLS_BY_GROUP.topology.includes('netx__getTopologyTree'))
|
||||
assert.ok(TOOLS_BY_GROUP.topology.includes('netx__layoutTopologyView'))
|
||||
assert.ok(TOOLS_BY_GROUP.topology.includes('netx__suggestSinkHubs'))
|
||||
assert.equal(TOOLS_BY_GROUP.topology.includes('netx__sinkTopologyDualUnits'), false)
|
||||
assert.ok(TOOLS_BY_GROUP.bizMonitor.includes('netx__getBizMonitorContext'))
|
||||
assert.ok(TOOLS_BY_GROUP.bizMonitor.includes('netx__listBizMonitors'))
|
||||
assert.ok(TOOLS_BY_GROUP.bizMonitor.includes('netx__listBizMonitorBatches'))
|
||||
assert.ok(TOOLS_BY_GROUP.bizMonitor.includes('netx__getBizCollectCommandRaw'))
|
||||
})
|
||||
|
||||
test('capabilityGroupsFromSettings honors explicit false for inPreset defaults', () => {
|
||||
|
|
@ -124,10 +108,6 @@ test('capabilityGroupsFromSettings honors explicit false for inPreset defaults',
|
|||
})
|
||||
assert.deepEqual(groupsForPlane(groups, 'preset'), ['topology'])
|
||||
assert.deepEqual(groupsForPlane(groups, 'public'), ['topology'])
|
||||
assert.ok(toolNamesForGroups(['topology']).has('netx__layoutTopologyView'))
|
||||
assert.equal(toolNamesForGroups(['topology']).has('netx__sinkTopologyDualUnits'), false)
|
||||
assert.equal(toolNamesForGroups(['topology']).has('netx__queryNmsAlarms'), false)
|
||||
assert.equal(toolNamesForGroups(['topology']).has('netx__getBizMonitorContext'), false)
|
||||
})
|
||||
|
||||
test('legacy groupNms/common/managedNe map to ops; absent topology flags use legacy layout', () => {
|
||||
|
|
|
|||
45
test/group-skills-disabled.test.mjs
Normal file
45
test/group-skills-disabled.test.mjs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/**
|
||||
* Skill injection appends disabled-tool footnotes when tools are filtered.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { registerGroupSkills } from '../src/netx/group-skills.ts'
|
||||
|
||||
test('registerGroupSkills appends disabled footnote; skips disabled groups', async () => {
|
||||
/** @type {Array<{ name: string, content: string }>} */
|
||||
const registered = []
|
||||
const ctx = {
|
||||
skills: {
|
||||
register(skill) {
|
||||
registered.push({ name: skill.name, content: skill.content })
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
logger: { warn() {} },
|
||||
}
|
||||
|
||||
const dispose = await registerGroupSkills(ctx, ['ops'], 'test', {
|
||||
disabledByGroup: { ops: ['netx__execManagedNe'] },
|
||||
lang: 'zh',
|
||||
})
|
||||
assert.ok(registered.length >= 1)
|
||||
assert.match(registered[0].content, /以下工具已禁用:netx__execManagedNe,不要调用/)
|
||||
dispose()
|
||||
|
||||
registered.length = 0
|
||||
const dispose2 = await registerGroupSkills(ctx, [], 'test', {
|
||||
disabledByGroup: { ops: ['netx__execManagedNe'] },
|
||||
})
|
||||
assert.equal(registered.length, 0)
|
||||
dispose2()
|
||||
|
||||
registered.length = 0
|
||||
const dispose3 = await registerGroupSkills(ctx, ['ops'], 'test', {
|
||||
disabledByGroup: { ops: ['netx__execManagedNe'] },
|
||||
lang: 'en',
|
||||
})
|
||||
assert.match(registered[0].content, /The following tools are disabled: netx__execManagedNe/)
|
||||
dispose3()
|
||||
})
|
||||
223
test/mcp-catalog.test.mjs
Normal file
223
test/mcp-catalog.test.mjs
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
/**
|
||||
* MCP catalog filtering: groups × disabledTools × readOnlyOnly.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import http from 'node:http'
|
||||
|
||||
import {
|
||||
filterCatalogTools,
|
||||
parseDisabledTools,
|
||||
resolveEnabledTools,
|
||||
disabledPublicNames,
|
||||
} from '../src/netx/mcp-catalog.ts'
|
||||
import { listMcpTools, callMcpTool, mcpEndpointUrl } from '../src/netx/mcp-remote.ts'
|
||||
import { refreshMcpCatalog, getMcpCatalogState, resetMcpCatalog } from '../src/netx/mcp-store.ts'
|
||||
import { DEFAULT_CAPABILITY_GROUPS } from '../src/netx/capability-groups.ts'
|
||||
|
||||
const SAMPLE = Object.freeze([
|
||||
{
|
||||
name: 'queryNmsAlarms',
|
||||
description: 'alarms',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
readOnlyHint: true,
|
||||
group: 'ops',
|
||||
},
|
||||
{
|
||||
name: 'execManagedNe',
|
||||
description: 'cli',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
readOnlyHint: false,
|
||||
group: 'ops',
|
||||
},
|
||||
{
|
||||
name: 'getTopologyTree',
|
||||
description: 'tree',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
readOnlyHint: true,
|
||||
group: 'topology',
|
||||
},
|
||||
{
|
||||
name: 'listBizMonitors',
|
||||
description: 'biz',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
readOnlyHint: true,
|
||||
group: 'bizMonitor',
|
||||
},
|
||||
{
|
||||
// Server later adds this under ops — must follow group default.
|
||||
name: 'brandNewOpsTool',
|
||||
description: 'new',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
readOnlyHint: true,
|
||||
group: 'ops',
|
||||
},
|
||||
])
|
||||
|
||||
test('parseDisabledTools accepts JSON and CSV', () => {
|
||||
assert.deepEqual(parseDisabledTools('["execManagedNe","x"]'), ['execManagedNe', 'x'])
|
||||
assert.deepEqual(parseDisabledTools('execManagedNe, queryNmsAlarms'), ['execManagedNe', 'queryNmsAlarms'])
|
||||
assert.deepEqual(parseDisabledTools('[]'), [])
|
||||
})
|
||||
|
||||
test('group off drops all tools in that group', () => {
|
||||
const enabled = filterCatalogTools(SAMPLE, ['ops', 'bizMonitor'], {})
|
||||
assert.deepEqual(enabled.map((t) => t.name).sort(), [
|
||||
'brandNewOpsTool',
|
||||
'execManagedNe',
|
||||
'listBizMonitors',
|
||||
'queryNmsAlarms',
|
||||
])
|
||||
})
|
||||
|
||||
test('disabledTools opts out one tool while group stays on', () => {
|
||||
const enabled = filterCatalogTools(SAMPLE, ['ops'], { disabledTools: ['execManagedNe'] })
|
||||
assert.deepEqual(enabled.map((t) => t.name).sort(), ['brandNewOpsTool', 'queryNmsAlarms'])
|
||||
})
|
||||
|
||||
test('readOnlyOnly drops writable tools', () => {
|
||||
const enabled = filterCatalogTools(SAMPLE, ['ops'], { readOnlyOnly: true })
|
||||
assert.deepEqual(enabled.map((t) => t.name).sort(), ['brandNewOpsTool', 'queryNmsAlarms'])
|
||||
})
|
||||
|
||||
test('new server tool follows group default (not listed in disabledTools)', () => {
|
||||
const enabled = resolveEnabledTools(SAMPLE, {
|
||||
plane: 'preset',
|
||||
groups: DEFAULT_CAPABILITY_GROUPS,
|
||||
disabledTools: ['execManagedNe'],
|
||||
})
|
||||
const names = enabled.map((t) => t.name)
|
||||
assert.ok(names.includes('brandNewOpsTool'))
|
||||
assert.ok(names.includes('queryNmsAlarms'))
|
||||
assert.ok(names.includes('listBizMonitors'))
|
||||
assert.equal(names.includes('execManagedNe'), false)
|
||||
assert.equal(names.includes('getTopologyTree'), false) // topology inPreset default off
|
||||
})
|
||||
|
||||
test('disabledPublicNames lists model-facing names for skill footnotes', () => {
|
||||
const names = disabledPublicNames(SAMPLE, ['ops'], ['execManagedNe'], true)
|
||||
assert.deepEqual(names, ['netx__execManagedNe'])
|
||||
})
|
||||
|
||||
test('mcpEndpointUrl appends /mcp', () => {
|
||||
assert.equal(mcpEndpointUrl('http://127.0.0.1:8890'), 'http://127.0.0.1:8890/mcp')
|
||||
assert.equal(mcpEndpointUrl('http://127.0.0.1:8890/'), 'http://127.0.0.1:8890/mcp')
|
||||
})
|
||||
|
||||
function startMockMcp(tools, { failList = false, token = 'secret' } = {}) {
|
||||
let callCount = 0
|
||||
const server = http.createServer(async (req, res) => {
|
||||
if (req.method !== 'POST') {
|
||||
res.writeHead(404)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
const chunks = []
|
||||
for await (const chunk of req) chunks.push(chunk)
|
||||
const body = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
||||
const auth = req.headers.authorization ?? ''
|
||||
if (token && auth !== `Bearer ${token}`) {
|
||||
res.writeHead(401)
|
||||
res.end('unauthorized')
|
||||
return
|
||||
}
|
||||
const reply = (result) => {
|
||||
res.writeHead(200, { 'content-type': 'application/json' })
|
||||
res.end(JSON.stringify({ jsonrpc: '2.0', id: body.id ?? 1, result }))
|
||||
}
|
||||
if (body.method === 'initialize') {
|
||||
reply({
|
||||
protocolVersion: '2025-03-26',
|
||||
capabilities: { tools: {} },
|
||||
serverInfo: { name: 'mock-netx', version: '0.0.0' },
|
||||
})
|
||||
return
|
||||
}
|
||||
if (body.method === 'notifications/initialized') {
|
||||
res.writeHead(202)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
if (body.method === 'tools/list') {
|
||||
if (failList) {
|
||||
res.writeHead(503)
|
||||
res.end('down')
|
||||
return
|
||||
}
|
||||
reply({
|
||||
tools: tools.map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
inputSchema: t.inputSchema,
|
||||
annotations: { readOnlyHint: t.readOnlyHint },
|
||||
_meta: { netx: { group: t.group } },
|
||||
})),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (body.method === 'tools/call') {
|
||||
callCount += 1
|
||||
reply({
|
||||
content: [{ type: 'text', text: JSON.stringify({ ok: true, name: body.params?.name }) }],
|
||||
isError: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
res.writeHead(400)
|
||||
res.end('unknown method')
|
||||
})
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const { port } = server.address()
|
||||
resolve({
|
||||
server,
|
||||
url: `http://127.0.0.1:${port}/mcp`,
|
||||
apiUrl: `http://127.0.0.1:${port}`,
|
||||
callCount: () => callCount,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
test('listMcpTools + callMcpTool against mock Streamable HTTP server', async () => {
|
||||
const mock = await startMockMcp(SAMPLE)
|
||||
try {
|
||||
const tools = await listMcpTools({ mcpUrl: mock.url, token: 'secret' })
|
||||
assert.equal(tools.length, SAMPLE.length)
|
||||
assert.equal(tools.find((t) => t.name === 'execManagedNe')?.readOnlyHint, false)
|
||||
assert.equal(tools.find((t) => t.name === 'queryNmsAlarms')?.group, 'ops')
|
||||
const result = await callMcpTool({ mcpUrl: mock.url, token: 'secret' }, 'queryNmsAlarms', {})
|
||||
assert.equal(result.isError, undefined)
|
||||
assert.ok(Array.isArray(result.content))
|
||||
assert.ok(mock.callCount() >= 1)
|
||||
} finally {
|
||||
mock.server.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('refreshMcpCatalog keeps cache on disconnect', async () => {
|
||||
resetMcpCatalog()
|
||||
const mock = await startMockMcp(SAMPLE)
|
||||
try {
|
||||
const ok = await refreshMcpCatalog({ apiUrl: mock.apiUrl, token: 'secret' })
|
||||
assert.equal(ok.phase, 'connected')
|
||||
assert.equal(ok.tools.length, SAMPLE.length)
|
||||
assert.equal(ok.fromCache, false)
|
||||
} finally {
|
||||
mock.server.close()
|
||||
}
|
||||
|
||||
const offline = await startMockMcp(SAMPLE, { failList: true })
|
||||
try {
|
||||
const next = await refreshMcpCatalog({ apiUrl: offline.apiUrl, token: 'secret' })
|
||||
assert.equal(next.phase, 'offline')
|
||||
assert.equal(next.fromCache, true)
|
||||
assert.equal(next.tools.length, SAMPLE.length)
|
||||
assert.ok(next.error)
|
||||
assert.equal(getMcpCatalogState().tools.length, SAMPLE.length)
|
||||
} finally {
|
||||
offline.server.close()
|
||||
resetMcpCatalog()
|
||||
}
|
||||
})
|
||||
116
test/mcp-test-catalog.mjs
Normal file
116
test/mcp-test-catalog.mjs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
/**
|
||||
* Seed the process-local MCP catalog for registration / publication tests.
|
||||
*/
|
||||
|
||||
import { publishNetxConnection } from '../src/netx/runtime.ts'
|
||||
|
||||
const STORE_KEY = Symbol.for('dsh-netxops.mcp-catalog-store')
|
||||
|
||||
/** Classic ops catalog size (matches historical TOOLS_BY_GROUP.ops.length). */
|
||||
export const OPS_TOOL_NAMES = Object.freeze([
|
||||
'queryNmsAlarms',
|
||||
'aggregateNmsAlarms',
|
||||
'runNmsDiagnostics',
|
||||
'queryNmsNeInventory',
|
||||
'getNmsNe',
|
||||
'queryNmsAlarmsRaw',
|
||||
'aggregateNmsAlarmsRaw',
|
||||
'listNmsAlarmFields',
|
||||
'sqlQueryNms',
|
||||
'listManagedNe',
|
||||
'getManagedNe',
|
||||
'execManagedNe',
|
||||
'getNeExecJob',
|
||||
'listCliTargets',
|
||||
'findTopologyPaths',
|
||||
])
|
||||
|
||||
export const TOPOLOGY_TOOL_NAMES = Object.freeze([
|
||||
'getTopologyTree',
|
||||
'getTopologyView',
|
||||
'createTopologyFolder',
|
||||
'addTopologyViewNodes',
|
||||
'removeTopologyViewNodes',
|
||||
'copyTopologyViewNodes',
|
||||
'updateTopologyViewPositions',
|
||||
'projectTopologyNeighbors',
|
||||
'queryTopologyFabricNodes',
|
||||
'classifyTopologyFabricNodes',
|
||||
'queryTopologyNeighborhood',
|
||||
'queryTopologyEdges',
|
||||
'layoutTopologyView',
|
||||
'suggestSinkHubs',
|
||||
'analyzeTopologyViewLayout',
|
||||
])
|
||||
|
||||
export const BIZ_MONITOR_TOOL_NAMES = Object.freeze([
|
||||
'listBizMonitors',
|
||||
'listBizMonitorBatches',
|
||||
'getBizMonitorContext',
|
||||
'getBizMonitorBoard',
|
||||
'listBizMonitorReds',
|
||||
'getBizMonitorDiffs',
|
||||
'getBizCollectBatch',
|
||||
'getBizCollectCommandRaw',
|
||||
])
|
||||
|
||||
const WRITE_OPS = new Set(['execManagedNe'])
|
||||
const WRITE_TOPOLOGY = new Set([
|
||||
'createTopologyFolder',
|
||||
'addTopologyViewNodes',
|
||||
'removeTopologyViewNodes',
|
||||
'copyTopologyViewNodes',
|
||||
'updateTopologyViewPositions',
|
||||
'projectTopologyNeighbors',
|
||||
'classifyTopologyFabricNodes',
|
||||
'layoutTopologyView',
|
||||
])
|
||||
|
||||
function descriptor(name, group) {
|
||||
const write = (group === 'ops' && WRITE_OPS.has(name))
|
||||
|| (group === 'topology' && WRITE_TOPOLOGY.has(name))
|
||||
return {
|
||||
name,
|
||||
description: name,
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
readOnlyHint: !write,
|
||||
group,
|
||||
}
|
||||
}
|
||||
|
||||
export function fullCatalog() {
|
||||
return [
|
||||
...OPS_TOOL_NAMES.map((name) => descriptor(name, 'ops')),
|
||||
...TOPOLOGY_TOOL_NAMES.map((name) => descriptor(name, 'topology')),
|
||||
...BIZ_MONITOR_TOOL_NAMES.map((name) => descriptor(name, 'bizMonitor')),
|
||||
]
|
||||
}
|
||||
|
||||
/** Install a frozen catalog into the shared Symbol.for store (no network). */
|
||||
export function seedMcpCatalog(tools = fullCatalog()) {
|
||||
const root = globalThis
|
||||
const store = {
|
||||
state: {
|
||||
phase: 'connected',
|
||||
mcpUrl: 'http://127.0.0.1:8890/mcp',
|
||||
tools: Object.freeze([...tools]),
|
||||
error: '',
|
||||
listedAt: new Date().toISOString(),
|
||||
fromCache: false,
|
||||
},
|
||||
listeners: new Set(),
|
||||
generation: 1,
|
||||
}
|
||||
root[STORE_KEY] = store
|
||||
return store.state
|
||||
}
|
||||
|
||||
export function clearMcpCatalogSeed() {
|
||||
delete globalThis[STORE_KEY]
|
||||
}
|
||||
|
||||
/** Convenience: seed catalog + publish a connection snapshot. */
|
||||
export function publishWithCatalog(connection) {
|
||||
seedMcpCatalog()
|
||||
publishNetxConnection(connection)
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import { registerGroupSkills } from '../src/netx/group-skills.ts'
|
|||
import { publishNetxConnection } from '../src/netx/runtime.ts'
|
||||
import { getToolMountStatuses, publishToolMountStatus, resetToolMountStatus } from '../src/netx/tool-mount-status.ts'
|
||||
import { asToolsMountView } from '../src/client/tools-status-view.ts'
|
||||
import { OPS_TOOL_NAMES, clearMcpCatalogSeed, seedMcpCatalog } from './mcp-test-catalog.mjs'
|
||||
|
||||
const log = { info() {}, warn() {}, error() {} }
|
||||
const pause = () => new Promise(resolve => setTimeout(resolve, 40))
|
||||
|
|
@ -37,7 +38,12 @@ function fakeContext(failAt = -1) {
|
|||
function diagnosticHome(t) {
|
||||
const previous = process.env.DSH_HOME
|
||||
process.env.DSH_HOME = mkdtempSync(join(tmpdir(), 'netxops-publication-'))
|
||||
t.after(() => { if (previous === undefined) delete process.env.DSH_HOME; else process.env.DSH_HOME = previous })
|
||||
seedMcpCatalog()
|
||||
t.after(() => {
|
||||
clearMcpCatalogSeed()
|
||||
if (previous === undefined) delete process.env.DSH_HOME
|
||||
else process.env.DSH_HOME = previous
|
||||
})
|
||||
}
|
||||
|
||||
test('selected presets are isolated and global publication is inherited once', () => {
|
||||
|
|
@ -67,7 +73,7 @@ test('failed tool registration rolls back every earlier tool', t => {
|
|||
const { ctx, tools } = fakeContext(3)
|
||||
assert.throws(() => registerNetxTools(ctx, connection(), { plane: 'preset' }), /registration refused/)
|
||||
assert.equal(tools.size, 0)
|
||||
assert.ok(getToolMountStatuses().some(row => row.error === 'registration refused'))
|
||||
assert.ok(getToolMountStatuses().some(row => String(row.error ?? '').includes('registration refused')))
|
||||
resetToolMountStatus()
|
||||
})
|
||||
|
||||
|
|
@ -87,7 +93,7 @@ test('turning off a mounted group revokes tools, KB context and pending skill lo
|
|||
publishNetxConnection(connection({ groupBizMonitorInPreset: false }))
|
||||
const mount = fakeContext()
|
||||
applyGroupToolsPlugin(mount.ctx, { name: 'lifecycle', mode: 'settings' })
|
||||
assert.equal(mount.tools.size, 15)
|
||||
assert.equal(mount.tools.size, OPS_TOOL_NAMES.length)
|
||||
assert.equal(mount.skills.has('kb-context'), true)
|
||||
publishNetxConnection(connection({ toolsEnabled: false }))
|
||||
await pause()
|
||||
|
|
@ -95,7 +101,7 @@ test('turning off a mounted group revokes tools, KB context and pending skill lo
|
|||
assert.equal(mount.skills.size, 0)
|
||||
publishNetxConnection(connection({ groupBizMonitorInPreset: false }))
|
||||
await pause()
|
||||
assert.equal(mount.tools.size, 15)
|
||||
assert.equal(mount.tools.size, OPS_TOOL_NAMES.length)
|
||||
assert.equal(mount.skills.has('netx-ops'), true)
|
||||
mount.dispose()
|
||||
await pause()
|
||||
|
|
@ -123,9 +129,9 @@ test('mount inventory survives cross-bundle publishes and reports all scopes', a
|
|||
entry.apply(mount.ctx)
|
||||
const statuses = getToolMountStatuses()
|
||||
assert.ok(statuses.some(row => row.id === 'host-test'))
|
||||
assert.ok(statuses.some(row => row.count === 15))
|
||||
assert.ok(statuses.some(row => row.count === OPS_TOOL_NAMES.length))
|
||||
const view = asToolsMountView({ mounts: statuses, connection: { toolsEnabled: true } })
|
||||
assert.equal(view.count, 15)
|
||||
assert.equal(view.count, OPS_TOOL_NAMES.length)
|
||||
mount.dispose()
|
||||
assert.equal(getToolMountStatuses().some(row => row.count > 0), false)
|
||||
resetToolMountStatus()
|
||||
|
|
|
|||
|
|
@ -13,6 +13,12 @@ import { npmRuntime } from '../scripts/npm-cli.mjs'
|
|||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
|
||||
const { node, npm } = npmRuntime()
|
||||
/** Inlined into the activate smoke so the packed Host can register without netx. */
|
||||
const OPS_TOOL_NAMES_INLINE = [
|
||||
'queryNmsAlarms', 'aggregateNmsAlarms', 'runNmsDiagnostics', 'queryNmsNeInventory', 'getNmsNe',
|
||||
'queryNmsAlarmsRaw', 'aggregateNmsAlarmsRaw', 'listNmsAlarmFields', 'sqlQueryNms',
|
||||
'listManagedNe', 'getManagedNe', 'execManagedNe', 'getNeExecJob', 'listCliTargets', 'findTopologyPaths',
|
||||
]
|
||||
const run = (args, cwd, env = {}) => {
|
||||
const result = spawnSync(node, args, { cwd, env: { ...process.env, ...env }, encoding: 'utf8', windowsHide: true, timeout: 120_000 })
|
||||
assert.equal(result.status, 0, `${args[0]} failed: ${result.error ?? ''}\n${result.stderr}\n${result.stdout}`)
|
||||
|
|
@ -87,6 +93,14 @@ test('the npm tarball installs offline and supplies every exported artifact and
|
|||
const root = new Context(), tools = new Map(), skills = new Map(), owners = [];
|
||||
class Tools extends Service { constructor(ctx) { super(ctx, 'tools') } register(entry) { assert.ok(!tools.has(entry.name)); tools.set(entry.name, entry); return () => tools.delete(entry.name) } }
|
||||
class Skills extends Service { constructor(ctx) { super(ctx, 'skills') } register(entry) { assert.ok(!skills.has(entry.name)); skills.set(entry.name, entry); return () => skills.delete(entry.name) } }
|
||||
// Seed a cached /mcp catalog so host registration does not need a live netx.
|
||||
const STORE = Symbol.for('dsh-netxops.mcp-catalog-store');
|
||||
const ops = ${JSON.stringify([...OPS_TOOL_NAMES_INLINE])};
|
||||
globalThis[STORE] = { generation: 1, listeners: new Set(), state: {
|
||||
phase: 'connected', mcpUrl: 'http://127.0.0.1:8890/mcp', error: '', listedAt: new Date().toISOString(), fromCache: false,
|
||||
tools: ops.map(name => ({ name, description: name, inputSchema: { type: 'object', properties: {} },
|
||||
readOnlyHint: name !== 'execManagedNe', group: 'ops' })),
|
||||
} };
|
||||
owners.push(await root.plugin(Tools), await root.plugin(Skills));
|
||||
const owner = await root.plugin(host, { installAgentPreset: false, alarmPushEnabled: false, groupOpsPublic: true }); owners.push(owner);
|
||||
assert.equal(owner.state, 2); assert.equal(tools.size, 15);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { join } from 'node:path'
|
|||
import { installPresetPublication } from '../src/netx/preset-publication.ts'
|
||||
import { publishNetxConnection } from '../src/netx/runtime.ts'
|
||||
import { capabilityGroupsFromSettings } from '../src/netx/capability-groups.ts'
|
||||
import { OPS_TOOL_NAMES, clearMcpCatalogSeed, seedMcpCatalog } from './mcp-test-catalog.mjs'
|
||||
|
||||
const layers = new Map()
|
||||
const skillLayers = new Map()
|
||||
|
|
@ -47,10 +48,12 @@ const wait = () => new Promise(resolve => setTimeout(resolve, 50))
|
|||
test('selected publication uses each exact session scope and cleans up on off, switch, and unload', async t => {
|
||||
const previous = process.env.DSH_HOME
|
||||
process.env.DSH_HOME = mkdtempSync(join(tmpdir(), 'netxops-selected-'))
|
||||
seedMcpCatalog()
|
||||
const ctx = new Context()
|
||||
const owners = []
|
||||
t.after(async () => {
|
||||
for (const owner of owners.reverse()) await owner.dispose()
|
||||
clearMcpCatalogSeed()
|
||||
if (previous === undefined) delete process.env.DSH_HOME; else process.env.DSH_HOME = previous
|
||||
layers.clear(); skillLayers.clear(); live = []
|
||||
})
|
||||
|
|
@ -78,7 +81,7 @@ test('selected publication uses each exact session scope and cleans up on off, s
|
|||
const owner = await ctx.plugin(installPresetPublication)
|
||||
owners.push(owner)
|
||||
await wait()
|
||||
assert.equal(layers.get(scopes[0].key)?.size, 15)
|
||||
assert.equal(layers.get(scopes[0].key)?.size, OPS_TOOL_NAMES.length)
|
||||
assert.equal(skillLayers.get(scopes[0].key)?.has('netx-ops'), true)
|
||||
assert.equal(layers.get(scopes[1].key)?.size ?? 0, 0)
|
||||
publish({ toolsEnabled: false })
|
||||
|
|
@ -87,7 +90,7 @@ test('selected publication uses each exact session scope and cleans up on off, s
|
|||
assert.equal(skillLayers.get(scopes[0].key)?.size, 0)
|
||||
publish()
|
||||
await wait()
|
||||
assert.equal(layers.get(scopes[0].key)?.size, 15)
|
||||
assert.equal(layers.get(scopes[0].key)?.size, OPS_TOOL_NAMES.length)
|
||||
scopes[0].agent.ctx = scopes[0].agent.ctx.extend({ presetId: 'review' })
|
||||
await ctx.serial('agent-preset/selected', 'standard', 'review')
|
||||
await wait()
|
||||
|
|
@ -95,7 +98,7 @@ test('selected publication uses each exact session scope and cleans up on off, s
|
|||
scopes[1].agent.ctx = scopes[1].agent.ctx.extend({ presetId: 'standard' })
|
||||
await ctx.serial('agent/created', { agent: scopes[1].agent })
|
||||
await wait()
|
||||
assert.equal(layers.get(scopes[1].key)?.size, 15)
|
||||
assert.equal(layers.get(scopes[1].key)?.size, OPS_TOOL_NAMES.length)
|
||||
await owner.dispose()
|
||||
await wait()
|
||||
assert.equal(layers.get(scopes[1].key)?.size, 0)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue