From 277a61894dcfae770d97d6f6a085d3ece9924092 Mon Sep 17 00:00:00 2001 From: oliver Date: Fri, 16 Oct 2026 22:28:26 +0800 Subject: [PATCH] Netx tools: convert MCP inputSchema to the DSH value schema DSL defineTool rejected netx tools whose parameters carry standard JSON Schema constraints ("parameters.page.minimum is not supported by the value schema DSL"), and the old pass-through dropped the top-level required list. - mcp-schema.ts: fold minimum/maxItems/pattern/... into the description, mark required per property, map anyOf/oneOf/type arrays (minus null) to oneOf, default additionalProperties to true. - Test compiles all 39 real /mcp tool schemas through defineTool. Co-Authored-By: Claude Opus 5.5 --- .release-build.json | 35 +- lib/agent-tools-biz-monitor.js | 110 +- lib/agent-tools-common.js | 110 +- lib/agent-tools-nms.js | 110 +- lib/agent-tools-ops.js | 110 +- lib/agent-tools-topology.js | 110 +- lib/agent-tools.js | 110 +- lib/index.js | 110 +- src/netx/mcp-schema.ts | 104 ++ src/netx/tools.ts | 19 +- test/fixtures-netx-mcp-tools.json | 1990 +++++++++++++++++++++++++++++ test/mcp-schema.test.mjs | 51 + 12 files changed, 2852 insertions(+), 117 deletions(-) create mode 100644 src/netx/mcp-schema.ts create mode 100644 test/fixtures-netx-mcp-tools.json create mode 100644 test/mcp-schema.test.mjs diff --git a/.release-build.json b/.release-build.json index 6a7bc5e..400bb14 100644 --- a/.release-build.json +++ b/.release-build.json @@ -2,7 +2,7 @@ "format": 1, "builds": { "lib/agent-tools-biz-monitor.js": { - "output": "2b382bb050cd2bdbba42c018359fcd81fd56f7caa77ab5fcea7dded47841eeb5", + "output": "35b27ce03d1f8fe8b8bf919d1649d0506fd957dbcb1d7856c3991a2781559f11", "inputs": { "package.json": "1bb1fb8d615f49c69665a3a89b5912fa414a3b93aa3c541ce909ff57f97e100d", "pnpm-lock.yaml": "f1209ac70e9c2294e0f300c0aad88b64aeecd6aa19dfc929c5766c7d033615a3", @@ -25,15 +25,16 @@ "src/netx/kb-runtime.ts": "ebf2fd36c407439f1a59344af18d9fe7275825925adbe08719595d06161d6d5b", "src/netx/mcp-catalog.ts": "b79962da8f74f04595ed89b166b851d83cac41e8338b80b7629da2021a2c281d", "src/netx/mcp-remote.ts": "d79925538e6adaccd8dbf8c65a90027d98464d37a57dce00b3e3155c08b02843", + "src/netx/mcp-schema.ts": "2bb2eccd001101bd97389578c627931e89fbab4e057dab500e8b7b8bd655366f", "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": "0dfc3fdada56ab04ce40fecbe6f229aabae4ff6132d60809a8fb8daade948641" + "src/netx/tools.ts": "88c925aba50a7d6b3fc800986789955fd77c7aca8387c399b1ef58edbf7b9139" } }, "lib/agent-tools-common.js": { - "output": "3e51e500080dfad4c0a2ea6060afb1b7fe4e6a349d13030aed7ad37db49eedfa", + "output": "529aa5d79d0c40030920a8b8053797f43812bf6d8fa4cb6df7ea7e62a46e4777", "inputs": { "package.json": "1bb1fb8d615f49c69665a3a89b5912fa414a3b93aa3c541ce909ff57f97e100d", "pnpm-lock.yaml": "f1209ac70e9c2294e0f300c0aad88b64aeecd6aa19dfc929c5766c7d033615a3", @@ -56,15 +57,16 @@ "src/netx/kb-runtime.ts": "ebf2fd36c407439f1a59344af18d9fe7275825925adbe08719595d06161d6d5b", "src/netx/mcp-catalog.ts": "b79962da8f74f04595ed89b166b851d83cac41e8338b80b7629da2021a2c281d", "src/netx/mcp-remote.ts": "d79925538e6adaccd8dbf8c65a90027d98464d37a57dce00b3e3155c08b02843", + "src/netx/mcp-schema.ts": "2bb2eccd001101bd97389578c627931e89fbab4e057dab500e8b7b8bd655366f", "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": "0dfc3fdada56ab04ce40fecbe6f229aabae4ff6132d60809a8fb8daade948641" + "src/netx/tools.ts": "88c925aba50a7d6b3fc800986789955fd77c7aca8387c399b1ef58edbf7b9139" } }, "lib/agent-tools-nms.js": { - "output": "895e3fdf1ab1a9fc9a0fd0d79f2d51b22f164a1a6ea1e0897f975b4b3103d647", + "output": "75b7a381afdae9932b5d2ec9d4ca6fc63aa0e6da1c6ff1466c57759e6a932528", "inputs": { "package.json": "1bb1fb8d615f49c69665a3a89b5912fa414a3b93aa3c541ce909ff57f97e100d", "pnpm-lock.yaml": "f1209ac70e9c2294e0f300c0aad88b64aeecd6aa19dfc929c5766c7d033615a3", @@ -87,15 +89,16 @@ "src/netx/kb-runtime.ts": "ebf2fd36c407439f1a59344af18d9fe7275825925adbe08719595d06161d6d5b", "src/netx/mcp-catalog.ts": "b79962da8f74f04595ed89b166b851d83cac41e8338b80b7629da2021a2c281d", "src/netx/mcp-remote.ts": "d79925538e6adaccd8dbf8c65a90027d98464d37a57dce00b3e3155c08b02843", + "src/netx/mcp-schema.ts": "2bb2eccd001101bd97389578c627931e89fbab4e057dab500e8b7b8bd655366f", "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": "0dfc3fdada56ab04ce40fecbe6f229aabae4ff6132d60809a8fb8daade948641" + "src/netx/tools.ts": "88c925aba50a7d6b3fc800986789955fd77c7aca8387c399b1ef58edbf7b9139" } }, "lib/agent-tools-ops.js": { - "output": "4ece4e983ee4c50a0591a9e601a6cded04f55ebe1c849b4b2eb9d458854e0a18", + "output": "16151108566b1f3313bfaace216ebb84656704c236b90cb468ba7f559dcd5e96", "inputs": { "package.json": "1bb1fb8d615f49c69665a3a89b5912fa414a3b93aa3c541ce909ff57f97e100d", "pnpm-lock.yaml": "f1209ac70e9c2294e0f300c0aad88b64aeecd6aa19dfc929c5766c7d033615a3", @@ -118,15 +121,16 @@ "src/netx/kb-runtime.ts": "ebf2fd36c407439f1a59344af18d9fe7275825925adbe08719595d06161d6d5b", "src/netx/mcp-catalog.ts": "b79962da8f74f04595ed89b166b851d83cac41e8338b80b7629da2021a2c281d", "src/netx/mcp-remote.ts": "d79925538e6adaccd8dbf8c65a90027d98464d37a57dce00b3e3155c08b02843", + "src/netx/mcp-schema.ts": "2bb2eccd001101bd97389578c627931e89fbab4e057dab500e8b7b8bd655366f", "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": "0dfc3fdada56ab04ce40fecbe6f229aabae4ff6132d60809a8fb8daade948641" + "src/netx/tools.ts": "88c925aba50a7d6b3fc800986789955fd77c7aca8387c399b1ef58edbf7b9139" } }, "lib/agent-tools-topology.js": { - "output": "cec2f877d8fe03b5e537c35ec39a0d66c5885910385f690a8a3bcb96e8ad3954", + "output": "2e3b6268a7e11cab0717faaf4d13bc3ee5a195f1aea94960118211f6773dac28", "inputs": { "package.json": "1bb1fb8d615f49c69665a3a89b5912fa414a3b93aa3c541ce909ff57f97e100d", "pnpm-lock.yaml": "f1209ac70e9c2294e0f300c0aad88b64aeecd6aa19dfc929c5766c7d033615a3", @@ -149,15 +153,16 @@ "src/netx/kb-runtime.ts": "ebf2fd36c407439f1a59344af18d9fe7275825925adbe08719595d06161d6d5b", "src/netx/mcp-catalog.ts": "b79962da8f74f04595ed89b166b851d83cac41e8338b80b7629da2021a2c281d", "src/netx/mcp-remote.ts": "d79925538e6adaccd8dbf8c65a90027d98464d37a57dce00b3e3155c08b02843", + "src/netx/mcp-schema.ts": "2bb2eccd001101bd97389578c627931e89fbab4e057dab500e8b7b8bd655366f", "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": "0dfc3fdada56ab04ce40fecbe6f229aabae4ff6132d60809a8fb8daade948641" + "src/netx/tools.ts": "88c925aba50a7d6b3fc800986789955fd77c7aca8387c399b1ef58edbf7b9139" } }, "lib/agent-tools.js": { - "output": "9c51c136d5b5be9de1479abcae7bd4a69a20fe4e1f92562ca6e13e59663669cc", + "output": "d14c5187ce4ea92c7a9e2a54949c6381c8ad9aede9a78264eeaca82d6618de6a", "inputs": { "package.json": "1bb1fb8d615f49c69665a3a89b5912fa414a3b93aa3c541ce909ff57f97e100d", "pnpm-lock.yaml": "f1209ac70e9c2294e0f300c0aad88b64aeecd6aa19dfc929c5766c7d033615a3", @@ -180,11 +185,12 @@ "src/netx/kb-runtime.ts": "ebf2fd36c407439f1a59344af18d9fe7275825925adbe08719595d06161d6d5b", "src/netx/mcp-catalog.ts": "b79962da8f74f04595ed89b166b851d83cac41e8338b80b7629da2021a2c281d", "src/netx/mcp-remote.ts": "d79925538e6adaccd8dbf8c65a90027d98464d37a57dce00b3e3155c08b02843", + "src/netx/mcp-schema.ts": "2bb2eccd001101bd97389578c627931e89fbab4e057dab500e8b7b8bd655366f", "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": "0dfc3fdada56ab04ce40fecbe6f229aabae4ff6132d60809a8fb8daade948641" + "src/netx/tools.ts": "88c925aba50a7d6b3fc800986789955fd77c7aca8387c399b1ef58edbf7b9139" } }, "lib/client.js": { @@ -219,7 +225,7 @@ } }, "lib/index.js": { - "output": "778c85d88498127abf5bb490b251b8b6f94b3ec38a8d7ad5c46c13854cc65d1f", + "output": "ceb3cb1b0e366d086fdca112904a73d8dbb0c9926064bbe13e8602e86267307d", "inputs": { "package.json": "1bb1fb8d615f49c69665a3a89b5912fa414a3b93aa3c541ce909ff57f97e100d", "pnpm-lock.yaml": "f1209ac70e9c2294e0f300c0aad88b64aeecd6aa19dfc929c5766c7d033615a3", @@ -251,6 +257,7 @@ "src/netx/kb-runtime.ts": "ebf2fd36c407439f1a59344af18d9fe7275825925adbe08719595d06161d6d5b", "src/netx/mcp-catalog.ts": "b79962da8f74f04595ed89b166b851d83cac41e8338b80b7629da2021a2c281d", "src/netx/mcp-remote.ts": "d79925538e6adaccd8dbf8c65a90027d98464d37a57dce00b3e3155c08b02843", + "src/netx/mcp-schema.ts": "2bb2eccd001101bd97389578c627931e89fbab4e057dab500e8b7b8bd655366f", "src/netx/mcp-store.ts": "eb93e466e16c3bca81bff01407689025711be52d55e79acbdfa4bec64bd0a6f3", "src/netx/mcp-types.ts": "4b30d6e8fba328b0e116c43a1a90d2b686ef4b9fc648b521063876426e9c2833", "src/netx/model-language.ts": "07df448b407086237c184a71bfc78e7cb27e50d7f8c499995d8b368b14780c9e", @@ -259,7 +266,7 @@ "src/netx/runtime.ts": "238555c92c3972ee9de8ec062b14d6a86961ec4c64609565ba1164b1a460d386", "src/netx/session-export.ts": "8e6e9a3094045901563366fac3d6dc74d9bfd78bbff853358d6500955d3dcff6", "src/netx/tool-mount-status.ts": "783981774d717ca5e7bf7c0ec7273b40787ef12a6b40749c74543b30fa390faf", - "src/netx/tools.ts": "0dfc3fdada56ab04ce40fecbe6f229aabae4ff6132d60809a8fb8daade948641", + "src/netx/tools.ts": "88c925aba50a7d6b3fc800986789955fd77c7aca8387c399b1ef58edbf7b9139", "src/session-export-shared.ts": "e30d884ec22a0ea2e016c1479f62a8bc8d7ddcce5369a36647d411d07a8620d5" } } diff --git a/lib/agent-tools-biz-monitor.js b/lib/agent-tools-biz-monitor.js index 711f9cb..9368410 100644 --- a/lib/agent-tools-biz-monitor.js +++ b/lib/agent-tools-biz-monitor.js @@ -1803,6 +1803,104 @@ 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/mcp-schema.ts +var ANNOTATIONS = ["description", "title", "default", "examples"]; +var SCALAR_TYPES = new Set(["string", "number", "integer", "boolean", "null"]); +var CONSTRAINTS = [ + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum", + "multipleOf", + "minLength", + "maxLength", + "pattern", + "format", + "minItems", + "maxItems", + "uniqueItems" +]; +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function withAnnotations(source, target) { + for (const key of ANNOTATIONS) { + if (Object.hasOwn(source, key)) + target[key] = source[key]; + } + const notes = CONSTRAINTS.filter((key) => Object.hasOwn(source, key)).map((key) => `${key}=${JSON.stringify(source[key])}`); + if (notes.length > 0) { + const base = typeof target.description === "string" ? target.description : ""; + target.description = `${base}${base ? " " : ""}(${notes.join(", ")})`; + } + return target; +} +function variants(node) { + const list = Array.isArray(node.oneOf) ? node.oneOf : Array.isArray(node.anyOf) ? node.anyOf : undefined; + if (list) + return list.filter(isRecord).filter((branch) => branch.type !== "null"); + if (Array.isArray(node.type)) { + const { type: _types, ...rest } = node; + return node.type.filter((t) => t !== "null").map((t) => ({ ...rest, type: t })); + } + return; +} +function toValue(node) { + if (!isRecord(node)) + return { type: "json" }; + const branches = variants(node); + if (branches) { + if (branches.length === 0) + return withAnnotations(node, { type: "json" }); + if (branches.length === 1) + return withAnnotations(node, toValue({ ...branches[0] })); + return withAnnotations(node, { oneOf: branches.map(toValue) }); + } + const type = node.type; + if (type === "object" || type === undefined && isRecord(node.properties)) { + const out = { + type: "object", + additionalProperties: typeof node.additionalProperties === "boolean" ? node.additionalProperties : true + }; + if (isRecord(node.properties)) + out.properties = toProperties(node.properties, node.required); + return withAnnotations(node, out); + } + if (type === "array") { + const out = { type: "array" }; + if (Object.hasOwn(node, "items")) + out.items = toValue(node.items); + return withAnnotations(node, out); + } + if (typeof type === "string" && SCALAR_TYPES.has(type)) { + const out = { type }; + if (Array.isArray(node.enum) && node.enum.length > 0) + out.enum = node.enum; + if (Object.hasOwn(node, "const")) + out.const = node.const; + return withAnnotations(node, out); + } + return withAnnotations(node, { type: "json" }); +} +function toProperties(properties, required) { + const requiredSet = new Set(Array.isArray(required) ? required.filter((k) => typeof k === "string") : []); + const out = {}; + for (const [key, value] of Object.entries(properties)) { + const converted = toValue(value); + if (requiredSet.has(key)) + converted.required = true; + out[key] = converted; + } + return out; +} +function parametersFromInputSchema(schema) { + if (!isRecord(schema) || !isRecord(schema.properties)) + return {}; + return toProperties(schema.properties, schema.required); +} + +// src/netx/tools.ts function packageVersion() { try { const here = dirname5(fileURLToPath2(import.meta.url)); @@ -1828,18 +1926,6 @@ var mcpOut = { schema: { type: "object", additionalProperties: true }, render: renderMcpResult }; -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({ diff --git a/lib/agent-tools-common.js b/lib/agent-tools-common.js index 4b147e9..e48e445 100644 --- a/lib/agent-tools-common.js +++ b/lib/agent-tools-common.js @@ -1803,6 +1803,104 @@ 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/mcp-schema.ts +var ANNOTATIONS = ["description", "title", "default", "examples"]; +var SCALAR_TYPES = new Set(["string", "number", "integer", "boolean", "null"]); +var CONSTRAINTS = [ + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum", + "multipleOf", + "minLength", + "maxLength", + "pattern", + "format", + "minItems", + "maxItems", + "uniqueItems" +]; +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function withAnnotations(source, target) { + for (const key of ANNOTATIONS) { + if (Object.hasOwn(source, key)) + target[key] = source[key]; + } + const notes = CONSTRAINTS.filter((key) => Object.hasOwn(source, key)).map((key) => `${key}=${JSON.stringify(source[key])}`); + if (notes.length > 0) { + const base = typeof target.description === "string" ? target.description : ""; + target.description = `${base}${base ? " " : ""}(${notes.join(", ")})`; + } + return target; +} +function variants(node) { + const list = Array.isArray(node.oneOf) ? node.oneOf : Array.isArray(node.anyOf) ? node.anyOf : undefined; + if (list) + return list.filter(isRecord).filter((branch) => branch.type !== "null"); + if (Array.isArray(node.type)) { + const { type: _types, ...rest } = node; + return node.type.filter((t) => t !== "null").map((t) => ({ ...rest, type: t })); + } + return; +} +function toValue(node) { + if (!isRecord(node)) + return { type: "json" }; + const branches = variants(node); + if (branches) { + if (branches.length === 0) + return withAnnotations(node, { type: "json" }); + if (branches.length === 1) + return withAnnotations(node, toValue({ ...branches[0] })); + return withAnnotations(node, { oneOf: branches.map(toValue) }); + } + const type = node.type; + if (type === "object" || type === undefined && isRecord(node.properties)) { + const out = { + type: "object", + additionalProperties: typeof node.additionalProperties === "boolean" ? node.additionalProperties : true + }; + if (isRecord(node.properties)) + out.properties = toProperties(node.properties, node.required); + return withAnnotations(node, out); + } + if (type === "array") { + const out = { type: "array" }; + if (Object.hasOwn(node, "items")) + out.items = toValue(node.items); + return withAnnotations(node, out); + } + if (typeof type === "string" && SCALAR_TYPES.has(type)) { + const out = { type }; + if (Array.isArray(node.enum) && node.enum.length > 0) + out.enum = node.enum; + if (Object.hasOwn(node, "const")) + out.const = node.const; + return withAnnotations(node, out); + } + return withAnnotations(node, { type: "json" }); +} +function toProperties(properties, required) { + const requiredSet = new Set(Array.isArray(required) ? required.filter((k) => typeof k === "string") : []); + const out = {}; + for (const [key, value] of Object.entries(properties)) { + const converted = toValue(value); + if (requiredSet.has(key)) + converted.required = true; + out[key] = converted; + } + return out; +} +function parametersFromInputSchema(schema) { + if (!isRecord(schema) || !isRecord(schema.properties)) + return {}; + return toProperties(schema.properties, schema.required); +} + +// src/netx/tools.ts function packageVersion() { try { const here = dirname5(fileURLToPath2(import.meta.url)); @@ -1828,18 +1926,6 @@ var mcpOut = { schema: { type: "object", additionalProperties: true }, render: renderMcpResult }; -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({ diff --git a/lib/agent-tools-nms.js b/lib/agent-tools-nms.js index 7d62d2e..938f600 100644 --- a/lib/agent-tools-nms.js +++ b/lib/agent-tools-nms.js @@ -1803,6 +1803,104 @@ 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/mcp-schema.ts +var ANNOTATIONS = ["description", "title", "default", "examples"]; +var SCALAR_TYPES = new Set(["string", "number", "integer", "boolean", "null"]); +var CONSTRAINTS = [ + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum", + "multipleOf", + "minLength", + "maxLength", + "pattern", + "format", + "minItems", + "maxItems", + "uniqueItems" +]; +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function withAnnotations(source, target) { + for (const key of ANNOTATIONS) { + if (Object.hasOwn(source, key)) + target[key] = source[key]; + } + const notes = CONSTRAINTS.filter((key) => Object.hasOwn(source, key)).map((key) => `${key}=${JSON.stringify(source[key])}`); + if (notes.length > 0) { + const base = typeof target.description === "string" ? target.description : ""; + target.description = `${base}${base ? " " : ""}(${notes.join(", ")})`; + } + return target; +} +function variants(node) { + const list = Array.isArray(node.oneOf) ? node.oneOf : Array.isArray(node.anyOf) ? node.anyOf : undefined; + if (list) + return list.filter(isRecord).filter((branch) => branch.type !== "null"); + if (Array.isArray(node.type)) { + const { type: _types, ...rest } = node; + return node.type.filter((t) => t !== "null").map((t) => ({ ...rest, type: t })); + } + return; +} +function toValue(node) { + if (!isRecord(node)) + return { type: "json" }; + const branches = variants(node); + if (branches) { + if (branches.length === 0) + return withAnnotations(node, { type: "json" }); + if (branches.length === 1) + return withAnnotations(node, toValue({ ...branches[0] })); + return withAnnotations(node, { oneOf: branches.map(toValue) }); + } + const type = node.type; + if (type === "object" || type === undefined && isRecord(node.properties)) { + const out = { + type: "object", + additionalProperties: typeof node.additionalProperties === "boolean" ? node.additionalProperties : true + }; + if (isRecord(node.properties)) + out.properties = toProperties(node.properties, node.required); + return withAnnotations(node, out); + } + if (type === "array") { + const out = { type: "array" }; + if (Object.hasOwn(node, "items")) + out.items = toValue(node.items); + return withAnnotations(node, out); + } + if (typeof type === "string" && SCALAR_TYPES.has(type)) { + const out = { type }; + if (Array.isArray(node.enum) && node.enum.length > 0) + out.enum = node.enum; + if (Object.hasOwn(node, "const")) + out.const = node.const; + return withAnnotations(node, out); + } + return withAnnotations(node, { type: "json" }); +} +function toProperties(properties, required) { + const requiredSet = new Set(Array.isArray(required) ? required.filter((k) => typeof k === "string") : []); + const out = {}; + for (const [key, value] of Object.entries(properties)) { + const converted = toValue(value); + if (requiredSet.has(key)) + converted.required = true; + out[key] = converted; + } + return out; +} +function parametersFromInputSchema(schema) { + if (!isRecord(schema) || !isRecord(schema.properties)) + return {}; + return toProperties(schema.properties, schema.required); +} + +// src/netx/tools.ts function packageVersion() { try { const here = dirname5(fileURLToPath2(import.meta.url)); @@ -1828,18 +1926,6 @@ var mcpOut = { schema: { type: "object", additionalProperties: true }, render: renderMcpResult }; -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({ diff --git a/lib/agent-tools-ops.js b/lib/agent-tools-ops.js index 6bdd43f..52708ba 100644 --- a/lib/agent-tools-ops.js +++ b/lib/agent-tools-ops.js @@ -1803,6 +1803,104 @@ 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/mcp-schema.ts +var ANNOTATIONS = ["description", "title", "default", "examples"]; +var SCALAR_TYPES = new Set(["string", "number", "integer", "boolean", "null"]); +var CONSTRAINTS = [ + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum", + "multipleOf", + "minLength", + "maxLength", + "pattern", + "format", + "minItems", + "maxItems", + "uniqueItems" +]; +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function withAnnotations(source, target) { + for (const key of ANNOTATIONS) { + if (Object.hasOwn(source, key)) + target[key] = source[key]; + } + const notes = CONSTRAINTS.filter((key) => Object.hasOwn(source, key)).map((key) => `${key}=${JSON.stringify(source[key])}`); + if (notes.length > 0) { + const base = typeof target.description === "string" ? target.description : ""; + target.description = `${base}${base ? " " : ""}(${notes.join(", ")})`; + } + return target; +} +function variants(node) { + const list = Array.isArray(node.oneOf) ? node.oneOf : Array.isArray(node.anyOf) ? node.anyOf : undefined; + if (list) + return list.filter(isRecord).filter((branch) => branch.type !== "null"); + if (Array.isArray(node.type)) { + const { type: _types, ...rest } = node; + return node.type.filter((t) => t !== "null").map((t) => ({ ...rest, type: t })); + } + return; +} +function toValue(node) { + if (!isRecord(node)) + return { type: "json" }; + const branches = variants(node); + if (branches) { + if (branches.length === 0) + return withAnnotations(node, { type: "json" }); + if (branches.length === 1) + return withAnnotations(node, toValue({ ...branches[0] })); + return withAnnotations(node, { oneOf: branches.map(toValue) }); + } + const type = node.type; + if (type === "object" || type === undefined && isRecord(node.properties)) { + const out = { + type: "object", + additionalProperties: typeof node.additionalProperties === "boolean" ? node.additionalProperties : true + }; + if (isRecord(node.properties)) + out.properties = toProperties(node.properties, node.required); + return withAnnotations(node, out); + } + if (type === "array") { + const out = { type: "array" }; + if (Object.hasOwn(node, "items")) + out.items = toValue(node.items); + return withAnnotations(node, out); + } + if (typeof type === "string" && SCALAR_TYPES.has(type)) { + const out = { type }; + if (Array.isArray(node.enum) && node.enum.length > 0) + out.enum = node.enum; + if (Object.hasOwn(node, "const")) + out.const = node.const; + return withAnnotations(node, out); + } + return withAnnotations(node, { type: "json" }); +} +function toProperties(properties, required) { + const requiredSet = new Set(Array.isArray(required) ? required.filter((k) => typeof k === "string") : []); + const out = {}; + for (const [key, value] of Object.entries(properties)) { + const converted = toValue(value); + if (requiredSet.has(key)) + converted.required = true; + out[key] = converted; + } + return out; +} +function parametersFromInputSchema(schema) { + if (!isRecord(schema) || !isRecord(schema.properties)) + return {}; + return toProperties(schema.properties, schema.required); +} + +// src/netx/tools.ts function packageVersion() { try { const here = dirname5(fileURLToPath2(import.meta.url)); @@ -1828,18 +1926,6 @@ var mcpOut = { schema: { type: "object", additionalProperties: true }, render: renderMcpResult }; -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({ diff --git a/lib/agent-tools-topology.js b/lib/agent-tools-topology.js index f7e5007..fa186a4 100644 --- a/lib/agent-tools-topology.js +++ b/lib/agent-tools-topology.js @@ -1803,6 +1803,104 @@ 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/mcp-schema.ts +var ANNOTATIONS = ["description", "title", "default", "examples"]; +var SCALAR_TYPES = new Set(["string", "number", "integer", "boolean", "null"]); +var CONSTRAINTS = [ + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum", + "multipleOf", + "minLength", + "maxLength", + "pattern", + "format", + "minItems", + "maxItems", + "uniqueItems" +]; +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function withAnnotations(source, target) { + for (const key of ANNOTATIONS) { + if (Object.hasOwn(source, key)) + target[key] = source[key]; + } + const notes = CONSTRAINTS.filter((key) => Object.hasOwn(source, key)).map((key) => `${key}=${JSON.stringify(source[key])}`); + if (notes.length > 0) { + const base = typeof target.description === "string" ? target.description : ""; + target.description = `${base}${base ? " " : ""}(${notes.join(", ")})`; + } + return target; +} +function variants(node) { + const list = Array.isArray(node.oneOf) ? node.oneOf : Array.isArray(node.anyOf) ? node.anyOf : undefined; + if (list) + return list.filter(isRecord).filter((branch) => branch.type !== "null"); + if (Array.isArray(node.type)) { + const { type: _types, ...rest } = node; + return node.type.filter((t) => t !== "null").map((t) => ({ ...rest, type: t })); + } + return; +} +function toValue(node) { + if (!isRecord(node)) + return { type: "json" }; + const branches = variants(node); + if (branches) { + if (branches.length === 0) + return withAnnotations(node, { type: "json" }); + if (branches.length === 1) + return withAnnotations(node, toValue({ ...branches[0] })); + return withAnnotations(node, { oneOf: branches.map(toValue) }); + } + const type = node.type; + if (type === "object" || type === undefined && isRecord(node.properties)) { + const out = { + type: "object", + additionalProperties: typeof node.additionalProperties === "boolean" ? node.additionalProperties : true + }; + if (isRecord(node.properties)) + out.properties = toProperties(node.properties, node.required); + return withAnnotations(node, out); + } + if (type === "array") { + const out = { type: "array" }; + if (Object.hasOwn(node, "items")) + out.items = toValue(node.items); + return withAnnotations(node, out); + } + if (typeof type === "string" && SCALAR_TYPES.has(type)) { + const out = { type }; + if (Array.isArray(node.enum) && node.enum.length > 0) + out.enum = node.enum; + if (Object.hasOwn(node, "const")) + out.const = node.const; + return withAnnotations(node, out); + } + return withAnnotations(node, { type: "json" }); +} +function toProperties(properties, required) { + const requiredSet = new Set(Array.isArray(required) ? required.filter((k) => typeof k === "string") : []); + const out = {}; + for (const [key, value] of Object.entries(properties)) { + const converted = toValue(value); + if (requiredSet.has(key)) + converted.required = true; + out[key] = converted; + } + return out; +} +function parametersFromInputSchema(schema) { + if (!isRecord(schema) || !isRecord(schema.properties)) + return {}; + return toProperties(schema.properties, schema.required); +} + +// src/netx/tools.ts function packageVersion() { try { const here = dirname5(fileURLToPath2(import.meta.url)); @@ -1828,18 +1926,6 @@ var mcpOut = { schema: { type: "object", additionalProperties: true }, render: renderMcpResult }; -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({ diff --git a/lib/agent-tools.js b/lib/agent-tools.js index a4add75..a7a874a 100644 --- a/lib/agent-tools.js +++ b/lib/agent-tools.js @@ -1803,6 +1803,104 @@ 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/mcp-schema.ts +var ANNOTATIONS = ["description", "title", "default", "examples"]; +var SCALAR_TYPES = new Set(["string", "number", "integer", "boolean", "null"]); +var CONSTRAINTS = [ + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum", + "multipleOf", + "minLength", + "maxLength", + "pattern", + "format", + "minItems", + "maxItems", + "uniqueItems" +]; +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function withAnnotations(source, target) { + for (const key of ANNOTATIONS) { + if (Object.hasOwn(source, key)) + target[key] = source[key]; + } + const notes = CONSTRAINTS.filter((key) => Object.hasOwn(source, key)).map((key) => `${key}=${JSON.stringify(source[key])}`); + if (notes.length > 0) { + const base = typeof target.description === "string" ? target.description : ""; + target.description = `${base}${base ? " " : ""}(${notes.join(", ")})`; + } + return target; +} +function variants(node) { + const list = Array.isArray(node.oneOf) ? node.oneOf : Array.isArray(node.anyOf) ? node.anyOf : undefined; + if (list) + return list.filter(isRecord).filter((branch) => branch.type !== "null"); + if (Array.isArray(node.type)) { + const { type: _types, ...rest } = node; + return node.type.filter((t) => t !== "null").map((t) => ({ ...rest, type: t })); + } + return; +} +function toValue(node) { + if (!isRecord(node)) + return { type: "json" }; + const branches = variants(node); + if (branches) { + if (branches.length === 0) + return withAnnotations(node, { type: "json" }); + if (branches.length === 1) + return withAnnotations(node, toValue({ ...branches[0] })); + return withAnnotations(node, { oneOf: branches.map(toValue) }); + } + const type = node.type; + if (type === "object" || type === undefined && isRecord(node.properties)) { + const out = { + type: "object", + additionalProperties: typeof node.additionalProperties === "boolean" ? node.additionalProperties : true + }; + if (isRecord(node.properties)) + out.properties = toProperties(node.properties, node.required); + return withAnnotations(node, out); + } + if (type === "array") { + const out = { type: "array" }; + if (Object.hasOwn(node, "items")) + out.items = toValue(node.items); + return withAnnotations(node, out); + } + if (typeof type === "string" && SCALAR_TYPES.has(type)) { + const out = { type }; + if (Array.isArray(node.enum) && node.enum.length > 0) + out.enum = node.enum; + if (Object.hasOwn(node, "const")) + out.const = node.const; + return withAnnotations(node, out); + } + return withAnnotations(node, { type: "json" }); +} +function toProperties(properties, required) { + const requiredSet = new Set(Array.isArray(required) ? required.filter((k) => typeof k === "string") : []); + const out = {}; + for (const [key, value] of Object.entries(properties)) { + const converted = toValue(value); + if (requiredSet.has(key)) + converted.required = true; + out[key] = converted; + } + return out; +} +function parametersFromInputSchema(schema) { + if (!isRecord(schema) || !isRecord(schema.properties)) + return {}; + return toProperties(schema.properties, schema.required); +} + +// src/netx/tools.ts function packageVersion() { try { const here = dirname5(fileURLToPath2(import.meta.url)); @@ -1828,18 +1926,6 @@ var mcpOut = { schema: { type: "object", additionalProperties: true }, render: renderMcpResult }; -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({ diff --git a/lib/index.js b/lib/index.js index df9648c..7255423 100644 --- a/lib/index.js +++ b/lib/index.js @@ -3217,7 +3217,7 @@ function watchNetxConnection(listener) { // src/netx/session-export.ts import { homedir as homedir4, hostname as osHostname } from "node:os"; -// ../../netxops/node_modules/.pnpm/fflate@0.8.3/node_modules/fflate/esm/index.mjs +// node_modules/.pnpm/fflate@0.8.3/node_modules/fflate/esm/index.mjs import { createRequire as createRequire3 } from "module"; var require2 = createRequire3("/"); var _a; @@ -4744,6 +4744,102 @@ function mcpContentText(content, fallback = "") { `) || fallback; } +// src/netx/mcp-schema.ts +var ANNOTATIONS = ["description", "title", "default", "examples"]; +var SCALAR_TYPES = new Set(["string", "number", "integer", "boolean", "null"]); +var CONSTRAINTS = [ + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum", + "multipleOf", + "minLength", + "maxLength", + "pattern", + "format", + "minItems", + "maxItems", + "uniqueItems" +]; +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function withAnnotations(source, target) { + for (const key of ANNOTATIONS) { + if (Object.hasOwn(source, key)) + target[key] = source[key]; + } + const notes = CONSTRAINTS.filter((key) => Object.hasOwn(source, key)).map((key) => `${key}=${JSON.stringify(source[key])}`); + if (notes.length > 0) { + const base = typeof target.description === "string" ? target.description : ""; + target.description = `${base}${base ? " " : ""}(${notes.join(", ")})`; + } + return target; +} +function variants(node) { + const list = Array.isArray(node.oneOf) ? node.oneOf : Array.isArray(node.anyOf) ? node.anyOf : undefined; + if (list) + return list.filter(isRecord).filter((branch) => branch.type !== "null"); + if (Array.isArray(node.type)) { + const { type: _types, ...rest } = node; + return node.type.filter((t) => t !== "null").map((t) => ({ ...rest, type: t })); + } + return; +} +function toValue(node) { + if (!isRecord(node)) + return { type: "json" }; + const branches = variants(node); + if (branches) { + if (branches.length === 0) + return withAnnotations(node, { type: "json" }); + if (branches.length === 1) + return withAnnotations(node, toValue({ ...branches[0] })); + return withAnnotations(node, { oneOf: branches.map(toValue) }); + } + const type = node.type; + if (type === "object" || type === undefined && isRecord(node.properties)) { + const out = { + type: "object", + additionalProperties: typeof node.additionalProperties === "boolean" ? node.additionalProperties : true + }; + if (isRecord(node.properties)) + out.properties = toProperties(node.properties, node.required); + return withAnnotations(node, out); + } + if (type === "array") { + const out = { type: "array" }; + if (Object.hasOwn(node, "items")) + out.items = toValue(node.items); + return withAnnotations(node, out); + } + if (typeof type === "string" && SCALAR_TYPES.has(type)) { + const out = { type }; + if (Array.isArray(node.enum) && node.enum.length > 0) + out.enum = node.enum; + if (Object.hasOwn(node, "const")) + out.const = node.const; + return withAnnotations(node, out); + } + return withAnnotations(node, { type: "json" }); +} +function toProperties(properties, required) { + const requiredSet = new Set(Array.isArray(required) ? required.filter((k) => typeof k === "string") : []); + const out = {}; + for (const [key, value] of Object.entries(properties)) { + const converted = toValue(value); + if (requiredSet.has(key)) + converted.required = true; + out[key] = converted; + } + return out; +} +function parametersFromInputSchema(schema) { + if (!isRecord(schema) || !isRecord(schema.properties)) + return {}; + return toProperties(schema.properties, schema.required); +} + // src/netx/mcp-store.ts var STORE_KEY4 = Symbol.for("dsh-netxops.mcp-catalog-store"); var EMPTY = Object.freeze({ @@ -4879,18 +4975,6 @@ var mcpOut = { schema: { type: "object", additionalProperties: true }, render: renderMcpResult }; -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({ diff --git a/src/netx/mcp-schema.ts b/src/netx/mcp-schema.ts new file mode 100644 index 0000000..a9212fb --- /dev/null +++ b/src/netx/mcp-schema.ts @@ -0,0 +1,104 @@ +/** + * Convert netx MCP `inputSchema` (standard JSON Schema) into the DSH value + * schema DSL accepted by `defineTool({ parameters })`. + * + * The DSL is a strict subset: nodes allow only type / enum / const / + * properties / additionalProperties / items / oneOf plus annotations, and + * `required` is a per-property `true`. Range and size constraints + * (`minimum`, `maxItems`, ...) are folded into the description so the model + * still sees them; netx validates arguments server-side anyway. + */ + +type Json = Record + +const ANNOTATIONS = ['description', 'title', 'default', 'examples'] as const +const SCALAR_TYPES = new Set(['string', 'number', 'integer', 'boolean', 'null']) +const CONSTRAINTS = [ + 'minimum', + 'maximum', + 'exclusiveMinimum', + 'exclusiveMaximum', + 'multipleOf', + 'minLength', + 'maxLength', + 'pattern', + 'format', + 'minItems', + 'maxItems', + 'uniqueItems', +] as const + +function isRecord(value: unknown): value is Json { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function withAnnotations(source: Json, target: Json): Json { + for (const key of ANNOTATIONS) { + if (Object.hasOwn(source, key)) target[key] = source[key] + } + const notes = CONSTRAINTS.filter(key => Object.hasOwn(source, key)).map(key => `${key}=${JSON.stringify(source[key])}`) + if (notes.length > 0) { + const base = typeof target.description === 'string' ? target.description : '' + target.description = `${base}${base ? ' ' : ''}(${notes.join(', ')})` + } + return target +} + +/** Branches of anyOf / oneOf / a type array, minus `null` (nullable = optional here). */ +function variants(node: Json): Json[] | undefined { + const list = Array.isArray(node.oneOf) ? node.oneOf : Array.isArray(node.anyOf) ? node.anyOf : undefined + if (list) return list.filter(isRecord).filter(branch => branch.type !== 'null') + if (Array.isArray(node.type)) { + const { type: _types, ...rest } = node + return node.type.filter(t => t !== 'null').map(t => ({ ...rest, type: t })) + } + return undefined +} + +function toValue(node: unknown): Json { + if (!isRecord(node)) return { type: 'json' } + const branches = variants(node) + if (branches) { + if (branches.length === 0) return withAnnotations(node, { type: 'json' }) + if (branches.length === 1) return withAnnotations(node, toValue({ ...branches[0] })) + return withAnnotations(node, { oneOf: branches.map(toValue) }) + } + const type = node.type + if (type === 'object' || (type === undefined && isRecord(node.properties))) { + const out: Json = { + type: 'object', + additionalProperties: typeof node.additionalProperties === 'boolean' ? node.additionalProperties : true, + } + if (isRecord(node.properties)) out.properties = toProperties(node.properties, node.required) + return withAnnotations(node, out) + } + if (type === 'array') { + const out: Json = { type: 'array' } + if (Object.hasOwn(node, 'items')) out.items = toValue(node.items) + return withAnnotations(node, out) + } + if (typeof type === 'string' && SCALAR_TYPES.has(type)) { + const out: Json = { type } + if (Array.isArray(node.enum) && node.enum.length > 0) out.enum = node.enum + if (Object.hasOwn(node, 'const')) out.const = node.const + return withAnnotations(node, out) + } + return withAnnotations(node, { type: 'json' }) +} + +function toProperties(properties: Json, required: unknown): Json { + const requiredSet = new Set(Array.isArray(required) ? required.filter(k => typeof k === 'string') : []) + const out: Json = {} + for (const [key, value] of Object.entries(properties)) { + const converted = toValue(value) + if (requiredSet.has(key)) converted.required = true + out[key] = converted + } + return out +} + +/** `defineTool` parameters (a property map) from an MCP tool `inputSchema`. */ +export function parametersFromInputSchema(schema: unknown): Json { + if (!isRecord(schema) || !isRecord(schema.properties)) return {} + return toProperties(schema.properties, schema.required) +} diff --git a/src/netx/tools.ts b/src/netx/tools.ts index 037eaa2..7deda18 100644 --- a/src/netx/tools.ts +++ b/src/netx/tools.ts @@ -14,6 +14,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools' import type { NetxCapabilityGroupId, NetxCapabilityGroups } from './capability-groups.ts' import { resolveEnabledTools } from './mcp-catalog.ts' import { callMcpTool, mcpContentText, mcpEndpointUrl } from './mcp-remote.ts' +import { parametersFromInputSchema } from './mcp-schema.ts' import { getMcpCatalogTools } from './mcp-store.ts' import { publicToolName, type NetxMcpToolDescriptor } from './mcp-types.ts' import { getNetxConnection } from './runtime.ts' @@ -73,24 +74,6 @@ const mcpOut = { render: renderMcpResult, } -/** - * `defineTool` expects a property map (DSH ParameterSchemaSpec), not a full - * JSON Schema document. MCP `inputSchema` is usually `{ type:'object', properties }`. - */ -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 defineRemoteTool( tool: NetxMcpToolDescriptor, timeoutMs: number, diff --git a/test/fixtures-netx-mcp-tools.json b/test/fixtures-netx-mcp-tools.json new file mode 100644 index 0000000..8594f6c --- /dev/null +++ b/test/fixtures-netx-mcp-tools.json @@ -0,0 +1,1990 @@ +[ + { + "name": "queryNmsAlarms", + "inputSchema": { + "properties": { + "severity": { + "type": "string", + "description": "critical/major/minor/warning" + }, + "is_cleared": { + "type": "string", + "description": "\"False\" = active only; \"True\" = cleared." + }, + "ne_id": { + "type": "string", + "description": "Filter only; do not show UUID to users" + }, + "host_name": { + "type": "string", + "description": "Filter by NE host_name" + }, + "keyword": { + "type": "string", + "description": "Substring on cause/object/event. Examples: LOS, Fiber Break, bandwidth, CRC, BN EMS, dying gasp, License, optical power, BGP Neighbour." + }, + "time_from": { + "type": "string", + "description": "ISO time; filters last_seen_at >=" + }, + "time_to": { + "type": "string", + "description": "ISO time; filters last_seen_at <=" + }, + "page": { + "type": "integer", + "minimum": 1, + "default": 1 + }, + "page_size": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "default": 50 + } + }, + "required": [], + "type": "object", + "additionalProperties": false + } + }, + { + "name": "aggregateNmsAlarms", + "inputSchema": { + "properties": { + "severity": { + "type": "string", + "description": "Optional perceived_severity filter (critical/major/minor/warning)." + }, + "top_ne": { + "type": "integer", + "minimum": 0, + "maximum": 5000, + "default": 50, + "description": "Max NE buckets to return (0 = all; positive limit up to 5000). Ignored when group_by is set." + }, + "exclude_missing_host": { + "type": "boolean", + "default": true, + "description": "Omit (host_name missing) from by_ne / host rankings." + }, + "time_from": { + "type": "string", + "description": "ISO time; filters last_seen_at >=" + }, + "time_to": { + "type": "string", + "description": "ISO time; filters last_seen_at <=" + }, + "group_by": { + "type": "string", + "enum": [ + "alarm_alarm_key", + "alarm_host_name", + "alarm_ne_id", + "alarm_object_name", + "alarm_event_type", + "alarm_native_probable_cause", + "alarm_perceived_severity", + "alarm_is_cleared", + "alarm_time_created", + "alarm_root_cause_alarm_indication", + "ne_ne_id", + "ne_ne_name", + "ne_user_label", + "ne_ip_address", + "ne_ipv6_address", + "ne_ne_type", + "ne_device_level", + "ne_host_name", + "ne_location", + "ne_hardware_version", + "ne_loopback", + "ne_consistent_state", + "ne_interface_version", + "ne_mac", + "ne_admin_status", + "ne_address_type", + "ne_connection_status", + "ne_maintain_status", + "ne_net_mask", + "ne_create_time", + "ne_creator", + "ne_vendor", + "ne_source_type", + "ne_exists" + ], + "description": "Optional. When set, routes to dynamic raw aggregation (same as aggregateNmsAlarmsRaw). Prefer alarm_host_name for NE Top." + }, + "group_by2": { + "type": "string", + "enum": [ + "alarm_alarm_key", + "alarm_host_name", + "alarm_ne_id", + "alarm_object_name", + "alarm_event_type", + "alarm_native_probable_cause", + "alarm_perceived_severity", + "alarm_is_cleared", + "alarm_time_created", + "alarm_root_cause_alarm_indication", + "ne_ne_id", + "ne_ne_name", + "ne_user_label", + "ne_ip_address", + "ne_ipv6_address", + "ne_ne_type", + "ne_device_level", + "ne_host_name", + "ne_location", + "ne_hardware_version", + "ne_loopback", + "ne_consistent_state", + "ne_interface_version", + "ne_mac", + "ne_admin_status", + "ne_address_type", + "ne_connection_status", + "ne_maintain_status", + "ne_net_mask", + "ne_create_time", + "ne_creator", + "ne_vendor", + "ne_source_type", + "ne_exists" + ], + "description": "Optional second group field when group_by is set." + }, + "is_cleared": { + "type": "string", + "description": "Only used when group_by is set." + }, + "ne_id": { + "type": "string", + "description": "Only used when group_by is set." + }, + "event_type": { + "type": "string", + "description": "Only used when group_by is set." + }, + "keyword": { + "type": "string", + "description": "Only used when group_by is set." + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 2000, + "default": 200, + "description": "Bucket limit when group_by is set (default 200)." + } + }, + "required": [], + "type": "object", + "additionalProperties": false + } + }, + { + "name": "runNmsDiagnostics", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object", + "additionalProperties": false + } + }, + { + "name": "queryNmsNeInventory", + "inputSchema": { + "properties": { + "keyword": { + "type": "string" + }, + "page": { + "type": "integer", + "minimum": 1, + "default": 1 + }, + "page_size": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "default": 50 + } + }, + "required": [], + "type": "object", + "additionalProperties": false + } + }, + { + "name": "getNmsNe", + "inputSchema": { + "properties": { + "ne_id": { + "type": "string", + "description": "NMS UUID, or exact host_name / IP." + } + }, + "required": [ + "ne_id" + ], + "type": "object", + "additionalProperties": false + } + }, + { + "name": "queryNmsAlarmsRaw", + "inputSchema": { + "properties": { + "severity": { + "type": "string" + }, + "is_cleared": { + "type": "string" + }, + "ne_id": { + "type": "string" + }, + "event_type": { + "type": "string" + }, + "keyword": { + "type": "string", + "description": "Substring filter; see queryNmsAlarms keyword examples." + }, + "time_from": { + "type": "string" + }, + "time_to": { + "type": "string" + }, + "order_by": { + "type": "string", + "enum": [ + "last_seen_at", + "time_created", + "perceived_severity", + "event_type", + "ne_id" + ] + }, + "order": { + "type": "string", + "enum": [ + "asc", + "desc" + ] + }, + "select_fields": { + "type": "array", + "items": { + "type": "string" + } + }, + "field_preset": { + "type": "string", + "enum": [ + "brief", + "evidence", + "ne_debug" + ] + }, + "page": { + "type": "integer", + "minimum": 1, + "default": 1 + }, + "page_size": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "default": 50 + } + }, + "required": [], + "type": "object", + "additionalProperties": false + } + }, + { + "name": "aggregateNmsAlarmsRaw", + "inputSchema": { + "properties": { + "group_by": { + "type": "string", + "enum": [ + "alarm_alarm_key", + "alarm_host_name", + "alarm_ne_id", + "alarm_object_name", + "alarm_event_type", + "alarm_native_probable_cause", + "alarm_perceived_severity", + "alarm_is_cleared", + "alarm_time_created", + "alarm_root_cause_alarm_indication", + "ne_ne_id", + "ne_ne_name", + "ne_user_label", + "ne_ip_address", + "ne_ipv6_address", + "ne_ne_type", + "ne_device_level", + "ne_host_name", + "ne_location", + "ne_hardware_version", + "ne_loopback", + "ne_consistent_state", + "ne_interface_version", + "ne_mac", + "ne_admin_status", + "ne_address_type", + "ne_connection_status", + "ne_maintain_status", + "ne_net_mask", + "ne_create_time", + "ne_creator", + "ne_vendor", + "ne_source_type", + "ne_exists" + ] + }, + "group_by2": { + "type": "string", + "enum": [ + "alarm_alarm_key", + "alarm_host_name", + "alarm_ne_id", + "alarm_object_name", + "alarm_event_type", + "alarm_native_probable_cause", + "alarm_perceived_severity", + "alarm_is_cleared", + "alarm_time_created", + "alarm_root_cause_alarm_indication", + "ne_ne_id", + "ne_ne_name", + "ne_user_label", + "ne_ip_address", + "ne_ipv6_address", + "ne_ne_type", + "ne_device_level", + "ne_host_name", + "ne_location", + "ne_hardware_version", + "ne_loopback", + "ne_consistent_state", + "ne_interface_version", + "ne_mac", + "ne_admin_status", + "ne_address_type", + "ne_connection_status", + "ne_maintain_status", + "ne_net_mask", + "ne_create_time", + "ne_creator", + "ne_vendor", + "ne_source_type", + "ne_exists" + ] + }, + "severity": { + "type": "string" + }, + "is_cleared": { + "type": "string" + }, + "ne_id": { + "type": "string" + }, + "event_type": { + "type": "string" + }, + "keyword": { + "type": "string" + }, + "time_from": { + "type": "string" + }, + "time_to": { + "type": "string" + }, + "exclude_missing_host": { + "type": "boolean", + "default": true, + "description": "Omit missing host buckets when grouping by host/user_label fields." + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 2000, + "default": 200 + } + }, + "required": [ + "group_by" + ], + "type": "object", + "additionalProperties": false + } + }, + { + "name": "listNmsAlarmFields", + "inputSchema": { + "properties": {}, + "required": [], + "type": "object", + "additionalProperties": false + } + }, + { + "name": "sqlQueryNms", + "inputSchema": { + "properties": { + "sql": { + "type": "string" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 2000, + "default": 200 + }, + "statement_timeout_ms": { + "type": "integer", + "minimum": 0, + "maximum": 30000, + "default": 0 + } + }, + "required": [ + "sql" + ], + "type": "object", + "additionalProperties": false + } + }, + { + "name": "listManagedNe", + "inputSchema": { + "properties": { + "keyword": { + "type": "string" + }, + "vendor": { + "type": "string" + }, + "connect_status": { + "type": "string", + "enum": [ + "unknown", + "testing", + "pass", + "fail" + ] + }, + "page": { + "type": "integer", + "minimum": 1, + "default": 1 + }, + "page_size": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + } + }, + "required": [], + "type": "object", + "additionalProperties": false + } + }, + { + "name": "getManagedNe", + "inputSchema": { + "properties": { + "ne_id": { + "type": "string", + "description": "Managed NE id (not NMS host_name / not NMS ne_id unless they coincide)." + } + }, + "required": [ + "ne_id" + ], + "type": "object", + "additionalProperties": false + } + }, + { + "name": "execManagedNe", + "inputSchema": { + "properties": { + "ne_id": { + "type": "string" + }, + "nms_ne_id": { + "type": "string", + "description": "NMS inventory id (preferred)." + }, + "ne_ids": { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 20, + "description": "Managed NE ids for concurrent batch (shared commands)." + }, + "nms_ne_ids": { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 20, + "description": "NMS inventory ne_ids for concurrent batch (shared commands)." + }, + "targets": { + "type": "array", + "maxItems": 20, + "items": { + "type": "object", + "properties": { + "ne_id": { + "type": "string" + }, + "nms_ne_id": { + "type": "string" + }, + "commands": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "maxItems": 5 + } + }, + "additionalProperties": false + }, + "description": "Preferred for mixed-vendor / per-NE command sets: each item is one NE (ne_id OR nms_ne_id) with its own commands[]. Per-target commands override top-level shared commands. Still one concurrent batch — not N single-NE calls." + }, + "commands": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "maxItems": 5, + "description": "Commands for single NE, or shared commands for ne_ids/nms_ne_ids. Optional fallback for targets that omit per-target commands." + }, + "read_timeout_sec": { + "type": "integer", + "minimum": 10, + "maximum": 120, + "default": 60, + "description": "Per-command read timeout (default 60; use 90–120 for slow show)." + }, + "concurrency": { + "type": "integer", + "minimum": 1, + "maximum": 8, + "default": 4, + "description": "Parallel NEs for batch mode (ignored for single-NE)." + }, + "async": { + "type": "boolean", + "description": "true=background job_id + getNeExecJob; false=force sync; omit=auto for large batches (~4+ NEs, NETX_NE_EXEC_ASYNC_MIN_NES)." + } + }, + "required": [], + "type": "object", + "additionalProperties": false + } + }, + { + "name": "getNeExecJob", + "inputSchema": { + "properties": { + "job_id": { + "type": "string", + "description": "Job id from execManagedNe async response." + } + }, + "required": [ + "job_id" + ], + "type": "object", + "additionalProperties": false + } + }, + { + "name": "listCliTargets", + "inputSchema": { + "properties": { + "source": { + "type": "string", + "enum": [ + "managed", + "nms", + "all" + ], + "default": "all", + "description": "nms selects the configured NMS inventory; managed selects managed NEs." + }, + "keyword": { + "type": "string" + }, + "page": { + "type": "integer", + "minimum": 1, + "default": 1 + }, + "page_size": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "default": 50 + } + }, + "required": [], + "type": "object", + "additionalProperties": false + } + }, + { + "name": "findTopologyPaths", + "inputSchema": { + "properties": { + "from_nms_ne_id": { + "type": "string", + "description": "Source NMS ne_id (from alarm ne_id); mutually exclusive with from_managed_ne_id" + }, + "from_managed_ne_id": { + "type": "string", + "description": "Source managed NE id; mutually exclusive with from_nms_ne_id" + }, + "to_nms_ne_id": { + "type": "string", + "description": "Target NMS ne_id; mutually exclusive with to_managed_ne_id" + }, + "to_managed_ne_id": { + "type": "string", + "description": "Target managed NE id; mutually exclusive with to_nms_ne_id" + }, + "max_paths": { + "type": "integer", + "minimum": 1, + "maximum": 10, + "default": 3 + }, + "max_hops": { + "type": "integer", + "minimum": 1, + "maximum": 12, + "default": 6 + }, + "layer": { + "type": "string", + "default": "physical" + }, + "detail": { + "type": "string", + "enum": [ + "summary", + "full" + ], + "default": "summary", + "description": "summary=compact ops fields; full=include attrs/coords." + } + }, + "required": [], + "type": "object", + "additionalProperties": false + } + }, + { + "name": "getTopologyTree", + "inputSchema": { + "properties": { + "compact": { + "type": "boolean", + "default": true, + "description": "Default true — slim folder/view fields. Pass false for raw API tree." + }, + "max_depth": { + "type": "integer", + "minimum": 0, + "description": "Optional depth limit from root (0 = root only). Omitted = full depth." + } + }, + "required": [], + "type": "object", + "additionalProperties": false + } + }, + { + "name": "getTopologyView", + "inputSchema": { + "properties": { + "view_id": { + "type": "string" + }, + "detail": { + "type": "string", + "enum": [ + "summary", + "full" + ], + "default": "summary" + }, + "sample": { + "type": "integer", + "minimum": 0, + "maximum": 200, + "default": 20, + "description": "Sample node count when detail=summary (use >=node_count for layout study)." + } + }, + "required": [ + "view_id" + ], + "type": "object", + "additionalProperties": false + } + }, + { + "name": "createTopologyFolder", + "inputSchema": { + "properties": { + "name": { + "type": "string", + "description": "Root or sub-region display name" + }, + "parent_id": { + "type": "string", + "description": "Omit for new top-level「根」+根图. Pass 根图/region folder id to create a sub-region under it (not a sibling custom view)." + }, + "locale": { + "type": "string", + "description": "Optional zh|en — labels auto「根图」vs Root map" + }, + "sort_order": { + "type": "integer", + "default": 0 + } + }, + "required": [ + "name" + ], + "type": "object", + "additionalProperties": false + } + }, + { + "name": "addTopologyViewNodes", + "inputSchema": { + "properties": { + "view_id": { + "type": "string" + }, + "max_nodes": { + "type": "integer", + "minimum": 1, + "maximum": 2000, + "description": "Optional: PATCH this view's membership.max_nodes before add (use on physical 根图/region when soft cap blocks members)." + }, + "keyword": { + "type": "string" + }, + "role": { + "type": "string" + }, + "vendor": { + "type": "string" + }, + "link_status": { + "type": "string", + "enum": [ + "linked", + "orphaned", + "managed", + "ume", + "both" + ] + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 2000, + "default": 500 + }, + "offset": { + "type": "integer", + "minimum": 0, + "default": 0 + }, + "fabric_node_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "layout": { + "type": "string", + "enum": [ + "grid", + "keep" + ], + "default": "grid" + } + }, + "required": [ + "view_id" + ], + "type": "object", + "additionalProperties": false + } + }, + { + "name": "removeTopologyViewNodes", + "inputSchema": { + "properties": { + "view_id": { + "type": "string" + }, + "keyword": { + "type": "string" + }, + "role": { + "type": "string" + }, + "vendor": { + "type": "string" + }, + "link_status": { + "type": "string", + "enum": [ + "linked", + "orphaned", + "managed", + "ume", + "both" + ] + }, + "fabric_node_ids": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "view_id" + ], + "type": "object", + "additionalProperties": false + } + }, + { + "name": "sinkTopologyDualUnits", + "inputSchema": { + "properties": { + "source_view_id": { + "type": "string", + "description": "Root / source physical view_id to drain." + }, + "sink_view_id": { + "type": "string", + "description": "Child-region physical view_id receiving batches." + }, + "max_units": { + "type": "integer", + "minimum": 1, + "maximum": 20, + "default": 3, + "description": "Max dual_units per batch (default 3)." + }, + "min_nodes": { + "type": "integer", + "minimum": 2, + "maximum": 200, + "default": 8 + }, + "max_nodes": { + "type": "integer", + "minimum": 2, + "maximum": 400, + "default": 300, + "description": "Max nodes per dual_unit candidate (CN eyes often 150–300)." + }, + "max_batch_nodes": { + "type": "integer", + "minimum": 8, + "maximum": 800, + "default": 400 + }, + "layout_batch": { + "type": "boolean", + "default": true, + "description": "Default true: run layout_dual_unit per unit before parking (old staging/block layout). false = copy source coords only." + }, + "unit_gap": { + "type": "number", + "default": 220, + "description": "Gap between laid-out units in a batch strip." + }, + "until_empty": { + "type": "boolean", + "default": false, + "description": "Loop batches until source empty (watch MCP timeout)." + }, + "max_batches": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "description": "Safety cap when until_empty (default 50)." + }, + "include_leftovers": { + "type": "boolean", + "default": true, + "description": "When dual_units exhausted, move leftover NE chunks." + }, + "keep_layers": { + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "core", + "agg" + ], + "description": "Fabric layers that stay on source (not sunk). Default core+agg so root keeps the gravity bar; pass [] to drain everything (legacy)." + }, + "sink_layers": { + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "access", + "other" + ], + "description": "Only move these layers this phase. Default access+other. Pass [] to allow any non-keep. With by_level=true, auto picks deepest remaining (access→agg→…)." + }, + "by_level": { + "type": "boolean", + "default": false, + "description": "Auto sink deepest remaining non-keep layer each batch (access first, then agg if keep allows)." + }, + "prefer_pure": { + "type": "boolean", + "default": false, + "description": "Legacy: prefer both portals sinkable. Default false so CN/core eyes (portals stay on root) are not deprioritized." + }, + "prefer_core_eye": { + "type": "boolean", + "default": true, + "description": "Alias of prefer_top_eye. Prefer core/agg eyes top→down before access rings." + }, + "prefer_top_eye": { + "type": "boolean", + "default": true, + "description": "Prefer dual_unit portals top→down: core–core, core–agg, agg–agg, then maximize movable coverage." + }, + "dry_run": { + "type": "boolean", + "default": false + }, + "pad": { + "type": "number", + "default": 280, + "description": "Gap between sink hull and parked batch." + }, + "detect_max_units": { + "type": "integer", + "minimum": 20, + "maximum": 300, + "default": 120 + } + }, + "required": [ + "source_view_id", + "sink_view_id" + ], + "type": "object", + "additionalProperties": false + } + }, + { + "name": "copyTopologyViewNodes", + "inputSchema": { + "properties": { + "source_view_id": { + "type": "string", + "description": "Canvas to copy FROM." + }, + "target_view_id": { + "type": "string", + "description": "Canvas to copy TO." + }, + "copy_positions": { + "type": "boolean", + "default": true, + "description": "Copy x/y from source (default true)." + }, + "clear_target": { + "type": "boolean", + "default": false, + "description": "Remove existing target members before copy." + }, + "offset_x": { + "type": "number", + "default": 0, + "description": "Shift copied x by this amount." + }, + "offset_y": { + "type": "number", + "default": 0 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 2000, + "description": "Optional cap on how many source nodes to copy." + }, + "dry_run": { + "type": "boolean", + "default": false + } + }, + "required": [ + "source_view_id", + "target_view_id" + ], + "type": "object", + "additionalProperties": false + } + }, + { + "name": "updateTopologyViewPositions", + "inputSchema": { + "properties": { + "view_id": { + "type": "string" + }, + "layout": { + "type": "string", + "enum": [ + "grid", + "offset", + "stack" + ] + }, + "keyword": { + "type": "string" + }, + "role": { + "type": "string" + }, + "vendor": { + "type": "string" + }, + "link_status": { + "type": "string", + "enum": [ + "linked", + "orphaned", + "managed", + "ume", + "both" + ] + }, + "fabric_node_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "origin_x": { + "type": "number", + "default": 40 + }, + "origin_y": { + "type": "number", + "default": 40 + }, + "gap_x": { + "type": "number", + "default": 180 + }, + "gap_y": { + "type": "number", + "default": 120 + }, + "cols": { + "type": "integer", + "minimum": 0, + "default": 0 + }, + "dx": { + "type": "number", + "default": 0 + }, + "dy": { + "type": "number", + "default": 0 + }, + "positions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "fabric_node_id": { + "type": "string" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "label": { + "type": "string", + "description": "Omit to keep the current label; \"\" clears a rename (follow device name)." + }, + "locked": { + "type": "boolean", + "description": "Omit to keep the lock. Locked nodes are skipped unless locked=false (unlock + move)." + } + }, + "required": [ + "fabric_node_id" + ], + "additionalProperties": false + } + } + }, + "required": [ + "view_id" + ], + "type": "object", + "additionalProperties": false + } + }, + { + "name": "projectTopologyNeighbors", + "inputSchema": { + "properties": { + "view_id": { + "type": "string" + }, + "seed_fabric_node_ids": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Optional fabric node ids already on the view to expand from" + }, + "managed_ne_ids": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Optional managed NE ids to resolve to on-view fabric seeds" + }, + "region_folder_id": { + "type": "string", + "description": "If set, only add neighbors with matching fabric.region_folder_id; skipped peers are counted in out_of_region_skipped." + }, + "dry_run": { + "type": "boolean", + "default": false, + "description": "Preview projected graph without saving placements." + }, + "detail": { + "type": "string", + "enum": [ + "summary", + "full" + ], + "default": "summary" + }, + "sample": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "default": 20 + } + }, + "required": [ + "view_id" + ], + "type": "object", + "additionalProperties": false + } + }, + { + "name": "queryTopologyFabricNodes", + "inputSchema": { + "properties": { + "mode": { + "type": "string", + "enum": [ + "summary", + "list", + "search" + ], + "description": "Omit to auto-pick: q→search, else list." + }, + "q": { + "type": "string", + "description": "Search needle (mode=search); also aliases keyword for list." + }, + "keyword": { + "type": "string", + "description": "List filter keyword; alias of q for search." + }, + "role": { + "type": "string" + }, + "level": { + "type": "string", + "description": "Exact fabric level e.g. 1.1" + }, + "level_major": { + "type": "string", + "description": "Major band e.g. 2 → [2.0, 3.0)" + }, + "region_folder_id": { + "type": "string", + "description": "Filter by topo folder id (UME region / canvas folder)." + }, + "link_status": { + "type": "string", + "enum": [ + "linked", + "orphaned", + "managed", + "ume", + "both" + ] + }, + "page": { + "type": "integer", + "minimum": 1, + "default": 1 + }, + "page_size": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "default": 50 + }, + "limit": { + "type": "integer", + "description": "Alias of page_size" + }, + "summary": { + "type": "boolean", + "default": false, + "description": "Shortcut for mode=summary" + } + }, + "required": [], + "type": "object", + "additionalProperties": false + } + }, + { + "name": "classifyTopologyFabricNodes", + "inputSchema": { + "properties": { + "action": { + "type": "string", + "enum": [ + "match", + "tag", + "patch", + "unmatched", + "preview_rules", + "apply_rules", + "list_rules" + ], + "description": "Required. Typical: match → tag(dry_run) → tag." + }, + "pattern": { + "type": "string", + "description": "Regex for match/tag (aliases q)." + }, + "q": { + "type": "string", + "description": "Alias of pattern." + }, + "match_field": { + "type": "string", + "enum": [ + "name", + "ip", + "name_ip" + ], + "default": "name" + }, + "sample_limit": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 50 + }, + "fabric_node_ids": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Explicit ids for tag (from match)." + }, + "fabric_node_id": { + "type": "string", + "description": "Single id for patch (alias node_id)." + }, + "node_id": { + "type": "string" + }, + "level": { + "description": "Layout rank e.g. 0, 1, 1.1, 2.1, 3. null clears." + }, + "role": { + "type": "string", + "description": "Preset alias → level: external|core|aggregation|access|edge." + }, + "region_folder_id": { + "type": "string", + "description": "Topo region folder id; empty + clear_region clears." + }, + "clear_region": { + "type": "boolean", + "default": false, + "description": "Clear region_folder_id on tag/patch." + }, + "dry_run": { + "type": "boolean", + "default": false, + "description": "tag only: preview without write." + }, + "kind": { + "type": "string", + "enum": [ + "any", + "level", + "role", + "region" + ], + "default": "any", + "description": "unmatched filter (role aliases level)." + }, + "page": { + "type": "integer", + "minimum": 1, + "default": 1 + }, + "page_size": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "default": 50 + }, + "limit": { + "type": "integer", + "description": "Alias of page_size" + }, + "skip_manual": { + "type": "boolean", + "default": true, + "description": "apply_rules: skip manually tagged nodes." + }, + "overwrite_manual": { + "type": "boolean", + "default": false, + "description": "apply_rules shortcut: skip_manual=false." + }, + "fill_empty_only": { + "type": "boolean", + "default": false, + "description": "apply_rules: only fill empty role/region." + } + }, + "required": [ + "action" + ], + "type": "object", + "additionalProperties": false + } + }, + { + "name": "queryTopologyNeighborhood", + "inputSchema": { + "properties": { + "node_id": { + "type": "string" + }, + "depth": { + "type": "integer", + "minimum": 1, + "maximum": 3, + "default": 1 + }, + "layer": { + "type": "string", + "default": "physical" + } + }, + "required": [ + "node_id" + ], + "type": "object", + "additionalProperties": false + } + }, + { + "name": "queryTopologyEdges", + "inputSchema": { + "properties": { + "node_id": { + "type": "string" + }, + "keyword": { + "type": "string" + }, + "layer": { + "type": "string", + "default": "physical" + }, + "status": { + "type": "string", + "enum": [ + "active", + "missing", + "stale" + ] + }, + "source": { + "type": "string", + "enum": [ + "lldp", + "ume", + "manual" + ] + }, + "detail": { + "type": "string", + "enum": [ + "adjacency", + "ports" + ], + "default": "adjacency", + "description": "adjacency (default) = NE pairs; ports = raw port rows in items[]." + }, + "page": { + "type": "integer", + "minimum": 1, + "default": 1 + }, + "page_size": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "default": 100 + } + }, + "required": [], + "type": "object", + "additionalProperties": false + } + }, + { + "name": "suggestSinkHubs", + "inputSchema": { + "properties": { + "source_view_id": { + "type": "string", + "description": "Root / leftover canvas (required). Alias: view_id." + }, + "view_id": { + "type": "string", + "description": "Alias of source_view_id." + }, + "sink_view_id": { + "type": "string", + "description": "Eye sink canvas; ids already there are dropped from batches." + }, + "exclude_portal_ids": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Extra freeze ids (CN portals). Merged with dual_units portals." + }, + "use_source_dual_portals": { + "type": "boolean", + "default": false, + "description": "Exclude portals from source dual_units (default false)." + }, + "use_sink_dual_portals": { + "type": "boolean", + "default": true, + "description": "Exclude portals from sink dual_units when sink_view_id set." + }, + "only_layers": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Hub layers to consider (default [agg, core])." + }, + "min_territory": { + "type": "integer", + "minimum": 0, + "default": 1, + "description": "Skip hubs with remaining_territory below this." + }, + "min_move_n": { + "type": "integer", + "minimum": 1, + "default": 1 + }, + "top_n": { + "type": "integer", + "minimum": 1, + "maximum": 40, + "default": 12 + }, + "pick": { + "type": "integer", + "minimum": 1, + "default": 1, + "description": "1-based batch to expose as batch + move_nodes." + }, + "include_hub": { + "type": "boolean", + "default": true, + "description": "Include hub id in fabric_node_ids when still on source." + } + }, + "required": [], + "type": "object", + "additionalProperties": false + } + }, + { + "name": "analyzeTopologyViewLayout", + "inputSchema": { + "properties": { + "view_id": { + "type": "string", + "description": "Analyze a single topology view" + }, + "folder_id": { + "type": "string", + "description": "Sample physical views under this folder (tree walk)" + }, + "detail": { + "type": "string", + "enum": [ + "summary", + "structure", + "hotspots", + "blocks", + "both" + ], + "default": "summary", + "description": "summary=score only; structure=gravity/hubs/recipe hint (phase 0.5); hotspots|blocks=sight; both=structure+sight (hand-drag)" + }, + "score_profile": { + "type": "string", + "enum": [ + "auto", + "default", + "eye" + ], + "default": "auto", + "description": "Scoring weights. auto→eye for large sparse diagonal canvases; eye softens axis/rings (arc-band dual sinks); default=metro." + }, + "sight_limit": { + "type": "integer", + "minimum": 5, + "maximum": 80, + "default": 40, + "description": "Max crossings / drag candidates when detail≠summary" + }, + "sight_cell": { + "type": "number", + "default": 600, + "description": "Grid cell size (px) for detail=blocks" + }, + "max_views": { + "type": "integer", + "minimum": 1, + "maximum": 80, + "default": 25 + }, + "min_nodes": { + "type": "integer", + "minimum": 0, + "default": 5 + }, + "max_nodes": { + "type": "integer", + "minimum": 1, + "maximum": 2000, + "default": 800 + }, + "with_meta": { + "type": "boolean", + "default": false, + "description": "Include IP/vendor caption line in overlap boxes" + } + }, + "required": [], + "type": "object", + "additionalProperties": false + } + }, + { + "name": "layoutTopologyView", + "inputSchema": { + "properties": { + "view_id": { + "type": "string", + "description": "Target canvas to layout (and write when mode=apply)" + }, + "action": { + "type": "string", + "enum": [ + "layout", + "fix_overlaps", + "layout_dual_unit", + "straighten_channels", + "untangle", + "polish_crossings", + "clear_edge_hits", + "compact_bbox", + "pull_far_chains", + "align_reference", + "orbit_sweep", + "level_bands", + "move_nodes", + "job_status", + "job_cancel" + ], + "default": "layout", + "description": "layout=full recipe; layout_dual_unit=dual-portal eye; level_bands=horizontal layer bands; move_nodes=migrate fabric_node_ids; orbit_sweep=polar sweep; polish_crossings=one-shot cut crossings; clear_edge_hits=eject edge hits (no crossing rise); pull_far_chains=scale far corridors toward portal mid; compact_bbox=gated farthest-K shrink; align_reference=same-network A/B debug only; fix_overlaps; untangle/straighten_channels; job_status|job_cancel." + }, + "source_view_id": { + "type": "string", + "description": "For action=layout: optional load graph (default=view_id). For action=move_nodes: required FROM canvas (view_id is TO). For action=align_reference: same-network reference (debug only)." + }, + "recipe": { + "type": "string", + "enum": [ + "rings", + "corridor", + "compact", + "unstick" + ], + "default": "rings", + "description": "Only for action=layout. rings=petals/min-rings; corridor/compact/unstick=Tutte corridor variants." + }, + "preset": { + "type": "string", + "enum": [ + "loose", + "balanced", + "dense" + ], + "default": "balanced" + }, + "mode": { + "type": "string", + "enum": [ + "preview", + "apply" + ], + "default": "preview", + "description": "preview=no write; apply=PATCH positions" + }, + "tune": { + "type": "boolean", + "default": false, + "description": "Small param sweep for action=layout; zero-overlap first" + }, + "params": { + "type": "object", + "description": "Overrides. layout: target_nn/target_util/…. job_status|job_cancel: job_id (required). layout_dual_unit: unit_id / portal_a+portal_b; require_zero_cross (default false). untangle: max_rounds/max_degree/protect_rigid/focus_ids[]. polish_crossings: portal_ids[]/source_view_ids[], top_n/max_moves/max_sweeps/straighten/untangle_rounds. clear_edge_hits: top_n/thr/margin/max_moves/portal_ids/max_eject_degree/preserve_axis. compact_bbox|pull_far_chains: portal_ids; pull scales toward portal mid (not outer hub); compact defaults farthest-K. orbit_sweep: node_id/pick/round/until_limit/portal_ids[]/freeze_layers[]/freeze_levels[]/max_degree/max_moves/stall_limit/max_stretch/min_delta/max_jump/angle_step/nn_floor; until_limit defaults protect_rigid=portals + objective=crossing; single-node/round default protect_rigid=off. move_nodes: fabric_node_ids[] (required), copy_positions (default true), park, remove_from_source (default true), pad/offset_x/offset_y.", + "properties": { + "job_id": { + "type": "string", + "description": "Required for job_status/job_cancel; returned by background start." + }, + "background": { + "type": "boolean", + "description": "Run apply computation in a durable subprocess and return job_id immediately." + }, + "sync": { + "type": "boolean", + "description": "Force inline computation, disabling automatic background execution." + } + }, + "additionalProperties": true + }, + "catalog": { + "type": "boolean", + "default": false, + "description": "If true, only return action/recipe/preset catalog" + } + }, + "required": [], + "type": "object", + "additionalProperties": false + } + }, + { + "name": "listBizMonitors", + "inputSchema": { + "properties": { + "kind": { + "type": "string", + "enum": [ + "all", + "projects", + "tasks" + ], + "default": "all" + }, + "purpose": { + "type": "string", + "description": "任务用途:portrait / cutover_hf;空为全部" + }, + "status": { + "type": "string", + "description": "本地过滤状态,如 active/paused" + }, + "q": { + "type": "string", + "description": "名称、设备、备注或 ID 关键词" + }, + "limit": { + "type": "integer", + "default": 100, + "minimum": 1, + "maximum": 500 + } + }, + "required": [], + "type": "object", + "additionalProperties": false + } + }, + { + "name": "listBizMonitorBatches", + "inputSchema": { + "properties": { + "kind": { + "type": "string", + "enum": [ + "auto", + "all", + "cutover", + "collect", + "runs" + ], + "default": "auto" + }, + "project_id": { + "type": "string", + "description": "" + }, + "task_id": { + "type": "string", + "description": "" + }, + "batch_id": { + "type": "string", + "description": "割接批次 ID,非采集批次 ID" + }, + "include_runs": { + "type": "boolean", + "default": false + }, + "status": { + "type": "string", + "description": "" + }, + "limit": { + "type": "integer", + "default": 50, + "minimum": 1, + "maximum": 200 + }, + "runs_limit": { + "type": "integer", + "default": 10, + "minimum": 1, + "maximum": 50 + } + }, + "required": [], + "type": "object", + "additionalProperties": false + } + }, + { + "name": "getBizMonitorContext", + "inputSchema": { + "properties": { + "project_id": { + "type": "string", + "description": "" + }, + "task_id": { + "type": "string", + "description": "" + } + }, + "required": [], + "type": "object", + "additionalProperties": false + } + }, + { + "name": "getBizMonitorBoard", + "inputSchema": { + "properties": { + "batch_id": { + "type": "string", + "description": "割接批次 ID" + }, + "run_id": { + "type": "string", + "description": "可选评估运行 ID" + } + }, + "required": [ + "batch_id" + ], + "type": "object", + "additionalProperties": false + } + }, + { + "name": "listBizMonitorReds", + "inputSchema": { + "properties": { + "project_id": { + "type": "string", + "description": "" + }, + "status": { + "type": "string", + "description": "open / carried / resolved;空为全部" + }, + "limit": { + "type": "integer", + "default": 200, + "minimum": 1, + "maximum": 500 + } + }, + "required": [ + "project_id" + ], + "type": "object", + "additionalProperties": false + } + }, + { + "name": "getBizMonitorDiffs", + "inputSchema": { + "properties": { + "run_id": { + "type": "string", + "description": "" + }, + "metric_id": { + "type": "string", + "description": "" + }, + "sheet_id": { + "type": "string", + "description": "" + }, + "verdict": { + "type": "string", + "description": "" + }, + "color": { + "type": "string", + "description": "如 red" + }, + "q": { + "type": "string", + "description": "差异关键词,映射 HTTP kw" + }, + "limit": { + "type": "integer", + "default": 100, + "minimum": 1, + "maximum": 500 + }, + "offset": { + "type": "integer", + "default": 0, + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "run_id" + ], + "type": "object", + "additionalProperties": false + } + }, + { + "name": "getBizCollectBatch", + "inputSchema": { + "properties": { + "batch_id": { + "type": "string", + "description": "业务状态采集批次 ID" + } + }, + "required": [ + "batch_id" + ], + "type": "object", + "additionalProperties": false + } + }, + { + "name": "getBizCollectCommandRaw", + "inputSchema": { + "properties": { + "batch_id": { + "type": "string", + "description": "采集批次 ID" + }, + "command_id": { + "type": "string", + "description": "getBizCollectBatch 返回的命令 ID" + } + }, + "required": [ + "batch_id", + "command_id" + ], + "type": "object", + "additionalProperties": false + } + } +] \ No newline at end of file diff --git a/test/mcp-schema.test.mjs b/test/mcp-schema.test.mjs new file mode 100644 index 0000000..2a835c2 --- /dev/null +++ b/test/mcp-schema.test.mjs @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import { defineTool } from '@deepseek-ai/dsh-tools' +import { parametersFromInputSchema } from '../src/netx/mcp-schema.ts' + +const tools = JSON.parse(readFileSync(new URL('./fixtures-netx-mcp-tools.json', import.meta.url), 'utf8')) + +function define(name, inputSchema) { + return defineTool({ + name: `netx__${name}`, + description: name, + parameters: parametersFromInputSchema(inputSchema), + output: { schema: { type: 'object', additionalProperties: true }, render: () => [] }, + async execute() { + return {} + }, + }) +} + +test('every real netx /mcp tool schema compiles in the DSH value schema DSL', () => { + assert.ok(tools.length >= 39) + for (const tool of tools) { + assert.doesNotThrow(() => define(tool.name, tool.inputSchema), tool.name) + } +}) + +test('required, constraints and nullable unions survive conversion', () => { + const params = parametersFromInputSchema({ + type: 'object', + required: ['ne_id'], + properties: { + ne_id: { type: 'string', description: 'NE id' }, + page: { type: 'integer', minimum: 1, maximum: 50, default: 1 }, + ids: { type: 'array', items: { type: 'string' }, maxItems: 20 }, + filter: { type: 'object', required: ['k'], properties: { k: { type: 'string' } } }, + maybe: { anyOf: [{ type: 'string' }, { type: 'null' }] }, + either: { type: ['string', 'integer'] }, + }, + }) + assert.equal(params.ne_id.required, true) + assert.equal(params.page.required, undefined) + assert.equal(params.page.minimum, undefined) + assert.match(params.page.description, /minimum=1, maximum=50/) + assert.match(params.ids.description, /maxItems=20/) + assert.equal(params.filter.additionalProperties, true) + assert.equal(params.filter.properties.k.required, true) + assert.equal(params.maybe.type, 'string') + assert.equal(params.either.oneOf.length, 2) + assert.doesNotThrow(() => define('sample', { type: 'object', properties: params })) +})