mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-20 16:02:00 +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
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue