mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-19 20:23:19 +08:00
Netx tools: load from netx remote /mcp, add per-tool toggles
- Drop the TypeScript netx tool implementations and the hard-coded TOOLS_BY_GROUP list; register netx__* tools from the server's tools/list (group from _meta.netx.group) and forward tools/call. - Thin Streamable HTTP client on standard fetch (fetchImpl injectable); cached catalog when netx is unreachable, refresh to reconnect. - disabledTools (deny list; new server tools follow their group) and readOnlyOnly (only readOnlyHint=true tools), re-registered live. - Settings card: per-group expandable tool rows with switches and a write marker; skills append a disabled-tools note. - Toggles only control model visibility; netx enforces security. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
2def53e7c7
commit
2ef3a43c63
38 changed files with 5049 additions and 17029 deletions
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
2441
lib/agent-tools.js
2441
lib/agent-tools.js
File diff suppressed because it is too large
Load diff
406
lib/client.js
406
lib/client.js
|
|
@ -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.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,15 +1775,51 @@ 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(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, enabled) => {
|
||||
props.setToolEnabled(name, enabled);
|
||||
}
|
||||
};
|
||||
return /* @__PURE__ */ jsx_runtime.jsxs(jsx_runtime.Fragment, {
|
||||
children: [
|
||||
/* @__PURE__ */ jsx_runtime.jsx(CapabilityGroupBlock, {
|
||||
title: t("groupOps"),
|
||||
toolCount: TOOLS_BY_GROUP.ops.length,
|
||||
toolCount: byGroup.ops.length,
|
||||
tools: byGroup.ops,
|
||||
targets: targets("groupOpsPresets", state.groupOpsPresets),
|
||||
inPresetLabel: t("groupInPreset"),
|
||||
publicLabel: t("groupPublic"),
|
||||
|
|
@ -1716,11 +1831,13 @@ function NetxopsCard(props) {
|
|||
},
|
||||
onEditPublic: (checked) => {
|
||||
props.edit("groupOpsPublic", checked ? "true" : "false");
|
||||
}
|
||||
},
|
||||
...toolProps
|
||||
}),
|
||||
/* @__PURE__ */ jsx_runtime.jsx(CapabilityGroupBlock, {
|
||||
title: t("groupTopology"),
|
||||
toolCount: TOOLS_BY_GROUP.topology.length,
|
||||
toolCount: byGroup.topology.length,
|
||||
tools: byGroup.topology,
|
||||
targets: targets("groupTopologyPresets", state.groupTopologyPresets),
|
||||
inPresetLabel: t("groupInPreset"),
|
||||
publicLabel: t("groupPublic"),
|
||||
|
|
@ -1732,11 +1849,13 @@ function NetxopsCard(props) {
|
|||
},
|
||||
onEditPublic: (checked) => {
|
||||
props.edit("groupTopologyPublic", checked ? "true" : "false");
|
||||
}
|
||||
},
|
||||
...toolProps
|
||||
}),
|
||||
/* @__PURE__ */ jsx_runtime.jsx(CapabilityGroupBlock, {
|
||||
title: t("groupBizMonitor"),
|
||||
toolCount: TOOLS_BY_GROUP.bizMonitor.length,
|
||||
toolCount: byGroup.bizMonitor.length,
|
||||
tools: byGroup.bizMonitor,
|
||||
targets: targets("groupBizMonitorPresets", state.groupBizMonitorPresets),
|
||||
inPresetLabel: t("groupInPreset"),
|
||||
publicLabel: t("groupPublic"),
|
||||
|
|
@ -1748,8 +1867,12 @@ function NetxopsCard(props) {
|
|||
},
|
||||
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(() => {
|
||||
|
|
|
|||
2595
lib/index.js
2595
lib/index.js
File diff suppressed because it is too large
Load diff
|
|
@ -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,17 +614,58 @@ export function NetxopsCard(props: NetxopsCardProps) {
|
|||
<div className="dsh-nx-settings-card">
|
||||
<div className="dsh-nx-card-head">
|
||||
<h3>{t('sectionCapabilities')}</h3>
|
||||
<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} />
|
||||
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={TOOLS_BY_GROUP.ops.length}
|
||||
toolCount={byGroup.ops.length}
|
||||
tools={byGroup.ops}
|
||||
targets={targets('groupOpsPresets', state.groupOpsPresets)}
|
||||
inPresetLabel={t('groupInPreset')}
|
||||
publicLabel={t('groupPublic')}
|
||||
|
|
@ -590,10 +678,12 @@ export function NetxopsCard(props: NetxopsCardProps) {
|
|||
onEditPublic={(checked) => {
|
||||
props.edit('groupOpsPublic', checked ? 'true' : 'false')
|
||||
}}
|
||||
{...toolProps}
|
||||
/>
|
||||
<CapabilityGroupBlock
|
||||
title={t('groupTopology')}
|
||||
toolCount={TOOLS_BY_GROUP.topology.length}
|
||||
toolCount={byGroup.topology.length}
|
||||
tools={byGroup.topology}
|
||||
targets={targets('groupTopologyPresets', state.groupTopologyPresets)}
|
||||
inPresetLabel={t('groupInPreset')}
|
||||
publicLabel={t('groupPublic')}
|
||||
|
|
@ -606,10 +696,12 @@ export function NetxopsCard(props: NetxopsCardProps) {
|
|||
onEditPublic={(checked) => {
|
||||
props.edit('groupTopologyPublic', checked ? 'true' : 'false')
|
||||
}}
|
||||
{...toolProps}
|
||||
/>
|
||||
<CapabilityGroupBlock
|
||||
title={t('groupBizMonitor')}
|
||||
toolCount={TOOLS_BY_GROUP.bizMonitor.length}
|
||||
toolCount={byGroup.bizMonitor.length}
|
||||
tools={byGroup.bizMonitor}
|
||||
targets={targets('groupBizMonitorPresets', state.groupBizMonitorPresets)}
|
||||
inPresetLabel={t('groupInPreset')}
|
||||
publicLabel={t('groupPublic')}
|
||||
|
|
@ -622,7 +714,11 @@ export function NetxopsCard(props: NetxopsCardProps) {
|
|||
onEditPublic={(checked) => {
|
||||
props.edit('groupBizMonitorPublic', checked ? 'true' : 'false')
|
||||
}}
|
||||
{...toolProps}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
{state.toolsStatus && state.toolsStatus.mounts.length > 0 ? (
|
||||
<details className="dsh-nx-diagnostics"><summary>{t('mountDetails')}</summary>
|
||||
<p className="dsh-nx-hint">{t('mountStatusHint')}</p>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
|||
import type { SettingsFormScope, SettingsFormSnapshot } from './settings-form.ts'
|
||||
import type { SnapshotStore } from './snapshot-store.ts'
|
||||
import {
|
||||
CardForm, apiUrlField, textField, presetIdsField, booleanFieldPersistFalse,
|
||||
CardForm, apiUrlField, textField, presetIdsField, booleanFieldPersistFalse, booleanField,
|
||||
type CardActions, type CardFieldState, type CardShell,
|
||||
} from './card-form.ts'
|
||||
import {
|
||||
|
|
@ -19,6 +19,12 @@ import {
|
|||
fetchToolsStatus,
|
||||
type ToolsMountView,
|
||||
} from './tools-status-view.ts'
|
||||
import {
|
||||
fetchMcpCatalog,
|
||||
refreshMcpCatalogRemote,
|
||||
type McpCatalogView,
|
||||
} from './mcp-catalog-view.ts'
|
||||
import { parseDisabledTools, serializeDisabledTools } from '../netx/mcp-catalog.ts'
|
||||
import {
|
||||
fetchKbStatus,
|
||||
reloadKbStatus,
|
||||
|
|
@ -81,6 +87,10 @@ export interface NetxopsSettings {
|
|||
groupKbInPreset?: boolean
|
||||
/** Publish `_skills/kb-*` to other presets. */
|
||||
groupKbPublic?: boolean
|
||||
/** JSON array of server tool names opted out. */
|
||||
disabledTools?: string
|
||||
/** Only register readOnlyHint tools. */
|
||||
readOnlyOnly?: boolean
|
||||
}
|
||||
|
||||
interface CredentialState {
|
||||
|
|
@ -126,6 +136,11 @@ export interface NetxopsCardState extends CardShell {
|
|||
alarmPushStatus: AlarmPushStatus | null
|
||||
/** Host `netx__*` tool mount snapshot; null when RPC unavailable. */
|
||||
toolsStatus: ToolsMountView | null
|
||||
/** Cached `/mcp` tools/list for the per-tool switches. */
|
||||
mcpCatalog: McpCatalogView | null
|
||||
mcpRefreshBusy: boolean
|
||||
disabledTools: CardFieldState
|
||||
readOnlyOnly: CardFieldState
|
||||
/** Latest KB snapshot (saved root, or live preview while editing). */
|
||||
kbStatus: KbSnapshot | null
|
||||
/** Soft-injected `remote.directoryPicker` is available. */
|
||||
|
|
@ -157,6 +172,10 @@ export interface NetxopsCardFace extends CardActions {
|
|||
exportAllSessions: () => void
|
||||
/** Open the Host directory picker and write the chosen path into kbRoot. */
|
||||
browseKbRoot: () => void
|
||||
/** Re-pull tools/list from netx `/mcp`. */
|
||||
refreshMcpCatalog: () => void
|
||||
/** Toggle one server tool in disabledTools (checked = enabled). */
|
||||
setToolEnabled: (serverName: string, enabled: boolean) => void
|
||||
}
|
||||
|
||||
type DirectoryPickerRemote = {
|
||||
|
|
@ -181,6 +200,9 @@ export class NetxopsCardController {
|
|||
private alarmPushStatus: AlarmPushStatus | null = null
|
||||
private toolsStatus: ToolsMountView | null = null
|
||||
private toolsStatusInFlight = false
|
||||
private mcpCatalog: McpCatalogView | null = null
|
||||
private mcpCatalogInFlight = false
|
||||
private mcpRefreshBusy = false
|
||||
private presetCatalog: NetxopsCardState['presetCatalog'] = []
|
||||
private presetCatalogError = false
|
||||
private presetCatalogInFlight = false
|
||||
|
|
@ -235,6 +257,8 @@ export class NetxopsCardController {
|
|||
booleanFieldPersistFalse('alarmPushEnabled'),
|
||||
booleanFieldPersistFalse('alarmDeliverDsh'),
|
||||
booleanFieldPersistFalse('alarmDeliverIm'),
|
||||
booleanField('readOnlyOnly'),
|
||||
textField('disabledTools'),
|
||||
textField('imTargets'),
|
||||
textField('imBotId'),
|
||||
textField('imTargetId'),
|
||||
|
|
@ -279,6 +303,10 @@ export class NetxopsCardController {
|
|||
this.toolsStatus = null
|
||||
changed = true
|
||||
}
|
||||
if (this.mcpCatalog !== null) {
|
||||
this.mcpCatalog = null
|
||||
changed = true
|
||||
}
|
||||
this.presetCatalog = []
|
||||
this.presetCatalogError = true
|
||||
changed = true
|
||||
|
|
@ -301,6 +329,7 @@ export class NetxopsCardController {
|
|||
this.startStatusPoll()
|
||||
void this.refreshAlarmPushStatus()
|
||||
void this.refreshToolsStatus()
|
||||
void this.refreshMcpCatalogStatus()
|
||||
void this.refreshPresetCatalog()
|
||||
void this.reloadKbFromHost()
|
||||
void this.refreshKbPreview()
|
||||
|
|
@ -325,6 +354,7 @@ export class NetxopsCardController {
|
|||
this.pollTimer = setInterval(() => {
|
||||
void this.refreshAlarmPushStatus()
|
||||
void this.refreshToolsStatus()
|
||||
void this.refreshMcpCatalogStatus()
|
||||
void this.refreshPresetCatalog()
|
||||
void this.refreshKbStatus()
|
||||
void this.refreshImDeliveryCatalog()
|
||||
|
|
@ -389,6 +419,51 @@ export class NetxopsCardController {
|
|||
}
|
||||
}
|
||||
|
||||
private async refreshMcpCatalogStatus(): Promise<void> {
|
||||
const call = this.rpcCall
|
||||
if (call === undefined || this.mcpCatalogInFlight) return
|
||||
this.mcpCatalogInFlight = true
|
||||
try {
|
||||
const next = await fetchMcpCatalog(call)
|
||||
const prev = this.mcpCatalog
|
||||
if (prev && JSON.stringify(prev) === JSON.stringify(next)) return
|
||||
this.mcpCatalog = next
|
||||
this.store.set(this.projection())
|
||||
} catch {
|
||||
// Keep last catalog.
|
||||
} finally {
|
||||
this.mcpCatalogInFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
private async runMcpRefresh(): Promise<void> {
|
||||
const call = this.rpcCall
|
||||
if (call === undefined || this.mcpRefreshBusy) return
|
||||
this.mcpRefreshBusy = true
|
||||
this.store.set(this.projection())
|
||||
try {
|
||||
const next = await refreshMcpCatalogRemote(call)
|
||||
this.mcpCatalog = next
|
||||
await this.refreshToolsStatus()
|
||||
} catch {
|
||||
// leave previous catalog
|
||||
} finally {
|
||||
this.mcpRefreshBusy = false
|
||||
this.store.set(this.projection())
|
||||
}
|
||||
}
|
||||
|
||||
private setToolEnabled(serverName: string, enabled: boolean): void {
|
||||
const name = serverName.trim()
|
||||
if (!name) return
|
||||
const current = new Set(parseDisabledTools(this.form.field('disabledTools').text))
|
||||
if (enabled) current.delete(name)
|
||||
else current.add(name)
|
||||
const next = serializeDisabledTools([...current])
|
||||
this.form.actions().edit('disabledTools', next)
|
||||
void this.form.save(['disabledTools'])
|
||||
}
|
||||
|
||||
private async refreshPresetCatalog(): Promise<void> {
|
||||
const call = this.rpcCall
|
||||
if (!call || this.presetCatalogInFlight) return
|
||||
|
|
@ -721,6 +796,10 @@ export class NetxopsCardController {
|
|||
apiTokenRemoteReady: this.credential.remoteReady,
|
||||
alarmPushStatus: this.alarmPushStatus,
|
||||
toolsStatus: this.toolsStatus,
|
||||
mcpCatalog: this.mcpCatalog,
|
||||
mcpRefreshBusy: this.mcpRefreshBusy,
|
||||
disabledTools: this.form.field('disabledTools'),
|
||||
readOnlyOnly: this.form.field('readOnlyOnly'),
|
||||
kbStatus: this.kbPreview ?? this.kbStatus,
|
||||
kbDirectoryPickerReady: this.kbDirectoryPicker !== undefined || this.rpcCall !== undefined,
|
||||
kbUiError: this.kbUiError,
|
||||
|
|
@ -794,6 +873,8 @@ export class NetxopsCardController {
|
|||
'groupBizMonitorPublic',
|
||||
'groupKbInPreset',
|
||||
'groupKbPublic',
|
||||
'disabledTools',
|
||||
'readOnlyOnly',
|
||||
])
|
||||
return {
|
||||
hooks: { netxopsCard: this.store },
|
||||
|
|
@ -816,6 +897,8 @@ export class NetxopsCardController {
|
|||
},
|
||||
exportAllSessions: () => { this.exportAllSessions() },
|
||||
browseKbRoot: () => { this.browseKbRoot() },
|
||||
refreshMcpCatalog: () => { void this.runMcpRefresh() },
|
||||
setToolEnabled: (serverName, enabled) => { this.setToolEnabled(serverName, enabled) },
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -162,6 +162,8 @@ const MEMORY_DEFAULTS: NetxopsSettings = {
|
|||
kbRoot: '',
|
||||
groupKbInPreset: true,
|
||||
groupKbPublic: false,
|
||||
disabledTools: '[]',
|
||||
readOnlyOnly: false,
|
||||
}
|
||||
|
||||
export function apply(ctx: ClientContext): void {
|
||||
|
|
|
|||
|
|
@ -66,6 +66,12 @@ export type NetxopsLocaleKey =
|
|||
| 'toolsStatusEmpty'
|
||||
| 'toolsStatusError'
|
||||
| 'toolsStatusUnknown'
|
||||
| 'mcpDisconnected'
|
||||
| 'mcpRefresh'
|
||||
| 'mcpRefreshing'
|
||||
| 'readOnlyOnly'
|
||||
| 'toolWriteBadge'
|
||||
| 'toolListEmpty'
|
||||
| 'sessionsExportCount'
|
||||
| 'sessionsExportUnavailable'
|
||||
| 'sessionsExportNoPersistence'
|
||||
|
|
@ -153,6 +159,12 @@ export const en: Record<NetxopsLocaleKey, string> = {
|
|||
toolsStatusEmpty: 'No active tools',
|
||||
toolsStatusError: 'Publication failed',
|
||||
toolsStatusUnknown: 'Status unavailable',
|
||||
mcpDisconnected: 'Disconnected',
|
||||
mcpRefresh: 'Refresh',
|
||||
mcpRefreshing: 'Refreshing…',
|
||||
readOnlyOnly: 'Read-only tools only',
|
||||
toolWriteBadge: 'write',
|
||||
toolListEmpty: 'No tools yet',
|
||||
sessionsExportCount: '{count} sessions',
|
||||
sessionsExportUnavailable: 'Export unavailable',
|
||||
sessionsExportNoPersistence: 'Session store unavailable.',
|
||||
|
|
@ -241,6 +253,12 @@ export const zh: Record<NetxopsLocaleKey, string> = {
|
|||
toolsStatusEmpty: '暂无活跃工具',
|
||||
toolsStatusError: '发布异常',
|
||||
toolsStatusUnknown: '状态不可用',
|
||||
mcpDisconnected: '未连接',
|
||||
mcpRefresh: '刷新',
|
||||
mcpRefreshing: '刷新中…',
|
||||
readOnlyOnly: '只允许只读工具',
|
||||
toolWriteBadge: '写',
|
||||
toolListEmpty: '暂无工具清单',
|
||||
sessionsExportCount: '{count} 个会话',
|
||||
sessionsExportUnavailable: '无法导出',
|
||||
sessionsExportNoPersistence: '会话存储不可用。',
|
||||
|
|
|
|||
92
src/client/mcp-catalog-view.ts
Normal file
92
src/client/mcp-catalog-view.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
/**
|
||||
* Browser-side MCP catalog (RPC `tools.catalog` / `tools.refresh`).
|
||||
*/
|
||||
|
||||
import type { AlarmPushRpcCall } from './alarm-push-status-view.ts'
|
||||
import { NETXOPS_RPC_CHANNEL } from './tools-status-view.ts'
|
||||
|
||||
export const TOOLS_CATALOG_ENDPOINT = 'tools.catalog'
|
||||
export const TOOLS_REFRESH_ENDPOINT = 'tools.refresh'
|
||||
|
||||
export interface McpToolRow {
|
||||
name: string
|
||||
description: string
|
||||
readOnlyHint: boolean
|
||||
group: 'ops' | 'topology' | 'bizMonitor' | null
|
||||
}
|
||||
|
||||
export interface McpCatalogView {
|
||||
phase: string
|
||||
mcpUrl: string
|
||||
error: string
|
||||
listedAt: string | null
|
||||
fromCache: boolean
|
||||
tools: McpToolRow[]
|
||||
}
|
||||
|
||||
const EMPTY: McpCatalogView = {
|
||||
phase: 'idle',
|
||||
mcpUrl: '',
|
||||
error: '',
|
||||
listedAt: null,
|
||||
fromCache: false,
|
||||
tools: [],
|
||||
}
|
||||
|
||||
export function asMcpCatalogView(value: unknown): McpCatalogView {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) return { ...EMPTY }
|
||||
const row = value as Record<string, unknown>
|
||||
const tools: McpToolRow[] = []
|
||||
if (Array.isArray(row.tools)) {
|
||||
for (const raw of row.tools) {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue
|
||||
const t = raw as Record<string, unknown>
|
||||
const name = typeof t.name === 'string' ? t.name : ''
|
||||
if (!name) continue
|
||||
const group = t.group === 'ops' || t.group === 'topology' || t.group === 'bizMonitor'
|
||||
? t.group
|
||||
: null
|
||||
tools.push({
|
||||
name,
|
||||
description: typeof t.description === 'string' ? t.description : '',
|
||||
readOnlyHint: t.readOnlyHint === true,
|
||||
group,
|
||||
})
|
||||
}
|
||||
}
|
||||
return {
|
||||
phase: typeof row.phase === 'string' ? row.phase : 'idle',
|
||||
mcpUrl: typeof row.mcpUrl === 'string' ? row.mcpUrl : '',
|
||||
error: typeof row.error === 'string' ? row.error : '',
|
||||
listedAt: typeof row.listedAt === 'string' ? row.listedAt : null,
|
||||
fromCache: row.fromCache === true,
|
||||
tools,
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMcpCatalog(
|
||||
call: AlarmPushRpcCall,
|
||||
signal?: AbortSignal,
|
||||
): Promise<McpCatalogView> {
|
||||
const result = await call(NETXOPS_RPC_CHANNEL, TOOLS_CATALOG_ENDPOINT, {}, signal)
|
||||
if (result !== null && typeof result === 'object' && (result as { ok?: boolean }).ok === true) {
|
||||
return asMcpCatalogView((result as { value?: unknown }).value)
|
||||
}
|
||||
return { ...EMPTY }
|
||||
}
|
||||
|
||||
export async function refreshMcpCatalogRemote(
|
||||
call: AlarmPushRpcCall,
|
||||
signal?: AbortSignal,
|
||||
): Promise<McpCatalogView> {
|
||||
const result = await call(NETXOPS_RPC_CHANNEL, TOOLS_REFRESH_ENDPOINT, {}, signal)
|
||||
if (result !== null && typeof result === 'object') {
|
||||
const row = result as { ok?: boolean; value?: unknown; error?: unknown }
|
||||
const view = asMcpCatalogView(row.value)
|
||||
if (typeof row.error === 'string' && row.error && !view.error) {
|
||||
return { ...view, error: row.error, phase: view.phase === 'connected' ? view.phase : 'error' }
|
||||
}
|
||||
return view
|
||||
}
|
||||
return { ...EMPTY, phase: 'error', error: 'refresh_failed' }
|
||||
}
|
||||
|
|
@ -48,6 +48,12 @@ const CSS = `
|
|||
.dsh-nx-groupBlock+.dsh-nx-groupBlock{border-top:1px solid var(--dsw-alias-border-l1,#eef0f3)}
|
||||
.dsh-nx-groupTitle{display:flex;align-items:center;gap:8px;font-size:13px;font-weight:600;line-height:1.5;color:var(--dsw-alias-label-primary,#1f2329)}
|
||||
.dsh-nx-groupChecks{display:flex;flex-wrap:wrap;gap:8px 24px}
|
||||
.dsh-nx-toolList{margin:0}
|
||||
.dsh-nx-toolList>summary{font-size:12px;color:var(--dsw-alias-label-secondary,#646a73)}
|
||||
.dsh-nx-toolRows{display:flex;flex-direction:column;gap:6px;padding:8px 0 0}
|
||||
.dsh-nx-toolRow{align-items:flex-start}
|
||||
.dsh-nx-toolMeta{display:flex;flex-direction:column;gap:2px;min-width:0}
|
||||
.dsh-nx-toolName{display:inline-flex;align-items:center;gap:6px;font-size:13px;font-weight:500;overflow-wrap:anywhere}
|
||||
.dsh-nx-targets{margin-top:0}
|
||||
.dsh-nx-diagnostics{padding-top:12px;border-top:1px solid var(--dsw-alias-border-l1,#eef0f3)}
|
||||
.dsh-nx-diagnostics[open] summary{margin-bottom:8px}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ export interface ToolsMountView {
|
|||
error?: string
|
||||
tokenConfigured: boolean | null
|
||||
apiUrl: string
|
||||
mcpPhase: string
|
||||
mcpError: string
|
||||
mcpFromCache: boolean
|
||||
}
|
||||
|
||||
const EMPTY: ToolsMountView = {
|
||||
|
|
@ -30,6 +33,9 @@ const EMPTY: ToolsMountView = {
|
|||
plane: '',
|
||||
tokenConfigured: null,
|
||||
apiUrl: '',
|
||||
mcpPhase: 'idle',
|
||||
mcpError: '',
|
||||
mcpFromCache: false,
|
||||
}
|
||||
|
||||
/** Normalize host `tools.status` RPC value. */
|
||||
|
|
@ -83,7 +89,19 @@ export function asToolsMountView(value: unknown): ToolsMountView {
|
|||
}
|
||||
const toolsEnabled = !(connection && typeof connection === 'object'
|
||||
&& (connection as Record<string, unknown>).toolsEnabled === false)
|
||||
return { count, version, groups, plane, error, tokenConfigured, apiUrl, mounts, toolsEnabled }
|
||||
let mcpPhase = 'idle'
|
||||
let mcpError = ''
|
||||
let mcpFromCache = false
|
||||
if (row.mcp !== null && typeof row.mcp === 'object' && !Array.isArray(row.mcp)) {
|
||||
const mcp = row.mcp as Record<string, unknown>
|
||||
mcpPhase = typeof mcp.phase === 'string' ? mcp.phase : 'idle'
|
||||
mcpError = typeof mcp.error === 'string' ? mcp.error : ''
|
||||
mcpFromCache = mcp.fromCache === true
|
||||
}
|
||||
return {
|
||||
count, version, groups, plane, error, tokenConfigured, apiUrl, mounts, toolsEnabled,
|
||||
mcpPhase, mcpError, mcpFromCache,
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch Host tool mount snapshot. */
|
||||
|
|
@ -101,7 +119,8 @@ export async function fetchToolsStatus(
|
|||
/** Badge tone for the tools mount line. */
|
||||
export function toolsMountTone(view: ToolsMountView | null): 'ok' | 'warn' | 'err' | 'neutral' {
|
||||
if (!view) return 'neutral'
|
||||
if (view.error) return 'err'
|
||||
if (view.error || view.mcpPhase === 'error') return 'err'
|
||||
if (view.mcpPhase === 'offline' || view.mcpFromCache) return 'warn'
|
||||
if (!view.toolsEnabled) return 'neutral'
|
||||
if (view.count > 0) return 'ok'
|
||||
return 'warn'
|
||||
|
|
|
|||
114
src/index.ts
114
src/index.ts
|
|
@ -97,6 +97,13 @@ import {
|
|||
import { registerNetxTools } from './netx/tools.ts'
|
||||
import { installPresetPublication } from './netx/preset-publication.ts'
|
||||
import { readConfigSnapshot, readConfigValue, type ConfigInput } from './netx/config-values.ts'
|
||||
import { disabledPublicNames, parseDisabledTools } from './netx/mcp-catalog.ts'
|
||||
import {
|
||||
getMcpCatalogState,
|
||||
getMcpCatalogTools,
|
||||
refreshMcpCatalog,
|
||||
watchMcpCatalog,
|
||||
} from './netx/mcp-store.ts'
|
||||
|
||||
// The event is absent from older peer declarations, where it is never emitted.
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
|
|
@ -214,6 +221,14 @@ export interface Config {
|
|||
groupKbInPreset: boolean
|
||||
/** Publish subset `_skills/kb-*` to other presets (default off). */
|
||||
groupKbPublic: boolean
|
||||
/**
|
||||
* JSON array of server tool names opted out by the operator.
|
||||
* Unlisted tools follow their capability-group switch (new server tools auto-enable with the group).
|
||||
* UX only — not a security boundary; netx `/mcp` enforces authz.
|
||||
*/
|
||||
disabledTools: string
|
||||
/** When true, only register tools with annotations.readOnlyHint === true. */
|
||||
readOnlyOnly: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -255,6 +270,8 @@ export const Config: z<Config, ConfigInput<Config>> = z.object({
|
|||
kbRoot: vol(z.string().default('')),
|
||||
groupKbInPreset: vol(z.boolean().default(true)),
|
||||
groupKbPublic: vol(z.boolean().default(false)),
|
||||
disabledTools: vol(z.string().default('[]')),
|
||||
readOnlyOnly: vol(z.boolean().default(false)),
|
||||
})
|
||||
|
||||
/** Merge settings projection without letting null/undefined wipe profile defaults. */
|
||||
|
|
@ -538,6 +555,8 @@ export function apply(ctx: Context, config: ConfigInput<Config> = Config({} as C
|
|||
lang: resolveLang(current.lang),
|
||||
toolCallTimeoutMs: resolveToolCallTimeoutMs(current.toolCallTimeoutMs),
|
||||
groups: capabilityGroupsFromSettings(current as NetxCapabilityGroupSettingsFields),
|
||||
disabledTools: parseDisabledTools(current.disabledTools),
|
||||
readOnlyOnly: current.readOnlyOnly === true,
|
||||
groupKbInPreset: typeof current.groupKbInPreset === 'boolean'
|
||||
? current.groupKbInPreset
|
||||
: true,
|
||||
|
|
@ -639,11 +658,13 @@ export function apply(ctx: Context, config: ConfigInput<Config> = Config({} as C
|
|||
remountHostTools()
|
||||
remountKbLocal()
|
||||
})
|
||||
const stopCatalogWatch = watchMcpCatalog(() => { remountHostTools() })
|
||||
const stopKbWatch = watchKbContext(() => { remountKbLocal() })
|
||||
remountHostTools()
|
||||
remountKbLocal()
|
||||
ctx.effect(() => () => {
|
||||
stopWatch()
|
||||
stopCatalogWatch()
|
||||
stopKbWatch()
|
||||
disposeToolsMount?.()
|
||||
disposeKbLocal?.()
|
||||
|
|
@ -746,6 +767,28 @@ export function apply(ctx: Context, config: ConfigInput<Config> = Config({} as C
|
|||
}
|
||||
restartAlarmPush(current, snapshot.apiUrl, token)
|
||||
const tokenConfigured = token.trim().length > 0
|
||||
// Refresh remote MCP catalog; failures stay on the card and keep last cache.
|
||||
const catalog = await refreshMcpCatalog({
|
||||
apiUrl: snapshot.apiUrl,
|
||||
token,
|
||||
lang: snapshot.lang,
|
||||
timeoutMs: Math.min(snapshot.toolCallTimeoutMs, 45_000),
|
||||
})
|
||||
if (disposed || gen !== generation) return
|
||||
if (catalog.phase === 'connected') {
|
||||
ctx.logger.info(
|
||||
'netxops: mcp catalog ok → %s tools=%s',
|
||||
catalog.mcpUrl,
|
||||
catalog.tools.length,
|
||||
)
|
||||
} else if (catalog.error) {
|
||||
ctx.logger.warn(
|
||||
'netxops: mcp catalog %s → %s (%s)',
|
||||
catalog.phase,
|
||||
catalog.mcpUrl || '(no url)',
|
||||
catalog.error,
|
||||
)
|
||||
}
|
||||
if (!tokenConfigured) {
|
||||
ctx.logger.warn(
|
||||
'netxops: published connection → %s tokenConfigured=false (set credential %s)',
|
||||
|
|
@ -887,7 +930,22 @@ export function apply(ctx: Context, config: ConfigInput<Config> = Config({} as C
|
|||
const connection = getNetxConnection()
|
||||
if (!connection) return
|
||||
const enabled = connection.toolsEnabled === false ? [] : groupsForPlane(connection.groups, 'host')
|
||||
void registerGroupSkills(skillsCtx, enabled, 'netxops-host', skillAbort.signal).then((dispose) => {
|
||||
const disabledByGroup = Object.fromEntries(
|
||||
enabled.map((groupId) => [
|
||||
groupId,
|
||||
disabledPublicNames(
|
||||
getMcpCatalogTools(),
|
||||
[groupId],
|
||||
connection.disabledTools,
|
||||
connection.readOnlyOnly,
|
||||
),
|
||||
]),
|
||||
)
|
||||
void registerGroupSkills(skillsCtx, enabled, 'netxops-host', {
|
||||
signal: skillAbort.signal,
|
||||
disabledByGroup,
|
||||
lang: connection.lang,
|
||||
}).then((dispose) => {
|
||||
if (gen !== generation) {
|
||||
dispose()
|
||||
return
|
||||
|
|
@ -942,6 +1000,7 @@ export function apply(ctx: Context, config: ConfigInput<Config> = Config({} as C
|
|||
remountKbSkill()
|
||||
remountKbPack()
|
||||
})
|
||||
const stopCatalogWatch = watchMcpCatalog(() => { remountHostSkills() })
|
||||
const stopKbWatch = watchKbContext(() => {
|
||||
remountKbSkill()
|
||||
remountKbPack()
|
||||
|
|
@ -955,6 +1014,7 @@ export function apply(ctx: Context, config: ConfigInput<Config> = Config({} as C
|
|||
skillAbort.abort()
|
||||
packAbort.abort()
|
||||
stopWatch()
|
||||
stopCatalogWatch()
|
||||
stopKbWatch()
|
||||
unregisterSkills?.()
|
||||
unregisterKbSkill?.()
|
||||
|
|
@ -974,11 +1034,20 @@ export function apply(ctx: Context, config: ConfigInput<Config> = Config({} as C
|
|||
if (endpoint === 'tools.status') {
|
||||
const mount = getToolMountStatus()
|
||||
const connection = getNetxConnection()
|
||||
const catalog = getMcpCatalogState()
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
mount: mount ?? null,
|
||||
mounts: getToolMountStatuses(),
|
||||
mcp: {
|
||||
phase: catalog.phase,
|
||||
mcpUrl: catalog.mcpUrl,
|
||||
error: catalog.error,
|
||||
listedAt: catalog.listedAt,
|
||||
fromCache: catalog.fromCache,
|
||||
toolCount: catalog.tools.length,
|
||||
},
|
||||
connection: connection
|
||||
? {
|
||||
apiUrl: connection.apiUrl,
|
||||
|
|
@ -987,11 +1056,54 @@ export function apply(ctx: Context, config: ConfigInput<Config> = Config({} as C
|
|||
hostGroups: connection.toolsEnabled === false ? [] : groupsForPlane(connection.groups, 'host'),
|
||||
presetGroups: connection.toolsEnabled === false ? [] : groupsForPlane(connection.groups, 'preset'),
|
||||
publicGroups: connection.toolsEnabled === false ? [] : groupsForPlane(connection.groups, 'public'),
|
||||
disabledTools: [...(connection.disabledTools ?? [])],
|
||||
readOnlyOnly: connection.readOnlyOnly === true,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
if (endpoint === 'tools.catalog') {
|
||||
const catalog = getMcpCatalogState()
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
phase: catalog.phase,
|
||||
mcpUrl: catalog.mcpUrl,
|
||||
error: catalog.error,
|
||||
listedAt: catalog.listedAt,
|
||||
fromCache: catalog.fromCache,
|
||||
tools: catalog.tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
readOnlyHint: tool.readOnlyHint,
|
||||
group: tool.group ?? null,
|
||||
})),
|
||||
},
|
||||
}
|
||||
}
|
||||
if (endpoint === 'tools.refresh') {
|
||||
const connection = getNetxConnection()
|
||||
if (!connection) return { ok: false, error: 'connection_unavailable' }
|
||||
const catalog = await refreshMcpCatalog({
|
||||
apiUrl: connection.apiUrl,
|
||||
token: connection.token,
|
||||
lang: connection.lang,
|
||||
timeoutMs: Math.min(connection.toolCallTimeoutMs, 45_000),
|
||||
})
|
||||
return {
|
||||
ok: catalog.phase === 'connected',
|
||||
value: {
|
||||
phase: catalog.phase,
|
||||
mcpUrl: catalog.mcpUrl,
|
||||
error: catalog.error,
|
||||
listedAt: catalog.listedAt,
|
||||
fromCache: catalog.fromCache,
|
||||
toolCount: catalog.tools.length,
|
||||
},
|
||||
...(catalog.error ? { error: catalog.error } : {}),
|
||||
}
|
||||
}
|
||||
if (endpoint === 'alarm-push.status') {
|
||||
return { ok: true, value: getAlarmPushStatus() }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,55 +50,9 @@ export const DEFAULT_CAPABILITY_GROUPS: NetxCapabilityGroups = Object.freeze({
|
|||
})
|
||||
|
||||
/**
|
||||
* Model-facing tool names (`netx__*`) owned by each group.
|
||||
* NMS tools use generic `Nms` names; HTTP still hits the configured NMS provider adapter.
|
||||
* Tool membership is no longer hardcoded here — it comes from netx `/mcp`
|
||||
* `_meta.netx.group`. See `mcp-catalog.ts` / `filterCatalogTools`.
|
||||
*/
|
||||
export const TOOLS_BY_GROUP: Readonly<Record<NetxCapabilityGroupId, readonly string[]>> = Object.freeze({
|
||||
ops: Object.freeze([
|
||||
'netx__queryNmsAlarms',
|
||||
'netx__aggregateNmsAlarms',
|
||||
'netx__runNmsDiagnostics',
|
||||
'netx__queryNmsNeInventory',
|
||||
'netx__getNmsNe',
|
||||
'netx__queryNmsAlarmsRaw',
|
||||
'netx__aggregateNmsAlarmsRaw',
|
||||
'netx__listNmsAlarmFields',
|
||||
'netx__sqlQueryNms',
|
||||
'netx__listManagedNe',
|
||||
'netx__getManagedNe',
|
||||
'netx__execManagedNe',
|
||||
'netx__getNeExecJob',
|
||||
'netx__listCliTargets',
|
||||
'netx__findTopologyPaths',
|
||||
]),
|
||||
topology: Object.freeze([
|
||||
'netx__getTopologyTree',
|
||||
'netx__getTopologyView',
|
||||
'netx__createTopologyFolder',
|
||||
'netx__addTopologyViewNodes',
|
||||
'netx__removeTopologyViewNodes',
|
||||
'netx__copyTopologyViewNodes',
|
||||
'netx__updateTopologyViewPositions',
|
||||
'netx__projectTopologyNeighbors',
|
||||
'netx__queryTopologyFabricNodes',
|
||||
'netx__classifyTopologyFabricNodes',
|
||||
'netx__queryTopologyNeighborhood',
|
||||
'netx__queryTopologyEdges',
|
||||
'netx__layoutTopologyView',
|
||||
'netx__suggestSinkHubs',
|
||||
'netx__analyzeTopologyViewLayout',
|
||||
]),
|
||||
bizMonitor: Object.freeze([
|
||||
'netx__listBizMonitors',
|
||||
'netx__listBizMonitorBatches',
|
||||
'netx__getBizMonitorContext',
|
||||
'netx__getBizMonitorBoard',
|
||||
'netx__listBizMonitorReds',
|
||||
'netx__getBizMonitorDiffs',
|
||||
'netx__getBizCollectBatch',
|
||||
'netx__getBizCollectCommandRaw',
|
||||
]),
|
||||
})
|
||||
|
||||
/** Skill directory name under skills root / `presets/netxops/skills/<group>/`. */
|
||||
export const SKILL_DIR_BY_GROUP: Readonly<Record<NetxCapabilityGroupId, string>> = Object.freeze({
|
||||
|
|
@ -244,17 +198,6 @@ export function groupsForced(
|
|||
return CAPABILITY_GROUP_IDS.filter((id) => normalized.includes(id))
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool name allow-list for the given groups.
|
||||
*/
|
||||
export function toolNamesForGroups(groupIds: readonly NetxCapabilityGroupId[]): Set<string> {
|
||||
const out = new Set<string>()
|
||||
for (const id of groupIds) {
|
||||
for (const name of TOOLS_BY_GROUP[id] ?? []) out.add(name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Parse the persisted preset-id list. Empty is an explicit empty selection. */
|
||||
export function parsePresetIds(value: unknown): string[] {
|
||||
const current = readConfigValue(value)
|
||||
|
|
|
|||
|
|
@ -189,8 +189,31 @@ async function loadGroupSkills(
|
|||
return { skills, skipped }
|
||||
}
|
||||
|
||||
/** Per-group model-facing tool names (`netx__*`) that are off while the group skill still mounts. */
|
||||
export type DisabledToolsByGroup = Partial<Record<NetxCapabilityGroupId, readonly string[]>>
|
||||
|
||||
export interface RegisterGroupSkillsOptions {
|
||||
signal?: AbortSignal
|
||||
/**
|
||||
* When a group is enabled but some tools are filtered out (disabledTools /
|
||||
* readOnlyOnly), append a short "do not call" footnote listing public names.
|
||||
*/
|
||||
disabledByGroup?: DisabledToolsByGroup
|
||||
lang?: string
|
||||
}
|
||||
|
||||
function disabledFootnote(names: readonly string[], lang?: string): string {
|
||||
if (names.length === 0) return ''
|
||||
const list = names.join(', ')
|
||||
const en = (lang ?? '').trim().toLowerCase().startsWith('en')
|
||||
return en
|
||||
? `\n\n## Disabled tools\nThe following tools are disabled: ${list}. Do not call them.\n`
|
||||
: `\n\n## 已禁用工具\n以下工具已禁用:${list},不要调用。\n`
|
||||
}
|
||||
|
||||
/**
|
||||
* Register playbook skills for the given capability groups into `ctx.skills`.
|
||||
* Groups that are off are skipped entirely (no skill injection).
|
||||
* @param ctx - context with a skills registry (preset or host layer).
|
||||
* @param groupIds - enabled groups for this plane.
|
||||
* @param providerLabel - `provider` stamp on each registration.
|
||||
|
|
@ -200,8 +223,13 @@ export async function registerGroupSkills(
|
|||
ctx: Context,
|
||||
groupIds: readonly NetxCapabilityGroupId[],
|
||||
providerLabel: string,
|
||||
signal?: AbortSignal,
|
||||
signalOrOptions?: AbortSignal | RegisterGroupSkillsOptions,
|
||||
): Promise<() => void> {
|
||||
const options: RegisterGroupSkillsOptions = signalOrOptions instanceof AbortSignal
|
||||
|| signalOrOptions === undefined
|
||||
? { signal: signalOrOptions }
|
||||
: signalOrOptions
|
||||
const signal = options.signal
|
||||
const skillsApi = (ctx as { skills?: { register: (skill: {
|
||||
name: string
|
||||
description: string
|
||||
|
|
@ -235,13 +263,15 @@ export async function registerGroupSkills(
|
|||
join(root, SKILL_DIR_BY_GROUP[groupId]),
|
||||
)
|
||||
}
|
||||
const footnote = disabledFootnote(options.disabledByGroup?.[groupId] ?? [], options.lang)
|
||||
for (const skill of skills) {
|
||||
const base = groupId === 'topology'
|
||||
? `${skill.content}\n\n## DeepSeek Harness REST capabilities\nUse only tools present in this session. Dual-unit/orbit/polish recipes require server-side topology tools. Use only tools present in this session.\n`
|
||||
: skill.content
|
||||
disposers.push(skillsApi.register({
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
content: groupId === 'topology'
|
||||
? `${skill.content}\n\n## DeepSeek Harness REST capabilities\nUse only tools present in this session. Native layout supports catalog and move_nodes/sink_nodes. Dual-unit/orbit/polish recipes require a separately installed netx-topology MCP. Native analyzeTopologyViewLayout reports basic graph statistics; suggestSinkHubs uses degree ranking. Native sinkTopologyDualUnits is unavailable.\n`
|
||||
: skill.content,
|
||||
content: `${base}${footnote}`,
|
||||
path: skill.path,
|
||||
resourceBase: { kind: 'directory', path: skill.directory },
|
||||
provider: providerLabel,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
/**
|
||||
* Shared agent-plane mount: tools + skills for one or more capability groups.
|
||||
*
|
||||
* Tools come from the cached netx `/mcp` catalog. Plugin switches only control
|
||||
* model visibility — not security.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
|
@ -12,7 +15,9 @@ import {
|
|||
kbEnabledForPlane,
|
||||
type NetxCapabilityGroupId,
|
||||
} from './capability-groups.ts'
|
||||
import { registerGroupSkills } from './group-skills.ts'
|
||||
import { disabledPublicNames } from './mcp-catalog.ts'
|
||||
import { getMcpCatalogTools, watchMcpCatalog } from './mcp-store.ts'
|
||||
import { registerGroupSkills, type DisabledToolsByGroup } from './group-skills.ts'
|
||||
import { registerKbContextSkill } from './kb-context-skill.ts'
|
||||
import { registerKbLocalTools } from './kb-local-tools.ts'
|
||||
import { registerKbPackSkills } from './kb-pack-skills.ts'
|
||||
|
|
@ -49,6 +54,24 @@ export interface GroupToolsPluginOptions {
|
|||
presetId?: string
|
||||
}
|
||||
|
||||
function disabledByGroupFor(
|
||||
groupIds: readonly NetxCapabilityGroupId[],
|
||||
connection: ReturnType<typeof requireNetxConnection>,
|
||||
): DisabledToolsByGroup {
|
||||
const catalog = getMcpCatalogTools()
|
||||
const out: DisabledToolsByGroup = {}
|
||||
for (const groupId of groupIds) {
|
||||
const names = disabledPublicNames(
|
||||
catalog,
|
||||
[groupId],
|
||||
connection.disabledTools,
|
||||
connection.readOnlyOnly,
|
||||
)
|
||||
if (names.length > 0) out[groupId] = names
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a group-scoped tools/skills plugin on the calling (usually preset) context.
|
||||
*/
|
||||
|
|
@ -75,19 +98,20 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt
|
|||
const connection = requireNetxConnection()
|
||||
const enabled = resolveGroups()
|
||||
try {
|
||||
// Always call register (incl. empty) so revoke publishes count=0 and matches host.
|
||||
unregisterTools = registerNetxTools(ctx, connection, {
|
||||
plane: 'preset',
|
||||
forceGroups: enabled,
|
||||
source: options.name,
|
||||
presetId: presetId(),
|
||||
})
|
||||
ctx.logger.info(
|
||||
'%s: groups=[%s] → %s tokenConfigured=%s live=%s',
|
||||
'%s: groups=[%s] → %s tokenConfigured=%s live=%s catalog=%s',
|
||||
options.name,
|
||||
enabled.join(',') || '(none)',
|
||||
connection.apiUrl,
|
||||
connection.token.trim().length > 0,
|
||||
getNetxConnection() !== undefined,
|
||||
getMcpCatalogTools().length,
|
||||
)
|
||||
} catch (error) {
|
||||
publishToolMountStatus({
|
||||
|
|
@ -113,17 +137,17 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt
|
|||
unregisterKbLocal = registerKbLocalTools(ctx, getKbContext(), { plane: 'preset', source: `${options.name}:kb-local` })
|
||||
}
|
||||
|
||||
// Subscribe BEFORE the first remount. Host publish is async (credentials
|
||||
// resolve); if it lands between remount and watch, tools stay empty forever.
|
||||
const stopToolWatch = watchNetxConnection(() => {
|
||||
remountTools()
|
||||
remountKbLocal()
|
||||
})
|
||||
const stopCatalogWatch = watchMcpCatalog(() => {
|
||||
remountTools()
|
||||
})
|
||||
const stopKbToolWatch = watchKbContext(() => { remountKbLocal() })
|
||||
remountTools()
|
||||
remountKbLocal()
|
||||
|
||||
// Backup poll: covers publish that raced before watch, and slow credentials.
|
||||
let pollTicks = 0
|
||||
const pollTimer = setInterval(() => {
|
||||
pollTicks += 1
|
||||
|
|
@ -133,7 +157,7 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt
|
|||
clearInterval(pollTimer)
|
||||
return
|
||||
}
|
||||
if (pollTicks >= 40) clearInterval(pollTimer) // ~20s
|
||||
if (pollTicks >= 40) clearInterval(pollTimer)
|
||||
}, 500)
|
||||
|
||||
ctx.inject(['skills'], (skillsCtx) => {
|
||||
|
|
@ -148,8 +172,13 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt
|
|||
skillAbort = new AbortController()
|
||||
unregisterSkills?.()
|
||||
unregisterSkills = undefined
|
||||
const connection = requireNetxConnection()
|
||||
const enabled = resolveGroups()
|
||||
void registerGroupSkills(skillsCtx, enabled, options.name, skillAbort.signal).then((dispose) => {
|
||||
void registerGroupSkills(skillsCtx, enabled, options.name, {
|
||||
signal: skillAbort.signal,
|
||||
disabledByGroup: disabledByGroupFor(enabled, connection),
|
||||
lang: connection.lang,
|
||||
}).then((dispose) => {
|
||||
if (gen !== skillGeneration) {
|
||||
dispose()
|
||||
return
|
||||
|
|
@ -194,6 +223,7 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt
|
|||
remountKbSkill()
|
||||
remountKbPack()
|
||||
})
|
||||
const stopSkillCatalogWatch = watchMcpCatalog(() => { remountSkills() })
|
||||
const stopKbWatch = watchKbContext(() => {
|
||||
remountKbSkill()
|
||||
remountKbPack()
|
||||
|
|
@ -207,6 +237,7 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt
|
|||
skillAbort.abort()
|
||||
packAbort.abort()
|
||||
stopSkillWatch()
|
||||
stopSkillCatalogWatch()
|
||||
stopKbWatch()
|
||||
unregisterSkills?.()
|
||||
unregisterSkills = undefined
|
||||
|
|
@ -220,6 +251,7 @@ export function applyGroupToolsPlugin(ctx: Context, options: GroupToolsPluginOpt
|
|||
ctx.effect(() => () => {
|
||||
clearInterval(pollTimer)
|
||||
stopToolWatch()
|
||||
stopCatalogWatch()
|
||||
stopKbToolWatch()
|
||||
unregisterTools?.()
|
||||
unregisterTools = undefined
|
||||
|
|
|
|||
|
|
@ -1,841 +0,0 @@
|
|||
/**
|
||||
* netx REST tool handlers — port of packages/netx-mcp http_tools.py.
|
||||
*/
|
||||
|
||||
import { type NetxClient, quoteNeId, type NetxJson } from './http.ts'
|
||||
import { omitUndefined } from './json-safe.ts'
|
||||
|
||||
const EXEC_MAX_COMMANDS_CAP = 50
|
||||
const EXEC_MAX_COMMANDS_DEFAULT = 5
|
||||
|
||||
function execMaxCommands(): number {
|
||||
const raw = Number.parseInt(String(process.env.NETX_NE_EXEC_MAX_COMMANDS ?? ''), 10)
|
||||
const n = Number.isFinite(raw) ? raw : EXEC_MAX_COMMANDS_DEFAULT
|
||||
return Math.max(1, Math.min(EXEC_MAX_COMMANDS_CAP, n))
|
||||
}
|
||||
|
||||
function asyncMinNes(): number {
|
||||
const raw = Number.parseInt(String(process.env.NETX_NE_EXEC_ASYNC_MIN_NES ?? ''), 10)
|
||||
const n = Number.isFinite(raw) ? raw : 4
|
||||
return Math.max(0, Math.min(50, n))
|
||||
}
|
||||
|
||||
function truthyAsyncFlag(raw: unknown): boolean | undefined {
|
||||
if (raw === undefined || raw === null) return undefined
|
||||
if (typeof raw === 'boolean') return raw
|
||||
const text = String(raw).trim().toLowerCase()
|
||||
if (!text) return undefined
|
||||
if (['1', 'true', 'yes', 'on'].includes(text)) return true
|
||||
if (['0', 'false', 'no', 'off'].includes(text)) return false
|
||||
return undefined
|
||||
}
|
||||
|
||||
function countExecNeTargets(args: NetxJson): number {
|
||||
let n = 0
|
||||
for (const key of ['ne_ids', 'nms_ne_ids', 'ume_ne_ids'] as const) {
|
||||
const val = strList(args, key)
|
||||
if (val.length > 0) n = Math.max(n, val.length)
|
||||
}
|
||||
const targets = args.targets
|
||||
if (Array.isArray(targets)) {
|
||||
n = Math.max(n, targets.filter(t => typeof t === 'object' && t !== null && !Array.isArray(t)).length)
|
||||
}
|
||||
if (n === 0) {
|
||||
if (str(args, 'ne_id').trim() || nmsOrUme(args, 'nms_ne_id', 'ume_ne_id')) return 1
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
function shouldRunExecAsync(args: NetxJson): boolean {
|
||||
const flag = truthyAsyncFlag(args.async)
|
||||
if (flag === false) return false
|
||||
if (flag === true) return true
|
||||
const minN = asyncMinNes()
|
||||
if (minN <= 0) return false
|
||||
return countExecNeTargets(args) >= minN
|
||||
}
|
||||
|
||||
const UME_RAW_FIELD_PRESETS: Record<string, string[]> = {
|
||||
brief: [
|
||||
'alarm_alarm_key', 'alarm_host_name', 'alarm_perceived_severity', 'alarm_event_type',
|
||||
'alarm_last_seen_at', 'ne_host_name', 'ne_user_label', 'ne_ne_name', 'ne_ip_address', 'ne_exists',
|
||||
],
|
||||
evidence: [
|
||||
'alarm_alarm_key', 'alarm_host_name', 'alarm_object_name', 'alarm_event_type',
|
||||
'alarm_native_probable_cause', 'alarm_perceived_severity', 'alarm_is_cleared',
|
||||
'alarm_time_created', 'alarm_last_seen_at', 'ne_host_name', 'ne_user_label',
|
||||
'ne_ne_name', 'ne_ip_address', 'ne_connection_status', 'ne_exists',
|
||||
],
|
||||
ne_debug: [
|
||||
'alarm_alarm_key', 'alarm_ne_id', 'alarm_perceived_severity', 'alarm_last_seen_at',
|
||||
'ne_user_label', 'ne_ne_name', 'ne_ip_address', 'ne_ipv6_address', 'ne_device_level',
|
||||
'ne_host_name', 'ne_connection_status', 'ne_admin_status', 'ne_address_type',
|
||||
'ne_maintain_status', 'ne_exists',
|
||||
],
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): NetxJson {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? value as NetxJson
|
||||
: {}
|
||||
}
|
||||
|
||||
function str(args: NetxJson, key: string, fallback = ''): string {
|
||||
const v = args[key]
|
||||
if (typeof v === 'string') return v
|
||||
if (typeof v === 'number' || typeof v === 'boolean') return String(v)
|
||||
return fallback
|
||||
}
|
||||
|
||||
/** Prefer generic `nms_*` aliases; fall back to historical `ume_*` wire names. */
|
||||
function nmsOrUme(args: NetxJson, nmsKey: string, umeKey: string): string {
|
||||
return str(args, nmsKey).trim() || str(args, umeKey).trim()
|
||||
}
|
||||
|
||||
function nmsOrUmeList(args: NetxJson, nmsKey: string, umeKey: string): string[] {
|
||||
const primary = strList(args, nmsKey)
|
||||
return primary.length > 0 ? primary : strList(args, umeKey)
|
||||
}
|
||||
|
||||
function num(args: NetxJson, key: string): number | undefined {
|
||||
const v = args[key]
|
||||
return typeof v === 'number' && Number.isFinite(v) ? v : undefined
|
||||
}
|
||||
|
||||
function bool(args: NetxJson, key: string): boolean | undefined {
|
||||
const v = args[key]
|
||||
return typeof v === 'boolean' ? v : undefined
|
||||
}
|
||||
|
||||
function strList(args: NetxJson, key: string): string[] {
|
||||
const v = args[key]
|
||||
if (!Array.isArray(v)) return []
|
||||
return v.map(x => String(x).trim()).filter(x => x.length > 0)
|
||||
}
|
||||
|
||||
function clampInt(value: number | undefined, fallback: number, min: number, max: number): number {
|
||||
const n = value === undefined ? fallback : Math.trunc(value)
|
||||
return Math.max(min, Math.min(max, n))
|
||||
}
|
||||
|
||||
function putStr(
|
||||
params: Record<string, string | number | boolean>,
|
||||
args: NetxJson,
|
||||
keys: string[],
|
||||
): void {
|
||||
for (const key of keys) {
|
||||
const v = str(args, key).trim()
|
||||
if (v) params[key] = v
|
||||
}
|
||||
}
|
||||
|
||||
export async function queryUmeAlarms(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||
let page = clampInt(num(args, 'page'), 1, 1, 2)
|
||||
const pageSize = clampInt(num(args, 'page_size'), 50, 1, 500)
|
||||
const params: Record<string, string | number | boolean> = { page, page_size: pageSize }
|
||||
putStr(params, args, ['severity', 'ne_id', 'host_name', 'time_from', 'time_to'])
|
||||
const keyword = str(args, 'keyword').trim()
|
||||
const neName = str(args, 'ne_name').trim()
|
||||
if (keyword) params.keyword = keyword
|
||||
else if (neName) params.keyword = neName
|
||||
return client.get('/v1/ume/alarms', params, signal)
|
||||
}
|
||||
|
||||
export async function aggregateUmeAlarmsRaw(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||
const params: Record<string, string | number | boolean> = {}
|
||||
putStr(params, args, [
|
||||
'group_by', 'group_by2', 'severity', 'is_cleared', 'ne_id', 'event_type',
|
||||
'keyword', 'time_from', 'time_to', 'limit',
|
||||
])
|
||||
if ('exclude_missing_host' in args) {
|
||||
const flag = bool(args, 'exclude_missing_host')
|
||||
if (flag !== undefined) params.exclude_missing_host = flag
|
||||
}
|
||||
return client.get('/v1/ume/alarms/aggregate/raw', params, signal)
|
||||
}
|
||||
|
||||
export async function aggregateUmeAlarms(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||
if (str(args, 'group_by').trim()) return aggregateUmeAlarmsRaw(client, args, signal)
|
||||
const topNe = clampInt(num(args, 'top_ne'), 50, 0, 500)
|
||||
const params: Record<string, string | number | boolean> = { top_ne: topNe }
|
||||
if ('exclude_missing_host' in args) {
|
||||
const flag = bool(args, 'exclude_missing_host')
|
||||
if (flag !== undefined) params.exclude_missing_host = flag
|
||||
}
|
||||
putStr(params, args, ['severity', 'time_from', 'time_to'])
|
||||
return client.get('/v1/ume/alarms/aggregate', params, signal)
|
||||
}
|
||||
|
||||
export async function runUmeDiagnostics(client: NetxClient, _args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||
return client.get('/v1/ume/diagnostics', undefined, signal)
|
||||
}
|
||||
|
||||
export async function queryUmeNeInventory(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||
const params: Record<string, string | number | boolean> = {
|
||||
page: clampInt(num(args, 'page'), 1, 1, Number.MAX_SAFE_INTEGER),
|
||||
page_size: clampInt(num(args, 'page_size'), 50, 1, 500),
|
||||
}
|
||||
putStr(params, args, ['keyword'])
|
||||
return client.get('/v1/ume/inventory/ne', params, signal)
|
||||
}
|
||||
|
||||
export async function getUmeNe(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||
const neId = str(args, 'ne_id').trim()
|
||||
if (!neId) return { ok: false, error: 'ne_id_required', error_code: 'ne_id_required' }
|
||||
return client.get(`/v1/ume/inventory/ne/${quoteNeId(neId)}`, undefined, signal)
|
||||
}
|
||||
|
||||
export async function queryUmeAlarmsRaw(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||
const params: Record<string, string | number | boolean> = {
|
||||
page: clampInt(num(args, 'page'), 1, 1, Number.MAX_SAFE_INTEGER),
|
||||
page_size: clampInt(num(args, 'page_size'), 50, 1, 500),
|
||||
}
|
||||
putStr(params, args, [
|
||||
'severity', 'is_cleared', 'ne_id', 'event_type', 'keyword',
|
||||
'time_from', 'time_to', 'order_by', 'order',
|
||||
])
|
||||
let fields = strList(args, 'select_fields')
|
||||
if (fields.length === 0) {
|
||||
const preset = str(args, 'field_preset').trim().toLowerCase()
|
||||
fields = UME_RAW_FIELD_PRESETS[preset] ?? []
|
||||
}
|
||||
if (fields.length > 0) params.select_fields = fields.join(',')
|
||||
return client.get('/v1/ume/alarms/raw', params, signal)
|
||||
}
|
||||
|
||||
export async function listUmeAlarmFields(client: NetxClient, _args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||
return client.get('/v1/ume/alarms/fields', undefined, signal)
|
||||
}
|
||||
|
||||
export async function sqlQueryUme(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||
const sql = str(args, 'sql').trim()
|
||||
if (!sql) return { ok: false, error: 'sql_required' }
|
||||
const limit = clampInt(num(args, 'limit'), 200, 1, 2000)
|
||||
const statementTimeoutMs = clampInt(num(args, 'statement_timeout_ms'), 0, 0, 30_000)
|
||||
return client.post('/v1/sql/ume_query', {
|
||||
sql,
|
||||
limit,
|
||||
statement_timeout_ms: statementTimeoutMs,
|
||||
}, signal, 60_000)
|
||||
}
|
||||
|
||||
export async function listManagedNe(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||
const keyword = str(args, 'keyword').trim()
|
||||
const vendor = str(args, 'vendor').trim()
|
||||
const connectStatus = str(args, 'connect_status').trim()
|
||||
if (!(keyword || vendor || connectStatus)) {
|
||||
return { ok: false, error: 'managed_ne_filter_required', error_code: 'managed_ne_filter_required' }
|
||||
}
|
||||
if (keyword && keyword.length < 2) {
|
||||
return { ok: false, error: 'managed_ne_keyword_too_short', error_code: 'managed_ne_keyword_too_short' }
|
||||
}
|
||||
const params: Record<string, string | number | boolean> = {
|
||||
page: clampInt(num(args, 'page'), 1, 1, Number.MAX_SAFE_INTEGER),
|
||||
page_size: clampInt(num(args, 'page_size'), 20, 1, 100),
|
||||
}
|
||||
if (keyword) params.keyword = keyword
|
||||
if (vendor) params.vendor = vendor
|
||||
if (connectStatus) params.connect_status = connectStatus
|
||||
return client.get('/v1/managed-ne', params, signal)
|
||||
}
|
||||
|
||||
export async function getManagedNe(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||
const neId = (
|
||||
str(args, 'ne_id') || str(args, 'managed_ne_id') || str(args, 'id')
|
||||
).trim()
|
||||
if (!neId) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'ne_id_required',
|
||||
error_code: 'ne_id_required',
|
||||
hint: 'Pass managed NE id from listManagedNe/listCliTargets (source=managed). For NMS inventory UUIDs use execManagedNe(nms_ne_id=...) or getNmsNe, not getManagedNe.',
|
||||
example: { ne_id: '<managed-ne-uuid-from-listManagedNe>' },
|
||||
}
|
||||
}
|
||||
const out = await client.get(`/v1/managed-ne/${quoteNeId(neId)}`, undefined, signal)
|
||||
if (out.ok === false) {
|
||||
const detail = `${str(out, 'detail')}${str(out, 'error')}`.toLowerCase()
|
||||
if (detail.includes('404') || detail.includes('not_found') || detail.includes('not found') || out.error === 'netx_http_404') {
|
||||
return {
|
||||
...out,
|
||||
hint: 'Managed NE not found for this ne_id. Call listManagedNe(keyword=...) or listCliTargets(source=managed) first. If this is an NMS inventory id, use execManagedNe(nms_ne_id=...) / getNmsNe instead of getManagedNe.',
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export async function execManagedNe(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||
const maxCmds = execMaxCommands()
|
||||
const wantAsync = shouldRunExecAsync(args)
|
||||
const targetsRaw = args.targets
|
||||
const neIds = strList(args, 'ne_ids')
|
||||
const umeNeIds = nmsOrUmeList(args, 'nms_ne_ids', 'ume_ne_ids')
|
||||
const sharedCommands = strList(args, 'commands')
|
||||
const multi = (Array.isArray(targetsRaw) && targetsRaw.length > 0)
|
||||
|| neIds.length > 0
|
||||
|| umeNeIds.length > 0
|
||||
|
||||
if (multi) {
|
||||
const body: NetxJson = {}
|
||||
if (Array.isArray(targetsRaw) && targetsRaw.length > 0) {
|
||||
const cleaned: NetxJson[] = []
|
||||
for (const t of targetsRaw) {
|
||||
if (typeof t !== 'object' || t === null || Array.isArray(t)) continue
|
||||
const row = t as NetxJson
|
||||
const item: NetxJson = {}
|
||||
const neId = str(row, 'ne_id').trim()
|
||||
const umeNeId = nmsOrUme(row, 'nms_ne_id', 'ume_ne_id')
|
||||
if (neId) item.ne_id = neId
|
||||
if (umeNeId) item.ume_ne_id = umeNeId
|
||||
const cmds = Array.isArray(row.commands)
|
||||
? row.commands.map(c => String(c).trim()).filter(c => c.length > 0)
|
||||
: []
|
||||
if (cmds.length > 0) item.commands = cmds
|
||||
if (Object.keys(item).length > 0) cleaned.push(item)
|
||||
}
|
||||
body.targets = cleaned
|
||||
}
|
||||
if (neIds.length > 0) body.ne_ids = neIds
|
||||
if (umeNeIds.length > 0) body.ume_ne_ids = umeNeIds
|
||||
if (sharedCommands.length > 0) {
|
||||
if (sharedCommands.length > maxCmds) {
|
||||
return { ok: false, error: 'too_many_commands', error_code: 'too_many_commands' }
|
||||
}
|
||||
body.commands = sharedCommands
|
||||
}
|
||||
body.read_timeout_sec = clampInt(num(args, 'read_timeout_sec'), 60, 10, 120)
|
||||
const concurrency = num(args, 'concurrency')
|
||||
if (concurrency !== undefined) body.concurrency = clampInt(concurrency, 4, 1, 8)
|
||||
if (wantAsync) {
|
||||
const out = await client.post('/v1/managed-ne/exec-jobs', body, signal, 60_000)
|
||||
if (out.ok !== true) return out
|
||||
const data = asRecord(out.data)
|
||||
return Object.keys(data).length > 0 ? data : out
|
||||
}
|
||||
const out = await client.post('/v1/managed-ne/exec-batch', body, signal, 600_000)
|
||||
if (out.ok !== true) return out
|
||||
const data = asRecord(out.data)
|
||||
if (data.ok === false) {
|
||||
return { ok: false, data, error: str(data, 'error', 'exec_batch_failed') }
|
||||
}
|
||||
return { ok: true, data }
|
||||
}
|
||||
|
||||
const neId = str(args, 'ne_id').trim()
|
||||
const umeNeId = nmsOrUme(args, 'nms_ne_id', 'ume_ne_id')
|
||||
if (Boolean(neId) === Boolean(umeNeId)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'exactly_one_of_ne_id_or_nms_ne_id_required',
|
||||
error_code: 'exactly_one_of_ne_id_or_nms_ne_id_required',
|
||||
hint: 'For one NE pass ne_id OR nms_ne_id (alias ume_ne_id). For many NEs pass ne_ids / nms_ne_ids with shared commands, or targets[] — one call, concurrent on server.',
|
||||
}
|
||||
}
|
||||
if (sharedCommands.length === 0) {
|
||||
return { ok: false, error: 'commands_required', error_code: 'commands_required' }
|
||||
}
|
||||
if (sharedCommands.length > maxCmds) {
|
||||
return { ok: false, error: 'too_many_commands', error_code: 'too_many_commands' }
|
||||
}
|
||||
const body: NetxJson = {
|
||||
commands: sharedCommands,
|
||||
read_timeout_sec: clampInt(num(args, 'read_timeout_sec'), 60, 10, 120),
|
||||
}
|
||||
if (neId) body.ne_id = neId
|
||||
if (umeNeId) body.ume_ne_id = umeNeId
|
||||
if (wantAsync) {
|
||||
const out = await client.post('/v1/managed-ne/exec-jobs', body, signal, 60_000)
|
||||
if (out.ok !== true) return out
|
||||
const data = asRecord(out.data)
|
||||
return Object.keys(data).length > 0 ? data : out
|
||||
}
|
||||
const out = await client.post('/v1/managed-ne/exec', body, signal, 300_000)
|
||||
if (out.ok !== true) return out
|
||||
const data = asRecord(out.data)
|
||||
if (data.ok === false) {
|
||||
return { ok: false, data, error: str(data, 'error', 'exec_failed') }
|
||||
}
|
||||
return { ok: true, data }
|
||||
}
|
||||
|
||||
export async function getNeExecJob(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||
const jobId = str(args, 'job_id').trim()
|
||||
if (!jobId) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'job_id_required',
|
||||
error_code: 'job_id_required',
|
||||
hint: 'Pass job_id from execManagedNe async ack.',
|
||||
}
|
||||
}
|
||||
return client.get(`/v1/managed-ne/exec-jobs/${quoteNeId(jobId)}`, undefined, signal)
|
||||
}
|
||||
|
||||
export async function listCliTargets(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||
const params: Record<string, string | number | boolean> = {
|
||||
page: clampInt(num(args, 'page'), 1, 1, Number.MAX_SAFE_INTEGER),
|
||||
page_size: clampInt(num(args, 'page_size'), 50, 1, 500),
|
||||
}
|
||||
putStr(params, args, ['source', 'keyword'])
|
||||
return client.get('/v1/cli/targets', params, signal)
|
||||
}
|
||||
|
||||
export async function findTopologyPaths(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise<NetxJson> {
|
||||
const fromUid = nmsOrUme(args, 'from_nms_ne_id', 'from_ume_ne_id')
|
||||
const fromMid = str(args, 'from_managed_ne_id').trim()
|
||||
const toUid = nmsOrUme(args, 'to_nms_ne_id', 'to_ume_ne_id')
|
||||
const toMid = str(args, 'to_managed_ne_id').trim()
|
||||
if (Boolean(fromUid) === Boolean(fromMid)) {
|
||||
return { ok: false, error: 'exactly_one_of_from_nms_ne_id_or_from_managed_ne_id_required' }
|
||||
}
|
||||
if (Boolean(toUid) === Boolean(toMid)) {
|
||||
return { ok: false, error: 'exactly_one_of_to_nms_ne_id_or_to_managed_ne_id_required' }
|
||||
}
|
||||
let detail = str(args, 'detail', 'summary').trim().toLowerCase() || 'summary'
|
||||
if (detail !== 'summary' && detail !== 'full') detail = 'summary'
|
||||
const body: NetxJson = {
|
||||
max_paths: clampInt(num(args, 'max_paths'), 3, 1, 10),
|
||||
max_hops: clampInt(num(args, 'max_hops'), 6, 1, 12),
|
||||
layer: str(args, 'layer', 'physical').trim() || 'physical',
|
||||
detail,
|
||||
}
|
||||
if (fromUid) body.from_ume_ne_id = fromUid
|
||||
else body.from_managed_ne_id = fromMid
|
||||
if (toUid) body.to_ume_ne_id = toUid
|
||||
else body.to_managed_ne_id = toMid
|
||||
return client.post('/v1/topology/fabric/paths', body, signal, 30_000)
|
||||
}
|
||||
|
||||
// ── bizMonitor group (cutover / biz_state read) ─────────────────────────────
|
||||
|
||||
function taskBriefNe(brief: unknown): string {
|
||||
const rec = asRecord(brief)
|
||||
const name = typeof rec.ne_name === 'string' ? rec.ne_name.trim() : ''
|
||||
const ip = typeof rec.ne_ip === 'string' ? rec.ne_ip.trim() : ''
|
||||
if (name && ip) return `${name} (${ip})`
|
||||
return name || ip
|
||||
}
|
||||
|
||||
function slimMigrationProject(row: NetxJson): NetxJson {
|
||||
const mt = asRecord(row.monitor_template)
|
||||
return omitUndefined({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
status: row.status,
|
||||
note: row.note,
|
||||
monitor_template_id: row.monitor_template_id,
|
||||
monitor_template_name: typeof mt.name === 'string' ? mt.name : '',
|
||||
old_task_id: row.old_task_id,
|
||||
new_task_id: row.new_task_id,
|
||||
old_hf_task_id: row.old_hf_task_id,
|
||||
new_hf_task_id: row.new_hf_task_id,
|
||||
old_ne: taskBriefNe(row.old_task),
|
||||
new_ne: taskBriefNe(row.new_task),
|
||||
hf_interval_sec: row.hf_interval_sec,
|
||||
hf_start_at: row.hf_start_at,
|
||||
hf_end_at: row.hf_end_at,
|
||||
updated_at: row.updated_at,
|
||||
})
|
||||
}
|
||||
|
||||
function matchesBizMonitorQ(row: NetxJson, q: string): boolean {
|
||||
if (!q) return true
|
||||
const hay = [
|
||||
row.id, row.name, row.note, row.status, row.ne_name, row.ne_ip, row.ne_id,
|
||||
row.old_ne, row.new_ne, row.monitor_template_name, row.purpose, row.vendor,
|
||||
]
|
||||
.map((v) => (typeof v === 'string' || typeof v === 'number' ? String(v).toLowerCase() : ''))
|
||||
.join(' ')
|
||||
return hay.includes(q)
|
||||
}
|
||||
|
||||
/**
|
||||
* Catalog entry: list cutover projects and/or biz_state tasks (no project_id required).
|
||||
* Use this first when the user did not give an id; then call getBizMonitorContext.
|
||||
*/
|
||||
export async function listBizMonitors(
|
||||
client: NetxClient,
|
||||
args: NetxJson,
|
||||
signal?: AbortSignal,
|
||||
): Promise<NetxJson> {
|
||||
const kindRaw = str(args, 'kind', 'all').trim().toLowerCase() || 'all'
|
||||
const kind = kindRaw === 'project' ? 'projects'
|
||||
: kindRaw === 'task' ? 'tasks'
|
||||
: kindRaw
|
||||
if (kind !== 'all' && kind !== 'projects' && kind !== 'tasks') {
|
||||
return { ok: false, error: 'kind_invalid', detail: 'kind must be all|projects|tasks' }
|
||||
}
|
||||
const purpose = str(args, 'purpose').trim()
|
||||
const status = str(args, 'status').trim().toLowerCase()
|
||||
const q = str(args, 'q').trim().toLowerCase()
|
||||
const limit = clampInt(num(args, 'limit') ?? 100, 100, 1, 500)
|
||||
|
||||
const wantProjects = kind === 'all' || kind === 'projects'
|
||||
const wantTasks = kind === 'all' || kind === 'tasks'
|
||||
|
||||
const fetches: Array<Promise<NetxJson>> = []
|
||||
if (wantProjects) fetches.push(client.get('/v1/biz-migration/projects', {}, signal))
|
||||
else fetches.push(Promise.resolve({ ok: true, data: { items: [] } }))
|
||||
if (wantTasks) {
|
||||
const params: Record<string, string | number | boolean> = {}
|
||||
if (purpose) params.purpose = purpose
|
||||
fetches.push(client.get('/v1/biz-state/tasks', params, signal))
|
||||
} else {
|
||||
fetches.push(Promise.resolve({ ok: true, data: { items: [] } }))
|
||||
}
|
||||
|
||||
const [projRes, taskRes] = await Promise.all(fetches)
|
||||
if (wantProjects && projRes.ok === false) return projRes
|
||||
if (wantTasks && taskRes.ok === false) return taskRes
|
||||
|
||||
const projItems = Array.isArray(asRecord(projRes.data).items)
|
||||
? (asRecord(projRes.data).items as unknown[])
|
||||
: []
|
||||
const taskItems = Array.isArray(asRecord(taskRes.data).items)
|
||||
? (asRecord(taskRes.data).items as unknown[])
|
||||
: []
|
||||
|
||||
let projects = projItems
|
||||
.map((row) => slimMigrationProject(asRecord(row)))
|
||||
.filter((row) => {
|
||||
if (status && String(row.status || '').toLowerCase() !== status) return false
|
||||
return matchesBizMonitorQ(row, q)
|
||||
})
|
||||
let tasks = taskItems
|
||||
.map((row) => asRecord(row))
|
||||
.filter((row) => {
|
||||
if (status && String(row.status || '').toLowerCase() !== status) return false
|
||||
return matchesBizMonitorQ(row, q)
|
||||
})
|
||||
|
||||
const projectsTotal = projects.length
|
||||
const tasksTotal = tasks.length
|
||||
projects = projects.slice(0, limit)
|
||||
tasks = tasks.slice(0, limit)
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
data: omitUndefined({
|
||||
kind,
|
||||
projects: wantProjects ? projects : undefined,
|
||||
tasks: wantTasks ? tasks : undefined,
|
||||
counts: {
|
||||
projects: wantProjects ? projectsTotal : 0,
|
||||
tasks: wantTasks ? tasksTotal : 0,
|
||||
projects_returned: wantProjects ? projects.length : 0,
|
||||
tasks_returned: wantTasks ? tasks.length : 0,
|
||||
},
|
||||
next: 'Pass project_id (or task_id) to netx__getBizMonitorContext; then listBizMonitorBatches for batch_id / run_id.',
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function slimCutoverBatch(row: NetxJson): NetxJson {
|
||||
return omitUndefined({
|
||||
id: row.id,
|
||||
project_id: row.project_id,
|
||||
batch_label: row.batch_label,
|
||||
status: row.status,
|
||||
accept_status: row.accept_status,
|
||||
accept_run_id: row.accept_run_id,
|
||||
started_at: row.started_at,
|
||||
ended_at: row.ended_at,
|
||||
note: row.note,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
})
|
||||
}
|
||||
|
||||
function slimCollectBatch(row: NetxJson, taskId = ''): NetxJson {
|
||||
return omitUndefined({
|
||||
id: row.id,
|
||||
task_id: row.task_id || taskId || undefined,
|
||||
status: row.status,
|
||||
ne_name: row.ne_name,
|
||||
command_count: row.command_count,
|
||||
row_count: row.row_count,
|
||||
is_baseline: row.is_baseline,
|
||||
protected: row.protected,
|
||||
message: row.message,
|
||||
started_at: row.started_at,
|
||||
ended_at: row.ended_at,
|
||||
})
|
||||
}
|
||||
|
||||
function slimEvalRun(row: NetxJson): NetxJson {
|
||||
const summary = asRecord(row.summary)
|
||||
const progress = asRecord(summary.progress)
|
||||
return omitUndefined({
|
||||
id: row.id,
|
||||
batch_id: row.batch_id,
|
||||
purpose: row.purpose,
|
||||
old_batch_id: row.old_batch_id,
|
||||
new_batch_id: row.new_batch_id,
|
||||
created_at: row.created_at,
|
||||
anomaly: summary.anomaly,
|
||||
progress_ok: progress.ok ?? summary.progress_ok,
|
||||
progress_total: progress.total ?? summary.progress_total,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* List cutover batches, biz_state collect batches, and/or evaluate runs.
|
||||
* Needs project_id and/or task_id and/or cutover batch_id (for runs).
|
||||
*/
|
||||
export async function listBizMonitorBatches(
|
||||
client: NetxClient,
|
||||
args: NetxJson,
|
||||
signal?: AbortSignal,
|
||||
): Promise<NetxJson> {
|
||||
const projectId = str(args, 'project_id').trim()
|
||||
const taskId = str(args, 'task_id').trim()
|
||||
const batchId = str(args, 'batch_id').trim()
|
||||
const kindRaw = str(args, 'kind', 'auto').trim().toLowerCase() || 'auto'
|
||||
const includeRuns = args.include_runs === true || args.include_runs === 'true' || args.include_runs === 1
|
||||
const status = str(args, 'status').trim().toLowerCase()
|
||||
const limit = clampInt(num(args, 'limit') ?? 50, 50, 1, 200)
|
||||
const runsLimit = clampInt(num(args, 'runs_limit') ?? 10, 10, 1, 50)
|
||||
|
||||
let kind = kindRaw
|
||||
if (kind === 'auto') {
|
||||
if (batchId && !projectId && !taskId) kind = 'runs'
|
||||
else if (projectId && !taskId) kind = 'cutover'
|
||||
else if (taskId && !projectId) kind = 'collect'
|
||||
else if (projectId && taskId) kind = 'all'
|
||||
else if (batchId) kind = 'runs'
|
||||
else {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'project_id_or_task_id_or_batch_id_required',
|
||||
detail: 'Pass project_id (cutover batches), task_id (collect batches), and/or batch_id (evaluate runs).',
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!['auto', 'all', 'cutover', 'collect', 'runs'].includes(kind)) {
|
||||
return { ok: false, error: 'kind_invalid', detail: 'kind must be auto|all|cutover|collect|runs' }
|
||||
}
|
||||
|
||||
const wantCutover = kind === 'all' || kind === 'cutover'
|
||||
const wantCollect = kind === 'all' || kind === 'collect'
|
||||
const wantRuns = kind === 'runs' || (includeRuns && (wantCutover || Boolean(batchId)))
|
||||
|
||||
if (wantCutover && !projectId) {
|
||||
return { ok: false, error: 'project_id_required', detail: 'kind=cutover|all needs project_id' }
|
||||
}
|
||||
if (wantCollect && !taskId) {
|
||||
return { ok: false, error: 'task_id_required', detail: 'kind=collect|all needs task_id' }
|
||||
}
|
||||
if (kind === 'runs' && !batchId) {
|
||||
return { ok: false, error: 'batch_id_required', detail: 'kind=runs needs cutover batch_id' }
|
||||
}
|
||||
|
||||
let cutoverBatches: NetxJson[] = []
|
||||
let collectBatches: NetxJson[] = []
|
||||
let runs: NetxJson[] = []
|
||||
|
||||
if (wantCutover) {
|
||||
const res = await client.get(
|
||||
`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/batches`,
|
||||
{},
|
||||
signal,
|
||||
)
|
||||
if (res.ok === false) return res
|
||||
const items = Array.isArray(asRecord(res.data).items)
|
||||
? (asRecord(res.data).items as unknown[])
|
||||
: []
|
||||
cutoverBatches = items
|
||||
.map((row) => slimCutoverBatch(asRecord(row)))
|
||||
.filter((row) => {
|
||||
if (batchId && String(row.id || '') !== batchId) return false
|
||||
if (status && String(row.status || '').toLowerCase() !== status) return false
|
||||
return true
|
||||
})
|
||||
.slice(0, limit)
|
||||
}
|
||||
|
||||
if (wantCollect) {
|
||||
const res = await client.get(
|
||||
`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/batches`,
|
||||
{ limit },
|
||||
signal,
|
||||
)
|
||||
if (res.ok === false) return res
|
||||
const items = Array.isArray(asRecord(res.data).items)
|
||||
? (asRecord(res.data).items as unknown[])
|
||||
: []
|
||||
collectBatches = items
|
||||
.map((row) => slimCollectBatch(asRecord(row), taskId))
|
||||
.filter((row) => {
|
||||
if (status && String(row.status || '').toLowerCase() !== status) return false
|
||||
return true
|
||||
})
|
||||
.slice(0, limit)
|
||||
}
|
||||
|
||||
if (wantRuns) {
|
||||
const runBatchIds = kind === 'runs'
|
||||
? [batchId]
|
||||
: cutoverBatches.map((b) => String(b.id || '').trim()).filter(Boolean).slice(0, includeRuns ? 5 : 0)
|
||||
if (kind === 'runs' && batchId) {
|
||||
const res = await client.get(
|
||||
`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/runs`,
|
||||
{ limit: runsLimit },
|
||||
signal,
|
||||
)
|
||||
if (res.ok === false) return res
|
||||
const items = Array.isArray(asRecord(res.data).items)
|
||||
? (asRecord(res.data).items as unknown[])
|
||||
: []
|
||||
runs = items.map((row) => slimEvalRun(asRecord(row))).slice(0, runsLimit)
|
||||
} else if (includeRuns && runBatchIds.length > 0) {
|
||||
const settled = await Promise.all(
|
||||
runBatchIds.map(async (id) => {
|
||||
const res = await client.get(
|
||||
`/v1/biz-migration/batches/${encodeURIComponent(id)}/runs`,
|
||||
{ limit: runsLimit },
|
||||
signal,
|
||||
)
|
||||
if (res.ok === false) return { batch_id: id, error: res, items: [] as NetxJson[] }
|
||||
const items = Array.isArray(asRecord(res.data).items)
|
||||
? (asRecord(res.data).items as unknown[])
|
||||
: []
|
||||
return {
|
||||
batch_id: id,
|
||||
items: items.map((row) => slimEvalRun(asRecord(row))).slice(0, runsLimit),
|
||||
}
|
||||
}),
|
||||
)
|
||||
for (const block of settled) {
|
||||
if ('error' in block && block.error) {
|
||||
// Keep listing batches even if one runs fetch fails; attach error note.
|
||||
const failed = cutoverBatches.find((b) => b.id === block.batch_id)
|
||||
if (failed) failed.runs_error = asRecord(block.error).error || 'runs_fetch_failed'
|
||||
continue
|
||||
}
|
||||
const target = cutoverBatches.find((b) => b.id === block.batch_id)
|
||||
if (target) target.runs = block.items
|
||||
for (const r of block.items) runs.push(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
data: omitUndefined({
|
||||
kind,
|
||||
project_id: projectId || undefined,
|
||||
task_id: taskId || undefined,
|
||||
batch_id: batchId || undefined,
|
||||
cutover_batches: wantCutover ? cutoverBatches : undefined,
|
||||
collect_batches: wantCollect ? collectBatches : undefined,
|
||||
runs: kind === 'runs' ? runs : undefined,
|
||||
counts: {
|
||||
cutover_batches: cutoverBatches.length,
|
||||
collect_batches: collectBatches.length,
|
||||
runs: runs.length,
|
||||
},
|
||||
next: wantCutover
|
||||
? 'Use cutover batch id with netx__getBizMonitorBoard / listBizMonitorBatches(kind=runs). Collect batch id → netx__getBizCollectBatch.'
|
||||
: wantCollect
|
||||
? 'Pass collect batch id to netx__getBizCollectBatch / getBizCollectCommandRaw.'
|
||||
: 'Pass run_id to netx__getBizMonitorDiffs or getBizMonitorBoard(run_id).',
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/** Fat cutover / biz_state definition bundle (templates, mapping, tasks). */
|
||||
export async function getBizMonitorContext(
|
||||
client: NetxClient,
|
||||
args: NetxJson,
|
||||
signal?: AbortSignal,
|
||||
): Promise<NetxJson> {
|
||||
const projectId = str(args, 'project_id').trim()
|
||||
const taskId = str(args, 'task_id').trim()
|
||||
if (!projectId && !taskId) {
|
||||
return { ok: false, error: 'project_id_or_task_id_required' }
|
||||
}
|
||||
const params: Record<string, string | number | boolean> = {}
|
||||
if (projectId) params.project_id = projectId
|
||||
if (taskId) params.task_id = taskId
|
||||
return client.get('/v1/biz-migration/monitor-context', params, signal)
|
||||
}
|
||||
|
||||
/** Cutover batch board + latest evaluate run summary. */
|
||||
export async function getBizMonitorBoard(
|
||||
client: NetxClient,
|
||||
args: NetxJson,
|
||||
signal?: AbortSignal,
|
||||
): Promise<NetxJson> {
|
||||
const batchId = str(args, 'batch_id').trim()
|
||||
if (!batchId) return { ok: false, error: 'batch_id_required' }
|
||||
const params: Record<string, string | number | boolean> = {}
|
||||
const runId = str(args, 'run_id').trim()
|
||||
if (runId) params.run_id = runId
|
||||
return client.get(`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/board`, params, signal)
|
||||
}
|
||||
|
||||
/** Red tickets with evidence (raw A/B + show command). */
|
||||
export async function listBizMonitorReds(
|
||||
client: NetxClient,
|
||||
args: NetxJson,
|
||||
signal?: AbortSignal,
|
||||
): Promise<NetxJson> {
|
||||
const projectId = str(args, 'project_id').trim()
|
||||
if (!projectId) return { ok: false, error: 'project_id_required' }
|
||||
const params: Record<string, string | number | boolean> = {}
|
||||
putStr(params, args, ['status'])
|
||||
const limit = num(args, 'limit')
|
||||
if (limit !== undefined) params.limit = clampInt(limit, 200, 1, 500)
|
||||
return client.get(
|
||||
`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/red-tickets`,
|
||||
params,
|
||||
signal,
|
||||
)
|
||||
}
|
||||
|
||||
/** Paged evaluate diffs (old_key/new_key = device-raw A/B). */
|
||||
export async function getBizMonitorDiffs(
|
||||
client: NetxClient,
|
||||
args: NetxJson,
|
||||
signal?: AbortSignal,
|
||||
): Promise<NetxJson> {
|
||||
const runId = str(args, 'run_id').trim()
|
||||
if (!runId) return { ok: false, error: 'run_id_required' }
|
||||
const params: Record<string, string | number | boolean> = {}
|
||||
putStr(params, args, ['metric_id', 'sheet_id', 'verdict', 'color', 'q'])
|
||||
const limit = num(args, 'limit')
|
||||
if (limit !== undefined) params.limit = clampInt(limit, 200, 1, 500)
|
||||
const offset = num(args, 'offset')
|
||||
if (offset !== undefined) params.offset = clampInt(offset, 0, 0, Number.MAX_SAFE_INTEGER)
|
||||
return client.get(`/v1/biz-migration/runs/${encodeURIComponent(runId)}/diffs`, params, signal)
|
||||
}
|
||||
|
||||
/** One biz_state collect batch (commands + metrics). */
|
||||
export async function getBizCollectBatch(
|
||||
client: NetxClient,
|
||||
args: NetxJson,
|
||||
signal?: AbortSignal,
|
||||
): Promise<NetxJson> {
|
||||
const batchId = str(args, 'batch_id').trim()
|
||||
if (!batchId) return { ok: false, error: 'batch_id_required' }
|
||||
return client.get(`/v1/biz-state/batches/${encodeURIComponent(batchId)}`, {}, signal)
|
||||
}
|
||||
|
||||
/** Full CLI raw_text for one collect command. */
|
||||
export async function getBizCollectCommandRaw(
|
||||
client: NetxClient,
|
||||
args: NetxJson,
|
||||
signal?: AbortSignal,
|
||||
): Promise<NetxJson> {
|
||||
const batchId = str(args, 'batch_id').trim()
|
||||
const commandId = str(args, 'command_id').trim()
|
||||
if (!batchId || !commandId) {
|
||||
return { ok: false, error: 'batch_id_and_command_id_required' }
|
||||
}
|
||||
return client.get(
|
||||
`/v1/biz-state/batches/${encodeURIComponent(batchId)}/commands/${encodeURIComponent(commandId)}`,
|
||||
{},
|
||||
signal,
|
||||
)
|
||||
}
|
||||
|
|
@ -54,6 +54,8 @@ export const NETXOPS_HOST_CONFIG_DEFAULTS: Readonly<Record<string, string | bool
|
|||
kbRoot: '',
|
||||
groupKbInPreset: true,
|
||||
groupKbPublic: false,
|
||||
disabledTools: '[]',
|
||||
readOnlyOnly: false,
|
||||
})
|
||||
|
||||
export interface HostBridgeHealResult {
|
||||
|
|
|
|||
150
src/netx/http.ts
150
src/netx/http.ts
|
|
@ -1,17 +1,8 @@
|
|||
/**
|
||||
* Minimal netx REST client (Bearer + lang), aligned with netx-mcp http_client.
|
||||
* Shared URL helpers for netx connection settings / alarm push.
|
||||
* Tool calls go through `/mcp` (see mcp-remote.ts); REST handlers were removed.
|
||||
*/
|
||||
|
||||
export interface NetxConnection {
|
||||
apiUrl: string
|
||||
lang: string
|
||||
timeoutMs: number
|
||||
/** Read the latest bearer on each request (do not freeze at client creation). */
|
||||
getToken: () => string
|
||||
/** Read the latest apiUrl on each request (settings may heal after first mount). */
|
||||
getApiUrl?: () => string
|
||||
}
|
||||
|
||||
export type NetxJson = Record<string, unknown>
|
||||
|
||||
export type NormalizeApiUrlResult =
|
||||
|
|
@ -41,140 +32,3 @@ export function normalizeApiUrl(raw: string): NormalizeApiUrlResult {
|
|||
const search = parsed.search && parsed.search !== '?' ? parsed.search : ''
|
||||
return { ok: true, url: `${parsed.origin}${path}${search}` }
|
||||
}
|
||||
|
||||
const PROTOCOL_KEY_ZH_TO_EN: Record<string, string> = {
|
||||
其他: 'Other',
|
||||
时钟: 'Clock',
|
||||
'OTN/光': 'OTN/Optical',
|
||||
电源: 'Power',
|
||||
}
|
||||
|
||||
function asLang(value: unknown): string {
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
/** Localize a few protocol bucket keys when lang starts with en (match netx-mcp). */
|
||||
function localizePayload(lang: string, data: NetxJson): NetxJson {
|
||||
if (!asLang(lang).trim().toLowerCase().startsWith('en')) return data
|
||||
const proto = data.protocol_summary
|
||||
if (!Array.isArray(proto)) return data
|
||||
for (const row of proto) {
|
||||
if (typeof row !== 'object' || row === null || Array.isArray(row)) continue
|
||||
const rec = row as NetxJson
|
||||
const key = typeof rec.key === 'string' ? rec.key : ''
|
||||
const mapped = PROTOCOL_KEY_ZH_TO_EN[key]
|
||||
if (mapped !== undefined) rec.key = mapped
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
function encodeQuery(params: Record<string, string | number | boolean>): string {
|
||||
const sp = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
sp.set(key, String(value))
|
||||
}
|
||||
const q = sp.toString()
|
||||
return q.length > 0 ? `?${q}` : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a client bound to apiUrl / lang / timeout; Bearer + apiUrl are resolved per request.
|
||||
* @param connection - apiUrl / lang / timeout / getToken / optional getApiUrl.
|
||||
* @returns get/post helpers that return `{ ok, data }` or `{ ok: false, error }`.
|
||||
*/
|
||||
export function createNetxClient(connection: NetxConnection) {
|
||||
const langParams = (): Record<string, string> => {
|
||||
const lang = asLang(connection.lang).trim().toLowerCase()
|
||||
if (lang.startsWith('en')) return { lang: 'en' }
|
||||
return {}
|
||||
}
|
||||
|
||||
async function request(
|
||||
method: string,
|
||||
path: string,
|
||||
options: {
|
||||
params?: Record<string, string | number | boolean>
|
||||
body?: NetxJson
|
||||
timeoutMs?: number
|
||||
signal?: AbortSignal
|
||||
} = {},
|
||||
): Promise<NetxJson> {
|
||||
const rawUrl = typeof connection.getApiUrl === 'function'
|
||||
? connection.getApiUrl()
|
||||
: connection.apiUrl
|
||||
const normalized = normalizeApiUrl(typeof rawUrl === 'string' ? rawUrl : '')
|
||||
const base = normalized.ok ? normalized.url : ''
|
||||
if (!normalized.ok || !base) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'netx_api_url_invalid',
|
||||
detail: `apiUrl must be http(s) without embedded credentials (${normalized.ok ? 'empty' : normalized.error}).`,
|
||||
}
|
||||
}
|
||||
const rawToken = connection.getToken()
|
||||
const token = typeof rawToken === 'string' ? rawToken.trim() : ''
|
||||
if (token.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'netx_token_missing',
|
||||
detail: 'Set credential NETX_API_TOKEN (Plugins → Netx Ops or scripts/set-netx-token).',
|
||||
}
|
||||
}
|
||||
|
||||
const merged: Record<string, string | number | boolean> = { ...langParams(), ...options.params }
|
||||
const url = `${base}${path}${encodeQuery(merged)}`
|
||||
const timeoutMs = options.timeoutMs ?? connection.timeoutMs
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => { controller.abort() }, timeoutMs)
|
||||
const onOuterAbort = () => { controller.abort() }
|
||||
options.signal?.addEventListener('abort', onOuterAbort, { once: true })
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
accept: 'application/json',
|
||||
authorization: `Bearer ${token}`,
|
||||
}
|
||||
if (options.body !== undefined) headers['content-type'] = 'application/json'
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
}
|
||||
if (options.body !== undefined) init.body = JSON.stringify(options.body)
|
||||
const resp = await fetch(url, init)
|
||||
const text = await resp.text()
|
||||
if (!resp.ok) {
|
||||
return { ok: false, error: `netx_http_${resp.status}`, detail: text.slice(0, 800) }
|
||||
}
|
||||
const data = text.length > 0 ? JSON.parse(text) as unknown : {}
|
||||
if (typeof data === 'object' && data !== null && !Array.isArray(data)) {
|
||||
return { ok: true, data: localizePayload(connection.lang, data as NetxJson) }
|
||||
}
|
||||
return { ok: true, data: { raw: data } }
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error)
|
||||
return { ok: false, error: 'netx_request_failed', detail: detail.slice(0, 800) }
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
options.signal?.removeEventListener('abort', onOuterAbort)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get(path: string, params?: Record<string, string | number | boolean>, signal?: AbortSignal, timeoutMs?: number) {
|
||||
return request('GET', path, { params, signal, timeoutMs })
|
||||
},
|
||||
post(path: string, body: NetxJson, signal?: AbortSignal, timeoutMs?: number) {
|
||||
return request('POST', path, { body, signal, timeoutMs })
|
||||
},
|
||||
patch(path: string, body: NetxJson, signal?: AbortSignal, timeoutMs?: number) {
|
||||
return request('PATCH', path, { body, signal, timeoutMs })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type NetxClient = ReturnType<typeof createNetxClient>
|
||||
|
||||
/** Path-segment encode for NE ids (match urllib.parse.quote(..., safe='')). */
|
||||
export function quoteNeId(neId: string): string {
|
||||
return encodeURIComponent(neId.trim())
|
||||
}
|
||||
|
|
|
|||
138
src/netx/mcp-catalog.ts
Normal file
138
src/netx/mcp-catalog.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
/**
|
||||
* Filter remote MCP tools for DSH registration.
|
||||
*
|
||||
* Security note: these filters only hide tools from the model. Real authz is
|
||||
* enforced by netx `/mcp` (token RBAC + CLI guards).
|
||||
*/
|
||||
|
||||
import type { NetxCapabilityGroupId, NetxCapabilityGroups } from './capability-groups.ts'
|
||||
import { groupsForPlane, groupsForPreset } from './capability-groups.ts'
|
||||
import type { NetxMcpToolDescriptor } from './mcp-types.ts'
|
||||
import { publicToolName } from './mcp-types.ts'
|
||||
|
||||
export interface ToolFilterPolicy {
|
||||
toolsEnabled?: boolean
|
||||
groups?: NetxCapabilityGroups
|
||||
/** Server tool names the operator turned off (opt-out). */
|
||||
disabledTools?: readonly string[]
|
||||
/** When true, drop tools whose readOnlyHint is not true. */
|
||||
readOnlyOnly?: boolean
|
||||
}
|
||||
|
||||
export interface ResolveEnabledToolsOptions extends ToolFilterPolicy {
|
||||
plane: 'preset' | 'public' | 'host'
|
||||
forceGroups?: readonly NetxCapabilityGroupId[]
|
||||
only?: readonly NetxCapabilityGroupId[]
|
||||
/** When plane is preset, which preset id is mounting. */
|
||||
presetId?: string
|
||||
}
|
||||
|
||||
function disabledSet(disabledTools: readonly string[] | undefined): Set<string> {
|
||||
const out = new Set<string>()
|
||||
for (const name of disabledTools ?? []) {
|
||||
const trimmed = typeof name === 'string' ? name.trim() : ''
|
||||
if (trimmed) out.add(trimmed)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse settings `disabledTools` (JSON array string or comma list) into server names.
|
||||
*/
|
||||
export function parseDisabledTools(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return [...new Set(value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0).map(s => s.trim()))]
|
||||
}
|
||||
if (typeof value !== 'string') return []
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return []
|
||||
if (trimmed.startsWith('[')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as unknown
|
||||
if (Array.isArray(parsed)) {
|
||||
return [...new Set(parsed.filter((item): item is string => typeof item === 'string' && item.trim().length > 0).map(s => s.trim()))]
|
||||
}
|
||||
} catch {
|
||||
// fall through to CSV
|
||||
}
|
||||
}
|
||||
return [...new Set(trimmed.split(/[\s,,]+/).filter(Boolean))]
|
||||
}
|
||||
|
||||
export function serializeDisabledTools(names: readonly string[]): string {
|
||||
return JSON.stringify([...new Set(names.map(n => n.trim()).filter(Boolean))].sort())
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply group + disabledTools + readOnlyOnly on top of a catalog snapshot.
|
||||
* Tools without a known `_meta.netx.group` are skipped.
|
||||
*/
|
||||
export function filterCatalogTools(
|
||||
catalog: readonly NetxMcpToolDescriptor[],
|
||||
enabledGroups: readonly NetxCapabilityGroupId[],
|
||||
policy: Pick<ToolFilterPolicy, 'disabledTools' | 'readOnlyOnly'>,
|
||||
): NetxMcpToolDescriptor[] {
|
||||
const allowGroups = new Set(enabledGroups)
|
||||
const disabled = disabledSet(policy.disabledTools)
|
||||
const readOnlyOnly = policy.readOnlyOnly === true
|
||||
const out: NetxMcpToolDescriptor[] = []
|
||||
for (const tool of catalog) {
|
||||
if (!tool.group || !allowGroups.has(tool.group)) continue
|
||||
if (disabled.has(tool.name)) continue
|
||||
if (readOnlyOnly && tool.readOnlyHint !== true) continue
|
||||
out.push(tool)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Resolve which tools a mount plane should register from the live catalog. */
|
||||
export function resolveEnabledTools(
|
||||
catalog: readonly NetxMcpToolDescriptor[],
|
||||
options: ResolveEnabledToolsOptions,
|
||||
): NetxMcpToolDescriptor[] {
|
||||
if (options.toolsEnabled === false) return []
|
||||
const enabledGroups = options.forceGroups
|
||||
?? (options.plane === 'preset'
|
||||
? groupsForPreset(
|
||||
{ toolsEnabled: options.toolsEnabled, groups: options.groups },
|
||||
options.presetId ?? 'netxops',
|
||||
options.only,
|
||||
)
|
||||
: groupsForPlane(options.groups, options.plane, options.only))
|
||||
return filterCatalogTools(catalog, enabledGroups, options)
|
||||
}
|
||||
|
||||
/** Disabled server names that fall inside the enabled groups (for skill footnotes). */
|
||||
export function disabledToolsInGroups(
|
||||
catalog: readonly NetxMcpToolDescriptor[],
|
||||
enabledGroups: readonly NetxCapabilityGroupId[],
|
||||
disabledTools: readonly string[] | undefined,
|
||||
): string[] {
|
||||
const allowGroups = new Set(enabledGroups)
|
||||
const disabled = disabledSet(disabledTools)
|
||||
const out: string[] = []
|
||||
for (const tool of catalog) {
|
||||
if (!tool.group || !allowGroups.has(tool.group)) continue
|
||||
if (!disabled.has(tool.name)) continue
|
||||
out.push(tool.name)
|
||||
}
|
||||
return out.sort()
|
||||
}
|
||||
|
||||
/** Model-facing names for skill footnotes. */
|
||||
export function disabledPublicNames(
|
||||
catalog: readonly NetxMcpToolDescriptor[],
|
||||
enabledGroups: readonly NetxCapabilityGroupId[],
|
||||
disabledTools: readonly string[] | undefined,
|
||||
readOnlyOnly?: boolean,
|
||||
): string[] {
|
||||
const allowGroups = new Set(enabledGroups)
|
||||
const disabled = disabledSet(disabledTools)
|
||||
const names: string[] = []
|
||||
for (const tool of catalog) {
|
||||
if (!tool.group || !allowGroups.has(tool.group)) continue
|
||||
const off = disabled.has(tool.name) || (readOnlyOnly === true && tool.readOnlyHint !== true)
|
||||
if (off) names.push(publicToolName(tool.name))
|
||||
}
|
||||
return names.sort()
|
||||
}
|
||||
258
src/netx/mcp-remote.ts
Normal file
258
src/netx/mcp-remote.ts
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
/**
|
||||
* Stateless Streamable HTTP client for netx `/mcp`.
|
||||
*
|
||||
* Uses the runtime's standard `fetch` (injectable via `fetchImpl` for tests).
|
||||
* Request/response shapes follow MCP Streamable HTTP JSON-RPC. Prefer this thin
|
||||
* client over wiring `@modelcontextprotocol/sdk` / DSH `dsh-mcp-client` here:
|
||||
* those pull Cordis peers or would require a new package dependency.
|
||||
*/
|
||||
|
||||
import type { NetxCapabilityGroupId } from './capability-groups.ts'
|
||||
import { CAPABILITY_GROUP_IDS } from './capability-groups.ts'
|
||||
import type { NetxMcpToolDescriptor } from './mcp-types.ts'
|
||||
|
||||
const PROTOCOL_VERSION = '2025-03-26'
|
||||
const CLIENT_INFO = { name: 'dsh-netxops', version: '0.1.71' }
|
||||
|
||||
export interface McpRemoteOptions {
|
||||
/** Absolute MCP endpoint, e.g. `http://127.0.0.1:8890/mcp`. */
|
||||
mcpUrl: string
|
||||
/** Bearer token (may be empty — server returns 401). */
|
||||
token: string
|
||||
/** Optional Accept-Language / lang hint for bilingual descriptions. */
|
||||
lang?: string
|
||||
timeoutMs?: number
|
||||
signal?: AbortSignal
|
||||
/** Optional override for tests; defaults to `globalThis.fetch`. */
|
||||
fetchImpl?: typeof fetch
|
||||
}
|
||||
|
||||
export interface McpCallResult {
|
||||
content: unknown[]
|
||||
structuredContent?: unknown
|
||||
isError?: boolean
|
||||
}
|
||||
|
||||
type JsonRpcSuccess = { jsonrpc: '2.0'; id: number | string; result: unknown }
|
||||
|
||||
export class McpRemoteError extends Error {
|
||||
readonly status?: number
|
||||
constructor(message: string, status?: number) {
|
||||
super(message)
|
||||
this.name = 'McpRemoteError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
/** Build `<apiUrl>/mcp` (apiUrl may already include a path prefix). */
|
||||
export function mcpEndpointUrl(apiUrl: string): string {
|
||||
const trimmed = apiUrl.trim().replace(/\/$/, '')
|
||||
if (!trimmed) return ''
|
||||
return `${trimmed}/mcp`
|
||||
}
|
||||
|
||||
function isGroupId(value: unknown): value is NetxCapabilityGroupId {
|
||||
return typeof value === 'string' && (CAPABILITY_GROUP_IDS as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
function parseTool(raw: unknown): NetxMcpToolDescriptor | null {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
|
||||
const row = raw as Record<string, unknown>
|
||||
const name = typeof row.name === 'string' ? row.name.trim() : ''
|
||||
if (!name) return null
|
||||
const description = typeof row.description === 'string' ? row.description : ''
|
||||
const inputSchema = row.inputSchema && typeof row.inputSchema === 'object' && !Array.isArray(row.inputSchema)
|
||||
? row.inputSchema as Record<string, unknown>
|
||||
: { type: 'object', properties: {} }
|
||||
const annotations = row.annotations && typeof row.annotations === 'object' && !Array.isArray(row.annotations)
|
||||
? row.annotations as Record<string, unknown>
|
||||
: {}
|
||||
const readOnlyHint = annotations.readOnlyHint === true
|
||||
const meta = row._meta && typeof row._meta === 'object' && !Array.isArray(row._meta)
|
||||
? row._meta as Record<string, unknown>
|
||||
: {}
|
||||
const netx = meta.netx && typeof meta.netx === 'object' && !Array.isArray(meta.netx)
|
||||
? meta.netx as Record<string, unknown>
|
||||
: {}
|
||||
const group = isGroupId(netx.group) ? netx.group : undefined
|
||||
return { name, description, inputSchema, readOnlyHint, group }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an MCP HTTP body: application/json, or SSE `data:` frames
|
||||
* (Streamable HTTP when not using json_response).
|
||||
*/
|
||||
export function parseMcpResponseBody(raw: string): unknown {
|
||||
const text = raw.replace(/^\uFEFF/, '').trim()
|
||||
if (!text) throw new McpRemoteError('empty /mcp response body')
|
||||
|
||||
if (text.startsWith('{') || text.startsWith('[')) {
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch (error) {
|
||||
throw new McpRemoteError(
|
||||
`mcp JSON parse failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (text.includes('data:')) {
|
||||
const dataLines: string[] = []
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
if (line.startsWith('data:')) dataLines.push(line.slice(5).trim())
|
||||
}
|
||||
for (let i = dataLines.length - 1; i >= 0; i -= 1) {
|
||||
try { return JSON.parse(dataLines[i]!) } catch { /* continue */ }
|
||||
}
|
||||
}
|
||||
|
||||
throw new McpRemoteError('mcp response was not JSON')
|
||||
}
|
||||
|
||||
function asRpcResult(payload: unknown): unknown {
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
throw new McpRemoteError('invalid JSON-RPC envelope from /mcp')
|
||||
}
|
||||
const row = payload as Record<string, unknown>
|
||||
if (row.error && typeof row.error === 'object' && !Array.isArray(row.error)) {
|
||||
const err = row.error as { code?: unknown; message?: unknown }
|
||||
const message = typeof err.message === 'string' ? err.message : 'JSON-RPC error'
|
||||
throw new McpRemoteError(message)
|
||||
}
|
||||
if (!('result' in row)) {
|
||||
throw new McpRemoteError('JSON-RPC response missing result')
|
||||
}
|
||||
return (payload as JsonRpcSuccess).result
|
||||
}
|
||||
|
||||
let nextId = 1
|
||||
|
||||
async function postRpc(
|
||||
options: McpRemoteOptions,
|
||||
method: string,
|
||||
params?: Record<string, unknown>,
|
||||
notification = false,
|
||||
): Promise<unknown> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, text/event-stream',
|
||||
}
|
||||
const token = options.token.trim()
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
const lang = options.lang?.trim()
|
||||
if (lang) {
|
||||
headers['Accept-Language'] = lang.toLowerCase().startsWith('en') ? 'en' : 'zh'
|
||||
}
|
||||
|
||||
const body = notification
|
||||
? { jsonrpc: '2.0', method, ...(params ? { params } : {}) }
|
||||
: { jsonrpc: '2.0', id: nextId++, method, ...(params ? { params } : {}) }
|
||||
const bodyText = JSON.stringify(body)
|
||||
|
||||
const timeoutMs = typeof options.timeoutMs === 'number' && options.timeoutMs > 0
|
||||
? options.timeoutMs
|
||||
: 60_000
|
||||
|
||||
const fetchImpl = options.fetchImpl ?? globalThis.fetch
|
||||
if (typeof fetchImpl !== 'function') {
|
||||
throw new McpRemoteError('fetch is not available')
|
||||
}
|
||||
|
||||
const abort = new AbortController()
|
||||
const timer = setTimeout(() => abort.abort(), timeoutMs)
|
||||
const onOuterAbort = (): void => { abort.abort() }
|
||||
options.signal?.addEventListener('abort', onOuterAbort, { once: true })
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetchImpl(options.mcpUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: bodyText,
|
||||
signal: abort.signal,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new McpRemoteError('mcp http timeout')
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
options.signal?.removeEventListener('abort', onOuterAbort)
|
||||
}
|
||||
|
||||
const responseText = await response.text()
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new McpRemoteError(`HTTP ${response.status} unauthorized`, response.status)
|
||||
}
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
throw new McpRemoteError(
|
||||
`HTTP ${response.status}${responseText ? `: ${responseText.slice(0, 200)}` : ''}`,
|
||||
response.status,
|
||||
)
|
||||
}
|
||||
if (notification) {
|
||||
return undefined
|
||||
}
|
||||
return asRpcResult(parseMcpResponseBody(responseText))
|
||||
}
|
||||
|
||||
/**
|
||||
* initialize → notifications/initialized → tools/list.
|
||||
*/
|
||||
export async function listMcpTools(options: McpRemoteOptions): Promise<NetxMcpToolDescriptor[]> {
|
||||
if (!options.mcpUrl) throw new McpRemoteError('mcp url is empty')
|
||||
await postRpc(options, 'initialize', {
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
capabilities: {},
|
||||
clientInfo: CLIENT_INFO,
|
||||
})
|
||||
await postRpc(options, 'notifications/initialized', {}, true)
|
||||
const result = await postRpc(options, 'tools/list', {}) as { tools?: unknown }
|
||||
const tools = Array.isArray(result?.tools) ? result.tools : []
|
||||
const out: NetxMcpToolDescriptor[] = []
|
||||
for (const item of tools) {
|
||||
const parsed = parseTool(item)
|
||||
if (parsed) out.push(parsed)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward tools/call. Stateless: initialize once per call sequence so servers
|
||||
* that reject orphaned calls still work.
|
||||
*/
|
||||
export async function callMcpTool(
|
||||
options: McpRemoteOptions,
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<McpCallResult> {
|
||||
if (!options.mcpUrl) throw new McpRemoteError('mcp url is empty')
|
||||
await postRpc(options, 'initialize', {
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
capabilities: {},
|
||||
clientInfo: CLIENT_INFO,
|
||||
})
|
||||
await postRpc(options, 'notifications/initialized', {}, true)
|
||||
const result = await postRpc(options, 'tools/call', {
|
||||
name,
|
||||
arguments: args,
|
||||
}) as Record<string, unknown>
|
||||
const content = Array.isArray(result.content) ? result.content : []
|
||||
return {
|
||||
content,
|
||||
...(result.structuredContent !== undefined ? { structuredContent: result.structuredContent } : {}),
|
||||
...(result.isError === true ? { isError: true } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract plain text from MCP content blocks for error / render paths. */
|
||||
export function mcpContentText(content: unknown[], fallback = ''): string {
|
||||
const parts: string[] = []
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== 'object' || Array.isArray(block)) continue
|
||||
const row = block as { type?: unknown; text?: unknown }
|
||||
if (row.type === 'text' && typeof row.text === 'string') parts.push(row.text)
|
||||
}
|
||||
return parts.join('\n') || fallback
|
||||
}
|
||||
149
src/netx/mcp-store.ts
Normal file
149
src/netx/mcp-store.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
/**
|
||||
* Process-local MCP catalog cache shared by host + preset tool mounts.
|
||||
*
|
||||
* Uses Symbol.for on globalThis so host / agent-tools bundles share one store.
|
||||
*/
|
||||
|
||||
import { listMcpTools, mcpEndpointUrl, McpRemoteError } from './mcp-remote.ts'
|
||||
import type { NetxMcpCatalogState, NetxMcpToolDescriptor } from './mcp-types.ts'
|
||||
|
||||
type Listener = () => void
|
||||
|
||||
interface Store {
|
||||
state: NetxMcpCatalogState
|
||||
listeners: Set<Listener>
|
||||
/** Generation counter so stale refreshes do not clobber newer ones. */
|
||||
generation: number
|
||||
}
|
||||
|
||||
const STORE_KEY = Symbol.for('dsh-netxops.mcp-catalog-store')
|
||||
|
||||
const EMPTY: NetxMcpCatalogState = Object.freeze({
|
||||
phase: 'idle',
|
||||
mcpUrl: '',
|
||||
tools: Object.freeze([]),
|
||||
error: '',
|
||||
listedAt: null,
|
||||
fromCache: false,
|
||||
})
|
||||
|
||||
function store(): Store {
|
||||
const root = globalThis as typeof globalThis & { [STORE_KEY]?: Store }
|
||||
let current = root[STORE_KEY]
|
||||
if (current === undefined) {
|
||||
current = { state: EMPTY, listeners: new Set(), generation: 0 }
|
||||
root[STORE_KEY] = current
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
function notify(): void {
|
||||
for (const listener of [...store().listeners]) {
|
||||
try { listener() } catch (error) { console.error('netxops: mcp catalog subscriber failed', error) }
|
||||
}
|
||||
}
|
||||
|
||||
function publish(next: NetxMcpCatalogState): void {
|
||||
store().state = next
|
||||
notify()
|
||||
}
|
||||
|
||||
export function getMcpCatalogState(): NetxMcpCatalogState {
|
||||
return store().state
|
||||
}
|
||||
|
||||
export function getMcpCatalogTools(): readonly NetxMcpToolDescriptor[] {
|
||||
return store().state.tools
|
||||
}
|
||||
|
||||
export function watchMcpCatalog(listener: Listener): () => void {
|
||||
const state = store()
|
||||
state.listeners.add(listener)
|
||||
return () => { state.listeners.delete(listener) }
|
||||
}
|
||||
|
||||
export function resetMcpCatalog(): void {
|
||||
store().generation += 1
|
||||
publish(EMPTY)
|
||||
}
|
||||
|
||||
export interface RefreshMcpCatalogInput {
|
||||
apiUrl: string
|
||||
token: string
|
||||
lang?: string
|
||||
timeoutMs?: number
|
||||
signal?: AbortSignal
|
||||
fetchImpl?: typeof fetch
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull tools/list. On failure keep the previous tool list (fromCache) and
|
||||
* surface the error for the Plugins card — never throw to the Host apply path.
|
||||
*/
|
||||
export async function refreshMcpCatalog(input: RefreshMcpCatalogInput): Promise<NetxMcpCatalogState> {
|
||||
const mcpUrl = mcpEndpointUrl(input.apiUrl)
|
||||
const state = store()
|
||||
const gen = ++state.generation
|
||||
const previous = state.state
|
||||
|
||||
if (!mcpUrl) {
|
||||
const next: NetxMcpCatalogState = {
|
||||
phase: 'error',
|
||||
mcpUrl: '',
|
||||
tools: previous.tools,
|
||||
error: 'apiUrl empty',
|
||||
listedAt: previous.listedAt,
|
||||
fromCache: previous.tools.length > 0,
|
||||
}
|
||||
if (gen === store().generation) publish(next)
|
||||
return next
|
||||
}
|
||||
|
||||
publish({
|
||||
phase: 'connecting',
|
||||
mcpUrl,
|
||||
tools: previous.tools,
|
||||
error: '',
|
||||
listedAt: previous.listedAt,
|
||||
fromCache: previous.tools.length > 0,
|
||||
})
|
||||
|
||||
try {
|
||||
const tools = await listMcpTools({
|
||||
mcpUrl,
|
||||
token: input.token,
|
||||
lang: input.lang,
|
||||
timeoutMs: input.timeoutMs,
|
||||
signal: input.signal,
|
||||
fetchImpl: input.fetchImpl,
|
||||
})
|
||||
if (gen !== store().generation) return store().state
|
||||
const next: NetxMcpCatalogState = {
|
||||
phase: 'connected',
|
||||
mcpUrl,
|
||||
tools: Object.freeze([...tools]),
|
||||
error: '',
|
||||
listedAt: new Date().toISOString(),
|
||||
fromCache: false,
|
||||
}
|
||||
publish(next)
|
||||
return next
|
||||
} catch (error) {
|
||||
if (gen !== store().generation) return store().state
|
||||
const message = error instanceof McpRemoteError
|
||||
? error.message
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: String(error)
|
||||
const next: NetxMcpCatalogState = {
|
||||
phase: previous.tools.length > 0 ? 'offline' : 'error',
|
||||
mcpUrl,
|
||||
tools: previous.tools,
|
||||
error: message,
|
||||
listedAt: previous.listedAt,
|
||||
fromCache: previous.tools.length > 0,
|
||||
}
|
||||
publish(next)
|
||||
return next
|
||||
}
|
||||
}
|
||||
50
src/netx/mcp-types.ts
Normal file
50
src/netx/mcp-types.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* Remote netx `/mcp` tool descriptors.
|
||||
*
|
||||
* Plugin switches (groups / disabledTools / readOnlyOnly) only control what the
|
||||
* model sees — they are **not** a security boundary. Authorization and CLI
|
||||
* guards live on the netx server.
|
||||
*/
|
||||
|
||||
import type { NetxCapabilityGroupId } from './capability-groups.ts'
|
||||
|
||||
/** Model-facing prefix kept stable across the REST→MCP migration. */
|
||||
export const NETX_TOOL_PREFIX = 'netx__'
|
||||
|
||||
/** One tool from `tools/list` (server name has no prefix). */
|
||||
export interface NetxMcpToolDescriptor {
|
||||
/** Server tool name, e.g. `queryNmsAlarms`. */
|
||||
name: string
|
||||
description: string
|
||||
inputSchema: Record<string, unknown>
|
||||
/** From `annotations.readOnlyHint`; missing → treat as writable. */
|
||||
readOnlyHint: boolean
|
||||
/** From `_meta.netx.group`; unknown → excluded from registration. */
|
||||
group: NetxCapabilityGroupId | undefined
|
||||
}
|
||||
|
||||
export type NetxMcpConnectionPhase = 'idle' | 'connecting' | 'connected' | 'error' | 'offline'
|
||||
|
||||
/** Cached catalog + last connect outcome for the Plugins card. */
|
||||
export interface NetxMcpCatalogState {
|
||||
phase: NetxMcpConnectionPhase
|
||||
/** Absolute `/mcp` URL last used for a successful or failed refresh. */
|
||||
mcpUrl: string
|
||||
tools: readonly NetxMcpToolDescriptor[]
|
||||
/** Human-readable connect / list failure; empty when ok. */
|
||||
error: string
|
||||
/** ISO timestamp of the last successful tools/list (cache may be stale when offline). */
|
||||
listedAt: string | null
|
||||
/** True when the in-memory list came from a previous successful fetch while currently offline. */
|
||||
fromCache: boolean
|
||||
}
|
||||
|
||||
export function publicToolName(serverName: string): string {
|
||||
return `${NETX_TOOL_PREFIX}${serverName}`
|
||||
}
|
||||
|
||||
export function serverToolName(publicName: string): string {
|
||||
return publicName.startsWith(NETX_TOOL_PREFIX)
|
||||
? publicName.slice(NETX_TOOL_PREFIX.length)
|
||||
: publicName
|
||||
}
|
||||
|
|
@ -64,6 +64,8 @@ export function provisionalNetxConnection(): NetxToolConnection {
|
|||
: 'zh'),
|
||||
toolCallTimeoutMs: 120_000,
|
||||
groups: DEFAULT_CAPABILITY_GROUPS,
|
||||
disabledTools: [],
|
||||
readOnlyOnly: false,
|
||||
groupKbInPreset: true,
|
||||
groupKbPublic: false,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
/**
|
||||
* Register native DSH tools (`netx__*`) that call netx REST.
|
||||
* Register native DSH tools (`netx__*`) by forwarding to netx `/mcp`.
|
||||
*
|
||||
* Tool visibility (groups / disabledTools / readOnlyOnly) is an operator UX
|
||||
* control only — not a security boundary. Server RBAC still applies on every
|
||||
* tools/call.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
|
@ -7,18 +11,13 @@ import { dirname, join } from 'node:path'
|
|||
import { fileURLToPath } from 'node:url'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import {
|
||||
groupsForPlane,
|
||||
toolNamesForGroups,
|
||||
type NetxCapabilityGroupId,
|
||||
type NetxCapabilityGroups,
|
||||
} from './capability-groups.ts'
|
||||
import { createNetxClient, type NetxClient, type NetxJson } from './http.ts'
|
||||
import { toLosslessJson } from './json-safe.ts'
|
||||
import type { NetxCapabilityGroupId, NetxCapabilityGroups } from './capability-groups.ts'
|
||||
import { resolveEnabledTools } from './mcp-catalog.ts'
|
||||
import { callMcpTool, mcpContentText, mcpEndpointUrl } from './mcp-remote.ts'
|
||||
import { getMcpCatalogTools } from './mcp-store.ts'
|
||||
import { publicToolName, type NetxMcpToolDescriptor } from './mcp-types.ts'
|
||||
import { getNetxConnection } from './runtime.ts'
|
||||
import { getToolMountId, publishToolMountStatus, removeToolMountStatus } from './tool-mount-status.ts'
|
||||
import * as H from './handlers.ts'
|
||||
import * as T from './topology-handlers.ts'
|
||||
|
||||
function packageVersion(): string {
|
||||
try {
|
||||
|
|
@ -39,6 +38,13 @@ export interface NetxToolConnection {
|
|||
toolCallTimeoutMs: number
|
||||
/** Capability-group exposure; omitted → package defaults. */
|
||||
groups?: NetxCapabilityGroups
|
||||
/**
|
||||
* Server tool names turned off by the operator (opt-out). New server tools
|
||||
* follow the group switch unless listed here.
|
||||
*/
|
||||
disabledTools?: readonly string[]
|
||||
/** When true, only register tools with readOnlyHint === true. */
|
||||
readOnlyOnly?: boolean
|
||||
/**
|
||||
* Mount operator-subset `_skills/kb-*` into the Netx Ops preset (default true).
|
||||
* Gated also by KB configured + content.hasSkills.
|
||||
|
|
@ -48,68 +54,86 @@ export interface NetxToolConnection {
|
|||
groupKbPublic?: boolean
|
||||
}
|
||||
|
||||
type Handler = (client: NetxClient, args: NetxJson, signal?: AbortSignal) => Promise<NetxJson>
|
||||
const MUTATING_TOOLS = new Set([
|
||||
'netx__execManagedNe', 'netx__createTopologyFolder', 'netx__addTopologyViewNodes',
|
||||
'netx__removeTopologyViewNodes', 'netx__copyTopologyViewNodes', 'netx__updateTopologyViewPositions',
|
||||
'netx__projectTopologyNeighbors', 'netx__classifyTopologyFabricNodes', 'netx__layoutTopologyView',
|
||||
])
|
||||
function renderMcpResult(_args: unknown, value: unknown) {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
const row = value as { text?: unknown; content?: unknown[] }
|
||||
if (typeof row.text === 'string' && row.text.length > 0) {
|
||||
return [{ type: 'text' as const, text: row.text }]
|
||||
}
|
||||
if (Array.isArray(row.content)) {
|
||||
return [{ type: 'text' as const, text: mcpContentText(row.content, JSON.stringify(value)) }]
|
||||
}
|
||||
}
|
||||
return [{ type: 'text' as const, text: typeof value === 'string' ? value : JSON.stringify(value ?? null) }]
|
||||
}
|
||||
|
||||
const str = (description?: string) => ({ type: 'string' as const, ...(description ? { description } : {}) })
|
||||
const num = (description?: string) => ({ type: 'number' as const, ...(description ? { description } : {}) })
|
||||
const bool = (description?: string) => ({ type: 'boolean' as const, ...(description ? { description } : {}) })
|
||||
const strArr = (description?: string) => ({
|
||||
type: 'array' as const,
|
||||
items: { type: 'string' as const },
|
||||
...(description ? { description } : {}),
|
||||
})
|
||||
/** Free-form JSON object — DSH requires an explicit additionalProperties boolean. */
|
||||
const anyObj = (description?: string) => ({
|
||||
type: 'object' as const,
|
||||
additionalProperties: true as const,
|
||||
...(description ? { description } : {}),
|
||||
})
|
||||
|
||||
function renderJson(_args: unknown, value: unknown) {
|
||||
return [{ type: 'text' as const, text: JSON.stringify(value, null, 0) }]
|
||||
/** Free-form object output — same wire shape as the former REST tools. */
|
||||
const mcpOut = {
|
||||
schema: { type: 'object' as const, additionalProperties: true as const },
|
||||
render: renderMcpResult,
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire-compatible free-form object output.
|
||||
* Prefer this over author-only `{ type: 'json' }`: Desktop Host ToolRuntime
|
||||
* often comes from app.asar while `defineTool` resolves from the linked
|
||||
* checkout — mismatched copies can reject `json` at register time, which
|
||||
* leaves skills mounted and `netx__*` absent.
|
||||
* `defineTool` expects a property map (DSH ParameterSchemaSpec), not a full
|
||||
* JSON Schema document. MCP `inputSchema` is usually `{ type:'object', properties }`.
|
||||
*/
|
||||
const jsonOut = {
|
||||
schema: { type: 'object' as const, additionalProperties: true as const },
|
||||
render: renderJson,
|
||||
function parametersFromInputSchema(schema: Record<string, unknown>): Record<string, unknown> {
|
||||
if (schema && typeof schema === 'object' && !Array.isArray(schema)) {
|
||||
const props = schema.properties
|
||||
if (props && typeof props === 'object' && !Array.isArray(props)) {
|
||||
return props as Record<string, unknown>
|
||||
}
|
||||
// Already a property map (legacy / odd servers).
|
||||
if (schema.type === undefined && schema.properties === undefined) {
|
||||
return schema
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
function tool(
|
||||
name: string,
|
||||
description: string,
|
||||
parameters: Record<string, unknown>,
|
||||
handler: Handler,
|
||||
getClient: () => NetxClient,
|
||||
function defineRemoteTool(
|
||||
tool: NetxMcpToolDescriptor,
|
||||
timeoutMs: number,
|
||||
) {
|
||||
): ReturnType<typeof defineTool> {
|
||||
const name = publicToolName(tool.name)
|
||||
return defineTool({
|
||||
name,
|
||||
description,
|
||||
parameters: parameters as never,
|
||||
output: jsonOut,
|
||||
description: tool.description || name,
|
||||
parameters: parametersFromInputSchema(tool.inputSchema) as never,
|
||||
output: mcpOut,
|
||||
timeoutMs,
|
||||
isConcurrencySafe: () => !MUTATING_TOOLS.has(name),
|
||||
isConcurrencySafe: () => tool.readOnlyHint === true,
|
||||
async execute(args, exec) {
|
||||
const result = await handler(getClient(), args as NetxJson, exec.signal)
|
||||
// DSH rejects tool results that contain `undefined` property holes
|
||||
// ("value is not lossless JSON"). Round-trip strips them.
|
||||
const safe = toLosslessJson(result)
|
||||
if (result.ok === false || safe.ok === false) {
|
||||
throw new Error(JSON.stringify(safe))
|
||||
const live = getNetxConnection()
|
||||
const apiUrl = live?.apiUrl ?? ''
|
||||
const token = live?.token ?? ''
|
||||
const lang = live?.lang ?? 'zh'
|
||||
const mcpUrl = mcpEndpointUrl(apiUrl)
|
||||
if (!mcpUrl) throw new Error('netx mcp: apiUrl empty')
|
||||
const result = await callMcpTool(
|
||||
{
|
||||
mcpUrl,
|
||||
token,
|
||||
lang,
|
||||
timeoutMs,
|
||||
signal: exec.signal,
|
||||
},
|
||||
tool.name,
|
||||
(typeof args === 'object' && args !== null ? args : {}) as Record<string, unknown>,
|
||||
)
|
||||
const text = mcpContentText(result.content, '')
|
||||
// Pass business failures through as ToolRuntime errors (model sees isError).
|
||||
if (result.isError === true) {
|
||||
throw new Error(text || `Tool "${tool.name}" failed`)
|
||||
}
|
||||
return safe
|
||||
// Round-trip keeps DSH tool results lossless JSON-compatible.
|
||||
return JSON.parse(JSON.stringify({
|
||||
content: result.content,
|
||||
...(result.structuredContent !== undefined
|
||||
? { structuredContent: result.structuredContent }
|
||||
: {}),
|
||||
...(text ? { text } : {}),
|
||||
})) as Record<string, never>
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -126,14 +150,11 @@ export interface RegisterNetxToolsOptions {
|
|||
/** When set, register exactly these groups (ignore plane flags). */
|
||||
forceGroups?: readonly NetxCapabilityGroupId[]
|
||||
source?: string
|
||||
presetId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Register Netx Ops tools for one exposure plane against the connection snapshot.
|
||||
* Bearer is read from the live process store on each request (not frozen here).
|
||||
* @param ctx - context with `tools`.
|
||||
* @param connection - apiUrl / token / lang / timeout / groups.
|
||||
* @param options - which plane's group flags to honor.
|
||||
* Register Netx Ops tools for one exposure plane from the cached MCP catalog.
|
||||
* @returns disposer that unregisters every tool.
|
||||
*/
|
||||
export function registerNetxTools(
|
||||
|
|
@ -141,16 +162,25 @@ export function registerNetxTools(
|
|||
connection: NetxToolConnection,
|
||||
options: RegisterNetxToolsOptions,
|
||||
): () => void {
|
||||
const enabledGroups = connection.toolsEnabled === false ? [] : (options.forceGroups
|
||||
?? groupsForPlane(connection.groups, options.plane, options.only))
|
||||
const id = getToolMountId(ctx, options.source ?? options.plane)
|
||||
const allow = toolNamesForGroups(enabledGroups)
|
||||
if (allow.size === 0) {
|
||||
const enabled = resolveEnabledTools(getMcpCatalogTools(), {
|
||||
toolsEnabled: connection.toolsEnabled,
|
||||
groups: connection.groups,
|
||||
disabledTools: connection.disabledTools,
|
||||
readOnlyOnly: connection.readOnlyOnly,
|
||||
plane: options.plane,
|
||||
forceGroups: options.forceGroups,
|
||||
only: options.only,
|
||||
presetId: options.presetId,
|
||||
})
|
||||
const groups = [...new Set(enabled.map(tool => tool.group).filter(Boolean))] as NetxCapabilityGroupId[]
|
||||
|
||||
if (enabled.length === 0) {
|
||||
publishToolMountStatus({
|
||||
id,
|
||||
version: packageVersion(),
|
||||
plane: options.plane,
|
||||
groups: [...enabledGroups],
|
||||
groups,
|
||||
names: [],
|
||||
count: 0,
|
||||
at: new Date().toISOString(),
|
||||
|
|
@ -159,597 +189,46 @@ 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))
|
||||
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.splice(0)) dispose()
|
||||
publishToolMountStatus({
|
||||
id,
|
||||
version: packageVersion(),
|
||||
plane: options.plane,
|
||||
groups: [...enabledGroups],
|
||||
groups,
|
||||
names: [],
|
||||
count: 0,
|
||||
at: new Date().toISOString(),
|
||||
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,
|
||||
})
|
||||
} catch (error) {
|
||||
for (const dispose of disposers.reverse()) dispose()
|
||||
publishToolMountStatus({
|
||||
id,
|
||||
version: packageVersion(),
|
||||
plane: options.plane,
|
||||
groups: [...enabledGroups],
|
||||
names: [],
|
||||
count: 0,
|
||||
at: new Date().toISOString(),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
source: options.source ?? options.plane,
|
||||
})
|
||||
throw error
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const dispose of disposers) dispose()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
45
test/group-skills-disabled.test.mjs
Normal file
45
test/group-skills-disabled.test.mjs
Normal 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
223
test/mcp-catalog.test.mjs
Normal 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
116
test/mcp-test-catalog.mjs
Normal 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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue