Files
toir-automatization/tools/mcp/npm-validator.mjs
2026-04-07 19:40:41 +03:00

142 lines
4.4 KiB
JavaScript

#!/usr/bin/env node
/**
* MCP server: npm package & semver validator (read-only).
*
* Tools:
* - validate_semver : check that a version string is valid semver
* and optionally reject ranges/latest
* - check_lockfile_package : look up a package in the root package-lock.json
* and return its locked version without touching
* the registry
*
* No network access, no CLI invocations. Pure parsing over local files.
*/
import fs from "node:fs";
import path from "node:path";
import { runMCPServer } from "./lib/mcp-server.mjs";
import { getProjectRoot } from "../lib/project-root.mjs";
const projectRoot = getProjectRoot();
// SemVer 2.0.0 regex from https://semver.org/ (BSD-licensed).
const SEMVER_RE =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
function loadLockfile() {
const lockPath = path.join(projectRoot, "package-lock.json");
if (!fs.existsSync(lockPath)) return null;
try {
return JSON.parse(fs.readFileSync(lockPath, "utf8"));
} catch {
return null;
}
}
const tools = [
{
name: "validate_semver",
description:
"Validate a version string as strict SemVer 2.0.0. When `rejectRanges` is true, reject caret/tilde/latest and require an exact pinned version (the KIS-TOiR version policy). Returns parsed components on success.",
inputSchema: {
type: "object",
properties: {
version: { type: "string", description: "Version string to validate" },
rejectRanges: {
type: "boolean",
description:
"If true, treat ^, ~, >, <, *, latest as invalid. Default true.",
},
},
required: ["version"],
},
execute: async ({ version, rejectRanges = true }) => {
if (typeof version !== "string" || !version.trim()) {
return { success: false, error: "version must be a non-empty string" };
}
const trimmed = version.trim();
if (rejectRanges) {
if (/^[\^~><=*]/.test(trimmed) || trimmed === "latest") {
return {
success: false,
error: `version '${trimmed}' is a range or tag; policy requires an exact pinned version`,
};
}
}
// Strip a leading range operator if ranges are allowed.
const candidate = rejectRanges ? trimmed : trimmed.replace(/^[\^~><=]+/, "");
const m = candidate.match(SEMVER_RE);
if (!m) {
return {
success: false,
error: `'${trimmed}' is not a valid SemVer 2.0.0 version`,
};
}
return {
success: true,
result: {
raw: trimmed,
major: Number(m[1]),
minor: Number(m[2]),
patch: Number(m[3]),
prerelease: m[4] ?? null,
build: m[5] ?? null,
},
};
},
},
{
name: "check_lockfile_package",
description:
"Look up a package inside the root package-lock.json and return its locked version. Read-only; does not contact the npm registry. Returns { found: boolean, version?: string, paths: string[] }.",
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: "Package name, e.g. @nestjs/core" },
},
required: ["name"],
},
execute: async ({ name }) => {
if (typeof name !== "string" || !name.trim()) {
return { success: false, error: "name must be a non-empty string" };
}
const lock = loadLockfile();
if (!lock) {
return {
success: false,
error: "package-lock.json not found or unreadable at project root",
};
}
const matches = [];
const packages = lock.packages ?? {};
for (const [key, value] of Object.entries(packages)) {
// npm v7+ lockfile: keys look like "node_modules/<pkg>" or
// "node_modules/<scope>/<pkg>". Strip the prefix and compare.
if (!key) continue;
const stripped = key.replace(/^.*node_modules\//, "");
if (stripped === name) {
matches.push({ path: key, version: value.version });
}
}
return {
success: true,
result: {
name,
found: matches.length > 0,
occurrences: matches,
},
};
},
},
];
runMCPServer({ name: "kis-toir-npm-validator", version: "0.1.0", tools });