ext-apps/scripts/generate-schemas.ts
Olivier Chafik f06340b6d9
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>
2025-12-10 01:05:49 +01:00

281 lines
8.6 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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