179 lines
6.0 KiB
JavaScript
179 lines
6.0 KiB
JavaScript
#!/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 });
|