
Playwright MCP with a coding agent: look once, then make the agent write the test
Playwright MCP gives a coding agent a browser to look through, and the looking is where the tokens go. On 2 November 2026 I ran @playwright/mcp 0.0.84 against four public pages and counted every response with Anthropic's token counter for Claude Sonnet 5: one page snapshot came to 6,030 tokens on a Stackness tool page and 25,274 to 33,917 on the GitHub page of Playwright MCP itself. A passing Playwright test that checks the Stackness page put 64 tokens of output in front of the agent.
My position: let the agent look through Playwright MCP the first time it checks a change, and make it write a Playwright test for anything it will need to check again. Every Playwright MCP action already prints the Playwright code it ran, so most of the test exists by the time the look is over. I moved my own end-to-end suite from looking to scripts in March 2026. Below are the numbers, the case against, and when I would be wrong.
What 1,929 Playwright MCP calls were used for
Chirag Aggarwal counted every Playwright MCP call in his Claude Code transcripts from 17 September to 17 October 2026 and wrote it up: 1,929 calls across 62 sessions, and "almost none of them were tests". 1,209 calls in 34 sessions checked a UI, docs or release change, 553 did chores in logged-in web apps and 167 were research.
| Tool | Calls |
|---|---|
| navigate | 372 |
| take_screenshot | 332 |
| evaluate | 304 |
| click | 247 |
| run_code_unsafe | 188 |
| wait_for | 160 |
| snapshot | 120 |
| resize | 45 |
| other 10 tools | 161 |
The verification sessions took 281 screenshots against 226 navigations, and 209 of those navigations went to localhost or a local file. 317 of the 332 screenshots had descriptive names, and 195 of the 1,929 calls returned an error. His post counts calls, not tokens: it has no context or cost figure at all, which is the gap my run fills.
Playwright MCP and Playwright Test do two different jobs
Playwright MCP is a server that lets an agent drive a browser one tool call at a time and read the page as an accessibility snapshot. Playwright Test runs spec files committed to the repo, headless, and reports pass or fail. Chirag's split is the right one: the suite answers "did something that used to work stop working?", the MCP answers "does the thing I just built look right?"
Since Playwright 1.62 both ship in one package (npx playwright mcp), and Microsoft's own README tells coding agents they "might benefit" from the Playwright CLI instead, because it keeps "large tool schemas and verbose accessibility trees" out of context. Chrome DevTools MCP is a third option built on Puppeteer, better at debugging a page than driving it, and all three sit among the AI tools developers list in their stacks. On npm the test runner is still the larger Playwright by far: @playwright/test had 313.6 million downloads in October 2026 against 26.1 million for @playwright/mcp (npm downloads API), and the MCP's monthly count has stayed between 24 and 29 million since June.
I measured what each Playwright call costs in context
I connected to @playwright/mcp 0.0.84 over stdio with headless Chromium at 1280x720, ran navigate, snapshot and screenshot on four pages twice, and counted each response with Anthropic's count_tokens endpoint for claude-sonnet-5, minus a one-word baseline. The test runner side is @playwright/test 1.64.1 running one spec. The script is at the end of the post.
| Call | Stackness tool page | playwright.dev docs | GitHub repo page | Hacker News |
|---|---|---|---|---|
browser_navigate |
138 | 123 | 154 | 116 |
browser_snapshot |
6,030 | 12,900 | 25,274 and 33,917 | 19,204 |
browser_take_screenshot |
1,367 | 1,367 | 1,367 and 1,493 | 1,367 |
The 25 tool definitions add 7,105 tokens when they are loaded, though Claude Code defers MCP tool definitions by default and loads only their names at session start. On the test runner side, a passing run of the spec printed 64 tokens of output, a run with one deliberately failing assertion printed 589, and the error-context.md file Playwright writes for that failure is another 686 if the agent opens it.
Playwright MCP actions are cheap now, and explicit snapshots are not
A navigate or a click in Playwright MCP 0.0.84 costs the agent 116 to 154 tokens (127 for my click on the Stackness page), because the snapshot taken after each action is written to .playwright-mcp/page-*.yml and the response carries only a link to it. Comparing the shipped packages, that change landed in version 0.0.69 on 30 March 2026, and the Browserless, Momentic and Currents guides that rank for the query still describe a fresh snapshot coming back after every action.
The cost moved rather than disappeared. A click needs a target, and the tool's own schema describes it as an "Exact target element reference from the page snapshot, or a unique element selector", so the agent either opens the snapshot file or calls browser_snapshot, which returns the whole tree inline. The GitHub page's snapshot, 25,274 and then 33,917 tokens on two runs in the same session, is past the 25,000-token limit Claude Code puts on MCP output by default, so Claude Code saves it to a file instead of showing it. A Playwright maintainer wrote in June that "Saving snapshots to a file doesn't consume extra tokens either, so there's no real cost there", which holds only until the agent reads the file.
A Playwright MCP screenshot costs 1,367 tokens, and a named one never reaches the model
An unnamed screenshot of a 1280x720 viewport cost 1,367 tokens in my run, close to the 1,196 visual tokens Anthropic's vision formula gives for that size plus the text around it. The same call with a filename argument cost 107 tokens and returned no image: since version 0.0.74 the server saves the file and skips the picture.
That makes an unnamed screenshot 4 to 23 times cheaper than a snapshot of the same page in my run, and it matters for how Chirag's 317 named screenshots are read. If his agent passed those names as filename, it took them for the reviewer and did not look at them itself, unless it opened the PNG afterwards. His post does not say which, so I treat it as open. Either way, a screenshot in a pull request is evidence for a person, and the agent's own check has to come from somewhere else.
Every Playwright MCP action already prints its test code
Each action response in Playwright MCP 0.0.84 opens with a "Ran Playwright code" section. My click on the Trending link returned await page.getByRole('link', { name: 'Trending' }).click(); above the snapshot link, and code generation is on by default (--codegen typescript). The look and the test share their locators, so turning one into the other is copying those lines into a spec and adding an expect.
Playwright has built more of this bridge itself. --caps=testing adds browser_verify_* tools and browser_generate_locator, and the Playwright Test Agents shipped in Playwright 1.56 (a planner, a generator and a healer, set up with npx playwright init-agents, which now also covers Codex) are MCP tools wrapped around writing and fixing specs. The agent does not need a new tool to write the test. It needs an instruction to do it.
I moved my own suite from MCP calls to agent-written scripts in a week
Stackness's end-to-end suite started as an agent driving Playwright MCP, and I switched it to agent-written Playwright scripts seven days later. On 20 March 2026 my /fulltest command was described as "Run all E2E scenarios via Playwright MCP". On 27 March I split it into one agent per feature area, "preventing context saturation", and the same day rewrote it so the agent writes a standalone Playwright script per area and runs it with Node.
The commit gives two reasons: it "avoids MCP tool permission issues and keeps the approach simple and resumable". I did not measure tokens in March, so I cannot say how much of the change was cost. Today the suite is 661 scenarios written as markdown, turned into 20 generated .mjs scripts, 34,572 lines in all. The agent still looks through Playwright MCP when it builds or reviews a page, through a QA subagent that runs its own isolated MCP server.
The price is upkeep: 46 commits touched those scripts in October 2026 alone, because every scenario change means the agent regenerates the script for its area.
The case for looking and never writing the test
The strongest case against my position comes from the same sources. One of the three sessions Chirag describes in detail was a logo, a black mark sitting on a near-black card, and he says "No test in the suite would have caught this, because no test checks that a logo is visible against its background. If I wrote one now, it would only protect against the last bug." Most of what a coding agent checks is new UI that no existing test covers.
Agent-written tests have their own record. In Slack Engineering's June 2026 study, generated tests failed about 48% of the time on the complex flow, and only about 20% of agent runs followed the same sequence of actions. Ken Alger described five verification dates in a demo video and its screenshots where "All five passed under the buggy version." A test written from a look can carry the look's blind spots into CI, where they pass every night.
My answer is that the rule is about repetition. A visual judgement made once stays a look, and its screenshot goes in the pull request. A check the agent will make again, such as signing in, the upgrade wizard or the page the next pull request touches, becomes a spec, because the second look costs as much as the first and the spec costs 64 tokens to re-run. Slack's numbers point the same way: an agent-driven run cost $15 to $30, and the generated tests' raw execution took about 32 to 45 seconds.
I would be wrong if an agent's checks rarely repeat. Chirag's month leans that way, with most verification going to new changes, and if that holds for most teams, the specs would be written for checks nobody makes twice. I would also be wrong if generated specs cost more to keep alive than the looks they replace, and my 46 commits in October are the number to watch on that.
Where Playwright MCP broke in my run and in Chirag's month
Chirag's 195 failed calls were mostly a browser or tab already closed (36), scripts reading files outside the sandbox (25) and timeouts (around 30), plus four "Browser is already in use" errors, which he says usually means two parallel sessions wanted the same browser profile. file:// URLs have been blocked by default since version 0.0.55 in January 2026, which his agent also ran into.
My own first click failed on arguments. I passed ref, as the snapshot labels each element, and 0.0.84 answered "Invalid input: expected string, received undefined → at target", because its click tool now takes a target. The fix was the argument name; an agent pinned to @latest, as my QA subagent is, gets changes like that without warning.
Run it yourself
The rule I would put in CLAUDE.md: "When you check a UI change through Playwright MCP and the check will matter again, copy the 'Ran Playwright code' lines into a spec, add the expect you checked by eye, and run npx playwright test before you report the change as done. Screenshots go in the pull request for the reviewer."
To repeat my measurement, with an Anthropic API key in ANTHROPIC_API_KEY:
npm install @playwright/mcp@0.0.84 @playwright/test @modelcontextprotocol/sdk
npx playwright install chromium --only-shell
node measure-mcp.mjs claude-sonnet-5 # tool definitions, navigate, snapshot, screenshot on four pages
npx playwright test --reporter=line # the spec, one passing and one failing check
This is measure-mcp.mjs as I ran it. It connects with the MCP SDK's stdio client, calls each tool and sends the response to /v1/messages/count_tokens:
// Measures what Playwright MCP puts into an agent's context: the tool schemas,
// and the response of each browser call, counted with Anthropic's count_tokens.
// Usage: ANTHROPIC_API_KEY=... node measure-mcp.mjs <model> [--snapshot-mode full|none]
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
const model = process.argv[2] || "claude-sonnet-5";
const extra = process.argv.slice(3);
const PAGES = [
"https://stackness.dev/tools/playwright",
"https://playwright.dev/docs/intro",
"https://github.com/microsoft/playwright-mcp",
"https://news.ycombinator.com/",
];
async function countTokens(body) {
const res = await fetch("https://api.anthropic.com/v1/messages/count_tokens", {
method: "POST",
headers: {
"x-api-key": process.env.ANTHROPIC_API_KEY,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
body: JSON.stringify({ model, ...body }),
});
const j = await res.json();
if (!res.ok) throw new Error(JSON.stringify(j));
return j.input_tokens;
}
const baseline = await countTokens({ messages: [{ role: "user", content: "x" }] });
const blockTokens = async (content) =>
(await countTokens({ messages: [{ role: "user", content: [{ type: "text", text: "x" }, ...content] }] })) - baseline;
function toBlocks(result) {
return result.content.map((c) =>
c.type === "image"
? { type: "image", source: { type: "base64", media_type: c.mimeType, data: c.data } }
: { type: "text", text: c.text },
);
}
function describe(result) {
const text = result.content.filter((c) => c.type === "text").map((c) => c.text).join("\n");
const images = result.content.filter((c) => c.type === "image");
return { textChars: text.length, images: images.length, imageBytes: images.reduce((n, i) => n + i.data.length * 0.75, 0) };
}
const transport = new StdioClientTransport({
command: "node",
args: ["node_modules/@playwright/mcp/cli.js", "--headless", "--isolated", "--viewport-size", "1280x720", ...extra],
});
const client = new Client({ name: "measure", version: "1.0.0" });
await client.connect(transport);
const { tools } = await client.listTools();
const anthropicTools = tools.map((t) => ({ name: t.name, description: t.description, input_schema: t.inputSchema }));
const schemaTokens = (await countTokens({ messages: [{ role: "user", content: "x" }], tools: anthropicTools })) - baseline;
console.log(`model ${model}, flags ${extra.join(" ") || "(default)"}`);
console.log(`tools/list: ${tools.length} tools, ${JSON.stringify(anthropicTools).length} chars, ${schemaTokens} tokens`);
console.log(`tool names: ${tools.map((t) => t.name).join(", ")}`);
for (const url of PAGES) {
for (const [name, args] of [
["browser_navigate", { url }],
["browser_snapshot", {}],
["browser_take_screenshot", { type: "png" }],
]) {
const result = await client.callTool({ name, arguments: args });
const d = describe(result);
const tokens = await blockTokens(toBlocks(result));
console.log(`${url} | ${name} | ${d.textChars} text chars | ${d.images} image(s) ${Math.round(d.imageBytes / 1024)} KB | ${tokens} tokens`);
}
}
await client.close();
Page sizes change from day to day, as the GitHub page did between my two runs, so compare the same page within one session.
New posts by email
About one email a week with what we published. No account needed.
One email to confirm first. Every email has a one-click unsubscribe.
Which of these do you use?
Nobody on Stackness runs Playwright MCP yet. Tick what you use and be the first.



