diff --git a/docs/configuration.md b/docs/configuration.md index 6558bec8..5fcbd4b3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -156,6 +156,19 @@ The Chrome DevTools MCP server supports the following configuration option: - **Type:** boolean - **Default:** `true` +- **`--packPolicy`/ `--pack-policy`** + Comma-separated pack=policy pairs, e.g. performance=enabled,memory=locked + - **Type:** string + +- **`--toolPolicy`/ `--tool-policy`** + Comma-separated tool=policy pairs, e.g. take_heapsnapshot=enabled + - **Type:** string + +- **`--metaFeatureSystem`/ `--meta-feature-system`** + Enable the slimmed core startup where all non-core Domain Packs default to on-demand + - **Type:** boolean + - **Default:** `false` + - **`--experimentalDevtools`/ `--experimental-devtools`** Whether to enable automation over DevTools targets. This exposes the DevTools UI to input tools, including the Console, which can evaluate JavaScript in the inspected page. --no-javascript-evaluation does not restrict this path. - **Type:** boolean diff --git a/docs/tool-reference.md b/docs/tool-reference.md index cce1ce3e..78924be6 100644 --- a/docs/tool-reference.md +++ b/docs/tool-reference.md @@ -30,7 +30,7 @@ - **[Network](#network)** (2 tools) - [`get_network_request`](#get_network_request) - [`list_network_requests`](#list_network_requests) -- **[Debugging](#debugging)** (9 tools) +- **[Debugging](#debugging)** (10 tools) - [`evaluate_script`](#evaluate_script) - [`get_console_message`](#get_console_message) - [`get_css_styles`](#get_css_styles) @@ -40,6 +40,7 @@ - [`screencast_stop`](#screencast_stop) - [`take_screenshot`](#take_screenshot) - [`take_snapshot`](#take_snapshot) + - [`manage_capabilities`](#manage_capabilities) - **[Memory](#memory)** (14 tools) - [`take_heapsnapshot`](#take_heapsnapshot) - [`analyze_heapsnapshot_contexts`](#analyze_heapsnapshot_contexts) @@ -504,6 +505,20 @@ in the DevTools Elements panel (if any). --- +### `manage_capabilities` + +**Description:** Manage on-demand capability packs. list, enable, disable, or call fallback. (requires flag: --metaFeatureSystem=true) + +**Parameters:** + +- **action** (enum: "list", "enable", "disable", "call") **(required)**: Action to perform. +- **capabilities** (array) _(optional)_: Multiple capability pack IDs to enable/disable. +- **capability** (string) _(optional)_: Single capability pack ID to enable/disable (e.g. memory, performance, audits, screencast, pwa, webmcp; locked by default: extensions). +- **tool** (string) _(optional)_: Tool name to call (only for action: "call"). +- **toolArgs** (object) _(optional)_: Arguments for the tool (only for action: "call"). + +--- + ## Memory ### `take_heapsnapshot` diff --git a/scripts/eval_gemini.ts b/scripts/eval_gemini.ts index 3ad738b6..82ed25ed 100644 --- a/scripts/eval_gemini.ts +++ b/scripts/eval_gemini.ts @@ -16,7 +16,7 @@ import {StdioClientTransport} from '@modelcontextprotocol/client/stdio'; import {TestServer} from '../build/tests/server.js'; import type {CapturedFunctionCall, TestScenario} from './eval_result.js'; -import {Result} from './eval_result.js'; +import {hasMetaFeatureSystemFlag, Result} from './eval_result.js'; const ROOT_DIR = path.resolve(import.meta.dirname, '..'); const SCENARIOS_DIR = path.join(import.meta.dirname, 'eval_scenarios'); @@ -127,7 +127,7 @@ async function runSingleScenario( client = new Client( {name: 'gemini-eval-client', version: '1.0.0'}, - {capabilities: {}}, + {capabilities: {tools: {listChanged: true}}}, ); await client.connect(transport); @@ -140,11 +140,12 @@ async function runSingleScenario( debugLog( `Executing tool: ${request.name} with args: ${JSON.stringify(request.arguments)}`, ); + const response = await originalCallTool(request, schema); allCalls.push({ name: request.name, args: (request.arguments as Record) || {}, + isError: Boolean(response.isError), }); - const response = await originalCallTool(request, schema); debugLog(`Tool response: ${JSON.stringify(response)}`); return response; }; @@ -153,21 +154,70 @@ async function runSingleScenario( debugLog(`\n--- Prompt ---\n${scenario.prompt}`); - const result = await ai.models.generateContent({ - model: modelId, - contents: scenario.prompt, - config: { - tools: [mcpToTool(client)], - automaticFunctionCalling: { - maximumRemoteCalls: scenario.maxTurns, + let finalResponseText = ''; + if (hasMetaFeatureSystemFlag(args)) { + const contents: Array> = [ + {role: 'user', parts: [{text: scenario.prompt}]}, + ]; + const maxDynamicTurns = Math.max(scenario.maxTurns + 3, 6); + for (let turn = 0; turn < maxDynamicTurns; turn++) { + const listed = await client.listTools(); + const functionDeclarations = listed.tools.map(t => ({ + name: t.name, + description: t.description ?? '', + parametersJsonSchema: t.inputSchema, + })); + const res = await ai.models.generateContent({ + model: modelId, + contents: contents as never, + config: { + tools: [{functionDeclarations: functionDeclarations as never}], + }, + }); + const candidateContent = res.candidates?.[0]?.content; + if (candidateContent) { + contents.push(candidateContent as unknown as Record); + } + const fnCalls = res.functionCalls ?? []; + if (fnCalls.length === 0) { + finalResponseText = res.text ?? ''; + break; + } + const responseParts: Array> = []; + for (const fc of fnCalls) { + const toolName = fc.name ?? ''; + const toolArgs = (fc.args as Record) ?? {}; + const toolRes = await client.callTool({ + name: toolName, + arguments: toolArgs, + }); + responseParts.push({ + functionResponse: { + name: toolName, + response: {result: toolRes}, + }, + }); + } + contents.push({role: 'user', parts: responseParts}); + } + } else { + const result = await ai.models.generateContent({ + model: modelId, + contents: scenario.prompt, + config: { + tools: [mcpToTool(client)], + automaticFunctionCalling: { + maximumRemoteCalls: scenario.maxTurns, + }, }, - }, - }); + }); + finalResponseText = result.text ?? ''; + } - debugLog(`\n--- Response ---\n${result.text}`); + debugLog(`\n--- Response ---\n${finalResponseText}`); debugLog('\nVerifying expectations...'); - scenario.expectations(new Result(allCalls, args, result.text ?? '')); + scenario.expectations(new Result(allCalls, args, finalResponseText)); } finally { try { await client?.close(); @@ -204,6 +254,10 @@ async function main() { 'skill-path': { type: 'string', }, + 'meta-feature-system': { + type: 'boolean', + default: false, + }, 'server-args': { type: 'string', }, @@ -219,8 +273,14 @@ async function main() { ? path.resolve(ROOT_DIR, values['skill-path']) : SKILL_PATH; const extraServerArgs = values['server-args'] - ? values['server-args'].split(/\s+/) + ? values['server-args'].split(/\s+/).filter(Boolean) : []; + if ( + values['meta-feature-system'] && + !hasMetaFeatureSystemFlag(extraServerArgs) + ) { + extraServerArgs.push('--meta-feature-system'); + } const scenarioFiles = positionals.length > 0 diff --git a/scripts/eval_result.ts b/scripts/eval_result.ts index dbd4a38a..8580484d 100644 --- a/scripts/eval_result.ts +++ b/scripts/eval_result.ts @@ -9,21 +9,84 @@ import assert from 'node:assert'; export interface CapturedFunctionCall { name: string; args: Record; + isError?: boolean; +} + +export function hasMetaFeatureSystemFlag(serverArgs: string[]): boolean { + return serverArgs.some( + arg => + arg === '--meta-feature-system' || + arg === '--meta-feature-system=true' || + arg === '--metaFeatureSystem' || + arg === '--metaFeatureSystem=true', + ); } export class Result { private nextCallIndex = 0; readonly calls: CapturedFunctionCall[]; + readonly rawCalls: CapturedFunctionCall[]; readonly serverArgs: string[]; /** The model's final response text. */ readonly text: string; constructor(calls: CapturedFunctionCall[], serverArgs: string[], text = '') { - this.calls = calls; + this.rawCalls = calls; + this.calls = calls.flatMap(call => { + if (call.isError) { + return []; + } + if (call.name !== 'manage_capabilities') { + return [call]; + } + if (call.args.action === 'call' && typeof call.args.tool === 'string') { + return [ + { + name: call.args.tool, + args: + (call.args.toolArgs as Record | undefined) ?? {}, + }, + ]; + } + return []; + }); this.serverArgs = serverArgs; this.text = text; } + get hasMetaFeatureSystem(): boolean { + return hasMetaFeatureSystemFlag(this.serverArgs); + } + + get capabilityCalls(): CapturedFunctionCall[] { + return this.rawCalls.filter(c => c.name === 'manage_capabilities'); + } + + assertCapabilityEnabled(packId: string): void { + if (!this.hasMetaFeatureSystem) { + return; + } + const enabled = this.capabilityCalls.some(c => { + if (c.args.action !== 'enable') { + return false; + } + if (c.args.capability === packId) { + return true; + } + if ( + Array.isArray(c.args.capabilities) && + c.args.capabilities.includes(packId) + ) { + return true; + } + return false; + }); + assert.ok( + enabled, + `Expected capability pack '${packId}' to be enabled via manage_capabilities, got capability calls: ${JSON.stringify(this.capabilityCalls)}`, + ); + } + get hasPageIdRouting(): boolean { return !this.serverArgs.includes('--no-page-id-routing'); } @@ -40,7 +103,7 @@ export class Result { * - Returns the active pageId. */ consumePageNavigation(): number | undefined { - if (this.calls[this.nextCallIndex]?.name === 'list_pages') { + while (this.calls[this.nextCallIndex]?.name === 'list_pages') { this.nextCallIndex++; } @@ -52,7 +115,7 @@ export class Result { ); this.nextCallIndex++; - if (this.calls[this.nextCallIndex]?.name === 'list_pages') { + while (this.calls[this.nextCallIndex]?.name === 'list_pages') { this.nextCallIndex++; } diff --git a/scripts/eval_scenarios/lighthouse_a11y_test.ts b/scripts/eval_scenarios/lighthouse_a11y_test.ts index 457192fa..8f1ea976 100644 --- a/scripts/eval_scenarios/lighthouse_a11y_test.ts +++ b/scripts/eval_scenarios/lighthouse_a11y_test.ts @@ -27,6 +27,7 @@ export const scenario: TestScenario = { `, }, expectations: result => { + result.assertCapabilityEnabled('audits'); const pageId = result.consumePageNavigation(); assert.ok(result.remainingCalls.length >= 1); result.assertNextCall( diff --git a/scripts/eval_scenarios/lighthouse_best_practices_test.ts b/scripts/eval_scenarios/lighthouse_best_practices_test.ts index 40d4dd11..fb0fb97a 100644 --- a/scripts/eval_scenarios/lighthouse_best_practices_test.ts +++ b/scripts/eval_scenarios/lighthouse_best_practices_test.ts @@ -27,6 +27,7 @@ export const scenario: TestScenario = { `, }, expectations: result => { + result.assertCapabilityEnabled('audits'); const pageId = result.consumePageNavigation(); assert.ok(result.remainingCalls.length >= 1); result.assertNextCall( diff --git a/scripts/eval_scenarios/memory_heapsnapshot_test.ts b/scripts/eval_scenarios/memory_heapsnapshot_test.ts new file mode 100644 index 00000000..a2c2c61f --- /dev/null +++ b/scripts/eval_scenarios/memory_heapsnapshot_test.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; +import os from 'node:os'; +import path from 'node:path'; + +import type {TestScenario} from '../eval_gemini.ts'; + +const heapSnapshotPath = path.join(os.tmpdir(), 'eval-heap.heapsnapshot'); + +export const scenario: TestScenario = { + prompt: `Navigate to , take a V8 heap snapshot to ${heapSnapshotPath}, and inspect the heap snapshot summary to report the total size and top constructors.`, + maxTurns: 5, + serverArgs: ['--allow-unrestricted-paths', '--memory-debugging'], + htmlRoute: { + path: '/memory_leak_test.html', + htmlContent: ` + + + Memory Leak Test + +

Checkout Cart Memory Test

+ + + + `, + }, + expectations: result => { + result.assertCapabilityEnabled('memory'); + result.consumePageNavigation(); + const snapshotCall = result.calls.find(c => c.name === 'take_heapsnapshot'); + assert.ok(snapshotCall, 'Expected take_heapsnapshot to be called'); + const summaryCall = result.calls.find( + c => + c.name === 'get_heapsnapshot_summary' || + c.name === 'get_heapsnapshot_details' || + c.name === 'get_heapsnapshot_class_nodes', + ); + assert.ok( + summaryCall, + 'Expected a heap snapshot inspection tool (get_heapsnapshot_summary/details/class_nodes) to be called', + ); + }, +}; diff --git a/scripts/eval_scenarios/performance_and_policy_test.ts b/scripts/eval_scenarios/performance_and_policy_test.ts new file mode 100644 index 00000000..863e7ee8 --- /dev/null +++ b/scripts/eval_scenarios/performance_and_policy_test.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; + +import type {TestScenario} from '../eval_gemini.ts'; + +export const scenario: TestScenario = { + prompt: + 'Navigate to , record a performance trace (with reload: true and autoStop: true) to check its performance, and also attempt to list installed Chrome extensions using list_extensions (if extensions are restricted or disabled by policy, report that clearly).', + maxTurns: 6, + htmlRoute: { + path: '/perf_and_policy_test.html', + htmlContent: ` + + + Performance & Policy Test + +

Dashboard Performance Test

+ + + + `, + }, + expectations: result => { + result.assertCapabilityEnabled('performance'); + result.consumePageNavigation(); + const perfCall = result.calls.find( + c => c.name === 'performance_start_trace', + ); + assert.ok(perfCall, 'Expected performance_start_trace to be called'); + if (result.hasMetaFeatureSystem) { + const extExecuted = result.calls.some(c => c.name === 'list_extensions'); + assert.strictEqual( + extExecuted, + false, + 'Expected list_extensions (extensions pack) to be blocked by locked security policy when --meta-feature-system is enabled', + ); + } + }, +}; diff --git a/scripts/eval_scenarios/performance_test.ts b/scripts/eval_scenarios/performance_test.ts index 6652e759..7b867406 100644 --- a/scripts/eval_scenarios/performance_test.ts +++ b/scripts/eval_scenarios/performance_test.ts @@ -12,6 +12,7 @@ export const scenario: TestScenario = { prompt: 'Check the performance of https://developers.chrome.com', maxTurns: 3, expectations: result => { + result.assertCapabilityEnabled('performance'); const pageId = result.consumePageNavigation(); assert.ok(result.remainingCalls.length >= 1); result.assertNextCall( diff --git a/scripts/generate-cli.ts b/scripts/generate-cli.ts index 367ad99c..b37246d3 100644 --- a/scripts/generate-cli.ts +++ b/scripts/generate-cli.ts @@ -99,6 +99,11 @@ async function generateCli() { if (tool.name === 'wait_for') { return false; } + // Skipping manage_capabilities because capability packs are for MCP sessions + // and CLI does not handle array/JSON args well. + if (tool.name === 'manage_capabilities') { + return false; + } // Skipping get_tab_id as it is for internal integrations if (tool.name === 'get_tab_id') { return false; diff --git a/src/McpContext.ts b/src/McpContext.ts index 6f1a9edb..6a076a9c 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -52,7 +52,9 @@ import {McpWorker} from './McpWorker.js'; import {getTempFilePath, resolveCanonicalPath} from './utils/files.js'; import {escapeForLog} from './utils/logger.js'; import {isAllowedUrl} from './utils/url.js'; +import type {McpServer} from './index.js'; interface McpContextOptions { + mcpServer?: McpServer; // Whether the DevTools windows are exposed as pages for debugging of DevTools. experimentalDevToolsDebugging: boolean; // Whether all page-like targets are exposed as pages. @@ -90,6 +92,7 @@ interface McpContextOptions { let nextPageId = 1; export class McpContext implements Context { + mcpServer?: McpServer; browser: Browser; logger: Logger; @@ -132,6 +135,7 @@ export class McpContext implements Context { }, }); + this.mcpServer = options.mcpServer; this.browser = browser; this.logger = logger; this.#locatorClass = locatorClass; diff --git a/src/McpResponse.ts b/src/McpResponse.ts index 59fbacdf..49f268d6 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -620,9 +620,16 @@ export class McpResponse implements Response { return undefined; } - async #handleThirdPartyDevelopeTools(): Promise { + async #handleThirdPartyDevelopeTools( + context: McpContext, + ): Promise { if ( - this.#args.categoryExperimentalThirdParty && + (this.#args.categoryExperimentalThirdParty || + Boolean( + context?.mcpServer?.capabilityManager?.isToolActive( + 'list_3p_developer_tools', + ), + )) && this.#listThirdPartyDeveloperTools && this.#page ) { @@ -631,9 +638,14 @@ export class McpResponse implements Response { return undefined; } - async #handleWebMCP(): Promise { + async #handleWebMCP(context: McpContext): Promise { if ( - this.#args.categoryExperimentalWebmcp && + (this.#args.categoryExperimentalWebmcp || + Boolean( + context?.mcpServer?.capabilityManager?.isToolActive( + 'list_webmcp_tools', + ), + )) && this.#listWebMcpTools && this.#page ) { @@ -803,8 +815,8 @@ export class McpResponse implements Response { this.#handleSnapshot(context), this.#handleAttachedNetworkRequest(context), this.#handleAttachedConsoleMessage(), - this.#handleThirdPartyDevelopeTools(), - this.#handleWebMCP(), + this.#handleThirdPartyDevelopeTools(context), + this.#handleWebMCP(context), this.#handleConsoleList(context), this.#handleNetworkRequestList(context), this.#handleComments(), diff --git a/src/ToolHandler.ts b/src/ToolHandler.ts index 6f89e617..c3cd1ea5 100644 --- a/src/ToolHandler.ts +++ b/src/ToolHandler.ts @@ -5,6 +5,7 @@ */ import type {ParsedArguments} from './config/ConfigParser.js'; +import type {McpServer} from './index.js'; import type {McpContext} from './McpContext.js'; import type {McpPage} from './McpPage.js'; import {McpResponse} from './McpResponse.js'; @@ -13,7 +14,10 @@ import {ClearcutLogger} from './telemetry/ClearcutLogger.js'; import type {CallToolResult, Mutex} from './third_party/index.js'; import {zod} from './third_party/index.js'; import {labels} from './tools/categories.js'; -import {categoryToFlagName} from './config/category-options.js'; +import { + categoryToFlagName, + isCategoryOffByDefault, +} from './config/category-options.js'; import type { DefinedPageTool, DevToolsData, @@ -57,6 +61,7 @@ function buildDisabledMessage( function getToolStatusInfo( tool: ToolDefinition | DefinedPageTool, serverArgs: ParsedArguments, + mcpServer?: McpServer, ): {disabled: boolean; reason?: string; unavailableInMode?: boolean} { if (!isAvailableInMode(tool, serverArgs)) { return { @@ -68,10 +73,43 @@ function getToolStatusInfo( }; } + const isMetaEnabled = Boolean( + mcpServer?.capabilityManager?.isMetaFeatureSystemEnabled, + ); + if (mcpServer) { + if (isMetaEnabled && tool.name === 'manage_capabilities') { + return {disabled: false}; + } + const policy = mcpServer.capabilityManager.getToolPolicy(tool.name); + if (policy === 'locked') { + return { + disabled: true, + reason: `Tool '${tool.name}' is locked by security policy and cannot be invoked or enabled.`, + }; + } + if (!mcpServer.capabilityManager.isToolActive(tool.name)) { + return { + disabled: true, + reason: `Tool '${tool.name}' is in an on-demand capability pack and is not currently active. Enable its pack via manage_capabilities first.`, + }; + } + } + + const isDynamicallyActive = + isMetaEnabled && + (mcpServer?.capabilityManager?.isToolActive(tool.name) ?? false); + const category = tool.annotations.category; if (category) { const flag = categoryToFlagName(category); - if (!serverArgs[flag]) { + if ( + !serverArgs[flag] && + !( + isDynamicallyActive && + isCategoryOffByDefault(category) && + serverArgs[flag] !== false + ) + ) { return { disabled: true, reason: buildDisabledMessage(tool.name, `--${flag}`, labels[category]), @@ -80,7 +118,11 @@ function getToolStatusInfo( } for (const condition of tool.annotations.conditions || []) { - if (!serverArgs[condition]) { + // Allow bypassing memoryDebugging via dynamic activation + if ( + !serverArgs[condition] && + !(isDynamicallyActive && condition === 'memoryDebugging') + ) { return { disabled: true, reason: buildDisabledMessage(tool.name, `--${condition}`), @@ -150,6 +192,9 @@ async function validateToolFiles( params: Record, context: McpContext, ): Promise { + if (Object.keys(tool.verifyFilesSchema).length === 0) { + return; + } const isLocal = isLocalBrowser(context); for (const [key, option] of Object.entries(tool.verifyFilesSchema)) { if (shouldValidateFile(option, isLocal)) { @@ -176,30 +221,42 @@ async function validateToolFiles( } } +function createCapabilitiesContext(mcpServer?: McpServer): McpContext { + return { + mcpServer, + getDevToolsData: async () => undefined, + getSelectedMcpPageUrl: () => undefined, + } as unknown as McpContext; +} + export class ToolHandler { readonly inputSchema: zod.ZodRawShape; readonly registeredInputSchema: zod.ZodObject< zod.ZodRawShape, zod.core.$strict >; - readonly disabled: boolean; - private readonly disabledReason?: string; + + get disabled(): boolean { + const {disabled, unavailableInMode} = getToolStatusInfo( + this.tool, + this.serverArgs, + this.mcpServer, + ); + return disabled && (Boolean(unavailableInMode) || !this.serverArgs.viaCli); + } + + private get disabledReason(): string | undefined { + return getToolStatusInfo(this.tool, this.serverArgs, this.mcpServer).reason; + } constructor( - private readonly tool: ToolDefinition | DefinedPageTool, + readonly tool: ToolDefinition | DefinedPageTool, private readonly serverArgs: ParsedArguments, private readonly getContext: () => Promise, private readonly toolMutex: Mutex, private readonly abandonPendingBrowserAttemptOnTimeout: () => void, + private readonly mcpServer?: McpServer, ) { - const {disabled, reason, unavailableInMode} = getToolStatusInfo( - tool, - serverArgs, - ); - this.disabledReason = reason; - this.disabled = - disabled && (Boolean(unavailableInMode) || !serverArgs.viaCli); - this.inputSchema = tool.schema; this.registeredInputSchema = zod.object(this.inputSchema).strict(); } @@ -236,7 +293,12 @@ export class ToolHandler { handle = async (params: Record): Promise => { using guard = await this.toolMutex.acquire(); + return await this.handleUnlocked(params); + }; + handleUnlocked = async ( + params: Record, + ): Promise => { if (this.disabledReason) { return { content: [ @@ -263,16 +325,22 @@ export class ToolHandler { // discard that attempt if it succeeds later instead of handing it to a // subsequent caller — see BrowserManager#abandonPendingAttempt()'s doc // comment for the full mechanism. - const context = await this.#raceWithTimeout(this.getContext(), () => - this.abandonPendingBrowserAttemptOnTimeout(), - ); + const context = + this.tool.name === 'manage_capabilities' + ? createCapabilitiesContext(this.mcpServer) + : await this.#raceWithTimeout(this.getContext(), () => + this.abandonPendingBrowserAttemptOnTimeout(), + ); logger?.(`${this.tool.name} context: resolved`); const response = this.serverArgs.slim ? new SlimMcpResponse(this.serverArgs) : new McpResponse(this.serverArgs); response.setRedactNetworkHeaders(this.serverArgs.redactNetworkHeaders); - if (context.consumeReconnectNotice()) { + if ( + this.tool.name !== 'manage_capabilities' && + context.consumeReconnectNotice() + ) { response.setReconnectNotice(); } let page: McpPage | undefined; @@ -281,21 +349,22 @@ export class ToolHandler { if (isPageScopedTool(this.tool)) { const pageId = typeof params.pageId === 'number' ? params.pageId : undefined; - page = + const resolvedPage = this.serverArgs.pageIdRouting && pageId !== undefined && !isSlimTool(this.tool) ? context.getPageById(pageId) : context.getSelectedMcpPage(); - await page?.init(); - response.setPage(page); + page = resolvedPage; + await resolvedPage?.init(); + response.setPage(resolvedPage); if (this.tool.blockedByDialog) { - page.throwIfDialogOpen(); + resolvedPage.throwIfDialogOpen(); } await this.tool.handler( { params, - page, + page: resolvedPage, }, response, context, diff --git a/src/capabilities/CapabilityManager.ts b/src/capabilities/CapabilityManager.ts new file mode 100644 index 00000000..41e8386e --- /dev/null +++ b/src/capabilities/CapabilityManager.ts @@ -0,0 +1,179 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {CORE_PACK_ID, DOMAIN_PACKS, type PolicyTier} from './DomainPacks.js'; + +export interface CapabilityManagerOptions { + metaFeatureSystem?: boolean; + packPolicy?: string; // e.g., 'performance=enabled,memory=locked' + toolPolicy?: string; // e.g., 'take_heapsnapshot=enabled' +} + +export class CapabilityManager { + #metaFeatureSystem = false; + get isMetaFeatureSystemEnabled(): boolean { + return this.#metaFeatureSystem; + } + #packPolicies = new Map(); + #toolPolicies = new Map(); + #activePacks = new Set(); + #explicitlyEnabledPacks = new Set(); + + constructor(options: CapabilityManagerOptions = {}) { + this.reconfigure(options); + } + + reconfigure(options: CapabilityManagerOptions): void { + this.#metaFeatureSystem = options.metaFeatureSystem ?? false; + this.#packPolicies.clear(); + this.#toolPolicies.clear(); + this.#activePacks.clear(); + this.#explicitlyEnabledPacks.clear(); + + if (options.packPolicy) { + for (const pair of options.packPolicy.split(',')) { + const [pack, policy] = pair.split('='); + if ( + pack && + policy && + ['enabled', 'on-demand', 'locked'].includes(policy.trim()) + ) { + this.#packPolicies.set(pack.trim(), policy.trim() as PolicyTier); + } + } + } + + if (options.toolPolicy) { + for (const pair of options.toolPolicy.split(',')) { + const [tool, policy] = pair.split('='); + if ( + tool && + policy && + ['enabled', 'on-demand', 'locked'].includes(policy.trim()) + ) { + this.#toolPolicies.set(tool.trim(), policy.trim() as PolicyTier); + } + } + } + + this.#initializeActivePacks(); + } + + #initializeActivePacks(): void { + for (const pack of DOMAIN_PACKS) { + const policy = this.getPackPolicy(pack.id); + if (policy === 'enabled') { + this.#activePacks.add(pack.id); + } + } + } + + getPackPolicy(packId: string): PolicyTier { + const configured = this.#packPolicies.get(packId); + if (configured !== undefined) { + return configured; + } + const pack = DOMAIN_PACKS.find(p => p.id === packId); + if (!pack) { + return 'locked'; + } + + if (!this.#metaFeatureSystem) { + // Legacy behavior: when --meta-feature-system is not enabled, all packs remain enabled unless overridden by --pack-policy + return 'enabled'; + } + + return pack.defaultPolicy; + } + + getToolPolicy(toolName: string): PolicyTier { + const configured = this.#toolPolicies.get(toolName); + if (configured !== undefined) { + return configured; + } + + for (const pack of DOMAIN_PACKS) { + if (pack.tools.includes(toolName)) { + return this.getPackPolicy(pack.id); + } + } + + return 'enabled'; // Default for tools not managed by packs + } + + isToolActive(toolName: string): boolean { + const policy = this.getToolPolicy(toolName); + if (policy === 'locked') { + return false; + } + if (policy === 'enabled') { + return true; + } + + // Policy is on-demand + for (const pack of DOMAIN_PACKS) { + if (pack.tools.includes(toolName)) { + if ( + this.#toolPolicies.get(toolName) === 'on-demand' && + this.getPackPolicy(pack.id) === 'enabled' + ) { + return this.#explicitlyEnabledPacks.has(pack.id); + } + return this.#activePacks.has(pack.id); + } + } + + return this.#explicitlyEnabledPacks.has(CORE_PACK_ID); + } + + enablePack(packId: string): { + success: boolean; + error?: string; + microSkill?: string; + } { + const policy = this.getPackPolicy(packId); + if (policy === 'locked') { + return { + success: false, + error: `Capability '${packId}' is locked by policy and cannot be enabled.`, + }; + } + + const pack = DOMAIN_PACKS.find(p => p.id === packId); + if (!pack) { + return {success: false, error: `Unknown capability '${packId}'.`}; + } + + this.#activePacks.add(packId); + this.#explicitlyEnabledPacks.add(packId); + return {success: true, microSkill: pack.microSkill}; + } + + disablePack(packId: string): {success: boolean; error?: string} { + const policy = this.getPackPolicy(packId); + if (policy === 'locked') { + return { + success: false, + error: `Capability '${packId}' is locked by policy.`, + }; + } + + if (policy === 'enabled') { + return { + success: false, + error: `Capability '${packId}' is permanently enabled by policy and cannot be disabled.`, + }; + } + + this.#activePacks.delete(packId); + this.#explicitlyEnabledPacks.delete(packId); + return {success: true}; + } + + getActivePacks(): string[] { + return Array.from(this.#activePacks); + } +} diff --git a/src/capabilities/DomainPacks.ts b/src/capabilities/DomainPacks.ts new file mode 100644 index 00000000..044f0943 --- /dev/null +++ b/src/capabilities/DomainPacks.ts @@ -0,0 +1,139 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export type PolicyTier = 'enabled' | 'on-demand' | 'locked'; + +export interface DomainPack { + id: string; + defaultPolicy: PolicyTier; + tools: string[]; + summary: string; + microSkill: string; +} + +export const CORE_PACK_ID = 'core'; + +export const DOMAIN_PACKS: DomainPack[] = [ + { + id: CORE_PACK_ID, + defaultPolicy: 'enabled', + tools: [ + 'navigate_page', + 'close_page', + 'new_page', + 'list_pages', + 'select_page', + 'wait_for', + 'take_snapshot', + 'take_screenshot', + 'click', + 'fill', + 'fill_form', + 'type_text', + 'press_key', + 'hover', + 'drag', + 'upload_file', + 'handle_dialog', + 'resize_page', + 'emulate', + 'evaluate_script', + 'get_console_message', + 'list_console_messages', + 'get_network_request', + 'list_network_requests', + 'get_css_styles', + ], + summary: 'Core tools for navigation, inspection, and interaction.', + microSkill: 'Core tools for navigation, inspection, and interaction.', + }, + { + id: 'memory', + defaultPolicy: 'on-demand', + tools: [ + 'take_heapsnapshot', + 'get_heapsnapshot_summary', + 'get_heapsnapshot_details', + 'get_heapsnapshot_class_nodes', + 'get_heapsnapshot_object_details', + 'get_heapsnapshot_edges', + 'get_heapsnapshot_retainers', + 'get_heapsnapshot_retaining_paths', + 'get_heapsnapshot_dominators', + 'get_heapsnapshot_duplicate_strings', + 'analyze_heapsnapshot_contexts', + 'compare_heapsnapshots', + 'query_heapsnapshot_objects', + 'close_heapsnapshot', + ], + summary: 'Heap snapshots and memory leak debugging.', + microSkill: + 'Enable this pack to take and inspect heap snapshots. If your harness caches tools at startup, invoke these tools via `manage_capabilities` action: "call". Disable when finished to free resources.', + }, + { + id: 'performance', + defaultPolicy: 'on-demand', + tools: [ + 'performance_start_trace', + 'performance_stop_trace', + 'performance_analyze_insight', + ], + summary: 'Performance traces and CrUX insights.', + microSkill: + 'Enable this pack to record and analyze performance traces. If your harness caches tools at startup, invoke these tools via `manage_capabilities` action: "call". Disable when finished.', + }, + { + id: 'audits', + defaultPolicy: 'on-demand', + tools: ['lighthouse_audit'], + summary: 'Lighthouse audits for accessibility, SEO, and best practices.', + microSkill: + 'Enable this pack to run Lighthouse audits. If your harness caches tools at startup, invoke these tools via `manage_capabilities` action: "call". Disable when finished.', + }, + { + id: 'screencast', + defaultPolicy: 'on-demand', + tools: ['screencast_start', 'screencast_stop'], + summary: 'Screencast recording.', + microSkill: + 'Enable this pack to record screencasts. If your harness caches tools at startup, invoke these tools via `manage_capabilities` action: "call".', + }, + { + id: 'extensions', + defaultPolicy: 'locked', + tools: [ + 'install_extension', + 'uninstall_extension', + 'list_extensions', + 'reload_extension', + 'trigger_extension_action', + ], + summary: 'Chrome extension management.', + microSkill: + 'Enable this pack to manage Chrome extensions. If your harness caches tools at startup, invoke these tools via `manage_capabilities` action: "call".', + }, + { + id: 'pwa', + defaultPolicy: 'on-demand', + tools: ['install_pwa', 'uninstall_pwa', 'launch_pwa', 'get_os_app_state'], + summary: 'PWA installation and state.', + microSkill: + 'Enable this pack to manage PWAs. If your harness caches tools at startup, invoke these tools via `manage_capabilities` action: "call".', + }, + { + id: 'webmcp', + defaultPolicy: 'on-demand', + tools: [ + 'list_webmcp_tools', + 'execute_webmcp_tool', + 'list_3p_developer_tools', + 'execute_3p_developer_tool', + ], + summary: 'WebMCP tools execution.', + microSkill: + 'Enable this pack to execute WebMCP tools. If your harness caches tools at startup, invoke these tools via `manage_capabilities` action: "call".', + }, +]; diff --git a/src/config/mcp-options.ts b/src/config/mcp-options.ts index 6ca24ddc..d257b2fa 100644 --- a/src/config/mcp-options.ts +++ b/src/config/mcp-options.ts @@ -40,6 +40,24 @@ export const mcpOptions = { default: true, reloadable: true, }, + packPolicy: { + type: 'string', + describe: + 'Comma-separated pack=policy pairs, e.g. performance=enabled,memory=locked', + reloadable: true, + }, + toolPolicy: { + type: 'string', + describe: + 'Comma-separated tool=policy pairs, e.g. take_heapsnapshot=enabled', + reloadable: true, + }, + metaFeatureSystem: { + type: 'boolean', + describe: + 'Enable the slimmed core startup where all non-core Domain Packs default to on-demand', + reloadable: true, + }, devtoolsComments: { type: 'boolean', describe: diff --git a/src/index.ts b/src/index.ts index 4e68ab08..bb7a5f0f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ import path from 'node:path'; import {pathToFileURL} from 'node:url'; import {BrowserManager} from './BrowserManager.js'; +import {CapabilityManager} from './capabilities/CapabilityManager.js'; import {type ParsedArguments} from './config/ConfigParser.js'; import {loadIssueDescriptions} from './devtools/issueDescriptions.js'; import {McpContext} from './McpContext.js'; @@ -57,6 +58,57 @@ interface ToolEntry { } export class McpServer { + readonly capabilityManager: CapabilityManager; + + get clientSupportsListChanged(): boolean { + const caps = this.server.server.getClientCapabilities() as + {tools?: {listChanged?: boolean}} | undefined; + return caps?.tools?.listChanged ?? false; + } + + get telemetry(): ClearcutLogger | undefined { + return ClearcutLogger.get(); + } + + get isPipeConnection(): boolean { + return Boolean( + !this.#serverArgs.autoConnect && + !this.#serverArgs.browserUrl && + !this.#serverArgs.wsEndpoint, + ); + } + + notifyToolsListChanged(): void { + if (!this.server.isConnected()) { + return; + } + void this.server.server.sendToolListChanged().catch(() => {}); + } + + getRegisteredTools(): ToolEntry[] { + return Array.from(this.#tools.values()); + } + + updateToolCapabilities(): void { + for (const [name, entry] of this.#tools.entries()) { + if (name === 'manage_capabilities') { + continue; + } + + if (entry.handler.disabled) { + entry.registeredTool.disable(); + continue; + } + + const isActive = this.capabilityManager.isToolActive(name); + if (isActive) { + entry.registeredTool.enable(); + } else { + entry.registeredTool.disable(); + } + } + } + readonly server: SdkMcpServer; #serverArgs: ParsedArguments; #browserManager: BrowserManager; @@ -75,6 +127,11 @@ export class McpServer { private constructor(serverArgs: ParsedArguments, options: McpServerOptions) { this.#serverArgs = serverArgs; this.#browserManager = options.browserManager; + this.capabilityManager = new CapabilityManager({ + metaFeatureSystem: this.#serverArgs.metaFeatureSystem, + packPolicy: this.#serverArgs.packPolicy, + toolPolicy: this.#serverArgs.toolPolicy, + }); if (this.#serverArgs.usageStatistics) { ClearcutLogger.initialize({ @@ -100,6 +157,15 @@ export class McpServer { debouncedNotificationMethods: ['notifications/tools/list_changed'], }, ); + const origSendToolListChanged = this.server.sendToolListChanged.bind( + this.server, + ); + this.server.sendToolListChanged = () => { + if (!this.server.isConnected()) { + return; + } + origSendToolListChanged(); + }; this.server.server.setRequestHandler('logging/setLevel', () => { return {}; @@ -139,6 +205,7 @@ export class McpServer { async callTool( name: string, args: Record = {}, + options?: {skipMutex?: boolean}, ): Promise { const toolHandler = this.#tools.get(name)?.handler; if (!toolHandler) { @@ -170,7 +237,9 @@ export class McpServer { isError: true, }; } - return await toolHandler.handle(parseResult.data); + return options?.skipMutex + ? await toolHandler.handleUnlocked(parseResult.data) + : await toolHandler.handle(parseResult.data); } /** @@ -211,7 +280,8 @@ export class McpServer { } async #init(): Promise { - for (const tool of createTools(this.#serverArgs)) { + const allTools = createTools(this.#serverArgs); + for (const tool of allTools) { // Slim and regular tools may share names, only register the current mode. if (isAvailableInMode(tool, this.#serverArgs)) { this.#registerTool(tool); @@ -265,6 +335,7 @@ export class McpServer { if (this.#context?.browser !== browser) { this.#context?.dispose(); this.#context = await McpContext.from(browser, logger, { + mcpServer: this, experimentalDevToolsDebugging: this.#serverArgs.experimentalDevtools ?? false, experimentalIncludeAllPages: @@ -310,6 +381,7 @@ export class McpServer { () => this.#getContext(), this.#toolMutex, () => this.#browserManager.abandonPendingAttempt(), + this, // Pass McpServer instance ); } @@ -326,7 +398,11 @@ export class McpServer { handler.handle, ); - if (handler.disabled) { + if ( + handler.disabled || + (tool.name !== 'manage_capabilities' && + !this.capabilityManager.isToolActive(tool.name)) + ) { registeredTool.disable(); } diff --git a/src/telemetry/ClearcutLogger.ts b/src/telemetry/ClearcutLogger.ts index b3b0a1cd..028f4ab5 100644 --- a/src/telemetry/ClearcutLogger.ts +++ b/src/telemetry/ClearcutLogger.ts @@ -257,6 +257,31 @@ export class ClearcutLogger { } } + async logCapabilityEvent(args: { + action: 'enable' | 'disable' | 'call'; + packId: string; + toolName?: string; + policyTier: 'enabled' | 'on-demand' | 'locked'; + clientSupportsListChanged: boolean; + latencyMs: number; + success: boolean; + }): Promise { + this.#watchdog.send({ + type: WatchdogMessageType.LOG_EVENT, + payload: this.#addMcpClient({ + capability_event: { + action: args.action, + pack_id: args.packId, + tool_name: args.toolName, + policy_tier: args.policyTier, + client_supports_list_changed: args.clientSupportsListChanged, + latency_ms: bucketizeLatency(args.latencyMs), + success: args.success, + }, + }), + }); + } + async logServerError(args: { toolName?: string; errorCode: ErrorCode; diff --git a/src/telemetry/flag_usage_metrics.json b/src/telemetry/flag_usage_metrics.json index d13eb04b..3c0032fb 100644 --- a/src/telemetry/flag_usage_metrics.json +++ b/src/telemetry/flag_usage_metrics.json @@ -470,5 +470,21 @@ { "name": "screencast_fps_present", "flagType": "boolean" + }, + { + "name": "pack_policy_present", + "flagType": "boolean" + }, + { + "name": "tool_policy_present", + "flagType": "boolean" + }, + { + "name": "meta_feature_system_present", + "flagType": "boolean" + }, + { + "name": "meta_feature_system", + "flagType": "boolean" } ] diff --git a/src/telemetry/tool_call_metrics.json b/src/telemetry/tool_call_metrics.json index 12974d47..18c6acab 100644 --- a/src/telemetry/tool_call_metrics.json +++ b/src/telemetry/tool_call_metrics.json @@ -1350,5 +1350,30 @@ "argType": "number" } ] + }, + { + "name": "manage_capabilities", + "args": [ + { + "name": "action", + "argType": "string" + }, + { + "name": "capability_length", + "argType": "number" + }, + { + "name": "capabilities_count", + "argType": "number" + }, + { + "name": "tool_length", + "argType": "number" + }, + { + "name": "tool_args_count", + "argType": "number" + } + ] } ] diff --git a/src/telemetry/transformation.ts b/src/telemetry/transformation.ts index 98f0bd9d..30dea43e 100644 --- a/src/telemetry/transformation.ts +++ b/src/telemetry/transformation.ts @@ -49,6 +49,7 @@ const SUPPORTED_ZOD_TYPES = [ 'ZodNumber', 'ZodBoolean', 'ZodArray', + 'ZodRecord', 'ZodEnum', ] as const; type ZodType = (typeof SUPPORTED_ZOD_TYPES)[number]; @@ -168,7 +169,7 @@ export function transformArgName(zodType: ZodType, name: string): string { let transformed: string; if (zodType === 'ZodString') { transformed = `${snakeCaseName}_length`; - } else if (zodType === 'ZodArray') { + } else if (zodType === 'ZodArray' || zodType === 'ZodRecord') { transformed = `${snakeCaseName}_count`; } else { transformed = snakeCaseName; @@ -177,7 +178,11 @@ export function transformArgName(zodType: ZodType, name: string): string { } export function transformArgType(zodType: ZodType): string { - if (zodType === 'ZodString' || zodType === 'ZodArray') { + if ( + zodType === 'ZodString' || + zodType === 'ZodArray' || + zodType === 'ZodRecord' + ) { return 'number'; } switch (zodType) { @@ -215,6 +220,9 @@ function transformValue( if (zodType === 'ZodArray') { return (value as unknown[]).length; } + if (zodType === 'ZodRecord') { + return Object.keys(value as Record).length; + } return value as LoggedToolCallArgValue; } @@ -225,6 +233,9 @@ function hasEquivalentType(zodType: ZodType, value: unknown): boolean { if (zodType === 'ZodArray') { return Array.isArray(value); } + if (zodType === 'ZodRecord') { + return typeof value === 'object' && value !== null && !Array.isArray(value); + } if (zodType === 'ZodNumber') { return typeof value === 'number'; } diff --git a/src/telemetry/types.ts b/src/telemetry/types.ts index 400877e9..6a5660ab 100644 --- a/src/telemetry/types.ts +++ b/src/telemetry/types.ts @@ -7,6 +7,16 @@ import type {ErrorCode} from './errors.js'; // Protobuf message interfaces +export interface CapabilityEvent { + action: 'enable' | 'disable' | 'call'; + pack_id: string; + tool_name?: string; + policy_tier: 'enabled' | 'on-demand' | 'locked'; + client_supports_list_changed: boolean; + latency_ms: number; + success: boolean; +} + export interface ChromeDevToolsMcpExtension { os_type?: OsType; mcp_client?: McpClient; @@ -19,6 +29,7 @@ export interface ChromeDevToolsMcpExtension { server_shutdown?: ServerShutdown; server_error?: ServerError; tool_active?: ToolActive; + capability_event?: CapabilityEvent; } export interface RawMcpClientInfo { diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index 6a7483d3..c3fa36af 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -57,6 +57,8 @@ import type { import type {ToolCategory} from './categories.js'; import type {ToolGroups} from './thirdPartyDeveloper.js'; +import type {McpServer} from '../index.js'; + export type FileVerificationOption = | true | { @@ -302,8 +304,8 @@ export interface ScreenRecorderData { recorder: ScreenRecorder | ScreenRecording; filePath: string; } - export type Context = Readonly<{ + mcpServer?: McpServer; installPWA(options: InstallPWAOptions): Promise; uninstallPWA(options: UninstallPWAOptions): Promise; launchPWA(options: LaunchPWAOptions): Promise; diff --git a/src/tools/capabilities.ts b/src/tools/capabilities.ts new file mode 100644 index 00000000..6d44f3d5 --- /dev/null +++ b/src/tools/capabilities.ts @@ -0,0 +1,288 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {DOMAIN_PACKS} from '../capabilities/DomainPacks.js'; +import type {McpContext} from '../McpContext.js'; +import {zod} from '../third_party/index.js'; + +import {ToolCategory} from './categories.js'; +import {defineTool} from './ToolDefinition.js'; + +interface JsonSchemaObject { + properties?: Record; + required?: string[]; +} + +function toJsonSchemaSafe(schema: unknown): JsonSchemaObject { + try { + const z = zod as unknown as { + toJSONSchema: ( + s: unknown, + opts: {unrepresentable: string}, + ) => JsonSchemaObject; + }; + return z.toJSONSchema(schema, {unrepresentable: 'any'}); + } catch { + return {}; + } +} + +export const manageCapabilities = defineTool(() => ({ + name: 'manage_capabilities', + description: + 'Manage on-demand capability packs. list, enable, disable, or call fallback.', + + annotations: { + category: ToolCategory.DEBUGGING, + readOnlyHint: false, + conditions: ['metaFeatureSystem'], + }, + blockedByDialog: false, + verifyFilesSchema: {}, + schema: { + action: zod + .enum(['list', 'enable', 'disable', 'call']) + .describe('Action to perform.'), + capability: zod + .string() + .optional() + .describe( + 'Single capability pack ID to enable/disable (e.g. memory, performance, audits, screencast, pwa, webmcp; locked by default: extensions).', + ), + capabilities: zod + .array(zod.string()) + .optional() + .describe('Multiple capability pack IDs to enable/disable.'), + tool: zod + .string() + .optional() + .describe('Tool name to call (only for action: "call").'), + toolArgs: zod + .record(zod.string(), zod.unknown()) + .optional() + .describe('Arguments for the tool (only for action: "call").'), + }, + handler: async (request, response, context: McpContext) => { + const {action, capability, capabilities, tool, toolArgs} = request.params; + const packsToProcess = capabilities + ? capabilities + : capability + ? [capability] + : []; + + const mcpServer = context.mcpServer; + const capabilityManager = mcpServer?.capabilityManager; + if (!mcpServer || !capabilityManager) { + throw new Error('CapabilityManager is not initialized.'); + } + + if (action === 'list') { + const active = capabilityManager.getActivePacks(); + const allPacks = DOMAIN_PACKS.map(p => ({ + id: p.id, + policy: capabilityManager.getPackPolicy(p.id), + tools: p.tools, + active: active.includes(p.id), + summary: p.summary, + })); + + const corePack = DOMAIN_PACKS.find(p => p.id === 'core'); + let coreTokens = 0; + let activeTokens = 0; + let totalTokens = 0; + + const tools = mcpServer.getRegisteredTools(); + for (const entry of tools) { + const toolName = entry.handler.tool.name; + const isActive = + !entry.handler.disabled && + (toolName === 'manage_capabilities' || + capabilityManager.isToolActive(toolName)); + const policy = capabilityManager.getToolPolicy(toolName); + if (!isActive && policy !== 'on-demand') { + continue; + } + + const toolStr = JSON.stringify({ + name: toolName, + description: entry.handler.tool.description, + inputSchema: toJsonSchemaSafe(entry.handler.registeredInputSchema), + }); + const tokens = Math.ceil(toolStr.length / 4); + + totalTokens += tokens; + + if (isActive) { + activeTokens += tokens; + } + if ( + toolName === 'manage_capabilities' || + (corePack?.tools.includes(toolName) && !entry.handler.disabled) + ) { + coreTokens += tokens; + } + } + const tokenSavingsPercent = + totalTokens > 0 + ? Math.round(((totalTokens - coreTokens) / totalTokens) * 100) + : 0; + + response.appendResponseLine( + JSON.stringify( + { + packs: allPacks, + message: 'Use enable to activate a pack.', + startupTokenSavingsPercent: tokenSavingsPercent, + coreTokens, + activeTokens, + totalCatalogTokens: totalTokens, + }, + null, + 2, + ), + ); + return; + } + + if (action === 'enable' || action === 'disable') { + if (packsToProcess.length === 0) { + throw new Error('No capabilities provided to ' + action); + } + + const startTime = Date.now(); + const results: Array<{ + pack: string; + success: boolean; + error?: string; + microSkill?: string; + tools?: Array>; + }> = []; + let anyChanged = false; + + const registeredTools = mcpServer.getRegisteredTools(); + for (const pack of packsToProcess) { + if (action === 'enable') { + if (pack === 'pwa' && !mcpServer.isPipeConnection) { + results.push({ + pack, + success: false, + error: + "Capability 'pwa' is only supported with a pipe connection; autoConnect, browserUrl, and wsEndpoint are not supported.", + }); + continue; + } + const res = capabilityManager.enablePack(pack); + if (res.success) { + anyChanged = true; + const packDef = DOMAIN_PACKS.find(p => p.id === pack); + const enabledTools = (packDef?.tools || []).map(toolName => { + const entry = registeredTools.find( + e => e.handler.tool.name === toolName, + ); + if (!entry) { + return {name: toolName}; + } + const inputSchema = toJsonSchemaSafe( + entry.handler.registeredInputSchema, + ); + return { + name: toolName, + description: entry.handler.tool.description, + parameters: inputSchema.properties || {}, + required: inputSchema.required || [], + }; + }); + results.push({pack, ...res, tools: enabledTools}); + } else { + results.push({pack, ...res}); + } + } else { + const res = capabilityManager.disablePack(pack); + results.push({pack, ...res}); + if (res.success) { + anyChanged = true; + } + } + } + + if (anyChanged) { + mcpServer.updateToolCapabilities(); + mcpServer.notifyToolsListChanged(); + } + + const latencyMs = Date.now() - startTime; + for (const r of results) { + await mcpServer.telemetry?.logCapabilityEvent({ + action, + packId: r.pack, + policyTier: capabilityManager.getPackPolicy(r.pack), + clientSupportsListChanged: mcpServer.clientSupportsListChanged, + latencyMs, + success: r.success, + }); + } + + const responsePayload: Record = {results}; + if (action === 'enable' && anyChanged) { + responsePayload.fallbackInstruction = + "If your client doesn't support dynamic tool updates, use action: 'call' to invoke the newly enabled tools."; + } + response.appendResponseLine(JSON.stringify(responsePayload, null, 2)); + return; + } + + if (action === 'call') { + if (!tool) { + throw new Error('Tool name required for action "call".'); + } + + const pack = DOMAIN_PACKS.find(p => p.tools.includes(tool))?.id || 'core'; + if (capabilityManager.getToolPolicy(tool) === 'locked') { + throw new Error( + `Tool '${tool}' (pack '${pack}') is locked by security policy and cannot be invoked or enabled.`, + ); + } + + if (!capabilityManager.isToolActive(tool)) { + throw new Error( + `Tool '${tool}' (pack '${pack}') is not active. Enable pack '${pack}' first.`, + ); + } + + const startTime = Date.now(); + const res = await mcpServer.callTool(tool, toolArgs || {}, { + skipMutex: true, + }); + await mcpServer.telemetry?.logCapabilityEvent({ + action: 'call', + packId: pack, + toolName: tool, + policyTier: capabilityManager.getPackPolicy(pack), + clientSupportsListChanged: mcpServer.clientSupportsListChanged, + latencyMs: Date.now() - startTime, + success: !res.isError, + }); + if (res.isError) { + throw new Error( + res.content.map(c => ('text' in c ? String(c.text) : '')).join('\n'), + ); + } + for (const c of res.content) { + if (c.type === 'text') { + response.appendResponseLine(c.text); + } else if (c.type === 'image' && 'data' in c && 'mimeType' in c) { + response.attachImage({ + data: String(c.data), + mimeType: String(c.mimeType), + }); + } + } + return; + } + + throw new Error('Unknown action: ' + String(action)); + }, +})); diff --git a/src/tools/tools.ts b/src/tools/tools.ts index 40b1cb83..9d097f29 100644 --- a/src/tools/tools.ts +++ b/src/tools/tools.ts @@ -28,6 +28,7 @@ import * as snapshotTools from './snapshot.js'; import * as thirdPartyDeveloperTools from './thirdPartyDeveloper.js'; import type {DefinedPageTool, ToolDefinition} from './ToolDefinition.js'; import * as webmcpTools from './webmcp.js'; +import {manageCapabilities} from './capabilities.js'; export const createTools = ( args: ParsedArguments, @@ -52,6 +53,7 @@ export const createTools = ( ...Object.values(snapshotTools), ...Object.values(thirdPartyDeveloperTools), ...Object.values(webmcpTools), + manageCapabilities, ]; const tools: Array = []; diff --git a/tests/capabilities/CapabilityManager.test.ts b/tests/capabilities/CapabilityManager.test.ts new file mode 100644 index 00000000..f85c3d53 --- /dev/null +++ b/tests/capabilities/CapabilityManager.test.ts @@ -0,0 +1,67 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {describe, it} from 'node:test'; +import assert from 'node:assert'; +import {CapabilityManager} from '../../src/capabilities/CapabilityManager.js'; + +describe('CapabilityManager', () => { + it('defaults to legacy enabled behavior when metaFeatureSystem is false', () => { + const manager = new CapabilityManager({metaFeatureSystem: false}); + assert.strictEqual(manager.getPackPolicy('performance'), 'enabled'); + assert.strictEqual(manager.isToolActive('performance_start_trace'), true); + }); + + it('respects metaFeatureSystem to slim the core startup', () => { + const manager = new CapabilityManager({metaFeatureSystem: true}); + assert.strictEqual(manager.getPackPolicy('core'), 'enabled'); + assert.strictEqual(manager.getPackPolicy('performance'), 'on-demand'); + assert.strictEqual(manager.isToolActive('performance_start_trace'), false); + + // Enable pack + const res = manager.enablePack('performance'); + assert.strictEqual(res.success, true); + assert.strictEqual(manager.isToolActive('performance_start_trace'), true); + }); + + it('enforces locked policy and per-tool overrides', () => { + const manager = new CapabilityManager({ + metaFeatureSystem: true, + packPolicy: 'performance=locked', + toolPolicy: 'take_heapsnapshot=enabled', + }); + + assert.strictEqual(manager.getPackPolicy('performance'), 'locked'); + assert.strictEqual(manager.isToolActive('performance_start_trace'), false); + const res = manager.enablePack('performance'); + assert.strictEqual(res.success, false); + + assert.strictEqual(manager.getToolPolicy('take_heapsnapshot'), 'enabled'); + assert.strictEqual(manager.isToolActive('take_heapsnapshot'), true); + }); + + it('supports on-demand tool policy for core tools and reconfigure', () => { + const manager = new CapabilityManager({ + metaFeatureSystem: true, + toolPolicy: 'click=on-demand', + }); + + assert.strictEqual(manager.getToolPolicy('click'), 'on-demand'); + assert.strictEqual(manager.isToolActive('click'), false); + assert.strictEqual(manager.isToolActive('navigate_page'), true); + + const res = manager.enablePack('core'); + assert.strictEqual(res.success, true); + assert.strictEqual(manager.isToolActive('click'), true); + + manager.reconfigure({ + metaFeatureSystem: false, + toolPolicy: 'navigate_page=locked', + }); + assert.strictEqual(manager.isToolActive('navigate_page'), false); + assert.strictEqual(manager.isToolActive('click'), true); + }); +}); diff --git a/tests/tools/capabilities.test.ts b/tests/tools/capabilities.test.ts new file mode 100644 index 00000000..3ef243eb --- /dev/null +++ b/tests/tools/capabilities.test.ts @@ -0,0 +1,471 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; +import path from 'node:path'; +import {afterEach, describe, it} from 'node:test'; +import {pathToFileURL} from 'node:url'; + +import sinon from 'sinon'; + +import {BrowserManager} from '../../src/BrowserManager.js'; +import {ConfigParser} from '../../src/config/ConfigParser.js'; +import {McpServer} from '../../src/index.js'; +import {McpContext} from '../../src/McpContext.js'; +import {createMockMcpContext, createMockPuppeteerBrowser} from '../mocks.js'; + +function getFirstTextContent(result: { + content: Array<{type: string; text?: string}>; +}): string { + const first = result.content[0]; + return first && 'text' in first && typeof first.text === 'string' + ? first.text + : ''; +} + +async function createTestServer(extraArgs: string[] = []) { + const browserManager = sinon.createStubInstance(BrowserManager); + const browser = createMockPuppeteerBrowser(); + browserManager.ensureBrowser.resolves(browser); + + const context = createMockMcpContext(); + context.browser = browser; + context.getPages.returns([]); + sinon.stub(McpContext, 'from').callsFake(async (_b, _l, opts) => { + context.mcpServer = opts.mcpServer; + return context; + }); + + const serverArgs = new ConfigParser( + '1.0.0', + ['node', 'script.js', ...extraArgs], + { + CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true', + }, + ).parse(); + const server = await McpServer.from(serverArgs, {browserManager}); + return {server, browserManager, context}; +} + +describe('manage_capabilities tool', () => { + afterEach(() => { + sinon.restore(); + }); + + it('Integration: full server capability lifecycle and >30% token reduction', async () => { + const {server, browserManager, context} = await createTestServer([ + '--meta-feature-system', + ]); + + const listRes = await server.callTool('manage_capabilities', { + action: 'list', + }); + if (listRes.isError) { + console.error('Error from listRes:', listRes); + } + assert.strictEqual(listRes.isError, undefined); + sinon.assert.notCalled(browserManager.ensureBrowser); + const listContent = JSON.parse(getFirstTextContent(listRes)) as { + packs: Array<{id: string; tools: string[]}>; + startupTokenSavingsPercent: number; + coreTokens: number; + activeTokens: number; + totalCatalogTokens: number; + }; + + assert( + listContent.startupTokenSavingsPercent >= 30, + `Expected token savings >= 30, got ${listContent.startupTokenSavingsPercent}`, + ); + assert(listContent.coreTokens > 0); + assert.strictEqual(listContent.activeTokens, listContent.coreTokens); + assert(listContent.totalCatalogTokens > listContent.coreTokens); + + const corePack = listContent.packs.find(p => p.id === 'core'); + assert(corePack); + assert(!corePack.tools.includes('get_devtools_comments')); + assert(!corePack.tools.includes('open_devtools')); + + assert.strictEqual( + server.capabilityManager.isToolActive('performance_start_trace'), + false, + ); + assert.strictEqual( + server.capabilityManager.isToolActive('take_heapsnapshot'), + false, + ); + assert.strictEqual( + server.capabilityManager.isToolActive('list_extensions'), + false, + ); + assert.strictEqual( + server.capabilityManager.isToolActive('navigate_page'), + true, + ); + + const enableRes = await server.callTool('manage_capabilities', { + action: 'enable', + capabilities: ['performance', 'memory'], + }); + assert.strictEqual(enableRes.isError, undefined); + const enableContent = JSON.parse(getFirstTextContent(enableRes)) as { + results: Array<{ + success: boolean; + microSkill?: string; + tools?: Array<{ + name: string; + parameters: Record; + }>; + }>; + fallbackInstruction?: string; + }; + assert.strictEqual(enableContent.results[0].success, true); + assert( + typeof enableContent.results[0].microSkill === 'string' && + enableContent.results[0].microSkill.length > 0, + ); + assert( + Array.isArray(enableContent.results[0].tools) && + enableContent.results[0].tools.length === 3, + ); + const analyzeToolSchema = enableContent.results[0].tools.find( + t => t.name === 'performance_analyze_insight', + ); + assert( + analyzeToolSchema && + analyzeToolSchema.parameters.insightSetId && + analyzeToolSchema.parameters.insightName, + ); + assert(typeof enableContent.fallbackInstruction === 'string'); + + assert.strictEqual( + server.capabilityManager.isToolActive('performance_start_trace'), + true, + ); + assert.strictEqual( + server.capabilityManager.isToolActive('take_heapsnapshot'), + true, + ); + + // Verify action: 'call' executes active tools without mutex deadlock + const callRes = await server.callTool('manage_capabilities', { + action: 'call', + tool: 'list_pages', + toolArgs: {}, + }); + assert.strictEqual(callRes.isError, undefined); + sinon.assert.calledOnce(browserManager.ensureBrowser); + sinon.assert.calledOnce(context.createPagesSnapshot); + + const lockedRes = await server.callTool('manage_capabilities', { + action: 'enable', + capability: 'extensions', + }); + const lockedContent = JSON.parse(getFirstTextContent(lockedRes)) as { + results: Array<{success: boolean}>; + }; + assert.strictEqual(lockedContent.results[0].success, false); + assert.strictEqual( + server.capabilityManager.isToolActive('list_extensions'), + false, + ); + + const lockedCallRes = await server.callTool('manage_capabilities', { + action: 'call', + tool: 'list_extensions', + toolArgs: {}, + }); + assert.strictEqual(lockedCallRes.isError, true); + assert( + getFirstTextContent(lockedCallRes).includes('locked by security policy'), + ); + + const disableRes = await server.callTool('manage_capabilities', { + action: 'disable', + capability: 'performance', + }); + const disableContent = JSON.parse(getFirstTextContent(disableRes)) as { + results: Array<{success: boolean}>; + fallbackInstruction?: string; + }; + assert.strictEqual(disableContent.results[0].success, true); + assert.strictEqual(disableContent.fallbackInstruction, undefined); + }); + + it('Enforces --tool-policy=on-demand for core tools, --no-category-* flags, and PWA pipe requirement', async () => { + const {server} = await createTestServer([ + '--meta-feature-system', + '--tool-policy=click=on-demand', + '--no-category-network', + '--browserUrl=http://127.0.0.1:9222', + ]); + + // 1. Core tool with --tool-policy=click=on-demand starts inactive until core is explicitly enabled + assert.strictEqual(server.capabilityManager.isToolActive('click'), false); + const inactiveClickRes = await server.callTool('click', { + pageId: 1, + uid: '1_1', + }); + assert.strictEqual(inactiveClickRes.isError, true); + assert( + getFirstTextContent(inactiveClickRes).includes( + 'in an on-demand capability pack', + ), + ); + + const enableCoreRes = await server.callTool('manage_capabilities', { + action: 'enable', + capability: 'core', + }); + assert.strictEqual(enableCoreRes.isError, undefined); + assert.strictEqual(server.capabilityManager.isToolActive('click'), true); + + // 2. Explicit --no-category-network is respected even under --meta-feature-system + const networkRes = await server.callTool('list_network_requests', { + pageId: 1, + }); + assert.strictEqual(networkRes.isError, true); + assert(getFirstTextContent(networkRes).includes('--categoryNetwork')); + + // 3. PWA pack cannot be enabled when connected via --browserUrl + const enablePwaRes = await server.callTool('manage_capabilities', { + action: 'enable', + capability: 'pwa', + }); + const enablePwaContent = JSON.parse(getFirstTextContent(enablePwaRes)) as { + results: Array<{success: boolean; error?: string}>; + }; + assert.strictEqual(enablePwaContent.results[0].success, false); + assert( + enablePwaContent.results[0].error?.includes( + 'only supported with a pipe connection', + ), + ); + }); + + it('Eval scenarios pass expectations both without and with --meta-feature-system', async () => { + const scriptsDir = path.resolve(process.cwd(), 'scripts'); + const evalResultUrl = pathToFileURL( + path.join(scriptsDir, 'eval_result.ts'), + ).href; + const evalResultMod = (await import(evalResultUrl)) as Record< + string, + new ( + calls: Array<{ + name: string; + args: Record; + isError?: boolean; + }>, + serverArgs: string[], + text?: string, + ) => unknown + >; + const ResultCtor = evalResultMod['Result']; + const loadScenario = async (fileName: string) => { + const scenarioUrl = pathToFileURL( + path.join(scriptsDir, 'eval_scenarios', fileName), + ).href; + const mod = (await import(scenarioUrl)) as { + scenario: {expectations: (res: unknown) => void}; + }; + return mod.scenario; + }; + const snapshotScenario = await loadScenario('snapshot_test.ts'); + const lighthouseScenario = await loadScenario('lighthouse_a11y_test.ts'); + const memoryScenario = await loadScenario('memory_heapsnapshot_test.ts'); + const perfPolicyScenario = await loadScenario( + 'performance_and_policy_test.ts', + ); + + // 1. snapshot_test without and with --meta-feature-system + snapshotScenario.expectations( + new ResultCtor( + [ + { + name: 'navigate_page', + args: {pageId: 1, type: 'url', url: 'http://127.0.0.1/test.html'}, + }, + {name: 'take_snapshot', args: {pageId: 1}}, + ], + ['--isolated', '--headless'], + 'Hello World', + ), + ); + snapshotScenario.expectations( + new ResultCtor( + [ + { + name: 'navigate_page', + args: {pageId: 1, type: 'url', url: 'http://127.0.0.1/test.html'}, + }, + {name: 'take_snapshot', args: {pageId: 1}}, + ], + ['--isolated', '--headless', '--meta-feature-system'], + 'Hello World', + ), + ); + + // 2. lighthouse_a11y_test without and with --meta-feature-system + lighthouseScenario.expectations( + new ResultCtor( + [ + { + name: 'navigate_page', + args: { + pageId: 1, + type: 'url', + url: 'http://127.0.0.1/lighthouse_test.html', + }, + }, + {name: 'lighthouse_audit', args: {pageId: 1}}, + ], + ['--isolated', '--headless'], + 'Audit complete', + ), + ); + lighthouseScenario.expectations( + new ResultCtor( + [ + {name: 'manage_capabilities', args: {action: 'list'}}, + { + name: 'manage_capabilities', + args: {action: 'enable', capability: 'audits'}, + }, + { + name: 'navigate_page', + args: { + pageId: 1, + type: 'url', + url: 'http://127.0.0.1/lighthouse_test.html', + }, + }, + { + name: 'manage_capabilities', + args: { + action: 'call', + tool: 'lighthouse_audit', + toolArgs: {pageId: 1}, + }, + }, + ], + ['--isolated', '--headless', '--meta-feature-system'], + 'Audit complete', + ), + ); + + // 3. memory_heapsnapshot_test without and with --meta-feature-system + memoryScenario.expectations( + new ResultCtor( + [ + { + name: 'navigate_page', + args: { + pageId: 1, + type: 'url', + url: 'http://127.0.0.1/memory_leak_test.html', + }, + }, + { + name: 'take_heapsnapshot', + args: {pageId: 1, filePath: '/tmp/eval-heap.heapsnapshot'}, + }, + { + name: 'get_heapsnapshot_summary', + args: {filePath: '/tmp/eval-heap.heapsnapshot'}, + }, + ], + ['--isolated', '--headless', '--memory-debugging'], + 'DetachedCheckoutCartListener', + ), + ); + memoryScenario.expectations( + new ResultCtor( + [ + { + name: 'manage_capabilities', + args: {action: 'enable', capability: 'memory'}, + }, + { + name: 'navigate_page', + args: { + pageId: 1, + type: 'url', + url: 'http://127.0.0.1/memory_leak_test.html', + }, + }, + { + name: 'take_heapsnapshot', + args: {pageId: 1, filePath: '/tmp/eval-heap.heapsnapshot'}, + }, + { + name: 'get_heapsnapshot_summary', + args: {filePath: '/tmp/eval-heap.heapsnapshot'}, + }, + ], + ['--isolated', '--headless', '--meta-feature-system'], + 'DetachedCheckoutCartListener', + ), + ); + + // 4. performance_and_policy_test without and with --meta-feature-system + perfPolicyScenario.expectations( + new ResultCtor( + [ + { + name: 'navigate_page', + args: { + pageId: 1, + type: 'url', + url: 'http://127.0.0.1/perf_and_policy_test.html', + }, + }, + { + name: 'performance_start_trace', + args: {pageId: 1, reload: true, autoStop: true}, + }, + ], + ['--isolated', '--headless'], + 'Trace complete', + ), + ); + perfPolicyScenario.expectations( + new ResultCtor( + [ + { + name: 'manage_capabilities', + args: { + action: 'enable', + capabilities: ['performance', 'extensions'], + }, + }, + { + name: 'navigate_page', + args: { + pageId: 1, + type: 'url', + url: 'http://127.0.0.1/perf_and_policy_test.html', + }, + }, + { + name: 'performance_start_trace', + args: {pageId: 1, reload: true, autoStop: true}, + }, + { + name: 'manage_capabilities', + args: { + action: 'call', + tool: 'list_extensions', + toolArgs: {}, + }, + isError: true, + }, + ], + ['--isolated', '--headless', '--meta-feature-system'], + 'Trace complete; extensions locked by security policy.', + ), + ); + }); +});