fix: capture thrown transform errors as Zod validation issues (#3242)

Fixes #2914

### Summary
- Add a shared `zodTransform` helper in `src/tools/ToolDefinition.ts`
that wraps `.transform(...)` callbacks in a `try/catch` block and
records thrown errors as custom Zod validation issues
(`ctx.addIssue({code: 'custom', message: ...})`) returning `zod.NEVER`.
- Wrap `geolocationTransform`, `viewportTransform`, and
`headerStringTransform` in `emulate` (`src/tools/emulation.ts`),
`timeoutSchema` (`src/tools/ToolDefinition.ts`), and
`parseByteSizeRange` in `byteSizeRangeSchema` (`src/utils/bytes.ts`)
with `zodTransform`.
- When invalid values are passed to `emulate`'s `--geolocation`,
`--viewport`, or `--extraHttpHeaders` in the `chrome-devtools` CLI (or
`McpServer.callTool`), `safeParseAsync` now returns a standard `isError:
true` input-validation tool result (`Input validation error: Invalid
arguments for tool emulate: ...`) printed to stdout with exit code `0`,
rather than rejecting and failing as a daemon error on stderr with exit
code `1`.
- Add unit tests in `tests/tools/emulation.test.ts` and
`tests/McpServer.test.ts`.
This commit is contained in:
Nikolay Vitkov authored and GitHub committed 2026-10-19 12:22:53 +00:00
1 parent c2c6cc47c6
commit 0f95c21b02
5 files changed
+126 -18

No files matched your search

+21 -3
View File
@@ -608,13 +608,31 @@ export const pageIdSchema = {
pageId: zod.number().describe('Targets a specific page by ID.'),
};
export function zodTransform<Input, Output>(
transform: (arg: Input) => Output,
): (arg: Input, ctx: zod.RefinementCtx) => Output {
return (arg: Input, ctx: zod.RefinementCtx): Output => {
try {
return transform(arg);
} catch (error) {
ctx.addIssue({
code: 'custom',
message: error instanceof Error ? error.message : String(error),
});
return zod.NEVER;
}
};
}
export const timeoutSchema = {
timeout: zod
.number()
.int()
.transform(value => {
return value <= 0 ? undefined : value;
})
.transform(
zodTransform(value => {
return value <= 0 ? undefined : value;
}),
)
.optional()
.describe(
`Maximum wait time in milliseconds. If set to 0, the default timeout will be used.`,
+4 -3
View File
@@ -17,6 +17,7 @@ import {
definePageTool,
geolocationTransform,
viewportTransform,
zodTransform,
} from './ToolDefinition.js';
function headerStringTransform(
@@ -72,7 +73,7 @@ export const emulate = definePageTool(() => ({
),
geolocation: zod
.string()
.transform(geolocationTransform)
.transform(zodTransform(geolocationTransform))
.optional()
.describe(
'Geolocation (`<latitude>,<longitude>`) to emulate. Latitude between -90 and 90. Longitude between -180 and 180. Omit to clear the geolocation override.',
@@ -109,14 +110,14 @@ export const emulate = definePageTool(() => ({
),
viewport: zod
.string()
.transform(viewportTransform)
.transform(zodTransform(viewportTransform))
.optional()
.describe(
`Emulate device viewports '<width>x<height>x<devicePixelRatio>[,mobile][,touch][,landscape]'. 'touch' and 'mobile' to emulate mobile devices. 'landscape' to emulate landscape mode. Omit to clear the viewport override.`,
),
extraHttpHeaders: zod
.string()
.transform(headerStringTransform)
.transform(zodTransform(headerStringTransform))
.optional()
.describe(
'Extra HTTP headers as a JSON string object, e.g. {"X-Custom": "value", "Authorization": "Bearer token"}. Headers are included into every HTTP request originating from the page and persist across navigations until cleared. Pass an empty string to clear all extra headers.',
+2 -12
View File
@@ -5,6 +5,7 @@
*/
import {zod} from '../third_party/index.js';
import {zodTransform} from '../tools/ToolDefinition.js';
const BYTE_UNITS: Readonly<Record<string, number>> = {
b: 1,
@@ -120,17 +121,6 @@ export function byteSizeRangeSchema(
): zod.ZodType<ByteSizeRange> {
return zod
.string()
.transform((value, context) => {
try {
return parseByteSizeRange(value);
} catch (error) {
context.addIssue({
code: zod.ZodIssueCode.custom,
message:
error instanceof Error ? error.message : 'Invalid byte size range',
});
return zod.NEVER;
}
})
.transform(zodTransform(parseByteSizeRange))
.describe(description);
}
+29
View File
@@ -88,6 +88,35 @@ describe('McpServer', () => {
sinon.assert.notCalled(browserManager.ensureBrowser);
});
it('returns a validation error when transform validation fails', async () => {
const {server, browserManager} = await createTestServer();
const cases: Array<[Record<string, unknown>, RegExp]> = [
[
{pageId: 1, geolocation: '0x0'},
/^Input validation error: Invalid arguments for tool emulate: geolocation: Invalid geolocation "0x0"\. Expected '<latitude>,<longitude>'\.$/,
],
[
{pageId: 1, viewport: '0x0'},
/^Input validation error: Invalid arguments for tool emulate: viewport: Invalid viewport width "0"\./,
],
[
{pageId: 1, extraHttpHeaders: 'notjson'},
/^Input validation error: Invalid arguments for tool emulate: extraHttpHeaders: Invalid JSON for headers:/,
],
];
for (const [args, expectedPattern] of cases) {
const result = await server.callTool('emulate', args);
assert.strictEqual(result.isError, true);
assert.strictEqual(result.content.length, 1);
const firstBlock = result.content[0];
assert.strictEqual(firstBlock.type, 'text');
assert.match(firstBlock.text, expectedPattern);
}
sinon.assert.notCalled(browserManager.ensureBrowser);
});
it('executes the tool handler when arguments are valid', async () => {
const {server, context} = await createTestServer();
+70
View File
@@ -514,6 +514,76 @@ describe('emulation', () => {
extraHttpHeaders: {'X-Page': 'one'},
});
});
it('parses valid extraHttpHeaders JSON and empty string in schema', () => {
const {args} = createHandlerMocks();
const schema = zod.object(emulate(args).schema);
assert.deepStrictEqual(
schema.safeParse({
pageId: 1,
extraHttpHeaders: '{"X-Custom": "value"}',
}),
{
success: true,
data: {
pageId: 1,
extraHttpHeaders: {'X-Custom': 'value'},
},
},
);
assert.deepStrictEqual(
schema.safeParse({
pageId: 1,
extraHttpHeaders: '',
}),
{
success: true,
data: {
pageId: 1,
extraHttpHeaders: {},
},
},
);
});
it('reports validation issues instead of throwing for invalid transform inputs', () => {
const {args} = createHandlerMocks();
const schema = zod.object(emulate(args).schema);
const geoResult = schema.safeParse({pageId: 1, geolocation: '0x0'});
assert.strictEqual(geoResult.success, false);
assert.strictEqual(
geoResult.error.issues[0].message,
'Invalid geolocation "0x0". Expected \'<latitude>,<longitude>\'.',
);
const viewportResult = schema.safeParse({pageId: 1, viewport: '0x0'});
assert.strictEqual(viewportResult.success, false);
assert.strictEqual(
viewportResult.error.issues[0].message,
'Invalid viewport width "0". Expected format \'<width>x<height>x<devicePixelRatio>[,mobile][,touch][,landscape]\' with a positive width.',
);
const invalidJsonResult = schema.safeParse({
pageId: 1,
extraHttpHeaders: 'notjson',
});
assert.strictEqual(invalidJsonResult.success, false);
assert.match(
invalidJsonResult.error.issues[0].message,
/^Invalid JSON for headers:/,
);
const nonObjectHeadersResult = schema.safeParse({
pageId: 1,
extraHttpHeaders: '[]',
});
assert.strictEqual(nonObjectHeadersResult.success, false);
assert.strictEqual(
nonObjectHeadersResult.error.issues[0].message,
'Invalid JSON for headers: Headers must be a JSON object',
);
});
});
describe('colorScheme', () => {