Skip to content

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.

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
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.

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-root is not a directory,
  • --tsconfig is not a file,
  • --playwright-config is not a file,
  • a non-exclusion --src-dir resolves 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.
Terminal window
$ npx playwright-page-object mcp --project-root example --src-dir nope
--src-dir does not exist: nope

Run the same command in a terminal when an MCP client reports a startup failure. The stderr message usually identifies the invalid path directly.

In order:

  1. --tsconfig, if given. A path that is not a file stops the server at startup.
  2. <project-root>/tsconfig.json.
  3. The nearest tsconfig.json walking up from the Playwright testDir towards the project root. A monorepo often keeps the e2e tsconfig next to the specs.
  4. None. The server falls back to synthesized compiler options and a **/*.{ts,tsx,mts,cts,jsx} scan, and reports a no-tsconfig diagnostic.

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:

  1. playwright.config.<ext> exactly, ahead of any other matching basename.
  2. Fewer path segments, so a root-level config outranks a nested one.
  3. Extension order ts, mts, cts, js, mjs, cjs.
  4. 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.

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.

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.

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.

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.

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.

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: true on every map_coverage response.
  • A forwarding-assumed warning counting the promoted IDs.
  • forwarding: "assumed" on every affected entry in matched, and assumed: true on 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.

  • Use the tool reference for per-call parameters and response fields.
  • Follow Troubleshooting for ambiguous configs, attribute mismatches, empty scans, and file-limit errors.