#!/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 });