@@ -480,6 +480,9 @@ const AST_TRANSFORMS: Transform[] = [
480480 addStrictToSchemas ,
481481 convertToDiscriminatedUnion ,
482482 addTopLevelDescribe ,
483+ addAssertObjectSchema ,
484+ addElicitationPreprocess ,
485+ convertCapabilitiesToLooseObject ,
483486] ;
484487
485488/**
@@ -782,6 +785,142 @@ function addTopLevelDescribe(sourceFile: SourceFile): void {
782785 }
783786}
784787
788+ /**
789+ * Schemas where z.record(z.string(), z.any()) should be replaced with AssertObjectSchema.
790+ * These are capability schemas that use `object` type for extensibility.
791+ */
792+ const ASSERT_OBJECT_SCHEMAS = [
793+ 'ClientCapabilitiesSchema' ,
794+ 'ServerCapabilitiesSchema' ,
795+ 'ClientTasksCapabilitySchema' ,
796+ 'ServerTasksCapabilitySchema' ,
797+ ] ;
798+
799+ /**
800+ * Add AssertObjectSchema definition and replace z.record(z.string(), z.any()) with it
801+ * in capability schemas. This provides better TypeScript typing (object vs { [x: string]: any }).
802+ */
803+ function addAssertObjectSchema ( sourceFile : SourceFile ) : void {
804+ // Check if any of the target schemas exist
805+ const hasTargetSchemas = ASSERT_OBJECT_SCHEMAS . some ( name => sourceFile . getVariableDeclaration ( name ) ) ;
806+ if ( ! hasTargetSchemas ) return ;
807+
808+ // Add AssertObjectSchema definition after imports
809+ const lastImport = sourceFile . getImportDeclarations ( ) . at ( - 1 ) ;
810+ if ( lastImport ) {
811+ lastImport . replaceWithText ( `${ lastImport . getText ( ) }
812+
813+ /**
814+ * Assert 'object' type schema - validates that value is a non-null object.
815+ * Provides better TypeScript typing than z.record(z.string(), z.any()).
816+ * @internal
817+ */
818+ const AssertObjectSchema = z.custom<object>((v): v is object => v !== null && (typeof v === 'object' || typeof v === 'function'));` ) ;
819+ }
820+
821+ // Replace z.record(z.string(), z.any()) with AssertObjectSchema in target schemas
822+ let count = 0 ;
823+ for ( const schemaName of ASSERT_OBJECT_SCHEMAS ) {
824+ const varDecl = sourceFile . getVariableDeclaration ( schemaName ) ;
825+ if ( ! varDecl ) continue ;
826+
827+ const initializer = varDecl . getInitializer ( ) ;
828+ if ( ! initializer ) continue ;
829+
830+ const text = initializer . getText ( ) ;
831+ // Replace the pattern - note we need to handle optional() suffix too
832+ const newText = text
833+ . replace ( / z \. r e c o r d \( z \. s t r i n g \( \) , z \. a n y \( \) \) / g, 'AssertObjectSchema' ) ;
834+
835+ if ( newText !== text ) {
836+ varDecl . setInitializer ( newText ) ;
837+ count ++ ;
838+ }
839+ }
840+
841+ if ( count > 0 ) {
842+ console . log ( ` ✓ Replaced z.record(z.string(), z.any()) with AssertObjectSchema in ${ count } schemas` ) ;
843+ }
844+ }
845+
846+ /**
847+ * Convert capability schemas to use looseObject for extensibility.
848+ * The spec says capabilities are "not a closed set" - any client/server can define
849+ * additional capabilities. Using looseObject allows extra properties.
850+ */
851+ function convertCapabilitiesToLooseObject ( sourceFile : SourceFile ) : void {
852+ const CAPABILITY_SCHEMAS = [
853+ 'ClientCapabilitiesSchema' ,
854+ 'ServerCapabilitiesSchema' ,
855+ 'ClientTasksCapabilitySchema' ,
856+ 'ServerTasksCapabilitySchema' ,
857+ ] ;
858+
859+ let count = 0 ;
860+ for ( const schemaName of CAPABILITY_SCHEMAS ) {
861+ const varDecl = sourceFile . getVariableDeclaration ( schemaName ) ;
862+ if ( ! varDecl ) continue ;
863+
864+ const initializer = varDecl . getInitializer ( ) ;
865+ if ( ! initializer ) continue ;
866+
867+ const text = initializer . getText ( ) ;
868+ // Replace z.object( with z.looseObject( for nested objects in capabilities
869+ // This allows extensibility for additional capability properties
870+ const newText = text . replace ( / z \. o b j e c t \( / g, 'z.looseObject(' ) ;
871+
872+ if ( newText !== text ) {
873+ varDecl . setInitializer ( newText ) ;
874+ count ++ ;
875+ }
876+ }
877+
878+ if ( count > 0 ) {
879+ console . log ( ` ✓ Converted ${ count } capability schemas to use looseObject for extensibility` ) ;
880+ }
881+ }
882+
883+ /**
884+ * Add z.preprocess to ClientCapabilitiesSchema.elicitation for backwards compatibility.
885+ * - preprocess: transforms empty {} to { form: {} } for SDK backwards compatibility
886+ * - keeps original schema structure to maintain type compatibility with spec
887+ */
888+ function addElicitationPreprocess ( sourceFile : SourceFile ) : void {
889+ const varDecl = sourceFile . getVariableDeclaration ( 'ClientCapabilitiesSchema' ) ;
890+ if ( ! varDecl ) return ;
891+
892+ const initializer = varDecl . getInitializer ( ) ;
893+ if ( ! initializer ) return ;
894+
895+ let text = initializer . getText ( ) ;
896+
897+ // Find the elicitation field and wrap with preprocess
898+ // Pattern: elicitation: z.object({ form: ..., url: ... }).optional().describe(...)
899+ // We need to capture everything up to and including the object's closing paren, then handle the trailing .optional().describe() separately
900+ const elicitationPattern = / e l i c i t a t i o n : \s * ( z \s * \. \s * o b j e c t \( \s * \{ [ ^ } ] * f o r m : [ ^ } ] * u r l : [ ^ } ] * \} \s * \) ) \s * \. o p t i o n a l \( \) ( \s * \. d e s c r i b e \( [ ^ ) ] * \) ) ? / ;
901+
902+ const match = text . match ( elicitationPattern ) ;
903+ if ( match ) {
904+ const innerSchema = match [ 1 ] ; // z.object({...}) without .optional()
905+ const describeCall = match [ 2 ] || '' ; // .describe(...) if present
906+ const replacement = `elicitation: z.preprocess(
907+ (value) => {
908+ if (value && typeof value === 'object' && !Array.isArray(value)) {
909+ if (Object.keys(value as Record<string, unknown>).length === 0) {
910+ return { form: {} };
911+ }
912+ }
913+ return value;
914+ },
915+ ${ innerSchema } ${ describeCall }
916+ ).optional()` ;
917+
918+ text = text . replace ( elicitationPattern , replacement ) ;
919+ varDecl . setInitializer ( text ) ;
920+ console . log ( ' ✓ Added z.preprocess to ClientCapabilitiesSchema.elicitation' ) ;
921+ }
922+ }
923+
785924// =============================================================================
786925// Main
787926// =============================================================================
@@ -873,6 +1012,40 @@ function postProcessTests(content: string): string {
8731012// Run: npm run generate:schemas` ,
8741013 ) ;
8751014
1015+ // Comment out bidirectional type checks for schemas that use looseObject.
1016+ // looseObject adds an index signature [x: string]: unknown which makes
1017+ // spec types (without index signature) not assignable to schema-inferred types.
1018+ // The one-way check (schema-inferred → spec) is kept to ensure compatibility.
1019+ const looseObjectSchemas = [
1020+ 'ClientCapabilities' ,
1021+ 'ServerCapabilities' ,
1022+ 'ClientTasksCapability' ,
1023+ 'ServerTasksCapability' ,
1024+ 'InitializeResult' ,
1025+ 'InitializeRequestParams' ,
1026+ 'InitializeRequest' ,
1027+ 'ClientRequest' , // Contains InitializeRequest
1028+ ] ;
1029+
1030+ let commentedCount = 0 ;
1031+ for ( const schemaName of looseObjectSchemas ) {
1032+ // Comment out spec → schema-inferred checks (these fail with looseObject)
1033+ // ts-to-zod generates PascalCase type names
1034+ // Pattern matches: expectType<FooSchemaInferredType>({} as spec.Foo)
1035+ const pattern = new RegExp (
1036+ `(expectType<${ schemaName } SchemaInferredType>\\(\\{\\} as spec\\.${ schemaName } \\))` ,
1037+ 'g'
1038+ ) ;
1039+ const before = content ;
1040+ content = content . replace ( pattern , `// Skip: looseObject index signature incompatible with spec interface\n// $1` ) ;
1041+ if ( before !== content ) {
1042+ commentedCount ++ ;
1043+ }
1044+ }
1045+ if ( commentedCount > 0 ) {
1046+ console . log ( ` ✓ Commented out ${ commentedCount } looseObject type checks in test file` ) ;
1047+ }
1048+
8761049 return content ;
8771050}
8781051
0 commit comments