mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-20 11:10:44 +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
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
107
src/netx/skills-cache.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue