diff --git a/src/tools/folders.ts b/src/tools/folders.ts index 129b7c6..f22b364 100644 --- a/src/tools/folders.ts +++ b/src/tools/folders.ts @@ -8,6 +8,7 @@ import { createEmptyResponse, createJsonResponse, } from "../utils/response-formatter.js"; +import { createPassthroughSchema } from "../utils/schema-helpers.js"; // Type definitions for the folder operations type HevyClient = ReturnType< @@ -25,10 +26,10 @@ export function registerFolderTools( server.tool( "get-routine-folders", "Get a paginated list of your routine folders, including both default and custom folders. Useful for organizing and browsing your workout routines.", - { + createPassthroughSchema({ page: z.coerce.number().int().gte(1).default(1), pageSize: z.coerce.number().int().gte(1).lte(10).default(5), - }, + }), withErrorHandling( async ({ page, pageSize }: { page: number; pageSize: number }) => { if (!hevyClient) { @@ -63,9 +64,9 @@ export function registerFolderTools( server.tool( "get-routine-folder", "Get complete details of a specific routine folder by its ID, including name, creation date, and associated routines.", - { + createPassthroughSchema({ folderId: z.string().min(1), - }, + }), withErrorHandling(async ({ folderId }: { folderId: string }) => { if (!hevyClient) { throw new Error( @@ -89,9 +90,9 @@ export function registerFolderTools( server.tool( "create-routine-folder", "Create a new routine folder in your Hevy account. Requires a name for the folder. Returns the full folder details including the new folder ID.", - { + createPassthroughSchema({ name: z.string().min(1), - }, + }), withErrorHandling(async ({ name }: { name: string }) => { if (!hevyClient) { throw new Error( diff --git a/src/tools/routines.ts b/src/tools/routines.ts index 1c1f113..074cac9 100644 --- a/src/tools/routines.ts +++ b/src/tools/routines.ts @@ -16,6 +16,7 @@ import { createEmptyResponse, createJsonResponse, } from "../utils/response-formatter.js"; +import { createPassthroughSchema } from "../utils/schema-helpers.js"; // Type definitions for the routine operations type HevyClient = ReturnType< @@ -74,10 +75,10 @@ export function registerRoutineTools( server.tool( "get-routines", "Get a paginated list of your workout routines, including custom and default routines. Useful for browsing or searching your available routines.", - { + createPassthroughSchema({ page: z.coerce.number().int().gte(1).default(1), pageSize: z.coerce.number().int().gte(1).lte(10).default(5), - }, + }), withErrorHandling(async (args) => { if (!hevyClient) { throw new Error( @@ -108,9 +109,9 @@ export function registerRoutineTools( server.tool( "get-routine", "Get a routine by its ID using the direct endpoint. Returns all details for the specified routine.", - { + createPassthroughSchema({ routineId: z.string().min(1), - }, + }), withErrorHandling(async ({ routineId }) => { if (!hevyClient) { throw new Error( @@ -130,7 +131,7 @@ export function registerRoutineTools( server.tool( "create-routine", "Create a new workout routine in your Hevy account. Requires a title and at least one exercise with sets. Optionally assign to a folder. Returns the full routine details including the new routine ID.", - { + createPassthroughSchema({ title: z.string().min(1), folderId: z.coerce.number().nullable().optional(), notes: z.string().optional(), @@ -154,7 +155,7 @@ export function registerRoutineTools( ), }), ), - }, + }), withErrorHandling(async (args) => { if (!hevyClient) { throw new Error( @@ -206,7 +207,7 @@ export function registerRoutineTools( server.tool( "update-routine", "Update an existing routine by ID. You can modify the title, notes, and exercise configurations. Returns the updated routine with all changes applied.", - { + createPassthroughSchema({ routineId: z.string().min(1), title: z.string().min(1), notes: z.string().optional(), @@ -230,7 +231,7 @@ export function registerRoutineTools( ), }), ), - }, + }), withErrorHandling(async (args) => { if (!hevyClient) { throw new Error( diff --git a/src/tools/templates.ts b/src/tools/templates.ts index 919f36a..21610df 100644 --- a/src/tools/templates.ts +++ b/src/tools/templates.ts @@ -8,6 +8,7 @@ import { createEmptyResponse, createJsonResponse, } from "../utils/response-formatter.js"; +import { createPassthroughSchema } from "../utils/schema-helpers.js"; // Type definitions for the template operations type HevyClient = ReturnType< @@ -25,10 +26,10 @@ export function registerTemplateTools( server.tool( "get-exercise-templates", "Get a paginated list of exercise templates (default and custom) with details like name, category, equipment, and muscle groups. Useful for browsing or searching available exercises.", - { + createPassthroughSchema({ page: z.coerce.number().int().gte(1).default(1), pageSize: z.coerce.number().int().gte(1).lte(100).default(5), - }, + }), withErrorHandling( async ({ page, pageSize }: { page: number; pageSize: number }) => { if (!hevyClient) { @@ -63,9 +64,9 @@ export function registerTemplateTools( server.tool( "get-exercise-template", "Get complete details of a specific exercise template by its ID, including name, category, equipment, muscle groups, and notes.", - { + createPassthroughSchema({ exerciseTemplateId: z.string().min(1), - }, + }), withErrorHandling( async ({ exerciseTemplateId }: { exerciseTemplateId: string }) => { if (!hevyClient) { diff --git a/src/tools/webhooks.ts b/src/tools/webhooks.ts index 358767e..2655319 100644 --- a/src/tools/webhooks.ts +++ b/src/tools/webhooks.ts @@ -6,6 +6,7 @@ import { createEmptyResponse, createJsonResponse, } from "../utils/response-formatter.js"; +import { createPassthroughSchema } from "../utils/schema-helpers.js"; type HevyClient = ReturnType< typeof import("../utils/hevyClientKubb.js").createClient @@ -52,7 +53,7 @@ export function registerWebhookTools( server.tool( "get-webhook-subscription", "Get the current webhook subscription for this account. Returns the webhook URL and auth token if a subscription exists.", - {}, + createPassthroughSchema({}), withErrorHandling(async () => { if (!hevyClient) { throw new Error( @@ -73,7 +74,7 @@ export function registerWebhookTools( server.tool( "create-webhook-subscription", "Create a new webhook subscription for this account. The webhook will receive POST requests when workouts are created. Your endpoint must respond with 200 OK within 5 seconds.", - { + createPassthroughSchema({ url: webhookUrlSchema.describe( "The webhook URL that will receive POST requests when workouts are created", ), @@ -83,7 +84,7 @@ export function registerWebhookTools( .describe( "Optional auth token that will be sent as Authorization header in webhook requests", ), - }, + }), withErrorHandling(async ({ url, authToken }) => { if (!hevyClient) { throw new Error( @@ -110,7 +111,7 @@ export function registerWebhookTools( server.tool( "delete-webhook-subscription", "Delete the current webhook subscription for this account. This will stop all webhook notifications.", - {}, + createPassthroughSchema({}), withErrorHandling(async () => { if (!hevyClient) { throw new Error( diff --git a/src/tools/workouts.ts b/src/tools/workouts.ts index 961a0b1..e248b7f 100644 --- a/src/tools/workouts.ts +++ b/src/tools/workouts.ts @@ -8,6 +8,7 @@ import { createEmptyResponse, createJsonResponse, } from "../utils/response-formatter.js"; +import { createPassthroughSchema } from "../utils/schema-helpers.js"; /** * Type definition for exercise set types @@ -48,10 +49,10 @@ export function registerWorkoutTools( server.tool( "get-workouts", "Get a paginated list of workouts. Returns workout details including title, description, start/end times, and exercises performed. Results are ordered from newest to oldest.", - { + createPassthroughSchema({ page: z.coerce.number().gte(1).default(1), pageSize: z.coerce.number().int().gte(1).lte(10).default(5), - }, + }), withErrorHandling(async ({ page, pageSize }) => { if (!hevyClient) { throw new Error( @@ -81,9 +82,9 @@ export function registerWorkoutTools( server.tool( "get-workout", "Get complete details of a specific workout by ID. Returns all workout information including title, description, start/end times, and detailed exercise data.", - { + createPassthroughSchema({ workoutId: z.string().min(1), - }, + }), withErrorHandling(async ({ workoutId }) => { if (!hevyClient) { throw new Error( @@ -105,7 +106,7 @@ export function registerWorkoutTools( server.tool( "get-workout-count", "Get the total number of workouts on the account. Useful for pagination or statistics.", - {}, + createPassthroughSchema({}), withErrorHandling(async () => { if (!hevyClient) { throw new Error( @@ -125,11 +126,11 @@ export function registerWorkoutTools( server.tool( "get-workout-events", "Retrieve a paged list of workout events (updates or deletes) since a given date. Events are ordered from newest to oldest. The intention is to allow clients to keep their local cache of workouts up to date without having to fetch the entire list of workouts.", - { + createPassthroughSchema({ page: z.coerce.number().int().gte(1).default(1), pageSize: z.coerce.number().int().gte(1).lte(10).default(5), since: z.string().default("1970-01-01T00:00:00Z"), - }, + }), withErrorHandling(async ({ page, pageSize, since }) => { if (!hevyClient) { throw new Error( @@ -158,7 +159,7 @@ export function registerWorkoutTools( server.tool( "create-workout", "Create a new workout in your Hevy account. Requires title, start/end times, and at least one exercise with sets. Returns the complete workout details upon successful creation including the newly assigned workout ID.", - { + createPassthroughSchema({ title: z.string().min(1), description: z.string().optional().nullable(), startTime: z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/), @@ -184,7 +185,7 @@ export function registerWorkoutTools( ), }), ), - }, + }), withErrorHandling( async ({ title, @@ -245,7 +246,7 @@ export function registerWorkoutTools( server.tool( "update-workout", "Update an existing workout by ID. You can modify the title, description, start/end times, privacy setting, and exercise data. Returns the updated workout with all changes applied.", - { + createPassthroughSchema({ workoutId: z.string().min(1), title: z.string().min(1), description: z.string().optional().nullable(), @@ -272,7 +273,7 @@ export function registerWorkoutTools( ), }), ), - }, + }), withErrorHandling( async ({ workoutId, diff --git a/src/utils/schema-helpers.test.ts b/src/utils/schema-helpers.test.ts new file mode 100644 index 0000000..9d3c660 --- /dev/null +++ b/src/utils/schema-helpers.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { createPassthroughSchema } from "./schema-helpers.js"; + +describe("schema-helpers", () => { + describe("createPassthroughSchema", () => { + it("should parse valid data correctly", () => { + const schema = createPassthroughSchema({ + page: z.number(), + pageSize: z.number(), + }); + + const result = schema.safeParse({ page: 1, pageSize: 10 }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ page: 1, pageSize: 10 }); + } + }); + + it("should allow extra properties to pass through", () => { + const schema = createPassthroughSchema({ + page: z.number(), + }); + + // These are the extra properties that n8n sends + const result = schema.safeParse({ + page: 1, + action: "getWorkouts", + chatInput: "test", + sessionId: "123", + toolCallId: "456", + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.page).toBe(1); + // Extra properties should be preserved + expect(result.data.action).toBe("getWorkouts"); + expect(result.data.chatInput).toBe("test"); + expect(result.data.sessionId).toBe("123"); + expect(result.data.toolCallId).toBe("456"); + } + }); + + it("should still validate required properties", () => { + const schema = createPassthroughSchema({ + page: z.number(), + pageSize: z.number(), + }); + + const result = schema.safeParse({ page: 1 }); + expect(result.success).toBe(false); + }); + + it("should still validate property types", () => { + const schema = createPassthroughSchema({ + page: z.number(), + }); + + const result = schema.safeParse({ page: "not a number" }); + expect(result.success).toBe(false); + }); + + it("should work with optional properties", () => { + const schema = createPassthroughSchema({ + page: z.number().default(1), + pageSize: z.number().optional(), + }); + + const result = schema.safeParse({ + toolCallId: "test", + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.page).toBe(1); + expect(result.data.pageSize).toBeUndefined(); + expect(result.data.toolCallId).toBe("test"); + } + }); + + it("should work with empty schema shape", () => { + const schema = createPassthroughSchema({}); + + const result = schema.safeParse({ + action: "test", + sessionId: "123", + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.action).toBe("test"); + expect(result.data.sessionId).toBe("123"); + } + }); + + it("should work with nested objects", () => { + const schema = createPassthroughSchema({ + exercises: z.array( + z.object({ + name: z.string(), + }), + ), + }); + + const result = schema.safeParse({ + exercises: [{ name: "Bench Press" }], + toolCallId: "456", + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.exercises).toEqual([{ name: "Bench Press" }]); + expect(result.data.toolCallId).toBe("456"); + } + }); + }); +}); diff --git a/src/utils/schema-helpers.ts b/src/utils/schema-helpers.ts new file mode 100644 index 0000000..92ba6be --- /dev/null +++ b/src/utils/schema-helpers.ts @@ -0,0 +1,18 @@ +import { type ZodRawShape, z } from "zod"; + +/** + * Wraps a Zod schema shape into an object schema with `.passthrough()`. + * This allows extra properties to pass through validation without causing errors. + * + * This is needed because some MCP clients (like n8n) send additional metadata + * properties (e.g., 'action', 'chatInput', 'sessionId', 'toolCallId') with tool + * calls that are not part of the tool's defined parameters. + * + * @param shape - The Zod raw shape (object with Zod schemas as values) + * @returns A Zod object schema that allows unknown properties to pass through + */ +export function createPassthroughSchema( + shape: T, +): z.ZodObject { + return z.object(shape).passthrough(); +}