UNPKG

@withpica/mcp-server

Version:

MCP Server for withPICA — enables AI assistants to interact with your withPICA music catalog

268 lines (248 loc) • 9.22 kB
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved. /** * ADR-214 — generator for `required-schemas.generated.ts`. * * Reads three inputs: * 1. ToolRegistry (instantiated from src) → tool inputSchemas * 2. mcp-server/src/resources/required-schemas.source.ts → narrative + companion_calls * 3. mcp-server/src/resources/schema-mirror.json → CHECK constraint enums * * Joins them and emits `mcp-server/src/resources/required-schemas.generated.ts`, * a TypeScript module exporting `REQUIRED_SCHEMAS` consumed by ResourceRegistry. * * Determinism: object keys sorted alphabetically, arrays preserved in source * order. Re-running with no source changes produces a byte-identical file. * * Wired into mcp-server/package.json as part of `prebuild` (after * bundle-apps.ts). Runs via tsx; no build step required. * * Failure modes: * - source.ts references a tool not in ToolRegistry → exit 1, name file to fix * - companion_calls[].tool not in ToolRegistry → exit 1 * - enum_tables references missing table/column in mirror → exit 1 */ import { readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { REQUIRED_SCHEMAS_SOURCE, type RequiredSchemaSource, } from "../src/resources/required-schemas.source.js"; import { ToolRegistry, type ToolDefinition } from "../src/tools/index.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const MCP_SERVER_ROOT = join(__dirname, ".."); const MIRROR_PATH = join(MCP_SERVER_ROOT, "src/resources/schema-mirror.json"); const OUTPUT_PATH = join( MCP_SERVER_ROOT, "src/resources/required-schemas.generated.ts", ); interface SchemaMirror { [table: string]: string[] | Record<string, string[]> | string | undefined; } function loadMirror(): SchemaMirror { const raw = readFileSync(MIRROR_PATH, "utf-8"); return JSON.parse(raw) as SchemaMirror; } function loadRegistry(): Map<string, ToolDefinition> { const stubPica = {} as never; const stubConfig = { picaApiKey: "stub", picaApiUrl: "https://stub.example.com/api", serverName: "schema-generator", version: "0.0.0", debug: false, lobbyMode: false, credentialsPath: "", discoveryMode: false, } as never; const registry = new ToolRegistry( stubPica, stubConfig, () => {}, { callerIdentity: "schema-generator", transport: "stdio" } as never, () => {}, ); const map = new Map<string, ToolDefinition>(); // ToolRegistry.tools is private but read-accessible from sibling code in // the same workspace (matches existing pattern in scripts/lint-mcp-tools.ts). const tools = ( registry as unknown as { tools: Map<string, { definition: ToolDefinition }>; } ).tools; for (const [name, entry] of tools) { map.set(name, entry.definition); } return map; } function fail(msg: string): never { console.error(`[build-required-schemas] ERROR: ${msg}`); process.exit(1); } // Arguments that are not fields of the record, so never recommended: // `verbose` shapes the tool's ANSWER (src/tools/write-receipt.ts), and // `catalogue` says WHOSE catalogue the call acts for (ADR-333 D7, // src/tools/catalogue-argument.ts). const NOT_RECORD_FIELDS = new Set(["verbose", "catalogue"]); function getRequiredAndProperties( tools: Map<string, ToolDefinition>, toolName: string, source: string, ): { required: string[]; properties: string[] } { const def = tools.get(toolName); if (!def) { fail( `${source} references tool '${toolName}' which is not in ToolRegistry. ` + `Edit mcp-server/src/resources/required-schemas.source.ts and remove or rename the reference.`, ); } const schema = def.inputSchema; const required = Array.isArray(schema.required) ? [...schema.required] : []; const properties = Object.keys(schema.properties ?? {}).filter( (p) => !NOT_RECORD_FIELDS.has(p), ); return { required, properties }; } function buildEnums( mirror: SchemaMirror, tables: RequiredSchemaSource["enum_tables"], workflow: string, ): Record<string, string[]> { if (!tables || tables.length === 0) return {}; const out: Record<string, string[]> = {}; for (const { table, columns } of tables) { const tableEntry = mirror[table]; if ( !tableEntry || typeof tableEntry !== "object" || Array.isArray(tableEntry) ) { fail( `Workflow '${workflow}' references table '${table}' missing from schema-mirror.json. ` + `Run \`tsx mcp-server/scripts/refresh-schema-mirror.ts\` against staging to regenerate.`, ); } const tableMap = tableEntry as Record<string, string[]>; for (const col of columns) { const values = tableMap[col]; if (!Array.isArray(values)) { fail( `Workflow '${workflow}' references ${table}.${col} missing from schema-mirror.json.`, ); } out[col] = [...values].sort(); } } return out; } function buildCompanion( tools: Map<string, ToolDefinition>, companion: RequiredSchemaSource["companion_calls"][number], workflow: string, ): Record<string, unknown> { if (!tools.has(companion.tool)) { fail( `Workflow '${workflow}' companion '${companion.tool}' not in ToolRegistry. ` + `Edit required-schemas.source.ts.`, ); } // Parity gate (2026-07-18): the hand-authored companion `required` array // MUST byte-equal the live tool's inputSchema.required. ADR-214's holdout // (scenarios/214/required-schemas-match.spec.ts) enforces exactly that // byte-equality against the deployed surface, and the WS5 write-merge // (b477cd175) proved the hand arrays silently drift when a tool goes // multi-mode (required → []). The arrays stay authored in source.ts for // readability, but drift is now a build failure instead of a holdout red. const { required: liveRequired } = getRequiredAndProperties( tools, companion.tool, `Workflow '${workflow}' companion`, ); const declaredSorted = [...companion.required].sort(); const liveSorted = [...liveRequired].sort(); if ( declaredSorted.length !== liveSorted.length || declaredSorted.some((v, i) => v !== liveSorted[i]) ) { fail( `Workflow '${workflow}' companion '${companion.tool}' declares ` + `required=${JSON.stringify(companion.required)} but the live ` + `inputSchema.required is ${JSON.stringify(liveRequired)}. ` + `Update required-schemas.source.ts to match the tool schema ` + `(the ADR-214 holdout asserts byte-equality).`, ); } const out: Record<string, unknown> = { tool: companion.tool, when: companion.when, required: companion.required, }; if (companion.per_row_required) { out.per_row_required = companion.per_row_required; } if (companion.constraint) { out.constraint = companion.constraint; } return out; } function buildResource( tools: Map<string, ToolDefinition>, mirror: SchemaMirror, source: RequiredSchemaSource, ): Record<string, unknown> { const { required, properties } = getRequiredAndProperties( tools, source.primary_tool, `Workflow '${source.workflow}' primary_tool`, ); const recommended = properties.filter((p) => !required.includes(p)); const enums = buildEnums(mirror, source.enum_tables, source.workflow); const companions = source.companion_calls.map((c) => buildCompanion(tools, c, source.workflow), ); return { workflow: source.workflow, summary: source.summary, primary_tool: source.primary_tool, primary_required: required, primary_recommended: recommended, enums, companion_calls: companions, example: source.example, }; } function emit(resources: Record<string, Record<string, unknown>>): string { // Sort keys deterministically so re-runs produce byte-identical output. const sortedKeys = Object.keys(resources).sort(); const sortedResources: Record<string, Record<string, unknown>> = {}; for (const k of sortedKeys) sortedResources[k] = resources[k]; const json = JSON.stringify(sortedResources, null, 2); return [ "// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.", "//", "// AUTO-GENERATED by mcp-server/scripts/build-required-schemas.ts.", "// Do not edit by hand. Re-run via `cd mcp-server && npm run prebuild`.", "// Source: required-schemas.source.ts + schema-mirror.json + ToolRegistry inputSchemas.", "// ADR-214 — required-fields contracts per workflow.", "", "export const REQUIRED_SCHEMAS = " + json + " as const;", "", "export type RequiredSchemaKey = keyof typeof REQUIRED_SCHEMAS;", "", ].join("\n"); } function main(): void { const tools = loadRegistry(); const mirror = loadMirror(); const out: Record<string, Record<string, unknown>> = {}; for (const [key, source] of Object.entries(REQUIRED_SCHEMAS_SOURCE)) { out[key] = buildResource(tools, mirror, source); } const text = emit(out); writeFileSync(OUTPUT_PATH, text, "utf-8"); console.log( `[build-required-schemas] wrote ${Object.keys(out).length} schemas to ${OUTPUT_PATH}`, ); } main();