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:
+47
-1
@@ -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
@@ -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> => {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user