Skip to content

Use the Playwright Page Object MCP Server in a Monorepo

A single-package repository needs no configuration: the server takes the working directory as its root and discovers everything else. A monorepo has to answer one question first - which sources belong to this server - because page objects and the components they select often live in different packages.

Get that wrong and the symptom is misleading rather than loud: selectors read as dead because the components that render their test IDs were never scanned.

Layout Approach
One app, its own e2e specs --project-root <app> - one server
App plus a shared UI package Root the server at the common ancestor, then narrow with --src-dir
Several independent apps One server per app, each with its own --project-root
Apps with different test-ID attributes One server per app with its own --attribute - or one server, overriding attribute per call

Every flag below defines the server’s workspace, so changing any of them needs a restart. Editing page objects, components, or the Playwright config does not.

Point --project-root at the package. Discovery then runs inside it: the Playwright config, the tsconfig, and the test-ID attribute all resolve as they would in a standalone repository.

{
"mcpServers": {
"web-page-objects": {
"command": "npx",
"args": ["playwright-page-object", "mcp", "--project-root", "apps/web"]
}
}
}

Name the server after the app rather than leaving it as playwright-page-object. In a multi-app setup the name is the only thing that tells an agent which one to call, and every tool description is otherwise identical.

What if my components live in a shared package?

Section titled “What if my components live in a shared package?”

This is the case that needs care. Suppose page objects sit in apps/web/e2e and the components they select come from packages/ui:

repo/
apps/web/e2e/page-objects/ <- @Selector("SubmitButton")
packages/ui/src/ <- renders data-testid="SubmitButton"

Rooting at apps/web scans the page objects but not the components. map_coverage then reports the selector under deadSelectors, because nothing it can see renders that ID.

--src-dir cannot fix this. It only ever narrows an existing root - a path resolving outside --project-root is refused at startup:

--src-dir is outside --project-root: ../../packages/ui

The fix is to re-root at the common ancestor and narrow from there:

{
"mcpServers": {
"web-page-objects": {
"command": "npx",
"args": [
"playwright-page-object", "mcp",
"--project-root", ".",
"--src-dir", "apps/web/e2e",
"--src-dir", "packages/ui/src",
"--playwright-config", "apps/web/playwright.config.ts"
]
}
}
}

--src-dir is repeatable and accepts globs, so --src-dir "packages/*/src" covers several packages at once. For a positive pattern a .. segment is refused wherever it appears, including after a glob (the check runs against the pattern’s static base), since its only effect there is to escape the root.

An exclusion is exempt. A value starting with ! is skipped before either check runs, on the grounds that excluding something outside the root is a harmless no-op. That also means a misspelled exclusion is accepted in silence - --src-dir '!../other/**' neither errors nor excludes anything - so do not read a clean startup as proof that a negated pattern matched what you meant.

Pin --playwright-config when you re-root like this. At the monorepo root the server may find several configs and rank one it did not intend; naming the app’s config removes the ambiguity, and the test-ID attribute follows from it.

The server tells you, but the two tools say it differently.

map_coverage raises ui-scope-incomplete, naming the modules:

N component tag(s) come from M module(s) outside the scanned sources (@acme/ui). Test ids rendered inside them are invisible here, so an id may exist without appearing in this report and a selector for one reads as dead.

When those sources are resolvable inside the repository, the hint names the directory to pass to --project-root. A module that resolves into node_modules has no usable re-root target - its IDs can only be confirmed from the package itself.

get_testid_tree does not emit that diagnostic. It reports the same situation structurally: the boundary node is marked external-module, fidelity drops to "partial", and fidelityReason counts the holes. So do not wait for ui-scope-incomplete on a tree response - read fidelity instead, and see Limitations for every boundary reason.

Read scope on any map_coverage response to confirm what was actually scanned:

"scope": {
"uiFilesScanned": 5,
"pageObjectFilesScanned": 5,
"externalComponentModules": ["react"],
"externalComponentTags": 1
}

externalComponentModules naming one of your own packages means the scope is still too narrow. react appearing there is expected and harmless.

Register one server per app. Each gets its own root, and anything that differs per app - the attribute, the config - goes on that server:

{
"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",
"--attribute", "data-tid"
]
}
}
}

Servers are fully independent: separate workspaces, separate caches, and separate coverage handles. A coverageId from one is not valid on another.

Can I just point one server at the monorepo root?

Section titled “Can I just point one server at the monorepo root?”

Often, yes - it is the simplest setup and it works up to the file limit. The trade-offs:

  • --max-files defaults to 8000. Crossing it does not degrade quietly - the call is refused with max_files_exceeded, and every later call raises again until you raise --max-files or narrow the scan with --src-dir. There is no partial-but-usable scan to fall back on. The cap counts files parsed, which includes anything the resolver pulls in, not just the files you analyse.
  • One default attribute, not one attribute. The server resolves a single default, but get_testid_tree and map_coverage each accept a per-call attribute that overrides it, and query_coverage inherits whatever map_coverage used through the handle. A root-scoped server can therefore serve apps that disagree on data-testid versus data-tid - the caller just has to pass the right one on every call, and a forgotten override silently reads the wrong attribute rather than failing. Per-app servers make the correct attribute the default, which is a reliability argument rather than a capability one.
  • Config discovery has more candidates. Pin --playwright-config if the ranked choice is not the one you want.
  • list_page_objects returns every app’s classes. That is useful for cross-app work and noisy for single-app work; the filter argument is a plain substring of class name and file path.

A root-scoped server is a good default when packages share one attribute and one Playwright config. Split per app as soon as either diverges.

Explicit --tsconfig wins. Failing that, <project-root>/tsconfig.json is checked before anything near the specs - only when the root has none does the server walk up from the Playwright testDir to find the nearest one.

That order matters most in exactly the setup this page recommends. Re-root at the repository root to reach a shared UI package and, if a tsconfig.json sits there, the e2e config beside your specs is not used - its paths, include, and compiler options are not in effect. Pass --tsconfig explicitly when you want the e2e one. See Configuration for the full order.

After a config change, one call answers it:

  1. list_page_objects - the classes you expect appear, and their paths are the ones you intended.
  2. map_coverage with buckets: [] - read scope and meta.warnings. No ui-scope-incomplete naming your own packages means both sides are in scope.

If list_page_objects is empty, the page objects are outside the scan or the files do not import from playwright-page-object. See Troubleshooting and Limitations for what the tools can see at all.

  • Configuration - every CLI option, discovery order, and refresh behaviour
  • Limitations - what static analysis cannot prove, including cross-package boundaries
  • Troubleshooting - empty scans, wrong attributes, and partial trees
  • Quick Start - registering the server with your MCP client