Skip to content

Conversation

@ochafik
Copy link
Contributor

@ochafikochafik commented Dec 12, 2025

Summary

This PR adds auto-generated Zod schemas and types from spec.types.ts using ts-to-zod + AST-based refactorings (for complete backwards compatibility), plus discriminated union types (McpRequest, McpNotification, McpResult) for better TypeScript type narrowing.

See Reviewer Verification section below for scripts to compare exports between branches.

Key Features

1. Schema Generation Pipeline

spec.types.ts (from MCP spec repo) ↓ preProcessTypes() [AST transforms] sdk.types.ts (SDK-compatible types + union types) ↓ ts-to-zod library ↓ postProcess() [AST transforms] sdk.schemas.ts (Zod schemas) 

2. Discriminated Union Types for Type Narrowing

// New union types enable TypeScript narrowing on 'method' fieldtypeMcpRequest=InitializeRequest|PingRequest|CallToolRequest| ... functionhandleRequest(request: McpRequest){if(request.method==='tools/call'){// TypeScript knows this is CallToolRequestconsole.log(request.params.name);// ✓ typed as string}}

3. Naming Scheme

TypeNameDescription
Base interfaceRequest, Notification, ResultBackwards-compatible base types
Union typeMcpRequest, McpNotification, McpResultDiscriminated unions for narrowing

4. Client/Server Use Union Types by Default

Client and Server now default to union types for better type narrowing out of the box:

// Before: Client<Request, Notification, Result>// After: Client<McpRequest, McpNotification, McpResult>constclient=newClient({name: "my-client",version: "1.0"});// Users get type narrowing automatically// Custom types still work (must extend base types)constclient=newClient<MyRequest,MyNotification,MyResult>(...);

Note: This is technically a breaking change if you explicitly annotated variables with the old defaults (e.g., Client<Request, Notification, Result>), but this is unlikely since those were the defaults and writing them explicitly would be redundant.

Schema Post-Processing

The script applies several transforms for SDK compatibility:

TransformPurpose
Zod v4 import"zod""zod/v4"
Index signaturesz.record().and(z.object())z.looseObject()
Integer refinementsAdd .int() to ProgressTokenSchema, RequestIdSchema
Content defaultsAdd .default([]) to content arrays for backwards compat
PassthroughAdd .passthrough() to ToolSchema.outputSchema
Union orderingReorder so specific schemas match first (EnumSchema before StringSchema)
Discriminated unionsConvert tagged unions for better performance
Field validationAdd datetime, startsWith, base64 validation

Configuration-Driven Transforms

Transforms are declaratively configured:

// Schemas needing .default([]) on content fieldconstARRAY_DEFAULT_FIELDS={'CallToolResultSchema': ['content'],'ToolResultContentSchema': ['content'],};// Union member ordering (more specific schemas first)constUNION_MEMBER_ORDER={'PrimitiveSchemaDefinitionSchema': ['EnumSchemaSchema','BooleanSchemaSchema', ...],'EnumSchemaSchema': ['LegacyTitledEnumSchemaSchema', ...],};

Generated Files

FileDescription
sdk.types.tsPre-processed types with base interfaces + union types
sdk.schemas.tsZod schemas with all post-processing applied
sdk.schemas.zod.test.tsIntegration tests for schema/type alignment

types.ts: Now a Thin Re-export Layer

src/types.ts is now primarily a re-export layer from the generated files:

// ~130 types re-exported from generated fileexporttype{Request,Notification,Result,McpRequest,McpNotification,McpResult, ... }from'./generated/sdk.types.js';// ~120 schemas re-exported from generated fileexport{JSONRPCMessageSchema,CallToolResultSchema, ... }from'./generated/sdk.schemas.js';// Only a few SDK-specific items still defined locally:// - JSONRPCResponseSchema (union of result + error)// - ProgressSchema (derived from ProgressNotificationParamsSchema)// - Type guards (isJSONRPCRequest, etc.)// - ErrorCode enum

Test Results

  • ✅ Typecheck: 0 errors
  • ✅ Tests: 1583 passed
  • ✅ All union types work correctly for narrowing
  • ✅ Export comparison: 329 → 334 symbols (5 additions, 0 removals)

