netxops/test/mcp-catalog.test.mjs
oliver fa82f530e9 feat(skills): pull group skills from netx /mcp resources, drop bundled copy
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:14:39 +08:00

259 lines
8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* MCP catalog filtering: groups × disabledTools × readOnlyOnly.
*/
import assert from 'node:assert/strict'
import test from 'node:test'
import http from 'node:http'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
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'
function withTempDshHome(t) {
const previous = process.env.DSH_HOME
const home = mkdtempSync(join(tmpdir(), 'netxops-mcp-catalog-'))
process.env.DSH_HOME = home
t.after(() => {
rmSync(home, { recursive: true, force: true })
if (previous === undefined) delete process.env.DSH_HOME
else process.env.DSH_HOME = previous
})
}
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('listMcpTools drops group=meta tools (getNetxSkill)', async () => {
const withMeta = [
...SAMPLE,
{
name: 'getNetxSkill',
description: 'read skill',
inputSchema: { type: 'object', properties: {} },
readOnlyHint: true,
group: 'meta',
},
]
const mock = await startMockMcp(withMeta)
try {
const tools = await listMcpTools({ mcpUrl: mock.url, token: 'secret' })
assert.equal(tools.length, SAMPLE.length)
assert.equal(tools.some((t) => t.name === 'getNetxSkill'), false)
} finally {
mock.server.close()
}
})
test('refreshMcpCatalog keeps cache on disconnect', async (t) => {
withTempDshHome(t)
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()
}
})