diff --git a/package-lock.json b/package-lock.json index e1c42be..43e97cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@modelcontextprotocol/sdk": "1.24.3", "date-fns": "4.1.0", "dotenv": "17.2.3", - "zod": "3.25.76" + "zod": "4.1.13" }, "bin": { "todoist-ai": "dist/main.js" @@ -5713,9 +5713,9 @@ } }, "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", + "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 7bb7612..4ed7684 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "@modelcontextprotocol/sdk": "1.24.3", "date-fns": "4.1.0", "dotenv": "17.2.3", - "zod": "3.25.76" + "zod": "4.1.13" }, "devDependencies": { "@biomejs/biome": "2.3.5", diff --git a/scripts/validate-schemas.ts b/scripts/validate-schemas.ts index 904cf7e..5e27d08 100644 --- a/scripts/validate-schemas.ts +++ b/scripts/validate-schemas.ts @@ -17,37 +17,37 @@ import { z } from 'zod' -interface ValidationIssue { +type ValidationIssue = { toolName: string parameterPath: string issue: string suggestion: string } -interface ValidationResult { +type ValidationResult = { success: boolean issues: ValidationIssue[] toolsChecked: number parametersChecked: number } +type AnyZodSchema = z.ZodTypeAny | { _zod: { def: unknown } } + /** * Recursively walk a Zod schema and detect problematic patterns */ function walkZodSchema( - schema: z.ZodTypeAny, + schema: AnyZodSchema, path: string, issues: ValidationIssue[], toolName: string, ): void { - const typeName = schema._def.typeName - // Check for ZodOptional containing a ZodNullable ZodString - if (typeName === 'ZodOptional') { - const innerSchema = schema._def.innerType - if (innerSchema._def.typeName === 'ZodNullable') { - const nullableInner = innerSchema._def.innerType - if (nullableInner._def.typeName === 'ZodString') { + if (schema instanceof z.ZodOptional) { + const innerSchema = schema.unwrap() + if (innerSchema instanceof z.ZodNullable) { + const nullableInner = innerSchema.unwrap() + if (nullableInner instanceof z.ZodString) { issues.push({ toolName, parameterPath: path, @@ -60,11 +60,11 @@ function walkZodSchema( } // Check for ZodNullable containing a ZodOptional ZodString - if (typeName === 'ZodNullable') { - const innerSchema = schema._def.innerType - if (innerSchema._def.typeName === 'ZodOptional') { - const optionalInner = innerSchema._def.innerType - if (optionalInner._def.typeName === 'ZodString') { + if (schema instanceof z.ZodNullable) { + const innerSchema = schema.unwrap() + if (innerSchema instanceof z.ZodOptional) { + const optionalInner = innerSchema.unwrap() + if (optionalInner instanceof z.ZodString) { issues.push({ toolName, parameterPath: path, @@ -77,61 +77,60 @@ function walkZodSchema( } // Recursively check nested schemas - switch (typeName) { - case 'ZodObject': { - const shape = schema._def.shape() - for (const [key, value] of Object.entries(shape)) { - const newPath = path ? `${path}.${key}` : key - walkZodSchema(value as z.ZodTypeAny, newPath, issues, toolName) - } - break - } - case 'ZodArray': - walkZodSchema(schema._def.type, `${path}[]`, issues, toolName) - break - - case 'ZodOptional': - case 'ZodNullable': - case 'ZodDefault': - walkZodSchema(schema._def.innerType, path, issues, toolName) - break - - case 'ZodUnion': - case 'ZodDiscriminatedUnion': { - const options = schema._def.options || schema._def.discriminatedUnion - if (Array.isArray(options)) { - options.forEach((option: z.ZodTypeAny, index: number) => { - walkZodSchema(option, `${path}[union:${index}]`, issues, toolName) - }) - } - break + if (schema instanceof z.ZodObject) { + const shape = schema.shape + for (const [key, value] of Object.entries(shape)) { + const newPath = path ? `${path}.${key}` : key + walkZodSchema(value as AnyZodSchema, newPath, issues, toolName) } - case 'ZodIntersection': - walkZodSchema(schema._def.left, `${path}[left]`, issues, toolName) - walkZodSchema(schema._def.right, `${path}[right]`, issues, toolName) - break - - case 'ZodRecord': - if (schema._def.valueType) { - walkZodSchema(schema._def.valueType, `${path}[value]`, issues, toolName) - } - break - - case 'ZodTuple': - if (schema._def.items) { - schema._def.items.forEach((item: z.ZodTypeAny, index: number) => { - walkZodSchema(item, `${path}[${index}]`, issues, toolName) - }) - } - break + } else if (schema instanceof z.ZodArray) { + const element = (schema as unknown as { _zod: { def: { element: AnyZodSchema } } })._zod.def + .element + walkZodSchema(element, `${path}[]`, issues, toolName) + } else if ( + schema instanceof z.ZodOptional || + schema instanceof z.ZodNullable || + schema instanceof z.ZodDefault + ) { + walkZodSchema(schema.unwrap() as AnyZodSchema, path, issues, toolName) + } else if (schema instanceof z.ZodUnion) { + const options = (schema as unknown as { _zod: { def: { options: AnyZodSchema[] } } })._zod + .def.options + options.forEach((option: AnyZodSchema, index: number) => { + walkZodSchema(option, `${path}[union:${index}]`, issues, toolName) + }) + } else if (schema instanceof z.ZodDiscriminatedUnion) { + const options = (schema as unknown as { _zod: { def: { options: AnyZodSchema[] } } })._zod + .def.options + options.forEach((option: AnyZodSchema, index: number) => { + walkZodSchema(option, `${path}[union:${index}]`, issues, toolName) + }) + } else if (schema instanceof z.ZodIntersection) { + const left = (schema as unknown as { _zod: { def: { left: AnyZodSchema } } })._zod.def.left + const right = (schema as unknown as { _zod: { def: { right: AnyZodSchema } } })._zod.def + .right + walkZodSchema(left, `${path}[left]`, issues, toolName) + walkZodSchema(right, `${path}[right]`, issues, toolName) + } else if (schema instanceof z.ZodRecord) { + const valueType = (schema as unknown as { _zod: { def: { valueType: AnyZodSchema } } })._zod + .def.valueType + walkZodSchema(valueType, `${path}[value]`, issues, toolName) + } else if (schema instanceof z.ZodTuple) { + const items = (schema as unknown as { _zod: { def: { items: AnyZodSchema[] } } })._zod.def + .items + items.forEach((item: AnyZodSchema, index: number) => { + walkZodSchema(item, `${path}[${index}]`, issues, toolName) + }) } } /** * Validate a single tool's parameter schema */ -// biome-ignore lint/suspicious/noExplicitAny: this is a tooling script -function validateToolSchema(tool: any): ValidationIssue[] { +function validateToolSchema(tool: { + name?: string + parameters?: Record +}): ValidationIssue[] { const issues: ValidationIssue[] = [] const toolName = tool.name || 'unknown' @@ -174,8 +173,10 @@ async function validateAllSchemas(verbose: boolean = false): Promise = async ( - args: z.objectOutputType, - _context, - ) => { + const cb: ToolCallback = async (args: z.infer>, _context) => { try { const { textContent, structuredContent } = await tool.execute( args as z.infer>, diff --git a/src/tools/fetch.ts b/src/tools/fetch.ts index ac606ce..3d23f2a 100644 --- a/src/tools/fetch.ts +++ b/src/tools/fetch.ts @@ -26,7 +26,10 @@ const OutputSchema = { title: z.string().describe('The title of the document.'), text: z.string().describe('The text content of the document.'), url: z.string().describe('The URL of the document.'), - metadata: z.record(z.unknown()).optional().describe('Additional metadata about the document.'), + metadata: z + .record(z.string(), z.unknown()) + .optional() + .describe('Additional metadata about the document.'), } /** diff --git a/src/tools/find-activity.ts b/src/tools/find-activity.ts index 33a8a1e..d36660d 100644 --- a/src/tools/find-activity.ts +++ b/src/tools/find-activity.ts @@ -57,7 +57,9 @@ const OutputSchema = { nextCursor: z.string().optional().describe('Cursor for the next page of results.'), totalCount: z.number().describe('The total number of events in this page.'), hasMore: z.boolean().describe('Whether there are more results available.'), - appliedFilters: z.record(z.unknown()).describe('The filters that were applied to the search.'), + appliedFilters: z + .record(z.string(), z.unknown()) + .describe('The filters that were applied to the search.'), } const findActivity = { diff --git a/src/tools/find-completed-tasks.ts b/src/tools/find-completed-tasks.ts index 6cbf909..94c919c 100644 --- a/src/tools/find-completed-tasks.ts +++ b/src/tools/find-completed-tasks.ts @@ -64,7 +64,9 @@ const OutputSchema = { nextCursor: z.string().optional().describe('Cursor for the next page of results.'), totalCount: z.number().describe('The total number of tasks in this page.'), hasMore: z.boolean().describe('Whether there are more results available.'), - appliedFilters: z.record(z.unknown()).describe('The filters that were applied to the search.'), + appliedFilters: z + .record(z.string(), z.unknown()) + .describe('The filters that were applied to the search.'), } const findCompletedTasks = { diff --git a/src/tools/find-project-collaborators.ts b/src/tools/find-project-collaborators.ts index 7a04829..116109c 100644 --- a/src/tools/find-project-collaborators.ts +++ b/src/tools/find-project-collaborators.ts @@ -33,7 +33,9 @@ const OutputSchema = { .number() .optional() .describe('The total number of available collaborators in the project.'), - appliedFilters: z.record(z.unknown()).describe('The filters that were applied to the search.'), + appliedFilters: z + .record(z.string(), z.unknown()) + .describe('The filters that were applied to the search.'), } const findProjectCollaborators = { diff --git a/src/tools/find-projects.ts b/src/tools/find-projects.ts index 806de7f..65cc31e 100644 --- a/src/tools/find-projects.ts +++ b/src/tools/find-projects.ts @@ -35,7 +35,9 @@ const OutputSchema = { nextCursor: z.string().optional().describe('Cursor for the next page of results.'), totalCount: z.number().describe('The total number of projects in this page.'), hasMore: z.boolean().describe('Whether there are more results available.'), - appliedFilters: z.record(z.unknown()).describe('The filters that were applied to the search.'), + appliedFilters: z + .record(z.string(), z.unknown()) + .describe('The filters that were applied to the search.'), } const findProjects = { diff --git a/src/tools/find-sections.ts b/src/tools/find-sections.ts index 906f1d1..95b7ffe 100644 --- a/src/tools/find-sections.ts +++ b/src/tools/find-sections.ts @@ -31,7 +31,9 @@ type SectionSummary = { const OutputSchema = { sections: z.array(SectionOutputSchema).describe('The found sections.'), totalCount: z.number().describe('The total number of sections found.'), - appliedFilters: z.record(z.unknown()).describe('The filters that were applied to the search.'), + appliedFilters: z + .record(z.string(), z.unknown()) + .describe('The filters that were applied to the search.'), } const findSections = { diff --git a/src/tools/find-tasks-by-date.ts b/src/tools/find-tasks-by-date.ts index 8cd3f71..b53308e 100644 --- a/src/tools/find-tasks-by-date.ts +++ b/src/tools/find-tasks-by-date.ts @@ -66,7 +66,9 @@ const OutputSchema = { nextCursor: z.string().optional().describe('Cursor for the next page of results.'), totalCount: z.number().describe('The total number of tasks in this page.'), hasMore: z.boolean().describe('Whether there are more results available.'), - appliedFilters: z.record(z.unknown()).describe('The filters that were applied to the search.'), + appliedFilters: z + .record(z.string(), z.unknown()) + .describe('The filters that were applied to the search.'), } const findTasksByDate = { diff --git a/src/tools/find-tasks.ts b/src/tools/find-tasks.ts index 7ed7714..9bab2ec 100644 --- a/src/tools/find-tasks.ts +++ b/src/tools/find-tasks.ts @@ -57,7 +57,9 @@ const OutputSchema = { nextCursor: z.string().optional().describe('Cursor for the next page of results.'), totalCount: z.number().describe('The total number of tasks in this page.'), hasMore: z.boolean().describe('Whether there are more results available.'), - appliedFilters: z.record(z.unknown()).describe('The filters that were applied to the search.'), + appliedFilters: z + .record(z.string(), z.unknown()) + .describe('The filters that were applied to the search.'), } const findTasks = { diff --git a/src/utils/output-schemas.ts b/src/utils/output-schemas.ts index 6566776..7e3ba23 100644 --- a/src/utils/output-schemas.ts +++ b/src/utils/output-schemas.ts @@ -110,7 +110,7 @@ const ActivityEventSchema = z.object({ parentProjectId: z.string().optional().describe('The ID of the parent project.'), parentItemId: z.string().optional().describe('The ID of the parent item.'), initiatorId: z.string().optional().describe('The ID of the user who initiated this event.'), - extraData: z.record(z.unknown()).optional().describe('Additional event data.'), + extraData: z.record(z.string(), z.unknown()).optional().describe('Additional event data.'), }) /** diff --git a/src/utils/priorities.ts b/src/utils/priorities.ts index 847455d..a6cd96e 100644 --- a/src/utils/priorities.ts +++ b/src/utils/priorities.ts @@ -3,9 +3,9 @@ import { z } from 'zod' const PRIORITY_VALUES = ['p1', 'p2', 'p3', 'p4'] as const export type Priority = (typeof PRIORITY_VALUES)[number] -export const PrioritySchema = z.enum(PRIORITY_VALUES, { - description: 'Task priority: p1 (highest), p2 (high), p3 (medium), p4 (lowest/default)', -}) +export const PrioritySchema = z + .enum(PRIORITY_VALUES) + .describe('Task priority: p1 (highest), p2 (high), p3 (medium), p4 (lowest/default)') export function convertPriorityToNumber(priority: Priority): number { // Todoist API uses inverse mapping: p1=4 (highest), p2=3, p3=2, p4=1 (lowest)