From 2651620d279c9e6644b82125f1c4b644729fe790 Mon Sep 17 00:00:00 2001 From: Jeremy Schoemaker Date: Sat, 22 Aug 2026 15:45:12 -0500 Subject: [PATCH 1/3] Bug 2065545 - [firefox-devtools-mcp] Add a tool to disable the network cache Adds set_network_cache, wrapping the BiDi network.setCacheBehavior command. behavior='bypass' sends every request to the network; 'default' restores normal caching. Scoped to the selected tab by default, browser-wide with scope='global', as the bug asks for. Joins the existing network module, so it comes with the developer preset alongside the profiler tools it is meant to be used with. Unknown behavior and scope values are rejected rather than defaulted: silently caching when the caller asked to bypass would quietly invalidate whatever they were measuring. Note the bug names emulation.setNetworkConditions. That command exists in Firefox but takes {type: "offline"} or null and has no cache parameter -- it is offline emulation, not cache control. network.setCacheBehavior is the command that bypasses the cache; details and measurements in the PR. --- src/firefox/index.ts | 29 ++++++++++++++ src/tools/network.ts | 60 ++++++++++++++++++++++++++++- tests/tools/network.test.ts | 77 ++++++++++++++++++++++++++++++++++++- 3 files changed, 164 insertions(+), 2 deletions(-) diff --git a/src/firefox/index.ts b/src/firefox/index.ts index c5392821..d6d208bb 100644 --- a/src/firefox/index.ts +++ b/src/firefox/index.ts @@ -14,6 +14,19 @@ import { DomInteractions } from './dom.js'; import { PageManagement, type ReadinessState } from './pages.js'; import { SnapshotManager, type Snapshot, type SnapshotOptions } from './snapshot/index.js'; +/** + * WebDriver BiDi network.CacheBehavior. + * - "default": normal HTTP cache behaviour + * - "bypass": skip the cache, so every request goes to the network + */ +export const CACHE_BEHAVIORS = ['default', 'bypass'] as const; + +export type CacheBehavior = (typeof CACHE_BEHAVIORS)[number]; + +export function isCacheBehavior(value: unknown): value is CacheBehavior { + return CACHE_BEHAVIORS.includes(value as CacheBehavior); +} + /** * Main Firefox Client facade * Delegates to modular components for clean separation of concerns @@ -320,6 +333,22 @@ export class FirefoxClient { // Network // ============================================================================ + async setCacheBehavior(behavior: CacheBehavior, options?: { global?: boolean }): Promise { + const params: Record = { cacheBehavior: behavior }; + + // Omitting `contexts` applies the behaviour globally; passing the current + // context scopes it to the selected tab, which is the default. + if (!options?.global) { + const contextId = this.getCurrentContextId(); + if (!contextId) { + throw new Error('Cannot set cache behavior: no browsing context ID'); + } + params.contexts = [contextId]; + } + + await this.sendBiDiCommand('network.setCacheBehavior', params); + } + async startNetworkMonitoring(): Promise { if (!this.networkEvents) { throw new Error( diff --git a/src/tools/network.ts b/src/tools/network.ts index 7aeece14..396d1be6 100644 --- a/src/tools/network.ts +++ b/src/tools/network.ts @@ -17,6 +17,7 @@ import { saveOutput } from '../utils/save-output.js'; import { defineModule, defineToolHandler, type ToolDefinition } from './module.js'; import type { McpToolResponse } from '../types/common.js'; import type { NetworkBodyResult } from '../firefox/events/network.js'; +import { CACHE_BEHAVIORS, isCacheBehavior } from '../firefox/index.js'; // Tool definitions export const listNetworkRequestsTool = { @@ -131,6 +132,31 @@ export const getNetworkRequestTool = { }, } satisfies ToolDefinition; +export const setNetworkCacheTool = { + name: 'set_network_cache', + description: + "Control the HTTP cache. Use behavior='bypass' so every request goes to the network — useful for performance measurement and for verifying a change that a cached asset would otherwise hide. Applies to the selected tab unless scope='global'. Persists until set back to 'default'.", + annotations: { + readOnlyHint: false, + }, + inputSchema: { + type: 'object' as const, + properties: { + behavior: { + type: 'string', + enum: [...CACHE_BEHAVIORS], + description: "'bypass' to skip the cache, 'default' to restore normal caching", + }, + scope: { + type: 'string', + enum: ['tab', 'global'], + description: "'tab' (default) applies to the selected tab; 'global' applies browser-wide", + }, + }, + required: ['behavior'], + }, +}; + /** * Fetch a body without letting a missing facade method or transport error fail * the whole tool call. Absent support degrades to an 'unsupported' marker. @@ -576,11 +602,43 @@ export const handleGetNetworkRequest = defineToolHandler( } ); +export async function handleSetNetworkCache(args: unknown): Promise { + try { + const { behavior, scope } = (args as { behavior?: unknown; scope?: unknown }) || {}; + + // An unrecognised value is rejected rather than defaulting: silently caching + // when the caller asked to bypass would quietly invalidate their measurement. + if (!isCacheBehavior(behavior)) { + return errorResponse( + `behavior must be one of ${CACHE_BEHAVIORS.join(', ')} (got ${JSON.stringify(behavior)})` + ); + } + if (scope !== undefined && scope !== 'tab' && scope !== 'global') { + return errorResponse(`scope must be 'tab' or 'global' (got ${JSON.stringify(scope)})`); + } + + const isGlobal = scope === 'global'; + + const { getFirefox } = await import('../index.js'); + const firefox = await getFirefox(); + + await firefox.setCacheBehavior(behavior, { global: isGlobal }); + + return successResponse( + `cache ${behavior} (${isGlobal ? 'all tabs' : 'selected tab'})` + + (behavior === 'bypass' ? " — set behavior='default' to restore caching" : '') + ); + } catch (error) { + return errorResponse(error instanceof Error ? error : new Error(String(error))); + } +} + export const module = defineModule({ name: 'network', - description: 'List and inspect network requests.', + description: 'List and inspect network requests, and control the HTTP cache.', tools: [ [listNetworkRequestsTool, handleListNetworkRequests], [getNetworkRequestTool, handleGetNetworkRequest], + [setNetworkCacheTool, handleSetNetworkCache], ], }); diff --git a/tests/tools/network.test.ts b/tests/tools/network.test.ts index 38040bab..3ca4c0dc 100644 --- a/tests/tools/network.test.ts +++ b/tests/tools/network.test.ts @@ -6,7 +6,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { existsSync, readFileSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { listNetworkRequestsTool, getNetworkRequestTool } from '../../src/tools/network.js'; +import { + listNetworkRequestsTool, + getNetworkRequestTool, + setNetworkCacheTool, +} from '../../src/tools/network.js'; describe('Network Tools', () => { describe('Tool Definitions', () => { @@ -26,6 +30,77 @@ describe('Network Tools', () => { }); }); + describe('set_network_cache', () => { + let setCacheBehavior: ReturnType; + + beforeEach(() => { + setCacheBehavior = vi.fn().mockResolvedValue(undefined); + vi.doMock('../../src/index.js', () => ({ + args: {}, + getFirefox: vi.fn().mockResolvedValue({ setCacheBehavior }), + })); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + }); + + const textOf = (r: { content: Array<{ type: string; text?: string }> }) => + (r.content[0] as { type: 'text'; text: string }).text; + + it('exposes a behavior enum and an optional scope', () => { + expect(setNetworkCacheTool.name).toBe('set_network_cache'); + const { properties, required } = setNetworkCacheTool.inputSchema; + expect(properties?.behavior.enum).toEqual(['default', 'bypass']); + expect(properties?.scope.enum).toEqual(['tab', 'global']); + expect(required).toEqual(['behavior']); + expect(required).not.toContain('scope'); + }); + + it('scopes to the selected tab by default', async () => { + const { handleSetNetworkCache } = await import('../../src/tools/network.js'); + const result = await handleSetNetworkCache({ behavior: 'bypass' }); + + expect(setCacheBehavior).toHaveBeenCalledWith('bypass', { global: false }); + expect(textOf(result)).toContain('selected tab'); + }); + + it('applies browser-wide when scope is global', async () => { + const { handleSetNetworkCache } = await import('../../src/tools/network.js'); + const result = await handleSetNetworkCache({ behavior: 'bypass', scope: 'global' }); + + expect(setCacheBehavior).toHaveBeenCalledWith('bypass', { global: true }); + expect(textOf(result)).toContain('all tabs'); + }); + + it('tells the caller how to restore caching after a bypass', async () => { + const { handleSetNetworkCache } = await import('../../src/tools/network.js'); + const bypass = await handleSetNetworkCache({ behavior: 'bypass' }); + expect(textOf(bypass)).toContain("behavior='default'"); + + const restore = await handleSetNetworkCache({ behavior: 'default' }); + expect(setCacheBehavior).toHaveBeenLastCalledWith('default', { global: false }); + expect(textOf(restore)).not.toContain("behavior='default' to restore"); + }); + + it('rejects an unknown behavior instead of defaulting to cached', async () => { + const { handleSetNetworkCache } = await import('../../src/tools/network.js'); + const result = await handleSetNetworkCache({ behavior: 'disabled' }); + + expect(textOf(result)).toContain('behavior must be one of default, bypass'); + expect(setCacheBehavior).not.toHaveBeenCalled(); + }); + + it('rejects an unknown scope instead of silently using the tab', async () => { + const { handleSetNetworkCache } = await import('../../src/tools/network.js'); + const result = await handleSetNetworkCache({ behavior: 'bypass', scope: 'everything' }); + + expect(textOf(result)).toContain("scope must be 'tab' or 'global'"); + expect(setCacheBehavior).not.toHaveBeenCalled(); + }); + }); + describe('Schema Properties', () => { it('listNetworkRequestsTool should have filtering options', () => { const { properties } = listNetworkRequestsTool.inputSchema; From d4066a9d1d8d42184b355c0dced5cb35a7e826e9 Mon Sep 17 00:00:00 2001 From: Jeremy Schoemaker Date: Thu, 27 Aug 2026 15:47:34 -0500 Subject: [PATCH 2/3] Bug 2065545 - [firefox-devtools-mcp] Move cache behavior into src/firefox/cache.ts --- src/firefox/cache.ts | 41 +++++++++++++++++++++++++++++++++++++++++ src/firefox/index.ts | 38 ++++++++++++++------------------------ 2 files changed, 55 insertions(+), 24 deletions(-) create mode 100644 src/firefox/cache.ts diff --git a/src/firefox/cache.ts b/src/firefox/cache.ts new file mode 100644 index 00000000..9283c4c9 --- /dev/null +++ b/src/firefox/cache.ts @@ -0,0 +1,41 @@ +/** + * Network cache behaviour (WebDriver BiDi network.setCacheBehavior) + */ + +export type BiDiCommandFn = (method: string, params: Record) => Promise; + +/** + * WebDriver BiDi network.CacheBehavior. + * - "default": normal HTTP cache behaviour + * - "bypass": skip the cache, so every request goes to the network + */ +export const CACHE_BEHAVIORS = ['default', 'bypass'] as const; + +export type CacheBehavior = (typeof CACHE_BEHAVIORS)[number]; + +export function isCacheBehavior(value: unknown): value is CacheBehavior { + return CACHE_BEHAVIORS.includes(value as CacheBehavior); +} + +export class CacheManagement { + constructor( + private getCurrentContextId: () => string | null, + private sendBiDiCommand: BiDiCommandFn + ) {} + + async setCacheBehavior(behavior: CacheBehavior, options?: { global?: boolean }): Promise { + const params: Record = { cacheBehavior: behavior }; + + // Omitting `contexts` applies the behaviour globally; passing the current + // context scopes it to the selected tab, which is the default. + if (!options?.global) { + const contextId = this.getCurrentContextId(); + if (!contextId) { + throw new Error('Cannot set cache behavior: no browsing context ID'); + } + params.contexts = [contextId]; + } + + await this.sendBiDiCommand('network.setCacheBehavior', params); + } +} diff --git a/src/firefox/index.ts b/src/firefox/index.ts index d6d208bb..fe2ae0b8 100644 --- a/src/firefox/index.ts +++ b/src/firefox/index.ts @@ -12,20 +12,12 @@ import { ConsoleEvents, NetworkEvents, DebuggingEvents, DownloadEvents } from '. import type { NetworkBodyResult } from './events/network.js'; import { DomInteractions } from './dom.js'; import { PageManagement, type ReadinessState } from './pages.js'; +import { CacheManagement, CACHE_BEHAVIORS, isCacheBehavior, type CacheBehavior } from './cache.js'; import { SnapshotManager, type Snapshot, type SnapshotOptions } from './snapshot/index.js'; -/** - * WebDriver BiDi network.CacheBehavior. - * - "default": normal HTTP cache behaviour - * - "bypass": skip the cache, so every request goes to the network - */ -export const CACHE_BEHAVIORS = ['default', 'bypass'] as const; - -export type CacheBehavior = (typeof CACHE_BEHAVIORS)[number]; - -export function isCacheBehavior(value: unknown): value is CacheBehavior { - return CACHE_BEHAVIORS.includes(value as CacheBehavior); -} +// Re-exported for backward compatibility: src/tools/network.ts imports +// these cache types from firefox/index.js rather than firefox/cache.js. +export { CACHE_BEHAVIORS, isCacheBehavior, type CacheBehavior }; /** * Main Firefox Client facade @@ -40,6 +32,7 @@ export class FirefoxClient { private downloadEvents: DownloadEvents | null = null; private dom: DomInteractions | null = null; private pages: PageManagement | null = null; + private cache: CacheManagement | null = null; private snapshot: SnapshotManager | null = null; constructor(options: FirefoxLaunchOptions) { @@ -114,6 +107,11 @@ export class FirefoxClient { (id: string) => this.core.setCurrentContextId(id), (method: string, params: Record) => this.getBidi().sendCommand(method, params) ); + + this.cache = new CacheManagement( + () => this.core.getCurrentContextId(), + (method: string, params: Record) => this.getBidi().sendCommand(method, params) + ); } // ============================================================================ @@ -334,19 +332,10 @@ export class FirefoxClient { // ============================================================================ async setCacheBehavior(behavior: CacheBehavior, options?: { global?: boolean }): Promise { - const params: Record = { cacheBehavior: behavior }; - - // Omitting `contexts` applies the behaviour globally; passing the current - // context scopes it to the selected tab, which is the default. - if (!options?.global) { - const contextId = this.getCurrentContextId(); - if (!contextId) { - throw new Error('Cannot set cache behavior: no browsing context ID'); - } - params.contexts = [contextId]; + if (!this.cache) { + throw new Error('Not connected'); } - - await this.sendBiDiCommand('network.setCacheBehavior', params); + await this.cache.setCacheBehavior(behavior, options); } async startNetworkMonitoring(): Promise { @@ -616,6 +605,7 @@ export class FirefoxClient { this.downloadEvents = null; this.dom = null; this.pages = null; + this.cache = null; this.snapshot = null; } } From 92ab26d7eacaa53b0df3c5f6f3b487eabd831917 Mon Sep 17 00:00:00 2001 From: Jeremy Schoemaker Date: Thu, 27 Aug 2026 20:12:25 -0500 Subject: [PATCH 3/3] Bug 2065545 - [firefox-devtools-mcp] fix setNetworkCacheTool schema typing after rebase --- src/tools/network.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tools/network.ts b/src/tools/network.ts index 396d1be6..2bdb6414 100644 --- a/src/tools/network.ts +++ b/src/tools/network.ts @@ -140,7 +140,7 @@ export const setNetworkCacheTool = { readOnlyHint: false, }, inputSchema: { - type: 'object' as const, + type: 'object', properties: { behavior: { type: 'string', @@ -155,7 +155,7 @@ export const setNetworkCacheTool = { }, required: ['behavior'], }, -}; +} satisfies ToolDefinition; /** * Fetch a body without letting a missing facade method or transport error fail