@withpica/mcp-server
Version:
MCP Server for withPICA — enables AI assistants to interact with your withPICA music catalog
266 lines (246 loc) • 9.1 kB
text/typescript
// 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);
}
const RESPONSE_SWITCHES = new Set(["verbose"]);
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] : [];
// `verbose` shapes the tool's ANSWER (src/tools/write-receipt.ts), not the
// record — it is not a field to recommend filling in.
const properties = Object.keys(schema.properties ?? {}).filter(
(p) => !RESPONSE_SWITCHES.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();