feat(tools): support updating ToolHandler definitions in place (#3050)

Supports updating registered `ToolHandler` instances in place when
reloadable options change tool schemas or availability.

- Passes `ReadonlyServerConfig` to `ToolHandler` so `disabled` and
`pageIdRouting` reflect the live config.
- Adds `ToolHandler.update(tool)` to swap the underlying tool
definition, rebuild its input schema, and return whether the
client-visible tool listing changed.
- Versions the input schema so calls queued behind the tool mutex or
in-flight async parses fail cleanly if the schema changed while they
waited.

<sub>Stack created with <a
href="https://github.com/github/gh-stack">GitHub Stacks CLI</a> • <a
href="https://gh.io/stacks-feedback">Give Feedback 💬</a></sub>
This commit is contained in:
Nikolay Vitkov authored and GitHub committed 2026-10-19 16:39:30 +00:00
1 parent a65f09e9da
commit 8064138482
4 files changed
+593 -321

No files matched your search

+222 -139
View File
@@ -5,13 +5,14 @@
*/
import type {ParsedArguments} from './config/ConfigParser.js';
import type {ReadonlyServerConfig} from './config/ServerConfig.js';
import type {McpContext} from './McpContext.js';
import type {McpPage} from './McpPage.js';
import {McpResponse, type DataFormat} from './McpResponse.js';
import {SlimMcpResponse} from './SlimMcpResponse.js';
import {ClearcutLogger} from './telemetry/ClearcutLogger.js';
import type {Browser, CallToolResult, Mutex} from './third_party/index.js';
import {zod} from './third_party/index.js';
import {ProtocolError, ProtocolErrorCode, zod} from './third_party/index.js';
import {labels} from './tools/categories.js';
import {categoryToFlagName} from './config/category-options.js';
import type {
@@ -30,6 +31,7 @@ import {
} from './tools/ToolDefinition.js';
import {logger} from './utils/logger.js';
import {fileURLToPath, pathToFileURL} from 'node:url';
import {isDeepStrictEqual} from 'node:util';
import {isLocalhost} from './utils/url.js';
type ToolLockGuard = Awaited<ReturnType<Mutex['acquire']>>;
@@ -68,41 +70,31 @@ function buildDisabledMessage(
return `Tool ${toolName} ${reason} is currently disabled. Enable it by running chrome-devtools start ${flag}=true. For more information check the README.`;
}
function getToolStatusInfo(
function getDisabledReason(
tool: AnyToolDefinition,
serverArgs: ParsedArguments,
): {disabled: boolean; reason?: string; unavailableInMode?: boolean} {
): string | undefined {
if (!isAvailableInMode(tool, serverArgs)) {
return {
disabled: true,
unavailableInMode: true,
reason: isSlimTool(tool)
? `Tool ${tool.name} is only available with --slim.`
: `Tool ${tool.name} is not available with --slim.`,
};
return isSlimTool(tool)
? `Tool ${tool.name} is only available with --slim.`
: `Tool ${tool.name} is not available with --slim.`;
}
const category = tool.annotations.category;
if (category) {
const flag = categoryToFlagName(category);
if (!serverArgs[flag]) {
return {
disabled: true,
reason: buildDisabledMessage(tool.name, `--${flag}`, labels[category]),
};
return buildDisabledMessage(tool.name, `--${flag}`, labels[category]);
}
}
for (const condition of tool.annotations.conditions || []) {
if (!serverArgs[condition]) {
return {
disabled: true,
reason: buildDisabledMessage(tool.name, `--${condition}`),
};
return buildDisabledMessage(tool.name, `--${condition}`);
}
}
return {disabled: false};
return undefined;
}
async function validateAndResolvePathOrUrl(
@@ -208,32 +200,93 @@ async function validateToolFiles(
}
}
function toInputJsonSchema(schema: zod.ZodType): unknown {
return zod.toJSONSchema(schema, {io: 'input', unrepresentable: 'any'});
}
function formatValidationError(toolName: string, error: zod.ZodError): string {
const details = error.issues
.map(
issue =>
`${issue.path.length > 0 ? `${issue.path.join('.')}: ` : ''}${issue.message}`,
)
.join(', ');
return `Input validation error: Invalid arguments for tool ${toolName}: ${details}`;
}
export class ToolHandler {
readonly inputSchema: zod.ZodRawShape;
readonly registeredInputSchema: zod.ZodObject<
zod.ZodRawShape,
zod.core.$strict
>;
readonly disabled: boolean;
private readonly disabledReason?: string;
#tool: AnyToolDefinition;
#config: ReadonlyServerConfig;
#registeredInputSchema: zod.ZodObject<zod.ZodRawShape, zod.core.$strict>;
#disabled = false;
#disabledReason?: string;
constructor(
private readonly tool: AnyToolDefinition,
private readonly serverArgs: ParsedArguments,
tool: AnyToolDefinition,
config: ReadonlyServerConfig,
private readonly getContext: () => Promise<McpContext>,
private readonly toolMutex: Mutex,
private readonly abandonPendingBrowserAttemptOnTimeout: () => void,
) {
const {disabled, reason, unavailableInMode} = getToolStatusInfo(
tool,
serverArgs,
);
this.disabledReason = reason;
this.disabled =
disabled && (Boolean(unavailableInMode) || !serverArgs.viaCli);
this.#tool = tool;
this.#config = config;
this.#registeredInputSchema = zod.object(tool.schema).strict();
this.#updateStatus(tool);
}
this.inputSchema = tool.schema;
this.registeredInputSchema = zod.object(this.inputSchema).strict();
get registeredInputSchema(): zod.ZodObject<
zod.ZodRawShape,
zod.core.$strict
> {
return this.#registeredInputSchema;
}
/**
* Whether the tool is hidden from the client.
*/
get disabled(): boolean {
return this.#disabled;
}
#updateStatus(tool: AnyToolDefinition): void {
const serverArgs = this.#config.resolved;
this.#disabledReason = getDisabledReason(tool, serverArgs);
this.#disabled =
Boolean(this.#disabledReason) &&
(!isAvailableInMode(tool, serverArgs) || !serverArgs.viaCli);
}
/**
* Replaces the tool definition and re-evaluates its status against the
* current server config. Callers must hold the tool mutex, so calls that
* are already waiting for it run with the new state. Returns true if the
* tool listing visible to the client changed.
*/
update(tool: AnyToolDefinition): boolean {
const previousTool = this.#tool;
const previousDisabled = this.#disabled;
const previousInputSchema = this.#registeredInputSchema;
const nextInputSchema = zod.object(tool.schema).strict();
this.#tool = tool;
this.#registeredInputSchema = nextInputSchema;
this.#updateStatus(tool);
if (previousDisabled !== this.#disabled) {
return true;
}
if (this.#disabled) {
return false;
}
return (
previousTool.description !== tool.description ||
!isDeepStrictEqual(previousTool.annotations, tool.annotations) ||
!isDeepStrictEqual(
toInputJsonSchema(previousInputSchema),
toInputJsonSchema(nextInputSchema),
)
);
}
/**
@@ -247,7 +300,7 @@ export class ToolHandler {
onTimeout: () => void,
): Promise<T> {
const timeoutError = new ToolCallTimeoutError(
`Tool "${this.tool.name}" timed out after ${TOOL_CALL_TIMEOUT_MS}ms waiting for the browser connection to be established. The connection attempt will be retried automatically on the next tool call.`,
`Tool "${this.#tool.name}" timed out after ${TOOL_CALL_TIMEOUT_MS}ms waiting for the browser connection to be established. The connection attempt will be retried automatically on the next tool call.`,
);
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
@@ -266,12 +319,12 @@ export class ToolHandler {
}
}
#dataFormat(): DataFormat {
#dataFormat(serverArgs: ParsedArguments): DataFormat {
// --experimentalDataFormat takes precedence over the legacy
// --experimentalToonFormat.
return (
this.serverArgs.experimentalDataFormat ??
(this.serverArgs.experimentalToonFormat ? 'toon' : 'default')
serverArgs.experimentalDataFormat ??
(serverArgs.experimentalToonFormat ? 'toon' : 'default')
);
}
@@ -281,6 +334,7 @@ export class ToolHandler {
*/
async #runBrowserlessTool(
tool: BrowserlessToolDefinition,
serverArgs: ParsedArguments,
params: Record<string, unknown>,
response: McpResponse,
context: McpContext,
@@ -295,11 +349,12 @@ export class ToolHandler {
} finally {
await lock.reacquire();
}
return await response.handle(context, this.#dataFormat());
return await response.handle(context, this.#dataFormat(serverArgs));
}
async #runBrowserTool(
tool: ToolDefinition | DefinedPageTool,
serverArgs: ParsedArguments,
params: Record<string, unknown>,
response: McpResponse,
context: McpContext,
@@ -329,9 +384,7 @@ export class ToolHandler {
const pageId =
typeof params.pageId === 'number' ? params.pageId : undefined;
page =
this.serverArgs.pageIdRouting &&
pageId !== undefined &&
!isSlimTool(tool)
serverArgs.pageIdRouting && pageId !== undefined && !isSlimTool(tool)
? context.getPageById(pageId)
: context.getSelectedMcpPage();
await page?.init();
@@ -367,110 +420,140 @@ export class ToolHandler {
await context.getDevToolsData(page),
context.getSelectedMcpPageUrl(page),
);
return await response.handle(context, this.#dataFormat());
return await response.handle(context, this.#dataFormat(serverArgs));
}
handle = async (params: Record<string, unknown>): Promise<CallToolResult> => {
handle = async (
rawArgs: Record<string, unknown> = {},
options?: {viaMcp?: boolean},
): Promise<CallToolResult> => {
let guard: ToolLockGuard | undefined = await this.toolMutex.acquire();
if (this.disabledReason) {
guard[Symbol.dispose]();
return {
content: [
{
type: 'text',
text: this.disabledReason,
},
],
isError: true,
};
}
const startTime = Date.now();
let success = false;
let devToolsData: DevToolsData | undefined;
let pageUrl: string | undefined;
try {
logger?.(
`${this.tool.name} request: ${JSON.stringify(params, null, ' ')}`,
);
const context = await this.getContext();
const response = this.serverArgs.slim
? new SlimMcpResponse(this.serverArgs)
: new McpResponse(this.serverArgs);
response.setRedactNetworkHeaders(this.serverArgs.redactNetworkHeaders);
// When the tool yields the lock, the handler waits for external events.
// Release the lock so other tool calls proceed, and re-acquire it
// before the response is built.
const lock: ToolLockControl = {
releaseIfYielding: () => {
if (this.tool.yieldsToolLock && guard) {
guard[Symbol.dispose]();
guard = undefined;
}
},
reacquire: async () => {
guard ??= await this.toolMutex.acquire();
},
};
const {content, structuredContent} = isBrowserlessTool(this.tool)
? await this.#runBrowserlessTool(
this.tool,
params,
response,
context,
lock,
)
: await this.#runBrowserTool(
this.tool,
params,
response,
context,
lock,
(data, url) => {
devToolsData = data;
pageUrl = url;
if (options?.viaMcp && this.#disabled) {
throw new ProtocolError(
ProtocolErrorCode.InvalidParams,
`Tool ${this.#tool.name} disabled`,
);
}
if (this.#disabledReason) {
return {
content: [
{
type: 'text',
text: this.#disabledReason,
},
);
const result: CallToolResult & {
structuredContent?: Record<string, unknown>;
} = {
content,
};
if (response.error) {
result.isError = true;
],
isError: true,
};
}
success = true;
if (this.serverArgs.experimentalStructuredContent) {
result.structuredContent = structuredContent as Record<string, unknown>;
const tool = this.#tool;
const serverArgs = this.#config.resolved;
const parseResult = this.#registeredInputSchema.safeParse(rawArgs);
if (!parseResult.success) {
return {
content: [
{
type: 'text',
text: formatValidationError(tool.name, parseResult.error),
},
],
isError: true,
};
}
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,
const params = parseResult.data;
const startTime = Date.now();
let success = false;
let devToolsData: DevToolsData | undefined;
let pageUrl: string | undefined;
try {
logger?.(`${tool.name} request: ${JSON.stringify(params, null, ' ')}`);
const context = await this.getContext();
const response = isSlimTool(tool)
? new SlimMcpResponse(serverArgs)
: new McpResponse(serverArgs);
response.setRedactNetworkHeaders(serverArgs.redactNetworkHeaders);
// When the tool yields the lock, the handler waits for external events.
// Release the lock so other tool calls proceed, and re-acquire it
// before the response is built.
const lock: ToolLockControl = {
releaseIfYielding: () => {
if (tool.yieldsToolLock && guard) {
guard[Symbol.dispose]();
guard = undefined;
}
},
],
isError: true,
};
reacquire: async () => {
guard ??= await this.toolMutex.acquire();
},
};
const {content, structuredContent} = isBrowserlessTool(tool)
? await this.#runBrowserlessTool(
tool,
serverArgs,
params,
response,
context,
lock,
)
: await this.#runBrowserTool(
tool,
serverArgs,
params,
response,
context,
lock,
(data, url) => {
devToolsData = data;
pageUrl = url;
},
);
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: tool.schema,
success,
latencyMs: Date.now() - startTime,
devToolsData,
pageUrl,
});
}
} finally {
guard?.[Symbol.dispose]();
void ClearcutLogger.get()?.logToolInvocation({
toolName: this.tool.name,
params,
schema: this.inputSchema,
success,
latencyMs: Date.now() - startTime,
devToolsData,
pageUrl,
});
}
};
}
+26 -23
View File
@@ -20,6 +20,8 @@ import {FilePersistence} from './telemetry/persistence.js';
import {
type CallToolResult,
McpServer as SdkMcpServer,
ProtocolError,
ProtocolErrorCode,
type RegisteredTool,
type Root,
type Transport,
@@ -163,25 +165,7 @@ export class McpServer {
isError: true,
};
}
const parseResult =
await toolHandler.registeredInputSchema.safeParseAsync(args);
if (!parseResult.success) {
return {
content: [
{
type: 'text',
text: `Input validation error: Invalid arguments for tool ${name}: ${parseResult.error.issues
.map(
issue =>
`${issue.path.length > 0 ? `${issue.path.join('.')}: ` : ''}${issue.message}`,
)
.join(', ')}`,
},
],
isError: true,
};
}
return await toolHandler.handle(parseResult.data);
return await toolHandler.handle(args);
}
/**
@@ -224,6 +208,7 @@ export class McpServer {
for (const tool of createTools(this.#config.resolved)) {
this.#registerTool(tool);
}
this.#setToolCallRequestHandler();
await loadIssueDescriptions();
}
@@ -277,17 +262,35 @@ export class McpServer {
}
#createToolHandler(tool: AnyToolDefinition): ToolHandler {
// No production code calls ServerConfig.commit() yet; a follow-up PR passes
// ReadonlyServerConfig to ToolHandler.
return new ToolHandler(
tool,
this.#config.resolved,
this.#config,
() => this.#getContext(),
this.#toolMutex,
() => this.#browserManager.abandonPendingAttempt(),
);
}
/**
* Overrides the SDK's `tools/call` handler so enabled and input-schema
* checks run inside `ToolHandler.handle()` after acquiring `#toolMutex`.
*/
#setToolCallRequestHandler(): void {
this.server.server.setRequestHandler('tools/call', async request => {
const entry = this.#tools.get(request.params.name);
if (!entry) {
throw new ProtocolError(
ProtocolErrorCode.InvalidParams,
`Tool ${request.params.name} not found`,
);
}
const result = await entry.handler.handle(request.params.arguments, {
viaMcp: true,
});
return this.server.server.projectCallToolResult(result, undefined);
});
}
#registerTool(tool: AnyToolDefinition): void {
const handler = this.#createToolHandler(tool);
@@ -298,7 +301,7 @@ export class McpServer {
inputSchema: handler.registeredInputSchema,
annotations: tool.annotations,
},
handler.handle,
args => handler.handle(args),
);
if (handler.disabled) {
+6 -1
View File
@@ -18,7 +18,12 @@ export type {Flags, Result, RunnerResult, OutputMode};
export type {YargsOptions, InferredOptionTypes} from './cli.js';
export {yargs, hideBin, semver, PipeTransport} from './cli.js';
export {McpServer, type RegisteredTool} from '@modelcontextprotocol/server';
export {
McpServer,
ProtocolError,
ProtocolErrorCode,
type RegisteredTool,
} from '@modelcontextprotocol/server';
export {
StdioServerTransport,
serveStdio,
+339 -158
View File
@@ -17,7 +17,12 @@ import {McpContext} from '../src/McpContext.js';
import {McpPage} from '../src/McpPage.js';
import {McpResponse, type DataFormat} from '../src/McpResponse.js';
import {ClearcutLogger} from '../src/telemetry/ClearcutLogger.js';
import {zod, Mutex} from '../src/third_party/index.js';
import {
Mutex,
ProtocolError,
ProtocolErrorCode,
zod,
} from '../src/third_party/index.js';
import {TOOL_CALL_TIMEOUT_MS, ToolHandler} from '../src/ToolHandler.js';
import {ToolCategory} from '../src/tools/categories.js';
import {
@@ -38,11 +43,15 @@ describe('ToolHandler', () => {
ClearcutLogger.resetForTesting();
});
function parseConfig(argv: string[] = []) {
return new ConfigParser('1.0.0', ['node', 'script.js', ...argv], {
CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true',
}).parseConfig();
}
it('calls getPageById for page scoped tools when pageId is provided', async () => {
let handlerCalled = false;
const serverArgs = new ConfigParser('1.0.0', ['node', 'script.js'], {
CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true',
}).parse();
const config = parseConfig();
const tool = definePageTool(() => ({
name: 'page_tool',
description: 'A page scoped tool',
@@ -56,7 +65,7 @@ describe('ToolHandler', () => {
handler: async () => {
handlerCalled = true;
},
}))(serverArgs);
}))(config.resolved);
const mockContext = sinon.createStubInstance(McpContext);
const mockProcess = sinon.createStubInstance(ChildProcess);
@@ -68,7 +77,7 @@ describe('ToolHandler', () => {
const toolHandler = new ToolHandler(
tool,
serverArgs,
config,
async () => mockContext,
toolMutex,
sinon.spy(),
@@ -84,11 +93,7 @@ describe('ToolHandler', () => {
it('calls getSelectedMcpPage for page scoped tools when pageIdRouting is disabled', async () => {
let handlerCalled = false;
const serverArgs = new ConfigParser(
'1.0.0',
['node', 'script.js', '--no-page-id-routing'],
{CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true'},
).parse();
const config = parseConfig(['--no-page-id-routing']);
const tool = definePageTool(() => ({
name: 'page_tool',
description: 'A page scoped tool',
@@ -102,7 +107,7 @@ describe('ToolHandler', () => {
handler: async () => {
handlerCalled = true;
},
}))(serverArgs);
}))(config.resolved);
const mockContext = sinon.createStubInstance(McpContext);
const mockProcess = sinon.createStubInstance(ChildProcess);
@@ -114,7 +119,7 @@ describe('ToolHandler', () => {
const toolHandler = new ToolHandler(
tool,
serverArgs,
config,
async () => mockContext,
toolMutex,
sinon.spy(),
@@ -150,13 +155,11 @@ describe('ToolHandler', () => {
mockContext.ensureBrowser.resolves(getMockBrowser({process: mockProcess}));
const toolMutex = new Mutex();
const serverArgs = new ConfigParser('1.0.0', ['node', 'script.js'], {
CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true',
}).parse();
const config = parseConfig();
const toolHandler = new ToolHandler(
tool,
serverArgs,
config,
async () => mockContext,
toolMutex,
sinon.spy(),
@@ -198,12 +201,9 @@ describe('ToolHandler', () => {
mockContext.ensureBrowser.resolves(
getMockBrowser({process: mockProcess}),
);
const serverArgs = new ConfigParser('1.0.0', ['node', 'script.js'], {
CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true',
}).parse();
return new ToolHandler(
tool,
serverArgs,
parseConfig(),
async () => mockContext,
toolMutex,
sinon.spy(),
@@ -387,15 +387,11 @@ describe('ToolHandler', () => {
const handleStub = sinon
.stub(McpResponse.prototype, 'handle')
.resolves({content: [], structuredContent: {}});
const serverArgs = new ConfigParser(
'1.0.0',
['node', 'script.js', ...argv],
{CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true'},
).parse();
const config = parseConfig(argv);
await new ToolHandler(
tool,
serverArgs,
config,
async () => mockContext,
new Mutex(),
sinon.spy(),
@@ -421,9 +417,7 @@ describe('ToolHandler', () => {
},
};
const serverArgs = new ConfigParser('1.0.0', ['node', 'script.js'], {
CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true',
}).parse();
const config = parseConfig(['--no-page-id-routing']);
const testCases: Array<{
tool: ToolDefinition | DefinedPageTool;
@@ -434,7 +428,7 @@ describe('ToolHandler', () => {
tool: definePageTool(() => ({
...baseTool,
name: 'page_tool',
}))(serverArgs),
}))(config.resolved),
devToolsData: {cdpBackendNodeId: 1},
pageUrl: 'http://localhost:9222/',
},
@@ -473,7 +467,7 @@ describe('ToolHandler', () => {
const toolHandler = new ToolHandler(
testCase.tool,
serverArgs,
config,
async () => mockContext,
toolMutex,
sinon.spy(),
@@ -515,13 +509,11 @@ describe('ToolHandler', () => {
const mockContext = sinon.createStubInstance(McpContext);
const toolMutex = new Mutex();
const serverArgs = new ConfigParser('1.0.0', ['node', 'script.js'], {
CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true',
}).parse();
const config = parseConfig();
const toolHandler = new ToolHandler(
tool,
serverArgs,
config,
async () => mockContext,
toolMutex,
sinon.spy(),
@@ -568,15 +560,11 @@ describe('ToolHandler', () => {
const mockContext = sinon.createStubInstance(McpContext);
const toolMutex = new Mutex();
const serverArgs = new ConfigParser(
'1.0.0',
['node', 'script.js', '--categoryEmulation=false'],
{CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true'},
).parse();
const config = parseConfig(['--categoryEmulation=false']);
const toolHandler = new ToolHandler(
tool,
serverArgs,
config,
async () => mockContext,
toolMutex,
sinon.spy(),
@@ -597,10 +585,8 @@ describe('ToolHandler', () => {
const mockContext = sinon.createStubInstance(McpContext);
const toolMutex = new Mutex();
const defaultServerArgs = new ConfigParser('1.0.0', ['node', 'script.js'], {
CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true',
}).parse();
const defaultTool = createTools(defaultServerArgs).find(
const defaultConfig = parseConfig();
const defaultTool = createTools(defaultConfig.resolved).find(
t => t.name === 'evaluate_script',
);
if (!defaultTool) {
@@ -608,19 +594,15 @@ describe('ToolHandler', () => {
}
const defaultHandler = new ToolHandler(
defaultTool,
defaultServerArgs,
defaultConfig,
async () => mockContext,
toolMutex,
sinon.spy(),
);
assert.strictEqual(defaultHandler.disabled, false);
const disabledServerArgs = new ConfigParser(
'1.0.0',
['node', 'script.js', '--no-javascript-evaluation'],
{CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true'},
).parse();
const disabledTool = createTools(disabledServerArgs).find(
const disabledConfig = parseConfig(['--no-javascript-evaluation']);
const disabledTool = createTools(disabledConfig.resolved).find(
t => t.name === 'evaluate_script',
);
if (!disabledTool) {
@@ -628,7 +610,7 @@ describe('ToolHandler', () => {
}
const disabledHandler = new ToolHandler(
disabledTool,
disabledServerArgs,
disabledConfig,
async () => mockContext,
toolMutex,
sinon.spy(),
@@ -644,12 +626,8 @@ describe('ToolHandler', () => {
/Tool evaluate_script requires flag --javascriptEvaluation and is currently disabled/,
);
const cliServerArgs = new ConfigParser(
'1.0.0',
['node', 'script.js', '--no-javascript-evaluation', '--viaCli'],
{CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true'},
).parse();
const cliTool = createTools(cliServerArgs).find(
const cliConfig = parseConfig(['--no-javascript-evaluation', '--viaCli']);
const cliTool = createTools(cliConfig.resolved).find(
t => t.name === 'evaluate_script',
);
if (!cliTool) {
@@ -657,7 +635,7 @@ describe('ToolHandler', () => {
}
const cliHandler = new ToolHandler(
cliTool,
cliServerArgs,
cliConfig,
async () => mockContext,
toolMutex,
sinon.spy(),
@@ -675,12 +653,8 @@ describe('ToolHandler', () => {
const mockContext = sinon.createStubInstance(McpContext);
const toolMutex = new Mutex();
const defaultServerArgs = new ConfigParser(
'1.0.0',
['node', 'script.js', '--slim'],
{CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true'},
).parse();
const defaultTool = createTools(defaultServerArgs).find(
const defaultConfig = parseConfig(['--slim']);
const defaultTool = createTools(defaultConfig.resolved).find(
t => t.name === 'evaluate',
);
if (!defaultTool) {
@@ -688,19 +662,18 @@ describe('ToolHandler', () => {
}
const defaultHandler = new ToolHandler(
defaultTool,
defaultServerArgs,
defaultConfig,
async () => mockContext,
toolMutex,
sinon.spy(),
);
assert.strictEqual(defaultHandler.disabled, false);
const disabledServerArgs = new ConfigParser(
'1.0.0',
['node', 'script.js', '--slim', '--javascriptEvaluation=false'],
{CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true'},
).parse();
const disabledTool = createTools(disabledServerArgs).find(
const disabledConfig = parseConfig([
'--slim',
'--javascriptEvaluation=false',
]);
const disabledTool = createTools(disabledConfig.resolved).find(
t => t.name === 'evaluate',
);
if (!disabledTool) {
@@ -708,7 +681,7 @@ describe('ToolHandler', () => {
}
const disabledHandler = new ToolHandler(
disabledTool,
disabledServerArgs,
disabledConfig,
async () => mockContext,
toolMutex,
sinon.spy(),
@@ -718,18 +691,14 @@ describe('ToolHandler', () => {
describe('slim mode', () => {
function createHandler(toolName: string, argv: string[]) {
const serverArgs = new ConfigParser(
'1.0.0',
['node', 'script.js', ...argv],
{CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true'},
).parse();
const tool = createTools(serverArgs).find(t => t.name === toolName);
const config = parseConfig(argv);
const tool = createTools(config.resolved).find(t => t.name === toolName);
if (!tool) {
assert.fail(`${toolName} not found`);
}
return new ToolHandler(
tool,
serverArgs,
config,
async () => sinon.createStubInstance(McpContext),
new Mutex(),
sinon.spy(),
@@ -814,13 +783,11 @@ describe('ToolHandler', () => {
});
const toolMutex = new Mutex();
const serverArgs = new ConfigParser('1.0.0', ['node', 'script.js'], {
CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true',
}).parse();
const config = parseConfig();
const toolHandler = new ToolHandler(
tool,
serverArgs,
config,
async () => mockContext,
toolMutex,
sinon.spy(),
@@ -891,13 +858,11 @@ describe('ToolHandler', () => {
);
const toolMutex = new Mutex();
const serverArgs = new ConfigParser('1.0.0', ['node', 'script.js'], {
CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true',
}).parse();
const config = parseConfig();
const toolHandler = new ToolHandler(
tool,
serverArgs,
config,
async () => mockContext,
toolMutex,
sinon.spy(),
@@ -916,10 +881,8 @@ describe('ToolHandler', () => {
});
it('validates evaluate_script sourcePath before reading the file', async () => {
const serverArgs = new ConfigParser('1.0.0', ['node', 'script.js'], {
CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true',
}).parse();
const tool = evaluateScript(serverArgs);
const config = parseConfig();
const tool = evaluateScript(config.resolved);
const mockContext = sinon.createStubInstance(McpContext);
const mockProcess = sinon.createStubInstance(ChildProcess);
mockContext.ensureBrowser.resolves(getMockBrowser({process: mockProcess}));
@@ -929,14 +892,14 @@ describe('ToolHandler', () => {
const toolHandler = new ToolHandler(
tool,
serverArgs,
config,
async () => mockContext,
new Mutex(),
sinon.spy(),
);
const sourcePath = path.resolve('/outside/workspace/script.js');
const result = await toolHandler.handle({sourcePath});
const result = await toolHandler.handle({sourcePath, pageId: 1});
assert.strictEqual(result.isError, true);
assert.match(
@@ -980,13 +943,11 @@ describe('ToolHandler', () => {
mockContext.validatePath.resolves(canonicalPath);
const toolMutex = new Mutex();
const serverArgs = new ConfigParser('1.0.0', ['node', 'script.js'], {
CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true',
}).parse();
const config = parseConfig();
const toolHandler = new ToolHandler(
tool,
serverArgs,
config,
async () => mockContext,
toolMutex,
sinon.spy(),
@@ -1040,13 +1001,11 @@ describe('ToolHandler', () => {
mockContext.validatePath.resolves(canonicalBundlePath);
const toolMutex = new Mutex();
const serverArgs = new ConfigParser('1.0.0', ['node', 'script.js'], {
CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true',
}).parse();
const config = parseConfig();
const toolHandler = new ToolHandler(
tool,
serverArgs,
config,
async () => mockContext,
toolMutex,
sinon.spy(),
@@ -1103,13 +1062,11 @@ describe('ToolHandler', () => {
);
const toolMutex = new Mutex();
const serverArgs = new ConfigParser('1.0.0', ['node', 'script.js'], {
CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true',
}).parse();
const config = parseConfig();
const toolHandler = new ToolHandler(
tool,
serverArgs,
config,
async () => mockContext,
toolMutex,
sinon.spy(),
@@ -1154,13 +1111,11 @@ describe('ToolHandler', () => {
mockContext.ensureBrowser.resolves(getMockBrowser());
const toolMutex = new Mutex();
const serverArgs = new ConfigParser('1.0.0', ['node', 'script.js'], {
CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true',
}).parse();
const config = parseConfig();
const toolHandler = new ToolHandler(
tool,
serverArgs,
config,
async () => mockContext,
toolMutex,
sinon.spy(),
@@ -1204,13 +1159,11 @@ describe('ToolHandler', () => {
mockContext.ensureBrowser.resolves(getMockBrowser({process: mockProcess}));
const toolMutex = new Mutex();
const serverArgs = new ConfigParser('1.0.0', ['node', 'script.js'], {
CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true',
}).parse();
const config = parseConfig();
const toolHandler = new ToolHandler(
tool,
serverArgs,
config,
async () => mockContext,
toolMutex,
sinon.spy(),
@@ -1255,13 +1208,11 @@ describe('ToolHandler', () => {
mockContext.ensureBrowser.resolves(getMockBrowser({process: mockProcess}));
const toolMutex = new Mutex();
const serverArgs = new ConfigParser('1.0.0', ['node', 'script.js'], {
CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true',
}).parse();
const config = parseConfig();
const toolHandler = new ToolHandler(
tool,
serverArgs,
config,
async () => mockContext,
toolMutex,
sinon.spy(),
@@ -1317,13 +1268,11 @@ describe('ToolHandler', () => {
mockContext.validatePath.resolves(canonicalOutputPath);
const toolMutex = new Mutex();
const serverArgs = new ConfigParser('1.0.0', ['node', 'script.js'], {
CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true',
}).parse();
const config = parseConfig();
const toolHandler = new ToolHandler(
tool,
serverArgs,
config,
async () => mockContext,
toolMutex,
sinon.spy(),
@@ -1379,13 +1328,11 @@ describe('ToolHandler', () => {
);
const toolMutex = new Mutex();
const serverArgs = new ConfigParser('1.0.0', ['node', 'script.js'], {
CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true',
}).parse();
const config = parseConfig();
const toolHandler = new ToolHandler(
tool,
serverArgs,
config,
async () => mockContext,
toolMutex,
sinon.spy(),
@@ -1427,9 +1374,7 @@ describe('ToolHandler', () => {
};
const toolMutex = new Mutex();
const serverArgs = new ConfigParser('1.0.0', ['node', 'script.js'], {
CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true',
}).parse();
const config = parseConfig();
// Remote browser: should validate
const mockRemoteContext = sinon.createStubInstance(McpContext);
@@ -1442,7 +1387,7 @@ describe('ToolHandler', () => {
const remoteToolHandler = new ToolHandler(
tool,
serverArgs,
config,
async () => mockRemoteContext,
toolMutex,
sinon.spy(),
@@ -1464,7 +1409,7 @@ describe('ToolHandler', () => {
const localToolHandler = new ToolHandler(
tool,
serverArgs,
config,
async () => mockLocalContext,
toolMutex,
sinon.spy(),
@@ -1476,9 +1421,7 @@ describe('ToolHandler', () => {
it('rewrites file paths in params for page scoped tools', async () => {
let receivedParams: Record<string, unknown> | undefined;
const serverArgs = new ConfigParser('1.0.0', ['node', 'script.js'], {
CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true',
}).parse();
const config = parseConfig(['--no-page-id-routing']);
const tool = definePageTool(() => ({
name: 'page_file_tool',
description: 'A page scoped tool with file verification',
@@ -1496,7 +1439,7 @@ describe('ToolHandler', () => {
handler: async request => {
receivedParams = request.params;
},
}))(serverArgs);
}))(config.resolved);
const mockContext = sinon.createStubInstance(McpContext);
const mockProcess = sinon.createStubInstance(ChildProcess);
@@ -1521,7 +1464,7 @@ describe('ToolHandler', () => {
const toolHandler = new ToolHandler(
tool,
serverArgs,
config,
async () => mockContext,
toolMutex,
sinon.spy(),
@@ -1567,13 +1510,11 @@ describe('ToolHandler', () => {
const mockContext = createMockMcpContext();
mockContext.ensureBrowser.resolves(getMockBrowser());
const serverArgs = new ConfigParser('1.0.0', ['node', 'script.js'], {
CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true',
}).parse();
const config = parseConfig();
const toolHandler = new ToolHandler(
tool,
serverArgs,
config,
async () => mockContext,
new Mutex(),
sinon.spy(),
@@ -1618,13 +1559,11 @@ describe('ToolHandler', () => {
mockContext.ensureBrowser.resolves(getMockBrowser({process: mockProcess}));
const toolMutex = new Mutex();
const serverArgs = new ConfigParser('1.0.0', ['node', 'script.js'], {
CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true',
}).parse();
const config = parseConfig();
const toolHandler = new ToolHandler(
tool,
serverArgs,
config,
async () => mockContext,
toolMutex,
sinon.spy(),
@@ -1672,13 +1611,11 @@ describe('ToolHandler', () => {
const abandonPendingBrowserAttemptSpy = sinon.spy();
const toolMutex = new Mutex();
const serverArgs = new ConfigParser('1.0.0', ['node', 'script.js'], {
CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true',
}).parse();
const config = parseConfig();
const toolHandler = new ToolHandler(
tool,
serverArgs,
config,
async () => mockContext,
toolMutex,
abandonPendingBrowserAttemptSpy,
@@ -1718,13 +1655,7 @@ describe('ToolHandler', () => {
blockedByDialog: false,
verifyFilesSchema: {filePath: true},
handler,
}))(createServerArgs());
}
function createServerArgs() {
return new ConfigParser('1.0.0', ['node', 'script.js'], {
CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true',
}).parse();
}))(parseConfig().resolved);
}
it('runs without establishing a browser connection', async () => {
@@ -1737,7 +1668,7 @@ describe('ToolHandler', () => {
const result = await new ToolHandler(
createBrowserlessTool(handler),
createServerArgs(),
parseConfig(),
async () => mockContext,
new Mutex(),
sinon.spy(),
@@ -1760,4 +1691,254 @@ describe('ToolHandler', () => {
sinon.assert.calledOnceWithExactly(handleStub, mockContext, 'default');
});
});
describe('update', () => {
function createGlobalTool(
schema: zod.ZodRawShape,
handler: ToolDefinition['handler'],
options: {
description?: string;
annotations?: ToolDefinition['annotations'];
} = {},
): ToolDefinition {
return {
name: 'global_tool',
description: options.description ?? 'A global tool',
annotations: options.annotations ?? {
category: ToolCategory.NAVIGATION,
readOnlyHint: true,
},
schema,
blockedByDialog: false,
verifyFilesSchema: {},
handler,
};
}
it('applies to calls that wait for the mutex', async () => {
const config = parseConfig();
const handler = sinon.stub().resolves();
const tool = createGlobalTool({}, handler);
const toolMutex = new Mutex();
const toolHandler = new ToolHandler(
tool,
config,
async () => createMockMcpContext(),
toolMutex,
sinon.spy(),
);
await toolMutex.acquire();
const call = toolHandler.handle({});
config.commit(parseConfig(['--no-category-navigation']));
toolHandler.update(tool);
toolMutex.release();
const result = await call;
assert.strictEqual(result.isError, true);
assert.match(
result.content[0]?.type === 'text' ? result.content[0].text : '',
/is currently disabled/,
);
sinon.assert.notCalled(handler);
});
it('validates waiting calls against the updated schema', async () => {
const config = parseConfig();
const handler = sinon.stub().resolves();
const toolMutex = new Mutex();
const toolHandler = new ToolHandler(
createGlobalTool({a: zod.string().optional()}, handler),
config,
async () => createMockMcpContext(),
toolMutex,
sinon.spy(),
);
await toolMutex.acquire();
const call = toolHandler.handle({a: 'value'});
const listingChanged = toolHandler.update(
createGlobalTool(
{a: zod.string().optional(), b: zod.string()},
handler,
),
);
toolMutex.release();
const result = await call;
assert.strictEqual(listingChanged, true);
assert.strictEqual(result.isError, true);
assert.match(
result.content[0]?.type === 'text' ? result.content[0].text : '',
/^Input validation error: Invalid arguments for tool global_tool: b:/,
);
sinon.assert.notCalled(handler);
});
it('runs waiting calls with the updated tool handler and schema', async () => {
const config = parseConfig();
const initialHandler = sinon.stub().resolves();
const updatedHandler = sinon.stub().resolves();
const toolMutex = new Mutex();
const mockContext = createMockMcpContext();
mockContext.ensureBrowser.resolves(getMockBrowser());
const toolHandler = new ToolHandler(
createGlobalTool({a: zod.string()}, initialHandler),
config,
async () => mockContext,
toolMutex,
sinon.spy(),
);
await toolMutex.acquire();
const call = toolHandler.handle({a: 'value', b: 'extra'});
const listingChanged = toolHandler.update(
createGlobalTool({a: zod.string(), b: zod.string()}, updatedHandler),
);
toolMutex.release();
const result = await call;
assert.strictEqual(listingChanged, true);
assert.strictEqual(result.isError, undefined);
sinon.assert.notCalled(initialHandler);
sinon.assert.calledOnceWithExactly(
updatedHandler,
{params: {a: 'value', b: 'extra'}},
sinon.match.instanceOf(McpResponse),
mockContext,
);
});
it('throws ProtocolError when a waiting MCP call becomes disabled', async () => {
const config = parseConfig();
const handler = sinon.stub().resolves();
const tool = createGlobalTool({}, handler);
const toolMutex = new Mutex();
const toolHandler = new ToolHandler(
tool,
config,
async () => createMockMcpContext(),
toolMutex,
sinon.spy(),
);
await toolMutex.acquire();
const call = toolHandler.handle({}, {viaMcp: true});
config.commit(parseConfig(['--no-category-navigation']));
toolHandler.update(tool);
toolMutex.release();
await assert.rejects(call, (error: unknown) => {
assert.ok(error instanceof ProtocolError);
assert.strictEqual(error.code, ProtocolErrorCode.InvalidParams);
assert.strictEqual(error.message, 'Tool global_tool disabled');
return true;
});
sinon.assert.notCalled(handler);
});
it('runs waiting calls when the schema is unchanged', async () => {
const config = parseConfig();
const initialHandler = sinon.stub().resolves();
const updatedHandler = sinon.stub().resolves();
const toolMutex = new Mutex();
const mockContext = createMockMcpContext();
mockContext.ensureBrowser.resolves(getMockBrowser());
const toolHandler = new ToolHandler(
createGlobalTool({a: zod.string().optional()}, initialHandler),
config,
async () => mockContext,
toolMutex,
sinon.spy(),
);
await toolMutex.acquire();
const call = toolHandler.handle({a: 'value'});
config.commit(parseConfig(['--redact-network-headers']));
const listingChanged = toolHandler.update(
createGlobalTool({a: zod.string().optional()}, updatedHandler),
);
toolMutex.release();
await call;
assert.strictEqual(listingChanged, false);
sinon.assert.notCalled(initialHandler);
sinon.assert.calledOnceWithExactly(
updatedHandler,
{params: {a: 'value'}},
sinon.match.instanceOf(McpResponse),
mockContext,
);
});
it('updates the disabled state and reports listing change', () => {
const config = parseConfig();
const tool = createGlobalTool({}, sinon.stub().resolves());
const toolHandler = new ToolHandler(
tool,
config,
async () => createMockMcpContext(),
new Mutex(),
sinon.spy(),
);
config.commit(parseConfig(['--no-category-navigation']));
const listingChanged = toolHandler.update(tool);
assert.strictEqual(listingChanged, true);
assert.strictEqual(toolHandler.disabled, true);
});
it('reports listing change when description or annotations change', () => {
const config = parseConfig();
const handler = sinon.stub().resolves();
const toolHandler = new ToolHandler(
createGlobalTool({}, handler),
config,
async () => createMockMcpContext(),
new Mutex(),
sinon.spy(),
);
assert.strictEqual(
toolHandler.update(
createGlobalTool({}, handler, {description: 'Updated description'}),
),
true,
);
assert.strictEqual(
toolHandler.update(
createGlobalTool({}, handler, {
description: 'Updated description',
annotations: {
category: ToolCategory.NAVIGATION,
readOnlyHint: false,
},
}),
),
true,
);
});
it('does not report listing change when a disabled tool remains disabled', () => {
const config = parseConfig(['--no-category-navigation']);
const handler = sinon.stub().resolves();
const toolHandler = new ToolHandler(
createGlobalTool({a: zod.string().optional()}, handler),
config,
async () => createMockMcpContext(),
new Mutex(),
sinon.spy(),
);
const listingChanged = toolHandler.update(
createGlobalTool({a: zod.string(), b: zod.number()}, handler, {
description: 'Updated description',
}),
);
assert.strictEqual(listingChanged, false);
assert.strictEqual(toolHandler.disabled, true);
});
});
});