Skip to content

feat(drivers): the browser computer serves Playwright MCP's browser_* tools, attached over CDP (FD-4955) - #615

Open
earakely-scale wants to merge 1 commit into
mainfrom
edgararakelyan/fd-4955-playwright-mcp-on-chromium
Open

earakely-scale wants to merge 1 commit into
mainfrom
edgararakelyan/fd-4955-playwright-mcp-on-chromium

Conversation

@earakely-scale

@earakely-scale earakely-scale commented Nov 11, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

M8.8 (FD-4955), migration step 1. The browser computer, chromium alone, now serves Playwright MCP's browser_* tools at its /mcp, beside computer_* and the page tools. Playwright MCP is 0.0.84, with --caps vision as today's playwright-mcp runs, attached to chromium over CDP.

  • A tool-based agent such as Claude Code drives the env's own browser, the one CDP agents like Browser Use drive. That gives the benchmarks their third arm on the same browser (Both-2, Both-3).
  • Tasks keep Playwright MCP's tool names.
  • Hidden by default, until a task's tool access names them: browser_evaluate (JavaScript in the page) and browser_run_code_unsafe (code in Playwright MCP's own process).

What changes

The page driver (drivers/envs/page/)

  • Install: it installs Node 24.22.2 and Playwright MCP 0.0.84 from pinned downloads.
    • Node's Linux tarball for each architecture, pinned to nodejs.org's SHASUMS256.
    • The three npm packages, checked against npm's integrity: @playwright/mcp, playwright and playwright-core, which have no other dependencies.
    • install-playwright-mcp.sh lays them out as npm would, since an install has no network and the composer locks no npm packages.
  • Serving: it serves Playwright MCP's tools through forwarded_tools, from playwright-mcp-tools.json, 0.0.84's own tools/list with vision. An integration test checks the manifest against the installed version.
  • Running Playwright MCP:
    • It attaches to the browser over a CDP address the browser issues to the user playwright-mcp. The address is under the driver's loopback origin (fix(browser): CDP addresses through a TLS-ending tunnel are https and wss (FD-5470) #608's origin), through the driver's /svc/chromium/ route, so the relay's guard applies to it as to any CDP client, and its connections show in the record.
    • It never starts a browser of its own: no --isolated, no --headless, no idle timeout.
    • It starts on the first call. It starts again once it has exited, and again on a fresh address when the browser's data reset ends the last one.
    • setpriv --pdeathsig ends it with the driver, so a restarted driver finds none of its predecessor's.
  • Sessions: each caller of the driver's tools gets a session of its own on Playwright MCP, so each agent keeps its own snapshots and tab.
  • Confinement:
    • It runs with none of the driver's environment, so the control token and the deploy's settings stay out of its process.
    • Its file access is the session user's ~/Uploads, its working folder with --ignore-roots, matching computer_upload.

The SDK (agentenv_protocol)

  • ForwardedTool.hidden_by_default hides a forwarded tool as @tool(hidden_by_default=True) hides one of the env's own:
    • listed with the _meta marker
    • hidden from every role until a tool-access step names it
    • never shown by tools="*"
    • fixed, so it survives a server restart
  • The field has a default, so existing callers are unaffected.

Not in this PR:

  • the action log (M8.19)
  • Playwright MCP in a desktop computer's chromium
  • making chromium the default website browser (M8.27). No regression for playwright-mcp's users is M8.27's bar.

The 0.0.41 → 0.0.84 contract change is on FD-4975 for that measurement:

  • browser_install is gone, and eight tools are new.
  • ref is renamed target in seven tools.
  • The context is shared, not isolated per session.

Decisions (FD-4955, in chat with Edgar)

  1. Playwright MCP runs in the page driver, whose tools it serves, rather than in the chromium app or an app of its own. Apps can't serve MCP tools to agents today.
  2. It reaches the browser through a relay-issued CDP address, rather than a debugging port that would skip the guard.
  3. The offline install is pinned downloads unpacked by a script, rather than a composer npm lock or NodeSource's apt repository.
  4. Versions: Playwright MCP 0.0.84 and Node 24 LTS. 0.0.85 has the same tools and arguments, and its changes are for extension mode only.
  5. --caps vision is on, as today.
  6. Hidden by default: the two code tools. browser_file_upload is confined to ~/Uploads. The rest are visible.
  7. ForwardedTool.hidden_by_default in the SDK, rather than a startup tool-access rule, which "every tool" steps and restarts would undo.

