fix: report unknown tool arguments (#2064)

## Summary
- register tool input schemas as passthrough so extra named arguments
reach ToolHandler validation
- report unknown arguments with an explicit error that names the unknown
and expected arguments
- stop before invoking tool handlers when unknown arguments are present
- add ToolHandler coverage for reporting an extra argument

Fixes #1940

## Tests
- `npm run check-format`
- `npx tsc --noEmitOnError false` *(emits build artifacts but still
reports the existing `chrome-devtools-frontend` type conflict in
`ModelImpl.ts`)*
- `node --experimental-strip-types --no-warnings=ExperimentalWarning
scripts/post-build.ts`
- `NODE_TEST_REPORTER=spec npm run test:no-build --
tests/ToolHandler.test.ts`
This commit is contained in:
lmlm
2026-05-15 23:52:57 +08:00
committed by GitHub
parent 3efd8c0194
commit 041b208378
3 changed files with 98 additions and 2 deletions
+47 -1
View File
@@ -12,7 +12,8 @@ import type {Mutex} from './Mutex.js';
import {SlimMcpResponse} from './SlimMcpResponse.js';
import {ClearcutLogger} from './telemetry/ClearcutLogger.js';
import {bucketizeLatency} from './telemetry/transformation.js';
import type {CallToolResult, zod} from './third_party/index.js';
import type {CallToolResult} from './third_party/index.js';
import {zod} from './third_party/index.js';
import type {ToolCategory} from './tools/categories.js';
import {labels, OFF_BY_DEFAULT_CATEGORIES} from './tools/categories.js';
import type {DefinedPageTool, ToolDefinition} from './tools/ToolDefinition.js';
@@ -121,8 +122,29 @@ function isPageScopedTool(
return 'pageScoped' in tool && tool.pageScoped === true;
}
function formatArgumentNames(names: string[]): string {
return names.map(name => `"${name}"`).join(', ');
}
function buildUnknownArgumentsMessage(
toolName: string,
unknownArgumentNames: string[],
expectedArgumentNames: string[],
): string {
const unknownLabel =
unknownArgumentNames.length === 1 ? 'argument' : 'arguments';
const expectedArguments = expectedArgumentNames.length
? `Expected arguments: ${formatArgumentNames(expectedArgumentNames)}.`
: 'This tool does not accept any arguments.';
const correction =
unknownArgumentNames.length === 1 ? 'Remove it' : 'Remove them';
return `Unknown ${unknownLabel} for tool "${toolName}": ${formatArgumentNames(unknownArgumentNames)}. ${expectedArguments} ${correction} and retry.`;
}
export class ToolHandler {
readonly inputSchema: zod.ZodRawShape;
readonly registeredInputSchema: zod.ZodTypeAny;
readonly shouldRegister: boolean;
private readonly disabledReason?: string;
@@ -143,6 +165,13 @@ export class ToolHandler {
!serverArgs.slim
? {...tool.schema, ...pageIdSchema}
: tool.schema;
this.registeredInputSchema = zod.object(this.inputSchema).passthrough();
}
unknownArgumentNames(params: Record<string, unknown>): string[] {
return Object.keys(params).filter(
key => !Object.hasOwn(this.inputSchema, key),
);
}
async handle(params: Record<string, unknown>): Promise<CallToolResult> {
@@ -158,6 +187,23 @@ export class ToolHandler {
};
}
const unknownArgumentNames = this.unknownArgumentNames(params);
if (unknownArgumentNames.length) {
return {
content: [
{
type: 'text',
text: buildUnknownArgumentsMessage(
this.tool.name,
unknownArgumentNames,
Object.keys(this.inputSchema),
),
},
],
isError: true,
};
}
const guard = await this.toolMutex.acquire();
const startTime = Date.now();
let success = false;
+1 -1
View File
@@ -157,7 +157,7 @@ export async function createMcpServer(
tool.name,
{
description: tool.description,
inputSchema: toolHandler.inputSchema,
inputSchema: toolHandler.registeredInputSchema,
annotations: tool.annotations,
},
async (params): Promise<CallToolResult> => {
+50
View File
@@ -13,6 +13,7 @@ import {parseArguments} from '../src/bin/chrome-devtools-mcp-cli-options.js';
import {McpContext} from '../src/McpContext.js';
import {McpPage} from '../src/McpPage.js';
import {Mutex} from '../src/Mutex.js';
import {zod} from '../src/third_party/index.js';
import {ToolHandler} from '../src/ToolHandler.js';
import {ToolCategory} from '../src/tools/categories.js';
import type {
@@ -106,6 +107,55 @@ describe('ToolHandler', () => {
assert.strictEqual(result.isError, undefined);
});
it('reports unknown registered tool arguments clearly', async () => {
let handlerCalled = false;
const tool: ToolDefinition = {
name: 'lenient_tool',
description: 'A tool with a required argument',
annotations: {
category: ToolCategory.NAVIGATION,
readOnlyHint: true,
},
schema: {
url: zod.string(),
},
blockedByDialog: false,
handler: async () => {
handlerCalled = true;
},
};
const mockContext = sinon.createStubInstance(McpContext);
mockContext.detectOpenDevToolsWindows.resolves();
const toolMutex = new Mutex();
const serverArgs = parseArguments('1.0.0', ['node', 'script.js'], {
CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true',
});
const toolHandler = new ToolHandler(
tool,
serverArgs,
async () => mockContext,
toolMutex,
);
const params = {url: 'https://example.com', description: 'open the page'};
assert.strictEqual(
toolHandler.registeredInputSchema.safeParse(params).success,
true,
);
const result = await toolHandler.handle(params);
assert.strictEqual(result.isError, true);
assert.match(
result.content[0].type === 'text' ? result.content[0].text : '',
/Unknown argument for tool "lenient_tool": "description"\. Expected arguments: "url"\./,
);
assert.strictEqual(handlerCalled, false);
});
it('sets shouldRegister to false and returns disabled reason when category is disabled', async () => {
let handlerCalled = false;
const tool: ToolDefinition = {