* 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>
281 lines
8.6 KiB
TypeScript
281 lines
8.6 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|