mirror of
https://github.com/ChromeDevTools/chrome-devtools-mcp.git
synced 2026-11-04 05:02:31 +00:00
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:
1 parent
a65f09e9da
commit
8064138482
4 files changed
+593
-321
No files matched your search
+222
-139
@@ -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
@@ -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) {
|
||||
|
||||
Vendored
+6
-1
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user