Skip to content

Commit b2deec8

Browse files
committed
feat: add eslint rule to validate tool schema patterns
Add `@local/no-zod-nullable-object` ESLint rule that disallows `.nullable()` and `.object()` in tool schemas (src/tools/**). Fix existing violation in fill_form by using "uid=value" string format instead of zod.object(). Remove the TODO from CONTRIBUTING.md. Closes ChromeDevTools#1076
1 parent e6b7a09 commit b2deec8

6 files changed

Lines changed: 89 additions & 28 deletions

File tree

CONTRIBUTING.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,5 @@ export const scenario: TestScenario = {
145145

146146
## Restrictions on JSON schema
147147

148-
- no .nullable(), no .object() types.
148+
- no .nullable(), no .object() types. Enforced by the `@local/no-zod-nullable-object` ESLint rule.
149149
- represent complex object as a short formatted string.
150-
151-
TODO: implement eslint for schema https://github.com/ChromeDevTools/chrome-devtools-mcp/issues/1076

eslint.config.mjs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,13 @@ export default defineConfig([
134134
],
135135
},
136136
},
137+
{
138+
name: 'Tool schema restrictions',
139+
files: ['src/tools/**/*.ts'],
140+
rules: {
141+
'@local/no-zod-nullable-object': 'error',
142+
},
143+
},
137144
{
138145
name: 'Tests',
139146
files: ['**/*.test.ts'],

scripts/eslint_rules/local-plugin.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,11 @@
55
*/
66

77
import checkLicenseRule from './check-license-rule.js';
8+
import noZodNullableObjectRule from './no-zod-nullable-object-rule.js';
89

9-
export default {rules: {'check-license': checkLicenseRule}};
10+
export default {
11+
rules: {
12+
'check-license': checkLicenseRule,
13+
'no-zod-nullable-object': noZodNullableObjectRule,
14+
},
15+
};
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
export default {
8+
name: 'no-zod-nullable-object',
9+
meta: {
10+
type: 'problem',
11+
docs: {
12+
description:
13+
'Disallow .nullable() and .object() in tool schemas. Use optional strings to represent complex objects.',
14+
},
15+
schema: [],
16+
messages: {
17+
noNullable:
18+
'Do not use .nullable() in tool schemas. Use .optional() instead.',
19+
noObject:
20+
'Do not use .object() in tool schemas. Represent complex objects as a short formatted string.',
21+
},
22+
},
23+
defaultOptions: [],
24+
create(context) {
25+
return {
26+
CallExpression(node) {
27+
if (
28+
node.callee.type !== 'MemberExpression' ||
29+
node.callee.property.type !== 'Identifier'
30+
) {
31+
return;
32+
}
33+
34+
const methodName = node.callee.property.name;
35+
36+
if (methodName === 'nullable') {
37+
context.report({
38+
node: node.callee.property,
39+
messageId: 'noNullable',
40+
});
41+
}
42+
43+
if (methodName === 'object') {
44+
// Only flag zod.object() calls, not arbitrary .object() calls.
45+
const obj = node.callee.object;
46+
if (
47+
obj.type === 'Identifier' &&
48+
(obj.name === 'zod' || obj.name === 'z')
49+
) {
50+
context.report({
51+
node: node.callee.property,
52+
messageId: 'noObject',
53+
});
54+
}
55+
}
56+
},
57+
};
58+
},
59+
};

src/tools/input.ts

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -319,25 +319,25 @@ export const fillForm = definePageTool({
319319
},
320320
schema: {
321321
elements: zod
322-
.array(
323-
zod.object({
324-
uid: zod.string().describe('The uid of the element to fill out'),
325-
value: zod.string().describe('Value for the element'),
326-
}),
327-
)
328-
.describe('Elements from snapshot to fill out.'),
322+
.array(zod.string())
323+
.describe(
324+
'Elements from snapshot to fill out. Each entry is formatted as "uid=value".',
325+
),
329326
includeSnapshot: includeSnapshotSchema,
330327
},
331328
handler: async (request, response, context) => {
332329
const page = request.page;
333-
for (const element of request.params.elements) {
334-
await context.waitForEventsAfterAction(async () => {
335-
await fillFormElement(
336-
element.uid,
337-
element.value,
338-
context as McpContext,
339-
page,
330+
for (const entry of request.params.elements) {
331+
const separatorIndex = entry.indexOf('=');
332+
if (separatorIndex === -1) {
333+
throw new Error(
334+
`Invalid element format: "${entry}". Expected "uid=value".`,
340335
);
336+
}
337+
const uid = entry.slice(0, separatorIndex);
338+
const value = entry.slice(separatorIndex + 1);
339+
await context.waitForEventsAfterAction(async () => {
340+
await fillFormElement(uid, value, context as McpContext, page);
341341
});
342342
}
343343
response.appendResponseLine(`Successfully filled out the form`);

tests/tools/input.test.ts

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -670,16 +670,7 @@ describe('input', () => {
670670
await fillForm.handler(
671671
{
672672
params: {
673-
elements: [
674-
{
675-
uid: '1_2',
676-
value: 'test',
677-
},
678-
{
679-
uid: '1_4',
680-
value: 'test2',
681-
},
682-
],
673+
elements: ['1_2=test', '1_4=test2'],
683674
},
684675
page: context.getSelectedMcpPage(),
685676
},

0 commit comments

Comments
 (0)