Test plan

  • Unit:

    • The SDK: a forwarded tool hidden by default stays hidden through an "every tool" rule until a rule names it, and is then listed with the _meta marker.
    • The page driver:
      • it serves the manifest's 31 tools, hiding exactly the two
      • it asks for its address under its loopback origin
      • Playwright MCP's command attaches and never launches a browser
    • Against a stand-in Playwright MCP:
      • it starts on the first call and keeps a session per caller
      • it starts again on a new address, or once killed
      • it doesn't get the driver's control token
      • an exit at start is refused, saying why
    • Results: the protocol package 1,417 passed. tst/unit/drivers, env, bundle and task_step 6,022 passed, apart from the known Mac-only Windows disk-recipe case.
  • Integration, local Docker (Mac, arm64): the new browser computer test, beside gate 3, the computer tools, the TLS front and browser_connect_cdp: 5 of 5. On the real composed computer:

    • the tools are listed, the two code tools hidden
    • browser_snapshot and browser_type type into the page, and its viewport is unchanged
    • browser_click opens a confirm that browser_handle_dialog answers
    • tool access shows browser_evaluate once it's named
    • the installed Playwright MCP's own tools/list equals the manifest
    • after Playwright MCP is killed, and after the driver is killed, its tools answer again, with one Playwright MCP left
    • the record shows the playwright-mcp connection
  • A real agent, local Docker: Claude Code (Sonnet 5.5) completed a synthetic-page task through browser_* alone. It snapshotted, typed lantern, clicked Reveal and answered LANTERN-7777. Its trajectory has only browser_* calls.

  • A real agent, modal_vm:

    • The browser computer was composed on a Modal builder VM from this branch, and gate 3 made it ready there (amd64).
    • Claude Code ran on this Mac and drove it through Modal's tunnel. It answered LANTERN-7777, and the check scored 1.0.
    • Its trajectory has only browser_snapshot, browser_type, browser_click and browser_find.
  • amd64 on local Docker is covered by CI's slow tier, which runs browser_computer_test.py on Linux amd64.

  • Chaos and e2e, local Docker (arm64), timed. Playwright MCP's tools (navigate, type, click, read) still work:

    • beside a CDP agent connected at the same time
    • after browser_close
    • after the browser's data reset, on the fresh address the driver gets
    • after the app server is killed and Chrome relaunched
    • after Playwright MCP is killed
    • after reset_env's fresh container
    • one Playwright MCP is running at the end

    Each answered within about a second.

Known limit: two browser_* agents on one browser at once interfere. Today's playwright-mcp gives each MCP session an isolated, headless context of its own. Here every session shares the one headful browser and its default context:

  • On the same tab, their navigations interrupt each other.
  • On a tab each, only the newest is in front. A click on the other waits for the page to render, which a background tab never does, and times out.

One agent works, and so does a CDP agent beside it. Keeping agents apart is contexts' work (M8.15, M8.16). It's recorded on M8.27 (FD-4975) for its no-regression bar.

Docs

The user guide's browser page should cover:

  • the browser_* tools the browser computer serves (Playwright MCP 0.0.84, vision)
  • the two code tools hidden by default and how a task shows them
  • ~/Uploads as their file root

🤖 Generated with Claude Code

RetriggerView in GreptileConfidence Score: 4/5

Fix pending browser calls after child failure before merging.

Fix All in CursorFindings

  1. P1 Browser calls can stay stuck ▶
  2. P2 Disconnected sessions keep running ▶
  3. P2 Failed startup cannot recover ▶
Fix with agent prompt
### Issue 1
src/agent_env/drivers/envs/page/driver.py:454-455
If Playwright MCP exits during a tool call, `call()` can remain stuck on `session.call_tool()`. The repository documents that this MCP client can leave requests pending after a response stream breaks. This client has neither the gateway's transport-failure reporting nor a wait for child exit. The HTTP timeout alone does not bound the pending MCP request.

Watch for child exit or transport failure, fail the pending call, and close its session without retrying the action.

### Issue 2
src/agent_env/drivers/envs/page/driver.py:464-469
Every new caller adds a live `_Session` to `_sessions`, but ending the caller's MCP session never removes or closes it. Its owner task keeps waiting on `close`, holding an HTTP client and a downstream MCP session. Reconnects keep adding resources until the child restarts or the browser resets.

Add cleanup when the caller disconnects, or close idle sessions after a bounded time.

### Issue 3
src/agent_env/drivers/envs/page/driver.py:485-486
`_running()` records the child as running before `_listening()` succeeds. If startup times out while the child stays alive, later calls reuse the same unready URL because `returncode` is still `None`. They cannot recover by starting a fresh child.

Stop and clear the child when readiness fails or startup is cancelled. Save the successful startup state only after the readiness check passes.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Adds Playwright MCP's browser_* tools to the page driver, attached to the existing browser through its guarded CDP relay.

  • Browser computers now serve Playwright tools for their own Chromium page.
  • Forwarded tools can stay hidden until a task names them.

earakely-scale explicitly identifies the changed tool names and shared browser context as intentional, with compatibility measurement deferred to FD-4975.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Agent[Agent MCP session] --> Driver[Page driver]
  Driver --> Access[Tool access checks]
  Access --> Session[Caller-specific MCP session]
  Session --> Child[Playwright MCP]
  Child --> Relay[Guarded CDP relay]
  Relay --> Browser[Existing Chromium browser]
Loading

Reviews (1) · Last reviewed commit: "feat(drivers): the browser computer serv..." · Reviewed by Greptile

… tools, attached over CDP (FD-4955)

The page driver installs Node 24.22.2 and Playwright MCP 0.0.84 from pinned downloads and serves its tools, with
--caps vision, beside computer_*: a Playwright MCP it runs attached to the browser over a CDP address the browser's
relay issues it, a session per caller, started again when it exits or the browser's data reset ends its address, ended
with the driver, with none of the driver's environment and its file access confined to ~/Uploads. browser_evaluate and
browser_run_code_unsafe are hidden by default, through ForwardedTool.hidden_by_default, which hides a forwarded tool as
@tool(hidden_by_default=True) hides one of an env's own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@earakely-scale
earakely-scale requested a review from a team as a code owner November 11, 2026 05:42
Comment on lines +454 to +455
session = await self._session(ToolContext.current().caller.session)
return await session.call_tool(tool, arguments)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Browser calls can stay stuck

If Playwright MCP exits during a tool call, call() can remain stuck on session.call_tool(). The repository documents that this MCP client can leave requests pending after a response stream breaks. This client has neither the gateway's transport-failure reporting nor a wait for child exit. The HTTP timeout alone does not bound the pending MCP request.

Watch for child exit or transport failure, fail the pending call, and close its session without retrying the action.

Knowledge Base Used: Fail Child Calls Promptly After a Gateway Transport Loss

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agent_env/drivers/envs/page/driver.py
Line: 454-455

Comment:
**Browser calls can stay stuck**

If Playwright MCP exits during a tool call, `call()` can remain stuck on `session.call_tool()`. The repository documents that this MCP client can leave requests pending after a response stream breaks. This client has neither the gateway's transport-failure reporting nor a wait for child exit. The HTTP timeout alone does not bound the pending MCP request.

Watch for child exit or transport failure, fail the pending call, and close its session without retrying the action.

**Knowledge Base Used:** [Fail Child Calls Promptly After a Gateway Transport Loss](https://app.greptile.com/-/switch-tenant?tenant=scale-ai&next=%2Fcustom-context%2Fknowledge-base%2Fscaleapi%2Fagentenv-framework%2F-%2Freverts%2Fincident-mitigation_165-20261013-child-call-hangs-c3b98d7.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Cursor Fix in Claude Code Fix in Codex

Comment on lines +464 to +469
held = self._sessions.get(caller)
if held is None or held.url != url or held.owner.done():
if held is not None:
held.close.set()
held = self._sessions[caller] = await _Session.open(url)
return held.session

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Disconnected sessions keep running

Every new caller adds a live _Session to _sessions, but ending the caller's MCP session never removes or closes it. Its owner task keeps waiting on close, holding an HTTP client and a downstream MCP session. Reconnects keep adding resources until the child restarts or the browser resets.

Add cleanup when the caller disconnects, or close idle sessions after a bounded time.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agent_env/drivers/envs/page/driver.py
Line: 464-469

Comment:
**Disconnected sessions keep running**

Every new caller adds a live `_Session` to `_sessions`, but ending the caller's MCP session never removes or closes it. Its owner task keeps waiting on `close`, holding an HTTP client and a downstream MCP session. Reconnects keep adding resources until the child restarts or the browser resets.

Add cleanup when the caller disconnects, or close idle sessions after a bounded time.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Cursor Fix in Claude Code Fix in Codex

Comment on lines +485 to +486
self._running_on, self._url = endpoint, f"http://127.0.0.1:{port}/mcp"
await _listening(port, self._process)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Failed startup cannot recover

_running() records the child as running before _listening() succeeds. If startup times out while the child stays alive, later calls reuse the same unready URL because returncode is still None. They cannot recover by starting a fresh child.

Stop and clear the child when readiness fails or startup is cancelled. Save the successful startup state only after the readiness check passes.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agent_env/drivers/envs/page/driver.py
Line: 485-486

Comment:
**Failed startup cannot recover**

`_running()` records the child as running before `_listening()` succeeds. If startup times out while the child stays alive, later calls reuse the same unready URL because `returncode` is still `None`. They cannot recover by starting a fresh child.

Stop and clear the child when readiness fails or startup is cancelled. Save the successful startup state only after the readiness check passes.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Cursor Fix in Claude Code Fix in Codex

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant