Configure the Playwright Page Object MCP Server
The MCP server usually needs no flags. It searches from the current working directory, reads the most likely Playwright config, uses that config to find the relevant tsconfig.json, and defaults to data-testid when use.testIdAttribute is absent.
Add configuration when you need to select one app in a monorepo, pin an ambiguous config, override a computed test-ID attribute, or narrow a repository that exceeds the file limit.
If the server is not connected yet, follow the quick start first.
Which option should I start with?
Section titled “Which option should I start with?”| Situation | Start with |
|---|---|
| Conventional repository | No flags |
| One app in a monorepo | --project-root |
| Wrong Playwright config selected | --playwright-config |
| Computed test-ID attribute | --attribute |
| Oversized scan | --src-dir |
| Components routinely forward test-ID props | --assume-forwarded |
Which CLI options are available?
Section titled “Which CLI options are available?”npx playwright-page-object mcp [options]| Flag | Default | Purpose |
|---|---|---|
--project-root <dir> |
cwd | Repository root to analyze. Every path in every response is relative to it |
--tsconfig <file> |
discovered | The tsconfig.json whose compiler options and file set to use |
--playwright-config <file> |
discovered, ranked | The playwright.config.* to read. Suppresses discovery entirely |
--src-dir <dir> |
tsconfig include |
Restrict scanning to this directory. Repeatable |
--attribute <name> |
see below | Test-ID attribute name |
--max-files <n> |
8000 |
Cap on files parsed. Roughly 0.13 MB of RSS per file, so the default ceiling is about a gigabyte |
--assume-forwarded |
off | Count a test ID written on a component tag as rendered in map_coverage |
--log-level <level> |
error |
stderr verbosity: silent, error, info, debug |
The server logs only to stderr. stdout carries the JSON-RPC stream, so changing --log-level does not mix log messages into the protocol.
When does the server refuse to start?
Section titled “When does the server refuse to start?”A mistyped path would make every later answer use the wrong scope. The server checks explicit paths before opening the stdio connection, writes the problem to stderr, and exits with code 1 when:
--project-rootis not a directory,--tsconfigis not a file,--playwright-configis not a file,- a non-exclusion
--src-dirresolves outside--project-root, contains a..path segment, or names a plain path that does not exist. For a glob, the containment check starts at its static base.
$ npx playwright-page-object mcp --project-root example --src-dir nope--src-dir does not exist: nopeRun the same command in a terminal when an MCP client reports a startup failure. The stderr message usually identifies the invalid path directly.
How does the server choose a tsconfig?
Section titled “How does the server choose a tsconfig?”In order:
--tsconfig, if given. A path that is not a file stops the server at startup.<project-root>/tsconfig.json.- The nearest
tsconfig.jsonwalking up from the PlaywrighttestDirtowards the project root. A monorepo often keeps the e2e tsconfig next to the specs. - None. The server falls back to synthesized compiler options and a
**/*.{ts,tsx,mts,cts,jsx}scan, and reports ano-tsconfigdiagnostic.
testDir comes from the Playwright config. A relative value resolves against the directory of the config layer that wrote it, an absent one means the config’s own directory (Playwright’s default), and a computed one is left unknown with a testdir-unresolved note rather than guessed.
How does the server choose a Playwright config?
Section titled “How does the server choose a Playwright config?”The search is repository-wide, not a fixed list of filenames in a fixed list of directories. The server globs **/playwright*.config.{ts,mts,cts,js,mjs,cjs} under the project root, pruning node_modules, dist, build, out, .git, coverage, playwright-report, test-results, .next and .astro.
Candidates are ranked, best first, and capped at 20:
playwright.config.<ext>exactly, ahead of any other matching basename.- Fewer path segments, so a root-level config outranks a nested one.
- Extension order
ts,mts,cts,js,mjs,cjs. - Lexicographic, so the result never depends on directory read order.
meta.playwrightConfig on every successful response names the file that was read. When more than one exists, a playwright-config-ambiguous diagnostic lists the others. It is informational when the attribute resolved and a warning when it did not.
How is the chosen config read?
Section titled “How is the chosen config read?”The config is read statically. It is never executed, so testIdAttribute: process.env.X resolves to unknown plus a testid-attribute-unresolved warning rather than to whatever the analyzing process has in its environment.
Real configs are rarely one object literal, so the reader flattens the export into ordered layers, lowest precedence first:
| Shape | Read as |
|---|---|
defineConfig({ … }) |
The literal itself |
defineConfig(merge(base, overrides)) |
Both arguments, left to right as lowest to highest |
defineConfig({ ...base, … }) |
The spread target, then the literal’s own properties |
import base from "./base.config" |
One hop into that file’s exported config |
Highest precedence wins. A layer that writes the key stops the walk even when the written value is not a literal, because use: { testIdAttribute: process.env.X } in the leaf config is positive evidence that a base config’s value is not what runs.
An attribute that came from anywhere but the chosen file’s own literal is reported with a testid-attribute-inherited note naming the file and the layer.
What if the test-ID attribute lives in a separate config?
Section titled “What if the test-ID attribute lives in a separate config?”Some repositories split playwright.config.ts (projects, reporters) and playwright.base.config.ts (use) without importing one from the other. When the chosen config says nothing about use.testIdAttribute, the server looks at the remaining ranked candidates and reports what it finds with a testid-attribute-sibling warning naming the file and the value. It does not apply it.
Borrowing across unrelated configs is what that restraint prevents: a playwright-ct.config.ts setting data-ct-id for component tests has no runtime relationship to the end-to-end config your tests actually run, and scanning the whole repository with its attribute while the response names a different config is worse than scanning with the default. A config the chosen one imports, spreads, or merges is related, so it is read as a layer and does apply.
Act on the warning with --attribute <name> or --playwright-config <file>. If the assumed attribute matches nothing in your sources, the attribute check tells you independently.
--playwright-config suppresses discovery entirely, sibling reporting included. The named file is the config, or there is none. A caller who names a config and silently gets a different one read is worse off than one told the file is missing.
Per-project overrides (projects[].use.testIdAttribute) are recorded and reported with testid-attribute-project-override, but the analysis uses the top-level value.
How does the server choose the test-ID attribute?
Section titled “How does the server choose the test-ID attribute?”| Precedence | Source | meta.attributeSource |
|---|---|---|
| 1 | --attribute, or the per-call attribute argument on get_testid_tree / map_coverage |
param |
| 2 | use.testIdAttribute in the chosen config’s own literal |
playwright-config |
| 3 | The same key in a merge layer, a spread, or an imported base config | playwright-config |
| 4 | Playwright’s built-in data-testid |
default |
A sibling config discovered but never imported is not in this table: it is reported, never applied.
meta.attributeSource ships on every successful response, so one field tells you which layer won. See the common meta fields for the rest of the response context.
How does the server detect a wrong attribute?
Section titled “How does the server detect a wrong attribute?”A wrong attribute can make the test-ID tree empty and make every selector look dead. Before returning a result, the server checks whether the resolved attribute appears in the scanned JSX or TSX source and looks for a more likely hyphenated attribute when it does not.
The census first checks whether <attribute>= appears in the scanned JSX or TSX. If it does not, the server counts alternative hyphenated attributes and emits one of:
| Diagnostic | Condition |
|---|---|
scope-empty |
No JSX/TSX files are in scope at all. The scope is wrong, not the attribute |
attribute-mismatch |
The attribute appears nowhere, and one hyphenated attribute name appears at least twice. The message names that candidate, its count, and the runner-up |
attribute-no-evidence |
The attribute appears nowhere and nothing else stood out |
aria-* names are never offered as candidates. When a census verdict fires, meta.hint on every tool leads with the fix, ahead of any per-tool advice:
The test-id attribute is almost certainly wrong: nothing in the scanned sources uses “data-qa”, while “data-testid” is everywhere. Restart the server with –attribute data-testid, or with –playwright-config <file> pointing at the config that sets use.testIdAttribute. Treat this result as unreliable until then.
Warnings are ordered by how badly they invalidate the result, and capped at eight per response: what makes the answer wrong first, then what makes it incomplete, then why.
How do I control the scanned files?
Section titled “How do I control the scanned files?”Narrowing the scan with --src-dir
Section titled “Narrowing the scan with --src-dir”Repeatable, and normalized once at the workspace boundary so every consumer sees the same globs:
| You pass | Scanned |
|---|---|
src |
src/**/*.{ts,tsx,mts,cts,jsx} |
src/**/*.tsx |
Exactly that glob |
src/setup.ts |
Exactly that file |
!src/generated |
Excludes src/generated/**/*.{ts,tsx,mts,cts,jsx} |
. |
The whole root, as the default scan glob |
Whether a bare path is a file or a directory is decided by a stat, not by its trailing dot segment, so a directory named foo.config still expands correctly.
Plain .js is not swept, because it would pull in build output and tooling config for every repository. A .js module imported from analyzed code is added on demand by the resolver.
A --src-dir naming a directory that is not on disk stops the server at startup. A glob matching nothing does not, since that is indistinguishable from a legitimately empty directory; the census reports it as scope-empty instead.
--src-dir can only ever narrow. A path resolving outside --project-root is refused at startup, so it is not the way to reach a shared UI package in a sibling directory; re-root the server instead, as described under monorepos. When linked in-repository sources can be resolved, the ui-scope-incomplete hint names the directory to pass to --project-root. Installed or unresolved modules may have no usable re-root target.
Narrowing the scope usually means fewer files are parsed, and so a lower count against --max-files, but the two are not the same set: see below. It does not narrow Playwright config discovery, which always searches the whole root.
Limiting the scan with --max-files
Section titled “Limiting the scan with --max-files”The default is 8000 files. Lower it to fail fast on an unexpectedly broad scope; raise it only when the intended project exceeds the cap. A scan that crosses the cap refuses the call and asks you to raise --max-files or narrow the scope. Larger scans use more memory, and the cached workspace is released after ten idle minutes.
A scan of 3000 files or more reports a large-scan info diagnostic with the count because project parsing dominates the cold start and memory use. Lower --max-files if you would rather an unexpectedly broad scope fail fast.
The cap governs files parsed, not files analyzed: every source the project holds, less declaration files and ignored directories such as node_modules and build output. --src-dir says which files are analyzed, and the resolver still parses what analyzed code imports — an alias target or a .js module outside the scope is parsed, retained, and counted. Counting only the narrowed scope let a project sitting on the cap pull in unlimited siblings for free.
A negated --src-dir prunes the scan itself, so an excluded directory is never parsed and never counted: --src-dir src --src-dir '!src/generated' costs nothing for the generated tree. The one exception is a project whose file set comes from a tsconfig.json with no --src-dir beside it — there the tsconfig decides what is parsed, and the exclusion shapes the answers only.
It is checked before parsing when a tsconfig enumerates the file set, so an oversized repository is refused before its sources are read. It is enforced again on the per-call rescan and on files the resolver pulls in mid-analysis; an addition that would break the cap is rolled back. Exceeding it returns max_files_exceeded with a hint to raise the cap or narrow the scan.
How do I configure a monorepo?
Section titled “How do I configure a monorepo?”Run one server per app when packages have different Playwright configs, test-ID attributes, or source roots:
{ "mcpServers": { "web-page-objects": { "command": "npx", "args": ["playwright-page-object", "mcp", "--project-root", "apps/web"] }, "admin-page-objects": { "command": "npx", "args": ["playwright-page-object", "mcp", "--project-root", "apps/admin"] } }}Add --attribute when apps use different test-ID attributes, and --playwright-config when an app’s config is not the highest-ranked one under its root.
A single server pointed at the monorepo root also works up to --max-files. In map_coverage, ui-scope-incomplete means at least one component tag resolves outside the scanned sources. A shared UI package can cause this warning. Test IDs declared inside that package are not visible to the scan, so read the named modules before treating an absent ID as dead.
See Monorepos for the full treatment: shared UI packages, why --src-dir cannot reach a sibling directory, running one server per app, and verifying the scope afterwards.
When do results refresh?
Section titled “When do results refresh?”The server checks files already loaded in the workspace on every tool call. It rereads changed files and removes deleted files immediately. A changed scan directory triggers a rescan for new files; a one-second interval is the backstop for a new file inside a pre-existing nested directory that contains no loaded source. It does not run a file watcher, so a long-lived server can also recover from branch switches and bulk checkouts.
No restart needed: editing a page object, editing a component, adding or deleting either, or editing the Playwright config. New files are normally visible on the next call because their directory changed. If a file is created inside a pre-existing nested directory with no loaded source, discovery can take up to one second. Newly created Playwright configs follow that same rescan path.
Restart needed: any change to a CLI flag. The flags define the server’s workspace, so a different --attribute, --src-dir, --project-root, --playwright-config, --tsconfig, --max-files, or --assume-forwarded requires a new process. Moving page objects outside the scanned directories also requires a new --src-dir value.
When should I assume prop forwarding?
Section titled “When should I assume prop forwarding?”A test ID written on a component tag reaches the DOM only if that component passes the prop to a host element:
<Card data-testid="SaveCard" />By default the server does not assume it does. The occurrence is recorded with reach: "component-prop", kept out of the rendered side of map_coverage, and any selector matching only it lands in unknownSelectors rather than in matched or deadSelectors.
Component libraries that forward props as a rule make that conservative. --assume-forwarded promotes those IDs to rendered, with three labeling guarantees:
meta.assumeForwarded: trueon everymap_coverageresponse.- A
forwarding-assumedwarning counting the promoted IDs. forwarding: "assumed"on every affected entry inmatched, andassumed: trueon the affected ID groups.
Nothing is silently upgraded, so you can always tell a proven match from an assumed one. It is a server-level flag rather than a tool argument so every map_coverage call from that server uses the same classification. Other tools still report the source evidence without applying the assumption.
One number moves in the direction you may not expect: summary.uiTestIds falls. An ID written as a prop and the host element it reaches become one ID once forwarding is assumed; before, they were counted separately. In one internal-repository measurement, the count moved from 1,571 to 1,287 while matched moved from 627 to 1,430. Nothing was lost: two partial facts merged into one. coverage moved little because the assumption grew the numerator and denominator together. Your counts will depend on your component patterns.
When the assumption would help, the server says so without being asked. forwarding-unproven-widespread fires when a large share of your test-ID selectors match only component props, which is the signature of a codebase that forwards as a matter of course.
Related guides
Section titled “Related guides”- Use the tool reference for per-call parameters and response fields.
- Follow Troubleshooting for ambiguous configs, attribute mismatches, empty scans, and file-limit errors.