feat: implement Meta-Feature System and dynamic domain packs

This commit is contained in:
Michael Hablich committed 2026-10-23 16:15:32 +02:00
1 parent c87d190b2e
commit ba71d0b9cd
27 files changed
+1735 -54

No files matched your search

+13
View File
@@ -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
+16 -1
View File
@@ -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`
+75 -15
View File
@@ -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<string, unknown>) || {},
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<Record<string, unknown>> = [
{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<string, unknown>);
}
const fnCalls = res.functionCalls ?? [];
if (fnCalls.length === 0) {
finalResponseText = res.text ?? '';
break;
}
const responseParts: Array<Record<string, unknown>> = [];
for (const fc of fnCalls) {
const toolName = fc.name ?? '';
const toolArgs = (fc.args as Record<string, unknown>) ?? {};
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
+66 -3
View File
@@ -9,21 +9,84 @@ import assert from 'node:assert';
export interface CapturedFunctionCall {
name: string;
args: Record<string, unknown>;
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<string, unknown> | 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++;
}
@@ -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(
@@ -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(
@@ -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 <TEST_URL>, 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: `
<!DOCTYPE html>
<html>
<head><title>Memory Leak Test</title></head>
<body>
<h1>Checkout Cart Memory Test</h1>
<script>
class DetachedCheckoutCartListener {
constructor(id) {
this.id = id;
this.payload = 'leak_payload_' + id;
}
}
window.__leakedListeners = Array.from(
{length: 2000},
(_, i) => new DetachedCheckoutCartListener(i),
);
</script>
</body>
</html>
`,
},
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',
);
},
};
@@ -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 <TEST_URL>, 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: `
<!DOCTYPE html>
<html>
<head><title>Performance & Policy Test</title></head>
<body>
<h1>Dashboard Performance Test</h1>
<script>
const start = performance.now();
while (performance.now() - start < 20) {}
console.log('Dashboard loaded');
</script>
</body>
</html>
`,
},
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',
);
}
},
};
@@ -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(
+5
View File
@@ -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;
+4
View File
@@ -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;
+18 -6
View File
@@ -620,9 +620,16 @@ export class McpResponse implements Response {
return undefined;
}
async #handleThirdPartyDevelopeTools(): Promise<ToolGroups | undefined> {
async #handleThirdPartyDevelopeTools(
context: McpContext,
): Promise<ToolGroups | undefined> {
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<WebMCPTool[] | undefined> {
async #handleWebMCP(context: McpContext): Promise<WebMCPTool[] | undefined> {
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(),
+92 -23
View File
@@ -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<string, unknown>,
context: McpContext,
): Promise<void> {
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<McpContext>,
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<string, unknown>): Promise<CallToolResult> => {
using guard = await this.toolMutex.acquire();
return await this.handleUnlocked(params);
};
handleUnlocked = async (
params: Record<string, unknown>,
): Promise<CallToolResult> => {
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,
+179
View File
@@ -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<string, PolicyTier>();
#toolPolicies = new Map<string, PolicyTier>();
#activePacks = new Set<string>();
#explicitlyEnabledPacks = new Set<string>();
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);
}
}
+139
View File
@@ -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".',
},
];
+18
View File
@@ -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:
+79 -3
View File
@@ -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<string, unknown> = {},
options?: {skipMutex?: boolean},
): Promise<CallToolResult> {
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<void> {
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();
}
+25
View File
@@ -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<void> {
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;
+16
View File
@@ -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"
}
]
+25
View File
@@ -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"
}
]
}
]
+13 -2
View File
@@ -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<string, unknown>).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';
}
+11
View File
@@ -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 {
+3 -1
View File
@@ -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<string>;
uninstallPWA(options: UninstallPWAOptions): Promise<void>;
launchPWA(options: LaunchPWAOptions): Promise<Page>;
+288
View File
@@ -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<string, unknown>;
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<Record<string, unknown>>;
}> = [];
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<string, unknown> = {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));
},
}));
+2
View File
@@ -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<ToolDefinition | DefinedPageTool> = [];
@@ -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);
});
});
+471
View File
@@ -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<string, unknown>;
}>;
}>;
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<string, unknown>;
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.',
),
);
});
});