From ed219842d3d311918e1a5b028656fbeb18a8c2fb Mon Sep 17 00:00:00 2001 From: oliver Date: Fri, 4 Sep 2026 23:38:24 +0800 Subject: [PATCH] Call netx REST from native netx__* tools. Drop the local python -m netx_mcp / mcp-client path so terminal users only need API URL + token. Co-authored-by: Cursor --- README.md | 10 +- cordis.patch.yml | 3 +- docs/INSTALL.md | 15 +- docs/PORTING.md | 6 +- docs/TOOL_MAP.md | 10 +- examples/local-debug/README.md | 2 +- examples/local-debug/patch.cordis.yml | 3 +- lib/client.js | 31 +- lib/index.js | 688 ++++++++++++++++-- package.json | 8 +- presets/netxops/PERSONA.md | 2 +- presets/netxops/agent.cordis.yml | 8 +- .../ops-netx-managed-ne-playbook/SKILL.md | 4 +- .../skills/ops-netx-ume-playbook/SKILL.md | 2 +- scripts/build-host.mjs | 1 - src/client/NetxopsCard.tsx | 14 +- src/client/controller.ts | 5 +- src/client/locales.ts | 14 +- src/index.ts | 64 +- src/netx/handlers.ts | 322 ++++++++ src/netx/http.ts | 125 ++++ src/netx/tools.ts | 273 +++++++ 22 files changed, 1431 insertions(+), 179 deletions(-) create mode 100644 src/netx/handlers.ts create mode 100644 src/netx/http.ts create mode 100644 src/netx/tools.ts diff --git a/README.md b/README.md index fb0506f..cad973e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # netxops — Netx Ops for DeepSeek Harness -Public **DeepSeek Harness** package: host bridge + Plugins card + **agent preset (skills included)**. +Public **DeepSeek Harness** package: native `netx__*` REST tools + Plugins card + **agent preset (skills included)**. - GitHub: https://github.com/hansjone/netxops - Package: `dsh-netxops` @@ -19,16 +19,18 @@ Then: 2. Settings → **Agent presets** → Custom → **Netx Ops** (installed automatically on first host activate) 3. New session → choose **Netx Ops** -Also need: running **netx API**, and `pip install` of `netx_mcp` (Python execution plane). Details: [docs/INSTALL.md](docs/INSTALL.md). +Also need: a reachable **netx API** (no local `pip install netx_mcp`). Details: [docs/INSTALL.md](docs/INSTALL.md). ## What is coupled | In the plugin | Outside (data / runtime) | |---------------|---------------------------| -| MCP spawn + settings + credentials | netx HTTP API | -| Persona + UME / managed-NE skills | `python -m netx_mcp` on PATH | +| `netx__*` REST tools + settings + credentials | netx HTTP API (URL + token) | +| Persona + UME / managed-NE skills | | | Agent preset auto-install to `~/.dsh/.agent-presets` | | +MCP (`python -m netx_mcp`) remains available for OpenClaw / other hosts — not required for DSH. + ## License MIT diff --git a/cordis.patch.yml b/cordis.patch.yml index 590c287..825b784 100644 --- a/cordis.patch.yml +++ b/cordis.patch.yml @@ -1,4 +1,4 @@ -# Host-plane Netx Ops bridge: settings + credentials → mcp__netx__* tools. +# Host-plane Netx Ops bridge: settings + credentials → netx__* REST tools. # # After `dsh plugin add` / link, open Settings → Plugins to edit apiUrl / lang # (settings card client half; see docs/INSTALL.md). Token: credential NETX_API_TOKEN. @@ -9,7 +9,6 @@ config: apiUrl: http://127.0.0.1:8890 lang: zh - pythonCommand: python tokenCredentialRef: NETX_API_TOKEN toolCallTimeoutMs: 120000 installAgentPreset: true diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 491065d..d4a5370 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -6,8 +6,8 @@ One install wires **all of**: | Piece | How you use it | |-------|----------------| -| Host bridge | spawns `mcp__netx__*` via `python -m netx_mcp` | -| Plugins card | Settings → Plugins → **Netx Ops** (URL / lang / python) | +| Host tools | native `netx__*` tools → netx REST (Bearer token) | +| Plugins card | Settings → Plugins → **Netx Ops** (URL / lang / token) | | Agent preset + skills | Settings → Agent presets → **Custom → Netx Ops** (copied into `~/.dsh/.agent-presets` on first boot) | You do **not** run `link-preset.ps1` for normal use. That script is only a manual fallback. @@ -15,14 +15,9 @@ You do **not** run `link-preset.ps1` for normal use. That script is only a manua ## Still required outside the npm package 1. **netx API** reachable (default `http://127.0.0.1:8890`) — the data plane. -2. **Python `netx_mcp`** on the same machine as `dsh`: +2. **API token** with scopes matching the tools you use (`alarms:read`, `ne:read`, `ne:exec`, `sql:query`, …). -```powershell -pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx-mcp" -python -c "import netx_mcp; print('ok')" -``` - -v1 keeps the Python MCP as the execution plane (shared with OpenClaw / `python -m netx_mcp`). The DSH plugin owns orchestration, settings, persona, and skills — not a second MCP install in the agent preset. +No local Python / `netx_mcp` install is required for DSH. (OpenClaw and other MCP hosts can still use `python -m netx_mcp` separately.) ## Install @@ -42,7 +37,7 @@ dsh web # or: pnpm dsh web 1. Plugins card **Netx Ops** visible. 2. Agent presets → Custom → **Netx Ops**. -3. Tools include `mcp__netx__queryUmeAlarms`. +3. Tools include `netx__queryUmeAlarms`. See [TOOL_MAP.md](TOOL_MAP.md). diff --git a/docs/PORTING.md b/docs/PORTING.md index ebf0f2d..2e329ef 100644 --- a/docs/PORTING.md +++ b/docs/PORTING.md @@ -7,11 +7,11 @@ | `runtime/workspaces/ops/ROLE_SYSTEM.md` | `presets/netxops/PERSONA.md` + persona in `agent.cordis.yml` (brand **Netx Ops**) | | `skills/_workspace/ops/ops-netx-ume-playbook/` | `presets/netxops/skills/ops-netx-ume-playbook/` | | `skills/_workspace/ops/ops-netx-managed-ne-playbook/` | `presets/netxops/skills/ops-netx-managed-ne-playbook/` | -| netx MCP + API URL/token | Host plugin `src/index.ts` (`dsh-netxops`): settings namespace `netxops` + credential `NETX_API_TOKEN` → dynamic `dsh-mcp-client` | +| netx MCP HTTP surface | Host plugin `src/netx/*` + `src/index.ts`: settings namespace `netxops` + credential `NETX_API_TOKEN` → native `netx__*` tools | -Adaptations: removed oclaw Admin / WhatsApp / `ume_alarm_xlsx_report` / wiki capture / skill_auto_install; fiber/offline recipes use Raw/aggregate MCP only. +Adaptations: removed oclaw Admin / WhatsApp / `ume_alarm_xlsx_report` / wiki capture / skill_auto_install; fiber/offline recipes use Raw/aggregate tools only. DSH no longer spawns `python -m netx_mcp`. -**Config UX:** Settings → Plugins → **Netx Ops** card (`src/client/` → `lib/client.js`). Token via credentials `NETX_API_TOKEN`; `apiUrl` / `lang` / `pythonCommand` via settings namespace `netxops`. +**Config UX:** Settings → Plugins → **Netx Ops** card (`src/client/` → `lib/client.js`). Token via credentials `NETX_API_TOKEN`; `apiUrl` / `lang` via settings namespace `netxops`. ## Stay in oclaw diff --git a/docs/TOOL_MAP.md b/docs/TOOL_MAP.md index e11fd89..4a62a99 100644 --- a/docs/TOOL_MAP.md +++ b/docs/TOOL_MAP.md @@ -1,8 +1,8 @@ -# Netx MCP tool map (v1) +# Netx Ops tool map -Tools are registered by `@deepseek-ai/dsh-mcp-client` with `serverName: netx`. +Tools are registered by the host plugin (`dsh-netxops`) via `@deepseek-ai/dsh-tools`. -Model-facing name: `mcp__netx__`. +Model-facing name: `netx__`. | Tool | Role | |------|------| @@ -21,8 +21,8 @@ Model-facing name: `mcp__netx__`. | `execManagedNe` | Read-only CLI (batch-first) | | `listCliTargets` | CLI target index (managed + ume) | -stdio entry: `python -m netx_mcp` → HTTP `NETX_API_URL`. +Execution: browser/`fetch` from the DSH host → HTTP `apiUrl` + Bearer `NETX_API_TOKEN`. -Upstream package: [netx `packages/netx-mcp`](https://github.com/hansjone/netx/tree/main/packages/netx-mcp). +Handler/paths mirror [netx `packages/netx-mcp`](https://github.com/hansjone/netx/tree/main/packages/netx-mcp) (`http_tools.py` / `http_client.py`). **Out of v1:** `netx-topology` MCP / topology canvas skills. diff --git a/examples/local-debug/README.md b/examples/local-debug/README.md index 3c45e7b..18cba22 100644 --- a/examples/local-debug/README.md +++ b/examples/local-debug/README.md @@ -33,5 +33,5 @@ Adjust paths if your checkout differs. 1. Settings → Plugins → **Netx Ops** card shows URL / token fields (after `bun run bundle` if you edited `src/client/`) 2. New session → preset **Netx Ops** -3. Tools include `mcp__netx__aggregateUmeAlarms` +3. Tools include `netx__aggregateUmeAlarms` 4. Ask Critical Top / single host alarms diff --git a/examples/local-debug/patch.cordis.yml b/examples/local-debug/patch.cordis.yml index 77b79e6..f598583 100644 --- a/examples/local-debug/patch.cordis.yml +++ b/examples/local-debug/patch.cordis.yml @@ -22,7 +22,6 @@ config: apiUrl: http://127.0.0.1:8890 lang: zh - pythonCommand: python tokenCredentialRef: NETX_API_TOKEN toolCallTimeoutMs: 120000 - failOnStartupError: false + installAgentPreset: true diff --git a/lib/client.js b/lib/client.js index 9c267f5..fafbce1 100644 --- a/lib/client.js +++ b/lib/client.js @@ -287,22 +287,6 @@ function NetxopsCard(props) { props.resetField("lang"); } }), - /* @__PURE__ */ jsx_runtime.jsx(ValueField, { - id: "netxops-python", - label: t("pythonCommand"), - hint: t("pythonCommandHint"), - field: state.pythonCommand, - overriddenLabel: t("overridden"), - resetLabel: t("reset"), - invalidLabel: t("invalid"), - disabled, - onEdit: (text) => { - props.edit("pythonCommand", text); - }, - onReset: () => { - props.resetField("pythonCommand"); - } - }), /* @__PURE__ */ jsx_runtime.jsxs("div", { className: "dsh-nx-footer", children: [ @@ -542,7 +526,7 @@ class NetxopsCardController { constructor(scope, ctx) { this.scope = scope; this.ctx = ctx; - this.form = new CardForm(scope, [textField("apiUrl"), textField("lang"), textField("pythonCommand")], [{ field: API_TOKEN_FIELD, write: (text) => this.writeToken(text) }]); + this.form = new CardForm(scope, [textField("apiUrl"), textField("lang")], [{ field: API_TOKEN_FIELD, write: (text) => this.writeToken(text) }]); this.store = this.form.bind(() => this.projection()); scope.subscribe(() => { this.readCredential(); @@ -566,7 +550,6 @@ class NetxopsCardController { ...this.form.shell(), apiUrl: this.form.field("apiUrl"), lang: this.form.field("lang"), - pythonCommand: this.form.field("pythonCommand"), apiToken: this.form.field(API_TOKEN_FIELD), apiTokenConfigured: this.credential.configured, apiTokenWritable: this.credential.remoteReady && this.credential.writable, @@ -635,13 +618,11 @@ function refOf(snapshot) { // src/client/locales.ts var en = { title: "Netx Ops", - description: "UME API endpoint and bearer token for mcp__netx__* tools.", + description: "UME API endpoint and bearer token for netx__* tools.", apiUrl: "API URL", apiUrlHint: "netx REST root, e.g. http://127.0.0.1:8890", lang: "Language", - langHint: "Passed as NETX_LANG (zh / en).", - pythonCommand: "Python command", - pythonCommandHint: "Executable that can run `python -m netx_mcp`.", + langHint: "Response language hint (zh / en).", apiToken: "API token", apiTokenHint: "Stored as credential NETX_API_TOKEN (never written into settings). Leave blank to keep the current token.", apiTokenUnavailable: "This DSH build does not expose remote.credentials. Set the token with scripts/set-netx-token.ps1 (or .sh), then restart is not required if credentials are watched.", @@ -661,13 +642,11 @@ var en = { }; var zh = { title: "Netx Ops", - description: "UME API 地址与 Bearer Token,供 mcp__netx__* 工具使用。", + description: "UME API 地址与 Bearer Token,供 netx__* 工具使用。", apiUrl: "API 地址", apiUrlHint: "netx REST 根地址,例如 http://127.0.0.1:8890", lang: "语言", - langHint: "传给 MCP 的 NETX_LANG(zh / en)。", - pythonCommand: "Python 命令", - pythonCommandHint: "能执行 `python -m netx_mcp` 的解释器。", + langHint: "响应语言提示(zh / en)。", apiToken: "API Token", apiTokenHint: "写入凭据 NETX_API_TOKEN(不会进 settings)。留空表示保留已有 token。", apiTokenUnavailable: "当前 DSH 未提供 remote.credentials。请用 scripts/set-netx-token.ps1(或 .sh)写入 token;若 harness 在监视凭据文件则无需重启。", diff --git a/lib/index.js b/lib/index.js index 9fbd188..1aa7e21 100644 --- a/lib/index.js +++ b/lib/index.js @@ -6,19 +6,646 @@ import { fileURLToPath } from "node:url"; import z from "@deepseek-ai/schemastery"; import { credentialRef } from "@deepseek-ai/dsh-credentials"; import * as DshSettings from "@deepseek-ai/dsh-settings"; -import * as McpClient from "@deepseek-ai/dsh-mcp-client"; + +// src/netx/tools.ts +import { defineTool } from "@deepseek-ai/dsh-tools"; + +// src/netx/http.ts +var PROTOCOL_KEY_ZH_TO_EN = { + 其他: "Other", + 时钟: "Clock", + "OTN/光": "OTN/Optical", + 电源: "Power" +}; +function localizePayload(lang, data) { + if (!lang.trim().toLowerCase().startsWith("en")) + return data; + const proto = data.protocol_summary; + if (!Array.isArray(proto)) + return data; + for (const row of proto) { + if (typeof row !== "object" || row === null || Array.isArray(row)) + continue; + const rec = row; + 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) { + 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}` : ""; +} +function createNetxClient(connection) { + const base = connection.apiUrl.replace(/\/$/, ""); + const headers = { + accept: "application/json" + }; + if (connection.token.trim().length > 0) { + headers.authorization = `Bearer ${connection.token.trim()}`; + } + const langParams = () => { + const lang = connection.lang.trim().toLowerCase(); + if (lang.startsWith("en")) + return { lang: "en" }; + return {}; + }; + async function request(method, path, options = {}) { + const merged = { ...langParams(), ...options.params }; + const url = `${base}${path}${encodeQuery(merged)}`; + const timeoutMs = options.timeoutMs ?? connection.timeoutMs; + const controller = new AbortController; + const timer = setTimeout(() => { + controller.abort(); + }, timeoutMs); + const onOuterAbort = () => { + controller.abort(); + }; + options.signal?.addEventListener("abort", onOuterAbort, { once: true }); + try { + const init = { + method, + headers: options.body === undefined ? headers : { ...headers, "content-type": "application/json" }, + signal: controller.signal + }; + if (options.body !== undefined) + init.body = JSON.stringify(options.body); + const resp = await fetch(url, init); + const text = await resp.text(); + if (!resp.ok) { + return { ok: false, error: `netx_http_${resp.status}`, detail: text.slice(0, 800) }; + } + const data = text.length > 0 ? JSON.parse(text) : {}; + if (typeof data === "object" && data !== null && !Array.isArray(data)) { + return { ok: true, data: localizePayload(connection.lang, data) }; + } + 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, params, signal, timeoutMs) { + return request("GET", path, { params, signal, timeoutMs }); + }, + post(path, body, signal, timeoutMs) { + return request("POST", path, { body, signal, timeoutMs }); + } + }; +} +function quoteNeId(neId) { + return encodeURIComponent(neId.trim()); +} + +// src/netx/handlers.ts +var EXEC_MAX_COMMANDS = 5; +var UME_RAW_FIELD_PRESETS = { + 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) { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; +} +function str(args, key, fallback = "") { + 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, key) { + const v = args[key]; + return typeof v === "number" && Number.isFinite(v) ? v : undefined; +} +function bool(args, key) { + const v = args[key]; + return typeof v === "boolean" ? v : undefined; +} +function strList(args, key) { + const v = args[key]; + if (!Array.isArray(v)) + return []; + return v.map((x) => String(x).trim()).filter((x) => x.length > 0); +} +function clampInt(value, fallback, min, max) { + const n = value === undefined ? fallback : Math.trunc(value); + return Math.max(min, Math.min(max, n)); +} +function putStr(params, args, keys) { + for (const key of keys) { + const v = str(args, key).trim(); + if (v) + params[key] = v; + } +} +async function queryUmeAlarms(client, args, signal) { + let page = clampInt(num(args, "page"), 1, 1, 2); + const pageSize = clampInt(num(args, "page_size"), 50, 1, 500); + const params = { 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); +} +async function aggregateUmeAlarmsRaw(client, args, signal) { + const params = {}; + 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); +} +async function aggregateUmeAlarms(client, args, signal) { + if (str(args, "group_by").trim()) + return aggregateUmeAlarmsRaw(client, args, signal); + const topNe = clampInt(num(args, "top_ne"), 50, 0, 500); + const params = { 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); +} +async function runUmeDiagnostics(client, _args, signal) { + return client.get("/v1/ume/diagnostics", undefined, signal); +} +async function queryUmeNeInventory(client, args, signal) { + const params = { + 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); +} +async function getUmeNe(client, args, signal) { + 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); +} +async function queryUmeAlarmsRaw(client, args, signal) { + const params = { + 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); +} +async function listUmeAlarmFields(client, _args, signal) { + return client.get("/v1/ume/alarms/fields", undefined, signal); +} +async function sqlQueryUme(client, args, signal) { + 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, 30000); + return client.post("/v1/sql/ume_query", { + sql, + limit, + statement_timeout_ms: statementTimeoutMs + }, signal, 60000); +} +async function listManagedNe(client, args, signal) { + 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 = { + 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); +} +async function getManagedNe(client, args, signal) { + const neId = (str(args, "ne_id") || str(args, "managed_ne_id") || str(args, "id")).trim(); + if (!neId) { + return { + ok: false, + error: "ne_id_required", + error_code: "ne_id_required", + hint: "Pass managed NE id from listManagedNe/listCliTargets (source=managed). For UME inventory UUIDs use execManagedNe(ume_ne_id=...) or getUmeNe, not getManagedNe.", + example: { ne_id: "" } + }; + } + const out = await client.get(`/v1/managed-ne/${quoteNeId(neId)}`, undefined, signal); + if (out.ok === false) { + const detail = `${str(out, "detail")}${str(out, "error")}`.toLowerCase(); + if (detail.includes("404") || detail.includes("not_found") || detail.includes("not found") || out.error === "netx_http_404") { + return { + ...out, + hint: "Managed NE not found for this ne_id. Call listManagedNe(keyword=...) or listCliTargets(source=managed) first. If this is a UME ne_id, use execManagedNe(ume_ne_id=...) / getUmeNe instead of getManagedNe." + }; + } + } + return out; +} +async function execManagedNe(client, args, signal) { + const targetsRaw = args.targets; + const neIds = strList(args, "ne_ids"); + const umeNeIds = strList(args, "ume_ne_ids"); + const sharedCommands = strList(args, "commands"); + const multi = Array.isArray(targetsRaw) && targetsRaw.length > 0 || neIds.length > 0 || umeNeIds.length > 0; + if (multi) { + const body2 = {}; + if (Array.isArray(targetsRaw) && targetsRaw.length > 0) { + const cleaned = []; + for (const t of targetsRaw) { + if (typeof t !== "object" || t === null || Array.isArray(t)) + continue; + const row = t; + const item = {}; + const neId2 = str(row, "ne_id").trim(); + const umeNeId2 = str(row, "ume_ne_id").trim(); + if (neId2) + item.ne_id = neId2; + if (umeNeId2) + item.ume_ne_id = umeNeId2; + 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); + } + body2.targets = cleaned; + } + if (neIds.length > 0) + body2.ne_ids = neIds; + if (umeNeIds.length > 0) + body2.ume_ne_ids = umeNeIds; + if (sharedCommands.length > 0) { + if (sharedCommands.length > EXEC_MAX_COMMANDS) { + return { ok: false, error: "too_many_commands", error_code: "too_many_commands" }; + } + body2.commands = sharedCommands; + } + body2.read_timeout_sec = clampInt(num(args, "read_timeout_sec"), 60, 10, 120); + const concurrency = num(args, "concurrency"); + if (concurrency !== undefined) + body2.concurrency = clampInt(concurrency, 4, 1, 8); + const out2 = await client.post("/v1/managed-ne/exec-batch", body2, signal, 600000); + if (out2.ok !== true) + return out2; + const data2 = asRecord(out2.data); + if (data2.ok === false) { + return { ok: false, data: data2, error: str(data2, "error", "exec_batch_failed") }; + } + return { ok: true, data: data2 }; + } + const neId = str(args, "ne_id").trim(); + const umeNeId = str(args, "ume_ne_id").trim(); + if (Boolean(neId) === Boolean(umeNeId)) { + return { + ok: false, + error: "exactly_one_of_ne_id_or_ume_ne_id_required", + error_code: "exactly_one_of_ne_id_or_ume_ne_id_required", + hint: "For one NE pass ne_id OR ume_ne_id. For many NEs pass ne_ids / ume_ne_ids with shared commands, or targets[] with per-NE commands — one call, concurrent on server." + }; + } + if (sharedCommands.length === 0) { + return { ok: false, error: "commands_required", error_code: "commands_required" }; + } + if (sharedCommands.length > EXEC_MAX_COMMANDS) { + return { ok: false, error: "too_many_commands", error_code: "too_many_commands" }; + } + const body = { + commands: sharedCommands, + read_timeout_sec: clampInt(num(args, "read_timeout_sec"), 60, 10, 120) + }; + if (neId) + body.ne_id = neId; + if (umeNeId) + body.ume_ne_id = umeNeId; + const out = await client.post("/v1/managed-ne/exec", body, signal, 300000); + 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 }; +} +async function listCliTargets(client, args, signal) { + const params = { + 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); +} +async function findTopologyPaths(client, args, signal) { + const fromUid = str(args, "from_ume_ne_id").trim(); + const fromMid = str(args, "from_managed_ne_id").trim(); + const toUid = str(args, "to_ume_ne_id").trim(); + const toMid = str(args, "to_managed_ne_id").trim(); + if (Boolean(fromUid) === Boolean(fromMid)) { + return { ok: false, error: "exactly_one_of_from_ume_ne_id_or_from_managed_ne_id_required" }; + } + if (Boolean(toUid) === Boolean(toMid)) { + return { ok: false, error: "exactly_one_of_to_ume_ne_id_or_to_managed_ne_id_required" }; + } + let detail = str(args, "detail", "summary").trim().toLowerCase() || "summary"; + if (detail !== "summary" && detail !== "full") + detail = "summary"; + const body = { + 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, 30000); +} + +// src/netx/tools.ts +var str2 = (description) => ({ type: "string", ...description ? { description } : {} }); +var num2 = (description) => ({ type: "number", ...description ? { description } : {} }); +var bool2 = (description) => ({ type: "boolean", ...description ? { description } : {} }); +var strArr = (description) => ({ + type: "array", + items: { type: "string" }, + ...description ? { description } : {} +}); +function renderJson(_args, value) { + return [{ type: "text", text: JSON.stringify(value, null, 0) }]; +} +var jsonOut = { + schema: { type: "json" }, + render: renderJson +}; +function tool(name, description, parameters, handler, getClient, timeoutMs) { + return defineTool({ + name, + description, + parameters, + output: jsonOut, + timeoutMs, + isConcurrencySafe: () => true, + async execute(args, exec) { + const result = await handler(getClient(), args, exec.signal); + if (result.ok === false) { + throw new Error(JSON.stringify(result)); + } + return result; + } + }); +} +function registerNetxTools(ctx, connection) { + const client = createNetxClient({ + apiUrl: connection.apiUrl, + token: connection.token, + lang: connection.lang, + timeoutMs: Math.min(connection.toolCallTimeoutMs, 45000) + }); + const getClient = () => client; + const t = connection.toolCallTimeoutMs; + const disposers = [ + ctx.tools.register(tool("netx__queryUmeAlarms", "Query UME current alarms (each row includes host_name). Supports severity/ne_id/host_name/keyword, last_seen time_from/time_to, pagination. Prefer host_name for display; ne_id is for filters only.", { + severity: str2(), + ne_id: str2("Filter only; do not show UUID to users"), + host_name: str2("Filter by NE host_name"), + ne_name: str2("Legacy alias mapped to keyword"), + keyword: str2("Substring on cause/object/event. Examples: LOS, Fiber Break, bandwidth, CRC."), + time_from: str2("ISO time; filters last_seen_at >="), + time_to: str2("ISO time; filters last_seen_at <="), + page: num2(), + page_size: num2() + }, queryUmeAlarms, getClient, t)), + ctx.tools.register(tool("netx__aggregateUmeAlarms", "Aggregate UME current alarms (by_severity + top by_ne). If group_by is set, routes to aggregateUmeAlarmsRaw. Always filter severity/keyword/time before paging.", { + severity: str2("Optional perceived_severity filter (critical/major/minor/warning)."), + top_ne: num2("Max NE buckets (default 50). Ignored when group_by is set."), + exclude_missing_host: bool2("Omit missing host_name from by_ne."), + time_from: str2(), + time_to: str2(), + group_by: str2("When set, routes to raw aggregation. Prefer alarm_host_name."), + group_by2: str2(), + is_cleared: str2(), + ne_id: str2(), + event_type: str2(), + keyword: str2(), + limit: num2() + }, aggregateUmeAlarms, getClient, t)), + ctx.tools.register(tool("netx__runUmeDiagnostics", "UME alarm diagnostics: severity, top_event_types, top_alarm_codes, top_ne, protocol buckets, freshness meta.", {}, runUmeDiagnostics, getClient, t)), + ctx.tools.register(tool("netx__queryUmeNeInventory", "Paged UME NE inventory synced in netx (keyword matches ne_id/ne_name/user_label/ip/host_name).", { + keyword: str2(), + page: num2(), + page_size: num2() + }, queryUmeNeInventory, getClient, t)), + ctx.tools.register(tool("netx__getUmeNe", "Get single UME NE detail by ne_id (UUID).", { + ne_id: { type: "string", required: true, description: "UME inventory ne_id (UUID)." } + }, getUmeNe, getClient, t)), + ctx.tools.register(tool("netx__queryUmeAlarmsRaw", "Power query UME current alarms with full alarm_* + ne_* fields; optional field_preset or select_fields. Use field_preset=evidence for citations.", { + severity: str2(), + is_cleared: str2(), + ne_id: str2(), + event_type: str2(), + keyword: str2(), + time_from: str2(), + time_to: str2(), + order_by: str2("last_seen_at | time_created | perceived_severity | event_type | ne_id"), + order: str2("asc | desc"), + select_fields: strArr(), + field_preset: str2("brief | evidence | ne_debug"), + page: num2(), + page_size: num2() + }, queryUmeAlarmsRaw, getClient, t)), + ctx.tools.register(tool("netx__aggregateUmeAlarmsRaw", "Dynamic aggregation on UME raw fields (group_by/group_by2); prefer alarm_host_name.", { + group_by: { type: "string", required: true }, + group_by2: str2(), + severity: str2(), + is_cleared: str2(), + ne_id: str2(), + event_type: str2(), + keyword: str2(), + time_from: str2(), + time_to: str2(), + exclude_missing_host: bool2(), + limit: num2() + }, aggregateUmeAlarmsRaw, getClient, t)), + ctx.tools.register(tool("netx__listUmeAlarmFields", "List available fields for UME raw alarm queries.", {}, listUmeAlarmFields, getClient, t)), + ctx.tools.register(tool("netx__sqlQueryUme", "Read-only SELECT on UME tables (ume_alarms_current/ume_inventory_ne); server enforces limits. Requires sql:query scope.", { + sql: { type: "string", required: true }, + limit: num2(), + statement_timeout_ms: num2() + }, sqlQueryUme, getClient, t)), + ctx.tools.register(tool("netx__listManagedNe", "List filtered netx managed NEs (keyword/vendor/connect_status required); use before execManagedNe.", { + keyword: str2(), + vendor: str2(), + connect_status: str2("unknown | testing | pass | fail"), + page: num2(), + page_size: num2() + }, listManagedNe, getClient, t)), + ctx.tools.register(tool("netx__getManagedNe", "Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Do NOT pass UME inventory UUID here.", { + ne_id: str2("Managed NE id"), + managed_ne_id: str2("Alias for ne_id"), + id: str2("Alias for ne_id") + }, getManagedNe, getClient, t)), + ctx.tools.register(tool("netx__execManagedNe", "Run read-only CLI via netx (show/display/ping/traceroute). Single NE: ne_id OR ume_ne_id + commands. Many NEs: ne_ids[]/ume_ne_ids[] + shared commands, or targets[{ume_ne_id|ne_id, commands}]. Do NOT loop one-NE calls for multi-NE work.", { + ne_id: str2(), + ume_ne_id: str2(), + ne_ids: strArr("Managed NE ids for concurrent batch (shared commands)."), + ume_ne_ids: strArr("UME inventory ne_ids for concurrent batch (shared commands)."), + targets: { + type: "array", + description: "Per-NE command sets: each item is one NE (ne_id OR ume_ne_id) with commands[].", + items: { + type: "object", + additionalProperties: false, + properties: { + ne_id: str2(), + ume_ne_id: str2(), + commands: strArr() + } + } + }, + commands: strArr("Commands for single NE, or shared commands for batch."), + read_timeout_sec: num2("Per-command read timeout (default 60; use 90–120 for slow show)."), + concurrency: num2("Parallel NEs for batch mode (1–8, default 4)."), + async: bool2("oclaw-only async hint; ignored by native REST client.") + }, execManagedNe, getClient, Math.max(t, 300000))), + ctx.tools.register(tool("netx__listCliTargets", "List CLI-capable targets (managed NE and/or UME inventory). Call once per session with keyword/source, cache ids, then execManagedNe.", { + source: str2("managed | ume | all"), + keyword: str2(), + page: num2(), + page_size: num2() + }, listCliTargets, getClient, t)), + ctx.tools.register(tool("netx__findTopologyPaths", "Find up to max_paths simple paths between two fabric nodes. For each endpoint provide exactly one of ume_ne_id or managed_ne_id.", { + from_ume_ne_id: str2(), + from_managed_ne_id: str2(), + to_ume_ne_id: str2(), + to_managed_ne_id: str2(), + max_paths: num2(), + max_hops: num2(), + layer: str2(), + detail: str2("summary | full") + }, findTopologyPaths, getClient, t)) + ]; + return () => { + for (const dispose of disposers) + dispose(); + }; +} + +// src/index.ts var name = "netxops"; var inject = ["tools"]; var NETXOPS_SETTINGS_NAMESPACE = "netxops"; var NETXOPS_PRESET_ID = "netxops"; var DEFAULT_TOKEN_REF = "NETX_API_TOKEN"; -var Config2 = z.object({ +var Config = z.object({ apiUrl: z.string().default("http://127.0.0.1:8890"), lang: z.string().default("zh"), - pythonCommand: z.string().default("python"), tokenCredentialRef: z.string().role("credential-ref").default(DEFAULT_TOKEN_REF), toolCallTimeoutMs: z.number().step(1).min(1000).default(120000), - failOnStartupError: z.boolean().default(false), installAgentPreset: z.boolean().default(true) }); function packageRoot() { @@ -62,16 +689,16 @@ async function resolveToken(ctx, refName) { function installNetxopsSettings(ctx, entry, hooks) { const legacy = DshSettings.installSettingsSection; if (typeof legacy === "function") { - legacy(ctx, NETXOPS_SETTINGS_NAMESPACE, Config2, entry, hooks); + legacy(ctx, NETXOPS_SETTINGS_NAMESPACE, Config, entry, hooks); return; } ctx.inject(["settings"], (settingsCtx) => { - settingsCtx.settings.installSection(ctx, NETXOPS_SETTINGS_NAMESPACE, Config2, entry, hooks); + settingsCtx.settings.installSection(ctx, NETXOPS_SETTINGS_NAMESPACE, Config, entry, hooks); }); } -function apply(ctx, config = Config2({})) { +function apply(ctx, config = Config({})) { let source = () => config; - let mcpFiber; + let unregister; let remounting = Promise.resolve(); let generation = 0; if (config.installAgentPreset) { @@ -80,41 +707,21 @@ function apply(ctx, config = Config2({})) { const remount = () => { remounting = remounting.then(async () => { const gen = ++generation; - const previous = mcpFiber; - mcpFiber = undefined; - if (previous !== undefined) { - try { - await previous.dispose(); - } catch (error) { - ctx.logger.warn("netxops: disposing previous mcp-client failed: %s", error); - } - } + unregister?.(); + unregister = undefined; if (gen !== generation) return; const current = source(); const token = await resolveToken(ctx, current.tokenCredentialRef); if (gen !== generation) return; - const mcpConfig = McpClient.Config({ - transport: "stdio", - serverName: "netx", - command: current.pythonCommand, - args: ["-m", "netx_mcp"], - env: { - NETX_API_URL: current.apiUrl.replace(/\/$/, ""), - NETX_API_TOKEN: token, - NETX_LANG: current.lang - }, - toolCallTimeoutMs: current.toolCallTimeoutMs, - failOnStartupError: current.failOnStartupError + unregister = registerNetxTools(ctx, { + apiUrl: current.apiUrl.replace(/\/$/, ""), + token, + lang: current.lang, + toolCallTimeoutMs: current.toolCallTimeoutMs }); - try { - mcpFiber = await ctx.plugin(McpClient, mcpConfig); - } catch (error) { - ctx.logger.error("netxops: failed to mount mcp-client: %s", error); - if (current.failOnStartupError) - throw error; - } + ctx.logger.info("netxops: registered netx__* REST tools → %s", current.apiUrl.replace(/\/$/, "")); }).catch((error) => { ctx.logger.error("netxops: remount error: %s", error); }); @@ -134,10 +741,9 @@ function apply(ctx, config = Config2({})) { }); ctx.effect(() => () => { generation += 1; - const fiber = mcpFiber; - mcpFiber = undefined; - fiber?.dispose(); - }, "netxops: dispose mcp-client"); + unregister?.(); + unregister = undefined; + }, "netxops: dispose netx tools"); } export { name, @@ -147,5 +753,5 @@ export { NETXOPS_SETTINGS_NAMESPACE, NETXOPS_PRESET_ID, DEFAULT_TOKEN_REF, - Config2 as Config + Config }; diff --git a/package.json b/package.json index 5cdf525..5f04975 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dsh-netxops", - "version": "0.1.11", - "description": "DeepSeek Harness Netx Ops: host bridge + Plugins settings card + agent preset", + "version": "0.1.12", + "description": "DeepSeek Harness Netx Ops: native netx__* REST tools + Plugins settings card + agent preset", "license": "MIT", "type": "module", "private": false, @@ -43,7 +43,8 @@ "bundle": "bun run scripts/build-host.mjs && bun run scripts/build-client.mjs", "bundle:host": "bun run scripts/build-host.mjs", "bundle:client": "bun run scripts/build-client.mjs" - }, "dsh": { + }, + "dsh": { "bundle": { "patch": "./cordis.patch.yml" }, @@ -62,7 +63,6 @@ "@deepseek-ai/schemastery": "*", "@deepseek-ai/dsh-credentials": "*", "@deepseek-ai/dsh-settings": "*", - "@deepseek-ai/dsh-mcp-client": "*", "@deepseek-ai/dsh-tools": "*", "@deepseek-ai/dsh-persona": "*", "@deepseek-ai/dsh-skill-filesystem": "*", diff --git a/presets/netxops/PERSONA.md b/presets/netxops/PERSONA.md index 77a6aa9..44b8ad2 100644 --- a/presets/netxops/PERSONA.md +++ b/presets/netxops/PERSONA.md @@ -30,4 +30,4 @@ You are **Netx Ops**, a network operations specialist for ZTE UME / netx. - `ops-netx-managed-ne-playbook` — managed / UME CLI batch ## Tools -`mcp__netx__*` only. Multi-NE CLI = one `execManagedNe` batch. +`netx__*` only. Multi-NE CLI = one `execManagedNe` batch. diff --git a/presets/netxops/agent.cordis.yml b/presets/netxops/agent.cordis.yml index 28b7dd4..deceb78 100644 --- a/presets/netxops/agent.cordis.yml +++ b/presets/netxops/agent.cordis.yml @@ -1,5 +1,6 @@ # Netx Ops agent preset — UME alarms / NE inventory / managed CLI. -# Host keeps registries, sandbox, model route; this file owns persona, skills, netx MCP. +# Host keeps registries, sandbox, model route; this file owns persona + skills. +# netx__* tools are registered on the HOST by package `dsh-netxops`. # ── identity ──────────────────────────────────────────────────────────────── @@ -36,7 +37,7 @@ - Managed SSH/Telnet CLI → `ops-netx-managed-ne-playbook` ## Tools - Call netx MCP as `mcp__netx__*` (camelCase tool names). See skill bodies for decision trees. + Call netx as `netx__*` (camelCase tool names). See skill bodies for decision trees. Batch multi-NE CLI in **one** `execManagedNe` call (`ne_ids` / `ume_ne_ids` / `targets`). - id: agent-instructions @@ -63,7 +64,6 @@ - id: tool-skill name: '@deepseek-ai/dsh-tool-skill' -# netx MCP is mounted on the HOST by package `dsh-netxops` (cordis.patch.yml). +# netx__* tools are mounted on the HOST by package `dsh-netxops` (cordis.patch.yml). # The same package copies this preset into `$DSH_HOME/.agent-presets/netxops` # on activate — no manual link-preset script for normal installs. -# Tools appear as mcp__netx__* on the host tool registry. diff --git a/presets/netxops/skills/ops-netx-managed-ne-playbook/SKILL.md b/presets/netxops/skills/ops-netx-managed-ne-playbook/SKILL.md index b4266b3..e12a8df 100644 --- a/presets/netxops/skills/ops-netx-managed-ne-playbook/SKILL.md +++ b/presets/netxops/skills/ops-netx-managed-ne-playbook/SKILL.md @@ -15,7 +15,7 @@ Differs from UME inventory (`ops-netx-ume-playbook`): this is **SSH/Telnet** (ZT ## Tool order -Use `mcp__netx__*`. +Use `netx__*`. 1. **Locate** - `listManagedNe`: `keyword`, `connect_status=pass` (preferred) @@ -27,7 +27,7 @@ Use `mcp__netx__*`. - **Multi-NE = batch-first (one tool call; server concurrency default 4, max 20)**: - Same commands: `ne_ids` / `ume_ne_ids` + shared `commands` - Different commands per NE: `targets=[{ume_ne_id|ne_id, commands:[…]}, …]` - - Many single-NE calls in one turn are **serial** on stdio — forbidden for multi-NE work + - Many single-NE calls in one turn are **serial** — forbidden for multi-NE work - Per session: call `listCliTargets` at most once; merge shows into each target's `commands[]` - Timeouts: raise `read_timeout_sec` (default 60; slow 90–120) or fewer commands — no blind retry diff --git a/presets/netxops/skills/ops-netx-ume-playbook/SKILL.md b/presets/netxops/skills/ops-netx-ume-playbook/SKILL.md index f96d0a0..c3a8351 100644 --- a/presets/netxops/skills/ops-netx-ume-playbook/SKILL.md +++ b/presets/netxops/skills/ops-netx-ume-playbook/SKILL.md @@ -14,7 +14,7 @@ For any Netx Ops request about UME **alarms** or **NE inventory**, load this ski ## Tool names -Host tool names are `mcp__netx__` (`serverName=netx`): +Host tool names are `netx__`: | Purpose | Tool | |---------|------| diff --git a/scripts/build-host.mjs b/scripts/build-host.mjs index da08cd1..2c1f5e2 100644 --- a/scripts/build-host.mjs +++ b/scripts/build-host.mjs @@ -16,7 +16,6 @@ const external = [ '@deepseek-ai/schemastery', '@deepseek-ai/dsh-credentials', '@deepseek-ai/dsh-settings', - '@deepseek-ai/dsh-mcp-client', '@deepseek-ai/dsh-tools', ] diff --git a/src/client/NetxopsCard.tsx b/src/client/NetxopsCard.tsx index dd75e7a..f4f7000 100644 --- a/src/client/NetxopsCard.tsx +++ b/src/client/NetxopsCard.tsx @@ -1,5 +1,5 @@ /** - * Netx Ops Plugins settings card — apiUrl / lang / python + credential token. + * Netx Ops Plugins settings card — apiUrl / lang + credential token. */ import { useEffect, useRef, useState } from 'react' @@ -147,18 +147,6 @@ export function NetxopsCard(props: NetxopsCardProps) { onEdit={(text) => { props.edit('lang', text) }} onReset={() => { props.resetField('lang') }} /> - { props.edit('pythonCommand', text) }} - onReset={() => { props.resetField('pythonCommand') }} - />
{state.failed ?

{t('saveFailed')}

: null}