rebase generation

This commit is contained in:
MaKarin
2026-04-07 19:40:41 +03:00
parent 73ddb1a948
commit aab7bfa691
180 changed files with 15512 additions and 364 deletions

View File

@@ -0,0 +1,185 @@
/**
* Minimal Model Context Protocol (MCP) server over stdio.
*
* Implements just enough of the MCP JSON-RPC 2.0 surface to expose
* a static list of read-only validation tools to a Claude Code client:
* - initialize
* - tools/list
* - tools/call
*
* We intentionally avoid the @modelcontextprotocol/sdk dependency here —
* the KIS-TOiR optimization policy forbids adding new npm packages for
* these lightweight validators, so we speak raw JSON-RPC on stdin/stdout
* with Node built-ins only.
*
* Tool contract:
* {
* name: string,
* description: string,
* inputSchema?: JSONSchema,
* execute: (args: object) => Promise<{ success: boolean, result?, error? }>
* }
*
* execute() must be read-only, must resolve quickly (<500ms target), and
* must NEVER throw — wrap failures in { success: false, error }.
*/
import readline from "node:readline";
const PROTOCOL_VERSION = "2024-11-05";
// JSON-RPC 2.0 constants
const JSONRPC_VERSION = "2.0";
const FIELD_JSONRPC = "jsonrpc";
const FIELD_ID = "id";
const FIELD_METHOD = "method";
const FIELD_PARAMS = "params";
const FIELD_RESULT = "result";
const FIELD_ERROR = "error";
const FIELD_CODE = "code";
const FIELD_MESSAGE = "message";
const FIELD_DATA = "data";
const CONTENT_TYPE_TEXT = "text";
const FIELD_CONTENT = "content";
const FIELD_TYPE = "type";
const FIELD_IS_ERROR = "isError";
function write(obj) {
// MCP framing over stdio is line-delimited JSON (one JSON object per line).
process.stdout.write(JSON.stringify(obj) + "\n");
}
function ok(id, result) {
write({ [FIELD_JSONRPC]: JSONRPC_VERSION, [FIELD_ID]: id, [FIELD_RESULT]: result });
}
function fail(id, code, message, data) {
write({
[FIELD_JSONRPC]: JSONRPC_VERSION,
[FIELD_ID]: id,
[FIELD_ERROR]: { [FIELD_CODE]: code, [FIELD_MESSAGE]: message, [FIELD_DATA]: data },
});
}
/**
* Start an MCP server. Blocks on stdin until the parent process closes it.
*/
export function runMCPServer({ name, version = "0.1.0", tools }) {
const byName = new Map(tools.map((t) => [t.name, t]));
const rl = readline.createInterface({
input: process.stdin,
crlfDelay: Infinity,
});
// Log to stderr so we don't corrupt the stdout JSON-RPC channel.
const log = (level, msg) =>
process.stderr.write(`[${level}] ${name}: ${msg}\n`);
log("INFO", `starting (tools=${tools.length})`);
rl.on("line", async (line) => {
const trimmed = line.trim();
if (!trimmed) return;
let req;
try {
req = JSON.parse(trimmed);
} catch (e) {
log("WARN", `invalid JSON on stdin: ${e.message}`);
return;
}
const { id, method, params } = req;
// Notifications have no id and expect no response.
const isNotification = id === undefined || id === null;
try {
if (method === "initialize") {
ok(id, {
protocolVersion: PROTOCOL_VERSION,
capabilities: { tools: { listChanged: false } },
serverInfo: { name, version },
});
return;
}
if (method === "tools/list") {
ok(id, {
tools: tools.map((t) => ({
name: t.name,
description: t.description,
inputSchema: t.inputSchema ?? {
[FIELD_TYPE]: "object",
properties: {},
additionalProperties: true,
},
})),
});
return;
}
if (method === "tools/call") {
const toolName = params?.name;
const tool = byName.get(toolName);
if (!tool) {
fail(id, -32601, `Unknown tool: ${toolName}`);
return;
}
let result;
try {
// Enforce per-tool timeout: tools MUST resolve within 2000ms.
// Prevents hung tools from blocking the orchestrator.
const toolTimeout = new Promise((_, reject) =>
setTimeout(
() => reject(new Error("Tool execution timeout (>2000ms)")),
2000
)
);
result = await Promise.race([
tool.execute(params?.arguments ?? {}),
toolTimeout,
]);
} catch (e) {
// Capture all errors (timeout, execution, etc.) uniformly.
result = { success: false, error: e?.message ?? String(e) };
}
ok(id, {
[FIELD_CONTENT]: [
{
[FIELD_TYPE]: CONTENT_TYPE_TEXT,
text:
typeof result === "string"
? result
: JSON.stringify(result, null, 2),
},
],
[FIELD_IS_ERROR]: result?.success === false,
});
return;
}
if (method?.startsWith("notifications/")) {
// Client lifecycle notifications (initialized, cancelled, ...).
return;
}
if (!isNotification) {
fail(id, -32601, `Method not found: ${method}`);
}
} catch (e) {
log("ERROR", `handler crashed: ${e?.stack ?? e}`);
if (!isNotification) {
fail(id, -32603, "Internal server error", { message: e?.message });
}
}
});
rl.on("close", () => {
log("INFO", "stdin closed, exiting");
process.exit(0);
});
}

