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:
oliver 2026-10-16 23:14:39 +08:00
parent 277a61894d
commit fa82f530e9
31 changed files with 2074 additions and 2027 deletions

View file

@ -54,7 +54,7 @@ export const DEFAULT_CAPABILITY_GROUPS: NetxCapabilityGroups = Object.freeze({
* `_meta.netx.group`. See `mcp-catalog.ts` / `filterCatalogTools`.
*/
/** Skill directory name under skills root / `presets/netxops/skills/<group>/`. */
/** Skill directory name under the MCP skills cache (`netxops-mcp-skills/<key>/<group>/`). */
export const SKILL_DIR_BY_GROUP: Readonly<Record<NetxCapabilityGroupId, string>> = Object.freeze({
ops: 'ops',
topology: 'topology',

View file

@ -1,17 +1,18 @@
/**
* Register Ops playbook skills for enabled capability groups from disk.
* Register Ops playbook skills for enabled capability groups from the
* on-disk MCP skills cache (`skills-cache.ts`).
*/
import { existsSync } from 'node:fs'
import { readdir, readFile, stat } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Context } from '@deepseek-ai/cordis'
import {
CAPABILITY_GROUP_IDS,
SKILL_DIR_BY_GROUP,
type NetxCapabilityGroupId,
} from './capability-groups.ts'
import { getNetxConnection } from './runtime.ts'
import { skillsCacheDir } from './skills-cache.ts'
interface ParsedSkill {
name: string
@ -23,39 +24,6 @@ interface ParsedSkill {
export type { ParsedSkill }
/**
* Package `presets/netxops/skills` root, or a shared netx skills checkout.
*
* Resolution order:
* 1. `NETX_SKILLS_ROOT` (canonical `netx/skills`)
* 2. Sibling `../netx/skills` when developing next to the netx repo
* 3. Bundled `presets/netxops/skills` inside this package
*/
export function opsSkillsRoot(): string {
const envRoot = process.env.NETX_SKILLS_ROOT?.trim()
if (envRoot && existsSync(envRoot)) return envRoot
const here = dirname(fileURLToPath(import.meta.url))
const checkout = [join(here, '..', '.git'), join(here, '..', '..', '.git')].some(existsSync)
const siblingCandidates = [
// src/netx → ../../../netx/skills ; lib → ../../netx/skills
join(here, '..', '..', '..', 'netx', 'skills'),
join(here, '..', '..', 'netx', 'skills'),
]
for (const candidate of checkout ? siblingCandidates : []) {
if (existsSync(candidate)) return candidate
}
const packaged = [
join(here, '..', 'presets', 'netxops', 'skills'),
join(here, '..', '..', 'presets', 'netxops', 'skills'),
]
for (const candidate of packaged) {
if (existsSync(candidate)) return candidate
}
return packaged[0]
}
function parseFrontmatter(raw: string): { data: Record<string, unknown>; body: string } | undefined {
// Windows CRLF (and mixed) checkouts break `$`-anchored YAML lines: a trailing
// `\r` is a JS line terminator, so `.` and `$` both refuse it and description
@ -200,6 +168,12 @@ export interface RegisterGroupSkillsOptions {
*/
disabledByGroup?: DisabledToolsByGroup
lang?: string
/**
* Cache root containing `ops` / `topology` / `biz-monitor`.
* Tests inject this; production resolves `$DSH_HOME/netxops-mcp-skills/<key>`
* from the live connection apiUrl.
*/
skillsRoot?: string
}
function disabledFootnote(names: readonly string[], lang?: string): string {
@ -211,6 +185,15 @@ function disabledFootnote(names: readonly string[], lang?: string): string {
: `\n\n## 已禁用工具\n以下工具已禁用:${list},不要调用。\n`
}
function resolveSkillsRoot(options: RegisterGroupSkillsOptions): string {
if (typeof options.skillsRoot === 'string' && options.skillsRoot.trim()) {
return options.skillsRoot.trim()
}
const connection = getNetxConnection()
if (!connection?.apiUrl?.trim()) return ''
return skillsCacheDir(connection.apiUrl)
}
/**
* Register playbook skills for the given capability groups into `ctx.skills`.
* Groups that are off are skipped entirely (no skill injection).
@ -243,10 +226,16 @@ export async function registerGroupSkills(
return () => {}
}
const root = opsSkillsRoot()
const root = resolveSkillsRoot(options)
const disposers: Array<() => void> = []
const enabled = new Set(groupIds)
const log = (ctx as { logger?: { warn: (msg: string, ...args: unknown[]) => void } }).logger
if (!root) {
if (enabled.size > 0) {
log?.warn?.('%s: no MCP skills cache (missing apiUrl)', providerLabel)
}
return () => {}
}
try {
for (const groupId of CAPABILITY_GROUP_IDS) {
if (!enabled.has(groupId)) continue

View file

@ -10,9 +10,11 @@
import type { NetxCapabilityGroupId } from './capability-groups.ts'
import { CAPABILITY_GROUP_IDS } from './capability-groups.ts'
import type { NetxMcpToolDescriptor } from './mcp-types.ts'
import type { CachedSkillFile } from './skills-cache.ts'
const PROTOCOL_VERSION = '2025-03-26'
const CLIENT_INFO = { name: 'dsh-netxops', version: '0.1.71' }
const SKILLS_URI_PREFIX = 'netx://skills/'
export interface McpRemoteOptions {
/** Absolute MCP endpoint, e.g. `http://127.0.0.1:8890/mcp`. */
@ -74,10 +76,48 @@ function parseTool(raw: unknown): NetxMcpToolDescriptor | null {
const netx = meta.netx && typeof meta.netx === 'object' && !Array.isArray(meta.netx)
? meta.netx as Record<string, unknown>
: {}
// Plugin mounts skills itself — never register getNetxSkill (group=meta).
if (netx.group === 'meta') return null
const group = isGroupId(netx.group) ? netx.group : undefined
return { name, description, inputSchema, readOnlyHint, group }
}
function readMetaNetx(row: Record<string, unknown>): Record<string, unknown> {
const meta = (row._meta ?? row.meta)
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return {}
const netx = (meta as Record<string, unknown>).netx
if (!netx || typeof netx !== 'object' || Array.isArray(netx)) return {}
return netx as Record<string, unknown>
}
function parseSkillResource(
resource: unknown,
text: string,
): CachedSkillFile | null {
if (!resource || typeof resource !== 'object' || Array.isArray(resource)) return null
const row = resource as Record<string, unknown>
const uri = typeof row.uri === 'string' ? row.uri.trim() : ''
const netx = readMetaNetx(row)
const group = isGroupId(netx.group) ? netx.group : undefined
const skill = typeof netx.skill === 'string' ? netx.skill.trim() : ''
if (!group || !skill) return null
let relPath = typeof row.name === 'string' ? row.name.trim().replace(/\\/g, '/') : ''
if (!relPath && uri.startsWith(SKILLS_URI_PREFIX)) {
const rest = uri.slice(SKILLS_URI_PREFIX.length)
const parts = rest.split('/')
// <groupDir>/<skill>/<rel...>
if (parts.length >= 3) relPath = parts.slice(2).join('/')
}
if (!relPath || relPath.includes('..')) return null
return { group, skill, relPath, text }
}
function resourcesCapability(capabilities: unknown): boolean {
if (!capabilities || typeof capabilities !== 'object' || Array.isArray(capabilities)) return false
return 'resources' in (capabilities as Record<string, unknown>)
}
/**
* Parse an MCP HTTP body: application/json, or SSE `data:` frames
* (Streamable HTTP when not using json_response).
@ -197,25 +237,79 @@ async function postRpc(
return asRpcResult(parseMcpResponseBody(responseText))
}
export interface McpCatalogFetchResult {
tools: NetxMcpToolDescriptor[]
/**
* Set when resources were listed and read successfully (may be empty).
* Omitted when the server has no resources capability or the pull failed —
* callers should treat that as “no skills” while still keeping tools.
*/
skills?: CachedSkillFile[]
skillsError?: string
}
/**
* initialize → notifications/initialized → tools/list
* (+ resources/list + resources/read when the server advertises resources).
*/
export async function fetchMcpCatalog(options: McpRemoteOptions): Promise<McpCatalogFetchResult> {
if (!options.mcpUrl) throw new McpRemoteError('mcp url is empty')
const init = await postRpc(options, 'initialize', {
protocolVersion: PROTOCOL_VERSION,
capabilities: {},
clientInfo: CLIENT_INFO,
}) as { capabilities?: unknown }
await postRpc(options, 'notifications/initialized', {}, true)
const toolsResult = await postRpc(options, 'tools/list', {}) as { tools?: unknown }
const toolsRaw = Array.isArray(toolsResult?.tools) ? toolsResult.tools : []
const tools: NetxMcpToolDescriptor[] = []
for (const item of toolsRaw) {
const parsed = parseTool(item)
if (parsed) tools.push(parsed)
}
if (!resourcesCapability(init?.capabilities)) {
// No resources capability → the server publishes no skills (not an error).
return { tools, skills: [] }
}
try {
const listed = await postRpc(options, 'resources/list', {}) as { resources?: unknown }
const resources = Array.isArray(listed?.resources) ? listed.resources : []
const skills: CachedSkillFile[] = []
for (const resource of resources) {
if (!resource || typeof resource !== 'object' || Array.isArray(resource)) continue
const uri = typeof (resource as { uri?: unknown }).uri === 'string'
? (resource as { uri: string }).uri.trim()
: ''
if (!uri.startsWith(SKILLS_URI_PREFIX)) continue
const read = await postRpc(options, 'resources/read', { uri }) as { contents?: unknown }
const contents = Array.isArray(read?.contents) ? read.contents : []
let text = ''
for (const block of contents) {
if (!block || typeof block !== 'object' || Array.isArray(block)) continue
const row = block as { text?: unknown }
if (typeof row.text === 'string') {
text = row.text
break
}
}
const parsed = parseSkillResource(resource, text)
if (parsed) skills.push(parsed)
}
return { tools, skills }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { tools, skillsError: message }
}
}
/**
* initialize → notifications/initialized → tools/list.
*/
export async function listMcpTools(options: McpRemoteOptions): Promise<NetxMcpToolDescriptor[]> {
if (!options.mcpUrl) throw new McpRemoteError('mcp url is empty')
await postRpc(options, 'initialize', {
protocolVersion: PROTOCOL_VERSION,
capabilities: {},
clientInfo: CLIENT_INFO,
})
await postRpc(options, 'notifications/initialized', {}, true)
const result = await postRpc(options, 'tools/list', {}) as { tools?: unknown }
const tools = Array.isArray(result?.tools) ? result.tools : []
const out: NetxMcpToolDescriptor[] = []
for (const item of tools) {
const parsed = parseTool(item)
if (parsed) out.push(parsed)
}
return out
const { tools } = await fetchMcpCatalog(options)
return tools
}
/**

View file

@ -2,10 +2,12 @@
* Process-local MCP catalog cache shared by host + preset tool mounts.
*
* Uses Symbol.for on globalThis so host / agent-tools bundles share one store.
* Skill files are mirrored under `$DSH_HOME/netxops-mcp-skills/<key>/`.
*/
import { listMcpTools, mcpEndpointUrl, McpRemoteError } from './mcp-remote.ts'
import { fetchMcpCatalog, mcpEndpointUrl, McpRemoteError } from './mcp-remote.ts'
import type { NetxMcpCatalogState, NetxMcpToolDescriptor } from './mcp-types.ts'
import { replaceSkillsCache } from './skills-cache.ts'
type Listener = () => void
@ -77,8 +79,10 @@ export interface RefreshMcpCatalogInput {
}
/**
* Pull tools/list. On failure keep the previous tool list (fromCache) and
* surface the error for the Plugins card — never throw to the Host apply path.
* Pull tools/list (+ skill resources). On failure keep the previous tool list
* (fromCache) and leave the on-disk skill cache untouched. When tools succeed:
* a server without resources → clear that address's skill cache; a failed
* resources call → keep the last good cache.
*/
export async function refreshMcpCatalog(input: RefreshMcpCatalogInput): Promise<NetxMcpCatalogState> {
const mcpUrl = mcpEndpointUrl(input.apiUrl)
@ -109,7 +113,7 @@ export async function refreshMcpCatalog(input: RefreshMcpCatalogInput): Promise<
})
try {
const tools = await listMcpTools({
const fetched = await fetchMcpCatalog({
mcpUrl,
token: input.token,
lang: input.lang,
@ -118,10 +122,19 @@ export async function refreshMcpCatalog(input: RefreshMcpCatalogInput): Promise<
fetchImpl: input.fetchImpl,
})
if (gen !== store().generation) return store().state
if (fetched.skills !== undefined) {
// Includes "no resources capability" (empty list) → wipe stale files.
replaceSkillsCache(input.apiUrl, fetched.skills)
} else {
// Transient resources failure: keep the last good skill cache.
console.warn('netxops: mcp skills pull failed, keeping cache — %s', fetched.skillsError)
}
const next: NetxMcpCatalogState = {
phase: 'connected',
mcpUrl,
tools: Object.freeze([...tools]),
tools: Object.freeze([...fetched.tools]),
error: '',
listedAt: new Date().toISOString(),
fromCache: false,
@ -135,6 +148,7 @@ export async function refreshMcpCatalog(input: RefreshMcpCatalogInput): Promise<
: error instanceof Error
? error.message
: String(error)
// Offline: keep prior tools in memory and leave disk skill cache as-is.
const next: NetxMcpCatalogState = {
phase: previous.tools.length > 0 ? 'offline' : 'error',
mcpUrl,

107
src/netx/skills-cache.ts Normal file
View file

@ -0,0 +1,107 @@
/**
* On-disk cache of netx MCP skill resources, keyed by API URL.
*
* Layout: `$DSH_HOME/netxops-mcp-skills/<key>/<groupDir>/<skill>/<relPath>`
*/
import { createHash } from 'node:crypto'
import {
existsSync,
mkdirSync,
readdirSync,
rmSync,
writeFileSync,
} from 'node:fs'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
import {
SKILL_DIR_BY_GROUP,
type NetxCapabilityGroupId,
} from './capability-groups.ts'
import { normalizeApiUrl } from './http.ts'
export interface CachedSkillFile {
group: NetxCapabilityGroupId
skill: string
/** Posix-relative path inside the skill directory (e.g. `SKILL.md`). */
relPath: string
text: string
}
function dshHome(): string {
const fromEnv = process.env.DSH_HOME?.trim()
return fromEnv || join(homedir(), '.dsh')
}
/** Normalize apiUrl the same way connection settings do. */
export function normalizeSkillsApiUrl(apiUrl: string): string {
const normalized = normalizeApiUrl(apiUrl)
return normalized.ok ? normalized.url : apiUrl.trim().replace(/\/$/, '')
}
/** Stable short key so each netx address gets its own cache tree. */
export function skillsCacheKey(apiUrl: string): string {
const base = normalizeSkillsApiUrl(apiUrl)
return createHash('sha256').update(base).digest('hex').slice(0, 16)
}
/** Absolute cache root for one netx API URL (may not exist yet). */
export function skillsCacheDir(apiUrl: string): string {
return join(dshHome(), 'netxops-mcp-skills', skillsCacheKey(apiUrl))
}
function isSafeSegment(value: string): boolean {
return Boolean(value)
&& !value.includes('..')
&& !value.includes('/')
&& !value.includes('\\')
&& !value.includes('\0')
}
function isSafeRelPath(relPath: string): boolean {
if (!relPath || relPath.startsWith('/') || relPath.includes('\0')) return false
const parts = relPath.replace(/\\/g, '/').split('/')
return parts.every((part) => part.length > 0 && part !== '.' && part !== '..')
}
/**
* Replace the cache for `apiUrl` with `files` (empty → wipe).
* Call only after a successful tools/list when skills were resolved
* (including “no resources / empty list”).
*/
export function replaceSkillsCache(apiUrl: string, files: readonly CachedSkillFile[]): string {
const root = skillsCacheDir(apiUrl)
rmSync(root, { recursive: true, force: true })
mkdirSync(root, { recursive: true })
let written = 0
for (const file of files) {
const groupDir = SKILL_DIR_BY_GROUP[file.group]
if (!groupDir || !isSafeSegment(file.skill) || !isSafeRelPath(file.relPath)) continue
const rel = file.relPath.replace(/\\/g, '/')
const dest = join(root, groupDir, file.skill, ...rel.split('/'))
mkdirSync(dirname(dest), { recursive: true })
const text = file.text.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
writeFileSync(dest, text, 'utf8')
written += 1
}
const normalized = normalizeSkillsApiUrl(apiUrl)
const meta = {
apiUrl: normalized,
mcpUrl: normalized ? `${normalized}/mcp` : '',
listedAt: new Date().toISOString(),
fileCount: written,
}
writeFileSync(join(root, '.meta.json'), `${JSON.stringify(meta, null, 2)}\n`, 'utf8')
return root
}
/** True when a previous successful skill pull left a cache directory. */
export function hasSkillsCache(apiUrl: string): boolean {
const root = skillsCacheDir(apiUrl)
if (!existsSync(root)) return false
try {
return readdirSync(root).some((name) => name !== '.meta.json')
} catch {
return false
}
}