mirror of
https://github.com/ChromeDevTools/chrome-devtools-mcp.git
synced 2026-11-04 15:00:11 +00:00
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:
1 parent
c2c6cc47c6
commit
0f95c21b02
5 files changed
+126
-18
No files matched your search
@@ -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.`,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user