Skip to content

Commit 178b790

Browse files
authored
refactor: extract ToolHandler (#2032)
Follow-up for #2030
1 parent aa5e21c commit 178b790

3 files changed

Lines changed: 411 additions & 216 deletions

File tree

src/ToolHandler.ts

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import type {parseArguments} from './bin/chrome-devtools-mcp-cli-options.js';
8+
import {logger} from './logger.js';
9+
import type {McpContext} from './McpContext.js';
10+
import {McpResponse} from './McpResponse.js';
11+
import type {Mutex} from './Mutex.js';
12+
import {SlimMcpResponse} from './SlimMcpResponse.js';
13+
import {ClearcutLogger} from './telemetry/ClearcutLogger.js';
14+
import {bucketizeLatency} from './telemetry/metricUtils.js';
15+
import type {CallToolResult, zod} from './third_party/index.js';
16+
import type {ToolCategory} from './tools/categories.js';
17+
import {labels, OFF_BY_DEFAULT_CATEGORIES} from './tools/categories.js';
18+
import type {DefinedPageTool, ToolDefinition} from './tools/ToolDefinition.js';
19+
import {pageIdSchema} from './tools/ToolDefinition.js';
20+
21+
export function buildFlag(category: ToolCategory) {
22+
return `category${category.charAt(0).toUpperCase() + category.slice(1)}`;
23+
}
24+
25+
function buildDisabledMessage(
26+
toolName: string,
27+
flag: string,
28+
categoryLabel?: string,
29+
): string {
30+
const reason = categoryLabel
31+
? `is in category ${categoryLabel} which`
32+
: `requires experimental feature ${flag} and`;
33+
34+
return `Tool ${toolName} ${reason} is currently disabled. Enable it by running chrome-devtools start ${flag}=true. For more information check the README.`;
35+
}
36+
37+
function getCategoryStatus(
38+
category: ToolCategory,
39+
serverArgs: ReturnType<typeof parseArguments>,
40+
): {categoryFlag?: string; disabled: boolean} {
41+
const categoryFlag = buildFlag(category);
42+
43+
const flagValue = serverArgs[categoryFlag];
44+
45+
const isDisabled = OFF_BY_DEFAULT_CATEGORIES.includes(category)
46+
? !flagValue
47+
: flagValue === false;
48+
49+
if (isDisabled) {
50+
return {
51+
categoryFlag,
52+
disabled: true,
53+
};
54+
}
55+
56+
return {
57+
disabled: false,
58+
};
59+
}
60+
61+
function getConditionStatus(
62+
condition: string,
63+
serverArgs: ReturnType<typeof parseArguments>,
64+
): {conditionFlag?: string; disabled: boolean} {
65+
if (condition && !serverArgs[condition]) {
66+
return {conditionFlag: condition, disabled: true};
67+
}
68+
69+
return {disabled: false};
70+
}
71+
72+
function getToolStatusInfo(
73+
tool: ToolDefinition | DefinedPageTool,
74+
serverArgs: ReturnType<typeof parseArguments>,
75+
): {disabled: boolean; reason?: string} {
76+
const category = tool.annotations.category;
77+
const categoryCheck = getCategoryStatus(category, serverArgs);
78+
79+
if (category && categoryCheck.disabled) {
80+
if (!categoryCheck.categoryFlag) {
81+
throw new Error(
82+
'when the category is disabled there should always be a flag set',
83+
);
84+
}
85+
86+
return {
87+
disabled: true,
88+
reason: buildDisabledMessage(
89+
tool.name,
90+
`--${categoryCheck.categoryFlag}`,
91+
labels[category!],
92+
),
93+
};
94+
}
95+
96+
for (const condition of tool.annotations.conditions || []) {
97+
const conditionCheck = getConditionStatus(condition, serverArgs);
98+
if (conditionCheck.disabled) {
99+
if (!conditionCheck.conditionFlag) {
100+
throw new Error(
101+
'when the condition is disabled there should always be a flag set',
102+
);
103+
}
104+
105+
return {
106+
disabled: true,
107+
reason: buildDisabledMessage(
108+
tool.name,
109+
`--${conditionCheck.conditionFlag}`,
110+
),
111+
};
112+
}
113+
}
114+
115+
return {disabled: false};
116+
}
117+
118+
function isPageScopedTool(
119+
tool: ToolDefinition | DefinedPageTool,
120+
): tool is DefinedPageTool {
121+
return 'pageScoped' in tool && tool.pageScoped === true;
122+
}
123+
124+
export class ToolHandler {
125+
readonly inputSchema: zod.ZodRawShape;
126+
readonly shouldRegister: boolean;
127+
private readonly disabledReason?: string;
128+
129+
constructor(
130+
private readonly tool: ToolDefinition | DefinedPageTool,
131+
private readonly serverArgs: ReturnType<typeof parseArguments>,
132+
private readonly getContext: () => Promise<McpContext>,
133+
private readonly toolMutex: Mutex,
134+
) {
135+
const {disabled, reason} = getToolStatusInfo(tool, serverArgs);
136+
this.disabledReason = reason;
137+
this.shouldRegister = !(disabled && !serverArgs.viaCli);
138+
139+
this.inputSchema =
140+
'pageScoped' in tool &&
141+
tool.pageScoped &&
142+
serverArgs.experimentalPageIdRouting &&
143+
!serverArgs.slim
144+
? {...tool.schema, ...pageIdSchema}
145+
: tool.schema;
146+
}
147+
148+
async handle(params: Record<string, unknown>): Promise<CallToolResult> {
149+
if (this.disabledReason) {
150+
return {
151+
content: [
152+
{
153+
type: 'text',
154+
text: this.disabledReason,
155+
},
156+
],
157+
isError: true,
158+
};
159+
}
160+
161+
const guard = await this.toolMutex.acquire();
162+
const startTime = Date.now();
163+
let success = false;
164+
try {
165+
logger(
166+
`${this.tool.name} request: ${JSON.stringify(params, null, ' ')}`,
167+
);
168+
const context = await this.getContext();
169+
logger(`${this.tool.name} context: resolved`);
170+
await context.detectOpenDevToolsWindows();
171+
const response = this.serverArgs.slim
172+
? new SlimMcpResponse(this.serverArgs)
173+
: new McpResponse(this.serverArgs);
174+
175+
response.setRedactNetworkHeaders(this.serverArgs.redactNetworkHeaders);
176+
try {
177+
if (isPageScopedTool(this.tool)) {
178+
const pageId =
179+
typeof params.pageId === 'number' ? params.pageId : undefined;
180+
const page =
181+
this.serverArgs.experimentalPageIdRouting &&
182+
pageId !== undefined &&
183+
!this.serverArgs.slim
184+
? context.getPageById(pageId)
185+
: context.getSelectedMcpPage();
186+
response.setPage(page);
187+
if (this.tool.blockedByDialog) {
188+
page.throwIfDialogOpen();
189+
}
190+
await this.tool.handler(
191+
{
192+
params,
193+
page,
194+
},
195+
response,
196+
context,
197+
);
198+
} else {
199+
await this.tool.handler(
200+
{
201+
params,
202+
},
203+
response,
204+
context,
205+
);
206+
}
207+
} catch (err) {
208+
response.setError(err);
209+
}
210+
const {content, structuredContent} = await response.handle(
211+
this.tool.name,
212+
context,
213+
);
214+
const result: CallToolResult & {
215+
structuredContent?: Record<string, unknown>;
216+
} = {
217+
content,
218+
};
219+
if (response.error) {
220+
result.isError = true;
221+
}
222+
success = true;
223+
if (this.serverArgs.experimentalStructuredContent) {
224+
result.structuredContent = structuredContent as Record<string, unknown>;
225+
}
226+
return result;
227+
} catch (err) {
228+
logger(`${this.tool.name} error:`, err, err?.stack);
229+
let errorText = err && 'message' in err ? err.message : String(err);
230+
if ('cause' in err && err.cause) {
231+
errorText += `\nCause: ${err.cause.message}`;
232+
}
233+
return {
234+
content: [
235+
{
236+
type: 'text',
237+
text: errorText,
238+
},
239+
],
240+
isError: true,
241+
};
242+
} finally {
243+
void ClearcutLogger.get()?.logToolInvocation({
244+
toolName: this.tool.name,
245+
params,
246+
schema: this.inputSchema,
247+
success,
248+
latencyMs: bucketizeLatency(Date.now() - startTime),
249+
});
250+
guard.dispose();
251+
}
252+
}
253+
}

0 commit comments

Comments
 (0)