#!/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/" or // "node_modules//". 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 });