Call netx REST from native netx__* tools.

Drop the local python -m netx_mcp / mcp-client path so terminal users only need API URL + token.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-04 23:38:24 +08:00
parent 5179eb74b1
commit ed219842d3
22 changed files with 1431 additions and 179 deletions

View file

@ -1,5 +1,5 @@
/**
* Netx Ops Plugins settings card — apiUrl / lang / python + credential token.
* Netx Ops Plugins settings card — apiUrl / lang + credential token.
*/
import { useEffect, useRef, useState } from 'react'
@ -147,18 +147,6 @@ export function NetxopsCard(props: NetxopsCardProps) {
onEdit={(text) => { props.edit('lang', text) }}
onReset={() => { props.resetField('lang') }}
/>
<ValueField
id="netxops-python"
label={t('pythonCommand')}
hint={t('pythonCommandHint')}
field={state.pythonCommand}
overriddenLabel={t('overridden')}
resetLabel={t('reset')}
invalidLabel={t('invalid')}
disabled={disabled}
onEdit={(text) => { props.edit('pythonCommand', text) }}
onReset={() => { props.resetField('pythonCommand') }}
/>
<div className="dsh-nx-footer">
{state.failed ? <p className="dsh-nx-failed" role="status">{t('saveFailed')}</p> : null}
<button

View file

@ -18,7 +18,6 @@ const API_TOKEN_FIELD = 'apiToken'
export interface NetxopsSettings {
apiUrl?: string
lang?: string
pythonCommand?: string
tokenCredentialRef?: string
}
@ -32,7 +31,6 @@ interface CredentialState {
export interface NetxopsCardState extends CardShell {
apiUrl: CardFieldState
lang: CardFieldState
pythonCommand: CardFieldState
apiToken: CardFieldState
apiTokenConfigured: boolean
apiTokenWritable: boolean
@ -66,7 +64,7 @@ export class NetxopsCardController {
) {
this.form = new CardForm(
scope,
[textField('apiUrl'), textField('lang'), textField('pythonCommand')],
[textField('apiUrl'), textField('lang')],
[{ field: API_TOKEN_FIELD, write: text => this.writeToken(text) }],
)
this.store = this.form.bind(() => this.projection())
@ -91,7 +89,6 @@ export class NetxopsCardController {
...this.form.shell(),
apiUrl: this.form.field('apiUrl'),
lang: this.form.field('lang'),
pythonCommand: this.form.field('pythonCommand'),
apiToken: this.form.field(API_TOKEN_FIELD),
apiTokenConfigured: this.credential.configured,
apiTokenWritable: this.credential.remoteReady && this.credential.writable,

View file

@ -7,8 +7,6 @@ export type NetxopsLocaleKey =
| 'apiUrlHint'
| 'lang'
| 'langHint'
| 'pythonCommand'
| 'pythonCommandHint'
| 'apiToken'
| 'apiTokenHint'
| 'apiTokenUnavailable'
@ -28,13 +26,11 @@ export type NetxopsLocaleKey =
export const en: Record<NetxopsLocaleKey, string> = {
title: 'Netx Ops',
description: 'UME API endpoint and bearer token for mcp__netx__* tools.',
description: 'UME API endpoint and bearer token for netx__* tools.',
apiUrl: 'API URL',
apiUrlHint: 'netx REST root, e.g. http://127.0.0.1:8890',
lang: 'Language',
langHint: 'Passed as NETX_LANG (zh / en).',
pythonCommand: 'Python command',
pythonCommandHint: 'Executable that can run `python -m netx_mcp`.',
langHint: 'Response language hint (zh / en).',
apiToken: 'API token',
apiTokenHint: 'Stored as credential NETX_API_TOKEN (never written into settings). Leave blank to keep the current token.',
apiTokenUnavailable: 'This DSH build does not expose remote.credentials. Set the token with scripts/set-netx-token.ps1 (or .sh), then restart is not required if credentials are watched.',
@ -55,13 +51,11 @@ export const en: Record<NetxopsLocaleKey, string> = {
export const zh: Record<NetxopsLocaleKey, string> = {
title: 'Netx Ops',
description: 'UME API 地址与 Bearer Token,供 mcp__netx__* 工具使用。',
description: 'UME API 地址与 Bearer Token,供 netx__* 工具使用。',
apiUrl: 'API 地址',
apiUrlHint: 'netx REST 根地址,例如 http://127.0.0.1:8890',
lang: '语言',
langHint: '传给 MCP 的 NETX_LANG(zh / en)。',
pythonCommand: 'Python 命令',
pythonCommandHint: '能执行 `python -m netx_mcp` 的解释器。',
langHint: '响应语言提示(zh / en)。',
apiToken: 'API Token',
apiTokenHint: '写入凭据 NETX_API_TOKEN(不会进 settings)。留空表示保留已有 token。',
apiTokenUnavailable: '当前 DSH 未提供 remote.credentials。请用 scripts/set-netx-token.ps1(或 .sh)写入 token;若 harness 在监视凭据文件则无需重启。',

View file

@ -1,6 +1,6 @@
/**
* Host-plane Netx Ops bridge: settings (apiUrl / lang / python) + credentials
* (NETX_API_TOKEN) drive a dynamically mounted `@deepseek-ai/dsh-mcp-client`.
* Host-plane Netx Ops: settings (apiUrl / lang) + credentials (NETX_API_TOKEN)
* drive native `netx__*` tools that call the netx REST API directly.
*
* On activate, the agent preset + skills are copied into
* `$DSH_HOME/.agent-presets/netxops` so `dsh plugin add` alone is enough
@ -13,18 +13,19 @@ import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Context, Fiber } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import type {} from '@deepseek-ai/dsh-credentials'
import * as DshSettings from '@deepseek-ai/dsh-settings'
import type {} from '@deepseek-ai/dsh-settings'
import * as McpClient from '@deepseek-ai/dsh-mcp-client'
import type {} from '@deepseek-ai/dsh-tools'
import { registerNetxTools } from './netx/tools.ts'
/** Cordis plugin name. */
export const name = 'netxops'
/** MCP tools registry must exist to mount the child client. */
/** Tool registry must exist to register `netx__*` tools. */
export const inject = ['tools']
/** Settings / composition namespace (Plugins page join key). */
@ -40,16 +41,12 @@ export const DEFAULT_TOKEN_REF = 'NETX_API_TOKEN'
export interface Config {
/** netx REST root (no trailing slash required). */
apiUrl: string
/** Passed to MCP as `NETX_LANG`. */
/** Passed as `lang` query when starting with `en`. */
lang: string
/** Executable that can run `python -m netx_mcp`. */
pythonCommand: string
/** Credential reference for the bearer token (never store the secret here). */
tokenCredentialRef: string
/** Per MCP tool-call timeout (ms). */
/** Per tool-call timeout (ms). */
toolCallTimeoutMs: number
/** Fail activation when MCP cannot connect / sync tools. */
failOnStartupError: boolean
/**
* Copy bundled agent preset + skills into `$DSH_HOME/.agent-presets/netxops`
* on every activate (required for Settings → Agent presets).
@ -60,10 +57,8 @@ export interface Config {
export const Config: z<Config> = z.object({
apiUrl: z.string().default('http://127.0.0.1:8890'),
lang: z.string().default('zh'),
pythonCommand: z.string().default('python'),
tokenCredentialRef: z.string().role('credential-ref').default(DEFAULT_TOKEN_REF),
toolCallTimeoutMs: z.number().step(1).min(1000).default(120_000),
failOnStartupError: z.boolean().default(false),
installAgentPreset: z.boolean().default(true),
})
@ -154,7 +149,7 @@ function installNetxopsSettings(
*/
export function apply(ctx: Context, config: Config = Config({})): void {
let source: () => Config = () => config
let mcpFiber: Fiber | undefined
let unregister: (() => void) | undefined
let remounting: Promise<void> = Promise.resolve()
let generation = 0
@ -165,41 +160,21 @@ export function apply(ctx: Context, config: Config = Config({})): void {
const remount = (): void => {
remounting = remounting.then(async () => {
const gen = ++generation
const previous = mcpFiber
mcpFiber = undefined
if (previous !== undefined) {
try {
await previous.dispose()
} catch (error) {
ctx.logger.warn('netxops: disposing previous mcp-client failed: %s', error)
}
}
unregister?.()
unregister = undefined
if (gen !== generation) return
const current = source()
const token = await resolveToken(ctx, current.tokenCredentialRef)
if (gen !== generation) return
const mcpConfig = McpClient.Config({
transport: 'stdio',
serverName: 'netx',
command: current.pythonCommand,
args: ['-m', 'netx_mcp'],
env: {
NETX_API_URL: current.apiUrl.replace(/\/$/, ''),
NETX_API_TOKEN: token,
NETX_LANG: current.lang,
},
unregister = registerNetxTools(ctx, {
apiUrl: current.apiUrl.replace(/\/$/, ''),
token,
lang: current.lang,
toolCallTimeoutMs: current.toolCallTimeoutMs,
failOnStartupError: current.failOnStartupError,
})
try {
mcpFiber = await ctx.plugin(McpClient, mcpConfig)
} catch (error) {
ctx.logger.error('netxops: failed to mount mcp-client: %s', error)
if (current.failOnStartupError) throw error
}
ctx.logger.info('netxops: registered netx__* REST tools → %s', current.apiUrl.replace(/\/$/, ''))
}).catch((error) => {
ctx.logger.error('netxops: remount error: %s', error)
})
@ -222,8 +197,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
ctx.effect(() => () => {
generation += 1
const fiber = mcpFiber
mcpFiber = undefined
void fiber?.dispose()
}, 'netxops: dispose mcp-client')
unregister?.()
unregister = undefined
}, 'netxops: dispose netx tools')
}

322
src/netx/handlers.ts Normal file
View file

@ -0,0 +1,322 @@
/**
* netx REST tool handlers — port of packages/netx-mcp http_tools.py.
*/
import { type NetxClient, quoteNeId, type NetxJson } from './http.ts'
const EXEC_MAX_COMMANDS = 5
const UME_RAW_FIELD_PRESETS: Record<string, string[]> = {
brief: [
'alarm_alarm_key', 'alarm_host_name', 'alarm_perceived_severity', 'alarm_event_type',
'alarm_last_seen_at', 'ne_host_name', 'ne_user_label', 'ne_ne_name', 'ne_ip_address', 'ne_exists',
],
evidence: [
'alarm_alarm_key', 'alarm_host_name', 'alarm_object_name', 'alarm_event_type',
'alarm_native_probable_cause', 'alarm_perceived_severity', 'alarm_is_cleared',
'alarm_time_created', 'alarm_last_seen_at', 'ne_host_name', 'ne_user_label',
'ne_ne_name', 'ne_ip_address', 'ne_connection_status', 'ne_exists',
],
ne_debug: [
'alarm_alarm_key', 'alarm_ne_id', 'alarm_perceived_severity', 'alarm_last_seen_at',
'ne_user_label', 'ne_ne_name', 'ne_ip_address', 'ne_ipv6_address', 'ne_device_level',
'ne_host_name', 'ne_connection_status', 'ne_admin_status', 'ne_address_type',
'ne_maintain_status', 'ne_exists',
],
}
function asRecord(value: unknown): NetxJson {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? value as NetxJson
: {}
}
function str(args: NetxJson, key: string, fallback = ''): string {
const v = args[key]
if (typeof v === 'string') return v
if (typeof v === 'number' || typeof v === 'boolean') return String(v)
return fallback
}
function num(args: NetxJson, key: string): number | undefined {
const v = args[key]
return typeof v === 'number' && Number.isFinite(v) ? v : undefined
}
function bool(args: NetxJson, key: string): boolean | undefined {
const v = args[key]
return typeof v === 'boolean' ? v : undefined
}
function strList(args: NetxJson, key: string): string[] {
const v = args[key]
if (!Array.isArray(v)) return []
return v.map(x => String(x).trim()).filter(x => x.length > 0)
}
function clampInt(value: number | undefined, fallback: number, min: number, max: number): number {
const n = value === undefined ? fallback : Math.trunc(value)
return Math.max(min, Math.min(max, n))
}
function putStr(
params: Record<string, string | number | boolean>,
args: NetxJson,
keys: string[],
): void {
for (const key of keys) {
const v = str(args, key).trim()
if (v) params[key] = v
}
}
export async function queryUmeAlarms(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
let page = clampInt(num(args, 'page'), 1, 1, 2)
const pageSize = clampInt(num(args, 'page_size'), 50, 1, 500)
const params: Record<string, string | number | boolean> = { page, page_size: pageSize }
putStr(params, args, ['severity', 'ne_id', 'host_name', 'time_from', 'time_to'])
const keyword = str(args, 'keyword').trim()
const neName = str(args, 'ne_name').trim()
if (keyword) params.keyword = keyword
else if (neName) params.keyword = neName
return client.get('/v1/ume/alarms', params, signal)
}
export async function aggregateUmeAlarmsRaw(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const params: Record<string, string | number | boolean> = {}
putStr(params, args, [
'group_by', 'group_by2', 'severity', 'is_cleared', 'ne_id', 'event_type',
'keyword', 'time_from', 'time_to', 'limit',
])
if ('exclude_missing_host' in args) {
const flag = bool(args, 'exclude_missing_host')
if (flag !== undefined) params.exclude_missing_host = flag
}
return client.get('/v1/ume/alarms/aggregate/raw', params, signal)
}
export async function aggregateUmeAlarms(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
if (str(args, 'group_by').trim()) return aggregateUmeAlarmsRaw(client, args, signal)
const topNe = clampInt(num(args, 'top_ne'), 50, 0, 500)
const params: Record<string, string | number | boolean> = { top_ne: topNe }
if ('exclude_missing_host' in args) {
const flag = bool(args, 'exclude_missing_host')
if (flag !== undefined) params.exclude_missing_host = flag
}
putStr(params, args, ['severity', 'time_from', 'time_to'])
return client.get('/v1/ume/alarms/aggregate', params, signal)
}
export async function runUmeDiagnostics(client: NetxClient, _args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
return client.get('/v1/ume/diagnostics', undefined, signal)
}
export async function queryUmeNeInventory(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const params: Record<string, string | number | boolean> = {
page: clampInt(num(args, 'page'), 1, 1, Number.MAX_SAFE_INTEGER),
page_size: clampInt(num(args, 'page_size'), 50, 1, 500),
}
putStr(params, args, ['keyword'])
return client.get('/v1/ume/inventory/ne', params, signal)
}
export async function getUmeNe(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const neId = str(args, 'ne_id').trim()
if (!neId) return { ok: false, error: 'ne_id_required', error_code: 'ne_id_required' }
return client.get(`/v1/ume/inventory/ne/${quoteNeId(neId)}`, undefined, signal)
}
export async function queryUmeAlarmsRaw(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const params: Record<string, string | number | boolean> = {
page: clampInt(num(args, 'page'), 1, 1, Number.MAX_SAFE_INTEGER),
page_size: clampInt(num(args, 'page_size'), 50, 1, 500),
}
putStr(params, args, [
'severity', 'is_cleared', 'ne_id', 'event_type', 'keyword',
'time_from', 'time_to', 'order_by', 'order',
])
let fields = strList(args, 'select_fields')
if (fields.length === 0) {
const preset = str(args, 'field_preset').trim().toLowerCase()
fields = UME_RAW_FIELD_PRESETS[preset] ?? []
}
if (fields.length > 0) params.select_fields = fields.join(',')
return client.get('/v1/ume/alarms/raw', params, signal)
}
export async function listUmeAlarmFields(client: NetxClient, _args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
return client.get('/v1/ume/alarms/fields', undefined, signal)
}
export async function sqlQueryUme(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const sql = str(args, 'sql').trim()
if (!sql) return { ok: false, error: 'sql_required' }
const limit = clampInt(num(args, 'limit'), 200, 1, 2000)
const statementTimeoutMs = clampInt(num(args, 'statement_timeout_ms'), 0, 0, 30_000)
return client.post('/v1/sql/ume_query', {
sql,
limit,
statement_timeout_ms: statementTimeoutMs,
}, signal, 60_000)
}
export async function listManagedNe(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const keyword = str(args, 'keyword').trim()
const vendor = str(args, 'vendor').trim()
const connectStatus = str(args, 'connect_status').trim()
if (!(keyword || vendor || connectStatus)) {
return { ok: false, error: 'managed_ne_filter_required', error_code: 'managed_ne_filter_required' }
}
if (keyword && keyword.length < 2) {
return { ok: false, error: 'managed_ne_keyword_too_short', error_code: 'managed_ne_keyword_too_short' }
}
const params: Record<string, string | number | boolean> = {
page: clampInt(num(args, 'page'), 1, 1, Number.MAX_SAFE_INTEGER),
page_size: clampInt(num(args, 'page_size'), 20, 1, 100),
}
if (keyword) params.keyword = keyword
if (vendor) params.vendor = vendor
if (connectStatus) params.connect_status = connectStatus
return client.get('/v1/managed-ne', params, signal)
}
export async function getManagedNe(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const neId = (
str(args, 'ne_id') || str(args, 'managed_ne_id') || str(args, 'id')
).trim()
if (!neId) {
return {
ok: false,
error: 'ne_id_required',
error_code: 'ne_id_required',
hint: 'Pass managed NE id from listManagedNe/listCliTargets (source=managed). For UME inventory UUIDs use execManagedNe(ume_ne_id=...) or getUmeNe, not getManagedNe.',
example: { ne_id: '<managed-ne-uuid-from-listManagedNe>' },
}
}
const out = await client.get(`/v1/managed-ne/${quoteNeId(neId)}`, undefined, signal)
if (out.ok === false) {
const detail = `${str(out, 'detail')}${str(out, 'error')}`.toLowerCase()
if (detail.includes('404') || detail.includes('not_found') || detail.includes('not found') || out.error === 'netx_http_404') {
return {
...out,
hint: 'Managed NE not found for this ne_id. Call listManagedNe(keyword=...) or listCliTargets(source=managed) first. If this is a UME ne_id, use execManagedNe(ume_ne_id=...) / getUmeNe instead of getManagedNe.',
}
}
}
return out
}
export async function execManagedNe(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const targetsRaw = args.targets
const neIds = strList(args, 'ne_ids')
const umeNeIds = strList(args, 'ume_ne_ids')
const sharedCommands = strList(args, 'commands')
const multi = (Array.isArray(targetsRaw) && targetsRaw.length > 0)
|| neIds.length > 0
|| umeNeIds.length > 0
if (multi) {
const body: NetxJson = {}
if (Array.isArray(targetsRaw) && targetsRaw.length > 0) {
const cleaned: NetxJson[] = []
for (const t of targetsRaw) {
if (typeof t !== 'object' || t === null || Array.isArray(t)) continue
const row = t as NetxJson
const item: NetxJson = {}
const neId = str(row, 'ne_id').trim()
const umeNeId = str(row, 'ume_ne_id').trim()
if (neId) item.ne_id = neId
if (umeNeId) item.ume_ne_id = umeNeId
const cmds = Array.isArray(row.commands)
? row.commands.map(c => String(c).trim()).filter(c => c.length > 0)
: []
if (cmds.length > 0) item.commands = cmds
if (Object.keys(item).length > 0) cleaned.push(item)
}
body.targets = cleaned
}
if (neIds.length > 0) body.ne_ids = neIds
if (umeNeIds.length > 0) body.ume_ne_ids = umeNeIds
if (sharedCommands.length > 0) {
if (sharedCommands.length > EXEC_MAX_COMMANDS) {
return { ok: false, error: 'too_many_commands', error_code: 'too_many_commands' }
}
body.commands = sharedCommands
}
body.read_timeout_sec = clampInt(num(args, 'read_timeout_sec'), 60, 10, 120)
const concurrency = num(args, 'concurrency')
if (concurrency !== undefined) body.concurrency = clampInt(concurrency, 4, 1, 8)
const out = await client.post('/v1/managed-ne/exec-batch', body, signal, 600_000)
if (out.ok !== true) return out
const data = asRecord(out.data)
if (data.ok === false) {
return { ok: false, data, error: str(data, 'error', 'exec_batch_failed') }
}
return { ok: true, data }
}
const neId = str(args, 'ne_id').trim()
const umeNeId = str(args, 'ume_ne_id').trim()
if (Boolean(neId) === Boolean(umeNeId)) {
return {
ok: false,
error: 'exactly_one_of_ne_id_or_ume_ne_id_required',
error_code: 'exactly_one_of_ne_id_or_ume_ne_id_required',
hint: 'For one NE pass ne_id OR ume_ne_id. For many NEs pass ne_ids / ume_ne_ids with shared commands, or targets[] with per-NE commands — one call, concurrent on server.',
}
}
if (sharedCommands.length === 0) {
return { ok: false, error: 'commands_required', error_code: 'commands_required' }
}
if (sharedCommands.length > EXEC_MAX_COMMANDS) {
return { ok: false, error: 'too_many_commands', error_code: 'too_many_commands' }
}
const body: NetxJson = {
commands: sharedCommands,
read_timeout_sec: clampInt(num(args, 'read_timeout_sec'), 60, 10, 120),
}
if (neId) body.ne_id = neId
if (umeNeId) body.ume_ne_id = umeNeId
const out = await client.post('/v1/managed-ne/exec', body, signal, 300_000)
if (out.ok !== true) return out
const data = asRecord(out.data)
if (data.ok === false) {
return { ok: false, data, error: str(data, 'error', 'exec_failed') }
}
return { ok: true, data }
}
export async function listCliTargets(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const params: Record<string, string | number | boolean> = {
page: clampInt(num(args, 'page'), 1, 1, Number.MAX_SAFE_INTEGER),
page_size: clampInt(num(args, 'page_size'), 50, 1, 500),
}
putStr(params, args, ['source', 'keyword'])
return client.get('/v1/cli/targets', params, signal)
}
export async function findTopologyPaths(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const fromUid = str(args, 'from_ume_ne_id').trim()
const fromMid = str(args, 'from_managed_ne_id').trim()
const toUid = str(args, 'to_ume_ne_id').trim()
const toMid = str(args, 'to_managed_ne_id').trim()
if (Boolean(fromUid) === Boolean(fromMid)) {
return { ok: false, error: 'exactly_one_of_from_ume_ne_id_or_from_managed_ne_id_required' }
}
if (Boolean(toUid) === Boolean(toMid)) {
return { ok: false, error: 'exactly_one_of_to_ume_ne_id_or_to_managed_ne_id_required' }
}
let detail = str(args, 'detail', 'summary').trim().toLowerCase() || 'summary'
if (detail !== 'summary' && detail !== 'full') detail = 'summary'
const body: NetxJson = {
max_paths: clampInt(num(args, 'max_paths'), 3, 1, 10),
max_hops: clampInt(num(args, 'max_hops'), 6, 1, 12),
layer: str(args, 'layer', 'physical').trim() || 'physical',
detail,
}
if (fromUid) body.from_ume_ne_id = fromUid
else body.from_managed_ne_id = fromMid
if (toUid) body.to_ume_ne_id = toUid
else body.to_managed_ne_id = toMid
return client.post('/v1/topology/fabric/paths', body, signal, 30_000)
}

125
src/netx/http.ts Normal file
View file

@ -0,0 +1,125 @@
/**
* Minimal netx REST client (Bearer + lang), aligned with netx-mcp http_client.
*/
export interface NetxConnection {
apiUrl: string
token: string
lang: string
timeoutMs: number
}
export type NetxJson = Record<string, unknown>
const PROTOCOL_KEY_ZH_TO_EN: Record<string, string> = {
其他: 'Other',
时钟: 'Clock',
'OTN/光': 'OTN/Optical',
电源: 'Power',
}
/** Localize a few protocol bucket keys when lang starts with en (match netx-mcp). */
function localizePayload(lang: string, data: NetxJson): NetxJson {
if (!lang.trim().toLowerCase().startsWith('en')) return data
const proto = data.protocol_summary
if (!Array.isArray(proto)) return data
for (const row of proto) {
if (typeof row !== 'object' || row === null || Array.isArray(row)) continue
const rec = row as NetxJson
const key = typeof rec.key === 'string' ? rec.key : ''
const mapped = PROTOCOL_KEY_ZH_TO_EN[key]
if (mapped !== undefined) rec.key = mapped
}
return data
}
function encodeQuery(params: Record<string, string | number | boolean>): string {
const sp = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
sp.set(key, String(value))
}
const q = sp.toString()
return q.length > 0 ? `?${q}` : ''
}
/**
* Build a client bound to the current settings/credentials snapshot.
* @param connection - apiUrl / token / lang / default timeout.
* @returns get/post helpers that return `{ ok, data }` or `{ ok: false, error }`.
*/
export function createNetxClient(connection: NetxConnection) {
const base = connection.apiUrl.replace(/\/$/, '')
const headers: Record<string, string> = {
accept: 'application/json',
}
if (connection.token.trim().length > 0) {
headers.authorization = `Bearer ${connection.token.trim()}`
}
const langParams = (): Record<string, string> => {
const lang = connection.lang.trim().toLowerCase()
if (lang.startsWith('en')) return { lang: 'en' }
return {}
}
async function request(
method: string,
path: string,
options: {
params?: Record<string, string | number | boolean>
body?: NetxJson
timeoutMs?: number
signal?: AbortSignal
} = {},
): Promise<NetxJson> {
const merged: Record<string, string | number | boolean> = { ...langParams(), ...options.params }
const url = `${base}${path}${encodeQuery(merged)}`
const timeoutMs = options.timeoutMs ?? connection.timeoutMs
const controller = new AbortController()
const timer = setTimeout(() => { controller.abort() }, timeoutMs)
const onOuterAbort = () => { controller.abort() }
options.signal?.addEventListener('abort', onOuterAbort, { once: true })
try {
const init: RequestInit = {
method,
headers: options.body === undefined
? headers
: { ...headers, 'content-type': 'application/json' },
signal: controller.signal,
}
if (options.body !== undefined) init.body = JSON.stringify(options.body)
const resp = await fetch(url, init)
const text = await resp.text()
if (!resp.ok) {
return { ok: false, error: `netx_http_${resp.status}`, detail: text.slice(0, 800) }
}
const data = text.length > 0 ? JSON.parse(text) as unknown : {}
if (typeof data === 'object' && data !== null && !Array.isArray(data)) {
return { ok: true, data: localizePayload(connection.lang, data as NetxJson) }
}
return { ok: true, data: { raw: data } }
} catch (error) {
const detail = error instanceof Error ? error.message : String(error)
return { ok: false, error: 'netx_request_failed', detail: detail.slice(0, 800) }
} finally {
clearTimeout(timer)
options.signal?.removeEventListener('abort', onOuterAbort)
}
}
return {
get(path: string, params?: Record<string, string | number | boolean>, signal?: AbortSignal, timeoutMs?: number) {
return request('GET', path, { params, signal, timeoutMs })
},
post(path: string, body: NetxJson, signal?: AbortSignal, timeoutMs?: number) {
return request('POST', path, { body, signal, timeoutMs })
},
}
}
export type NetxClient = ReturnType<typeof createNetxClient>
/** Path-segment encode for NE ids (match urllib.parse.quote(..., safe='')). */
export function quoteNeId(neId: string): string {
return encodeURIComponent(neId.trim())
}

273
src/netx/tools.ts Normal file
View file

@ -0,0 +1,273 @@
/**
* Register native DSH tools (`netx__*`) that call netx REST.
*/
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { createNetxClient, type NetxClient, type NetxJson } from './http.ts'
import * as H from './handlers.ts'
export interface NetxToolConnection {
apiUrl: string
token: string
lang: string
toolCallTimeoutMs: number
}
type Handler = (client: NetxClient, args: NetxJson, signal?: AbortSignal) => Promise<NetxJson>
const str = (description?: string) => ({ type: 'string' as const, ...(description ? { description } : {}) })
const num = (description?: string) => ({ type: 'number' as const, ...(description ? { description } : {}) })
const bool = (description?: string) => ({ type: 'boolean' as const, ...(description ? { description } : {}) })
const strArr = (description?: string) => ({
type: 'array' as const,
items: { type: 'string' as const },
...(description ? { description } : {}),
})
function renderJson(_args: unknown, value: unknown) {
return [{ type: 'text' as const, text: JSON.stringify(value, null, 0) }]
}
const jsonOut = {
schema: { type: 'json' as const },
render: renderJson,
}
function tool(
name: string,
description: string,
parameters: Record<string, unknown>,
handler: Handler,
getClient: () => NetxClient,
timeoutMs: number,
) {
return defineTool({
name,
description,
parameters: parameters as never,
output: jsonOut,
timeoutMs,
isConcurrencySafe: () => true,
async execute(args, exec) {
const result = await handler(getClient(), args as NetxJson, exec.signal)
if (result.ok === false) {
throw new Error(JSON.stringify(result))
}
return result
},
})
}
/**
* Register all Netx Ops tools against the current connection snapshot.
* @param ctx - host context with `tools`.
* @param connection - apiUrl / token / lang / timeout.
* @returns disposer that unregisters every tool.
*/
export function registerNetxTools(ctx: Context, connection: NetxToolConnection): () => void {
const client = createNetxClient({
apiUrl: connection.apiUrl,
token: connection.token,
lang: connection.lang,
timeoutMs: Math.min(connection.toolCallTimeoutMs, 45_000),
})
const getClient = () => client
const t = connection.toolCallTimeoutMs
const disposers = [
ctx.tools.register(tool(
'netx__queryUmeAlarms',
'Query UME current alarms (each row includes host_name). Supports severity/ne_id/host_name/keyword, last_seen time_from/time_to, pagination. Prefer host_name for display; ne_id is for filters only.',
{
severity: str(),
ne_id: str('Filter only; do not show UUID to users'),
host_name: str('Filter by NE host_name'),
ne_name: str('Legacy alias mapped to keyword'),
keyword: str('Substring on cause/object/event. Examples: LOS, Fiber Break, bandwidth, CRC.'),
time_from: str('ISO time; filters last_seen_at >='),
time_to: str('ISO time; filters last_seen_at <='),
page: num(),
page_size: num(),
},
H.queryUmeAlarms, getClient, t,
)),
ctx.tools.register(tool(
'netx__aggregateUmeAlarms',
'Aggregate UME current alarms (by_severity + top by_ne). If group_by is set, routes to aggregateUmeAlarmsRaw. Always filter severity/keyword/time before paging.',
{
severity: str('Optional perceived_severity filter (critical/major/minor/warning).'),
top_ne: num('Max NE buckets (default 50). Ignored when group_by is set.'),
exclude_missing_host: bool('Omit missing host_name from by_ne.'),
time_from: str(),
time_to: str(),
group_by: str('When set, routes to raw aggregation. Prefer alarm_host_name.'),
group_by2: str(),
is_cleared: str(),
ne_id: str(),
event_type: str(),
keyword: str(),
limit: num(),
},
H.aggregateUmeAlarms, getClient, t,
)),
ctx.tools.register(tool(
'netx__runUmeDiagnostics',
'UME alarm diagnostics: severity, top_event_types, top_alarm_codes, top_ne, protocol buckets, freshness meta.',
{},
H.runUmeDiagnostics, getClient, t,
)),
ctx.tools.register(tool(
'netx__queryUmeNeInventory',
'Paged UME NE inventory synced in netx (keyword matches ne_id/ne_name/user_label/ip/host_name).',
{
keyword: str(),
page: num(),
page_size: num(),
},
H.queryUmeNeInventory, getClient, t,
)),
ctx.tools.register(tool(
'netx__getUmeNe',
'Get single UME NE detail by ne_id (UUID).',
{
ne_id: { type: 'string' as const, required: true as const, description: 'UME inventory ne_id (UUID).' },
},
H.getUmeNe, getClient, t,
)),
ctx.tools.register(tool(
'netx__queryUmeAlarmsRaw',
'Power query UME current alarms with full alarm_* + ne_* fields; optional field_preset or select_fields. Use field_preset=evidence for citations.',
{
severity: str(),
is_cleared: str(),
ne_id: str(),
event_type: str(),
keyword: str(),
time_from: str(),
time_to: str(),
order_by: str('last_seen_at | time_created | perceived_severity | event_type | ne_id'),
order: str('asc | desc'),
select_fields: strArr(),
field_preset: str('brief | evidence | ne_debug'),
page: num(),
page_size: num(),
},
H.queryUmeAlarmsRaw, getClient, t,
)),
ctx.tools.register(tool(
'netx__aggregateUmeAlarmsRaw',
'Dynamic aggregation on UME raw fields (group_by/group_by2); prefer alarm_host_name.',
{
group_by: { type: 'string' as const, required: true as const },
group_by2: str(),
severity: str(),
is_cleared: str(),
ne_id: str(),
event_type: str(),
keyword: str(),
time_from: str(),
time_to: str(),
exclude_missing_host: bool(),
limit: num(),
},
H.aggregateUmeAlarmsRaw, getClient, t,
)),
ctx.tools.register(tool(
'netx__listUmeAlarmFields',
'List available fields for UME raw alarm queries.',
{},
H.listUmeAlarmFields, getClient, t,
)),
ctx.tools.register(tool(
'netx__sqlQueryUme',
'Read-only SELECT on UME tables (ume_alarms_current/ume_inventory_ne); server enforces limits. Requires sql:query scope.',
{
sql: { type: 'string' as const, required: true as const },
limit: num(),
statement_timeout_ms: num(),
},
H.sqlQueryUme, getClient, t,
)),
ctx.tools.register(tool(
'netx__listManagedNe',
'List filtered netx managed NEs (keyword/vendor/connect_status required); use before execManagedNe.',
{
keyword: str(),
vendor: str(),
connect_status: str('unknown | testing | pass | fail'),
page: num(),
page_size: num(),
},
H.listManagedNe, getClient, t,
)),
ctx.tools.register(tool(
'netx__getManagedNe',
'Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Do NOT pass UME inventory UUID here.',
{
ne_id: str('Managed NE id'),
managed_ne_id: str('Alias for ne_id'),
id: str('Alias for ne_id'),
},
H.getManagedNe, getClient, t,
)),
ctx.tools.register(tool(
'netx__execManagedNe',
'Run read-only CLI via netx (show/display/ping/traceroute). Single NE: ne_id OR ume_ne_id + commands. Many NEs: ne_ids[]/ume_ne_ids[] + shared commands, or targets[{ume_ne_id|ne_id, commands}]. Do NOT loop one-NE calls for multi-NE work.',
{
ne_id: str(),
ume_ne_id: str(),
ne_ids: strArr('Managed NE ids for concurrent batch (shared commands).'),
ume_ne_ids: strArr('UME inventory ne_ids for concurrent batch (shared commands).'),
targets: {
type: 'array' as const,
description: 'Per-NE command sets: each item is one NE (ne_id OR ume_ne_id) with commands[].',
items: {
type: 'object' as const,
additionalProperties: false,
properties: {
ne_id: str(),
ume_ne_id: str(),
commands: strArr(),
},
},
},
commands: strArr('Commands for single NE, or shared commands for batch.'),
read_timeout_sec: num('Per-command read timeout (default 60; use 90–120 for slow show).'),
concurrency: num('Parallel NEs for batch mode (1–8, default 4).'),
async: bool('oclaw-only async hint; ignored by native REST client.'),
},
H.execManagedNe, getClient, Math.max(t, 300_000),
)),
ctx.tools.register(tool(
'netx__listCliTargets',
'List CLI-capable targets (managed NE and/or UME inventory). Call once per session with keyword/source, cache ids, then execManagedNe.',
{
source: str('managed | ume | all'),
keyword: str(),
page: num(),
page_size: num(),
},
H.listCliTargets, getClient, t,
)),
ctx.tools.register(tool(
'netx__findTopologyPaths',
'Find up to max_paths simple paths between two fabric nodes. For each endpoint provide exactly one of ume_ne_id or managed_ne_id.',
{
from_ume_ne_id: str(),
from_managed_ne_id: str(),
to_ume_ne_id: str(),
to_managed_ne_id: str(),
max_paths: num(),
max_hops: num(),
layer: str(),
detail: str('summary | full'),
},
H.findTopologyPaths, getClient, t,
)),
]
return () => {
for (const dispose of disposers) dispose()
}
}