From 1534a3309ae14d073109fb4d4b717042b4cba998 Mon Sep 17 00:00:00 2001 From: XinLei Date: Wed, 11 Mar 2026 20:35:13 -0700 Subject: [PATCH 1/2] fix: getParseErrorMessage now surfaces custom Zod v4 error messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In Zod v4, `error.message` is a JSON serialization of the issues array rather than a user-facing string. The previous implementation prioritized `error.message`, which caused custom error messages defined via `.min(1, 'My custom error')` or custom error maps to be swallowed — callers saw a raw JSON blob instead of the human-readable message. Fix by extracting messages from `error.issues` first, falling back to `error.message` only when no issue messages are available. Also update call sites in `client.ts` and `mcp.ts` that were hand-rolling `issues.map(i => i.message).join(', ')` to use `getParseErrorMessage` consistently. Closes #1415 --- packages/client/src/client/client.ts | 3 +- packages/core/src/util/schema.ts | 17 ++++ packages/core/test/util/schema.test.ts | 110 +++++++++++++++++++++++++ packages/server/src/server/mcp.ts | 7 +- 4 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 packages/core/test/util/schema.test.ts diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index edb08ee58..91feefed3 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -47,6 +47,7 @@ import { ElicitResultSchema, EmptyResultSchema, GetPromptResultSchema, + getParseErrorMessage, InitializeResultSchema, LATEST_PROTOCOL_VERSION, ListChangedOptionsBaseSchema, @@ -1004,7 +1005,7 @@ export class Client extends Protocol { // Validate options using Zod schema (validates autoRefresh and debounceMs) const parseResult = parseSchema(ListChangedOptionsBaseSchema, options); if (!parseResult.success) { - throw new Error(`Invalid ${listType} listChanged options: ${parseResult.error.message}`); + throw new Error(`Invalid ${listType} listChanged options: ${getParseErrorMessage(parseResult.error)}`); } // Validate callback diff --git a/packages/core/src/util/schema.ts b/packages/core/src/util/schema.ts index adecee361..2133c3741 100644 --- a/packages/core/src/util/schema.ts +++ b/packages/core/src/util/schema.ts @@ -92,3 +92,20 @@ export function unwrapOptionalSchema(schema: AnySchema): AnySchema { const candidate = schema as { def?: { innerType?: AnySchema } }; return candidate.def?.innerType ?? schema; } + +/** + * Extracts error message from a Zod parse error result. + * In Zod v4, error.message is a JSON serialization of issues rather than a + * user-facing message, so prefer individual issue messages when available. + */ +export function getParseErrorMessage(error: z.core.$ZodError): string { + const issueMessages = error.issues + .map((issue: { message?: string }) => issue.message?.trim()) + .filter((message): message is string => Boolean(message)); + + if (issueMessages.length > 0) { + return issueMessages.join(', '); + } + + return error.message; +} diff --git a/packages/core/test/util/schema.test.ts b/packages/core/test/util/schema.test.ts new file mode 100644 index 000000000..e1286b206 --- /dev/null +++ b/packages/core/test/util/schema.test.ts @@ -0,0 +1,110 @@ +/** + * Tests for schema utility functions + */ + +import { describe, expect, test } from 'vitest'; +import * as z from 'zod/v4'; +import { getParseErrorMessage, parseSchema } from '../../src/util/schema.js'; + +describe('getParseErrorMessage', () => { + test('should preserve custom error messages from Zod v4', () => { + const schema = z.object({ + name: z.string().min(1, 'Name is required'), + email: z.string().email('Please provide a valid email address'), + age: z.number().min(18, 'Must be at least 18 years old') + }); + + // Test with invalid data that should trigger custom errors + const parseResult = parseSchema(schema, { + name: '', + email: 'invalid-email', + age: 16 + }); + + expect(parseResult.success).toBe(false); + if (!parseResult.success) { + const errorMessage = getParseErrorMessage(parseResult.error); + + // The error message should contain our custom messages + expect(errorMessage).toContain('Name is required'); + expect(errorMessage).toContain('Please provide a valid email address'); + expect(errorMessage).toContain('Must be at least 18 years old'); + } + }); + + test('should handle single custom error message', () => { + const schema = z.string().min(1, 'My custom error'); + + const parseResult = parseSchema(schema, ''); + + expect(parseResult.success).toBe(false); + if (!parseResult.success) { + const errorMessage = getParseErrorMessage(parseResult.error); + expect(errorMessage).toBe('My custom error'); + } + }); + + test('should fall back to default error messages when no custom message is provided', () => { + const schema = z.string().min(5); // No custom message + + const parseResult = parseSchema(schema, 'abc'); + + expect(parseResult.success).toBe(false); + if (!parseResult.success) { + const errorMessage = getParseErrorMessage(parseResult.error); + // Should contain some error message (exact wording may vary) + expect(errorMessage).toBeTruthy(); + expect(errorMessage.length).toBeGreaterThan(0); + } + }); + + test('should handle nested object validation errors', () => { + const schema = z.object({ + user: z.object({ + profile: z.object({ + displayName: z.string().min(1, 'Display name cannot be empty') + }) + }) + }); + + const parseResult = parseSchema(schema, { + user: { + profile: { + displayName: '' + } + } + }); + + expect(parseResult.success).toBe(false); + if (!parseResult.success) { + const errorMessage = getParseErrorMessage(parseResult.error); + expect(errorMessage).toContain('Display name cannot be empty'); + } + }); + + test('should prefer issue messages over Zod v4 JSON error.message output', () => { + const schema = z.string().min(1, 'Custom error message'); + + const parseResult = parseSchema(schema, ''); + + expect(parseResult.success).toBe(false); + if (!parseResult.success) { + const ourMessage = getParseErrorMessage(parseResult.error); + const zodMessage = parseResult.error.message; + + expect(zodMessage).toContain('"message": "Custom error message"'); + expect(ourMessage).toBe('Custom error message'); + expect(ourMessage).not.toBe(zodMessage); + } + }); + + test('should fall back to error.message when issues do not contain usable messages', () => { + const fallbackMessage = 'Serialized Zod failure'; + const error = { + issues: [{ message: ' ' }, {}], + message: fallbackMessage + } as unknown as z.core.$ZodError; + + expect(getParseErrorMessage(error)).toBe(fallbackMessage); + }); +}); diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 05136f5b6..56d860eb5 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -32,6 +32,7 @@ import type { import { assertCompleteRequestPrompt, assertCompleteRequestResourceTemplate, + getParseErrorMessage, getSchemaDescription, getSchemaShape, isOptionalSchema, @@ -258,7 +259,7 @@ export class McpServer { const parseResult = await parseSchemaAsync(tool.inputSchema, args ?? {}); if (!parseResult.success) { - const errorMessage = parseResult.error.issues.map((i: { message: string }) => i.message).join(', '); + const errorMessage = getParseErrorMessage(parseResult.error); throw new ProtocolError( ProtocolErrorCode.InvalidParams, `Input validation error: Invalid arguments for tool ${toolName}: ${errorMessage}` @@ -295,7 +296,7 @@ export class McpServer { // if the tool has an output schema, validate structured content const parseResult = await parseSchemaAsync(tool.outputSchema, result.structuredContent); if (!parseResult.success) { - const errorMessage = parseResult.error.issues.map((i: { message: string }) => i.message).join(', '); + const errorMessage = getParseErrorMessage(parseResult.error); throw new ProtocolError( ProtocolErrorCode.InvalidParams, `Output validation error: Invalid structured content for tool ${toolName}: ${errorMessage}` @@ -1262,7 +1263,7 @@ function createPromptHandler( return async (args, ctx) => { const parseResult = await parseSchemaAsync(argsSchema, args); if (!parseResult.success) { - const errorMessage = parseResult.error.issues.map((i: { message: string }) => i.message).join(', '); + const errorMessage = getParseErrorMessage(parseResult.error); throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid arguments for prompt ${name}: ${errorMessage}`); } return typedCallback(parseResult.data as SchemaOutput, ctx); From f249e99478b1b61fc27d7e7e586c525639c16ed6 Mon Sep 17 00:00:00 2001 From: XinLei Date: Wed, 11 Mar 2026 20:39:40 -0700 Subject: [PATCH 2/2] fix: use Boolean directly in filter to satisfy unicorn/prefer-native-coercion-functions --- packages/core/src/util/schema.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/core/src/util/schema.ts b/packages/core/src/util/schema.ts index 2133c3741..91713e87f 100644 --- a/packages/core/src/util/schema.ts +++ b/packages/core/src/util/schema.ts @@ -99,9 +99,7 @@ export function unwrapOptionalSchema(schema: AnySchema): AnySchema { * user-facing message, so prefer individual issue messages when available. */ export function getParseErrorMessage(error: z.core.$ZodError): string { - const issueMessages = error.issues - .map((issue: { message?: string }) => issue.message?.trim()) - .filter((message): message is string => Boolean(message)); + const issueMessages = error.issues.map((issue: { message?: string }) => issue.message?.trim()).filter(Boolean); if (issueMessages.length > 0) { return issueMessages.join(', ');