Skip to content

Instantly share code, notes, and snippets.

@monotykamary
Last active December 10, 2025 08:45
Show Gist options
  • Select an option

  • Save monotykamary/24b7d38a2688f742b48ed34aec45209d to your computer and use it in GitHub Desktop.

Select an option

Save monotykamary/24b7d38a2688f742b48ed34aec45209d to your computer and use it in GitHub Desktop.
Playwright MCP Context Optimization Guide - Reduce action tool responses by 86-100% with real measurements

Playwright MCP Context Optimization Guide

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%.

Problem Statement

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).

Solution Overview

Three optimizations that work together:

  1. return_snapshot parameter - Skip returning snapshots for action tools (default: false)
  2. Block tracker origins - Block ~130 ad/tracker/cookie-consent domains
  3. Truncate large responses - Cap snapshot size at 100KB

Implementation Options

Option A: Application-Level Filtering (Recommended)

Filter responses in your application after calling Playwright MCP. Best for frameworks like Mastra, LangChain, etc.

Option B: Proxy Server

Run a proxy that wraps Playwright MCP. Adds process overhead but requires no app changes.

Option C: Vendor/Fork

Copy and modify the Playwright MCP source. Most control but highest maintenance.


Option A: Application-Level Filtering

Core Filtering Logic

// 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);
}

Default Blocked Origins

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 ?? []),
  ];
}

Usage with Mastra

// 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
// });

Option B: Proxy Server

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-specific options

--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

How it works

  1. Proxy spawns upstream Playwright MCP with --blocked-origins injected
  2. Intercepts listTools to inject return_snapshot parameter
  3. Intercepts callTool to filter responses based on return_snapshot
  4. Truncates oversized responses

Configuration Reference

Tool Behavior

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

Default Settings

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

Testing

// 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');
  });
});

Why This Works: LLMs Still Get Snapshots When Needed

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.

When Snapshots ARE Returned (Always)

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

When Snapshots Are Skipped (With Optimization)

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.

The Practical Workflow

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

Why This Doesn't Break Automation

  1. 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.

  2. 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.

  3. 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.

  4. Navigation always shows state - Navigating to a new page automatically returns the snapshot, so the LLM always knows what's on a new page.

Context Savings Example

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.


Measured Context Reduction

Real measurements taken on December 10, 2025 using Playwright MCP:

Navigation Response Sizes

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.

Action Tool Response Sizes (Click)

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%

Key Findings

  1. 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
  2. return_snapshot: false on 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
  3. 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

Recommendations

  • Always use return_snapshot: false for action tools unless you need to verify the result
  • Use browser_snapshot explicitly 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: 102400 to prevent outlier pages from consuming excessive context

References

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment