Skip to content

Commit 6eef9e6

Browse files
ochafikclaude
andcommitted
refactor: re-export ClientCapabilitiesSchema and ServerCapabilitiesSchema from generated
- Remove local ClientCapabilitiesSchema and ServerCapabilitiesSchema from types.ts - Add AssertObjectSchema and looseObject transforms to generated capability schemas - Add z.preprocess for ClientCapabilitiesSchema.elicitation backwards compatibility - Cast applyDefaults access in client/index.ts (SDK extension not in spec) - Comment out bidirectional type checks for looseObject schemas in test file This makes types.ts a thinner re-export layer from generated schemas. The capability schemas now come from generated with proper transforms: - AssertObjectSchema for better typing than z.record(z.string(), z.any()) - looseObject for extensibility (spec says "not a closed set") - z.preprocess for elicitation to handle empty {} → { form: {} } 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent fba3e0e commit 6eef9e6

File tree

5 files changed

+274
-275
lines changed

5 files changed

+274
-275
lines changed

scripts/generate-schemas.ts

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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\.record\(z\.string\(\), z\.any\(\)\)/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\.object\(/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 = /elicitation:\s*(z\s*\.\s*object\(\s*\{[^}]*form:[^}]*url:[^}]*\}\s*\))\s*\.optional\(\)(\s*\.describe\([^)]*\))?/;
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

src/client/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -407,7 +407,8 @@ export class Client<
407407
const requestedSchema = params.mode === 'form' ? (params.requestedSchema as JsonSchemaType) : undefined;
408408

409409
if (params.mode === 'form' && validatedResult.action === 'accept' && validatedResult.content && requestedSchema) {
410-
if (this._capabilities.elicitation?.form?.applyDefaults) {
410+
// applyDefaults is an SDK extension not in the spec, so cast to access it
411+
if ((this._capabilities.elicitation?.form as { applyDefaults?: boolean } | undefined)?.applyDefaults) {
411412
try {
412413
applyElicitationDefaults(requestedSchema, validatedResult.content);
413414
} catch {

0 commit comments

Comments
 (0)