Repository navigation
Description
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/[email protected] --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
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. - 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.
Activity
theRizwan commentedon Sep 1, 2026
I went through this on tip-of-tree and reproduced both symptoms. They have different answers, so it may be worth splitting them:
filename: "sub/shot.png"→ ENOENT is a plain bug.Response.resolveClientFile()sends an explicit name throughworkspaceFile(), which creates nothing, while the auto-named branch goes throughoutputFile(), which doesmkdir -pon the parent. Creating the parent in the explicit branch too, aftercheckFile()has already run so it stays confined to the allowed roots, fixes it, and leavesresolveClientFilename()alone since that is also the read path forbrowser_file_upload,browser_drop,browser_set_storage_stateandbrowser_run_code_unsafe.A bare name landing in the server cwd rather than
--output-dirlooks intended, not broken. An explicit name is resolved against the client workspace: an MCP root,_meta.cwdfor the CLI, otherwise the server's cwd. Three existing tests pin exactly that:screenshot.spec.ts"filename: "output.png"",storage.spec.ts"browser_storage_state saves to custom filename", androots.spec.ts"should return relative paths when root is specified". So on this half the thing that is wrong is the description, which is Yasutaka Nishii (@ystknsh)'s option 2:on
browser_take_screenshotandbrowser_pdf_save.I have both ready with a regression test that fails on
mainwith the ENOENT above. CONTRIBUTING asks for the issue to be assigned first, so: would you like me to send it? Happy to drop it if the core team would rather own this, or to redo it as option 1 (route relative names into the output directory) if that is the call you would prefer.ystknsh commentedon Sep 2, 2026
Thanks for reproducing both — that helps a lot.
Understood that a bare name resolving to the cwd is by design.
I would like screenshots taken by an agent to land in the output directory rather than at the repository root; that is all I am asking for. With the current description, "prefer relative file names to stay within the output directory" reads as if
shot.pngalone is enough, so the model writes exactly that. The new wording in #42494 — an explicit name is resolved against the working directory, not the output directory — says what the model needs to know.Thanks for the ENOENT fix as well. Splitting is not needed from my side now that #42494 covers both.
theRizwan commentedon Sep 2, 2026
Thanks for confirming, that settles the direction.
One thing worth being precise about, since it changes what you would tell your agent: correcting the description does not move an explicitly named screenshot into the output directory. It only stops the description from promising that it will. With the new wording, the way to land in the output directory is to omit
filenamealtogether, which is the branch that goes throughoutputFile(). An explicit name still resolves against the working directory, bare or not. If you want a chosen name and the output directory, the only route today is an absolute path into it, which means you have to know where--output-dirpoints.So for the practical goal of "agent screenshots should not litter the repository root", the doc change gets you there only insofar as the model reads it and omits the name. That may well be enough in practice, but it is a nudge rather than a guarantee. If it turns out not to be, a separate issue asking for a way to put a named artifact in the output directory would be the cleaner ask, and easier for the maintainers to weigh on its own.
For the maintainers: this issue is still unassigned, and CONTRIBUTING asks for assignment before a PR. #42494 is up with a regression test that fails on
mainwith the ENOENT above, andlicense/clais green. Could it be assigned to me? Equally happy for it to be closed if the core team would rather own this one.dgozman commentedon Sep 3, 2026
Explicitly passed file names are resolved against workspace root when available, with cwd as a fallback. This is by design. Output dir only controls automatically created files. I'll look into tool descriptions to make sure things are clear.
dgozman commentedon Sep 3, 2026
Closing as per above.
nix: reroute an explicit playwright filename into the artifacts dir