Skip to content

Commit b628b91

Browse files
intojhanuragabhiaiyer91roaminroNikAiyer
authored
feat(server): expose validation error hook in all server adapters (#13477)
## Description <!-- Provide a brief description of the changes in this PR --> Added `onValidationError` hook to customize Zod validation error responses. When a request fails schema validation (query parameters, request body, or path parameters), this hook lets users control the HTTP status code and response body instead of the hard-coded 400 response with `{ error, issues }` format. The hook can be set at two levels: - **Server-level** via `ServerConfig.onValidationError` — applies to all routes - **Route-level** via `createRoute({ onValidationError })` — overrides the server-level hook for that route Supported by all four server adapters: Hono, Express, Fastify, and Koa. ## Related Issue(s) <!-- Link to the issue(s) this PR addresses, using hashtag notation: Fixes #123 --> Fixes #13465 ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [x] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [x] Documentation update - [ ] Code refactoring - [ ] Performance improvement - [x] Test update ## Checklist - [x] I have made corresponding changes to the documentation (if applicable) - [x] I have added tests that prove my fix is effective or that my feature works <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added server-level onValidationError hook to customize Zod validation error responses (status + body). * Added route-level onValidationError override via createRoute to take precedence per-route. * Applies to query, body, and path parameter validation across all server adapters (Hono, Express, Fastify, Koa). * **Documentation** * Docs updated with configuration and route examples showing custom 422/structured error responses and fallback behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: intojhanurag <aojharaj2004@gmail.com> Co-authored-by: Abhi Aiyer <abhiaiyer91@gmail.com> Co-authored-by: Roamin <97863888+roaminro@users.noreply.github.com> Co-authored-by: NikAiyer <nick.aiyer@gmail.com>
1 parent 41d79a1 commit b628b91

13 files changed

Lines changed: 423 additions & 31 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'@mastra/core': minor
3+
'@mastra/server': minor
4+
'@mastra/hono': minor
5+
'@mastra/express': minor
6+
'@mastra/fastify': minor
7+
'@mastra/koa': minor
8+
---
9+
10+
Added `onValidationError` hook to `ServerConfig` and `createRoute()`. When a request fails Zod schema validation (query parameters, request body, or path parameters), this hook lets you customize the error response — including the HTTP status code and response body — instead of the default 400 response. Set it on the server config to apply globally, or on individual routes to override per-route. All server adapters (Hono, Express, Fastify, Koa) support this hook.
11+
12+
```ts
13+
const mastra = new Mastra({
14+
server: {
15+
onValidationError: (error, context) => ({
16+
status: 422,
17+
body: {
18+
ok: false,
19+
errors: error.issues.map(i => ({
20+
path: i.path.join('.'),
21+
message: i.message,
22+
})),
23+
source: context,
24+
},
25+
}),
26+
},
27+
})
28+
```

docs/src/content/en/reference/configuration.mdx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -726,6 +726,42 @@ export const mastra = new Mastra({
726726
})
727727
```
728728

729+
### server.onValidationError
730+
731+
**Type:** `(error: ZodError, context: 'query' | 'body' | 'path') => { status: number; body: unknown } | undefined`
732+
733+
Custom handler called when a request fails Zod schema validation. Use this to customize validation error responses, change the status code, or format errors to match your API standards.
734+
735+
Return a `{ status, body }` object to override the default `400` response, or `undefined` to fall through to the default behavior. This hook is supported by all server adapters (Hono, Express, Fastify, Koa).
736+
737+
The `context` parameter indicates which part of the request failed validation:
738+
739+
- `'query'` — query parameters
740+
- `'body'` — request body
741+
- `'path'` — path parameters
742+
743+
```typescript title="src/mastra/index.ts"
744+
import { Mastra } from '@mastra/core'
745+
746+
export const mastra = new Mastra({
747+
server: {
748+
onValidationError: (error, context) => ({
749+
status: 422,
750+
body: {
751+
ok: false,
752+
errors: error.issues.map(i => ({
753+
path: i.path.join('.'),
754+
message: i.message,
755+
})),
756+
source: context,
757+
},
758+
}),
759+
},
760+
})
761+
```
762+
763+
You can also set `onValidationError` on individual routes created with `createRoute()`. A route-level hook takes precedence over the server-level hook.
764+
729765
### server.port
730766

731767
**Type:** `number`\

docs/src/content/en/reference/server/create-route.mdx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,13 @@ function createRoute<TPath, TQuery, TBody, TResponse, TResponseType>(
114114
description: 'Mark route as deprecated',
115115
isOptional: true,
116116
},
117+
{
118+
name: 'onValidationError',
119+
type: "(error: ZodError, context: 'query' | 'body' | 'path') => { status: number; body: unknown } | undefined",
120+
description:
121+
'Custom validation error handler for this route. Overrides the server-level `onValidationError` hook. Return `{ status, body }` to customize the response, or `undefined` to use the default.',
122+
isOptional: true,
123+
},
117124
]}
118125
/>
119126

packages/core/src/server/index.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,15 @@ import type { Mastra } from '../mastra';
55
import type { RequestContext } from '../request-context';
66
import type { ApiRoute, MastraAuthConfig, Methods } from './types';
77

8-
export type { MastraAuthConfig, ContextWithMastra, ApiRoute, HttpLoggingConfig } from './types';
8+
export type {
9+
MastraAuthConfig,
10+
ContextWithMastra,
11+
ApiRoute,
12+
HttpLoggingConfig,
13+
ValidationErrorContext,
14+
ValidationErrorResponse,
15+
ValidationErrorHook,
16+
} from './types';
917
export { MastraAuthProvider } from './auth';
1018
export type { MastraAuthProviderOptions } from './auth';
1119
export { CompositeAuth } from './composite-auth';

packages/core/src/server/types.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Handler, MiddlewareHandler, HonoRequest, Context } from 'hono';
22
import type { cors } from 'hono/cors';
33
import type { DescribeRouteOptions } from 'hono-openapi';
4+
import type { ZodError } from 'zod';
45
import type { IRBACProvider } from '../auth/ee/interfaces/rbac';
56
import type { Mastra } from '../mastra';
67
import type { RequestContext } from '../request-context';
@@ -112,6 +113,18 @@ export type HttpLoggingConfig = {
112113
redactHeaders?: string[];
113114
};
114115

116+
export type ValidationErrorContext = 'query' | 'body' | 'path';
117+
118+
export type ValidationErrorResponse = {
119+
status: number;
120+
body: unknown;
121+
};
122+
123+
export type ValidationErrorHook = (
124+
error: ZodError,
125+
context: ValidationErrorContext,
126+
) => ValidationErrorResponse | undefined | void;
127+
115128
export type ServerConfig = {
116129
/**
117130
* Port for the server
@@ -283,4 +296,35 @@ export type ServerConfig = {
283296
* ```
284297
*/
285298
onError?: (err: Error, c: Context) => Response | Promise<Response>;
299+
300+
/**
301+
* Custom validation error handler for the server. Called when a request fails
302+
* Zod schema validation (query parameters, request body, or path parameters).
303+
*
304+
* Return a `{ status, body }` object to override the default 400 response,
305+
* or return `undefined` to fall back to the default behavior.
306+
*
307+
* @param error - The ZodError from schema validation
308+
* @param context - Which part of the request failed: 'query', 'body', or 'path'
309+
*
310+
* @example
311+
* ```ts
312+
* const mastra = new Mastra({
313+
* server: {
314+
* onValidationError: (error, context) => ({
315+
* status: 422,
316+
* body: {
317+
* ok: false,
318+
* errors: error.issues.map(i => ({
319+
* path: i.path.join('.'),
320+
* message: i.message,
321+
* })),
322+
* source: context,
323+
* },
324+
* }),
325+
* },
326+
* });
327+
* ```
328+
*/
329+
onValidationError?: ValidationErrorHook;
286330
};

