Skip to content

Conversation

@ochafik
Copy link
Contributor

@ochafik ochafik 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' field
type McpRequest = InitializeRequest | PingRequest | CallToolRequest | ...

function handleRequest(request: McpRequest) {
  if (request.method === 'tools/call') {
    // TypeScript knows this is CallToolRequest
    console.log(request.params.name); // ✓ typed as string
  }
}

3. Naming Scheme

Type Name Description
Base interface Request, Notification, Result Backwards-compatible base types
Union type McpRequest, McpNotification, McpResult Discriminated 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>

const client = new Client({ name: "my-client", version: "1.0" });
// Users get type narrowing automatically

// Custom types still work (must extend base types)
const client = new Client<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:

Transform Purpose
Zod v4 import "zod""zod/v4"
Index signatures z.record().and(z.object())z.looseObject()
Integer refinements Add .int() to ProgressTokenSchema, RequestIdSchema
Content defaults Add .default([]) to content arrays for backwards compat
Passthrough Add .passthrough() to ToolSchema.outputSchema
Union ordering Reorder so specific schemas match first (EnumSchema before StringSchema)
Discriminated unions Convert tagged unions for better performance
Field validation Add datetime, startsWith, base64 validation

Configuration-Driven Transforms

Transforms are declaratively configured:

// Schemas needing .default([]) on content field
const ARRAY_DEFAULT_FIELDS = {
    'CallToolResultSchema': ['content'],
    'ToolResultContentSchema': ['content'],
};

// Union member ordering (more specific schemas first)
const UNION_MEMBER_ORDER = {
    'PrimitiveSchemaDefinitionSchema': ['EnumSchemaSchema', 'BooleanSchemaSchema', ...],
    'EnumSchemaSchema': ['LegacyTitledEnumSchemaSchema', ...],
};

Generated Files

File Description
sdk.types.ts Pre-processed types with base interfaces + union types
sdk.schemas.ts Zod schemas with all post-processing applied
sdk.schemas.zod.test.ts Integration 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 file
export type { Request, Notification, Result, McpRequest, McpNotification, McpResult, ... } from './generated/sdk.types.js';

// ~120 schemas re-exported from generated file
export { 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 * as ts from 'typescript';
import * as path from 'path';

const typesPath = path.resolve(import.meta.dirname, 'src/types.ts');
const jsonOutput = process.argv.includes('--json');

const program = ts.createProgram([typesPath], {
    target: ts.ScriptTarget.ESNext,
    module: ts.ModuleKind.ESNext,
    moduleResolution: ts.ModuleResolutionKind.NodeNext,
    strict: true,
    skipLibCheck: true,
});

const checker = program.getTypeChecker();
const sourceFile = program.getSourceFile(typesPath);
if (!sourceFile) { console.error('Could not find:', typesPath); process.exit(1); }

interface ExportInfo { name: string; kind: string; isType: boolean; }
const exports: ExportInfo[] = [];

const moduleSymbol = checker.getSymbolAtLocation(sourceFile);
if (moduleSymbol) {
    for (const symbol of checker.getExportsOfModule(moduleSymbol)) {
        const name = symbol.getName();
        const declarations = symbol.getDeclarations();
        let kind = 'unknown', isType = false;

        if (declarations?.length) {
            const decl = declarations[0];
            if (ts.isTypeAliasDeclaration(decl)) { kind = 'type'; isType = true; }
            else if (ts.isInterfaceDeclaration(decl)) { kind = 'interface'; isType = true; }
            else if (ts.isVariableDeclaration(decl)) { kind = 'const'; }
            else if (ts.isEnumDeclaration(decl)) { kind = 'enum'; }
            else if (ts.isFunctionDeclaration(decl)) { kind = 'function'; }
            else if (ts.isExportSpecifier(decl)) {
                const orig = checker.getAliasedSymbol(symbol).getDeclarations()?.[0];
                if (orig) {
                    if (ts.isTypeAliasDeclaration(orig)) { kind = 'type'; isType = true; }
                    else if (ts.isInterfaceDeclaration(orig)) { kind = 'interface'; isType = true; }
                    else if (ts.isVariableDeclaration(orig)) { kind = 'const'; }
                    else if (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';
import type { $ZodType } from 'zod/v4/core';
import * as types from './dist/esm/types.js';

const schemaExports: Record<string, unknown> = {};

for (const [name, value] of Object.entries(types)) {
    if (name.endsWith('Schema') && value && typeof value === 'object' && '_zod' in value) {
        try {
            schemaExports[name] = toJSONSchema(value as $ZodType, { target: 'draft-7' });
        } catch (e) {
            schemaExports[name] = { error: String(e) };
        }
    }
}

const sorted: Record<string, unknown> = {};
for (const name of Object.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-new bot commented Dec 12, 2025

Open in StackBlitz

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

commit: 1ebf506

@ochafik ochafik force-pushed the ochafik/ts2zod-library-approach branch 2 times, most recently from b093040 to 400cfa6 Compare December 12, 2025 16:59
@ochafik ochafik changed the title feat: add library-based ts-to-zod schema generation feat: generate schemas from types, improve type definitions Dec 13, 2025
@ochafik ochafik force-pushed the ochafik/ts2zod-library-approach branch from 9c79016 to 2ca16dc Compare December 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 <noreply@anthropic.com>
@ochafik ochafik force-pushed the ochafik/ts2zod-library-approach branch 2 times, most recently from 0e236d5 to ac367a9 Compare December 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 <noreply@anthropic.com>
@ochafik ochafik force-pushed the ochafik/ts2zod-library-approach branch from ac367a9 to ff03e42 Compare December 13, 2025 17:35
ochafik and 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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
@ochafik ochafik force-pushed the ochafik/ts2zod-library-approach branch 3 times, most recently from 68a830e to 2421901 Compare December 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 <noreply@anthropic.com>
@ochafik ochafik force-pushed the ochafik/ts2zod-library-approach branch from 2421901 to 1ebf506 Compare December 13, 2025 17:59
Sign up for free to 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