Reviewer Verification

export-symbols.ts
#!/usr/bin/env npx tsx /** * List all exported symbols from src/types.ts using TypeScript compiler API. * Usage: npx tsx export-symbols.ts [--json] */import*astsfrom'typescript';import*aspathfrom'path';consttypesPath=path.resolve(import.meta.dirname,'src/types.ts');constjsonOutput=process.argv.includes('--json');constprogram=ts.createProgram([typesPath],{target: ts.ScriptTarget.ESNext,module: ts.ModuleKind.ESNext,moduleResolution: ts.ModuleResolutionKind.NodeNext,strict: true,skipLibCheck: true,});constchecker=program.getTypeChecker();constsourceFile=program.getSourceFile(typesPath);if(!sourceFile){console.error('Could not find:',typesPath);process.exit(1);}interfaceExportInfo{name: string;kind: string;isType: boolean;}constexports: ExportInfo[]=[];constmoduleSymbol=checker.getSymbolAtLocation(sourceFile);if(moduleSymbol){for(constsymbolofchecker.getExportsOfModule(moduleSymbol)){constname=symbol.getName();constdeclarations=symbol.getDeclarations();letkind='unknown',isType=false;if(declarations?.length){constdecl=declarations[0];if(ts.isTypeAliasDeclaration(decl)){kind='type';isType=true;}elseif(ts.isInterfaceDeclaration(decl)){kind='interface';isType=true;}elseif(ts.isVariableDeclaration(decl)){kind='const';}elseif(ts.isEnumDeclaration(decl)){kind='enum';}elseif(ts.isFunctionDeclaration(decl)){kind='function';}elseif(ts.isExportSpecifier(decl)){constorig=checker.getAliasedSymbol(symbol).getDeclarations()?.[0];if(orig){if(ts.isTypeAliasDeclaration(orig)){kind='type';isType=true;}elseif(ts.isInterfaceDeclaration(orig)){kind='interface';isType=true;}elseif(ts.isVariableDeclaration(orig)){kind='const';}elseif(ts.isEnumDeclaration(orig)){kind='enum';}}}}exports.push({ name, kind, isType });}}exports.sort((a,b)=>a.name.localeCompare(b.name));if(jsonOutput){console.log(JSON.stringify(exports,null,2));}else{exports.forEach(e=>console.log(`${e.kind}\t${e.name}`));}
# Compare exported symbols between branches npx tsx export-symbols.ts --json > pr-symbols.json git stash && git checkout main npx tsx export-symbols.ts --json > main-symbols.json git checkout - && git stash pop diff <(jq -r '.[].name' main-symbols.json | sort)<(jq -r '.[].name' pr-symbols.json | sort)

Expected: Only additions (5 new exports), no removals:

  • McpRequest, McpNotification, McpResult - new union types for type narrowing
  • ElicitationCapabilitySchema, ErrorSchema - new schemas
export-schemas-to-json.ts
#!/usr/bin/env npx tsx /** * Export all Zod schemas to JSON Schema format. * Usage: npm run build && npx tsx export-schemas-to-json.ts */import{toJSONSchema}from'zod/v4-mini';importtype{$ZodType}from'zod/v4/core';import*astypesfrom'./dist/esm/types.js';constschemaExports: Record<string,unknown>={};for(const[name,value]ofObject.entries(types)){if(name.endsWith('Schema')&&value&&typeofvalue==='object'&&'_zod'invalue){try{schemaExports[name]=toJSONSchema(valueas$ZodType,{target: 'draft-7'});}catch(e){schemaExports[name]={error: String(e)};}}}constsorted: Record<string,unknown>={};for(constnameofObject.keys(schemaExports).sort()){sorted[name]=schemaExports[name];}console.log(JSON.stringify(sorted,null,2));
# Compare JSON Schema output between branches npm run build && npx tsx export-schemas-to-json.ts > pr-schemas.json git stash && git checkout main npm run build && npx tsx export-schemas-to-json.ts > main-schemas.json git checkout - && git stash pop diff <(jq -r 'keys[]' main-schemas.json | sort)<(jq -r 'keys[]' pr-schemas.json | sort)

🤖 Generated with Claude Code

@pkg-pr-new
Copy link

pkg-pr-newbot commented Dec 12, 2025

Open in StackBlitz

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/sdk@1292 

commit: 1ebf506

@ochafikochafikforce-pushed the ochafik/ts2zod-library-approach branch 2 times, most recently from b093040 to 400cfa6CompareDecember 12, 2025 16:59
@ochafikochafik changed the title feat: add library-based ts-to-zod schema generationfeat: generate schemas from types, improve type definitionsDec 13, 2025
@ochafikochafikforce-pushed the ochafik/ts2zod-library-approach branch from 9c79016 to 2ca16dcCompareDecember 13, 2025 17:14
This refactors the type generation approach to use the ts-to-zod library for converting TypeScript types to Zod schemas, replacing the previous manual schema generation. Key changes: - Add scripts/generate-schemas.ts using ts-to-zod as a library with: - Custom pre-processing to inject SDK-specific features (Base64 validation, description JSDoc tags, RELATED_TASK_META_KEY) - Post-processing transforms for strict() mode, discriminatedUnions, enums - AST-based schema post-processing with ts-morph for robust transformations - Generate src/generated/sdk.types.ts (pre-processed TypeScript types) - Generate src/generated/sdk.schemas.ts (Zod schemas with SDK conventions) - Refactor src/types.ts to re-export most schemas from generated code - Convert Request/Notification/Result from intersection to union types for better type narrowing in switch statements - Add union types: McpRequest, McpNotification, McpResult for type guards - Fix test files for union type compatibility and new schema structures - Add comprehensive schema comparison tests The generated schemas maintain full compatibility with the existing API while improving type safety and reducing manual maintenance burden. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
@ochafikochafikforce-pushed the ochafik/ts2zod-library-approach branch 2 times, most recently from 0e236d5 to ac367a9CompareDecember 13, 2025 17:34
- Generate schemas during `npm run build` - Add `npm run check:schemas` script to verify generated files are in sync - Run schema sync check in CI after build to catch uncommitted changes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
@ochafikochafikforce-pushed the ochafik/ts2zod-library-approach branch from ac367a9 to ff03e42CompareDecember 13, 2025 17:35
ochafikand others added 2 commits December 13, 2025 17:41
Replace manual BASE_TO_UNION_CONFIG (46 items) with auto-discovery that: - Finds interfaces transitively extending Request/Notification/Result - Finds type aliases referencing the base type (e.g., EmptyResult = Result) - Filters by naming convention (*Request, *Notification, *Result) - Excludes abstract bases via small exclusion list (4 items) This eliminates maintenance burden when spec adds new request/result types. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
- Add --if-changed flag that skips regeneration if outputs are newer than inputs - Use this flag in build script for faster incremental builds - Move prettier formatting inside the script for cleaner integration Benchmarks: - Full generation: ~5.3s - With --if-changed (skip): ~1.0s (tsx startup only) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
@ochafikochafikforce-pushed the ochafik/ts2zod-library-approach branch 3 times, most recently from 68a830e to 2421901CompareDecember 13, 2025 17:56
Restructure generate-schemas.ts with clear separation of concerns: - TYPE_TRANSFORMS: Adapt spec types for SDK (extends clauses, extensions) - TYPE_CLEANUP_TRANSFORMS: Prepare for export (remove index sigs, create unions) - SCHEMA_TRANSFORMS: Post-process ts-to-zod output for Zod v4 Key improvements: - `named()` helper creates programmatic names for parameterized transforms (e.g., `extendsClause(JSONRPCRequest→Request)`) - `applyTransforms()` runs pipelines with consistent logging - Transforms are declarative arrays, easy to reorder/add/remove - Clear 3-phase pipeline visible in main() 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
@ochafikochafikforce-pushed the ochafik/ts2zod-library-approach branch from 2421901 to 1ebf506CompareDecember 13, 2025 17:59
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@ochafik