Skip to content
Merged
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
1 change: 1 addition & 0 deletions packages/playwright-core/src/tools/backend/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export type ContextConfig = {
blockedOrigins?: string[];
};
outputDir?: string;
outputMaxSize?: number;
outputMode?: 'file' | 'stdout';
saveSession?: boolean;
saveTrace?: boolean;
Expand Down
46 changes: 46 additions & 0 deletions packages/playwright-core/src/tools/backend/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import debug from 'debug';
import { renderModalStates } from './tab';
import { scaleImageToFitMessage } from './screenshot';

import { outputDir as resolveOutputDir } from './context';

import type * as playwright from '../../..';
import type { TabHeader } from './tab';
import type { CallToolResult, ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
Expand Down Expand Up @@ -59,6 +61,7 @@ export class Response {
private _imageResults: { data: Buffer, imageType: 'png' | 'jpeg' }[] = [];
private _raw: boolean;
private _json: boolean;
private _writtenFiles = new Set<string>();

constructor(context: Context, toolName: string, toolArgs: Record<string, any>, options?: { relativeTo?: string, raw?: boolean, json?: boolean }) {
this._context = context;
Expand Down Expand Up @@ -111,6 +114,7 @@ export class Response {
await fs.promises.writeFile(resolvedFile.fileName, this._redactSecrets(data), 'utf-8');
else if (data)
await fs.promises.writeFile(resolvedFile.fileName, data);
this._writtenFiles.add(path.resolve(resolvedFile.fileName));
}

async addFileResult(resolvedFile: ResolvedFile, data: Buffer | string | null) {
Expand Down Expand Up @@ -163,6 +167,7 @@ export class Response {

async serialize(): Promise<CallToolResult> {
const allSections = await this._build();
await this._enforceOutputBudget();
const rawSections = ['Error', 'Result', 'Snapshot'] as const;
const sections = this._raw ? allSections.filter(section => rawSections.includes(section.title as typeof rawSections[number])) : allSections;

Expand Down Expand Up @@ -225,6 +230,37 @@ export class Response {
};
}

private async _enforceOutputBudget(): Promise<void> {
const maxSize = this._context.config.outputMaxSize;
if (!maxSize)
return;
const dir = resolveOutputDir(this._context.options);
let entries: { path: string, size: number, mtimeMs: number }[];
try {
entries = await listFilesRecursive(dir);
} catch {
return;
}
let total = 0;
for (const e of entries)
total += e.size;
if (total <= maxSize)
return;
entries.sort((a, b) => a.mtimeMs - b.mtimeMs);
for (const entry of entries) {
if (total <= maxSize)
break;
if (this._writtenFiles.has(entry.path))
continue;
try {
await fs.promises.unlink(entry.path);
total -= entry.size;
} catch (error) {
requestDebug('output-budget unlink failed %s: %s', entry.path, error);
}
}
}

private async _build(): Promise<Section[]> {
const sections: Section[] = [];
const addSection = (title: string, content: string[], codeframe?: 'yaml' | 'js') => {
Expand Down Expand Up @@ -329,6 +365,16 @@ function sanitizeUnicode(text: string): string {
return text.toWellFormed?.() ?? text;
}

async function listFilesRecursive(dir: string): Promise<{ path: string, size: number, mtimeMs: number }[]> {
Comment on lines 367 to +368
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nit: we are already assuming Node 20 for MCP in other places, so can be shorter:

async function listFilesRecursive(dir: string) {
const entries = await fs.promises.readdir(dir, { recursive: true, withFileTypes: true });
const files = entries.filter(e => e.isFile());
return Promise.all(files.map(async e => {
const full = path.join(e.parentPath, e.name);
const { size, mtimeMs } = await fs.promises.stat(full);
return { path: full, size, mtimeMs };
}));
}

const entries = await fs.promises.readdir(dir, { recursive: true, withFileTypes: true });
const files = entries.filter(e => e.isFile());
return Promise.all(files.map(async e => {
const full = path.join(e.parentPath, e.name);
const { size, mtimeMs } = await fs.promises.stat(full);
return { path: full, size, mtimeMs };
}));
}

function parseSections(text: string): Map<string, string> {
const sections = new Map<string, string>();
const sectionHeaders = text.split(/^### /m).slice(1); // Remove empty first element
Expand Down
5 changes: 5 additions & 0 deletions packages/playwright-core/src/tools/mcp/config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,11 @@ export type Config = {
*/
outputDir?: string;

/**
* Threshold for evicting old output files, in bytes.
*/
outputMaxSize?: number;

console?: {
/**
* The level of console messages to return. Each level includes the messages of more severe levels. Defaults to "info".
Expand Down
3 changes: 3 additions & 0 deletions packages/playwright-core/src/tools/mcp/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export type CLIOptions = {
imageResponses?: 'allow' | 'omit';
sandbox?: boolean;
outputDir?: string;
outputMaxSize?: number;
port?: number;
proxyBypass?: string;
proxyServer?: string;
Expand Down Expand Up @@ -354,6 +355,7 @@ function configFromCLIOptions(cliOptions: CLIOptions): Config & { configFile?: s
sharedBrowserContext: cliOptions.sharedBrowserContext,
snapshot: cliOptions.snapshotMode ? { mode: cliOptions.snapshotMode } : undefined,
outputDir: cliOptions.outputDir,
outputMaxSize: cliOptions.outputMaxSize,
imageResponses: cliOptions.imageResponses,
testIdAttribute: cliOptions.testIdAttribute,
timeouts: {
Expand Down Expand Up @@ -399,6 +401,7 @@ export function configFromEnv(env?: NodeJS.ProcessEnv): Config & { configFile?:
options.imageResponses = enumParser<'allow' | 'omit'>('--image-responses', ['allow', 'omit'], e.PLAYWRIGHT_MCP_IMAGE_RESPONSES);
options.sandbox = envToBoolean(e.PLAYWRIGHT_MCP_SANDBOX);
options.outputDir = envToString(e.PLAYWRIGHT_MCP_OUTPUT_DIR);
options.outputMaxSize = numberParser(e.PLAYWRIGHT_MCP_OUTPUT_MAX_SIZE);
options.port = numberParser(e.PLAYWRIGHT_MCP_PORT);
options.proxyBypass = envToString(e.PLAYWRIGHT_MCP_PROXY_BYPASS);
options.proxyServer = envToString(e.PLAYWRIGHT_MCP_PROXY_SERVER);
Expand Down
1 change: 1 addition & 0 deletions packages/playwright-core/src/tools/mcp/configIni.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ const longhandTypes: Record<string, LonghandType> = {
'saveVideo': 'size',
'sharedBrowserContext': 'boolean',
'outputDir': 'string',
'outputMaxSize': 'number',
'imageResponses': 'string',
'allowUnrestrictedFileAccess': 'boolean',
'codegen': 'string',
Expand Down
1 change: 1 addition & 0 deletions packages/playwright-core/src/tools/mcp/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export function decorateMCPCommand(command: Command) {
.option('--image-responses <mode>', 'whether to send image responses to the client. Can be "allow" or "omit", Defaults to "allow".', enumParser.bind(null, '--image-responses', ['allow', 'omit']))
.option('--no-sandbox', 'disable the sandbox for all process types that are normally sandboxed.')
.option('--output-dir <path>', 'path to the directory for output files.')
.option('--output-max-size <bytes>', 'Threshold for evicting old output files, in bytes.', numberParser)
.option('--output-mode <mode>', 'whether to save snapshots, console messages, network logs to a file or to the standard output. Can be "file" or "stdout". Default is "stdout".', enumParser.bind(null, '--output-mode', ['file', 'stdout']))
.option('--port <port>', 'port to listen on for SSE transport.')
.option('--proxy-bypass <bypass>', 'comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com"')
Expand Down
71 changes: 71 additions & 0 deletions tests/mcp/output-max-size.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import fs from 'fs';
import path from 'path';

import { test, expect } from './fixtures';

test('evicts oldest evictable files before write exceeds cap, pinned session.md survives', async ({ startClient, server }, testInfo) => {
const outputDir = testInfo.outputPath('output');
const { client } = await startClient({
config: { outputDir, saveSession: true, outputMaxSize: 5_000 },
});

let n = 0;
server.setRoute('/download', (req, res) => {
res.writeHead(200, {
'Content-Type': 'application/octet-stream',
'Content-Disposition': `attachment; filename=file-${n++}.bin`,
});
res.end(Buffer.alloc(1000, 'x'));
});
server.setContent('/', `<!doctype html><body><a href="/download" download>D</a></body>`, 'text/html');

await client.callTool({ name: 'browser_navigate', arguments: { url: server.PREFIX } });
for (let i = 0; i < 8; i++) {
await client.callTool({ name: 'browser_click', arguments: { element: 'D', target: 'e2' } });
// Click returns before download.saveAs() completes; wait for the file before the next eviction.
await expect.poll(() => fs.existsSync(path.join(outputDir, `file-${i}.bin`))).toBe(true);
}
// One more tool call so eviction runs with all 8 downloads on disk.
await client.callTool({ name: 'browser_snapshot' });

const bins = fs.readdirSync(outputDir).filter(f => f.endsWith('.bin'));
expect(bins.length).toBeLessThan(8);
expect(bins.reduce((acc, f) => acc + fs.statSync(path.join(outputDir, f)).size, 0)).toBeLessThanOrEqual(5_000);

const sessionFolder = fs.readdirSync(outputDir).find(e => e.startsWith('session-'));
expect(sessionFolder).toBeTruthy();
expect(fs.existsSync(path.join(outputDir, sessionFolder!, 'session.md'))).toBe(true);
});

test('oversize single file evicts everything and still writes', async ({ startClient, server }, testInfo) => {
const outputDir = testInfo.outputPath('output');
const { client } = await startClient({
config: {
outputDir,
outputMaxSize: 100,
},
});

await client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD } });

await client.callTool({ name: 'browser_take_screenshot' });
await client.callTool({ name: 'browser_take_screenshot' });

expect(fs.readdirSync(outputDir)).toHaveLength(1);
});
Loading