← Back to Blog

Give Your Agent a Browser with Playwright MCP (Without Handing Over Your Logins)

An agent that writes a signup form and never opens it is guessing. It will tell you validation works because the code looks right, and the field that breaks on Safari stays broken until a human clicks it.

Screenshot-driven browser agents fix that badly. The model gets a picture, guesses pixel coordinates, clicks the wrong button, then spends a few thousand tokens describing what it thinks it sees. Playwright MCP takes a different route: the page arrives as an accessibility tree with element refs, so the model acts on roles and names instead of coordinates.

This walkthrough installs Microsoft's Playwright MCP server, runs a real task against the TodoMVC demo, then tightens the defaults that matter. Those defaults are the part install guides usually skip, and they decide whether an autonomous agent can reach your logged-in admin panel.

Prerequisites

  • Node.js 20 or newer (node --version). The docs page asks for 20, the repo README still says 18, so treat 20 as the floor.
  • An MCP client: Claude Code, Codex CLI, VS Code with Copilot, Cursor, or Claude Desktop.
  • npx on PATH. Nothing else, the server downloads its own Chromium. If you would rather drive the Chrome you already have, pass --browser chrome.

Install it in your client

Claude Code, user scope so it is available in every project:

claude mcp add --scope user playwright -- npx -y @playwright/mcp@latest

Drop --scope user and the entry is local to the directory you ran the command from. That is the first thing people trip on: the server works in one repo and looks missing in the next.

Codex:

codex mcp add playwright npx "@playwright/mcp@latest"

VS Code:

code --add-mcp '{"name":"playwright","command":"npx","args":["@playwright/mcp@latest"]}'

Every other client reads the same JSON shape:

{
  "mcpServers": {
    "playwright": {
      "command": "npx",
      "args": ["@playwright/mcp@latest"]
    }
  }
}

Confirm it loaded with claude mcp list or /mcp inside the client. The current package is 0.0.83. On a team, pin the version instead of chasing @latest, because tool names and flags move between releases.

Run a task that proves it works

Ask for something with a visible end state:

Open https://demo.playwright.dev/todomvc, add three todos (buy coffee, ship the PR, sleep), then tell me how many items are in the list.

On navigation, the model gets a snapshot roughly like this:

- heading "todos" [level=1]
- textbox "What needs to be done?" [ref=e5]
- listitem:
  - checkbox "Toggle Todo" [ref=e10]
  - text: "Buy groceries"

The tool sequence is short: browser_navigate with the URL, browser_snapshot to read the tree, then browser_type with target set to e5, text set to the todo, and submit set to true. That last flag presses Enter for you, which saves one tool call per item.

Two things are worth noticing. First, refs come from the snapshot, not from CSS you wrote, so the model cannot invent a selector that does not exist. Second, a ref belongs to the snapshot it came from. When the page re-renders, take a fresh snapshot before the next click instead of reusing e5 from three steps ago.

Lock down the defaults

The out-of-the-box config is tuned for someone sitting in front of the browser. Most of it should change for an unattended agent.

The browser profile

By default the server keeps a persistent profile, so cookies and logins survive between sessions. On Linux it lives under ~/.cache/ms-playwright/mcp-{channel}-{workspace-hash}, and the workspace hash means each project gets its own. Nice for interactive use, awkward when an agent that logged into your staging admin at 10am still has that session at 6pm. A profile also accepts one browser instance at a time, so two clients pointed at the same workspace will fight over it.

For anything automated, keep the profile in memory:

"args": ["@playwright/mcp@latest", "--isolated"]

Authenticated flows without your main profile

Recording a storage state once is cleaner than letting the agent inherit everything. Open the app, log in by hand, close the window:

npx playwright codegen --save-storage=playwright/.auth/user.json https://app.example.com

That JSON holds cookies, localStorage, and IndexedDB for the session. Treat it like a password: add playwright/.auth/ to .gitignore before the file exists, and delete it when the task is done. Then hand it to an isolated browser:

"args": [
  "@playwright/mcp@latest",
  "--isolated",
  "--storage-state=playwright/.auth/user.json"
]

Origins

--blocked-origins and --allowed-origins control where the browser may send requests. The README says plainly that neither is a security boundary and that they do not affect redirects. A page that redirects off your target still loads. Use them to keep an agent on the site you pointed it at, and get real isolation from the network layer or a container.

Filesystem and code execution

By default, file access is limited to the workspace roots and file:// navigation is blocked. --allow-unrestricted-file-access turns both off. Leave it alone unless the task is genuinely about local HTML.

browser_run_code_unsafe runs a Playwright snippet inside the server process, and the README calls it RCE-equivalent. That is acceptable on your laptop with the browser in front of you. It is not acceptable for an agent that reads untrusted pages, because prompt injection plus that tool ends as a shell. If you need free-form code in automation, run the Docker image and keep the container on a throwaway network.

There is also --secrets, a dotenv file whose values get replaced in tool responses so the model does not read them. Useful, and explicitly a convenience rather than a security feature.

Keep the context small

A dashboard page can produce a snapshot big enough to eat your context window before the agent does anything useful. Tools built for that:

  • browser_find searches the current snapshot by text or regex and returns matching nodes with a few lines of context, which is far cheaper than pulling the whole tree.
  • browser_snapshot accepts depth when you only need the top of the tree, and filename to write the tree to disk instead of into the conversation.
  • --console-level error keeps browser_console_messages from returning debug noise.
  • --mobile renders a lighter page, and the docs point out that mobile pages usually cost fewer tokens.
  • --idle-timeout=300000 closes the browser after five minutes of no tool calls. The default in headless mode is an hour, and headed browsers never close on their own.

Run it as a shared server

If several tools should use one browser, start the server on a port:

npx @playwright/mcp@latest --port 8931 --headless

and point clients at http://localhost:8931/mcp:

{ "mcpServers": { "playwright": { "url": "http://localhost:8931/mcp" } } }

For CI, the published image runs headless Chromium only:

docker run -d -i --rm --init --pull=always \
  --entrypoint node \
  --name playwright \
  -p 8931:8931 \
  mcr.microsoft.com/playwright/mcp \
  /app/cli.js --headless --browser chromium --no-sandbox --port 8931 --host 0.0.0.0

--no-sandbox is there because Chromium's sandbox needs namespaces that many container runtimes restrict. It removes a protection layer, so only use it inside a container that is already isolated.

When this is the wrong tool

Playwright MCP gives judgment, not regression coverage. If a flow must pass on every deploy, write it as a Playwright test and run it in CI. Use the MCP server to explore, reproduce, and verify once, then promote the path into a test file.

For coding agents specifically, Microsoft now also ships Playwright CLI with skills, and their README argues CLI beats MCP on token efficiency because it avoids loading large tool schemas and verbose trees into context. The trade is state: MCP keeps a live browser session and lets the model reason iteratively over the same page, which matters for exploratory work and long flows.

Screenshots still have a place for visual bugs. Enable them with --caps vision and use browser_take_screenshot as evidence, not as the navigation mechanism. Screenshots cannot be acted on; the tool description says so directly.

Errors you will actually hit

  • Two clients against one workspace: the second browser refuses the profile lock. Start it with --isolated or give it a separate --user-data-dir.
  • Server present in one repo and absent in another: the entry was added at local scope. Re-add with --scope user.
  • Chromium exits immediately inside a container: usually the sandbox, see the Docker command above.
  • The agent clicks a ref that no longer exists: the DOM changed under it. Snapshot again, then act.
  • Origin filters do not stop a redirect out of your site: that is documented behavior. Block at the proxy with --proxy-server or at DNS.

Next steps

Put the safe flags into a project .mcp.json (or a shell alias for claude mcp add) so every session starts isolated with blocked origins, and nobody has to remember the flags. Then pick one flow the agent verified by hand and turn it into a Playwright test, so the next deploy checks it without an LLM in the loop.

References

Need Help Implementing This?

I help teams design and build scalable cloud infrastructure, DevOps pipelines, and production-grade systems.

Book a Free Consultation