UNPKG

mcp-server-kubernetes

Version:

MCP server for interacting with Kubernetes clusters via kubectl

180 lines (179 loc) • 6.79 kB
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; /** * Validate `input` against `tool.inputSchema` and return the arguments the * tool should run with. Throws an McpError (InvalidParams) listing every * problem found. * * Normalisation is limited to unambiguous cases, so a call that would have * worked before keeps working: * - an enum value that differs only in case is replaced by the listed one * ("Pod" -> "pod"); the handlers lower-case several of these anyway * - a required argument that is missing but has a schema default gets the * default, which is what the handlers fall back to * - an optional argument passed as null is treated as omitted */ export function validateToolInput(tool, input) { const errors = []; if (input === undefined || input === null) input = {}; if (!isPlainObject(input)) { throw invalid(tool.name, [ `the arguments must be a JSON object; got ${describe(input)}.`, ]); } const result = validateObject(tool.inputSchema, input, "", errors); if (errors.length > 0) throw invalid(tool.name, errors); return result; } function validateObject(schema, value, path, errors) { const properties = schema.properties ?? {}; const required = new Set(schema.required ?? []); const result = {}; for (const [key, raw] of Object.entries(value)) { const propSchema = properties[key]; const propPath = join(path, key); // null means "not provided": dropped if optional, and reported (or // defaulted) by the required check below if not. if (raw === null) continue; if (propSchema) { result[key] = validateValue(propSchema, raw, propPath, errors); } else if (isPlainObject(schema.additionalProperties)) { result[key] = validateValue(schema.additionalProperties, raw, propPath, errors); } else if (schema.additionalProperties === false) { errors.push(`"${propPath}" is not a recognised argument. Allowed arguments: ` + `${listOf(Object.keys(properties))}.`); } else { // Unknown arguments are ignored by the handlers; pass them through. result[key] = raw; } } for (const key of required) { if (result[key] !== undefined && result[key] !== null) continue; const propSchema = properties[key]; if (propSchema?.default !== undefined) { result[key] = propSchema.default; continue; } errors.push(missing(join(path, key), propSchema)); } return result; } function validateValue(schema, value, path, errors) { const types = schema.type === undefined ? [] : [schema.type].flat(); if (types.length > 0 && !types.some((t) => matchesType(t, value))) { errors.push(`"${path}" must be ${types.map(article).join(" or ")}; got ` + `${describe(value)}.${typeHint(types, value)}`); return value; } if (schema.enum) { if (schema.enum.includes(value)) return value; const folded = typeof value === "string" ? schema.enum.find((option) => typeof option === "string" && option.toLowerCase() === value.toLowerCase()) : undefined; if (folded !== undefined) return folded; errors.push(`"${path}" must be one of ${listOf(schema.enum)}; got ${describe(value)}.`); return value; } if (Array.isArray(value) && schema.items) { return value.map((item, i) => validateValue(schema.items, item, `${path}[${i}]`, errors)); } if (isPlainObject(value) && (schema.properties || schema.required)) { return validateObject(schema, value, path, errors); } return value; } function matchesType(type, value) { switch (type) { case "string": return typeof value === "string"; case "number": return typeof value === "number" && Number.isFinite(value); case "integer": return Number.isInteger(value); case "boolean": return typeof value === "boolean"; case "array": return Array.isArray(value); case "object": return isPlainObject(value); case "null": return value === null; default: return true; } } function isPlainObject(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } // The most common LLM slip is quoting a scalar or JSON-encoding a structure; // say what the fix is when that is what happened. function typeHint(types, value) { if (typeof value !== "string") return ""; const trimmed = value.trim(); if ((types.includes("number") || types.includes("integer")) && trimmed !== "" && Number.isFinite(Number(trimmed))) { return ` Pass the number without quotes: ${trimmed}.`; } if (types.includes("boolean") && (trimmed === "true" || trimmed === "false")) { return ` Pass the boolean without quotes: ${trimmed}.`; } if ((types.includes("array") && trimmed.startsWith("[")) || (types.includes("object") && trimmed.startsWith("{"))) { return " Pass the JSON value itself, not a string containing it."; } return ""; } function missing(path, schema) { const type = schema?.type ? ` (${[schema.type].flat().join(" or ")})` : ""; const description = schema?.description ? `: ${schema.description}` : "."; return `missing required argument "${path}"${type}${description}`; } function invalid(toolName, errors) { return new McpError(ErrorCode.InvalidParams, `Invalid arguments for tool "${toolName}":\n` + errors.map((e) => ` - ${e}`).join("\n") + `\nCorrect these arguments and call ${toolName} again.`); } function join(path, key) { return path === "" ? key : `${path}.${key}`; } function article(type) { switch (type) { case "array": return "an array"; case "object": return "an object"; case "integer": return "an integer"; default: return `a ${type}`; } } function listOf(values) { return values.map((v) => JSON.stringify(v)).join(", "); } function describe(value) { if (value === null) return "null"; if (value === undefined) return "nothing"; const kind = Array.isArray(value) ? "an array" : typeof value === "object" ? "an object" : `a ${typeof value}`; let json = JSON.stringify(value) ?? String(value); if (json.length > 80) json = `${json.slice(0, 77)}...`; return `${kind} (${json})`; }