packages/server/src/server/server-adapter/index.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ import type { ToolsInput } from '@mastra/core/agent';
22
import type { Mastra } from '@mastra/core/mastra';
33
import { RequestContext } from '@mastra/core/request-context';
44
import { MastraServerBase } from '@mastra/core/server';
5-
import type { ApiRoute, HttpLoggingConfig } from '@mastra/core/server';
5+
import type { ApiRoute, HttpLoggingConfig, ValidationErrorContext, ValidationErrorResponse } from '@mastra/core/server';
66
import { Hono } from 'hono';
7+
import type { ZodError } from 'zod';
78

89
import type { InMemoryTaskStore } from '../a2a/store';
910
import { coreAuthMiddleware } from '../auth/helpers';
11+
import { formatZodError } from '../handlers/error';
1012
import { normalizeRoutePath } from '../utils';
1113
import { generateOpenAPIDocument, convertCustomRoutesToOpenAPIPaths } from './openapi-utils';
1214
import { SERVER_ROUTES, getEffectivePermission } from './routes';
@@ -661,4 +663,36 @@ export abstract class MastraServer<TApp, TRequest, TResponse> extends MastraServ
661663

662664
return bodySchema.parseAsync(body);
663665
}
666+
667+
private static readonly CONTEXT_LABELS: Record<ValidationErrorContext, string> = {
668+
query: 'query parameters',
669+
body: 'request body',
670+
path: 'path parameters',
671+
};
672+
673+
protected resolveValidationError(
674+
route: ServerRoute,
675+
error: ZodError,
676+
context: ValidationErrorContext,
677+
): ValidationErrorResponse {
678+
const hook = route.onValidationError ?? this.mastra.getServer()?.onValidationError;
679+
680+
if (hook) {
681+
try {
682+
const result = hook(error, context);
683+
if (result) {
684+
return result;
685+
}
686+
} catch (hookError) {
687+
this.mastra.getLogger()?.error('Error in custom onValidationError hook', {
688+
error: hookError instanceof Error ? { message: hookError.message, stack: hookError.stack } : hookError,
689+
});
690+
}
691+
}
692+
693+
return {
694+
status: 400,
695+
body: formatZodError(error, MastraServer.CONTEXT_LABELS[context]),
696+
};
697+
}
664698
}

