build: auto-generate Zod schemas from TypeScript types using ts-to-zod (#116)
* feat: add ts-to-zod schema generation from spec.types.ts Add automated Zod schema generation using ts-to-zod: - New npm script `generate:schemas` to regenerate schemas from spec.types.ts - Post-processing script for Zod v4 compatibility and MCP SDK type imports - src/spec.types.ts: Pure TypeScript interface definitions (source of truth) - src/schemas.generated.ts: Auto-generated Zod schemas - src/schemas.ts: Re-exports with PascalCase naming for backwards compatibility The generated schemas provide a simpler alternative to the hand-written schemas in types.ts, useful for lighter validation or reference implementations. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: types.ts now re-exports from spec.types.ts and schemas.generated.ts - types.ts imports and re-exports types from spec.types.ts (source of truth) - types.ts imports and re-exports schemas from schemas.generated.ts - Added compile-time VerifySchemaMatches checks to flag mismatches between interfaces and generated schemas - Removed redundant schemas.ts (types.ts now handles all re-exports) - Applied prettier formatting to schemas.generated.ts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: use ts-to-zod as library with integration tests Replace CLI-based schema generation with library API: - scripts/generate-schemas.ts: New script using ts-to-zod library API - Detailed documentation of why each post-processing step is needed - Generates integration tests via getIntegrationTestFile() - Better error reporting and control - src/schemas.generated.test.ts: Auto-generated integration tests - Compile-time verification that schemas match TypeScript types - Uses expectType<> pattern for bidirectional type checking - Removed scripts/postprocess-schemas.ts (merged into generate-schemas.ts) - Fixed McpUiResourceTeardownResult to include index signature for MCP SDK Protocol compatibility (detected by the new integration tests) Post-processing rationale (documented in generate-schemas.ts): 1. zod → zod/v4: Explicit v4 subpath for version clarity 2. z.any() → MCP SDK schemas: External types need real validation 3. z.record().and() → z.looseObject(): Index signatures need Zod v4 idiom 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: regenerate schemas during build + CI check - build script now runs generate:schemas before bun build - CI workflow verifies generated schemas are up-to-date (fails if spec.types.ts changed but schemas weren't regenerated) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: generate PascalCase schemas, move verification to tests - Add getSchemaName option to generate PascalCase schema names directly (McpUiOpenLinkRequestSchema instead of mcpUiOpenLinkRequestSchema) - Remove redundant compile-time verification from types.ts (already handled by generated test file's bidirectional expectType checks) - Simplify types.ts from 235 to 57 lines (just re-exports) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: add missing csp field, simplify schema generation - Add missing csp field to McpUiSandboxResourceReadyNotification (regression from merge) - Simplify generate-schemas.ts - remove complex .describe() conversion - Document that ts-to-zod supports @description JSDoc tags for .describe() - Improve z.looseObject() replacement with brace-counting for nested objects 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: add @description JSDoc tags for .describe() on schemas Added @description tags to all interfaces and fields in spec.types.ts. ts-to-zod now generates .describe() calls on all schema fields, making descriptions available at runtime for introspection and JSON Schema generation. Example: /** @description URL to open in the host's browser */ url: string; Generates: url: z.string().describe("URL to open in the host's browser") 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: generate JSON Schema from Zod schemas Added JSON Schema generation to the schema generation pipeline: - Uses Zod v4's built-in toJSONSchema() to convert schemas - Generates src/schema.json with all message types in $defs - Includes descriptions from @description JSDoc tags - Added export "./schema.json" to package.json - Updated CI to verify schema.json is up-to-date Use cases: - Language-agnostic validation (Python, Go, Rust) - Documentation generation (Redoc, Swagger UI) - Code generation (quicktype) - IDE autocomplete for JSON/YAML 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: rename schema.json to schema.generated.json Renamed to match other generated files naming convention. Export path unchanged: ./schema.json -> ./dist/src/schema.generated.json 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: rename schemas.generated.* to schema.generated.* (singular) Consistent singular naming for all generated files: - schema.generated.ts - schema.generated.test.ts - schema.generated.json 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: rename schemas.generated.* to schema.generated.* (singular) Consistent singular naming for all generated files: - schema.generated.ts - schema.generated.test.ts - schema.generated.json 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: move generated files to src/generated/ with simpler names Moved: - src/schema.generated.ts → src/generated/schema.ts - src/schema.generated.test.ts → src/generated/schema.test.ts - src/schema.generated.json → src/generated/schema.json Benefits: - Cleaner organization (generated files isolated) - Simpler names without .generated suffix - Easier to gitignore if desired later 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: add missing type exports for backwards compatibility Added missing exports that existed in main: - McpUiTheme, McpUiThemeSchema - McpUiDisplayMode, McpUiDisplayModeSchema - McpUiResourceCsp, McpUiResourceCspSchema - McpUiResourceMeta, McpUiResourceMetaSchema Also fixed theme/displayMode to match original values: - theme: 'light' | 'dark' (removed 'system') - displayMode: 'inline' | 'fullscreen' | 'pip' (removed 'carousel') Verified: all 47 exports from main are preserved with compatible types. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: stage generated files after prettier in pre-commit hook The pre-commit hook runs build (which generates schemas) then prettier (which formats them). The formatted generated files weren't being staged, causing CI to fail with 'Generated schemas are out of date'. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * update generated files * fix: run prettier after regenerating schemas in CI verification CI was comparing unformatted generated output against formatted committed files, causing false 'out of date' failures. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
4953cd320e
commit
f06340b6d9
1 changed files with 5346 additions and 1043 deletions
281
scripts/generate-schemas.ts
Normal file
281
scripts/generate-schemas.ts
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
/**
|
||||
* Schema Generation Script using ts-to-zod as a library
|
||||
*
|
||||
* This script generates Zod schemas from spec.types.ts and performs necessary
|
||||
* post-processing for compatibility with this project.
|
||||
*
|
||||
* ## Why Post-Processing is Needed
|
||||
*
|
||||
* ts-to-zod is a powerful tool but has limitations that require post-processing:
|
||||
*
|
||||
* ### 1. Zod Import Path (`"zod"` → `"zod/v4"`)
|
||||
*
|
||||
* **Problem**: ts-to-zod generates `import { z } from "zod"` but this project
|
||||
* uses the Zod v4 subpath import `"zod/v4"` for explicit version targeting.
|
||||
*
|
||||
* **Solution**: Replace the import path in the generated output.
|
||||
*
|
||||
* ### 2. External Type References (`z.any()` → actual schemas)
|
||||
*
|
||||
* **Problem**: ts-to-zod cannot resolve types imported from external packages.
|
||||
* When it encounters types like `ContentBlock`, `CallToolResult`, `Implementation`,
|
||||
* `RequestId`, and `Tool` from `@modelcontextprotocol/sdk`, it generates `z.any()`
|
||||
* as a placeholder.
|
||||
*
|
||||
* **Solution**: Import the schemas from MCP SDK and remove the z.any() placeholders.
|
||||
*
|
||||
* ### 3. Index Signatures (`z.record().and()` → `z.looseObject()`)
|
||||
*
|
||||
* **Problem**: TypeScript index signatures like `[key: string]: unknown` are
|
||||
* translated by ts-to-zod to `z.record(z.string(), z.unknown()).and(z.object({...}))`.
|
||||
* This creates a `ZodIntersection` type which doesn't support `.extend()` etc.
|
||||
*
|
||||
* **Solution**: Replace the intersection pattern with `z.looseObject()`.
|
||||
*
|
||||
* ## Adding Schema Descriptions
|
||||
*
|
||||
* ts-to-zod supports `@description` JSDoc tags to generate `.describe()` calls:
|
||||
*
|
||||
* ```typescript
|
||||
* interface MyType {
|
||||
* /** @description The user's full name */
|
||||
* name: string;
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Generates: `name: z.string().describe("The user's full name")`
|
||||
*
|
||||
* @see https://github.com/fabien0102/ts-to-zod
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { generate } from "ts-to-zod";
|
||||
import { toJSONSchema, type $ZodType } from "zod/v4/core";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const PROJECT_ROOT = join(__dirname, "..");
|
||||
|
||||
const SPEC_TYPES_FILE = join(PROJECT_ROOT, "src", "spec.types.ts");
|
||||
const GENERATED_DIR = join(PROJECT_ROOT, "src", "generated");
|
||||
const SCHEMA_OUTPUT_FILE = join(GENERATED_DIR, "schema.ts");
|
||||
const SCHEMA_TEST_OUTPUT_FILE = join(GENERATED_DIR, "schema.test.ts");
|
||||
const JSON_SCHEMA_OUTPUT_FILE = join(GENERATED_DIR, "schema.json");
|
||||
|
||||
/**
|
||||
* External types from MCP SDK that ts-to-zod can't resolve.
|
||||
* With PascalCase naming (via getSchemaName), generated placeholders match MCP SDK exports.
|
||||
*/
|
||||
const EXTERNAL_TYPE_SCHEMAS = [
|
||||
"ContentBlockSchema",
|
||||
"CallToolResultSchema",
|
||||
"ImplementationSchema",
|
||||
"RequestIdSchema",
|
||||
"ToolSchema",
|
||||
];
|
||||
|
||||
async function main() {
|
||||
console.log("🔧 Generating Zod schemas from spec.types.ts...\n");
|
||||
|
||||
const sourceText = readFileSync(SPEC_TYPES_FILE, "utf-8");
|
||||
|
||||
const result = generate({
|
||||
sourceText,
|
||||
keepComments: true,
|
||||
skipParseJSDoc: false,
|
||||
// Generate PascalCase schema names: McpUiOpenLinkRequest → McpUiOpenLinkRequestSchema
|
||||
getSchemaName: (typeName: string) => `${typeName}Schema`,
|
||||
});
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
console.error("❌ Generation errors:");
|
||||
for (const error of result.errors) {
|
||||
console.error(` - ${error}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (result.hasCircularDependencies) {
|
||||
console.warn("⚠️ Warning: Circular dependencies detected in types");
|
||||
}
|
||||
|
||||
let schemasContent = result.getZodSchemasFile("../spec.types.js");
|
||||
schemasContent = postProcess(schemasContent);
|
||||
|
||||
writeFileSync(SCHEMA_OUTPUT_FILE, schemasContent, "utf-8");
|
||||
console.log(`✅ Written: ${SCHEMA_OUTPUT_FILE}`);
|
||||
|
||||
const testsContent = result.getIntegrationTestFile(
|
||||
"../spec.types.js",
|
||||
"./schema.js",
|
||||
);
|
||||
if (testsContent) {
|
||||
const processedTests = postProcessTests(testsContent);
|
||||
writeFileSync(SCHEMA_TEST_OUTPUT_FILE, processedTests, "utf-8");
|
||||
console.log(`✅ Written: ${SCHEMA_TEST_OUTPUT_FILE}`);
|
||||
}
|
||||
|
||||
// Generate JSON Schema from the Zod schemas
|
||||
await generateJsonSchema();
|
||||
|
||||
console.log("\n🎉 Schema generation complete!");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate JSON Schema from the Zod schemas.
|
||||
* Uses dynamic import to load the generated schemas after they're written.
|
||||
*/
|
||||
async function generateJsonSchema() {
|
||||
// Dynamic import of the generated schemas
|
||||
// tsx handles TypeScript imports at runtime
|
||||
const schemas = await import("../src/generated/schema.js");
|
||||
|
||||
const jsonSchema: {
|
||||
$schema: string;
|
||||
$id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
$defs: Record<string, unknown>;
|
||||
} = {
|
||||
$schema: "https://json-schema.org/draft/2020-12/schema",
|
||||
$id: "https://modelcontextprotocol.io/ext-apps/schema.json",
|
||||
title: "MCP Apps Protocol",
|
||||
description: "JSON Schema for MCP Apps UI protocol messages",
|
||||
$defs: {},
|
||||
};
|
||||
|
||||
// Convert each exported Zod schema to JSON Schema
|
||||
for (const [name, schema] of Object.entries(schemas)) {
|
||||
if (
|
||||
name.endsWith("Schema") &&
|
||||
typeof schema === "object" &&
|
||||
schema !== null
|
||||
) {
|
||||
const typeName = name.replace(/Schema$/, "");
|
||||
try {
|
||||
// Use unrepresentable: "any" to handle external types (MCP SDK schemas)
|
||||
// that can't be directly represented in JSON Schema
|
||||
jsonSchema.$defs[typeName] = toJSONSchema(schema as $ZodType, {
|
||||
unrepresentable: "any",
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(`⚠️ Could not convert ${name} to JSON Schema: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(
|
||||
JSON_SCHEMA_OUTPUT_FILE,
|
||||
JSON.stringify(jsonSchema, null, 2) + "\n",
|
||||
"utf-8",
|
||||
);
|
||||
console.log(`✅ Written: ${JSON_SCHEMA_OUTPUT_FILE}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-process generated schemas for project compatibility.
|
||||
*/
|
||||
function postProcess(content: string): string {
|
||||
// 1. Update import to use zod/v4
|
||||
content = content.replace(
|
||||
'import { z } from "zod";',
|
||||
'import { z } from "zod/v4";',
|
||||
);
|
||||
|
||||
// 2. Add MCP SDK schema imports
|
||||
const mcpImports = EXTERNAL_TYPE_SCHEMAS.join(",\n ");
|
||||
content = content.replace(
|
||||
'import { z } from "zod/v4";',
|
||||
`import { z } from "zod/v4";
|
||||
import {
|
||||
${mcpImports},
|
||||
} from "@modelcontextprotocol/sdk/types.js";`,
|
||||
);
|
||||
|
||||
// 3. Remove z.any() placeholders for external types (now imported from MCP SDK)
|
||||
for (const schema of EXTERNAL_TYPE_SCHEMAS) {
|
||||
content = content.replace(
|
||||
new RegExp(`(?:export )?const ${schema} = z\\.any\\(\\);\\n?`, "g"),
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Replace z.record().and(z.object({...})) with z.looseObject({...})
|
||||
// Uses brace-counting to handle nested objects correctly.
|
||||
content = replaceRecordAndWithLooseObject(content);
|
||||
|
||||
// 5. Add header comment
|
||||
content = content.replace(
|
||||
"// Generated by ts-to-zod",
|
||||
`// Generated by ts-to-zod
|
||||
// Post-processed for Zod v4 and MCP SDK compatibility
|
||||
// Run: npm run generate:schemas`,
|
||||
);
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace z.record(z.string(), z.unknown()).and(z.object({...})) with z.looseObject({...})
|
||||
* Uses brace-counting to handle nested objects correctly.
|
||||
*/
|
||||
function replaceRecordAndWithLooseObject(content: string): string {
|
||||
const pattern = "z.record(z.string(), z.unknown()).and(z.object({";
|
||||
let result = content;
|
||||
let startIndex = 0;
|
||||
|
||||
while (true) {
|
||||
const matchStart = result.indexOf(pattern, startIndex);
|
||||
if (matchStart === -1) break;
|
||||
|
||||
// Find the matching closing brace for z.object({
|
||||
const objectStart = matchStart + pattern.length;
|
||||
let braceCount = 1;
|
||||
let i = objectStart;
|
||||
|
||||
while (i < result.length && braceCount > 0) {
|
||||
if (result[i] === "{") braceCount++;
|
||||
else if (result[i] === "}") braceCount--;
|
||||
i++;
|
||||
}
|
||||
|
||||
// i now points after the closing } of z.object({...})
|
||||
// Check if followed by ))
|
||||
if (result.slice(i, i + 2) === "))") {
|
||||
const objectContent = result.slice(objectStart, i - 1);
|
||||
const replacement = `z.looseObject({${objectContent}})`;
|
||||
result = result.slice(0, matchStart) + replacement + result.slice(i + 2);
|
||||
startIndex = matchStart + replacement.length;
|
||||
} else {
|
||||
startIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-process generated integration tests.
|
||||
*/
|
||||
function postProcessTests(content: string): string {
|
||||
content = content.replace(
|
||||
'import { z } from "zod";',
|
||||
'import { z } from "zod/v4";',
|
||||
);
|
||||
|
||||
content = content.replace(
|
||||
"// Generated by ts-to-zod",
|
||||
`// Generated by ts-to-zod
|
||||
// Integration tests verifying schemas match TypeScript types
|
||||
// Run: npm run generate:schemas`,
|
||||
);
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("❌ Schema generation failed:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
Loading…
Reference in a new issue