11/**
22 * Schema Generation Script using ts-to-zod as a library
33 *
4- * This script generates Zod schemas from spec.types.ts and performs necessary
5- * post-processing for compatibility with this project .
4+ * This script generates Zod schemas from spec.types.ts with pre-processing and
5+ * post-processing for SDK compatibility .
66 *
7- * ## Why Library-based Generation?
7+ * ## Pipeline
88 *
9- * Using ts-to-zod as a library (vs CLI) provides:
10- * - Access to configuration options like getSchemaName, keepComments
11- * - Ability to generate integration tests that verify type-schema alignment
12- * - Programmatic post-processing with full control
9+ * 1. **Pre-process spec.types.ts** - Transform type hierarchy to match SDK:
10+ * - `extends JSONRPCRequest` → `extends Request`
11+ * - `extends JSONRPCNotification` → `extends Notification`
1312 *
14- * ## Post-Processing
13+ * 2. **Generate schemas** via ts-to-zod library
1514 *
16- * ts-to-zod has limitations that require post-processing:
15+ * 3. **Post-process schemas** for Zod v4 compatibility:
16+ * - `"zod"` → `"zod/v4"`
17+ * - `z.record().and(z.object())` → `z.looseObject()`
18+ * - `jsonrpc: z.any()` → `z.literal("2.0")`
19+ * - Add `.int()` refinements to ProgressTokenSchema, RequestIdSchema
1720 *
18- * ### 1. Zod Import Path (`"zod"` → `"zod/v4"`)
19- * ts-to-zod generates `import { z } from "zod"` but this project uses `"zod/v4"`.
21+ * ## Why Pre-Process Types?
2022 *
21- * ### 2. Index Signatures (`z.record().and()` → `z.looseObject()`)
22- * TypeScript index signatures like `[key: string]: unknown` are translated to
23- * `z.record(z.string(), z.unknown()).and(z.object({...}))`, which creates
24- * ZodIntersection types that don't support `.extend()`. We replace these with
25- * `z.looseObject()`.
23+ * The MCP spec defines request/notification types extending JSONRPCRequest/JSONRPCNotification
24+ * which include `jsonrpc` and `id` fields. The SDK handles these at the transport layer,
25+ * so SDK types extend the simpler Request/Notification without these fields.
2626 *
27- * ### 3. TypeOf Expressions (`z.any()` → literal values)
28- * ts-to-zod can't translate `typeof CONST` expressions and falls back to `z.any()`.
29- * We replace these with the actual literal values from the spec:
30- * - `jsonrpc: z.any()` → `jsonrpc: z.literal("2.0")`
31- * - `code: z.any()` for URL_ELICITATION_REQUIRED → `code: z.literal(-32042)`
27+ * By transforming the types BEFORE schema generation, we get schemas that match
28+ * the SDK's type hierarchy exactly, enabling types.ts to re-export from generated/.
3229 *
3330 * @see https://github.com/fabien0102/ts-to-zod
3431 */
@@ -46,6 +43,51 @@ const GENERATED_DIR = join(PROJECT_ROOT, 'src', 'generated');
4643const SCHEMA_OUTPUT_FILE = join ( GENERATED_DIR , 'spec.schemas.ts' ) ;
4744const SCHEMA_TEST_OUTPUT_FILE = join ( GENERATED_DIR , 'spec.schemas.zod.test.ts' ) ;
4845
46+ // =============================================================================
47+ // Pre-processing: Transform spec types to SDK-compatible hierarchy
48+ // =============================================================================
49+
50+ /**
51+ * Pre-process spec.types.ts to transform type hierarchy for SDK compatibility.
52+ *
53+ * The MCP spec defines:
54+ * - `interface InitializeRequest extends JSONRPCRequest { ... }`
55+ * - `interface CancelledNotification extends JSONRPCNotification { ... }`
56+ *
57+ * JSONRPCRequest/JSONRPCNotification include `jsonrpc` and `id` fields.
58+ * The SDK handles these at the transport layer, so SDK types should extend
59+ * the simpler Request/Notification without these fields.
60+ *
61+ * This transformation allows the generated schemas to match types.ts exactly.
62+ *
63+ * ## Alternative: ts-morph for AST-based transforms
64+ *
65+ * If regex becomes fragile, consider using ts-morph for precise AST manipulation:
66+ * ```typescript
67+ * import { Project } from 'ts-morph';
68+ * const project = new Project();
69+ * const sourceFile = project.createSourceFile('temp.ts', content);
70+ * for (const iface of sourceFile.getInterfaces()) {
71+ * for (const ext of iface.getExtends()) {
72+ * if (ext.getText() === 'JSONRPCRequest') ext.replaceWithText('Request');
73+ * if (ext.getText() === 'JSONRPCNotification') ext.replaceWithText('Notification');
74+ * }
75+ * }
76+ * return sourceFile.getFullText();
77+ * ```
78+ */
79+ function preProcessTypes ( content : string ) : string {
80+ // Transform extends clauses for requests
81+ // e.g., "extends JSONRPCRequest" → "extends Request"
82+ content = content . replace ( / \b e x t e n d s \s + J S O N R P C R e q u e s t \b / g, 'extends Request' ) ;
83+
84+ // Transform extends clauses for notifications
85+ // e.g., "extends JSONRPCNotification" → "extends Notification"
86+ content = content . replace ( / \b e x t e n d s \s + J S O N R P C N o t i f i c a t i o n \b / g, 'extends Notification' ) ;
87+
88+ return content ;
89+ }
90+
4991async function main ( ) {
5092 console . log ( '🔧 Generating Zod schemas from spec.types.ts...\n' ) ;
5193
@@ -54,7 +96,9 @@ async function main() {
5496 mkdirSync ( GENERATED_DIR , { recursive : true } ) ;
5597 }
5698
57- const sourceText = readFileSync ( SPEC_TYPES_FILE , 'utf-8' ) ;
99+ // Read and pre-process spec types to match SDK hierarchy
100+ const rawSourceText = readFileSync ( SPEC_TYPES_FILE , 'utf-8' ) ;
101+ const sourceText = preProcessTypes ( rawSourceText ) ;
58102
59103 const result = generate ( {
60104 sourceText,
@@ -116,14 +160,13 @@ function postProcess(content: string): string {
116160 // ts-to-zod can't translate `typeof CONST` and falls back to z.any()
117161 content = fixTypeOfExpressions ( content ) ;
118162
119- // 4. Remap notification/request schemas to SDK-compatible hierarchy
120- // (extend Notification/Request instead of JSONRPCNotification/JSONRPCRequest)
121- content = remapToSdkHierarchy ( content ) ;
122-
123- // 5. Add integer refinements to match SDK types.ts
163+ // 4. Add integer refinements to match SDK types.ts
124164 content = addIntegerRefinements ( content ) ;
125165
126- // 6. Add header comment
166+ // Note: SDK hierarchy remapping is now done as PRE-processing on the types,
167+ // not post-processing on the schemas. See preProcessTypes().
168+
169+ // 5. Add header comment
127170 content = content . replace (
128171 '// Generated by ts-to-zod' ,
129172 `// Generated by ts-to-zod
@@ -182,78 +225,6 @@ function addIntegerRefinements(content: string): string {
182225 return content ;
183226}
184227
185- /**
186- * Remap notification and request schemas to use SDK-compatible hierarchy.
187- *
188- * The spec defines:
189- * - XxxNotification extends JSONRPCNotification (includes jsonrpc field)
190- * - XxxRequest extends JSONRPCRequest (includes jsonrpc, id fields)
191- *
192- * The SDK types.ts uses:
193- * - XxxNotification extends Notification (no jsonrpc field)
194- * - XxxRequest extends Request (no jsonrpc, id fields)
195- *
196- * This allows the jsonrpc/id fields to be handled at the transport layer.
197- */
198- function remapToSdkHierarchy ( content : string ) : string {
199- // List of notifications that should extend NotificationSchema instead of JSONRPCNotificationSchema
200- const notifications = [
201- 'CancelledNotification' ,
202- 'InitializedNotification' ,
203- 'ProgressNotification' ,
204- 'ResourceListChangedNotification' ,
205- 'ResourceUpdatedNotification' ,
206- 'PromptListChangedNotification' ,
207- 'ToolListChangedNotification' ,
208- 'TaskStatusNotification' ,
209- 'LoggingMessageNotification' ,
210- 'RootsListChangedNotification' ,
211- 'ElicitationCompleteNotification' ,
212- ] ;
213-
214- // List of requests that should extend RequestSchema instead of JSONRPCRequestSchema
215- const requests = [
216- 'InitializeRequest' ,
217- 'PingRequest' ,
218- 'ListResourcesRequest' ,
219- 'ListResourceTemplatesRequest' ,
220- 'ReadResourceRequest' ,
221- 'SubscribeRequest' ,
222- 'UnsubscribeRequest' ,
223- 'ListPromptsRequest' ,
224- 'GetPromptRequest' ,
225- 'ListToolsRequest' ,
226- 'CallToolRequest' ,
227- 'GetTaskRequest' ,
228- 'ListTasksRequest' ,
229- 'GetTaskPayloadRequest' ,
230- 'CancelTaskRequest' ,
231- 'SetLevelRequest' ,
232- 'CreateMessageRequest' ,
233- 'CompleteRequest' ,
234- 'ListRootsRequest' ,
235- 'ElicitRequest' ,
236- ] ;
237-
238- // Replace JSONRPCNotificationSchema.extend with NotificationSchema.extend for specific schemas
239- for ( const name of notifications ) {
240- content = content . replace (
241- new RegExp ( `export const ${ name } Schema = JSONRPCNotificationSchema\\.extend\\(` ) ,
242- `export const ${ name } Schema = NotificationSchema.extend(`
243- ) ;
244- }
245-
246- // Replace JSONRPCRequestSchema.extend with RequestSchema.extend for specific schemas
247- for ( const name of requests ) {
248- content = content . replace (
249- new RegExp ( `export const ${ name } Schema = JSONRPCRequestSchema\\.extend\\(` ) ,
250- `export const ${ name } Schema = RequestSchema.extend(`
251- ) ;
252- }
253-
254- return content ;
255- }
256-
257228/**
258229 * Replace z.record(z.string(), z.unknown()).and(z.object({...})) with z.looseObject({...})
259230 * Uses brace-counting to handle nested objects correctly.
0 commit comments