View File

@@ -0,0 +1,158 @@
#!/usr/bin/env node
/**
* MCP server: NestJS module/controller validator (read-only).
*
* Tools:
* - validate_module_source : assert a TypeScript source string is a
* well-formed Nest module
* - check_app_registration : verify that server/src/app.module.ts
* imports and registers a given module class
*
* Purely lexical — we do not use the TypeScript compiler API (that would
* add a dependency). Regex-level checks are sufficient for the structural
* invariants the orchestrator cares about.
*/
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();
// Regex constants for NestJS module validation
const DECORATOR_KEYS_RE = /(?:^|[,{\s])(imports|controllers|providers|exports)\s*:/g;
const IMPORTS_BLOCK_RE = /imports\s*:\s*\[([\s\S]*?)\]/;
const tools = [
{
name: "validate_module_source",
description:
"Validate that a TypeScript source string declares a well-formed NestJS module: imports @Module from @nestjs/common, applies the @Module decorator with an object argument, and exports a class. Returns the class name and the decorator keys it declares.",
inputSchema: {
type: "object",
properties: {
source: { type: "string", description: "Contents of a *.module.ts file" },
},
required: ["source"],
},
execute: async ({ source }) => {
if (typeof source !== "string" || source.length === 0) {
return { success: false, error: "source must be a non-empty string" };
}
const issues = [];
if (!/from ['"]@nestjs\/common['"]/.test(source)) {
issues.push("does not import from @nestjs/common");
}
if (!/\bModule\b/.test(source)) {
issues.push("does not reference the Module symbol");
}
const decoratorMatch = source.match(/@Module\s*\(\s*\{([\s\S]*?)\}\s*\)/);
if (!decoratorMatch) {
issues.push("no @Module({...}) decorator found");
}
const classMatch = source.match(/export\s+class\s+(\w+)/);
if (!classMatch) {
issues.push("no `export class <Name>` declaration");
}
// Extract the top-level keys inside the decorator object so callers
// can sanity-check providers/controllers/imports presence.
const declaredKeys = [];
if (decoratorMatch) {
const body = decoratorMatch[1];
let m;
while ((m = DECORATOR_KEYS_RE.exec(body)) !== null) {
declaredKeys.push(m[1]);
}
}
return {
success: issues.length === 0,
result: {
className: classMatch?.[1] ?? null,
declaredKeys,
issues,
},
};
},
},
{
name: "check_app_registration",
description:
"Check whether server/src/app.module.ts imports and registers a given Nest module class. Useful after generator_nest_resources produces a module, to verify the orchestrator has wired it into the parent-owned app.module.ts.",
inputSchema: {
type: "object",
properties: {
moduleClass: {
type: "string",
description: "e.g. `ChangeEquipmentStatusModule`",
},
appModulePath: {
type: "string",
description:
"Optional override for the app.module.ts path (relative to project root). Defaults to server/src/app.module.ts.",
},
},
required: ["moduleClass"],
},
execute: async ({ moduleClass, appModulePath }) => {
if (typeof moduleClass !== "string" || !/^[A-Z]\w*Module$/.test(moduleClass)) {
return {
success: false,
error: "moduleClass must be a PascalCase class name ending in Module",
};
}
const relPath = appModulePath ?? "server/src/app.module.ts";
const absPath = path.isAbsolute(relPath)
? relPath
: path.join(projectRoot, relPath);
let source: string;
try {
source = fs.readFileSync(absPath, "utf8");
} catch (err) {
return {
success: false,
error: `failed to read ${relPath}: ${err instanceof Error ? err.message : String(err)}`,
};
}
const importRe = new RegExp(
`import\\s*\\{[^}]*\\b${moduleClass}\\b[^}]*\\}\\s*from\\s*['"]([^'"]+)['"]`
);
const importMatch = source.match(importRe);
const decoratorMatch = source.match(/@Module\s*\(\s*\{([\s\S]*?)\}\s*\)/);
let registered = false;
if (decoratorMatch) {
const importsBlock = decoratorMatch[1].match(IMPORTS_BLOCK_RE);
if (importsBlock) {
const importedModules = importsBlock[1]
.split(",")
.map((s) => s.trim())
.filter(Boolean);
registered = importedModules.includes(moduleClass);
}
}
return {
success: Boolean(importMatch) && registered,
result: {
moduleClass,
appModulePath: relPath,
imported: Boolean(importMatch),
importedFrom: importMatch?.[1] ?? null,
registered,
},
};
},
},
];
runMCPServer({ name: "kis-toir-nest-validator", version: "0.1.0", tools });

141
tools/mcp/npm-validator.mjs Normal file
View File

@@ -0,0 +1,141 @@
#!/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 });

View File

@@ -0,0 +1,178 @@
#!/usr/bin/env node
/**
* MCP server: Prisma schema validator (read-only).
*
* Tools:
* - validate_schema : structural checks against a Prisma schema string
* - extract_models : return model/enum names and field counts
*
* These are deliberately NOT a shell-out to `prisma validate` — per the
* KIS-TOiR optimization constraints, MCP servers must be pure, synchronous,
* read-only validators with no external deps or CLI subprocess calls.
*/
import { runMCPServer } from "./lib/mcp-server.mjs";
// Prisma schema constants
const BUILTIN_TYPES = new Set([
"String",
"Int",
"Float",
"Boolean",
"DateTime",
"Json",
"Bytes",
"Decimal",
"BigInt",
]);
// Regex constants (compiled once, reused across invocations)
const BLOCK_RE = /^\s*(model|enum|datasource|generator)\s+(\w+)\s*\{/gm;
const INLINE_ID_RE = /@id\b/;
const COMPOSITE_ID_RE = /@@id\s*\(/;
const FIELD_RE = /^(\w+)\s+([\w?\[\]]+)/;
const RELATION_RE = /@relation\b/;
/**
* Extremely lightweight Prisma schema parser. Not a full grammar — it
* recognizes top-level blocks (`model`, `enum`, `datasource`, `generator`)
* and counts fields inside model/enum blocks by line. Sufficient for the
* structural sanity checks the orchestrator asks for.
*/
function parseBlocks(schema) {
const blocks = [];
let m;
while ((m = BLOCK_RE.exec(schema)) !== null) {
const kind = m[1];
const name = m[2];
const openIdx = schema.indexOf("{", m.index);
// Walk forward to find the matching close brace. Prisma schemas do not
// nest braces inside blocks, so a simple depth counter is enough.
let depth = 1;
let i = openIdx + 1;
for (; i < schema.length && depth > 0; i++) {
if (schema[i] === "{") depth++;
else if (schema[i] === "}") depth--;
}
const body = schema.slice(openIdx + 1, i - 1);
const fieldLines = body
.split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l && !l.startsWith("//") && !l.startsWith("@@"));
blocks.push({ kind, name, body, fields: fieldLines });
}
return blocks;
}
const tools = [
{
name: "validate_schema",
description:
"Validate a Prisma schema string for structural soundness: required generator and datasource blocks, at least one model, each model has an @id or @@id, referenced enums exist. Read-only; never writes files.",
inputSchema: {
type: "object",
properties: {
schema: {
type: "string",
description: "Full contents of schema.prisma",
},
},
required: ["schema"],
},
execute: async ({ schema }) => {
if (typeof schema !== "string" || schema.length === 0) {
return { success: false, error: "schema must be a non-empty string" };
}
const issues = [];
const blocks = parseBlocks(schema);
const generators = blocks.filter((b) => b.kind === "generator");
const datasources = blocks.filter((b) => b.kind === "datasource");
const models = blocks.filter((b) => b.kind === "model");
const enums = blocks.filter((b) => b.kind === "enum");
if (generators.length === 0)
issues.push("missing generator block (expected `generator client`)");
if (datasources.length === 0)
issues.push("missing datasource block (expected `datasource db`)");
if (models.length === 0) issues.push("schema declares no models");
const enumNames = new Set(enums.map((e) => e.name));
for (const model of models) {
const hasInlineId = INLINE_ID_RE.test(model.body);
const hasCompositeId = COMPOSITE_ID_RE.test(model.body);
if (!hasInlineId && !hasCompositeId) {
issues.push(`model ${model.name} has no @id or @@id directive`);
}
// Detect referenced types that look like enums but are not declared.
for (const fieldLine of model.fields) {
// field definition: `name Type[?] ...`
const fieldMatch = fieldLine.match(FIELD_RE);
if (!fieldMatch) continue;
const rawType = fieldMatch[2].replace(/[\?\[\]]/g, "");
if (BUILTIN_TYPES.has(rawType)) continue;
// If a type looks like an enum reference (capitalized, no model match),
// warn when it is not found in declared models or enums.
const isKnownModel = models.some((m) => m.name === rawType);
const isKnownEnum = enumNames.has(rawType);
if (!isKnownModel && !isKnownEnum) {
// Might be a relation target defined later — only flag if it
// looks enum-shaped (all caps first letter, no `@relation` on the line).
if (!RELATION_RE.test(fieldLine)) {
issues.push(
`model ${model.name} field \`${fieldMatch[1]}\` references unknown type \`${rawType}\``
);
}
}
}
}
return {
success: issues.length === 0,
result: {
blockCounts: {
generator: generators.length,
datasource: datasources.length,
model: models.length,
enum: enums.length,
},
issues,
},
};
},
},
{
name: "extract_models",
description:
"Extract model and enum names from a Prisma schema string, along with per-model field counts. Useful before diffing against a frozen contract.",
inputSchema: {
type: "object",
properties: {
schema: { type: "string" },
},
required: ["schema"],
},
execute: async ({ schema }) => {
if (typeof schema !== "string") {
return { success: false, error: "schema must be a string" };
}
const blocks = parseBlocks(schema);
return {
success: true,
result: {
models: blocks
.filter((b) => b.kind === "model")
.map((b) => ({ name: b.name, fieldCount: b.fields.length })),
enums: blocks
.filter((b) => b.kind === "enum")
.map((b) => ({ name: b.name, valueCount: b.fields.length })),
},
};
},
},
];
runMCPServer({ name: "kis-toir-prisma-validator", version: "0.1.0", tools });