Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions src/firefox/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* Network cache behaviour (WebDriver BiDi network.setCacheBehavior)
*/

export type BiDiCommandFn = (method: string, params: Record<string, any>) => Promise<any>;

/**
* 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<void> {
const params: Record<string, unknown> = { 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);
}
}
19 changes: 19 additions & 0 deletions src/firefox/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,13 @@ 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';

// 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 };
Comment on lines +18 to +20

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure about the backward compatibility issue?

src/tools and src/firefox should always be in sync, so src/tools/network.ts can import from cache.ts directly?


/**
* Main Firefox Client facade
* Delegates to modular components for clean separation of concerns
Expand All @@ -27,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) {
Expand Down Expand Up @@ -101,6 +107,11 @@ export class FirefoxClient {
(id: string) => this.core.setCurrentContextId(id),
(method: string, params: Record<string, any>) => this.getBidi().sendCommand(method, params)
);

this.cache = new CacheManagement(
() => this.core.getCurrentContextId(),
(method: string, params: Record<string, any>) => this.getBidi().sendCommand(method, params)
);
}

// ============================================================================
Expand Down Expand Up @@ -320,6 +331,13 @@ export class FirefoxClient {
// Network
// ============================================================================

async setCacheBehavior(behavior: CacheBehavior, options?: { global?: boolean }): Promise<void> {
if (!this.cache) {
throw new Error('Not connected');
}
await this.cache.setCacheBehavior(behavior, options);
}

async startNetworkMonitoring(): Promise<void> {
if (!this.networkEvents) {
throw new Error(
Expand Down Expand Up @@ -587,6 +605,7 @@ export class FirefoxClient {
this.downloadEvents = null;
this.dom = null;
this.pages = null;
this.cache = null;
this.snapshot = null;
}
}
Expand Down
60 changes: 59 additions & 1 deletion src/tools/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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'.",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"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'.",
"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 currently selected tab unless scope='global'. Persists until set back to 'default' or Firefox shuts down.",

Minor modifications for the description. Should make it clear that the behavior will not "move" to another tab when it gets selected. Also the MCP can restart Firefox and the behavior would be lost at this point.

annotations: {
readOnlyHint: false,
},
inputSchema: {
type: 'object',
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'],
},
} satisfies ToolDefinition;

/**
* Fetch a body without letting a missing facade method or transport error fail
* the whole tool call. Absent support degrades to an 'unsupported' marker.
Expand Down Expand Up @@ -576,11 +602,43 @@ export const handleGetNetworkRequest = defineToolHandler(
}
);

export async function handleSetNetworkCache(args: unknown): Promise<McpToolResponse> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: I can update it in a follow up but since PR #171, we can use defineToolHandler to avoid the duplicated try / catch + error handling

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],
],
});
77 changes: 76 additions & 1 deletion tests/tools/network.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -26,6 +30,77 @@ describe('Network Tools', () => {
});
});

describe('set_network_cache', () => {
let setCacheBehavior: ReturnType<typeof vi.fn>;

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;
Expand Down