diff --git a/.release-build.json b/.release-build.json index 081143e..6a7bc5e 100644 --- a/.release-build.json +++ b/.release-build.json @@ -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" } } diff --git a/docs/TOOL_MAP.md b/docs/TOOL_MAP.md index 4e16970..4f383e3 100644 --- a/docs/TOOL_MAP.md +++ b/docs/TOOL_MAP.md @@ -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__`. + +**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__`. 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. diff --git a/lib/agent-tools-biz-monitor.js b/lib/agent-tools-biz-monitor.js index e7e7da3..711f9cb 100644 --- a/lib/agent-tools-biz-monitor.js +++ b/lib/agent-tools-biz-monitor.js @@ -21,52 +21,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", @@ -101,14 +55,6 @@ function groupsForPlane(groups, plane, only) { const allow = new Set(only); return enabled.filter((id) => allow.has(id)); } -function toolNamesForGroups(groupIds) { - const out = new Set; - for (const id of groupIds) { - for (const name of TOOLS_BY_GROUP[id] ?? []) - out.add(name); - } - return out; -} function parsePresetIds(value) { const current = readConfigValue(value); if (typeof current !== "string") @@ -122,6 +68,234 @@ function groupsForPreset(connection, presetId, only) { return CAPABILITY_GROUP_IDS.filter((id) => (!only?.length || only.includes(id)) && !policy[id].public && (presetId === "netxops" ? policy[id].inPreset : policy[id].presets?.includes(presetId))); } +// src/netx/mcp-types.ts +var NETX_TOOL_PREFIX = "netx__"; +function publicToolName(serverName) { + return `${NETX_TOOL_PREFIX}${serverName}`; +} + +// src/netx/mcp-catalog.ts +function disabledSet(disabledTools) { + const out = new Set; + for (const name of disabledTools ?? []) { + const trimmed = typeof name === "string" ? name.trim() : ""; + if (trimmed) + out.add(trimmed); + } + return out; +} +function filterCatalogTools(catalog, enabledGroups, policy) { + const allowGroups = new Set(enabledGroups); + const disabled = disabledSet(policy.disabledTools); + const readOnlyOnly = policy.readOnlyOnly === true; + const out = []; + 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; +} +function resolveEnabledTools(catalog, options) { + 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); +} +function disabledPublicNames(catalog, enabledGroups, disabledTools, readOnlyOnly) { + const allowGroups = new Set(enabledGroups); + const disabled = disabledSet(disabledTools); + const names = []; + 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(); +} + +// src/netx/mcp-remote.ts +var PROTOCOL_VERSION = "2025-03-26"; +var CLIENT_INFO = { name: "dsh-netxops", version: "0.1.71" }; + +class McpRemoteError extends Error { + status; + constructor(message, status) { + super(message); + this.name = "McpRemoteError"; + this.status = status; + } +} +function mcpEndpointUrl(apiUrl) { + const trimmed = apiUrl.trim().replace(/\/$/, ""); + if (!trimmed) + return ""; + return `${trimmed}/mcp`; +} +function parseMcpResponseBody(raw) { + 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 = []; + 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 {} + } + } + throw new McpRemoteError("mcp response was not JSON"); +} +function asRpcResult(payload) { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + throw new McpRemoteError("invalid JSON-RPC envelope from /mcp"); + } + const row = payload; + if (row.error && typeof row.error === "object" && !Array.isArray(row.error)) { + const err = row.error; + 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.result; +} +var nextId = 1; +async function postRpc(options, method, params, notification = false) { + const headers = { + "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 : 60000; + 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 = () => { + abort.abort(); + }; + options.signal?.addEventListener("abort", onOuterAbort, { once: true }); + let 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; + } + return asRpcResult(parseMcpResponseBody(responseText)); +} +async function callMcpTool(options, name, args) { + 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 + }); + const content = Array.isArray(result.content) ? result.content : []; + return { + content, + ...result.structuredContent !== undefined ? { structuredContent: result.structuredContent } : {}, + ...result.isError === true ? { isError: true } : {} + }; +} +function mcpContentText(content, fallback = "") { + const parts = []; + for (const block of content) { + if (!block || typeof block !== "object" || Array.isArray(block)) + continue; + const row = block; + if (row.type === "text" && typeof row.text === "string") + parts.push(row.text); + } + return parts.join(` +`) || fallback; +} + +// src/netx/mcp-store.ts +var STORE_KEY = Symbol.for("dsh-netxops.mcp-catalog-store"); +var EMPTY = Object.freeze({ + phase: "idle", + mcpUrl: "", + tools: Object.freeze([]), + error: "", + listedAt: null, + fromCache: false +}); +function store() { + const root = globalThis; + let current = root[STORE_KEY]; + if (current === undefined) { + current = { state: EMPTY, listeners: new Set, generation: 0 }; + root[STORE_KEY] = current; + } + return current; +} +function getMcpCatalogTools() { + return store().state.tools; +} +function watchMcpCatalog(listener) { + const state = store(); + state.listeners.add(listener); + return () => { + state.listeners.delete(listener); + }; +} + // src/netx/group-skills.ts import { existsSync } from "node:fs"; import { readdir, readFile, stat } from "node:fs/promises"; @@ -271,7 +445,24 @@ async function loadGroupSkills(skillsRoot, groupId) { } return { skills, skipped }; } -async function registerGroupSkills(ctx, groupIds, providerLabel, signal) { +function disabledFootnote(names, lang) { + if (names.length === 0) + return ""; + const list = names.join(", "); + const en = (lang ?? "").trim().toLowerCase().startsWith("en"); + return en ? ` + +## Disabled tools +The following tools are disabled: ${list}. Do not call them. +` : ` + +## 已禁用工具 +以下工具已禁用:${list},不要调用。 +`; +} +async function registerGroupSkills(ctx, groupIds, providerLabel, signalOrOptions) { + const options = signalOrOptions instanceof AbortSignal || signalOrOptions === undefined ? { signal: signalOrOptions } : signalOrOptions; + const signal = options.signal; const skillsApi = ctx.skills; if (!skillsApi || typeof skillsApi.register !== "function") { return () => {}; @@ -293,15 +484,17 @@ async function registerGroupSkills(ctx, groupIds, providerLabel, signal) { if (skills.length === 0) { log?.warn?.("%s: no skills loaded for group=%s from %s", providerLabel, groupId, 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} + +## DeepSeek Harness REST capabilities +Use only tools present in this session. Dual-unit/orbit/polish recipes require server-side topology tools. Use only tools present in this session. +` : skill.content; disposers.push(skillsApi.register({ name: skill.name, description: skill.description, - content: groupId === "topology" ? `${skill.content} - -## DeepSeek Harness REST capabilities -Use 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. -` : skill.content, + content: `${base}${footnote}`, path: skill.path, resourceBase: { kind: "directory", path: skill.directory }, provider: providerLabel, @@ -1119,18 +1312,18 @@ function searchKb(snapshot, args) { } // src/netx/kb-runtime.ts -var STORE_KEY = Symbol.for("dsh-netxops.kb-store"); -function store() { +var STORE_KEY2 = Symbol.for("dsh-netxops.kb-store"); +function store2() { const root = globalThis; - let current = root[STORE_KEY]; + let current = root[STORE_KEY2]; if (current === undefined) { current = { snapshot: unconfiguredKbSnapshot(), listeners: new Set }; - root[STORE_KEY] = current; + root[STORE_KEY2] = current; } return current; } function getKbContext() { - const snap = store().snapshot; + const snap = store2().snapshot; return { ...snap, content: { ...snap.content }, @@ -1138,7 +1331,7 @@ function getKbContext() { }; } function watchKbContext(listener) { - const state = store(); + const state = store2(); state.listeners.add(listener); return () => { state.listeners.delete(listener); @@ -1162,26 +1355,18 @@ function toLosslessJson(value) { return { ok: false, error: "tool_result_not_json", detail: detail.slice(0, 400) }; } } -function omitUndefined(record) { - const out = {}; - for (const [key, value] of Object.entries(record)) { - if (value !== undefined) - out[key] = value; - } - return out; -} // src/netx/tool-mount-status.ts import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs"; import { homedir } from "node:os"; import { dirname as dirname4, join as join7 } from "node:path"; var KEY = Symbol.for("dsh-netxops.mount-inventory"); -function store2() { +function store3() { const root = globalThis; return root[KEY] ??= { mounts: new Map, contexts: new WeakMap, sequence: 0 }; } function getToolMountId(ctx, source) { - const state = store2(); + const state = store3(); let id = state.contexts.get(ctx); if (id === undefined) { id = ++state.sequence; @@ -1190,7 +1375,7 @@ function getToolMountId(ctx, source) { return `${source}:${id}`; } function getToolMountStatuses() { - return [...store2().mounts.values()].map((row) => ({ ...row, groups: [...row.groups], names: [...row.names] })); + return [...store3().mounts.values()].map((row) => ({ ...row, groups: [...row.groups], names: [...row.names] })); } function getToolMountStatus() { const rows = getToolMountStatuses(); @@ -1205,11 +1390,11 @@ function persist() { } catch {} } function publishToolMountStatus(next) { - store2().mounts.set(next.id ?? next.source ?? next.plane, { ...next, groups: [...next.groups], names: [...next.names] }); + store3().mounts.set(next.id ?? next.source ?? next.plane, { ...next, groups: [...next.groups], names: [...next.names] }); persist(); } function removeToolMountStatus(id) { - store2().mounts.delete(id); + store3().mounts.delete(id); persist(); } @@ -1571,139 +1756,20 @@ function normalizeApiUrl(raw) { const search = parsed.search && parsed.search !== "?" ? parsed.search : ""; return { ok: true, url: `${parsed.origin}${path}${search}` }; } -var PROTOCOL_KEY_ZH_TO_EN = { - 其他: "Other", - 时钟: "Clock", - "OTN/光": "OTN/Optical", - 电源: "Power" -}; -function asLang(value) { - return typeof value === "string" ? value : ""; -} -function localizePayload(lang, data) { - 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; - const key = typeof rec.key === "string" ? rec.key : ""; - const mapped = PROTOCOL_KEY_ZH_TO_EN[key]; - if (mapped !== undefined) - rec.key = mapped; - } - return data; -} -function encodeQuery(params) { - const sp = new URLSearchParams; - for (const [key, value] of Object.entries(params)) { - sp.set(key, String(value)); - } - const q = sp.toString(); - return q.length > 0 ? `?${q}` : ""; -} -function createNetxClient(connection) { - const langParams = () => { - const lang = asLang(connection.lang).trim().toLowerCase(); - if (lang.startsWith("en")) - return { lang: "en" }; - return {}; - }; - async function request(method, path, options = {}) { - 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 = { ...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 = { - accept: "application/json", - authorization: `Bearer ${token}` - }; - if (options.body !== undefined) - headers["content-type"] = "application/json"; - const init = { - 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) : {}; - if (typeof data === "object" && data !== null && !Array.isArray(data)) { - return { ok: true, data: localizePayload(connection.lang, data) }; - } - return { ok: true, data: { raw: data } }; - } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - return { ok: false, error: "netx_request_failed", detail: detail.slice(0, 800) }; - } finally { - clearTimeout(timer); - options.signal?.removeEventListener("abort", onOuterAbort); - } - } - return { - get(path, params, signal, timeoutMs) { - return request("GET", path, { params, signal, timeoutMs }); - }, - post(path, body, signal, timeoutMs) { - return request("POST", path, { body, signal, timeoutMs }); - }, - patch(path, body, signal, timeoutMs) { - return request("PATCH", path, { body, signal, timeoutMs }); - } - }; -} -function quoteNeId(neId) { - return encodeURIComponent(neId.trim()); -} // src/netx/runtime.ts -var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store"); -function store3() { +var STORE_KEY3 = Symbol.for("dsh-netxops.connection-store"); +function store4() { const root = globalThis; - let current = root[STORE_KEY2]; + let current = root[STORE_KEY3]; if (current === undefined) { current = { connection: undefined, listeners: new Set }; - root[STORE_KEY2] = current; + root[STORE_KEY3] = current; } return current; } function getNetxConnection() { - return store3().connection; + return store4().connection; } function provisionalNetxConnection() { const envUrl = process.env.NETX_API_URL?.trim() ?? ""; @@ -1715,6 +1781,8 @@ function provisionalNetxConnection() { lang: typeof process.env.NETX_LANG === "string" && process.env.NETX_LANG.trim() ? process.env.NETX_LANG.trim() : "zh", toolCallTimeoutMs: 120000, groups: DEFAULT_CAPABILITY_GROUPS, + disabledTools: [], + readOnlyOnly: false, groupKbInPreset: true, groupKbPublic: false }; @@ -1723,7 +1791,7 @@ function requireNetxConnection() { return getNetxConnection() ?? provisionalNetxConnection(); } function watchNetxConnection(listener) { - const state = store3(); + const state = store4(); state.listeners.add(listener); return () => { state.listeners.delete(listener); @@ -1735,1443 +1803,6 @@ import { readFileSync as readFileSync5 } from "node:fs"; import { dirname as dirname5, join as join9 } from "node:path"; import { fileURLToPath as fileURLToPath2 } from "node:url"; import { defineTool as defineTool2 } from "@deepseek-ai/dsh-tools"; - -// src/netx/handlers.ts -var EXEC_MAX_COMMANDS_CAP = 50; -var EXEC_MAX_COMMANDS_DEFAULT = 5; -function execMaxCommands() { - 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() { - 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) { - if (raw === undefined || raw === null) - return; - if (typeof raw === "boolean") - return raw; - const text = String(raw).trim().toLowerCase(); - if (!text) - return; - if (["1", "true", "yes", "on"].includes(text)) - return true; - if (["0", "false", "no", "off"].includes(text)) - return false; - return; -} -function countExecNeTargets(args) { - let n = 0; - for (const key of ["ne_ids", "nms_ne_ids", "ume_ne_ids"]) { - 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 (str2(args, "ne_id").trim() || nmsOrUme(args, "nms_ne_id", "ume_ne_id")) - return 1; - } - return n; -} -function shouldRunExecAsync(args) { - 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; -} -var UME_RAW_FIELD_PRESETS = { - brief: [ - "alarm_alarm_key", - "alarm_host_name", - "alarm_perceived_severity", - "alarm_event_type", - "alarm_last_seen_at", - "ne_host_name", - "ne_user_label", - "ne_ne_name", - "ne_ip_address", - "ne_exists" - ], - evidence: [ - "alarm_alarm_key", - "alarm_host_name", - "alarm_object_name", - "alarm_event_type", - "alarm_native_probable_cause", - "alarm_perceived_severity", - "alarm_is_cleared", - "alarm_time_created", - "alarm_last_seen_at", - "ne_host_name", - "ne_user_label", - "ne_ne_name", - "ne_ip_address", - "ne_connection_status", - "ne_exists" - ], - ne_debug: [ - "alarm_alarm_key", - "alarm_ne_id", - "alarm_perceived_severity", - "alarm_last_seen_at", - "ne_user_label", - "ne_ne_name", - "ne_ip_address", - "ne_ipv6_address", - "ne_device_level", - "ne_host_name", - "ne_connection_status", - "ne_admin_status", - "ne_address_type", - "ne_maintain_status", - "ne_exists" - ] -}; -function asRecord(value) { - return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; -} -function str2(args, key, fallback = "") { - const v = args[key]; - if (typeof v === "string") - return v; - if (typeof v === "number" || typeof v === "boolean") - return String(v); - return fallback; -} -function nmsOrUme(args, nmsKey, umeKey) { - return str2(args, nmsKey).trim() || str2(args, umeKey).trim(); -} -function nmsOrUmeList(args, nmsKey, umeKey) { - const primary = strList(args, nmsKey); - return primary.length > 0 ? primary : strList(args, umeKey); -} -function num2(args, key) { - const v = args[key]; - return typeof v === "number" && Number.isFinite(v) ? v : undefined; -} -function bool2(args, key) { - const v = args[key]; - return typeof v === "boolean" ? v : undefined; -} -function strList(args, key) { - const v = args[key]; - if (!Array.isArray(v)) - return []; - return v.map((x) => String(x).trim()).filter((x) => x.length > 0); -} -function clampInt(value, fallback, min, max) { - const n = value === undefined ? fallback : Math.trunc(value); - return Math.max(min, Math.min(max, n)); -} -function putStr(params, args, keys) { - for (const key of keys) { - const v = str2(args, key).trim(); - if (v) - params[key] = v; - } -} -async function queryUmeAlarms(client, args, signal) { - let page = clampInt(num2(args, "page"), 1, 1, 2); - const pageSize = clampInt(num2(args, "page_size"), 50, 1, 500); - const params = { page, page_size: pageSize }; - putStr(params, args, ["severity", "ne_id", "host_name", "time_from", "time_to"]); - const keyword = str2(args, "keyword").trim(); - const neName = str2(args, "ne_name").trim(); - if (keyword) - params.keyword = keyword; - else if (neName) - params.keyword = neName; - return client.get("/v1/ume/alarms", params, signal); -} -async function aggregateUmeAlarmsRaw(client, args, signal) { - const params = {}; - putStr(params, args, [ - "group_by", - "group_by2", - "severity", - "is_cleared", - "ne_id", - "event_type", - "keyword", - "time_from", - "time_to", - "limit" - ]); - if ("exclude_missing_host" in args) { - const flag = bool2(args, "exclude_missing_host"); - if (flag !== undefined) - params.exclude_missing_host = flag; - } - return client.get("/v1/ume/alarms/aggregate/raw", params, signal); -} -async function aggregateUmeAlarms(client, args, signal) { - if (str2(args, "group_by").trim()) - return aggregateUmeAlarmsRaw(client, args, signal); - const topNe = clampInt(num2(args, "top_ne"), 50, 0, 500); - const params = { top_ne: topNe }; - if ("exclude_missing_host" in args) { - const flag = bool2(args, "exclude_missing_host"); - if (flag !== undefined) - params.exclude_missing_host = flag; - } - putStr(params, args, ["severity", "time_from", "time_to"]); - return client.get("/v1/ume/alarms/aggregate", params, signal); -} -async function runUmeDiagnostics(client, _args, signal) { - return client.get("/v1/ume/diagnostics", undefined, signal); -} -async function queryUmeNeInventory(client, args, signal) { - const params = { - page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num2(args, "page_size"), 50, 1, 500) - }; - putStr(params, args, ["keyword"]); - return client.get("/v1/ume/inventory/ne", params, signal); -} -async function getUmeNe(client, args, signal) { - const neId = str2(args, "ne_id").trim(); - if (!neId) - return { ok: false, error: "ne_id_required", error_code: "ne_id_required" }; - return client.get(`/v1/ume/inventory/ne/${quoteNeId(neId)}`, undefined, signal); -} -async function queryUmeAlarmsRaw(client, args, signal) { - const params = { - page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num2(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 = str2(args, "field_preset").trim().toLowerCase(); - fields = UME_RAW_FIELD_PRESETS[preset] ?? []; - } - if (fields.length > 0) - params.select_fields = fields.join(","); - return client.get("/v1/ume/alarms/raw", params, signal); -} -async function listUmeAlarmFields(client, _args, signal) { - return client.get("/v1/ume/alarms/fields", undefined, signal); -} -async function sqlQueryUme(client, args, signal) { - const sql = str2(args, "sql").trim(); - if (!sql) - return { ok: false, error: "sql_required" }; - const limit = clampInt(num2(args, "limit"), 200, 1, 2000); - const statementTimeoutMs = clampInt(num2(args, "statement_timeout_ms"), 0, 0, 30000); - return client.post("/v1/sql/ume_query", { - sql, - limit, - statement_timeout_ms: statementTimeoutMs - }, signal, 60000); -} -async function listManagedNe(client, args, signal) { - const keyword = str2(args, "keyword").trim(); - const vendor = str2(args, "vendor").trim(); - const connectStatus = str2(args, "connect_status").trim(); - if (!(keyword || vendor || connectStatus)) { - return { ok: false, error: "managed_ne_filter_required", error_code: "managed_ne_filter_required" }; - } - if (keyword && keyword.length < 2) { - return { ok: false, error: "managed_ne_keyword_too_short", error_code: "managed_ne_keyword_too_short" }; - } - const params = { - page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num2(args, "page_size"), 20, 1, 100) - }; - if (keyword) - params.keyword = keyword; - if (vendor) - params.vendor = vendor; - if (connectStatus) - params.connect_status = connectStatus; - return client.get("/v1/managed-ne", params, signal); -} -async function getManagedNe(client, args, signal) { - const neId = (str2(args, "ne_id") || str2(args, "managed_ne_id") || str2(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: "" } - }; - } - const out = await client.get(`/v1/managed-ne/${quoteNeId(neId)}`, undefined, signal); - if (out.ok === false) { - const detail = `${str2(out, "detail")}${str2(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; -} -async function execManagedNe(client, args, signal) { - 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 body2 = {}; - if (Array.isArray(targetsRaw) && targetsRaw.length > 0) { - const cleaned = []; - for (const t of targetsRaw) { - if (typeof t !== "object" || t === null || Array.isArray(t)) - continue; - const row = t; - const item = {}; - const neId2 = str2(row, "ne_id").trim(); - const umeNeId2 = nmsOrUme(row, "nms_ne_id", "ume_ne_id"); - if (neId2) - item.ne_id = neId2; - if (umeNeId2) - item.ume_ne_id = umeNeId2; - const cmds = Array.isArray(row.commands) ? row.commands.map((c) => String(c).trim()).filter((c) => c.length > 0) : []; - if (cmds.length > 0) - item.commands = cmds; - if (Object.keys(item).length > 0) - cleaned.push(item); - } - body2.targets = cleaned; - } - if (neIds.length > 0) - body2.ne_ids = neIds; - if (umeNeIds.length > 0) - body2.ume_ne_ids = umeNeIds; - if (sharedCommands.length > 0) { - if (sharedCommands.length > maxCmds) { - return { ok: false, error: "too_many_commands", error_code: "too_many_commands" }; - } - body2.commands = sharedCommands; - } - body2.read_timeout_sec = clampInt(num2(args, "read_timeout_sec"), 60, 10, 120); - const concurrency = num2(args, "concurrency"); - if (concurrency !== undefined) - body2.concurrency = clampInt(concurrency, 4, 1, 8); - if (wantAsync) { - const out3 = await client.post("/v1/managed-ne/exec-jobs", body2, signal, 60000); - if (out3.ok !== true) - return out3; - const data3 = asRecord(out3.data); - return Object.keys(data3).length > 0 ? data3 : out3; - } - const out2 = await client.post("/v1/managed-ne/exec-batch", body2, signal, 600000); - if (out2.ok !== true) - return out2; - const data2 = asRecord(out2.data); - if (data2.ok === false) { - return { ok: false, data: data2, error: str2(data2, "error", "exec_batch_failed") }; - } - return { ok: true, data: data2 }; - } - const neId = str2(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 = { - commands: sharedCommands, - read_timeout_sec: clampInt(num2(args, "read_timeout_sec"), 60, 10, 120) - }; - if (neId) - body.ne_id = neId; - if (umeNeId) - body.ume_ne_id = umeNeId; - if (wantAsync) { - const out2 = await client.post("/v1/managed-ne/exec-jobs", body, signal, 60000); - if (out2.ok !== true) - return out2; - const data2 = asRecord(out2.data); - return Object.keys(data2).length > 0 ? data2 : out2; - } - const out = await client.post("/v1/managed-ne/exec", body, signal, 300000); - if (out.ok !== true) - return out; - const data = asRecord(out.data); - if (data.ok === false) { - return { ok: false, data, error: str2(data, "error", "exec_failed") }; - } - return { ok: true, data }; -} -async function getNeExecJob(client, args, signal) { - const jobId = str2(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); -} -async function listCliTargets(client, args, signal) { - const params = { - page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num2(args, "page_size"), 50, 1, 500) - }; - putStr(params, args, ["source", "keyword"]); - return client.get("/v1/cli/targets", params, signal); -} -async function findTopologyPaths(client, args, signal) { - const fromUid = nmsOrUme(args, "from_nms_ne_id", "from_ume_ne_id"); - const fromMid = str2(args, "from_managed_ne_id").trim(); - const toUid = nmsOrUme(args, "to_nms_ne_id", "to_ume_ne_id"); - const toMid = str2(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 = str2(args, "detail", "summary").trim().toLowerCase() || "summary"; - if (detail !== "summary" && detail !== "full") - detail = "summary"; - const body = { - max_paths: clampInt(num2(args, "max_paths"), 3, 1, 10), - max_hops: clampInt(num2(args, "max_hops"), 6, 1, 12), - layer: str2(args, "layer", "physical").trim() || "physical", - detail - }; - if (fromUid) - body.from_ume_ne_id = fromUid; - else - body.from_managed_ne_id = fromMid; - if (toUid) - body.to_ume_ne_id = toUid; - else - body.to_managed_ne_id = toMid; - return client.post("/v1/topology/fabric/paths", body, signal, 30000); -} -function taskBriefNe(brief) { - 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) { - 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, q) { - 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); -} -async function listBizMonitors(client, args, signal) { - const kindRaw = str2(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 = str2(args, "purpose").trim(); - const status = str2(args, "status").trim().toLowerCase(); - const q = str2(args, "q").trim().toLowerCase(); - const limit = clampInt(num2(args, "limit") ?? 100, 100, 1, 500); - const wantProjects = kind === "all" || kind === "projects"; - const wantTasks = kind === "all" || kind === "tasks"; - const fetches = []; - if (wantProjects) - fetches.push(client.get("/v1/biz-migration/projects", {}, signal)); - else - fetches.push(Promise.resolve({ ok: true, data: { items: [] } })); - if (wantTasks) { - const params = {}; - 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 : []; - const taskItems = Array.isArray(asRecord(taskRes.data).items) ? asRecord(taskRes.data).items : []; - 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) { - 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, taskId = "") { - 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) { - 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 - }); -} -async function listBizMonitorBatches(client, args, signal) { - const projectId = str2(args, "project_id").trim(); - const taskId = str2(args, "task_id").trim(); - const batchId = str2(args, "batch_id").trim(); - const kindRaw = str2(args, "kind", "auto").trim().toLowerCase() || "auto"; - const includeRuns = args.include_runs === true || args.include_runs === "true" || args.include_runs === 1; - const status = str2(args, "status").trim().toLowerCase(); - const limit = clampInt(num2(args, "limit") ?? 50, 50, 1, 200); - const runsLimit = clampInt(num2(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 = []; - let collectBatches = []; - let runs = []; - 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 : []; - 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 : []; - 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 : []; - 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: [] }; - const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : []; - 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) { - 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)." - }) - }; -} -async function getBizMonitorContext(client, args, signal) { - const projectId = str2(args, "project_id").trim(); - const taskId = str2(args, "task_id").trim(); - if (!projectId && !taskId) { - return { ok: false, error: "project_id_or_task_id_required" }; - } - const params = {}; - if (projectId) - params.project_id = projectId; - if (taskId) - params.task_id = taskId; - return client.get("/v1/biz-migration/monitor-context", params, signal); -} -async function getBizMonitorBoard(client, args, signal) { - const batchId = str2(args, "batch_id").trim(); - if (!batchId) - return { ok: false, error: "batch_id_required" }; - const params = {}; - const runId = str2(args, "run_id").trim(); - if (runId) - params.run_id = runId; - return client.get(`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/board`, params, signal); -} -async function listBizMonitorReds(client, args, signal) { - const projectId = str2(args, "project_id").trim(); - if (!projectId) - return { ok: false, error: "project_id_required" }; - const params = {}; - putStr(params, args, ["status"]); - const limit = num2(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); -} -async function getBizMonitorDiffs(client, args, signal) { - const runId = str2(args, "run_id").trim(); - if (!runId) - return { ok: false, error: "run_id_required" }; - const params = {}; - putStr(params, args, ["metric_id", "sheet_id", "verdict", "color", "q"]); - const limit = num2(args, "limit"); - if (limit !== undefined) - params.limit = clampInt(limit, 200, 1, 500); - const offset = num2(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); -} -async function getBizCollectBatch(client, args, signal) { - const batchId = str2(args, "batch_id").trim(); - if (!batchId) - return { ok: false, error: "batch_id_required" }; - return client.get(`/v1/biz-state/batches/${encodeURIComponent(batchId)}`, {}, signal); -} -async function getBizCollectCommandRaw(client, args, signal) { - const batchId = str2(args, "batch_id").trim(); - const commandId = str2(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); -} - -// src/netx/topology-handlers.ts -function asRecord2(value) { - return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; -} -function str3(args, key, fallback = "") { - const v = args[key]; - if (typeof v === "string") - return v; - if (typeof v === "number" || typeof v === "boolean") - return String(v); - return fallback; -} -function num3(args, key) { - const v = args[key]; - return typeof v === "number" && Number.isFinite(v) ? v : undefined; -} -function bool3(args, key) { - const v = args[key]; - return typeof v === "boolean" ? v : undefined; -} -function strList2(args, key) { - const v = args[key]; - if (!Array.isArray(v)) - return []; - return v.map((x) => String(x).trim()).filter((x) => x.length > 0); -} -function clampInt2(value, fallback, min, max) { - const n = value === undefined ? fallback : Math.trunc(value); - return Math.max(min, Math.min(max, n)); -} -function unwrap(out) { - if (out.ok !== true) - return out; - const data = asRecord2(out.data); - if (Object.keys(data).length === 0) - return { ok: true, data: out.data }; - return { ok: true, ...data, data }; -} -function filterFields(args) { - const out = {}; - for (const key of ["keyword", "role", "vendor", "link_status"]) { - const v = str3(args, key).trim(); - if (v) - out[key] = v; - } - return out; -} -function edgeEndpoints(edge) { - const a = str3(edge, "a_node_id") || str3(edge, "a"); - const b = str3(edge, "b_node_id") || str3(edge, "b"); - return [a.trim(), b.trim()]; -} -function collapseEdgesToLinks(edges, includeNames = false) { - const buckets = new Map; - for (const raw of edges) { - if (!raw || typeof raw !== "object" || Array.isArray(raw)) - continue; - const e = raw; - 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}\x00${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 = str3(e, "a_name"); - row.b_name = str3(e, "b_name"); - } else { - row.a_name = str3(e, "b_name"); - row.b_name = str3(e, "a_name"); - } - } - buckets.set(key, row); - } - return [...buckets.values()]; -} -function summarizeViewGraph(graph, sample = 20) { - 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 = asRecord2(n); - return { - id: str3(row, "id") || str3(row, "fabric_node_id"), - name: str3(row, "name"), - x: row.x, - y: row.y, - level: row.level, - role: row.role - }; - }); - return { - ok: true, - view_id: str3(graph, "id") || str3(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) { - 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)." - }; -} -async function getTopologyTree(client, args, signal) { - const out = unwrap(await client.get("/v1/topology/tree", undefined, signal)); - if (out.ok !== true) - return out; - const compact = bool3(args, "compact") !== false; - const maxDepth = num3(args, "max_depth"); - if (!compact && maxDepth === undefined) - return out; - return { ...out, compact, max_depth: maxDepth ?? null }; -} -async function getTopologyView(client, args, signal) { - const viewId = str3(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, 120000)); - if (out.ok !== true) - return out; - const detail = str3(args, "detail", "summary").trim().toLowerCase() || "summary"; - if (detail === "full") - return { ...out, detail: "full" }; - return summarizeViewGraph(out, clampInt2(num3(args, "sample"), 20, 0, 200)); -} -async function createTopologyFolder(client, args, signal) { - const name = str3(args, "name").trim(); - if (!name) - return { ok: false, error: "name_required" }; - const body = { name }; - const parentId = str3(args, "parent_id").trim(); - if (parentId) - body.parent_id = parentId; - const locale = str3(args, "locale").trim(); - if (locale) - body.locale = locale; - if (num3(args, "sort_order") !== undefined) - body.sort_order = Math.trunc(num3(args, "sort_order")); - return unwrap(await client.post("/v1/topology/folders", body, signal)); -} -async function addTopologyViewNodes(client, args, signal) { - const viewId = str3(args, "view_id").trim(); - if (!viewId) - return { ok: false, error: "view_id_required" }; - if (strList2(args, "managed_ne_ids").length || strList2(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 = strList2(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 (num3(args, "max_nodes") !== undefined) { - const got = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal)); - if (got.ok === true) { - const data = asRecord2(got.data); - const filt = asRecord2(got.filter ?? data.filter); - const membership = asRecord2(filt.membership ?? {}); - membership.max_nodes = clampInt2(num3(args, "max_nodes"), 2000, 1, 2000); - filt.membership = membership; - await client.patch(`/v1/topology/views/${encodeURIComponent(viewId)}`, { filter: filt }, signal); - } - } - const layout = str3(args, "layout", "grid").trim() || "grid"; - const body = { - managed_ne_ids: [], - layout, - ...filters - }; - if (fabricIds.length) - body.fabric_node_ids = fabricIds; - if (num3(args, "limit") !== undefined) - body.limit = clampInt2(num3(args, "limit"), 500, 1, 2000); - if (num3(args, "offset") !== undefined) - body.offset = clampInt2(num3(args, "offset"), 0, 0, Number.MAX_SAFE_INTEGER); - return unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes`, body, signal, 180000)); -} -async function removeTopologyViewNodes(client, args, signal) { - const viewId = str3(args, "view_id").trim(); - if (!viewId) - return { ok: false, error: "view_id_required" }; - const body = { ...filterFields(args) }; - const fabricIds = strList2(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)); -} -async function copyTopologyViewNodes(client, args, signal) { - const sourceId = str3(args, "source_view_id").trim(); - const targetId = str3(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, 120000)); - if (source.ok !== true) - return source; - const nodes = Array.isArray(source.nodes) ? source.nodes : []; - const limit = num3(args, "limit"); - const selected = typeof limit === "number" ? nodes.slice(0, clampInt2(limit, nodes.length, 1, 2000)) : nodes; - const fabricIds = selected.map((n) => { - const row = asRecord2(n); - return str3(row, "id") || str3(row, "fabric_node_id"); - }).filter(Boolean); - if (bool3(args, "dry_run") === true) { - return { ok: true, dry_run: true, would_copy: fabricIds.length, source_view_id: sourceId, target_view_id: targetId }; - } - if (bool3(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, 180000)); - if (add.ok !== true) - return add; - if (bool3(args, "copy_positions") !== false) { - const ox = num3(args, "offset_x") ?? 0; - const oy = num3(args, "offset_y") ?? 0; - const positions = selected.map((n) => { - const row = asRecord2(n); - const id = str3(row, "id") || str3(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, 120000); - } - } - return { ok: true, added: fabricIds.length, source_view_id: sourceId, target_view_id: targetId, ...asRecord2(add) }; -} -async function updateTopologyViewPositions(client, args, signal) { - const viewId = str3(args, "view_id").trim(); - if (!viewId) - return { ok: false, error: "view_id_required" }; - const body = {}; - if (Array.isArray(args.positions)) - body.positions = args.positions; - const layout = str3(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"]) { - if (num3(args, key) !== undefined) - body[key] = num3(args, key); - } - const fabricIds = strList2(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, 120000)); -} -async function projectTopologyNeighbors(client, args, signal) { - const viewId = str3(args, "view_id").trim(); - if (!viewId) - return { ok: false, error: "view_id_required" }; - const body = {}; - const seeds = strList2(args, "seed_fabric_node_ids").length ? strList2(args, "seed_fabric_node_ids") : strList2(args, "fabric_node_ids"); - if (seeds.length) - body.seed_fabric_node_ids = seeds; - const mids = strList2(args, "managed_ne_ids"); - if (mids.length) - body.managed_ne_ids = mids; - const region = str3(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, 180000)); - if (out.ok !== true) - return out; - const detail = str3(args, "detail", "summary").trim().toLowerCase() || "summary"; - if (detail === "full" || detail === "raw" || detail === "graph") { - return { ...out, detail: "full", projected: true }; - } - return { ...summarizeViewGraph(out, clampInt2(num3(args, "sample"), 20, 0, 100)), projected: true, view_id: viewId }; -} -async function queryTopologyFabricNodes(client, args, signal) { - let mode = str3(args, "mode").trim().toLowerCase(); - const q = str3(args, "q").trim(); - const keyword = str3(args, "keyword").trim(); - if (!mode) { - if (bool3(args, "summary") === true) - mode = "summary"; - else if (q) - mode = "search"; - else - mode = "list"; - } - if (mode === "summary" || mode === "stats" || mode === "count") { - const out2 = unwrap(await client.get("/v1/topology/fabric/summary", undefined, signal)); - return out2.ok === true ? { ...out2, mode: "summary" } : out2; - } - 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: clampInt2(num3(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num3(args, "page_size") ?? num3(args, "limit"), 50, 1, 200) - }, signal)); - } - const params = { - page: clampInt2(num3(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num3(args, "page_size") ?? num3(args, "limit"), 50, 1, 500) - }; - for (const key of ["keyword", "role", "level", "level_major", "region_folder_id", "link_status"]) { - const v = str3(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; -} -async function classifyTopologyFabricNodes(client, args, signal) { - const action = str3(args, "action").trim().toLowerCase(); - if (!action) - return { ok: false, error: "action_required" }; - if (action === "match") { - const pattern = str3(args, "pattern").trim() || str3(args, "q").trim(); - if (!pattern) - return { ok: false, error: "pattern_required" }; - return unwrap(await client.post("/v1/topology/fabric/nodes/match", { - pattern, - match_field: str3(args, "match_field", "name").trim() || "name", - sample_limit: clampInt2(num3(args, "sample_limit"), 50, 1, 200) - }, signal)); - } - if (action === "tag") { - const body = { - fabric_node_ids: strList2(args, "fabric_node_ids"), - dry_run: bool3(args, "dry_run") === true - }; - const pattern = str3(args, "pattern").trim() || str3(args, "q").trim(); - if (pattern) - body.pattern = pattern; - if (args.level !== undefined) - body.level = args.level; - const role = str3(args, "role").trim(); - if (role) - body.role = role; - const region = str3(args, "region_folder_id").trim(); - if (region) - body.region_folder_id = region; - if (bool3(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 = str3(args, "fabric_node_id").trim() || str3(args, "node_id").trim(); - if (!nodeId) - return { ok: false, error: "fabric_node_id_required" }; - const body = {}; - if (args.level !== undefined) - body.level = args.level; - const role = str3(args, "role").trim(); - if (role) - body.role = role; - const region = str3(args, "region_folder_id").trim(); - if (region) - body.region_folder_id = region; - if (bool3(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: str3(args, "kind", "any").trim() || "any", - page: clampInt2(num3(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num3(args, "page_size") ?? num3(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 = {}; - if (bool3(args, "overwrite_manual") === true) - params.skip_manual = false; - else if (bool3(args, "skip_manual") !== undefined) - params.skip_manual = bool3(args, "skip_manual") !== false; - if (bool3(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 }; -} -async function queryTopologyNeighborhood(client, args, signal) { - const nodeId = str3(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: clampInt2(num3(args, "depth"), 1, 1, 3), - layer: str3(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) }; -} -async function queryTopologyEdges(client, args, signal) { - const params = { - page: clampInt2(num3(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num3(args, "page_size"), 100, 1, 500), - layer: str3(args, "layer", "physical").trim() || "physical" - }; - for (const key of ["node_id", "keyword", "status", "source"]) { - const v = str3(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 = str3(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 }; -} -async function suggestSinkHubs(client, args, signal) { - const viewId = str3(args, "source_view_id").trim() || str3(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, 120000)); - 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; - for (const raw of edges) { - const [a, b] = edgeEndpoints(asRecord2(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([ - ...strList2(args, "exclude_portal_ids"), - ...strList2(args, "exclude_fabric_node_ids") - ]); - const pick = clampInt2(num3(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 row2 = asRecord2(n); - return (str3(row2, "id") || str3(row2, "fabric_node_id")) === fabric_node_id; - }); - const row = asRecord2(node); - return { - fabric_node_id, - degree: deg, - name: str3(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." - }; -} -async function analyzeTopologyViewLayout(client, args, signal) { - const viewId = str3(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, 120000)); - 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 = asRecord2(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; - for (const raw of edges) { - const [a, b] = edgeEndpoints(asRecord2(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: str3(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." - }; -} -async function sinkTopologyDualUnits(_client, args) { - return { - ok: false, - error: "sink_dual_units_requires_netx_topology_mcp", - source_view_id: str3(args, "source_view_id"), - sink_view_id: str3(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." - }; -} -async function layoutTopologyView(client, args, signal) { - if (bool3(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 = str3(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 = str3(args, "view_id").trim(); - const fromId = str3(args, "source_view_id").trim(); - const ids = strList2(args, "fabric_node_ids"); - const params = asRecord2(args.params); - const fabricIds = ids.length ? ids : strList2(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 = bool3(params, "copy_positions") !== false && bool3(args, "copy_positions") !== false; - const removeFromSource = bool3(params, "remove_from_source") !== false; - const source = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(fromId)}`, undefined, signal, 120000)); - 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 = asRecord2(n); - const id = str3(row, "id") || str3(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, 180000)); - 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 = asRecord2(n); - return { - fabric_node_id: str3(row, "id") || str3(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, 120000); - } - 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: bool3(params, "park") === true || bool3(args, "park") === true - }; - } - return layoutRecipeUnavailable(action); -} - -// src/netx/tools.ts function packageVersion() { try { const here = dirname5(fileURLToPath2(import.meta.url)); @@ -3181,65 +1812,89 @@ function packageVersion() { return "0"; } } -var MUTATING_TOOLS = new Set([ - "netx__execManagedNe", - "netx__createTopologyFolder", - "netx__addTopologyViewNodes", - "netx__removeTopologyViewNodes", - "netx__copyTopologyViewNodes", - "netx__updateTopologyViewPositions", - "netx__projectTopologyNeighbors", - "netx__classifyTopologyFabricNodes", - "netx__layoutTopologyView" -]); -var str4 = (description) => ({ type: "string", ...description ? { description } : {} }); -var num4 = (description) => ({ type: "number", ...description ? { description } : {} }); -var bool4 = (description) => ({ type: "boolean", ...description ? { description } : {} }); -var strArr = (description) => ({ - type: "array", - items: { type: "string" }, - ...description ? { description } : {} -}); -var anyObj = (description) => ({ - type: "object", - additionalProperties: true, - ...description ? { description } : {} -}); -function renderJson2(_args, value) { - return [{ type: "text", text: JSON.stringify(value, null, 0) }]; +function renderMcpResult(_args, value) { + if (value && typeof value === "object" && !Array.isArray(value)) { + const row = value; + if (typeof row.text === "string" && row.text.length > 0) { + return [{ type: "text", text: row.text }]; + } + if (Array.isArray(row.content)) { + return [{ type: "text", text: mcpContentText(row.content, JSON.stringify(value)) }]; + } + } + return [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value ?? null) }]; } -var jsonOut2 = { +var mcpOut = { schema: { type: "object", additionalProperties: true }, - render: renderJson2 + render: renderMcpResult }; -function tool2(name, description, parameters, handler, getClient, timeoutMs) { +function parametersFromInputSchema(schema) { + if (schema && typeof schema === "object" && !Array.isArray(schema)) { + const props = schema.properties; + if (props && typeof props === "object" && !Array.isArray(props)) { + return props; + } + if (schema.type === undefined && schema.properties === undefined) { + return schema; + } + } + return {}; +} +function defineRemoteTool(tool2, timeoutMs) { + const name = publicToolName(tool2.name); return defineTool2({ name, - description, - parameters, - output: jsonOut2, + description: tool2.description || name, + parameters: parametersFromInputSchema(tool2.inputSchema), + output: mcpOut, timeoutMs, - isConcurrencySafe: () => !MUTATING_TOOLS.has(name), + isConcurrencySafe: () => tool2.readOnlyHint === true, async execute(args, exec) { - const result = await handler(getClient(), args, exec.signal); - 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 + }, tool2.name, typeof args === "object" && args !== null ? args : {}); + const text = mcpContentText(result.content, ""); + if (result.isError === true) { + throw new Error(text || `Tool "${tool2.name}" failed`); } - return safe; + return JSON.parse(JSON.stringify({ + content: result.content, + ...result.structuredContent !== undefined ? { structuredContent: result.structuredContent } : {}, + ...text ? { text } : {} + })); } }); } function registerNetxTools(ctx, connection, options) { - 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((tool2) => tool2.group).filter(Boolean))]; + if (enabled.length === 0) { publishToolMountStatus({ id, version: packageVersion(), plane: options.plane, - groups: [...enabledGroups], + groups, names: [], count: 0, at: new Date().toISOString(), @@ -3250,392 +1905,40 @@ function registerNetxTools(ctx, connection, options) { }; } const t = typeof connection.toolCallTimeoutMs === "number" && Number.isFinite(connection.toolCallTimeoutMs) && connection.toolCallTimeoutMs > 0 ? connection.toolCallTimeoutMs : 120000; - const client = createNetxClient({ - apiUrl: connection.apiUrl, - lang: connection.lang, - timeoutMs: Math.min(t, 45000), - getToken: () => { - const live = getNetxConnection(); - return live ? live.token.trim() : connection.token.trim(); - }, - getApiUrl: () => getNetxConnection()?.apiUrl ?? connection.apiUrl - }); - const getClient = () => client; - const catalog = [ - tool2("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: str4(), - ne_id: str4("Filter only; do not show UUID to users"), - host_name: str4("Filter by NE host_name"), - ne_name: str4("Legacy alias mapped to keyword"), - keyword: str4("Substring on cause/object/event. Examples: LOS, Fiber Break, bandwidth, CRC."), - time_from: str4("ISO time; filters last_seen_at >="), - time_to: str4("ISO time; filters last_seen_at <="), - page: num4(), - page_size: num4() - }, queryUmeAlarms, getClient, t), - tool2("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: str4("Optional perceived_severity filter (critical/major/minor/warning)."), - top_ne: num4("Max NE buckets (default 50). Ignored when group_by is set."), - exclude_missing_host: bool4("Omit missing host_name from by_ne."), - time_from: str4(), - time_to: str4(), - group_by: str4("When set, routes to raw aggregation. Prefer alarm_host_name."), - group_by2: str4(), - is_cleared: str4(), - ne_id: str4(), - event_type: str4(), - keyword: str4(), - limit: num4() - }, aggregateUmeAlarms, getClient, t), - tool2("netx__runNmsDiagnostics", "NMS alarm diagnostics: severity, top_event_types, top_alarm_codes, top_ne, protocol buckets, freshness meta.", {}, runUmeDiagnostics, getClient, t), - tool2("netx__queryNmsNeInventory", "Paged NMS NE inventory synced in netx (keyword matches ne_id/ne_name/user_label/ip/host_name).", { - keyword: str4(), - page: num4(), - page_size: num4() - }, queryUmeNeInventory, getClient, t), - tool2("netx__getNmsNe", "Get single NMS NE detail by ne_id (UUID).", { - ne_id: { type: "string", required: true, description: "NMS inventory ne_id (UUID)." } - }, getUmeNe, getClient, t), - tool2("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: str4(), - is_cleared: str4(), - ne_id: str4(), - event_type: str4(), - keyword: str4(), - time_from: str4(), - time_to: str4(), - order_by: str4("last_seen_at | time_created | perceived_severity | event_type | ne_id"), - order: str4("asc | desc"), - select_fields: strArr(), - field_preset: str4("brief | evidence | ne_debug"), - page: num4(), - page_size: num4() - }, queryUmeAlarmsRaw, getClient, t), - tool2("netx__aggregateNmsAlarmsRaw", "Dynamic aggregation on NMS raw fields (group_by/group_by2); prefer alarm_host_name.", { - group_by: { type: "string", required: true }, - group_by2: str4(), - severity: str4(), - is_cleared: str4(), - ne_id: str4(), - event_type: str4(), - keyword: str4(), - time_from: str4(), - time_to: str4(), - exclude_missing_host: bool4(), - limit: num4() - }, aggregateUmeAlarmsRaw, getClient, t), - tool2("netx__listNmsAlarmFields", "List available fields for NMS raw alarm queries.", {}, listUmeAlarmFields, getClient, t), - tool2("netx__sqlQueryNms", "Read-only SELECT on NMS tables (ume_alarms_current/ume_inventory_ne); server enforces limits. Requires sql:query scope.", { - sql: { type: "string", required: true }, - limit: num4(), - statement_timeout_ms: num4() - }, sqlQueryUme, getClient, t), - tool2("netx__listManagedNe", "List filtered netx managed NEs (keyword/vendor/connect_status required); use before execManagedNe.", { - keyword: str4(), - vendor: str4(), - connect_status: str4("unknown | testing | pass | fail"), - page: num4(), - page_size: num4() - }, listManagedNe, getClient, t), - tool2("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: str4("Managed NE id"), - managed_ne_id: str4("Alias for ne_id"), - id: str4("Alias for ne_id") - }, getManagedNe, getClient, t), - tool2("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: str4(), - nms_ne_id: str4("NMS inventory id; alias ume_ne_id"), - ume_ne_id: str4("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", - description: "Per-NE command sets: each item is one NE (ne_id OR nms_ne_id) with commands[].", - items: { - type: "object", - additionalProperties: false, - properties: { - ne_id: str4(), - nms_ne_id: str4(), - ume_ne_id: str4(), - commands: strArr() - } - } - }, - commands: strArr("Commands for single NE, or shared commands for batch."), - read_timeout_sec: num4("Per-command read timeout (default 60; use 90–120 for slow show)."), - concurrency: num4("Parallel NEs for batch mode (1–8, default 4)."), - async: bool4("true=background job_id + getNeExecJob; false=force sync; omit=auto for ~4+ NEs (NETX_NE_EXEC_ASYNC_MIN_NES).") - }, execManagedNe, getClient, Math.max(t, 300000)), - tool2("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: str4("Job id from execManagedNe async response.") - }, getNeExecJob, getClient, Math.max(t, 60000)), - tool2("netx__listCliTargets", "List CLI-capable targets (managed NE and/or NMS inventory). Call once per session with keyword/source, cache ids, then execManagedNe.", { - source: str4("managed | nms | ume | all"), - keyword: str4(), - page: num4(), - page_size: num4() - }, listCliTargets, getClient, t), - tool2("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: str4("NMS inventory id"), - from_ume_ne_id: str4("Legacy alias of from_nms_ne_id"), - from_managed_ne_id: str4(), - to_nms_ne_id: str4("NMS inventory id"), - to_ume_ne_id: str4("Legacy alias of to_nms_ne_id"), - to_managed_ne_id: str4(), - max_paths: num4(), - max_hops: num4(), - layer: str4(), - detail: str4("summary | full") - }, findTopologyPaths, getClient, t), - tool2("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: str4("all | projects | tasks (default all)"), - purpose: str4("Filter tasks: portrait | cutover_hf | empty=all"), - status: str4("Filter by status (e.g. active / paused)"), - q: str4("Keyword on name / ne_name / note / id"), - limit: num4("Max rows per list (default 100, max 500)") - }, listBizMonitors, getClient, t), - tool2("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: str4("auto | all | cutover | collect | runs (default auto)"), - project_id: str4("Cutover project id → cutover_batches"), - task_id: str4("biz_state task id → collect_batches"), - batch_id: str4("Cutover batch id (filter or kind=runs)"), - include_runs: bool4("Attach recent evaluate runs onto listed cutover batches"), - status: str4("Filter batch status"), - limit: num4("Max batches (default 50)"), - runs_limit: num4("Max runs per batch when include_runs / kind=runs (default 10)") - }, listBizMonitorBatches, getClient, t), - tool2("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: str4("Cutover / biz-migration project id"), - task_id: str4("biz_state task id (optional if project_id set)") - }, getBizMonitorContext, getClient, t), - tool2("netx__getBizMonitorBoard", "Cutover batch board: project/batch status, evaluate run summary, sheet cards, verdict counts, metric_batches.", { - batch_id: { type: "string", required: true }, - run_id: str4("Optional evaluate run id") - }, getBizMonitorBoard, getClient, t), - tool2("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", required: true }, - status: str4("open|carried|resolved or empty=all"), - limit: num4() - }, listBizMonitorReds, getClient, t), - tool2("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", required: true }, - metric_id: str4(), - sheet_id: str4(), - verdict: str4(), - color: str4("e.g. red"), - q: str4(), - limit: num4(), - offset: num4() - }, getBizMonitorDiffs, getClient, t), - tool2("netx__getBizCollectBatch", "One biz_state collect batch: commands (raw_command, parse_status, preview), parsed metrics.", { - batch_id: { type: "string", required: true } - }, getBizCollectBatch, getClient, t), - tool2("netx__getBizCollectCommandRaw", "Full CLI raw_text for one collect command inside a batch (deep dive).", { - batch_id: { type: "string", required: true }, - command_id: { type: "string", required: true } - }, getBizCollectCommandRaw, getClient, t), - tool2("netx__getTopologyTree", "Get topology folder tree (nav roots + Root map canvases). Start here before createTopologyFolder.", { compact: bool4(), max_depth: num4() }, getTopologyTree, getClient, t), - tool2("netx__getTopologyView", "Get a topology view by view_id. Default detail=summary with sample_nodes + links[].", { - view_id: { type: "string", required: true }, - detail: str4("summary | full"), - sample: num4() - }, getTopologyView, getClient, t), - tool2("netx__createTopologyFolder", "Create topology folders / region canvases. Only way to create canvases (ne:write).", { - name: { type: "string", required: true }, - parent_id: str4(), - locale: str4("zh | en"), - sort_order: num4() - }, createTopologyFolder, getClient, t), - tool2("netx__addTopologyViewNodes", "Bulk-place fabric nodes on a view via filters or fabric_node_ids (never managed/UME ids).", { - view_id: { type: "string", required: true }, - max_nodes: num4(), - keyword: str4(), - role: str4(), - vendor: str4(), - link_status: str4(), - limit: num4(), - offset: num4(), - fabric_node_ids: strArr(), - layout: str4("grid | keep") - }, addTopologyViewNodes, getClient, t), - tool2("netx__removeTopologyViewNodes", "Remove placements from a view (not fabric) by filters or fabric_node_ids.", { - view_id: { type: "string", required: true }, - keyword: str4(), - role: str4(), - vendor: str4(), - link_status: str4(), - fabric_node_ids: strArr() - }, removeTopologyViewNodes, getClient, t), - tool2("netx__copyTopologyViewNodes", "Clone fabric placements from source_view_id onto target_view_id (optional clear_target / copy_positions).", { - source_view_id: { type: "string", required: true }, - target_view_id: { type: "string", required: true }, - copy_positions: bool4(), - clear_target: bool4(), - offset_x: num4(), - offset_y: num4(), - limit: num4(), - dry_run: bool4() - }, copyTopologyViewNodes, getClient, t), - tool2("netx__updateTopologyViewPositions", "Move nodes on a view via positions[] or layout=grid|offset|stack + filters.", { - view_id: { type: "string", required: true }, - positions: { - type: "array", - description: "Manual placements: [{ fabric_node_id, x, y }, …]", - items: { - type: "object", - additionalProperties: false, - properties: { - fabric_node_id: str4(), - x: num4(), - y: num4() - } - } - }, - layout: str4("grid | offset | stack"), - keyword: str4(), - role: str4(), - vendor: str4(), - link_status: str4(), - fabric_node_ids: strArr(), - offset_x: num4(), - offset_y: num4() - }, updateTopologyViewPositions, getClient, t), - tool2("netx__projectTopologyNeighbors", "Project existing fabric neighbors of on-view nodes onto the canvas.", { - view_id: { type: "string", required: true }, - seed_fabric_node_ids: strArr(), - managed_ne_ids: strArr(), - region_folder_id: str4(), - detail: str4("summary | full"), - sample: num4() - }, projectTopologyNeighbors, getClient, t), - tool2("netx__queryTopologyFabricNodes", "Fabric inventory: mode=summary|list|search (keyword/role/level/region/link_status).", { - mode: str4("summary | list | search"), - q: str4(), - keyword: str4(), - role: str4(), - level: str4(), - level_major: str4(), - region_folder_id: str4(), - link_status: str4(), - page: num4(), - page_size: num4(), - limit: num4(), - summary: bool4() - }, queryTopologyFabricNodes, getClient, t), - tool2("netx__classifyTopologyFabricNodes", "Classify fabric nodes: action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules.", { - action: { type: "string", required: true }, - pattern: str4(), - q: str4(), - match_field: str4(), - sample_limit: num4(), - fabric_node_ids: strArr(), - fabric_node_id: str4(), - node_id: str4(), - level: str4(), - role: str4(), - region_folder_id: str4(), - clear_region: bool4(), - dry_run: bool4(), - kind: str4(), - page: num4(), - page_size: num4(), - skip_manual: bool4(), - overwrite_manual: bool4(), - fill_empty_only: bool4() - }, classifyTopologyFabricNodes, getClient, t), - tool2("netx__queryTopologyNeighborhood", "Neighborhood around a fabric node (depth 1–3) with compact links[].", { - node_id: { type: "string", required: true }, - depth: num4(), - layer: str4() - }, queryTopologyNeighborhood, getClient, t), - tool2("netx__queryTopologyEdges", "Fabric adjacency links[] (detail=adjacency) or port rows (detail=ports).", { - node_id: str4(), - keyword: str4(), - layer: str4(), - status: str4(), - source: str4("lldp | ume | manual"), - detail: str4("adjacency | ports"), - page: num4(), - page_size: num4() - }, queryTopologyEdges, getClient, t), - tool2("netx__suggestSinkHubs", "Rank hub territories on a source view for non-dual move_nodes(park) batches (degree stand-in in DSH).", { - source_view_id: str4(), - view_id: str4(), - pick: num4(), - exclude_portal_ids: strArr(), - exclude_fabric_node_ids: strArr() - }, suggestSinkHubs, getClient, t), - tool2("netx__analyzeTopologyViewLayout", "Basic layout QA for a view (bbox/hubs). Full dual_unit/crossing scores need netx-topology MCP.", { - view_id: str4(), - folder_id: str4(), - detail: str4("summary | structure | hotspots | blocks | both"), - score_profile: str4(), - sight_limit: num4(), - max_views: num4(), - min_nodes: num4(), - max_nodes: num4(), - with_meta: bool4() - }, analyzeTopologyViewLayout, getClient, t), - tool2("netx__sinkTopologyDualUnits", "Drain dual_unit eyes from source to sink (requires netx-topology MCP layout engine in DSH).", { - source_view_id: str4(), - sink_view_id: str4(), - max_units: num4(), - min_nodes: num4(), - max_nodes: num4(), - max_batch_nodes: num4(), - layout_batch: bool4(), - until_empty: bool4(), - dry_run: bool4() - }, sinkTopologyDualUnits, getClient, t), - tool2("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: str4(), - action: str4(), - source_view_id: str4(), - recipe: str4(), - preset: str4(), - mode: str4("preview | apply"), - tune: bool4(), - params: anyObj("Action-specific options (move_nodes / recipes / polish)."), - catalog: bool4(), - fabric_node_ids: strArr(), - park: bool4(), - copy_positions: bool4() - }, layoutTopologyView, getClient, Math.max(t, 180000)) - ]; - const selected = catalog.filter((entry) => allow.has(entry.name)); - const names = selected.map((entry) => entry.name); const disposers = []; + const names = []; try { - for (const entry of selected) - disposers.push(ctx.tools.register(entry)); - publishToolMountStatus({ - id, - version: packageVersion(), - plane: options.plane, - groups: [...enabledGroups], - names, - count: names.length, - at: new Date().toISOString(), - source: options.source ?? options.plane - }); + for (const tool2 of enabled) { + const definition = defineRemoteTool(tool2, t); + disposers.push(ctx.tools.register(definition)); + names.push(publicToolName(tool2.name)); + } } catch (error) { - for (const dispose of disposers.reverse()) + for (const dispose of disposers.splice(0)) dispose(); publishToolMountStatus({ id, version: packageVersion(), plane: options.plane, - groups: [...enabledGroups], + groups, names: [], count: 0, at: new Date().toISOString(), - error: error instanceof Error ? error.message : String(error), + error: String(error), source: options.source ?? options.plane }); throw error; } + publishToolMountStatus({ + id, + version: packageVersion(), + plane: options.plane, + groups, + names, + count: names.length, + at: new Date().toISOString(), + source: options.source ?? options.plane + }); return () => { for (const dispose of disposers) dispose(); @@ -3653,6 +1956,16 @@ function packageVersion2() { return "0"; } } +function disabledByGroupFor(groupIds, connection) { + const catalog = getMcpCatalogTools(); + const out = {}; + for (const groupId of groupIds) { + const names = disabledPublicNames(catalog, [groupId], connection.disabledTools, connection.readOnlyOnly); + if (names.length > 0) + out[groupId] = names; + } + return out; +} function applyGroupToolsPlugin(ctx, options) { let unregisterTools; let unregisterKbLocal; @@ -3678,9 +1991,10 @@ function applyGroupToolsPlugin(ctx, options) { unregisterTools = registerNetxTools(ctx, connection, { plane: "preset", forceGroups: enabled, - source: options.name + source: options.name, + presetId: presetId() }); - ctx.logger.info("%s: groups=[%s] → %s tokenConfigured=%s live=%s", options.name, enabled.join(",") || "(none)", connection.apiUrl, connection.token.trim().length > 0, getNetxConnection() !== undefined); + ctx.logger.info("%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({ id: mountId, @@ -3708,6 +2022,9 @@ function applyGroupToolsPlugin(ctx, options) { remountTools(); remountKbLocal(); }); + const stopCatalogWatch = watchMcpCatalog(() => { + remountTools(); + }); const stopKbToolWatch = watchKbContext(() => { remountKbLocal(); }); @@ -3737,8 +2054,13 @@ function applyGroupToolsPlugin(ctx, options) { skillAbort = new AbortController; unregisterSkills?.(); unregisterSkills = undefined; + const connection = requireNetxConnection(); const enabled = resolveGroups(); - registerGroupSkills(skillsCtx, enabled, options.name, skillAbort.signal).then((dispose) => { + registerGroupSkills(skillsCtx, enabled, options.name, { + signal: skillAbort.signal, + disabledByGroup: disabledByGroupFor(enabled, connection), + lang: connection.lang + }).then((dispose) => { if (gen !== skillGeneration) { dispose(); return; @@ -3783,6 +2105,9 @@ function applyGroupToolsPlugin(ctx, options) { remountKbSkill(); remountKbPack(); }); + const stopSkillCatalogWatch = watchMcpCatalog(() => { + remountSkills(); + }); const stopKbWatch = watchKbContext(() => { remountKbSkill(); remountKbPack(); @@ -3796,6 +2121,7 @@ function applyGroupToolsPlugin(ctx, options) { skillAbort.abort(); packAbort.abort(); stopSkillWatch(); + stopSkillCatalogWatch(); stopKbWatch(); unregisterSkills?.(); unregisterSkills = undefined; @@ -3808,6 +2134,7 @@ function applyGroupToolsPlugin(ctx, options) { ctx.effect(() => () => { clearInterval(pollTimer); stopToolWatch(); + stopCatalogWatch(); stopKbToolWatch(); unregisterTools?.(); unregisterTools = undefined; diff --git a/lib/agent-tools-common.js b/lib/agent-tools-common.js index bbdb2d9..4b147e9 100644 --- a/lib/agent-tools-common.js +++ b/lib/agent-tools-common.js @@ -21,52 +21,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", @@ -101,14 +55,6 @@ function groupsForPlane(groups, plane, only) { const allow = new Set(only); return enabled.filter((id) => allow.has(id)); } -function toolNamesForGroups(groupIds) { - const out = new Set; - for (const id of groupIds) { - for (const name of TOOLS_BY_GROUP[id] ?? []) - out.add(name); - } - return out; -} function parsePresetIds(value) { const current = readConfigValue(value); if (typeof current !== "string") @@ -122,6 +68,234 @@ function groupsForPreset(connection, presetId, only) { return CAPABILITY_GROUP_IDS.filter((id) => (!only?.length || only.includes(id)) && !policy[id].public && (presetId === "netxops" ? policy[id].inPreset : policy[id].presets?.includes(presetId))); } +// src/netx/mcp-types.ts +var NETX_TOOL_PREFIX = "netx__"; +function publicToolName(serverName) { + return `${NETX_TOOL_PREFIX}${serverName}`; +} + +// src/netx/mcp-catalog.ts +function disabledSet(disabledTools) { + const out = new Set; + for (const name of disabledTools ?? []) { + const trimmed = typeof name === "string" ? name.trim() : ""; + if (trimmed) + out.add(trimmed); + } + return out; +} +function filterCatalogTools(catalog, enabledGroups, policy) { + const allowGroups = new Set(enabledGroups); + const disabled = disabledSet(policy.disabledTools); + const readOnlyOnly = policy.readOnlyOnly === true; + const out = []; + 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; +} +function resolveEnabledTools(catalog, options) { + 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); +} +function disabledPublicNames(catalog, enabledGroups, disabledTools, readOnlyOnly) { + const allowGroups = new Set(enabledGroups); + const disabled = disabledSet(disabledTools); + const names = []; + 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(); +} + +// src/netx/mcp-remote.ts +var PROTOCOL_VERSION = "2025-03-26"; +var CLIENT_INFO = { name: "dsh-netxops", version: "0.1.71" }; + +class McpRemoteError extends Error { + status; + constructor(message, status) { + super(message); + this.name = "McpRemoteError"; + this.status = status; + } +} +function mcpEndpointUrl(apiUrl) { + const trimmed = apiUrl.trim().replace(/\/$/, ""); + if (!trimmed) + return ""; + return `${trimmed}/mcp`; +} +function parseMcpResponseBody(raw) { + 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 = []; + 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 {} + } + } + throw new McpRemoteError("mcp response was not JSON"); +} +function asRpcResult(payload) { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + throw new McpRemoteError("invalid JSON-RPC envelope from /mcp"); + } + const row = payload; + if (row.error && typeof row.error === "object" && !Array.isArray(row.error)) { + const err = row.error; + 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.result; +} +var nextId = 1; +async function postRpc(options, method, params, notification = false) { + const headers = { + "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 : 60000; + 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 = () => { + abort.abort(); + }; + options.signal?.addEventListener("abort", onOuterAbort, { once: true }); + let 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; + } + return asRpcResult(parseMcpResponseBody(responseText)); +} +async function callMcpTool(options, name, args) { + 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 + }); + const content = Array.isArray(result.content) ? result.content : []; + return { + content, + ...result.structuredContent !== undefined ? { structuredContent: result.structuredContent } : {}, + ...result.isError === true ? { isError: true } : {} + }; +} +function mcpContentText(content, fallback = "") { + const parts = []; + for (const block of content) { + if (!block || typeof block !== "object" || Array.isArray(block)) + continue; + const row = block; + if (row.type === "text" && typeof row.text === "string") + parts.push(row.text); + } + return parts.join(` +`) || fallback; +} + +// src/netx/mcp-store.ts +var STORE_KEY = Symbol.for("dsh-netxops.mcp-catalog-store"); +var EMPTY = Object.freeze({ + phase: "idle", + mcpUrl: "", + tools: Object.freeze([]), + error: "", + listedAt: null, + fromCache: false +}); +function store() { + const root = globalThis; + let current = root[STORE_KEY]; + if (current === undefined) { + current = { state: EMPTY, listeners: new Set, generation: 0 }; + root[STORE_KEY] = current; + } + return current; +} +function getMcpCatalogTools() { + return store().state.tools; +} +function watchMcpCatalog(listener) { + const state = store(); + state.listeners.add(listener); + return () => { + state.listeners.delete(listener); + }; +} + // src/netx/group-skills.ts import { existsSync } from "node:fs"; import { readdir, readFile, stat } from "node:fs/promises"; @@ -271,7 +445,24 @@ async function loadGroupSkills(skillsRoot, groupId) { } return { skills, skipped }; } -async function registerGroupSkills(ctx, groupIds, providerLabel, signal) { +function disabledFootnote(names, lang) { + if (names.length === 0) + return ""; + const list = names.join(", "); + const en = (lang ?? "").trim().toLowerCase().startsWith("en"); + return en ? ` + +## Disabled tools +The following tools are disabled: ${list}. Do not call them. +` : ` + +## 已禁用工具 +以下工具已禁用:${list},不要调用。 +`; +} +async function registerGroupSkills(ctx, groupIds, providerLabel, signalOrOptions) { + const options = signalOrOptions instanceof AbortSignal || signalOrOptions === undefined ? { signal: signalOrOptions } : signalOrOptions; + const signal = options.signal; const skillsApi = ctx.skills; if (!skillsApi || typeof skillsApi.register !== "function") { return () => {}; @@ -293,15 +484,17 @@ async function registerGroupSkills(ctx, groupIds, providerLabel, signal) { if (skills.length === 0) { log?.warn?.("%s: no skills loaded for group=%s from %s", providerLabel, groupId, 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} + +## DeepSeek Harness REST capabilities +Use only tools present in this session. Dual-unit/orbit/polish recipes require server-side topology tools. Use only tools present in this session. +` : skill.content; disposers.push(skillsApi.register({ name: skill.name, description: skill.description, - content: groupId === "topology" ? `${skill.content} - -## DeepSeek Harness REST capabilities -Use 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. -` : skill.content, + content: `${base}${footnote}`, path: skill.path, resourceBase: { kind: "directory", path: skill.directory }, provider: providerLabel, @@ -1119,18 +1312,18 @@ function searchKb(snapshot, args) { } // src/netx/kb-runtime.ts -var STORE_KEY = Symbol.for("dsh-netxops.kb-store"); -function store() { +var STORE_KEY2 = Symbol.for("dsh-netxops.kb-store"); +function store2() { const root = globalThis; - let current = root[STORE_KEY]; + let current = root[STORE_KEY2]; if (current === undefined) { current = { snapshot: unconfiguredKbSnapshot(), listeners: new Set }; - root[STORE_KEY] = current; + root[STORE_KEY2] = current; } return current; } function getKbContext() { - const snap = store().snapshot; + const snap = store2().snapshot; return { ...snap, content: { ...snap.content }, @@ -1138,7 +1331,7 @@ function getKbContext() { }; } function watchKbContext(listener) { - const state = store(); + const state = store2(); state.listeners.add(listener); return () => { state.listeners.delete(listener); @@ -1162,26 +1355,18 @@ function toLosslessJson(value) { return { ok: false, error: "tool_result_not_json", detail: detail.slice(0, 400) }; } } -function omitUndefined(record) { - const out = {}; - for (const [key, value] of Object.entries(record)) { - if (value !== undefined) - out[key] = value; - } - return out; -} // src/netx/tool-mount-status.ts import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs"; import { homedir } from "node:os"; import { dirname as dirname4, join as join7 } from "node:path"; var KEY = Symbol.for("dsh-netxops.mount-inventory"); -function store2() { +function store3() { const root = globalThis; return root[KEY] ??= { mounts: new Map, contexts: new WeakMap, sequence: 0 }; } function getToolMountId(ctx, source) { - const state = store2(); + const state = store3(); let id = state.contexts.get(ctx); if (id === undefined) { id = ++state.sequence; @@ -1190,7 +1375,7 @@ function getToolMountId(ctx, source) { return `${source}:${id}`; } function getToolMountStatuses() { - return [...store2().mounts.values()].map((row) => ({ ...row, groups: [...row.groups], names: [...row.names] })); + return [...store3().mounts.values()].map((row) => ({ ...row, groups: [...row.groups], names: [...row.names] })); } function getToolMountStatus() { const rows = getToolMountStatuses(); @@ -1205,11 +1390,11 @@ function persist() { } catch {} } function publishToolMountStatus(next) { - store2().mounts.set(next.id ?? next.source ?? next.plane, { ...next, groups: [...next.groups], names: [...next.names] }); + store3().mounts.set(next.id ?? next.source ?? next.plane, { ...next, groups: [...next.groups], names: [...next.names] }); persist(); } function removeToolMountStatus(id) { - store2().mounts.delete(id); + store3().mounts.delete(id); persist(); } @@ -1571,139 +1756,20 @@ function normalizeApiUrl(raw) { const search = parsed.search && parsed.search !== "?" ? parsed.search : ""; return { ok: true, url: `${parsed.origin}${path}${search}` }; } -var PROTOCOL_KEY_ZH_TO_EN = { - 其他: "Other", - 时钟: "Clock", - "OTN/光": "OTN/Optical", - 电源: "Power" -}; -function asLang(value) { - return typeof value === "string" ? value : ""; -} -function localizePayload(lang, data) { - 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; - const key = typeof rec.key === "string" ? rec.key : ""; - const mapped = PROTOCOL_KEY_ZH_TO_EN[key]; - if (mapped !== undefined) - rec.key = mapped; - } - return data; -} -function encodeQuery(params) { - const sp = new URLSearchParams; - for (const [key, value] of Object.entries(params)) { - sp.set(key, String(value)); - } - const q = sp.toString(); - return q.length > 0 ? `?${q}` : ""; -} -function createNetxClient(connection) { - const langParams = () => { - const lang = asLang(connection.lang).trim().toLowerCase(); - if (lang.startsWith("en")) - return { lang: "en" }; - return {}; - }; - async function request(method, path, options = {}) { - 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 = { ...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 = { - accept: "application/json", - authorization: `Bearer ${token}` - }; - if (options.body !== undefined) - headers["content-type"] = "application/json"; - const init = { - 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) : {}; - if (typeof data === "object" && data !== null && !Array.isArray(data)) { - return { ok: true, data: localizePayload(connection.lang, data) }; - } - return { ok: true, data: { raw: data } }; - } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - return { ok: false, error: "netx_request_failed", detail: detail.slice(0, 800) }; - } finally { - clearTimeout(timer); - options.signal?.removeEventListener("abort", onOuterAbort); - } - } - return { - get(path, params, signal, timeoutMs) { - return request("GET", path, { params, signal, timeoutMs }); - }, - post(path, body, signal, timeoutMs) { - return request("POST", path, { body, signal, timeoutMs }); - }, - patch(path, body, signal, timeoutMs) { - return request("PATCH", path, { body, signal, timeoutMs }); - } - }; -} -function quoteNeId(neId) { - return encodeURIComponent(neId.trim()); -} // src/netx/runtime.ts -var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store"); -function store3() { +var STORE_KEY3 = Symbol.for("dsh-netxops.connection-store"); +function store4() { const root = globalThis; - let current = root[STORE_KEY2]; + let current = root[STORE_KEY3]; if (current === undefined) { current = { connection: undefined, listeners: new Set }; - root[STORE_KEY2] = current; + root[STORE_KEY3] = current; } return current; } function getNetxConnection() { - return store3().connection; + return store4().connection; } function provisionalNetxConnection() { const envUrl = process.env.NETX_API_URL?.trim() ?? ""; @@ -1715,6 +1781,8 @@ function provisionalNetxConnection() { lang: typeof process.env.NETX_LANG === "string" && process.env.NETX_LANG.trim() ? process.env.NETX_LANG.trim() : "zh", toolCallTimeoutMs: 120000, groups: DEFAULT_CAPABILITY_GROUPS, + disabledTools: [], + readOnlyOnly: false, groupKbInPreset: true, groupKbPublic: false }; @@ -1723,7 +1791,7 @@ function requireNetxConnection() { return getNetxConnection() ?? provisionalNetxConnection(); } function watchNetxConnection(listener) { - const state = store3(); + const state = store4(); state.listeners.add(listener); return () => { state.listeners.delete(listener); @@ -1735,1443 +1803,6 @@ import { readFileSync as readFileSync5 } from "node:fs"; import { dirname as dirname5, join as join9 } from "node:path"; import { fileURLToPath as fileURLToPath2 } from "node:url"; import { defineTool as defineTool2 } from "@deepseek-ai/dsh-tools"; - -// src/netx/handlers.ts -var EXEC_MAX_COMMANDS_CAP = 50; -var EXEC_MAX_COMMANDS_DEFAULT = 5; -function execMaxCommands() { - 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() { - 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) { - if (raw === undefined || raw === null) - return; - if (typeof raw === "boolean") - return raw; - const text = String(raw).trim().toLowerCase(); - if (!text) - return; - if (["1", "true", "yes", "on"].includes(text)) - return true; - if (["0", "false", "no", "off"].includes(text)) - return false; - return; -} -function countExecNeTargets(args) { - let n = 0; - for (const key of ["ne_ids", "nms_ne_ids", "ume_ne_ids"]) { - 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 (str2(args, "ne_id").trim() || nmsOrUme(args, "nms_ne_id", "ume_ne_id")) - return 1; - } - return n; -} -function shouldRunExecAsync(args) { - 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; -} -var UME_RAW_FIELD_PRESETS = { - brief: [ - "alarm_alarm_key", - "alarm_host_name", - "alarm_perceived_severity", - "alarm_event_type", - "alarm_last_seen_at", - "ne_host_name", - "ne_user_label", - "ne_ne_name", - "ne_ip_address", - "ne_exists" - ], - evidence: [ - "alarm_alarm_key", - "alarm_host_name", - "alarm_object_name", - "alarm_event_type", - "alarm_native_probable_cause", - "alarm_perceived_severity", - "alarm_is_cleared", - "alarm_time_created", - "alarm_last_seen_at", - "ne_host_name", - "ne_user_label", - "ne_ne_name", - "ne_ip_address", - "ne_connection_status", - "ne_exists" - ], - ne_debug: [ - "alarm_alarm_key", - "alarm_ne_id", - "alarm_perceived_severity", - "alarm_last_seen_at", - "ne_user_label", - "ne_ne_name", - "ne_ip_address", - "ne_ipv6_address", - "ne_device_level", - "ne_host_name", - "ne_connection_status", - "ne_admin_status", - "ne_address_type", - "ne_maintain_status", - "ne_exists" - ] -}; -function asRecord(value) { - return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; -} -function str2(args, key, fallback = "") { - const v = args[key]; - if (typeof v === "string") - return v; - if (typeof v === "number" || typeof v === "boolean") - return String(v); - return fallback; -} -function nmsOrUme(args, nmsKey, umeKey) { - return str2(args, nmsKey).trim() || str2(args, umeKey).trim(); -} -function nmsOrUmeList(args, nmsKey, umeKey) { - const primary = strList(args, nmsKey); - return primary.length > 0 ? primary : strList(args, umeKey); -} -function num2(args, key) { - const v = args[key]; - return typeof v === "number" && Number.isFinite(v) ? v : undefined; -} -function bool2(args, key) { - const v = args[key]; - return typeof v === "boolean" ? v : undefined; -} -function strList(args, key) { - const v = args[key]; - if (!Array.isArray(v)) - return []; - return v.map((x) => String(x).trim()).filter((x) => x.length > 0); -} -function clampInt(value, fallback, min, max) { - const n = value === undefined ? fallback : Math.trunc(value); - return Math.max(min, Math.min(max, n)); -} -function putStr(params, args, keys) { - for (const key of keys) { - const v = str2(args, key).trim(); - if (v) - params[key] = v; - } -} -async function queryUmeAlarms(client, args, signal) { - let page = clampInt(num2(args, "page"), 1, 1, 2); - const pageSize = clampInt(num2(args, "page_size"), 50, 1, 500); - const params = { page, page_size: pageSize }; - putStr(params, args, ["severity", "ne_id", "host_name", "time_from", "time_to"]); - const keyword = str2(args, "keyword").trim(); - const neName = str2(args, "ne_name").trim(); - if (keyword) - params.keyword = keyword; - else if (neName) - params.keyword = neName; - return client.get("/v1/ume/alarms", params, signal); -} -async function aggregateUmeAlarmsRaw(client, args, signal) { - const params = {}; - putStr(params, args, [ - "group_by", - "group_by2", - "severity", - "is_cleared", - "ne_id", - "event_type", - "keyword", - "time_from", - "time_to", - "limit" - ]); - if ("exclude_missing_host" in args) { - const flag = bool2(args, "exclude_missing_host"); - if (flag !== undefined) - params.exclude_missing_host = flag; - } - return client.get("/v1/ume/alarms/aggregate/raw", params, signal); -} -async function aggregateUmeAlarms(client, args, signal) { - if (str2(args, "group_by").trim()) - return aggregateUmeAlarmsRaw(client, args, signal); - const topNe = clampInt(num2(args, "top_ne"), 50, 0, 500); - const params = { top_ne: topNe }; - if ("exclude_missing_host" in args) { - const flag = bool2(args, "exclude_missing_host"); - if (flag !== undefined) - params.exclude_missing_host = flag; - } - putStr(params, args, ["severity", "time_from", "time_to"]); - return client.get("/v1/ume/alarms/aggregate", params, signal); -} -async function runUmeDiagnostics(client, _args, signal) { - return client.get("/v1/ume/diagnostics", undefined, signal); -} -async function queryUmeNeInventory(client, args, signal) { - const params = { - page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num2(args, "page_size"), 50, 1, 500) - }; - putStr(params, args, ["keyword"]); - return client.get("/v1/ume/inventory/ne", params, signal); -} -async function getUmeNe(client, args, signal) { - const neId = str2(args, "ne_id").trim(); - if (!neId) - return { ok: false, error: "ne_id_required", error_code: "ne_id_required" }; - return client.get(`/v1/ume/inventory/ne/${quoteNeId(neId)}`, undefined, signal); -} -async function queryUmeAlarmsRaw(client, args, signal) { - const params = { - page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num2(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 = str2(args, "field_preset").trim().toLowerCase(); - fields = UME_RAW_FIELD_PRESETS[preset] ?? []; - } - if (fields.length > 0) - params.select_fields = fields.join(","); - return client.get("/v1/ume/alarms/raw", params, signal); -} -async function listUmeAlarmFields(client, _args, signal) { - return client.get("/v1/ume/alarms/fields", undefined, signal); -} -async function sqlQueryUme(client, args, signal) { - const sql = str2(args, "sql").trim(); - if (!sql) - return { ok: false, error: "sql_required" }; - const limit = clampInt(num2(args, "limit"), 200, 1, 2000); - const statementTimeoutMs = clampInt(num2(args, "statement_timeout_ms"), 0, 0, 30000); - return client.post("/v1/sql/ume_query", { - sql, - limit, - statement_timeout_ms: statementTimeoutMs - }, signal, 60000); -} -async function listManagedNe(client, args, signal) { - const keyword = str2(args, "keyword").trim(); - const vendor = str2(args, "vendor").trim(); - const connectStatus = str2(args, "connect_status").trim(); - if (!(keyword || vendor || connectStatus)) { - return { ok: false, error: "managed_ne_filter_required", error_code: "managed_ne_filter_required" }; - } - if (keyword && keyword.length < 2) { - return { ok: false, error: "managed_ne_keyword_too_short", error_code: "managed_ne_keyword_too_short" }; - } - const params = { - page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num2(args, "page_size"), 20, 1, 100) - }; - if (keyword) - params.keyword = keyword; - if (vendor) - params.vendor = vendor; - if (connectStatus) - params.connect_status = connectStatus; - return client.get("/v1/managed-ne", params, signal); -} -async function getManagedNe(client, args, signal) { - const neId = (str2(args, "ne_id") || str2(args, "managed_ne_id") || str2(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: "" } - }; - } - const out = await client.get(`/v1/managed-ne/${quoteNeId(neId)}`, undefined, signal); - if (out.ok === false) { - const detail = `${str2(out, "detail")}${str2(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; -} -async function execManagedNe(client, args, signal) { - 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 body2 = {}; - if (Array.isArray(targetsRaw) && targetsRaw.length > 0) { - const cleaned = []; - for (const t of targetsRaw) { - if (typeof t !== "object" || t === null || Array.isArray(t)) - continue; - const row = t; - const item = {}; - const neId2 = str2(row, "ne_id").trim(); - const umeNeId2 = nmsOrUme(row, "nms_ne_id", "ume_ne_id"); - if (neId2) - item.ne_id = neId2; - if (umeNeId2) - item.ume_ne_id = umeNeId2; - const cmds = Array.isArray(row.commands) ? row.commands.map((c) => String(c).trim()).filter((c) => c.length > 0) : []; - if (cmds.length > 0) - item.commands = cmds; - if (Object.keys(item).length > 0) - cleaned.push(item); - } - body2.targets = cleaned; - } - if (neIds.length > 0) - body2.ne_ids = neIds; - if (umeNeIds.length > 0) - body2.ume_ne_ids = umeNeIds; - if (sharedCommands.length > 0) { - if (sharedCommands.length > maxCmds) { - return { ok: false, error: "too_many_commands", error_code: "too_many_commands" }; - } - body2.commands = sharedCommands; - } - body2.read_timeout_sec = clampInt(num2(args, "read_timeout_sec"), 60, 10, 120); - const concurrency = num2(args, "concurrency"); - if (concurrency !== undefined) - body2.concurrency = clampInt(concurrency, 4, 1, 8); - if (wantAsync) { - const out3 = await client.post("/v1/managed-ne/exec-jobs", body2, signal, 60000); - if (out3.ok !== true) - return out3; - const data3 = asRecord(out3.data); - return Object.keys(data3).length > 0 ? data3 : out3; - } - const out2 = await client.post("/v1/managed-ne/exec-batch", body2, signal, 600000); - if (out2.ok !== true) - return out2; - const data2 = asRecord(out2.data); - if (data2.ok === false) { - return { ok: false, data: data2, error: str2(data2, "error", "exec_batch_failed") }; - } - return { ok: true, data: data2 }; - } - const neId = str2(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 = { - commands: sharedCommands, - read_timeout_sec: clampInt(num2(args, "read_timeout_sec"), 60, 10, 120) - }; - if (neId) - body.ne_id = neId; - if (umeNeId) - body.ume_ne_id = umeNeId; - if (wantAsync) { - const out2 = await client.post("/v1/managed-ne/exec-jobs", body, signal, 60000); - if (out2.ok !== true) - return out2; - const data2 = asRecord(out2.data); - return Object.keys(data2).length > 0 ? data2 : out2; - } - const out = await client.post("/v1/managed-ne/exec", body, signal, 300000); - if (out.ok !== true) - return out; - const data = asRecord(out.data); - if (data.ok === false) { - return { ok: false, data, error: str2(data, "error", "exec_failed") }; - } - return { ok: true, data }; -} -async function getNeExecJob(client, args, signal) { - const jobId = str2(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); -} -async function listCliTargets(client, args, signal) { - const params = { - page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num2(args, "page_size"), 50, 1, 500) - }; - putStr(params, args, ["source", "keyword"]); - return client.get("/v1/cli/targets", params, signal); -} -async function findTopologyPaths(client, args, signal) { - const fromUid = nmsOrUme(args, "from_nms_ne_id", "from_ume_ne_id"); - const fromMid = str2(args, "from_managed_ne_id").trim(); - const toUid = nmsOrUme(args, "to_nms_ne_id", "to_ume_ne_id"); - const toMid = str2(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 = str2(args, "detail", "summary").trim().toLowerCase() || "summary"; - if (detail !== "summary" && detail !== "full") - detail = "summary"; - const body = { - max_paths: clampInt(num2(args, "max_paths"), 3, 1, 10), - max_hops: clampInt(num2(args, "max_hops"), 6, 1, 12), - layer: str2(args, "layer", "physical").trim() || "physical", - detail - }; - if (fromUid) - body.from_ume_ne_id = fromUid; - else - body.from_managed_ne_id = fromMid; - if (toUid) - body.to_ume_ne_id = toUid; - else - body.to_managed_ne_id = toMid; - return client.post("/v1/topology/fabric/paths", body, signal, 30000); -} -function taskBriefNe(brief) { - 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) { - 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, q) { - 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); -} -async function listBizMonitors(client, args, signal) { - const kindRaw = str2(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 = str2(args, "purpose").trim(); - const status = str2(args, "status").trim().toLowerCase(); - const q = str2(args, "q").trim().toLowerCase(); - const limit = clampInt(num2(args, "limit") ?? 100, 100, 1, 500); - const wantProjects = kind === "all" || kind === "projects"; - const wantTasks = kind === "all" || kind === "tasks"; - const fetches = []; - if (wantProjects) - fetches.push(client.get("/v1/biz-migration/projects", {}, signal)); - else - fetches.push(Promise.resolve({ ok: true, data: { items: [] } })); - if (wantTasks) { - const params = {}; - 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 : []; - const taskItems = Array.isArray(asRecord(taskRes.data).items) ? asRecord(taskRes.data).items : []; - 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) { - 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, taskId = "") { - 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) { - 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 - }); -} -async function listBizMonitorBatches(client, args, signal) { - const projectId = str2(args, "project_id").trim(); - const taskId = str2(args, "task_id").trim(); - const batchId = str2(args, "batch_id").trim(); - const kindRaw = str2(args, "kind", "auto").trim().toLowerCase() || "auto"; - const includeRuns = args.include_runs === true || args.include_runs === "true" || args.include_runs === 1; - const status = str2(args, "status").trim().toLowerCase(); - const limit = clampInt(num2(args, "limit") ?? 50, 50, 1, 200); - const runsLimit = clampInt(num2(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 = []; - let collectBatches = []; - let runs = []; - 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 : []; - 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 : []; - 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 : []; - 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: [] }; - const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : []; - 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) { - 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)." - }) - }; -} -async function getBizMonitorContext(client, args, signal) { - const projectId = str2(args, "project_id").trim(); - const taskId = str2(args, "task_id").trim(); - if (!projectId && !taskId) { - return { ok: false, error: "project_id_or_task_id_required" }; - } - const params = {}; - if (projectId) - params.project_id = projectId; - if (taskId) - params.task_id = taskId; - return client.get("/v1/biz-migration/monitor-context", params, signal); -} -async function getBizMonitorBoard(client, args, signal) { - const batchId = str2(args, "batch_id").trim(); - if (!batchId) - return { ok: false, error: "batch_id_required" }; - const params = {}; - const runId = str2(args, "run_id").trim(); - if (runId) - params.run_id = runId; - return client.get(`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/board`, params, signal); -} -async function listBizMonitorReds(client, args, signal) { - const projectId = str2(args, "project_id").trim(); - if (!projectId) - return { ok: false, error: "project_id_required" }; - const params = {}; - putStr(params, args, ["status"]); - const limit = num2(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); -} -async function getBizMonitorDiffs(client, args, signal) { - const runId = str2(args, "run_id").trim(); - if (!runId) - return { ok: false, error: "run_id_required" }; - const params = {}; - putStr(params, args, ["metric_id", "sheet_id", "verdict", "color", "q"]); - const limit = num2(args, "limit"); - if (limit !== undefined) - params.limit = clampInt(limit, 200, 1, 500); - const offset = num2(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); -} -async function getBizCollectBatch(client, args, signal) { - const batchId = str2(args, "batch_id").trim(); - if (!batchId) - return { ok: false, error: "batch_id_required" }; - return client.get(`/v1/biz-state/batches/${encodeURIComponent(batchId)}`, {}, signal); -} -async function getBizCollectCommandRaw(client, args, signal) { - const batchId = str2(args, "batch_id").trim(); - const commandId = str2(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); -} - -// src/netx/topology-handlers.ts -function asRecord2(value) { - return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; -} -function str3(args, key, fallback = "") { - const v = args[key]; - if (typeof v === "string") - return v; - if (typeof v === "number" || typeof v === "boolean") - return String(v); - return fallback; -} -function num3(args, key) { - const v = args[key]; - return typeof v === "number" && Number.isFinite(v) ? v : undefined; -} -function bool3(args, key) { - const v = args[key]; - return typeof v === "boolean" ? v : undefined; -} -function strList2(args, key) { - const v = args[key]; - if (!Array.isArray(v)) - return []; - return v.map((x) => String(x).trim()).filter((x) => x.length > 0); -} -function clampInt2(value, fallback, min, max) { - const n = value === undefined ? fallback : Math.trunc(value); - return Math.max(min, Math.min(max, n)); -} -function unwrap(out) { - if (out.ok !== true) - return out; - const data = asRecord2(out.data); - if (Object.keys(data).length === 0) - return { ok: true, data: out.data }; - return { ok: true, ...data, data }; -} -function filterFields(args) { - const out = {}; - for (const key of ["keyword", "role", "vendor", "link_status"]) { - const v = str3(args, key).trim(); - if (v) - out[key] = v; - } - return out; -} -function edgeEndpoints(edge) { - const a = str3(edge, "a_node_id") || str3(edge, "a"); - const b = str3(edge, "b_node_id") || str3(edge, "b"); - return [a.trim(), b.trim()]; -} -function collapseEdgesToLinks(edges, includeNames = false) { - const buckets = new Map; - for (const raw of edges) { - if (!raw || typeof raw !== "object" || Array.isArray(raw)) - continue; - const e = raw; - 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}\x00${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 = str3(e, "a_name"); - row.b_name = str3(e, "b_name"); - } else { - row.a_name = str3(e, "b_name"); - row.b_name = str3(e, "a_name"); - } - } - buckets.set(key, row); - } - return [...buckets.values()]; -} -function summarizeViewGraph(graph, sample = 20) { - 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 = asRecord2(n); - return { - id: str3(row, "id") || str3(row, "fabric_node_id"), - name: str3(row, "name"), - x: row.x, - y: row.y, - level: row.level, - role: row.role - }; - }); - return { - ok: true, - view_id: str3(graph, "id") || str3(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) { - 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)." - }; -} -async function getTopologyTree(client, args, signal) { - const out = unwrap(await client.get("/v1/topology/tree", undefined, signal)); - if (out.ok !== true) - return out; - const compact = bool3(args, "compact") !== false; - const maxDepth = num3(args, "max_depth"); - if (!compact && maxDepth === undefined) - return out; - return { ...out, compact, max_depth: maxDepth ?? null }; -} -async function getTopologyView(client, args, signal) { - const viewId = str3(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, 120000)); - if (out.ok !== true) - return out; - const detail = str3(args, "detail", "summary").trim().toLowerCase() || "summary"; - if (detail === "full") - return { ...out, detail: "full" }; - return summarizeViewGraph(out, clampInt2(num3(args, "sample"), 20, 0, 200)); -} -async function createTopologyFolder(client, args, signal) { - const name = str3(args, "name").trim(); - if (!name) - return { ok: false, error: "name_required" }; - const body = { name }; - const parentId = str3(args, "parent_id").trim(); - if (parentId) - body.parent_id = parentId; - const locale = str3(args, "locale").trim(); - if (locale) - body.locale = locale; - if (num3(args, "sort_order") !== undefined) - body.sort_order = Math.trunc(num3(args, "sort_order")); - return unwrap(await client.post("/v1/topology/folders", body, signal)); -} -async function addTopologyViewNodes(client, args, signal) { - const viewId = str3(args, "view_id").trim(); - if (!viewId) - return { ok: false, error: "view_id_required" }; - if (strList2(args, "managed_ne_ids").length || strList2(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 = strList2(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 (num3(args, "max_nodes") !== undefined) { - const got = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal)); - if (got.ok === true) { - const data = asRecord2(got.data); - const filt = asRecord2(got.filter ?? data.filter); - const membership = asRecord2(filt.membership ?? {}); - membership.max_nodes = clampInt2(num3(args, "max_nodes"), 2000, 1, 2000); - filt.membership = membership; - await client.patch(`/v1/topology/views/${encodeURIComponent(viewId)}`, { filter: filt }, signal); - } - } - const layout = str3(args, "layout", "grid").trim() || "grid"; - const body = { - managed_ne_ids: [], - layout, - ...filters - }; - if (fabricIds.length) - body.fabric_node_ids = fabricIds; - if (num3(args, "limit") !== undefined) - body.limit = clampInt2(num3(args, "limit"), 500, 1, 2000); - if (num3(args, "offset") !== undefined) - body.offset = clampInt2(num3(args, "offset"), 0, 0, Number.MAX_SAFE_INTEGER); - return unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes`, body, signal, 180000)); -} -async function removeTopologyViewNodes(client, args, signal) { - const viewId = str3(args, "view_id").trim(); - if (!viewId) - return { ok: false, error: "view_id_required" }; - const body = { ...filterFields(args) }; - const fabricIds = strList2(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)); -} -async function copyTopologyViewNodes(client, args, signal) { - const sourceId = str3(args, "source_view_id").trim(); - const targetId = str3(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, 120000)); - if (source.ok !== true) - return source; - const nodes = Array.isArray(source.nodes) ? source.nodes : []; - const limit = num3(args, "limit"); - const selected = typeof limit === "number" ? nodes.slice(0, clampInt2(limit, nodes.length, 1, 2000)) : nodes; - const fabricIds = selected.map((n) => { - const row = asRecord2(n); - return str3(row, "id") || str3(row, "fabric_node_id"); - }).filter(Boolean); - if (bool3(args, "dry_run") === true) { - return { ok: true, dry_run: true, would_copy: fabricIds.length, source_view_id: sourceId, target_view_id: targetId }; - } - if (bool3(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, 180000)); - if (add.ok !== true) - return add; - if (bool3(args, "copy_positions") !== false) { - const ox = num3(args, "offset_x") ?? 0; - const oy = num3(args, "offset_y") ?? 0; - const positions = selected.map((n) => { - const row = asRecord2(n); - const id = str3(row, "id") || str3(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, 120000); - } - } - return { ok: true, added: fabricIds.length, source_view_id: sourceId, target_view_id: targetId, ...asRecord2(add) }; -} -async function updateTopologyViewPositions(client, args, signal) { - const viewId = str3(args, "view_id").trim(); - if (!viewId) - return { ok: false, error: "view_id_required" }; - const body = {}; - if (Array.isArray(args.positions)) - body.positions = args.positions; - const layout = str3(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"]) { - if (num3(args, key) !== undefined) - body[key] = num3(args, key); - } - const fabricIds = strList2(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, 120000)); -} -async function projectTopologyNeighbors(client, args, signal) { - const viewId = str3(args, "view_id").trim(); - if (!viewId) - return { ok: false, error: "view_id_required" }; - const body = {}; - const seeds = strList2(args, "seed_fabric_node_ids").length ? strList2(args, "seed_fabric_node_ids") : strList2(args, "fabric_node_ids"); - if (seeds.length) - body.seed_fabric_node_ids = seeds; - const mids = strList2(args, "managed_ne_ids"); - if (mids.length) - body.managed_ne_ids = mids; - const region = str3(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, 180000)); - if (out.ok !== true) - return out; - const detail = str3(args, "detail", "summary").trim().toLowerCase() || "summary"; - if (detail === "full" || detail === "raw" || detail === "graph") { - return { ...out, detail: "full", projected: true }; - } - return { ...summarizeViewGraph(out, clampInt2(num3(args, "sample"), 20, 0, 100)), projected: true, view_id: viewId }; -} -async function queryTopologyFabricNodes(client, args, signal) { - let mode = str3(args, "mode").trim().toLowerCase(); - const q = str3(args, "q").trim(); - const keyword = str3(args, "keyword").trim(); - if (!mode) { - if (bool3(args, "summary") === true) - mode = "summary"; - else if (q) - mode = "search"; - else - mode = "list"; - } - if (mode === "summary" || mode === "stats" || mode === "count") { - const out2 = unwrap(await client.get("/v1/topology/fabric/summary", undefined, signal)); - return out2.ok === true ? { ...out2, mode: "summary" } : out2; - } - 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: clampInt2(num3(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num3(args, "page_size") ?? num3(args, "limit"), 50, 1, 200) - }, signal)); - } - const params = { - page: clampInt2(num3(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num3(args, "page_size") ?? num3(args, "limit"), 50, 1, 500) - }; - for (const key of ["keyword", "role", "level", "level_major", "region_folder_id", "link_status"]) { - const v = str3(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; -} -async function classifyTopologyFabricNodes(client, args, signal) { - const action = str3(args, "action").trim().toLowerCase(); - if (!action) - return { ok: false, error: "action_required" }; - if (action === "match") { - const pattern = str3(args, "pattern").trim() || str3(args, "q").trim(); - if (!pattern) - return { ok: false, error: "pattern_required" }; - return unwrap(await client.post("/v1/topology/fabric/nodes/match", { - pattern, - match_field: str3(args, "match_field", "name").trim() || "name", - sample_limit: clampInt2(num3(args, "sample_limit"), 50, 1, 200) - }, signal)); - } - if (action === "tag") { - const body = { - fabric_node_ids: strList2(args, "fabric_node_ids"), - dry_run: bool3(args, "dry_run") === true - }; - const pattern = str3(args, "pattern").trim() || str3(args, "q").trim(); - if (pattern) - body.pattern = pattern; - if (args.level !== undefined) - body.level = args.level; - const role = str3(args, "role").trim(); - if (role) - body.role = role; - const region = str3(args, "region_folder_id").trim(); - if (region) - body.region_folder_id = region; - if (bool3(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 = str3(args, "fabric_node_id").trim() || str3(args, "node_id").trim(); - if (!nodeId) - return { ok: false, error: "fabric_node_id_required" }; - const body = {}; - if (args.level !== undefined) - body.level = args.level; - const role = str3(args, "role").trim(); - if (role) - body.role = role; - const region = str3(args, "region_folder_id").trim(); - if (region) - body.region_folder_id = region; - if (bool3(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: str3(args, "kind", "any").trim() || "any", - page: clampInt2(num3(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num3(args, "page_size") ?? num3(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 = {}; - if (bool3(args, "overwrite_manual") === true) - params.skip_manual = false; - else if (bool3(args, "skip_manual") !== undefined) - params.skip_manual = bool3(args, "skip_manual") !== false; - if (bool3(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 }; -} -async function queryTopologyNeighborhood(client, args, signal) { - const nodeId = str3(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: clampInt2(num3(args, "depth"), 1, 1, 3), - layer: str3(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) }; -} -async function queryTopologyEdges(client, args, signal) { - const params = { - page: clampInt2(num3(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num3(args, "page_size"), 100, 1, 500), - layer: str3(args, "layer", "physical").trim() || "physical" - }; - for (const key of ["node_id", "keyword", "status", "source"]) { - const v = str3(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 = str3(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 }; -} -async function suggestSinkHubs(client, args, signal) { - const viewId = str3(args, "source_view_id").trim() || str3(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, 120000)); - 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; - for (const raw of edges) { - const [a, b] = edgeEndpoints(asRecord2(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([ - ...strList2(args, "exclude_portal_ids"), - ...strList2(args, "exclude_fabric_node_ids") - ]); - const pick = clampInt2(num3(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 row2 = asRecord2(n); - return (str3(row2, "id") || str3(row2, "fabric_node_id")) === fabric_node_id; - }); - const row = asRecord2(node); - return { - fabric_node_id, - degree: deg, - name: str3(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." - }; -} -async function analyzeTopologyViewLayout(client, args, signal) { - const viewId = str3(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, 120000)); - 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 = asRecord2(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; - for (const raw of edges) { - const [a, b] = edgeEndpoints(asRecord2(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: str3(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." - }; -} -async function sinkTopologyDualUnits(_client, args) { - return { - ok: false, - error: "sink_dual_units_requires_netx_topology_mcp", - source_view_id: str3(args, "source_view_id"), - sink_view_id: str3(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." - }; -} -async function layoutTopologyView(client, args, signal) { - if (bool3(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 = str3(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 = str3(args, "view_id").trim(); - const fromId = str3(args, "source_view_id").trim(); - const ids = strList2(args, "fabric_node_ids"); - const params = asRecord2(args.params); - const fabricIds = ids.length ? ids : strList2(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 = bool3(params, "copy_positions") !== false && bool3(args, "copy_positions") !== false; - const removeFromSource = bool3(params, "remove_from_source") !== false; - const source = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(fromId)}`, undefined, signal, 120000)); - 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 = asRecord2(n); - const id = str3(row, "id") || str3(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, 180000)); - 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 = asRecord2(n); - return { - fabric_node_id: str3(row, "id") || str3(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, 120000); - } - 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: bool3(params, "park") === true || bool3(args, "park") === true - }; - } - return layoutRecipeUnavailable(action); -} - -// src/netx/tools.ts function packageVersion() { try { const here = dirname5(fileURLToPath2(import.meta.url)); @@ -3181,65 +1812,89 @@ function packageVersion() { return "0"; } } -var MUTATING_TOOLS = new Set([ - "netx__execManagedNe", - "netx__createTopologyFolder", - "netx__addTopologyViewNodes", - "netx__removeTopologyViewNodes", - "netx__copyTopologyViewNodes", - "netx__updateTopologyViewPositions", - "netx__projectTopologyNeighbors", - "netx__classifyTopologyFabricNodes", - "netx__layoutTopologyView" -]); -var str4 = (description) => ({ type: "string", ...description ? { description } : {} }); -var num4 = (description) => ({ type: "number", ...description ? { description } : {} }); -var bool4 = (description) => ({ type: "boolean", ...description ? { description } : {} }); -var strArr = (description) => ({ - type: "array", - items: { type: "string" }, - ...description ? { description } : {} -}); -var anyObj = (description) => ({ - type: "object", - additionalProperties: true, - ...description ? { description } : {} -}); -function renderJson2(_args, value) { - return [{ type: "text", text: JSON.stringify(value, null, 0) }]; +function renderMcpResult(_args, value) { + if (value && typeof value === "object" && !Array.isArray(value)) { + const row = value; + if (typeof row.text === "string" && row.text.length > 0) { + return [{ type: "text", text: row.text }]; + } + if (Array.isArray(row.content)) { + return [{ type: "text", text: mcpContentText(row.content, JSON.stringify(value)) }]; + } + } + return [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value ?? null) }]; } -var jsonOut2 = { +var mcpOut = { schema: { type: "object", additionalProperties: true }, - render: renderJson2 + render: renderMcpResult }; -function tool2(name, description, parameters, handler, getClient, timeoutMs) { +function parametersFromInputSchema(schema) { + if (schema && typeof schema === "object" && !Array.isArray(schema)) { + const props = schema.properties; + if (props && typeof props === "object" && !Array.isArray(props)) { + return props; + } + if (schema.type === undefined && schema.properties === undefined) { + return schema; + } + } + return {}; +} +function defineRemoteTool(tool2, timeoutMs) { + const name = publicToolName(tool2.name); return defineTool2({ name, - description, - parameters, - output: jsonOut2, + description: tool2.description || name, + parameters: parametersFromInputSchema(tool2.inputSchema), + output: mcpOut, timeoutMs, - isConcurrencySafe: () => !MUTATING_TOOLS.has(name), + isConcurrencySafe: () => tool2.readOnlyHint === true, async execute(args, exec) { - const result = await handler(getClient(), args, exec.signal); - 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 + }, tool2.name, typeof args === "object" && args !== null ? args : {}); + const text = mcpContentText(result.content, ""); + if (result.isError === true) { + throw new Error(text || `Tool "${tool2.name}" failed`); } - return safe; + return JSON.parse(JSON.stringify({ + content: result.content, + ...result.structuredContent !== undefined ? { structuredContent: result.structuredContent } : {}, + ...text ? { text } : {} + })); } }); } function registerNetxTools(ctx, connection, options) { - 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((tool2) => tool2.group).filter(Boolean))]; + if (enabled.length === 0) { publishToolMountStatus({ id, version: packageVersion(), plane: options.plane, - groups: [...enabledGroups], + groups, names: [], count: 0, at: new Date().toISOString(), @@ -3250,392 +1905,40 @@ function registerNetxTools(ctx, connection, options) { }; } const t = typeof connection.toolCallTimeoutMs === "number" && Number.isFinite(connection.toolCallTimeoutMs) && connection.toolCallTimeoutMs > 0 ? connection.toolCallTimeoutMs : 120000; - const client = createNetxClient({ - apiUrl: connection.apiUrl, - lang: connection.lang, - timeoutMs: Math.min(t, 45000), - getToken: () => { - const live = getNetxConnection(); - return live ? live.token.trim() : connection.token.trim(); - }, - getApiUrl: () => getNetxConnection()?.apiUrl ?? connection.apiUrl - }); - const getClient = () => client; - const catalog = [ - tool2("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: str4(), - ne_id: str4("Filter only; do not show UUID to users"), - host_name: str4("Filter by NE host_name"), - ne_name: str4("Legacy alias mapped to keyword"), - keyword: str4("Substring on cause/object/event. Examples: LOS, Fiber Break, bandwidth, CRC."), - time_from: str4("ISO time; filters last_seen_at >="), - time_to: str4("ISO time; filters last_seen_at <="), - page: num4(), - page_size: num4() - }, queryUmeAlarms, getClient, t), - tool2("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: str4("Optional perceived_severity filter (critical/major/minor/warning)."), - top_ne: num4("Max NE buckets (default 50). Ignored when group_by is set."), - exclude_missing_host: bool4("Omit missing host_name from by_ne."), - time_from: str4(), - time_to: str4(), - group_by: str4("When set, routes to raw aggregation. Prefer alarm_host_name."), - group_by2: str4(), - is_cleared: str4(), - ne_id: str4(), - event_type: str4(), - keyword: str4(), - limit: num4() - }, aggregateUmeAlarms, getClient, t), - tool2("netx__runNmsDiagnostics", "NMS alarm diagnostics: severity, top_event_types, top_alarm_codes, top_ne, protocol buckets, freshness meta.", {}, runUmeDiagnostics, getClient, t), - tool2("netx__queryNmsNeInventory", "Paged NMS NE inventory synced in netx (keyword matches ne_id/ne_name/user_label/ip/host_name).", { - keyword: str4(), - page: num4(), - page_size: num4() - }, queryUmeNeInventory, getClient, t), - tool2("netx__getNmsNe", "Get single NMS NE detail by ne_id (UUID).", { - ne_id: { type: "string", required: true, description: "NMS inventory ne_id (UUID)." } - }, getUmeNe, getClient, t), - tool2("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: str4(), - is_cleared: str4(), - ne_id: str4(), - event_type: str4(), - keyword: str4(), - time_from: str4(), - time_to: str4(), - order_by: str4("last_seen_at | time_created | perceived_severity | event_type | ne_id"), - order: str4("asc | desc"), - select_fields: strArr(), - field_preset: str4("brief | evidence | ne_debug"), - page: num4(), - page_size: num4() - }, queryUmeAlarmsRaw, getClient, t), - tool2("netx__aggregateNmsAlarmsRaw", "Dynamic aggregation on NMS raw fields (group_by/group_by2); prefer alarm_host_name.", { - group_by: { type: "string", required: true }, - group_by2: str4(), - severity: str4(), - is_cleared: str4(), - ne_id: str4(), - event_type: str4(), - keyword: str4(), - time_from: str4(), - time_to: str4(), - exclude_missing_host: bool4(), - limit: num4() - }, aggregateUmeAlarmsRaw, getClient, t), - tool2("netx__listNmsAlarmFields", "List available fields for NMS raw alarm queries.", {}, listUmeAlarmFields, getClient, t), - tool2("netx__sqlQueryNms", "Read-only SELECT on NMS tables (ume_alarms_current/ume_inventory_ne); server enforces limits. Requires sql:query scope.", { - sql: { type: "string", required: true }, - limit: num4(), - statement_timeout_ms: num4() - }, sqlQueryUme, getClient, t), - tool2("netx__listManagedNe", "List filtered netx managed NEs (keyword/vendor/connect_status required); use before execManagedNe.", { - keyword: str4(), - vendor: str4(), - connect_status: str4("unknown | testing | pass | fail"), - page: num4(), - page_size: num4() - }, listManagedNe, getClient, t), - tool2("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: str4("Managed NE id"), - managed_ne_id: str4("Alias for ne_id"), - id: str4("Alias for ne_id") - }, getManagedNe, getClient, t), - tool2("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: str4(), - nms_ne_id: str4("NMS inventory id; alias ume_ne_id"), - ume_ne_id: str4("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", - description: "Per-NE command sets: each item is one NE (ne_id OR nms_ne_id) with commands[].", - items: { - type: "object", - additionalProperties: false, - properties: { - ne_id: str4(), - nms_ne_id: str4(), - ume_ne_id: str4(), - commands: strArr() - } - } - }, - commands: strArr("Commands for single NE, or shared commands for batch."), - read_timeout_sec: num4("Per-command read timeout (default 60; use 90–120 for slow show)."), - concurrency: num4("Parallel NEs for batch mode (1–8, default 4)."), - async: bool4("true=background job_id + getNeExecJob; false=force sync; omit=auto for ~4+ NEs (NETX_NE_EXEC_ASYNC_MIN_NES).") - }, execManagedNe, getClient, Math.max(t, 300000)), - tool2("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: str4("Job id from execManagedNe async response.") - }, getNeExecJob, getClient, Math.max(t, 60000)), - tool2("netx__listCliTargets", "List CLI-capable targets (managed NE and/or NMS inventory). Call once per session with keyword/source, cache ids, then execManagedNe.", { - source: str4("managed | nms | ume | all"), - keyword: str4(), - page: num4(), - page_size: num4() - }, listCliTargets, getClient, t), - tool2("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: str4("NMS inventory id"), - from_ume_ne_id: str4("Legacy alias of from_nms_ne_id"), - from_managed_ne_id: str4(), - to_nms_ne_id: str4("NMS inventory id"), - to_ume_ne_id: str4("Legacy alias of to_nms_ne_id"), - to_managed_ne_id: str4(), - max_paths: num4(), - max_hops: num4(), - layer: str4(), - detail: str4("summary | full") - }, findTopologyPaths, getClient, t), - tool2("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: str4("all | projects | tasks (default all)"), - purpose: str4("Filter tasks: portrait | cutover_hf | empty=all"), - status: str4("Filter by status (e.g. active / paused)"), - q: str4("Keyword on name / ne_name / note / id"), - limit: num4("Max rows per list (default 100, max 500)") - }, listBizMonitors, getClient, t), - tool2("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: str4("auto | all | cutover | collect | runs (default auto)"), - project_id: str4("Cutover project id → cutover_batches"), - task_id: str4("biz_state task id → collect_batches"), - batch_id: str4("Cutover batch id (filter or kind=runs)"), - include_runs: bool4("Attach recent evaluate runs onto listed cutover batches"), - status: str4("Filter batch status"), - limit: num4("Max batches (default 50)"), - runs_limit: num4("Max runs per batch when include_runs / kind=runs (default 10)") - }, listBizMonitorBatches, getClient, t), - tool2("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: str4("Cutover / biz-migration project id"), - task_id: str4("biz_state task id (optional if project_id set)") - }, getBizMonitorContext, getClient, t), - tool2("netx__getBizMonitorBoard", "Cutover batch board: project/batch status, evaluate run summary, sheet cards, verdict counts, metric_batches.", { - batch_id: { type: "string", required: true }, - run_id: str4("Optional evaluate run id") - }, getBizMonitorBoard, getClient, t), - tool2("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", required: true }, - status: str4("open|carried|resolved or empty=all"), - limit: num4() - }, listBizMonitorReds, getClient, t), - tool2("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", required: true }, - metric_id: str4(), - sheet_id: str4(), - verdict: str4(), - color: str4("e.g. red"), - q: str4(), - limit: num4(), - offset: num4() - }, getBizMonitorDiffs, getClient, t), - tool2("netx__getBizCollectBatch", "One biz_state collect batch: commands (raw_command, parse_status, preview), parsed metrics.", { - batch_id: { type: "string", required: true } - }, getBizCollectBatch, getClient, t), - tool2("netx__getBizCollectCommandRaw", "Full CLI raw_text for one collect command inside a batch (deep dive).", { - batch_id: { type: "string", required: true }, - command_id: { type: "string", required: true } - }, getBizCollectCommandRaw, getClient, t), - tool2("netx__getTopologyTree", "Get topology folder tree (nav roots + Root map canvases). Start here before createTopologyFolder.", { compact: bool4(), max_depth: num4() }, getTopologyTree, getClient, t), - tool2("netx__getTopologyView", "Get a topology view by view_id. Default detail=summary with sample_nodes + links[].", { - view_id: { type: "string", required: true }, - detail: str4("summary | full"), - sample: num4() - }, getTopologyView, getClient, t), - tool2("netx__createTopologyFolder", "Create topology folders / region canvases. Only way to create canvases (ne:write).", { - name: { type: "string", required: true }, - parent_id: str4(), - locale: str4("zh | en"), - sort_order: num4() - }, createTopologyFolder, getClient, t), - tool2("netx__addTopologyViewNodes", "Bulk-place fabric nodes on a view via filters or fabric_node_ids (never managed/UME ids).", { - view_id: { type: "string", required: true }, - max_nodes: num4(), - keyword: str4(), - role: str4(), - vendor: str4(), - link_status: str4(), - limit: num4(), - offset: num4(), - fabric_node_ids: strArr(), - layout: str4("grid | keep") - }, addTopologyViewNodes, getClient, t), - tool2("netx__removeTopologyViewNodes", "Remove placements from a view (not fabric) by filters or fabric_node_ids.", { - view_id: { type: "string", required: true }, - keyword: str4(), - role: str4(), - vendor: str4(), - link_status: str4(), - fabric_node_ids: strArr() - }, removeTopologyViewNodes, getClient, t), - tool2("netx__copyTopologyViewNodes", "Clone fabric placements from source_view_id onto target_view_id (optional clear_target / copy_positions).", { - source_view_id: { type: "string", required: true }, - target_view_id: { type: "string", required: true }, - copy_positions: bool4(), - clear_target: bool4(), - offset_x: num4(), - offset_y: num4(), - limit: num4(), - dry_run: bool4() - }, copyTopologyViewNodes, getClient, t), - tool2("netx__updateTopologyViewPositions", "Move nodes on a view via positions[] or layout=grid|offset|stack + filters.", { - view_id: { type: "string", required: true }, - positions: { - type: "array", - description: "Manual placements: [{ fabric_node_id, x, y }, …]", - items: { - type: "object", - additionalProperties: false, - properties: { - fabric_node_id: str4(), - x: num4(), - y: num4() - } - } - }, - layout: str4("grid | offset | stack"), - keyword: str4(), - role: str4(), - vendor: str4(), - link_status: str4(), - fabric_node_ids: strArr(), - offset_x: num4(), - offset_y: num4() - }, updateTopologyViewPositions, getClient, t), - tool2("netx__projectTopologyNeighbors", "Project existing fabric neighbors of on-view nodes onto the canvas.", { - view_id: { type: "string", required: true }, - seed_fabric_node_ids: strArr(), - managed_ne_ids: strArr(), - region_folder_id: str4(), - detail: str4("summary | full"), - sample: num4() - }, projectTopologyNeighbors, getClient, t), - tool2("netx__queryTopologyFabricNodes", "Fabric inventory: mode=summary|list|search (keyword/role/level/region/link_status).", { - mode: str4("summary | list | search"), - q: str4(), - keyword: str4(), - role: str4(), - level: str4(), - level_major: str4(), - region_folder_id: str4(), - link_status: str4(), - page: num4(), - page_size: num4(), - limit: num4(), - summary: bool4() - }, queryTopologyFabricNodes, getClient, t), - tool2("netx__classifyTopologyFabricNodes", "Classify fabric nodes: action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules.", { - action: { type: "string", required: true }, - pattern: str4(), - q: str4(), - match_field: str4(), - sample_limit: num4(), - fabric_node_ids: strArr(), - fabric_node_id: str4(), - node_id: str4(), - level: str4(), - role: str4(), - region_folder_id: str4(), - clear_region: bool4(), - dry_run: bool4(), - kind: str4(), - page: num4(), - page_size: num4(), - skip_manual: bool4(), - overwrite_manual: bool4(), - fill_empty_only: bool4() - }, classifyTopologyFabricNodes, getClient, t), - tool2("netx__queryTopologyNeighborhood", "Neighborhood around a fabric node (depth 1–3) with compact links[].", { - node_id: { type: "string", required: true }, - depth: num4(), - layer: str4() - }, queryTopologyNeighborhood, getClient, t), - tool2("netx__queryTopologyEdges", "Fabric adjacency links[] (detail=adjacency) or port rows (detail=ports).", { - node_id: str4(), - keyword: str4(), - layer: str4(), - status: str4(), - source: str4("lldp | ume | manual"), - detail: str4("adjacency | ports"), - page: num4(), - page_size: num4() - }, queryTopologyEdges, getClient, t), - tool2("netx__suggestSinkHubs", "Rank hub territories on a source view for non-dual move_nodes(park) batches (degree stand-in in DSH).", { - source_view_id: str4(), - view_id: str4(), - pick: num4(), - exclude_portal_ids: strArr(), - exclude_fabric_node_ids: strArr() - }, suggestSinkHubs, getClient, t), - tool2("netx__analyzeTopologyViewLayout", "Basic layout QA for a view (bbox/hubs). Full dual_unit/crossing scores need netx-topology MCP.", { - view_id: str4(), - folder_id: str4(), - detail: str4("summary | structure | hotspots | blocks | both"), - score_profile: str4(), - sight_limit: num4(), - max_views: num4(), - min_nodes: num4(), - max_nodes: num4(), - with_meta: bool4() - }, analyzeTopologyViewLayout, getClient, t), - tool2("netx__sinkTopologyDualUnits", "Drain dual_unit eyes from source to sink (requires netx-topology MCP layout engine in DSH).", { - source_view_id: str4(), - sink_view_id: str4(), - max_units: num4(), - min_nodes: num4(), - max_nodes: num4(), - max_batch_nodes: num4(), - layout_batch: bool4(), - until_empty: bool4(), - dry_run: bool4() - }, sinkTopologyDualUnits, getClient, t), - tool2("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: str4(), - action: str4(), - source_view_id: str4(), - recipe: str4(), - preset: str4(), - mode: str4("preview | apply"), - tune: bool4(), - params: anyObj("Action-specific options (move_nodes / recipes / polish)."), - catalog: bool4(), - fabric_node_ids: strArr(), - park: bool4(), - copy_positions: bool4() - }, layoutTopologyView, getClient, Math.max(t, 180000)) - ]; - const selected = catalog.filter((entry) => allow.has(entry.name)); - const names = selected.map((entry) => entry.name); const disposers = []; + const names = []; try { - for (const entry of selected) - disposers.push(ctx.tools.register(entry)); - publishToolMountStatus({ - id, - version: packageVersion(), - plane: options.plane, - groups: [...enabledGroups], - names, - count: names.length, - at: new Date().toISOString(), - source: options.source ?? options.plane - }); + for (const tool2 of enabled) { + const definition = defineRemoteTool(tool2, t); + disposers.push(ctx.tools.register(definition)); + names.push(publicToolName(tool2.name)); + } } catch (error) { - for (const dispose of disposers.reverse()) + for (const dispose of disposers.splice(0)) dispose(); publishToolMountStatus({ id, version: packageVersion(), plane: options.plane, - groups: [...enabledGroups], + groups, names: [], count: 0, at: new Date().toISOString(), - error: error instanceof Error ? error.message : String(error), + error: String(error), source: options.source ?? options.plane }); throw error; } + publishToolMountStatus({ + id, + version: packageVersion(), + plane: options.plane, + groups, + names, + count: names.length, + at: new Date().toISOString(), + source: options.source ?? options.plane + }); return () => { for (const dispose of disposers) dispose(); @@ -3653,6 +1956,16 @@ function packageVersion2() { return "0"; } } +function disabledByGroupFor(groupIds, connection) { + const catalog = getMcpCatalogTools(); + const out = {}; + for (const groupId of groupIds) { + const names = disabledPublicNames(catalog, [groupId], connection.disabledTools, connection.readOnlyOnly); + if (names.length > 0) + out[groupId] = names; + } + return out; +} function applyGroupToolsPlugin(ctx, options) { let unregisterTools; let unregisterKbLocal; @@ -3678,9 +1991,10 @@ function applyGroupToolsPlugin(ctx, options) { unregisterTools = registerNetxTools(ctx, connection, { plane: "preset", forceGroups: enabled, - source: options.name + source: options.name, + presetId: presetId() }); - ctx.logger.info("%s: groups=[%s] → %s tokenConfigured=%s live=%s", options.name, enabled.join(",") || "(none)", connection.apiUrl, connection.token.trim().length > 0, getNetxConnection() !== undefined); + ctx.logger.info("%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({ id: mountId, @@ -3708,6 +2022,9 @@ function applyGroupToolsPlugin(ctx, options) { remountTools(); remountKbLocal(); }); + const stopCatalogWatch = watchMcpCatalog(() => { + remountTools(); + }); const stopKbToolWatch = watchKbContext(() => { remountKbLocal(); }); @@ -3737,8 +2054,13 @@ function applyGroupToolsPlugin(ctx, options) { skillAbort = new AbortController; unregisterSkills?.(); unregisterSkills = undefined; + const connection = requireNetxConnection(); const enabled = resolveGroups(); - registerGroupSkills(skillsCtx, enabled, options.name, skillAbort.signal).then((dispose) => { + registerGroupSkills(skillsCtx, enabled, options.name, { + signal: skillAbort.signal, + disabledByGroup: disabledByGroupFor(enabled, connection), + lang: connection.lang + }).then((dispose) => { if (gen !== skillGeneration) { dispose(); return; @@ -3783,6 +2105,9 @@ function applyGroupToolsPlugin(ctx, options) { remountKbSkill(); remountKbPack(); }); + const stopSkillCatalogWatch = watchMcpCatalog(() => { + remountSkills(); + }); const stopKbWatch = watchKbContext(() => { remountKbSkill(); remountKbPack(); @@ -3796,6 +2121,7 @@ function applyGroupToolsPlugin(ctx, options) { skillAbort.abort(); packAbort.abort(); stopSkillWatch(); + stopSkillCatalogWatch(); stopKbWatch(); unregisterSkills?.(); unregisterSkills = undefined; @@ -3808,6 +2134,7 @@ function applyGroupToolsPlugin(ctx, options) { ctx.effect(() => () => { clearInterval(pollTimer); stopToolWatch(); + stopCatalogWatch(); stopKbToolWatch(); unregisterTools?.(); unregisterTools = undefined; diff --git a/lib/agent-tools-nms.js b/lib/agent-tools-nms.js index 6e437c8..7d62d2e 100644 --- a/lib/agent-tools-nms.js +++ b/lib/agent-tools-nms.js @@ -21,52 +21,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", @@ -101,14 +55,6 @@ function groupsForPlane(groups, plane, only) { const allow = new Set(only); return enabled.filter((id) => allow.has(id)); } -function toolNamesForGroups(groupIds) { - const out = new Set; - for (const id of groupIds) { - for (const name of TOOLS_BY_GROUP[id] ?? []) - out.add(name); - } - return out; -} function parsePresetIds(value) { const current = readConfigValue(value); if (typeof current !== "string") @@ -122,6 +68,234 @@ function groupsForPreset(connection, presetId, only) { return CAPABILITY_GROUP_IDS.filter((id) => (!only?.length || only.includes(id)) && !policy[id].public && (presetId === "netxops" ? policy[id].inPreset : policy[id].presets?.includes(presetId))); } +// src/netx/mcp-types.ts +var NETX_TOOL_PREFIX = "netx__"; +function publicToolName(serverName) { + return `${NETX_TOOL_PREFIX}${serverName}`; +} + +// src/netx/mcp-catalog.ts +function disabledSet(disabledTools) { + const out = new Set; + for (const name of disabledTools ?? []) { + const trimmed = typeof name === "string" ? name.trim() : ""; + if (trimmed) + out.add(trimmed); + } + return out; +} +function filterCatalogTools(catalog, enabledGroups, policy) { + const allowGroups = new Set(enabledGroups); + const disabled = disabledSet(policy.disabledTools); + const readOnlyOnly = policy.readOnlyOnly === true; + const out = []; + 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; +} +function resolveEnabledTools(catalog, options) { + 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); +} +function disabledPublicNames(catalog, enabledGroups, disabledTools, readOnlyOnly) { + const allowGroups = new Set(enabledGroups); + const disabled = disabledSet(disabledTools); + const names = []; + 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(); +} + +// src/netx/mcp-remote.ts +var PROTOCOL_VERSION = "2025-03-26"; +var CLIENT_INFO = { name: "dsh-netxops", version: "0.1.71" }; + +class McpRemoteError extends Error { + status; + constructor(message, status) { + super(message); + this.name = "McpRemoteError"; + this.status = status; + } +} +function mcpEndpointUrl(apiUrl) { + const trimmed = apiUrl.trim().replace(/\/$/, ""); + if (!trimmed) + return ""; + return `${trimmed}/mcp`; +} +function parseMcpResponseBody(raw) { + 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 = []; + 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 {} + } + } + throw new McpRemoteError("mcp response was not JSON"); +} +function asRpcResult(payload) { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + throw new McpRemoteError("invalid JSON-RPC envelope from /mcp"); + } + const row = payload; + if (row.error && typeof row.error === "object" && !Array.isArray(row.error)) { + const err = row.error; + 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.result; +} +var nextId = 1; +async function postRpc(options, method, params, notification = false) { + const headers = { + "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 : 60000; + 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 = () => { + abort.abort(); + }; + options.signal?.addEventListener("abort", onOuterAbort, { once: true }); + let 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; + } + return asRpcResult(parseMcpResponseBody(responseText)); +} +async function callMcpTool(options, name, args) { + 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 + }); + const content = Array.isArray(result.content) ? result.content : []; + return { + content, + ...result.structuredContent !== undefined ? { structuredContent: result.structuredContent } : {}, + ...result.isError === true ? { isError: true } : {} + }; +} +function mcpContentText(content, fallback = "") { + const parts = []; + for (const block of content) { + if (!block || typeof block !== "object" || Array.isArray(block)) + continue; + const row = block; + if (row.type === "text" && typeof row.text === "string") + parts.push(row.text); + } + return parts.join(` +`) || fallback; +} + +// src/netx/mcp-store.ts +var STORE_KEY = Symbol.for("dsh-netxops.mcp-catalog-store"); +var EMPTY = Object.freeze({ + phase: "idle", + mcpUrl: "", + tools: Object.freeze([]), + error: "", + listedAt: null, + fromCache: false +}); +function store() { + const root = globalThis; + let current = root[STORE_KEY]; + if (current === undefined) { + current = { state: EMPTY, listeners: new Set, generation: 0 }; + root[STORE_KEY] = current; + } + return current; +} +function getMcpCatalogTools() { + return store().state.tools; +} +function watchMcpCatalog(listener) { + const state = store(); + state.listeners.add(listener); + return () => { + state.listeners.delete(listener); + }; +} + // src/netx/group-skills.ts import { existsSync } from "node:fs"; import { readdir, readFile, stat } from "node:fs/promises"; @@ -271,7 +445,24 @@ async function loadGroupSkills(skillsRoot, groupId) { } return { skills, skipped }; } -async function registerGroupSkills(ctx, groupIds, providerLabel, signal) { +function disabledFootnote(names, lang) { + if (names.length === 0) + return ""; + const list = names.join(", "); + const en = (lang ?? "").trim().toLowerCase().startsWith("en"); + return en ? ` + +## Disabled tools +The following tools are disabled: ${list}. Do not call them. +` : ` + +## 已禁用工具 +以下工具已禁用:${list},不要调用。 +`; +} +async function registerGroupSkills(ctx, groupIds, providerLabel, signalOrOptions) { + const options = signalOrOptions instanceof AbortSignal || signalOrOptions === undefined ? { signal: signalOrOptions } : signalOrOptions; + const signal = options.signal; const skillsApi = ctx.skills; if (!skillsApi || typeof skillsApi.register !== "function") { return () => {}; @@ -293,15 +484,17 @@ async function registerGroupSkills(ctx, groupIds, providerLabel, signal) { if (skills.length === 0) { log?.warn?.("%s: no skills loaded for group=%s from %s", providerLabel, groupId, 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} + +## DeepSeek Harness REST capabilities +Use only tools present in this session. Dual-unit/orbit/polish recipes require server-side topology tools. Use only tools present in this session. +` : skill.content; disposers.push(skillsApi.register({ name: skill.name, description: skill.description, - content: groupId === "topology" ? `${skill.content} - -## DeepSeek Harness REST capabilities -Use 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. -` : skill.content, + content: `${base}${footnote}`, path: skill.path, resourceBase: { kind: "directory", path: skill.directory }, provider: providerLabel, @@ -1119,18 +1312,18 @@ function searchKb(snapshot, args) { } // src/netx/kb-runtime.ts -var STORE_KEY = Symbol.for("dsh-netxops.kb-store"); -function store() { +var STORE_KEY2 = Symbol.for("dsh-netxops.kb-store"); +function store2() { const root = globalThis; - let current = root[STORE_KEY]; + let current = root[STORE_KEY2]; if (current === undefined) { current = { snapshot: unconfiguredKbSnapshot(), listeners: new Set }; - root[STORE_KEY] = current; + root[STORE_KEY2] = current; } return current; } function getKbContext() { - const snap = store().snapshot; + const snap = store2().snapshot; return { ...snap, content: { ...snap.content }, @@ -1138,7 +1331,7 @@ function getKbContext() { }; } function watchKbContext(listener) { - const state = store(); + const state = store2(); state.listeners.add(listener); return () => { state.listeners.delete(listener); @@ -1162,26 +1355,18 @@ function toLosslessJson(value) { return { ok: false, error: "tool_result_not_json", detail: detail.slice(0, 400) }; } } -function omitUndefined(record) { - const out = {}; - for (const [key, value] of Object.entries(record)) { - if (value !== undefined) - out[key] = value; - } - return out; -} // src/netx/tool-mount-status.ts import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs"; import { homedir } from "node:os"; import { dirname as dirname4, join as join7 } from "node:path"; var KEY = Symbol.for("dsh-netxops.mount-inventory"); -function store2() { +function store3() { const root = globalThis; return root[KEY] ??= { mounts: new Map, contexts: new WeakMap, sequence: 0 }; } function getToolMountId(ctx, source) { - const state = store2(); + const state = store3(); let id = state.contexts.get(ctx); if (id === undefined) { id = ++state.sequence; @@ -1190,7 +1375,7 @@ function getToolMountId(ctx, source) { return `${source}:${id}`; } function getToolMountStatuses() { - return [...store2().mounts.values()].map((row) => ({ ...row, groups: [...row.groups], names: [...row.names] })); + return [...store3().mounts.values()].map((row) => ({ ...row, groups: [...row.groups], names: [...row.names] })); } function getToolMountStatus() { const rows = getToolMountStatuses(); @@ -1205,11 +1390,11 @@ function persist() { } catch {} } function publishToolMountStatus(next) { - store2().mounts.set(next.id ?? next.source ?? next.plane, { ...next, groups: [...next.groups], names: [...next.names] }); + store3().mounts.set(next.id ?? next.source ?? next.plane, { ...next, groups: [...next.groups], names: [...next.names] }); persist(); } function removeToolMountStatus(id) { - store2().mounts.delete(id); + store3().mounts.delete(id); persist(); } @@ -1571,139 +1756,20 @@ function normalizeApiUrl(raw) { const search = parsed.search && parsed.search !== "?" ? parsed.search : ""; return { ok: true, url: `${parsed.origin}${path}${search}` }; } -var PROTOCOL_KEY_ZH_TO_EN = { - 其他: "Other", - 时钟: "Clock", - "OTN/光": "OTN/Optical", - 电源: "Power" -}; -function asLang(value) { - return typeof value === "string" ? value : ""; -} -function localizePayload(lang, data) { - 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; - const key = typeof rec.key === "string" ? rec.key : ""; - const mapped = PROTOCOL_KEY_ZH_TO_EN[key]; - if (mapped !== undefined) - rec.key = mapped; - } - return data; -} -function encodeQuery(params) { - const sp = new URLSearchParams; - for (const [key, value] of Object.entries(params)) { - sp.set(key, String(value)); - } - const q = sp.toString(); - return q.length > 0 ? `?${q}` : ""; -} -function createNetxClient(connection) { - const langParams = () => { - const lang = asLang(connection.lang).trim().toLowerCase(); - if (lang.startsWith("en")) - return { lang: "en" }; - return {}; - }; - async function request(method, path, options = {}) { - 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 = { ...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 = { - accept: "application/json", - authorization: `Bearer ${token}` - }; - if (options.body !== undefined) - headers["content-type"] = "application/json"; - const init = { - 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) : {}; - if (typeof data === "object" && data !== null && !Array.isArray(data)) { - return { ok: true, data: localizePayload(connection.lang, data) }; - } - return { ok: true, data: { raw: data } }; - } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - return { ok: false, error: "netx_request_failed", detail: detail.slice(0, 800) }; - } finally { - clearTimeout(timer); - options.signal?.removeEventListener("abort", onOuterAbort); - } - } - return { - get(path, params, signal, timeoutMs) { - return request("GET", path, { params, signal, timeoutMs }); - }, - post(path, body, signal, timeoutMs) { - return request("POST", path, { body, signal, timeoutMs }); - }, - patch(path, body, signal, timeoutMs) { - return request("PATCH", path, { body, signal, timeoutMs }); - } - }; -} -function quoteNeId(neId) { - return encodeURIComponent(neId.trim()); -} // src/netx/runtime.ts -var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store"); -function store3() { +var STORE_KEY3 = Symbol.for("dsh-netxops.connection-store"); +function store4() { const root = globalThis; - let current = root[STORE_KEY2]; + let current = root[STORE_KEY3]; if (current === undefined) { current = { connection: undefined, listeners: new Set }; - root[STORE_KEY2] = current; + root[STORE_KEY3] = current; } return current; } function getNetxConnection() { - return store3().connection; + return store4().connection; } function provisionalNetxConnection() { const envUrl = process.env.NETX_API_URL?.trim() ?? ""; @@ -1715,6 +1781,8 @@ function provisionalNetxConnection() { lang: typeof process.env.NETX_LANG === "string" && process.env.NETX_LANG.trim() ? process.env.NETX_LANG.trim() : "zh", toolCallTimeoutMs: 120000, groups: DEFAULT_CAPABILITY_GROUPS, + disabledTools: [], + readOnlyOnly: false, groupKbInPreset: true, groupKbPublic: false }; @@ -1723,7 +1791,7 @@ function requireNetxConnection() { return getNetxConnection() ?? provisionalNetxConnection(); } function watchNetxConnection(listener) { - const state = store3(); + const state = store4(); state.listeners.add(listener); return () => { state.listeners.delete(listener); @@ -1735,1443 +1803,6 @@ import { readFileSync as readFileSync5 } from "node:fs"; import { dirname as dirname5, join as join9 } from "node:path"; import { fileURLToPath as fileURLToPath2 } from "node:url"; import { defineTool as defineTool2 } from "@deepseek-ai/dsh-tools"; - -// src/netx/handlers.ts -var EXEC_MAX_COMMANDS_CAP = 50; -var EXEC_MAX_COMMANDS_DEFAULT = 5; -function execMaxCommands() { - 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() { - 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) { - if (raw === undefined || raw === null) - return; - if (typeof raw === "boolean") - return raw; - const text = String(raw).trim().toLowerCase(); - if (!text) - return; - if (["1", "true", "yes", "on"].includes(text)) - return true; - if (["0", "false", "no", "off"].includes(text)) - return false; - return; -} -function countExecNeTargets(args) { - let n = 0; - for (const key of ["ne_ids", "nms_ne_ids", "ume_ne_ids"]) { - 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 (str2(args, "ne_id").trim() || nmsOrUme(args, "nms_ne_id", "ume_ne_id")) - return 1; - } - return n; -} -function shouldRunExecAsync(args) { - 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; -} -var UME_RAW_FIELD_PRESETS = { - brief: [ - "alarm_alarm_key", - "alarm_host_name", - "alarm_perceived_severity", - "alarm_event_type", - "alarm_last_seen_at", - "ne_host_name", - "ne_user_label", - "ne_ne_name", - "ne_ip_address", - "ne_exists" - ], - evidence: [ - "alarm_alarm_key", - "alarm_host_name", - "alarm_object_name", - "alarm_event_type", - "alarm_native_probable_cause", - "alarm_perceived_severity", - "alarm_is_cleared", - "alarm_time_created", - "alarm_last_seen_at", - "ne_host_name", - "ne_user_label", - "ne_ne_name", - "ne_ip_address", - "ne_connection_status", - "ne_exists" - ], - ne_debug: [ - "alarm_alarm_key", - "alarm_ne_id", - "alarm_perceived_severity", - "alarm_last_seen_at", - "ne_user_label", - "ne_ne_name", - "ne_ip_address", - "ne_ipv6_address", - "ne_device_level", - "ne_host_name", - "ne_connection_status", - "ne_admin_status", - "ne_address_type", - "ne_maintain_status", - "ne_exists" - ] -}; -function asRecord(value) { - return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; -} -function str2(args, key, fallback = "") { - const v = args[key]; - if (typeof v === "string") - return v; - if (typeof v === "number" || typeof v === "boolean") - return String(v); - return fallback; -} -function nmsOrUme(args, nmsKey, umeKey) { - return str2(args, nmsKey).trim() || str2(args, umeKey).trim(); -} -function nmsOrUmeList(args, nmsKey, umeKey) { - const primary = strList(args, nmsKey); - return primary.length > 0 ? primary : strList(args, umeKey); -} -function num2(args, key) { - const v = args[key]; - return typeof v === "number" && Number.isFinite(v) ? v : undefined; -} -function bool2(args, key) { - const v = args[key]; - return typeof v === "boolean" ? v : undefined; -} -function strList(args, key) { - const v = args[key]; - if (!Array.isArray(v)) - return []; - return v.map((x) => String(x).trim()).filter((x) => x.length > 0); -} -function clampInt(value, fallback, min, max) { - const n = value === undefined ? fallback : Math.trunc(value); - return Math.max(min, Math.min(max, n)); -} -function putStr(params, args, keys) { - for (const key of keys) { - const v = str2(args, key).trim(); - if (v) - params[key] = v; - } -} -async function queryUmeAlarms(client, args, signal) { - let page = clampInt(num2(args, "page"), 1, 1, 2); - const pageSize = clampInt(num2(args, "page_size"), 50, 1, 500); - const params = { page, page_size: pageSize }; - putStr(params, args, ["severity", "ne_id", "host_name", "time_from", "time_to"]); - const keyword = str2(args, "keyword").trim(); - const neName = str2(args, "ne_name").trim(); - if (keyword) - params.keyword = keyword; - else if (neName) - params.keyword = neName; - return client.get("/v1/ume/alarms", params, signal); -} -async function aggregateUmeAlarmsRaw(client, args, signal) { - const params = {}; - putStr(params, args, [ - "group_by", - "group_by2", - "severity", - "is_cleared", - "ne_id", - "event_type", - "keyword", - "time_from", - "time_to", - "limit" - ]); - if ("exclude_missing_host" in args) { - const flag = bool2(args, "exclude_missing_host"); - if (flag !== undefined) - params.exclude_missing_host = flag; - } - return client.get("/v1/ume/alarms/aggregate/raw", params, signal); -} -async function aggregateUmeAlarms(client, args, signal) { - if (str2(args, "group_by").trim()) - return aggregateUmeAlarmsRaw(client, args, signal); - const topNe = clampInt(num2(args, "top_ne"), 50, 0, 500); - const params = { top_ne: topNe }; - if ("exclude_missing_host" in args) { - const flag = bool2(args, "exclude_missing_host"); - if (flag !== undefined) - params.exclude_missing_host = flag; - } - putStr(params, args, ["severity", "time_from", "time_to"]); - return client.get("/v1/ume/alarms/aggregate", params, signal); -} -async function runUmeDiagnostics(client, _args, signal) { - return client.get("/v1/ume/diagnostics", undefined, signal); -} -async function queryUmeNeInventory(client, args, signal) { - const params = { - page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num2(args, "page_size"), 50, 1, 500) - }; - putStr(params, args, ["keyword"]); - return client.get("/v1/ume/inventory/ne", params, signal); -} -async function getUmeNe(client, args, signal) { - const neId = str2(args, "ne_id").trim(); - if (!neId) - return { ok: false, error: "ne_id_required", error_code: "ne_id_required" }; - return client.get(`/v1/ume/inventory/ne/${quoteNeId(neId)}`, undefined, signal); -} -async function queryUmeAlarmsRaw(client, args, signal) { - const params = { - page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num2(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 = str2(args, "field_preset").trim().toLowerCase(); - fields = UME_RAW_FIELD_PRESETS[preset] ?? []; - } - if (fields.length > 0) - params.select_fields = fields.join(","); - return client.get("/v1/ume/alarms/raw", params, signal); -} -async function listUmeAlarmFields(client, _args, signal) { - return client.get("/v1/ume/alarms/fields", undefined, signal); -} -async function sqlQueryUme(client, args, signal) { - const sql = str2(args, "sql").trim(); - if (!sql) - return { ok: false, error: "sql_required" }; - const limit = clampInt(num2(args, "limit"), 200, 1, 2000); - const statementTimeoutMs = clampInt(num2(args, "statement_timeout_ms"), 0, 0, 30000); - return client.post("/v1/sql/ume_query", { - sql, - limit, - statement_timeout_ms: statementTimeoutMs - }, signal, 60000); -} -async function listManagedNe(client, args, signal) { - const keyword = str2(args, "keyword").trim(); - const vendor = str2(args, "vendor").trim(); - const connectStatus = str2(args, "connect_status").trim(); - if (!(keyword || vendor || connectStatus)) { - return { ok: false, error: "managed_ne_filter_required", error_code: "managed_ne_filter_required" }; - } - if (keyword && keyword.length < 2) { - return { ok: false, error: "managed_ne_keyword_too_short", error_code: "managed_ne_keyword_too_short" }; - } - const params = { - page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num2(args, "page_size"), 20, 1, 100) - }; - if (keyword) - params.keyword = keyword; - if (vendor) - params.vendor = vendor; - if (connectStatus) - params.connect_status = connectStatus; - return client.get("/v1/managed-ne", params, signal); -} -async function getManagedNe(client, args, signal) { - const neId = (str2(args, "ne_id") || str2(args, "managed_ne_id") || str2(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: "" } - }; - } - const out = await client.get(`/v1/managed-ne/${quoteNeId(neId)}`, undefined, signal); - if (out.ok === false) { - const detail = `${str2(out, "detail")}${str2(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; -} -async function execManagedNe(client, args, signal) { - 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 body2 = {}; - if (Array.isArray(targetsRaw) && targetsRaw.length > 0) { - const cleaned = []; - for (const t of targetsRaw) { - if (typeof t !== "object" || t === null || Array.isArray(t)) - continue; - const row = t; - const item = {}; - const neId2 = str2(row, "ne_id").trim(); - const umeNeId2 = nmsOrUme(row, "nms_ne_id", "ume_ne_id"); - if (neId2) - item.ne_id = neId2; - if (umeNeId2) - item.ume_ne_id = umeNeId2; - const cmds = Array.isArray(row.commands) ? row.commands.map((c) => String(c).trim()).filter((c) => c.length > 0) : []; - if (cmds.length > 0) - item.commands = cmds; - if (Object.keys(item).length > 0) - cleaned.push(item); - } - body2.targets = cleaned; - } - if (neIds.length > 0) - body2.ne_ids = neIds; - if (umeNeIds.length > 0) - body2.ume_ne_ids = umeNeIds; - if (sharedCommands.length > 0) { - if (sharedCommands.length > maxCmds) { - return { ok: false, error: "too_many_commands", error_code: "too_many_commands" }; - } - body2.commands = sharedCommands; - } - body2.read_timeout_sec = clampInt(num2(args, "read_timeout_sec"), 60, 10, 120); - const concurrency = num2(args, "concurrency"); - if (concurrency !== undefined) - body2.concurrency = clampInt(concurrency, 4, 1, 8); - if (wantAsync) { - const out3 = await client.post("/v1/managed-ne/exec-jobs", body2, signal, 60000); - if (out3.ok !== true) - return out3; - const data3 = asRecord(out3.data); - return Object.keys(data3).length > 0 ? data3 : out3; - } - const out2 = await client.post("/v1/managed-ne/exec-batch", body2, signal, 600000); - if (out2.ok !== true) - return out2; - const data2 = asRecord(out2.data); - if (data2.ok === false) { - return { ok: false, data: data2, error: str2(data2, "error", "exec_batch_failed") }; - } - return { ok: true, data: data2 }; - } - const neId = str2(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 = { - commands: sharedCommands, - read_timeout_sec: clampInt(num2(args, "read_timeout_sec"), 60, 10, 120) - }; - if (neId) - body.ne_id = neId; - if (umeNeId) - body.ume_ne_id = umeNeId; - if (wantAsync) { - const out2 = await client.post("/v1/managed-ne/exec-jobs", body, signal, 60000); - if (out2.ok !== true) - return out2; - const data2 = asRecord(out2.data); - return Object.keys(data2).length > 0 ? data2 : out2; - } - const out = await client.post("/v1/managed-ne/exec", body, signal, 300000); - if (out.ok !== true) - return out; - const data = asRecord(out.data); - if (data.ok === false) { - return { ok: false, data, error: str2(data, "error", "exec_failed") }; - } - return { ok: true, data }; -} -async function getNeExecJob(client, args, signal) { - const jobId = str2(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); -} -async function listCliTargets(client, args, signal) { - const params = { - page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num2(args, "page_size"), 50, 1, 500) - }; - putStr(params, args, ["source", "keyword"]); - return client.get("/v1/cli/targets", params, signal); -} -async function findTopologyPaths(client, args, signal) { - const fromUid = nmsOrUme(args, "from_nms_ne_id", "from_ume_ne_id"); - const fromMid = str2(args, "from_managed_ne_id").trim(); - const toUid = nmsOrUme(args, "to_nms_ne_id", "to_ume_ne_id"); - const toMid = str2(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 = str2(args, "detail", "summary").trim().toLowerCase() || "summary"; - if (detail !== "summary" && detail !== "full") - detail = "summary"; - const body = { - max_paths: clampInt(num2(args, "max_paths"), 3, 1, 10), - max_hops: clampInt(num2(args, "max_hops"), 6, 1, 12), - layer: str2(args, "layer", "physical").trim() || "physical", - detail - }; - if (fromUid) - body.from_ume_ne_id = fromUid; - else - body.from_managed_ne_id = fromMid; - if (toUid) - body.to_ume_ne_id = toUid; - else - body.to_managed_ne_id = toMid; - return client.post("/v1/topology/fabric/paths", body, signal, 30000); -} -function taskBriefNe(brief) { - 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) { - 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, q) { - 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); -} -async function listBizMonitors(client, args, signal) { - const kindRaw = str2(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 = str2(args, "purpose").trim(); - const status = str2(args, "status").trim().toLowerCase(); - const q = str2(args, "q").trim().toLowerCase(); - const limit = clampInt(num2(args, "limit") ?? 100, 100, 1, 500); - const wantProjects = kind === "all" || kind === "projects"; - const wantTasks = kind === "all" || kind === "tasks"; - const fetches = []; - if (wantProjects) - fetches.push(client.get("/v1/biz-migration/projects", {}, signal)); - else - fetches.push(Promise.resolve({ ok: true, data: { items: [] } })); - if (wantTasks) { - const params = {}; - 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 : []; - const taskItems = Array.isArray(asRecord(taskRes.data).items) ? asRecord(taskRes.data).items : []; - 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) { - 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, taskId = "") { - 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) { - 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 - }); -} -async function listBizMonitorBatches(client, args, signal) { - const projectId = str2(args, "project_id").trim(); - const taskId = str2(args, "task_id").trim(); - const batchId = str2(args, "batch_id").trim(); - const kindRaw = str2(args, "kind", "auto").trim().toLowerCase() || "auto"; - const includeRuns = args.include_runs === true || args.include_runs === "true" || args.include_runs === 1; - const status = str2(args, "status").trim().toLowerCase(); - const limit = clampInt(num2(args, "limit") ?? 50, 50, 1, 200); - const runsLimit = clampInt(num2(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 = []; - let collectBatches = []; - let runs = []; - 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 : []; - 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 : []; - 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 : []; - 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: [] }; - const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : []; - 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) { - 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)." - }) - }; -} -async function getBizMonitorContext(client, args, signal) { - const projectId = str2(args, "project_id").trim(); - const taskId = str2(args, "task_id").trim(); - if (!projectId && !taskId) { - return { ok: false, error: "project_id_or_task_id_required" }; - } - const params = {}; - if (projectId) - params.project_id = projectId; - if (taskId) - params.task_id = taskId; - return client.get("/v1/biz-migration/monitor-context", params, signal); -} -async function getBizMonitorBoard(client, args, signal) { - const batchId = str2(args, "batch_id").trim(); - if (!batchId) - return { ok: false, error: "batch_id_required" }; - const params = {}; - const runId = str2(args, "run_id").trim(); - if (runId) - params.run_id = runId; - return client.get(`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/board`, params, signal); -} -async function listBizMonitorReds(client, args, signal) { - const projectId = str2(args, "project_id").trim(); - if (!projectId) - return { ok: false, error: "project_id_required" }; - const params = {}; - putStr(params, args, ["status"]); - const limit = num2(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); -} -async function getBizMonitorDiffs(client, args, signal) { - const runId = str2(args, "run_id").trim(); - if (!runId) - return { ok: false, error: "run_id_required" }; - const params = {}; - putStr(params, args, ["metric_id", "sheet_id", "verdict", "color", "q"]); - const limit = num2(args, "limit"); - if (limit !== undefined) - params.limit = clampInt(limit, 200, 1, 500); - const offset = num2(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); -} -async function getBizCollectBatch(client, args, signal) { - const batchId = str2(args, "batch_id").trim(); - if (!batchId) - return { ok: false, error: "batch_id_required" }; - return client.get(`/v1/biz-state/batches/${encodeURIComponent(batchId)}`, {}, signal); -} -async function getBizCollectCommandRaw(client, args, signal) { - const batchId = str2(args, "batch_id").trim(); - const commandId = str2(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); -} - -// src/netx/topology-handlers.ts -function asRecord2(value) { - return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; -} -function str3(args, key, fallback = "") { - const v = args[key]; - if (typeof v === "string") - return v; - if (typeof v === "number" || typeof v === "boolean") - return String(v); - return fallback; -} -function num3(args, key) { - const v = args[key]; - return typeof v === "number" && Number.isFinite(v) ? v : undefined; -} -function bool3(args, key) { - const v = args[key]; - return typeof v === "boolean" ? v : undefined; -} -function strList2(args, key) { - const v = args[key]; - if (!Array.isArray(v)) - return []; - return v.map((x) => String(x).trim()).filter((x) => x.length > 0); -} -function clampInt2(value, fallback, min, max) { - const n = value === undefined ? fallback : Math.trunc(value); - return Math.max(min, Math.min(max, n)); -} -function unwrap(out) { - if (out.ok !== true) - return out; - const data = asRecord2(out.data); - if (Object.keys(data).length === 0) - return { ok: true, data: out.data }; - return { ok: true, ...data, data }; -} -function filterFields(args) { - const out = {}; - for (const key of ["keyword", "role", "vendor", "link_status"]) { - const v = str3(args, key).trim(); - if (v) - out[key] = v; - } - return out; -} -function edgeEndpoints(edge) { - const a = str3(edge, "a_node_id") || str3(edge, "a"); - const b = str3(edge, "b_node_id") || str3(edge, "b"); - return [a.trim(), b.trim()]; -} -function collapseEdgesToLinks(edges, includeNames = false) { - const buckets = new Map; - for (const raw of edges) { - if (!raw || typeof raw !== "object" || Array.isArray(raw)) - continue; - const e = raw; - 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}\x00${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 = str3(e, "a_name"); - row.b_name = str3(e, "b_name"); - } else { - row.a_name = str3(e, "b_name"); - row.b_name = str3(e, "a_name"); - } - } - buckets.set(key, row); - } - return [...buckets.values()]; -} -function summarizeViewGraph(graph, sample = 20) { - 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 = asRecord2(n); - return { - id: str3(row, "id") || str3(row, "fabric_node_id"), - name: str3(row, "name"), - x: row.x, - y: row.y, - level: row.level, - role: row.role - }; - }); - return { - ok: true, - view_id: str3(graph, "id") || str3(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) { - 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)." - }; -} -async function getTopologyTree(client, args, signal) { - const out = unwrap(await client.get("/v1/topology/tree", undefined, signal)); - if (out.ok !== true) - return out; - const compact = bool3(args, "compact") !== false; - const maxDepth = num3(args, "max_depth"); - if (!compact && maxDepth === undefined) - return out; - return { ...out, compact, max_depth: maxDepth ?? null }; -} -async function getTopologyView(client, args, signal) { - const viewId = str3(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, 120000)); - if (out.ok !== true) - return out; - const detail = str3(args, "detail", "summary").trim().toLowerCase() || "summary"; - if (detail === "full") - return { ...out, detail: "full" }; - return summarizeViewGraph(out, clampInt2(num3(args, "sample"), 20, 0, 200)); -} -async function createTopologyFolder(client, args, signal) { - const name = str3(args, "name").trim(); - if (!name) - return { ok: false, error: "name_required" }; - const body = { name }; - const parentId = str3(args, "parent_id").trim(); - if (parentId) - body.parent_id = parentId; - const locale = str3(args, "locale").trim(); - if (locale) - body.locale = locale; - if (num3(args, "sort_order") !== undefined) - body.sort_order = Math.trunc(num3(args, "sort_order")); - return unwrap(await client.post("/v1/topology/folders", body, signal)); -} -async function addTopologyViewNodes(client, args, signal) { - const viewId = str3(args, "view_id").trim(); - if (!viewId) - return { ok: false, error: "view_id_required" }; - if (strList2(args, "managed_ne_ids").length || strList2(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 = strList2(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 (num3(args, "max_nodes") !== undefined) { - const got = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal)); - if (got.ok === true) { - const data = asRecord2(got.data); - const filt = asRecord2(got.filter ?? data.filter); - const membership = asRecord2(filt.membership ?? {}); - membership.max_nodes = clampInt2(num3(args, "max_nodes"), 2000, 1, 2000); - filt.membership = membership; - await client.patch(`/v1/topology/views/${encodeURIComponent(viewId)}`, { filter: filt }, signal); - } - } - const layout = str3(args, "layout", "grid").trim() || "grid"; - const body = { - managed_ne_ids: [], - layout, - ...filters - }; - if (fabricIds.length) - body.fabric_node_ids = fabricIds; - if (num3(args, "limit") !== undefined) - body.limit = clampInt2(num3(args, "limit"), 500, 1, 2000); - if (num3(args, "offset") !== undefined) - body.offset = clampInt2(num3(args, "offset"), 0, 0, Number.MAX_SAFE_INTEGER); - return unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes`, body, signal, 180000)); -} -async function removeTopologyViewNodes(client, args, signal) { - const viewId = str3(args, "view_id").trim(); - if (!viewId) - return { ok: false, error: "view_id_required" }; - const body = { ...filterFields(args) }; - const fabricIds = strList2(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)); -} -async function copyTopologyViewNodes(client, args, signal) { - const sourceId = str3(args, "source_view_id").trim(); - const targetId = str3(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, 120000)); - if (source.ok !== true) - return source; - const nodes = Array.isArray(source.nodes) ? source.nodes : []; - const limit = num3(args, "limit"); - const selected = typeof limit === "number" ? nodes.slice(0, clampInt2(limit, nodes.length, 1, 2000)) : nodes; - const fabricIds = selected.map((n) => { - const row = asRecord2(n); - return str3(row, "id") || str3(row, "fabric_node_id"); - }).filter(Boolean); - if (bool3(args, "dry_run") === true) { - return { ok: true, dry_run: true, would_copy: fabricIds.length, source_view_id: sourceId, target_view_id: targetId }; - } - if (bool3(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, 180000)); - if (add.ok !== true) - return add; - if (bool3(args, "copy_positions") !== false) { - const ox = num3(args, "offset_x") ?? 0; - const oy = num3(args, "offset_y") ?? 0; - const positions = selected.map((n) => { - const row = asRecord2(n); - const id = str3(row, "id") || str3(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, 120000); - } - } - return { ok: true, added: fabricIds.length, source_view_id: sourceId, target_view_id: targetId, ...asRecord2(add) }; -} -async function updateTopologyViewPositions(client, args, signal) { - const viewId = str3(args, "view_id").trim(); - if (!viewId) - return { ok: false, error: "view_id_required" }; - const body = {}; - if (Array.isArray(args.positions)) - body.positions = args.positions; - const layout = str3(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"]) { - if (num3(args, key) !== undefined) - body[key] = num3(args, key); - } - const fabricIds = strList2(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, 120000)); -} -async function projectTopologyNeighbors(client, args, signal) { - const viewId = str3(args, "view_id").trim(); - if (!viewId) - return { ok: false, error: "view_id_required" }; - const body = {}; - const seeds = strList2(args, "seed_fabric_node_ids").length ? strList2(args, "seed_fabric_node_ids") : strList2(args, "fabric_node_ids"); - if (seeds.length) - body.seed_fabric_node_ids = seeds; - const mids = strList2(args, "managed_ne_ids"); - if (mids.length) - body.managed_ne_ids = mids; - const region = str3(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, 180000)); - if (out.ok !== true) - return out; - const detail = str3(args, "detail", "summary").trim().toLowerCase() || "summary"; - if (detail === "full" || detail === "raw" || detail === "graph") { - return { ...out, detail: "full", projected: true }; - } - return { ...summarizeViewGraph(out, clampInt2(num3(args, "sample"), 20, 0, 100)), projected: true, view_id: viewId }; -} -async function queryTopologyFabricNodes(client, args, signal) { - let mode = str3(args, "mode").trim().toLowerCase(); - const q = str3(args, "q").trim(); - const keyword = str3(args, "keyword").trim(); - if (!mode) { - if (bool3(args, "summary") === true) - mode = "summary"; - else if (q) - mode = "search"; - else - mode = "list"; - } - if (mode === "summary" || mode === "stats" || mode === "count") { - const out2 = unwrap(await client.get("/v1/topology/fabric/summary", undefined, signal)); - return out2.ok === true ? { ...out2, mode: "summary" } : out2; - } - 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: clampInt2(num3(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num3(args, "page_size") ?? num3(args, "limit"), 50, 1, 200) - }, signal)); - } - const params = { - page: clampInt2(num3(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num3(args, "page_size") ?? num3(args, "limit"), 50, 1, 500) - }; - for (const key of ["keyword", "role", "level", "level_major", "region_folder_id", "link_status"]) { - const v = str3(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; -} -async function classifyTopologyFabricNodes(client, args, signal) { - const action = str3(args, "action").trim().toLowerCase(); - if (!action) - return { ok: false, error: "action_required" }; - if (action === "match") { - const pattern = str3(args, "pattern").trim() || str3(args, "q").trim(); - if (!pattern) - return { ok: false, error: "pattern_required" }; - return unwrap(await client.post("/v1/topology/fabric/nodes/match", { - pattern, - match_field: str3(args, "match_field", "name").trim() || "name", - sample_limit: clampInt2(num3(args, "sample_limit"), 50, 1, 200) - }, signal)); - } - if (action === "tag") { - const body = { - fabric_node_ids: strList2(args, "fabric_node_ids"), - dry_run: bool3(args, "dry_run") === true - }; - const pattern = str3(args, "pattern").trim() || str3(args, "q").trim(); - if (pattern) - body.pattern = pattern; - if (args.level !== undefined) - body.level = args.level; - const role = str3(args, "role").trim(); - if (role) - body.role = role; - const region = str3(args, "region_folder_id").trim(); - if (region) - body.region_folder_id = region; - if (bool3(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 = str3(args, "fabric_node_id").trim() || str3(args, "node_id").trim(); - if (!nodeId) - return { ok: false, error: "fabric_node_id_required" }; - const body = {}; - if (args.level !== undefined) - body.level = args.level; - const role = str3(args, "role").trim(); - if (role) - body.role = role; - const region = str3(args, "region_folder_id").trim(); - if (region) - body.region_folder_id = region; - if (bool3(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: str3(args, "kind", "any").trim() || "any", - page: clampInt2(num3(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num3(args, "page_size") ?? num3(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 = {}; - if (bool3(args, "overwrite_manual") === true) - params.skip_manual = false; - else if (bool3(args, "skip_manual") !== undefined) - params.skip_manual = bool3(args, "skip_manual") !== false; - if (bool3(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 }; -} -async function queryTopologyNeighborhood(client, args, signal) { - const nodeId = str3(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: clampInt2(num3(args, "depth"), 1, 1, 3), - layer: str3(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) }; -} -async function queryTopologyEdges(client, args, signal) { - const params = { - page: clampInt2(num3(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num3(args, "page_size"), 100, 1, 500), - layer: str3(args, "layer", "physical").trim() || "physical" - }; - for (const key of ["node_id", "keyword", "status", "source"]) { - const v = str3(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 = str3(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 }; -} -async function suggestSinkHubs(client, args, signal) { - const viewId = str3(args, "source_view_id").trim() || str3(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, 120000)); - 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; - for (const raw of edges) { - const [a, b] = edgeEndpoints(asRecord2(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([ - ...strList2(args, "exclude_portal_ids"), - ...strList2(args, "exclude_fabric_node_ids") - ]); - const pick = clampInt2(num3(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 row2 = asRecord2(n); - return (str3(row2, "id") || str3(row2, "fabric_node_id")) === fabric_node_id; - }); - const row = asRecord2(node); - return { - fabric_node_id, - degree: deg, - name: str3(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." - }; -} -async function analyzeTopologyViewLayout(client, args, signal) { - const viewId = str3(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, 120000)); - 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 = asRecord2(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; - for (const raw of edges) { - const [a, b] = edgeEndpoints(asRecord2(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: str3(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." - }; -} -async function sinkTopologyDualUnits(_client, args) { - return { - ok: false, - error: "sink_dual_units_requires_netx_topology_mcp", - source_view_id: str3(args, "source_view_id"), - sink_view_id: str3(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." - }; -} -async function layoutTopologyView(client, args, signal) { - if (bool3(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 = str3(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 = str3(args, "view_id").trim(); - const fromId = str3(args, "source_view_id").trim(); - const ids = strList2(args, "fabric_node_ids"); - const params = asRecord2(args.params); - const fabricIds = ids.length ? ids : strList2(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 = bool3(params, "copy_positions") !== false && bool3(args, "copy_positions") !== false; - const removeFromSource = bool3(params, "remove_from_source") !== false; - const source = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(fromId)}`, undefined, signal, 120000)); - 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 = asRecord2(n); - const id = str3(row, "id") || str3(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, 180000)); - 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 = asRecord2(n); - return { - fabric_node_id: str3(row, "id") || str3(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, 120000); - } - 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: bool3(params, "park") === true || bool3(args, "park") === true - }; - } - return layoutRecipeUnavailable(action); -} - -// src/netx/tools.ts function packageVersion() { try { const here = dirname5(fileURLToPath2(import.meta.url)); @@ -3181,65 +1812,89 @@ function packageVersion() { return "0"; } } -var MUTATING_TOOLS = new Set([ - "netx__execManagedNe", - "netx__createTopologyFolder", - "netx__addTopologyViewNodes", - "netx__removeTopologyViewNodes", - "netx__copyTopologyViewNodes", - "netx__updateTopologyViewPositions", - "netx__projectTopologyNeighbors", - "netx__classifyTopologyFabricNodes", - "netx__layoutTopologyView" -]); -var str4 = (description) => ({ type: "string", ...description ? { description } : {} }); -var num4 = (description) => ({ type: "number", ...description ? { description } : {} }); -var bool4 = (description) => ({ type: "boolean", ...description ? { description } : {} }); -var strArr = (description) => ({ - type: "array", - items: { type: "string" }, - ...description ? { description } : {} -}); -var anyObj = (description) => ({ - type: "object", - additionalProperties: true, - ...description ? { description } : {} -}); -function renderJson2(_args, value) { - return [{ type: "text", text: JSON.stringify(value, null, 0) }]; +function renderMcpResult(_args, value) { + if (value && typeof value === "object" && !Array.isArray(value)) { + const row = value; + if (typeof row.text === "string" && row.text.length > 0) { + return [{ type: "text", text: row.text }]; + } + if (Array.isArray(row.content)) { + return [{ type: "text", text: mcpContentText(row.content, JSON.stringify(value)) }]; + } + } + return [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value ?? null) }]; } -var jsonOut2 = { +var mcpOut = { schema: { type: "object", additionalProperties: true }, - render: renderJson2 + render: renderMcpResult }; -function tool2(name, description, parameters, handler, getClient, timeoutMs) { +function parametersFromInputSchema(schema) { + if (schema && typeof schema === "object" && !Array.isArray(schema)) { + const props = schema.properties; + if (props && typeof props === "object" && !Array.isArray(props)) { + return props; + } + if (schema.type === undefined && schema.properties === undefined) { + return schema; + } + } + return {}; +} +function defineRemoteTool(tool2, timeoutMs) { + const name = publicToolName(tool2.name); return defineTool2({ name, - description, - parameters, - output: jsonOut2, + description: tool2.description || name, + parameters: parametersFromInputSchema(tool2.inputSchema), + output: mcpOut, timeoutMs, - isConcurrencySafe: () => !MUTATING_TOOLS.has(name), + isConcurrencySafe: () => tool2.readOnlyHint === true, async execute(args, exec) { - const result = await handler(getClient(), args, exec.signal); - 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 + }, tool2.name, typeof args === "object" && args !== null ? args : {}); + const text = mcpContentText(result.content, ""); + if (result.isError === true) { + throw new Error(text || `Tool "${tool2.name}" failed`); } - return safe; + return JSON.parse(JSON.stringify({ + content: result.content, + ...result.structuredContent !== undefined ? { structuredContent: result.structuredContent } : {}, + ...text ? { text } : {} + })); } }); } function registerNetxTools(ctx, connection, options) { - 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((tool2) => tool2.group).filter(Boolean))]; + if (enabled.length === 0) { publishToolMountStatus({ id, version: packageVersion(), plane: options.plane, - groups: [...enabledGroups], + groups, names: [], count: 0, at: new Date().toISOString(), @@ -3250,392 +1905,40 @@ function registerNetxTools(ctx, connection, options) { }; } const t = typeof connection.toolCallTimeoutMs === "number" && Number.isFinite(connection.toolCallTimeoutMs) && connection.toolCallTimeoutMs > 0 ? connection.toolCallTimeoutMs : 120000; - const client = createNetxClient({ - apiUrl: connection.apiUrl, - lang: connection.lang, - timeoutMs: Math.min(t, 45000), - getToken: () => { - const live = getNetxConnection(); - return live ? live.token.trim() : connection.token.trim(); - }, - getApiUrl: () => getNetxConnection()?.apiUrl ?? connection.apiUrl - }); - const getClient = () => client; - const catalog = [ - tool2("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: str4(), - ne_id: str4("Filter only; do not show UUID to users"), - host_name: str4("Filter by NE host_name"), - ne_name: str4("Legacy alias mapped to keyword"), - keyword: str4("Substring on cause/object/event. Examples: LOS, Fiber Break, bandwidth, CRC."), - time_from: str4("ISO time; filters last_seen_at >="), - time_to: str4("ISO time; filters last_seen_at <="), - page: num4(), - page_size: num4() - }, queryUmeAlarms, getClient, t), - tool2("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: str4("Optional perceived_severity filter (critical/major/minor/warning)."), - top_ne: num4("Max NE buckets (default 50). Ignored when group_by is set."), - exclude_missing_host: bool4("Omit missing host_name from by_ne."), - time_from: str4(), - time_to: str4(), - group_by: str4("When set, routes to raw aggregation. Prefer alarm_host_name."), - group_by2: str4(), - is_cleared: str4(), - ne_id: str4(), - event_type: str4(), - keyword: str4(), - limit: num4() - }, aggregateUmeAlarms, getClient, t), - tool2("netx__runNmsDiagnostics", "NMS alarm diagnostics: severity, top_event_types, top_alarm_codes, top_ne, protocol buckets, freshness meta.", {}, runUmeDiagnostics, getClient, t), - tool2("netx__queryNmsNeInventory", "Paged NMS NE inventory synced in netx (keyword matches ne_id/ne_name/user_label/ip/host_name).", { - keyword: str4(), - page: num4(), - page_size: num4() - }, queryUmeNeInventory, getClient, t), - tool2("netx__getNmsNe", "Get single NMS NE detail by ne_id (UUID).", { - ne_id: { type: "string", required: true, description: "NMS inventory ne_id (UUID)." } - }, getUmeNe, getClient, t), - tool2("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: str4(), - is_cleared: str4(), - ne_id: str4(), - event_type: str4(), - keyword: str4(), - time_from: str4(), - time_to: str4(), - order_by: str4("last_seen_at | time_created | perceived_severity | event_type | ne_id"), - order: str4("asc | desc"), - select_fields: strArr(), - field_preset: str4("brief | evidence | ne_debug"), - page: num4(), - page_size: num4() - }, queryUmeAlarmsRaw, getClient, t), - tool2("netx__aggregateNmsAlarmsRaw", "Dynamic aggregation on NMS raw fields (group_by/group_by2); prefer alarm_host_name.", { - group_by: { type: "string", required: true }, - group_by2: str4(), - severity: str4(), - is_cleared: str4(), - ne_id: str4(), - event_type: str4(), - keyword: str4(), - time_from: str4(), - time_to: str4(), - exclude_missing_host: bool4(), - limit: num4() - }, aggregateUmeAlarmsRaw, getClient, t), - tool2("netx__listNmsAlarmFields", "List available fields for NMS raw alarm queries.", {}, listUmeAlarmFields, getClient, t), - tool2("netx__sqlQueryNms", "Read-only SELECT on NMS tables (ume_alarms_current/ume_inventory_ne); server enforces limits. Requires sql:query scope.", { - sql: { type: "string", required: true }, - limit: num4(), - statement_timeout_ms: num4() - }, sqlQueryUme, getClient, t), - tool2("netx__listManagedNe", "List filtered netx managed NEs (keyword/vendor/connect_status required); use before execManagedNe.", { - keyword: str4(), - vendor: str4(), - connect_status: str4("unknown | testing | pass | fail"), - page: num4(), - page_size: num4() - }, listManagedNe, getClient, t), - tool2("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: str4("Managed NE id"), - managed_ne_id: str4("Alias for ne_id"), - id: str4("Alias for ne_id") - }, getManagedNe, getClient, t), - tool2("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: str4(), - nms_ne_id: str4("NMS inventory id; alias ume_ne_id"), - ume_ne_id: str4("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", - description: "Per-NE command sets: each item is one NE (ne_id OR nms_ne_id) with commands[].", - items: { - type: "object", - additionalProperties: false, - properties: { - ne_id: str4(), - nms_ne_id: str4(), - ume_ne_id: str4(), - commands: strArr() - } - } - }, - commands: strArr("Commands for single NE, or shared commands for batch."), - read_timeout_sec: num4("Per-command read timeout (default 60; use 90–120 for slow show)."), - concurrency: num4("Parallel NEs for batch mode (1–8, default 4)."), - async: bool4("true=background job_id + getNeExecJob; false=force sync; omit=auto for ~4+ NEs (NETX_NE_EXEC_ASYNC_MIN_NES).") - }, execManagedNe, getClient, Math.max(t, 300000)), - tool2("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: str4("Job id from execManagedNe async response.") - }, getNeExecJob, getClient, Math.max(t, 60000)), - tool2("netx__listCliTargets", "List CLI-capable targets (managed NE and/or NMS inventory). Call once per session with keyword/source, cache ids, then execManagedNe.", { - source: str4("managed | nms | ume | all"), - keyword: str4(), - page: num4(), - page_size: num4() - }, listCliTargets, getClient, t), - tool2("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: str4("NMS inventory id"), - from_ume_ne_id: str4("Legacy alias of from_nms_ne_id"), - from_managed_ne_id: str4(), - to_nms_ne_id: str4("NMS inventory id"), - to_ume_ne_id: str4("Legacy alias of to_nms_ne_id"), - to_managed_ne_id: str4(), - max_paths: num4(), - max_hops: num4(), - layer: str4(), - detail: str4("summary | full") - }, findTopologyPaths, getClient, t), - tool2("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: str4("all | projects | tasks (default all)"), - purpose: str4("Filter tasks: portrait | cutover_hf | empty=all"), - status: str4("Filter by status (e.g. active / paused)"), - q: str4("Keyword on name / ne_name / note / id"), - limit: num4("Max rows per list (default 100, max 500)") - }, listBizMonitors, getClient, t), - tool2("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: str4("auto | all | cutover | collect | runs (default auto)"), - project_id: str4("Cutover project id → cutover_batches"), - task_id: str4("biz_state task id → collect_batches"), - batch_id: str4("Cutover batch id (filter or kind=runs)"), - include_runs: bool4("Attach recent evaluate runs onto listed cutover batches"), - status: str4("Filter batch status"), - limit: num4("Max batches (default 50)"), - runs_limit: num4("Max runs per batch when include_runs / kind=runs (default 10)") - }, listBizMonitorBatches, getClient, t), - tool2("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: str4("Cutover / biz-migration project id"), - task_id: str4("biz_state task id (optional if project_id set)") - }, getBizMonitorContext, getClient, t), - tool2("netx__getBizMonitorBoard", "Cutover batch board: project/batch status, evaluate run summary, sheet cards, verdict counts, metric_batches.", { - batch_id: { type: "string", required: true }, - run_id: str4("Optional evaluate run id") - }, getBizMonitorBoard, getClient, t), - tool2("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", required: true }, - status: str4("open|carried|resolved or empty=all"), - limit: num4() - }, listBizMonitorReds, getClient, t), - tool2("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", required: true }, - metric_id: str4(), - sheet_id: str4(), - verdict: str4(), - color: str4("e.g. red"), - q: str4(), - limit: num4(), - offset: num4() - }, getBizMonitorDiffs, getClient, t), - tool2("netx__getBizCollectBatch", "One biz_state collect batch: commands (raw_command, parse_status, preview), parsed metrics.", { - batch_id: { type: "string", required: true } - }, getBizCollectBatch, getClient, t), - tool2("netx__getBizCollectCommandRaw", "Full CLI raw_text for one collect command inside a batch (deep dive).", { - batch_id: { type: "string", required: true }, - command_id: { type: "string", required: true } - }, getBizCollectCommandRaw, getClient, t), - tool2("netx__getTopologyTree", "Get topology folder tree (nav roots + Root map canvases). Start here before createTopologyFolder.", { compact: bool4(), max_depth: num4() }, getTopologyTree, getClient, t), - tool2("netx__getTopologyView", "Get a topology view by view_id. Default detail=summary with sample_nodes + links[].", { - view_id: { type: "string", required: true }, - detail: str4("summary | full"), - sample: num4() - }, getTopologyView, getClient, t), - tool2("netx__createTopologyFolder", "Create topology folders / region canvases. Only way to create canvases (ne:write).", { - name: { type: "string", required: true }, - parent_id: str4(), - locale: str4("zh | en"), - sort_order: num4() - }, createTopologyFolder, getClient, t), - tool2("netx__addTopologyViewNodes", "Bulk-place fabric nodes on a view via filters or fabric_node_ids (never managed/UME ids).", { - view_id: { type: "string", required: true }, - max_nodes: num4(), - keyword: str4(), - role: str4(), - vendor: str4(), - link_status: str4(), - limit: num4(), - offset: num4(), - fabric_node_ids: strArr(), - layout: str4("grid | keep") - }, addTopologyViewNodes, getClient, t), - tool2("netx__removeTopologyViewNodes", "Remove placements from a view (not fabric) by filters or fabric_node_ids.", { - view_id: { type: "string", required: true }, - keyword: str4(), - role: str4(), - vendor: str4(), - link_status: str4(), - fabric_node_ids: strArr() - }, removeTopologyViewNodes, getClient, t), - tool2("netx__copyTopologyViewNodes", "Clone fabric placements from source_view_id onto target_view_id (optional clear_target / copy_positions).", { - source_view_id: { type: "string", required: true }, - target_view_id: { type: "string", required: true }, - copy_positions: bool4(), - clear_target: bool4(), - offset_x: num4(), - offset_y: num4(), - limit: num4(), - dry_run: bool4() - }, copyTopologyViewNodes, getClient, t), - tool2("netx__updateTopologyViewPositions", "Move nodes on a view via positions[] or layout=grid|offset|stack + filters.", { - view_id: { type: "string", required: true }, - positions: { - type: "array", - description: "Manual placements: [{ fabric_node_id, x, y }, …]", - items: { - type: "object", - additionalProperties: false, - properties: { - fabric_node_id: str4(), - x: num4(), - y: num4() - } - } - }, - layout: str4("grid | offset | stack"), - keyword: str4(), - role: str4(), - vendor: str4(), - link_status: str4(), - fabric_node_ids: strArr(), - offset_x: num4(), - offset_y: num4() - }, updateTopologyViewPositions, getClient, t), - tool2("netx__projectTopologyNeighbors", "Project existing fabric neighbors of on-view nodes onto the canvas.", { - view_id: { type: "string", required: true }, - seed_fabric_node_ids: strArr(), - managed_ne_ids: strArr(), - region_folder_id: str4(), - detail: str4("summary | full"), - sample: num4() - }, projectTopologyNeighbors, getClient, t), - tool2("netx__queryTopologyFabricNodes", "Fabric inventory: mode=summary|list|search (keyword/role/level/region/link_status).", { - mode: str4("summary | list | search"), - q: str4(), - keyword: str4(), - role: str4(), - level: str4(), - level_major: str4(), - region_folder_id: str4(), - link_status: str4(), - page: num4(), - page_size: num4(), - limit: num4(), - summary: bool4() - }, queryTopologyFabricNodes, getClient, t), - tool2("netx__classifyTopologyFabricNodes", "Classify fabric nodes: action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules.", { - action: { type: "string", required: true }, - pattern: str4(), - q: str4(), - match_field: str4(), - sample_limit: num4(), - fabric_node_ids: strArr(), - fabric_node_id: str4(), - node_id: str4(), - level: str4(), - role: str4(), - region_folder_id: str4(), - clear_region: bool4(), - dry_run: bool4(), - kind: str4(), - page: num4(), - page_size: num4(), - skip_manual: bool4(), - overwrite_manual: bool4(), - fill_empty_only: bool4() - }, classifyTopologyFabricNodes, getClient, t), - tool2("netx__queryTopologyNeighborhood", "Neighborhood around a fabric node (depth 1–3) with compact links[].", { - node_id: { type: "string", required: true }, - depth: num4(), - layer: str4() - }, queryTopologyNeighborhood, getClient, t), - tool2("netx__queryTopologyEdges", "Fabric adjacency links[] (detail=adjacency) or port rows (detail=ports).", { - node_id: str4(), - keyword: str4(), - layer: str4(), - status: str4(), - source: str4("lldp | ume | manual"), - detail: str4("adjacency | ports"), - page: num4(), - page_size: num4() - }, queryTopologyEdges, getClient, t), - tool2("netx__suggestSinkHubs", "Rank hub territories on a source view for non-dual move_nodes(park) batches (degree stand-in in DSH).", { - source_view_id: str4(), - view_id: str4(), - pick: num4(), - exclude_portal_ids: strArr(), - exclude_fabric_node_ids: strArr() - }, suggestSinkHubs, getClient, t), - tool2("netx__analyzeTopologyViewLayout", "Basic layout QA for a view (bbox/hubs). Full dual_unit/crossing scores need netx-topology MCP.", { - view_id: str4(), - folder_id: str4(), - detail: str4("summary | structure | hotspots | blocks | both"), - score_profile: str4(), - sight_limit: num4(), - max_views: num4(), - min_nodes: num4(), - max_nodes: num4(), - with_meta: bool4() - }, analyzeTopologyViewLayout, getClient, t), - tool2("netx__sinkTopologyDualUnits", "Drain dual_unit eyes from source to sink (requires netx-topology MCP layout engine in DSH).", { - source_view_id: str4(), - sink_view_id: str4(), - max_units: num4(), - min_nodes: num4(), - max_nodes: num4(), - max_batch_nodes: num4(), - layout_batch: bool4(), - until_empty: bool4(), - dry_run: bool4() - }, sinkTopologyDualUnits, getClient, t), - tool2("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: str4(), - action: str4(), - source_view_id: str4(), - recipe: str4(), - preset: str4(), - mode: str4("preview | apply"), - tune: bool4(), - params: anyObj("Action-specific options (move_nodes / recipes / polish)."), - catalog: bool4(), - fabric_node_ids: strArr(), - park: bool4(), - copy_positions: bool4() - }, layoutTopologyView, getClient, Math.max(t, 180000)) - ]; - const selected = catalog.filter((entry) => allow.has(entry.name)); - const names = selected.map((entry) => entry.name); const disposers = []; + const names = []; try { - for (const entry of selected) - disposers.push(ctx.tools.register(entry)); - publishToolMountStatus({ - id, - version: packageVersion(), - plane: options.plane, - groups: [...enabledGroups], - names, - count: names.length, - at: new Date().toISOString(), - source: options.source ?? options.plane - }); + for (const tool2 of enabled) { + const definition = defineRemoteTool(tool2, t); + disposers.push(ctx.tools.register(definition)); + names.push(publicToolName(tool2.name)); + } } catch (error) { - for (const dispose of disposers.reverse()) + for (const dispose of disposers.splice(0)) dispose(); publishToolMountStatus({ id, version: packageVersion(), plane: options.plane, - groups: [...enabledGroups], + groups, names: [], count: 0, at: new Date().toISOString(), - error: error instanceof Error ? error.message : String(error), + error: String(error), source: options.source ?? options.plane }); throw error; } + publishToolMountStatus({ + id, + version: packageVersion(), + plane: options.plane, + groups, + names, + count: names.length, + at: new Date().toISOString(), + source: options.source ?? options.plane + }); return () => { for (const dispose of disposers) dispose(); @@ -3653,6 +1956,16 @@ function packageVersion2() { return "0"; } } +function disabledByGroupFor(groupIds, connection) { + const catalog = getMcpCatalogTools(); + const out = {}; + for (const groupId of groupIds) { + const names = disabledPublicNames(catalog, [groupId], connection.disabledTools, connection.readOnlyOnly); + if (names.length > 0) + out[groupId] = names; + } + return out; +} function applyGroupToolsPlugin(ctx, options) { let unregisterTools; let unregisterKbLocal; @@ -3678,9 +1991,10 @@ function applyGroupToolsPlugin(ctx, options) { unregisterTools = registerNetxTools(ctx, connection, { plane: "preset", forceGroups: enabled, - source: options.name + source: options.name, + presetId: presetId() }); - ctx.logger.info("%s: groups=[%s] → %s tokenConfigured=%s live=%s", options.name, enabled.join(",") || "(none)", connection.apiUrl, connection.token.trim().length > 0, getNetxConnection() !== undefined); + ctx.logger.info("%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({ id: mountId, @@ -3708,6 +2022,9 @@ function applyGroupToolsPlugin(ctx, options) { remountTools(); remountKbLocal(); }); + const stopCatalogWatch = watchMcpCatalog(() => { + remountTools(); + }); const stopKbToolWatch = watchKbContext(() => { remountKbLocal(); }); @@ -3737,8 +2054,13 @@ function applyGroupToolsPlugin(ctx, options) { skillAbort = new AbortController; unregisterSkills?.(); unregisterSkills = undefined; + const connection = requireNetxConnection(); const enabled = resolveGroups(); - registerGroupSkills(skillsCtx, enabled, options.name, skillAbort.signal).then((dispose) => { + registerGroupSkills(skillsCtx, enabled, options.name, { + signal: skillAbort.signal, + disabledByGroup: disabledByGroupFor(enabled, connection), + lang: connection.lang + }).then((dispose) => { if (gen !== skillGeneration) { dispose(); return; @@ -3783,6 +2105,9 @@ function applyGroupToolsPlugin(ctx, options) { remountKbSkill(); remountKbPack(); }); + const stopSkillCatalogWatch = watchMcpCatalog(() => { + remountSkills(); + }); const stopKbWatch = watchKbContext(() => { remountKbSkill(); remountKbPack(); @@ -3796,6 +2121,7 @@ function applyGroupToolsPlugin(ctx, options) { skillAbort.abort(); packAbort.abort(); stopSkillWatch(); + stopSkillCatalogWatch(); stopKbWatch(); unregisterSkills?.(); unregisterSkills = undefined; @@ -3808,6 +2134,7 @@ function applyGroupToolsPlugin(ctx, options) { ctx.effect(() => () => { clearInterval(pollTimer); stopToolWatch(); + stopCatalogWatch(); stopKbToolWatch(); unregisterTools?.(); unregisterTools = undefined; diff --git a/lib/agent-tools-ops.js b/lib/agent-tools-ops.js index d50a822..6bdd43f 100644 --- a/lib/agent-tools-ops.js +++ b/lib/agent-tools-ops.js @@ -21,52 +21,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", @@ -101,14 +55,6 @@ function groupsForPlane(groups, plane, only) { const allow = new Set(only); return enabled.filter((id) => allow.has(id)); } -function toolNamesForGroups(groupIds) { - const out = new Set; - for (const id of groupIds) { - for (const name of TOOLS_BY_GROUP[id] ?? []) - out.add(name); - } - return out; -} function parsePresetIds(value) { const current = readConfigValue(value); if (typeof current !== "string") @@ -122,6 +68,234 @@ function groupsForPreset(connection, presetId, only) { return CAPABILITY_GROUP_IDS.filter((id) => (!only?.length || only.includes(id)) && !policy[id].public && (presetId === "netxops" ? policy[id].inPreset : policy[id].presets?.includes(presetId))); } +// src/netx/mcp-types.ts +var NETX_TOOL_PREFIX = "netx__"; +function publicToolName(serverName) { + return `${NETX_TOOL_PREFIX}${serverName}`; +} + +// src/netx/mcp-catalog.ts +function disabledSet(disabledTools) { + const out = new Set; + for (const name of disabledTools ?? []) { + const trimmed = typeof name === "string" ? name.trim() : ""; + if (trimmed) + out.add(trimmed); + } + return out; +} +function filterCatalogTools(catalog, enabledGroups, policy) { + const allowGroups = new Set(enabledGroups); + const disabled = disabledSet(policy.disabledTools); + const readOnlyOnly = policy.readOnlyOnly === true; + const out = []; + 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; +} +function resolveEnabledTools(catalog, options) { + 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); +} +function disabledPublicNames(catalog, enabledGroups, disabledTools, readOnlyOnly) { + const allowGroups = new Set(enabledGroups); + const disabled = disabledSet(disabledTools); + const names = []; + 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(); +} + +// src/netx/mcp-remote.ts +var PROTOCOL_VERSION = "2025-03-26"; +var CLIENT_INFO = { name: "dsh-netxops", version: "0.1.71" }; + +class McpRemoteError extends Error { + status; + constructor(message, status) { + super(message); + this.name = "McpRemoteError"; + this.status = status; + } +} +function mcpEndpointUrl(apiUrl) { + const trimmed = apiUrl.trim().replace(/\/$/, ""); + if (!trimmed) + return ""; + return `${trimmed}/mcp`; +} +function parseMcpResponseBody(raw) { + 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 = []; + 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 {} + } + } + throw new McpRemoteError("mcp response was not JSON"); +} +function asRpcResult(payload) { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + throw new McpRemoteError("invalid JSON-RPC envelope from /mcp"); + } + const row = payload; + if (row.error && typeof row.error === "object" && !Array.isArray(row.error)) { + const err = row.error; + 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.result; +} +var nextId = 1; +async function postRpc(options, method, params, notification = false) { + const headers = { + "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 : 60000; + 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 = () => { + abort.abort(); + }; + options.signal?.addEventListener("abort", onOuterAbort, { once: true }); + let 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; + } + return asRpcResult(parseMcpResponseBody(responseText)); +} +async function callMcpTool(options, name, args) { + 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 + }); + const content = Array.isArray(result.content) ? result.content : []; + return { + content, + ...result.structuredContent !== undefined ? { structuredContent: result.structuredContent } : {}, + ...result.isError === true ? { isError: true } : {} + }; +} +function mcpContentText(content, fallback = "") { + const parts = []; + for (const block of content) { + if (!block || typeof block !== "object" || Array.isArray(block)) + continue; + const row = block; + if (row.type === "text" && typeof row.text === "string") + parts.push(row.text); + } + return parts.join(` +`) || fallback; +} + +// src/netx/mcp-store.ts +var STORE_KEY = Symbol.for("dsh-netxops.mcp-catalog-store"); +var EMPTY = Object.freeze({ + phase: "idle", + mcpUrl: "", + tools: Object.freeze([]), + error: "", + listedAt: null, + fromCache: false +}); +function store() { + const root = globalThis; + let current = root[STORE_KEY]; + if (current === undefined) { + current = { state: EMPTY, listeners: new Set, generation: 0 }; + root[STORE_KEY] = current; + } + return current; +} +function getMcpCatalogTools() { + return store().state.tools; +} +function watchMcpCatalog(listener) { + const state = store(); + state.listeners.add(listener); + return () => { + state.listeners.delete(listener); + }; +} + // src/netx/group-skills.ts import { existsSync } from "node:fs"; import { readdir, readFile, stat } from "node:fs/promises"; @@ -271,7 +445,24 @@ async function loadGroupSkills(skillsRoot, groupId) { } return { skills, skipped }; } -async function registerGroupSkills(ctx, groupIds, providerLabel, signal) { +function disabledFootnote(names, lang) { + if (names.length === 0) + return ""; + const list = names.join(", "); + const en = (lang ?? "").trim().toLowerCase().startsWith("en"); + return en ? ` + +## Disabled tools +The following tools are disabled: ${list}. Do not call them. +` : ` + +## 已禁用工具 +以下工具已禁用:${list},不要调用。 +`; +} +async function registerGroupSkills(ctx, groupIds, providerLabel, signalOrOptions) { + const options = signalOrOptions instanceof AbortSignal || signalOrOptions === undefined ? { signal: signalOrOptions } : signalOrOptions; + const signal = options.signal; const skillsApi = ctx.skills; if (!skillsApi || typeof skillsApi.register !== "function") { return () => {}; @@ -293,15 +484,17 @@ async function registerGroupSkills(ctx, groupIds, providerLabel, signal) { if (skills.length === 0) { log?.warn?.("%s: no skills loaded for group=%s from %s", providerLabel, groupId, 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} + +## DeepSeek Harness REST capabilities +Use only tools present in this session. Dual-unit/orbit/polish recipes require server-side topology tools. Use only tools present in this session. +` : skill.content; disposers.push(skillsApi.register({ name: skill.name, description: skill.description, - content: groupId === "topology" ? `${skill.content} - -## DeepSeek Harness REST capabilities -Use 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. -` : skill.content, + content: `${base}${footnote}`, path: skill.path, resourceBase: { kind: "directory", path: skill.directory }, provider: providerLabel, @@ -1119,18 +1312,18 @@ function searchKb(snapshot, args) { } // src/netx/kb-runtime.ts -var STORE_KEY = Symbol.for("dsh-netxops.kb-store"); -function store() { +var STORE_KEY2 = Symbol.for("dsh-netxops.kb-store"); +function store2() { const root = globalThis; - let current = root[STORE_KEY]; + let current = root[STORE_KEY2]; if (current === undefined) { current = { snapshot: unconfiguredKbSnapshot(), listeners: new Set }; - root[STORE_KEY] = current; + root[STORE_KEY2] = current; } return current; } function getKbContext() { - const snap = store().snapshot; + const snap = store2().snapshot; return { ...snap, content: { ...snap.content }, @@ -1138,7 +1331,7 @@ function getKbContext() { }; } function watchKbContext(listener) { - const state = store(); + const state = store2(); state.listeners.add(listener); return () => { state.listeners.delete(listener); @@ -1162,26 +1355,18 @@ function toLosslessJson(value) { return { ok: false, error: "tool_result_not_json", detail: detail.slice(0, 400) }; } } -function omitUndefined(record) { - const out = {}; - for (const [key, value] of Object.entries(record)) { - if (value !== undefined) - out[key] = value; - } - return out; -} // src/netx/tool-mount-status.ts import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs"; import { homedir } from "node:os"; import { dirname as dirname4, join as join7 } from "node:path"; var KEY = Symbol.for("dsh-netxops.mount-inventory"); -function store2() { +function store3() { const root = globalThis; return root[KEY] ??= { mounts: new Map, contexts: new WeakMap, sequence: 0 }; } function getToolMountId(ctx, source) { - const state = store2(); + const state = store3(); let id = state.contexts.get(ctx); if (id === undefined) { id = ++state.sequence; @@ -1190,7 +1375,7 @@ function getToolMountId(ctx, source) { return `${source}:${id}`; } function getToolMountStatuses() { - return [...store2().mounts.values()].map((row) => ({ ...row, groups: [...row.groups], names: [...row.names] })); + return [...store3().mounts.values()].map((row) => ({ ...row, groups: [...row.groups], names: [...row.names] })); } function getToolMountStatus() { const rows = getToolMountStatuses(); @@ -1205,11 +1390,11 @@ function persist() { } catch {} } function publishToolMountStatus(next) { - store2().mounts.set(next.id ?? next.source ?? next.plane, { ...next, groups: [...next.groups], names: [...next.names] }); + store3().mounts.set(next.id ?? next.source ?? next.plane, { ...next, groups: [...next.groups], names: [...next.names] }); persist(); } function removeToolMountStatus(id) { - store2().mounts.delete(id); + store3().mounts.delete(id); persist(); } @@ -1571,139 +1756,20 @@ function normalizeApiUrl(raw) { const search = parsed.search && parsed.search !== "?" ? parsed.search : ""; return { ok: true, url: `${parsed.origin}${path}${search}` }; } -var PROTOCOL_KEY_ZH_TO_EN = { - 其他: "Other", - 时钟: "Clock", - "OTN/光": "OTN/Optical", - 电源: "Power" -}; -function asLang(value) { - return typeof value === "string" ? value : ""; -} -function localizePayload(lang, data) { - 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; - const key = typeof rec.key === "string" ? rec.key : ""; - const mapped = PROTOCOL_KEY_ZH_TO_EN[key]; - if (mapped !== undefined) - rec.key = mapped; - } - return data; -} -function encodeQuery(params) { - const sp = new URLSearchParams; - for (const [key, value] of Object.entries(params)) { - sp.set(key, String(value)); - } - const q = sp.toString(); - return q.length > 0 ? `?${q}` : ""; -} -function createNetxClient(connection) { - const langParams = () => { - const lang = asLang(connection.lang).trim().toLowerCase(); - if (lang.startsWith("en")) - return { lang: "en" }; - return {}; - }; - async function request(method, path, options = {}) { - 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 = { ...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 = { - accept: "application/json", - authorization: `Bearer ${token}` - }; - if (options.body !== undefined) - headers["content-type"] = "application/json"; - const init = { - 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) : {}; - if (typeof data === "object" && data !== null && !Array.isArray(data)) { - return { ok: true, data: localizePayload(connection.lang, data) }; - } - return { ok: true, data: { raw: data } }; - } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - return { ok: false, error: "netx_request_failed", detail: detail.slice(0, 800) }; - } finally { - clearTimeout(timer); - options.signal?.removeEventListener("abort", onOuterAbort); - } - } - return { - get(path, params, signal, timeoutMs) { - return request("GET", path, { params, signal, timeoutMs }); - }, - post(path, body, signal, timeoutMs) { - return request("POST", path, { body, signal, timeoutMs }); - }, - patch(path, body, signal, timeoutMs) { - return request("PATCH", path, { body, signal, timeoutMs }); - } - }; -} -function quoteNeId(neId) { - return encodeURIComponent(neId.trim()); -} // src/netx/runtime.ts -var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store"); -function store3() { +var STORE_KEY3 = Symbol.for("dsh-netxops.connection-store"); +function store4() { const root = globalThis; - let current = root[STORE_KEY2]; + let current = root[STORE_KEY3]; if (current === undefined) { current = { connection: undefined, listeners: new Set }; - root[STORE_KEY2] = current; + root[STORE_KEY3] = current; } return current; } function getNetxConnection() { - return store3().connection; + return store4().connection; } function provisionalNetxConnection() { const envUrl = process.env.NETX_API_URL?.trim() ?? ""; @@ -1715,6 +1781,8 @@ function provisionalNetxConnection() { lang: typeof process.env.NETX_LANG === "string" && process.env.NETX_LANG.trim() ? process.env.NETX_LANG.trim() : "zh", toolCallTimeoutMs: 120000, groups: DEFAULT_CAPABILITY_GROUPS, + disabledTools: [], + readOnlyOnly: false, groupKbInPreset: true, groupKbPublic: false }; @@ -1723,7 +1791,7 @@ function requireNetxConnection() { return getNetxConnection() ?? provisionalNetxConnection(); } function watchNetxConnection(listener) { - const state = store3(); + const state = store4(); state.listeners.add(listener); return () => { state.listeners.delete(listener); @@ -1735,1443 +1803,6 @@ import { readFileSync as readFileSync5 } from "node:fs"; import { dirname as dirname5, join as join9 } from "node:path"; import { fileURLToPath as fileURLToPath2 } from "node:url"; import { defineTool as defineTool2 } from "@deepseek-ai/dsh-tools"; - -// src/netx/handlers.ts -var EXEC_MAX_COMMANDS_CAP = 50; -var EXEC_MAX_COMMANDS_DEFAULT = 5; -function execMaxCommands() { - 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() { - 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) { - if (raw === undefined || raw === null) - return; - if (typeof raw === "boolean") - return raw; - const text = String(raw).trim().toLowerCase(); - if (!text) - return; - if (["1", "true", "yes", "on"].includes(text)) - return true; - if (["0", "false", "no", "off"].includes(text)) - return false; - return; -} -function countExecNeTargets(args) { - let n = 0; - for (const key of ["ne_ids", "nms_ne_ids", "ume_ne_ids"]) { - 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 (str2(args, "ne_id").trim() || nmsOrUme(args, "nms_ne_id", "ume_ne_id")) - return 1; - } - return n; -} -function shouldRunExecAsync(args) { - 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; -} -var UME_RAW_FIELD_PRESETS = { - brief: [ - "alarm_alarm_key", - "alarm_host_name", - "alarm_perceived_severity", - "alarm_event_type", - "alarm_last_seen_at", - "ne_host_name", - "ne_user_label", - "ne_ne_name", - "ne_ip_address", - "ne_exists" - ], - evidence: [ - "alarm_alarm_key", - "alarm_host_name", - "alarm_object_name", - "alarm_event_type", - "alarm_native_probable_cause", - "alarm_perceived_severity", - "alarm_is_cleared", - "alarm_time_created", - "alarm_last_seen_at", - "ne_host_name", - "ne_user_label", - "ne_ne_name", - "ne_ip_address", - "ne_connection_status", - "ne_exists" - ], - ne_debug: [ - "alarm_alarm_key", - "alarm_ne_id", - "alarm_perceived_severity", - "alarm_last_seen_at", - "ne_user_label", - "ne_ne_name", - "ne_ip_address", - "ne_ipv6_address", - "ne_device_level", - "ne_host_name", - "ne_connection_status", - "ne_admin_status", - "ne_address_type", - "ne_maintain_status", - "ne_exists" - ] -}; -function asRecord(value) { - return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; -} -function str2(args, key, fallback = "") { - const v = args[key]; - if (typeof v === "string") - return v; - if (typeof v === "number" || typeof v === "boolean") - return String(v); - return fallback; -} -function nmsOrUme(args, nmsKey, umeKey) { - return str2(args, nmsKey).trim() || str2(args, umeKey).trim(); -} -function nmsOrUmeList(args, nmsKey, umeKey) { - const primary = strList(args, nmsKey); - return primary.length > 0 ? primary : strList(args, umeKey); -} -function num2(args, key) { - const v = args[key]; - return typeof v === "number" && Number.isFinite(v) ? v : undefined; -} -function bool2(args, key) { - const v = args[key]; - return typeof v === "boolean" ? v : undefined; -} -function strList(args, key) { - const v = args[key]; - if (!Array.isArray(v)) - return []; - return v.map((x) => String(x).trim()).filter((x) => x.length > 0); -} -function clampInt(value, fallback, min, max) { - const n = value === undefined ? fallback : Math.trunc(value); - return Math.max(min, Math.min(max, n)); -} -function putStr(params, args, keys) { - for (const key of keys) { - const v = str2(args, key).trim(); - if (v) - params[key] = v; - } -} -async function queryUmeAlarms(client, args, signal) { - let page = clampInt(num2(args, "page"), 1, 1, 2); - const pageSize = clampInt(num2(args, "page_size"), 50, 1, 500); - const params = { page, page_size: pageSize }; - putStr(params, args, ["severity", "ne_id", "host_name", "time_from", "time_to"]); - const keyword = str2(args, "keyword").trim(); - const neName = str2(args, "ne_name").trim(); - if (keyword) - params.keyword = keyword; - else if (neName) - params.keyword = neName; - return client.get("/v1/ume/alarms", params, signal); -} -async function aggregateUmeAlarmsRaw(client, args, signal) { - const params = {}; - putStr(params, args, [ - "group_by", - "group_by2", - "severity", - "is_cleared", - "ne_id", - "event_type", - "keyword", - "time_from", - "time_to", - "limit" - ]); - if ("exclude_missing_host" in args) { - const flag = bool2(args, "exclude_missing_host"); - if (flag !== undefined) - params.exclude_missing_host = flag; - } - return client.get("/v1/ume/alarms/aggregate/raw", params, signal); -} -async function aggregateUmeAlarms(client, args, signal) { - if (str2(args, "group_by").trim()) - return aggregateUmeAlarmsRaw(client, args, signal); - const topNe = clampInt(num2(args, "top_ne"), 50, 0, 500); - const params = { top_ne: topNe }; - if ("exclude_missing_host" in args) { - const flag = bool2(args, "exclude_missing_host"); - if (flag !== undefined) - params.exclude_missing_host = flag; - } - putStr(params, args, ["severity", "time_from", "time_to"]); - return client.get("/v1/ume/alarms/aggregate", params, signal); -} -async function runUmeDiagnostics(client, _args, signal) { - return client.get("/v1/ume/diagnostics", undefined, signal); -} -async function queryUmeNeInventory(client, args, signal) { - const params = { - page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num2(args, "page_size"), 50, 1, 500) - }; - putStr(params, args, ["keyword"]); - return client.get("/v1/ume/inventory/ne", params, signal); -} -async function getUmeNe(client, args, signal) { - const neId = str2(args, "ne_id").trim(); - if (!neId) - return { ok: false, error: "ne_id_required", error_code: "ne_id_required" }; - return client.get(`/v1/ume/inventory/ne/${quoteNeId(neId)}`, undefined, signal); -} -async function queryUmeAlarmsRaw(client, args, signal) { - const params = { - page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num2(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 = str2(args, "field_preset").trim().toLowerCase(); - fields = UME_RAW_FIELD_PRESETS[preset] ?? []; - } - if (fields.length > 0) - params.select_fields = fields.join(","); - return client.get("/v1/ume/alarms/raw", params, signal); -} -async function listUmeAlarmFields(client, _args, signal) { - return client.get("/v1/ume/alarms/fields", undefined, signal); -} -async function sqlQueryUme(client, args, signal) { - const sql = str2(args, "sql").trim(); - if (!sql) - return { ok: false, error: "sql_required" }; - const limit = clampInt(num2(args, "limit"), 200, 1, 2000); - const statementTimeoutMs = clampInt(num2(args, "statement_timeout_ms"), 0, 0, 30000); - return client.post("/v1/sql/ume_query", { - sql, - limit, - statement_timeout_ms: statementTimeoutMs - }, signal, 60000); -} -async function listManagedNe(client, args, signal) { - const keyword = str2(args, "keyword").trim(); - const vendor = str2(args, "vendor").trim(); - const connectStatus = str2(args, "connect_status").trim(); - if (!(keyword || vendor || connectStatus)) { - return { ok: false, error: "managed_ne_filter_required", error_code: "managed_ne_filter_required" }; - } - if (keyword && keyword.length < 2) { - return { ok: false, error: "managed_ne_keyword_too_short", error_code: "managed_ne_keyword_too_short" }; - } - const params = { - page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num2(args, "page_size"), 20, 1, 100) - }; - if (keyword) - params.keyword = keyword; - if (vendor) - params.vendor = vendor; - if (connectStatus) - params.connect_status = connectStatus; - return client.get("/v1/managed-ne", params, signal); -} -async function getManagedNe(client, args, signal) { - const neId = (str2(args, "ne_id") || str2(args, "managed_ne_id") || str2(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: "" } - }; - } - const out = await client.get(`/v1/managed-ne/${quoteNeId(neId)}`, undefined, signal); - if (out.ok === false) { - const detail = `${str2(out, "detail")}${str2(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; -} -async function execManagedNe(client, args, signal) { - 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 body2 = {}; - if (Array.isArray(targetsRaw) && targetsRaw.length > 0) { - const cleaned = []; - for (const t of targetsRaw) { - if (typeof t !== "object" || t === null || Array.isArray(t)) - continue; - const row = t; - const item = {}; - const neId2 = str2(row, "ne_id").trim(); - const umeNeId2 = nmsOrUme(row, "nms_ne_id", "ume_ne_id"); - if (neId2) - item.ne_id = neId2; - if (umeNeId2) - item.ume_ne_id = umeNeId2; - const cmds = Array.isArray(row.commands) ? row.commands.map((c) => String(c).trim()).filter((c) => c.length > 0) : []; - if (cmds.length > 0) - item.commands = cmds; - if (Object.keys(item).length > 0) - cleaned.push(item); - } - body2.targets = cleaned; - } - if (neIds.length > 0) - body2.ne_ids = neIds; - if (umeNeIds.length > 0) - body2.ume_ne_ids = umeNeIds; - if (sharedCommands.length > 0) { - if (sharedCommands.length > maxCmds) { - return { ok: false, error: "too_many_commands", error_code: "too_many_commands" }; - } - body2.commands = sharedCommands; - } - body2.read_timeout_sec = clampInt(num2(args, "read_timeout_sec"), 60, 10, 120); - const concurrency = num2(args, "concurrency"); - if (concurrency !== undefined) - body2.concurrency = clampInt(concurrency, 4, 1, 8); - if (wantAsync) { - const out3 = await client.post("/v1/managed-ne/exec-jobs", body2, signal, 60000); - if (out3.ok !== true) - return out3; - const data3 = asRecord(out3.data); - return Object.keys(data3).length > 0 ? data3 : out3; - } - const out2 = await client.post("/v1/managed-ne/exec-batch", body2, signal, 600000); - if (out2.ok !== true) - return out2; - const data2 = asRecord(out2.data); - if (data2.ok === false) { - return { ok: false, data: data2, error: str2(data2, "error", "exec_batch_failed") }; - } - return { ok: true, data: data2 }; - } - const neId = str2(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 = { - commands: sharedCommands, - read_timeout_sec: clampInt(num2(args, "read_timeout_sec"), 60, 10, 120) - }; - if (neId) - body.ne_id = neId; - if (umeNeId) - body.ume_ne_id = umeNeId; - if (wantAsync) { - const out2 = await client.post("/v1/managed-ne/exec-jobs", body, signal, 60000); - if (out2.ok !== true) - return out2; - const data2 = asRecord(out2.data); - return Object.keys(data2).length > 0 ? data2 : out2; - } - const out = await client.post("/v1/managed-ne/exec", body, signal, 300000); - if (out.ok !== true) - return out; - const data = asRecord(out.data); - if (data.ok === false) { - return { ok: false, data, error: str2(data, "error", "exec_failed") }; - } - return { ok: true, data }; -} -async function getNeExecJob(client, args, signal) { - const jobId = str2(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); -} -async function listCliTargets(client, args, signal) { - const params = { - page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num2(args, "page_size"), 50, 1, 500) - }; - putStr(params, args, ["source", "keyword"]); - return client.get("/v1/cli/targets", params, signal); -} -async function findTopologyPaths(client, args, signal) { - const fromUid = nmsOrUme(args, "from_nms_ne_id", "from_ume_ne_id"); - const fromMid = str2(args, "from_managed_ne_id").trim(); - const toUid = nmsOrUme(args, "to_nms_ne_id", "to_ume_ne_id"); - const toMid = str2(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 = str2(args, "detail", "summary").trim().toLowerCase() || "summary"; - if (detail !== "summary" && detail !== "full") - detail = "summary"; - const body = { - max_paths: clampInt(num2(args, "max_paths"), 3, 1, 10), - max_hops: clampInt(num2(args, "max_hops"), 6, 1, 12), - layer: str2(args, "layer", "physical").trim() || "physical", - detail - }; - if (fromUid) - body.from_ume_ne_id = fromUid; - else - body.from_managed_ne_id = fromMid; - if (toUid) - body.to_ume_ne_id = toUid; - else - body.to_managed_ne_id = toMid; - return client.post("/v1/topology/fabric/paths", body, signal, 30000); -} -function taskBriefNe(brief) { - 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) { - 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, q) { - 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); -} -async function listBizMonitors(client, args, signal) { - const kindRaw = str2(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 = str2(args, "purpose").trim(); - const status = str2(args, "status").trim().toLowerCase(); - const q = str2(args, "q").trim().toLowerCase(); - const limit = clampInt(num2(args, "limit") ?? 100, 100, 1, 500); - const wantProjects = kind === "all" || kind === "projects"; - const wantTasks = kind === "all" || kind === "tasks"; - const fetches = []; - if (wantProjects) - fetches.push(client.get("/v1/biz-migration/projects", {}, signal)); - else - fetches.push(Promise.resolve({ ok: true, data: { items: [] } })); - if (wantTasks) { - const params = {}; - 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 : []; - const taskItems = Array.isArray(asRecord(taskRes.data).items) ? asRecord(taskRes.data).items : []; - 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) { - 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, taskId = "") { - 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) { - 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 - }); -} -async function listBizMonitorBatches(client, args, signal) { - const projectId = str2(args, "project_id").trim(); - const taskId = str2(args, "task_id").trim(); - const batchId = str2(args, "batch_id").trim(); - const kindRaw = str2(args, "kind", "auto").trim().toLowerCase() || "auto"; - const includeRuns = args.include_runs === true || args.include_runs === "true" || args.include_runs === 1; - const status = str2(args, "status").trim().toLowerCase(); - const limit = clampInt(num2(args, "limit") ?? 50, 50, 1, 200); - const runsLimit = clampInt(num2(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 = []; - let collectBatches = []; - let runs = []; - 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 : []; - 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 : []; - 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 : []; - 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: [] }; - const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : []; - 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) { - 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)." - }) - }; -} -async function getBizMonitorContext(client, args, signal) { - const projectId = str2(args, "project_id").trim(); - const taskId = str2(args, "task_id").trim(); - if (!projectId && !taskId) { - return { ok: false, error: "project_id_or_task_id_required" }; - } - const params = {}; - if (projectId) - params.project_id = projectId; - if (taskId) - params.task_id = taskId; - return client.get("/v1/biz-migration/monitor-context", params, signal); -} -async function getBizMonitorBoard(client, args, signal) { - const batchId = str2(args, "batch_id").trim(); - if (!batchId) - return { ok: false, error: "batch_id_required" }; - const params = {}; - const runId = str2(args, "run_id").trim(); - if (runId) - params.run_id = runId; - return client.get(`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/board`, params, signal); -} -async function listBizMonitorReds(client, args, signal) { - const projectId = str2(args, "project_id").trim(); - if (!projectId) - return { ok: false, error: "project_id_required" }; - const params = {}; - putStr(params, args, ["status"]); - const limit = num2(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); -} -async function getBizMonitorDiffs(client, args, signal) { - const runId = str2(args, "run_id").trim(); - if (!runId) - return { ok: false, error: "run_id_required" }; - const params = {}; - putStr(params, args, ["metric_id", "sheet_id", "verdict", "color", "q"]); - const limit = num2(args, "limit"); - if (limit !== undefined) - params.limit = clampInt(limit, 200, 1, 500); - const offset = num2(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); -} -async function getBizCollectBatch(client, args, signal) { - const batchId = str2(args, "batch_id").trim(); - if (!batchId) - return { ok: false, error: "batch_id_required" }; - return client.get(`/v1/biz-state/batches/${encodeURIComponent(batchId)}`, {}, signal); -} -async function getBizCollectCommandRaw(client, args, signal) { - const batchId = str2(args, "batch_id").trim(); - const commandId = str2(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); -} - -// src/netx/topology-handlers.ts -function asRecord2(value) { - return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; -} -function str3(args, key, fallback = "") { - const v = args[key]; - if (typeof v === "string") - return v; - if (typeof v === "number" || typeof v === "boolean") - return String(v); - return fallback; -} -function num3(args, key) { - const v = args[key]; - return typeof v === "number" && Number.isFinite(v) ? v : undefined; -} -function bool3(args, key) { - const v = args[key]; - return typeof v === "boolean" ? v : undefined; -} -function strList2(args, key) { - const v = args[key]; - if (!Array.isArray(v)) - return []; - return v.map((x) => String(x).trim()).filter((x) => x.length > 0); -} -function clampInt2(value, fallback, min, max) { - const n = value === undefined ? fallback : Math.trunc(value); - return Math.max(min, Math.min(max, n)); -} -function unwrap(out) { - if (out.ok !== true) - return out; - const data = asRecord2(out.data); - if (Object.keys(data).length === 0) - return { ok: true, data: out.data }; - return { ok: true, ...data, data }; -} -function filterFields(args) { - const out = {}; - for (const key of ["keyword", "role", "vendor", "link_status"]) { - const v = str3(args, key).trim(); - if (v) - out[key] = v; - } - return out; -} -function edgeEndpoints(edge) { - const a = str3(edge, "a_node_id") || str3(edge, "a"); - const b = str3(edge, "b_node_id") || str3(edge, "b"); - return [a.trim(), b.trim()]; -} -function collapseEdgesToLinks(edges, includeNames = false) { - const buckets = new Map; - for (const raw of edges) { - if (!raw || typeof raw !== "object" || Array.isArray(raw)) - continue; - const e = raw; - 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}\x00${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 = str3(e, "a_name"); - row.b_name = str3(e, "b_name"); - } else { - row.a_name = str3(e, "b_name"); - row.b_name = str3(e, "a_name"); - } - } - buckets.set(key, row); - } - return [...buckets.values()]; -} -function summarizeViewGraph(graph, sample = 20) { - 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 = asRecord2(n); - return { - id: str3(row, "id") || str3(row, "fabric_node_id"), - name: str3(row, "name"), - x: row.x, - y: row.y, - level: row.level, - role: row.role - }; - }); - return { - ok: true, - view_id: str3(graph, "id") || str3(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) { - 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)." - }; -} -async function getTopologyTree(client, args, signal) { - const out = unwrap(await client.get("/v1/topology/tree", undefined, signal)); - if (out.ok !== true) - return out; - const compact = bool3(args, "compact") !== false; - const maxDepth = num3(args, "max_depth"); - if (!compact && maxDepth === undefined) - return out; - return { ...out, compact, max_depth: maxDepth ?? null }; -} -async function getTopologyView(client, args, signal) { - const viewId = str3(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, 120000)); - if (out.ok !== true) - return out; - const detail = str3(args, "detail", "summary").trim().toLowerCase() || "summary"; - if (detail === "full") - return { ...out, detail: "full" }; - return summarizeViewGraph(out, clampInt2(num3(args, "sample"), 20, 0, 200)); -} -async function createTopologyFolder(client, args, signal) { - const name = str3(args, "name").trim(); - if (!name) - return { ok: false, error: "name_required" }; - const body = { name }; - const parentId = str3(args, "parent_id").trim(); - if (parentId) - body.parent_id = parentId; - const locale = str3(args, "locale").trim(); - if (locale) - body.locale = locale; - if (num3(args, "sort_order") !== undefined) - body.sort_order = Math.trunc(num3(args, "sort_order")); - return unwrap(await client.post("/v1/topology/folders", body, signal)); -} -async function addTopologyViewNodes(client, args, signal) { - const viewId = str3(args, "view_id").trim(); - if (!viewId) - return { ok: false, error: "view_id_required" }; - if (strList2(args, "managed_ne_ids").length || strList2(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 = strList2(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 (num3(args, "max_nodes") !== undefined) { - const got = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal)); - if (got.ok === true) { - const data = asRecord2(got.data); - const filt = asRecord2(got.filter ?? data.filter); - const membership = asRecord2(filt.membership ?? {}); - membership.max_nodes = clampInt2(num3(args, "max_nodes"), 2000, 1, 2000); - filt.membership = membership; - await client.patch(`/v1/topology/views/${encodeURIComponent(viewId)}`, { filter: filt }, signal); - } - } - const layout = str3(args, "layout", "grid").trim() || "grid"; - const body = { - managed_ne_ids: [], - layout, - ...filters - }; - if (fabricIds.length) - body.fabric_node_ids = fabricIds; - if (num3(args, "limit") !== undefined) - body.limit = clampInt2(num3(args, "limit"), 500, 1, 2000); - if (num3(args, "offset") !== undefined) - body.offset = clampInt2(num3(args, "offset"), 0, 0, Number.MAX_SAFE_INTEGER); - return unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes`, body, signal, 180000)); -} -async function removeTopologyViewNodes(client, args, signal) { - const viewId = str3(args, "view_id").trim(); - if (!viewId) - return { ok: false, error: "view_id_required" }; - const body = { ...filterFields(args) }; - const fabricIds = strList2(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)); -} -async function copyTopologyViewNodes(client, args, signal) { - const sourceId = str3(args, "source_view_id").trim(); - const targetId = str3(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, 120000)); - if (source.ok !== true) - return source; - const nodes = Array.isArray(source.nodes) ? source.nodes : []; - const limit = num3(args, "limit"); - const selected = typeof limit === "number" ? nodes.slice(0, clampInt2(limit, nodes.length, 1, 2000)) : nodes; - const fabricIds = selected.map((n) => { - const row = asRecord2(n); - return str3(row, "id") || str3(row, "fabric_node_id"); - }).filter(Boolean); - if (bool3(args, "dry_run") === true) { - return { ok: true, dry_run: true, would_copy: fabricIds.length, source_view_id: sourceId, target_view_id: targetId }; - } - if (bool3(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, 180000)); - if (add.ok !== true) - return add; - if (bool3(args, "copy_positions") !== false) { - const ox = num3(args, "offset_x") ?? 0; - const oy = num3(args, "offset_y") ?? 0; - const positions = selected.map((n) => { - const row = asRecord2(n); - const id = str3(row, "id") || str3(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, 120000); - } - } - return { ok: true, added: fabricIds.length, source_view_id: sourceId, target_view_id: targetId, ...asRecord2(add) }; -} -async function updateTopologyViewPositions(client, args, signal) { - const viewId = str3(args, "view_id").trim(); - if (!viewId) - return { ok: false, error: "view_id_required" }; - const body = {}; - if (Array.isArray(args.positions)) - body.positions = args.positions; - const layout = str3(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"]) { - if (num3(args, key) !== undefined) - body[key] = num3(args, key); - } - const fabricIds = strList2(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, 120000)); -} -async function projectTopologyNeighbors(client, args, signal) { - const viewId = str3(args, "view_id").trim(); - if (!viewId) - return { ok: false, error: "view_id_required" }; - const body = {}; - const seeds = strList2(args, "seed_fabric_node_ids").length ? strList2(args, "seed_fabric_node_ids") : strList2(args, "fabric_node_ids"); - if (seeds.length) - body.seed_fabric_node_ids = seeds; - const mids = strList2(args, "managed_ne_ids"); - if (mids.length) - body.managed_ne_ids = mids; - const region = str3(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, 180000)); - if (out.ok !== true) - return out; - const detail = str3(args, "detail", "summary").trim().toLowerCase() || "summary"; - if (detail === "full" || detail === "raw" || detail === "graph") { - return { ...out, detail: "full", projected: true }; - } - return { ...summarizeViewGraph(out, clampInt2(num3(args, "sample"), 20, 0, 100)), projected: true, view_id: viewId }; -} -async function queryTopologyFabricNodes(client, args, signal) { - let mode = str3(args, "mode").trim().toLowerCase(); - const q = str3(args, "q").trim(); - const keyword = str3(args, "keyword").trim(); - if (!mode) { - if (bool3(args, "summary") === true) - mode = "summary"; - else if (q) - mode = "search"; - else - mode = "list"; - } - if (mode === "summary" || mode === "stats" || mode === "count") { - const out2 = unwrap(await client.get("/v1/topology/fabric/summary", undefined, signal)); - return out2.ok === true ? { ...out2, mode: "summary" } : out2; - } - 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: clampInt2(num3(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num3(args, "page_size") ?? num3(args, "limit"), 50, 1, 200) - }, signal)); - } - const params = { - page: clampInt2(num3(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num3(args, "page_size") ?? num3(args, "limit"), 50, 1, 500) - }; - for (const key of ["keyword", "role", "level", "level_major", "region_folder_id", "link_status"]) { - const v = str3(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; -} -async function classifyTopologyFabricNodes(client, args, signal) { - const action = str3(args, "action").trim().toLowerCase(); - if (!action) - return { ok: false, error: "action_required" }; - if (action === "match") { - const pattern = str3(args, "pattern").trim() || str3(args, "q").trim(); - if (!pattern) - return { ok: false, error: "pattern_required" }; - return unwrap(await client.post("/v1/topology/fabric/nodes/match", { - pattern, - match_field: str3(args, "match_field", "name").trim() || "name", - sample_limit: clampInt2(num3(args, "sample_limit"), 50, 1, 200) - }, signal)); - } - if (action === "tag") { - const body = { - fabric_node_ids: strList2(args, "fabric_node_ids"), - dry_run: bool3(args, "dry_run") === true - }; - const pattern = str3(args, "pattern").trim() || str3(args, "q").trim(); - if (pattern) - body.pattern = pattern; - if (args.level !== undefined) - body.level = args.level; - const role = str3(args, "role").trim(); - if (role) - body.role = role; - const region = str3(args, "region_folder_id").trim(); - if (region) - body.region_folder_id = region; - if (bool3(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 = str3(args, "fabric_node_id").trim() || str3(args, "node_id").trim(); - if (!nodeId) - return { ok: false, error: "fabric_node_id_required" }; - const body = {}; - if (args.level !== undefined) - body.level = args.level; - const role = str3(args, "role").trim(); - if (role) - body.role = role; - const region = str3(args, "region_folder_id").trim(); - if (region) - body.region_folder_id = region; - if (bool3(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: str3(args, "kind", "any").trim() || "any", - page: clampInt2(num3(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num3(args, "page_size") ?? num3(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 = {}; - if (bool3(args, "overwrite_manual") === true) - params.skip_manual = false; - else if (bool3(args, "skip_manual") !== undefined) - params.skip_manual = bool3(args, "skip_manual") !== false; - if (bool3(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 }; -} -async function queryTopologyNeighborhood(client, args, signal) { - const nodeId = str3(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: clampInt2(num3(args, "depth"), 1, 1, 3), - layer: str3(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) }; -} -async function queryTopologyEdges(client, args, signal) { - const params = { - page: clampInt2(num3(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num3(args, "page_size"), 100, 1, 500), - layer: str3(args, "layer", "physical").trim() || "physical" - }; - for (const key of ["node_id", "keyword", "status", "source"]) { - const v = str3(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 = str3(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 }; -} -async function suggestSinkHubs(client, args, signal) { - const viewId = str3(args, "source_view_id").trim() || str3(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, 120000)); - 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; - for (const raw of edges) { - const [a, b] = edgeEndpoints(asRecord2(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([ - ...strList2(args, "exclude_portal_ids"), - ...strList2(args, "exclude_fabric_node_ids") - ]); - const pick = clampInt2(num3(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 row2 = asRecord2(n); - return (str3(row2, "id") || str3(row2, "fabric_node_id")) === fabric_node_id; - }); - const row = asRecord2(node); - return { - fabric_node_id, - degree: deg, - name: str3(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." - }; -} -async function analyzeTopologyViewLayout(client, args, signal) { - const viewId = str3(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, 120000)); - 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 = asRecord2(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; - for (const raw of edges) { - const [a, b] = edgeEndpoints(asRecord2(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: str3(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." - }; -} -async function sinkTopologyDualUnits(_client, args) { - return { - ok: false, - error: "sink_dual_units_requires_netx_topology_mcp", - source_view_id: str3(args, "source_view_id"), - sink_view_id: str3(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." - }; -} -async function layoutTopologyView(client, args, signal) { - if (bool3(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 = str3(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 = str3(args, "view_id").trim(); - const fromId = str3(args, "source_view_id").trim(); - const ids = strList2(args, "fabric_node_ids"); - const params = asRecord2(args.params); - const fabricIds = ids.length ? ids : strList2(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 = bool3(params, "copy_positions") !== false && bool3(args, "copy_positions") !== false; - const removeFromSource = bool3(params, "remove_from_source") !== false; - const source = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(fromId)}`, undefined, signal, 120000)); - 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 = asRecord2(n); - const id = str3(row, "id") || str3(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, 180000)); - 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 = asRecord2(n); - return { - fabric_node_id: str3(row, "id") || str3(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, 120000); - } - 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: bool3(params, "park") === true || bool3(args, "park") === true - }; - } - return layoutRecipeUnavailable(action); -} - -// src/netx/tools.ts function packageVersion() { try { const here = dirname5(fileURLToPath2(import.meta.url)); @@ -3181,65 +1812,89 @@ function packageVersion() { return "0"; } } -var MUTATING_TOOLS = new Set([ - "netx__execManagedNe", - "netx__createTopologyFolder", - "netx__addTopologyViewNodes", - "netx__removeTopologyViewNodes", - "netx__copyTopologyViewNodes", - "netx__updateTopologyViewPositions", - "netx__projectTopologyNeighbors", - "netx__classifyTopologyFabricNodes", - "netx__layoutTopologyView" -]); -var str4 = (description) => ({ type: "string", ...description ? { description } : {} }); -var num4 = (description) => ({ type: "number", ...description ? { description } : {} }); -var bool4 = (description) => ({ type: "boolean", ...description ? { description } : {} }); -var strArr = (description) => ({ - type: "array", - items: { type: "string" }, - ...description ? { description } : {} -}); -var anyObj = (description) => ({ - type: "object", - additionalProperties: true, - ...description ? { description } : {} -}); -function renderJson2(_args, value) { - return [{ type: "text", text: JSON.stringify(value, null, 0) }]; +function renderMcpResult(_args, value) { + if (value && typeof value === "object" && !Array.isArray(value)) { + const row = value; + if (typeof row.text === "string" && row.text.length > 0) { + return [{ type: "text", text: row.text }]; + } + if (Array.isArray(row.content)) { + return [{ type: "text", text: mcpContentText(row.content, JSON.stringify(value)) }]; + } + } + return [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value ?? null) }]; } -var jsonOut2 = { +var mcpOut = { schema: { type: "object", additionalProperties: true }, - render: renderJson2 + render: renderMcpResult }; -function tool2(name, description, parameters, handler, getClient, timeoutMs) { +function parametersFromInputSchema(schema) { + if (schema && typeof schema === "object" && !Array.isArray(schema)) { + const props = schema.properties; + if (props && typeof props === "object" && !Array.isArray(props)) { + return props; + } + if (schema.type === undefined && schema.properties === undefined) { + return schema; + } + } + return {}; +} +function defineRemoteTool(tool2, timeoutMs) { + const name = publicToolName(tool2.name); return defineTool2({ name, - description, - parameters, - output: jsonOut2, + description: tool2.description || name, + parameters: parametersFromInputSchema(tool2.inputSchema), + output: mcpOut, timeoutMs, - isConcurrencySafe: () => !MUTATING_TOOLS.has(name), + isConcurrencySafe: () => tool2.readOnlyHint === true, async execute(args, exec) { - const result = await handler(getClient(), args, exec.signal); - 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 + }, tool2.name, typeof args === "object" && args !== null ? args : {}); + const text = mcpContentText(result.content, ""); + if (result.isError === true) { + throw new Error(text || `Tool "${tool2.name}" failed`); } - return safe; + return JSON.parse(JSON.stringify({ + content: result.content, + ...result.structuredContent !== undefined ? { structuredContent: result.structuredContent } : {}, + ...text ? { text } : {} + })); } }); } function registerNetxTools(ctx, connection, options) { - 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((tool2) => tool2.group).filter(Boolean))]; + if (enabled.length === 0) { publishToolMountStatus({ id, version: packageVersion(), plane: options.plane, - groups: [...enabledGroups], + groups, names: [], count: 0, at: new Date().toISOString(), @@ -3250,392 +1905,40 @@ function registerNetxTools(ctx, connection, options) { }; } const t = typeof connection.toolCallTimeoutMs === "number" && Number.isFinite(connection.toolCallTimeoutMs) && connection.toolCallTimeoutMs > 0 ? connection.toolCallTimeoutMs : 120000; - const client = createNetxClient({ - apiUrl: connection.apiUrl, - lang: connection.lang, - timeoutMs: Math.min(t, 45000), - getToken: () => { - const live = getNetxConnection(); - return live ? live.token.trim() : connection.token.trim(); - }, - getApiUrl: () => getNetxConnection()?.apiUrl ?? connection.apiUrl - }); - const getClient = () => client; - const catalog = [ - tool2("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: str4(), - ne_id: str4("Filter only; do not show UUID to users"), - host_name: str4("Filter by NE host_name"), - ne_name: str4("Legacy alias mapped to keyword"), - keyword: str4("Substring on cause/object/event. Examples: LOS, Fiber Break, bandwidth, CRC."), - time_from: str4("ISO time; filters last_seen_at >="), - time_to: str4("ISO time; filters last_seen_at <="), - page: num4(), - page_size: num4() - }, queryUmeAlarms, getClient, t), - tool2("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: str4("Optional perceived_severity filter (critical/major/minor/warning)."), - top_ne: num4("Max NE buckets (default 50). Ignored when group_by is set."), - exclude_missing_host: bool4("Omit missing host_name from by_ne."), - time_from: str4(), - time_to: str4(), - group_by: str4("When set, routes to raw aggregation. Prefer alarm_host_name."), - group_by2: str4(), - is_cleared: str4(), - ne_id: str4(), - event_type: str4(), - keyword: str4(), - limit: num4() - }, aggregateUmeAlarms, getClient, t), - tool2("netx__runNmsDiagnostics", "NMS alarm diagnostics: severity, top_event_types, top_alarm_codes, top_ne, protocol buckets, freshness meta.", {}, runUmeDiagnostics, getClient, t), - tool2("netx__queryNmsNeInventory", "Paged NMS NE inventory synced in netx (keyword matches ne_id/ne_name/user_label/ip/host_name).", { - keyword: str4(), - page: num4(), - page_size: num4() - }, queryUmeNeInventory, getClient, t), - tool2("netx__getNmsNe", "Get single NMS NE detail by ne_id (UUID).", { - ne_id: { type: "string", required: true, description: "NMS inventory ne_id (UUID)." } - }, getUmeNe, getClient, t), - tool2("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: str4(), - is_cleared: str4(), - ne_id: str4(), - event_type: str4(), - keyword: str4(), - time_from: str4(), - time_to: str4(), - order_by: str4("last_seen_at | time_created | perceived_severity | event_type | ne_id"), - order: str4("asc | desc"), - select_fields: strArr(), - field_preset: str4("brief | evidence | ne_debug"), - page: num4(), - page_size: num4() - }, queryUmeAlarmsRaw, getClient, t), - tool2("netx__aggregateNmsAlarmsRaw", "Dynamic aggregation on NMS raw fields (group_by/group_by2); prefer alarm_host_name.", { - group_by: { type: "string", required: true }, - group_by2: str4(), - severity: str4(), - is_cleared: str4(), - ne_id: str4(), - event_type: str4(), - keyword: str4(), - time_from: str4(), - time_to: str4(), - exclude_missing_host: bool4(), - limit: num4() - }, aggregateUmeAlarmsRaw, getClient, t), - tool2("netx__listNmsAlarmFields", "List available fields for NMS raw alarm queries.", {}, listUmeAlarmFields, getClient, t), - tool2("netx__sqlQueryNms", "Read-only SELECT on NMS tables (ume_alarms_current/ume_inventory_ne); server enforces limits. Requires sql:query scope.", { - sql: { type: "string", required: true }, - limit: num4(), - statement_timeout_ms: num4() - }, sqlQueryUme, getClient, t), - tool2("netx__listManagedNe", "List filtered netx managed NEs (keyword/vendor/connect_status required); use before execManagedNe.", { - keyword: str4(), - vendor: str4(), - connect_status: str4("unknown | testing | pass | fail"), - page: num4(), - page_size: num4() - }, listManagedNe, getClient, t), - tool2("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: str4("Managed NE id"), - managed_ne_id: str4("Alias for ne_id"), - id: str4("Alias for ne_id") - }, getManagedNe, getClient, t), - tool2("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: str4(), - nms_ne_id: str4("NMS inventory id; alias ume_ne_id"), - ume_ne_id: str4("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", - description: "Per-NE command sets: each item is one NE (ne_id OR nms_ne_id) with commands[].", - items: { - type: "object", - additionalProperties: false, - properties: { - ne_id: str4(), - nms_ne_id: str4(), - ume_ne_id: str4(), - commands: strArr() - } - } - }, - commands: strArr("Commands for single NE, or shared commands for batch."), - read_timeout_sec: num4("Per-command read timeout (default 60; use 90–120 for slow show)."), - concurrency: num4("Parallel NEs for batch mode (1–8, default 4)."), - async: bool4("true=background job_id + getNeExecJob; false=force sync; omit=auto for ~4+ NEs (NETX_NE_EXEC_ASYNC_MIN_NES).") - }, execManagedNe, getClient, Math.max(t, 300000)), - tool2("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: str4("Job id from execManagedNe async response.") - }, getNeExecJob, getClient, Math.max(t, 60000)), - tool2("netx__listCliTargets", "List CLI-capable targets (managed NE and/or NMS inventory). Call once per session with keyword/source, cache ids, then execManagedNe.", { - source: str4("managed | nms | ume | all"), - keyword: str4(), - page: num4(), - page_size: num4() - }, listCliTargets, getClient, t), - tool2("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: str4("NMS inventory id"), - from_ume_ne_id: str4("Legacy alias of from_nms_ne_id"), - from_managed_ne_id: str4(), - to_nms_ne_id: str4("NMS inventory id"), - to_ume_ne_id: str4("Legacy alias of to_nms_ne_id"), - to_managed_ne_id: str4(), - max_paths: num4(), - max_hops: num4(), - layer: str4(), - detail: str4("summary | full") - }, findTopologyPaths, getClient, t), - tool2("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: str4("all | projects | tasks (default all)"), - purpose: str4("Filter tasks: portrait | cutover_hf | empty=all"), - status: str4("Filter by status (e.g. active / paused)"), - q: str4("Keyword on name / ne_name / note / id"), - limit: num4("Max rows per list (default 100, max 500)") - }, listBizMonitors, getClient, t), - tool2("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: str4("auto | all | cutover | collect | runs (default auto)"), - project_id: str4("Cutover project id → cutover_batches"), - task_id: str4("biz_state task id → collect_batches"), - batch_id: str4("Cutover batch id (filter or kind=runs)"), - include_runs: bool4("Attach recent evaluate runs onto listed cutover batches"), - status: str4("Filter batch status"), - limit: num4("Max batches (default 50)"), - runs_limit: num4("Max runs per batch when include_runs / kind=runs (default 10)") - }, listBizMonitorBatches, getClient, t), - tool2("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: str4("Cutover / biz-migration project id"), - task_id: str4("biz_state task id (optional if project_id set)") - }, getBizMonitorContext, getClient, t), - tool2("netx__getBizMonitorBoard", "Cutover batch board: project/batch status, evaluate run summary, sheet cards, verdict counts, metric_batches.", { - batch_id: { type: "string", required: true }, - run_id: str4("Optional evaluate run id") - }, getBizMonitorBoard, getClient, t), - tool2("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", required: true }, - status: str4("open|carried|resolved or empty=all"), - limit: num4() - }, listBizMonitorReds, getClient, t), - tool2("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", required: true }, - metric_id: str4(), - sheet_id: str4(), - verdict: str4(), - color: str4("e.g. red"), - q: str4(), - limit: num4(), - offset: num4() - }, getBizMonitorDiffs, getClient, t), - tool2("netx__getBizCollectBatch", "One biz_state collect batch: commands (raw_command, parse_status, preview), parsed metrics.", { - batch_id: { type: "string", required: true } - }, getBizCollectBatch, getClient, t), - tool2("netx__getBizCollectCommandRaw", "Full CLI raw_text for one collect command inside a batch (deep dive).", { - batch_id: { type: "string", required: true }, - command_id: { type: "string", required: true } - }, getBizCollectCommandRaw, getClient, t), - tool2("netx__getTopologyTree", "Get topology folder tree (nav roots + Root map canvases). Start here before createTopologyFolder.", { compact: bool4(), max_depth: num4() }, getTopologyTree, getClient, t), - tool2("netx__getTopologyView", "Get a topology view by view_id. Default detail=summary with sample_nodes + links[].", { - view_id: { type: "string", required: true }, - detail: str4("summary | full"), - sample: num4() - }, getTopologyView, getClient, t), - tool2("netx__createTopologyFolder", "Create topology folders / region canvases. Only way to create canvases (ne:write).", { - name: { type: "string", required: true }, - parent_id: str4(), - locale: str4("zh | en"), - sort_order: num4() - }, createTopologyFolder, getClient, t), - tool2("netx__addTopologyViewNodes", "Bulk-place fabric nodes on a view via filters or fabric_node_ids (never managed/UME ids).", { - view_id: { type: "string", required: true }, - max_nodes: num4(), - keyword: str4(), - role: str4(), - vendor: str4(), - link_status: str4(), - limit: num4(), - offset: num4(), - fabric_node_ids: strArr(), - layout: str4("grid | keep") - }, addTopologyViewNodes, getClient, t), - tool2("netx__removeTopologyViewNodes", "Remove placements from a view (not fabric) by filters or fabric_node_ids.", { - view_id: { type: "string", required: true }, - keyword: str4(), - role: str4(), - vendor: str4(), - link_status: str4(), - fabric_node_ids: strArr() - }, removeTopologyViewNodes, getClient, t), - tool2("netx__copyTopologyViewNodes", "Clone fabric placements from source_view_id onto target_view_id (optional clear_target / copy_positions).", { - source_view_id: { type: "string", required: true }, - target_view_id: { type: "string", required: true }, - copy_positions: bool4(), - clear_target: bool4(), - offset_x: num4(), - offset_y: num4(), - limit: num4(), - dry_run: bool4() - }, copyTopologyViewNodes, getClient, t), - tool2("netx__updateTopologyViewPositions", "Move nodes on a view via positions[] or layout=grid|offset|stack + filters.", { - view_id: { type: "string", required: true }, - positions: { - type: "array", - description: "Manual placements: [{ fabric_node_id, x, y }, …]", - items: { - type: "object", - additionalProperties: false, - properties: { - fabric_node_id: str4(), - x: num4(), - y: num4() - } - } - }, - layout: str4("grid | offset | stack"), - keyword: str4(), - role: str4(), - vendor: str4(), - link_status: str4(), - fabric_node_ids: strArr(), - offset_x: num4(), - offset_y: num4() - }, updateTopologyViewPositions, getClient, t), - tool2("netx__projectTopologyNeighbors", "Project existing fabric neighbors of on-view nodes onto the canvas.", { - view_id: { type: "string", required: true }, - seed_fabric_node_ids: strArr(), - managed_ne_ids: strArr(), - region_folder_id: str4(), - detail: str4("summary | full"), - sample: num4() - }, projectTopologyNeighbors, getClient, t), - tool2("netx__queryTopologyFabricNodes", "Fabric inventory: mode=summary|list|search (keyword/role/level/region/link_status).", { - mode: str4("summary | list | search"), - q: str4(), - keyword: str4(), - role: str4(), - level: str4(), - level_major: str4(), - region_folder_id: str4(), - link_status: str4(), - page: num4(), - page_size: num4(), - limit: num4(), - summary: bool4() - }, queryTopologyFabricNodes, getClient, t), - tool2("netx__classifyTopologyFabricNodes", "Classify fabric nodes: action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules.", { - action: { type: "string", required: true }, - pattern: str4(), - q: str4(), - match_field: str4(), - sample_limit: num4(), - fabric_node_ids: strArr(), - fabric_node_id: str4(), - node_id: str4(), - level: str4(), - role: str4(), - region_folder_id: str4(), - clear_region: bool4(), - dry_run: bool4(), - kind: str4(), - page: num4(), - page_size: num4(), - skip_manual: bool4(), - overwrite_manual: bool4(), - fill_empty_only: bool4() - }, classifyTopologyFabricNodes, getClient, t), - tool2("netx__queryTopologyNeighborhood", "Neighborhood around a fabric node (depth 1–3) with compact links[].", { - node_id: { type: "string", required: true }, - depth: num4(), - layer: str4() - }, queryTopologyNeighborhood, getClient, t), - tool2("netx__queryTopologyEdges", "Fabric adjacency links[] (detail=adjacency) or port rows (detail=ports).", { - node_id: str4(), - keyword: str4(), - layer: str4(), - status: str4(), - source: str4("lldp | ume | manual"), - detail: str4("adjacency | ports"), - page: num4(), - page_size: num4() - }, queryTopologyEdges, getClient, t), - tool2("netx__suggestSinkHubs", "Rank hub territories on a source view for non-dual move_nodes(park) batches (degree stand-in in DSH).", { - source_view_id: str4(), - view_id: str4(), - pick: num4(), - exclude_portal_ids: strArr(), - exclude_fabric_node_ids: strArr() - }, suggestSinkHubs, getClient, t), - tool2("netx__analyzeTopologyViewLayout", "Basic layout QA for a view (bbox/hubs). Full dual_unit/crossing scores need netx-topology MCP.", { - view_id: str4(), - folder_id: str4(), - detail: str4("summary | structure | hotspots | blocks | both"), - score_profile: str4(), - sight_limit: num4(), - max_views: num4(), - min_nodes: num4(), - max_nodes: num4(), - with_meta: bool4() - }, analyzeTopologyViewLayout, getClient, t), - tool2("netx__sinkTopologyDualUnits", "Drain dual_unit eyes from source to sink (requires netx-topology MCP layout engine in DSH).", { - source_view_id: str4(), - sink_view_id: str4(), - max_units: num4(), - min_nodes: num4(), - max_nodes: num4(), - max_batch_nodes: num4(), - layout_batch: bool4(), - until_empty: bool4(), - dry_run: bool4() - }, sinkTopologyDualUnits, getClient, t), - tool2("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: str4(), - action: str4(), - source_view_id: str4(), - recipe: str4(), - preset: str4(), - mode: str4("preview | apply"), - tune: bool4(), - params: anyObj("Action-specific options (move_nodes / recipes / polish)."), - catalog: bool4(), - fabric_node_ids: strArr(), - park: bool4(), - copy_positions: bool4() - }, layoutTopologyView, getClient, Math.max(t, 180000)) - ]; - const selected = catalog.filter((entry) => allow.has(entry.name)); - const names = selected.map((entry) => entry.name); const disposers = []; + const names = []; try { - for (const entry of selected) - disposers.push(ctx.tools.register(entry)); - publishToolMountStatus({ - id, - version: packageVersion(), - plane: options.plane, - groups: [...enabledGroups], - names, - count: names.length, - at: new Date().toISOString(), - source: options.source ?? options.plane - }); + for (const tool2 of enabled) { + const definition = defineRemoteTool(tool2, t); + disposers.push(ctx.tools.register(definition)); + names.push(publicToolName(tool2.name)); + } } catch (error) { - for (const dispose of disposers.reverse()) + for (const dispose of disposers.splice(0)) dispose(); publishToolMountStatus({ id, version: packageVersion(), plane: options.plane, - groups: [...enabledGroups], + groups, names: [], count: 0, at: new Date().toISOString(), - error: error instanceof Error ? error.message : String(error), + error: String(error), source: options.source ?? options.plane }); throw error; } + publishToolMountStatus({ + id, + version: packageVersion(), + plane: options.plane, + groups, + names, + count: names.length, + at: new Date().toISOString(), + source: options.source ?? options.plane + }); return () => { for (const dispose of disposers) dispose(); @@ -3653,6 +1956,16 @@ function packageVersion2() { return "0"; } } +function disabledByGroupFor(groupIds, connection) { + const catalog = getMcpCatalogTools(); + const out = {}; + for (const groupId of groupIds) { + const names = disabledPublicNames(catalog, [groupId], connection.disabledTools, connection.readOnlyOnly); + if (names.length > 0) + out[groupId] = names; + } + return out; +} function applyGroupToolsPlugin(ctx, options) { let unregisterTools; let unregisterKbLocal; @@ -3678,9 +1991,10 @@ function applyGroupToolsPlugin(ctx, options) { unregisterTools = registerNetxTools(ctx, connection, { plane: "preset", forceGroups: enabled, - source: options.name + source: options.name, + presetId: presetId() }); - ctx.logger.info("%s: groups=[%s] → %s tokenConfigured=%s live=%s", options.name, enabled.join(",") || "(none)", connection.apiUrl, connection.token.trim().length > 0, getNetxConnection() !== undefined); + ctx.logger.info("%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({ id: mountId, @@ -3708,6 +2022,9 @@ function applyGroupToolsPlugin(ctx, options) { remountTools(); remountKbLocal(); }); + const stopCatalogWatch = watchMcpCatalog(() => { + remountTools(); + }); const stopKbToolWatch = watchKbContext(() => { remountKbLocal(); }); @@ -3737,8 +2054,13 @@ function applyGroupToolsPlugin(ctx, options) { skillAbort = new AbortController; unregisterSkills?.(); unregisterSkills = undefined; + const connection = requireNetxConnection(); const enabled = resolveGroups(); - registerGroupSkills(skillsCtx, enabled, options.name, skillAbort.signal).then((dispose) => { + registerGroupSkills(skillsCtx, enabled, options.name, { + signal: skillAbort.signal, + disabledByGroup: disabledByGroupFor(enabled, connection), + lang: connection.lang + }).then((dispose) => { if (gen !== skillGeneration) { dispose(); return; @@ -3783,6 +2105,9 @@ function applyGroupToolsPlugin(ctx, options) { remountKbSkill(); remountKbPack(); }); + const stopSkillCatalogWatch = watchMcpCatalog(() => { + remountSkills(); + }); const stopKbWatch = watchKbContext(() => { remountKbSkill(); remountKbPack(); @@ -3796,6 +2121,7 @@ function applyGroupToolsPlugin(ctx, options) { skillAbort.abort(); packAbort.abort(); stopSkillWatch(); + stopSkillCatalogWatch(); stopKbWatch(); unregisterSkills?.(); unregisterSkills = undefined; @@ -3808,6 +2134,7 @@ function applyGroupToolsPlugin(ctx, options) { ctx.effect(() => () => { clearInterval(pollTimer); stopToolWatch(); + stopCatalogWatch(); stopKbToolWatch(); unregisterTools?.(); unregisterTools = undefined; diff --git a/lib/agent-tools-topology.js b/lib/agent-tools-topology.js index 311912e..f7e5007 100644 --- a/lib/agent-tools-topology.js +++ b/lib/agent-tools-topology.js @@ -21,52 +21,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", @@ -101,14 +55,6 @@ function groupsForPlane(groups, plane, only) { const allow = new Set(only); return enabled.filter((id) => allow.has(id)); } -function toolNamesForGroups(groupIds) { - const out = new Set; - for (const id of groupIds) { - for (const name of TOOLS_BY_GROUP[id] ?? []) - out.add(name); - } - return out; -} function parsePresetIds(value) { const current = readConfigValue(value); if (typeof current !== "string") @@ -122,6 +68,234 @@ function groupsForPreset(connection, presetId, only) { return CAPABILITY_GROUP_IDS.filter((id) => (!only?.length || only.includes(id)) && !policy[id].public && (presetId === "netxops" ? policy[id].inPreset : policy[id].presets?.includes(presetId))); } +// src/netx/mcp-types.ts +var NETX_TOOL_PREFIX = "netx__"; +function publicToolName(serverName) { + return `${NETX_TOOL_PREFIX}${serverName}`; +} + +// src/netx/mcp-catalog.ts +function disabledSet(disabledTools) { + const out = new Set; + for (const name of disabledTools ?? []) { + const trimmed = typeof name === "string" ? name.trim() : ""; + if (trimmed) + out.add(trimmed); + } + return out; +} +function filterCatalogTools(catalog, enabledGroups, policy) { + const allowGroups = new Set(enabledGroups); + const disabled = disabledSet(policy.disabledTools); + const readOnlyOnly = policy.readOnlyOnly === true; + const out = []; + 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; +} +function resolveEnabledTools(catalog, options) { + 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); +} +function disabledPublicNames(catalog, enabledGroups, disabledTools, readOnlyOnly) { + const allowGroups = new Set(enabledGroups); + const disabled = disabledSet(disabledTools); + const names = []; + 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(); +} + +// src/netx/mcp-remote.ts +var PROTOCOL_VERSION = "2025-03-26"; +var CLIENT_INFO = { name: "dsh-netxops", version: "0.1.71" }; + +class McpRemoteError extends Error { + status; + constructor(message, status) { + super(message); + this.name = "McpRemoteError"; + this.status = status; + } +} +function mcpEndpointUrl(apiUrl) { + const trimmed = apiUrl.trim().replace(/\/$/, ""); + if (!trimmed) + return ""; + return `${trimmed}/mcp`; +} +function parseMcpResponseBody(raw) { + 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 = []; + 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 {} + } + } + throw new McpRemoteError("mcp response was not JSON"); +} +function asRpcResult(payload) { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + throw new McpRemoteError("invalid JSON-RPC envelope from /mcp"); + } + const row = payload; + if (row.error && typeof row.error === "object" && !Array.isArray(row.error)) { + const err = row.error; + 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.result; +} +var nextId = 1; +async function postRpc(options, method, params, notification = false) { + const headers = { + "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 : 60000; + 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 = () => { + abort.abort(); + }; + options.signal?.addEventListener("abort", onOuterAbort, { once: true }); + let 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; + } + return asRpcResult(parseMcpResponseBody(responseText)); +} +async function callMcpTool(options, name, args) { + 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 + }); + const content = Array.isArray(result.content) ? result.content : []; + return { + content, + ...result.structuredContent !== undefined ? { structuredContent: result.structuredContent } : {}, + ...result.isError === true ? { isError: true } : {} + }; +} +function mcpContentText(content, fallback = "") { + const parts = []; + for (const block of content) { + if (!block || typeof block !== "object" || Array.isArray(block)) + continue; + const row = block; + if (row.type === "text" && typeof row.text === "string") + parts.push(row.text); + } + return parts.join(` +`) || fallback; +} + +// src/netx/mcp-store.ts +var STORE_KEY = Symbol.for("dsh-netxops.mcp-catalog-store"); +var EMPTY = Object.freeze({ + phase: "idle", + mcpUrl: "", + tools: Object.freeze([]), + error: "", + listedAt: null, + fromCache: false +}); +function store() { + const root = globalThis; + let current = root[STORE_KEY]; + if (current === undefined) { + current = { state: EMPTY, listeners: new Set, generation: 0 }; + root[STORE_KEY] = current; + } + return current; +} +function getMcpCatalogTools() { + return store().state.tools; +} +function watchMcpCatalog(listener) { + const state = store(); + state.listeners.add(listener); + return () => { + state.listeners.delete(listener); + }; +} + // src/netx/group-skills.ts import { existsSync } from "node:fs"; import { readdir, readFile, stat } from "node:fs/promises"; @@ -271,7 +445,24 @@ async function loadGroupSkills(skillsRoot, groupId) { } return { skills, skipped }; } -async function registerGroupSkills(ctx, groupIds, providerLabel, signal) { +function disabledFootnote(names, lang) { + if (names.length === 0) + return ""; + const list = names.join(", "); + const en = (lang ?? "").trim().toLowerCase().startsWith("en"); + return en ? ` + +## Disabled tools +The following tools are disabled: ${list}. Do not call them. +` : ` + +## 已禁用工具 +以下工具已禁用:${list},不要调用。 +`; +} +async function registerGroupSkills(ctx, groupIds, providerLabel, signalOrOptions) { + const options = signalOrOptions instanceof AbortSignal || signalOrOptions === undefined ? { signal: signalOrOptions } : signalOrOptions; + const signal = options.signal; const skillsApi = ctx.skills; if (!skillsApi || typeof skillsApi.register !== "function") { return () => {}; @@ -293,15 +484,17 @@ async function registerGroupSkills(ctx, groupIds, providerLabel, signal) { if (skills.length === 0) { log?.warn?.("%s: no skills loaded for group=%s from %s", providerLabel, groupId, 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} + +## DeepSeek Harness REST capabilities +Use only tools present in this session. Dual-unit/orbit/polish recipes require server-side topology tools. Use only tools present in this session. +` : skill.content; disposers.push(skillsApi.register({ name: skill.name, description: skill.description, - content: groupId === "topology" ? `${skill.content} - -## DeepSeek Harness REST capabilities -Use 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. -` : skill.content, + content: `${base}${footnote}`, path: skill.path, resourceBase: { kind: "directory", path: skill.directory }, provider: providerLabel, @@ -1119,18 +1312,18 @@ function searchKb(snapshot, args) { } // src/netx/kb-runtime.ts -var STORE_KEY = Symbol.for("dsh-netxops.kb-store"); -function store() { +var STORE_KEY2 = Symbol.for("dsh-netxops.kb-store"); +function store2() { const root = globalThis; - let current = root[STORE_KEY]; + let current = root[STORE_KEY2]; if (current === undefined) { current = { snapshot: unconfiguredKbSnapshot(), listeners: new Set }; - root[STORE_KEY] = current; + root[STORE_KEY2] = current; } return current; } function getKbContext() { - const snap = store().snapshot; + const snap = store2().snapshot; return { ...snap, content: { ...snap.content }, @@ -1138,7 +1331,7 @@ function getKbContext() { }; } function watchKbContext(listener) { - const state = store(); + const state = store2(); state.listeners.add(listener); return () => { state.listeners.delete(listener); @@ -1162,26 +1355,18 @@ function toLosslessJson(value) { return { ok: false, error: "tool_result_not_json", detail: detail.slice(0, 400) }; } } -function omitUndefined(record) { - const out = {}; - for (const [key, value] of Object.entries(record)) { - if (value !== undefined) - out[key] = value; - } - return out; -} // src/netx/tool-mount-status.ts import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs"; import { homedir } from "node:os"; import { dirname as dirname4, join as join7 } from "node:path"; var KEY = Symbol.for("dsh-netxops.mount-inventory"); -function store2() { +function store3() { const root = globalThis; return root[KEY] ??= { mounts: new Map, contexts: new WeakMap, sequence: 0 }; } function getToolMountId(ctx, source) { - const state = store2(); + const state = store3(); let id = state.contexts.get(ctx); if (id === undefined) { id = ++state.sequence; @@ -1190,7 +1375,7 @@ function getToolMountId(ctx, source) { return `${source}:${id}`; } function getToolMountStatuses() { - return [...store2().mounts.values()].map((row) => ({ ...row, groups: [...row.groups], names: [...row.names] })); + return [...store3().mounts.values()].map((row) => ({ ...row, groups: [...row.groups], names: [...row.names] })); } function getToolMountStatus() { const rows = getToolMountStatuses(); @@ -1205,11 +1390,11 @@ function persist() { } catch {} } function publishToolMountStatus(next) { - store2().mounts.set(next.id ?? next.source ?? next.plane, { ...next, groups: [...next.groups], names: [...next.names] }); + store3().mounts.set(next.id ?? next.source ?? next.plane, { ...next, groups: [...next.groups], names: [...next.names] }); persist(); } function removeToolMountStatus(id) { - store2().mounts.delete(id); + store3().mounts.delete(id); persist(); } @@ -1571,139 +1756,20 @@ function normalizeApiUrl(raw) { const search = parsed.search && parsed.search !== "?" ? parsed.search : ""; return { ok: true, url: `${parsed.origin}${path}${search}` }; } -var PROTOCOL_KEY_ZH_TO_EN = { - 其他: "Other", - 时钟: "Clock", - "OTN/光": "OTN/Optical", - 电源: "Power" -}; -function asLang(value) { - return typeof value === "string" ? value : ""; -} -function localizePayload(lang, data) { - 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; - const key = typeof rec.key === "string" ? rec.key : ""; - const mapped = PROTOCOL_KEY_ZH_TO_EN[key]; - if (mapped !== undefined) - rec.key = mapped; - } - return data; -} -function encodeQuery(params) { - const sp = new URLSearchParams; - for (const [key, value] of Object.entries(params)) { - sp.set(key, String(value)); - } - const q = sp.toString(); - return q.length > 0 ? `?${q}` : ""; -} -function createNetxClient(connection) { - const langParams = () => { - const lang = asLang(connection.lang).trim().toLowerCase(); - if (lang.startsWith("en")) - return { lang: "en" }; - return {}; - }; - async function request(method, path, options = {}) { - 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 = { ...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 = { - accept: "application/json", - authorization: `Bearer ${token}` - }; - if (options.body !== undefined) - headers["content-type"] = "application/json"; - const init = { - 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) : {}; - if (typeof data === "object" && data !== null && !Array.isArray(data)) { - return { ok: true, data: localizePayload(connection.lang, data) }; - } - return { ok: true, data: { raw: data } }; - } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - return { ok: false, error: "netx_request_failed", detail: detail.slice(0, 800) }; - } finally { - clearTimeout(timer); - options.signal?.removeEventListener("abort", onOuterAbort); - } - } - return { - get(path, params, signal, timeoutMs) { - return request("GET", path, { params, signal, timeoutMs }); - }, - post(path, body, signal, timeoutMs) { - return request("POST", path, { body, signal, timeoutMs }); - }, - patch(path, body, signal, timeoutMs) { - return request("PATCH", path, { body, signal, timeoutMs }); - } - }; -} -function quoteNeId(neId) { - return encodeURIComponent(neId.trim()); -} // src/netx/runtime.ts -var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store"); -function store3() { +var STORE_KEY3 = Symbol.for("dsh-netxops.connection-store"); +function store4() { const root = globalThis; - let current = root[STORE_KEY2]; + let current = root[STORE_KEY3]; if (current === undefined) { current = { connection: undefined, listeners: new Set }; - root[STORE_KEY2] = current; + root[STORE_KEY3] = current; } return current; } function getNetxConnection() { - return store3().connection; + return store4().connection; } function provisionalNetxConnection() { const envUrl = process.env.NETX_API_URL?.trim() ?? ""; @@ -1715,6 +1781,8 @@ function provisionalNetxConnection() { lang: typeof process.env.NETX_LANG === "string" && process.env.NETX_LANG.trim() ? process.env.NETX_LANG.trim() : "zh", toolCallTimeoutMs: 120000, groups: DEFAULT_CAPABILITY_GROUPS, + disabledTools: [], + readOnlyOnly: false, groupKbInPreset: true, groupKbPublic: false }; @@ -1723,7 +1791,7 @@ function requireNetxConnection() { return getNetxConnection() ?? provisionalNetxConnection(); } function watchNetxConnection(listener) { - const state = store3(); + const state = store4(); state.listeners.add(listener); return () => { state.listeners.delete(listener); @@ -1735,1443 +1803,6 @@ import { readFileSync as readFileSync5 } from "node:fs"; import { dirname as dirname5, join as join9 } from "node:path"; import { fileURLToPath as fileURLToPath2 } from "node:url"; import { defineTool as defineTool2 } from "@deepseek-ai/dsh-tools"; - -// src/netx/handlers.ts -var EXEC_MAX_COMMANDS_CAP = 50; -var EXEC_MAX_COMMANDS_DEFAULT = 5; -function execMaxCommands() { - 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() { - 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) { - if (raw === undefined || raw === null) - return; - if (typeof raw === "boolean") - return raw; - const text = String(raw).trim().toLowerCase(); - if (!text) - return; - if (["1", "true", "yes", "on"].includes(text)) - return true; - if (["0", "false", "no", "off"].includes(text)) - return false; - return; -} -function countExecNeTargets(args) { - let n = 0; - for (const key of ["ne_ids", "nms_ne_ids", "ume_ne_ids"]) { - 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 (str2(args, "ne_id").trim() || nmsOrUme(args, "nms_ne_id", "ume_ne_id")) - return 1; - } - return n; -} -function shouldRunExecAsync(args) { - 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; -} -var UME_RAW_FIELD_PRESETS = { - brief: [ - "alarm_alarm_key", - "alarm_host_name", - "alarm_perceived_severity", - "alarm_event_type", - "alarm_last_seen_at", - "ne_host_name", - "ne_user_label", - "ne_ne_name", - "ne_ip_address", - "ne_exists" - ], - evidence: [ - "alarm_alarm_key", - "alarm_host_name", - "alarm_object_name", - "alarm_event_type", - "alarm_native_probable_cause", - "alarm_perceived_severity", - "alarm_is_cleared", - "alarm_time_created", - "alarm_last_seen_at", - "ne_host_name", - "ne_user_label", - "ne_ne_name", - "ne_ip_address", - "ne_connection_status", - "ne_exists" - ], - ne_debug: [ - "alarm_alarm_key", - "alarm_ne_id", - "alarm_perceived_severity", - "alarm_last_seen_at", - "ne_user_label", - "ne_ne_name", - "ne_ip_address", - "ne_ipv6_address", - "ne_device_level", - "ne_host_name", - "ne_connection_status", - "ne_admin_status", - "ne_address_type", - "ne_maintain_status", - "ne_exists" - ] -}; -function asRecord(value) { - return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; -} -function str2(args, key, fallback = "") { - const v = args[key]; - if (typeof v === "string") - return v; - if (typeof v === "number" || typeof v === "boolean") - return String(v); - return fallback; -} -function nmsOrUme(args, nmsKey, umeKey) { - return str2(args, nmsKey).trim() || str2(args, umeKey).trim(); -} -function nmsOrUmeList(args, nmsKey, umeKey) { - const primary = strList(args, nmsKey); - return primary.length > 0 ? primary : strList(args, umeKey); -} -function num2(args, key) { - const v = args[key]; - return typeof v === "number" && Number.isFinite(v) ? v : undefined; -} -function bool2(args, key) { - const v = args[key]; - return typeof v === "boolean" ? v : undefined; -} -function strList(args, key) { - const v = args[key]; - if (!Array.isArray(v)) - return []; - return v.map((x) => String(x).trim()).filter((x) => x.length > 0); -} -function clampInt(value, fallback, min, max) { - const n = value === undefined ? fallback : Math.trunc(value); - return Math.max(min, Math.min(max, n)); -} -function putStr(params, args, keys) { - for (const key of keys) { - const v = str2(args, key).trim(); - if (v) - params[key] = v; - } -} -async function queryUmeAlarms(client, args, signal) { - let page = clampInt(num2(args, "page"), 1, 1, 2); - const pageSize = clampInt(num2(args, "page_size"), 50, 1, 500); - const params = { page, page_size: pageSize }; - putStr(params, args, ["severity", "ne_id", "host_name", "time_from", "time_to"]); - const keyword = str2(args, "keyword").trim(); - const neName = str2(args, "ne_name").trim(); - if (keyword) - params.keyword = keyword; - else if (neName) - params.keyword = neName; - return client.get("/v1/ume/alarms", params, signal); -} -async function aggregateUmeAlarmsRaw(client, args, signal) { - const params = {}; - putStr(params, args, [ - "group_by", - "group_by2", - "severity", - "is_cleared", - "ne_id", - "event_type", - "keyword", - "time_from", - "time_to", - "limit" - ]); - if ("exclude_missing_host" in args) { - const flag = bool2(args, "exclude_missing_host"); - if (flag !== undefined) - params.exclude_missing_host = flag; - } - return client.get("/v1/ume/alarms/aggregate/raw", params, signal); -} -async function aggregateUmeAlarms(client, args, signal) { - if (str2(args, "group_by").trim()) - return aggregateUmeAlarmsRaw(client, args, signal); - const topNe = clampInt(num2(args, "top_ne"), 50, 0, 500); - const params = { top_ne: topNe }; - if ("exclude_missing_host" in args) { - const flag = bool2(args, "exclude_missing_host"); - if (flag !== undefined) - params.exclude_missing_host = flag; - } - putStr(params, args, ["severity", "time_from", "time_to"]); - return client.get("/v1/ume/alarms/aggregate", params, signal); -} -async function runUmeDiagnostics(client, _args, signal) { - return client.get("/v1/ume/diagnostics", undefined, signal); -} -async function queryUmeNeInventory(client, args, signal) { - const params = { - page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num2(args, "page_size"), 50, 1, 500) - }; - putStr(params, args, ["keyword"]); - return client.get("/v1/ume/inventory/ne", params, signal); -} -async function getUmeNe(client, args, signal) { - const neId = str2(args, "ne_id").trim(); - if (!neId) - return { ok: false, error: "ne_id_required", error_code: "ne_id_required" }; - return client.get(`/v1/ume/inventory/ne/${quoteNeId(neId)}`, undefined, signal); -} -async function queryUmeAlarmsRaw(client, args, signal) { - const params = { - page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num2(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 = str2(args, "field_preset").trim().toLowerCase(); - fields = UME_RAW_FIELD_PRESETS[preset] ?? []; - } - if (fields.length > 0) - params.select_fields = fields.join(","); - return client.get("/v1/ume/alarms/raw", params, signal); -} -async function listUmeAlarmFields(client, _args, signal) { - return client.get("/v1/ume/alarms/fields", undefined, signal); -} -async function sqlQueryUme(client, args, signal) { - const sql = str2(args, "sql").trim(); - if (!sql) - return { ok: false, error: "sql_required" }; - const limit = clampInt(num2(args, "limit"), 200, 1, 2000); - const statementTimeoutMs = clampInt(num2(args, "statement_timeout_ms"), 0, 0, 30000); - return client.post("/v1/sql/ume_query", { - sql, - limit, - statement_timeout_ms: statementTimeoutMs - }, signal, 60000); -} -async function listManagedNe(client, args, signal) { - const keyword = str2(args, "keyword").trim(); - const vendor = str2(args, "vendor").trim(); - const connectStatus = str2(args, "connect_status").trim(); - if (!(keyword || vendor || connectStatus)) { - return { ok: false, error: "managed_ne_filter_required", error_code: "managed_ne_filter_required" }; - } - if (keyword && keyword.length < 2) { - return { ok: false, error: "managed_ne_keyword_too_short", error_code: "managed_ne_keyword_too_short" }; - } - const params = { - page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num2(args, "page_size"), 20, 1, 100) - }; - if (keyword) - params.keyword = keyword; - if (vendor) - params.vendor = vendor; - if (connectStatus) - params.connect_status = connectStatus; - return client.get("/v1/managed-ne", params, signal); -} -async function getManagedNe(client, args, signal) { - const neId = (str2(args, "ne_id") || str2(args, "managed_ne_id") || str2(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: "" } - }; - } - const out = await client.get(`/v1/managed-ne/${quoteNeId(neId)}`, undefined, signal); - if (out.ok === false) { - const detail = `${str2(out, "detail")}${str2(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; -} -async function execManagedNe(client, args, signal) { - 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 body2 = {}; - if (Array.isArray(targetsRaw) && targetsRaw.length > 0) { - const cleaned = []; - for (const t of targetsRaw) { - if (typeof t !== "object" || t === null || Array.isArray(t)) - continue; - const row = t; - const item = {}; - const neId2 = str2(row, "ne_id").trim(); - const umeNeId2 = nmsOrUme(row, "nms_ne_id", "ume_ne_id"); - if (neId2) - item.ne_id = neId2; - if (umeNeId2) - item.ume_ne_id = umeNeId2; - const cmds = Array.isArray(row.commands) ? row.commands.map((c) => String(c).trim()).filter((c) => c.length > 0) : []; - if (cmds.length > 0) - item.commands = cmds; - if (Object.keys(item).length > 0) - cleaned.push(item); - } - body2.targets = cleaned; - } - if (neIds.length > 0) - body2.ne_ids = neIds; - if (umeNeIds.length > 0) - body2.ume_ne_ids = umeNeIds; - if (sharedCommands.length > 0) { - if (sharedCommands.length > maxCmds) { - return { ok: false, error: "too_many_commands", error_code: "too_many_commands" }; - } - body2.commands = sharedCommands; - } - body2.read_timeout_sec = clampInt(num2(args, "read_timeout_sec"), 60, 10, 120); - const concurrency = num2(args, "concurrency"); - if (concurrency !== undefined) - body2.concurrency = clampInt(concurrency, 4, 1, 8); - if (wantAsync) { - const out3 = await client.post("/v1/managed-ne/exec-jobs", body2, signal, 60000); - if (out3.ok !== true) - return out3; - const data3 = asRecord(out3.data); - return Object.keys(data3).length > 0 ? data3 : out3; - } - const out2 = await client.post("/v1/managed-ne/exec-batch", body2, signal, 600000); - if (out2.ok !== true) - return out2; - const data2 = asRecord(out2.data); - if (data2.ok === false) { - return { ok: false, data: data2, error: str2(data2, "error", "exec_batch_failed") }; - } - return { ok: true, data: data2 }; - } - const neId = str2(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 = { - commands: sharedCommands, - read_timeout_sec: clampInt(num2(args, "read_timeout_sec"), 60, 10, 120) - }; - if (neId) - body.ne_id = neId; - if (umeNeId) - body.ume_ne_id = umeNeId; - if (wantAsync) { - const out2 = await client.post("/v1/managed-ne/exec-jobs", body, signal, 60000); - if (out2.ok !== true) - return out2; - const data2 = asRecord(out2.data); - return Object.keys(data2).length > 0 ? data2 : out2; - } - const out = await client.post("/v1/managed-ne/exec", body, signal, 300000); - if (out.ok !== true) - return out; - const data = asRecord(out.data); - if (data.ok === false) { - return { ok: false, data, error: str2(data, "error", "exec_failed") }; - } - return { ok: true, data }; -} -async function getNeExecJob(client, args, signal) { - const jobId = str2(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); -} -async function listCliTargets(client, args, signal) { - const params = { - page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num2(args, "page_size"), 50, 1, 500) - }; - putStr(params, args, ["source", "keyword"]); - return client.get("/v1/cli/targets", params, signal); -} -async function findTopologyPaths(client, args, signal) { - const fromUid = nmsOrUme(args, "from_nms_ne_id", "from_ume_ne_id"); - const fromMid = str2(args, "from_managed_ne_id").trim(); - const toUid = nmsOrUme(args, "to_nms_ne_id", "to_ume_ne_id"); - const toMid = str2(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 = str2(args, "detail", "summary").trim().toLowerCase() || "summary"; - if (detail !== "summary" && detail !== "full") - detail = "summary"; - const body = { - max_paths: clampInt(num2(args, "max_paths"), 3, 1, 10), - max_hops: clampInt(num2(args, "max_hops"), 6, 1, 12), - layer: str2(args, "layer", "physical").trim() || "physical", - detail - }; - if (fromUid) - body.from_ume_ne_id = fromUid; - else - body.from_managed_ne_id = fromMid; - if (toUid) - body.to_ume_ne_id = toUid; - else - body.to_managed_ne_id = toMid; - return client.post("/v1/topology/fabric/paths", body, signal, 30000); -} -function taskBriefNe(brief) { - 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) { - 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, q) { - 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); -} -async function listBizMonitors(client, args, signal) { - const kindRaw = str2(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 = str2(args, "purpose").trim(); - const status = str2(args, "status").trim().toLowerCase(); - const q = str2(args, "q").trim().toLowerCase(); - const limit = clampInt(num2(args, "limit") ?? 100, 100, 1, 500); - const wantProjects = kind === "all" || kind === "projects"; - const wantTasks = kind === "all" || kind === "tasks"; - const fetches = []; - if (wantProjects) - fetches.push(client.get("/v1/biz-migration/projects", {}, signal)); - else - fetches.push(Promise.resolve({ ok: true, data: { items: [] } })); - if (wantTasks) { - const params = {}; - 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 : []; - const taskItems = Array.isArray(asRecord(taskRes.data).items) ? asRecord(taskRes.data).items : []; - 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) { - 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, taskId = "") { - 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) { - 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 - }); -} -async function listBizMonitorBatches(client, args, signal) { - const projectId = str2(args, "project_id").trim(); - const taskId = str2(args, "task_id").trim(); - const batchId = str2(args, "batch_id").trim(); - const kindRaw = str2(args, "kind", "auto").trim().toLowerCase() || "auto"; - const includeRuns = args.include_runs === true || args.include_runs === "true" || args.include_runs === 1; - const status = str2(args, "status").trim().toLowerCase(); - const limit = clampInt(num2(args, "limit") ?? 50, 50, 1, 200); - const runsLimit = clampInt(num2(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 = []; - let collectBatches = []; - let runs = []; - 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 : []; - 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 : []; - 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 : []; - 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: [] }; - const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : []; - 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) { - 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)." - }) - }; -} -async function getBizMonitorContext(client, args, signal) { - const projectId = str2(args, "project_id").trim(); - const taskId = str2(args, "task_id").trim(); - if (!projectId && !taskId) { - return { ok: false, error: "project_id_or_task_id_required" }; - } - const params = {}; - if (projectId) - params.project_id = projectId; - if (taskId) - params.task_id = taskId; - return client.get("/v1/biz-migration/monitor-context", params, signal); -} -async function getBizMonitorBoard(client, args, signal) { - const batchId = str2(args, "batch_id").trim(); - if (!batchId) - return { ok: false, error: "batch_id_required" }; - const params = {}; - const runId = str2(args, "run_id").trim(); - if (runId) - params.run_id = runId; - return client.get(`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/board`, params, signal); -} -async function listBizMonitorReds(client, args, signal) { - const projectId = str2(args, "project_id").trim(); - if (!projectId) - return { ok: false, error: "project_id_required" }; - const params = {}; - putStr(params, args, ["status"]); - const limit = num2(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); -} -async function getBizMonitorDiffs(client, args, signal) { - const runId = str2(args, "run_id").trim(); - if (!runId) - return { ok: false, error: "run_id_required" }; - const params = {}; - putStr(params, args, ["metric_id", "sheet_id", "verdict", "color", "q"]); - const limit = num2(args, "limit"); - if (limit !== undefined) - params.limit = clampInt(limit, 200, 1, 500); - const offset = num2(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); -} -async function getBizCollectBatch(client, args, signal) { - const batchId = str2(args, "batch_id").trim(); - if (!batchId) - return { ok: false, error: "batch_id_required" }; - return client.get(`/v1/biz-state/batches/${encodeURIComponent(batchId)}`, {}, signal); -} -async function getBizCollectCommandRaw(client, args, signal) { - const batchId = str2(args, "batch_id").trim(); - const commandId = str2(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); -} - -// src/netx/topology-handlers.ts -function asRecord2(value) { - return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; -} -function str3(args, key, fallback = "") { - const v = args[key]; - if (typeof v === "string") - return v; - if (typeof v === "number" || typeof v === "boolean") - return String(v); - return fallback; -} -function num3(args, key) { - const v = args[key]; - return typeof v === "number" && Number.isFinite(v) ? v : undefined; -} -function bool3(args, key) { - const v = args[key]; - return typeof v === "boolean" ? v : undefined; -} -function strList2(args, key) { - const v = args[key]; - if (!Array.isArray(v)) - return []; - return v.map((x) => String(x).trim()).filter((x) => x.length > 0); -} -function clampInt2(value, fallback, min, max) { - const n = value === undefined ? fallback : Math.trunc(value); - return Math.max(min, Math.min(max, n)); -} -function unwrap(out) { - if (out.ok !== true) - return out; - const data = asRecord2(out.data); - if (Object.keys(data).length === 0) - return { ok: true, data: out.data }; - return { ok: true, ...data, data }; -} -function filterFields(args) { - const out = {}; - for (const key of ["keyword", "role", "vendor", "link_status"]) { - const v = str3(args, key).trim(); - if (v) - out[key] = v; - } - return out; -} -function edgeEndpoints(edge) { - const a = str3(edge, "a_node_id") || str3(edge, "a"); - const b = str3(edge, "b_node_id") || str3(edge, "b"); - return [a.trim(), b.trim()]; -} -function collapseEdgesToLinks(edges, includeNames = false) { - const buckets = new Map; - for (const raw of edges) { - if (!raw || typeof raw !== "object" || Array.isArray(raw)) - continue; - const e = raw; - 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}\x00${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 = str3(e, "a_name"); - row.b_name = str3(e, "b_name"); - } else { - row.a_name = str3(e, "b_name"); - row.b_name = str3(e, "a_name"); - } - } - buckets.set(key, row); - } - return [...buckets.values()]; -} -function summarizeViewGraph(graph, sample = 20) { - 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 = asRecord2(n); - return { - id: str3(row, "id") || str3(row, "fabric_node_id"), - name: str3(row, "name"), - x: row.x, - y: row.y, - level: row.level, - role: row.role - }; - }); - return { - ok: true, - view_id: str3(graph, "id") || str3(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) { - 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)." - }; -} -async function getTopologyTree(client, args, signal) { - const out = unwrap(await client.get("/v1/topology/tree", undefined, signal)); - if (out.ok !== true) - return out; - const compact = bool3(args, "compact") !== false; - const maxDepth = num3(args, "max_depth"); - if (!compact && maxDepth === undefined) - return out; - return { ...out, compact, max_depth: maxDepth ?? null }; -} -async function getTopologyView(client, args, signal) { - const viewId = str3(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, 120000)); - if (out.ok !== true) - return out; - const detail = str3(args, "detail", "summary").trim().toLowerCase() || "summary"; - if (detail === "full") - return { ...out, detail: "full" }; - return summarizeViewGraph(out, clampInt2(num3(args, "sample"), 20, 0, 200)); -} -async function createTopologyFolder(client, args, signal) { - const name = str3(args, "name").trim(); - if (!name) - return { ok: false, error: "name_required" }; - const body = { name }; - const parentId = str3(args, "parent_id").trim(); - if (parentId) - body.parent_id = parentId; - const locale = str3(args, "locale").trim(); - if (locale) - body.locale = locale; - if (num3(args, "sort_order") !== undefined) - body.sort_order = Math.trunc(num3(args, "sort_order")); - return unwrap(await client.post("/v1/topology/folders", body, signal)); -} -async function addTopologyViewNodes(client, args, signal) { - const viewId = str3(args, "view_id").trim(); - if (!viewId) - return { ok: false, error: "view_id_required" }; - if (strList2(args, "managed_ne_ids").length || strList2(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 = strList2(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 (num3(args, "max_nodes") !== undefined) { - const got = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal)); - if (got.ok === true) { - const data = asRecord2(got.data); - const filt = asRecord2(got.filter ?? data.filter); - const membership = asRecord2(filt.membership ?? {}); - membership.max_nodes = clampInt2(num3(args, "max_nodes"), 2000, 1, 2000); - filt.membership = membership; - await client.patch(`/v1/topology/views/${encodeURIComponent(viewId)}`, { filter: filt }, signal); - } - } - const layout = str3(args, "layout", "grid").trim() || "grid"; - const body = { - managed_ne_ids: [], - layout, - ...filters - }; - if (fabricIds.length) - body.fabric_node_ids = fabricIds; - if (num3(args, "limit") !== undefined) - body.limit = clampInt2(num3(args, "limit"), 500, 1, 2000); - if (num3(args, "offset") !== undefined) - body.offset = clampInt2(num3(args, "offset"), 0, 0, Number.MAX_SAFE_INTEGER); - return unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes`, body, signal, 180000)); -} -async function removeTopologyViewNodes(client, args, signal) { - const viewId = str3(args, "view_id").trim(); - if (!viewId) - return { ok: false, error: "view_id_required" }; - const body = { ...filterFields(args) }; - const fabricIds = strList2(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)); -} -async function copyTopologyViewNodes(client, args, signal) { - const sourceId = str3(args, "source_view_id").trim(); - const targetId = str3(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, 120000)); - if (source.ok !== true) - return source; - const nodes = Array.isArray(source.nodes) ? source.nodes : []; - const limit = num3(args, "limit"); - const selected = typeof limit === "number" ? nodes.slice(0, clampInt2(limit, nodes.length, 1, 2000)) : nodes; - const fabricIds = selected.map((n) => { - const row = asRecord2(n); - return str3(row, "id") || str3(row, "fabric_node_id"); - }).filter(Boolean); - if (bool3(args, "dry_run") === true) { - return { ok: true, dry_run: true, would_copy: fabricIds.length, source_view_id: sourceId, target_view_id: targetId }; - } - if (bool3(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, 180000)); - if (add.ok !== true) - return add; - if (bool3(args, "copy_positions") !== false) { - const ox = num3(args, "offset_x") ?? 0; - const oy = num3(args, "offset_y") ?? 0; - const positions = selected.map((n) => { - const row = asRecord2(n); - const id = str3(row, "id") || str3(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, 120000); - } - } - return { ok: true, added: fabricIds.length, source_view_id: sourceId, target_view_id: targetId, ...asRecord2(add) }; -} -async function updateTopologyViewPositions(client, args, signal) { - const viewId = str3(args, "view_id").trim(); - if (!viewId) - return { ok: false, error: "view_id_required" }; - const body = {}; - if (Array.isArray(args.positions)) - body.positions = args.positions; - const layout = str3(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"]) { - if (num3(args, key) !== undefined) - body[key] = num3(args, key); - } - const fabricIds = strList2(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, 120000)); -} -async function projectTopologyNeighbors(client, args, signal) { - const viewId = str3(args, "view_id").trim(); - if (!viewId) - return { ok: false, error: "view_id_required" }; - const body = {}; - const seeds = strList2(args, "seed_fabric_node_ids").length ? strList2(args, "seed_fabric_node_ids") : strList2(args, "fabric_node_ids"); - if (seeds.length) - body.seed_fabric_node_ids = seeds; - const mids = strList2(args, "managed_ne_ids"); - if (mids.length) - body.managed_ne_ids = mids; - const region = str3(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, 180000)); - if (out.ok !== true) - return out; - const detail = str3(args, "detail", "summary").trim().toLowerCase() || "summary"; - if (detail === "full" || detail === "raw" || detail === "graph") { - return { ...out, detail: "full", projected: true }; - } - return { ...summarizeViewGraph(out, clampInt2(num3(args, "sample"), 20, 0, 100)), projected: true, view_id: viewId }; -} -async function queryTopologyFabricNodes(client, args, signal) { - let mode = str3(args, "mode").trim().toLowerCase(); - const q = str3(args, "q").trim(); - const keyword = str3(args, "keyword").trim(); - if (!mode) { - if (bool3(args, "summary") === true) - mode = "summary"; - else if (q) - mode = "search"; - else - mode = "list"; - } - if (mode === "summary" || mode === "stats" || mode === "count") { - const out2 = unwrap(await client.get("/v1/topology/fabric/summary", undefined, signal)); - return out2.ok === true ? { ...out2, mode: "summary" } : out2; - } - 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: clampInt2(num3(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num3(args, "page_size") ?? num3(args, "limit"), 50, 1, 200) - }, signal)); - } - const params = { - page: clampInt2(num3(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num3(args, "page_size") ?? num3(args, "limit"), 50, 1, 500) - }; - for (const key of ["keyword", "role", "level", "level_major", "region_folder_id", "link_status"]) { - const v = str3(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; -} -async function classifyTopologyFabricNodes(client, args, signal) { - const action = str3(args, "action").trim().toLowerCase(); - if (!action) - return { ok: false, error: "action_required" }; - if (action === "match") { - const pattern = str3(args, "pattern").trim() || str3(args, "q").trim(); - if (!pattern) - return { ok: false, error: "pattern_required" }; - return unwrap(await client.post("/v1/topology/fabric/nodes/match", { - pattern, - match_field: str3(args, "match_field", "name").trim() || "name", - sample_limit: clampInt2(num3(args, "sample_limit"), 50, 1, 200) - }, signal)); - } - if (action === "tag") { - const body = { - fabric_node_ids: strList2(args, "fabric_node_ids"), - dry_run: bool3(args, "dry_run") === true - }; - const pattern = str3(args, "pattern").trim() || str3(args, "q").trim(); - if (pattern) - body.pattern = pattern; - if (args.level !== undefined) - body.level = args.level; - const role = str3(args, "role").trim(); - if (role) - body.role = role; - const region = str3(args, "region_folder_id").trim(); - if (region) - body.region_folder_id = region; - if (bool3(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 = str3(args, "fabric_node_id").trim() || str3(args, "node_id").trim(); - if (!nodeId) - return { ok: false, error: "fabric_node_id_required" }; - const body = {}; - if (args.level !== undefined) - body.level = args.level; - const role = str3(args, "role").trim(); - if (role) - body.role = role; - const region = str3(args, "region_folder_id").trim(); - if (region) - body.region_folder_id = region; - if (bool3(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: str3(args, "kind", "any").trim() || "any", - page: clampInt2(num3(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num3(args, "page_size") ?? num3(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 = {}; - if (bool3(args, "overwrite_manual") === true) - params.skip_manual = false; - else if (bool3(args, "skip_manual") !== undefined) - params.skip_manual = bool3(args, "skip_manual") !== false; - if (bool3(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 }; -} -async function queryTopologyNeighborhood(client, args, signal) { - const nodeId = str3(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: clampInt2(num3(args, "depth"), 1, 1, 3), - layer: str3(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) }; -} -async function queryTopologyEdges(client, args, signal) { - const params = { - page: clampInt2(num3(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num3(args, "page_size"), 100, 1, 500), - layer: str3(args, "layer", "physical").trim() || "physical" - }; - for (const key of ["node_id", "keyword", "status", "source"]) { - const v = str3(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 = str3(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 }; -} -async function suggestSinkHubs(client, args, signal) { - const viewId = str3(args, "source_view_id").trim() || str3(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, 120000)); - 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; - for (const raw of edges) { - const [a, b] = edgeEndpoints(asRecord2(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([ - ...strList2(args, "exclude_portal_ids"), - ...strList2(args, "exclude_fabric_node_ids") - ]); - const pick = clampInt2(num3(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 row2 = asRecord2(n); - return (str3(row2, "id") || str3(row2, "fabric_node_id")) === fabric_node_id; - }); - const row = asRecord2(node); - return { - fabric_node_id, - degree: deg, - name: str3(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." - }; -} -async function analyzeTopologyViewLayout(client, args, signal) { - const viewId = str3(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, 120000)); - 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 = asRecord2(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; - for (const raw of edges) { - const [a, b] = edgeEndpoints(asRecord2(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: str3(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." - }; -} -async function sinkTopologyDualUnits(_client, args) { - return { - ok: false, - error: "sink_dual_units_requires_netx_topology_mcp", - source_view_id: str3(args, "source_view_id"), - sink_view_id: str3(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." - }; -} -async function layoutTopologyView(client, args, signal) { - if (bool3(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 = str3(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 = str3(args, "view_id").trim(); - const fromId = str3(args, "source_view_id").trim(); - const ids = strList2(args, "fabric_node_ids"); - const params = asRecord2(args.params); - const fabricIds = ids.length ? ids : strList2(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 = bool3(params, "copy_positions") !== false && bool3(args, "copy_positions") !== false; - const removeFromSource = bool3(params, "remove_from_source") !== false; - const source = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(fromId)}`, undefined, signal, 120000)); - 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 = asRecord2(n); - const id = str3(row, "id") || str3(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, 180000)); - 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 = asRecord2(n); - return { - fabric_node_id: str3(row, "id") || str3(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, 120000); - } - 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: bool3(params, "park") === true || bool3(args, "park") === true - }; - } - return layoutRecipeUnavailable(action); -} - -// src/netx/tools.ts function packageVersion() { try { const here = dirname5(fileURLToPath2(import.meta.url)); @@ -3181,65 +1812,89 @@ function packageVersion() { return "0"; } } -var MUTATING_TOOLS = new Set([ - "netx__execManagedNe", - "netx__createTopologyFolder", - "netx__addTopologyViewNodes", - "netx__removeTopologyViewNodes", - "netx__copyTopologyViewNodes", - "netx__updateTopologyViewPositions", - "netx__projectTopologyNeighbors", - "netx__classifyTopologyFabricNodes", - "netx__layoutTopologyView" -]); -var str4 = (description) => ({ type: "string", ...description ? { description } : {} }); -var num4 = (description) => ({ type: "number", ...description ? { description } : {} }); -var bool4 = (description) => ({ type: "boolean", ...description ? { description } : {} }); -var strArr = (description) => ({ - type: "array", - items: { type: "string" }, - ...description ? { description } : {} -}); -var anyObj = (description) => ({ - type: "object", - additionalProperties: true, - ...description ? { description } : {} -}); -function renderJson2(_args, value) { - return [{ type: "text", text: JSON.stringify(value, null, 0) }]; +function renderMcpResult(_args, value) { + if (value && typeof value === "object" && !Array.isArray(value)) { + const row = value; + if (typeof row.text === "string" && row.text.length > 0) { + return [{ type: "text", text: row.text }]; + } + if (Array.isArray(row.content)) { + return [{ type: "text", text: mcpContentText(row.content, JSON.stringify(value)) }]; + } + } + return [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value ?? null) }]; } -var jsonOut2 = { +var mcpOut = { schema: { type: "object", additionalProperties: true }, - render: renderJson2 + render: renderMcpResult }; -function tool2(name, description, parameters, handler, getClient, timeoutMs) { +function parametersFromInputSchema(schema) { + if (schema && typeof schema === "object" && !Array.isArray(schema)) { + const props = schema.properties; + if (props && typeof props === "object" && !Array.isArray(props)) { + return props; + } + if (schema.type === undefined && schema.properties === undefined) { + return schema; + } + } + return {}; +} +function defineRemoteTool(tool2, timeoutMs) { + const name = publicToolName(tool2.name); return defineTool2({ name, - description, - parameters, - output: jsonOut2, + description: tool2.description || name, + parameters: parametersFromInputSchema(tool2.inputSchema), + output: mcpOut, timeoutMs, - isConcurrencySafe: () => !MUTATING_TOOLS.has(name), + isConcurrencySafe: () => tool2.readOnlyHint === true, async execute(args, exec) { - const result = await handler(getClient(), args, exec.signal); - 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 + }, tool2.name, typeof args === "object" && args !== null ? args : {}); + const text = mcpContentText(result.content, ""); + if (result.isError === true) { + throw new Error(text || `Tool "${tool2.name}" failed`); } - return safe; + return JSON.parse(JSON.stringify({ + content: result.content, + ...result.structuredContent !== undefined ? { structuredContent: result.structuredContent } : {}, + ...text ? { text } : {} + })); } }); } function registerNetxTools(ctx, connection, options) { - 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((tool2) => tool2.group).filter(Boolean))]; + if (enabled.length === 0) { publishToolMountStatus({ id, version: packageVersion(), plane: options.plane, - groups: [...enabledGroups], + groups, names: [], count: 0, at: new Date().toISOString(), @@ -3250,392 +1905,40 @@ function registerNetxTools(ctx, connection, options) { }; } const t = typeof connection.toolCallTimeoutMs === "number" && Number.isFinite(connection.toolCallTimeoutMs) && connection.toolCallTimeoutMs > 0 ? connection.toolCallTimeoutMs : 120000; - const client = createNetxClient({ - apiUrl: connection.apiUrl, - lang: connection.lang, - timeoutMs: Math.min(t, 45000), - getToken: () => { - const live = getNetxConnection(); - return live ? live.token.trim() : connection.token.trim(); - }, - getApiUrl: () => getNetxConnection()?.apiUrl ?? connection.apiUrl - }); - const getClient = () => client; - const catalog = [ - tool2("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: str4(), - ne_id: str4("Filter only; do not show UUID to users"), - host_name: str4("Filter by NE host_name"), - ne_name: str4("Legacy alias mapped to keyword"), - keyword: str4("Substring on cause/object/event. Examples: LOS, Fiber Break, bandwidth, CRC."), - time_from: str4("ISO time; filters last_seen_at >="), - time_to: str4("ISO time; filters last_seen_at <="), - page: num4(), - page_size: num4() - }, queryUmeAlarms, getClient, t), - tool2("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: str4("Optional perceived_severity filter (critical/major/minor/warning)."), - top_ne: num4("Max NE buckets (default 50). Ignored when group_by is set."), - exclude_missing_host: bool4("Omit missing host_name from by_ne."), - time_from: str4(), - time_to: str4(), - group_by: str4("When set, routes to raw aggregation. Prefer alarm_host_name."), - group_by2: str4(), - is_cleared: str4(), - ne_id: str4(), - event_type: str4(), - keyword: str4(), - limit: num4() - }, aggregateUmeAlarms, getClient, t), - tool2("netx__runNmsDiagnostics", "NMS alarm diagnostics: severity, top_event_types, top_alarm_codes, top_ne, protocol buckets, freshness meta.", {}, runUmeDiagnostics, getClient, t), - tool2("netx__queryNmsNeInventory", "Paged NMS NE inventory synced in netx (keyword matches ne_id/ne_name/user_label/ip/host_name).", { - keyword: str4(), - page: num4(), - page_size: num4() - }, queryUmeNeInventory, getClient, t), - tool2("netx__getNmsNe", "Get single NMS NE detail by ne_id (UUID).", { - ne_id: { type: "string", required: true, description: "NMS inventory ne_id (UUID)." } - }, getUmeNe, getClient, t), - tool2("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: str4(), - is_cleared: str4(), - ne_id: str4(), - event_type: str4(), - keyword: str4(), - time_from: str4(), - time_to: str4(), - order_by: str4("last_seen_at | time_created | perceived_severity | event_type | ne_id"), - order: str4("asc | desc"), - select_fields: strArr(), - field_preset: str4("brief | evidence | ne_debug"), - page: num4(), - page_size: num4() - }, queryUmeAlarmsRaw, getClient, t), - tool2("netx__aggregateNmsAlarmsRaw", "Dynamic aggregation on NMS raw fields (group_by/group_by2); prefer alarm_host_name.", { - group_by: { type: "string", required: true }, - group_by2: str4(), - severity: str4(), - is_cleared: str4(), - ne_id: str4(), - event_type: str4(), - keyword: str4(), - time_from: str4(), - time_to: str4(), - exclude_missing_host: bool4(), - limit: num4() - }, aggregateUmeAlarmsRaw, getClient, t), - tool2("netx__listNmsAlarmFields", "List available fields for NMS raw alarm queries.", {}, listUmeAlarmFields, getClient, t), - tool2("netx__sqlQueryNms", "Read-only SELECT on NMS tables (ume_alarms_current/ume_inventory_ne); server enforces limits. Requires sql:query scope.", { - sql: { type: "string", required: true }, - limit: num4(), - statement_timeout_ms: num4() - }, sqlQueryUme, getClient, t), - tool2("netx__listManagedNe", "List filtered netx managed NEs (keyword/vendor/connect_status required); use before execManagedNe.", { - keyword: str4(), - vendor: str4(), - connect_status: str4("unknown | testing | pass | fail"), - page: num4(), - page_size: num4() - }, listManagedNe, getClient, t), - tool2("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: str4("Managed NE id"), - managed_ne_id: str4("Alias for ne_id"), - id: str4("Alias for ne_id") - }, getManagedNe, getClient, t), - tool2("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: str4(), - nms_ne_id: str4("NMS inventory id; alias ume_ne_id"), - ume_ne_id: str4("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", - description: "Per-NE command sets: each item is one NE (ne_id OR nms_ne_id) with commands[].", - items: { - type: "object", - additionalProperties: false, - properties: { - ne_id: str4(), - nms_ne_id: str4(), - ume_ne_id: str4(), - commands: strArr() - } - } - }, - commands: strArr("Commands for single NE, or shared commands for batch."), - read_timeout_sec: num4("Per-command read timeout (default 60; use 90–120 for slow show)."), - concurrency: num4("Parallel NEs for batch mode (1–8, default 4)."), - async: bool4("true=background job_id + getNeExecJob; false=force sync; omit=auto for ~4+ NEs (NETX_NE_EXEC_ASYNC_MIN_NES).") - }, execManagedNe, getClient, Math.max(t, 300000)), - tool2("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: str4("Job id from execManagedNe async response.") - }, getNeExecJob, getClient, Math.max(t, 60000)), - tool2("netx__listCliTargets", "List CLI-capable targets (managed NE and/or NMS inventory). Call once per session with keyword/source, cache ids, then execManagedNe.", { - source: str4("managed | nms | ume | all"), - keyword: str4(), - page: num4(), - page_size: num4() - }, listCliTargets, getClient, t), - tool2("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: str4("NMS inventory id"), - from_ume_ne_id: str4("Legacy alias of from_nms_ne_id"), - from_managed_ne_id: str4(), - to_nms_ne_id: str4("NMS inventory id"), - to_ume_ne_id: str4("Legacy alias of to_nms_ne_id"), - to_managed_ne_id: str4(), - max_paths: num4(), - max_hops: num4(), - layer: str4(), - detail: str4("summary | full") - }, findTopologyPaths, getClient, t), - tool2("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: str4("all | projects | tasks (default all)"), - purpose: str4("Filter tasks: portrait | cutover_hf | empty=all"), - status: str4("Filter by status (e.g. active / paused)"), - q: str4("Keyword on name / ne_name / note / id"), - limit: num4("Max rows per list (default 100, max 500)") - }, listBizMonitors, getClient, t), - tool2("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: str4("auto | all | cutover | collect | runs (default auto)"), - project_id: str4("Cutover project id → cutover_batches"), - task_id: str4("biz_state task id → collect_batches"), - batch_id: str4("Cutover batch id (filter or kind=runs)"), - include_runs: bool4("Attach recent evaluate runs onto listed cutover batches"), - status: str4("Filter batch status"), - limit: num4("Max batches (default 50)"), - runs_limit: num4("Max runs per batch when include_runs / kind=runs (default 10)") - }, listBizMonitorBatches, getClient, t), - tool2("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: str4("Cutover / biz-migration project id"), - task_id: str4("biz_state task id (optional if project_id set)") - }, getBizMonitorContext, getClient, t), - tool2("netx__getBizMonitorBoard", "Cutover batch board: project/batch status, evaluate run summary, sheet cards, verdict counts, metric_batches.", { - batch_id: { type: "string", required: true }, - run_id: str4("Optional evaluate run id") - }, getBizMonitorBoard, getClient, t), - tool2("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", required: true }, - status: str4("open|carried|resolved or empty=all"), - limit: num4() - }, listBizMonitorReds, getClient, t), - tool2("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", required: true }, - metric_id: str4(), - sheet_id: str4(), - verdict: str4(), - color: str4("e.g. red"), - q: str4(), - limit: num4(), - offset: num4() - }, getBizMonitorDiffs, getClient, t), - tool2("netx__getBizCollectBatch", "One biz_state collect batch: commands (raw_command, parse_status, preview), parsed metrics.", { - batch_id: { type: "string", required: true } - }, getBizCollectBatch, getClient, t), - tool2("netx__getBizCollectCommandRaw", "Full CLI raw_text for one collect command inside a batch (deep dive).", { - batch_id: { type: "string", required: true }, - command_id: { type: "string", required: true } - }, getBizCollectCommandRaw, getClient, t), - tool2("netx__getTopologyTree", "Get topology folder tree (nav roots + Root map canvases). Start here before createTopologyFolder.", { compact: bool4(), max_depth: num4() }, getTopologyTree, getClient, t), - tool2("netx__getTopologyView", "Get a topology view by view_id. Default detail=summary with sample_nodes + links[].", { - view_id: { type: "string", required: true }, - detail: str4("summary | full"), - sample: num4() - }, getTopologyView, getClient, t), - tool2("netx__createTopologyFolder", "Create topology folders / region canvases. Only way to create canvases (ne:write).", { - name: { type: "string", required: true }, - parent_id: str4(), - locale: str4("zh | en"), - sort_order: num4() - }, createTopologyFolder, getClient, t), - tool2("netx__addTopologyViewNodes", "Bulk-place fabric nodes on a view via filters or fabric_node_ids (never managed/UME ids).", { - view_id: { type: "string", required: true }, - max_nodes: num4(), - keyword: str4(), - role: str4(), - vendor: str4(), - link_status: str4(), - limit: num4(), - offset: num4(), - fabric_node_ids: strArr(), - layout: str4("grid | keep") - }, addTopologyViewNodes, getClient, t), - tool2("netx__removeTopologyViewNodes", "Remove placements from a view (not fabric) by filters or fabric_node_ids.", { - view_id: { type: "string", required: true }, - keyword: str4(), - role: str4(), - vendor: str4(), - link_status: str4(), - fabric_node_ids: strArr() - }, removeTopologyViewNodes, getClient, t), - tool2("netx__copyTopologyViewNodes", "Clone fabric placements from source_view_id onto target_view_id (optional clear_target / copy_positions).", { - source_view_id: { type: "string", required: true }, - target_view_id: { type: "string", required: true }, - copy_positions: bool4(), - clear_target: bool4(), - offset_x: num4(), - offset_y: num4(), - limit: num4(), - dry_run: bool4() - }, copyTopologyViewNodes, getClient, t), - tool2("netx__updateTopologyViewPositions", "Move nodes on a view via positions[] or layout=grid|offset|stack + filters.", { - view_id: { type: "string", required: true }, - positions: { - type: "array", - description: "Manual placements: [{ fabric_node_id, x, y }, …]", - items: { - type: "object", - additionalProperties: false, - properties: { - fabric_node_id: str4(), - x: num4(), - y: num4() - } - } - }, - layout: str4("grid | offset | stack"), - keyword: str4(), - role: str4(), - vendor: str4(), - link_status: str4(), - fabric_node_ids: strArr(), - offset_x: num4(), - offset_y: num4() - }, updateTopologyViewPositions, getClient, t), - tool2("netx__projectTopologyNeighbors", "Project existing fabric neighbors of on-view nodes onto the canvas.", { - view_id: { type: "string", required: true }, - seed_fabric_node_ids: strArr(), - managed_ne_ids: strArr(), - region_folder_id: str4(), - detail: str4("summary | full"), - sample: num4() - }, projectTopologyNeighbors, getClient, t), - tool2("netx__queryTopologyFabricNodes", "Fabric inventory: mode=summary|list|search (keyword/role/level/region/link_status).", { - mode: str4("summary | list | search"), - q: str4(), - keyword: str4(), - role: str4(), - level: str4(), - level_major: str4(), - region_folder_id: str4(), - link_status: str4(), - page: num4(), - page_size: num4(), - limit: num4(), - summary: bool4() - }, queryTopologyFabricNodes, getClient, t), - tool2("netx__classifyTopologyFabricNodes", "Classify fabric nodes: action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules.", { - action: { type: "string", required: true }, - pattern: str4(), - q: str4(), - match_field: str4(), - sample_limit: num4(), - fabric_node_ids: strArr(), - fabric_node_id: str4(), - node_id: str4(), - level: str4(), - role: str4(), - region_folder_id: str4(), - clear_region: bool4(), - dry_run: bool4(), - kind: str4(), - page: num4(), - page_size: num4(), - skip_manual: bool4(), - overwrite_manual: bool4(), - fill_empty_only: bool4() - }, classifyTopologyFabricNodes, getClient, t), - tool2("netx__queryTopologyNeighborhood", "Neighborhood around a fabric node (depth 1–3) with compact links[].", { - node_id: { type: "string", required: true }, - depth: num4(), - layer: str4() - }, queryTopologyNeighborhood, getClient, t), - tool2("netx__queryTopologyEdges", "Fabric adjacency links[] (detail=adjacency) or port rows (detail=ports).", { - node_id: str4(), - keyword: str4(), - layer: str4(), - status: str4(), - source: str4("lldp | ume | manual"), - detail: str4("adjacency | ports"), - page: num4(), - page_size: num4() - }, queryTopologyEdges, getClient, t), - tool2("netx__suggestSinkHubs", "Rank hub territories on a source view for non-dual move_nodes(park) batches (degree stand-in in DSH).", { - source_view_id: str4(), - view_id: str4(), - pick: num4(), - exclude_portal_ids: strArr(), - exclude_fabric_node_ids: strArr() - }, suggestSinkHubs, getClient, t), - tool2("netx__analyzeTopologyViewLayout", "Basic layout QA for a view (bbox/hubs). Full dual_unit/crossing scores need netx-topology MCP.", { - view_id: str4(), - folder_id: str4(), - detail: str4("summary | structure | hotspots | blocks | both"), - score_profile: str4(), - sight_limit: num4(), - max_views: num4(), - min_nodes: num4(), - max_nodes: num4(), - with_meta: bool4() - }, analyzeTopologyViewLayout, getClient, t), - tool2("netx__sinkTopologyDualUnits", "Drain dual_unit eyes from source to sink (requires netx-topology MCP layout engine in DSH).", { - source_view_id: str4(), - sink_view_id: str4(), - max_units: num4(), - min_nodes: num4(), - max_nodes: num4(), - max_batch_nodes: num4(), - layout_batch: bool4(), - until_empty: bool4(), - dry_run: bool4() - }, sinkTopologyDualUnits, getClient, t), - tool2("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: str4(), - action: str4(), - source_view_id: str4(), - recipe: str4(), - preset: str4(), - mode: str4("preview | apply"), - tune: bool4(), - params: anyObj("Action-specific options (move_nodes / recipes / polish)."), - catalog: bool4(), - fabric_node_ids: strArr(), - park: bool4(), - copy_positions: bool4() - }, layoutTopologyView, getClient, Math.max(t, 180000)) - ]; - const selected = catalog.filter((entry) => allow.has(entry.name)); - const names = selected.map((entry) => entry.name); const disposers = []; + const names = []; try { - for (const entry of selected) - disposers.push(ctx.tools.register(entry)); - publishToolMountStatus({ - id, - version: packageVersion(), - plane: options.plane, - groups: [...enabledGroups], - names, - count: names.length, - at: new Date().toISOString(), - source: options.source ?? options.plane - }); + for (const tool2 of enabled) { + const definition = defineRemoteTool(tool2, t); + disposers.push(ctx.tools.register(definition)); + names.push(publicToolName(tool2.name)); + } } catch (error) { - for (const dispose of disposers.reverse()) + for (const dispose of disposers.splice(0)) dispose(); publishToolMountStatus({ id, version: packageVersion(), plane: options.plane, - groups: [...enabledGroups], + groups, names: [], count: 0, at: new Date().toISOString(), - error: error instanceof Error ? error.message : String(error), + error: String(error), source: options.source ?? options.plane }); throw error; } + publishToolMountStatus({ + id, + version: packageVersion(), + plane: options.plane, + groups, + names, + count: names.length, + at: new Date().toISOString(), + source: options.source ?? options.plane + }); return () => { for (const dispose of disposers) dispose(); @@ -3653,6 +1956,16 @@ function packageVersion2() { return "0"; } } +function disabledByGroupFor(groupIds, connection) { + const catalog = getMcpCatalogTools(); + const out = {}; + for (const groupId of groupIds) { + const names = disabledPublicNames(catalog, [groupId], connection.disabledTools, connection.readOnlyOnly); + if (names.length > 0) + out[groupId] = names; + } + return out; +} function applyGroupToolsPlugin(ctx, options) { let unregisterTools; let unregisterKbLocal; @@ -3678,9 +1991,10 @@ function applyGroupToolsPlugin(ctx, options) { unregisterTools = registerNetxTools(ctx, connection, { plane: "preset", forceGroups: enabled, - source: options.name + source: options.name, + presetId: presetId() }); - ctx.logger.info("%s: groups=[%s] → %s tokenConfigured=%s live=%s", options.name, enabled.join(",") || "(none)", connection.apiUrl, connection.token.trim().length > 0, getNetxConnection() !== undefined); + ctx.logger.info("%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({ id: mountId, @@ -3708,6 +2022,9 @@ function applyGroupToolsPlugin(ctx, options) { remountTools(); remountKbLocal(); }); + const stopCatalogWatch = watchMcpCatalog(() => { + remountTools(); + }); const stopKbToolWatch = watchKbContext(() => { remountKbLocal(); }); @@ -3737,8 +2054,13 @@ function applyGroupToolsPlugin(ctx, options) { skillAbort = new AbortController; unregisterSkills?.(); unregisterSkills = undefined; + const connection = requireNetxConnection(); const enabled = resolveGroups(); - registerGroupSkills(skillsCtx, enabled, options.name, skillAbort.signal).then((dispose) => { + registerGroupSkills(skillsCtx, enabled, options.name, { + signal: skillAbort.signal, + disabledByGroup: disabledByGroupFor(enabled, connection), + lang: connection.lang + }).then((dispose) => { if (gen !== skillGeneration) { dispose(); return; @@ -3783,6 +2105,9 @@ function applyGroupToolsPlugin(ctx, options) { remountKbSkill(); remountKbPack(); }); + const stopSkillCatalogWatch = watchMcpCatalog(() => { + remountSkills(); + }); const stopKbWatch = watchKbContext(() => { remountKbSkill(); remountKbPack(); @@ -3796,6 +2121,7 @@ function applyGroupToolsPlugin(ctx, options) { skillAbort.abort(); packAbort.abort(); stopSkillWatch(); + stopSkillCatalogWatch(); stopKbWatch(); unregisterSkills?.(); unregisterSkills = undefined; @@ -3808,6 +2134,7 @@ function applyGroupToolsPlugin(ctx, options) { ctx.effect(() => () => { clearInterval(pollTimer); stopToolWatch(); + stopCatalogWatch(); stopKbToolWatch(); unregisterTools?.(); unregisterTools = undefined; diff --git a/lib/agent-tools.js b/lib/agent-tools.js index ef6d689..a4add75 100644 --- a/lib/agent-tools.js +++ b/lib/agent-tools.js @@ -21,52 +21,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", @@ -101,14 +55,6 @@ function groupsForPlane(groups, plane, only) { const allow = new Set(only); return enabled.filter((id) => allow.has(id)); } -function toolNamesForGroups(groupIds) { - const out = new Set; - for (const id of groupIds) { - for (const name of TOOLS_BY_GROUP[id] ?? []) - out.add(name); - } - return out; -} function parsePresetIds(value) { const current = readConfigValue(value); if (typeof current !== "string") @@ -122,6 +68,234 @@ function groupsForPreset(connection, presetId, only) { return CAPABILITY_GROUP_IDS.filter((id) => (!only?.length || only.includes(id)) && !policy[id].public && (presetId === "netxops" ? policy[id].inPreset : policy[id].presets?.includes(presetId))); } +// src/netx/mcp-types.ts +var NETX_TOOL_PREFIX = "netx__"; +function publicToolName(serverName) { + return `${NETX_TOOL_PREFIX}${serverName}`; +} + +// src/netx/mcp-catalog.ts +function disabledSet(disabledTools) { + const out = new Set; + for (const name of disabledTools ?? []) { + const trimmed = typeof name === "string" ? name.trim() : ""; + if (trimmed) + out.add(trimmed); + } + return out; +} +function filterCatalogTools(catalog, enabledGroups, policy) { + const allowGroups = new Set(enabledGroups); + const disabled = disabledSet(policy.disabledTools); + const readOnlyOnly = policy.readOnlyOnly === true; + const out = []; + 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; +} +function resolveEnabledTools(catalog, options) { + 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); +} +function disabledPublicNames(catalog, enabledGroups, disabledTools, readOnlyOnly) { + const allowGroups = new Set(enabledGroups); + const disabled = disabledSet(disabledTools); + const names = []; + 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(); +} + +// src/netx/mcp-remote.ts +var PROTOCOL_VERSION = "2025-03-26"; +var CLIENT_INFO = { name: "dsh-netxops", version: "0.1.71" }; + +class McpRemoteError extends Error { + status; + constructor(message, status) { + super(message); + this.name = "McpRemoteError"; + this.status = status; + } +} +function mcpEndpointUrl(apiUrl) { + const trimmed = apiUrl.trim().replace(/\/$/, ""); + if (!trimmed) + return ""; + return `${trimmed}/mcp`; +} +function parseMcpResponseBody(raw) { + 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 = []; + 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 {} + } + } + throw new McpRemoteError("mcp response was not JSON"); +} +function asRpcResult(payload) { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + throw new McpRemoteError("invalid JSON-RPC envelope from /mcp"); + } + const row = payload; + if (row.error && typeof row.error === "object" && !Array.isArray(row.error)) { + const err = row.error; + 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.result; +} +var nextId = 1; +async function postRpc(options, method, params, notification = false) { + const headers = { + "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 : 60000; + 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 = () => { + abort.abort(); + }; + options.signal?.addEventListener("abort", onOuterAbort, { once: true }); + let 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; + } + return asRpcResult(parseMcpResponseBody(responseText)); +} +async function callMcpTool(options, name, args) { + 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 + }); + const content = Array.isArray(result.content) ? result.content : []; + return { + content, + ...result.structuredContent !== undefined ? { structuredContent: result.structuredContent } : {}, + ...result.isError === true ? { isError: true } : {} + }; +} +function mcpContentText(content, fallback = "") { + const parts = []; + for (const block of content) { + if (!block || typeof block !== "object" || Array.isArray(block)) + continue; + const row = block; + if (row.type === "text" && typeof row.text === "string") + parts.push(row.text); + } + return parts.join(` +`) || fallback; +} + +// src/netx/mcp-store.ts +var STORE_KEY = Symbol.for("dsh-netxops.mcp-catalog-store"); +var EMPTY = Object.freeze({ + phase: "idle", + mcpUrl: "", + tools: Object.freeze([]), + error: "", + listedAt: null, + fromCache: false +}); +function store() { + const root = globalThis; + let current = root[STORE_KEY]; + if (current === undefined) { + current = { state: EMPTY, listeners: new Set, generation: 0 }; + root[STORE_KEY] = current; + } + return current; +} +function getMcpCatalogTools() { + return store().state.tools; +} +function watchMcpCatalog(listener) { + const state = store(); + state.listeners.add(listener); + return () => { + state.listeners.delete(listener); + }; +} + // src/netx/group-skills.ts import { existsSync } from "node:fs"; import { readdir, readFile, stat } from "node:fs/promises"; @@ -271,7 +445,24 @@ async function loadGroupSkills(skillsRoot, groupId) { } return { skills, skipped }; } -async function registerGroupSkills(ctx, groupIds, providerLabel, signal) { +function disabledFootnote(names, lang) { + if (names.length === 0) + return ""; + const list = names.join(", "); + const en = (lang ?? "").trim().toLowerCase().startsWith("en"); + return en ? ` + +## Disabled tools +The following tools are disabled: ${list}. Do not call them. +` : ` + +## 已禁用工具 +以下工具已禁用:${list},不要调用。 +`; +} +async function registerGroupSkills(ctx, groupIds, providerLabel, signalOrOptions) { + const options = signalOrOptions instanceof AbortSignal || signalOrOptions === undefined ? { signal: signalOrOptions } : signalOrOptions; + const signal = options.signal; const skillsApi = ctx.skills; if (!skillsApi || typeof skillsApi.register !== "function") { return () => {}; @@ -293,15 +484,17 @@ async function registerGroupSkills(ctx, groupIds, providerLabel, signal) { if (skills.length === 0) { log?.warn?.("%s: no skills loaded for group=%s from %s", providerLabel, groupId, 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} + +## DeepSeek Harness REST capabilities +Use only tools present in this session. Dual-unit/orbit/polish recipes require server-side topology tools. Use only tools present in this session. +` : skill.content; disposers.push(skillsApi.register({ name: skill.name, description: skill.description, - content: groupId === "topology" ? `${skill.content} - -## DeepSeek Harness REST capabilities -Use 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. -` : skill.content, + content: `${base}${footnote}`, path: skill.path, resourceBase: { kind: "directory", path: skill.directory }, provider: providerLabel, @@ -1119,18 +1312,18 @@ function searchKb(snapshot, args) { } // src/netx/kb-runtime.ts -var STORE_KEY = Symbol.for("dsh-netxops.kb-store"); -function store() { +var STORE_KEY2 = Symbol.for("dsh-netxops.kb-store"); +function store2() { const root = globalThis; - let current = root[STORE_KEY]; + let current = root[STORE_KEY2]; if (current === undefined) { current = { snapshot: unconfiguredKbSnapshot(), listeners: new Set }; - root[STORE_KEY] = current; + root[STORE_KEY2] = current; } return current; } function getKbContext() { - const snap = store().snapshot; + const snap = store2().snapshot; return { ...snap, content: { ...snap.content }, @@ -1138,7 +1331,7 @@ function getKbContext() { }; } function watchKbContext(listener) { - const state = store(); + const state = store2(); state.listeners.add(listener); return () => { state.listeners.delete(listener); @@ -1162,26 +1355,18 @@ function toLosslessJson(value) { return { ok: false, error: "tool_result_not_json", detail: detail.slice(0, 400) }; } } -function omitUndefined(record) { - const out = {}; - for (const [key, value] of Object.entries(record)) { - if (value !== undefined) - out[key] = value; - } - return out; -} // src/netx/tool-mount-status.ts import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs"; import { homedir } from "node:os"; import { dirname as dirname4, join as join7 } from "node:path"; var KEY = Symbol.for("dsh-netxops.mount-inventory"); -function store2() { +function store3() { const root = globalThis; return root[KEY] ??= { mounts: new Map, contexts: new WeakMap, sequence: 0 }; } function getToolMountId(ctx, source) { - const state = store2(); + const state = store3(); let id = state.contexts.get(ctx); if (id === undefined) { id = ++state.sequence; @@ -1190,7 +1375,7 @@ function getToolMountId(ctx, source) { return `${source}:${id}`; } function getToolMountStatuses() { - return [...store2().mounts.values()].map((row) => ({ ...row, groups: [...row.groups], names: [...row.names] })); + return [...store3().mounts.values()].map((row) => ({ ...row, groups: [...row.groups], names: [...row.names] })); } function getToolMountStatus() { const rows = getToolMountStatuses(); @@ -1205,11 +1390,11 @@ function persist() { } catch {} } function publishToolMountStatus(next) { - store2().mounts.set(next.id ?? next.source ?? next.plane, { ...next, groups: [...next.groups], names: [...next.names] }); + store3().mounts.set(next.id ?? next.source ?? next.plane, { ...next, groups: [...next.groups], names: [...next.names] }); persist(); } function removeToolMountStatus(id) { - store2().mounts.delete(id); + store3().mounts.delete(id); persist(); } @@ -1571,139 +1756,20 @@ function normalizeApiUrl(raw) { const search = parsed.search && parsed.search !== "?" ? parsed.search : ""; return { ok: true, url: `${parsed.origin}${path}${search}` }; } -var PROTOCOL_KEY_ZH_TO_EN = { - 其他: "Other", - 时钟: "Clock", - "OTN/光": "OTN/Optical", - 电源: "Power" -}; -function asLang(value) { - return typeof value === "string" ? value : ""; -} -function localizePayload(lang, data) { - 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; - const key = typeof rec.key === "string" ? rec.key : ""; - const mapped = PROTOCOL_KEY_ZH_TO_EN[key]; - if (mapped !== undefined) - rec.key = mapped; - } - return data; -} -function encodeQuery(params) { - const sp = new URLSearchParams; - for (const [key, value] of Object.entries(params)) { - sp.set(key, String(value)); - } - const q = sp.toString(); - return q.length > 0 ? `?${q}` : ""; -} -function createNetxClient(connection) { - const langParams = () => { - const lang = asLang(connection.lang).trim().toLowerCase(); - if (lang.startsWith("en")) - return { lang: "en" }; - return {}; - }; - async function request(method, path, options = {}) { - 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 = { ...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 = { - accept: "application/json", - authorization: `Bearer ${token}` - }; - if (options.body !== undefined) - headers["content-type"] = "application/json"; - const init = { - 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) : {}; - if (typeof data === "object" && data !== null && !Array.isArray(data)) { - return { ok: true, data: localizePayload(connection.lang, data) }; - } - return { ok: true, data: { raw: data } }; - } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - return { ok: false, error: "netx_request_failed", detail: detail.slice(0, 800) }; - } finally { - clearTimeout(timer); - options.signal?.removeEventListener("abort", onOuterAbort); - } - } - return { - get(path, params, signal, timeoutMs) { - return request("GET", path, { params, signal, timeoutMs }); - }, - post(path, body, signal, timeoutMs) { - return request("POST", path, { body, signal, timeoutMs }); - }, - patch(path, body, signal, timeoutMs) { - return request("PATCH", path, { body, signal, timeoutMs }); - } - }; -} -function quoteNeId(neId) { - return encodeURIComponent(neId.trim()); -} // src/netx/runtime.ts -var STORE_KEY2 = Symbol.for("dsh-netxops.connection-store"); -function store3() { +var STORE_KEY3 = Symbol.for("dsh-netxops.connection-store"); +function store4() { const root = globalThis; - let current = root[STORE_KEY2]; + let current = root[STORE_KEY3]; if (current === undefined) { current = { connection: undefined, listeners: new Set }; - root[STORE_KEY2] = current; + root[STORE_KEY3] = current; } return current; } function getNetxConnection() { - return store3().connection; + return store4().connection; } function provisionalNetxConnection() { const envUrl = process.env.NETX_API_URL?.trim() ?? ""; @@ -1715,6 +1781,8 @@ function provisionalNetxConnection() { lang: typeof process.env.NETX_LANG === "string" && process.env.NETX_LANG.trim() ? process.env.NETX_LANG.trim() : "zh", toolCallTimeoutMs: 120000, groups: DEFAULT_CAPABILITY_GROUPS, + disabledTools: [], + readOnlyOnly: false, groupKbInPreset: true, groupKbPublic: false }; @@ -1723,7 +1791,7 @@ function requireNetxConnection() { return getNetxConnection() ?? provisionalNetxConnection(); } function watchNetxConnection(listener) { - const state = store3(); + const state = store4(); state.listeners.add(listener); return () => { state.listeners.delete(listener); @@ -1735,1443 +1803,6 @@ import { readFileSync as readFileSync5 } from "node:fs"; import { dirname as dirname5, join as join9 } from "node:path"; import { fileURLToPath as fileURLToPath2 } from "node:url"; import { defineTool as defineTool2 } from "@deepseek-ai/dsh-tools"; - -// src/netx/handlers.ts -var EXEC_MAX_COMMANDS_CAP = 50; -var EXEC_MAX_COMMANDS_DEFAULT = 5; -function execMaxCommands() { - 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() { - 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) { - if (raw === undefined || raw === null) - return; - if (typeof raw === "boolean") - return raw; - const text = String(raw).trim().toLowerCase(); - if (!text) - return; - if (["1", "true", "yes", "on"].includes(text)) - return true; - if (["0", "false", "no", "off"].includes(text)) - return false; - return; -} -function countExecNeTargets(args) { - let n = 0; - for (const key of ["ne_ids", "nms_ne_ids", "ume_ne_ids"]) { - 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 (str2(args, "ne_id").trim() || nmsOrUme(args, "nms_ne_id", "ume_ne_id")) - return 1; - } - return n; -} -function shouldRunExecAsync(args) { - 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; -} -var UME_RAW_FIELD_PRESETS = { - brief: [ - "alarm_alarm_key", - "alarm_host_name", - "alarm_perceived_severity", - "alarm_event_type", - "alarm_last_seen_at", - "ne_host_name", - "ne_user_label", - "ne_ne_name", - "ne_ip_address", - "ne_exists" - ], - evidence: [ - "alarm_alarm_key", - "alarm_host_name", - "alarm_object_name", - "alarm_event_type", - "alarm_native_probable_cause", - "alarm_perceived_severity", - "alarm_is_cleared", - "alarm_time_created", - "alarm_last_seen_at", - "ne_host_name", - "ne_user_label", - "ne_ne_name", - "ne_ip_address", - "ne_connection_status", - "ne_exists" - ], - ne_debug: [ - "alarm_alarm_key", - "alarm_ne_id", - "alarm_perceived_severity", - "alarm_last_seen_at", - "ne_user_label", - "ne_ne_name", - "ne_ip_address", - "ne_ipv6_address", - "ne_device_level", - "ne_host_name", - "ne_connection_status", - "ne_admin_status", - "ne_address_type", - "ne_maintain_status", - "ne_exists" - ] -}; -function asRecord(value) { - return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; -} -function str2(args, key, fallback = "") { - const v = args[key]; - if (typeof v === "string") - return v; - if (typeof v === "number" || typeof v === "boolean") - return String(v); - return fallback; -} -function nmsOrUme(args, nmsKey, umeKey) { - return str2(args, nmsKey).trim() || str2(args, umeKey).trim(); -} -function nmsOrUmeList(args, nmsKey, umeKey) { - const primary = strList(args, nmsKey); - return primary.length > 0 ? primary : strList(args, umeKey); -} -function num2(args, key) { - const v = args[key]; - return typeof v === "number" && Number.isFinite(v) ? v : undefined; -} -function bool2(args, key) { - const v = args[key]; - return typeof v === "boolean" ? v : undefined; -} -function strList(args, key) { - const v = args[key]; - if (!Array.isArray(v)) - return []; - return v.map((x) => String(x).trim()).filter((x) => x.length > 0); -} -function clampInt(value, fallback, min, max) { - const n = value === undefined ? fallback : Math.trunc(value); - return Math.max(min, Math.min(max, n)); -} -function putStr(params, args, keys) { - for (const key of keys) { - const v = str2(args, key).trim(); - if (v) - params[key] = v; - } -} -async function queryUmeAlarms(client, args, signal) { - let page = clampInt(num2(args, "page"), 1, 1, 2); - const pageSize = clampInt(num2(args, "page_size"), 50, 1, 500); - const params = { page, page_size: pageSize }; - putStr(params, args, ["severity", "ne_id", "host_name", "time_from", "time_to"]); - const keyword = str2(args, "keyword").trim(); - const neName = str2(args, "ne_name").trim(); - if (keyword) - params.keyword = keyword; - else if (neName) - params.keyword = neName; - return client.get("/v1/ume/alarms", params, signal); -} -async function aggregateUmeAlarmsRaw(client, args, signal) { - const params = {}; - putStr(params, args, [ - "group_by", - "group_by2", - "severity", - "is_cleared", - "ne_id", - "event_type", - "keyword", - "time_from", - "time_to", - "limit" - ]); - if ("exclude_missing_host" in args) { - const flag = bool2(args, "exclude_missing_host"); - if (flag !== undefined) - params.exclude_missing_host = flag; - } - return client.get("/v1/ume/alarms/aggregate/raw", params, signal); -} -async function aggregateUmeAlarms(client, args, signal) { - if (str2(args, "group_by").trim()) - return aggregateUmeAlarmsRaw(client, args, signal); - const topNe = clampInt(num2(args, "top_ne"), 50, 0, 500); - const params = { top_ne: topNe }; - if ("exclude_missing_host" in args) { - const flag = bool2(args, "exclude_missing_host"); - if (flag !== undefined) - params.exclude_missing_host = flag; - } - putStr(params, args, ["severity", "time_from", "time_to"]); - return client.get("/v1/ume/alarms/aggregate", params, signal); -} -async function runUmeDiagnostics(client, _args, signal) { - return client.get("/v1/ume/diagnostics", undefined, signal); -} -async function queryUmeNeInventory(client, args, signal) { - const params = { - page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num2(args, "page_size"), 50, 1, 500) - }; - putStr(params, args, ["keyword"]); - return client.get("/v1/ume/inventory/ne", params, signal); -} -async function getUmeNe(client, args, signal) { - const neId = str2(args, "ne_id").trim(); - if (!neId) - return { ok: false, error: "ne_id_required", error_code: "ne_id_required" }; - return client.get(`/v1/ume/inventory/ne/${quoteNeId(neId)}`, undefined, signal); -} -async function queryUmeAlarmsRaw(client, args, signal) { - const params = { - page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num2(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 = str2(args, "field_preset").trim().toLowerCase(); - fields = UME_RAW_FIELD_PRESETS[preset] ?? []; - } - if (fields.length > 0) - params.select_fields = fields.join(","); - return client.get("/v1/ume/alarms/raw", params, signal); -} -async function listUmeAlarmFields(client, _args, signal) { - return client.get("/v1/ume/alarms/fields", undefined, signal); -} -async function sqlQueryUme(client, args, signal) { - const sql = str2(args, "sql").trim(); - if (!sql) - return { ok: false, error: "sql_required" }; - const limit = clampInt(num2(args, "limit"), 200, 1, 2000); - const statementTimeoutMs = clampInt(num2(args, "statement_timeout_ms"), 0, 0, 30000); - return client.post("/v1/sql/ume_query", { - sql, - limit, - statement_timeout_ms: statementTimeoutMs - }, signal, 60000); -} -async function listManagedNe(client, args, signal) { - const keyword = str2(args, "keyword").trim(); - const vendor = str2(args, "vendor").trim(); - const connectStatus = str2(args, "connect_status").trim(); - if (!(keyword || vendor || connectStatus)) { - return { ok: false, error: "managed_ne_filter_required", error_code: "managed_ne_filter_required" }; - } - if (keyword && keyword.length < 2) { - return { ok: false, error: "managed_ne_keyword_too_short", error_code: "managed_ne_keyword_too_short" }; - } - const params = { - page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num2(args, "page_size"), 20, 1, 100) - }; - if (keyword) - params.keyword = keyword; - if (vendor) - params.vendor = vendor; - if (connectStatus) - params.connect_status = connectStatus; - return client.get("/v1/managed-ne", params, signal); -} -async function getManagedNe(client, args, signal) { - const neId = (str2(args, "ne_id") || str2(args, "managed_ne_id") || str2(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: "" } - }; - } - const out = await client.get(`/v1/managed-ne/${quoteNeId(neId)}`, undefined, signal); - if (out.ok === false) { - const detail = `${str2(out, "detail")}${str2(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; -} -async function execManagedNe(client, args, signal) { - 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 body2 = {}; - if (Array.isArray(targetsRaw) && targetsRaw.length > 0) { - const cleaned = []; - for (const t of targetsRaw) { - if (typeof t !== "object" || t === null || Array.isArray(t)) - continue; - const row = t; - const item = {}; - const neId2 = str2(row, "ne_id").trim(); - const umeNeId2 = nmsOrUme(row, "nms_ne_id", "ume_ne_id"); - if (neId2) - item.ne_id = neId2; - if (umeNeId2) - item.ume_ne_id = umeNeId2; - const cmds = Array.isArray(row.commands) ? row.commands.map((c) => String(c).trim()).filter((c) => c.length > 0) : []; - if (cmds.length > 0) - item.commands = cmds; - if (Object.keys(item).length > 0) - cleaned.push(item); - } - body2.targets = cleaned; - } - if (neIds.length > 0) - body2.ne_ids = neIds; - if (umeNeIds.length > 0) - body2.ume_ne_ids = umeNeIds; - if (sharedCommands.length > 0) { - if (sharedCommands.length > maxCmds) { - return { ok: false, error: "too_many_commands", error_code: "too_many_commands" }; - } - body2.commands = sharedCommands; - } - body2.read_timeout_sec = clampInt(num2(args, "read_timeout_sec"), 60, 10, 120); - const concurrency = num2(args, "concurrency"); - if (concurrency !== undefined) - body2.concurrency = clampInt(concurrency, 4, 1, 8); - if (wantAsync) { - const out3 = await client.post("/v1/managed-ne/exec-jobs", body2, signal, 60000); - if (out3.ok !== true) - return out3; - const data3 = asRecord(out3.data); - return Object.keys(data3).length > 0 ? data3 : out3; - } - const out2 = await client.post("/v1/managed-ne/exec-batch", body2, signal, 600000); - if (out2.ok !== true) - return out2; - const data2 = asRecord(out2.data); - if (data2.ok === false) { - return { ok: false, data: data2, error: str2(data2, "error", "exec_batch_failed") }; - } - return { ok: true, data: data2 }; - } - const neId = str2(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 = { - commands: sharedCommands, - read_timeout_sec: clampInt(num2(args, "read_timeout_sec"), 60, 10, 120) - }; - if (neId) - body.ne_id = neId; - if (umeNeId) - body.ume_ne_id = umeNeId; - if (wantAsync) { - const out2 = await client.post("/v1/managed-ne/exec-jobs", body, signal, 60000); - if (out2.ok !== true) - return out2; - const data2 = asRecord(out2.data); - return Object.keys(data2).length > 0 ? data2 : out2; - } - const out = await client.post("/v1/managed-ne/exec", body, signal, 300000); - if (out.ok !== true) - return out; - const data = asRecord(out.data); - if (data.ok === false) { - return { ok: false, data, error: str2(data, "error", "exec_failed") }; - } - return { ok: true, data }; -} -async function getNeExecJob(client, args, signal) { - const jobId = str2(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); -} -async function listCliTargets(client, args, signal) { - const params = { - page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num2(args, "page_size"), 50, 1, 500) - }; - putStr(params, args, ["source", "keyword"]); - return client.get("/v1/cli/targets", params, signal); -} -async function findTopologyPaths(client, args, signal) { - const fromUid = nmsOrUme(args, "from_nms_ne_id", "from_ume_ne_id"); - const fromMid = str2(args, "from_managed_ne_id").trim(); - const toUid = nmsOrUme(args, "to_nms_ne_id", "to_ume_ne_id"); - const toMid = str2(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 = str2(args, "detail", "summary").trim().toLowerCase() || "summary"; - if (detail !== "summary" && detail !== "full") - detail = "summary"; - const body = { - max_paths: clampInt(num2(args, "max_paths"), 3, 1, 10), - max_hops: clampInt(num2(args, "max_hops"), 6, 1, 12), - layer: str2(args, "layer", "physical").trim() || "physical", - detail - }; - if (fromUid) - body.from_ume_ne_id = fromUid; - else - body.from_managed_ne_id = fromMid; - if (toUid) - body.to_ume_ne_id = toUid; - else - body.to_managed_ne_id = toMid; - return client.post("/v1/topology/fabric/paths", body, signal, 30000); -} -function taskBriefNe(brief) { - 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) { - 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, q) { - 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); -} -async function listBizMonitors(client, args, signal) { - const kindRaw = str2(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 = str2(args, "purpose").trim(); - const status = str2(args, "status").trim().toLowerCase(); - const q = str2(args, "q").trim().toLowerCase(); - const limit = clampInt(num2(args, "limit") ?? 100, 100, 1, 500); - const wantProjects = kind === "all" || kind === "projects"; - const wantTasks = kind === "all" || kind === "tasks"; - const fetches = []; - if (wantProjects) - fetches.push(client.get("/v1/biz-migration/projects", {}, signal)); - else - fetches.push(Promise.resolve({ ok: true, data: { items: [] } })); - if (wantTasks) { - const params = {}; - 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 : []; - const taskItems = Array.isArray(asRecord(taskRes.data).items) ? asRecord(taskRes.data).items : []; - 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) { - 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, taskId = "") { - 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) { - 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 - }); -} -async function listBizMonitorBatches(client, args, signal) { - const projectId = str2(args, "project_id").trim(); - const taskId = str2(args, "task_id").trim(); - const batchId = str2(args, "batch_id").trim(); - const kindRaw = str2(args, "kind", "auto").trim().toLowerCase() || "auto"; - const includeRuns = args.include_runs === true || args.include_runs === "true" || args.include_runs === 1; - const status = str2(args, "status").trim().toLowerCase(); - const limit = clampInt(num2(args, "limit") ?? 50, 50, 1, 200); - const runsLimit = clampInt(num2(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 = []; - let collectBatches = []; - let runs = []; - 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 : []; - 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 : []; - 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 : []; - 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: [] }; - const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : []; - 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) { - 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)." - }) - }; -} -async function getBizMonitorContext(client, args, signal) { - const projectId = str2(args, "project_id").trim(); - const taskId = str2(args, "task_id").trim(); - if (!projectId && !taskId) { - return { ok: false, error: "project_id_or_task_id_required" }; - } - const params = {}; - if (projectId) - params.project_id = projectId; - if (taskId) - params.task_id = taskId; - return client.get("/v1/biz-migration/monitor-context", params, signal); -} -async function getBizMonitorBoard(client, args, signal) { - const batchId = str2(args, "batch_id").trim(); - if (!batchId) - return { ok: false, error: "batch_id_required" }; - const params = {}; - const runId = str2(args, "run_id").trim(); - if (runId) - params.run_id = runId; - return client.get(`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/board`, params, signal); -} -async function listBizMonitorReds(client, args, signal) { - const projectId = str2(args, "project_id").trim(); - if (!projectId) - return { ok: false, error: "project_id_required" }; - const params = {}; - putStr(params, args, ["status"]); - const limit = num2(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); -} -async function getBizMonitorDiffs(client, args, signal) { - const runId = str2(args, "run_id").trim(); - if (!runId) - return { ok: false, error: "run_id_required" }; - const params = {}; - putStr(params, args, ["metric_id", "sheet_id", "verdict", "color", "q"]); - const limit = num2(args, "limit"); - if (limit !== undefined) - params.limit = clampInt(limit, 200, 1, 500); - const offset = num2(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); -} -async function getBizCollectBatch(client, args, signal) { - const batchId = str2(args, "batch_id").trim(); - if (!batchId) - return { ok: false, error: "batch_id_required" }; - return client.get(`/v1/biz-state/batches/${encodeURIComponent(batchId)}`, {}, signal); -} -async function getBizCollectCommandRaw(client, args, signal) { - const batchId = str2(args, "batch_id").trim(); - const commandId = str2(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); -} - -// src/netx/topology-handlers.ts -function asRecord2(value) { - return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; -} -function str3(args, key, fallback = "") { - const v = args[key]; - if (typeof v === "string") - return v; - if (typeof v === "number" || typeof v === "boolean") - return String(v); - return fallback; -} -function num3(args, key) { - const v = args[key]; - return typeof v === "number" && Number.isFinite(v) ? v : undefined; -} -function bool3(args, key) { - const v = args[key]; - return typeof v === "boolean" ? v : undefined; -} -function strList2(args, key) { - const v = args[key]; - if (!Array.isArray(v)) - return []; - return v.map((x) => String(x).trim()).filter((x) => x.length > 0); -} -function clampInt2(value, fallback, min, max) { - const n = value === undefined ? fallback : Math.trunc(value); - return Math.max(min, Math.min(max, n)); -} -function unwrap(out) { - if (out.ok !== true) - return out; - const data = asRecord2(out.data); - if (Object.keys(data).length === 0) - return { ok: true, data: out.data }; - return { ok: true, ...data, data }; -} -function filterFields(args) { - const out = {}; - for (const key of ["keyword", "role", "vendor", "link_status"]) { - const v = str3(args, key).trim(); - if (v) - out[key] = v; - } - return out; -} -function edgeEndpoints(edge) { - const a = str3(edge, "a_node_id") || str3(edge, "a"); - const b = str3(edge, "b_node_id") || str3(edge, "b"); - return [a.trim(), b.trim()]; -} -function collapseEdgesToLinks(edges, includeNames = false) { - const buckets = new Map; - for (const raw of edges) { - if (!raw || typeof raw !== "object" || Array.isArray(raw)) - continue; - const e = raw; - 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}\x00${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 = str3(e, "a_name"); - row.b_name = str3(e, "b_name"); - } else { - row.a_name = str3(e, "b_name"); - row.b_name = str3(e, "a_name"); - } - } - buckets.set(key, row); - } - return [...buckets.values()]; -} -function summarizeViewGraph(graph, sample = 20) { - 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 = asRecord2(n); - return { - id: str3(row, "id") || str3(row, "fabric_node_id"), - name: str3(row, "name"), - x: row.x, - y: row.y, - level: row.level, - role: row.role - }; - }); - return { - ok: true, - view_id: str3(graph, "id") || str3(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) { - 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)." - }; -} -async function getTopologyTree(client, args, signal) { - const out = unwrap(await client.get("/v1/topology/tree", undefined, signal)); - if (out.ok !== true) - return out; - const compact = bool3(args, "compact") !== false; - const maxDepth = num3(args, "max_depth"); - if (!compact && maxDepth === undefined) - return out; - return { ...out, compact, max_depth: maxDepth ?? null }; -} -async function getTopologyView(client, args, signal) { - const viewId = str3(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, 120000)); - if (out.ok !== true) - return out; - const detail = str3(args, "detail", "summary").trim().toLowerCase() || "summary"; - if (detail === "full") - return { ...out, detail: "full" }; - return summarizeViewGraph(out, clampInt2(num3(args, "sample"), 20, 0, 200)); -} -async function createTopologyFolder(client, args, signal) { - const name = str3(args, "name").trim(); - if (!name) - return { ok: false, error: "name_required" }; - const body = { name }; - const parentId = str3(args, "parent_id").trim(); - if (parentId) - body.parent_id = parentId; - const locale = str3(args, "locale").trim(); - if (locale) - body.locale = locale; - if (num3(args, "sort_order") !== undefined) - body.sort_order = Math.trunc(num3(args, "sort_order")); - return unwrap(await client.post("/v1/topology/folders", body, signal)); -} -async function addTopologyViewNodes(client, args, signal) { - const viewId = str3(args, "view_id").trim(); - if (!viewId) - return { ok: false, error: "view_id_required" }; - if (strList2(args, "managed_ne_ids").length || strList2(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 = strList2(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 (num3(args, "max_nodes") !== undefined) { - const got = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal)); - if (got.ok === true) { - const data = asRecord2(got.data); - const filt = asRecord2(got.filter ?? data.filter); - const membership = asRecord2(filt.membership ?? {}); - membership.max_nodes = clampInt2(num3(args, "max_nodes"), 2000, 1, 2000); - filt.membership = membership; - await client.patch(`/v1/topology/views/${encodeURIComponent(viewId)}`, { filter: filt }, signal); - } - } - const layout = str3(args, "layout", "grid").trim() || "grid"; - const body = { - managed_ne_ids: [], - layout, - ...filters - }; - if (fabricIds.length) - body.fabric_node_ids = fabricIds; - if (num3(args, "limit") !== undefined) - body.limit = clampInt2(num3(args, "limit"), 500, 1, 2000); - if (num3(args, "offset") !== undefined) - body.offset = clampInt2(num3(args, "offset"), 0, 0, Number.MAX_SAFE_INTEGER); - return unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes`, body, signal, 180000)); -} -async function removeTopologyViewNodes(client, args, signal) { - const viewId = str3(args, "view_id").trim(); - if (!viewId) - return { ok: false, error: "view_id_required" }; - const body = { ...filterFields(args) }; - const fabricIds = strList2(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)); -} -async function copyTopologyViewNodes(client, args, signal) { - const sourceId = str3(args, "source_view_id").trim(); - const targetId = str3(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, 120000)); - if (source.ok !== true) - return source; - const nodes = Array.isArray(source.nodes) ? source.nodes : []; - const limit = num3(args, "limit"); - const selected = typeof limit === "number" ? nodes.slice(0, clampInt2(limit, nodes.length, 1, 2000)) : nodes; - const fabricIds = selected.map((n) => { - const row = asRecord2(n); - return str3(row, "id") || str3(row, "fabric_node_id"); - }).filter(Boolean); - if (bool3(args, "dry_run") === true) { - return { ok: true, dry_run: true, would_copy: fabricIds.length, source_view_id: sourceId, target_view_id: targetId }; - } - if (bool3(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, 180000)); - if (add.ok !== true) - return add; - if (bool3(args, "copy_positions") !== false) { - const ox = num3(args, "offset_x") ?? 0; - const oy = num3(args, "offset_y") ?? 0; - const positions = selected.map((n) => { - const row = asRecord2(n); - const id = str3(row, "id") || str3(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, 120000); - } - } - return { ok: true, added: fabricIds.length, source_view_id: sourceId, target_view_id: targetId, ...asRecord2(add) }; -} -async function updateTopologyViewPositions(client, args, signal) { - const viewId = str3(args, "view_id").trim(); - if (!viewId) - return { ok: false, error: "view_id_required" }; - const body = {}; - if (Array.isArray(args.positions)) - body.positions = args.positions; - const layout = str3(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"]) { - if (num3(args, key) !== undefined) - body[key] = num3(args, key); - } - const fabricIds = strList2(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, 120000)); -} -async function projectTopologyNeighbors(client, args, signal) { - const viewId = str3(args, "view_id").trim(); - if (!viewId) - return { ok: false, error: "view_id_required" }; - const body = {}; - const seeds = strList2(args, "seed_fabric_node_ids").length ? strList2(args, "seed_fabric_node_ids") : strList2(args, "fabric_node_ids"); - if (seeds.length) - body.seed_fabric_node_ids = seeds; - const mids = strList2(args, "managed_ne_ids"); - if (mids.length) - body.managed_ne_ids = mids; - const region = str3(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, 180000)); - if (out.ok !== true) - return out; - const detail = str3(args, "detail", "summary").trim().toLowerCase() || "summary"; - if (detail === "full" || detail === "raw" || detail === "graph") { - return { ...out, detail: "full", projected: true }; - } - return { ...summarizeViewGraph(out, clampInt2(num3(args, "sample"), 20, 0, 100)), projected: true, view_id: viewId }; -} -async function queryTopologyFabricNodes(client, args, signal) { - let mode = str3(args, "mode").trim().toLowerCase(); - const q = str3(args, "q").trim(); - const keyword = str3(args, "keyword").trim(); - if (!mode) { - if (bool3(args, "summary") === true) - mode = "summary"; - else if (q) - mode = "search"; - else - mode = "list"; - } - if (mode === "summary" || mode === "stats" || mode === "count") { - const out2 = unwrap(await client.get("/v1/topology/fabric/summary", undefined, signal)); - return out2.ok === true ? { ...out2, mode: "summary" } : out2; - } - 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: clampInt2(num3(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num3(args, "page_size") ?? num3(args, "limit"), 50, 1, 200) - }, signal)); - } - const params = { - page: clampInt2(num3(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num3(args, "page_size") ?? num3(args, "limit"), 50, 1, 500) - }; - for (const key of ["keyword", "role", "level", "level_major", "region_folder_id", "link_status"]) { - const v = str3(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; -} -async function classifyTopologyFabricNodes(client, args, signal) { - const action = str3(args, "action").trim().toLowerCase(); - if (!action) - return { ok: false, error: "action_required" }; - if (action === "match") { - const pattern = str3(args, "pattern").trim() || str3(args, "q").trim(); - if (!pattern) - return { ok: false, error: "pattern_required" }; - return unwrap(await client.post("/v1/topology/fabric/nodes/match", { - pattern, - match_field: str3(args, "match_field", "name").trim() || "name", - sample_limit: clampInt2(num3(args, "sample_limit"), 50, 1, 200) - }, signal)); - } - if (action === "tag") { - const body = { - fabric_node_ids: strList2(args, "fabric_node_ids"), - dry_run: bool3(args, "dry_run") === true - }; - const pattern = str3(args, "pattern").trim() || str3(args, "q").trim(); - if (pattern) - body.pattern = pattern; - if (args.level !== undefined) - body.level = args.level; - const role = str3(args, "role").trim(); - if (role) - body.role = role; - const region = str3(args, "region_folder_id").trim(); - if (region) - body.region_folder_id = region; - if (bool3(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 = str3(args, "fabric_node_id").trim() || str3(args, "node_id").trim(); - if (!nodeId) - return { ok: false, error: "fabric_node_id_required" }; - const body = {}; - if (args.level !== undefined) - body.level = args.level; - const role = str3(args, "role").trim(); - if (role) - body.role = role; - const region = str3(args, "region_folder_id").trim(); - if (region) - body.region_folder_id = region; - if (bool3(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: str3(args, "kind", "any").trim() || "any", - page: clampInt2(num3(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num3(args, "page_size") ?? num3(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 = {}; - if (bool3(args, "overwrite_manual") === true) - params.skip_manual = false; - else if (bool3(args, "skip_manual") !== undefined) - params.skip_manual = bool3(args, "skip_manual") !== false; - if (bool3(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 }; -} -async function queryTopologyNeighborhood(client, args, signal) { - const nodeId = str3(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: clampInt2(num3(args, "depth"), 1, 1, 3), - layer: str3(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) }; -} -async function queryTopologyEdges(client, args, signal) { - const params = { - page: clampInt2(num3(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num3(args, "page_size"), 100, 1, 500), - layer: str3(args, "layer", "physical").trim() || "physical" - }; - for (const key of ["node_id", "keyword", "status", "source"]) { - const v = str3(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 = str3(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 }; -} -async function suggestSinkHubs(client, args, signal) { - const viewId = str3(args, "source_view_id").trim() || str3(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, 120000)); - 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; - for (const raw of edges) { - const [a, b] = edgeEndpoints(asRecord2(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([ - ...strList2(args, "exclude_portal_ids"), - ...strList2(args, "exclude_fabric_node_ids") - ]); - const pick = clampInt2(num3(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 row2 = asRecord2(n); - return (str3(row2, "id") || str3(row2, "fabric_node_id")) === fabric_node_id; - }); - const row = asRecord2(node); - return { - fabric_node_id, - degree: deg, - name: str3(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." - }; -} -async function analyzeTopologyViewLayout(client, args, signal) { - const viewId = str3(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, 120000)); - 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 = asRecord2(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; - for (const raw of edges) { - const [a, b] = edgeEndpoints(asRecord2(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: str3(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." - }; -} -async function sinkTopologyDualUnits(_client, args) { - return { - ok: false, - error: "sink_dual_units_requires_netx_topology_mcp", - source_view_id: str3(args, "source_view_id"), - sink_view_id: str3(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." - }; -} -async function layoutTopologyView(client, args, signal) { - if (bool3(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 = str3(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 = str3(args, "view_id").trim(); - const fromId = str3(args, "source_view_id").trim(); - const ids = strList2(args, "fabric_node_ids"); - const params = asRecord2(args.params); - const fabricIds = ids.length ? ids : strList2(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 = bool3(params, "copy_positions") !== false && bool3(args, "copy_positions") !== false; - const removeFromSource = bool3(params, "remove_from_source") !== false; - const source = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(fromId)}`, undefined, signal, 120000)); - 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 = asRecord2(n); - const id = str3(row, "id") || str3(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, 180000)); - 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 = asRecord2(n); - return { - fabric_node_id: str3(row, "id") || str3(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, 120000); - } - 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: bool3(params, "park") === true || bool3(args, "park") === true - }; - } - return layoutRecipeUnavailable(action); -} - -// src/netx/tools.ts function packageVersion() { try { const here = dirname5(fileURLToPath2(import.meta.url)); @@ -3181,65 +1812,89 @@ function packageVersion() { return "0"; } } -var MUTATING_TOOLS = new Set([ - "netx__execManagedNe", - "netx__createTopologyFolder", - "netx__addTopologyViewNodes", - "netx__removeTopologyViewNodes", - "netx__copyTopologyViewNodes", - "netx__updateTopologyViewPositions", - "netx__projectTopologyNeighbors", - "netx__classifyTopologyFabricNodes", - "netx__layoutTopologyView" -]); -var str4 = (description) => ({ type: "string", ...description ? { description } : {} }); -var num4 = (description) => ({ type: "number", ...description ? { description } : {} }); -var bool4 = (description) => ({ type: "boolean", ...description ? { description } : {} }); -var strArr = (description) => ({ - type: "array", - items: { type: "string" }, - ...description ? { description } : {} -}); -var anyObj = (description) => ({ - type: "object", - additionalProperties: true, - ...description ? { description } : {} -}); -function renderJson2(_args, value) { - return [{ type: "text", text: JSON.stringify(value, null, 0) }]; +function renderMcpResult(_args, value) { + if (value && typeof value === "object" && !Array.isArray(value)) { + const row = value; + if (typeof row.text === "string" && row.text.length > 0) { + return [{ type: "text", text: row.text }]; + } + if (Array.isArray(row.content)) { + return [{ type: "text", text: mcpContentText(row.content, JSON.stringify(value)) }]; + } + } + return [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value ?? null) }]; } -var jsonOut2 = { +var mcpOut = { schema: { type: "object", additionalProperties: true }, - render: renderJson2 + render: renderMcpResult }; -function tool2(name, description, parameters, handler, getClient, timeoutMs) { +function parametersFromInputSchema(schema) { + if (schema && typeof schema === "object" && !Array.isArray(schema)) { + const props = schema.properties; + if (props && typeof props === "object" && !Array.isArray(props)) { + return props; + } + if (schema.type === undefined && schema.properties === undefined) { + return schema; + } + } + return {}; +} +function defineRemoteTool(tool2, timeoutMs) { + const name = publicToolName(tool2.name); return defineTool2({ name, - description, - parameters, - output: jsonOut2, + description: tool2.description || name, + parameters: parametersFromInputSchema(tool2.inputSchema), + output: mcpOut, timeoutMs, - isConcurrencySafe: () => !MUTATING_TOOLS.has(name), + isConcurrencySafe: () => tool2.readOnlyHint === true, async execute(args, exec) { - const result = await handler(getClient(), args, exec.signal); - 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 + }, tool2.name, typeof args === "object" && args !== null ? args : {}); + const text = mcpContentText(result.content, ""); + if (result.isError === true) { + throw new Error(text || `Tool "${tool2.name}" failed`); } - return safe; + return JSON.parse(JSON.stringify({ + content: result.content, + ...result.structuredContent !== undefined ? { structuredContent: result.structuredContent } : {}, + ...text ? { text } : {} + })); } }); } function registerNetxTools(ctx, connection, options) { - 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((tool2) => tool2.group).filter(Boolean))]; + if (enabled.length === 0) { publishToolMountStatus({ id, version: packageVersion(), plane: options.plane, - groups: [...enabledGroups], + groups, names: [], count: 0, at: new Date().toISOString(), @@ -3250,392 +1905,40 @@ function registerNetxTools(ctx, connection, options) { }; } const t = typeof connection.toolCallTimeoutMs === "number" && Number.isFinite(connection.toolCallTimeoutMs) && connection.toolCallTimeoutMs > 0 ? connection.toolCallTimeoutMs : 120000; - const client = createNetxClient({ - apiUrl: connection.apiUrl, - lang: connection.lang, - timeoutMs: Math.min(t, 45000), - getToken: () => { - const live = getNetxConnection(); - return live ? live.token.trim() : connection.token.trim(); - }, - getApiUrl: () => getNetxConnection()?.apiUrl ?? connection.apiUrl - }); - const getClient = () => client; - const catalog = [ - tool2("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: str4(), - ne_id: str4("Filter only; do not show UUID to users"), - host_name: str4("Filter by NE host_name"), - ne_name: str4("Legacy alias mapped to keyword"), - keyword: str4("Substring on cause/object/event. Examples: LOS, Fiber Break, bandwidth, CRC."), - time_from: str4("ISO time; filters last_seen_at >="), - time_to: str4("ISO time; filters last_seen_at <="), - page: num4(), - page_size: num4() - }, queryUmeAlarms, getClient, t), - tool2("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: str4("Optional perceived_severity filter (critical/major/minor/warning)."), - top_ne: num4("Max NE buckets (default 50). Ignored when group_by is set."), - exclude_missing_host: bool4("Omit missing host_name from by_ne."), - time_from: str4(), - time_to: str4(), - group_by: str4("When set, routes to raw aggregation. Prefer alarm_host_name."), - group_by2: str4(), - is_cleared: str4(), - ne_id: str4(), - event_type: str4(), - keyword: str4(), - limit: num4() - }, aggregateUmeAlarms, getClient, t), - tool2("netx__runNmsDiagnostics", "NMS alarm diagnostics: severity, top_event_types, top_alarm_codes, top_ne, protocol buckets, freshness meta.", {}, runUmeDiagnostics, getClient, t), - tool2("netx__queryNmsNeInventory", "Paged NMS NE inventory synced in netx (keyword matches ne_id/ne_name/user_label/ip/host_name).", { - keyword: str4(), - page: num4(), - page_size: num4() - }, queryUmeNeInventory, getClient, t), - tool2("netx__getNmsNe", "Get single NMS NE detail by ne_id (UUID).", { - ne_id: { type: "string", required: true, description: "NMS inventory ne_id (UUID)." } - }, getUmeNe, getClient, t), - tool2("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: str4(), - is_cleared: str4(), - ne_id: str4(), - event_type: str4(), - keyword: str4(), - time_from: str4(), - time_to: str4(), - order_by: str4("last_seen_at | time_created | perceived_severity | event_type | ne_id"), - order: str4("asc | desc"), - select_fields: strArr(), - field_preset: str4("brief | evidence | ne_debug"), - page: num4(), - page_size: num4() - }, queryUmeAlarmsRaw, getClient, t), - tool2("netx__aggregateNmsAlarmsRaw", "Dynamic aggregation on NMS raw fields (group_by/group_by2); prefer alarm_host_name.", { - group_by: { type: "string", required: true }, - group_by2: str4(), - severity: str4(), - is_cleared: str4(), - ne_id: str4(), - event_type: str4(), - keyword: str4(), - time_from: str4(), - time_to: str4(), - exclude_missing_host: bool4(), - limit: num4() - }, aggregateUmeAlarmsRaw, getClient, t), - tool2("netx__listNmsAlarmFields", "List available fields for NMS raw alarm queries.", {}, listUmeAlarmFields, getClient, t), - tool2("netx__sqlQueryNms", "Read-only SELECT on NMS tables (ume_alarms_current/ume_inventory_ne); server enforces limits. Requires sql:query scope.", { - sql: { type: "string", required: true }, - limit: num4(), - statement_timeout_ms: num4() - }, sqlQueryUme, getClient, t), - tool2("netx__listManagedNe", "List filtered netx managed NEs (keyword/vendor/connect_status required); use before execManagedNe.", { - keyword: str4(), - vendor: str4(), - connect_status: str4("unknown | testing | pass | fail"), - page: num4(), - page_size: num4() - }, listManagedNe, getClient, t), - tool2("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: str4("Managed NE id"), - managed_ne_id: str4("Alias for ne_id"), - id: str4("Alias for ne_id") - }, getManagedNe, getClient, t), - tool2("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: str4(), - nms_ne_id: str4("NMS inventory id; alias ume_ne_id"), - ume_ne_id: str4("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", - description: "Per-NE command sets: each item is one NE (ne_id OR nms_ne_id) with commands[].", - items: { - type: "object", - additionalProperties: false, - properties: { - ne_id: str4(), - nms_ne_id: str4(), - ume_ne_id: str4(), - commands: strArr() - } - } - }, - commands: strArr("Commands for single NE, or shared commands for batch."), - read_timeout_sec: num4("Per-command read timeout (default 60; use 90–120 for slow show)."), - concurrency: num4("Parallel NEs for batch mode (1–8, default 4)."), - async: bool4("true=background job_id + getNeExecJob; false=force sync; omit=auto for ~4+ NEs (NETX_NE_EXEC_ASYNC_MIN_NES).") - }, execManagedNe, getClient, Math.max(t, 300000)), - tool2("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: str4("Job id from execManagedNe async response.") - }, getNeExecJob, getClient, Math.max(t, 60000)), - tool2("netx__listCliTargets", "List CLI-capable targets (managed NE and/or NMS inventory). Call once per session with keyword/source, cache ids, then execManagedNe.", { - source: str4("managed | nms | ume | all"), - keyword: str4(), - page: num4(), - page_size: num4() - }, listCliTargets, getClient, t), - tool2("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: str4("NMS inventory id"), - from_ume_ne_id: str4("Legacy alias of from_nms_ne_id"), - from_managed_ne_id: str4(), - to_nms_ne_id: str4("NMS inventory id"), - to_ume_ne_id: str4("Legacy alias of to_nms_ne_id"), - to_managed_ne_id: str4(), - max_paths: num4(), - max_hops: num4(), - layer: str4(), - detail: str4("summary | full") - }, findTopologyPaths, getClient, t), - tool2("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: str4("all | projects | tasks (default all)"), - purpose: str4("Filter tasks: portrait | cutover_hf | empty=all"), - status: str4("Filter by status (e.g. active / paused)"), - q: str4("Keyword on name / ne_name / note / id"), - limit: num4("Max rows per list (default 100, max 500)") - }, listBizMonitors, getClient, t), - tool2("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: str4("auto | all | cutover | collect | runs (default auto)"), - project_id: str4("Cutover project id → cutover_batches"), - task_id: str4("biz_state task id → collect_batches"), - batch_id: str4("Cutover batch id (filter or kind=runs)"), - include_runs: bool4("Attach recent evaluate runs onto listed cutover batches"), - status: str4("Filter batch status"), - limit: num4("Max batches (default 50)"), - runs_limit: num4("Max runs per batch when include_runs / kind=runs (default 10)") - }, listBizMonitorBatches, getClient, t), - tool2("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: str4("Cutover / biz-migration project id"), - task_id: str4("biz_state task id (optional if project_id set)") - }, getBizMonitorContext, getClient, t), - tool2("netx__getBizMonitorBoard", "Cutover batch board: project/batch status, evaluate run summary, sheet cards, verdict counts, metric_batches.", { - batch_id: { type: "string", required: true }, - run_id: str4("Optional evaluate run id") - }, getBizMonitorBoard, getClient, t), - tool2("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", required: true }, - status: str4("open|carried|resolved or empty=all"), - limit: num4() - }, listBizMonitorReds, getClient, t), - tool2("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", required: true }, - metric_id: str4(), - sheet_id: str4(), - verdict: str4(), - color: str4("e.g. red"), - q: str4(), - limit: num4(), - offset: num4() - }, getBizMonitorDiffs, getClient, t), - tool2("netx__getBizCollectBatch", "One biz_state collect batch: commands (raw_command, parse_status, preview), parsed metrics.", { - batch_id: { type: "string", required: true } - }, getBizCollectBatch, getClient, t), - tool2("netx__getBizCollectCommandRaw", "Full CLI raw_text for one collect command inside a batch (deep dive).", { - batch_id: { type: "string", required: true }, - command_id: { type: "string", required: true } - }, getBizCollectCommandRaw, getClient, t), - tool2("netx__getTopologyTree", "Get topology folder tree (nav roots + Root map canvases). Start here before createTopologyFolder.", { compact: bool4(), max_depth: num4() }, getTopologyTree, getClient, t), - tool2("netx__getTopologyView", "Get a topology view by view_id. Default detail=summary with sample_nodes + links[].", { - view_id: { type: "string", required: true }, - detail: str4("summary | full"), - sample: num4() - }, getTopologyView, getClient, t), - tool2("netx__createTopologyFolder", "Create topology folders / region canvases. Only way to create canvases (ne:write).", { - name: { type: "string", required: true }, - parent_id: str4(), - locale: str4("zh | en"), - sort_order: num4() - }, createTopologyFolder, getClient, t), - tool2("netx__addTopologyViewNodes", "Bulk-place fabric nodes on a view via filters or fabric_node_ids (never managed/UME ids).", { - view_id: { type: "string", required: true }, - max_nodes: num4(), - keyword: str4(), - role: str4(), - vendor: str4(), - link_status: str4(), - limit: num4(), - offset: num4(), - fabric_node_ids: strArr(), - layout: str4("grid | keep") - }, addTopologyViewNodes, getClient, t), - tool2("netx__removeTopologyViewNodes", "Remove placements from a view (not fabric) by filters or fabric_node_ids.", { - view_id: { type: "string", required: true }, - keyword: str4(), - role: str4(), - vendor: str4(), - link_status: str4(), - fabric_node_ids: strArr() - }, removeTopologyViewNodes, getClient, t), - tool2("netx__copyTopologyViewNodes", "Clone fabric placements from source_view_id onto target_view_id (optional clear_target / copy_positions).", { - source_view_id: { type: "string", required: true }, - target_view_id: { type: "string", required: true }, - copy_positions: bool4(), - clear_target: bool4(), - offset_x: num4(), - offset_y: num4(), - limit: num4(), - dry_run: bool4() - }, copyTopologyViewNodes, getClient, t), - tool2("netx__updateTopologyViewPositions", "Move nodes on a view via positions[] or layout=grid|offset|stack + filters.", { - view_id: { type: "string", required: true }, - positions: { - type: "array", - description: "Manual placements: [{ fabric_node_id, x, y }, …]", - items: { - type: "object", - additionalProperties: false, - properties: { - fabric_node_id: str4(), - x: num4(), - y: num4() - } - } - }, - layout: str4("grid | offset | stack"), - keyword: str4(), - role: str4(), - vendor: str4(), - link_status: str4(), - fabric_node_ids: strArr(), - offset_x: num4(), - offset_y: num4() - }, updateTopologyViewPositions, getClient, t), - tool2("netx__projectTopologyNeighbors", "Project existing fabric neighbors of on-view nodes onto the canvas.", { - view_id: { type: "string", required: true }, - seed_fabric_node_ids: strArr(), - managed_ne_ids: strArr(), - region_folder_id: str4(), - detail: str4("summary | full"), - sample: num4() - }, projectTopologyNeighbors, getClient, t), - tool2("netx__queryTopologyFabricNodes", "Fabric inventory: mode=summary|list|search (keyword/role/level/region/link_status).", { - mode: str4("summary | list | search"), - q: str4(), - keyword: str4(), - role: str4(), - level: str4(), - level_major: str4(), - region_folder_id: str4(), - link_status: str4(), - page: num4(), - page_size: num4(), - limit: num4(), - summary: bool4() - }, queryTopologyFabricNodes, getClient, t), - tool2("netx__classifyTopologyFabricNodes", "Classify fabric nodes: action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules.", { - action: { type: "string", required: true }, - pattern: str4(), - q: str4(), - match_field: str4(), - sample_limit: num4(), - fabric_node_ids: strArr(), - fabric_node_id: str4(), - node_id: str4(), - level: str4(), - role: str4(), - region_folder_id: str4(), - clear_region: bool4(), - dry_run: bool4(), - kind: str4(), - page: num4(), - page_size: num4(), - skip_manual: bool4(), - overwrite_manual: bool4(), - fill_empty_only: bool4() - }, classifyTopologyFabricNodes, getClient, t), - tool2("netx__queryTopologyNeighborhood", "Neighborhood around a fabric node (depth 1–3) with compact links[].", { - node_id: { type: "string", required: true }, - depth: num4(), - layer: str4() - }, queryTopologyNeighborhood, getClient, t), - tool2("netx__queryTopologyEdges", "Fabric adjacency links[] (detail=adjacency) or port rows (detail=ports).", { - node_id: str4(), - keyword: str4(), - layer: str4(), - status: str4(), - source: str4("lldp | ume | manual"), - detail: str4("adjacency | ports"), - page: num4(), - page_size: num4() - }, queryTopologyEdges, getClient, t), - tool2("netx__suggestSinkHubs", "Rank hub territories on a source view for non-dual move_nodes(park) batches (degree stand-in in DSH).", { - source_view_id: str4(), - view_id: str4(), - pick: num4(), - exclude_portal_ids: strArr(), - exclude_fabric_node_ids: strArr() - }, suggestSinkHubs, getClient, t), - tool2("netx__analyzeTopologyViewLayout", "Basic layout QA for a view (bbox/hubs). Full dual_unit/crossing scores need netx-topology MCP.", { - view_id: str4(), - folder_id: str4(), - detail: str4("summary | structure | hotspots | blocks | both"), - score_profile: str4(), - sight_limit: num4(), - max_views: num4(), - min_nodes: num4(), - max_nodes: num4(), - with_meta: bool4() - }, analyzeTopologyViewLayout, getClient, t), - tool2("netx__sinkTopologyDualUnits", "Drain dual_unit eyes from source to sink (requires netx-topology MCP layout engine in DSH).", { - source_view_id: str4(), - sink_view_id: str4(), - max_units: num4(), - min_nodes: num4(), - max_nodes: num4(), - max_batch_nodes: num4(), - layout_batch: bool4(), - until_empty: bool4(), - dry_run: bool4() - }, sinkTopologyDualUnits, getClient, t), - tool2("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: str4(), - action: str4(), - source_view_id: str4(), - recipe: str4(), - preset: str4(), - mode: str4("preview | apply"), - tune: bool4(), - params: anyObj("Action-specific options (move_nodes / recipes / polish)."), - catalog: bool4(), - fabric_node_ids: strArr(), - park: bool4(), - copy_positions: bool4() - }, layoutTopologyView, getClient, Math.max(t, 180000)) - ]; - const selected = catalog.filter((entry) => allow.has(entry.name)); - const names = selected.map((entry) => entry.name); const disposers = []; + const names = []; try { - for (const entry of selected) - disposers.push(ctx.tools.register(entry)); - publishToolMountStatus({ - id, - version: packageVersion(), - plane: options.plane, - groups: [...enabledGroups], - names, - count: names.length, - at: new Date().toISOString(), - source: options.source ?? options.plane - }); + for (const tool2 of enabled) { + const definition = defineRemoteTool(tool2, t); + disposers.push(ctx.tools.register(definition)); + names.push(publicToolName(tool2.name)); + } } catch (error) { - for (const dispose of disposers.reverse()) + for (const dispose of disposers.splice(0)) dispose(); publishToolMountStatus({ id, version: packageVersion(), plane: options.plane, - groups: [...enabledGroups], + groups, names: [], count: 0, at: new Date().toISOString(), - error: error instanceof Error ? error.message : String(error), + error: String(error), source: options.source ?? options.plane }); throw error; } + publishToolMountStatus({ + id, + version: packageVersion(), + plane: options.plane, + groups, + names, + count: names.length, + at: new Date().toISOString(), + source: options.source ?? options.plane + }); return () => { for (const dispose of disposers) dispose(); @@ -3653,6 +1956,16 @@ function packageVersion2() { return "0"; } } +function disabledByGroupFor(groupIds, connection) { + const catalog = getMcpCatalogTools(); + const out = {}; + for (const groupId of groupIds) { + const names = disabledPublicNames(catalog, [groupId], connection.disabledTools, connection.readOnlyOnly); + if (names.length > 0) + out[groupId] = names; + } + return out; +} function applyGroupToolsPlugin(ctx, options) { let unregisterTools; let unregisterKbLocal; @@ -3678,9 +1991,10 @@ function applyGroupToolsPlugin(ctx, options) { unregisterTools = registerNetxTools(ctx, connection, { plane: "preset", forceGroups: enabled, - source: options.name + source: options.name, + presetId: presetId() }); - ctx.logger.info("%s: groups=[%s] → %s tokenConfigured=%s live=%s", options.name, enabled.join(",") || "(none)", connection.apiUrl, connection.token.trim().length > 0, getNetxConnection() !== undefined); + ctx.logger.info("%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({ id: mountId, @@ -3708,6 +2022,9 @@ function applyGroupToolsPlugin(ctx, options) { remountTools(); remountKbLocal(); }); + const stopCatalogWatch = watchMcpCatalog(() => { + remountTools(); + }); const stopKbToolWatch = watchKbContext(() => { remountKbLocal(); }); @@ -3737,8 +2054,13 @@ function applyGroupToolsPlugin(ctx, options) { skillAbort = new AbortController; unregisterSkills?.(); unregisterSkills = undefined; + const connection = requireNetxConnection(); const enabled = resolveGroups(); - registerGroupSkills(skillsCtx, enabled, options.name, skillAbort.signal).then((dispose) => { + registerGroupSkills(skillsCtx, enabled, options.name, { + signal: skillAbort.signal, + disabledByGroup: disabledByGroupFor(enabled, connection), + lang: connection.lang + }).then((dispose) => { if (gen !== skillGeneration) { dispose(); return; @@ -3783,6 +2105,9 @@ function applyGroupToolsPlugin(ctx, options) { remountKbSkill(); remountKbPack(); }); + const stopSkillCatalogWatch = watchMcpCatalog(() => { + remountSkills(); + }); const stopKbWatch = watchKbContext(() => { remountKbSkill(); remountKbPack(); @@ -3796,6 +2121,7 @@ function applyGroupToolsPlugin(ctx, options) { skillAbort.abort(); packAbort.abort(); stopSkillWatch(); + stopSkillCatalogWatch(); stopKbWatch(); unregisterSkills?.(); unregisterSkills = undefined; @@ -3808,6 +2134,7 @@ function applyGroupToolsPlugin(ctx, options) { ctx.effect(() => () => { clearInterval(pollTimer); stopToolWatch(); + stopCatalogWatch(); stopKbToolWatch(); unregisterTools?.(); unregisterTools = undefined; diff --git a/lib/client.js b/lib/client.js index 2fe685b..64d8086 100644 --- a/lib/client.js +++ b/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.jsx(StatusBadge, { - tone: toolsMountTone(state.toolsStatus), - label: toolsBadgeLabel(t, state.toolsStatus) + /* @__PURE__ */ jsx_runtime.jsxs("span", { + className: "dsh-nx-badges", + children: [ + /* @__PURE__ */ jsx_runtime.jsx(StatusBadge, { + tone: toolsMountTone(state.toolsStatus), + label: toolsBadgeLabel(t, state.toolsStatus) + }), + /* @__PURE__ */ jsx_runtime.jsx("button", { + type: "button", + className: "dsh-nx-btn", + disabled: disabled || state.mcpRefreshBusy, + onClick: () => { + props.refreshMcpCatalog(); + }, + children: state.mcpRefreshBusy ? t("mcpRefreshing") : t("mcpRefresh") + }) + ] }) ] }), @@ -1696,60 +1775,104 @@ function NetxopsCard(props) { onReset: () => { props.resetField("toolsEnabled"); }, - error: state.toolsStatus?.error ? /* @__PURE__ */ jsx_runtime.jsx("p", { + error: state.toolsStatus?.error || state.toolsStatus?.mcpError || state.mcpCatalog?.error ? /* @__PURE__ */ jsx_runtime.jsxs("p", { className: "dsh-nx-invalid", role: "status", - children: state.toolsStatus.error + children: [ + state.toolsStatus?.error || state.toolsStatus?.mcpError || state.mcpCatalog?.error, + state.toolsStatus?.mcpFromCache || state.mcpCatalog?.fromCache ? ` · ${t("mcpDisconnected")}` : "" + ] }) : null }), - /* @__PURE__ */ jsx_runtime.jsx(CapabilityGroupBlock, { - title: t("groupOps"), - toolCount: TOOLS_BY_GROUP.ops.length, - targets: targets("groupOpsPresets", state.groupOpsPresets), - inPresetLabel: t("groupInPreset"), - publicLabel: t("groupPublic"), - inPreset: state.groupOpsInPreset, - published: state.groupOpsPublic, + /* @__PURE__ */ jsx_runtime.jsx(ToggleField, { + id: "netxops-readonly-only", + label: t("readOnlyOnly"), + checked: state.readOnlyOnly.text === "true", disabled: publicationDisabled, - onEditInPreset: (checked) => { - props.edit("groupOpsInPreset", checked ? "true" : "false"); + overridden: state.readOnlyOnly.overridden, + resetLabel: t("reset"), + onToggle: (checked) => { + props.edit("readOnlyOnly", String(checked)); }, - onEditPublic: (checked) => { - props.edit("groupOpsPublic", checked ? "true" : "false"); - } - }), - /* @__PURE__ */ jsx_runtime.jsx(CapabilityGroupBlock, { - title: t("groupTopology"), - toolCount: TOOLS_BY_GROUP.topology.length, - targets: targets("groupTopologyPresets", state.groupTopologyPresets), - inPresetLabel: t("groupInPreset"), - publicLabel: t("groupPublic"), - inPreset: state.groupTopologyInPreset, - published: state.groupTopologyPublic, - disabled: publicationDisabled, - onEditInPreset: (checked) => { - props.edit("groupTopologyInPreset", checked ? "true" : "false"); - }, - onEditPublic: (checked) => { - props.edit("groupTopologyPublic", checked ? "true" : "false"); - } - }), - /* @__PURE__ */ jsx_runtime.jsx(CapabilityGroupBlock, { - title: t("groupBizMonitor"), - toolCount: TOOLS_BY_GROUP.bizMonitor.length, - targets: targets("groupBizMonitorPresets", state.groupBizMonitorPresets), - inPresetLabel: t("groupInPreset"), - publicLabel: t("groupPublic"), - inPreset: state.groupBizMonitorInPreset, - published: state.groupBizMonitorPublic, - disabled: publicationDisabled, - onEditInPreset: (checked) => { - props.edit("groupBizMonitorInPreset", checked ? "true" : "false"); - }, - onEditPublic: (checked) => { - props.edit("groupBizMonitorPublic", checked ? "true" : "false"); + onReset: () => { + props.resetField("readOnlyOnly"); } }), + (() => { + const catalogTools = state.mcpCatalog?.tools ?? []; + const byGroup = { + ops: catalogTools.filter((tool) => tool.group === "ops"), + topology: catalogTools.filter((tool) => tool.group === "topology"), + bizMonitor: catalogTools.filter((tool) => tool.group === "bizMonitor") + }; + const disabledSet = new Set(parseDisabledTools(state.disabledTools.text)); + const toolProps = { + disabledTools: disabledSet, + writeBadge: t("toolWriteBadge"), + emptyLabel: t("toolListEmpty"), + onToggleTool: (name, enabled) => { + props.setToolEnabled(name, enabled); + } + }; + return /* @__PURE__ */ jsx_runtime.jsxs(jsx_runtime.Fragment, { + children: [ + /* @__PURE__ */ jsx_runtime.jsx(CapabilityGroupBlock, { + title: t("groupOps"), + toolCount: byGroup.ops.length, + tools: byGroup.ops, + targets: targets("groupOpsPresets", state.groupOpsPresets), + inPresetLabel: t("groupInPreset"), + publicLabel: t("groupPublic"), + inPreset: state.groupOpsInPreset, + published: state.groupOpsPublic, + disabled: publicationDisabled, + onEditInPreset: (checked) => { + props.edit("groupOpsInPreset", checked ? "true" : "false"); + }, + onEditPublic: (checked) => { + props.edit("groupOpsPublic", checked ? "true" : "false"); + }, + ...toolProps + }), + /* @__PURE__ */ jsx_runtime.jsx(CapabilityGroupBlock, { + title: t("groupTopology"), + toolCount: byGroup.topology.length, + tools: byGroup.topology, + targets: targets("groupTopologyPresets", state.groupTopologyPresets), + inPresetLabel: t("groupInPreset"), + publicLabel: t("groupPublic"), + inPreset: state.groupTopologyInPreset, + published: state.groupTopologyPublic, + disabled: publicationDisabled, + onEditInPreset: (checked) => { + props.edit("groupTopologyInPreset", checked ? "true" : "false"); + }, + onEditPublic: (checked) => { + props.edit("groupTopologyPublic", checked ? "true" : "false"); + }, + ...toolProps + }), + /* @__PURE__ */ jsx_runtime.jsx(CapabilityGroupBlock, { + title: t("groupBizMonitor"), + toolCount: byGroup.bizMonitor.length, + tools: byGroup.bizMonitor, + targets: targets("groupBizMonitorPresets", state.groupBizMonitorPresets), + inPresetLabel: t("groupInPreset"), + publicLabel: t("groupPublic"), + inPreset: state.groupBizMonitorInPreset, + published: state.groupBizMonitorPublic, + disabled: publicationDisabled, + onEditInPreset: (checked) => { + props.edit("groupBizMonitorInPreset", checked ? "true" : "false"); + }, + onEditPublic: (checked) => { + props.edit("groupBizMonitorPublic", checked ? "true" : "false"); + }, + ...toolProps + }) + ] + }); + })(), state.toolsStatus && state.toolsStatus.mounts.length > 0 ? /* @__PURE__ */ jsx_runtime.jsxs("details", { className: "dsh-nx-diagnostics", children: [ @@ -2097,6 +2220,22 @@ function apiUrlField(field) { } }; } +function booleanField(field) { + return { + field, + format: (value) => value === true ? "true" : "false", + parse: (text) => { + const normalized = text.trim().toLowerCase(); + if (normalized === "true" || normalized === "1" || normalized === "yes") { + return { kind: "set", value: true }; + } + if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "") { + return { kind: "clear" }; + } + return; + } + }; +} function booleanFieldPersistFalse(field) { return { field, @@ -2306,6 +2445,68 @@ class CardForm { } } +// src/client/mcp-catalog-view.ts +var TOOLS_CATALOG_ENDPOINT = "tools.catalog"; +var TOOLS_REFRESH_ENDPOINT = "tools.refresh"; +var EMPTY3 = { + phase: "idle", + mcpUrl: "", + error: "", + listedAt: null, + fromCache: false, + tools: [] +}; +function asMcpCatalogView(value) { + if (value === null || typeof value !== "object" || Array.isArray(value)) + return { ...EMPTY3 }; + const row = value; + const tools = []; + if (Array.isArray(row.tools)) { + for (const raw of row.tools) { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) + continue; + const t = raw; + const name = typeof t.name === "string" ? t.name : ""; + if (!name) + continue; + const group = t.group === "ops" || t.group === "topology" || t.group === "bizMonitor" ? t.group : null; + tools.push({ + name, + description: typeof t.description === "string" ? t.description : "", + readOnlyHint: t.readOnlyHint === true, + group + }); + } + } + return { + phase: typeof row.phase === "string" ? row.phase : "idle", + mcpUrl: typeof row.mcpUrl === "string" ? row.mcpUrl : "", + error: typeof row.error === "string" ? row.error : "", + listedAt: typeof row.listedAt === "string" ? row.listedAt : null, + fromCache: row.fromCache === true, + tools + }; +} +async function fetchMcpCatalog(call, signal) { + const result = await call(NETXOPS_RPC_CHANNEL3, TOOLS_CATALOG_ENDPOINT, {}, signal); + if (result !== null && typeof result === "object" && result.ok === true) { + return asMcpCatalogView(result.value); + } + return { ...EMPTY3 }; +} +async function refreshMcpCatalogRemote(call, signal) { + const result = await call(NETXOPS_RPC_CHANNEL3, TOOLS_REFRESH_ENDPOINT, {}, signal); + if (result !== null && typeof result === "object") { + const row = result; + const view = asMcpCatalogView(row.value); + if (typeof row.error === "string" && row.error && !view.error) { + return { ...view, error: row.error, phase: view.phase === "connected" ? view.phase : "error" }; + } + return view; + } + return { ...EMPTY3, phase: "error", error: "refresh_failed" }; +} + // src/client/controller.ts var NETXOPS_NS = "netxops"; var NETXOPS_SECTION_ID = "dsh-netxops"; @@ -2328,6 +2529,9 @@ class NetxopsCardController { alarmPushStatus = null; toolsStatus = null; toolsStatusInFlight = false; + mcpCatalog = null; + mcpCatalogInFlight = false; + mcpRefreshBusy = false; presetCatalog = []; presetCatalogError = false; presetCatalogInFlight = false; @@ -2375,6 +2579,8 @@ class NetxopsCardController { booleanFieldPersistFalse("alarmPushEnabled"), booleanFieldPersistFalse("alarmDeliverDsh"), booleanFieldPersistFalse("alarmDeliverIm"), + booleanField("readOnlyOnly"), + textField("disabledTools"), textField("imTargets"), textField("imBotId"), textField("imTargetId") @@ -2412,6 +2618,10 @@ class NetxopsCardController { this.toolsStatus = null; changed = true; } + if (this.mcpCatalog !== null) { + this.mcpCatalog = null; + changed = true; + } this.presetCatalog = []; this.presetCatalogError = true; changed = true; @@ -2435,6 +2645,7 @@ class NetxopsCardController { this.startStatusPoll(); this.refreshAlarmPushStatus(); this.refreshToolsStatus(); + this.refreshMcpCatalogStatus(); this.refreshPresetCatalog(); this.reloadKbFromHost(); this.refreshKbPreview(); @@ -2455,6 +2666,7 @@ class NetxopsCardController { this.pollTimer = setInterval(() => { this.refreshAlarmPushStatus(); this.refreshToolsStatus(); + this.refreshMcpCatalogStatus(); this.refreshPresetCatalog(); this.refreshKbStatus(); this.refreshImDeliveryCatalog(); @@ -2499,6 +2711,50 @@ class NetxopsCardController { this.toolsStatusInFlight = false; } } + async refreshMcpCatalogStatus() { + const call = this.rpcCall; + if (call === undefined || this.mcpCatalogInFlight) + return; + this.mcpCatalogInFlight = true; + try { + const next = await fetchMcpCatalog(call); + const prev = this.mcpCatalog; + if (prev && JSON.stringify(prev) === JSON.stringify(next)) + return; + this.mcpCatalog = next; + this.store.set(this.projection()); + } catch {} finally { + this.mcpCatalogInFlight = false; + } + } + async runMcpRefresh() { + const call = this.rpcCall; + if (call === undefined || this.mcpRefreshBusy) + return; + this.mcpRefreshBusy = true; + this.store.set(this.projection()); + try { + const next = await refreshMcpCatalogRemote(call); + this.mcpCatalog = next; + await this.refreshToolsStatus(); + } catch {} finally { + this.mcpRefreshBusy = false; + this.store.set(this.projection()); + } + } + setToolEnabled(serverName, enabled) { + const name = serverName.trim(); + if (!name) + return; + const current = new Set(parseDisabledTools(this.form.field("disabledTools").text)); + if (enabled) + current.delete(name); + else + current.add(name); + const next = serializeDisabledTools([...current]); + this.form.actions().edit("disabledTools", next); + this.form.save(["disabledTools"]); + } async refreshPresetCatalog() { const call = this.rpcCall; if (!call || this.presetCatalogInFlight) @@ -2759,6 +3015,10 @@ class NetxopsCardController { apiTokenRemoteReady: this.credential.remoteReady, alarmPushStatus: this.alarmPushStatus, toolsStatus: this.toolsStatus, + mcpCatalog: this.mcpCatalog, + mcpRefreshBusy: this.mcpRefreshBusy, + disabledTools: this.form.field("disabledTools"), + readOnlyOnly: this.form.field("readOnlyOnly"), kbStatus: this.kbPreview ?? this.kbStatus, kbDirectoryPickerReady: this.kbDirectoryPicker !== undefined || this.rpcCall !== undefined, kbUiError: this.kbUiError, @@ -2826,7 +3086,9 @@ class NetxopsCardController { "groupBizMonitorInPreset", "groupBizMonitorPublic", "groupKbInPreset", - "groupKbPublic" + "groupKbPublic", + "disabledTools", + "readOnlyOnly" ]); return { hooks: { netxopsCard: this.store }, @@ -2854,6 +3116,12 @@ class NetxopsCardController { }, browseKbRoot: () => { this.browseKbRoot(); + }, + refreshMcpCatalog: () => { + this.runMcpRefresh(); + }, + setToolEnabled: (serverName, enabled) => { + this.setToolEnabled(serverName, enabled); } }; } @@ -2969,6 +3237,12 @@ var en = { toolsStatusEmpty: "No active tools", toolsStatusError: "Publication failed", toolsStatusUnknown: "Status unavailable", + mcpDisconnected: "Disconnected", + mcpRefresh: "Refresh", + mcpRefreshing: "Refreshing…", + readOnlyOnly: "Read-only tools only", + toolWriteBadge: "write", + toolListEmpty: "No tools yet", sessionsExportCount: "{count} sessions", sessionsExportUnavailable: "Export unavailable", sessionsExportNoPersistence: "Session store unavailable.", @@ -3056,6 +3330,12 @@ var zh = { toolsStatusEmpty: "暂无活跃工具", toolsStatusError: "发布异常", toolsStatusUnknown: "状态不可用", + mcpDisconnected: "未连接", + mcpRefresh: "刷新", + mcpRefreshing: "刷新中…", + readOnlyOnly: "只允许只读工具", + toolWriteBadge: "写", + toolListEmpty: "暂无工具清单", sessionsExportCount: "{count} 个会话", sessionsExportUnavailable: "无法导出", sessionsExportNoPersistence: "会话存储不可用。", @@ -3196,7 +3476,9 @@ var MEMORY_DEFAULTS = { groupBizMonitorPublic: false, kbRoot: "", groupKbInPreset: true, - groupKbPublic: false + groupKbPublic: false, + disabledTools: "[]", + readOnlyOnly: false }; function apply(ctx) { ctx.effect(() => { diff --git a/lib/index.js b/lib/index.js index 2a71a94..df9648c 100644 --- a/lib/index.js +++ b/lib/index.js @@ -603,7 +603,9 @@ var NETXOPS_HOST_CONFIG_DEFAULTS = Object.freeze({ groupBizMonitorPublic: false, kbRoot: "", groupKbInPreset: true, - groupKbPublic: false + groupKbPublic: false, + disabledTools: "[]", + readOnlyOnly: false }); function mergeHostConfigForHeal(existing) { const out = { ...existing }; @@ -647,125 +649,6 @@ function normalizeApiUrl(raw) { const search = parsed.search && parsed.search !== "?" ? parsed.search : ""; return { ok: true, url: `${parsed.origin}${path}${search}` }; } -var PROTOCOL_KEY_ZH_TO_EN = { - 其他: "Other", - 时钟: "Clock", - "OTN/光": "OTN/Optical", - 电源: "Power" -}; -function asLang(value) { - return typeof value === "string" ? value : ""; -} -function localizePayload(lang, data) { - 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; - const key = typeof rec.key === "string" ? rec.key : ""; - const mapped = PROTOCOL_KEY_ZH_TO_EN[key]; - if (mapped !== undefined) - rec.key = mapped; - } - return data; -} -function encodeQuery(params) { - const sp = new URLSearchParams; - for (const [key, value] of Object.entries(params)) { - sp.set(key, String(value)); - } - const q = sp.toString(); - return q.length > 0 ? `?${q}` : ""; -} -function createNetxClient(connection) { - const langParams = () => { - const lang = asLang(connection.lang).trim().toLowerCase(); - if (lang.startsWith("en")) - return { lang: "en" }; - return {}; - }; - async function request(method, path, options = {}) { - 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 = { ...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 = { - accept: "application/json", - authorization: `Bearer ${token}` - }; - if (options.body !== undefined) - headers["content-type"] = "application/json"; - const init = { - 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) : {}; - if (typeof data === "object" && data !== null && !Array.isArray(data)) { - return { ok: true, data: localizePayload(connection.lang, data) }; - } - return { ok: true, data: { raw: data } }; - } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - return { ok: false, error: "netx_request_failed", detail: detail.slice(0, 800) }; - } finally { - clearTimeout(timer); - options.signal?.removeEventListener("abort", onOuterAbort); - } - } - return { - get(path, params, signal, timeoutMs) { - return request("GET", path, { params, signal, timeoutMs }); - }, - post(path, body, signal, timeoutMs) { - return request("POST", path, { body, signal, timeoutMs }); - }, - patch(path, body, signal, timeoutMs) { - return request("PATCH", path, { body, signal, timeoutMs }); - } - }; -} -function quoteNeId(neId) { - return encodeURIComponent(neId.trim()); -} // src/netx/alarm-push-status.ts var STORE_KEY = Symbol.for("dsh-netxops.alarm-push-status"); function emptyStatus(partial = {}) { @@ -1349,52 +1232,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", @@ -1477,14 +1314,6 @@ function groupsForPlane(groups, plane, only) { const allow = new Set(only); return enabled.filter((id) => allow.has(id)); } -function toolNamesForGroups(groupIds) { - const out = new Set; - for (const id of groupIds) { - for (const name of TOOLS_BY_GROUP[id] ?? []) - out.add(name); - } - return out; -} function parsePresetIds(value) { const current = readConfigValue(value); if (typeof current !== "string") @@ -1647,7 +1476,24 @@ async function loadGroupSkills(skillsRoot, groupId) { } return { skills, skipped }; } -async function registerGroupSkills(ctx, groupIds, providerLabel, signal) { +function disabledFootnote(names, lang) { + if (names.length === 0) + return ""; + const list = names.join(", "); + const en = (lang ?? "").trim().toLowerCase().startsWith("en"); + return en ? ` + +## Disabled tools +The following tools are disabled: ${list}. Do not call them. +` : ` + +## 已禁用工具 +以下工具已禁用:${list},不要调用。 +`; +} +async function registerGroupSkills(ctx, groupIds, providerLabel, signalOrOptions) { + const options = signalOrOptions instanceof AbortSignal || signalOrOptions === undefined ? { signal: signalOrOptions } : signalOrOptions; + const signal = options.signal; const skillsApi = ctx.skills; if (!skillsApi || typeof skillsApi.register !== "function") { return () => {}; @@ -1669,15 +1515,17 @@ async function registerGroupSkills(ctx, groupIds, providerLabel, signal) { if (skills.length === 0) { log?.warn?.("%s: no skills loaded for group=%s from %s", providerLabel, groupId, join3(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} + +## DeepSeek Harness REST capabilities +Use only tools present in this session. Dual-unit/orbit/polish recipes require server-side topology tools. Use only tools present in this session. +` : skill.content; disposers.push(skillsApi.register({ name: skill.name, description: skill.description, - content: groupId === "topology" ? `${skill.content} - -## DeepSeek Harness REST capabilities -Use 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. -` : skill.content, + content: `${base}${footnote}`, path: skill.path, resourceBase: { kind: "directory", path: skill.directory }, provider: providerLabel, @@ -2806,14 +2654,6 @@ function toLosslessJson(value) { return { ok: false, error: "tool_result_not_json", detail: detail.slice(0, 400) }; } } -function omitUndefined(record) { - const out = {}; - for (const [key, value] of Object.entries(record)) { - if (value !== undefined) - out[key] = value; - } - return out; -} // src/netx/tool-mount-status.ts import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync4 } from "node:fs"; @@ -3357,6 +3197,8 @@ function provisionalNetxConnection() { lang: typeof process.env.NETX_LANG === "string" && process.env.NETX_LANG.trim() ? process.env.NETX_LANG.trim() : "zh", toolCallTimeoutMs: 120000, groups: DEFAULT_CAPABILITY_GROUPS, + disabledTools: [], + readOnlyOnly: false, groupKbInPreset: true, groupKbPublic: false }; @@ -3375,7 +3217,7 @@ function watchNetxConnection(listener) { // src/netx/session-export.ts import { homedir as homedir4, hostname as osHostname } from "node:os"; -// node_modules/.pnpm/fflate@0.8.3/node_modules/fflate/esm/index.mjs +// ../../netxops/node_modules/.pnpm/fflate@0.8.3/node_modules/fflate/esm/index.mjs import { createRequire as createRequire3 } from "module"; var require2 = createRequire3("/"); var _a; @@ -4647,1439 +4489,368 @@ import { dirname as dirname6, join as join11 } from "node:path"; import { fileURLToPath as fileURLToPath3 } from "node:url"; import { defineTool as defineTool2 } from "@deepseek-ai/dsh-tools"; -// src/netx/handlers.ts -var EXEC_MAX_COMMANDS_CAP = 50; -var EXEC_MAX_COMMANDS_DEFAULT = 5; -function execMaxCommands() { - 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() { - 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) { - if (raw === undefined || raw === null) - return; - if (typeof raw === "boolean") - return raw; - const text = String(raw).trim().toLowerCase(); - if (!text) - return; - if (["1", "true", "yes", "on"].includes(text)) - return true; - if (["0", "false", "no", "off"].includes(text)) - return false; - return; -} -function countExecNeTargets(args) { - let n = 0; - for (const key of ["ne_ids", "nms_ne_ids", "ume_ne_ids"]) { - 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 (str2(args, "ne_id").trim() || nmsOrUme(args, "nms_ne_id", "ume_ne_id")) - return 1; - } - return n; -} -function shouldRunExecAsync(args) { - 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; -} -var UME_RAW_FIELD_PRESETS = { - brief: [ - "alarm_alarm_key", - "alarm_host_name", - "alarm_perceived_severity", - "alarm_event_type", - "alarm_last_seen_at", - "ne_host_name", - "ne_user_label", - "ne_ne_name", - "ne_ip_address", - "ne_exists" - ], - evidence: [ - "alarm_alarm_key", - "alarm_host_name", - "alarm_object_name", - "alarm_event_type", - "alarm_native_probable_cause", - "alarm_perceived_severity", - "alarm_is_cleared", - "alarm_time_created", - "alarm_last_seen_at", - "ne_host_name", - "ne_user_label", - "ne_ne_name", - "ne_ip_address", - "ne_connection_status", - "ne_exists" - ], - ne_debug: [ - "alarm_alarm_key", - "alarm_ne_id", - "alarm_perceived_severity", - "alarm_last_seen_at", - "ne_user_label", - "ne_ne_name", - "ne_ip_address", - "ne_ipv6_address", - "ne_device_level", - "ne_host_name", - "ne_connection_status", - "ne_admin_status", - "ne_address_type", - "ne_maintain_status", - "ne_exists" - ] -}; -function asRecord(value) { - return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; -} -function str2(args, key, fallback = "") { - const v = args[key]; - if (typeof v === "string") - return v; - if (typeof v === "number" || typeof v === "boolean") - return String(v); - return fallback; -} -function nmsOrUme(args, nmsKey, umeKey) { - return str2(args, nmsKey).trim() || str2(args, umeKey).trim(); -} -function nmsOrUmeList(args, nmsKey, umeKey) { - const primary = strList(args, nmsKey); - return primary.length > 0 ? primary : strList(args, umeKey); -} -function num2(args, key) { - const v = args[key]; - return typeof v === "number" && Number.isFinite(v) ? v : undefined; -} -function bool2(args, key) { - const v = args[key]; - return typeof v === "boolean" ? v : undefined; -} -function strList(args, key) { - const v = args[key]; - if (!Array.isArray(v)) - return []; - return v.map((x2) => String(x2).trim()).filter((x2) => x2.length > 0); -} -function clampInt(value, fallback, min, max) { - const n = value === undefined ? fallback : Math.trunc(value); - return Math.max(min, Math.min(max, n)); -} -function putStr(params, args, keys) { - for (const key of keys) { - const v = str2(args, key).trim(); - if (v) - params[key] = v; - } -} -async function queryUmeAlarms(client, args, signal) { - let page = clampInt(num2(args, "page"), 1, 1, 2); - const pageSize = clampInt(num2(args, "page_size"), 50, 1, 500); - const params = { page, page_size: pageSize }; - putStr(params, args, ["severity", "ne_id", "host_name", "time_from", "time_to"]); - const keyword = str2(args, "keyword").trim(); - const neName = str2(args, "ne_name").trim(); - if (keyword) - params.keyword = keyword; - else if (neName) - params.keyword = neName; - return client.get("/v1/ume/alarms", params, signal); -} -async function aggregateUmeAlarmsRaw(client, args, signal) { - const params = {}; - putStr(params, args, [ - "group_by", - "group_by2", - "severity", - "is_cleared", - "ne_id", - "event_type", - "keyword", - "time_from", - "time_to", - "limit" - ]); - if ("exclude_missing_host" in args) { - const flag = bool2(args, "exclude_missing_host"); - if (flag !== undefined) - params.exclude_missing_host = flag; - } - return client.get("/v1/ume/alarms/aggregate/raw", params, signal); -} -async function aggregateUmeAlarms(client, args, signal) { - if (str2(args, "group_by").trim()) - return aggregateUmeAlarmsRaw(client, args, signal); - const topNe = clampInt(num2(args, "top_ne"), 50, 0, 500); - const params = { top_ne: topNe }; - if ("exclude_missing_host" in args) { - const flag = bool2(args, "exclude_missing_host"); - if (flag !== undefined) - params.exclude_missing_host = flag; - } - putStr(params, args, ["severity", "time_from", "time_to"]); - return client.get("/v1/ume/alarms/aggregate", params, signal); -} -async function runUmeDiagnostics(client, _args, signal) { - return client.get("/v1/ume/diagnostics", undefined, signal); -} -async function queryUmeNeInventory(client, args, signal) { - const params = { - page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num2(args, "page_size"), 50, 1, 500) - }; - putStr(params, args, ["keyword"]); - return client.get("/v1/ume/inventory/ne", params, signal); -} -async function getUmeNe(client, args, signal) { - const neId = str2(args, "ne_id").trim(); - if (!neId) - return { ok: false, error: "ne_id_required", error_code: "ne_id_required" }; - return client.get(`/v1/ume/inventory/ne/${quoteNeId(neId)}`, undefined, signal); -} -async function queryUmeAlarmsRaw(client, args, signal) { - const params = { - page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num2(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 = str2(args, "field_preset").trim().toLowerCase(); - fields = UME_RAW_FIELD_PRESETS[preset] ?? []; - } - if (fields.length > 0) - params.select_fields = fields.join(","); - return client.get("/v1/ume/alarms/raw", params, signal); -} -async function listUmeAlarmFields(client, _args, signal) { - return client.get("/v1/ume/alarms/fields", undefined, signal); -} -async function sqlQueryUme(client, args, signal) { - const sql = str2(args, "sql").trim(); - if (!sql) - return { ok: false, error: "sql_required" }; - const limit = clampInt(num2(args, "limit"), 200, 1, 2000); - const statementTimeoutMs = clampInt(num2(args, "statement_timeout_ms"), 0, 0, 30000); - return client.post("/v1/sql/ume_query", { - sql, - limit, - statement_timeout_ms: statementTimeoutMs - }, signal, 60000); -} -async function listManagedNe(client, args, signal) { - const keyword = str2(args, "keyword").trim(); - const vendor = str2(args, "vendor").trim(); - const connectStatus = str2(args, "connect_status").trim(); - if (!(keyword || vendor || connectStatus)) { - return { ok: false, error: "managed_ne_filter_required", error_code: "managed_ne_filter_required" }; - } - if (keyword && keyword.length < 2) { - return { ok: false, error: "managed_ne_keyword_too_short", error_code: "managed_ne_keyword_too_short" }; - } - const params = { - page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num2(args, "page_size"), 20, 1, 100) - }; - if (keyword) - params.keyword = keyword; - if (vendor) - params.vendor = vendor; - if (connectStatus) - params.connect_status = connectStatus; - return client.get("/v1/managed-ne", params, signal); -} -async function getManagedNe(client, args, signal) { - const neId = (str2(args, "ne_id") || str2(args, "managed_ne_id") || str2(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: "" } - }; - } - const out = await client.get(`/v1/managed-ne/${quoteNeId(neId)}`, undefined, signal); - if (out.ok === false) { - const detail = `${str2(out, "detail")}${str2(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; -} -async function execManagedNe(client, args, signal) { - 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 body2 = {}; - if (Array.isArray(targetsRaw) && targetsRaw.length > 0) { - const cleaned = []; - for (const t of targetsRaw) { - if (typeof t !== "object" || t === null || Array.isArray(t)) - continue; - const row = t; - const item = {}; - const neId2 = str2(row, "ne_id").trim(); - const umeNeId2 = nmsOrUme(row, "nms_ne_id", "ume_ne_id"); - if (neId2) - item.ne_id = neId2; - if (umeNeId2) - item.ume_ne_id = umeNeId2; - const cmds = Array.isArray(row.commands) ? row.commands.map((c) => String(c).trim()).filter((c) => c.length > 0) : []; - if (cmds.length > 0) - item.commands = cmds; - if (Object.keys(item).length > 0) - cleaned.push(item); - } - body2.targets = cleaned; - } - if (neIds.length > 0) - body2.ne_ids = neIds; - if (umeNeIds.length > 0) - body2.ume_ne_ids = umeNeIds; - if (sharedCommands.length > 0) { - if (sharedCommands.length > maxCmds) { - return { ok: false, error: "too_many_commands", error_code: "too_many_commands" }; - } - body2.commands = sharedCommands; - } - body2.read_timeout_sec = clampInt(num2(args, "read_timeout_sec"), 60, 10, 120); - const concurrency = num2(args, "concurrency"); - if (concurrency !== undefined) - body2.concurrency = clampInt(concurrency, 4, 1, 8); - if (wantAsync) { - const out3 = await client.post("/v1/managed-ne/exec-jobs", body2, signal, 60000); - if (out3.ok !== true) - return out3; - const data3 = asRecord(out3.data); - return Object.keys(data3).length > 0 ? data3 : out3; - } - const out2 = await client.post("/v1/managed-ne/exec-batch", body2, signal, 600000); - if (out2.ok !== true) - return out2; - const data2 = asRecord(out2.data); - if (data2.ok === false) { - return { ok: false, data: data2, error: str2(data2, "error", "exec_batch_failed") }; - } - return { ok: true, data: data2 }; - } - const neId = str2(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 = { - commands: sharedCommands, - read_timeout_sec: clampInt(num2(args, "read_timeout_sec"), 60, 10, 120) - }; - if (neId) - body.ne_id = neId; - if (umeNeId) - body.ume_ne_id = umeNeId; - if (wantAsync) { - const out2 = await client.post("/v1/managed-ne/exec-jobs", body, signal, 60000); - if (out2.ok !== true) - return out2; - const data2 = asRecord(out2.data); - return Object.keys(data2).length > 0 ? data2 : out2; - } - const out = await client.post("/v1/managed-ne/exec", body, signal, 300000); - if (out.ok !== true) - return out; - const data = asRecord(out.data); - if (data.ok === false) { - return { ok: false, data, error: str2(data, "error", "exec_failed") }; - } - return { ok: true, data }; -} -async function getNeExecJob(client, args, signal) { - const jobId = str2(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); -} -async function listCliTargets(client, args, signal) { - const params = { - page: clampInt(num2(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt(num2(args, "page_size"), 50, 1, 500) - }; - putStr(params, args, ["source", "keyword"]); - return client.get("/v1/cli/targets", params, signal); -} -async function findTopologyPaths(client, args, signal) { - const fromUid = nmsOrUme(args, "from_nms_ne_id", "from_ume_ne_id"); - const fromMid = str2(args, "from_managed_ne_id").trim(); - const toUid = nmsOrUme(args, "to_nms_ne_id", "to_ume_ne_id"); - const toMid = str2(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 = str2(args, "detail", "summary").trim().toLowerCase() || "summary"; - if (detail !== "summary" && detail !== "full") - detail = "summary"; - const body = { - max_paths: clampInt(num2(args, "max_paths"), 3, 1, 10), - max_hops: clampInt(num2(args, "max_hops"), 6, 1, 12), - layer: str2(args, "layer", "physical").trim() || "physical", - detail - }; - if (fromUid) - body.from_ume_ne_id = fromUid; - else - body.from_managed_ne_id = fromMid; - if (toUid) - body.to_ume_ne_id = toUid; - else - body.to_managed_ne_id = toMid; - return client.post("/v1/topology/fabric/paths", body, signal, 30000); -} -function taskBriefNe(brief) { - 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) { - 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, q) { - 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); -} -async function listBizMonitors(client, args, signal) { - const kindRaw = str2(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 = str2(args, "purpose").trim(); - const status = str2(args, "status").trim().toLowerCase(); - const q = str2(args, "q").trim().toLowerCase(); - const limit = clampInt(num2(args, "limit") ?? 100, 100, 1, 500); - const wantProjects = kind === "all" || kind === "projects"; - const wantTasks = kind === "all" || kind === "tasks"; - const fetches = []; - if (wantProjects) - fetches.push(client.get("/v1/biz-migration/projects", {}, signal)); - else - fetches.push(Promise.resolve({ ok: true, data: { items: [] } })); - if (wantTasks) { - const params = {}; - 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 : []; - const taskItems = Array.isArray(asRecord(taskRes.data).items) ? asRecord(taskRes.data).items : []; - 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) { - 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, taskId = "") { - 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) { - 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 - }); -} -async function listBizMonitorBatches(client, args, signal) { - const projectId = str2(args, "project_id").trim(); - const taskId = str2(args, "task_id").trim(); - const batchId = str2(args, "batch_id").trim(); - const kindRaw = str2(args, "kind", "auto").trim().toLowerCase() || "auto"; - const includeRuns = args.include_runs === true || args.include_runs === "true" || args.include_runs === 1; - const status = str2(args, "status").trim().toLowerCase(); - const limit = clampInt(num2(args, "limit") ?? 50, 50, 1, 200); - const runsLimit = clampInt(num2(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 = []; - let collectBatches = []; - let runs = []; - 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 : []; - 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 : []; - 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 : []; - 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: [] }; - const items = Array.isArray(asRecord(res.data).items) ? asRecord(res.data).items : []; - 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) { - 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)." - }) - }; -} -async function getBizMonitorContext(client, args, signal) { - const projectId = str2(args, "project_id").trim(); - const taskId = str2(args, "task_id").trim(); - if (!projectId && !taskId) { - return { ok: false, error: "project_id_or_task_id_required" }; - } - const params = {}; - if (projectId) - params.project_id = projectId; - if (taskId) - params.task_id = taskId; - return client.get("/v1/biz-migration/monitor-context", params, signal); -} -async function getBizMonitorBoard(client, args, signal) { - const batchId = str2(args, "batch_id").trim(); - if (!batchId) - return { ok: false, error: "batch_id_required" }; - const params = {}; - const runId = str2(args, "run_id").trim(); - if (runId) - params.run_id = runId; - return client.get(`/v1/biz-migration/batches/${encodeURIComponent(batchId)}/board`, params, signal); -} -async function listBizMonitorReds(client, args, signal) { - const projectId = str2(args, "project_id").trim(); - if (!projectId) - return { ok: false, error: "project_id_required" }; - const params = {}; - putStr(params, args, ["status"]); - const limit = num2(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); -} -async function getBizMonitorDiffs(client, args, signal) { - const runId = str2(args, "run_id").trim(); - if (!runId) - return { ok: false, error: "run_id_required" }; - const params = {}; - putStr(params, args, ["metric_id", "sheet_id", "verdict", "color", "q"]); - const limit = num2(args, "limit"); - if (limit !== undefined) - params.limit = clampInt(limit, 200, 1, 500); - const offset = num2(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); -} -async function getBizCollectBatch(client, args, signal) { - const batchId = str2(args, "batch_id").trim(); - if (!batchId) - return { ok: false, error: "batch_id_required" }; - return client.get(`/v1/biz-state/batches/${encodeURIComponent(batchId)}`, {}, signal); -} -async function getBizCollectCommandRaw(client, args, signal) { - const batchId = str2(args, "batch_id").trim(); - const commandId = str2(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); +// src/netx/mcp-types.ts +var NETX_TOOL_PREFIX = "netx__"; +function publicToolName(serverName) { + return `${NETX_TOOL_PREFIX}${serverName}`; } -// src/netx/topology-handlers.ts -function asRecord2(value) { - return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {}; -} -function str3(args, key, fallback = "") { - const v = args[key]; - if (typeof v === "string") - return v; - if (typeof v === "number" || typeof v === "boolean") - return String(v); - return fallback; -} -function num3(args, key) { - const v = args[key]; - return typeof v === "number" && Number.isFinite(v) ? v : undefined; -} -function bool3(args, key) { - const v = args[key]; - return typeof v === "boolean" ? v : undefined; -} -function strList2(args, key) { - const v = args[key]; - if (!Array.isArray(v)) - return []; - return v.map((x2) => String(x2).trim()).filter((x2) => x2.length > 0); -} -function clampInt2(value, fallback, min, max) { - const n = value === undefined ? fallback : Math.trunc(value); - return Math.max(min, Math.min(max, n)); -} -function unwrap(out) { - if (out.ok !== true) - return out; - const data = asRecord2(out.data); - if (Object.keys(data).length === 0) - return { ok: true, data: out.data }; - return { ok: true, ...data, data }; -} -function filterFields(args) { - const out = {}; - for (const key of ["keyword", "role", "vendor", "link_status"]) { - const v = str3(args, key).trim(); - if (v) - out[key] = v; +// src/netx/mcp-catalog.ts +function disabledSet(disabledTools) { + const out = new Set; + for (const name of disabledTools ?? []) { + const trimmed = typeof name === "string" ? name.trim() : ""; + if (trimmed) + out.add(trimmed); } return out; } -function edgeEndpoints(edge) { - const a = str3(edge, "a_node_id") || str3(edge, "a"); - const b = str3(edge, "b_node_id") || str3(edge, "b"); - return [a.trim(), b.trim()]; -} -function collapseEdgesToLinks(edges, includeNames = false) { - const buckets = new Map; - for (const raw of edges) { - if (!raw || typeof raw !== "object" || Array.isArray(raw)) - continue; - const e = raw; - 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}\x00${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 = str3(e, "a_name"); - row.b_name = str3(e, "b_name"); - } else { - row.a_name = str3(e, "b_name"); - row.b_name = str3(e, "a_name"); +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()))]; } - } - buckets.set(key, row); + } catch {} } - return [...buckets.values()]; + return [...new Set(trimmed.split(/[\s,,]+/).filter(Boolean))]; } -function summarizeViewGraph(graph, sample = 20) { - 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 = asRecord2(n); - return { - id: str3(row, "id") || str3(row, "fabric_node_id"), - name: str3(row, "name"), - x: row.x, - y: row.y, - level: row.level, - role: row.role - }; - }); - return { - ok: true, - view_id: str3(graph, "id") || str3(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 filterCatalogTools(catalog, enabledGroups, policy) { + const allowGroups = new Set(enabledGroups); + const disabled = disabledSet(policy.disabledTools); + const readOnlyOnly = policy.readOnlyOnly === true; + const out = []; + for (const tool2 of catalog) { + if (!tool2.group || !allowGroups.has(tool2.group)) + continue; + if (disabled.has(tool2.name)) + continue; + if (readOnlyOnly && tool2.readOnlyHint !== true) + continue; + out.push(tool2); + } + return out; } -function layoutRecipeUnavailable(action) { - 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)." - }; +function resolveEnabledTools(catalog, options) { + 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); } -async function getTopologyTree(client, args, signal) { - const out = unwrap(await client.get("/v1/topology/tree", undefined, signal)); - if (out.ok !== true) - return out; - const compact = bool3(args, "compact") !== false; - const maxDepth = num3(args, "max_depth"); - if (!compact && maxDepth === undefined) - return out; - return { ...out, compact, max_depth: maxDepth ?? null }; +function disabledPublicNames(catalog, enabledGroups, disabledTools, readOnlyOnly) { + const allowGroups = new Set(enabledGroups); + const disabled = disabledSet(disabledTools); + const names = []; + for (const tool2 of catalog) { + if (!tool2.group || !allowGroups.has(tool2.group)) + continue; + const off = disabled.has(tool2.name) || readOnlyOnly === true && tool2.readOnlyHint !== true; + if (off) + names.push(publicToolName(tool2.name)); + } + return names.sort(); } -async function getTopologyView(client, args, signal) { - const viewId = str3(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, 120000)); - if (out.ok !== true) - return out; - const detail = str3(args, "detail", "summary").trim().toLowerCase() || "summary"; - if (detail === "full") - return { ...out, detail: "full" }; - return summarizeViewGraph(out, clampInt2(num3(args, "sample"), 20, 0, 200)); + +// src/netx/mcp-remote.ts +var PROTOCOL_VERSION = "2025-03-26"; +var CLIENT_INFO = { name: "dsh-netxops", version: "0.1.71" }; + +class McpRemoteError extends Error { + status; + constructor(message, status) { + super(message); + this.name = "McpRemoteError"; + this.status = status; + } } -async function createTopologyFolder(client, args, signal) { - const name = str3(args, "name").trim(); +function mcpEndpointUrl(apiUrl) { + const trimmed = apiUrl.trim().replace(/\/$/, ""); + if (!trimmed) + return ""; + return `${trimmed}/mcp`; +} +function isGroupId(value) { + return typeof value === "string" && CAPABILITY_GROUP_IDS.includes(value); +} +function parseTool(raw) { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) + return null; + const row = raw; + const name = typeof row.name === "string" ? row.name.trim() : ""; if (!name) - return { ok: false, error: "name_required" }; - const body = { name }; - const parentId = str3(args, "parent_id").trim(); - if (parentId) - body.parent_id = parentId; - const locale = str3(args, "locale").trim(); - if (locale) - body.locale = locale; - if (num3(args, "sort_order") !== undefined) - body.sort_order = Math.trunc(num3(args, "sort_order")); - return unwrap(await client.post("/v1/topology/folders", body, signal)); + return null; + const description = typeof row.description === "string" ? row.description : ""; + const inputSchema = row.inputSchema && typeof row.inputSchema === "object" && !Array.isArray(row.inputSchema) ? row.inputSchema : { type: "object", properties: {} }; + const annotations = row.annotations && typeof row.annotations === "object" && !Array.isArray(row.annotations) ? row.annotations : {}; + const readOnlyHint = annotations.readOnlyHint === true; + const meta = row._meta && typeof row._meta === "object" && !Array.isArray(row._meta) ? row._meta : {}; + const netx = meta.netx && typeof meta.netx === "object" && !Array.isArray(meta.netx) ? meta.netx : {}; + const group = isGroupId(netx.group) ? netx.group : undefined; + return { name, description, inputSchema, readOnlyHint, group }; } -async function addTopologyViewNodes(client, args, signal) { - const viewId = str3(args, "view_id").trim(); - if (!viewId) - return { ok: false, error: "view_id_required" }; - if (strList2(args, "managed_ne_ids").length || strList2(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 = strList2(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 (num3(args, "max_nodes") !== undefined) { - const got = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(viewId)}`, undefined, signal)); - if (got.ok === true) { - const data = asRecord2(got.data); - const filt = asRecord2(got.filter ?? data.filter); - const membership = asRecord2(filt.membership ?? {}); - membership.max_nodes = clampInt2(num3(args, "max_nodes"), 2000, 1, 2000); - filt.membership = membership; - await client.patch(`/v1/topology/views/${encodeURIComponent(viewId)}`, { filter: filt }, signal); +function parseMcpResponseBody(raw) { + 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)}`); } } - const layout = str3(args, "layout", "grid").trim() || "grid"; - const body = { - managed_ne_ids: [], - layout, - ...filters - }; - if (fabricIds.length) - body.fabric_node_ids = fabricIds; - if (num3(args, "limit") !== undefined) - body.limit = clampInt2(num3(args, "limit"), 500, 1, 2000); - if (num3(args, "offset") !== undefined) - body.offset = clampInt2(num3(args, "offset"), 0, 0, Number.MAX_SAFE_INTEGER); - return unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes`, body, signal, 180000)); -} -async function removeTopologyViewNodes(client, args, signal) { - const viewId = str3(args, "view_id").trim(); - if (!viewId) - return { ok: false, error: "view_id_required" }; - const body = { ...filterFields(args) }; - const fabricIds = strList2(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)); -} -async function copyTopologyViewNodes(client, args, signal) { - const sourceId = str3(args, "source_view_id").trim(); - const targetId = str3(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, 120000)); - if (source.ok !== true) - return source; - const nodes = Array.isArray(source.nodes) ? source.nodes : []; - const limit = num3(args, "limit"); - const selected = typeof limit === "number" ? nodes.slice(0, clampInt2(limit, nodes.length, 1, 2000)) : nodes; - const fabricIds = selected.map((n) => { - const row = asRecord2(n); - return str3(row, "id") || str3(row, "fabric_node_id"); - }).filter(Boolean); - if (bool3(args, "dry_run") === true) { - return { ok: true, dry_run: true, would_copy: fabricIds.length, source_view_id: sourceId, target_view_id: targetId }; - } - if (bool3(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, 180000)); - if (add.ok !== true) - return add; - if (bool3(args, "copy_positions") !== false) { - const ox = num3(args, "offset_x") ?? 0; - const oy = num3(args, "offset_y") ?? 0; - const positions = selected.map((n) => { - const row = asRecord2(n); - const id = str3(row, "id") || str3(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, 120000); + if (text.includes("data:")) { + const dataLines = []; + for (const line of text.split(/\r?\n/)) { + if (line.startsWith("data:")) + dataLines.push(line.slice(5).trim()); + } + for (let i2 = dataLines.length - 1;i2 >= 0; i2 -= 1) { + try { + return JSON.parse(dataLines[i2]); + } catch {} } } - return { ok: true, added: fabricIds.length, source_view_id: sourceId, target_view_id: targetId, ...asRecord2(add) }; + throw new McpRemoteError("mcp response was not JSON"); } -async function updateTopologyViewPositions(client, args, signal) { - const viewId = str3(args, "view_id").trim(); - if (!viewId) - return { ok: false, error: "view_id_required" }; - const body = {}; - if (Array.isArray(args.positions)) - body.positions = args.positions; - const layout = str3(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"]) { - if (num3(args, key) !== undefined) - body[key] = num3(args, key); +function asRpcResult(payload) { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + throw new McpRemoteError("invalid JSON-RPC envelope from /mcp"); } - const fabricIds = strList2(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, 120000)); + const row = payload; + if (row.error && typeof row.error === "object" && !Array.isArray(row.error)) { + const err2 = row.error; + const message = typeof err2.message === "string" ? err2.message : "JSON-RPC error"; + throw new McpRemoteError(message); + } + if (!("result" in row)) { + throw new McpRemoteError("JSON-RPC response missing result"); + } + return payload.result; } -async function projectTopologyNeighbors(client, args, signal) { - const viewId = str3(args, "view_id").trim(); - if (!viewId) - return { ok: false, error: "view_id_required" }; - const body = {}; - const seeds = strList2(args, "seed_fabric_node_ids").length ? strList2(args, "seed_fabric_node_ids") : strList2(args, "fabric_node_ids"); - if (seeds.length) - body.seed_fabric_node_ids = seeds; - const mids = strList2(args, "managed_ne_ids"); - if (mids.length) - body.managed_ne_ids = mids; - const region = str3(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, 180000)); - if (out.ok !== true) - return out; - const detail = str3(args, "detail", "summary").trim().toLowerCase() || "summary"; - if (detail === "full" || detail === "raw" || detail === "graph") { - return { ...out, detail: "full", projected: true }; - } - return { ...summarizeViewGraph(out, clampInt2(num3(args, "sample"), 20, 0, 100)), projected: true, view_id: viewId }; -} -async function queryTopologyFabricNodes(client, args, signal) { - let mode = str3(args, "mode").trim().toLowerCase(); - const q = str3(args, "q").trim(); - const keyword = str3(args, "keyword").trim(); - if (!mode) { - if (bool3(args, "summary") === true) - mode = "summary"; - else if (q) - mode = "search"; - else - mode = "list"; - } - if (mode === "summary" || mode === "stats" || mode === "count") { - const out2 = unwrap(await client.get("/v1/topology/fabric/summary", undefined, signal)); - return out2.ok === true ? { ...out2, mode: "summary" } : out2; - } - 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: clampInt2(num3(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num3(args, "page_size") ?? num3(args, "limit"), 50, 1, 200) - }, signal)); - } - const params = { - page: clampInt2(num3(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num3(args, "page_size") ?? num3(args, "limit"), 50, 1, 500) +var nextId = 1; +async function postRpc(options, method, params, notification = false) { + const headers = { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream" }; - for (const key of ["keyword", "role", "level", "level_major", "region_folder_id", "link_status"]) { - const v = str3(args, key).trim() || (key === "keyword" ? q : ""); - if (v) - params[key] = v; + 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 out = unwrap(await client.get("/v1/topology/fabric/nodes", params, signal)); - return out.ok === true ? { ...out, mode: "list" } : out; -} -async function classifyTopologyFabricNodes(client, args, signal) { - const action = str3(args, "action").trim().toLowerCase(); - if (!action) - return { ok: false, error: "action_required" }; - if (action === "match") { - const pattern = str3(args, "pattern").trim() || str3(args, "q").trim(); - if (!pattern) - return { ok: false, error: "pattern_required" }; - return unwrap(await client.post("/v1/topology/fabric/nodes/match", { - pattern, - match_field: str3(args, "match_field", "name").trim() || "name", - sample_limit: clampInt2(num3(args, "sample_limit"), 50, 1, 200) - }, signal)); + 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 : 60000; + const fetchImpl = options.fetchImpl ?? globalThis.fetch; + if (typeof fetchImpl !== "function") { + throw new McpRemoteError("fetch is not available"); } - if (action === "tag") { - const body = { - fabric_node_ids: strList2(args, "fabric_node_ids"), - dry_run: bool3(args, "dry_run") === true - }; - const pattern = str3(args, "pattern").trim() || str3(args, "q").trim(); - if (pattern) - body.pattern = pattern; - if (args.level !== undefined) - body.level = args.level; - const role = str3(args, "role").trim(); - if (role) - body.role = role; - const region = str3(args, "region_folder_id").trim(); - if (region) - body.region_folder_id = region; - if (bool3(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 = str3(args, "fabric_node_id").trim() || str3(args, "node_id").trim(); - if (!nodeId) - return { ok: false, error: "fabric_node_id_required" }; - const body = {}; - if (args.level !== undefined) - body.level = args.level; - const role = str3(args, "role").trim(); - if (role) - body.role = role; - const region = str3(args, "region_folder_id").trim(); - if (region) - body.region_folder_id = region; - if (bool3(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: str3(args, "kind", "any").trim() || "any", - page: clampInt2(num3(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num3(args, "page_size") ?? num3(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 = {}; - if (bool3(args, "overwrite_manual") === true) - params.skip_manual = false; - else if (bool3(args, "skip_manual") !== undefined) - params.skip_manual = bool3(args, "skip_manual") !== false; - if (bool3(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 }; -} -async function queryTopologyNeighborhood(client, args, signal) { - const nodeId = str3(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: clampInt2(num3(args, "depth"), 1, 1, 3), - layer: str3(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) }; -} -async function queryTopologyEdges(client, args, signal) { - const params = { - page: clampInt2(num3(args, "page"), 1, 1, Number.MAX_SAFE_INTEGER), - page_size: clampInt2(num3(args, "page_size"), 100, 1, 500), - layer: str3(args, "layer", "physical").trim() || "physical" + const abort = new AbortController; + const timer = setTimeout(() => abort.abort(), timeoutMs); + const onOuterAbort = () => { + abort.abort(); }; - for (const key of ["node_id", "keyword", "status", "source"]) { - const v = str3(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 = str3(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 }; -} -async function suggestSinkHubs(client, args, signal) { - const viewId = str3(args, "source_view_id").trim() || str3(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, 120000)); - 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; - for (const raw of edges) { - const [a, b] = edgeEndpoints(asRecord2(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([ - ...strList2(args, "exclude_portal_ids"), - ...strList2(args, "exclude_fabric_node_ids") - ]); - const pick = clampInt2(num3(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 row2 = asRecord2(n); - return (str3(row2, "id") || str3(row2, "fabric_node_id")) === fabric_node_id; + options.signal?.addEventListener("abort", onOuterAbort, { once: true }); + let response; + try { + response = await fetchImpl(options.mcpUrl, { + method: "POST", + headers, + body: bodyText, + signal: abort.signal }); - const row = asRecord2(node); - return { - fabric_node_id, - degree: deg, - name: str3(row, "name"), - level: row.level, - role: row.role - }; + } 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; + } + return asRpcResult(parseMcpResponseBody(responseText)); +} +async function listMcpTools(options) { + 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", {}); + const tools = Array.isArray(result?.tools) ? result.tools : []; + const out = []; + for (const item of tools) { + const parsed = parseTool(item); + if (parsed) + out.push(parsed); + } + return out; +} +async function callMcpTool(options, name, args) { + 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 + }); + const content = Array.isArray(result.content) ? result.content : []; return { - ok: true, - source_view_id: viewId, - hubs, - hint: "Degree ranking only — full dual_unit-aware suggestSinkHubs lives in netx-topology MCP." + content, + ...result.structuredContent !== undefined ? { structuredContent: result.structuredContent } : {}, + ...result.isError === true ? { isError: true } : {} }; } -async function analyzeTopologyViewLayout(client, args, signal) { - const viewId = str3(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, 120000)); - 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 = asRecord2(raw); - const x2 = Number(row.x); - const y = Number(row.y); - if (!Number.isFinite(x2) || !Number.isFinite(y)) { - missingxy += 1; +function mcpContentText(content, fallback = "") { + const parts = []; + for (const block of content) { + if (!block || typeof block !== "object" || Array.isArray(block)) continue; + const row = block; + if (row.type === "text" && typeof row.text === "string") + parts.push(row.text); + } + return parts.join(` +`) || fallback; +} + +// src/netx/mcp-store.ts +var STORE_KEY4 = Symbol.for("dsh-netxops.mcp-catalog-store"); +var EMPTY = Object.freeze({ + phase: "idle", + mcpUrl: "", + tools: Object.freeze([]), + error: "", + listedAt: null, + fromCache: false +}); +function store5() { + const root = globalThis; + let current = root[STORE_KEY4]; + if (current === undefined) { + current = { state: EMPTY, listeners: new Set, generation: 0 }; + root[STORE_KEY4] = current; + } + return current; +} +function notify() { + for (const listener of [...store5().listeners]) { + try { + listener(); + } catch (error) { + console.error("netxops: mcp catalog subscriber failed", error); } - minX = Math.min(minX, x2); - minY = Math.min(minY, y); - maxX = Math.max(maxX, x2); - maxY = Math.max(maxY, y); } - const degree = new Map; - for (const raw of edges) { - const [a, b] = edgeEndpoints(asRecord2(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: str3(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." +} +function publish(next) { + store5().state = next; + notify(); +} +function getMcpCatalogState() { + return store5().state; +} +function getMcpCatalogTools() { + return store5().state.tools; +} +function watchMcpCatalog(listener) { + const state = store5(); + state.listeners.add(listener); + return () => { + state.listeners.delete(listener); }; } -async function sinkTopologyDualUnits(_client, args) { - return { - ok: false, - error: "sink_dual_units_requires_netx_topology_mcp", - source_view_id: str3(args, "source_view_id"), - sink_view_id: str3(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." - }; -} -async function layoutTopologyView(client, args, signal) { - if (bool3(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." +async function refreshMcpCatalog(input) { + const mcpUrl = mcpEndpointUrl(input.apiUrl); + const state = store5(); + const gen = ++state.generation; + const previous = state.state; + if (!mcpUrl) { + const next = { + phase: "error", + mcpUrl: "", + tools: previous.tools, + error: "apiUrl empty", + listedAt: previous.listedAt, + fromCache: previous.tools.length > 0 }; + if (gen === store5().generation) + publish(next); + return next; } - const action = str3(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 = str3(args, "view_id").trim(); - const fromId = str3(args, "source_view_id").trim(); - const ids = strList2(args, "fabric_node_ids"); - const params = asRecord2(args.params); - const fabricIds = ids.length ? ids : strList2(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 = bool3(params, "copy_positions") !== false && bool3(args, "copy_positions") !== false; - const removeFromSource = bool3(params, "remove_from_source") !== false; - const source = unwrap(await client.get(`/v1/topology/views/${encodeURIComponent(fromId)}`, undefined, signal, 120000)); - 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 = asRecord2(n); - const id = str3(row, "id") || str3(row, "fabric_node_id"); - return wanted.has(id); + 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 }); - const add = unwrap(await client.post(`/v1/topology/views/${encodeURIComponent(toId)}/nodes`, { fabric_node_ids: fabricIds, layout: copyPositions ? "keep" : "grid", managed_ne_ids: [] }, signal, 180000)); - 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 = asRecord2(n); - return { - fabric_node_id: str3(row, "id") || str3(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, 120000); - } - 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: bool3(params, "park") === true || bool3(args, "park") === true + if (gen !== store5().generation) + return store5().state; + const next = { + phase: "connected", + mcpUrl, + tools: Object.freeze([...tools]), + error: "", + listedAt: new Date().toISOString(), + fromCache: false }; + publish(next); + return next; + } catch (error) { + if (gen !== store5().generation) + return store5().state; + const message = error instanceof McpRemoteError ? error.message : error instanceof Error ? error.message : String(error); + const next = { + 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; } - return layoutRecipeUnavailable(action); } // src/netx/tools.ts @@ -6092,65 +4863,89 @@ function packageVersion() { return "0"; } } -var MUTATING_TOOLS = new Set([ - "netx__execManagedNe", - "netx__createTopologyFolder", - "netx__addTopologyViewNodes", - "netx__removeTopologyViewNodes", - "netx__copyTopologyViewNodes", - "netx__updateTopologyViewPositions", - "netx__projectTopologyNeighbors", - "netx__classifyTopologyFabricNodes", - "netx__layoutTopologyView" -]); -var str4 = (description) => ({ type: "string", ...description ? { description } : {} }); -var num4 = (description) => ({ type: "number", ...description ? { description } : {} }); -var bool4 = (description) => ({ type: "boolean", ...description ? { description } : {} }); -var strArr = (description) => ({ - type: "array", - items: { type: "string" }, - ...description ? { description } : {} -}); -var anyObj = (description) => ({ - type: "object", - additionalProperties: true, - ...description ? { description } : {} -}); -function renderJson2(_args, value) { - return [{ type: "text", text: JSON.stringify(value, null, 0) }]; +function renderMcpResult(_args, value) { + if (value && typeof value === "object" && !Array.isArray(value)) { + const row = value; + if (typeof row.text === "string" && row.text.length > 0) { + return [{ type: "text", text: row.text }]; + } + if (Array.isArray(row.content)) { + return [{ type: "text", text: mcpContentText(row.content, JSON.stringify(value)) }]; + } + } + return [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value ?? null) }]; } -var jsonOut2 = { +var mcpOut = { schema: { type: "object", additionalProperties: true }, - render: renderJson2 + render: renderMcpResult }; -function tool2(name, description, parameters, handler, getClient, timeoutMs) { +function parametersFromInputSchema(schema) { + if (schema && typeof schema === "object" && !Array.isArray(schema)) { + const props = schema.properties; + if (props && typeof props === "object" && !Array.isArray(props)) { + return props; + } + if (schema.type === undefined && schema.properties === undefined) { + return schema; + } + } + return {}; +} +function defineRemoteTool(tool2, timeoutMs) { + const name = publicToolName(tool2.name); return defineTool2({ name, - description, - parameters, - output: jsonOut2, + description: tool2.description || name, + parameters: parametersFromInputSchema(tool2.inputSchema), + output: mcpOut, timeoutMs, - isConcurrencySafe: () => !MUTATING_TOOLS.has(name), + isConcurrencySafe: () => tool2.readOnlyHint === true, async execute(args, exec) { - const result = await handler(getClient(), args, exec.signal); - 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 + }, tool2.name, typeof args === "object" && args !== null ? args : {}); + const text = mcpContentText(result.content, ""); + if (result.isError === true) { + throw new Error(text || `Tool "${tool2.name}" failed`); } - return safe; + return JSON.parse(JSON.stringify({ + content: result.content, + ...result.structuredContent !== undefined ? { structuredContent: result.structuredContent } : {}, + ...text ? { text } : {} + })); } }); } function registerNetxTools(ctx, connection, options) { - 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((tool2) => tool2.group).filter(Boolean))]; + if (enabled.length === 0) { publishToolMountStatus({ id, version: packageVersion(), plane: options.plane, - groups: [...enabledGroups], + groups, names: [], count: 0, at: new Date().toISOString(), @@ -6161,392 +4956,40 @@ function registerNetxTools(ctx, connection, options) { }; } const t = typeof connection.toolCallTimeoutMs === "number" && Number.isFinite(connection.toolCallTimeoutMs) && connection.toolCallTimeoutMs > 0 ? connection.toolCallTimeoutMs : 120000; - const client = createNetxClient({ - apiUrl: connection.apiUrl, - lang: connection.lang, - timeoutMs: Math.min(t, 45000), - getToken: () => { - const live = getNetxConnection(); - return live ? live.token.trim() : connection.token.trim(); - }, - getApiUrl: () => getNetxConnection()?.apiUrl ?? connection.apiUrl - }); - const getClient = () => client; - const catalog = [ - tool2("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: str4(), - ne_id: str4("Filter only; do not show UUID to users"), - host_name: str4("Filter by NE host_name"), - ne_name: str4("Legacy alias mapped to keyword"), - keyword: str4("Substring on cause/object/event. Examples: LOS, Fiber Break, bandwidth, CRC."), - time_from: str4("ISO time; filters last_seen_at >="), - time_to: str4("ISO time; filters last_seen_at <="), - page: num4(), - page_size: num4() - }, queryUmeAlarms, getClient, t), - tool2("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: str4("Optional perceived_severity filter (critical/major/minor/warning)."), - top_ne: num4("Max NE buckets (default 50). Ignored when group_by is set."), - exclude_missing_host: bool4("Omit missing host_name from by_ne."), - time_from: str4(), - time_to: str4(), - group_by: str4("When set, routes to raw aggregation. Prefer alarm_host_name."), - group_by2: str4(), - is_cleared: str4(), - ne_id: str4(), - event_type: str4(), - keyword: str4(), - limit: num4() - }, aggregateUmeAlarms, getClient, t), - tool2("netx__runNmsDiagnostics", "NMS alarm diagnostics: severity, top_event_types, top_alarm_codes, top_ne, protocol buckets, freshness meta.", {}, runUmeDiagnostics, getClient, t), - tool2("netx__queryNmsNeInventory", "Paged NMS NE inventory synced in netx (keyword matches ne_id/ne_name/user_label/ip/host_name).", { - keyword: str4(), - page: num4(), - page_size: num4() - }, queryUmeNeInventory, getClient, t), - tool2("netx__getNmsNe", "Get single NMS NE detail by ne_id (UUID).", { - ne_id: { type: "string", required: true, description: "NMS inventory ne_id (UUID)." } - }, getUmeNe, getClient, t), - tool2("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: str4(), - is_cleared: str4(), - ne_id: str4(), - event_type: str4(), - keyword: str4(), - time_from: str4(), - time_to: str4(), - order_by: str4("last_seen_at | time_created | perceived_severity | event_type | ne_id"), - order: str4("asc | desc"), - select_fields: strArr(), - field_preset: str4("brief | evidence | ne_debug"), - page: num4(), - page_size: num4() - }, queryUmeAlarmsRaw, getClient, t), - tool2("netx__aggregateNmsAlarmsRaw", "Dynamic aggregation on NMS raw fields (group_by/group_by2); prefer alarm_host_name.", { - group_by: { type: "string", required: true }, - group_by2: str4(), - severity: str4(), - is_cleared: str4(), - ne_id: str4(), - event_type: str4(), - keyword: str4(), - time_from: str4(), - time_to: str4(), - exclude_missing_host: bool4(), - limit: num4() - }, aggregateUmeAlarmsRaw, getClient, t), - tool2("netx__listNmsAlarmFields", "List available fields for NMS raw alarm queries.", {}, listUmeAlarmFields, getClient, t), - tool2("netx__sqlQueryNms", "Read-only SELECT on NMS tables (ume_alarms_current/ume_inventory_ne); server enforces limits. Requires sql:query scope.", { - sql: { type: "string", required: true }, - limit: num4(), - statement_timeout_ms: num4() - }, sqlQueryUme, getClient, t), - tool2("netx__listManagedNe", "List filtered netx managed NEs (keyword/vendor/connect_status required); use before execManagedNe.", { - keyword: str4(), - vendor: str4(), - connect_status: str4("unknown | testing | pass | fail"), - page: num4(), - page_size: num4() - }, listManagedNe, getClient, t), - tool2("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: str4("Managed NE id"), - managed_ne_id: str4("Alias for ne_id"), - id: str4("Alias for ne_id") - }, getManagedNe, getClient, t), - tool2("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: str4(), - nms_ne_id: str4("NMS inventory id; alias ume_ne_id"), - ume_ne_id: str4("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", - description: "Per-NE command sets: each item is one NE (ne_id OR nms_ne_id) with commands[].", - items: { - type: "object", - additionalProperties: false, - properties: { - ne_id: str4(), - nms_ne_id: str4(), - ume_ne_id: str4(), - commands: strArr() - } - } - }, - commands: strArr("Commands for single NE, or shared commands for batch."), - read_timeout_sec: num4("Per-command read timeout (default 60; use 90–120 for slow show)."), - concurrency: num4("Parallel NEs for batch mode (1–8, default 4)."), - async: bool4("true=background job_id + getNeExecJob; false=force sync; omit=auto for ~4+ NEs (NETX_NE_EXEC_ASYNC_MIN_NES).") - }, execManagedNe, getClient, Math.max(t, 300000)), - tool2("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: str4("Job id from execManagedNe async response.") - }, getNeExecJob, getClient, Math.max(t, 60000)), - tool2("netx__listCliTargets", "List CLI-capable targets (managed NE and/or NMS inventory). Call once per session with keyword/source, cache ids, then execManagedNe.", { - source: str4("managed | nms | ume | all"), - keyword: str4(), - page: num4(), - page_size: num4() - }, listCliTargets, getClient, t), - tool2("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: str4("NMS inventory id"), - from_ume_ne_id: str4("Legacy alias of from_nms_ne_id"), - from_managed_ne_id: str4(), - to_nms_ne_id: str4("NMS inventory id"), - to_ume_ne_id: str4("Legacy alias of to_nms_ne_id"), - to_managed_ne_id: str4(), - max_paths: num4(), - max_hops: num4(), - layer: str4(), - detail: str4("summary | full") - }, findTopologyPaths, getClient, t), - tool2("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: str4("all | projects | tasks (default all)"), - purpose: str4("Filter tasks: portrait | cutover_hf | empty=all"), - status: str4("Filter by status (e.g. active / paused)"), - q: str4("Keyword on name / ne_name / note / id"), - limit: num4("Max rows per list (default 100, max 500)") - }, listBizMonitors, getClient, t), - tool2("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: str4("auto | all | cutover | collect | runs (default auto)"), - project_id: str4("Cutover project id → cutover_batches"), - task_id: str4("biz_state task id → collect_batches"), - batch_id: str4("Cutover batch id (filter or kind=runs)"), - include_runs: bool4("Attach recent evaluate runs onto listed cutover batches"), - status: str4("Filter batch status"), - limit: num4("Max batches (default 50)"), - runs_limit: num4("Max runs per batch when include_runs / kind=runs (default 10)") - }, listBizMonitorBatches, getClient, t), - tool2("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: str4("Cutover / biz-migration project id"), - task_id: str4("biz_state task id (optional if project_id set)") - }, getBizMonitorContext, getClient, t), - tool2("netx__getBizMonitorBoard", "Cutover batch board: project/batch status, evaluate run summary, sheet cards, verdict counts, metric_batches.", { - batch_id: { type: "string", required: true }, - run_id: str4("Optional evaluate run id") - }, getBizMonitorBoard, getClient, t), - tool2("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", required: true }, - status: str4("open|carried|resolved or empty=all"), - limit: num4() - }, listBizMonitorReds, getClient, t), - tool2("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", required: true }, - metric_id: str4(), - sheet_id: str4(), - verdict: str4(), - color: str4("e.g. red"), - q: str4(), - limit: num4(), - offset: num4() - }, getBizMonitorDiffs, getClient, t), - tool2("netx__getBizCollectBatch", "One biz_state collect batch: commands (raw_command, parse_status, preview), parsed metrics.", { - batch_id: { type: "string", required: true } - }, getBizCollectBatch, getClient, t), - tool2("netx__getBizCollectCommandRaw", "Full CLI raw_text for one collect command inside a batch (deep dive).", { - batch_id: { type: "string", required: true }, - command_id: { type: "string", required: true } - }, getBizCollectCommandRaw, getClient, t), - tool2("netx__getTopologyTree", "Get topology folder tree (nav roots + Root map canvases). Start here before createTopologyFolder.", { compact: bool4(), max_depth: num4() }, getTopologyTree, getClient, t), - tool2("netx__getTopologyView", "Get a topology view by view_id. Default detail=summary with sample_nodes + links[].", { - view_id: { type: "string", required: true }, - detail: str4("summary | full"), - sample: num4() - }, getTopologyView, getClient, t), - tool2("netx__createTopologyFolder", "Create topology folders / region canvases. Only way to create canvases (ne:write).", { - name: { type: "string", required: true }, - parent_id: str4(), - locale: str4("zh | en"), - sort_order: num4() - }, createTopologyFolder, getClient, t), - tool2("netx__addTopologyViewNodes", "Bulk-place fabric nodes on a view via filters or fabric_node_ids (never managed/UME ids).", { - view_id: { type: "string", required: true }, - max_nodes: num4(), - keyword: str4(), - role: str4(), - vendor: str4(), - link_status: str4(), - limit: num4(), - offset: num4(), - fabric_node_ids: strArr(), - layout: str4("grid | keep") - }, addTopologyViewNodes, getClient, t), - tool2("netx__removeTopologyViewNodes", "Remove placements from a view (not fabric) by filters or fabric_node_ids.", { - view_id: { type: "string", required: true }, - keyword: str4(), - role: str4(), - vendor: str4(), - link_status: str4(), - fabric_node_ids: strArr() - }, removeTopologyViewNodes, getClient, t), - tool2("netx__copyTopologyViewNodes", "Clone fabric placements from source_view_id onto target_view_id (optional clear_target / copy_positions).", { - source_view_id: { type: "string", required: true }, - target_view_id: { type: "string", required: true }, - copy_positions: bool4(), - clear_target: bool4(), - offset_x: num4(), - offset_y: num4(), - limit: num4(), - dry_run: bool4() - }, copyTopologyViewNodes, getClient, t), - tool2("netx__updateTopologyViewPositions", "Move nodes on a view via positions[] or layout=grid|offset|stack + filters.", { - view_id: { type: "string", required: true }, - positions: { - type: "array", - description: "Manual placements: [{ fabric_node_id, x, y }, …]", - items: { - type: "object", - additionalProperties: false, - properties: { - fabric_node_id: str4(), - x: num4(), - y: num4() - } - } - }, - layout: str4("grid | offset | stack"), - keyword: str4(), - role: str4(), - vendor: str4(), - link_status: str4(), - fabric_node_ids: strArr(), - offset_x: num4(), - offset_y: num4() - }, updateTopologyViewPositions, getClient, t), - tool2("netx__projectTopologyNeighbors", "Project existing fabric neighbors of on-view nodes onto the canvas.", { - view_id: { type: "string", required: true }, - seed_fabric_node_ids: strArr(), - managed_ne_ids: strArr(), - region_folder_id: str4(), - detail: str4("summary | full"), - sample: num4() - }, projectTopologyNeighbors, getClient, t), - tool2("netx__queryTopologyFabricNodes", "Fabric inventory: mode=summary|list|search (keyword/role/level/region/link_status).", { - mode: str4("summary | list | search"), - q: str4(), - keyword: str4(), - role: str4(), - level: str4(), - level_major: str4(), - region_folder_id: str4(), - link_status: str4(), - page: num4(), - page_size: num4(), - limit: num4(), - summary: bool4() - }, queryTopologyFabricNodes, getClient, t), - tool2("netx__classifyTopologyFabricNodes", "Classify fabric nodes: action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules.", { - action: { type: "string", required: true }, - pattern: str4(), - q: str4(), - match_field: str4(), - sample_limit: num4(), - fabric_node_ids: strArr(), - fabric_node_id: str4(), - node_id: str4(), - level: str4(), - role: str4(), - region_folder_id: str4(), - clear_region: bool4(), - dry_run: bool4(), - kind: str4(), - page: num4(), - page_size: num4(), - skip_manual: bool4(), - overwrite_manual: bool4(), - fill_empty_only: bool4() - }, classifyTopologyFabricNodes, getClient, t), - tool2("netx__queryTopologyNeighborhood", "Neighborhood around a fabric node (depth 1–3) with compact links[].", { - node_id: { type: "string", required: true }, - depth: num4(), - layer: str4() - }, queryTopologyNeighborhood, getClient, t), - tool2("netx__queryTopologyEdges", "Fabric adjacency links[] (detail=adjacency) or port rows (detail=ports).", { - node_id: str4(), - keyword: str4(), - layer: str4(), - status: str4(), - source: str4("lldp | ume | manual"), - detail: str4("adjacency | ports"), - page: num4(), - page_size: num4() - }, queryTopologyEdges, getClient, t), - tool2("netx__suggestSinkHubs", "Rank hub territories on a source view for non-dual move_nodes(park) batches (degree stand-in in DSH).", { - source_view_id: str4(), - view_id: str4(), - pick: num4(), - exclude_portal_ids: strArr(), - exclude_fabric_node_ids: strArr() - }, suggestSinkHubs, getClient, t), - tool2("netx__analyzeTopologyViewLayout", "Basic layout QA for a view (bbox/hubs). Full dual_unit/crossing scores need netx-topology MCP.", { - view_id: str4(), - folder_id: str4(), - detail: str4("summary | structure | hotspots | blocks | both"), - score_profile: str4(), - sight_limit: num4(), - max_views: num4(), - min_nodes: num4(), - max_nodes: num4(), - with_meta: bool4() - }, analyzeTopologyViewLayout, getClient, t), - tool2("netx__sinkTopologyDualUnits", "Drain dual_unit eyes from source to sink (requires netx-topology MCP layout engine in DSH).", { - source_view_id: str4(), - sink_view_id: str4(), - max_units: num4(), - min_nodes: num4(), - max_nodes: num4(), - max_batch_nodes: num4(), - layout_batch: bool4(), - until_empty: bool4(), - dry_run: bool4() - }, sinkTopologyDualUnits, getClient, t), - tool2("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: str4(), - action: str4(), - source_view_id: str4(), - recipe: str4(), - preset: str4(), - mode: str4("preview | apply"), - tune: bool4(), - params: anyObj("Action-specific options (move_nodes / recipes / polish)."), - catalog: bool4(), - fabric_node_ids: strArr(), - park: bool4(), - copy_positions: bool4() - }, layoutTopologyView, getClient, Math.max(t, 180000)) - ]; - const selected = catalog.filter((entry) => allow.has(entry.name)); - const names = selected.map((entry) => entry.name); const disposers = []; + const names = []; try { - for (const entry of selected) - disposers.push(ctx.tools.register(entry)); - publishToolMountStatus({ - id, - version: packageVersion(), - plane: options.plane, - groups: [...enabledGroups], - names, - count: names.length, - at: new Date().toISOString(), - source: options.source ?? options.plane - }); + for (const tool2 of enabled) { + const definition = defineRemoteTool(tool2, t); + disposers.push(ctx.tools.register(definition)); + names.push(publicToolName(tool2.name)); + } } catch (error) { - for (const dispose of disposers.reverse()) + for (const dispose of disposers.splice(0)) dispose(); publishToolMountStatus({ id, version: packageVersion(), plane: options.plane, - groups: [...enabledGroups], + groups, names: [], count: 0, at: new Date().toISOString(), - error: error instanceof Error ? error.message : String(error), + error: String(error), source: options.source ?? options.plane }); throw error; } + publishToolMountStatus({ + id, + version: packageVersion(), + plane: options.plane, + groups, + names, + count: names.length, + at: new Date().toISOString(), + source: options.source ?? options.plane + }); return () => { for (const dispose of disposers) dispose(); @@ -6570,6 +5013,16 @@ function packageVersion2() { return "0"; } } +function disabledByGroupFor(groupIds, connection) { + const catalog = getMcpCatalogTools(); + const out = {}; + for (const groupId of groupIds) { + const names = disabledPublicNames(catalog, [groupId], connection.disabledTools, connection.readOnlyOnly); + if (names.length > 0) + out[groupId] = names; + } + return out; +} function applyGroupToolsPlugin(ctx, options) { let unregisterTools; let unregisterKbLocal; @@ -6595,9 +5048,10 @@ function applyGroupToolsPlugin(ctx, options) { unregisterTools = registerNetxTools(ctx, connection, { plane: "preset", forceGroups: enabled, - source: options.name + source: options.name, + presetId: presetId() }); - ctx.logger.info("%s: groups=[%s] → %s tokenConfigured=%s live=%s", options.name, enabled.join(",") || "(none)", connection.apiUrl, connection.token.trim().length > 0, getNetxConnection() !== undefined); + ctx.logger.info("%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({ id: mountId, @@ -6625,6 +5079,9 @@ function applyGroupToolsPlugin(ctx, options) { remountTools(); remountKbLocal(); }); + const stopCatalogWatch = watchMcpCatalog(() => { + remountTools(); + }); const stopKbToolWatch = watchKbContext(() => { remountKbLocal(); }); @@ -6654,8 +5111,13 @@ function applyGroupToolsPlugin(ctx, options) { skillAbort = new AbortController; unregisterSkills?.(); unregisterSkills = undefined; + const connection = requireNetxConnection(); const enabled = resolveGroups(); - registerGroupSkills(skillsCtx, enabled, options.name, skillAbort.signal).then((dispose) => { + registerGroupSkills(skillsCtx, enabled, options.name, { + signal: skillAbort.signal, + disabledByGroup: disabledByGroupFor(enabled, connection), + lang: connection.lang + }).then((dispose) => { if (gen !== skillGeneration) { dispose(); return; @@ -6700,6 +5162,9 @@ function applyGroupToolsPlugin(ctx, options) { remountKbSkill(); remountKbPack(); }); + const stopSkillCatalogWatch = watchMcpCatalog(() => { + remountSkills(); + }); const stopKbWatch = watchKbContext(() => { remountKbSkill(); remountKbPack(); @@ -6713,6 +5178,7 @@ function applyGroupToolsPlugin(ctx, options) { skillAbort.abort(); packAbort.abort(); stopSkillWatch(); + stopSkillCatalogWatch(); stopKbWatch(); unregisterSkills?.(); unregisterSkills = undefined; @@ -6725,6 +5191,7 @@ function applyGroupToolsPlugin(ctx, options) { ctx.effect(() => () => { clearInterval(pollTimer); stopToolWatch(); + stopCatalogWatch(); stopKbToolWatch(); unregisterTools?.(); unregisterTools = undefined; @@ -6833,7 +5300,9 @@ var Config = z.object({ groupBizMonitorPublic: vol(z.boolean().default(false)), kbRoot: vol(z.string().default("")), groupKbInPreset: vol(z.boolean().default(true)), - groupKbPublic: vol(z.boolean().default(false)) + groupKbPublic: vol(z.boolean().default(false)), + disabledTools: vol(z.string().default("[]")), + readOnlyOnly: vol(z.boolean().default(false)) }); function mergeConfigSkippingNullish(base, patch) { const live = Object.fromEntries(Object.entries(readConfigSnapshot(patch)).filter(([, v]) => v !== null && v !== undefined)); @@ -7033,6 +5502,8 @@ function apply(ctx, config = Config({})) { lang: resolveLang(current.lang), toolCallTimeoutMs: resolveToolCallTimeoutMs(current.toolCallTimeoutMs), groups: capabilityGroupsFromSettings(current), + disabledTools: parseDisabledTools(current.disabledTools), + readOnlyOnly: current.readOnlyOnly === true, groupKbInPreset: typeof current.groupKbInPreset === "boolean" ? current.groupKbInPreset : true, groupKbPublic: current.groupKbPublic === true }; @@ -7118,6 +5589,9 @@ function apply(ctx, config = Config({})) { remountHostTools(); remountKbLocal(); }); + const stopCatalogWatch = watchMcpCatalog(() => { + remountHostTools(); + }); const stopKbWatch = watchKbContext(() => { remountKbLocal(); }); @@ -7125,6 +5599,7 @@ function apply(ctx, config = Config({})) { remountKbLocal(); ctx.effect(() => () => { stopWatch(); + stopCatalogWatch(); stopKbWatch(); disposeToolsMount?.(); disposeKbLocal?.(); @@ -7172,7 +5647,7 @@ function apply(ctx, config = Config({})) { } }); }; - const publish = () => { + const publish2 = () => { if (disposed) return; const gen = ++generation; @@ -7212,6 +5687,19 @@ function apply(ctx, config = Config({})) { } restartAlarmPush(current, snapshot.apiUrl, token); const tokenConfigured = token.trim().length > 0; + const catalog = await refreshMcpCatalog({ + apiUrl: snapshot.apiUrl, + token, + lang: snapshot.lang, + timeoutMs: Math.min(snapshot.toolCallTimeoutMs, 45000) + }); + 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)", snapshot.apiUrl, tokenRef); } else { @@ -7226,24 +5714,24 @@ function apply(ctx, config = Config({})) { ctx.logger.error("netxops: connection publish error: %s", error); }); }; - publish(); + publish2(); ctx.on("loader/volatile-update", () => { - publish(); + publish2(); }); installNetxopsSettings(ctx, config, { setSource: (current) => { source = () => readConfigSnapshot(current()); }, onChange: () => { - publish(); + publish2(); } }); ctx.inject(["credentials"], (credCtx) => { credentialsCtx = credCtx; - publish(); + publish2(); credCtx.on("credentials/reference-updated", (ref) => { if (String(ref) === resolveTokenCredentialRef(source().tokenCredentialRef)) - publish(); + publish2(); }); }); ctx.inject(["systemPrompt", "settings"], (promptCtx) => { @@ -7321,7 +5809,15 @@ function apply(ctx, config = Config({})) { if (!connection) return; const enabled = connection.toolsEnabled === false ? [] : groupsForPlane(connection.groups, "host"); - registerGroupSkills(skillsCtx, enabled, "netxops-host", skillAbort.signal).then((dispose) => { + const disabledByGroup = Object.fromEntries(enabled.map((groupId) => [ + groupId, + disabledPublicNames(getMcpCatalogTools(), [groupId], connection.disabledTools, connection.readOnlyOnly) + ])); + registerGroupSkills(skillsCtx, enabled, "netxops-host", { + signal: skillAbort.signal, + disabledByGroup, + lang: connection.lang + }).then((dispose) => { if (gen !== generation2) { dispose(); return; @@ -7373,6 +5869,9 @@ function apply(ctx, config = Config({})) { remountKbSkill(); remountKbPack(); }); + const stopCatalogWatch = watchMcpCatalog(() => { + remountHostSkills(); + }); const stopKbWatch = watchKbContext(() => { remountKbSkill(); remountKbPack(); @@ -7386,6 +5885,7 @@ function apply(ctx, config = Config({})) { skillAbort.abort(); packAbort.abort(); stopWatch(); + stopCatalogWatch(); stopKbWatch(); unregisterSkills?.(); unregisterKbSkill?.(); @@ -7403,22 +5903,75 @@ function apply(ctx, config = Config({})) { 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, toolsEnabled: connection.toolsEnabled !== false, tokenConfigured: connection.token.trim().length > 0, hostGroups: connection.toolsEnabled === false ? [] : groupsForPlane(connection.groups, "host"), presetGroups: connection.toolsEnabled === false ? [] : groupsForPlane(connection.groups, "preset"), - publicGroups: connection.toolsEnabled === false ? [] : groupsForPlane(connection.groups, "public") + 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((tool2) => ({ + name: tool2.name, + description: tool2.description, + readOnlyHint: tool2.readOnlyHint, + group: tool2.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, 45000) + }); + 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() }; } diff --git a/src/client/NetxopsCard.tsx b/src/client/NetxopsCard.tsx index 0f96357..1dddab0 100644 --- a/src/client/NetxopsCard.tsx +++ b/src/client/NetxopsCard.tsx @@ -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 + writeBadge?: string + emptyLabel?: string + onToggleTool?: (name: string, enabled: boolean) => void }) { return (
@@ -386,6 +396,43 @@ function CapabilityGroupBlock(props: {
{props.targets} + {props.tools + ? ( +
+ {props.toolCount ?? props.tools.length} + {props.tools.length === 0 + ?

{props.emptyLabel ?? ''}

+ : ( +
+ {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 ( + + ) + })} +
+ )} +
+ ) + : null} ) } @@ -567,62 +614,111 @@ export function NetxopsCard(props: NetxopsCardProps) {

{t('sectionCapabilities')}

- + + + +
{ props.edit('toolsEnabled', String(checked)) }} onReset={() => { props.resetField('toolsEnabled') }} - error={state.toolsStatus?.error ?

{state.toolsStatus.error}

: null} /> - { - props.edit('groupOpsInPreset', checked ? 'true' : 'false') - }} - onEditPublic={(checked) => { - props.edit('groupOpsPublic', checked ? 'true' : 'false') - }} - /> - { - props.edit('groupTopologyInPreset', checked ? 'true' : 'false') - }} - onEditPublic={(checked) => { - props.edit('groupTopologyPublic', checked ? 'true' : 'false') - }} - /> - { - props.edit('groupBizMonitorInPreset', checked ? 'true' : 'false') - }} - onEditPublic={(checked) => { - props.edit('groupBizMonitorPublic', checked ? 'true' : 'false') - }} - /> + error={(state.toolsStatus?.error || state.toolsStatus?.mcpError || state.mcpCatalog?.error) + ? ( +

+ {state.toolsStatus?.error || state.toolsStatus?.mcpError || state.mcpCatalog?.error} + {(state.toolsStatus?.mcpFromCache || state.mcpCatalog?.fromCache) + ? ` · ${t('mcpDisconnected')}` + : ''} +

+ ) + : null} /> + { 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 ( + <> + { + props.edit('groupOpsInPreset', checked ? 'true' : 'false') + }} + onEditPublic={(checked) => { + props.edit('groupOpsPublic', checked ? 'true' : 'false') + }} + {...toolProps} + /> + { + props.edit('groupTopologyInPreset', checked ? 'true' : 'false') + }} + onEditPublic={(checked) => { + props.edit('groupTopologyPublic', checked ? 'true' : 'false') + }} + {...toolProps} + /> + { + props.edit('groupBizMonitorInPreset', checked ? 'true' : 'false') + }} + onEditPublic={(checked) => { + props.edit('groupBizMonitorPublic', checked ? 'true' : 'false') + }} + {...toolProps} + /> + + ) + })()} {state.toolsStatus && state.toolsStatus.mounts.length > 0 ? (
{t('mountDetails')}

{t('mountStatusHint')}

diff --git a/src/client/controller.ts b/src/client/controller.ts index 5912bb2..6f5823e 100644 --- a/src/client/controller.ts +++ b/src/client/controller.ts @@ -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 { + 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 { + 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 { 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) }, } } diff --git a/src/client/index.ts b/src/client/index.ts index 5ee57d4..f048adc 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -162,6 +162,8 @@ const MEMORY_DEFAULTS: NetxopsSettings = { kbRoot: '', groupKbInPreset: true, groupKbPublic: false, + disabledTools: '[]', + readOnlyOnly: false, } export function apply(ctx: ClientContext): void { diff --git a/src/client/locales.ts b/src/client/locales.ts index 7acaf34..ce29a4c 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -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 = { 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 = { toolsStatusEmpty: '暂无活跃工具', toolsStatusError: '发布异常', toolsStatusUnknown: '状态不可用', + mcpDisconnected: '未连接', + mcpRefresh: '刷新', + mcpRefreshing: '刷新中…', + readOnlyOnly: '只允许只读工具', + toolWriteBadge: '写', + toolListEmpty: '暂无工具清单', sessionsExportCount: '{count} 个会话', sessionsExportUnavailable: '无法导出', sessionsExportNoPersistence: '会话存储不可用。', diff --git a/src/client/mcp-catalog-view.ts b/src/client/mcp-catalog-view.ts new file mode 100644 index 0000000..8014d0d --- /dev/null +++ b/src/client/mcp-catalog-view.ts @@ -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 + 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 + 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 { + 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 { + 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' } +} diff --git a/src/client/styles.ts b/src/client/styles.ts index c5848b1..2c8b1a8 100644 --- a/src/client/styles.ts +++ b/src/client/styles.ts @@ -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} diff --git a/src/client/tools-status-view.ts b/src/client/tools-status-view.ts index 4740651..08f78e8 100644 --- a/src/client/tools-status-view.ts +++ b/src/client/tools-status-view.ts @@ -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).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 + 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' diff --git a/src/index.ts b/src/index.ts index aefea25..9df7b9b 100644 --- a/src/index.ts +++ b/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> = 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({} 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({} 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({} 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({} 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({} 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({} as C skillAbort.abort() packAbort.abort() stopWatch() + stopCatalogWatch() stopKbWatch() unregisterSkills?.() unregisterKbSkill?.() @@ -974,11 +1034,20 @@ export function apply(ctx: Context, config: ConfigInput = 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({} 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() } } diff --git a/src/netx/capability-groups.ts b/src/netx/capability-groups.ts index 5acd5c5..d0f77f0 100644 --- a/src/netx/capability-groups.ts +++ b/src/netx/capability-groups.ts @@ -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> = 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//`. */ export const SKILL_DIR_BY_GROUP: Readonly> = 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 { - const out = new Set() - 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) diff --git a/src/netx/group-skills.ts b/src/netx/group-skills.ts index 47d24b8..de7cb01 100644 --- a/src/netx/group-skills.ts +++ b/src/netx/group-skills.ts @@ -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> + +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, diff --git a/src/netx/group-tools-plugin.ts b/src/netx/group-tools-plugin.ts index 77b0568..cfe784e 100644 --- a/src/netx/group-tools-plugin.ts +++ b/src/netx/group-tools-plugin.ts @@ -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, +): 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 diff --git a/src/netx/handlers.ts b/src/netx/handlers.ts deleted file mode 100644 index c5ea77f..0000000 --- a/src/netx/handlers.ts +++ /dev/null @@ -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 = { - 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, - 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 { - let page = clampInt(num(args, 'page'), 1, 1, 2) - const pageSize = clampInt(num(args, 'page_size'), 50, 1, 500) - const params: Record = { 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 { - const params: Record = {} - 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 { - if (str(args, 'group_by').trim()) return aggregateUmeAlarmsRaw(client, args, signal) - const topNe = clampInt(num(args, 'top_ne'), 50, 0, 500) - const params: Record = { 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 { - return client.get('/v1/ume/diagnostics', undefined, signal) -} - -export async function queryUmeNeInventory(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise { - const params: Record = { - 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 { - 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 { - const params: Record = { - 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 { - return client.get('/v1/ume/alarms/fields', undefined, signal) -} - -export async function sqlQueryUme(client: NetxClient, args: NetxJson, signal?: AbortSignal): Promise { - 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 { - 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 = { - 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 { - 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: '' }, - } - } - 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 { - 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 { - 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 { - const params: Record = { - 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 { - 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 { - 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> = [] - 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 = {} - 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 { - 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 { - 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 = {} - 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 { - const batchId = str(args, 'batch_id').trim() - if (!batchId) return { ok: false, error: 'batch_id_required' } - const params: Record = {} - 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 { - const projectId = str(args, 'project_id').trim() - if (!projectId) return { ok: false, error: 'project_id_required' } - const params: Record = {} - 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 { - const runId = str(args, 'run_id').trim() - if (!runId) return { ok: false, error: 'run_id_required' } - const params: Record = {} - 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 { - 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 { - 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, - ) -} diff --git a/src/netx/heal-profile-entry.ts b/src/netx/heal-profile-entry.ts index 04e7a19..22a2bcf 100644 --- a/src/netx/heal-profile-entry.ts +++ b/src/netx/heal-profile-entry.ts @@ -54,6 +54,8 @@ export const NETXOPS_HOST_CONFIG_DEFAULTS: Readonly string - /** Read the latest apiUrl on each request (settings may heal after first mount). */ - getApiUrl?: () => string -} - export type NetxJson = Record 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 = { - 其他: '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 { - 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 => { - 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 - body?: NetxJson - timeoutMs?: number - signal?: AbortSignal - } = {}, - ): Promise { - 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 = { ...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 = { - 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, 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 - -/** Path-segment encode for NE ids (match urllib.parse.quote(..., safe='')). */ -export function quoteNeId(neId: string): string { - return encodeURIComponent(neId.trim()) -} diff --git a/src/netx/mcp-catalog.ts b/src/netx/mcp-catalog.ts new file mode 100644 index 0000000..ebf77bc --- /dev/null +++ b/src/netx/mcp-catalog.ts @@ -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 { + const out = new Set() + 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, +): 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() +} diff --git a/src/netx/mcp-remote.ts b/src/netx/mcp-remote.ts new file mode 100644 index 0000000..4f8fcdf --- /dev/null +++ b/src/netx/mcp-remote.ts @@ -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 `/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 + 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 + : { type: 'object', properties: {} } + const annotations = row.annotations && typeof row.annotations === 'object' && !Array.isArray(row.annotations) + ? row.annotations as Record + : {} + const readOnlyHint = annotations.readOnlyHint === true + const meta = row._meta && typeof row._meta === 'object' && !Array.isArray(row._meta) + ? row._meta as Record + : {} + const netx = meta.netx && typeof meta.netx === 'object' && !Array.isArray(meta.netx) + ? meta.netx as Record + : {} + 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 + 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, + notification = false, +): Promise { + const headers: Record = { + '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 { + 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, +): Promise { + 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 + 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 +} diff --git a/src/netx/mcp-store.ts b/src/netx/mcp-store.ts new file mode 100644 index 0000000..e23304e --- /dev/null +++ b/src/netx/mcp-store.ts @@ -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 + /** 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 { + 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 + } +} diff --git a/src/netx/mcp-types.ts b/src/netx/mcp-types.ts new file mode 100644 index 0000000..3ffc1d1 --- /dev/null +++ b/src/netx/mcp-types.ts @@ -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 + /** 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 +} diff --git a/src/netx/runtime.ts b/src/netx/runtime.ts index 26658e8..7ab1757 100644 --- a/src/netx/runtime.ts +++ b/src/netx/runtime.ts @@ -64,6 +64,8 @@ export function provisionalNetxConnection(): NetxToolConnection { : 'zh'), toolCallTimeoutMs: 120_000, groups: DEFAULT_CAPABILITY_GROUPS, + disabledTools: [], + readOnlyOnly: false, groupKbInPreset: true, groupKbPublic: false, } diff --git a/src/netx/tools.ts b/src/netx/tools.ts index 3e25f7f..037eaa2 100644 --- a/src/netx/tools.ts +++ b/src/netx/tools.ts @@ -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 -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): Record { + if (schema && typeof schema === 'object' && !Array.isArray(schema)) { + const props = schema.properties + if (props && typeof props === 'object' && !Array.isArray(props)) { + return props as Record + } + // 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, - handler: Handler, - getClient: () => NetxClient, +function defineRemoteTool( + tool: NetxMcpToolDescriptor, timeoutMs: number, -) { +): ReturnType { + 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, + ) + 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 }, }) } @@ -126,14 +150,11 @@ export interface RegisterNetxToolsOptions { /** When set, register exactly these groups (ignore plane flags). */ forceGroups?: readonly NetxCapabilityGroupId[] source?: string + presetId?: string } /** - * Register Netx Ops tools for one exposure plane against the connection snapshot. - * Bearer is read from the live process store on each request (not frozen here). - * @param ctx - context with `tools`. - * @param connection - apiUrl / token / lang / timeout / groups. - * @param options - which plane's group flags to honor. + * Register Netx Ops tools for one exposure plane from the cached MCP catalog. * @returns disposer that unregisters every tool. */ export function registerNetxTools( @@ -141,16 +162,25 @@ export function registerNetxTools( connection: NetxToolConnection, options: RegisterNetxToolsOptions, ): () => void { - const enabledGroups = connection.toolsEnabled === false ? [] : (options.forceGroups - ?? groupsForPlane(connection.groups, options.plane, options.only)) const id = getToolMountId(ctx, options.source ?? options.plane) - const allow = toolNamesForGroups(enabledGroups) - if (allow.size === 0) { + const enabled = resolveEnabledTools(getMcpCatalogTools(), { + toolsEnabled: connection.toolsEnabled, + groups: connection.groups, + disabledTools: connection.disabledTools, + readOnlyOnly: connection.readOnlyOnly, + plane: options.plane, + forceGroups: options.forceGroups, + only: options.only, + presetId: options.presetId, + }) + const groups = [...new Set(enabled.map(tool => tool.group).filter(Boolean))] as NetxCapabilityGroupId[] + + if (enabled.length === 0) { publishToolMountStatus({ id, version: packageVersion(), plane: options.plane, - groups: [...enabledGroups], + groups, names: [], count: 0, at: new Date().toISOString(), @@ -159,598 +189,47 @@ export function registerNetxTools( return () => { removeToolMountStatus(id) } } - // Guard: DSH defineTool throws if timeoutMs is null/NaN/≤0 (settings projection can emit null). const t = typeof connection.toolCallTimeoutMs === 'number' && Number.isFinite(connection.toolCallTimeoutMs) && connection.toolCallTimeoutMs > 0 ? connection.toolCallTimeoutMs : 120_000 - const client = createNetxClient({ - apiUrl: connection.apiUrl, - lang: connection.lang, - timeoutMs: Math.min(t, 45_000), - getToken: () => { - const live = getNetxConnection() - // A resolved empty token means revoked; never resurrect an ambient token. - return live ? live.token.trim() : connection.token.trim() - }, - // Live apiUrl: settings may heal null→default after the first mount. - getApiUrl: () => getNetxConnection()?.apiUrl ?? connection.apiUrl, - }) - const getClient = () => client - const catalog = [ - tool( - 'netx__queryNmsAlarms', - 'Query NMS current alarms (each row includes host_name). Supports severity/ne_id/host_name/keyword, last_seen time_from/time_to, pagination. Prefer host_name for display; ne_id is for filters only.', - { - severity: str(), - ne_id: str('Filter only; do not show UUID to users'), - host_name: str('Filter by NE host_name'), - ne_name: str('Legacy alias mapped to keyword'), - keyword: str('Substring on cause/object/event. Examples: LOS, Fiber Break, bandwidth, CRC.'), - time_from: str('ISO time; filters last_seen_at >='), - time_to: str('ISO time; filters last_seen_at <='), - page: num(), - page_size: num(), - }, - H.queryUmeAlarms, getClient, t, - ), - tool( - 'netx__aggregateNmsAlarms', - 'Aggregate NMS current alarms (by_severity + top by_ne). If group_by is set, routes to aggregateUmeAlarmsRaw. Always filter severity/keyword/time before paging.', - { - severity: str('Optional perceived_severity filter (critical/major/minor/warning).'), - top_ne: num('Max NE buckets (default 50). Ignored when group_by is set.'), - exclude_missing_host: bool('Omit missing host_name from by_ne.'), - time_from: str(), - time_to: str(), - group_by: str('When set, routes to raw aggregation. Prefer alarm_host_name.'), - group_by2: str(), - is_cleared: str(), - ne_id: str(), - event_type: str(), - keyword: str(), - limit: num(), - }, - H.aggregateUmeAlarms, getClient, t, - ), - tool( - 'netx__runNmsDiagnostics', - 'NMS alarm diagnostics: severity, top_event_types, top_alarm_codes, top_ne, protocol buckets, freshness meta.', - {}, - H.runUmeDiagnostics, getClient, t, - ), - tool( - 'netx__queryNmsNeInventory', - 'Paged NMS NE inventory synced in netx (keyword matches ne_id/ne_name/user_label/ip/host_name).', - { - keyword: str(), - page: num(), - page_size: num(), - }, - H.queryUmeNeInventory, getClient, t, - ), - tool( - 'netx__getNmsNe', - 'Get single NMS NE detail by ne_id (UUID).', - { - ne_id: { type: 'string' as const, required: true as const, description: 'NMS inventory ne_id (UUID).' }, - }, - H.getUmeNe, getClient, t, - ), - tool( - 'netx__queryNmsAlarmsRaw', - 'Power query NMS current alarms with full alarm_* + ne_* fields; optional field_preset or select_fields. Use field_preset=evidence for citations.', - { - severity: str(), - is_cleared: str(), - ne_id: str(), - event_type: str(), - keyword: str(), - time_from: str(), - time_to: str(), - order_by: str('last_seen_at | time_created | perceived_severity | event_type | ne_id'), - order: str('asc | desc'), - select_fields: strArr(), - field_preset: str('brief | evidence | ne_debug'), - page: num(), - page_size: num(), - }, - H.queryUmeAlarmsRaw, getClient, t, - ), - tool( - 'netx__aggregateNmsAlarmsRaw', - 'Dynamic aggregation on NMS raw fields (group_by/group_by2); prefer alarm_host_name.', - { - group_by: { type: 'string' as const, required: true as const }, - group_by2: str(), - severity: str(), - is_cleared: str(), - ne_id: str(), - event_type: str(), - keyword: str(), - time_from: str(), - time_to: str(), - exclude_missing_host: bool(), - limit: num(), - }, - H.aggregateUmeAlarmsRaw, getClient, t, - ), - tool( - 'netx__listNmsAlarmFields', - 'List available fields for NMS raw alarm queries.', - {}, - H.listUmeAlarmFields, getClient, t, - ), - tool( - 'netx__sqlQueryNms', - 'Read-only SELECT on NMS tables (ume_alarms_current/ume_inventory_ne); server enforces limits. Requires sql:query scope.', - { - sql: { type: 'string' as const, required: true as const }, - limit: num(), - statement_timeout_ms: num(), - }, - H.sqlQueryUme, getClient, t, - ), - tool( - 'netx__listManagedNe', - 'List filtered netx managed NEs (keyword/vendor/connect_status required); use before execManagedNe.', - { - keyword: str(), - vendor: str(), - connect_status: str('unknown | testing | pass | fail'), - page: num(), - page_size: num(), - }, - H.listManagedNe, getClient, t, - ), - tool( - 'netx__getManagedNe', - 'Get one managed NE by managed ne_id (from listManagedNe / listCliTargets source=managed). Response includes capability (device_family, exec_policy_effective, recommended_mode, hints) — read before complex execManagedNe. Do NOT pass NMS inventory UUID here.', - { - ne_id: str('Managed NE id'), - managed_ne_id: str('Alias for ne_id'), - id: str('Alias for ne_id'), - }, - H.getManagedNe, getClient, t, - ), - tool( - 'netx__execManagedNe', - 'Run CLI via netx (readonly show/display/ping/traceroute; linux_shell on Linux/MikroTik allows scripts). Single NE: ne_id OR nms_ne_id + commands. Many NEs: ne_ids[]/nms_ne_ids[] + shared commands, or targets[{nms_ne_id|ne_id, commands}]. Do NOT loop one-NE calls for multi-NE work. Long or multi-NE: async=true (or auto when ≥4 NEs) returns job_id — poll getNeExecJob. Read getManagedNe.capability first.', - { - ne_id: str(), - nms_ne_id: str('NMS inventory id; alias ume_ne_id'), - ume_ne_id: str('Legacy alias of nms_ne_id'), - ne_ids: strArr('Managed NE ids for concurrent batch (shared commands).'), - nms_ne_ids: strArr('NMS inventory ids; alias ume_ne_ids'), - ume_ne_ids: strArr('Legacy alias of nms_ne_ids'), - targets: { - type: 'array' as const, - description: 'Per-NE command sets: each item is one NE (ne_id OR nms_ne_id) with commands[].', - items: { - type: 'object' as const, - additionalProperties: false, - properties: { - ne_id: str(), - nms_ne_id: str(), - ume_ne_id: str(), - commands: strArr(), - }, - }, - }, - commands: strArr('Commands for single NE, or shared commands for batch.'), - read_timeout_sec: num('Per-command read timeout (default 60; use 90–120 for slow show).'), - concurrency: num('Parallel NEs for batch mode (1–8, default 4).'), - async: bool('true=background job_id + getNeExecJob; false=force sync; omit=auto for ~4+ NEs (NETX_NE_EXEC_ASYNC_MIN_NES).'), - }, - H.execManagedNe, getClient, Math.max(t, 300_000), - ), - tool( - 'netx__getNeExecJob', - 'Poll a background execManagedNe job (job_id from async ack). When terminal=true, result holds exec/exec-batch payload. Do not busy-wait in the same turn.', - { - job_id: str('Job id from execManagedNe async response.'), - }, - H.getNeExecJob, getClient, Math.max(t, 60_000), - ), - tool( - 'netx__listCliTargets', - 'List CLI-capable targets (managed NE and/or NMS inventory). Call once per session with keyword/source, cache ids, then execManagedNe.', - { - source: str('managed | nms | ume | all'), - keyword: str(), - page: num(), - page_size: num(), - }, - H.listCliTargets, getClient, t, - ), - tool( - 'netx__findTopologyPaths', - 'Find up to max_paths simple paths between two fabric nodes (common group — native netx, not NMS-bound). For each endpoint provide exactly one of ume_ne_id or managed_ne_id.', - { - from_nms_ne_id: str('NMS inventory id'), - from_ume_ne_id: str('Legacy alias of from_nms_ne_id'), - from_managed_ne_id: str(), - to_nms_ne_id: str('NMS inventory id'), - to_ume_ne_id: str('Legacy alias of to_nms_ne_id'), - to_managed_ne_id: str(), - max_paths: num(), - max_hops: num(), - layer: str(), - detail: str('summary | full'), - }, - H.findTopologyPaths, getClient, t, - ), - // ── bizMonitor group (cutover / biz_state read via netx REST) ───────── - tool( - 'netx__listBizMonitors', - 'Catalog: list cutover/monitor-compare projects and biz_state monitor tasks. No project_id needed — call this first when the user did not give an id, then listBizMonitorBatches / getBizMonitorContext.', - { - kind: str('all | projects | tasks (default all)'), - purpose: str('Filter tasks: portrait | cutover_hf | empty=all'), - status: str('Filter by status (e.g. active / paused)'), - q: str('Keyword on name / ne_name / note / id'), - limit: num('Max rows per list (default 100, max 500)'), - }, - H.listBizMonitors, getClient, t, - ), - tool( - 'netx__listBizMonitorBatches', - 'List batch ids for further analysis. project_id → cutover batches; task_id → biz_state collect batches; batch_id + kind=runs → evaluate runs. Optional include_runs attaches recent runs onto cutover batches.', - { - kind: str('auto | all | cutover | collect | runs (default auto)'), - project_id: str('Cutover project id → cutover_batches'), - task_id: str('biz_state task id → collect_batches'), - batch_id: str('Cutover batch id (filter or kind=runs)'), - include_runs: bool('Attach recent evaluate runs onto listed cutover batches'), - status: str('Filter batch status'), - limit: num('Max batches (default 50)'), - runs_limit: num('Max runs per batch when include_runs / kind=runs (default 10)'), - }, - H.listBizMonitorBatches, getClient, t, - ), - tool( - 'netx__getBizMonitorContext', - 'Biz-monitor definition bundle: cutover project + monitor/compare templates + iface normalize + port mapping + related tasks/commands. Prefer project_id from listBizMonitors. Display keys are device-raw A/B.', - { - project_id: str('Cutover / biz-migration project id'), - task_id: str('biz_state task id (optional if project_id set)'), - }, - H.getBizMonitorContext, getClient, t, - ), - tool( - 'netx__getBizMonitorBoard', - 'Cutover batch board: project/batch status, evaluate run summary, sheet cards, verdict counts, metric_batches.', - { - batch_id: { type: 'string' as const, required: true as const }, - run_id: str('Optional evaluate run id'), - }, - H.getBizMonitorBoard, getClient, t, - ), - tool( - 'netx__listBizMonitorReds', - 'Open/carried red tickets. Each item has old_key/new_key (raw A/B) and evidence (device, collect time, show command, iface raw→norm→map).', - { - project_id: { type: 'string' as const, required: true as const }, - status: str('open|carried|resolved or empty=all'), - limit: num(), - }, - H.listBizMonitorReds, getClient, t, - ), - tool( - 'netx__getBizMonitorDiffs', - 'Paged evaluate diffs. Rows expose old_key/new_key (raw A/B), match_* keys, status, evidence with show commands.', - { - run_id: { type: 'string' as const, required: true as const }, - metric_id: str(), - sheet_id: str(), - verdict: str(), - color: str('e.g. red'), - q: str(), - limit: num(), - offset: num(), - }, - H.getBizMonitorDiffs, getClient, t, - ), - tool( - 'netx__getBizCollectBatch', - 'One biz_state collect batch: commands (raw_command, parse_status, preview), parsed metrics.', - { - batch_id: { type: 'string' as const, required: true as const }, - }, - H.getBizCollectBatch, getClient, t, - ), - tool( - 'netx__getBizCollectCommandRaw', - 'Full CLI raw_text for one collect command inside a batch (deep dive).', - { - batch_id: { type: 'string' as const, required: true as const }, - command_id: { type: 'string' as const, required: true as const }, - }, - H.getBizCollectCommandRaw, getClient, t, - ), - // ── topology group (netx-topology canvas / fabric) ───────────────────── - tool( - 'netx__getTopologyTree', - 'Get topology folder tree (nav roots + Root map canvases). Start here before createTopologyFolder.', - { compact: bool(), max_depth: num() }, - T.getTopologyTree, getClient, t, - ), - tool( - 'netx__getTopologyView', - 'Get a topology view by view_id. Default detail=summary with sample_nodes + links[].', - { - view_id: { type: 'string' as const, required: true as const }, - detail: str('summary | full'), - sample: num(), - }, - T.getTopologyView, getClient, t, - ), - tool( - 'netx__createTopologyFolder', - 'Create topology folders / region canvases. Only way to create canvases (ne:write).', - { - name: { type: 'string' as const, required: true as const }, - parent_id: str(), - locale: str('zh | en'), - sort_order: num(), - }, - T.createTopologyFolder, getClient, t, - ), - tool( - 'netx__addTopologyViewNodes', - 'Bulk-place fabric nodes on a view via filters or fabric_node_ids (never managed/UME ids).', - { - view_id: { type: 'string' as const, required: true as const }, - max_nodes: num(), - keyword: str(), - role: str(), - vendor: str(), - link_status: str(), - limit: num(), - offset: num(), - fabric_node_ids: strArr(), - layout: str('grid | keep'), - }, - T.addTopologyViewNodes, getClient, t, - ), - tool( - 'netx__removeTopologyViewNodes', - 'Remove placements from a view (not fabric) by filters or fabric_node_ids.', - { - view_id: { type: 'string' as const, required: true as const }, - keyword: str(), - role: str(), - vendor: str(), - link_status: str(), - fabric_node_ids: strArr(), - }, - T.removeTopologyViewNodes, getClient, t, - ), - tool( - 'netx__copyTopologyViewNodes', - 'Clone fabric placements from source_view_id onto target_view_id (optional clear_target / copy_positions).', - { - source_view_id: { type: 'string' as const, required: true as const }, - target_view_id: { type: 'string' as const, required: true as const }, - copy_positions: bool(), - clear_target: bool(), - offset_x: num(), - offset_y: num(), - limit: num(), - dry_run: bool(), - }, - T.copyTopologyViewNodes, getClient, t, - ), - tool( - 'netx__updateTopologyViewPositions', - 'Move nodes on a view via positions[] or layout=grid|offset|stack + filters.', - { - view_id: { type: 'string' as const, required: true as const }, - positions: { - type: 'array' as const, - description: 'Manual placements: [{ fabric_node_id, x, y }, …]', - items: { - type: 'object' as const, - additionalProperties: false, - properties: { - fabric_node_id: str(), - x: num(), - y: num(), - }, - }, - }, - layout: str('grid | offset | stack'), - keyword: str(), - role: str(), - vendor: str(), - link_status: str(), - fabric_node_ids: strArr(), - offset_x: num(), - offset_y: num(), - }, - T.updateTopologyViewPositions, getClient, t, - ), - tool( - 'netx__projectTopologyNeighbors', - 'Project existing fabric neighbors of on-view nodes onto the canvas.', - { - view_id: { type: 'string' as const, required: true as const }, - seed_fabric_node_ids: strArr(), - managed_ne_ids: strArr(), - region_folder_id: str(), - detail: str('summary | full'), - sample: num(), - }, - T.projectTopologyNeighbors, getClient, t, - ), - tool( - 'netx__queryTopologyFabricNodes', - 'Fabric inventory: mode=summary|list|search (keyword/role/level/region/link_status).', - { - mode: str('summary | list | search'), - q: str(), - keyword: str(), - role: str(), - level: str(), - level_major: str(), - region_folder_id: str(), - link_status: str(), - page: num(), - page_size: num(), - limit: num(), - summary: bool(), - }, - T.queryTopologyFabricNodes, getClient, t, - ), - tool( - 'netx__classifyTopologyFabricNodes', - 'Classify fabric nodes: action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules.', - { - action: { type: 'string' as const, required: true as const }, - pattern: str(), - q: str(), - match_field: str(), - sample_limit: num(), - fabric_node_ids: strArr(), - fabric_node_id: str(), - node_id: str(), - level: str(), - role: str(), - region_folder_id: str(), - clear_region: bool(), - dry_run: bool(), - kind: str(), - page: num(), - page_size: num(), - skip_manual: bool(), - overwrite_manual: bool(), - fill_empty_only: bool(), - }, - T.classifyTopologyFabricNodes, getClient, t, - ), - tool( - 'netx__queryTopologyNeighborhood', - 'Neighborhood around a fabric node (depth 1–3) with compact links[].', - { - node_id: { type: 'string' as const, required: true as const }, - depth: num(), - layer: str(), - }, - T.queryTopologyNeighborhood, getClient, t, - ), - tool( - 'netx__queryTopologyEdges', - 'Fabric adjacency links[] (detail=adjacency) or port rows (detail=ports).', - { - node_id: str(), - keyword: str(), - layer: str(), - status: str(), - source: str('lldp | ume | manual'), - detail: str('adjacency | ports'), - page: num(), - page_size: num(), - }, - T.queryTopologyEdges, getClient, t, - ), - tool( - 'netx__suggestSinkHubs', - 'Rank hub territories on a source view for non-dual move_nodes(park) batches (degree stand-in in DSH).', - { - source_view_id: str(), - view_id: str(), - pick: num(), - exclude_portal_ids: strArr(), - exclude_fabric_node_ids: strArr(), - }, - T.suggestSinkHubs, getClient, t, - ), - tool( - 'netx__analyzeTopologyViewLayout', - 'Basic layout QA for a view (bbox/hubs). Full dual_unit/crossing scores need netx-topology MCP.', - { - view_id: str(), - folder_id: str(), - detail: str('summary | structure | hotspots | blocks | both'), - score_profile: str(), - sight_limit: num(), - max_views: num(), - min_nodes: num(), - max_nodes: num(), - with_meta: bool(), - }, - T.analyzeTopologyViewLayout, getClient, t, - ), - tool( - 'netx__sinkTopologyDualUnits', - 'Drain dual_unit eyes from source to sink (requires netx-topology MCP layout engine in DSH).', - { - source_view_id: str(), - sink_view_id: str(), - max_units: num(), - min_nodes: num(), - max_nodes: num(), - max_batch_nodes: num(), - layout_batch: bool(), - until_empty: bool(), - dry_run: bool(), - }, - T.sinkTopologyDualUnits, getClient, t, - ), - tool( - 'netx__layoutTopologyView', - 'Layout / polish a canvas. DSH supports catalog + move_nodes|sink_nodes over HTTP; dual_unit/orbit/polish recipes need netx-topology MCP.', - { - view_id: str(), - action: str(), - source_view_id: str(), - recipe: str(), - preset: str(), - mode: str('preview | apply'), - tune: bool(), - params: anyObj('Action-specific options (move_nodes / recipes / polish).'), - catalog: bool(), - fabric_node_ids: strArr(), - park: bool(), - copy_positions: bool(), - }, - T.layoutTopologyView, getClient, Math.max(t, 180_000), - ), - ] - - const selected = catalog.filter((entry) => allow.has(entry.name)) - const names = selected.map((entry) => entry.name) const disposers: Array<() => void> = [] + const names: string[] = [] try { - for (const entry of selected) disposers.push(ctx.tools.register(entry)) - publishToolMountStatus({ - id, - version: packageVersion(), - plane: options.plane, - groups: [...enabledGroups], - names, - count: names.length, - at: new Date().toISOString(), - source: options.source ?? options.plane, - }) + for (const tool of enabled) { + const definition = defineRemoteTool(tool, t) + disposers.push(ctx.tools.register(definition)) + names.push(publicToolName(tool.name)) + } } catch (error) { - for (const dispose of disposers.reverse()) dispose() + for (const dispose of disposers.splice(0)) dispose() publishToolMountStatus({ id, version: packageVersion(), plane: options.plane, - groups: [...enabledGroups], + groups, names: [], count: 0, at: new Date().toISOString(), - error: error instanceof Error ? error.message : String(error), + error: String(error), source: options.source ?? options.plane, }) throw error } + publishToolMountStatus({ + id, + version: packageVersion(), + plane: options.plane, + groups, + names, + count: names.length, + at: new Date().toISOString(), + source: options.source ?? options.plane, + }) + return () => { for (const dispose of disposers) dispose() removeToolMountStatus(id) diff --git a/src/netx/topology-handlers.ts b/src/netx/topology-handlers.ts deleted file mode 100644 index c6cdeab..0000000 --- a/src/netx/topology-handlers.ts +++ /dev/null @@ -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() - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 = { - 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 { - 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 = {} - 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 { - 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 { - const params: Record = { - 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 { - 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() - 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 { - 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() - 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 { - 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 { - 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) -} diff --git a/test/capability-groups.test.mjs b/test/capability-groups.test.mjs index 3238b67..8b4f0cf 100644 --- a/test/capability-groups.test.mjs +++ b/test/capability-groups.test.mjs @@ -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', () => { diff --git a/test/group-skills-disabled.test.mjs b/test/group-skills-disabled.test.mjs new file mode 100644 index 0000000..ac37258 --- /dev/null +++ b/test/group-skills-disabled.test.mjs @@ -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() +}) diff --git a/test/mcp-catalog.test.mjs b/test/mcp-catalog.test.mjs new file mode 100644 index 0000000..6068255 --- /dev/null +++ b/test/mcp-catalog.test.mjs @@ -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() + } +}) diff --git a/test/mcp-test-catalog.mjs b/test/mcp-test-catalog.mjs new file mode 100644 index 0000000..5bde92e --- /dev/null +++ b/test/mcp-test-catalog.mjs @@ -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) +} diff --git a/test/publication-lifecycle.test.mjs b/test/publication-lifecycle.test.mjs index d367a15..c9eabf3 100644 --- a/test/publication-lifecycle.test.mjs +++ b/test/publication-lifecycle.test.mjs @@ -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() diff --git a/test/release-package.test.mjs b/test/release-package.test.mjs index 3a10774..6ff1a77 100644 --- a/test/release-package.test.mjs +++ b/test/release-package.test.mjs @@ -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); diff --git a/test/selected-presets.test.mjs b/test/selected-presets.test.mjs index 7ff8f1f..f7e2644 100644 --- a/test/selected-presets.test.mjs +++ b/test/selected-presets.test.mjs @@ -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)