refactor: extract ToolHandler (#2032)

Follow-up for
https://github.com/ChromeDevTools/chrome-devtools-mcp/pull/2030
This commit is contained in:
Alex Rudenko
2026-05-11 16:56:47 +02:00
committed by GitHub
parent aa5e21cd02
commit 178b790493
3 changed files with 411 additions and 216 deletions
+253
View File
@@ -0,0 +1,253 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {parseArguments} from './bin/chrome-devtools-mcp-cli-options.js';
import {logger} from './logger.js';
import type {McpContext} from './McpContext.js';
import {McpResponse} from './McpResponse.js';
import type {Mutex} from './Mutex.js';
import {SlimMcpResponse} from './SlimMcpResponse.js';
import {ClearcutLogger} from './telemetry/ClearcutLogger.js';
import {bucketizeLatency} from './telemetry/metricUtils.js';
import type {CallToolResult, 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';
import {pageIdSchema} from './tools/ToolDefinition.js';
export function buildFlag(category: ToolCategory) {
return `category${category.charAt(0).toUpperCase() + category.slice(1)}`;
}
function buildDisabledMessage(
toolName: string,
flag: string,
categoryLabel?: string,
): string {
const reason = categoryLabel
? `is in category ${categoryLabel} which`
: `requires experimental feature ${flag} and`;
return `Tool ${toolName} ${reason} is currently disabled. Enable it by running chrome-devtools start ${flag}=true. For more information check the README.`;
}
function getCategoryStatus(
category: ToolCategory,
serverArgs: ReturnType<typeof parseArguments>,
): {categoryFlag?: string; disabled: boolean} {
const categoryFlag = buildFlag(category);
const flagValue = serverArgs[categoryFlag];
const isDisabled = OFF_BY_DEFAULT_CATEGORIES.includes(category)
? !flagValue
: flagValue === false;
if (isDisabled) {
return {
categoryFlag,
disabled: true,
};
}
return {
disabled: false,
};
}
function getConditionStatus(
condition: string,
serverArgs: ReturnType<typeof parseArguments>,
): {conditionFlag?: string; disabled: boolean} {
if (condition && !serverArgs[condition]) {
return {conditionFlag: condition, disabled: true};
}
return {disabled: false};
}
function getToolStatusInfo(
tool: ToolDefinition | DefinedPageTool,
serverArgs: ReturnType<typeof parseArguments>,
): {disabled: boolean; reason?: string} {
const category = tool.annotations.category;
const categoryCheck = getCategoryStatus(category, serverArgs);
if (category && categoryCheck.disabled) {
if (!categoryCheck.categoryFlag) {
throw new Error(
'when the category is disabled there should always be a flag set',
);
}
return {
disabled: true,
reason: buildDisabledMessage(
tool.name,
`--${categoryCheck.categoryFlag}`,
labels[category!],
),
};
}
for (const condition of tool.annotations.conditions || []) {
const conditionCheck = getConditionStatus(condition, serverArgs);
if (conditionCheck.disabled) {
if (!conditionCheck.conditionFlag) {
throw new Error(
'when the condition is disabled there should always be a flag set',
);
}
return {
disabled: true,
reason: buildDisabledMessage(
tool.name,
`--${conditionCheck.conditionFlag}`,
),
};
}
}
return {disabled: false};
}
function isPageScopedTool(
tool: ToolDefinition | DefinedPageTool,
): tool is DefinedPageTool {
return 'pageScoped' in tool && tool.pageScoped === true;
}
export class ToolHandler {
readonly inputSchema: zod.ZodRawShape;
readonly shouldRegister: boolean;
private readonly disabledReason?: string;
constructor(
private readonly tool: ToolDefinition | DefinedPageTool,
private readonly serverArgs: ReturnType<typeof parseArguments>,
private readonly getContext: () => Promise<McpContext>,
private readonly toolMutex: Mutex,
) {
const {disabled, reason} = getToolStatusInfo(tool, serverArgs);
this.disabledReason = reason;
this.shouldRegister = !(disabled && !serverArgs.viaCli);
this.inputSchema =
'pageScoped' in tool &&
tool.pageScoped &&
serverArgs.experimentalPageIdRouting &&
!serverArgs.slim
? {...tool.schema, ...pageIdSchema}
: tool.schema;
}
async handle(params: Record<string, unknown>): Promise<CallToolResult> {
if (this.disabledReason) {
return {
content: [
{
type: 'text',
text: this.disabledReason,
},
],
isError: true,
};
}
const guard = await this.toolMutex.acquire();
const startTime = Date.now();
let success = false;
try {
logger(
`${this.tool.name} request: ${JSON.stringify(params, null, ' ')}`,
);
const context = await this.getContext();
logger(`${this.tool.name} context: resolved`);
await context.detectOpenDevToolsWindows();
const response = this.serverArgs.slim
? new SlimMcpResponse(this.serverArgs)
: new McpResponse(this.serverArgs);
response.setRedactNetworkHeaders(this.serverArgs.redactNetworkHeaders);
try {
if (isPageScopedTool(this.tool)) {
const pageId =
typeof params.pageId === 'number' ? params.pageId : undefined;
const page =
this.serverArgs.experimentalPageIdRouting &&
pageId !== undefined &&
!this.serverArgs.slim
? context.getPageById(pageId)
: context.getSelectedMcpPage();
response.setPage(page);
if (this.tool.blockedByDialog) {
page.throwIfDialogOpen();
}
await this.tool.handler(
{
params,
page,
},
response,
context,
);
} else {
await this.tool.handler(
{
params,
},
response,
context,
);
}
} catch (err) {
response.setError(err);
}
const {content, structuredContent} = await response.handle(
this.tool.name,
context,
);
const result: CallToolResult & {
structuredContent?: Record<string, unknown>;
} = {
content,
};
if (response.error) {
result.isError = true;
}
success = true;
if (this.serverArgs.experimentalStructuredContent) {
result.structuredContent = structuredContent as Record<string, unknown>;
}
return result;
} catch (err) {
logger(`${this.tool.name} error:`, err, err?.stack);
let errorText = err && 'message' in err ? err.message : String(err);
if ('cause' in err && err.cause) {
errorText += `\nCause: ${err.cause.message}`;
}
return {
content: [
{
type: 'text',
text: errorText,
},
],
isError: true,
};
} finally {
void ClearcutLogger.get()?.logToolInvocation({
toolName: this.tool.name,
params,
schema: this.inputSchema,
success,
latencyMs: bucketizeLatency(Date.now() - startTime),
});
guard.dispose();
}
}
}
+8 -216
View File
@@ -12,11 +12,8 @@ import {ensureBrowserConnected, ensureBrowserLaunched} from './browser.js';
import {loadIssueDescriptions} from './issue-descriptions.js';
import {logger} from './logger.js';
import {McpContext} from './McpContext.js';
import {McpResponse} from './McpResponse.js';
import {Mutex} from './Mutex.js';
import {SlimMcpResponse} from './SlimMcpResponse.js';
import {ClearcutLogger} from './telemetry/ClearcutLogger.js';
import {bucketizeLatency} from './telemetry/metricUtils.js';
import {
McpServer,
type CallToolResult,
@@ -24,109 +21,12 @@ import {
ListRootsResultSchema,
RootsListChangedNotificationSchema,
} from './third_party/index.js';
import type {ToolCategory} from './tools/categories.js';
import {labels, OFF_BY_DEFAULT_CATEGORIES} from './tools/categories.js';
import {ToolHandler} from './ToolHandler.js';
import type {DefinedPageTool, ToolDefinition} from './tools/ToolDefinition.js';
import {pageIdSchema} from './tools/ToolDefinition.js';
import {createTools} from './tools/tools.js';
import {VERSION} from './version.js';
export function buildFlag(category: ToolCategory) {
return `category${category.charAt(0).toUpperCase() + category.slice(1)}`;
}
function buildDisabledMessage(
toolName: string,
flag: string,
categoryLabel?: string,
): string {
const reason = categoryLabel
? `is in category ${categoryLabel} which`
: `requires experimental feature ${flag} and`;
return `Tool ${toolName} ${reason} is currently disabled. Enable it by running chrome-devtools start ${flag}=true. For more information check the README.`;
}
function getCategoryStatus(
category: ToolCategory,
serverArgs: ReturnType<typeof parseArguments>,
): {categoryFlag?: string; disabled: boolean} {
const categoryFlag = buildFlag(category);
const flagValue = serverArgs[categoryFlag];
const isDisabled = OFF_BY_DEFAULT_CATEGORIES.includes(category)
? !flagValue
: flagValue === false;
if (isDisabled) {
return {
categoryFlag,
disabled: true,
};
}
return {
disabled: false,
};
}
function getConditionStatus(
condition: string,
serverArgs: ReturnType<typeof parseArguments>,
): {conditionFlag?: string; disabled: boolean} {
if (condition && !serverArgs[condition]) {
return {conditionFlag: condition, disabled: true};
}
return {disabled: false};
}
function getToolStatusInfo(
tool: ToolDefinition | DefinedPageTool,
serverArgs: ReturnType<typeof parseArguments>,
): {disabled: boolean; reason?: string} {
const category = tool.annotations.category;
const categoryCheck = getCategoryStatus(category, serverArgs);
if (category && categoryCheck.disabled) {
if (!categoryCheck.categoryFlag) {
throw new Error(
'when the category is disabled there should always be a flag set',
);
}
return {
disabled: true,
reason: buildDisabledMessage(
tool.name,
`--${categoryCheck.categoryFlag}`,
labels[category!],
),
};
}
for (const condition of tool.annotations.conditions || []) {
const conditionCheck = getConditionStatus(condition, serverArgs);
if (conditionCheck.disabled) {
if (!conditionCheck.conditionFlag) {
throw new Error(
'when the condition is disabled there should always be a flag set',
);
}
return {
disabled: true,
reason: buildDisabledMessage(
tool.name,
`--${conditionCheck.conditionFlag}`,
),
};
}
}
return {disabled: false};
}
export {buildFlag} from './ToolHandler.js';
export async function createMcpServer(
serverArgs: ReturnType<typeof parseArguments>,
@@ -240,134 +140,26 @@ export async function createMcpServer(
const toolMutex = new Mutex();
function registerTool(tool: ToolDefinition | DefinedPageTool): void {
const {disabled, reason: disabledReason} = getToolStatusInfo(
const toolHandler = new ToolHandler(
tool,
serverArgs,
getContext,
toolMutex,
);
if (disabled && !serverArgs.viaCli) {
if (!toolHandler.shouldRegister) {
return;
}
const schema =
'pageScoped' in tool &&
tool.pageScoped &&
serverArgs.experimentalPageIdRouting &&
!serverArgs.slim
? {...tool.schema, ...pageIdSchema}
: tool.schema;
server.registerTool(
tool.name,
{
description: tool.description,
inputSchema: schema,
inputSchema: toolHandler.inputSchema,
annotations: tool.annotations,
},
async (params): Promise<CallToolResult> => {
if (disabledReason) {
return {
content: [
{
type: 'text',
text: disabledReason,
},
],
isError: true,
};
}
const guard = await toolMutex.acquire();
const startTime = Date.now();
let success = false;
try {
logger(`${tool.name} request: ${JSON.stringify(params, null, ' ')}`);
const context = await getContext();
logger(`${tool.name} context: resolved`);
await context.detectOpenDevToolsWindows();
const response = serverArgs.slim
? new SlimMcpResponse(serverArgs)
: new McpResponse(serverArgs);
response.setRedactNetworkHeaders(serverArgs.redactNetworkHeaders);
try {
if ('pageScoped' in tool && tool.pageScoped) {
const page =
serverArgs.experimentalPageIdRouting &&
params.pageId &&
!serverArgs.slim
? context.getPageById(params.pageId)
: context.getSelectedMcpPage();
response.setPage(page);
if (tool.blockedByDialog) {
page.throwIfDialogOpen();
}
await tool.handler(
{
params,
page,
},
response,
context,
);
} else {
await tool.handler(
// @ts-expect-error types do not match.
{
params,
},
response,
context,
);
}
} catch (err) {
response.setError(err);
}
const {content, structuredContent} = await response.handle(
tool.name,
context,
);
const result: CallToolResult & {
structuredContent?: Record<string, unknown>;
} = {
content,
};
if (response.error) {
result.isError = true;
}
success = true;
if (serverArgs.experimentalStructuredContent) {
result.structuredContent = structuredContent as Record<
string,
unknown
>;
}
return result;
} catch (err) {
logger(`${tool.name} error:`, err, err?.stack);
let errorText = err && 'message' in err ? err.message : String(err);
if ('cause' in err && err.cause) {
errorText += `\nCause: ${err.cause.message}`;
}
return {
content: [
{
type: 'text',
text: errorText,
},
],
isError: true,
};
} finally {
void ClearcutLogger.get()?.logToolInvocation({
toolName: tool.name,
params,
schema,
success,
latencyMs: bucketizeLatency(Date.now() - startTime),
});
guard.dispose();
}
return await toolHandler.handle(params);
},
);
}
+150
View File
@@ -0,0 +1,150 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import assert from 'node:assert';
import {afterEach, describe, it} from 'node:test';
import sinon from 'sinon';
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 {ToolHandler} from '../src/ToolHandler.js';
import {ToolCategory} from '../src/tools/categories.js';
import type {
DefinedPageTool,
ToolDefinition,
} from '../src/tools/ToolDefinition.js';
describe('ToolHandler', () => {
afterEach(() => {
sinon.restore();
});
it('calls page getter for page scoped tools', async () => {
let handlerCalled = false;
const tool: DefinedPageTool = {
name: 'page_tool',
description: 'A page scoped tool',
annotations: {
category: ToolCategory.INPUT,
readOnlyHint: false,
},
schema: {},
blockedByDialog: false,
pageScoped: true,
handler: async () => {
handlerCalled = true;
},
};
const mockContext = sinon.createStubInstance(McpContext);
const mockPage = sinon.createStubInstance(McpPage);
mockContext.getSelectedMcpPage.returns(mockPage);
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,
);
assert.strictEqual(toolHandler.shouldRegister, true);
await toolHandler.handle({});
assert.strictEqual(mockContext.getSelectedMcpPage.calledOnce, true);
assert.strictEqual(handlerCalled, true);
});
it('does not call page getter for non-page scoped tools', async () => {
let handlerCalled = false;
const tool: ToolDefinition = {
name: 'global_tool',
description: 'A global tool',
annotations: {
category: ToolCategory.NAVIGATION,
readOnlyHint: true,
},
schema: {},
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,
);
assert.strictEqual(toolHandler.shouldRegister, true);
const result = await toolHandler.handle({});
assert.strictEqual(mockContext.getSelectedMcpPage.called, false);
assert.strictEqual(mockContext.getPageById.called, false);
assert.strictEqual(handlerCalled, true);
assert.strictEqual(result.isError, undefined);
});
it('sets shouldRegister to false and returns disabled reason when category is disabled', async () => {
let handlerCalled = false;
const tool: ToolDefinition = {
name: 'disabled_tool',
description: 'A disabled tool',
annotations: {
category: ToolCategory.EMULATION,
readOnlyHint: true,
},
schema: {},
blockedByDialog: false,
handler: async () => {
handlerCalled = true;
},
};
const mockContext = sinon.createStubInstance(McpContext);
const toolMutex = new Mutex();
const serverArgs = parseArguments(
'1.0.0',
['node', 'script.js', '--categoryEmulation=false'],
{CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true'},
);
const toolHandler = new ToolHandler(
tool,
serverArgs,
async () => mockContext,
toolMutex,
);
assert.strictEqual(toolHandler.shouldRegister, false);
const result = await toolHandler.handle({});
assert.strictEqual(result.isError, true);
assert.match(
result.content[0].type === 'text' ? result.content[0].text : '',
/is currently disabled/,
);
assert.strictEqual(handlerCalled, false);
});
});