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>
This commit is contained in:
oliver 2026-10-16 22:08:22 +08:00
parent 2def53e7c7
commit 2ef3a43c63
38 changed files with 5049 additions and 17029 deletions

View file

@ -21,7 +21,9 @@ import { kbStatusTone, type KbSnapshot } from './kb-status-view.ts'
import { toolsMountTone, type ToolsMountView } from './tools-status-view.ts'
import { ensureStyles } from './styles.ts'
import { sessionsExportReasonLocaleKey } from './sessions-export-view.ts'
import { parsePresetIds, TOOLS_BY_GROUP } from '../netx/capability-groups.ts'
import { parsePresetIds } from '../netx/capability-groups.ts'
import { parseDisabledTools } from '../netx/mcp-catalog.ts'
import type { McpToolRow } from './mcp-catalog-view.ts'
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
@ -85,9 +87,12 @@ function toolsBadgeLabel(
): string {
if (!view) return t('toolsStatusUnknown')
if (!view.toolsEnabled) return t('toolsDisabled')
if (view.error) {
if (view.error || view.mcpPhase === 'error') {
return t('toolsStatusError')
}
if (view.mcpPhase === 'offline' || view.mcpFromCache) {
return t('mcpDisconnected')
}
if (view.count > 0) {
return fillTemplate(t('toolsStatusMounted'), {
count: String(view.count),
@ -358,6 +363,11 @@ function CapabilityGroupBlock(props: {
onEditInPreset: (checked: boolean) => void
onEditPublic: (checked: boolean) => void
targets?: ReactNode
tools?: McpToolRow[]
disabledTools?: ReadonlySet<string>
writeBadge?: string
emptyLabel?: string
onToggleTool?: (name: string, enabled: boolean) => void
}) {
return (
<div className="dsh-nx-groupBlock" role="group" aria-label={props.title}>
@ -386,6 +396,43 @@ function CapabilityGroupBlock(props: {
</label>
</div>
{props.targets}
{props.tools
? (
<details className="dsh-nx-toolList">
<summary>{props.toolCount ?? props.tools.length}</summary>
{props.tools.length === 0
? <p className="dsh-nx-hint">{props.emptyLabel ?? ''}</p>
: (
<div className="dsh-nx-toolRows">
{props.tools.map((tool) => {
const enabled = !(props.disabledTools?.has(tool.name) ?? false)
const desc = tool.description.trim()
const short = desc.length > 72 ? `${desc.slice(0, 72)}…` : desc
return (
<label key={tool.name} className="dsh-nx-checkRow dsh-nx-toolRow">
<input
type="checkbox"
checked={enabled}
disabled={props.disabled}
onChange={(event) => { props.onToggleTool?.(tool.name, event.target.checked) }}
/>
<span className="dsh-nx-toolMeta">
<span className="dsh-nx-toolName">
{tool.name}
{tool.readOnlyHint !== true
? <span className="dsh-nx-badgeMuted">{props.writeBadge ?? ''}</span>
: null}
</span>
{short ? <span className="dsh-nx-hint">{short}</span> : null}
</span>
</label>
)
})}
</div>
)}
</details>
)
: null}
</div>
)
}
@ -567,62 +614,111 @@ export function NetxopsCard(props: NetxopsCardProps) {
<div className="dsh-nx-settings-card">
<div className="dsh-nx-card-head">
<h3>{t('sectionCapabilities')}</h3>
<StatusBadge tone={toolsMountTone(state.toolsStatus)} label={toolsBadgeLabel(t, state.toolsStatus)} />
<span className="dsh-nx-badges">
<StatusBadge tone={toolsMountTone(state.toolsStatus)} label={toolsBadgeLabel(t, state.toolsStatus)} />
<button
type="button"
className="dsh-nx-btn"
disabled={disabled || state.mcpRefreshBusy}
onClick={() => { props.refreshMcpCatalog() }}
>
{state.mcpRefreshBusy ? t('mcpRefreshing') : t('mcpRefresh')}
</button>
</span>
</div>
<ToggleField id="netxops-tools-enabled" label={t('toolsEnabled')}
checked={state.toolsEnabled.text === 'true'} disabled={disabled}
overridden={state.toolsEnabled.overridden} resetLabel={t('reset')}
onToggle={checked => { props.edit('toolsEnabled', String(checked)) }}
onReset={() => { props.resetField('toolsEnabled') }}
error={state.toolsStatus?.error ? <p className="dsh-nx-invalid" role="status">{state.toolsStatus.error}</p> : null} />
<CapabilityGroupBlock
title={t('groupOps')}
toolCount={TOOLS_BY_GROUP.ops.length}
targets={targets('groupOpsPresets', state.groupOpsPresets)}
inPresetLabel={t('groupInPreset')}
publicLabel={t('groupPublic')}
inPreset={state.groupOpsInPreset}
published={state.groupOpsPublic}
disabled={publicationDisabled}
onEditInPreset={(checked) => {
props.edit('groupOpsInPreset', checked ? 'true' : 'false')
}}
onEditPublic={(checked) => {
props.edit('groupOpsPublic', checked ? 'true' : 'false')
}}
/>
<CapabilityGroupBlock
title={t('groupTopology')}
toolCount={TOOLS_BY_GROUP.topology.length}
targets={targets('groupTopologyPresets', state.groupTopologyPresets)}
inPresetLabel={t('groupInPreset')}
publicLabel={t('groupPublic')}
inPreset={state.groupTopologyInPreset}
published={state.groupTopologyPublic}
disabled={publicationDisabled}
onEditInPreset={(checked) => {
props.edit('groupTopologyInPreset', checked ? 'true' : 'false')
}}
onEditPublic={(checked) => {
props.edit('groupTopologyPublic', checked ? 'true' : 'false')
}}
/>
<CapabilityGroupBlock
title={t('groupBizMonitor')}
toolCount={TOOLS_BY_GROUP.bizMonitor.length}
targets={targets('groupBizMonitorPresets', state.groupBizMonitorPresets)}
inPresetLabel={t('groupInPreset')}
publicLabel={t('groupPublic')}
inPreset={state.groupBizMonitorInPreset}
published={state.groupBizMonitorPublic}
disabled={publicationDisabled}
onEditInPreset={(checked) => {
props.edit('groupBizMonitorInPreset', checked ? 'true' : 'false')
}}
onEditPublic={(checked) => {
props.edit('groupBizMonitorPublic', checked ? 'true' : 'false')
}}
/>
error={(state.toolsStatus?.error || state.toolsStatus?.mcpError || state.mcpCatalog?.error)
? (
<p className="dsh-nx-invalid" role="status">
{state.toolsStatus?.error || state.toolsStatus?.mcpError || state.mcpCatalog?.error}
{(state.toolsStatus?.mcpFromCache || state.mcpCatalog?.fromCache)
? ` · ${t('mcpDisconnected')}`
: ''}
</p>
)
: null} />
<ToggleField id="netxops-readonly-only" label={t('readOnlyOnly')}
checked={state.readOnlyOnly.text === 'true'} disabled={publicationDisabled}
overridden={state.readOnlyOnly.overridden} resetLabel={t('reset')}
onToggle={checked => { props.edit('readOnlyOnly', String(checked)) }}
onReset={() => { props.resetField('readOnlyOnly') }} />
{(() => {
const catalogTools = state.mcpCatalog?.tools ?? []
const byGroup = {
ops: catalogTools.filter((tool) => tool.group === 'ops'),
topology: catalogTools.filter((tool) => tool.group === 'topology'),
bizMonitor: catalogTools.filter((tool) => tool.group === 'bizMonitor'),
}
const disabledSet = new Set(parseDisabledTools(state.disabledTools.text))
const toolProps = {
disabledTools: disabledSet,
writeBadge: t('toolWriteBadge'),
emptyLabel: t('toolListEmpty'),
onToggleTool: (name: string, enabled: boolean) => { props.setToolEnabled(name, enabled) },
}
return (
<>
<CapabilityGroupBlock
title={t('groupOps')}
toolCount={byGroup.ops.length}
tools={byGroup.ops}
targets={targets('groupOpsPresets', state.groupOpsPresets)}
inPresetLabel={t('groupInPreset')}
publicLabel={t('groupPublic')}
inPreset={state.groupOpsInPreset}
published={state.groupOpsPublic}
disabled={publicationDisabled}
onEditInPreset={(checked) => {
props.edit('groupOpsInPreset', checked ? 'true' : 'false')
}}
onEditPublic={(checked) => {
props.edit('groupOpsPublic', checked ? 'true' : 'false')
}}
{...toolProps}
/>
<CapabilityGroupBlock
title={t('groupTopology')}
toolCount={byGroup.topology.length}
tools={byGroup.topology}
targets={targets('groupTopologyPresets', state.groupTopologyPresets)}
inPresetLabel={t('groupInPreset')}
publicLabel={t('groupPublic')}
inPreset={state.groupTopologyInPreset}
published={state.groupTopologyPublic}
disabled={publicationDisabled}
onEditInPreset={(checked) => {
props.edit('groupTopologyInPreset', checked ? 'true' : 'false')
}}
onEditPublic={(checked) => {
props.edit('groupTopologyPublic', checked ? 'true' : 'false')
}}
{...toolProps}
/>
<CapabilityGroupBlock
title={t('groupBizMonitor')}
toolCount={byGroup.bizMonitor.length}
tools={byGroup.bizMonitor}
targets={targets('groupBizMonitorPresets', state.groupBizMonitorPresets)}
inPresetLabel={t('groupInPreset')}
publicLabel={t('groupPublic')}
inPreset={state.groupBizMonitorInPreset}
published={state.groupBizMonitorPublic}
disabled={publicationDisabled}
onEditInPreset={(checked) => {
props.edit('groupBizMonitorInPreset', checked ? 'true' : 'false')
}}
onEditPublic={(checked) => {
props.edit('groupBizMonitorPublic', checked ? 'true' : 'false')
}}
{...toolProps}
/>
</>
)
})()}
{state.toolsStatus && state.toolsStatus.mounts.length > 0 ? (
<details className="dsh-nx-diagnostics"><summary>{t('mountDetails')}</summary>
<p className="dsh-nx-hint">{t('mountStatusHint')}</p>

View file

@ -7,7 +7,7 @@ import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type { SettingsFormScope, SettingsFormSnapshot } from './settings-form.ts'
import type { SnapshotStore } from './snapshot-store.ts'
import {
CardForm, apiUrlField, textField, presetIdsField, booleanFieldPersistFalse,
CardForm, apiUrlField, textField, presetIdsField, booleanFieldPersistFalse, booleanField,
type CardActions, type CardFieldState, type CardShell,
} from './card-form.ts'
import {
@ -19,6 +19,12 @@ import {
fetchToolsStatus,
type ToolsMountView,
} from './tools-status-view.ts'
import {
fetchMcpCatalog,
refreshMcpCatalogRemote,
type McpCatalogView,
} from './mcp-catalog-view.ts'
import { parseDisabledTools, serializeDisabledTools } from '../netx/mcp-catalog.ts'
import {
fetchKbStatus,
reloadKbStatus,
@ -81,6 +87,10 @@ export interface NetxopsSettings {
groupKbInPreset?: boolean
/** Publish `_skills/kb-*` to other presets. */
groupKbPublic?: boolean
/** JSON array of server tool names opted out. */
disabledTools?: string
/** Only register readOnlyHint tools. */
readOnlyOnly?: boolean
}
interface CredentialState {
@ -126,6 +136,11 @@ export interface NetxopsCardState extends CardShell {
alarmPushStatus: AlarmPushStatus | null
/** Host `netx__*` tool mount snapshot; null when RPC unavailable. */
toolsStatus: ToolsMountView | null
/** Cached `/mcp` tools/list for the per-tool switches. */
mcpCatalog: McpCatalogView | null
mcpRefreshBusy: boolean
disabledTools: CardFieldState
readOnlyOnly: CardFieldState
/** Latest KB snapshot (saved root, or live preview while editing). */
kbStatus: KbSnapshot | null
/** Soft-injected `remote.directoryPicker` is available. */
@ -157,6 +172,10 @@ export interface NetxopsCardFace extends CardActions {
exportAllSessions: () => void
/** Open the Host directory picker and write the chosen path into kbRoot. */
browseKbRoot: () => void
/** Re-pull tools/list from netx `/mcp`. */
refreshMcpCatalog: () => void
/** Toggle one server tool in disabledTools (checked = enabled). */
setToolEnabled: (serverName: string, enabled: boolean) => void
}
type DirectoryPickerRemote = {
@ -181,6 +200,9 @@ export class NetxopsCardController {
private alarmPushStatus: AlarmPushStatus | null = null
private toolsStatus: ToolsMountView | null = null
private toolsStatusInFlight = false
private mcpCatalog: McpCatalogView | null = null
private mcpCatalogInFlight = false
private mcpRefreshBusy = false
private presetCatalog: NetxopsCardState['presetCatalog'] = []
private presetCatalogError = false
private presetCatalogInFlight = false
@ -235,6 +257,8 @@ export class NetxopsCardController {
booleanFieldPersistFalse('alarmPushEnabled'),
booleanFieldPersistFalse('alarmDeliverDsh'),
booleanFieldPersistFalse('alarmDeliverIm'),
booleanField('readOnlyOnly'),
textField('disabledTools'),
textField('imTargets'),
textField('imBotId'),
textField('imTargetId'),
@ -279,6 +303,10 @@ export class NetxopsCardController {
this.toolsStatus = null
changed = true
}
if (this.mcpCatalog !== null) {
this.mcpCatalog = null
changed = true
}
this.presetCatalog = []
this.presetCatalogError = true
changed = true
@ -301,6 +329,7 @@ export class NetxopsCardController {
this.startStatusPoll()
void this.refreshAlarmPushStatus()
void this.refreshToolsStatus()
void this.refreshMcpCatalogStatus()
void this.refreshPresetCatalog()
void this.reloadKbFromHost()
void this.refreshKbPreview()
@ -325,6 +354,7 @@ export class NetxopsCardController {
this.pollTimer = setInterval(() => {
void this.refreshAlarmPushStatus()
void this.refreshToolsStatus()
void this.refreshMcpCatalogStatus()
void this.refreshPresetCatalog()
void this.refreshKbStatus()
void this.refreshImDeliveryCatalog()
@ -389,6 +419,51 @@ export class NetxopsCardController {
}
}
private async refreshMcpCatalogStatus(): Promise<void> {
const call = this.rpcCall
if (call === undefined || this.mcpCatalogInFlight) return
this.mcpCatalogInFlight = true
try {
const next = await fetchMcpCatalog(call)
const prev = this.mcpCatalog
if (prev && JSON.stringify(prev) === JSON.stringify(next)) return
this.mcpCatalog = next
this.store.set(this.projection())
} catch {
// Keep last catalog.
} finally {
this.mcpCatalogInFlight = false
}
}
private async runMcpRefresh(): Promise<void> {
const call = this.rpcCall
if (call === undefined || this.mcpRefreshBusy) return
this.mcpRefreshBusy = true
this.store.set(this.projection())
try {
const next = await refreshMcpCatalogRemote(call)
this.mcpCatalog = next
await this.refreshToolsStatus()
} catch {
// leave previous catalog
} finally {
this.mcpRefreshBusy = false
this.store.set(this.projection())
}
}
private setToolEnabled(serverName: string, enabled: boolean): void {
const name = serverName.trim()
if (!name) return
const current = new Set(parseDisabledTools(this.form.field('disabledTools').text))
if (enabled) current.delete(name)
else current.add(name)
const next = serializeDisabledTools([...current])
this.form.actions().edit('disabledTools', next)
void this.form.save(['disabledTools'])
}
private async refreshPresetCatalog(): Promise<void> {
const call = this.rpcCall
if (!call || this.presetCatalogInFlight) return
@ -721,6 +796,10 @@ export class NetxopsCardController {
apiTokenRemoteReady: this.credential.remoteReady,
alarmPushStatus: this.alarmPushStatus,
toolsStatus: this.toolsStatus,
mcpCatalog: this.mcpCatalog,
mcpRefreshBusy: this.mcpRefreshBusy,
disabledTools: this.form.field('disabledTools'),
readOnlyOnly: this.form.field('readOnlyOnly'),
kbStatus: this.kbPreview ?? this.kbStatus,
kbDirectoryPickerReady: this.kbDirectoryPicker !== undefined || this.rpcCall !== undefined,
kbUiError: this.kbUiError,
@ -794,6 +873,8 @@ export class NetxopsCardController {
'groupBizMonitorPublic',
'groupKbInPreset',
'groupKbPublic',
'disabledTools',
'readOnlyOnly',
])
return {
hooks: { netxopsCard: this.store },
@ -816,6 +897,8 @@ export class NetxopsCardController {
},
exportAllSessions: () => { this.exportAllSessions() },
browseKbRoot: () => { this.browseKbRoot() },
refreshMcpCatalog: () => { void this.runMcpRefresh() },
setToolEnabled: (serverName, enabled) => { this.setToolEnabled(serverName, enabled) },
}
}

View file

@ -162,6 +162,8 @@ const MEMORY_DEFAULTS: NetxopsSettings = {
kbRoot: '',
groupKbInPreset: true,
groupKbPublic: false,
disabledTools: '[]',
readOnlyOnly: false,
}
export function apply(ctx: ClientContext): void {

View file

@ -66,6 +66,12 @@ export type NetxopsLocaleKey =
| 'toolsStatusEmpty'
| 'toolsStatusError'
| 'toolsStatusUnknown'
| 'mcpDisconnected'
| 'mcpRefresh'
| 'mcpRefreshing'
| 'readOnlyOnly'
| 'toolWriteBadge'
| 'toolListEmpty'
| 'sessionsExportCount'
| 'sessionsExportUnavailable'
| 'sessionsExportNoPersistence'
@ -153,6 +159,12 @@ export const en: Record<NetxopsLocaleKey, string> = {
toolsStatusEmpty: 'No active tools',
toolsStatusError: 'Publication failed',
toolsStatusUnknown: 'Status unavailable',
mcpDisconnected: 'Disconnected',
mcpRefresh: 'Refresh',
mcpRefreshing: 'Refreshing…',
readOnlyOnly: 'Read-only tools only',
toolWriteBadge: 'write',
toolListEmpty: 'No tools yet',
sessionsExportCount: '{count} sessions',
sessionsExportUnavailable: 'Export unavailable',
sessionsExportNoPersistence: 'Session store unavailable.',
@ -241,6 +253,12 @@ export const zh: Record<NetxopsLocaleKey, string> = {
toolsStatusEmpty: '暂无活跃工具',
toolsStatusError: '发布异常',
toolsStatusUnknown: '状态不可用',
mcpDisconnected: '未连接',
mcpRefresh: '刷新',
mcpRefreshing: '刷新中…',
readOnlyOnly: '只允许只读工具',
toolWriteBadge: '写',
toolListEmpty: '暂无工具清单',
sessionsExportCount: '{count} 个会话',
sessionsExportUnavailable: '无法导出',
sessionsExportNoPersistence: '会话存储不可用。',

View file

@ -0,0 +1,92 @@
/**
* Browser-side MCP catalog (RPC `tools.catalog` / `tools.refresh`).
*/
import type { AlarmPushRpcCall } from './alarm-push-status-view.ts'
import { NETXOPS_RPC_CHANNEL } from './tools-status-view.ts'
export const TOOLS_CATALOG_ENDPOINT = 'tools.catalog'
export const TOOLS_REFRESH_ENDPOINT = 'tools.refresh'
export interface McpToolRow {
name: string
description: string
readOnlyHint: boolean
group: 'ops' | 'topology' | 'bizMonitor' | null
}
export interface McpCatalogView {
phase: string
mcpUrl: string
error: string
listedAt: string | null
fromCache: boolean
tools: McpToolRow[]
}
const EMPTY: McpCatalogView = {
phase: 'idle',
mcpUrl: '',
error: '',
listedAt: null,
fromCache: false,
tools: [],
}
export function asMcpCatalogView(value: unknown): McpCatalogView {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return { ...EMPTY }
const row = value as Record<string, unknown>
const tools: McpToolRow[] = []
if (Array.isArray(row.tools)) {
for (const raw of row.tools) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue
const t = raw as Record<string, unknown>
const name = typeof t.name === 'string' ? t.name : ''
if (!name) continue
const group = t.group === 'ops' || t.group === 'topology' || t.group === 'bizMonitor'
? t.group
: null
tools.push({
name,
description: typeof t.description === 'string' ? t.description : '',
readOnlyHint: t.readOnlyHint === true,
group,
})
}
}
return {
phase: typeof row.phase === 'string' ? row.phase : 'idle',
mcpUrl: typeof row.mcpUrl === 'string' ? row.mcpUrl : '',
error: typeof row.error === 'string' ? row.error : '',
listedAt: typeof row.listedAt === 'string' ? row.listedAt : null,
fromCache: row.fromCache === true,
tools,
}
}
export async function fetchMcpCatalog(
call: AlarmPushRpcCall,
signal?: AbortSignal,
): Promise<McpCatalogView> {
const result = await call(NETXOPS_RPC_CHANNEL, TOOLS_CATALOG_ENDPOINT, {}, signal)
if (result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === true) {
return asMcpCatalogView((result as { value?: unknown }).value)
}
return { ...EMPTY }
}
export async function refreshMcpCatalogRemote(
call: AlarmPushRpcCall,
signal?: AbortSignal,
): Promise<McpCatalogView> {
const result = await call(NETXOPS_RPC_CHANNEL, TOOLS_REFRESH_ENDPOINT, {}, signal)
if (result !== null && typeof result === 'object') {
const row = result as { ok?: boolean; value?: unknown; error?: unknown }
const view = asMcpCatalogView(row.value)
if (typeof row.error === 'string' && row.error && !view.error) {
return { ...view, error: row.error, phase: view.phase === 'connected' ? view.phase : 'error' }
}
return view
}
return { ...EMPTY, phase: 'error', error: 'refresh_failed' }
}

View file

@ -48,6 +48,12 @@ const CSS = `
.dsh-nx-groupBlock+.dsh-nx-groupBlock{border-top:1px solid var(--dsw-alias-border-l1,#eef0f3)}
.dsh-nx-groupTitle{display:flex;align-items:center;gap:8px;font-size:13px;font-weight:600;line-height:1.5;color:var(--dsw-alias-label-primary,#1f2329)}
.dsh-nx-groupChecks{display:flex;flex-wrap:wrap;gap:8px 24px}
.dsh-nx-toolList{margin:0}
.dsh-nx-toolList>summary{font-size:12px;color:var(--dsw-alias-label-secondary,#646a73)}
.dsh-nx-toolRows{display:flex;flex-direction:column;gap:6px;padding:8px 0 0}
.dsh-nx-toolRow{align-items:flex-start}
.dsh-nx-toolMeta{display:flex;flex-direction:column;gap:2px;min-width:0}
.dsh-nx-toolName{display:inline-flex;align-items:center;gap:6px;font-size:13px;font-weight:500;overflow-wrap:anywhere}
.dsh-nx-targets{margin-top:0}
.dsh-nx-diagnostics{padding-top:12px;border-top:1px solid var(--dsw-alias-border-l1,#eef0f3)}
.dsh-nx-diagnostics[open] summary{margin-bottom:8px}

View file

@ -19,6 +19,9 @@ export interface ToolsMountView {
error?: string
tokenConfigured: boolean | null
apiUrl: string
mcpPhase: string
mcpError: string
mcpFromCache: boolean
}
const EMPTY: ToolsMountView = {
@ -30,6 +33,9 @@ const EMPTY: ToolsMountView = {
plane: '',
tokenConfigured: null,
apiUrl: '',
mcpPhase: 'idle',
mcpError: '',
mcpFromCache: false,
}
/** Normalize host `tools.status` RPC value. */
@ -83,7 +89,19 @@ export function asToolsMountView(value: unknown): ToolsMountView {
}
const toolsEnabled = !(connection && typeof connection === 'object'
&& (connection as Record<string, unknown>).toolsEnabled === false)
return { count, version, groups, plane, error, tokenConfigured, apiUrl, mounts, toolsEnabled }
let mcpPhase = 'idle'
let mcpError = ''
let mcpFromCache = false
if (row.mcp !== null && typeof row.mcp === 'object' && !Array.isArray(row.mcp)) {
const mcp = row.mcp as Record<string, unknown>
mcpPhase = typeof mcp.phase === 'string' ? mcp.phase : 'idle'
mcpError = typeof mcp.error === 'string' ? mcp.error : ''
mcpFromCache = mcp.fromCache === true
}
return {
count, version, groups, plane, error, tokenConfigured, apiUrl, mounts, toolsEnabled,
mcpPhase, mcpError, mcpFromCache,
}
}
/** Fetch Host tool mount snapshot. */
@ -101,7 +119,8 @@ export async function fetchToolsStatus(
/** Badge tone for the tools mount line. */
export function toolsMountTone(view: ToolsMountView | null): 'ok' | 'warn' | 'err' | 'neutral' {
if (!view) return 'neutral'
if (view.error) return 'err'
if (view.error || view.mcpPhase === 'error') return 'err'
if (view.mcpPhase === 'offline' || view.mcpFromCache) return 'warn'
if (!view.toolsEnabled) return 'neutral'
if (view.count > 0) return 'ok'
return 'warn'

View file

@ -97,6 +97,13 @@ import {
import { registerNetxTools } from './netx/tools.ts'
import { installPresetPublication } from './netx/preset-publication.ts'
import { readConfigSnapshot, readConfigValue, type ConfigInput } from './netx/config-values.ts'
import { disabledPublicNames, parseDisabledTools } from './netx/mcp-catalog.ts'
import {
getMcpCatalogState,
getMcpCatalogTools,
refreshMcpCatalog,
watchMcpCatalog,
} from './netx/mcp-store.ts'
// The event is absent from older peer declarations, where it is never emitted.
declare module '@deepseek-ai/cordis' {
@ -214,6 +221,14 @@ export interface Config {
groupKbInPreset: boolean
/** Publish subset `_skills/kb-*` to other presets (default off). */
groupKbPublic: boolean
/**
* JSON array of server tool names opted out by the operator.
* Unlisted tools follow their capability-group switch (new server tools auto-enable with the group).
* UX only — not a security boundary; netx `/mcp` enforces authz.
*/
disabledTools: string
/** When true, only register tools with annotations.readOnlyHint === true. */
readOnlyOnly: boolean
}
/**
@ -255,6 +270,8 @@ export const Config: z<Config, ConfigInput<Config>> = z.object({
kbRoot: vol(z.string().default('')),
groupKbInPreset: vol(z.boolean().default(true)),
groupKbPublic: vol(z.boolean().default(false)),
disabledTools: vol(z.string().default('[]')),
readOnlyOnly: vol(z.boolean().default(false)),
})
/** Merge settings projection without letting null/undefined wipe profile defaults. */
@ -538,6 +555,8 @@ export function apply(ctx: Context, config: ConfigInput<Config> = Config({} as C
lang: resolveLang(current.lang),
toolCallTimeoutMs: resolveToolCallTimeoutMs(current.toolCallTimeoutMs),
groups: capabilityGroupsFromSettings(current as NetxCapabilityGroupSettingsFields),
disabledTools: parseDisabledTools(current.disabledTools),
readOnlyOnly: current.readOnlyOnly === true,
groupKbInPreset: typeof current.groupKbInPreset === 'boolean'
? current.groupKbInPreset
: true,
@ -639,11 +658,13 @@ export function apply(ctx: Context, config: ConfigInput<Config> = Config({} as C
remountHostTools()
remountKbLocal()
})
const stopCatalogWatch = watchMcpCatalog(() => { remountHostTools() })
const stopKbWatch = watchKbContext(() => { remountKbLocal() })
remountHostTools()
remountKbLocal()
ctx.effect(() => () => {
stopWatch()
stopCatalogWatch()
stopKbWatch()
disposeToolsMount?.()
disposeKbLocal?.()
@ -746,6 +767,28 @@ export function apply(ctx: Context, config: ConfigInput<Config> = Config({} as C
}
restartAlarmPush(current, snapshot.apiUrl, token)
const tokenConfigured = token.trim().length > 0
// Refresh remote MCP catalog; failures stay on the card and keep last cache.
const catalog = await refreshMcpCatalog({
apiUrl: snapshot.apiUrl,
token,
lang: snapshot.lang,
timeoutMs: Math.min(snapshot.toolCallTimeoutMs, 45_000),
})
if (disposed || gen !== generation) return
if (catalog.phase === 'connected') {
ctx.logger.info(
'netxops: mcp catalog ok → %s tools=%s',
catalog.mcpUrl,
catalog.tools.length,
)
} else if (catalog.error) {
ctx.logger.warn(
'netxops: mcp catalog %s → %s (%s)',
catalog.phase,
catalog.mcpUrl || '(no url)',
catalog.error,
)
}
if (!tokenConfigured) {
ctx.logger.warn(
'netxops: published connection → %s tokenConfigured=false (set credential %s)',
@ -887,7 +930,22 @@ export function apply(ctx: Context, config: ConfigInput<Config> = Config({} as C
const connection = getNetxConnection()
if (!connection) return
const enabled = connection.toolsEnabled === false ? [] : groupsForPlane(connection.groups, 'host')
void registerGroupSkills(skillsCtx, enabled, 'netxops-host', skillAbort.signal).then((dispose) => {
const disabledByGroup = Object.fromEntries(
enabled.map((groupId) => [
groupId,
disabledPublicNames(
getMcpCatalogTools(),
[groupId],
connection.disabledTools,
connection.readOnlyOnly,
),
]),
)
void registerGroupSkills(skillsCtx, enabled, 'netxops-host', {
signal: skillAbort.signal,
disabledByGroup,
lang: connection.lang,
}).then((dispose) => {
if (gen !== generation) {
dispose()
return
@ -942,6 +1000,7 @@ export function apply(ctx: Context, config: ConfigInput<Config> = Config({} as C
remountKbSkill()
remountKbPack()
})
const stopCatalogWatch = watchMcpCatalog(() => { remountHostSkills() })
const stopKbWatch = watchKbContext(() => {
remountKbSkill()
remountKbPack()
@ -955,6 +1014,7 @@ export function apply(ctx: Context, config: ConfigInput<Config> = Config({} as C
skillAbort.abort()
packAbort.abort()
stopWatch()
stopCatalogWatch()
stopKbWatch()
unregisterSkills?.()
unregisterKbSkill?.()
@ -974,11 +1034,20 @@ export function apply(ctx: Context, config: ConfigInput<Config> = Config({} as C
if (endpoint === 'tools.status') {
const mount = getToolMountStatus()
const connection = getNetxConnection()
const catalog = getMcpCatalogState()
return {
ok: true,
value: {
mount: mount ?? null,
mounts: getToolMountStatuses(),
mcp: {
phase: catalog.phase,
mcpUrl: catalog.mcpUrl,
error: catalog.error,
listedAt: catalog.listedAt,
fromCache: catalog.fromCache,
toolCount: catalog.tools.length,
},
connection: connection
? {
apiUrl: connection.apiUrl,
@ -987,11 +1056,54 @@ export function apply(ctx: Context, config: ConfigInput<Config> = Config({} as C
hostGroups: connection.toolsEnabled === false ? [] : groupsForPlane(connection.groups, 'host'),
presetGroups: connection.toolsEnabled === false ? [] : groupsForPlane(connection.groups, 'preset'),
publicGroups: connection.toolsEnabled === false ? [] : groupsForPlane(connection.groups, 'public'),
disabledTools: [...(connection.disabledTools ?? [])],
readOnlyOnly: connection.readOnlyOnly === true,
}
: null,
},
}
}
if (endpoint === 'tools.catalog') {
const catalog = getMcpCatalogState()
return {
ok: true,
value: {
phase: catalog.phase,
mcpUrl: catalog.mcpUrl,
error: catalog.error,
listedAt: catalog.listedAt,
fromCache: catalog.fromCache,
tools: catalog.tools.map((tool) => ({
name: tool.name,
description: tool.description,
readOnlyHint: tool.readOnlyHint,
group: tool.group ?? null,
})),
},
}
}
if (endpoint === 'tools.refresh') {
const connection = getNetxConnection()
if (!connection) return { ok: false, error: 'connection_unavailable' }
const catalog = await refreshMcpCatalog({
apiUrl: connection.apiUrl,
token: connection.token,
lang: connection.lang,
timeoutMs: Math.min(connection.toolCallTimeoutMs, 45_000),
})
return {
ok: catalog.phase === 'connected',
value: {
phase: catalog.phase,
mcpUrl: catalog.mcpUrl,
error: catalog.error,
listedAt: catalog.listedAt,
fromCache: catalog.fromCache,
toolCount: catalog.tools.length,
},
...(catalog.error ? { error: catalog.error } : {}),
}
}
if (endpoint === 'alarm-push.status') {
return { ok: true, value: getAlarmPushStatus() }
}

View file

@ -50,55 +50,9 @@ export const DEFAULT_CAPABILITY_GROUPS: NetxCapabilityGroups = Object.freeze({
})
/**
* Model-facing tool names (`netx__*`) owned by each group.
* NMS tools use generic `Nms` names; HTTP still hits the configured NMS provider adapter.
* Tool membership is no longer hardcoded here — it comes from netx `/mcp`
* `_meta.netx.group`. See `mcp-catalog.ts` / `filterCatalogTools`.
*/
export const TOOLS_BY_GROUP: Readonly<Record<NetxCapabilityGroupId, readonly string[]>> = Object.freeze({
ops: Object.freeze([
'netx__queryNmsAlarms',
'netx__aggregateNmsAlarms',
'netx__runNmsDiagnostics',
'netx__queryNmsNeInventory',
'netx__getNmsNe',
'netx__queryNmsAlarmsRaw',
'netx__aggregateNmsAlarmsRaw',
'netx__listNmsAlarmFields',
'netx__sqlQueryNms',
'netx__listManagedNe',
'netx__getManagedNe',
'netx__execManagedNe',
'netx__getNeExecJob',
'netx__listCliTargets',
'netx__findTopologyPaths',
]),
topology: Object.freeze([
'netx__getTopologyTree',
'netx__getTopologyView',
'netx__createTopologyFolder',
'netx__addTopologyViewNodes',
'netx__removeTopologyViewNodes',
'netx__copyTopologyViewNodes',
'netx__updateTopologyViewPositions',
'netx__projectTopologyNeighbors',
'netx__queryTopologyFabricNodes',
'netx__classifyTopologyFabricNodes',
'netx__queryTopologyNeighborhood',
'netx__queryTopologyEdges',
'netx__layoutTopologyView',
'netx__suggestSinkHubs',
'netx__analyzeTopologyViewLayout',
]),
bizMonitor: Object.freeze([
'netx__listBizMonitors',
'netx__listBizMonitorBatches',
'netx__getBizMonitorContext',
'netx__getBizMonitorBoard',
'netx__listBizMonitorReds',
'netx__getBizMonitorDiffs',
'netx__getBizCollectBatch',
'netx__getBizCollectCommandRaw',
]),
})
/** Skill directory name under skills root / `presets/netxops/skills/<group>/`. */
export const SKILL_DIR_BY_GROUP: Readonly<Record<NetxCapabilityGroupId, string>> = Object.freeze({
@ -244,17 +198,6 @@ export function groupsForced(
return CAPABILITY_GROUP_IDS.filter((id) => normalized.includes(id))
}
/**
* Tool name allow-list for the given groups.
*/
export function toolNamesForGroups(groupIds: readonly NetxCapabilityGroupId[]): Set<string> {
const out = new Set<string>()
for (const id of groupIds) {
for (const name of TOOLS_BY_GROUP[id] ?? []) out.add(name)
}
return out
}
/** Parse the persisted preset-id list. Empty is an explicit empty selection. */
export function parsePresetIds(value: unknown): string[] {
const current = readConfigValue(value)

View file

@ -189,8 +189,31 @@ async function loadGroupSkills(
return { skills, skipped }
}
/** Per-group model-facing tool names (`netx__*`) that are off while the group skill still mounts. */
export type DisabledToolsByGroup = Partial<Record<NetxCapabilityGroupId, readonly string[]>>
export interface RegisterGroupSkillsOptions {
signal?: AbortSignal
/**
* When a group is enabled but some tools are filtered out (disabledTools /
* readOnlyOnly), append a short "do not call" footnote listing public names.
*/
disabledByGroup?: DisabledToolsByGroup
lang?: string
}
function disabledFootnote(names: readonly string[], lang?: string): string {
if (names.length === 0) return ''
const list = names.join(', ')
const en = (lang ?? '').trim().toLowerCase().startsWith('en')
return en
? `\n\n## Disabled tools\nThe following tools are disabled: ${list}. Do not call them.\n`
: `\n\n## 已禁用工具\n以下工具已禁用:${list},不要调用。\n`
}
/**
* Register playbook skills for the given capability groups into `ctx.skills`.
* Groups that are off are skipped entirely (no skill injection).
* @param ctx - context with a skills registry (preset or host layer).
* @param groupIds - enabled groups for this plane.
* @param providerLabel - `provider` stamp on each registration.
@ -200,8 +223,13 @@ export async function registerGroupSkills(
ctx: Context,
groupIds: readonly NetxCapabilityGroupId[],
providerLabel: string,
signal?: AbortSignal,
signalOrOptions?: AbortSignal | RegisterGroupSkillsOptions,
): Promise<() => void> {
const options: RegisterGroupSkillsOptions = signalOrOptions instanceof AbortSignal
|| signalOrOptions === undefined
? { signal: signalOrOptions }
: signalOrOptions
const signal = options.signal
const skillsApi = (ctx as { skills?: { register: (skill: {
name: string
description: string
@ -235,13 +263,15 @@ export async function registerGroupSkills(
join(root, SKILL_DIR_BY_GROUP[groupId]),
)
}
const footnote = disabledFootnote(options.disabledByGroup?.[groupId] ?? [], options.lang)
for (const skill of skills) {
const base = groupId === 'topology'
? `${skill.content}\n\n## DeepSeek Harness REST capabilities\nUse only tools present in this session. Dual-unit/orbit/polish recipes require server-side topology tools. Use only tools present in this session.\n`
: skill.content
disposers.push(skillsApi.register({
name: skill.name,
description: skill.description,
content: groupId === 'topology'
? `${skill.content}\n\n## DeepSeek Harness REST capabilities\nUse only tools present in this session. Native layout supports catalog and move_nodes/sink_nodes. Dual-unit/orbit/polish recipes require a separately installed netx-topology MCP. Native analyzeTopologyViewLayout reports basic graph statistics; suggestSinkHubs uses degree ranking. Native sinkTopologyDualUnits is unavailable.\n`
: skill.content,
content: `${base}${footnote}`,
path: skill.path,
resourceBase: { kind: 'directory', path: skill.directory },
provider: providerLabel,

View file

@ -1,5 +1,8 @@
/**
* Shared agent-plane mount: tools + skills for one or more capability groups.
*
* Tools come from the cached netx `/mcp` catalog. Plugin switches only control
* model visibility — not security.
*/
import { readFileSync } from 'node:fs'
@ -12,7 +15,9 @@ import {
kbEnabledForPlane,
type NetxCapabilityGroupId,
} from './capability-groups.ts'
import { registerGroupSkills } from './group-skills.ts'
import { disabledPublicNames } from './mcp-catalog.ts'
import { getMcpCatalogTools, watchMcpCatalog } from './mcp-store.ts'
import { registerGroupSkills, type DisabledToolsByGroup } from './group-skills.ts'
import { registerKbContextSkill } from './kb-context-skill.ts'
import { registerKbLocalTools } from './kb-local-tools.ts'
import { registerKbPackSkills } from './kb-pack-skills.ts'
@ -49,6 +54,24 @@ export interface GroupToolsPluginOptions {
presetId?: string
}
function disabledByGroupFor(
groupIds: readonly NetxCapabilityGroupId[],
connection: ReturnType<typeof requireNetxConnection>,
): DisabledToolsByGroup {
const catalog = getMcpCatalogTools()
const out: DisabledToolsByGroup = {}
for (const groupId of groupIds) {
const names = disabledPublicNames(
catalog,
[groupId],
connection.disabledTools,
connection.readOnlyOnly,
)
if (names.length > 0) out[groupId] = names
}
return out
}
/**
* Apply a group-scoped tools/skills plugin on the calling (usually preset) context.
*/
@ -75,19 +98,20 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt
const connection = requireNetxConnection()
const enabled = resolveGroups()
try {
// Always call register (incl. empty) so revoke publishes count=0 and matches host.
unregisterTools = registerNetxTools(ctx, connection, {
plane: 'preset',
forceGroups: enabled,
source: options.name,
presetId: presetId(),
})
ctx.logger.info(
'%s: groups=[%s] → %s tokenConfigured=%s live=%s',
'%s: groups=[%s] → %s tokenConfigured=%s live=%s catalog=%s',
options.name,
enabled.join(',') || '(none)',
connection.apiUrl,
connection.token.trim().length > 0,
getNetxConnection() !== undefined,
getMcpCatalogTools().length,
)
} catch (error) {
publishToolMountStatus({
@ -113,17 +137,17 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt
unregisterKbLocal = registerKbLocalTools(ctx, getKbContext(), { plane: 'preset', source: `${options.name}:kb-local` })
}
// Subscribe BEFORE the first remount. Host publish is async (credentials
// resolve); if it lands between remount and watch, tools stay empty forever.
const stopToolWatch = watchNetxConnection(() => {
remountTools()
remountKbLocal()
})
const stopCatalogWatch = watchMcpCatalog(() => {
remountTools()
})
const stopKbToolWatch = watchKbContext(() => { remountKbLocal() })
remountTools()
remountKbLocal()
// Backup poll: covers publish that raced before watch, and slow credentials.
let pollTicks = 0
const pollTimer = setInterval(() => {
pollTicks += 1
@ -133,7 +157,7 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt
clearInterval(pollTimer)
return
}
if (pollTicks >= 40) clearInterval(pollTimer) // ~20s
if (pollTicks >= 40) clearInterval(pollTimer)
}, 500)
ctx.inject(['skills'], (skillsCtx) => {
@ -148,8 +172,13 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt
skillAbort = new AbortController()
unregisterSkills?.()
unregisterSkills = undefined
const connection = requireNetxConnection()
const enabled = resolveGroups()
void registerGroupSkills(skillsCtx, enabled, options.name, skillAbort.signal).then((dispose) => {
void registerGroupSkills(skillsCtx, enabled, options.name, {
signal: skillAbort.signal,
disabledByGroup: disabledByGroupFor(enabled, connection),
lang: connection.lang,
}).then((dispose) => {
if (gen !== skillGeneration) {
dispose()
return
@ -194,6 +223,7 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt
remountKbSkill()
remountKbPack()
})
const stopSkillCatalogWatch = watchMcpCatalog(() => { remountSkills() })
const stopKbWatch = watchKbContext(() => {
remountKbSkill()
remountKbPack()
@ -207,6 +237,7 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt
skillAbort.abort()
packAbort.abort()
stopSkillWatch()
stopSkillCatalogWatch()
stopKbWatch()
unregisterSkills?.()
unregisterSkills = undefined
@ -220,6 +251,7 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt
ctx.effect(() => () => {
clearInterval(pollTimer)
stopToolWatch()
stopCatalogWatch()
stopKbToolWatch()
unregisterTools?.()
unregisterTools = undefined

View file

@ -1,841 +0,0 @@
/**
* netx REST tool handlers — port of packages/netx-mcp http_tools.py.
*/
import { type NetxClient, quoteNeId, type NetxJson } from './http.ts'
import { omitUndefined } from './json-safe.ts'
const EXEC_MAX_COMMANDS_CAP = 50
const EXEC_MAX_COMMANDS_DEFAULT = 5
function execMaxCommands(): number {
const raw = Number.parseInt(String(process.env.NETX_NE_EXEC_MAX_COMMANDS ?? ''), 10)
const n = Number.isFinite(raw) ? raw : EXEC_MAX_COMMANDS_DEFAULT
return Math.max(1, Math.min(EXEC_MAX_COMMANDS_CAP, n))
}
function asyncMinNes(): number {
const raw = Number.parseInt(String(process.env.NETX_NE_EXEC_ASYNC_MIN_NES ?? ''), 10)
const n = Number.isFinite(raw) ? raw : 4
return Math.max(0, Math.min(50, n))
}
function truthyAsyncFlag(raw: unknown): boolean | undefined {
if (raw === undefined || raw === null) return undefined
if (typeof raw === 'boolean') return raw
const text = String(raw).trim().toLowerCase()
if (!text) return undefined
if (['1', 'true', 'yes', 'on'].includes(text)) return true
if (['0', 'false', 'no', 'off'].includes(text)) return false
return undefined
}
function countExecNeTargets(args: NetxJson): number {
let n = 0
for (const key of ['ne_ids', 'nms_ne_ids', 'ume_ne_ids'] as const) {
const val = strList(args, key)
if (val.length > 0) n = Math.max(n, val.length)
}
const targets = args.targets
if (Array.isArray(targets)) {
n = Math.max(n, targets.filter(t => typeof t === 'object' && t !== null && !Array.isArray(t)).length)
}
if (n === 0) {
if (str(args, 'ne_id').trim() || nmsOrUme(args, 'nms_ne_id', 'ume_ne_id')) return 1
}
return n
}
function shouldRunExecAsync(args: NetxJson): boolean {
const flag = truthyAsyncFlag(args.async)
if (flag === false) return false
if (flag === true) return true
const minN = asyncMinNes()
if (minN <= 0) return false
return countExecNeTargets(args) >= minN
}
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
}
/** Prefer generic `nms_*` aliases; fall back to historical `ume_*` wire names. */
function nmsOrUme(args: NetxJson, nmsKey: string, umeKey: string): string {
return str(args, nmsKey).trim() || str(args, umeKey).trim()
}
function nmsOrUmeList(args: NetxJson, nmsKey: string, umeKey: string): string[] {
const primary = strList(args, nmsKey)
return primary.length > 0 ? primary : strList(args, umeKey)
}
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 NMS inventory UUIDs use execManagedNe(nms_ne_id=...) or getNmsNe, 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 an NMS inventory id, use execManagedNe(nms_ne_id=...) / getNmsNe instead of getManagedNe.',
}
}
}
return out
}
export async function execManagedNe(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const maxCmds = execMaxCommands()
const wantAsync = shouldRunExecAsync(args)
const targetsRaw = args.targets
const neIds = strList(args, 'ne_ids')
const umeNeIds = nmsOrUmeList(args, 'nms_ne_ids', '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 = nmsOrUme(row, 'nms_ne_id', 'ume_ne_id')
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 > maxCmds) {
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)
if (wantAsync) {
const out = await client.post('/v1/managed-ne/exec-jobs', body, signal, 60_000)
if (out.ok !== true) return out
const data = asRecord(out.data)
return Object.keys(data).length > 0 ? data : out
}
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 = nmsOrUme(args, 'nms_ne_id', 'ume_ne_id')
if (Boolean(neId) === Boolean(umeNeId)) {
return {
ok: false,
error: 'exactly_one_of_ne_id_or_nms_ne_id_required',
error_code: 'exactly_one_of_ne_id_or_nms_ne_id_required',
hint: 'For one NE pass ne_id OR nms_ne_id (alias ume_ne_id). For many NEs pass ne_ids / nms_ne_ids with shared commands, or targets[] — one call, concurrent on server.',
}
}
if (sharedCommands.length === 0) {
return { ok: false, error: 'commands_required', error_code: 'commands_required' }
}
if (sharedCommands.length > maxCmds) {
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
if (wantAsync) {
const out = await client.post('/v1/managed-ne/exec-jobs', body, signal, 60_000)
if (out.ok !== true) return out
const data = asRecord(out.data)
return Object.keys(data).length > 0 ? data : out
}
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 getNeExecJob(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const jobId = str(args, 'job_id').trim()
if (!jobId) {
return {
ok: false,
error: 'job_id_required',
error_code: 'job_id_required',
hint: 'Pass job_id from execManagedNe async ack.',
}
}
return client.get(`/v1/managed-ne/exec-jobs/${quoteNeId(jobId)}`, undefined, signal)
}
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 = nmsOrUme(args, 'from_nms_ne_id', 'from_ume_ne_id')
const fromMid = str(args, 'from_managed_ne_id').trim()
const toUid = nmsOrUme(args, 'to_nms_ne_id', 'to_ume_ne_id')
const toMid = str(args, 'to_managed_ne_id').trim()
if (Boolean(fromUid) === Boolean(fromMid)) {
return { ok: false, error: 'exactly_one_of_from_nms_ne_id_or_from_managed_ne_id_required' }
}
if (Boolean(toUid) === Boolean(toMid)) {
return { ok: false, error: 'exactly_one_of_to_nms_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)
}
// ── bizMonitor group (cutover / biz_state read) ─────────────────────────────
function taskBriefNe(brief: unknown): string {
const rec = asRecord(brief)
const name = typeof rec.ne_name === 'string' ? rec.ne_name.trim() : ''
const ip = typeof rec.ne_ip === 'string' ? rec.ne_ip.trim() : ''
if (name && ip) return `${name} (${ip})`
return name || ip
}
function slimMigrationProject(row: NetxJson): NetxJson {
const mt = asRecord(row.monitor_template)
return omitUndefined({
id: row.id,
name: row.name,
status: row.status,
note: row.note,
monitor_template_id: row.monitor_template_id,
monitor_template_name: typeof mt.name === 'string' ? mt.name : '',
old_task_id: row.old_task_id,
new_task_id: row.new_task_id,
old_hf_task_id: row.old_hf_task_id,
new_hf_task_id: row.new_hf_task_id,
old_ne: taskBriefNe(row.old_task),
new_ne: taskBriefNe(row.new_task),
hf_interval_sec: row.hf_interval_sec,
hf_start_at: row.hf_start_at,
hf_end_at: row.hf_end_at,
updated_at: row.updated_at,
})
}
function matchesBizMonitorQ(row: NetxJson, q: string): boolean {
if (!q) return true
const hay = [
row.id, row.name, row.note, row.status, row.ne_name, row.ne_ip, row.ne_id,
row.old_ne, row.new_ne, row.monitor_template_name, row.purpose, row.vendor,
]
.map((v) => (typeof v === 'string' || typeof v === 'number' ? String(v).toLowerCase() : ''))
.join(' ')
return hay.includes(q)
}
/**
* Catalog entry: list cutover projects and/or biz_state tasks (no project_id required).
* Use this first when the user did not give an id; then call getBizMonitorContext.
*/
export async function listBizMonitors(
client: NetxClient,
args: NetxJson,
signal?: AbortSignal,
): Promise<NetxJson> {
const kindRaw = str(args, 'kind', 'all').trim().toLowerCase() || 'all'
const kind = kindRaw === 'project' ? 'projects'
: kindRaw === 'task' ? 'tasks'
: kindRaw
if (kind !== 'all' && kind !== 'projects' && kind !== 'tasks') {
return { ok: false, error: 'kind_invalid', detail: 'kind must be all|projects|tasks' }
}
const purpose = str(args, 'purpose').trim()
const status = str(args, 'status').trim().toLowerCase()
const q = str(args, 'q').trim().toLowerCase()
const limit = clampInt(num(args, 'limit') ?? 100, 100, 1, 500)
const wantProjects = kind === 'all' || kind === 'projects'
const wantTasks = kind === 'all' || kind === 'tasks'
const fetches: Array<Promise<NetxJson>> = []
if (wantProjects) fetches.push(client.get('/v1/biz-migration/projects', {}, signal))
else fetches.push(Promise.resolve({ ok: true, data: { items: [] } }))
if (wantTasks) {
const params: Record<string, string | number | boolean> = {}
if (purpose) params.purpose = purpose
fetches.push(client.get('/v1/biz-state/tasks', params, signal))
} else {
fetches.push(Promise.resolve({ ok: true, data: { items: [] } }))
}
const [projRes, taskRes] = await Promise.all(fetches)
if (wantProjects && projRes.ok === false) return projRes
if (wantTasks && taskRes.ok === false) return taskRes
const projItems = Array.isArray(asRecord(projRes.data).items)
? (asRecord(projRes.data).items as unknown[])
: []
const taskItems = Array.isArray(asRecord(taskRes.data).items)
? (asRecord(taskRes.data).items as unknown[])
: []
let projects = projItems
.map((row) => slimMigrationProject(asRecord(row)))
.filter((row) => {
if (status && String(row.status || '').toLowerCase() !== status) return false
return matchesBizMonitorQ(row, q)
})
let tasks = taskItems
.map((row) => asRecord(row))
.filter((row) => {
if (status && String(row.status || '').toLowerCase() !== status) return false
return matchesBizMonitorQ(row, q)
})
const projectsTotal = projects.length
const tasksTotal = tasks.length
projects = projects.slice(0, limit)
tasks = tasks.slice(0, limit)
return {
ok: true,
data: omitUndefined({
kind,
projects: wantProjects ? projects : undefined,
tasks: wantTasks ? tasks : undefined,
counts: {
projects: wantProjects ? projectsTotal : 0,
tasks: wantTasks ? tasksTotal : 0,
projects_returned: wantProjects ? projects.length : 0,
tasks_returned: wantTasks ? tasks.length : 0,
},
next: 'Pass project_id (or task_id) to netx__getBizMonitorContext; then listBizMonitorBatches for batch_id / run_id.',
}),
}
}
function slimCutoverBatch(row: NetxJson): NetxJson {
return omitUndefined({
id: row.id,
project_id: row.project_id,
batch_label: row.batch_label,
status: row.status,
accept_status: row.accept_status,
accept_run_id: row.accept_run_id,
started_at: row.started_at,
ended_at: row.ended_at,
note: row.note,
created_at: row.created_at,
updated_at: row.updated_at,
})
}
function slimCollectBatch(row: NetxJson, taskId = ''): NetxJson {
return omitUndefined({
id: row.id,
task_id: row.task_id || taskId || undefined,
status: row.status,
ne_name: row.ne_name,
command_count: row.command_count,
row_count: row.row_count,
is_baseline: row.is_baseline,
protected: row.protected,
message: row.message,
started_at: row.started_at,
ended_at: row.ended_at,
})
}
function slimEvalRun(row: NetxJson): NetxJson {
const summary = asRecord(row.summary)
const progress = asRecord(summary.progress)
return omitUndefined({
id: row.id,
batch_id: row.batch_id,
purpose: row.purpose,
old_batch_id: row.old_batch_id,
new_batch_id: row.new_batch_id,
created_at: row.created_at,
anomaly: summary.anomaly,
progress_ok: progress.ok ?? summary.progress_ok,
progress_total: progress.total ?? summary.progress_total,
})
}
/**
* List cutover batches, biz_state collect batches, and/or evaluate runs.
* Needs project_id and/or task_id and/or cutover batch_id (for runs).
*/
export async function listBizMonitorBatches(
client: NetxClient,
args: NetxJson,
signal?: AbortSignal,
): Promise<NetxJson> {
const projectId = str(args, 'project_id').trim()
const taskId = str(args, 'task_id').trim()
const batchId = str(args, 'batch_id').trim()
const kindRaw = str(args, 'kind', 'auto').trim().toLowerCase() || 'auto'
const includeRuns = args.include_runs === true || args.include_runs === 'true' || args.include_runs === 1
const status = str(args, 'status').trim().toLowerCase()
const limit = clampInt(num(args, 'limit') ?? 50, 50, 1, 200)
const runsLimit = clampInt(num(args, 'runs_limit') ?? 10, 10, 1, 50)
let kind = kindRaw
if (kind === 'auto') {
if (batchId && !projectId && !taskId) kind = 'runs'
else if (projectId && !taskId) kind = 'cutover'
else if (taskId && !projectId) kind = 'collect'
else if (projectId && taskId) kind = 'all'
else if (batchId) kind = 'runs'
else {
return {
ok: false,
error: 'project_id_or_task_id_or_batch_id_required',
detail: 'Pass project_id (cutover batches), task_id (collect batches), and/or batch_id (evaluate runs).',
}
}
}
if (!['auto', 'all', 'cutover', 'collect', 'runs'].includes(kind)) {
return { ok: false, error: 'kind_invalid', detail: 'kind must be auto|all|cutover|collect|runs' }
}
const wantCutover = kind === 'all' || kind === 'cutover'
const wantCollect = kind === 'all' || kind === 'collect'
const wantRuns = kind === 'runs' || (includeRuns && (wantCutover || Boolean(batchId)))
if (wantCutover && !projectId) {
return { ok: false, error: 'project_id_required', detail: 'kind=cutover|all needs project_id' }
}
if (wantCollect && !taskId) {
return { ok: false, error: 'task_id_required', detail: 'kind=collect|all needs task_id' }
}
if (kind === 'runs' && !batchId) {
return { ok: false, error: 'batch_id_required', detail: 'kind=runs needs cutover batch_id' }
}
let cutoverBatches: NetxJson[] = []
let collectBatches: NetxJson[] = []
let runs: NetxJson[] = []
if (wantCutover) {
const res = await client.get(
`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/batches`,
{},
signal,
)
if (res.ok === false) return res
const items = Array.isArray(asRecord(res.data).items)
? (asRecord(res.data).items as unknown[])
: []
cutoverBatches = items
.map((row) => slimCutoverBatch(asRecord(row)))
.filter((row) => {
if (batchId && String(row.id || '') !== batchId) return false
if (status && String(row.status || '').toLowerCase() !== status) return false
return true
})
.slice(0, limit)
}
if (wantCollect) {
const res = await client.get(
`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/batches`,
{ limit },
signal,
)
if (res.ok === false) return res
const items = Array.isArray(asRecord(res.data).items)
? (asRecord(res.data).items as unknown[])
: []
collectBatches = items
.map((row) => slimCollectBatch(asRecord(row), taskId))
.filter((row) => {
if (status && String(row.status || '').toLowerCase() !== status) return false
return true
})
.slice(0, limit)
}
if (wantRuns) {
const runBatchIds = kind === 'runs'
? [batchId]
: cutoverBatches.map((b) => String(b.id || '').trim()).filter(Boolean).slice(0, includeRuns ? 5 : 0)
if (kind === 'runs' && batchId) {
const res = await client.get(
`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/runs`,
{ limit: runsLimit },
signal,
)
if (res.ok === false) return res
const items = Array.isArray(asRecord(res.data).items)
? (asRecord(res.data).items as unknown[])
: []
runs = items.map((row) => slimEvalRun(asRecord(row))).slice(0, runsLimit)
} else if (includeRuns && runBatchIds.length > 0) {
const settled = await Promise.all(
runBatchIds.map(async (id) => {
const res = await client.get(
`/v1/biz-migration/batches/${encodeURIComponent(id)}/runs`,
{ limit: runsLimit },
signal,
)
if (res.ok === false) return { batch_id: id, error: res, items: [] as NetxJson[] }
const items = Array.isArray(asRecord(res.data).items)
? (asRecord(res.data).items as unknown[])
: []
return {
batch_id: id,
items: items.map((row) => slimEvalRun(asRecord(row))).slice(0, runsLimit),
}
}),
)
for (const block of settled) {
if ('error' in block && block.error) {
// Keep listing batches even if one runs fetch fails; attach error note.
const failed = cutoverBatches.find((b) => b.id === block.batch_id)
if (failed) failed.runs_error = asRecord(block.error).error || 'runs_fetch_failed'
continue
}
const target = cutoverBatches.find((b) => b.id === block.batch_id)
if (target) target.runs = block.items
for (const r of block.items) runs.push(r)
}
}
}
return {
ok: true,
data: omitUndefined({
kind,
project_id: projectId || undefined,
task_id: taskId || undefined,
batch_id: batchId || undefined,
cutover_batches: wantCutover ? cutoverBatches : undefined,
collect_batches: wantCollect ? collectBatches : undefined,
runs: kind === 'runs' ? runs : undefined,
counts: {
cutover_batches: cutoverBatches.length,
collect_batches: collectBatches.length,
runs: runs.length,
},
next: wantCutover
? 'Use cutover batch id with netx__getBizMonitorBoard / listBizMonitorBatches(kind=runs). Collect batch id → netx__getBizCollectBatch.'
: wantCollect
? 'Pass collect batch id to netx__getBizCollectBatch / getBizCollectCommandRaw.'
: 'Pass run_id to netx__getBizMonitorDiffs or getBizMonitorBoard(run_id).',
}),
}
}
/** Fat cutover / biz_state definition bundle (templates, mapping, tasks). */
export async function getBizMonitorContext(
client: NetxClient,
args: NetxJson,
signal?: AbortSignal,
): Promise<NetxJson> {
const projectId = str(args, 'project_id').trim()
const taskId = str(args, 'task_id').trim()
if (!projectId && !taskId) {
return { ok: false, error: 'project_id_or_task_id_required' }
}
const params: Record<string, string | number | boolean> = {}
if (projectId) params.project_id = projectId
if (taskId) params.task_id = taskId
return client.get('/v1/biz-migration/monitor-context', params, signal)
}
/** Cutover batch board + latest evaluate run summary. */
export async function getBizMonitorBoard(
client: NetxClient,
args: NetxJson,
signal?: AbortSignal,
): Promise<NetxJson> {
const batchId = str(args, 'batch_id').trim()
if (!batchId) return { ok: false, error: 'batch_id_required' }
const params: Record<string, string | number | boolean> = {}
const runId = str(args, 'run_id').trim()
if (runId) params.run_id = runId
return client.get(`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/board`, params, signal)
}
/** Red tickets with evidence (raw A/B + show command). */
export async function listBizMonitorReds(
client: NetxClient,
args: NetxJson,
signal?: AbortSignal,
): Promise<NetxJson> {
const projectId = str(args, 'project_id').trim()
if (!projectId) return { ok: false, error: 'project_id_required' }
const params: Record<string, string | number | boolean> = {}
putStr(params, args, ['status'])
const limit = num(args, 'limit')
if (limit !== undefined) params.limit = clampInt(limit, 200, 1, 500)
return client.get(
`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/red-tickets`,
params,
signal,
)
}
/** Paged evaluate diffs (old_key/new_key = device-raw A/B). */
export async function getBizMonitorDiffs(
client: NetxClient,
args: NetxJson,
signal?: AbortSignal,
): Promise<NetxJson> {
const runId = str(args, 'run_id').trim()
if (!runId) return { ok: false, error: 'run_id_required' }
const params: Record<string, string | number | boolean> = {}
putStr(params, args, ['metric_id', 'sheet_id', 'verdict', 'color', 'q'])
const limit = num(args, 'limit')
if (limit !== undefined) params.limit = clampInt(limit, 200, 1, 500)
const offset = num(args, 'offset')
if (offset !== undefined) params.offset = clampInt(offset, 0, 0, Number.MAX_SAFE_INTEGER)
return client.get(`/v1/biz-migration/runs/${encodeURIComponent(runId)}/diffs`, params, signal)
}
/** One biz_state collect batch (commands + metrics). */
export async function getBizCollectBatch(
client: NetxClient,
args: NetxJson,
signal?: AbortSignal,
): Promise<NetxJson> {
const batchId = str(args, 'batch_id').trim()
if (!batchId) return { ok: false, error: 'batch_id_required' }
return client.get(`/v1/biz-state/batches/${encodeURIComponent(batchId)}`, {}, signal)
}
/** Full CLI raw_text for one collect command. */
export async function getBizCollectCommandRaw(
client: NetxClient,
args: NetxJson,
signal?: AbortSignal,
): Promise<NetxJson> {
const batchId = str(args, 'batch_id').trim()
const commandId = str(args, 'command_id').trim()
if (!batchId || !commandId) {
return { ok: false, error: 'batch_id_and_command_id_required' }
}
return client.get(
`/v1/biz-state/batches/${encodeURIComponent(batchId)}/commands/${encodeURIComponent(commandId)}`,
{},
signal,
)
}

View file

@ -54,6 +54,8 @@ export const NETXOPS_HOST_CONFIG_DEFAULTS: Readonly<Record<string, string | bool
kbRoot: '',
groupKbInPreset: true,
groupKbPublic: false,
disabledTools: '[]',
readOnlyOnly: false,
})
export interface HostBridgeHealResult {

View file

@ -1,17 +1,8 @@
/**
* Minimal netx REST client (Bearer + lang), aligned with netx-mcp http_client.
* Shared URL helpers for netx connection settings / alarm push.
* Tool calls go through `/mcp` (see mcp-remote.ts); REST handlers were removed.
*/
export interface NetxConnection {
apiUrl: string
lang: string
timeoutMs: number
/** Read the latest bearer on each request (do not freeze at client creation). */
getToken: () => string
/** Read the latest apiUrl on each request (settings may heal after first mount). */
getApiUrl?: () => string
}
export type NetxJson = Record<string, unknown>
export type NormalizeApiUrlResult =
@ -41,140 +32,3 @@ export function normalizeApiUrl(raw: string): NormalizeApiUrlResult {
const search = parsed.search && parsed.search !== '?' ? parsed.search : ''
return { ok: true, url: `${parsed.origin}${path}${search}` }
}
const PROTOCOL_KEY_ZH_TO_EN: Record<string, string> = {
其他: 'Other',
时钟: 'Clock',
'OTN/光': 'OTN/Optical',
电源: 'Power',
}
function asLang(value: unknown): string {
return typeof value === 'string' ? value : ''
}
/** Localize a few protocol bucket keys when lang starts with en (match netx-mcp). */
function localizePayload(lang: string, data: NetxJson): NetxJson {
if (!asLang(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 apiUrl / lang / timeout; Bearer + apiUrl are resolved per request.
* @param connection - apiUrl / lang / timeout / getToken / optional getApiUrl.
* @returns get/post helpers that return `{ ok, data }` or `{ ok: false, error }`.
*/
export function createNetxClient(connection: NetxConnection) {
const langParams = (): Record<string, string> => {
const lang = asLang(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 rawUrl = typeof connection.getApiUrl === 'function'
? connection.getApiUrl()
: connection.apiUrl
const normalized = normalizeApiUrl(typeof rawUrl === 'string' ? rawUrl : '')
const base = normalized.ok ? normalized.url : ''
if (!normalized.ok || !base) {
return {
ok: false,
error: 'netx_api_url_invalid',
detail: `apiUrl must be http(s) without embedded credentials (${normalized.ok ? 'empty' : normalized.error}).`,
}
}
const rawToken = connection.getToken()
const token = typeof rawToken === 'string' ? rawToken.trim() : ''
if (token.length === 0) {
return {
ok: false,
error: 'netx_token_missing',
detail: 'Set credential NETX_API_TOKEN (Plugins → Netx Ops or scripts/set-netx-token).',
}
}
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 headers: Record<string, string> = {
accept: 'application/json',
authorization: `Bearer ${token}`,
}
if (options.body !== undefined) headers['content-type'] = 'application/json'
const init: RequestInit = {
method,
headers,
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 })
},
patch(path: string, body: NetxJson, signal?: AbortSignal, timeoutMs?: number) {
return request('PATCH', 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())
}

138
src/netx/mcp-catalog.ts Normal file
View file

@ -0,0 +1,138 @@
/**
* Filter remote MCP tools for DSH registration.
*
* Security note: these filters only hide tools from the model. Real authz is
* enforced by netx `/mcp` (token RBAC + CLI guards).
*/
import type { NetxCapabilityGroupId, NetxCapabilityGroups } from './capability-groups.ts'
import { groupsForPlane, groupsForPreset } from './capability-groups.ts'
import type { NetxMcpToolDescriptor } from './mcp-types.ts'
import { publicToolName } from './mcp-types.ts'
export interface ToolFilterPolicy {
toolsEnabled?: boolean
groups?: NetxCapabilityGroups
/** Server tool names the operator turned off (opt-out). */
disabledTools?: readonly string[]
/** When true, drop tools whose readOnlyHint is not true. */
readOnlyOnly?: boolean
}
export interface ResolveEnabledToolsOptions extends ToolFilterPolicy {
plane: 'preset' | 'public' | 'host'
forceGroups?: readonly NetxCapabilityGroupId[]
only?: readonly NetxCapabilityGroupId[]
/** When plane is preset, which preset id is mounting. */
presetId?: string
}
function disabledSet(disabledTools: readonly string[] | undefined): Set<string> {
const out = new Set<string>()
for (const name of disabledTools ?? []) {
const trimmed = typeof name === 'string' ? name.trim() : ''
if (trimmed) out.add(trimmed)
}
return out
}
/**
* Parse settings `disabledTools` (JSON array string or comma list) into server names.
*/
export function parseDisabledTools(value: unknown): string[] {
if (Array.isArray(value)) {
return [...new Set(value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0).map(s => s.trim()))]
}
if (typeof value !== 'string') return []
const trimmed = value.trim()
if (!trimmed) return []
if (trimmed.startsWith('[')) {
try {
const parsed = JSON.parse(trimmed) as unknown
if (Array.isArray(parsed)) {
return [...new Set(parsed.filter((item): item is string => typeof item === 'string' && item.trim().length > 0).map(s => s.trim()))]
}
} catch {
// fall through to CSV
}
}
return [...new Set(trimmed.split(/[\s,,]+/).filter(Boolean))]
}
export function serializeDisabledTools(names: readonly string[]): string {
return JSON.stringify([...new Set(names.map(n => n.trim()).filter(Boolean))].sort())
}
/**
* Apply group + disabledTools + readOnlyOnly on top of a catalog snapshot.
* Tools without a known `_meta.netx.group` are skipped.
*/
export function filterCatalogTools(
catalog: readonly NetxMcpToolDescriptor[],
enabledGroups: readonly NetxCapabilityGroupId[],
policy: Pick<ToolFilterPolicy, 'disabledTools' | 'readOnlyOnly'>,
): NetxMcpToolDescriptor[] {
const allowGroups = new Set(enabledGroups)
const disabled = disabledSet(policy.disabledTools)
const readOnlyOnly = policy.readOnlyOnly === true
const out: NetxMcpToolDescriptor[] = []
for (const tool of catalog) {
if (!tool.group || !allowGroups.has(tool.group)) continue
if (disabled.has(tool.name)) continue
if (readOnlyOnly && tool.readOnlyHint !== true) continue
out.push(tool)
}
return out
}
/** Resolve which tools a mount plane should register from the live catalog. */
export function resolveEnabledTools(
catalog: readonly NetxMcpToolDescriptor[],
options: ResolveEnabledToolsOptions,
): NetxMcpToolDescriptor[] {
if (options.toolsEnabled === false) return []
const enabledGroups = options.forceGroups
?? (options.plane === 'preset'
? groupsForPreset(
{ toolsEnabled: options.toolsEnabled, groups: options.groups },
options.presetId ?? 'netxops',
options.only,
)
: groupsForPlane(options.groups, options.plane, options.only))
return filterCatalogTools(catalog, enabledGroups, options)
}
/** Disabled server names that fall inside the enabled groups (for skill footnotes). */
export function disabledToolsInGroups(
catalog: readonly NetxMcpToolDescriptor[],
enabledGroups: readonly NetxCapabilityGroupId[],
disabledTools: readonly string[] | undefined,
): string[] {
const allowGroups = new Set(enabledGroups)
const disabled = disabledSet(disabledTools)
const out: string[] = []
for (const tool of catalog) {
if (!tool.group || !allowGroups.has(tool.group)) continue
if (!disabled.has(tool.name)) continue
out.push(tool.name)
}
return out.sort()
}
/** Model-facing names for skill footnotes. */
export function disabledPublicNames(
catalog: readonly NetxMcpToolDescriptor[],
enabledGroups: readonly NetxCapabilityGroupId[],
disabledTools: readonly string[] | undefined,
readOnlyOnly?: boolean,
): string[] {
const allowGroups = new Set(enabledGroups)
const disabled = disabledSet(disabledTools)
const names: string[] = []
for (const tool of catalog) {
if (!tool.group || !allowGroups.has(tool.group)) continue
const off = disabled.has(tool.name) || (readOnlyOnly === true && tool.readOnlyHint !== true)
if (off) names.push(publicToolName(tool.name))
}
return names.sort()
}

258
src/netx/mcp-remote.ts Normal file
View file

@ -0,0 +1,258 @@
/**
* Stateless Streamable HTTP client for netx `/mcp`.
*
* Uses the runtime's standard `fetch` (injectable via `fetchImpl` for tests).
* Request/response shapes follow MCP Streamable HTTP JSON-RPC. Prefer this thin
* client over wiring `@modelcontextprotocol/sdk` / DSH `dsh-mcp-client` here:
* those pull Cordis peers or would require a new package dependency.
*/
import type { NetxCapabilityGroupId } from './capability-groups.ts'
import { CAPABILITY_GROUP_IDS } from './capability-groups.ts'
import type { NetxMcpToolDescriptor } from './mcp-types.ts'
const PROTOCOL_VERSION = '2025-03-26'
const CLIENT_INFO = { name: 'dsh-netxops', version: '0.1.71' }
export interface McpRemoteOptions {
/** Absolute MCP endpoint, e.g. `http://127.0.0.1:8890/mcp`. */
mcpUrl: string
/** Bearer token (may be empty — server returns 401). */
token: string
/** Optional Accept-Language / lang hint for bilingual descriptions. */
lang?: string
timeoutMs?: number
signal?: AbortSignal
/** Optional override for tests; defaults to `globalThis.fetch`. */
fetchImpl?: typeof fetch
}
export interface McpCallResult {
content: unknown[]
structuredContent?: unknown
isError?: boolean
}
type JsonRpcSuccess = { jsonrpc: '2.0'; id: number | string; result: unknown }
export class McpRemoteError extends Error {
readonly status?: number
constructor(message: string, status?: number) {
super(message)
this.name = 'McpRemoteError'
this.status = status
}
}
/** Build `<apiUrl>/mcp` (apiUrl may already include a path prefix). */
export function mcpEndpointUrl(apiUrl: string): string {
const trimmed = apiUrl.trim().replace(/\/$/, '')
if (!trimmed) return ''
return `${trimmed}/mcp`
}
function isGroupId(value: unknown): value is NetxCapabilityGroupId {
return typeof value === 'string' && (CAPABILITY_GROUP_IDS as readonly string[]).includes(value)
}
function parseTool(raw: unknown): NetxMcpToolDescriptor | null {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
const row = raw as Record<string, unknown>
const name = typeof row.name === 'string' ? row.name.trim() : ''
if (!name) return null
const description = typeof row.description === 'string' ? row.description : ''
const inputSchema = row.inputSchema && typeof row.inputSchema === 'object' && !Array.isArray(row.inputSchema)
? row.inputSchema as Record<string, unknown>
: { type: 'object', properties: {} }
const annotations = row.annotations && typeof row.annotations === 'object' && !Array.isArray(row.annotations)
? row.annotations as Record<string, unknown>
: {}
const readOnlyHint = annotations.readOnlyHint === true
const meta = row._meta && typeof row._meta === 'object' && !Array.isArray(row._meta)
? row._meta as Record<string, unknown>
: {}
const netx = meta.netx && typeof meta.netx === 'object' && !Array.isArray(meta.netx)
? meta.netx as Record<string, unknown>
: {}
const group = isGroupId(netx.group) ? netx.group : undefined
return { name, description, inputSchema, readOnlyHint, group }
}
/**
* Parse an MCP HTTP body: application/json, or SSE `data:` frames
* (Streamable HTTP when not using json_response).
*/
export function parseMcpResponseBody(raw: string): unknown {
const text = raw.replace(/^\uFEFF/, '').trim()
if (!text) throw new McpRemoteError('empty /mcp response body')
if (text.startsWith('{') || text.startsWith('[')) {
try {
return JSON.parse(text)
} catch (error) {
throw new McpRemoteError(
`mcp JSON parse failed: ${error instanceof Error ? error.message : String(error)}`,
)
}
}
if (text.includes('data:')) {
const dataLines: string[] = []
for (const line of text.split(/\r?\n/)) {
if (line.startsWith('data:')) dataLines.push(line.slice(5).trim())
}
for (let i = dataLines.length - 1; i >= 0; i -= 1) {
try { return JSON.parse(dataLines[i]!) } catch { /* continue */ }
}
}
throw new McpRemoteError('mcp response was not JSON')
}
function asRpcResult(payload: unknown): unknown {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new McpRemoteError('invalid JSON-RPC envelope from /mcp')
}
const row = payload as Record<string, unknown>
if (row.error && typeof row.error === 'object' && !Array.isArray(row.error)) {
const err = row.error as { code?: unknown; message?: unknown }
const message = typeof err.message === 'string' ? err.message : 'JSON-RPC error'
throw new McpRemoteError(message)
}
if (!('result' in row)) {
throw new McpRemoteError('JSON-RPC response missing result')
}
return (payload as JsonRpcSuccess).result
}
let nextId = 1
async function postRpc(
options: McpRemoteOptions,
method: string,
params?: Record<string, unknown>,
notification = false,
): Promise<unknown> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
}
const token = options.token.trim()
if (token) headers.Authorization = `Bearer ${token}`
const lang = options.lang?.trim()
if (lang) {
headers['Accept-Language'] = lang.toLowerCase().startsWith('en') ? 'en' : 'zh'
}
const body = notification
? { jsonrpc: '2.0', method, ...(params ? { params } : {}) }
: { jsonrpc: '2.0', id: nextId++, method, ...(params ? { params } : {}) }
const bodyText = JSON.stringify(body)
const timeoutMs = typeof options.timeoutMs === 'number' && options.timeoutMs > 0
? options.timeoutMs
: 60_000
const fetchImpl = options.fetchImpl ?? globalThis.fetch
if (typeof fetchImpl !== 'function') {
throw new McpRemoteError('fetch is not available')
}
const abort = new AbortController()
const timer = setTimeout(() => abort.abort(), timeoutMs)
const onOuterAbort = (): void => { abort.abort() }
options.signal?.addEventListener('abort', onOuterAbort, { once: true })
let response: Response
try {
response = await fetchImpl(options.mcpUrl, {
method: 'POST',
headers,
body: bodyText,
signal: abort.signal,
})
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw new McpRemoteError('mcp http timeout')
}
throw error
} finally {
clearTimeout(timer)
options.signal?.removeEventListener('abort', onOuterAbort)
}
const responseText = await response.text()
if (response.status === 401 || response.status === 403) {
throw new McpRemoteError(`HTTP ${response.status} unauthorized`, response.status)
}
if (response.status < 200 || response.status >= 300) {
throw new McpRemoteError(
`HTTP ${response.status}${responseText ? `: ${responseText.slice(0, 200)}` : ''}`,
response.status,
)
}
if (notification) {
return undefined
}
return asRpcResult(parseMcpResponseBody(responseText))
}
/**
* 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
}
/**
* Forward tools/call. Stateless: initialize once per call sequence so servers
* that reject orphaned calls still work.
*/
export async function callMcpTool(
options: McpRemoteOptions,
name: string,
args: Record<string, unknown>,
): Promise<McpCallResult> {
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/call', {
name,
arguments: args,
}) as Record<string, unknown>
const content = Array.isArray(result.content) ? result.content : []
return {
content,
...(result.structuredContent !== undefined ? { structuredContent: result.structuredContent } : {}),
...(result.isError === true ? { isError: true } : {}),
}
}
/** Extract plain text from MCP content blocks for error / render paths. */
export function mcpContentText(content: unknown[], fallback = ''): string {
const parts: string[] = []
for (const block of content) {
if (!block || typeof block !== 'object' || Array.isArray(block)) continue
const row = block as { type?: unknown; text?: unknown }
if (row.type === 'text' && typeof row.text === 'string') parts.push(row.text)
}
return parts.join('\n') || fallback
}

149
src/netx/mcp-store.ts Normal file
View file

@ -0,0 +1,149 @@
/**
* Process-local MCP catalog cache shared by host + preset tool mounts.
*
* Uses Symbol.for on globalThis so host / agent-tools bundles share one store.
*/
import { listMcpTools, mcpEndpointUrl, McpRemoteError } from './mcp-remote.ts'
import type { NetxMcpCatalogState, NetxMcpToolDescriptor } from './mcp-types.ts'
type Listener = () => void
interface Store {
state: NetxMcpCatalogState
listeners: Set<Listener>
/** Generation counter so stale refreshes do not clobber newer ones. */
generation: number
}
const STORE_KEY = Symbol.for('dsh-netxops.mcp-catalog-store')
const EMPTY: NetxMcpCatalogState = Object.freeze({
phase: 'idle',
mcpUrl: '',
tools: Object.freeze([]),
error: '',
listedAt: null,
fromCache: false,
})
function store(): Store {
const root = globalThis as typeof globalThis & { [STORE_KEY]?: Store }
let current = root[STORE_KEY]
if (current === undefined) {
current = { state: EMPTY, listeners: new Set(), generation: 0 }
root[STORE_KEY] = current
}
return current
}
function notify(): void {
for (const listener of [...store().listeners]) {
try { listener() } catch (error) { console.error('netxops: mcp catalog subscriber failed', error) }
}
}
function publish(next: NetxMcpCatalogState): void {
store().state = next
notify()
}
export function getMcpCatalogState(): NetxMcpCatalogState {
return store().state
}
export function getMcpCatalogTools(): readonly NetxMcpToolDescriptor[] {
return store().state.tools
}
export function watchMcpCatalog(listener: Listener): () => void {
const state = store()
state.listeners.add(listener)
return () => { state.listeners.delete(listener) }
}
export function resetMcpCatalog(): void {
store().generation += 1
publish(EMPTY)
}
export interface RefreshMcpCatalogInput {
apiUrl: string
token: string
lang?: string
timeoutMs?: number
signal?: AbortSignal
fetchImpl?: typeof fetch
}
/**
* 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.
*/
export async function refreshMcpCatalog(input: RefreshMcpCatalogInput): Promise<NetxMcpCatalogState> {
const mcpUrl = mcpEndpointUrl(input.apiUrl)
const state = store()
const gen = ++state.generation
const previous = state.state
if (!mcpUrl) {
const next: NetxMcpCatalogState = {
phase: 'error',
mcpUrl: '',
tools: previous.tools,
error: 'apiUrl empty',
listedAt: previous.listedAt,
fromCache: previous.tools.length > 0,
}
if (gen === store().generation) publish(next)
return next
}
publish({
phase: 'connecting',
mcpUrl,
tools: previous.tools,
error: '',
listedAt: previous.listedAt,
fromCache: previous.tools.length > 0,
})
try {
const tools = await listMcpTools({
mcpUrl,
token: input.token,
lang: input.lang,
timeoutMs: input.timeoutMs,
signal: input.signal,
fetchImpl: input.fetchImpl,
})
if (gen !== store().generation) return store().state
const next: NetxMcpCatalogState = {
phase: 'connected',
mcpUrl,
tools: Object.freeze([...tools]),
error: '',
listedAt: new Date().toISOString(),
fromCache: false,
}
publish(next)
return next
} catch (error) {
if (gen !== store().generation) return store().state
const message = error instanceof McpRemoteError
? error.message
: error instanceof Error
? error.message
: String(error)
const next: NetxMcpCatalogState = {
phase: previous.tools.length > 0 ? 'offline' : 'error',
mcpUrl,
tools: previous.tools,
error: message,
listedAt: previous.listedAt,
fromCache: previous.tools.length > 0,
}
publish(next)
return next
}
}

50
src/netx/mcp-types.ts Normal file
View file

@ -0,0 +1,50 @@
/**
* Remote netx `/mcp` tool descriptors.
*
* Plugin switches (groups / disabledTools / readOnlyOnly) only control what the
* model sees — they are **not** a security boundary. Authorization and CLI
* guards live on the netx server.
*/
import type { NetxCapabilityGroupId } from './capability-groups.ts'
/** Model-facing prefix kept stable across the REST→MCP migration. */
export const NETX_TOOL_PREFIX = 'netx__'
/** One tool from `tools/list` (server name has no prefix). */
export interface NetxMcpToolDescriptor {
/** Server tool name, e.g. `queryNmsAlarms`. */
name: string
description: string
inputSchema: Record<string, unknown>
/** From `annotations.readOnlyHint`; missing → treat as writable. */
readOnlyHint: boolean
/** From `_meta.netx.group`; unknown → excluded from registration. */
group: NetxCapabilityGroupId | undefined
}
export type NetxMcpConnectionPhase = 'idle' | 'connecting' | 'connected' | 'error' | 'offline'
/** Cached catalog + last connect outcome for the Plugins card. */
export interface NetxMcpCatalogState {
phase: NetxMcpConnectionPhase
/** Absolute `/mcp` URL last used for a successful or failed refresh. */
mcpUrl: string
tools: readonly NetxMcpToolDescriptor[]
/** Human-readable connect / list failure; empty when ok. */
error: string
/** ISO timestamp of the last successful tools/list (cache may be stale when offline). */
listedAt: string | null
/** True when the in-memory list came from a previous successful fetch while currently offline. */
fromCache: boolean
}
export function publicToolName(serverName: string): string {
return `${NETX_TOOL_PREFIX}${serverName}`
}
export function serverToolName(publicName: string): string {
return publicName.startsWith(NETX_TOOL_PREFIX)
? publicName.slice(NETX_TOOL_PREFIX.length)
: publicName
}

View file

@ -64,6 +64,8 @@ export function provisionalNetxConnection(): NetxToolConnection {
: 'zh'),
toolCallTimeoutMs: 120_000,
groups: DEFAULT_CAPABILITY_GROUPS,
disabledTools: [],
readOnlyOnly: false,
groupKbInPreset: true,
groupKbPublic: false,
}

View file

@ -1,5 +1,9 @@
/**
* Register native DSH tools (`netx__*`) that call netx REST.
* Register native DSH tools (`netx__*`) by forwarding to netx `/mcp`.
*
* Tool visibility (groups / disabledTools / readOnlyOnly) is an operator UX
* control only — not a security boundary. Server RBAC still applies on every
* tools/call.
*/
import { readFileSync } from 'node:fs'
@ -7,18 +11,13 @@ import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import {
groupsForPlane,
toolNamesForGroups,
type NetxCapabilityGroupId,
type NetxCapabilityGroups,
} from './capability-groups.ts'
import { createNetxClient, type NetxClient, type NetxJson } from './http.ts'
import { toLosslessJson } from './json-safe.ts'
import type { NetxCapabilityGroupId, NetxCapabilityGroups } from './capability-groups.ts'
import { resolveEnabledTools } from './mcp-catalog.ts'
import { callMcpTool, mcpContentText, mcpEndpointUrl } from './mcp-remote.ts'
import { getMcpCatalogTools } from './mcp-store.ts'
import { publicToolName, type NetxMcpToolDescriptor } from './mcp-types.ts'
import { getNetxConnection } from './runtime.ts'
import { getToolMountId, publishToolMountStatus, removeToolMountStatus } from './tool-mount-status.ts'
import * as H from './handlers.ts'
import * as T from './topology-handlers.ts'
function packageVersion(): string {
try {
@ -39,6 +38,13 @@ export interface NetxToolConnection {
toolCallTimeoutMs: number
/** Capability-group exposure; omitted → package defaults. */
groups?: NetxCapabilityGroups
/**
* Server tool names turned off by the operator (opt-out). New server tools
* follow the group switch unless listed here.
*/
disabledTools?: readonly string[]
/** When true, only register tools with readOnlyHint === true. */
readOnlyOnly?: boolean
/**
* Mount operator-subset `_skills/kb-*` into the Netx Ops preset (default true).
* Gated also by KB configured + content.hasSkills.
@ -48,68 +54,86 @@ export interface NetxToolConnection {
groupKbPublic?: boolean
}
type Handler = (client: NetxClient, args: NetxJson, signal?: AbortSignal) => Promise<NetxJson>
const MUTATING_TOOLS = new Set([
'netx__execManagedNe', 'netx__createTopologyFolder', 'netx__addTopologyViewNodes',
'netx__removeTopologyViewNodes', 'netx__copyTopologyViewNodes', 'netx__updateTopologyViewPositions',
'netx__projectTopologyNeighbors', 'netx__classifyTopologyFabricNodes', 'netx__layoutTopologyView',
])
function renderMcpResult(_args: unknown, value: unknown) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
const row = value as { text?: unknown; content?: unknown[] }
if (typeof row.text === 'string' && row.text.length > 0) {
return [{ type: 'text' as const, text: row.text }]
}
if (Array.isArray(row.content)) {
return [{ type: 'text' as const, text: mcpContentText(row.content, JSON.stringify(value)) }]
}
}
return [{ type: 'text' as const, text: typeof value === 'string' ? value : JSON.stringify(value ?? null) }]
}
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 } : {}),
})
/** Free-form JSON object — DSH requires an explicit additionalProperties boolean. */
const anyObj = (description?: string) => ({
type: 'object' as const,
additionalProperties: true as const,
...(description ? { description } : {}),
})
function renderJson(_args: unknown, value: unknown) {
return [{ type: 'text' as const, text: JSON.stringify(value, null, 0) }]
/** Free-form object output — same wire shape as the former REST tools. */
const mcpOut = {
schema: { type: 'object' as const, additionalProperties: true as const },
render: renderMcpResult,
}
/**
* Wire-compatible free-form object output.
* Prefer this over author-only `{ type: 'json' }`: Desktop Host ToolRuntime
* often comes from app.asar while `defineTool` resolves from the linked
* checkout — mismatched copies can reject `json` at register time, which
* leaves skills mounted and `netx__*` absent.
* `defineTool` expects a property map (DSH ParameterSchemaSpec), not a full
* JSON Schema document. MCP `inputSchema` is usually `{ type:'object', properties }`.
*/
const jsonOut = {
schema: { type: 'object' as const, additionalProperties: true as const },
render: renderJson,
function parametersFromInputSchema(schema: Record<string, unknown>): Record<string, unknown> {
if (schema && typeof schema === 'object' && !Array.isArray(schema)) {
const props = schema.properties
if (props && typeof props === 'object' && !Array.isArray(props)) {
return props as Record<string, unknown>
}
// Already a property map (legacy / odd servers).
if (schema.type === undefined && schema.properties === undefined) {
return schema
}
}
return {}
}
function tool(
name: string,
description: string,
parameters: Record<string, unknown>,
handler: Handler,
getClient: () => NetxClient,
function defineRemoteTool(
tool: NetxMcpToolDescriptor,
timeoutMs: number,
) {
): ReturnType<typeof defineTool> {
const name = publicToolName(tool.name)
return defineTool({
name,
description,
parameters: parameters as never,
output: jsonOut,
description: tool.description || name,
parameters: parametersFromInputSchema(tool.inputSchema) as never,
output: mcpOut,
timeoutMs,
isConcurrencySafe: () => !MUTATING_TOOLS.has(name),
isConcurrencySafe: () => tool.readOnlyHint === true,
async execute(args, exec) {
const result = await handler(getClient(), args as NetxJson, exec.signal)
// DSH rejects tool results that contain `undefined` property holes
// ("value is not lossless JSON"). Round-trip strips them.
const safe = toLosslessJson(result)
if (result.ok === false || safe.ok === false) {
throw new Error(JSON.stringify(safe))
const live = getNetxConnection()
const apiUrl = live?.apiUrl ?? ''
const token = live?.token ?? ''
const lang = live?.lang ?? 'zh'
const mcpUrl = mcpEndpointUrl(apiUrl)
if (!mcpUrl) throw new Error('netx mcp: apiUrl empty')
const result = await callMcpTool(
{
mcpUrl,
token,
lang,
timeoutMs,
signal: exec.signal,
},
tool.name,
(typeof args === 'object' && args !== null ? args : {}) as Record<string, unknown>,
)
const text = mcpContentText(result.content, '')
// Pass business failures through as ToolRuntime errors (model sees isError).
if (result.isError === true) {
throw new Error(text || `Tool "${tool.name}" failed`)
}
return safe
// Round-trip keeps DSH tool results lossless JSON-compatible.
return JSON.parse(JSON.stringify({
content: result.content,
...(result.structuredContent !== undefined
? { structuredContent: result.structuredContent }
: {}),
...(text ? { text } : {}),
})) as Record<string, never>
},
})
}
@ -126,14 +150,11 @@ export interface RegisterNetxToolsOptions {
/** When set, register exactly these groups (ignore plane flags). */
forceGroups?: readonly NetxCapabilityGroupId[]
source?: string
presetId?: string
}
/**
* Register Netx Ops tools for one exposure plane against the connection snapshot.
* Bearer is read from the live process store on each request (not frozen here).
* @param ctx - context with `tools`.
* @param connection - apiUrl / token / lang / timeout / groups.
* @param options - which plane's group flags to honor.
* Register Netx Ops tools for one exposure plane from the cached MCP catalog.
* @returns disposer that unregisters every tool.
*/
export function registerNetxTools(
@ -141,16 +162,25 @@ export function registerNetxTools(
connection: NetxToolConnection,
options: RegisterNetxToolsOptions,
): () => void {
const enabledGroups = connection.toolsEnabled === false ? [] : (options.forceGroups
?? groupsForPlane(connection.groups, options.plane, options.only))
const id = getToolMountId(ctx, options.source ?? options.plane)
const allow = toolNamesForGroups(enabledGroups)
if (allow.size === 0) {
const enabled = resolveEnabledTools(getMcpCatalogTools(), {
toolsEnabled: connection.toolsEnabled,
groups: connection.groups,
disabledTools: connection.disabledTools,
readOnlyOnly: connection.readOnlyOnly,
plane: options.plane,
forceGroups: options.forceGroups,
only: options.only,
presetId: options.presetId,
})
const groups = [...new Set(enabled.map(tool => tool.group).filter(Boolean))] as NetxCapabilityGroupId[]
if (enabled.length === 0) {
publishToolMountStatus({
id,
version: packageVersion(),
plane: options.plane,
groups: [...enabledGroups],
groups,
names: [],
count: 0,
at: new Date().toISOString(),
@ -159,598 +189,47 @@ export function registerNetxTools(
return () => { removeToolMountStatus(id) }
}
// Guard: DSH defineTool throws if timeoutMs is null/NaN/≤0 (settings projection can emit null).
const t = typeof connection.toolCallTimeoutMs === 'number'
&& Number.isFinite(connection.toolCallTimeoutMs)
&& connection.toolCallTimeoutMs > 0
? connection.toolCallTimeoutMs
: 120_000
const client = createNetxClient({
apiUrl: connection.apiUrl,
lang: connection.lang,
timeoutMs: Math.min(t, 45_000),
getToken: () => {
const live = getNetxConnection()
// A resolved empty token means revoked; never resurrect an ambient token.
return live ? live.token.trim() : connection.token.trim()
},
// Live apiUrl: settings may heal null→default after the first mount.
getApiUrl: () => getNetxConnection()?.apiUrl ?? connection.apiUrl,
})
const getClient = () => client
const catalog = [
tool(
'netx__queryNmsAlarms',
'Query NMS 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,
),
tool(
'netx__aggregateNmsAlarms',
'Aggregate NMS 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,
),
tool(
'netx__runNmsDiagnostics',
'NMS alarm diagnostics: severity, top_event_types, top_alarm_codes, top_ne, protocol buckets, freshness meta.',
{},
H.runUmeDiagnostics, getClient, t,
),
tool(
'netx__queryNmsNeInventory',
'Paged NMS 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,
),
tool(
'netx__getNmsNe',
'Get single NMS NE detail by ne_id (UUID).',
{
ne_id: { type: 'string' as const, required: true as const, description: 'NMS inventory ne_id (UUID).' },
},
H.getUmeNe, getClient, t,
),
tool(
'netx__queryNmsAlarmsRaw',
'Power query NMS 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,
),
tool(
'netx__aggregateNmsAlarmsRaw',
'Dynamic aggregation on NMS 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,
),
tool(
'netx__listNmsAlarmFields',
'List available fields for NMS raw alarm queries.',
{},
H.listUmeAlarmFields, getClient, t,
),
tool(
'netx__sqlQueryNms',
'Read-only SELECT on NMS 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,
),
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,
),
tool(
'netx__getManagedNe',
'Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Response includes capability (device_family, exec_policy_effective, recommended_mode, hints) — read before complex execManagedNe. Do NOT pass NMS 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,
),
tool(
'netx__execManagedNe',
'Run CLI via netx (readonly show/display/ping/traceroute; linux_shell on Linux/MikroTik allows scripts). Single NE: ne_id OR nms_ne_id + commands. Many NEs: ne_ids[]/nms_ne_ids[] + shared commands, or targets[{nms_ne_id|ne_id, commands}]. Do NOT loop one-NE calls for multi-NE work. Long or multi-NE: async=true (or auto when ≥4 NEs) returns job_id — poll getNeExecJob. Read getManagedNe.capability first.',
{
ne_id: str(),
nms_ne_id: str('NMS inventory id; alias ume_ne_id'),
ume_ne_id: str('Legacy alias of nms_ne_id'),
ne_ids: strArr('Managed NE ids for concurrent batch (shared commands).'),
nms_ne_ids: strArr('NMS inventory ids; alias ume_ne_ids'),
ume_ne_ids: strArr('Legacy alias of nms_ne_ids'),
targets: {
type: 'array' as const,
description: 'Per-NE command sets: each item is one NE (ne_id OR nms_ne_id) with commands[].',
items: {
type: 'object' as const,
additionalProperties: false,
properties: {
ne_id: str(),
nms_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('true=background job_id + getNeExecJob; false=force sync; omit=auto for ~4+ NEs (NETX_NE_EXEC_ASYNC_MIN_NES).'),
},
H.execManagedNe, getClient, Math.max(t, 300_000),
),
tool(
'netx__getNeExecJob',
'Poll a background execManagedNe job (job_id from async ack). When terminal=true, result holds exec/exec-batch payload. Do not busy-wait in the same turn.',
{
job_id: str('Job id from execManagedNe async response.'),
},
H.getNeExecJob, getClient, Math.max(t, 60_000),
),
tool(
'netx__listCliTargets',
'List CLI-capable targets (managed NE and/or NMS inventory). Call once per session with keyword/source, cache ids, then execManagedNe.',
{
source: str('managed | nms | ume | all'),
keyword: str(),
page: num(),
page_size: num(),
},
H.listCliTargets, getClient, t,
),
tool(
'netx__findTopologyPaths',
'Find up to max_paths simple paths between two fabric nodes (common group — native netx, not NMS-bound). For each endpoint provide exactly one of ume_ne_id or managed_ne_id.',
{
from_nms_ne_id: str('NMS inventory id'),
from_ume_ne_id: str('Legacy alias of from_nms_ne_id'),
from_managed_ne_id: str(),
to_nms_ne_id: str('NMS inventory id'),
to_ume_ne_id: str('Legacy alias of to_nms_ne_id'),
to_managed_ne_id: str(),
max_paths: num(),
max_hops: num(),
layer: str(),
detail: str('summary | full'),
},
H.findTopologyPaths, getClient, t,
),
// ── bizMonitor group (cutover / biz_state read via netx REST) ─────────
tool(
'netx__listBizMonitors',
'Catalog: list cutover/monitor-compare projects and biz_state monitor tasks. No project_id needed — call this first when the user did not give an id, then listBizMonitorBatches / getBizMonitorContext.',
{
kind: str('all | projects | tasks (default all)'),
purpose: str('Filter tasks: portrait | cutover_hf | empty=all'),
status: str('Filter by status (e.g. active / paused)'),
q: str('Keyword on name / ne_name / note / id'),
limit: num('Max rows per list (default 100, max 500)'),
},
H.listBizMonitors, getClient, t,
),
tool(
'netx__listBizMonitorBatches',
'List batch ids for further analysis. project_id → cutover batches; task_id → biz_state collect batches; batch_id + kind=runs → evaluate runs. Optional include_runs attaches recent runs onto cutover batches.',
{
kind: str('auto | all | cutover | collect | runs (default auto)'),
project_id: str('Cutover project id → cutover_batches'),
task_id: str('biz_state task id → collect_batches'),
batch_id: str('Cutover batch id (filter or kind=runs)'),
include_runs: bool('Attach recent evaluate runs onto listed cutover batches'),
status: str('Filter batch status'),
limit: num('Max batches (default 50)'),
runs_limit: num('Max runs per batch when include_runs / kind=runs (default 10)'),
},
H.listBizMonitorBatches, getClient, t,
),
tool(
'netx__getBizMonitorContext',
'Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id from listBizMonitors. Display keys are device-raw A/B.',
{
project_id: str('Cutover / biz-migration project id'),
task_id: str('biz_state task id (optional if project_id set)'),
},
H.getBizMonitorContext, getClient, t,
),
tool(
'netx__getBizMonitorBoard',
'Cutover batch board: project/batch status, evaluate run summary, sheet cards, verdict counts, metric_batches.',
{
batch_id: { type: 'string' as const, required: true as const },
run_id: str('Optional evaluate run id'),
},
H.getBizMonitorBoard, getClient, t,
),
tool(
'netx__listBizMonitorReds',
'Open/carried red tickets. Each item has old_key/new_key (raw A/B) and evidence (device, collect time, show command, iface raw→norm→map).',
{
project_id: { type: 'string' as const, required: true as const },
status: str('open|carried|resolved or empty=all'),
limit: num(),
},
H.listBizMonitorReds, getClient, t,
),
tool(
'netx__getBizMonitorDiffs',
'Paged evaluate diffs. Rows expose old_key/new_key (raw A/B), match_* keys, status, evidence with show commands.',
{
run_id: { type: 'string' as const, required: true as const },
metric_id: str(),
sheet_id: str(),
verdict: str(),
color: str('e.g. red'),
q: str(),
limit: num(),
offset: num(),
},
H.getBizMonitorDiffs, getClient, t,
),
tool(
'netx__getBizCollectBatch',
'One biz_state collect batch: commands (raw_command, parse_status, preview), parsed metrics.',
{
batch_id: { type: 'string' as const, required: true as const },
},
H.getBizCollectBatch, getClient, t,
),
tool(
'netx__getBizCollectCommandRaw',
'Full CLI raw_text for one collect command inside a batch (deep dive).',
{
batch_id: { type: 'string' as const, required: true as const },
command_id: { type: 'string' as const, required: true as const },
},
H.getBizCollectCommandRaw, getClient, t,
),
// ── topology group (netx-topology canvas / fabric) ─────────────────────
tool(
'netx__getTopologyTree',
'Get topology folder tree (nav roots + Root map canvases). Start here before createTopologyFolder.',
{ compact: bool(), max_depth: num() },
T.getTopologyTree, getClient, t,
),
tool(
'netx__getTopologyView',
'Get a topology view by view_id. Default detail=summary with sample_nodes + links[].',
{
view_id: { type: 'string' as const, required: true as const },
detail: str('summary | full'),
sample: num(),
},
T.getTopologyView, getClient, t,
),
tool(
'netx__createTopologyFolder',
'Create topology folders / region canvases. Only way to create canvases (ne:write).',
{
name: { type: 'string' as const, required: true as const },
parent_id: str(),
locale: str('zh | en'),
sort_order: num(),
},
T.createTopologyFolder, getClient, t,
),
tool(
'netx__addTopologyViewNodes',
'Bulk-place fabric nodes on a view via filters or fabric_node_ids (never managed/UME ids).',
{
view_id: { type: 'string' as const, required: true as const },
max_nodes: num(),
keyword: str(),
role: str(),
vendor: str(),
link_status: str(),
limit: num(),
offset: num(),
fabric_node_ids: strArr(),
layout: str('grid | keep'),
},
T.addTopologyViewNodes, getClient, t,
),
tool(
'netx__removeTopologyViewNodes',
'Remove placements from a view (not fabric) by filters or fabric_node_ids.',
{
view_id: { type: 'string' as const, required: true as const },
keyword: str(),
role: str(),
vendor: str(),
link_status: str(),
fabric_node_ids: strArr(),
},
T.removeTopologyViewNodes, getClient, t,
),
tool(
'netx__copyTopologyViewNodes',
'Clone fabric placements from source_view_id onto target_view_id (optional clear_target / copy_positions).',
{
source_view_id: { type: 'string' as const, required: true as const },
target_view_id: { type: 'string' as const, required: true as const },
copy_positions: bool(),
clear_target: bool(),
offset_x: num(),
offset_y: num(),
limit: num(),
dry_run: bool(),
},
T.copyTopologyViewNodes, getClient, t,
),
tool(
'netx__updateTopologyViewPositions',
'Move nodes on a view via positions[] or layout=grid|offset|stack + filters.',
{
view_id: { type: 'string' as const, required: true as const },
positions: {
type: 'array' as const,
description: 'Manual placements: [{ fabric_node_id, x, y }, …]',
items: {
type: 'object' as const,
additionalProperties: false,
properties: {
fabric_node_id: str(),
x: num(),
y: num(),
},
},
},
layout: str('grid | offset | stack'),
keyword: str(),
role: str(),
vendor: str(),
link_status: str(),
fabric_node_ids: strArr(),
offset_x: num(),
offset_y: num(),
},
T.updateTopologyViewPositions, getClient, t,
),
tool(
'netx__projectTopologyNeighbors',
'Project existing fabric neighbors of on-view nodes onto the canvas.',
{
view_id: { type: 'string' as const, required: true as const },
seed_fabric_node_ids: strArr(),
managed_ne_ids: strArr(),
region_folder_id: str(),
detail: str('summary | full'),
sample: num(),
},
T.projectTopologyNeighbors, getClient, t,
),
tool(
'netx__queryTopologyFabricNodes',
'Fabric inventory: mode=summary|list|search (keyword/role/level/region/link_status).',
{
mode: str('summary | list | search'),
q: str(),
keyword: str(),
role: str(),
level: str(),
level_major: str(),
region_folder_id: str(),
link_status: str(),
page: num(),
page_size: num(),
limit: num(),
summary: bool(),
},
T.queryTopologyFabricNodes, getClient, t,
),
tool(
'netx__classifyTopologyFabricNodes',
'Classify fabric nodes: action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules.',
{
action: { type: 'string' as const, required: true as const },
pattern: str(),
q: str(),
match_field: str(),
sample_limit: num(),
fabric_node_ids: strArr(),
fabric_node_id: str(),
node_id: str(),
level: str(),
role: str(),
region_folder_id: str(),
clear_region: bool(),
dry_run: bool(),
kind: str(),
page: num(),
page_size: num(),
skip_manual: bool(),
overwrite_manual: bool(),
fill_empty_only: bool(),
},
T.classifyTopologyFabricNodes, getClient, t,
),
tool(
'netx__queryTopologyNeighborhood',
'Neighborhood around a fabric node (depth 1–3) with compact links[].',
{
node_id: { type: 'string' as const, required: true as const },
depth: num(),
layer: str(),
},
T.queryTopologyNeighborhood, getClient, t,
),
tool(
'netx__queryTopologyEdges',
'Fabric adjacency links[] (detail=adjacency) or port rows (detail=ports).',
{
node_id: str(),
keyword: str(),
layer: str(),
status: str(),
source: str('lldp | ume | manual'),
detail: str('adjacency | ports'),
page: num(),
page_size: num(),
},
T.queryTopologyEdges, getClient, t,
),
tool(
'netx__suggestSinkHubs',
'Rank hub territories on a source view for non-dual move_nodes(park) batches (degree stand-in in DSH).',
{
source_view_id: str(),
view_id: str(),
pick: num(),
exclude_portal_ids: strArr(),
exclude_fabric_node_ids: strArr(),
},
T.suggestSinkHubs, getClient, t,
),
tool(
'netx__analyzeTopologyViewLayout',
'Basic layout QA for a view (bbox/hubs). Full dual_unit/crossing scores need netx-topology MCP.',
{
view_id: str(),
folder_id: str(),
detail: str('summary | structure | hotspots | blocks | both'),
score_profile: str(),
sight_limit: num(),
max_views: num(),
min_nodes: num(),
max_nodes: num(),
with_meta: bool(),
},
T.analyzeTopologyViewLayout, getClient, t,
),
tool(
'netx__sinkTopologyDualUnits',
'Drain dual_unit eyes from source to sink (requires netx-topology MCP layout engine in DSH).',
{
source_view_id: str(),
sink_view_id: str(),
max_units: num(),
min_nodes: num(),
max_nodes: num(),
max_batch_nodes: num(),
layout_batch: bool(),
until_empty: bool(),
dry_run: bool(),
},
T.sinkTopologyDualUnits, getClient, t,
),
tool(
'netx__layoutTopologyView',
'Layout / polish a canvas. DSH supports catalog + move_nodes|sink_nodes over HTTP; dual_unit/orbit/polish recipes need netx-topology MCP.',
{
view_id: str(),
action: str(),
source_view_id: str(),
recipe: str(),
preset: str(),
mode: str('preview | apply'),
tune: bool(),
params: anyObj('Action-specific options (move_nodes / recipes / polish).'),
catalog: bool(),
fabric_node_ids: strArr(),
park: bool(),
copy_positions: bool(),
},
T.layoutTopologyView, getClient, Math.max(t, 180_000),
),
]
const selected = catalog.filter((entry) => allow.has(entry.name))
const names = selected.map((entry) => entry.name)
const disposers: Array<() => void> = []
const names: string[] = []
try {
for (const entry of selected) disposers.push(ctx.tools.register(entry))
publishToolMountStatus({
id,
version: packageVersion(),
plane: options.plane,
groups: [...enabledGroups],
names,
count: names.length,
at: new Date().toISOString(),
source: options.source ?? options.plane,
})
for (const tool of enabled) {
const definition = defineRemoteTool(tool, t)
disposers.push(ctx.tools.register(definition))
names.push(publicToolName(tool.name))
}
} catch (error) {
for (const dispose of disposers.reverse()) dispose()
for (const dispose of disposers.splice(0)) dispose()
publishToolMountStatus({
id,
version: packageVersion(),
plane: options.plane,
groups: [...enabledGroups],
groups,
names: [],
count: 0,
at: new Date().toISOString(),
error: error instanceof Error ? error.message : String(error),
error: String(error),
source: options.source ?? options.plane,
})
throw error
}
publishToolMountStatus({
id,
version: packageVersion(),
plane: options.plane,
groups,
names,
count: names.length,
at: new Date().toISOString(),
source: options.source ?? options.plane,
})
return () => {
for (const dispose of disposers) dispose()
removeToolMountStatus(id)

View file

@ -1,668 +0,0 @@
/**
* Topology canvas / fabric handlers — HTTP port of netx-topology-mcp tools.
* Layout recipes (dual_unit / orbit / polish) stay MCP-local; DSH exposes HTTP
* CRUD + fabric query + move_nodes, and returns a clear hint for recipe actions.
*/
import { type NetxClient, type NetxJson } from './http.ts'
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 unwrap(out: NetxJson): NetxJson {
if (out.ok !== true) return out
const data = asRecord(out.data)
if (Object.keys(data).length === 0) return { ok: true, data: out.data }
return { ok: true, ...data, data }
}
function filterFields(args: NetxJson): NetxJson {
const out: NetxJson = {}
for (const key of ['keyword', 'role', 'vendor', 'link_status'] as const) {
const v = str(args, key).trim()
if (v) out[key] = v
}
return out
}
function edgeEndpoints(edge: NetxJson): [string, string] {
const a = str(edge, 'a_node_id') || str(edge, 'a')
const b = str(edge, 'b_node_id') || str(edge, 'b')
return [a.trim(), b.trim()]
}
function collapseEdgesToLinks(edges: unknown[], includeNames = false): NetxJson[] {
const buckets = new Map<string, NetxJson>()
for (const raw of edges) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue
const e = raw as NetxJson
const [a, b] = edgeEndpoints(e)
if (!a || !b || a === b) continue
const lo = a < b ? a : b
const hi = a < b ? b : a
const key = `${lo}\0${hi}`
const row = buckets.get(key) ?? {
a_node_id: lo,
b_node_id: hi,
link_count: 0,
}
row.link_count = Number(row.link_count || 0) + 1
if (includeNames && !row.a_name) {
if (a === lo) {
row.a_name = str(e, 'a_name')
row.b_name = str(e, 'b_name')
} else {
row.a_name = str(e, 'b_name')
row.b_name = str(e, 'a_name')
}
}
buckets.set(key, row)
}
return [...buckets.values()]
}
function summarizeViewGraph(graph: NetxJson, sample = 20): NetxJson {
const nodes = Array.isArray(graph.nodes) ? graph.nodes : []
const edges = Array.isArray(graph.edges) ? graph.edges : (Array.isArray(graph.links) ? graph.links : [])
const sampleNodes = nodes.slice(0, Math.max(0, sample)).map((n) => {
const row = asRecord(n)
return {
id: str(row, 'id') || str(row, 'fabric_node_id'),
name: str(row, 'name'),
x: row.x,
y: row.y,
level: row.level,
role: row.role,
}
})
return {
ok: true,
view_id: str(graph, 'id') || str(graph, 'view_id'),
node_count: nodes.length,
edge_count: edges.length,
link_count: collapseEdgesToLinks(edges).length,
links: collapseEdgesToLinks(edges),
sample_nodes: sampleNodes,
detail: 'summary',
}
}
function layoutRecipeUnavailable(action: string): NetxJson {
return {
ok: false,
error: 'layout_recipe_requires_netx_topology_mcp',
action,
hint: 'Canvas CRUD/query and move_nodes work in dsh-netxops. Dual-unit / orbit / polish recipes still need the netx-topology MCP layout engine (or enable that MCP alongside).',
}
}
/** GET /v1/topology/tree */
export async function getTopologyTree(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const out = unwrap(await client.get('/v1/topology/tree', undefined, signal))
if (out.ok !== true) return out
const compact = bool(args, 'compact') !== false
const maxDepth = num(args, 'max_depth')
if (!compact && maxDepth === undefined) return out
// Tree is already usable; pass through with flags for the model.
return { ...out, compact, max_depth: maxDepth ?? null }
}
/** GET /v1/topology/views/{id} */
export async function getTopologyView(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const viewId = str(args, 'view_id').trim()
if (!viewId) return { ok: false, error: 'view_id_required' }
const out = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal, 120_000))
if (out.ok !== true) return out
const detail = str(args, 'detail', 'summary').trim().toLowerCase() || 'summary'
if (detail === 'full') return { ...out, detail: 'full' }
return summarizeViewGraph(out, clampInt(num(args, 'sample'), 20, 0, 200))
}
/** POST /v1/topology/folders */
export async function createTopologyFolder(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const name = str(args, 'name').trim()
if (!name) return { ok: false, error: 'name_required' }
const body: NetxJson = { name }
const parentId = str(args, 'parent_id').trim()
if (parentId) body.parent_id = parentId
const locale = str(args, 'locale').trim()
if (locale) body.locale = locale
if (num(args, 'sort_order') !== undefined) body.sort_order = Math.trunc(num(args, 'sort_order')!)
return unwrap(await client.post('/v1/topology/folders', body, signal))
}
/** POST /v1/topology/views/{id}/nodes */
export async function addTopologyViewNodes(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const viewId = str(args, 'view_id').trim()
if (!viewId) return { ok: false, error: 'view_id_required' }
if (strList(args, 'managed_ne_ids').length || strList(args, 'ume_ne_ids').length) {
return {
ok: false,
error: 'fabric_nodes_only',
detail: 'Use keyword/role/vendor/link_status or fabric_node_ids; never managed/UME ids.',
}
}
const filters = filterFields(args)
const fabricIds = strList(args, 'fabric_node_ids')
if (Object.keys(filters).length === 0 && fabricIds.length === 0) {
return {
ok: false,
error: 'filter_or_fabric_node_ids_required',
detail: 'Pass keyword/role/vendor/link_status (preferred) or fabric_node_ids.',
}
}
if (num(args, 'max_nodes') !== undefined) {
const got = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal))
if (got.ok === true) {
const data = asRecord(got.data)
const filt = asRecord(got.filter ?? data.filter)
const membership = asRecord(filt.membership ?? {})
membership.max_nodes = clampInt(num(args, 'max_nodes'), 2000, 1, 2000)
filt.membership = membership
await client.patch(`/v1/topology/views/${encodeURIComponent(viewId)}`, { filter: filt }, signal)
}
}
const layout = str(args, 'layout', 'grid').trim() || 'grid'
const body: NetxJson = {
managed_ne_ids: [],
layout,
...filters,
}
if (fabricIds.length) body.fabric_node_ids = fabricIds
if (num(args, 'limit') !== undefined) body.limit = clampInt(num(args, 'limit'), 500, 1, 2000)
if (num(args, 'offset') !== undefined) body.offset = clampInt(num(args, 'offset'), 0, 0, Number.MAX_SAFE_INTEGER)
return unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes`, body, signal, 180_000))
}
/** POST /v1/topology/views/{id}/nodes/remove */
export async function removeTopologyViewNodes(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const viewId = str(args, 'view_id').trim()
if (!viewId) return { ok: false, error: 'view_id_required' }
const body: NetxJson = { ...filterFields(args) }
const fabricIds = strList(args, 'fabric_node_ids')
if (fabricIds.length) body.fabric_node_ids = fabricIds
return unwrap(await client.post(
`/v1/topology/views/${encodeURIComponent(viewId)}/nodes/remove`,
body,
signal,
))
}
/** Clone placements from source view onto target view. */
export async function copyTopologyViewNodes(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const sourceId = str(args, 'source_view_id').trim()
const targetId = str(args, 'target_view_id').trim()
if (!sourceId || !targetId) {
return { ok: false, error: 'source_view_id_and_target_view_id_required' }
}
const source = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(sourceId)}`, undefined, signal, 120_000))
if (source.ok !== true) return source
const nodes = Array.isArray(source.nodes) ? source.nodes : []
const limit = num(args, 'limit')
const selected = typeof limit === 'number' ? nodes.slice(0, clampInt(limit, nodes.length, 1, 2000)) : nodes
const fabricIds = selected.map((n) => {
const row = asRecord(n)
return str(row, 'id') || str(row, 'fabric_node_id')
}).filter(Boolean)
if (bool(args, 'dry_run') === true) {
return { ok: true, dry_run: true, would_copy: fabricIds.length, source_view_id: sourceId, target_view_id: targetId }
}
if (bool(args, 'clear_target') === true) {
await client.post(`/v1/topology/views/${encodeURIComponent(targetId)}/nodes/remove`, {
fabric_node_ids: 'ALL',
clear_all: true,
}, signal)
}
if (fabricIds.length === 0) {
return { ok: true, added: 0, source_view_id: sourceId, target_view_id: targetId }
}
const add = unwrap(await client.post(
`/v1/topology/views/${encodeURIComponent(targetId)}/nodes`,
{ fabric_node_ids: fabricIds, layout: 'keep', managed_ne_ids: [] },
signal,
180_000,
))
if (add.ok !== true) return add
if (bool(args, 'copy_positions') !== false) {
const ox = num(args, 'offset_x') ?? 0
const oy = num(args, 'offset_y') ?? 0
const positions = selected.map((n) => {
const row = asRecord(n)
const id = str(row, 'id') || str(row, 'fabric_node_id')
return {
fabric_node_id: id,
x: Number(row.x || 0) + ox,
y: Number(row.y || 0) + oy,
}
}).filter((p) => p.fabric_node_id)
if (positions.length) {
await client.patch(
`/v1/topology/views/${encodeURIComponent(targetId)}/positions`,
{ positions },
signal,
120_000,
)
}
}
return { ok: true, added: fabricIds.length, source_view_id: sourceId, target_view_id: targetId, ...asRecord(add) }
}
/** PATCH /v1/topology/views/{id}/positions */
export async function updateTopologyViewPositions(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const viewId = str(args, 'view_id').trim()
if (!viewId) return { ok: false, error: 'view_id_required' }
const body: NetxJson = {}
if (Array.isArray(args.positions)) body.positions = args.positions
const layout = str(args, 'layout').trim()
if (layout) body.layout = layout
Object.assign(body, filterFields(args))
for (const key of ['offset_x', 'offset_y', 'cols', 'gap_x', 'gap_y'] as const) {
if (num(args, key) !== undefined) body[key] = num(args, key)
}
const fabricIds = strList(args, 'fabric_node_ids')
if (fabricIds.length) body.fabric_node_ids = fabricIds
return unwrap(await client.patch(
`/v1/topology/views/${encodeURIComponent(viewId)}/positions`,
body,
signal,
120_000,
))
}
/** POST .../project-neighbors */
export async function projectTopologyNeighbors(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const viewId = str(args, 'view_id').trim()
if (!viewId) return { ok: false, error: 'view_id_required' }
const body: NetxJson = {}
const seeds = strList(args, 'seed_fabric_node_ids').length
? strList(args, 'seed_fabric_node_ids')
: strList(args, 'fabric_node_ids')
if (seeds.length) body.seed_fabric_node_ids = seeds
const mids = strList(args, 'managed_ne_ids')
if (mids.length) body.managed_ne_ids = mids
const region = str(args, 'region_folder_id').trim()
if (region) body.region_folder_id = region
const out = unwrap(await client.post(
`/v1/topology/views/${encodeURIComponent(viewId)}/project-neighbors`,
body,
signal,
180_000,
))
if (out.ok !== true) return out
const detail = str(args, 'detail', 'summary').trim().toLowerCase() || 'summary'
if (detail === 'full' || detail === 'raw' || detail === 'graph') {
return { ...out, detail: 'full', projected: true }
}
return { ...summarizeViewGraph(out, clampInt(num(args, 'sample'), 20, 0, 100)), projected: true, view_id: viewId }
}
/** Fabric inventory summary|list|search */
export async function queryTopologyFabricNodes(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
let mode = str(args, 'mode').trim().toLowerCase()
const q = str(args, 'q').trim()
const keyword = str(args, 'keyword').trim()
if (!mode) {
if (bool(args, 'summary') === true) mode = 'summary'
else if (q) mode = 'search'
else mode = 'list'
}
if (mode === 'summary' || mode === 'stats' || mode === 'count') {
const out = unwrap(await client.get('/v1/topology/fabric/summary', undefined, signal))
return out.ok === true ? { ...out, mode: 'summary' } : out
}
if (mode === 'search' || mode === 'find') {
const needle = q || keyword
if (!needle) return { ok: false, error: 'q_required', hint: 'mode=search needs q (or keyword).' }
return unwrap(await client.get('/v1/topology/fabric/nodes/search', {
q: needle,
page: clampInt(num(args, 'page'), 1, 1, Number.MAX_SAFE_INTEGER),
page_size: clampInt(num(args, 'page_size') ?? num(args, 'limit'), 50, 1, 200),
}, signal))
}
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') ?? num(args, 'limit'), 50, 1, 500),
}
for (const key of ['keyword', 'role', 'level', 'level_major', 'region_folder_id', 'link_status'] as const) {
const v = str(args, key).trim() || (key === 'keyword' ? q : '')
if (v) params[key] = v
}
const out = unwrap(await client.get('/v1/topology/fabric/nodes', params, signal))
return out.ok === true ? { ...out, mode: 'list' } : out
}
/** Classify / tag fabric nodes */
export async function classifyTopologyFabricNodes(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const action = str(args, 'action').trim().toLowerCase()
if (!action) return { ok: false, error: 'action_required' }
if (action === 'match') {
const pattern = str(args, 'pattern').trim() || str(args, 'q').trim()
if (!pattern) return { ok: false, error: 'pattern_required' }
return unwrap(await client.post('/v1/topology/fabric/nodes/match', {
pattern,
match_field: str(args, 'match_field', 'name').trim() || 'name',
sample_limit: clampInt(num(args, 'sample_limit'), 50, 1, 200),
}, signal))
}
if (action === 'tag') {
const body: NetxJson = {
fabric_node_ids: strList(args, 'fabric_node_ids'),
dry_run: bool(args, 'dry_run') === true,
}
const pattern = str(args, 'pattern').trim() || str(args, 'q').trim()
if (pattern) body.pattern = pattern
if (args.level !== undefined) body.level = args.level
const role = str(args, 'role').trim()
if (role) body.role = role
const region = str(args, 'region_folder_id').trim()
if (region) body.region_folder_id = region
if (bool(args, 'clear_region') === true) body.clear_region = true
return unwrap(await client.post('/v1/topology/fabric/nodes/tags/bulk', body, signal))
}
if (action === 'patch') {
const nodeId = str(args, 'fabric_node_id').trim() || str(args, 'node_id').trim()
if (!nodeId) return { ok: false, error: 'fabric_node_id_required' }
const body: NetxJson = {}
if (args.level !== undefined) body.level = args.level
const role = str(args, 'role').trim()
if (role) body.role = role
const region = str(args, 'region_folder_id').trim()
if (region) body.region_folder_id = region
if (bool(args, 'clear_region') === true) body.clear_region = true
return unwrap(await client.patch(
`/v1/topology/fabric/nodes/${encodeURIComponent(nodeId)}/tags`,
body,
signal,
))
}
if (action === 'unmatched') {
return unwrap(await client.get('/v1/topology/classify/unmatched', {
kind: str(args, 'kind', 'any').trim() || 'any',
page: clampInt(num(args, 'page'), 1, 1, Number.MAX_SAFE_INTEGER),
page_size: clampInt(num(args, 'page_size') ?? num(args, 'limit'), 50, 1, 500),
}, signal))
}
if (action === 'preview_rules') {
return unwrap(await client.post('/v1/topology/classify/preview', {}, signal))
}
if (action === 'apply_rules') {
const params: Record<string, string | number | boolean> = {}
if (bool(args, 'overwrite_manual') === true) params.skip_manual = false
else if (bool(args, 'skip_manual') !== undefined) params.skip_manual = bool(args, 'skip_manual') !== false
if (bool(args, 'fill_empty_only') === true) params.fill_empty_only = true
return unwrap(await client.post('/v1/topology/classify/apply', {}, signal))
}
if (action === 'list_rules') {
return unwrap(await client.get('/v1/topology/classify/rules', undefined, signal))
}
return { ok: false, error: 'unknown_action', action }
}
/** GET /v1/topology/fabric/neighborhood */
export async function queryTopologyNeighborhood(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const nodeId = str(args, 'node_id').trim()
if (!nodeId) return { ok: false, error: 'node_id_required' }
const out = unwrap(await client.get('/v1/topology/fabric/neighborhood', {
node_id: nodeId,
depth: clampInt(num(args, 'depth'), 1, 1, 3),
layer: str(args, 'layer', 'physical').trim() || 'physical',
}, signal))
if (out.ok !== true) return out
const edges = Array.isArray(out.edges) ? out.edges : []
return { ...out, links: collapseEdgesToLinks(edges) }
}
/** GET /v1/topology/fabric/edges */
export async function queryTopologyEdges(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'), 100, 1, 500),
layer: str(args, 'layer', 'physical').trim() || 'physical',
}
for (const key of ['node_id', 'keyword', 'status', 'source'] as const) {
const v = str(args, key).trim()
if (v) params[key] = v
}
const out = unwrap(await client.get('/v1/topology/fabric/edges', params, signal))
if (out.ok !== true) return out
const detail = str(args, 'detail', 'adjacency').trim().toLowerCase() || 'adjacency'
const items = Array.isArray(out.items) ? out.items : (Array.isArray(out.edges) ? out.edges : [])
if (detail === 'ports') return { ...out, detail: 'ports', items }
const links = collapseEdgesToLinks(items, true)
return { ...out, detail: 'adjacency', links, items: undefined }
}
/** Simple degree-ranked hub suggestions (HTTP-only stand-in). */
export async function suggestSinkHubs(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const viewId = str(args, 'source_view_id').trim() || str(args, 'view_id').trim()
if (!viewId) return { ok: false, error: 'source_view_id_required' }
const out = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal, 120_000))
if (out.ok !== true) return out
const nodes = Array.isArray(out.nodes) ? out.nodes : []
const edges = Array.isArray(out.edges) ? out.edges : []
const degree = new Map<string, number>()
for (const raw of edges) {
const [a, b] = edgeEndpoints(asRecord(raw))
if (!a || !b) continue
degree.set(a, (degree.get(a) || 0) + 1)
degree.set(b, (degree.get(b) || 0) + 1)
}
const exclude = new Set([
...strList(args, 'exclude_portal_ids'),
...strList(args, 'exclude_fabric_node_ids'),
])
const pick = clampInt(num(args, 'pick'), 5, 1, 50)
const hubs = [...degree.entries()]
.filter(([id]) => !exclude.has(id))
.sort((a, b) => b[1] - a[1])
.slice(0, pick)
.map(([fabric_node_id, deg]) => {
const node = nodes.find((n) => {
const row = asRecord(n)
return (str(row, 'id') || str(row, 'fabric_node_id')) === fabric_node_id
})
const row = asRecord(node)
return {
fabric_node_id,
degree: deg,
name: str(row, 'name'),
level: row.level,
role: row.role,
}
})
return {
ok: true,
source_view_id: viewId,
hubs,
hint: 'Degree ranking only — full dual_unit-aware suggestSinkHubs lives in netx-topology MCP.',
}
}
/** Basic layout QA from view graph (no Python layout_stats). */
export async function analyzeTopologyViewLayout(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const viewId = str(args, 'view_id').trim()
if (!viewId) {
return {
ok: false,
error: 'view_id_required',
hint: 'Pass view_id. folder_id sampling without view_id needs netx-topology MCP structure analysis.',
}
}
const out = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal, 120_000))
if (out.ok !== true) return out
const nodes = Array.isArray(out.nodes) ? out.nodes : []
const edges = Array.isArray(out.edges) ? out.edges : []
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
let missingxy = 0
for (const raw of nodes) {
const row = asRecord(raw)
const x = Number(row.x)
const y = Number(row.y)
if (!Number.isFinite(x) || !Number.isFinite(y)) {
missingxy += 1
continue
}
minX = Math.min(minX, x)
minY = Math.min(minY, y)
maxX = Math.max(maxX, x)
maxY = Math.max(maxY, y)
}
const degree = new Map<string, number>()
for (const raw of edges) {
const [a, b] = edgeEndpoints(asRecord(raw))
if (!a || !b) continue
degree.set(a, (degree.get(a) || 0) + 1)
degree.set(b, (degree.get(b) || 0) + 1)
}
const hubs = [...degree.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10)
.map(([id, deg]) => ({ fabric_node_id: id, degree: deg }))
return {
ok: true,
view_id: viewId,
detail: str(args, 'detail', 'summary') || 'summary',
node_count: nodes.length,
edge_count: edges.length,
link_count: collapseEdgesToLinks(edges).length,
missing_xy: missingxy,
bbox: Number.isFinite(minX) ? { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY } : null,
hubs,
hint: 'Basic graph stats only. Crossing/dual_unit/score.total analysis needs netx-topology MCP.',
}
}
/** Dual-unit sink requires MCP layout engine. */
export async function sinkTopologyDualUnits(_client: NetxClient, args: NetxJson): Promise<NetxJson> {
return {
ok: false,
error: 'sink_dual_units_requires_netx_topology_mcp',
source_view_id: str(args, 'source_view_id'),
sink_view_id: str(args, 'sink_view_id'),
hint: 'sinkTopologyDualUnits needs the dual_unit layout engine from netx-topology MCP. Use layoutTopologyView(action=move_nodes, park=true) for HTTP-only parking, or enable the topology MCP.',
}
}
/** Layout tool: catalog + move_nodes over HTTP; recipes deferred to MCP. */
export async function layoutTopologyView(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
if (bool(args, 'catalog') === true) {
return {
ok: true,
catalog: true,
actions: [
'layout', 'layout_dual_unit', 'move_nodes', 'sink_nodes', 'orbit_sweep', 'level_bands',
'polish_crossings', 'clear_edge_hits', 'compact_bbox', 'pull_far_chains', 'align_reference',
'fix_overlaps', 'resolve_overlaps', 'untangle', 'straighten_channels', 'job_status', 'job_cancel',
],
dsh_supported: ['move_nodes', 'sink_nodes', 'catalog'],
recipes: ['rings', 'corridor', 'compact', 'unstick'],
hint: 'Only move_nodes/sink_nodes/catalog run in dsh-netxops; other actions need netx-topology MCP.',
}
}
const action = str(args, 'action', 'layout').trim().toLowerCase() || 'layout'
if (action === 'job_status' || action === 'job_cancel') {
return {
ok: false,
error: 'layout_jobs_not_in_dsh_host',
hint: 'Background layout jobs are owned by netx-topology MCP.',
}
}
if (action === 'move_nodes' || action === 'sink_nodes') {
const toId = str(args, 'view_id').trim()
const fromId = str(args, 'source_view_id').trim()
const ids = strList(args, 'fabric_node_ids')
const params = asRecord(args.params)
const fabricIds = ids.length ? ids : strList(params, 'fabric_node_ids')
if (!toId || !fromId) {
return { ok: false, error: 'view_id_and_source_view_id_required' }
}
if (fabricIds.length === 0) {
return { ok: false, error: 'fabric_node_ids_required' }
}
const copyPositions = bool(params, 'copy_positions') !== false
&& bool(args, 'copy_positions') !== false
const removeFromSource = bool(params, 'remove_from_source') !== false
const source = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(fromId)}`, undefined, signal, 120_000))
if (source.ok !== true) return source
const nodes = Array.isArray(source.nodes) ? source.nodes : []
const wanted = new Set(fabricIds)
const selected = nodes.filter((n) => {
const row = asRecord(n)
const id = str(row, 'id') || str(row, 'fabric_node_id')
return wanted.has(id)
})
const add = unwrap(await client.post(
`/v1/topology/views/${encodeURIComponent(toId)}/nodes`,
{ fabric_node_ids: fabricIds, layout: copyPositions ? 'keep' : 'grid', managed_ne_ids: [] },
signal,
180_000,
))
if (add.ok !== true) return add
if (copyPositions && selected.length) {
const ox = Number(params.offset_x || 0)
const oy = Number(params.offset_y || 0)
const positions = selected.map((n) => {
const row = asRecord(n)
return {
fabric_node_id: str(row, 'id') || str(row, 'fabric_node_id'),
x: Number(row.x || 0) + ox,
y: Number(row.y || 0) + oy,
}
}).filter((p) => p.fabric_node_id)
await client.patch(
`/v1/topology/views/${encodeURIComponent(toId)}/positions`,
{ positions },
signal,
120_000,
)
}
if (removeFromSource) {
await client.post(
`/v1/topology/views/${encodeURIComponent(fromId)}/nodes/remove`,
{ fabric_node_ids: fabricIds },
signal,
)
}
return {
ok: true,
action,
moved: fabricIds.length,
source_view_id: fromId,
view_id: toId,
park: bool(params, 'park') === true || bool(args, 'park') === true,
}
}
return layoutRecipeUnavailable(action)
}