packages/server/src/server/server-adapter/routes/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Mastra } from '@mastra/core';
22
import type { ToolsInput } from '@mastra/core/agent';
33
import type { RequestContext } from '@mastra/core/request-context';
4-
import type { ApiRoute } from '@mastra/core/server';
4+
import type { ApiRoute, ValidationErrorHook } from '@mastra/core/server';
55
import type z from 'zod';
66
import type { InMemoryTaskStore } from '../../a2a/store';
77
import { A2A_ROUTES } from './a2a';
@@ -107,6 +107,7 @@ export type ServerRoute<
107107
* requiresPermission: 'workflows:execute'
108108
*/
109109
requiresPermission?: string;
110+
onValidationError?: ValidationErrorHook;
110111
};
111112

112113
export const SERVER_ROUTES: ServerRoute<any, any, any>[] = [

packages/server/src/server/server-adapter/routes/route-builder.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { ValidationErrorHook } from '@mastra/core/server';
12
import { z } from 'zod';
23
import type { ZodObject, ZodRawShape, ZodTypeAny } from 'zod';
34
import { generateRouteOpenAPI } from '../openapi-utils';
@@ -181,6 +182,7 @@ interface RouteConfig<
181182
* Uses the format: `resource:action` or `resource:action:resourceId`
182183
*/
183184
requiresPermission?: string;
185+
onValidationError?: ValidationErrorHook;
184186
}
185187

