Skip to content

Commit 0a9bddb

Browse files
ochafikclaude
andcommitted
feat: make generated Zod schemas version-agnostic (v3.25+/v4)
- Change generated schema imports from `zod/v4` to standard `zod` - Replace v4-only `z.looseObject()` with `z.object().passthrough()` - Works with both Zod v3.25+ and v4, matching MCP TS SDK approach - Bump version to 0.2.2 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 3e95d64 commit 0a9bddb

File tree

4 files changed

+169
-168
lines changed

4 files changed

+169
-168
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"url": "https://github.com/modelcontextprotocol/ext-apps"
66
},
77
"homepage": "https://github.com/modelcontextprotocol/ext-apps",
8-
"version": "0.2.0",
8+
"version": "0.2.2",
99
"license": "MIT",
1010
"description": "MCP Apps SDK — Enable MCP servers to display interactive user interfaces in conversational clients.",
1111
"type": "module",

scripts/generate-schemas.ts

Lines changed: 20 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,10 @@
88
*
99
* ts-to-zod is a powerful tool but has limitations that require post-processing:
1010
*
11-
* ### 1. Zod Import Path (`"zod"` → `"zod/v4"`)
11+
* ### 1. Zod Import Path (keep standard `"zod"` for version agnosticism)
1212
*
13-
* **Problem**: ts-to-zod generates `import { z } from "zod"` but this project
14-
* uses the Zod v4 subpath import `"zod/v4"` for explicit version targeting.
15-
*
16-
* **Solution**: Replace the import path in the generated output.
13+
* ts-to-zod generates `import { z } from "zod"` which works with both
14+
* Zod v3.25+ and v4. We keep this standard import to support both versions.
1715
*
1816
* ### 2. External Type References (`z.any()` → actual schemas)
1917
*
@@ -24,13 +22,14 @@
2422
*
2523
* **Solution**: Import the schemas from MCP SDK and remove the z.any() placeholders.
2624
*
27-
* ### 3. Index Signatures (`z.record().and()` → `z.looseObject()`)
25+
* ### 3. Index Signatures (`z.record().and()` → `z.object().passthrough()`)
2826
*
2927
* **Problem**: TypeScript index signatures like `[key: string]: unknown` are
3028
* translated by ts-to-zod to `z.record(z.string(), z.unknown()).and(z.object({...}))`.
3129
* This creates a `ZodIntersection` type which doesn't support `.extend()` etc.
3230
*
33-
* **Solution**: Replace the intersection pattern with `z.looseObject()`.
31+
* **Solution**: Replace with `z.object({...}).passthrough()` which works in both
32+
* Zod v3 and v4, allowing extra properties while validating known ones.
3433
*
3534
* ## Adding Schema Descriptions
3635
*
@@ -178,50 +177,46 @@ async function generateJsonSchema() {
178177
* Post-process generated schemas for project compatibility.
179178
*/
180179
function postProcess(content: string): string {
181-
// 1. Update import to use zod/v4
182-
content = content.replace(
183-
'import { z } from "zod";',
184-
'import * as z from "zod/v4";',
185-
);
186-
187-
// 2. Add MCP SDK schema imports
180+
// 1. Add MCP SDK schema imports (keep standard zod import for v3/v4 compatibility)
188181
const mcpImports = EXTERNAL_TYPE_SCHEMAS.join(",\n ");
189182
content = content.replace(
190-
'import * as z from "zod/v4";',
191-
`import * as z from "zod/v4";
183+
'import { z } from "zod";',
184+
`import { z } from "zod";
192185
import {
193186
${mcpImports},
194187
} from "@modelcontextprotocol/sdk/types.js";`,
195188
);
196189

197-
// 3. Remove z.any() placeholders for external types (now imported from MCP SDK)
190+
// 2. Remove z.any() placeholders for external types (now imported from MCP SDK)
198191
for (const schema of EXTERNAL_TYPE_SCHEMAS) {
199192
content = content.replace(
200193
new RegExp(`(?:export )?const ${schema} = z\\.any\\(\\);\\n?`, "g"),
201194
"",
202195
);
203196
}
204197

205-
// 4. Replace z.record().and(z.object({...})) with z.looseObject({...})
198+
// 3. Replace z.record().and(z.object({...})) with z.object({...}).passthrough()
206199
// Uses brace-counting to handle nested objects correctly.
207-
content = replaceRecordAndWithLooseObject(content);
200+
// passthrough() works in both Zod v3 and v4, unlike looseObject() which is v4-only.
201+
content = replaceRecordAndWithPassthrough(content);
208202

209-
// 5. Add header comment
203+
// 4. Add header comment
210204
content = content.replace(
211205
"// Generated by ts-to-zod",
212206
`// Generated by ts-to-zod
213-
// Post-processed for Zod v4 and MCP SDK compatibility
207+
// Post-processed for Zod v3/v4 compatibility and MCP SDK integration
214208
// Run: npm run generate:schemas`,
215209
);
216210

217211
return content;
218212
}
219213

220214
/**
221-
* Replace z.record(z.string(), z.unknown()).and(z.object({...})) with z.looseObject({...})
215+
* Replace z.record(z.string(), z.unknown()).and(z.object({...})) with z.object({...}).passthrough()
222216
* Uses brace-counting to handle nested objects correctly.
217+
* passthrough() works in both Zod v3 and v4, allowing extra properties.
223218
*/
224-
function replaceRecordAndWithLooseObject(content: string): string {
219+
function replaceRecordAndWithPassthrough(content: string): string {
225220
const pattern = "z.record(z.string(), z.unknown()).and(z.object({";
226221
let result = content;
227222
let startIndex = 0;
@@ -245,7 +240,7 @@ function replaceRecordAndWithLooseObject(content: string): string {
245240
// Check if followed by ))
246241
if (result.slice(i, i + 2) === "))") {
247242
const objectContent = result.slice(objectStart, i - 1);
248-
const replacement = `z.looseObject({${objectContent}})`;
243+
const replacement = `z.object({${objectContent}}).passthrough()`;
249244
result = result.slice(0, matchStart) + replacement + result.slice(i + 2);
250245
startIndex = matchStart + replacement.length;
251246
} else {
@@ -260,11 +255,7 @@ function replaceRecordAndWithLooseObject(content: string): string {
260255
* Post-process generated integration tests.
261256
*/
262257
function postProcessTests(content: string): string {
263-
content = content.replace(
264-
'import { z } from "zod";',
265-
'import * as z from "zod/v4";',
266-
);
267-
258+
// Keep standard zod import for v3/v4 compatibility
268259
content = content.replace(
269260
"// Generated by ts-to-zod",
270261
`// Generated by ts-to-zod

src/generated/schema.test.ts

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)