netxops/test/mcp-catalog.test.mjs
oliver 2ef3a43c63 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>
2026-10-16 22:08:22 +08:00

223 lines
7 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 {
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()
}
})