From fd1a2739cbe06b6bd62328448fdee4953f7c10ef Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sun, 15 Mar 2026 09:34:59 -0700 Subject: [PATCH 1/4] 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 #1076 --- CONTRIBUTING.md | 4 +- eslint.config.mjs | 7 +++ scripts/eslint_rules/local-plugin.js | 8 ++- .../no-zod-nullable-object-rule.js | 59 +++++++++++++++++++ src/tools/input.ts | 28 ++++----- tests/tools/input.test.ts | 11 +--- 6 files changed, 89 insertions(+), 28 deletions(-) create mode 100644 scripts/eslint_rules/no-zod-nullable-object-rule.js diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 12ee96ac4..86809a915 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -145,7 +145,5 @@ export const scenario: TestScenario = { ## Restrictions on JSON schema -- no .nullable(), no .object() types. +- no .nullable(), no .object() types. Enforced by the `@local/no-zod-nullable-object` ESLint rule. - represent complex object as a short formatted string. - -TODO: implement eslint for schema https://github.com/ChromeDevTools/chrome-devtools-mcp/issues/1076 diff --git a/eslint.config.mjs b/eslint.config.mjs index b1a6121c6..012691621 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -135,6 +135,13 @@ export default defineConfig([ ], }, }, + { + name: 'Tool schema restrictions', + files: ['src/tools/**/*.ts'], + rules: { + '@local/no-zod-nullable-object': 'error', + }, + }, { name: 'Tests', files: ['**/*.test.ts'], diff --git a/scripts/eslint_rules/local-plugin.js b/scripts/eslint_rules/local-plugin.js index 27a20d372..a9b4d103a 100644 --- a/scripts/eslint_rules/local-plugin.js +++ b/scripts/eslint_rules/local-plugin.js @@ -5,5 +5,11 @@ */ import checkLicenseRule from './check-license-rule.js'; +import noZodNullableObjectRule from './no-zod-nullable-object-rule.js'; -export default {rules: {'check-license': checkLicenseRule}}; +export default { + rules: { + 'check-license': checkLicenseRule, + 'no-zod-nullable-object': noZodNullableObjectRule, + }, +}; diff --git a/scripts/eslint_rules/no-zod-nullable-object-rule.js b/scripts/eslint_rules/no-zod-nullable-object-rule.js new file mode 100644 index 000000000..85e97024c --- /dev/null +++ b/scripts/eslint_rules/no-zod-nullable-object-rule.js @@ -0,0 +1,59 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export default { + name: 'no-zod-nullable-object', + meta: { + type: 'problem', + docs: { + description: + 'Disallow .nullable() and .object() in tool schemas. Use optional strings to represent complex objects.', + }, + schema: [], + messages: { + noNullable: + 'Do not use .nullable() in tool schemas. Use .optional() instead.', + noObject: + 'Do not use .object() in tool schemas. Represent complex objects as a short formatted string.', + }, + }, + defaultOptions: [], + create(context) { + return { + CallExpression(node) { + if ( + node.callee.type !== 'MemberExpression' || + node.callee.property.type !== 'Identifier' + ) { + return; + } + + const methodName = node.callee.property.name; + + if (methodName === 'nullable') { + context.report({ + node: node.callee.property, + messageId: 'noNullable', + }); + } + + if (methodName === 'object') { + // Only flag zod.object() calls, not arbitrary .object() calls. + const obj = node.callee.object; + if ( + obj.type === 'Identifier' && + (obj.name === 'zod' || obj.name === 'z') + ) { + context.report({ + node: node.callee.property, + messageId: 'noObject', + }); + } + } + }, + }; + }, +}; diff --git a/src/tools/input.ts b/src/tools/input.ts index 059652a17..764bb85e8 100644 --- a/src/tools/input.ts +++ b/src/tools/input.ts @@ -321,25 +321,25 @@ export const fillForm = definePageTool({ }, schema: { elements: zod - .array( - zod.object({ - uid: zod.string().describe('The uid of the element to fill out'), - value: zod.string().describe('Value for the element'), - }), - ) - .describe('Elements from snapshot to fill out.'), + .array(zod.string()) + .describe( + 'Elements from snapshot to fill out. Each entry is formatted as "uid=value".', + ), includeSnapshot: includeSnapshotSchema, }, handler: async (request, response, context) => { const page = request.page; - for (const element of request.params.elements) { - await context.waitForEventsAfterAction(async () => { - await fillFormElement( - element.uid, - element.value, - context as McpContext, - page, + for (const entry of request.params.elements) { + const separatorIndex = entry.indexOf('='); + if (separatorIndex === -1) { + throw new Error( + `Invalid element format: "${entry}". Expected "uid=value".`, ); + } + const uid = entry.slice(0, separatorIndex); + const value = entry.slice(separatorIndex + 1); + await context.waitForEventsAfterAction(async () => { + await fillFormElement(uid, value, context as McpContext, page); }); } response.appendResponseLine(`Successfully filled out the form`); diff --git a/tests/tools/input.test.ts b/tests/tools/input.test.ts index 5590e88c1..734cbc086 100644 --- a/tests/tools/input.test.ts +++ b/tests/tools/input.test.ts @@ -670,16 +670,7 @@ describe('input', () => { await fillForm.handler( { params: { - elements: [ - { - uid: '1_2', - value: 'test', - }, - { - uid: '1_4', - value: 'test2', - }, - ], + elements: ['1_2=test', '1_4=test2'], }, page: context.getSelectedMcpPage(), }, From ae1b155cfaabafbb9841f62b5193f9a1df65291e Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Thu, 19 Mar 2026 10:57:49 -0700 Subject: [PATCH 2/4] Exclude fill_form from schema validation lint rule per review - Revert fill_form API changes (src/tools/input.ts, tests/tools/input.test.ts) - Rename rule to enforce-zod-schema per reviewer suggestion - Fix copyright year to 2026 - Add comment explaining .nullable() catches all calls intentionally - Exclude src/tools/input.ts from the lint rule via ignores --- CONTRIBUTING.md | 2 +- eslint.config.mjs | 3 +- ...ect-rule.js => enforce-zod-schema-rule.js} | 7 +++-- scripts/eslint_rules/local-plugin.js | 4 +-- src/tools/input.ts | 28 +++++++++---------- tests/tools/input.test.ts | 11 +++++++- 6 files changed, 34 insertions(+), 21 deletions(-) rename scripts/eslint_rules/{no-zod-nullable-object-rule.js => enforce-zod-schema-rule.js} (85%) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 86809a915..067d20aeb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -145,5 +145,5 @@ export const scenario: TestScenario = { ## Restrictions on JSON schema -- no .nullable(), no .object() types. Enforced by the `@local/no-zod-nullable-object` ESLint rule. +- no .nullable(), no .object() types. Enforced by the `@local/enforce-zod-schema` ESLint rule. - represent complex object as a short formatted string. diff --git a/eslint.config.mjs b/eslint.config.mjs index 012691621..ef00fd352 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -138,8 +138,9 @@ export default defineConfig([ { name: 'Tool schema restrictions', files: ['src/tools/**/*.ts'], + ignores: ['src/tools/input.ts'], rules: { - '@local/no-zod-nullable-object': 'error', + '@local/enforce-zod-schema': 'error', }, }, { diff --git a/scripts/eslint_rules/no-zod-nullable-object-rule.js b/scripts/eslint_rules/enforce-zod-schema-rule.js similarity index 85% rename from scripts/eslint_rules/no-zod-nullable-object-rule.js rename to scripts/eslint_rules/enforce-zod-schema-rule.js index 85e97024c..d75dfb11d 100644 --- a/scripts/eslint_rules/no-zod-nullable-object-rule.js +++ b/scripts/eslint_rules/enforce-zod-schema-rule.js @@ -1,11 +1,11 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ export default { - name: 'no-zod-nullable-object', + name: 'enforce-zod-schema', meta: { type: 'problem', docs: { @@ -33,6 +33,9 @@ export default { const methodName = node.callee.property.name; + // We don't validate that .nullable() is called on a ZodObject + // specifically - this intentionally catches all .nullable() calls + // in tool schema files. if (methodName === 'nullable') { context.report({ node: node.callee.property, diff --git a/scripts/eslint_rules/local-plugin.js b/scripts/eslint_rules/local-plugin.js index a9b4d103a..b61d399b4 100644 --- a/scripts/eslint_rules/local-plugin.js +++ b/scripts/eslint_rules/local-plugin.js @@ -5,11 +5,11 @@ */ import checkLicenseRule from './check-license-rule.js'; -import noZodNullableObjectRule from './no-zod-nullable-object-rule.js'; +import enforceZodSchemaRule from './enforce-zod-schema-rule.js'; export default { rules: { 'check-license': checkLicenseRule, - 'no-zod-nullable-object': noZodNullableObjectRule, + 'enforce-zod-schema': enforceZodSchemaRule, }, }; diff --git a/src/tools/input.ts b/src/tools/input.ts index 764bb85e8..059652a17 100644 --- a/src/tools/input.ts +++ b/src/tools/input.ts @@ -321,25 +321,25 @@ export const fillForm = definePageTool({ }, schema: { elements: zod - .array(zod.string()) - .describe( - 'Elements from snapshot to fill out. Each entry is formatted as "uid=value".', - ), + .array( + zod.object({ + uid: zod.string().describe('The uid of the element to fill out'), + value: zod.string().describe('Value for the element'), + }), + ) + .describe('Elements from snapshot to fill out.'), includeSnapshot: includeSnapshotSchema, }, handler: async (request, response, context) => { const page = request.page; - for (const entry of request.params.elements) { - const separatorIndex = entry.indexOf('='); - if (separatorIndex === -1) { - throw new Error( - `Invalid element format: "${entry}". Expected "uid=value".`, - ); - } - const uid = entry.slice(0, separatorIndex); - const value = entry.slice(separatorIndex + 1); + for (const element of request.params.elements) { await context.waitForEventsAfterAction(async () => { - await fillFormElement(uid, value, context as McpContext, page); + await fillFormElement( + element.uid, + element.value, + context as McpContext, + page, + ); }); } response.appendResponseLine(`Successfully filled out the form`); diff --git a/tests/tools/input.test.ts b/tests/tools/input.test.ts index 734cbc086..5590e88c1 100644 --- a/tests/tools/input.test.ts +++ b/tests/tools/input.test.ts @@ -670,7 +670,16 @@ describe('input', () => { await fillForm.handler( { params: { - elements: ['1_2=test', '1_4=test2'], + elements: [ + { + uid: '1_2', + value: 'test', + }, + { + uid: '1_4', + value: 'test2', + }, + ], }, page: context.getSelectedMcpPage(), }, From 536f1b0378ea5e96ded3e8b2a10f5053708811e7 Mon Sep 17 00:00:00 2001 From: Alex Rudenko Date: Wed, 1 Apr 2026 08:58:24 +0200 Subject: [PATCH 3/4] Apply suggestion from @Lightning00Blade Co-authored-by: Nikolay Vitkov <34244704+Lightning00Blade@users.noreply.github.com> --- eslint.config.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index ef00fd352..6a7993d8d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -136,7 +136,7 @@ export default defineConfig([ }, }, { - name: 'Tool schema restrictions', + name: 'Tools definitions', files: ['src/tools/**/*.ts'], ignores: ['src/tools/input.ts'], rules: { From c54339168be5ab8995673b47f4168c010874fd67 Mon Sep 17 00:00:00 2001 From: Alex Rudenko Date: Wed, 1 Apr 2026 09:00:23 +0200 Subject: [PATCH 4/4] chore: ignore inline --- eslint.config.mjs | 1 - src/tools/input.ts | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 6a7993d8d..ade8c4e8f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -138,7 +138,6 @@ export default defineConfig([ { name: 'Tools definitions', files: ['src/tools/**/*.ts'], - ignores: ['src/tools/input.ts'], rules: { '@local/enforce-zod-schema': 'error', }, diff --git a/src/tools/input.ts b/src/tools/input.ts index 059652a17..1ddbcacd9 100644 --- a/src/tools/input.ts +++ b/src/tools/input.ts @@ -322,6 +322,7 @@ export const fillForm = definePageTool({ schema: { elements: zod .array( + // eslint-disable-next-line @local/enforce-zod-schema zod.object({ uid: zod.string().describe('The uid of the element to fill out'), value: zod.string().describe('Value for the element'),