mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-20 13:30:46 +08:00
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:
parent
2def53e7c7
commit
2ef3a43c63
38 changed files with 5049 additions and 17029 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -162,6 +162,8 @@ const MEMORY_DEFAULTS: NetxopsSettings = {
|
|||
kbRoot: '',
|
||||
groupKbInPreset: true,
|
||||
groupKbPublic: false,
|
||||
disabledTools: '[]',
|
||||
readOnlyOnly: false,
|
||||
}
|
||||
|
||||
export function apply(ctx: ClientContext): void {
|
||||
|
|
|
|||
|
|
@ -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: '会话存储不可用。',
|
||||
|
|
|
|||
92
src/client/mcp-catalog-view.ts
Normal file
92
src/client/mcp-catalog-view.ts
Normal 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' }
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
114
src/index.ts
114
src/index.ts
|
|
@ -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() }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
150
src/netx/http.ts
150
src/netx/http.ts
|
|
@ -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
138
src/netx/mcp-catalog.ts
Normal 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
258
src/netx/mcp-remote.ts
Normal 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
149
src/netx/mcp-store.ts
Normal 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
50
src/netx/mcp-types.ts
Normal 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
|
||||
}
|
||||
|
|
@ -64,6 +64,8 @@ export function provisionalNetxConnection(): NetxToolConnection {
|
|||
: 'zh'),
|
||||
toolCallTimeoutMs: 120_000,
|
||||
groups: DEFAULT_CAPABILITY_GROUPS,
|
||||
disabledTools: [],
|
||||
readOnlyOnly: false,
|
||||
groupKbInPreset: true,
|
||||
groupKbPublic: false,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue