OpenCode is a terminal-native, provider-agnostic coding agent — it can run against Claude, GPT, or local models through its own routing layer, and its MCP support follows a simple TOML/JSON config file rather than a CLI subcommand. That makes Playwright MCP setup mechanically simpler than Claude Code but slightly less guided: there's no mcp add wizard, and error messages from a misconfigured server are terser.
Installing and Connecting Playwright MCP to OpenCode
OpenCode reads MCP server definitions from opencode.json (project-scoped) or ~/.config/opencode/opencode.json (global). Add Playwright MCP under the mcp key:
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"playwright": {
"type": "local",
"command": ["npx", "-y", "@playwright/mcp@latest", "--isolated"],
"enabled": true
}
}
}
The type: "local" field distinguishes a locally-spawned stdio server from a remote HTTP/SSE MCP endpoint (type: "remote", with a url field) — Playwright MCP only ships as a local stdio server, so local is the correct choice here.
Start OpenCode from the project root so it picks up the project-scoped config:
cd my-app
opencode
Inside the session, confirm the server connected:
/mcp
playwright ● connected (23 tools)
If it shows disconnected, check two things in order: first, that npx @playwright/mcp@latest --version runs cleanly outside OpenCode (isolates whether it's an OpenCode config issue or a Node/npx environment issue); second, that browsers are installed (npx playwright install chromium firefox webkit), since a missing binary produces a connection-looking failure rather than a clear "browser missing" error in some OpenCode versions.
Tips
- Test the raw command (npx -y @playwright/mcp@latest --isolated) directly in a terminal before trusting OpenCode's config — it isolates config typos from actual server startup failures.
- Usetype: "local"explicitly; omitting it in older OpenCode versions can default incorrectly and silently no-op the server.
- Keepopencode.jsonin version control alongsideplaywright.config.tsso the whole team gets the same tool surface.
Generating and Running E2E Tests from Natural Language Prompts in OpenCode
OpenCode's chat interface takes the same style of natural-language flow description as any other MCP-connected agent, but its default behavior is more literal — it tends to stick closer to exactly what you asked and less to inferred best practice unless your system prompt or AGENTS.md file states conventions explicitly.
Navigate to http://localhost:4000/search. Type "wireless headphones" into the
search box, submit, and verify at least 3 results render. Then write a
Playwright test at e2e/search.spec.ts covering this, and add an assertion that
each result card shows a price.
A typical result:
import { test, expect } from '@playwright/test';
test('search returns results with prices', async ({ page }) => {
await page.goto('/search');
await page.getByPlaceholder('Search products').fill('wireless headphones');
await page.getByRole('button', { name: 'Search' }).click();
const results = page.getByTestId('result-card');
await expect(results).toHaveCount(await results.count());
expect(await results.count()).toBeGreaterThanOrEqual(3);
const prices = results.locator('[data-testid="price"]');
const count = await prices.count();
for (let i = 0; i < count; i++) {
await expect(prices.nth(i)).not.toHaveText('');
}
});
Worth noting: that toHaveCount(await results.count()) line is a real pattern seen from less-guided models — it's a self-referential assertion that always passes and adds nothing. It's the kind of low-value assertion OpenCode's model produces more often than Claude Code's when you don't specify the exact check. Fix prompts should call this out directly rather than a vague "improve the test":
That count assertion is circular — it compares results.count() to itself.
Replace it with a fixed expectation: results.count() should be >= 3, and
remove the redundant toHaveCount line entirely.
Run generated specs the standard way:
npx playwright test e2e/search.spec.ts
Tips
- Review every generated assertion line by line the first few times you use OpenCode with Playwright MCP — it's more prone to filler assertions that technically pass without testing anything than Claude Code is by default.
- Put explicit assertion requirements in the prompt ("assert result count >= 3, assert each card has non-empty price text") rather than relying on the model to infer a thorough check.
- AnAGENTS.mdfile at the repo root with locator and assertion conventions measurably tightens OpenCode's output — it reads project-level agent instruction files the same way Claude Code readsCLAUDE.md.
Practical Example: Generating a Checkout Flow E2E Test in OpenCode
Walking through a full session end to end. Given an app with a cart badge, a checkout form, and a payment step backed by a test payment provider:
1. Navigate to http://localhost:4000/cart (assume 1 item already in cart from
a previous fixture, or add one from /products first if empty).
2. Proceed to checkout.
3. Fill shipping: name "Jane Tester", address "500 Market St", city
"Metropolis", zip "10001".
4. Use test card 4242 4242 4242 4242, any future expiry, any CVC.
5. Submit and confirm order success.
6. Write the full flow as e2e/checkout-flow.spec.ts, including a beforeEach
that adds an item to cart via the products page so the test doesn't depend
on pre-existing state.
The beforeEach requirement matters because OpenCode, left to its own devices, will often generate a test that assumes the cart already has an item — a snapshot of whatever the live session happened to see — rather than a self-contained test:
import { test, expect } from '@playwright/test';
test.describe('checkout flow', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/products');
await page.getByRole('button', { name: 'Add to cart' }).first().click();
});
test('completes checkout with test card', async ({ page }) => {
await page.goto('/cart');
await page.getByRole('link', { name: 'Checkout' }).click();
await page.getByLabel('Full name').fill('Jane Tester');
await page.getByLabel('Address').fill('500 Market St');
await page.getByLabel('City').fill('Metropolis');
await page.getByLabel('ZIP').fill('10001');
const cardFrame = page.frameLocator('iframe[title="Card number"]');
await cardFrame.getByPlaceholder('Card number').fill('4242424242424242');
await page.getByLabel('Expiry').fill('12/30');
await page.getByLabel('CVC').fill('123');
await page.getByRole('button', { name: 'Place order' }).click();
await expect(page.getByText('Order Confirmed')).toBeVisible({ timeout: 10000 });
});
});
Note the frameLocator — payment providers commonly embed card fields in an iframe for PCI compliance, and this is a detail the agent only gets right if it actually navigated the live checkout during its MCP session rather than guessing at generic form structure. It's a good concrete example of why live-browser generation beats a model writing tests from a text description of the UI alone.
Tips
- Always require a self-containedbeforeEach/setup step in prompts for flows with cart or session-dependent state — otherwise the generated test only passes when run right after the exact session that created it.
- Watch for iframe-embedded payment fields; confirm the agent usedframeLocatorcorrectly rather than a plaingetByLabelthat will silently fail to find the field.
- Bump the assertion timeout ({ timeout: 10000 }) on payment confirmation steps — these are commonly slower than typical UI transitions due to processor round-trips.
Known Limitations for Playwright MCP in OpenCode
A few things to set expectations on, based on how OpenCode's agent loop differs from Claude Code's:
Shorter effective context retention across tool calls. On longer multi-step flows (10+ sequential interactions), OpenCode is more likely to lose track of earlier state and re-navigate unnecessarily, burning extra tool calls and tokens. Breaking a long flow into two prompts with the second referencing "continuing from the checkout page" tends to produce more reliable results than one giant prompt.
Less consistent locator strategy without explicit instruction. As shown above, OpenCode needs more explicit "use getByRole, not CSS classes" guidance than Claude Code, which appears to have stronger built-in bias toward Playwright's own best practices.
No built-in equivalent to Claude Code's visible tool-call sidebar. Debugging why the agent chose a given path means reading its text narration in the terminal, not inspecting a structured tool-call log in a GUI. For deep debugging, running npx playwright test --ui separately alongside the OpenCode session is close to mandatory.
Provider variance. Because OpenCode can route to different model providers, generation quality for the same prompt varies noticeably depending on which model backs the session — a GPT-4-class model and a Claude-class model given the identical Playwright MCP prompt can produce meaningfully different locator strategies and assertion depth. If your team standardizes on OpenCode, pin the model in opencode.json rather than leaving it on a default that might change.
{
"model": "anthropic/claude-sonnet-4-5",
"mcp": {
"playwright": {
"type": "local",
"command": ["npx", "-y", "@playwright/mcp@latest", "--isolated"],
"enabled": true
}
}
}
Tips
- Break flows longer than ~8 sequential steps into two prompts referencing prior state, rather than one long instruction — reduces redundant re-navigation.
- Pin the model explicitly inopencode.jsonif test-generation consistency matters to your team; the provider-agnostic flexibility that makes OpenCode attractive is also a source of output variance.
- Runnpx playwright test --uiin a second terminal for any debugging session — OpenCode has no built-in visual trace equivalent.
Tips
Tips
- Confirm the raw MCP server command works standalone before debugging inside OpenCode — it separates config issues from server issues fast.
- Maintain anAGENTS.mdwith explicit locator, assertion, and file-naming conventions; OpenCode leans on it more heavily than more opinionated agents to produce consistent output.
- Always request self-contained setup (beforeEach, fixtures) explicitly — generated tests otherwise silently depend on whatever state existed during the live authoring session.