I hit this through Claude Code, then reduced it to a direct stdio client for the repro below, so it does not depend on a particular MCP client.
Summary
The filename parameter of browser_take_screenshot is documented as
File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg|webp}`
if not specified. Prefer relative file names to stay within the output directory.
A relative file name does not stay within the output directory. It is resolved against the server's working directory, and --output-dir has no effect on it. Two symptoms follow from the same cause:
filename: "shot.png" writes to <cwd>/shot.png, not to the output directory.
filename: "sub/shot.png" fails with ENOENT, because the parent directory is not created.
A screenshot taken with no filename behaves as documented: it goes to the output directory, and its parent directory is created.
This is not a sandbox escape. The allowed-roots check does run for an explicit file name, and an absolute path outside both the cwd and the output directory is rejected with File access denied: ... is outside allowed roots.
The practical impact is on agent clients. A model that reads "prefer relative file names" will pass a bare name, and the file lands wherever the server was started. For coding agents that directory is the repository the user is working in, so each screenshot leaves an untracked file behind. The tool result reports the path relative to that same directory, as ./shot.png, so nothing in the response distinguishes it from the documented destination.
Repro
mkdir -p /tmp/probe-cwd /tmp/probe-out
cd /tmp/probe-cwd
npx @playwright/mcp@0.0.80 --isolated --headless --output-dir /tmp/probe-out
/tmp/probe-cwd is empty and /tmp/probe-cwd/sub does not exist. No allowUnrestrictedFileAccess and no skillMode. Driven over stdio from a small MCP client, issuing these calls in order:
browser_navigate { "url": "about:blank" }
browser_take_screenshot { "filename": "shot.png" }
browser_take_screenshot { "filename": "sub/shot.png" }
browser_take_screenshot { }
Then:
find /tmp/probe-cwd /tmp/probe-out -maxdepth 2 -type f
Observed:
| call |
result |
filename: "shot.png" |
written to /tmp/probe-cwd/shot.png |
filename: "sub/shot.png" |
Error: ENOENT: no such file or directory, open '/tmp/probe-cwd/sub/shot.png' |
no filename |
written to /tmp/probe-out/page-<timestamp>.png |
Running the same sequence without --output-dir writes shot.png beside the server's cwd while the auto-named screenshot goes to .playwright-mcp/, so the two forms disagree about where the output directory is.
Cause
From playwright-core/lib/coreBundle.js as shipped. The snippets below are verbatim except that the bundler's import aliases are written back as path and fs for readability.
resolveClientFile (packages/playwright-core/src/tools/backend/response.ts) branches on whether the caller supplied a name:
async resolveClientFile(template, title) {
let fileName;
if (template.suggestedFilename)
fileName = await this.resolveClientFilename(template.suggestedFilename);
else
fileName = await this._context.outputFile(template, { origin: "llm" });
...
}
resolveClientFilename reaches workspaceFile, which resolves against a workspace directory and does not create the parent:
async function workspaceFile(options, fileName, perCallWorkspaceDir) {
const workspace = perCallWorkspaceDir ?? options.cwd;
const resolvedName = path.resolve(workspace, fileName);
await checkFile(options, resolvedName, { origin: "llm" });
return resolvedName;
}
On this path the workspace comes from the response object, which defaults to the server's cwd:
this._clientWorkspace = options?.relativeTo ?? context.options.cwd;
The other branch, outputFile, does both of the things the documentation describes:
async function outputFile(options, fileName, flags) {
const resolvedFile = path.resolve(outputDir(options), fileName);
await checkFile(options, resolvedFile, flags);
await fs.promises.mkdir(path.dirname(resolvedFile), { recursive: true });
...
}
One note for anyone reading the tool result while debugging this: the await page.screenshot({ path: './shot.png' }) line in the response is produced by addCode for display. The image is captured in memory and written through the path above, so that line does not show where the file goes.
Possible fixes
Either would resolve it, and they are alternatives rather than steps:
- Route a relative
suggestedFilename through outputFile, so it lands in the output directory and its parent is created, keeping the absolute-path case on workspaceFile. This makes the behaviour match the current description.
- Or change the description to say that a relative file name is resolved against the server's working directory, and that parent directories are not created.
The first seems preferable for agent clients, because it makes the safe destination the default for a model that passes a bare name, but that is a call for the maintainers.
Related
microsoft/playwright-mcp#1253 looks similar but is a different problem. It was closed after the reporter confirmed the cause was unescaped backslashes on Windows. Its description also states that a relative file name is saved into --output-dir, which does not match the behaviour above.
I first filed this at microsoft/playwright-mcp#1731 before I saw the notice to file here; that one is closed.
Version
@playwright/mcp 0.0.80 (playwright-core 1.63.0-alpha-2026-08-31), macOS 26.5.1 arm64, Node v22.22.2.
I hit this through Claude Code, then reduced it to a direct stdio client for the repro below, so it does not depend on a particular MCP client.
Summary
The
filenameparameter ofbrowser_take_screenshotis documented asA relative file name does not stay within the output directory. It is resolved against the server's working directory, and
--output-dirhas no effect on it. Two symptoms follow from the same cause:filename: "shot.png"writes to<cwd>/shot.png, not to the output directory.filename: "sub/shot.png"fails with ENOENT, because the parent directory is not created.A screenshot taken with no
filenamebehaves as documented: it goes to the output directory, and its parent directory is created.This is not a sandbox escape. The allowed-roots check does run for an explicit file name, and an absolute path outside both the cwd and the output directory is rejected with
File access denied: ... is outside allowed roots.The practical impact is on agent clients. A model that reads "prefer relative file names" will pass a bare name, and the file lands wherever the server was started. For coding agents that directory is the repository the user is working in, so each screenshot leaves an untracked file behind. The tool result reports the path relative to that same directory, as
./shot.png, so nothing in the response distinguishes it from the documented destination.Repro
/tmp/probe-cwdis empty and/tmp/probe-cwd/subdoes not exist. NoallowUnrestrictedFileAccessand noskillMode. Driven over stdio from a small MCP client, issuing these calls in order:Then:
Observed:
filename: "shot.png"/tmp/probe-cwd/shot.pngfilename: "sub/shot.png"Error: ENOENT: no such file or directory, open '/tmp/probe-cwd/sub/shot.png'filename/tmp/probe-out/page-<timestamp>.pngRunning the same sequence without
--output-dirwritesshot.pngbeside the server's cwd while the auto-named screenshot goes to.playwright-mcp/, so the two forms disagree about where the output directory is.Cause
From
playwright-core/lib/coreBundle.jsas shipped. The snippets below are verbatim except that the bundler's import aliases are written back aspathandfsfor readability.resolveClientFile(packages/playwright-core/src/tools/backend/response.ts) branches on whether the caller supplied a name:resolveClientFilenamereachesworkspaceFile, which resolves against a workspace directory and does not create the parent:On this path the workspace comes from the response object, which defaults to the server's cwd:
The other branch,
outputFile, does both of the things the documentation describes:One note for anyone reading the tool result while debugging this: the
await page.screenshot({ path: './shot.png' })line in the response is produced byaddCodefor display. The image is captured in memory and written through the path above, so that line does not show where the file goes.Possible fixes
Either would resolve it, and they are alternatives rather than steps:
suggestedFilenamethroughoutputFile, so it lands in the output directory and its parent is created, keeping the absolute-path case onworkspaceFile. This makes the behaviour match the current description.The first seems preferable for agent clients, because it makes the safe destination the default for a model that passes a bare name, but that is a call for the maintainers.
Related
microsoft/playwright-mcp#1253 looks similar but is a different problem. It was closed after the reporter confirmed the cause was unescaped backslashes on Windows. Its description also states that a relative file name is saved into
--output-dir, which does not match the behaviour above.I first filed this at microsoft/playwright-mcp#1731 before I saw the notice to file here; that one is closed.
Version
@playwright/mcp0.0.80 (playwright-core1.63.0-alpha-2026-08-31), macOS 26.5.1 arm64, Node v22.22.2.