Skip to content

Commit e223551

Browse files
ochafikclaude
andcommitted
refactor: pre-process types instead of post-processing schemas
Move the SDK hierarchy transformation from post-processing (on generated schemas) to pre-processing (on source types). This is cleaner because: 1. Types are transformed ONCE, before schema generation 2. Generated schemas directly reflect SDK hierarchy 3. No need to maintain a list of request/notification names Transform applied: - `extends JSONRPCRequest` → `extends Request` - `extends JSONRPCNotification` → `extends Notification` Added ts-morph alternative in comments for future reference if regex becomes fragile. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 400cfa6 commit e223551

File tree

2 files changed

+72
-101
lines changed

2 files changed

+72
-101
lines changed

scripts/generate-schemas.ts

Lines changed: 71 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,31 @@
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');
4643
const SCHEMA_OUTPUT_FILE = join(GENERATED_DIR, 'spec.schemas.ts');
4744
const 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(/\bextends\s+JSONRPCRequest\b/g, 'extends Request');
83+
84+
// Transform extends clauses for notifications
85+
// e.g., "extends JSONRPCNotification" → "extends Notification"
86+
content = content.replace(/\bextends\s+JSONRPCNotification\b/g, 'extends Notification');
87+
88+
return content;
89+
}
90+
4991
async 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.

src/generated/spec.schemas.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -563,7 +563,7 @@ export const PaginatedRequestParamsSchema = RequestParamsSchema.extend({
563563
});
564564

565565
/** @internal */
566-
export const PaginatedRequestSchema = JSONRPCRequestSchema.extend({
566+
export const PaginatedRequestSchema = RequestSchema.extend({
567567
params: PaginatedRequestParamsSchema.optional()
568568
});
569569

0 commit comments

Comments
 (0)