Netx tools: load from netx remote /mcp, add per-tool toggles

- Drop the TypeScript netx tool implementations and the hard-coded
  TOOLS_BY_GROUP list; register netx__* tools from the server's
  tools/list (group from _meta.netx.group) and forward tools/call.
- Thin Streamable HTTP client on standard fetch (fetchImpl injectable);
  cached catalog when netx is unreachable, refresh to reconnect.
- disabledTools (deny list; new server tools follow their group) and
  readOnlyOnly (only readOnlyHint=true tools), re-registered live.
- Settings card: per-group expandable tool rows with switches and a
  write marker; skills append a disabled-tools note.
- Toggles only control model visibility; netx enforces security.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
oliver 2026-10-16 22:08:22 +08:00
parent 2def53e7c7
commit 2ef3a43c63
38 changed files with 5049 additions and 17029 deletions

View file

@ -2,19 +2,18 @@
"format": 1,
"builds": {
"lib/agent-tools-biz-monitor.js": {
"output": "8e72f3ef56c2700444531f0c0af778efbb4838b1f67d42e4fc8711aa575f544a",
"output": "2b382bb050cd2bdbba42c018359fcd81fd56f7caa77ab5fcea7dded47841eeb5",
"inputs": {
"package.json": "1bb1fb8d615f49c69665a3a89b5912fa414a3b93aa3c541ce909ff57f97e100d",
"pnpm-lock.yaml": "f1209ac70e9c2294e0f300c0aad88b64aeecd6aa19dfc929c5766c7d033615a3",
"scripts/build-host.mjs": "1210c12b05a31f4ed64439fe5f08750cb75605b30327210cd97584b64aadf6a8",
"scripts/build-integrity.mjs": "18e9e32c452ef6217d771675fa8b763464a27ac0cf192d5fbe5cb1682380b0d9",
"src/agent-tools-biz-monitor.ts": "201bd447198d5ee7cb1aa25031a3f6054fe12a8a221edd4915752696cca1537d",
"src/netx/capability-groups.ts": "d916fd36bc75e081f567e0545097feb6f113b4c7365b620c1dd8174bb08caeef",
"src/netx/capability-groups.ts": "d91046451e2c2d7968a4cae98dccc0a86c1b77c7855542d4edf604833db385a0",
"src/netx/config-values.ts": "7e8ca894d0b09f5e52686d6b293e49b46dd2cc13d714b0e0b103c3d07433e117",
"src/netx/group-skills.ts": "eb5a8b2bdbbc0c1cf3b2ece7f22e1a7a10c41a5a23be737d0c121cc5e5315b14",
"src/netx/group-tools-plugin.ts": "7a5daaead0d1f228cb35d8eba56c8efd8a529e31e0e44463cef2904ceb64397b",
"src/netx/handlers.ts": "5b6f86c96ccd57f00183c98f9fa122101d485444cc73d04a5d1529cd8522d08d",
"src/netx/http.ts": "1846030e34e03eeebd44f5ed87d92dcdab62429c4f3f740d6c2dcfafd1854f7d",
"src/netx/group-skills.ts": "e870e984569b7e3f5c05d50a1e489ddf4e222a738354df93a6a860bb35d7152c",
"src/netx/group-tools-plugin.ts": "647e5258b46648705585cd5d3765556f391810434c6c7f04f3df9e2f44d8ba67",
"src/netx/http.ts": "aa0552d6087593924f448fc8efaadda92f8803147190741f5abc3030cf32c439",
"src/netx/json-safe.ts": "5fe610d5ec96d31096733f7e828b4b2266c19692535a232a644bb49784ee24d9",
"src/netx/kb-context-skill.ts": "e0375006ba1e505fa6e5fd50f1748d7b43bb8c34809d10a0549e934006eb3045",
"src/netx/kb-local-ops.ts": "2c28ff25c8abcb68e2b197767a3eea0e056bcc3aa4ccdd6561d069a7a0da2463",
@ -24,26 +23,28 @@
"src/netx/kb-pack-skills.ts": "a843b91bf45ba7072b330945bd692eb4c36922958f8d8875b9a163c00fd096e5",
"src/netx/kb-read-ops.ts": "1359b370293b9f4afa51774ed729525d77756f1552cd46abf4d4d6c595eaeb86",
"src/netx/kb-runtime.ts": "ebf2fd36c407439f1a59344af18d9fe7275825925adbe08719595d06161d6d5b",
"src/netx/runtime.ts": "eca10943a2ade5f0460f92a69a1d26abb77981ff56dcf00e6199b8586a818450",
"src/netx/mcp-catalog.ts": "b79962da8f74f04595ed89b166b851d83cac41e8338b80b7629da2021a2c281d",
"src/netx/mcp-remote.ts": "d79925538e6adaccd8dbf8c65a90027d98464d37a57dce00b3e3155c08b02843",
"src/netx/mcp-store.ts": "eb93e466e16c3bca81bff01407689025711be52d55e79acbdfa4bec64bd0a6f3",
"src/netx/mcp-types.ts": "4b30d6e8fba328b0e116c43a1a90d2b686ef4b9fc648b521063876426e9c2833",
"src/netx/runtime.ts": "238555c92c3972ee9de8ec062b14d6a86961ec4c64609565ba1164b1a460d386",
"src/netx/tool-mount-status.ts": "783981774d717ca5e7bf7c0ec7273b40787ef12a6b40749c74543b30fa390faf",
"src/netx/tools.ts": "3027ee16ed8df3de020b07d0fa39d91b7c5676fbea6fcf074309175a0d853508",
"src/netx/topology-handlers.ts": "2735c22d8773b5a7b1f8e181a8bbd350fff580a41018cbddcdb7bc239fded25f"
"src/netx/tools.ts": "0dfc3fdada56ab04ce40fecbe6f229aabae4ff6132d60809a8fb8daade948641"
}
},
"lib/agent-tools-common.js": {
"output": "a4bd50da911bed64245ebee31a27f4f112729e7b35b99816671c3253e89719f0",
"output": "3e51e500080dfad4c0a2ea6060afb1b7fe4e6a349d13030aed7ad37db49eedfa",
"inputs": {
"package.json": "1bb1fb8d615f49c69665a3a89b5912fa414a3b93aa3c541ce909ff57f97e100d",
"pnpm-lock.yaml": "f1209ac70e9c2294e0f300c0aad88b64aeecd6aa19dfc929c5766c7d033615a3",
"scripts/build-host.mjs": "1210c12b05a31f4ed64439fe5f08750cb75605b30327210cd97584b64aadf6a8",
"scripts/build-integrity.mjs": "18e9e32c452ef6217d771675fa8b763464a27ac0cf192d5fbe5cb1682380b0d9",
"src/agent-tools-common.ts": "cf463e940bc2042d7462502de93d45380550da54f1965fa684d72f71b16bc41b",
"src/netx/capability-groups.ts": "d916fd36bc75e081f567e0545097feb6f113b4c7365b620c1dd8174bb08caeef",
"src/netx/capability-groups.ts": "d91046451e2c2d7968a4cae98dccc0a86c1b77c7855542d4edf604833db385a0",
"src/netx/config-values.ts": "7e8ca894d0b09f5e52686d6b293e49b46dd2cc13d714b0e0b103c3d07433e117",
"src/netx/group-skills.ts": "eb5a8b2bdbbc0c1cf3b2ece7f22e1a7a10c41a5a23be737d0c121cc5e5315b14",
"src/netx/group-tools-plugin.ts": "7a5daaead0d1f228cb35d8eba56c8efd8a529e31e0e44463cef2904ceb64397b",
"src/netx/handlers.ts": "5b6f86c96ccd57f00183c98f9fa122101d485444cc73d04a5d1529cd8522d08d",
"src/netx/http.ts": "1846030e34e03eeebd44f5ed87d92dcdab62429c4f3f740d6c2dcfafd1854f7d",
"src/netx/group-skills.ts": "e870e984569b7e3f5c05d50a1e489ddf4e222a738354df93a6a860bb35d7152c",
"src/netx/group-tools-plugin.ts": "647e5258b46648705585cd5d3765556f391810434c6c7f04f3df9e2f44d8ba67",
"src/netx/http.ts": "aa0552d6087593924f448fc8efaadda92f8803147190741f5abc3030cf32c439",
"src/netx/json-safe.ts": "5fe610d5ec96d31096733f7e828b4b2266c19692535a232a644bb49784ee24d9",
"src/netx/kb-context-skill.ts": "e0375006ba1e505fa6e5fd50f1748d7b43bb8c34809d10a0549e934006eb3045",
"src/netx/kb-local-ops.ts": "2c28ff25c8abcb68e2b197767a3eea0e056bcc3aa4ccdd6561d069a7a0da2463",
@ -53,26 +54,28 @@
"src/netx/kb-pack-skills.ts": "a843b91bf45ba7072b330945bd692eb4c36922958f8d8875b9a163c00fd096e5",
"src/netx/kb-read-ops.ts": "1359b370293b9f4afa51774ed729525d77756f1552cd46abf4d4d6c595eaeb86",
"src/netx/kb-runtime.ts": "ebf2fd36c407439f1a59344af18d9fe7275825925adbe08719595d06161d6d5b",
"src/netx/runtime.ts": "eca10943a2ade5f0460f92a69a1d26abb77981ff56dcf00e6199b8586a818450",
"src/netx/mcp-catalog.ts": "b79962da8f74f04595ed89b166b851d83cac41e8338b80b7629da2021a2c281d",
"src/netx/mcp-remote.ts": "d79925538e6adaccd8dbf8c65a90027d98464d37a57dce00b3e3155c08b02843",
"src/netx/mcp-store.ts": "eb93e466e16c3bca81bff01407689025711be52d55e79acbdfa4bec64bd0a6f3",
"src/netx/mcp-types.ts": "4b30d6e8fba328b0e116c43a1a90d2b686ef4b9fc648b521063876426e9c2833",
"src/netx/runtime.ts": "238555c92c3972ee9de8ec062b14d6a86961ec4c64609565ba1164b1a460d386",
"src/netx/tool-mount-status.ts": "783981774d717ca5e7bf7c0ec7273b40787ef12a6b40749c74543b30fa390faf",
"src/netx/tools.ts": "3027ee16ed8df3de020b07d0fa39d91b7c5676fbea6fcf074309175a0d853508",
"src/netx/topology-handlers.ts": "2735c22d8773b5a7b1f8e181a8bbd350fff580a41018cbddcdb7bc239fded25f"
"src/netx/tools.ts": "0dfc3fdada56ab04ce40fecbe6f229aabae4ff6132d60809a8fb8daade948641"
}
},
"lib/agent-tools-nms.js": {
"output": "b67bb855df12beb0ee24c1c3cd3fb0d42203c060568731784e9a14b545060214",
"output": "895e3fdf1ab1a9fc9a0fd0d79f2d51b22f164a1a6ea1e0897f975b4b3103d647",
"inputs": {
"package.json": "1bb1fb8d615f49c69665a3a89b5912fa414a3b93aa3c541ce909ff57f97e100d",
"pnpm-lock.yaml": "f1209ac70e9c2294e0f300c0aad88b64aeecd6aa19dfc929c5766c7d033615a3",
"scripts/build-host.mjs": "1210c12b05a31f4ed64439fe5f08750cb75605b30327210cd97584b64aadf6a8",
"scripts/build-integrity.mjs": "18e9e32c452ef6217d771675fa8b763464a27ac0cf192d5fbe5cb1682380b0d9",
"src/agent-tools-nms.ts": "6505e51bfb2948756440b00724b19d907d410d34ef1dcbf08019774c0f9d7ef5",
"src/netx/capability-groups.ts": "d916fd36bc75e081f567e0545097feb6f113b4c7365b620c1dd8174bb08caeef",
"src/netx/capability-groups.ts": "d91046451e2c2d7968a4cae98dccc0a86c1b77c7855542d4edf604833db385a0",
"src/netx/config-values.ts": "7e8ca894d0b09f5e52686d6b293e49b46dd2cc13d714b0e0b103c3d07433e117",
"src/netx/group-skills.ts": "eb5a8b2bdbbc0c1cf3b2ece7f22e1a7a10c41a5a23be737d0c121cc5e5315b14",
"src/netx/group-tools-plugin.ts": "7a5daaead0d1f228cb35d8eba56c8efd8a529e31e0e44463cef2904ceb64397b",
"src/netx/handlers.ts": "5b6f86c96ccd57f00183c98f9fa122101d485444cc73d04a5d1529cd8522d08d",
"src/netx/http.ts": "1846030e34e03eeebd44f5ed87d92dcdab62429c4f3f740d6c2dcfafd1854f7d",
"src/netx/group-skills.ts": "e870e984569b7e3f5c05d50a1e489ddf4e222a738354df93a6a860bb35d7152c",
"src/netx/group-tools-plugin.ts": "647e5258b46648705585cd5d3765556f391810434c6c7f04f3df9e2f44d8ba67",
"src/netx/http.ts": "aa0552d6087593924f448fc8efaadda92f8803147190741f5abc3030cf32c439",
"src/netx/json-safe.ts": "5fe610d5ec96d31096733f7e828b4b2266c19692535a232a644bb49784ee24d9",
"src/netx/kb-context-skill.ts": "e0375006ba1e505fa6e5fd50f1748d7b43bb8c34809d10a0549e934006eb3045",
"src/netx/kb-local-ops.ts": "2c28ff25c8abcb68e2b197767a3eea0e056bcc3aa4ccdd6561d069a7a0da2463",
@ -82,26 +85,28 @@
"src/netx/kb-pack-skills.ts": "a843b91bf45ba7072b330945bd692eb4c36922958f8d8875b9a163c00fd096e5",
"src/netx/kb-read-ops.ts": "1359b370293b9f4afa51774ed729525d77756f1552cd46abf4d4d6c595eaeb86",
"src/netx/kb-runtime.ts": "ebf2fd36c407439f1a59344af18d9fe7275825925adbe08719595d06161d6d5b",
"src/netx/runtime.ts": "eca10943a2ade5f0460f92a69a1d26abb77981ff56dcf00e6199b8586a818450",
"src/netx/mcp-catalog.ts": "b79962da8f74f04595ed89b166b851d83cac41e8338b80b7629da2021a2c281d",
"src/netx/mcp-remote.ts": "d79925538e6adaccd8dbf8c65a90027d98464d37a57dce00b3e3155c08b02843",
"src/netx/mcp-store.ts": "eb93e466e16c3bca81bff01407689025711be52d55e79acbdfa4bec64bd0a6f3",
"src/netx/mcp-types.ts": "4b30d6e8fba328b0e116c43a1a90d2b686ef4b9fc648b521063876426e9c2833",
"src/netx/runtime.ts": "238555c92c3972ee9de8ec062b14d6a86961ec4c64609565ba1164b1a460d386",
"src/netx/tool-mount-status.ts": "783981774d717ca5e7bf7c0ec7273b40787ef12a6b40749c74543b30fa390faf",
"src/netx/tools.ts": "3027ee16ed8df3de020b07d0fa39d91b7c5676fbea6fcf074309175a0d853508",
"src/netx/topology-handlers.ts": "2735c22d8773b5a7b1f8e181a8bbd350fff580a41018cbddcdb7bc239fded25f"
"src/netx/tools.ts": "0dfc3fdada56ab04ce40fecbe6f229aabae4ff6132d60809a8fb8daade948641"
}
},
"lib/agent-tools-ops.js": {
"output": "4515ae3096e1698f6e8b059c84eb4b91f0f3f62ca1831551484f581d29c28a80",
"output": "4ece4e983ee4c50a0591a9e601a6cded04f55ebe1c849b4b2eb9d458854e0a18",
"inputs": {
"package.json": "1bb1fb8d615f49c69665a3a89b5912fa414a3b93aa3c541ce909ff57f97e100d",
"pnpm-lock.yaml": "f1209ac70e9c2294e0f300c0aad88b64aeecd6aa19dfc929c5766c7d033615a3",
"scripts/build-host.mjs": "1210c12b05a31f4ed64439fe5f08750cb75605b30327210cd97584b64aadf6a8",
"scripts/build-integrity.mjs": "18e9e32c452ef6217d771675fa8b763464a27ac0cf192d5fbe5cb1682380b0d9",
"src/agent-tools-ops.ts": "2f0331106de53f3dcdd8d61ce2d9da363f0a9b854f1dd7d98e27997c82d98a77",
"src/netx/capability-groups.ts": "d916fd36bc75e081f567e0545097feb6f113b4c7365b620c1dd8174bb08caeef",
"src/netx/capability-groups.ts": "d91046451e2c2d7968a4cae98dccc0a86c1b77c7855542d4edf604833db385a0",
"src/netx/config-values.ts": "7e8ca894d0b09f5e52686d6b293e49b46dd2cc13d714b0e0b103c3d07433e117",
"src/netx/group-skills.ts": "eb5a8b2bdbbc0c1cf3b2ece7f22e1a7a10c41a5a23be737d0c121cc5e5315b14",
"src/netx/group-tools-plugin.ts": "7a5daaead0d1f228cb35d8eba56c8efd8a529e31e0e44463cef2904ceb64397b",
"src/netx/handlers.ts": "5b6f86c96ccd57f00183c98f9fa122101d485444cc73d04a5d1529cd8522d08d",
"src/netx/http.ts": "1846030e34e03eeebd44f5ed87d92dcdab62429c4f3f740d6c2dcfafd1854f7d",
"src/netx/group-skills.ts": "e870e984569b7e3f5c05d50a1e489ddf4e222a738354df93a6a860bb35d7152c",
"src/netx/group-tools-plugin.ts": "647e5258b46648705585cd5d3765556f391810434c6c7f04f3df9e2f44d8ba67",
"src/netx/http.ts": "aa0552d6087593924f448fc8efaadda92f8803147190741f5abc3030cf32c439",
"src/netx/json-safe.ts": "5fe610d5ec96d31096733f7e828b4b2266c19692535a232a644bb49784ee24d9",
"src/netx/kb-context-skill.ts": "e0375006ba1e505fa6e5fd50f1748d7b43bb8c34809d10a0549e934006eb3045",
"src/netx/kb-local-ops.ts": "2c28ff25c8abcb68e2b197767a3eea0e056bcc3aa4ccdd6561d069a7a0da2463",
@ -111,26 +116,28 @@
"src/netx/kb-pack-skills.ts": "a843b91bf45ba7072b330945bd692eb4c36922958f8d8875b9a163c00fd096e5",
"src/netx/kb-read-ops.ts": "1359b370293b9f4afa51774ed729525d77756f1552cd46abf4d4d6c595eaeb86",
"src/netx/kb-runtime.ts": "ebf2fd36c407439f1a59344af18d9fe7275825925adbe08719595d06161d6d5b",
"src/netx/runtime.ts": "eca10943a2ade5f0460f92a69a1d26abb77981ff56dcf00e6199b8586a818450",
"src/netx/mcp-catalog.ts": "b79962da8f74f04595ed89b166b851d83cac41e8338b80b7629da2021a2c281d",
"src/netx/mcp-remote.ts": "d79925538e6adaccd8dbf8c65a90027d98464d37a57dce00b3e3155c08b02843",
"src/netx/mcp-store.ts": "eb93e466e16c3bca81bff01407689025711be52d55e79acbdfa4bec64bd0a6f3",
"src/netx/mcp-types.ts": "4b30d6e8fba328b0e116c43a1a90d2b686ef4b9fc648b521063876426e9c2833",
"src/netx/runtime.ts": "238555c92c3972ee9de8ec062b14d6a86961ec4c64609565ba1164b1a460d386",
"src/netx/tool-mount-status.ts": "783981774d717ca5e7bf7c0ec7273b40787ef12a6b40749c74543b30fa390faf",
"src/netx/tools.ts": "3027ee16ed8df3de020b07d0fa39d91b7c5676fbea6fcf074309175a0d853508",
"src/netx/topology-handlers.ts": "2735c22d8773b5a7b1f8e181a8bbd350fff580a41018cbddcdb7bc239fded25f"
"src/netx/tools.ts": "0dfc3fdada56ab04ce40fecbe6f229aabae4ff6132d60809a8fb8daade948641"
}
},
"lib/agent-tools-topology.js": {
"output": "54f0b223025fcc1618771cb1148f41547cb66ea296e238b9ad214fef28bd73ae",
"output": "cec2f877d8fe03b5e537c35ec39a0d66c5885910385f690a8a3bcb96e8ad3954",
"inputs": {
"package.json": "1bb1fb8d615f49c69665a3a89b5912fa414a3b93aa3c541ce909ff57f97e100d",
"pnpm-lock.yaml": "f1209ac70e9c2294e0f300c0aad88b64aeecd6aa19dfc929c5766c7d033615a3",
"scripts/build-host.mjs": "1210c12b05a31f4ed64439fe5f08750cb75605b30327210cd97584b64aadf6a8",
"scripts/build-integrity.mjs": "18e9e32c452ef6217d771675fa8b763464a27ac0cf192d5fbe5cb1682380b0d9",
"src/agent-tools-topology.ts": "22bf5f1c71a5b1d33bd3a894bca2c6e4c2fc72072c8a9d7bbc00e214dfafa9f8",
"src/netx/capability-groups.ts": "d916fd36bc75e081f567e0545097feb6f113b4c7365b620c1dd8174bb08caeef",
"src/netx/capability-groups.ts": "d91046451e2c2d7968a4cae98dccc0a86c1b77c7855542d4edf604833db385a0",
"src/netx/config-values.ts": "7e8ca894d0b09f5e52686d6b293e49b46dd2cc13d714b0e0b103c3d07433e117",
"src/netx/group-skills.ts": "eb5a8b2bdbbc0c1cf3b2ece7f22e1a7a10c41a5a23be737d0c121cc5e5315b14",
"src/netx/group-tools-plugin.ts": "7a5daaead0d1f228cb35d8eba56c8efd8a529e31e0e44463cef2904ceb64397b",
"src/netx/handlers.ts": "5b6f86c96ccd57f00183c98f9fa122101d485444cc73d04a5d1529cd8522d08d",
"src/netx/http.ts": "1846030e34e03eeebd44f5ed87d92dcdab62429c4f3f740d6c2dcfafd1854f7d",
"src/netx/group-skills.ts": "e870e984569b7e3f5c05d50a1e489ddf4e222a738354df93a6a860bb35d7152c",
"src/netx/group-tools-plugin.ts": "647e5258b46648705585cd5d3765556f391810434c6c7f04f3df9e2f44d8ba67",
"src/netx/http.ts": "aa0552d6087593924f448fc8efaadda92f8803147190741f5abc3030cf32c439",
"src/netx/json-safe.ts": "5fe610d5ec96d31096733f7e828b4b2266c19692535a232a644bb49784ee24d9",
"src/netx/kb-context-skill.ts": "e0375006ba1e505fa6e5fd50f1748d7b43bb8c34809d10a0549e934006eb3045",
"src/netx/kb-local-ops.ts": "2c28ff25c8abcb68e2b197767a3eea0e056bcc3aa4ccdd6561d069a7a0da2463",
@ -140,26 +147,28 @@
"src/netx/kb-pack-skills.ts": "a843b91bf45ba7072b330945bd692eb4c36922958f8d8875b9a163c00fd096e5",
"src/netx/kb-read-ops.ts": "1359b370293b9f4afa51774ed729525d77756f1552cd46abf4d4d6c595eaeb86",
"src/netx/kb-runtime.ts": "ebf2fd36c407439f1a59344af18d9fe7275825925adbe08719595d06161d6d5b",
"src/netx/runtime.ts": "eca10943a2ade5f0460f92a69a1d26abb77981ff56dcf00e6199b8586a818450",
"src/netx/mcp-catalog.ts": "b79962da8f74f04595ed89b166b851d83cac41e8338b80b7629da2021a2c281d",
"src/netx/mcp-remote.ts": "d79925538e6adaccd8dbf8c65a90027d98464d37a57dce00b3e3155c08b02843",
"src/netx/mcp-store.ts": "eb93e466e16c3bca81bff01407689025711be52d55e79acbdfa4bec64bd0a6f3",
"src/netx/mcp-types.ts": "4b30d6e8fba328b0e116c43a1a90d2b686ef4b9fc648b521063876426e9c2833",
"src/netx/runtime.ts": "238555c92c3972ee9de8ec062b14d6a86961ec4c64609565ba1164b1a460d386",
"src/netx/tool-mount-status.ts": "783981774d717ca5e7bf7c0ec7273b40787ef12a6b40749c74543b30fa390faf",
"src/netx/tools.ts": "3027ee16ed8df3de020b07d0fa39d91b7c5676fbea6fcf074309175a0d853508",
"src/netx/topology-handlers.ts": "2735c22d8773b5a7b1f8e181a8bbd350fff580a41018cbddcdb7bc239fded25f"
"src/netx/tools.ts": "0dfc3fdada56ab04ce40fecbe6f229aabae4ff6132d60809a8fb8daade948641"
}
},
"lib/agent-tools.js": {
"output": "16f2f1ec267929d2055546eebf980544db5758590a7e1b4073c2d4ae5843ddde",
"output": "9c51c136d5b5be9de1479abcae7bd4a69a20fe4e1f92562ca6e13e59663669cc",
"inputs": {
"package.json": "1bb1fb8d615f49c69665a3a89b5912fa414a3b93aa3c541ce909ff57f97e100d",
"pnpm-lock.yaml": "f1209ac70e9c2294e0f300c0aad88b64aeecd6aa19dfc929c5766c7d033615a3",
"scripts/build-host.mjs": "1210c12b05a31f4ed64439fe5f08750cb75605b30327210cd97584b64aadf6a8",
"scripts/build-integrity.mjs": "18e9e32c452ef6217d771675fa8b763464a27ac0cf192d5fbe5cb1682380b0d9",
"src/agent-tools.ts": "b7e4f8476ba9c5f2754d859074c94129642d18883da293b80e310c09336bc5c5",
"src/netx/capability-groups.ts": "d916fd36bc75e081f567e0545097feb6f113b4c7365b620c1dd8174bb08caeef",
"src/netx/capability-groups.ts": "d91046451e2c2d7968a4cae98dccc0a86c1b77c7855542d4edf604833db385a0",
"src/netx/config-values.ts": "7e8ca894d0b09f5e52686d6b293e49b46dd2cc13d714b0e0b103c3d07433e117",
"src/netx/group-skills.ts": "eb5a8b2bdbbc0c1cf3b2ece7f22e1a7a10c41a5a23be737d0c121cc5e5315b14",
"src/netx/group-tools-plugin.ts": "7a5daaead0d1f228cb35d8eba56c8efd8a529e31e0e44463cef2904ceb64397b",
"src/netx/handlers.ts": "5b6f86c96ccd57f00183c98f9fa122101d485444cc73d04a5d1529cd8522d08d",
"src/netx/http.ts": "1846030e34e03eeebd44f5ed87d92dcdab62429c4f3f740d6c2dcfafd1854f7d",
"src/netx/group-skills.ts": "e870e984569b7e3f5c05d50a1e489ddf4e222a738354df93a6a860bb35d7152c",
"src/netx/group-tools-plugin.ts": "647e5258b46648705585cd5d3765556f391810434c6c7f04f3df9e2f44d8ba67",
"src/netx/http.ts": "aa0552d6087593924f448fc8efaadda92f8803147190741f5abc3030cf32c439",
"src/netx/json-safe.ts": "5fe610d5ec96d31096733f7e828b4b2266c19692535a232a644bb49784ee24d9",
"src/netx/kb-context-skill.ts": "e0375006ba1e505fa6e5fd50f1748d7b43bb8c34809d10a0549e934006eb3045",
"src/netx/kb-local-ops.ts": "2c28ff25c8abcb68e2b197767a3eea0e056bcc3aa4ccdd6561d069a7a0da2463",
@ -169,61 +178,66 @@
"src/netx/kb-pack-skills.ts": "a843b91bf45ba7072b330945bd692eb4c36922958f8d8875b9a163c00fd096e5",
"src/netx/kb-read-ops.ts": "1359b370293b9f4afa51774ed729525d77756f1552cd46abf4d4d6c595eaeb86",
"src/netx/kb-runtime.ts": "ebf2fd36c407439f1a59344af18d9fe7275825925adbe08719595d06161d6d5b",
"src/netx/runtime.ts": "eca10943a2ade5f0460f92a69a1d26abb77981ff56dcf00e6199b8586a818450",
"src/netx/mcp-catalog.ts": "b79962da8f74f04595ed89b166b851d83cac41e8338b80b7629da2021a2c281d",
"src/netx/mcp-remote.ts": "d79925538e6adaccd8dbf8c65a90027d98464d37a57dce00b3e3155c08b02843",
"src/netx/mcp-store.ts": "eb93e466e16c3bca81bff01407689025711be52d55e79acbdfa4bec64bd0a6f3",
"src/netx/mcp-types.ts": "4b30d6e8fba328b0e116c43a1a90d2b686ef4b9fc648b521063876426e9c2833",
"src/netx/runtime.ts": "238555c92c3972ee9de8ec062b14d6a86961ec4c64609565ba1164b1a460d386",
"src/netx/tool-mount-status.ts": "783981774d717ca5e7bf7c0ec7273b40787ef12a6b40749c74543b30fa390faf",
"src/netx/tools.ts": "3027ee16ed8df3de020b07d0fa39d91b7c5676fbea6fcf074309175a0d853508",
"src/netx/topology-handlers.ts": "2735c22d8773b5a7b1f8e181a8bbd350fff580a41018cbddcdb7bc239fded25f"
"src/netx/tools.ts": "0dfc3fdada56ab04ce40fecbe6f229aabae4ff6132d60809a8fb8daade948641"
}
},
"lib/client.js": {
"output": "cd0b85687902aae42158838e000f7e9a6bef488abbcbf6c7885d0a87c40e00f8",
"output": "16f3328f3d747ebcfc02accbc09419868db10618569fab7493e3875ada9d6b45",
"inputs": {
"package.json": "1bb1fb8d615f49c69665a3a89b5912fa414a3b93aa3c541ce909ff57f97e100d",
"pnpm-lock.yaml": "f1209ac70e9c2294e0f300c0aad88b64aeecd6aa19dfc929c5766c7d033615a3",
"scripts/build-client.mjs": "bbb37a7d5024772f35824a4a3b5b9bc75fc670da391151e35a3811a677e23fd1",
"scripts/build-integrity.mjs": "18e9e32c452ef6217d771675fa8b763464a27ac0cf192d5fbe5cb1682380b0d9",
"src/client/NetxopsCard.tsx": "2428aba7db1a92873a890db2ea494701317b254d2eaaff509dce7d83ac7c9509",
"src/client/NetxopsCard.tsx": "ab8c18fdfd5550b89c8d3b0df46e0448abc27b824a6cd644e9e364fdaceafd46",
"src/client/alarm-push-status-view.ts": "9ad84e4a691a6c22de4d85c45047251716929c63cada362205db98428a810a31",
"src/client/card-form.ts": "00ec10c453fffae8beac4a35a13299514c120c913100a6e4536e7a00d072b8d9",
"src/client/controller.ts": "87a77c1c139cc9d9ea501f5f77a824eebdeac786d64ed64b5de08ded9d7f3c99",
"src/client/controller.ts": "d3353feda6f6b004dd094b1fc90d743003b55ce5a9d0b46c8fb9c1eb33925cc1",
"src/client/im-delivery-catalog.ts": "faf18d4a8fdcf30033b6d905f156c6fc953d214f8d734417f3bed783994d6117",
"src/client/index.ts": "740dc42992e5930f50125fa4eba01bdbd70cd10b5121f2e6eaaad479dcb6bf06",
"src/client/index.ts": "1f73ae8e8c02e13b47c5679869359444485b4621eca20a15c632289f22e5d760",
"src/client/kb-status-view.ts": "458d1dfd9c0f93e6cdd06780c0ad813de54c3aa952fcdb63758a616ab43ffb10",
"src/client/locales.ts": "491ebc35a5daa5335f0f804fc320b11203a2c5e9d21f93a40ff57f3623f0fe4a",
"src/client/locales.ts": "5acc66e429bfd9e91c628e6d825582d9fa2f4bebcc6d114384ca5628f5deb61f",
"src/client/mcp-catalog-view.ts": "68a79f1de44760c1e08edad53e1df47eb82124924b9bb680bb430db3c7edb89a",
"src/client/sessions-export-view.ts": "3397b081318c23d9d816596566e19101df7234390a698a07481c7c1a019a66fd",
"src/client/settings-form.ts": "0318749c9afd049982a60eee7b2ca4a0a2cdcd4f574f9c87616f2ccbe39779eb",
"src/client/snapshot-store.ts": "25fdcae95490a9d7f1f520ec90981cefb4fdd576b340717e9087f1ca82c6563d",
"src/client/styles.ts": "bef5bd6a3b2f0213addfd309021029f669a65811fe366ed38b6d26e39cfb63b1",
"src/client/tools-status-view.ts": "4a1a4f9eb026a67d43cc02074a3ee915df76f8a56a188e16af9deb819f9ba166",
"src/netx/capability-groups.ts": "d916fd36bc75e081f567e0545097feb6f113b4c7365b620c1dd8174bb08caeef",
"src/client/styles.ts": "db3cbdbd00e747baa963af6436a532ff4214ce379c162a84dadf837dbaa15de2",
"src/client/tools-status-view.ts": "4ff296641164124748356a24e0d38ec88f09ee9826bf1a2ab51159026a2b2ca8",
"src/netx/capability-groups.ts": "d91046451e2c2d7968a4cae98dccc0a86c1b77c7855542d4edf604833db385a0",
"src/netx/config-values.ts": "7e8ca894d0b09f5e52686d6b293e49b46dd2cc13d714b0e0b103c3d07433e117",
"src/netx/http.ts": "1846030e34e03eeebd44f5ed87d92dcdab62429c4f3f740d6c2dcfafd1854f7d",
"src/netx/http.ts": "aa0552d6087593924f448fc8efaadda92f8803147190741f5abc3030cf32c439",
"src/netx/im-targets.ts": "123613038dee8b14ce63ea46e88029287c1a8c527e6ba6cadab02f8c95b28917",
"src/netx/kb-manifest.ts": "0389b1801aa2028b2a05044f5b4560273e9ea77f11145280d7620762df4cad02",
"src/netx/mcp-catalog.ts": "b79962da8f74f04595ed89b166b851d83cac41e8338b80b7629da2021a2c281d",
"src/netx/mcp-types.ts": "4b30d6e8fba328b0e116c43a1a90d2b686ef4b9fc648b521063876426e9c2833",
"src/session-export-shared.ts": "e30d884ec22a0ea2e016c1479f62a8bc8d7ddcce5369a36647d411d07a8620d5"
}
},
"lib/index.js": {
"output": "982dd1a42b990fdd17467a5963b11494ac1b5f32ba54dffdeee414ed84df5908",
"output": "778c85d88498127abf5bb490b251b8b6f94b3ec38a8d7ad5c46c13854cc65d1f",
"inputs": {
"package.json": "1bb1fb8d615f49c69665a3a89b5912fa414a3b93aa3c541ce909ff57f97e100d",
"pnpm-lock.yaml": "f1209ac70e9c2294e0f300c0aad88b64aeecd6aa19dfc929c5766c7d033615a3",
"scripts/build-host.mjs": "1210c12b05a31f4ed64439fe5f08750cb75605b30327210cd97584b64aadf6a8",
"scripts/build-integrity.mjs": "18e9e32c452ef6217d771675fa8b763464a27ac0cf192d5fbe5cb1682380b0d9",
"src/agent-preset-install.ts": "1f397bb8e1c9a3f13cb7e4c442c3d13c42fce94fbc4eb1d90b85a0485bda48bc",
"src/index.ts": "dc869281bf04502e63abf69d7502afa834cfb2f6cbafb8e1a97e009c15844d61",
"src/index.ts": "d5f7417240c56ec24bf9b456be26749da7130a60d84659f7dd1941965682c086",
"src/netx/alarm-dispatch.ts": "9f476f1c620e40d3e2017554ec2fa8a1848f75d7e77e7c9ee5ee0464e970ba62",
"src/netx/alarm-im.ts": "a75b37e379500cbb90f1f4b81d448d45a97fd9e744e507608910f510480cffaf",
"src/netx/alarm-push-status.ts": "21f0efa33ea6f21e3ede35b4accbc6c0c4c78fea23631f1c5b80e6e9aef8b82f",
"src/netx/alarm-push.ts": "96f3fca6a7fd50024a1d233ff43984fc0ff77261ce873b69396eb7ed5344f473",
"src/netx/alarm-session.ts": "0df97efc3debb262573c19ade84d9d50584d58b21fa2c5ae5badd382915f18e3",
"src/netx/capability-groups.ts": "d916fd36bc75e081f567e0545097feb6f113b4c7365b620c1dd8174bb08caeef",
"src/netx/capability-groups.ts": "d91046451e2c2d7968a4cae98dccc0a86c1b77c7855542d4edf604833db385a0",
"src/netx/config-values.ts": "7e8ca894d0b09f5e52686d6b293e49b46dd2cc13d714b0e0b103c3d07433e117",
"src/netx/group-skills.ts": "eb5a8b2bdbbc0c1cf3b2ece7f22e1a7a10c41a5a23be737d0c121cc5e5315b14",
"src/netx/group-tools-plugin.ts": "7a5daaead0d1f228cb35d8eba56c8efd8a529e31e0e44463cef2904ceb64397b",
"src/netx/handlers.ts": "5b6f86c96ccd57f00183c98f9fa122101d485444cc73d04a5d1529cd8522d08d",
"src/netx/heal-profile-entry.ts": "03b80f6d2e78e4b62f8fd53649f875c94afb6e66fad65197572947279bfca186",
"src/netx/http.ts": "1846030e34e03eeebd44f5ed87d92dcdab62429c4f3f740d6c2dcfafd1854f7d",
"src/netx/group-skills.ts": "e870e984569b7e3f5c05d50a1e489ddf4e222a738354df93a6a860bb35d7152c",
"src/netx/group-tools-plugin.ts": "647e5258b46648705585cd5d3765556f391810434c6c7f04f3df9e2f44d8ba67",
"src/netx/heal-profile-entry.ts": "18c2b635f727bf41d38c7c38fcc0ce0c5391c9d98b2d662db354f1908defb6be",
"src/netx/http.ts": "aa0552d6087593924f448fc8efaadda92f8803147190741f5abc3030cf32c439",
"src/netx/i18n.ts": "9278e4adaa96cae413326f4dd8c796f2712d4749bcf92e5170967e38cee2d082",
"src/netx/im-targets.ts": "123613038dee8b14ce63ea46e88029287c1a8c527e6ba6cadab02f8c95b28917",
"src/netx/json-safe.ts": "5fe610d5ec96d31096733f7e828b4b2266c19692535a232a644bb49784ee24d9",
@ -235,14 +249,17 @@
"src/netx/kb-pack-skills.ts": "a843b91bf45ba7072b330945bd692eb4c36922958f8d8875b9a163c00fd096e5",
"src/netx/kb-read-ops.ts": "1359b370293b9f4afa51774ed729525d77756f1552cd46abf4d4d6c595eaeb86",
"src/netx/kb-runtime.ts": "ebf2fd36c407439f1a59344af18d9fe7275825925adbe08719595d06161d6d5b",
"src/netx/mcp-catalog.ts": "b79962da8f74f04595ed89b166b851d83cac41e8338b80b7629da2021a2c281d",
"src/netx/mcp-remote.ts": "d79925538e6adaccd8dbf8c65a90027d98464d37a57dce00b3e3155c08b02843",
"src/netx/mcp-store.ts": "eb93e466e16c3bca81bff01407689025711be52d55e79acbdfa4bec64bd0a6f3",
"src/netx/mcp-types.ts": "4b30d6e8fba328b0e116c43a1a90d2b686ef4b9fc648b521063876426e9c2833",
"src/netx/model-language.ts": "07df448b407086237c184a71bfc78e7cb27e50d7f8c499995d8b368b14780c9e",
"src/netx/netxops-web-rpc.ts": "c6981c91b0a300806d44f45708f07c5102ec40655e5d387394608e5472ad3214",
"src/netx/preset-publication.ts": "22e357cb58bf72cb0d269246f9e3e083269feb1706a9c4f152eb042330c9e967",
"src/netx/runtime.ts": "eca10943a2ade5f0460f92a69a1d26abb77981ff56dcf00e6199b8586a818450",
"src/netx/runtime.ts": "238555c92c3972ee9de8ec062b14d6a86961ec4c64609565ba1164b1a460d386",
"src/netx/session-export.ts": "8e6e9a3094045901563366fac3d6dc74d9bfd78bbff853358d6500955d3dcff6",
"src/netx/tool-mount-status.ts": "783981774d717ca5e7bf7c0ec7273b40787ef12a6b40749c74543b30fa390faf",
"src/netx/tools.ts": "3027ee16ed8df3de020b07d0fa39d91b7c5676fbea6fcf074309175a0d853508",
"src/netx/topology-handlers.ts": "2735c22d8773b5a7b1f8e181a8bbd350fff580a41018cbddcdb7bc239fded25f",
"src/netx/tools.ts": "0dfc3fdada56ab04ce40fecbe6f229aabae4ff6132d60809a8fb8daade948641",
"src/session-export-shared.ts": "e30d884ec22a0ea2e016c1479f62a8bc8d7ddcce5369a36647d411d07a8620d5"
}
}

View file

@ -1,6 +1,8 @@
# Netx Ops tool map
Tools register by capability group. **One group ↔ one skill.**
Tools are discovered from netx **`/mcp`** (`tools/list`) and registered into DSH as `netx__<stem>`.
**One group ↔ one skill.** Group membership comes from `_meta.netx.group` on each MCP tool — there is no hardcoded catalog in the plugin.
| Group | Skill | Default in Ops preset |
|-------|-------|------------------------|
@ -8,57 +10,18 @@ Tools register by capability group. **One group ↔ one skill.**
| **topology** | `netx-topology` | off |
| **bizMonitor** | `netx-biz-monitor` | on |
Public flags mount the same group on the host for other presets.
Forced mounts: `dsh-netxops/tools-ops` | `tools-topology` | `tools-biz-monitor` (legacy aliases: `tools-nms` / `tools-common` → ops).
## Visibility switches (UX only — not a security boundary)
Model names: `netx__<stem>`. NMS tools use `Nms`; adapter `nmsProvider=zte-ume` still hits `/v1/ume/*`.
- **Group inPreset / public** — register the whole group (or not).
- **`disabledTools`** — opt-out list of **server** tool names; unlisted tools follow the group.
- **`readOnlyOnly`** — drop tools whose `annotations.readOnlyHint` is not `true`.
## ops → `netx-ops`
Authorization, CLI show/display guards, and RBAC stay on the netx `/mcp` server.
| Tool | Role |
|------|------|
| `queryNmsAlarms` … `sqlQueryNms` | Alarm + inventory + SQL |
| `listManagedNe` / `getManagedNe` / `execManagedNe` / `getNeExecJob` / `listCliTargets` | Managed CLI (login / show / async jobs) |
| `findTopologyPaths` | Fabric path lookup |
## Forced mounts
## bizMonitor → `netx-biz-monitor`
`dsh-netxops/tools-ops` | `tools-topology` | `tools-biz-monitor` (legacy aliases: `tools-nms` / `tools-common` → ops).
Host tools call netx REST directly (**not** netx-mcp). Token needs `biz-monitor:read`.
## Offline
| Tool | Role |
|------|------|
| `listBizMonitors` | Catalog: cutover projects + biz_state tasks (no id required) |
| `listBizMonitorBatches` | Cutover / collect batch ids (+ optional evaluate runs) |
| `getBizMonitorContext` | Project + templates + normalize + port map + tasks |
| `getBizMonitorBoard` | Batch / evaluate progress |
| `listBizMonitorReds` / `getBizMonitorDiffs` | Reds & diffs with evidence (raw A/B + show cmd) |
| `getBizCollectBatch` / `getBizCollectCommandRaw` | Collect audit + full CLI raw |
## topology → `netx-topology`
| Tool | Role |
|------|------|
| Tree / view / folders / membership / neighbors | Canvas CRUD |
| Fabric query / classify / edges | Inventory + adjacency |
| `layoutTopologyView` | move_nodes / catalog / … |
| `suggestSinkHubs` / `analyzeTopologyViewLayout` / `sinkTopologyDualUnits` | Dual-unit / QA (may stub → MCP layout engine) |
Canonical skill bodies: sibling **`netx/skills/`**. DSH loads them at runtime (`NETX_SKILLS_ROOT` or `../netx/skills`) or from `presets/netxops/skills` after sync.
Execution: HTTP `apiUrl` + Bearer `NETX_API_TOKEN`.
## knowledge base → `groupKb*` (not ops/topology)
Registered when KB is configured and the KB in-preset / public toggle is on. Read tools are jailed to `kbRoot`; write tools additionally need MANIFEST `paths.local` and are jailed to `{kbLocal}/refs|memories|drafts|suggestions`. Host-side FS (bypass workspace sandbox).
| Tool | Role |
|------|------|
| `kbSearch` | Keyword (space-separated, case-insensitive) or regex search over the whole package or `scope` = MANIFEST paths key(s) / relative dir. Files ranked by distinct terms matched; ≤4 hit lines each. Stat-validated in-memory cache. |
| `kbRead` | Read one text file (offset/limit line paging) or list a directory. `path` = kbRoot-relative, absolute inside kbRoot, or a paths key (`neIndex`, `cmdLib`, `rca`, …). |
| `kbWriteRef` | Site product KB under `refs/{inventory\|devices\|topology\|business\|commands\|handbooks}/`. `devices` requires `device=host_name`; primary profile slug=`PROFILE`. |
| `kbWriteMemory` | Diary under `memories/{日常笔记\|排障复盘\|AI思维链}/` — write as soon as reusable experience appears. |
| `kbWriteDraft` | New `DRAFT-…` under `drafts/` (+ `status: draft`) |
| `kbWriteSuggestion` | New file under `suggestions/{theory\|improvement}/` |
| `kbUpdateLocal` | Replace body of an existing jailed file |
| `kbDeleteLocal` | Delete one jailed file |
| `kbListLocal` | List recent `.md` under writable trees (`root` may be `refs`) |
When `/mcp` is unreachable, the Plugins card shows the last cached tools/list and a disconnected hint. Registration uses that cache so toggles still work until connect recovers.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -682,7 +682,10 @@ var EMPTY2 = {
groups: [],
plane: "",
tokenConfigured: null,
apiUrl: ""
apiUrl: "",
mcpPhase: "idle",
mcpError: "",
mcpFromCache: false
};
function asToolsMountView(value) {
if (value === null || typeof value !== "object" || Array.isArray(value))
@ -741,7 +744,29 @@ function asToolsMountView(value) {
groups = [...new Set(mounts.flatMap((m) => m.groups))];
}
const toolsEnabled = !(connection && typeof connection === "object" && connection.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;
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
};
}
async function fetchToolsStatus(call, signal) {
const result = await call(NETXOPS_RPC_CHANNEL3, TOOLS_STATUS_ENDPOINT, {}, signal);
@ -753,8 +778,10 @@ async function fetchToolsStatus(call, signal) {
function toolsMountTone(view) {
if (!view)
return "neutral";
if (view.error)
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)
@ -810,6 +837,12 @@ var 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}
@ -996,52 +1029,6 @@ var DEFAULT_CAPABILITY_GROUPS = Object.freeze({
topology: Object.freeze({ inPreset: false, public: false }),
bizMonitor: Object.freeze({ inPreset: true, public: false })
});
var TOOLS_BY_GROUP = 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"
])
});
var SKILL_DIR_BY_GROUP = Object.freeze({
ops: "ops",
topology: "topology",
@ -1059,6 +1046,30 @@ function parsePresetIds(value) {
return [...new Set(current.split(/[\s,,]+/).filter(Boolean))];
}
// src/netx/mcp-catalog.ts
function parseDisabledTools(value) {
if (Array.isArray(value)) {
return [...new Set(value.filter((item) => 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);
if (Array.isArray(parsed)) {
return [...new Set(parsed.filter((item) => typeof item === "string" && item.trim().length > 0).map((s) => s.trim()))];
}
} catch {}
}
return [...new Set(trimmed.split(/[\s,,]+/).filter(Boolean))];
}
function serializeDisabledTools(names) {
return JSON.stringify([...new Set(names.map((n) => n.trim()).filter(Boolean))].sort());
}
// src/client/NetxopsCard.tsx
var jsx_runtime = require("react/jsx-runtime");
function phaseLocaleKey(phase) {
@ -1112,9 +1123,12 @@ function toolsBadgeLabel(t, 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)
@ -1420,7 +1434,58 @@ function CapabilityGroupBlock(props) {
})
]
}),
props.targets
props.targets,
props.tools ? /* @__PURE__ */ jsx_runtime.jsxs("details", {
className: "dsh-nx-toolList",
children: [
/* @__PURE__ */ jsx_runtime.jsx("summary", {
children: props.toolCount ?? props.tools.length
}),
props.tools.length === 0 ? /* @__PURE__ */ jsx_runtime.jsx("p", {
className: "dsh-nx-hint",
children: props.emptyLabel ?? ""
}) : /* @__PURE__ */ jsx_runtime.jsx("div", {
className: "dsh-nx-toolRows",
children: 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 /* @__PURE__ */ jsx_runtime.jsxs("label", {
className: "dsh-nx-checkRow dsh-nx-toolRow",
children: [
/* @__PURE__ */ jsx_runtime.jsx("input", {
type: "checkbox",
checked: enabled,
disabled: props.disabled,
onChange: (event) => {
props.onToggleTool?.(tool.name, event.target.checked);
}
}),
/* @__PURE__ */ jsx_runtime.jsxs("span", {
className: "dsh-nx-toolMeta",
children: [
/* @__PURE__ */ jsx_runtime.jsxs("span", {
className: "dsh-nx-toolName",
children: [
tool.name,
tool.readOnlyHint !== true ? /* @__PURE__ */ jsx_runtime.jsx("span", {
className: "dsh-nx-badgeMuted",
children: props.writeBadge ?? ""
}) : null
]
}),
short ? /* @__PURE__ */ jsx_runtime.jsx("span", {
className: "dsh-nx-hint",
children: short
}) : null
]
})
]
}, tool.name);
})
})
]
}) : null
]
});
}
@ -1677,9 +1742,23 @@ function NetxopsCard(props) {
/* @__PURE__ */ jsx_runtime.jsx("h3", {
children: t("sectionCapabilities")
}),
/* @__PURE__ */ jsx_runtime.jsx(StatusBadge, {
tone: toolsMountTone(state.toolsStatus),
label: toolsBadgeLabel(t, state.toolsStatus)
/* @__PURE__ */ jsx_runtime.jsxs("span", {
className: "dsh-nx-badges",
children: [
/* @__PURE__ */ jsx_runtime.jsx(StatusBadge, {
tone: toolsMountTone(state.toolsStatus),
label: toolsBadgeLabel(t, state.toolsStatus)
}),
/* @__PURE__ */ jsx_runtime.jsx("button", {
type: "button",
className: "dsh-nx-btn",
disabled: disabled || state.mcpRefreshBusy,
onClick: () => {
props.refreshMcpCatalog();
},
children: state.mcpRefreshBusy ? t("mcpRefreshing") : t("mcpRefresh")
})
]
})
]
}),
@ -1696,60 +1775,104 @@ function NetxopsCard(props) {
onReset: () => {
props.resetField("toolsEnabled");
},
error: state.toolsStatus?.error ? /* @__PURE__ */ jsx_runtime.jsx("p", {
error: state.toolsStatus?.error || state.toolsStatus?.mcpError || state.mcpCatalog?.error ? /* @__PURE__ */ jsx_runtime.jsxs("p", {
className: "dsh-nx-invalid",
role: "status",
children: state.toolsStatus.error
children: [
state.toolsStatus?.error || state.toolsStatus?.mcpError || state.mcpCatalog?.error,
state.toolsStatus?.mcpFromCache || state.mcpCatalog?.fromCache ? ` · ${t("mcpDisconnected")}` : ""
]
}) : null
}),
/* @__PURE__ */ jsx_runtime.jsx(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,
/* @__PURE__ */ jsx_runtime.jsx(ToggleField, {
id: "netxops-readonly-only",
label: t("readOnlyOnly"),
checked: state.readOnlyOnly.text === "true",
disabled: publicationDisabled,
onEditInPreset: (checked) => {
props.edit("groupOpsInPreset", checked ? "true" : "false");
overridden: state.readOnlyOnly.overridden,
resetLabel: t("reset"),
onToggle: (checked) => {
props.edit("readOnlyOnly", String(checked));
},
onEditPublic: (checked) => {
props.edit("groupOpsPublic", checked ? "true" : "false");
}
}),
/* @__PURE__ */ jsx_runtime.jsx(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");
}
}),
/* @__PURE__ */ jsx_runtime.jsx(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");
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, enabled) => {
props.setToolEnabled(name, enabled);
}
};
return /* @__PURE__ */ jsx_runtime.jsxs(jsx_runtime.Fragment, {
children: [
/* @__PURE__ */ jsx_runtime.jsx(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
}),
/* @__PURE__ */ jsx_runtime.jsx(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
}),
/* @__PURE__ */ jsx_runtime.jsx(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 ? /* @__PURE__ */ jsx_runtime.jsxs("details", {
className: "dsh-nx-diagnostics",
children: [
@ -2097,6 +2220,22 @@ function apiUrlField(field) {
}
};
}
function booleanField(field) {
return {
field,
format: (value) => value === true ? "true" : "false",
parse: (text) => {
const normalized = text.trim().toLowerCase();
if (normalized === "true" || normalized === "1" || normalized === "yes") {
return { kind: "set", value: true };
}
if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "") {
return { kind: "clear" };
}
return;
}
};
}
function booleanFieldPersistFalse(field) {
return {
field,
@ -2306,6 +2445,68 @@ class CardForm {
}
}
// src/client/mcp-catalog-view.ts
var TOOLS_CATALOG_ENDPOINT = "tools.catalog";
var TOOLS_REFRESH_ENDPOINT = "tools.refresh";
var EMPTY3 = {
phase: "idle",
mcpUrl: "",
error: "",
listedAt: null,
fromCache: false,
tools: []
};
function asMcpCatalogView(value) {
if (value === null || typeof value !== "object" || Array.isArray(value))
return { ...EMPTY3 };
const row = value;
const tools = [];
if (Array.isArray(row.tools)) {
for (const raw of row.tools) {
if (!raw || typeof raw !== "object" || Array.isArray(raw))
continue;
const t = raw;
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
};
}
async function fetchMcpCatalog(call, signal) {
const result = await call(NETXOPS_RPC_CHANNEL3, TOOLS_CATALOG_ENDPOINT, {}, signal);
if (result !== null && typeof result === "object" && result.ok === true) {
return asMcpCatalogView(result.value);
}
return { ...EMPTY3 };
}
async function refreshMcpCatalogRemote(call, signal) {
const result = await call(NETXOPS_RPC_CHANNEL3, TOOLS_REFRESH_ENDPOINT, {}, signal);
if (result !== null && typeof result === "object") {
const row = result;
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 { ...EMPTY3, phase: "error", error: "refresh_failed" };
}
// src/client/controller.ts
var NETXOPS_NS = "netxops";
var NETXOPS_SECTION_ID = "dsh-netxops";
@ -2328,6 +2529,9 @@ class NetxopsCardController {
alarmPushStatus = null;
toolsStatus = null;
toolsStatusInFlight = false;
mcpCatalog = null;
mcpCatalogInFlight = false;
mcpRefreshBusy = false;
presetCatalog = [];
presetCatalogError = false;
presetCatalogInFlight = false;
@ -2375,6 +2579,8 @@ class NetxopsCardController {
booleanFieldPersistFalse("alarmPushEnabled"),
booleanFieldPersistFalse("alarmDeliverDsh"),
booleanFieldPersistFalse("alarmDeliverIm"),
booleanField("readOnlyOnly"),
textField("disabledTools"),
textField("imTargets"),
textField("imBotId"),
textField("imTargetId")
@ -2412,6 +2618,10 @@ class NetxopsCardController {
this.toolsStatus = null;
changed = true;
}
if (this.mcpCatalog !== null) {
this.mcpCatalog = null;
changed = true;
}
this.presetCatalog = [];
this.presetCatalogError = true;
changed = true;
@ -2435,6 +2645,7 @@ class NetxopsCardController {
this.startStatusPoll();
this.refreshAlarmPushStatus();
this.refreshToolsStatus();
this.refreshMcpCatalogStatus();
this.refreshPresetCatalog();
this.reloadKbFromHost();
this.refreshKbPreview();
@ -2455,6 +2666,7 @@ class NetxopsCardController {
this.pollTimer = setInterval(() => {
this.refreshAlarmPushStatus();
this.refreshToolsStatus();
this.refreshMcpCatalogStatus();
this.refreshPresetCatalog();
this.refreshKbStatus();
this.refreshImDeliveryCatalog();
@ -2499,6 +2711,50 @@ class NetxopsCardController {
this.toolsStatusInFlight = false;
}
}
async refreshMcpCatalogStatus() {
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 {} finally {
this.mcpCatalogInFlight = false;
}
}
async runMcpRefresh() {
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 {} finally {
this.mcpRefreshBusy = false;
this.store.set(this.projection());
}
}
setToolEnabled(serverName, enabled) {
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);
this.form.save(["disabledTools"]);
}
async refreshPresetCatalog() {
const call = this.rpcCall;
if (!call || this.presetCatalogInFlight)
@ -2759,6 +3015,10 @@ 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,
@ -2826,7 +3086,9 @@ class NetxopsCardController {
"groupBizMonitorInPreset",
"groupBizMonitorPublic",
"groupKbInPreset",
"groupKbPublic"
"groupKbPublic",
"disabledTools",
"readOnlyOnly"
]);
return {
hooks: { netxopsCard: this.store },
@ -2854,6 +3116,12 @@ class NetxopsCardController {
},
browseKbRoot: () => {
this.browseKbRoot();
},
refreshMcpCatalog: () => {
this.runMcpRefresh();
},
setToolEnabled: (serverName, enabled) => {
this.setToolEnabled(serverName, enabled);
}
};
}
@ -2969,6 +3237,12 @@ var en = {
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.",
@ -3056,6 +3330,12 @@ var zh = {
toolsStatusEmpty: "暂无活跃工具",
toolsStatusError: "发布异常",
toolsStatusUnknown: "状态不可用",
mcpDisconnected: "未连接",
mcpRefresh: "刷新",
mcpRefreshing: "刷新中…",
readOnlyOnly: "只允许只读工具",
toolWriteBadge: "写",
toolListEmpty: "暂无工具清单",
sessionsExportCount: "{count} 个会话",
sessionsExportUnavailable: "无法导出",
sessionsExportNoPersistence: "会话存储不可用。",
@ -3196,7 +3476,9 @@ var MEMORY_DEFAULTS = {
groupBizMonitorPublic: false,
kbRoot: "",
groupKbInPreset: true,
groupKbPublic: false
groupKbPublic: false,
disabledTools: "[]",
readOnlyOnly: false
};
function apply(ctx) {
ctx.effect(() => {

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,841 +0,0 @@
/**
* netx REST tool handlers — port of packages/netx-mcp http_tools.py.
*/
import { type NetxClient, quoteNeId, type NetxJson } from './http.ts'
import { omitUndefined } from './json-safe.ts'
const EXEC_MAX_COMMANDS_CAP = 50
const EXEC_MAX_COMMANDS_DEFAULT = 5
function execMaxCommands(): number {
const raw = Number.parseInt(String(process.env.NETX_NE_EXEC_MAX_COMMANDS ?? ''), 10)
const n = Number.isFinite(raw) ? raw : EXEC_MAX_COMMANDS_DEFAULT
return Math.max(1, Math.min(EXEC_MAX_COMMANDS_CAP, n))
}
function asyncMinNes(): number {
const raw = Number.parseInt(String(process.env.NETX_NE_EXEC_ASYNC_MIN_NES ?? ''), 10)
const n = Number.isFinite(raw) ? raw : 4
return Math.max(0, Math.min(50, n))
}
function truthyAsyncFlag(raw: unknown): boolean | undefined {
if (raw === undefined || raw === null) return undefined
if (typeof raw === 'boolean') return raw
const text = String(raw).trim().toLowerCase()
if (!text) return undefined
if (['1', 'true', 'yes', 'on'].includes(text)) return true
if (['0', 'false', 'no', 'off'].includes(text)) return false
return undefined
}
function countExecNeTargets(args: NetxJson): number {
let n = 0
for (const key of ['ne_ids', 'nms_ne_ids', 'ume_ne_ids'] as const) {
const val = strList(args, key)
if (val.length > 0) n = Math.max(n, val.length)
}
const targets = args.targets
if (Array.isArray(targets)) {
n = Math.max(n, targets.filter(t => typeof t === 'object' && t !== null && !Array.isArray(t)).length)
}
if (n === 0) {
if (str(args, 'ne_id').trim() || nmsOrUme(args, 'nms_ne_id', 'ume_ne_id')) return 1
}
return n
}
function shouldRunExecAsync(args: NetxJson): boolean {
const flag = truthyAsyncFlag(args.async)
if (flag === false) return false
if (flag === true) return true
const minN = asyncMinNes()
if (minN <= 0) return false
return countExecNeTargets(args) >= minN
}
const UME_RAW_FIELD_PRESETS: Record<string, string[]> = {
brief: [
'alarm_alarm_key', 'alarm_host_name', 'alarm_perceived_severity', 'alarm_event_type',
'alarm_last_seen_at', 'ne_host_name', 'ne_user_label', 'ne_ne_name', 'ne_ip_address', 'ne_exists',
],
evidence: [
'alarm_alarm_key', 'alarm_host_name', 'alarm_object_name', 'alarm_event_type',
'alarm_native_probable_cause', 'alarm_perceived_severity', 'alarm_is_cleared',
'alarm_time_created', 'alarm_last_seen_at', 'ne_host_name', 'ne_user_label',
'ne_ne_name', 'ne_ip_address', 'ne_connection_status', 'ne_exists',
],
ne_debug: [
'alarm_alarm_key', 'alarm_ne_id', 'alarm_perceived_severity', 'alarm_last_seen_at',
'ne_user_label', 'ne_ne_name', 'ne_ip_address', 'ne_ipv6_address', 'ne_device_level',
'ne_host_name', 'ne_connection_status', 'ne_admin_status', 'ne_address_type',
'ne_maintain_status', 'ne_exists',
],
}
function asRecord(value: unknown): NetxJson {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? value as NetxJson
: {}
}
function str(args: NetxJson, key: string, fallback = ''): string {
const v = args[key]
if (typeof v === 'string') return v
if (typeof v === 'number' || typeof v === 'boolean') return String(v)
return fallback
}
/** Prefer generic `nms_*` aliases; fall back to historical `ume_*` wire names. */
function nmsOrUme(args: NetxJson, nmsKey: string, umeKey: string): string {
return str(args, nmsKey).trim() || str(args, umeKey).trim()
}
function nmsOrUmeList(args: NetxJson, nmsKey: string, umeKey: string): string[] {
const primary = strList(args, nmsKey)
return primary.length > 0 ? primary : strList(args, umeKey)
}
function num(args: NetxJson, key: string): number | undefined {
const v = args[key]
return typeof v === 'number' && Number.isFinite(v) ? v : undefined
}
function bool(args: NetxJson, key: string): boolean | undefined {
const v = args[key]
return typeof v === 'boolean' ? v : undefined
}
function strList(args: NetxJson, key: string): string[] {
const v = args[key]
if (!Array.isArray(v)) return []
return v.map(x => String(x).trim()).filter(x => x.length > 0)
}
function clampInt(value: number | undefined, fallback: number, min: number, max: number): number {
const n = value === undefined ? fallback : Math.trunc(value)
return Math.max(min, Math.min(max, n))
}
function putStr(
params: Record<string, string | number | boolean>,
args: NetxJson,
keys: string[],
): void {
for (const key of keys) {
const v = str(args, key).trim()
if (v) params[key] = v
}
}
export async function queryUmeAlarms(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
let page = clampInt(num(args, 'page'), 1, 1, 2)
const pageSize = clampInt(num(args, 'page_size'), 50, 1, 500)
const params: Record<string, string | number | boolean> = { page, page_size: pageSize }
putStr(params, args, ['severity', 'ne_id', 'host_name', 'time_from', 'time_to'])
const keyword = str(args, 'keyword').trim()
const neName = str(args, 'ne_name').trim()
if (keyword) params.keyword = keyword
else if (neName) params.keyword = neName
return client.get('/v1/ume/alarms', params, signal)
}
export async function aggregateUmeAlarmsRaw(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const params: Record<string, string | number | boolean> = {}
putStr(params, args, [
'group_by', 'group_by2', 'severity', 'is_cleared', 'ne_id', 'event_type',
'keyword', 'time_from', 'time_to', 'limit',
])
if ('exclude_missing_host' in args) {
const flag = bool(args, 'exclude_missing_host')
if (flag !== undefined) params.exclude_missing_host = flag
}
return client.get('/v1/ume/alarms/aggregate/raw', params, signal)
}
export async function aggregateUmeAlarms(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
if (str(args, 'group_by').trim()) return aggregateUmeAlarmsRaw(client, args, signal)
const topNe = clampInt(num(args, 'top_ne'), 50, 0, 500)
const params: Record<string, string | number | boolean> = { top_ne: topNe }
if ('exclude_missing_host' in args) {
const flag = bool(args, 'exclude_missing_host')
if (flag !== undefined) params.exclude_missing_host = flag
}
putStr(params, args, ['severity', 'time_from', 'time_to'])
return client.get('/v1/ume/alarms/aggregate', params, signal)
}
export async function runUmeDiagnostics(client: NetxClient, _args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
return client.get('/v1/ume/diagnostics', undefined, signal)
}
export async function queryUmeNeInventory(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const params: Record<string, string | number | boolean> = {
page: clampInt(num(args, 'page'), 1, 1, Number.MAX_SAFE_INTEGER),
page_size: clampInt(num(args, 'page_size'), 50, 1, 500),
}
putStr(params, args, ['keyword'])
return client.get('/v1/ume/inventory/ne', params, signal)
}
export async function getUmeNe(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const neId = str(args, 'ne_id').trim()
if (!neId) return { ok: false, error: 'ne_id_required', error_code: 'ne_id_required' }
return client.get(`/v1/ume/inventory/ne/${quoteNeId(neId)}`, undefined, signal)
}
export async function queryUmeAlarmsRaw(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const params: Record<string, string | number | boolean> = {
page: clampInt(num(args, 'page'), 1, 1, Number.MAX_SAFE_INTEGER),
page_size: clampInt(num(args, 'page_size'), 50, 1, 500),
}
putStr(params, args, [
'severity', 'is_cleared', 'ne_id', 'event_type', 'keyword',
'time_from', 'time_to', 'order_by', 'order',
])
let fields = strList(args, 'select_fields')
if (fields.length === 0) {
const preset = str(args, 'field_preset').trim().toLowerCase()
fields = UME_RAW_FIELD_PRESETS[preset] ?? []
}
if (fields.length > 0) params.select_fields = fields.join(',')
return client.get('/v1/ume/alarms/raw', params, signal)
}
export async function listUmeAlarmFields(client: NetxClient, _args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
return client.get('/v1/ume/alarms/fields', undefined, signal)
}
export async function sqlQueryUme(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const sql = str(args, 'sql').trim()
if (!sql) return { ok: false, error: 'sql_required' }
const limit = clampInt(num(args, 'limit'), 200, 1, 2000)
const statementTimeoutMs = clampInt(num(args, 'statement_timeout_ms'), 0, 0, 30_000)
return client.post('/v1/sql/ume_query', {
sql,
limit,
statement_timeout_ms: statementTimeoutMs,
}, signal, 60_000)
}
export async function listManagedNe(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const keyword = str(args, 'keyword').trim()
const vendor = str(args, 'vendor').trim()
const connectStatus = str(args, 'connect_status').trim()
if (!(keyword || vendor || connectStatus)) {
return { ok: false, error: 'managed_ne_filter_required', error_code: 'managed_ne_filter_required' }
}
if (keyword && keyword.length < 2) {
return { ok: false, error: 'managed_ne_keyword_too_short', error_code: 'managed_ne_keyword_too_short' }
}
const params: Record<string, string | number | boolean> = {
page: clampInt(num(args, 'page'), 1, 1, Number.MAX_SAFE_INTEGER),
page_size: clampInt(num(args, 'page_size'), 20, 1, 100),
}
if (keyword) params.keyword = keyword
if (vendor) params.vendor = vendor
if (connectStatus) params.connect_status = connectStatus
return client.get('/v1/managed-ne', params, signal)
}
export async function getManagedNe(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const neId = (
str(args, 'ne_id') || str(args, 'managed_ne_id') || str(args, 'id')
).trim()
if (!neId) {
return {
ok: false,
error: 'ne_id_required',
error_code: 'ne_id_required',
hint: 'Pass managed NE id from listManagedNe/listCliTargets (source=managed). For NMS inventory UUIDs use execManagedNe(nms_ne_id=...) or getNmsNe, not getManagedNe.',
example: { ne_id: '<managed-ne-uuid-from-listManagedNe>' },
}
}
const out = await client.get(`/v1/managed-ne/${quoteNeId(neId)}`, undefined, signal)
if (out.ok === false) {
const detail = `${str(out, 'detail')}${str(out, 'error')}`.toLowerCase()
if (detail.includes('404') || detail.includes('not_found') || detail.includes('not found') || out.error === 'netx_http_404') {
return {
...out,
hint: 'Managed NE not found for this ne_id. Call listManagedNe(keyword=...) or listCliTargets(source=managed) first. If this is an NMS inventory id, use execManagedNe(nms_ne_id=...) / getNmsNe instead of getManagedNe.',
}
}
}
return out
}
export async function execManagedNe(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const maxCmds = execMaxCommands()
const wantAsync = shouldRunExecAsync(args)
const targetsRaw = args.targets
const neIds = strList(args, 'ne_ids')
const umeNeIds = nmsOrUmeList(args, 'nms_ne_ids', 'ume_ne_ids')
const sharedCommands = strList(args, 'commands')
const multi = (Array.isArray(targetsRaw) && targetsRaw.length > 0)
|| neIds.length > 0
|| umeNeIds.length > 0
if (multi) {
const body: NetxJson = {}
if (Array.isArray(targetsRaw) && targetsRaw.length > 0) {
const cleaned: NetxJson[] = []
for (const t of targetsRaw) {
if (typeof t !== 'object' || t === null || Array.isArray(t)) continue
const row = t as NetxJson
const item: NetxJson = {}
const neId = str(row, 'ne_id').trim()
const umeNeId = nmsOrUme(row, 'nms_ne_id', 'ume_ne_id')
if (neId) item.ne_id = neId
if (umeNeId) item.ume_ne_id = umeNeId
const cmds = Array.isArray(row.commands)
? row.commands.map(c => String(c).trim()).filter(c => c.length > 0)
: []
if (cmds.length > 0) item.commands = cmds
if (Object.keys(item).length > 0) cleaned.push(item)
}
body.targets = cleaned
}
if (neIds.length > 0) body.ne_ids = neIds
if (umeNeIds.length > 0) body.ume_ne_ids = umeNeIds
if (sharedCommands.length > 0) {
if (sharedCommands.length > maxCmds) {
return { ok: false, error: 'too_many_commands', error_code: 'too_many_commands' }
}
body.commands = sharedCommands
}
body.read_timeout_sec = clampInt(num(args, 'read_timeout_sec'), 60, 10, 120)
const concurrency = num(args, 'concurrency')
if (concurrency !== undefined) body.concurrency = clampInt(concurrency, 4, 1, 8)
if (wantAsync) {
const out = await client.post('/v1/managed-ne/exec-jobs', body, signal, 60_000)
if (out.ok !== true) return out
const data = asRecord(out.data)
return Object.keys(data).length > 0 ? data : out
}
const out = await client.post('/v1/managed-ne/exec-batch', body, signal, 600_000)
if (out.ok !== true) return out
const data = asRecord(out.data)
if (data.ok === false) {
return { ok: false, data, error: str(data, 'error', 'exec_batch_failed') }
}
return { ok: true, data }
}
const neId = str(args, 'ne_id').trim()
const umeNeId = nmsOrUme(args, 'nms_ne_id', 'ume_ne_id')
if (Boolean(neId) === Boolean(umeNeId)) {
return {
ok: false,
error: 'exactly_one_of_ne_id_or_nms_ne_id_required',
error_code: 'exactly_one_of_ne_id_or_nms_ne_id_required',
hint: 'For one NE pass ne_id OR nms_ne_id (alias ume_ne_id). For many NEs pass ne_ids / nms_ne_ids with shared commands, or targets[] — one call, concurrent on server.',
}
}
if (sharedCommands.length === 0) {
return { ok: false, error: 'commands_required', error_code: 'commands_required' }
}
if (sharedCommands.length > maxCmds) {
return { ok: false, error: 'too_many_commands', error_code: 'too_many_commands' }
}
const body: NetxJson = {
commands: sharedCommands,
read_timeout_sec: clampInt(num(args, 'read_timeout_sec'), 60, 10, 120),
}
if (neId) body.ne_id = neId
if (umeNeId) body.ume_ne_id = umeNeId
if (wantAsync) {
const out = await client.post('/v1/managed-ne/exec-jobs', body, signal, 60_000)
if (out.ok !== true) return out
const data = asRecord(out.data)
return Object.keys(data).length > 0 ? data : out
}
const out = await client.post('/v1/managed-ne/exec', body, signal, 300_000)
if (out.ok !== true) return out
const data = asRecord(out.data)
if (data.ok === false) {
return { ok: false, data, error: str(data, 'error', 'exec_failed') }
}
return { ok: true, data }
}
export async function getNeExecJob(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const jobId = str(args, 'job_id').trim()
if (!jobId) {
return {
ok: false,
error: 'job_id_required',
error_code: 'job_id_required',
hint: 'Pass job_id from execManagedNe async ack.',
}
}
return client.get(`/v1/managed-ne/exec-jobs/${quoteNeId(jobId)}`, undefined, signal)
}
export async function listCliTargets(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const params: Record<string, string | number | boolean> = {
page: clampInt(num(args, 'page'), 1, 1, Number.MAX_SAFE_INTEGER),
page_size: clampInt(num(args, 'page_size'), 50, 1, 500),
}
putStr(params, args, ['source', 'keyword'])
return client.get('/v1/cli/targets', params, signal)
}
export async function findTopologyPaths(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
const fromUid = nmsOrUme(args, 'from_nms_ne_id', 'from_ume_ne_id')
const fromMid = str(args, 'from_managed_ne_id').trim()
const toUid = nmsOrUme(args, 'to_nms_ne_id', 'to_ume_ne_id')
const toMid = str(args, 'to_managed_ne_id').trim()
if (Boolean(fromUid) === Boolean(fromMid)) {
return { ok: false, error: 'exactly_one_of_from_nms_ne_id_or_from_managed_ne_id_required' }
}
if (Boolean(toUid) === Boolean(toMid)) {
return { ok: false, error: 'exactly_one_of_to_nms_ne_id_or_to_managed_ne_id_required' }
}
let detail = str(args, 'detail', 'summary').trim().toLowerCase() || 'summary'
if (detail !== 'summary' && detail !== 'full') detail = 'summary'
const body: NetxJson = {
max_paths: clampInt(num(args, 'max_paths'), 3, 1, 10),
max_hops: clampInt(num(args, 'max_hops'), 6, 1, 12),
layer: str(args, 'layer', 'physical').trim() || 'physical',
detail,
}
if (fromUid) body.from_ume_ne_id = fromUid
else body.from_managed_ne_id = fromMid
if (toUid) body.to_ume_ne_id = toUid
else body.to_managed_ne_id = toMid
return client.post('/v1/topology/fabric/paths', body, signal, 30_000)
}
// ── bizMonitor group (cutover / biz_state read) ─────────────────────────────
function taskBriefNe(brief: unknown): string {
const rec = asRecord(brief)
const name = typeof rec.ne_name === 'string' ? rec.ne_name.trim() : ''
const ip = typeof rec.ne_ip === 'string' ? rec.ne_ip.trim() : ''
if (name && ip) return `${name} (${ip})`
return name || ip
}
function slimMigrationProject(row: NetxJson): NetxJson {
const mt = asRecord(row.monitor_template)
return omitUndefined({
id: row.id,
name: row.name,
status: row.status,
note: row.note,
monitor_template_id: row.monitor_template_id,
monitor_template_name: typeof mt.name === 'string' ? mt.name : '',
old_task_id: row.old_task_id,
new_task_id: row.new_task_id,
old_hf_task_id: row.old_hf_task_id,
new_hf_task_id: row.new_hf_task_id,
old_ne: taskBriefNe(row.old_task),
new_ne: taskBriefNe(row.new_task),
hf_interval_sec: row.hf_interval_sec,
hf_start_at: row.hf_start_at,
hf_end_at: row.hf_end_at,
updated_at: row.updated_at,
})
}
function matchesBizMonitorQ(row: NetxJson, q: string): boolean {
if (!q) return true
const hay = [
row.id, row.name, row.note, row.status, row.ne_name, row.ne_ip, row.ne_id,
row.old_ne, row.new_ne, row.monitor_template_name, row.purpose, row.vendor,
]
.map((v) => (typeof v === 'string' || typeof v === 'number' ? String(v).toLowerCase() : ''))
.join(' ')
return hay.includes(q)
}
/**
* Catalog entry: list cutover projects and/or biz_state tasks (no project_id required).
* Use this first when the user did not give an id; then call getBizMonitorContext.
*/
export async function listBizMonitors(
client: NetxClient,
args: NetxJson,
signal?: AbortSignal,
): Promise<NetxJson> {
const kindRaw = str(args, 'kind', 'all').trim().toLowerCase() || 'all'
const kind = kindRaw === 'project' ? 'projects'
: kindRaw === 'task' ? 'tasks'
: kindRaw
if (kind !== 'all' && kind !== 'projects' && kind !== 'tasks') {
return { ok: false, error: 'kind_invalid', detail: 'kind must be all|projects|tasks' }
}
const purpose = str(args, 'purpose').trim()
const status = str(args, 'status').trim().toLowerCase()
const q = str(args, 'q').trim().toLowerCase()
const limit = clampInt(num(args, 'limit') ?? 100, 100, 1, 500)
const wantProjects = kind === 'all' || kind === 'projects'
const wantTasks = kind === 'all' || kind === 'tasks'
const fetches: Array<Promise<NetxJson>> = []
if (wantProjects) fetches.push(client.get('/v1/biz-migration/projects', {}, signal))
else fetches.push(Promise.resolve({ ok: true, data: { items: [] } }))
if (wantTasks) {
const params: Record<string, string | number | boolean> = {}
if (purpose) params.purpose = purpose
fetches.push(client.get('/v1/biz-state/tasks', params, signal))
} else {
fetches.push(Promise.resolve({ ok: true, data: { items: [] } }))
}
const [projRes, taskRes] = await Promise.all(fetches)
if (wantProjects && projRes.ok === false) return projRes
if (wantTasks && taskRes.ok === false) return taskRes
const projItems = Array.isArray(asRecord(projRes.data).items)
? (asRecord(projRes.data).items as unknown[])
: []
const taskItems = Array.isArray(asRecord(taskRes.data).items)
? (asRecord(taskRes.data).items as unknown[])
: []
let projects = projItems
.map((row) => slimMigrationProject(asRecord(row)))
.filter((row) => {
if (status && String(row.status || '').toLowerCase() !== status) return false
return matchesBizMonitorQ(row, q)
})
let tasks = taskItems
.map((row) => asRecord(row))
.filter((row) => {
if (status && String(row.status || '').toLowerCase() !== status) return false
return matchesBizMonitorQ(row, q)
})
const projectsTotal = projects.length
const tasksTotal = tasks.length
projects = projects.slice(0, limit)
tasks = tasks.slice(0, limit)
return {
ok: true,
data: omitUndefined({
kind,
projects: wantProjects ? projects : undefined,
tasks: wantTasks ? tasks : undefined,
counts: {
projects: wantProjects ? projectsTotal : 0,
tasks: wantTasks ? tasksTotal : 0,
projects_returned: wantProjects ? projects.length : 0,
tasks_returned: wantTasks ? tasks.length : 0,
},
next: 'Pass project_id (or task_id) to netx__getBizMonitorContext; then listBizMonitorBatches for batch_id / run_id.',
}),
}
}
function slimCutoverBatch(row: NetxJson): NetxJson {
return omitUndefined({
id: row.id,
project_id: row.project_id,
batch_label: row.batch_label,
status: row.status,
accept_status: row.accept_status,
accept_run_id: row.accept_run_id,
started_at: row.started_at,
ended_at: row.ended_at,
note: row.note,
created_at: row.created_at,
updated_at: row.updated_at,
})
}
function slimCollectBatch(row: NetxJson, taskId = ''): NetxJson {
return omitUndefined({
id: row.id,
task_id: row.task_id || taskId || undefined,
status: row.status,
ne_name: row.ne_name,
command_count: row.command_count,
row_count: row.row_count,
is_baseline: row.is_baseline,
protected: row.protected,
message: row.message,
started_at: row.started_at,
ended_at: row.ended_at,
})
}
function slimEvalRun(row: NetxJson): NetxJson {
const summary = asRecord(row.summary)
const progress = asRecord(summary.progress)
return omitUndefined({
id: row.id,
batch_id: row.batch_id,
purpose: row.purpose,
old_batch_id: row.old_batch_id,
new_batch_id: row.new_batch_id,
created_at: row.created_at,
anomaly: summary.anomaly,
progress_ok: progress.ok ?? summary.progress_ok,
progress_total: progress.total ?? summary.progress_total,
})
}
/**
* List cutover batches, biz_state collect batches, and/or evaluate runs.
* Needs project_id and/or task_id and/or cutover batch_id (for runs).
*/
export async function listBizMonitorBatches(
client: NetxClient,
args: NetxJson,
signal?: AbortSignal,
): Promise<NetxJson> {
const projectId = str(args, 'project_id').trim()
const taskId = str(args, 'task_id').trim()
const batchId = str(args, 'batch_id').trim()
const kindRaw = str(args, 'kind', 'auto').trim().toLowerCase() || 'auto'
const includeRuns = args.include_runs === true || args.include_runs === 'true' || args.include_runs === 1
const status = str(args, 'status').trim().toLowerCase()
const limit = clampInt(num(args, 'limit') ?? 50, 50, 1, 200)
const runsLimit = clampInt(num(args, 'runs_limit') ?? 10, 10, 1, 50)
let kind = kindRaw
if (kind === 'auto') {
if (batchId && !projectId && !taskId) kind = 'runs'
else if (projectId && !taskId) kind = 'cutover'
else if (taskId && !projectId) kind = 'collect'
else if (projectId && taskId) kind = 'all'
else if (batchId) kind = 'runs'
else {
return {
ok: false,
error: 'project_id_or_task_id_or_batch_id_required',
detail: 'Pass project_id (cutover batches), task_id (collect batches), and/or batch_id (evaluate runs).',
}
}
}
if (!['auto', 'all', 'cutover', 'collect', 'runs'].includes(kind)) {
return { ok: false, error: 'kind_invalid', detail: 'kind must be auto|all|cutover|collect|runs' }
}
const wantCutover = kind === 'all' || kind === 'cutover'
const wantCollect = kind === 'all' || kind === 'collect'
const wantRuns = kind === 'runs' || (includeRuns && (wantCutover || Boolean(batchId)))
if (wantCutover && !projectId) {
return { ok: false, error: 'project_id_required', detail: 'kind=cutover|all needs project_id' }
}
if (wantCollect && !taskId) {
return { ok: false, error: 'task_id_required', detail: 'kind=collect|all needs task_id' }
}
if (kind === 'runs' && !batchId) {
return { ok: false, error: 'batch_id_required', detail: 'kind=runs needs cutover batch_id' }
}
let cutoverBatches: NetxJson[] = []
let collectBatches: NetxJson[] = []
let runs: NetxJson[] = []
if (wantCutover) {
const res = await client.get(
`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/batches`,
{},
signal,
)
if (res.ok === false) return res
const items = Array.isArray(asRecord(res.data).items)
? (asRecord(res.data).items as unknown[])
: []
cutoverBatches = items
.map((row) => slimCutoverBatch(asRecord(row)))
.filter((row) => {
if (batchId && String(row.id || '') !== batchId) return false
if (status && String(row.status || '').toLowerCase() !== status) return false
return true
})
.slice(0, limit)
}
if (wantCollect) {
const res = await client.get(
`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/batches`,
{ limit },
signal,
)
if (res.ok === false) return res
const items = Array.isArray(asRecord(res.data).items)
? (asRecord(res.data).items as unknown[])
: []
collectBatches = items
.map((row) => slimCollectBatch(asRecord(row), taskId))
.filter((row) => {
if (status && String(row.status || '').toLowerCase() !== status) return false
return true
})
.slice(0, limit)
}
if (wantRuns) {
const runBatchIds = kind === 'runs'
? [batchId]
: cutoverBatches.map((b) => String(b.id || '').trim()).filter(Boolean).slice(0, includeRuns ? 5 : 0)
if (kind === 'runs' && batchId) {
const res = await client.get(
`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/runs`,
{ limit: runsLimit },
signal,
)
if (res.ok === false) return res
const items = Array.isArray(asRecord(res.data).items)
? (asRecord(res.data).items as unknown[])
: []
runs = items.map((row) => slimEvalRun(asRecord(row))).slice(0, runsLimit)
} else if (includeRuns && runBatchIds.length > 0) {
const settled = await Promise.all(
runBatchIds.map(async (id) => {
const res = await client.get(
`/v1/biz-migration/batches/${encodeURIComponent(id)}/runs`,
{ limit: runsLimit },
signal,
)
if (res.ok === false) return { batch_id: id, error: res, items: [] as NetxJson[] }
const items = Array.isArray(asRecord(res.data).items)
? (asRecord(res.data).items as unknown[])
: []
return {
batch_id: id,
items: items.map((row) => slimEvalRun(asRecord(row))).slice(0, runsLimit),
}
}),
)
for (const block of settled) {
if ('error' in block && block.error) {
// Keep listing batches even if one runs fetch fails; attach error note.
const failed = cutoverBatches.find((b) => b.id === block.batch_id)
if (failed) failed.runs_error = asRecord(block.error).error || 'runs_fetch_failed'
continue
}
const target = cutoverBatches.find((b) => b.id === block.batch_id)
if (target) target.runs = block.items
for (const r of block.items) runs.push(r)
}
}
}
return {
ok: true,
data: omitUndefined({
kind,
project_id: projectId || undefined,
task_id: taskId || undefined,
batch_id: batchId || undefined,
cutover_batches: wantCutover ? cutoverBatches : undefined,
collect_batches: wantCollect ? collectBatches : undefined,
runs: kind === 'runs' ? runs : undefined,
counts: {
cutover_batches: cutoverBatches.length,
collect_batches: collectBatches.length,
runs: runs.length,
},
next: wantCutover
? 'Use cutover batch id with netx__getBizMonitorBoard / listBizMonitorBatches(kind=runs). Collect batch id → netx__getBizCollectBatch.'
: wantCollect
? 'Pass collect batch id to netx__getBizCollectBatch / getBizCollectCommandRaw.'
: 'Pass run_id to netx__getBizMonitorDiffs or getBizMonitorBoard(run_id).',
}),
}
}
/** Fat cutover / biz_state definition bundle (templates, mapping, tasks). */
export async function getBizMonitorContext(
client: NetxClient,
args: NetxJson,
signal?: AbortSignal,
): Promise<NetxJson> {
const projectId = str(args, 'project_id').trim()
const taskId = str(args, 'task_id').trim()
if (!projectId && !taskId) {
return { ok: false, error: 'project_id_or_task_id_required' }
}
const params: Record<string, string | number | boolean> = {}
if (projectId) params.project_id = projectId
if (taskId) params.task_id = taskId
return client.get('/v1/biz-migration/monitor-context', params, signal)
}
/** Cutover batch board + latest evaluate run summary. */
export async function getBizMonitorBoard(
client: NetxClient,
args: NetxJson,
signal?: AbortSignal,
): Promise<NetxJson> {
const batchId = str(args, 'batch_id').trim()
if (!batchId) return { ok: false, error: 'batch_id_required' }
const params: Record<string, string | number | boolean> = {}
const runId = str(args, 'run_id').trim()
if (runId) params.run_id = runId
return client.get(`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/board`, params, signal)
}
/** Red tickets with evidence (raw A/B + show command). */
export async function listBizMonitorReds(
client: NetxClient,
args: NetxJson,
signal?: AbortSignal,
): Promise<NetxJson> {
const projectId = str(args, 'project_id').trim()
if (!projectId) return { ok: false, error: 'project_id_required' }
const params: Record<string, string | number | boolean> = {}
putStr(params, args, ['status'])
const limit = num(args, 'limit')
if (limit !== undefined) params.limit = clampInt(limit, 200, 1, 500)
return client.get(
`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/red-tickets`,
params,
signal,
)
}
/** Paged evaluate diffs (old_key/new_key = device-raw A/B). */
export async function getBizMonitorDiffs(
client: NetxClient,
args: NetxJson,
signal?: AbortSignal,
): Promise<NetxJson> {
const runId = str(args, 'run_id').trim()
if (!runId) return { ok: false, error: 'run_id_required' }
const params: Record<string, string | number | boolean> = {}
putStr(params, args, ['metric_id', 'sheet_id', 'verdict', 'color', 'q'])
const limit = num(args, 'limit')
if (limit !== undefined) params.limit = clampInt(limit, 200, 1, 500)
const offset = num(args, 'offset')
if (offset !== undefined) params.offset = clampInt(offset, 0, 0, Number.MAX_SAFE_INTEGER)
return client.get(`/v1/biz-migration/runs/${encodeURIComponent(runId)}/diffs`, params, signal)
}
/** One biz_state collect batch (commands + metrics). */
export async function getBizCollectBatch(
client: NetxClient,
args: NetxJson,
signal?: AbortSignal,
): Promise<NetxJson> {
const batchId = str(args, 'batch_id').trim()
if (!batchId) return { ok: false, error: 'batch_id_required' }
return client.get(`/v1/biz-state/batches/${encodeURIComponent(batchId)}`, {}, signal)
}
/** Full CLI raw_text for one collect command. */
export async function getBizCollectCommandRaw(
client: NetxClient,
args: NetxJson,
signal?: AbortSignal,
): Promise<NetxJson> {
const batchId = str(args, 'batch_id').trim()
const commandId = str(args, 'command_id').trim()
if (!batchId || !commandId) {
return { ok: false, error: 'batch_id_and_command_id_required' }
}
return client.get(
`/v1/biz-state/batches/${encodeURIComponent(batchId)}/commands/${encodeURIComponent(commandId)}`,
{},
signal,
)
}

View file

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

View file

@ -1,17 +1,8 @@
/**
* Minimal netx REST client (Bearer + lang), aligned with netx-mcp http_client.
* Shared URL helpers for netx connection settings / alarm push.
* Tool calls go through `/mcp` (see mcp-remote.ts); REST handlers were removed.
*/
export interface NetxConnection {
apiUrl: string
lang: string
timeoutMs: number
/** Read the latest bearer on each request (do not freeze at client creation). */
getToken: () => string
/** Read the latest apiUrl on each request (settings may heal after first mount). */
getApiUrl?: () => string
}
export type NetxJson = Record<string, unknown>
export type NormalizeApiUrlResult =
@ -41,140 +32,3 @@ export function normalizeApiUrl(raw: string): NormalizeApiUrlResult {
const search = parsed.search && parsed.search !== '?' ? parsed.search : ''
return { ok: true, url: `${parsed.origin}${path}${search}` }
}
const PROTOCOL_KEY_ZH_TO_EN: Record<string, string> = {
其他: 'Other',
时钟: 'Clock',
'OTN/光': 'OTN/Optical',
电源: 'Power',
}
function asLang(value: unknown): string {
return typeof value === 'string' ? value : ''
}
/** Localize a few protocol bucket keys when lang starts with en (match netx-mcp). */
function localizePayload(lang: string, data: NetxJson): NetxJson {
if (!asLang(lang).trim().toLowerCase().startsWith('en')) return data
const proto = data.protocol_summary
if (!Array.isArray(proto)) return data
for (const row of proto) {
if (typeof row !== 'object' || row === null || Array.isArray(row)) continue
const rec = row as NetxJson
const key = typeof rec.key === 'string' ? rec.key : ''
const mapped = PROTOCOL_KEY_ZH_TO_EN[key]
if (mapped !== undefined) rec.key = mapped
}
return data
}
function encodeQuery(params: Record<string, string | number | boolean>): string {
const sp = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
sp.set(key, String(value))
}
const q = sp.toString()
return q.length > 0 ? `?${q}` : ''
}
/**
* Build a client bound to apiUrl / lang / timeout; Bearer + apiUrl are resolved per request.
* @param connection - apiUrl / lang / timeout / getToken / optional getApiUrl.
* @returns get/post helpers that return `{ ok, data }` or `{ ok: false, error }`.
*/
export function createNetxClient(connection: NetxConnection) {
const langParams = (): Record<string, string> => {
const lang = asLang(connection.lang).trim().toLowerCase()
if (lang.startsWith('en')) return { lang: 'en' }
return {}
}
async function request(
method: string,
path: string,
options: {
params?: Record<string, string | number | boolean>
body?: NetxJson
timeoutMs?: number
signal?: AbortSignal
} = {},
): Promise<NetxJson> {
const rawUrl = typeof connection.getApiUrl === 'function'
? connection.getApiUrl()
: connection.apiUrl
const normalized = normalizeApiUrl(typeof rawUrl === 'string' ? rawUrl : '')
const base = normalized.ok ? normalized.url : ''
if (!normalized.ok || !base) {
return {
ok: false,
error: 'netx_api_url_invalid',
detail: `apiUrl must be http(s) without embedded credentials (${normalized.ok ? 'empty' : normalized.error}).`,
}
}
const rawToken = connection.getToken()
const token = typeof rawToken === 'string' ? rawToken.trim() : ''
if (token.length === 0) {
return {
ok: false,
error: 'netx_token_missing',
detail: 'Set credential NETX_API_TOKEN (Plugins → Netx Ops or scripts/set-netx-token).',
}
}
const merged: Record<string, string | number | boolean> = { ...langParams(), ...options.params }
const url = `${base}${path}${encodeQuery(merged)}`
const timeoutMs = options.timeoutMs ?? connection.timeoutMs
const controller = new AbortController()
const timer = setTimeout(() => { controller.abort() }, timeoutMs)
const onOuterAbort = () => { controller.abort() }
options.signal?.addEventListener('abort', onOuterAbort, { once: true })
try {
const headers: Record<string, string> = {
accept: 'application/json',
authorization: `Bearer ${token}`,
}
if (options.body !== undefined) headers['content-type'] = 'application/json'
const init: RequestInit = {
method,
headers,
signal: controller.signal,
}
if (options.body !== undefined) init.body = JSON.stringify(options.body)
const resp = await fetch(url, init)
const text = await resp.text()
if (!resp.ok) {
return { ok: false, error: `netx_http_${resp.status}`, detail: text.slice(0, 800) }
}
const data = text.length > 0 ? JSON.parse(text) as unknown : {}
if (typeof data === 'object' && data !== null && !Array.isArray(data)) {
return { ok: true, data: localizePayload(connection.lang, data as NetxJson) }
}
return { ok: true, data: { raw: data } }
} catch (error) {
const detail = error instanceof Error ? error.message : String(error)
return { ok: false, error: 'netx_request_failed', detail: detail.slice(0, 800) }
} finally {
clearTimeout(timer)
options.signal?.removeEventListener('abort', onOuterAbort)
}
}
return {
get(path: string, params?: Record<string, string | number | boolean>, signal?: AbortSignal, timeoutMs?: number) {
return request('GET', path, { params, signal, timeoutMs })
},
post(path: string, body: NetxJson, signal?: AbortSignal, timeoutMs?: number) {
return request('POST', path, { body, signal, timeoutMs })
},
patch(path: string, body: NetxJson, signal?: AbortSignal, timeoutMs?: number) {
return request('PATCH', path, { body, signal, timeoutMs })
},
}
}
export type NetxClient = ReturnType<typeof createNetxClient>
/** Path-segment encode for NE ids (match urllib.parse.quote(..., safe='')). */
export function quoteNeId(neId: string): string {
return encodeURIComponent(neId.trim())
}

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

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

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

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

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

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

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

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

View file

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

View file

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

View file

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

View file

@ -12,8 +12,6 @@ import {
groupsForced,
kbEnabledForPlane,
SKILL_DIR_BY_GROUP,
toolNamesForGroups,
TOOLS_BY_GROUP,
} from '../src/netx/capability-groups.ts'
test('defaults: ops + bizMonitor in preset; topology and public off', () => {
@ -74,14 +72,12 @@ test('preset and host planes are symmetric; only defaults differ', () => {
})
test('null boolean flags do not resurrect defaults incorrectly', () => {
// Settings projection may emit null for volatile booleans.
const groups = capabilityGroupsFromSettings({
groupOpsInPreset: /** @type {any} */ (null),
groupOpsPublic: /** @type {any} */ (null),
groupBizMonitorInPreset: /** @type {any} */ (null),
groupBizMonitorPublic: /** @type {any} */ (null),
})
// null is not a boolean → fall back to defaults (ops+bizMonitor inPreset, public off)
assert.deepEqual(groupsForPlane(groups, 'preset'), ['ops', 'bizMonitor'])
assert.deepEqual(groupsForPlane(groups, 'host'), [])
})
@ -95,24 +91,12 @@ test('kbEnabledForPlane mirrors inPreset/public symmetry', () => {
assert.equal(kbEnabledForPlane({}, 'host'), false)
})
test('one group one skill dir; ops owns NMS + managed CLI; topology owns canvas; bizMonitor owns cutover read', () => {
test('one group one skill dir', () => {
assert.deepEqual(SKILL_DIR_BY_GROUP, {
ops: 'ops',
topology: 'topology',
bizMonitor: 'biz-monitor',
})
assert.ok(TOOLS_BY_GROUP.ops.includes('netx__findTopologyPaths'))
assert.ok(TOOLS_BY_GROUP.ops.includes('netx__execManagedNe'))
assert.ok(TOOLS_BY_GROUP.ops.includes('netx__getNeExecJob'))
assert.ok(TOOLS_BY_GROUP.ops.includes('netx__queryNmsAlarms'))
assert.ok(TOOLS_BY_GROUP.topology.includes('netx__getTopologyTree'))
assert.ok(TOOLS_BY_GROUP.topology.includes('netx__layoutTopologyView'))
assert.ok(TOOLS_BY_GROUP.topology.includes('netx__suggestSinkHubs'))
assert.equal(TOOLS_BY_GROUP.topology.includes('netx__sinkTopologyDualUnits'), false)
assert.ok(TOOLS_BY_GROUP.bizMonitor.includes('netx__getBizMonitorContext'))
assert.ok(TOOLS_BY_GROUP.bizMonitor.includes('netx__listBizMonitors'))
assert.ok(TOOLS_BY_GROUP.bizMonitor.includes('netx__listBizMonitorBatches'))
assert.ok(TOOLS_BY_GROUP.bizMonitor.includes('netx__getBizCollectCommandRaw'))
})
test('capabilityGroupsFromSettings honors explicit false for inPreset defaults', () => {
@ -124,10 +108,6 @@ test('capabilityGroupsFromSettings honors explicit false for inPreset defaults',
})
assert.deepEqual(groupsForPlane(groups, 'preset'), ['topology'])
assert.deepEqual(groupsForPlane(groups, 'public'), ['topology'])
assert.ok(toolNamesForGroups(['topology']).has('netx__layoutTopologyView'))
assert.equal(toolNamesForGroups(['topology']).has('netx__sinkTopologyDualUnits'), false)
assert.equal(toolNamesForGroups(['topology']).has('netx__queryNmsAlarms'), false)
assert.equal(toolNamesForGroups(['topology']).has('netx__getBizMonitorContext'), false)
})
test('legacy groupNms/common/managedNe map to ops; absent topology flags use legacy layout', () => {

View file

@ -0,0 +1,45 @@
/**
* Skill injection appends disabled-tool footnotes when tools are filtered.
*/
import assert from 'node:assert/strict'
import test from 'node:test'
import { registerGroupSkills } from '../src/netx/group-skills.ts'
test('registerGroupSkills appends disabled footnote; skips disabled groups', async () => {
/** @type {Array<{ name: string, content: string }>} */
const registered = []
const ctx = {
skills: {
register(skill) {
registered.push({ name: skill.name, content: skill.content })
return () => {}
},
},
logger: { warn() {} },
}
const dispose = await registerGroupSkills(ctx, ['ops'], 'test', {
disabledByGroup: { ops: ['netx__execManagedNe'] },
lang: 'zh',
})
assert.ok(registered.length >= 1)
assert.match(registered[0].content, /以下工具已禁用:netx__execManagedNe,不要调用/)
dispose()
registered.length = 0
const dispose2 = await registerGroupSkills(ctx, [], 'test', {
disabledByGroup: { ops: ['netx__execManagedNe'] },
})
assert.equal(registered.length, 0)
dispose2()
registered.length = 0
const dispose3 = await registerGroupSkills(ctx, ['ops'], 'test', {
disabledByGroup: { ops: ['netx__execManagedNe'] },
lang: 'en',
})
assert.match(registered[0].content, /The following tools are disabled: netx__execManagedNe/)
dispose3()
})

223
test/mcp-catalog.test.mjs Normal file
View file

@ -0,0 +1,223 @@
/**
* MCP catalog filtering: groups × disabledTools × readOnlyOnly.
*/
import assert from 'node:assert/strict'
import test from 'node:test'
import http from 'node:http'
import {
filterCatalogTools,
parseDisabledTools,
resolveEnabledTools,
disabledPublicNames,
} from '../src/netx/mcp-catalog.ts'
import { listMcpTools, callMcpTool, mcpEndpointUrl } from '../src/netx/mcp-remote.ts'
import { refreshMcpCatalog, getMcpCatalogState, resetMcpCatalog } from '../src/netx/mcp-store.ts'
import { DEFAULT_CAPABILITY_GROUPS } from '../src/netx/capability-groups.ts'
const SAMPLE = Object.freeze([
{
name: 'queryNmsAlarms',
description: 'alarms',
inputSchema: { type: 'object', properties: {} },
readOnlyHint: true,
group: 'ops',
},
{
name: 'execManagedNe',
description: 'cli',
inputSchema: { type: 'object', properties: {} },
readOnlyHint: false,
group: 'ops',
},
{
name: 'getTopologyTree',
description: 'tree',
inputSchema: { type: 'object', properties: {} },
readOnlyHint: true,
group: 'topology',
},
{
name: 'listBizMonitors',
description: 'biz',
inputSchema: { type: 'object', properties: {} },
readOnlyHint: true,
group: 'bizMonitor',
},
{
// Server later adds this under ops — must follow group default.
name: 'brandNewOpsTool',
description: 'new',
inputSchema: { type: 'object', properties: {} },
readOnlyHint: true,
group: 'ops',
},
])
test('parseDisabledTools accepts JSON and CSV', () => {
assert.deepEqual(parseDisabledTools('["execManagedNe","x"]'), ['execManagedNe', 'x'])
assert.deepEqual(parseDisabledTools('execManagedNe, queryNmsAlarms'), ['execManagedNe', 'queryNmsAlarms'])
assert.deepEqual(parseDisabledTools('[]'), [])
})
test('group off drops all tools in that group', () => {
const enabled = filterCatalogTools(SAMPLE, ['ops', 'bizMonitor'], {})
assert.deepEqual(enabled.map((t) => t.name).sort(), [
'brandNewOpsTool',
'execManagedNe',
'listBizMonitors',
'queryNmsAlarms',
])
})
test('disabledTools opts out one tool while group stays on', () => {
const enabled = filterCatalogTools(SAMPLE, ['ops'], { disabledTools: ['execManagedNe'] })
assert.deepEqual(enabled.map((t) => t.name).sort(), ['brandNewOpsTool', 'queryNmsAlarms'])
})
test('readOnlyOnly drops writable tools', () => {
const enabled = filterCatalogTools(SAMPLE, ['ops'], { readOnlyOnly: true })
assert.deepEqual(enabled.map((t) => t.name).sort(), ['brandNewOpsTool', 'queryNmsAlarms'])
})
test('new server tool follows group default (not listed in disabledTools)', () => {
const enabled = resolveEnabledTools(SAMPLE, {
plane: 'preset',
groups: DEFAULT_CAPABILITY_GROUPS,
disabledTools: ['execManagedNe'],
})
const names = enabled.map((t) => t.name)
assert.ok(names.includes('brandNewOpsTool'))
assert.ok(names.includes('queryNmsAlarms'))
assert.ok(names.includes('listBizMonitors'))
assert.equal(names.includes('execManagedNe'), false)
assert.equal(names.includes('getTopologyTree'), false) // topology inPreset default off
})
test('disabledPublicNames lists model-facing names for skill footnotes', () => {
const names = disabledPublicNames(SAMPLE, ['ops'], ['execManagedNe'], true)
assert.deepEqual(names, ['netx__execManagedNe'])
})
test('mcpEndpointUrl appends /mcp', () => {
assert.equal(mcpEndpointUrl('http://127.0.0.1:8890'), 'http://127.0.0.1:8890/mcp')
assert.equal(mcpEndpointUrl('http://127.0.0.1:8890/'), 'http://127.0.0.1:8890/mcp')
})
function startMockMcp(tools, { failList = false, token = 'secret' } = {}) {
let callCount = 0
const server = http.createServer(async (req, res) => {
if (req.method !== 'POST') {
res.writeHead(404)
res.end()
return
}
const chunks = []
for await (const chunk of req) chunks.push(chunk)
const body = JSON.parse(Buffer.concat(chunks).toString('utf8'))
const auth = req.headers.authorization ?? ''
if (token && auth !== `Bearer ${token}`) {
res.writeHead(401)
res.end('unauthorized')
return
}
const reply = (result) => {
res.writeHead(200, { 'content-type': 'application/json' })
res.end(JSON.stringify({ jsonrpc: '2.0', id: body.id ?? 1, result }))
}
if (body.method === 'initialize') {
reply({
protocolVersion: '2025-03-26',
capabilities: { tools: {} },
serverInfo: { name: 'mock-netx', version: '0.0.0' },
})
return
}
if (body.method === 'notifications/initialized') {
res.writeHead(202)
res.end()
return
}
if (body.method === 'tools/list') {
if (failList) {
res.writeHead(503)
res.end('down')
return
}
reply({
tools: tools.map((t) => ({
name: t.name,
description: t.description,
inputSchema: t.inputSchema,
annotations: { readOnlyHint: t.readOnlyHint },
_meta: { netx: { group: t.group } },
})),
})
return
}
if (body.method === 'tools/call') {
callCount += 1
reply({
content: [{ type: 'text', text: JSON.stringify({ ok: true, name: body.params?.name }) }],
isError: false,
})
return
}
res.writeHead(400)
res.end('unknown method')
})
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
const { port } = server.address()
resolve({
server,
url: `http://127.0.0.1:${port}/mcp`,
apiUrl: `http://127.0.0.1:${port}`,
callCount: () => callCount,
})
})
})
}
test('listMcpTools + callMcpTool against mock Streamable HTTP server', async () => {
const mock = await startMockMcp(SAMPLE)
try {
const tools = await listMcpTools({ mcpUrl: mock.url, token: 'secret' })
assert.equal(tools.length, SAMPLE.length)
assert.equal(tools.find((t) => t.name === 'execManagedNe')?.readOnlyHint, false)
assert.equal(tools.find((t) => t.name === 'queryNmsAlarms')?.group, 'ops')
const result = await callMcpTool({ mcpUrl: mock.url, token: 'secret' }, 'queryNmsAlarms', {})
assert.equal(result.isError, undefined)
assert.ok(Array.isArray(result.content))
assert.ok(mock.callCount() >= 1)
} finally {
mock.server.close()
}
})
test('refreshMcpCatalog keeps cache on disconnect', async () => {
resetMcpCatalog()
const mock = await startMockMcp(SAMPLE)
try {
const ok = await refreshMcpCatalog({ apiUrl: mock.apiUrl, token: 'secret' })
assert.equal(ok.phase, 'connected')
assert.equal(ok.tools.length, SAMPLE.length)
assert.equal(ok.fromCache, false)
} finally {
mock.server.close()
}
const offline = await startMockMcp(SAMPLE, { failList: true })
try {
const next = await refreshMcpCatalog({ apiUrl: offline.apiUrl, token: 'secret' })
assert.equal(next.phase, 'offline')
assert.equal(next.fromCache, true)
assert.equal(next.tools.length, SAMPLE.length)
assert.ok(next.error)
assert.equal(getMcpCatalogState().tools.length, SAMPLE.length)
} finally {
offline.server.close()
resetMcpCatalog()
}
})

116
test/mcp-test-catalog.mjs Normal file
View file

@ -0,0 +1,116 @@
/**
* Seed the process-local MCP catalog for registration / publication tests.
*/
import { publishNetxConnection } from '../src/netx/runtime.ts'
const STORE_KEY = Symbol.for('dsh-netxops.mcp-catalog-store')
/** Classic ops catalog size (matches historical TOOLS_BY_GROUP.ops.length). */
export const OPS_TOOL_NAMES = Object.freeze([
'queryNmsAlarms',
'aggregateNmsAlarms',
'runNmsDiagnostics',
'queryNmsNeInventory',
'getNmsNe',
'queryNmsAlarmsRaw',
'aggregateNmsAlarmsRaw',
'listNmsAlarmFields',
'sqlQueryNms',
'listManagedNe',
'getManagedNe',
'execManagedNe',
'getNeExecJob',
'listCliTargets',
'findTopologyPaths',
])
export const TOPOLOGY_TOOL_NAMES = Object.freeze([
'getTopologyTree',
'getTopologyView',
'createTopologyFolder',
'addTopologyViewNodes',
'removeTopologyViewNodes',
'copyTopologyViewNodes',
'updateTopologyViewPositions',
'projectTopologyNeighbors',
'queryTopologyFabricNodes',
'classifyTopologyFabricNodes',
'queryTopologyNeighborhood',
'queryTopologyEdges',
'layoutTopologyView',
'suggestSinkHubs',
'analyzeTopologyViewLayout',
])
export const BIZ_MONITOR_TOOL_NAMES = Object.freeze([
'listBizMonitors',
'listBizMonitorBatches',
'getBizMonitorContext',
'getBizMonitorBoard',
'listBizMonitorReds',
'getBizMonitorDiffs',
'getBizCollectBatch',
'getBizCollectCommandRaw',
])
const WRITE_OPS = new Set(['execManagedNe'])
const WRITE_TOPOLOGY = new Set([
'createTopologyFolder',
'addTopologyViewNodes',
'removeTopologyViewNodes',
'copyTopologyViewNodes',
'updateTopologyViewPositions',
'projectTopologyNeighbors',
'classifyTopologyFabricNodes',
'layoutTopologyView',
])
function descriptor(name, group) {
const write = (group === 'ops' && WRITE_OPS.has(name))
|| (group === 'topology' && WRITE_TOPOLOGY.has(name))
return {
name,
description: name,
inputSchema: { type: 'object', properties: {} },
readOnlyHint: !write,
group,
}
}
export function fullCatalog() {
return [
...OPS_TOOL_NAMES.map((name) => descriptor(name, 'ops')),
...TOPOLOGY_TOOL_NAMES.map((name) => descriptor(name, 'topology')),
...BIZ_MONITOR_TOOL_NAMES.map((name) => descriptor(name, 'bizMonitor')),
]
}
/** Install a frozen catalog into the shared Symbol.for store (no network). */
export function seedMcpCatalog(tools = fullCatalog()) {
const root = globalThis
const store = {
state: {
phase: 'connected',
mcpUrl: 'http://127.0.0.1:8890/mcp',
tools: Object.freeze([...tools]),
error: '',
listedAt: new Date().toISOString(),
fromCache: false,
},
listeners: new Set(),
generation: 1,
}
root[STORE_KEY] = store
return store.state
}
export function clearMcpCatalogSeed() {
delete globalThis[STORE_KEY]
}
/** Convenience: seed catalog + publish a connection snapshot. */
export function publishWithCatalog(connection) {
seedMcpCatalog()
publishNetxConnection(connection)
}

View file

@ -11,6 +11,7 @@ import { registerGroupSkills } from '../src/netx/group-skills.ts'
import { publishNetxConnection } from '../src/netx/runtime.ts'
import { getToolMountStatuses, publishToolMountStatus, resetToolMountStatus } from '../src/netx/tool-mount-status.ts'
import { asToolsMountView } from '../src/client/tools-status-view.ts'
import { OPS_TOOL_NAMES, clearMcpCatalogSeed, seedMcpCatalog } from './mcp-test-catalog.mjs'
const log = { info() {}, warn() {}, error() {} }
const pause = () => new Promise(resolve => setTimeout(resolve, 40))
@ -37,7 +38,12 @@ function fakeContext(failAt = -1) {
function diagnosticHome(t) {
const previous = process.env.DSH_HOME
process.env.DSH_HOME = mkdtempSync(join(tmpdir(), 'netxops-publication-'))
t.after(() => { if (previous === undefined) delete process.env.DSH_HOME; else process.env.DSH_HOME = previous })
seedMcpCatalog()
t.after(() => {
clearMcpCatalogSeed()
if (previous === undefined) delete process.env.DSH_HOME
else process.env.DSH_HOME = previous
})
}
test('selected presets are isolated and global publication is inherited once', () => {
@ -67,7 +73,7 @@ test('failed tool registration rolls back every earlier tool', t => {
const { ctx, tools } = fakeContext(3)
assert.throws(() => registerNetxTools(ctx, connection(), { plane: 'preset' }), /registration refused/)
assert.equal(tools.size, 0)
assert.ok(getToolMountStatuses().some(row => row.error === 'registration refused'))
assert.ok(getToolMountStatuses().some(row => String(row.error ?? '').includes('registration refused')))
resetToolMountStatus()
})
@ -87,7 +93,7 @@ test('turning off a mounted group revokes tools, KB context and pending skill lo
publishNetxConnection(connection({ groupBizMonitorInPreset: false }))
const mount = fakeContext()
applyGroupToolsPlugin(mount.ctx, { name: 'lifecycle', mode: 'settings' })
assert.equal(mount.tools.size, 15)
assert.equal(mount.tools.size, OPS_TOOL_NAMES.length)
assert.equal(mount.skills.has('kb-context'), true)
publishNetxConnection(connection({ toolsEnabled: false }))
await pause()
@ -95,7 +101,7 @@ test('turning off a mounted group revokes tools, KB context and pending skill lo
assert.equal(mount.skills.size, 0)
publishNetxConnection(connection({ groupBizMonitorInPreset: false }))
await pause()
assert.equal(mount.tools.size, 15)
assert.equal(mount.tools.size, OPS_TOOL_NAMES.length)
assert.equal(mount.skills.has('netx-ops'), true)
mount.dispose()
await pause()
@ -123,9 +129,9 @@ test('mount inventory survives cross-bundle publishes and reports all scopes', a
entry.apply(mount.ctx)
const statuses = getToolMountStatuses()
assert.ok(statuses.some(row => row.id === 'host-test'))
assert.ok(statuses.some(row => row.count === 15))
assert.ok(statuses.some(row => row.count === OPS_TOOL_NAMES.length))
const view = asToolsMountView({ mounts: statuses, connection: { toolsEnabled: true } })
assert.equal(view.count, 15)
assert.equal(view.count, OPS_TOOL_NAMES.length)
mount.dispose()
assert.equal(getToolMountStatuses().some(row => row.count > 0), false)
resetToolMountStatus()

View file

@ -13,6 +13,12 @@ import { npmRuntime } from '../scripts/npm-cli.mjs'
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
const { node, npm } = npmRuntime()
/** Inlined into the activate smoke so the packed Host can register without netx. */
const OPS_TOOL_NAMES_INLINE = [
'queryNmsAlarms', 'aggregateNmsAlarms', 'runNmsDiagnostics', 'queryNmsNeInventory', 'getNmsNe',
'queryNmsAlarmsRaw', 'aggregateNmsAlarmsRaw', 'listNmsAlarmFields', 'sqlQueryNms',
'listManagedNe', 'getManagedNe', 'execManagedNe', 'getNeExecJob', 'listCliTargets', 'findTopologyPaths',
]
const run = (args, cwd, env = {}) => {
const result = spawnSync(node, args, { cwd, env: { ...process.env, ...env }, encoding: 'utf8', windowsHide: true, timeout: 120_000 })
assert.equal(result.status, 0, `${args[0]} failed: ${result.error ?? ''}\n${result.stderr}\n${result.stdout}`)
@ -87,6 +93,14 @@ test('the npm tarball installs offline and supplies every exported artifact and
const root = new Context(), tools = new Map(), skills = new Map(), owners = [];
class Tools extends Service { constructor(ctx) { super(ctx, 'tools') } register(entry) { assert.ok(!tools.has(entry.name)); tools.set(entry.name, entry); return () => tools.delete(entry.name) } }
class Skills extends Service { constructor(ctx) { super(ctx, 'skills') } register(entry) { assert.ok(!skills.has(entry.name)); skills.set(entry.name, entry); return () => skills.delete(entry.name) } }
// Seed a cached /mcp catalog so host registration does not need a live netx.
const STORE = Symbol.for('dsh-netxops.mcp-catalog-store');
const ops = ${JSON.stringify([...OPS_TOOL_NAMES_INLINE])};
globalThis[STORE] = { generation: 1, listeners: new Set(), state: {
phase: 'connected', mcpUrl: 'http://127.0.0.1:8890/mcp', error: '', listedAt: new Date().toISOString(), fromCache: false,
tools: ops.map(name => ({ name, description: name, inputSchema: { type: 'object', properties: {} },
readOnlyHint: name !== 'execManagedNe', group: 'ops' })),
} };
owners.push(await root.plugin(Tools), await root.plugin(Skills));
const owner = await root.plugin(host, { installAgentPreset: false, alarmPushEnabled: false, groupOpsPublic: true }); owners.push(owner);
assert.equal(owner.state, 2); assert.equal(tools.size, 15);

View file

@ -8,6 +8,7 @@ import { join } from 'node:path'
import { installPresetPublication } from '../src/netx/preset-publication.ts'
import { publishNetxConnection } from '../src/netx/runtime.ts'
import { capabilityGroupsFromSettings } from '../src/netx/capability-groups.ts'
import { OPS_TOOL_NAMES, clearMcpCatalogSeed, seedMcpCatalog } from './mcp-test-catalog.mjs'
const layers = new Map()
const skillLayers = new Map()
@ -47,10 +48,12 @@ const wait = () => new Promise(resolve => setTimeout(resolve, 50))
test('selected publication uses each exact session scope and cleans up on off, switch, and unload', async t => {
const previous = process.env.DSH_HOME
process.env.DSH_HOME = mkdtempSync(join(tmpdir(), 'netxops-selected-'))
seedMcpCatalog()
const ctx = new Context()
const owners = []
t.after(async () => {
for (const owner of owners.reverse()) await owner.dispose()
clearMcpCatalogSeed()
if (previous === undefined) delete process.env.DSH_HOME; else process.env.DSH_HOME = previous
layers.clear(); skillLayers.clear(); live = []
})
@ -78,7 +81,7 @@ test('selected publication uses each exact session scope and cleans up on off, s
const owner = await ctx.plugin(installPresetPublication)
owners.push(owner)
await wait()
assert.equal(layers.get(scopes[0].key)?.size, 15)
assert.equal(layers.get(scopes[0].key)?.size, OPS_TOOL_NAMES.length)
assert.equal(skillLayers.get(scopes[0].key)?.has('netx-ops'), true)
assert.equal(layers.get(scopes[1].key)?.size ?? 0, 0)
publish({ toolsEnabled: false })
@ -87,7 +90,7 @@ test('selected publication uses each exact session scope and cleans up on off, s
assert.equal(skillLayers.get(scopes[0].key)?.size, 0)
publish()
await wait()
assert.equal(layers.get(scopes[0].key)?.size, 15)
assert.equal(layers.get(scopes[0].key)?.size, OPS_TOOL_NAMES.length)
scopes[0].agent.ctx = scopes[0].agent.ctx.extend({ presetId: 'review' })
await ctx.serial('agent-preset/selected', 'standard', 'review')
await wait()
@ -95,7 +98,7 @@ test('selected publication uses each exact session scope and cleans up on off, s
scopes[1].agent.ctx = scopes[1].agent.ctx.extend({ presetId: 'standard' })
await ctx.serial('agent/created', { agent: scopes[1].agent })
await wait()
assert.equal(layers.get(scopes[1].key)?.size, 15)
assert.equal(layers.get(scopes[1].key)?.size, OPS_TOOL_NAMES.length)
await owner.dispose()
await wait()
assert.equal(layers.get(scopes[1].key)?.size, 0)