Skip to content

[MCP]: browser_take_screenshot resolves an explicit filename against the server cwd, not the output directory #42487

Closed

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:

  1. filename: "shot.png" writes to <cwd>/shot.png, not to the output directory.
  2. 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 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.

Activity

  1. theRizwan commented on Sep 1, 2026

    @theRizwan

    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 through workspaceFile(), which creates nothing, while the auto-named branch goes through outputFile(), which does mkdir -p on the parent. Creating the parent in the explicit branch too, after checkFile() has already run so it stays confined to the allowed roots, fixes it, and leaves resolveClientFilename() alone since that is also the read path for browser_file_upload, browser_drop, browser_set_storage_state and browser_run_code_unsafe.

    A bare name landing in the server cwd rather than --output-dir looks intended, not broken. An explicit name is resolved against the client workspace: an MCP root, _meta.cwd for 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", and roots.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:

    Prefer relative file names to stay within the output directory.

    on browser_take_screenshot and browser_pdf_save.

    I have both ready with a regression test that fails on main with 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.

  2. ystknsh commented on Sep 2, 2026

    @ystknsh
    Author

    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.png alone 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.

  3. theRizwan commented on Sep 2, 2026

    @theRizwan

    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 filename altogether, which is the branch that goes through outputFile(). 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-dir points.

    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 main with the ENOENT above, and license/cla is green. Could it be assigned to me? Equally happy for it to be closed if the core team would rather own this one.

  4. dgozman commented on Sep 3, 2026

    @dgozman
    Collaborator

    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.

  5. dgozman commented on Sep 3, 2026

    @dgozman
    Collaborator

    Closing as per above.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions