Skip to content

Commit 007cff8

Browse files
committed
feat(mcp): add --output-max-size with post-response disk eviction (draft)
Alternative to #41021 implementing Pavel's suggestion: instead of intercepting writes in code with an OutputFile/OutputDir layer, let tools write whatever and prune the outputDir from disk after each tool call. After Response.serialize() builds its sections, _enforceOutputBudget recursively lists outputDir, sorts by mtime ascending, and unlinks oldest files until total <= outputMaxSize. Files written by the current Response are tracked in _writtenFiles and never evicted, so artifacts referenced in the response stay readable. Config: same outputMaxSize field, --output-max-size CLI flag, and PLAYWRIGHT_MCP_OUTPUT_MAX_SIZE env var as #41021. Trade-offs vs #41021: - ~74 lines added vs ~390; no new types or evictable plumbing. - Trace/sessionLog need no special-casing — their mtime bumps keep them young, naturally last to evict. - Sees externally-written files (downloads, trace bundles) — they count against the budget and evict by age. - Self-healing on external fs changes. - Cap can briefly exceed during a single tool call that writes a large artifact; #41021 pre-evicts so the cap is hard. - Weaker pin guarantee for session.md (recency-based, not flag-based).
1 parent 89e46b8 commit 007cff8

6 files changed

Lines changed: 73 additions & 0 deletions

File tree

packages/playwright-core/src/tools/backend/context.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export type ContextConfig = {
4545
blockedOrigins?: string[];
4646
};
4747
outputDir?: string;
48+
outputMaxSize?: number;
4849
outputMode?: 'file' | 'stdout';
4950
saveSession?: boolean;
5051
saveTrace?: boolean;

packages/playwright-core/src/tools/backend/response.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import debug from 'debug';
2121
import { renderModalStates } from './tab';
2222
import { scaleImageToFitMessage } from './screenshot';
2323

24+
import { outputDir as resolveOutputDir } from './context';
25+
2426
import type * as playwright from '../../..';
2527
import type { TabHeader } from './tab';
2628
import type { CallToolResult, ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
@@ -59,6 +61,7 @@ export class Response {
5961
private _imageResults: { data: Buffer, imageType: 'png' | 'jpeg' }[] = [];
6062
private _raw: boolean;
6163
private _json: boolean;
64+
private _writtenFiles = new Set<string>();
6265

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

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

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

@@ -225,6 +230,37 @@ export class Response {
225230
};
226231
}
227232

233+
private async _enforceOutputBudget(): Promise<void> {
234+
const maxSize = this._context.config.outputMaxSize;
235+
if (!maxSize)
236+
return;
237+
const dir = resolveOutputDir(this._context.options);
238+
let entries: { path: string, size: number, mtimeMs: number }[];
239+
try {
240+
entries = await listFilesRecursive(dir);
241+
} catch {
242+
return;
243+
}
244+
let total = 0;
245+
for (const e of entries)
246+
total += e.size;
247+
if (total <= maxSize)
248+
return;
249+
entries.sort((a, b) => a.mtimeMs - b.mtimeMs);
250+
for (const entry of entries) {
251+
if (total <= maxSize)
252+
break;
253+
if (this._writtenFiles.has(entry.path))
254+
continue;
255+
try {
256+
await fs.promises.unlink(entry.path);
257+
total -= entry.size;
258+
} catch (error) {
259+
requestDebug('output-budget unlink failed %s: %s', entry.path, error);
260+
}
261+
}
262+
}
263+
228264
private async _build(): Promise<Section[]> {
229265
const sections: Section[] = [];
230266
const addSection = (title: string, content: string[], codeframe?: 'yaml' | 'js') => {
@@ -329,6 +365,32 @@ function sanitizeUnicode(text: string): string {
329365
return text.toWellFormed?.() ?? text;
330366
}
331367

368+
async function listFilesRecursive(dir: string): Promise<{ path: string, size: number, mtimeMs: number }[]> {
369+
const result: { path: string, size: number, mtimeMs: number }[] = [];
370+
async function walk(current: string) {
371+
let entries: fs.Dirent[];
372+
try {
373+
entries = await fs.promises.readdir(current, { withFileTypes: true });
374+
} catch {
375+
return;
376+
}
377+
for (const entry of entries) {
378+
const full = path.join(current, entry.name);
379+
if (entry.isDirectory()) {
380+
await walk(full);
381+
} else if (entry.isFile()) {
382+
try {
383+
const stat = await fs.promises.stat(full);
384+
result.push({ path: full, size: stat.size, mtimeMs: stat.mtimeMs });
385+
} catch {
386+
}
387+
}
388+
}
389+
}
390+
await walk(dir);
391+
return result;
392+
}
393+
332394
function parseSections(text: string): Map<string, string> {
333395
const sections = new Map<string, string>();
334396
const sectionHeaders = text.split(/^### /m).slice(1); // Remove empty first element

packages/playwright-core/src/tools/mcp/config.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,11 @@ export type Config = {
154154
*/
155155
outputDir?: string;
156156

157+
/**
158+
* Threshold for evicting old output files, in bytes.
159+
*/
160+
outputMaxSize?: number;
161+
157162
console?: {
158163
/**
159164
* The level of console messages to return. Each level includes the messages of more severe levels. Defaults to "info".

packages/playwright-core/src/tools/mcp/config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export type CLIOptions = {
6060
imageResponses?: 'allow' | 'omit';
6161
sandbox?: boolean;
6262
outputDir?: string;
63+
outputMaxSize?: number;
6364
port?: number;
6465
proxyBypass?: string;
6566
proxyServer?: string;
@@ -354,6 +355,7 @@ function configFromCLIOptions(cliOptions: CLIOptions): Config & { configFile?: s
354355
sharedBrowserContext: cliOptions.sharedBrowserContext,
355356
snapshot: cliOptions.snapshotMode ? { mode: cliOptions.snapshotMode } : undefined,
356357
outputDir: cliOptions.outputDir,
358+
outputMaxSize: cliOptions.outputMaxSize,
357359
imageResponses: cliOptions.imageResponses,
358360
testIdAttribute: cliOptions.testIdAttribute,
359361
timeouts: {
@@ -399,6 +401,7 @@ export function configFromEnv(env?: NodeJS.ProcessEnv): Config & { configFile?:
399401
options.imageResponses = enumParser<'allow' | 'omit'>('--image-responses', ['allow', 'omit'], e.PLAYWRIGHT_MCP_IMAGE_RESPONSES);
400402
options.sandbox = envToBoolean(e.PLAYWRIGHT_MCP_SANDBOX);
401403
options.outputDir = envToString(e.PLAYWRIGHT_MCP_OUTPUT_DIR);
404+
options.outputMaxSize = numberParser(e.PLAYWRIGHT_MCP_OUTPUT_MAX_SIZE);
402405
options.port = numberParser(e.PLAYWRIGHT_MCP_PORT);
403406
options.proxyBypass = envToString(e.PLAYWRIGHT_MCP_PROXY_BYPASS);
404407
options.proxyServer = envToString(e.PLAYWRIGHT_MCP_PROXY_SERVER);

packages/playwright-core/src/tools/mcp/configIni.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ const longhandTypes: Record<string, LonghandType> = {
161161
'saveVideo': 'size',
162162
'sharedBrowserContext': 'boolean',
163163
'outputDir': 'string',
164+
'outputMaxSize': 'number',
164165
'imageResponses': 'string',
165166
'allowUnrestrictedFileAccess': 'boolean',
166167
'codegen': 'string',

packages/playwright-core/src/tools/mcp/program.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export function decorateMCPCommand(command: Command) {
5959
.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']))
6060
.option('--no-sandbox', 'disable the sandbox for all process types that are normally sandboxed.')
6161
.option('--output-dir <path>', 'path to the directory for output files.')
62+
.option('--output-max-size <bytes>', 'Threshold for evicting old output files, in bytes.', numberParser)
6263
.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']))
6364
.option('--port <port>', 'port to listen on for SSE transport.')
6465
.option('--proxy-bypass <bypass>', 'comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com"')

0 commit comments

Comments
 (0)