Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
131 changes: 66 additions & 65 deletions scripts/validate-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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<string, z.ZodTypeAny>
}): ValidationIssue[] {
const issues: ValidationIssue[] = []
const toolName = tool.name || 'unknown'

Expand Down Expand Up @@ -174,8 +173,10 @@ async function validateAllSchemas(verbose: boolean = false): Promise<ValidationR
if (tool.parameters) {
try {
const schema = z.object(tool.parameters)
const shape = schema._def.shape()
totalParameters += Object.keys(shape).length
const shape = schema.shape
if (shape) {
totalParameters += Object.keys(shape).length
}
} catch {
// Skip counting if schema is invalid
}
Expand Down
7 changes: 2 additions & 5 deletions src/mcp-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { TodoistApi } from '@doist/todoist-api-typescript'
import type { McpServer, ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { ZodTypeAny, z } from 'zod'
import type { z } from 'zod'
import type { TodoistTool, ToolMutability } from './todoist-tool.js'
import { removeNullFields } from './utils/sanitize-data.js'

Expand Down Expand Up @@ -95,10 +95,7 @@ function registerTool<Params extends z.ZodRawShape, Output extends z.ZodRawShape
client: TodoistApi,
) {
// @ts-expect-error I give up
const cb: ToolCallback<Params> = async (
args: z.objectOutputType<Params, ZodTypeAny>,
_context,
) => {
const cb: ToolCallback<Params> = async (args: z.infer<z.ZodObject<Params>>, _context) => {
try {
const { textContent, structuredContent } = await tool.execute(
args as z.infer<z.ZodObject<Params>>,
Expand Down
5 changes: 4 additions & 1 deletion src/tools/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.'),
}

/**
Expand Down
4 changes: 3 additions & 1 deletion src/tools/find-activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
4 changes: 3 additions & 1 deletion src/tools/find-completed-tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
4 changes: 3 additions & 1 deletion src/tools/find-project-collaborators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
4 changes: 3 additions & 1 deletion src/tools/find-projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
4 changes: 3 additions & 1 deletion src/tools/find-sections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
4 changes: 3 additions & 1 deletion src/tools/find-tasks-by-date.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
4 changes: 3 additions & 1 deletion src/tools/find-tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
2 changes: 1 addition & 1 deletion src/utils/output-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.'),
})

/**
Expand Down
6 changes: 3 additions & 3 deletions src/utils/priorities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down