186188
/**
@@ -255,7 +257,8 @@ export function createRoute<
255257
TResponseSchema extends z.ZodTypeAny ? z.infer<TResponseSchema> : unknown,
256258
TResponseType
257259
> {
258-
const { summary, description, tags, deprecated, requiresAuth, requiresPermission, ...baseRoute } = config;
260+
const { summary, description, tags, deprecated, requiresAuth, requiresPermission, onValidationError, ...baseRoute } =
261+
config;
259262

260263
// Generate OpenAPI specification from the route config
261264
// Skip OpenAPI generation for 'ALL' method as it doesn't map to OpenAPI
@@ -281,6 +284,7 @@ export function createRoute<
281284
deprecated,
282285
requiresAuth,
283286
requiresPermission,
287+
onValidationError,
284288
};
285289
}
286290

server-adapters/express/src/index.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import type { Mastra } from '@mastra/core/mastra';
44
import type { RequestContext } from '@mastra/core/request-context';
55
import type { InMemoryTaskStore } from '@mastra/server/a2a/store';
66
import { isProtectedCustomRoute } from '@mastra/server/auth';
7-
import { formatZodError } from '@mastra/server/handlers/error';
87
import type { MCPHttpTransportResult, MCPSseTransportResult } from '@mastra/server/handlers/mcp';
98
import type { ParsedRequestParams, ServerRoute } from '@mastra/server/server-adapter';
109
import {
@@ -430,9 +429,9 @@ export class MastraServer extends MastraServerBase<Application, Request, Respons
430429
this.mastra.getLogger()?.error('Error parsing query params', {
431430
error: error instanceof Error ? { message: error.message, stack: error.stack } : error,
432431
});
433-
// Zod validation errors should return 400 Bad Request with structured issues
434432
if (error instanceof ZodError) {
435-
return res.status(400).json(formatZodError(error, 'query parameters'));
433+
const { status, body } = this.resolveValidationError(route, error, 'query');
434+
return res.status(status).json(body);
436435
}
437436
return res.status(400).json({
438437
error: 'Invalid query parameters',
@@ -448,9 +447,9 @@ export class MastraServer extends MastraServerBase<Application, Request, Respons
448447
this.mastra.getLogger()?.error('Error parsing body', {
449448
error: error instanceof Error ? { message: error.message, stack: error.stack } : error,
450449
});
451-
// Zod validation errors should return 400 Bad Request with structured issues
452450
if (error instanceof ZodError) {
453-
return res.status(400).json(formatZodError(error, 'request body'));
451+
const { status, body } = this.resolveValidationError(route, error, 'body');
452+
return res.status(status).json(body);
454453
}
455454
return res.status(400).json({
456455
error: 'Invalid request body',
@@ -468,7 +467,8 @@ export class MastraServer extends MastraServerBase<Application, Request, Respons
468467
error: error instanceof Error ? { message: error.message, stack: error.stack } : error,
469468
});
470469
if (error instanceof ZodError) {
471-
return res.status(400).json(formatZodError(error, 'path parameters'));
470+
const { status, body } = this.resolveValidationError(route, error, 'path');
471+
return res.status(status).json(body);
472472
}
473473
return res.status(400).json({
474474
error: 'Invalid path parameters',

server-adapters/fastify/src/index.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import type { ToolsInput } from '@mastra/core/agent';
33
import type { Mastra } from '@mastra/core/mastra';
44
import type { RequestContext } from '@mastra/core/request-context';
55
import type { InMemoryTaskStore } from '@mastra/server/a2a/store';
6-
import { formatZodError } from '@mastra/server/handlers/error';
76
import type { MCPHttpTransportResult, MCPSseTransportResult } from '@mastra/server/handlers/mcp';
87
import type { ParsedRequestParams, ServerRoute } from '@mastra/server/server-adapter';
98
import {
@@ -462,9 +461,9 @@ export class MastraServer extends MastraServerBase<FastifyInstance, FastifyReque
462461
this.mastra.getLogger()?.error('Error parsing query params', {
463462
error: error instanceof Error ? { message: error.message, stack: error.stack } : error,
464463
});
465-
// Zod validation errors should return 400 Bad Request with structured issues
466464
if (error instanceof ZodError) {
467-
return reply.status(400).send(formatZodError(error, 'query parameters'));
465+
const { status, body } = this.resolveValidationError(route, error, 'query');
466+
return reply.status(status).send(body);
468467
}
469468
return reply.status(400).send({
470469
error: 'Invalid query parameters',
@@ -480,9 +479,9 @@ export class MastraServer extends MastraServerBase<FastifyInstance, FastifyReque
480479
this.mastra.getLogger()?.error('Error parsing body', {
481480
error: error instanceof Error ? { message: error.message, stack: error.stack } : error,
482481
});
483-
// Zod validation errors should return 400 Bad Request with structured issues
484482
if (error instanceof ZodError) {
485-
return reply.status(400).send(formatZodError(error, 'request body'));
483+
const { status, body } = this.resolveValidationError(route, error, 'body');
484+
return reply.status(status).send(body);
486485
}
487486
return reply.status(400).send({
488487
error: 'Invalid request body',
@@ -500,7 +499,8 @@ export class MastraServer extends MastraServerBase<FastifyInstance, FastifyReque
500499
error: error instanceof Error ? { message: error.message, stack: error.stack } : error,
501500
});
502501
if (error instanceof ZodError) {
503-
return reply.status(400).send(formatZodError(error, 'path parameters'));
502+
const { status, body } = this.resolveValidationError(route, error, 'path');
503+
return reply.status(status).send(body);
504504
}
505505
return reply.status(400).send({
506506
error: 'Invalid path parameters',

0 commit comments

Comments
 (0)