This document describes how to reduce context overflow when using Playwright MCP with LLMs. A single tool call can produce 400k+ tokens due to ads, trackers, cookie banners, and verbose page snapshots. These optimizations can reduce that by 80-90%.
Playwright MCP returns full page snapshots (accessibility tree in YAML format) after every action. This causes:
- Token overflow: 400k+ tokens per tool call on complex pages
- Wasted context: Ads, trackers, cookie banners inflate content
- Unnecessary data: Most actions don't need the resulting snapshot
Microsoft declined to fix this upstream (see issue #889).
Three optimizations that work together:
return_snapshotparameter - Skip returning snapshots for action tools (default: false)- Block tracker origins - Block ~130 ad/tracker/cookie-consent domains
- Truncate large responses - Cap snapshot size at 100KB
Filter responses in your application after calling Playwright MCP. Best for frameworks like Mastra, LangChain, etc.
Run a proxy that wraps Playwright MCP. Adds process overhead but requires no app changes.
Copy and modify the Playwright MCP source. Most control but highest maintenance.
// response-filter.ts
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
/**
* Tools that perform actions and can optionally return snapshots.
*/
export const ACTION_TOOLS = new Set([
// Snapshot/interaction tools
'browser_click',
'browser_drag',
'browser_hover',
'browser_select_option',
// Keyboard tools
'browser_press_key',
'browser_type',
// Mouse tools (vision mode)
'browser_mouse_move_xy',
'browser_mouse_click_xy',
'browser_mouse_drag_xy',
// Other action tools
'browser_wait_for',
'browser_evaluate',
'browser_handle_dialog',
'browser_file_upload',
]);
/**
* Tools that always return snapshots (don't filter these).
*/
export const SNAPSHOT_TOOLS = new Set([
'browser_navigate',
'browser_navigate_back',
'browser_navigate_forward',
'browser_snapshot',
]);
export interface FilterOptions {
/** Whether to include the page snapshot in the response */
returnSnapshot: boolean;
/** Maximum response size in bytes before truncation */
maxSnapshotSize: number;
}
/**
* Filter and transform a tool response.
*/
export function filterResponse(
result: CallToolResult,
options: FilterOptions
): CallToolResult {
const content = result.content.map(part => {
if (part.type !== 'text') return part;
let text = part.text;
// Remove page state section if not returning snapshot
if (!options.returnSnapshot) {
text = removePageStateSection(text);
}
// Truncate if too large
if (text.length > options.maxSnapshotSize) {
text = truncateText(text, options.maxSnapshotSize);
}
return { ...part, text };
});
return { ...result, content };
}
/**
* Remove the "Page state" section from response text.
* Format:
* ### Page state
* - Page URL: ...
* - Page title: ...
* - Page snapshot
* ```yaml
* ...
* ```
*/
function removePageStateSection(text: string): string {
const pageStateRegex = /### Page state\n[\s\S]*?(?=### |\n*$)/;
return text.replace(pageStateRegex, '').trim();
}
/**
* Truncate text to maxSize, breaking at a clean line boundary.
*/
function truncateText(text: string, maxSize: number): string {
if (text.length <= maxSize) return text;
const truncated = text.substring(0, maxSize);
const lastNewline = truncated.lastIndexOf('\n');
const breakPoint = lastNewline > maxSize * 0.8 ? lastNewline : maxSize;
return (
text.substring(0, breakPoint) +
`\n\n[TRUNCATED: Response exceeded ${maxSize} bytes (${text.length} total)]`
);
}
/**
* Check if a tool is an action tool that supports return_snapshot.
*/
export function isActionTool(toolName: string): boolean {
return ACTION_TOOLS.has(toolName);
}
/**
* Check if a tool always returns snapshots.
*/
export function isSnapshotTool(toolName: string): boolean {
return SNAPSHOT_TOOLS.has(toolName);
}Pass these to Playwright MCP via --blocked-origins or config:
// blocklist.ts
export const DEFAULT_BLOCKED_ORIGINS: string[] = [
// Google Analytics & Ads
'*.google-analytics.com',
'*.googleadservices.com',
'*.googlesyndication.com',
'*.doubleclick.net',
'*.googletagmanager.com',
'*.googletagservices.com',
'adservice.google.com',
'pagead2.googlesyndication.com',
// Facebook/Meta
'*.facebook.net',
'*.facebook.com/tr',
'*.fbcdn.net',
'connect.facebook.net',
'pixel.facebook.com',
// Twitter/X
'*.ads-twitter.com',
'analytics.twitter.com',
// Microsoft/LinkedIn
'*.clarity.ms',
'*.bing.com/bat',
'bat.bing.com',
'*.linkedin.com/px',
// Analytics platforms
'*.hotjar.com',
'*.hotjar.io',
'*.mixpanel.com',
'*.segment.io',
'*.segment.com',
'*.amplitude.com',
'*.fullstory.com',
'*.heap.io',
'*.heapanalytics.com',
'*.mouseflow.com',
'*.luckyorange.com',
'*.crazyegg.com',
'*.inspectlet.com',
'*.logrocket.com',
'*.smartlook.com',
'*.pendo.io',
'*.walkme.com',
'*.appcues.com',
'*.intercom.io',
'*.intercomcdn.com',
'*.drift.com',
'*.driftt.com',
// Ad networks
'*.criteo.com',
'*.criteo.net',
'*.taboola.com',
'*.outbrain.com',
'*.mgid.com',
'*.revcontent.com',
'*.adroll.com',
'*.quantserve.com',
'*.quantcount.com',
'*.adsrvr.org',
'*.demdex.net',
'*.bluekai.com',
'*.krxd.net',
'*.exelator.com',
'*.liveramp.com',
'*.rlcdn.com',
'*.casalemedia.com',
'*.pubmatic.com',
'*.rubiconproject.com',
'*.openx.net',
'*.indexww.com',
'*.bidswitch.net',
'*.sharethrough.com',
'*.amazon-adsystem.com',
'*.media.net',
'*.yieldmo.com',
'*.triplelift.com',
// Cookie consent / Privacy banners
'*.cookielaw.org',
'*.onetrust.com',
'*.cookiebot.com',
'*.trustarc.com',
'*.trustpilot.com',
'*.consentmanager.net',
'*.usercentrics.eu',
'*.iubenda.com',
'*.termly.io',
'*.secureprivacy.ai',
'*.osano.com',
'*.quantcast.com',
'cdn.cookielaw.org',
'consent.cookiebot.com',
// Error tracking (often large payloads)
'*.newrelic.com',
'*.nr-data.net',
'*.sentry.io',
'*.bugsnag.com',
'*.rollbar.com',
'*.datadoghq.com',
// A/B testing
'*.optimizely.com',
'*.launchdarkly.com',
'*.split.io',
'*.abtasty.com',
'*.vwo.com',
// Social widgets
'*.addthis.com',
'*.addtoany.com',
'*.sharethis.com',
// Chat widgets
'*.zendesk.com',
'*.zopim.com',
'*.tawk.to',
'*.livechatinc.com',
'*.olark.com',
'*.crisp.chat',
'*.freshchat.com',
'*.tidio.co',
];
/**
* Get blocked origins, optionally merging with user config.
*/
export function getBlockedOrigins(config?: {
blockTrackers?: boolean;
additionalBlockedOrigins?: string[];
}): string[] {
if (config?.blockTrackers === false) return [];
return [
...DEFAULT_BLOCKED_ORIGINS,
...(config?.additionalBlockedOrigins ?? []),
];
}// mastra-playwright-wrapper.ts
import { MCPClient } from '@mastra/mcp';
import {
filterResponse,
isActionTool,
isSnapshotTool,
ACTION_TOOLS,
} from './response-filter';
import { getBlockedOrigins } from './blocklist';
interface OptimizedConfig {
/** Skip snapshots for action tools by default */
defaultReturnSnapshot?: boolean;
/** Max response size before truncation */
maxSnapshotSize?: number;
/** Block tracker domains */
blockTrackers?: boolean;
/** Additional domains to block */
additionalBlockedOrigins?: string[];
}
const DEFAULT_CONFIG: Required<OptimizedConfig> = {
defaultReturnSnapshot: false,
maxSnapshotSize: 102400, // 100KB
blockTrackers: true,
additionalBlockedOrigins: [],
};
export class OptimizedPlaywrightMCP {
private client: MCPClient;
private config: Required<OptimizedConfig>;
constructor(client: MCPClient, config?: OptimizedConfig) {
this.client = client;
this.config = { ...DEFAULT_CONFIG, ...config };
}
/**
* Get MCP server args with blocked origins injected.
*/
static getServerArgs(config?: OptimizedConfig): string[] {
const blockedOrigins = getBlockedOrigins({
blockTrackers: config?.blockTrackers ?? true,
additionalBlockedOrigins: config?.additionalBlockedOrigins,
});
if (blockedOrigins.length === 0) return [];
return [`--blocked-origins=${blockedOrigins.join(';')}`];
}
/**
* List tools with return_snapshot parameter injected.
*/
async listTools() {
const { tools } = await this.client.listTools();
return tools.map(tool => {
if (!ACTION_TOOLS.has(tool.name)) return tool;
// Inject return_snapshot parameter
const inputSchema = tool.inputSchema as any;
return {
...tool,
inputSchema: {
...inputSchema,
properties: {
...inputSchema.properties,
return_snapshot: {
type: 'boolean',
default: this.config.defaultReturnSnapshot,
description:
'Return page snapshot after action. Default: false to reduce context size.',
},
},
},
};
});
}
/**
* Call tool with response filtering.
*/
async callTool(name: string, args: Record<string, any>) {
// Extract return_snapshot before forwarding
const returnSnapshot =
args.return_snapshot ?? this.config.defaultReturnSnapshot;
const cleanArgs = { ...args };
delete cleanArgs.return_snapshot;
// Call upstream
const result = await this.client.callTool({
name,
arguments: cleanArgs,
});
// Filter response for action tools
if (isActionTool(name)) {
return filterResponse(result, {
returnSnapshot,
maxSnapshotSize: this.config.maxSnapshotSize,
});
}
// For snapshot/navigation tools, apply truncation only
return filterResponse(result, {
returnSnapshot: true,
maxSnapshotSize: this.config.maxSnapshotSize,
});
}
}
// Example usage:
//
// const serverArgs = OptimizedPlaywrightMCP.getServerArgs();
// const mcpClient = new MCPClient({
// command: 'npx',
// args: ['@playwright/mcp', ...serverArgs],
// });
//
// const playwright = new OptimizedPlaywrightMCP(mcpClient);
// const tools = await playwright.listTools();
// const result = await playwright.callTool('browser_click', {
// element: 'Submit button',
// ref: 'e2',
// // return_snapshot: true // Only if you need the snapshot
// });If you prefer not to modify your application code, use the proxy server approach:
# Install
npm install @playwright/mcp
# Run in proxy mode
npx @playwright/mcp --proxy
# Or via environment variable
PLAYWRIGHT_MCP_PROXY=1 npx @playwright/mcp--proxy # Enable proxy mode
--no-block-trackers # Disable default tracker blocking
--max-snapshot-size=102400 # Max snapshot size (default: 100KB)
--default-return-snapshot # Make return_snapshot default to true
--additional-blocked-origins=a.com;b.com # Extra domains to block- Proxy spawns upstream Playwright MCP with
--blocked-originsinjected - Intercepts
listToolsto injectreturn_snapshotparameter - Intercepts
callToolto filter responses based onreturn_snapshot - Truncates oversized responses
| Tool | Category | Returns Snapshot |
|---|---|---|
browser_navigate |
Navigation | Always |
browser_navigate_back |
Navigation | Always |
browser_navigate_forward |
Navigation | Always |
browser_snapshot |
Snapshot | Always |
browser_click |
Action | Only if return_snapshot: true |
browser_type |
Action | Only if return_snapshot: true |
browser_press_key |
Action | Only if return_snapshot: true |
browser_hover |
Action | Only if return_snapshot: true |
browser_drag |
Action | Only if return_snapshot: true |
browser_select_option |
Action | Only if return_snapshot: true |
browser_evaluate |
Action | Only if return_snapshot: true |
browser_wait_for |
Action | Only if return_snapshot: true |
browser_handle_dialog |
Action | Only if return_snapshot: true |
browser_file_upload |
Action | Only if return_snapshot: true |
browser_mouse_* |
Action (vision) | Only if return_snapshot: true |
| Setting | Default | Description |
|---|---|---|
defaultReturnSnapshot |
false |
Whether action tools return snapshots |
maxSnapshotSize |
102400 (100KB) |
Max response size before truncation |
blockTrackers |
true |
Block ~130 ad/tracker domains |
// test/optimization.spec.ts
import { filterResponse, isActionTool } from './response-filter';
describe('Response filtering', () => {
it('removes page state when returnSnapshot is false', () => {
const result = {
content: [{
type: 'text',
text: '### Ran Playwright code\n```js\nawait page.click();\n```\n\n### Page state\n- Page URL: https://example.com\n- Page snapshot\n```yaml\n- button "Submit"\n```',
}],
};
const filtered = filterResponse(result, {
returnSnapshot: false,
maxSnapshotSize: 100000,
});
expect(filtered.content[0].text).not.toContain('### Page state');
expect(filtered.content[0].text).toContain('### Ran Playwright code');
});
it('keeps page state when returnSnapshot is true', () => {
const result = {
content: [{
type: 'text',
text: '### Page state\n- Page URL: https://example.com',
}],
};
const filtered = filterResponse(result, {
returnSnapshot: true,
maxSnapshotSize: 100000,
});
expect(filtered.content[0].text).toContain('### Page state');
});
it('truncates oversized responses', () => {
const largeText = 'x'.repeat(200000);
const result = {
content: [{ type: 'text', text: largeText }],
};
const filtered = filterResponse(result, {
returnSnapshot: true,
maxSnapshotSize: 1000,
});
expect(filtered.content[0].text.length).toBeLessThan(1100);
expect(filtered.content[0].text).toContain('[TRUNCATED');
});
});A common concern: "Without the YAML accessibility tree, LLMs can't do browser automation, right?"
The answer is nuanced. LLMs do need the snapshot to know what elements exist on a page, but they don't need it returned after every single action.
These tools always return the full page snapshot:
| Tool | Why |
|---|---|
browser_navigate |
LLM needs to see what's on the new page |
browser_navigate_back |
Page changed, need new snapshot |
browser_navigate_forward |
Page changed, need new snapshot |
browser_snapshot |
Explicit request for current state |
These tools return only a brief confirmation by default:
browser_click→ "Clicked button"browser_type→ "Typed 'hello' into input"browser_press_key→ "Pressed Enter"- etc.
LLM: browser_navigate("https://example.com")
→ Gets full YAML snapshot (57KB)
→ LLM now knows: button[ref="e5"], input[ref="e12"], link[ref="e23"]
LLM: browser_click(ref="e5")
→ Gets: "Clicked button"
→ NO snapshot returned (saves 57KB)
LLM: browser_type(ref="e12", text="hello")
→ Gets: "Typed 'hello' into input"
→ NO snapshot returned (saves another 57KB)
LLM: browser_click(ref="e23")
→ Gets: "Clicked link"
→ NO snapshot (page might navigate...)
LLM: browser_snapshot() ← If LLM needs to see the page again
→ Gets full snapshot of current state
-
Element refs are stable - The
ref="e5"from the initial snapshot remains valid until the page DOM changes significantly. Clicking a button doesn't invalidate other refs. -
LLMs remember context - After seeing the snapshot once, the LLM knows what elements exist and their refs. It doesn't need to re-read the entire page after every keystroke.
-
Explicit refresh when needed - If the LLM is uncertain about the current page state (after a form submission, AJAX update, etc.), it can explicitly call
browser_snapshot. -
Navigation always shows state - Navigating to a new page automatically returns the snapshot, so the LLM always knows what's on a new page.
Consider filling out a login form:
| Step | Without Optimization | With Optimization |
|---|---|---|
| Navigate to login | 50KB | 50KB |
| Click username field | 50KB | 0.2KB |
| Type username | 50KB | 0.2KB |
| Click password field | 50KB | 0.2KB |
| Type password | 50KB | 0.2KB |
| Click submit | 50KB | 0.2KB |
| Navigate (redirect) | 50KB | 50KB |
| Total | 350KB | 101KB |
That's a 71% reduction in context usage for a simple login flow, with no loss of functionality.
Real measurements taken on December 10, 2025 using Playwright MCP:
| Site | Type | Baseline | With Tracker Blocking | Reduction |
|---|---|---|---|---|
| Hacker News | Simple | 57.4 KB | 57.4 KB | 0% |
| CNN | News (heavy ads) | 88.1 KB | 86.5 KB | 2% |
| Amazon | E-commerce | 775 B* | 775 B | 0% |
| GitHub | SPA | 29.9 KB | 29.9 KB | 0% |
| Wikipedia | Content site | 111.1 KB | 100.1 KB | 10% |
*Amazon showed a small navigation response likely due to CAPTCHA/redirect page.
| Site | Baseline Click | Optimized Click (return_snapshot: false) |
Reduction |
|---|---|---|---|
| Hacker News | 109 B | 108 B | 1% |
| CNN | 2.1 KB | 304 B | 86% |
| Amazon | 79.3 KB | 268 B | 100% |
| GitHub | 1.3 KB | 66 B | 95% |
| Wikipedia | 1.3 KB | 1.3 KB | 0% |
-
Tracker blocking has minimal impact on navigation responses (0-10% reduction)
- Most tracking scripts load asynchronously after initial page render
- The accessibility snapshot is taken before trackers fully load
- Blocking still reduces network requests and may improve page stability
-
return_snapshot: falseon action tools provides the biggest wins (86-100% reduction)- Action responses without snapshots are typically 66-304 bytes
- Action responses with snapshots can be 1.3KB-79KB
- This is where most context savings come from
-
Navigation snapshots are unavoidably large (29KB-111KB)
- You need the snapshot to know what elements exist on the page
- Truncation at 100KB helps cap worst-case scenarios
- Always use
return_snapshot: falsefor action tools unless you need to verify the result - Use
browser_snapshotexplicitly when you need to see page state after multiple actions - Tracker blocking is still useful for page stability and reducing cookie banners, even if context reduction is minimal
- Set
maxSnapshotSize: 102400to prevent outlier pages from consuming excessive context
- Playwright MCP Issue #889 - Original issue (declined by Microsoft)
- playwright4LLM fork - Alternative fork with similar optimizations
- MCP Protocol Spec - Model Context Protocol documentation