mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-20 21:53:28 +08:00
feat(skills): pull group skills from netx /mcp resources, drop bundled copy
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
277a61894d
commit
fa82f530e9
31 changed files with 2074 additions and 2027 deletions
|
|
@ -1,32 +1,350 @@
|
|||
/**
|
||||
* Group skill loader: one capability group ↔ one skill dir.
|
||||
* Group skills: MCP resources → disk cache → registerGroupSkills.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { readdirSync, readFileSync, existsSync } from 'node:fs'
|
||||
import http from 'node:http'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { opsSkillsRoot } from '../src/netx/group-skills.ts'
|
||||
import { SKILL_DIR_BY_GROUP } from '../src/netx/capability-groups.ts'
|
||||
import { registerGroupSkills } from '../src/netx/group-skills.ts'
|
||||
import { refreshMcpCatalog, resetMcpCatalog } from '../src/netx/mcp-store.ts'
|
||||
import { publishNetxConnection } from '../src/netx/runtime.ts'
|
||||
import {
|
||||
hasSkillsCache,
|
||||
replaceSkillsCache,
|
||||
skillsCacheDir,
|
||||
} from '../src/netx/skills-cache.ts'
|
||||
|
||||
test('opsSkillsRoot points at ops / topology playbook dirs', () => {
|
||||
const root = opsSkillsRoot()
|
||||
assert.ok(existsSync(root), root)
|
||||
for (const dir of Object.values(SKILL_DIR_BY_GROUP)) {
|
||||
assert.ok(existsSync(join(root, dir)), join(root, dir))
|
||||
const API = 'http://127.0.0.1:18890'
|
||||
const OPS_SKILL = `---
|
||||
name: netx-ops
|
||||
description: Ops playbook for tests
|
||||
---
|
||||
# Ops
|
||||
|
||||
Use queryNmsAlarms.
|
||||
`
|
||||
|
||||
const OPS_REF = '# Reference\n\nExtra detail.\n'
|
||||
const TOPO_SKILL = `---
|
||||
name: netx-topology
|
||||
description: Topology playbook
|
||||
---
|
||||
# Topology
|
||||
`
|
||||
|
||||
function skillResources(files) {
|
||||
return files.map((f) => ({
|
||||
uri: `netx://skills/${f.groupDir}/${f.skill}/${f.relPath}`,
|
||||
name: f.relPath,
|
||||
mimeType: 'text/markdown',
|
||||
...(f.relPath === 'SKILL.md' ? { description: f.description || f.skill } : {}),
|
||||
_meta: {
|
||||
netx: {
|
||||
group: f.group,
|
||||
skill: f.skill,
|
||||
entry: f.relPath === 'SKILL.md',
|
||||
},
|
||||
},
|
||||
text: f.text,
|
||||
}))
|
||||
}
|
||||
|
||||
function startMockMcp({
|
||||
tools = [{ name: 'queryNmsAlarms', description: 'alarms', readOnlyHint: true, group: 'ops' }],
|
||||
resources = null,
|
||||
failResources = false,
|
||||
token = 'secret',
|
||||
} = {}) {
|
||||
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: resources === null && !failResources
|
||||
? { tools: {} }
|
||||
: { tools: {}, resources: {} },
|
||||
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') {
|
||||
reply({
|
||||
tools: tools.map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
annotations: { readOnlyHint: t.readOnlyHint !== false },
|
||||
_meta: { netx: { group: t.group } },
|
||||
})),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (body.method === 'resources/list') {
|
||||
if (failResources) {
|
||||
res.writeHead(503)
|
||||
res.end('resources down')
|
||||
return
|
||||
}
|
||||
const list = Array.isArray(resources) ? resources : []
|
||||
reply({
|
||||
resources: list.map(({ text: _t, ...rest }) => rest),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (body.method === 'resources/read') {
|
||||
if (failResources) {
|
||||
res.writeHead(503)
|
||||
res.end('resources down')
|
||||
return
|
||||
}
|
||||
const list = Array.isArray(resources) ? resources : []
|
||||
const hit = list.find((r) => r.uri === body.params?.uri)
|
||||
if (!hit) {
|
||||
res.writeHead(400)
|
||||
res.end('missing')
|
||||
return
|
||||
}
|
||||
reply({
|
||||
contents: [{ uri: hit.uri, mimeType: 'text/markdown', text: hit.text }],
|
||||
})
|
||||
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,
|
||||
apiUrl: `http://127.0.0.1:${port}`,
|
||||
url: `http://127.0.0.1:${port}/mcp`,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function fakeSkillsCtx() {
|
||||
/** @type {Array<{ name: string, content: string, path?: string, resourceBase?: { path: string } }>} */
|
||||
const registered = []
|
||||
const ctx = {
|
||||
skills: {
|
||||
register(skill) {
|
||||
registered.push(skill)
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
logger: { warn() {}, info() {} },
|
||||
}
|
||||
return { ctx, registered }
|
||||
}
|
||||
|
||||
function withTempHome(t) {
|
||||
const previous = process.env.DSH_HOME
|
||||
const home = mkdtempSync(join(tmpdir(), 'netxops-skills-'))
|
||||
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
|
||||
resetMcpCatalog()
|
||||
})
|
||||
return home
|
||||
}
|
||||
|
||||
const FULL_RESOURCES = skillResources([
|
||||
{
|
||||
group: 'ops',
|
||||
groupDir: 'ops',
|
||||
skill: 'netx-ops',
|
||||
relPath: 'SKILL.md',
|
||||
text: OPS_SKILL,
|
||||
description: 'Ops playbook for tests',
|
||||
},
|
||||
{
|
||||
group: 'ops',
|
||||
groupDir: 'ops',
|
||||
skill: 'netx-ops',
|
||||
relPath: 'reference.md',
|
||||
text: OPS_REF,
|
||||
},
|
||||
{
|
||||
group: 'topology',
|
||||
groupDir: 'topology',
|
||||
skill: 'netx-topology',
|
||||
relPath: 'SKILL.md',
|
||||
text: TOPO_SKILL,
|
||||
description: 'Topology playbook',
|
||||
},
|
||||
])
|
||||
|
||||
test('refresh + register: normal skill with reference.md', async (t) => {
|
||||
withTempHome(t)
|
||||
const mock = await startMockMcp({ resources: FULL_RESOURCES })
|
||||
t.after(() => mock.server.close())
|
||||
publishNetxConnection({
|
||||
apiUrl: mock.apiUrl,
|
||||
token: 'secret',
|
||||
lang: 'zh',
|
||||
toolCallTimeoutMs: 5_000,
|
||||
toolsEnabled: true,
|
||||
})
|
||||
const catalog = await refreshMcpCatalog({ apiUrl: mock.apiUrl, token: 'secret' })
|
||||
assert.equal(catalog.phase, 'connected')
|
||||
assert.equal(catalog.tools.length, 1)
|
||||
const cache = skillsCacheDir(mock.apiUrl)
|
||||
assert.ok(existsSync(join(cache, 'ops', 'netx-ops', 'SKILL.md')))
|
||||
assert.ok(existsSync(join(cache, 'ops', 'netx-ops', 'reference.md')))
|
||||
assert.equal(readFileSync(join(cache, 'ops', 'netx-ops', 'reference.md'), 'utf8'), OPS_REF)
|
||||
|
||||
const { ctx, registered } = fakeSkillsCtx()
|
||||
const dispose = await registerGroupSkills(ctx, ['ops'], 'test')
|
||||
assert.equal(registered.length, 1)
|
||||
assert.equal(registered[0].name, 'netx-ops')
|
||||
assert.match(registered[0].content, /queryNmsAlarms/)
|
||||
assert.equal(registered[0].resourceBase?.path, join(cache, 'ops', 'netx-ops'))
|
||||
assert.ok(registered[0].path?.endsWith('SKILL.md'))
|
||||
dispose()
|
||||
})
|
||||
|
||||
test('each group dir has a playbook with name + description', () => {
|
||||
const root = opsSkillsRoot()
|
||||
for (const group of Object.keys(SKILL_DIR_BY_GROUP)) {
|
||||
const groupDir = join(root, SKILL_DIR_BY_GROUP[group])
|
||||
const bundles = readdirSync(groupDir).filter((name) => existsSync(join(groupDir, name, 'SKILL.md')))
|
||||
assert.ok(bundles.length >= 1, group)
|
||||
const raw = readFileSync(join(groupDir, bundles[0], 'SKILL.md'), 'utf8')
|
||||
assert.match(raw, /^---\r?\n/)
|
||||
assert.match(raw, /\nname:\s+\S+/)
|
||||
assert.match(raw, /\ndescription:\s/)
|
||||
}
|
||||
test('server without resources capability → no skills, tools still ok', async (t) => {
|
||||
withTempHome(t)
|
||||
// Pre-seed stale cache that must be cleared on successful tools refresh.
|
||||
replaceSkillsCache(API, [{
|
||||
group: 'ops',
|
||||
skill: 'netx-ops',
|
||||
relPath: 'SKILL.md',
|
||||
text: OPS_SKILL,
|
||||
}])
|
||||
assert.equal(hasSkillsCache(API), true)
|
||||
|
||||
const mock = await startMockMcp({ resources: null })
|
||||
t.after(() => mock.server.close())
|
||||
const catalog = await refreshMcpCatalog({ apiUrl: mock.apiUrl, token: 'secret' })
|
||||
assert.equal(catalog.phase, 'connected')
|
||||
assert.equal(catalog.tools.length, 1)
|
||||
assert.equal(hasSkillsCache(mock.apiUrl), false)
|
||||
|
||||
publishNetxConnection({
|
||||
apiUrl: mock.apiUrl, token: 'secret', lang: 'zh', toolCallTimeoutMs: 5_000,
|
||||
})
|
||||
const { ctx, registered } = fakeSkillsCtx()
|
||||
await registerGroupSkills(ctx, ['ops'], 'test')
|
||||
assert.equal(registered.length, 0)
|
||||
})
|
||||
|
||||
test('resources call failure keeps prior skill cache, tools still ok', async (t) => {
|
||||
withTempHome(t)
|
||||
const mock = await startMockMcp({ failResources: true })
|
||||
t.after(() => mock.server.close())
|
||||
replaceSkillsCache(mock.apiUrl, [{
|
||||
group: 'ops',
|
||||
skill: 'netx-ops',
|
||||
relPath: 'SKILL.md',
|
||||
text: OPS_SKILL,
|
||||
}])
|
||||
const catalog = await refreshMcpCatalog({ apiUrl: mock.apiUrl, token: 'secret' })
|
||||
assert.equal(catalog.phase, 'connected')
|
||||
assert.equal(catalog.tools.length, 1)
|
||||
assert.equal(hasSkillsCache(mock.apiUrl), true)
|
||||
})
|
||||
|
||||
test('offline refresh keeps prior skill cache', async (t) => {
|
||||
withTempHome(t)
|
||||
const mock = await startMockMcp({ resources: FULL_RESOURCES })
|
||||
const ok = await refreshMcpCatalog({ apiUrl: mock.apiUrl, token: 'secret' })
|
||||
assert.equal(ok.phase, 'connected')
|
||||
mock.server.close()
|
||||
|
||||
const offline = await startMockMcp({ failResources: true, tools: [] })
|
||||
// Fail tools/list entirely
|
||||
offline.server.close()
|
||||
const down = http.createServer((_req, res) => {
|
||||
res.writeHead(503)
|
||||
res.end('down')
|
||||
})
|
||||
await new Promise((resolve) => down.listen(0, '127.0.0.1', resolve))
|
||||
const { port } = down.address()
|
||||
t.after(() => down.close())
|
||||
// Same apiUrl as successful pull so cache key matches — use mock.apiUrl host
|
||||
// but port changed; instead keep using the first mock's cache by refreshing
|
||||
// the same apiUrl against a dead server.
|
||||
const deadUrl = mock.apiUrl
|
||||
const next = await refreshMcpCatalog({ apiUrl: deadUrl, token: 'secret', timeoutMs: 500 })
|
||||
assert.ok(next.phase === 'offline' || next.phase === 'error')
|
||||
assert.equal(hasSkillsCache(deadUrl), true)
|
||||
publishNetxConnection({
|
||||
apiUrl: deadUrl, token: 'secret', lang: 'zh', toolCallTimeoutMs: 5_000,
|
||||
})
|
||||
const { ctx, registered } = fakeSkillsCtx()
|
||||
await registerGroupSkills(ctx, ['ops'], 'test')
|
||||
assert.equal(registered[0]?.name, 'netx-ops')
|
||||
})
|
||||
|
||||
test('refresh clears deleted skills from cache', async (t) => {
|
||||
withTempHome(t)
|
||||
const slim = skillResources([
|
||||
{
|
||||
group: 'ops',
|
||||
groupDir: 'ops',
|
||||
skill: 'netx-ops',
|
||||
relPath: 'SKILL.md',
|
||||
text: OPS_SKILL,
|
||||
description: 'Ops playbook for tests',
|
||||
},
|
||||
])
|
||||
const mock = await startMockMcp({ resources: slim })
|
||||
t.after(() => mock.server.close())
|
||||
replaceSkillsCache(mock.apiUrl, [
|
||||
{ group: 'ops', skill: 'netx-ops', relPath: 'SKILL.md', text: OPS_SKILL },
|
||||
{ group: 'topology', skill: 'netx-topology', relPath: 'SKILL.md', text: TOPO_SKILL },
|
||||
])
|
||||
assert.ok(existsSync(join(skillsCacheDir(mock.apiUrl), 'topology', 'netx-topology', 'SKILL.md')))
|
||||
await refreshMcpCatalog({ apiUrl: mock.apiUrl, token: 'secret' })
|
||||
assert.ok(existsSync(join(skillsCacheDir(mock.apiUrl), 'ops', 'netx-ops', 'SKILL.md')))
|
||||
assert.equal(existsSync(join(skillsCacheDir(mock.apiUrl), 'topology', 'netx-topology', 'SKILL.md')), false)
|
||||
})
|
||||
|
||||
test('group off → skill not registered', async (t) => {
|
||||
withTempHome(t)
|
||||
replaceSkillsCache(API, [
|
||||
{ group: 'ops', skill: 'netx-ops', relPath: 'SKILL.md', text: OPS_SKILL },
|
||||
{ group: 'topology', skill: 'netx-topology', relPath: 'SKILL.md', text: TOPO_SKILL },
|
||||
])
|
||||
publishNetxConnection({
|
||||
apiUrl: API, token: 'secret', lang: 'zh', toolCallTimeoutMs: 5_000,
|
||||
})
|
||||
const { ctx, registered } = fakeSkillsCtx()
|
||||
await registerGroupSkills(ctx, ['topology'], 'test')
|
||||
assert.equal(registered.length, 1)
|
||||
assert.equal(registered[0].name, 'netx-topology')
|
||||
registered.length = 0
|
||||
await registerGroupSkills(ctx, [], 'test')
|
||||
assert.equal(registered.length, 0)
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue