spec.types.ts previously inferred CallToolResult, ContentBlock and friends from core's Zod schemas via a local core-types module so that the type surface stayed free of a client package edge. Now that client is a required peer, take the types from it directly and delete the indirection. Generated schemas are unchanged.
288 lines
9.4 KiB
TypeScript
288 lines
9.4 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"`)
|
||
*
|
||
* ts-to-zod generates `import { z } from "zod"`. We rewrite this to
|
||
* `import { z } from "zod/v4"` because the generated schemas compose with
|
||
* schemas imported from `@modelcontextprotocol/core`, which the SDK
|
||
* constructs via `zod/v4`. Mixing schemas from the v3 and v4 APIs at runtime
|
||
* fails with errors like `keyValidator._parse is not a function` (v3 internals
|
||
* calling into v4 objects, or vice versa). The `zod/v4` subpath is exported by
|
||
* both zod 3.25+ and zod 4.x, so the peerDependency range is preserved.
|
||
*
|
||
* ### 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` (imported by spec.types.ts from
|
||
* `@modelcontextprotocol/client`), it generates `z.any()` as a placeholder.
|
||
*
|
||
* **Solution**: Import the corresponding Zod schemas from
|
||
* `@modelcontextprotocol/core` (the role-neutral package both client and
|
||
* server re-export) and remove the z.any() placeholders.
|
||
*
|
||
* ### 3. Index Signatures (`z.record().and()` → `z.object().passthrough()`)
|
||
*
|
||
* **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 with `z.object({...}).passthrough()` which works in both
|
||
* Zod v3 and v4, allowing extra properties while validating known ones.
|
||
*
|
||
* ## 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",
|
||
"EmbeddedResourceSchema",
|
||
"ImplementationSchema",
|
||
"RequestIdSchema",
|
||
"ResourceLinkSchema",
|
||
"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. Rewrite to zod/v4 and add MCP SDK schema imports.
|
||
// zod/v4 aligns with the SDK's own zod import — composing v3 and v4
|
||
// schema instances throws at parse time. See header comment for details.
|
||
const mcpImports = EXTERNAL_TYPE_SCHEMAS.join(",\n ");
|
||
content = content.replace(
|
||
'import { z } from "zod";',
|
||
`import { z } from "zod/v4";
|
||
import {
|
||
${mcpImports},
|
||
} from "@modelcontextprotocol/core";`,
|
||
);
|
||
|
||
// 2. 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"),
|
||
"",
|
||
);
|
||
}
|
||
|
||
// 3. Replace z.record().and(z.object({...})) with z.object({...}).passthrough()
|
||
// Uses brace-counting to handle nested objects correctly.
|
||
// passthrough() works in both Zod v3 and v4, unlike looseObject() which is v4-only.
|
||
content = replaceRecordAndWithPassthrough(content);
|
||
|
||
// 4. Add header comment
|
||
content = content.replace(
|
||
"// Generated by ts-to-zod",
|
||
`// Generated by ts-to-zod
|
||
// Post-processed for Zod v3/v4 compatibility and MCP SDK integration
|
||
// Run: npm run generate:schemas`,
|
||
);
|
||
|
||
return content;
|
||
}
|
||
|
||
/**
|
||
* Replace z.record(z.string(), z.unknown()).and(z.object({...})) with z.object({...}).passthrough()
|
||
* Uses brace-counting to handle nested objects correctly.
|
||
* passthrough() works in both Zod v3 and v4, allowing extra properties.
|
||
*/
|
||
function replaceRecordAndWithPassthrough(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.object({${objectContent}}).passthrough()`;
|
||
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 {
|
||
// Rewrite to zod/v4 so z.infer matches the v4 schemas in schema.ts.
|
||
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);
|
||
});
|