Claude Code with Playwright MCP can open your app, navigate to the page and interact with its elements through structured accessibility snapshots. Then a test you wrote with its help passes on your laptop and fails in CI, and the log says only that a locator timed out.
Hand an agent that log and nothing else, and it tends to guess. It adds a wait, loosens the assertion or retries until the test passes, and the bug stays in the code.
TL;DR
To fix a Playwright test that fails in CI with Claude Code and Playwright MCP, give Claude what CI saw and make the fix prove itself.
- Commit a shared
.mcp.jsonthat starts the MCP browser the way CI starts your tests:--headless,--isolatedand--storage-statepointing at your suite's auth file. - Put the trace URL of a failed attempt and its error-context snapshot in the prompt, along with how CI ran the test and rules for the fix: no fixed waits, no extra retries, no looser assertions.
- Reproduce with the failing test itself (
npx playwright test --debug=cli) and take locators from the accessibility snapshot, by role and name. - Reject a diff that adds a wait, raises retries or weakens an assertion. Merge only when the unfixed test fails under
--repeat-eachand the fixed one passes every run.
Reproducing the failure in the MCP browser is harder than it looks. Playwright MCP starts a headed browser by default and keeps a persistent profile, so login state and cookies carry over between sessions. Playwright Test runs headless by default and loads the storage state your config gives it. A test can pass in the MCP browser because of a session you created by hand, with the bug still in place.
The fix is evidence instead of guesses: Claude Code works from what CI saw, and the change has to survive --repeat-each before it merges.
MCP, the Playwright CLI or the healer agent: pick by token budget
Playwright MCP keeps its tool schemas and snapshots in the model's context. The Playwright docs compare MCP with the Playwright CLI and rate MCP's token cost higher for that reason. The CLI's token cost is lower, because its output is concise and it loads skills on demand. The CLI is a command-line interface for browser automation built for coding agents, and its token-efficient commands and installable skills let an agent fit browser work next to a large codebase in a limited context window.
That difference decides where each tool belongs.
Anti-pattern
Keeping MCP attached for a long session in a large repository
Every snapshot takes room Claude needs for the source files it is trying to fix, and late in the session much of the window holds pages Claude has already finished with.
We'd pick like this:
- Use MCP for short, interactive exploration of a live page: what a screen shows, which role and name an element has, whether a few steps behave the way the test expects.
- Use the Playwright CLI when the same session also has to hold a large codebase or a long debugging run.
- Use the healer agent when you want failing tests repaired across the suite without you: Playwright's healer runs the test suite and repairs failing tests on its own.
Plain-English checks are where MCP earns its tokens. "Log in as the test user, apply the promo code and tell me what the total says" against a local or staging app takes a handful of snapshots, not a long session. A pass/fail answer from that session is still not a regression test. The test that guards the feature is a spec in the repository. For a side-by-side run of all of these on a real suite, see our comparison of MCP, the CLI, test agents and prompts.
None of the three belongs in the CI pipeline. CI should run npx playwright test, and MCP, the CLI and the healer stay on your machine for writing and debugging tests. Playwright Test runs headless by default, and so does the Playwright CLI; Playwright MCP runs headed unless you configure it otherwise.
If you pick the CLI for long sessions, install its skill for Claude Code. The Claude Code layout is the command's default:
playwright-cli install --skills
Add Playwright MCP to Claude Code and share it with the team
Register Playwright MCP once per repository, not once per engineer. The Playwright docs give the Claude Code command as claude mcp add playwright npx @playwright/mcp@latest. Add --scope project, and Claude Code writes the server into a .mcp.json file that you commit:
claude mcp add --scope project playwright npx @playwright/mcp@latest
The committed file holds one server entry:
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": ["@playwright/mcp@latest"]
}
}
}
With that file in the repository, the server is available in the project to everyone who clones it. A per-user setup drifts. One person logs in by hand, another runs headless, and the same prompt behaves differently on each machine.
The server is worth sharing for what it hands Claude. Playwright MCP works on the page's accessibility tree, not pixels. Each tool returns a structured snapshot of the page's elements, their roles and their text content. A locator Claude writes from that snapshot can name a role and text that exist on the page, instead of a CSS selector guessed from the source code.
Check the setup before you rely on it. Start your app locally, open Claude Code in the repository and name the server in your first prompt:
Using Playwright MCP, open http://localhost:3000 and describe the page from its accessibility snapshot: the headings, the main landmarks and every button with its accessible name.
An answer built from roles and names on the page means the server is connected and reading snapshots.
If the answer describes the page from your source files instead, or Claude says it can't open a browser, Claude is not working from snapshots. Don't start on a failing test until it is. Check your registration and install against the getting-started docs linked at the top of this section. That page gives the exact Claude Code command, so compare it with what you ran.
Start the MCP browser the way CI starts your tests
The usual way to start looks harmless.
Anti-pattern
Logging in by hand in the default MCP browser
You open the default MCP browser, log in by hand and let Claude explore. That browser is headed, and its persistent profile keeps your login and cookies from one session to the next. Claude writes a test against what it sees there, and the test passes. Then CI runs it headless, with only the storage state the config loads, and every step that leaned on your manual session breaks.
Start the MCP browser the way your tests start. Three arguments do it:
--isolatedturns on isolated mode, where each session starts fresh instead of reusing the persistent profile.--storage-stateloads an initial state into that fresh session. Point it at the file your tests load throughstorageState. ThestorageStateoption populates the context with a given storage state, and the option can initialize the context with logged-in information obtained viabrowserContext.storageState().--headless, from the docs' headless configuration, replaces the headed default.
Here is the shared .mcp.json with all three:
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": [
"@playwright/mcp@latest",
"--headless",
"--isolated",
"--storage-state",
"playwright/.auth/user.json"
]
}
}
}
The state file comes from whatever setup project saves your suite's storage state. If the suite logs in as several roles, point the server at the file for the role the failing test uses.
These args don't set a viewport. Playwright Test emulates a 1280x720 viewport for each page unless your config sets viewport to something else. When the failure depends on layout, or when viewport, headless mode and login all have to match exactly, reproduce with the test runner itself, as the reproduction step below does.
Commit the .mcp.json. Keep the saved browser state file out of the repository: Playwright strongly discourages checking it into private or public repositories. A saved browser state can hold cookies and headers that impersonate your test account, and Playwright strongly discourages checking it into any repository, private or public. Add the playwright/.auth directory to .gitignore, as the auth guide recommends. We'd also log the suite in as a dedicated test account so exploration never touches a real user's data.
Give Claude Code the failing run, not just the log
A CI log tells Claude which line failed, not what the page looked like when it did. Don't paste that log into Claude Code and ask for a fix. That's how you get the wait, the looser assertion or the extra retry.
Start with the config, because the trace you need may not exist. Playwright doesn't retry failing tests by default. The docs recommend recording traces on CI on the first retry with trace: 'on-first-retry', which produces a trace.zip for each test that was retried. With retries left at the default, that setting records nothing, since no test ever gets a first retry.
import { defineConfig } from '@playwright/test';
export default defineConfig({
// Retry only on CI: the first retry is what records the trace.
retries: process.env.CI ? 2 : 0,
use: {
trace: 'on-first-retry',
},
});
Retries here exist to produce a trace. They are not there to turn the run green.
Then hand the trace over by URL. Remote traces open directly from their URL, so nobody has to download the file from the CI run first. Put the URL of the failed test's trace in the prompt.
Add the error-context snapshot as well. Playwright's error context can include the aria snapshot of the receiver at the time of an expect(...) matcher failure. Playwright MCP's tools return the same kind of view: a structured snapshot of the page's accessibility tree. With both in hand, Claude can compare what CI saw when the test failed with what the page shows now.
Write the prompt in three parts: how CI ran the test, the artifacts from the failure, and the rules a fix must follow.
Using Playwright MCP, fix tests/cart.spec.ts, which fails in CI.
How CI ran it:
- npx playwright test, headless (the Playwright Test default)
- viewport 1280x720 (the default; playwright.config.ts doesn't override it)
- storage state from playwright/.auth/user.json, a dedicated test account
Failure artifacts:
- Error and stack: <paste from the CI log>
- Trace: <URL of the failed test's trace>
- Error context: <paste the error-context snapshot>
Rules for the fix:
- Find the cause in the test or the app before you change code.
- No page.waitForTimeout and no other fixed waits.
- Don't raise retries and don't loosen assertions.
- Take locators from the accessibility snapshot, by role and name.
- Show the change as a diff and stop.
The first part keeps Claude from debugging a different environment. The third keeps the bug from hiding behind a wait.
Reproduce the failure in a live browser
One pass in a headed browser, logged in differently from CI, is not enough to call a failure "not reproducible". That pass ran under other conditions, so it tells you nothing about the failure.
The closest match to CI is the failing test itself. The optional debug mode is inspector (the default) or cli, and cli allows attaching to the test and debugging it over the CLI. The Playwright CLI docs say to run it in the background: the test stays paused and holds the browser open while you work.
npx playwright test tests/cart.spec.ts --debug=cli
Ask Claude to start that command in the background, then attach over the Playwright CLI and inspect the paused page. By default, npx playwright test reads playwright.config.ts or playwright.config.js from the current directory, so the test gets the options set in that file's use section. --debug is a shortcut that includes --headed, so this run is headed.
For a wider look, have Claude replay the test's steps in the isolated, headless MCP browser you configured earlier and take a snapshot where the test failed. Compare that snapshot with the CI error context. A missing element, a different accessible name or an extra dialog in one of the two is where the fix starts.
Take locators from the snapshot's roles and names, not from a guess at the source. Then check that a user can reach the element. In a Playwright issue, a user reports that the MCP accessibility snapshot includes elements outside the viewport, such as footer elements, and that AI agents then generate test cases for UI a user can't reach. A locator that matches an element doesn't mean a user can see it.
Fix the root cause instead of adding waits or retries
A green run is the easiest thing for an agent to produce and the weakest proof of a fix.
Anti-pattern
A fixed wait in place of a fix
Claude's "fix" for a cart test that failed in CI adds a fixed wait before the check that failed.
import { test, expect } from '@playwright/test';
test('promo code lowers the cart total', async ({ page }) => {
await page.goto('http://localhost:3000/cart');
await page.getByLabel('Promo code').fill('SAVE10');
await page.getByRole('button', { name: 'Apply' }).click();
// Anti-pattern: a fixed wait added because the total updated late in CI.
// It hides the real bug below and stays flaky.
await page.waitForTimeout(3000);
expect(await page.getByTestId('cart-total').textContent()).toBe('$90.00');
});
That test will probably pass more often. It is still broken. Tests that wait for a fixed time are inherently flaky, and the Playwright docs say never to wait for timeout in production. Three seconds is only the number that happened to be enough on the run Claude watched.
Fix
The real bug is the assertion. textContent() returns the node's text content. expect(value).toBe() is a non-retrying assertion. Locator actions and web assertions wait automatically, so the fix drops the wait and lets a web assertion wait for the expected total:
import { test, expect } from '@playwright/test';
test('promo code lowers the cart total', async ({ page }) => {
await page.goto('http://localhost:3000/cart');
await page.getByLabel('Promo code').fill('SAVE10');
await page.getByRole('button', { name: 'Apply' }).click();
// A web assertion waits for the expected total instead of reading it once.
await expect(page.getByTestId('cart-total')).toHaveText('$90.00');
});
More retries don't fix anything either. With retries enabled, Playwright retries a failing test until it passes or reaches the maximum number of retries, so a higher count can turn an intermittent failure into a pass without changing the code that fails.
The rules part of the prompt already forbids both moves. Enforce them again at review. Ask for the change as a diff and read it before you run anything. Reject a diff that adds a wait, raises a retry count or weakens an assertion, however green the run behind it; our review checklist covers the other signs of heals that weaken a test's intent. And when the cause is in the app (a total that really does update late, a button that renders before it works), fix the app, not the test.
Prove the fix with --repeat-each before merge
One green local run of a test that failed intermittently in CI proves little, so don't merge on it. --repeat-each runs each test N times, and N defaults to 1, so set it high for the spec you fixed:
npx playwright test tests/cart.spec.ts --repeat-each=20
Twenty runs is our starting point, not a threshold; a test that failed rarely in CI deserves more. The runs are headless. With the config above (retries: process.env.CI ? 2 : 0), a failing test gets no retry off CI, so every repeat has to pass on its own. If your config enables retries locally, don't count a test that passed only on a retry.
Run the same command before the fix as well. If the unfixed test never fails in 20 local runs, you haven't reproduced the CI failure, and a clean run after the fix says nothing about it.
Make the repeat run the last step of the loop. The retain-on-failure trace mode records a trace for every run, but keeps it only for runs that failed.
Gotchas: idle browsers, leaked processes and unsafe tools
An MCP browser left open costs more than context. A headless browser launched by the MCP server closes after an hour without tool calls. Headed browsers, and browsers attached over --cdp-endpoint or --extension, never close automatically. The idle close is one more reason to keep --headless in the shared file.
A page that keeps animating or rendering costs CPU for as long as the browser is open. In one GitHub issue, a user running Playwright MCP headless with Chromium from Claude Code reports the gpu-process at around 700% CPU on a WebGL page for 30 minutes and for 90 minutes. In another, a user running Playwright MCP in isolated mode from the Claude Code VS Code extension on macOS reports Chromium processes still running after the client disconnects. If your machine slows down after a debugging session, look for leftover browser processes before you blame the app.
Persistent is Playwright MCP's default profile mode. In an issue, a user on Windows with Claude Code in VS Code reports that any file download with that default profile crashed the MCP server with an "MCP error". Since --isolated makes each MCP session start fresh, keep it in the shared file when several Claude sessions or git worktrees run at once. In a separate issue, a user running Playwright MCP in Claude Code with git worktrees reports that the MCP output directory doesn't follow the session into a new worktree.
The last gotcha is browser_run_code_unsafe. That tool runs arbitrary JavaScript in the Playwright server process, and the docs call it equivalent to remote code execution and say to enable it only for trusted MCP clients.
Anti-pattern
Every tool turned on in a shared .mcp.json
Turning on every tool in a shared .mcp.json turns on remote code execution for the whole team. A server added with --scope project is saved in .mcp.json in the repository root and is available in the project for everyone who clones it.
Fix
Keep browser_run_code_unsafe out of the shared file, and treat a request to add it as a security review.
Where this loop stops and what takes over
This loop is manual on purpose, and it doesn't scale to every red test on every branch. When failures pile up faster than you can work through them, try the healer agent before you build a custom multi-agent pipeline. Playwright's healer agent executes the test suite and automatically repairs failing tests. Its repairs face the same merge gate as yours.
The slowest manual step is collecting the trace and error context from CI for every failure. Currents' Fix failing CI tests hands the agent that failure context, error-context snapshot included, so it finds the cause in the app or the test, fixes it and reruns the test to check the fix. The Currents MCP server goes into Claude Code with one claude mcp add command over HTTP, and adding the server with --scope project writes the .mcp.json you commit. After you sign in from the browser, Claude Code can read your runs and test results from any session.
Whoever writes the fix, you, Claude Code or the healer, the gate in front of merge stays the same, because an automatic repair is still a change someone has to trust. Read the diff and reject any new wait, higher retry count or weaker assertion. Then run --repeat-each on the spec before and after the change. The fix counts only when the unfixed test fails under that run and the fixed one passes every time.
Frequently asked questions
How do I add Playwright MCP to Claude Code?
Run claude mcp add --scope project playwright npx @playwright/mcp@latest in the repository. With --scope project, Claude Code writes the server into a .mcp.json file you commit, so everyone who clones the repository gets the same setup (details). Then add --headless, --isolated and --storage-state to the server's args, so the MCP browser starts the way CI starts your tests (the config).
Should I use Playwright MCP or the Playwright CLI with Claude Code?
Use MCP for short, interactive exploration of a live page. Use the Playwright CLI when the same session also has to hold a large codebase or a long debugging run, because MCP keeps its tool schemas and snapshots in the model's context and the CLI's output is more concise (how to pick).
Why does a test pass in the Playwright MCP browser but fail in CI?
Playwright MCP starts a headed browser with a persistent profile, so a login you did by hand carries over between sessions. CI runs the test headless with only the storage state your config loads. Start the MCP browser with --headless, --isolated and --storage-state so it matches (the config).
What should I give Claude Code to fix a test that fails in CI?
How CI ran the test, the trace URL of a failed attempt and its error-context snapshot, and rules for the fix: no fixed waits, no extra retries, no looser assertions. With only the log, Claude tends to guess, and the guess is usually a wait (the prompt).
Why shouldn't Claude Code fix a failing test with waitForTimeout?
A fixed wait is only the number that happened to be enough on the run Claude watched, and tests that wait for a fixed time are inherently flaky. Replace the one-time read with a web assertion such as toHaveText, which waits for the expected value (the before and after).
How do I prove a fix for a flaky Playwright test?
Run the spec with --repeat-each=20 before and after the change, and don't count a run that passed only on a retry. The fix counts only when the unfixed test fails under that run and the fixed one passes every time. If the unfixed test never fails, you haven't reproduced the CI failure (details).
Is it safe to turn on every Playwright MCP tool?
Not in a shared config. browser_run_code_unsafe runs arbitrary JavaScript in the Playwright server process, and the docs call it equivalent to remote code execution. Keep it out of the .mcp.json you commit, and treat a request to add it as a security review (details).




