From e3888069f9bbb53d29ac85d3592096b4cf4be532 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 3 Jul 2025 20:40:43 +0100 Subject: [PATCH] add MakeUnknownsNotOptional to fix optionality of Zod unknown() fields --- package.json | 2 +- src/spec.types.test.ts | 71 ++++++++++++++++++++++++++++++------------ 2 files changed, 52 insertions(+), 21 deletions(-) diff --git a/package.json b/package.json index bbca197b..79da5193 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "dist" ], "scripts": { - "fetch:spec-types": "test -f src/spec.types.ts || curl -o src/spec.types.ts https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/refs/heads/main/schema/draft/schema.ts", + "fetch:spec-types": "curl -o src/spec.types.ts https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/refs/heads/main/schema/draft/schema.ts", "build": "npm run build:esm && npm run build:cjs", "build:esm": "mkdir -p dist/esm && echo '{\"type\": \"module\"}' > dist/esm/package.json && tsc -p tsconfig.prod.json", "build:esm:w": "npm run build:esm -- -w", diff --git a/src/spec.types.test.ts b/src/spec.types.test.ts index b8b89fc6..8914fcba 100644 --- a/src/spec.types.test.ts +++ b/src/spec.types.test.ts @@ -1,3 +1,10 @@ +/** + * This contains: + * - Static type checks to verify the Spec's types are compatible with the SDK's types + * (mutually assignable, w/ slight affordances to get rid of ZodObject.passthrough() index signatures, etc) + * - Runtime checks to verify all Spec types have a static check + * (a few don't have SDK types, see TODOs in this file) + */ import * as SDKTypes from "./types.js"; import * as SpecTypes from "./spec.types.js"; @@ -5,16 +12,40 @@ import * as SpecTypes from "./spec.types.js"; /* eslint-disable @typescript-eslint/no-unsafe-function-type */ /* eslint-disable @typescript-eslint/no-require-imports */ -// Deep version that recursively removes index signatures (caused by ZodObject.passthrough()) and turns unknowns into `object | undefined` -// TODO: make string index mapping tighter -// TODO: split into multiple transformations if needed +// Removes index signatures added by ZodObject.passthrough(). type RemovePassthrough = T extends object ? T extends Array ? Array> : T extends Function ? T : {[K in keyof T as string extends K ? never : K]: RemovePassthrough} - : T; + : T; + +type IsUnknown = [unknown] extends [T] ? [T] extends [unknown] ? true : false : false; + +// Turns {x?: unknown} into {x: unknown} but keeps {_meta?: unknown} unchanged (and leaves other optional properties unchanged, e.g. {x?: string}). +// This works around an apparent quirk of ZodObject.unknown() (makes fields optional) +type MakeUnknownsNotOptional = + IsUnknown extends true + ? unknown + : (T extends object + ? (T extends Array + ? Array> + : (T extends Function + ? T + : Pick & { + // Start with empty object to avoid duplicates + // Make unknown properties required (except _meta) + [K in keyof T as '_meta' extends K ? never : IsUnknown extends true ? K : never]-?: unknown; + } & + Pick extends true ? never : K + }[keyof T]> & { + // Recurse on the picked properties + [K in keyof Pick extends true ? never : K}[keyof T]>]: MakeUnknownsNotOptional + })) + : T); function checkCancelledNotification( sdk: SDKTypes.CancelledNotification, @@ -36,7 +67,7 @@ function checkImplementation( ) { sdk = spec; spec = sdk; -} +} function checkProgressNotification( sdk: SDKTypes.ProgressNotification, spec: SpecTypes.ProgressNotification @@ -564,70 +595,70 @@ function checkJSONRPCMessage( spec = sdk; } function checkCreateMessageRequest( - sdk: RemovePassthrough, // TODO(quirk): some {} typ>e + sdk: RemovePassthrough, spec: SpecTypes.CreateMessageRequest ) { sdk = spec; spec = sdk; } function checkInitializeRequest( - sdk: RemovePassthrough, // TODO(quirk): some {} type + sdk: RemovePassthrough, spec: SpecTypes.InitializeRequest ) { sdk = spec; spec = sdk; } function checkInitializeResult( - sdk: RemovePassthrough, // TODO(quirk): some {} type + sdk: RemovePassthrough, spec: SpecTypes.InitializeResult ) { sdk = spec; spec = sdk; } function checkClientCapabilities( - sdk: RemovePassthrough, // TODO(quirk): {} + sdk: RemovePassthrough, spec: SpecTypes.ClientCapabilities ) { sdk = spec; spec = sdk; } function checkServerCapabilities( - sdk: RemovePassthrough, // TODO(quirk): {} + sdk: RemovePassthrough, spec: SpecTypes.ServerCapabilities ) { sdk = spec; spec = sdk; } function checkClientRequest( - sdk: RemovePassthrough, // TODO(quirk): capabilities.logging is {} + sdk: RemovePassthrough, spec: SpecTypes.ClientRequest ) { sdk = spec; spec = sdk; } function checkServerRequest( - sdk: RemovePassthrough, // TODO(quirk): some {} typ + sdk: RemovePassthrough, spec: SpecTypes.ServerRequest ) { sdk = spec; spec = sdk; } function checkLoggingMessageNotification( - sdk: SDKTypes.LoggingMessageNotification, + sdk: MakeUnknownsNotOptional, spec: SpecTypes.LoggingMessageNotification ) { sdk = spec; - // spec = sdk; // TODO(bug): data is optional + spec = sdk; } function checkServerNotification( - sdk: SDKTypes.ServerNotification, + sdk: MakeUnknownsNotOptional, spec: SpecTypes.ServerNotification ) { sdk = spec; - // spec = sdk; // TODO(bug): data is optional + spec = sdk; } -// TODO(bug): missing type in SDK +// TODO(bug): missing type in SDK. This dead code is checked by the test suite below. // function checkModelHint( // RemovePassthrough< sdk: SDKTypes.ModelHint>, // spec: SpecTypes.ModelHint @@ -636,7 +667,7 @@ function checkServerNotification( // spec = sdk; // } -// TODO(bug): missing type in SDK +// TODO(bug): missing type in SDK. This dead code is checked by the test suite below. // function checkModelPreferences( // RemovePassthrough< sdk: SDKTypes.ModelPreferences>, // spec: SpecTypes.ModelPreferences @@ -645,7 +676,7 @@ function checkServerNotification( // spec = sdk; // } -// TODO(bug): missing type in SDK +// TODO(bug): missing type in SDK. This dead code is checked by the test suite below. // function checkAnnotations( // RemovePassthrough< sdk: SDKTypes.Annotations>, // spec: SpecTypes.Annotations @@ -661,7 +692,7 @@ describe('Spec Types', () => { const specTypesContent = require('fs').readFileSync(SPEC_TYPES_FILE, 'utf-8'); const typeNames = [...specTypesContent.matchAll(/export\s+interface\s+(\w+)\b/g)].map(m => m[1]); const testContent = require('fs').readFileSync(THIS_SOURCE_FILE, 'utf-8'); - + it('should define some expected types', () => { expect(typeNames).toContain('JSONRPCNotification'); expect(typeNames).toContain('ElicitResult');